702 lines
48 KiB
JavaScript
702 lines
48 KiB
JavaScript
import test from 'node:test'
|
|
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, verifyFulfillmentRootStatus,
|
|
completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer,
|
|
dispatchDestinationConservation } from './phase5-overseas-fulfillment.mjs'
|
|
import assert from 'node:assert/strict'
|
|
import { readFile } from 'node:fs/promises'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { parseArguments } from '../admin-test-env-acceptance.mjs'
|
|
|
|
const source = await readFile(fileURLToPath(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url)), 'utf8')
|
|
const entry = await readFile(fileURLToPath(new URL('../admin-test-env-acceptance.mjs', import.meta.url)), 'utf8')
|
|
|
|
test('一箱实收从四箱派送退回仅保持RETURN_PENDING,全部实收才RETURNED,缺失收货或来源证据仍拒绝', () => {
|
|
const expected = { customerId: '100', outboundNo: 'OB-1', deliveryNo: 'DL-1', inboundCode: 'IN-1', evidenceFileId: '91' }
|
|
const detail = { order: { ...expected, status: 'COMPLETED', packageCount: 1, evidenceFileIds: ['91'] },
|
|
returnSources: [], returnReceipts: [{ receiptNo: 'RSR-1', receivedPackages: '1', receivedAt: '2026-10-06T01:00:00' }] }
|
|
const delivery = { order: { customerId: '100', outboundNo: 'OB-1', deliveryNo: 'DL-1', packageCount: 4, status: 'RETURN_PENDING' } }
|
|
assert.equal(returnedExceptionFactsMatch(detail, delivery, expected), true)
|
|
assert.equal(returnedExceptionFactsMatch(detail, { order: { ...delivery.order, packageCount: 1, status: 'RETURNED' } }, expected), true)
|
|
assert.equal(returnedExceptionFactsMatch(detail, { order: { ...delivery.order, status: 'RETURNED' } }, expected), false)
|
|
assert.equal(returnedExceptionFactsMatch(detail, { order: { ...delivery.order, customerId: 'OTHER' } }, expected), false)
|
|
for (const mutate of [v => { v.returnReceipts = [] }, v => { v.returnReceipts[0].receivedPackages = 2 },
|
|
v => { v.order.outboundNo = 'OB-OTHER' }, v => { v.order.evidenceFileIds = [] },
|
|
v => { v.returnSources = [{ maxReturnPackages: 1 }] }, v => { v.returnReceipts.push({ ...v.returnReceipts[0] }) }]) {
|
|
const changed = structuredClone(detail); mutate(changed)
|
|
assert.equal(returnedExceptionFactsMatch(changed, delivery, expected), false)
|
|
}
|
|
})
|
|
|
|
test('route equals actual item destinations and frozen selected candidates, then conserves split deliveries',()=>{
|
|
const fixture={planPalletIds:['10','11','12'],destinationWarehouseCodes:['ONT8','LAX9']}
|
|
const detail={route:'ONT8,LAX9',items:[{planPalletId:'10',fbaWarehouseCode:'ONT8'},
|
|
{planPalletId:'11',destinationWarehouseCode:'LAX9'},{planPalletId:'12',fbaWarehouseCode:'ONT8'}]}
|
|
const deliveries=[{destinationWarehouseCode:'LAX9'},{destinationWarehouseCode:'ONT8'}]
|
|
assert.equal(dispatchDestinationConservation(detail,fixture),true)
|
|
assert.equal(dispatchDestinationConservation(detail,fixture,deliveries),true)
|
|
for(const mutate of [
|
|
value=>value.detail.route='ONT8',value=>value.detail.route='ONT8,LAX9,OTHER',
|
|
value=>value.detail.route='ONT8,ONT8,LAX9',value=>value.detail.items[1].destinationWarehouseCode='OTHER',
|
|
value=>value.detail.items[0].destinationWarehouseCode='REASSIGNED',
|
|
value=>value.detail.items[1].planPalletId='99',value=>value.detail.items[1].planPalletId='10',
|
|
value=>value.fixture.destinationWarehouseCodes.push('UNSELECTED'),value=>value.deliveries.pop(),
|
|
value=>value.deliveries.push({destinationWarehouseCode:'OTHER'}),
|
|
value=>value.deliveries.push({destinationWarehouseCode:'ONT8'}),
|
|
]) {
|
|
const value=structuredClone({detail,fixture,deliveries});mutate(value)
|
|
assert.equal(dispatchDestinationConservation(value.detail,value.fixture,value.deliveries),false)
|
|
}
|
|
assert.match(source,/dispatchDestinationConservation\(beforeEdit.data,source.dispatch\)/)
|
|
assert.match(source,/dispatchDestinationConservation\(beforeEdit.data,source.dispatch,deliveries\)/)
|
|
})
|
|
|
|
test('phase-five fulfillment is a registered default HTTP module after the real phase-four dispatch fixture', () => {
|
|
assert.match(entry, /CONFIGURED_MODULES[^\n]*'phase4-overseas-dispatch', 'phase5-overseas-fulfillment'/)
|
|
assert.match(entry, /'phase5-overseas-fulfillment', \['phase4-overseas-dispatch'\]/)
|
|
const modules = parseArguments(['--modules', 'phase5-overseas-fulfillment']).modules
|
|
for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading', 'phase3-loading-finalize',
|
|
'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-dispatch']) {
|
|
assert.ok(modules.indexOf(prerequisite) >= 0
|
|
&& modules.indexOf(prerequisite) < modules.indexOf('phase5-overseas-fulfillment'), prerequisite)
|
|
}
|
|
})
|
|
|
|
test('phase-five module covers dispatch edit, transfer, PDA pick/depart and delivery state', () => {
|
|
for (const token of [
|
|
"const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches'",
|
|
"const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'",
|
|
'query(MERCHANT_DELIVERY_BASE, { dispatchNo })', '/items/${encodeURIComponent(id(item.dispatchItemId))}/pick',
|
|
'/complete`', '/depart`', '/outbound-orders/${encodeURIComponent(outboundNo)}',
|
|
'/delivery-orders/${encodeURIComponent(currentNo)}/status',
|
|
]) assert.ok(source.includes(token), token)
|
|
assert.match(source, /WAIT_OUTBOUND/)
|
|
assert.match(source, /WAIT_DELIVERY/)
|
|
assert.match(source, /READY_TO_DEPART/)
|
|
assert.match(source, /OUTBOUNDED/)
|
|
assert.match(source, /DELIVERING/)
|
|
assert.match(source, /expectedVersion: deliveryVersion/)
|
|
assert.match(source, /transferDeliveries\(transferred\.data\)/)
|
|
assert.match(source, /new Set\(destinationKeys\)\.size === deliveries\.length/)
|
|
assert.match(source, /deliveries\.reduce\(\(sum, value\) => sum \+ Number\(value\?\.packageCount \|\| 0\), 0\)/)
|
|
})
|
|
|
|
test('phase-five module proves the client delivery create, list and nested detail contract', () => {
|
|
assert.match(source, /const MERCHANT_DELIVERY_BASE = '\/api\/merchant\/v1\/last-leg\/delivery-orders'/)
|
|
assert.match(source, /loginMerchant\(context, customerId\)/)
|
|
assert.match(source, /context\.options\?\.merchantUsername/)
|
|
assert.match(source, /\/api\/merchant\/v1\/iam\/tenant\/profile/)
|
|
assert.match(source, /id\(profile\?\.id\) === customerId/)
|
|
assert.match(source, /客户创建派送并原子转出库单/)
|
|
assert.match(source, /deliveryMethod: 'TRUCK_PALLET'/)
|
|
assert.match(source, /fees: \[\{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE',\s*currency: 'USD', amount: 10/)
|
|
assert.match(source, /客户分页回查自身派送单/)
|
|
assert.match(source, /客户读取自身派送详情/)
|
|
assert.match(source, /merchantDetail\.data\?\.order\?\.deliveryNo/)
|
|
assert.match(source, /merchantDetail\.data\?\.skus/)
|
|
assert.match(source, /merchantDetail\.data\?\.fees/)
|
|
assert.match(source, /merchantDetail\.data\?\.operations/)
|
|
})
|
|
|
|
test('phase-five module proves receipt-save completion, cross-region convergence, customer visit, exception and return', () => {
|
|
for (const token of [
|
|
'/documents/RECEIPT/files', 'requestMultipart', "category: 'RECEIPT'",
|
|
'SAVE-RECEIPT', 'RECEIPT_AUTO_COMPLETE',
|
|
'/customer-visits/${encodeURIComponent(visitId)}',
|
|
"visitResult: 'SATISFIED'", '/exception-orders',
|
|
'/return-inbound-options', '/mark-return', "returnDirection: 'OVERSEAS_WAREHOUSE'",
|
|
'returnSources', 'outboundPalletItemId', 'inventoryPositionId',
|
|
'value.returnSources.some', 'returnInbound?.deliveryNo === deliveryNo',
|
|
"idempotencyKey: key(context, 'DELIVERY-RETURN')", '派送退回同键同载荷重放',
|
|
'RETURN_PENDING_WITH_COMPLETED_VISIT',
|
|
]) assert.ok(source.includes(token), token)
|
|
assert.match(source, /saved\.data\?\.categories\?\.some/)
|
|
assert.match(source, /completed\.data\?\.order\?\.status === 'DELIVERED'/)
|
|
assert.match(source, /人工确认派送完成接口已移除/)
|
|
assert.match(source, /query\('\/api\/platform\/inbounds'/)
|
|
assert.match(source, /inboundCode: inbound\.inboundCode/)
|
|
assert.match(source, /inboundSummary\?\.processStatus === 'DELIVERED'/)
|
|
assert.match(source, /verifyFulfillmentRootStatus\(context, \{ sourceOrderId, customerId, merchantToken \}\)/)
|
|
assert.match(source, /\/api\/platform\/v1\/orders\/\$\{encodeURIComponent\(rootOrderId\)\}/)
|
|
assert.match(source, /\/fulfillment-tree/)
|
|
assert.match(source, /mainStatus === 'COMPLETED'/)
|
|
assert.doesNotMatch(source, /platform\(context, 'POST', '确认派送完成/)
|
|
assert.match(source, /manualExceptionNo/)
|
|
assert.match(source, /returnExceptionNo/)
|
|
})
|
|
|
|
test('phase-five module uses marker-owned fixtures and always disables its temporary PDA identity', () => {
|
|
assert.match(source, /dispatch\?\.marker !== context\.marker/)
|
|
assert.match(source, /receiving\?\.marker !== context\.marker/)
|
|
assert.match(source, /resolvePickerAccess\(context\)/)
|
|
assert.match(source, /createPicker\(context, 5, warehouseId, access\)/)
|
|
assert.match(source, /finally \{[\s\S]*disablePicker\(context, picker, 5\)[\s\S]*picker\.token = null/)
|
|
assert.doesNotMatch(source, /8\.163\.|password\s*:\s*['"`]/)
|
|
})
|
|
|
|
test('phase-five writes carry idempotency and record deployment blockers instead of passing empty fixtures', () => {
|
|
assert.match(source, /idempotencyKey: key\(context, 'TRANSFER'\)/)
|
|
assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-PICK'/)
|
|
assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-COMPLETE'\)/)
|
|
assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-DEPART'\)/)
|
|
assert.match(source, /addCoverageGap\(MODULE, SCENARIO/)
|
|
assert.match(source, /deploymentBlocker: true/)
|
|
})
|
|
test('relabel marks are checked on the authoritative outbound DTO without weakening identity checks', () => {
|
|
const relabel = { relabelCartonMarks: ['NEW-1', 'NEW-2'], sourceCartonMarks: ['OLD-1', 'OLD-2'] }
|
|
const detail = { outbound: { packageCount: 2 }, pallets: [{ cartonMarks: 'NEW-1,NEW-2' }] }
|
|
assert.equal(relabelOutboundMarksMatch(detail, relabel), true)
|
|
for (const cartonMarks of ['', 'NEW-1', 'NEW-1,NEW-1', 'NEW-1,OLD-2', 'NEW-1,NEW-2,OTHER']) {
|
|
assert.equal(relabelOutboundMarksMatch({ ...detail, pallets: [{ cartonMarks }] }, relabel), false)
|
|
}
|
|
assert.equal(relabelOutboundMarksMatch({ ...detail, outbound: { packageCount: 3 } }, relabel), false)
|
|
assert.match(source, /detail\.data\?\.outbound\?\.dispatchNo === relabel\.dispatchNo/)
|
|
assert.match(source, /relabel\?\.marker !== context\.marker/)
|
|
})
|
|
|
|
test('manual exception retries only a transport failure with the identical business body', () => {
|
|
assert.match(source, /manualException\.response\.http === 0/)
|
|
assert.match(source, /人工异常连接失败后使用同载荷重试/)
|
|
assert.match(source, /manualExceptionBody/)
|
|
})
|
|
|
|
test('quantity snapshots ignore timestamps but preserve every inventory bucket and source identity', () => {
|
|
const original = { id: '1', inboundCode: 'IN-1', totalPackages: 5, availablePackages: 3,
|
|
holdPackages: 2, allocatedPackages: 0, pickedPackages: 0, updatedAt: 'earlier' }
|
|
assert.deepEqual(inventoryQuantitySnapshot([original]), inventoryQuantitySnapshot([{ ...original, updatedAt: 'later' }]))
|
|
for (const field of ['id', 'inboundCode', 'totalPackages', 'availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages']) {
|
|
assert.notDeepEqual(inventoryQuantitySnapshot([original]),
|
|
inventoryQuantitySnapshot([{ ...original, [field]: field === 'id' || field === 'inboundCode' ? 'different' : 99 }]))
|
|
}
|
|
})
|
|
|
|
function contextFor(request) {
|
|
const assertions = []
|
|
const calls = []
|
|
return { marker: 'TAIL-CURRENT', options: { gateway: 'https://test.invalid' }, createdResources: {},
|
|
assertions, calls,
|
|
async request(method, path, body, token) { calls.push({ method, path, body, token }); return request(method, path, body, token) },
|
|
apiSuccess(module, name, response) { assertions.push({ name, passed: response?.body?.code === '200' }); return response.body?.data },
|
|
assert(module, name, response, passed) { assertions.push({ name, passed }) },
|
|
addStep(module, name, response, passed) { assertions.push({ name, passed }) },
|
|
addCoverageGap(module, scenario, name) { assertions.push({ name, passed: false }) },
|
|
}
|
|
}
|
|
const ok = data => ({ http: 200, body: { code: '200', data } })
|
|
|
|
test('partial completion rejects different Orders and an already delivered pending fragment', async () => {
|
|
for (const second of [{ orderId: 'OTHER', processStatus: 'WAIT_SHIP' },
|
|
{ orderId: 'ORDER', processStatus: 'DELIVERED' }]) {
|
|
const context = contextFor((method, path) => ok({ records: path.includes('inboundCode=IN-A')
|
|
? [{ inboundCode: 'IN-A', orderId: 'ORDER', processStatus: 'DELIVERED' }]
|
|
: [{ inboundCode: 'IN-B', ...second }] }))
|
|
assert.equal(await verifyPartialOrderCompletion(context, { orderId: 'ORDER', customerId: '100',
|
|
deliveredInboundCode: 'IN-A', pendingInboundCodes: ['IN-B'] }), false)
|
|
assert.equal(context.calls.some(call => call.path.startsWith('/api/platform/v1/orders/')), false)
|
|
}
|
|
})
|
|
|
|
test('partial completion verifies an unfinished valid sibling and catches premature COMPLETED', async () => {
|
|
for (const mainStatus of ['IN_TRANSIT', 'COMPLETED']) {
|
|
const context = contextFor((method, path) => path.startsWith('/api/platform/v1/orders/')
|
|
? ok({ summary: { mainStatus } }) : ok({ records: [{ inboundCode: path.includes('inboundCode=IN-A') ? 'IN-A' : 'IN-B',
|
|
orderId: 'ORDER', processStatus: path.includes('inboundCode=IN-A') ? 'DELIVERED' : 'WAIT_SHIP' }] }))
|
|
assert.equal(await verifyPartialOrderCompletion(context, { orderId: 'ORDER', customerId: '100',
|
|
deliveredInboundCode: 'IN-A', pendingInboundCodes: ['IN-B'] }), mainStatus !== 'COMPLETED')
|
|
}
|
|
})
|
|
|
|
test('same dispatch-return exception reaches actual restored inventory and retries without another ledger row', async () => {
|
|
for (const initialStatus of ['PENDING', 'PROCESSING']) {
|
|
let completed = false
|
|
let version = initialStatus === 'PROCESSING' ? 1 : 0
|
|
let uploads = 0
|
|
const source = { outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }
|
|
const context = contextFor((method, path, body) => {
|
|
if (path.includes('/inventory/balances/1/positions')) return ok(path.includes('palletNo=') ? (completed ? [{ id: '3', balanceId: '1',
|
|
customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', palletId: '71',
|
|
palletNo: 'RET-TAIL-CURRENT', warehouseLocationId: '72', totalPackages: 1, availablePackages: 1,
|
|
holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }] : [])
|
|
: [{ id: '61', balanceId: '1', customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A',
|
|
palletNo: 'SOURCE', totalPackages: 8, availablePackages: 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 },
|
|
...(completed ? [{ id: '3', balanceId: '1', customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', palletId: '71',
|
|
palletNo: 'RET-TAIL-CURRENT', warehouseLocationId: '72', totalPackages: 1, availablePackages: 1, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }] : [])])
|
|
if (path.includes('/inventory/balances')) { const balance = { id: '1', customerId: '100',
|
|
overseasWarehouseId: 'WH-1', totalPackages: completed ? 9 : 8,
|
|
availablePackages: completed ? 9 : 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 }; return ok(path.includes('/balances/1?') ? balance : { total: 1, records: [balance] }) }
|
|
if (path.includes('/inventory/ledger')) return ok({ total: completed ? 1 : 0, records: completed ? [{ id: '2',
|
|
customerId: '100', inboundCode: 'IN-A', businessType: 'RETURN_STOCK_IN', businessOrderNo: 'EX-1',
|
|
outboundOrderNo: 'OB-1', deltaPackages: 1, beforePackages: 0, afterPackages: 1 }] : [] })
|
|
if (path.includes('/locations')) return ok({ total: 1, records: [{ id: '72', locationCode: 'L-1' }] })
|
|
if (method === 'POST' && path.endsWith('/pallets')) return ok('71')
|
|
if (path.endsWith('/pallets/71')) return ok({ id: '71', warehouseId: 'WH-1', palletCode: 'RET-TAIL-CURRENT', locationId: '72' })
|
|
if (path.includes('/start-processing')) { version = 1; return ok({ status: 'PROCESSING', version }) }
|
|
if (path.includes('/complete')) {
|
|
assert.deepEqual(body.receipts[0], { outboundPalletItemId: '51', inventoryPositionId: '61', receivedPackages: 1,
|
|
targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', targetLocationId: '72', targetLocationCode: 'L-1' })
|
|
const replayed = completed; completed = true
|
|
return ok({ status: 'COMPLETED', restoredPackages: 1, inventoryAdjustmentId: '81', replayed })
|
|
}
|
|
if (path.includes('/delivery-orders/')) return ok({ order: { status: 'RETURNED', outboundNo: 'OB-1', deliveryNo: 'DEL-1', customerId: '100', packageCount: 1 } })
|
|
return ok({ order: { status: completed ? 'COMPLETED' : initialStatus, exceptionType: 'RETURN',
|
|
outboundNo: 'OB-1', inboundCode: 'IN-A', deliveryNo: 'DEL-1', customerId: '100', packageCount: 1, version, evidenceFileIds: ['91'] },
|
|
returnSources: completed ? [] : [source], returnReceipts: completed ? [{ receiptNo: 'RSR-CURRENT', receivedPackages: 1, receivedAt: '2026-10-06T00:00:00' }] : [] })
|
|
})
|
|
context.requestMultipart = async () => { uploads += 1; return ok({ fileId: '91' }) }
|
|
context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1', inventoryProof: { sourceBalanceIds: ['1'] } }
|
|
const resume = initialStatus === 'PROCESSING'
|
|
? { targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', evidenceFileId: '91', completeIdempotencyKey: 'ORIGINAL-COMPLETE' } : null
|
|
const result = await completeReturnedException(context, { customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1', resume })
|
|
assert.equal(result?.restoredPackages, 1)
|
|
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
|
|
assert.equal(context.calls.filter(call => call.path.includes('/complete')).length, 2)
|
|
if (initialStatus === 'PROCESSING') {
|
|
assert.equal(context.calls.some(call => call.method === 'POST' && call.path.endsWith('/pallets')), false)
|
|
assert.equal(context.calls.some(call => call.path.includes('/start-processing')), false)
|
|
assert.equal(uploads, 0)
|
|
assert.ok(context.calls.filter(call => call.path.includes('/complete')).every(call => call.body.idempotencyKey === 'ORIGINAL-COMPLETE'))
|
|
}
|
|
assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.evidenceFileId, '91')
|
|
}
|
|
})
|
|
|
|
test('PROCESSING continuation without the original pallet and evidence remains blocked without creating replacements', async () => {
|
|
const context = contextFor(() => ok({ order: { status: 'PROCESSING', exceptionType: 'RETURN', outboundNo: 'OB-1', version: 1 },
|
|
returnSources: [{ outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }] }))
|
|
context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1', inventoryProof: { sourceBalanceIds: ['1'] } }
|
|
assert.equal(await completeReturnedException(context,
|
|
{ customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1' }), null)
|
|
assert.equal(context.calls.length, 1)
|
|
assert.ok(context.assertions.some(row => !row.passed))
|
|
})
|
|
|
|
test('failed same-exception completion still checks zero inventory drift and retains the original resume evidence', async () => {
|
|
const context = contextFor((method, path) => {
|
|
if (path.includes('/inventory/ledger')) return ok({ records: [], total: 0 })
|
|
if (path.includes('/inventory/balances')) {
|
|
const balance = { id: '1', customerId: '100', overseasWarehouseId: 'WH-1', totalPackages: 1, availablePackages: 1,
|
|
holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 }
|
|
return ok(path.includes('/positions') ? [{ ...balance, id: '61', balanceId: '1', inboundCode: 'IN-A' }]
|
|
: path.includes('/balances/1?') ? balance : { records: [balance], total: 1 })
|
|
}
|
|
if (path.includes('/locations')) return ok({ records: [{ id: '72', locationCode: 'L-1' }], total: 1 })
|
|
if (path.endsWith('/pallets/71')) return ok({ id: '71', warehouseId: 'WH-1', palletCode: 'RET-TAIL-CURRENT', locationId: '72' })
|
|
if (path.includes('/complete')) return { http: 200, body: { code: 'A0400', message: 'Source rejected' } }
|
|
return ok({ order: { status: 'PROCESSING', exceptionType: 'RETURN', outboundNo: 'OB-1', inboundCode: 'IN-A', version: 1 },
|
|
returnSources: [{ outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }] })
|
|
})
|
|
context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1', inventoryProof: { sourceBalanceIds: ['1'] } }
|
|
const resume = { targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', evidenceFileId: '91', completeIdempotencyKey: 'ORIGINAL-COMPLETE' }
|
|
assert.equal(await completeReturnedException(context,
|
|
{ customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1', resume }), null)
|
|
assert.equal(context.calls.filter(call => call.path.includes('/inventory/balances')).length, 6)
|
|
assert.equal(context.calls.filter(call => call.path.includes('/inventory/ledger')).length, 2)
|
|
assert.ok(context.assertions.some(row => row.name === '退回实收失败后库存及流水保持不变并保留原续验资料' && row.passed))
|
|
assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.evidenceFileId, '91')
|
|
assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.completeIdempotencyKey, 'ORIGINAL-COMPLETE')
|
|
assert.equal(context.calls.some(call => call.method === 'POST' && !call.path.includes('/complete')), false)
|
|
})
|
|
|
|
test('customer receipt acceptance downloads exact bytes and rejects a foreign customer without a platform fallback', async () => {
|
|
const context = contextFor((method, path, body, token) => path.endsWith('/iam/tenant/profile')
|
|
? ok({ id: token === 'FOREIGN' ? '200' : '100' }) : token === 'FOREIGN'
|
|
? { http: 404, body: { code: 'A0404' } }
|
|
: ok({ deliveryNo: 'DEL-1', files: [{ fileId: '91' }] }))
|
|
const previousFetch = globalThis.fetch
|
|
globalThis.fetch = async (url, options) => options.headers.Authorization === 'Bearer FOREIGN'
|
|
? new Response(JSON.stringify({ code: 'A0404' }), { status: 404, headers: { 'content-type': 'application/json' } })
|
|
: new Response('CURRENT-RECEIPT', { status: 200, headers: { 'content-type': 'application/octet-stream' } })
|
|
try {
|
|
assert.equal(await verifyCustomerSignedDocuments(context, 'OWN',
|
|
[{ deliveryNo: 'DEL-1', fileId: '91', content: 'CURRENT-RECEIPT' }], 'FOREIGN'), true)
|
|
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
|
|
assert.equal(context.calls.some(call => call.path.includes('/api/platform/')), false)
|
|
} finally { globalThis.fetch = previousFetch }
|
|
})
|
|
|
|
test('SIGNED-B fixture keeps primary options and credentials unchanged and records no password or token', async () => {
|
|
const context = contextFor((method, path, body, token) => {
|
|
assert.equal(path, '/api/auth/login')
|
|
assert.equal(token, '')
|
|
assert.deepEqual(body, { loginId: 'SECONDARY', password: 'SECONDARY-IN-MEMORY' })
|
|
return ok({ accessToken: 'SECONDARY-TOKEN' })
|
|
})
|
|
context.options.customerName = 'PRIMARY'
|
|
context.context = { merchantQualificationFixture: { tenantId: '100', username: 'PRIMARY', password: 'PRIMARY-IN-MEMORY' } }
|
|
context.createdResources.phase5OverseasFulfillment = { marker: context.marker }
|
|
const primary = context.context.merchantQualificationFixture
|
|
const options = context.options
|
|
const create = async (secondary, flags) => {
|
|
assert.equal(flags.publishEnvironment, false)
|
|
assert.equal(secondary.marker, `${context.marker}-SIGNED-B`)
|
|
assert.notEqual(secondary.options, context.options)
|
|
assert.notEqual(secondary.context, context.context)
|
|
assert.notEqual(secondary.createdResources, context.createdResources)
|
|
assert.equal(secondary.options.customerName, '')
|
|
secondary.context.merchantQualificationFixture = { tenantId: '200', tenantName: 'SECONDARY', username: 'SECONDARY',
|
|
password: 'SECONDARY-IN-MEMORY', marker: secondary.marker }
|
|
// Even unexpected producer fields cannot enter the report's explicit metadata whitelist.
|
|
secondary.createdResources.merchantQualificationFixture = { ...secondary.context.merchantQualificationFixture, token: 'UNEXPECTED' }
|
|
}
|
|
assert.equal(await createSignedDocumentIsolationCustomer(context, { create }), 'SECONDARY-TOKEN')
|
|
assert.equal(context.options, options)
|
|
assert.equal(context.options.customerName, 'PRIMARY')
|
|
assert.equal(context.context.merchantQualificationFixture, primary)
|
|
assert.equal(context.context.phase5TailVerification.foreignMerchantToken, 'SECONDARY-TOKEN')
|
|
const report = JSON.stringify(context.createdResources)
|
|
assert.doesNotMatch(report, /password|token|IN-MEMORY|UNEXPECTED|SECONDARY-TOKEN/)
|
|
assert.equal(context.createdResources.phase5OverseasFulfillment.signedDocumentIsolationCustomer.marker, 'TAIL-CURRENT-SIGNED-B')
|
|
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
|
|
})
|
|
|
|
test('incomplete SIGNED-B producer remains a blocker and never logs in as the primary customer', async () => {
|
|
const context = contextFor(() => { throw new Error('must not log in') })
|
|
context.context = { merchantQualificationFixture: { tenantId: '100', username: 'PRIMARY', password: 'PRIMARY-IN-MEMORY' } }
|
|
context.createdResources.phase5OverseasFulfillment = { marker: context.marker }
|
|
const token = await createSignedDocumentIsolationCustomer(context, { create: async () => {} })
|
|
assert.equal(token, null)
|
|
assert.equal(context.calls.length, 0)
|
|
assert.ok(context.assertions.some(row => !row.passed))
|
|
assert.equal(context.context.phase5TailVerification, undefined)
|
|
})
|
|
|
|
function inventoryFixture() {
|
|
const position = (positionId, inboundCode, packages) => ({ id: positionId, balanceId: '1', customerId: '100',
|
|
overseasWarehouseId: 'WH-1', inboundCode, inboundOrderId: `ORDER-${inboundCode}`, palletId: `PALLET-${positionId}`,
|
|
palletNo: `P-${positionId}`, batchNo: 'B-1', warehouseLocationId: 'LOCATION-1', warehouseLocationCode: 'L-1',
|
|
totalPackages: packages, availablePackages: packages, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 })
|
|
return { balance: { id: '1', customerId: '100', overseasWarehouseId: 'WH-1', skuCode: 'MIXED',
|
|
totalPackages: 9, availablePackages: 9, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0,
|
|
receivingPackages: 0, inTransitPackages: 4, procurementPackages: 2 },
|
|
positions: [position('11', 'IN-A', 3), position('12', 'IN-A-1', 2), position('13', 'IN-B', 4)] }
|
|
}
|
|
function inventoryContext(fixture) {
|
|
return contextFor((method, path) => path.includes('/positions') ? ok(fixture.positions)
|
|
: path.includes('/balances/1?') ? ok(fixture.balance) : ok({ records: [fixture.balance], total: 1 }))
|
|
}
|
|
|
|
test('inventory evidence resolves missing Balance inboundCode through exact positions and retains an independent shared balance', async () => {
|
|
const fixture = inventoryFixture()
|
|
const context = inventoryContext(fixture)
|
|
const snapshot = await inventorySnapshot(context, '100', 'WH-1', ['IN-A', 'IN-A', 'IN-B'], 'stock', ['1'])
|
|
assert.equal(snapshot.length, 1)
|
|
assert.equal(snapshot[0].id, '1')
|
|
assert.equal(snapshot[0].totalPackages, 7)
|
|
assert.deepEqual(snapshot[0].inboundCodes, ['IN-A', 'IN-B'])
|
|
assert.deepEqual(snapshot[0].matchingPositionIds, ['11', '13'])
|
|
assert.equal(snapshot[0].balance.totalPackages, 9)
|
|
assert.equal(snapshot[0].balance.inTransitPackages, 4)
|
|
assert.equal(snapshot[0].balance.procurementPackages, 2)
|
|
assert.equal(snapshot[0].positions.length, 3)
|
|
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
|
|
assert.equal(context.calls.filter(call => call.path.includes('/positions')).length, 1)
|
|
const childOnly = inventoryFixture()
|
|
childOnly.positions = [childOnly.positions[1]]
|
|
childOnly.balance.totalPackages = 2; childOnly.balance.availablePackages = 2
|
|
const childSnapshot = await inventorySnapshot(inventoryContext(childOnly), '100', 'WH-1', ['IN-A'], 'stock', ['1'])
|
|
assert.equal(childSnapshot[0].totalPackages, 0)
|
|
assert.deepEqual(childSnapshot[0].matchingPositionIds, [])
|
|
})
|
|
|
|
test('inventory evidence rejects foreign balances and positions, mismatched balance IDs, duplicate identities, and broken bucket reconciliation', async () => {
|
|
for (const mutate of [
|
|
fixture => { fixture.balance.customerId = 'OTHER' },
|
|
fixture => { fixture.balance.overseasWarehouseId = 'OTHER' },
|
|
fixture => { fixture.positions[0].customerId = 'OTHER' },
|
|
fixture => { fixture.positions[0].overseasWarehouseId = 'OTHER' },
|
|
fixture => { fixture.positions[0].balanceId = 'OTHER' },
|
|
fixture => { fixture.positions[0].id = fixture.positions[1].id },
|
|
fixture => { fixture.positions[0].inboundCode = '' },
|
|
fixture => { fixture.positions[0].receivingPackages = 1 },
|
|
fixture => { fixture.balance.inTransitPackages = null },
|
|
fixture => { delete fixture.positions[0].receivingPackages },
|
|
fixture => { fixture.balance.availablePackages = 8 },
|
|
]) {
|
|
const fixture = inventoryFixture(); mutate(fixture)
|
|
const context = inventoryContext(fixture)
|
|
assert.equal(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'stock', ['1']), null)
|
|
assert.ok(context.assertions.some(row => !row.passed))
|
|
}
|
|
})
|
|
|
|
test('inventory evidence captures zero drift independently for every balance bucket, position identity, and unselected sibling position', async () => {
|
|
const fixture = inventoryFixture()
|
|
const original = await inventorySnapshot(inventoryContext(fixture), '100', 'WH-1', ['IN-A'], 'stock', ['1'])
|
|
for (const field of ['inTransitPackages', 'procurementPackages']) {
|
|
const changed = inventoryFixture(); changed.balance[field] += 1
|
|
assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock', ['1']), original)
|
|
}
|
|
for (const field of ['palletId', 'palletNo', 'batchNo', 'warehouseLocationId', 'warehouseLocationCode', 'inboundOrderId']) {
|
|
const changed = inventoryFixture(); changed.positions[2][field] = 'DIFFERENT'
|
|
assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock', ['1']), original)
|
|
}
|
|
for (const field of ['holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages']) {
|
|
const changed = inventoryFixture(); changed.positions[2][field] += 1; changed.balance[field] += 1
|
|
assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock', ['1']), original)
|
|
}
|
|
})
|
|
|
|
test('departure gate requires the PDA task COMPLETED, server canDepart, and independent matching dispatch READY_TO_DEPART', async () => {
|
|
for (const [taskStatus, canDepart, dispatchStatus, dispatchNo, expected] of [
|
|
['COMPLETED', true, 'READY_TO_DEPART', 'DS-1', true],
|
|
['READY_TO_DEPART', true, 'READY_TO_DEPART', 'DS-1', false],
|
|
['PICKING', true, 'READY_TO_DEPART', 'DS-1', false],
|
|
['COMPLETED', false, 'READY_TO_DEPART', 'DS-1', false],
|
|
['COMPLETED', true, 'PENDING_DEPARTURE', 'DS-1', false],
|
|
['COMPLETED', true, 'DEPARTED', 'DS-1', false],
|
|
['COMPLETED', true, 'READY_TO_DEPART', 'DS-OTHER', false],
|
|
]) {
|
|
const context = contextFor((method, path) => path.startsWith('/api/pda/')
|
|
? ok({ dispatchNo: 'DS-1', status: taskStatus, canDepart }) : ok({ dispatchNo, status: dispatchStatus }))
|
|
assert.equal(Boolean(await verifyDepartureReadiness(context, 'PDA', 'DS-1')), expected)
|
|
assert.equal(context.calls.length, 2)
|
|
assert.equal(context.calls.some(call => call.method !== 'GET'), false)
|
|
assert.equal(context.assertions.some(row => !row.passed), !expected)
|
|
}
|
|
})
|
|
|
|
|
|
test('G05 freezes actual PDA balance IDs and measures raw sources independently from grouped representatives without inbound filters', async () => {
|
|
const task = { dispatchNo: 'DS-1', warehouseId: 'WH-1', items: [
|
|
{ positions: [{ id: '11', balanceId: '1' }, { id: '12', balanceId: '2' }, { id: '11', balanceId: '1' }] }] }
|
|
assert.deepEqual(dispatchInventorySources(task, 'DS-1', 'WH-1'), { sourceBalanceIds: ['1', '2'], sourcePositionIds: ['11', '12'],
|
|
sourcePositionBalances: [{ positionId: '11', balanceId: '1' }, { positionId: '12', balanceId: '2' }] })
|
|
assert.equal(dispatchInventorySources(task, 'OTHER', 'WH-1'), null)
|
|
assert.equal(dispatchInventorySources(task, 'DS-1', 'OTHER'), null)
|
|
assert.equal(dispatchInventorySources({ ...task, items: [{ positions: [{ id: '11' }] }] }, 'DS-1', 'WH-1'), null)
|
|
let shipped = false
|
|
const source = balanceId => ({ id: balanceId, customerId: '100', overseasWarehouseId: 'WH-1',
|
|
destinationWarehouseCode: 'FBA', skuCode: 'MIXED', totalPackages: balanceId === '1' ? 3 : 6,
|
|
availablePackages: balanceId === '1' ? 3 : 6, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0,
|
|
receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 })
|
|
const context = contextFor((method, path) => {
|
|
assert.equal(new URL(`https://test.invalid${path}`).searchParams.has('inboundCode'), false)
|
|
const match = path.match(/balances\/(1|2)(\/positions)?\?/)
|
|
if (!match) return ok({ records: [{ ...source(shipped ? '99' : '1'), totalPackages: shipped ? 3 : 12, availablePackages: shipped ? 3 : 12 }], total: 1 })
|
|
const balance = source(match[1])
|
|
if (match[2]) return ok([{ ...balance, id: match[1] === '1' ? '11' : '12', balanceId: match[1], inboundCode: 'IN-A',
|
|
totalPackages: shipped ? 0 : balance.totalPackages, availablePackages: shipped ? 0 : balance.availablePackages }])
|
|
return shipped ? { http: 404, body: { code: 'A0404' } } : ok(balance)
|
|
})
|
|
const frozenSources = dispatchInventorySources(task, 'DS-1', 'WH-1')
|
|
const frozenIds = frozenSources.sourceBalanceIds
|
|
const before = await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'before', frozenIds)
|
|
assert.equal(inventorySourcesPresent(before, frozenSources.sourcePositionBalances), true)
|
|
assert.equal(inventorySourcesPresent(before, [{ positionId: '11', balanceId: '2' }]), false)
|
|
assert.equal(inventorySourcesPresent(before, [{ positionId: 'UNKNOWN', balanceId: '1' }]), false)
|
|
assert.equal(before.reduce((sum, row) => sum + row.totalPackages, 0), 9)
|
|
assert.equal(before.reduce((sum, row) => sum + row.balance.totalPackages, 0), 9)
|
|
assert.equal(before[0].globalGroups[0].totalPackages, 12)
|
|
shipped = true
|
|
const after = await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'after', frozenIds)
|
|
assert.equal(inventorySourcesPresent(after, frozenSources.sourcePositionBalances), true)
|
|
assert.equal(after.reduce((sum, row) => sum + row.totalPackages, 0), 0)
|
|
assert.equal(after.reduce((sum, row) => sum + row.balance.totalPackages, 0), 0)
|
|
assert.equal(after[0].globalGroups[0].totalPackages, 3)
|
|
assert.deepEqual(after, await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'replay', frozenIds))
|
|
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
|
|
})
|
|
|
|
test('source snapshot never treats unknown or incompletely proven raw 404 as zero inventory', async () => {
|
|
const missing = { http: 404, body: { code: 'A0404' } }
|
|
for (const mode of ['unknown', 'positions-failure', 'position-nonzero', 'group-not-decreased']) {
|
|
let absent = false
|
|
const fixture = inventoryFixture()
|
|
fixture.balance.inTransitPackages = 0; fixture.balance.procurementPackages = 0
|
|
const context = contextFor((method, path) => {
|
|
if (path.includes('/positions')) {
|
|
if (absent && mode === 'positions-failure') return missing
|
|
return ok(fixture.positions.map(row => absent && mode !== 'position-nonzero'
|
|
? { ...row, totalPackages: 0, availablePackages: 0 } : row))
|
|
}
|
|
if (path.includes('/balances/1?')) return absent ? missing : ok(fixture.balance)
|
|
return ok({ total: absent && mode !== 'group-not-decreased' ? 0 : 1,
|
|
records: absent && mode !== 'group-not-decreased' ? [] : [fixture.balance] })
|
|
})
|
|
if (mode !== 'unknown') assert.ok(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'before', ['1']))
|
|
absent = true
|
|
assert.equal(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'after', ['1']), null)
|
|
assert.ok(context.assertions.some(row => !row.passed))
|
|
}
|
|
const context = contextFor(() => { throw new Error('Missing fixed IDs cannot issue HTTP reads') })
|
|
assert.equal(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'stock'), null)
|
|
})
|
|
|
|
|
|
function representativePalletConservationFixture() {
|
|
const quantities = [1, 3, 1, 3, 1]
|
|
const codes = ['IN-A', 'IN-A-OTHER', 'IN-B', 'IN-B-OTHER', 'IN-C']
|
|
const references = quantities.map((_, index) => ({ balanceId: String(index + 1), positionId: String(index + 11) }))
|
|
const groups = packages => [{ id: 'GROUP', customerId: '100', overseasWarehouseId: 'WH-1', skuCode: 'MIXED',
|
|
totalPackages: packages, availablePackages: packages, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0,
|
|
receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 }]
|
|
const snapshot = after => quantities.map((quantity, index) => {
|
|
const balanceId = String(index + 1)
|
|
const position = { id: String(index + 11), balanceId, customerId: '100', overseasWarehouseId: 'WH-1',
|
|
inboundCode: codes[index], palletNo: index < 2 ? 'P-A' : index < 4 ? 'P-B' : 'P-C',
|
|
totalPackages: after ? 0 : quantity, availablePackages: after ? 0 : quantity,
|
|
holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }
|
|
const leftover = { ...position, id: '99', palletNo: 'UNSELECTED', totalPackages: 3, availablePackages: 3 }
|
|
const positions = index === 4 ? [position, leftover] : [position]
|
|
const exact = positions.filter(row => ['IN-A', 'IN-B', 'IN-C'].includes(row.inboundCode))
|
|
const total = positions.reduce((sum, row) => sum + row.totalPackages, 0)
|
|
return { id: balanceId, totalPackages: exact.reduce((sum, row) => sum + row.totalPackages, 0),
|
|
balance: { id: balanceId, totalPackages: total }, positions, globalGroups: groups(after ? 3 : 12) }
|
|
})
|
|
const ledger = quantities.map((quantity, index) => ({ id: String(index + 101), customerId: '100',
|
|
overseasWarehouseId: 'WH-1', businessType: 'SHIP', businessOrderNo: 'DS-1', outboundOrderNo: 'DS-1',
|
|
inboundCode: codes[index], palletNo: index < 2 ? 'P-A' : index < 4 ? 'P-B' : 'P-C',
|
|
beforePackages: quantity, deltaPackages: -quantity, afterPackages: 0 }))
|
|
return { customerId: '100', warehouseId: 'WH-1', dispatchNo: 'DS-1', expectedPackages: 9,
|
|
sourcePositionBalances: references, stockBefore: snapshot(false), stockAfter: snapshot(true),
|
|
ledgerBefore: [], ledgerAfter: ledger }
|
|
}
|
|
|
|
test('G05 MIN inbound-code display does not filter real same-pallet sources: positions9→0/raw12→3/groups12→3/SHIP-9', () => {
|
|
const fixture = representativePalletConservationFixture()
|
|
const context = contextFor(() => { throw new Error('Offline arithmetic cannot issue HTTP') })
|
|
context.createdResources.phase5OverseasFulfillment = { inventoryProof: {} }
|
|
assert.equal(fixture.stockBefore.reduce((sum, row) => sum + row.totalPackages, 0), 6)
|
|
assert.equal(fixture.stockAfter.reduce((sum, row) => sum + row.totalPackages, 0), 3)
|
|
const result = dispatchStockConservation(context, fixture)
|
|
assert.equal(result.passed, true)
|
|
assert.deepEqual(result.proof.totals, { positionBefore: 9, positionAfter: 0, rawBefore: 12, rawAfter: 3,
|
|
groupBefore: 12, groupAfter: 3, ledgerShipped: 9, expectedPackages: 9 })
|
|
assert.equal(result.proof.ledgerRowChecks.length, 5)
|
|
assert.deepEqual(context.assertions.filter(row => !row.passed), [])
|
|
const saved = context.createdResources.phase5OverseasFulfillment.inventoryProof
|
|
assert.deepEqual(saved.snapshots.beforePick, fixture.stockBefore)
|
|
assert.deepEqual(saved.ledgers.afterPick, fixture.ledgerAfter)
|
|
assert.ok(JSON.stringify(saved).includes('IN-A-OTHER'))
|
|
assert.doesNotMatch(JSON.stringify(saved), /password|accessToken|Bearer|base64/)
|
|
})
|
|
|
|
test('G05 failures still independently review and preserve raw, group, each SHIP quantity/source, and unselected positions', () => {
|
|
for (const mutate of [
|
|
fixture => { fixture.stockAfter[0].positions[0].id = 'WRONG-POSITION' },
|
|
fixture => { fixture.stockAfter[0].balance.totalPackages = 1 },
|
|
fixture => { for (const row of fixture.stockAfter) row.globalGroups[0].totalPackages = 4 },
|
|
fixture => { fixture.ledgerAfter[0].deltaPackages = -2 },
|
|
fixture => { fixture.ledgerAfter[0].customerId = 'OTHER' },
|
|
fixture => { fixture.ledgerAfter[0].overseasWarehouseId = 'OTHER' },
|
|
fixture => { fixture.ledgerAfter[0].outboundOrderNo = 'OTHER' },
|
|
fixture => { fixture.ledgerAfter[0].palletNo = 'UNSELECTED' },
|
|
fixture => { fixture.stockAfter[4].positions[1].availablePackages = 2 },
|
|
]) {
|
|
const fixture = representativePalletConservationFixture(); mutate(fixture)
|
|
const context = contextFor(() => { throw new Error('Offline only') })
|
|
context.createdResources.phase5OverseasFulfillment = { inventoryProof: {} }
|
|
const result = dispatchStockConservation(context, fixture)
|
|
assert.equal(result.passed, false)
|
|
assert.equal(context.assertions.length, 9)
|
|
assert.equal(Object.keys(result.proof.checks).length, 8)
|
|
assert.equal(result.proof.ledgerRowChecks.length, 5)
|
|
assert.deepEqual(context.createdResources.phase5OverseasFulfillment.inventoryProof.snapshots.afterPick, fixture.stockAfter)
|
|
assert.deepEqual(context.createdResources.phase5OverseasFulfillment.inventoryProof.ledgers.afterPick, fixture.ledgerAfter)
|
|
}
|
|
})
|
|
|
|
function rootCompletionFixture({ childStatus = 'PROCESSING', rootStatus = 'PROCESSING', rootDelivered = true,
|
|
fullyLoaded = false, mainStatus = rootStatus === 'COMPLETED' ? 'COMPLETED' : 'DELIVERED', sourceOrderId = '1001' } = {}) {
|
|
const root = { order: { id: '1001', tenantId: '100', customerId: '100', orderRole: 'MASTER', rootOrderId: '1001',
|
|
orderNo: 'FL1001', status: rootStatus }, statusHistory: [
|
|
...(rootDelivered ? [{ action: 'OVERSEAS_DELIVERED' }] : []),
|
|
...(rootStatus === 'COMPLETED' ? [{ action: 'FULFILLMENT_COMPLETED' }] : [])] }
|
|
const child = { order: { id: '1002', tenantId: '100', customerId: '100', orderRole: 'CHILD',
|
|
rootOrderId: '1001', parentOrderId: '1001', orderNo: 'FL1001-1', status: childStatus } }
|
|
const tree = { parentOrderId: '1001', parentOrderNo: 'FL1001', originalPackageCount: 4,
|
|
cumulativeLoadedPackageCount: fullyLoaded ? 4 : 1, currentRemainderPackageCount: fullyLoaded ? 0 : 3,
|
|
children: [{ orderId: '1001', orderNo: 'FL1001', splitRole: 'LOADED', sourceOrderId: '1001', packageCount: 1 },
|
|
{ orderId: '1002', orderNo: 'FL1001-1', splitRole: 'REMAINDER', sourceOrderId: '1001', packageCount: 3 },
|
|
...(fullyLoaded ? [{ orderId: '1002', orderNo: 'FL1001-1', splitRole: 'LOADED', sourceOrderId: '1002', packageCount: 3 }] : [])] }
|
|
const context = contextFor((method, path) => {
|
|
if (path.includes('/fulfillment-tree')) return ok(tree)
|
|
if (path.includes('/first-leg/1002')) return ok(child)
|
|
if (path.includes('/first-leg/1001')) return ok(root)
|
|
return ok({ summary: { orderId: '1001', mainStatus } })
|
|
})
|
|
context.createdResources.phase5OverseasFulfillment = {}
|
|
return { root, child, tree, context,
|
|
args: { sourceOrderId, customerId: '100', merchantToken: 'ONLY-IN-MEMORY', pollAttempts: 1, pollIntervalMs: 0 } }
|
|
}
|
|
|
|
test('real root1 signed plus effective remainder3 keeps raw PROCESSING and both platform/client DELIVERED', async () => {
|
|
const fixture = rootCompletionFixture()
|
|
const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args)
|
|
assert.equal(result.passed, true)
|
|
assert.equal(result.expectedCompleted, false)
|
|
assert.deepEqual(result.pendingChildren.map(row => row.orderId), ['1002'])
|
|
assert.deepEqual(fixture.context.assertions.filter(row => !row.passed), [])
|
|
const proof = fixture.context.createdResources.phase5OverseasFulfillment.orderCompletionProof
|
|
assert.equal(proof.tree.originalPackageCount, 4)
|
|
assert.equal(proof.tree.currentRemainderPackageCount, 3)
|
|
assert.deepEqual(proof.pendingChildOrderIds, ['1002'])
|
|
assert.doesNotMatch(JSON.stringify(proof), /ONLY-IN-MEMORY|password|accessToken|Bearer/)
|
|
})
|
|
|
|
test('missing, foreign, unknown or omitted real child cannot be treated as all fulfillment completed', async () => {
|
|
for (const mutate of [
|
|
fixture => { fixture.child.order = null },
|
|
fixture => { fixture.child.order.customerId = 'FOREIGN' },
|
|
fixture => { fixture.child.order.rootOrderId = 'OTHER' },
|
|
fixture => { fixture.child.order.parentOrderId = 'OTHER' },
|
|
fixture => { fixture.child.order.orderNo = 'WRONG' },
|
|
fixture => { fixture.child.order.status = undefined },
|
|
fixture => { fixture.child.order.status = 'UNKNOWN' },
|
|
fixture => { fixture.tree.children.pop() },
|
|
fixture => { fixture.tree.children = undefined },
|
|
]) {
|
|
const fixture = rootCompletionFixture(); mutate(fixture)
|
|
assert.equal(await verifyFulfillmentRootStatus(fixture.context, fixture.args), null)
|
|
assert.ok(fixture.context.assertions.some(row => !row.passed))
|
|
assert.equal(fixture.context.calls.some(call => call.path.startsWith('/api/merchant/')), false)
|
|
}
|
|
})
|
|
|
|
test('draft, rejected and pending child stay effective, and a premature root COMPLETED still fails', async () => {
|
|
for (const childStatus of ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED', 'PROCESSING']) {
|
|
const fixture = rootCompletionFixture({ childStatus, rootStatus: 'COMPLETED' })
|
|
const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args)
|
|
assert.equal(result.expectedCompleted, false)
|
|
assert.equal(result.passed, false)
|
|
assert.equal(result.pendingChildren.length, 1)
|
|
assert.ok(fixture.context.assertions.some(row => !row.passed))
|
|
}
|
|
})
|
|
|
|
test('actual completed child or explicit CANCELLED permits completion only with root own receipt and raw completion fact', async () => {
|
|
for (const childStatus of ['COMPLETED', 'CANCELLED']) {
|
|
const fixture = rootCompletionFixture({ childStatus, rootStatus: 'COMPLETED', fullyLoaded: childStatus === 'COMPLETED' })
|
|
const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args)
|
|
assert.equal(result.expectedCompleted, true)
|
|
assert.equal(result.passed, true)
|
|
assert.deepEqual(result.pendingChildren, [])
|
|
assert.deepEqual(fixture.context.assertions.filter(row => !row.passed), [])
|
|
}
|
|
const fixture = rootCompletionFixture({ childStatus: 'COMPLETED', fullyLoaded: true, rootDelivered: false })
|
|
const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args)
|
|
assert.equal(result.expectedCompleted, false)
|
|
assert.equal(result.passed, true)
|
|
})
|
|
|
|
test('receipt of a real child resolves and checks its owning root instead of completing the child as the aggregate', async () => {
|
|
const fixture = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true, sourceOrderId: '1002' })
|
|
const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args)
|
|
assert.equal(result.rootOrderId, '1001')
|
|
assert.equal(result.passed, true)
|
|
assert.ok(fixture.context.calls.some(call => call.path === '/api/merchant/v1/orders/1001'))
|
|
assert.equal(fixture.context.calls.some(call => call.path === '/api/merchant/v1/orders/1002'), false)
|
|
})
|
|
|
|
test('all fragments completed still requires raw aggregate history and matching platform/customer completion', async () => {
|
|
const missingHistory = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true })
|
|
missingHistory.root.statusHistory = [{ action: 'OVERSEAS_DELIVERED' }]
|
|
assert.equal((await verifyFulfillmentRootStatus(missingHistory.context, missingHistory.args)).passed, false)
|
|
const notProjected = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true, mainStatus: 'DELIVERED' })
|
|
assert.equal((await verifyFulfillmentRootStatus(notProjected.context, notProjected.args)).passed, false)
|
|
const foreignProjection = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true })
|
|
const originalRequest = foreignProjection.context.request
|
|
foreignProjection.context.request = async (...args) => args[1].startsWith('/api/merchant/')
|
|
? ok({ summary: { orderId: '1002', mainStatus: 'COMPLETED' } }) : originalRequest(...args)
|
|
assert.equal((await verifyFulfillmentRootStatus(foreignProjection.context, foreignProjection.args)).passed, false)
|
|
})
|