test(fleet): persist independent acceptance requests and current-trip evidence
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
# Admin 测试环境 HTTP 模块
|
||||
|
||||
`fleet-handover-acceptance.mjs` 是车辆专项实际DEPART后的内部交接助手,由`../fleet-test-env-acceptance.mjs`调用;仅接受本轮已验证车次/派送及自有PDA会话,核对原DEPART/费用不变、真实剩余货物和接替资源、同键重放及旧资源分别释放。调用方必须注入发送前持久化检查点,落盘失败不发送HTTP;仅保存脱敏原命令及附件摘要。签收前导出本轮`handover-fixture.json`供页面/运营统计检查,成本验证与账号清理由同一入口编排;失败保留证据,不借旧车次或自动续跑。对应`fleet-handover-acceptance-contract.test.mjs`为离线合同。
|
||||
|
||||
`fleet-quotes-operations.mjs` 的`runQuoteSession`使用独立TEST登录及本轮客户准备,`run`只验证报价增改/状态/历史及原子CSV;`verifyOperations`另需当前轮DEPART的独立托位/货量/原币成本期望,报价通过不代表运营统计通过。后者默认只读,实测更正必须显式传入写入开关和独立幂等命名空间;不推算财务实付。由专项编排器提供持久化检查点和独占报告目录,原请求未知时不自动重造样本,结束退出自身会话。对应`fleet-quotes-operations-contract.test.mjs`不访问真实环境。
|
||||
|
||||
`fleet-trip-cost-acceptance.mjs` 是同轮实际发车后的车次直接成本内部验收助手,由车辆专项入口显式调用 `verifyFleetTripCosts(context, module, tripProof, inputs)`,不独立造车次、不修改IAM。调用方须提供真实DEPART、独立确认的自有/外接逐票托盘及托位预期、仅列表权限和无车次权限两个独立会话,并负责退出;缺前置在成本写入前停止。写入本车次两条成本并更正,最终CNY100.03、USD13.07分别按整数分和真实托位核验分摊,检查重放、历史、权限拒绝后事实不变;公司维修成本不并入。请求意图和响应通过调用方checkpoint脱敏落盘,失败保留原样本/键,不自动重跑、删除或清理。`fleet-trip-cost-acceptance-contract.test.mjs` 为离线合同,不证明真实HTTP已通过。
|
||||
|
||||
`fleet-company-cost-acceptance.mjs` 是公司车辆成本内部验收模块,由 `../fleet-company-cost-test-env.mjs` 使用独立 marker 和 TEST 会话调用,不依赖或借用 `financeFullFixtures`。自行创建专用供应商、车辆及维修/救援记录,覆盖 CNY/USD 来源匹配、未付款作废重建、凭证字节、部分/全额付款重放、超付与已付改写拒绝;客户隔离只读检索本 marker 费用未进入客户费用池,不借用其他客户查询明细或账户,避免将其他并行任务的正常变化误判为污染。要求当月已有启用 USD 汇率,缺失则停止,不修改共享汇率。报告和清理边界见脚本根索引;`fleet-company-cost-acceptance-contract.test.mjs` 为离线替身测试,不调用 TEST。
|
||||
|
||||
`fleet-driver-qualification.mjs` 是司机证照专项可选模块,从 `admin-test-env-acceptance.mjs --modules fleet-driver-qualification --output <报告路径>` 执行,凭据由调用者显式 `node --env-file=<授权TEST环境文件>` 提供。经正式API创建两名本轮受控司机、上传非真实证照PNG,验证缺件/审核/打回/重提、30天内提醒、任务结束跨到期日禁止、非必需资质不阻断、关键资料及附件变化重审、旧版本拒绝、附件跨司机归属和历史下载。仅操作本轮司机及文件,不改排车/库存/财务、不执行SQL或部署;成功失败均按最新版本停用本轮司机,保留样本和报告 `createdResources.fleetDriverQualification`。失败重跑使用新样本,原失败证据不改写;该专项不代替整车实际发车链和浏览器验收。
|
||||
|
||||
`fleet-vehicle-care.mjs` 是可选车辆运维专项,通过正式入口 `admin-test-env-acceptance.mjs --modules fleet-vehicle-care --output <报告路径>` 执行,不单独运行。仅用于获授权 TEST,凭据由调用者显式 `node --env-file=<指定环境文件>` 提供;复用总入口 gateway/username/password 参数。经正式 API 创建本轮专用车辆,再复用 `vehicle-care.mjs` 验证六类记录、版本、更正历史、照片幂等及跨车隔离;不依赖订单、库存、报价或历史样本。写入仅涉及测试车辆、运维记录和图片;成功/失败均保留报告 `createdResources.fleetVehicleCare` 和样本,finally 读取最新版本停用本轮主车辆,内部助手停用隔离车辆,不删除记录或文件。失败重跑创建新样本,不修改原失败证据;不执行 SQL 或部署。该场景不证明费用已经入账或付款。
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { run } from './fleet-company-cost-acceptance.mjs'
|
||||
import { requireReady, READY_COMMIT } from '../fleet-company-cost-test-env.mjs'
|
||||
|
||||
const copy = value => value == null ? value : structuredClone(value)
|
||||
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
|
||||
function fixture(fault) {
|
||||
const marker = 'CONTROLLED-COMPANY-COST', calls = [], vehicles = new Map(), records = new Map(), costs = new Map(), submissions = new Map(), commands = new Map(), files = new Map()
|
||||
const response = (data, code = '200', http = 200) => ({ http, body: { code, data: copy(data) }, requestId: 'controlled-request', traceId: 'controlled-trace' })
|
||||
let sequence = 0
|
||||
const history = row => ({ recordVersion: row.cost.recordVersion, action: row.action, snapshot: copy(row.cost) })
|
||||
function save(row, action) { row.action = action; row.history.unshift(history(row)) }
|
||||
function current(row) { const { cost } = row; return copy(cost) }
|
||||
let supplier
|
||||
const context = { marker, token: 'offline-test-only', createdResources: {},
|
||||
assert(_module, label, _response, condition) { assert.ok(condition, label) },
|
||||
apiSuccess(_module, label, result) { assert.equal(result?.http, 200, label); assert.equal(result?.body?.code, '200', label); return result.body.data },
|
||||
async request(method, raw, body, _token, headers) {
|
||||
calls.push({ method, raw, body: copy(body), headers: copy(headers) }); const url = new URL(raw, 'https://fixture.invalid'), path = url.pathname
|
||||
if (method === 'POST' && path === '/api/platform/v1/suppliers') { supplier = { id: '8', ...copy(body) }; return response('8') }
|
||||
if (method === 'GET' && path === '/api/platform/v1/suppliers/8') return response(supplier)
|
||||
if (method === 'GET' && path.includes('/master-data/exchange-rates/')) return response({ month: path.split('/').at(-1), status: fault === 'inactive exchange' ? 'INACTIVE' : 'ACTIVE', usdToCnyAverage: '7.123456', recordVersion: 0 })
|
||||
if (path.endsWith('/fee-pool')) return response(fault === 'customer pollution' && costs.size ? [{ poolItemId: 'LEAK', customerId: '9', amount: 27.31, feeItemName: marker }] : [])
|
||||
if (path.endsWith('/cost-entries')) return response([])
|
||||
if (path.endsWith('/customer-accounts/9/summary')) return response({ customerId: '9', currency: 'CNY', totalReceivable: 100, receivedAmount: 30, outstandingReceivable: 70,
|
||||
pendingBilling: 0, totalCost: 0, paidCost: 0, outstandingPayable: 0, asOf: new Date().toISOString() })
|
||||
if (path === '/api/platform/v1/last-leg/master-data/vehicles' && method === 'POST') {
|
||||
const row = { id: '71', ...copy(body), recordVersion: 0 }; vehicles.set(row.id, row); return response(row)
|
||||
}
|
||||
if (path.startsWith('/api/platform/v1/last-leg/master-data/vehicles/')) {
|
||||
const row = vehicles.get(path.split('/vehicles/')[1].split('/')[0]); assert.ok(row)
|
||||
if (method === 'GET') return response(row)
|
||||
assert.equal(method, 'PATCH'); assert.equal(body.status, 'INACTIVE')
|
||||
if (fault === 'cleanup failure') return response(null, 'S0503', 503)
|
||||
row.status = 'INACTIVE'; row.recordVersion++; return response(row)
|
||||
}
|
||||
const care = path.match(/^\/api\/platform\/v1\/last-leg\/fleet\/vehicles\/71\/records(?:\/(.*))?$/)
|
||||
if (care) {
|
||||
const suffix = care[1]
|
||||
if (!suffix && method === 'POST') {
|
||||
const recordNo = `VR-${records.size + 1}`, row = { recordNo, vehicleId: '71', vehiclePlate: vehicles.get('71').plateNumber, recordVersion: 0, details: copy(body.details) }
|
||||
records.set(recordNo, row); return response(row)
|
||||
}
|
||||
if (suffix === 'finance-status') {
|
||||
return response(body.recordNos.map(recordNo => {
|
||||
const record = records.get(recordNo), row = costs.get(recordNo); assert.ok(record && row)
|
||||
if (fault === 'delivery exhausted') return { recordNo, status: 'FINANCE_ATTENTION', sourceVersion: row.cost.source.sourceVersion }
|
||||
if (fault === 'never delivered') return { recordNo, status: 'PENDING_FINANCE', sourceVersion: row.cost.source.sourceVersion }
|
||||
const cost = current(row)
|
||||
if (fault === 'wrong source vehicle') cost.source.vehicleId = 'OTHER'
|
||||
if (fault === 'wrong source version') cost.source.sourceVersion = 999
|
||||
if (fault === 'wrong conversion') cost.source.amount = 999
|
||||
if (fault === 'premature paid') cost.status = 'PAID'
|
||||
return { recordNo, status: cost.status, sourceVersion: cost.source.sourceVersion, submittedRecordVersion: record.recordVersion, finance: cost }
|
||||
}))
|
||||
}
|
||||
const [recordNo, action] = suffix.split('/'), record = records.get(recordNo); assert.ok(record)
|
||||
if (method === 'GET') return response(record)
|
||||
if (method === 'PUT') {
|
||||
if (fault === 'paid source mutable') { record.details = copy(body.details); record.recordVersion++; return response(record) }
|
||||
return response(null, 'A0409')
|
||||
}
|
||||
assert.equal(action, 'finance')
|
||||
const key = `${recordNo}:${body.idempotencyKey}`
|
||||
if (submissions.has(key)) return response({ ...submissions.get(key), replayed: true })
|
||||
let row = costs.get(recordNo), sourceVersion = row ? row.cost.source.sourceVersion + 1 : 1
|
||||
if (row && row.cost.status !== 'VOIDED') return response(null, 'A0409')
|
||||
const incident = record.details.incident, rate = incident.currency === 'CNY' ? 1 : 7.123456
|
||||
const source = { sourceType: record.details.recordType, sourceNo: recordNo, sourceVersion, vehicleId: '71', vehiclePlate: record.vehiclePlate,
|
||||
supplierId: body.supplierId, feeName: incident.item, businessDate: body.businessDate, originalCurrency: incident.currency,
|
||||
originalAmount: incident.reportedCost, exchangeRate: rate, exchangeRateMonth: body.businessDate.slice(0, 7),
|
||||
amount: Math.round(incident.reportedCost * rate * 100) / 100, remark: record.details.remark }
|
||||
const cost = { costNo: row?.cost.costNo ?? `VC-${++sequence}`, subjectCode: 'PLATFORM_DEFAULT', currency: 'CNY', status: 'PENDING', paidAmount: 0, outstandingAmount: null,
|
||||
recordVersion: row ? row.cost.recordVersion + 1 : 0, source }
|
||||
if (!row) row = { cost, payments: [], history: [] }
|
||||
else row.cost = cost
|
||||
save(row, 'RECEIVE'); costs.set(recordNo, row)
|
||||
const result = { recordNo, sourceVersion, submittedRecordVersion: record.recordVersion, replayed: false, status: 'PENDING_FINANCE' }
|
||||
submissions.set(key, copy(result)); return response(result)
|
||||
}
|
||||
const match = path.match(/^\/api\/platform\/finance\/v1\/vehicle-costs\/([^/]+)(?:\/(.*))?$/)
|
||||
assert.ok(match, `Unexpected ${method} ${raw}`)
|
||||
const [, no, action] = match, row = [...costs.values()].find(r => r.cost.costNo === no); assert.ok(row)
|
||||
if (method === 'GET') {
|
||||
if (!action) return response(current(row))
|
||||
const list = action === 'payments' ? row.payments : row.history
|
||||
return response({ records: list, page: 1, size: 100, total: list.length, pages: list.length ? 1 : 0 })
|
||||
}
|
||||
const key = `${no}:${action}:${headers?.['Idempotency-Key']}`
|
||||
assert.ok(headers?.['Idempotency-Key'], 'financial commands require their stable header key')
|
||||
if (commands.has(key) && !(fault === 'duplicate payment replay' && action === 'payments')) return response(current(row))
|
||||
if (Number(body.expectedVersion) !== row.cost.recordVersion && !(fault === 'duplicate payment replay' && action === 'payments')) return response(null, 'FIN_409')
|
||||
if (action === 'approve') { row.cost.status = 'APPROVED'; row.cost.outstandingAmount = row.cost.source.amount; row.cost.recordVersion++; save(row, 'APPROVE') }
|
||||
else if (action === 'void') {
|
||||
if (row.cost.paidAmount > 0 && fault !== 'paid void allowed') return response(null, 'FIN_409')
|
||||
row.cost.status = 'VOIDED'; row.cost.outstandingAmount = null; row.cost.recordVersion++; save(row, 'VOID')
|
||||
} else if (action === 'payments') {
|
||||
if (body.amount > row.cost.outstandingAmount && fault !== 'overpayment allowed') return response(null, 'FIN_409')
|
||||
row.cost.paidAmount = Math.round((row.cost.paidAmount + body.amount) * 100) / 100
|
||||
row.cost.outstandingAmount = Math.round((row.cost.source.amount - row.cost.paidAmount) * 100) / 100
|
||||
row.cost.status = row.cost.outstandingAmount === 0 ? 'PAID' : 'PARTIALLY_PAID'; row.cost.recordVersion++
|
||||
row.payments.push({ paymentNo: `VP-${++sequence}`, currency: 'CNY', amount: body.amount, voucherFileIds: copy(body.voucherFileIds) }); save(row, 'PAY')
|
||||
if (fault === 'partial money drift') row.cost.outstandingAmount += 0.01
|
||||
} else throw new Error(`Unexpected action ${action}`)
|
||||
commands.set(key, true); return response(current(row))
|
||||
},
|
||||
async requestMultipart(method, raw, file) {
|
||||
calls.push({ method, raw, multipart: true }); assert.ok(Buffer.from(file.content).equals(PNG))
|
||||
if (fault === 'file outage') return response(null, 'FIN_503', 503)
|
||||
const fileId = id(files.size + 1); files.set(fileId, raw)
|
||||
return response({ fileId, ownerScope: 'PLATFORM', tenantId: '0', businessType: 'FINANCE_VEHICLE_VOUCHER', contentType: 'image/png', sizeBytes: PNG.length })
|
||||
},
|
||||
async requestBinary(method, raw) { calls.push({ method, raw, binary: true }); return { http: 200, contentType: 'image/png', bytes: fault === 'wrong file bytes' ? Buffer.from('wrong') : PNG } },
|
||||
}
|
||||
return { context, calls, vehicles, records, costs }
|
||||
}
|
||||
const id = value => String(value)
|
||||
const options = { pollAttempts: 2, pollDelayMs: 0 }
|
||||
test('independent runner requires explicit service readiness and only the specified TEST profile', () => {
|
||||
const env = 'C:/Users/BY-OY/.codex/env/chongchong-global/test-acceptance.env'
|
||||
assert.throws(() => requireReady([], [`--env-file=${env}`]), /就绪/)
|
||||
assert.throws(() => requireReady([`--ready-commit=${READY_COMMIT}`], [`--env-file=${env.replace('test-acceptance', 'dev-acceptance')}`]), /授权目录/)
|
||||
assert.ok(requireReady([`--ready-commit=${READY_COMMIT}`], [`--env-file=${env}`]).endsWith('test-acceptance.env'))
|
||||
})
|
||||
test('company cost verifier exercises CNY/USD, void/rebuild, payment replay and customer isolation without direct transport', async () => {
|
||||
const { context, calls, vehicles, records, costs } = fixture()
|
||||
await run(context, options)
|
||||
const proof = context.createdResources.fleetCompanyCostAcceptance
|
||||
assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.cleanup, 'INACTIVE')
|
||||
assert.equal(proof.cases.length, 2); assert.equal(records.size, 2)
|
||||
assert.ok([...costs.values()].every(row => row.cost.status === 'PAID' && row.payments.length === 2))
|
||||
assert.ok([...vehicles.values()].every(row => row.status === 'INACTIVE'))
|
||||
assert.ok(calls.every(call => call.raw.startsWith('/api/platform/')))
|
||||
assert.equal(calls.some(call => /cost-entries|customer-accounts|customerId=/.test(call.raw)), false, 'must not borrow another task customer for evidence')
|
||||
assert.equal(calls.some(call => call.method === 'DELETE'), false)
|
||||
await assert.rejects(run(context, options), /本轮已存在/)
|
||||
})
|
||||
test('missing independent marker blocks before any write', async () => {
|
||||
const { context, calls } = fixture(); context.marker = ''
|
||||
await assert.rejects(run(context, options), /独立marker/); assert.equal(calls.length, 0)
|
||||
})
|
||||
for (const fault of ['inactive exchange', 'delivery exhausted', 'never delivered', 'wrong source vehicle', 'wrong source version', 'wrong conversion', 'premature paid',
|
||||
'duplicate payment replay', 'overpayment allowed', 'partial money drift', 'paid void allowed', 'paid source mutable', 'customer pollution', 'file outage', 'wrong file bytes', 'cleanup failure']) {
|
||||
test(`company cost verifier rejects ${fault} and preserves failure evidence`, async () => {
|
||||
const { context, vehicles } = fixture(fault)
|
||||
await assert.rejects(run(context, options))
|
||||
const proof = context.createdResources.fleetCompanyCostAcceptance
|
||||
assert.equal(proof.status, 'FAILED')
|
||||
assert.ok(proof.error || proof.cleanupError)
|
||||
if (vehicles.size && fault !== 'cleanup failure') assert.ok([...vehicles.values()].every(row => row.status === 'INACTIVE'))
|
||||
if (fault === 'cleanup failure') assert.equal(proof.cleanup, 'FAILED')
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
|
||||
const MODULE = 'fleet-company-cost-acceptance'
|
||||
const OPTIONS = { scenario: 'company-vehicle-cost-payment', deploymentBlocker: true }
|
||||
const FINANCE = '/api/platform/finance/v1'
|
||||
const MASTER = '/api/platform/v1/last-leg/master-data'
|
||||
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
|
||||
const id = value => value == null ? '' : String(value)
|
||||
const ok = response => response?.http === 200 && id(response?.body?.code) === '200'
|
||||
function scaled(value, scale) {
|
||||
if (!new RegExp(`^\\d+(\\.\\d{1,${scale}})?$`).test(id(value))) throw new Error('验收金额或汇率格式无效')
|
||||
const [whole, fraction = ''] = id(value).split('.')
|
||||
return BigInt(whole) * 10n ** BigInt(scale) + BigInt(fraction.padEnd(scale, '0'))
|
||||
}
|
||||
const cents = value => scaled(value, 2)
|
||||
const amount = value => Number(`${value / 100n}.${id(value % 100n).padStart(2, '0')}`)
|
||||
function canonical(value) {
|
||||
if (Array.isArray(value)) return value.map(canonical).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))
|
||||
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]))
|
||||
return value
|
||||
}
|
||||
const same = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b))
|
||||
|
||||
/** Internal authorized TEST module; entrypoint owns registration, credentials and execution. No direct fetch, SQL or historical fixtures. */
|
||||
export async function run(context, { pollAttempts = 60, pollDelayMs = 1000 } = {}) {
|
||||
if (!context.marker || !context.createdResources) throw new Error('公司车辆成本验收须有独立marker和本轮资源上下文')
|
||||
if (context.createdResources.fleetCompanyCostAcceptance) throw new Error('本轮已存在公司成本验收记录;保留原证据,不自动重新造数')
|
||||
if (!Number.isInteger(pollAttempts) || pollAttempts < 1 || pollAttempts > 120 || !Number.isFinite(pollDelayMs) || pollDelayMs < 0 || pollDelayMs > 5000)
|
||||
throw new Error('公司成本轮询范围无效')
|
||||
const proof = { marker: context.marker, status: 'CHECKING', cases: [], requests: [], cleanup: 'NOT_CREATED', isolationScope: 'Only marker-owned company fees must be absent from the customer fee pool. Customer-specific cost/account records and unrelated concurrent changes are not inspected or compared.' }
|
||||
context.createdResources.fleetCompanyCostAcceptance = proof
|
||||
function check(label, condition, response = null) {
|
||||
context.assert(MODULE, label, response, Boolean(condition), '', OPTIONS)
|
||||
if (!condition) throw new Error(`${MODULE}: ${label}`)
|
||||
}
|
||||
function evidence(label, method, path, response) {
|
||||
proof.requests.push({ label, method, path, http: response?.http, code: response?.body?.code,
|
||||
requestId: response?.requestId, traceId: response?.traceId })
|
||||
}
|
||||
async function request(method, path, body, label, { key, reject } = {}) {
|
||||
const planned = { label, method, path, requestBody: body, idempotencyKey: key, state: 'PLANNED' }
|
||||
proof.requests.push(planned)
|
||||
await context.checkpoint?.()
|
||||
const response = await context.request(method, path, body, context.token, key ? { 'Idempotency-Key': key } : {})
|
||||
Object.assign(planned, { state: 'RETURNED', http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
|
||||
await context.checkpoint?.()
|
||||
if (reject) {
|
||||
check(label, response?.http === 200 && reject.includes(id(response?.body?.code)), response)
|
||||
return response.body?.data
|
||||
}
|
||||
const data = context.apiSuccess(MODULE, label, response, '', OPTIONS)
|
||||
check(label, ok(response) && data != null, response)
|
||||
return data
|
||||
}
|
||||
async function snapshotCustomer(label) {
|
||||
const pool = await request('GET', `${FINANCE}/fee-pool?direction=ALL`, undefined, `${label}只读核对公司来源未进入客户费用池`)
|
||||
check('客户费用池隔离查询形状完整', Array.isArray(pool))
|
||||
// Do not retain or operate other tasks' customer records. Only count this run's unique marker.
|
||||
return { companyFeePoolRows: pool.filter(row => JSON.stringify(row).includes(context.marker)).length }
|
||||
}
|
||||
async function page(path, label) {
|
||||
const result = await request('GET', `${path}?page=1&size=100`, undefined, label)
|
||||
check(`${label}分页完整`, Array.isArray(result.records) && Number(result.page) === 1 && Number(result.size) === 100
|
||||
&& Number(result.total) === result.records.length && Number(result.total) <= 100)
|
||||
return result.records
|
||||
}
|
||||
let failure
|
||||
try {
|
||||
const businessDate = new Date().toISOString().slice(0, 10), month = businessDate.slice(0, 7)
|
||||
const exchange = await request('GET', `${MASTER}/exchange-rates/${month}`, undefined, '读取本月有效USD汇率,不修改共享汇率')
|
||||
check('USD汇率月份有效', exchange.month === month && exchange.status === 'ACTIVE' && scaled(exchange.usdToCnyAverage, 6) > 0n)
|
||||
proof.exchangeRate = { month, usdToCnyAverage: id(exchange.usdToCnyAverage), recordVersion: id(exchange.recordVersion) }
|
||||
proof.customerBefore = await snapshotCustomer('操作前')
|
||||
check('本轮marker尚无客户费用污染,禁止覆盖旧验收', proof.customerBefore.companyFeePoolRows === 0)
|
||||
proof.supplierRequest = { name: `${context.marker} 公司成本专用供应商`, category: 'TRUCKING', carrierCodes: [], region: 'TEST-OVERSEAS',
|
||||
legalRepresentative: '受控验收', contactName: '受控验收', phone: '00000000000', email: null, address: '受控测试地点',
|
||||
businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'IMMEDIATE', remark: context.marker, truckingScope: 'OVERSEAS' }
|
||||
proof.supplierId = id(await request('POST', '/api/platform/v1/suppliers?tenantId=0', proof.supplierRequest, '创建本轮独占公司成本供应商'))
|
||||
check('供应商创建返回真实标识', /^[1-9]\d*$/.test(proof.supplierId))
|
||||
const supplier = await request('GET', `/api/platform/v1/suppliers/${encodeURIComponent(proof.supplierId)}?tenantId=0`, undefined, '核对本轮独占供应商')
|
||||
check('供应商确属本轮独占样本', id(supplier.id) === proof.supplierId && supplier.status === 'ACTIVE' && supplier.remark === context.marker && supplier.name === proof.supplierRequest.name)
|
||||
const vehicle = await request('POST', `${MASTER}/vehicles`, { plateNumber: `FC-${randomUUID().slice(0, 12)}`, model: 'Company cost TEST', fuelConsumptionPer100Km: 10,
|
||||
mileageKm: 100, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: context.marker }, '创建本轮公司成本专用车辆')
|
||||
check('车辆返回稳定身份', Boolean(vehicle.id && vehicle.plateNumber))
|
||||
proof.vehicleId = id(vehicle.id); proof.plateNumber = vehicle.plateNumber; proof.cleanup = 'PENDING'
|
||||
const care = `/api/platform/v1/last-leg/fleet/vehicles/${encodeURIComponent(proof.vehicleId)}/records`
|
||||
for (const [recordType, currency, originalAmount] of [['REPAIR', 'CNY', 27.31], ['RESCUE', 'USD', 10]]) {
|
||||
const scenario = { recordType, status: 'CHECKING', commands: [] }; proof.cases.push(scenario)
|
||||
const details = { recordType, occurredAt: new Date(Date.now() - 1000).toISOString(), distanceUnit: null, odometer: null,
|
||||
lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: null, fuelUnit: null, verificationStatus: null, remark: context.marker,
|
||||
incident: { description: `${context.marker} 受控验收事件`, item: `${context.marker} ${recordType}公司费用`, place: '受控测试地点', handler: '验收操作人',
|
||||
processingStatus: 'COMPLETED', exceptionType: null, reportedCost: originalAmount, currency } }
|
||||
const creation = { idempotencyKey: randomUUID(), details }; scenario.creation = creation
|
||||
const record = await request('POST', care, creation, `创建本轮${recordType}记录`)
|
||||
check('仅操作当前车辆当前运行记录', id(record.vehicleId) === proof.vehicleId && record.details?.remark === context.marker && record.details?.recordType === recordType && record.recordNo)
|
||||
scenario.recordNo = record.recordNo
|
||||
const recordPath = `${care}/${encodeURIComponent(record.recordNo)}`
|
||||
const rate = currency === 'CNY' ? 1000000n : scaled(exchange.usdToCnyAverage, 6)
|
||||
const total = (cents(originalAmount) * rate + 500000n) / 1000000n
|
||||
check('受控金额可分为两笔付款', total > 1n && total <= BigInt(Number.MAX_SAFE_INTEGER))
|
||||
let submission
|
||||
async function submit() {
|
||||
const command = { recordVersion: record.recordVersion, supplierId: proof.supplierId, businessDate, idempotencyKey: randomUUID() }
|
||||
scenario.commands.push({ action: 'SUBMIT', request: command })
|
||||
submission = await request('POST', `${recordPath}/finance`, command, '提交真实公司费用来源')
|
||||
check('提交不冒充入账或付款', submission.recordNo === record.recordNo && submission.status === 'PENDING_FINANCE' && BigInt(submission.sourceVersion) > 0n)
|
||||
const replay = await request('POST', `${recordPath}/finance`, command, '原请求重放提交')
|
||||
check('重放保持同一来源版本', replay.replayed === true && id(replay.sourceVersion) === id(submission.sourceVersion))
|
||||
}
|
||||
async function awaitFinance() {
|
||||
for (let attempt = 0; attempt < pollAttempts; attempt++) {
|
||||
const rows = await request('POST', `${care}/finance-status`, { recordNos: [record.recordNo] }, '轮询本条已提交Finance来源')
|
||||
check('来源状态严格按本车本记录返回', Array.isArray(rows) && rows.length === 1 && rows[0].recordNo === record.recordNo)
|
||||
const state = rows[0]
|
||||
check('投递未进入需人工处理状态', state.status !== 'FINANCE_ATTENTION')
|
||||
if (state.finance) {
|
||||
const cost = state.finance, source = cost.source
|
||||
check('Finance来源与提交版本、车辆、供应商及费用完全匹配', source?.sourceNo === record.recordNo && source.sourceType === recordType
|
||||
&& id(source.sourceVersion) === id(submission.sourceVersion) && id(source.vehicleId) === proof.vehicleId
|
||||
&& id(source.supplierId) === proof.supplierId && source.businessDate === businessDate && source.exchangeRateMonth === month
|
||||
&& source.originalCurrency === currency && cents(source.originalAmount) === cents(originalAmount)
|
||||
&& scaled(source.exchangeRate, 6) === rate && cents(source.amount) === total && source.remark === context.marker
|
||||
&& cost.subjectCode === 'PLATFORM_DEFAULT' && cost.currency === 'CNY' && Boolean(cost.costNo))
|
||||
check('首次财务接收仍未入账付款', state.status === 'PENDING' && cost.status === 'PENDING' && cents(cost.paidAmount) === 0n && cost.outstandingAmount == null)
|
||||
return cost
|
||||
}
|
||||
check('未接收时明确等待,不接受假成功', state.status === 'PENDING_FINANCE')
|
||||
if (attempt + 1 < pollAttempts) await delay(pollDelayMs)
|
||||
}
|
||||
throw new Error('Finance来源轮询超时,不能认定已接收或完成验收')
|
||||
}
|
||||
await submit(); let cost = await awaitFinance(); scenario.costNo = cost.costNo
|
||||
const costPath = `${FINANCE}/vehicle-costs/${encodeURIComponent(cost.costNo)}`
|
||||
async function write(action, body, label) {
|
||||
const key = randomUUID(); scenario.commands.push({ action, key, request: body })
|
||||
const result = await request('POST', `${costPath}/${action}`, body, label, { key })
|
||||
const replay = await request('POST', `${costPath}/${action}`, body, `${label}原键重放`, { key })
|
||||
check(`${label}重放不重复推进`, same(result, replay))
|
||||
return result
|
||||
}
|
||||
async function approve() {
|
||||
cost = await write('approve', { expectedVersion: cost.recordVersion, reason: '受控验收入账' }, '确认公司成本入账')
|
||||
check('入账只形成未付应付', cost.status === 'APPROVED' && cents(cost.paidAmount) === 0n && cents(cost.outstandingAmount) === total)
|
||||
}
|
||||
await approve()
|
||||
if (recordType === 'REPAIR') {
|
||||
cost = await write('void', { expectedVersion: cost.recordVersion, reason: '受控验证未付款作废后重建' }, '未付款费用作废')
|
||||
check('作废保留原金额且未付款', cost.status === 'VOIDED' && cents(cost.paidAmount) === 0n && cost.outstandingAmount == null)
|
||||
const previousVersion = BigInt(submission.sourceVersion)
|
||||
await submit(); cost = await awaitFinance()
|
||||
check('作废重建保留费用号并增加来源版本', cost.costNo === scenario.costNo && BigInt(submission.sourceVersion) === previousVersion + 1n)
|
||||
await approve()
|
||||
}
|
||||
const uploadPath = `${costPath}/vouchers`
|
||||
const uploaded = await context.requestMultipart('POST', uploadPath, { name: `${recordType.toLowerCase()}-controlled-payment.png`, type: 'image/png', content: PNG })
|
||||
evidence('上传合成测试付款凭证', 'POST', uploadPath, uploaded)
|
||||
const voucher = context.apiSuccess(MODULE, '上传合成测试付款凭证', uploaded, '', OPTIONS)
|
||||
check('付款凭证为公司专属真实文件', ok(uploaded) && voucher?.fileId && voucher.ownerScope === 'PLATFORM' && id(voucher.tenantId) === '0'
|
||||
&& voucher.businessType === 'FINANCE_VEHICLE_VOUCHER' && voucher.contentType === 'image/png' && Number(voucher.sizeBytes) === PNG.length, uploaded)
|
||||
scenario.voucherFileId = id(voucher.fileId)
|
||||
const binary = await context.requestBinary('GET', `${costPath}/vouchers/${encodeURIComponent(voucher.fileId)}/content`)
|
||||
check('付款凭证下载字节与本轮合成图片一致', binary?.http === 200 && id(binary.contentType).startsWith('image/png') && binary.bytes instanceof Uint8Array && Buffer.from(binary.bytes).equals(PNG), binary)
|
||||
const partial = total / 2n
|
||||
const payment = { expectedVersion: cost.recordVersion, amount: amount(partial), paidAt: new Date(Date.now() - 1000).toISOString(),
|
||||
method: 'BANK_TRANSFER', referenceNo: `${context.marker}-${recordType}`, voucherFileIds: [id(voucher.fileId)], remark: '受控验收模拟线下付款凭证,不发起银行转账' }
|
||||
cost = await write('payments', payment, '登记部分付款')
|
||||
check('部分付款金额守恒', cost.status === 'PARTIALLY_PAID' && cents(cost.paidAmount) === partial && cents(cost.outstandingAmount) === total - partial)
|
||||
await request('POST', `${costPath}/payments`, { ...payment, expectedVersion: cost.recordVersion, amount: amount(total - partial + 1n) }, '拒绝超过未付余额的付款', { key: randomUUID(), reject: ['FIN_409'] })
|
||||
const unchanged = await request('GET', costPath, undefined, '超付失败后回查余额及版本')
|
||||
check('超付拒绝没有改变财务事实', same(unchanged, cost))
|
||||
cost = await write('payments', { ...payment, expectedVersion: cost.recordVersion, amount: amount(total - partial) }, '登记剩余实际付款')
|
||||
check('全额付款终态金额守恒', cost.status === 'PAID' && cents(cost.paidAmount) === total && cents(cost.outstandingAmount) === 0n)
|
||||
await request('POST', `${costPath}/void`, { expectedVersion: cost.recordVersion, reason: '负向验证已付款不可作废' }, '已付款作废被拒绝', { key: randomUUID(), reject: ['FIN_409'] })
|
||||
await request('PUT', recordPath, { recordVersion: record.recordVersion, reason: '负向验证已付款不可改金额', details: { ...details, incident: { ...details.incident, reportedCost: originalAmount + 1 } } }, '已付款来源修改被拒绝', { reject: ['A0409'] })
|
||||
check('已付款负向动作不改变费用', same(await request('GET', costPath, undefined, '负向动作后回查已付费用'), cost))
|
||||
const careAfter = await request('GET', recordPath, undefined, '回查运维申报金额未被覆盖')
|
||||
check('负向修改不改变运维版本或金额', id(careAfter.recordVersion) === id(record.recordVersion) && cents(careAfter.details?.incident?.reportedCost) === cents(originalAmount))
|
||||
const paid = await page(`${costPath}/payments`, '核对实际付款流水')
|
||||
check('两次付款重放仅留下两条不同流水及原凭证', paid.length === 2 && new Set(paid.map(row => row.paymentNo)).size === 2
|
||||
&& paid.reduce((sum, row) => sum + cents(row.amount), 0n) === total && paid.every(row => row.currency === 'CNY' && same(row.voucherFileIds.map(id), [id(voucher.fileId)])))
|
||||
const history = await page(`${costPath}/history`, '核对不可变财务操作历史')
|
||||
check('历史保留接收、入账、两次付款及作废过程', history.filter(row => row.action === 'PAY').length === 2
|
||||
&& history.some(row => row.action === 'RECEIVE') && history.some(row => row.action === 'APPROVE')
|
||||
&& (recordType !== 'REPAIR' || history.some(row => row.action === 'VOID')))
|
||||
scenario.finance = cost; scenario.payments = paid; scenario.history = history; scenario.status = 'VERIFIED'
|
||||
check('本轮公司费用未进入客户费用池', same(proof.customerBefore, await snapshotCustomer(`${recordType}完成后`)))
|
||||
}
|
||||
proof.status = 'VERIFIED'
|
||||
} catch (error) { failure = error; proof.status = 'FAILED'; proof.error = error instanceof Error ? error.message : String(error) }
|
||||
finally {
|
||||
if (proof.vehicleId) {
|
||||
try {
|
||||
const path = `${MASTER}/vehicles/${encodeURIComponent(proof.vehicleId)}`
|
||||
const vehicle = await request('GET', path, undefined, '结束前读取本轮专用车辆')
|
||||
const ended = await request('PATCH', `${path}/status`, { recordVersion: vehicle.recordVersion, status: 'INACTIVE' }, '停用本轮专用车辆,保留财务与运维证据')
|
||||
check('本轮专用车辆已停用', ended.status === 'INACTIVE'); proof.cleanup = 'INACTIVE'
|
||||
} catch (error) { proof.cleanup = 'FAILED'; proof.cleanupError = error instanceof Error ? error.message : String(error); proof.status = 'FAILED'; failure ??= error }
|
||||
}
|
||||
}
|
||||
if (failure) throw failure
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { verifyFleetHandover, cleanupFleetHandoverResources } from './fleet-handover-acceptance.mjs'
|
||||
|
||||
const copy = value => value == null ? value : structuredClone(value)
|
||||
function fixture(fault = '') {
|
||||
const marker = 'TEST-HANDOVER-RUN', now = new Date(Date.now() - 60000).toISOString()
|
||||
const proof = { marker: 'FT-CONTROLLED', tripNo: 'TR-ONE', vehicleId: '11', driverId: '21', warehouseId: '92',
|
||||
stops: [{ name: 'Origin' }, { name: 'Destination', destinationKey: 'FBA:ONE' }] }
|
||||
const originalTransport = { vehiclePlate: 'ORIGINAL', driverName: '原司机', driverPhone: '111' }
|
||||
const trip = { tripNo: proof.tripNo, vehicleId: '11', driverId: '21', warehouseId: '92', ...originalTransport, status: 'IN_TRANSIT',
|
||||
recordVersion: '7', departedAt: now, expectedDriverEndAt: new Date(Date.now() + 86400000).toISOString(), maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 }
|
||||
const dispatchDetails = ['A', 'B'].map(customer => ({ dispatchNo: `PC-${customer}`, customerId: customer, overseasWarehouseId: '92',
|
||||
status: 'READY_TO_DEPART', packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport, items: [{ inboundCode: `IN-${customer}`, packageCount: 10 }] }))
|
||||
const liveDispatches = dispatchDetails.map(row => ({ ...row, status: 'DEPARTED', shippedPackageCount: 10 }))
|
||||
const cargo = ['A', 'B'].map(customer => ({ customerId: customer, dispatchNo: `PC-${customer}`, dispatchItemId: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', palletNo: 'PAL-REAL', packages: 10, destinationKey: 'FBA:ONE' }))
|
||||
const originalHistory = { action: 'DEPART', recordVersion: '7', snapshot: { trip: copy(trip), confirmedLoad: { cargo,
|
||||
pallets: [{ warehousePalletId: 'PALLET-INTERNAL', palletSpaces: 1, weightKg: 100, volumeCbm: 1 }] } } }
|
||||
const resources = new Map([['VEHICLE:11', 'TR-ONE'], ['DRIVER:21', 'TR-ONE']])
|
||||
const vehicles = new Map(), drivers = new Map(), probes = new Map(), calls = []
|
||||
let createdVehicles = 0, handover = null, preview = null
|
||||
const ok = data => ({ http: 200, body: { code: '200', data: copy(data) }, requestId: 'request', traceId: 'trace' })
|
||||
const conflict = message => ({ http: 200, body: { code: 'A0409', message }, requestId: 'request', traceId: 'trace' })
|
||||
const bump = () => { trip.recordVersion = String(BigInt(trip.recordVersion) + 1n) }
|
||||
const bindings = dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, customerId: row.customerId }))
|
||||
const context = {
|
||||
context: { async checkpointFleetHandover() {} },
|
||||
marker, createdResources: { fleetTrips: [proof], fleetAcceptance: { marker, tripNo: proof.tripNo,
|
||||
departure: { tripNo: proof.tripNo, status: 'IN_TRANSIT', departedAt: now, shippedPackages: 20, dispatches: dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, shippedPackages: 10 })) } } },
|
||||
assert(_module, label, _response, passed) { calls.push({ assertion: label, passed }) },
|
||||
async requestMultipart(method, path, file) {
|
||||
calls.push({ method, path, certificate: file.name }); assert.ok(file.name.includes('TEST-NOT-REAL')); assert.ok(file.content.byteLength > 0)
|
||||
drivers.get('22').recordVersion = '1'; return ok({ id: '22', recordVersion: '1', licenseFileId: 'TEST-LICENSE-FILE' })
|
||||
},
|
||||
async request(method, path, body, token) {
|
||||
calls.push({ method, path, body: copy(body), token })
|
||||
if (path.includes('/finance-fee-sources?')) {
|
||||
const customer = new URLSearchParams(path.split('?')[1]).get('customerId')
|
||||
return ok([{ sourceFeeId: `FEE-${customer}`, sourceVersion: 1, sourceStatus: 'ACTIVE', feeItemCode: 'DELIVERY_FEE', direction: 'RECEIVABLE', originalCurrency: 'CNY', originalAmount: fault === 'fee-changed' && handover ? 99 : 10 }])
|
||||
}
|
||||
if (path.includes('/handover-status')) {
|
||||
assert.equal(token, 'picker-secret')
|
||||
const dispatchNo = path.split('/').at(-2), customer = dispatchNo.slice(-1)
|
||||
const current = handover && fault !== 'pda-keeps-original' ? handover.snapshot.replacement : originalTransport
|
||||
return ok({ dispatchNo, tripNo: trip.tripNo, originalDeparture: fault === 'rewritten-pda-departure' && handover ? current : originalTransport,
|
||||
deliveries: [{ deliveryNo: `DL-${customer}`, status: 'WAIT_DELIVERY', packages: 10, terminalAt: null,
|
||||
transport: { vehiclePlate: current.vehiclePlate, driverName: current.driverName, driverPhone: current.driverPhone } }],
|
||||
latestHandover: handover ? { handoverNo: handover.handoverNo } : null })
|
||||
}
|
||||
if (path.startsWith('/api/platform/v1/last-leg/dispatches/')) return ok(liveDispatches.find(row => row.dispatchNo === path.split('/').at(-1)))
|
||||
if (path.endsWith('/master-data/vehicles') && method === 'POST') {
|
||||
const vehicle = { ...body, id: String(12 + createdVehicles++), recordVersion: '0' }; vehicles.set(vehicle.id, vehicle); return ok(vehicle)
|
||||
}
|
||||
if (path.includes('/master-data/vehicles/')) {
|
||||
const id = path.match(/\/vehicles\/([^/]+)/)[1], vehicle = vehicles.get(id)
|
||||
if (method === 'PATCH') { vehicle.status = body.status; vehicle.recordVersion = '1' }
|
||||
return ok(vehicle)
|
||||
}
|
||||
if (path.endsWith('/fleet/drivers') && method === 'POST') {
|
||||
const driver = { id: '22', recordVersion: '0', details: body, active: true }; drivers.set(driver.id, driver); return ok(driver)
|
||||
}
|
||||
if (path.includes('/fleet/drivers/')) {
|
||||
const driver = drivers.get('22')
|
||||
if (path.endsWith('/review')) { driver.reviewStatus = 'APPROVED'; driver.recordVersion = '2' }
|
||||
if (path.endsWith('/status')) { driver.active = body.active; driver.recordVersion = '3' }
|
||||
return ok(driver)
|
||||
}
|
||||
if (path.endsWith('/fleet/trips') && method === 'POST') {
|
||||
const next = { ...body, tripNo: `PROBE-${probes.size + 1}`, status: 'PLANNED', recordVersion: '0' }; probes.set(next.tripNo, next); return ok({ trip: next, dispatches: [] })
|
||||
}
|
||||
if (path.includes('/trips/PROBE-')) {
|
||||
const number = path.match(/\/trips\/([^/]+)/)[1], probe = probes.get(number)
|
||||
if (path.endsWith('/assignment')) {
|
||||
if (resources.has(`VEHICLE:${body.vehicleId}`)) return conflict('车辆已被车次占用')
|
||||
if (resources.has(`DRIVER:${body.driverId}`)) return conflict('司机已被车次占用')
|
||||
resources.set(`VEHICLE:${body.vehicleId}`, number); resources.set(`DRIVER:${body.driverId}`, number)
|
||||
Object.assign(probe, { vehicleId: body.vehicleId, driverId: body.driverId, recordVersion: '1' })
|
||||
}
|
||||
if (path.endsWith('/cancel')) {
|
||||
for (const [resource, owner] of resources) if (owner === number) resources.delete(resource)
|
||||
probe.status = 'CANCELLED'
|
||||
}
|
||||
return ok({ trip: probe, dispatches: [] })
|
||||
}
|
||||
if (path.includes('/history?')) {
|
||||
const departure = copy(originalHistory)
|
||||
if (handover && fault === 'rewritten-departure') departure.snapshot.trip.vehiclePlate = 'WRONG'
|
||||
const records = handover ? [departure, { action: 'HANDOVER', recordVersion: '8' }] : [departure]
|
||||
return ok({ records, total: records.length })
|
||||
}
|
||||
if (path.endsWith('/handovers/preview')) {
|
||||
preview = { cargoHash: 'a'.repeat(64), driverQualification: { canDispatch: true }, cargo: { remainingCargo: cargo.map(row => ({ customerId: row.customerId, dispatchNo: row.dispatchNo, itemId: row.dispatchItemId,
|
||||
palletId: row.warehousePalletId, destinationKey: row.destinationKey, packages: row.packages })), completedDeliveries: [], remainingPalletIds: ['PALLET-INTERNAL'], occupied: { palletSpaces: 1, weightKg: 100, volumeCbm: 1 } } }
|
||||
if (fault === 'wrong-customer') preview.cargo.remainingCargo[0].customerId = 'OTHER'
|
||||
if (fault === 'double-count-pallet') preview.cargo.occupied.palletSpaces = 2
|
||||
return ok(preview)
|
||||
}
|
||||
if (path.endsWith('/handovers') && method === 'POST') {
|
||||
if (handover && body.idempotencyKey === proof.handover.confirmRequest.idempotencyKey) return ok(handover)
|
||||
if (String(body.selection.recordVersion) !== trip.recordVersion) {
|
||||
if (fault === 'stale-accepted') return ok(handover)
|
||||
return conflict(fault === 'unrelated-conflict' ? '费用正在处理中' : '车次已变化,请刷新后重新核对')
|
||||
}
|
||||
handover = { handoverNo: 'HO-REAL', tripNo: trip.tripNo, recordVersion: '0', snapshot: {
|
||||
previous: { vehicleId: '11', driverId: '21', ...originalTransport }, replacement: { vehicleId: '12', driverId: '22', vehiclePlate: vehicles.get('12').plateNumber, driverName: '接替司机', driverPhone: '222' }, cargo: preview.cargo }, vehicleRelease: null, driverRelease: null }
|
||||
Object.assign(trip, handover.snapshot.replacement); bump()
|
||||
resources.set('VEHICLE:12', 'TR-ONE'); resources.set('DRIVER:22', 'TR-ONE')
|
||||
if (fault === 'unknown-confirmation') throw new Error('connection lost after commit')
|
||||
return ok(handover)
|
||||
}
|
||||
if (path.includes('/handovers?')) return ok({ records: handover ? [handover] : [], total: handover ? 1 : 0 })
|
||||
if (path.endsWith('/resources/release')) {
|
||||
const vehicle = body.resourceKind === 'VEHICLE', field = vehicle ? 'vehicleRelease' : 'driverRelease', resource = vehicle ? 'VEHICLE:11' : 'DRIVER:21'
|
||||
if (handover[field]) {
|
||||
if (fault === 'replay-releases-next') resources.delete(resource)
|
||||
return ok(handover)
|
||||
}
|
||||
assert.equal(String(body.recordVersion), handover.recordVersion)
|
||||
resources.delete(resource); handover[field] = { actualEndAt: body.actualEndAt, location: body.location }
|
||||
handover.recordVersion = String(BigInt(handover.recordVersion) + 1n); bump(); return ok(handover)
|
||||
}
|
||||
if (path.endsWith('/trips/TR-ONE') && method === 'GET') return ok({ trip, dispatches: bindings })
|
||||
throw new Error(`Unexpected test route ${method} ${path}`)
|
||||
},
|
||||
}
|
||||
return { context, proof, dispatchDetails, calls, trip, resources, probes, vehicles, drivers }
|
||||
}
|
||||
|
||||
test('fresh real departure carries all customer cargo, preserves DEPART and fees, and verifies both old-resource releases', async () => {
|
||||
const f = fixture(), result = await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails)
|
||||
assert.equal(result.status, 'VERIFIED'); assert.equal(result.pdaReadCount, 4)
|
||||
assert.equal(result.browser.originalVehiclePlate, 'ORIGINAL'); assert.match(result.browser.replacementVehiclePlate, /^HO-/)
|
||||
assert.equal(f.proof.vehicleId, '12'); assert.equal(f.proof.driverId, '22'); assert.equal(f.proof.licenseFileId, 'TEST-LICENSE-FILE')
|
||||
assert.equal(result.replacement.certificate.synthetic, true); assert.ok(result.replacement.certificate.contentBase64)
|
||||
assert.doesNotMatch(JSON.stringify(result), /picker-secret/)
|
||||
assert.ok(f.calls.filter(row => row.assertion).every(row => row.passed))
|
||||
assert.ok(!f.calls.some(row => row.path?.endsWith('/depart') || row.path?.endsWith('/complete') || row.path?.endsWith('/cancel')))
|
||||
assert.equal(f.resources.get('VEHICLE:11'), 'PROBE-1'); assert.equal(f.resources.get('DRIVER:21'), 'PROBE-1')
|
||||
})
|
||||
|
||||
test('marker mismatch, unregistered copied proof and missing actual departure fail without HTTP', async () => {
|
||||
for (const fault of ['marker', 'unregistered', 'departure']) {
|
||||
const f = fixture(); let proof = f.proof
|
||||
if (fault === 'marker') f.context.createdResources.fleetAcceptance.marker = 'ANOTHER-RUN'
|
||||
if (fault === 'unregistered') proof = structuredClone(proof)
|
||||
if (fault === 'departure') delete f.context.createdResources.fleetAcceptance.departure
|
||||
await assert.rejects(verifyFleetHandover(f.context, 'test', proof, 'picker-secret', f.dispatchDetails), /本轮|当前marker/)
|
||||
assert.equal(f.calls.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('unknown successful write preserves the original command and blocks automatic rerun and cleanup', async () => {
|
||||
const f = fixture('unknown-confirmation')
|
||||
const saved = []
|
||||
f.context.context.checkpointFleetHandover = async proof => saved.push(copy(proof))
|
||||
await assert.rejects(verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails), /connection lost/)
|
||||
assert.equal(f.proof.handover.status, 'FAILED'); assert.ok(f.proof.handover.confirmRequest.idempotencyKey)
|
||||
assert.equal(f.proof.handover.requests.at(-1).status, 'UNKNOWN_RESULT')
|
||||
const sending = saved.find(row => row.requests.at(-1).path.endsWith('/handovers') && row.requests.at(-1).status === 'SENDING')
|
||||
assert.deepEqual(sending.requests.at(-1).body, f.proof.handover.confirmRequest)
|
||||
assert.equal(saved.at(-1).requests.at(-1).status, 'UNKNOWN_RESULT')
|
||||
assert.ok(!JSON.stringify(saved).includes('picker-secret'))
|
||||
const calls = f.calls.length
|
||||
await assert.rejects(verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails), /不自动续跑/)
|
||||
await assert.rejects(cleanupFleetHandoverResources(f.context, 'test', f.proof), /仅允许/)
|
||||
assert.equal(f.calls.length, calls); assert.equal(f.proof.vehicleId, '11')
|
||||
})
|
||||
|
||||
test('missing persistence and failed pre-send checkpoints cannot send HTTP', async () => {
|
||||
const missing = fixture(); delete missing.context.context.checkpointFleetHandover
|
||||
await assert.rejects(verifyFleetHandover(missing.context, 'test', missing.proof, 'picker-secret', missing.dispatchDetails), /持久化/)
|
||||
assert.equal(missing.calls.length, 0)
|
||||
for (const target of ['vehicle', 'multipart']) {
|
||||
const f = fixture()
|
||||
let blocked
|
||||
f.context.context.checkpointFleetHandover = async proof => {
|
||||
const entry = proof.requests.at(-1)
|
||||
if (entry.status === 'SENDING' && (target === 'multipart' ? entry.file : entry.method === 'POST')) {
|
||||
blocked = copy(entry); throw Error('disk full')
|
||||
}
|
||||
}
|
||||
await assert.rejects(verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails), /disk full/)
|
||||
assert.ok(blocked)
|
||||
assert.ok(!f.calls.some(row => row.method === blocked.method && row.path === blocked.path))
|
||||
if (target === 'multipart') {
|
||||
assert.match(blocked.file.sha256, /^[a-f0-9]{64}$/); assert.ok(blocked.file.bytes > 0)
|
||||
assert.equal(blocked.file.content, undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('every HTTP call observes a completed pre-send checkpoint; post-response disk failure stops the chain', async () => {
|
||||
const f = fixture(), original = f.context.request.bind(f.context), saved = []
|
||||
f.context.context.checkpointFleetHandover = async proof => {
|
||||
const entry = proof.requests.at(-1)
|
||||
if (entry.status === 'RESPONDED' && entry.method === 'POST') throw Error('response disk failure')
|
||||
saved.push(copy(proof))
|
||||
}
|
||||
f.context.request = async (...args) => {
|
||||
const entry = saved.at(-1).requests.at(-1)
|
||||
assert.equal(entry.status, 'SENDING'); assert.equal(entry.path, args[1]); assert.deepEqual(entry.body, args[2])
|
||||
return original(...args)
|
||||
}
|
||||
await assert.rejects(verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails), /response disk failure/)
|
||||
assert.equal(f.calls.filter(row => row.method === 'POST').length, 1)
|
||||
assert.equal(saved.at(-1).requests.at(-1).status, 'SENDING')
|
||||
})
|
||||
|
||||
for (const [fault, message] of Object.entries({ 'wrong-customer': '预览精确包含', 'double-count-pallet': '共享实托只计一次', 'stale-accepted': '陈旧版本交接必须拒绝', 'unrelated-conflict': '陈旧版本交接必须拒绝',
|
||||
'pda-keeps-original': 'PDA保持原发车资源', 'rewritten-pda-departure': 'PDA保持原发车资源', 'fee-changed': '交接不新增或改写派送费用',
|
||||
'rewritten-departure': '只新增一次交接且原DEPART内容保持不变', 'replay-releases-next': '旧请求重放不能释放下一计划车辆' })) {
|
||||
test(`business counterexample ${fault} cannot be reported as successful handover`, async () => {
|
||||
const f = fixture(fault)
|
||||
await assert.rejects(verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails), new RegExp(message))
|
||||
assert.equal(f.proof.handover.status, 'FAILED'); assert.ok(f.proof.handover.requests.length > 0)
|
||||
assert.ok(!f.calls.some(row => row.path?.endsWith('/cancel')))
|
||||
})
|
||||
}
|
||||
|
||||
test('cleanup cannot run before genuine cargo completion and current-resource release', async () => {
|
||||
const f = fixture(); await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails)
|
||||
const before = f.calls.length
|
||||
await assert.rejects(cleanupFleetHandoverResources(f.context, 'test', f.proof), /整车结案/)
|
||||
assert.ok(f.calls.slice(before).filter(row => row.method).every(row => row.method === 'GET'))
|
||||
assert.equal(f.proof.handover.cleanup.status, 'FAILED')
|
||||
})
|
||||
|
||||
test('missing diagnostic correlation blocks the run before creating replacement resources', async () => {
|
||||
const f = fixture(), original = f.context.request.bind(f.context)
|
||||
f.context.request = async (...args) => { const response = await original(...args); delete response.traceId; return response }
|
||||
await assert.rejects(verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails), /读取真实在途车次/)
|
||||
assert.equal(f.vehicles.size, 0); assert.equal(f.proof.handover.requests.length, 1)
|
||||
assert.equal(f.proof.handover.requests[0].status, 'RESPONDED')
|
||||
})
|
||||
|
||||
test('explicit closure cleanup cancels only test probes and disables newly created resources without deletion', async () => {
|
||||
const f = fixture(); await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails)
|
||||
f.trip.status = 'COMPLETED'; f.trip.vehicleRelease = { actualEndAt: new Date().toISOString() }; f.trip.driverRelease = { actualEndAt: new Date().toISOString() }
|
||||
f.resources.delete('VEHICLE:12'); f.resources.delete('DRIVER:22')
|
||||
const result = await cleanupFleetHandoverResources(f.context, 'test', f.proof)
|
||||
assert.equal(result.status, 'COMPLETED'); assert.ok([...f.probes.values()].every(row => row.status === 'CANCELLED'))
|
||||
assert.ok([...f.vehicles.values()].every(row => row.status === 'INACTIVE')); assert.equal(f.drivers.get('22').active, false)
|
||||
assert.ok(!f.calls.some(row => row.method === 'DELETE'))
|
||||
})
|
||||
|
||||
test('internal helper has no environment loader, SQL or standalone execution path', async () => {
|
||||
const source = await readFile(new URL('./fleet-handover-acceptance.mjs', import.meta.url), 'utf8')
|
||||
assert.doesNotMatch(source, /process\.env|process\.argv|fetch\(|execFile|INSERT INTO|UPDATE ll_|DELETE FROM/)
|
||||
assert.match(source, /export async function verifyFleetHandover\(context, module, tripProof, pickerToken, dispatchDetails\)/)
|
||||
})
|
||||
@@ -0,0 +1,277 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
|
||||
const FLEET = '/api/platform/v1/last-leg/fleet'
|
||||
const VEHICLES = '/api/platform/v1/last-leg/master-data/vehicles'
|
||||
const DISPATCHES = '/api/platform/v1/last-leg/dispatches'
|
||||
const OPTIONS = { scenario: 'fleet-in-transit-handover', deploymentBlocker: true }
|
||||
const text = value => value == null ? '' : String(value)
|
||||
const key = () => `handover-${randomUUID()}`
|
||||
const clone = value => value == null ? value : structuredClone(value)
|
||||
const stable = value => JSON.stringify(canonical(value))
|
||||
function canonical(value) {
|
||||
if (Array.isArray(value)) return value.map(canonical)
|
||||
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(name => [name, canonical(value[name])]))
|
||||
return value
|
||||
}
|
||||
const same = (left, right) => stable(left) === stable(right)
|
||||
const validVersion = value => (typeof value !== 'number' || Number.isSafeInteger(value)) && /^(0|[1-9]\d*)$/.test(text(value))
|
||||
const transport = value => ({ vehiclePlate: value?.vehiclePlate ?? null, driverName: value?.driverName ?? null, driverPhone: value?.driverPhone ?? null })
|
||||
const count = value => Number.isSafeInteger(Number(value)) && Number(value) > 0
|
||||
const trace = response => ({ http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
|
||||
const pathFor = number => `${FLEET}/trips/${encodeURIComponent(number)}`
|
||||
const certificate = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
|
||||
|
||||
function requireRun(context, tripProof) {
|
||||
const run = context.createdResources?.fleetAcceptance
|
||||
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)
|
||||
throw new Error('在途交接仅接受当前marker登记的原车次及真实DEPART结果,禁止借用旧样本')
|
||||
return run
|
||||
}
|
||||
function io(context, module, proof) {
|
||||
const checkpoint = context.context?.checkpointFleetHandover
|
||||
if (typeof checkpoint !== 'function') throw new Error('交接验收必须注入发送前持久化检查点')
|
||||
const check = (label, passed, response = null, evidence = '') => {
|
||||
context.assert(module, label, response, Boolean(passed), evidence, OPTIONS)
|
||||
if (!passed) throw new Error(`${module}: ${label}`)
|
||||
}
|
||||
async function request(method, path, body, label, { token, conflict, file } = {}) {
|
||||
const entry = { method, path, label, actor: token ? 'PDA' : 'PLATFORM', body: clone(body), status: 'SENDING' }
|
||||
if (file) entry.file = { name: file.name, type: file.type, sha256: createHash('sha256').update(file.content).digest('hex'), bytes: file.content.length }
|
||||
proof.requests.push(entry)
|
||||
// A failed write must stop before HTTP. Keep the durable SENDING record if the process dies in flight.
|
||||
await checkpoint(proof)
|
||||
try {
|
||||
const response = file ? await context.requestMultipart(method, path, file) : await context.request(method, path, body, token)
|
||||
Object.assign(entry, trace(response), { status: 'RESPONDED', result: clone(response?.body?.data) })
|
||||
await checkpoint(proof)
|
||||
const accepted = conflict ? (response?.http === 409 || (response?.http === 200 && text(response?.body?.code) === 'A0409'))
|
||||
&& conflict.test(text(response?.body?.message)) : response?.http === 200 && text(response?.body?.code) === '200'
|
||||
check(label, accepted && response?.requestId && response?.traceId, response, stable(trace(response)))
|
||||
return response.body?.data
|
||||
} catch (error) {
|
||||
if (entry.status === 'SENDING') {
|
||||
entry.status = 'UNKNOWN_RESULT'
|
||||
await checkpoint(proof)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return { check, request }
|
||||
}
|
||||
function dispatchFacts(row) {
|
||||
const fields = ['dispatchNo', 'customerId', 'overseasWarehouseId', 'status', 'packageCount', 'pickedPackageCount', 'shippedPackageCount',
|
||||
'vehiclePlate', 'driverName', 'driverPhone', 'outboundNo', 'departAt', 'estimatedArrivalAt', 'items']
|
||||
return Object.fromEntries(fields.filter(name => row[name] !== undefined).map(name => [name, row[name]]))
|
||||
}
|
||||
function feeFacts(rows) {
|
||||
if (!Array.isArray(rows)) throw new Error('费用来源不是正式列表')
|
||||
return rows.map(row => Object.fromEntries(['sourceFeeId', 'sourceVersion', 'sourceStatus', 'feeItemCode', 'direction', 'originalAmount', 'originalCurrency']
|
||||
.map(name => [name, row[name]]))).sort((a, b) => text(a.sourceFeeId).localeCompare(text(b.sourceFeeId)))
|
||||
}
|
||||
function inspectionFacts(value) {
|
||||
return { ...value, remainingPalletIds: [...(value?.remainingPalletIds || [])].sort() }
|
||||
}
|
||||
function thousandths(value) {
|
||||
const match = /^(\d+)(?:\.(\d{1,3}))?$/.exec(text(value))
|
||||
if (!match) throw new Error('实测载荷不是有效的三位小数')
|
||||
return BigInt(match[1]) * 1000n + BigInt((match[2] || '').padEnd(3, '0'))
|
||||
}
|
||||
|
||||
/** Internal fresh-run helper, called only after the caller's real departure + replay and before signing. */
|
||||
export async function verifyFleetHandover(context, module, tripProof, pickerToken, dispatchDetails) {
|
||||
const run = requireRun(context, tripProof)
|
||||
if (tripProof.handover || !pickerToken || !Array.isArray(dispatchDetails) || !dispatchDetails.length)
|
||||
throw new Error('交接验收缺少本轮PDA或排车依据,或已开始;保留原请求,不自动续跑')
|
||||
const numbers = dispatchDetails.map(row => row.dispatchNo)
|
||||
if (new Set(numbers).size !== numbers.length || numbers.some(number => !number)
|
||||
|| !same([...numbers].sort(), run.departure.dispatches?.map(row => row.dispatchNo).sort())
|
||||
|| !dispatchDetails.every(row => count(row.packageCount))
|
||||
|| Number(run.departure.shippedPackages) !== dispatchDetails.reduce((sum, row) => sum + Number(row.packageCount), 0))
|
||||
throw new Error('交接输入排车清单及箱数未覆盖本轮真实发车')
|
||||
const proof = tripProof.handover = { marker: context.marker, fixtureMarker: tripProof.marker, status: 'CHECKING', requests: [], probes: [],
|
||||
dispatchNos: [...numbers], stockEvidence: 'CALLER_FULL_CHAIN_INVENTORY_AUDIT_REQUIRED' }
|
||||
const { check, request } = io(context, module, proof), base = pathFor(tripProof.tripNo)
|
||||
try {
|
||||
const detail = await request('GET', base, undefined, '读取真实在途车次及分配资源')
|
||||
check('真实车次属于本轮且已经发车', detail?.trip?.tripNo === tripProof.tripNo && detail.trip.status === 'IN_TRANSIT'
|
||||
&& detail.trip.departedAt === run.departure.departedAt && text(detail.trip.vehicleId) === text(tripProof.vehicleId)
|
||||
&& text(detail.trip.driverId) === text(tripProof.driverId) && text(detail.trip.warehouseId) === text(tripProof.warehouseId)
|
||||
&& validVersion(detail.trip.recordVersion) && same(detail.dispatches?.map(row => row.dispatchNo).sort(), [...numbers].sort()))
|
||||
const initialHistory = await request('GET', `${base}/history?page=1&size=200`, undefined, '读取不可变原DEPART证据')
|
||||
check('原始发车历史唯一且没有此前交接', Array.isArray(initialHistory?.records)
|
||||
&& Number(initialHistory.total) === initialHistory.records.length
|
||||
&& initialHistory.records.filter(row => row.action === 'DEPART').length === 1 && !initialHistory.records.some(row => row.action === 'HANDOVER'))
|
||||
proof.originalDeparture = clone(initialHistory.records.find(row => row.action === 'DEPART'))
|
||||
const original = proof.originalDeparture.snapshot
|
||||
check('原DEPART装载确属本车及当前分配', original?.trip?.tripNo === tripProof.tripNo && original.trip.departedAt === detail.trip.departedAt
|
||||
&& text(original.trip.vehicleId) === text(detail.trip.vehicleId) && text(original.trip.driverId) === text(detail.trip.driverId)
|
||||
&& original.confirmedLoad?.cargo?.length > 0 && original.confirmedLoad?.pallets?.length > 0)
|
||||
proof.previous = { vehicleId: text(detail.trip.vehicleId), driverId: text(detail.trip.driverId), ...transport(detail.trip) }
|
||||
proof.dispatchBefore = []; proof.pdaBefore = []; proof.feesBefore = []
|
||||
for (const expected of dispatchDetails) {
|
||||
const row = await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对客户排车已经真实出库')
|
||||
const binding = detail.dispatches.find(item => item.dispatchNo === expected.dispatchNo)
|
||||
check('每张排车已发车且货量与客户起运仓一致', row?.dispatchNo === expected.dispatchNo && row.status === 'DEPARTED'
|
||||
&& Number(row.shippedPackageCount) === Number(expected.packageCount) && Number(row.pickedPackageCount) === Number(expected.packageCount)
|
||||
&& text(row.customerId) === text(binding.customerId) && text(row.overseasWarehouseId) === text(tripProof.warehouseId))
|
||||
proof.dispatchBefore.push(dispatchFacts(row))
|
||||
const pda = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(row.dispatchNo)}/handover-status`, undefined, 'PDA读取原运输资源', { token: pickerToken })
|
||||
check('PDA原资源与未签收货物对应当前排车', pda?.dispatchNo === row.dispatchNo && pda.tripNo === tripProof.tripNo
|
||||
&& pda.latestHandover == null && same(transport(pda.originalDeparture), transport(original.trip)) && pda.deliveries?.length > 0
|
||||
&& pda.deliveries.every(item => !item.terminalAt && item.status === 'WAIT_DELIVERY' && same(transport(item.transport), transport(detail.trip)))
|
||||
&& pda.deliveries.reduce((sum, item) => sum + Number(item.packages), 0) === Number(expected.packageCount))
|
||||
proof.pdaBefore.push(pda)
|
||||
for (const delivery of pda.deliveries) {
|
||||
const path = `/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({ customerId: text(binding.customerId), businessType: 'DELIVERY_ORDER', businessNo: delivery.deliveryNo })}`
|
||||
proof.feesBefore.push({ path, facts: feeFacts(await request('GET', path, undefined, '冻结本次派送费用来源')) })
|
||||
}
|
||||
}
|
||||
const capacity = { maxPalletSpaces: detail.trip.maxPalletSpaces, maxLoadKg: detail.trip.maxLoadKg, maxVolumeCbm: detail.trip.maxVolumeCbm }
|
||||
check('使用正式原车辆容量及有效任务结束时间', Object.values(capacity).every(value => Number(value) > 0 && Number.isFinite(Number(value)))
|
||||
&& Date.parse(detail.trip.expectedDriverEndAt) > Date.now())
|
||||
const marker = `HO-${randomUUID().slice(0, 12)}`
|
||||
const vehicleBody = { plateNumber: marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 0,
|
||||
region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', ...capacity, remark: `${context.marker} controlled TEST handover vehicle` }
|
||||
proof.replacement = { marker, vehicleRequest: vehicleBody }
|
||||
const vehicle = await request('POST', VEHICLES, vehicleBody, '建立本轮受控接替车辆')
|
||||
check('接替车辆返回真实身份及车牌', vehicle?.id && vehicle.plateNumber === marker)
|
||||
proof.replacement.vehicleId = text(vehicle.id); proof.replacement.vehiclePlate = vehicle.plateNumber
|
||||
const driver = await request('POST', `${FLEET}/drivers`, { name: `${marker}测试司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS',
|
||||
licenseNumber: `${marker}-TEST-NOT-REAL`, licenseType: 'TEST', licenseZone: 'UTC', licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10),
|
||||
remark: `${context.marker} TEST credential fixture; not a real driving licence` }, '建立本轮接替司机档案')
|
||||
check('接替司机返回身份和版本', driver?.id && validVersion(driver.recordVersion))
|
||||
proof.replacement.driverId = text(driver.id)
|
||||
const file = { name: `${marker}-TEST-NOT-REAL-LICENSE.png`, type: 'image/png', content: certificate }
|
||||
proof.replacement.certificate = { name: file.name, type: file.type, contentBase64: certificate.toString('base64'), sha256: createHash('sha256').update(certificate).digest('hex'), synthetic: true }
|
||||
const uploaded = await request('POST', `${FLEET}/drivers/${encodeURIComponent(driver.id)}/license?recordVersion=${driver.recordVersion}`, undefined, '正式上传非真实测试证照', { file })
|
||||
check('测试证照有正式文件归属', uploaded?.licenseFileId && validVersion(uploaded.recordVersion))
|
||||
proof.replacement.licenseFileId = text(uploaded.licenseFileId)
|
||||
const reviewed = await request('POST', `${FLEET}/drivers/${encodeURIComponent(driver.id)}/review`, { recordVersion: uploaded.recordVersion, decision: 'APPROVED', reason: '仅核验本轮合成测试证照' }, '正式审核本轮接替司机')
|
||||
check('接替司机已正式审核', reviewed?.reviewStatus === 'APPROVED')
|
||||
proof.selection = { recordVersion: detail.trip.recordVersion, vehicleId: vehicle.id, driverId: driver.id,
|
||||
expectedDriverEndAt: detail.trip.expectedDriverEndAt, handedOverAt: new Date().toISOString() }
|
||||
const preview = await request('POST', `${base}/handovers/preview`, proof.selection, '预览真实剩余货物及资源资格')
|
||||
const expectedCargo = original.confirmedLoad.cargo.map(row => ({ customerId: row.customerId, dispatchNo: row.dispatchNo, itemId: row.dispatchItemId,
|
||||
palletId: row.warehousePalletId, destinationKey: row.destinationKey, packages: row.packages }))
|
||||
check('预览精确包含全部未签收客户货物且资格有效', /^[0-9a-f]{64}$/.test(text(preview?.cargoHash)) && preview.driverQualification?.canDispatch === true
|
||||
&& same(preview.cargo?.remainingCargo, expectedCargo) && preview.cargo.completedDeliveries?.length === 0
|
||||
&& same([...preview.cargo.remainingPalletIds].sort(), [...new Set(expectedCargo.map(row => row.palletId))].sort()))
|
||||
const measured = original.confirmedLoad.pallets
|
||||
check('剩余载荷沿用原DEPART实测且共享实托只计一次', new Set(measured.map(row => row.warehousePalletId)).size === measured.length
|
||||
&& same(measured.map(row => row.warehousePalletId).sort(), [...preview.cargo.remainingPalletIds].sort())
|
||||
&& Number(preview.cargo.occupied?.palletSpaces) === measured.reduce((sum, row) => sum + Number(row.palletSpaces), 0)
|
||||
&& thousandths(preview.cargo.occupied?.weightKg) === measured.reduce((sum, row) => sum + thousandths(row.weightKg), 0n)
|
||||
&& thousandths(preview.cargo.occupied?.volumeCbm) === measured.reduce((sum, row) => sum + thousandths(row.volumeCbm), 0n))
|
||||
proof.preview = preview
|
||||
proof.confirmRequest = { selection: proof.selection, cargoHash: preview.cargoHash, location: `${context.marker}受控交接点`, reason: '本轮真实发运后整车资源交接', idempotencyKey: key() }
|
||||
const handover = await request('POST', `${base}/handovers`, proof.confirmRequest, '确认实际在途交接')
|
||||
check('交接保留原新资源和货物快照', handover?.handoverNo && handover.tripNo === tripProof.tripNo && validVersion(handover.recordVersion)
|
||||
&& text(handover.snapshot?.previous?.vehicleId) === proof.previous.vehicleId && text(handover.snapshot.previous.driverId) === proof.previous.driverId
|
||||
&& text(handover.snapshot.replacement.vehicleId) === text(vehicle.id) && text(handover.snapshot.replacement.driverId) === text(driver.id)
|
||||
&& same(inspectionFacts(handover.snapshot.cargo), inspectionFacts(preview.cargo)) && handover.vehicleRelease == null && handover.driverRelease == null)
|
||||
proof.handoverNo = handover.handoverNo; proof.confirmed = handover
|
||||
const persisted = await request('GET', `${base}/handovers?page=1&size=10`, undefined, '读取持久化交接事实')
|
||||
check('只生成一份持久化交接记录', Number(persisted?.total) === 1 && persisted.records?.[0]?.handoverNo === handover.handoverNo)
|
||||
proof.persisted = persisted.records[0]
|
||||
const replay = await request('POST', `${base}/handovers`, proof.confirmRequest, '原交接请求重放')
|
||||
check('重放返回同一持久化记录而不重复交接', same(replay, proof.persisted))
|
||||
const after = await request('GET', base, undefined, '交接后读取车次状态和当前资源')
|
||||
check('交接保持在途且版本只增加一次', after.trip?.status === 'IN_TRANSIT' && after.trip.departedAt === detail.trip.departedAt
|
||||
&& BigInt(text(after.trip.recordVersion)) === BigInt(text(detail.trip.recordVersion)) + 1n
|
||||
&& text(after.trip.vehicleId) === text(vehicle.id) && text(after.trip.driverId) === text(driver.id))
|
||||
proof.staleRequest = { ...clone(proof.confirmRequest), idempotencyKey: key() }
|
||||
await request('POST', `${base}/handovers`, proof.staleRequest, '陈旧版本交接必须拒绝', { conflict: /车次.*(变化|修改)|版本/ })
|
||||
check('陈旧命令没有改变车次版本或资源', same(await request('GET', base, undefined, '拒绝后核对车次零变更'), after))
|
||||
const firstProbe = await createProbe(1)
|
||||
const assignment = { recordVersion: firstProbe.recordVersion, vehicleId: proof.previous.vehicleId, driverId: proof.previous.driverId, reason: '本轮旧资源占用核对' }
|
||||
await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '交接后旧车辆仍被原车次占用', { conflict: /车辆.*占用/ })
|
||||
const releaseBase = `${base}/handovers/${encodeURIComponent(handover.handoverNo)}/resources/release`
|
||||
proof.vehicleReleaseRequest = { recordVersion: handover.recordVersion, resourceKind: 'VEHICLE', actualEndAt: new Date().toISOString(), location: `${context.marker}车辆任务结束点`, vehicleStatus: 'IN_SERVICE', idempotencyKey: key() }
|
||||
const vehicleReleased = await request('POST', releaseBase, proof.vehicleReleaseRequest, '单独结束交接前车辆任务')
|
||||
check('原车辆已结束而原司机仍在任务中', vehicleReleased.vehicleRelease?.actualEndAt && vehicleReleased.driverRelease == null)
|
||||
await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '只释放车辆后旧司机仍被占用', { conflict: /司机.*占用/ })
|
||||
proof.driverReleaseRequest = { recordVersion: vehicleReleased.recordVersion, resourceKind: 'DRIVER', actualEndAt: new Date().toISOString(), location: `${context.marker}司机任务结束点`, idempotencyKey: key() }
|
||||
const released = await request('POST', releaseBase, proof.driverReleaseRequest, '单独结束交接前司机任务')
|
||||
check('原车辆司机分别保留结束事实', released.vehicleRelease?.actualEndAt && released.driverRelease?.actualEndAt)
|
||||
const assigned = await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '将已释放原资源分配给本轮无货计划')
|
||||
firstProbe.recordVersion = assigned.trip.recordVersion
|
||||
check('原资源真正可重新分配', text(assigned.trip.vehicleId) === proof.previous.vehicleId && text(assigned.trip.driverId) === proof.previous.driverId)
|
||||
const persistedReleases = await request('GET', `${base}/handovers?page=1&size=10`, undefined, '读取持久化资源结束记录')
|
||||
const releaseSnapshot = persistedReleases.records?.find(row => row.handoverNo === proof.handoverNo)
|
||||
check('原车辆结束重放不改记录', same(await request('POST', releaseBase, proof.vehicleReleaseRequest, '新计划占用后重放旧车辆结束请求'), releaseSnapshot))
|
||||
check('原司机结束重放不改记录', same(await request('POST', releaseBase, proof.driverReleaseRequest, '新计划占用后重放旧司机结束请求'), releaseSnapshot))
|
||||
const secondProbe = await createProbe(2)
|
||||
await request('POST', `${pathFor(secondProbe.tripNo)}/assignment`, { ...assignment, recordVersion: secondProbe.recordVersion }, '旧请求重放不能释放下一计划车辆', { conflict: /车辆.*占用/ })
|
||||
const spareBody = { ...vehicleBody, plateNumber: `HO-PROBE-${randomUUID().slice(0, 10)}` }
|
||||
const spare = await request('POST', VEHICLES, spareBody, '创建仅用于司机占用隔离的本轮车辆')
|
||||
check('隔离车辆已创建', spare?.id); proof.spareVehicleId = text(spare.id)
|
||||
await request('POST', `${pathFor(secondProbe.tripNo)}/assignment`, { ...assignment, vehicleId: spare.id, recordVersion: secondProbe.recordVersion }, '旧请求重放不能释放下一计划司机', { conflict: /司机.*占用/ })
|
||||
for (const expected of proof.dispatchBefore) check('交接不改变原排车出库和货物归属', same(dispatchFacts(await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对排车原始出库事实')), expected))
|
||||
proof.pdaAfter = []
|
||||
for (const previous of proof.pdaBefore) {
|
||||
const current = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(previous.dispatchNo)}/handover-status`, undefined, 'PDA只读核对接替资源与原始发车', { token: pickerToken })
|
||||
check('PDA保持原发车资源并展示每票接替资源', current.dispatchNo === previous.dispatchNo && current.tripNo === tripProof.tripNo
|
||||
&& same(current.originalDeparture, previous.originalDeparture) && current.latestHandover?.handoverNo === proof.handoverNo
|
||||
&& same(current.deliveries.map(({ transport: ignored, ...rest }) => rest), previous.deliveries.map(({ transport: ignored, ...rest }) => rest))
|
||||
&& current.deliveries.every(row => same(transport(row.transport), transport(handover.snapshot.replacement))))
|
||||
proof.pdaAfter.push(current)
|
||||
}
|
||||
for (const fees of proof.feesBefore) check('交接不新增或改写派送费用', same(feeFacts(await request('GET', fees.path, undefined, '交接后核对原费用来源')), fees.facts))
|
||||
const historyAfter = await request('GET', `${base}/history?page=1&size=200`, undefined, '交接完成后核对原DEPART不变')
|
||||
check('只新增一次交接且原DEPART内容保持不变', historyAfter.records?.filter(row => row.action === 'HANDOVER').length === 1
|
||||
&& same(historyAfter.records.filter(row => row.action === 'DEPART'), [proof.originalDeparture]))
|
||||
const final = await request('GET', base, undefined, '保存当前接替资源供后续真实签收结案')
|
||||
proof.finalTrip = final.trip; proof.requestCount = proof.requests.length
|
||||
proof.pdaReadCount = proof.requests.filter(row => row.actor === 'PDA').length
|
||||
proof.browser = { tripNo: tripProof.tripNo, dispatchNos: numbers, handoverNo: proof.handoverNo,
|
||||
originalVehiclePlate: proof.previous.vehiclePlate, replacementVehiclePlate: handover.snapshot.replacement.vehiclePlate,
|
||||
certificateFileId: proof.replacement.licenseFileId, expectedPdaReadsPerOpenedDispatch: 1 }
|
||||
proof.status = 'VERIFIED'
|
||||
// Caller finishFleetTrip must finish the currently assigned resources, not the original pair.
|
||||
tripProof.vehicleId = proof.replacement.vehicleId; tripProof.driverId = proof.replacement.driverId; tripProof.licenseFileId = proof.replacement.licenseFileId
|
||||
return proof
|
||||
} catch (error) { proof.status = 'FAILED'; proof.failure = { message: error.message, requestCount: proof.requests.length }; throw error }
|
||||
|
||||
async function createProbe(index) {
|
||||
const saved = { marker: context.marker, index }; proof.probes.push(saved)
|
||||
const value = await request('POST', `${FLEET}/trips`, { warehouseId: tripProof.warehouseId, idempotencyKey: key(),
|
||||
plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), expectedDriverEndAt: new Date(Date.now() + 48 * 3600000).toISOString(),
|
||||
stops: tripProof.stops, remark: `${context.marker}在途交接资源验证无货计划${index}` }, '创建本轮无货资源核对计划')
|
||||
check('核对计划不承载货物且尚未发车', value?.trip?.tripNo && value.trip.status === 'PLANNED' && value.dispatches?.length === 0 && validVersion(value.trip.recordVersion))
|
||||
saved.tripNo = value.trip.tripNo; saved.recordVersion = value.trip.recordVersion; return saved
|
||||
}
|
||||
}
|
||||
|
||||
/** Optional explicit cleanup, only after the caller signed every shipment and released the current trip's resources. */
|
||||
export async function cleanupFleetHandoverResources(context, module, tripProof) {
|
||||
requireRun(context, tripProof)
|
||||
const proof = tripProof.handover
|
||||
if (proof?.status !== 'VERIFIED' || proof.marker !== context.marker || proof.cleanup)
|
||||
throw new Error('仅允许已验证本轮交接的一次性结案后清理;失败不自动重试')
|
||||
const cleanup = proof.cleanup = { status: 'CHECKING', requests: [] }, { check, request } = io(context, module, cleanup)
|
||||
try {
|
||||
const current = await request('GET', pathFor(tripProof.tripNo), undefined, '清理前核对整车货物结案及当前资源释放')
|
||||
check('整车结案且接替车辆司机均已释放', current.trip?.status === 'COMPLETED' && current.trip.vehicleRelease && current.trip.driverRelease
|
||||
&& text(current.trip.vehicleId) === proof.replacement.vehicleId && text(current.trip.driverId) === proof.replacement.driverId)
|
||||
for (const probe of [...proof.probes].reverse()) {
|
||||
const path = pathFor(probe.tripNo), detail = await request('GET', path, undefined, '核对仅本轮无货资源计划')
|
||||
check('仅取消本轮未执行无货计划', detail.trip?.tripNo === probe.tripNo && detail.trip.status === 'PLANNED' && detail.trip.remark?.includes(context.marker) && detail.dispatches?.length === 0)
|
||||
const cancelled = await request('POST', `${path}/cancel`, { recordVersion: detail.trip.recordVersion, reason: '本轮交接验证完成,取消无货核对计划' }, '取消本轮资源核对计划')
|
||||
check('无货计划已取消', cancelled.trip?.status === 'CANCELLED')
|
||||
}
|
||||
for (const vehicleId of [proof.replacement.vehicleId, proof.spareVehicleId]) {
|
||||
const vehicle = await request('GET', `${VEHICLES}/${encodeURIComponent(vehicleId)}`, undefined, '读取本轮接替或隔离车辆版本')
|
||||
check('停用目标确属本轮新建车辆', vehicle.remark?.includes(context.marker) && text(vehicle.id) === vehicleId)
|
||||
const stopped = await request('PATCH', `${VEHICLES}/${encodeURIComponent(vehicleId)}/status`, { recordVersion: vehicle.recordVersion, status: 'INACTIVE' }, '停用本轮新增车辆并保留记录')
|
||||
check('本轮车辆已停用', stopped.status === 'INACTIVE')
|
||||
}
|
||||
const driverPath = `${FLEET}/drivers/${encodeURIComponent(proof.replacement.driverId)}`
|
||||
const driver = await request('GET', driverPath, undefined, '读取本轮接替司机版本')
|
||||
check('停用司机确属本轮受控档案', driver.details?.remark?.includes(context.marker) && text(driver.id) === proof.replacement.driverId)
|
||||
const stopped = await request('PATCH', `${driverPath}/status`, { recordVersion: driver.recordVersion, active: false, reason: '本轮完整验收结束,保留测试证照与审计记录' }, '停用本轮接替司机')
|
||||
check('本轮接替司机已停用', stopped.active === false)
|
||||
cleanup.status = 'COMPLETED'; return cleanup
|
||||
} catch (error) { cleanup.status = 'FAILED'; cleanup.failure = error.message; throw error }
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { run, runQuoteSession, verifyOperations, requireCurrentFixture, readCsv, fixtureContract } from './fleet-quotes-operations.mjs'
|
||||
import { AcceptanceRun } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
const copy = value => structuredClone(value)
|
||||
function harness(fault) {
|
||||
const marker = 'FLEET-CURRENT-RUN', tripNo = 'TR-CURRENT', departedAt = '2026-10-03T01:00:00Z'
|
||||
const rows = new Map(), histories = new Map(), commands = new Map(), measuredKeys = new Map(), calls = []
|
||||
let next = 0, supplierStatus = 'ACTIVE'
|
||||
let measured = { tripNo, recordVersion: '0', details: { actualDistanceMiles: null, actualFuelLitres: null, remark: null } }
|
||||
const measuredHistory = []
|
||||
const expected = { physicalPallets: 1, palletSpaces: 2, ownPackages: 11, externalPackages: 4, externalAgreedAmounts: [{ currency: 'USD', amount: 0 }], costs: [] }
|
||||
const response = (data, code = '200', http = 200) => ({ http, body: { code, data: copy(data) }, requestId: 'offline-request', traceId: 'offline-trace' })
|
||||
function snapshot(row, action) { histories.get(row.quoteNo).unshift({ action, recordVersion: row.recordVersion, snapshot: copy(row) }) }
|
||||
const natural = d => JSON.stringify([d.kind, d.partyId, d.originCity, d.destinationCode, d.distanceMiles, d.currency, d.validFrom, d.validTo])
|
||||
function create(request, batchHash = '') {
|
||||
const hash = batchHash + JSON.stringify(request.details), prior = commands.get(request.idempotencyKey)
|
||||
if (prior) return prior.hash === hash ? response(fault === 'mutable creation replay' ? rows.get(prior.row.quoteNo) : prior.row) : response(null, 'A0409')
|
||||
const d = request.details
|
||||
if ([d.selfPickupPrice, d.fullDeliveryPrice, d.looseDeliveryPrice].every(v => v == null)) return response(null, 'A0400')
|
||||
if ([...rows.values()].some(r => natural(r.details) === natural(d))) return response(null, 'A0409')
|
||||
const row = { quoteNo: `FQ-${++next}`, recordVersion: '0', details: copy(d), partyName: '本轮往来单位', status: 'ACTIVE' }
|
||||
if (fault === 'null price becomes zero') row.details.selfPickupPrice = 0
|
||||
rows.set(row.quoteNo, row); histories.set(row.quoteNo, []); snapshot(row, 'CREATE'); commands.set(request.idempotencyKey, { hash, row: copy(row) }); return response(row)
|
||||
}
|
||||
const context = {
|
||||
marker, context: { fleetQuotesOperationsFixture: { marker, stage: fixtureContract.stage, departureVerified: true, tripNo, warehouseId: '92', departedAt, expected } },
|
||||
createdResources: { merchantQualificationFixture: { marker, tenantId: '101' } },
|
||||
assert(_module, label, _response, condition) { assert.ok(condition, label) },
|
||||
apiSuccess(_module, label, result) { assert.equal(result.http, 200, label); assert.equal(result.body.code, '200', label); return result.body.data },
|
||||
async request(method, path, body) {
|
||||
calls.push({ method, path, body: copy(body) }); const u = new URL(path, 'http://offline.invalid'), p = u.pathname
|
||||
if (p === '/api/platform/v1/last-leg/fleet/trips/TR-CURRENT') return response({ trip: { tripNo, warehouseId: '92', departedAt, status: 'IN_TRANSIT', remark: fault === 'historical trip' ? 'OLD' : marker } })
|
||||
if (p === '/api/platform/v1/suppliers' && method === 'POST') {
|
||||
for (const field of ['name', 'region', 'legalRepresentative', 'contactName', 'phone', 'settlementPeriod']) assert.ok(body[field], field)
|
||||
assert.equal(body.truckingScope, 'OVERSEAS'); return response('901')
|
||||
}
|
||||
if (p === '/api/platform/v1/suppliers/901/status') { supplierStatus = body.status; return response(null) }
|
||||
if (p === '/api/platform/v1/suppliers/901') return response({ id: '901', status: supplierStatus })
|
||||
if (p === '/api/platform/v1/last-leg/fleet/quotes/import') {
|
||||
const backup = { rows: copy(rows), histories: copy(histories), commands: copy(commands) }, result = []
|
||||
for (let index = 0; index < body.rows.length; index++) {
|
||||
const outcome = create({ idempotencyKey: `${body.idempotencyKey}:${index + 1}`, details: body.rows[index] }, JSON.stringify(body.rows))
|
||||
if (outcome.body.code !== '200') {
|
||||
if (fault !== 'partial import leak') { rows.clear(); histories.clear(); commands.clear(); backup.rows.forEach((v, k) => rows.set(k, v)); backup.histories.forEach((v, k) => histories.set(k, v)); backup.commands.forEach((v, k) => commands.set(k, v)) }
|
||||
return outcome
|
||||
}
|
||||
result.push(outcome.body.data)
|
||||
}
|
||||
return response({ importedRows: result.length, records: result })
|
||||
}
|
||||
if (p === '/api/platform/v1/last-leg/fleet/quotes') {
|
||||
if (method === 'POST') return create(body)
|
||||
let records = [...rows.values()]
|
||||
for (const field of ['kind', 'status']) if (u.searchParams.get(field)) records = records.filter(r => (field === 'kind' ? r.details.kind : r.status) === u.searchParams.get(field))
|
||||
const keyword = u.searchParams.get('keyword') || ''; records = records.filter(r => [r.details.originCity, r.details.destinationCode, r.quoteNo].some(v => v.includes(keyword)))
|
||||
if (fault === 'inactive leaks into active list' && u.searchParams.get('status') === 'ACTIVE') records.push([...rows.values()].find(r => r.status === 'INACTIVE'))
|
||||
return response({ records, total: String(records.length), page: '1', size: '200', pages: '1' })
|
||||
}
|
||||
const quote = p.match(/\/quotes\/(FQ-\d+)(?:\/(history|status))?$/)
|
||||
if (quote) {
|
||||
const row = rows.get(quote[1]); assert.ok(row)
|
||||
if (method === 'GET') return response(quote[2] === 'history' ? histories.get(row.quoteNo) : row)
|
||||
if (String(body.recordVersion) !== row.recordVersion) {
|
||||
if (fault === 'stale write accepted' && method === 'PUT') { row.details = copy(body.details); return response(row) }
|
||||
return response(null, 'A0409')
|
||||
}
|
||||
if (method === 'PUT') row.details = copy(body.details); else row.status = body.status
|
||||
row.recordVersion = String(Number(row.recordVersion) + 1); snapshot(row, method === 'PUT' ? 'UPDATE' : body.status); return response(row)
|
||||
}
|
||||
if (p === '/api/platform/v1/last-leg/fleet/operations/TR-CURRENT/record/history') return response({ records: measuredHistory, total: String(measuredHistory.length), page: '1', size: '20', pages: '1' })
|
||||
if (p === '/api/platform/v1/last-leg/fleet/operations/TR-CURRENT/record') {
|
||||
if (method === 'GET') return response(measured)
|
||||
const prior = measuredKeys.get(body.idempotencyKey)
|
||||
if (prior) { if (fault === 'measured replay adds history') measuredHistory.push(copy(measured)); return response(prior) }
|
||||
if (String(body.recordVersion) !== measured.recordVersion) return response(null, 'A0409')
|
||||
measured = { tripNo, recordVersion: String(Number(measured.recordVersion) + 1), details: copy(body.details) }
|
||||
measuredKeys.set(body.idempotencyKey, copy(measured)); measuredHistory.unshift(copy(measured)); return response(measured)
|
||||
}
|
||||
if (p === '/api/platform/v1/last-leg/fleet/operations') {
|
||||
assert.equal(u.searchParams.get('keyword'), tripNo); assert.equal(u.searchParams.get('warehouseId'), '92')
|
||||
const metrics = { physicalPallets: 1, palletSpaces: 2, ownPackages: 11, externalPackages: 4, externalAgreedAmounts: [{ currency: 'USD', amount: '0.00' }] }
|
||||
const row = { tripNo, ...metrics, measured: measured.details, financeStatus: 'NOT_QUERIED', costs: { totals: [] }, costStatus: fault === 'unknown cost becomes zero' ? 'AVAILABLE' : 'NOT_RECORDED' }
|
||||
return response({ page: { records: [row], total: '1' }, summary: { trips: fault === 'counts destinations as trips' ? '2' : '1', ...metrics, costs: { visible: true, unrecordedTrips: 1 } }, charts: { physicalPallets: [{ points: [{ value: 1 }] }] } })
|
||||
}
|
||||
throw new Error(`Unexpected offline HTTP: ${method} ${path}`)
|
||||
},
|
||||
async requestBinary(method, path) {
|
||||
calls.push({ method, path }); const u = new URL(path, 'http://offline.invalid')
|
||||
const error = response(null, 'A0400')
|
||||
if (u.pathname.includes('/quotes/') && u.searchParams.get('size') === '0') { const invalid = response(null, 'A0422'); return { ...invalid, contentType: 'application/json', bytes: Buffer.from(JSON.stringify(invalid.body)) } }
|
||||
if (fault === 'json export masquerades as csv' || (u.searchParams.has('from') && Date.parse(u.searchParams.get('from')) > Date.parse(u.searchParams.get('to')))) return { ...error, contentType: fault ? 'text/csv' : 'application/json', bytes: Buffer.from(JSON.stringify(error.body)) }
|
||||
const text = u.pathname.includes('/quotes/') ? `\uFEFF报价号,金额\r\n${[...rows.keys()].join('\r\n')}` : `\uFEFF车次号,实际里程(mile)\r\n${tripNo},25.5`
|
||||
return { http: 200, body: null, bytes: Buffer.from(text), contentType: 'text/csv;charset=UTF-8', requestId: 'offline-request', traceId: 'offline-trace' }
|
||||
},
|
||||
}
|
||||
return { context, rows, calls, supplierStatus: () => supplierStatus }
|
||||
}
|
||||
|
||||
test('offline full flow checks quotes, atomic import, current departure and measured history without live network', async () => {
|
||||
const { context, rows, calls, supplierStatus } = harness(); await run(context); await verifyOperations(context, { writeMeasured: true, idempotencyNamespace: 'OPERATIONS-INDEPENDENT-KEY' })
|
||||
const proof = context.createdResources.fleetQuoteAcceptance
|
||||
assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.financePayment, 'NOT_TESTED'); assert.equal(proof.upstreamDeparture, 'NOT_TESTED')
|
||||
assert.equal(proof.operationsStatus, 'NOT_TESTED'); assert.equal(proof.quoteStatus, 'VERIFIED')
|
||||
assert.equal(context.createdResources.fleetOperationsAcceptance.operationsStatus, 'VERIFIED'); assert.equal(context.createdResources.fleetOperationsAcceptance.quoteStatus, 'NOT_TESTED')
|
||||
assert.equal(rows.size, 4); assert.ok([...rows.values()].every(r => r.status === 'INACTIVE')); assert.equal(supplierStatus(), 'INACTIVE')
|
||||
assert.ok(!calls.some(call => call.method === 'DELETE')); assert.ok(!JSON.stringify(proof).includes('password'))
|
||||
})
|
||||
for (const fault of ['null price becomes zero', 'mutable creation replay', 'partial import leak', 'inactive leaks into active list', 'stale write accepted', 'counts destinations as trips', 'unknown cost becomes zero', 'measured replay adds history', 'json export masquerades as csv', 'historical trip']) {
|
||||
test(`verifier fails closed for ${fault}`, async () => {
|
||||
const { context } = harness(fault)
|
||||
const operations = ['counts destinations as trips', 'unknown cost becomes zero', 'measured replay adds history', 'historical trip'].includes(fault)
|
||||
await assert.rejects(operations ? verifyOperations(context, { writeMeasured: true, idempotencyNamespace: 'INDEPENDENT-KEY' }) : run(context))
|
||||
assert.equal(context.createdResources[operations ? 'fleetOperationsAcceptance' : 'fleetQuoteAcceptance'].status, 'FAILED')
|
||||
})
|
||||
}
|
||||
test('missing or stale fixture fails before any HTTP and cannot be treated as skipped success', async () => {
|
||||
for (const alter of [c => delete c.context.fleetQuotesOperationsFixture, c => { c.context.fleetQuotesOperationsFixture.marker = 'OLD' }, c => { c.context.fleetQuotesOperationsFixture.departureVerified = false }]) {
|
||||
const { context, calls } = harness(); alter(context); await assert.rejects(verifyOperations(context), /current-run/); assert.equal(calls.length, 0); assert.equal(context.createdResources.fleetOperationsAcceptance.status, 'FAILED')
|
||||
}
|
||||
const { context, calls } = harness(); context.createdResources.merchantQualificationFixture.marker = 'OLD'
|
||||
await assert.rejects(run(context), /current-run/); assert.equal(calls.length, 0)
|
||||
})
|
||||
test('proof cannot be overwritten on rerun and CSV detector rejects misleading success content', async () => {
|
||||
const { context } = harness(); await run(context); const proof = context.createdResources.fleetQuoteAcceptance
|
||||
await assert.rejects(run(context), /Existing proof/); assert.equal(context.createdResources.fleetQuoteAcceptance, proof)
|
||||
assert.throws(() => readCsv({ http: 200, contentType: 'text/csv', bytes: Buffer.from('{"code":"A0403"}') }, '报价号'), /not a valid CSV/)
|
||||
assert.throws(() => requireCurrentFixture({ marker: 'x' }), /current-run/)
|
||||
})
|
||||
test('unknown expected money is rejected before HTTP instead of comparing as zero', async () => {
|
||||
const { context, calls } = harness(); context.context.fleetQuotesOperationsFixture.expected.externalAgreedAmounts[0].amount = null
|
||||
await assert.rejects(verifyOperations(context), /never convert unknown to zero/); assert.equal(calls.length, 0)
|
||||
})
|
||||
test('quote acceptance is independent of DEPART and never requests operations, inventory or finance', async () => {
|
||||
const { context, calls } = harness(); delete context.context.fleetQuotesOperationsFixture
|
||||
await run(context)
|
||||
assert.equal(context.createdResources.fleetQuoteAcceptance.scope, 'QUOTES_ONLY')
|
||||
assert.equal(context.createdResources.fleetQuoteAcceptance.operationsStatus, 'NOT_TESTED')
|
||||
assert.ok(calls.every(call => !/operations|inventory|finance|\/trips\//.test(call.path)))
|
||||
})
|
||||
test('operations defaults to read-only and never creates customer, supplier or quote records', async () => {
|
||||
const { context, calls, rows } = harness(); delete context.createdResources.merchantQualificationFixture
|
||||
await verifyOperations(context)
|
||||
assert.ok(calls.every(call => call.method === 'GET')); assert.equal(rows.size, 0)
|
||||
assert.equal(context.createdResources.fleetOperationsAcceptance.measuredStatus, 'NOT_TESTED')
|
||||
})
|
||||
test('measured writes require an explicit independent idempotency namespace', async () => {
|
||||
const { context, calls } = harness(); await assert.rejects(verifyOperations(context, { writeMeasured: true }), /independent idempotency/)
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
test('independent quote session logs out after a failed quote scenario and persists scoped failure without secrets', async () => {
|
||||
const offline = harness('null price becomes zero')
|
||||
const context = new AcceptanceRun({ environment: 'test', gateway: 'http://offline.invalid', username: 'test-user', password: 'synthetic-password', quiet: true })
|
||||
const authCalls = []
|
||||
context.request = async (method, path, body) => {
|
||||
if (path.startsWith('/api/auth/')) {
|
||||
authCalls.push(path)
|
||||
if (path.endsWith('/logout')) { assert.equal(body.refreshToken, 'synthetic-refresh'); return { http: 200, body: { code: '200', data: null } } }
|
||||
return { http: 200, body: { code: '200', data: { accessToken: 'synthetic-access', refreshToken: 'synthetic-refresh' } } }
|
||||
}
|
||||
return offline.context.request(method, path, body)
|
||||
}
|
||||
context.requestBinary = offline.context.requestBinary
|
||||
const report = await runQuoteSession(context, { marker: 'FLEET-QUOTE-20261003', prepareCustomer: async (current, options) => {
|
||||
assert.equal(options.publishEnvironment, false)
|
||||
current.createdResources.merchantQualificationFixture = { marker: current.marker, tenantId: '101' }
|
||||
} })
|
||||
assert.deepEqual(authCalls, ['/api/auth/login', '/api/auth/logout'])
|
||||
assert.equal(report.success, false); assert.equal(report.loggedOut, true); assert.equal(report.operationsStatus, 'NOT_TESTED')
|
||||
assert.equal(report.createdResources.fleetQuoteAcceptance.status, 'FAILED')
|
||||
assert.equal(context.token, ''); assert.equal(context.refreshToken, '')
|
||||
assert.ok(!/synthetic-password|synthetic-access|synthetic-refresh/.test(JSON.stringify(report)))
|
||||
})
|
||||
test('session helper rejects a borrowed login before touching HTTP', async () => {
|
||||
const context = new AcceptanceRun({ environment: 'test', quiet: true }); context.token = 'already-logged-in'
|
||||
context.request = () => { throw new Error('must not run') }
|
||||
await assert.rejects(runQuoteSession(context), /fresh TEST context/)
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
const MODULE = 'fleet-quotes-operations'
|
||||
const QUOTES = '/api/platform/v1/last-leg/fleet/quotes'
|
||||
const OPERATIONS = '/api/platform/v1/last-leg/fleet/operations'
|
||||
const OPTIONS = { scenario: 'fleet-quotes-and-current-departure', deploymentBlocker: true }
|
||||
const id = value => value == null ? '' : String(value)
|
||||
const successful = response => response?.http === 200 && id(response.body?.code) === '200'
|
||||
const encode = encodeURIComponent
|
||||
const query = values => new URLSearchParams(Object.entries(values).filter(([, value]) => value != null).map(([key, value]) => [key, String(value)])).toString()
|
||||
const version = value => BigInt(id(value))
|
||||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
||||
function number(value) {
|
||||
if (!['number', 'string'].includes(typeof value) || !/^\d+(?:\.\d{1,2})?$/.test(String(value)) || !Number.isFinite(Number(value))) throw new Error('Missing or invalid numeric fact; never convert unknown to zero')
|
||||
return Number(value)
|
||||
}
|
||||
|
||||
export const fixtureContract = Object.freeze({
|
||||
quoteEntry: 'run(context): QUOTES_ONLY; requires only current-run merchant-qualification-fixture, never DEPART',
|
||||
operationsEntry: 'verifyOperations(context,{writeMeasured:false,idempotencyNamespace}): OPERATIONS_ONLY, read-only by default; explicit writeMeasured:true uses a separate stable idempotency namespace',
|
||||
contextKey: 'context.fleetQuotesOperationsFixture',
|
||||
stage: 'CURRENT_RUN_DEPARTED',
|
||||
required: 'marker,tripNo,warehouseId,departedAt,departureVerified:true,expected:{physicalPallets,palletSpaces,ownPackages,externalPackages,externalAgreedAmounts:[{currency,amount}],costs:[{currency,directCost,ownCost,externalCost}]}',
|
||||
prerequisite: 'Operations require a current-run real HTTP departure producer and a trip remark containing its marker. Quotes require their own current-run qualified customer only. These are independent acceptance scopes.',
|
||||
boundary: 'No historical fallback, no invented DEPART, no SQL or deployment. Does not prove the upstream departure transaction or Finance payment. Import exercises the structured rows produced by the CSV UI, not browser file selection.',
|
||||
effects: 'Creates a fresh overseas TRUCKING supplier and isolated purchase/sale quotes; appends measured distance/fuel corrections to the supplied current-run trip. Finally deactivates only own quotes/supplier; retains all rows/history. No DELETE.',
|
||||
})
|
||||
|
||||
export function requireCurrentFixture(context) {
|
||||
const fixture = context?.context?.fleetQuotesOperationsFixture
|
||||
if (!context?.marker || !fixture || fixture.marker !== context.marker || fixture.stage !== fixtureContract.stage
|
||||
|| fixture.departureVerified !== true || !fixture.tripNo || !fixture.warehouseId || !Number.isFinite(Date.parse(fixture.departedAt))) throw new Error('Requires current-run verified real DEPART fixture; historical samples are forbidden')
|
||||
const expected = fixture.expected
|
||||
if (!expected || ['physicalPallets', 'palletSpaces', 'ownPackages', 'externalPackages'].some(key => !Number.isSafeInteger(expected[key]) || expected[key] < 0)
|
||||
|| expected.physicalPallets < 1 || expected.palletSpaces < 1 || expected.ownPackages + expected.externalPackages < 1
|
||||
|| !Array.isArray(expected.externalAgreedAmounts) || !Array.isArray(expected.costs)) throw new Error('Requires independent expected cargo and currency-separated cost facts')
|
||||
amounts(expected.externalAgreedAmounts); costs(expected.costs)
|
||||
return fixture
|
||||
}
|
||||
function requireQuoteCustomer(context) {
|
||||
const customer = context?.createdResources?.merchantQualificationFixture
|
||||
if (!context?.marker || customer?.marker !== context.marker || !customer.tenantId) throw new Error('Requires current-run qualified customer for quote acceptance')
|
||||
return customer
|
||||
}
|
||||
|
||||
export function readCsv(response, requiredHeader) {
|
||||
const text = new TextDecoder().decode(response?.bytes || new Uint8Array()).replace(/^\uFEFF/, '')
|
||||
if (response?.http !== 200 || /json/i.test(response?.contentType || '') || /^\s*[{[]/.test(text)
|
||||
|| !/^text\/csv(?:;|$)/i.test(response?.contentType || '') || !text.split(/\r?\n/, 1)[0].includes(requiredHeader)) {
|
||||
throw new Error(`Export is not a valid CSV: HTTP=${response?.http}, code=${response?.body?.code || 'n/a'}, requestId=${response?.requestId || 'n/a'}, traceId=${response?.traceId || 'n/a'}`)
|
||||
}
|
||||
return text
|
||||
}
|
||||
const currency = value => { if (!['CNY', 'USD'].includes(value)) throw new Error('Unsupported or missing currency fact'); return value }
|
||||
const amounts = rows => rows.map(row => [currency(row.currency), number(row.amount)]).sort((a, b) => a[0].localeCompare(b[0]))
|
||||
const costs = rows => rows.map(row => [currency(row.currency), number(row.directCost), row.ownCost == null ? null : number(row.ownCost), row.externalCost == null ? null : number(row.externalCost)]).sort((a, b) => a[0].localeCompare(b[0]))
|
||||
|
||||
/** Only the registered TEST runner invokes run; importing this module never reads credentials or calls HTTP. */
|
||||
export async function run(context) { return execute(context, { scope: 'QUOTES_ONLY' }) }
|
||||
export async function verifyOperations(context, { writeMeasured = false, idempotencyNamespace } = {}) {
|
||||
if (writeMeasured && !idempotencyNamespace) throw new Error('Measured corrections require an explicit independent idempotency namespace')
|
||||
return execute(context, { scope: 'OPERATIONS_ONLY', writeMeasured, idempotencyNamespace })
|
||||
}
|
||||
/** Explicit orchestrator helper. Caller supplies a fresh AcceptanceRun with TEST options; no global env mutation.
|
||||
* Real standalone execution uses fleet-quotes-test-env.mjs, which journals every request before sending.
|
||||
* This return value is a final summary, not a replacement for durable pre-request evidence.
|
||||
*/
|
||||
export async function runQuoteSession(context, { marker = 'FLEET-QUOTE-20261003', prepareCustomer } = {}) {
|
||||
if (context?.options?.environment !== 'test' || context.token || context.refreshToken || Object.keys(context.createdResources || {}).length)
|
||||
throw new Error('Quote session requires a fresh TEST context, never a borrowed session or fixture')
|
||||
context.marker = marker
|
||||
context.options.customerName = `报价专用客户-${marker}`
|
||||
let fatal = null, loggedOut = false
|
||||
try {
|
||||
const response = await context.request('POST', '/api/auth/login', { loginId: context.options.username, password: context.options.password }, '')
|
||||
const login = context.apiSuccess(MODULE, '独立报价验收会话登录', response, '', OPTIONS)
|
||||
if (!successful(response) || !login?.accessToken || !login?.refreshToken) throw new Error('Independent TEST login failed')
|
||||
context.token = login.accessToken; context.refreshToken = login.refreshToken
|
||||
const prepare = prepareCustomer || (await import('./merchant-qualification-fixture.mjs')).run
|
||||
await context.executeModule('merchant-qualification-fixture', () => prepare(context, { publishEnvironment: false }))
|
||||
if (context.steps.some(step => !step.passed)) throw new Error('Current-run customer preparation failed; quotes not executed')
|
||||
await context.executeModule(MODULE, () => run(context))
|
||||
} catch (error) { fatal = error instanceof Error ? error.message : String(error) }
|
||||
finally {
|
||||
if (context.token) {
|
||||
const response = await context.request('POST', '/api/auth/logout', { refreshToken: context.refreshToken })
|
||||
context.apiSuccess(MODULE, '退出独立报价验收会话', response, '', OPTIONS)
|
||||
loggedOut = successful(response)
|
||||
if (!loggedOut) fatal ||= 'Independent TEST session logout failed'
|
||||
}
|
||||
}
|
||||
const { buildAcceptanceSummary } = await import('../admin-test-env-acceptance.mjs')
|
||||
const summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps)
|
||||
const report = { marker, scope: 'QUOTES_ONLY', generatedAt: new Date().toISOString(), success: !fatal && loggedOut && summary.failed === 0,
|
||||
fatal, loggedOut, operationsStatus: 'NOT_TESTED', financeStatus: 'NOT_TESTED', summary,
|
||||
modules: context.modules, steps: context.steps, createdResources: context.createdResources }
|
||||
const serialized = JSON.stringify(report)
|
||||
const secrets = [context.options.password, context.token, context.refreshToken, context.context?.merchantQualificationFixture?.password].filter(value => typeof value === 'string' && value.length >= 8)
|
||||
if (secrets.some(value => serialized.includes(value)) || /"(?:password|accessToken|refreshToken|authorization)"\s*:/i.test(serialized)) throw new Error('Unsafe report rejected: contains credentials')
|
||||
context.token = ''; context.refreshToken = ''
|
||||
return report
|
||||
}
|
||||
async function execute(context, { scope, writeMeasured = false, idempotencyNamespace }) {
|
||||
context.createdResources ||= {}
|
||||
const proofKey = scope === 'QUOTES_ONLY' ? 'fleetQuoteAcceptance' : 'fleetOperationsAcceptance'
|
||||
if (context.createdResources[proofKey]) throw new Error('Existing proof must be retained; do not overwrite failed evidence')
|
||||
const proof = context.createdResources[proofKey] = { marker: context.marker, scope, status: 'CHECKING', quoteStatus: 'NOT_TESTED', operationsStatus: 'NOT_TESTED', measuredStatus: 'NOT_TESTED', requests: [], quotes: [], supplierId: null, cleanup: 'PENDING', upstreamDeparture: 'NOT_TESTED', financePayment: 'NOT_TESTED' }
|
||||
let failure
|
||||
const check = (label, response, condition) => {
|
||||
context.assert(MODULE, label, response, Boolean(condition), '', OPTIONS)
|
||||
if (!condition) throw new Error(label)
|
||||
}
|
||||
const key = suffix => createHash('sha256').update(`${idempotencyNamespace || context.marker}:${suffix}`).digest('hex')
|
||||
const recordResponse = (method, path, response) => proof.requests.push({ method, path, http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId })
|
||||
async function request(method, path, body) { const response = await context.request(method, path, body); recordResponse(method, path, response); return response }
|
||||
async function command(method, path, body, label) {
|
||||
const response = await request(method, path, body)
|
||||
const data = context.apiSuccess(MODULE, label, response, '', OPTIONS)
|
||||
check(`${label}保留链路证据`, response, successful(response) && response.requestId && response.traceId && data != null)
|
||||
return data
|
||||
}
|
||||
async function reject(method, path, body, expectedCode, label) {
|
||||
const response = await request(method, path, body)
|
||||
check(label, response, response.http === 200 && id(response.body?.code) === expectedCode && response.requestId && response.traceId)
|
||||
}
|
||||
async function binary(path) { const response = await context.requestBinary('GET', path); recordResponse('GET', path, response); return response }
|
||||
function retain(row) {
|
||||
check('报价返回稳定业务号', null, typeof row?.quoteNo === 'string' && row.quoteNo.length > 0)
|
||||
if (!proof.quotes.includes(row.quoteNo)) proof.quotes.push(row.quoteNo)
|
||||
return row
|
||||
}
|
||||
try {
|
||||
let fixture, from, to, report
|
||||
if (scope === 'OPERATIONS_ONLY') {
|
||||
fixture = requireCurrentFixture(context)
|
||||
proof.tripNo = fixture.tripNo
|
||||
const trip = await command('GET', `/api/platform/v1/last-leg/fleet/trips/${encode(fixture.tripNo)}`, undefined, '核对本轮真实发车车次')
|
||||
check('车次属于当前场景并有真实发车时间', null, trip.trip?.tripNo === fixture.tripNo && id(trip.trip.warehouseId) === id(fixture.warehouseId)
|
||||
&& trip.trip.remark?.includes(context.marker) && Date.parse(trip.trip.departedAt) === Date.parse(fixture.departedAt)
|
||||
&& ['IN_TRANSIT', 'COMPLETED'].includes(trip.trip.status))
|
||||
from = new Date(Date.parse(fixture.departedAt) - 1).toISOString(); to = new Date(Date.parse(fixture.departedAt) + 1000).toISOString()
|
||||
const reportPath = `${OPERATIONS}?${query({ from, to, warehouseId: fixture.warehouseId, keyword: fixture.tripNo, page: 1, size: 20 })}`
|
||||
report = async () => {
|
||||
const result = await command('GET', reportPath, undefined, '读取本轮车次运营与成本汇总')
|
||||
check('同车多客户及目的地只计一次出车', null, Number(result.page?.total) === 1 && result.page.records?.length === 1 && Number(result.summary?.trips) === 1 && result.page.records[0].tripNo === fixture.tripNo)
|
||||
const row = result.page.records[0]
|
||||
for (const field of ['physicalPallets', 'palletSpaces', 'ownPackages', 'externalPackages']) check(`实运${field}与独立fixture一致`, null, number(row[field]) === fixture.expected[field] && number(result.summary[field]) === fixture.expected[field])
|
||||
check('约定金额分币种核平且不伪造实收', null, same(amounts(row.externalAgreedAmounts), amounts(fixture.expected.externalAgreedAmounts)) && same(amounts(result.summary.externalAgreedAmounts), amounts(fixture.expected.externalAgreedAmounts)) && row.financeStatus === 'NOT_QUERIED')
|
||||
check('成本使用正式批量事实而非缺失补零', null, result.summary.costs?.visible === true && row.costs && same(costs(row.costs.totals), costs(fixture.expected.costs))
|
||||
&& row.costStatus === (fixture.expected.costs.length ? 'AVAILABLE' : 'NOT_RECORDED') && Number(result.summary.costs.unrecordedTrips) === (fixture.expected.costs.length ? 0 : 1))
|
||||
check('统计图来自同一查询范围', null, result.charts?.physicalPallets?.flatMap(series => series.points).reduce((sum, point) => sum + Number(point.value), 0) === fixture.expected.physicalPallets)
|
||||
return result
|
||||
}
|
||||
await report()
|
||||
}
|
||||
if (scope === 'QUOTES_ONLY') {
|
||||
const customer = requireQuoteCustomer(context)
|
||||
const supplier = await command('POST', '/api/platform/v1/suppliers?tenantId=0', {
|
||||
name: `车辆报价验收-${context.marker}`, category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], status: 'ACTIVE', remark: context.marker,
|
||||
region: 'TEST', legalRepresentative: '测试法人', contactName: '测试联系人', phone: '19900000000', email: 'fleet-acceptance@example.com', settlementPeriod: 'MONTHLY_30',
|
||||
}, '新建本轮海外车队供应商')
|
||||
proof.supplierId = id(supplier.id ?? supplier)
|
||||
check('供应商返回明确标识', null, /^\d+$/.test(proof.supplierId))
|
||||
const day = new Date().toISOString().slice(0, 10)
|
||||
const details = { kind: 'SALE', partyId: id(customer.tenantId), originCity: `QA-${context.marker}`, destinationCode: 'QA-FLEET', distanceMiles: 10,
|
||||
currency: 'USD', selfPickupPrice: null, fullDeliveryPrice: 0, looseDeliveryPrice: 12.5, validFrom: day, validTo: day, remark: context.marker }
|
||||
const create = { idempotencyKey: key('sale'), details }
|
||||
await reject('POST', QUOTES, { idempotencyKey: key('no-price'), details: { ...details, fullDeliveryPrice: null, looseDeliveryPrice: null } }, 'A0400', '所有价格为空不能成为免费报价')
|
||||
const sale = retain(await command('POST', QUOTES, create, '创建空价与明确零价销售报价'))
|
||||
check('空价与零价严格区分', null, sale.details?.selfPickupPrice == null && sale.details?.fullDeliveryPrice != null && Number(sale.details.fullDeliveryPrice) === 0 && Number(sale.details.looseDeliveryPrice) === 12.5 && id(sale.details.partyId) === id(customer.tenantId))
|
||||
const replay = await command('POST', QUOTES, create, '重放原报价创建请求')
|
||||
check('创建重放不新增报价或版本', null, replay.quoteNo === sale.quoteNo && id(replay.recordVersion) === id(sale.recordVersion))
|
||||
await reject('POST', QUOTES, { ...create, details: { ...details, looseDeliveryPrice: 13 } }, 'A0409', '同键异价拒绝')
|
||||
let current = await command('PUT', `${QUOTES}/${encode(sale.quoteNo)}`, { recordVersion: sale.recordVersion, details: { ...details, looseDeliveryPrice: 15 } }, '更正销售报价')
|
||||
check('更正只递增一个版本', null, version(current.recordVersion) === version(sale.recordVersion) + 1n)
|
||||
await reject('PUT', `${QUOTES}/${encode(sale.quoteNo)}`, { recordVersion: sale.recordVersion, details }, 'A0409', '旧版本不能覆盖新报价')
|
||||
const afterConflict = await command('GET', `${QUOTES}/${encode(sale.quoteNo)}`, undefined, '回查版本冲突无写入')
|
||||
check('冲突后价格版本不漂移且空价零价已持久化', null, id(afterConflict.recordVersion) === id(current.recordVersion) && number(afterConflict.details.looseDeliveryPrice) === 15
|
||||
&& afterConflict.details.selfPickupPrice == null && afterConflict.details.fullDeliveryPrice != null && number(afterConflict.details.fullDeliveryPrice) === 0)
|
||||
const historicalReplay = await command('POST', QUOTES, create, '编辑后原创建键仍返回原始快照')
|
||||
check('创建重放不会返回被更正价格冒充原结果', null, id(historicalReplay.recordVersion) === id(sale.recordVersion) && Number(historicalReplay.details.looseDeliveryPrice) === 12.5)
|
||||
const purchase = retain(await command('POST', QUOTES, { idempotencyKey: key('purchase'), details: { ...details, kind: 'PURCHASE', partyId: proof.supplierId } }, '创建独立采购报价'))
|
||||
check('采购销售没有合并成同一记录', null, purchase.quoteNo !== sale.quoteNo && purchase.details.kind === 'PURCHASE' && id(purchase.details.partyId) === proof.supplierId)
|
||||
const importCity = `IMPORT-${context.marker}`
|
||||
const invalidBatch = { idempotencyKey: key('rollback-import'), rows: [{ ...details, originCity: importCity }, { ...details, originCity: importCity }] }
|
||||
await reject('POST', `${QUOTES}/import`, invalidBatch, 'A0409', '重复组合导致整批导入失败')
|
||||
const rollback = await command('GET', `${QUOTES}?${query({ keyword: importCity, kind: 'SALE', page: 1, size: 200 })}`, undefined, '回查失败批次无部分写入')
|
||||
proof.rollbackReturnedQuoteNos = rollback.records.map(row => row.quoteNo)
|
||||
check('CSV结构化导入失败整批回滚', null, Number(rollback.total) === 0 && rollback.records.length === 0)
|
||||
const batch = { idempotencyKey: key('valid-import'), rows: [{ ...details, originCity: importCity }, { ...details, originCity: importCity, destinationCode: 'QA-FLEET-B' }] }
|
||||
const imported = await command('POST', `${QUOTES}/import`, batch, '原子导入两条报价')
|
||||
check('成功导入返回两条业务记录', null, Number(imported.importedRows) === 2 && imported.records?.length === 2)
|
||||
imported.records.forEach(retain)
|
||||
const importedAgain = await command('POST', `${QUOTES}/import`, batch, '重放原批次导入')
|
||||
check('整批重放保持原业务号和版本', null, same(importedAgain.records.map(r => [r.quoteNo, id(r.recordVersion)]), imported.records.map(r => [r.quoteNo, id(r.recordVersion)])))
|
||||
await reject('POST', `${QUOTES}/import`, { ...batch, rows: batch.rows.slice(0, 1) }, 'A0409', '同批次键不能缩短或改变内容')
|
||||
const confirmedImport = await command('GET', `${QUOTES}?${query({ keyword: importCity, kind: 'SALE', page: 1, size: 200 })}`, undefined, '核对导入重放无重复行')
|
||||
check('导入重放及拒绝后仍恰好两条', null, Number(confirmedImport.total) === 2 && new Set(confirmedImport.records.map(r => r.quoteNo)).size === 2)
|
||||
current = await command('PATCH', `${QUOTES}/${encode(sale.quoteNo)}/status`, { recordVersion: current.recordVersion, status: 'INACTIVE' }, '停用销售报价')
|
||||
const activeSales = await command('GET', `${QUOTES}?${query({ keyword: details.originCity, kind: 'SALE', status: 'ACTIVE', page: 1, size: 200 })}`, undefined, '核对停用及用途筛选隔离')
|
||||
check('停用销售不被启用筛选或采购混入', null, activeSales.records.every(r => r.details.kind === 'SALE' && r.status === 'ACTIVE' && r.quoteNo !== sale.quoteNo))
|
||||
const stillPurchase = await command('GET', `${QUOTES}/${encode(purchase.quoteNo)}`, undefined, '核对采购报价不受销售停用影响')
|
||||
check('用途独立状态未串改', null, stillPurchase.status === 'ACTIVE' && id(stillPurchase.recordVersion) === id(purchase.recordVersion))
|
||||
const history = await command('GET', `${QUOTES}/${encode(sale.quoteNo)}/history`, undefined, '读取报价不可变版本历史')
|
||||
check('历史保留原始零价与更正版本', null, history.length === 3 && history.some(h => Number(h.snapshot?.details?.looseDeliveryPrice) === 12.5) && history.some(h => Number(h.snapshot?.details?.looseDeliveryPrice) === 15))
|
||||
const quoteCsv = await binary(`${QUOTES}/export?${query({ keyword: details.originCity })}`)
|
||||
const quoteText = readCsv(quoteCsv, '报价号')
|
||||
check('报价CSV为真实文件并包含本轮业务号', quoteCsv, quoteText.includes(sale.quoteNo) && quoteText.includes(purchase.quoteNo))
|
||||
const error = await binary(`${QUOTES}/export?size=0`)
|
||||
check('报价导出参数错误保留业务码', error, error.http === 200 && id(error.body?.code) === 'A0422')
|
||||
let rejected = false; try { readCsv(error, '报价号') } catch { rejected = true }
|
||||
check('报价下载拒绝JSON错误文件', error, rejected)
|
||||
proof.quoteStatus = 'VERIFIED'
|
||||
}
|
||||
if (scope === 'OPERATIONS_ONLY') {
|
||||
if (writeMeasured) {
|
||||
const recordPath = `${OPERATIONS}/${encode(fixture.tripNo)}/record`
|
||||
const initial = await command('GET', recordPath, undefined, '核对本轮车次尚无实测记录')
|
||||
check('禁止覆盖已有实测历史样本', null, id(initial.recordVersion) === '0' && initial.details.actualDistanceMiles == null && initial.details.actualFuelLitres == null)
|
||||
const measured = { recordVersion: initial.recordVersion, idempotencyKey: key('measured-zero'), details: { actualDistanceMiles: 0, actualFuelLitres: null, remark: context.marker }, reason: null }
|
||||
const first = await command('PUT', recordPath, measured, '登记明确零里程和未知油耗')
|
||||
check('实测明确零值不变成未填写', null, first.details.actualDistanceMiles != null && Number(first.details.actualDistanceMiles) === 0 && first.details.actualFuelLitres == null)
|
||||
const firstAgain = await command('PUT', recordPath, measured, '重放实测保存')
|
||||
check('实测重放保留原版本', null, id(firstAgain.recordVersion) === id(first.recordVersion))
|
||||
const corrected = await command('PUT', recordPath, { recordVersion: first.recordVersion, idempotencyKey: key('measured-correct'), details: { actualDistanceMiles: 25.5, actualFuelLitres: 8.2, remark: context.marker }, reason: '核对受控实测' }, '更正实测里程油耗')
|
||||
await reject('PUT', recordPath, { ...measured, idempotencyKey: key('measured-stale') }, 'A0409', '旧实测版本拒绝')
|
||||
const measuredHistory = await command('GET', `${recordPath}/history?page=1&size=20`, undefined, '读取实测更正历史')
|
||||
check('实测重放与冲突没有追加历史', null, Number(measuredHistory.total) === 2 && version(corrected.recordVersion) === version(first.recordVersion) + 1n)
|
||||
proof.measuredStatus = 'VERIFIED'
|
||||
}
|
||||
const finalReport = await report()
|
||||
if (writeMeasured) check('运营列表批量回显最新实测记录', null, Number(finalReport.page.records[0].measured?.actualDistanceMiles) === 25.5 && Number(finalReport.page.records[0].measured?.actualFuelLitres) === 8.2)
|
||||
const exported = await binary(`${OPERATIONS}/export?${query({ from, to, warehouseId: fixture.warehouseId, keyword: fixture.tripNo })}`)
|
||||
const operationsCsv = readCsv(exported, '实际里程(mile)')
|
||||
check('运营CSV包含本轮车次及实际里程字段', exported, operationsCsv.includes(fixture.tripNo) && (!writeMeasured || operationsCsv.includes('25.5')))
|
||||
const exportError = await binary(`${OPERATIONS}/export?${query({ from: to, to: from })}`)
|
||||
check('错误导出保留标准业务错误而非CSV成功', exportError, exportError.http === 200 && id(exportError.body?.code) === 'A0400')
|
||||
let rejected = false
|
||||
try { readCsv(exportError, '车次号') } catch { rejected = true }
|
||||
check('下载检查器拒绝JSON错误响应', exportError, rejected)
|
||||
proof.operationsStatus = 'VERIFIED'
|
||||
}
|
||||
proof.status = 'VERIFIED'
|
||||
} catch (error) { proof.status = 'FAILED'; proof.error = error instanceof Error ? error.message : String(error); failure = error }
|
||||
finally {
|
||||
const cleanupErrors = []
|
||||
for (const no of context.quoteRequestOutcomeUnknown ? [] : proof.quotes) {
|
||||
try {
|
||||
const row = await command('GET', `${QUOTES}/${encode(no)}`, undefined, '读取本轮报价最终版本')
|
||||
if (row.status === 'ACTIVE') await command('PATCH', `${QUOTES}/${encode(no)}/status`, { recordVersion: row.recordVersion, status: 'INACTIVE' }, '停用本轮专用报价并保留历史')
|
||||
} catch (error) { cleanupErrors.push(error.message) }
|
||||
}
|
||||
if (proof.supplierId && !context.quoteRequestOutcomeUnknown) {
|
||||
try {
|
||||
const path = `/api/platform/v1/suppliers/${encode(proof.supplierId)}`
|
||||
const response = await request('PATCH', `${path}/status?tenantId=0`, { status: 'INACTIVE' })
|
||||
context.apiSuccess(MODULE, '停用本轮车队供应商并保留证据', response, '', OPTIONS)
|
||||
check('供应商停用命令成功', response, successful(response))
|
||||
const supplier = await command('GET', `${path}?tenantId=0`, undefined, '回查本轮车队供应商已停用')
|
||||
check('本轮车队供应商最终停用', null, supplier.status === 'INACTIVE' && id(supplier.id) === proof.supplierId)
|
||||
} catch (error) { cleanupErrors.push(error.message) }
|
||||
}
|
||||
proof.cleanup = context.quoteRequestOutcomeUnknown ? 'NOT_ATTEMPTED_UNKNOWN_OUTCOME' : cleanupErrors.length ? 'FAILED' : proof.quotes.length || proof.supplierId ? 'RETAINED_AND_DEACTIVATED' : 'NOTHING_CREATED'
|
||||
if (cleanupErrors.length) { proof.cleanupErrors = cleanupErrors; proof.status = 'FAILED'; failure ||= new Error('本轮报价资源停用失败') }
|
||||
}
|
||||
if (failure) throw failure
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { verifyFleetTripCosts, expectedAllocations, TRIP_COST_EXPECTATIONS } from './fleet-trip-cost-acceptance.mjs'
|
||||
|
||||
const copy = value => value === undefined ? undefined : structuredClone(value)
|
||||
const prefix = 'platform:fleet:trip:'
|
||||
function fixture(fault) {
|
||||
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'] },
|
||||
{ businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', sourceType: 'EXTERNAL', palletSpaces: 1, warehousePalletIds: ['P2'] },
|
||||
]
|
||||
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' },
|
||||
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 }] },
|
||||
externalBookings: [{ bookingNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', status: 'BOUND',
|
||||
receipt: { source: { receivedPackages: 9, pallets: [{ warehousePalletId: 'P2', packages: 9 }] } } }] } }
|
||||
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() {
|
||||
return { departureVersion: fault === 'wrong departure' ? 8 : 7, manifestHash: 'original-manifest',
|
||||
allocationMethod: 'SAME_ROUTE_PALLET_SPACES', allocationBlockReason: 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
|
||||
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',
|
||||
palletSpaces: 1, amount: (external + (fault === 'lost cent' ? 1 : 0)) / 100 }] }
|
||||
}) }
|
||||
}
|
||||
const response = (data, code = '200', http = 200) => ({ http, body: { code, data: copy(data) }, requestId: 'request', traceId: 'trace' })
|
||||
const listing = () => ({ page: { records: copy(rows), total: rows.length, page: 1, size: 100 }, summary: summary() })
|
||||
function audit(row, action) {
|
||||
const history = histories.get(row.costNo) || []
|
||||
history.push({ recordVersion: row.recordVersion, action, reason: 'controlled audit', occurredAt: new Date().toISOString(),
|
||||
snapshot: { cost: copy(row), summary: summary() } })
|
||||
histories.set(row.costNo, history)
|
||||
}
|
||||
const context = { marker: 'CURRENT-RUN', token: 'offline-writer', createdResources: { fleetTrips: [trip],
|
||||
fleetAcceptance: { marker: 'CURRENT-RUN', tripNo: trip.tripNo,
|
||||
departure: { tripNo: trip.tripNo, status: 'IN_TRANSIT', departedAt: depart.snapshot.trip.departedAt } } },
|
||||
assert(_module, label, _response, condition) { assert.ok(condition, label) }, async checkpoint() {},
|
||||
async request(method, path, body, token) {
|
||||
calls.push({ method, path, body: copy(body), actor: token })
|
||||
if (path.endsWith('/authorization/permissions')) return response((token === 'offline-writer' || fault === 'reader is admin'
|
||||
? ['list', 'cost-create', 'cost-edit'] : token === 'offline-reader' ? ['list'] : []).map(name => prefix + name))
|
||||
const denied = token === 'offline-denied' || (token === 'offline-reader' && method !== 'GET')
|
||||
if (denied && fault !== 'permission bypass') return fault === 'denial is outage' ? response(null, 'S0503', 503) : response(null, 'A0403', 403)
|
||||
if (method === 'GET' && path.includes('/history') && !path.includes('/costs')) return response({ records: [depart], total: 1 })
|
||||
if (method === 'GET' && path.includes('/history')) {
|
||||
const costNo = path.split('/costs/')[1].split('/')[0], history = copy(histories.get(costNo))
|
||||
if (fault === 'history overwritten') history[0].snapshot.cost.details.amount = '100.03'
|
||||
if (fault === 'history summary drift') history[0].snapshot.summary.totals[0].directCost = 999
|
||||
return response({ records: history, total: history.length })
|
||||
}
|
||||
if (method === 'GET') return response(listing())
|
||||
if (method === 'POST') {
|
||||
const saved = keys.get(body.idempotencyKey)
|
||||
if (saved) {
|
||||
if (JSON.stringify(saved.details) !== JSON.stringify(body.details)) return response(null, 'A0409')
|
||||
if (fault === 'replay duplicate') { const duplicate = { ...copy(saved.row), costNo: `TC-${rows.length + 1}` }; rows.push(duplicate); return response(duplicate) }
|
||||
if (fault === 'creation overwrites correction') saved.row.details = copy(body.details)
|
||||
return response(saved.row)
|
||||
}
|
||||
const row = { costNo: `TC-${rows.length + 1}`, tripNo: trip.tripNo, recordVersion: 0, details: copy(body.details) }
|
||||
rows.push(row); keys.set(body.idempotencyKey, { details: copy(body.details), row }); audit(row, 'CREATE'); return response(row)
|
||||
}
|
||||
assert.equal(method, 'PUT')
|
||||
const row = rows.find(item => path.endsWith(item.costNo)); assert.ok(row)
|
||||
if (Number(body.recordVersion) !== row.recordVersion && fault !== 'stale accepted') return response(null, 'A0409')
|
||||
if (JSON.stringify(row.details) === JSON.stringify(body.details) && fault !== 'noop audit') return response(row)
|
||||
row.details = copy(body.details); row.recordVersion++; audit(row, 'UPDATE'); return response(row)
|
||||
} }
|
||||
return { context, trip, inputs, calls, rows }
|
||||
}
|
||||
|
||||
test('physical DEPART costs include original currencies, correction, immutable history and three permission actors', async () => {
|
||||
const f = fixture(), proof = await verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs)
|
||||
assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.expected.finalCny, '100.03')
|
||||
assert.equal(proof.final.summary.totals.find(row => row.currency === 'CNY').ownCost, 66.69)
|
||||
assert.deepEqual(proof.coverage.sourceTypes, ['OWN', 'EXTERNAL'])
|
||||
assert.ok(f.calls.every(call => call.path.includes('/fleet/trips/FT-CURRENT') || call.path.endsWith('/authorization/permissions')))
|
||||
assert.ok(!JSON.stringify(proof).includes('offline-reader')); assert.ok(!JSON.stringify(proof).includes('offline-denied'))
|
||||
assert.ok(proof.requests.filter(row => row.method !== 'GET').every(row => row.body))
|
||||
const count = f.calls.length
|
||||
await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs), /重复开始/)
|
||||
assert.equal(f.calls.length, count)
|
||||
})
|
||||
|
||||
test('cost expectation uses stable largest remainder and conserves every cent including tie and zero', () => {
|
||||
const shares = ['B', 'A', 'C'].map(businessNo => ({ businessNo, sourceType: 'OWN', dispatchNo: businessNo, palletSpaces: 1 }))
|
||||
assert.deepEqual(expectedAllocations('0.02', shares).map(row => [row.businessNo, row.cents]), [['A', '1'], ['B', '1'], ['C', '0']])
|
||||
assert.deepEqual(expectedAllocations('0', shares).map(row => row.cents), ['0', '0', '0'])
|
||||
assert.equal(TRIP_COST_EXPECTATIONS.finalCny, '100.03')
|
||||
})
|
||||
|
||||
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']) {
|
||||
test(`rejects ${fault} and preserves evidence without cleanup`, async () => {
|
||||
const f = fixture(fault)
|
||||
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 !== 'DELETE'))
|
||||
})
|
||||
}
|
||||
|
||||
test('missing current-run membership or permission session fails before HTTP', async () => {
|
||||
for (const mutate of [f => { f.inputs.deniedToken = '' }, f => { f.context.createdResources.fleetTrips = [] },
|
||||
f => { f.context.createdResources.fleetAcceptance.marker = 'OLD' }, f => { f.inputs.shares = [] }]) {
|
||||
const f = fixture(); mutate(f)
|
||||
await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs)); assert.equal(f.calls.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('unknown HTTP outcome retains original key and does not retry or hide failure', async () => {
|
||||
const f = fixture(), original = f.context.request
|
||||
f.context.request = async (...args) => {
|
||||
if (args[0] === 'POST' && args[3] === f.context.token) throw new Error('controlled network loss')
|
||||
return original(...args)
|
||||
}
|
||||
await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs), /network loss/)
|
||||
const entry = f.trip.costAcceptance.requests.at(-1)
|
||||
assert.equal(entry.status, 'UNKNOWN_RESULT'); assert.ok(entry.body.idempotencyKey)
|
||||
assert.equal(f.rows.length, 0)
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user