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) + } + }) +}