2026-10-03 14:52:04 +08:00
|
|
|
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
|
2026-10-20 10:35:52 +08:00
|
|
|
|| run.departure?.dispatchNo !== tripProof.dispatchNo || run.departure.status !== 'ACTIVE'
|
|
|
|
|
|| run.departure.stage !== 'DELIVERING' || !run.departure.result?.departedAt || !count(run.departure.result.shippedPackages))
|
2026-10-03 14:52:04 +08:00
|
|
|
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' }
|
2026-10-03 23:58:47 +08:00
|
|
|
if (file) entry.file = { name: file.name, type: file.type, sha256: createHash('sha256').update(file.content).digest('hex'), sizeBytes: file.content.length }
|
2026-10-03 14:52:04 +08:00
|
|
|
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 }
|
|
|
|
|
}
|
2026-10-20 10:35:52 +08:00
|
|
|
function cargoFacts(values) {
|
|
|
|
|
const totals = new Map()
|
|
|
|
|
for (const row of values) {
|
|
|
|
|
const facts = { itemId: text(row.dispatchItemId), customerId: text(row.customerId), palletId: text(row.warehousePalletId),
|
|
|
|
|
destinationKey: row.destination?.key ?? row.destinationKey }
|
|
|
|
|
if (!facts.itemId || !facts.customerId || !facts.palletId || !facts.destinationKey) throw new Error('原生货物缺少真实明细客户或目的地')
|
|
|
|
|
const identity = stable(facts), packages = Number(row.scheduledPackages ?? row.packages)
|
|
|
|
|
if (!count(packages)) throw new Error('原生货物缺少有效真实箱数')
|
|
|
|
|
totals.set(identity, { ...facts, packages: (totals.get(identity)?.packages || 0) + packages })
|
|
|
|
|
}
|
|
|
|
|
return [...totals.values()].sort((a, b) => stable(a).localeCompare(stable(b)))
|
2026-10-03 14:52:04 +08:00
|
|
|
}
|
|
|
|
|
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. */
|
2026-10-03 21:00:07 +08:00
|
|
|
export async function verifyFleetHandover(context, module, tripProof, pickerToken, dispatchDetails, { reviewedVehicle } = {}) {
|
2026-10-03 14:52:04 +08:00
|
|
|
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)
|
2026-10-20 10:35:52 +08:00
|
|
|
if (numbers.length !== 1 || numbers[0] !== tripProof.dispatchNo || numbers[0] !== run.departure.dispatchNo
|
|
|
|
|
|| !dispatchDetails.every(row => count(row.counts?.packageCount))
|
|
|
|
|
|| Number(run.departure.result.shippedPackages) !== dispatchDetails.reduce((sum, row) => sum + Number(row.counts.packageCount), 0))
|
2026-10-03 14:52:04 +08:00
|
|
|
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'
|
2026-10-20 10:35:52 +08:00
|
|
|
&& detail.trip.departedAt === run.departure.result.departedAt && text(detail.trip.vehicleId) === text(tripProof.vehicleId)
|
2026-10-03 14:52:04 +08:00
|
|
|
&& 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) }
|
2026-10-20 10:35:52 +08:00
|
|
|
proof.dispatchBefore = []; proof.pdaBefore = []; proof.pdaTasksBefore = []; proof.feesBefore = []
|
2026-10-03 14:52:04 +08:00
|
|
|
for (const expected of dispatchDetails) {
|
|
|
|
|
const row = await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对客户排车已经真实出库')
|
2026-10-20 10:35:52 +08:00
|
|
|
const items = await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}/items`, undefined, '读取原生主单逐客户真实货物')
|
2026-10-03 20:24:56 +08:00
|
|
|
const cargo = original.confirmedLoad.cargo.filter(item => item.dispatchNo === expected.dispatchNo)
|
2026-10-20 10:35:52 +08:00
|
|
|
check('每张排车已发车且货量与客户起运仓一致', row?.dispatchNo === expected.dispatchNo && row.status === 'ACTIVE' && row.stage === 'DELIVERING'
|
|
|
|
|
&& Number(row.counts?.shippedPackageCount) === Number(expected.counts.packageCount) && Number(row.counts?.pickedPackageCount) === Number(expected.counts.packageCount)
|
|
|
|
|
&& items.dispatchNo === row.dispatchNo && text(items.recordVersion) === text(row.recordVersion) && cargo.length > 0
|
|
|
|
|
&& same(cargoFacts(items.items || []), cargoFacts(cargo)) && row.documents?.deliveries?.length > 0)
|
|
|
|
|
proof.dispatchBefore.push({ dispatchNo: row.dispatchNo, detail: row, items })
|
|
|
|
|
const task = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(row.dispatchNo)}`, undefined, 'PDA读取原生整车任务和客户来源', { token: pickerToken })
|
|
|
|
|
check('PDA原生作业与整车真实客户货物核平', task.dispatchNo === row.dispatchNo && task.status === 'ACTIVE' && task.stage === 'DELIVERING'
|
|
|
|
|
&& text(task.warehouseId) === text(tripProof.warehouseId) && text(task.recordVersion) === text(row.recordVersion)
|
|
|
|
|
&& same(task.counts, row.counts) && task.pickingReconciled === true
|
|
|
|
|
&& same(task.items?.map(i => ({ dispatchItemId: text(i.dispatchItemId), customerId: text(i.customerId), scheduledPackages: i.scheduledPackages, pickedPackages: i.pickedPackages }))
|
|
|
|
|
.sort((a,b) => a.dispatchItemId.localeCompare(b.dispatchItemId)),
|
|
|
|
|
items.items.map(i => ({ dispatchItemId: text(i.dispatchItemId), customerId: text(i.customerId), scheduledPackages: i.scheduledPackages, pickedPackages: i.pickedPackages }))
|
|
|
|
|
.sort((a,b) => a.dispatchItemId.localeCompare(b.dispatchItemId))))
|
|
|
|
|
proof.pdaTasksBefore.push(task)
|
2026-10-03 14:52:04 +08:00
|
|
|
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
|
2026-10-20 10:35:52 +08:00
|
|
|
&& pda.deliveries.every(item => !item.terminalAt && item.status === 'DELIVERING' && same(transport(item.transport), transport(detail.trip)))
|
|
|
|
|
&& pda.deliveries.reduce((sum, item) => sum + Number(item.packages), 0) === Number(expected.counts.packageCount)
|
|
|
|
|
&& same(pda.deliveries.map(i => i.deliveryNo).sort(), row.documents.deliveries.map(i => i.deliveryNo).sort()))
|
2026-10-03 14:52:04 +08:00
|
|
|
proof.pdaBefore.push(pda)
|
|
|
|
|
for (const delivery of pda.deliveries) {
|
2026-10-20 10:35:52 +08:00
|
|
|
const reference = row.documents.deliveries.find(i => i.deliveryNo === delivery.deliveryNo)
|
|
|
|
|
check('派送费用严格沿真实主单逐客户单据归属', reference?.customerId && cargo.some(i => text(i.customerId) === text(reference.customerId)))
|
|
|
|
|
const path = `/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({ customerId: text(reference.customerId), businessType: 'DELIVERY_ORDER', businessNo: delivery.deliveryNo })}`
|
2026-10-03 14:52:04 +08:00
|
|
|
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())
|
2026-10-03 21:00:07 +08:00
|
|
|
const marker = reviewedVehicle ? reviewedVehicle.snapshot.plateNumber : `HO-${randomUUID().slice(0, 12).toUpperCase()}`
|
2026-10-03 14:52:04 +08:00
|
|
|
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 }
|
2026-10-03 21:00:07 +08:00
|
|
|
let vehicle
|
|
|
|
|
if (reviewedVehicle) {
|
|
|
|
|
check('显式沿用车辆具有已审阅成功请求和无占用证明', reviewedVehicle.creationRequest?.status === 'RESPONDED'
|
|
|
|
|
&& reviewedVehicle.creationRequest.http === 200 && text(reviewedVehicle.creationRequest.code) === '200'
|
|
|
|
|
&& reviewedVehicle.creationRequest.requestId && reviewedVehicle.creationRequest.traceId
|
|
|
|
|
&& text(reviewedVehicle.occupation?.vehicleId) === text(reviewedVehicle.snapshot.id) && reviewedVehicle.occupation.assignmentCount === 0)
|
|
|
|
|
vehicle = await request('GET', `${VEHICLES}/${encodeURIComponent(reviewedVehicle.snapshot.id)}`, undefined, '再次核对已创建接替车辆完整快照')
|
|
|
|
|
check('已创建车辆未发生身份版本容量或状态变更', same(vehicle, reviewedVehicle.snapshot)
|
|
|
|
|
&& text(vehicle.recordVersion) === '0' && vehicle.status === 'IN_SERVICE'
|
|
|
|
|
&& vehicle.region === 'OVERSEAS' && vehicle.ownership === 'OWNED'
|
|
|
|
|
&& Number(vehicle.maxPalletSpaces) === capacity.maxPalletSpaces && Number(vehicle.maxLoadKg) === capacity.maxLoadKg && Number(vehicle.maxVolumeCbm) === capacity.maxVolumeCbm)
|
|
|
|
|
proof.replacement.vehicleReuse = clone(reviewedVehicle)
|
|
|
|
|
} else vehicle = await request('POST', VEHICLES, vehicleBody, '建立本轮受控接替车辆')
|
2026-10-03 14:52:04 +08:00
|
|
|
check('接替车辆返回真实身份及车牌', vehicle?.id && vehicle.plateNumber === marker)
|
|
|
|
|
proof.replacement.vehicleId = text(vehicle.id); proof.replacement.vehiclePlate = vehicle.plateNumber
|
2026-10-03 21:00:07 +08:00
|
|
|
const driverBody = { name: `${marker}测试司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS',
|
2026-10-03 14:52:04 +08:00
|
|
|
licenseNumber: `${marker}-TEST-NOT-REAL`, licenseType: 'TEST', licenseZone: 'UTC', licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10),
|
2026-10-03 21:00:07 +08:00
|
|
|
remark: `${context.marker} TEST credential fixture; not a real driving licence` }
|
|
|
|
|
const driver = await request('POST', `${FLEET}/drivers`, driverBody, '建立本轮接替司机档案')
|
|
|
|
|
const profileFields = ['warehouseId', 'vehicleModels', 'languagesAndArea', 'medicalExpiresOn', 'trainingExpiresOn', 'insuranceExpiresOn']
|
|
|
|
|
check('接替司机返回身份和版本', driver?.id && text(driver.recordVersion) === '0' && driver.active === true
|
|
|
|
|
&& Object.entries(driverBody).every(([field, value]) => driver.details?.[field] === value)
|
|
|
|
|
&& driver.details.licenseIssuedOn == null && driver.details.profile && !Array.isArray(driver.details.profile)
|
|
|
|
|
&& profileFields.every(field => driver.details.profile[field] == null))
|
2026-10-03 14:52:04 +08:00
|
|
|
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 })
|
2026-10-03 21:00:07 +08:00
|
|
|
check('测试证照有正式文件归属', text(uploaded?.id) === text(driver.id) && uploaded?.licenseFileId
|
|
|
|
|
&& text(uploaded.recordVersion) === String(BigInt(driver.recordVersion) + 1n))
|
2026-10-03 14:52:04 +08:00
|
|
|
proof.replacement.licenseFileId = text(uploaded.licenseFileId)
|
|
|
|
|
const reviewed = await request('POST', `${FLEET}/drivers/${encodeURIComponent(driver.id)}/review`, { recordVersion: uploaded.recordVersion, decision: 'APPROVED', reason: '仅核验本轮合成测试证照' }, '正式审核本轮接替司机')
|
2026-10-03 21:00:07 +08:00
|
|
|
check('接替司机已正式审核', text(reviewed?.id) === text(driver.id) && reviewed.reviewStatus === 'APPROVED'
|
|
|
|
|
&& text(reviewed.recordVersion) === String(BigInt(uploaded.recordVersion) + 1n) && reviewed.licenseFileId === uploaded.licenseFileId)
|
2026-10-03 14:52:04 +08:00
|
|
|
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, '预览真实剩余货物及资源资格')
|
2026-10-03 21:00:07 +08:00
|
|
|
check('交接预览绑定原车次版本和确切新旧身份', preview?.tripNo === tripProof.tripNo
|
|
|
|
|
&& text(preview.recordVersion) === text(proof.selection.recordVersion)
|
|
|
|
|
&& text(preview.previous?.vehicleId) === proof.previous.vehicleId && text(preview.previous?.driverId) === proof.previous.driverId
|
|
|
|
|
&& text(preview.replacement?.vehicleId) === text(vehicle.id) && text(preview.replacement?.driverId) === text(driver.id))
|
|
|
|
|
check('交接预览容量与已核对车辆一致', Number(preview.capacity?.palletSpaces) === capacity.maxPalletSpaces
|
|
|
|
|
&& Number(preview.capacity?.weightKg) === capacity.maxLoadKg && Number(preview.capacity?.volumeCbm) === capacity.maxVolumeCbm)
|
2026-10-03 14:52:04 +08:00
|
|
|
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, '确认实际在途交接')
|
2026-10-03 21:00:07 +08:00
|
|
|
check('交接保留原新资源和货物快照', handover?.handoverNo && handover.tripNo === tripProof.tripNo && text(handover.recordVersion) === '0'
|
2026-10-03 14:52:04 +08:00
|
|
|
&& 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)
|
2026-10-20 10:35:52 +08:00
|
|
|
const assignment = { recordVersion: firstProbe.recordVersion, idempotencyKey: key(),
|
|
|
|
|
vehicle: { mode: 'MASTER', vehicleId: proof.previous.vehicleId },
|
|
|
|
|
driver: { mode: 'MASTER', driverId: proof.previous.driverId, evidence: { expectedTaskEndAt: detail.trip.expectedDriverEndAt } },
|
|
|
|
|
plannedDepartureAt: new Date().toISOString(), estimatedArrivalAt: null, remark: `${context.marker}本轮旧资源占用核对` }
|
|
|
|
|
firstProbe.assignmentRequest = clone(assignment)
|
|
|
|
|
await request('PUT', `${DISPATCHES}/${encodeURIComponent(firstProbe.dispatchNo)}`, assignment, '交接后旧车辆仍被原车次占用', { conflict: /车辆.*占用/ })
|
2026-10-03 14:52:04 +08:00
|
|
|
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, '单独结束交接前车辆任务')
|
2026-10-03 21:00:07 +08:00
|
|
|
check('原车辆已结束而原司机仍在任务中', vehicleReleased.handoverNo === handover.handoverNo && vehicleReleased.tripNo === tripProof.tripNo
|
|
|
|
|
&& text(vehicleReleased.recordVersion) === String(BigInt(proof.vehicleReleaseRequest.recordVersion) + 1n)
|
|
|
|
|
&& Date.parse(vehicleReleased.vehicleRelease?.actualEndAt) === Date.parse(proof.vehicleReleaseRequest.actualEndAt)
|
|
|
|
|
&& vehicleReleased.vehicleRelease?.idempotencyKey === proof.vehicleReleaseRequest.idempotencyKey
|
|
|
|
|
&& vehicleReleased.vehicleRelease?.location === proof.vehicleReleaseRequest.location && vehicleReleased.vehicleRelease?.vehicleStatus === 'IN_SERVICE'
|
|
|
|
|
&& vehicleReleased.driverRelease == null)
|
2026-10-20 10:35:52 +08:00
|
|
|
await request('PUT', `${DISPATCHES}/${encodeURIComponent(firstProbe.dispatchNo)}`, assignment, '只释放车辆后旧司机仍被占用', { conflict: /司机.*占用/ })
|
2026-10-03 14:52:04 +08:00
|
|
|
proof.driverReleaseRequest = { recordVersion: vehicleReleased.recordVersion, resourceKind: 'DRIVER', actualEndAt: new Date().toISOString(), location: `${context.marker}司机任务结束点`, idempotencyKey: key() }
|
|
|
|
|
const released = await request('POST', releaseBase, proof.driverReleaseRequest, '单独结束交接前司机任务')
|
2026-10-03 21:00:07 +08:00
|
|
|
check('原车辆司机分别保留结束事实', released.handoverNo === handover.handoverNo && released.tripNo === tripProof.tripNo
|
|
|
|
|
&& text(released.recordVersion) === String(BigInt(proof.driverReleaseRequest.recordVersion) + 1n)
|
|
|
|
|
&& same(released.vehicleRelease, vehicleReleased.vehicleRelease)
|
|
|
|
|
&& Date.parse(released.driverRelease?.actualEndAt) === Date.parse(proof.driverReleaseRequest.actualEndAt)
|
|
|
|
|
&& released.driverRelease?.idempotencyKey === proof.driverReleaseRequest.idempotencyKey
|
|
|
|
|
&& released.driverRelease?.location === proof.driverReleaseRequest.location)
|
2026-10-20 10:35:52 +08:00
|
|
|
const assigned = await request('PUT', `${DISPATCHES}/${encodeURIComponent(firstProbe.dispatchNo)}`, assignment, '将已释放原资源分配给本轮无货计划')
|
|
|
|
|
check('原资源真正可重新分配', assigned.dispatchNo === firstProbe.dispatchNo && assigned.status === 'ACTIVE' && assigned.stage === 'BEFORE_LOADING'
|
|
|
|
|
&& BigInt(text(assigned.recordVersion)) > BigInt(text(firstProbe.recordVersion)))
|
|
|
|
|
firstProbe.recordVersion = assigned.recordVersion
|
|
|
|
|
const candidateRows = []; let candidateTotal
|
|
|
|
|
for (let page = 1; ; page++) {
|
|
|
|
|
const candidates = await request('GET', `${FLEET}/trips?${new URLSearchParams({ keyword: proof.previous.vehiclePlate,
|
|
|
|
|
warehouseId: text(tripProof.warehouseId), page: text(page), size: '200' })}`, undefined, '定位本轮空主单自动Fleet资源记录')
|
|
|
|
|
check('资源候选分页完整且稳定', Array.isArray(candidates.records) && Number(candidates.page) === page && Number(candidates.size) === 200
|
|
|
|
|
&& Number.isSafeInteger(Number(candidates.total)) && Number(candidates.total) >= 0
|
|
|
|
|
&& (candidateTotal === undefined || candidateTotal === Number(candidates.total)))
|
|
|
|
|
candidateTotal ??= Number(candidates.total)
|
|
|
|
|
check('资源候选未缺页', candidates.records.length > 0 || candidateRows.length === candidateTotal)
|
|
|
|
|
candidateRows.push(...candidates.records); if (candidateRows.length >= candidateTotal) break
|
|
|
|
|
}
|
|
|
|
|
check('资源候选总数和业务号无重复', candidateRows.length === candidateTotal && new Set(candidateRows.map(i => i.tripNo)).size === candidateRows.length)
|
|
|
|
|
const matches = []
|
|
|
|
|
for (const row of candidateRows.filter(row => text(row.vehicleId) === proof.previous.vehicleId && text(row.driverId) === proof.previous.driverId
|
|
|
|
|
&& row.vehiclePlate === proof.previous.vehiclePlate && text(row.warehouseId) === text(tripProof.warehouseId))) {
|
|
|
|
|
const candidate = await request('GET', pathFor(row.tripNo), undefined, '核对新资源记录与原生空单唯一绑定')
|
|
|
|
|
if (candidate.dispatches?.some(i => i.dispatchNo === firstProbe.dispatchNo)) {
|
|
|
|
|
check('新资源记录只能绑定本轮唯一空主单', candidate.trip?.tripNo === row.tripNo && candidate.dispatches.length === 1
|
|
|
|
|
&& text(candidate.trip.vehicleId) === proof.previous.vehicleId && text(candidate.trip.driverId) === proof.previous.driverId)
|
|
|
|
|
matches.push(candidate)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
check('原资源真正可重新分配且唯一定位', matches.length === 1 && matches[0].trip.tripNo !== tripProof.tripNo)
|
|
|
|
|
firstProbe.tripNo = matches[0].trip.tripNo
|
2026-10-03 14:52:04 +08:00
|
|
|
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)
|
2026-10-20 10:35:52 +08:00
|
|
|
const secondAssignment = { ...assignment, idempotencyKey: key(), recordVersion: secondProbe.recordVersion }
|
|
|
|
|
await request('PUT', `${DISPATCHES}/${encodeURIComponent(secondProbe.dispatchNo)}`, secondAssignment, '旧请求重放不能释放下一计划车辆', { conflict: /车辆.*占用/ })
|
2026-10-03 21:00:07 +08:00
|
|
|
const spareBody = { ...vehicleBody, plateNumber: `HO-PROBE-${randomUUID().slice(0, 10).toUpperCase()}` }
|
2026-10-03 14:52:04 +08:00
|
|
|
const spare = await request('POST', VEHICLES, spareBody, '创建仅用于司机占用隔离的本轮车辆')
|
|
|
|
|
check('隔离车辆已创建', spare?.id); proof.spareVehicleId = text(spare.id)
|
2026-10-20 10:35:52 +08:00
|
|
|
await request('PUT', `${DISPATCHES}/${encodeURIComponent(secondProbe.dispatchNo)}`, { ...secondAssignment, idempotencyKey: key(), vehicle: { mode: 'MASTER', vehicleId: spare.id } }, '旧请求重放不能释放下一计划司机', { conflict: /司机.*占用/ })
|
|
|
|
|
for (const expected of proof.dispatchBefore) {
|
|
|
|
|
check('交接不改变原排车出库和货物归属', same(await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对原生排车出库事实'), expected.detail)
|
|
|
|
|
&& same(await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}/items`, undefined, '核对原生排车逐客户货物事实'), expected.items))
|
|
|
|
|
}
|
|
|
|
|
for (const previous of proof.pdaTasksBefore) {
|
|
|
|
|
const current = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(previous.dispatchNo)}`, undefined, 'PDA回查原生整车版本及扫码归属未变化', { token: pickerToken })
|
|
|
|
|
check('交接不改变PDA原生整车版本及扫码归属', same(current, previous))
|
|
|
|
|
}
|
2026-10-03 14:52:04 +08:00
|
|
|
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
|
2026-10-20 10:35:52 +08:00
|
|
|
tripProof.marker = proof.replacement.vehiclePlate
|
2026-10-03 14:52:04 +08:00
|
|
|
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)
|
2026-10-20 10:35:52 +08:00
|
|
|
saved.createRequest = { warehouseId: text(tripProof.warehouseId), idempotencyKey: key(), vehicle: null, driver: null,
|
|
|
|
|
plannedDepartureAt: new Date().toISOString(), estimatedArrivalAt: null, cargo: [], batchAllocations: [],
|
|
|
|
|
remark: `${context.marker}在途交接资源验证原生空排车${index}` }
|
|
|
|
|
const value = await request('POST', DISPATCHES, saved.createRequest, '创建本轮无货资源核对计划')
|
|
|
|
|
check('核对计划不承载货物且尚未发车', value?.dispatchNo && value.status === 'ACTIVE' && value.stage === 'BEFORE_LOADING'
|
|
|
|
|
&& value.counts?.packageCount === 0 && value.counts?.pickedPackageCount === 0 && value.counts?.shippedPackageCount === 0 && validVersion(value.recordVersion))
|
|
|
|
|
saved.dispatchNo = value.dispatchNo; saved.recordVersion = value.recordVersion; return saved
|
2026-10-03 14:52:04 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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()) {
|
2026-10-20 10:35:52 +08:00
|
|
|
const path = `${DISPATCHES}/${encodeURIComponent(probe.dispatchNo)}`, detail = await request('GET', path, undefined, '核对仅本轮原生空排车')
|
|
|
|
|
const items = await request('GET', `${path}/items`, undefined, '核对本轮空排车零实际货物')
|
|
|
|
|
check('仅取消本轮未执行无货计划', detail.dispatchNo === probe.dispatchNo && detail.status === 'ACTIVE' && detail.stage === 'BEFORE_LOADING'
|
|
|
|
|
&& detail.documents?.remark?.includes(context.marker) && text(detail.recordVersion) === text(probe.recordVersion)
|
|
|
|
|
&& detail.counts?.itemCount === 0 && detail.counts?.packageCount === 0 && detail.counts?.pickedPackageCount === 0 && detail.counts?.shippedPackageCount === 0
|
|
|
|
|
&& items.dispatchNo === probe.dispatchNo && text(items.recordVersion) === text(detail.recordVersion) && items.items?.length === 0)
|
|
|
|
|
probe.cancelRequest = { recordVersion: detail.recordVersion, idempotencyKey: key(), reason: '本轮交接验证完成,取消原生空排车并保留历史' }
|
|
|
|
|
const cancelled = await request('POST', `${path}/cancel`, probe.cancelRequest, '取消本轮资源核对计划')
|
|
|
|
|
check('无货计划已取消', cancelled.dispatchNo === probe.dispatchNo && cancelled.status === 'CANCELLED')
|
2026-10-03 14:52:04 +08:00
|
|
|
}
|
|
|
|
|
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 }
|
|
|
|
|
}
|