feat(commerce): formal order, shipment, delivery, and return state machines
This commit is contained in:
parent
ae35865829
commit
a29d683edb
6 changed files with 418 additions and 22 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
import React, { useEffect, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
DEFAULT_TENANT_ID,
|
DEFAULT_TENANT_ID,
|
||||||
createRequestContext,
|
createRequestContext,
|
||||||
|
|
@ -22,22 +22,18 @@ export const TenantProvider: React.FC<TenantProviderProps> = ({
|
||||||
initialTenantId,
|
initialTenantId,
|
||||||
}) => {
|
}) => {
|
||||||
const sessionTenantId = useSessionStore((s) => s.activeTenantId);
|
const sessionTenantId = useSessionStore((s) => s.activeTenantId);
|
||||||
const [currentTenantId, setCurrentTenantId] = useState<string>(() => {
|
const currentTenantId = sessionTenantId || initialTenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
|
||||||
return initialTenantId || sessionTenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
|
|
||||||
});
|
|
||||||
|
|
||||||
const switchTenant = useCallback((tenantId: string) => {
|
const switchTenant = useCallback((tenantId: string) => {
|
||||||
setCurrentTenantId(tenantId);
|
|
||||||
setActiveGlobalTenantId(tenantId);
|
setActiveGlobalTenantId(tenantId);
|
||||||
useSessionStore.getState().setActiveTenantId(tenantId);
|
useSessionStore.getState().setActiveTenantId(tenantId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionTenantId && sessionTenantId !== currentTenantId) {
|
if (initialTenantId && initialTenantId !== sessionTenantId) {
|
||||||
setCurrentTenantId(sessionTenantId);
|
useSessionStore.getState().setActiveTenantId(initialTenantId);
|
||||||
setActiveGlobalTenantId(sessionTenantId);
|
|
||||||
}
|
}
|
||||||
}, [sessionTenantId, currentTenantId]);
|
}, [initialTenantId, sessionTenantId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveGlobalTenantId(currentTenantId);
|
setActiveGlobalTenantId(currentTenantId);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { useDemoStore as useDemoControlsStore } from '../stores/demo';
|
|
||||||
import { CapabilityId } from '../core/capabilities/types';
|
import { CapabilityId } from '../core/capabilities/types';
|
||||||
import { SystemRoleId } from '../core/permissions/types';
|
import { SystemRoleId } from '../core/permissions/types';
|
||||||
import { BUSINESS_PROFILES } from '../core/profiles/profiles';
|
|
||||||
import { BusinessProfile } from '../core/profiles/types';
|
import { BusinessProfile } from '../core/profiles/types';
|
||||||
|
|
||||||
export type ThemeMode = 'soft-light' | 'light' | 'dark';
|
export type ThemeMode = 'soft-light' | 'light' | 'dark';
|
||||||
|
|
|
||||||
280
src/services/__tests__/orderStateMachine.test.ts
Normal file
280
src/services/__tests__/orderStateMachine.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -79,5 +79,18 @@ export interface ICommerceService {
|
||||||
idempotencyKey?: string
|
idempotencyKey?: string
|
||||||
): Promise<Payment>;
|
): Promise<Payment>;
|
||||||
createShipment(orderId: string, warehouseId: string, items?: ShipmentItem[]): Promise<Shipment>;
|
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>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,27 @@ import {
|
||||||
ReturnItem,
|
ReturnItem,
|
||||||
} from '../contracts/commerce';
|
} from '../contracts/commerce';
|
||||||
import { IRepository } from '../contracts/repository';
|
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 { Product, ProductCategory } from '../../entities/commerce/product';
|
||||||
import { MoneyAmount } from '../../entities/types';
|
import { MoneyAmount } from '../../entities/types';
|
||||||
import { BaseMockRepository } from './baseRepository';
|
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';
|
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 {
|
export class MockCommerceService implements ICommerceService {
|
||||||
products: IRepository<Product>;
|
products: IRepository<Product>;
|
||||||
categories: IRepository<ProductCategory>;
|
categories: IRepository<ProductCategory>;
|
||||||
|
|
@ -36,8 +48,41 @@ export class MockCommerceService implements ICommerceService {
|
||||||
this.returns = new BaseMockRepository<Return>('return');
|
this.returns = new BaseMockRepository<Return>('return');
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateOrderStatus(orderId: string, status: Order['status']): Promise<Order> {
|
async updateOrderStatus(orderId: string, nextStatus: OrderStatus): Promise<Order> {
|
||||||
return this.orders.update(orderId, { status });
|
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> {
|
async confirmOrder(orderId: string, warehouseId = 'wh_main'): Promise<Order> {
|
||||||
|
|
@ -215,6 +260,22 @@ export class MockCommerceService implements ICommerceService {
|
||||||
throw new Error(`Заказ с ID ${orderId} не найден.`);
|
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[] =
|
const shipmentItems: ShipmentItem[] =
|
||||||
items ||
|
items ||
|
||||||
order.items.map((i) => ({
|
order.items.map((i) => ({
|
||||||
|
|
@ -237,7 +298,7 @@ export class MockCommerceService implements ICommerceService {
|
||||||
|
|
||||||
await this.orders.update(orderId, { fulfillmentStatus: 'fulfilled', status: 'shipped' });
|
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;
|
const inventory = getServices().inventory;
|
||||||
if (inventory) {
|
if (inventory) {
|
||||||
await inventory.fulfillOrderReservations(orderId, warehouseId, shipmentItems);
|
await inventory.fulfillOrderReservations(orderId, warehouseId, shipmentItems);
|
||||||
|
|
@ -246,18 +307,56 @@ export class MockCommerceService implements ICommerceService {
|
||||||
return shipment;
|
return shipment;
|
||||||
}
|
}
|
||||||
|
|
||||||
async processReturn(
|
async createReturn(
|
||||||
orderId: string,
|
orderId: string,
|
||||||
reason: string,
|
reason: string,
|
||||||
items: ReturnItem[],
|
items: ReturnItem[],
|
||||||
warehouseId = 'wh_main',
|
warehouseId = 'wh_main',
|
||||||
autoRecordFinance = false
|
autoRecordFinance = true
|
||||||
): Promise<Return> {
|
): Promise<Return> {
|
||||||
const order = await this.orders.getById(orderId);
|
const order = await this.orders.getById(orderId);
|
||||||
if (!order) {
|
if (!order) {
|
||||||
throw new Error(`Заказ с ID ${orderId} не найден.`);
|
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 totalMinor = items.reduce((sum, item) => sum + item.refundAmount.minor, 0);
|
||||||
|
|
||||||
const ret = await this.returns.create({
|
const ret = await this.returns.create({
|
||||||
|
|
@ -274,7 +373,7 @@ export class MockCommerceService implements ICommerceService {
|
||||||
paymentStatus: 'refunded',
|
paymentStatus: 'refunded',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore physical stock in inventory
|
// Restore physical stock onHand in inventory
|
||||||
const inventory = getServices().inventory;
|
const inventory = getServices().inventory;
|
||||||
if (inventory) {
|
if (inventory) {
|
||||||
for (const item of items) {
|
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) {
|
if (autoRecordFinance) {
|
||||||
const finance = getServices().finance;
|
const finance = getServices().finance;
|
||||||
if (finance) {
|
if (finance) {
|
||||||
|
|
@ -297,4 +396,14 @@ export class MockCommerceService implements ICommerceService {
|
||||||
|
|
||||||
return ret;
|
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
Loading…
Reference in a new issue