Files
baiyue-firstleg/scripts/prod-release-overseas-stage.mjs
T

465 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFile } from 'node:fs/promises'
import { isAbsolute } from 'node:path'
import { createHash } from 'node:crypto'
import { fingerprint } from './prod-release-order-stage.mjs'
import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
const BASE = '/api/platform/v1/last-leg'
const PDA = '/api/pda/v1/last-leg'
const check = (ok, message) => { if (!ok) throw new Error(message) }
const id = value => { check(typeof value !== 'number' || Number.isSafeInteger(value), '业务ID精度无效'); return value == null ? '' : String(value) }
const version = value => { check(/^(0|[1-9]\d*)$/.test(String(value)) && (typeof value !== 'number' || Number.isSafeInteger(value)), '原生排车recordVersion缺失或精度无效'); return value }
const DISPATCH_PROTOCOL = 'NATIVE_DISPATCH_AUTO_DEPART_V2'
const query = (path, values) => `${path}?${new URLSearchParams(values)}`
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
/** Each successful command is persisted; an interrupted command must be investigated, never resent. */
export function overseasCommands(context, checkpoint) {
const { state, save } = checkpoint
const unwrap = response => {
check(response?.http === 200 && String(response?.body?.code) === '200',
`海外 HTTP=${response?.http}; code=${response?.body?.code}; requestId=${response?.body?.requestId}; traceId=${response?.body?.traceId}`)
return response.body.data
}
async function command(name, method, path, body, token = context.token, headers = {}, file = null) {
const request = { method, path, body: body ?? null, headers, file }
const binding = fingerprint(request)
const previous = state.commands?.[name]
check(!previous || previous.binding === binding, `海外命令 ${name} 与检查点不符`)
if (previous?.status === 'DONE') return previous.result
check(!previous, `海外命令 ${name} 结果未知;停止重发并保留检查点`)
const persist = async value => { const commands = { ...state.commands, [name]: value }; await save({ commands }); state.commands = commands }
await persist({ binding, request, status: 'INTENT' })
let response
if (file) {
const form = new FormData()
form.append('file', new Blob([PNG], { type: 'image/png' }), file)
response = await context.requestWithBody(method, path, form, token)
} else response = await context.request(method, path, body, token, headers)
const result = unwrap(response)
await persist({ binding, request, status: 'DONE', result })
return result
}
async function recorded(name, method, path, createBody, token = context.token, headers = {}) {
const previous = state.commands?.[name]
if (previous) {
check(previous.status === 'DONE', `海外命令 ${name} 结果未知;停止重发并保留检查点`)
check(previous.request && previous.binding === fingerprint(previous.request)
&& previous.request.method === method && previous.request.path === path
&& fingerprint(previous.request.headers) === fingerprint(headers) && previous.request.file === null,
`海外命令 ${name} 缺少原生原请求或检查点不符`)
return command(name, method, path, previous.request.body, token, headers)
}
return command(name, method, path, await createBody(), token, headers)
}
return { command, recorded, read: async (path, token = context.token) => unwrap(await context.request('GET', path, undefined, token)),
readCurrentReceivingSession: async token => {
const response = await context.request('GET', `${PDA}/receiving-containers/current`, undefined, token)
// Only the initial current-session probe treats the service's absent-session business code as empty.
// Subsequent mandatory session reads and every other resource remain strict.
if (response?.http === 200 && String(response?.body?.code) === 'A0404') return null
return unwrap(response)
},
lookup: async (path, body) => unwrap(await context.request('POST', path, body)) }
}
/** Explicit, test-only alternative; never manufactures actual:true evidence. */
export function validateSyntheticLoadingInput(input) {
check(input.syntheticLoadingMeasurements == null, '当前装车接口不接受客户端测量数值;合成验收使用syntheticLoading=true')
if (input.syntheticLoading == null || input.syntheticLoading === false) return
check(input.syntheticLoading === true && input.environment?.mode === 'gcp-test' && input.syntheticOnly === true,
'合成装车仅允许显式gcp-test且syntheticLoading=true、syntheticOnly=true')
check(input.loadingMeasurementFile == null && input.loadingMeasurements == null, '合成装车不能同时提供真实测量文件或内联测量')
}
export function validateOverseasInput(input, domestic) {
validateSyntheticLoadingInput(input)
check(/^[A-Za-z0-9][A-Za-z0-9_-]{7,47}$/.test(input.batch || ''), '无效验收批次')
check([1, 2].includes(input.packageCount), '海外仅支持1~2箱单票')
for (const key of ['customerId', 'warehouseId']) check(/^[1-9]\d*$/.test(input[key] || ''), `缺少 ${key}`)
for (const key of ['vehicleId', 'driverId']) check(/^[1-9]\d*$/.test(input.resources?.[key] || ''), `缺少既有 ${key}`)
check(Boolean(input.resources.carrierName), '缺少既有车队名称')
check(!(input.loadingMeasurementFile != null && input.loadingMeasurements != null), '真实测量文件与内联测量不能同时提供')
if (input.loadingMeasurementFile != null) check(typeof input.loadingMeasurementFile === 'string' && isAbsolute(input.loadingMeasurementFile), '真实测量文件须为预先绑定的明确绝对路径')
if (input.loadingMeasurementFile != null || input.loadingMeasurements != null) check(['test', 'gray', 'gcp-test'].includes(input.environment?.mode), '真实测量缺少本次TEST、GRAY或GCP-TEST环境绑定')
for (const key of ['containerBookingId', 'sourceInboundId', 'orderId']) check(/^[1-9]\d*$/.test(domestic[key] || ''), `缺少 ${key}`)
for (const key of ['containerNo', 'inboundCode', 'orderNo']) check(Boolean(domestic[key]), `缺少 ${key}`)
if (domestic.cartonMarks != null) check(Array.isArray(domestic.cartonMarks)
&& domestic.cartonMarks.length === input.packageCount && new Set(domestic.cartonMarks).size === input.packageCount,
'本票冻结箱码数量不符')
}
/** Normal single-ticket path only. No fixture runners, account creation, or master-data mutation. */
export async function runOverseasStage({ input, domestic, context, checkpoint }) {
validateOverseasInput(input, domestic)
check(context.overseasPdaToken && context.merchantToken, '缺少既有海外 PDA 或本客户会话')
const { state, save } = checkpoint
check(!Object.values(state.commands || {}).some(row => row.status !== 'DONE'), '海外命令结果未知;停止重发并保留检查点')
const loadingModeBinding = fingerprint({ synthetic: input.syntheticLoading === true, syntheticOnly: input.syntheticOnly === true })
check(!state.loadingModeBinding || state.loadingModeBinding === loadingModeBinding, '装车模式或合成值与冻结检查点不符')
check(!input.syntheticLoading || !state.loadingMeasurementEvidence || state.loadingMeasurementEvidence.source === 'SYNTHETIC_TEST', '不能将既有真实测量检查点改为合成装车')
if (!state.loadingModeBinding) { await save({ loadingModeBinding }); state.loadingModeBinding = loadingModeBinding }
check(!state.dispatchProtocol || state.dispatchProtocol === DISPATCH_PROTOCOL, '海外排车检查点协议不符')
const oldDispatchCommands = ['dispatch', 'dispatch-vehicle', 'transfer', 'trip', 'trip-assign', 'trip-bind', 'trip-plan', 'trip-review', 'pickComplete', 'loading', 'loading-review', 'depart']
check(state.dispatchProtocol === DISPATCH_PROTOCOL || !oldDispatchCommands.some(name => state.commands?.[name])
&& !Object.keys(state.commands || {}).some(name => name.startsWith('pick-')), '旧排车协议检查点不能映射原生主单,请人工核对原业务')
if (!state.dispatchProtocol) { await save({ dispatchProtocol: DISPATCH_PROTOCOL }); state.dispatchProtocol = DISPATCH_PROTOCOL }
const { read, lookup, command, recorded, readCurrentReceivingSession } = overseasCommands(context, checkpoint)
const pdaToken = context.overseasPdaToken
const syntheticLoading = input.syntheticLoading === true
const remark = `RELEASE-ACCEPTANCE:${input.batch}${syntheticLoading ? ':SYNTHETIC_TEST:actual=false' : ''}`
const key = name => `${input.batch}-${name}`
if (!state.schedule) {
const schedule = { plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(),
expectedDriverEndAt: new Date(Date.now() + 48 * 3600000).toISOString(),
arrival: new Date(Date.now() + 86400000).toISOString().slice(0, 19) }
await save({ schedule }); state.schedule = schedule
}
const schedule = state.schedule
const vehicle = await read(`${BASE}/master-data/vehicles/${input.resources.vehicleId}`)
const driver = await read(query(`${BASE}/fleet/drivers/${input.resources.driverId}`, { expectedEnd: schedule.expectedDriverEndAt }))
check(id(vehicle.id) === input.resources.vehicleId && vehicle.region === 'OVERSEAS' && vehicle.status === 'IN_SERVICE', '既有海外车辆不可用')
check(id(driver.id) === input.resources.driverId && (state.commands?.dispatch || driver.qualification?.canDispatch === true), '既有司机证照/审核不满足本次出车')
let sources
for (let attempt = 0; attempt < 60; attempt++) {
const page = await read(query(`${BASE}/orders`, { containerNo: domestic.containerNo, page: '1', size: '200' }))
sources = (page.records || []).filter(row => id(row.sourceContainerBookingId) === domestic.containerBookingId)
if (sources.length) break
if (attempt < 59) await (context.wait || (ms => new Promise(resolve => setTimeout(resolve, ms))))(1000)
}
check(sources?.length === 1 && id(sources[0].customerId) === input.customerId
&& id(sources[0].sourceInboundId) === domestic.sourceInboundId && sources[0].inboundCode === domestic.inboundCode
&& id(sources[0].overseasWarehouseId) === input.warehouseId, '柜内尾程来源不属于本次唯一客户/单票/接柜仓')
const tailOrderId = id(sources[0].id)
const arrivalRows = await lookup(`${BASE}/orders/container-arrival-times/query`, { sourceContainerBookingIds: [domestic.containerBookingId] })
const arrival = arrivalRows.find(row => id(row.sourceContainerBookingId) === domestic.containerBookingId)
check(arrival && Number.isSafeInteger(Number(arrival.version)), '柜级到仓版本缺失')
if (!arrival.estimatedWarehouseArrivalAt) await command('arrival', 'PUT', `${BASE}/orders/container-summaries/${domestic.containerBookingId}/expected-arrival`,
{ estimatedWarehouseArrivalAt: schedule.arrival, expectedVersion: Number(arrival.version), remark }, undefined, { 'Idempotency-Key': key('arrival') })
let plans = state.commands?.convert?.result
if (!plans) {
const preview = await read(query(`${BASE}/inbound-plans/container-preview`, { sourceContainerBookingId: domestic.containerBookingId }))
check(preview.ready === true && Number(preview.totalOrderCount) === 1 && Number(preview.pendingOrderCount) === 1, '本票计划不可转换或存在未登记计划')
plans = await command('convert', 'POST', `${BASE}/inbound-plans/convert-container`, {
sourceContainerBookingId: domestic.containerBookingId, snapshotVersion: Number(preview.snapshotVersion), snapshotHash: preview.snapshotHash,
}, undefined, { 'Idempotency-Key': key('convert') })
}
check(plans?.length === 1 && plans[0].lines?.length > 0 && plans[0].lines.every(line => line.inboundCode === domestic.inboundCode)
&& plans[0].pallets?.length === 1, '本票入仓计划/实体托盘不唯一')
const pallet = plans[0].pallets[0]
check(pallet.palletNo && pallet.warehousePalletId, '计划未生成实体托盘')
const receiving = `${PDA}/receiving-containers`
if (!state.commands?.receiveComplete) {
const current = await readCurrentReceivingSession(pdaToken)
check(!current || current.containerNo === domestic.containerNo && current.inbounds?.length === 1
&& current.inbounds[0].inboundCode === domestic.inboundCode, '海外 PDA 正在操作其他柜')
const detailPath = query(`${receiving}/${encodeURIComponent(domestic.containerNo)}`, { sourceContainerBookingId: domestic.containerBookingId })
const detail = await read(detailPath, pdaToken)
check(id(detail.overseasWarehouseId) === input.warehouseId && detail.inbounds?.length === 1
&& detail.inbounds[0].inboundCode === domestic.inboundCode && Number(detail.totalPackages) === input.packageCount, '收货柜身份或箱数不符')
const destinationType = detail.inbounds[0].destinationType
check(['OVERSEAS', 'FBA'].includes(destinationType), '单票不支持此收货类型')
if (destinationType === 'OVERSEAS') check(domestic.cartonMarks?.length === input.packageCount,
'OVERSEAS逐箱收货必须提供本票正式冻结箱码')
await command('prepare', 'POST', query(`${receiving}/${encodeURIComponent(domestic.containerNo)}/prepare`, { sourceContainerBookingId: domestic.containerBookingId }), { idempotencyKey: key('prepare') }, pdaToken)
const photos = []
for (const role of ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']) {
const uploaded = await command(`photo-${role}`, 'POST', `${receiving}/current/unload-photos/files`, undefined, pdaToken, {}, `${input.batch}-SYNTHETIC-${role}.png`)
check(/^[1-9]\d*$/.test(id(uploaded.fileId)), '验收图片未返回文件ID')
photos.push({ role, fileId: id(uploaded.fileId) })
}
await command('sign', 'POST', `${receiving}/current/sign`, { photos, idempotencyKey: key('sign') }, pdaToken)
const resolved = await command('resolve-pallet', 'POST', `${receiving}/current/pallets/resolve`, { palletCode: pallet.palletNo, inboundCode: domestic.inboundCode }, pdaToken)
check(resolved.compatible === true, '计划托盘不兼容')
// FBA receives cumulative physical counts on its source pallet; carton resolution is not a receiving prerequisite.
for (const [index, cartonMark] of (destinationType === 'OVERSEAS' ? domestic.cartonMarks : []).entries()) {
const carton = await command(`resolve-carton-${index}`, 'POST', `${receiving}/current/cartons/resolve`, { cartonMark }, pdaToken)
check(carton.inboundCode === domestic.inboundCode, '箱码未归属本票')
await command(`receive-${index}`, 'POST', `${receiving}/current/receipts/carton`, { cartonMark, palletCode: pallet.palletNo, idempotencyKey: key(`receive-${index}`) }, pdaToken)
}
if (destinationType === 'FBA') await command('receive-bulk', 'POST', `${receiving}/current/receipts/bulk`, {
inboundCode: domestic.inboundCode, palletCode: pallet.palletNo, packageCount: input.packageCount, idempotencyKey: key('receive-bulk'),
}, pdaToken)
const received = await read(`${receiving}/current`, pdaToken)
check(Number(received.pendingPackages) === 0 && Number(received.totalPackages) === input.packageCount, '本票收货数量未核平')
await command('receiveComplete', 'POST', `${receiving}/current/complete`, { idempotencyKey: key('receive-complete') }, pdaToken)
}
if (!state.commands?.emptyReturn) {
const drayagePath = `/api/platform/v1/terminal-drayages/${domestic.containerBookingId}`
let drayage
for (let attempt = 0; attempt < 60; attempt++) {
drayage = await read(drayagePath)
if (drayage.status === 'UNLOADED' && drayage.unloadingCompletedAt) break
if (attempt < 59) await (context.wait || (ms => new Promise(resolve => setTimeout(resolve, ms))))(1000)
}
check(id(drayage.containerBookingId) === domestic.containerBookingId && drayage.status === 'UNLOADED'
&& drayage.allowedActions?.includes('RECORD_EMPTY_RETURN'), '海外卸柜事实尚未回传国内码头拖车')
const unloaded = Date.parse(`${drayage.unloadingCompletedAt}Z`)
check(Number.isFinite(unloaded), '卸柜时间无效')
const returned = await command('emptyReturn', 'POST', `${drayagePath}/empty-return`, {
expectedVersion: Number(drayage.version), actualAt: new Date(unloaded + 3600000).toISOString().slice(0, 19), remark,
}, undefined, { 'Idempotency-Key': key('empty-return') })
check(returned.status === 'EMPTY_RETURNED', '实际还柜未完成')
}
const dispatch = await recorded('dispatch', 'POST', `${BASE}/dispatches`, async () => {
const candidates = await read(query(`${BASE}/dispatches/candidates`, { warehouseId: input.warehouseId, orderNo: domestic.inboundCode }))
check(candidates.complete === true && Array.isArray(candidates.candidates), '本票原生排车候选不完整')
const matches = candidates.candidates.filter(row => row.inboundCode === domestic.inboundCode && id(row.customerId) === input.customerId
&& id(row.warehouseId) === input.warehouseId && row.palletNo === pallet.palletNo)
check(matches.length === 1 && candidates.candidates.length === 1 && Number(matches[0].availablePackages) === input.packageCount
&& matches[0].selection?.sourceType === 'INBOUND_PLAN' && matches[0].selection.planPalletId
&& matches[0].selection.sourceVersion && matches[0].selection.sourceVersion === matches[0].sourceVersion
&& matches[0].blockReasons?.length === 0, '本票原生排车候选身份、来源版本或数量不符')
return { idempotencyKey: key('dispatch'), warehouseId: input.warehouseId,
vehicle: { mode: 'MASTER', vehicleId: input.resources.vehicleId },
driver: { mode: 'MASTER', driverId: input.resources.driverId, evidence: { expectedTaskEndAt: schedule.expectedDriverEndAt } },
plannedDepartureAt: schedule.plannedDepartureAt, estimatedArrivalAt: new Date(Date.parse(schedule.plannedDepartureAt) + 86400000).toISOString(),
remark, cargo: matches.map(row => row.selection), batchAllocations: [] }
})
const dispatchNo = dispatch.dispatchNo
check(dispatchNo && dispatch.status === 'ACTIVE' && dispatch.stage === 'BEFORE_LOADING', '本票原生建单回执不符')
const dispatchPath = `${BASE}/dispatches/${encodeURIComponent(dispatchNo)}`, pdaDispatch = `${PDA}/dispatches/${encodeURIComponent(dispatchNo)}`
const ownMain = async () => {
const detail = await read(dispatchPath), items = await read(`${dispatchPath}/items`)
check(detail.dispatchNo === dispatchNo && detail.status === 'ACTIVE' && detail.orderDetails?.length > 0
&& detail.orderDetails.every(row => row.inboundCode === domestic.inboundCode && id(row.customerId) === input.customerId)
&& id(detail.vehicleInfo?.vehicle?.vehicleId) === input.resources.vehicleId && id(detail.vehicleInfo?.driver?.driverId) === input.resources.driverId
&& items.dispatchNo === dispatchNo && items.items?.length > 0 && items.items.every(row => row.inboundCode === domestic.inboundCode
&& id(row.customerId) === input.customerId && row.palletNo === pallet.palletNo)
&& Number(detail.counts?.packageCount) === input.packageCount, '原生主单含其他票、客户、资源或箱数不符')
version(detail.recordVersion); return { detail, items: items.items }
}
await ownMain()
const vehiclePhoto = await command('vehicle-photo', 'POST', `${dispatchPath}/evidence/VEHICLE`, null, context.token, {}, `${input.batch}-SYNTHETIC_TEST-vehicle.png`)
const driverPhoto = await command('driver-photo', 'POST', `${dispatchPath}/evidence/DRIVER_PHOTO`, null, context.token, {}, `${input.batch}-SYNTHETIC_TEST-driver.png`)
check(/^[1-9]\d*$/.test(id(vehiclePhoto.fileId)) && /^[1-9]\d*$/.test(id(driverPhoto.fileId)), '本车合成资源照片上传回执缺少文件身份')
await recorded('resource-evidence', 'PUT', dispatchPath, async () => {
const { detail } = await ownMain()
check(detail.stage === 'BEFORE_LOADING', '资源照片绑定必须在送审前')
return { recordVersion: version(detail.recordVersion), idempotencyKey: key('resource-evidence'),
vehicle: { mode: 'MASTER', vehicleId: input.resources.vehicleId, evidenceFileIds: [id(vehiclePhoto.fileId)] },
driver: { mode: 'MASTER', driverId: input.resources.driverId,
evidence: { expectedTaskEndAt: schedule.expectedDriverEndAt, photoFileIds: [id(driverPhoto.fileId)] } },
plannedDepartureAt: schedule.plannedDepartureAt, estimatedArrivalAt: new Date(Date.parse(schedule.plannedDepartureAt) + 86400000).toISOString(),
remark: `${remark}:SYNTHETIC_TEST资源照片,非真实资质` }
})
const reviewDetail = (await ownMain()).detail
check(Array.isArray(reviewDetail.dispatchInfo?.fbaAppointments), '本车目的仓预约预览缺失')
const missingAppointments = reviewDetail.dispatchInfo.fbaAppointments.filter(row => !row.appointmentNo?.trim())
if (state.commands?.['destination-appointment'] || missingAppointments.length) {
await recorded('destination-appointment', 'PUT', dispatchPath, async () => {
check(syntheticLoading && input.syntheticOnly === true && input.environment?.mode === 'gcp-test',
'本车目的仓缺少正式预约号;仅显式GCP合成验收可登记合成预约')
const { detail } = await ownMain(), appointments = detail.dispatchInfo?.fbaAppointments
check(detail.stage === 'BEFORE_LOADING' && appointments?.length === 1 && input.destination?.type === 'FBA'
&& /^[A-Z0-9-]{1,32}$/.test(input.destination.code || '')
&& appointments[0].destinationKey === `FBA:${input.destination.code}`
&& appointments[0].destinationWarehouseCode === input.destination.code && !appointments[0].appointmentNo?.trim(),
'合成预约须绑定本票唯一正式FBA目的仓,不能猜测或覆盖已有预约')
const appointmentNo = `SYNTHETIC_TEST-${input.batch}-${input.destination.code}`
const destinationAppointments = [{ destinationKey: appointments[0].destinationKey, appointmentNo }]
const vehicle = detail.vehicleInfo.vehicle, driver = detail.vehicleInfo.driver
check(vehicle.mode === 'MASTER' && driver.mode === 'MASTER' && vehicle.evidenceFileIds?.length
&& driver.evidence?.photoFileIds?.length && detail.dispatchInfo.plannedDepartureAt && detail.dispatchInfo.estimatedArrivalAt,
'预约维护缺少本车已核验资源照片或运输时间')
const destinationAppointmentEvidence = { source: 'SYNTHETIC_TEST', actual: false, batch: input.batch, dispatchNo, destinationAppointments }
await save({ destinationAppointmentEvidence }); state.destinationAppointmentEvidence = destinationAppointmentEvidence
return { recordVersion: version(detail.recordVersion), idempotencyKey: key('destination-appointment'),
vehicle: { mode: vehicle.mode, vehicleId: id(vehicle.vehicleId), plate: vehicle.plate, carrierName: vehicle.carrierName,
capacity: vehicle.capacity, evidenceFileIds: vehicle.evidenceFileIds },
driver: { mode: driver.mode, driverId: id(driver.driverId), name: driver.name, phone: driver.phone, evidence: driver.evidence },
plannedDepartureAt: detail.dispatchInfo.plannedDepartureAt, estimatedArrivalAt: detail.dispatchInfo.estimatedArrivalAt,
maintenance: { ...detail.vehicleInfo.maintenance, destinationAppointments },
remark: `${remark}:SYNTHETIC_TEST预约号,仅测试,不代表第三方确认` }
})
const current = (await ownMain()).detail.dispatchInfo.fbaAppointments
const expected = state.commands['destination-appointment'].request.body.maintenance.destinationAppointments
check(current?.length === expected.length && expected.every(row => current.some(saved =>
saved.destinationKey === row.destinationKey && saved.appointmentNo === row.appointmentNo)), '本车合成预约保存后回查不符')
}
for (const action of ['submit-review', 'dispatch-review', 'loading-review']) await recorded(action, 'POST', `${dispatchPath}/${action}`, async () => {
const { detail } = await ownMain()
check(detail.stage === 'BEFORE_LOADING', '本票原生审核阶段不符')
return { recordVersion: detail.recordVersion, idempotencyKey: key(action === 'submit-review' ? 'submit-review-with-appointment' : action),
...(action === 'submit-review' ? { remark } : { decision: 'APPROVED', reason: remark }) }
})
const candidates = []; let total
for (let page = 1; ; page++) {
const rows = await read(query(`${BASE}/fleet/trips`, { warehouseId: input.warehouseId, keyword: vehicle.plateNumber, page: String(page), size: '200' }))
check(Array.isArray(rows.records) && Number.isSafeInteger(Number(rows.total)) && Number(rows.total) >= 0
&& Number(rows.page) === page && Number(rows.size) === 200, '自动车次分页缺失或不完整')
total ??= Number(rows.total)
check(Number(rows.total) === total && (rows.records.length > 0 || candidates.length === total), '自动车次分页变化或缺页')
for (const row of rows.records) { check(row.tripNo && !candidates.some(saved => saved.tripNo === row.tripNo), '自动车次业务号缺失或重复'); candidates.push(row) }
if (candidates.length >= total) break
}
check(candidates.length === total, '自动车次分页总数不一致')
const bound = []
for (const row of candidates.filter(row => id(row.vehicleId) === input.resources.vehicleId && id(row.driverId) === input.resources.driverId
&& id(row.warehouseId) === input.warehouseId && row.vehiclePlate === vehicle.plateNumber && row.tripPurpose === 'DELIVERY')) {
const detail = await read(`${BASE}/fleet/trips/${encodeURIComponent(row.tripNo)}`)
if (detail.dispatches?.some(binding => binding.dispatchNo === dispatchNo)) {
check(detail.trip?.tripNo === row.tripNo && id(detail.trip.vehicleId) === input.resources.vehicleId
&& id(detail.trip.driverId) === input.resources.driverId && detail.dispatches.length === 1, '自动车次绑定混入其他排车或资源')
bound.push(detail)
}
}
check(bound.length === 1, '本票主单自动Fleet资源记录不唯一')
const trip = bound[0], tripNo = trip.trip.tripNo
if (!state.pickingVerified) {
const { detail, items } = await ownMain()
const picking = await read(pdaDispatch, pdaToken)
check(detail.stage === 'LOADING' && picking.dispatchNo === dispatchNo && picking.status === 'ACTIVE' && picking.stage === 'LOADING'
&& id(picking.warehouseId) === input.warehouseId && picking.items?.length === items.length
&& picking.items.every(row => items.some(item => id(item.dispatchItemId) === id(row.dispatchItemId)
&& id(row.customerId) === input.customerId && row.palletNo === pallet.palletNo)), 'PDA拣货包含其他票或尚未通过两级审核')
if (!state.pickSources) {
const pickSources = picking.items.flatMap(item => (item.positions || []).map(position => ({ dispatchItemId: id(item.dispatchItemId), positionId: id(position.positionId) })))
check(pickSources.length > 0 && pickSources.every(row => /^[1-9]\d*$/.test(row.positionId)), '本票真实库存位置缺失')
await save({ pickSources }); state.pickSources = pickSources
}
for (const source of state.pickSources) {
const name = `pick-${source.dispatchItemId}-${source.positionId}`
if (state.commands?.[name]) {
await recorded(name, 'POST', `${pdaDispatch}/items/${source.dispatchItemId}/pick`, () => { throw new Error('成功扫码不得重新构造原请求') }, pdaToken)
continue
}
const current = await read(pdaDispatch, pdaToken), item = current.items?.find(row => id(row.dispatchItemId) === source.dispatchItemId)
check(current.status === 'ACTIVE' && current.stage === 'LOADING' && item && id(item.customerId) === input.customerId, '扫码前本票任务身份变化')
const remaining = Number(item.scheduledPackages) - Number(item.pickedPackages)
if (remaining === 0) continue
const position = item.positions?.find(row => id(row.positionId) === source.positionId)
const packages = Math.min(remaining, Number(position?.availablePackages))
check(Number.isSafeInteger(packages) && packages > 0, '本票库存不足以完成拣货')
await recorded(name, 'POST', `${pdaDispatch}/items/${source.dispatchItemId}/pick`, async () => ({ recordVersion: version(current.recordVersion),
positionId: source.positionId, cartonMark: null, packages, productQuantity: packages, idempotencyKey: key(name), remark }), pdaToken)
}
const reconciled = await read(pdaDispatch, pdaToken)
check(reconciled.status === 'ACTIVE' && reconciled.stage === 'LOADING' && reconciled.pickingReconciled === true
&& Number(reconciled.counts?.packageCount) === input.packageCount && Number(reconciled.counts?.pickedPackageCount) === input.packageCount
&& reconciled.items.every(item => Number(item.scheduledPackages) === Number(item.pickedPackages)), '本票真实扫码未核平或提前越过装车中')
await save({ pickingVerified: true }); state.pickingVerified = true
}
const loadingDocuments = `${pdaDispatch}/loading-documents`
const slots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT']
for (const slot of slots) {
const photo = await command(`loading-photo-${slot}`, 'POST', query(`${loadingDocuments}/${slot}/uploads`, { idempotencyKey: key(`photo-${slot}`) }),
null, pdaToken, {}, `${input.batch}-SYNTHETIC_TEST-${slot}.png`)
check(photo.slot === slot && /^[1-9]\d*$/.test(id(photo.fileId)), '装车合成照片回执槽位或身份不符')
await recorded(`loading-photo-bind-${slot}`, 'PUT', `${loadingDocuments}/${slot}`, async () => {
const documents = await read(loadingDocuments, pdaToken)
check(documents.dispatchNo === dispatchNo && documents.stage === 'LOADING' && documents.editable === true, '本车装车资料尚不可编辑')
return { recordVersion: String(version(documents.recordVersion)), idempotencyKey: key(`photo-bind-${slot}`), fileId: id(photo.fileId) }
}, pdaToken)
}
const documents = await read(loadingDocuments, pdaToken)
check(documents.dispatchNo === dispatchNo && documents.complete === true && slots.every(slot =>
documents.photos?.filter(photo => photo.slot === slot && id(photo.fileId)).length === 1), '本车五槽装车资料未核齐')
const loaded = await recorded('loading-complete', 'POST', `${pdaDispatch}/loading-complete`, async () => {
const preview = await read(`${pdaDispatch}/loading`, pdaToken)
check(preview.dispatchNo === dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'LOADING'
&& preview.pickingReconciled === true && preview.loadingComplete?.allowed === true && preview.manifestHash
&& preview.pallets?.length === 1 && id(preview.pallets[0].warehousePalletId) === id(pallet.warehousePalletId), '本票实际装载预览未就绪或包含其他托盘')
let actual = input.loadingMeasurements, sourceSha256 = null
if (input.loadingMeasurementFile != null) {
let bytes
try { bytes = await readFile(input.loadingMeasurementFile) }
catch { throw new Error('本票已就绪等待真实测量:请在预先绑定的loadingMeasurementFile绝对路径提供本轮JSON回执,保留同一输入与检查点续跑') }
sourceSha256 = createHash('sha256').update(bytes).digest('hex')
try { actual = JSON.parse(bytes.toString('utf8')) }
catch { throw new Error('本轮真实测量文件不是有效JSON,停止装车写入') }
} else if (actual != null) sourceSha256 = createHash('sha256').update(JSON.stringify(actual)).digest('hex')
if (actual != null) {
check(actual.environment === input.environment.mode.toUpperCase() && actual.batch === input.batch && actual.dispatchNo === dispatchNo
&& Array.isArray(actual.pallets) && actual.pallets.length === preview.pallets.length, '本轮实际测量回执环境、批次、排车或托盘绑定不符')
for (const measured of actual.pallets) {
check(preview.pallets.some(row => id(row.warehousePalletId) === id(measured.warehousePalletId)), '本轮实际测量包含其他托盘')
for (const field of ['palletSpaces', 'weightKg', 'volumeCbm']) check(measured[field] != null && measured.evidence?.some(evidence => evidence.actual === true
&& evidence.field === field && evidence.sourceType && evidence.sourceBusinessNo && Number.isFinite(Date.parse(evidence.observedAt))
&& Number(evidence.value) === Number(measured[field])), `本轮实际测量${field}缺少逐字段actual来源证据`)
}
}
const measurements = syntheticLoading ? null : controlledPalletMeasurements(preview.pallets, trip.trip.stops, actual?.pallets)
check(preview.pallets.every(row => Number.isSafeInteger(Number(row.scheduledPackages)) && Number(row.scheduledPackages) > 0
&& Number(row.pickedPackages) === Number(row.scheduledPackages) && Number.isSafeInteger(row.deliveryIndex)
&& row.deliveryIndex >= 1 && trip.trip.stops?.[row.deliveryIndex]?.destinationKey), '本票正式预览拣货或卸货路线不符')
const loadingMeasurementEvidence = { environment: input.environment?.mode?.toUpperCase() ?? null, batch: input.batch, dispatchNo,
source: syntheticLoading ? 'SYNTHETIC_TEST' : input.loadingMeasurementFile != null ? 'EXPLICIT_FILE' : actual != null ? 'INLINE' : 'NATIVE_PREVIEW',
...(syntheticLoading ? { actual: false, realMeasurementVerified: false, tripNo, stops: trip.trip.stops,
previewPallets: preview.pallets.map(row => ({ warehousePalletId: id(row.warehousePalletId), palletNo: row.palletNo, deliveryIndex: row.deliveryIndex })) } : {}),
file: input.loadingMeasurementFile ?? null, sourceSha256, receipt: actual ?? null, manifestHash: preview.manifestHash, measurements }
if (state.loadingMeasurementEvidence) check(fingerprint(state.loadingMeasurementEvidence) === fingerprint(loadingMeasurementEvidence),
'已冻结的本轮真实测量发生变化,禁止替换原证据或重造装车命令')
else { await save({ loadingMeasurementEvidence }); state.loadingMeasurementEvidence = loadingMeasurementEvidence }
return { recordVersion: version(preview.recordVersion), idempotencyKey: key('loading-complete'), manifestHash: preview.manifestHash,
remark: syntheticLoading ? `${remark}合成验收,仅验证测试HTTP流程,不提供实测数值` : `${remark}仅采用真实来源测量,不从计费或计划常量推算` }
}, pdaToken)
check(loaded.dispatchNo === dispatchNo && loaded.status === 'ACTIVE' && loaded.stage === 'DELIVERING'
&& loaded.result?.confirmedLoad?.pallets?.length === 1
&& id(loaded.result.confirmedLoad.pallets[0].warehousePalletId) === id(pallet.warehousePalletId), '本票装车自动发车回执不符')
if (!syntheticLoading) {
const confirmed = loaded.result.confirmedLoad.pallets[0], measured = state.loadingMeasurementEvidence.measurements[0]
check(['palletSpaces', 'weightKg', 'volumeCbm', 'deliveryIndex'].every(field => Number(confirmed[field]) === Number(measured[field])),
'服务端已确认装车与本轮实测核验不一致')
}
const { detail: dispatched } = await ownMain()
const documentsAfter = dispatched.documents
check(dispatched.stage === 'DELIVERING' && Number(dispatched.counts?.shippedPackageCount) === input.packageCount
&& documentsAfter?.dispatchNo === dispatchNo && documentsAfter.deliveries?.length === 1 && documentsAfter.outbounds?.length === 1
&& id(documentsAfter.deliveries[0].customerId) === input.customerId && id(documentsAfter.outbounds[0].customerId) === input.customerId
&& documentsAfter.deliveries[0].outboundNo === documentsAfter.outbounds[0].outboundNo
&& Number(documentsAfter.deliveries[0].packageCount) === input.packageCount, '本票自动派送出库身份或数量不符')
const outboundNo = documentsAfter.outbounds[0].outboundNo, deliveryNo = documentsAfter.deliveries[0].deliveryNo
const outbound = await read(query(`${BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId: input.customerId }))
check(outbound.outbound?.outboundNo === outboundNo && outbound.outbound.dispatchNo === dispatchNo
&& id(outbound.outbound.overseasWarehouseId) === input.warehouseId && outbound.deliveries?.length === 1
&& id(outbound.deliveries[0].customerId) === input.customerId && outbound.deliveries[0].deliveryNo === deliveryNo
&& outbound.outbound.status === 'OUTBOUNDED' && outbound.pallets?.length > 0
&& outbound.pallets.every(row => row.inboundCode === domestic.inboundCode) && Number(outbound.outbound.packageCount) === input.packageCount, '本票未真实完成出库')
const result = { ...domestic, batch: input.batch, packageCount: input.packageCount, warehouseId: input.warehouseId,
tailOrderId, dispatchNo, outboundNo, deliveryNo,
tripNo, status: 'OVERSEAS_OUTBOUNDED', fullFlowVerified: false, photos: 'SYNTHETIC_ACCEPTANCE_ONLY',
loadingMeasurementMode: syntheticLoading ? 'SYNTHETIC_TEST' : 'ACTUAL_EVIDENCE', realMeasurementVerified: !syntheticLoading }
await save({ result }); return result
}
/** Close only this ticket's trip after delivery; release its two resource claims without creating probe trips. */
export async function finishSingleOrderTrip({ input, overseas, context, checkpoint }) {
check(overseas?.tripNo && overseas?.dispatchNo && overseas?.deliveryNo, '缺少本票车次与派送身份')
check(!Object.values(checkpoint.state.commands || {}).some(row => row.status !== 'DONE'), '车次结案命令结果未知;停止重发')
const { read, command } = overseasCommands(context, checkpoint)
const path = `${BASE}/fleet/trips/${encodeURIComponent(overseas.tripNo)}`
const delivery = await read(query(`${BASE}/delivery-orders/${encodeURIComponent(overseas.deliveryNo)}`, { customerId: input.customerId }))
check(delivery.order?.status === 'DELIVERED' && id(delivery.order.customerId) === input.customerId
&& delivery.order.dispatchNo === overseas.dispatchNo, '本票尚未完成签收,不能结案车次')
const detail = await read(path)
check(detail.trip?.tripNo === overseas.tripNo && id(detail.trip.vehicleId) === input.resources.vehicleId
&& id(detail.trip.driverId) === input.resources.driverId && detail.dispatches?.length === 1
&& detail.dispatches[0].dispatchNo === overseas.dispatchNo, '车次含其他票或资源身份不符')
let completed = checkpoint.state.commands?.tripComplete?.result
if (!completed) {
const completion = await read(`${path}/completion`)
check(completion.canComplete === true && completion.outcomes?.length > 0, '车次结案条件未通过')
completed = await command('tripComplete', 'POST', `${path}/complete`, { recordVersion: completion.recordVersion,
idempotencyKey: `${input.batch}-trip-complete`, reason: '单票验收实际签收完成' })
}
check(completed.trip?.status === 'COMPLETED' && completed.trip.completedAt, '车次货物未结案')
const endLocation = detail.trip.stops?.at(-1)?.name
check(Boolean(endLocation), '本车次缺少结束地点')
const vehicle = await command('releaseVehicle', 'POST', `${path}/resources/release`, {
recordVersion: completed.trip.recordVersion, resourceKind: 'VEHICLE', actualEndAt: completed.trip.completedAt,
endLocation, vehicleStatus: 'IN_SERVICE', idempotencyKey: `${input.batch}-vehicle-release`,
reason: '单票受控验收车辆在签收末站结束,无返程安排',
})
const driver = await command('releaseDriver', 'POST', `${path}/resources/release`, {
recordVersion: vehicle.trip.recordVersion, resourceKind: 'DRIVER', actualEndAt: completed.trip.completedAt,
endLocation, vehicleStatus: null, idempotencyKey: `${input.batch}-driver-release`,
reason: '单票受控验收司机在签收末站结束,无返程安排',
})
check(driver.trip?.vehicleRelease && driver.trip?.driverRelease, '本车次车辆或司机未释放')
const final = await read(path)
check(final.trip?.status === 'COMPLETED' && final.trip.vehicleRelease && final.trip.driverRelease, '车次结案回查未通过')
const result = { tripNo: overseas.tripNo, status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' }
await checkpoint.save({ result }); return result
}