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

284 lines
22 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 { 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
}