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 }