Files
baiyue-firstleg/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs
T

463 lines
39 KiB
JavaScript
Raw Normal View History

import { createHash, randomUUID } from 'node:crypto'
const BASE = '/api/platform/v1/last-leg/fleet'
2026-10-20 10:35:52 +08:00
const DISPATCH = '/api/platform/v1/last-leg/dispatches'
const PDA = '/api/pda/v1/last-leg/dispatches'
const OPTIONS = { scenario: 'fleet-physical-trip-departure', deploymentBlocker: true }
2026-10-20 10:35:52 +08:00
const id = value => {
if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('服务端资源ID超出安全整数范围,必须保留原始字符串')
return value == null ? '' : String(value)
}
const key = (proof, action) => createHash('sha256').update(`${proof.marker}:${proof.dispatchNo}:${action}`).digest('hex')
const version = value => {
2026-10-20 10:35:52 +08:00
if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^(0|[1-9]\d*)$/.test(String(value))) throw new Error('服务端排车版本无效')
return BigInt(String(value))
}
2026-10-20 10:35:52 +08:00
function check(context, module, label, condition, evidence = '') {
context.assert(module, label, null, condition, evidence, OPTIONS)
if (!condition) throw new Error(`${module}: ${label}失败`)
}
// 写前冻结原请求;未知结果停写,不能换键重造或自动重发。
async function command(context, module, method, path, body, label, token, expectedRejection) {
let entry
if (method !== 'GET') {
context.createdResources ??= {}; context.createdResources.fleetTripCommands ??= []
entry = { method, path, body: structuredClone(body), status: 'PREPARED' }
context.createdResources.fleetTripCommands.push(entry); await context.checkpoint?.()
}
let response
try { response = await context.request(method, path, body, token) }
catch (error) { if (entry) { entry.status = 'UNKNOWN'; await context.checkpoint?.() }; throw error }
if (entry) {
Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code, requestId: response?.body?.requestId, traceId: response?.body?.traceId })
await context.checkpoint?.()
}
if (expectedRejection) {
check(context, module, label, response?.http === 200 && String(response?.body?.code) === expectedRejection.code
&& response?.body?.message === expectedRejection.message)
return { http: response.http, code: response.body.code, message: response.body.message,
requestId: response.body.requestId, traceId: response.body.traceId }
}
const data = context.apiSuccess(module, label, response, '', OPTIONS)
if (response?.http !== 200 || String(response?.body?.code) !== '200' || !data) throw new Error(`${module}: ${label}失败`)
return data
}
2026-10-22 11:56:47 +08:00
// 按审核后权威车辆快照分页,再核对真实资源及本轮排车唯一绑定。
2026-10-20 10:35:52 +08:00
async function discoverTrip(context, module, proof) {
2026-10-22 11:56:47 +08:00
const vehiclePlate = proof.vehicle?.plate
if (typeof vehiclePlate !== 'string' || !vehiclePlate.trim()) throw Error('审核后的车辆快照缺少真实车牌')
2026-10-20 10:35:52 +08:00
const rows = [], seen = new Set(); let total
for (let page = 1; ; page++) {
const query = new URLSearchParams({ page: String(page), size: '200', keyword: vehiclePlate, warehouseId: proof.warehouseId })
2026-10-22 11:56:47 +08:00
const data = await command(context, module, 'GET', `${BASE}/trips?${query}`, undefined, '按实际车牌读取自动资源记录')
if (!Array.isArray(data.records) || data.total == null || data.total === '' || !Number.isSafeInteger(Number(data.total)) || Number(data.total) < 0
2026-10-20 10:35:52 +08:00
|| Number(data.page) !== page || Number(data.size) !== 200) throw new Error('车次分页不完整,不能证明唯一关联')
total ??= Number(data.total)
if (Number(data.total) !== total || data.records.length > 200 || (!data.records.length && rows.length < total)) throw new Error('车次分页变化或缺页')
for (const row of data.records) {
if (!row.tripNo || seen.has(row.tripNo)) throw new Error('车次分页业务号缺失或重复')
seen.add(row.tripNo); rows.push(row)
}
if (rows.length >= total) break
}
if (rows.length !== total) throw new Error('车次分页总数不一致')
const matches = rows.filter(row => row.vehiclePlate === vehiclePlate && id(row.vehicleId) === proof.vehicleId
&& id(row.driverId) === proof.driverId && id(row.warehouseId) === proof.warehouseId && row.tripPurpose === 'DELIVERY')
const bound = []
for (const candidate of matches) {
const detail = await command(context, module, 'GET', `${BASE}/trips/${encodeURIComponent(candidate.tripNo)}`, undefined, '核对排车与资源记录唯一绑定')
if (detail.trip?.tripNo !== candidate.tripNo || id(detail.trip.vehicleId) !== proof.vehicleId || id(detail.trip.driverId) !== proof.driverId)
throw new Error('真实车次候选资源发生变化')
if (detail.dispatches?.some(row => row.dispatchNo === proof.dispatchNo)) {
if (detail.dispatches.length !== 1) throw new Error('原生排车资源记录仍关联多张子单')
bound.push(detail)
}
}
if (bound.length !== 1) throw new Error(`本轮排车自动资源记录不是唯一绑定:${bound.length}`)
const [detail] = bound
Object.assign(proof, { tripNo: detail.trip.tripNo, trip: detail.trip, stops: detail.trip.stops, fleetRecordVersion: detail.trip.recordVersion })
}
2026-10-22 11:56:47 +08:00
export async function reuseFleetResources(context, module, proof, arrival) {
const pairs = context.options.fixedFleetResources
const busy = (context.createdResources.fleetTrips || []).filter(item => item !== proof
&& item.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
const localPairs = Array.isArray(pairs) ? pairs.filter(item => /^[1-9][0-9]*$/.test(id(item?.vehicleId))
2026-10-22 11:56:47 +08:00
&& /^[1-9][0-9]*$/.test(id(item?.driverId))
&& !busy.some(trip => id(trip.vehicleId) === id(item.vehicleId) || id(trip.driverId) === id(item.driverId))) : []
if (!localPairs.length) throw Error('缺少本轮可用的固定车辆司机组合;禁止自动新建主档或证照')
const trips = [], seen = new Set(); let total
for (let page = 1; page <= 100; page++) {
const data = await command(context, module, 'GET', `${BASE}/trips?page=${page}&size=200`, undefined, '核对共享环境车辆司机占用')
if (!Array.isArray(data.records) || !Number.isSafeInteger(Number(data.total)) || Number(data.total) < 0
|| Number(data.page) !== page || Number(data.size) !== 200) throw Error('固定车辆司机占用分页无效')
total ??= Number(data.total)
if (Number(data.total) !== total || data.records.length > 200 || (!data.records.length && trips.length < total))
throw Error('固定车辆司机占用分页变化或缺页')
for (const row of data.records) {
if (!row.tripNo || seen.has(row.tripNo)) throw Error('固定车辆司机占用车次重复或缺少业务号')
seen.add(row.tripNo); trips.push(row)
}
if (trips.length >= total) break
}
if (trips.length !== total) throw Error('固定车辆司机占用分页不完整')
const pair = localPairs.find(item => !trips.some(trip => trip.status !== 'CANCELLED'
&& ((id(trip.vehicleId) === id(item.vehicleId) && !trip.vehicleRelease?.actualEndAt)
|| (id(trip.driverId) === id(item.driverId) && !trip.driverRelease?.actualEndAt))))
2026-10-22 11:56:47 +08:00
if (!pair) throw Error('缺少本轮可用的固定车辆司机组合;禁止自动新建主档或证照')
proof.expectedDriverEndAt = new Date(Math.max(Date.now() + 48 * 3600000, arrival ? Date.parse(arrival) + 6 * 3600000 : 0)).toISOString()
const vehicle = await command(context, module, 'GET', `/api/platform/v1/last-leg/master-data/vehicles/${id(pair.vehicleId)}`, undefined, '核对固定车辆状态')
const driver = await command(context, module, 'GET', `${BASE}/drivers/${id(pair.driverId)}?expectedEnd=${encodeURIComponent(proof.expectedDriverEndAt)}`, undefined, '核对固定司机至任务结束的资格')
if (id(vehicle.id) !== id(pair.vehicleId) || vehicle.status !== 'IN_SERVICE' || vehicle.region !== 'OVERSEAS' || vehicle.ownership !== 'OWNED'
|| id(driver.id) !== id(pair.driverId) || driver.active !== true || driver.reviewStatus !== 'APPROVED'
|| driver.details?.ownership !== 'OWNED' || driver.qualification?.canDispatch !== true || driver.qualification?.reason !== 'ELIGIBLE')
throw Error('固定车辆司机状态或资格不符;不创建、启用或修复主档')
Object.assign(proof, { vehicleId: id(vehicle.id), driverId: id(driver.id), reusedMasterData: true })
}
/** 固定客户复用已配置主档;非固定生命周期场景才建立专用主档。 */
export async function prepareFleetTrip(context, module, dispatchNo, warehouseId) {
if (!dispatchNo || !warehouseId) throw new Error('车次验收缺少本轮排车单或实际起运仓')
2026-10-20 10:35:52 +08:00
const proof = { dispatchNo, warehouseId: id(warehouseId), marker: `FT-${randomUUID().slice(0, 12)}`,
syntheticTestMaterials: true, preparation: 'CHECKING', reassignment: { status: 'PENDING' } }
context.createdResources.fleetTrips ??= []; context.createdResources.fleetTrips.push(proof)
const path = `${DISPATCH}/${encodeURIComponent(dispatchNo)}`
let before = await command(context, module, 'GET', path, undefined, '读取原生排车主单版本和阶段')
version(before.recordVersion)
if (before.dispatchNo !== dispatchNo || before.status !== 'ACTIVE' || safePackageCount(before.counts?.pickedPackageCount) !== 0
|| safePackageCount(before.counts?.shippedPackageCount) !== 0) throw new Error('仅本轮有效零扫码排车可准备车辆和审核')
2026-10-22 11:56:47 +08:00
const confirmedTimes = (before.dispatchInfo?.fbaAppointments || []).filter(row => row.status === 'SUCCESS' && row.confirmedAt).map(row => row.confirmedAt).sort()
const arrival = before.dispatchInfo?.estimatedArrivalAt ?? confirmedTimes[0] ?? null
if (context.options?.fixedCustomerId) await reuseFleetResources(context, module, proof, arrival)
2026-10-20 10:35:52 +08:00
if (before.stage === 'LOADING') {
proof.returnRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'RETURN_TO_REVIEW'), reason: 'TEST零扫码明确退回装车前后调整资源' }
await command(context, module, 'POST', `${path}/return-to-review`, proof.returnRequest, '零扫码排车显式退回装车前')
before = await command(context, module, 'GET', path, undefined, '回查显式退回后的审核状态')
}
2026-10-20 10:35:52 +08:00
if (before.stage !== 'BEFORE_LOADING' || !['DRAFT', 'DISPATCH_REJECTED', 'LOADING_REJECTED'].includes(before.reviewState))
throw new Error('排车未处于可编辑装车前阶段,不能隐式撤销审核或改派')
2026-10-22 11:56:47 +08:00
if (!context.options?.fixedCustomerId) {
2026-10-20 10:35:52 +08:00
const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', {
plateNumber: proof.marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 100,
region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90,
remark: `${context.marker} TEST合成车辆主档,容量不是实载测量`,
}, '建立本轮TEST车辆主档及容量')
proof.vehicleId = id(vehicle.id)
if (!proof.vehicleId) throw new Error('车辆主档没有返回真实资源ID')
2026-10-21 21:30:24 +08:00
await prepareQualifiedDriver(context, module, proof, arrival)
2026-10-22 11:56:47 +08:00
}
proof.reviewPhotos = {}
for (const kind of ['VEHICLE', 'DRIVER_PHOTO', 'DRIVER']) {
const upload = { path: `${path}/evidence/${kind}`, name: `${proof.marker}-TEST-${kind}.png`, status: 'PREPARED' }
proof.reviewPhotos[kind] = upload
await context.checkpoint?.()
let response
try {
response = await context.requestMultipart('POST', upload.path, { name: upload.name, type: 'image/png',
content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') })
} catch (error) { upload.status = 'UNKNOWN'; await context.checkpoint?.(); throw error }
Object.assign(upload, { status: 'RESPONDED', http: response?.http, code: response?.body?.code })
const file = context.apiSuccess(module, `上传本车TEST合成${kind}审核照片`, response, '', OPTIONS)
if (response?.http !== 200 || String(response?.body?.code) !== '200' || !/^[1-9]\d*$/.test(id(file?.fileId)))
throw Error(`本车${kind}审核照片上传失败,停止送审`)
upload.fileId = id(file.fileId)
await context.checkpoint?.()
}
const maintenance = structuredClone(before.vehicleInfo?.maintenance || {})
const appointments = new Map((maintenance.destinationAppointments || []).map(row => [row.destinationKey, row]))
for (const row of before.dispatchInfo?.fbaAppointments || []) {
if (!row.destinationKey || !row.destinationWarehouseCode) continue
const existing = row.appointmentNo || appointments.get(row.destinationKey)?.appointmentNo
appointments.set(row.destinationKey, { destinationKey: row.destinationKey,
appointmentNo: existing || `TEST-${proof.marker}-${row.destinationWarehouseCode}`.slice(0, 128) })
}
maintenance.destinationAppointments = [...appointments.values()]
2026-10-20 10:35:52 +08:00
proof.updateRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'EDIT'),
maintenance,
2026-10-21 21:30:24 +08:00
publishLooseboardDemand: before.publishLooseboardDemand,
vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId, evidenceFileIds: [proof.reviewPhotos.VEHICLE.fileId] },
driver: { mode: 'MASTER', driverId: proof.driverId, evidence: { expectedTaskEndAt: proof.expectedDriverEndAt,
fileIds: [proof.reviewPhotos.DRIVER.fileId], photoFileIds: [proof.reviewPhotos.DRIVER_PHOTO.fileId] } },
2026-10-21 21:30:24 +08:00
plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: arrival,
2026-10-20 10:35:52 +08:00
remark: `${context.marker} TEST合成资料正常履约` }
let result = await command(context, module, 'PUT', path, proof.updateRequest, '通过排车主单编辑正式分配车辆司机')
for (const action of ['submit-review', 'dispatch-review', 'loading-review']) {
const body = { recordVersion: result.recordVersion, idempotencyKey: key(proof, action),
...(action === 'submit-review' ? { remark: '本轮TEST整车提交审核' } : { decision: 'APPROVED', reason: '本轮TEST整车审核' }) }
result = await command(context, module, 'POST', `${path}/${action}`, body, `原生整车${action}`)
}
const detail = await command(context, module, 'GET', path, undefined, '审核后读取原生资源快照')
check(context, module, '两级审核通过才进入装车中', detail.status === 'ACTIVE' && detail.stage === 'LOADING'
&& detail.reviewState === 'APPROVED' && detail.reviews?.dispatchReview?.status === 'APPROVED'
&& detail.reviews?.loadingReview?.status === 'APPROVED' && safePackageCount(detail.counts?.pickedPackageCount) === 0
&& version(detail.recordVersion) === version(result.recordVersion))
Object.assign(proof, { vehicleInfo: detail.vehicleInfo, vehicle: detail.vehicleInfo?.vehicle, driver: detail.vehicleInfo?.driver,
dispatchReview: detail.reviews.dispatchReview, loadingReview: detail.reviews.loadingReview, recordVersion: detail.recordVersion })
await discoverTrip(context, module, proof)
proof.preparation = 'VERIFIED'; await context.checkpoint?.(); return proof
}
2026-10-21 21:30:24 +08:00
async function prepareQualifiedDriver(context, module, proof, arrival) {
proof.expectedDriverEndAt = new Date(Math.max(Date.now() + 48 * 3600000, arrival ? Date.parse(arrival) + 6 * 3600000 : 0)).toISOString()
let driver = await command(context, module, 'POST', `${BASE}/drivers`, {
2026-10-20 10:35:52 +08:00
name: `${proof.marker}验收司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS',
licenseNumber: proof.marker, licenseType: 'Class A', licenseZone: 'UTC',
licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10), remark: `${context.marker} TEST合成证照资料,不是真实驾驶证`,
}, '建立本轮TEST司机档案')
proof.driverId = id(driver.id)
2026-10-20 10:35:52 +08:00
if (!proof.driverId) throw new Error('司机主档没有返回真实资源ID')
proof.licenseUpload = { path: `${BASE}/drivers/${proof.driverId}/license?recordVersion=${driver.recordVersion}`,
name: `${proof.marker}-TEST-LICENSE.png`, syntheticTestMaterials: true, status: 'PREPARED' }
await context.checkpoint?.()
let uploaded
try {
uploaded = await context.requestMultipart('POST', proof.licenseUpload.path, { name: proof.licenseUpload.name, type: 'image/png',
content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') })
} catch (error) { proof.licenseUpload.status = 'UNKNOWN'; await context.checkpoint?.(); throw error }
Object.assign(proof.licenseUpload, { status: 'RESPONDED', http: uploaded?.http, code: uploaded?.body?.code }); await context.checkpoint?.()
driver = context.apiSuccess(module, '上传TEST合成证照文件', uploaded, '', OPTIONS)
if (uploaded?.http !== 200 || String(uploaded?.body?.code) !== '200' || !driver?.licenseFileId) throw new Error('TEST证照上传失败,不能伪造资格')
proof.licenseFileId = id(driver.licenseFileId)
const reviewed = await command(context, module, 'POST', `${BASE}/drivers/${proof.driverId}/review`, {
2026-10-20 10:35:52 +08:00
recordVersion: driver.recordVersion, decision: 'APPROVED', reason: '仅本轮TEST合成证照验收',
}, '正式审核本轮TEST司机')
if (reviewed.reviewStatus !== 'APPROVED') throw new Error('TEST司机未审核通过')
}
2026-10-20 10:35:52 +08:00
const decimal = value => String(value).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')
function evidenceFor(row, field, value) {
return row?.evidence?.some(e => e.field === field && e.actual === true && e.sourceType && e.sourceBusinessNo
&& Number.isFinite(Date.parse(e.observedAt)) && decimal(e.value) === decimal(value))
}
function safePackageCount(value) {
if ((typeof value !== 'number' && typeof value !== 'string')
|| !/^(0|[1-9]\d*)$/.test(String(value)) || !Number.isSafeInteger(Number(value)))
throw new Error('实际托盘箱数缺失或精度无效')
return Number(value)
}
/** 保留导出名称供直接调用方定位;任何实际量均须来自实测证据。 */
export function controlledPalletMeasurements(pallets, stops, actualPallets = []) {
if (!Array.isArray(pallets) || !pallets.length || !Array.isArray(stops)) throw new Error('没有实际装载托盘或真实路线')
const supplied = new Map(), seen = new Set()
for (const row of actualPallets) {
if (!row.warehousePalletId || supplied.has(id(row.warehousePalletId))) throw new Error('生产实测回执托盘缺失或重复')
supplied.set(id(row.warehousePalletId), row)
}
2026-10-20 10:35:52 +08:00
const result = pallets.map(row => {
const palletId = id(row.warehousePalletId)
if (!palletId || !row.palletNo || seen.has(palletId)) throw new Error('实际预览托盘缺失或重复')
seen.add(palletId)
const scheduled = safePackageCount(row.scheduledPackages), picked = safePackageCount(row.pickedPackages)
if (scheduled <= 0 || picked !== scheduled) throw new Error('实际托盘拣货尚未核平')
const receipt = supplied.get(palletId), output = { warehousePalletId: palletId }
for (const field of ['palletSpaces', 'weightKg', 'volumeCbm']) {
const source = row[field] == null ? receipt : row, value = source?.[field]
if (!/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(String(value)) || !Number.isFinite(Number(value)) || Number(value) <= 0) throw new Error(`实际测量缺少有效${field}`)
if (!evidenceFor(source, field, value)) throw new Error(`实际测量${field}缺少真实来源证据`)
if (field === 'palletSpaces' && !Number.isSafeInteger(value)) throw new Error('实际托位必须为正整数')
if (row[field] != null && receipt?.[field] != null && decimal(row[field]) !== decimal(receipt[field])) throw new Error(`生产回执与预览${field}不一致`)
output[field] = value
}
const index = row.deliveryIndex ?? receipt?.deliveryIndex
if (!Number.isSafeInteger(index) || index < 1 || !stops[index]?.destinationKey) throw new Error('实际卸货站缺失或不在车次路线')
if (row.deliveryIndex != null && receipt?.deliveryIndex != null && row.deliveryIndex !== receipt.deliveryIndex) throw new Error('生产回执与实际卸货站不一致')
output.deliveryIndex = index; return output
})
if ([...supplied.keys()].some(palletId => !seen.has(palletId))) throw new Error('生产实测回执包含本车以外托盘')
return result
}
2026-10-20 10:35:52 +08:00
// 已扫码单只能用原生编辑拒绝证明,不能再走旧Fleet强行改派。
async function verifyPreDepartureReassignment(context, module, proof) {
const state = proof.reassignment
2026-10-20 10:35:52 +08:00
if (!state) return
if (state.status !== 'PENDING') throw new Error('本轮改派拒绝检查已开始;保留原请求排查,不自动重试')
state.status = 'CHECKING'
const path = `${DISPATCH}/${encodeURIComponent(proof.dispatchNo)}`
state.before = await command(context, module, 'GET', path, undefined, '已扫码拒绝前保存主单')
state.itemsBefore = await command(context, module, 'GET', `${path}/items`, undefined, '保存真实扫码与来源')
if (state.before.status !== 'ACTIVE' || state.before.stage !== 'LOADING' || safePackageCount(state.before.counts?.pickedPackageCount) <= 0) throw new Error('拒绝检查缺少本轮真实扫码事实')
state.request = { ...proof.updateRequest, recordVersion: state.before.recordVersion, idempotencyKey: key(proof, 'SCANNED_EDIT_MUST_REJECT'),
plannedDepartureAt: new Date(Date.parse(proof.updateRequest.plannedDepartureAt) + 60000).toISOString(), remark: 'TEST已扫码后编辑资源计划必须拒绝' }
await context.checkpoint?.()
const response = await context.request('PUT', path, state.request)
state.response = { http: response?.http, code: response?.body?.code, message: response?.body?.message }
check(context, module, '已扫码原生排车编辑被服务端拒绝', response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(String(response?.body?.code))))
state.after = await command(context, module, 'GET', path, undefined, '拒绝后回查主单')
state.itemsAfter = await command(context, module, 'GET', `${path}/items`, undefined, '拒绝后回查真实货物')
check(context, module, '已扫码拒绝后版本资源货物数量不变', JSON.stringify(state.before) === JSON.stringify(state.after) && JSON.stringify(state.itemsBefore) === JSON.stringify(state.itemsAfter))
state.status = 'REJECTED_WITHOUT_MUTATION'
}
2026-10-22 11:56:47 +08:00
export const LOADING_PHOTO_SLOTS = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT']
export async function ensureFleetLoadingDocuments(context, module, proof, token) {
const path = `${token ? PDA : DISPATCH}/${encodeURIComponent(proof.dispatchNo)}/loading-documents`
const read = () => command(context, module, 'GET', path, undefined, '回查本车装车资料', token)
let documents = await read()
const initialStage = documents.stage
const complete = value => LOADING_PHOTO_SLOTS.every(slot => value.photos?.some(photo => photo.slot === slot && id(photo.fileId)))
const validate = value => {
if (value.dispatchNo !== proof.dispatchNo || !['LOADING', 'AFTER_LOADING', 'DELIVERING'].includes(value.stage)
|| value.complete !== complete(value)) throw Error('本车装车资料身份、阶段或五框完整性不匹配')
}
validate(documents)
proof.loadingDocuments ??= { syntheticTestMaterials: true, uploads: [] }
for (const slot of LOADING_PHOTO_SLOTS) {
if (documents.photos?.some(photo => photo.slot === slot && id(photo.fileId))) continue
if (!documents.editable || typeof context.requestMultipart !== 'function') throw Error('缺少本车装车照片写入资格或上传能力')
const content = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
const upload = { slot, path: `${path}/${slot}/uploads?idempotencyKey=${key(proof, `PHOTO_UPLOAD_${slot}`)}`,
name: `${proof.marker}-TEST-${slot}.png`, sha256: createHash('sha256').update(content).digest('hex'), status: 'PREPARED' }
if (proof.loadingDocuments.uploads.some(item => item.slot === slot)) throw Error('该照片上传已开始,保留原键和结果排查,不自动重发')
proof.loadingDocuments.uploads.push(upload); await context.checkpoint?.()
let response
try { response = await context.requestMultipart('POST', upload.path, { name: upload.name, type: 'image/png', content }, token) }
catch (error) { upload.status = 'UNKNOWN'; await context.checkpoint?.(); throw error }
Object.assign(upload, { status: response?.http === 200 && response?.body?.code ? 'RESPONDED' : 'UNKNOWN',
http: response?.http, code: response?.body?.code, requestId: response?.body?.requestId, traceId: response?.body?.traceId })
await context.checkpoint?.()
const photo = context.apiSuccess(module, '上传本轮TEST合成装车照片', response, '', OPTIONS)
if (response?.http !== 200 || String(response?.body?.code) !== '200' || photo?.slot !== slot || !id(photo.fileId)) throw Error('装车照片上传未确认,停止绑定及发车')
const body = { recordVersion: documents.recordVersion, idempotencyKey: key(proof, `PHOTO_BIND_${slot}`), fileId: id(photo.fileId) }
const bound = await command(context, module, 'PUT', `${path}/${slot}`, body, '保存本车装车照片', token)
validate(bound)
check(context, module, '绑定回执保留本槽文件', bound.photos.some(item => item.slot === slot && id(item.fileId) === id(photo.fileId)))
const replay = await command(context, module, 'PUT', `${path}/${slot}`, body, '原键重放装车照片绑定', token)
check(context, module, '照片绑定重放恢复原回执', JSON.stringify(replay) === JSON.stringify(bound))
documents = await read(); validate(documents)
check(context, module, '装车资料未齐不得提前发车', documents.stage === (initialStage === 'AFTER_LOADING' && documents.complete ? 'DELIVERING' : initialStage))
check(context, module, '装车照片已持久化', documents.photos.some(item => item.slot === slot && id(item.fileId) === id(photo.fileId)))
}
check(context, module, '五个装车照片框均已保存', documents.complete && complete(documents))
proof.loadingDocuments.result = documents
await context.checkpoint?.()
return documents
}
2026-10-21 14:30:41 +08:00
export async function confirmFleetLoading(context, module, proof, pdaToken, actualMeasurements, beforeComplete) {
2026-10-20 10:35:52 +08:00
if (!proof?.dispatchNo || !proof.tripNo) throw new Error('缺少本轮原生排车及唯一资源记录证明')
if (proof.loadingCommand) throw new Error('本轮装车命令已开始;保留原请求排查,不自动重试')
const path = `${DISPATCH}/${encodeURIComponent(proof.dispatchNo)}`, pdaPath = `${PDA}/${encodeURIComponent(proof.dispatchNo)}`
2026-10-22 11:56:47 +08:00
let preview = await command(context, module, 'GET', `${pdaToken ? pdaPath : path}/loading`, undefined, '读取真实整车拣货及实载证据', pdaToken)
2026-10-20 10:35:52 +08:00
if (preview.dispatchNo !== proof.dispatchNo || preview.status !== 'ACTIVE' || preview.stage !== 'LOADING' || preview.pickingReconciled !== true
|| preview.loadingComplete?.allowed !== true || !preview.manifestHash) throw new Error(`整车未就绪:${preview.loadingComplete?.blockReason || '真实拣货未核平或阶段不符'}`)
if (actualMeasurements && (actualMeasurements.environment !== 'TEST' || actualMeasurements.marker !== context.marker
|| actualMeasurements.dispatchNo !== proof.dispatchNo || !Array.isArray(actualMeasurements.pallets))) throw new Error('实测回执不属于本轮TEST排车')
const measurements = preview.pallets.map(p => ({ warehousePalletId: id(p.warehousePalletId), palletSpaces: 1, weightKg: p.weightKg ?? null, volumeCbm: p.volumeCbm ?? null, deliveryIndex: p.deliveryIndex }))
2026-10-20 10:35:52 +08:00
await verifyPreDepartureReassignment(context, module, proof)
2026-10-22 11:56:47 +08:00
await ensureFleetLoadingDocuments(context, module, proof, pdaToken)
const refreshed = await command(context, module, 'GET', `${pdaToken ? pdaPath : path}/loading`, undefined, '照片保存后回查装车资格', pdaToken)
check(context, module, '资料保存不提前装车或改变实载清单', refreshed.dispatchNo === proof.dispatchNo
&& refreshed.status === 'ACTIVE' && refreshed.stage === 'LOADING' && refreshed.pickingReconciled === true
&& refreshed.loadingComplete?.allowed === true && refreshed.manifestHash === preview.manifestHash)
preview = refreshed
2026-10-20 10:35:52 +08:00
proof.loadingPreview = preview; proof.loadingMeasurementReceipt = actualMeasurements
proof.loadingCommand = { path: `${pdaToken ? pdaPath : path}/loading-complete`, body: { recordVersion: preview.recordVersion,
idempotencyKey: key(proof, 'LOADING_COMPLETE'), manifestHash: preview.manifestHash, remark: 'TEST本轮实际读取测量证据,不使用计费重量或固定值' } }
2026-10-21 14:30:41 +08:00
if (beforeComplete) await beforeComplete(proof.loadingCommand)
2026-10-20 10:35:52 +08:00
const confirmed = await command(context, module, 'POST', proof.loadingCommand.path, proof.loadingCommand.body, '原生显式装车完成', pdaToken)
2026-10-21 14:30:41 +08:00
check(context, module, '装车完成即进入派送中', confirmed.dispatchNo === proof.dispatchNo && confirmed.status === 'ACTIVE' && confirmed.stage === 'DELIVERING'
2026-10-20 10:35:52 +08:00
&& confirmed.result?.confirmedLoad?.manifestHash === preview.manifestHash && version(confirmed.recordVersion) > version(preview.recordVersion))
const returned = confirmed.result.confirmedLoad.pallets
check(context, module, '实载回执逐托保留原测量且不增加货量', returned?.length === measurements.length
&& measurements.every(m => returned.some(r => id(r.warehousePalletId) === m.warehousePalletId
&& r.palletSpaces === m.palletSpaces && (r.weightKg == null ? null : decimal(r.weightKg)) === (m.weightKg == null ? null : decimal(m.weightKg))
&& (r.volumeCbm == null ? null : decimal(r.volumeCbm)) === (m.volumeCbm == null ? null : decimal(m.volumeCbm)) && r.deliveryIndex === m.deliveryIndex)))
2026-10-20 10:35:52 +08:00
proof.loadingCommand.result = confirmed; proof.confirmedLoad = confirmed.result.confirmedLoad
const snapshot = async label => ({
main: await command(context, module, 'GET', path, undefined, `${label}主单`),
items: await command(context, module, 'GET', `${path}/items`, undefined, `${label}货物`),
loading: await command(context, module, 'GET', `${path}/loading`, undefined, `${label}装车事实`),
})
proof.loadingCommand.beforeReplay = await snapshot('装车原键验证前回读')
const replay = await command(context, module, 'POST', proof.loadingCommand.path, proof.loadingCommand.body, '原操作员原端原键重放装车', pdaToken)
check(context, module, '原操作员装车重放恢复原结果且不增加版本', replay.replayed === true && replay.dispatchNo === confirmed.dispatchNo
&& version(replay.recordVersion) === version(confirmed.recordVersion) && JSON.stringify(replay.result) === JSON.stringify(confirmed.result))
proof.loadingCommand.replay = replay
if (pdaToken) {
// 当前场景使用独立平台/PDA操作者;由账本的精确所有者拒绝证明隔离,不能按Token外观猜同一账号。
proof.loadingCommand.platformRejection = await command(context, module, 'POST', `${path}/loading-complete`, proof.loadingCommand.body,
'另一平台操作员不能重用PDA装车请求标识', undefined, { code: 'A0403', message: '该请求标识不属于当前操作员' })
}
2026-10-20 10:35:52 +08:00
proof.loadingCommand.afterReplay = await snapshot('装车原键验证后回读')
check(context, module, '原键重放及操作者拒绝后主单货物装车事实不变',
JSON.stringify(proof.loadingCommand.beforeReplay) === JSON.stringify(proof.loadingCommand.afterReplay)
&& version(proof.loadingCommand.afterReplay.main.recordVersion) === version(confirmed.recordVersion))
proof.loadingCommand.platformPositiveLoading = pdaToken ? 'PENDING_SEPARATE_SCENARIO' : 'VERIFIED'
proof.recordVersion = confirmed.recordVersion; await context.checkpoint?.(); return proof.recordVersion
}
2026-10-20 10:35:52 +08:00
/** 货物结案与车辆司机结束分别执行;不再创建退役Fleet空计划作为探针。 */
export async function finishFleetTrip(context, module, proof) {
if (!proof?.tripNo || !proof.vehicleId || !proof.driverId || !proof.warehouseId) throw new Error('缺少本轮车次与资源证明')
2026-10-20 10:35:52 +08:00
if (proof.closure) throw new Error('本轮车次结案检查已开始;保留原请求排查,不自动重造')
const state = proof.closure = { status: 'CHECKING', occupancy: [] }, path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}`
const inspection = await command(context, module, 'GET', `${path}/completion`, undefined, '核对本车货物结案条件')
check(context, module, '全部货物具有本车结案依据', inspection.canComplete === true && inspection.outcomes?.length > 0, inspection.blockReason || '')
state.completeRequest = { recordVersion: inspection.recordVersion, idempotencyKey: `${proof.tripNo}:COMPLETE`, reason: '本轮实际签收及退回终态核对' }
2026-10-20 10:35:52 +08:00
const completed = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '正式结案货物')
check(context, module, '结案未自动结束车辆或司机', completed.trip?.status === 'COMPLETED' && Boolean(completed.trip.completedAt) && completed.trip.vehicleRelease == null && completed.trip.driverRelease == null)
const replay = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '原键重放结案')
check(context, module, '结案重放不增加版本或改变完成时间', version(replay.trip.recordVersion) === version(completed.trip.recordVersion) && replay.trip.completedAt === completed.trip.completedAt)
async function occupancy(kind, expectedOwner) {
const resourceId = kind === 'VEHICLE' ? proof.vehicleId : proof.driverId
const rows = await command(context, module, 'GET', `${path}/resource-occupancy?${new URLSearchParams({ kind, resourceIds: resourceId })}`, undefined, `读取${kind}独立占用`)
state.occupancy.push({ kind, rows })
// available对本车为true,必须核对occupiedTripNo的真实拥有者。
check(context, module, `${kind}占用拥有者符合独立结束阶段`, rows.length === 1 && id(rows[0].resourceId) === id(resourceId) && (rows[0].occupiedTripNo ?? null) === expectedOwner)
}
2026-10-20 10:35:52 +08:00
await occupancy('VEHICLE', proof.tripNo); await occupancy('DRIVER', proof.tripNo)
const endLocation = proof.stops?.at(-1)?.name
2026-10-20 10:35:52 +08:00
if (!endLocation) throw new Error('TEST运输缺少明确末站结束地点')
state.vehicleReleaseRequest = { recordVersion: completed.trip.recordVersion, resourceKind: 'VEHICLE', actualEndAt: completed.trip.completedAt,
endLocation, vehicleStatus: 'IN_SERVICE', idempotencyKey: `${proof.tripNo}:RELEASE-VEHICLE`, reason: 'TEST车辆末站任务独立结束,无返程安排' }
const vehicleReleased = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '独立结束TEST车辆任务')
check(context, module, '只结束车辆仍保留司机任务', Boolean(vehicleReleased.trip?.vehicleRelease) && vehicleReleased.trip.driverRelease == null)
await occupancy('VEHICLE', null); await occupancy('DRIVER', proof.tripNo)
state.driverReleaseRequest = { recordVersion: vehicleReleased.trip.recordVersion, resourceKind: 'DRIVER', actualEndAt: completed.trip.completedAt,
endLocation, vehicleStatus: null, idempotencyKey: `${proof.tripNo}:RELEASE-DRIVER`, reason: 'TEST司机末站任务独立结束,无返程安排' }
const released = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '独立结束TEST司机任务')
check(context, module, '两项资源各自保留结束记录', Boolean(released.trip?.vehicleRelease && released.trip.driverRelease))
await occupancy('VEHICLE', null); await occupancy('DRIVER', null)
// 正式空排车是新资源拥有者;不恢复已退役的独立Fleet建单或手工关联。
state.nextDispatchRequest = { idempotencyKey: key(proof, 'NEXT_NATIVE_DISPATCH'), warehouseId: proof.warehouseId,
vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId },
driver: { mode: 'MASTER', driverId: proof.driverId, evidence: { expectedTaskEndAt: new Date(Date.now() + 48 * 3600000).toISOString() } },
plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: null,
remark: `${context.marker} TEST本轮空排车验证旧结束键不能释放新占用`, cargo: [], batchAllocations: [] }
state.nextDispatchResult = await command(context, module, 'POST', DISPATCH, state.nextDispatchRequest, '建立本轮原生空排车占用已结束资源')
if (!state.nextDispatchResult.dispatchNo || state.nextDispatchResult.dispatchNo === proof.dispatchNo) throw new Error('原生空排车未返回独立真实业务号')
state.nextDispatch = { dispatchNo: state.nextDispatchResult.dispatchNo, warehouseId: proof.warehouseId,
2026-10-22 11:56:47 +08:00
marker: proof.marker, vehicleId: proof.vehicleId, driverId: proof.driverId, vehicle: proof.vehicle }
2026-10-20 10:35:52 +08:00
await discoverTrip(context, module, state.nextDispatch)
if (state.nextDispatch.tripNo === proof.tripNo) throw new Error('新空排车未建立独立资源记录')
await occupancy('VEHICLE', state.nextDispatch.tripNo); await occupancy('DRIVER', state.nextDispatch.tripNo)
const oldVehicle = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '原键重放车辆结束')
const oldDriver = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '原键重放司机结束')
check(context, module, '独立结束重放不增加版本', version(oldVehicle.trip.recordVersion) === version(released.trip.recordVersion) && version(oldDriver.trip.recordVersion) === version(released.trip.recordVersion))
await occupancy('VEHICLE', state.nextDispatch.tripNo); await occupancy('DRIVER', state.nextDispatch.tripNo)
const nextPath = `${DISPATCH}/${encodeURIComponent(state.nextDispatch.dispatchNo)}`
state.nextDispatchBeforeCancel = await command(context, module, 'GET', nextPath, undefined, '取消前核齐本轮新空排车当前版本和零实物')
state.nextDispatchItems = await command(context, module, 'GET', `${nextPath}/items`, undefined, '取消前核齐本轮新空排车零货物来源')
const next = state.nextDispatchBeforeCancel
check(context, module, '仅取消本轮已确定且从未执行的原生空单', next.dispatchNo === state.nextDispatch.dispatchNo
&& next.status === 'ACTIVE' && next.stage === 'BEFORE_LOADING' && safePackageCount(next.counts?.itemCount) === 0
&& safePackageCount(next.counts?.packageCount) === 0 && safePackageCount(next.counts?.pickedPackageCount) === 0 && safePackageCount(next.counts?.shippedPackageCount) === 0
&& version(next.recordVersion) === version(state.nextDispatchResult.recordVersion)
&& state.nextDispatchItems.dispatchNo === next.dispatchNo && state.nextDispatchItems.items?.length === 0
&& version(state.nextDispatchItems.recordVersion) === version(next.recordVersion))
state.nextDispatchCancelRequest = { recordVersion: next.recordVersion,
idempotencyKey: key(proof, 'CANCEL_NEXT_NATIVE_DISPATCH'), reason: 'TEST原生空排车占用核对完成,保留取消历史' }
const cancelled = await command(context, module, 'POST', `${nextPath}/cancel`,
state.nextDispatchCancelRequest, '正式取消本轮未执行空排车')
check(context, module, '空排车取消并保留历史', cancelled.dispatchNo === state.nextDispatch.dispatchNo && cancelled.status === 'CANCELLED')
state.nextDispatch.finalStatus = cancelled.status
await occupancy('VEHICLE', null); await occupancy('DRIVER', null)
state.finalTrip = (await command(context, module, 'GET', path, undefined, '回查结案及资源结束记录')).trip
check(context, module, '最终结案和资源结束保持一致', state.finalTrip?.status === 'COMPLETED' && Boolean(state.finalTrip.vehicleRelease && state.finalTrip.driverRelease))
state.subsequentTripClaimProtection = 'VERIFIED_NATIVE_EMPTY_DISPATCH'
state.status = 'CARGO_COMPLETED_AND_RESOURCES_RELEASED'; await context.checkpoint?.(); return state
}