fix: complete single-order acceptance and add Lingxing WMS integration
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user