|
|
|
@@ -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');
|
|
|
|
|
}
|
|
|
|
|