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
+70 -7
View File
@@ -226,6 +226,7 @@ interface DeltaState {
lockedDelta: number;
lossDelta: number;
reservationDelta: number;
saleDelta: number;
next: StockState;
}
@@ -416,7 +417,8 @@ export class InventoryService {
lowStockThreshold: input.policyType === 'UNLIMITED' ? 0 : threshold
};
return this.persistMutation(connection, stock, identity, {
availableDelta: 0, lockedDelta: 0, lossDelta: 0, reservationDelta: 0, next
availableDelta: 0, lockedDelta: 0, lossDelta: 0,
reservationDelta: 0, saleDelta: 0, next
}, auditFromActor(actor));
});
}
@@ -499,11 +501,22 @@ export class InventoryService {
async deductMany(input: InventoryBatchMutationInput, connection?: PoolConnection) {
return this.batchMutation(input, 'DEDUCT', (stock, quantity) => {
if (stock.policyType === 'UNLIMITED') return delta(stock, 0, 0, 0, -quantity);
if (stock.policyType === 'UNLIMITED') {
return delta(stock, 0, 0, 0, -quantity, quantity);
}
if (number(stock.lockedQuantity) < quantity) {
throw new InventoryError('INVENTORY_INSUFFICIENT_LOCKED');
}
return delta(stock, 0, -quantity, 0, -quantity);
return delta(stock, 0, -quantity, 0, -quantity, quantity);
}, connection);
}
async returnMany(input: InventoryBatchMutationInput, connection?: PoolConnection) {
return this.batchMutation(input, 'RETURN', (stock, quantity) => {
if (stock.policyType === 'UNLIMITED') {
return delta(stock, 0, 0, 0, 0, -quantity);
}
return delta(stock, quantity, 0, 0, 0, -quantity);
}, connection);
}
@@ -554,7 +567,7 @@ export class InventoryService {
private async batchMutation(
input: InventoryBatchMutationInput,
operation: 'LOCK' | 'RELEASE' | 'DEDUCT',
operation: 'LOCK' | 'RELEASE' | 'DEDUCT' | 'RETURN',
calculate: (stock: InventoryRow, quantity: number) => DeltaState,
externalConnection?: PoolConnection
) {
@@ -605,11 +618,16 @@ export class InventoryService {
}
return { items: duplicates as InventoryMutationResult[], idempotent: true };
}
if (operation !== 'LOCK') {
if (operation === 'RELEASE' || operation === 'DEDUCT') {
await this.assertReservationBalances(
connection, normalized.tenantId, normalized.storeId,
normalized.businessType, normalized.businessId, stocks
);
} else if (operation === 'RETURN') {
await this.assertReturnBalances(
connection, normalized.tenantId, normalized.storeId,
normalized.businessType, normalized.businessId, stocks
);
}
const changes = stocks.map(({ stock, quantity }) => ({
stock, change: calculate(stock, quantity)
@@ -762,6 +780,48 @@ export class InventoryService {
}
}
private async assertReturnBalances(
connection: PoolConnection,
tenantId: string,
storeId: string,
businessType: string,
businessId: string,
stocks: Array<{ stock: InventoryRow; quantity: number }>
) {
const placeholders = stocks.map(() => '?').join(', ');
const [rows] = await connection.execute<ReservationRow[]>(
`SELECT inventory_id AS inventoryId,
COALESCE(SUM(COALESCE(
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.inventorySaleDelta'))
AS SIGNED),
CASE
WHEN operation = 'DEDUCT' THEN -COALESCE(
CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata,
'$.inventoryReservationDelta')) AS SIGNED), locked_delta)
WHEN operation = 'RETURN' THEN available_delta
ELSE 0
END
)), 0) AS reservedQuantity
FROM qipai_product_inventory_ledger
WHERE tenant_id = ? AND store_id = ?
AND business_type = ? AND business_id = ?
AND operation IN ('DEDUCT', 'RETURN')
AND inventory_id IN (${placeholders})
GROUP BY inventory_id
FOR SHARE`,
[tenantId, storeId, businessType, businessId,
...stocks.map(({ stock }) => stock.id)]
);
const balances = new Map(
rows.map((row) => [String(row.inventoryId), number(row.reservedQuantity)])
);
for (const { stock, quantity } of stocks) {
if ((balances.get(stock.id) ?? 0) < quantity) {
throw new InventoryError('INVENTORY_INSUFFICIENT_DEDUCTED');
}
}
}
private async assertNoActiveReservations(
connection: PoolConnection,
stock: InventoryRow
@@ -883,6 +943,7 @@ export class InventoryService {
...identity.metadata,
idempotencyFingerprint: identity.fingerprint,
inventoryReservationDelta: change.reservationDelta,
inventorySaleDelta: change.saleDelta,
resultPolicyType: change.next.policyType,
resultLowStockThreshold: change.next.lowStockThreshold
};
@@ -943,6 +1004,7 @@ export class InventoryService {
lockedDelta: change.lockedDelta,
lossDelta: change.lossDelta,
reservationDelta: change.reservationDelta,
saleDelta: change.saleDelta,
versionAfter: number(stock.version) + 1
})]
);
@@ -1088,7 +1150,8 @@ function delta(
availableDelta: number,
lockedDelta: number,
lossDelta: number,
reservationDelta = 0
reservationDelta = 0,
saleDelta = 0
): DeltaState {
const next = {
policyType: stock.policyType,
@@ -1098,7 +1161,7 @@ function delta(
lowStockThreshold: number(stock.lowStockThreshold)
};
validateState(next);
return { availableDelta, lockedDelta, lossDelta, reservationDelta, next };
return { availableDelta, lockedDelta, lossDelta, reservationDelta, saleDelta, next };
}
function validateState(state: StockState) {