Test #6

Merged
admin merged 4 commits from test into main 2026-09-19 22:59:43 +08:00
10 changed files with 960 additions and 18 deletions
Showing only changes of commit 28d3d6b8b6 - Show all commits
Vendored
+46 -15
View File
@@ -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}
> 状态:<font color="${color}">${status}</font>
> 详情:${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}"
+1
View File
@@ -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 全量入口 |
+29
View File
@@ -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:<batch>`。显式填 `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 只维护在用户目录的两份外部文件中,不写入仓库:
| 环境 | 文件 |
+15 -3
View File
@@ -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/)
})
+252
View File
@@ -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 })
}
+112
View File
@@ -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, {}), /尚未开放/)
}
})
+74
View File
@@ -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} 不守恒`)
}
}
@@ -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))
}
})
+166
View File
@@ -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' }
}
+208
View File
@@ -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)
}
})
}