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
@@ -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 }