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

182 lines
12 KiB
JavaScript
Raw Normal View History

2026-09-20 09:17:47 +08:00
import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises'
import { dirname } from 'node:path'
import { fingerprint } from './prod-release-order-stage.mjs'
const check = (ok, message) => { if (!ok) throw new Error(message) }
const longId = value => typeof value === 'string' && /^[1-9]\d*$/.test(value)
const segment = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(value)
const number = value => (typeof value === 'number' || typeof value === 'string' && /^\d+$/.test(value))
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0 ? Number(value) : NaN
2026-09-20 09:17:47 +08:00
export function validateDeliveryInput(input) {
check(/^[A-Za-z0-9][A-Za-z0-9_-]{7,47}$/.test(input.batch || ''), '无效验收批次')
for (const key of ['orderId', 'tailOrderId', 'warehouseId']) check(longId(input[key]), `无效 ${key}`)
for (const key of ['orderNo', 'inboundCode', 'dispatchNo', 'outboundNo', 'deliveryNo']) check(segment(input[key]), `无效 ${key}`)
check([1, 2].includes(input.packageCount), '只允许 1~2 箱单票验收')
}
// Separate journal: never overwrite the order-stage checkpoint or imply earlier stages were automated.
export async function withDeliveryJournal(path, binding, action) {
await mkdir(dirname(path), { recursive: true })
const lock = await open(`${path}.lock`, 'wx', 0o600)
try {
let state
try { state = JSON.parse(await readFile(path, 'utf8')) }
catch (error) {
if (error.code !== 'ENOENT') throw new Error('签收检查点损坏,禁止重新上传')
state = { version: 1, binding, steps: {} }
}
check(state.version === 1 && state.binding === binding && state.steps, '签收检查点与环境、账号或输入不符')
const save = async patch => {
const next = { ...state, ...patch }
const file = await open(`${path}.next`, 'wx', 0o600)
try { await file.writeFile(JSON.stringify(next, null, 2)); await file.sync() } finally { await file.close() }
await rename(`${path}.next`, path)
Object.assign(state, next)
}
return await action(state, save)
} finally { await lock.close(); await unlink(`${path}.lock`) }
}
export function verifyOutbound(input, dispatch, detail, tail) {
check(dispatch?.dispatchNo === input.dispatchNo && dispatch.overseasWarehouseId === input.warehouseId
&& dispatch.status === 'DEPARTED' && number(dispatch.packageCount) === input.packageCount
&& number(dispatch.shippedPackageCount) === input.packageCount
&& number(dispatch.pickedPackageCount) === input.packageCount
&& number(dispatch.remainingPackageCount) === 0, '排车身份、发车状态或数量不符')
check(dispatch.items?.length > 0 && dispatch.items.every(item => item.inboundCode === input.inboundCode), '排车包含其他票')
check(detail?.outbound?.outboundNo === input.outboundNo && detail.outbound.dispatchNo === input.dispatchNo
&& detail.outbound.overseasWarehouseId === input.warehouseId && detail.outbound.status === 'OUTBOUNDED'
&& number(detail.outbound.packageCount) === input.packageCount, '出库单身份或状态不符')
check(detail.deliveries?.length === 1 && detail.deliveries[0].deliveryNo === input.deliveryNo, '不是本票唯一派送单')
check(tail?.order?.id === input.tailOrderId && tail.order.sourceOrderNo === input.orderNo
&& tail.order.inboundCode === input.inboundCode && tail.order.overseasWarehouseId === input.warehouseId, '尾程与国内订单来源不符')
const blockers = []
if (!detail.pallets?.length || detail.pallets.some(item => item.inboundCode !== input.inboundCode
|| number(item.pickedPackageCount) !== number(item.packageCount) || item.status === 'WAIT_PICK')) {
blockers.push('OUTBOUND_PALLET_PICK_STATE_STALE')
}
if (String(tail.order.subOrderStatus).includes('OUTBOUND:WAIT_OUTBOUND')) blockers.push('TAIL_OUTBOUND_SUB_STATUS_STALE')
return blockers
}
export async function runDeliveryStage({ input, customerId, state, save, request, upload, wait = ms => new Promise(r => setTimeout(r, ms)) }) {
validateDeliveryInput(input)
const root = '/platform/v1/last-leg'
const deliveryPath = `${root}/delivery-orders/${input.deliveryNo}`
const scoped = path => `${path}?customerId=${customerId}`
const readDelivery = async () => {
const detail = await request('overseas', 'GET', scoped(deliveryPath))
check(detail?.order?.customerId === customerId && detail.order.deliveryNo === input.deliveryNo
&& detail.order.dispatchNo === input.dispatchNo && detail.order.outboundNo === input.outboundNo
&& detail.order.remark === `RELEASE-ACCEPTANCE:${input.batch}`, '派送单身份或验收标记不符')
return detail
}
const dispatch = await request('overseas', 'GET', `${root}/dispatches/${input.dispatchNo}`)
const outbound = await request('overseas', 'GET', `${root}/outbound-orders/${input.outboundNo}`)
const tail = await request('overseas', 'GET', scoped(`${root}/orders/${input.tailOrderId}`))
check(tail?.order?.customerId === customerId, '尾程客户不符')
const blockers = verifyOutbound(input, dispatch, outbound, tail)
let delivery = await readDelivery()
if (delivery.order.status === 'WAIT_DELIVERY') {
check(!state.startIntent, '配送中操作结果不确定,禁止重发;请核对服务端状态')
check(Number.isSafeInteger(number(delivery.order.version)), '派送状态版本缺失或无效')
2026-09-20 09:17:47 +08:00
await save({ startIntent: true })
await request('overseas', 'PUT', scoped(`${deliveryPath}/status`), {
status: 'DELIVERING', expectedVersion: number(delivery.order.version), remark: `RELEASE-ACCEPTANCE:${input.batch}`,
})
delivery = await readDelivery()
}
check(['DELIVERING', 'DELIVERED'].includes(delivery.order.status), '派送状态不能继续签收')
const documentsPath = scoped(`${deliveryPath}/documents`)
let documents = await request('overseas', 'GET', documentsPath)
if (delivery.order.status !== 'DELIVERED') {
if (!state.fileId) {
check(!state.uploadIntent, '签收上传结果不确定;保留检查点,禁止重复上传')
check(!documents.categories?.some(c => c.category === 'RECEIPT' && c.files?.length), '存在未登记的签收文件,需核对')
await save({ uploadIntent: true })
const file = await upload(scoped(`${deliveryPath}/documents/RECEIPT/files`),
`${input.batch}-receipt.txt`, `RELEASE ACCEPTANCE ONLY - NOT A REAL PROOF OF DELIVERY\n${input.batch}\n${input.deliveryNo}\n`)
check(longId(file?.fileId), '上传未返回精确文件 ID')
await save({ fileId: file.fileId })
}
if (!state.saveBody) {
check(Number.isSafeInteger(number(documents.version)) && number(documents.version) >= 0, '签收资料版本缺失')
const existing = (documents.categories || []).filter(c => c.category !== 'RECEIPT' && c.files?.length)
.map(c => ({ category: c.category, fileIds: c.files.map(f => f.fileId) }))
await save({ saveBody: { expectedVersion: number(documents.version),
idempotencyKey: `prod-receipt-${fingerprint({ batch: input.batch, delivery: input.deliveryNo }).slice(0, 40)}`,
bolNo: documents.bolNo || null, documents: [...existing, { category: 'RECEIPT', fileIds: [state.fileId] }] } })
}
await request('overseas', 'PUT', documentsPath, state.saveBody)
delivery = await readDelivery()
documents = await request('overseas', 'GET', documentsPath)
}
check(delivery.order.status === 'DELIVERED' && delivery.operations?.some(o => o.operationNode === 'RECEIPT_AUTO_COMPLETE'), '签收未自动完成派送')
check(documents.categories?.some(c => c.category === 'RECEIPT' && c.status === 'UPLOADED'
&& c.files?.some(f => f.fileId === state.fileId)), '签收文件没有绑定本次上传结果')
let inbound, order
for (let attempt = 0; attempt < 30; attempt++) {
const page = await request('cn', 'GET', `/platform/inbounds?tenantId=${customerId}&inboundCode=${input.inboundCode}&page=1&size=20`)
inbound = page?.records?.find(row => row.inboundCode === input.inboundCode && row.orderId === input.orderId)
order = await request('cn', 'GET', `/platform/v1/orders/${input.orderId}?tenantId=${customerId}`)
if (inbound?.processStatus === 'DELIVERED' && order?.summary?.mainStatus === 'COMPLETED') break
if (attempt < 29) await wait(1000)
}
const closure = inbound?.processStatus === 'DELIVERED' && order?.summary?.mainStatus === 'COMPLETED'
if (!closure) blockers.push('CN_DELIVERY_EVENT_NOT_RECONCILED')
const finalOutbound = await request('overseas', 'GET', `${root}/outbound-orders/${input.outboundNo}`)
const finalTail = await request('overseas', 'GET', scoped(`${root}/orders/${input.tailOrderId}`))
const finalBlockers = verifyOutbound(input, dispatch, finalOutbound, finalTail)
const result = {
status: closure && !finalBlockers.length ? 'DELIVERY_STAGE_PASSED' : 'DELIVERY_STAGE_WITH_BLOCKERS',
batch: input.batch, orderNo: input.orderNo, inboundCode: input.inboundCode,
dispatchNo: input.dispatchNo, outboundNo: input.outboundNo, deliveryNo: input.deliveryNo,
deliveryStatus: delivery.order.status, domesticInboundStatus: inbound?.processStatus || null,
domesticOrderStatus: order?.summary?.mainStatus || null, deliveryClosureVerified: closure,
fullFlowVerified: false, blockers: [...new Set([...finalBlockers, ...blockers.filter(b => b === 'CN_DELIVERY_EVENT_NOT_RECONCILED')])],
packageCount: input.packageCount, receipt: 'SYNTHETIC_ACCEPTANCE_FILE',
checkedAt: new Date().toISOString(),
}
await save({ result })
return result
}
export async function executeDelivery(settings, input, journalPath, fetcher = fetch) {
validateDeliveryInput(input)
const binding = fingerprint({ input, environment: settings.environment, apiBase: settings.apiBase,
2026-09-20 09:17:47 +08:00
customer: settings.customer, admin: settings.credentials.ADMIN.loginId })
return withDeliveryJournal(journalPath, binding, async (state, save) => {
const { client } = await import('./prod-release-acceptance.mjs')
const login = await client(settings, fetcher).request('cn', 'POST', '/auth/login', null, settings.credentials.ADMIN)
check(login?.accessToken, '缺少统一 accessToken')
2026-09-20 09:17:47 +08:00
const root = '/platform/v1/last-leg'
const delivery = `${root}/delivery-orders/${input.deliveryNo}`
const scoped = path => `${path}?customerId=${settings.customer.id}`
const reads = new Set([`${root}/dispatches/${input.dispatchNo}`, `${root}/outbound-orders/${input.outboundNo}`,
scoped(`${root}/orders/${input.tailOrderId}`), scoped(delivery), scoped(`${delivery}/documents`)])
const writes = new Set([scoped(`${delivery}/status`), scoped(`${delivery}/documents`)])
const cnReads = new Set([`/platform/inbounds?tenantId=${settings.customer.id}&inboundCode=${input.inboundCode}&page=1&size=20`,
`/platform/v1/orders/${input.orderId}?tenantId=${settings.customer.id}`])
const http = client(settings, fetcher, (method, path, region) => region === 'cn'
? method === 'GET' && cnReads.has(path)
: region === 'overseas' && (method === 'GET' && reads.has(path) || method === 'PUT' && writes.has(path)))
return runDeliveryStage({ input, customerId: settings.customer.id, state, save,
request: (region, method, path, body) => http.request(region, method, path,
login.accessToken, body),
2026-09-20 09:17:47 +08:00
upload: async (path, name, text) => {
check(path === scoped(`${delivery}/documents/RECEIPT/files`), '上传路径不符')
const form = new FormData(); form.append('file', new Blob([text], { type: 'text/plain' }), name)
let response
try { response = await fetcher(settings.apiBase + path, { method: 'POST', redirect: 'error',
signal: AbortSignal.timeout(20_000), headers: { Authorization: `Bearer ${login.accessToken}` }, body: form }) }
2026-09-20 09:17:47 +08:00
catch { throw new Error('签收上传连接失败或超时;未自动重试') }
const data = await response.json()
const safe = value => /^[\w-]{1,100}$/.test(String(value)) ? value : 'unavailable'
check(response.ok && data?.code === '200', `签收上传 HTTP=${response.status}; code=${safe(data?.code)}; requestId=${safe(data?.requestId)}; traceId=${safe(data?.traceId)}`)
return data.data
},
})
})
}