From 8910d5dd6a72438690d1bb4d5e836cf75a01a564 Mon Sep 17 00:00:00 2001 From: ochenstarik Date: Fri, 11 Sep 2026 15:02:29 +0700 Subject: [PATCH] feat(inventory): strict stock invariants, ledger separation, and atomic order reservation --- .../orderReservationIntegrity.test.ts | 328 ++++++++++++++++++ src/services/contracts/inventory.ts | 47 ++- src/services/mock/commerceService.ts | 56 ++- src/services/mock/inventoryService.ts | 147 +++++--- 4 files changed, 518 insertions(+), 60 deletions(-) create mode 100644 src/services/__tests__/orderReservationIntegrity.test.ts diff --git a/src/services/__tests__/orderReservationIntegrity.test.ts b/src/services/__tests__/orderReservationIntegrity.test.ts new file mode 100644 index 0000000..c343edf --- /dev/null +++ b/src/services/__tests__/orderReservationIntegrity.test.ts @@ -0,0 +1,328 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetDatabase } from '../storage/indexedDb'; +import { getServices } from '../mock'; +import { InsufficientStockError } from '../contracts/inventory'; +import { Order } from '../../entities/commerce/order'; + +describe('Order & Inventory Reservation Integrity Suite (F02, F06)', () => { + const { commerce, inventory } = getServices(); + const WAREHOUSE_ID = 'wh_test_main'; + + beforeEach(async () => { + await resetDatabase(); + }); + + it('1. Available 2, requested 3 -> rejects with InsufficientStockError and creates NO reservations', async () => { + const productId = 'prod_espresso_beans'; + + // Seed stock: onHand = 2, reserved = 0, available = 2 + await inventory.adjustStock(WAREHOUSE_ID, productId, 2, 'Начальный остаток'); + + const draftOrder = await commerce.orders.create({ + orderNumber: 'ORD-TEST-001', + customerType: 'contact', + customerId: 'cust_1', + customerName: 'Тестовый клиент', + status: 'draft', + paymentStatus: 'pending', + fulfillmentStatus: 'unfulfilled', + items: [ + { + id: 'item_1', + productId, + sku: 'SKU-BEANS', + name: 'Кофе в зёрнах', + quantity: 3, + unitPrice: { minor: 100000, currency: 'RUB' }, + discountMinor: 0, + totalPrice: { minor: 300000, currency: 'RUB' }, + }, + ], + subtotal: { minor: 300000, currency: 'RUB' }, + discountMinor: 0, + taxMinor: 0, + shippingMinor: 0, + total: { minor: 300000, currency: 'RUB' }, + channel: 'manual', + } as Omit); + + // Attempting to confirm order with quantity 3 when available is 2 must throw InsufficientStockError + await expect(commerce.confirmOrder(draftOrder.id, WAREHOUSE_ID)).rejects.toThrow( + InsufficientStockError + ); + + // Verify invariant: zero reservations created + const reservations = await inventory.reservations.list({ filter: { orderId: draftOrder.id } }); + expect(reservations.items).toHaveLength(0); + + // Verify stock remains untouched: available = 2, reserved = 0 + const stockList = await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } }); + expect(stockList.items[0].onHand).toBe(2); + expect(stockList.items[0].reserved).toBe(0); + expect(stockList.items[0].available).toBe(2); + }); + + it('2. Idempotent confirmation: repeating confirmOrder does not duplicate reservations or decrease available stock', async () => { + const productId = 'prod_arabica_special'; + + // Seed stock: onHand = 10, available = 10 + await inventory.adjustStock(WAREHOUSE_ID, productId, 10, 'Поступление зерна'); + + const draftOrder = await commerce.orders.create({ + orderNumber: 'ORD-TEST-002', + customerType: 'contact', + customerId: 'cust_2', + customerName: 'Иван Иванов', + status: 'draft', + paymentStatus: 'pending', + fulfillmentStatus: 'unfulfilled', + items: [ + { + id: 'item_2', + productId, + sku: 'SKU-ARABICA', + name: 'Арабика Премиум', + quantity: 4, + unitPrice: { minor: 50000, currency: 'RUB' }, + discountMinor: 0, + totalPrice: { minor: 200000, currency: 'RUB' }, + }, + ], + subtotal: { minor: 200000, currency: 'RUB' }, + discountMinor: 0, + taxMinor: 0, + shippingMinor: 0, + total: { minor: 200000, currency: 'RUB' }, + channel: 'manual', + } as Omit); + + // First confirmation + const confirmed = await commerce.confirmOrder(draftOrder.id, WAREHOUSE_ID); + expect(confirmed.status).toBe('confirmed'); + + const stockListAfter1 = await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } }); + expect(stockListAfter1.items[0].onHand).toBe(10); + expect(stockListAfter1.items[0].reserved).toBe(4); + expect(stockListAfter1.items[0].available).toBe(6); + + // Second confirmation (idempotent call) + const confirmedAgain = await commerce.confirmOrder(draftOrder.id, WAREHOUSE_ID); + expect(confirmedAgain.status).toBe('confirmed'); + + // Stock must be exactly the same, NOT double-reserved (must remain reserved: 4, available: 6) + const stockListAfter2 = await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } }); + expect(stockListAfter2.items[0].onHand).toBe(10); + expect(stockListAfter2.items[0].reserved).toBe(4); + expect(stockListAfter2.items[0].available).toBe(6); + + // Exact count of active reservations for order must be 1 + const reservations = await inventory.reservations.list({ filter: { orderId: draftOrder.id, status: 'active' } }); + expect(reservations.items).toHaveLength(1); + expect(reservations.items[0].quantity).toBe(4); + }); + + it('3. All-or-Nothing: deficit on second line prevents reservation of first line', async () => { + const productA = 'prod_tea_earl_grey'; + const productB = 'prod_tea_jasmine'; + + // Seed: Product A has 10 available, Product B has only 1 available + await inventory.adjustStock(WAREHOUSE_ID, productA, 10, 'Приход Эрл Грей'); + await inventory.adjustStock(WAREHOUSE_ID, productB, 1, 'Приход Жасмин'); + + const draftOrder = await commerce.orders.create({ + orderNumber: 'ORD-TEST-003', + customerType: 'company', + customerId: 'comp_1', + customerName: 'ООО "Чайный Дом"', + status: 'draft', + paymentStatus: 'pending', + fulfillmentStatus: 'unfulfilled', + items: [ + { + id: 'line_1', + productId: productA, + sku: 'TEA-EG', + name: 'Чай Эрл Грей', + quantity: 5, // Available is 10 (sufficient) + unitPrice: { minor: 40000, currency: 'RUB' }, + discountMinor: 0, + totalPrice: { minor: 200000, currency: 'RUB' }, + }, + { + id: 'line_2', + productId: productB, + sku: 'TEA-JASMINE', + name: 'Чай Жасмин', + quantity: 3, // Available is 1 (DEFICIT!) + unitPrice: { minor: 60000, currency: 'RUB' }, + discountMinor: 0, + totalPrice: { minor: 180000, currency: 'RUB' }, + }, + ], + subtotal: { minor: 380000, currency: 'RUB' }, + discountMinor: 0, + taxMinor: 0, + shippingMinor: 0, + total: { minor: 380000, currency: 'RUB' }, + channel: 'b2b', + } as Omit); + + // Two-phase execution must reject because line 2 has deficit + await expect(commerce.confirmOrder(draftOrder.id, WAREHOUSE_ID)).rejects.toThrow( + InsufficientStockError + ); + + // Crucial check: Product A must NOT have been reserved! All-or-nothing atomicity! + const stockA = await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId: productA } }); + expect(stockA.items[0].reserved).toBe(0); + expect(stockA.items[0].available).toBe(10); + + const stockB = await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId: productB } }); + expect(stockB.items[0].reserved).toBe(0); + expect(stockB.items[0].available).toBe(1); + + // Zero reservations registered + const reservations = await inventory.reservations.list({ filter: { orderId: draftOrder.id } }); + expect(reservations.items).toHaveLength(0); + }); + + it('4. Cancel order releases active reservations and restores available stock', async () => { + const productId = 'prod_matcha_latte'; + await inventory.adjustStock(WAREHOUSE_ID, productId, 8, 'Приход матча'); + + const draftOrder = await commerce.orders.create({ + orderNumber: 'ORD-TEST-004', + customerType: 'contact', + customerId: 'cust_4', + customerName: 'Анна Соколова', + status: 'draft', + paymentStatus: 'pending', + fulfillmentStatus: 'unfulfilled', + items: [ + { + id: 'line_matcha', + productId, + sku: 'MATCHA', + name: 'Матча Латте', + quantity: 3, + unitPrice: { minor: 35000, currency: 'RUB' }, + discountMinor: 0, + totalPrice: { minor: 105000, currency: 'RUB' }, + }, + ], + subtotal: { minor: 105000, currency: 'RUB' }, + discountMinor: 0, + taxMinor: 0, + shippingMinor: 0, + total: { minor: 105000, currency: 'RUB' }, + channel: 'web', + } as Omit); + + // 1. Confirm order + await commerce.confirmOrder(draftOrder.id, WAREHOUSE_ID); + let stock = (await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } })).items[0]; + expect(stock.reserved).toBe(3); + expect(stock.available).toBe(5); + + // 2. Cancel order + const cancelled = await commerce.cancelOrder(draftOrder.id); + expect(cancelled.status).toBe('cancelled'); + + // 3. Verify stock restored + stock = (await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } })).items[0]; + expect(stock.onHand).toBe(8); + expect(stock.reserved).toBe(0); + expect(stock.available).toBe(8); + + // 4. Verify reservation marked as cancelled + const resList = await inventory.reservations.list({ filter: { orderId: draftOrder.id } }); + expect(resList.items[0].status).toBe('cancelled'); + }); + + it('5. Stock reconciliation matches movements: initial + receipts - shipments + adjustments = onHand', async () => { + const productId = 'prod_syrup_vanilla'; + + // 1. Receipt: +20 + await inventory.createAndReceiveGoods(WAREHOUSE_ID, 'REC-001', 'ООО "Сиропы"', [ + { + productId, + sku: 'SYRUP-VANILLA', + name: 'Сироп Ваниль 1л', + quantity: 20, + costPrice: { minor: 45000, currency: 'RUB' }, + }, + ]); + + // 2. Additional Receipt: +10 + await inventory.createAndReceiveGoods(WAREHOUSE_ID, 'REC-002', 'ООО "Сиропы"', [ + { + productId, + sku: 'SYRUP-VANILLA', + name: 'Сироп Ваниль 1л', + quantity: 10, + costPrice: { minor: 45000, currency: 'RUB' }, + }, + ]); + + // 3. Create order, confirm and ship: -7 + const order = await commerce.orders.create({ + orderNumber: 'ORD-TEST-005', + customerType: 'contact', + customerId: 'cust_5', + customerName: 'Бариста Тест', + status: 'draft', + paymentStatus: 'paid', + fulfillmentStatus: 'unfulfilled', + items: [ + { + id: 'item_syrup', + productId, + sku: 'SYRUP-VANILLA', + name: 'Сироп Ваниль 1л', + quantity: 7, + unitPrice: { minor: 70000, currency: 'RUB' }, + discountMinor: 0, + totalPrice: { minor: 490000, currency: 'RUB' }, + }, + ], + subtotal: { minor: 490000, currency: 'RUB' }, + discountMinor: 0, + taxMinor: 0, + shippingMinor: 0, + total: { minor: 490000, currency: 'RUB' }, + channel: 'pos', + } as Omit); + + await commerce.confirmOrder(order.id, WAREHOUSE_ID); + await commerce.createShipment(order.id, WAREHOUSE_ID); + + // 4. Inventory adjustment (audit discrepancy correction): +2 + await inventory.adjustStock(WAREHOUSE_ID, productId, 2, 'Инвентаризация: найден излишек'); + + // 5. Query Stock Ledger movements + const movements = await inventory.movements.list({ filter: { productId } }); + + let calculatedOnHand = 0; + for (const m of movements.items) { + if (m.type === 'receipt') { + calculatedOnHand += m.quantity; + } else if (m.type === 'shipment') { + calculatedOnHand -= m.quantity; + } else if (m.type === 'adjustment') { + calculatedOnHand += m.quantity; + } else if (m.type === 'loss') { + calculatedOnHand -= m.quantity; + } + } + + // Expected: 20 + 10 - 7 + 2 = 25 + expect(calculatedOnHand).toBe(25); + + // Stock item must exactly match calculated balance + const stockItem = (await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } })).items[0]; + expect(stockItem.onHand).toBe(25); + expect(stockItem.reserved).toBe(0); + expect(stockItem.available).toBe(25); + expect(stockItem.onHand).toBe(calculatedOnHand); + }); +}); diff --git a/src/services/contracts/inventory.ts b/src/services/contracts/inventory.ts index 1151aef..dca9a0d 100644 --- a/src/services/contracts/inventory.ts +++ b/src/services/contracts/inventory.ts @@ -2,6 +2,21 @@ import { Warehouse } from '../../entities/inventory/warehouse'; import { BaseEntity, MoneyAmount } from '../../entities/types'; import { IRepository } from './repository'; +export class InsufficientStockError extends Error { + constructor( + public readonly productId: string, + public readonly requested: number, + public readonly available: number, + message?: string + ) { + super( + message || + `Недостаточно товара на складе (ID: ${productId}): запрошено ${requested}, доступно ${available}` + ); + this.name = 'InsufficientStockError'; + } +} + export interface StockItem extends BaseEntity { warehouseId: string; warehouseName: string; @@ -15,15 +30,21 @@ export interface StockItem extends BaseEntity { minThreshold?: number; } +export type StockMovementType = 'receipt' | 'shipment' | 'adjustment' | 'return' | 'transfer' | 'loss'; + export interface StockMovement extends BaseEntity { - type: 'receipt' | 'shipment' | 'transfer' | 'adjustment' | 'loss'; + type: StockMovementType; productId: string; productName: string; sku: string; sourceWarehouseId?: string; targetWarehouseId?: string; + warehouseId?: string; quantity: number; referenceNumber?: string; + orderId?: string; + orderLineId?: string; + reason?: string; notes?: string; timestamp: string; } @@ -49,10 +70,12 @@ export interface GoodsReceipt extends BaseEntity { export interface StockReservation extends BaseEntity { orderId: string; + orderLineId?: string; warehouseId: string; productId: string; quantity: number; status: 'active' | 'fulfilled' | 'cancelled'; + reason?: string; expiresAt?: string; } @@ -63,9 +86,25 @@ export interface IInventoryService { receipts: IRepository; reservations: IRepository; adjustStock(warehouseId: string, productId: string, delta: number, reason: string): Promise; - reserveStock(orderId: string, warehouseId: string, productId: string, quantity: number): Promise; + reserveStock( + orderId: string, + warehouseId: string, + productId: string, + quantity: number, + orderLineId?: string, + reason?: string + ): Promise; cancelOrderReservations(orderId: string): Promise; - fulfillOrderReservations(orderId: string, warehouseId: string, items?: { productId: string; name: string; sku: string; quantity: number }[]): Promise; + fulfillOrderReservations( + orderId: string, + warehouseId: string, + items?: { productId: string; name: string; sku: string; quantity: number }[] + ): Promise; receiveGoods(receiptId: string): Promise; - createAndReceiveGoods(warehouseId: string, receiptNumber: string, supplierName: string, items: GoodsReceiptItem[]): Promise; + createAndReceiveGoods( + warehouseId: string, + receiptNumber: string, + supplierName: string, + items: GoodsReceiptItem[] + ): Promise; } diff --git a/src/services/mock/commerceService.ts b/src/services/mock/commerceService.ts index dd1a29c..bff6164 100644 --- a/src/services/mock/commerceService.ts +++ b/src/services/mock/commerceService.ts @@ -15,6 +15,8 @@ import { BaseMockRepository } from './baseRepository'; import { ulid } from '../../shared/lib/ulid'; import { getServices } from './index'; +import { InsufficientStockError } from '../contracts/inventory'; + export class MockCommerceService implements ICommerceService { products: IRepository; categories: IRepository; @@ -44,11 +46,47 @@ export class MockCommerceService implements ICommerceService { throw new Error(`Заказ с ID ${orderId} не найден.`); } - // Reserve stock in inventory + // Idempotency: if already confirmed, do not duplicate reservations + if (order.status === 'confirmed') { + return order; + } + + // Validation: only draft orders can be confirmed + if (order.status !== 'draft') { + throw new Error( + `Подтверждать можно только заказ в статусе черновика (draft). Текущий статус: «${order.status}»` + ); + } + const inventory = getServices().inventory; - if (inventory && order.items) { + if (inventory && order.items && order.items.length > 0) { + // Phase 1: Pre-flight availability check for ALL items (All-or-Nothing) for (const item of order.items) { - await inventory.reserveStock(orderId, warehouseId, item.productId, item.quantity); + const stockList = await inventory.stockItems.list({ + filter: { warehouseId, productId: item.productId }, + }); + const stockItem = stockList.items[0]; + const available = stockItem ? stockItem.available : 0; + if (available < item.quantity) { + throw new InsufficientStockError( + item.productId, + item.quantity, + available, + `Недостаточно товара «${item.name || item.productId}» на складе: требуется ${item.quantity}, доступно ${available}` + ); + } + } + + // Phase 2: All items are available, perform atomic reservation + for (const item of order.items) { + await inventory.reserveStock( + orderId, + warehouseId, + item.productId, + item.quantity, + item.id, + `Резервирование по заказу #${order.orderNumber}` + ); } } @@ -61,7 +99,17 @@ export class MockCommerceService implements ICommerceService { throw new Error(`Заказ с ID ${orderId} не найден.`); } - // Release any active warehouse reservations + if (order.status === 'cancelled') { + return order; + } + + if (order.status !== 'draft' && order.status !== 'confirmed') { + throw new Error( + `Отменить можно только черновик или подтверждённый заказ (draft, confirmed). Текущий статус: «${order.status}»` + ); + } + + // Release only active warehouse reservations specifically for this order const inventory = getServices().inventory; if (inventory) { await inventory.cancelOrderReservations(orderId); diff --git a/src/services/mock/inventoryService.ts b/src/services/mock/inventoryService.ts index 28718c5..8e2396e 100644 --- a/src/services/mock/inventoryService.ts +++ b/src/services/mock/inventoryService.ts @@ -5,6 +5,7 @@ import { GoodsReceipt, GoodsReceiptItem, StockReservation, + InsufficientStockError, } from '../contracts/inventory'; import { IRepository } from '../contracts/repository'; import { Warehouse } from '../../entities/inventory/warehouse'; @@ -39,34 +40,64 @@ export class MockInventoryService implements IInventoryService { const now = new Date().toISOString(); if (!stockItem) { + if (delta < 0) { + throw new InsufficientStockError( + productId, + Math.abs(delta), + 0, + `Невозможно списать товар (ID: ${productId}): товар отсутствует на складе` + ); + } + stockItem = await this.stockItems.create({ warehouseId, warehouseName: 'Склад', productId, sku: 'SKU', productName: 'Товар', - onHand: Math.max(0, delta), + onHand: delta, reserved: 0, - available: Math.max(0, delta), + available: delta, unit: 'шт', }); } else { - const newOnHand = Math.max(0, stockItem.onHand + delta); - const newAvailable = Math.max(0, newOnHand - stockItem.reserved); + if (stockItem.onHand + delta < 0) { + throw new InsufficientStockError( + productId, + Math.abs(delta), + stockItem.onHand, + `Невозможно списать товар (ID: ${productId}): запрошено ${Math.abs(delta)}, фактически в наличии ${stockItem.onHand}` + ); + } + + const newOnHand = stockItem.onHand + delta; + const newAvailable = newOnHand - stockItem.reserved; + + if (newAvailable < 0) { + throw new InsufficientStockError( + productId, + Math.abs(delta), + stockItem.available, + `Списание ${Math.abs(delta)} шт. превышает свободный остаток (${stockItem.available} шт.) с учётом активных резервов (${stockItem.reserved} шт.)` + ); + } + stockItem = await this.stockItems.update(stockItem.id, { onHand: newOnHand, available: newAvailable, }); } - // Record movement + // Record strictly physical movement in Stock Ledger await this.movements.create({ type: delta >= 0 ? 'adjustment' : 'loss', productId, productName: stockItem.productName, sku: stockItem.sku, + warehouseId, targetWarehouseId: warehouseId, quantity: Math.abs(delta), + reason, notes: reason, timestamp: now, }); @@ -78,43 +109,50 @@ export class MockInventoryService implements IInventoryService { orderId: string, warehouseId: string, productId: string, - quantity: number + quantity: number, + orderLineId?: string, + reason?: string ): Promise { + if (quantity <= 0) { + throw new Error(`Количество для резервирования должно быть больше 0 (получено: ${quantity})`); + } + + const list = await this.stockItems.list({ + filter: { warehouseId, productId }, + }); + const item = list.items[0]; + + if (!item || item.available < quantity) { + const currentAvailable = item ? item.available : 0; + throw new InsufficientStockError( + productId, + quantity, + currentAvailable, + `Недостаточно свободного остатка для резервирования товара (ID: ${productId}): запрошено ${quantity}, доступно ${currentAvailable}` + ); + } + + // Invariant update: available = onHand - reserved + const newReserved = item.reserved + quantity; + const newAvailable = item.onHand - newReserved; + + await this.stockItems.update(item.id, { + reserved: newReserved, + available: newAvailable, + }); + + // Record in Reservation Ledger (append-only, isolated from physical stock movements) const reservation = await this.reservations.create({ orderId, + orderLineId, warehouseId, productId, quantity, status: 'active', + reason: reason || `Резервирование под заказ #${orderId.slice(0, 8)}`, expiresAt: new Date(Date.now() + 24 * 3600 * 1000).toISOString(), }); - // Update stock item reserved - const list = await this.stockItems.list({ - filter: { warehouseId, productId }, - }); - if (list.items.length > 0) { - const item = list.items[0]; - const newReserved = item.reserved + quantity; - const newAvailable = Math.max(0, item.onHand - newReserved); - await this.stockItems.update(item.id, { - reserved: newReserved, - available: newAvailable, - }); - } - - // Record movement as reservation note - await this.movements.create({ - type: 'transfer', - productId, - productName: list.items[0]?.productName || 'Товар', - sku: list.items[0]?.sku || 'SKU', - sourceWarehouseId: warehouseId, - quantity, - notes: `Резервирование под заказ #${orderId.slice(0, 8)}`, - timestamp: new Date().toISOString(), - }); - return reservation; } @@ -122,29 +160,19 @@ export class MockInventoryService implements IInventoryService { const list = await this.reservations.list({ filter: { orderId, status: 'active' } }); for (const r of list.items) { await this.reservations.update(r.id, { status: 'cancelled' }); + const stockList = await this.stockItems.list({ filter: { warehouseId: r.warehouseId, productId: r.productId }, }); if (stockList.items.length > 0) { const si = stockList.items[0]; - const newReserved = Math.max(0, si.reserved - r.quantity); - const newAvailable = Math.max(0, si.onHand - newReserved); + const newReserved = si.reserved - r.quantity; + const newAvailable = si.onHand - newReserved; await this.stockItems.update(si.id, { reserved: newReserved, available: newAvailable, }); } - - await this.movements.create({ - type: 'adjustment', - productId: r.productId, - productName: stockList.items[0]?.productName || 'Товар', - sku: stockList.items[0]?.sku || 'SKU', - targetWarehouseId: r.warehouseId, - quantity: r.quantity, - notes: `Снятие резерва (отмена заказа #${orderId.slice(0, 8)})`, - timestamp: new Date().toISOString(), - }); } } @@ -153,7 +181,7 @@ export class MockInventoryService implements IInventoryService { warehouseId: string, items?: { productId: string; name: string; sku: string; quantity: number }[] ): Promise { - // 1. Mark reservations as fulfilled + // 1. Mark active reservations as fulfilled const resList = await this.reservations.list({ filter: { orderId, status: 'active' }, }); @@ -161,7 +189,7 @@ export class MockInventoryService implements IInventoryService { await this.reservations.update(r.id, { status: 'fulfilled' }); } - // 2. Deduct physical stock (onHand) and reserved quantity + // 2. Deduct physical stock (onHand) and reserved quantity in Stock Ledger if (items && items.length > 0) { for (const item of items) { const stockList = await this.stockItems.list({ @@ -169,9 +197,19 @@ export class MockInventoryService implements IInventoryService { }); if (stockList.items.length > 0) { const si = stockList.items[0]; - const newOnHand = Math.max(0, si.onHand - item.quantity); + if (si.onHand < item.quantity) { + throw new InsufficientStockError( + item.productId, + item.quantity, + si.onHand, + `Невозможно отгрузить: физический остаток (${si.onHand}) меньше списываемого (${item.quantity})` + ); + } + + const newOnHand = si.onHand - item.quantity; const newReserved = Math.max(0, si.reserved - item.quantity); - const newAvailable = Math.max(0, newOnHand - newReserved); + const newAvailable = newOnHand - newReserved; + await this.stockItems.update(si.id, { onHand: newOnHand, reserved: newReserved, @@ -179,13 +217,17 @@ export class MockInventoryService implements IInventoryService { }); } + // Record physical shipment in Stock Ledger await this.movements.create({ type: 'shipment', productId: item.productId, productName: item.name, sku: item.sku, + warehouseId, sourceWarehouseId: warehouseId, quantity: item.quantity, + orderId, + reason: `Отгрузка по заказу #${orderId.slice(0, 8)}`, notes: `Отгрузка по заказу #${orderId.slice(0, 8)}`, timestamp: new Date().toISOString(), }); @@ -237,7 +279,6 @@ export class MockInventoryService implements IInventoryService { items, }); - // Find warehouse name const wh = await this.warehouses.getById(warehouseId); const whName = wh?.name || 'Склад'; @@ -248,7 +289,7 @@ export class MockInventoryService implements IInventoryService { if (list.items.length > 0) { const si = list.items[0]; const newOnHand = si.onHand + item.quantity; - const newAvailable = Math.max(0, newOnHand - si.reserved); + const newAvailable = newOnHand - si.reserved; await this.stockItems.update(si.id, { onHand: newOnHand, available: newAvailable, @@ -272,8 +313,11 @@ export class MockInventoryService implements IInventoryService { productId: item.productId, productName: item.name, sku: item.sku, + warehouseId, targetWarehouseId: warehouseId, quantity: item.quantity, + referenceNumber: receiptNumber, + reason: `Приходная накладная №${receiptNumber} (${supplierName})`, notes: `Приходная накладная №${receiptNumber} (${supplierName})`, timestamp: new Date().toISOString(), }); @@ -282,4 +326,3 @@ export class MockInventoryService implements IInventoryService { return receipt; } } -