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 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 shares = [
{ 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 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: [
{ warehousePalletId: 'P1', palletSpaces: 2, deliveryIndex: 1 }, { warehousePalletId: 'P2', palletSpaces: 1, deliveryIndex: 1 }],
cargo: [{ warehousePalletId: 'P1', dispatchNo: 'DP-OWN', customerId: 'C1', packages: 5 },
{ warehousePalletId: 'P2', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', packages: 9 }] },
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: '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 => {
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',
@@ -103,6 +127,38 @@ test('cost expectation uses stable largest remainder and conserves every cent in
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']) {
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'
const OPTIONS = { scenario: 'fleet-trip-operating-cost', deploymentBlocker: true }
const PERM = 'platform:fleet:trip:'
const AUTH = '/api/platform/v1/iam/authorization/permissions'
const MULTI_STOP_REASON = '各票卸货站不同,暂不可按同路线托位自动分摊'
const clone = value => value === undefined ? undefined : structuredClone(value)
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)
@@ -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.
* 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:
* {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.
* 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 }
}
const list = () => request('GET', `${path}?page=1&size=100`, undefined, '读取本趟成本及分摊')
let departure
let departure, sameRoute
function verifySummary(summary, amounts) {
check('分摊锁定原始DEPART版本和装载摘要', String(summary?.departureVersion) === String(departure.recordVersion)
&& 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
&& new Set(summary.totals.map(row => row.currency)).size === summary.totals.length)
for (const row of summary.totals) {
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 actual = sorted(row.allocations).map(part => ({ businessNo: part.businessNo, sourceType: part.sourceType,
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)
check('独立预期覆盖全部实物且没有重叠托盘', new Set(ids).size === ids.length
&& 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))))
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))
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()))
@@ -179,6 +197,8 @@ export async function verifyFleetTripCosts(context, module, tripProof, inputs =
(proof.histories ??= []).push({ costNo: row.costNo, records: clone(rows) })
}
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' }
proof.status = 'VERIFIED'; await context.checkpoint?.(); return proof
} catch (error) { proof.status = 'FAILED'; await context.checkpoint?.(); throw error }