396 lines
33 KiB
JavaScript
396 lines
33 KiB
JavaScript
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_V1'
|
||
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)) }
|
||
}
|
||
|
||
export function validateOverseasInput(input, domestic) {
|
||
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'].includes(input.environment?.mode), '真实测量缺少本次TEST或GRAY环境绑定')
|
||
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'), '海外命令结果未知;停止重发并保留检查点')
|
||
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 remark = `RELEASE-ACCEPTANCE:${input.batch}`
|
||
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()
|
||
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),
|
||
...(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 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 = controlledPalletMeasurements(preview.pallets, trip.trip.stops, actual?.pallets)
|
||
const loadingMeasurementEvidence = { environment: input.environment?.mode?.toUpperCase() ?? null, batch: input.batch, dispatchNo,
|
||
source: input.loadingMeasurementFile != null ? 'EXPLICIT_FILE' : actual != null ? 'INLINE' : 'NATIVE_PREVIEW',
|
||
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,
|
||
measurements, remark: `${remark}仅采用真实来源测量,不从计费或计划常量推算` }
|
||
}, pdaToken)
|
||
check(loaded.dispatchNo === dispatchNo && loaded.status === 'ACTIVE' && loaded.stage === 'AFTER_LOADING'
|
||
&& loaded.result?.confirmedLoad?.pallets?.length === 1, '本票显式装车回执不符')
|
||
const transfer = await recorded('transfer', 'POST', `${dispatchPath}/transfer-delivery`, async () => {
|
||
const preview = await read(`${dispatchPath}/delivery-arrangement`)
|
||
check(preview.dispatchNo === dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'AFTER_LOADING'
|
||
&& preview.arrange?.allowed === true && preview.cargoHash && preview.loadingCompletionHash && preview.groups?.length === 1
|
||
&& id(preview.groups[0].customerId) === input.customerId && preview.groups[0].cargo?.length > 0
|
||
&& preview.groups[0].cargo.every(row => id(row.customerId) === input.customerId && row.inboundCode === domestic.inboundCode)
|
||
&& preview.groups[0].blockReasons?.length === 0 && preview.groups[0].externalQuotes?.length === 0, '本票派送安排不完整、混入其他票或存在外接约定')
|
||
return { recordVersion: version(preview.recordVersion), idempotencyKey: key('transfer'), cargoHash: preview.cargoHash,
|
||
loadingCompletionHash: preview.loadingCompletionHash, groups: preview.groups.map(group => ({ groupKey: group.groupKey, groupCargoHash: group.groupCargoHash,
|
||
plannedDeliveryAt: new Date(Date.parse(schedule.plannedDepartureAt) + 86400000).toISOString(), deliveryMethod: 'TRUCK_PALLET', waybillNo: key('WAYBILL'),
|
||
carrierName: input.resources.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '本票真实实载',
|
||
appointmentAccount: 'SELF', sellerName: null, remark, externalFreightHash: group.externalFreightHash, feeCoverageCargoHash: group.feeCoverageCargoHash,
|
||
feeCoverage: [], fees: (group.requiredServices || []).map(feeType => ({ feeType, feeName: '派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark })) })) }
|
||
})
|
||
check(transfer.dispatchNo === dispatchNo && transfer.status === 'ACTIVE' && transfer.stage === 'AFTER_LOADING'
|
||
&& transfer.result?.deliveries?.length === 1 && transfer.result.outbounds?.length === 1
|
||
&& id(transfer.result.deliveries[0].customerId) === input.customerId && id(transfer.result.outbounds[0].customerId) === input.customerId
|
||
&& transfer.result.deliveries[0].outboundNo === transfer.result.outbounds[0].outboundNo
|
||
&& Number(transfer.result.deliveries[0].packageCount) === input.packageCount, '本票未形成原生唯一客户出库和派送')
|
||
const outboundNo = transfer.result.outbounds[0].outboundNo, deliveryNo = transfer.result.deliveries[0].deliveryNo
|
||
const departed = await recorded('depart', 'POST', `${pdaDispatch}/depart`, async () => {
|
||
const ready = await read(pdaDispatch, pdaToken), { detail } = await ownMain()
|
||
check(ready.dispatchNo === dispatchNo && ready.status === 'ACTIVE' && ready.stage === 'AFTER_LOADING'
|
||
&& ready.pickingReconciled === true && ready.actions?.DEPART?.allowed === true && detail.actions?.DEPART?.allowed === true
|
||
&& id(ready.recordVersion) === id(detail.recordVersion), 'PDA及平台当前主版本发车门禁未通过')
|
||
return { recordVersion: version(ready.recordVersion), idempotencyKey: key('depart'), remark }
|
||
}, pdaToken)
|
||
check(departed.dispatchNo === dispatchNo && departed.status === 'ACTIVE' && departed.stage === 'DELIVERING'
|
||
&& Number(departed.result?.shippedPackages) === input.packageCount && departed.result.deliveries?.length === 1
|
||
&& departed.result.deliveries[0].deliveryNo === deliveryNo, '本票原生发车结果不符')
|
||
const outbound = await read(query(`${BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId: input.customerId }))
|
||
check(id(outbound.outbound?.customerId) === input.customerId && outbound.outbound.dispatchNo === dispatchNo
|
||
&& 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' }
|
||
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
|
||
}
|