fix(M09-REGRESSION): 完成商品逐单对账与权限收口
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
||||
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
||||
"pretest": "npm run build && node tests/product-storage-service.test.mjs && node tests/product-storage-route.test.mjs",
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/admin-auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs && node tests/product-catalog.test.mjs && node tests/product-route.test.mjs && node tests/inventory-service.test.mjs && node tests/inventory-route.test.mjs && node tests/product-order-service.test.mjs && node tests/product-order-route.test.mjs"
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/admin-auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs && node tests/product-catalog.test.mjs && node tests/product-route.test.mjs && node tests/inventory-service.test.mjs && node tests/inventory-route.test.mjs && node tests/product-order-service.test.mjs && node tests/product-order-route.test.mjs && node tests/product-reconciliation.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -95,6 +95,17 @@ const forbidden = await app.inject({
|
||||
assert.equal(forbidden.statusCode, 403);
|
||||
assert.equal(forbidden.json().code, 'INVENTORY_OPERATION_FORBIDDEN');
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STAFF'], capabilities: ['platform.manage'], storeIds: []
|
||||
};
|
||||
assert.equal((await app.inject({
|
||||
method: 'GET', url: '/admin-api/inventory/stocks?storeId=11', headers
|
||||
})).statusCode, 200, 'platform.manage must authorize inventory reads');
|
||||
assert.equal((await app.inject({
|
||||
method: 'POST', url: '/admin-api/inventory/stocks/5/inbound', headers,
|
||||
payload: { storeId: '11', requestId: 'platform-inbound', reason: 'platform check', quantity: 1 }
|
||||
})).statusCode, 200, 'platform.manage must authorize inventory writes');
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STAFF'], capabilities: ['inventory.read'], storeIds: ['11']
|
||||
};
|
||||
@@ -105,7 +116,7 @@ const listed = await app.inject({
|
||||
});
|
||||
assert.equal(listed.statusCode, 200);
|
||||
assert.deepEqual(listed.json().data, { items: [], total: 0, page: 2, pageSize: 25 });
|
||||
const listCall = calls.find((call) => call.method === 'listStocks');
|
||||
const listCall = calls.findLast((call) => call.method === 'listStocks');
|
||||
assert.equal(listCall.args[0].tenantId, '7');
|
||||
assert.equal(listCall.args[0].traceId, 'm09d1-inventory-route');
|
||||
assert.deepEqual(listCall.args[1], {
|
||||
|
||||
@@ -338,6 +338,13 @@ await assert.rejects(
|
||||
() => service.listStocks(scopedOut, { storeId: '11', page: 1, pageSize: 20 }),
|
||||
(error) => error instanceof InventoryError && error.code === 'INVENTORY_STORE_SCOPE_FORBIDDEN'
|
||||
);
|
||||
const platformManager = {
|
||||
...manager,
|
||||
access: { roles: ['STAFF'], capabilities: ['platform.manage'], storeIds: [] }
|
||||
};
|
||||
assert.equal((await service.listStocks(
|
||||
platformManager, { storeId: '11', page: 1, pageSize: 20 }
|
||||
)).total, 4, 'platform.manage must bypass store grants for inventory reads');
|
||||
|
||||
state.lockOrder.length = 0;
|
||||
const batchBase = {
|
||||
|
||||
@@ -4228,6 +4228,30 @@ async function assertProductOrderPaymentInventory(pool, context) {
|
||||
callbackId: 'm09d2-first-refund-callback', amountCents: 1400
|
||||
}
|
||||
);
|
||||
const completedReconciliation = await service.reconcileForManagement(
|
||||
adminActor, firstOrder.id
|
||||
);
|
||||
assert.equal(completedReconciliation.consistent, true);
|
||||
assert.equal(completedReconciliation.amounts.netRevenueCents, 0);
|
||||
assert.equal(completedReconciliation.inventory.saleBalance, 2);
|
||||
assert.ok(completedReconciliation.inventory.ledger.some(
|
||||
(entry) => entry.operation === 'LOCK'
|
||||
));
|
||||
assert.ok(completedReconciliation.inventory.ledger.some(
|
||||
(entry) => entry.operation === 'DEDUCT'
|
||||
));
|
||||
assert.ok(completedReconciliation.checks.every((check) => check.passed));
|
||||
|
||||
const platformActor = {
|
||||
...adminActor,
|
||||
access: { roles: ['STAFF'], capabilities: ['platform.manage'], storeIds: [] }
|
||||
};
|
||||
assert.equal((await service.reconcileForManagement(
|
||||
platformActor, firstOrder.id
|
||||
)).consistent, true);
|
||||
assert.ok((await inventory.listStocks(
|
||||
platformActor, { storeId, page: 1, pageSize: 20 }
|
||||
)).total >= 1);
|
||||
|
||||
const restockOrder = await service.create(customerActor, {
|
||||
...firstInput, requestId: 'm09d2-order-restock', note: 'restock order',
|
||||
@@ -4526,6 +4550,14 @@ async function assertProductStorageLifecycle(pool, context) {
|
||||
assert.equal(retrieved.movements.reduce(
|
||||
(sum, movement) => sum + Number(movement.quantity), 0
|
||||
), 3);
|
||||
const storageReconciliation = await orders.reconcileForManagement(
|
||||
adminActor, sourceOrder.id
|
||||
);
|
||||
assert.equal(storageReconciliation.consistent, true);
|
||||
assert.equal(storageReconciliation.storages.length, 1);
|
||||
assert.equal(storageReconciliation.storages[0].recordTotalQuantity, 3);
|
||||
assert.equal(storageReconciliation.storages[0].recordRemainingQuantity, 0);
|
||||
assert.ok(storageReconciliation.checks.every((check) => check.passed));
|
||||
|
||||
await assert.rejects(
|
||||
() => storages.listForManagement({
|
||||
|
||||
@@ -38,6 +38,9 @@ const service = {
|
||||
items: [order], total: 1, page: input.page, pageSize: input.pageSize
|
||||
})),
|
||||
getForManagement: record('getForManagement', () => order),
|
||||
reconcileForManagement: record('reconcileForManagement', () => ({
|
||||
order: { id: '101', orderNo: 'PG101' }, consistent: true, checks: []
|
||||
})),
|
||||
managementAction: record('managementAction', (_actor, _id, input) => ({
|
||||
...order, status: input.action === 'ACCEPT' ? 'ACCEPTED' : order.status
|
||||
})),
|
||||
@@ -146,6 +149,11 @@ const managementList = await app.inject({
|
||||
assert.equal(managementList.statusCode, 200);
|
||||
const managementCall = calls.find((call) => call.method === 'listForManagement');
|
||||
assert.equal(managementCall.args[0].source, 'MANAGEMENT');
|
||||
const reconciliation = await app.inject({
|
||||
method: 'GET', url: '/admin-api/product-orders/101/reconciliation', headers
|
||||
});
|
||||
assert.equal(reconciliation.statusCode, 200);
|
||||
assert.equal(reconciliation.json().data.consistent, true);
|
||||
|
||||
const readOnlyAction = await app.inject({
|
||||
method: 'POST', url: '/admin-api/product-orders/101/actions', headers,
|
||||
@@ -170,5 +178,16 @@ const refundCompleted = await app.inject({
|
||||
assert.equal(refundCompleted.statusCode, 200);
|
||||
assert.equal(refundCompleted.json().data.status, 'SUCCEEDED');
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STAFF'], capabilities: ['platform.manage'], storeIds: []
|
||||
};
|
||||
assert.equal((await app.inject({
|
||||
method: 'GET', url: '/admin-api/product-orders?storeId=11', headers
|
||||
})).statusCode, 200, 'platform.manage must authorize product-order reads');
|
||||
assert.equal((await app.inject({
|
||||
method: 'POST', url: '/admin-api/product-orders/101/actions', headers,
|
||||
payload: { requestId: 'platform-accept-1', action: 'ACCEPT', reason: '' }
|
||||
})).statusCode, 200, 'platform.manage must authorize product-order writes');
|
||||
|
||||
await app.close();
|
||||
console.log('PASS: M09-D2 product order routes enforce customer ownership, management permissions and test payment boundaries.');
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildProductOrderReconciliation } from '../dist/products/product-order-service.js';
|
||||
|
||||
const detail = {
|
||||
id: '101', orderNo: 'PG101', status: 'COMPLETED', inventoryStatus: 'DEDUCTED',
|
||||
itemCount: 1, totalQuantity: 2, totalAmountCents: 500,
|
||||
paidAmountCents: 500, refundedAmountCents: 0,
|
||||
items: [{ id: '201', quantity: 2, subtotalCents: 500 }],
|
||||
payments: [{ id: '301', status: 'SUCCEEDED', amountCents: 500, refundedAmountCents: 0 }],
|
||||
refunds: []
|
||||
};
|
||||
const inventoryLedger = [
|
||||
{
|
||||
id: '401', inventoryId: '51', skuId: '5', operation: 'LOCK',
|
||||
availableDelta: -2, lockedDelta: 2, lossDelta: 0,
|
||||
reservationDelta: 2, saleDelta: 0,
|
||||
availableAfter: 8, lockedAfter: 2, lossAfter: 0, versionAfter: 2
|
||||
},
|
||||
{
|
||||
id: '402', inventoryId: '51', skuId: '5', operation: 'DEDUCT',
|
||||
availableDelta: 0, lockedDelta: -2, lossDelta: 0,
|
||||
reservationDelta: -2, saleDelta: 2,
|
||||
availableAfter: 8, lockedAfter: 0, lossAfter: 0, versionAfter: 3
|
||||
}
|
||||
];
|
||||
const storages = [{
|
||||
id: '601', storageNo: 'PS601', status: 'PARTIALLY_RETRIEVED',
|
||||
recordTotalQuantity: 2, recordRemainingQuantity: 1,
|
||||
itemTotalQuantity: 2, itemRemainingQuantity: 1, itemCount: 1
|
||||
}];
|
||||
|
||||
const consistent = buildProductOrderReconciliation(detail, inventoryLedger, storages);
|
||||
assert.equal(consistent.consistent, true);
|
||||
assert.equal(consistent.amounts.netRevenueCents, 500);
|
||||
assert.deepEqual(
|
||||
[consistent.inventory.reservationBalance, consistent.inventory.saleBalance], [0, 2]
|
||||
);
|
||||
assert.equal(consistent.storages[0].consistent, true);
|
||||
assert.ok(consistent.checks.every((check) => check.passed));
|
||||
|
||||
const inconsistent = buildProductOrderReconciliation(
|
||||
{ ...detail, paidAmountCents: 400, refundedAmountCents: 100 },
|
||||
inventoryLedger,
|
||||
[{ ...storages[0], itemRemainingQuantity: 2 }]
|
||||
);
|
||||
assert.equal(inconsistent.consistent, false);
|
||||
assert.equal(
|
||||
inconsistent.checks.find((check) => check.key === 'PAYMENT_CAPTURED_AMOUNT').passed, false
|
||||
);
|
||||
assert.equal(
|
||||
inconsistent.checks.find((check) => check.key === 'REFUND_SUCCEEDED_AMOUNT').passed, false
|
||||
);
|
||||
assert.equal(
|
||||
inconsistent.checks.find((check) => check.key === 'STORAGE_ITEM_BALANCE').passed, false
|
||||
);
|
||||
|
||||
const released = buildProductOrderReconciliation({
|
||||
...detail, status: 'CANCELLED', inventoryStatus: 'RELEASED',
|
||||
paidAmountCents: 0, payments: [], refunds: []
|
||||
}, [
|
||||
inventoryLedger[0],
|
||||
{ ...inventoryLedger[1], operation: 'RELEASE', reservationDelta: -2, saleDelta: 0 }
|
||||
], []);
|
||||
assert.equal(released.consistent, true);
|
||||
assert.deepEqual([released.inventory.reservationBalance, released.inventory.saleBalance], [0, 0]);
|
||||
|
||||
console.log('PASS: M09-REGRESSION detects amount, payment, refund, inventory and storage drift.');
|
||||
Reference in New Issue
Block a user