192 lines
13 KiB
JavaScript
192 lines
13 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import test from 'node:test'
|
|
import { verifyFleetTripCosts, expectedAllocations, TRIP_COST_EXPECTATIONS } from './fleet-trip-cost-acceptance.mjs'
|
|
|
|
const copy = value => value === undefined ? undefined : structuredClone(value)
|
|
const prefix = 'platform:fleet:trip:'
|
|
function fixture(fault, multiDestination = false) {
|
|
const trip = { marker: 'fixture-vehicle', tripNo: 'FT-CURRENT' }, calls = [], rows = [], histories = new Map(), keys = new Map()
|
|
const shares = [
|
|
{ businessNo: 'DP-OWN', dispatchNo: 'DP-OWN', sourceType: 'OWN', palletSpaces: 2, warehousePalletIds: ['P1'] },
|
|
{ businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', sourceType: 'EXTERNAL', palletSpaces: 1, warehousePalletIds: ['P2'] },
|
|
]
|
|
const inputs = { shares, listOnlyToken: 'offline-reader', deniedToken: 'offline-denied' }
|
|
const depart = { recordVersion: 7, action: 'DEPART', snapshot: {
|
|
trip: { tripNo: trip.tripNo, departedAt: '2026-10-03T01:00:00Z',
|
|
stops: [{ name: 'Origin' }, { destinationKey: 'FBA:A' }, { destinationKey: 'FBA:B' }] },
|
|
confirmedLoad: { manifestHash: 'original-manifest', pallets: [
|
|
{ warehousePalletId: 'P1', palletSpaces: 2, deliveryIndex: 1 }, { warehousePalletId: 'P2', palletSpaces: 1, deliveryIndex: 1 }],
|
|
cargo: [{ warehousePalletId: 'P1', dispatchNo: 'DP-OWN', customerId: 'C1', packages: 5, destinationKey: 'FBA:A' },
|
|
{ warehousePalletId: 'P2', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', packages: 9, destinationKey: 'FBA:A' }] },
|
|
externalBookings: [{ bookingNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', status: 'BOUND',
|
|
receipt: { source: { receivedPackages: 9, pallets: [{ warehousePalletId: 'P2', packages: 9 }] } } }] } }
|
|
const load = depart.snapshot.confirmedLoad
|
|
if (multiDestination) {
|
|
shares[0].warehousePalletIds.push('P3')
|
|
load.pallets[0].palletSpaces = 1
|
|
load.pallets.push({ warehousePalletId: 'P3', palletSpaces: 1, deliveryIndex: 2 })
|
|
load.cargo[0].packages = 1
|
|
load.cargo.push({ warehousePalletId: 'P3', dispatchNo: 'DP-OWN', customerId: 'C1', packages: 4, destinationKey: 'FBA:B' })
|
|
}
|
|
if (fault === 'duplicate pallet') load.pallets.push(copy(load.pallets[0]))
|
|
if (fault === 'overlapping shares') shares[1].warehousePalletIds.push('P1')
|
|
if (fault === 'missing pallet') load.pallets.pop()
|
|
if (fault === 'uncovered cargo') load.cargo.push({ ...load.cargo[0], warehousePalletId: 'UNKNOWN' })
|
|
if (fault === 'mixed pallet destinations') load.cargo.push({ ...load.cargo[0], destinationKey: 'FBA:B' })
|
|
if (fault === 'origin stop') load.pallets[0].deliveryIndex = 0
|
|
if (fault === 'out of range stop') load.pallets[0].deliveryIndex = 3
|
|
if (fault === 'fractional stop') load.pallets[0].deliveryIndex = 1.5
|
|
if (fault === 'missing destination') delete load.cargo[0].destinationKey
|
|
if (fault === 'missing stops') delete depart.snapshot.trip.stops
|
|
if (fault === 'unbound external') depart.snapshot.externalBookings[0].status = 'CONFIRMED'
|
|
if (fault === 'wrong stop') depart.snapshot.confirmedLoad.pallets[1].deliveryIndex = 2
|
|
if (fault === 'wrong receipt') depart.snapshot.externalBookings[0].receipt.source.pallets[0].packages = 8
|
|
if (fault === 'wrong weights') shares[0].palletSpaces = 3
|
|
function summary() {
|
|
const unavailable = multiDestination && fault !== 'forced allocation'
|
|
return { departureVersion: fault === 'wrong departure' ? 8 : 7, manifestHash: 'original-manifest',
|
|
allocationMethod: unavailable ? null : 'SAME_ROUTE_PALLET_SPACES',
|
|
allocationBlockReason: unavailable ? fault === 'wrong block reason' ? 'other' : '各票卸货站不同,暂不可按同路线托位自动分摊' : null,
|
|
totals: ['CNY', 'USD'].filter(currency => rows.some(row => row.details.currency === currency)).map(currency => {
|
|
const amount = rows.filter(row => row.details.currency === currency).reduce((sum, row) => sum + Math.round(Number(row.details.amount) * 100), 0)
|
|
// Hard-coded independently calculated expected cent splits for 2:1 physical spaces.
|
|
const own = currency === 'USD' ? 871 : amount === 10003 ? 6669 : 6667
|
|
const external = currency === 'USD' ? 436 : 3334
|
|
if (unavailable) return { currency, directCost: (amount + (fault === 'wrong direct total' ? 1 : 0)) / 100,
|
|
ownCost: fault === 'zero instead of unavailable' ? 0 : null, externalCost: null,
|
|
allocations: fault === 'invented allocation' ? [{ businessNo: 'DP-OWN', amount: amount / 100 }] : [] }
|
|
return { currency, directCost: amount / 100, ownCost: own / 100, externalCost: external / 100, allocations: [
|
|
{ businessNo: 'DP-OWN', dispatchNo: 'DP-OWN', sourceType: 'OWN', palletSpaces: 2, amount: own / 100 },
|
|
{ businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', sourceType: fault === 'external counted own' ? 'OWN' : 'EXTERNAL',
|
|
palletSpaces: 1, amount: (external + (fault === 'lost cent' ? 1 : 0)) / 100 }] }
|
|
}) }
|
|
}
|
|
const response = (data, code = '200', http = 200) => ({ http, body: { code, data: copy(data) }, requestId: 'request', traceId: 'trace' })
|
|
const listing = () => ({ page: { records: copy(rows), total: rows.length, page: 1, size: 100 }, summary: summary() })
|
|
function audit(row, action) {
|
|
const history = histories.get(row.costNo) || []
|
|
history.push({ recordVersion: row.recordVersion, action, reason: 'controlled audit', occurredAt: new Date().toISOString(),
|
|
snapshot: { cost: copy(row), summary: summary() } })
|
|
histories.set(row.costNo, history)
|
|
}
|
|
const context = { marker: 'CURRENT-RUN', token: 'offline-writer', createdResources: { fleetTrips: [trip],
|
|
fleetAcceptance: { marker: 'CURRENT-RUN', tripNo: trip.tripNo,
|
|
departure: { tripNo: trip.tripNo, status: 'IN_TRANSIT', departedAt: depart.snapshot.trip.departedAt } } },
|
|
assert(_module, label, _response, condition) { assert.ok(condition, label) }, async checkpoint() {},
|
|
async request(method, path, body, token) {
|
|
calls.push({ method, path, body: copy(body), actor: token })
|
|
if (path.endsWith('/authorization/permissions')) return response((token === 'offline-writer' || fault === 'reader is admin'
|
|
? ['list', 'cost-create', 'cost-edit'] : token === 'offline-reader' ? ['list'] : []).map(name => prefix + name))
|
|
const denied = token === 'offline-denied' || (token === 'offline-reader' && method !== 'GET')
|
|
if (denied && fault !== 'permission bypass') return fault === 'denial is outage' ? response(null, 'S0503', 503) : response(null, 'A0403', 403)
|
|
if (method === 'GET' && path.includes('/history') && !path.includes('/costs')) return response({ records: [depart], total: 1 })
|
|
if (method === 'GET' && path.includes('/history')) {
|
|
const costNo = path.split('/costs/')[1].split('/')[0], history = copy(histories.get(costNo))
|
|
if (fault === 'history overwritten') history[0].snapshot.cost.details.amount = '100.03'
|
|
if (fault === 'history summary drift') history[0].snapshot.summary.totals[0].directCost = 999
|
|
return response({ records: history, total: history.length })
|
|
}
|
|
if (method === 'GET') return response(listing())
|
|
if (method === 'POST') {
|
|
const saved = keys.get(body.idempotencyKey)
|
|
if (saved) {
|
|
if (JSON.stringify(saved.details) !== JSON.stringify(body.details)) return response(null, 'A0409')
|
|
if (fault === 'replay duplicate') { const duplicate = { ...copy(saved.row), costNo: `TC-${rows.length + 1}` }; rows.push(duplicate); return response(duplicate) }
|
|
if (fault === 'creation overwrites correction') saved.row.details = copy(body.details)
|
|
return response(saved.row)
|
|
}
|
|
const row = { costNo: `TC-${rows.length + 1}`, tripNo: trip.tripNo, recordVersion: 0, details: copy(body.details) }
|
|
rows.push(row); keys.set(body.idempotencyKey, { details: copy(body.details), row }); audit(row, 'CREATE'); return response(row)
|
|
}
|
|
assert.equal(method, 'PUT')
|
|
const row = rows.find(item => path.endsWith(item.costNo)); assert.ok(row)
|
|
if (Number(body.recordVersion) !== row.recordVersion && fault !== 'stale accepted') return response(null, 'A0409')
|
|
if (JSON.stringify(row.details) === JSON.stringify(body.details) && fault !== 'noop audit') return response(row)
|
|
row.details = copy(body.details); row.recordVersion++; audit(row, 'UPDATE'); return response(row)
|
|
} }
|
|
return { context, trip, inputs, calls, rows }
|
|
}
|
|
|
|
test('physical DEPART costs include original currencies, correction, immutable history and three permission actors', async () => {
|
|
const f = fixture(), proof = await verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs)
|
|
assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.expected.finalCny, '100.03')
|
|
assert.equal(proof.final.summary.totals.find(row => row.currency === 'CNY').ownCost, 66.69)
|
|
assert.deepEqual(proof.coverage.sourceTypes, ['OWN', 'EXTERNAL'])
|
|
assert.ok(f.calls.every(call => call.path.includes('/fleet/trips/FT-CURRENT') || call.path.endsWith('/authorization/permissions')))
|
|
assert.ok(!JSON.stringify(proof).includes('offline-reader')); assert.ok(!JSON.stringify(proof).includes('offline-denied'))
|
|
assert.ok(proof.requests.filter(row => row.method !== 'GET').every(row => row.body))
|
|
const count = f.calls.length
|
|
await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs), /重复开始/)
|
|
assert.equal(f.calls.length, count)
|
|
})
|
|
|
|
test('cost expectation uses stable largest remainder and conserves every cent including tie and zero', () => {
|
|
const shares = ['B', 'A', 'C'].map(businessNo => ({ businessNo, sourceType: 'OWN', dispatchNo: businessNo, palletSpaces: 1 }))
|
|
assert.deepEqual(expectedAllocations('0.02', shares).map(row => [row.businessNo, row.cents]), [['A', '1'], ['B', '1'], ['C', '0']])
|
|
assert.deepEqual(expectedAllocations('0', shares).map(row => row.cents), ['0', '0', '0'])
|
|
assert.equal(TRIP_COST_EXPECTATIONS.finalCny, '100.03')
|
|
})
|
|
|
|
test('multi-destination records and corrects whole-trip costs while allocations remain unavailable', async () => {
|
|
const f = fixture(undefined, true), proof = await verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs)
|
|
assert.equal(proof.status, 'VERIFIED')
|
|
assert.deepEqual(proof.departure.snapshot.confirmedLoad.pallets.map(row => row.deliveryIndex), [1, 1, 2])
|
|
assert.deepEqual(proof.final.summary.totals.map(row => [row.currency, row.directCost, row.ownCost, row.externalCost]),
|
|
[['CNY', 100.03, null, null], ['USD', 13.07, null, null]])
|
|
assert.ok(proof.final.summary.totals.every(row => row.allocations.length === 0))
|
|
assert.equal(proof.expected.allocationStatus, 'UNAVAILABLE')
|
|
assert.equal(proof.expected.allocationMethod, null)
|
|
assert.equal(proof.coverage.allocation, 'MULTI_STOP_UNAVAILABLE_VERIFIED')
|
|
assert.equal(proof.coverage.convertedCnyStatistics, 'NOT_TESTED_BY_THIS_MODULE')
|
|
})
|
|
|
|
for (const fault of ['forced allocation', 'wrong block reason', 'wrong direct total', 'zero instead of unavailable', 'invented allocation']) {
|
|
test(`multi-destination rejects ${fault}`, async () => {
|
|
const f = fixture(fault, true)
|
|
await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs))
|
|
assert.equal(f.trip.costAcceptance.status, 'FAILED')
|
|
})
|
|
}
|
|
|
|
for (const fault of ['duplicate pallet', 'overlapping shares', 'missing pallet', 'uncovered cargo',
|
|
'mixed pallet destinations', 'origin stop', 'out of range stop', 'fractional stop', 'missing destination', 'missing stops', 'wrong stop']) {
|
|
test(`multi-destination ${fault} fails before any cost write`, async () => {
|
|
const f = fixture(fault, true)
|
|
await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs))
|
|
assert.equal(f.trip.costAcceptance.status, 'FAILED')
|
|
assert.ok(f.calls.every(call => call.method === 'GET'))
|
|
assert.equal(f.rows.length, 0)
|
|
})
|
|
}
|
|
|
|
for (const fault of ['unbound external', 'wrong stop', 'wrong receipt', 'wrong weights', 'wrong departure', 'external counted own',
|
|
'lost cent', 'reader is admin', 'denial is outage', 'permission bypass', 'history overwritten', 'history summary drift',
|
|
'replay duplicate', 'creation overwrites correction', 'stale accepted', 'noop audit']) {
|
|
test(`rejects ${fault} and preserves evidence without cleanup`, async () => {
|
|
const f = fixture(fault)
|
|
await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs))
|
|
assert.equal(f.trip.costAcceptance.status, 'FAILED')
|
|
assert.ok(f.calls.every(call => call.method !== 'DELETE'))
|
|
})
|
|
}
|
|
|
|
test('missing current-run membership or permission session fails before HTTP', async () => {
|
|
for (const mutate of [f => { f.inputs.deniedToken = '' }, f => { f.context.createdResources.fleetTrips = [] },
|
|
f => { f.context.createdResources.fleetAcceptance.marker = 'OLD' }, f => { f.inputs.shares = [] }]) {
|
|
const f = fixture(); mutate(f)
|
|
await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs)); assert.equal(f.calls.length, 0)
|
|
}
|
|
})
|
|
|
|
test('unknown HTTP outcome retains original key and does not retry or hide failure', async () => {
|
|
const f = fixture(), original = f.context.request
|
|
f.context.request = async (...args) => {
|
|
if (args[0] === 'POST' && args[3] === f.context.token) throw new Error('controlled network loss')
|
|
return original(...args)
|
|
}
|
|
await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs), /network loss/)
|
|
const entry = f.trip.costAcceptance.requests.at(-1)
|
|
assert.equal(entry.status, 'UNKNOWN_RESULT'); assert.ok(entry.body.idempotencyKey)
|
|
assert.equal(f.rows.length, 0)
|
|
})
|