fix(M09-REGRESSION): 完成商品逐单对账与权限收口
This commit is contained in:
@@ -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