121 lines
8.2 KiB
JavaScript
121 lines
8.2 KiB
JavaScript
import { mkdir, writeFile } from 'node:fs/promises'
|
|
const MODULE = 'phase4-overseas-business'
|
|
const OPTIONS = { scenario: 'us-truck-fulfillment', deploymentBlocker: true }
|
|
|
|
/** 沿用本轮独立卡车原单,以正式履约命令完成实收、分配和运输。 */
|
|
export async function verifyUsTruckFulfillment(context, proof, { checkpoint = context.checkpoint } = {}) {
|
|
if (!proof || proof.marker !== context.marker || !proof.orderNo || !proof.customerId
|
|
|| context.createdResources?.phase4OverseasBusiness?.truck !== proof || proof.fulfillment
|
|
|| proof.finalStatus !== 'WAITING_INBOUND') throw Error('卡车履约必须使用本轮未执行的待入仓原单')
|
|
if (!/^[A-Za-z0-9_-]{1,100}$/.test(context.marker)) throw Error('卡车批次标识不合法')
|
|
const evidence = proof.fulfillment = { status: 'RUNNING', requests: [] }
|
|
let initialized = false
|
|
const directory = new URL('../../reports/us-truck-checkpoints/', import.meta.url)
|
|
// 总编排提供检查点时沿用其持久化边界;否则独占创建批次文件,拒绝覆盖旧执行。
|
|
const persist = checkpoint ? () => checkpoint.call(context) : async () => {
|
|
await mkdir(directory, { recursive: true })
|
|
await writeFile(new URL(`${context.marker}.json`, directory), JSON.stringify(proof, null, 2),
|
|
{ encoding: 'utf8', flag: initialized ? 'w' : 'wx' })
|
|
initialized = true
|
|
}
|
|
const base = `/api/platform/v1/last-leg/us-truck-orders/${encodeURIComponent(proof.orderNo)}`
|
|
const suffix = `?customerId=${encodeURIComponent(proof.customerId)}`
|
|
const check = (name, ok) => {
|
|
context.assert(MODULE, name, null, Boolean(ok), `orderNo=${proof.orderNo}`, OPTIONS)
|
|
if (!ok) throw Error(name)
|
|
}
|
|
const request = async (method, action, body, expectedRejection = false) => {
|
|
const log = { method, action, body, expectedRejection, status: 'PREPARED' }
|
|
evidence.requests.push(log)
|
|
await persist()
|
|
log.status = 'SENDING'
|
|
const response = await context.request(method, `${base}${action}${suffix}`, body)
|
|
Object.assign(log, { http: response?.http, code: response?.body?.code,
|
|
requestId: response?.requestId, traceId: response?.traceId })
|
|
const ok = response?.http === 200 && String(response?.body?.code) === '200'
|
|
log.status = ok ? 'SUCCEEDED' : !response?.http || response.http >= 500 || !response.body ? 'UNKNOWN_RESULT' : 'REJECTED'
|
|
await persist()
|
|
if (expectedRejection) {
|
|
check('卡车无效命令按预期错误码拒绝且保留诊断', [200, expectedRejection].includes(response?.http)
|
|
&& [String(expectedRejection), `A0${expectedRejection}`].includes(String(response?.body?.code)) && Boolean(response.requestId) && Boolean(response.traceId))
|
|
return response.body.data
|
|
}
|
|
context.apiSuccess(MODULE, `本土卡车${method} ${action || '详情'}`, response,
|
|
`HTTP=${response?.http}; requestId=${response?.requestId}; traceId=${response?.traceId}`, OPTIONS)
|
|
if (!ok) throw Error(`卡车履约请求失败,不自动重发: ${action}`)
|
|
return response.body.data
|
|
}
|
|
const identity = detail => check('卡车原单客户和版本有效', detail?.summary?.orderNo === proof.orderNo
|
|
&& String(detail.summary.customerId) === String(proof.customerId)
|
|
&& Number.isSafeInteger(detail.summary.version) && detail.summary.version >= 0)
|
|
try {
|
|
let current = await request('GET', '')
|
|
identity(current)
|
|
check('卡车实收前仍为本轮待入仓单', current.summary.status === 'WAITING_INBOUND'
|
|
&& current.remark?.startsWith(context.marker) && Number(current.summary.receivedPalletCount) === 0)
|
|
const packages = Number(current.summary.packageCount)
|
|
check('卡车实收箱数来自原订单且为有效正整数', Number.isSafeInteger(packages) && packages > 0)
|
|
const packageCounts = packages >= 2 ? [1, packages - 1] : [packages]
|
|
const palletCount = packageCounts.length
|
|
const stages = [
|
|
['receive-pallets', 'WAITING_CONSOLIDATION'],
|
|
...(palletCount === 2 ? [['allocate-pallets', 'PARTIALLY_ALLOCATED', 0], ['allocate-pallets', 'ALLOCATED', 1]] : [['allocate-pallets', 'ALLOCATED', 0]]),
|
|
['prepare-dispatch', 'PENDING_DISPATCH'], ['dispatch', 'DISPATCHED'],
|
|
['start-transit', 'IN_TRANSIT'], ['complete', 'COMPLETED'],
|
|
]
|
|
let pallets
|
|
const allocatedIds = new Set()
|
|
for (const [action, state, allocationIndex] of stages) {
|
|
if (action === 'allocate-pallets' && allocationIndex === 1) {
|
|
await request('POST', '/prepare-dispatch', {
|
|
version: current.summary.version, idempotencyKey: `${context.marker}-truck-prepare-dispatch-partial`, remark: context.marker,
|
|
}, 409)
|
|
const unchanged = await request('GET', '')
|
|
check('卡车部分分配禁止待派车且原单完全不变', JSON.stringify(unchanged) === JSON.stringify(current))
|
|
}
|
|
const beforeVersion = current.summary.version
|
|
const body = { version: beforeVersion, idempotencyKey: `${context.marker}-truck-${action}`, remark: context.marker }
|
|
if (action === 'receive-pallets') Object.assign(body, { actualPalletCount: palletCount, packageCount: packages, palletPackageCounts: packageCounts })
|
|
if (action === 'allocate-pallets') {
|
|
body.palletIds = [pallets[allocationIndex].id]
|
|
body.idempotencyKey += `-${allocationIndex}`
|
|
}
|
|
// 拒绝分支使用同一原单与独立键,回查整个聚合证明未偷偷收托或分配。
|
|
if (action === 'receive-pallets' || action === 'allocate-pallets' && allocationIndex === 0) {
|
|
const invalid = { ...body, idempotencyKey: `${body.idempotencyKey}-invalid`,
|
|
...(action === 'receive-pallets' ? { palletPackageCounts: packageCounts.map((n, i) => n + (i === 0 ? 1 : 0)) } : { palletIds: [body.palletIds[0], body.palletIds[0]] }) }
|
|
await request('POST', `/${action}`, invalid, 400)
|
|
const unchanged = await request('GET', '')
|
|
check(`卡车${action}拒绝后数量状态历史版本均不变`, JSON.stringify(unchanged) === JSON.stringify(current))
|
|
}
|
|
const next = await request('POST', `/${action}`, body)
|
|
identity(next)
|
|
check(`卡车${action}状态和版本正确`, next.summary.status === state && next.summary.version === beforeVersion + 1)
|
|
check('卡车原单实收托盘及箱数守恒', next.pallets?.length === palletCount && Number(next.summary.receivedPalletCount) === palletCount
|
|
&& new Set(next.pallets.map(row => String(row.id))).size === palletCount
|
|
&& next.pallets.every((row, index) => Number(row.packageCount) === packageCounts[index]
|
|
&& /^[1-9]\d*$/.test(String(row.id)) && typeof row.palletNo === 'string' && row.palletNo.startsWith(`${proof.orderNo}-P`)))
|
|
pallets ??= next.pallets.map(row => ({ id: String(row.id), palletNo: row.palletNo, packageCount: Number(row.packageCount) }))
|
|
if (action === 'allocate-pallets') allocatedIds.add(String(body.palletIds[0]))
|
|
check('卡车履约复用原实收托盘且只分配本次选择', next.pallets.every((row, index) => String(row.id) === pallets[index].id
|
|
&& row.palletNo === pallets[index].palletNo && row.status === (allocatedIds.has(String(row.id)) ? 'ALLOCATED' : 'RECEIVED'))
|
|
&& Number(next.summary.allocatedPalletCount) === allocatedIds.size)
|
|
check('卡车履约写入对应操作历史', next.histories?.some(row => row.idempotencyKey === body.idempotencyKey
|
|
&& row.toStatus === state && row.fromStatus === current.summary.status))
|
|
const replay = await request('POST', `/${action}`, body)
|
|
check('卡车同键重放不重复收托或推进版本', JSON.stringify(replay) === JSON.stringify(next))
|
|
current = await request('GET', '')
|
|
check('卡车履约状态已持久化且回查一致', JSON.stringify(current) === JSON.stringify(next))
|
|
}
|
|
Object.assign(proof, { finalStatus: 'COMPLETED', version: current.summary.version, retained: true })
|
|
Object.assign(evidence, { status: 'HTTP_VERIFIED', pallets, packages, partialAllocationVerified: palletCount === 2 })
|
|
await persist()
|
|
} catch (error) {
|
|
for (const log of evidence.requests) if (log.status === 'SENDING') log.status = 'UNKNOWN_RESULT'
|
|
evidence.status = 'FAILED'
|
|
evidence.failure = error.message
|
|
try { await persist() } catch (checkpointError) { evidence.checkpointFailure = checkpointError.message }
|
|
throw error
|
|
}
|
|
}
|