test(fleet): preserve unavailable allocations for multi-stop trips

This commit is contained in:
2026-10-03 19:51:29 +08:00
parent e5af1ba758
commit bab967acce
2 changed files with 85 additions and 9 deletions
@@ -4,7 +4,7 @@ import { verifyFleetTripCosts, expectedAllocations, TRIP_COST_EXPECTATIONS } fro
const copy = value => value === undefined ? undefined : structuredClone(value) const copy = value => value === undefined ? undefined : structuredClone(value)
const prefix = 'platform:fleet:trip:' const prefix = 'platform:fleet:trip:'
function fixture(fault) { function fixture(fault, multiDestination = false) {
const trip = { marker: 'fixture-vehicle', tripNo: 'FT-CURRENT' }, calls = [], rows = [], histories = new Map(), keys = new Map() const trip = { marker: 'fixture-vehicle', tripNo: 'FT-CURRENT' }, calls = [], rows = [], histories = new Map(), keys = new Map()
const shares = [ const shares = [
{ businessNo: 'DP-OWN', dispatchNo: 'DP-OWN', sourceType: 'OWN', palletSpaces: 2, warehousePalletIds: ['P1'] }, { businessNo: 'DP-OWN', dispatchNo: 'DP-OWN', sourceType: 'OWN', palletSpaces: 2, warehousePalletIds: ['P1'] },
@@ -12,25 +12,49 @@ function fixture(fault) {
] ]
const inputs = { shares, listOnlyToken: 'offline-reader', deniedToken: 'offline-denied' } const inputs = { shares, listOnlyToken: 'offline-reader', deniedToken: 'offline-denied' }
const depart = { recordVersion: 7, action: 'DEPART', snapshot: { const depart = { recordVersion: 7, action: 'DEPART', snapshot: {
trip: { tripNo: trip.tripNo, departedAt: '2026-10-03T01:00:00Z' }, trip: { tripNo: trip.tripNo, departedAt: '2026-10-03T01:00:00Z',
stops: [{ name: 'Origin' }, { destinationKey: 'FBA:A' }, { destinationKey: 'FBA:B' }] },
confirmedLoad: { manifestHash: 'original-manifest', pallets: [ confirmedLoad: { manifestHash: 'original-manifest', pallets: [
{ warehousePalletId: 'P1', palletSpaces: 2, deliveryIndex: 1 }, { warehousePalletId: 'P2', palletSpaces: 1, deliveryIndex: 1 }], { warehousePalletId: 'P1', palletSpaces: 2, deliveryIndex: 1 }, { warehousePalletId: 'P2', palletSpaces: 1, deliveryIndex: 1 }],
cargo: [{ warehousePalletId: 'P1', dispatchNo: 'DP-OWN', customerId: 'C1', packages: 5 }, cargo: [{ warehousePalletId: 'P1', dispatchNo: 'DP-OWN', customerId: 'C1', packages: 5, destinationKey: 'FBA:A' },
{ warehousePalletId: 'P2', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', packages: 9 }] }, { warehousePalletId: 'P2', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', packages: 9, destinationKey: 'FBA:A' }] },
externalBookings: [{ bookingNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', status: 'BOUND', externalBookings: [{ bookingNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', status: 'BOUND',
receipt: { source: { receivedPackages: 9, pallets: [{ warehousePalletId: 'P2', packages: 9 }] } } }] } } 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 === 'unbound external') depart.snapshot.externalBookings[0].status = 'CONFIRMED'
if (fault === 'wrong stop') depart.snapshot.confirmedLoad.pallets[1].deliveryIndex = 2 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 receipt') depart.snapshot.externalBookings[0].receipt.source.pallets[0].packages = 8
if (fault === 'wrong weights') shares[0].palletSpaces = 3 if (fault === 'wrong weights') shares[0].palletSpaces = 3
function summary() { function summary() {
const unavailable = multiDestination && fault !== 'forced allocation'
return { departureVersion: fault === 'wrong departure' ? 8 : 7, manifestHash: 'original-manifest', return { departureVersion: fault === 'wrong departure' ? 8 : 7, manifestHash: 'original-manifest',
allocationMethod: 'SAME_ROUTE_PALLET_SPACES', allocationBlockReason: null, 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 => { 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) 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. // Hard-coded independently calculated expected cent splits for 2:1 physical spaces.
const own = currency === 'USD' ? 871 : amount === 10003 ? 6669 : 6667 const own = currency === 'USD' ? 871 : amount === 10003 ? 6669 : 6667
const external = currency === 'USD' ? 436 : 3334 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: [ 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: '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', { businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', sourceType: fault === 'external counted own' ? 'OWN' : 'EXTERNAL',
@@ -103,6 +127,38 @@ test('cost expectation uses stable largest remainder and conserves every cent in
assert.equal(TRIP_COST_EXPECTATIONS.finalCny, '100.03') 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', 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', 'lost cent', 'reader is admin', 'denial is outage', 'permission bypass', 'history overwritten', 'history summary drift',
'replay duplicate', 'creation overwrites correction', 'stale accepted', 'noop audit']) { 'replay duplicate', 'creation overwrites correction', 'stale accepted', 'noop audit']) {
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'
const OPTIONS = { scenario: 'fleet-trip-operating-cost', deploymentBlocker: true } const OPTIONS = { scenario: 'fleet-trip-operating-cost', deploymentBlocker: true }
const PERM = 'platform:fleet:trip:' const PERM = 'platform:fleet:trip:'
const AUTH = '/api/platform/v1/iam/authorization/permissions' const AUTH = '/api/platform/v1/iam/authorization/permissions'
const MULTI_STOP_REASON = '各票卸货站不同,暂不可按同路线托位自动分摊'
const clone = value => value === undefined ? undefined : structuredClone(value) const clone = value => value === undefined ? undefined : structuredClone(value)
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
const sorted = rows => [...rows].sort((a, b) => a.businessNo < b.businessNo ? -1 : a.businessNo > b.businessNo ? 1 : 0) const sorted = rows => [...rows].sort((a, b) => a.businessNo < b.businessNo ? -1 : a.businessNo > b.businessNo ? 1 : 0)
@@ -30,7 +31,7 @@ export function expectedAllocations(amount, shares) {
* C calls after real DEPART + replay, before releasing the fixture. No entry registration or IAM mutation. * C calls after real DEPART + replay, before releasing the fixture. No entry registration or IAM mutation.
* tripProof must be the object in createdResources.fleetTrips and fleetAcceptance.departure must match. * tripProof must be the object in createdResources.fleetTrips and fleetAcceptance.departure must match.
* inputs.shares is the caller's independently known OWN dispatch / EXTERNAL booking mapping: * inputs.shares is the caller's independently known OWN dispatch / EXTERNAL booking mapping:
* {businessNo,sourceType,dispatchNo,palletSpaces,warehousePalletIds[]} (same unloading stop). * {businessNo,sourceType,dispatchNo,palletSpaces,warehousePalletIds[]} (may span unloading stops).
* inputs.listOnlyToken/deniedToken are current-run platform sessions owned and cleaned up by the caller. * inputs.listOnlyToken/deniedToken are current-run platform sessions owned and cleaned up by the caller.
* Neither token is persisted. Missing actors/facts fail before cost writes. No automatic restart/cleanup. * Neither token is persisted. Missing actors/facts fail before cost writes. No automatic restart/cleanup.
*/ */
@@ -69,15 +70,22 @@ export async function verifyFleetTripCosts(context, module, tripProof, inputs =
} catch (error) { if (entry.status === 'SENDING') entry.status = 'UNKNOWN_RESULT'; throw error } } catch (error) { if (entry.status === 'SENDING') entry.status = 'UNKNOWN_RESULT'; throw error }
} }
const list = () => request('GET', `${path}?page=1&size=100`, undefined, '读取本趟成本及分摊') const list = () => request('GET', `${path}?page=1&size=100`, undefined, '读取本趟成本及分摊')
let departure let departure, sameRoute
function verifySummary(summary, amounts) { function verifySummary(summary, amounts) {
check('分摊锁定原始DEPART版本和装载摘要', String(summary?.departureVersion) === String(departure.recordVersion) check('分摊锁定原始DEPART版本和装载摘要', String(summary?.departureVersion) === String(departure.recordVersion)
&& summary.manifestHash === departure.snapshot.confirmedLoad.manifestHash && summary.manifestHash === departure.snapshot.confirmedLoad.manifestHash
&& summary.allocationMethod === TRIP_COST_EXPECTATIONS.allocationMethod && summary.allocationBlockReason == null) && (sameRoute
? summary.allocationMethod === TRIP_COST_EXPECTATIONS.allocationMethod && summary.allocationBlockReason == null
: summary.allocationMethod == null && summary.allocationBlockReason === MULTI_STOP_REASON))
check('原币分别汇总,未混币', Array.isArray(summary.totals) && summary.totals.length === Object.keys(amounts).length check('原币分别汇总,未混币', Array.isArray(summary.totals) && summary.totals.length === Object.keys(amounts).length
&& new Set(summary.totals.map(row => row.currency)).size === summary.totals.length) && new Set(summary.totals.map(row => row.currency)).size === summary.totals.length)
for (const row of summary.totals) { for (const row of summary.totals) {
check('整趟成本与确定预期一致', amounts[row.currency] != null && cents(row.directCost) === cents(amounts[row.currency])) check('整趟成本与确定预期一致', amounts[row.currency] != null && cents(row.directCost) === cents(amounts[row.currency]))
if (!sameRoute) {
check('多目的地保留整趟原币成本,分票及自有外接成本未分摊', Array.isArray(row.allocations)
&& row.allocations.length === 0 && row.ownCost == null && row.externalCost == null)
continue
}
const expected = expectedAllocations(amounts[row.currency], shares) const expected = expectedAllocations(amounts[row.currency], shares)
const actual = sorted(row.allocations).map(part => ({ businessNo: part.businessNo, sourceType: part.sourceType, const actual = sorted(row.allocations).map(part => ({ businessNo: part.businessNo, sourceType: part.sourceType,
dispatchNo: part.dispatchNo, palletSpaces: Number(part.palletSpaces), cents: String(cents(part.amount)) })) dispatchNo: part.dispatchNo, palletSpaces: Number(part.palletSpaces), cents: String(cents(part.amount)) }))
@@ -110,8 +118,18 @@ export async function verifyFleetTripCosts(context, module, tripProof, inputs =
const ids = shares.flatMap(row => row.warehousePalletIds).map(String) const ids = shares.flatMap(row => row.warehousePalletIds).map(String)
check('独立预期覆盖全部实物且没有重叠托盘', new Set(ids).size === ids.length check('独立预期覆盖全部实物且没有重叠托盘', new Set(ids).size === ids.length
&& same([...ids].sort(), load.pallets.map(row => String(row.warehousePalletId)).sort()) && same([...ids].sort(), load.pallets.map(row => String(row.warehousePalletId)).sort())
&& new Set(load.pallets.map(row => row.deliveryIndex)).size === 1
&& load.cargo.every(row => ids.includes(String(row.warehousePalletId)))) && load.cargo.every(row => ids.includes(String(row.warehousePalletId))))
const stops = snapshot.trip.stops
check('逐托目的站索引与DEPART货物目的地一致', Array.isArray(stops) && load.pallets.every(pallet => {
const index = pallet.deliveryIndex
const cargo = load.cargo.filter(row => String(row.warehousePalletId) === String(pallet.warehousePalletId))
return Number.isSafeInteger(index) && index > 0 && index < stops.length && cargo.length > 0
&& Boolean(stops[index]?.destinationKey)
&& cargo.every(row => row.destinationKey === stops[index].destinationKey)
}))
sameRoute = new Set(load.pallets.map(row => row.deliveryIndex)).size === 1
Object.assign(proof.expected, { allocationMethod: sameRoute ? TRIP_COST_EXPECTATIONS.allocationMethod : null,
allocationStatus: sameRoute ? 'AVAILABLE' : 'UNAVAILABLE', allocationBlockReason: sameRoute ? null : MULTI_STOP_REASON })
const external = (snapshot.externalBookings || []).filter(row => !['DRAFT', 'CANCELLED'].includes(row.status)) const external = (snapshot.externalBookings || []).filter(row => !['DRAFT', 'CANCELLED'].includes(row.status))
check('有效外接预订全为BOUND且预期完整', external.every(row => row.status === 'BOUND') check('有效外接预订全为BOUND且预期完整', external.every(row => row.status === 'BOUND')
&& same(external.map(row => row.bookingNo).sort(), shares.filter(row => row.sourceType === 'EXTERNAL').map(row => row.businessNo).sort())) && same(external.map(row => row.bookingNo).sort(), shares.filter(row => row.sourceType === 'EXTERNAL').map(row => row.businessNo).sort()))
@@ -179,6 +197,8 @@ export async function verifyFleetTripCosts(context, module, tripProof, inputs =
(proof.histories ??= []).push({ costNo: row.costNo, records: clone(rows) }) (proof.histories ??= []).push({ costNo: row.costNo, records: clone(rows) })
} }
proof.coverage = { currencies: ['CNY', 'USD'], sourceTypes: [...new Set(shares.map(row => row.sourceType))], proof.coverage = { currencies: ['CNY', 'USD'], sourceTypes: [...new Set(shares.map(row => row.sourceType))],
allocation: sameRoute ? 'SAME_ROUTE_CONSERVATION_VERIFIED' : 'MULTI_STOP_UNAVAILABLE_VERIFIED',
convertedCnyStatistics: 'NOT_TESTED_BY_THIS_MODULE',
permissions: 'WRITER_READER_DENIED', customerFinance: 'NOT_TESTED_BY_THIS_MODULE' } permissions: 'WRITER_READER_DENIED', customerFinance: 'NOT_TESTED_BY_THIS_MODULE' }
proof.status = 'VERIFIED'; await context.checkpoint?.(); return proof proof.status = 'VERIFIED'; await context.checkpoint?.(); return proof
} catch (error) { proof.status = 'FAILED'; await context.checkpoint?.(); throw error } } catch (error) { proof.status = 'FAILED'; await context.checkpoint?.(); throw error }