2026-10-03 14:52:04 +08:00
|
|
|
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'
|
2026-10-03 19:51:29 +08:00
|
|
|
const MULTI_STOP_REASON = '各票卸货站不同,暂不可按同路线托位自动分摊'
|
2026-10-03 14:52:04 +08:00
|
|
|
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)
|
|
|
|
|
const cents = value => {
|
|
|
|
|
const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(String(value))
|
|
|
|
|
if (!match) throw new Error('成本金额不是非负两位小数')
|
|
|
|
|
return BigInt(match[1]) * 100n + BigInt((match[2] || '').padEnd(2, '0'))
|
|
|
|
|
}
|
|
|
|
|
export const TRIP_COST_EXPECTATIONS = Object.freeze({ initialCny: '100.01', finalCny: '100.03', usd: '13.07', rows: 2,
|
|
|
|
|
history: { CNY: ['CREATE', 'UPDATE'], USD: ['CREATE'] }, allocationMethod: 'SAME_ROUTE_PALLET_SPACES' })
|
|
|
|
|
|
|
|
|
|
/** Integer-cent oracle for the documented largest-remainder rule; does not consume API allocations. */
|
|
|
|
|
export function expectedAllocations(amount, shares) {
|
|
|
|
|
const total = shares.reduce((sum, row) => sum + BigInt(row.palletSpaces), 0n)
|
|
|
|
|
const rows = shares.map(row => ({ ...row, value: cents(amount) * BigInt(row.palletSpaces) / total,
|
|
|
|
|
remainder: cents(amount) * BigInt(row.palletSpaces) % total }))
|
|
|
|
|
rows.sort((a, b) => a.remainder > b.remainder ? -1 : a.remainder < b.remainder ? 1 : a.businessNo < b.businessNo ? -1 : 1)
|
|
|
|
|
let rest = cents(amount) - rows.reduce((sum, row) => sum + row.value, 0n)
|
|
|
|
|
for (const row of rows) if (rest > 0n) { row.value++; rest-- }
|
|
|
|
|
return sorted(rows).map(row => ({ businessNo: row.businessNo, sourceType: row.sourceType, dispatchNo: row.dispatchNo,
|
|
|
|
|
palletSpaces: row.palletSpaces, cents: String(row.value) }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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:
|
2026-10-03 19:51:29 +08:00
|
|
|
* {businessNo,sourceType,dispatchNo,palletSpaces,warehousePalletIds[]} (may span unloading stops).
|
2026-10-03 14:52:04 +08:00
|
|
|
* 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.
|
|
|
|
|
*/
|
|
|
|
|
export async function verifyFleetTripCosts(context, module, tripProof, inputs = {}) {
|
|
|
|
|
const run = context.createdResources?.fleetAcceptance, shares = inputs.shares
|
|
|
|
|
if (!context.marker || !context.createdResources?.fleetTrips?.includes(tripProof) || !tripProof?.tripNo || !tripProof.marker
|
|
|
|
|
|| run?.marker !== context.marker || run.tripNo !== tripProof.tripNo || run.departure?.tripNo !== tripProof.tripNo
|
|
|
|
|
|| run.departure.status !== 'IN_TRANSIT' || !run.departure.departedAt || tripProof.costAcceptance
|
|
|
|
|
|| !inputs.listOnlyToken || !inputs.deniedToken || inputs.listOnlyToken === inputs.deniedToken
|
|
|
|
|
|| !Array.isArray(shares) || !shares.length || new Set(shares.map(row => row.businessNo)).size !== shares.length
|
|
|
|
|
|| shares.some(row => !row.businessNo || !row.dispatchNo || !['OWN', 'EXTERNAL'].includes(row.sourceType)
|
|
|
|
|
|| !Number.isSafeInteger(row.palletSpaces) || row.palletSpaces < 1 || !Array.isArray(row.warehousePalletIds)
|
|
|
|
|
|| !row.warehousePalletIds.length || row.warehousePalletIds.some(id => !id)))
|
|
|
|
|
throw new Error('车次成本验收须提供本轮唯一真实DEPART车次、独立托位预期及两类权限会话;禁止借旧样本或重复开始')
|
|
|
|
|
const proof = tripProof.costAcceptance = { marker: context.marker, status: 'CHECKING', tripNo: tripProof.tripNo,
|
|
|
|
|
expected: clone(TRIP_COST_EXPECTATIONS), shares: clone(shares), requests: [], costs: [] }
|
|
|
|
|
const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`, path = `${base}/costs`
|
|
|
|
|
const check = (label, condition, response) => {
|
|
|
|
|
context.assert(module, label, response, Boolean(condition), '', OPTIONS)
|
|
|
|
|
if (!condition) throw new Error(`${module}: ${label}`)
|
|
|
|
|
}
|
|
|
|
|
async function request(method, url, body, label, actor = 'writer', rejectCode) {
|
|
|
|
|
const entry = { method, path: url, body: clone(body), label, actor, status: 'SENDING' }
|
|
|
|
|
proof.requests.push(entry); await context.checkpoint?.()
|
|
|
|
|
try {
|
|
|
|
|
const token = actor === 'reader' ? inputs.listOnlyToken : actor === 'denied' ? inputs.deniedToken : context.token
|
|
|
|
|
const response = await context.request(method, url, body, token)
|
|
|
|
|
Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code,
|
|
|
|
|
requestId: response?.requestId, traceId: response?.traceId })
|
|
|
|
|
await context.checkpoint?.()
|
|
|
|
|
const accepted = rejectCode === 'FORBIDDEN' ? [200, 403].includes(response?.http) && String(response?.body?.code) === 'A0403'
|
|
|
|
|
: rejectCode ? response?.http === 200 && String(response?.body?.code) === rejectCode
|
|
|
|
|
: response?.http === 200 && String(response?.body?.code) === '200'
|
|
|
|
|
check(label, accepted && response?.requestId && response?.traceId, response)
|
|
|
|
|
return response.body?.data
|
|
|
|
|
} catch (error) { if (entry.status === 'SENDING') entry.status = 'UNKNOWN_RESULT'; throw error }
|
|
|
|
|
}
|
|
|
|
|
const list = () => request('GET', `${path}?page=1&size=100`, undefined, '读取本趟成本及分摊')
|
2026-10-03 19:51:29 +08:00
|
|
|
let departure, sameRoute
|
2026-10-03 14:52:04 +08:00
|
|
|
function verifySummary(summary, amounts) {
|
|
|
|
|
check('分摊锁定原始DEPART版本和装载摘要', String(summary?.departureVersion) === String(departure.recordVersion)
|
|
|
|
|
&& summary.manifestHash === departure.snapshot.confirmedLoad.manifestHash
|
2026-10-03 19:51:29 +08:00
|
|
|
&& (sameRoute
|
|
|
|
|
? summary.allocationMethod === TRIP_COST_EXPECTATIONS.allocationMethod && summary.allocationBlockReason == null
|
|
|
|
|
: summary.allocationMethod == null && summary.allocationBlockReason === MULTI_STOP_REASON))
|
2026-10-03 14:52:04 +08:00
|
|
|
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]))
|
2026-10-03 19:51:29 +08:00
|
|
|
if (!sameRoute) {
|
|
|
|
|
check('多目的地保留整趟原币成本,分票及自有外接成本未分摊', Array.isArray(row.allocations)
|
|
|
|
|
&& row.allocations.length === 0 && row.ownCost == null && row.externalCost == null)
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-10-03 14:52:04 +08:00
|
|
|
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)) }))
|
|
|
|
|
check('逐票分摊及自有/外接归属准确且分币守恒', same(actual, expected)
|
|
|
|
|
&& cents(row.ownCost) === expected.filter(part => part.sourceType === 'OWN').reduce((sum, part) => sum + BigInt(part.cents), 0n)
|
|
|
|
|
&& cents(row.externalCost) === expected.filter(part => part.sourceType === 'EXTERNAL').reduce((sum, part) => sum + BigInt(part.cents), 0n))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
function verifyCost(row, details, costNo, version) {
|
|
|
|
|
check('成本业务号、车次、版本和完整内容一致', row?.costNo && (!costNo || row.costNo === costNo) && row.tripNo === tripProof.tripNo
|
|
|
|
|
&& Number(row.recordVersion) === version && row.details?.category === details.category && row.details.currency === details.currency
|
|
|
|
|
&& cents(row.details.amount) === cents(details.amount) && Date.parse(row.details.incurredAt) === Date.parse(details.incurredAt)
|
|
|
|
|
&& row.details.remark === details.remark)
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
for (const actor of ['writer', 'reader', 'denied']) {
|
|
|
|
|
const permissions = await request('GET', AUTH, undefined, `核对${actor}实际权限`, actor)
|
|
|
|
|
check('权限身份准确,不能用管理员冒充拒绝样本', Array.isArray(permissions) && (actor === 'writer'
|
|
|
|
|
? ['list', 'cost-create', 'cost-edit'].every(name => permissions.includes(PERM + name))
|
|
|
|
|
: (permissions.includes(PERM + 'list') === (actor === 'reader'))
|
|
|
|
|
&& !permissions.includes(PERM + 'cost-create') && !permissions.includes(PERM + 'cost-edit')))
|
|
|
|
|
}
|
|
|
|
|
const history = await request('GET', `${base}/history?page=1&size=100`, undefined, '读取原DEPART不可变实物依据')
|
|
|
|
|
check('历史完整且DEPART唯一', Array.isArray(history?.records) && Number(history.total) === history.records.length
|
|
|
|
|
&& history.records.filter(row => row.action === 'DEPART').length === 1)
|
|
|
|
|
departure = history.records.find(row => row.action === 'DEPART'); proof.departure = clone(departure)
|
|
|
|
|
const snapshot = departure.snapshot, load = snapshot?.confirmedLoad
|
|
|
|
|
check('发车实物与本轮车次绑定', snapshot?.trip?.tripNo === tripProof.tripNo && snapshot.trip.departedAt === run.departure.departedAt
|
|
|
|
|
&& load?.manifestHash && load.pallets?.length > 0 && load.cargo?.length > 0)
|
|
|
|
|
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())
|
|
|
|
|
&& load.cargo.every(row => ids.includes(String(row.warehousePalletId))))
|
2026-10-03 19:51:29 +08:00
|
|
|
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 })
|
2026-10-03 14:52:04 +08:00
|
|
|
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()))
|
|
|
|
|
for (const share of shares) {
|
|
|
|
|
const ownedIds = share.warehousePalletIds.map(String), cargo = load.cargo.filter(row => ownedIds.includes(String(row.warehousePalletId)))
|
|
|
|
|
check('预期托位来自DEPART实测并对应排车', share.palletSpaces === load.pallets.filter(row => ownedIds.includes(String(row.warehousePalletId)))
|
|
|
|
|
.reduce((sum, row) => sum + Number(row.palletSpaces), 0) && cargo.length > 0 && cargo.every(row => row.dispatchNo === share.dispatchNo)
|
|
|
|
|
&& ownedIds.every(id => cargo.some(row => String(row.warehousePalletId) === id)))
|
|
|
|
|
if (share.sourceType === 'OWN') check('自有货使用排车业务号', share.businessNo === share.dispatchNo)
|
|
|
|
|
else {
|
|
|
|
|
const booking = external.find(row => row.bookingNo === share.businessNo), receipt = booking?.receipt?.source
|
|
|
|
|
check('外接分摊来源为真实绑定及实收', booking?.dispatchNo === share.dispatchNo && receipt?.pallets?.length > 0
|
|
|
|
|
&& same(receipt.pallets.map(row => String(row.warehousePalletId)).sort(), [...ownedIds].sort())
|
|
|
|
|
&& cargo.every(row => String(row.customerId) === String(booking.customerId))
|
|
|
|
|
&& Number(receipt.receivedPackages) === cargo.reduce((sum, row) => sum + Number(row.packages), 0)
|
|
|
|
|
&& receipt.pallets.every(pallet => Number(pallet.packages) === cargo.filter(row => String(row.warehousePalletId) === String(pallet.warehousePalletId))
|
|
|
|
|
.reduce((sum, row) => sum + Number(row.packages), 0)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const before = await list()
|
|
|
|
|
check('专用车次尚无成本,未知不冒充零', Number(before?.page?.total) === 0 && before.page.records.length === 0 && before.summary?.totals?.length === 0)
|
|
|
|
|
verifySummary(before.summary, {})
|
|
|
|
|
const initial = { category: 'FUEL', currency: 'CNY', amount: TRIP_COST_EXPECTATIONS.initialCny,
|
|
|
|
|
incurredAt: new Date(Date.now() - 60000).toISOString(), remark: `${context.marker} 车次成本验收` }
|
|
|
|
|
const create = { idempotencyKey: `trip-cost-${randomUUID()}`, details: initial }
|
|
|
|
|
await request('POST', path, create, '列表权限不能录入成本', 'reader', 'FORBIDDEN')
|
|
|
|
|
await request('GET', `${path}?page=1&size=100`, undefined, '无列表权限不能读取成本', 'denied', 'FORBIDDEN')
|
|
|
|
|
const first = await request('POST', path, create, '录入CNY原币运营成本'); verifyCost(first, initial, null, 0)
|
|
|
|
|
proof.costs.push(clone(first))
|
|
|
|
|
verifyCost(await request('POST', path, create, '同键重放不重复记成本'), initial, first.costNo, 0)
|
|
|
|
|
await request('POST', path, { ...create, details: { ...initial, amount: '100.02' } }, '同键异载荷拒绝', 'writer', 'A0409')
|
|
|
|
|
const usdDetails = { ...initial, category: 'TOLL', currency: 'USD', amount: TRIP_COST_EXPECTATIONS.usd }
|
|
|
|
|
const usd = await request('POST', path, { idempotencyKey: `trip-cost-${randomUUID()}`, details: usdDetails }, '录入USD原币运营成本')
|
|
|
|
|
verifyCost(usd, usdDetails, null, 0); proof.costs.push(clone(usd))
|
|
|
|
|
verifySummary((await list()).summary, { CNY: '100.01', USD: '13.07' })
|
|
|
|
|
const costPath = `${path}/${encodeURIComponent(first.costNo)}`, corrected = { ...initial, amount: TRIP_COST_EXPECTATIONS.finalCny }
|
|
|
|
|
const edit = { recordVersion: first.recordVersion, details: corrected, reason: `${context.marker} 成本更正` }
|
|
|
|
|
await request('PUT', costPath, edit, '列表权限不能更正成本', 'reader', 'FORBIDDEN')
|
|
|
|
|
const changed = await request('PUT', costPath, edit, '更正CNY成本'); verifyCost(changed, corrected, first.costNo, 1)
|
|
|
|
|
await request('PUT', costPath, edit, '旧版本即使内容相同仍拒绝', 'writer', 'A0409')
|
|
|
|
|
verifyCost(await request('PUT', costPath, { ...edit, recordVersion: changed.recordVersion }, '无内容更改不追加历史'), corrected, first.costNo, 1)
|
|
|
|
|
verifyCost(await request('POST', path, create, '原创建键重放保留后续更正'), corrected, first.costNo, 1)
|
|
|
|
|
const final = await list(); proof.final = clone(final)
|
|
|
|
|
check('两币种仅有两条成本,无权限及冲突请求不落库', Number(final.page?.total) === 2 && final.page.records.length === 2
|
|
|
|
|
&& new Set(final.page.records.map(row => row.costNo)).size === 2)
|
|
|
|
|
verifyCost(final.page.records.find(row => row.costNo === first.costNo), corrected, first.costNo, 1)
|
|
|
|
|
verifyCost(final.page.records.find(row => row.costNo === usd.costNo), usdDetails, usd.costNo, 0)
|
|
|
|
|
verifySummary(final.summary, { CNY: '100.03', USD: '13.07' })
|
|
|
|
|
const reader = await request('GET', `${path}?page=1&size=100`, undefined, '列表权限可读取全部本趟成本', 'reader')
|
|
|
|
|
check('只读结果一致', same(reader, final))
|
|
|
|
|
for (const row of [first, usd]) {
|
|
|
|
|
const historyPath = `${path}/${encodeURIComponent(row.costNo)}/history?page=1&size=100`
|
|
|
|
|
const history = await request('GET', historyPath, undefined, '列表权限可查成本历史', 'reader')
|
|
|
|
|
await request('GET', historyPath, undefined, '无列表权限不可查成本历史', 'denied', 'FORBIDDEN')
|
|
|
|
|
const rows = [...(history?.records || [])].sort((a, b) => Number(a.recordVersion) - Number(b.recordVersion)), isCny = row === first
|
|
|
|
|
check('历史动作与数量精确,无重放/无变化冗余', Number(history.total) === rows.length
|
|
|
|
|
&& same(rows.map(item => item.action), isCny ? ['CREATE', 'UPDATE'] : ['CREATE']))
|
|
|
|
|
for (let index = 0; index < rows.length; index++) {
|
|
|
|
|
const item = rows[index]
|
|
|
|
|
check('历史记录版本与原因完整', Number(item.recordVersion) === index && Boolean(item.reason) && Number.isFinite(Date.parse(item.occurredAt)))
|
|
|
|
|
verifyCost(item.snapshot?.cost, isCny ? index === 0 ? initial : corrected : usdDetails, row.costNo, index)
|
|
|
|
|
verifySummary(item.snapshot.summary, isCny && index === 0 ? { CNY: '100.01' }
|
|
|
|
|
: { CNY: isCny ? '100.03' : '100.01', USD: '13.07' })
|
|
|
|
|
}
|
|
|
|
|
(proof.histories ??= []).push({ costNo: row.costNo, records: clone(rows) })
|
|
|
|
|
}
|
|
|
|
|
proof.coverage = { currencies: ['CNY', 'USD'], sourceTypes: [...new Set(shares.map(row => row.sourceType))],
|
2026-10-03 19:51:29 +08:00
|
|
|
allocation: sameRoute ? 'SAME_ROUTE_CONSERVATION_VERIFIED' : 'MULTI_STOP_UNAVAILABLE_VERIFIED',
|
|
|
|
|
convertedCnyStatistics: 'NOT_TESTED_BY_THIS_MODULE',
|
2026-10-03 14:52:04 +08:00
|
|
|
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 }
|
|
|
|
|
}
|