Files
baiyue-firstleg/scripts/prod-release-acceptance.mjs
T

279 lines
17 KiB
JavaScript
Raw Normal View History

2026-09-18 18:50:40 +08:00
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'
// Production is deliberately not routed through the multi-customer test 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',
])
2026-09-20 09:17:47 +08:00
// 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'])
2026-09-18 18:50:40 +08:00
export function plan() {
return {
2026-09-20 09:17:47 +08:00
scope: { orders: 1, origins: [...DOMESTIC_WAREHOUSE_CODES], origin: 'CN-YIWU-01', receiving: 'US-LAX-01', sameOrder: true },
2026-09-18 18:50:40 +08:00
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' })),
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 (env.CHONGCHONG_ENV !== 'prod') throw new Error('必须显式配置 CHONGCHONG_ENV=prod')
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 merchant = baseUrl(get('MERCHANT_API_BASE_URL'), '/api')
if (new URL(cn).origin !== new URL(overseas).origin || cn !== merchant) {
throw new Error('当前专属入口要求国内、海外及客户 API 使用同一 Admin 前端代理')
}
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 }
}
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)) throw new Error('请求不在生产前置检查白名单内')
if (++requests > 100) throw new Error('超过单次前置检查请求上限')
let response
try {
response = await fetcher((region === 'cn' ? settings.cn : settings.overseas) + 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} 登录缺少国内 Token`)
}
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 = {}
2026-09-20 09:17:47 +08:00
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)
2026-09-18 18:50:40 +08:00
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)
2026-09-20 09:17:47 +08:00
if (!Array.isArray(cnWarehouses) || DOMESTIC_WAREHOUSE_CODES.some(code =>
!cnWarehouses.some(w => w.warehouseCode === code && id(w.id) === warehouses[code]))) {
throw new Error('国内 PDA 不可访问义乌或东莞仓')
}
2026-09-18 18:50:40 +08:00
const overseasToken = logins.OVERSEAS_LAX_PDA.overseasAccessToken
if (!overseasToken) throw new Error('洛杉矶 PDA 登录缺少海外 Token')
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,
2026-09-20 09:17:47 +08:00
checks: ['客户身份及箱规状态', '四个账号登录', '义乌、东莞及洛杉矶仓', 'PDA 仓库范围', '国内既有托盘及可用库位'],
2026-09-18 18:50:40 +08:00
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: settings.cn,
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) => 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'
|| 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
2026-09-20 09:17:47 +08:00
if (mode === 'delivery-stage') {
if (rest.length !== 3 || rest[2] !== '--confirm-production-delivery') {
throw new Error('delivery-stage 需要输入文件、独立签收检查点、--confirm-production-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
}
2026-09-18 18:50:40 +08:00
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-production-order'
|| rest.length === 4 && rest[3] !== '--resume') throw new Error('order-stage 需要输入文件、检查点、--confirm-production-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];全链路执行尚未开放')
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 })
}