fix(M09-REGRESSION): 完成商品逐单对账与权限收口

This commit is contained in:
Codex
2026-08-11 08:25:12 +08:00
parent e91b979128
commit 41b9cf7349
21 changed files with 566 additions and 39 deletions
+5 -4
View File
@@ -310,8 +310,8 @@ export class InventoryService {
ON p.tenant_id = s.tenant_id AND p.id = s.product_id AND p.deleted_at IS NULL
WHERE ${where}
ORDER BY p.sort_order, p.id, s.id
LIMIT ? OFFSET ?`,
[...params, pageSize, (page - 1) * pageSize]
LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`,
params
);
return {
items: rows.map(mapStock),
@@ -346,8 +346,8 @@ export class InventoryService {
trace_id AS traceId, reason, metadata, created_at AS createdAt
FROM qipai_product_inventory_ledger
WHERE tenant_id = ? AND store_id = ? AND inventory_id = ?
ORDER BY id DESC LIMIT ? OFFSET ?`,
[...params, pageSize, (page - 1) * pageSize]
ORDER BY id DESC LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`,
params
);
return {
items: rows.map(mapLedger),
@@ -1013,6 +1013,7 @@ export class InventoryService {
private assertStoreScope(actor: ManagementActor, storeId: string, write: boolean) {
assertId(storeId, 'INVENTORY_STORE_ID_INVALID');
if (actor.access.capabilities.includes('tenant.manage')
|| actor.access.capabilities.includes('platform.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
const capability = write ? 'inventory.adjust' : 'inventory.read';
const hasCapability = actor.access.capabilities.includes(capability)
@@ -378,6 +378,54 @@ export class ProductOrderService {
return this.transaction((connection) => this.loadOrderView(connection, actor, orderId, true));
}
async reconcileForManagement(actor: ProductOrderActor, orderId: string) {
assertId(orderId);
return this.transaction(async (connection) => {
const order = await this.loadOrder(connection, actor.tenantId, orderId, false);
this.assertManagementStore(actor, order.storeId);
const detail = await this.loadOrderView(connection, actor, orderId, true);
const [inventoryLedger] = await connection.execute<RowDataPacket[]>(
`SELECT l.id, l.inventory_id AS inventoryId, l.sku_id AS skuId,
p.name AS productName, s.name AS skuName, l.request_id AS requestId,
l.operation, l.available_delta AS availableDelta,
l.locked_delta AS lockedDelta, l.loss_delta AS lossDelta,
CAST(JSON_UNQUOTE(JSON_EXTRACT(l.metadata,
'$.inventoryReservationDelta')) AS SIGNED) AS reservationDelta,
CAST(JSON_UNQUOTE(JSON_EXTRACT(l.metadata,
'$.inventorySaleDelta')) AS SIGNED) AS saleDelta,
l.available_after AS availableAfter, l.locked_after AS lockedAfter,
l.loss_after AS lossAfter, l.version_after AS versionAfter,
l.created_at AS createdAt
FROM qipai_product_inventory_ledger l
INNER JOIN qipai_product_skus s
ON s.tenant_id = l.tenant_id AND s.id = l.sku_id
INNER JOIN qipai_products p
ON p.tenant_id = s.tenant_id AND p.id = s.product_id
WHERE l.tenant_id = ? AND l.store_id = ?
AND l.business_type = 'PRODUCT_ORDER' AND l.business_id = ?
ORDER BY l.created_at, l.id`,
[actor.tenantId, order.storeId, order.inventoryBusinessId]
);
const [storageSummaries] = await connection.execute<RowDataPacket[]>(
`SELECT r.id, r.storage_no AS storageNo, r.status,
r.total_quantity AS recordTotalQuantity,
r.remaining_quantity AS recordRemainingQuantity,
COALESCE(SUM(i.total_quantity), 0) AS itemTotalQuantity,
COALESCE(SUM(i.remaining_quantity), 0) AS itemRemainingQuantity,
COUNT(i.id) AS itemCount
FROM qipai_product_storage_records r
LEFT JOIN qipai_product_storage_items i
ON i.tenant_id = r.tenant_id AND i.store_id = r.store_id
AND i.storage_id = r.id
WHERE r.tenant_id = ? AND r.store_id = ? AND r.source_order_id = ?
GROUP BY r.id, r.storage_no, r.status, r.total_quantity, r.remaining_quantity
ORDER BY r.id`,
[actor.tenantId, order.storeId, order.id]
);
return buildProductOrderReconciliation(detail, inventoryLedger, storageSummaries);
});
}
async managementAction(
actor: ProductOrderActor,
orderId: string,
@@ -1437,6 +1485,106 @@ function publicPayment(row: PaymentRow, idempotent: boolean) {
};
}
export function buildProductOrderReconciliation(
detail: Record<string, any>,
inventoryLedger: Array<Record<string, any>>,
storageSummaries: Array<Record<string, any>>
) {
const items = Array.isArray(detail.items) ? detail.items : [];
const payments = Array.isArray(detail.payments) ? detail.payments : [];
const refunds = Array.isArray(detail.refunds) ? detail.refunds : [];
const itemAmountCents = items.reduce(
(sum: number, item: Record<string, any>) => sum + numeric(item.subtotalCents), 0
);
const itemQuantity = items.reduce(
(sum: number, item: Record<string, any>) => sum + numeric(item.quantity), 0
);
const capturedAmountCents = payments
.filter((payment: Record<string, any>) => ['SUCCEEDED', 'REFUNDED'].includes(payment.status))
.reduce((sum: number, payment: Record<string, any>) => sum + numeric(payment.amountCents), 0);
const paymentRefundedCents = payments.reduce(
(sum: number, payment: Record<string, any>) => sum + numeric(payment.refundedAmountCents), 0
);
const succeededRefundCents = refunds
.filter((refund: Record<string, any>) => refund.status === 'SUCCEEDED')
.reduce((sum: number, refund: Record<string, any>) => sum + numeric(refund.amountCents), 0);
const reservationBalance = inventoryLedger.reduce(
(sum, entry) => sum + numeric(entry.reservationDelta), 0
);
const saleBalance = inventoryLedger.reduce(
(sum, entry) => sum + numeric(entry.saleDelta), 0
);
const expectedInventory = detail.inventoryStatus === 'LOCKED'
? { reservationBalance: numeric(detail.totalQuantity), saleBalance: 0 }
: detail.inventoryStatus === 'DEDUCTED'
? { reservationBalance: 0, saleBalance: numeric(detail.totalQuantity) }
: { reservationBalance: 0, saleBalance: 0 };
const storages = storageSummaries.map((storage) => ({
id: String(storage.id), storageNo: storage.storageNo, status: storage.status,
recordTotalQuantity: numeric(storage.recordTotalQuantity),
recordRemainingQuantity: numeric(storage.recordRemainingQuantity),
itemTotalQuantity: numeric(storage.itemTotalQuantity),
itemRemainingQuantity: numeric(storage.itemRemainingQuantity),
itemCount: numeric(storage.itemCount),
consistent: numeric(storage.recordTotalQuantity) === numeric(storage.itemTotalQuantity)
&& numeric(storage.recordRemainingQuantity) === numeric(storage.itemRemainingQuantity)
&& numeric(storage.itemRemainingQuantity) >= 0
&& numeric(storage.itemRemainingQuantity) <= numeric(storage.itemTotalQuantity)
}));
const checks = [
reconciliationCheck('ORDER_ITEM_AMOUNT', numeric(detail.totalAmountCents), itemAmountCents),
reconciliationCheck('ORDER_ITEM_QUANTITY', numeric(detail.totalQuantity), itemQuantity),
reconciliationCheck('ORDER_ITEM_COUNT', numeric(detail.itemCount), items.length),
reconciliationCheck('PAYMENT_CAPTURED_AMOUNT', numeric(detail.paidAmountCents), capturedAmountCents),
reconciliationCheck('PAYMENT_REFUNDED_AMOUNT', numeric(detail.refundedAmountCents), paymentRefundedCents),
reconciliationCheck('REFUND_SUCCEEDED_AMOUNT', numeric(detail.refundedAmountCents), succeededRefundCents),
reconciliationCheck(
'INVENTORY_RESERVATION_BALANCE', expectedInventory.reservationBalance, reservationBalance
),
reconciliationCheck('INVENTORY_SALE_BALANCE', expectedInventory.saleBalance, saleBalance),
{
key: 'STORAGE_ITEM_BALANCE', passed: storages.every((storage) => storage.consistent),
expected: storages.length, actual: storages.filter((storage) => storage.consistent).length
}
];
return {
order: {
id: String(detail.id), orderNo: detail.orderNo, status: detail.status,
inventoryStatus: detail.inventoryStatus
},
amounts: {
orderAmountCents: numeric(detail.totalAmountCents), itemAmountCents,
paidAmountCents: numeric(detail.paidAmountCents), capturedAmountCents,
refundedAmountCents: numeric(detail.refundedAmountCents),
paymentRefundedCents, succeededRefundCents,
netRevenueCents: numeric(detail.paidAmountCents) - numeric(detail.refundedAmountCents)
},
inventory: {
status: detail.inventoryStatus, reservationBalance, saleBalance,
ledger: inventoryLedger.map((entry) => ({
...entry, id: String(entry.id), inventoryId: String(entry.inventoryId),
skuId: String(entry.skuId), availableDelta: numeric(entry.availableDelta),
lockedDelta: numeric(entry.lockedDelta), lossDelta: numeric(entry.lossDelta),
reservationDelta: numeric(entry.reservationDelta), saleDelta: numeric(entry.saleDelta),
availableAfter: numeric(entry.availableAfter), lockedAfter: numeric(entry.lockedAfter),
lossAfter: numeric(entry.lossAfter), versionAfter: numeric(entry.versionAfter)
}))
},
storages,
checks,
consistent: checks.every((item) => item.passed)
};
}
function reconciliationCheck(key: string, expected: number, actual: number) {
return { key, passed: expected === actual, expected, actual };
}
function numeric(value: unknown) {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
}
function assertId(value: string) {
if (!idPattern.test(value)) throw new ProductOrderError('PRODUCT_ORDER_INPUT_INVALID');
}
+1
View File
@@ -192,6 +192,7 @@ async function requireInventoryActor(
auth.session.user.id
);
const manager = access.capabilities.includes('tenant.manage')
|| access.capabilities.includes('platform.manage')
|| access.roles.includes('PLATFORM_ADMIN');
const allowed = manager || (write
? access.capabilities.includes('inventory.adjust')
+14 -1
View File
@@ -68,7 +68,8 @@ const testCallbackSchema = z.object({
export interface ProductOrderRouteOptions {
service: Pick<ProductOrderService,
'create' | 'listForCustomer' | 'getForCustomer' | 'cancelForCustomer'
| 'listForManagement' | 'getForManagement' | 'managementAction'
| 'listForManagement' | 'getForManagement' | 'reconcileForManagement'
| 'managementAction'
| 'createPaymentForCustomer' | 'completeTestPaymentForCustomer'
| 'completeTestRefundForManagement'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
@@ -187,6 +188,17 @@ export async function registerProductOrderRoutes(
}));
});
app.get(`${prefix}/product-orders/:orderId/reconciliation`, async (request, reply) => {
const actor = await authenticate(request, reply, options, true, false);
const params = orderParams.safeParse(request.params);
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.reconcileForManagement(actor, params.data.orderId),
traceId: request.traceId
}));
});
app.post(`${prefix}/product-orders/:orderId/actions`, async (request, reply) => {
const actor = await authenticate(request, reply, options, true, true);
const params = orderParams.safeParse(request.params);
@@ -243,6 +255,7 @@ async function authenticate(
);
if (management) {
const manager = access.capabilities.includes('tenant.manage')
|| access.capabilities.includes('platform.manage')
|| access.roles.includes('PLATFORM_ADMIN');
const capability = write ? 'goods.order.manage' : 'goods.order.read';
if (!manager && !access.capabilities.includes(capability)