feat(inventory): strict stock invariants, ledger separation, and atomic order reservation
This commit is contained in:
parent
fcabd60523
commit
8910d5dd6a
4 changed files with 518 additions and 60 deletions
328
src/services/__tests__/orderReservationIntegrity.test.ts
Normal file
328
src/services/__tests__/orderReservationIntegrity.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue