feat(commerce): formal order, shipment, delivery, and return state machines

This commit is contained in:
ochenstarik 2026-09-11 15:11:26 +07:00
parent ae35865829
commit a29d683edb
6 changed files with 418 additions and 22 deletions

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import React, { useEffect, useMemo, useCallback } from 'react';
import {
DEFAULT_TENANT_ID,
createRequestContext,
@ -22,22 +22,18 @@ export const TenantProvider: React.FC<TenantProviderProps> = ({
initialTenantId,
}) => {
const sessionTenantId = useSessionStore((s) => s.activeTenantId);
const [currentTenantId, setCurrentTenantId] = useState<string>(() => {
return initialTenantId || sessionTenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
});
const currentTenantId = sessionTenantId || initialTenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
const switchTenant = useCallback((tenantId: string) => {
setCurrentTenantId(tenantId);
setActiveGlobalTenantId(tenantId);
useSessionStore.getState().setActiveTenantId(tenantId);
}, []);
useEffect(() => {
if (sessionTenantId && sessionTenantId !== currentTenantId) {
setCurrentTenantId(sessionTenantId);
setActiveGlobalTenantId(sessionTenantId);
if (initialTenantId && initialTenantId !== sessionTenantId) {
useSessionStore.getState().setActiveTenantId(initialTenantId);
}
}, [sessionTenantId, currentTenantId]);
}, [initialTenantId, sessionTenantId]);
useEffect(() => {
setActiveGlobalTenantId(currentTenantId);

View file

@ -1,8 +1,6 @@
import { create } from 'zustand';
import { useDemoStore as useDemoControlsStore } from '../stores/demo';
import { CapabilityId } from '../core/capabilities/types';
import { SystemRoleId } from '../core/permissions/types';
import { BUSINESS_PROFILES } from '../core/profiles/profiles';
import { BusinessProfile } from '../core/profiles/types';
export type ThemeMode = 'soft-light' | 'light' | 'dark';

View file

@ -0,0 +1,280 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { resetDatabase } from '../storage/indexedDb';
import { getServices } from '../mock';
import { Order } from '../../entities/commerce/order';
describe('Order State Machine & Shipment/Return Lifecycle Suite (F04, F05)', () => {
const { commerce, inventory, finance } = getServices();
const WAREHOUSE_ID = 'wh_sm_test';
beforeEach(async () => {
await resetDatabase();
// Ensure default bank account exists in finance
await finance.accounts.create({
name: 'Расчётный счёт компании',
type: 'bank',
balance: { minor: 10000000, currency: 'RUB' },
currency: 'RUB',
isDefault: true,
status: 'active',
});
});
const createDraftOrder = async (productId: string, qty = 5, channel: Order['channel'] = 'web') => {
// Seed initial stock
await inventory.adjustStock(WAREHOUSE_ID, productId, 20, 'Начальный остаток для SM');
return commerce.orders.create({
orderNumber: `ORD-SM-${Date.now().toString().slice(-4)}`,
customerType: 'contact',
customerId: 'cust_sm',
customerName: 'Клиент SM',
status: 'draft',
paymentStatus: 'unpaid',
fulfillmentStatus: 'unfulfilled',
warehouseId: WAREHOUSE_ID,
items: [
{
id: 'item_sm_1',
productId,
sku: 'SKU-SM',
name: 'Товар SM',
quantity: qty,
unitPrice: { minor: 100000, currency: 'RUB' },
discountMinor: 0,
totalPrice: { minor: qty * 100000, currency: 'RUB' },
},
],
subtotal: { minor: qty * 100000, currency: 'RUB' },
discountMinor: 0,
taxMinor: 0,
shippingMinor: 0,
total: { minor: qty * 100000, currency: 'RUB' },
channel,
} as Omit<Order, 'id' | 'createdAt' | 'updatedAt'>);
};
it('1. Executes valid state machine progression: draft -> confirmed -> preparing -> ready_for_shipment -> shipped -> delivered -> completed', async () => {
const order = await createDraftOrder('prod_sm_1', 3, 'web');
expect(order.status).toBe('draft');
// 1. draft -> confirmed (via confirmOrder)
const confirmed = await commerce.confirmOrder(order.id, WAREHOUSE_ID);
expect(confirmed.status).toBe('confirmed');
// 2. confirmed -> preparing
const preparing = await commerce.updateOrderStatus(order.id, 'preparing');
expect(preparing.status).toBe('preparing');
// 3. preparing -> ready_for_shipment
const ready = await commerce.updateOrderStatus(order.id, 'ready_for_shipment');
expect(ready.status).toBe('ready_for_shipment');
// Pay order before shipping (for B2C/web channel)
await commerce.createPayment(order.id, ready.total, 'card', true);
// 4. ready_for_shipment -> shipped
const shipped = await commerce.updateOrderStatus(order.id, 'shipped');
expect(shipped.status).toBe('shipped');
// 5. shipped -> delivered
const delivered = await commerce.updateOrderStatus(order.id, 'delivered');
expect(delivered.status).toBe('delivered');
// 6. delivered -> completed
const completed = await commerce.updateOrderStatus(order.id, 'completed');
expect(completed.status).toBe('completed');
});
it('2. Rejects invalid state transitions (draft -> shipped, completed -> cancelled, shipped -> cancelled)', async () => {
const order = await createDraftOrder('prod_sm_2', 2, 'web');
// Direct illegal transition from draft to shipped
await expect(commerce.updateOrderStatus(order.id, 'shipped')).rejects.toThrow(
/Недопустимый переход статуса/
);
// Direct illegal transition from draft to ready_for_shipment
await expect(commerce.updateOrderStatus(order.id, 'ready_for_shipment')).rejects.toThrow(
/Недопустимый переход статуса/
);
// Confirm order
await commerce.confirmOrder(order.id, WAREHOUSE_ID);
await commerce.createPayment(order.id, order.total, 'card', false);
await commerce.createShipment(order.id, WAREHOUSE_ID);
// After shipping, cannot cancel!
await expect(commerce.updateOrderStatus(order.id, 'cancelled')).rejects.toThrow(
/Недопустимый переход статуса/
);
await expect(commerce.cancelOrder(order.id)).rejects.toThrow(
/Отменить можно только черновик или подтверждённый заказ/
);
});
it('3. Shipment precondition: unpaid retail orders cannot be shipped; B2B orders allow postpaid shipment', async () => {
// A) Retail order unpaid -> createShipment must be blocked
const retailOrder = await createDraftOrder('prod_sm_retail', 2, 'web');
await commerce.confirmOrder(retailOrder.id, WAREHOUSE_ID);
await commerce.updateOrderStatus(retailOrder.id, 'preparing');
await commerce.updateOrderStatus(retailOrder.id, 'ready_for_shipment');
await expect(commerce.createShipment(retailOrder.id, WAREHOUSE_ID)).rejects.toThrow(
/Отгрузка невозможна: заказ не оплачен/
);
// Pay retail order -> now createShipment succeeds!
await commerce.createPayment(retailOrder.id, retailOrder.total, 'card', false);
const retailShipment = await commerce.createShipment(retailOrder.id, WAREHOUSE_ID);
expect(retailShipment.status).toBe('ready');
const updatedRetailOrder = await commerce.orders.getById(retailOrder.id);
expect(updatedRetailOrder?.status).toBe('shipped');
// B) B2B order unpaid -> postpaid shipment allowed
const b2bOrder = await createDraftOrder('prod_sm_b2b', 4, 'b2b');
await commerce.confirmOrder(b2bOrder.id, WAREHOUSE_ID);
await commerce.updateOrderStatus(b2bOrder.id, 'preparing');
await commerce.updateOrderStatus(b2bOrder.id, 'ready_for_shipment');
const b2bShipment = await commerce.createShipment(b2bOrder.id, WAREHOUSE_ID);
expect(b2bShipment.status).toBe('ready');
const updatedB2BOrder = await commerce.orders.getById(b2bOrder.id);
expect(updatedB2BOrder?.status).toBe('shipped');
});
it('4. createShipment fulfills active reservation and deducts physical stock onHand', async () => {
const productId = 'prod_sm_stock';
const order = await createDraftOrder(productId, 5, 'b2b');
// Before confirmation: onHand = 20, reserved = 0, available = 20
let stock = (await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } })).items[0];
expect(stock.onHand).toBe(20);
expect(stock.reserved).toBe(0);
// Confirm: onHand = 20, reserved = 5, available = 15
await commerce.confirmOrder(order.id, WAREHOUSE_ID);
stock = (await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } })).items[0];
expect(stock.onHand).toBe(20);
expect(stock.reserved).toBe(5);
expect(stock.available).toBe(15);
// Create Shipment: physical onHand reduces to 15, reserved cleared to 0, available = 15
await commerce.createShipment(order.id, WAREHOUSE_ID);
stock = (await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } })).items[0];
expect(stock.onHand).toBe(15);
expect(stock.reserved).toBe(0);
expect(stock.available).toBe(15);
// Reservation is marked 'fulfilled'
const resList = await inventory.reservations.list({ filter: { orderId: order.id } });
expect(resList.items[0].status).toBe('fulfilled');
});
it('5. createReturn: allows return only on shipped/delivered orders, checks max returnable qty, restores onHand, and records outflow in finance', async () => {
const productId = 'prod_sm_return';
const order = await createDraftOrder(productId, 6, 'b2b');
// 1. Attempting return on draft order must be rejected
await expect(
commerce.createReturn(order.id, 'Брак', [
{
productId,
sku: 'SKU-SM',
name: 'Товар SM',
quantity: 2,
refundAmount: { minor: 200000, currency: 'RUB' },
},
])
).rejects.toThrow(/Возврат разрешён только по отгруженным или доставленным/);
// Confirm and Ship order (qty = 6)
await commerce.confirmOrder(order.id, WAREHOUSE_ID);
await commerce.createShipment(order.id, WAREHOUSE_ID);
// Stock onHand after shipment: 20 - 6 = 14
let stock = (await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } })).items[0];
expect(stock.onHand).toBe(14);
// 2. Attempting return of 7 when only 6 were shipped must be rejected
await expect(
commerce.createReturn(order.id, 'Отказ', [
{
productId,
sku: 'SKU-SM',
name: 'Товар SM',
quantity: 7,
refundAmount: { minor: 700000, currency: 'RUB' },
},
])
).rejects.toThrow(/превышает отгруженное доступное/);
// 3. Valid return of 2 items
const ret1 = await commerce.createReturn(
order.id,
'Частичный отказ клиента',
[
{
productId,
sku: 'SKU-SM',
name: 'Товар SM',
quantity: 2,
refundAmount: { minor: 200000, currency: 'RUB' },
},
],
WAREHOUSE_ID,
true // auto-record finance transaction
);
expect(ret1.status).toBe('refunded');
expect(ret1.totalRefund.minor).toBe(200000);
// Verify physical stock onHand restored from 14 -> 16
stock = (await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } })).items[0];
expect(stock.onHand).toBe(16);
expect(stock.available).toBe(16);
// Verify finance outflow transaction recorded
const txList = await finance.transactions.list({ filter: { referenceId: ret1.id } });
expect(txList.items).toHaveLength(1);
expect(txList.items[0].type).toBe('outflow');
expect(txList.items[0].category).toBe('refund');
expect(txList.items[0].amount.minor).toBe(200000);
// 4. Second return: can return up to 4 more. Trying to return 5 must be rejected!
await expect(
commerce.createReturn(order.id, 'Лишний возврат', [
{
productId,
sku: 'SKU-SM',
name: 'Товар SM',
quantity: 5,
refundAmount: { minor: 500000, currency: 'RUB' },
},
])
).rejects.toThrow(/превышает отгруженное доступное/);
// Returning exactly the remaining 4 items succeeds
const ret2 = await commerce.createReturn(
order.id,
'Возврат остатка партии',
[
{
productId,
sku: 'SKU-SM',
name: 'Товар SM',
quantity: 4,
refundAmount: { minor: 400000, currency: 'RUB' },
},
],
WAREHOUSE_ID,
true
);
expect(ret2.status).toBe('refunded');
// Stock onHand restored from 16 -> 20 (original initial balance)
stock = (await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } })).items[0];
expect(stock.onHand).toBe(20);
expect(stock.available).toBe(20);
});
});

View file

@ -79,5 +79,18 @@ export interface ICommerceService {
idempotencyKey?: string
): Promise<Payment>;
createShipment(orderId: string, warehouseId: string, items?: ShipmentItem[]): Promise<Shipment>;
processReturn(orderId: string, reason: string, items: ReturnItem[], warehouseId?: string, autoRecordFinance?: boolean): Promise<Return>;
createReturn(
orderId: string,
reason: string,
items: ReturnItem[],
warehouseId?: string,
autoRecordFinance?: boolean
): Promise<Return>;
processReturn(
orderId: string,
reason: string,
items: ReturnItem[],
warehouseId?: string,
autoRecordFinance?: boolean
): Promise<Return>;
}

View file

@ -8,15 +8,27 @@ import {
ReturnItem,
} from '../contracts/commerce';
import { IRepository } from '../contracts/repository';
import { Order } from '../../entities/commerce/order';
import { Order, OrderStatus } from '../../entities/commerce/order';
import { Product, ProductCategory } from '../../entities/commerce/product';
import { MoneyAmount } from '../../entities/types';
import { BaseMockRepository } from './baseRepository';
import { ulid } from '../../shared/lib/ulid';
import { getServices } from './index';
import { InsufficientStockError } from '../contracts/inventory';
const ALLOWED_ORDER_TRANSITIONS: Record<OrderStatus, OrderStatus[]> = {
draft: ['confirmed', 'cancelled'],
confirmed: ['preparing', 'processing', 'cancelled'],
preparing: ['ready_for_shipment', 'ready'],
processing: ['ready_for_shipment', 'ready'],
ready_for_shipment: ['shipped'],
ready: ['shipped'],
shipped: ['delivered'],
delivered: ['completed'],
completed: [],
cancelled: [],
};
export class MockCommerceService implements ICommerceService {
products: IRepository<Product>;
categories: IRepository<ProductCategory>;
@ -36,8 +48,41 @@ export class MockCommerceService implements ICommerceService {
this.returns = new BaseMockRepository<Return>('return');
}
async updateOrderStatus(orderId: string, status: Order['status']): Promise<Order> {
return this.orders.update(orderId, { status });
async updateOrderStatus(orderId: string, nextStatus: OrderStatus): Promise<Order> {
const order = await this.orders.getById(orderId);
if (!order) {
throw new Error(`Заказ с ID ${orderId} не найден.`);
}
if (order.status === nextStatus) {
return order;
}
const allowed = ALLOWED_ORDER_TRANSITIONS[order.status] || [];
if (!allowed.includes(nextStatus)) {
throw new Error(
`Недопустимый переход статуса заказа: «${order.status}» -> «${nextStatus}». Запрещено нарушать State Machine заказа.`
);
}
// Direct routing to full operations for confirmed and cancelled
if (nextStatus === 'confirmed') {
return this.confirmOrder(orderId);
}
if (nextStatus === 'cancelled') {
return this.cancelOrder(orderId);
}
// Pre-condition for shipped status: must be paid or B2B credit
if (nextStatus === 'shipped') {
const isPaid = order.paymentStatus === 'paid' || order.paymentStatus === 'overpaid';
const isB2B = order.channel === 'b2b';
if (!isPaid && !isB2B) {
throw new Error('Отгрузка невозможна: заказ не оплачен (для розницы требуется 100% предоплата)');
}
}
return this.orders.update(orderId, { status: nextStatus });
}
async confirmOrder(orderId: string, warehouseId = 'wh_main'): Promise<Order> {
@ -215,6 +260,22 @@ export class MockCommerceService implements ICommerceService {
throw new Error(`Заказ с ID ${orderId} не найден.`);
}
if (order.status === 'draft') {
throw new Error('Нельзя отгрузить черновик заказа (draft). Сначала подтвердите заказ.');
}
if (order.status === 'cancelled') {
throw new Error('Нельзя отгрузить отмененный заказ (cancelled).');
}
if (order.status === 'completed') {
throw new Error('Заказ уже полностью выполнен (completed).');
}
const isPaid = order.paymentStatus === 'paid' || order.paymentStatus === 'overpaid';
const isB2B = order.channel === 'b2b';
if (!isPaid && !isB2B) {
throw new Error('Отгрузка невозможна: заказ не оплачен (для розницы требуется 100% предоплата)');
}
const shipmentItems: ShipmentItem[] =
items ||
order.items.map((i) => ({
@ -237,7 +298,7 @@ export class MockCommerceService implements ICommerceService {
await this.orders.update(orderId, { fulfillmentStatus: 'fulfilled', status: 'shipped' });
// Deduct reserved stock and physical stock in inventory
// Deduct physical stock (onHand) and fulfill active reservations in inventory
const inventory = getServices().inventory;
if (inventory) {
await inventory.fulfillOrderReservations(orderId, warehouseId, shipmentItems);
@ -246,18 +307,56 @@ export class MockCommerceService implements ICommerceService {
return shipment;
}
async processReturn(
async createReturn(
orderId: string,
reason: string,
items: ReturnItem[],
warehouseId = 'wh_main',
autoRecordFinance = false
autoRecordFinance = true
): Promise<Return> {
const order = await this.orders.getById(orderId);
if (!order) {
throw new Error(`Заказ с ID ${orderId} не найден.`);
}
// Return is strictly allowed only for shipped, delivered, or completed orders
const isEligibleForReturn =
order.status === 'shipped' ||
order.status === 'delivered' ||
order.status === 'completed' ||
order.fulfillmentStatus === 'fulfilled';
if (!isEligibleForReturn) {
throw new Error(
`Возврат разрешён только по отгруженным или доставленным заказам (shipped, delivered, completed). Текущий статус: «${order.status}»`
);
}
// Validate quantities against fulfilled quantities minus already returned quantities
const previousReturns = await this.returns.list({ filter: { orderId } });
for (const item of items) {
const orderItem = order.items.find((i) => i.productId === item.productId);
if (!orderItem) {
throw new Error(`Товар с ID «${item.productId}» не найден в позициях заказа`);
}
let alreadyReturnedQty = 0;
for (const pr of previousReturns.items) {
const prevItem = pr.items.find((pi) => pi.productId === item.productId);
if (prevItem) {
alreadyReturnedQty += prevItem.quantity;
}
}
const maxAllowedReturn = orderItem.quantity - alreadyReturnedQty;
if (item.quantity > maxAllowedReturn) {
throw new Error(
`Количество возврата (${item.quantity}) превышает отгруженное доступное для возврата количество (${maxAllowedReturn}) для товара «${item.name || item.productId}»`
);
}
}
const totalMinor = items.reduce((sum, item) => sum + item.refundAmount.minor, 0);
const ret = await this.returns.create({
@ -274,7 +373,7 @@ export class MockCommerceService implements ICommerceService {
paymentStatus: 'refunded',
});
// Restore physical stock in inventory
// Restore physical stock onHand in inventory
const inventory = getServices().inventory;
if (inventory) {
for (const item of items) {
@ -287,7 +386,7 @@ export class MockCommerceService implements ICommerceService {
}
}
// Automatically record refund transaction in finance if requested
// Automatically record refund transaction in finance
if (autoRecordFinance) {
const finance = getServices().finance;
if (finance) {
@ -297,4 +396,14 @@ export class MockCommerceService implements ICommerceService {
return ret;
}
async processReturn(
orderId: string,
reason: string,
items: ReturnItem[],
warehouseId = 'wh_main',
autoRecordFinance = true
): Promise<Return> {
return this.createReturn(orderId, reason, items, warehouseId, autoRecordFinance);
}
}

File diff suppressed because one or more lines are too long