This commit is contained in:
2026-09-30 17:13:11 +08:00
parent babc7de4b6
commit 32fb9ec7b3
26 changed files with 1484 additions and 70 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# 头程与海外仓后端项目指引
> 当前只有单一测试环境,所有服务同机部署。统一 Gateway/会话/Nacos 与去事件桥目标见 `../docs/测试环境单机部署与配置契约.md`、`../docs/统一认证与服务通信契约.md`;整体迁移未完成,下面现有服务入口用于定位,不能视为继续维持双区域部署的要求。
> 当前为单 TEST、统一 Gateway/Auth 会话/Nacos 与业务 Outbox 直接投递。架构规则及部署/验收状态唯一见 `../docs/测试环境单机部署与配置契约.md`、`../docs/统一认证与服务通信契约.md`。模块旧双区域说明不能覆盖这两份契约;构建、部署与整体验收分别核对。
根 POM 当前聚合 20 个模块(7 个公共/依赖模块、13 个服务),部署映射见 `deploy/service-map.yml`。`gateway-overseas/` 与 `event-bridge-service/` 为退役待清理源码,不是当前构建或部署单元。
@@ -27,7 +27,7 @@
- 单服务 Controller/Service/Mapper 任务:本文件、目标服务 `AGENTS.md`、目标类及其直接调用点。
- 跨服务 API/事件任务:在上述基础上增加直接调用方、被调用方和对应 Gateway/OpenAPI;不因业务关键词自动加载整个业务专题。
- 只有状态、权限、数据库或验收边界无法从上述入口确定时,才读取 `docs/业务快速索引.md`、专题 README 或完整契约。
- 后端指引累计超过约 250 行时优先用 `rg` 读取命中段;聚合构建或架构任务才全文读取 `README.md`、`pom.xml` 和目录结构文档。
- 读取预算遵循工作区根指引:超过约 250 行或 8000 字符时先读职责、硬边界和验证章节,再检索任务命中段;不得仅因行数少而全文加载长业务表。聚合构建或架构任务才读取相关 `README.md`、`pom.xml` 和目录结构文档,已知单模块任务不增加概览文档。
| 任务关键词 | 首选服务 |
|---|---|
+1 -1
View File
@@ -5,7 +5,7 @@
- Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/统一认证与服务通信契约.md`。
- 修改统一 Access/Refresh Token、Audience、JWKS、Redis 会话或权限撤销前,先读 `../../docs/统一认证与服务通信契约.md` 的认证章节;平台、客户及两类 PDA 保持各自访问域和仓绑定边界。
- `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。
## 模块入口
+2 -2
View File
@@ -25,8 +25,8 @@
## 修改与验证
- 普通 `BizException`、参数校验和兜底系统异常统一返回 HTTP 200 + 非 `"200"` body code;查询参数/消息解析失败使用 `A0400`,Bean Validation 使用 `A0422`,不要再按 HTTP 400/422 判断业务失败。
- 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。
- 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503 与 Audit 202 仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 修改自动配置时验证 `audit-service` 等当前构建内、只扫描自身包的直接消费者仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`;按根 POM 与实际依赖限定范围。
- 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。
- 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。
- 同步 Feign 出站固定先移除调用模板中的 `X-Request-Id`、`X-Trace-Id`、`traceparent`,再仅从当前 MDC 的合法请求 ID 和 trace ID 重建;`traceparent` 沿用当前 trace ID、每次调用生成新非零 span ID、flags 固定 `01`。不得从业务参数、远端返回头或无效 MDC 建立上下文;本能力不处理 Reactor、异步线程、重试、超时或 fallback。
+3 -1
View File
@@ -1,5 +1,7 @@
# Event Bridge Service 模块指引
> 本目录为退役源码,已退出根 POM 和部署映射。下文仅记录旧实现,不是现行规则。事件任务先按 `../../docs/统一认证与服务通信契约.md` 的逐事件表定位生产者/直接消费者与业务 Relay;不得恢复区域分派或重新加入构建。源码清理按 `../../docs/测试环境单机部署与配置契约.md` 核对引用及运行证据。
## 职责边界
- Mapper 只由 `EventBridgeApplication.java` 对 `com.baiyue.eventbridge` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
@@ -36,5 +38,5 @@
## 修改与验证
- 定向验证:`mvn -pl event-bridge-service -am test`。
- 本模块已退出聚合,不能从根使用 `mvn -pl event-bridge-service`;现行投递验证直接生产者/消费者及对应 Outbox/Inbox、ACK、重试和审计。
- 重试相关修改至少覆盖 `RECEIVED`、`RETRY`、成功、失败、租约、最大次数和人工补投。
+1 -1
View File
@@ -3,7 +3,7 @@
## 模块定位
- 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、客户资金账户及充值申请、应收款月度统计、客户对账确认、收款及分配、成本及分摊、账单修订、单据编号、幂等记录和财务审计状态。
- 产品范围先读 `../../docs/财务管理/README.md`、`../../docs/财务管理/财务领域模型与跨服务契约.md` 和 `Firstleg-Finance跨服务契约.md`;产品确认表、五页验收契约和证据矩阵只在状态核对或验收任务中读取。只有海外仓费用阶段 0A—0E 的设计或实现任务再读取阶段技术契约,并明确区分其目标 V2 协议与当前 V1 代码事实。
- 普通代码任务从本文件快速查询和目标 Controller/Service 进入;产品范围不明时查 `../../docs/财务管理/README.md`,领域归属变化时查 `../../docs/财务管理/财务领域模型与跨服务契约.md`,Firstleg 费用对接变化时查 `../../docs/财务管理/Firstleg-Finance跨服务契约.md` 对应章节。产品确认表、五页验收契约和证据矩阵仅按具体规则或验收问题读取,不默认同时加载。海外仓费用阶段 0A—0E 任务才读取阶段技术契约,目标 V2 与当前实现沿代码分别核对。
- 海外仓费用阶段 0 按需契约:`../../docs/财务管理/海外仓费用统一接入阶段0技术契约.md`;仅在该阶段的实现、接口或验收任务中读取,不作为普通 Finance 任务首读。
- Firstleg 拥有入库/柜次业务费用和履约事实,File 拥有凭证文件;Finance 通过内部接口读取或消费快照,不跨库查询,也不复制上游业务状态机。
+1 -1
View File
@@ -50,7 +50,7 @@
| 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
| 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
| 船节点交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 直投 Lastleg `/internal/lastleg/events`;消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java`,ACK/重试按 `../../docs/统一认证与服务通信契约.md` |
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 |
| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` |
+4 -2
View File
@@ -1,10 +1,12 @@
# Overseas Gateway 模块指引
> 本目录为退役源码,已退出根 POM 和部署映射。下文仅记录旧实现,不是现行规则。当前入口读 `../gateway/AGENTS.md`,认证读 `../auth/AGENTS.md` 与 `../../docs/统一认证与服务通信契约.md`;退役清理按 `../../docs/测试环境单机部署与配置契约.md` 核对实际引用与运行证据。不得恢复双 Token、专用安全投影或重新加入构建。
## 职责边界
- 负责海外 API 统一入口、路由、RS256/JWKS 验签、Audience 和必要 Claims 校验。
- 不保存签名私钥,不依赖国内 Redis 会话,不在网关实现尾程业务逻辑。
- 修改海外 Token、Audience、JWKS、区域路由或服务身份时,先读 `../../docs/跨区域认证与服务通信契约.md`。
- 历史路由与身份实现只用于追溯;现行认证按 `../../docs/统一认证与服务通信契约.md`。
## 快速索引
@@ -31,5 +33,5 @@
## 修改与验证
- 定向验证:`mvn -pl gateway-overseas -am test`。
- 本模块已退出聚合,不能从根使用 `mvn -pl gateway-overseas`;当前路由变更验证 `gateway` 及调用端,执行根 `scripts/gateway-route-contract.test.mjs`。
- 安全变更覆盖合法令牌、错误 Audience、缺失 Claims、过期/错误签名和身份头伪造测试。
+3 -2
View File
@@ -30,9 +30,10 @@
| 内部令牌保护 | `security/InternalServiceTokenGuard.java` |
| 统一会话校验 | 复用 `../common-security/` Redis会话与Auth权威状态校验;Lastleg不维护授权投影 |
| 入仓计划分页、柜级预览、转换与详情 | `inboundplan/controller/InboundPlanController.java` |
| 自动生成托盘、资源生成失败与重试 | `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;详细规则定向查 `docs/business-index.md` 的“人工计划自动生成托盘与资源重试” |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` |
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java` |
| PDA 入仓、柜号签收、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java` → `inboundplan/pda/service/ReceivingContainerService.java`;门禁与状态定向查 `docs/business-index.md` 的“PDA 入仓、实收、上架与完成” |
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` |
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` |
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java` |
@@ -139,6 +140,6 @@
- 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-lastleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。
- 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。
- 事件契约变化同时检查 `common-event-contract` 版本、Event Bridge 消费方和旧 Payload 哈希兼容。
- 事件契约变化同时检查 `common-event-contract`、直接生产者/消费者、Outbox/Inbox 和 DeliveryAck 的 eventId/payloadHash;按 `../../docs/统一认证与服务通信契约.md` 的逐事件表验证,不引入旧 Payload 兼容。
- 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。
- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。
@@ -27,6 +27,7 @@ import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -406,6 +407,9 @@ public class OrderApplicationService {
throw new BizException(ResultCode.VALIDATION_FAILED, "木制品必须选择商检");
}
}
validateMixedCartons(order.getFulfillmentType(), cargoes.stream().map(cargo -> new MixedCarton(
cargo.getPackingGroupNo(), cargo.getPackageCount(), cargo.getPackageType(), cargo.getLengthCm(),
cargo.getWidthCm(), cargo.getHeightCm(), cargo.getGrossWeightKg(), cargo.getNetWeightKg(), cargo.getVolumeCbm())).toList());
var parties = repository.findParties(tenantId, order.getId());
if ("PICKUP".equals(order.getPickupMethod()) && !hasParty(parties, "SENDER")) {
throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须填写发货信息");
@@ -887,13 +891,21 @@ public class OrderApplicationService {
return (int) total;
}
private void validateMixedCartons(OrderRequests.SaveRequest request) {
if (!"ONE_CLICK_DROPSHIP".equals(request.fulfillmentType())) return;
Map<String, OrderRequests.Cargo> firstByGroup = new LinkedHashMap<>();
for (OrderRequests.Cargo cargo : request.cargoes()) {
validateMixedCartons(request.fulfillmentType(), request.cargoes().stream().map(cargo -> new MixedCarton(
cargo.packingGroupNo(), cargo.packageCount(), cargo.packageType(), cargo.lengthCm(),
cargo.widthCm(), cargo.heightCm(), cargo.grossWeightKg(), cargo.netWeightKg(), cargo.volumeCbm())).toList());
}
private record MixedCarton(String packingGroupNo, Integer packageCount, String packageType,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm) { }
private void validateMixedCartons(String fulfillmentType, List<MixedCarton> cargoes) {
if (!"ONE_CLICK_DROPSHIP".equals(fulfillmentType)) return;
Map<String, MixedCarton> firstByGroup = new LinkedHashMap<>();
for (MixedCarton cargo : cargoes) {
String group = normalize(cargo.packingGroupNo());
if (group == null) continue;
OrderRequests.Cargo first = firstByGroup.putIfAbsent(group, cargo);
if (first != null && (first.packageCount() != cargo.packageCount()
MixedCarton first = firstByGroup.putIfAbsent(group, cargo);
if (first != null && (!Objects.equals(first.packageCount(), cargo.packageCount())
|| !Objects.equals(first.packageType(), cargo.packageType())
|| !sameDecimal(first.lengthCm(), cargo.lengthCm())
|| !sameDecimal(first.widthCm(), cargo.widthCm())
@@ -441,6 +441,85 @@ class OrderApplicationServiceStateTest {
verify(repository, never()).insertHistory(any());
}
@Test
void submittingPersistedMixedCartonRejectsEveryPhysicalMismatchWithoutWritingForBothTaxModes() {
List<java.util.function.Consumer<OrderCargoEntity>> conflicts = List.of(
cargo -> cargo.setLengthCm(new BigDecimal("31")),
cargo -> { cargo.setPackageCount(3); cargo.setTotalQuantity(cargo.getUnitsPerPackage() * 3); },
cargo -> cargo.setPackageType("PALLET"),
cargo -> cargo.setWidthCm(new BigDecimal("21")),
cargo -> cargo.setHeightCm(new BigDecimal("21")),
cargo -> cargo.setGrossWeightKg(new BigDecimal("21")),
cargo -> cargo.setNetWeightKg(new BigDecimal("19")),
cargo -> cargo.setVolumeCbm(new BigDecimal("0.025")));
for (String taxMode : List.of("BUY_DECLARATION", "TAX_REFUND")) {
for (var conflict : conflicts) {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS");
order.setFulfillmentType("ONE_CLICK_DROPSHIP"); order.setTaxMode(taxMode);
OrderCargoEntity third = mixedCargo("SKU-3", 10); conflict.accept(third);
when(repository.findOwned(20002L, 1L)).thenReturn(order);
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(
mixedCargo("SKU-1", 10), mixedCargo("SKU-2", 11), third));
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
.submit(20002L, 2L, "u", 1L, "mixed-carton-conflict"))
.isInstanceOf(BizException.class).hasMessage("同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致")
.extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
assertThat(order.getStatus()).isEqualTo(OrderStatus.DRAFT);
assertThat(order.getSubmittedAt()).isNull(); assertThat(order.getInboundCode()).isNull();
verify(repository, never()).saveOrder(anyLong(), any());
verify(repository, never()).insertHistory(any()); verify(repository, never()).insertOutbox(any());
verify(repository, never()).completeIdempotency(anyLong(), any(), anyLong());
}
}
}
@Test
void submittingConsistentMixedCartonAllowsDifferentSkuQuantitiesAndDecimalScalesAndCountsBoxesOnce() {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS");
order.setFulfillmentType("ONE_CLICK_DROPSHIP");
OrderCargoEntity third = mixedCargo("SKU-3", 12);
third.setPackingGroupNo(" GROUP-1 "); third.setLengthCm(new BigDecimal("30.00"));
third.setGrossWeightKg(new BigDecimal("20.0")); third.setVolumeCbm(new BigDecimal("0.0240"));
when(repository.findOwned(20002L, 1L)).thenReturn(order);
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(mixedCargo("SKU-1", 10), mixedCargo("SKU-2", 11), third));
when(repository.findParties(20002L, 1L)).thenReturn(List.of());
when(repository.saveOrder(20002L, order)).thenReturn(1);
service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
.transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null);
assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED);
verify(repository).saveOrder(20002L, order);
verify(repository).insertOutbox(argThat(event -> event.getPayloadJson().contains("\"totalPackageCount\":2")));
}
@Test
void submittedMixedCartonEditorRejectsThirdSkuLengthConflictBeforeReplacingCargo() {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = persistedOrder(OrderStatus.SUBMITTED);
when(repository.findOwned(10001L, 1L)).thenReturn(order);
var request = mixedCartonRequest(31);
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
.updateSubmittedOrder(10001L, 2L, 1L, request))
.isInstanceOf(BizException.class).hasMessage("同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致")
.extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
verify(repository, never()).saveOrder(anyLong(), any());
verify(repository, never()).replaceDraftChildren(anyLong(), anyLong());
verify(repository, never()).insertHistory(any()); verify(repository, never()).insertOutbox(any());
}
@Test
void mixedCartonDraftStillAllowsIncompletePhysicalGroupBeforeSubmission() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
var request = mixedCartonRequest(31);
when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
service(repository, mock(OrderEligibilityGuard.class), masterData)
.createDraft(10001L, 2L, request, "mixed-carton-draft");
verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT));
}
@Test
void submittingDraftPreparesInboundForWarehouseMeasurementBeforeReview() {
OrderRepository repository = mock(OrderRepository.class);
@@ -1013,6 +1092,39 @@ class OrderApplicationServiceStateTest {
return cargo;
}
private OrderCargoEntity mixedCargo(String skuNo, int unitsPerPackage) {
OrderCargoEntity cargo = completeCargo();
cargo.setSkuNo(skuNo); cargo.setPackingGroupNo("GROUP-1"); cargo.setPackageCount(2);
cargo.setUnitsPerPackage(unitsPerPackage); cargo.setTotalQuantity(unitsPerPackage * 2);
cargo.setLengthCm(new BigDecimal("30")); cargo.setWidthCm(new BigDecimal("20")); cargo.setHeightCm(new BigDecimal("20"));
cargo.setGrossWeightKg(new BigDecimal("20")); cargo.setNetWeightKg(new BigDecimal("18"));
cargo.setVolumeCbm(new BigDecimal("0.024"));
return cargo;
}
private OrderRequests.SaveRequest mixedCartonRequest(int thirdLength) {
OrderRequests.SaveRequest source = request();
var mapper = new ObjectMapper();
var cargoes = java.util.stream.IntStream.rangeClosed(1, 3).mapToObj(index -> {
var json = mapper.valueToTree(source.cargoes().get(0));
var cargo = (com.fasterxml.jackson.databind.node.ObjectNode) json;
cargo.put("skuNo", "SKU-" + index); cargo.put("packingGroupNo", "GROUP-1"); cargo.put("packageCount", 2);
cargo.put("unitsPerPackage", index == 2 ? 11 : 10); cargo.put("totalQuantity", index == 2 ? 22 : 20);
cargo.put("lengthCm", index == 3 ? thirdLength : 30); cargo.put("widthCm", 20); cargo.put("heightCm", 20);
cargo.put("grossWeightKg", 20); cargo.put("netWeightKg", 18); cargo.put("volumeCbm", new BigDecimal("0.024"));
return mapper.convertValue(cargo, OrderRequests.Cargo.class);
}).toList();
return new OrderRequests.SaveRequest(
source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(),
source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(),
source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), cargoes,
source.pickupMethod(), source.requestedPickupAt(), source.receivingWarehouseId(),
source.receivingWarehouseName(), source.channelCode(), source.channelName(), source.carrierId(),
source.carrierName(), source.taxMode(), source.pricingTaxMode(), source.containerMode(),
source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(),
source.referenceId(), source.inboundCode(), "ONE_CLICK_DROPSHIP");
}
private OrderEntity persistedOrder(OrderStatus status) {
OrderEntity order = new OrderEntity();
order.setId(1L);
+7 -1
View File
@@ -4,7 +4,13 @@
总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。
`phase5-root-fulfillment.mjs` 是显式 TEST 的 F40 两箱根/余量专项,通过总入口 `--modules phase5-root-fulfillment` 准备本轮资质客户;模块用正式下单、测量、国内 PDA 拆分装柜、船节点、海外 PDA 收货、拣货发车及文件签收链创建两柜真实来源。默认根先签,编程调用 `AcceptanceRun.context.f40SignatureOrder='CHILD_FIRST'` 切换子先签;分别核对第一片签收不得根完成、最后一片签收才根完成、真实签收时间、状态历史和重放零新增。会写已授权测试环境,保留新客户、单据、文件及报告,临时海外 PDA 在 finally 停用;失败保留样本,不自动删除或重复执行部分已完成链。配置仅来自显式测试环境文件,报告由总入口 `--output` 指定。完整验收还须向 `AcceptanceRun.context.f40ReadOnlyOutboxEvidence` 提供本轮客户/根/实际来源的只读完成 Outbox 计数;缺少该证据明确为 `HTTP_VERIFIED_OUTBOX_PENDING` 和阻断,不能报告全部通过。离线合同为 `phase5-root-fulfillment-contract.test.mjs`;不能代替真实环境验收。
`phase5-root-fulfillment.mjs` 是显式 TEST 的 F40 两箱根/余量专项,通过总入口 `--modules phase5-root-fulfillment` 准备本轮资质客户;模块用正式下单、测量、国内 PDA 拆分装柜、船节点、海外 PDA 收货、拣货发车及文件签收链创建两柜真实来源。默认根先签,编程调用 `AcceptanceRun.context.f40SignatureOrder='CHILD_FIRST'` 切换子先签;分别核对第一片签收不得根完成、最后一片签收才根完成、真实签收时间、状态历史和重放零新增。
`AcceptanceRun.context.f40SignatureOrder='ROOT_SIGNED_REMAINDER_CANCELLED'` 单独验证合法末票余量取消:本轮新根通过 `orderStopAtApproved=true` 停在原始 `APPROVED`,只装运签收首箱;回查真实履约树及未装子单原始 `APPROVED`、入仓 `WAIT_SPLIT` 和一箱余量后,调用公开取消接口。`PROCESSING` 子单不得作为取消样本。根完成事件必须唯一发布,取消子单完成事件显式为零;原取消请求重放须 `A0409`,且根子版本、历史、摘要、履约树及完成 Outbox 零漂移。两种双签模式仍须两片实际签收,不能用取消替代第二签。
会写已授权测试环境,保留新客户、单据、文件及报告,临时海外 PDA 在 finally 停用;失败保留样本,不自动删除或重复执行部分已完成链。签收资料使用新 key 保存同文件时,按正式保存命令递增一次资料版本、新增一条版本历史及 `SAVE_DOCUMENTS` 审计;签收时间、状态、原完成记录、文件绑定和根子完成事件不得变化。同一新命令再用原 key、原请求重放,必须明确 `replayed=true` 且完整资料、派送事实、版本和审计历史零漂移。资料保存前后及原 key 重放响应保存在 `phase5RootFulfillment.receiptReplayProofs`,失败也保留已取得的快照。
配置仅来自显式测试环境文件,报告由总入口 `--output` 指定。完整验收还须向 `AcceptanceRun.context.f40ReadOnlyOutboxEvidence` 提供本轮客户/根/实际来源的只读完成 Outbox 计数(取消模式必须返回零事件子行);缺少该证据明确为 `HTTP_VERIFIED_OUTBOX_PENDING` 和阻断,不能报告全部通过。海外收货同时需要 `AcceptanceRun.context.phase4ReceivingEvidenceReader` 捕获首次完成和重放前后的完整库存证据,缺失明确阻断;四阶段原始库存快照通过 `phase5RootFulfillment.receivingCompletionEvidence` 原样保存,部分捕获与失败也保留,不用终态补造历史。离线合同为 `phase5-root-fulfillment-contract.test.mjs`;不能代替真实环境验收。
单票串行验收可显式传 `--modules phase4-overseas-return-followups --return-main-chain-only true`:仍由总入口准备本轮客户并完成退货签收、合箱、换标和出库;不执行第二客户、跨仓和并发扩展,报告 `excludedScope` 记录排除范围,不代表完整退货边界回归。目标由显式网关与账号参数指定;仅在已授权环境执行,保留业务单据及 `--output` 报告,失败先核对报告再重跑,不自动清理。
@@ -0,0 +1,37 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { sharedPalletFreezeEvidence } from './inbound-measurement.mjs'
function fixture() {
const sources = ['A', 'B'].map(inboundId => ({ inboundId, boxCodes: [`${inboundId}#1-1`],
skuLinesByBox: { [`${inboundId}#1-1`]: [{ inboundDetailId: `${inboundId}1`, skuCode: 'SHARED', quantity: 2 }] } }))
const pallet = { palletId: 'P1', palletNo: 'DP-260930-0001', status: 'SEALED',
sealedAt: '2026-09-30T16:00:00', version: 3, cartons: sources.map(source => ({
boxCode: source.boxCodes[0], inboundId: source.inboundId, palletId: 'P1', palletNo: 'DP-260930-0001',
skuLines: [{ inboundDetailId: `${source.inboundId}1`, skuCode: 'SHARED', expectedQuantity: 2, actualQuantity: 2 }],
})) }
return { sources, tasks: [0, 1].map(() => ({ taskStatus: 'COMPLETED', pallets: [structuredClone(pallet)] })) }
}
test('shared pallet compares both complete task projections to independently frozen per-box SKU sources', () => {
const { tasks, sources } = fixture()
assert.equal(sharedPalletFreezeEvidence(tasks, sources).passed, true)
tasks[1].pallets[0].cartons.reverse()
assert.equal(sharedPalletFreezeEvidence(tasks, sources).passed, true)
})
for (const [name, mutate] of [
['wrong owner despite matching aggregate count', task => { task.pallets[0].cartons[1].inboundId = 'A' }],
['duplicate box despite matching aggregate count', task => { task.pallets[0].cartons[1] = structuredClone(task.pallets[0].cartons[0]) }],
['SKU quantity drift', task => { task.pallets[0].cartons[0].skuLines[0].actualQuantity = 1 }],
['wrong SKU detail ownership', task => { task.pallets[0].cartons[0].skuLines[0].inboundDetailId = 'B1' }],
['box bound to another pallet', task => { task.pallets[0].cartons[0].palletId = 'P2' }],
['missing seal fact', task => { delete task.pallets[0].sealedAt }],
['incomplete carton snapshot', task => { task.pallets[0].cartons.pop() }],
]) {
test(`shared pallet rejects ${name}`, () => {
const { tasks, sources } = fixture()
mutate(tasks[1])
assert.equal(sharedPalletFreezeEvidence(tasks, sources).passed, false)
})
}
@@ -34,6 +34,116 @@ export function measurementReplayDigest(facts) {
return createHash('sha256').update(JSON.stringify(facts)).digest('hex')
}
// Compare both task projections to independently frozen source identities, not
// to the first carton returned by a shared pallet.
export function sharedPalletFreezeEvidence(tasks, sources) {
const errors = []
const expected = new Map()
for (const source of sources) {
for (const boxCode of source.boxCodes || []) {
if (expected.has(boxCode)) errors.push(`duplicate source box ${boxCode}`)
expected.set(boxCode, source)
}
}
if (sources.length < 2 || new Set(sources.map(source => asId(source.inboundId))).size !== sources.length) {
errors.push('requires distinct source inbounds')
}
const snapshots = tasks.map(task => {
if (task?.taskStatus !== 'COMPLETED' || task.pallets?.length !== 1) errors.push('task not completed on one pallet')
const pallet = task?.pallets?.[0]
if (!pallet?.palletId || !pallet.palletNo || pallet.status !== 'SEALED'
|| !pallet.sealedAt || !Number.isInteger(Number(pallet.version))) errors.push('missing sealed pallet identity')
const seen = new Set()
const cartons = (pallet?.cartons || []).map(carton => {
const source = expected.get(carton.boxCode)
if (seen.has(carton.boxCode)) errors.push(`duplicate carton ${carton.boxCode}`)
seen.add(carton.boxCode)
if (!source || asId(carton.inboundId) !== asId(source.inboundId)) errors.push(`wrong owner ${carton.boxCode}`)
if (asId(carton.palletId) !== asId(pallet?.palletId) || carton.palletNo !== pallet?.palletNo) {
errors.push(`wrong pallet ${carton.boxCode}`)
}
const skuLines = (carton.skuLines || []).map(line => ({
inboundDetailId: asId(line.inboundDetailId), skuCode: line.skuCode,
expectedQuantity: Number(line.expectedQuantity), actualQuantity: Number(line.actualQuantity),
})).sort((left, right) => left.inboundDetailId.localeCompare(right.inboundDetailId))
const expectedLines = (source?.skuLinesByBox?.[carton.boxCode] || []).map(line => ({
inboundDetailId: asId(line.inboundDetailId), skuCode: line.skuCode,
expectedQuantity: Number(line.quantity), actualQuantity: Number(line.quantity),
})).sort((left, right) => left.inboundDetailId.localeCompare(right.inboundDetailId))
if (!expectedLines.length || JSON.stringify(skuLines) !== JSON.stringify(expectedLines)) {
errors.push(`sku source mismatch ${carton.boxCode}`)
}
return { boxCode: carton.boxCode, inboundId: asId(carton.inboundId), skuLines }
}).sort((left, right) => left.boxCode.localeCompare(right.boxCode))
if (seen.size !== expected.size || [...expected.keys()].some(box => !seen.has(box))) errors.push('carton set incomplete')
return { palletId: asId(pallet?.palletId), palletNo: pallet?.palletNo, status: pallet?.status,
sealedAt: pallet?.sealedAt, version: Number(pallet?.version), cartons }
})
if (tasks.length !== sources.length || snapshots.some(snapshot => JSON.stringify(snapshot) !== JSON.stringify(snapshots[0]))) {
errors.push('shared task freeze projections differ')
}
return { passed: errors.length === 0, errors, snapshot: snapshots[0] || null,
digest: measurementReplayDigest(snapshots[0] || null), sources }
}
export async function verifySharedPalletFreeze(context, sources) {
const tasks = []
for (const source of sources) {
const response = await context.request('GET',
`/api/platform/v1/first-leg/pda-dropship/tasks/${source.taskId}?warehouseId=${source.warehouseId}`)
tasks.push(context.apiSuccess(MODULE, '共托封托后逐票读取完整箱和SKU快照', response,
`inboundId=${source.inboundId}, taskId=${source.taskId}`, OPTIONS))
}
const evidence = sharedPalletFreezeEvidence(tasks, sources)
context.assert(MODULE, '两票共托逐箱唯一归属及全部SKU冻结组成一致', null,
evidence.passed, JSON.stringify({ digest: evidence.digest, errors: evidence.errors,
palletId: evidence.snapshot?.palletId, cartonCount: evidence.snapshot?.cartons.length }), OPTIONS)
context.createdResources.sharedPalletFreeze = { marker: context.marker, ...evidence }
return evidence
}
export async function prepareSharedPalletFreeze(context) {
const { run: createOrder } = await import('./orders.mjs')
const originalMarker = context.marker
const originalContext = { ...context.context }
const originalManualCount = context.options.prepareDropshipManualCount
const sources = []
try {
for (const label of ['A', 'B']) {
context.marker = `${originalMarker}-PALLET-${label}`
for (const key of ['orders', 'inboundMeasurement', 'dropshipManualCount']) delete context.createdResources[key]
for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key]
context.context.orderDestinationType = 'OVERSEAS'
context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP'
context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' }
context.context.waitSplitVariant = { skuCount: 1, packageCountPerSku: 2, packageCounts: [2],
totalPackageCount: 2, palletCount: 1, skuCodes: [`SKU-COMMON-${originalMarker}`.slice(0, 64)] }
context.options.prepareDropshipManualCount = true
const start = context.steps.length
await createOrder(context)
await run(context)
const source = context.createdResources.dropshipManualCount
if (!source?.taskId || context.steps.slice(start).some(step => !step.passed)) throw new Error('shared pallet preparation failed')
const task = await completeDropshipPalletization(context, { ...source,
measurement: { actualPackageCount: source.totalCartons, expectedPalletCount: 1 },
...(label === 'A' ? { deferSeal: true } : { targetPalletNo: sources[0].palletNo }),
})
if (!task || context.steps.slice(start).some(step => !step.passed)) throw new Error('shared pallet count or seal failed')
sources.push({ taskId: asId(source.taskId), inboundId: asId(source.inboundId),
inboundCode: source.inboundCode, warehouseId: asId(source.warehouseId),
palletNo: task.pallets?.[0]?.palletNo, boxCodes: task.sealedCartonMarks,
skuLinesByBox: task.sealedSkuSources })
context.createdResources.sharedPalletFreezePreparation = { marker: originalMarker, sources }
}
context.marker = originalMarker
return await verifySharedPalletFreeze(context, sources)
} finally {
context.marker = originalMarker
context.context = originalContext
context.options.prepareDropshipManualCount = originalManualCount
}
}
async function readMeasurementReplayFacts(context, { inboundId, tenantId, measurementId }, name) {
const response = await context.request('GET',
`/api/platform/v1/first-leg/inbounds/${inboundId}/measurement?${query(tenantId)}`)
@@ -236,6 +346,7 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
let countedCartons = 0
const sealedCartonMarks = []
const sealedSkuSources = {}
for (let index = 0; index < totalCartons; index += 1) {
const boxCode = `${inboundCode}#${totalCartons}-${index + 1}`
@@ -244,6 +355,9 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
const resolved = context.apiSuccess(MODULE, `解析第${index + 1}箱的冻结箱唛`, resolveResponse,
`boxCode=${boxCode}`, OPTIONS)
const resolvedSkuLines = Array.isArray(resolved?.skuLines) ? resolved.skuLines : []
sealedSkuSources[boxCode] = resolvedSkuLines.map(line => ({
inboundDetailId: asId(line.inboundDetailId), skuCode: line.skuCode, quantity: Number(line.expectedQuantity),
}))
context.assert(MODULE, `第${index + 1}箱允许清点且每箱SKU明细完整`, resolveResponse,
resolved?.canCount === true && resolvedSkuLines.length > 0
&& resolvedSkuLines.every((line) => asId(line?.inboundDetailId) && line?.skuCode
@@ -361,7 +475,7 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
if (deferSeal) {
context.assert(MODULE, '多票合托首票归托后保持开放等待下一票', null,
sealed?.taskStatus !== 'COMPLETED' && sealed?.pallets?.every((item) => item.status === 'OPEN'), '', OPTIONS)
return { ...sealed, sealedCartonMarks, sealedSourcePalletNos: [] }
return { ...sealed, sealedCartonMarks, sealedSkuSources, sealedSourcePalletNos: [] }
}
let lastSealResponse = countedTaskResponse
for (let index = 0; index < (sealed?.pallets || []).length; index += 1) {
@@ -383,7 +497,7 @@ export async function completeDropshipPalletization(context, { inboundId, inboun
&& Number(sealed?.unboundCartons) === 0
&& sealed?.pallets?.every((item) => item?.status === 'SEALED'),
`taskStatus=${sealed?.taskStatus ?? 'missing'}, counted=${sealed?.countedCartons ?? 'missing'}, bound=${sealed?.boundCartons ?? 'missing'}`, OPTIONS)
return { ...sealed, sealedCartonMarks,
return { ...sealed, sealedCartonMarks, sealedSkuSources,
sealedSourcePalletNos: (sealed?.pallets || []).map((item) => item.palletNo).filter(Boolean) }
}
@@ -0,0 +1,34 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { cargoRequirementNegativeCases } from './orders.mjs'
const payload = { destinationType: 'FBA', fulfillmentType: 'STANDARD', customsInspection: true,
shipmentId: 'SHIP-1', cargoes: [{ skuNo: 'SKU-1', packageCount: 2, unitsPerPackage: 3,
totalQuantity: 6, referenceId: 'TRACK-1', woodenProduct: false, lengthCm: 10, packingGroupNo: 'GROUP-1' }] }
test('FBA negatives independently remove order identifiers and per-line identifiers without mutating source', () => {
const before = structuredClone(payload)
const cases = cargoRequirementNegativeCases(payload)
assert.equal(cases.length, 5)
assert.equal(cases.find(item => item.name.includes('订单货件')).body.shipmentId, null)
assert.equal(cases.find(item => item.name.includes('逐行跟踪')).body.cargoes[0].referenceId, null)
assert.deepEqual(payload, before)
})
test('overseas and private standard paths retain destination type and common declaration constraints', () => {
for (const destinationType of ['OVERSEAS', 'PRIVATE']) {
const cases = cargoRequirementNegativeCases({ ...payload, destinationType })
assert.equal(cases.length, 3)
assert.ok(cases.every(item => item.body.destinationType === destinationType))
assert.ok(cases.every(item => !item.name.startsWith('FBA')))
}
})
test('dropship checks required SKU and inconsistent physical carton grouping', () => {
const cases = cargoRequirementNegativeCases({ ...payload, destinationType: 'OVERSEAS', fulfillmentType: 'ONE_CLICK_DROPSHIP' })
assert.equal(cases.length, 5)
assert.equal(cases.find(item => item.name.includes('缺少SKU')).body.cargoes[0].skuNo, null)
const mixed = cases.find(item => item.name.includes('混箱')).body.cargoes
assert.equal(mixed[0].packingGroupNo, mixed[1].packingGroupNo)
assert.notEqual(mixed[0].lengthCm, mixed[1].lengthCm)
})
@@ -0,0 +1,65 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { finishOrderLifecycle } from './orders.mjs'
const input = { tenantId: '2105193679178272769', orderId: '2105193704449441794', firstLegDetailPath: '/original-order-detail' }
function fixture(option, status, transitions) {
const calls = []; const checks = []
const resources = { orders: { tenantId: input.tenantId, orderId: input.orderId,
inboundId: '2105193705748041729', inboundCode: 'TEST-APPROVED', skuLines: [{ skuNo: 'SKU-A' }] },
ocrInbound: { tenantId: input.tenantId, inboundId: '2105193705748041729' } }
const context = { context: { orderStopAtApproved: option }, createdResources: resources,
async request(method, path) { calls.push({ method, path });
return { http: 200, body: { code: '200', data: method === 'GET'
? { order: { status }, statusHistory: transitions.map(toStatus => ({ toStatus })) } : {} } } },
apiSuccess(_module, _step, response) { assert.equal(response.body.code, '200'); return response.body.data },
assert(_module, step, _response, valid) { checks.push({ step, valid: Boolean(valid) }) },
}
return { context, calls, checks, resources }
}
test('explicit approved stop checks raw Order and never sends accept or start; measurement resources survive', async () => {
const f = fixture(true, 'APPROVED', ['DRAFT', 'PENDING_REVIEW', 'APPROVED'])
const before = JSON.stringify(f.resources)
await finishOrderLifecycle(f.context, input)
assert.deepEqual(f.calls, [{ method: 'GET', path: input.firstLegDetailPath }])
assert.ok(f.checks.every(check => check.valid))
assert.equal(f.resources.orders.rawOrderStatus, 'APPROVED')
assert.equal(f.resources.orders.stoppedAtApproved, true)
delete f.resources.orders.rawOrderStatus; delete f.resources.orders.stoppedAtApproved
assert.equal(JSON.stringify(f.resources), before)
})
for (const option of [undefined, false, 'true']) {
test(`only boolean true opts out of default accept/start: ${String(option)}`, async () => {
const f = fixture(option, 'PROCESSING', ['APPROVED', 'ACCEPTED', 'PROCESSING'])
await finishOrderLifecycle(f.context, input)
assert.equal(f.calls.length, 3)
assert.ok(f.calls[0].path.includes('/accept?'))
assert.ok(f.calls[1].path.includes('/start?'))
assert.deepEqual(f.calls.map(call => call.method), ['POST', 'POST', 'GET'])
assert.ok(f.checks.every(check => check.valid))
assert.equal(f.resources.orders.rawOrderStatus, 'PROCESSING')
})
}
for (const [status, history] of [['PROCESSING', ['APPROVED', 'ACCEPTED', 'PROCESSING']],
['APPROVED', ['APPROVED', 'ACCEPTED']], ['APPROVED', ['PENDING_REVIEW']]]) {
test(`approved stop rejects invalid raw status/history: ${status}/${history.join(',')}`, async () => {
const f = fixture(true, status, history)
await assert.rejects(finishOrderLifecycle(f.context, input), /原始APPROVED/)
assert.equal(f.calls.length, 1)
assert.ok(f.checks.some(check => !check.valid))
})
}
test('approved stop runs after full approval replay, inbound readiness and shared measurement handoff', () => {
const source = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8')
const run = source.slice(source.indexOf('export async function run('))
const finish = run.indexOf('await finishOrderLifecycle(')
for (const fragment of ['await verifyOrderApprovalReplay(', '隔离入库单 marker 来源与可测量状态正确',
'context.createdResources.orders.inboundId = inboundId', 'context.context.createdInbound =', '履约任务与本轮订单和客户一致']) {
assert.ok(run.indexOf(fragment) >= 0 && run.indexOf(fragment) < finish, fragment)
}
})
@@ -18,6 +18,17 @@ test('orders fixture verifies the complete pricing tuple before selecting an FBA
assert.match(source, /warehouse\?\.status === 'ACTIVE'/)
})
test('private addresses select an authoritative channel without requiring a warehouse destination rate row', async () => {
const result = await findPricedOrderRouteAcrossOrigins({
receivings: [{ id: 'origin' }], destinations: [], destinationType: 'PRIVATE',
loadChannels: async () => ({ http: 200, body: { code: '200', data: [{ channelCode: 'C', pricingTaxModes: ['TAX_INCLUDED'] }] } }),
estimate: async () => { throw new Error('private address is not a warehouse destination') },
})
assert.equal(result.priced.channel.channelCode, 'C')
assert.equal(result.priced.destination, null)
assert.equal(result.attempts, 0)
})
test('FBA order fixture excludes warehouses without the internal code required by pallet labels', () => {
const warehouses = [
{ id: 'abe3', warehouseCode: 'ABE3', status: 'ACTIVE', internalCode: null },
+99 -17
View File
@@ -42,6 +42,61 @@ export function fixtureInboundCode(policy, marker) {
// Short enough for the 34-character carton QR limit; stable within this fresh fixture.
return { inboundCode: `AT${createHash('sha256').update(marker).digest('hex').slice(0, 20).toUpperCase()}` }
}
export function cargoRequirementNegativeCases(payload) {
const cases = [
{ name: '木制品未选择商检', at: 'CREATE', mutate: value => { value.customsInspection = false; value.cargoes[0].woodenProduct = true } },
{ name: '货物零箱数', at: 'CREATE', mutate: value => { value.cargoes[0].packageCount = 0 } },
{ name: '货物总件数与每箱件数不守恒', at: 'CREATE', mutate: value => { value.cargoes[0].totalQuantity++ } },
]
if (payload.destinationType === 'FBA') {
cases.push({ name: 'FBA缺少订单货件编号', at: 'SUBMIT', mutate: value => { value.shipmentId = null } },
{ name: 'FBA缺少逐行跟踪编号', at: 'SUBMIT', mutate: value => { value.cargoes[0].referenceId = null } })
}
if (payload.fulfillmentType === 'ONE_CLICK_DROPSHIP') {
cases.push({ name: '一件代发缺少SKU', at: 'CREATE_OR_SUBMIT', mutate: value => { value.cargoes[0].skuNo = null } },
{ name: '一件代发混箱组箱规冲突', at: 'SUBMIT', mutate: value => {
const extra = structuredClone(value.cargoes[0]); extra.skuNo = `${extra.skuNo}-B`.slice(0, 64); extra.lengthCm++
value.cargoes.push(extra)
} })
}
return cases.map(({ mutate, ...item }) => { const body = structuredClone(payload); mutate(body); return { ...item, body } })
}
async function verifyCargoRequirementNegatives(context, payload, policy, merchantToken) {
const base = '/api/merchant/v1/orders/first-leg'
const cases = cargoRequirementNegativeCases(payload)
const evidence = []
for (const [index, item] of cases.entries()) {
const body = { ...item.body, ...fixtureInboundCode(policy, `${context.marker}-NEG-${index}`) }
const created = await request(context, 'POST', base, body, merchantToken,
{ 'Idempotency-Key': key(context, `cargo-negative-${index}-create`) })
if (item.at === 'CREATE' || String(created?.body?.code) !== '200') {
context.assert(MODULE, `${payload.destinationType}/${payload.fulfillmentType} ${item.name}创建拒绝`, created,
created.http === 200 && String(created.body?.code) === 'A0422',
`HTTP=${created.http}, code=${created.body?.code || 'missing'}`, CONTRACT_OPTIONS)
evidence.push({ name: item.name, stage: 'CREATE', code: created.body?.code,
orderId: String(created.body?.code) === '200' ? id(created.body?.data) : null })
continue
}
const orderId = id(created.body?.data)
if (!orderId) throw new Error('negative draft missing order id')
const before = await request(context, 'GET', `${base}/${orderId}`, undefined, merchantToken)
const submitted = await request(context, 'POST', `${base}/${orderId}/submit`, undefined, merchantToken,
{ 'Idempotency-Key': key(context, `cargo-negative-${index}-submit`) })
const after = await request(context, 'GET', `${base}/${orderId}`, undefined, merchantToken)
context.assert(MODULE, `${payload.destinationType}/${payload.fulfillmentType} ${item.name}提交拒绝且草稿零漂移`, submitted,
submitted.http === 200 && String(submitted.body?.code) === 'A0422'
&& String(before.body?.code) === '200' && String(after.body?.code) === '200'
&& after.body.data?.order?.status === 'DRAFT'
&& JSON.stringify(before.body.data) === JSON.stringify(after.body.data),
`orderId=${orderId}, code=${submitted.body?.code || 'missing'}`, CONTRACT_OPTIONS)
evidence.push({ name: item.name, stage: 'SUBMIT', code: submitted.body?.code, orderId })
}
context.createdResources.orderCargoNegatives ??= []
context.createdResources.orderCargoNegatives.push({ marker: context.marker,
destinationType: payload.destinationType, fulfillmentType: payload.fulfillmentType, cases: evidence })
}
function declarationCargo(source, index) {
const number = (value) => Number.isFinite(Number(value)) ? Number(value) : 0
const optionalNumber = (value) => value === null || value === undefined || value === '' ? null : number(value)
@@ -152,6 +207,33 @@ export async function verifyOrderApprovalReplay(context, { tenantId, orderId, in
context.createdResources.orders.approvalReplay = evidence
return evidence
}
export async function finishOrderLifecycle(context, { tenantId, orderId, firstLegDetailPath }) {
const stopAtApproved = context.context?.orderStopAtApproved === true
if (!stopAtApproved) {
const acceptResponse = await context.request('POST',
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/accept?tenantId=${encodeURIComponent(tenantId)}`)
context.apiSuccess(MODULE, 'Admin 受理本轮订单', acceptResponse)
const startResponse = await context.request('POST',
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/start?tenantId=${encodeURIComponent(tenantId)}`)
context.apiSuccess(MODULE, 'Admin 启动本轮订单履约', startResponse)
}
const finalDetailResponse = await context.request('GET', firstLegDetailPath)
const finalDetail = context.apiSuccess(MODULE, 'Admin 回查本轮订单履约状态', finalDetailResponse)
const history = finalDetail?.statusHistory
const valid = finalDetail?.order?.status === (stopAtApproved ? 'APPROVED' : 'PROCESSING')
&& history?.some?.((item) => item.toStatus === 'APPROVED')
&& (stopAtApproved
? !history.some((item) => ['ACCEPTED', 'PROCESSING'].includes(item.toStatus))
: history.some((item) => item.toStatus === 'ACCEPTED')
&& history.some((item) => item.toStatus === 'PROCESSING'))
context.assert(MODULE, stopAtApproved ? '显式审批停点保留原始APPROVED且未受理启动' : '本轮订单已进入处理中且保留状态历史',
finalDetailResponse, valid,
`status=${finalDetail?.order?.status ?? 'missing'}, history=${history?.length ?? 'n/a'}`, CONTRACT_OPTIONS)
if (stopAtApproved && !valid) throw new Error('显式审批停点未保留原始APPROVED,不能继续准备该专项')
context.createdResources.orders.rawOrderStatus = finalDetail?.order?.status || null
context.createdResources.orders.stoppedAtApproved = stopAtApproved
}
function tomorrow(days = 30) {
const date = new Date(); date.setUTCDate(date.getUTCDate() + days)
return date.toISOString().slice(0, 10)
@@ -239,6 +321,14 @@ export async function findPricedOrderRouteAcrossOrigins({ receivings, destinatio
traceId: channelResponse?.traceId ?? 'missing', count: channels.length })
continue
}
if (destinationType === 'PRIVATE') {
// Private destinations use customer-owned addresses, not warehouse-rate
// destination rows. Match the same authoritative channel path already
// used by fixed-origin private orders.
const channel = priceableChannels[0]
return { receiving, priced: { channel, pricingTaxMode: channel.pricingTaxModes[0], destination: null },
response: channelResponse, attempts: 0, failures: [], originFailures }
}
const result = await findPricedOrderRoute({ receiving, destinations, channels: priceableChannels,
estimate, concurrency, destinationType })
attempts += result.attempts
@@ -439,7 +529,9 @@ async function loadMasterData(context, merchantToken) {
}
const priced = pricingResult.priced
const receiving = pricingResult.receiving
context.addStep(MODULE, 'Client 从 ACTIVE 国内仓选择具有真实价格覆盖的渠道和目的仓', pricingResult.response, Boolean(priced),
context.addStep(MODULE, destinationType === 'PRIVATE'
? 'Client 私人地址订单选择有效国内仓渠道与报价税制'
: 'Client 从 ACTIVE 国内仓选择具有真实价格覆盖的渠道和目的仓', pricingResult.response, Boolean(priced),
priced
? `origin=${receiving.id}, priceBookVersionId=${priced.channel.priceBookVersionId}, channel=${priced.channel.channelCode}, pricingTaxMode=${priced.pricingTaxMode}, destination=${priced.destination?.warehouseCode || 'PRIVATE_ADDRESS'}, attempts=${pricingResult.attempts}`
: originPricingFailureEvidence(pricingResult),
@@ -559,7 +651,8 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr
shipmentId: master.destinationType === 'FBA' ? shipmentId : null,
referenceId: master.destinationType === 'FBA' ? referenceId : null,
upcNo: `${Date.now()}${index}`.slice(-12),
productImageFileId, dangerousGoods: false, woodenProduct: false,
productImageFileId, dangerousGoods: orderProfile.dangerousGoods === true,
woodenProduct: orderProfile.woodenProduct === true,
cargoRemark: `${context.marker} boxType=${box.boxType}`,
}
}),
@@ -673,6 +766,9 @@ export async function run(context) {
const policy = context.apiSuccess(MODULE, 'Client 读取入仓码录入策略', policyResponse, '', CONTRACT_OPTIONS)
const payload = { ...buildPayload(context, profile, master, productImageFileId, customerAddresses),
...fixtureInboundCode(policy, context.marker) }
if (context.context.verifyOrderCargoNegatives === true) {
await verifyCargoRequirementNegatives(context, payload, policy, merchantToken)
}
const createResponse = await request(context, 'POST', createPath, payload, merchantToken,
{ 'Idempotency-Key': key(context, 'client-create') })
const orderId = id(context.apiSuccess(MODULE, 'Client 创建四箱型隔离订单', createResponse,
@@ -910,19 +1006,5 @@ export async function run(context) {
`taskId=${task.id}, total=${history?.total ?? 'n/a'}`)
}
const acceptResponse = await context.request('POST',
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/accept?tenantId=${encodeURIComponent(tenantId)}`)
context.apiSuccess(MODULE, 'Admin 受理本轮订单', acceptResponse)
const startResponse = await context.request('POST',
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/start?tenantId=${encodeURIComponent(tenantId)}`)
context.apiSuccess(MODULE, 'Admin 启动本轮订单履约', startResponse)
const finalDetailResponse = await context.request('GET', firstLegDetailPath)
const finalDetail = context.apiSuccess(MODULE, 'Admin 回查本轮订单履约状态', finalDetailResponse)
context.assert(MODULE, '本轮订单已进入处理中且保留状态历史', finalDetailResponse,
finalDetail?.order?.status === 'PROCESSING'
&& finalDetail?.statusHistory?.some?.((item) => item.toStatus === 'APPROVED')
&& finalDetail?.statusHistory?.some?.((item) => item.toStatus === 'ACCEPTED')
&& finalDetail?.statusHistory?.some?.((item) => item.toStatus === 'PROCESSING'),
`status=${finalDetail?.order?.status ?? 'missing'}, history=${finalDetail?.statusHistory?.length ?? 'n/a'}`)
await finishOrderLifecycle(context, { tenantId, orderId, firstLegDetailPath })
}
@@ -0,0 +1,83 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { customsRetentionDigest, verifyTaxCustomsAfterShipEdit } from './phase3-port-schedules.mjs'
test('customs retention digest includes documents, receipt versions, confirmations and completion facts', () => {
const before = { status: 'COMPLETED', documents: [{ fileId: '1', tenantId: 'A', version: 2 }],
receipts: [{ fileId: '2', version: 3 }], confirmations: [{ tenantId: 'A', status: 'CONFIRMED' }],
completionFiles: [{ fileId: '3' }] }
assert.equal(customsRetentionDigest(before), customsRetentionDigest({ ...before }))
for (const mutate of [
value => { value.documents[0].tenantId = 'B' },
value => { value.receipts[0].version++ },
value => { value.confirmations[0].status = 'RETURNED' },
value => { value.completionFiles = [] },
]) {
const after = structuredClone(before)
mutate(after)
assert.notEqual(customsRetentionDigest(before), customsRetentionDigest(after))
}
})
test('object key ordering does not obscure stable customs facts', () => {
assert.equal(customsRetentionDigest({ a: 1, b: { x: 2, y: 3 } }),
customsRetentionDigest({ b: { y: 3, x: 2 }, a: 1 }))
})
test('ship edit keeps original completed customs, query and node facts and sends only formal DTO fields', async () => {
const marker = 'F0124-SHIP-TEST'
const customs = { loadingPlanId: 'plan', containerBookingId: 'container', status: 'COMPLETED', taxRefundCount: 1,
documents: [{ source: { fileId: 'source' }, receipt: { fileId: 'receipt', version: 2 },
receiptStatus: 'CONFIRMED', confirmationHistory: [{ id: 'confirmation' }], finalDocument: { fileId: 'final' } }] }
const schedule = { id: 'schedule', version: 2, customerId: 'customer', oceanBookingId: 'ocean',
containerBookingId: 'container', vesselName: 'old', voyageNo: 'old', sourceType: 'MANUAL',
sourceName: 'source', sourceRef: 'ref', observedAt: '2026-09-30T12:00:00', expiresAt: '2026-10-01T12:00:00' }
const query = { id: 'query', containerBookingId: 'container', oceanBookingId: 'ocean',
trackingNo: 'BOX', latestActualNode: 'DEPARTED_WAREHOUSE', latestActualAt: '2026-09-30T12:00:00' }
const nodes = [{ id: 'original-node', standardNode: 'DEPARTED_WAREHOUSE', rawPayloadHash: 'original-hash' }]
let edited
const steps = []
const context = { marker, token: 'offline-placeholder', options: { gateway: 'https://offline.invalid' }, createdResources: {},
apiSuccess(_module, name, response) { steps.push({ name, passed: response.http === 200 }); return response.body.data },
assert(_module, name, _response, passed) { steps.push({ name, passed }) } }
const originalFetch = globalThis.fetch
globalThis.fetch = async (url, options) => {
const path = new URL(url).pathname
let data
if (options.method === 'PUT') {
edited = JSON.parse(options.body)
data = {}
} else if (path.includes('/customs/')) data = structuredClone(customs)
else if (path.endsWith('/carrier-schedule-queries/query')) data = { query: { query }, nodes }
else if (path.endsWith('/carrier-schedule-queries')) data = { total: 1, records: [{ query }] }
else data = { portSchedule: { schedule: edited ? { ...schedule, ...edited.data, version: 3 } : schedule },
histories: edited ? [{ id: 'history', action: 'UPDATE', operatorId: 'operator', createdAt: '2026-09-30T13:00:00', toVersion: 3 }] : [] }
return new Response(JSON.stringify({ code: '200', data }), { status: 200, headers: { 'content-type': 'application/json' } })
}
try {
await verifyTaxCustomsAfterShipEdit(context, { marker, loadingPlanId: 'plan', containerBookingId: 'container', queryId: 'query', scheduleId: 'schedule' })
assert.equal(steps.every(step => step.passed), true, JSON.stringify(steps))
assert.equal(edited.expectedVersion, 2)
assert.equal('id' in edited.data, false)
assert.equal('version' in edited.data, false)
assert.equal(edited.data.customerId, 'customer')
assert.equal(context.createdResources.taxCustomsShipRetention.customsBeforeDigest,
context.createdResources.taxCustomsShipRetention.customsAfterDigest)
} finally { globalThis.fetch = originalFetch }
})
test('ship edit refuses incomplete tax customs before any PUT', async () => {
const calls = []
const originalFetch = globalThis.fetch
globalThis.fetch = async (_url, options) => {
calls.push(options.method)
return new Response(JSON.stringify({ code: '200', data: { status: 'DECLARING', taxRefundCount: 1 } }), { status: 200 })
}
const context = { marker: 'own', token: '', options: { gateway: 'https://offline.invalid' },
apiSuccess(_module, _name, response) { return response.body.data }, assert() {} }
try {
await assert.rejects(verifyTaxCustomsAfterShipEdit(context,
{ marker: 'own', loadingPlanId: 'plan', containerBookingId: 'container', queryId: 'query', scheduleId: 'schedule' }), /completed tax customs/)
assert.deepEqual(calls, ['GET'])
} finally { globalThis.fetch = originalFetch }
})
@@ -6,6 +6,81 @@ const BASE = '/api/platform/v1/port-schedules'
const OCEAN = '/api/platform/v1/ocean-bookings'
const OUTBOUND = '/api/platform/v1/first-leg/outbound-dispatches'
import { resolveOceanBookingMaster, oceanBookingDraft } from './ocean-booking-fixture.mjs'
import { createHash } from 'node:crypto'
export function customsRetentionDigest(value) {
const canonical = input => Array.isArray(input) ? input.map(canonical)
: input && typeof input === 'object' ? Object.fromEntries(Object.keys(input).sort().map(key => [key, canonical(input[key])]))
: input
return createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex')
}
export async function verifyTaxCustomsAfterShipEdit(context, fixture) {
if (fixture?.marker !== context.marker || !fixture?.loadingPlanId || !fixture?.queryId || !fixture?.scheduleId) {
throw new Error('requires current marker owned tax customs, query and schedule fixture')
}
const customsPath = `/api/platform/v1/customs/loading-plans/${fixture.loadingPlanId}`
const queryPath = `/api/platform/v1/carrier-schedule-queries/${fixture.queryId}`
async function read(path, name) {
const response = await request(context, 'GET', path)
const value = context.apiSuccess(MODULE, name, response, '', OPTIONS)
if (!value) throw new Error(`${name} failed`)
return value
}
const beforeCustoms = await read(customsPath, '改船前冻结同一退税柜完整资料回执确认事实')
const completedTax = beforeCustoms.status === 'COMPLETED' && Number(beforeCustoms.taxRefundCount) > 0
&& id(beforeCustoms.loadingPlanId) === id(fixture.loadingPlanId)
&& id(beforeCustoms.containerBookingId) === id(fixture.containerBookingId)
&& beforeCustoms.documents?.length > 0
&& beforeCustoms.documents.every(document => document.source?.fileId && document.receipt?.fileId
&& document.receiptStatus === 'CONFIRMED' && document.finalDocument?.fileId)
context.assert(MODULE, '改船样本为本轮已完成且含资料回执确认完成文件的退税柜', null,
completedTax, `loadingPlanId=${fixture.loadingPlanId}, taxRefundCount=${beforeCustoms.taxRefundCount}`, OPTIONS)
if (!completedTax) throw new Error('requires completed tax customs evidence')
const beforeQuery = await read(queryPath, '改船前冻结原柜唯一船任务与正常节点')
const beforeSchedule = await read(`${BASE}/${fixture.scheduleId}`, '改船前读取原柜船期')
const schedule = beforeSchedule.portSchedule?.schedule
const beforeTask = beforeQuery.query?.query
if (!schedule || id(beforeTask?.id) !== id(fixture.queryId) || id(schedule.containerBookingId) !== id(fixture.containerBookingId)
|| id(beforeTask?.containerBookingId) !== id(fixture.containerBookingId)) throw new Error('ship fixture identity mismatch')
const editedData = { ...Object.fromEntries(['customerId', 'oceanBookingId', 'containerBookingId',
'sourceType', 'sourceName', 'sourceRef', 'departurePortCode', 'departurePortName',
'destinationPortCode', 'destinationPortName', 'etdAt', 'etaAt', 'bookingCutoffAt',
'customsCutoffAt', 'portCutoffAt', 'observedAt', 'expiresAt', 'remark']
.map(key => [key, schedule[key]])), vesselName: `人工改船-${context.marker}`.slice(0, 128),
voyageNo: `R-${context.marker}`.slice(0, 64) }
const response = await request(context, 'PUT', `${BASE}/${fixture.scheduleId}`,
{ expectedVersion: Number(schedule.version), data: editedData, reason: `${context.marker} 原柜人工改船` },
{ 'Idempotency-Key': `${context.marker}-tax-ship-edit`.slice(0, 128) })
const edited = context.apiSuccess(MODULE, '退税报关完成后在原柜人工改船', response, '', OPTIONS)
if (!edited) throw new Error('ship edit failed')
const afterCustoms = await read(customsPath, '改船后读取同一退税柜完整资料回执确认事实')
const afterQuery = await read(queryPath, '改船后回查原唯一船任务与正常节点')
const afterSchedule = await read(`${BASE}/${fixture.scheduleId}`, '改船后读取修改历史')
const selector = new URLSearchParams({ containerBookingId: fixture.containerBookingId, page: '1', size: '200' })
const page = await read(`/api/platform/v1/carrier-schedule-queries?${selector}`, '改船后按原柜核对船任务唯一')
const customsBeforeDigest = customsRetentionDigest(beforeCustoms)
const customsAfterDigest = customsRetentionDigest(afterCustoms)
context.assert(MODULE, '原柜改船前后报关资料回执确认完成文件完整摘要零漂移', null,
customsBeforeDigest === customsAfterDigest, `before=${customsBeforeDigest}, after=${customsAfterDigest}`, OPTIONS)
const sameTask = ['id', 'containerBookingId', 'oceanBookingId', 'trackingNo', 'latestActualNode', 'latestActualAt']
.every(key => id(beforeTask[key]) === id(afterQuery.query?.query?.[key]))
context.assert(MODULE, '原柜改船保留唯一查询任务和全部已记录正常节点', null,
sameTask && customsRetentionDigest(beforeQuery.nodes) === customsRetentionDigest(afterQuery.nodes)
&& Number(page.total) === 1 && id(page.records?.[0]?.query?.id) === id(fixture.queryId),
`queryId=${fixture.queryId}, total=${page.total}`, OPTIONS)
const latest = afterSchedule.portSchedule?.schedule
const update = afterSchedule.histories?.at(-1)
context.assert(MODULE, '人工改船保留原柜订舱及操作人原因版本历史', null,
id(latest?.id) === id(schedule.id) && id(latest?.containerBookingId) === id(schedule.containerBookingId)
&& id(latest?.oceanBookingId) === id(schedule.oceanBookingId)
&& latest?.vesselName === editedData.vesselName && latest?.voyageNo === editedData.voyageNo
&& update?.action === 'UPDATE' && Boolean(update.operatorId && update.createdAt)
&& Number(update.toVersion) === Number(schedule.version) + 1,
`scheduleId=${fixture.scheduleId}, historyId=${update?.id || 'missing'}`, OPTIONS)
context.createdResources.taxCustomsShipRetention = { fixture, beforeCustoms, afterCustoms,
customsBeforeDigest, customsAfterDigest, beforeQuery, afterQuery, beforeSchedule, afterSchedule }
}
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
@@ -5,7 +5,137 @@ import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { expandOverseasCartonCandidates, resolveCartonFixtures, run, uniqueContainerProducer,
crossContainerCartonProbe } from './phase4-overseas-pda.mjs'
crossContainerCartonProbe, normalizeReceivingEvidence, receivingEvidenceValid,
receivingCompletionProof, captureReceivingEvidence, probeCartonResolveNegatives } from './phase4-overseas-pda.mjs'
test('single-container continuation retains malformed/unknown probes without requiring unshipped cancelled CHILD; default chain still blocks missing real other container', async () => {
const requests = [], gaps = [], checks = []
const context = { marker: 'ONE-CANCEL', createdResources: {},
request: async (_method, _path, body) => { requests.push(body.cartonMark); return { http: 200, body: { code: 'A0404', message: 'Carton mark was not found' }, requestId: 'request', traceId: 'trace' } },
addStep: (_module, _name, _response, passed) => checks.push(passed),
addCoverageGap: (...args) => gaps.push(args), assert: () => {} }
const current = { containerNo: 'REAL', inbounds: [{ inboundCode: 'ONE', destinationType: 'FBA' }] }
await probeCartonResolveNegatives(context, 'token', current, false)
assert.equal(requests.length, 3)
assert.ok(checks.every(Boolean))
assert.equal(gaps.length, 0)
await probeCartonResolveNegatives(context, 'token', current)
assert.equal(gaps.length, 1)
assert.match(gaps[0][3], /另一真实柜/)
assert.match(source, /probeCartonResolveNegatives\(context, token, current.data, runAuthorizationProbes\)/)
})
function receivingProofFixture() {
const scope = { customerId: '1', warehouseId: '2', bookingId: '3', containerNo: 'REAL',
inboundCodes: ['ONE', 'TWO'], sourceInboundIds: ['101', '102'], expectedPackagesByInbound: { ONE: 2, TWO: 2 },
expectedTransitPackages: 4, completeKey: 'REAL-COMPLETE', freshKey: 'REAL-FRESH' }
const buckets = { availablePackages: 0, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }
const balance = (bid, available, transit) => ({ id: bid, customerId: '1', overseasWarehouseId: '2',
destinationWarehouseId: '4', destinationWarehouseCode: 'FBA', inboundOrderId: bid === '10' ? '101' : '102',
inboundCode: bid === '10' ? 'ONE' : 'TWO', skuCode: 'SAME-SKU', ...buckets, availablePackages: available, inTransitPackages: transit, procurementPackages: 0 })
const position = (pid, bid) => ({ id: pid, balanceId: bid, customerId: '1', overseasWarehouseId: '2',
destinationWarehouseCode: 'FBA', palletId: '70', palletNo: 'DP-REAL', batchNo: 'BATCH',
warehouseLocationId: '80', warehouseLocationCode: 'LOC', status: 'AVAILABLE', ...buckets, availablePackages: 2 })
const receipt = (rid, bid, pid, code, tail, task) => ({ id: rid, customerId: '1', planId: '90', taskId: task,
allocationId: '91', lineId: '92', orderId: tail, inboundCode: code, skuCode: 'SAME-SKU',
warehousePalletId: '70', palletNo: 'DP-REAL', warehouseLocationId: '80', warehouseLocationCode: 'LOC',
batchNo: 'BATCH', receivedPackages: 2, inventoryBalanceId: bid, inventoryPositionId: pid,
inventoryOperationKey: `PDA_STOCK_IN:${task}:91:SAME-SKU` })
const receipts = [receipt('1001', '10', '201', 'ONE', '101', '301'), receipt('1002', '20', '202', 'TWO', '102', '302')]
const operations = receipts.map((row, index) => ({ id: String(400 + index), customerId: '1',
idempotencyKey: row.inventoryOperationKey, operationType: 'STOCK_IN', requestHash: 'a'.repeat(64), status: 'COMPLETED' }))
const ledgers = receipts.map((row, index) => ({ id: String(500 + index), customerId: '1', balanceId: row.inventoryBalanceId,
positionId: row.inventoryPositionId, overseasWarehouseId: '2', inboundOrderId: row.orderId, inboundCode: row.inboundCode,
palletId: '70', palletNo: 'DP-REAL', batchNo: 'BATCH', skuCode: 'SAME-SKU', businessType: 'STOCK_IN', businessOrderNo: 'TAIL-REAL',
sourceEventId: 'EVENT-REAL', idempotencyKey: `${row.inventoryOperationKey}:${operations[index].id}`,
statusFrom: 'IN_TRANSIT', statusTo: 'AVAILABLE', beforePackages: 0, deltaPackages: 2, afterPackages: 2 }))
const before = { balances: [balance('10', 0, 2), balance('20', 0, 2)], positions: [], palletComposition: [],
stockReceipts: [], inventoryOperations: [], stockInLedger: [], receivingOperations: [],
httpGroups: [balance('10', 0, 4)], httpLedger: [] }
const after = { balances: [balance('10', 2, 0), balance('20', 2, 0)], positions: [position('201', '10'), position('202', '20')],
palletComposition: [position('201', '10'), position('202', '20')], stockReceipts: receipts, inventoryOperations: operations, stockInLedger: ledgers,
receivingOperations: [{ id: '601', sessionId: '701', action: 'COMPLETE', idempotencyKey: 'REAL-COMPLETE', requestHash: 'b'.repeat(64) }],
httpGroups: [balance('10', 4, 0)], httpLedger: ledgers }
const stages = { BEFORE_COMPLETE: normalizeReceivingEvidence(before), AFTER_COMPLETE: normalizeReceivingEvidence(after),
AFTER_SAME_KEY_REPLAY: normalizeReceivingEvidence(after), AFTER_FRESH_KEY_REJECTION: normalizeReceivingEvidence(after) }
return { scope, before, after, stages }
}
test('receiving completion freezes all raw balances when group MIN represents two tickets on one pallet', () => {
const { scope, stages } = receivingProofFixture()
assert.equal(receivingEvidenceValid(stages.BEFORE_COMPLETE, scope), true)
assert.equal(receivingEvidenceValid(stages.AFTER_COMPLETE, scope), true)
const proof = receivingCompletionProof(scope, stages)
assert.equal(proof.passed, true, JSON.stringify(proof))
assert.equal(stages.AFTER_COMPLETE.balances.length, 2)
assert.equal(stages.AFTER_COMPLETE.httpGroups.length, 1)
assert.equal(stages.AFTER_COMPLETE.httpGroups[0].availablePackages, 4)
assert.equal(stages.AFTER_COMPLETE.positions.length, 2)
})
test('receiving proof rejects missing raw source, wrong customer/warehouse, prefix source, missing bucket, ledger key, duplicate receipt and operation mismatch', () => {
const mutations = [
row => row.balances.pop(), row => row.positions[0].customerId = 'OTHER',
row => row.balances[0].overseasWarehouseId = 'OTHER', row => row.stockReceipts[0].inboundCode = 'ONE-CHILD',
row => row.balances[0].holdPackages = null, row => row.stockInLedger[0].idempotencyKey = row.inventoryOperations[0].idempotencyKey,
row => row.stockReceipts.push({ ...row.stockReceipts[0] }), row => row.inventoryOperations[0].status = 'PROCESSING',
row => row.palletComposition[0].availablePackages += 1, row => row.stockInLedger[0].deltaPackages += 1,
row => row.receivingOperations[0].idempotencyKey = 'ANOTHER',
]
for (const mutate of mutations) {
const { scope, after, stages } = receivingProofFixture()
mutate(after); stages.AFTER_COMPLETE = normalizeReceivingEvidence(after)
assert.equal(receivingCompletionProof(scope, stages).passed, false)
}
})
test('same-key replay and fresh-key rejection must preserve every bucket, position, pallet and immutable row', () => {
for (const stage of ['AFTER_SAME_KEY_REPLAY', 'AFTER_FRESH_KEY_REJECTION']) {
for (const mutate of [row => row.balances[0].availablePackages++, row => row.stockReceipts.pop(),
row => row.receivingOperations.push({ ...row.receivingOperations[0], id: '999' }),
row => row.stockInLedger[0].sourceEventId = 'OTHER']) {
const { scope, stages } = receivingProofFixture(); mutate(stages[stage])
assert.equal(receivingCompletionProof(scope, stages).passed, false)
}
}
})
test('normalization is deterministic, preserves zero buckets, and excludes token/operator/provider fields', () => {
const { after } = receivingProofFixture()
after.token = 'SECRET'; after.stockReceipts[0].password = 'SECRET'; after.receivingOperations[0].operatorKey = 'PERSON'
const normal = normalizeReceivingEvidence(after)
const reversed = { ...after, balances: [...after.balances].reverse(), stockReceipts: [...after.stockReceipts].reverse() }
assert.deepEqual(normalizeReceivingEvidence(reversed), normal)
assert.equal(normal.balances[0].procurementPackages, 0)
assert.doesNotMatch(JSON.stringify(normal), /SECRET|PERSON|password|operatorKey|token/)
assert.equal(normalizeReceivingEvidence({ ...after, stockReceipts: undefined }), null)
})
test('missing read-only receipt/key reader is a blocker and retains an unproven boundary without inventing HTTP', async () => {
const checks = [], target = { stages: {} }
const context = { context: {}, addCoverageGap() {}, assert(m, n, r, passed) { checks.push(passed) },
async request() { assert.fail('missing provider cannot pretend public DTO supplies raw keys') } }
const { scope } = receivingProofFixture()
assert.equal(await captureReceivingEvidence(context, scope, 'BEFORE_COMPLETE', target), null)
assert.deepEqual(checks, [false]); assert.equal(target.stages.BEFORE_COMPLETE.captured, false)
})
test('capture independently reads complete unfiltered customer warehouse pages and saves only sanitized live facts', async () => {
const { scope, after } = receivingProofFixture(); const calls = [], checks = []
const context = { context: { async phase4ReceivingEvidenceReader(actual) {
assert.equal(actual.stage, 'AFTER_COMPLETE'); assert.deepEqual(actual.inboundCodes, ['ONE', 'TWO'])
return { ...after, token: 'SECRET' }
} }, async request(method, requestPath) {
calls.push(requestPath)
const data = requestPath.includes('/balances?') ? after.httpGroups : after.httpLedger
return { http: 200, body: { code: '200', data: { records: data, total: data.length } } }
}, addStep(m, n, r, passed) { checks.push(passed) }, assert(m, n, r, passed) { checks.push(passed) }, addCoverageGap() { assert.fail('valid fixture must not gap') } }
const target = { stages: {} }
const snapshot = await captureReceivingEvidence(context, scope, 'AFTER_COMPLETE', target)
assert.equal(receivingEvidenceValid(snapshot, scope), true); assert.ok(checks.every(Boolean))
assert.ok(calls.every(requestPath => requestPath.includes('customerId=1') && requestPath.includes('overseasWarehouseId=2') && !requestPath.includes('inboundCode=')))
assert.doesNotMatch(JSON.stringify(target), /SECRET/)
})
test('same-root FBA cross-container rejection uses explicit real receiving prefix while preserving original physical box code',() => {
const current={containerNo:'ROOT-CABINET',inbounds:[{inboundCode:'ROOT'}]}
@@ -126,7 +256,9 @@ test('PDA module is injected, token-scoped, traceable, and contains no direct ne
assert.match(source, /traceId=/)
assert.doesNotMatch(source, /https?:\/\//)
assert.doesNotMatch(source, /fetch\(|process\.env/)
assert.doesNotMatch(source, /planNo|allocationId/)
assert.doesNotMatch(source, /planNo/)
// Allocation identity is read-only evidence; PDA command URLs/bodies remain container-scoped.
assert.doesNotMatch(source, /\/allocations\//)
})
test('PDA module covers the container-number receiving contract and excludes outbound domains', () => {
@@ -61,6 +61,165 @@ function receivingSnapshot(data) {
})
}
const RECEIVING_BALANCE_BUCKETS = ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'inTransitPackages', 'receivingPackages', 'procurementPackages']
const RECEIVING_POSITION_BUCKETS = ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages']
const RECEIVING_FIELDS = {
balances: ['id', 'customerId', 'overseasWarehouseId', 'destinationWarehouseId', 'destinationWarehouseCode', 'inboundOrderId', 'inboundOrderNo', 'inboundCode', 'skuCode', ...RECEIVING_BALANCE_BUCKETS],
positions: ['id', 'balanceId', 'customerId', 'overseasWarehouseId', 'destinationWarehouseCode', 'palletId', 'palletNo', 'batchNo', 'warehouseLocationId', 'warehouseLocationCode', 'status', ...RECEIVING_POSITION_BUCKETS],
stockReceipts: ['id', 'customerId', 'planId', 'taskId', 'allocationId', 'lineId', 'orderId', 'inboundCode', 'skuCode', 'warehousePalletId', 'palletNo', 'warehouseLocationId', 'warehouseLocationCode', 'batchNo', 'receivedPackages', 'inventoryBalanceId', 'inventoryPositionId', 'inventoryOperationKey'],
inventoryOperations: ['id', 'customerId', 'idempotencyKey', 'operationType', 'requestHash', 'status'],
stockInLedger: ['id', 'customerId', 'balanceId', 'positionId', 'overseasWarehouseId', 'inboundOrderId', 'inboundCode', 'palletId', 'palletNo', 'batchNo', 'skuCode', 'businessType', 'businessOrderNo', 'sourceEventId', 'idempotencyKey', 'statusFrom', 'statusTo', 'beforePackages', 'deltaPackages', 'afterPackages'],
receivingOperations: ['id', 'sessionId', 'action', 'idempotencyKey', 'requestHash'],
httpGroups: ['id', 'customerId', 'overseasWarehouseId', 'destinationWarehouseId', 'destinationWarehouseCode', 'skuCode', ...RECEIVING_BALANCE_BUCKETS],
httpLedger: ['id', 'customerId', 'overseasWarehouseId', 'inboundOrderId', 'inboundOrderNo', 'inboundCode', 'palletNo', 'batchNo', 'skuCode', 'businessType', 'businessOrderNo', 'statusFrom', 'statusTo', 'beforePackages', 'deltaPackages', 'afterPackages'],
}
RECEIVING_FIELDS.palletComposition = RECEIVING_FIELDS.positions
const receivingNumberFields = new Set([...RECEIVING_BALANCE_BUCKETS, 'receivedPackages', 'beforePackages', 'deltaPackages', 'afterPackages'])
const receivingArrays = Object.keys(RECEIVING_FIELDS)
const sameEvidence = (left, right) => JSON.stringify(left) === JSON.stringify(right)
const receivingTotal = (rows, field) => rows.reduce((total, row) => total + row[field], 0)
const balanceGroupKey = row => JSON.stringify([row.customerId, row.overseasWarehouseId, row.destinationWarehouseId, row.destinationWarehouseCode, row.skuCode])
// Only safe business identities and bucket facts enter reports. Never save reader connection,
// tokens, operator identifiers, request/result JSON, binary files or arbitrary provider fields.
export function normalizeReceivingEvidence(value) {
if (!value || receivingArrays.some(name => !Array.isArray(value[name]))) return null
return Object.fromEntries(receivingArrays.map(name => [name, value[name].map(row =>
Object.fromEntries(RECEIVING_FIELDS[name].map(field => [field, receivingNumberFields.has(field)
? (row[field] !== null && row[field] !== undefined && row[field] !== '' && Number.isSafeInteger(Number(row[field])) ? Number(row[field]) : null)
: row[field] == null ? null : id(row[field])]))).sort((a, b) => id(a.id).localeCompare(id(b.id)))]))
}
export function receivingEvidenceValid(snapshot, scope) {
if (!snapshot || receivingArrays.some(name => !Array.isArray(snapshot[name]) || snapshot[name].some(row => !longId(row.id))
|| new Set(snapshot[name].map(row => row.id)).size !== snapshot[name].length)) return false
const bucketsValid = (row, fields) => fields.every(field => Number.isSafeInteger(row[field]) && row[field] >= 0)
const owned = row => row.customerId === scope.customerId && row.overseasWarehouseId === scope.warehouseId
if (!snapshot.balances.every(row => owned(row) && bucketsValid(row, RECEIVING_BALANCE_BUCKETS))
|| !snapshot.httpGroups.every(row => owned(row) && bucketsValid(row, RECEIVING_BALANCE_BUCKETS))
|| !snapshot.httpLedger.every(owned)
|| !snapshot.positions.every(row => owned(row) && bucketsValid(row, RECEIVING_POSITION_BUCKETS)
&& snapshot.balances.some(balance => balance.id === row.balanceId) && longId(row.palletId) && nonBlank(row.palletNo)
&& longId(row.warehouseLocationId) && nonBlank(row.warehouseLocationCode))
|| !sameEvidence(snapshot.positions, snapshot.palletComposition)) return false
if (!snapshot.balances.every(balance => RECEIVING_POSITION_BUCKETS.every(field =>
receivingTotal(snapshot.positions.filter(row => row.balanceId === balance.id), field) === balance[field]))) return false
const groups = new Map()
for (const balance of snapshot.balances) {
const key = balanceGroupKey(balance)
const current = groups.get(key) || Object.fromEntries(RECEIVING_BALANCE_BUCKETS.map(field => [field, 0]))
for (const field of RECEIVING_BALANCE_BUCKETS) current[field] += balance[field]
groups.set(key, current)
}
for (const [key, totals] of groups) if (!RECEIVING_BALANCE_BUCKETS.some(field => totals[field] > 0)) groups.delete(key)
if (groups.size !== snapshot.httpGroups.length || new Set(snapshot.httpGroups.map(balanceGroupKey)).size !== snapshot.httpGroups.length
|| !snapshot.httpGroups.every(row => groups.has(balanceGroupKey(row)) && RECEIVING_BALANCE_BUCKETS.every(field => row[field] === groups.get(balanceGroupKey(row))[field]))) return false
if (!snapshot.stockReceipts.every(row => row.customerId === scope.customerId && scope.inboundCodes.includes(row.inboundCode)
&& longId(row.planId) && longId(row.taskId) && longId(row.allocationId) && longId(row.lineId) && longId(row.orderId)
&& nonBlank(row.skuCode) && Number.isSafeInteger(row.receivedPackages) && row.receivedPackages > 0)) return false
if (snapshot.inventoryOperations.length !== snapshot.stockReceipts.length || snapshot.stockInLedger.length !== snapshot.stockReceipts.length) return false
for (const receipt of snapshot.stockReceipts) {
const baseKey = `PDA_STOCK_IN:${receipt.taskId}:${receipt.allocationId}:${receipt.skuCode}`
const operations = snapshot.inventoryOperations.filter(row => row.idempotencyKey === baseKey)
if (receipt.inventoryOperationKey !== baseKey || operations.length !== 1) return false
const operation = operations[0]
const ledgers = snapshot.stockInLedger.filter(row => row.idempotencyKey === `${baseKey}:${operation.id}`)
if (operation.customerId !== scope.customerId || operation.operationType !== 'STOCK_IN' || operation.status !== 'COMPLETED'
|| !/^[a-f0-9]{64}$/.test(operation.requestHash || '') || ledgers.length !== 1) return false
const ledger = ledgers[0]
const balance = snapshot.balances.find(row => row.id === receipt.inventoryBalanceId)
const position = snapshot.positions.find(row => row.id === receipt.inventoryPositionId)
if (!balance || !position || position.balanceId !== balance.id || !owned(ledger) || ledger.businessType !== 'STOCK_IN'
|| ledger.balanceId !== balance.id || ledger.positionId !== position.id || ledger.inboundOrderId !== receipt.orderId
|| ledger.inboundCode !== receipt.inboundCode || ledger.skuCode !== receipt.skuCode || !nonBlank(ledger.sourceEventId)
|| ledger.palletId !== receipt.warehousePalletId || ledger.palletNo !== receipt.palletNo || ledger.batchNo !== receipt.batchNo
|| position.palletId !== receipt.warehousePalletId || position.palletNo !== receipt.palletNo
|| position.warehouseLocationId !== receipt.warehouseLocationId || position.warehouseLocationCode !== receipt.warehouseLocationCode
|| ledger.deltaPackages !== receipt.receivedPackages || ledger.afterPackages - ledger.beforePackages !== receipt.receivedPackages
|| ledger.statusTo !== 'AVAILABLE') return false
}
return snapshot.receivingOperations.every(row => row.action === 'COMPLETE' && longId(row.sessionId)
&& nonBlank(row.idempotencyKey) && /^[a-f0-9]{64}$/.test(row.requestHash || ''))
}
export function receivingCompletionProof(scope, stages) {
const names = ['BEFORE_COMPLETE', 'AFTER_COMPLETE', 'AFTER_SAME_KEY_REPLAY', 'AFTER_FRESH_KEY_REJECTION']
const snapshotsValid = names.every(name => receivingEvidenceValid(stages[name], scope))
const checks = { completeScope: scope.inboundCodes.length > 0 && new Set(scope.inboundCodes).size === scope.inboundCodes.length
&& scope.inboundCodes.every(code => Number.isSafeInteger(scope.expectedPackagesByInbound[code]) && scope.expectedPackagesByInbound[code] > 0)
&& scope.sourceInboundIds.length === scope.inboundCodes.length && new Set(scope.sourceInboundIds).size === scope.sourceInboundIds.length,
fourCompleteSnapshots: snapshotsValid }
if (!snapshotsValid) return { passed: false, checks }
const before = stages.BEFORE_COMPLETE, after = stages.AFTER_COMPLETE
const expected = Object.values(scope.expectedPackagesByInbound).reduce((sum, count) => sum + count, 0)
checks.freshSourceHasNoPriorStock = ['stockReceipts', 'inventoryOperations', 'stockInLedger', 'receivingOperations'].every(name => before[name].length === 0)
checks.exactSourceReceiptAndLedgerQuantities = scope.inboundCodes.every(code => receivingTotal(after.stockReceipts.filter(row => row.inboundCode === code), 'receivedPackages') === scope.expectedPackagesByInbound[code]
&& receivingTotal(after.stockInLedger.filter(row => row.inboundCode === code), 'deltaPackages') === scope.expectedPackagesByInbound[code])
checks.completeCommandExactlyOnce = after.receivingOperations.length === 1 && after.receivingOperations[0].idempotencyKey === scope.completeKey
checks.firstCompleteAllBalanceBuckets = RECEIVING_BALANCE_BUCKETS.every(field => receivingTotal(after.balances, field) - receivingTotal(before.balances, field)
=== (field === 'availablePackages' ? expected : field === 'inTransitPackages' ? -scope.expectedTransitPackages : 0))
checks.firstCompleteAllPositionBuckets = RECEIVING_POSITION_BUCKETS.every(field => receivingTotal(after.positions, field) - receivingTotal(before.positions, field)
=== (field === 'availablePackages' ? expected : 0))
const touchedBalances = new Set(after.stockReceipts.map(row => row.inventoryBalanceId))
const touchedPositions = new Set(after.stockReceipts.map(row => row.inventoryPositionId))
checks.untouchedBalancesAndPositionsUnchanged = sameEvidence(before.balances.filter(row => !touchedBalances.has(row.id)), after.balances.filter(row => !touchedBalances.has(row.id)))
&& sameEvidence(before.positions.filter(row => !touchedPositions.has(row.id)), after.positions.filter(row => !touchedPositions.has(row.id)))
const oldLedgerIds = new Set(before.httpLedger.map(row => row.id))
const delta = after.httpLedger.filter(row => !oldLedgerIds.has(row.id))
checks.publicImmutableLedgerPreservedAndNewStockExact = sameEvidence(before.httpLedger, after.httpLedger.filter(row => oldLedgerIds.has(row.id)))
&& delta.filter(row => row.businessType === 'STOCK_IN').length === after.stockInLedger.length
&& delta.filter(row => row.businessType === 'STOCK_IN').every(row => after.stockInLedger.some(source => source.id === row.id && source.deltaPackages === row.deltaPackages))
&& delta.every(row => scope.inboundCodes.includes(row.inboundCode) && ['STOCK_IN', 'STOCK_IN_TRANSIT_RELEASE'].includes(row.businessType))
checks.sameKeyReplayAllRawBucketsPositionsPalletsAndImmutableKeysUnchanged = sameEvidence(after, stages.AFTER_SAME_KEY_REPLAY)
checks.freshKeyRejectedAllRawBucketsPositionsPalletsAndImmutableKeysUnchanged = sameEvidence(after, stages.AFTER_FRESH_KEY_REJECTION)
return { passed: Object.values(checks).every(Boolean), checks, expectedPackages: expected, expectedTransitPackages: scope.expectedTransitPackages }
}
async function receivingEvidencePages(context, scope, resource, name) {
const collected = []
for (let page = 1; page <= 100; page++) {
const response = await context.request('GET', `/api/platform/v1/last-leg/inventory/${resource}?customerId=${encodeURIComponent(scope.customerId)}&overseasWarehouseId=${encodeURIComponent(scope.warehouseId)}&page=${page}&size=200`)
const data = response.body?.data
const valid = success(response) && Array.isArray(data?.records) && Number.isSafeInteger(Number(data?.total)) && Number(data.total) >= 0
context.addStep(MODULE, `${name}完整客户仓${resource}第${page}页`, response, valid, trace(response), OPTIONS)
if (!valid) return null
collected.push(...data.records)
if (collected.length >= Number(data.total)) {
const complete = collected.length === Number(data.total) && new Set(collected.map(row => id(row.id))).size === collected.length
context.assert(MODULE, `${name}${resource}分页无截断重复`, response, complete, `rows=${collected.length}, total=${data.total}`, OPTIONS)
return complete ? collected : null
}
if (!data.records.length) break
}
blockerGap(context, `${name}库存分页`, '完整客户仓分页未读完,不能冻结截断库存', 'receiving-replay-evidence')
return null
}
export async function captureReceivingEvidence(context, scope, stage, target) {
const slot = target.stages[stage] = { captured: false, evidence: null }
const reader = context.context?.phase4ReceivingEvidenceReader
if (typeof reader !== 'function') {
blockerGap(context, `${stage}原始库存与不可变完整键取证`, '未注入phase4ReceivingEvidenceReader;公共ledger不返回原始balance/position/幂等键且无immutable receipt接口,不能以终态补历史快照', 'receiving-replay-evidence')
return null
}
try {
const httpGroups = await receivingEvidencePages(context, scope, 'balances', stage)
const httpLedger = await receivingEvidencePages(context, scope, 'ledger', stage)
if (!httpGroups || !httpLedger) return null
const raw = await reader({ ...scope, stage })
const evidence = normalizeReceivingEvidence({ ...raw, httpGroups, httpLedger })
slot.evidence = evidence
slot.captured = receivingEvidenceValid(evidence, scope)
context.assert(MODULE, `${stage}完整raw库存位置托盘组成及收货完整键核平`, null, slot.captured,
`bookingId=${scope.bookingId}, balances=${evidence?.balances.length ?? 'missing'}, positions=${evidence?.positions.length ?? 'missing'}, receipts=${evidence?.stockReceipts.length ?? 'missing'}`, OPTIONS)
return evidence
} catch {
slot.error = 'Read-only receiving evidence reader failed; sensitive connection/provider details omitted'
blockerGap(context, `${stage}只读取证`, slot.error, 'receiving-replay-evidence')
return null
}
}
export function expandOverseasCartonCandidates(inbounds, producerCartonMarks = []) {
const marks = (Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks])
.map(id).filter(nonBlank)
@@ -533,7 +692,7 @@ export async function resolveCartonFixtures(context, token, inbounds, producerCa
return cartonFixtures
}
async function probeCartonResolveNegatives(context, token, current) {
export async function probeCartonResolveNegatives(context, token, current, runCrossContainerProbe = true) {
const fba = current.inbounds.find((item) => item.destinationType === 'FBA')
await expected(context, token, 'POST', '无#箱码被服务端拒绝',
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
@@ -545,6 +704,7 @@ async function probeCartonResolveNegatives(context, token, current) {
'/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve',
{ cartonMark: `UNMATCHED-${markerSuffix(context)}#ANY-SUFFIX` }, cartonResolveRejected)
if (runCrossContainerProbe) {
const allProducers = context.createdResources?.phase4InboundProducerFixtures?.containers || []
const crossContainerProbe = crossContainerCartonProbe(allProducers,current)
if (crossContainerProbe) {
@@ -556,6 +716,7 @@ async function probeCartonResolveNegatives(context, token, current) {
} else {
blockerGap(context, '箱码跨柜负向', '缺少另一真实柜的冻结箱码,不能伪造跨柜归属反证')
}
}
const overseas = current.inbounds.find((item) => item.destinationType === 'OVERSEAS')
if (overseas) {
@@ -747,7 +908,9 @@ export async function runReceiving(context, token, secondaryToken, merchantToken
const current = await pda(context, token, 'GET', '签收后按账号恢复当前柜', '/api/pda/v1/last-leg/receiving-containers/current')
if (!success(current.response)) return
const beforeResolve = receivingSnapshot(current.data)
await probeCartonResolveNegatives(context, token, current.data)
// Single-container completion/cancellation continuation does not provision a second
// loaded source. The default full receiving chain still requires its cross-container proof.
await probeCartonResolveNegatives(context, token, current.data, runAuthorizationProbes)
const producerCartonMarks = preferredProducer?.cartonMarks?.length
? preferredProducer.cartonMarks : [preferredProducer?.cartonMark]
const cartonFixtures = await resolveCartonFixtures(context, token, current.data.inbounds, producerCartonMarks, preferredProducer)
@@ -841,11 +1004,35 @@ export async function runReceiving(context, token, secondaryToken, merchantToken
return
}
const completeBody = { idempotencyKey: key(context, 'complete') }
const sourceInboundIds = [...new Set([id(preferredProducer?.sourceInboundId),
...(preferredProducer?.palletLabelManifest?.groups || []).flatMap(group => (group.labels || []).map(label => id(label.inboundId)))].filter(longId))].sort()
const scope = { customerId: id(preferredProducer?.tenantId), warehouseId: id(refreshed.data?.overseasWarehouseId),
bookingId: sourceContainerBookingId, containerNo,
inboundCodes: refreshed.data.inbounds.map(inbound => inbound.inboundCode).sort(), sourceInboundIds,
expectedPackagesByInbound: Object.fromEntries(refreshed.data.inbounds.map(inbound => [inbound.inboundCode, Number(inbound.totalPackages)])),
expectedTransitPackages: refreshed.data.inbounds.filter(inbound => inbound.orderType === 'FIRST_LEG').reduce((sum, inbound) => sum + Number(inbound.totalPackages), 0),
completeKey: completeBody.idempotencyKey, freshKey: key(context, 'complete-fresh-repeat') }
const receivingProof = { scope, stages: {}, proof: null, status: 'UNPROVEN' }
context.createdResources.phase4OverseasPda.receivingCompletionEvidence ||= {}
context.createdResources.phase4OverseasPda.receivingCompletionEvidence[sourceContainerBookingId] = receivingProof
await captureReceivingEvidence(context, scope, 'BEFORE_COMPLETE', receivingProof)
const completed = await pda(context, token, 'POST', '完成当前柜并写入库存与卸柜事件', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody)
await captureReceivingEvidence(context, scope, 'AFTER_COMPLETE', receivingProof)
if (!success(completed.response)) return
const completeReplay = await pda(context, token, 'POST', '完成当前柜同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody)
context.assert(MODULE, '完成幂等重放仍返回COMPLETED', completeReplay.response,
success(completeReplay.response) && completeReplay.data?.status === 'COMPLETED', trace(completeReplay.response), OPTIONS)
success(completeReplay.response) && completeReplay.data?.status === 'COMPLETED' && sameEvidence(completeReplay.data, completed.data), trace(completeReplay.response), OPTIONS)
await captureReceivingEvidence(context, scope, 'AFTER_SAME_KEY_REPLAY', receivingProof)
const freshComplete = await expected(context, token, 'POST', '已完成柜新键重复完成被拒绝且不能再次写库存',
'/api/pda/v1/last-leg/receiving-containers/current/complete', { idempotencyKey: scope.freshKey },
response => a0404Message(response, /^Current receiving container session was not found$/))
await captureReceivingEvidence(context, scope, 'AFTER_FRESH_KEY_REJECTION', receivingProof)
receivingProof.proof = receivingCompletionProof(scope, Object.fromEntries(Object.entries(receivingProof.stages).map(([stage, row]) => [stage, row.evidence])))
receivingProof.commandResults = { firstCompleteSucceeded: success(completed.response), sameKeyReturnedExactOriginal: success(completeReplay.response) && sameEvidence(completeReplay.data, completed.data),
freshKeyRejected: a0404Message(freshComplete, /^Current receiving container session was not found$/) }
receivingProof.status = receivingProof.proof.passed && Object.values(receivingProof.commandResults).every(Boolean) ? 'VERIFIED' : 'UNPROVEN'
for (const [check, passed] of Object.entries(receivingProof.proof.checks)) context.assert(MODULE, `收货完成库存守恒 ${check}`, null, passed,
`bookingId=${sourceContainerBookingId}, proofStatus=${receivingProof.status}`, OPTIONS)
const after = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {})
context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS)
context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null
@@ -1,6 +1,7 @@
import test from 'node:test'
import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, verifyFulfillmentRootStatus,
completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer } from './phase5-overseas-fulfillment.mjs'
completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer,
dispatchDestinationConservation } from './phase5-overseas-fulfillment.mjs'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
@@ -9,6 +10,28 @@ import { parseArguments } from '../admin-test-env-acceptance.mjs'
const source = await readFile(fileURLToPath(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url)), 'utf8')
const entry = await readFile(fileURLToPath(new URL('../admin-test-env-acceptance.mjs', import.meta.url)), 'utf8')
test('route equals actual item destinations and frozen selected candidates, then conserves split deliveries',()=>{
const fixture={planPalletIds:['10','11','12'],destinationWarehouseCodes:['ONT8','LAX9']}
const detail={route:'ONT8,LAX9',items:[{planPalletId:'10',fbaWarehouseCode:'ONT8'},
{planPalletId:'11',destinationWarehouseCode:'LAX9'},{planPalletId:'12',fbaWarehouseCode:'ONT8'}]}
const deliveries=[{destinationWarehouseCode:'LAX9'},{destinationWarehouseCode:'ONT8'}]
assert.equal(dispatchDestinationConservation(detail,fixture),true)
assert.equal(dispatchDestinationConservation(detail,fixture,deliveries),true)
for(const mutate of [
value=>value.detail.route='ONT8',value=>value.detail.route='ONT8,LAX9,OTHER',
value=>value.detail.route='ONT8,ONT8,LAX9',value=>value.detail.items[1].destinationWarehouseCode='OTHER',
value=>value.detail.items[1].planPalletId='99',value=>value.detail.items[1].planPalletId='10',
value=>value.fixture.destinationWarehouseCodes.push('UNSELECTED'),value=>value.deliveries.pop(),
value=>value.deliveries.push({destinationWarehouseCode:'OTHER'}),
value=>value.deliveries.push({destinationWarehouseCode:'ONT8'}),
]) {
const value=structuredClone({detail,fixture,deliveries});mutate(value)
assert.equal(dispatchDestinationConservation(value.detail,value.fixture,value.deliveries),false)
}
assert.match(source,/dispatchDestinationConservation\(beforeEdit.data,source.dispatch\)/)
assert.match(source,/dispatchDestinationConservation\(beforeEdit.data,source.dispatch,deliveries\)/)
})
test('phase-five fulfillment is a registered default HTTP module after the real phase-four dispatch fixture', () => {
assert.match(entry, /CONFIGURED_MODULES[^\n]*'phase4-overseas-dispatch', 'phase5-overseas-fulfillment'/)
assert.match(entry, /'phase5-overseas-fulfillment', \['phase4-overseas-dispatch'\]/)
@@ -57,6 +57,25 @@ function deliveryNumbers(data) {
return transferDeliveries(data).map((value) => id(value.deliveryNo)).sort()
}
export function dispatchDestinationConservation(detail, fixture, deliveries = null) {
const codes = values => values.map(value => id(value).trim().toUpperCase())
const sameSet = (left,right) => left.length > 0 && right.length > 0 && left.every(nonBlank)
&& right.every(nonBlank) && JSON.stringify([...new Set(left)].sort()) === JSON.stringify([...new Set(right)].sort())
const items = detail?.items
if (!Array.isArray(items) || !items.length || !Array.isArray(fixture?.planPalletIds)
|| !Array.isArray(fixture?.destinationWarehouseCodes) || !nonBlank(detail?.route)) return false
const actualIds = items.map(row => id(row.planPalletId)), expectedIds=fixture.planPalletIds.map(id)
if (!actualIds.every(nonBlank) || actualIds.length !== expectedIds.length
|| new Set(actualIds).size !== actualIds.length || !sameSet(actualIds,expectedIds)) return false
const actual = codes(items.map(row => row.fbaWarehouseCode || row.destinationWarehouseCode))
const frozen = codes(fixture.destinationWarehouseCodes), route = codes(detail.route.split(','))
if (!sameSet(actual,frozen) || !sameSet(route,actual) || route.length !== new Set(route).size) return false
if (deliveries == null) return true
if (!Array.isArray(deliveries) || !deliveries.length) return false
const delivered=codes(deliveries.map(row => row.destinationWarehouseCode))
return delivered.length === new Set(delivered).size && sameSet(delivered,actual)
}
async function poll(action, predicate, attempts = 45, intervalMs = 1_000) {
let result = null
for (let attempt = 1; attempt <= attempts; attempt += 1) {
@@ -873,6 +892,14 @@ export async function run(context) {
const beforeEdit = await platform(context, 'GET', '编辑车辆前读取服务端目的仓线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
if (!success(beforeEdit.response) || beforeEdit.data?.dispatchNo !== dispatchNo) return
const routeProof = context.createdResources.phase5OverseasFulfillment.routeProof = {
frozenPlanPalletIds:source.dispatch.planPalletIds,
frozenDestinationWarehouseCodes:source.dispatch.destinationWarehouseCodes,
beforeEdit:beforeEdit.data,
}
context.assert(MODULE,'排车线路目的地集合与实际托盘及本轮冻结候选完全一致',beforeEdit.response,
dispatchDestinationConservation(beforeEdit.data,source.dispatch),trace(beforeEdit.response),OPTIONS)
if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch)) return
const updateBody = {
vehiclePlate: `DEV-${context.marker.slice(-8)}`.slice(0, 64),
driverName: `五期验收司机-${context.marker.slice(-6)}`,
@@ -909,6 +936,10 @@ export async function run(context) {
const deliveries = transferDeliveries(transferred.data)
const deliveryNos = deliveryNumbers(transferred.data)
const destinationKeys = deliveries.map((value) => id(value.destinationWarehouseCode || value.destinationWarehouseId))
routeProof.deliveries = deliveries
context.assert(MODULE,'转派送目的地集合与实际排车线路及本轮冻结候选守恒',transferred.response,
dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries),trace(transferred.response),OPTIONS)
if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries)) return
const deliveryNo = deliveryNos[0] || ''
context.assert(MODULE, '转派送按目的FBA仓生成WAIT_DELIVERY单据数组', transferred.response,
nonBlank(outboundNo) && deliveries.length > 0 && new Set(deliveryNos).size === deliveries.length
@@ -3,7 +3,9 @@ import test from 'node:test'
import { readFileSync } from 'node:fs'
import { fulfillmentHistorySnapshot, validTwoFragmentTree, validSignatureGate,
completionCounts, exactCompletionOutboxProof, resolveF40MerchantCredentials,
f40TransferBody, f40LocalDateTime, validFragmentCabinetIdentity, run } from './phase5-root-fulfillment.mjs'
f40TransferBody, f40LocalDateTime, validFragmentCabinetIdentity, validUnloadedRemainder,
validRemainderCancellationGate, exactCancelledRemainderOutboxProof, cancelUnloadedRemainder,
verifyRootProjections, validFreshReceiptSave, preserveReceivingEvidence, run } from './phase5-root-fulfillment.mjs'
import { loadOneFragment } from './phase3-order-splitting.mjs'
import { verifyPalletLabels } from './phase3-loading-finalize.mjs'
import { awaitProducerConsumption, convertReceivingContainers, runReceiving,
@@ -30,6 +32,70 @@ const fixture = () => ({steps:[],token:'offline',marker:'offline-only',createdRe
addCoverageGap(){},apiSuccess(_module,_label,response){return response?.body?.data},
})
test('F40 unified projections require exact root and customer session with final COMPLETED or unfinished DELIVERED',async()=>{
for (const completed of [true,false]) {
const context=fixture(),calls=[],snapshot={root:{status:completed?'COMPLETED':'TRANSPORTING',histories:history('OVERSEAS_DELIVERED')}}
context.request=async(method,path,body,token)=>{
calls.push({method,path,token})
return {http:200,body:{code:'200',data:{summary:{orderId:'100',mainStatus:completed?'COMPLETED':'DELIVERED'}}}}
}
const result=await verifyRootProjections(context,'90','100','customer-session',snapshot)
assert.deepEqual(result.platform,result.customer)
assert.equal(calls[0].path,'/api/platform/v1/orders/100?tenantId=90')
assert.equal(calls[1].path,'/api/merchant/v1/orders/100')
assert.equal(calls[1].token,'customer-session')
assert.equal(context.steps.every(step=>step.passed),true)
}
})
test('F40 customer mismatched root or prematurely completed projection fails closed',async()=>{
for (const summary of [{orderId:'101',mainStatus:'COMPLETED'},{orderId:'100',mainStatus:'DELIVERED'}]) {
const context=fixture()
context.request=async(_method,path)=>({http:200,body:{code:'200',data:{summary:
path.startsWith('/api/platform')?{orderId:'100',mainStatus:'COMPLETED'}:summary}}})
await assert.rejects(verifyRootProjections(context,'90','100','customer-session',{root:{status:'COMPLETED',histories:[]}}),
/平台客户统一状态/)
}
})
test('F40 README documents all three independent modes and fail-closed receiving/zero child event evidence',()=>{
const readme=readFileSync(new URL('./README.md',import.meta.url),'utf8')
for(const value of ['CHILD_FIRST','ROOT_SIGNED_REMAINDER_CANCELLED','orderStopAtApproved=true',
'PROCESSING','WAIT_SPLIT','A0409','phase4ReceivingEvidenceReader','零事件子行'])assert.ok(readme.includes(value),value)
assert.match(source,/proof\.beforeReceiptReplayOutbox = await waitState/)
assert.match(source,/outboxCounts\(proof.beforeReceiptReplayOutbox\) === outboxCounts\(proof.outboxProof\)/)
assert.match(source,/validFreshReceiptSave\(beforeFresh,afterFresh\)/)
assert.match(source,/JSON.stringify\(afterFresh\) === JSON.stringify\(receiptProof.afterSameKey\)/)
})
test('fresh receipt save allows exactly one document version/revision/audit while business completion stays unchanged',()=>{
const documents={version:'2',uploadedCategoryCount:1,categories:[{category:'RECEIPT',files:[{fileId:'1'}]}],replayed:false}
const before={documents,delivery:{order:{version:'2',status:'DELIVERED',signedAt:'2026-09-30T16:00:00'},
operations:[{id:'1',operationNode:'RECEIPT_AUTO_COMPLETE'}],skus:[{packageCount:1}],fees:[]},revisions:[{version:'2'}]}
const after=structuredClone(before);after.documents.version='3';after.delivery.order.version='3'
after.delivery.operations.push({id:'2',operationNode:'SAVE_DOCUMENTS',result:'保存派送资料:1/3类',
operatedAt:'2026-09-30T16:01:00',operatorName:'platform'})
after.revisions.push({version:'3',savedAt:'2026-09-30T16:01:00',operatorId:'platform',snapshot:after.documents})
assert.equal(validFreshReceiptSave(before,after),true)
for(const mutate of [
value=>value.documents.version='4',value=>value.delivery.order.version='2',
value=>value.delivery.order.signedAt='2026-09-30T16:01:00',value=>value.delivery.order.status='RETURNED',
value=>value.documents.categories[0].files[0].fileId='2',value=>value.delivery.skus[0].packageCount=2,
value=>value.delivery.fees.push({amount:1}),value=>value.delivery.operations[0].id='999',
value=>value.delivery.operations[1].operationNode='RECEIPT_AUTO_COMPLETE',
value=>value.delivery.operations.push({...value.delivery.operations[1],id:'3'}),
value=>value.revisions.pop(),value=>value.revisions[0].version='1',
]){const bad=structuredClone(after);mutate(bad);assert.equal(validFreshReceiptSave(before,bad),false)}
})
test('F40 retains partial and failed receiving raw snapshots without exporting identity credentials',()=>{
const proof={},scope={createdResources:{phase4OverseasPda:{receivingCompletionEvidence:{}}},token:'secret'}
preserveReceivingEvidence(proof,scope)
scope.createdResources.phase4OverseasPda.receivingCompletionEvidence['20']={status:'UNPROVEN',stages:{
BEFORE_COMPLETE:{captured:true,evidence:{balances:[]}},AFTER_COMPLETE:{captured:false,evidence:{balances:[{id:'1'}]}}}}
assert.deepEqual(proof.receivingCompletionEvidence,scope.createdResources.phase4OverseasPda.receivingCompletionEvidence)
assert.equal(JSON.stringify(proof).includes('secret'),false)
assert.match(source,/finally \{ preserveReceivingEvidence\(proof,scope\);/)
assert.match(source,/proof.receiptReplayProofs \|\|= \[\]/)
})
test('F40 transfer uses the current formal Transfer DTO including required positive fee and same LocalDateTime tool',() => {
const dto=readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java',import.meta.url),'utf8')
const vehicle={carrierName:'offline-fleet',vehiclePlate:'offline-plate',driverName:'offline-driver',driverPhone:'19900000000'}
@@ -175,17 +241,141 @@ test('exported receiving warehouse resolver continues to choose only configured
]}}})
assert.deepEqual(await resolveSharedWarehouse(context),{id:'193000000000200003',name:'offline'})
})
test('complete orchestrator calls actual source chain and blocks missing Outbox evidence; no fake callback, SQL or cancellation substitute',() => {
test('complete orchestrator calls actual source chain and blocks missing Outbox evidence; no fake callback or SQL',() => {
assert.equal(typeof run,'function')
for(const name of ['orders','inbound-measurement','phase3-customs','phase3-outbound-dispatch',
'phase3-carrier-schedule-queries'])assert.ok(source.includes(`'${name}'`))
for(const name of ['loadOneFragment','verifyPalletLabels','awaitProducerConsumption',
'convertReceivingContainers','prepareTerminalDrayage','runReceiving','pickAll','saveReceiptAndComplete'])
assert.match(source,new RegExp(`await ${name}\\(`))
assert.match(source,/rootOrderId, sourceOrderIds:\[rootOrderId,childOrderId\]/)
assert.match(source,/rootOrderId,\s*sourceOrderIds:\[rootOrderId,childOrderId\]/)
assert.match(source,/HTTP_VERIFIED_OUTBOX_PENDING/)
assert.match(source,/finally \{ proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker/)
assert.doesNotMatch(source,/['"`]\/internal\/|UPDATE\s+ord_|INSERT\s+INTO|\/cancel|\/complete-delivery/)
assert.match(source,/finally \{ preserveReceivingEvidence\(proof,scope\); proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker/)
assert.doesNotMatch(source,/['"`]\/internal\/|UPDATE\s+ord_|INSERT\s+INTO|\/complete-delivery/)
})
const oneLoadedTree = () => ({...tree(),cumulativeLoadedPackageCount:1,currentRemainderPackageCount:1,
children:tree().children.slice(0,2).map(row=>({...row,loadingPlanId:'30',containerBookingId:'20'}))})
const approvedChild = () => fulfillmentHistorySnapshot({order:{id:'101',tenantId:'90',orderRole:'CHILD',
rootOrderId:'100',parentOrderId:'100',status:'APPROVED',version:1,orderNo:'R-1',inboundCode:'I-1',completedAt:null},statusHistory:[]})
const unshippedInbound = () => ({id:'301',tenantId:'90',orderId:'101',orderNo:'R-1',inboundCode:'I-1',
processStatus:'WAIT_SPLIT',orderStatusSnapshot:'APPROVED',totalPackageCount:1,containerNo:null,cabinetNo:null})
const cancelled = () => ({root:{status:'COMPLETED',histories:history('OVERSEAS_DELIVERED','FULFILLMENT_COMPLETED')},
child:{status:'CANCELLED',rawOrder:{completedAt:null},histories:history('CANCELLED')},
rootInbound:{processStatus:'DELIVERED',orderStatusSnapshot:'COMPLETED'}})
const cancellationOutbox = () => {const value=outbox();value.orders[1].count=0;value.orders[1].publishedCount=0;return value}
test('unloaded remainder requires real raw APPROVED child, exact customer/root/source and one remaining physical box',() => {
const child=approvedChild(),inbound=unshippedInbound(),tree=oneLoadedTree()
assert.equal(validUnloadedRemainder(tree,child,inbound,'90','100','101'),true)
for(const mutate of [
value => value.child.status='PROCESSING',value => value.child.status='ACCEPTED',
value => value.child.rootOrderId='999',value => value.child.parentOrderId='999',
value => value.child.rawOrder.tenantId='91',value => value.child.orderId='102',
value => value.child.histories=history('OVERSEAS_DELIVERED'),
value => value.inbound.orderId='102',value => value.inbound.tenantId='91',
value => value.inbound.inboundCode='ANOTHER',value => value.inbound.orderNo='ANOTHER',
value => value.inbound.processStatus='LOADED',value => value.inbound.totalPackageCount=2,
value => value.inbound.orderStatusSnapshot='PROCESSING',value => value.inbound.containerNo='ACTUAL-CABINET',
value => value.inbound.cabinetNo='ACTUAL-CABINET',value => value.tree.children.push(tree.children[0]),
value => value.tree.children[0].sourceOrderId='101',value => value.tree.children[1].loadingPlanId='31',
value => value.tree.children[1].containerBookingId='21',value => value.tree.currentRemainderPackageCount=0,
]) {
const value={tree:oneLoadedTree(),child:approvedChild(),inbound:unshippedInbound()};mutate(value)
assert.equal(validUnloadedRemainder(value.tree,value.child,value.inbound,'90','100','101'),false)
}
})
test('cancelled remainder cannot substitute for child signature in either full-signature mode',() => {
assert.equal(validRemainderCancellationGate(cancelled()),true)
assert.equal(validSignatureGate(cancelled(),'MASTER',true),false)
assert.equal(validSignatureGate(cancelled(),'CHILD',true),false)
for(const mutate of [
value=>value.root.histories=history('FULFILLMENT_COMPLETED'),
value=>value.root.histories.push({action:'FULFILLMENT_COMPLETED'}),
value=>value.child.status='COMPLETED',value=>value.child.histories.push({action:'OVERSEAS_DELIVERED'}),
value=>value.child.histories.push({action:'CANCELLED'}),value=>value.child.rawOrder.completedAt='2026-09-30T01:00:00',
value=>value.rootInbound.orderStatusSnapshot='APPROVED',value=>value.rootInbound.processStatus='RECEIVED',
]){const value=cancelled();mutate(value);assert.equal(validRemainderCancellationGate(value),false)}
})
test('cancellation Outbox requires exactly one published root completion and explicit zero child completion counts',() => {
assert.equal(exactCancelledRemainderOutboxProof(cancellationOutbox(),'90','100','101'),true)
assert.equal(exactCompletionOutboxProof(cancellationOutbox(),'90','100',['100','101']),false)
for(const mutate of [
value=>value.orders.pop(),value=>value.orders[1].count=1,value=>value.orders[1].publishedCount=1,
value=>value.orders[1].pendingCount=1,value=>value.orders[1].failedCount=1,
value=>value.orders[1].count=null,value=>delete value.orders[1].failedCount,
value=>value.orders[1].rootOrderId='999',value=>value.orders[1].customerId='91',
value=>value.orders[1].orderId='100',value=>value.orders[1].eventType='ORDER_CANCELLED',
value=>value.orders[0].count=2,value=>value.orders[0].publishedCount=0,value=>value.readOnly=false,
]){const value=cancellationOutbox();mutate(value);assert.equal(exactCancelledRemainderOutboxProof(value,'90','100','101'),false)}
})
function cancellationFixture(change=null) {
const context=fixture(),calls=[],proof={snapshots:[]}
const rootDetail={order:{id:'100',tenantId:'90',orderRole:'MASTER',rootOrderId:'100',status:'APPROVED',
version:1,orderNo:'R',inboundCode:'I',completedAt:null},statusHistory:history('OVERSEAS_DELIVERED')}
const childDetail={order:approvedChild().rawOrder,statusHistory:[]}
const rootInbound={summary:{processStatus:'DELIVERED',orderStatusSnapshot:'APPROVED'}}
const childInbound={summary:unshippedInbound()},tree=oneLoadedTree()
let count=0
context.context={f40BaseMarker:'offline-only',f40ReadOnlyOutboxEvidence:async args=>{
assert.deepEqual(args,{customerId:'90',rootOrderId:'100',sourceOrderIds:['100','101'],
eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'})
return cancellationOutbox()
}}
if(change)change({rootDetail,childDetail,childInbound,tree})
context.request=async(method,path,body)=>{
calls.push({method,path,body})
if(method==='POST'){
assert.equal(path,'/api/platform/v1/orders/first-leg/101/cancel?tenantId=90')
assert.ok(body.reason.includes('offline-only'))
if(count++)return {http:200,body:{code:'A0409'}}
childDetail.order.status='CANCELLED';childDetail.order.version++
childDetail.statusHistory=history('CANCELLED')
rootDetail.order.status='COMPLETED';rootDetail.order.version++
rootDetail.order.completedAt='2026-09-30T01:00:00'
rootDetail.statusHistory=history('OVERSEAS_DELIVERED','FULFILLMENT_COMPLETED')
rootInbound.summary.orderStatusSnapshot='COMPLETED'
return {http:200,body:{code:'200',data:null}}
}
let data
if(path.includes('/fulfillment-tree?'))data=tree
else if(path.includes('/orders/first-leg/100?'))data=rootDetail
else if(path.includes('/orders/first-leg/101?'))data=childDetail
else if(path.includes('/inbounds/300?'))data=rootInbound
else if(path.includes('/inbounds/301?'))data=childInbound
else throw new Error(`unexpected offline request ${path}`)
return {http:200,body:{code:'200',data:structuredClone(data)}}
}
return {context,proof,calls}
}
test('formal cancellation helper rereads physical eligibility, uses public cancel and accepts replay rejection only with unchanged facts and Outbox',async()=>{
const {context,proof,calls}=cancellationFixture()
const result=await cancelUnloadedRemainder(context,proof,'90','100','101','300','301')
assert.equal(validRemainderCancellationGate(result),true)
assert.equal(calls.filter(row=>row.method==='POST').length,2)
assert.equal(proof.snapshots.length,3)
assert.deepEqual(proof.cancelReplayOutbox.before,proof.cancelReplayOutbox.after)
assert.equal(context.steps.every(step=>step.passed),true)
})
test('formal cancellation helper stops before any mutation for PROCESSING, unsigned root or already loaded child',async()=>{
for(const change of [value=>value.childDetail.order.status='PROCESSING',
value=>value.rootDetail.order.status='PROCESSING',value=>value.rootDetail.statusHistory=[],
value=>value.childInbound.summary.processStatus='LOADED',value=>value.tree.children.push(tree().children[2])]) {
const {context,proof,calls}=cancellationFixture(change)
await assert.rejects(cancelUnloadedRemainder(context,proof,'90','100','101','300','301'),/仅取消真实签收根下未装 APPROVED/)
assert.equal(calls.some(row=>row.method!=='GET'),false)
}
})
test('cancel scenario uses new approved-only order and exactly root shipment; both existing full-signature modes remain independent',()=>{
assert.match(source,/const cancelRemainder = signatureOrder === 'ROOT_SIGNED_REMAINDER_CANCELLED'/)
assert.match(source,/orderStopAtApproved:cancelRemainder/)
assert.match(source,/approvedRoot.status === 'APPROVED'/)
assert.match(source,/if \(cancelRemainder\) \{[\s\S]*?proof.unloadedRemainder[\s\S]*?\} else \{[\s\S]*?second = await fragment/)
assert.match(source,/const producers = cancelRemainder \? \[first\] : \[first,second\]/)
assert.match(source,/signatureOrder === 'CHILD_FIRST' \? \[second,first\] : producers/)
assert.match(source,/if \(cancelRemainder\) await cancelUnloadedRemainder/)
const orderSource=readFileSync(new URL('./orders.mjs',import.meta.url),'utf8')
assert.match(orderSource,/context.context\?\.orderStopAtApproved === true/)
})
test('early F40 failures become FAILED with only a safe stage and preserve the original exception',async() => {
const context=fixture();context.context={};context.options={}
@@ -15,6 +15,36 @@ const success = response => response?.http === 200 && String(response?.body?.cod
const query = (path, values) => `${path}?${new URLSearchParams(values)}`
export function f40LocalDateTime(value) { return value.toISOString().slice(0,19) }
export function preserveReceivingEvidence(proof, scope) {
proof.receivingCompletionEvidence = scope.createdResources?.phase4OverseasPda?.receivingCompletionEvidence || {}
}
export function validFreshReceiptSave(before, after) {
const previousVersion = Number(before?.documents?.version)
const nextVersion = Number(after?.documents?.version)
if (!Number.isSafeInteger(previousVersion) || nextVersion !== previousVersion + 1
|| Number(before?.delivery?.order?.version) !== previousVersion
|| Number(after?.delivery?.order?.version) !== nextVersion
|| before?.delivery?.order?.status !== 'DELIVERED' || !before.delivery.order.signedAt) return false
const same = (left,right) => JSON.stringify(left) === JSON.stringify(right)
if (!same(before.documents,{...after.documents,version:before.documents.version})) return false
const oldOperations = before.delivery.operations, newOperations = after.delivery.operations
if (!Array.isArray(oldOperations) || !Array.isArray(newOperations)
|| newOperations.length !== oldOperations.length + 1) return false
const oldIds = new Set(oldOperations.map(row => id(row.id)))
const added = newOperations.filter(row => !oldIds.has(id(row.id)))
if (added.length !== 1 || !id(added[0].id) || added[0].operationNode !== 'SAVE_DOCUMENTS'
|| added[0].result !== `保存派送资料:${after.documents.uploadedCategoryCount}/3类`
|| !added[0].operatedAt || !added[0].operatorName
|| !same(oldOperations,newOperations.filter(row => oldIds.has(id(row.id))))) return false
if (!same(before.delivery,{...after.delivery,order:{...after.delivery.order,version:before.delivery.order.version},
operations:oldOperations})) return false
if (!Array.isArray(before.revisions) || !Array.isArray(after.revisions)
|| after.revisions.length !== before.revisions.length + 1) return false
const addedRevisions = after.revisions.filter(row => Number(row.version) === nextVersion)
return addedRevisions.length === 1 && Boolean(addedRevisions[0].savedAt && addedRevisions[0].operatorId)
&& same(before.revisions,after.revisions.filter(row => Number(row.version) !== nextVersion))
&& same(addedRevisions[0].snapshot,after.documents)
}
export function resolveF40MerchantCredentials(context) {
const fixture = context.context?.merchantQualificationFixture
if (!fixture) {
@@ -103,6 +133,56 @@ export function exactCompletionOutboxProof(evidence, customerId, rootOrderId, so
&& Number(row.count) === 1 && Number(row.publishedCount) === 1 && Number(row.pendingCount) === 0
&& Number(row.failedCount) === 0)
}
export function validUnloadedRemainder(tree, child, inbound, customerId, rootOrderId, childOrderId) {
const rows = Array.isArray(tree?.children) ? tree.children : []
const loaded = rows.filter(row => row.splitRole === 'LOADED')
const remainder = rows.filter(row => row.splitRole === 'REMAINDER')
return Boolean(id(rootOrderId) && id(childOrderId) && id(rootOrderId) !== id(childOrderId))
&& id(tree?.parentOrderId) === id(rootOrderId) && Number(tree.originalPackageCount) === 2
&& Number(tree.cumulativeLoadedPackageCount) === 1 && Number(tree.currentRemainderPackageCount) === 1
&& rows.length === 2 && loaded.length === 1 && remainder.length === 1
&& id(loaded[0].orderId) === id(rootOrderId) && id(loaded[0].sourceOrderId) === id(rootOrderId)
&& Number(loaded[0].packageCount) === 1
&& id(remainder[0].orderId) === id(childOrderId) && id(remainder[0].sourceOrderId) === id(rootOrderId)
&& Number(remainder[0].packageCount) === 1
&& id(remainder[0].loadingPlanId) === id(loaded[0].loadingPlanId)
&& id(remainder[0].containerBookingId) === id(loaded[0].containerBookingId)
&& Boolean(id(loaded[0].loadingPlanId) && id(loaded[0].containerBookingId))
&& child?.role === 'CHILD' && child.status === 'APPROVED'
&& child.orderId === id(childOrderId) && child.rootOrderId === id(rootOrderId)
&& child.parentOrderId === id(rootOrderId) && id(child.rawOrder?.tenantId) === id(customerId)
&& completionCounts(child).own === 0 && completionCounts(child).final === 0
&& Boolean(id(inbound?.id) && inbound?.inboundCode)
&& id(inbound.orderId) === id(childOrderId) && id(inbound.tenantId) === id(customerId)
&& inbound.inboundCode === child.inboundCode && inbound.orderNo === child.orderNo
&& inbound.processStatus === 'WAIT_SPLIT' && inbound.orderStatusSnapshot === 'APPROVED'
&& Number(inbound.totalPackageCount) === 1 && !inbound.containerNo && !inbound.cabinetNo
}
export function validRemainderCancellationGate(snapshot) {
const root = completionCounts(snapshot?.root), child = completionCounts(snapshot?.child)
return snapshot?.root?.status === 'COMPLETED' && snapshot?.child?.status === 'CANCELLED'
&& root.own === 1 && root.final === 1 && child.own === 0 && child.final === 0
&& snapshot.child.histories.filter(row => row.action === 'CANCELLED').length === 1
&& snapshot.child.rawOrder?.completedAt == null
&& snapshot?.rootInbound?.processStatus === 'DELIVERED'
&& snapshot?.rootInbound?.orderStatusSnapshot === 'COMPLETED'
}
export function exactCancelledRemainderOutboxProof(evidence, customerId, rootOrderId, childOrderId) {
const rows = Array.isArray(evidence?.orders) ? evidence.orders : []
const root = rows.filter(row => id(row.orderId) === id(rootOrderId))
const child = rows.filter(row => id(row.orderId) === id(childOrderId))
return id(rootOrderId) !== id(childOrderId) && rows.length === 2 && root.length === 1 && child.length === 1
&& exactCompletionOutboxProof({...evidence,orders:root},customerId,rootOrderId,[rootOrderId])
&& id(child[0].customerId) === id(customerId) && id(child[0].rootOrderId) === id(rootOrderId)
&& child[0].eventType === 'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'
&& ['count','publishedCount','pendingCount','failedCount'].every(field => child[0][field] != null && Number(child[0][field]) === 0)
}
function outboxCounts(evidence) {
return JSON.stringify((evidence?.orders || []).map(row => ({
orderId:id(row.orderId),customerId:id(row.customerId),rootOrderId:id(row.rootOrderId),eventType:row.eventType,
count:Number(row.count),publishedCount:Number(row.publishedCount),pendingCount:Number(row.pendingCount),failedCount:Number(row.failedCount),
})).sort((left,right) => left.orderId.localeCompare(right.orderId)))
}
function check(context, label, condition, evidence, response = null) {
context.assert(MODULE, label, response, condition, evidence, OPTIONS)
if (!condition) throw new Error(`${MODULE}: ${label}: ${evidence}`)
@@ -138,6 +218,74 @@ async function waitState(read, predicate, attempts = 90) {
}
return last
}
export async function verifyRootProjections(context, customerId, rootOrderId, merchantToken, snapshot) {
const completed = snapshot.root.status === 'COMPLETED'
const ownSigned = completionCounts(snapshot.root).own === 1
const valid = value => id(value?.summary?.orderId) === id(rootOrderId)
&& (completed ? value.summary.mainStatus === 'COMPLETED'
: ownSigned ? value.summary.mainStatus === 'DELIVERED'
: Boolean(value.summary.mainStatus) && value.summary.mainStatus !== 'COMPLETED')
const platform = await waitState(() => api(context,'GET','F40 平台统一根订单状态',
query(`/api/platform/v1/orders/${rootOrderId}`,{tenantId:customerId})),valid)
const customer = await api(context,'GET','F40 本企业客户统一根订单状态',
`/api/merchant/v1/orders/${rootOrderId}`,undefined,merchantToken)
check(context,'F40 平台客户统一状态与原始根完成门禁一致',valid(platform) && valid(customer)
&& customer.summary.mainStatus === platform.summary.mainStatus,'exact-root-platform-customer-status')
return {platform:platform.summary,customer:customer.summary}
}
export async function cancelUnloadedRemainder(context, proof, customerId, rootOrderId, childOrderId,
rootInboundId, childInboundId) {
proof.currentStage = 'REMAINDER_CANCEL_PRECONDITION'
const before = await state(context,customerId,rootOrderId,childOrderId,rootInboundId)
const treePath = query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`,{tenantId:customerId})
const tree = await api(context,'GET','取消前重新核对唯一未消费余量履约树',treePath)
const childInbound = await api(context,'GET','取消前回查未装余量入仓来源',
query(`/api/platform/inbounds/${childInboundId}`,{tenantId:customerId}))
check(context,'F40 仅取消真实签收根下未装 APPROVED 一箱余量', before.root.status === 'APPROVED'
&& validSignatureGate(before,'MASTER')
&& validUnloadedRemainder(tree,before.child,childInbound?.summary,customerId,rootOrderId,childOrderId),
'raw-approved-root/child; one-loaded-root/one-unloaded-remainder')
proof.snapshots.push({phase:'BEFORE_REMAINDER_CANCELLATION',...before,tree,childInbound:childInbound.summary})
const cancelPath = query(`/api/platform/v1/orders/first-leg/${childOrderId}/cancel`,{tenantId:customerId})
const cancelBody = {reason:`${context.context.f40BaseMarker} 未装柜余量取消验收`}
proof.currentStage = 'REMAINDER_CANCELLATION'
await api(context,'POST','正式取消未装柜 APPROVED 余量订单',cancelPath,cancelBody)
const after = await waitState(() => state(context,customerId,rootOrderId,childOrderId,rootInboundId),
validRemainderCancellationGate)
check(context,'F40 最后有效未装余量取消后根仅完成一次且子未签收',validRemainderCancellationGate(after),
'root-own=1/root-final=1/child-cancel=1/child-signature=0')
const afterTree = await api(context,'GET','取消后保留真实分片数量台账',treePath)
check(context,'F40 取消没有伪造第二柜实装或改写分片数量',
JSON.stringify(tree.children) === JSON.stringify(afterTree.children)
&& Number(afterTree.cumulativeLoadedPackageCount) === 1 && Number(afterTree.currentRemainderPackageCount) === 1,
'ledger-retains-one-physical-box-and-one-cancelled-remainder')
proof.snapshots.push({phase:'ROOT_SIGNED_REMAINDER_CANCELLED',...after,tree:afterTree})
proof.currentStage = 'REMAINDER_CANCEL_REPLAY'
const reader = context.context?.f40ReadOnlyOutboxEvidence
const request = {customerId,rootOrderId,sourceOrderIds:[rootOrderId,childOrderId],
eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'}
const beforeOutbox = typeof reader === 'function' ? await waitState(() => reader(request),
value => exactCancelledRemainderOutboxProof(value,customerId,rootOrderId,childOrderId),30) : null
if (beforeOutbox) check(context,'F40 取消后唯一根完成Outbox且取消子完成Outbox为零',
exactCancelledRemainderOutboxProof(beforeOutbox,customerId,rootOrderId,childOrderId),'exact-root-one/cancelled-child-zero')
const replay = await context.request('POST',cancelPath,cancelBody)
check(context,'F40 取消重放按当前非幂等状态接口返回 A0409',
replay?.http === 200 && String(replay?.body?.code) === 'A0409','cancel-replay-conflict',replay)
const stable = await state(context,customerId,rootOrderId,childOrderId,rootInboundId)
const replayTree = await api(context,'GET','取消重放后回查原履约树',treePath)
check(context,'F40 取消重放订单版本历史根摘要及履约台账零变化',
JSON.stringify(after) === JSON.stringify(stable) && JSON.stringify(afterTree) === JSON.stringify(replayTree),
'exact-root/child/history/summary/tree')
if (beforeOutbox) {
const afterOutbox = await reader(request)
check(context,'F40 取消重放完成Outbox计数发布状态零变化',
exactCancelledRemainderOutboxProof(afterOutbox,customerId,rootOrderId,childOrderId)
&& outboxCounts(beforeOutbox) === outboxCounts(afterOutbox),'exact-root-child-completion-counts')
proof.cancelReplayOutbox = {before:beforeOutbox,after:afterOutbox}
}
proof.snapshots.push({phase:'AFTER_CANCELLATION_REPLAY',...stable,tree:replayTree})
return stable
}
async function domesticLogin(context) {
if (context.pdaToken) return
const username = context.options.pdaUsername, password = context.options.pdaPassword
@@ -242,7 +390,7 @@ async function sendFragment(context, producer, picker, merchantToken, index) {
check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`)
check(context,'F40 签收完成记录真实签收时间', Boolean(completed.order.signedAt), `delivery=${deliveries[0].deliveryNo}`)
return { producer, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: deliveries[0].deliveryNo,
receiptVersion: completed.order.version, signedAt: completed.order.signedAt }
receiptVersion: completed.order.version, signedAt: completed.order.signedAt, receiptSnapshot:completed }
}
/** Explicit TEST orchestrator, called by the parent acceptance context; never synthesizes an event. */
@@ -266,11 +414,14 @@ async function runRootFulfillment(context) {
cleanup: 'Fresh completed root/child/cabinet/PDA/receipt audit resources remain as acceptance evidence',
}
const signatureOrder = context.context?.f40SignatureOrder || 'ROOT_FIRST'
check(context,'F40 签收次序为显式正式来源顺序', ['ROOT_FIRST','CHILD_FIRST'].includes(signatureOrder), signatureOrder)
check(context,'F40 签收次序为显式正式来源顺序',
['ROOT_FIRST','CHILD_FIRST','ROOT_SIGNED_REMAINDER_CANCELLED'].includes(signatureOrder), signatureOrder)
const cancelRemainder = signatureOrder === 'ROOT_SIGNED_REMAINDER_CANCELLED'
proof.signatureOrder = signatureOrder
const scope = Object.create(context)
scope.marker = `${context.marker}-F40`
scope.context = { ...context.context, orderDestinationType: 'FBA', orderFulfillmentType: 'STANDARD',
orderStopAtApproved:cancelRemainder,
orderProfile: { taxMode: 'BUY_DECLARATION', fulfillmentType: 'STANDARD' },
waitSplitVariant: { skuCount: 1, packageCountPerSku: 2, packageCounts: [2] },
phase4OverseasPdaOperations: { completedFixtures: [] },
@@ -292,6 +443,14 @@ async function runRootFulfillment(context) {
const orderFixture = { ...scope.createdResources.orders }
check(scope,'F40 正式订单创建客户与本轮登录客户一致', id(orderFixture.tenantId) === expectedCustomerId,
'actual-order-customer-matches-profile')
if (cancelRemainder) {
const approvedRoot = fulfillmentHistorySnapshot(await api(scope,'GET','取消专项回查新根真实审核状态',
query(`/api/platform/v1/orders/first-leg/${orderFixture.orderId}`,{tenantId:expectedCustomerId})))
check(scope,'F40 取消专项新根必须为原始 APPROVED 而非快照推断',approvedRoot.role === 'MASTER'
&& approvedRoot.status === 'APPROVED' && approvedRoot.orderId === id(orderFixture.orderId)
&& id(approvedRoot.rawOrder?.tenantId) === expectedCustomerId,'raw-approved-fresh-root')
proof.snapshots.push({phase:'APPROVED_NEW_ROOT',root:approvedRoot})
}
proof.currentStage = 'DOMESTIC_MEASUREMENT'
await stage(scope, 'inbound-measurement')
const rootOrderId = id(orderFixture.orderId), customerId = id(orderFixture.tenantId)
@@ -323,15 +482,26 @@ async function runRootFulfillment(context) {
check(scope, 'F40 子单有唯一独立真实入仓来源', childRows.length === 1, `sources=${childRows.length}`)
const childInbound = await api(scope, 'GET', '读取子单可装柜来源',
query(`/api/platform/inbounds/${childRows[0].id}`, { tenantId: customerId }))
scope.marker = `${scope.context.f40BaseMarker}-CHILD`
proof.currentStage = 'CHILD_LOAD_AND_SHIP'
const second = await fragment(scope, childInbound, rootOrderId, 1)
proof.fragments.push(second)
let second = null
if (cancelRemainder) {
check(scope,'F40 取消专项真实余量未装且原始 APPROVED',
validUnloadedRemainder(tree,fulfillmentHistorySnapshot(childDetail),childInbound?.summary,
customerId,rootOrderId,childOrderId),'one-approved-unloaded-remainder')
proof.unloadedRemainder = {childOrderId,childInboundId:id(childRows[0].id),tree,summary:childInbound.summary}
} else {
scope.marker = `${scope.context.f40BaseMarker}-CHILD`
proof.currentStage = 'CHILD_LOAD_AND_SHIP'
second = await fragment(scope, childInbound, rootOrderId, 1)
proof.fragments.push(second)
}
proof.currentStage = 'FULFILLMENT_TREE'
const finalTree = await api(scope, 'GET', '两柜装完后核对数量守恒履约树',
query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId }))
check(scope, 'F40 真实同根两个一箱来源和柜次数量守恒', validTwoFragmentTree(finalTree, rootOrderId, childOrderId)
&& first.containerBookingId !== second.containerBookingId, `root=${rootOrderId}, child=${childOrderId}`)
check(scope, 'F40 真实同根实装及余量数量守恒', cancelRemainder
? validUnloadedRemainder(finalTree,fulfillmentHistorySnapshot(childDetail),childInbound?.summary,
customerId,rootOrderId,childOrderId)
: validTwoFragmentTree(finalTree, rootOrderId, childOrderId)
&& first.containerBookingId !== second.containerBookingId, `root=${rootOrderId}, child=${childOrderId}`)
const before = await state(scope, customerId, rootOrderId, childOrderId, first.inboundId)
check(scope, 'F40 尚未签收两片不得出现完成事实', before.root.role === 'MASTER'
&& id(before.root.rootOrderId) === rootOrderId && before.root.status !== 'COMPLETED'
@@ -346,14 +516,17 @@ async function runRootFulfillment(context) {
check(scope, 'F40 海外PDA操作员会话有效', Boolean(picker?.token), 'tokenPresent')
picker.warehouseId = warehouse.id
check(scope, 'F40 客户会话属于该根订单客户', id(profile.id) === customerId, 'customerOwned')
scope.createdResources.phase4InboundProducerFixtures = { marker: scope.marker, containers:[first,second] }
const producers = cancelRemainder ? [first] : [first,second]
scope.createdResources.phase4InboundProducerFixtures = { marker: scope.marker, containers:producers }
scope.createdResources.phase4OverseasPda = { marker:scope.marker, generatedPalletsByInbound:{},
palletIds:[],locationIds:[],completedContainerFacts:[], completedContainers:[],
overseasWarehouseId:warehouse.id, overseasWarehouseName:warehouse.name, customerId, finalStatus:null }
overseasWarehouseId:warehouse.id, overseasWarehouseName:warehouse.name, customerId, finalStatus:null,
receivingCompletionEvidence:{} }
preserveReceivingEvidence(proof,scope)
proof.currentStage = 'OVERSEAS_PLAN_CONVERSION'
check(scope, 'F40 两柜真实装柜事件已消费', await awaitProducerConsumption(scope,[first,second]), 'two-producers-consumed')
check(scope, 'F40 两柜真实计划及托盘转换成功', await convertReceivingContainers(scope,[first,second]), 'two-containers-converted')
const signatureSources = signatureOrder === 'CHILD_FIRST' ? [second,first] : [first,second]
check(scope, 'F40 实装来源真实装柜事件已消费', await awaitProducerConsumption(scope,producers), `producers=${producers.length}`)
check(scope, 'F40 实装来源真实计划及托盘转换成功', await convertReceivingContainers(scope,producers), `producers=${producers.length}`)
const signatureSources = signatureOrder === 'CHILD_FIRST' ? [second,first] : producers
for (const [index, producer] of signatureSources.entries()) {
proof.currentStage = `OVERSEAS_RECEIVING_${index+1}`
scope.marker = `${scope.context.f40BaseMarker}-RECEIVE-${index}`
@@ -376,12 +549,27 @@ async function runRootFulfillment(context) {
proof.snapshots.push({ phase:index === 0 ? 'ONE_SIGNED_ROOT_PENDING' : 'ALL_SIGNED', ...observed })
check(scope,index === 0 ? 'F40 第一片签收根仍未完成' : 'F40 最后一片签收才完成根及来源摘要且各历史只一次',
validSignatureGate(observed, signatureOrder === 'CHILD_FIRST' ? 'CHILD' : 'MASTER', index === 1), signatureOrder)
proof.snapshots.at(-1).projections = await verifyRootProjections(scope,customerId,rootOrderId,login.accessToken,observed)
}
if (cancelRemainder) await cancelUnloadedRemainder(scope,proof,customerId,rootOrderId,childOrderId,
first.inboundId,id(childRows[0].id))
proof.currentStage = 'RECEIPT_REPLAY'
const stable = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId)
proof.finalProjections = await verifyRootProjections(scope,customerId,rootOrderId,login.accessToken,stable)
const finalSignedAt = proof.signatures.map(row => row.signedAt).sort().at(-1)
check(scope,'F40 根完成时间为实际两片签收时间最大值', stable.root.rawOrder?.completedAt === finalSignedAt,
check(scope,'F40 根完成时间为实际有效来源签收时间最大值', stable.root.rawOrder?.completedAt === finalSignedAt,
`completedAt=${stable.root.rawOrder?.completedAt}, maxSignedAt=${finalSignedAt}`)
const reader = context.context?.f40ReadOnlyOutboxEvidence
const outboxRequest = {customerId,rootOrderId,sourceOrderIds:[rootOrderId,childOrderId],
eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'}
const validOutbox = evidence => cancelRemainder
? exactCancelledRemainderOutboxProof(evidence,customerId,rootOrderId,childOrderId)
: exactCompletionOutboxProof(evidence,customerId,rootOrderId,[rootOrderId,childOrderId])
if (typeof reader === 'function') {
proof.beforeReceiptReplayOutbox = await waitState(() => reader(outboxRequest),validOutbox)
check(scope,'F40 重复签收前完成事件各实际来源唯一发布',validOutbox(proof.beforeReceiptReplayOutbox),
'exact-source-before-receipt-replay')
}
// Existing receipt helper replays the exact original save command before each signature observation.
// Re-saving completed receipts with a fresh business key is also a no-transition operation.
for (const [index,signed] of proof.signatures.entries()) {
@@ -390,21 +578,48 @@ async function runRootFulfillment(context) {
query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}/documents`,{customerId}))
const documents = (current.categories || []).filter(row => row.files?.length).map(row => ({category:row.category,
fileIds:row.files.map(file => id(file.fileId))}))
await api(scope,'PUT','重复已完成派送文件保存不得重发签收事实',
const documentPath = query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}/documents`,{customerId})
const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}`,{customerId})
const historyPath = query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}/documents/history`,{customerId})
const readReceipt = async target => {
target.documents = await api(scope,'GET','F40 回查签收资料完整绑定',documentPath)
target.delivery = await api(scope,'GET','F40 回查签收派送完整事实',deliveryPath)
target.revisions = await api(scope,'GET','F40 回查签收资料完整版本历史',historyPath)
return target
}
const beforeFresh = {documents:current,delivery:await api(scope,'GET','F40 新键前冻结派送业务与操作事实',deliveryPath),
revisions:await api(scope,'GET','F40 新键前冻结资料版本历史',historyPath)}
check(scope,'F40 新键前原签收事实保持完整稳定',
JSON.stringify(signed.receiptSnapshot) === JSON.stringify(beforeFresh.delivery),`delivery=${signed.deliveryNo}`)
const saveBody = {expectedVersion:Number(current.version),idempotencyKey:`${scope.marker}-SAVE`,bolNo:current.bolNo || null,documents}
const receiptProof = {deliveryNo:signed.deliveryNo,beforeFresh,saveBody,afterFresh:{},afterSameKey:{}}
;(proof.receiptReplayProofs ||= []).push(receiptProof)
const saved = await api(scope,'PUT','重复已完成派送文件保存不得重发签收事实',
query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}/documents`,{customerId}),
{expectedVersion:Number(current.version),idempotencyKey:`${scope.marker}-SAVE`,bolNo:current.bolNo || null,documents})
saveBody)
receiptProof.saved = saved
const afterFresh = await readReceipt(receiptProof.afterFresh)
check(scope,'F40 新键资料保存仅递增一次版本及资料审计不得重复完成',
saved.replayed === false && validFreshReceiptSave(beforeFresh,afterFresh),`delivery=${signed.deliveryNo}`)
const sameKeyReplay = await api(scope,'PUT','F40 新资料命令原键原请求重放',documentPath,saveBody)
receiptProof.sameKeyReplay = sameKeyReplay
await readReceipt(receiptProof.afterSameKey)
check(scope,'F40 新资料命令原键重放资料版本历史派送业务及操作零漂移',
sameKeyReplay.replayed === true && JSON.stringify({...sameKeyReplay,replayed:false}) === JSON.stringify(saved)
&& JSON.stringify(afterFresh) === JSON.stringify(receiptProof.afterSameKey),`delivery=${signed.deliveryNo}`)
}
const afterReplay = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId)
check(scope,'F40 已完成签收重放根子订单状态版本和历史零新增',
JSON.stringify(stable) === JSON.stringify(afterReplay),'exact-root-child-inbound-snapshot')
proof.snapshots.push({phase:'AFTER_RECEIPT_REPLAY',...afterReplay})
proof.currentStage = 'READ_ONLY_OUTBOX_EVIDENCE'
const reader = context.context?.f40ReadOnlyOutboxEvidence
if (typeof reader === 'function') {
proof.outboxProof = await reader({customerId, rootOrderId, sourceOrderIds:[rootOrderId,childOrderId],
eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'})
proof.outboxProof = await reader(outboxRequest)
check(scope,'F40 每个实际来源完成Outbox唯一并已发布',
exactCompletionOutboxProof(proof.outboxProof,customerId,rootOrderId,[rootOrderId,childOrderId]),'exact-root/source/customer/readOnly-proof')
validOutbox(proof.outboxProof),
'exact-root/source/customer/readOnly-proof')
check(scope,'F40 新键重复签收完成Outbox数量与发布状态零漂移',
outboxCounts(proof.beforeReceiptReplayOutbox) === outboxCounts(proof.outboxProof),'before-after-receipt-replay-outbox')
proof.finalStatus='VERIFIED'
} else {
proof.finalStatus='HTTP_VERIFIED_OUTBOX_PENDING'
@@ -417,7 +632,7 @@ async function runRootFulfillment(context) {
proof.finalStatus = 'FAILED'
proof.failedStage = proof.currentStage
throw error
} finally { proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker(scope,picker,7) }
} finally { preserveReceivingEvidence(proof,scope); proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker(scope,picker,7) }
if (proof.finalStatus === 'VERIFIED') {
const failedSteps = (context.steps || []).slice(initialStepCount).filter(row => row.passed === false)
if (failedSteps.length) proof.finalStatus = 'FAILED'
+1 -1
View File
@@ -903,7 +903,7 @@ Assert-Contains $backendAgents '各端共用 Gateway 和 Redis 会话' '后端
$unifiedCommunication = Read-RequiredText (Join-Path $workspaceRoot 'docs/统一认证与服务通信契约.md')
Assert-Contains $unifiedCommunication 'JWT 不携带角色或权限集合' '统一 JWT 最小身份边界'
Assert-Contains $unifiedCommunication '平台/客户/PDA' '统一认证访问域隔离'
Assert-Contains $unifiedCommunication '整体迁移尚未完成' '认证目标与落地状态分离'
Assert-Matches $unifiedCommunication '整体迁移(尚未完成|验收尚未关闭)' '认证目标与落地状态分离'
Assert-Contains $financeEventConsumer 'envelope.payloadHash().equals(row.get("payload_hash"))' 'Finance 海外事件 Payload 哈希幂等'
Assert-Contains $financeEventConsumer "cost_origin='BUSINESS_DIRECT_PAID'" 'Finance 海外直接已付成本投影'
Assert-Contains $financePhase1Service "f.settlement_mode='ON_ACCOUNT'" 'Finance AP 只纳入账期应付'