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 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: * {businessNo,sourceType,dispatchNo,palletSpaces,warehousePalletIds[]} (same unloading stop). * 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, '读取本趟成本及分摊') let departure 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) 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])) 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()) && new Set(load.pallets.map(row => row.deliveryIndex)).size === 1 && load.cargo.every(row => ids.includes(String(row.warehousePalletId)))) 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))], 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 } }