Merge latest test into dev preserving initialization fixes [skip ci]

This commit is contained in:
2026-10-14 15:55:04 +08:00
405 changed files with 30826 additions and 800 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
| 类别 | 正式入口 | 管理边界 |
|---|---|---|
| 生产单票发布验收 | `prod-release-acceptance.mjs`、`prod-release-delivery-stage.mjs`、内部 `prod-release-order-stage.mjs` | 同一票义乌到洛杉矶、前置同时核对东莞;订单创建/提交/审核与尾程签收/国内回传分阶段续跑,检查点独立;不调用测试造数模块,不创建账号/主档或删除数据;配置和边界见 README |
| 测试/灰度单票验收 | `prod-release-acceptance.mjs`、`prod-release-delivery-stage.mjs`、内部 `prod-release-order-stage.mjs` | 显式 test/gray、统一 Gateway/accessToken;同一票义乌到洛杉矶、前置同时核对东莞;full-flow 串行覆盖买单FBA正常链至签收回传及车次资源释放,保留分阶段入口与独立检查点;不调用测试造数模块,不创建账号/主档或删除数据;配置和边界见 README |
| Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;默认模块覆盖平台全链路,定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 |
| Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 |
| Client 快速下单定向验证 | `client-quick-order-test-env-acceptance.mjs` | 使用既有测试客户通过批量草稿接口验证 FBA、海外仓、私人地址、逐行失败和幂等重放;只创建草稿,不提交或审核,不替代 Client 全量入口 |
+23 -14
View File
@@ -1,5 +1,9 @@
# 后端脚本管理与全量 HTTP 验收索引
`../lastleg-service/scripts/selfoperated-test-env-acceptance.mjs`:自营统计 TEST Gateway 验收,默认仅输出计划;显式 `--execute --environment TEST --source-account <账号键> --warehouse <仓库编码> --other-warehouse <第二仓> --report <报告路径>` 才登录并查询。凭据由运行者通过脚本之前的 `node --env-file=<明确TEST文件>` 注入,需平台、只读平台、无权限平台及客户身份。`--probe-off-command` 仅在确认 OFF 后提交预期被拒绝的命令并核对无新增任务;不采集、发布、造数或写 SQL。`--synthetic-fixture <审核后的说明文件>` 验证合成样本,不能证明真实来源覆盖。报告脱敏记录各项结果及请求标识;失败保留报告,重跑使用新报告路径并重新登录,结束退出自身会话,不清理业务样本。离线合同为同目录 `selfoperated-test-env-acceptance.test.mjs`。
`db/return-consolidated-incremental-20261012.sql`:10月10日/12日退货、签收、清点、换标与按件出库增量汇总,显式 `USE baiyue_lastleg`,MySQL 8.0.16+ 已有业务库可重复执行。本轮补齐签收原包装、尺寸重量来源及库存箱原包装三个字段;逐列判断存在性,不回填签收/清点事实,不改库存数量。连接实例及凭据由执行者明确指定,整份执行、遇错停止;保留执行错误,修复后可重跑,末尾输出字段、索引与约束供核对。托盘重量、体积汇总是应用逻辑,不新增资源库字段;不自动执行到测试或生产环境。
`procurement-sample-test-env.mjs` 是采购样品正常全链路 TEST 专项入口,默认或 `--plan` 零网络。真实执行使用 `node --env-file="<授权目录>/test-acceptance.env" scripts/procurement-sample-test-env.mjs --execute --environment TEST --run-id <唯一短批次> --warehouse-id <已核对有效海外收货仓>`。只允许当前 TEST 工作区及指定配置;由正式 API 创建独立客户并完成资质,人工充值10元,上传凭证/面单,调用内部 `procurement-sample-fulfillment.mjs` 完成两箱正常履约。充值、文件和采购请求前落盘,结果未知停写;报告独占 `reports/procurement-sample-<批次>/report.json`,不覆盖旧报告,不自动重跑,不执行 SQL/部署/删除。结束退出自身平台与客户会话,保留样本。通过只表示该正常路径,不替代异常、退款、权限、真实页面和请求量验收。离线入口合同为 `procurement-sample-test-env.test.mjs`;内部模块安全合同见模块索引。
@@ -79,7 +83,7 @@ F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_
`fleet-cross-customer-test-env.mjs` 是最小两客户共享车次的串行 TEST 专项入口,默认 `--plan` 零网络。显式运行参数为 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外收货仓ID> --destination-warehouse-id <FBA目的仓ID>`,凭据仅从显式 `node --env-file="<TEST配置>" --env-file="<Jenkins配置>"` 与 `FBA_CORE_JENKINS_AGENT` 提供。通过普通转运来货、PDA实收和客户确认创建两个新客户各一托一箱,分别预排并关联同车;一单未拣齐时验证全车拒绝及完整库存快照不变,再统一发车、原键重放、逐客户签收,最后正式结案并分别释放车辆/司机,核对结案前后库存零变化。完整库存由只读取证器获取,不写业务SQL,不部署或改共享配置。报告位于 `reports/fleet-cross-<新批次>/`,每次请求发送前记录意图;失败保留原现场,不自动重跑或续跑,已存在批次拒绝覆盖。finally停用本轮PDA并退出独立会话;业务样本保留。前置拒绝不代表事务中途回滚,后者另验。对应入口保护合同为 `fleet-cross-customer-test-env.test.mjs`;内部生成与验收函数见模块README。
头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。生产单票的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。
头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。单票验收的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。
## 验收证据校验
@@ -153,34 +157,37 @@ node --env-file="<外部生产数据库.env>" scripts/prod-database-schema-accep
## 验收环境变量文件
### 生产单票发布验收(独立入口)
### 测试/灰度单票验收(独立入口)
称重计算内部模块 `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 --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`,并在前置检查中同时核对东莞 `CN-DONGGUAN-01`;客户身份由外部配置精确限定,不调用开发/测试全量造数模块。订单阶段负责单票创建、提交和审核;`prod-release-delivery-stage.mjs` 负责既有本批次的派送中、签收文件、派送完成和国内签收事件回查,两个阶段分别使用独立检查点。签收脚本不会借用历史单据或删除数据。
- `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`。不修改环境文件,不读取其他环境作为回退。
- `node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/prod-release-acceptance.mjs preflight`:获得当次目标环境 HTTP 授权后运行;登录既有四个账号并查询客户、义乌/东莞/洛杉矶仓、PDA 绑定及义乌资源。不创建业务数据、主档或账号,不删除数据;登录本身会创建会话及审计记录。
- 必填 `CHONGCHONG_ENV=test` 或 `gray`;`CHONGCHONG_ADMIN_API_BASE_URL` 与 `CHONGCHONG_MERCHANT_API_BASE_URL` 必须完全指向同一 HTTPS `/api` 统一入口。拒绝 `prod/dev` 及旧 `CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL`、`CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL` 配置(即使值与统一入口相同也必须移除)。所有业务及文件上传只使用登录返回的 `accessToken`,不读取海外 Token。灰度由运行者显式选择外部灰度配置,不自动读取、修改或回退环境文件。
- 客户必须为 `ACTIVE` 且箱规初始化为 `READY`;`PROFILE_REQUIRED` 等状态阻断,不自动完善资料或审核客户。客户锁定配置:`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 仓显式指定,洛杉矶是后续接柜仓,不是第二票。输入与检查点放仓库外,同一批次始终使用同一检查点文件,禁止并发另开路径或换批次重试。
- 尾程签收入口 `delivery-stage <尾程事实输入.json> <独立签收检查点.json> --confirm-production-delivery`:须另获当次生产写入授权。脚本只接受本批次已核对的排车、出库、派送和尾程订单身份;按状态版本推进 `DELIVERING`,上传验收用签收文件并保存资料,回查 `DELIVERED`、国内入仓 `DELIVERED` 和头程订单 `COMPLETED`。上传或状态写入结果不确定时停止;托盘明细或尾程子状态未同步会记录为阻断项。
- `prod-release-order-stage.mjs` 是上述入口使用的内部订单阶段与检查点实现,不可直接执行。先落盘并同步意图,再调用 HTTP;检查点绑定环境、客户、操作者、批次和载荷摘要,不保存密码/Token/完整申报。创建与提交仅在显式续跑时复用原幂等键;审核无幂等键,响应丢失后只回查,若仍不能确认已审核则停止并要求人工核对,不自动再次审核。崩溃残留 `.lock` 时先确认无运行进程、检查业务状态,再人工解除锁;不得删除业务检查点。
- 单票入口 `order-stage <外部订单输入.json> <本批次检查点.json> --confirm-single-order [--resume]`:须另获当次目标环境写入授权。自动执行基本前置检查,额外校验指定客户可选的唯一渠道/包税维度与 1~2 箱实际重量、体积报价,然后创建一票、提交、审核;不创建图片、地址、托盘或库位。仅支持 AUTO 入仓码、普通海运 FBA 自送散货、单 SKU 非木制非危险品;目的 FBA 仓显式指定,洛杉矶是后续接柜仓,不是第二票。输入与检查点放仓库外,同一批次始终使用同一检查点文件,禁止并发另开路径或换批次重试。
- 尾程签收入口 `delivery-stage <尾程事实输入.json> <独立签收检查点.json> --confirm-single-delivery`:须另获当次目标环境写入授权。脚本只接受本批次已核对的排车、出库、派送和尾程订单身份;按状态版本推进 `DELIVERING`,上传验收用签收文件并保存资料,回查 `DELIVERED`、国内入仓 `DELIVERED` 和头程订单 `COMPLETED`。上传或状态写入结果不确定时停止;托盘明细或尾程子状态未同步会记录为阻断项。
- `prod-release-order-stage.mjs` 是上述入口使用的内部订单阶段与检查点实现,不可直接执行。先落盘并同步意图,再调用 HTTP;检查点绑定环境模式及统一 API 地址、客户、操作者、批次和载荷摘要,不保存密码/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`。一票主链不覆盖多客户混柜、退货异常、真实付款、外部下单、邮件发送或页面交互。
- 完整正常链:`full-flow <外部订单输入.json> <独立全流程检查点.json> --confirm-single-full-flow [--resume]`。仅支持 `BUY_DECLARATION` 买单报关、单 SKU、1~2 箱、义乌起运及洛杉矶接柜的 FBA 正常链。先用现有客户下单审核,再测量/费用确认、订舱/分柜、国内 PDA 装柜、买单报关、人工船节点、目的港提柜预约、海外 PDA 收货/还空柜、排车/派送、车次调度/拣货/发车、签收回传及车次结案释放资源。任何HTTP或归属/数量门禁失败都停止,保留同票检查点,不新建订单代替失败。
- 全流程内部模块:`prod-release-full-flow.mjs` 负责单票阶段编排和独立子检查点;`prod-release-http.mjs` 负责统一 Gateway、写前请求摘要落盘、失败诊断、合成文件上传及独立登录会话退出;`prod-release-domestic-stage.mjs` 负责国内正常链;`prod-release-overseas-stage.mjs` 负责海外正常链和签收后车次结案。这些文件没有独立CLI,不运行全量fixture,不创建账号、客户、车辆、司机、供应商等主档。正常业务会创建本票货柜、订舱、作业托盘、排车、车次、派送及费用记录。
- 完整输入还需 `domestic: {palletId, assigneeId, bookingAgent:{id,name}, carrier:{id,name,carrierCode}, terminalFleet:{id,name}, estimatedSailingAt, estimatedArrivalAt}` 和 `resources:{vehicleId,driverId,carrierName}`,均引用既有启用且有资格的资源。订单级及货物行都必须明确提供 `shipmentId/referenceId`;不能只填货物行。报告/输入/检查点使用仓库外私有路径;公开交付仅保留脱敏结果、业务号及诊断编号。
- 全流程报告只有全部正常链及签收回传、资源释放核对成功才输出 `SINGLE_ORDER_HTTP_COMPLETED` 与 `fullFlowVerified:true`,其范围仅为 `ONE_ORDER_NORMAL_HTTP_PATH`。测量、资质、装柜、卸柜和签收文件均明确为合成验收材料,不代表真实物流证据;不覆盖退税报关、多客户/拆单/异常、故障注入、真实支付、第三方下单、邮件、浏览器及设备验收。阶段检查点已完成命令复用原结果,未知写入意图必须人工核对,不能自动重发。
- 离线验证:`node --test scripts/prod-release-acceptance.test.mjs scripts/prod-release-order-stage.test.mjs scripts/prod-release-delivery-stage.test.mjs scripts/prod-release-measurement-plan.test.mjs scripts/prod-release-http.test.mjs scripts/prod-release-domestic-stage.test.mjs scripts/prod-release-overseas-stage.test.mjs scripts/script-documentation-contract.test.mjs`。一票主链不覆盖多客户混柜、退货异常、真实付款、外部下单、邮件发送或页面交互。最终输出单独报告 `sessionCleanup`,会话退出不受业务请求次数上限影响。
订单阶段与恢复验证:`node --test scripts/prod-release-order-stage.test.mjs`,包括断网响应丢失、原幂等键复用、未知审核结果停机、账号/客户/环境/载荷漂移、并发锁、损坏检查点与入口 HTTP mock。生产运行示例(只到已审核,执行前确认授权,不代表全链完成):
订单阶段与恢复验证:`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-single-order
# 中断后保持相同输入、账号和检查点;不要删除检查点或换批次。
node --env-file="<外部生产配置.env>" scripts/prod-release-acceptance.mjs order-stage "<外部订单输入.json>" "<本批次检查点.json>" --confirm-production-order --resume
node --env-file="<明确目标环境配置.env>" scripts/prod-release-acceptance.mjs order-stage "<外部订单输入.json>" "<本批次检查点.json>" --confirm-single-order --resume
```
统一 TEST 验收账号、密码和 Gateway 由仓库外配置文件显式提供,例如 `$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env`,不写入仓库。文件提供以下变量:
@@ -497,3 +504,5 @@ Admin 全量中的 `phase4-cross-region-events` 使用内部 `test-event-fault-e
`f32-pda-hold-race.mjs` 是显式 TEST 暂扣/PDA拣货并发验收入口,离线合同 `f32-pda-hold-race.test.mjs`。命令为 `node --env-file="<TEST配置>" --env-file="<Jenkins配置>" --env-file="<本轮自有PDA配置>" scripts/f32-pda-hold-race.mjs --plan <冻结计划> --report <全新报告> --confirm-test-runtime yes`;只消费已实际入仓、4箱可用、无占用、调度审核通过且同仓PDA canPick=true的本轮独立样本,不创建订单或车次。凭据只读明确TEST/Jenkins/自有PDA配置,不写报告;计划冻结客户、来源订单、仓、余额、位置、排车明细、车次、PDA成员和不可变Lastleg镜像ID。通过Jenkins在指定TEST Lastleg所用MySQL建立仅针对原订单的12秒行锁租约(SELECT FOR UPDATE、固定等待、ROLLBACK,无业务DML),用performance_schema实际锁等待证明两条正式HTTP命令同时竞争,再按暂扣先到/拣货先到两种顺序验证。会真实暂扣、解除本轮暂扣和拣货1箱,逐位置、库存流水、拣货及来源归属、原键重放分别核对;两个阶段顺序执行,不并发造数。结束恢复自有PDA停用状态并退出认证会话,测试单据、部分拣货和资源保留。命令结果未知或断言失败时保存原报告及逐步checkpoint,不自动重发命令、重建样本、删数据或回填SQL;租约自动释放后先核对原单和原命令,未证实的暂扣恢复必须单独按原事实处理。离线通过不代表实际MySQL/PDA或四端页面通过。
- `db/return-package-packing-boundary-incremental-20261010.sql`:退货包裹/箱出库及标签字段增量,显式选定 baiyue_lastleg 库后写结构;无凭据、无业务造数,可重复执行,失败停止并检查数据库错误。
@@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa
const overseasSchemas = ['baiyue_lastleg', 'baiyue_lastlag_warehouse']
test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => {
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 226], [overseas, overseasSchemas, domesticSchemas, 184]]) {
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 226], [overseas, overseasSchemas, domesticSchemas, 197]]) {
assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed)
assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed)
const body = sql.replace(/^--.*$/gm, '')
@@ -39,7 +39,7 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前
assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi)
assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i)
assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1)
assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (226|184) AS initialized_tables;$/)
assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (226|197) AS initialized_tables;$/)
}
})
+2 -1
View File
@@ -201,7 +201,8 @@ Assert-Contains $docsIndex '[测试环境独立任务协作登记](独立任务
Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境独立任务协作登记.md)' '文档开发登记入口'
# BEGIN CURRENT TEST REGISTRY CONTRACT
function Assert-TestTaskRegistry([string]$registry) {
$current = ($registry -split '(?m)^## 2026-10-04 全流程唯一持续目标与车辆交接(当前)')[0]
# Count this authorized group only; newer independently authorized groups must not be mixed into it.
$current = [regex]::Match($registry, '(?ms)^## 2026-10-04 用户确认的三个独立执行对话(当前派工)\r?\n.*?(?=^## |\z)').Value
Assert-Contains $current '## 2026-10-04 用户确认的三个独立执行对话(当前派工)' '当前全流程协作入口'
Assert-Contains $current '用户明确选择“按这 3 个独立对话创建”' '测试任务精确数量授权'
Assert-Contains $current '所属唯一总控仍为 `退税报关` / `01a0d0da-130f-7d43-97b5-9c76abc1b5f1`' '测试所属总控'
@@ -5,6 +5,8 @@ import test from 'node:test'
const source = readFileSync(new URL('./agents-index-contract.ps1', import.meta.url), 'utf8').replaceAll('\r\n', '\n')
const registry = readFileSync(new URL('../../docs/独立任务协作登记.md', import.meta.url), 'utf8')
const currentGroup = registry.match(/^## 2026-10-04 用户确认的三个独立执行对话(当前派工)\r?\n[\s\S]*?(?=^## |$(?![\s\S]))/m)?.[0]
assert.ok(currentGroup, 'mutations must target their own authorized group')
const contract = source.split('# BEGIN CURRENT TEST REGISTRY CONTRACT\n')[1]?.split('# END CURRENT TEST REGISTRY CONTRACT')[0]
assert.ok(contract, 'test exercises the production PowerShell registry validator')
function failures(text) {
@@ -17,7 +19,10 @@ function Assert-NotContains([string]$text,[string]$needle,[string]$scope){if($te
${contract}
Assert-TestTaskRegistry ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')))
ConvertTo-Json -InputObject @($script:failures) -Compress`
return JSON.parse(execFileSync('pwsh', ['-NoProfile', '-NonInteractive', '-Command', command], { encoding: 'utf8' }))
// Large registries exceed Windows command-line limits; the generated harness is passed over stdin.
return JSON.parse(execFileSync('pwsh', ['-NoProfile', '-NonInteractive', '-Command',
'$registryHarness = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([Console]::In.ReadToEnd())); & ([ScriptBlock]::Create($registryHarness))'],
{ encoding: 'utf8', input: Buffer.from(command).toString('base64') }))
}
test('actual explicitly authorized TEST registry passes the production validator', () => {
assert.deepEqual(failures(registry), [])
@@ -32,4 +37,5 @@ for (const [name, mutate] of [
['obsolete empty status', s => s + '\n当前无登记任务'],
['foreign project identity', s => s + '\n6f874ec3-d3b2-49bd-8625-327f4d685f98'],
['retired controller', s => s + '\n01a0194a-259c-7171-8967-074731a27618'],
]) test(`production validator rejects ${name}`, () => assert.ok(failures(mutate(registry)).length > 0))
]) test(`production validator rejects ${name}`, () =>
assert.ok(failures(registry.replace(currentGroup, mutate(currentGroup))).length > 0))
+25
View File
@@ -5054,3 +5054,28 @@ SET @return_sku_index_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistic
PREPARE return_sku_index_stmt FROM @return_sku_index_ddl;
EXECUTE return_sku_index_stmt;
DEALLOCATE PREPARE return_sku_index_stmt;
-- 退货以包裹/箱为最小出库实物单位;部分数量必须先合箱生成新标签箱。
-- 可重复执行,目标库 baiyue_lastleg。
USE baiyue_lastleg;
-- 合箱后的新箱与原包裹共用同一箱唛、标签和来源追溯模型。
-- 流水约束允许部分合箱/拆分只扣件数,不虚减箱数。
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement'
AND constraint_name='chk_ll_return_outbound_movement_delta'),
'ALTER TABLE ll_return_inventory_outbound_movement DROP CHECK chk_ll_return_outbound_movement_delta, ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)',
'ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 同一包裹可分批合箱/出库,重复提交仍由 outbound_pick_id + SKU 幂等约束保护。
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement'
AND index_name='uk_ll_return_outbound_carton_sku'),
'ALTER TABLE ll_return_inventory_outbound_movement DROP INDEX uk_ll_return_outbound_carton_sku, ADD INDEX idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code)',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 新签收及新合箱必须打印并扫码确认标签;既有实物按原状态继续作业。
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签''');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -0,0 +1,123 @@
-- 退货近期增量汇总(2026-10-12),MySQL 8.0.16+,可重复执行。
-- 连接到需要升级的 MySQL 实例后,整份执行;目标仅 baiyue_lastleg。
-- USE 指定数据库,不指定服务器;本脚本不创建数据库,不写测试单据,不修改库存/清点事实。
-- 前提:已有尾程和退货合箱/出库基线表(不是新库初始化脚本)。
-- 汇总10月10日/12日直接退货增量,并补齐清点、事实数量及多次换标申请结构。
-- 排车车队/IAM权限为独立业务基线,不纳入本退货脚本。
-- PARCEL三类型换标、RETURN_PACK/RETURN_COMPLETION_TRANSFER历史以及清点续作均复用现有结构。
-- DDL隐式提交;执行客户端遇错须停止,不使用 --force。可重复执行不等于整个脚本事务回滚。
USE `baiyue_lastleg`;
SELECT DATABASE() AS target_database, @@hostname AS mysql_host, @@port AS mysql_port, VERSION() AS mysql_version;
-- 一、件数出库快照、部分扣件守恒、同箱分批出库索引。
-- 退货单 SKU 按件数出库快照。目标库:baiyue_lastleg。可重复执行。
USE baiyue_lastleg;
SET @return_sku_quantity_ddl = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='ll_outbound_pallet_item' AND column_name='return_sku_quantity'),
'SELECT 1',
'ALTER TABLE ll_outbound_pallet_item ADD COLUMN return_sku_quantity BIGINT NULL COMMENT ''退货单SKU本来源计划出库件数快照,NULL为历史整箱出库'' AFTER sku_code'
);
PREPARE return_sku_quantity_stmt FROM @return_sku_quantity_ddl;
EXECUTE return_sku_quantity_stmt;
DEALLOCATE PREPARE return_sku_quantity_stmt;
-- 部分出库仅扣件数;整箱扣一箱,保留守恒和非负约束。
SET @return_sku_check_ddl = IF(EXISTS(SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' AND constraint_name='chk_ll_return_outbound_movement_delta'), 'ALTER TABLE ll_return_inventory_outbound_movement DROP CHECK chk_ll_return_outbound_movement_delta, ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)', 'ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)');
PREPARE return_sku_check_stmt FROM @return_sku_check_ddl;
EXECUTE return_sku_check_stmt;
DEALLOCATE PREPARE return_sku_check_stmt;
-- 二、标签状态(旧记录保留NULL,应用按既有兼容规则处理)。
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签''');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 三、清点结构;不将历史已签收箱自动回退为待清点。
-- 退货托盘签收箱内清点;MySQL 8;仅在目标尾程库执行,可重复运行。
USE baiyue_lastleg;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='count_status'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN count_status VARCHAR(16) NOT NULL DEFAULT ''CONFIRMED'' COMMENT ''箱内清点状态 PENDING待清点 CONFIRMED已确认''');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='counted_at'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点确认时间''');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='counted_by'),'SELECT 1','ALTER TABLE ll_return_inventory_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点确认操作人''');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
SET @rc_count_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND index_name='idx_ll_return_count'),'SELECT 1','CREATE INDEX idx_ll_return_count ON ll_return_inventory_carton(overseas_warehouse_id,count_status,status,source_receipt_id)');
PREPARE rc_count_stmt FROM @rc_count_ddl; EXECUTE rc_count_stmt; DEALLOCATE PREPARE rc_count_stmt;
-- MySQL 8,本地尾程库,可重复执行;不修改现有清点数量或自动释放未完成任务。
USE baiyue_lastleg;
-- 退货箱内清点作业会话:每位操作员只能认领一个批次,每个批次只能被一人认领。
CREATE TABLE IF NOT EXISTS ll_return_carton_count_session (
operator_id VARCHAR(128) NOT NULL COMMENT '平台PDA操作员标识',
warehouse_id VARCHAR(128) NOT NULL COMMENT '认领时绑定海外仓标识',
receipt_id BIGINT NULL COMMENT '正在清点的签收批次,完成后释放为NULL',
started_at DATETIME(3) NULL COMMENT '本次开始清点时间',
created_at DATETIME(3) NOT NULL COMMENT '会话创建时间',
updated_at DATETIME(3) NOT NULL COMMENT '会话更新时间',
PRIMARY KEY(operator_id),
UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id)
) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话';
-- 四、换标事实数量与一单多申请;保留幂等唯一键。
SET @ddl = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'll_relabel_pda_unit_fact'
AND column_name = 'quantity'
),
'SELECT 1',
'ALTER TABLE ll_relabel_pda_unit_fact ADD COLUMN quantity INT NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER reason'
)
);
PREPARE ll_return_bulk_relabel_quantity_stmt FROM @ddl;
EXECUTE ll_return_bulk_relabel_quantity_stmt;
DEALLOCATE PREPARE ll_return_bulk_relabel_quantity_stmt;
SET @relabel_has_unique = (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'll_relabel_application'
AND index_name = 'uk_ll_relabel_application_order');
SET @relabel_has_index = (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'll_relabel_application'
AND index_name = 'idx_ll_relabel_application_order');
SET @relabel_index_sql = CASE
WHEN @relabel_has_unique > 0 AND @relabel_has_index = 0 THEN
'ALTER TABLE ll_relabel_application DROP INDEX uk_ll_relabel_application_order, ADD INDEX idx_ll_relabel_application_order (lastleg_order_id)'
WHEN @relabel_has_unique > 0 THEN
'ALTER TABLE ll_relabel_application DROP INDEX uk_ll_relabel_application_order'
WHEN @relabel_has_index = 0 THEN
'ALTER TABLE ll_relabel_application ADD INDEX idx_ll_relabel_application_order (lastleg_order_id)'
ELSE 'SELECT 1' END;
PREPARE relabel_index_migration FROM @relabel_index_sql;
EXECUTE relabel_index_migration;
DEALLOCATE PREPARE relabel_index_migration;
-- 同箱分批索引:同时已有旧/新索引或旧索引已删除时也可执行。
SET @return_index_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' AND index_name='uk_ll_return_outbound_carton_sku'), 'ALTER TABLE ll_return_inventory_outbound_movement DROP INDEX uk_ll_return_outbound_carton_sku', 'SELECT 1');
PREPARE return_index_stmt FROM @return_index_ddl; EXECUTE return_index_stmt; DEALLOCATE PREPARE return_index_stmt;
SET @return_index_ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement' AND index_name='idx_ll_return_outbound_carton_sku'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_outbound_movement ADD INDEX idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code)');
PREPARE return_index_stmt FROM @return_index_ddl; EXECUTE return_index_stmt; DEALLOCATE PREPARE return_index_stmt;
-- 五、原包装及尺寸重量来源。逐列检查,支持部分执行后重跑。
-- 托盘重量/体积由当前绑定箱的实测尺寸重量汇总,无需新增托盘字段。
SET @return_measurement_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='original_packaging'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN original_packaging TINYINT(1) NULL COMMENT ''是否使用原箱原包装'' AFTER weight');
PREPARE return_measurement_stmt FROM @return_measurement_ddl; EXECUTE return_measurement_stmt; DEALLOCATE PREPARE return_measurement_stmt;
SET @return_measurement_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='measurement_source'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN measurement_source VARCHAR(32) NULL COMMENT ''尺寸重量来源 PALLET_MEASURED托盘实测 UNIT_SUM子箱汇总'' AFTER original_packaging');
PREPARE return_measurement_stmt FROM @return_measurement_ddl; EXECUTE return_measurement_stmt; DEALLOCATE PREPARE return_measurement_stmt;
SET @return_measurement_ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='original_packaging'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN original_packaging TINYINT(1) NULL COMMENT ''是否使用原箱原包装'' AFTER weight');
PREPARE return_measurement_stmt FROM @return_measurement_ddl; EXECUTE return_measurement_stmt; DEALLOCATE PREPARE return_measurement_stmt;
-- 校验输出:核对新增字段、索引和守恒约束。
SELECT table_name,column_name,column_type,is_nullable,column_default FROM information_schema.columns
WHERE table_schema=DATABASE() AND (
(table_name='ll_outbound_pallet_item' AND column_name='return_sku_quantity') OR
(table_name='ll_return_inventory_carton' AND column_name IN('label_status','count_status','counted_at','counted_by','original_packaging')) OR
(table_name='ll_return_receipt' AND column_name IN('original_packaging','measurement_source')) OR
(table_name='ll_relabel_pda_unit_fact' AND column_name='quantity')) ORDER BY table_name,column_name;
SELECT table_name,index_name,non_unique,GROUP_CONCAT(column_name ORDER BY seq_in_index) AS indexed_columns
FROM information_schema.statistics WHERE table_schema=DATABASE() AND index_name IN
('idx_ll_return_outbound_carton_sku','uk_ll_return_outbound_pick_sku','idx_ll_relabel_application_order','uk_ll_return_count_session_receipt','idx_ll_return_count') GROUP BY table_name,index_name,non_unique;
SELECT constraint_name,check_clause FROM information_schema.check_constraints WHERE constraint_schema=DATABASE() AND constraint_name='chk_ll_return_outbound_movement_delta';
@@ -0,0 +1,24 @@
-- 退货以包裹/箱为最小出库实物单位;部分数量必须先合箱生成新标签箱。
-- 可重复执行,目标库 baiyue_lastleg。
USE baiyue_lastleg;
-- 合箱后的新箱与原包裹共用同一箱唛、标签和来源追溯模型。
-- 流水约束允许部分合箱/拆分只扣件数,不虚减箱数。
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement'
AND constraint_name='chk_ll_return_outbound_movement_delta'),
'ALTER TABLE ll_return_inventory_outbound_movement DROP CHECK chk_ll_return_outbound_movement_delta, ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)',
'ALTER TABLE ll_return_inventory_outbound_movement ADD CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 AND total_before-total_after=quantity AND available_before-available_after=quantity AND cartons_after>=0 AND cartons_before-cartons_after IN (0,1) AND total_after>=available_after)');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 同一包裹可分批合箱/出库,重复提交仍由 outbound_pick_id + SKU 幂等约束保护。
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_outbound_movement'
AND index_name='uk_ll_return_outbound_carton_sku'),
'ALTER TABLE ll_return_inventory_outbound_movement DROP INDEX uk_ll_return_outbound_carton_sku, ADD INDEX idx_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code)',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 新签收及新合箱必须打印并扫码确认标签;既有实物按原状态继续作业。
SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签''');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+35 -24
View File
@@ -3,7 +3,7 @@ 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.
// Single-order acceptance never invokes the multi-customer fixture runner.
export const STAGES = Object.freeze([
'CUSTOMER_ORDER', 'ORDER_REVIEW', 'YIWU_MEASUREMENT', 'CUSTOMER_FEE_CONFIRMATION',
'CONTAINER_ALLOCATION', 'DOMESTIC_PDA_LOADING', 'CUSTOMS_AND_BOOKING',
@@ -20,9 +20,8 @@ export const DOMESTIC_WAREHOUSE_CODES = Object.freeze(['CN-YIWU-01', 'CN-DONGGUA
export function plan() {
return {
scope: { orders: 1, origins: [...DOMESTIC_WAREHOUSE_CODES], 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' })),
status: 'FULL_FLOW_IMPLEMENTED_NOT_HTTP_VERIFIED', fullFlowVerified: false,
stages: STAGES.map(name => ({ name, status: 'IMPLEMENTED_NOT_HTTP_VERIFIED' })),
excluded: ['其他客户', '混柜及拆单', '退货及异常分支', '真实付款', '第三方下单或发送邮件', '浏览器和设备交互'],
policy: { createMasterData: false, createAccounts: false, deleteData: false },
}
@@ -43,20 +42,22 @@ function baseUrl(value, path) {
}
export function config(env) {
if (env.CHONGCHONG_ENV !== 'prod') throw new Error('必须显式配置 CHONGCHONG_ENV=prod')
if (!['test', 'gray'].includes(env.CHONGCHONG_ENV)) throw new Error('必须显式配置 CHONGCHONG_ENV=test 或 gray')
for (const key of ['CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL', 'CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL']) {
if (env[key] != null) throw new Error(`移除旧海外网关配置:${key};所有业务使用统一 Gateway`)
}
const get = key => requireValue(env, `CHONGCHONG_${key}`)
const cn = baseUrl(get('ADMIN_API_BASE_URL'), '/api')
const overseas = baseUrl(get('ADMIN_OVERSEAS_API_BASE_URL'), '/overseas-api')
const apiBase = baseUrl(get('ADMIN_API_BASE_URL'), '/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 前端代理')
if (apiBase !== merchant) {
throw new Error('单票入口要求平台及客户 API 使用同一 HTTPS /api 统一入口')
}
const customer = { id: get('ACCEPTANCE_CUSTOMER_ID'), code: get('ACCEPTANCE_CUSTOMER_CODE'), name: get('ACCEPTANCE_CUSTOMER_NAME') }
if (!/^[1-9]\d*$/.test(customer.id)) throw new Error('客户 ID 必须为非零十进制字符串')
const credentials = Object.fromEntries(['ADMIN', 'MERCHANT', 'CN_PDA', 'OVERSEAS_LAX_PDA'].map(role =>
[role, { loginId: get(`${role}_USERNAME`), password: get(`${role}_PASSWORD`) }]))
if (get('OVERSEAS_LAX_PDA_WAREHOUSE_CODE') !== 'US-LAX-01') throw new Error('海外 PDA 必须绑定洛杉矶仓')
return { cn, overseas, customer, credentials }
return { environment: env.CHONGCHONG_ENV, apiBase, customer, credentials }
}
function id(value) {
@@ -88,11 +89,11 @@ export function client(settings, fetcher = fetch, policy = allowedRequest) {
return {
get requests() { return requests },
async request(region, method, path, token, body, idempotencyKey) {
if (!policy(method, path, region, body, idempotencyKey)) throw new Error('请求不在生产前置检查白名单内')
if (++requests > 100) throw new Error('超过单次前置检查请求上限')
if (!policy(method, path, region, body, idempotencyKey)) throw new Error('请求不在单票请求白名单内')
if (++requests > 100) throw new Error('超过单阶段请求上限')
let response
try {
response = await fetcher((region === 'cn' ? settings.cn : settings.overseas) + path, {
response = await fetcher(settings.apiBase + path, {
method, redirect: 'error', signal: AbortSignal.timeout(20_000),
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}),
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}) },
@@ -129,7 +130,7 @@ 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`)
if (!logins[role]?.accessToken) throw new Error(`${role} 登录缺少统一 accessToken`)
}
const admin = logins.ADMIN.accessToken
assertCustomer(await http.request('cn', 'GET', `/platform/v1/iam/tenants/${settings.customer.id}`, admin), settings.customer)
@@ -152,8 +153,8 @@ export async function preflight(settings, http, receiveContext = () => {}) {
!cnWarehouses.some(w => w.warehouseCode === code && id(w.id) === warehouses[code]))) {
throw new Error('国内 PDA 不可访问义乌或东莞仓')
}
const overseasToken = logins.OVERSEAS_LAX_PDA.overseasAccessToken
if (!overseasToken) throw new Error('洛杉矶 PDA 登录缺少海外 Token')
const overseasToken = logins.OVERSEAS_LAX_PDA.accessToken
if (!overseasToken) throw new Error('洛杉矶 PDA 登录缺少统一 accessToken')
const binding = await http.request('overseas', 'GET', '/pda/v1/last-leg/receiving-containers/warehouse-profile', overseasToken)
if (binding?.warehouseCode !== 'US-LAX-01' || id(binding.warehouseId) !== warehouses['US-LAX-01']) throw new Error('洛杉矶 PDA 仓库绑定不符')
const counts = {}
@@ -169,14 +170,14 @@ export async function preflight(settings, http, receiveContext = () => {}) {
return { ...plan(), status: 'BASIC_PREFLIGHT_PASSED', requests: http.requests,
checks: ['客户身份及箱规状态', '四个账号登录', '义乌、东莞及洛杉矶仓', 'PDA 仓库范围', '国内既有托盘及可用库位'],
resourceCounts: counts,
pending: ['单票执行入口的精确线路及小票报价检查', '供应商与海外资源门禁', '测量至签收的写入执行器和检查点', '所有业务阶段真实 HTTP 验证'],
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,
const bound = { ...normalizeOrderInput(input), customer: settings.customer, environment: { mode: settings.environment, apiBase: settings.apiBase },
operator: { merchant: settings.credentials.MERCHANT.loginId, admin: settings.credentials.ADMIN.loginId } }
validateOrderInput(bound)
return withOrderJournal(journalPath, bound, async (state, save) => {
@@ -229,9 +230,19 @@ export async function executeOrder(settings, input, journalPath, resume, fetcher
export async function main(argv = process.argv.slice(2), env = process.env) {
const [mode = 'plan', ...rest] = argv
if (mode === 'full-flow') {
if ((rest.length !== 3 && rest.length !== 4) || rest[2] !== '--confirm-single-full-flow'
|| rest.length === 4 && rest[3] !== '--resume') throw Error('full-flow 需要输入文件、检查点、--confirm-single-full-flow,可选 --resume')
const inputPath = resolve(rest[0]), journalPath = resolve(rest[1])
if (inputPath === journalPath) throw Error('输入和检查点不能使用同一文件')
const { executeFullFlow } = await import('./prod-release-full-flow.mjs')
const result = await executeFullFlow(config(env), JSON.parse(await readFile(inputPath, 'utf8')), journalPath, rest[3] === '--resume')
console.log(JSON.stringify(result, null, 2))
return
}
if (mode === 'delivery-stage') {
if (rest.length !== 3 || rest[2] !== '--confirm-production-delivery') {
throw new Error('delivery-stage 需要输入文件、独立签收检查点、--confirm-production-delivery')
if (rest.length !== 3 || rest[2] !== '--confirm-single-delivery') {
throw new Error('delivery-stage 需要输入文件、独立签收检查点、--confirm-single-delivery')
}
const inputPath = resolve(rest[0]), journalPath = resolve(rest[1])
if (inputPath === journalPath) throw new Error('输入和检查点不能使用同一文件')
@@ -252,8 +263,8 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
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')
if ((rest.length !== 3 && rest.length !== 4) || rest[2] !== '--confirm-single-order'
|| rest.length === 4 && rest[3] !== '--resume') throw new Error('order-stage 需要输入文件、检查点、--confirm-single-order,可选 --resume')
const inputPath = resolve(rest[0]), journalPath = resolve(rest[1])
if (inputPath === journalPath) throw new Error('输入和检查点不能使用同一文件')
const input = JSON.parse(await readFile(inputPath, 'utf8'))
@@ -261,7 +272,7 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
console.log(JSON.stringify(result))
return
}
if (!['plan', 'preflight'].includes(mode) || rest.length) throw new Error('用法:prod-release-acceptance.mjs [plan|preflight];全链路执行尚未开放')
if (!['plan', 'preflight'].includes(mode) || rest.length) throw new Error('用法:prod-release-acceptance.mjs [plan|preflight];完整链使用 full-flow <输入> <检查点> --confirm-single-full-flow')
if (mode === 'plan') { console.log(JSON.stringify(plan(), null, 2)); return }
const settings = config(env)
const reportPath = resolve('reports', `prod-release-preflight-${Date.now()}.json`)
@@ -276,5 +287,5 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
}
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 })
main().catch(error => { console.error(`单票验收入口失败:${error instanceof SyntaxError ? '输入或检查点 JSON 无效' : error.message}`); process.exitCode = 1 })
}
+38 -6
View File
@@ -3,9 +3,8 @@ import assert from 'node:assert/strict'
import { plan, config, assertCustomer, allowedRequest, client, listAll, preflight, main, DOMESTIC_WAREHOUSE_CODES } from './prod-release-acceptance.mjs'
function environment() {
const env = { CHONGCHONG_ENV: 'prod',
const env = { CHONGCHONG_ENV: 'gray',
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',
@@ -25,8 +24,8 @@ test('single order scope does not claim implemented or full coverage', () => {
assert.deepEqual(result.scope.origins, [...DOMESTIC_WAREHOUSE_CODES])
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)
assert.equal(result.stages.filter(s => s.status === 'IMPLEMENTED_NOT_HTTP_VERIFIED').length, 14)
assert.equal(result.stages.filter(s => s.status === 'NOT_IMPLEMENTED').length, 0)
})
test('configuration has no development/test fallback', () => {
assert.throws(() => config({ ADMIN_TEST_GATEWAY: 'https://acceptance.invalid' }))
@@ -85,7 +84,7 @@ 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 (method === 'POST') return { accessToken: 'fixture-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' }, { id: '4', warehouseCode: 'CN-DONGGUAN-01' }]
@@ -110,6 +109,39 @@ test('PDA warehouse mismatch blocks preflight', async () => {
})
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, {}), /尚未开放/)
await assert.rejects(main(args, {}), /完整链使用/)
}
})
test('explicit gray/test mode shares one API base and rejects retired environment keys', () => {
for (const mode of ['gray', 'test']) {
const settings = config({ ...environment(), CHONGCHONG_ENV: mode })
assert.equal(settings.environment, mode)
assert.equal(settings.apiBase, 'https://acceptance.invalid/api')
assert.equal(settings.overseas, undefined)
}
for (const mode of ['prod', 'dev', '', undefined]) {
assert.throws(() => config({ ...environment(), CHONGCHONG_ENV: mode }), /显式配置/)
}
for (const key of ['CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL', 'CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL']) {
assert.throws(() => config({ ...environment(), [key]: 'https://acceptance.invalid/api' }), /旧海外网关/)
}
})
test('overseas PDA calls the unified gateway using its ordinary access token', async () => {
const calls = []
const http = client(config(environment()), async (url, options) => {
calls.push({ url, token: options.headers.Authorization })
return { ok: true, status: 200, json: async () => ({ code: '200', data: {} }) }
})
await http.request('overseas', 'GET', '/pda/v1/last-leg/receiving-containers/warehouse-profile', 'ordinary-access')
assert.deepEqual(calls, [{ url: 'https://acceptance.invalid/api/pda/v1/last-leg/receiving-containers/warehouse-profile', token: 'Bearer ordinary-access' }])
})
test('unapproved customer blocks preflight before any business stage', async () => {
const http = fakeHttp(), original = http.request.bind(http)
http.request = async (...args) => args[2].includes('/iam/')
? { ...profile, status: 'PROFILE_REQUIRED' } : original(...args)
await assert.rejects(preflight(config(environment()), http), /审核状态/)
})
+8 -6
View File
@@ -5,7 +5,8 @@ import { fingerprint } from './prod-release-order-stage.mjs'
const check = (ok, message) => { if (!ok) throw new Error(message) }
const longId = value => typeof value === 'string' && /^[1-9]\d*$/.test(value)
const segment = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(value)
const number = value => Number.isSafeInteger(Number(value)) ? Number(value) : NaN
const number = value => (typeof value === 'number' || typeof value === 'string' && /^\d+$/.test(value))
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0 ? Number(value) : NaN
export function validateDeliveryInput(input) {
check(/^[A-Za-z0-9][A-Za-z0-9_-]{7,47}$/.test(input.batch || ''), '无效验收批次')
@@ -79,6 +80,7 @@ export async function runDeliveryStage({ input, customerId, state, save, request
let delivery = await readDelivery()
if (delivery.order.status === 'WAIT_DELIVERY') {
check(!state.startIntent, '配送中操作结果不确定,禁止重发;请核对服务端状态')
check(Number.isSafeInteger(number(delivery.order.version)), '派送状态版本缺失或无效')
await save({ startIntent: true })
await request('overseas', 'PUT', scoped(`${deliveryPath}/status`), {
status: 'DELIVERING', expectedVersion: number(delivery.order.version), remark: `RELEASE-ACCEPTANCE:${input.batch}`,
@@ -142,12 +144,12 @@ export async function runDeliveryStage({ input, customerId, state, save, request
export async function executeDelivery(settings, input, journalPath, fetcher = fetch) {
validateDeliveryInput(input)
const binding = fingerprint({ input, cn: settings.cn, overseas: settings.overseas,
const binding = fingerprint({ input, environment: settings.environment, apiBase: settings.apiBase,
customer: settings.customer, admin: settings.credentials.ADMIN.loginId })
return withDeliveryJournal(journalPath, binding, async (state, save) => {
const { client } = await import('./prod-release-acceptance.mjs')
const login = await client(settings, fetcher).request('cn', 'POST', '/auth/login', null, settings.credentials.ADMIN)
check(login?.accessToken && login?.overseasAccessToken, '缺少国内或海外令牌')
check(login?.accessToken, '缺少统一 accessToken')
const root = '/platform/v1/last-leg'
const delivery = `${root}/delivery-orders/${input.deliveryNo}`
const scoped = path => `${path}?customerId=${settings.customer.id}`
@@ -161,13 +163,13 @@ export async function executeDelivery(settings, input, journalPath, fetcher = fe
: region === 'overseas' && (method === 'GET' && reads.has(path) || method === 'PUT' && writes.has(path)))
return runDeliveryStage({ input, customerId: settings.customer.id, state, save,
request: (region, method, path, body) => http.request(region, method, path,
region === 'cn' ? login.accessToken : login.overseasAccessToken, body),
login.accessToken, body),
upload: async (path, name, text) => {
check(path === scoped(`${delivery}/documents/RECEIPT/files`), '上传路径不符')
const form = new FormData(); form.append('file', new Blob([text], { type: 'text/plain' }), name)
let response
try { response = await fetcher(settings.overseas + path, { method: 'POST', redirect: 'error',
signal: AbortSignal.timeout(20_000), headers: { Authorization: `Bearer ${login.overseasAccessToken}` }, body: form }) }
try { response = await fetcher(settings.apiBase + path, { method: 'POST', redirect: 'error',
signal: AbortSignal.timeout(20_000), headers: { Authorization: `Bearer ${login.accessToken}` }, body: form }) }
catch { throw new Error('签收上传连接失败或超时;未自动重试') }
const data = await response.json()
const safe = value => /^[\w-]{1,100}$/.test(String(value)) ? value : 'unavailable'
+47 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runDeliveryStage, validateDeliveryInput, verifyOutbound, withDeliveryJournal } from './prod-release-delivery-stage.mjs'
import { executeDelivery, runDeliveryStage, validateDeliveryInput, verifyOutbound, withDeliveryJournal } from './prod-release-delivery-stage.mjs'
const input = { batch: 'acceptance-1234', orderId: '101', orderNo: 'FL-101', tailOrderId: '201',
warehouseId: '301', inboundCode: 'IN-101', dispatchNo: 'DS-101', outboundNo: 'OB-101', deliveryNo: 'DL-101', packageCount: 1 }
@@ -112,3 +112,49 @@ test('journal rejects changed binding and concurrent processes', async () => {
await assert.rejects(withDeliveryJournal(path, 'changed', async () => {}), /不符/)
} finally { await rm(dir, { recursive: true, force: true }) }
})
test('missing or malformed delivery version blocks status write and upload', async () => {
for (const version of [undefined, null, '', ' ', false, -1, '9007199254740993']) {
const h = harness(), original = h.options.request
h.options.request = async (...args) => {
const result = await original(...args)
if (result?.order?.deliveryNo) result.order.version = version
return result
}
await assert.rejects(runDeliveryStage(h.options), /版本缺失或无效/)
assert.equal(h.writes.length, 0)
assert.equal(h.uploads(), 0)
}
})
test('delivery executor uses one gateway/accessToken for status, multipart receipt and domestic reconciliation', async t => {
const dir = await mkdtemp(join(tmpdir(), 'single-delivery-test-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const h = harness(), calls = []
const settings = { environment: 'gray', apiBase: 'https://fixture.invalid/api', customer: { id: '501' },
credentials: { ADMIN: { loginId: 'admin-fixture', password: 'secret-fixture' } } }
const fetcher = async (url, options) => {
assert.ok(url.startsWith(settings.apiBase + '/'))
const path = url.slice(settings.apiBase.length)
calls.push(path)
let data
if (path === '/auth/login') data = { accessToken: 'single-access' }
else {
assert.equal(options.headers.Authorization, 'Bearer single-access')
if (options.body instanceof FormData) {
assert.equal(path, '/platform/v1/last-leg/delivery-orders/DL-101/documents/RECEIPT/files?customerId=501')
assert.match(await options.body.get('file').text(), /NOT A REAL PROOF/)
data = { fileId: '701' }
} else {
data = await h.options.request(path.startsWith('/platform/v1/last-leg/') ? 'overseas' : 'cn',
options.method, path, options.body ? JSON.parse(options.body) : undefined)
}
}
return { ok: true, status: 200, json: async () => ({ code: '200', data }) }
}
const result = await executeDelivery(settings, input, join(dir, 'journal.json'), fetcher)
assert.equal(result.status, 'DELIVERY_STAGE_PASSED')
assert.equal(result.fullFlowVerified, false)
assert.equal(calls.filter(path => path === '/auth/login').length, 1)
})
+231
View File
@@ -0,0 +1,231 @@
import { fingerprint } from './prod-release-order-stage.mjs'
import { oceanBookingDraft } from './admin-test-env-modules/ocean-booking-fixture.mjs'
import { iso6346ContainerNumber } from './admin-test-env-modules/iso6346-container-number.mjs'
import { channelGroup } from './admin-test-env-modules/smart-loading-channel-group.mjs'
const assert = (value, message) => { if (!value) throw Error(message) }
const id = value => value == null ? '' : String(value)
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==', 'base64')
const NODES = ['DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY']
const localTime = () => new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 19)
function fileForm(name, content = PNG, type = 'image/png', fields = {}) {
const form = new FormData()
for (const [key, value] of Object.entries(fields)) form.append(key, String(value))
form.append('file', new Blob([content], { type }), name)
return form
}
export function domesticSuccess(response) {
if (response?.http !== 200 || String(response?.body?.code) !== '200') {
const error = Error(`国内履约请求失败 HTTP=${response?.http} code=${response?.body?.code}`)
error.evidence = { http: response?.http, code: response?.body?.code, message: response?.body?.message,
requestId: response?.requestId, traceId: response?.traceId }
throw error
}
return response.body.data
}
// Persist intent before invoking transport. Never retry a partially observed write.
export function domesticCommands(checkpoint, binding) {
const { state, save } = checkpoint
assert(!state.binding || state.binding === binding, '国内检查点绑定发生变化')
return async (name, command, execute) => {
const previous = state.commands?.[name]
if (previous?.status === 'DONE') return previous.result
assert(!previous, `国内写入 ${name} 结果未知,必须人工核对原请求,禁止自动重放`)
const intent = { status: 'INTENT', command, fingerprint: fingerprint(command) }
const persist = async value => {
const patch = { binding, commands: { ...state.commands, [name]: value } }
await save(patch); Object.assign(state, patch)
}
await persist(intent)
const result = await execute()
await persist({ ...intent, status: 'DONE', result })
return result
}
}
export async function runDomesticStage({ input, order, context, checkpoint }) {
const config = input.domestic
const cargo = input.payload.cargoes[0]
assert(order?.status === 'ORDER_APPROVED' && order.orderId && order.inboundCode, '必须使用本票已审核订单')
assert(cargo.packageCount >= 1 && cargo.packageCount <= 2 && input.payload.cargoes.length === 1, '国内阶段仅支持本票一个 SKU、1~2 箱')
assert(input.payload.taxMode === 'BUY_DECLARATION', '当前专票须为买单报关,不自动改写报关方式')
assert(config?.palletId && config.assigneeId && config.bookingAgent?.id && config.carrier?.id
&& config.carrier.carrierCode && config.estimatedSailingAt && config.estimatedArrivalAt
&& config.terminalFleet?.id && config.terminalFleet.name, '缺少已有国内资源、目的港车队或明确船期')
assert(context.token && context.pdaToken && context.merchantToken, '缺少已登录平台、国内 PDA 或客户会话')
const binding = fingerprint({ input, orderId: order.orderId })
const command = domesticCommands(checkpoint, binding)
const tenantId = input.customer.id, warehouseId = input.origin.id
const q = `tenantId=${encodeURIComponent(tenantId)}`
const key = name => `release-${fingerprint({ binding, name }).slice(0, 48)}`
const get = async (path, token = context.token) => domesticSuccess(await context.request('GET', path, undefined, token))
const write = (name, method, path, body, token = context.token) => command(name, { method, path, body, idempotencyKey: key(name) },
async () => domesticSuccess(await context.request(method, path, body, token, { 'Idempotency-Key': key(name) })))
const upload = (name, path, form, token = context.token) => command(name, { method: 'POST', path, idempotencyKey: key(name), testEvidence: name },
async () => domesticSuccess(await context.requestMultipart('POST', path, form, token, { 'Idempotency-Key': key(name) })))
const poll = async (read, condition, description) => {
for (let i = 0; i < (context.pollAttempts || 45); i++) {
const data = await read()
if (condition(data)) return data
await (context.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms))))(1000)
}
throw Error(`国内异步事实未就绪:${description}`)
}
const unified = await poll(() => get(`/api/platform/v1/orders/${order.orderId}?${q}`), data => data.summary?.inboundId, '订单入库投影')
const inboundId = id(unified.summary.inboundId)
const inboundPath = `/api/platform/inbounds/${inboundId}?${q}`
const base = `/api/platform/v1/first-leg/inbounds/${inboundId}`
const measurementPath = `${base}/measurement?${q}`
const inbound = await get(inboundPath)
assert(id(inbound.summary?.tenantId) === tenantId && inbound.summary.inboundCode === order.inboundCode
&& id(inbound.summary?.orderId) === order.orderId, '入库单客户、订单或入仓码不属于本票')
await write('accept', 'POST', `/api/platform/v1/orders/first-leg/${order.orderId}/accept?${q}`)
await write('start', 'POST', `/api/platform/v1/orders/first-leg/${order.orderId}/start?${q}`)
await command('carton-labels', { method: 'POST', path: `/api/platform/inbounds/${inboundId}/carton-label-pdf?${q}` }, async () => {
const result = await context.requestBinary('POST', `/api/platform/inbounds/${inboundId}/carton-label-pdf?${q}`)
assert(result.http === 200 && String(result.contentType).includes('pdf') && Buffer.from(result.bytes || []).subarray(0, 4).toString() === '%PDF', '本票箱唛未成功生成')
return { generated: true }
})
const scanForm = new FormData()
scanForm.append('data', new Blob([JSON.stringify({ boxCode: `${order.inboundCode}#${cargo.packageCount}-1`,
length: cargo.lengthCm, width: cargo.widthCm, height: cargo.heightCm,
machineNo: 'TEST-RELEASE-SIMULATED', measuredAt: localTime() })], { type: 'application/json' }))
scanForm.append('image', new Blob([PNG], { type: 'image/png' }), 'TEST-simulated-carton.png')
const scan = await upload('carton-scan', `${base}/measurement/carton-scans?${q}`, scanForm)
assert(scan?.itemId && scan.measurementId, '测量扫描未生成本票明细')
await write('carton-count', 'PUT', `${base}/measurement/items/${scan.itemId}/confirmed-package-count?${q}`,
{ measurementVersion: Number(scan.measurementVersion), confirmedPackageCount: cargo.packageCount })
const measured = await get(measurementPath)
assert(measured.items?.length === 1 && id(measured.items[0].id) === id(scan.itemId), '本票测量明细数量不符')
const item = measured.items[0]
await write('measurement-draft', 'PUT', `${base}/measurement/draft?${q}`, {
measurementId: measured.measurementId, version: Number(measured.version), actualGoodsName: cargo.goodsName || cargo.skuNo,
items: [{ inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification,
packageCount: cargo.packageCount, lengthCm: cargo.lengthCm, widthCm: cargo.widthCm, heightCm: cargo.heightCm,
intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning ?? null,
outerLengthCm: cargo.lengthCm, outerWidthCm: cargo.widthCm, outerHeightCm: cargo.heightCm,
coefficientMode: 'ADD', remark: `${input.batch} TEST模拟测量,非实测证据` }] })
const weightFile = await upload('weight-file', `/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, fileForm('TEST-weight.png'))
const weight = await write('weight-create', 'POST', `${base}/weight-results?${q}`, { fileId: weightFile.fileId, palletId: config.palletId,
ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null, ocrWeightValue: null, ocrWeightUnit: null, confidence: null })
assert(weight.palletTareWeightKg != null && Number(weight.palletTareWeightKg) >= 0, '缺少已有托盘皮重快照')
await write('weight-confirm', 'POST', `${base}/weight-results/${weight.id}/confirm?${q}`, {
version: Number(weight.version), adjustedWeightValue: Number(cargo.grossWeightKg) + Number(weight.palletTareWeightKg),
adjustedWeightUnit: 'KG', adjustmentReason: `${input.batch} TEST模拟称重,申报货重加托盘皮重` })
const ready = await get(measurementPath)
await write('measurement-submit', 'POST', `${base}/measurement/submit?${q}`, { measurementId: ready.measurementId, version: Number(ready.version) })
const pending = await get(inboundPath)
await write('measurement-approve', 'POST', `/api/platform/v1/inbounds/${inboundId}/measurements/approve?${q}`,
{ measurementId: ready.measurementId, expectedVersion: Number(pending.summary.version), reason: `${input.batch} TEST专票流程` })
const approved = await poll(() => get(measurementPath), value => value.processStatus === 'WAIT_SPLIT'
|| checkpoint.state.commands?.['plan-assign']?.status === 'DONE', '测量审核待分柜')
assert(approved.measurementStatus === 'APPROVED' && approved.measureReviewStatus === 'APPROVED'
&& Number(approved.actualPackageCount) === cargo.packageCount
&& Math.abs(Number(approved.actualNetWeightKg) - cargo.grossWeightKg) <= 0.001, '测量审核、箱数或净重不符')
const latestFee = await context.request('GET', `/api/platform/v1/inbounds/${inboundId}/fees/latest?${q}`, undefined, context.token)
if (latestFee.http === 200 && String(latestFee.body?.code) === 'A0404') {
await write('fee-calculate', 'POST', `/api/platform/v1/inbounds/${inboundId}/fees/calculate?${q}`)
} else domesticSuccess(latestFee)
await write('fee-confirm', 'POST', `/api/platform/v1/inbounds/${inboundId}/fees/confirm?${q}`)
const sent = await write('fee-send', 'POST', `/api/platform/v1/inbounds/${inboundId}/fees/send?${q}`)
assert(sent.feeStatus === 'SENT', '头程费用未发送客户')
const billing = `/api/merchant/v1/first-leg/orders/${order.orderId}/inbound-billing`
const customerFee = await write('customer-fee-confirm', 'POST', `${billing}/confirm`, { expectedFeeVersion: Number(sent.feeVersion) }, context.merchantToken)
assert(customerFee.customerConfirmationStatus === 'CONFIRMED', '客户费用未确认')
const containers = '/api/platform/v1/containers', smart = '/api/platform/v1/first-leg/smart-loading', pda = '/api/platform/v1/first-leg/pda-loading'
const simulation = await write('container-create', 'POST', containers, { channelGroupCode: channelGroup(input.payload.channelCode), warehouseId,
containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 })
const containerId = id(simulation.id)
const booking = await write('booking-create', 'POST', '/api/platform/v1/ocean-bookings', { expectedVersion: 0,
data: oceanBookingDraft({ marker: input.batch }, input.batch, { customerId: tenantId, customerName: input.customer.name,
channelCode: input.payload.channelCode, warehouseId }, { shippingCompanyId: config.carrier.id, shippingCompanyName: config.carrier.name,
carrierCode: config.carrier.carrierCode, bookingAgentId: config.bookingAgent.id, bookingAgentName: config.bookingAgent.name,
estimatedSailingAt: config.estimatedSailingAt, estimatedArrivalAt: config.estimatedArrivalAt }) })
const oceanBookingId = id(booking.oceanBooking.id)
const document = await upload('booking-document', `/api/platform/v1/ocean-bookings/${oceanBookingId}/documents/upload`,
fileForm('TEST-booking-confirmation.txt', `${input.batch} TEST模拟订舱确认文件,非真实单证`, 'text/plain',
{ expectedVersion: booking.oceanBooking.version, documentType: 'BOOKING_CONFIRMATION' }))
const released = await write('booking-release', 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/release`,
{ expectedVersion: Number(document.bookingVersion), reason: `${input.batch} TEST` })
assert(released.oceanBooking?.status === 'RELEASED', '订舱未放舱')
const requirement = released.containerRequirements?.find(value => value.containerType === '40HQ')
assert(requirement?.id, '订舱缺少40HQ需求')
const containerNo = iso6346ContainerNumber(input.batch)
await write('booking-link', 'POST', `${containers}/${containerId}/ocean-booking-link`, { oceanBookingId,
requirementId: id(requirement.id), containerNo, expectedVersion: Number(simulation.version) })
const candidates = await get(`${smart}/candidates?bookingId=${containerId}&warehouseId=${warehouseId}`)
const candidate = candidates.find(value => id(value.orderId) === order.orderId && id(value.accountId) === tenantId)
// After assignment a resumed run uses the captured candidate, never another order.
const assignedBefore = checkpoint.state.commands?.['plan-assign']
assert(candidate || assignedBefore?.status === 'DONE', '本票不在可分柜候选中')
const plan = await get(`${smart}/plans/by-booking/${containerId}`), planId = id(plan.id)
const assigned = await write('plan-assign', 'POST', `${smart}/plans/${planId}/assignments`, {
inboundId, inboundDetailId: id(candidate?.inboundDetailId), packageCount: cargo.packageCount,
expectedVersion: Number(plan.version), operationId: key('plan-assign') })
assert(assigned.items?.length === 1 && Number(assigned.items[0].packageCount) === cargo.packageCount, '装柜计划混入其他货物或数量不符')
const confirmable = await get(`/api/platform/v1/first-leg/loading-plans/${planId}?warehouseId=${warehouseId}`)
const confirmed = await write('plan-confirm', 'POST', `/api/platform/v1/first-leg/loading-plans/${planId}/confirm`,
{ expectedVersion: Number(confirmable.summary.version), assigneeId: config.assigneeId })
const taskId = id(confirmed.readiness?.pdaTaskId)
assert(taskId && confirmed.confirmationStatus === 'CONFIRMED', '国内 PDA 任务未生成')
const claimed = await write('pda-claim', 'POST', `${pda}/tasks/${taskId}/claim`,
{ expectedAssignmentVersion: 0, operationId: key('pda-claim') }, context.pdaToken)
assert(claimed.items?.length === 1, '国内 PDA 任务货物不唯一')
const snapshot = await get(`${pda}/tasks/${taskId}/inventory-snapshot?warehouseId=${warehouseId}`, context.pdaToken)
const picked = await write('pda-pick', 'POST', `${pda}/tasks/${taskId}/batch`, { warehouseId, loadingPlanId: planId,
containerBookingId: containerId, expectedVersion: Number(snapshot.taskVersion), inventorySnapshotToken: snapshot.snapshotToken,
items: [{ itemId: id(claimed.items[0].id), action: 'PICK', pickedPackageCount: cargo.packageCount, version: Number(claimed.items[0].version) }] }, context.pdaToken)
for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) {
const params = new URLSearchParams({ warehouseId, expectedVersion: '0', evidenceType, remark: `${input.batch} TEST模拟凭证` })
if (evidenceType === 'SEAL') params.set('sealNo', `TEST-${input.batch}`.slice(0, 64))
await upload(`loading-${evidenceType}`, `${pda}/tasks/${taskId}/evidence?${params}`, fileForm(`TEST-${evidenceType}.png`), context.pdaToken)
}
await write('pda-complete', 'POST', `${pda}/tasks/${taskId}/complete`, { expectedTaskVersion: Number(picked.version),
operationId: key('pda-complete'), remark: `${input.batch} TEST装柜` }, context.pdaToken)
await poll(() => get(`${pda}/tasks/${taskId}?warehouseId=${warehouseId}`, context.pdaToken),
value => value.taskStatus === 'COMPLETED' && value.workflowStatus === 'COMPLETED', '国内装柜完成')
const customsPath = `/api/platform/v1/customs/loading-plans/${planId}`
let customs = await get(customsPath)
assert(customs.inbounds?.length === 1 && id(customs.inbounds[0].inboundId) === inboundId
&& customs.inbounds.every(value => value.taxMode === 'BUY_DECLARATION'), '报关聚合不是本票买单货物')
if (customs.status === 'READY_TO_START') customs = await write('customs-start', 'POST', `${customsPath}/start`,
{ expectedVersion: Number(customs.version), confirm: true, remark: `${input.batch} TEST` })
if (customs.status === 'DECLARING') customs = await write('customs-complete', 'POST', `${customsPath}/complete-all-buy`,
{ expectedVersion: Number(customs.version), confirm: true, remark: `${input.batch} TEST` })
assert(customs.status === 'COMPLETED', '国内报关未完成')
const outboundPath = `/api/platform/v1/first-leg/outbound-dispatches/${containerId}`
const outbound = await poll(() => get(outboundPath), value => value.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING', '报关自动国内离仓')
assert(outbound.aggregate.inbounds?.length === 1 && id(outbound.aggregate.inbounds[0].inboundId) === inboundId, '离仓货物归属不符')
const queries = await poll(() => get(`/api/platform/v1/carrier-schedule-queries?containerBookingId=${containerId}&page=1&size=20`),
value => value.records?.some(row => row.query?.triggerSource === 'OUTBOUND_CONFIRM'), '离仓船节点任务')
const owned = queries.records.filter(row => id(row.query?.containerBookingId) === containerId && row.query.triggerSource === 'OUTBOUND_CONFIRM')
assert(owned.length === 1, '本柜船节点任务不唯一')
const carrierQueryId = id(owned[0].query.id), carrierPath = `/api/platform/v1/carrier-schedule-queries/${carrierQueryId}`
let carrierDetail = await get(carrierPath)
let start = NODES.indexOf(carrierDetail.query?.query?.latestActualNode)
assert(start >= 0, '缺少自动离仓起点')
for (let index = start + 1; index < NODES.length; index++) {
const latest = carrierDetail.query.query
// Current observed wall clock; actual timestamps advance from the authoritative last node.
const actualAt = new Date(Date.parse(`${latest.latestActualAt}Z`) + 1000).toISOString().slice(0, 19)
carrierDetail = await write(`carrier-${NODES[index]}`, 'POST', `${carrierPath}/manual-nodes`, {
expectedVersion: Number(latest.version), standardNode: NODES[index], observedAt: localTime(), actualAt, estimatedAt: null,
locationCode: 'USLAX', locationName: 'Los Angeles', reason: `${input.batch} TEST模拟船节点 ${NODES[index]}` })
}
assert(carrierDetail.query?.query?.latestActualNode === 'PICKUP_READY', '船节点未到可提柜')
const terminalPath = `/api/platform/v1/terminal-drayages/${containerId}`
const initialTerminal = await get(terminalPath)
assert(id(initialTerminal.containerBookingId) === containerId && initialTerminal.containerNo === containerNo, '目的港拖车货柜不符')
assert(!initialTerminal.fleetSupplierId || checkpoint.state.commands?.['terminal-fleet']?.status === 'DONE', '禁止更换既有车队触发邮件')
const selected = await write('terminal-fleet', 'POST', `${terminalPath}/fleet`, { expectedVersion: Number(initialTerminal.version),
fleetSupplierId: config.terminalFleet.id, fleetSupplierName: config.terminalFleet.name, reason: `${input.batch} TEST` })
const appointmentAt = new Date(Date.parse(`${carrierDetail.query.query.latestActualAt}Z`) + 3600000).toISOString().slice(0, 19)
const scheduled = await write('terminal-appointment', 'POST', `${terminalPath}/pickup-appointment`, {
expectedVersion: Number(selected.version), pickupAppointmentAt: appointmentAt, reason: `${input.batch} TEST` })
assert(scheduled.pickupAppointmentAt === appointmentAt, '目的港提柜预约未持久化')
return { status: 'DOMESTIC_PICKUP_SCHEDULED', orderId: order.orderId, orderNo: order.orderNo, tenantId, warehouseId,
inboundId, inboundCode: order.inboundCode, containerId, containerBookingId: containerId, containerNo,
oceanBookingId, planId, loadingPlanId: planId, taskId, carrierQueryId, packageCount: cargo.packageCount,
terminalDrayageVersion: Number(scheduled.version), pickupAppointmentAt: appointmentAt }
}
@@ -0,0 +1,122 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { domesticCommands, domesticSuccess, runDomesticStage } from './prod-release-domestic-stage.mjs'
const journal = () => {
const state = {}, saves = []
return { state, saves, async save(patch) { saves.push(structuredClone(patch)); Object.assign(state, patch) } }
}
test('uncertain writes retain original intent and cannot replay', async () => {
const checkpoint = journal(), run = domesticCommands(checkpoint, 'bound')
let count = 0
const execute = async () => { count++; assert.equal(checkpoint.state.commands.create.status, 'INTENT'); throw Error('network') }
await assert.rejects(run('create', { path: '/create' }, execute), /network/)
await assert.rejects(run('create', { path: '/create' }, execute), /结果未知/)
assert.equal(count, 1)
})
test('completed writes reuse exact saved result and reject another binding', async () => {
const checkpoint = journal(), run = domesticCommands(checkpoint, 'bound')
const first = await run('create', { path: '/create' }, async () => ({ id: '17' }))
assert.deepEqual(await run('create', { path: '/create' }, () => { throw Error('must not run') }), first)
assert.throws(() => domesticCommands(checkpoint, 'other'), /绑定/)
})
test('business rejection retains diagnostic evidence and cannot count as success', () => {
assert.throws(() => domesticSuccess({ http: 200, body: { code: 'A0409', message: 'version' }, requestId: 'r', traceId: 't' }),
error => error.evidence.code === 'A0409' && error.evidence.requestId === 'r' && error.evidence.traceId === 't')
assert.throws(() => domesticSuccess({ http: 503, body: { code: '200' } }))
})
function fixture() {
const input = { batch: 'release-test-001', customer: { id: '1', name: 'TEST' }, origin: { id: '2' },
payload: { taxMode: 'BUY_DECLARATION', channelCode: 'MATSON_CLX', cargoes: [{ skuNo: 'TEST-SKU', packageCount: 2,
lengthCm: 30, widthCm: 20, heightCm: 10, grossWeightKg: 8 }] },
domestic: { palletId: '3', assigneeId: '4', bookingAgent: { id: '5', name: 'AGENT' },
carrier: { id: '6', name: 'CARRIER', carrierCode: 'MATSON' }, terminalFleet: { id: '7', name: 'FLEET' },
estimatedSailingAt: '2026-10-12T08:00:00', estimatedArrivalAt: '2026-10-20T08:00:00' } }
const order = { status: 'ORDER_APPROVED', orderId: '10', orderNo: 'TEST-ORDER', inboundCode: 'TEST-INBOUND' }
const checkpoint = journal(), calls = []
const context = { token: 'platform-test', merchantToken: 'merchant-test', pdaToken: 'pda-test', pollAttempts: 1, sleep: async () => {} }
let containerNo, carrierNode = 'DEPARTED_WAREHOUSE', carrierVersion = 0, latestActualAt = '2026-10-12T10:00:00'
let measured = false
const carrier = () => ({ query: { query: { latestActualNode: carrierNode, version: carrierVersion, latestActualAt } } })
context.request = async (method, path, body, token, headers) => {
calls.push({ method, path, body, token, headers })
let data
if (path === '/api/platform/v1/orders/10?tenantId=1') data = { summary: { inboundId: '20' } }
else if (path === '/api/platform/inbounds/20?tenantId=1') data = { summary: { tenantId: '1', orderId: '10', inboundCode: 'TEST-INBOUND', version: 1 } }
else if (path.includes('/measurement?')) data = { measurementId: '30', version: 2, items: [{ id: '31', inboundDetailId: '32', itemNo: 1 }],
processStatus: measured ? 'WAIT_SPLIT' : 'MEASURING', measurementStatus: 'APPROVED', measureReviewStatus: 'APPROVED', actualPackageCount: 2, actualNetWeightKg: 8 }
else if (path.includes('/measurements/approve')) { measured = true; data = {} }
else if (path.includes('/weight-results?')) data = { id: '40', version: 0, palletTareWeightKg: 2 }
else if (path.includes('/fees/send')) data = { feeStatus: 'SENT', feeVersion: 1 }
else if (path.includes('/inbound-billing/confirm')) data = { customerConfirmationStatus: 'CONFIRMED' }
else if (path === '/api/platform/v1/containers') data = { id: '50', version: 0 }
else if (path === '/api/platform/v1/ocean-bookings') data = { oceanBooking: { id: '60', version: 0 } }
else if (path.includes('/ocean-bookings/60/release')) data = { oceanBooking: { status: 'RELEASED' }, containerRequirements: [{ id: '61', containerType: '40HQ' }] }
else if (path.includes('/ocean-booking-link')) { containerNo = body.containerNo; data = {} }
else if (path.includes('/smart-loading/candidates')) data = [{ orderId: '10', accountId: '1', inboundDetailId: '32' }]
else if (path.includes('/plans/by-booking/')) data = { id: '70', version: 0 }
else if (path.includes('/plans/70/assignments')) data = { items: [{ id: '71', packageCount: 2 }] }
else if (path.includes('/loading-plans/70/confirm')) data = { confirmationStatus: 'CONFIRMED', readiness: { pdaTaskId: '80' } }
else if (path.includes('/first-leg/loading-plans/70?')) data = { summary: { version: 1 } }
else if (path.includes('/tasks/80/claim')) data = { items: [{ id: '81', version: 0 }] }
else if (path.includes('/tasks/80/inventory-snapshot')) data = { taskVersion: 1, snapshotToken: 'snapshot' }
else if (path.includes('/tasks/80/batch')) data = { version: 2 }
else if (path.includes('/tasks/80?')) data = { taskStatus: 'COMPLETED', workflowStatus: 'COMPLETED' }
else if (path === '/api/platform/v1/customs/loading-plans/70') data = { inbounds: [{ inboundId: '20', taxMode: 'BUY_DECLARATION' }], status: 'READY_TO_START', version: 0 }
else if (path.endsWith('/customs/loading-plans/70/start')) data = { status: 'DECLARING', version: 1 }
else if (path.endsWith('/complete-all-buy')) data = { status: 'COMPLETED', version: 2 }
else if (path.includes('/outbound-dispatches/50')) data = { aggregate: { dispatchStatus: 'WAITING_FOR_LOADING', inbounds: [{ inboundId: '20' }] } }
else if (path.includes('/carrier-schedule-queries?')) data = { records: [{ query: { id: '90', containerBookingId: '50', triggerSource: 'OUTBOUND_CONFIRM' } }] }
else if (path.endsWith('/carrier-schedule-queries/90')) data = carrier()
else if (path.endsWith('/manual-nodes')) { carrierNode = body.standardNode; carrierVersion++; latestActualAt = body.actualAt; data = carrier() }
else if (path.endsWith('/terminal-drayages/50')) data = { containerBookingId: '50', containerNo, version: 0 }
else if (path.endsWith('/terminal-drayages/50/fleet')) data = { version: 1 }
else if (path.endsWith('/pickup-appointment')) data = { version: 2, pickupAppointmentAt: body.pickupAppointmentAt }
else if (/\/(accept|start)\?/.test(path) || /confirmed-package-count|measurement\/draft|weight-results\/40\/confirm|measurement\/submit|fees\/latest|fees\/confirm|tasks\/80\/complete/.test(path)) data = {}
else throw Error(`unhandled ${method} ${path}`)
return { http: 200, body: { code: '200', data } }
}
context.requestMultipart = async (method, path, form, token, headers) => {
assert.ok(form instanceof FormData)
calls.push({ method, path, token, headers, multipart: true })
let data
if (path.includes('/carton-scans')) data = { itemId: '31', measurementId: '30', measurementVersion: 0 }
else if (path.includes('/files/measurements/')) data = { fileId: '41' }
else if (path.includes('/documents/upload')) data = { bookingVersion: 1 }
else if (path.includes('/tasks/80/evidence')) data = { taskId: '80' }
else throw Error(`unhandled upload ${path}`)
return { http: 200, body: { code: '200', data } }
}
context.requestBinary = async () => ({ http: 200, contentType: 'application/pdf', bytes: Buffer.from('%PDF-test') })
return { input, order, context, checkpoint, calls }
}
test('single-ticket positive HTTP orchestration conserves two cartons and stops at pickup appointment', async () => {
const value = fixture(), result = await runDomesticStage(value)
assert.equal(result.status, 'DOMESTIC_PICKUP_SCHEDULED')
assert.equal(result.inboundId, '20')
assert.equal(result.packageCount, 2)
const batch = value.calls.find(call => call.path.endsWith('/tasks/80/batch'))
assert.equal(batch.body.items[0].pickedPackageCount, 2)
assert.equal(batch.token, value.context.pdaToken)
const weight = value.calls.find(call => call.path.includes('/weight-results/40/confirm'))
assert.equal(weight.body.adjustedWeightValue, 10)
assert.equal(value.calls.filter(call => call.path.endsWith('/manual-nodes')).length, 7)
assert.equal(value.calls.filter(call => call.path.includes('/evidence?')).length, 5)
assert.ok(value.calls.every(call => !/pickup-email|\/suppliers|\/users|\/customers/.test(call.path)))
assert.ok(Object.values(value.checkpoint.state.commands).every(command => command.status === 'DONE'))
})
test('tax mode and customer identity fail closed before unrelated business writes', async () => {
const value = fixture()
value.input.payload.taxMode = 'SELF_DECLARATION'
await assert.rejects(runDomesticStage(value), /买单报关/)
assert.equal(value.calls.length, 0)
})
test('a timeout at first business write preserves intent and stops all later actions', async () => {
const value = fixture(), request = value.context.request
value.context.request = async (...args) => args[0] === 'POST' ? Promise.reject(Error('timeout')) : request(...args)
await assert.rejects(runDomesticStage(value), /timeout/)
assert.equal(value.checkpoint.state.commands.accept.status, 'INTENT')
assert.equal(Object.keys(value.checkpoint.state.commands).length, 1)
})
+93
View File
@@ -0,0 +1,93 @@
import { resolve } from 'node:path'
import { fingerprint, validateOrderInput } from './prod-release-order-stage.mjs'
import { normalizeOrderInput } from './prod-release-measurement-plan.mjs'
import { executeOrder } from './prod-release-acceptance.mjs'
import { executeDelivery } from './prod-release-delivery-stage.mjs'
import { createFlowContext, withFlowJournal, requireSuccess } from './prod-release-http.mjs'
import { runDomesticStage } from './prod-release-domestic-stage.mjs'
import { runOverseasStage, finishSingleOrderTrip } from './prod-release-overseas-stage.mjs'
export async function executeFullFlow(settings, rawInput, journalPath, resume = false, fetcher = fetch) {
const input = { ...normalizeOrderInput(rawInput), customer: settings.customer,
environment: { mode: settings.environment, apiBase: settings.apiBase },
operator: { merchant: settings.credentials.MERCHANT.loginId, admin: settings.credentials.ADMIN.loginId } }
validateOrderInput(input)
const binding = fingerprint({ input, credentials: Object.fromEntries(Object.entries(settings.credentials).map(([key,value])=>[key,value.loginId])) })
return withFlowJournal(journalPath, binding, async (state, save) => {
if (state.status !== 'NEW' && !resume) throw Error('已有全流程检查点,须保持原输入并显式 --resume')
const ctx = createFlowContext({ settings, state, save, fetcher })
ctx.marker = input.batch
const checkpoint = name => {
const local = state.stages[name] || {}
return { state: local, save: async patch => {
const next = { ...local, ...patch }
await save({ stages: { ...state.stages, [name]: next } }); Object.assign(local, next)
} }
}
const bridgeFetch = async (url, options) => {
if (!url.startsWith(settings.apiBase + '/')) throw Error('全流程请求偏离统一Gateway')
const path = '/api' + url.slice(settings.apiBase.length)
const token = options.headers?.Authorization?.replace(/^Bearer /, '')
const body = options.body instanceof FormData ? options.body : options.body ? JSON.parse(options.body) : undefined
const result = await ctx.requestWithBody(options.method, path, body, token ?? null,
options.headers?.['Idempotency-Key'] ? { 'Idempotency-Key': options.headers['Idempotency-Key'] } : {})
if (path === '/api/auth/login' && result.body?.code === '200') ctx.registerSession(result.body.data)
return { ok: result.http >= 200 && result.http < 300, status: result.http, json: async () => result.body }
}
try {
await save({ status: 'RUNNING' })
ctx.token = (await ctx.login('ADMIN')).accessToken
ctx.merchantToken = (await ctx.login('MERCHANT')).accessToken
ctx.pdaToken = (await ctx.login('CN_PDA')).accessToken
ctx.overseasPdaToken = (await ctx.login('OVERSEAS_LAX_PDA')).accessToken
const binding = requireSuccess(await ctx.request('GET', '/api/pda/v1/last-leg/receiving-containers/warehouse-profile', undefined, ctx.overseasPdaToken))
if (binding.warehouseCode !== 'US-LAX-01') throw Error('海外PDA不是洛杉矶接柜仓')
const orderCp = checkpoint('order')
let order = orderCp.state.result
if (!order) {
order = await executeOrder(settings, input, resolve(`${journalPath}.order.json`), resume, bridgeFetch)
await orderCp.save({ result: order })
}
console.log('单票阶段:ORDER_APPROVED')
const domesticCp = checkpoint('domestic')
let domestic = domesticCp.state.result
if (!domestic) {
domestic = await runDomesticStage({ input, order, context: ctx, checkpoint: domesticCp })
await domesticCp.save({ result: domestic })
}
console.log(`单票阶段:${domestic.status}`)
const overseasCp = checkpoint('overseas')
let overseas = overseasCp.state.result
if (!overseas) {
overseas = await runOverseasStage({ input: { ...input, customerId: settings.customer.id,
packageCount: input.payload.cargoes[0].packageCount, warehouseId: binding.warehouseId },
domestic: { ...domestic, sourceInboundId: domestic.inboundId, orderId: order.orderId, orderNo: order.orderNo, inboundCode: order.inboundCode },
context: ctx, checkpoint: overseasCp })
await overseasCp.save({ result: overseas })
}
console.log(`单票阶段:${overseas.status}`)
const delivery = await executeDelivery(settings, { batch: input.batch, ...overseas,
orderId: order.orderId, orderNo: order.orderNo, inboundCode: order.inboundCode,
packageCount: input.payload.cargoes[0].packageCount, warehouseId: binding.warehouseId },
resolve(`${journalPath}.delivery.json`), bridgeFetch)
if (delivery.blockers.length) throw Error(`签收/回传未闭环:${delivery.blockers.join(',')}`)
const tripClosure = await finishSingleOrderTrip({ input: { ...input, customerId: settings.customer.id }, overseas, context: ctx, checkpoint: checkpoint('tripClosure') })
const result = { status: 'SINGLE_ORDER_HTTP_COMPLETED', fullFlowVerified: true,
scope: 'ONE_ORDER_NORMAL_HTTP_PATH', orderNo: order.orderNo, inboundCode: order.inboundCode,
containerNo: domestic.containerNo, dispatchNo: overseas.dispatchNo, deliveryNo: overseas.deliveryNo,
domesticOrderStatus: delivery.domesticOrderStatus, domesticInboundStatus: delivery.domesticInboundStatus,
tripClosureStatus: tripClosure.status,
syntheticEvidence: true, excluded: ['真实货物及设备', '浏览器视觉', '故障注入', '多客户/拆单/异常分支', '真实付款/第三方下单/邮件'],
completedAt: new Date().toISOString() }
await save({ status: 'COMPLETED', result })
return result
} catch (error) {
await save({ status: 'BLOCKED', failure: error.message })
throw error
} finally {
const cleanup = await ctx.close()
if (state.result) state.result.sessionCleanup = cleanup.length ? 'FAILED' : 'COMPLETED'
await save({ cleanup })
}
})
}
+123
View File
@@ -0,0 +1,123 @@
import { createHash } from 'node:crypto'
import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises'
import { dirname } from 'node:path'
const hash = value => createHash('sha256').update(value).digest('hex')
const safe = value => typeof value === 'string' && /^[\w-]{1,100}$/.test(value) ? value : 'unavailable'
export const success = response => response?.http === 200 && response.body?.code === '200'
export function requireSuccess(response) {
if (!success(response)) throw new Error(`HTTP=${response?.http}; code=${safe(response?.body?.code)}; requestId=${safe(response?.requestId)}; traceId=${safe(response?.traceId)}`)
return response.body.data
}
export async function withFlowJournal(path, binding, action) {
await mkdir(dirname(path), { recursive: true })
const lock = await open(`${path}.lock`, 'wx', 0o600)
try {
let state
try { state = JSON.parse(await readFile(path, 'utf8')) }
catch (error) {
if (error.code !== 'ENOENT') throw new Error('全流程检查点损坏;禁止新建替代批次')
state = { version: 1, binding, stages: {}, requests: [], status: 'NEW' }
}
if (state.version !== 1 || state.binding !== binding) throw new Error('全流程检查点绑定变化')
const save = async patch => {
const next = { ...state, ...patch }
const file = await open(`${path}.next`, 'wx', 0o600)
try { await file.writeFile(JSON.stringify(next, null, 2)); await file.sync() } finally { await file.close() }
await rename(`${path}.next`, path)
Object.assign(state, next)
}
await save({})
return await action(state, save)
} finally { await lock.close(); await unlink(`${path}.lock`) }
}
async function bodyDigest(body) {
if (!(body instanceof FormData)) return hash(JSON.stringify(body ?? null))
const parts = []
for (const [key, value] of body) parts.push([key, typeof value === 'string' ? value
: { name: value.name, type: value.type, sha256: hash(Buffer.from(await value.arrayBuffer())) }])
return hash(JSON.stringify(parts))
}
// Only public business HTTP. No fixture runner, SQL, deployment or automatic network retry.
export function createFlowContext({ settings, state, save, fetcher = fetch }) {
const sessions = []
const context = {
marker: '', options: {}, context: {}, createdResources: {}, steps: [],
registerSession(session) { sessions.push(session) },
async request(method, path, body, token = context.token, extraHeaders = {}) {
return send(method, path, body, token, extraHeaders, false)
},
async requestWithBody(method, path, body, token = context.token, extraHeaders = {}) {
return send(method, path, body, token, extraHeaders, false)
},
async requestMultipart(method, path, file, token = context.token, extraHeaders = {}) {
const form = file instanceof FormData ? file : new FormData()
if (!(file instanceof FormData)) {
if (!file || file.content === undefined) throw new Error('缺少上传内容')
form.append('file', new Blob([file.content], { type: file.type || 'application/octet-stream' }), file.name || 'acceptance.bin')
}
return send(method, path, form, token, extraHeaders, false)
},
async requestBinary(method, path, token = context.token) { return send(method, path, undefined, token, {}, true) },
apiSuccess(module, name, response) {
const data = requireSuccess(response)
context.steps.push({ module, name, passed: true })
return data
},
assert(module, name, response, condition) {
context.steps.push({ module, name, passed: Boolean(condition) })
if (!condition) throw new Error(`${module}: ${name}`)
},
async login(role) {
const data = requireSuccess(await send('POST', '/api/auth/login', settings.credentials[role], null, {}, false))
if (!data?.accessToken || !data.refreshToken) throw new Error(`${role}: 登录令牌不完整`)
sessions.push(data)
return data
},
async close() {
const failures = []
for (const session of sessions) {
try { requireSuccess(await send('POST', '/api/auth/logout', { refreshToken: session.refreshToken }, session.accessToken, {}, false)) }
catch { failures.push('SESSION_LOGOUT_FAILED') }
}
return failures
},
}
async function send(method, path, body, token, extraHeaders, binary) {
if (!['GET', 'POST', 'PUT'].includes(method) || !/^\/api\/(auth\/(login|logout)$|platform\/|merchant\/|pda\/)/.test(path)
|| path.includes('..') || path.includes('#')) throw new Error('全流程请求路径或方法不允许')
const auth = path === '/api/auth/login' || path === '/api/auth/logout'
if (path !== '/api/auth/logout' && state.requests.length >= 2500) throw new Error('全流程请求超过安全上限')
const entry = { sequence: state.requests.length + 1, method, path, startedAt: new Date().toISOString(),
status: 'INTENT', ...(auth ? {} : { bodyHash: await bodyDigest(body) }) }
await save({ requests: [...state.requests, entry] })
let result
try {
const response = await fetcher(settings.apiBase.replace(/\/api$/, '') + path, {
method, redirect: 'error', signal: AbortSignal.timeout(45000),
headers: { ...(body !== undefined && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}),
...extraHeaders, ...(token ? { Authorization: `Bearer ${token}` } : {}) },
...(body !== undefined ? { body: body instanceof FormData ? body : JSON.stringify(body) } : {}),
})
if (binary && response.ok && !response.headers.get('content-type')?.includes('json')) {
const bytes = new Uint8Array(await response.arrayBuffer())
result = { http: response.status, bytes, buffer: Buffer.from(bytes), contentType: response.headers.get('content-type'), headers: Object.fromEntries(response.headers) }
} else {
const envelope = await response.json()
result = { http: response.status, body: envelope, requestId: envelope?.requestId || response.headers.get('x-request-id'),
traceId: envelope?.traceId || response.headers.get('x-trace-id') }
}
} catch {
await save({ requests: state.requests.map(row => row.sequence === entry.sequence ? { ...row, status: 'UNKNOWN' } : row) })
throw new Error(`HTTP结果不确定:${method} ${path};禁止自动重试`)
}
await save({ requests: state.requests.map(row => row.sequence === entry.sequence ? { ...row,
status: success(result) || result.bytes ? 'DONE' : 'REJECTED', http: result.http,
code: safe(result.body?.code), requestId: safe(result.requestId), traceId: safe(result.traceId) } : row) })
return result
}
return context
}
+50
View File
@@ -0,0 +1,50 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createFlowContext, requireSuccess } from './prod-release-http.mjs'
const settings = { apiBase: 'https://fixture.invalid/api', credentials: { ADMIN: { loginId: 'fixture', password: 'private' } } }
function harness(fetcher, saveError = false) {
const state = { requests: [] }, saves = []
const save = async patch => { if (saveError) throw Error('disk failed'); Object.assign(state,patch); saves.push(structuredClone(state)) }
return { state, saves, ctx: createFlowContext({ settings, state, save, fetcher }) }
}
test('persists intent before writes and never saves credentials or tokens', async () => {
let h
h = harness(async (_url, options) => {
assert.equal(h.state.requests.at(-1).status, 'INTENT')
return new Response(JSON.stringify({ code: '200', data: { accessToken: 'access-secret', refreshToken: 'refresh-secret' } }))
})
await h.ctx.login('ADMIN')
await h.ctx.close()
assert.doesNotMatch(JSON.stringify(h.state), /private|access-secret|refresh-secret/)
assert.ok(h.state.requests.every(row=>row.status==='DONE'))
})
test('disk failure prevents HTTP and unknown network result is never retried', async () => {
let calls=0
const broken=harness(async()=>{calls++;throw Error('network')},true)
await assert.rejects(broken.ctx.request('POST','/api/platform/v1/example',{}),/disk/)
assert.equal(calls,0)
const h=harness(async()=>{calls++;throw Error('network')})
await assert.rejects(h.ctx.request('POST','/api/platform/v1/example',{}),/结果不确定/)
assert.equal(calls,1)
assert.equal(h.state.requests[0].status,'UNKNOWN')
})
test('business errors retain diagnostic IDs and binary responses preserve bytes',async()=>{
const h=harness(async()=>new Response(JSON.stringify({code:'A0403',requestId:'req-1',traceId:'trace-1',message:'private'})))
const response=await h.ctx.request('GET','/api/platform/v1/example')
assert.throws(()=>requireSuccess(response),/A0403.*req-1.*trace-1/)
assert.doesNotMatch(JSON.stringify(h.state),/private/)
const binary=harness(async()=>new Response('%PDF-fixture',{headers:{'content-type':'application/pdf'}}))
const pdf=await binary.ctx.requestBinary('POST','/api/platform/inbounds/1/carton-label-pdf')
assert.equal(Buffer.from(pdf.bytes).toString(),'%PDF-fixture')
assert.equal(pdf.contentType,'application/pdf')
})
test('request limit blocks business calls but permits session logout', async () => {
const h = harness(async () => new Response(JSON.stringify({ code: '200', data: {} })))
h.ctx.registerSession({ accessToken: 'secret', refreshToken: 'refresh' })
h.state.requests = Array.from({ length: 2500 }, (_, index) => ({ sequence: index + 1, status: 'DONE' }))
await assert.rejects(h.ctx.request('GET', '/api/platform/v1/example'), /安全上限/)
assert.deepEqual(await h.ctx.close(), [])
assert.equal(h.state.requests.at(-1).path, '/api/auth/logout')
assert.equal(h.state.requests.at(-1).status, 'DONE')
})
+2 -2
View File
@@ -2,13 +2,13 @@
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 箱')
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')
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))
+4 -1
View File
@@ -36,6 +36,8 @@ export function validateOrderInput(input) {
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(payload.shipmentId && payload.referenceId && cargo.shipmentId && cargo.referenceId,
'FBA 订单及货物行均必须提供 Shipment ID 和 Reference ID')
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), '需要客户已上传产品图片,不生成虚假证明文件')
@@ -58,7 +60,8 @@ function binding(input) {
// 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://'), '缺少环境绑定')
assert(['test', 'gray'].includes(input.environment?.mode)
&& typeof input.environment.apiBase === 'string' && input.environment.apiBase.startsWith('https://'), '缺少环境绑定')
await mkdir(dirname(path), { recursive: true })
const lock = await open(`${path}.lock`, 'wx', 0o600)
const temp = `${path}.next`
+9 -6
View File
@@ -7,15 +7,17 @@ import { fingerprint, validateOrderInput, withOrderJournal, runOrderStage, valid
import { executeOrder } from './prod-release-acceptance.mjs'
function fixture() {
return { batch: 'release-fixture-001', environment: 'https://fixture.invalid/api',
return { batch: 'release-fixture-001', environment: { mode: 'gray', apiBase: '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',
shipmentId: 'SHIP-fixture', referenceId: 'REF-fixture',
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,
shipmentId: 'SHIP-fixture', referenceId: 'REF-fixture',
lengthCm: 40, widthCm: 30, heightCm: 30, grossWeightKg: 15, volumeCbm: 0.072,
productImageFileId: '4', woodenProduct: false, dangerousGoods: false }] } }
}
@@ -143,7 +145,7 @@ test('uncertain approval that still reads submitted must not be resent', async t
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',
for (const change of [i => i.environment.apiBase = 'https://other.invalid/api', i => i.environment.mode = 'test', 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
@@ -184,14 +186,15 @@ test('corrupt journal is preserved and fails closed', async t => {
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,
const settings = { environment: 'gray', apiBase: 'https://fixture.invalid/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
assert.ok(url.startsWith('https://fixture.invalid/api/'))
const endpoint = new URL(url).pathname.replace(/^\/api/, '') + new URL(url).search
let data
if (endpoint === '/auth/login') {
const role = JSON.parse(options.body).loginId
data = { accessToken: role, overseasAccessToken: `${role}-overseas` }
data = { accessToken: role }
} else if (endpoint.includes('/iam/')) {
data = { id: '1', tenantCode: 'fixture', tenantName: 'fixture', status: 'ACTIVE', dimensionInitializationStatus: 'READY' }
} else if (endpoint.includes('/warehouse-admin?')) {
@@ -203,7 +206,7 @@ for (const quoteMismatch of [false, true]) {
assert.equal(options.headers.Authorization, 'Bearer CN_PDA')
data = [{ id: '2', warehouseCode: 'CN-YIWU-01' }, { id: '4', warehouseCode: 'CN-DONGGUAN-01' }]
} else if (endpoint.endsWith('/warehouse-profile')) {
assert.equal(options.headers.Authorization, 'Bearer OVERSEAS_LAX_PDA-overseas')
assert.equal(options.headers.Authorization, 'Bearer OVERSEAS_LAX_PDA')
data = { warehouseId: '6', warehouseCode: 'US-LAX-01' }
} else if (/\/platform\/v1\/(pallets|locations)\?/.test(endpoint)) {
data = { total: '1', records: [{ warehouseId: '2', status: 'AVAILABLE' }] }
+283
View File
@@ -0,0 +1,283 @@
import { fingerprint } from './prod-release-order-stage.mjs'
import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
const BASE = '/api/platform/v1/last-leg'
const PDA = '/api/pda/v1/last-leg'
const check = (ok, message) => { if (!ok) throw new Error(message) }
const id = value => value == null ? '' : String(value)
const query = (path, values) => `${path}?${new URLSearchParams(values)}`
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
/** Each successful command is persisted; an interrupted command must be investigated, never resent. */
export function overseasCommands(context, checkpoint) {
const { state, save } = checkpoint
const unwrap = response => {
check(response?.http === 200 && String(response?.body?.code) === '200',
`海外 HTTP=${response?.http}; code=${response?.body?.code}; requestId=${response?.body?.requestId}; traceId=${response?.body?.traceId}`)
return response.body.data
}
async function command(name, method, path, body, token = context.token, headers = {}, file = null) {
const binding = fingerprint({ method, path, body: body ?? null, headers, file })
const previous = state.commands?.[name]
check(!previous || previous.binding === binding, `海外命令 ${name} 与检查点不符`)
if (previous?.status === 'DONE') return previous.result
check(!previous, `海外命令 ${name} 结果未知;停止重发并保留检查点`)
const persist = async value => { const commands = { ...state.commands, [name]: value }; await save({ commands }); state.commands = commands }
await persist({ binding, status: 'INTENT' })
let response
if (file) {
const form = new FormData()
form.append('file', new Blob([PNG], { type: 'image/png' }), file)
response = await context.requestWithBody(method, path, form, token)
} else response = await context.request(method, path, body, token, headers)
const result = unwrap(response)
await persist({ binding, status: 'DONE', result })
return result
}
return { command, read: async (path, token = context.token) => unwrap(await context.request('GET', path, undefined, token)),
readCurrentReceivingSession: async token => {
const response = await context.request('GET', `${PDA}/receiving-containers/current`, undefined, token)
// Only the initial current-session probe treats the service's absent-session business code as empty.
// Subsequent mandatory session reads and every other resource remain strict.
if (response?.http === 200 && String(response?.body?.code) === 'A0404') return null
return unwrap(response)
},
lookup: async (path, body) => unwrap(await context.request('POST', path, body)) }
}
export function validateOverseasInput(input, domestic) {
check(/^[A-Za-z0-9][A-Za-z0-9_-]{7,47}$/.test(input.batch || ''), '无效验收批次')
check([1, 2].includes(input.packageCount), '海外仅支持1~2箱单票')
for (const key of ['customerId', 'warehouseId']) check(/^[1-9]\d*$/.test(input[key] || ''), `缺少 ${key}`)
for (const key of ['vehicleId', 'driverId']) check(/^[1-9]\d*$/.test(input.resources?.[key] || ''), `缺少既有 ${key}`)
check(Boolean(input.resources.carrierName), '缺少既有车队名称')
for (const key of ['containerBookingId', 'sourceInboundId', 'orderId']) check(/^[1-9]\d*$/.test(domestic[key] || ''), `缺少 ${key}`)
for (const key of ['containerNo', 'inboundCode', 'orderNo']) check(Boolean(domestic[key]), `缺少 ${key}`)
if (domestic.cartonMarks != null) check(Array.isArray(domestic.cartonMarks)
&& domestic.cartonMarks.length === input.packageCount && new Set(domestic.cartonMarks).size === input.packageCount,
'本票冻结箱码数量不符')
}
/** Normal single-ticket path only. No fixture runners, account creation, or master-data mutation. */
export async function runOverseasStage({ input, domestic, context, checkpoint }) {
validateOverseasInput(input, domestic)
check(context.overseasPdaToken && context.merchantToken, '缺少既有海外 PDA 或本客户会话')
const { state, save } = checkpoint
check(!Object.values(state.commands || {}).some(row => row.status !== 'DONE'), '海外命令结果未知;停止重发并保留检查点')
const { read, lookup, command, readCurrentReceivingSession } = overseasCommands(context, checkpoint)
const pdaToken = context.overseasPdaToken
const remark = `RELEASE-ACCEPTANCE:${input.batch}`
const key = name => `${input.batch}-${name}`
if (!state.schedule) {
const schedule = { plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(),
expectedDriverEndAt: new Date(Date.now() + 48 * 3600000).toISOString(),
arrival: new Date(Date.now() + 86400000).toISOString().slice(0, 19) }
await save({ schedule }); state.schedule = schedule
}
const schedule = state.schedule
const vehicle = await read(`${BASE}/master-data/vehicles/${input.resources.vehicleId}`)
const driver = await read(query(`${BASE}/fleet/drivers/${input.resources.driverId}`, { expectedEnd: schedule.expectedDriverEndAt }))
check(id(vehicle.id) === input.resources.vehicleId && vehicle.region === 'OVERSEAS' && vehicle.status === 'IN_SERVICE', '既有海外车辆不可用')
check(id(driver.id) === input.resources.driverId && driver.qualification?.canDispatch === true, '既有司机证照/审核不满足本次出车')
let sources
for (let attempt = 0; attempt < 60; attempt++) {
const page = await read(query(`${BASE}/orders`, { containerNo: domestic.containerNo, page: '1', size: '200' }))
sources = (page.records || []).filter(row => id(row.sourceContainerBookingId) === domestic.containerBookingId)
if (sources.length) break
if (attempt < 59) await (context.wait || (ms => new Promise(resolve => setTimeout(resolve, ms))))(1000)
}
check(sources?.length === 1 && id(sources[0].customerId) === input.customerId
&& id(sources[0].sourceInboundId) === domestic.sourceInboundId && sources[0].inboundCode === domestic.inboundCode
&& id(sources[0].overseasWarehouseId) === input.warehouseId, '柜内尾程来源不属于本次唯一客户/单票/接柜仓')
const tailOrderId = id(sources[0].id)
const arrivalRows = await lookup(`${BASE}/orders/container-arrival-times/query`, { sourceContainerBookingIds: [domestic.containerBookingId] })
const arrival = arrivalRows.find(row => id(row.sourceContainerBookingId) === domestic.containerBookingId)
check(arrival && Number.isSafeInteger(Number(arrival.version)), '柜级到仓版本缺失')
if (!arrival.estimatedWarehouseArrivalAt) await command('arrival', 'PUT', `${BASE}/orders/container-summaries/${domestic.containerBookingId}/expected-arrival`,
{ estimatedWarehouseArrivalAt: schedule.arrival, expectedVersion: Number(arrival.version), remark }, undefined, { 'Idempotency-Key': key('arrival') })
let plans = state.commands?.convert?.result
if (!plans) {
const preview = await read(query(`${BASE}/inbound-plans/container-preview`, { sourceContainerBookingId: domestic.containerBookingId }))
check(preview.ready === true && Number(preview.totalOrderCount) === 1 && Number(preview.pendingOrderCount) === 1, '本票计划不可转换或存在未登记计划')
plans = await command('convert', 'POST', `${BASE}/inbound-plans/convert-container`, {
sourceContainerBookingId: domestic.containerBookingId, snapshotVersion: Number(preview.snapshotVersion), snapshotHash: preview.snapshotHash,
}, undefined, { 'Idempotency-Key': key('convert') })
}
check(plans?.length === 1 && plans[0].lines?.length > 0 && plans[0].lines.every(line => line.inboundCode === domestic.inboundCode)
&& plans[0].pallets?.length === 1, '本票入仓计划/实体托盘不唯一')
const pallet = plans[0].pallets[0]
check(pallet.palletNo && pallet.warehousePalletId, '计划未生成实体托盘')
const receiving = `${PDA}/receiving-containers`
if (!state.commands?.receiveComplete) {
const current = await readCurrentReceivingSession(pdaToken)
check(!current || current.containerNo === domestic.containerNo && current.inbounds?.length === 1
&& current.inbounds[0].inboundCode === domestic.inboundCode, '海外 PDA 正在操作其他柜')
const detailPath = query(`${receiving}/${encodeURIComponent(domestic.containerNo)}`, { sourceContainerBookingId: domestic.containerBookingId })
const detail = await read(detailPath, pdaToken)
check(id(detail.overseasWarehouseId) === input.warehouseId && detail.inbounds?.length === 1
&& detail.inbounds[0].inboundCode === domestic.inboundCode && Number(detail.totalPackages) === input.packageCount, '收货柜身份或箱数不符')
const destinationType = detail.inbounds[0].destinationType
check(['OVERSEAS', 'FBA'].includes(destinationType), '单票不支持此收货类型')
if (destinationType === 'OVERSEAS') check(domestic.cartonMarks?.length === input.packageCount,
'OVERSEAS逐箱收货必须提供本票正式冻结箱码')
await command('prepare', 'POST', query(`${receiving}/${encodeURIComponent(domestic.containerNo)}/prepare`, { sourceContainerBookingId: domestic.containerBookingId }), { idempotencyKey: key('prepare') }, pdaToken)
const photos = []
for (const role of ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']) {
const uploaded = await command(`photo-${role}`, 'POST', `${receiving}/current/unload-photos/files`, undefined, pdaToken, {}, `${input.batch}-SYNTHETIC-${role}.png`)
check(/^[1-9]\d*$/.test(id(uploaded.fileId)), '验收图片未返回文件ID')
photos.push({ role, fileId: id(uploaded.fileId) })
}
await command('sign', 'POST', `${receiving}/current/sign`, { photos, idempotencyKey: key('sign') }, pdaToken)
const resolved = await command('resolve-pallet', 'POST', `${receiving}/current/pallets/resolve`, { palletCode: pallet.palletNo, inboundCode: domestic.inboundCode }, pdaToken)
check(resolved.compatible === true, '计划托盘不兼容')
// FBA receives cumulative physical counts on its source pallet; carton resolution is not a receiving prerequisite.
for (const [index, cartonMark] of (destinationType === 'OVERSEAS' ? domestic.cartonMarks : []).entries()) {
const carton = await command(`resolve-carton-${index}`, 'POST', `${receiving}/current/cartons/resolve`, { cartonMark }, pdaToken)
check(carton.inboundCode === domestic.inboundCode, '箱码未归属本票')
await command(`receive-${index}`, 'POST', `${receiving}/current/receipts/carton`, { cartonMark, palletCode: pallet.palletNo, idempotencyKey: key(`receive-${index}`) }, pdaToken)
}
if (destinationType === 'FBA') await command('receive-bulk', 'POST', `${receiving}/current/receipts/bulk`, {
inboundCode: domestic.inboundCode, palletCode: pallet.palletNo, packageCount: input.packageCount, idempotencyKey: key('receive-bulk'),
}, pdaToken)
const received = await read(`${receiving}/current`, pdaToken)
check(Number(received.pendingPackages) === 0 && Number(received.totalPackages) === input.packageCount, '本票收货数量未核平')
await command('receiveComplete', 'POST', `${receiving}/current/complete`, { idempotencyKey: key('receive-complete') }, pdaToken)
}
if (!state.commands?.emptyReturn) {
const drayagePath = `/api/platform/v1/terminal-drayages/${domestic.containerBookingId}`
let drayage
for (let attempt = 0; attempt < 60; attempt++) {
drayage = await read(drayagePath)
if (drayage.status === 'UNLOADED' && drayage.unloadingCompletedAt) break
if (attempt < 59) await (context.wait || (ms => new Promise(resolve => setTimeout(resolve, ms))))(1000)
}
check(id(drayage.containerBookingId) === domestic.containerBookingId && drayage.status === 'UNLOADED'
&& drayage.allowedActions?.includes('RECORD_EMPTY_RETURN'), '海外卸柜事实尚未回传国内码头拖车')
const unloaded = Date.parse(`${drayage.unloadingCompletedAt}Z`)
check(Number.isFinite(unloaded), '卸柜时间无效')
const returned = await command('emptyReturn', 'POST', `${drayagePath}/empty-return`, {
expectedVersion: Number(drayage.version), actualAt: new Date(unloaded + 3600000).toISOString().slice(0, 19), remark,
}, undefined, { 'Idempotency-Key': key('empty-return') })
check(returned.status === 'EMPTY_RETURNED', '实际还柜未完成')
}
let dispatch = state.commands?.dispatch?.result
if (!dispatch) {
const candidates = await read(`${BASE}/dispatches/candidates?sourceStage=RECEIVED`)
const matches = candidates.filter(row => row.sourceStage === 'RECEIVED' && row.inboundCode === domestic.inboundCode
&& id(row.customerId) === input.customerId && id(row.overseasWarehouseId) === input.warehouseId)
check(matches.length === 1 && Number(matches[0].packageCount) === input.packageCount, '本票排车候选不唯一或数量不符')
dispatch = await command('dispatch', 'POST', `${BASE}/dispatches/pre-arrange`, { planPalletIds: matches.map(row => id(row.planPalletId)) })
}
const dispatchNo = dispatch.dispatchNo
check(Boolean(dispatchNo), '排车单号缺失')
const transport = { vehiclePlate: vehicle.plateNumber, driverName: driver.details?.name,
driverPhone: driver.details?.phone, carrierName: input.resources.carrierName, estimatedArrivalAt: schedule.arrival }
await command('dispatch-vehicle', 'PUT', `${BASE}/dispatches/${dispatchNo}`, transport)
const transfer = await command('transfer', 'POST', query('/api/merchant/v1/last-leg/delivery-orders', { dispatchNo }), {
idempotencyKey: key('transfer'), plannedDeliveryAt: schedule.arrival, deliveryMethod: 'TRUCK_PALLET',
waybillNo: key('WAYBILL'), carrierName: transport.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET',
vehicleType: 'TRUCK', vehicleSize: '15托', driverName: transport.driverName, driverPhone: transport.driverPhone,
vehiclePlate: transport.vehiclePlate, appointmentAccount: 'SELF', sellerName: null, remark,
fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark }],
}, context.merchantToken)
check(transfer.deliveries?.length === 1 && transfer.outboundNo, '本票未形成唯一出库/派送单')
let trip = await command('trip', 'POST', `${BASE}/fleet/trips`, { warehouseId: input.warehouseId,
plannedDepartureAt: schedule.plannedDepartureAt, expectedDriverEndAt: schedule.expectedDriverEndAt,
stops: [{ name: '验收起运仓' }, { name: '待匹配本票目的地' }], remark, idempotencyKey: key('trip') })
const tripNo = trip.trip.tripNo, tripPath = `${BASE}/fleet/trips/${tripNo}`
trip = await command('trip-assign', 'POST', `${tripPath}/assignment`, { recordVersion: trip.trip.recordVersion,
vehicleId: input.resources.vehicleId, driverId: input.resources.driverId, reason: remark })
trip = await command('trip-bind', 'POST', `${tripPath}/dispatches/bind`, { recordVersion: trip.trip.recordVersion, dispatchNo, reason: remark })
let stops = state.stops
if (!stops) {
const loading = await read(`${tripPath}/loading`)
check(loading.cargo?.length > 0 && loading.cargo.every(row => row.destinationKey), '本票装载缺少真实目的地')
stops = [{ name: '验收起运仓' }, ...new Map(loading.cargo.map(row => [row.destinationKey, { name: row.destinationLabel, destinationKey: row.destinationKey }])).values()]
await save({ stops }); state.stops = stops
}
trip = await command('trip-plan', 'PUT', `${tripPath}/plan`, { recordVersion: trip.trip.recordVersion,
plannedDepartureAt: schedule.plannedDepartureAt, expectedDriverEndAt: schedule.expectedDriverEndAt, stops, reason: remark })
trip = await command('trip-review', 'POST', `${tripPath}/dispatches/${dispatchNo}/review`, { recordVersion: trip.trip.recordVersion, decision: 'APPROVED' })
const pdaDispatch = `${PDA}/dispatches/${dispatchNo}`
if (!state.commands?.pickComplete) {
const picking = await read(pdaDispatch, pdaToken)
const exactDispatch = await read(`${BASE}/dispatches/${dispatchNo}`)
check(exactDispatch.items?.length > 0 && exactDispatch.items.every(row => row.inboundCode === domestic.inboundCode)
&& picking.items?.length === exactDispatch.items.length
&& picking.items.every(row => exactDispatch.items.some(item => id(item.id) === id(row.dispatchItemId))), 'PDA拣货包含其他票')
for (const item of picking.items) {
let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages)
for (const position of item.positions || []) {
if (remaining <= 0) break
const packages = Math.min(remaining, Number(position.availablePackages))
if (!(packages > 0)) continue
await command(`pick-${item.dispatchItemId}-${position.id}`, 'POST', `${pdaDispatch}/items/${item.dispatchItemId}/pick`, {
positionId: id(position.id), packages, productQuantity: packages, idempotencyKey: key(`pick-${position.id}`), remark,
}, pdaToken)
remaining -= packages
}
check(remaining === 0, '本票库存不足以完成拣货')
}
await command('pickComplete', 'POST', `${pdaDispatch}/complete`, { idempotencyKey: key('pick-complete') }, pdaToken)
}
let loadingResult = state.commands?.loading?.result
if (!loadingResult) {
const loading = await read(`${tripPath}/loading`)
check(!loading.blockReason, '整车装载未就绪')
loadingResult = await command('loading', 'POST', `${tripPath}/loading`, { recordVersion: loading.recordVersion,
manifestHash: loading.manifestHash, pallets: controlledPalletMeasurements(loading.cargo, stops), reason: `${remark} 合成验收测量` })
}
const reviewed = await command('loading-review', 'POST', `${tripPath}/loading-review`, { recordVersion: loadingResult.recordVersion, decision: 'APPROVED' })
if (!state.commands?.depart) {
const ready = await read(pdaDispatch, pdaToken)
check(ready.status === 'COMPLETED' && ready.canDepart === true, 'PDA发车门禁未通过')
}
await command('depart', 'POST', `${pdaDispatch}/depart`, { recordVersion: reviewed.trip.recordVersion, idempotencyKey: key('depart'), remark }, pdaToken)
const outbound = await read(`${BASE}/outbound-orders/${transfer.outboundNo}`)
check(outbound.outbound?.status === 'OUTBOUNDED' && outbound.pallets?.length > 0
&& outbound.pallets.every(row => row.inboundCode === domestic.inboundCode), '本票未真实完成出库')
const result = { ...domestic, batch: input.batch, packageCount: input.packageCount, warehouseId: input.warehouseId,
tailOrderId, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: transfer.deliveries[0].deliveryNo,
tripNo, status: 'OVERSEAS_OUTBOUNDED', fullFlowVerified: false, photos: 'SYNTHETIC_ACCEPTANCE_ONLY' }
await save({ result }); return result
}
/** Close only this ticket's trip after delivery; release its two resource claims without creating probe trips. */
export async function finishSingleOrderTrip({ input, overseas, context, checkpoint }) {
check(overseas?.tripNo && overseas?.dispatchNo && overseas?.deliveryNo, '缺少本票车次与派送身份')
check(!Object.values(checkpoint.state.commands || {}).some(row => row.status !== 'DONE'), '车次结案命令结果未知;停止重发')
const { read, command } = overseasCommands(context, checkpoint)
const path = `${BASE}/fleet/trips/${encodeURIComponent(overseas.tripNo)}`
const delivery = await read(query(`${BASE}/delivery-orders/${encodeURIComponent(overseas.deliveryNo)}`, { customerId: input.customerId }))
check(delivery.order?.status === 'DELIVERED' && id(delivery.order.customerId) === input.customerId
&& delivery.order.dispatchNo === overseas.dispatchNo, '本票尚未完成签收,不能结案车次')
const detail = await read(path)
check(detail.trip?.tripNo === overseas.tripNo && id(detail.trip.vehicleId) === input.resources.vehicleId
&& id(detail.trip.driverId) === input.resources.driverId && detail.dispatches?.length === 1
&& detail.dispatches[0].dispatchNo === overseas.dispatchNo, '车次含其他票或资源身份不符')
let completed = checkpoint.state.commands?.tripComplete?.result
if (!completed) {
const completion = await read(`${path}/completion`)
check(completion.canComplete === true && completion.outcomes?.length > 0, '车次结案条件未通过')
completed = await command('tripComplete', 'POST', `${path}/complete`, { recordVersion: completion.recordVersion,
idempotencyKey: `${input.batch}-trip-complete`, reason: '单票验收实际签收完成' })
}
check(completed.trip?.status === 'COMPLETED' && completed.trip.completedAt, '车次货物未结案')
const endLocation = detail.trip.stops?.at(-1)?.name
check(Boolean(endLocation), '本车次缺少结束地点')
const vehicle = await command('releaseVehicle', 'POST', `${path}/resources/release`, {
recordVersion: completed.trip.recordVersion, resourceKind: 'VEHICLE', actualEndAt: completed.trip.completedAt,
endLocation, vehicleStatus: 'IN_SERVICE', idempotencyKey: `${input.batch}-vehicle-release`,
reason: '单票受控验收车辆在签收末站结束,无返程安排',
})
const driver = await command('releaseDriver', 'POST', `${path}/resources/release`, {
recordVersion: vehicle.trip.recordVersion, resourceKind: 'DRIVER', actualEndAt: completed.trip.completedAt,
endLocation, vehicleStatus: null, idempotencyKey: `${input.batch}-driver-release`,
reason: '单票受控验收司机在签收末站结束,无返程安排',
})
check(driver.trip?.vehicleRelease && driver.trip?.driverRelease, '本车次车辆或司机未释放')
const final = await read(path)
check(final.trip?.status === 'COMPLETED' && final.trip.vehicleRelease && final.trip.driverRelease, '车次结案回查未通过')
const result = { tripNo: overseas.tripNo, status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' }
await checkpoint.save({ result }); return result
}
@@ -0,0 +1,158 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { overseasCommands, runOverseasStage, validateOverseasInput, finishSingleOrderTrip } from './prod-release-overseas-stage.mjs'
const input = { batch: 'release-test-001', customerId: '1', warehouseId: '2', packageCount: 1,
resources: { vehicleId: '3', driverId: '4', carrierName: '验收既有车队' } }
const domestic = { containerBookingId: '5', sourceInboundId: '6', orderId: '7', containerNo: 'TEST0000001',
inboundCode: 'IN-TEST', orderNo: 'ORDER-TEST', cartonMarks: ['CARTON-TEST'] }
const ok = data => ({ http: 200, body: { code: '200', data } })
const journal = () => { const state = {}; return { state, save: async patch => Object.assign(state, patch) } }
test('reject multi-ticket quantities and duplicate frozen cartons before HTTP', () => {
assert.throws(() => validateOverseasInput({ ...input, packageCount: 3 }, domestic), /1~2/)
assert.throws(() => validateOverseasInput({ ...input, packageCount: 2 }, { ...domestic, cartonMarks: ['a', 'a'] }), /箱码/)
})
test('command records intent before HTTP; uncertain result blocks resend', async () => {
const checkpoint = journal(); let calls = 0
const { command } = overseasCommands({ request: async () => {
calls++; assert.equal(checkpoint.state.commands.create.status, 'INTENT'); throw new Error('timeout')
} }, checkpoint)
await assert.rejects(command('create', 'POST', '/example', { x: 1 }), /timeout/)
await assert.rejects(command('create', 'POST', '/example', { x: 1 }), /结果未知/)
assert.equal(calls, 1)
})
test('completed command restores result without HTTP and rejects changed request', async () => {
const checkpoint = journal(); let calls = 0
const { command } = overseasCommands({ request: async () => { calls++; return ok({ id: '99' }) } }, checkpoint)
await command('create', 'POST', '/example', { x: 1 })
assert.deepEqual(await command('create', 'POST', '/example', { x: 1 }), { id: '99' })
await assert.rejects(command('create', 'POST', '/example', { x: 2 }), /不符/)
assert.equal(calls, 1)
})
test('unknown prior command blocks even read/other writes on continuation', async () => {
const checkpoint = journal(); checkpoint.state.commands = { lost: { status: 'INTENT' } }
await assert.rejects(runOverseasStage({ input, domestic, checkpoint,
context: { overseasPdaToken: 'pda', merchantToken: 'merchant', request: () => assert.fail('must not call HTTP') } }), /结果未知/)
})
test('only initial current receiving probe accepts HTTP 200 A0404 as no session', async () => {
const calls = []
const helpers = overseasCommands({ request: async (method, path, body, token) => {
calls.push({ method, path, token }); return { http: 200, body: { code: 'A0404' } }
} }, journal())
assert.equal(await helpers.readCurrentReceivingSession('pda'), null)
assert.deepEqual(calls[0], { method: 'GET', path: '/api/pda/v1/last-leg/receiving-containers/current', token: 'pda' })
await assert.rejects(helpers.read('/api/pda/v1/last-leg/receiving-containers/current', 'pda'), /A0404/)
await assert.rejects(helpers.read('/api/platform/v1/last-leg/orders/1'), /A0404/)
})
test('current receiving probe preserves auth, transport and other business errors', async () => {
for (const [http, code] of [[200, 'A0403'], [200, 'A0500'], [401, 'A0404'], [404, 'A0404'], [503, 'A0404']]) {
const helpers = overseasCommands({ request: async () => ({ http, body: { code, requestId: 'request-test' } }) }, journal())
await assert.rejects(helpers.readCurrentReceivingSession('pda'), /request-test/)
}
})
function fixture({ foreignSource = false, destinationType = 'OVERSEAS' } = {}) {
const checkpoint = journal(), calls = []
const session = { containerNo: domestic.containerNo, overseasWarehouseId: '2', totalPackages: 1, pendingPackages: 0,
inbounds: [{ inboundCode: domestic.inboundCode, destinationType }] }
const trip = { trip: { tripNo: 'TRIP-TEST', recordVersion: 1, stops: [] } }
const cargo = [{ warehousePalletId: '9', palletNo: 'PLT-TEST', destinationKey: 'DEST', destinationLabel: 'Destination' }]
const context = { token: 'admin', overseasPdaToken: 'pda', merchantToken: 'merchant', wait: async () => {},
requestWithBody: async (method, path, form, token) => {
calls.push({ method, path, token }); assert.equal(token, 'pda'); assert.ok(form instanceof FormData); return ok({ fileId: '10' })
}, request: async (method, path, body, token) => {
calls.push({ method, path, body, token })
if (path.includes('/master-data/vehicles/')) return ok({ id: '3', region: 'OVERSEAS', status: 'IN_SERVICE', plateNumber: 'TEST' })
if (path.includes('/fleet/drivers/')) return ok({ id: '4', qualification: { canDispatch: true }, details: { name: 'Driver', phone: '10000000000' } })
if (path.includes('/orders?containerNo=')) return ok({ records: [{ sourceContainerBookingId: '5', customerId: foreignSource ? '99' : '1', sourceInboundId: '6',
inboundCode: domestic.inboundCode, overseasWarehouseId: '2', id: '8' }] })
if (path.endsWith('/container-arrival-times/query')) return ok([{ sourceContainerBookingId: '5', version: 0 }])
if (path.includes('/container-preview?')) return ok({ ready: true, totalOrderCount: 1, pendingOrderCount: 1, snapshotVersion: 1, snapshotHash: 'hash' })
if (path.endsWith('/convert-container')) return ok([{ lines: [{ inboundCode: domestic.inboundCode }], pallets: [{ palletNo: 'PLT-TEST', warehousePalletId: '9' }] }])
if (path.endsWith('/current') && method === 'GET') return calls.filter(c => c.path.endsWith('/current')).length === 1
? { http: 200, body: { code: 'A0404' } } : ok(session)
if (path.includes('/receiving-containers/TEST0000001?')) return ok(session)
if (path.endsWith('/pallets/resolve')) return ok({ compatible: true })
if (path.endsWith('/cartons/resolve')) return ok({ inboundCode: domestic.inboundCode })
if (path.endsWith('/terminal-drayages/5')) return ok({ containerBookingId: '5', status: 'UNLOADED',
version: 1, unloadingCompletedAt: '2026-10-12T12:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] })
if (path.endsWith('/empty-return')) return ok({ status: 'EMPTY_RETURNED' })
if (path.endsWith('/candidates?sourceStage=RECEIVED')) return ok([{ sourceStage: 'RECEIVED', inboundCode: domestic.inboundCode,
customerId: '1', overseasWarehouseId: '2', packageCount: 1, planPalletId: '11' }])
if (path.endsWith('/pre-arrange')) return ok({ dispatchNo: 'DISPATCH-TEST' })
if (path.includes('/merchant/v1/last-leg/delivery-orders?')) return ok({ outboundNo: 'OUT-TEST', deliveries: [{ deliveryNo: 'DELIVERY-TEST' }] })
if (path.endsWith('/loading') && method === 'GET') return ok({ cargo, recordVersion: 1, manifestHash: 'manifest' })
if (path.endsWith('/loading') && method === 'POST') return ok({ recordVersion: 2 })
if (path.includes('/fleet/trips')) return ok(trip)
if (path === '/api/pda/v1/last-leg/dispatches/DISPATCH-TEST') return ok({ status: 'COMPLETED', canDepart: true,
items: [{ dispatchItemId: '12', scheduledPackages: 1, pickedPackages: 0, positions: [{ id: '13', availablePackages: 1 }] }] })
if (path === '/api/platform/v1/last-leg/dispatches/DISPATCH-TEST' && method === 'GET') return ok({ items: [{ id: '12', inboundCode: domestic.inboundCode }] })
if (path.includes('/outbound-orders/')) return ok({ outbound: { status: 'OUTBOUNDED' }, pallets: [{ inboundCode: domestic.inboundCode }] })
if (method === 'GET') assert.fail(`unexpected GET ${path}`)
return ok({})
} }
return { input, domestic, context, checkpoint, calls }
}
test('normal single-ticket HTTP chain reaches outbound with existing master resources and PDA identity', async () => {
const f = fixture(), result = await runOverseasStage(f)
assert.equal(result.status, 'OVERSEAS_OUTBOUNDED')
assert.equal(result.fullFlowVerified, false)
assert.equal(result.deliveryNo, 'DELIVERY-TEST')
assert.ok(f.calls.some(row => row.path.endsWith('/receipts/carton') && row.token === 'pda'))
assert.ok(f.calls.some(row => row.path.endsWith('/depart') && row.token === 'pda'))
assert.equal(f.calls.filter(row => row.path.endsWith('/terminal-drayages/5')).length, 1)
assert.equal(f.calls.filter(row => row.path.endsWith('/empty-return')).length, 1)
assert.equal(f.calls.filter(row => row.method !== 'GET' && /\/master-data\/|\/fleet\/drivers|\/iam\//.test(row.path)).length, 0)
assert.ok(Object.values(f.checkpoint.state.commands).every(row => row.status === 'DONE'))
})
test('foreign customer source stops before plan conversion or receiving', async () => {
const f = fixture({ foreignSource: true })
await assert.rejects(runOverseasStage(f), /来源不属于/)
assert.equal(f.calls.filter(row => row.method !== 'GET').length, 0)
})
test('FBA receives cumulative package count without fabricated or resolved carton marks', async () => {
const f = fixture({ destinationType: 'FBA' })
f.domestic = { ...f.domestic }; delete f.domestic.cartonMarks
const result = await runOverseasStage(f)
assert.equal(result.status, 'OVERSEAS_OUTBOUNDED')
const received = f.calls.filter(row => row.path.endsWith('/receipts/bulk'))
assert.equal(received.length, 1)
assert.equal(received[0].body.packageCount, 1)
assert.equal(f.calls.filter(row => /\/cartons\/resolve|\/receipts\/carton/.test(row.path)).length, 0)
})
test('OVERSEAS missing frozen carton marks fails before opening receiving session', async () => {
const f = fixture(); f.domestic = { ...f.domestic }; delete f.domestic.cartonMarks
await assert.rejects(runOverseasStage(f), /正式冻结箱码/)
assert.equal(f.calls.filter(row => /\/prepare\?|\/current\/sign|\/receipts\//.test(row.path)).length, 0)
})
test('signed single ticket closes its trip and releases only its existing resources', async () => {
const checkpoint = journal(), writes = []
const overseas = { tripNo: 'TRIP-TEST', dispatchNo: 'DISPATCH-TEST', deliveryNo: 'DELIVERY-TEST' }
let trip = { tripNo: overseas.tripNo, vehicleId: '3', driverId: '4', recordVersion: 1,
stops: [{ name: 'Origin' }, { name: 'Destination' }] }
const context = { request: async (method, path, body) => {
if (path.includes('/delivery-orders/')) return ok({ order: { customerId: '1', status: 'DELIVERED', dispatchNo: overseas.dispatchNo } })
if (path.endsWith('/completion')) return ok({ canComplete: true, recordVersion: 1, outcomes: [{}] })
if (method === 'POST') {
writes.push({ path, body }); trip = { ...trip, recordVersion: trip.recordVersion + 1 }
if (path.endsWith('/complete')) trip = { ...trip, status: 'COMPLETED', completedAt: '2026-10-12T12:00:00Z' }
else if (body.resourceKind === 'VEHICLE') trip.vehicleRelease = { ended: true }
else trip.driverRelease = { ended: true }
}
return ok({ trip, dispatches: [{ dispatchNo: overseas.dispatchNo }] })
} }
assert.equal((await finishSingleOrderTrip({ input, overseas, context, checkpoint })).status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
assert.equal(writes.length, 3)
assert.deepEqual(writes.map(row => row.body.recordVersion), [1, 2, 3])
})
@@ -15,6 +15,30 @@ const http = (path, source, target, contexts, access, idempotency, note) => ({
})
export const SYNCHRONOUS_CLIENTS = Object.freeze([
feign('lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentForecastClient.java',
'lastleg-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ',
'预约影响刷新按柜次批量读取头程不可变船期事实;POST仅查询,刷新写入仅在Lastleg本域,不写Firstleg'),
feign('lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentWarehouseClient.java',
'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ',
'预约货源、整柜及外接候选读取权威启用仓库目录与主档,不创建仓库或预留资源'),
feign('lastleg-service/src/main/java/com/baiyue/lastleg/service/reference/ReturnCustomerReferenceClient.java',
'lastleg-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ',
'退货订单补全客户快照时按客户ID读取IAM有效客户;历史读取失败保留未知,不调用车队业务或客户资格写操作'),
feign('lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferWarehouseCatalogClient.java',
'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ',
'转仓实收按冻结FBA编码读取权威目录,拒绝缺失或歧义;资源预留仍由独立WarehouseResourceGateway处理'),
feign('order/src/main/java/com/baiyue/order/client/LastlegOrderProgressClient.java',
'order-service', 'lastleg-service', ['REQUEST'], 'READ', 'SAFE_READ',
'客户订单工作台按服务端客户ID批量读取尾程进度;POST无业务写入,失败不伪造待办或回退成功'),
http('lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java',
'lastleg-service', 'integration-service', ['BACKGROUND'], 'READ', 'NONE',
'持久任务后台逐页/逐单读取领星适配接口;每次调用先预留账号额度并冻结attempt上下文,真实尝试耗费预算,未知结果持久暂停,禁止传输层自动重试;人工命令仅入队,报表查询不调用此客户端'),
http('integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthority.java',
'integration-service', 'lastleg-service', ['BACKGROUND'], 'READ_WRITE', 'MIXED',
'后台采集出口向Lastleg申请一次性许可并确认真实尝试;authorize消费持久预算,不可重复获得许可,complete按attempt/operation/hash及结果摘要幂等,冲突拒绝;不递归执行采集,不自动重试或跟随重定向'),
http('integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java',
'integration-service', 'external-lingxing-wms', ['EXTERNAL'], 'READ', 'NONE',
'受控WMS OMP查询不写供应商业务,但每次HTTP消耗账号额度;一许可仅允许一次尝试,OFF/无独占窗口拒绝,429或结果不确定停止新请求,无自动重试,不等同可无成本重放的SAFE_READ'),
feign('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripFinanceClient.java',
'lastleg-service', 'finance-service', ['REQUEST'], 'READ', 'SAFE_READ',
'车次财务按不可变发车客户及派送来源批量只读查询,POST无业务写入;内部服务令牌保护,实收另按公网权限裁剪,不逐行查询或推算未分配收款'),
@@ -33,6 +33,7 @@ const expectedOnlineEdges = [
'iam-service->warehouse-service',
'lastleg-service->file-service',
'lastleg-service->finance-service',
'lastleg-service->firstleg-service',
'lastleg-service->iam-service',
'lastleg-service->print-service',
'lastleg-service->warehouse-overseas-service',
@@ -41,12 +42,15 @@ const expectedOnlineEdges = [
'order-service->finance-service',
'order-service->firstleg-service',
'order-service->iam-service',
'order-service->lastleg-service',
'order-service->warehouse-service',
'warehouse-overseas-service->print-service',
'warehouse-overseas-service->warehouse-service'
]
const expectedBackgroundEdges = [
'integration-service->lastleg-service',
'lastleg-service->integration-service',
'file-service->finance-service',
'file-service->firstleg-service',
'file-service->iam-service',
@@ -103,7 +107,7 @@ test('all Feign clients have reviewed context, access and idempotency metadata',
const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN')
.map(({ path, source, target }) => ({ path, source, target }))
.sort((left, right) => left.path.localeCompare(right.path))
assert.equal(reviewed.length, 72)
assert.equal(reviewed.length, 77)
assert.deepEqual(await discoverFeignEdges(), reviewed)
})
@@ -119,7 +123,7 @@ test('all raw HTTP transports have reviewed context, access and idempotency meta
}
const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP')
.map(client => client.path).sort()
assert.equal(reviewed.length, 25)
assert.equal(reviewed.length, 28)
assert.deepEqual(discovered.sort(), reviewed)
})
@@ -194,14 +198,14 @@ test('REQUEST, BACKGROUND and full graphs remain separate executable baselines',
])
assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [
['auth-service', 'iam-service'],
['finance-service', 'firstleg-service', 'order-service']
['finance-service', 'firstleg-service', 'lastleg-service', 'order-service']
])
assert.deepEqual(stronglyConnectedComponents(graphEdges('BACKGROUND')), [[
'finance-service', 'firstleg-service', 'lastleg-service', 'order-service'
'finance-service', 'firstleg-service', 'integration-service', 'lastleg-service', 'order-service'
]])
assert.deepEqual(stronglyConnectedComponents(graphEdges('ALL')), [[
'auth-service', 'file-service', 'finance-service',
'firstleg-service', 'iam-service', 'lastleg-service', 'order-service'
'firstleg-service', 'iam-service', 'integration-service', 'lastleg-service', 'order-service'
]])
})
@@ -222,7 +226,7 @@ test('unified authority checks remain infrastructure edges and direct events rem
assert.ok(!SYNCHRONOUS_CLIENTS.some(client => ['event-bridge-service','gateway-overseas'].includes(client.source) || ['event-bridge-service','gateway-overseas'].includes(client.target)))
})
test('Order, Firstleg and Finance form the reviewed REQUEST SCC', () => {
test('Order, Firstleg, Finance and Lastleg form the reviewed REQUEST SCC', () => {
const requestClients = SYNCHRONOUS_CLIENTS.filter(client => client.contexts.includes('REQUEST'))
const orderToFirstleg = requestClients.filter(client =>
client.source === 'order-service' && client.target === 'firstleg-service')
@@ -260,17 +264,17 @@ test('known user entries expose every longest simple REQUEST path over the two-h
'iam-service->auth-service',
'iam-service->warehouse-service'
] },
'order-service': { hops: 5, paths: [
'order-service->finance-service->firstleg-service->file-service->iam-service->auth-service',
'order-service->finance-service->firstleg-service->file-service->iam-service->warehouse-service'
'order-service': { hops: 6, paths: [
'order-service->lastleg-service->finance-service->firstleg-service->file-service->iam-service->auth-service',
'order-service->lastleg-service->finance-service->firstleg-service->file-service->iam-service->warehouse-service'
] },
'firstleg-service': { hops: 5, paths: [
'firstleg-service->order-service->finance-service->file-service->iam-service->auth-service',
'firstleg-service->order-service->finance-service->file-service->iam-service->warehouse-service'
'firstleg-service': { hops: 6, paths: [
'firstleg-service->order-service->lastleg-service->finance-service->file-service->iam-service->auth-service',
'firstleg-service->order-service->lastleg-service->finance-service->file-service->iam-service->warehouse-service'
] },
'finance-service': { hops: 5, paths: [
'finance-service->firstleg-service->order-service->file-service->iam-service->auth-service',
'finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service'
'finance-service': { hops: 6, paths: [
'finance-service->firstleg-service->order-service->lastleg-service->file-service->iam-service->auth-service',
'finance-service->firstleg-service->order-service->lastleg-service->file-service->iam-service->warehouse-service'
] },
'file-service': { hops: 2, paths: [
'file-service->iam-service->auth-service',
@@ -278,7 +282,9 @@ test('known user entries expose every longest simple REQUEST path over the two-h
] },
'lastleg-service': { hops: 6, paths: [
'lastleg-service->finance-service->firstleg-service->order-service->file-service->iam-service->auth-service',
'lastleg-service->finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service'
'lastleg-service->finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service',
'lastleg-service->firstleg-service->order-service->finance-service->file-service->iam-service->auth-service',
'lastleg-service->firstleg-service->order-service->finance-service->file-service->iam-service->warehouse-service'
] },
'warehouse-service': { hops: 0, paths: ['warehouse-service'] },
'warehouse-overseas-service': { hops: 1, paths: [
@@ -292,6 +298,44 @@ test('known user entries expose every longest simple REQUEST path over the two-h
}
})
test('self-operated collection and quota callbacks stay outside the REQUEST graph', async () => {
const find = suffix => SYNCHRONOUS_CLIENTS.find(client => client.path.endsWith(suffix))
const collection = find('/selfoperated/analytics/client/RestLingxingAnalyticsClient.java')
const authority = find('/provider/lingxing/LingxingHttpBudgetAuthority.java')
const supplier = find('/provider/lingxing/LingxingWmsClient.java')
assert.deepEqual([collection.contexts, collection.access, collection.idempotency], [['BACKGROUND'], 'READ', 'NONE'])
assert.deepEqual([authority.contexts, authority.access, authority.idempotency], [['BACKGROUND'], 'READ_WRITE', 'MIXED'])
assert.deepEqual([supplier.contexts, supplier.access, supplier.idempotency], [['EXTERNAL'], 'READ', 'NONE'])
assert.equal(graphEdges('REQUEST').includes('lastleg-service->integration-service'), false)
assert.equal(graphEdges('REQUEST').includes('integration-service->lastleg-service'), false)
// The background service graph is bidirectional; the permit callback must not re-enter collection.
const egress = await readFile(path.join(root,
'lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java'), 'utf8')
assert.doesNotMatch(egress, /RestLingxingAnalyticsClient|LingxingAnalyticsClient|SelfopExecutionService/)
const callback = await readFile(path.join(root, authority.path), 'utf8')
assert.match(callback, /followRedirects\(HttpClient\.Redirect\.NEVER\)/)
assert.match(callback, /timeout\(Duration\.ofSeconds\(5\)\)/)
assert.doesNotMatch(callback, /RetryTemplate|Retryer|@Retryable|\.retry\(/)
})
test('new domain reference clients are exact readonly internal dependencies', async () => {
const reviewed = [
['lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentForecastClient.java', 'firstleg-service', '/internal/firstleg/appointment-forecasts/batch'],
['lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentWarehouseClient.java', 'warehouse-service', '/internal/warehouses/order-catalog'],
['lastleg-service/src/main/java/com/baiyue/lastleg/service/reference/ReturnCustomerReferenceClient.java', 'iam-service', '/internal/iam/finance-customers'],
['lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferWarehouseCatalogClient.java', 'warehouse-service', '/internal/warehouses/order-catalog'],
['order/src/main/java/com/baiyue/order/client/LastlegOrderProgressClient.java', 'lastleg-service', '/internal/lastleg/v1/customer-order-progress']
]
for (const [file, target, endpoint] of reviewed) {
const client = SYNCHRONOUS_CLIENTS.find(row => row.path === file)
assert.deepEqual([client.target, client.contexts, client.access, client.idempotency], [target, ['REQUEST'], 'READ', 'SAFE_READ'])
const source = await readFile(path.join(root, file), 'utf8')
assert.ok(source.includes(endpoint))
assert.ok(source.includes('X-Internal-Service-Token'))
assert.doesNotMatch(source, /@(?:PutMapping|DeleteMapping|PatchMapping)/)
}
})
test('delivery fee coverage is a single readonly Finance hop and cannot write financial state', async () => {
const client = await readFile(path.join(root, 'lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java'), 'utf8')
const service = await readFile(path.join(root, 'finance/src/main/java/com/baiyue/finance/service/DeliveryFeeCoverageQueryService.java'), 'utf8')