feat(inventory): strict stock invariants, ledger separation, and atomic order reservation

This commit is contained in:
ochenstarik 2026-09-11 15:02:29 +07:00
parent fcabd60523
commit 8910d5dd6a
4 changed files with 518 additions and 60 deletions

View file

@ -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<Order, 'id' | 'createdAt' | 'updatedAt'>);
// 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<Order, 'id' | 'createdAt' | 'updatedAt'>);
// 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<Order, 'id' | 'createdAt' | 'updatedAt'>);
// 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<Order, 'id' | 'createdAt' | 'updatedAt'>);
// 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<Order, 'id' | 'createdAt' | 'updatedAt'>);
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);
});
});

View file

@ -2,6 +2,21 @@ import { Warehouse } from '../../entities/inventory/warehouse';
import { BaseEntity, MoneyAmount } from '../../entities/types'; import { BaseEntity, MoneyAmount } from '../../entities/types';
import { IRepository } from './repository'; 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 { export interface StockItem extends BaseEntity {
warehouseId: string; warehouseId: string;
warehouseName: string; warehouseName: string;
@ -15,15 +30,21 @@ export interface StockItem extends BaseEntity {
minThreshold?: number; minThreshold?: number;
} }
export type StockMovementType = 'receipt' | 'shipment' | 'adjustment' | 'return' | 'transfer' | 'loss';
export interface StockMovement extends BaseEntity { export interface StockMovement extends BaseEntity {
type: 'receipt' | 'shipment' | 'transfer' | 'adjustment' | 'loss'; type: StockMovementType;
productId: string; productId: string;
productName: string; productName: string;
sku: string; sku: string;
sourceWarehouseId?: string; sourceWarehouseId?: string;
targetWarehouseId?: string; targetWarehouseId?: string;
warehouseId?: string;
quantity: number; quantity: number;
referenceNumber?: string; referenceNumber?: string;
orderId?: string;
orderLineId?: string;
reason?: string;
notes?: string; notes?: string;
timestamp: string; timestamp: string;
} }
@ -49,10 +70,12 @@ export interface GoodsReceipt extends BaseEntity {
export interface StockReservation extends BaseEntity { export interface StockReservation extends BaseEntity {
orderId: string; orderId: string;
orderLineId?: string;
warehouseId: string; warehouseId: string;
productId: string; productId: string;
quantity: number; quantity: number;
status: 'active' | 'fulfilled' | 'cancelled'; status: 'active' | 'fulfilled' | 'cancelled';
reason?: string;
expiresAt?: string; expiresAt?: string;
} }
@ -63,9 +86,25 @@ export interface IInventoryService {
receipts: IRepository<GoodsReceipt>; receipts: IRepository<GoodsReceipt>;
reservations: IRepository<StockReservation>; reservations: IRepository<StockReservation>;
adjustStock(warehouseId: string, productId: string, delta: number, reason: string): Promise<StockItem>; adjustStock(warehouseId: string, productId: string, delta: number, reason: string): Promise<StockItem>;
reserveStock(orderId: string, warehouseId: string, productId: string, quantity: number): Promise<StockReservation>; reserveStock(
orderId: string,
warehouseId: string,
productId: string,
quantity: number,
orderLineId?: string,
reason?: string
): Promise<StockReservation>;
cancelOrderReservations(orderId: string): Promise<void>; cancelOrderReservations(orderId: string): Promise<void>;
fulfillOrderReservations(orderId: string, warehouseId: string, items?: { productId: string; name: string; sku: string; quantity: number }[]): Promise<void>; fulfillOrderReservations(
orderId: string,
warehouseId: string,
items?: { productId: string; name: string; sku: string; quantity: number }[]
): Promise<void>;
receiveGoods(receiptId: string): Promise<GoodsReceipt>; receiveGoods(receiptId: string): Promise<GoodsReceipt>;
createAndReceiveGoods(warehouseId: string, receiptNumber: string, supplierName: string, items: GoodsReceiptItem[]): Promise<GoodsReceipt>; createAndReceiveGoods(
warehouseId: string,
receiptNumber: string,
supplierName: string,
items: GoodsReceiptItem[]
): Promise<GoodsReceipt>;
} }

View file

@ -15,6 +15,8 @@ import { BaseMockRepository } from './baseRepository';
import { ulid } from '../../shared/lib/ulid'; import { ulid } from '../../shared/lib/ulid';
import { getServices } from './index'; import { getServices } from './index';
import { InsufficientStockError } from '../contracts/inventory';
export class MockCommerceService implements ICommerceService { export class MockCommerceService implements ICommerceService {
products: IRepository<Product>; products: IRepository<Product>;
categories: IRepository<ProductCategory>; categories: IRepository<ProductCategory>;
@ -44,11 +46,47 @@ export class MockCommerceService implements ICommerceService {
throw new Error(`Заказ с ID ${orderId} не найден.`); 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; 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) { 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} не найден.`); 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; const inventory = getServices().inventory;
if (inventory) { if (inventory) {
await inventory.cancelOrderReservations(orderId); await inventory.cancelOrderReservations(orderId);

View file

@ -5,6 +5,7 @@ import {
GoodsReceipt, GoodsReceipt,
GoodsReceiptItem, GoodsReceiptItem,
StockReservation, StockReservation,
InsufficientStockError,
} from '../contracts/inventory'; } from '../contracts/inventory';
import { IRepository } from '../contracts/repository'; import { IRepository } from '../contracts/repository';
import { Warehouse } from '../../entities/inventory/warehouse'; import { Warehouse } from '../../entities/inventory/warehouse';
@ -39,34 +40,64 @@ export class MockInventoryService implements IInventoryService {
const now = new Date().toISOString(); const now = new Date().toISOString();
if (!stockItem) { if (!stockItem) {
if (delta < 0) {
throw new InsufficientStockError(
productId,
Math.abs(delta),
0,
`Невозможно списать товар (ID: ${productId}): товар отсутствует на складе`
);
}
stockItem = await this.stockItems.create({ stockItem = await this.stockItems.create({
warehouseId, warehouseId,
warehouseName: 'Склад', warehouseName: 'Склад',
productId, productId,
sku: 'SKU', sku: 'SKU',
productName: 'Товар', productName: 'Товар',
onHand: Math.max(0, delta), onHand: delta,
reserved: 0, reserved: 0,
available: Math.max(0, delta), available: delta,
unit: 'шт', unit: 'шт',
}); });
} else { } else {
const newOnHand = Math.max(0, stockItem.onHand + delta); if (stockItem.onHand + delta < 0) {
const newAvailable = Math.max(0, newOnHand - stockItem.reserved); 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, { stockItem = await this.stockItems.update(stockItem.id, {
onHand: newOnHand, onHand: newOnHand,
available: newAvailable, available: newAvailable,
}); });
} }
// Record movement // Record strictly physical movement in Stock Ledger
await this.movements.create({ await this.movements.create({
type: delta >= 0 ? 'adjustment' : 'loss', type: delta >= 0 ? 'adjustment' : 'loss',
productId, productId,
productName: stockItem.productName, productName: stockItem.productName,
sku: stockItem.sku, sku: stockItem.sku,
warehouseId,
targetWarehouseId: warehouseId, targetWarehouseId: warehouseId,
quantity: Math.abs(delta), quantity: Math.abs(delta),
reason,
notes: reason, notes: reason,
timestamp: now, timestamp: now,
}); });
@ -78,43 +109,50 @@ export class MockInventoryService implements IInventoryService {
orderId: string, orderId: string,
warehouseId: string, warehouseId: string,
productId: string, productId: string,
quantity: number quantity: number,
orderLineId?: string,
reason?: string
): Promise<StockReservation> { ): Promise<StockReservation> {
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({ const reservation = await this.reservations.create({
orderId, orderId,
orderLineId,
warehouseId, warehouseId,
productId, productId,
quantity, quantity,
status: 'active', status: 'active',
reason: reason || `Резервирование под заказ #${orderId.slice(0, 8)}`,
expiresAt: new Date(Date.now() + 24 * 3600 * 1000).toISOString(), 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; return reservation;
} }
@ -122,29 +160,19 @@ export class MockInventoryService implements IInventoryService {
const list = await this.reservations.list({ filter: { orderId, status: 'active' } }); const list = await this.reservations.list({ filter: { orderId, status: 'active' } });
for (const r of list.items) { for (const r of list.items) {
await this.reservations.update(r.id, { status: 'cancelled' }); await this.reservations.update(r.id, { status: 'cancelled' });
const stockList = await this.stockItems.list({ const stockList = await this.stockItems.list({
filter: { warehouseId: r.warehouseId, productId: r.productId }, filter: { warehouseId: r.warehouseId, productId: r.productId },
}); });
if (stockList.items.length > 0) { if (stockList.items.length > 0) {
const si = stockList.items[0]; const si = stockList.items[0];
const newReserved = Math.max(0, si.reserved - r.quantity); const newReserved = si.reserved - r.quantity;
const newAvailable = Math.max(0, si.onHand - newReserved); const newAvailable = si.onHand - newReserved;
await this.stockItems.update(si.id, { await this.stockItems.update(si.id, {
reserved: newReserved, reserved: newReserved,
available: newAvailable, 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, warehouseId: string,
items?: { productId: string; name: string; sku: string; quantity: number }[] items?: { productId: string; name: string; sku: string; quantity: number }[]
): Promise<void> { ): Promise<void> {
// 1. Mark reservations as fulfilled // 1. Mark active reservations as fulfilled
const resList = await this.reservations.list({ const resList = await this.reservations.list({
filter: { orderId, status: 'active' }, filter: { orderId, status: 'active' },
}); });
@ -161,7 +189,7 @@ export class MockInventoryService implements IInventoryService {
await this.reservations.update(r.id, { status: 'fulfilled' }); 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) { if (items && items.length > 0) {
for (const item of items) { for (const item of items) {
const stockList = await this.stockItems.list({ const stockList = await this.stockItems.list({
@ -169,9 +197,19 @@ export class MockInventoryService implements IInventoryService {
}); });
if (stockList.items.length > 0) { if (stockList.items.length > 0) {
const si = stockList.items[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 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, { await this.stockItems.update(si.id, {
onHand: newOnHand, onHand: newOnHand,
reserved: newReserved, reserved: newReserved,
@ -179,13 +217,17 @@ export class MockInventoryService implements IInventoryService {
}); });
} }
// Record physical shipment in Stock Ledger
await this.movements.create({ await this.movements.create({
type: 'shipment', type: 'shipment',
productId: item.productId, productId: item.productId,
productName: item.name, productName: item.name,
sku: item.sku, sku: item.sku,
warehouseId,
sourceWarehouseId: warehouseId, sourceWarehouseId: warehouseId,
quantity: item.quantity, quantity: item.quantity,
orderId,
reason: `Отгрузка по заказу #${orderId.slice(0, 8)}`,
notes: `Отгрузка по заказу #${orderId.slice(0, 8)}`, notes: `Отгрузка по заказу #${orderId.slice(0, 8)}`,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
@ -237,7 +279,6 @@ export class MockInventoryService implements IInventoryService {
items, items,
}); });
// Find warehouse name
const wh = await this.warehouses.getById(warehouseId); const wh = await this.warehouses.getById(warehouseId);
const whName = wh?.name || 'Склад'; const whName = wh?.name || 'Склад';
@ -248,7 +289,7 @@ export class MockInventoryService implements IInventoryService {
if (list.items.length > 0) { if (list.items.length > 0) {
const si = list.items[0]; const si = list.items[0];
const newOnHand = si.onHand + item.quantity; 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, { await this.stockItems.update(si.id, {
onHand: newOnHand, onHand: newOnHand,
available: newAvailable, available: newAvailable,
@ -272,8 +313,11 @@ export class MockInventoryService implements IInventoryService {
productId: item.productId, productId: item.productId,
productName: item.name, productName: item.name,
sku: item.sku, sku: item.sku,
warehouseId,
targetWarehouseId: warehouseId, targetWarehouseId: warehouseId,
quantity: item.quantity, quantity: item.quantity,
referenceNumber: receiptNumber,
reason: `Приходная накладная №${receiptNumber} (${supplierName})`,
notes: `Приходная накладная №${receiptNumber} (${supplierName})`, notes: `Приходная накладная №${receiptNumber} (${supplierName})`,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
@ -282,4 +326,3 @@ export class MockInventoryService implements IInventoryService {
return receipt; return receipt;
} }
} }