fix: complete GCP synthetic single-order acceptance and align booking collation [skip ci]

This commit is contained in:
2026-10-24 19:20:44 +08:00
parent 493656e677
commit 5cc6f617f5
13 changed files with 420 additions and 83 deletions
@@ -3203,7 +3203,7 @@ CREATE TABLE ll_fleet_external_booking (
confirm_hash CHAR(64) NULL COMMENT '承运确认摘要', confirm_hash CHAR(64) NULL COMMENT '承运确认摘要',
cancel_key VARCHAR(128) NULL COMMENT '取消请求键', cancel_key VARCHAR(128) NULL COMMENT '取消请求键',
cancel_hash CHAR(64) NULL COMMENT '取消摘要', cancel_hash CHAR(64) NULL COMMENT '取消摘要',
incoming_no VARCHAR(64) NULL COMMENT '正式来货单业务编号', incoming_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '正式来货单业务编号',
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车业务编号', dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车业务编号',
receipt_json JSON NULL COMMENT '实测来源及运营确认的托位和运费依据', receipt_json JSON NULL COMMENT '实测来源及运营确认的托位和运费依据',
incoming_key VARCHAR(128) NULL COMMENT '来货登记请求键', incoming_key VARCHAR(128) NULL COMMENT '来货登记请求键',
@@ -1192,7 +1192,7 @@ CREATE TABLE ll_fleet_external_booking (
confirm_hash CHAR(64) NULL COMMENT '承运确认摘要', confirm_hash CHAR(64) NULL COMMENT '承运确认摘要',
cancel_key VARCHAR(128) NULL COMMENT '取消请求键', cancel_key VARCHAR(128) NULL COMMENT '取消请求键',
cancel_hash CHAR(64) NULL COMMENT '取消摘要', cancel_hash CHAR(64) NULL COMMENT '取消摘要',
incoming_no VARCHAR(64) NULL COMMENT '正式来货单业务编号', incoming_no VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '正式来货单业务编号',
dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车业务编号', dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车业务编号',
receipt_json JSON NULL COMMENT '实测来源及运营确认的托位和运费依据', receipt_json JSON NULL COMMENT '实测来源及运营确认的托位和运费依据',
incoming_key VARCHAR(128) NULL COMMENT '来货登记请求键', incoming_key VARCHAR(128) NULL COMMENT '来货登记请求键',
+2 -1
View File
@@ -12,7 +12,7 @@
|---|---|---| |---|---|---|
| 本机重型检查限流 | `local-check.ps1` | 所有工作目录、对话共用两个用户级执行名额;PowerShell 7,状态/超时/并发与 Job Object 回收规则见外层 `docs/AI工程执行与协作约束.md`;只读状态不申请重型名额 | | 本机重型检查限流 | `local-check.ps1` | 所有工作目录、对话共用两个用户级执行名额;PowerShell 7,状态/超时/并发与 Job Object 回收规则见外层 `docs/AI工程执行与协作约束.md`;只读状态不申请重型名额 |
| 后端分层检查 | `backend-check.ps1` | 局部任务显式选 Focused;模式缺省为 Module,不是日常必跑;必填模块并接入限流,范围见后端根指引,参数见 README | | 后端分层检查 | `backend-check.ps1` | 局部任务显式选 Focused;模式缺省为 Module,不是日常必跑;必填模块并接入限流,范围见后端根指引,参数见 README |
| 测试/灰度单票验收 | `prod-release-acceptance.mjs`、`prod-release-delivery-stage.mjs`、内部 `prod-release-order-stage.mjs` | 显式 test/gray、统一 Gateway/accessToken;同一票义乌到洛杉矶、前置同时核对东莞;full-flow 串行覆盖买单FBA正常链至签收回传及车次资源释放,保留分阶段入口与独立检查点;不调用测试造数模块,不创建账号/主档或删除数据;配置和边界见 README | | 测试/灰度单票验收 | `prod-release-acceptance.mjs`、`prod-release-delivery-stage.mjs`、内部 `prod-release-order-stage.mjs` | 显式 test/gray/gcp-test、统一 Gateway/accessToken;同一票义乌到洛杉矶、前置同时核对东莞;full-flow 串行覆盖买单FBA正常链至签收回传及车次资源释放,保留分阶段入口与独立检查点;不调用测试造数模块,不创建账号/主档或删除数据;配置和边界见 README |
| Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;默认模块覆盖平台全链路,定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 | | 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 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 |
| Client 快速下单定向验证 | `client-quick-order-test-env-acceptance.mjs` | 使用既有测试客户通过批量草稿接口验证 FBA、海外仓、私人地址、逐行失败和幂等重放;只创建草稿,不提交或审核,不替代 Client 全量入口 | | Client 快速下单定向验证 | `client-quick-order-test-env-acceptance.mjs` | 使用既有测试客户通过批量草稿接口验证 FBA、海外仓、私人地址、逐行失败和幂等重放;只创建草稿,不提交或审核,不替代 Client 全量入口 |
@@ -37,6 +37,7 @@
- 不在源码、AGENTS、README 或报告中写入密码、Token、私钥、真实客户数据和固定环境密钥;环境地址与账号从入口已支持的参数或本机环境配置读取。 - 不在源码、AGENTS、README 或报告中写入密码、Token、私钥、真实客户数据和固定环境密钥;环境地址与账号从入口已支持的参数或本机环境配置读取。
- 开发与测试验收配置分别只维护在 `$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env`、`$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env`。脚本不得自动猜测环境或读取另一份文件;运行者必须通过位于脚本路径之前的 `node --env-file="<明确文件>"` 选择环境。 - 开发与测试验收配置分别只维护在 `$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env`、`$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env`。脚本不得自动猜测环境或读取另一份文件;运行者必须通过位于脚本路径之前的 `node --env-file="<明确文件>"` 选择环境。
- 开发目录只使用开发环境文件和开发域名,测试目录只使用测试环境文件和测试域名;不得跨目录复制报告、fixture 或未提交脚本来替代正常分支合并。 - 开发目录只使用开发环境文件和开发域名,测试目录只使用测试环境文件和测试域名;不得跨目录复制报告、fixture 或未提交脚本来替代正常分支合并。
- 用户明确授权从当前工作区验收谷歌测试时,单票入口显式使用仓库外 `gcp-test/acceptance.env` 与 `CHONGCHONG_ENV=gcp-test`;不得回退或混用 dev/test/gray 配置。检查点与测量回执保持 GCP 环境身份,不能冒充 TEST 或 GRAY;此例外不授权 SQL 重置或部署。
- 不直接写业务数据库伪造 HTTP 验收成功,不吞掉 `deploymentBlocker`、业务错误码、`requestId` 或 `traceId`。造数脚本的部分成功数据默认保留,清理、重置或删除必须另行授权。 - 不直接写业务数据库伪造 HTTP 验收成功,不吞掉 `deploymentBlocker`、业务错误码、`requestId` 或 `traceId`。造数脚本的部分成功数据默认保留,清理、重置或删除必须另行授权。
- 不默认并发运行共享环境造数。只有用户明确要求并确认序号、报告目录和服务容量边界时,才允许拆分 worker;每个 worker 必须使用不重叠序号和独立报告目录。 - 不默认并发运行共享环境造数。只有用户明确要求并确认序号、报告目录和服务容量边界时,才允许拆分 worker;每个 worker 必须使用不重叠序号和独立报告目录。
- 修改公共参数、上下文或模块签名时,定向检查总入口、生产模块、fixture、合同测试、README 和调用命令,禁止保留旧参数兼容分支。 - 修改公共参数、上下文或模块签名时,定向检查总入口、生产模块、fixture、合同测试、README 和调用命令,禁止保留旧参数兼容分支。
+5 -3
View File
@@ -234,7 +234,7 @@ node --env-file="<外部生产数据库.env>" scripts/prod-database-schema-accep
- `node scripts/prod-release-acceptance.mjs plan`:不加载凭据,不访问 HTTP,列出主链阶段与不覆盖范围。 - `node scripts/prod-release-acceptance.mjs plan`:不加载凭据,不访问 HTTP,列出主链阶段与不覆盖范围。
- `node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/prod-release-acceptance.mjs preflight`:获得当次目标环境 HTTP 授权后运行;登录既有四个账号并查询客户、义乌/东莞/洛杉矶仓、PDA 绑定及义乌资源。不创建业务数据、主档或账号,不删除数据;登录本身会创建会话及审计记录。 - `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。灰度由运行者显式选择外部灰度配置,不自动读取、修改或回退环境文件。 - 必填 `CHONGCHONG_ENV=test`、`gray` 或 `gcp-test`;`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。灰度和谷歌测试由运行者显式选择对应的外部配置,谷歌测试使用 `gcp-test/acceptance.env` 并保留真实环境绑定,不改写为 test;不自动读取、修改或回退环境文件。
- 客户必须为 `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`。不修改环境文件,不读取其他环境作为回退。 - 客户必须为 `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、真实客户名称或原始响应。网络异常不自动重试;修正问题后可重新执行只读检查,产生新报告,不清理旧报告或业务数据。 - 报告位于 `reports/prod-release-preflight-<时间戳>.json`,不包含账号、Token、真实客户名称或原始响应。网络异常不自动重试;修正问题后可重新执行只读检查,产生新报告,不清理旧报告或业务数据。
- 单票入口 `order-stage <外部订单输入.json> <本批次检查点.json> --confirm-single-order [--resume]`:须另获当次目标环境写入授权。自动执行基本前置检查,额外校验指定客户可选的唯一渠道/包税维度与 1~2 箱实际重量、体积报价,然后创建一票、提交、审核;不创建图片、地址、托盘或库位。仅支持 AUTO 入仓码、普通海运 FBA 自送散货、单 SKU 非木制非危险品;目的 FBA 仓显式指定,洛杉矶是后续接柜仓,不是第二票。输入与检查点放仓库外,同一批次始终使用同一检查点文件,禁止并发另开路径或换批次重试。 - 单票入口 `order-stage <外部订单输入.json> <本批次检查点.json> --confirm-single-order [--resume]`:须另获当次目标环境写入授权。自动执行基本前置检查,额外校验指定客户可选的唯一渠道/包税维度与 1~2 箱实际重量、体积报价,然后创建一票、提交、审核;不创建图片、地址、托盘或库位。仅支持 AUTO 入仓码、普通海运 FBA 自送散货、单 SKU 非木制非危险品;目的 FBA 仓显式指定,洛杉矶是后续接柜仓,不是第二票。输入与检查点放仓库外,同一批次始终使用同一检查点文件,禁止并发另开路径或换批次重试。
@@ -244,8 +244,10 @@ node --env-file="<外部生产数据库.env>" scripts/prod-database-schema-accep
- 完整正常链:`full-flow <外部订单输入.json> <独立全流程检查点.json> --confirm-single-full-flow [--resume]`。仅支持 `BUY_DECLARATION` 买单报关、单 SKU、1~2 箱、义乌起运及洛杉矶接柜的 FBA 正常链。先用现有客户下单审核,再测量/费用确认、订舱/分柜、国内 PDA 装柜、买单报关、人工船节点、目的港提柜预约、海外 PDA 收货/还空柜、排车/派送、车次调度/拣货/发车、签收回传及车次结案释放资源。任何HTTP或归属/数量门禁失败都停止,保留同票检查点,不新建订单代替失败。 - 完整正常链:`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,不创建账号、客户、车辆、司机、供应商等主档。正常业务会创建本票货柜、订舱、作业托盘、排车、车次、派送及费用记录。 - 全流程内部模块:`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`;不能只填货物行。报告/输入/检查点使用仓库外私有路径;公开交付仅保留脱敏结果、业务号及诊断编号。 - 完整输入还需 `domestic: {palletId, assigneeId, bookingAgent:{id,name}, carrier:{id,name,carrierCode}, terminalFleet:{id,name}, estimatedSailingAt, estimatedArrivalAt}` 和 `resources:{vehicleId,driverId,carrierName}`,均引用既有启用且有资格的资源。订单级及货物行都必须明确提供 `shipmentId/referenceId`;不能只填货物行。报告/输入/检查点使用仓库外私有路径;公开交付仅保留脱敏结果、业务号及诊断编号。
- 实际装载缺少托位等实测值时,在首次输入中预先指定 `loadingMeasurementFile` 私有绝对路径;该路径随完整输入指纹冻结,文件可在本票扫码核平后补齐,再以同一输入和检查点 `--resume`。文件为 JSON:`{environment:"TEST"|"GRAY",batch,dispatchNo,pallets:[{warehousePalletId,palletSpaces,weightKg,volumeCbm,deliveryIndex,evidence:[{field,actual:true,sourceType,sourceBusinessNo,observedAt,value}]}]}`,环境须与本次入口一致,三项测量各有实际来源、时间和值,实托和路线须匹配当前装载预览。缺失、格式错误或归属不符停止装车,不从计划或计费常量补值。装车意图前保存原文件 SHA、回执和最终测量;成功命令恢复原请求,结果未知停写,不用修改文件触发重发。内部直接调用的 `loadingMeasurements` 与文件输入互斥。 - 实际装载缺少托位等实测值时,在首次输入中预先指定 `loadingMeasurementFile` 私有绝对路径;该路径随完整输入指纹冻结,文件可在本票扫码核平后补齐,再以同一输入和检查点 `--resume`。文件为 JSON:`{environment:"TEST"|"GRAY"|"GCP-TEST",batch,dispatchNo,pallets:[{warehousePalletId,palletSpaces,weightKg,volumeCbm,deliveryIndex,evidence:[{field,actual:true,sourceType,sourceBusinessNo,observedAt,value}]}]}`,环境须与本次入口一致,三项测量各有实际来源、时间和值,实托和路线须匹配当前装载预览。缺失、格式错误或归属不符停止装车,不从计划或计费常量补值。装车意图前保存原文件 SHA、回执和核验测量;现行服务只接收版本、幂等键、清单摘要和备注,不发送客户端 measurements,服务端确认值必须与实测核验一致;成功命令恢复原请求,结果未知停写,不用修改文件触发重发。内部直接调用的 `loadingMeasurements` 与文件输入互斥。
- 全流程报告只有全部正常链及签收回传、资源释放核对成功才输出 `SINGLE_ORDER_HTTP_COMPLETED` 与 `fullFlowVerified:true`,其范围仅为 `ONE_ORDER_NORMAL_HTTP_PATH`。测量、资质、装柜、卸柜和签收文件均明确为合成验收材料,不代表真实物流证据;不覆盖退税报关、多客户/拆单/异常、故障注入、真实支付、第三方下单、邮件、浏览器及设备验收。阶段检查点已完成命令复用原结果,未知写入意图必须人工核对,不能自动重发。 - 谷歌测试合成装车必须由当轮授权后在首票输入显式设置 `syntheticLoading:true` 和 `syntheticOnly:true`,入口环境严格为 `gcp-test`;与 `loadingMeasurementFile`、`loadingMeasurements` 互斥,拒绝 `syntheticLoadingMeasurements` 数值输入。此模式不要求现场实测回执,也不构造 `actual:true` 或向服务发送虚假测量。按正式预览核对本票唯一托盘、拣货数量及车次卸货站,检查点与备注标记 `SYNTHETIC_TEST`、`actual:false`,结果 `realMeasurementVerified:false`、`scope:ONE_ORDER_SYNTHETIC_HTTP_PATH`;默认真实测量来源门禁不变。开关随输入及阶段指纹冻结,不能续跑时切换模式。
- 当前原生排车合同先上传本车车辆、司机合成照片并版本化绑定资源,再送两级审核;拣货后分别上传并保存 `LOADING_SCENE/LOADING_LIST/PRE_DEPARTURE_CHECK/DRIVER_LICENSE/RECEIPT` 五槽合成资料。资料版本与排车版本分开使用;五槽齐全后 `loading-complete` 自动准备派送和发车,脚本不再调用已删除的 `delivery-arrangement/transfer-delivery/PDA depart`。按主单 `documents` 回查唯一客户出库/派送及入仓来源,自动派送没有批次备注,以冻结业务关联和客户/仓库/箱数/来源逐项核验。所有上传/绑定/装车均写前落盘;未知结果停止且不自动重发。
- 全流程报告只有全部正常链及签收回传、资源释放核对成功才输出 `SINGLE_ORDER_HTTP_COMPLETED` 与 `fullFlowVerified:true`,默认范围为 `ONE_ORDER_NORMAL_HTTP_PATH`,显式合成装车范围为 `ONE_ORDER_SYNTHETIC_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-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。单票运行示例(只到已审核,执行前确认授权,不代表全链完成):
+1 -1
View File
@@ -42,7 +42,7 @@ function baseUrl(value, path) {
} }
export function config(env) { export function config(env) {
if (!['test', 'gray'].includes(env.CHONGCHONG_ENV)) throw new Error('必须显式配置 CHONGCHONG_ENV=test 或 gray') if (!['test', 'gray', 'gcp-test'].includes(env.CHONGCHONG_ENV)) throw new Error('必须显式配置 CHONGCHONG_ENV=test、gray 或 gcp-test')
for (const key of ['CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL', 'CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL']) { 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`) if (env[key] != null) throw new Error(`移除旧海外网关配置:${key};所有业务使用统一 Gateway`)
} }
+18 -3
View File
@@ -114,14 +114,14 @@ test('execute, resume and unrecognized arguments fail closed', async () => {
}) })
test('explicit gray/test mode shares one API base and rejects retired environment keys', () => { test('explicit gray/test/gcp-test mode shares one API base and rejects retired environment keys', () => {
for (const mode of ['gray', 'test']) { for (const mode of ['gray', 'test', 'gcp-test']) {
const settings = config({ ...environment(), CHONGCHONG_ENV: mode }) const settings = config({ ...environment(), CHONGCHONG_ENV: mode })
assert.equal(settings.environment, mode) assert.equal(settings.environment, mode)
assert.equal(settings.apiBase, 'https://acceptance.invalid/api') assert.equal(settings.apiBase, 'https://acceptance.invalid/api')
assert.equal(settings.overseas, undefined) assert.equal(settings.overseas, undefined)
} }
for (const mode of ['prod', 'dev', '', undefined]) { for (const mode of ['prod', 'dev', 'GCP-TEST', 'gcp', '', undefined]) {
assert.throws(() => config({ ...environment(), CHONGCHONG_ENV: mode }), /显式配置/) assert.throws(() => config({ ...environment(), CHONGCHONG_ENV: mode }), /显式配置/)
} }
for (const key of ['CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL', 'CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL']) { for (const key of ['CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL', 'CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL']) {
@@ -129,6 +129,21 @@ test('explicit gray/test mode shares one API base and rejects retired environmen
} }
}) })
test('gcp-test preserves its explicit target and cannot fall back to a different customer gateway', () => {
const env = { ...environment(), CHONGCHONG_ENV: 'gcp-test',
CHONGCHONG_ADMIN_API_BASE_URL: 'https://gcp-fixture.invalid/api',
CHONGCHONG_MERCHANT_API_BASE_URL: 'https://gcp-fixture.invalid/api' }
assert.equal(config(env).environment, 'gcp-test')
assert.equal(config(env).apiBase, 'https://gcp-fixture.invalid/api')
assert.throws(() => config({ ...env, CHONGCHONG_MERCHANT_API_BASE_URL: 'https://other-fixture.invalid/api' }), /同一 HTTPS/)
for (const value of ['http://gcp-fixture.invalid/api', 'https://gcp-fixture.invalid/', undefined]) {
assert.throws(() => config({ ...env, CHONGCHONG_ADMIN_API_BASE_URL: value }))
}
for (const key of ['CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL', 'CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL']) {
assert.throws(() => config({ ...env, [key]: env.CHONGCHONG_ADMIN_API_BASE_URL }), /旧海外网关/)
}
})
test('overseas PDA calls the unified gateway using its ordinary access token', async () => { test('overseas PDA calls the unified gateway using its ordinary access token', async () => {
const calls = [] const calls = []
const http = client(config(environment()), async (url, options) => { const http = client(config(environment()), async (url, options) => {
+20 -10
View File
@@ -39,18 +39,27 @@ export async function withDeliveryJournal(path, binding, action) {
} }
export function verifyOutbound(input, dispatch, detail, tail) { export function verifyOutbound(input, dispatch, detail, tail) {
check(dispatch?.dispatchNo === input.dispatchNo && dispatch.overseasWarehouseId === input.warehouseId const customerId = tail?.order?.customerId
&& dispatch.status === 'DEPARTED' && number(dispatch.packageCount) === input.packageCount check(tail?.order?.id === input.tailOrderId && segment(tail.order.orderNo) && tail.order.sourceOrderNo === input.orderNo
&& number(dispatch.shippedPackageCount) === input.packageCount && tail.order.inboundCode === input.inboundCode && tail.order.overseasWarehouseId === input.warehouseId, '尾程与国内订单来源不符')
&& number(dispatch.pickedPackageCount) === input.packageCount check(customerId && dispatch?.dispatchNo === input.dispatchNo && dispatch.status === 'ACTIVE'
&& number(dispatch.remainingPackageCount) === 0, '排车身份、发车状态或数量不符') && ['DELIVERING', 'DELIVERED'].includes(dispatch.stage) && dispatch.pickingReconciled === true
check(dispatch.items?.length > 0 && dispatch.items.every(item => item.inboundCode === input.inboundCode), '排车包含其他票') && number(dispatch.counts?.packageCount) === input.packageCount
&& number(dispatch.counts?.shippedPackageCount) === input.packageCount
&& number(dispatch.counts?.pickedPackageCount) === input.packageCount, '排车身份、发车状态或数量不符')
check(dispatch.orderDetails?.length > 0 && dispatch.orderDetails.every(item => item.inboundCode === input.inboundCode
&& item.customerId === customerId && item.orderNo === tail.order.orderNo), '排车包含其他票或客户')
check(dispatch.documents?.dispatchNo === input.dispatchNo && dispatch.documents.outbounds?.length === 1
&& dispatch.documents.outbounds[0].outboundNo === input.outboundNo && dispatch.documents.outbounds[0].customerId === customerId
&& dispatch.documents.deliveries?.length === 1 && dispatch.documents.deliveries[0].deliveryNo === input.deliveryNo
&& dispatch.documents.deliveries[0].outboundNo === input.outboundNo && dispatch.documents.deliveries[0].customerId === customerId,
'排车关联单据不是本客户本票唯一出库派送')
check(detail?.outbound?.outboundNo === input.outboundNo && detail.outbound.dispatchNo === input.dispatchNo check(detail?.outbound?.outboundNo === input.outboundNo && detail.outbound.dispatchNo === input.dispatchNo
&& detail.outbound.overseasWarehouseId === input.warehouseId && detail.outbound.status === 'OUTBOUNDED' && detail.outbound.overseasWarehouseId === input.warehouseId && detail.outbound.status === 'OUTBOUNDED'
&& number(detail.outbound.packageCount) === input.packageCount, '出库单身份或状态不符') && number(detail.outbound.packageCount) === input.packageCount, '出库单身份或状态不符')
check(detail.deliveries?.length === 1 && detail.deliveries[0].deliveryNo === input.deliveryNo, '不是本票唯一派送单') check(detail.deliveries?.length === 1 && detail.deliveries[0].deliveryNo === input.deliveryNo
check(tail?.order?.id === input.tailOrderId && tail.order.sourceOrderNo === input.orderNo && detail.deliveries[0].customerId === customerId && detail.deliveries[0].dispatchNo === input.dispatchNo
&& tail.order.inboundCode === input.inboundCode && tail.order.overseasWarehouseId === input.warehouseId, '尾程与国内订单来源不符') && detail.deliveries[0].outboundNo === input.outboundNo, '不是本票唯一派送单')
const blockers = [] const blockers = []
if (!detail.pallets?.length || detail.pallets.some(item => item.inboundCode !== input.inboundCode if (!detail.pallets?.length || detail.pallets.some(item => item.inboundCode !== input.inboundCode
|| number(item.pickedPackageCount) !== number(item.packageCount) || item.status === 'WAIT_PICK')) { || number(item.pickedPackageCount) !== number(item.packageCount) || item.status === 'WAIT_PICK')) {
@@ -69,7 +78,8 @@ export async function runDeliveryStage({ input, customerId, state, save, request
const detail = await request('overseas', 'GET', scoped(deliveryPath)) const detail = await request('overseas', 'GET', scoped(deliveryPath))
check(detail?.order?.customerId === customerId && detail.order.deliveryNo === input.deliveryNo check(detail?.order?.customerId === customerId && detail.order.deliveryNo === input.deliveryNo
&& detail.order.dispatchNo === input.dispatchNo && detail.order.outboundNo === input.outboundNo && detail.order.dispatchNo === input.dispatchNo && detail.order.outboundNo === input.outboundNo
&& detail.order.remark === `RELEASE-ACCEPTANCE:${input.batch}`, '派送单身份或验收标记不符') && detail.order.overseasWarehouseId === input.warehouseId && number(detail.order.packageCount) === input.packageCount
&& detail.skus?.length > 0 && detail.skus.every(row => row.inboundCode === input.inboundCode), '派送单身份、客户、源入仓或数量不符')
return detail return detail
} }
const dispatch = await request('overseas', 'GET', `${root}/dispatches/${input.dispatchNo}`) const dispatch = await request('overseas', 'GET', `${root}/dispatches/${input.dispatchNo}`)
+53 -6
View File
@@ -8,11 +8,14 @@ import { executeDelivery, runDeliveryStage, validateDeliveryInput, verifyOutboun
const input = { batch: 'acceptance-1234', orderId: '101', orderNo: 'FL-101', tailOrderId: '201', 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 } warehouseId: '301', inboundCode: 'IN-101', dispatchNo: 'DS-101', outboundNo: 'OB-101', deliveryNo: 'DL-101', packageCount: 1 }
function fixture() { function fixture() {
const dispatch = { dispatchNo: 'DS-101', overseasWarehouseId: '301', status: 'DEPARTED', packageCount: 1, const dispatch = { dispatchNo: 'DS-101', status: 'ACTIVE', stage: 'DELIVERING', pickingReconciled: true,
shippedPackageCount: 1, pickedPackageCount: 1, remainingPackageCount: 0, items: [{ inboundCode: 'IN-101' }] } counts: { packageCount: 1, shippedPackageCount: 1, pickedPackageCount: 1 },
orderDetails: [{ inboundCode: 'IN-101', customerId: '501', orderNo: 'WC-201' }],
documents: { dispatchNo: 'DS-101', outbounds: [{ customerId: '501', outboundNo: 'OB-101' }],
deliveries: [{ customerId: '501', outboundNo: 'OB-101', deliveryNo: 'DL-101' }] } }
const outbound = { outbound: { outboundNo: 'OB-101', dispatchNo: 'DS-101', overseasWarehouseId: '301', status: 'OUTBOUNDED', packageCount: 1 }, const outbound = { outbound: { outboundNo: 'OB-101', dispatchNo: 'DS-101', overseasWarehouseId: '301', status: 'OUTBOUNDED', packageCount: 1 },
deliveries: [{ deliveryNo: 'DL-101' }], pallets: [{ inboundCode: 'IN-101', packageCount: 1, pickedPackageCount: 1, status: 'PICKED' }] } deliveries: [{ deliveryNo: 'DL-101', customerId: '501', dispatchNo: 'DS-101', outboundNo: 'OB-101' }], pallets: [{ inboundCode: 'IN-101', packageCount: 1, pickedPackageCount: 1, status: 'PICKED' }] }
const tail = { order: { id: '201', customerId: '501', sourceOrderNo: 'FL-101', inboundCode: 'IN-101', overseasWarehouseId: '301', subOrderStatus: 'OUTBOUND:OUTBOUNDED' } } const tail = { order: { id: '201', customerId: '501', orderNo: 'WC-201', sourceOrderNo: 'FL-101', inboundCode: 'IN-101', overseasWarehouseId: '301', subOrderStatus: 'OUTBOUND:OUTBOUNDED' } }
return { dispatch, outbound, tail } return { dispatch, outbound, tail }
} }
function harness(state = { steps: {} }) { function harness(state = { steps: {} }) {
@@ -29,7 +32,7 @@ function harness(state = { steps: {} }) {
if (path.includes('/outbound-orders/')) return facts.outbound if (path.includes('/outbound-orders/')) return facts.outbound
if (path.includes('/last-leg/orders/')) return facts.tail if (path.includes('/last-leg/orders/')) return facts.tail
if (path.includes('/delivery-orders/')) return { order: { customerId: '501', deliveryNo: 'DL-101', dispatchNo: 'DS-101', outboundNo: 'OB-101', if (path.includes('/delivery-orders/')) return { order: { customerId: '501', deliveryNo: 'DL-101', dispatchNo: 'DS-101', outboundNo: 'OB-101',
remark: 'RELEASE-ACCEPTANCE:acceptance-1234', status, version: 0 }, operations: status === 'DELIVERED' ? [{ operationNode: 'RECEIPT_AUTO_COMPLETE' }] : [] } remark: null, overseasWarehouseId: '301', packageCount: 1, status, version: 0 }, skus: [{ inboundCode: 'IN-101' }], operations: status === 'DELIVERED' ? [{ operationNode: 'RECEIPT_AUTO_COMPLETE' }] : [] }
if (region === 'cn' && path.includes('/platform/inbounds?')) return { records: [{ inboundCode: 'IN-101', orderId: '101', processStatus: 'DELIVERED' }] } if (region === 'cn' && path.includes('/platform/inbounds?')) return { records: [{ inboundCode: 'IN-101', orderId: '101', processStatus: 'DELIVERED' }] }
if (region === 'cn') return { summary: { mainStatus: 'COMPLETED' } } if (region === 'cn') return { summary: { mainStatus: 'COMPLETED' } }
throw new Error('unexpected request') throw new Error('unexpected request')
@@ -64,13 +67,36 @@ test('stale pallet detail blocks release despite successful dispatch and receipt
assert.deepEqual(result.blockers, ['OUTBOUND_PALLET_PICK_STATE_STALE', 'TAIL_OUTBOUND_SUB_STATUS_STALE']) assert.deepEqual(result.blockers, ['OUTBOUND_PALLET_PICK_STATE_STALE', 'TAIL_OUTBOUND_SUB_STATUS_STALE'])
}) })
test('wrong source order or additional inbound blocks before mutations', async () => { test('wrong source order or additional inbound blocks before mutations', async () => {
for (const mutate of [h => { h.facts.tail.order.sourceOrderNo = 'OTHER' }, h => { h.facts.dispatch.items.push({ inboundCode: 'OTHER' }) }]) { for (const mutate of [h => { h.facts.tail.order.sourceOrderNo = 'OTHER' }, h => { h.facts.dispatch.orderDetails.push({ inboundCode: 'OTHER', customerId: '501', orderNo: 'FL-101' }) }]) {
const h = harness(); mutate(h) const h = harness(); mutate(h)
await assert.rejects(runDeliveryStage(h.options)) await assert.rejects(runDeliveryStage(h.options))
assert.equal(h.writes.length, 0) assert.equal(h.writes.length, 0)
assert.equal(h.uploads(), 0) assert.equal(h.uploads(), 0)
} }
}) })
test('dispatch binds the tail order number and tail binds the domestic source without either-or fallback', async () => {
const facts = fixture()
assert.notEqual(facts.tail.order.orderNo, input.orderNo)
assert.deepEqual(verifyOutbound(input, facts.dispatch, facts.outbound, facts.tail), [])
for (const mutate of [
h => { h.facts.dispatch.orderDetails[0].orderNo = input.orderNo },
h => { h.facts.dispatch.orderDetails[0].orderNo = 'WC-OTHER' },
h => { h.facts.dispatch.orderDetails.push({ ...h.facts.dispatch.orderDetails[0], orderNo: 'WC-OTHER' }) },
h => { h.facts.tail.order.sourceOrderNo = h.facts.tail.order.orderNo },
h => { delete h.facts.tail.order.orderNo; delete h.facts.dispatch.orderDetails[0].orderNo },
h => { h.facts.tail.order.orderNo = ''; h.facts.dispatch.orderDetails[0].orderNo = '' },
h => { h.facts.tail.order.id = '999' },
h => { h.facts.tail.order.inboundCode = 'OTHER' },
h => { h.facts.tail.order.overseasWarehouseId = '999' },
]) {
const h = harness(); mutate(h)
await assert.rejects(runDeliveryStage(h.options))
assert.equal(h.writes.length, 0)
assert.equal(h.uploads(), 0)
assert.deepEqual(h.options.state, { steps: {} })
}
})
test('uncertain upload and uncertain status mutation are never resent', async () => { test('uncertain upload and uncertain status mutation are never resent', async () => {
const h = harness({ uploadIntent: true }) const h = harness({ uploadIntent: true })
await assert.rejects(runDeliveryStage(h.options), /禁止重复上传/) await assert.rejects(runDeliveryStage(h.options), /禁止重复上传/)
@@ -158,3 +184,24 @@ test('delivery executor uses one gateway/accessToken for status, multipart recei
assert.equal(result.fullFlowVerified, false) assert.equal(result.fullFlowVerified, false)
assert.equal(calls.filter(path => path === '/auth/login').length, 1) assert.equal(calls.filter(path => path === '/auth/login').length, 1)
}) })
test('native auto delivery rejects foreign customer, dispatch, outbound and source before receipt writes', async () => {
for (const patch of [{ customerId: '999' }, { dispatchNo: 'OTHER' }, { outboundNo: 'OTHER' }, { overseasWarehouseId: '999' }, { packageCount: 2 }]) {
const h = harness(), request = h.options.request
h.options.request = async (...args) => { const data = await request(...args); if (data?.order?.deliveryNo) Object.assign(data.order, patch); return data }
await assert.rejects(runDeliveryStage(h.options), /派送单身份/)
assert.equal(h.writes.length, 0); assert.equal(h.uploads(), 0)
}
for (const mutate of [h => { h.facts.dispatch.orderDetails[0].customerId = '999' },
h => { h.facts.dispatch.documents.deliveries[0].outboundNo = 'OTHER' },
h => { h.facts.outbound.deliveries[0].customerId = '999' }]) {
const h = harness(); mutate(h)
await assert.rejects(runDeliveryStage(h.options))
assert.equal(h.writes.length, 0); assert.equal(h.uploads(), 0)
}
const h = harness(), request = h.options.request
h.options.request = async (...args) => { const data = await request(...args); if (data?.skus) data.skus[0].inboundCode = 'OTHER'; return data }
await assert.rejects(runDeliveryStage(h.options), /源入仓/)
assert.equal(h.writes.length, 0); assert.equal(h.uploads(), 0)
})
+8 -2
View File
@@ -5,13 +5,19 @@ import { executeOrder } from './prod-release-acceptance.mjs'
import { executeDelivery } from './prod-release-delivery-stage.mjs' import { executeDelivery } from './prod-release-delivery-stage.mjs'
import { createFlowContext, withFlowJournal, requireSuccess } from './prod-release-http.mjs' import { createFlowContext, withFlowJournal, requireSuccess } from './prod-release-http.mjs'
import { runDomesticStage } from './prod-release-domestic-stage.mjs' import { runDomesticStage } from './prod-release-domestic-stage.mjs'
import { runOverseasStage, finishSingleOrderTrip } from './prod-release-overseas-stage.mjs' import { runOverseasStage, finishSingleOrderTrip, validateSyntheticLoadingInput } from './prod-release-overseas-stage.mjs'
export function loadingVerificationResult(overseas) {
return { loadingMeasurementMode: overseas.loadingMeasurementMode, realMeasurementVerified: overseas.realMeasurementVerified === true,
scope: overseas.loadingMeasurementMode === 'SYNTHETIC_TEST' ? 'ONE_ORDER_SYNTHETIC_HTTP_PATH' : 'ONE_ORDER_NORMAL_HTTP_PATH' }
}
export async function executeFullFlow(settings, rawInput, journalPath, resume = false, fetcher = fetch) { export async function executeFullFlow(settings, rawInput, journalPath, resume = false, fetcher = fetch) {
const input = { ...normalizeOrderInput(rawInput), customer: settings.customer, const input = { ...normalizeOrderInput(rawInput), customer: settings.customer,
environment: { mode: settings.environment, apiBase: settings.apiBase }, environment: { mode: settings.environment, apiBase: settings.apiBase },
operator: { merchant: settings.credentials.MERCHANT.loginId, admin: settings.credentials.ADMIN.loginId } } operator: { merchant: settings.credentials.MERCHANT.loginId, admin: settings.credentials.ADMIN.loginId } }
validateOrderInput(input) validateOrderInput(input)
validateSyntheticLoadingInput(input)
const binding = fingerprint({ input, credentials: Object.fromEntries(Object.entries(settings.credentials).map(([key,value])=>[key,value.loginId])) }) const binding = fingerprint({ input, credentials: Object.fromEntries(Object.entries(settings.credentials).map(([key,value])=>[key,value.loginId])) })
return withFlowJournal(journalPath, binding, async (state, save) => { return withFlowJournal(journalPath, binding, async (state, save) => {
if (state.status !== 'NEW' && !resume) throw Error('已有全流程检查点,须保持原输入并显式 --resume') if (state.status !== 'NEW' && !resume) throw Error('已有全流程检查点,须保持原输入并显式 --resume')
@@ -73,7 +79,7 @@ export async function executeFullFlow(settings, rawInput, journalPath, resume =
if (delivery.blockers.length) throw Error(`签收/回传未闭环:${delivery.blockers.join(',')}`) 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 tripClosure = await finishSingleOrderTrip({ input: { ...input, customerId: settings.customer.id }, overseas, context: ctx, checkpoint: checkpoint('tripClosure') })
const result = { status: 'SINGLE_ORDER_HTTP_COMPLETED', fullFlowVerified: true, const result = { status: 'SINGLE_ORDER_HTTP_COMPLETED', fullFlowVerified: true,
scope: 'ONE_ORDER_NORMAL_HTTP_PATH', orderNo: order.orderNo, inboundCode: order.inboundCode, ...loadingVerificationResult(overseas), orderNo: order.orderNo, inboundCode: order.inboundCode,
containerNo: domestic.containerNo, dispatchNo: overseas.dispatchNo, deliveryNo: overseas.deliveryNo, containerNo: domestic.containerNo, dispatchNo: overseas.dispatchNo, deliveryNo: overseas.deliveryNo,
domesticOrderStatus: delivery.domesticOrderStatus, domesticInboundStatus: delivery.domesticInboundStatus, domesticOrderStatus: delivery.domesticOrderStatus, domesticInboundStatus: delivery.domesticInboundStatus,
tripClosureStatus: tripClosure.status, tripClosureStatus: tripClosure.status,
+1 -1
View File
@@ -60,7 +60,7 @@ function binding(input) {
// The caller must retain this file between invocations. Never generate a new batch to retry a failed one. // 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) { export async function withOrderJournal(path, input, action) {
validateOrderInput(input) validateOrderInput(input)
assert(['test', 'gray'].includes(input.environment?.mode) assert(['test', 'gray', 'gcp-test'].includes(input.environment?.mode)
&& typeof input.environment.apiBase === 'string' && input.environment.apiBase.startsWith('https://'), '缺少环境绑定') && typeof input.environment.apiBase === 'string' && input.environment.apiBase.startsWith('https://'), '缺少环境绑定')
await mkdir(dirname(path), { recursive: true }) await mkdir(dirname(path), { recursive: true })
const lock = await open(`${path}.lock`, 'wx', 0o600) const lock = await open(`${path}.lock`, 'wx', 0o600)
+20
View File
@@ -26,6 +26,26 @@ async function sandbox(t) {
t.after(() => rm(path, { recursive: true, force: true })) t.after(() => rm(path, { recursive: true, force: true }))
return join(path, 'journal.json') return join(path, 'journal.json')
} }
test('gcp-test journal resumes only with the same environment and unified gateway', async t => {
const path = await sandbox(t)
const input = fixture()
input.environment = { mode: 'gcp-test', apiBase: 'https://gcp-fixture.invalid/api' }
await withOrderJournal(path, input, async (state, save) => {
assert.equal(state.phase, 'NEW')
await save({})
})
const original = await readFile(path, 'utf8')
for (const environment of [
{ ...input.environment, mode: 'test' },
{ ...input.environment, mode: 'gray' },
{ ...input.environment, apiBase: 'https://other-fixture.invalid/api' },
]) {
await assert.rejects(withOrderJournal(path, { ...input, environment }, () => assert.fail('must not run')), /环境、客户、账号或载荷已变化/)
assert.equal(await readFile(path, 'utf8'), original)
}
await withOrderJournal(path, input, state => assert.equal(state.phase, 'NEW'))
})
function server(input, fail) { function server(input, fail) {
let status = null let status = null
let failed = false let failed = false
+110 -41
View File
@@ -9,7 +9,7 @@ const PDA = '/api/pda/v1/last-leg'
const check = (ok, message) => { if (!ok) throw new Error(message) } const check = (ok, message) => { if (!ok) throw new Error(message) }
const id = value => { check(typeof value !== 'number' || Number.isSafeInteger(value), '业务ID精度无效'); return value == null ? '' : String(value) } const id = value => { check(typeof value !== 'number' || Number.isSafeInteger(value), '业务ID精度无效'); return value == null ? '' : String(value) }
const version = value => { check(/^(0|[1-9]\d*)$/.test(String(value)) && (typeof value !== 'number' || Number.isSafeInteger(value)), '原生排车recordVersion缺失或精度无效'); return value } const version = value => { check(/^(0|[1-9]\d*)$/.test(String(value)) && (typeof value !== 'number' || Number.isSafeInteger(value)), '原生排车recordVersion缺失或精度无效'); return value }
const DISPATCH_PROTOCOL = 'NATIVE_DISPATCH_V1' const DISPATCH_PROTOCOL = 'NATIVE_DISPATCH_AUTO_DEPART_V2'
const query = (path, values) => `${path}?${new URLSearchParams(values)}` const query = (path, values) => `${path}?${new URLSearchParams(values)}`
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
@@ -63,7 +63,17 @@ export function overseasCommands(context, checkpoint) {
lookup: async (path, body) => unwrap(await context.request('POST', path, body)) } lookup: async (path, body) => unwrap(await context.request('POST', path, body)) }
} }
/** Explicit, test-only alternative; never manufactures actual:true evidence. */
export function validateSyntheticLoadingInput(input) {
check(input.syntheticLoadingMeasurements == null, '当前装车接口不接受客户端测量数值;合成验收使用syntheticLoading=true')
if (input.syntheticLoading == null || input.syntheticLoading === false) return
check(input.syntheticLoading === true && input.environment?.mode === 'gcp-test' && input.syntheticOnly === true,
'合成装车仅允许显式gcp-test且syntheticLoading=true、syntheticOnly=true')
check(input.loadingMeasurementFile == null && input.loadingMeasurements == null, '合成装车不能同时提供真实测量文件或内联测量')
}
export function validateOverseasInput(input, domestic) { export function validateOverseasInput(input, domestic) {
validateSyntheticLoadingInput(input)
check(/^[A-Za-z0-9][A-Za-z0-9_-]{7,47}$/.test(input.batch || ''), '无效验收批次') check(/^[A-Za-z0-9][A-Za-z0-9_-]{7,47}$/.test(input.batch || ''), '无效验收批次')
check([1, 2].includes(input.packageCount), '海外仅支持1~2箱单票') 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 ['customerId', 'warehouseId']) check(/^[1-9]\d*$/.test(input[key] || ''), `缺少 ${key}`)
@@ -71,7 +81,7 @@ export function validateOverseasInput(input, domestic) {
check(Boolean(input.resources.carrierName), '缺少既有车队名称') check(Boolean(input.resources.carrierName), '缺少既有车队名称')
check(!(input.loadingMeasurementFile != null && input.loadingMeasurements != null), '真实测量文件与内联测量不能同时提供') check(!(input.loadingMeasurementFile != null && input.loadingMeasurements != null), '真实测量文件与内联测量不能同时提供')
if (input.loadingMeasurementFile != null) check(typeof input.loadingMeasurementFile === 'string' && isAbsolute(input.loadingMeasurementFile), '真实测量文件须为预先绑定的明确绝对路径') if (input.loadingMeasurementFile != null) check(typeof input.loadingMeasurementFile === 'string' && isAbsolute(input.loadingMeasurementFile), '真实测量文件须为预先绑定的明确绝对路径')
if (input.loadingMeasurementFile != null || input.loadingMeasurements != null) check(['test', 'gray'].includes(input.environment?.mode), '真实测量缺少本次TEST或GRAY环境绑定') if (input.loadingMeasurementFile != null || input.loadingMeasurements != null) check(['test', 'gray', 'gcp-test'].includes(input.environment?.mode), '真实测量缺少本次TEST、GRAY或GCP-TEST环境绑定')
for (const key of ['containerBookingId', 'sourceInboundId', 'orderId']) check(/^[1-9]\d*$/.test(domestic[key] || ''), `缺少 ${key}`) 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}`) for (const key of ['containerNo', 'inboundCode', 'orderNo']) check(Boolean(domestic[key]), `缺少 ${key}`)
if (domestic.cartonMarks != null) check(Array.isArray(domestic.cartonMarks) if (domestic.cartonMarks != null) check(Array.isArray(domestic.cartonMarks)
@@ -85,6 +95,10 @@ export async function runOverseasStage({ input, domestic, context, checkpoint })
check(context.overseasPdaToken && context.merchantToken, '缺少既有海外 PDA 或本客户会话') check(context.overseasPdaToken && context.merchantToken, '缺少既有海外 PDA 或本客户会话')
const { state, save } = checkpoint const { state, save } = checkpoint
check(!Object.values(state.commands || {}).some(row => row.status !== 'DONE'), '海外命令结果未知;停止重发并保留检查点') check(!Object.values(state.commands || {}).some(row => row.status !== 'DONE'), '海外命令结果未知;停止重发并保留检查点')
const loadingModeBinding = fingerprint({ synthetic: input.syntheticLoading === true, syntheticOnly: input.syntheticOnly === true })
check(!state.loadingModeBinding || state.loadingModeBinding === loadingModeBinding, '装车模式或合成值与冻结检查点不符')
check(!input.syntheticLoading || !state.loadingMeasurementEvidence || state.loadingMeasurementEvidence.source === 'SYNTHETIC_TEST', '不能将既有真实测量检查点改为合成装车')
if (!state.loadingModeBinding) { await save({ loadingModeBinding }); state.loadingModeBinding = loadingModeBinding }
check(!state.dispatchProtocol || state.dispatchProtocol === DISPATCH_PROTOCOL, '海外排车检查点协议不符') check(!state.dispatchProtocol || state.dispatchProtocol === DISPATCH_PROTOCOL, '海外排车检查点协议不符')
const oldDispatchCommands = ['dispatch', 'dispatch-vehicle', 'transfer', 'trip', 'trip-assign', 'trip-bind', 'trip-plan', 'trip-review', 'pickComplete', 'loading', 'loading-review', 'depart'] const oldDispatchCommands = ['dispatch', 'dispatch-vehicle', 'transfer', 'trip', 'trip-assign', 'trip-bind', 'trip-plan', 'trip-review', 'pickComplete', 'loading', 'loading-review', 'depart']
check(state.dispatchProtocol === DISPATCH_PROTOCOL || !oldDispatchCommands.some(name => state.commands?.[name]) check(state.dispatchProtocol === DISPATCH_PROTOCOL || !oldDispatchCommands.some(name => state.commands?.[name])
@@ -92,7 +106,8 @@ export async function runOverseasStage({ input, domestic, context, checkpoint })
if (!state.dispatchProtocol) { await save({ dispatchProtocol: DISPATCH_PROTOCOL }); state.dispatchProtocol = DISPATCH_PROTOCOL } if (!state.dispatchProtocol) { await save({ dispatchProtocol: DISPATCH_PROTOCOL }); state.dispatchProtocol = DISPATCH_PROTOCOL }
const { read, lookup, command, recorded, readCurrentReceivingSession } = overseasCommands(context, checkpoint) const { read, lookup, command, recorded, readCurrentReceivingSession } = overseasCommands(context, checkpoint)
const pdaToken = context.overseasPdaToken const pdaToken = context.overseasPdaToken
const remark = `RELEASE-ACCEPTANCE:${input.batch}` const syntheticLoading = input.syntheticLoading === true
const remark = `RELEASE-ACCEPTANCE:${input.batch}${syntheticLoading ? ':SYNTHETIC_TEST:actual=false' : ''}`
const key = name => `${input.batch}-${name}` const key = name => `${input.batch}-${name}`
if (!state.schedule) { if (!state.schedule) {
const schedule = { plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), const schedule = { plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(),
@@ -215,10 +230,57 @@ export async function runOverseasStage({ input, domestic, context, checkpoint })
version(detail.recordVersion); return { detail, items: items.items } version(detail.recordVersion); return { detail, items: items.items }
} }
await ownMain() await ownMain()
const vehiclePhoto = await command('vehicle-photo', 'POST', `${dispatchPath}/evidence/VEHICLE`, null, context.token, {}, `${input.batch}-SYNTHETIC_TEST-vehicle.png`)
const driverPhoto = await command('driver-photo', 'POST', `${dispatchPath}/evidence/DRIVER_PHOTO`, null, context.token, {}, `${input.batch}-SYNTHETIC_TEST-driver.png`)
check(/^[1-9]\d*$/.test(id(vehiclePhoto.fileId)) && /^[1-9]\d*$/.test(id(driverPhoto.fileId)), '本车合成资源照片上传回执缺少文件身份')
await recorded('resource-evidence', 'PUT', dispatchPath, async () => {
const { detail } = await ownMain()
check(detail.stage === 'BEFORE_LOADING', '资源照片绑定必须在送审前')
return { recordVersion: version(detail.recordVersion), idempotencyKey: key('resource-evidence'),
vehicle: { mode: 'MASTER', vehicleId: input.resources.vehicleId, evidenceFileIds: [id(vehiclePhoto.fileId)] },
driver: { mode: 'MASTER', driverId: input.resources.driverId,
evidence: { expectedTaskEndAt: schedule.expectedDriverEndAt, photoFileIds: [id(driverPhoto.fileId)] } },
plannedDepartureAt: schedule.plannedDepartureAt, estimatedArrivalAt: new Date(Date.parse(schedule.plannedDepartureAt) + 86400000).toISOString(),
remark: `${remark}:SYNTHETIC_TEST资源照片,非真实资质` }
})
const reviewDetail = (await ownMain()).detail
check(Array.isArray(reviewDetail.dispatchInfo?.fbaAppointments), '本车目的仓预约预览缺失')
const missingAppointments = reviewDetail.dispatchInfo.fbaAppointments.filter(row => !row.appointmentNo?.trim())
if (state.commands?.['destination-appointment'] || missingAppointments.length) {
await recorded('destination-appointment', 'PUT', dispatchPath, async () => {
check(syntheticLoading && input.syntheticOnly === true && input.environment?.mode === 'gcp-test',
'本车目的仓缺少正式预约号;仅显式GCP合成验收可登记合成预约')
const { detail } = await ownMain(), appointments = detail.dispatchInfo?.fbaAppointments
check(detail.stage === 'BEFORE_LOADING' && appointments?.length === 1 && input.destination?.type === 'FBA'
&& /^[A-Z0-9-]{1,32}$/.test(input.destination.code || '')
&& appointments[0].destinationKey === `FBA:${input.destination.code}`
&& appointments[0].destinationWarehouseCode === input.destination.code && !appointments[0].appointmentNo?.trim(),
'合成预约须绑定本票唯一正式FBA目的仓,不能猜测或覆盖已有预约')
const appointmentNo = `SYNTHETIC_TEST-${input.batch}-${input.destination.code}`
const destinationAppointments = [{ destinationKey: appointments[0].destinationKey, appointmentNo }]
const vehicle = detail.vehicleInfo.vehicle, driver = detail.vehicleInfo.driver
check(vehicle.mode === 'MASTER' && driver.mode === 'MASTER' && vehicle.evidenceFileIds?.length
&& driver.evidence?.photoFileIds?.length && detail.dispatchInfo.plannedDepartureAt && detail.dispatchInfo.estimatedArrivalAt,
'预约维护缺少本车已核验资源照片或运输时间')
const destinationAppointmentEvidence = { source: 'SYNTHETIC_TEST', actual: false, batch: input.batch, dispatchNo, destinationAppointments }
await save({ destinationAppointmentEvidence }); state.destinationAppointmentEvidence = destinationAppointmentEvidence
return { recordVersion: version(detail.recordVersion), idempotencyKey: key('destination-appointment'),
vehicle: { mode: vehicle.mode, vehicleId: id(vehicle.vehicleId), plate: vehicle.plate, carrierName: vehicle.carrierName,
capacity: vehicle.capacity, evidenceFileIds: vehicle.evidenceFileIds },
driver: { mode: driver.mode, driverId: id(driver.driverId), name: driver.name, phone: driver.phone, evidence: driver.evidence },
plannedDepartureAt: detail.dispatchInfo.plannedDepartureAt, estimatedArrivalAt: detail.dispatchInfo.estimatedArrivalAt,
maintenance: { ...detail.vehicleInfo.maintenance, destinationAppointments },
remark: `${remark}:SYNTHETIC_TEST预约号,仅测试,不代表第三方确认` }
})
const current = (await ownMain()).detail.dispatchInfo.fbaAppointments
const expected = state.commands['destination-appointment'].request.body.maintenance.destinationAppointments
check(current?.length === expected.length && expected.every(row => current.some(saved =>
saved.destinationKey === row.destinationKey && saved.appointmentNo === row.appointmentNo)), '本车合成预约保存后回查不符')
}
for (const action of ['submit-review', 'dispatch-review', 'loading-review']) await recorded(action, 'POST', `${dispatchPath}/${action}`, async () => { for (const action of ['submit-review', 'dispatch-review', 'loading-review']) await recorded(action, 'POST', `${dispatchPath}/${action}`, async () => {
const { detail } = await ownMain() const { detail } = await ownMain()
check(detail.stage === 'BEFORE_LOADING', '本票原生审核阶段不符') check(detail.stage === 'BEFORE_LOADING', '本票原生审核阶段不符')
return { recordVersion: detail.recordVersion, idempotencyKey: key(action), return { recordVersion: detail.recordVersion, idempotencyKey: key(action === 'submit-review' ? 'submit-review-with-appointment' : action),
...(action === 'submit-review' ? { remark } : { decision: 'APPROVED', reason: remark }) } ...(action === 'submit-review' ? { remark } : { decision: 'APPROVED', reason: remark }) }
}) })
const candidates = []; let total const candidates = []; let total
@@ -278,6 +340,21 @@ export async function runOverseasStage({ input, domestic, context, checkpoint })
&& reconciled.items.every(item => Number(item.scheduledPackages) === Number(item.pickedPackages)), '本票真实扫码未核平或提前越过装车中') && reconciled.items.every(item => Number(item.scheduledPackages) === Number(item.pickedPackages)), '本票真实扫码未核平或提前越过装车中')
await save({ pickingVerified: true }); state.pickingVerified = true await save({ pickingVerified: true }); state.pickingVerified = true
} }
const loadingDocuments = `${pdaDispatch}/loading-documents`
const slots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT']
for (const slot of slots) {
const photo = await command(`loading-photo-${slot}`, 'POST', query(`${loadingDocuments}/${slot}/uploads`, { idempotencyKey: key(`photo-${slot}`) }),
null, pdaToken, {}, `${input.batch}-SYNTHETIC_TEST-${slot}.png`)
check(photo.slot === slot && /^[1-9]\d*$/.test(id(photo.fileId)), '装车合成照片回执槽位或身份不符')
await recorded(`loading-photo-bind-${slot}`, 'PUT', `${loadingDocuments}/${slot}`, async () => {
const documents = await read(loadingDocuments, pdaToken)
check(documents.dispatchNo === dispatchNo && documents.stage === 'LOADING' && documents.editable === true, '本车装车资料尚不可编辑')
return { recordVersion: String(version(documents.recordVersion)), idempotencyKey: key(`photo-bind-${slot}`), fileId: id(photo.fileId) }
}, pdaToken)
}
const documents = await read(loadingDocuments, pdaToken)
check(documents.dispatchNo === dispatchNo && documents.complete === true && slots.every(slot =>
documents.photos?.filter(photo => photo.slot === slot && id(photo.fileId)).length === 1), '本车五槽装车资料未核齐')
const loaded = await recorded('loading-complete', 'POST', `${pdaDispatch}/loading-complete`, async () => { const loaded = await recorded('loading-complete', 'POST', `${pdaDispatch}/loading-complete`, async () => {
const preview = await read(`${pdaDispatch}/loading`, pdaToken) const preview = await read(`${pdaDispatch}/loading`, pdaToken)
check(preview.dispatchNo === dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'LOADING' check(preview.dispatchNo === dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'LOADING'
@@ -302,55 +379,47 @@ export async function runOverseasStage({ input, domestic, context, checkpoint })
&& Number(evidence.value) === Number(measured[field])), `本轮实际测量${field}缺少逐字段actual来源证据`) && Number(evidence.value) === Number(measured[field])), `本轮实际测量${field}缺少逐字段actual来源证据`)
} }
} }
const measurements = controlledPalletMeasurements(preview.pallets, trip.trip.stops, actual?.pallets) const measurements = syntheticLoading ? null : controlledPalletMeasurements(preview.pallets, trip.trip.stops, actual?.pallets)
check(preview.pallets.every(row => Number.isSafeInteger(Number(row.scheduledPackages)) && Number(row.scheduledPackages) > 0
&& Number(row.pickedPackages) === Number(row.scheduledPackages) && Number.isSafeInteger(row.deliveryIndex)
&& row.deliveryIndex >= 1 && trip.trip.stops?.[row.deliveryIndex]?.destinationKey), '本票正式预览拣货或卸货路线不符')
const loadingMeasurementEvidence = { environment: input.environment?.mode?.toUpperCase() ?? null, batch: input.batch, dispatchNo, const loadingMeasurementEvidence = { environment: input.environment?.mode?.toUpperCase() ?? null, batch: input.batch, dispatchNo,
source: input.loadingMeasurementFile != null ? 'EXPLICIT_FILE' : actual != null ? 'INLINE' : 'NATIVE_PREVIEW', source: syntheticLoading ? 'SYNTHETIC_TEST' : input.loadingMeasurementFile != null ? 'EXPLICIT_FILE' : actual != null ? 'INLINE' : 'NATIVE_PREVIEW',
...(syntheticLoading ? { actual: false, realMeasurementVerified: false, tripNo, stops: trip.trip.stops,
previewPallets: preview.pallets.map(row => ({ warehousePalletId: id(row.warehousePalletId), palletNo: row.palletNo, deliveryIndex: row.deliveryIndex })) } : {}),
file: input.loadingMeasurementFile ?? null, sourceSha256, receipt: actual ?? null, manifestHash: preview.manifestHash, measurements } file: input.loadingMeasurementFile ?? null, sourceSha256, receipt: actual ?? null, manifestHash: preview.manifestHash, measurements }
if (state.loadingMeasurementEvidence) check(fingerprint(state.loadingMeasurementEvidence) === fingerprint(loadingMeasurementEvidence), if (state.loadingMeasurementEvidence) check(fingerprint(state.loadingMeasurementEvidence) === fingerprint(loadingMeasurementEvidence),
'已冻结的本轮真实测量发生变化,禁止替换原证据或重造装车命令') '已冻结的本轮真实测量发生变化,禁止替换原证据或重造装车命令')
else { await save({ loadingMeasurementEvidence }); state.loadingMeasurementEvidence = loadingMeasurementEvidence } else { await save({ loadingMeasurementEvidence }); state.loadingMeasurementEvidence = loadingMeasurementEvidence }
return { recordVersion: version(preview.recordVersion), idempotencyKey: key('loading-complete'), manifestHash: preview.manifestHash, return { recordVersion: version(preview.recordVersion), idempotencyKey: key('loading-complete'), manifestHash: preview.manifestHash,
measurements, remark: `${remark}仅采用真实来源测量,不从计费或计划常量推算` } remark: syntheticLoading ? `${remark}合成验收,仅验证测试HTTP流程,不提供实测数值` : `${remark}仅采用真实来源测量,不从计费或计划常量推算` }
}, pdaToken) }, pdaToken)
check(loaded.dispatchNo === dispatchNo && loaded.status === 'ACTIVE' && loaded.stage === 'AFTER_LOADING' check(loaded.dispatchNo === dispatchNo && loaded.status === 'ACTIVE' && loaded.stage === 'DELIVERING'
&& loaded.result?.confirmedLoad?.pallets?.length === 1, '本票显式装车回执不符') && loaded.result?.confirmedLoad?.pallets?.length === 1
const transfer = await recorded('transfer', 'POST', `${dispatchPath}/transfer-delivery`, async () => { && id(loaded.result.confirmedLoad.pallets[0].warehousePalletId) === id(pallet.warehousePalletId), '本票装车自动发车回执不符')
const preview = await read(`${dispatchPath}/delivery-arrangement`) if (!syntheticLoading) {
check(preview.dispatchNo === dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'AFTER_LOADING' const confirmed = loaded.result.confirmedLoad.pallets[0], measured = state.loadingMeasurementEvidence.measurements[0]
&& preview.arrange?.allowed === true && preview.cargoHash && preview.loadingCompletionHash && preview.groups?.length === 1 check(['palletSpaces', 'weightKg', 'volumeCbm', 'deliveryIndex'].every(field => Number(confirmed[field]) === Number(measured[field])),
&& id(preview.groups[0].customerId) === input.customerId && preview.groups[0].cargo?.length > 0 '服务端已确认装车与本轮实测核验不一致')
&& preview.groups[0].cargo.every(row => id(row.customerId) === input.customerId && row.inboundCode === domestic.inboundCode) }
&& preview.groups[0].blockReasons?.length === 0 && preview.groups[0].externalQuotes?.length === 0, '本票派送安排不完整、混入其他票或存在外接约定') const { detail: dispatched } = await ownMain()
return { recordVersion: version(preview.recordVersion), idempotencyKey: key('transfer'), cargoHash: preview.cargoHash, const documentsAfter = dispatched.documents
loadingCompletionHash: preview.loadingCompletionHash, groups: preview.groups.map(group => ({ groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, check(dispatched.stage === 'DELIVERING' && Number(dispatched.counts?.shippedPackageCount) === input.packageCount
plannedDeliveryAt: new Date(Date.parse(schedule.plannedDepartureAt) + 86400000).toISOString(), deliveryMethod: 'TRUCK_PALLET', waybillNo: key('WAYBILL'), && documentsAfter?.dispatchNo === dispatchNo && documentsAfter.deliveries?.length === 1 && documentsAfter.outbounds?.length === 1
carrierName: input.resources.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '本票真实实载', && id(documentsAfter.deliveries[0].customerId) === input.customerId && id(documentsAfter.outbounds[0].customerId) === input.customerId
appointmentAccount: 'SELF', sellerName: null, remark, externalFreightHash: group.externalFreightHash, feeCoverageCargoHash: group.feeCoverageCargoHash, && documentsAfter.deliveries[0].outboundNo === documentsAfter.outbounds[0].outboundNo
feeCoverage: [], fees: (group.requiredServices || []).map(feeType => ({ feeType, feeName: '派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark })) })) } && Number(documentsAfter.deliveries[0].packageCount) === input.packageCount, '本票自动派送出库身份或数量不符')
}) const outboundNo = documentsAfter.outbounds[0].outboundNo, deliveryNo = documentsAfter.deliveries[0].deliveryNo
check(transfer.dispatchNo === dispatchNo && transfer.status === 'ACTIVE' && transfer.stage === 'AFTER_LOADING'
&& transfer.result?.deliveries?.length === 1 && transfer.result.outbounds?.length === 1
&& id(transfer.result.deliveries[0].customerId) === input.customerId && id(transfer.result.outbounds[0].customerId) === input.customerId
&& transfer.result.deliveries[0].outboundNo === transfer.result.outbounds[0].outboundNo
&& Number(transfer.result.deliveries[0].packageCount) === input.packageCount, '本票未形成原生唯一客户出库和派送')
const outboundNo = transfer.result.outbounds[0].outboundNo, deliveryNo = transfer.result.deliveries[0].deliveryNo
const departed = await recorded('depart', 'POST', `${pdaDispatch}/depart`, async () => {
const ready = await read(pdaDispatch, pdaToken), { detail } = await ownMain()
check(ready.dispatchNo === dispatchNo && ready.status === 'ACTIVE' && ready.stage === 'AFTER_LOADING'
&& ready.pickingReconciled === true && ready.actions?.DEPART?.allowed === true && detail.actions?.DEPART?.allowed === true
&& id(ready.recordVersion) === id(detail.recordVersion), 'PDA及平台当前主版本发车门禁未通过')
return { recordVersion: version(ready.recordVersion), idempotencyKey: key('depart'), remark }
}, pdaToken)
check(departed.dispatchNo === dispatchNo && departed.status === 'ACTIVE' && departed.stage === 'DELIVERING'
&& Number(departed.result?.shippedPackages) === input.packageCount && departed.result.deliveries?.length === 1
&& departed.result.deliveries[0].deliveryNo === deliveryNo, '本票原生发车结果不符')
const outbound = await read(query(`${BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId: input.customerId })) const outbound = await read(query(`${BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId: input.customerId }))
check(id(outbound.outbound?.customerId) === input.customerId && outbound.outbound.dispatchNo === dispatchNo check(outbound.outbound?.outboundNo === outboundNo && outbound.outbound.dispatchNo === dispatchNo
&& id(outbound.outbound.overseasWarehouseId) === input.warehouseId && outbound.deliveries?.length === 1
&& id(outbound.deliveries[0].customerId) === input.customerId && outbound.deliveries[0].deliveryNo === deliveryNo
&& outbound.outbound.status === 'OUTBOUNDED' && outbound.pallets?.length > 0 && outbound.outbound.status === 'OUTBOUNDED' && outbound.pallets?.length > 0
&& outbound.pallets.every(row => row.inboundCode === domestic.inboundCode) && Number(outbound.outbound.packageCount) === input.packageCount, '本票未真实完成出库') && outbound.pallets.every(row => row.inboundCode === domestic.inboundCode) && Number(outbound.outbound.packageCount) === input.packageCount, '本票未真实完成出库')
const result = { ...domestic, batch: input.batch, packageCount: input.packageCount, warehouseId: input.warehouseId, const result = { ...domestic, batch: input.batch, packageCount: input.packageCount, warehouseId: input.warehouseId,
tailOrderId, dispatchNo, outboundNo, deliveryNo, tailOrderId, dispatchNo, outboundNo, deliveryNo,
tripNo, status: 'OVERSEAS_OUTBOUNDED', fullFlowVerified: false, photos: 'SYNTHETIC_ACCEPTANCE_ONLY' } tripNo, status: 'OVERSEAS_OUTBOUNDED', fullFlowVerified: false, photos: 'SYNTHETIC_ACCEPTANCE_ONLY',
loadingMeasurementMode: syntheticLoading ? 'SYNTHETIC_TEST' : 'ACTUAL_EVIDENCE', realMeasurementVerified: !syntheticLoading }
await save({ result }); return result await save({ result }); return result
} }
+180 -13
View File
@@ -1,10 +1,11 @@
import test from 'node:test' import test from 'node:test'
import { loadingVerificationResult } from './prod-release-full-flow.mjs'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { mkdtemp, writeFile, rm, readFile, rmdir } from 'node:fs/promises' import { mkdtemp, writeFile, rm, readFile, rmdir } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { overseasCommands, runOverseasStage, validateOverseasInput, finishSingleOrderTrip } from './prod-release-overseas-stage.mjs' import { overseasCommands, runOverseasStage, validateOverseasInput, finishSingleOrderTrip, validateSyntheticLoadingInput } from './prod-release-overseas-stage.mjs'
const input = { environment: { mode: 'test', apiBase: 'https://test.example.invalid/api' }, batch: 'release-test-001', customerId: '1', warehouseId: '2', packageCount: 1, const input = { environment: { mode: 'test', apiBase: 'https://test.example.invalid/api' }, batch: 'release-test-001', customerId: '1', warehouseId: '2', packageCount: 1,
resources: { vehicleId: '3', driverId: '4', carrierName: '验收既有车队' } } resources: { vehicleId: '3', driverId: '4', carrierName: '验收既有车队' } }
@@ -62,12 +63,16 @@ test('current receiving probe preserves auth, transport and other business error
}) })
function fixture({ foreignSource = false, destinationType = 'OVERSEAS', missingActual = false, foreignCargo = false, function fixture({ foreignSource = false, destinationType = 'OVERSEAS', missingActual = false, foreignCargo = false,
foreignTrip = false, candidateComplete = true, loseCommand = null } = {}) { foreignTrip = false, candidateComplete = true, loseCommand = null, missingAppointment = false } = {}) {
const checkpoint = journal(), calls = [] const checkpoint = journal(), calls = []
const session = { containerNo: domestic.containerNo, overseasWarehouseId: '2', totalPackages: 1, pendingPackages: 0, const session = { containerNo: domestic.containerNo, overseasWarehouseId: '2', totalPackages: 1, pendingPackages: 0,
inbounds: [{ inboundCode: domestic.inboundCode, destinationType }] } inbounds: [{ inboundCode: domestic.inboundCode, destinationType }] }
const dispatchNo = 'DISPATCH-TEST', dispatchPath = `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, pdaPath = `/api/pda/v1/last-leg/dispatches/${dispatchNo}` const dispatchNo = 'DISPATCH-TEST', dispatchPath = `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, pdaPath = `/api/pda/v1/last-leg/dispatches/${dispatchNo}`
let stage = 'BEFORE_LOADING', recordVersion = 0, picked = 0, created = false, receivingRead = 0 let stage = 'BEFORE_LOADING', recordVersion = 0, picked = 0, created = false, receivingRead = 0, documentVersion = 0
const loadingPhotos = []
let savedMaintenance = { fleetEmail: 'synthetic@example.invalid', destinationAppointments: [] }, savedVehicle = { mode: 'MASTER', vehicleId: '3' }, savedDriver = { mode: 'MASTER', driverId: '4' }
let plannedDepartureAt = null, estimatedArrivalAt = null
const appointments = () => missingAppointment ? [{ destinationKey: 'FBA:DEST', destinationWarehouseCode: 'DEST', appointmentNo: savedMaintenance.destinationAppointments[0]?.appointmentNo ?? null, status: savedMaintenance.destinationAppointments.length ? 'REGISTERED' : 'PENDING' }] : []
const stops = [{ name: 'Origin' }, { name: 'Destination', destinationKey: 'FBA:DEST' }] const stops = [{ name: 'Origin' }, { name: 'Destination', destinationKey: 'FBA:DEST' }]
const counts = () => ({ packageCount: 1, pickedPackageCount: picked, shippedPackageCount: stage === 'DELIVERING' ? 1 : 0 }) const counts = () => ({ packageCount: 1, pickedPackageCount: picked, shippedPackageCount: stage === 'DELIVERING' ? 1 : 0 })
const nativeResult = result => ({ dispatchNo, status: 'ACTIVE', stage, recordVersion, counts: counts(), result }) const nativeResult = result => ({ dispatchNo, status: 'ACTIVE', stage, recordVersion, counts: counts(), result })
@@ -87,7 +92,11 @@ function fixture({ foreignSource = false, destinationType = 'OVERSEAS', missingA
value: { palletSpaces: 2, weightKg: 17.75, volumeCbm: '0.072' }[field] })) }) value: { palletSpaces: 2, weightKg: 17.75, volumeCbm: '0.072' }[field] })) })
const context = { token: 'admin', overseasPdaToken: 'pda', merchantToken: 'merchant', wait: async () => {}, const context = { token: 'admin', overseasPdaToken: 'pda', merchantToken: 'merchant', wait: async () => {},
requestWithBody: async (method, path, form, token) => { requestWithBody: async (method, path, form, token) => {
calls.push({ method, path, token }); assert.equal(token, 'pda'); assert.ok(form instanceof FormData); return ok({ fileId: '10' }) calls.push({ method, path, token }); assert.ok(form instanceof FormData)
if (path.includes('/evidence/')) { assert.equal(token, 'admin'); return ok({ fileId: '10' }) }
assert.equal(token, 'pda')
if (path.includes('/loading-documents/')) return ok({ slot: path.split('/loading-documents/')[1].split('/')[0], fileId: '10' })
return ok({ fileId: '10' })
}, request: async (method, path, body, token) => { }, request: async (method, path, body, token) => {
calls.push({ method, path, body: body == null ? body : structuredClone(body), token }) calls.push({ method, path, body: body == null ? body : structuredClone(body), token })
if (path.includes('/master-data/vehicles/')) return ok({ id: '3', region: 'OVERSEAS', status: 'IN_SERVICE', plateNumber: 'TEST' }) if (path.includes('/master-data/vehicles/')) return ok({ id: '3', region: 'OVERSEAS', status: 'IN_SERVICE', plateNumber: 'TEST' })
@@ -110,12 +119,26 @@ function fixture({ foreignSource = false, destinationType = 'OVERSEAS', missingA
if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') { created = true; return ok(nativeResult(null)) } if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') { created = true; return ok(nativeResult(null)) }
if (path.startsWith('/api/platform/v1/last-leg/fleet/trips?')) return ok({ records: [trip().trip], total: 1, page: 1, size: 200 }) if (path.startsWith('/api/platform/v1/last-leg/fleet/trips?')) return ok({ records: [trip().trip], total: 1, page: 1, size: 200 })
if (path.endsWith('/fleet/trips/TRIP-TEST')) return ok(trip()) if (path.endsWith('/fleet/trips/TRIP-TEST')) return ok(trip())
if (path === dispatchPath && method === 'PUT') {
assert.equal(body.recordVersion, recordVersion); recordVersion++
savedVehicle = structuredClone(body.vehicle); savedDriver = structuredClone(body.driver)
plannedDepartureAt = body.plannedDepartureAt; estimatedArrivalAt = body.estimatedArrivalAt
if (body.maintenance) savedMaintenance = structuredClone(body.maintenance)
if (body.idempotencyKey.endsWith('-destination-appointment') && loseCommand === 'appointment') throw Error('appointment response lost')
return ok(nativeResult(null))
}
if (path === dispatchPath && method === 'GET') return ok({ ...nativeResult(null), pickingReconciled: picked === 1, if (path === dispatchPath && method === 'GET') return ok({ ...nativeResult(null), pickingReconciled: picked === 1,
documents: { dispatchNo, outbounds: stage === 'DELIVERING' ? [{ customerId: '1', outboundNo: 'OUT-TEST' }] : [], deliveries: stage === 'DELIVERING' ? [delivery] : [] },
orderDetails: [{ inboundCode: domestic.inboundCode, customerId: '1' }], orderDetails: [{ inboundCode: domestic.inboundCode, customerId: '1' }],
vehicleInfo: { vehicle: { vehicleId: '3' }, driver: { driverId: '4' } }, actions: { DEPART: { allowed: stage === 'AFTER_LOADING' } } }) vehicleInfo: { vehicle: savedVehicle, driver: savedDriver, maintenance: savedMaintenance },
dispatchInfo: { fbaAppointments: appointments(), plannedDepartureAt, estimatedArrivalAt }, actions: { DEPART: { allowed: stage === 'AFTER_LOADING' } } })
if (path === `${dispatchPath}/items`) return ok({ dispatchNo, recordVersion, items: [ownItem()] }) if (path === `${dispatchPath}/items`) return ok({ dispatchNo, recordVersion, items: [ownItem()] })
if (path === pdaPath && method === 'GET') return ok({ ...nativeResult(null), warehouseId: '2', pickingReconciled: picked === 1, if (path === pdaPath && method === 'GET') return ok({ ...nativeResult(null), warehouseId: '2', pickingReconciled: picked === 1,
items: [ownItem()], actions: { DEPART: { allowed: stage === 'AFTER_LOADING' } } }) items: [ownItem()], actions: { DEPART: { allowed: stage === 'AFTER_LOADING' } } })
if (path.startsWith(`${pdaPath}/loading-documents`)) {
if (method === 'PUT') { assert.equal(body.recordVersion, String(documentVersion)); documentVersion++; loadingPhotos.push({ slot: path.split('/').at(-1), fileId: body.fileId }) }
return ok({ dispatchNo, stage, recordVersion: String(documentVersion), dispatchRecordVersion: String(recordVersion), editable: true, complete: loadingPhotos.length === 5, photos: [...loadingPhotos] })
}
if (path === `${pdaPath}/loading` && method === 'GET') return ok({ ...nativeResult(null), pickingReconciled: picked === 1, if (path === `${pdaPath}/loading` && method === 'GET') return ok({ ...nativeResult(null), pickingReconciled: picked === 1,
loadingComplete: { allowed: picked === 1 }, manifestHash: 'real-manifest', pallets: [measured()] }) loadingComplete: { allowed: picked === 1 }, manifestHash: 'real-manifest', pallets: [measured()] })
if (path === `${dispatchPath}/delivery-arrangement`) return ok({ ...nativeResult(null), arrange: { allowed: true }, if (path === `${dispatchPath}/delivery-arrangement`) return ok({ ...nativeResult(null), arrange: { allowed: true },
@@ -125,7 +148,10 @@ function fixture({ foreignSource = false, destinationType = 'OVERSEAS', missingA
recordVersion++ recordVersion++
if (path.endsWith('/loading-review')) stage = 'LOADING' if (path.endsWith('/loading-review')) stage = 'LOADING'
if (path.endsWith('/pick')) { assert.equal(stage, 'LOADING'); picked = 1 } if (path.endsWith('/pick')) { assert.equal(stage, 'LOADING'); picked = 1 }
if (path.endsWith('/loading-complete')) { stage = 'AFTER_LOADING'; return ok(nativeResult({ confirmedLoad: { pallets: body.measurements } })) } if (path.endsWith('/loading-complete')) {
assert.deepEqual(Object.keys(body).sort(), ['idempotencyKey','manifestHash','recordVersion','remark'])
assert.equal(loadingPhotos.length, 5); stage = 'DELIVERING'; return ok(nativeResult({ confirmedLoad: { pallets: [{ ...measured(), palletSpaces: 2 }] } }))
}
if (path.endsWith('/transfer-delivery')) { if (path.endsWith('/transfer-delivery')) {
assert.equal(token, 'admin'); assert.equal(stage, 'AFTER_LOADING'); assert.equal(body.groups.length, 1) assert.equal(token, 'admin'); assert.equal(stage, 'AFTER_LOADING'); assert.equal(body.groups.length, 1)
assert.equal(body.groups[0].fees[0].currency, 'USD'); assert.equal(body.groups[0].fees[0].amount, 10) assert.equal(body.groups[0].fees[0].currency, 'USD'); assert.equal(body.groups[0].fees[0].amount, 10)
@@ -135,7 +161,7 @@ function fixture({ foreignSource = false, destinationType = 'OVERSEAS', missingA
if (path.endsWith(loseCommand || 'NOT_A_COMMAND')) throw Error('simulated response loss') if (path.endsWith(loseCommand || 'NOT_A_COMMAND')) throw Error('simulated response loss')
return ok(nativeResult(null)) return ok(nativeResult(null))
} }
if (path.includes('/outbound-orders/')) return ok({ outbound: { customerId: '1', dispatchNo, status: 'OUTBOUNDED', packageCount: 1 }, pallets: [{ inboundCode: domestic.inboundCode }] }) if (path.includes('/outbound-orders/')) return ok({ outbound: { outboundNo: 'OUT-TEST', overseasWarehouseId: '2', dispatchNo, status: 'OUTBOUNDED', packageCount: 1 }, deliveries: [delivery], pallets: [{ inboundCode: domestic.inboundCode }] })
if (method === 'GET') assert.fail(`unexpected GET ${path}`) if (method === 'GET') assert.fail(`unexpected GET ${path}`)
return ok({}) return ok({})
} } } }
@@ -148,7 +174,8 @@ test('normal single-ticket HTTP chain reaches outbound with existing master reso
assert.equal(result.fullFlowVerified, false) assert.equal(result.fullFlowVerified, false)
assert.equal(result.deliveryNo, 'DELIVERY-TEST') 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('/receipts/carton') && row.token === 'pda'))
assert.ok(f.calls.some(row => row.path.endsWith('/depart') && row.token === 'pda')) assert.ok(f.calls.some(row => row.path.endsWith('/loading-complete') && row.token === 'pda'))
assert.equal(f.calls.filter(row => /delivery-arrangement|transfer-delivery|\/depart$/.test(row.path)).length, 0)
assert.equal(f.calls.filter(row => row.path.endsWith('/terminal-drayages/5')).length, 1) 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.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.equal(f.calls.filter(row => row.method !== 'GET' && /\/master-data\/|\/fleet\/drivers|\/iam\//.test(row.path)).length, 0)
@@ -208,7 +235,7 @@ test('native successful continuation restores original dynamic requests without
assert.equal((await runOverseasStage(f)).dispatchNo, result.dispatchNo) assert.equal((await runOverseasStage(f)).dispatchNo, result.dispatchNo)
assert.equal(f.calls.filter(row => row.method !== 'GET' && !row.path.endsWith('/container-arrival-times/query')).length, writes - 1) assert.equal(f.calls.filter(row => row.method !== 'GET' && !row.path.endsWith('/container-arrival-times/query')).length, writes - 1)
assert.deepEqual(f.checkpoint.state.commands['submit-review'].request, firstSubmit) assert.deepEqual(f.checkpoint.state.commands['submit-review'].request, firstSubmit)
assert.equal(firstSubmit.body.recordVersion, 0) assert.equal(firstSubmit.body.recordVersion, 1)
assert.equal(f.calls.filter(row => row.path.endsWith('/loading-complete')).length, 1) assert.equal(f.calls.filter(row => row.path.endsWith('/loading-complete')).length, 1)
}) })
@@ -252,14 +279,16 @@ test('incomplete candidates, foreign cargo and wrong auto-trip binding cannot en
test('actual native loading keeps measured 2 spaces, 17.75kg and 0.072m3 and distinct explicit stages', async () => { test('actual native loading keeps measured 2 spaces, 17.75kg and 0.072m3 and distinct explicit stages', async () => {
const f = fixture(); await runOverseasStage(f) const f = fixture(); await runOverseasStage(f)
const load = f.calls.find(row => row.path.endsWith('/loading-complete')) const load = f.calls.find(row => row.path.endsWith('/loading-complete'))
assert.deepEqual(load.body.measurements, [{ warehousePalletId: '9', palletSpaces: 2, weightKg: 17.75, volumeCbm: '0.072', deliveryIndex: 1 }]) assert.equal(Object.hasOwn(load.body, 'measurements'), false)
assert.deepEqual(f.checkpoint.state.loadingMeasurementEvidence.measurements, [{ warehousePalletId: '9', palletSpaces: 2, weightKg: 17.75, volumeCbm: '0.072', deliveryIndex: 1 }])
const create = f.calls.find(row => row.method === 'POST' && row.path === '/api/platform/v1/last-leg/dispatches') const create = f.calls.find(row => row.method === 'POST' && row.path === '/api/platform/v1/last-leg/dispatches')
assert.deepEqual(create.body.vehicle, { mode: 'MASTER', vehicleId: '3' }) assert.deepEqual(create.body.vehicle, { mode: 'MASTER', vehicleId: '3' })
assert.equal(create.body.driver.mode, 'MASTER'); assert.equal(create.body.driver.driverId, '4') assert.equal(create.body.driver.mode, 'MASTER'); assert.equal(create.body.driver.driverId, '4')
assert.equal(create.body.driver.evidence.expectedTaskEndAt, f.checkpoint.state.schedule.expectedDriverEndAt) assert.equal(create.body.driver.evidence.expectedTaskEndAt, f.checkpoint.state.schedule.expectedDriverEndAt)
assert.equal(Object.hasOwn(create.body, 'customerId'), false) assert.equal(Object.hasOwn(create.body, 'customerId'), false)
const pick = f.calls.findIndex(row => row.path.endsWith('/pick')), loading = f.calls.indexOf(load), arrangement = f.calls.findIndex(row => row.path.endsWith('/transfer-delivery')), depart = f.calls.findIndex(row => row.path.endsWith('/depart')) const pick = f.calls.findIndex(row => row.path.endsWith('/pick')), loading = f.calls.indexOf(load)
assert.ok(pick < loading && loading < arrangement && arrangement < depart) assert.ok(pick < loading)
assert.equal(f.calls.filter(row => /delivery-arrangement|transfer-delivery|\/depart$/.test(row.path)).length, 0)
assert.equal(f.calls.filter(row => row.path === '/api/pda/v1/last-leg/dispatches/DISPATCH-TEST/complete').length, 0) assert.equal(f.calls.filter(row => row.path === '/api/pda/v1/last-leg/dispatches/DISPATCH-TEST/complete').length, 0)
assert.equal(f.calls.filter(row => row.path.includes('/merchant/v1/last-leg/delivery-orders')).length, 0) assert.equal(f.calls.filter(row => row.path.includes('/merchant/v1/last-leg/delivery-orders')).length, 0)
}) })
@@ -270,7 +299,7 @@ test('lost native review result preserves exact INTENT request and prevents all
await assert.rejects(runOverseasStage(f), /response loss/) await assert.rejects(runOverseasStage(f), /response loss/)
const intent = f.checkpoint.state.commands['dispatch-review'] const intent = f.checkpoint.state.commands['dispatch-review']
assert.equal(intent.status, 'INTENT') assert.equal(intent.status, 'INTENT')
assert.equal(intent.request.body.recordVersion, 1) assert.equal(intent.request.body.recordVersion, 2)
assert.equal(intent.request.body.idempotencyKey, `${input.batch}-dispatch-review`) assert.equal(intent.request.body.idempotencyKey, `${input.batch}-dispatch-review`)
const calls = f.calls.length const calls = f.calls.length
await assert.rejects(runOverseasStage(f), /结果未知/) await assert.rejects(runOverseasStage(f), /结果未知/)
@@ -291,6 +320,28 @@ function measurementReceipt(patch = {}) {
sourceBusinessNo: 'MEASURE-SAME-RUN', observedAt: '2026-10-19T10:00:00Z', value: { palletSpaces: 2, weightKg: 17.75, volumeCbm: '0.072' }[field] })) }], ...patch } sourceBusinessNo: 'MEASURE-SAME-RUN', observedAt: '2026-10-19T10:00:00Z', value: { palletSpaces: 2, weightKg: 17.75, volumeCbm: '0.072' }[field] })) }], ...patch }
} }
test('gcp-test loading accepts only GCP-TEST receipt and preserves actual field evidence', async () => {
for (const environment of ['TEST', 'GRAY', 'gcp-test', 'GCP-TEST']) {
const f = fixture({ missingActual: true })
f.input = { ...f.input, environment: { mode: 'gcp-test', apiBase: 'https://gcp-fixture.invalid/api' },
loadingMeasurements: measurementReceipt({ environment }) }
if (environment === 'GCP-TEST') {
assert.equal((await runOverseasStage(f)).status, 'OVERSEAS_OUTBOUNDED')
assert.equal(f.checkpoint.state.loadingMeasurementEvidence.environment, 'GCP-TEST')
assert.equal(f.checkpoint.state.loadingMeasurementEvidence.receipt.environment, 'GCP-TEST')
} else {
await assert.rejects(runOverseasStage(f), /绑定不符/)
assert.equal(f.checkpoint.state.commands['loading-complete'], undefined)
}
}
const f = fixture({ missingActual: true })
const receipt = measurementReceipt({ environment: 'GCP-TEST' })
receipt.pallets[0].evidence[0].actual = false
f.input = { ...f.input, environment: { mode: 'gcp-test', apiBase: 'https://gcp-fixture.invalid/api' }, loadingMeasurements: receipt }
await assert.rejects(runOverseasStage(f), /actual来源证据/)
assert.equal(f.checkpoint.state.commands['loading-complete'], undefined)
})
test('explicit fixed measurement file can be supplied after picking and continued without changing the original input', async () => { test('explicit fixed measurement file can be supplied after picking and continued without changing the original input', async () => {
const directory = await mkdtemp(join(tmpdir(), 'release-native-measurement-')) const directory = await mkdtemp(join(tmpdir(), 'release-native-measurement-'))
try { try {
@@ -364,3 +415,119 @@ test('changed file after an unknown loading write cannot replace frozen original
assert.deepEqual(f.checkpoint.state.commands['loading-complete'].request, original) assert.deepEqual(f.checkpoint.state.commands['loading-complete'].request, original)
} finally { await rm(join(directory, 'actual.json'), { force: true }); await rmdir(directory) } } finally { await rm(join(directory, 'actual.json'), { force: true }); await rmdir(directory) }
}) })
test('synthetic loading is explicit GCP-only and rejects ambiguous actual inputs before HTTP', () => {
const base = { ...input, environment: { mode: 'gcp-test' }, syntheticOnly: true, syntheticLoading: true }
validateSyntheticLoadingInput(base)
for (const patch of [{ environment: { mode: 'test' } }, { environment: { mode: 'gray' } }, { environment: { mode: 'prod' } },
{ syntheticOnly: false }, { syntheticLoading: 'true' }, { loadingMeasurements: {} }, { loadingMeasurementFile: 'anything' },
{ syntheticLoadingMeasurements: { values: { palletSpaces: 1, weightKg: 1, volumeCbm: 1 } } }]) {
assert.throws(() => validateSyntheticLoadingInput({ ...base, ...patch }))
}
})
test('explicit GCP synthetic path never manufactures actual evidence or sends numeric measurements', async () => {
const f = fixture({ missingActual: true })
f.input = { ...input, environment: { mode: 'gcp-test' }, syntheticOnly: true, syntheticLoading: true }
const result = await runOverseasStage(f)
assert.equal(result.realMeasurementVerified, false)
assert.equal(result.loadingMeasurementMode, 'SYNTHETIC_TEST')
assert.deepEqual(loadingVerificationResult(result), { loadingMeasurementMode: 'SYNTHETIC_TEST', realMeasurementVerified: false, scope: 'ONE_ORDER_SYNTHETIC_HTTP_PATH' })
const proof = f.checkpoint.state.loadingMeasurementEvidence
assert.equal(proof.actual, false); assert.equal(proof.source, 'SYNTHETIC_TEST'); assert.equal(proof.measurements, null)
assert.equal(proof.dispatchNo, 'DISPATCH-TEST'); assert.equal(proof.tripNo, 'TRIP-TEST')
assert.equal(JSON.stringify(proof).includes('"actual":true'), false)
const load = f.checkpoint.state.commands['loading-complete'].request
assert.equal(Object.hasOwn(load.body, 'measurements'), false)
assert.match(load.body.remark, /SYNTHETIC_TEST:actual=false/)
const writes = f.calls.filter(row => row.method !== 'GET' && !row.path.endsWith('/container-arrival-times/query')).length
await runOverseasStage(f)
assert.equal(f.calls.filter(row => row.method !== 'GET' && !row.path.endsWith('/container-arrival-times/query')).length, writes)
const calls = f.calls.length
f.input = { ...f.input, syntheticLoading: false }
await assert.rejects(runOverseasStage(f), /冻结检查点/)
assert.equal(f.calls.length, calls)
})
test('synthetic unknown loading result blocks all continuation, including reads', async () => {
const f = fixture({ missingActual: true })
f.input = { ...input, environment: { mode: 'gcp-test' }, syntheticOnly: true, syntheticLoading: true }
const request = f.context.request
f.context.request = async (...args) => { const result = await request(...args); if (args[1].endsWith('/loading-complete')) throw Error('response lost'); return result }
await assert.rejects(runOverseasStage(f), /response lost/)
const calls = f.calls.length
assert.equal(f.checkpoint.state.commands['loading-complete'].status, 'INTENT')
await assert.rejects(runOverseasStage(f), /结果未知/)
assert.equal(f.calls.length, calls)
})
test('synthetic mode preserves authoritative preview pallet, dispatch, pick and stop boundaries', async () => {
for (const mutate of [p => { p.dispatchNo = 'FOREIGN' }, p => { p.pallets.push({ ...p.pallets[0] }) },
p => { p.pallets[0].warehousePalletId = '999' }, p => { p.pallets[0].pickedPackages = 0 },
p => { p.pallets[0].deliveryIndex = 99 }]) {
const f = fixture({ missingActual: true }), request = f.context.request
f.input = { ...input, environment: { mode: 'gcp-test' }, syntheticOnly: true, syntheticLoading: true }
f.context.request = async (...args) => { const result = await request(...args); if (args[0] === 'GET' && args[1].endsWith('/loading')) mutate(result.body.data); return result }
await assert.rejects(runOverseasStage(f), /预览|路线/)
assert.equal(f.checkpoint.state.commands['loading-complete'], undefined)
}
})
test('uncertain synthetic photo upload is not resent and incomplete slots cannot trigger auto departure', async () => {
const f = fixture(), upload = f.context.requestWithBody
f.context.requestWithBody = async (...args) => { const result = await upload(...args); if (args[1].includes('/loading-documents/')) throw Error('photo response lost'); return result }
await assert.rejects(runOverseasStage(f), /photo response lost/)
assert.equal(f.checkpoint.state.commands['loading-photo-LOADING_SCENE'].status, 'INTENT')
const calls = f.calls.length
await assert.rejects(runOverseasStage(f), /结果未知/)
assert.equal(f.calls.length, calls)
const other = fixture(), request = other.context.request
other.context.request = async (...args) => { const result = await request(...args); if (args[0] === 'GET' && args[1].endsWith('/loading-documents')) result.body.data.complete = false; return result }
await assert.rejects(runOverseasStage(other), /五槽装车资料未核齐/)
assert.equal(other.checkpoint.state.commands['loading-complete'], undefined)
})
test('GCP synthetic FBA appointment binds only this destination and preserves photos, maintenance and schedule', async () => {
const f = fixture({ missingAppointment: true })
f.input = { ...input, environment: { mode: 'gcp-test' }, syntheticOnly: true, syntheticLoading: true, destination: { type: 'FBA', code: 'DEST' } }
await runOverseasStage(f)
const resource = f.checkpoint.state.commands['resource-evidence'].request.body
const appointment = f.checkpoint.state.commands['destination-appointment'].request.body
assert.deepEqual(appointment.vehicle.evidenceFileIds, resource.vehicle.evidenceFileIds)
assert.deepEqual(appointment.driver.evidence, resource.driver.evidence)
assert.equal(appointment.plannedDepartureAt, resource.plannedDepartureAt)
assert.equal(appointment.estimatedArrivalAt, resource.estimatedArrivalAt)
assert.equal(appointment.maintenance.fleetEmail, 'synthetic@example.invalid')
assert.deepEqual(appointment.maintenance.destinationAppointments, [{ destinationKey: 'FBA:DEST', appointmentNo: `SYNTHETIC_TEST-${input.batch}-DEST` }])
assert.equal(f.checkpoint.state.destinationAppointmentEvidence.actual, false)
assert.match(appointment.remark, /不代表第三方确认/)
const submit = f.checkpoint.state.commands['submit-review'].request.body
assert.equal(submit.recordVersion, appointment.recordVersion + 1)
assert.equal(submit.idempotencyKey, `${input.batch}-submit-review-with-appointment`)
const writes = f.calls.filter(row => row.method !== 'GET' && !row.path.endsWith('/container-arrival-times/query')).length
await runOverseasStage(f)
assert.equal(f.calls.filter(row => row.method !== 'GET' && !row.path.endsWith('/container-arrival-times/query')).length, writes)
})
test('missing FBA appointment never gets fabricated outside explicit GCP mode or for a foreign destination', async () => {
for (const patch of [{}, { environment: { mode: 'gcp-test' }, syntheticOnly: true, syntheticLoading: true, destination: { type: 'FBA', code: 'OTHER' } }]) {
const f = fixture({ missingAppointment: true }); f.input = { ...input, ...patch }
await assert.rejects(runOverseasStage(f), /预约|FBA目的仓/)
assert.equal(f.checkpoint.state.commands['destination-appointment'], undefined)
assert.equal(f.checkpoint.state.commands['submit-review'], undefined)
}
})
test('uncertain appointment update preserves INTENT and cannot be resent or followed by submit review', async () => {
const f = fixture({ missingAppointment: true, loseCommand: 'appointment' })
f.input = { ...input, environment: { mode: 'gcp-test' }, syntheticOnly: true, syntheticLoading: true, destination: { type: 'FBA', code: 'DEST' } }
await assert.rejects(runOverseasStage(f), /appointment response lost/)
assert.equal(f.checkpoint.state.commands['destination-appointment'].status, 'INTENT')
assert.equal(f.checkpoint.state.commands['submit-review'], undefined)
const calls = f.calls.length
await assert.rejects(runOverseasStage(f), /结果未知/)
assert.equal(f.calls.length, calls)
})