feat(M09-D2): 完成商品订单与库存占用生命周期
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user