180 lines
12 KiB
JavaScript
180 lines
12 KiB
JavaScript
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
|
||
},
|
||
})
|
||
})
|
||
}
|