Files
baiyue-firstleg/scripts/prod-release-delivery-stage.mjs
T
2026-09-20 09:17:47 +08:00

180 lines
12 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 { 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 => Number.isSafeInteger(Number(value)) ? Number(value) : NaN
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, '配送中操作结果不确定,禁止重发;请核对服务端状态')
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, cn: settings.cn, overseas: settings.overseas,
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 && login?.overseasAccessToken, '缺少国内或海外令牌')
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,
region === 'cn' ? login.accessToken : login.overseasAccessToken, body),
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.overseas + path, { method: 'POST', redirect: 'error',
signal: AbortSignal.timeout(20_000), headers: { Authorization: `Bearer ${login.overseasAccessToken}` }, body: form }) }
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
},
})
})
}