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?.dispatchNo !== tripProof.dispatchNo || run.departure.status !== 'ACTIVE' || run.departure.stage !== 'DELIVERING' || !run.departure.result?.departedAt || !count(run.departure.result.shippedPackages)) 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 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))) } 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 (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)) 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.result.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.pdaTasksBefore = []; proof.feesBefore = [] for (const expected of dispatchDetails) { const row = await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对客户排车已经真实出库') const items = await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}/items`, undefined, '读取原生主单逐客户真实货物') const cargo = original.confirmedLoad.cargo.filter(item => item.dispatchNo === expected.dispatchNo) 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) 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 === '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())) proof.pdaBefore.push(pda) for (const delivery of pda.deliveries) { 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 })}` 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, 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: /车辆.*占用/ }) 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('PUT', `${DISPATCHES}/${encodeURIComponent(firstProbe.dispatchNo)}`, 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('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 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) const secondAssignment = { ...assignment, idempotencyKey: key(), recordVersion: secondProbe.recordVersion } await request('PUT', `${DISPATCHES}/${encodeURIComponent(secondProbe.dispatchNo)}`, secondAssignment, '旧请求重放不能释放下一计划车辆', { 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('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)) } 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 tripProof.marker = proof.replacement.vehiclePlate 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) 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 } } /** 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 = `${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') } 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 } }