feat(M09-D2): 完成商品订单与库存占用生命周期

This commit is contained in:
Codex
2026-08-11 07:04:23 +08:00
parent 93af128002
commit 1a3ea7bdf0
30 changed files with 3773 additions and 91 deletions
+66 -1
View File
@@ -167,6 +167,26 @@ function execute(sql, params) {
&& item.inventoryId === String(params[1]) && item.requestId === String(params[2]));
return [row ? [{ ...row }] : [], []];
}
if (sql.includes('AS reservedQuantity') && sql.includes('inventorySaleDelta')) {
const inventoryIds = new Set(params.slice(4).map(String));
const balances = new Map();
for (const item of state.ledgers) {
if (item.tenantId !== String(params[0]) || item.storeId !== String(params[1])
|| item.businessType !== String(params[2]) || item.businessId !== String(params[3])
|| !inventoryIds.has(item.inventoryId)
|| !['DEDUCT', 'RETURN'].includes(item.operation)) continue;
const metadata = JSON.parse(item.metadata);
const fallback = item.operation === 'DEDUCT'
? -Number(metadata.inventoryReservationDelta ?? item.lockedDelta)
: Number(item.availableDelta);
balances.set(item.inventoryId,
(balances.get(item.inventoryId) ?? 0)
+ Number(metadata.inventorySaleDelta ?? fallback));
}
return [[...balances.entries()].map(([inventoryId, reservedQuantity]) => ({
inventoryId, reservedQuantity
})), []];
}
if (sql.includes('AS reservedQuantity') && sql.includes('business_type = ?')) {
const inventoryIds = new Set(params.slice(4).map(String));
const balances = new Map();
@@ -376,6 +396,51 @@ assert.deepEqual(
[8, 0]
);
const returned = await service.returnMany({
...batchBase, requestId: 'order-return-1', reason: '退款成功回补',
items: [{ skuId: '5', quantity: 1 }]
});
assert.equal(returned.idempotent, false);
assert.equal(findStock('7', '11', '5').availableQuantity, 9);
const returnedReplay = await service.returnMany({
...batchBase, requestId: 'order-return-1', reason: '退款成功回补',
items: [{ skuId: '5', quantity: 1 }]
});
assert.equal(returnedReplay.idempotent, true);
assert.equal(findStock('7', '11', '5').availableQuantity, 9);
await assert.rejects(
() => service.returnMany({
...batchBase, requestId: 'order-return-1', reason: '退款成功回补',
items: [{ skuId: '5', quantity: 2 }]
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_IDEMPOTENCY_CONFLICT'
);
await assert.rejects(
() => service.returnMany({
...batchBase, requestId: 'foreign-order-return', businessId: 'order-foreign',
reason: '跨订单退款不得回补', items: [{ skuId: '5', quantity: 1 }]
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_INSUFFICIENT_DEDUCTED'
);
await assert.rejects(
() => service.returnMany({
...batchBase, requestId: 'order-return-too-many', reason: '超额退款不得回补',
items: [{ skuId: '5', quantity: 2 }]
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_INSUFFICIENT_DEDUCTED'
);
await assert.rejects(
() => service.returnMany({
...batchBase, requestId: 'order-return-case', businessId: 'ORDER-500',
reason: '业务号大小写敏感', items: [{ skuId: '5', quantity: 1 }]
}),
(error) => error instanceof InventoryError
&& error.code === 'INVENTORY_INSUFFICIENT_DEDUCTED'
);
const beforeFailedBatch = structuredClone(state.stocks);
await assert.rejects(
() => service.lockMany({
@@ -443,4 +508,4 @@ assert.ok(history.items.length >= 7);
assert.ok(history.items.every((item) => item.metadata.idempotencyFingerprint));
assert.equal(state.audits.length, state.ledgers.length);
console.log('PASS: M09-D1 inventory policy, inbound/adjust/stocktake/loss, ordered batch locks, idempotency, immutable ledger, scope and audit work.');
console.log('PASS: M09-D1 inventory policy, batch reservation/sale return lifecycle, idempotency, immutable ledger, scope and audit work.');