diff --git a/Jenkinsfile b/Jenkinsfile
index d12eddd11..5a1264f5c 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -58,17 +58,22 @@ def commonPathServices = [
def databaseMigrationPaths = ['deploy/sql/']
def downstreamResults = [:]
+def downstreamBuildUrls = [:]
+def downstreamBuildNumbers = [:]
-def sendProductionWeComNotification(String status, String color, String detail) {
+def sendProductionWeComNotification(String title, String status, String color, String detail) {
node('java-builder') {
withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) {
- String sourceVersion = env.IMAGE_TAG ?: env.SOURCE_COMMIT ?: env.GIT_COMMIT ?: '—'
- String content = """## 生产部署通知
-> 项目:${env.JOB_NAME}
+ String sourceVersion = env.IMAGE_TAG ?: env.SOURCE_COMMIT ?: env.GIT_COMMIT ?: '未解析'
+ String duration = currentBuild.durationString?.replace(' and counting', '') ?: '—'
+ String content = """## ${title}
+> 应用:后端服务
+> Job:${env.JOB_NAME}
> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL})
> 提交:${sourceVersion}
> 状态:${status}
-> 详情:${detail}
+${detail}
+> 耗时:${duration}
> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))}
"""
String payloadFile = 'wecom-production-notification.json'
@@ -228,6 +233,10 @@ pipeline {
env.RESOLVED_ENVIRONMENT = resolvedEnvironment
echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
+ // 先记录当前提交,使后续生产校验失败时通知仍能定位版本。
+ env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
+ env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
+
if (env.RESOLVED_ENVIRONMENT == 'production') {
sh '''
set -eu
@@ -255,8 +264,6 @@ pipeline {
error('生产环境禁止跳过测试')
}
- env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
- env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
List selected = []
if (params.BUILD_MODE == 'ALL') {
selected = serviceModules
@@ -427,6 +434,8 @@ pipeline {
]
)
downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN'
+ downstreamBuildUrls[service] = downstreamBuild.absoluteUrl ?: ''
+ downstreamBuildNumbers[service] = downstreamBuild.number?.toString() ?: ''
echo "${service} 执行结果: ${downstreamResults[service]} (${downstreamBuild.absoluteUrl})"
}
}
@@ -459,10 +468,18 @@ pipeline {
script {
if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY && env.NO_SERVICE_CHANGE != 'true') {
try {
+ List deployedServices = env.SELECTED_SERVICES?.split(',')
+ ?.collect { it.trim() }
+ ?.findAll { it } ?: []
+ String serviceSummary = deployedServices.size() <= 3
+ ? deployedServices.join('、')
+ : "${deployedServices.size()} 个服务"
sendProductionWeComNotification(
- '部署成功',
+ '✅ 生产部署成功',
+ '成功',
'info',
- "服务:${env.SELECTED_SERVICES ?: '—'}"
+ "> 结果:${deployedServices.size()} 个服务全部成功\n" +
+ "> 部署服务:${serviceSummary ?: '—'}"
)
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
@@ -474,10 +491,22 @@ pipeline {
script {
if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) {
try {
+ List failedServices = downstreamResults.findAll { service, result -> result != 'SUCCESS' }
+ .collect { service, result -> service }
+ int successCount = downstreamResults.findAll { service, result -> result == 'SUCCESS' }.size()
+ String failedLinks = failedServices.collect { service ->
+ String url = downstreamBuildUrls[service]
+ String number = downstreamBuildNumbers[service]
+ String label = number ? "${service} #${number}" : service
+ url ? "[${label}](${url})" : label
+ }.join('、')
sendProductionWeComNotification(
- '部署部分失败',
+ '⚠️ 生产部署部分失败',
+ '部分失败',
'warning',
- currentBuild.description ?: '部分服务部署未成功,请查看 Jenkins 日志'
+ "> 结果:成功 ${successCount} / 失败 ${failedServices.size()}\n" +
+ "> 失败服务:${failedLinks ?: '未解析'}\n" +
+ '> 说明:点击失败服务可直接查看下游构建日志'
)
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
@@ -490,9 +519,10 @@ pipeline {
if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) {
try {
sendProductionWeComNotification(
- '部署失败',
+ '❌ 生产发布失败',
+ '失败',
'warning',
- '生产发布流程失败,请打开 Jenkins 构建日志查看失败阶段'
+ '> 说明:生产发布流程失败,请打开父构建日志查看失败阶段'
)
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
@@ -505,9 +535,10 @@ pipeline {
if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) {
try {
sendProductionWeComNotification(
- '部署已取消',
+ '⏹ 生产部署已取消',
+ '已取消',
'comment',
- '生产部署流程被人工取消或中止'
+ '> 说明:生产部署流程被人工取消或中止'
)
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md
index e2db4bc29..144127444 100644
--- a/scripts/AGENTS.md
+++ b/scripts/AGENTS.md
@@ -10,6 +10,7 @@
| 类别 | 正式入口 | 管理边界 |
|---|---|---|
+| 生产单票发布验收 | `prod-release-acceptance.mjs`、内部 `prod-release-order-stage.mjs` | 同一票义乌到洛杉矶、客户由外部生产配置精确限定;离线计划、基础只读前置及显式确认的单票创建/提交/审核和本阶段续跑,全链执行未开放;不调用测试造数模块,不创建账号/主档或删除数据;配置和待实现范围见 README |
| Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 通过显式环境文件运行开发或测试环境;默认模块覆盖平台全链路,定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 |
| Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行开发或测试环境;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 |
| Client 快速下单定向验证 | `client-quick-order-test-env-acceptance.mjs` | 使用既有测试客户通过批量草稿接口验证 FBA、海外仓、私人地址、逐行失败和幂等重放;只创建草稿,不提交或审核,不替代 Client 全量入口 |
diff --git a/scripts/README.md b/scripts/README.md
index 817f4a5fa..65210024f 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -12,6 +12,35 @@
## 验收环境变量文件
+### 生产单票发布验收(独立入口,建设中)
+
+称重计算内部模块 `prod-release-measurement-plan.mjs` 统一采用每箱含纸箱、不含托盘 7.5 kg、40×30×30 cm(0.036 m³)。`order-stage` 在载荷绑定及报价前自动按箱数统一申报箱规、货重及总体积;不推算商品净重,已填商品净重若超过含箱货重则阻断。改口径后的已有检查点载荷不一致会停止,不能以换批次方式盲目重跑。
+
+只读计算命令:`node --env-file="<外部生产配置.env>" scripts/prod-release-acceptance.mjs measurement-plan 2`(箱数只允许 1 或 2;执行前仍需生产 HTTP 授权)。外部配置新增 `CHONGCHONG_ACCEPTANCE_PALLET_CODES`,逗号分隔明确允许复用的现有托盘编码;检查所有编码唯一存在于义乌仓,稳定选择列表第一个,读取其实际 `capacityCbm` 和 `weightKg`,不写死容量或皮重。托数按总体积/选中托盘容量向上取整,每托分配整数箱,称重毛重=该托箱数×7.5+皮重;容量不足以容纳完整箱或参数异常时阻断。不创建托盘/库位、不上传称重图片、不确认称重,只向终端输出 `MEASUREMENT_PLAN_ONLY` 计算结果。服务端库位槽数还依赖其默认托容量配置,与此处实际称重托数不是同一字段;后续由服务端分配并回查既有普通位或 A0,不在脚本伪造库位数。
+
+离线验证:`node --test scripts/prod-release-measurement-plan.test.mjs`,覆盖小数容量边界、皮重、箱数/重量守恒、错误仓库和托盘白名单;不证明真实称重写入已实现。
+
+`prod-release-acceptance.mjs` 目标固定为同一票从义乌 `CN-YIWU-01` 到洛杉矶 `US-LAX-01`,客户身份由外部配置精确限定,不调用开发/测试全量造数模块。已实现离线计划、基础前置检查及单票下单提交审核阶段(支持本阶段检查点续跑);**测量至洛杉矶签收尚未实现,当前不能一键全链验收**。前置通过仅返回 `BASIC_PREFLIGHT_PASSED`,订单阶段通过返回 `ORDER_APPROVED`,始终保留 `fullFlowVerified=false`。实现仅经离线测试,不代表生产 HTTP 已验证。
+
+- `node scripts/prod-release-acceptance.mjs plan`:不加载凭据,不访问 HTTP,列出主链阶段与不覆盖范围。
+- `node --env-file="$env:USERPROFILE\.codex\env\chongchong-global\prod-acceptance.env" scripts/prod-release-acceptance.mjs preflight`:获得当次生产 HTTP 授权后运行;登录既有四个账号并查询客户、仓库、PDA 绑定及国内资源。不创建业务数据、主档或账号,不删除数据;登录本身会创建会话及审计记录。
+- 必填 `CHONGCHONG_ENV=prod`;`CHONGCHONG_ADMIN_API_BASE_URL`、`CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL`、`CHONGCHONG_MERCHANT_API_BASE_URL` 分别为同一 HTTPS 前端代理的 `/api`、`/overseas-api`、`/api`。
+- 客户锁定配置:`CHONGCHONG_ACCEPTANCE_CUSTOMER_ID`、`CHONGCHONG_ACCEPTANCE_CUSTOMER_CODE`、`CHONGCHONG_ACCEPTANCE_CUSTOMER_NAME`。四组账号分别使用 `CHONGCHONG_ADMIN_*`、`CHONGCHONG_MERCHANT_*`、`CHONGCHONG_CN_PDA_*`、`CHONGCHONG_OVERSEAS_LAX_PDA_*` 的 `USERNAME` / `PASSWORD`;另需 `CHONGCHONG_OVERSEAS_LAX_PDA_WAREHOUSE_CODE=US-LAX-01`。不修改环境文件,不读取其他环境作为回退。
+- 报告位于 `reports/prod-release-preflight-<时间戳>.json`,不包含账号、Token、真实客户名称或原始响应。网络异常不自动重试;修正问题后可重新执行只读检查,产生新报告,不清理旧报告或业务数据。
+- 单票入口 `order-stage <外部订单输入.json> <本批次检查点.json> --confirm-production-order [--resume]`:须另获当次生产写入授权。自动执行基本前置检查,额外校验指定客户可选的唯一渠道/包税维度与 1~2 箱实际重量、体积报价,然后创建一票、提交、审核;不创建图片、地址、托盘或库位。仅支持 AUTO 入仓码、普通海运 FBA 自送散货、单 SKU 非木制非危险品;目的 FBA 仓显式指定,洛杉矶是后续接柜仓,不是第二票。输入与检查点放仓库外,同一批次始终使用同一检查点文件,禁止并发另开路径或换批次重试。
+- `prod-release-order-stage.mjs` 是上述入口使用的内部订单阶段与检查点实现,不可直接执行。先落盘并同步意图,再调用 HTTP;检查点绑定环境、客户、操作者、批次和载荷摘要,不保存密码/Token/完整申报。创建与提交仅在显式续跑时复用原幂等键;审核无幂等键,响应丢失后只回查,若仍不能确认已审核则停止并要求人工核对,不自动再次审核。崩溃残留 `.lock` 时先确认无运行进程、检查业务状态,再人工解除锁;不得删除业务检查点。
+- 订单输入字段:`batch`(8~48 位字母数字/连字符)、`origin: {id, code: "CN-YIWU-01"}`、`destination: {id, code, type: "FBA"}`、`payload`(`order` 服务 `OrderRequests.SaveRequest` 完整请求)。客户、环境和操作者由外部 env 强制绑定;`payload.customerName` 必须匹配配置,`customerRemark` 必须为 `RELEASE-ACCEPTANCE:`。显式填 `fulfillmentType=STANDARD`、`transportMode=SEA`、`pickupMethod=SELF_EXPRESS`、`containerMode=LCL`,`cargoes` 一行 1~2 箱;已上传的产品图片 ID、申报字段、箱规和总重由本次验收资料提供,不生成资质或物流签收证明。箱数×每箱数量必须等于总件数,箱规×箱数必须等于总体积。
+- 待实现:供应商及海外资源门禁、测量至签收写入、后续阶段归属核验、跨区等待和检查点、所有阶段真实 HTTP 验证。全链 `execute` / `resume` 命令仍拒绝;订单续跑仅使用上述 `order-stage ... --resume`。不得改用测试全量脚本来代替。
+- 离线验证:`node --test scripts/prod-release-acceptance.test.mjs`。一票主链不覆盖多客户混柜、退货异常、真实付款、外部下单、邮件发送或页面交互。
+
+订单阶段与恢复验证:`node --test scripts/prod-release-order-stage.test.mjs`,包括断网响应丢失、原幂等键复用、未知审核结果停机、账号/客户/环境/载荷漂移、并发锁、损坏检查点与入口 HTTP mock。生产运行示例(只到已审核,执行前确认授权,不代表全链完成):
+
+```powershell
+node --env-file="<外部生产配置.env>" scripts/prod-release-acceptance.mjs order-stage "<外部订单输入.json>" "<本批次检查点.json>" --confirm-production-order
+# 中断后保持相同输入、账号和检查点;不要删除检查点或换批次。
+node --env-file="<外部生产配置.env>" scripts/prod-release-acceptance.mjs order-stage "<外部订单输入.json>" "<本批次检查点.json>" --confirm-production-order --resume
+```
+
开发与测试环境的账号、密码和 Gateway 只维护在用户目录的两份外部文件中,不写入仓库:
| 环境 | 文件 |
diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs
index 20084f53b..80909a4e0 100644
--- a/scripts/controller-pipeline-contract.test.mjs
+++ b/scripts/controller-pipeline-contract.test.mjs
@@ -72,12 +72,24 @@ test('production deploy uses DEPLOY as the single explicit release switch', () =
})
test('production deploy reports one aggregated result to WeCom', () => {
+ const sourceCommitIndex = pipeline.indexOf("env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD'")
+ const productionGuardIndex = pipeline.indexOf("if (env.RESOLVED_ENVIRONMENT == 'production')")
+
assert.match(pipeline, /credentialsId: 'wecom-prod-webhook'/)
assert.match(pipeline, /node\('java-builder'\) \{[\s\S]*?WECOM_WEBHOOK/)
assert.match(pipeline, /env\.JOB_BASE_NAME == 'backend-java-prod' && params\.DEPLOY/)
- assert.match(pipeline, /unstable \{[\s\S]*?'\u90e8\u7f72\u90e8\u5206\u5931\u8d25'/)
- assert.match(pipeline, /failure \{[\s\S]*?'\u90e8\u7f72\u5931\u8d25'/)
- assert.match(pipeline, /aborted \{[\s\S]*?'\u90e8\u7f72\u5df2\u53d6\u6d88'/)
+ assert.ok(sourceCommitIndex >= 0, 'source commit must be recorded for notifications')
+ assert.ok(sourceCommitIndex < productionGuardIndex,
+ 'source commit must be recorded before production validation can fail')
+ assert.match(pipeline, /def downstreamBuildUrls = \[:\]/)
+ assert.match(pipeline, /downstreamBuildUrls\[service\] = downstreamBuild\.absoluteUrl/)
+ assert.match(pipeline, /downstreamBuildNumbers\[service\] = downstreamBuild\.number/)
+ assert.match(pipeline, /unstable \{[\s\S]*?'\u26a0\ufe0f \u751f\u4ea7\u90e8\u7f72\u90e8\u5206\u5931\u8d25'/)
+ assert.match(pipeline, /\u7ed3\u679c\uff1a\u6210\u529f \$\{successCount\} \/ \u5931\u8d25 \$\{failedServices\.size\(\)\}/)
+ assert.match(pipeline, /\u5931\u8d25\u670d\u52a1\uff1a\$\{failedLinks/)
+ assert.match(pipeline, /failure \{[\s\S]*?'\u274c \u751f\u4ea7\u53d1\u5e03\u5931\u8d25'/)
+ assert.match(pipeline, /aborted \{[\s\S]*?'\u23f9 \u751f\u4ea7\u90e8\u7f72\u5df2\u53d6\u6d88'/)
+ assert.match(pipeline, /> \u8017\u65f6\uff1a\$\{duration\}/)
assert.match(pipeline, /\u4f01\u4e1a\u5fae\u4fe1\u901a\u77e5\u5931\u8d25\uff0c\u4e0d\u5f71\u54cd\u539f\u6784\u5efa\u7ed3\u679c/)
assert.doesNotMatch(servicePipeline, /wecom-prod-webhook|sendProductionWeComNotification/)
})
diff --git a/scripts/prod-release-acceptance.mjs b/scripts/prod-release-acceptance.mjs
new file mode 100644
index 000000000..9684fa8d4
--- /dev/null
+++ b/scripts/prod-release-acceptance.mjs
@@ -0,0 +1,252 @@
+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',
+])
+
+export function plan() {
+ return {
+ scope: { orders: 1, 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' })),
+ 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 = {}
+ for (const [type, code] of [['DOMESTIC', 'CN-YIWU-01'], ['OVERSEAS', 'US-LAX-01']]) {
+ const all = await listAll(http, `/platform/v1/warehouse-admin?page={page}&size=100&warehouseType=${type}&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) || !cnWarehouses.some(w => w.warehouseCode === 'CN-YIWU-01'
+ && id(w.id) === warehouses['CN-YIWU-01'])) throw new Error('国内 PDA 不可访问义乌仓')
+ 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,
+ 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: 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
+ 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 })
+}
diff --git a/scripts/prod-release-acceptance.test.mjs b/scripts/prod-release-acceptance.test.mjs
new file mode 100644
index 000000000..2294e0214
--- /dev/null
+++ b/scripts/prod-release-acceptance.test.mjs
@@ -0,0 +1,112 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { plan, config, assertCustomer, allowedRequest, client, listAll, preflight, main } from './prod-release-acceptance.mjs'
+
+function environment() {
+ const env = { CHONGCHONG_ENV: 'prod',
+ 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',
+ CHONGCHONG_OVERSEAS_LAX_PDA_WAREHOUSE_CODE: 'US-LAX-01' }
+ for (const role of ['ADMIN', 'MERCHANT', 'CN_PDA', 'OVERSEAS_LAX_PDA']) {
+ env[`CHONGCHONG_${role}_USERNAME`] = role
+ env[`CHONGCHONG_${role}_PASSWORD`] = 'fixture-secret'
+ }
+ return env
+}
+const profile = { id: '9007199254740993', tenantCode: 'fixture', tenantName: 'fixture', status: 'ACTIVE', dimensionInitializationStatus: 'READY' }
+
+test('single order scope does not claim implemented or full coverage', () => {
+ const result = plan()
+ assert.equal(result.scope.orders, 1)
+ assert.equal(result.scope.sameOrder, true)
+ 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)
+})
+test('configuration has no development/test fallback', () => {
+ assert.throws(() => config({ ADMIN_TEST_GATEWAY: 'https://acceptance.invalid' }))
+ const env = environment()
+ assert.equal(config(env).customer.id, profile.id)
+ delete env.CHONGCHONG_CN_PDA_PASSWORD
+ assert.throws(() => config(env), /CN_PDA_PASSWORD/)
+})
+test('reject wrong proxy, insecure URL, embedded credentials and cross-host routing', () => {
+ for (const value of ['http://acceptance.invalid/api', 'https://user:password@acceptance.invalid/api',
+ 'https://acceptance.invalid/api?token=x', 'https://acceptance.invalid', 'https://other.invalid/api']) {
+ assert.throws(() => config({ ...environment(), CHONGCHONG_ADMIN_API_BASE_URL: value }))
+ }
+})
+test('customer identity and initialized state are exact', () => {
+ const expected = config(environment()).customer
+ assert.doesNotThrow(() => assertCustomer(profile, expected))
+ for (const patch of [{ id: '2' }, { id: Number(profile.id) }, { tenantCode: 'other' },
+ { tenantName: 'other' }, { status: 'PENDING' }, { dimensionInitializationStatus: 'FAILED' }]) {
+ assert.throws(() => assertCustomer({ ...profile, ...patch }, expected))
+ }
+})
+test('business mutations, redirects, arbitrary paths and region confusion are denied before fetch', async () => {
+ let calls = 0
+ const http = client(config(environment()), async () => { calls++; throw new Error('unexpected') })
+ for (const [region, method, path] of [['cn', 'POST', '/platform/v1/pallets'],
+ ['cn', 'DELETE', '/platform/v1/locations/1'], ['cn', 'POST', '/merchant/v1/orders'],
+ ['overseas', 'POST', '/auth/login'], ['cn', 'GET', '//untrusted.invalid'],
+ ['cn', 'GET', '/merchant/v1/iam/tenant/profile?redirect=x']]) {
+ assert.equal(allowedRequest(method, path, region), false)
+ await assert.rejects(http.request(region, method, path))
+ }
+ assert.equal(calls, 0)
+})
+test('errors retain diagnostic codes, never echoed credentials or response body', async () => {
+ const http = client(config(environment()), async (_url, options) => {
+ assert.equal(options.redirect, 'error')
+ return { ok: true, status: 200, json: async () => ({ code: 'A0403', requestId: 'r-1', traceId: 't-1', message: 'fixture-secret', data: { password: 'fixture-secret' } }) }
+ })
+ await assert.rejects(http.request('cn', 'POST', '/auth/login'), error =>
+ error.message.includes('A0403') && error.message.includes('r-1') && !error.message.includes('fixture-secret'))
+})
+test('network failure is not retried', async () => {
+ let count = 0
+ const http = client(config(environment()), async () => { count++; throw new Error('fixture-secret') })
+ await assert.rejects(http.request('cn', 'POST', '/auth/login'), /未自动重试/)
+ assert.equal(count, 1)
+})
+test('pagination continues and refuses incomplete or malformed lists', async () => {
+ const pages = [{ records: [{ id: '1' }], total: '2' }, { records: [{ id: '2' }], total: '2' }]
+ assert.equal((await listAll({ request: async () => pages.shift() }, '/?page={page}', 'token')).length, 2)
+ await assert.rejects(listAll({ request: async () => ({ records: [], total: '1' }) }, '/?page={page}'), /提前结束/)
+ await assert.rejects(listAll({ request: async () => ({}) }, '/?page={page}'), /结构/)
+})
+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 (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' }]
+ if (path.includes('/warehouse-admin?')) {
+ const domestic = path.includes('warehouseType=DOMESTIC')
+ return { total: '1', records: [{ id: domestic ? '2' : '3', warehouseCode: domestic ? 'CN-YIWU-01' : 'US-LAX-01', status: 'ACTIVE' }] }
+ }
+ return { total: '1', records: [{ warehouseId: '2', status: 'AVAILABLE' }] }
+ } }
+}
+test('basic preflight is explicitly not full chain and contains no accounts or tokens', async () => {
+ const result = await preflight(config(environment()), fakeHttp())
+ assert.equal(result.status, 'BASIC_PREFLIGHT_PASSED')
+ assert.equal(result.fullFlowVerified, false)
+ assert.ok(result.pending.length)
+ assert.doesNotMatch(JSON.stringify(result), /fixture|9007199254740993/)
+})
+test('PDA warehouse mismatch blocks preflight', async () => {
+ await assert.rejects(preflight(config(environment()), fakeHttp('4')), /仓库绑定不符/)
+})
+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, {}), /尚未开放/)
+ }
+})
diff --git a/scripts/prod-release-measurement-plan.mjs b/scripts/prod-release-measurement-plan.mjs
new file mode 100644
index 000000000..c9ad7584c
--- /dev/null
+++ b/scripts/prod-release-measurement-plan.mjs
@@ -0,0 +1,74 @@
+// Shared declaration / measurement arithmetic. Pure functions; no HTTP or master-data writes.
+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 箱')
+ 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')
+ const cargo = input.payload.cargoes[0]
+ const totals = cartonTotals(cargo.packageCount)
+ if (cargo.netWeightKg != null && (!Number.isFinite(Number(cargo.netWeightKg))
+ || Number(cargo.netWeightKg) <= 0 || Number(cargo.netWeightKg) > totals.grossWeightKg)) {
+ throw new Error('申报商品净重无效或超过含箱货重;不能据此推算商品净重')
+ }
+ return { ...input, payload: { ...input.payload, cargoes: [{ ...cargo, ...totals }] } }
+}
+
+function scaled(value, places, label, allowZero = false) {
+ const text = String(value)
+ if (!/^\d+(?:\.\d+)?$/.test(text)) throw new Error(`${label} 无效`)
+ const [whole, fraction = ''] = text.split('.')
+ if (fraction.slice(places).replace(/0/g, '')) throw new Error(`${label} 超出允许精度`)
+ const result = BigInt(whole) * 10n ** BigInt(places) + BigInt(fraction.slice(0, places).padEnd(places, '0'))
+ if ((!allowZero && result === 0n) || result > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`${label} 超出安全范围`)
+ return result
+}
+
+export function measurementPlan({ packageCount, warehouseId, pallets, allowedCodes }) {
+ const totals = cartonTotals(packageCount)
+ if (!Array.isArray(allowedCodes) || !allowedCodes.length || new Set(allowedCodes).size !== allowedCodes.length
+ || allowedCodes.some(code => typeof code !== 'string' || !code.trim())) throw new Error('需要明确允许复用的既有托盘编码')
+ if (typeof warehouseId !== 'string' || !/^[1-9]\d*$/.test(warehouseId)) throw new Error('仓库 ID 无效')
+ const candidates = pallets.filter(p => p.warehouseId === warehouseId && allowedCodes.includes(p.palletCode))
+ // Resolve every allowed code exactly; do not silently choose another warehouse or create a replacement.
+ for (const code of allowedCodes) {
+ if (candidates.filter(p => p.palletCode === code).length !== 1) throw new Error('允许的托盘缺失或编码重复')
+ }
+ const pallet = candidates.find(p => p.palletCode === allowedCodes[0])
+ if (typeof pallet.id !== 'string' || !/^[1-9]\d*$/.test(pallet.id)) throw new Error('托盘 ID 无效')
+ const capacity = scaled(pallet.capacityCbm, 6, '托容量')
+ const tare = scaled(pallet.weightKg, 3, '托盘皮重', true)
+ const cartonVolume = 36000n // cubic centimetres, i.e. 0.036 m³
+ const totalVolume = cartonVolume * BigInt(packageCount)
+ const count = Number((totalVolume + capacity - 1n) / capacity)
+ const cartonsPerPallet = Number(capacity / cartonVolume)
+ if (!cartonsPerPallet || count > packageCount || cartonsPerPallet * count < packageCount) {
+ throw new Error('按体积取整无法容纳完整箱,需人工确认托盘容量或装托规则')
+ }
+ let remaining = packageCount
+ const weights = Array.from({ length: count }, (_, index) => {
+ const cartons = Math.min(cartonsPerPallet, remaining - (count - index - 1))
+ remaining -= cartons
+ const netGrams = BigInt(cartons) * 7500n
+ if (netGrams + tare > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('连托毛重超出安全范围')
+ return { sequence: index + 1, palletId: pallet.id, palletCode: pallet.palletCode,
+ packageCount: cartons, volumeCbm: cartons * 36 / 1000,
+ palletTareWeightKg: Number(tare) / 1000, netWeightKg: Number(netGrams) / 1000,
+ grossWeightKg: Number(netGrams + tare) / 1000 }
+ })
+ return { status: 'MEASUREMENT_PLAN_ONLY', fullFlowVerified: false, ...totals,
+ cargoWeightKg: totals.grossWeightKg, palletCount: count, capacityCbm: Number(capacity) / 1_000_000,
+ weights, weightGrossTotalKg: weights.reduce((sum, w) => sum + Math.round(w.grossWeightKg * 1000), 0) / 1000,
+ locationPolicy: 'SERVER_ALLOCATES_EXISTING_OR_A0', measurementWritten: false }
+}
+
+export function assertConfirmedWeight(actual, expected) {
+ if (actual?.recognitionStatus !== 'CONFIRMED' || actual.palletId !== expected.palletId) throw new Error('称重确认身份或状态不符')
+ for (const key of ['grossWeightKg', 'netWeightKg', 'palletTareWeightKg']) {
+ if (scaled(actual[key], 3, key, true) !== scaled(expected[key], 3, key, true)) throw new Error(`称重回查 ${key} 不守恒`)
+ }
+}
diff --git a/scripts/prod-release-measurement-plan.test.mjs b/scripts/prod-release-measurement-plan.test.mjs
new file mode 100644
index 000000000..b56006313
--- /dev/null
+++ b/scripts/prod-release-measurement-plan.test.mjs
@@ -0,0 +1,57 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { cartonTotals, normalizeOrderInput, measurementPlan, assertConfirmedWeight } from './prod-release-measurement-plan.mjs'
+
+const pallet = { id: '10', warehouseId: '20', palletCode: 'FIXTURE-A', capacityCbm: '1.200000', weightKg: '8.000' }
+const plan = (patch = {}) => measurementPlan({ packageCount: 2, warehouseId: '20', pallets: [pallet], allowedCodes: ['FIXTURE-A'], ...patch })
+test('agreed carton profile: 7.5kg, 40x30x30cm', () => {
+ assert.deepEqual(cartonTotals(2), { packageCount: 2, lengthCm: 40, widthCm: 30, heightCm: 30, grossWeightKg: 15, volumeCbm: 0.072 })
+ assert.equal(cartonTotals(1).volumeCbm, 0.036)
+ for (const count of [0, -1, 3, 1.5, '2', NaN]) assert.throws(() => cartonTotals(count))
+})
+test('declaration normalization preserves product facts and never mutates source', () => {
+ const input = { payload: { cargoes: [{ packageCount: 2, skuNo: 'fixture', grossWeightKg: 500, netWeightKg: 12 }] } }
+ const normalized = normalizeOrderInput(input)
+ assert.equal(normalized.payload.cargoes[0].grossWeightKg, 15)
+ assert.equal(normalized.payload.cargoes[0].skuNo, 'fixture')
+ assert.equal(normalized.payload.cargoes[0].netWeightKg, 12)
+ assert.equal(input.payload.cargoes[0].grossWeightKg, 500)
+ assert.throws(() => normalizeOrderInput({ payload: { cargoes: [{ packageCount: 1, netWeightKg: 8 }] } }), /净重/)
+})
+test('two cartons on existing 8kg tare pallet: scale 23kg, cargo 15kg', () => {
+ const result = plan()
+ assert.equal(result.palletCount, 1)
+ assert.equal(result.weightGrossTotalKg, 23)
+ assert.equal(result.weights[0].netWeightKg, 15)
+ assert.equal(result.measurementWritten, false)
+ assert.equal(result.fullFlowVerified, false)
+})
+test('exact volume boundaries use decimal integer arithmetic', () => {
+ assert.equal(plan({ pallets: [{ ...pallet, capacityCbm: '0.072000' }] }).palletCount, 1)
+ const result = plan({ pallets: [{ ...pallet, capacityCbm: '0.071999' }] })
+ assert.equal(result.palletCount, 2)
+ assert.equal(result.weights.reduce((sum, item) => sum + item.packageCount, 0), 2)
+ assert.equal(result.weightGrossTotalKg, 31)
+ assert.ok(result.weights.every(w => w.palletId === pallet.id))
+})
+test('missing capacity or tare never falls back to a fabricated default', () => {
+ for (const changes of [{ capacityCbm: 0 }, { capacityCbm: undefined }, { capacityCbm: '0.0000001' },
+ { weightKg: null }, { weightKg: -1 }, { warehouseId: '99' }, { id: 10 }]) {
+ assert.throws(() => plan({ pallets: [{ ...pallet, ...changes }] }))
+ }
+ assert.equal(plan({ pallets: [{ ...pallet, weightKg: 0 }] }).weightGrossTotalKg, 15)
+})
+test('missing/duplicate allowlisted pallet fails; no automatic substitute', () => {
+ assert.throws(() => plan({ allowedCodes: [] }))
+ assert.throws(() => plan({ allowedCodes: ['FIXTURE-A', 'MISSING'] }))
+ assert.throws(() => plan({ pallets: [pallet, pallet] }))
+ assert.throws(() => plan({ pallets: [{ ...pallet, capacityCbm: '0.035' }] }), /完整箱/)
+})
+test('confirmed weights must preserve tare and cargo totals', () => {
+ const expected = plan().weights[0]
+ assert.doesNotThrow(() => assertConfirmedWeight({ ...expected, recognitionStatus: 'CONFIRMED' }, expected))
+ for (const patch of [{ palletId: '99' }, { recognitionStatus: 'NEEDS_MANUAL' }, { grossWeightKg: 15 },
+ { netWeightKg: 7 }, { palletTareWeightKg: 0 }]) {
+ assert.throws(() => assertConfirmedWeight({ ...expected, recognitionStatus: 'CONFIRMED', ...patch }, expected))
+ }
+})
diff --git a/scripts/prod-release-order-stage.mjs b/scripts/prod-release-order-stage.mjs
new file mode 100644
index 000000000..19f1d2315
--- /dev/null
+++ b/scripts/prod-release-order-stage.mjs
@@ -0,0 +1,166 @@
+import { createHash } from 'node:crypto'
+import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises'
+import { dirname } from 'node:path'
+
+// Internal stage only. No credentials, fixture generation, CLI or environment fallback.
+const ROOT = '/merchant/v1/orders/first-leg'
+const assert = (condition, message) => { if (!condition) throw new Error(message) }
+const longId = value => typeof value === 'string' && /^[1-9]\d*$/.test(value)
+const canonical = value => Array.isArray(value) ? value.map(canonical)
+ : value && typeof value === 'object'
+ ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value
+export const fingerprint = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex')
+
+export function validateOrderInput(input) {
+ const { batch, customer, origin, destination, payload, operator } = input
+ assert(/^[A-Za-z0-9][A-Za-z0-9_-]{7,47}$/.test(batch || ''), '批次须为 8~48 位字母数字或连字符')
+ assert(longId(customer?.id) && customer.name && customer.code, '缺少固定客户身份')
+ assert(operator?.merchant && operator?.admin, '缺少幂等操作者身份')
+ assert(origin?.code === 'CN-YIWU-01' && longId(origin.id), '仅允许义乌始发')
+ assert(destination?.type === 'FBA' && longId(destination.id) && destination.code, '当前订单阶段仅支持显式 FBA 目的仓')
+ assert(payload?.customerName === customer.name && (payload.customerId == null || payload.customerId === customer.id), '下单客户与固定客户不符')
+ assert(payload.receivingWarehouseId === origin.id && payload.destinationWarehouseId === destination.id
+ && payload.amazonWarehouseCode === destination.code, '下单仓库与已核对主档不符')
+ assert(payload.transportMode === 'SEA' && payload.destinationType === 'FBA'
+ && payload.fulfillmentType === 'STANDARD' && payload.containerMode === 'LCL'
+ && payload.pickupMethod === 'SELF_EXPRESS', '仅支持标准海运 FBA 自送散货链路')
+ assert(payload.customerRemark === `RELEASE-ACCEPTANCE:${batch}`, '批次标记必须精确匹配')
+ assert(typeof payload.channelCode === 'string' && payload.channelCode.length > 0, '缺少明确渠道')
+ 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(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), '需要客户已上传产品图片,不生成虚假证明文件')
+ assert(cargo.skuNo && Number.isInteger(cargo.unitsPerPackage) && cargo.unitsPerPackage > 0
+ && cargo.totalQuantity === cargo.packageCount * cargo.unitsPerPackage, 'SKU 或件数不守恒')
+ for (const field of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'volumeCbm']) {
+ assert(typeof cargo[field] === 'number' && Number.isFinite(cargo[field]) && cargo[field] > 0, `货物 ${field} 无效`)
+ }
+ const volume = cargo.lengthCm * cargo.widthCm * cargo.heightCm * cargo.packageCount / 1_000_000
+ assert(Math.abs(volume - cargo.volumeCbm) < 0.000001, '箱规与总体积不守恒')
+ return input
+}
+
+function binding(input) {
+ return fingerprint({ batch: input.batch, customer: input.customer, origin: input.origin,
+ destination: input.destination, payload: input.payload, operator: input.operator, environment: input.environment })
+}
+
+// Atomic snapshots plus an exclusive process lock. A crashed process leaves the lock for manual review.
+// 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://'), '缺少环境绑定')
+ await mkdir(dirname(path), { recursive: true })
+ const lock = await open(`${path}.lock`, 'wx', 0o600)
+ const temp = `${path}.next`
+ let tempOwned = false
+ try {
+ let state
+ try { state = JSON.parse(await readFile(path, 'utf8')) }
+ catch (error) {
+ if (error.code !== 'ENOENT') throw new Error('检查点损坏,禁止重新创建订单')
+ state = { version: 1, binding: binding(input), phase: 'NEW', orderId: null }
+ }
+ assert(state.version === 1 && state.binding === binding(input), '批次配置、环境、客户、账号或载荷已变化')
+ assert(['NEW', 'CREATE_INTENT', 'CREATED', 'SUBMIT_INTENT', 'SUBMITTED', 'APPROVE_INTENT', 'APPROVED'].includes(state.phase), '检查点阶段无效')
+ assert(['NEW', 'CREATE_INTENT'].includes(state.phase) ? state.orderId === null : longId(state.orderId), '检查点订单身份无效')
+ const save = async patch => {
+ const next = { ...state, ...patch }
+ const file = await open(temp, 'wx', 0o600)
+ tempOwned = true
+ try { await file.writeFile(JSON.stringify(next, null, 2)); await file.sync() } finally { await file.close() }
+ await rename(temp, path)
+ tempOwned = false
+ Object.assign(state, next)
+ }
+ if (state.phase === 'NEW') await save({})
+ return await action(state, save)
+ } finally {
+ // Only delete files created by this invocation; never remove an existing crash checkpoint.
+ if (tempOwned) await unlink(temp).catch(() => {})
+ await lock.close()
+ await unlink(`${path}.lock`)
+ }
+}
+
+export function assertOrderDetail(detail, input, orderId) {
+ const order = detail?.order
+ assert(order?.id === orderId && order.tenantId === input.customer.id && order.customerId === input.customer.id,
+ '订单归属与本批次不符')
+ for (const field of ['receivingWarehouseId', 'destinationWarehouseId', 'channelCode', 'destinationType',
+ 'fulfillmentType', 'customerRemark', 'pickupMethod', 'containerMode', 'pricingTaxMode']) {
+ assert(order[field] === input.payload[field], `订单 ${field} 与批次不符`)
+ }
+ assert(typeof order.orderNo === 'string' && order.orderNo.length > 0, '缺少业务订单号')
+ assert(['DRAFT', 'SUBMITTED', 'APPROVED'].includes(order.status), '订单已偏离当前验收阶段,停止自动推进')
+ assert(Array.isArray(detail.cargoes) && detail.cargoes.length === 1, '订单货物行发生变化')
+ const cargo = detail.cargoes[0]
+ for (const key of ['skuNo', 'packageCount', 'totalQuantity', 'unitsPerPackage', 'productImageFileId']) {
+ assert(cargo[key] === input.payload.cargoes[0][key], `订单货物 ${key} 与批次不符`)
+ }
+ for (const key of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'volumeCbm']) {
+ assert(Number(cargo[key]) === input.payload.cargoes[0][key], `订单货物 ${key} 与批次不符`)
+ }
+ return order
+}
+
+/**
+ * transport is intentionally scoped to this module's five fixed endpoints, not the old test context.
+ * authorize performs fresh client/admin identity and route/quote checks, including on explicit resume.
+ * An uncertain non-idempotent approval is never replayed based only on SUBMITTED being observed.
+ */
+export async function runOrderStage({ input, state, save, transport, authorize, resume = false }) {
+ validateOrderInput(input)
+ assert(state.binding === binding(input), '检查点绑定不符')
+ await authorize(input)
+ const key = op => `prod-release-${fingerprint({ binding: state.binding, op }).slice(0, 48)}`
+ const request = (role, method, path, body, idempotencyKey) => transport({ role, method, path, body, idempotencyKey })
+ if (state.phase === 'CREATE_INTENT') assert(resume, '创建结果待核对;需要显式续跑')
+ if (['NEW', 'CREATE_INTENT'].includes(state.phase)) {
+ const policy = await request('MERCHANT', 'GET', `${ROOT}/inbound-code-policy`)
+ assert(policy?.mode === 'AUTO' && policy.manualEntryRequired === false, '当前专属链只支持自动入仓码')
+ await save({ phase: 'CREATE_INTENT' })
+ const orderId = await request('MERCHANT', 'POST', ROOT, input.payload, key('create'))
+ assert(longId(orderId), '创建返回订单 ID 无效,保留创建意图待核对')
+ await save({ phase: 'CREATED', orderId })
+ }
+ assert(longId(state.orderId), '缺少精确订单 ID')
+ const path = `${ROOT}/${state.orderId}`
+ const read = async () => assertOrderDetail(await request('MERCHANT', 'GET', path), input, state.orderId)
+ let order = await read()
+ if (state.phase === 'CREATED') {
+ assert(order.status === 'DRAFT', '创建后状态被外部修改')
+ await save({ phase: 'SUBMIT_INTENT' })
+ await request('MERCHANT', 'POST', `${path}/submit`, undefined, key('submit'))
+ order = await read()
+ assert(order.status === 'SUBMITTED', '提交后未进入待审核')
+ await save({ phase: 'SUBMITTED', orderNo: order.orderNo, inboundCode: order.inboundCode })
+ } else if (state.phase === 'SUBMIT_INTENT') {
+ assert(resume, '提交结果待核对;需要显式续跑')
+ if (order.status === 'DRAFT') {
+ await request('MERCHANT', 'POST', `${path}/submit`, undefined, key('submit'))
+ order = await read()
+ }
+ assert(order.status === 'SUBMITTED', '提交结果无法安全核对')
+ await save({ phase: 'SUBMITTED', orderNo: order.orderNo, inboundCode: order.inboundCode })
+ }
+ if (state.phase === 'SUBMITTED') {
+ order = await read()
+ assert(order.status === 'SUBMITTED', '审核前状态被外部修改')
+ await save({ phase: 'APPROVE_INTENT' })
+ await request('ADMIN', 'POST', `/platform/v1/orders/first-leg/${state.orderId}/approve?tenantId=${input.customer.id}`)
+ order = await read()
+ assert(order.status === 'APPROVED', '审核后未进入已审核,保留意图待核对')
+ await save({ phase: 'APPROVED' })
+ } else if (state.phase === 'APPROVE_INTENT') {
+ assert(resume && order.status === 'APPROVED', '审核结果不确定;禁止重发审核,需人工核对')
+ await save({ phase: 'APPROVED' })
+ }
+ order = await read()
+ assert(state.phase === 'APPROVED' && order.status === 'APPROVED', '订单阶段没有完成')
+ assert(typeof order.inboundCode === 'string' && order.inboundCode.length > 0, '审核订单缺少入仓码')
+ return { status: 'ORDER_APPROVED', orderId: state.orderId, orderNo: order.orderNo,
+ inboundCode: order.inboundCode, fullFlowVerified: false, next: 'YIWU_MEASUREMENT' }
+}
diff --git a/scripts/prod-release-order-stage.test.mjs b/scripts/prod-release-order-stage.test.mjs
new file mode 100644
index 000000000..24f284597
--- /dev/null
+++ b/scripts/prod-release-order-stage.test.mjs
@@ -0,0 +1,208 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fingerprint, validateOrderInput, withOrderJournal, runOrderStage } from './prod-release-order-stage.mjs'
+import { executeOrder } from './prod-release-acceptance.mjs'
+
+function fixture() {
+ return { batch: 'release-fixture-001', environment: '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',
+ 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,
+ lengthCm: 40, widthCm: 30, heightCm: 30, grossWeightKg: 15, volumeCbm: 0.072,
+ productImageFileId: '4', woodenProduct: false, dangerousGoods: false }] } }
+}
+async function sandbox(t) {
+ const path = await mkdtemp(join(tmpdir(), 'prod-order-contract-'))
+ t.after(() => rm(path, { recursive: true, force: true }))
+ return join(path, 'journal.json')
+}
+function server(input, fail) {
+ let status = null
+ let failed = false
+ const calls = []
+ const state = { calls, created: 0, approved: 0,
+ async transport(request) {
+ calls.push(request)
+ const { method, path, idempotencyKey } = request
+ if (path.endsWith('/inbound-code-policy')) return { mode: 'AUTO', manualEntryRequired: false }
+ if (method === 'GET') return { order: { ...input.payload, id: '5', tenantId: '1', customerId: '1',
+ status, orderNo: 'FIXTURE-ORDER', inboundCode: status === 'DRAFT' ? null : 'FIXTURE-INBOUND' }, cargoes: input.payload.cargoes }
+ let op
+ if (path.endsWith('/first-leg')) {
+ op = 'create'
+ assert.ok(idempotencyKey)
+ if (!status) { state.created++; status = 'DRAFT' }
+ } else if (path.endsWith('/submit')) {
+ op = 'submit'
+ assert.ok(idempotencyKey)
+ status = 'SUBMITTED'
+ } else {
+ assert.match(path, /^\/platform\/v1\/orders\/first-leg\/5\/approve\?tenantId=1$/)
+ assert.equal(request.role, 'ADMIN')
+ op = 'approve'
+ state.approved++
+ if (fail !== 'approve-before') status = 'APPROVED'
+ }
+ if ((fail === op || fail === 'approve-before' && op === 'approve') && !failed) {
+ failed = true
+ throw new Error('simulated response loss')
+ }
+ return op === 'create' ? '5' : null
+ },
+ }
+ return state
+}
+async function run(path, input, api, resume = false, authorize = async () => {}) {
+ return withOrderJournal(path, input, (state, save) => runOrderStage({ input, state, save,
+ transport: api.transport, authorize, resume }))
+}
+test('fingerprint is stable for reordered keys', () => {
+ assert.equal(fingerprint({ a: 1, b: 2 }), fingerprint({ b: 2, a: 1 }))
+})
+test('rejects multiple SKUs, excess cartons, incorrect company and volume', () => {
+ for (const change of [i => i.payload.cargoes.push(i.payload.cargoes[0]),
+ i => i.payload.cargoes[0].packageCount = 300,
+ i => i.payload.customerId = '999', i => i.origin.code = 'CN-OTHER',
+ i => i.payload.customerRemark += '-other', i => i.payload.cargoes[0].volumeCbm = 2]) {
+ const input = fixture(); change(input)
+ assert.throws(() => validateOrderInput(input))
+ }
+})
+test('one create, one submit, one approval; completed resume performs reads only', async t => {
+ const path = await sandbox(t), input = fixture(), api = server(input)
+ const result = await run(path, input, api)
+ assert.equal(result.status, 'ORDER_APPROVED')
+ assert.equal(result.fullFlowVerified, false)
+ const count = api.calls.filter(c => c.method === 'POST').length
+ await run(path, input, api, true)
+ assert.equal(api.calls.filter(c => c.method === 'POST').length, count)
+ assert.equal(api.created, 1)
+ assert.equal(api.approved, 1)
+ const disk = await readFile(path, 'utf8')
+ assert.doesNotMatch(disk, /fixture-client|fixture-admin|productImageFileId|https:/)
+})
+for (const failure of ['create', 'submit', 'approve']) {
+ test(`recover lost ${failure} response without duplicate business records`, async t => {
+ const path = await sandbox(t), input = fixture(), api = server(input, failure)
+ await assert.rejects(run(path, input, api), /response loss/)
+ await assert.rejects(run(path, input, api), /显式续跑|禁止重发/)
+ await run(path, input, api, true)
+ assert.equal(api.created, 1)
+ assert.equal(api.approved, 1)
+ if (failure === 'create') {
+ const creates = api.calls.filter(c => c.method === 'POST' && c.path.endsWith('/first-leg'))
+ assert.equal(creates.length, 2)
+ assert.equal(creates[0].idempotencyKey, creates[1].idempotencyKey)
+ }
+ })
+}
+test('uncertain approval that still reads submitted must not be resent', async t => {
+ const path = await sandbox(t), input = fixture(), api = server(input, 'approve-before')
+ await assert.rejects(run(path, input, api))
+ await assert.rejects(run(path, input, api, true), /禁止重发审核/)
+ assert.equal(api.approved, 1)
+})
+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',
+ 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
+ await assert.rejects(run(path, other, api, true), /已变化/)
+ assert.equal(api.calls.length, count)
+ }
+})
+test('authorization failure prevents first write', async t => {
+ const path = await sandbox(t), input = fixture(), api = server(input)
+ await assert.rejects(run(path, input, api, false, async () => { throw new Error('wrong customer') }))
+ assert.equal(api.calls.length, 0)
+})
+test('foreign order detail blocks submission', async t => {
+ const path = await sandbox(t), input = fixture(), api = server(input)
+ const original = api.transport
+ api.transport = async req => {
+ const data = await original(req)
+ if (data?.order) data.order.customerId = '999'
+ return data
+ }
+ await assert.rejects(run(path, input, api), /归属/)
+ assert.equal(api.calls.filter(c => c.method === 'POST').length, 1)
+})
+test('concurrent journal process is rejected', async t => {
+ const path = await sandbox(t), input = fixture()
+ await withOrderJournal(path, input, async () => {
+ await assert.rejects(withOrderJournal(path, input, async () => {}), { code: 'EEXIST' })
+ })
+})
+test('corrupt journal is preserved and fails closed', async t => {
+ const path = await sandbox(t), input = fixture(), api = server(input)
+ await writeFile(path, '{broken')
+ await assert.rejects(run(path, input, api), /检查点损坏/)
+ assert.equal(await readFile(path, 'utf8'), '{broken')
+ assert.equal(api.calls.length, 0)
+})
+
+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,
+ 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
+ let data
+ if (endpoint === '/auth/login') {
+ const role = JSON.parse(options.body).loginId
+ data = { accessToken: role, overseasAccessToken: `${role}-overseas` }
+ } else if (endpoint.includes('/iam/')) {
+ data = { id: '1', tenantCode: 'fixture', tenantName: 'fixture', status: 'ACTIVE', dimensionInitializationStatus: 'READY' }
+ } else if (endpoint.includes('/warehouse-admin?')) {
+ const domestic = endpoint.includes('warehouseType=DOMESTIC')
+ data = { total: '1', records: [{ id: domestic ? '2' : '6', warehouseCode: domestic ? 'CN-YIWU-01' : 'US-LAX-01', status: 'ACTIVE' }] }
+ } else if (endpoint.endsWith('/pda-loading/warehouses')) {
+ assert.equal(options.headers.Authorization, 'Bearer CN_PDA')
+ data = [{ id: '2', warehouseCode: 'CN-YIWU-01' }]
+ } else if (endpoint.endsWith('/warehouse-profile')) {
+ assert.equal(options.headers.Authorization, 'Bearer OVERSEAS_LAX_PDA-overseas')
+ data = { warehouseId: '6', warehouseCode: 'US-LAX-01' }
+ } else if (/\/platform\/v1\/(pallets|locations)\?/.test(endpoint)) {
+ data = { total: '1', records: [{ warehouseId: '2', status: 'AVAILABLE' }] }
+ } else if (endpoint === '/merchant/v1/warehouses/3') {
+ data = { warehouse: { id: '3', warehouseCode: 'FIXTURE', warehouseType: 'FBA', status: 'ACTIVE', warehouseName: 'fixture' } }
+ } else if (endpoint.includes('/pricing/channels?')) {
+ data = [{ channelCode: 'FIXTURE', priceBookVersionId: '7', pricingTaxModes: ['TAX_INCLUDED'] }]
+ } else if (endpoint.endsWith('/pricing/estimate')) {
+ assert.equal(options.headers.Authorization, 'Bearer MERCHANT')
+ const body = JSON.parse(options.body)
+ assert.equal(body.actualWeightKg, 15)
+ assert.equal(body.actualVolumeCbm, 0.072)
+ data = { channelCode: 'FIXTURE', priceBookVersionId: quoteMismatch ? '8' : '7', pricingTaxMode: 'TAX_INCLUDED', estimatedAmount: 10 }
+ } else {
+ const role = endpoint.includes('/approve?') ? 'ADMIN' : 'MERCHANT'
+ assert.equal(options.headers.Authorization, `Bearer ${role}`)
+ data = await api.transport({ role, path: endpoint, method: options.method,
+ body: options.body ? JSON.parse(options.body) : undefined, idempotencyKey: options.headers['Idempotency-Key'] })
+ }
+ return { ok: true, status: 200, json: async () => ({ code: '200', data }) }
+ }
+ if (quoteMismatch) {
+ await assert.rejects(executeOrder(settings, input, path, false, fetcher), /报价不匹配/)
+ assert.equal(api.created, 0)
+ } else {
+ assert.equal((await executeOrder(settings, input, path, false, fetcher)).status, 'ORDER_APPROVED')
+ assert.equal(api.created, 1)
+ assert.equal(api.approved, 1)
+ await executeOrder(settings, input, path, true, fetcher)
+ assert.equal(api.created, 1)
+ assert.equal(api.approved, 1)
+ }
+ })
+}