test(fleet): preserve unavailable allocations for multi-stop trips
This commit is contained in:
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user