From 32fb9ec7b35e434d11023e15ccd57ca6dd4730f5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 30 Sep 2026 17:13:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +- auth/AGENTS.md | 2 +- common-web/AGENTS.md | 4 +- event-bridge-service/AGENTS.md | 4 +- finance/AGENTS.md | 2 +- firstleg/AGENTS.md | 2 +- gateway-overseas/AGENTS.md | 6 +- lastleg-service/AGENTS.md | 5 +- .../service/OrderApplicationService.java | 22 +- .../OrderApplicationServiceStateTest.java | 112 ++++++++ scripts/admin-test-env-modules/README.md | 8 +- ...easurement-pallet-freeze-contract.test.mjs | 37 +++ .../inbound-measurement.mjs | 118 +++++++- ...rders-cargo-requirements-contract.test.mjs | 34 +++ .../orders-contract.test.mjs | 65 +++++ .../orders-pricing-fixture-contract.test.mjs | 11 + scripts/admin-test-env-modules/orders.mjs | 116 ++++++-- ...port-schedules-retention-contract.test.mjs | 83 ++++++ .../phase3-port-schedules.mjs | 75 +++++ .../phase4-overseas-pda-contract.test.mjs | 136 ++++++++- .../phase4-overseas-pda.mjs | 193 ++++++++++++- ...se5-overseas-fulfillment-contract.test.mjs | 25 +- .../phase5-overseas-fulfillment.mjs | 31 +++ .../phase5-root-fulfillment-contract.test.mjs | 200 +++++++++++++- .../phase5-root-fulfillment.mjs | 257 ++++++++++++++++-- scripts/agents-index-contract.ps1 | 2 +- 26 files changed, 1484 insertions(+), 70 deletions(-) create mode 100644 scripts/admin-test-env-modules/inbound-measurement-pallet-freeze-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/orders-cargo-requirements-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/orders-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase3-port-schedules-retention-contract.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 78460ce9e..28b316cf6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 和目录结构文档,已知单模块任务不增加概览文档。 | 任务关键词 | 首选服务 | |---|---| diff --git a/auth/AGENTS.md b/auth/AGENTS.md index 2fc5501e1..5decc247f 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -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;不得引入租户管理或切换。 ## 模块入口 diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index edc326106..3820a631b 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -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。 diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index 890bd77e2..85e616a0d 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -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`、成功、失败、租约、最大次数和人工补投。 diff --git a/finance/AGENTS.md b/finance/AGENTS.md index cffd9fd56..fdb7f6c70 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -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 通过内部接口读取或消费快照,不跨库查询,也不复制上游业务状态机。 diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index e9bea312c..3279bbcd3 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -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` | diff --git a/gateway-overseas/AGENTS.md b/gateway-overseas/AGENTS.md index 14d35f248..8965757f4 100644 --- a/gateway-overseas/AGENTS.md +++ b/gateway-overseas/AGENTS.md @@ -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、过期/错误签名和身份头伪造测试。 diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index ba6124ce2..258d382be 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -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`。 - 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。 diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index c3e71b849..6c9fd5e18 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -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 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 cargoes) { + if (!"ONE_CLICK_DROPSHIP".equals(fulfillmentType)) return; + Map 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()) diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 1417e4afc..087c9615d 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -441,6 +441,85 @@ class OrderApplicationServiceStateTest { verify(repository, never()).insertHistory(any()); } + @Test + void submittingPersistedMixedCartonRejectsEveryPhysicalMismatchWithoutWritingForBothTaxModes() { + List> 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); diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index a68a7ae97..65182cb7f 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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` 报告,失败先核对报告再重跑,不自动清理。 diff --git a/scripts/admin-test-env-modules/inbound-measurement-pallet-freeze-contract.test.mjs b/scripts/admin-test-env-modules/inbound-measurement-pallet-freeze-contract.test.mjs new file mode 100644 index 000000000..67518ec7e --- /dev/null +++ b/scripts/admin-test-env-modules/inbound-measurement-pallet-freeze-contract.test.mjs @@ -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) + }) +} diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 319527697..fbd696dd1 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -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) } } diff --git a/scripts/admin-test-env-modules/orders-cargo-requirements-contract.test.mjs b/scripts/admin-test-env-modules/orders-cargo-requirements-contract.test.mjs new file mode 100644 index 000000000..439381694 --- /dev/null +++ b/scripts/admin-test-env-modules/orders-cargo-requirements-contract.test.mjs @@ -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) +}) diff --git a/scripts/admin-test-env-modules/orders-contract.test.mjs b/scripts/admin-test-env-modules/orders-contract.test.mjs new file mode 100644 index 000000000..00f019527 --- /dev/null +++ b/scripts/admin-test-env-modules/orders-contract.test.mjs @@ -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) + } +}) diff --git a/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs b/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs index 5198741cb..43a4ae583 100644 --- a/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/orders-pricing-fixture-contract.test.mjs @@ -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 }, diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index a48979c7e..823c6a3f6 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -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 }) } diff --git a/scripts/admin-test-env-modules/phase3-port-schedules-retention-contract.test.mjs b/scripts/admin-test-env-modules/phase3-port-schedules-retention-contract.test.mjs new file mode 100644 index 000000000..587f1e20e --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-port-schedules-retention-contract.test.mjs @@ -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 } +}) diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index bd1171bb1..a7ef61927 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -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) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 6bce91243..a70e5bbdf 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -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', () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 55a13985d..3d05cd717 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -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 diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 25b7b3f51..8ca8cf005 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -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'\]/) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index f136606d2..d86984c1a 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -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 diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs index d237ea7b3..d6fe85f94 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs @@ -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={} diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs index 829071171..faba94683 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs @@ -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' diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index cb39c70ec..ef1cb940b 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -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 只纳入账期应付'