Files
baiyue-firstleg/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs
T

317 lines
30 KiB
JavaScript

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'), sizeBytes: 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', '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, { reviewedVehicle } = {}) {
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)
const cargo = original.confirmedLoad.cargo.filter(item => item.dispatchNo === expected.dispatchNo)
const items = values => values.map(item => ({ itemId: text(item.id ?? item.dispatchItemId), palletId: text(item.warehousePalletId),
packages: Number(item.packageCount ?? item.packages) })).sort((a, b) => a.itemId.localeCompare(b.itemId))
check('每张排车已发车且货量与客户起运仓一致', row?.dispatchNo === expected.dispatchNo && row.status === 'DEPARTED'
&& Number(row.shippedPackageCount) === Number(expected.packageCount) && Number(row.pickedPackageCount) === Number(expected.packageCount)
&& binding?.customerId && cargo.length > 0 && cargo.every(item => text(item.customerId) === text(binding.customerId))
&& same(items(row.items || []), items(cargo)) && 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 = reviewedVehicle ? reviewedVehicle.snapshot.plateNumber : `HO-${randomUUID().slice(0, 12).toUpperCase()}`
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 }
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, '建立本轮受控接替车辆')
check('接替车辆返回真实身份及车牌', vehicle?.id && vehicle.plateNumber === marker)
proof.replacement.vehicleId = text(vehicle.id); proof.replacement.vehiclePlate = vehicle.plateNumber
const driverBody = { 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` }
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))
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('测试证照有正式文件归属', text(uploaded?.id) === text(driver.id) && uploaded?.licenseFileId
&& text(uploaded.recordVersion) === String(BigInt(driver.recordVersion) + 1n))
proof.replacement.licenseFileId = text(uploaded.licenseFileId)
const reviewed = await request('POST', `${FLEET}/drivers/${encodeURIComponent(driver.id)}/review`, { recordVersion: uploaded.recordVersion, decision: 'APPROVED', reason: '仅核验本轮合成测试证照' }, '正式审核本轮接替司机')
check('接替司机已正式审核', text(reviewed?.id) === text(driver.id) && reviewed.reviewStatus === 'APPROVED'
&& text(reviewed.recordVersion) === String(BigInt(uploaded.recordVersion) + 1n) && reviewed.licenseFileId === uploaded.licenseFileId)
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, '预览真实剩余货物及资源资格')
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)
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 && text(handover.recordVersion) === '0'
&& 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.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)
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.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)
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).toUpperCase()}` }
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 }
}