fix: complete single-order acceptance and add Lingxing WMS integration

This commit is contained in:
2026-10-12 16:09:21 +08:00
parent 6c4ee2bfde
commit c06e788b0f
27 changed files with 2305 additions and 46 deletions
+35 -24
View File
@@ -3,7 +3,7 @@ import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { normalizeOrderInput, measurementPlan } from './prod-release-measurement-plan.mjs'
// Production is deliberately not routed through the multi-customer test runner.
// Single-order acceptance never invokes the multi-customer fixture runner.
export const STAGES = Object.freeze([
'CUSTOMER_ORDER', 'ORDER_REVIEW', 'YIWU_MEASUREMENT', 'CUSTOMER_FEE_CONFIRMATION',
'CONTAINER_ALLOCATION', 'DOMESTIC_PDA_LOADING', 'CUSTOMS_AND_BOOKING',
@@ -20,9 +20,8 @@ export const DOMESTIC_WAREHOUSE_CODES = Object.freeze(['CN-YIWU-01', 'CN-DONGGUA
export function plan() {
return {
scope: { orders: 1, origins: [...DOMESTIC_WAREHOUSE_CODES], origin: 'CN-YIWU-01', receiving: 'US-LAX-01', sameOrder: true },
status: 'FULL_FLOW_NOT_IMPLEMENTED', fullFlowVerified: false,
stages: STAGES.map(name => ({ name, status: ['CUSTOMER_ORDER', 'ORDER_REVIEW'].includes(name)
? 'IMPLEMENTED_NOT_HTTP_VERIFIED' : 'NOT_IMPLEMENTED' })),
status: 'FULL_FLOW_IMPLEMENTED_NOT_HTTP_VERIFIED', fullFlowVerified: false,
stages: STAGES.map(name => ({ name, status: 'IMPLEMENTED_NOT_HTTP_VERIFIED' })),
excluded: ['其他客户', '混柜及拆单', '退货及异常分支', '真实付款', '第三方下单或发送邮件', '浏览器和设备交互'],
policy: { createMasterData: false, createAccounts: false, deleteData: false },
}
@@ -43,20 +42,22 @@ function baseUrl(value, path) {
}
export function config(env) {
if (env.CHONGCHONG_ENV !== 'prod') throw new Error('必须显式配置 CHONGCHONG_ENV=prod')
if (!['test', 'gray'].includes(env.CHONGCHONG_ENV)) throw new Error('必须显式配置 CHONGCHONG_ENV=test 或 gray')
for (const key of ['CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL', 'CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL']) {
if (env[key] != null) throw new Error(`移除旧海外网关配置:${key};所有业务使用统一 Gateway`)
}
const get = key => requireValue(env, `CHONGCHONG_${key}`)
const cn = baseUrl(get('ADMIN_API_BASE_URL'), '/api')
const overseas = baseUrl(get('ADMIN_OVERSEAS_API_BASE_URL'), '/overseas-api')
const apiBase = baseUrl(get('ADMIN_API_BASE_URL'), '/api')
const merchant = baseUrl(get('MERCHANT_API_BASE_URL'), '/api')
if (new URL(cn).origin !== new URL(overseas).origin || cn !== merchant) {
throw new Error('当前专属入口要求国内、海外及客户 API 使用同一 Admin 前端代理')
if (apiBase !== merchant) {
throw new Error('单票入口要求平台及客户 API 使用同一 HTTPS /api 统一入口')
}
const customer = { id: get('ACCEPTANCE_CUSTOMER_ID'), code: get('ACCEPTANCE_CUSTOMER_CODE'), name: get('ACCEPTANCE_CUSTOMER_NAME') }
if (!/^[1-9]\d*$/.test(customer.id)) throw new Error('客户 ID 必须为非零十进制字符串')
const credentials = Object.fromEntries(['ADMIN', 'MERCHANT', 'CN_PDA', 'OVERSEAS_LAX_PDA'].map(role =>
[role, { loginId: get(`${role}_USERNAME`), password: get(`${role}_PASSWORD`) }]))
if (get('OVERSEAS_LAX_PDA_WAREHOUSE_CODE') !== 'US-LAX-01') throw new Error('海外 PDA 必须绑定洛杉矶仓')
return { cn, overseas, customer, credentials }
return { environment: env.CHONGCHONG_ENV, apiBase, customer, credentials }
}
function id(value) {
@@ -88,11 +89,11 @@ export function client(settings, fetcher = fetch, policy = allowedRequest) {
return {
get requests() { return requests },
async request(region, method, path, token, body, idempotencyKey) {
if (!policy(method, path, region, body, idempotencyKey)) throw new Error('请求不在生产前置检查白名单内')
if (++requests > 100) throw new Error('超过单次前置检查请求上限')
if (!policy(method, path, region, body, idempotencyKey)) throw new Error('请求不在单票请求白名单内')
if (++requests > 100) throw new Error('超过单阶段请求上限')
let response
try {
response = await fetcher((region === 'cn' ? settings.cn : settings.overseas) + path, {
response = await fetcher(settings.apiBase + path, {
method, redirect: 'error', signal: AbortSignal.timeout(20_000),
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}),
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}) },
@@ -129,7 +130,7 @@ export async function preflight(settings, http, receiveContext = () => {}) {
const logins = {}
for (const [role, credentials] of Object.entries(settings.credentials)) {
logins[role] = await http.request('cn', 'POST', '/auth/login', undefined, credentials)
if (!logins[role]?.accessToken) throw new Error(`${role} 登录缺少国内 Token`)
if (!logins[role]?.accessToken) throw new Error(`${role} 登录缺少统一 accessToken`)
}
const admin = logins.ADMIN.accessToken
assertCustomer(await http.request('cn', 'GET', `/platform/v1/iam/tenants/${settings.customer.id}`, admin), settings.customer)
@@ -152,8 +153,8 @@ export async function preflight(settings, http, receiveContext = () => {}) {
!cnWarehouses.some(w => w.warehouseCode === code && id(w.id) === warehouses[code]))) {
throw new Error('国内 PDA 不可访问义乌或东莞仓')
}
const overseasToken = logins.OVERSEAS_LAX_PDA.overseasAccessToken
if (!overseasToken) throw new Error('洛杉矶 PDA 登录缺少海外 Token')
const overseasToken = logins.OVERSEAS_LAX_PDA.accessToken
if (!overseasToken) throw new Error('洛杉矶 PDA 登录缺少统一 accessToken')
const binding = await http.request('overseas', 'GET', '/pda/v1/last-leg/receiving-containers/warehouse-profile', overseasToken)
if (binding?.warehouseCode !== 'US-LAX-01' || id(binding.warehouseId) !== warehouses['US-LAX-01']) throw new Error('洛杉矶 PDA 仓库绑定不符')
const counts = {}
@@ -169,14 +170,14 @@ export async function preflight(settings, http, receiveContext = () => {}) {
return { ...plan(), status: 'BASIC_PREFLIGHT_PASSED', requests: http.requests,
checks: ['客户身份及箱规状态', '四个账号登录', '义乌、东莞及洛杉矶仓', 'PDA 仓库范围', '国内既有托盘及可用库位'],
resourceCounts: counts,
pending: ['单票执行入口的精确线路及小票报价检查', '供应商与海外资源门禁', '测量至签收的写入执行器和检查点', '所有业务阶段真实 HTTP 验证'],
pending: ['单票执行入口的精确线路及小票报价检查', '供应商与海外资源门禁', '全流程输入指定的资源资格', '本轮全部业务阶段真实 HTTP 验证'],
}
}
export async function executeOrder(settings, input, journalPath, resume, fetcher = fetch) {
const { validateOrderInput, withOrderJournal, runOrderStage } = await import('./prod-release-order-stage.mjs')
// These facts come from the selected external environment, not from user-editable order JSON.
const bound = { ...normalizeOrderInput(input), customer: settings.customer, environment: settings.cn,
const bound = { ...normalizeOrderInput(input), customer: settings.customer, environment: { mode: settings.environment, apiBase: settings.apiBase },
operator: { merchant: settings.credentials.MERCHANT.loginId, admin: settings.credentials.ADMIN.loginId } }
validateOrderInput(bound)
return withOrderJournal(journalPath, bound, async (state, save) => {
@@ -229,9 +230,19 @@ export async function executeOrder(settings, input, journalPath, resume, fetcher
export async function main(argv = process.argv.slice(2), env = process.env) {
const [mode = 'plan', ...rest] = argv
if (mode === 'full-flow') {
if ((rest.length !== 3 && rest.length !== 4) || rest[2] !== '--confirm-single-full-flow'
|| rest.length === 4 && rest[3] !== '--resume') throw Error('full-flow 需要输入文件、检查点、--confirm-single-full-flow,可选 --resume')
const inputPath = resolve(rest[0]), journalPath = resolve(rest[1])
if (inputPath === journalPath) throw Error('输入和检查点不能使用同一文件')
const { executeFullFlow } = await import('./prod-release-full-flow.mjs')
const result = await executeFullFlow(config(env), JSON.parse(await readFile(inputPath, 'utf8')), journalPath, rest[3] === '--resume')
console.log(JSON.stringify(result, null, 2))
return
}
if (mode === 'delivery-stage') {
if (rest.length !== 3 || rest[2] !== '--confirm-production-delivery') {
throw new Error('delivery-stage 需要输入文件、独立签收检查点、--confirm-production-delivery')
if (rest.length !== 3 || rest[2] !== '--confirm-single-delivery') {
throw new Error('delivery-stage 需要输入文件、独立签收检查点、--confirm-single-delivery')
}
const inputPath = resolve(rest[0]), journalPath = resolve(rest[1])
if (inputPath === journalPath) throw new Error('输入和检查点不能使用同一文件')
@@ -252,8 +263,8 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
return
}
if (mode === 'order-stage') {
if ((rest.length !== 3 && rest.length !== 4) || rest[2] !== '--confirm-production-order'
|| rest.length === 4 && rest[3] !== '--resume') throw new Error('order-stage 需要输入文件、检查点、--confirm-production-order,可选 --resume')
if ((rest.length !== 3 && rest.length !== 4) || rest[2] !== '--confirm-single-order'
|| rest.length === 4 && rest[3] !== '--resume') throw new Error('order-stage 需要输入文件、检查点、--confirm-single-order,可选 --resume')
const inputPath = resolve(rest[0]), journalPath = resolve(rest[1])
if (inputPath === journalPath) throw new Error('输入和检查点不能使用同一文件')
const input = JSON.parse(await readFile(inputPath, 'utf8'))
@@ -261,7 +272,7 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
console.log(JSON.stringify(result))
return
}
if (!['plan', 'preflight'].includes(mode) || rest.length) throw new Error('用法:prod-release-acceptance.mjs [plan|preflight];全链路执行尚未开放')
if (!['plan', 'preflight'].includes(mode) || rest.length) throw new Error('用法:prod-release-acceptance.mjs [plan|preflight];完整链使用 full-flow <输入> <检查点> --confirm-single-full-flow')
if (mode === 'plan') { console.log(JSON.stringify(plan(), null, 2)); return }
const settings = config(env)
const reportPath = resolve('reports', `prod-release-preflight-${Date.now()}.json`)
@@ -276,5 +287,5 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
main().catch(error => { console.error(`生产验收入口失败:${error instanceof SyntaxError ? '输入或检查点 JSON 无效' : error.message}`); process.exitCode = 1 })
main().catch(error => { console.error(`单票验收入口失败:${error instanceof SyntaxError ? '输入或检查点 JSON 无效' : error.message}`); process.exitCode = 1 })
}
+38 -6
View File
@@ -3,9 +3,8 @@ import assert from 'node:assert/strict'
import { plan, config, assertCustomer, allowedRequest, client, listAll, preflight, main, DOMESTIC_WAREHOUSE_CODES } from './prod-release-acceptance.mjs'
function environment() {
const env = { CHONGCHONG_ENV: 'prod',
const env = { CHONGCHONG_ENV: 'gray',
CHONGCHONG_ADMIN_API_BASE_URL: 'https://acceptance.invalid/api',
CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL: 'https://acceptance.invalid/overseas-api',
CHONGCHONG_MERCHANT_API_BASE_URL: 'https://acceptance.invalid/api',
CHONGCHONG_ACCEPTANCE_CUSTOMER_ID: '9007199254740993',
CHONGCHONG_ACCEPTANCE_CUSTOMER_CODE: 'fixture', CHONGCHONG_ACCEPTANCE_CUSTOMER_NAME: 'fixture',
@@ -25,8 +24,8 @@ test('single order scope does not claim implemented or full coverage', () => {
assert.deepEqual(result.scope.origins, [...DOMESTIC_WAREHOUSE_CODES])
assert.equal(result.scope.receiving, 'US-LAX-01')
assert.equal(result.fullFlowVerified, false)
assert.equal(result.stages.filter(s => s.status === 'IMPLEMENTED_NOT_HTTP_VERIFIED').length, 2)
assert.equal(result.stages.filter(s => s.status === 'NOT_IMPLEMENTED').length, 12)
assert.equal(result.stages.filter(s => s.status === 'IMPLEMENTED_NOT_HTTP_VERIFIED').length, 14)
assert.equal(result.stages.filter(s => s.status === 'NOT_IMPLEMENTED').length, 0)
})
test('configuration has no development/test fallback', () => {
assert.throws(() => config({ ADMIN_TEST_GATEWAY: 'https://acceptance.invalid' }))
@@ -85,7 +84,7 @@ function fakeHttp(bindingId = '3') {
return { requests: 0, async request(region, method, path) {
this.requests++
assert.equal(allowedRequest(method, path, region), true)
if (method === 'POST') return { accessToken: 'fixture-token', overseasAccessToken: 'fixture-overseas-token' }
if (method === 'POST') return { accessToken: 'fixture-token' }
if (path.includes('/iam/')) return profile
if (path.includes('/warehouse-profile')) return { warehouseId: bindingId, warehouseCode: 'US-LAX-01' }
if (path.endsWith('/pda-loading/warehouses')) return [{ id: '2', warehouseCode: 'CN-YIWU-01' }, { id: '4', warehouseCode: 'CN-DONGGUAN-01' }]
@@ -110,6 +109,39 @@ test('PDA warehouse mismatch blocks preflight', async () => {
})
test('execute, resume and unrecognized arguments fail closed', async () => {
for (const args of [['execute'], ['resume'], ['preflight', '--execute'], ['plan', '--count', '2']]) {
await assert.rejects(main(args, {}), /尚未开放/)
await assert.rejects(main(args, {}), /完整链使用/)
}
})
test('explicit gray/test mode shares one API base and rejects retired environment keys', () => {
for (const mode of ['gray', 'test']) {
const settings = config({ ...environment(), CHONGCHONG_ENV: mode })
assert.equal(settings.environment, mode)
assert.equal(settings.apiBase, 'https://acceptance.invalid/api')
assert.equal(settings.overseas, undefined)
}
for (const mode of ['prod', 'dev', '', undefined]) {
assert.throws(() => config({ ...environment(), CHONGCHONG_ENV: mode }), /显式配置/)
}
for (const key of ['CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL', 'CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL']) {
assert.throws(() => config({ ...environment(), [key]: 'https://acceptance.invalid/api' }), /旧海外网关/)
}
})
test('overseas PDA calls the unified gateway using its ordinary access token', async () => {
const calls = []
const http = client(config(environment()), async (url, options) => {
calls.push({ url, token: options.headers.Authorization })
return { ok: true, status: 200, json: async () => ({ code: '200', data: {} }) }
})
await http.request('overseas', 'GET', '/pda/v1/last-leg/receiving-containers/warehouse-profile', 'ordinary-access')
assert.deepEqual(calls, [{ url: 'https://acceptance.invalid/api/pda/v1/last-leg/receiving-containers/warehouse-profile', token: 'Bearer ordinary-access' }])
})
test('unapproved customer blocks preflight before any business stage', async () => {
const http = fakeHttp(), original = http.request.bind(http)
http.request = async (...args) => args[2].includes('/iam/')
? { ...profile, status: 'PROFILE_REQUIRED' } : original(...args)
await assert.rejects(preflight(config(environment()), http), /审核状态/)
})
+8 -6
View File
@@ -5,7 +5,8 @@ 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
const number = value => (typeof value === 'number' || typeof value === 'string' && /^\d+$/.test(value))
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0 ? Number(value) : NaN
export function validateDeliveryInput(input) {
check(/^[A-Za-z0-9][A-Za-z0-9_-]{7,47}$/.test(input.batch || ''), '无效验收批次')
@@ -79,6 +80,7 @@ export async function runDeliveryStage({ input, customerId, state, save, request
let delivery = await readDelivery()
if (delivery.order.status === 'WAIT_DELIVERY') {
check(!state.startIntent, '配送中操作结果不确定,禁止重发;请核对服务端状态')
check(Number.isSafeInteger(number(delivery.order.version)), '派送状态版本缺失或无效')
await save({ startIntent: true })
await request('overseas', 'PUT', scoped(`${deliveryPath}/status`), {
status: 'DELIVERING', expectedVersion: number(delivery.order.version), remark: `RELEASE-ACCEPTANCE:${input.batch}`,
@@ -142,12 +144,12 @@ export async function runDeliveryStage({ input, customerId, state, save, request
export async function executeDelivery(settings, input, journalPath, fetcher = fetch) {
validateDeliveryInput(input)
const binding = fingerprint({ input, cn: settings.cn, overseas: settings.overseas,
const binding = fingerprint({ input, environment: settings.environment, apiBase: settings.apiBase,
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, '缺少国内或海外令牌')
check(login?.accessToken, '缺少统一 accessToken')
const root = '/platform/v1/last-leg'
const delivery = `${root}/delivery-orders/${input.deliveryNo}`
const scoped = path => `${path}?customerId=${settings.customer.id}`
@@ -161,13 +163,13 @@ export async function executeDelivery(settings, input, journalPath, fetcher = fe
: 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),
login.accessToken, 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 }) }
try { response = await fetcher(settings.apiBase + path, { method: 'POST', redirect: 'error',
signal: AbortSignal.timeout(20_000), headers: { Authorization: `Bearer ${login.accessToken}` }, body: form }) }
catch { throw new Error('签收上传连接失败或超时;未自动重试') }
const data = await response.json()
const safe = value => /^[\w-]{1,100}$/.test(String(value)) ? value : 'unavailable'
+47 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runDeliveryStage, validateDeliveryInput, verifyOutbound, withDeliveryJournal } from './prod-release-delivery-stage.mjs'
import { executeDelivery, runDeliveryStage, validateDeliveryInput, verifyOutbound, withDeliveryJournal } from './prod-release-delivery-stage.mjs'
const input = { batch: 'acceptance-1234', orderId: '101', orderNo: 'FL-101', tailOrderId: '201',
warehouseId: '301', inboundCode: 'IN-101', dispatchNo: 'DS-101', outboundNo: 'OB-101', deliveryNo: 'DL-101', packageCount: 1 }
@@ -112,3 +112,49 @@ test('journal rejects changed binding and concurrent processes', async () => {
await assert.rejects(withDeliveryJournal(path, 'changed', async () => {}), /不符/)
} finally { await rm(dir, { recursive: true, force: true }) }
})
test('missing or malformed delivery version blocks status write and upload', async () => {
for (const version of [undefined, null, '', ' ', false, -1, '9007199254740993']) {
const h = harness(), original = h.options.request
h.options.request = async (...args) => {
const result = await original(...args)
if (result?.order?.deliveryNo) result.order.version = version
return result
}
await assert.rejects(runDeliveryStage(h.options), /版本缺失或无效/)
assert.equal(h.writes.length, 0)
assert.equal(h.uploads(), 0)
}
})
test('delivery executor uses one gateway/accessToken for status, multipart receipt and domestic reconciliation', async t => {
const dir = await mkdtemp(join(tmpdir(), 'single-delivery-test-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const h = harness(), calls = []
const settings = { environment: 'gray', apiBase: 'https://fixture.invalid/api', customer: { id: '501' },
credentials: { ADMIN: { loginId: 'admin-fixture', password: 'secret-fixture' } } }
const fetcher = async (url, options) => {
assert.ok(url.startsWith(settings.apiBase + '/'))
const path = url.slice(settings.apiBase.length)
calls.push(path)
let data
if (path === '/auth/login') data = { accessToken: 'single-access' }
else {
assert.equal(options.headers.Authorization, 'Bearer single-access')
if (options.body instanceof FormData) {
assert.equal(path, '/platform/v1/last-leg/delivery-orders/DL-101/documents/RECEIPT/files?customerId=501')
assert.match(await options.body.get('file').text(), /NOT A REAL PROOF/)
data = { fileId: '701' }
} else {
data = await h.options.request(path.startsWith('/platform/v1/last-leg/') ? 'overseas' : 'cn',
options.method, path, options.body ? JSON.parse(options.body) : undefined)
}
}
return { ok: true, status: 200, json: async () => ({ code: '200', data }) }
}
const result = await executeDelivery(settings, input, join(dir, 'journal.json'), fetcher)
assert.equal(result.status, 'DELIVERY_STAGE_PASSED')
assert.equal(result.fullFlowVerified, false)
assert.equal(calls.filter(path => path === '/auth/login').length, 1)
})
+231
View File
@@ -0,0 +1,231 @@
import { fingerprint } from './prod-release-order-stage.mjs'
import { oceanBookingDraft } from './admin-test-env-modules/ocean-booking-fixture.mjs'
import { iso6346ContainerNumber } from './admin-test-env-modules/iso6346-container-number.mjs'
import { channelGroup } from './admin-test-env-modules/smart-loading-channel-group.mjs'
const assert = (value, message) => { if (!value) throw Error(message) }
const id = value => value == null ? '' : String(value)
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==', 'base64')
const NODES = ['DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY']
const localTime = () => new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 19)
function fileForm(name, content = PNG, type = 'image/png', fields = {}) {
const form = new FormData()
for (const [key, value] of Object.entries(fields)) form.append(key, String(value))
form.append('file', new Blob([content], { type }), name)
return form
}
export function domesticSuccess(response) {
if (response?.http !== 200 || String(response?.body?.code) !== '200') {
const error = Error(`国内履约请求失败 HTTP=${response?.http} code=${response?.body?.code}`)
error.evidence = { http: response?.http, code: response?.body?.code, message: response?.body?.message,
requestId: response?.requestId, traceId: response?.traceId }
throw error
}
return response.body.data
}
// Persist intent before invoking transport. Never retry a partially observed write.
export function domesticCommands(checkpoint, binding) {
const { state, save } = checkpoint
assert(!state.binding || state.binding === binding, '国内检查点绑定发生变化')
return async (name, command, execute) => {
const previous = state.commands?.[name]
if (previous?.status === 'DONE') return previous.result
assert(!previous, `国内写入 ${name} 结果未知,必须人工核对原请求,禁止自动重放`)
const intent = { status: 'INTENT', command, fingerprint: fingerprint(command) }
const persist = async value => {
const patch = { binding, commands: { ...state.commands, [name]: value } }
await save(patch); Object.assign(state, patch)
}
await persist(intent)
const result = await execute()
await persist({ ...intent, status: 'DONE', result })
return result
}
}
export async function runDomesticStage({ input, order, context, checkpoint }) {
const config = input.domestic
const cargo = input.payload.cargoes[0]
assert(order?.status === 'ORDER_APPROVED' && order.orderId && order.inboundCode, '必须使用本票已审核订单')
assert(cargo.packageCount >= 1 && cargo.packageCount <= 2 && input.payload.cargoes.length === 1, '国内阶段仅支持本票一个 SKU、1~2 箱')
assert(input.payload.taxMode === 'BUY_DECLARATION', '当前专票须为买单报关,不自动改写报关方式')
assert(config?.palletId && config.assigneeId && config.bookingAgent?.id && config.carrier?.id
&& config.carrier.carrierCode && config.estimatedSailingAt && config.estimatedArrivalAt
&& config.terminalFleet?.id && config.terminalFleet.name, '缺少已有国内资源、目的港车队或明确船期')
assert(context.token && context.pdaToken && context.merchantToken, '缺少已登录平台、国内 PDA 或客户会话')
const binding = fingerprint({ input, orderId: order.orderId })
const command = domesticCommands(checkpoint, binding)
const tenantId = input.customer.id, warehouseId = input.origin.id
const q = `tenantId=${encodeURIComponent(tenantId)}`
const key = name => `release-${fingerprint({ binding, name }).slice(0, 48)}`
const get = async (path, token = context.token) => domesticSuccess(await context.request('GET', path, undefined, token))
const write = (name, method, path, body, token = context.token) => command(name, { method, path, body, idempotencyKey: key(name) },
async () => domesticSuccess(await context.request(method, path, body, token, { 'Idempotency-Key': key(name) })))
const upload = (name, path, form, token = context.token) => command(name, { method: 'POST', path, idempotencyKey: key(name), testEvidence: name },
async () => domesticSuccess(await context.requestMultipart('POST', path, form, token, { 'Idempotency-Key': key(name) })))
const poll = async (read, condition, description) => {
for (let i = 0; i < (context.pollAttempts || 45); i++) {
const data = await read()
if (condition(data)) return data
await (context.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms))))(1000)
}
throw Error(`国内异步事实未就绪:${description}`)
}
const unified = await poll(() => get(`/api/platform/v1/orders/${order.orderId}?${q}`), data => data.summary?.inboundId, '订单入库投影')
const inboundId = id(unified.summary.inboundId)
const inboundPath = `/api/platform/inbounds/${inboundId}?${q}`
const base = `/api/platform/v1/first-leg/inbounds/${inboundId}`
const measurementPath = `${base}/measurement?${q}`
const inbound = await get(inboundPath)
assert(id(inbound.summary?.tenantId) === tenantId && inbound.summary.inboundCode === order.inboundCode
&& id(inbound.summary?.orderId) === order.orderId, '入库单客户、订单或入仓码不属于本票')
await write('accept', 'POST', `/api/platform/v1/orders/first-leg/${order.orderId}/accept?${q}`)
await write('start', 'POST', `/api/platform/v1/orders/first-leg/${order.orderId}/start?${q}`)
await command('carton-labels', { method: 'POST', path: `/api/platform/inbounds/${inboundId}/carton-label-pdf?${q}` }, async () => {
const result = await context.requestBinary('POST', `/api/platform/inbounds/${inboundId}/carton-label-pdf?${q}`)
assert(result.http === 200 && String(result.contentType).includes('pdf') && Buffer.from(result.bytes || []).subarray(0, 4).toString() === '%PDF', '本票箱唛未成功生成')
return { generated: true }
})
const scanForm = new FormData()
scanForm.append('data', new Blob([JSON.stringify({ boxCode: `${order.inboundCode}#${cargo.packageCount}-1`,
length: cargo.lengthCm, width: cargo.widthCm, height: cargo.heightCm,
machineNo: 'TEST-RELEASE-SIMULATED', measuredAt: localTime() })], { type: 'application/json' }))
scanForm.append('image', new Blob([PNG], { type: 'image/png' }), 'TEST-simulated-carton.png')
const scan = await upload('carton-scan', `${base}/measurement/carton-scans?${q}`, scanForm)
assert(scan?.itemId && scan.measurementId, '测量扫描未生成本票明细')
await write('carton-count', 'PUT', `${base}/measurement/items/${scan.itemId}/confirmed-package-count?${q}`,
{ measurementVersion: Number(scan.measurementVersion), confirmedPackageCount: cargo.packageCount })
const measured = await get(measurementPath)
assert(measured.items?.length === 1 && id(measured.items[0].id) === id(scan.itemId), '本票测量明细数量不符')
const item = measured.items[0]
await write('measurement-draft', 'PUT', `${base}/measurement/draft?${q}`, {
measurementId: measured.measurementId, version: Number(measured.version), actualGoodsName: cargo.goodsName || cargo.skuNo,
items: [{ inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification,
packageCount: cargo.packageCount, lengthCm: cargo.lengthCm, widthCm: cargo.widthCm, heightCm: cargo.heightCm,
intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning ?? null,
outerLengthCm: cargo.lengthCm, outerWidthCm: cargo.widthCm, outerHeightCm: cargo.heightCm,
coefficientMode: 'ADD', remark: `${input.batch} TEST模拟测量,非实测证据` }] })
const weightFile = await upload('weight-file', `/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, fileForm('TEST-weight.png'))
const weight = await write('weight-create', 'POST', `${base}/weight-results?${q}`, { fileId: weightFile.fileId, palletId: config.palletId,
ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null, ocrWeightValue: null, ocrWeightUnit: null, confidence: null })
assert(weight.palletTareWeightKg != null && Number(weight.palletTareWeightKg) >= 0, '缺少已有托盘皮重快照')
await write('weight-confirm', 'POST', `${base}/weight-results/${weight.id}/confirm?${q}`, {
version: Number(weight.version), adjustedWeightValue: Number(cargo.grossWeightKg) + Number(weight.palletTareWeightKg),
adjustedWeightUnit: 'KG', adjustmentReason: `${input.batch} TEST模拟称重,申报货重加托盘皮重` })
const ready = await get(measurementPath)
await write('measurement-submit', 'POST', `${base}/measurement/submit?${q}`, { measurementId: ready.measurementId, version: Number(ready.version) })
const pending = await get(inboundPath)
await write('measurement-approve', 'POST', `/api/platform/v1/inbounds/${inboundId}/measurements/approve?${q}`,
{ measurementId: ready.measurementId, expectedVersion: Number(pending.summary.version), reason: `${input.batch} TEST专票流程` })
const approved = await poll(() => get(measurementPath), value => value.processStatus === 'WAIT_SPLIT'
|| checkpoint.state.commands?.['plan-assign']?.status === 'DONE', '测量审核待分柜')
assert(approved.measurementStatus === 'APPROVED' && approved.measureReviewStatus === 'APPROVED'
&& Number(approved.actualPackageCount) === cargo.packageCount
&& Math.abs(Number(approved.actualNetWeightKg) - cargo.grossWeightKg) <= 0.001, '测量审核、箱数或净重不符')
const latestFee = await context.request('GET', `/api/platform/v1/inbounds/${inboundId}/fees/latest?${q}`, undefined, context.token)
if (latestFee.http === 200 && String(latestFee.body?.code) === 'A0404') {
await write('fee-calculate', 'POST', `/api/platform/v1/inbounds/${inboundId}/fees/calculate?${q}`)
} else domesticSuccess(latestFee)
await write('fee-confirm', 'POST', `/api/platform/v1/inbounds/${inboundId}/fees/confirm?${q}`)
const sent = await write('fee-send', 'POST', `/api/platform/v1/inbounds/${inboundId}/fees/send?${q}`)
assert(sent.feeStatus === 'SENT', '头程费用未发送客户')
const billing = `/api/merchant/v1/first-leg/orders/${order.orderId}/inbound-billing`
const customerFee = await write('customer-fee-confirm', 'POST', `${billing}/confirm`, { expectedFeeVersion: Number(sent.feeVersion) }, context.merchantToken)
assert(customerFee.customerConfirmationStatus === 'CONFIRMED', '客户费用未确认')
const containers = '/api/platform/v1/containers', smart = '/api/platform/v1/first-leg/smart-loading', pda = '/api/platform/v1/first-leg/pda-loading'
const simulation = await write('container-create', 'POST', containers, { channelGroupCode: channelGroup(input.payload.channelCode), warehouseId,
containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 })
const containerId = id(simulation.id)
const booking = await write('booking-create', 'POST', '/api/platform/v1/ocean-bookings', { expectedVersion: 0,
data: oceanBookingDraft({ marker: input.batch }, input.batch, { customerId: tenantId, customerName: input.customer.name,
channelCode: input.payload.channelCode, warehouseId }, { shippingCompanyId: config.carrier.id, shippingCompanyName: config.carrier.name,
carrierCode: config.carrier.carrierCode, bookingAgentId: config.bookingAgent.id, bookingAgentName: config.bookingAgent.name,
estimatedSailingAt: config.estimatedSailingAt, estimatedArrivalAt: config.estimatedArrivalAt }) })
const oceanBookingId = id(booking.oceanBooking.id)
const document = await upload('booking-document', `/api/platform/v1/ocean-bookings/${oceanBookingId}/documents/upload`,
fileForm('TEST-booking-confirmation.txt', `${input.batch} TEST模拟订舱确认文件,非真实单证`, 'text/plain',
{ expectedVersion: booking.oceanBooking.version, documentType: 'BOOKING_CONFIRMATION' }))
const released = await write('booking-release', 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/release`,
{ expectedVersion: Number(document.bookingVersion), reason: `${input.batch} TEST` })
assert(released.oceanBooking?.status === 'RELEASED', '订舱未放舱')
const requirement = released.containerRequirements?.find(value => value.containerType === '40HQ')
assert(requirement?.id, '订舱缺少40HQ需求')
const containerNo = iso6346ContainerNumber(input.batch)
await write('booking-link', 'POST', `${containers}/${containerId}/ocean-booking-link`, { oceanBookingId,
requirementId: id(requirement.id), containerNo, expectedVersion: Number(simulation.version) })
const candidates = await get(`${smart}/candidates?bookingId=${containerId}&warehouseId=${warehouseId}`)
const candidate = candidates.find(value => id(value.orderId) === order.orderId && id(value.accountId) === tenantId)
// After assignment a resumed run uses the captured candidate, never another order.
const assignedBefore = checkpoint.state.commands?.['plan-assign']
assert(candidate || assignedBefore?.status === 'DONE', '本票不在可分柜候选中')
const plan = await get(`${smart}/plans/by-booking/${containerId}`), planId = id(plan.id)
const assigned = await write('plan-assign', 'POST', `${smart}/plans/${planId}/assignments`, {
inboundId, inboundDetailId: id(candidate?.inboundDetailId), packageCount: cargo.packageCount,
expectedVersion: Number(plan.version), operationId: key('plan-assign') })
assert(assigned.items?.length === 1 && Number(assigned.items[0].packageCount) === cargo.packageCount, '装柜计划混入其他货物或数量不符')
const confirmable = await get(`/api/platform/v1/first-leg/loading-plans/${planId}?warehouseId=${warehouseId}`)
const confirmed = await write('plan-confirm', 'POST', `/api/platform/v1/first-leg/loading-plans/${planId}/confirm`,
{ expectedVersion: Number(confirmable.summary.version), assigneeId: config.assigneeId })
const taskId = id(confirmed.readiness?.pdaTaskId)
assert(taskId && confirmed.confirmationStatus === 'CONFIRMED', '国内 PDA 任务未生成')
const claimed = await write('pda-claim', 'POST', `${pda}/tasks/${taskId}/claim`,
{ expectedAssignmentVersion: 0, operationId: key('pda-claim') }, context.pdaToken)
assert(claimed.items?.length === 1, '国内 PDA 任务货物不唯一')
const snapshot = await get(`${pda}/tasks/${taskId}/inventory-snapshot?warehouseId=${warehouseId}`, context.pdaToken)
const picked = await write('pda-pick', 'POST', `${pda}/tasks/${taskId}/batch`, { warehouseId, loadingPlanId: planId,
containerBookingId: containerId, expectedVersion: Number(snapshot.taskVersion), inventorySnapshotToken: snapshot.snapshotToken,
items: [{ itemId: id(claimed.items[0].id), action: 'PICK', pickedPackageCount: cargo.packageCount, version: Number(claimed.items[0].version) }] }, context.pdaToken)
for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) {
const params = new URLSearchParams({ warehouseId, expectedVersion: '0', evidenceType, remark: `${input.batch} TEST模拟凭证` })
if (evidenceType === 'SEAL') params.set('sealNo', `TEST-${input.batch}`.slice(0, 64))
await upload(`loading-${evidenceType}`, `${pda}/tasks/${taskId}/evidence?${params}`, fileForm(`TEST-${evidenceType}.png`), context.pdaToken)
}
await write('pda-complete', 'POST', `${pda}/tasks/${taskId}/complete`, { expectedTaskVersion: Number(picked.version),
operationId: key('pda-complete'), remark: `${input.batch} TEST装柜` }, context.pdaToken)
await poll(() => get(`${pda}/tasks/${taskId}?warehouseId=${warehouseId}`, context.pdaToken),
value => value.taskStatus === 'COMPLETED' && value.workflowStatus === 'COMPLETED', '国内装柜完成')
const customsPath = `/api/platform/v1/customs/loading-plans/${planId}`
let customs = await get(customsPath)
assert(customs.inbounds?.length === 1 && id(customs.inbounds[0].inboundId) === inboundId
&& customs.inbounds.every(value => value.taxMode === 'BUY_DECLARATION'), '报关聚合不是本票买单货物')
if (customs.status === 'READY_TO_START') customs = await write('customs-start', 'POST', `${customsPath}/start`,
{ expectedVersion: Number(customs.version), confirm: true, remark: `${input.batch} TEST` })
if (customs.status === 'DECLARING') customs = await write('customs-complete', 'POST', `${customsPath}/complete-all-buy`,
{ expectedVersion: Number(customs.version), confirm: true, remark: `${input.batch} TEST` })
assert(customs.status === 'COMPLETED', '国内报关未完成')
const outboundPath = `/api/platform/v1/first-leg/outbound-dispatches/${containerId}`
const outbound = await poll(() => get(outboundPath), value => value.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING', '报关自动国内离仓')
assert(outbound.aggregate.inbounds?.length === 1 && id(outbound.aggregate.inbounds[0].inboundId) === inboundId, '离仓货物归属不符')
const queries = await poll(() => get(`/api/platform/v1/carrier-schedule-queries?containerBookingId=${containerId}&page=1&size=20`),
value => value.records?.some(row => row.query?.triggerSource === 'OUTBOUND_CONFIRM'), '离仓船节点任务')
const owned = queries.records.filter(row => id(row.query?.containerBookingId) === containerId && row.query.triggerSource === 'OUTBOUND_CONFIRM')
assert(owned.length === 1, '本柜船节点任务不唯一')
const carrierQueryId = id(owned[0].query.id), carrierPath = `/api/platform/v1/carrier-schedule-queries/${carrierQueryId}`
let carrierDetail = await get(carrierPath)
let start = NODES.indexOf(carrierDetail.query?.query?.latestActualNode)
assert(start >= 0, '缺少自动离仓起点')
for (let index = start + 1; index < NODES.length; index++) {
const latest = carrierDetail.query.query
// Current observed wall clock; actual timestamps advance from the authoritative last node.
const actualAt = new Date(Date.parse(`${latest.latestActualAt}Z`) + 1000).toISOString().slice(0, 19)
carrierDetail = await write(`carrier-${NODES[index]}`, 'POST', `${carrierPath}/manual-nodes`, {
expectedVersion: Number(latest.version), standardNode: NODES[index], observedAt: localTime(), actualAt, estimatedAt: null,
locationCode: 'USLAX', locationName: 'Los Angeles', reason: `${input.batch} TEST模拟船节点 ${NODES[index]}` })
}
assert(carrierDetail.query?.query?.latestActualNode === 'PICKUP_READY', '船节点未到可提柜')
const terminalPath = `/api/platform/v1/terminal-drayages/${containerId}`
const initialTerminal = await get(terminalPath)
assert(id(initialTerminal.containerBookingId) === containerId && initialTerminal.containerNo === containerNo, '目的港拖车货柜不符')
assert(!initialTerminal.fleetSupplierId || checkpoint.state.commands?.['terminal-fleet']?.status === 'DONE', '禁止更换既有车队触发邮件')
const selected = await write('terminal-fleet', 'POST', `${terminalPath}/fleet`, { expectedVersion: Number(initialTerminal.version),
fleetSupplierId: config.terminalFleet.id, fleetSupplierName: config.terminalFleet.name, reason: `${input.batch} TEST` })
const appointmentAt = new Date(Date.parse(`${carrierDetail.query.query.latestActualAt}Z`) + 3600000).toISOString().slice(0, 19)
const scheduled = await write('terminal-appointment', 'POST', `${terminalPath}/pickup-appointment`, {
expectedVersion: Number(selected.version), pickupAppointmentAt: appointmentAt, reason: `${input.batch} TEST` })
assert(scheduled.pickupAppointmentAt === appointmentAt, '目的港提柜预约未持久化')
return { status: 'DOMESTIC_PICKUP_SCHEDULED', orderId: order.orderId, orderNo: order.orderNo, tenantId, warehouseId,
inboundId, inboundCode: order.inboundCode, containerId, containerBookingId: containerId, containerNo,
oceanBookingId, planId, loadingPlanId: planId, taskId, carrierQueryId, packageCount: cargo.packageCount,
terminalDrayageVersion: Number(scheduled.version), pickupAppointmentAt: appointmentAt }
}
@@ -0,0 +1,122 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { domesticCommands, domesticSuccess, runDomesticStage } from './prod-release-domestic-stage.mjs'
const journal = () => {
const state = {}, saves = []
return { state, saves, async save(patch) { saves.push(structuredClone(patch)); Object.assign(state, patch) } }
}
test('uncertain writes retain original intent and cannot replay', async () => {
const checkpoint = journal(), run = domesticCommands(checkpoint, 'bound')
let count = 0
const execute = async () => { count++; assert.equal(checkpoint.state.commands.create.status, 'INTENT'); throw Error('network') }
await assert.rejects(run('create', { path: '/create' }, execute), /network/)
await assert.rejects(run('create', { path: '/create' }, execute), /结果未知/)
assert.equal(count, 1)
})
test('completed writes reuse exact saved result and reject another binding', async () => {
const checkpoint = journal(), run = domesticCommands(checkpoint, 'bound')
const first = await run('create', { path: '/create' }, async () => ({ id: '17' }))
assert.deepEqual(await run('create', { path: '/create' }, () => { throw Error('must not run') }), first)
assert.throws(() => domesticCommands(checkpoint, 'other'), /绑定/)
})
test('business rejection retains diagnostic evidence and cannot count as success', () => {
assert.throws(() => domesticSuccess({ http: 200, body: { code: 'A0409', message: 'version' }, requestId: 'r', traceId: 't' }),
error => error.evidence.code === 'A0409' && error.evidence.requestId === 'r' && error.evidence.traceId === 't')
assert.throws(() => domesticSuccess({ http: 503, body: { code: '200' } }))
})
function fixture() {
const input = { batch: 'release-test-001', customer: { id: '1', name: 'TEST' }, origin: { id: '2' },
payload: { taxMode: 'BUY_DECLARATION', channelCode: 'MATSON_CLX', cargoes: [{ skuNo: 'TEST-SKU', packageCount: 2,
lengthCm: 30, widthCm: 20, heightCm: 10, grossWeightKg: 8 }] },
domestic: { palletId: '3', assigneeId: '4', bookingAgent: { id: '5', name: 'AGENT' },
carrier: { id: '6', name: 'CARRIER', carrierCode: 'MATSON' }, terminalFleet: { id: '7', name: 'FLEET' },
estimatedSailingAt: '2026-10-12T08:00:00', estimatedArrivalAt: '2026-10-20T08:00:00' } }
const order = { status: 'ORDER_APPROVED', orderId: '10', orderNo: 'TEST-ORDER', inboundCode: 'TEST-INBOUND' }
const checkpoint = journal(), calls = []
const context = { token: 'platform-test', merchantToken: 'merchant-test', pdaToken: 'pda-test', pollAttempts: 1, sleep: async () => {} }
let containerNo, carrierNode = 'DEPARTED_WAREHOUSE', carrierVersion = 0, latestActualAt = '2026-10-12T10:00:00'
let measured = false
const carrier = () => ({ query: { query: { latestActualNode: carrierNode, version: carrierVersion, latestActualAt } } })
context.request = async (method, path, body, token, headers) => {
calls.push({ method, path, body, token, headers })
let data
if (path === '/api/platform/v1/orders/10?tenantId=1') data = { summary: { inboundId: '20' } }
else if (path === '/api/platform/inbounds/20?tenantId=1') data = { summary: { tenantId: '1', orderId: '10', inboundCode: 'TEST-INBOUND', version: 1 } }
else if (path.includes('/measurement?')) data = { measurementId: '30', version: 2, items: [{ id: '31', inboundDetailId: '32', itemNo: 1 }],
processStatus: measured ? 'WAIT_SPLIT' : 'MEASURING', measurementStatus: 'APPROVED', measureReviewStatus: 'APPROVED', actualPackageCount: 2, actualNetWeightKg: 8 }
else if (path.includes('/measurements/approve')) { measured = true; data = {} }
else if (path.includes('/weight-results?')) data = { id: '40', version: 0, palletTareWeightKg: 2 }
else if (path.includes('/fees/send')) data = { feeStatus: 'SENT', feeVersion: 1 }
else if (path.includes('/inbound-billing/confirm')) data = { customerConfirmationStatus: 'CONFIRMED' }
else if (path === '/api/platform/v1/containers') data = { id: '50', version: 0 }
else if (path === '/api/platform/v1/ocean-bookings') data = { oceanBooking: { id: '60', version: 0 } }
else if (path.includes('/ocean-bookings/60/release')) data = { oceanBooking: { status: 'RELEASED' }, containerRequirements: [{ id: '61', containerType: '40HQ' }] }
else if (path.includes('/ocean-booking-link')) { containerNo = body.containerNo; data = {} }
else if (path.includes('/smart-loading/candidates')) data = [{ orderId: '10', accountId: '1', inboundDetailId: '32' }]
else if (path.includes('/plans/by-booking/')) data = { id: '70', version: 0 }
else if (path.includes('/plans/70/assignments')) data = { items: [{ id: '71', packageCount: 2 }] }
else if (path.includes('/loading-plans/70/confirm')) data = { confirmationStatus: 'CONFIRMED', readiness: { pdaTaskId: '80' } }
else if (path.includes('/first-leg/loading-plans/70?')) data = { summary: { version: 1 } }
else if (path.includes('/tasks/80/claim')) data = { items: [{ id: '81', version: 0 }] }
else if (path.includes('/tasks/80/inventory-snapshot')) data = { taskVersion: 1, snapshotToken: 'snapshot' }
else if (path.includes('/tasks/80/batch')) data = { version: 2 }
else if (path.includes('/tasks/80?')) data = { taskStatus: 'COMPLETED', workflowStatus: 'COMPLETED' }
else if (path === '/api/platform/v1/customs/loading-plans/70') data = { inbounds: [{ inboundId: '20', taxMode: 'BUY_DECLARATION' }], status: 'READY_TO_START', version: 0 }
else if (path.endsWith('/customs/loading-plans/70/start')) data = { status: 'DECLARING', version: 1 }
else if (path.endsWith('/complete-all-buy')) data = { status: 'COMPLETED', version: 2 }
else if (path.includes('/outbound-dispatches/50')) data = { aggregate: { dispatchStatus: 'WAITING_FOR_LOADING', inbounds: [{ inboundId: '20' }] } }
else if (path.includes('/carrier-schedule-queries?')) data = { records: [{ query: { id: '90', containerBookingId: '50', triggerSource: 'OUTBOUND_CONFIRM' } }] }
else if (path.endsWith('/carrier-schedule-queries/90')) data = carrier()
else if (path.endsWith('/manual-nodes')) { carrierNode = body.standardNode; carrierVersion++; latestActualAt = body.actualAt; data = carrier() }
else if (path.endsWith('/terminal-drayages/50')) data = { containerBookingId: '50', containerNo, version: 0 }
else if (path.endsWith('/terminal-drayages/50/fleet')) data = { version: 1 }
else if (path.endsWith('/pickup-appointment')) data = { version: 2, pickupAppointmentAt: body.pickupAppointmentAt }
else if (/\/(accept|start)\?/.test(path) || /confirmed-package-count|measurement\/draft|weight-results\/40\/confirm|measurement\/submit|fees\/latest|fees\/confirm|tasks\/80\/complete/.test(path)) data = {}
else throw Error(`unhandled ${method} ${path}`)
return { http: 200, body: { code: '200', data } }
}
context.requestMultipart = async (method, path, form, token, headers) => {
assert.ok(form instanceof FormData)
calls.push({ method, path, token, headers, multipart: true })
let data
if (path.includes('/carton-scans')) data = { itemId: '31', measurementId: '30', measurementVersion: 0 }
else if (path.includes('/files/measurements/')) data = { fileId: '41' }
else if (path.includes('/documents/upload')) data = { bookingVersion: 1 }
else if (path.includes('/tasks/80/evidence')) data = { taskId: '80' }
else throw Error(`unhandled upload ${path}`)
return { http: 200, body: { code: '200', data } }
}
context.requestBinary = async () => ({ http: 200, contentType: 'application/pdf', bytes: Buffer.from('%PDF-test') })
return { input, order, context, checkpoint, calls }
}
test('single-ticket positive HTTP orchestration conserves two cartons and stops at pickup appointment', async () => {
const value = fixture(), result = await runDomesticStage(value)
assert.equal(result.status, 'DOMESTIC_PICKUP_SCHEDULED')
assert.equal(result.inboundId, '20')
assert.equal(result.packageCount, 2)
const batch = value.calls.find(call => call.path.endsWith('/tasks/80/batch'))
assert.equal(batch.body.items[0].pickedPackageCount, 2)
assert.equal(batch.token, value.context.pdaToken)
const weight = value.calls.find(call => call.path.includes('/weight-results/40/confirm'))
assert.equal(weight.body.adjustedWeightValue, 10)
assert.equal(value.calls.filter(call => call.path.endsWith('/manual-nodes')).length, 7)
assert.equal(value.calls.filter(call => call.path.includes('/evidence?')).length, 5)
assert.ok(value.calls.every(call => !/pickup-email|\/suppliers|\/users|\/customers/.test(call.path)))
assert.ok(Object.values(value.checkpoint.state.commands).every(command => command.status === 'DONE'))
})
test('tax mode and customer identity fail closed before unrelated business writes', async () => {
const value = fixture()
value.input.payload.taxMode = 'SELF_DECLARATION'
await assert.rejects(runDomesticStage(value), /买单报关/)
assert.equal(value.calls.length, 0)
})
test('a timeout at first business write preserves intent and stops all later actions', async () => {
const value = fixture(), request = value.context.request
value.context.request = async (...args) => args[0] === 'POST' ? Promise.reject(Error('timeout')) : request(...args)
await assert.rejects(runDomesticStage(value), /timeout/)
assert.equal(value.checkpoint.state.commands.accept.status, 'INTENT')
assert.equal(Object.keys(value.checkpoint.state.commands).length, 1)
})
+93
View File
@@ -0,0 +1,93 @@
import { resolve } from 'node:path'
import { fingerprint, validateOrderInput } from './prod-release-order-stage.mjs'
import { normalizeOrderInput } from './prod-release-measurement-plan.mjs'
import { executeOrder } from './prod-release-acceptance.mjs'
import { executeDelivery } from './prod-release-delivery-stage.mjs'
import { createFlowContext, withFlowJournal, requireSuccess } from './prod-release-http.mjs'
import { runDomesticStage } from './prod-release-domestic-stage.mjs'
import { runOverseasStage, finishSingleOrderTrip } from './prod-release-overseas-stage.mjs'
export async function executeFullFlow(settings, rawInput, journalPath, resume = false, fetcher = fetch) {
const input = { ...normalizeOrderInput(rawInput), customer: settings.customer,
environment: { mode: settings.environment, apiBase: settings.apiBase },
operator: { merchant: settings.credentials.MERCHANT.loginId, admin: settings.credentials.ADMIN.loginId } }
validateOrderInput(input)
const binding = fingerprint({ input, credentials: Object.fromEntries(Object.entries(settings.credentials).map(([key,value])=>[key,value.loginId])) })
return withFlowJournal(journalPath, binding, async (state, save) => {
if (state.status !== 'NEW' && !resume) throw Error('已有全流程检查点,须保持原输入并显式 --resume')
const ctx = createFlowContext({ settings, state, save, fetcher })
ctx.marker = input.batch
const checkpoint = name => {
const local = state.stages[name] || {}
return { state: local, save: async patch => {
const next = { ...local, ...patch }
await save({ stages: { ...state.stages, [name]: next } }); Object.assign(local, next)
} }
}
const bridgeFetch = async (url, options) => {
if (!url.startsWith(settings.apiBase + '/')) throw Error('全流程请求偏离统一Gateway')
const path = '/api' + url.slice(settings.apiBase.length)
const token = options.headers?.Authorization?.replace(/^Bearer /, '')
const body = options.body instanceof FormData ? options.body : options.body ? JSON.parse(options.body) : undefined
const result = await ctx.requestWithBody(options.method, path, body, token ?? null,
options.headers?.['Idempotency-Key'] ? { 'Idempotency-Key': options.headers['Idempotency-Key'] } : {})
if (path === '/api/auth/login' && result.body?.code === '200') ctx.registerSession(result.body.data)
return { ok: result.http >= 200 && result.http < 300, status: result.http, json: async () => result.body }
}
try {
await save({ status: 'RUNNING' })
ctx.token = (await ctx.login('ADMIN')).accessToken
ctx.merchantToken = (await ctx.login('MERCHANT')).accessToken
ctx.pdaToken = (await ctx.login('CN_PDA')).accessToken
ctx.overseasPdaToken = (await ctx.login('OVERSEAS_LAX_PDA')).accessToken
const binding = requireSuccess(await ctx.request('GET', '/api/pda/v1/last-leg/receiving-containers/warehouse-profile', undefined, ctx.overseasPdaToken))
if (binding.warehouseCode !== 'US-LAX-01') throw Error('海外PDA不是洛杉矶接柜仓')
const orderCp = checkpoint('order')
let order = orderCp.state.result
if (!order) {
order = await executeOrder(settings, input, resolve(`${journalPath}.order.json`), resume, bridgeFetch)
await orderCp.save({ result: order })
}
console.log('单票阶段:ORDER_APPROVED')
const domesticCp = checkpoint('domestic')
let domestic = domesticCp.state.result
if (!domestic) {
domestic = await runDomesticStage({ input, order, context: ctx, checkpoint: domesticCp })
await domesticCp.save({ result: domestic })
}
console.log(`单票阶段:${domestic.status}`)
const overseasCp = checkpoint('overseas')
let overseas = overseasCp.state.result
if (!overseas) {
overseas = await runOverseasStage({ input: { ...input, customerId: settings.customer.id,
packageCount: input.payload.cargoes[0].packageCount, warehouseId: binding.warehouseId },
domestic: { ...domestic, sourceInboundId: domestic.inboundId, orderId: order.orderId, orderNo: order.orderNo, inboundCode: order.inboundCode },
context: ctx, checkpoint: overseasCp })
await overseasCp.save({ result: overseas })
}
console.log(`单票阶段:${overseas.status}`)
const delivery = await executeDelivery(settings, { batch: input.batch, ...overseas,
orderId: order.orderId, orderNo: order.orderNo, inboundCode: order.inboundCode,
packageCount: input.payload.cargoes[0].packageCount, warehouseId: binding.warehouseId },
resolve(`${journalPath}.delivery.json`), bridgeFetch)
if (delivery.blockers.length) throw Error(`签收/回传未闭环:${delivery.blockers.join(',')}`)
const tripClosure = await finishSingleOrderTrip({ input: { ...input, customerId: settings.customer.id }, overseas, context: ctx, checkpoint: checkpoint('tripClosure') })
const result = { status: 'SINGLE_ORDER_HTTP_COMPLETED', fullFlowVerified: true,
scope: 'ONE_ORDER_NORMAL_HTTP_PATH', orderNo: order.orderNo, inboundCode: order.inboundCode,
containerNo: domestic.containerNo, dispatchNo: overseas.dispatchNo, deliveryNo: overseas.deliveryNo,
domesticOrderStatus: delivery.domesticOrderStatus, domesticInboundStatus: delivery.domesticInboundStatus,
tripClosureStatus: tripClosure.status,
syntheticEvidence: true, excluded: ['真实货物及设备', '浏览器视觉', '故障注入', '多客户/拆单/异常分支', '真实付款/第三方下单/邮件'],
completedAt: new Date().toISOString() }
await save({ status: 'COMPLETED', result })
return result
} catch (error) {
await save({ status: 'BLOCKED', failure: error.message })
throw error
} finally {
const cleanup = await ctx.close()
if (state.result) state.result.sessionCleanup = cleanup.length ? 'FAILED' : 'COMPLETED'
await save({ cleanup })
}
})
}
+123
View File
@@ -0,0 +1,123 @@
import { createHash } from 'node:crypto'
import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises'
import { dirname } from 'node:path'
const hash = value => createHash('sha256').update(value).digest('hex')
const safe = value => typeof value === 'string' && /^[\w-]{1,100}$/.test(value) ? value : 'unavailable'
export const success = response => response?.http === 200 && response.body?.code === '200'
export function requireSuccess(response) {
if (!success(response)) throw new Error(`HTTP=${response?.http}; code=${safe(response?.body?.code)}; requestId=${safe(response?.requestId)}; traceId=${safe(response?.traceId)}`)
return response.body.data
}
export async function withFlowJournal(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, stages: {}, requests: [], status: 'NEW' }
}
if (state.version !== 1 || state.binding !== binding) throw new Error('全流程检查点绑定变化')
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)
}
await save({})
return await action(state, save)
} finally { await lock.close(); await unlink(`${path}.lock`) }
}
async function bodyDigest(body) {
if (!(body instanceof FormData)) return hash(JSON.stringify(body ?? null))
const parts = []
for (const [key, value] of body) parts.push([key, typeof value === 'string' ? value
: { name: value.name, type: value.type, sha256: hash(Buffer.from(await value.arrayBuffer())) }])
return hash(JSON.stringify(parts))
}
// Only public business HTTP. No fixture runner, SQL, deployment or automatic network retry.
export function createFlowContext({ settings, state, save, fetcher = fetch }) {
const sessions = []
const context = {
marker: '', options: {}, context: {}, createdResources: {}, steps: [],
registerSession(session) { sessions.push(session) },
async request(method, path, body, token = context.token, extraHeaders = {}) {
return send(method, path, body, token, extraHeaders, false)
},
async requestWithBody(method, path, body, token = context.token, extraHeaders = {}) {
return send(method, path, body, token, extraHeaders, false)
},
async requestMultipart(method, path, file, token = context.token, extraHeaders = {}) {
const form = file instanceof FormData ? file : new FormData()
if (!(file instanceof FormData)) {
if (!file || file.content === undefined) throw new Error('缺少上传内容')
form.append('file', new Blob([file.content], { type: file.type || 'application/octet-stream' }), file.name || 'acceptance.bin')
}
return send(method, path, form, token, extraHeaders, false)
},
async requestBinary(method, path, token = context.token) { return send(method, path, undefined, token, {}, true) },
apiSuccess(module, name, response) {
const data = requireSuccess(response)
context.steps.push({ module, name, passed: true })
return data
},
assert(module, name, response, condition) {
context.steps.push({ module, name, passed: Boolean(condition) })
if (!condition) throw new Error(`${module}: ${name}`)
},
async login(role) {
const data = requireSuccess(await send('POST', '/api/auth/login', settings.credentials[role], null, {}, false))
if (!data?.accessToken || !data.refreshToken) throw new Error(`${role}: 登录令牌不完整`)
sessions.push(data)
return data
},
async close() {
const failures = []
for (const session of sessions) {
try { requireSuccess(await send('POST', '/api/auth/logout', { refreshToken: session.refreshToken }, session.accessToken, {}, false)) }
catch { failures.push('SESSION_LOGOUT_FAILED') }
}
return failures
},
}
async function send(method, path, body, token, extraHeaders, binary) {
if (!['GET', 'POST', 'PUT'].includes(method) || !/^\/api\/(auth\/(login|logout)$|platform\/|merchant\/|pda\/)/.test(path)
|| path.includes('..') || path.includes('#')) throw new Error('全流程请求路径或方法不允许')
const auth = path === '/api/auth/login' || path === '/api/auth/logout'
if (path !== '/api/auth/logout' && state.requests.length >= 2500) throw new Error('全流程请求超过安全上限')
const entry = { sequence: state.requests.length + 1, method, path, startedAt: new Date().toISOString(),
status: 'INTENT', ...(auth ? {} : { bodyHash: await bodyDigest(body) }) }
await save({ requests: [...state.requests, entry] })
let result
try {
const response = await fetcher(settings.apiBase.replace(/\/api$/, '') + path, {
method, redirect: 'error', signal: AbortSignal.timeout(45000),
headers: { ...(body !== undefined && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}),
...extraHeaders, ...(token ? { Authorization: `Bearer ${token}` } : {}) },
...(body !== undefined ? { body: body instanceof FormData ? body : JSON.stringify(body) } : {}),
})
if (binary && response.ok && !response.headers.get('content-type')?.includes('json')) {
const bytes = new Uint8Array(await response.arrayBuffer())
result = { http: response.status, bytes, buffer: Buffer.from(bytes), contentType: response.headers.get('content-type'), headers: Object.fromEntries(response.headers) }
} else {
const envelope = await response.json()
result = { http: response.status, body: envelope, requestId: envelope?.requestId || response.headers.get('x-request-id'),
traceId: envelope?.traceId || response.headers.get('x-trace-id') }
}
} catch {
await save({ requests: state.requests.map(row => row.sequence === entry.sequence ? { ...row, status: 'UNKNOWN' } : row) })
throw new Error(`HTTP结果不确定:${method} ${path};禁止自动重试`)
}
await save({ requests: state.requests.map(row => row.sequence === entry.sequence ? { ...row,
status: success(result) || result.bytes ? 'DONE' : 'REJECTED', http: result.http,
code: safe(result.body?.code), requestId: safe(result.requestId), traceId: safe(result.traceId) } : row) })
return result
}
return context
}
+50
View File
@@ -0,0 +1,50 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createFlowContext, requireSuccess } from './prod-release-http.mjs'
const settings = { apiBase: 'https://fixture.invalid/api', credentials: { ADMIN: { loginId: 'fixture', password: 'private' } } }
function harness(fetcher, saveError = false) {
const state = { requests: [] }, saves = []
const save = async patch => { if (saveError) throw Error('disk failed'); Object.assign(state,patch); saves.push(structuredClone(state)) }
return { state, saves, ctx: createFlowContext({ settings, state, save, fetcher }) }
}
test('persists intent before writes and never saves credentials or tokens', async () => {
let h
h = harness(async (_url, options) => {
assert.equal(h.state.requests.at(-1).status, 'INTENT')
return new Response(JSON.stringify({ code: '200', data: { accessToken: 'access-secret', refreshToken: 'refresh-secret' } }))
})
await h.ctx.login('ADMIN')
await h.ctx.close()
assert.doesNotMatch(JSON.stringify(h.state), /private|access-secret|refresh-secret/)
assert.ok(h.state.requests.every(row=>row.status==='DONE'))
})
test('disk failure prevents HTTP and unknown network result is never retried', async () => {
let calls=0
const broken=harness(async()=>{calls++;throw Error('network')},true)
await assert.rejects(broken.ctx.request('POST','/api/platform/v1/example',{}),/disk/)
assert.equal(calls,0)
const h=harness(async()=>{calls++;throw Error('network')})
await assert.rejects(h.ctx.request('POST','/api/platform/v1/example',{}),/结果不确定/)
assert.equal(calls,1)
assert.equal(h.state.requests[0].status,'UNKNOWN')
})
test('business errors retain diagnostic IDs and binary responses preserve bytes',async()=>{
const h=harness(async()=>new Response(JSON.stringify({code:'A0403',requestId:'req-1',traceId:'trace-1',message:'private'})))
const response=await h.ctx.request('GET','/api/platform/v1/example')
assert.throws(()=>requireSuccess(response),/A0403.*req-1.*trace-1/)
assert.doesNotMatch(JSON.stringify(h.state),/private/)
const binary=harness(async()=>new Response('%PDF-fixture',{headers:{'content-type':'application/pdf'}}))
const pdf=await binary.ctx.requestBinary('POST','/api/platform/inbounds/1/carton-label-pdf')
assert.equal(Buffer.from(pdf.bytes).toString(),'%PDF-fixture')
assert.equal(pdf.contentType,'application/pdf')
})
test('request limit blocks business calls but permits session logout', async () => {
const h = harness(async () => new Response(JSON.stringify({ code: '200', data: {} })))
h.ctx.registerSession({ accessToken: 'secret', refreshToken: 'refresh' })
h.state.requests = Array.from({ length: 2500 }, (_, index) => ({ sequence: index + 1, status: 'DONE' }))
await assert.rejects(h.ctx.request('GET', '/api/platform/v1/example'), /安全上限/)
assert.deepEqual(await h.ctx.close(), [])
assert.equal(h.state.requests.at(-1).path, '/api/auth/logout')
assert.equal(h.state.requests.at(-1).status, 'DONE')
})
+2 -2
View File
@@ -2,13 +2,13 @@
export const CARTON_PROFILE = Object.freeze({ lengthCm: 40, widthCm: 30, heightCm: 30, weightKg: 7.5 })
export function cartonTotals(packageCount) {
if (!Number.isInteger(packageCount) || packageCount < 1 || packageCount > 2) throw new Error('生产冒烟仅允许 1~2 箱')
if (!Number.isInteger(packageCount) || packageCount < 1 || packageCount > 2) throw new Error('单票冒烟仅允许 1~2 箱')
return { packageCount, lengthCm: 40, widthCm: 30, heightCm: 30,
grossWeightKg: packageCount * 7.5, volumeCbm: packageCount * 36 / 1000 }
}
export function normalizeOrderInput(input) {
if (!Array.isArray(input?.payload?.cargoes) || input.payload.cargoes.length !== 1) throw new Error('生产冒烟仅支持一个 SKU')
if (!Array.isArray(input?.payload?.cargoes) || input.payload.cargoes.length !== 1) throw new Error('单票冒烟仅支持一个 SKU')
const cargo = input.payload.cargoes[0]
const totals = cartonTotals(cargo.packageCount)
if (cargo.netWeightKg != null && (!Number.isFinite(Number(cargo.netWeightKg))
+4 -1
View File
@@ -36,6 +36,8 @@ export function validateOrderInput(input) {
assert(payload.inboundCode == null && payload.senderAddressId == null && payload.privateDestinationAddressId == null, '不允许手工入仓码或地址副作用')
assert(Array.isArray(payload.cargoes) && payload.cargoes.length === 1, '专属冒烟固定一个 SKU')
const cargo = payload.cargoes[0]
assert(payload.shipmentId && payload.referenceId && cargo.shipmentId && cargo.referenceId,
'FBA 订单及货物行均必须提供 Shipment ID 和 Reference ID')
assert(Number.isInteger(cargo.packageCount) && cargo.packageCount >= 1 && cargo.packageCount <= 2, '冒烟仅允许 1~2 箱')
assert(cargo.woodenProduct === false && cargo.dangerousGoods === false && !cargo.packingGroupNo, '仅支持非木制非危险单 SKU 货物')
assert(longId(cargo.productImageFileId), '需要客户已上传产品图片,不生成虚假证明文件')
@@ -58,7 +60,8 @@ function binding(input) {
// The caller must retain this file between invocations. Never generate a new batch to retry a failed one.
export async function withOrderJournal(path, input, action) {
validateOrderInput(input)
assert(typeof input.environment === 'string' && input.environment.startsWith('https://'), '缺少环境绑定')
assert(['test', 'gray'].includes(input.environment?.mode)
&& typeof input.environment.apiBase === 'string' && input.environment.apiBase.startsWith('https://'), '缺少环境绑定')
await mkdir(dirname(path), { recursive: true })
const lock = await open(`${path}.lock`, 'wx', 0o600)
const temp = `${path}.next`
+9 -6
View File
@@ -7,15 +7,17 @@ import { fingerprint, validateOrderInput, withOrderJournal, runOrderStage, valid
import { executeOrder } from './prod-release-acceptance.mjs'
function fixture() {
return { batch: 'release-fixture-001', environment: 'https://fixture.invalid/api',
return { batch: 'release-fixture-001', environment: { mode: 'gray', apiBase: 'https://fixture.invalid/api' },
operator: { merchant: 'fixture-client', admin: 'fixture-admin' },
customer: { id: '1', name: 'fixture', code: 'fixture' }, origin: { id: '2', code: 'CN-YIWU-01' },
destination: { id: '3', code: 'FIXTURE', type: 'FBA' },
payload: { customerName: 'fixture', receivingWarehouseId: '2', destinationWarehouseId: '3',
shipmentId: 'SHIP-fixture', referenceId: 'REF-fixture',
amazonWarehouseCode: 'FIXTURE', transportMode: 'SEA', destinationType: 'FBA', fulfillmentType: 'STANDARD',
containerMode: 'LCL', pickupMethod: 'SELF_EXPRESS', channelCode: 'FIXTURE', pricingTaxMode: 'TAX_INCLUDED',
customerRemark: 'RELEASE-ACCEPTANCE:release-fixture-001',
cargoes: [{ skuNo: 'fixture-sku', packageCount: 2, unitsPerPackage: 1, totalQuantity: 2,
shipmentId: 'SHIP-fixture', referenceId: 'REF-fixture',
lengthCm: 40, widthCm: 30, heightCm: 30, grossWeightKg: 15, volumeCbm: 0.072,
productImageFileId: '4', woodenProduct: false, dangerousGoods: false }] } }
}
@@ -143,7 +145,7 @@ test('uncertain approval that still reads submitted must not be resent', async t
test('change in environment, account, payload or batch is blocked before HTTP', async t => {
const path = await sandbox(t), input = fixture(), api = server(input)
await run(path, input, api)
for (const change of [i => i.environment = 'https://other.invalid/api', i => i.operator.merchant = 'other',
for (const change of [i => i.environment.apiBase = 'https://other.invalid/api', i => i.environment.mode = 'test', i => i.operator.merchant = 'other',
i => i.payload.channelCode = 'OTHER', i => { i.batch = 'release-fixture-002'; i.payload.customerRemark = `RELEASE-ACCEPTANCE:${i.batch}` }]) {
const other = fixture(); change(other)
const count = api.calls.length
@@ -184,14 +186,15 @@ test('corrupt journal is preserved and fails closed', async t => {
for (const quoteMismatch of [false, true]) {
test(`entry integration: customer/profile/route checks and HTTP headers (quote mismatch=${quoteMismatch})`, async t => {
const path = await sandbox(t), input = fixture(), api = server(input)
const settings = { cn: 'https://fixture.invalid/api', overseas: 'https://fixture.invalid/overseas-api', customer: input.customer,
const settings = { environment: 'gray', apiBase: 'https://fixture.invalid/api', customer: input.customer,
credentials: Object.fromEntries(['ADMIN', 'MERCHANT', 'CN_PDA', 'OVERSEAS_LAX_PDA'].map(role => [role, { loginId: role, password: 'secret' }])) }
const fetcher = async (url, options) => {
const endpoint = new URL(url).pathname.replace(/^\/(overseas-api|api)/, '') + new URL(url).search
assert.ok(url.startsWith('https://fixture.invalid/api/'))
const endpoint = new URL(url).pathname.replace(/^\/api/, '') + new URL(url).search
let data
if (endpoint === '/auth/login') {
const role = JSON.parse(options.body).loginId
data = { accessToken: role, overseasAccessToken: `${role}-overseas` }
data = { accessToken: role }
} else if (endpoint.includes('/iam/')) {
data = { id: '1', tenantCode: 'fixture', tenantName: 'fixture', status: 'ACTIVE', dimensionInitializationStatus: 'READY' }
} else if (endpoint.includes('/warehouse-admin?')) {
@@ -203,7 +206,7 @@ for (const quoteMismatch of [false, true]) {
assert.equal(options.headers.Authorization, 'Bearer CN_PDA')
data = [{ id: '2', warehouseCode: 'CN-YIWU-01' }, { id: '4', warehouseCode: 'CN-DONGGUAN-01' }]
} else if (endpoint.endsWith('/warehouse-profile')) {
assert.equal(options.headers.Authorization, 'Bearer OVERSEAS_LAX_PDA-overseas')
assert.equal(options.headers.Authorization, 'Bearer OVERSEAS_LAX_PDA')
data = { warehouseId: '6', warehouseCode: 'US-LAX-01' }
} else if (/\/platform\/v1\/(pallets|locations)\?/.test(endpoint)) {
data = { total: '1', records: [{ warehouseId: '2', status: 'AVAILABLE' }] }
+283
View File
@@ -0,0 +1,283 @@
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
}
@@ -0,0 +1,158 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { overseasCommands, runOverseasStage, validateOverseasInput, finishSingleOrderTrip } from './prod-release-overseas-stage.mjs'
const input = { batch: 'release-test-001', customerId: '1', warehouseId: '2', packageCount: 1,
resources: { vehicleId: '3', driverId: '4', carrierName: '验收既有车队' } }
const domestic = { containerBookingId: '5', sourceInboundId: '6', orderId: '7', containerNo: 'TEST0000001',
inboundCode: 'IN-TEST', orderNo: 'ORDER-TEST', cartonMarks: ['CARTON-TEST'] }
const ok = data => ({ http: 200, body: { code: '200', data } })
const journal = () => { const state = {}; return { state, save: async patch => Object.assign(state, patch) } }
test('reject multi-ticket quantities and duplicate frozen cartons before HTTP', () => {
assert.throws(() => validateOverseasInput({ ...input, packageCount: 3 }, domestic), /1~2/)
assert.throws(() => validateOverseasInput({ ...input, packageCount: 2 }, { ...domestic, cartonMarks: ['a', 'a'] }), /箱码/)
})
test('command records intent before HTTP; uncertain result blocks resend', async () => {
const checkpoint = journal(); let calls = 0
const { command } = overseasCommands({ request: async () => {
calls++; assert.equal(checkpoint.state.commands.create.status, 'INTENT'); throw new Error('timeout')
} }, checkpoint)
await assert.rejects(command('create', 'POST', '/example', { x: 1 }), /timeout/)
await assert.rejects(command('create', 'POST', '/example', { x: 1 }), /结果未知/)
assert.equal(calls, 1)
})
test('completed command restores result without HTTP and rejects changed request', async () => {
const checkpoint = journal(); let calls = 0
const { command } = overseasCommands({ request: async () => { calls++; return ok({ id: '99' }) } }, checkpoint)
await command('create', 'POST', '/example', { x: 1 })
assert.deepEqual(await command('create', 'POST', '/example', { x: 1 }), { id: '99' })
await assert.rejects(command('create', 'POST', '/example', { x: 2 }), /不符/)
assert.equal(calls, 1)
})
test('unknown prior command blocks even read/other writes on continuation', async () => {
const checkpoint = journal(); checkpoint.state.commands = { lost: { status: 'INTENT' } }
await assert.rejects(runOverseasStage({ input, domestic, checkpoint,
context: { overseasPdaToken: 'pda', merchantToken: 'merchant', request: () => assert.fail('must not call HTTP') } }), /结果未知/)
})
test('only initial current receiving probe accepts HTTP 200 A0404 as no session', async () => {
const calls = []
const helpers = overseasCommands({ request: async (method, path, body, token) => {
calls.push({ method, path, token }); return { http: 200, body: { code: 'A0404' } }
} }, journal())
assert.equal(await helpers.readCurrentReceivingSession('pda'), null)
assert.deepEqual(calls[0], { method: 'GET', path: '/api/pda/v1/last-leg/receiving-containers/current', token: 'pda' })
await assert.rejects(helpers.read('/api/pda/v1/last-leg/receiving-containers/current', 'pda'), /A0404/)
await assert.rejects(helpers.read('/api/platform/v1/last-leg/orders/1'), /A0404/)
})
test('current receiving probe preserves auth, transport and other business errors', async () => {
for (const [http, code] of [[200, 'A0403'], [200, 'A0500'], [401, 'A0404'], [404, 'A0404'], [503, 'A0404']]) {
const helpers = overseasCommands({ request: async () => ({ http, body: { code, requestId: 'request-test' } }) }, journal())
await assert.rejects(helpers.readCurrentReceivingSession('pda'), /request-test/)
}
})
function fixture({ foreignSource = false, destinationType = 'OVERSEAS' } = {}) {
const checkpoint = journal(), calls = []
const session = { containerNo: domestic.containerNo, overseasWarehouseId: '2', totalPackages: 1, pendingPackages: 0,
inbounds: [{ inboundCode: domestic.inboundCode, destinationType }] }
const trip = { trip: { tripNo: 'TRIP-TEST', recordVersion: 1, stops: [] } }
const cargo = [{ warehousePalletId: '9', palletNo: 'PLT-TEST', destinationKey: 'DEST', destinationLabel: 'Destination' }]
const context = { token: 'admin', overseasPdaToken: 'pda', merchantToken: 'merchant', wait: async () => {},
requestWithBody: async (method, path, form, token) => {
calls.push({ method, path, token }); assert.equal(token, 'pda'); assert.ok(form instanceof FormData); return ok({ fileId: '10' })
}, request: async (method, path, body, token) => {
calls.push({ method, path, body, token })
if (path.includes('/master-data/vehicles/')) return ok({ id: '3', region: 'OVERSEAS', status: 'IN_SERVICE', plateNumber: 'TEST' })
if (path.includes('/fleet/drivers/')) return ok({ id: '4', qualification: { canDispatch: true }, details: { name: 'Driver', phone: '10000000000' } })
if (path.includes('/orders?containerNo=')) return ok({ records: [{ sourceContainerBookingId: '5', customerId: foreignSource ? '99' : '1', sourceInboundId: '6',
inboundCode: domestic.inboundCode, overseasWarehouseId: '2', id: '8' }] })
if (path.endsWith('/container-arrival-times/query')) return ok([{ sourceContainerBookingId: '5', version: 0 }])
if (path.includes('/container-preview?')) return ok({ ready: true, totalOrderCount: 1, pendingOrderCount: 1, snapshotVersion: 1, snapshotHash: 'hash' })
if (path.endsWith('/convert-container')) return ok([{ lines: [{ inboundCode: domestic.inboundCode }], pallets: [{ palletNo: 'PLT-TEST', warehousePalletId: '9' }] }])
if (path.endsWith('/current') && method === 'GET') return calls.filter(c => c.path.endsWith('/current')).length === 1
? { http: 200, body: { code: 'A0404' } } : ok(session)
if (path.includes('/receiving-containers/TEST0000001?')) return ok(session)
if (path.endsWith('/pallets/resolve')) return ok({ compatible: true })
if (path.endsWith('/cartons/resolve')) return ok({ inboundCode: domestic.inboundCode })
if (path.endsWith('/terminal-drayages/5')) return ok({ containerBookingId: '5', status: 'UNLOADED',
version: 1, unloadingCompletedAt: '2026-10-12T12:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] })
if (path.endsWith('/empty-return')) return ok({ status: 'EMPTY_RETURNED' })
if (path.endsWith('/candidates?sourceStage=RECEIVED')) return ok([{ sourceStage: 'RECEIVED', inboundCode: domestic.inboundCode,
customerId: '1', overseasWarehouseId: '2', packageCount: 1, planPalletId: '11' }])
if (path.endsWith('/pre-arrange')) return ok({ dispatchNo: 'DISPATCH-TEST' })
if (path.includes('/merchant/v1/last-leg/delivery-orders?')) return ok({ outboundNo: 'OUT-TEST', deliveries: [{ deliveryNo: 'DELIVERY-TEST' }] })
if (path.endsWith('/loading') && method === 'GET') return ok({ cargo, recordVersion: 1, manifestHash: 'manifest' })
if (path.endsWith('/loading') && method === 'POST') return ok({ recordVersion: 2 })
if (path.includes('/fleet/trips')) return ok(trip)
if (path === '/api/pda/v1/last-leg/dispatches/DISPATCH-TEST') return ok({ status: 'COMPLETED', canDepart: true,
items: [{ dispatchItemId: '12', scheduledPackages: 1, pickedPackages: 0, positions: [{ id: '13', availablePackages: 1 }] }] })
if (path === '/api/platform/v1/last-leg/dispatches/DISPATCH-TEST' && method === 'GET') return ok({ items: [{ id: '12', inboundCode: domestic.inboundCode }] })
if (path.includes('/outbound-orders/')) return ok({ outbound: { status: 'OUTBOUNDED' }, pallets: [{ inboundCode: domestic.inboundCode }] })
if (method === 'GET') assert.fail(`unexpected GET ${path}`)
return ok({})
} }
return { input, domestic, context, checkpoint, calls }
}
test('normal single-ticket HTTP chain reaches outbound with existing master resources and PDA identity', async () => {
const f = fixture(), result = await runOverseasStage(f)
assert.equal(result.status, 'OVERSEAS_OUTBOUNDED')
assert.equal(result.fullFlowVerified, false)
assert.equal(result.deliveryNo, 'DELIVERY-TEST')
assert.ok(f.calls.some(row => row.path.endsWith('/receipts/carton') && row.token === 'pda'))
assert.ok(f.calls.some(row => row.path.endsWith('/depart') && row.token === 'pda'))
assert.equal(f.calls.filter(row => row.path.endsWith('/terminal-drayages/5')).length, 1)
assert.equal(f.calls.filter(row => row.path.endsWith('/empty-return')).length, 1)
assert.equal(f.calls.filter(row => row.method !== 'GET' && /\/master-data\/|\/fleet\/drivers|\/iam\//.test(row.path)).length, 0)
assert.ok(Object.values(f.checkpoint.state.commands).every(row => row.status === 'DONE'))
})
test('foreign customer source stops before plan conversion or receiving', async () => {
const f = fixture({ foreignSource: true })
await assert.rejects(runOverseasStage(f), /来源不属于/)
assert.equal(f.calls.filter(row => row.method !== 'GET').length, 0)
})
test('FBA receives cumulative package count without fabricated or resolved carton marks', async () => {
const f = fixture({ destinationType: 'FBA' })
f.domestic = { ...f.domestic }; delete f.domestic.cartonMarks
const result = await runOverseasStage(f)
assert.equal(result.status, 'OVERSEAS_OUTBOUNDED')
const received = f.calls.filter(row => row.path.endsWith('/receipts/bulk'))
assert.equal(received.length, 1)
assert.equal(received[0].body.packageCount, 1)
assert.equal(f.calls.filter(row => /\/cartons\/resolve|\/receipts\/carton/.test(row.path)).length, 0)
})
test('OVERSEAS missing frozen carton marks fails before opening receiving session', async () => {
const f = fixture(); f.domestic = { ...f.domestic }; delete f.domestic.cartonMarks
await assert.rejects(runOverseasStage(f), /正式冻结箱码/)
assert.equal(f.calls.filter(row => /\/prepare\?|\/current\/sign|\/receipts\//.test(row.path)).length, 0)
})
test('signed single ticket closes its trip and releases only its existing resources', async () => {
const checkpoint = journal(), writes = []
const overseas = { tripNo: 'TRIP-TEST', dispatchNo: 'DISPATCH-TEST', deliveryNo: 'DELIVERY-TEST' }
let trip = { tripNo: overseas.tripNo, vehicleId: '3', driverId: '4', recordVersion: 1,
stops: [{ name: 'Origin' }, { name: 'Destination' }] }
const context = { request: async (method, path, body) => {
if (path.includes('/delivery-orders/')) return ok({ order: { customerId: '1', status: 'DELIVERED', dispatchNo: overseas.dispatchNo } })
if (path.endsWith('/completion')) return ok({ canComplete: true, recordVersion: 1, outcomes: [{}] })
if (method === 'POST') {
writes.push({ path, body }); trip = { ...trip, recordVersion: trip.recordVersion + 1 }
if (path.endsWith('/complete')) trip = { ...trip, status: 'COMPLETED', completedAt: '2026-10-12T12:00:00Z' }
else if (body.resourceKind === 'VEHICLE') trip.vehicleRelease = { ended: true }
else trip.driverRelease = { ended: true }
}
return ok({ trip, dispatches: [{ dispatchNo: overseas.dispatchNo }] })
} }
assert.equal((await finishSingleOrderTrip({ input, overseas, context, checkpoint })).status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
assert.equal(writes.length, 3)
assert.deepEqual(writes.map(row => row.body.recordVersion), [1, 2, 3])
})