278 lines
26 KiB
JavaScript
278 lines
26 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'), 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 }
|
||
|
|
}
|