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 => value == null ? '' : String(value) 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 binding = fingerprint({ method, path, body: body ?? null, headers, file }) 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, 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, status: 'DONE', result }) return result } return { command, 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), '缺少既有车队名称') 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 { read, lookup, command, 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 && 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', '实际还柜未完成') } let dispatch = state.commands?.dispatch?.result if (!dispatch) { const candidates = await read(`${BASE}/dispatches/candidates?sourceStage=RECEIVED`) const matches = candidates.filter(row => row.sourceStage === 'RECEIVED' && row.inboundCode === domestic.inboundCode && id(row.customerId) === input.customerId && id(row.overseasWarehouseId) === input.warehouseId) check(matches.length === 1 && Number(matches[0].packageCount) === input.packageCount, '本票排车候选不唯一或数量不符') dispatch = await command('dispatch', 'POST', `${BASE}/dispatches/pre-arrange`, { planPalletIds: matches.map(row => id(row.planPalletId)) }) } const dispatchNo = dispatch.dispatchNo check(Boolean(dispatchNo), '排车单号缺失') const transport = { vehiclePlate: vehicle.plateNumber, driverName: driver.details?.name, driverPhone: driver.details?.phone, carrierName: input.resources.carrierName, estimatedArrivalAt: schedule.arrival } await command('dispatch-vehicle', 'PUT', `${BASE}/dispatches/${dispatchNo}`, transport) const transfer = await command('transfer', 'POST', query('/api/merchant/v1/last-leg/delivery-orders', { dispatchNo }), { idempotencyKey: key('transfer'), plannedDeliveryAt: schedule.arrival, deliveryMethod: 'TRUCK_PALLET', waybillNo: key('WAYBILL'), carrierName: transport.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '15托', driverName: transport.driverName, driverPhone: transport.driverPhone, vehiclePlate: transport.vehiclePlate, appointmentAccount: 'SELF', sellerName: null, remark, fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark }], }, context.merchantToken) check(transfer.deliveries?.length === 1 && transfer.outboundNo, '本票未形成唯一出库/派送单') let trip = await command('trip', 'POST', `${BASE}/fleet/trips`, { warehouseId: input.warehouseId, plannedDepartureAt: schedule.plannedDepartureAt, expectedDriverEndAt: schedule.expectedDriverEndAt, stops: [{ name: '验收起运仓' }, { name: '待匹配本票目的地' }], remark, idempotencyKey: key('trip') }) const tripNo = trip.trip.tripNo, tripPath = `${BASE}/fleet/trips/${tripNo}` trip = await command('trip-assign', 'POST', `${tripPath}/assignment`, { recordVersion: trip.trip.recordVersion, vehicleId: input.resources.vehicleId, driverId: input.resources.driverId, reason: remark }) trip = await command('trip-bind', 'POST', `${tripPath}/dispatches/bind`, { recordVersion: trip.trip.recordVersion, dispatchNo, reason: remark }) let stops = state.stops if (!stops) { const loading = await read(`${tripPath}/loading`) check(loading.cargo?.length > 0 && loading.cargo.every(row => row.destinationKey), '本票装载缺少真实目的地') stops = [{ name: '验收起运仓' }, ...new Map(loading.cargo.map(row => [row.destinationKey, { name: row.destinationLabel, destinationKey: row.destinationKey }])).values()] await save({ stops }); state.stops = stops } trip = await command('trip-plan', 'PUT', `${tripPath}/plan`, { recordVersion: trip.trip.recordVersion, plannedDepartureAt: schedule.plannedDepartureAt, expectedDriverEndAt: schedule.expectedDriverEndAt, stops, reason: remark }) trip = await command('trip-review', 'POST', `${tripPath}/dispatches/${dispatchNo}/review`, { recordVersion: trip.trip.recordVersion, decision: 'APPROVED' }) const pdaDispatch = `${PDA}/dispatches/${dispatchNo}` if (!state.commands?.pickComplete) { const picking = await read(pdaDispatch, pdaToken) const exactDispatch = await read(`${BASE}/dispatches/${dispatchNo}`) check(exactDispatch.items?.length > 0 && exactDispatch.items.every(row => row.inboundCode === domestic.inboundCode) && picking.items?.length === exactDispatch.items.length && picking.items.every(row => exactDispatch.items.some(item => id(item.id) === id(row.dispatchItemId))), 'PDA拣货包含其他票') for (const item of picking.items) { let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) for (const position of item.positions || []) { if (remaining <= 0) break const packages = Math.min(remaining, Number(position.availablePackages)) if (!(packages > 0)) continue await command(`pick-${item.dispatchItemId}-${position.id}`, 'POST', `${pdaDispatch}/items/${item.dispatchItemId}/pick`, { positionId: id(position.id), packages, productQuantity: packages, idempotencyKey: key(`pick-${position.id}`), remark, }, pdaToken) remaining -= packages } check(remaining === 0, '本票库存不足以完成拣货') } await command('pickComplete', 'POST', `${pdaDispatch}/complete`, { idempotencyKey: key('pick-complete') }, pdaToken) } let loadingResult = state.commands?.loading?.result if (!loadingResult) { const loading = await read(`${tripPath}/loading`) check(!loading.blockReason, '整车装载未就绪') loadingResult = await command('loading', 'POST', `${tripPath}/loading`, { recordVersion: loading.recordVersion, manifestHash: loading.manifestHash, pallets: controlledPalletMeasurements(loading.cargo, stops), reason: `${remark} 合成验收测量` }) } const reviewed = await command('loading-review', 'POST', `${tripPath}/loading-review`, { recordVersion: loadingResult.recordVersion, decision: 'APPROVED' }) if (!state.commands?.depart) { const ready = await read(pdaDispatch, pdaToken) check(ready.status === 'COMPLETED' && ready.canDepart === true, 'PDA发车门禁未通过') } await command('depart', 'POST', `${pdaDispatch}/depart`, { recordVersion: reviewed.trip.recordVersion, idempotencyKey: key('depart'), remark }, pdaToken) const outbound = await read(`${BASE}/outbound-orders/${transfer.outboundNo}`) check(outbound.outbound?.status === 'OUTBOUNDED' && outbound.pallets?.length > 0 && outbound.pallets.every(row => row.inboundCode === domestic.inboundCode), '本票未真实完成出库') const result = { ...domestic, batch: input.batch, packageCount: input.packageCount, warehouseId: input.warehouseId, tailOrderId, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: transfer.deliveries[0].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 }