优化
This commit is contained in:
@@ -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
@@ -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;不得引入租户管理或切换。
|
||||
|
||||
## 模块入口
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
@@ -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
@@ -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` |
|
||||
|
||||
@@ -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、过期/错误签名和身份头伪造测试。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 只纳入账期应付'
|
||||
|
||||
Reference in New Issue
Block a user