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 { 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<GoodsReceipt>;
|
||||
reservations: IRepository<StockReservation>;
|
||||
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>;
|
||||
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>;
|
||||
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 { getServices } from './index';
|
||||
|
||||
import { InsufficientStockError } from '../contracts/inventory';
|
||||
|
||||
export class MockCommerceService implements ICommerceService {
|
||||
products: IRepository<Product>;
|
||||
categories: IRepository<ProductCategory>;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<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({
|
||||
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<void> {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue