import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { normalizeOrderInput, measurementPlan } from './prod-release-measurement-plan.mjs' // 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', 'CROSS_REGION_SYNC', 'VESSEL_AND_TERMINAL', 'LAX_PDA_RECEIVING', 'LAX_DISPATCH', 'LAX_PDA_OUTBOUND', 'DELIVERY_RECEIPT', 'CN_STATUS_RECONCILIATION', ]) // These are the two domestic warehouses that are currently in the production // master data. The release smoke route still uses Yiwu as its fixed origin, // but the preflight must prove that both domestic operating sites are active // and visible to the domestic PDA account. export const DOMESTIC_WAREHOUSE_CODES = Object.freeze(['CN-YIWU-01', 'CN-DONGGUAN-01']) export function plan() { return { scope: { orders: 1, origins: [...DOMESTIC_WAREHOUSE_CODES], origin: 'CN-YIWU-01', receiving: 'US-LAX-01', sameOrder: true }, 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 }, } } const requireValue = (env, key) => { const value = env[key]?.trim() if (!value) throw new Error(`缺少配置:${key}`) return value } function baseUrl(value, path) { let url try { url = new URL(value) } catch { throw new Error('API 地址格式错误') } if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || url.pathname.replace(/\/$/, '') !== path) throw new Error(`API 必须使用 HTTPS 前端代理 ${path}`) return url.origin + path } export function config(env) { 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 apiBase = baseUrl(get('ADMIN_API_BASE_URL'), '/api') const merchant = baseUrl(get('MERCHANT_API_BASE_URL'), '/api') 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 { environment: env.CHONGCHONG_ENV, apiBase, customer, credentials } } function id(value) { if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) throw new Error('接口 ID 必须为精确十进制字符串') return value } export function assertCustomer(profile, expected) { if (id(profile?.id) !== expected.id || profile.tenantCode !== expected.code || profile.tenantName !== expected.name || profile.status !== 'ACTIVE' || profile.dimensionInitializationStatus !== 'READY') throw new Error('客户身份、审核状态或箱规初始化不符合配置') } // Exact allowlist: adding a business command requires implementing and reviewing a separate executor. export function allowedRequest(method, path, region) { if (method === 'POST') return region === 'cn' && path === '/auth/login' if (method !== 'GET') return false if (region === 'overseas') return path === '/pda/v1/last-leg/receiving-containers/warehouse-profile' if (region !== 'cn') return false return path === '/merchant/v1/iam/tenant/profile' || path === '/platform/v1/first-leg/pda-loading/warehouses' || /^\/platform\/v1\/iam\/tenants\/[1-9]\d*$/.test(path) || /^\/platform\/v1\/warehouse-admin\?page=[1-9]\d*&size=100&warehouseType=(DOMESTIC|OVERSEAS)&status=ACTIVE$/.test(path) || /^\/platform\/v1\/(pallets|locations)\?page=[1-9]\d*&size=100&warehouseId=[1-9]\d*$/.test(path) } export function client(settings, fetcher = fetch, policy = allowedRequest) { let requests = 0 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('超过单阶段请求上限') let response try { 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 } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}), }) } catch { throw new Error('HTTP 连接失败或超时;未自动重试') } let envelope try { envelope = await response.json() } catch { throw new Error('接口未返回 JSON 响应') } if (!response.ok || envelope?.code !== '200') { // Never include response message/body: upstream errors may echo submitted credentials. const safe = value => typeof value === 'string' && /^[\w-]{1,100}$/.test(value) ? value : 'unavailable' throw new Error(`HTTP ${response.status}; code=${safe(envelope?.code)}; requestId=${safe(envelope?.requestId)}; traceId=${safe(envelope?.traceId)}`) } return envelope.data }, } } export async function listAll(http, path, token) { const records = [] for (let page = 1; page <= 20; page++) { const data = await http.request('cn', 'GET', path.replace('{page}', String(page)), token) // PageResult.total is a Java long; LongIdJacksonConfiguration serializes it as a string. if (!Array.isArray(data?.records) || typeof data.total !== 'string' || !/^\d+$/.test(data.total)) throw new Error('分页接口结构不符合预期') records.push(...data.records) if (BigInt(records.length) > BigInt(data.total)) throw new Error('分页总数发生漂移') if (BigInt(records.length) === BigInt(data.total)) return records if (!data.records.length) throw new Error('分页结果提前结束') } throw new Error('分页超过安全上限,不将截断结果视为完整') } 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} 登录缺少统一 accessToken`) } const admin = logins.ADMIN.accessToken assertCustomer(await http.request('cn', 'GET', `/platform/v1/iam/tenants/${settings.customer.id}`, admin), settings.customer) assertCustomer(await http.request('cn', 'GET', '/merchant/v1/iam/tenant/profile', logins.MERCHANT.accessToken), settings.customer) const warehouses = {} for (const code of DOMESTIC_WAREHOUSE_CODES) { const all = await listAll(http, `/platform/v1/warehouse-admin?page={page}&size=100&warehouseType=DOMESTIC&status=ACTIVE`, admin) const matches = all.filter(w => w.warehouseCode === code && w.status === 'ACTIVE') if (matches.length !== 1) throw new Error(`${code} 必须唯一且启用`) warehouses[code] = id(matches[0].id) } for (const code of ['US-LAX-01']) { const all = await listAll(http, `/platform/v1/warehouse-admin?page={page}&size=100&warehouseType=OVERSEAS&status=ACTIVE`, admin) const matches = all.filter(w => w.warehouseCode === code && w.status === 'ACTIVE') if (matches.length !== 1) throw new Error(`${code} 必须唯一且启用`) warehouses[code] = id(matches[0].id) } const cnWarehouses = await http.request('cn', 'GET', '/platform/v1/first-leg/pda-loading/warehouses', logins.CN_PDA.accessToken) if (!Array.isArray(cnWarehouses) || DOMESTIC_WAREHOUSE_CODES.some(code => !cnWarehouses.some(w => w.warehouseCode === code && id(w.id) === warehouses[code]))) { throw new Error('国内 PDA 不可访问义乌或东莞仓') } 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 = {} let reusablePallets for (const resource of ['pallets', 'locations']) { const rows = await listAll(http, `/platform/v1/${resource}?page={page}&size=100&warehouseId=${warehouses['CN-YIWU-01']}`, admin) if (rows.some(row => id(row.warehouseId) !== warehouses['CN-YIWU-01'])) throw new Error('资源查询出现其他仓库数据') counts[resource] = rows.filter(row => resource === 'pallets' || row.status === 'AVAILABLE').length if (!counts[resource]) throw new Error(`义乌仓缺少可复用的 ${resource},不会自动新增`) if (resource === 'pallets') reusablePallets = rows } receiveContext({ logins, warehouses, reusablePallets }) return { ...plan(), status: 'BASIC_PREFLIGHT_PASSED', requests: http.requests, checks: ['客户身份及箱规状态', '四个账号登录', '义乌、东莞及洛杉矶仓', 'PDA 仓库范围', '国内既有托盘及可用库位'], resourceCounts: counts, 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: { 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) => { let context const base = client(settings, fetcher) await preflight(settings, base, value => { context = value }) if (context.warehouses['CN-YIWU-01'] !== bound.origin.id) throw new Error('下单始发仓与义乌主档不符') const root = '/merchant/v1/orders/first-leg' const policy = (method, path, region, body, idempotencyKey) => region === 'cn' && ( method === 'GET' && (path === `${root}/inbound-code-policy` || state.orderId && path === `${root}/${state.orderId}` || path === `/merchant/v1/warehouses/${bound.destination.id}` || path === `/merchant/v1/first-leg/pricing/channels?originWarehouseId=${bound.origin.id}`) || method === 'POST' && (path === '/merchant/v1/first-leg/pricing/estimate' || path === root && state.phase === 'CREATE_INTENT' || state.orderId && path === `${root}/${state.orderId}/submit` && state.phase === 'SUBMIT_INTENT' && Number.isSafeInteger(state.submitCommand?.body?.expectedVersion) && JSON.stringify(body) === JSON.stringify(state.submitCommand.body) && idempotencyKey === state.submitCommand.idempotencyKey || state.orderId && path === `/platform/v1/orders/first-leg/${state.orderId}/approve?tenantId=${settings.customer.id}` && state.phase === 'APPROVE_INTENT')) const http = client(settings, fetcher, policy) const merchant = context.logins.MERCHANT.accessToken return runOrderStage({ input: bound, state, save, resume, authorize: async () => { const detail = await http.request('cn', 'GET', `/merchant/v1/warehouses/${bound.destination.id}`, merchant) const warehouse = detail?.warehouse if (warehouse?.id !== bound.destination.id || warehouse.warehouseCode !== bound.destination.code || warehouse.warehouseType !== 'FBA' || warehouse.status !== 'ACTIVE') throw new Error('目的仓主档不符或未启用') const channels = await http.request('cn', 'GET', `/merchant/v1/first-leg/pricing/channels?originWarehouseId=${bound.origin.id}`, merchant) const matching = Array.isArray(channels) ? channels.filter(c => c.channelCode === bound.payload.channelCode && c.pricingTaxModes?.includes(bound.payload.pricingTaxMode)) : [] if (matching.length !== 1) throw new Error('当前客户没有唯一匹配的渠道报价') const cargo = bound.payload.cargoes[0] const quote = await http.request('cn', 'POST', '/merchant/v1/first-leg/pricing/estimate', merchant, { originWarehouseId: bound.origin.id, channelCode: bound.payload.channelCode, destinationCode: bound.destination.code, destinationType: 'FBA', destinationWarehouseId: bound.destination.id, destinationName: warehouse.warehouseName, pricingTaxMode: bound.payload.pricingTaxMode, actualWeightKg: cargo.grossWeightKg, actualVolumeCbm: cargo.volumeCbm, }) if (!quote || quote.priceBookVersionId !== matching[0].priceBookVersionId || quote.channelCode !== bound.payload.channelCode || quote.pricingTaxMode !== bound.payload.pricingTaxMode || quote.estimatedAmount == null || !Number.isFinite(Number(quote.estimatedAmount)) || Number(quote.estimatedAmount) < 0) { throw new Error('小票报价不匹配或金额无效') } }, transport: ({ role, method, path, body, idempotencyKey }) => http.request('cn', method, path, context.logins[role].accessToken, body, idempotencyKey), }) }) } 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-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('输入和检查点不能使用同一文件') const { executeDelivery } = await import('./prod-release-delivery-stage.mjs') const result = await executeDelivery(config(env), JSON.parse(await readFile(inputPath, 'utf8')), journalPath) console.log(JSON.stringify(result, null, 2)) if (result.blockers.length) process.exitCode = 1 return } if (mode === 'measurement-plan') { if (rest.length !== 1 || !/^[12]$/.test(rest[0])) throw new Error('measurement-plan 需要箱数 1 或 2') const allowedCodes = requireValue(env, 'CHONGCHONG_ACCEPTANCE_PALLET_CODES').split(',').map(v => v.trim()) const settings = config(env) let context await preflight(settings, client(settings), value => { context = value }) console.log(JSON.stringify(measurementPlan({ packageCount: Number(rest[0]), warehouseId: context.warehouses['CN-YIWU-01'], pallets: context.reusablePallets, allowedCodes }), null, 2)) return } if (mode === 'order-stage') { 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')) const result = await executeOrder(config(env), input, journalPath, rest[3] === '--resume') console.log(JSON.stringify(result)) return } 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`) const http = client(settings) let report try { report = await preflight(settings, http) } catch (error) { report = { ...plan(), status: 'PREFLIGHT_FAILED', requests: http.requests, error: error.message } } await mkdir(dirname(reportPath), { recursive: true }) await writeFile(reportPath, JSON.stringify(report, null, 2), { flag: 'wx', mode: 0o600 }) console.log(`${report.status}; fullFlowVerified=false; report=${reportPath}`) if (report.status === 'PREFLIGHT_FAILED') process.exitCode = 1 } 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 }) }