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 })
}