151 lines
11 KiB
JavaScript
151 lines
11 KiB
JavaScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { verifyFleetExternalReceipt } from './fleet-external-receipt-acceptance.mjs'
|
|
|
|
function fixture(fault = '') {
|
|
const token = 'offline-secret-pda-token'
|
|
const customerId = '900719925474099301'
|
|
const tripProof = { tripNo: 'T-RECEIPT', warehouseId: '92', stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] }
|
|
const baseline = [{ fromIndex: 0, toIndex: 1, occupied: { palletSpaces: 1, weightKg: 100, volumeCbm: 1 },
|
|
remaining: { palletSpaces: 29, weightKg: 17900, volumeCbm: 89 } }]
|
|
const finalSegments = [{ fromIndex: 0, toIndex: 1, occupied: { palletSpaces: 2, weightKg: 200, volumeCbm: 2 },
|
|
remaining: { palletSpaces: 28, weightKg: 17800, volumeCbm: 88 } }]
|
|
const reservedSegments = [{ fromIndex: 0, toIndex: 1, occupied: { palletSpaces: 3, weightKg: 150, volumeCbm: 1.5 },
|
|
remaining: { palletSpaces: 27, weightKg: 17850, volumeCbm: 88.5 } }]
|
|
const calls = [], assertions = [], commands = new Map()
|
|
let tripVersion = 10, booking, pallet, taskStatus = 'WAIT_PDA_RECEIVING', stock = 0, ledgers = []
|
|
const ok = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data: structuredClone(data) }, requestId: 'req', traceId: 'trace' })
|
|
const result = replayed => ok({ tripRecordVersion: tripVersion, booking: { ...booking,
|
|
updatedAt: replayed ? '2026-10-02T21:55:52.334Z' : '2026-10-02T21:55:52.334272005Z' }, replayed })
|
|
const task = () => ({ transferNo: 'WT-NEW', inboundCode: 'WT-NEW', operatingWarehouseId: fault === 'foreign-warehouse' ? '93' : '92',
|
|
status: taskStatus, pallets: pallet ? [pallet] : [] })
|
|
const evidence = () => ({ status: taskStatus, warehouseId: '92', destinationKey: 'FBA:ONT8', incomingNo: 'WT-NEW', inboundCode: 'WT-NEW',
|
|
receiptHash: 'a'.repeat(64), receivedPackages: pallet?.status === 'COMPLETED' ? 1 : 0,
|
|
pallets: pallet?.status === 'COMPLETED' ? [{ warehousePalletId: pallet.warehousePalletId, palletNo: pallet.palletNo, packages: 1, weightKg: 100, volumeCbm: 1 }] : [] })
|
|
const ledger = () => ({ id: 'ledger-1', customerId, overseasWarehouseId: '92', inboundCode: 'WT-NEW',
|
|
palletNo: 'PLT-NEW', businessOrderNo: 'WT-NEW', beforePackages: 0, deltaPackages: 1, afterPackages: 1 })
|
|
const context = { marker: 'TEST-RECEIPT', createdResources: { fleetTrips: [tripProof] },
|
|
assert(module, name, response, passed, details, options) { assertions.push({ name, passed, options }) },
|
|
async requestMultipart(method, path, file, auth) {
|
|
assert.equal(auth, token); assert.equal(file.type, 'image/png'); assert.ok(file.name.includes('TEST-MEASUREMENT'))
|
|
calls.push({ method, path }); return ok({ fileId: fault === 'missing-file' ? null : '901', sizeBytes: file.content.length })
|
|
},
|
|
async request(method, path, body, auth) {
|
|
calls.push({ method, path, body: structuredClone(body) })
|
|
if (path.includes('/inventory/')) {
|
|
const url = new URL(path, 'https://test.invalid')
|
|
assert.equal(url.searchParams.get('customerId'), customerId)
|
|
const balance = { id: '900719925474099900', customerId, overseasWarehouseId: '92', inboundCode: 'WT-NEW', availablePackages: stock,
|
|
holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 }
|
|
if (url.pathname.endsWith('/positions')) return ok([{ id: 'pos-1', balanceId: balance.id, customerId, overseasWarehouseId: '92',
|
|
inboundCode: 'WT-NEW', palletId: '900719925474099311', palletNo: 'PLT-NEW', availablePackages: stock }])
|
|
if (url.pathname.endsWith('/balances/900719925474099900')) return ok({ ...balance, inboundCode: fault === 'wrong-balance-source' ? 'WT-OTHER' : balance.inboundCode })
|
|
assert.equal(url.searchParams.get('overseasWarehouseId'), '92')
|
|
assert.equal(url.searchParams.get('inboundCode'), 'WT-NEW')
|
|
const { inboundCode: omitted, ...group } = balance
|
|
const rows = path.includes('/ledger?') ? ledgers : stock ? [group] : []
|
|
return ok({ records: rows, total: rows.length })
|
|
}
|
|
if (path.endsWith('/capacity-plan')) return ok({ planValid: true, blockReason: null, recordVersion: tripVersion,
|
|
cargo: [{ customerId, destinationKey: 'FBA:ONT8', warehousePalletId: '123' }],
|
|
availableSegments: booking?.status === 'RECEIVED' && fault !== 'keeps-reservation' ? finalSegments : booking?.status === 'CONFIRMED' ? reservedSegments : baseline })
|
|
if (path.startsWith('/api/pda/')) {
|
|
assert.equal(auth, token)
|
|
if (method === 'GET') return ok(task())
|
|
if (commands.has(body.idempotencyKey)) {
|
|
if (fault === 'pda-replay-stock' && path.endsWith('/palletize')) { stock++; ledgers.push({ ...ledger(), id: 'ledger-2' }) }
|
|
return ok(task())
|
|
}
|
|
commands.set(body.idempotencyKey, true)
|
|
if (path.endsWith('/pallets/drafts')) pallet = { id: 'pallet-record', status: 'DRAFT', cartonCount: body.cartonCount }
|
|
else if (path.endsWith('/generate')) Object.assign(pallet, { status: 'GENERATED', warehousePalletId: '900719925474099311', palletNo: 'PLT-NEW' })
|
|
else if (path.endsWith('/bind')) {
|
|
assert.equal(body.palletCode, pallet.palletNo); pallet.status = 'BOUND'
|
|
} else if (path.endsWith('/palletize')) {
|
|
assert.equal(pallet.status, 'BOUND'); assert.deepEqual(body.photoFileIds, ['901'])
|
|
pallet.status = 'COMPLETED'; taskStatus = 'WAIT_CUSTOMER_CONFIRMATION'
|
|
if (fault !== 'no-stock') { stock = 1; ledgers = [ledger()] }
|
|
} else throw new Error(`unexpected PDA path ${path}`)
|
|
return ok(task())
|
|
}
|
|
if (method === 'GET' && path.endsWith('/incoming')) return ok({ tripRecordVersion: tripVersion, recordVersion: booking.recordVersion, incoming: evidence() })
|
|
if (method === 'GET' && path.endsWith('/bookings')) {
|
|
if (fault === 'persisted-read') throw Error('read interrupted after successful confirmation')
|
|
return ok({ tripRecordVersion: tripVersion, records: [{ ...booking, updatedAt: '2026-10-02T21:55:52.334Z' }] })
|
|
}
|
|
if (commands.has(body.idempotencyKey)) return result(true)
|
|
assert.equal(body.tripRecordVersion, tripVersion)
|
|
if (path.endsWith('/bookings')) booking = { bookingNo: 'FB-NEW', customerId, status: 'DRAFT', recordVersion: 0, cargo: body.cargo }
|
|
else if (path.endsWith('/confirm')) { booking.status = 'CONFIRMED'; booking.agreedAmount = 50; booking.recordVersion++ }
|
|
else if (path.endsWith('/incoming')) { booking.incomingNo = 'WT-NEW'; booking.recordVersion++ }
|
|
else if (path.endsWith('/receipt-confirmation')) {
|
|
if (taskStatus === 'WAIT_PDA_RECEIVING') return ok(null, '仓库尚未完成实收及测量')
|
|
assert.equal(body.receiptHash, 'a'.repeat(64)); assert.equal(body.pallets[0].warehousePalletId, pallet.warehousePalletId)
|
|
booking.status = 'RECEIVED'; booking.agreedAmount = fault === 'old-price' ? 50 : 30; booking.recordVersion++
|
|
booking.cargo = { ...booking.cargo, palletSpaces: 1, weightKg: 100, volumeCbm: 1, unitPrice: 30 }
|
|
taskStatus = 'READY_FOR_DISPATCH'; booking.receipt = { source: evidence() }
|
|
if (fault === 'confirmation-stock') { stock++; ledgers.push({ ...ledger(), id: 'ledger-3' }) }
|
|
} else throw new Error(`unexpected platform path ${path}`)
|
|
tripVersion++; commands.set(body.idempotencyKey, true)
|
|
return result(false)
|
|
},
|
|
}
|
|
return { context, tripProof, token, calls, assertions, clearFault() { fault = '' } }
|
|
}
|
|
|
|
test('external receipt proof drives real PDA command chain and checks quantity/price/reservation separately', async () => {
|
|
const f = fixture()
|
|
const proof = await verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token)
|
|
assert.equal(proof.status, 'VERIFIED')
|
|
assert.equal(proof.finalBooking.status, 'RECEIVED')
|
|
assert.equal(proof.finalBooking.agreedAmount, 30)
|
|
assert.equal(proof.finalInventory.ledger.length, 1)
|
|
assert.ok(f.assertions.every(row => row.passed && row.options.deploymentBlocker))
|
|
assert.ok(!JSON.stringify(proof).includes(f.token))
|
|
assert.ok(f.calls.every(row => !/transfer-delivery|departure|cancel/.test(row.path)))
|
|
await assert.rejects(() => verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token), /保留原证据/)
|
|
})
|
|
|
|
test('reviewed palletized sample continues with the original PDA key and never recreates or rereceives cargo', async () => {
|
|
const f = fixture('wrong-balance-source')
|
|
await assert.rejects(() => verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token), /余额与位置确属/)
|
|
const original = structuredClone(f.tripProof.externalReceipt.palletizeRequest)
|
|
const priorCalls = f.calls.length
|
|
assert.equal(f.tripProof.externalReceipt.receiptRequest, undefined)
|
|
f.clearFault()
|
|
const proof = await verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token, { reconcilePalletized: true })
|
|
const writes = f.calls.slice(priorCalls).filter(row => row.method !== 'GET')
|
|
assert.deepEqual(writes.map(row => row.path.split('/').at(-1)), ['palletize', 'receipt-confirmation', 'receipt-confirmation'])
|
|
assert.deepEqual(writes[0].body, original)
|
|
assert.equal(proof.status, 'VERIFIED')
|
|
assert.equal(proof.finalInventory.balances[0].availablePackages, 1)
|
|
assert.equal(proof.finalInventory.positions[0].availablePackages, 1)
|
|
assert.equal(proof.finalInventory.ledger.length, 1)
|
|
assert.equal(proof.finalBooking.status, 'RECEIVED')
|
|
await assert.rejects(() => verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token, { reconcilePalletized: true }), /仅核对/)
|
|
})
|
|
|
|
test('confirmed receipt reconciliation replays only the saved confirmation and preserves persisted timestamp and stock', async () => {
|
|
const f = fixture('persisted-read')
|
|
await assert.rejects(() => verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token), /read interrupted/)
|
|
const request = structuredClone(f.tripProof.externalReceipt.receiptRequest), previous = f.calls.length
|
|
f.clearFault()
|
|
const proof = await verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token, { reconcileConfirmed: true })
|
|
const writes = f.calls.slice(previous).filter(row => row.method !== 'GET')
|
|
assert.equal(writes.length, 2)
|
|
assert.ok(writes.every(row => row.path.endsWith('/receipt-confirmation')))
|
|
for (const row of writes) assert.deepEqual(row.body, request)
|
|
assert.equal(proof.finalInventory.ledger.length, 1)
|
|
assert.equal(proof.finalInventory.balances[0].availablePackages, 1)
|
|
assert.equal(proof.status, 'VERIFIED')
|
|
})
|
|
|
|
for (const fault of ['foreign-warehouse', 'missing-file', 'no-stock', 'pda-replay-stock', 'confirmation-stock', 'keeps-reservation', 'old-price']) {
|
|
test(`external receipt helper blocks ${fault} instead of treating status as inventory proof`, async () => {
|
|
const f = fixture(fault)
|
|
await assert.rejects(() => verifyFleetExternalReceipt(f.context, 'fleet-receipt', f.tripProof, f.token))
|
|
assert.equal(f.tripProof.externalReceipt.status, 'CHECKING')
|
|
assert.ok(f.assertions.some(row => !row.passed))
|
|
})
|
|
}
|