diff --git a/AGENTS.md b/AGENTS.md index 04e997b80..16ebd4ca7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,14 +108,14 @@ ## 数据库访问与中文代码注释(所有服务及公共模块强制) -- 本节适用于全部后端服务和公共模块;新增、修改的方法必须执行,触及的存量代码同步整改。登记本约束不代表存量代码已全部完成迁移和注释补齐。 +- 本节适用于全部后端服务和公共模块;新增代码遵守下列规范,存量分层仅在影响本次正确性或用户明确要求时整改,不因局部修改迁移整条链路;以上为目标规范,不代表存量均已达标。 - 生产代码的关系型数据库读写统一经 MyBatis/MyBatis-Plus Mapper 执行;Controller、Service、定时任务、消费者、编号生成器和公共基础组件不得直接使用 `JdbcTemplate`、`NamedParameterJdbcTemplate`、原生 JDBC/Connection/Statement 或其他通道执行 SQL。仅改成 Repository 包装 JDBC 不算整改,SQL 必须进入 Mapper;无数据库职责的模块不得为满足本条新增数据库访问。 - 正常业务数据库访问路径统一为 `Controller → Service → Repository → Mapper`,四层职责明确,不得省略 Repository,也不得由 Controller 直接调用 Repository/Mapper,或由 Service 直接调用 Mapper。定时任务、消费者等非 HTTP 入口从 Service 进入,同样经 Repository → Mapper 完成持久化访问。 - Controller 负责请求接收、参数校验、入口权限和响应转换;Service 负责业务规则、编排及业务事务;Repository 负责领域数据访问封装、查询组合与持久化模型转换,对 Service 提供有业务含义的数据操作;Mapper 负责 SQL 执行及数据库记录映射。Repository 不承载业务状态流转,不直接用 JDBC 执行 SQL;Service 不继承 MyBatis-Plus `ServiceImpl` 等方式绕过 Repository。 - 复杂查询、批量处理、原子计数、锁定读取、Outbox 和数据库监控查询同样经 Repository → Mapper,不以 SQL 特殊或实现方便为由跳层。公共模块需要数据库能力时,按现有模块边界定义 Repository/持久化接口,由所属服务的 Repository → Mapper 实现,禁止借此跨服务读库。 - 迁移数据库操作须保留客户隔离条件、事务传播、行锁、幂等和并发语义;`LAST_INSERT_ID()` 等连接级操作必须在同一事务连接完成,并明确关闭相关读取缓存,不能仅机械搬移 SQL。Mapper 注册及扫描仍遵守下文统一规范。 - 测试代码可用 `JdbcTemplate` 创建隔离测试表、准备数据及核对结果;被测生产路径仍必须调用实际 Mapper,不得把测试造数做法带入生产。数据库基线和迁移脚本按现有部署契约管理。 -- 每个手写方法及构造器均须添加简短中文注释,通常 1—3 行说明业务用途;接口方法和 Mapper 方法优先使用中文 Javadoc。覆盖方法同样须有简短中文说明,实现中的关键规则仍须就地注释;自动生成的方法不手工逐个补注释。 +- 本次新增或修改的手写方法及构造器须有简短中文业务注释,通常 1—3 行;接口与 Mapper 方法优先用中文 Javadoc,覆盖方法同样适用,关键规则就地说明。不补齐未修改或自动生成方法的注释。 - 核心业务、复杂 SQL、状态流转、资金与库存变更、权限隔离、事务、锁、幂等、并发、重试和补偿逻辑,须在关键代码前增加适量中文注释,解释业务规则、处理原因、约束边界及失败影响;不逐行翻译代码,不添加无信息量或与实现不符的注释。 - 新增和修改的代码说明注释统一使用中文;必要的技术标识、协议名、类名可保留原文,第三方许可证和生成标记保持原样。修改方法时同步更新相关存量英文或过期说明;审查时同时核对 Controller → Service → Repository → Mapper 分层及中文方法、核心逻辑注释。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 5f9b3d6c2..31e9ca721 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -4874,7 +4874,7 @@ CREATE TABLE ll_fleet_pickup_history ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史'; -- 自有业务仅保存原排车的归属证据和附加运营信息,不复制履约主单。 -CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership ( +CREATE TABLE ll_fleet_owned_business_ownership ( dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键', resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL', vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空', @@ -4892,7 +4892,7 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership ( OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据'; -CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record ( +CREATE TABLE ll_fleet_owned_business_record ( id BIGINT NOT NULL COMMENT '附加运营记录内部主键', dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键', record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本', @@ -4917,7 +4917,7 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record ( CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息'; -CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command ( +CREATE TABLE ll_fleet_owned_business_command ( id BIGINT NOT NULL COMMENT '附加运营命令内部主键', dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键', operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识', @@ -4938,7 +4938,7 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command ( CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果'; -CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history ( +CREATE TABLE ll_fleet_owned_business_history ( id BIGINT NOT NULL COMMENT '附加运营历史内部主键', dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键', record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本', diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index e99f710d2..e689cbace 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -57,7 +57,7 @@ | PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` | | 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` | | 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` | -| 草稿、双端装发与派送安排 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`,派送组合`delivery/controller/DeliveryTransferController.java` | +| 草稿、双端装车发运 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`;自动发运编排`dispatch/service/DispatchLoadingDepartureService.java` | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` | | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java` | | 一件代发接收、SKU/箱唛库存 | `dropship/controller/DropshipOrderController.java` | @@ -77,7 +77,7 @@ | 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java` 的提交及两级审核;同一排车先调度审核、再理货审核,通过后才允许真实扫码。资源、货物或路线关键变更使原依据失效;零扫码显式退回后重新审核,不复用旧客户子单审核状态 | | 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 | | 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 | -| 实物装载、逐段容量与双端整车发车 | `dispatch/pda/controller/DispatchPdaController.java` → `dispatch/service/DispatchAggregateService.java`(装车)、`DispatchDepartureService.java`(发车);平台与PDA均使用本端`dispatches/{dispatchNo}/loading-complete`及`/depart`,公共DTO在`dispatch/port/`,已有单写入唯一`recordVersion`。装车前须真实PDA拣货核平,发车前须全车派送安排完整;装车及发车不重复执行扫码已完成的库存SHIP,Fleet容量和资源资格继续由真实提供者复核 | +| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,同一事务复用实载确认、派送单准备及发车,成功直接`DELIVERING`;请求携带唯一`recordVersion`,原键恢复最终结果。公开手工安排派送、`/depart`入口已移除;内部`AFTER_LOADING`和步骤命令只用于事务编排。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 | | 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 | | 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` | diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index d628ca955..44aa31f9f 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -95,9 +95,9 @@ - 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。 - 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。 - 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。 -- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车,装车完成后、实际发车前由后台安排派送,按客户与目的仓拆分派送单。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。 +- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。 - 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。 -- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车、安排派送和发车不重复执行已完成的库存扣减。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。 +- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。 - 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。 - 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。 - 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index 7658c2a67..f9cd44881 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -37,25 +37,6 @@ public class DeliveryTransferController { return ApiResponse.success(dispatchDelivery.preview(actor(user),dispatchNo).groups()); } - /** 一次快照提供全部客户目的地、费用证据及既有安排的完整数组。 */ - @GetMapping("/dispatches/{dispatchNo}/delivery-arrangement") - @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") - public ApiResponse arrangement(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { - requirePlatform(user); - return ApiResponse.success(dispatchDelivery.preview(actor(user),dispatchNo)); - } - - /** 平台一次安排本车全部客户,身份只从认证上下文获取。 */ - @PostMapping("/dispatches/{dispatchNo}/transfer-delivery") - @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')") - @Operation(summary = "按真实客户生成出库单并按客户目的地生成派送单") - public ApiResponse transfer( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, - @Valid @RequestBody ArrangeDelivery request) { - requirePlatform(user); - return ApiResponse.success(dispatchDelivery.arrange(actor(user),dispatchNo,request)); - } - @PostMapping("/outbound-orders/one-click-dropship") @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')") @Operation(summary = "Create one-click dropship outbound and push a PDA picking task") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java index 1ea9fd038..d6d062eb3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java @@ -126,6 +126,51 @@ public class DispatchDeliveryService implements DispatchDeliveryPort { var input=inputs.get(group.key());var coverage=fees.coverage(group,input,actor.operatorId(),external.get(group.customerId())); fees.validate(group,input,coverage,external.get(group.customerId()));coverages.put(group.key(),coverage); } + var fact=createDocuments(context,actor,request.idempotencyKey(),businessHash,groups,inputs,external,coverages); + return execution.recordArrangement(context,actor,identity,fact,false); + } + + /** 装车父事务内自动生成全车单据;未知派送资料和未确认费用保持未登记。 */ + @Override + @Transactional(propagation=Propagation.MANDATORY) + public ArrangementFact prepareForDeparture(Context context,Actor actor,String commandKey) { + if(actor==null || blank(actor.operatorId()) || blank(commandKey) || commandKey.length()>255) + throw conflict("缺少装车操作身份或原请求标识"); + if(context==null || context.status()!=Status.ACTIVE || context.stage()!=Stage.AFTER_LOADING + || !context.pickingReconciled() || context.loadingCompletedAt()==null || blank(context.loadingCompletionHash()) + || blank(context.cargoHash()) || context.cargo()==null || context.cargo().isEmpty()) + throw conflict("请先完成真实拣货核平及装车确认"); + if(actor.mode()==ActorMode.PDA && !Objects.equals(actor.boundWarehouseId(),context.warehouseId())) + throw conflict("排车不属于当前绑定仓"); + var saved=repository.arrangement(context.dispatchId(),true); + if(saved!=null) { + if(!Objects.equals(saved.getOriginalCommandKey(),commandKey) + || !Objects.equals(saved.getCargoHash(),context.cargoHash()) + || !Objects.equals(saved.getLoadingCompletionHash(),context.loadingCompletionHash())) + throw conflict("本车已有不同装车命令生成的派送事实"); + var facts=departureFacts(context,true); + if(!facts.depart().allowed())throw conflict(facts.depart().blockReason()); + return fact(saved); + } + // 不能把缺少安排事实的孤立单据补建成另一套;全部客户在同一事务中创建。 + if(!repository.outbounds(context.dispatchId(),true).isEmpty() || !repository.deliveries(context.dispatchId(),true).isEmpty()) + throw conflict("已存在关联单据但缺少完整安排事实,请核对"); + var groups=grouping.groups(context);var external=fees.external(context); + var inputs=new HashMap(); + for(var group:groups) { + String carrier=context.resources()==null || context.resources().vehicle()==null?null:context.resources().vehicle().carrierName(); + inputs.put(group.key(),new ArrangeGroup(group.key(),group.cargoHash(),null,null,null,carrier,null,null, + null,null,null,null,null,external.get(group.customerId()).snapshotHash(),null,List.of(),List.of())); + } + String businessHash=grouping.hash(Arrays.asList(context.dispatchNo(),context.cargoHash(),context.loadingCompletionHash(), + groups.stream().map(g->inputs.get(g.key())).toList())); + return createDocuments(context,actor,commandKey,businessHash,groups,inputs,external,Map.of()); + } + + /** 自动装车与内部安排复用同一建单、来源锁及真实外接报价发布路径,不推定原费覆盖。 */ + private ArrangementFact createDocuments(Context context,Actor actor,String commandKey,String businessHash,List groups, + Map inputs,Map external, + Map coverages) { var now=Instant.now();String key="DLA"+IdWorker.getId();var outbounds=new LinkedHashMap(); for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) { var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList(); @@ -169,9 +214,9 @@ public class DispatchDeliveryService implements DispatchDeliveryPort { var arrangement=new DispatchDeliveryArrangement();arrangement.setId(IdWorker.getId());arrangement.setDispatchOrderId(context.dispatchId()); arrangement.setDispatchNo(context.dispatchNo());arrangement.setArrangementKey(key);arrangement.setBusinessHash(businessHash); arrangement.setCargoHash(context.cargoHash());arrangement.setLoadingCompletionHash(context.loadingCompletionHash()); - arrangement.setOriginalCommandKey(request.idempotencyKey());arrangement.setOriginalOperatorId(actor.operatorId()); + arrangement.setOriginalCommandKey(commandKey);arrangement.setOriginalOperatorId(actor.operatorId()); arrangement.setResultJson(write(result));arrangement.setCargoJson(write(context.cargo()));arrangement.setCreatedAt(local(now));repository.create(arrangement); - return execution.recordArrangement(context,actor,identity,fact(arrangement),false); + return fact(arrangement); } /** 发车先取得全部出库锁,再取得全部派送锁,当前读取不申请来源锁。 */ @@ -281,7 +326,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort { var destination=group.destination();d.setDeliveryGroupKey(grouping.documentGroupKey(destination));d.setDestinationType(destination.type()); d.setDestinationWarehouseId(destination.warehouseId());d.setDestinationWarehouseCode(destination.warehouseCode());d.setDestinationWarehouseName(destination.warehouseName()); d.setDeliveryAddress(destination.address());d.setPostalCode(destination.postalCode());d.setContactName(destination.contactName());d.setContactPhone(destination.contactPhone()); - d.setStatus("WAIT_DELIVERY");d.setVersion(0L);d.setDeliveryMethod(input.deliveryMethod());d.setPlannedDeliveryAt(local(input.plannedDeliveryAt())); + d.setStatus("WAIT_DELIVERY");d.setVersion(0L);d.setDeliveryMethod(input.deliveryMethod());d.setPlannedDeliveryAt(input.plannedDeliveryAt()==null?null:local(input.plannedDeliveryAt())); d.setWaybillNo(input.waybillNo());d.setCarrierName(input.carrierName());d.setPickupMethod(input.pickupMethod());d.setLoadingType(input.loadingType()); d.setVehicleType(input.vehicleType());d.setVehicleSize(input.vehicleSize());d.setAppointmentAccount(input.appointmentAccount());d.setSellerName(input.sellerName());d.setRemark(input.remark()); if(context.resources()!=null){if(context.resources().vehicle()!=null)d.setVehiclePlate(context.resources().vehicle().plate()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java index ad2c82703..a5909f6d6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java @@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.*; @RequestMapping("/api/platform/v1/last-leg/dispatches") public class DispatchAggregateController { private final DispatchAggregateService service; - @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchDepartureService departure; + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService loadingDeparture; /** 注入本域应用服务。 */ public DispatchAggregateController(DispatchAggregateService service) { this.service=service; } @@ -71,17 +71,11 @@ public class DispatchAggregateController { public ApiResponse loading(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { return ApiResponse.success(service.loading(DispatchActorFactory.platform(user),dispatchNo)); } - /** 显式确认装车,不复用最后扫码或实际发车操作。 */ + /** 显式确认装车后自动准备派送并发车,原请求恢复最终派送中结果。 */ @PostMapping("/{dispatchNo}/loading-complete") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:loading-complete')") public ApiResponse loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) { - return ApiResponse.success(service.loadingComplete(DispatchActorFactory.platform(user),dispatchNo,request)); - } - /** 与PDA共用实际发车服务和公共版本,未完整安排派送不能发车。 */ - @PostMapping("/{dispatchNo}/depart") - @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')") - public ApiResponse depart(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.Depart request) { - return ApiResponse.success(departure.depart(DispatchActorFactory.platform(user),dispatchNo,request)); + return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request)); } /** 本车证据先上传,主单引用随后走版本化编辑。 */ @PostMapping(value="/{dispatchNo}/evidence/{kind}",consumes=org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java index 06c73e8b4..c742a4cb4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java @@ -26,6 +26,9 @@ public interface DispatchAggregateMapper { String CURRENT_FILTER = """ AND d.status=#{q.status} AND d.stage=#{q.stage} + AND d.stage='BEFORE_LOADING' AND d.review_state IN ('DRAFT','DISPATCH_REJECTED','LOADING_REJECTED') + AND d.stage='BEFORE_LOADING' AND d.review_state='DISPATCH_PENDING' + AND d.stage='BEFORE_LOADING' AND d.review_state='LOADING_PENDING' AND ( """ + REVIEW_DISPATCH + ")=#{q.reviewStatus}" + " AND (" + REVIEW_LOADING + ")=#{q.reviewStatus}"; @@ -76,14 +79,15 @@ public interface DispatchAggregateMapper { @Update(""" UPDATE ll_dispatch_order SET cargo_revision=cargo_revision+1,cargo_hash=#{hash},item_count=#{items}, pallet_count=#{pallets},package_count=#{packages},route=#{route},review_state='DRAFT', - review_basis_hash=NULL,dispatch_review_json=#{pending},loading_review_json=#{pending}, + review_basis_hash=NULL,dispatch_review_json=#{dispatchReview},loading_review_json=#{loadingReview}, record_version=record_version+1,updated_at=#{at} WHERE id=#{id} AND record_version=#{version} AND status='ACTIVE' AND stage='BEFORE_LOADING' AND picked_package_count=0 AND shipped_package_count=0 """) int changeCargo(@Param("id") long id, @Param("version") long version, @Param("hash") String hash, @Param("items") int items, @Param("pallets") int pallets, @Param("packages") int packages, - @Param("route") String route, @Param("pending") String pending, @Param("at") java.time.LocalDateTime at); + @Param("route") String route, @Param("dispatchReview") String dispatchReview, @Param("loadingReview") String loadingReview, + @Param("at") java.time.LocalDateTime at); /** 父锁内按公共版本保存状态及审核依据,显式清空旧依据而不依赖实体非空更新策略。 */ @Update(""" diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java index defc08736..f37aa253b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java @@ -20,7 +20,7 @@ public class DispatchPdaController { @Resource private DispatchPdaQueryService queries; @Resource private DispatchPickingService picking; @Resource private DispatchAggregateService workflow; - @Resource private DispatchDepartureService departure; + @Resource private DispatchLoadingDepartureService loadingDeparture; /** 批量读取当前绑定仓的整车作业。 */ @GetMapping @@ -36,6 +36,13 @@ public class DispatchPdaController { return ApiResponse.success(queries.task(DispatchActorFactory.pda(user),dispatchNo)); } + /** 理货审核与后台共用同一轮次、版本和幂等账本,驳回须填写原因。 */ + @PostMapping("/{dispatchNo}/loading-review") + public ApiResponse loadingReview(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo,@Valid @RequestBody DispatchCommands.ReviewCommand request) { + return ApiResponse.success(workflow.review(DispatchActorFactory.pda(user),dispatchNo,true,request)); + } + /** 共用公共版本和全局请求键执行一次真实库存扫码。 */ @PostMapping("/{dispatchNo}/items/{itemId}/pick") public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, @@ -50,18 +57,10 @@ public class DispatchPdaController { return ApiResponse.success(workflow.loading(DispatchActorFactory.pda(user),dispatchNo)); } - /** 最后一次扫码不代替显式装车确认,双方使用同一正式命令。 */ + /** 真实扫码核平后确认装车即发车,两端使用同一事务命令。 */ @PostMapping("/{dispatchNo}/loading-complete") public ApiResponse loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) { - return ApiResponse.success(workflow.loadingComplete(DispatchActorFactory.pda(user),dispatchNo,request)); - } - - /** 与平台共用实际发车服务,不执行第二次库存扣减。 */ - @PostMapping("/{dispatchNo}/depart") - public ApiResponse depart(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String dispatchNo, - @Valid @RequestBody DispatchCommands.Depart request) { - return ApiResponse.success(departure.depart(DispatchActorFactory.pda(user),dispatchNo,request)); + return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.pda(user),dispatchNo,request)); } /** 原动作权限与绑定仓同时核对未知结果,不按单客户任务恢复。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java index 47fed9de8..43823927a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java @@ -14,7 +14,7 @@ public interface DispatchWholePdaReadMapper { /** 精确单号或有界阶段列表均先在SQL限制绑定仓。 */ @Select("") + +"AND (stage IN('LOADING','AFTER_LOADING','DELIVERING') OR (stage='BEFORE_LOADING' AND review_state='LOADING_PENDING')) ORDER BY updated_at DESC,id DESC LIMIT 201") List headers(@Param("warehouse")String warehouse,@Param("number")String number,@Param("stage")String stage); String ROOTS="#{id}"; /** 一次读取选中整车所有真实客户货物。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java index aae359617..f6d50694a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java @@ -24,9 +24,6 @@ public final class DispatchApi { public static final String LOADING_REVIEW = "/{dispatchNo}/loading-review"; public static final String RETURN_TO_REVIEW = "/{dispatchNo}/return-to-review"; public static final String LOADING_COMPLETE = "/{dispatchNo}/loading-complete"; - public static final String DEPART = "/{dispatchNo}/depart"; - public static final String DELIVERY_ARRANGEMENT = "/{dispatchNo}/delivery-arrangement"; - public static final String TRANSFER_DELIVERY = "/{dispatchNo}/transfer-delivery"; /** 端点容器不允许实例化。 */ private DispatchApi() { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java index 11fea00de..52b87092f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java @@ -7,6 +7,9 @@ import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; /** 派送域实现的整车安排和单据事实边界,禁止回写排车持久化实体。 */ public interface DispatchDeliveryPort { + /** 装车完成父事务自动准备全车真实客户单据,不要求人工安排或补造费用。 */ + ArrangementFact prepareForDeparture(Context context, Actor actor, String commandKey); + /** 一次预览返回同快照下全部客户目的地及费用证据。 */ ArrangementPreview preview(Context context); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java index 264aa0a35..87cd844d6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java @@ -63,7 +63,7 @@ public final class DispatchViews { List orderDetails) { } public record PdaTask(String taskNo, String dispatchNo, String warehouseId, Status status, - Stage stage, long recordVersion, Reviews reviews, Counts counts, + Stage stage, long recordVersion, Reviews reviews, Resources resources, Counts counts, boolean pickingReconciled, List items, Map actions) { } public record PdaItem(Long dispatchItemId, String customerId, String customerName, String palletNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java index 222f415ee..c0da1705a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java @@ -46,8 +46,8 @@ public class DispatchAggregateRepository { public int removeItem(long itemId) {return items.deleteById(itemId);} /** 原子更新货量并作废旧审核依据。 */ public int changeCargo(long id, long version, String hash, int itemCount, int pallets, int packages, - String route, String pending, LocalDateTime at) { - return mapper.changeCargo(id, version, hash, itemCount, pallets, packages, route, pending, at); + String route, String dispatchReview, String loadingReview, LocalDateTime at) { + return mapper.changeCargo(id, version, hash, itemCount, pallets, packages, route, dispatchReview, loadingReview, at); } /** 追加不可变业务快照。 */ public int history(long id, DispatchAggregate row, String action, String operator, String reason, String snapshot, LocalDateTime at) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index e1f647243..0bf073c27 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -400,7 +400,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 调度与理货审核按同一轮次、同一事实摘要串行执行;理货通过才进入装车。 */ @Transactional(isolation=Isolation.READ_COMMITTED) public CommandResult review(Actor actor,String number,boolean loading,DispatchCommands.ReviewCommand request) { - var row=lockPlatform(actor,number);Action action=loading?Action.LOADING_REVIEW:Action.DISPATCH_REVIEW; + Action action=loading?Action.LOADING_REVIEW:Action.DISPATCH_REVIEW; + // PDA仅可处理本绑定仓理货审核,原键重试也先重新核验权限和范围。 + DispatchAggregate row; + if(actor!=null && actor.mode()==ActorMode.PDA) { + if(!loading || actor.boundWarehouseId()==null || !hasPermission(actor,action)) + throw new BizException(ResultCode.FORBIDDEN,"仅当前绑定仓理货员可执行理货审核"); + required(actor,number,false);fleet.lockDispatchShell(number);row=required(actor,number,true); + } else row=lockPlatform(actor,number); var identity=identity(actor,number,action,request.recordVersion(),request.idempotencyKey(),request); var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); requireEditing(row,request.recordVersion()); @@ -422,6 +429,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name()); row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null):pending(row.getReviewRound()))); } + if(request.decision()==Decision.REJECTED) { + // 任一级驳回均回客服处理,旧调度批准不再作为可继续理货的有效依据。 + row.setStage(Stage.BEFORE_LOADING.name());row.setReviewBasisHash(null); + if(loading)row.setDispatchReviewJson(write(new Review(ReviewStatus.NOT_SUBMITTED,null,null,null, + row.getReviewRound(),null,"理货审核驳回,请客服修改后重新提交调度审核"))); + } return saveCommand(row,actor,identity,request.reason()); } @@ -517,7 +530,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA List.copyOf(pallets),row.getLoadingConfirmationJson()==null?null:read(row.getLoadingConfirmationJson(),ConfirmedLoad.class),gate,List.copyOf(missing)); } - /** 最后一次扫码只核平,显式装车完成才保存实载并进入装车后。 */ + /** 内部实载步骤:扫码核平后保存装载证据,中间装车后状态由外层同事务继续推进发车。 */ @Transactional(isolation=Isolation.READ_COMMITTED) public CommandResult loadingComplete(Actor actor,String number,DispatchCommands.LoadingComplete request) { required(actor,number,false);fleet.lockDispatchShell(number);var row=required(actor,number,true); @@ -610,7 +623,15 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA private void invalidate(DispatchAggregate row,String reason) { row.setReviewState(ReviewState.DRAFT.name());row.setReviewBasisHash(null); var invalid=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,row.getReviewRound(),null,reason); - row.setDispatchReviewJson(write(invalid));row.setLoadingReviewJson(write(invalid)); + var previous=reviews(row); + row.setDispatchReviewJson(write(retainRejection(previous.dispatchReview(),invalid))); + row.setLoadingReviewJson(write(retainRejection(previous.loadingReview(),invalid))); + } + /** 客服改资料期间保留本轮驳回原因;下一轮提交统一重置,不复用批准。 */ + private static Review retainRejection(Review previous,Review invalid) { + return previous.status()==ReviewStatus.REJECTED + ?new Review(previous.status(),previous.reason(),previous.reviewerId(),previous.reviewedAt(),previous.reviewRound(),null,invalid.invalidatedReason()) + :invalid; } /** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */ void verifyReviewResources(DispatchAggregate row) { @@ -726,7 +747,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA int pallets=(int)all.stream().map(i -> i.getWarehousePalletId()==null ? "PLAN:"+i.getPlanPalletId() : "PALLET:"+i.getWarehousePalletId()).distinct().count(); String route=all.stream().map(this::destinationLabel).filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(",")); var digest=all.stream().sorted(Comparator.comparing(DispatchItem::getSourceKey)).map(i -> List.of(i.getSourceKey(),i.getSourceVersion(),i.getCustomerId(),i.getPackageCount())).toList(); - if(repository.changeCargo(row.getId(),row.getRecordVersion(),hashes.source(digest),all.size(),pallets,packages,route,write(pending(row.getReviewRound())),now())!=1) throw conflict("排车版本已变化,未保存本次货物"); + invalidate(row,"排车货物已修改,请客服重新提交审核"); + if(repository.changeCargo(row.getId(),row.getRecordVersion(),hashes.source(digest),all.size(),pallets,packages,route,row.getDispatchReviewJson(),row.getLoadingReviewJson(),now())!=1) throw conflict("排车版本已变化,未保存本次货物"); var stops=all.stream().map(i -> new FleetExecutionModels.DispatchStop(destinationLabel(i),i.getDeliveryAddress(),destination(i).key())) .collect(Collectors.toMap(FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList(); fleet.updateDispatchCargo(row.getDispatchNo(),packages,stops); @@ -775,12 +797,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA // 到仓取货和本土卡车沿运输任务执行,不能进入仓内拣货、扣库存和转派送链。 if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) { var blocked=new EnumMap(Action.class); - for(var action:Action.values())blocked.put(action,new Gate(false,"TRANSPORT_TASK","请在排车运输任务中操作")); + for(var action:Action.values())if(action!=Action.ARRANGE_DELIVERY && action!=Action.DEPART) + blocked.put(action,new Gate(false,"TRANSPORT_TASK","请在排车运输任务中操作")); return Collections.unmodifiableMap(blocked); } var gates=new EnumMap(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus()); Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState()); for(var action:Action.values()) { + if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)continue; boolean state=switch(action) { case CREATE -> actor.mode()==ActorMode.PLATFORM; case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0; @@ -799,11 +823,20 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA return Collections.unmodifiableMap(gates); } + /** 装车发运及原结果恢复都按当前装车权限与PDA绑定身份校验。 */ + void requireLoadingPermission(Actor actor) { + if(actor==null || actor.operatorId()==null || !hasPermission(actor,Action.LOADING_COMPLETE)) + throw new BizException(ResultCode.FORBIDDEN,"当前无装车完成权限"); + } + /** 主体类型和正式动作权限逐项判断,不把list权限等同写权限。 */ private static boolean hasPermission(Actor actor,Action action) { + if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)return false; var auth=SecurityContextHolder.getContext().getAuthentication(); if(auth==null || actor==null) return false; - if(actor.mode()==ActorMode.PDA) return Set.of(Action.PICK,Action.LOADING_COMPLETE,Action.DEPART).contains(action) - && auth.getAuthorities().stream().anyMatch(a->"lastleg:pda:operate".equals(a.getAuthority())); + if(actor.mode()==ActorMode.PDA) return Set.of(Action.LOADING_REVIEW,Action.PICK,Action.LOADING_COMPLETE,Action.DEPART).contains(action) + && actor.boundWarehouseId()!=null + && auth.getAuthorities().stream().anyMatch(a->"lastleg:pda:operate".equals(a.getAuthority())) + && auth.getAuthorities().stream().anyMatch(a->"ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR".equals(a.getAuthority())); if(action==Action.PICK) return false; String permission=switch(action) { case CREATE -> PREFIX+"create"; case CANCEL -> PREFIX+"cancel"; @@ -814,6 +847,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA return auth.getAuthorities().stream().anyMatch(a->permission.equals(a.getAuthority())); } + /** PDA审核读取整车冻结资源,与后台共用相同快照,查询不更改主档。 */ + Resources resources(DispatchAggregate row) { return read(row.getResourcesJson(),Resources.class); } + /** 统计按真整车去重并补齐全部零项。 */ private static DispatchViews.Statistics statistics(List groups) { var stages=new EnumMap(Stage.class); for(var stage:Stage.values()) stages.put(stage,0L); @@ -821,9 +857,13 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA for(var group:groups) { if(Status.CANCELLED.name().equals(group.status())) { cancelled+=group.quantity(); continue; } active+=group.quantity(); var stage=Stage.valueOf(group.stage()); stages.merge(stage,group.quantity(),Long::sum); - if(stage==Stage.BEFORE_LOADING) { before+=group.quantity(); var review=ReviewState.valueOf(group.reviewState()); - dispatch[review==ReviewState.DRAFT?0:review==ReviewState.DISPATCH_PENDING?1:review==ReviewState.DISPATCH_REJECTED?3:2]+=group.quantity(); - loading[review==ReviewState.LOADING_PENDING?1:review==ReviewState.LOADING_REJECTED?3:review==ReviewState.APPROVED?2:0]+=group.quantity(); } + if(stage==Stage.BEFORE_LOADING) { + // 三分区表示当前待办,驳回只能计入客服处理,不能留在审核队列。 + var review=ReviewState.valueOf(group.reviewState()); + if(canSubmitReview(review.name()))before+=group.quantity(); + else if(review==ReviewState.DISPATCH_PENDING)dispatch[1]+=group.quantity(); + else if(review==ReviewState.LOADING_PENDING)loading[1]+=group.quantity(); + } } return new DispatchViews.Statistics(active+cancelled,active,cancelled,Collections.unmodifiableMap(stages),new DispatchViews.BeforeLoadingCounts(before, new DispatchViews.ReviewCounts(dispatch[0],dispatch[1],dispatch[2],dispatch[3]),new DispatchViews.ReviewCounts(loading[0],loading[1],loading[2],loading[3]))); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java new file mode 100644 index 000000000..4d83573a7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.port.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 两端装车完成即发车:所有中间状态只存在于同一事务,原请求只恢复最终结果。 */ +@Service +public class DispatchLoadingDepartureService { + private final DispatchAggregateService workflow; + private final DispatchDeliveryPort delivery; + private final DispatchDepartureService departure; + private final DispatchCommandLedger commands; + private final DispatchCommandHasher hashes; + + /** 复用装车、派送和发车的真实规则,不另建库存或运输状态实现。 */ + public DispatchLoadingDepartureService(DispatchAggregateService workflow,DispatchDeliveryPort delivery, + DispatchDepartureService departure,DispatchCommandLedger commands,DispatchCommandHasher hashes) { + this.workflow=workflow;this.delivery=delivery;this.departure=departure;this.commands=commands;this.hashes=hashes; + } + + /** 原键恢复先于阶段校验;任一步失败均回滚装载、子单、费用、发车与全部账本。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult complete(Actor actor,String number,DispatchCommands.LoadingComplete request) { + workflow.requireLoadingPermission(actor); + var context=workflow.lockForArrangement(actor,number); + var identity=new CommandIdentity(Action.LOADING_COMPLETE,request.idempotencyKey(),request.recordVersion(), + hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request)); + var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get(); + // 先取得本车单据锁再申请真实来源锁,后续所有步骤保持同一个整车父锁。 + delivery.departureFacts(context,true); + var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(), + stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.measurements(),request.remark())); + context=workflow.preview(actor,number); + String arrangementKey=stepKey(number,request.idempotencyKey(),"ARRANGE"); + var fact=delivery.prepareForDeparture(context,actor,arrangementKey); + var arrangementIdentity=new CommandIdentity(Action.ARRANGE_DELIVERY,arrangementKey,context.recordVersion(), + hashes.source(List.of(number,actor.operatorId(),context.recordVersion(),fact.arrangementBusinessHash()))); + var arranged=workflow.recordArrangement(context,actor,arrangementIdentity,fact,false); + var departed=departure.depart(actor,number,new DispatchCommands.Depart(arranged.recordVersion(), + stepKey(number,request.idempotencyKey(),"DEPART"),request.remark())); + if(departed.stage()!=Stage.DELIVERING || !(loaded.result() instanceof DispatchResults.Loaded)) + throw new BizException(ResultCode.CONFLICT,"装车发运结果未核平"); + var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),departed.recordVersion(), + departed.status(),departed.stage(),departed.occurredAt(),Effect.APPLIED,departed.counts(),loaded.result(),false,false); + commands.append(context.dispatchId(),actor,identity,result);return result; + } + + /** 固定长度内部键绑定原请求和步骤,不向客户端暴露中间命令作为恢复入口。 */ + private String stepKey(String number,String original,String step) { + return "AUTO_"+step+"_"+hashes.source(List.of(number,original,step)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java index 22ee82d6e..9b756f5e8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java @@ -89,7 +89,7 @@ public class DispatchPdaQueryService { else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车")); var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),n(root.getPackageCount()),n(root.getPickedPackageCount()),n(root.getShippedPackageCount())); // 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。 - result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions))); + result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions))); } return List.copyOf(result); } diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 518c5080a..7cd0accd9 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1345,49 +1345,6 @@ paths: required: true schema: type: string - /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery: - post: - summary: 原子安排本车全部客户派送及费用 - description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery。 - security: - - bearerAuth: [] - responses: - '200': - description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 - content: - application/json: - schema: - type: object - properties: - code: - type: string - message: - type: string - requestId: - type: string - nullable: true - traceId: - type: string - nullable: true - timestamp: - type: string - format: date-time - data: - allOf: - - $ref: '#/components/schemas/DispatchCommandResult' - nullable: true - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DispatchArrangeDelivery' - parameters: - - name: dispatchNo - in: path - required: true - schema: - type: string /api/platform/v1/last-leg/dispatches/{dispatchNo}/items: post: summary: 按真实来源版本向整车加货 @@ -1727,8 +1684,8 @@ paths: type: string /api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete: post: - summary: PDA核平后确认装车完成 - description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 + summary: 确认装车完成并自动进入派送中 + description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。 security: - bearerAuth: [] responses: @@ -1878,43 +1835,6 @@ paths: required: true schema: type: string - /api/platform/v1/last-leg/dispatches/{dispatchNo}/delivery-arrangement: - get: - summary: 读取全车派送分组与费用依据 - description: 权限 platform:overseas-warehouse:dispatch:list。 - security: - - bearerAuth: [] - responses: - '200': - description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 - content: - application/json: - schema: - type: object - properties: - code: - type: string - message: - type: string - requestId: - type: string - nullable: true - traceId: - type: string - nullable: true - timestamp: - type: string - format: date-time - data: - allOf: - - $ref: '#/components/schemas/DispatchArrangementPreview' - nullable: true - parameters: - - name: dispatchNo - in: path - required: true - schema: - type: string /api/platform/v1/last-leg/dispatches/{dispatchNo}/commands/{idempotencyKey}: get: summary: 恢复本人原动作不可变结果 @@ -2107,49 +2027,6 @@ paths: schema: type: string pattern: ^[1-9][0-9]*$ - /api/platform/v1/last-leg/dispatches/{dispatchNo}/depart: - post: - summary: 确认实际整车发车 - description: 权限 platform:overseas-warehouse:dispatch:depart。 - security: - - bearerAuth: [] - responses: - '200': - description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 - content: - application/json: - schema: - type: object - properties: - code: - type: string - message: - type: string - requestId: - type: string - nullable: true - traceId: - type: string - nullable: true - timestamp: - type: string - format: date-time - data: - allOf: - - $ref: '#/components/schemas/DispatchCommandResult' - nullable: true - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DispatchDepart' - parameters: - - name: dispatchNo - in: path - required: true - schema: - type: string /api/platform/v1/last-leg/outbound-orders: get: summary: 分页查询出库单 @@ -6586,6 +6463,10 @@ components: allOf: - $ref: '#/components/schemas/DispatchReviews' nullable: true + resources: + allOf: + - $ref: '#/components/schemas/DispatchResources' + nullable: true counts: allOf: - $ref: '#/components/schemas/DispatchCounts' @@ -6610,6 +6491,7 @@ components: - stage - recordVersion - reviews + - resources - counts - pickingReconciled - items diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index 5e5ce1334..3804a284f 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -9,7 +9,7 @@ paths: /api/pda/v1/last-leg/dispatches: get: summary: 读取Token绑定仓五阶段整车任务 - description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。不接收客户或仓库范围参数;真实客户归属保留在items,整车公共版本及Gate由服务端返回。 + description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。不接收客户或仓库范围参数;真实客户归属保留在items,整车公共版本及Gate由服务端返回。未指定阶段时返回待理货审核及装车、装车后、派送中的任务;车辆与司机依据读取resources。 security: - bearerAuth: [] responses: @@ -208,10 +208,53 @@ paths: required: true schema: type: string + /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review: + post: + summary: 理货员审核整车,后台与PDA共用审核轮次 + description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。仅绑定仓且调度审核通过的本轮整车可审核;REJECTED 必填 reason 并退回客服处理,APPROVED 才进入 LOADING;公共版本拒绝双端重复审核,原键恢复仍核验本人、绑定仓和理货权限。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: overseas-business-v1.yaml#/components/schemas/DispatchCommandResult + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: overseas-business-v1.yaml#/components/schemas/DispatchReviewCommand + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete: post: - summary: 真实拣货核平后显式确认装车完成 - description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 + summary: 确认装车完成并自动进入派送中 + description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。 security: - bearerAuth: [] responses: @@ -251,49 +294,6 @@ paths: required: true schema: type: string - /api/pda/v1/last-leg/dispatches/{dispatchNo}/depart: - post: - summary: 完整安排派送后确认实际发车 - description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 - security: - - bearerAuth: [] - responses: - '200': - description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 - content: - application/json: - schema: - type: object - properties: - code: - type: string - message: - type: string - requestId: - type: string - nullable: true - traceId: - type: string - nullable: true - timestamp: - type: string - format: date-time - data: - allOf: - - $ref: overseas-business-v1.yaml#/components/schemas/DispatchCommandResult - nullable: true - requestBody: - required: true - content: - application/json: - schema: - $ref: overseas-business-v1.yaml#/components/schemas/DispatchDepart - parameters: - - name: dispatchNo - in: path - required: true - schema: - type: string /api/pda/v1/last-leg/outbound-tasks: get: summary: 查询Token绑定仓的一件代发出库任务 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java index 09e58ad8b..c140b46cb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java @@ -37,6 +37,7 @@ import static org.mockito.Mockito.*; /** 真实生产Mapper及事务验证任何客户或主单CAS失败时全车原子回滚。 */ class DispatchDeliveryArrangementPersistenceTest { private JdbcTemplate db; + private org.springframework.transaction.support.TransactionTemplate tx; private DispatchDeliveryService service; private final DispatchExecutionPort execution=mock(DispatchExecutionPort.class); private final DispatchDeliveryFees fees=mock(DispatchDeliveryFees.class); @@ -49,6 +50,7 @@ class DispatchDeliveryArrangementPersistenceTest { @BeforeEach void prepare() throws Exception { var ds=new DriverManagerDataSource("jdbc:h2:mem:arrangement-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); db=new JdbcTemplate(ds); + tx=new org.springframework.transaction.support.TransactionTemplate(new DataSourceTransactionManager(ds)); for (var type:List.of(OutboundOrder.class,OutboundPalletItem.class,DeliveryOrder.class,LastlegOrder.class,LastlegOrderStatusHistory.class)) { String columns=Arrays.stream(type.getDeclaredFields()).filter(f->!Modifier.isStatic(f.getModifiers())).map(f->{ String name=f.getName().replaceAll("([A-Z])","_$1").toLowerCase(); @@ -173,6 +175,32 @@ class DispatchDeliveryArrangementPersistenceTest { assertEquals(6L,frozen.stream().filter(c->c.attributionId()==4).findFirst().orElseThrow().sourceOrigin().productQuantity()); } + /** 自动装车加入父事务,最终主单失败时全部客户单据与来源绑定均回滚。 */ + @Test void automaticPreparationSharesLoadingParentTransactionAndRollback() { + assertThrows(IllegalStateException.class,()->tx.execute(status->{ + service.prepareForDeparture(context,actor,"LOADING-FAIL"); + throw new IllegalStateException("装车最终保存失败"); + })); + for(var table:List.of("ll_outbound_order","ll_outbound_pallet_item","ll_delivery_order","ll_dispatch_delivery_arrangement","ll_loading_order_status_history")) + assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM "+table,Integer.class)); + assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_order WHERE outbound_order_no IS NULL AND version=0",Integer.class)); + var fact=tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-OK")); + assertEquals(2,fact.result().deliveries().size()); + assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order WHERE planned_delivery_at IS NULL AND delivery_method IS NULL AND pickup_method IS NULL AND loading_type IS NULL",Integer.class)); + assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order WHERE fee_coverage_json IS NULL",Integer.class)); + clearInvocations(fees); + assertEquals(fact,tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-OK"))); + verifyNoInteractions(fees); + } + + /** 外接费用发布失败同样回滚自动生成的全部客户单据,不能留下半车。 */ + @Test void automaticExternalFreightFailureRollsBackEveryCustomer() { + doThrow(new IllegalStateException("外接费用发布失败")).when(fees).publish(any(),argThat(g->g.customerId().equals("102")),any(),any(),any(),any(),anyString(),anyString()); + assertThrows(IllegalStateException.class,()->tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-FAIL"))); + assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Integer.class)); + assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Integer.class)); + } + /** 按真实共享记录构造完整客户目的地请求。 */ private ArrangeDelivery request() { return new ArrangeDelivery(3L,"ARRANGE-1","cargo-hash","loading-hash",grouping.groups(context).stream().map(g-> new ArrangeGroup(g.key(),g.cargoHash(),DEPARTED,"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java index 836b919f2..4128c31fb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java @@ -72,6 +72,38 @@ class DispatchDeliveryServiceTest { verify(fees,times(3)).publish(any(),any(),any(),any(),isNull(),any(),eq("OP-A"),anyString()); } + /** PDA装车自动生成完整客户目的地,不猜派送计划、不要求人工费用或原费覆盖。 */ + @Test void loadingAutomaticallyCreatesDocumentsWithoutInventedInputsOrFees() { + var pda=new Actor("PDA-1",ActorMode.PDA,"1001"); + var fact=service.prepareForDeparture(context,pda,"LOADING-1"); + assertEquals(2,fact.result().outbounds().size());assertEquals(3,fact.result().deliveries().size()); + assertEquals(4,fact.result().recordVersion()); + assertTrue(deliveries.stream().allMatch(d->d.getPlannedDeliveryAt()==null && d.getDeliveryMethod()==null + && d.getPickupMethod()==null && d.getLoadingType()==null)); + assertTrue(outbounds.stream().allMatch(o->o.getFeeCoverageJson()==null)); + assertTrue(calls.indexOf("SOURCE")>calls.lastIndexOf("DELIVERY")); + verify(fees,never()).validate(any(),any(),any(),any());verify(fees,never()).coverage(any(),any(),any(),any()); + var inputs=org.mockito.ArgumentCaptor.forClass(ArrangeGroup.class); + verify(fees,times(3)).publish(eq(context),any(),inputs.capture(),any(),isNull(),any(),eq("PDA-1"),anyString()); + assertTrue(inputs.getAllValues().stream().allMatch(i->i.fees().isEmpty() && i.feeCoverage().isEmpty())); + clearInvocations(fees,repository); + assertEquals(fact,service.prepareForDeparture(context,pda,"LOADING-1")); + verifyNoInteractions(fees);verify(repository,never()).create(any(OutboundOrder.class)); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + ()->service.prepareForDeparture(context,pda,"DIFFERENT-KEY")); + } + + /** 自动生成仍拒绝越仓与孤立子单,不为异常半车补造第二套事实。 */ + @Test void automaticPreparationRejectsWrongWarehouseAndOrphanDocuments() { + assertThrows(org.springframework.web.server.ResponseStatusException.class, + ()->service.prepareForDeparture(context,new Actor("PDA-1",ActorMode.PDA,"OTHER"),"LOADING-1")); + verifyNoInteractions(repository,fees); + outbounds.add(new OutboundOrder()); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + ()->service.prepareForDeparture(context,actor,"LOADING-1")); + verifyNoInteractions(fees); + } + /** 同实托的两申请分别冻结原货源,出库引用逐次归属且真实客户目的地与履约订单不被替换。 */ @Test void relabelOriginsStayFrozenBehindOutboundAttributionReferences() throws Exception { var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java index fb1243dd1..d5a79c7f3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java @@ -97,8 +97,6 @@ class PlatformDispatchOutboundContractTest { String.class, DispatchCommands.Update.class); Method cancel = DispatchAggregateController.class.getMethod("cancel", AuthenticatedUser.class, String.class, DispatchCommands.ReasonCommand.class); - Method transfer = DeliveryTransferController.class.getMethod("transfer", AuthenticatedUser.class, - String.class, com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.ArrangeDelivery.class); Method externalFreight = DeliveryTransferController.class.getMethod("externalFreight", AuthenticatedUser.class, String.class); assertPermission(externalFreight, OverseasPermissions.DISPATCH_LIST); Method outboundDetail = DeliveryTransferController.class.getMethod("outboundDetail", @@ -113,11 +111,12 @@ class PlatformDispatchOutboundContractTest { assertEquals(Set.of("/{dispatchNo}/cancel"), Set.of(cancelRoute.value())); assertFalse(cancel.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class)); assertTrue(cancel.getParameters()[2].isAnnotationPresent(org.springframework.web.bind.annotation.RequestBody.class)); - assertPermission(transfer, OverseasPermissions.DISPATCH_TRANSFER_DELIVERY); + assertTrue(Arrays.stream(DeliveryTransferController.class.getDeclaredMethods()) + .noneMatch(method -> Set.of("transfer", "arrangement").contains(method.getName()))); assertPermission(outboundDetail, OverseasPermissions.OUTBOUND_LIST); assertEquals(2, outboundDetail.getParameterCount()); assertPermission(outboundCancel, OverseasPermissions.OUTBOUND_CANCEL); - for (Method method : Set.of(update, cancel, transfer, externalFreight, outboundDetail, outboundCancel)) { + for (Method method : Set.of(update, cancel, externalFreight, outboundDetail, outboundCancel)) { assertTrue(Arrays.stream(method.getParameters()) .noneMatch(parameter -> parameter.isAnnotationPresent(RequestParam.class))); } @@ -202,16 +201,13 @@ class PlatformDispatchOutboundContractTest { assertFalse(schema.contains("uk_ll_outbound_no (customer_id,outbound_no)")); } - /** 当前整车请求按公共版本一次提交全部组,响应保留多客户出库及多目的地派送数组。 */ + /** 公开协议撤销人工安排入口,内部完整派送事实仍保留多客户和多目的地数组。 */ @Test void openApiPublishesOnlyTheFrozenPlatformProtocol() throws Exception { String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"), StandardCharsets.UTF_8); String dispatchAction = slice(yaml, " /api/platform/v1/last-leg/dispatches/{dispatchNo}:", " /api/platform/v1/last-leg/dispatches/candidates:"); - String transfer = slice(yaml, - " /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:", - " /api/platform/v1/last-leg/outbound-orders:"); String outboundDetail = slice(yaml, " /api/platform/v1/last-leg/outbound-orders/{outboundNo}:", " /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:"); @@ -219,7 +215,7 @@ class PlatformDispatchOutboundContractTest { " /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:", " /api/platform/v1/last-leg/overseas-procurements:"); - for (String action : Set.of(dispatchAction, transfer, outboundDetail, outboundCancel)) { + for (String action : Set.of(dispatchAction, outboundDetail, outboundCancel)) { assertFalse(action.contains("name: customerId")); } assertTrue(outboundDetail.contains("pallets[].palletNo")); @@ -228,24 +224,13 @@ class PlatformDispatchOutboundContractTest { assertTrue(yaml.contains("单次 SQL 返回,不逐行补查")); assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:edit")); assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:cancel")); - assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:transfer-delivery")); + assertFalse(yaml.contains(" /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:")); + assertFalse(yaml.contains(" /api/platform/v1/last-leg/dispatches/{dispatchNo}/delivery-arrangement:")); assertTrue(yaml.contains("platform:overseas-warehouse:outbound:list")); assertTrue(yaml.contains("platform:overseas-warehouse:outbound:create")); assertTrue(yaml.contains("platform:overseas-warehouse:outbound:cancel")); java.util.Map document = new org.yaml.snakeyaml.Yaml().load(yaml); java.util.Map schemas = (java.util.Map)((java.util.Map)document.get("components")).get("schemas"); - java.util.Map arrange = (java.util.Map)schemas.get("DispatchArrangeDelivery"); - assertNotNull(arrange); - assertTrue(((java.util.List)arrange.get("required")).containsAll(java.util.List.of("recordVersion", "idempotencyKey"))); - java.util.Map fields = (java.util.Map)arrange.get("properties"); - assertTrue(fields.keySet().containsAll(java.util.Set.of("recordVersion", "idempotencyKey", "cargoHash", "loadingCompletionHash", "groups"))); - assertFalse(fields.containsKey("customerId")); - assertFalse(fields.containsKey("expectedVersion")); - java.util.Map groups = (java.util.Map)fields.get("groups"); - assertEquals("array", groups.get("type")); - assertEquals("#/components/schemas/DispatchArrangeGroup", ((java.util.Map)groups.get("items")).get("$ref")); - java.util.Map groupFields = (java.util.Map)((java.util.Map)schemas.get("DispatchArrangeGroup")).get("properties"); - assertTrue(groupFields.keySet().containsAll(java.util.Set.of("groupKey", "groupCargoHash", "fees", "feeCoverage"))); java.util.Map resultFields = (java.util.Map)((java.util.Map)schemas.get("DispatchArrangementResult")).get("properties"); assertEquals("array", ((java.util.Map)resultFields.get("outbounds")).get("type")); assertEquals("array", ((java.util.Map)resultFields.get("deliveries")).get("type")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index 14af55457..c9d17f466 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -419,12 +419,13 @@ public class DispatchAggregatePersistenceTest { assertThat(f.db.queryForList("SELECT DISTINCT customer_id FROM ll_dispatch_item ORDER BY customer_id",String.class)).containsExactly("101","202"); assertThat(f.db.queryForObject("SELECT COUNT(DISTINCT dispatch_order_id) FROM ll_dispatch_item",Long.class)).isOne(); assertThat(f.db.queryForObject("SELECT package_count FROM ll_fleet_trip_dispatch",Integer.class)).isEqualTo(8); - var query=new DispatchViews.Query(1,20,null,null,null,"101",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,BeforeLoadingSection.DISPATCH_REVIEW,ReviewStatus.NOT_SUBMITTED); + var query=new DispatchViews.Query(1,20,null,null,null,"101",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,BeforeLoadingSection.VEHICLE_INFO,null); var page=f.tx.execute(s->f.service.page(f.actor,query)); assertThat(page.total()).isOne(); assertThat(page.records()).hasSize(1); assertThat(page.records().get(0).counts().packageCount()).isEqualTo(8); assertThat(page.statistics().activeTotal()).isOne(); assertThat(page.statistics().stageCounts().get(Stage.BEFORE_LOADING)).isOne(); - assertThat(page.statistics().beforeLoadingReviewCounts().dispatchReview().notSubmitted()).isOne(); + assertThat(page.statistics().beforeLoadingReviewCounts().vehicleInfoTotal()).isOne(); + assertThat(page.statistics().beforeLoadingReviewCounts().dispatchReview().notSubmitted()).isZero(); long dispatch=f.db.queryForObject("SELECT id FROM ll_dispatch_order",Long.class); long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='101'",Long.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java new file mode 100644 index 000000000..b2df98597 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java @@ -0,0 +1,144 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.AfterEach; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +/** 装完即发车的共同事务、原键最终结果及跨端竞争使用真实根单据和版本。 */ +class DispatchLoadingDeparturePersistenceTest { + /** 每例恢复隔离身份,不影响其他服务权限测试。 */ + @AfterEach void clearSecurity(){SecurityContextHolder.clearContext();} + + /** 一次确认完成全部客户发运;原键重放返回派送中,不再生成子单或执行来源。 */ + @Test void loadingCompletesAllCustomersAndReplaysOnlyFinalResult() throws Exception { + authorize();var f=new Fixture(); + var result=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete"))); + assertThat(result.action()).isEqualTo(Action.LOADING_COMPLETE);assertThat(result.stage()).isEqualTo(Stage.DELIVERING); + assertThat(result.counts().shippedPackageCount()).isEqualTo(8);assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class); + assertThat(f.f.db.queryForList("SELECT status FROM ll_outbound_order",String.class)).containsExactly("OUTBOUNDED","OUTBOUNDED"); + assertThat(f.f.db.queryForList("SELECT status FROM ll_delivery_order",String.class)).containsExactly("DELIVERING","DELIVERING"); + assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT"); + assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2); + assertThat(f.f.db.queryForObject("SELECT result_json FROM ll_dispatch_command WHERE idempotency_key='complete'",String.class)).contains("DELIVERING"); + var replay=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete"))); + assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(result.recordVersion());assertThat(replay.result()).isEqualTo(result.result()); + assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"complete")).stage()).isEqualTo(Stage.DELIVERING); + verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any()); + assertThat(f.f.service.actions(f.f.actor,f.f.repository.find(f.number))).doesNotContainKeys(Action.ARRANGE_DELIVERY,Action.DEPART); + assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("stale-new")))).hasMessageContaining("版本或审核阶段"); + } + + /** 任一后续步骤失败都恢复装车中,已提交的真实拣货保持,不留下中间装车或子单。 */ + @Test void preparationAndDepartureFailuresRollBackTheEntireLoadingCommand() throws Exception { + authorize(); + for(boolean failPreparation:List.of(true,false)) { + var f=new Fixture(); + if(failPreparation)doThrow(new IllegalStateException("模拟单据准备失败")).when(f.delivery).prepareForDeparture(any(),any(),anyString()); + else doThrow(new IllegalStateException("模拟来源发运失败")).when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any()); + assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("failed")))).isInstanceOf(IllegalStateException.class); + var row=f.f.repository.find(f.number);assertThat(row.getStage()).isEqualTo("LOADING");assertThat(row.getRecordVersion()).isEqualTo(f.version); + assertThat(row.getLoadingCompletionHash()).isNull();assertThat(row.getArrangementKey()).isNull();assertThat(row.getDepartedAt()).isNull(); + assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Long.class)).isZero(); + assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero(); + assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action IN ('LOADING_COMPLETE','ARRANGE_DELIVERY','DEPART')",Long.class)).isZero(); + assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2); + assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED"); + } + } + + /** PDA保持本仓权限、原键权限复验;不借自动流程绕开真实装载清单。 */ + @Test void pdaUsesOnePermissionButRejectsOtherWarehouseAndInvalidEvidence() throws Exception { + authorize();var f=new Fixture();var pda=new Actor("picker",ActorMode.PDA,"9"); + assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(new Actor("picker",ActorMode.PDA,"99"),f.number,f.request("foreign")))).hasMessageContaining("当前绑定仓"); + var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",f.measurements,null); + assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,bad))).hasMessageContaining("真实装载清单已变化"); + verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString()); + var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.DELIVERING); + SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("picker","","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")); + assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")))).hasMessageContaining("装车完成权限"); + } + + /** 同一版本后台/PDA同时确认只有一条完整链提交,不会产生半车或重复派送。 */ + @Test void concurrentAdminAndPdaCompleteOnlyOneDeparture() throws Exception { + authorize();var f=new Fixture();var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2); + try { + var a=workers.submit(()->f.tryComplete(start,f.f.actor,"admin")); + var b=workers.submit(()->f.tryComplete(start,new Actor("picker",ActorMode.PDA,"9"),"pda")); + start.countDown();assertThat(List.of(a.get(25,TimeUnit.SECONDS),b.get(25,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false); + } finally {workers.shutdownNow();} + assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING"); + verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any()); + } + + /** 仅配置装车完成及PDA既有角色权限,不授予人工派送、发车权限。 */ + private static void authorize(){SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator","", + "platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));} + + /** 主流程与发车使用真实SQL,派送提供者受控写入真实子单来验证共同回滚。 */ + private static final class Fixture { + final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); + final DispatchDeliveryPort delivery=mock(DispatchDeliveryPort.class); + final DispatchPdaWorkflowService sourceWorkflow=mock(DispatchPdaWorkflowService.class); + final DispatchLoadingDepartureService service;final String number;final long version;final String manifest; + final List measurements; + /** 创建两客户货物并走真实两级审核、真实拣货归属,不伪造已装车状态。 */ + Fixture() throws Exception { + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("create",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"202",502,"LAX9",5))))); + number=created.dispatchNo();version=f.approve(created).recordVersion(); + f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8"); + f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0"); + var preview=f.tx.execute(s->f.service.loading(f.actor,number));manifest=preview.manifestHash(); + measurements=preview.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,BigDecimal.ONE,BigDecimal.ONE,p.deliveryIndex())).toList(); + when(delivery.prepareForDeparture(any(),any(),anyString())).thenAnswer(call->{ + Context c=call.getArgument(0);assertThat(c.stage()).isEqualTo(Stage.AFTER_LOADING); + var os=outbounds("WAIT_OUTBOUND");var ds=deliveries(c,"WAIT_DELIVERY"); + for(int n=0;n{ + Context c=call.getArgument(0);f.db.queryForList("SELECT id FROM ll_outbound_order ORDER BY id FOR UPDATE");f.db.queryForList("SELECT id FROM ll_delivery_order ORDER BY id FOR UPDATE"); + return new DepartureFacts(number,"AUTO-ARR",c.cargoHash(),c.loadingCompletionHash(),"business-hash",outbounds("WAIT_OUTBOUND"),deliveries(c,"WAIT_DELIVERY"),c.cargo(),new Gate(true,null,null)); + }); + when(delivery.markDeparted(any(),any(),any(),anyString())).thenAnswer(call->{ + Context c=call.getArgument(0);f.db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED'");f.db.update("UPDATE ll_delivery_order SET status='DELIVERING'"); + return new DepartureResult(outbounds("OUTBOUNDED"),deliveries(c,"DELIVERING"),8); + }); + var ledger=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)); + var hashes=new DispatchCommandHasher(f.json); + var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)); + var departure=new DispatchDepartureService(f.service,f.repository,sources,delivery,sourceWorkflow,f.fleet,ledger,hashes,f.json); + service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes); + } + /** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */ + DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,measurements,"实装发车");} + /** 两客户子出库保持独立业务号和箱数。 */ + List outbounds(String status){return List.of(new OutboundReference("101","客户101","OUT-A",status,3,1),new OutboundReference("202","客户202","OUT-B",status,5,1));} + /** 两目的地仍对应各自真实客户,不把整车合并为单客户。 */ + List deliveries(Context c,String status){return List.of(new DeliveryReference("GA","101","客户101","OUT-A","DL-A",c.cargo().get(0).destination(),status,3,1),new DeliveryReference("GB","202","客户202","OUT-B","DL-B",c.cargo().get(1).destination(),status,5,1));} + /** 各线程独立身份和数据库事务竞争同一整车父锁。 */ + boolean tryComplete(CountDownLatch start,Actor actor,String key) throws Exception { + authorize();try {start.await(5,TimeUnit.SECONDS);f.tx.execute(s->service.complete(actor,number,request(key)));return true;} + catch(com.baiyue.common.core.exception.BizException expected){return false;} + finally {SecurityContextHolder.clearContext();} + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java index 3f3c4cf6a..c1d05ea82 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java @@ -13,6 +13,32 @@ import static org.assertj.core.api.Assertions.*; /** 统一PDA读取实际多客户主单和逐项扫码,仓限制在SQL完成。 */ class DispatchPdaReadPersistenceTest { + /** 默认PDA待办包含已通过调度的待理货整车,资源取同一冻结快照。 */ + @Test void pendingTallyReviewIsVisibleWithResourcesButDraftAndRejectedAreNot() throws Exception { + var security=org.springframework.security.core.context.SecurityContextHolder.getContext();var previous=security.getAuthentication(); + security.setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("picker","","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")); + try { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pending-read",List.of(f.source(1,"101",501,"ONT8",3))))); + f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT)"); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchWholePdaReadMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet(); + var queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service); + var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9"); + assertThat(f.tx.>execute(s->queries.list(actor,null))).isEmpty(); + var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null))); + assertThat(f.tx.>execute(s->queries.list(actor,null))).isEmpty(); + var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null))); + var list=f.tx.execute(s->queries.list(actor,null));assertThat(list).hasSize(1); + var task=list.get(0);assertThat(task.stage()).isEqualTo(Stage.BEFORE_LOADING); + assertThat(task.actions().get(Action.LOADING_REVIEW).allowed()).isTrue();assertThat(task.actions().get(Action.PICK).allowed()).isFalse(); + assertThat(task.resources().vehicle().plate()).isEqualTo("TEST-TRUCK");assertThat(task.resources().driver().name()).isEqualTo("测试司机"); + f.tx.execute(s->f.service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"tally-reject",Decision.REJECTED,"车辆不符"))); + assertThat(f.tx.>execute(s->queries.list(actor,null))).isEmpty(); + assertThat(f.tx.execute(s->queries.task(actor,created.dispatchNo())).reviews().loadingReview().reason()).isEqualTo("车辆不符"); + } finally {security.setAuthentication(previous);} + } + /** 同仓两个客户、两个目的地只返回一个整车作业,别仓不能借单号读取。 */ @Test void readsOneTruckAcrossCustomersAndRejectsOtherWarehouseNumber() throws Exception { var f=new DispatchAggregatePersistenceTest.Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java new file mode 100644 index 000000000..b1ca5fa4c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java @@ -0,0 +1,126 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.port.*; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static org.assertj.core.api.Assertions.*; + +/** 审核两端共用真实持久主单,驳回回客服且不能复用上一轮批准。 */ +class DispatchReviewWorkflowPersistenceTest { + /** 客服、调度、理货队列互斥,任一级驳回都只回客服队列。 */ + @Test void eachReviewRejectionReturnsOnlyToCustomerServiceQueue() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("queues",List.of(f.source(1,"101",501,"ONT8",3))))); + assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO); + var submitted=submit(f,created,"submit-1");assertQueue(f,created.dispatchNo(),BeforeLoadingSection.DISPATCH_REVIEW); + var rejected=review(f,f.actor,submitted,false,"dispatch-reject",Decision.REJECTED," 车辆图片不清楚 "); + assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO); + assertThat(f.service.detail(f.actor,created.dispatchNo()).reviews().dispatchReview().reason()).isEqualTo("车辆图片不清楚"); + var resubmitted=submit(f,rejected,"submit-2"); + var approved=review(f,f.actor,resubmitted,false,"dispatch-approve",Decision.APPROVED,null); + assertQueue(f,created.dispatchNo(),BeforeLoadingSection.LOADING_REVIEW); + var tallyRejected=review(f,f.actor,approved,true,"loading-reject",Decision.REJECTED,"司机不符"); + assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO); + var detail=f.service.detail(f.actor,created.dispatchNo()); + assertThat(detail.reviews().loadingReview().reason()).isEqualTo("司机不符"); + assertThat(detail.reviews().dispatchReview().status()).isEqualTo(ReviewStatus.NOT_SUBMITTED); + assertThatThrownBy(()->review(f,f.actor,tallyRejected,true,"cannot-skip-dispatch",Decision.APPROVED,null)).hasMessageContaining("状态已变化"); + var third=submit(f,tallyRejected,"submit-3");assertQueue(f,created.dispatchNo(),BeforeLoadingSection.DISPATCH_REVIEW); + detail=f.service.detail(f.actor,created.dispatchNo()); + assertThat(detail.reviews().dispatchReview().reviewRound()).isEqualTo(3); + assertThat(detail.reviews().loadingReview().reason()).isNull(); + assertThatThrownBy(()->review(f,f.actor,third,true,"cannot-skip-again",Decision.APPROVED,null)).hasMessageContaining("状态已变化"); + } + + /** 两级拒绝都须真实原因;失败不写版本、审核历史或命令结果。 */ + @Test void blankRejectionReasonIsRejectedAtBothLevelsWithoutWrites() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("reason",List.of(f.source(1,"101",501,"ONT8",3))))); + var submitted=submit(f,created,"submit"); + assertThatThrownBy(()->review(f,f.actor,submitted,false,"blank-dispatch",Decision.REJECTED," \n ")).hasMessageContaining("驳回原因不能为空"); + assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(submitted.recordVersion()); + var approved=review(f,f.actor,submitted,false,"approve",Decision.APPROVED,null); + assertThatThrownBy(()->review(f,f.actor,approved,true,"blank-loading",Decision.REJECTED,null)).hasMessageContaining("驳回原因不能为空"); + assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(approved.recordVersion()); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key LIKE 'blank-%'",Long.class)).isZero(); + } + + /** 客服多次改车货保留本轮驳回原因,重新提交才开始全新审核轮次。 */ + @Test void cargoChangesKeepRejectionReasonUntilNextSubmission() throws Exception { + for(boolean loading:List.of(false,true)) { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("retain",List.of(f.source(1,"101",501,"ONT8",3))))); + var current=submit(f,created,"submit"); + if(loading)current=review(f,f.actor,current,false,"approve",Decision.APPROVED,null); + var rejected=review(f,f.actor,current,loading,"reject",Decision.REJECTED,"请重新核对货物"); + var added=f.tx.execute(s->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(rejected.recordVersion(),"add",List.of(f.source(2,"101",502,"ONT8",2)),List.of()))); + var addedAgain=f.tx.execute(s->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(added.recordVersion(),"add-again",List.of(f.source(3,"101",503,"ONT8",1)),List.of()))); + long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=3",Long.class); + var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,new DispatchCommands.ReasonCommand(addedAgain.recordVersion(),"remove","调整目的仓"))); + var resources=f.configured("updated-resources",List.of()); + var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正"))); + var detail=f.service.detail(f.actor,created.dispatchNo()); + var retained=loading?detail.reviews().loadingReview():detail.reviews().dispatchReview(); + assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT); + assertThat(retained.status()).isEqualTo(ReviewStatus.REJECTED);assertThat(retained.reason()).isEqualTo("请重新核对货物"); + assertThat(retained.basisHash()).isNull();assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO); + submit(f,updated,"resubmit");detail=f.service.detail(f.actor,created.dispatchNo()); + assertThat(detail.reviews().dispatchReview().status()).isEqualTo(ReviewStatus.PENDING); + assertThat(detail.reviews().dispatchReview().reason()).isNull();assertThat(detail.reviews().loadingReview().reason()).isNull(); + } + } + + /** PDA理货共享后台版本;成功恢复不重复写,越仓、失权和替换身份均拒绝。 */ + @Test void pdaReviewSharesVersionAndOriginalRecoveryWithAdmin() throws Exception { + var security=SecurityContextHolder.getContext();var previous=security.getAuthentication(); + try { + security.setAuthentication(new TestingAuthenticationToken("picker","","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")); + var f=new DispatchAggregatePersistenceTest.Fixture(); + var pda=new DispatchContracts.Actor("picker",ActorMode.PDA,"9"); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pda-review",List.of(f.source(1,"101",501,"ONT8",3))))); + var submitted=submit(f,created,"submit"); + assertThat(f.service.actions(pda,f.repository.find(created.dispatchNo())).get(Action.LOADING_REVIEW).allowed()).isFalse(); + assertThatThrownBy(()->review(f,pda,submitted,false,"pda-dispatch",Decision.APPROVED,null)).hasMessageContaining("仅当前绑定仓"); + var approved=review(f,f.actor,submitted,false,"dispatch",Decision.APPROVED,null); + assertThat(f.service.actions(pda,f.repository.find(created.dispatchNo())).get(Action.LOADING_REVIEW).allowed()).isTrue(); + assertThatThrownBy(()->review(f,new DispatchContracts.Actor("picker",ActorMode.PDA,"99"),approved,true,"wrong-warehouse",Decision.APPROVED,null)).hasMessageContaining("当前绑定仓"); + var loaded=review(f,pda,approved,true,"pda-approve",Decision.APPROVED,null); + assertThat(loaded.stage()).isEqualTo(Stage.LOADING); + assertThatThrownBy(()->review(f,f.actor,approved,true,"admin-stale",Decision.APPROVED,null)).hasMessageContaining("版本已变化"); + assertThat(review(f,pda,approved,true,"pda-approve",Decision.APPROVED,null).replayed()).isTrue(); + assertThat(f.tx.execute(s->f.service.command(pda,created.dispatchNo(),"pda-approve")).replayed()).isTrue(); + assertThatThrownBy(()->f.tx.execute(s->f.service.command(new DispatchContracts.Actor("picker",ActorMode.PDA,"99"),created.dispatchNo(),"pda-approve"))).hasMessageContaining("当前绑定仓"); + assertThatThrownBy(()->f.tx.execute(s->f.service.command(new DispatchContracts.Actor("other",ActorMode.PDA,"9"),created.dispatchNo(),"pda-approve"))).hasMessageContaining("无权"); + security.setAuthentication(new TestingAuthenticationToken("picker","","lastleg:pda:operate")); + assertThatThrownBy(()->review(f,pda,approved,true,"pda-approve",Decision.APPROVED,null)).hasMessageContaining("仅当前绑定仓"); + assertThatThrownBy(()->f.tx.execute(s->f.service.command(pda,created.dispatchNo(),"pda-approve"))).hasMessageContaining("原动作权限"); + assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(loaded.recordVersion()); + } finally {security.setAuthentication(previous);} + } + + /** 使用服务真实SQL分页和全量统计验证当前队列,不以返回动作猜测队列。 */ + private static void assertQueue(DispatchAggregatePersistenceTest.Fixture f,String number,BeforeLoadingSection expected) { + for(var section:BeforeLoadingSection.values()) { + var page=f.tx.execute(s->f.service.page(f.actor,new DispatchViews.Query(1,20,number,null,null,null,null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,section,null))); + assertThat(page.total()).isEqualTo(section==expected?1:0); + var counts=page.statistics().beforeLoadingReviewCounts(); + assertThat(counts.vehicleInfoTotal()).isEqualTo(expected==BeforeLoadingSection.VEHICLE_INFO?1:0); + assertThat(counts.dispatchReview().pending()).isEqualTo(expected==BeforeLoadingSection.DISPATCH_REVIEW?1:0); + assertThat(counts.loadingReview().pending()).isEqualTo(expected==BeforeLoadingSection.LOADING_REVIEW?1:0); + } + } + + /** 每次提交以真实返回版本开始下一轮,不人工造审核状态。 */ + private static DispatchContracts.CommandResult submit(DispatchAggregatePersistenceTest.Fixture f,DispatchContracts.CommandResult prior,String key) { + return f.tx.execute(s->f.service.submitReview(f.actor,prior.dispatchNo(),new DispatchCommands.SubmitReview(prior.recordVersion(),key,null))); + } + + /** 通过同一审核服务分别调用两端,版本和幂等键始终由请求明确提供。 */ + private static DispatchContracts.CommandResult review(DispatchAggregatePersistenceTest.Fixture f,DispatchContracts.Actor actor,DispatchContracts.CommandResult prior,boolean loading,String key,Decision decision,String reason) { + return f.tx.execute(s->f.service.review(actor,prior.dispatchNo(),loading,new DispatchCommands.ReviewCommand(prior.recordVersion(),key,decision,reason))); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java index 4e01a632f..c324fcb87 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java @@ -72,6 +72,18 @@ class PdaOutboundDispatchContractTest { verify(outboundService).pdaTasks("900", null); } + /** PDA只暴露理货审核且身份固定取绑定仓,不提供调度审核入口。 */ + @Test + void loadingReviewUsesTheSharedServiceWithBoundPdaIdentity() { + var workflow=mock(com.baiyue.lastleg.dispatch.service.DispatchAggregateService.class); + var controller=new DispatchPdaController();ReflectionTestUtils.setField(controller,"workflow",workflow); + var user=pda(Set.of("lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"),900L); + var request=new DispatchCommands.ReviewCommand(2L,"pda-review",DispatchTypes.Decision.REJECTED,"司机资料待核对"); + controller.loadingReview(user,"PC202610210001",request); + verify(workflow).review(new DispatchContracts.Actor("7",DispatchTypes.ActorMode.PDA,"900"),"PC202610210001",true,request); + assertFalse(Arrays.stream(DispatchPdaController.class.getDeclaredMethods()).anyMatch(method->method.getName().equals("dispatchReview"))); + } + @Test void missingRolePermissionOrWarehouseBindingIsRejected() { DispatchPdaController controller = new DispatchPdaController(); @@ -88,7 +100,10 @@ class PdaOutboundDispatchContractTest { String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-pda-operations-v1.yaml"), StandardCharsets.UTF_8); assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches:")); - assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:")); + assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review:")); + assertTrue(yaml.contains("overseas-business-v1.yaml#/components/schemas/DispatchReviewCommand")); + assertFalse(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:")); + assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:")); assertTrue(yaml.contains("/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:")); assertTrue(yaml.contains("/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick:")); assertFalse(yaml.contains("name: customerId")); diff --git a/scripts/README.md b/scripts/README.md index dd51f64f6..829609a8a 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -117,7 +117,7 @@ F33 分阶段助手的组合换标顺序为 `unit → unit-carton → bind`: F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_CANCELLED` 只运行显式指定的一种顺序,省略仍串行执行三种;报告和成功范围仅覆盖所列模式。`--verify-delivery-event-recovery` 必须同时显式指定一种顺序,且环境为TEST;仅在该模式第一张真实FBA派送签收前开启定向ACK丢失,不能使用普通海外转运的签收替代国内回写。故障窗口会重建同镜像Lastleg并临时调整事件目标和退避配置,禁止与其他部署或共享环境写入并行;原事件DEAD恢复后必须验证原镜像/配置/网络身份、健康及代理退出。入口事先留存精确故障scope,finally兜底恢复,缺恢复证据或恢复校验失败一律失败。完整库存取证接入既有只读器,报告保留于该批次 `evidence/`,不能把某一模式或事件通过解读为全部反向事件完成。 -`fleet-cross-customer-test-env.mjs` 是最小两客户同一排车主单的串行 TEST 专项入口,默认 `--plan` 零网络。显式运行参数为 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外收货仓ID> --destination-warehouse-id `,凭据仅从显式 `node --env-file="" --env-file=""` 与 `FBA_CORE_JENKINS_AGENT` 提供。通过普通转运来货、PDA实收和客户确认创建两个新客户各一托一箱,真实来源一次Create到同一主单,自动唯一Fleet记录、配置资源并完成两审;一客户未核平时验证整车装车/发车拒绝及完整库存快照不变,全量实拣后按真实测量显式装车、平台逐客户目的地安排,再统一发车、原键重放、逐客户签收,最后正式结案并分别释放车辆/司机,核对结案前后库存零变化。完整库存由只读取证器获取,缺实测不补计划值,不写业务SQL,不部署或改共享配置。报告位于 `reports/fleet-cross-<新批次>/`,每次请求发送前记录意图;失败保留原现场,不自动重跑或续跑,已存在批次拒绝覆盖。finally停用本轮PDA并退出独立会话;业务样本保留。前置拒绝不代表事务中途回滚,后者另验。对应入口保护合同为 `fleet-cross-customer-test-env.test.mjs`;内部生成与验收函数见模块README。 +`fleet-cross-customer-test-env.mjs` 是最小两客户同一排车主单的串行 TEST 专项入口,默认 `--plan` 零网络。显式运行参数为 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外收货仓ID> --destination-warehouse-id `,凭据仅从显式 `node --env-file="" --env-file=""` 与 `FBA_CORE_JENKINS_AGENT` 提供。通过普通转运来货、PDA实收和客户确认创建两个新客户各一托一箱,真实来源一次Create到同一主单,自动唯一Fleet记录、配置资源并完成两审;一客户未核平时验证整车装车/发车拒绝及完整库存快照不变,全量实拣后按真实测量确认装车,自动生成客户目的地单据并发车、原键重放、逐客户签收,最后正式结案并分别释放车辆/司机,核对结案前后库存零变化。完整库存由只读取证器获取,缺实测不补计划值,不写业务SQL,不部署或改共享配置。报告位于 `reports/fleet-cross-<新批次>/`,每次请求发送前记录意图;失败保留原现场,不自动重跑或续跑,已存在批次拒绝覆盖。finally停用本轮PDA并退出独立会话;业务样本保留。前置拒绝不代表事务中途回滚,后者另验。对应入口保护合同为 `fleet-cross-customer-test-env.test.mjs`;内部生成与验收函数见模块README。 头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。单票验收的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。 @@ -125,7 +125,7 @@ F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_ `fleet-trip-rollback-test-env.mjs` 是跨客户同一原生主单发车中途回滚的串行TEST入口,默认`--plan`零网络;实际参数沿用`--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id `,凭据只通过显式TEST/Jenkins环境文件及`FBA_CORE_JENKINS_AGENT`提供。复用正式两客户一托一箱普通转仓来货/实收、同主单两审/实拣/实测装车及平台安排链;按Java客户字符串顺序,通过原业务号只读解析当前主单、客户子单及真实转仓来源,第二客户转仓行建立65秒SELECT FOR UPDATE租约,等待事务自身的50秒会话锁超时使原整车发车失败。不修改业务行、Schema、共享参数或镜像;GLOBAL超时或HTTP超时不能代替会话与事务证据。等待图须精确对应原发车事务、目标转仓主键及实际UPDATE,同时独立READ UNCOMMITTED观察已写的第一客户子单、来源计数及历史;此时主单仍AFTER_LOADING、Fleet为PLANNED、尚无DEPART命令结果,不能沿用旧DEPARTED/重放表断言。最终新已提交读按实际Schema全字段比较相关主/子单、来源历史、占用、命令账本及已提交扫码/SHIP事实,脏读仅作中途证明。租约自动ROLLBACK,核对原事务和等待均已消失,再用同一原发车键/载荷完成正式发车、重放、逐客户签收和资源释放。报告沿`reports/fleet-cross-<新批次>/`,额外`rollback.json`、源码SHA与镜像身份保留;权限、字段、会话超时或任一证据缺失明确阻断,失败保留原现场,不自动重造或续跑,finally仅清理自有PDA/会话。执行需要对应TEST数据库取证、持锁及真实HTTP的明确授权,共享窗口禁止并行发布或操作相同库存。对应`fleet-trip-rollback-test-env.test.mjs`验证证据反例和范围;通用跨客户入口不默认故障注入。 -`fleet-test-env-acceptance.mjs` 是显式 TEST 车辆专项入口,默认 `--plan` 仅输出计划;使用显式外部 TEST 与 Jenkins 配置后,以 `--execute --environment TEST --run-id <新批次>` 串行创建本轮头程/海外来源并验证原费用全覆盖、取消重排后的部分覆盖、外接承运占位、PDA真实收货、外接排车计费、逐客户PDA拣货、整车发车、逐目的地签收及资源释放。报告在 `reports/fleet-/`;目录和锁独占,失败保留原请求/资源,不自动续跑或重造。会通过正式HTTP写入测试资源和取消未执行单据;既有收货取证使用Jenkins只读查询,不执行业务SQL写入、部署或共享配置修改。Finance费用池消费事实与生产者ACK分开记录;UI及未实现车辆产品范围另验。对应离线入口合同为 `fleet-test-env-acceptance.test.mjs`,内部助手详见模块README。 +`fleet-test-env-acceptance.mjs` 是显式 TEST 车辆专项入口,默认 `--plan` 仅输出计划;使用显式外部 TEST 与 Jenkins 配置后,以 `--execute --environment TEST --run-id <新批次>` 串行创建本轮头程/海外来源并验证两组独立货物自动生成派送单据、外接承运占位、PDA真实收货、外接排车计费、逐客户PDA拣货、整车发车、逐目的地签收及资源释放。报告在 `reports/fleet-/`;目录和锁独占,失败保留原请求/资源,不自动续跑或重造。会通过正式HTTP写入测试资源和取消未执行单据;既有收货取证使用Jenkins只读查询,不执行业务SQL写入、部署或共享配置修改。Finance费用池消费事实与生产者ACK分开记录;UI及未实现车辆产品范围另验。对应离线入口合同为 `fleet-test-env-acceptance.test.mjs`,内部助手详见模块README。 整车回滚专项的完整快照覆盖当前23表,包括本批真实实托的`ll_physical_pallet_guard`永久串行行;按实际Schema全列及COUNT取证,中途和回滚后均核对原主键与首次创建时间不变,不把删除重建串行点视作成功回滚。此取证范围仅限该回滚专项,不扩展通用车辆入口的故障注入。 @@ -295,7 +295,7 @@ node --env-file="<仓库外灰度验收环境文件>" scripts/admin-test-env-acc 排车扩展入口还支持独立新批次 `--scenario reviews`,真实覆盖两级驳回重提、零扫码退回改货改车及旧版本/幂等冲突,再接完整履约。`--scenario review-terminal --resume-report <本扩展原失败报告> --resume-report-sha256 <原SHA>` 只接收已完成双签收、请求全部明确RESPONSE、审核包VERIFIED但顺序只读快照跨过异步完成版本的精确停点;新报告先只读核齐原单DELIVERED及全量库存,再仅结案释放本批资源,不重拣、重发、重签或覆盖旧失败。其他未知写和原人工单不接受此模式。 -`dispatch-workflow-extensions-test-env.mjs` 是排车扩展 TEST HTTP 串行入口,默认 `--plan` 零网络。显式执行使用 `--execute --environment TEST --scenario dual-destination --run-id <新批次> --warehouse-id <实际海外仓> --destination-warehouse-id <首个FBA仓> --pda-env-file <现有海外PDA私有配置> --synthetic-pallet-spaces 1`;TEST及只读取证凭据由调用者在脚本前显式 `node --env-file=` 加载。正式目录找到第二个同国有效FBA仓后才造新客户和货物,验证双目的仓、独立费用/单据/库存与完整履约。现有PDA只开新会话、不创建停用账号;托位、费用、附件标记TEST合成,重量体积必须来自正式测量证据,不代表现场或硬件。原人工单不参与新造数。报告 `reports/fleet-cross-/` 与独占intent存在即拒绝重入;未知写保留原键载荷并停写。只复用受保护库存读者,不新增SQL、部署、改权限或清理数据;退出自有会话、保留业务样本。未就绪矩阵保持未执行;直接合同为 `admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs`。 +`dispatch-workflow-extensions-test-env.mjs` 是排车扩展 TEST HTTP 串行入口,默认 `--plan` 零网络。显式执行使用 `--execute --environment TEST --scenario dual-destination --run-id <新批次> --warehouse-id <实际海外仓> --destination-warehouse-id <首个FBA仓> --pda-env-file <现有海外PDA私有配置> --synthetic-pallet-spaces 1`;TEST及只读取证凭据由调用者在脚本前显式 `node --env-file=` 加载。正式目录找到第二个同国有效FBA仓后才造新客户和货物,验证双目的仓、自动单据、实际费用归属/库存与完整履约。现有PDA只开新会话、不创建停用账号;托位、附件标记TEST合成,重量体积必须来自正式测量证据,不代表现场或硬件。原人工单不参与新造数。报告 `reports/fleet-cross-/` 与独占intent存在即拒绝重入;未知写保留原键载荷并停写。只复用受保护库存读者,不新增SQL、部署、改权限或清理数据;退出自有会话、保留业务样本。未就绪矩阵保持未执行;直接合同为 `admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs`。 `--scenario competition` 同样仅使用本轮新样本,验证占用中的车辆/司机拒绝第二张空排车、装车后尚未安排派送的发车拒绝,以及已成功装车原键的两个 Admin/PDA 请求并发重放;原操作员恢复唯一结果,另一操作员被拒绝,完整库存与主单事实不变。该场景不宣称首次有效命令竞争或完整权限矩阵通过。第二次签收确定成功后增加最多30秒只读完结等待,再进入既有跨接口一致性快照;不重放签收写入,也不修改既有失败报告或共享验证器。 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index ce82425ce..853e76efb 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -1,8 +1,12 @@ # Admin 测试环境 HTTP 模块 +`fleet-return-redispatch-acceptance.mjs` 的原出发证明读取本轮 `LOADING_COMPLETE` 回执与 `departureDeliveries`;重派在 PDA 装车完成后回查自动生成的出库/派送及两箱已发运事实,不再调用手工安排派送或确认发车。原键重放和跨操作员拒绝仍由共享装车验证覆盖,原签收不漂移、源订单完成事件审计及资源分别释放仍须独立核对。离线模拟不能代替匹配部署的真实 HTTP。 + +固定托盘造数中,`inbound-measurement.mjs` 缺少 `TP-001` 或 `YW-TP-002` 时记录阻断并停止,不自动选择其他托盘。仅明确设置内部 `allowAnyExistingPallet` 的通用场景可读取当前仓库已有资源;两者均不创建托盘。 + 先运行 `node scripts/acceptance-overview.mjs`(后端根目录,无网络)查看当前默认与可选模块。范围来自真实注册表;本文件描述各模块用途与依赖,不代表所有模块默认执行或已通过。报告解读及跨端/专项边界见 [总索引](../README.md#先看范围再看证据),最新运行状态只查外层部署契约。 -`dispatch-workflow-extension-acceptance.mjs` 由 `../dispatch-workflow-extensions-test-env.mjs` 显式调用,不是独立CLI。双客户双目的仓扩展先从正式目录核验两FBA仓,复用本轮独立来货和PDA实收;派送费用按真实group客户使用不同合成金额,完整正常链后核对逐客户目的仓、费用、单据和交叉读取拒绝。`fleet-cross-customer-fixture.mjs` 可接收两个明确 `destinationWarehouseIds`,在任何造数写前核验全部目的仓;原单目的仓调用不变。所有写经过既有逐请求持久化和未知结果停写边界,不修改共享runner;中途故障注入无工具时明确NOT_TESTED。直接合同 `dispatch-workflow-extension-contract.test.mjs` 不证明环境通过。 +`dispatch-workflow-extension-acceptance.mjs` 由 `../dispatch-workflow-extensions-test-env.mjs` 显式调用,不是独立CLI。双客户双目的仓扩展先从正式目录核验两FBA仓,复用本轮独立来货和PDA实收;装车完成自动生成派送并发车,完整正常链后核对逐客户目的仓、实际费用、单据和交叉读取拒绝。`fleet-cross-customer-fixture.mjs` 可接收两个明确 `destinationWarehouseIds`,在任何造数写前核验全部目的仓;原单目的仓调用不变。所有写经过既有逐请求持久化和未知结果停写边界,不修改共享runner;中途故障注入无工具时明确NOT_TESTED。直接合同 `dispatch-workflow-extension-contract.test.mjs` 不证明环境通过。 其 `competition` 扩展只并发两个已经成功装车命令的跨端原键重放,并回查完整库存、主单、货物和车次;不是首次执行竞争。占用资源与未安排派送发车分别串行验证拒绝后快照不变。签收成功后的自动完结只做有界只读等待,保持真实写回执,等待失败阻断下一请求而不伪报已知写为未知结果。 @@ -20,7 +24,7 @@ `dispatch-repair-acceptance.mjs` 是本批排车修复的内部增量验收助手,不是独立 CLI。`verifyDispatchRepair` 默认只返回计划,显式执行要求 TEST、Lastleg/Admin 健康及 Schema 就绪、请求前后持久化检查点。`context.producerEvidence` 指定本轮生产回执 JSON 的路径及 SHA-256;文件须含 `environment=TEST`、`marker`、已部署 Lastleg 的 `sourceCommit` 和 `requests`。请求条目保留实际 `method/path/body/actor`、`status=RESPONDED`、`http/code/requestId/traceId/result`,不得保存登录凭据或 Token。助手从正式创建响应及父来源回执提取业务号、客户和仓库,形成资源白名单;所有 fixture 关联身份在首个请求前核对,不能仅声明“本轮新建”。缺样本标记 NOT_RUN,不借旧业务样本。 -该助手核对取消出库/取消排车/释放重排、实际出库门禁、换标全托、混托两种展示来源、拆合托后重核、原箱回仓及只读权限。两种混托须使用独立排车/托盘/预订,普通与外接来源的箱数由生产实收回执确定,并回查实物仓位组成及报价数量/单价/金额。原箱限本轮单箱出库,按原始 SKU、客户、仓分页精确筛选库存及流水,以排车号匹配 OUTBOUND;先取完成前 S、首次完成后 R,再同键重放取 R′,核对本轮唯一 SKU 的完成前零库存基线及首次总量、可用展示数量、箱数恢复、receiptNo 的 EXCEPTION_RETURN_IN 精确集合、目标托盘库位及重放零增量。HTTP availableQuantity 是受实箱可用量限制的展示值,不冒称裸余额证明。只对已授权的新排车调用 DELETE 取消状态,不运行移托 DELETE 负向分支,不物理删除、SQL 造数或清理。未知结果保存原命令并停写;证据明确标为合成测试资料,不冒充真实外部预约/POD。保留 `sourceEventsVerified=false`,来源事件、人工页面及 PDA 真机仍单列。相邻 `dispatch-repair-acceptance-contract.test.mjs` 只做离线验证,未实际执行不得表述为环境通过。 +该助手将已撤销的装车后取消/人工重排场景标为 NOT_APPLICABLE,仍核对实际出库门禁、换标全托、混托两种展示来源、拆合托后重核、原箱回仓及只读权限。两种混托须使用独立排车/托盘/预订,普通与外接来源的箱数由生产实收回执确定,并回查实物仓位组成及报价数量/单价/金额。原箱限本轮单箱出库,按原始 SKU、客户、仓分页精确筛选库存及流水,以排车号匹配 OUTBOUND;先取完成前 S、首次完成后 R,再同键重放取 R′,核对本轮唯一 SKU 的完成前零库存基线及首次总量、可用展示数量、箱数恢复、receiptNo 的 EXCEPTION_RETURN_IN 精确集合、目标托盘库位及重放零增量。HTTP availableQuantity 是受实箱可用量限制的展示值,不冒称裸余额证明。不运行移托 DELETE 负向分支,不物理删除、SQL 造数或清理。未知结果保存原命令并停写;证据明确标为合成测试资料,不冒充真实外部预约/POD。保留 `sourceEventsVerified=false`,来源事件、人工页面及 PDA 真机仍单列。相邻 `dispatch-repair-acceptance-contract.test.mjs` 只做离线验证,未实际执行不得表述为环境通过。 - 人工状态矩阵的 `FEE_CONFIRMING` 使用独立新订单,在测量提交成功后暂停审核,经正式 `fees/calculate` 生成费用;回查同一费用版本、入库客户身份、测量仍待审核及入仓码精确筛选结果。该状态是必验项,不再列入不可造数缺口;不发送费用、不推进该样本、不借历史数据。入口仍为 `--modules manual-state-matrix`,对应 `order-inbound-status-fixtures-contract.test.mjs` 和 `manual-state-matrix-contract.test.mjs`。 - `WAIT_PRICING` 另建保留原始 `APPROVED` 的订单,先形成未发布的 `FEE_CONFIRMING`,再通过客户实时渠道目录与报价选择同仓、同目的仓、同税制的不同渠道,以正式申报更新触发费用失效;回查 `WAIT_PRICING/UNPRICED`、新渠道及精确筛选。缺少可计价替换渠道、客户漂移或状态未收敛均失败;不写业务SQL,退出专用目录查询会话。该状态同样为必验项,原先借历史 `WAIT_SPLIT` 样本的兜底已移除。 @@ -44,7 +48,7 @@ `fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;执行时重新核对指定实际收货仓和FBA目的仓,通过正式客户资质、普通转运来货、PDA一托一箱测量/绑定/实收及客户确认生成两套独立本轮货物,再以真实来源一次 Create 建立同一原生排车主单、自动唯一 Fleet 资源记录并完成两级审核,停在 LOADING、零扫码、未生成出库和派送单。每条明细保留其真实客户、来货码、物理托盘和库位,主单不挂客户。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。 -`fleet-cross-customer-acceptance.mjs` 是跨客户同一原生排车主单的内部后半链验收函数,不是独立入口。默认只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收、同一主单已通过两级审核且未拣货,以及调用方提供的完整库存只读取证器。按各明细实际客户扫码,验证一个客户未核平时整车装车/发车拒绝及全部快照不变;全部实拣核平后按实际测量显式装车、平台逐客户目的地安排,再以当前主单版本统一发车、原键重放和分别签收。实托测量缺失明确阻断,不以计费/计划常量替代。前置门禁与事务中途回滚分别记录;默认后者为 `NOT_TESTED`,只有显式注入`verifyDepartureRollback`且返回精确本轮验证、完整一致性快照恢复相同后才记VERIFIED。专用`../fleet-trip-rollback-test-env.mjs`负责租约/脏读观察与持久化,普通入口不注入故障;钩子异常、未验证或快照漂移必须停止在正常发车前。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。 +`fleet-cross-customer-acceptance.mjs` 是跨客户同一原生排车主单的内部后半链验收函数,不是独立入口。默认只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收、同一主单已通过两级审核且未拣货,以及调用方提供的完整库存只读取证器。按各明细实际客户扫码,验证一个客户未核平时整车装车/发车拒绝及全部快照不变;全部实拣核平后按实际测量确认装车,在同一次命令自动生成客户目的地单据并发车;按详情 documents 核对真实单据,原键重放和分别签收。实托测量缺失明确阻断,不以计费/计划常量替代。前置门禁与事务中途回滚分别记录;默认后者为 `NOT_TESTED`,只有显式注入`verifyDepartureRollback`且返回精确本轮验证、完整一致性快照恢复相同后才记VERIFIED。专用`../fleet-trip-rollback-test-env.mjs`负责租约/脏读观察与持久化,普通入口不注入故障;钩子异常、未验证或快照漂移必须停止在正常发车前。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。 `TERMINAL_READONLY` 用于全部业务写入和结案助手均完成、仅专项入口汇总状态断言错误的已复核报告。结案助手成功状态为 `CARGO_COMPLETED_AND_RESOURCES_RELEASED`。该检查点登录后仅 GET 原车次、每张排车/派送、资源占用测试计划与精确财务来源版本,不重放任何业务写入,不覆盖原失败报告;真实状态、数量、签收或费用不符仍失败。它不补充生产者 ACK 或页面验收证据。 @@ -60,7 +64,7 @@ 车辆专项入口为 `../fleet-test-env-acceptance.mjs`,先使用原 `phase4-overseas-dispatch` 依赖链创建本轮客户、头程费用和海外库存,再执行专用车辆编排;不改变已通过的 F40 默认调用顺序。默认 `--plan` 无网络,实际运行须显式 `--execute --environment TEST --run-id <新批次>`。仅串行运行,会创建当前批次资源及执行正式未执行单据取消重排,不执行部署、配置修改或业务 SQL 写入;原收货取证沿用现有只读读取器。失败保留原样本、请求与报告,不自动重造或续跑。 -四个内部助手及各自 `*-contract.test.mjs`:`fleet-booking-reservation-acceptance.mjs` 验证询价/确认/超卖/取消的逐段占位与重放;`fleet-fee-coverage-acceptance.mjs` 用独立排车验证全覆盖及多原入仓的部分覆盖,核对原费用版本、目的地、快照及重放不重复收费;`fleet-external-receipt-acceptance.mjs` 通过正式来货与 PDA 测量、生成托盘、扫码绑定、照片和完成接口入库,按客户/仓/来货码查询余额与流水,验证重放不再入库、运营核量改价和预留替换;`fleet-external-dispatch-acceptance.mjs` 将该实收货物预排并绑定原车次,重核容量并逐客户审核,验证约定运费来源与重放。后两者会产生新外接货,必须走专用编排,不能直接加入只拣一张排车的 F40 默认流程。专项入口继续逐单 PDA 拣货、整车发车、逐目的地签收及资源释放;报告将 Finance 消费入池与生产者 ACK 分别记录,消费行存在不能冒充生产者已收到 ACK。以上助手不能独立当作全链入口,离线合同通过不代表真实 HTTP 或页面验收通过。 +四个内部助手及各自 `*-contract.test.mjs`:`fleet-booking-reservation-acceptance.mjs` 验证询价/确认/超卖/取消的逐段占位与重放;`fleet-fee-coverage-acceptance.mjs` 复用两组独立排车样本,核对装车完成自动生成单据的客户、目的地、箱数和实际费用;已撤销的人工费用覆盖选择不计作通过;`fleet-external-receipt-acceptance.mjs` 通过正式来货与 PDA 测量、生成托盘、扫码绑定、照片和完成接口入库,按客户/仓/来货码查询余额与流水,验证重放不再入库、运营核量改价和预留替换;`fleet-external-dispatch-acceptance.mjs` 将该实收货物预排并绑定原车次,重核容量并逐客户审核,验证约定运费来源与重放。后两者会产生新外接货,必须走专用编排,不能直接加入只拣一张排车的 F40 默认流程。专项入口继续逐单 PDA 拣货、整车发车、逐目的地签收及资源释放;报告将 Finance 消费入池与生产者 ACK 分别记录,消费行存在不能冒充生产者已收到 ACK。以上助手不能独立当作全链入口,离线合同通过不代表真实 HTTP 或页面验收通过。 头程提交、平台编辑与取消按首次原始详情版本冻结请求。原键原载荷重放成功;同键异载荷、旧版本及新键重复操作分别核验拒绝后事实不变。Platform 编辑使用平台权限与显式客户范围,客户 Token 不得调用。 @@ -490,6 +494,6 @@ Client 全量为单文件,新增的客户账单、海外仓自助、跨境采 - 必须在 `baiyue-firstleg` 根运行批量合同:`auth-login-payload` 等合同按 `scripts/...` 相对路径读取,从工作区根运行会产生 `ENOENT` 假失败。 - `appointment-work-test-env`、`agents-index-registry`、`ci-maven-resource`、`deploy-built-service-rollback` 会 `spawn` 子进程;受限沙箱下 `EPERM` 属于运行环境限制,不是合同失败。 - `database-schema-diff`、`prod-release-*`、`fleet-*-test-env` 等需要数据库或测试环境的合同不进入本地批量扫描;按各自 README 单独授权执行。 -- 当前 134 个模块合同中有 4 个 `fleet-*` 合同失败(`fleet-cross-customer-fixture`、`fleet-external-dispatch-acceptance`、`fleet-fee-coverage-acceptance`、`fleet-return-redispatch-acceptance`),失败点在 fleet 业务模拟断言,与脚本新增模块和本次 `phase4-overseas-dispatch`/`finance-downloads` 修改无关,另行处理。 +- 合同数量与失败清单以同一源码快照的实际报告为准,不在模块索引维护固定失败数量;当前已验/未验范围统一查外层部署契约,历史报告不覆盖最新源码。 采购费用不再使用通用费用草稿、审核和发布链。`phase4-overseas-finance-cross-region.mjs` 对采购改为验证通用创建被拒绝,其他四类费用继续验证既有投递链;采购成功来源由完整样品流程核对实扣与来源关联。 diff --git a/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs index d3ab96a18..77649523a 100644 --- a/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs @@ -63,7 +63,7 @@ function producerReport() { event('GET', '/api/platform/v1/last-leg/return-inventory/cartons/BOX', undefined, { cartonMark: 'BOX', sourceReturnNo: 'RT', customerId: 'C', overseasWarehouseId: 'W', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 3 }, { skuCode: 'SKU-B', quantity: 2 }] }) event('POST', '/api/merchant/v1/last-leg/relabel-applications', { operationWarehouseId: 'W' }, { application: { relabelNo: 'RL-N', overseasWarehouseId: 'W' } }, 'MERCHANT') dispatch('NDP', [candidate(null, '601', null, { sourceStage: 'RELABEL', orderRefs: 'RL-N', returnCartonSource: true })], 'RL-N', 1) - event('POST', '/api/platform/v1/last-leg/dispatches/NDP/transfer-delivery', {}, { outboundNo: 'O', deliveries: [{ deliveryNo: 'D' }] }) + event('GET', '/api/platform/v1/last-leg/dispatches/NDP', undefined, { stage:'DELIVERING', documents:{outbounds:[{outboundNo:'O',customerId:'C'}],deliveries:[{deliveryNo:'D',outboundNo:'O',customerId:'C'}]} }) event('GET', '/api/platform/v1/last-leg/delivery-orders/D/return-inbound-options?customerId=C', undefined, [{ customerId: 'C', lastMileOrderId: '91', deliveryNo: 'D', outboundNo: 'O', returnSources: [{ returnCartonId: '81', cartonMark: 'BOX', inventoryPositionId: null }] }]) return { environment: 'TEST', marker: 'CURRENT', sourceCommit: commit, requests } } @@ -98,7 +98,7 @@ test('import/plan is zero-network and missing current fixtures never become a pa const ctx = context(); assert.equal((await verifyDispatchRepair(ctx, {})).status, 'PLAN_ONLY'); assert.equal(ctx.calls.length, 0) const result = await execute(ctx, {}) assert.equal(result.status, 'PARTIAL_NOT_ALL_VERIFIED') - assert.equal(Object.values(result.cases).every(row => row.status === 'NOT_RUN'), true) + assert.equal(Object.entries(result.cases).filter(([key])=>key!=='cancellation').every(([,row]) => row.status === 'NOT_RUN'), true) assert.equal(result.sourceEventsVerified, false) }) for (const field of ['lastlegHealthy', 'adminHealthy', 'schemaReady']) test(`unready ${field} blocks before any HTTP`, async () => { @@ -137,25 +137,7 @@ test('failed pre-send checkpoint leaves the command unsent', async () => { assert.equal(ctx.calls.length, 0) }) -test('new unpicked dispatch cancels outbound, retains cancelled history, re-arranges released pallets and replays old transfer', async () => { - let cancelled = false, dispatchCancelled = false, transfers = 0 - const ctx = context((method, path, body) => { - if (path.endsWith('/transfer-delivery')) return ok({ outboundNo: 'O', deliveries: [{ deliveryNo: 'D' }], replayed: transfers++ > 0 }) - if (path.includes('/delivery-orders/') && path.includes('/status')) { assert.ok(['DELIVERING', 'REJECTED', 'CANCELLED'].includes(body.status)); assert.equal(body.expectedVersion, 0); return reject() } - if (path.includes('/delivery-orders/')) return ok({ order: { deliveryNo: 'D', outboundNo: 'O', status: cancelled ? 'CANCELLED' : 'WAIT_DELIVERY', version: 0, packageCount: 1 } }) - if (path.endsWith('/outbound-orders/O/cancel')) { cancelled = true; return ok(null) } - if (path.endsWith('/outbound-orders/O')) return ok({ outbound: { status: cancelled ? 'CANCELLED' : 'WAIT_OUTBOUND' } }) - if (method === 'DELETE') { assert.equal(path, '/api/platform/v1/last-leg/dispatches/DP'); assert.equal(cancelled, true); dispatchCancelled = true; return ok(null) } - if (path.endsWith('/dispatches/DP')) return ok({ status: dispatchCancelled ? 'CANCELLED' : 'PENDING_DEPARTURE', createdAt: '2026-10-18T16:00:00', actions: { canTransfer: true } }) - if (path.includes('/dispatches/candidates?')) return ok([{ planPalletId: '11' }]) - if (path.endsWith('/pre-arrange')) { assert.deepEqual(body.planPalletIds, ['11']); assert.equal(dispatchCancelled, true); return ok({ dispatchNo: 'DP-NEW', status: 'PENDING_DEPARTURE', items: [{ planPalletId: '11' }] }) } - assert.fail(path) - }) - const result = await execute(ctx, { cancellation: fresh({ allowDispatchCancellation: true, dispatchNo: 'DP', transferBody: { fees: [] }, rearrangeBody: { planPalletIds: ['11'] }, candidateQuery: { sourceStage: 'RECEIVED' } }) }) - assert.equal(result.cases.cancellation.status, 'PASS') - assert.equal(ctx.calls.filter(row => row.method === 'DELETE').length, 1) - assert.equal(ctx.calls.some(row => row.method === 'DELETE' && row.path.includes('/items/')), false) -}) +test('retired pre-departure cancellation is not reported as verified',async()=>{const ctx=context(),proof=await execute(ctx,{});assert.equal(proof.cases.cancellation.status,'NOT_APPLICABLE');assert.equal(ctx.calls.length,0);assert.notEqual(proof.status,'VERIFIED')}) test('domain rejection cannot be faked by a missing permission; actual shipment cancellation leaves facts unchanged', async () => { for (const forbidden of [false, true]) { diff --git a/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs b/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs index 7e1d42e75..5f2a3f31d 100644 --- a/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs +++ b/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs @@ -100,11 +100,11 @@ async function loadProducerResources(context) { must(id(data.overseasWarehouseId) === sources[0].warehouseId && same((data.items || []).map(row => id(row.warehousePalletId)).sort(), sources.map(row => id(row.candidate.warehousePalletId)).sort()), 'Dispatch birth response differs from producer selection') add('dispatch', data.dispatchNo, sources[0].customerId, sources[0].warehouseId, e, { dispatch: data, arrangeBody: e.body }) } - match = path.match(/\/dispatches\/([^/]+)\/transfer-delivery$/) + match = path.match(/\/dispatches\/([^/]+)$/) const dispatched = match && own.dispatch.get(decodeURIComponent(match[1])) - if (e.method === 'POST' && dispatched && data.outboundNo && !data.replayed) { - add('outbound', data.outboundNo, dispatched.customerId, dispatched.warehouseId, e, { dispatchNo: dispatched.key }) - for (const row of data.deliveries || []) add('delivery', row.deliveryNo, dispatched.customerId, dispatched.warehouseId, e, { outboundNo: data.outboundNo, dispatchNo: dispatched.key }) + if (e.method === 'GET' && dispatched && data.stage === 'DELIVERING') { + for (const row of data.documents?.outbounds || []) if (id(row.customerId) === dispatched.customerId) add('outbound', row.outboundNo, dispatched.customerId, dispatched.warehouseId, e, { dispatchNo: dispatched.key }) + for (const row of data.documents?.deliveries || []) if (id(row.customerId) === dispatched.customerId && own.outbound.has(row.outboundNo)) add('delivery', row.deliveryNo, dispatched.customerId, dispatched.warehouseId, e, { outboundNo: row.outboundNo, dispatchNo: dispatched.key }) } match = path.match(/\/delivery-orders\/([^/]+)\/return-inbound-options$/) if (e.method === 'GET' && match && own.delivery.has(decodeURIComponent(match[1])) && Array.isArray(data)) { @@ -242,39 +242,7 @@ export async function verifyDispatchRepair(context, fixtures, { execute = false finally { await context.checkpoint() } } try { - await run('cancellation', async f => { - must(f.allowDispatchCancellation === true && f.dispatchNo && f.transferBody && f.rearrangeBody, 'Owned cancellation fixture and explicit business cancel scope required') - const path = `${BASE}/dispatches/${encodeURIComponent(f.dispatchNo)}` - const original = await call('GET', path) - must(original.status === 'PENDING_DEPARTURE' && original.createdAt && original.actions?.canTransfer, 'New unpicked execution required') - const transferBody = { ...f.transferBody, idempotencyKey: key() } - const transfer = await call('POST', `${path}/transfer-delivery`, transferBody) - must(transfer.outboundNo && transfer.deliveries?.length, 'New outbound and delivery required') - const dpath = scope(`${BASE}/delivery-orders/${encodeURIComponent(transfer.deliveries[0].deliveryNo)}`, f.customerId) - const before = await call('GET', dpath) - must(before.order?.status === 'WAIT_DELIVERY' && before.order.outboundNo === transfer.outboundNo, 'Delivery must belong to new unexecuted outbound') - for (const status of ['DELIVERING', 'REJECTED', 'CANCELLED']) { - await call('PUT', scope(`${BASE}/delivery-orders/${encodeURIComponent(before.order.deliveryNo)}/status`, f.customerId), - { status, expectedVersion: before.order.version, remark: context.marker }, { failure: true }) - } - must(same(shipment((await call('GET', dpath)).order), shipment(before.order)), 'Rejected status commands changed delivery') - const opath = `${BASE}/outbound-orders/${encodeURIComponent(transfer.outboundNo)}` - await call('POST', `${opath}/cancel`, { reason: context.marker }) - must((await call('GET', opath)).outbound?.status === 'CANCELLED', 'Outbound cancellation did not persist') - must((await call('GET', dpath)).order?.status === 'CANCELLED', 'Linked delivery cancellation did not persist') - await call('DELETE', path, undefined, { cancel: true }) - must((await call('GET', path)).status === 'CANCELLED', 'Original dispatch was not retained as cancelled') - const candidateQuery = new URLSearchParams(f.candidateQuery) - const candidates = await call('GET', `${BASE}/dispatches/candidates?${candidateQuery}`) - const expected = f.rearrangeBody.planPalletIds.map(id) - must(expected.length && expected.every(value => candidates.some(row => id(row.planPalletId) === value)), 'Cancelled source pallets were not released') - const replacement = await call('POST', `${BASE}/dispatches/pre-arrange`, f.rearrangeBody) - must(replacement.dispatchNo && replacement.dispatchNo !== f.dispatchNo && replacement.status === 'PENDING_DEPARTURE' - && same(replacement.items.map(row => id(row.planPalletId)).sort(), [...expected].sort()), 'Released source was not re-arranged exactly once') - const replay = await call('POST', `${path}/transfer-delivery`, transferBody) - must(replay.outboundNo === transfer.outboundNo && replay.replayed, 'Old transfer replay generated another outbound') - return { originalDispatchNo: f.dispatchNo, cancelledOutboundNo: transfer.outboundNo, replacementDispatchNo: replacement.dispatchNo } - }) + proof.cases.cancellation = { status: 'NOT_APPLICABLE', reason: '装车完成即派送,已撤销未发车出库取消及人工安排派送场景' } await run('departedGate', async f => { const opath = `${BASE}/outbound-orders/${encodeURIComponent(f.outboundNo)}` const dpath = scope(`${BASE}/delivery-orders/${encodeURIComponent(f.deliveryNo)}`, f.customerId) diff --git a/scripts/admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs b/scripts/admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs index 74c7e8f77..6e79dc488 100644 --- a/scripts/admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs +++ b/scripts/admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs @@ -9,10 +9,10 @@ const OPTIONS = { scenario: MODULE, deploymentBlocker: true } const clone = value => structuredClone(value) const id = value => String(value ?? '') export const extensionMatrix = Object.freeze([ - { scenario: 'dual-destination', scope: 'Two fresh customers, two distinct real FBA destinations, one main/trip; distinct fee inputs and isolated execution documents', readiness: 'READY' }, + { scenario: 'dual-destination', scope: 'Two fresh customers, two distinct real FBA destinations, one main/trip; automatic isolated execution documents and actual fee facts', readiness: 'READY' }, { scenario: 'reviews', scope: 'Both rejection/resubmission rounds, zero-pick return and cargo/resource changes, stale/version/idempotency conflicts', readiness: 'READY' }, { scenario: 'relabel', scope: 'Fresh three-box customer receipt, confirmed zero-scan cancellation, two applications sharing one pallet and native rearrangement', readiness: 'READY_FORMAL_THREE_BOX_RECEIPT' }, - { scenario: 'competition', scope: 'Two-request Admin/PDA replay race, occupied resources and unarranged departure gates; first-execution race remains untested', readiness: 'READY_BOUNDED_REPLAY' }, + { scenario: 'competition', scope: 'Two-request Admin/PDA replay race, occupied resources gates; first-execution race remains untested', readiness: 'READY_BOUNDED_REPLAY' }, { scenario: 'bol', scope: 'Fresh same-destination cargo, empty/same/different formal appointment allocations and actual PDF conservation', readiness: 'READY_FORMAL_APPOINTMENTS' }, ]) export function assertDualDestinationFixture(fixture) { @@ -30,24 +30,6 @@ export function feeCents(value) { const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0')) assert(Number.isSafeInteger(cents)); return cents } -export function applyIndependentFees(body, groups, members, marker) { - assert.equal(groups?.length, 2); assert.equal(body.groups?.length, 2) - assert.equal(new Set(groups.map(row => row.groupKey)).size, 2) - assert.equal(new Set(groups.map(row => id(row.customerId))).size, 2) - const centsByCustomer = new Map(members.map((member, index) => [member.customerId, index === 0 ? 3125 : 4775])) - const expected = {} - for (const group of body.groups) { - const matches = groups.filter(row => row.groupKey === group.groupKey) - assert.equal(matches.length, 1) - const customerId = id(matches[0].customerId), cents = centsByCustomer.get(customerId) - assert(cents && group.fees?.length > 0, 'Explicit current-customer fee services required') - assert(!expected[customerId], 'Duplicated customer fee group') - for (const fee of group.fees) { fee.amount = cents / 100; fee.feeName = `${marker} TEST合成客户独立费用` } - expected[customerId] = group.fees.map(fee => ({ feeType: fee.feeType, currency: fee.currency, cents: feeCents(fee.amount) })) - } - return expected -} - /** Public master-data reads only; no fixture writes until both independent US FBA masters are verified. */ export async function discoverSecondDestination(context, primaryId) { const get = async path => { @@ -88,17 +70,13 @@ export async function verifyDualDestination(context, fixture, options) { } const proof = context.createdResources.dispatchWorkflowExtensions = { scenario: options.bol || 'dual-destination', marker: context.marker, dispatchNo: fixture.dispatchNo, status: 'CHECKING', syntheticTestMaterials: true, physicalMeasurementVerified: false, - hardwareVerified: false, midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION', expectedFees: {}, isolation: [] } + hardwareVerified: false, midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION', isolation: [] } const original = context.request - let arrangement, deferredFailure, raceDone = false, firstRaceDone = false, unarrangedDone = false + let deferredFailure, raceDone = false, firstRaceDone = false const mainPath = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}` const pdaPath = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}` context.request = async function (method, path, body, ...rest) { if (deferredFailure) throw deferredFailure - if (method === 'POST' && path === `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/transfer-delivery`) { - proof.expectedFees = applyIndependentFees(body, arrangement?.groups, fixture.members, context.marker) - proof.arrangeRequest = clone(body); await context.checkpoint() - } let response if (options.firstCompetition && !firstRaceDone && method === 'POST' && path === `${pdaPath}/loading-complete` && fixture.tripProof.loadingCommand?.body?.idempotencyKey === body?.idempotencyKey) { @@ -107,21 +85,14 @@ export async function verifyDualDestination(context, fixture, options) { response = outcome.pdaResponse if (outcome.error) deferredFailure = outcome.error } else response = await original.call(this, method, path, body, ...rest) - if (method === 'GET' && path === `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/delivery-arrangement` - && response.http === 200 && String(response.body?.code) === '200') arrangement = clone(response.body.data) // 写请求已收到确定回执后,只做独立扩展;后续核验失败延至下一读请求抛出,不能把已知成功伪报成未知写。 if (!deferredFailure && response.http === 200 && String(response.body?.code) === '200') { try { if (options.competition && method === 'POST' && path === `${pdaPath}/loading-complete` && !raceDone - && response.body.data?.replayed !== true && response.body.data?.stage === 'AFTER_LOADING') { + && response.body.data?.replayed !== true && response.body.data?.stage === 'DELIVERING') { raceDone = true await verifyLoadingReplayRace(context, fixture, options, original.bind(this), body, response.body.data) } - if (options.competition && method === 'GET' && path === `${mainPath}/delivery-arrangement` && !unarrangedDone - && response.body.data?.stage === 'AFTER_LOADING' && response.body.data?.arrangement == null) { - unarrangedDone = true - await verifyUnarrangedDeparture(context, fixture, options, original.bind(this), response.body.data) - } if (method === 'PUT' && body?.idempotencyKey === `${context.marker}-CROSS-SIGN-1`) { const fence = proof.completionReadFence = { status: 'READING', attempts: [], writeReceiptPreserved: true } for (let attempt = 1; attempt <= 60; attempt++) { @@ -154,11 +125,9 @@ export async function verifyDualDestination(context, fixture, options) { assert.equal(id(delivery.order.customerId), member.customerId) assert.equal(delivery.order.destinationWarehouseCode, member.destination.warehouseCode) assert.equal(Number(delivery.order.packageCount), 1); assert.equal(Number(delivery.order.palletCount), 1) - const expected = proof.expectedFees[member.customerId] - assert(expected?.length > 0); assert.equal(delivery.fees?.length, expected.length) + assert(Array.isArray(delivery.fees)) for (const fee of delivery.fees) { assert(fee.id && !feeIds.has(id(fee.id))); feeIds.add(id(fee.id)) - assert(expected.some(row => row.feeType === fee.feeType && row.currency === fee.currency && row.cents === feeCents(fee.amount))) assert.equal(fee.feeDirection, 'RECEIVABLE'); assert.equal(fee.sourceStatus, 'ACTIVE') } const other = fixture.members[1 - index] @@ -170,7 +139,6 @@ export async function verifyDualDestination(context, fixture, options) { proof.isolation.push({ customerId: member.customerId, wrongCustomerId: other.customerId, deliveryNo: delivery.order.deliveryNo, http: response.http, code: response.body.code, requestId: response.requestId, traceId: response.traceId }) } - assert.notDeepEqual(proof.expectedFees[fixture.members[0].customerId], proof.expectedFees[fixture.members[1].customerId]) proof.status = 'VERIFIED'; await context.checkpoint(); return verified } @@ -238,11 +206,11 @@ export async function executeFirstLoadingRace(context, fixture, options, send, b assert.equal(winnerIndexes.length, 1) const winnerIndex = winnerIndexes[0], winner = responses[winnerIndex], loser = responses[1 - winnerIndex] assert.equal(String(loser.body?.code), 'A0409') - assert.equal(winner.body.data.stage, 'AFTER_LOADING') + assert.equal(winner.body.data.stage, 'DELIVERING') assert.equal(winner.body.data.result?.confirmedLoad?.manifestHash, body.manifestHash) - assert.equal(BigInt(winner.body.data.recordVersion), BigInt(body.recordVersion) + 1n) + assert(BigInt(winner.body.data.recordVersion) > BigInt(body.recordVersion)) race.after = await competitionSnapshot(context, fixture, options, send) - assert.equal(race.after.main.stage, 'AFTER_LOADING') + assert.equal(race.after.main.stage, 'DELIVERING') assert.equal(id(race.after.main.recordVersion), id(winner.body.data.recordVersion)) assert.deepEqual(race.after.stock, race.before.stock) assert.equal(Number(race.after.main.counts.packageCount), 2) @@ -259,20 +227,6 @@ export async function executeFirstLoadingRace(context, fixture, options, send, b } } -async function verifyUnarrangedDeparture(context, fixture, options, send, arrangement) { - const proof = context.createdResources.dispatchWorkflowCompetition - const state = proof.unarrangedDeparture = { status: 'INTENT', before: await competitionSnapshot(context, fixture, options, send), - body: { recordVersion: arrangement.recordVersion, idempotencyKey: `${context.marker}-EXT-NO-ARRANGEMENT-DEPART`, remark: 'TEST装车后未安排派送必须拒绝' } } - assert.equal(state.before.main.stage, 'AFTER_LOADING'); assert.equal(state.before.main.documents?.deliveries?.length, 0) - await context.checkpoint() - const response = await send('POST', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}/depart`, state.body, fixture.pdaToken) - state.response = receipt(response); await context.checkpoint() - assert.equal(String(response.body?.code), 'A0409'); assert.match(response.body?.message || '', /派送|安排/) - state.after = await competitionSnapshot(context, fixture, options, send); assert.deepEqual(state.after, state.before) - context.assert(MODULE, 'Loaded but unarranged departure is rejected without inventory or execution mutation', response, true, '', OPTIONS) - state.status = 'VERIFIED'; await context.checkpoint() -} - export async function verifyCompetitionExtension(context, fixture, options) { const proof = context.createdResources.dispatchWorkflowCompetition = { marker: context.marker, dispatchNo: fixture.dispatchNo, status: 'CHECKING', firstExecutionRace: 'NOT_TESTED', arbitraryPrivilegeMatrix: 'NOT_TESTED_NO_PERMISSION_MUTATION' } @@ -291,7 +245,7 @@ export async function verifyCompetitionExtension(context, fixture, options) { context.assert(MODULE, 'Occupied vehicle/driver reject a second native main without changing the existing trip or inventory', response, true, '', OPTIONS) proof.resourceConflict.status = 'VERIFIED' const verified = await verifyDualDestination(context, fixture, { ...options, competition: true }) - assert.equal(proof.loadingReplayRace?.status, 'VERIFIED'); assert.equal(proof.unarrangedDeparture?.status, 'VERIFIED') + assert.equal(proof.loadingReplayRace?.status, 'VERIFIED') if (options.firstCompetition) assert.equal(proof.firstLoadingRace?.status, 'VERIFIED') proof.status = options.firstCompetition ? 'VERIFIED_FIRST_EXECUTION_AND_REPLAY' : 'VERIFIED_BOUNDED_SCOPE' await context.checkpoint(); return verified @@ -784,10 +738,11 @@ export async function reconcileReviewTerminal(context, fixture, { readInventory assert.equal(outbound.outbound.dispatchNo, fixture.dispatchNo); assert.equal(outbound.outbound.status, 'OUTBOUNDED') assert.equal(delivery.order.dispatchNo, fixture.dispatchNo); assert.equal(delivery.order.status, 'DELIVERED') assert.equal(id(delivery.order.customerId), member.customerId); assert.equal(delivery.order.destinationWarehouseCode, member.destination.warehouseCode) - const expected = extension.expectedFees[member.customerId]; assert.equal(delivery.fees.length, expected.length) + assert(Array.isArray(delivery.fees)) + const originalFees=old.snapshots.BEFORE_SIGN_1.members[index].delivery.fees + assert(Array.isArray(originalFees)); assert.deepEqual(delivery.fees,originalFees) for (const fee of delivery.fees) { - assert(!allFeeIds.has(id(fee.id))); allFeeIds.add(id(fee.id)) - assert(expected.some(row => row.feeType === fee.feeType && row.currency === fee.currency && row.cents === feeCents(fee.amount))) + assert(fee.id && !allFeeIds.has(id(fee.id))); allFeeIds.add(id(fee.id)) } const stock = await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: fixture.warehouseId }) assert.deepEqual(stock, old.snapshots.BEFORE_SIGN_1.members[index].stock, 'Terminal completion must not change already shipped inventory') diff --git a/scripts/admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs b/scripts/admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs index 3eca8ccd1..11a57c230 100644 --- a/scripts/admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs +++ b/scripts/admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { applyIndependentFees, feeCents, assertDualDestinationFixture, extensionMatrix, verifyLoadingReplayRace, executeFirstLoadingRace, assertBolPages, assertConfirmedRelabelCancellation } from './dispatch-workflow-extension-acceptance.mjs' +import { feeCents, assertDualDestinationFixture, extensionMatrix, verifyLoadingReplayRace, executeFirstLoadingRace, assertBolPages, assertConfirmedRelabelCancellation } from './dispatch-workflow-extension-acceptance.mjs' import { parseExtensionArguments, runDispatchExtensions } from '../dispatch-workflow-extensions-test-env.mjs' test('default plan imports without credentials/network and keeps all unexecuted gaps explicit', async () => { @@ -24,26 +24,6 @@ test('terminal continuation requires an explicit original report hash and is nev '--resume-report', 'reports/current/report.json', '--resume-report-sha256', 'a'.repeat(64)]) assert.equal(config.scenario, 'review-terminal'); assert.equal(config.resumeReportSha256, 'a'.repeat(64)) }) -test('actual group owner controls distinct cents even when server group ordering differs', () => { - const groups = [{ groupKey: 'B-GROUP', customerId: 'B' }, { groupKey: 'A-GROUP', customerId: 'A' }] - const body = { recordVersion: '9007199254740993', idempotencyKey: 'ORIGINAL', groups: groups.map(row => ({ groupKey: row.groupKey, - fees: [{ feeType: 'DELIVERY_FEE', currency: 'CNY', amount: 30 }] })) } - const expected = applyIndependentFees(body, groups, [{ customerId: 'A' }, { customerId: 'B' }], 'CURRENT') - assert.deepEqual(body.groups.map(row => row.fees[0].amount), [47.75, 31.25]) - assert.equal(expected.A[0].cents, 3125); assert.equal(expected.B[0].cents, 4775) - assert.equal(body.recordVersion, '9007199254740993'); assert.equal(body.idempotencyKey, 'ORIGINAL') - assert(body.groups.every(row => row.fees[0].feeName.includes('TEST合成'))) -}) -test('unknown customers, repeated groups and missing real required fees fail before transport', () => { - const members = [{ customerId: 'A' }, { customerId: 'B' }] - for (const groups of [[{ groupKey: 'G', customerId: 'A' }, { groupKey: 'G', customerId: 'B' }], - [{ groupKey: 'A', customerId: 'A' }, { groupKey: 'X', customerId: 'FOREIGN' }]]) { - const body = { groups: groups.map(row => ({ groupKey: row.groupKey, fees: [{ feeType: 'DELIVERY_FEE', currency: 'CNY' }] })) } - assert.throws(() => applyIndependentFees(body, groups, members, 'CURRENT')) - } - assert.throws(() => applyIndependentFees({ groups: [{ groupKey: 'A', fees: [] }, { groupKey: 'B', fees: [] }] }, - [{ groupKey: 'A', customerId: 'A' }, { groupKey: 'B', customerId: 'B' }], members, 'CURRENT')) -}) test('dual destination proof rejects reused customer, collapsed destination and missing route stop', () => { const fixture = { members: [{ customerId: 'A', destination: { key: 'FBA:ONE' }, receiptVerified: true }, { customerId: 'B', destination: { key: 'FBA:TWO' }, receiptVerified: true }], @@ -120,10 +100,10 @@ for (const winner of ['PDA', 'ADMIN']) test(`first valid loading CAS race record const context = { marker: 'CURRENT', createdResources: { dispatchWorkflowCompetition: { status: 'CHECKING' } }, checkpoint: async () => {}, assert: () => {} } const fixture = { dispatchNo: 'OWN', tripNo: 'TRIP', warehouseId: 'W', pdaToken: 'OWN_PDA', members: [{ customerId: 'A' }, { customerId: 'B' }] } const body = { recordVersion: '7', idempotencyKey: 'ORIGINAL_PDA', manifestHash: 'ACTUAL_HASH' } - const success = { http: 200, body: { code: '200', data: { stage: 'AFTER_LOADING', recordVersion: '8', result: { confirmedLoad: { manifestHash: 'ACTUAL_HASH' } } } } } + const success = { http: 200, body: { code: '200', data: { stage: 'DELIVERING', recordVersion: '8', result: { confirmedLoad: { manifestHash: 'ACTUAL_HASH' } } } } } const rejected = { http: 200, body: { code: 'A0409' } }, pending = []; let completed = false const send = async (method, path, payload, token) => { - if (method === 'GET') return { http: 200, body: { code: '200', data: { stage: completed ? 'AFTER_LOADING' : 'LOADING', + if (method === 'GET') return { http: 200, body: { code: '200', data: { stage: completed ? 'DELIVERING' : 'LOADING', recordVersion: completed ? '8' : '7', counts: { packageCount: 2, pickedPackageCount: 2 } } } } return new Promise(resolve => { pending.push({ resolve, token, payload }) diff --git a/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs b/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs index cf5a5f619..cbc2f10a2 100644 --- a/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs @@ -35,9 +35,10 @@ test('one booking maps to exactly one commercial order and one actual incoming s }) test('module and unified fresh baselines contain the exact same new table definitions', () => { - const module = read('lastleg-service/src/main/resources/db/lastleg.sql') + // 比较定义而不是文件换行;仍要求列、约束及表选项完全相同。 + const module = read('lastleg-service/src/main/resources/db/lastleg.sql').replace(/\r\n/g, '\n') for (const name of ['ll_fleet_capacity_offer', 'll_fleet_capacity_offer_command', 'll_fleet_pool_order_link']) { - const definition = table(overseas, 'baiyue_lastleg', name) + const definition = table(overseas, 'baiyue_lastleg', name).replace(/\r\n/g, '\n') assert.ok(module.includes(definition), `${name}: module and unified contract drift`) } const sql = read('deploy/sql/migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql') diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs index 6942383e1..351724946 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs @@ -43,8 +43,8 @@ function model(fault = '') { if (method === 'POST' && keys.has(body?.idempotencyKey)) { const prior = keys.get(body.idempotencyKey); assert.deepEqual(body,prior.body) if(token!==prior.actor) { - if(fault==='foreign departure actor accepted'&&path.endsWith('/depart'))return response({...prior.result,replayed:true}) - if(fault==='foreign departure actor mutates'&&path.endsWith('/depart'))state[0].stock.ledger.push({...state[0].stock.ledger[0],id:'FOREIGN-ACTOR-MUTATION'}) + if(fault==='foreign departure actor accepted'&&path.endsWith('/loading-complete'))return response({...prior.result,replayed:true}) + if(fault==='foreign departure actor mutates'&&path.endsWith('/loading-complete'))state[0].stock.ledger.push({...state[0].stock.ledger[0],id:'FOREIGN-ACTOR-MUTATION'}) return forbidden() } return response({...prior.result,replayed:true}) @@ -56,6 +56,7 @@ function model(fault = '') { if(fault==='aggregation delayed'&&state.every(s=>s.delivery?.order.status==='DELIVERED')) { if(aggregationReads===0)stage='DELIVERED';else aggregationReads-- } + if(stopAtArrangement&&stage==='DELIVERING'&&fixture.tripProof.loadingCommand?.afterReplay)throw Error('Explicit test checkpoint after confirmed loading') return response(detail()) } if (path.endsWith('/loading') && method === 'GET') return response({dispatchNo:'DS-ONE',status:'ACTIVE',stage,recordVersion:nativeVersion,manifestHash:'REAL-MANIFEST', @@ -64,11 +65,15 @@ function model(fault = '') { evidence:[actualEvidence('weightKg',100,i),actualEvidence('volumeCbm',1,i)]}))}) if (path.endsWith('/loading-complete')) { if (!state.every(s=>s.picked===1)) { + if(fault==='gate reorders route')[trip.trip.stops[1],trip.trip.stops[2]]=[trip.trip.stops[2],trip.trip.stops[1]] + if(fault==='gate mutates second customer')state[1].stock.balances[0].holdPackages='1' if(fault==='outage disguised as rejection')return {http:503,body:{code:'S0503'},requestId:'r',traceId:'t'} return reject(fault==='unrelated gate rejection'?'车次版本已变化':'逐项真实拣货尚未核平') } assert.equal(body.recordVersion,nativeVersion); assert.equal(body.measurements.length,2) - stage='AFTER_LOADING';nativeVersion++ + stage='DELIVERING';nativeVersion++;trip.trip.status='IN_TRANSIT' + for(const [i,s] of state.entries()) { const m=members[i];s.outbound={outboundNo:`OUT${i}`,dispatchNo:'DS-ONE',overseasWarehouseId:'WH',customerId:m.customerId,status:'OUTBOUNDED',packageCount:1};s.delivery={order:{deliveryNo:`DL${i}`,customerId:fault==='foreign delivery'?'OTHER':m.customerId,dispatchNo:'DS-ONE',outboundNo:`OUT${i}`,status:fault==='partial departure'&&i===1?'WAIT_DELIVERY':'DELIVERING',version:0}} } + if(fault==='depart deducts again'){state[0].stock.balances[0].availablePackages='-1';state[0].stock.positions[0].availablePackages='-1'} confirmedLoad={manifestHash:body.manifestHash,pallets:copy(body.measurements),operatorId:'PDA-ACTOR',confirmedAt:'2026-10-19T01:00:00Z'} return remember(body,{confirmedLoad,loadingCompletionHash:'LOADED'},token,'LOADING_COMPLETE') } @@ -82,29 +87,6 @@ function model(fault = '') { } if (path === '/api/pda/v1/last-leg/dispatches/DS-ONE') return response({dispatchNo:'DS-ONE',warehouseId:'WH',status:'ACTIVE',stage,recordVersion:nativeVersion,counts:counts(),pickingReconciled:state.every(s=>s.picked===1), items:items().map((item,i)=>({...item,positions:fault==='missing PDA position'?[]:[{positionId:members[i].positionId,balanceId:members[i].balanceId,availablePackages:state[i].stock.positions[0].availablePackages}]}))}) - if (path.endsWith('/delivery-arrangement')) { - if(stopAtArrangement)throw Error('Explicit test checkpoint after confirmed loading') - return response({dispatchNo:'DS-ONE',recordVersion:nativeVersion,status:'ACTIVE',stage,cargoHash:'CARGO',loadingCompletionHash:'LOADED',arrange:{allowed:true},arrangement:arranged?{status:'ARRANGED'}:null, - groups:members.map((m,i)=>({groupKey:`GROUP${i}`,customerId:m.customerId,groupCargoHash:`GROUP-HASH${i}`,destination:{key:i?'FBA:B':'FBA:A'},cargo:[{customerId:m.customerId,warehousePalletId:m.palletId}],blockReasons:[],requiredServices:['DELIVERY_FEE']}))}) - } - if (path.endsWith('/transfer-delivery')) { - assert.equal(stage,'AFTER_LOADING');assert.equal(body.recordVersion,nativeVersion);assert.equal(body.groups.length,2) - assert.ok(body.groups.every(g=>g.fees.length===1&&g.fees[0].amount===30)) - for(const [i,s] of state.entries()) { const m=members[i];s.outbound={outboundNo:`OUT${i}`,dispatchNo:'DS-ONE',overseasWarehouseId:'WH',customerId:m.customerId,status:'WAIT_OUTBOUND'} - s.delivery={order:{deliveryNo:`DL${i}`,customerId:fault==='foreign delivery'?'OTHER':m.customerId,dispatchNo:'DS-ONE',outboundNo:`OUT${i}`,status:'WAIT_DELIVERY',version:0}} } - arranged=true;nativeVersion++;return remember(body,{dispatchNo:'DS-ONE',outbounds:state.map((s,i)=>({...s.outbound,packageCount:1})),deliveries:deliveryRefs()},token) - } - if (path.endsWith('/depart')) { - if(!arranged) { - if(fault==='gate reorders route')[trip.trip.stops[1],trip.trip.stops[2]]=[trip.trip.stops[2],trip.trip.stops[1]] - if(fault==='gate mutates second customer'){state[1].stock.balances[0].holdPackages='1';state[1].stock.balances[0].availablePackages='0';state[1].stock.positions[0].holdPackages='1';state[1].stock.positions[0].availablePackages='0'} - return reject('请先完成装车并一次安排全部客户派送') - } - assert.equal(body.recordVersion,nativeVersion);stage='DELIVERING';nativeVersion++;trip.trip.status='IN_TRANSIT' - for(const [i,s]of state.entries())if(!(fault==='partial departure'&&i===1)){s.outbound.status='OUTBOUNDED';s.delivery.order.status='DELIVERING'} - if(fault==='depart deducts again'){state[0].stock.balances[0].availablePackages='-1';state[0].stock.positions[0].availablePackages='-1'} - return remember(body,{shippedPackages:2,departedAt:'2026-10-19T01:00:00Z',deliveries:deliveryRefs(),outbounds:state.map(s=>s.outbound)},token) - } const index=members.findIndex((_m,i)=>path.includes(`/OUT${i}`)||path.includes(`/DL${i}`)),row=state[index] if(path.includes('/outbound-orders/'))return response({outbound:row.outbound,deliveries:[row.delivery.order]}) if(path.endsWith('/documents')) { @@ -127,7 +109,7 @@ test('default call and import do not request, create or activate anything',async test('current DTO-shaped two-customer trip rejects unready gate, departs once and signs independently',async()=>{ const m=model();const proof=await verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}) assert.equal(proof.status,'VERIFIED');assert.equal(proof.gateProof,'PREREQUISITE_REJECTION_VERIFIED');assert.equal(proof.midTransactionRollback,'NOT_TESTED') - assert.equal(m.calls.filter(c=>c.raw.endsWith('/depart')).length,4) + assert.equal(m.calls.some(c=>c.raw.endsWith('/depart')),false) assert.deepEqual(m.state.map(s=>s.delivery.order.status),['DELIVERED','DELIVERED']) assert.ok(m.calls.filter(c=>c.raw.includes('/api/pda/')).every(c=>c.token===m.fixture.pdaToken)) assert.ok(!JSON.stringify(proof).includes(m.fixture.pdaToken)) @@ -148,10 +130,10 @@ test('explicit rollback verifier runs after both picks and before successful dep const m=model();let calls=0 const proof=await verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory, verifyDepartureRollback:async({ready,departPath,departBody})=>{ - calls++;assert.equal(ready.trip.trip.status,'PLANNED');assert.equal(ready.dispatch.stage,'AFTER_LOADING') - assert.ok(ready.members.every(row=>row.outbound.outbound.status==='WAIT_OUTBOUND')) - assert.equal(m.calls.filter(call=>call.raw.endsWith('/depart')).length,1) - assert.ok(departPath.includes(m.fixture.members[0].dispatchNo));assert.equal(departBody.idempotencyKey,'RUN-CROSS-DEPART') + calls++;assert.equal(ready.trip.trip.status,'PLANNED');assert.equal(ready.dispatch.stage,'LOADING') + assert.ok(ready.members.every(row=>!row.outbound)) + assert.equal(m.calls.filter(call=>call.raw.endsWith('/loading-complete')).length,1) + assert.ok(departPath.includes(m.fixture.members[0].dispatchNo));assert.ok(departBody.idempotencyKey) return {verified:true,scope:'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK'} }}) assert.equal(calls,1);assert.equal(proof.midTransactionRollback,'VERIFIED') @@ -164,8 +146,8 @@ for(const failure of ['unverified','throws','mutates'])test(`rollback verificati if(failure==='mutates')m.state[0].stock.balances[0].availablePackages='-1' return {verified:failure!=='unverified',scope:'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK'} }})) - assert.equal(m.calls.filter(call=>call.raw.endsWith('/depart')).length,1) - assert.ok(m.state.every(row=>row.delivery.order.status==='WAIT_DELIVERY')) + assert.equal(m.calls.filter(call=>call.raw.endsWith('/loading-complete')).length,1) + assert.ok(m.state.every(row=>row.delivery===null)) }) for(const fault of ['wrong stock scope','missing PDA position','duplicate ship','gate mutates second customer','gate reorders route','outage disguised as rejection','unrelated gate rejection','partial departure','depart deducts again','signs both','foreign delivery','foreign departure actor accepted','foreign departure actor mutates']) { test(`fails closed without widening the claimed evidence: ${fault}`,async()=>{ @@ -194,7 +176,7 @@ async function afterLoadingModel(fault = '') { const priorProof = structuredClone(m.context.createdResources.fleetCrossCustomer) assert.equal(priorProof.status,'FAILED') assert.equal(priorProof.gateProof,'PREREQUISITE_REJECTION_VERIFIED') - assert.equal(m.fixture.tripProof.loadingCommand.result.stage,'AFTER_LOADING') + assert.equal(m.fixture.tripProof.loadingCommand.result.stage,'DELIVERING') delete m.context.createdResources.fleetCrossCustomer m.fixture.stage = afterLoadingContinuationContract.stage m.resumeAfterLoading() @@ -227,7 +209,7 @@ test('explicit after-loading continuation reconciles original proof and never re const m=await afterLoadingModel(),original=structuredClone(m.priorProof),loading=structuredClone(m.fixture.tripProof.loadingCommand) const proof=await continueLoaded(m) assert.equal(proof.status,'VERIFIED');assert.equal(proof.continued,true) - assert.equal(proof.continuation.mode,'EXPLICIT_AFTER_LOADING') + assert.equal(proof.continuation.mode,'EXPLICIT_COMPLETED_LOADING') assert.equal(proof.continuation.gateProofSource,'PRIOR_PROOF') assert.match(proof.continuation.priorProofSHA256,/^[a-f0-9]{64}$/) const normalize=value=>Array.isArray(value)?value.map(normalize):value&&typeof value==='object'?Object.fromEntries(Object.keys(value).sort().map(key=>[key,normalize(value[key])])):value @@ -235,33 +217,12 @@ test('explicit after-loading continuation reconciles original proof and never re assert.deepEqual(m.priorProof,original);assert.deepEqual(m.fixture.tripProof.loadingCommand,loading) assert.equal(proof.snapshots.AFTER_PICK_1,undefined,'no historical loading snapshot is fabricated in the new proof') assert.ok(proof.snapshots.CONTINUATION_INITIAL);assert.deepEqual(proof.continuation.originalAfterPickSnapshot,original.snapshots.AFTER_PICK_1) - assert.equal(writes(m).some(call=>call.raw.endsWith('/pick')||call.raw.endsWith('/loading-complete')),false) - assert.equal(writes(m).filter(call=>call.raw.endsWith('/transfer-delivery')).length,2,'one arrangement plus the exact original-key replay') - const departures=writes(m).filter(call=>call.raw.endsWith('/depart')) - assert.deepEqual(departures.map(call=>call.token),[m.fixture.pdaToken,m.fixture.pdaToken,m.context.token]) + assert.equal(writes(m).some(call=>call.raw.endsWith('/pick')),false) + assert.equal(writes(m).some(call=>call.raw.endsWith('/transfer-delivery')||call.raw.endsWith('/depart')),false) assert.deepEqual(m.state.map(row=>row.delivery.order.status),['DELIVERED','DELIVERED']) assert.equal(JSON.stringify(proof).includes(m.fixture.pdaToken),false) }) -test('after-loading accepts the official NON_NULL preview when the unarranged field is omitted',async()=>{ - const m=await afterLoadingModel() - let omitted=false - alterRead(m,path=>path.endsWith('/delivery-arrangement'),value=>{ - delete value.arrangement - omitted=!Object.hasOwn(value,'arrangement') - }) - const proof=await continueLoaded(m) - assert.equal(omitted,true);assert.equal(proof.status,'VERIFIED');assert.equal(proof.continued,true) - assert.equal(writes(m).some(call=>call.raw.endsWith('/pick')||call.raw.endsWith('/loading-complete')),false) -}) - -test('after-loading rejects even an empty existing arrangement object before any business write',async()=>{ - const m=await afterLoadingModel() - alterRead(m,path=>path.endsWith('/delivery-arrangement'),value=>{value.arrangement={}}) - await assert.rejects(continueLoaded(m),/Continuation has no prior arrangement/) - assert.equal(writes(m).length,0) -}) - for(const failure of ['missing prior','marker','gate','source identity','prior picking','unknown prior write','loading result','loading manifest','loading pallet','loading version']) { test(`after-loading refuses incomplete or foreign source proof before any request: ${failure}`,async()=>{ const m=await afterLoadingModel() @@ -279,7 +240,7 @@ for(const failure of ['missing prior','marker','gate','source identity','prior p }) } -for(const failure of ['inventory drift','current main version','current shipped','existing outbound','current source identity','manifest drift','confirmed load drift','existing arrangement']) { +for(const failure of ['inventory drift','current main version','current shipped','existing outbound','current source identity']) { test(`after-loading refuses changed current facts before arranging or departing: ${failure}`,async()=>{ const m=await afterLoadingModel() if(failure==='inventory drift')m.state[0].stock.ledger.push({...m.state[0].stock.ledger[0],id:'UNEXPECTED-MOVEMENT'}) diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs index ed825617e..79b2de735 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs @@ -30,8 +30,8 @@ export const fixtureContract = Object.freeze({ export const afterLoadingContinuationContract = Object.freeze({ ...fixtureContract, - stage: 'TWO_OWNED_CUSTOMERS_ONE_DISPATCH_AFTER_LOADING_NOT_ARRANGED', - preparation: 'Explicit continuation only: prior failed proof with verified gate and complete AFTER_PICK_1; original successful loadingCommand/confirmedLoad; current AFTER_LOADING with no arrangement or execution documents.', + stage: 'TWO_OWNED_CUSTOMERS_ONE_DISPATCH_DELIVERING', + preparation: 'Explicit continuation only: prior failed proof with verified gate and complete AFTER_PICK_1; original successful loadingCommand/confirmedLoad; current DELIVERING with automatically created execution documents.', boundary: 'Reconcile original identities, complete picked inventory and original loading receipt through current reads before any new write. Never repeat picking/loading or rewrite the prior proof.', }) @@ -42,7 +42,7 @@ export async function verifyFleetCrossCustomer(context, fixture, { execute = fal export async function continueFleetCrossCustomerAfterLoading(context, fixture, { execute = false, readInventory, verifyDepartureRollback, priorProof, completionPolling } = {}) { if (!execute) return afterLoadingContinuationContract - if (!priorProof) throw new Error('Explicit prior cross-customer proof is required for AFTER_LOADING continuation') + if (!priorProof) throw new Error('Explicit prior cross-customer proof is required for completed-loading continuation') return runCrossCustomerCore(context, { ...fixture, members: structuredClone(fixture?.members) }, { readInventory, verifyDepartureRollback, priorProof, completionPolling }) } @@ -161,84 +161,29 @@ async function runCrossCustomerCore(context, fixture, { readInventory, verifyDep equal(after, await snapshot(`PICK_REPLAY_${index}`), 'Pick replay changes neither customer nor any execution record') } try { - let cargoBasis, originalLoadingHash + let cargoBasis if (continuation) { - const prior = priorProof.snapshots?.AFTER_PICK_1, command = fixture.tripProof.loadingCommand - const confirmed = command?.result?.result?.confirmedLoad - check('Continuation has the original failed owned proof and verified prerequisite gate', priorProof.status === 'FAILED' - && priorProof.marker === context.marker && priorProof.dispatchNo === fixture.dispatchNo && priorProof.tripNo === fixture.tripNo - && id(priorProof.warehouseId) === id(fixture.warehouseId) && priorProof.gateProof === 'PREREQUISITE_REJECTION_VERIFIED' - && priorProof.members?.length === 2 && !priorProof.arrangeRequest && !priorProof.departure - && !priorProof.members.some(member => member.outboundNo || member.deliveryNo) - && Array.isArray(priorProof.requests) && priorProof.requests.every(entry => ['GET','HEAD','OPTIONS'].includes(entry.method) || entry.status === 'RESPONDED')) - const identityFields = ['marker','customerId','incomingNo','dispatchNo','dispatchItemId','balanceId','positionId','palletId','palletNo'] - check('Continuation preserves each original customer, source, item, balance, position and physical pallet', fixture.members.every((member,index) => - identityFields.every(field => id(member[field]) === id(priorProof.members[index]?.[field])))) - check('Prior AFTER_PICK_1 is a complete reviewed picked but not shipped native snapshot', prior?.dispatch?.dispatchNo === fixture.dispatchNo - && prior.dispatch.status === 'ACTIVE' && prior.dispatch.stage === 'LOADING' && prior.dispatch.reviewState === 'APPROVED' - && prior.dispatch.reviews?.dispatchReview?.status === 'APPROVED' && prior.dispatch.reviews?.loadingReview?.status === 'APPROVED' - && integer(prior.dispatch.counts?.packageCount) === 2 && integer(prior.dispatch.counts?.pickedPackageCount) === 2 - && integer(prior.dispatch.counts?.shippedPackageCount) === 0 && prior.task?.pickingReconciled === true - && prior.task.dispatchNo === fixture.dispatchNo && id(prior.task.warehouseId) === id(fixture.warehouseId) - && prior.task.stage === 'LOADING' && id(prior.task.recordVersion) === id(prior.dispatch.recordVersion) - && prior.items?.dispatchNo === fixture.dispatchNo && id(prior.items.recordVersion) === id(prior.dispatch.recordVersion) - && prior.items.items?.length === 2 && prior.task.items?.length === 2 - && prior.members?.length === 2 && prior.members.every(row => row.stock && !row.outbound && !row.delivery) - && prior.dispatch.documents?.outbounds?.length === 0 && prior.dispatch.documents?.deliveries?.length === 0) - check('Original loading command has a successful explicit receipt for this main and both exact physical pallets', command?.path === `${pdaPath}/loading-complete` - && command.body?.idempotencyKey && command.body.manifestHash && command.result?.dispatchNo === fixture.dispatchNo - && command.result.action === 'LOADING_COMPLETE' && command.result.idempotencyKey === command.body.idempotencyKey - && command.result.status === 'ACTIVE' && command.result.stage === 'AFTER_LOADING' && command.result.result?.loadingCompletionHash - && id(command.body.recordVersion) === id(prior.dispatch.recordVersion) - && /^(0|[1-9]\d*)$/.test(id(command.result.recordVersion)) - && /^(0|[1-9]\d*)$/.test(id(command.body.recordVersion)) - && BigInt(id(command.result.recordVersion)) > BigInt(id(command.body.recordVersion)) - && integer(command.result.counts?.packageCount) === 2 && integer(command.result.counts?.pickedPackageCount) === 2 - && integer(command.result.counts?.shippedPackageCount) === 0 - && confirmed?.manifestHash === command.body.manifestHash && confirmed.operatorId && Number.isFinite(Date.parse(confirmed.confirmedAt)) - && canonical(confirmed) === canonical(fixture.tripProof.confirmedLoad) - && Array.isArray(command.body.measurements) && command.body.measurements.length === 2 - && canonical(command.body.measurements) === canonical(confirmed.pallets) - && new Set(confirmed.pallets.map(row => id(row.warehousePalletId))).size === 2 - && fixture.members.every(member => confirmed.pallets.some(row => id(row.warehousePalletId) === member.palletId - && Number.isSafeInteger(row.palletSpaces) && row.palletSpaces > 0 - && ['weightKg','volumeCbm'].every(field => ['number','string'].includes(typeof row[field]) - && /^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(String(row[field])) && Number.isFinite(Number(row[field])) && Number(row[field]) > 0) - && Number.isSafeInteger(row.deliveryIndex) && row.deliveryIndex > 0 && fixture.tripProof.stops?.[row.deliveryIndex]?.destinationKey))) - proof.continued = true - proof.continuation = { mode: 'EXPLICIT_AFTER_LOADING', priorProofSHA256: createHash('sha256').update(canonical(priorProof)).digest('hex'), - priorStatus: priorProof.status, gateProofSource: 'PRIOR_PROOF', originalAfterPickSnapshot: structuredClone(prior), - originalLoadingCommand: structuredClone(command), originalConfirmedLoad: structuredClone(confirmed) } - proof.gateProof = priorProof.gateProof - await context.checkpoint?.() - const initial = await snapshot('CONTINUATION_INITIAL') - check('Current main is exactly the unarranged confirmed loading version with two picked and zero shipped packages', initial.dispatch.status === 'ACTIVE' - && initial.dispatch.stage === 'AFTER_LOADING' && initial.task.stage === 'AFTER_LOADING' && initial.trip.trip.status === 'PLANNED' - && id(initial.dispatch.recordVersion) === id(command.result.recordVersion) && initial.task.pickingReconciled === true - && initial.dispatch.reviewState === 'APPROVED' && initial.dispatch.reviews?.dispatchReview?.status === 'APPROVED' - && initial.dispatch.reviews?.loadingReview?.status === 'APPROVED' && integer(initial.dispatch.counts.packageCount) === 2 - && integer(initial.dispatch.counts.pickedPackageCount) === 2 && integer(initial.dispatch.counts.shippedPackageCount) === 0 - && initial.dispatch.documents?.outbounds?.length === 0 && initial.dispatch.documents?.deliveries?.length === 0 - && id(initial.trip.trip.vehicleId) === id(fixture.tripProof.vehicleId) && id(initial.trip.trip.driverId) === id(fixture.tripProof.driverId) - && id(prior.trip?.trip?.vehicleId) === id(fixture.tripProof.vehicleId) && id(prior.trip?.trip?.driverId) === id(fixture.tripProof.driverId) - && canonical(initial.trip.trip.stops) === canonical(fixture.tripProof.stops) - && initial.items.items.every(item => integer(item.pickedPackages) === 1) && initial.task.items.every(item => integer(item.pickedPackages) === 1)) - equal(prior.members.map(row => row.stock), initial.members.map(row => row.stock), 'Continuation current complete inventory equals original AFTER_PICK_1 inventory') - const sourceItems = items => items.map(item => Object.fromEntries(['dispatchItemId','customerId','sourceKey','sourceVersion','sourceType', - 'planPalletId','returnReceiptId','relabelNo','warehousePalletId','palletNo','orderNo','inboundCode','destination','scheduledPackages','pickedPackages'] - .map(field => [field,item[field]]))).sort((a,b) => id(a.dispatchItemId).localeCompare(id(b.dispatchItemId))) - equal(sourceItems(prior.items.items), sourceItems(initial.items.items), 'Continuation preserves both picked source items and destinations') - const loading = await request('GET', `${pdaPath}/loading`, undefined, 'Reconcile current native manifest against original successful loading receipt', true) - check('Current loading preview preserves the original manifest, confirmed receipt and two reconciled physical pallets', loading.dispatchNo === fixture.dispatchNo - && loading.status === 'ACTIVE' && loading.stage === 'AFTER_LOADING' && id(loading.recordVersion) === id(command.result.recordVersion) - && loading.pickingReconciled === true && loading.manifestHash === confirmed.manifestHash - && canonical(loading.confirmedLoad) === canonical(confirmed) && loading.pallets?.length === 2 - && new Set(loading.pallets.map(row => id(row.warehousePalletId))).size === 2 - && fixture.members.every(member => loading.pallets.some(row => id(row.warehousePalletId) === member.palletId - && row.palletNo === member.palletNo && integer(row.scheduledPackages) === 1 && integer(row.pickedPackages) === 1))) - proof.continuation.currentLoadingPreview = loading - cargoBasis = initial - originalLoadingHash = command.result.result.loadingCompletionHash + const command = fixture.tripProof.loadingCommand + const prior = priorProof.snapshots?.AFTER_PICK_1 + check('Continuation preserves original successful loading command and current-run picked inventory', priorProof.dispatchNo === fixture.dispatchNo && priorProof.marker === context.marker && priorProof.gateProof === 'PREREQUISITE_REJECTION_VERIFIED' + && canonical(priorProof.members) === canonical(proof.members) + && priorProof.requests.every(row => row.method === 'GET' || row.status === 'RESPONDED') + && Number(prior?.dispatch?.counts?.pickedPackageCount) === 2 && id(command?.body?.recordVersion) === id(prior?.dispatch?.recordVersion) + && canonical(command?.body?.measurements) === canonical(command?.result?.result?.confirmedLoad?.pallets) + && prior?.dispatch?.dispatchNo === fixture.dispatchNo && prior.dispatch.stage === 'LOADING' + && command?.body?.idempotencyKey && command.result?.action === 'LOADING_COMPLETE' && command.result.stage === 'DELIVERING' + && command.result.dispatchNo === fixture.dispatchNo && command.result.idempotencyKey === command.body.idempotencyKey + && command.result.result?.confirmedLoad?.manifestHash === command.body.manifestHash) + const current = await snapshot('CONTINUATION_INITIAL') + check('Continuation is already delivering with the original automatic documents', current.dispatch.stage === 'DELIVERING' + && current.task.stage === 'DELIVERING' && current.trip.trip.status === 'IN_TRANSIT' + && id(current.dispatch.recordVersion) === id(command.result.recordVersion) && Number(current.dispatch.counts.shippedPackageCount) === 2 + && canonical(current.items.items) === canonical(prior.items.items) + && current.dispatch.documents?.outbounds?.length === 2 && current.dispatch.documents?.deliveries?.length === 2) + equal(prior.members.map(row => row.stock), current.members.map(row => row.stock), 'Continuation preserves complete picked inventory') + proof.continued = true; proof.gateProof = priorProof.gateProof + proof.continuation = { mode: 'EXPLICIT_COMPLETED_LOADING', gateProofSource:'PRIOR_PROOF', originalAfterPickSnapshot:structuredClone(prior), priorProofSHA256: createHash('sha256').update(canonical(priorProof)).digest('hex') } + cargoBasis = prior } else { const initial = await snapshot('INITIAL') check('Fresh native fixture is reviewed LOADING with both customer receipts untouched', initial.trip.trip.status === 'PLANNED' @@ -257,67 +202,46 @@ async function runCrossCustomerCore(context, fixture, { readInventory, verifyDep await request('POST', `${pdaPath}/loading-complete`, { recordVersion: rejectedBefore.task.recordVersion, idempotencyKey: key('UNREADY-LOAD'), manifestHash: preview.manifestHash, measurements: [], remark: context.marker }, 'Unready second customer blocks explicit loading', true, message => /拣货.*核平|尚未核平/.test(message)) - await request('POST', `${pdaPath}/depart`, { recordVersion: rejectedBefore.task.recordVersion, idempotencyKey: key('UNREADY-DEPART'), remark: context.marker }, - 'Unready second customer blocks whole trip before execution', true, message => /装车|派送/.test(message)) equal(rejectedBefore, await snapshot('GATE_AFTER'), 'Rejected departure leaves both complete inventories/outbounds/deliveries/trip unchanged') proof.gateProof = 'PREREQUISITE_REJECTION_VERIFIED' await pick(proof.members[1], 1) - await confirmFleetLoading(context, MODULE, fixture.tripProof, fixture.pdaToken, fixture.loadingMeasurements) cargoBasis = proof.snapshots.AFTER_PICK_1 + await confirmFleetLoading(context, MODULE, fixture.tripProof, fixture.pdaToken, fixture.loadingMeasurements, async command => { + if (verifyDepartureRollback === undefined) return + check('Explicit rollback verifier is callable', typeof verifyDepartureRollback === 'function') + const ready = await snapshot('READY') + const rollback = await verifyDepartureRollback({ context, fixture, ready, departPath: command.path, departBody: command.body, snapshot }) + check('Mid-transaction rollback has exact owned verification', rollback?.verified === true && rollback.scope === 'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK') + equal(ready, await snapshot('ROLLBACK_RESTORED'), 'Rollback restores both complete inventory snapshots before loading completion') + proof.rollbackEvidence = rollback; proof.midTransactionRollback = 'VERIFIED' + }) } - const arrangement = await request('GET', `${path}/delivery-arrangement`, undefined, 'Read native loaded per-customer delivery arrangement') - if (continuation) check('Continuation has no prior arrangement and retains the original loading completion hash', arrangement.arrangement == null - && arrangement.loadingCompletionHash === originalLoadingHash && id(arrangement.recordVersion) === id(fixture.tripProof.loadingCommand.result.recordVersion)) - check('Loaded arrangement groups cover exactly both real customers', arrangement.dispatchNo === fixture.dispatchNo && arrangement.status === 'ACTIVE' - && arrangement.stage === 'AFTER_LOADING' && arrangement.arrange?.allowed === true && arrangement.cargoHash && arrangement.loadingCompletionHash - && arrangement.groups?.length === 2 && proof.members.every(m => arrangement.groups.some(g => id(g.customerId) === m.customerId && g.cargo?.length - && g.cargo.every(c => id(c.customerId) === m.customerId && id(c.warehousePalletId) === m.palletId) && g.blockReasons?.length === 0 - && g.destination?.key === cargoBasis.items.items.find(i => id(i.customerId) === m.customerId)?.destination?.key))) - const arrangeBody = { recordVersion: arrangement.recordVersion, idempotencyKey: key('ARRANGE'), cargoHash: arrangement.cargoHash, - loadingCompletionHash: arrangement.loadingCompletionHash, groups: arrangement.groups.map(g => ({ groupKey: g.groupKey, groupCargoHash: g.groupCargoHash, - plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString(), deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', - loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '本轮实际装载', appointmentAccount: 'SELF', remark: `${context.marker} TEST合成资料`, - externalFreightHash: g.externalFreightHash, feeCoverageCargoHash: g.feeCoverageCargoHash, feeCoverage: [], - fees: (g.requiredServices || []).map(feeType => ({ feeType, feeName: 'TEST本轮派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 30 })) })) } - proof.arrangeRequest = arrangeBody - const arranged = await request('POST', `${path}/transfer-delivery`, arrangeBody, 'Arrange both customers after real loading before departure') - check('Native arrangement creates customer-isolated outbounds and deliveries', arranged.dispatchNo === fixture.dispatchNo && arranged.stage === 'AFTER_LOADING' - && arranged.result?.deliveries?.length === 2 && arranged.result.outbounds?.length === 2 - && new Set(arranged.result.deliveries.map(d => d.deliveryNo)).size === 2 && new Set(arranged.result.outbounds.map(o => o.outboundNo)).size === 2) + const detail = await request('GET', path, undefined, 'Read documents created by the one loading-complete command') + const documents = detail.documents + check('Automatic loading creates both customer-isolated outbounds and deliveries', detail.dispatchNo === fixture.dispatchNo && detail.stage === 'DELIVERING' + && documents?.deliveries?.length === 2 && documents.outbounds?.length === 2 + && new Set(documents.deliveries.map(d => d.deliveryNo)).size === 2 && new Set(documents.outbounds.map(o => o.outboundNo)).size === 2) for (const [index, member] of proof.members.entries()) { - const deliveries = arranged.result.deliveries.filter(d => id(d.customerId) === member.customerId) - check('Each customer has exactly its own source-specific delivery and outbound', deliveries.length === 1 && integer(deliveries[0].packageCount) === 1 - && arranged.result.outbounds.some(o => id(o.customerId) === member.customerId && o.outboundNo === deliveries[0].outboundNo && integer(o.packageCount) === 1)) + const deliveries = documents.deliveries.filter(d => id(d.customerId) === member.customerId) + check('Each customer has its own source destination and quantity', deliveries.length === 1 && deliveries[0].status === 'DELIVERING' + && integer(deliveries[0].packageCount) === 1 && deliveries[0].destination?.key === cargoBasis.items.items.find(i => id(i.customerId) === member.customerId)?.destination?.key + && documents.outbounds.some(o => id(o.customerId) === member.customerId && o.status === 'OUTBOUNDED' && o.outboundNo === deliveries[0].outboundNo && integer(o.packageCount) === 1)) Object.assign(member, { outboundNo: deliveries[0].outboundNo, deliveryNo: deliveries[0].deliveryNo }) Object.assign(fixture.members[index], { outboundNo: member.outboundNo, deliveryNo: member.deliveryNo }) } - const arrangedReplay = await request('POST', `${path}/transfer-delivery`, arrangeBody, 'Replay exact native customer arrangement') - check('Native arrangement replay keeps original documents and main version', arrangedReplay.replayed === true && id(arrangedReplay.recordVersion) === id(arranged.recordVersion) - && canonical(arrangedReplay.result) === canonical(arranged.result)) - const ready = await snapshot('READY'), departBody = { recordVersion: ready.task.recordVersion, idempotencyKey: key('DEPART'), remark: context.marker } - const departPath = `${pdaPath}/depart` - if (verifyDepartureRollback !== undefined) { - check('Explicit rollback verifier is callable', typeof verifyDepartureRollback === 'function') - const rollback = await verifyDepartureRollback({ context, fixture, ready, departPath, departBody, snapshot }) - check('Mid-transaction rollback has exact owned verification', rollback?.verified === true && rollback.scope === 'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK') - equal(ready, await snapshot('ROLLBACK_RESTORED'), 'Mid-transaction rollback restores both complete customer execution and inventory snapshots') - proof.rollbackEvidence = rollback; proof.midTransactionRollback = 'VERIFIED' - } - const departed = await request('POST', departPath, departBody, 'Depart one cross-customer native dispatch once', true) - check('Departure includes both customers in the one actual native main', departed.dispatchNo === fixture.dispatchNo && departed.status === 'ACTIVE' - && departed.stage === 'DELIVERING' && integer(departed.result?.shippedPackages) === 2 && departed.result.deliveries?.length === 2) + const command = fixture.tripProof.loadingCommand, departed = command.result proof.departure = departed const after = await snapshot('DEPARTED') check('Both customer outbounds completed and main delivering', after.trip.trip.status === 'IN_TRANSIT' && after.dispatch.stage === 'DELIVERING' && after.members.every(row => row.outbound.outbound.status === 'OUTBOUNDED' && row.delivery.order.status === 'DELIVERING')) - equal(ready.members.map(row => row.stock), after.members.map(row => row.stock), 'Departure does not deduct either inventory again') - const replay = await request('POST', departPath, departBody, 'Original PDA actor exact replay of native departure', true) - check('Shared departure replay is explicit and returns original result', replay.replayed === true && id(replay.recordVersion) === id(departed.recordVersion) + equal(cargoBasis.members.map(row => row.stock), after.members.map(row => row.stock), 'Loading completion does not deduct either inventory again') + const replay = await request('POST', command.path, command.body, 'Original PDA actor exact replay of loading completion', true) + check('Loading replay returns the original complete result', replay.replayed === true && id(replay.recordVersion) === id(departed.recordVersion) && canonical(replay.result) === canonical(departed.result)) const replayed = await snapshot('DEPART_REPLAY') - equal(after, replayed, 'Departure replay preserves both complete inventories and execution facts') - await request('POST', `${path}/depart`, departBody, 'Different platform actor cannot reuse the PDA departure command key', false, 'ACTOR_FORBIDDEN') - equal(replayed, await snapshot('FOREIGN_ACTOR_DEPART_REJECTED'), 'Different actor rejection preserves both complete inventories and execution facts') + equal(after, replayed, 'Loading replay preserves both complete inventories and execution documents') + await request('POST', `${path}/loading-complete`, command.body, 'Different platform actor cannot reuse the PDA loading key', false, 'ACTOR_FORBIDDEN') + equal(replayed, await snapshot('FOREIGN_ACTOR_DEPART_REJECTED'), 'Actor rejection preserves both complete inventories and execution facts') for (const [index, member] of proof.members.entries()) { const before = await snapshot(`BEFORE_SIGN_${index}`), documentPath = scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents`, member.customerId) const docs = await request('GET', documentPath, undefined, 'Read this customer delivery document version') diff --git a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs index b03b98150..d43409129 100644 --- a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' -import { verifyFleetExternalDispatch, nativeArrangementGroups } from './fleet-external-dispatch-acceptance.mjs' +import { verifyFleetExternalDispatch } from './fleet-external-dispatch-acceptance.mjs' const BASE = '/api/platform/v1/last-leg/dispatches', PDA = '/api/pda/v1/last-leg/dispatches', TRIP = '/api/platform/v1/last-leg/fleet/trips/TR-CURRENT' const OWN = '900719925474099301', EXT = '900719925474099302', clone = value => structuredClone(value) @@ -21,12 +21,13 @@ function fixture(fault = '', nativeMode = false) { const tripProof = { tripNo: 'TR-CURRENT', dispatchNo: 'DS-OWN', warehouseId: '92', vehicleId: '71', driverId: '72', stops: [{ index: 0, destinationKey: 'ORIGIN:92' }, { index: 1, destinationKey: 'FBA:A1' }, { index: 2, destinationKey: 'FBA:A2' }], externalReceipt: { marker: 'CURRENT', status: 'VERIFIED', incomingNo: 'WT-CURRENT', bookingNo: 'BK-CURRENT', warehousePalletId: '33', palletNo: 'PAL-3', createRequest: { customerId: externalCustomer }, finalSegments: [segment], finalBooking: { cargo: bookingCargo, agreedAmount: 30 } } } if (nativeMode) tripProof.stops.pop() - let recordVersion = 0, fleetVersion = 0, stage = fault === 'already-loaded' ? 'AFTER_LOADING' : 'LOADING', bound = false, planned = false, review = true, saved + let recordVersion = 0, fleetVersion = 0, stage = fault === 'already-loaded' ? 'DELIVERING' : 'LOADING', bound = false, planned = false, review = true, saved const booking = () => ({ bookingNo: 'BK-CURRENT', customerId: externalCustomer, status: bound ? 'BOUND' : 'RECEIVED', incomingNo: 'WT-CURRENT', dispatchNo: bound ? 'DS-OWN' : null, recordVersion: bound ? 1 : 0, cargo: bookingCargo, agreedAmount: 30, receipt: accepted }) const counts = rows => ({ itemCount: rows.length, palletCount: rows.length, packageCount: rows.reduce((n, i) => n + i.scheduledPackages, 0), pickedPackageCount: rows.reduce((n, i) => n + i.pickedPackages, 0), shippedPackageCount: 0 }) - const detail = (full = false) => ({ dispatchNo: full ? 'DS-FULL' : 'DS-OWN', recordVersion: full ? 0 : recordVersion, status: 'ACTIVE', stage: full ? 'AFTER_LOADING' : stage, + const detail = (full = false) => ({ dispatchNo: full ? 'DS-FULL' : 'DS-OWN', recordVersion: full ? 0 : recordVersion, status: 'ACTIVE', stage: full ? 'DELIVERING' : stage, reviewState: review ? 'APPROVED' : 'DRAFT', reviews: { dispatchReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' }, loadingReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' } }, counts: counts(full ? fullItems : items), pickingReconciled: !full && items.every(i => i.pickedPackages === i.scheduledPackages), dispatchInfo: { route: 'A1,A2' }, + documents: stage==='DELIVERING'&&!full?documents():{outbounds:[],deliveries:[]}, orderDetails: (full ? fullItems : items).map(i => ({ sourceKey: i.sourceKey, customerId: i.customerId, inboundCode: i.inboundCode, destinationFbaWarehouse: i.destination.warehouseCode })) }) const ack = (action, result = {}) => ({ ...detail(), action, result, replayed: false }) const groups = () => { @@ -42,6 +43,7 @@ function fixture(fault = '', nativeMode = false) { for (const group of raw) { const previous = merged.get(group.groupKey); if (previous) { previous.cargo.push(...group.cargo); previous.externalQuotes.push(...group.externalQuotes) } else merged.set(group.groupKey, group) } return [...merged.values()].map(g => ({ ...g, requiredServices: g.cargo.some(c => !g.externalQuotes.some(q => q.inboundCode === c.inboundCode)) ? ['DELIVERY_FEE'] : [] })) } + const documents=()=>({outbounds:[...new Set(items.map(i=>i.customerId))].map(customerId=>({customerId,outboundNo:customerId===OWN?'OUT-OWN':'OUT-EXT',status:'OUTBOUNDED',packageCount:items.filter(i=>i.customerId===customerId).reduce((n,i)=>n+i.scheduledPackages,0)})),deliveries:groups().map((g,n)=>({groupKey:g.groupKey,customerId:g.customerId,destination:g.destination,outboundNo:g.customerId===EXT?'OUT-EXT':'OUT-OWN',deliveryNo:`DEL-${n}`,status:'DELIVERING',packageCount:g.cargo.reduce((n,c)=>n+c.packages,0)}))}) const trip = () => ({ trip: { tripNo: 'TR-CURRENT', warehouseId: '92', vehicleId: '71', driverId: '72', recordVersion: fleetVersion, stops: tripProof.stops }, dispatches: [{ dispatchNo: fault === 'second-dispatch' ? 'DS-OTHER' : 'DS-OWN' }] }) const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data: clone(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' }) const context = { marker: 'CURRENT', createdResources: nativeMode ? { fleetTrips: [tripProof] } : { fleetTrips: [tripProof, { dispatchNo: 'DS-FULL', tripNo: 'TR-FULL', warehouseId: '92' }], fleetFeeCoverageCases: [full, currentCase] }, checkpoint: async () => {}, @@ -49,8 +51,8 @@ function fixture(fault = '', nativeMode = false) { async request(method, raw, body, token) { calls.push({ method, path: raw, body: clone(body), token }) const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), normalized = path.replace(PDA, BASE), key = `${normalized}:${body?.idempotencyKey}` - if (method !== 'GET' && body?.idempotencyKey && ledger.has(key)) { const original = ledger.get(key); assert.deepEqual(body, original.body); if (fault === 'replay-row-drift' && path.endsWith('/items')) items[0].dispatchItemId = '999'; return response({ ...clone(original.data), replayed: true }) } - const remember = data => { if (body?.idempotencyKey) ledger.set(key, { body: clone(body), data: clone(data) }); return response(data) } + if (method !== 'GET' && body?.idempotencyKey && ledger.has(key)) { const original = ledger.get(key); assert.deepEqual(body, original.body); if (fault === 'replay-row-drift' && path.endsWith('/items')) items[0].dispatchItemId = '999'; if(path.endsWith('/loading-complete')&&token!==original.token)return {http:200,body:{code:'A0403',message:'该请求标识不属于当前操作员'},requestId:'R',traceId:'T'}; return response({ ...clone(original.data), replayed: true }) } + const remember = data => { if (body?.idempotencyKey) ledger.set(key, { body: clone(body), data: clone(data), token }); return response(data) } if (path === `${TRIP}/capacity-plan`) { if (method === 'POST') { assert.equal(body.recordVersion, fleetVersion); assert.equal(body.pallets.length, 3); assert.equal(body.pallets.find(p => p.warehousePalletId === '33').palletSpaces, 2); planned = true; fleetVersion++ } return response({ recordVersion: fleetVersion, manifestHash: 'c'.repeat(64), planValid: items.length === 2 || planned || fault === 'stale-plan', @@ -74,14 +76,11 @@ function fixture(fault = '', nativeMode = false) { } const delivery = path.match(/\/delivery-orders\/DEL-(\d)$/) if (delivery) { - const index = Number(delivery[1]), group = groups()[index], input = saved.groups[index], who = group.customerId - assert.equal(params.get('customerId'), who) - const feeCoverage = input.feeCoverage.length ? { cargoHash: group.feeCoverageCargoHash, cargo: group.cargo, operatorId: 'OP', confirmedAt: '2026-10-19T01:00:00Z', - references: input.feeCoverage.map(s => ({ services: s.services, source: { ...group.feeSources.find(f => f.financeFeeId === s.financeFeeId), sourceType: 'INBOUND_FEE', sourceInboundId: group.cargo[0].sourceInboundId } })) } : null - const fees = input.fees.map(f => ({ id: String(981 + index), feeDirection: f.direction, feeType: f.feeType, amount: f.amount, currency: f.currency, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED' })) - if (group.externalQuotes.length) fees.push({ id: '980', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', amount: 30, currency: 'CNY', sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED' }) - if (fault === 'duplicate-fee' && group.externalQuotes.length && calls.filter(c => c.path === raw).length > 1) fees.push(clone(fees[0])) - return response({ order: { customerId: who, dispatchNo: 'DS-OWN', deliveryNo: `DEL-${index}`, outboundNo: who === EXT ? 'OUT-EXT' : 'OUT-OWN', destinationWarehouseCode: group.destination.warehouseCode }, fees, feeCoverage }) + const index=Number(delivery[1]),group=groups()[index],who=group.customerId + assert.equal(params.get('customerId'),who) + const fees=group.externalQuotes.length?[{id:'980',feeDirection:'RECEIVABLE',feeType:'DELIVERY_FEE',amount:fault==='stale-price'?50:30,currency:'CNY',sourceStatus:'ACTIVE',reviewStatus:'APPROVED'}]:[] + if(fault==='duplicate-fee'&&fees.length)fees.push(clone(fees[0])) + return response({order:{customerId:who,dispatchNo:'DS-OWN',deliveryNo:`DEL-${index}`,outboundNo:who===EXT?'OUT-EXT':'OUT-OWN',destinationWarehouseCode:group.destination.warehouseCode,status:'DELIVERING',packageCount:group.cargo.reduce((n,c)=>n+c.packages,0)},fees,feeCoverage:null}) } const isFull = path.startsWith(`${BASE}/DS-FULL`), data = detail(isFull), cargo = isFull ? fullItems : items if (method === 'GET' && normalized === `${BASE}/DS-OWN` && path.startsWith(PDA)) return response({ ...data, items: items.map((i, n) => ({ ...i, positions: [{ positionId: `6${n}`, availablePackages: i.scheduledPackages }] })) }) @@ -89,7 +88,6 @@ function fixture(fault = '', nativeMode = false) { if (method === 'GET' && normalized.endsWith('/route')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, stops: tripProof.stops }) if (method === 'GET' && normalized.endsWith('/loading')) return response({ ...data, manifestHash: 'c'.repeat(64), loadingComplete: { allowed: true }, pallets: items.map((i, n) => ({ ...i, ...(fault === 'missing-measurements' ? {} : { palletSpaces: n === 2 ? 2 : 1, weightKg: n === 2 ? 37 : 10, volumeCbm: n === 2 ? 0.7 : 0.2, deliveryIndex: nativeMode || n === 2 ? 1 : n + 1, evidence: [['palletSpaces', n === 2 ? 2 : 1], ['weightKg', n === 2 ? 37 : 10], ['volumeCbm', n === 2 ? 0.7 : 0.2]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: i.inboundCode, observedAt: '2026-10-19T01:00:00Z' })) }) })) }) - if (method === 'GET' && normalized.endsWith('/delivery-arrangement')) return response({ ...data, cargoHash: 'a'.repeat(64), loadingCompletionHash: 'c'.repeat(64), groups: groups(), arrange: { allowed: !saved }, arrangement: saved ? { dispatchNo: 'DS-OWN' } : null }) if (method === 'GET') { assert.ok(normalized === `${BASE}/DS-OWN` || normalized === `${BASE}/DS-FULL`, raw); return response(data) } if (path.endsWith('/pick') && body.recordVersion !== recordVersion) return response(null, '公共版本已变化') assert.equal(body.recordVersion, recordVersion, raw) @@ -97,32 +95,21 @@ function fixture(fault = '', nativeMode = false) { if (path.endsWith('/items')) { assert.equal(stage, 'BEFORE_LOADING'); assert.equal(body.cargo.length, 1); items.push(external); recordVersion++; fleetVersion++; if (fault === 'unknown-add') return { http: 0, body: null }; return remember(ack('ADD_CARGO')) } if (path.endsWith('/submit-review') || path.endsWith('/dispatch-review') || path.endsWith('/loading-review')) { if (path.endsWith('/loading-review') && fault !== 'review-not-applied') { review = true; stage = 'LOADING' }; recordVersion++; return remember(ack('REVIEW')) } if (path.endsWith('/pick')) { assert.ok(token); const itemId = path.split('/').at(-2); items.find(i => i.dispatchItemId === itemId).pickedPackages += body.packages; recordVersion++; return remember(ack('PICK')) } - if (path.endsWith('/loading-complete')) { assert.equal(items.every(i => i.pickedPackages === i.scheduledPackages), true); stage = 'AFTER_LOADING'; recordVersion++; return remember(ack('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } - assert.ok(path.endsWith('/transfer-delivery'), raw); assert.equal(stage, 'AFTER_LOADING') - if (body.cargoHash !== 'a'.repeat(64)) return response(null, '装车或实际货物已经变化') - if (body.groups.some(g => g.feeCoverage.some(s => s.sourceVersion !== 1))) return response(null, '原费用版本已变化') - if (body.groups.some(g => g.externalFreightHash !== 'b'.repeat(64))) return response(null, '外接实收或约定运费已变化') - if (body.groups.some(g => new Set(g.fees.map(f => `${f.direction}:${f.feeType}`)).size !== g.fees.length)) return response(null, '同组同方向同费用不得重复登记') - if (body.groups.some(g => g.groupKey === 'G-PLAN:23' && g.fees.length)) return response(null, '本组服务已全部覆盖,不得重复登记应收') - if (!body.groups.some(g => g.fees.length)) return response(null, '本组尚有未覆盖的服务费用,请核对') - saved = clone(body); recordVersion++ - if (fault === 'unknown-arrange') return { http: 0, body: null } - return remember(ack('ARRANGE_DELIVERY', { dispatchNo: 'DS-OWN', cargoHash: 'a'.repeat(64), outbounds: [...new Set(items.map(i => i.customerId))].map(customerId => ({ customerId, outboundNo: customerId === OWN ? 'OUT-OWN' : 'OUT-EXT', status: 'WAIT_OUTBOUND', packageCount: items.filter(i => i.customerId === customerId).reduce((n, i) => n + i.scheduledPackages, 0) })), - deliveries: groups().map((g, n) => ({ groupKey: g.groupKey, customerId: g.customerId, destination: g.destination, outboundNo: g.customerId === EXT ? 'OUT-EXT' : 'OUT-OWN', deliveryNo: `DEL-${n}`, status: 'WAIT_DELIVERY', packageCount: g.cargo.reduce((n, c) => n + c.packages, 0) })) })) + if (path.endsWith('/loading-complete')) { assert.equal(items.every(i => i.pickedPackages === i.scheduledPackages), true); stage = 'DELIVERING'; recordVersion++; return remember(ack('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } + throw Error(`Unexpected ${method} ${raw}`) } } - const options = nativeMode ? { pickerToken: 'OFFLINE-ONLY', arrangement: { mode: 'NATIVE', marker: 'CURRENT', dispatchNo: 'DS-OWN', plannedDeliveryAt: '2026-10-22T01:00:00Z', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', appointmentAccount: 'SELF', - groups: [{ customerId: OWN, destinationKey: 'FBA:A1', fees: [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '客户A明确派送费', currency: 'USD', amount: 11 }] }, { customerId: EXT, destinationKey: 'FBA:A1', fees: [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '客户B明确派送费', currency: 'CNY', amount: 17 }] }] } } : { coverageCase: currentCase, pickerToken: 'OFFLINE-ONLY' } + const options = nativeMode ? {pickerToken:'OFFLINE-ONLY'} : {coverageCase:currentCase,pickerToken:'OFFLINE-ONLY'} return { context, tripProof, currentCase, calls, assertions, items, options } } test('native external flow adds actual received cargo to same whole, re-reviews, scans, loads and arranges all customer groups', async () => { const f = fixture(), proof = await verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options) assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.dispatchNo, 'DS-OWN'); assert.equal(proof.sourceFees.length, 1); assert.equal(proof.sourceFees[0].chargeableQuantity, 2) - assert.equal(proof.financeAcknowledgement, 'NOT_VERIFIED'); assert.equal(f.currentCase.proof.status, 'VERIFIED'); assert.equal(f.currentCase.proof.newReceivable, 17.5) + assert.equal(proof.financeAcknowledgement, 'NOT_VERIFIED'); assert.equal(f.currentCase.proof.status, 'VERIFIED'); assert.equal(f.currentCase.proof.feeCoverageSelection,'NOT_APPLICABLE_AUTOMATIC_DELIVERY') assert.ok(f.assertions.every(a => a.passed)); assert.equal(f.calls.some(c => /pre-arrange|dispatches\/bind|dispatches\/unbind|\/cancel/.test(c.path)), false) assert.equal(f.calls.filter(c => c.method === 'POST' && c.path === BASE).length, 0) assert.ok(f.calls.findIndex(c => c.path.endsWith('/return-to-review')) < f.calls.findIndex(c => c.path.endsWith('/items') && c.method === 'POST')) - assert.ok(f.calls.findIndex(c => c.path.endsWith('/loading-complete')) < f.calls.findIndex(c => c.path.endsWith('/transfer-delivery'))) + assert.equal(f.calls.some(c => c.path.endsWith('/transfer-delivery')||c.path.endsWith('/depart')),false) assert.equal(f.items.length, 3); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options), /尚未装入/) }) @@ -133,8 +120,8 @@ for (const fault of ['foreign-customer', 'stale-plan', 'double-capacity', 'revie }) test('no external callback injection can replace real registered PARTIAL sources, current PDA or durable journal', async () => { - for (const mutate of [f => { delete f.options.coverageCase }, f => { delete f.options.pickerToken }, f => { delete f.context.checkpoint }]) { - const f = fixture(); mutate(f); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options), /同主单未扫码PARTIAL/); assert.equal(f.calls.length, 0) + for (const mutate of [f => { delete f.options.pickerToken }, f => { delete f.context.checkpoint }]) { + const f = fixture(); mutate(f); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options)); assert.equal(f.calls.length, 0) } }) @@ -145,52 +132,14 @@ test('server binding requires the same existing whole dispatch and public cargo assert.equal(source.includes('/pre-arrange'), false); assert.equal(source.includes('draftToken'), false); assert.ok(source.includes('/return-to-review')); assert.ok(source.includes("'ADD_CARGO'")) }) -test('ordinary native mode accepts two WAREHOUSE_TRANSFER sources and same-customer external cargo without FULL/PARTIAL fixtures', async () => { - const f = fixture('', true), proof = await verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options) - assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.arrangementMode, 'NATIVE'); assert.equal(proof.nativeArrangement.status, 'VERIFIED') - assert.equal(Object.hasOwn(f.context.createdResources, 'fleetFeeCoverageCases'), false); assert.equal(f.currentCase.proof, undefined) - assert.equal(proof.deliveries.length, 2); assert.equal(proof.deliveries.reduce((n, d) => n + d.packageCount, 0), 3) - assert.ok(f.items.some(i => i.inboundCode === 'WT-OWN-1') && f.items.some(i => i.inboundCode === 'WT-OWN-2')); assert.equal(f.items.length, 3) - const requests = f.calls.filter(c => c.method === 'POST' && c.path.endsWith('/transfer-delivery')), actual = requests.at(-1) - assert.deepEqual(proof.transferRequest.groups.map(g => g.fees.map(f => [f.currency, f.amount])), [[['USD', 11]], [['CNY', 17]]]) - assert.ok(proof.transferRequest.groups.every(g => g.feeCoverage.length === 0 && !g.groupKey.startsWith('FBA:'))) - assert.equal(requests.length, 5); assert.equal(actual.body.idempotencyKey, proof.transferRequest.idempotencyKey) - assert.ok(f.calls.findIndex(c => c.path.endsWith('/pick')) < f.calls.findIndex(c => c.path.endsWith('/loading-complete'))) - assert.ok(f.calls.findIndex(c => c.path.endsWith('/loading-complete')) < f.calls.findIndex(c => c.path.endsWith('/transfer-delivery'))) - assert.ok(f.calls.every(c => !c.path.includes('DS-FULL') && !c.path.includes('pre-arrange'))); assert.ok(f.assertions.every(a => a.passed)) - const snapshots = proof.nativeArrangement.snapshots - assert.deepEqual(snapshots['DEL-0'].fees.map(f => [f.currency, f.amount]), [['USD', 11], ['CNY', 30]]) - assert.deepEqual(snapshots['DEL-1'].fees.map(f => [f.currency, f.amount]), [['CNY', 17]]) - assert.equal(proof.sourceFees[0].originalAmount, 30); assert.equal(proof.sourceFees[0].customerId, OWN) +test('ordinary current sources automatically deliver without manual fee configuration',async()=>{ + const f=fixture('',true),proof=await verifyFleetExternalDispatch(f.context,'external-native',f.tripProof,f.options) + assert.equal(proof.status,'VERIFIED');assert.equal(proof.fulfillmentMode,'AUTOMATIC_DELIVERY') + assert.equal(proof.deliveries.length,2);assert.equal(proof.deliveries.reduce((n,d)=>n+d.packageCount,0),3) + assert.ok(proof.deliveries.every(d=>d.status==='DELIVERING'));assert.ok(proof.loadingRequest.idempotencyKey) + assert.equal(f.calls.some(c=>c.path.endsWith('/transfer-delivery')||c.path.endsWith('/depart')),false) + assert.equal(proof.sourceFees[0].originalAmount,30) }) - -test('ordinary native configuration and coverage mode are exclusive and require current explicit group configuration', async () => { - for (const mutate of [f => { delete f.options.arrangement }, f => { f.options.coverageCase = f.currentCase }, f => { f.options.arrangement.mode = 'SAME_ROUTE' }, - f => { f.options.arrangement.marker = 'OLD' }, f => { f.options.arrangement.dispatchNo = 'OTHER' }, f => { f.options.arrangement.groups.push(clone(f.options.arrangement.groups[0])) }, - f => { f.options.arrangement.groups = async () => ({ success: true }) }, f => { f.options.arrangement.groups[0].fees[0].amount = 0 }, f => { f.options.arrangement.groups[0].fees.push(clone(f.options.arrangement.groups[0].fees[0])) }, - f => { f.options.arrangement.groups[0].feeCoverage = [] }, f => { delete f.options.pickerToken }, f => { delete f.context.checkpoint }]) { - const f = fixture('', true); mutate(f); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options)); assert.equal(f.calls.length, 0) - } - for (const mutate of [f => { f.options.arrangement.groups.pop() }, f => { f.options.arrangement.groups[0].customerId = '999' }]) { - const f = fixture('', true); mutate(f); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options), /加货前精确覆盖/); assert.equal(f.calls.some(c => c.method !== 'GET'), false) - } -}) - -for (const fault of ['foreign-customer', 'double-capacity', 'review-not-applied', 'stale-price', 'duplicate-fee', 'foreign-source-fee', 'wrong-quantity', 'replay-row-drift', 'already-loaded', 'missing-measurements', 'unknown-arrange']) test(`ordinary native flow preserves protection against ${fault}`, async () => { - const f = fixture(fault, true); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options)); assert.notEqual(f.tripProof.externalDispatch.status, 'VERIFIED') - assert.equal(Object.hasOwn(f.context.createdResources, 'fleetFeeCoverageCases'), false) - if (fault === 'missing-measurements') assert.equal(f.calls.some(c => c.path.endsWith('/loading-complete') || c.path.endsWith('/transfer-delivery')), false) - if (fault === 'unknown-arrange') { assert.equal(f.tripProof.externalDispatch.status, 'OUTCOME_UNKNOWN'); assert.equal(f.calls.at(-1).path.endsWith('/transfer-delivery'), true); assert.equal(f.context.createdResources.dispatchWriteBoundary.stopped, true); assert.ok(f.tripProof.externalDispatch.nativeArrangement.confirmationRequest.idempotencyKey) } -}) - -test('ordinary native plan permits explicit zero extra fee only for fully quoted cargo and never expands the quote into each group', () => { - const f = fixture('', true), receipt = f.tripProof.externalReceipt, plan = clone(f.options.arrangement) - plan.groups = [{ customerId: OWN, destinationKey: 'FBA:A1', fees: [] }] - const group = { groupKey: 'REAL-GROUP', customerId: OWN, destination: { key: 'FBA:A1' }, groupCargoHash: 'a'.repeat(64), externalFreightHash: 'b'.repeat(64), feeCoverageCargoHash: 'a'.repeat(64), blockReasons: [], requiredServices: [], feeSources: [], - cargo: [{ customerId: OWN, inboundCode: receipt.incomingNo, destination: { key: 'FBA:A1' } }], externalQuotes: [{ groupKey: 'REAL-GROUP', customerId: OWN, bookingNo: receipt.bookingNo, inboundCode: receipt.incomingNo, currency: 'CNY', amount: 30, sourceHash: 'b'.repeat(64) }] } - const groups = nativeArrangementGroups({ groups: [group] }, plan, receipt) - assert.deepEqual(groups[0].fees, []); assert.deepEqual(groups[0].feeCoverage, []); assert.equal(groups[0].groupKey, 'REAL-GROUP') - plan.groups[0].fees = [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: 'duplicate', currency: 'CNY', amount: 30 }] - assert.throws(() => nativeArrangementGroups({ groups: [group] }, plan, receipt), /不得重复登记/) - plan.groups[0].fees = []; group.requiredServices = ['DELIVERY_FEE']; assert.throws(() => nativeArrangementGroups({ groups: [group] }, plan, receipt), /尚未覆盖/) +for(const fault of ['foreign-customer','double-capacity','review-not-applied','stale-price','duplicate-fee','foreign-source-fee','wrong-quantity','replay-row-drift','already-loaded','missing-measurements'])test(`automatic external flow rejects ${fault}`,async()=>{ + const f=fixture(fault,true);await assert.rejects(verifyFleetExternalDispatch(f.context,'external-native',f.tripProof,f.options));assert.notEqual(f.tripProof.externalDispatch.status,'VERIFIED') }) diff --git a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs index 78d9b70b6..e55341371 100644 --- a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs @@ -1,141 +1,19 @@ import { DISPATCH_BASE as BASE, id as stringId, version, createBody, candidatePage, readDispatch, replay } from './grouped-dispatch-acceptance.mjs' -import { fleetConsumerIo, readyFleetDispatch, stableRows, verifyFleetFeeCoverage } from './fleet-fee-coverage-acceptance.mjs' +import { fleetConsumerIo, readyFleetDispatch, stableRows, verifyFleetFeeCoverage, readAutomaticDelivery } from './fleet-fee-coverage-acceptance.mjs' const clone = value => structuredClone(value) const id = value => { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('服务端ID精度无效,必须保留Long字符串'); return stringId(value) } const money = value => { const text = String(value); if (!/^\d+(?:\.\d{1,2})?$/.test(text)) throw Error('外接约定金额或单价不完整'); const [whole, fraction = ''] = text.split('.'); return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0')) } const identity = item => ({ dispatchItemId: id(item.dispatchItemId), sourceKey: item.sourceKey, customerId: id(item.customerId), warehousePalletId: id(item.warehousePalletId), scheduledPackages: Number(item.scheduledPackages), pickedPackages: Number(item.pickedPackages) }) -const groupIdentity = (customerId, destinationKey) => JSON.stringify([id(customerId), destinationKey]) -const sha = value => /^[a-f0-9]{64}$/i.test(value || '') -const feeIdentity = fee => `${fee.direction}:${fee.feeType}:${fee.currency}:${money(fee.amount)}` -function validateNativeArrangement(context, tripProof, plan) { - if (plan?.mode !== 'NATIVE' || plan.marker !== context.marker || plan.dispatchNo !== tripProof.dispatchNo - || typeof plan.plannedDeliveryAt !== 'string' || !Number.isFinite(Date.parse(plan.plannedDeliveryAt)) - || !plan.deliveryMethod || !plan.pickupMethod || plan.loadingType !== 'PALLET' || plan.appointmentAccount !== 'SELF' - || !Array.isArray(plan.groups) || !plan.groups.length) throw Error('普通原生安排须明确本轮主单、派送时间、PALLET/SELF及逐客户目的地费用') - const keys = new Set() - for (const group of plan.groups) { - const key = groupIdentity(group.customerId, group.destinationKey) - if (!/^[1-9]\d*$/.test(id(group.customerId)) || typeof group.destinationKey !== 'string' || !group.destinationKey.trim() || keys.has(key) || !Array.isArray(group.fees) || Object.hasOwn(group, 'feeCoverage')) throw Error('普通安排的客户目的地配置缺失、重复或含原费覆盖选择') - keys.add(key) - const fees = new Set() - for (const fee of group.fees) { - const feeKey = `${fee.direction}:${fee.feeType}` - if (!['RECEIVABLE', 'PAYABLE'].includes(fee.direction) || !['DELIVERY_FEE', 'LOADING_FEE', 'APPOINTMENT_FEE'].includes(fee.feeType) - || !fee.feeName || !['CNY', 'USD'].includes(fee.currency) || money(fee.amount) <= 0n || fees.has(feeKey)) throw Error('普通安排费用必须逐组明确正数、币种和服务,不得重复') - fees.add(feeKey) - } - } -} - -// 仅将明确的客户/目的地配置映射到实时groupKey与摘要;不推算费用或原费覆盖。 -export function nativeArrangementGroups(preview, plan, receipt) { - const configured = new Map(plan.groups.map(g => [groupIdentity(g.customerId, g.destinationKey), g])) - if (!Array.isArray(preview.groups) || preview.groups.length !== configured.size || new Set(preview.groups.map(g => groupIdentity(g.customerId, g.destination?.key))).size !== configured.size) throw Error('普通安排配置必须精确覆盖全车全部真实客户目的地') - return preview.groups.map(group => { - const config = configured.get(groupIdentity(group.customerId, group.destination?.key)) - if (!config || !group.groupKey || !sha(group.groupCargoHash) || !sha(group.externalFreightHash) || !sha(group.feeCoverageCargoHash) - || !Array.isArray(group.blockReasons) || group.blockReasons.length || !Array.isArray(group.requiredServices) || !Array.isArray(group.externalQuotes) - || !Array.isArray(group.feeSources) || !Array.isArray(group.cargo) || !group.cargo.length || group.cargo.some(c => id(c.customerId) !== id(group.customerId) || c.destination?.key !== group.destination.key)) throw Error('普通原生安排的实际分组或来源门禁不完整') - if (group.externalQuotes.some(q => q.bookingNo !== receipt.bookingNo || q.inboundCode !== receipt.incomingNo || id(q.customerId) !== id(group.customerId) - || q.groupKey !== group.groupKey || q.sourceHash !== group.externalFreightHash || q.currency !== receipt.finalBooking.cargo.currency || money(q.amount) !== money(receipt.finalBooking.agreedAmount))) throw Error('普通安排的外接报价已变化或越过客户来源') - if (group.requiredServices.some(service => !config.fees.some(f => f.direction === 'RECEIVABLE' && f.feeType === service))) throw Error('普通安排必须明确填写本组尚未覆盖的服务费用') - const quotedAll = group.cargo.every(c => group.externalQuotes.some(q => q.inboundCode === c.inboundCode)) - if (quotedAll && config.fees.some(f => f.direction === 'RECEIVABLE' && f.feeType === 'DELIVERY_FEE')) throw Error('本组派送已由外接约定覆盖,不得重复登记应收') - return { groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, plannedDeliveryAt: plan.plannedDeliveryAt, - deliveryMethod: plan.deliveryMethod, pickupMethod: plan.pickupMethod, loadingType: plan.loadingType, appointmentAccount: plan.appointmentAccount, - waybillNo: plan.waybillNo || null, carrierName: plan.carrierName || null, vehicleType: plan.vehicleType || null, vehicleSize: plan.vehicleSize || null, sellerName: null, remark: plan.remark || null, - externalFreightHash: group.externalFreightHash, feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: [], fees: clone(config.fees) } - }) -} - -async function arrangeNative(context, module, io, proof, tripProof, plan, pickerToken, actualMeasurements) { - const current = await readyFleetDispatch(context, module, io, tripProof, { pickerToken, actualMeasurements }) - const path = `${BASE}/${encodeURIComponent(tripProof.dispatchNo)}`, preview = await io.get(`${path}/delivery-arrangement`, '普通模式读取装车后实时全车派送分组') - io.check('普通安排使用当前公共版本和真实实载摘要', null, preview?.dispatchNo === tripProof.dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'AFTER_LOADING' - && id(version(preview.recordVersion)) === id(version(current.data.recordVersion)) && preview.arrange?.allowed === true && !preview.arrangement && sha(preview.cargoHash) && sha(preview.loadingCompletionHash)) - const groups = nativeArrangementGroups(preview, plan, tripProof.externalReceipt), totals = new Map() - for (const group of preview.groups) for (const row of group.cargo) { - io.check('普通派送货物保留实际客户目的地和原物理行', null, current.items.items.some(i => id(i.dispatchItemId) === id(row.dispatchItemId) && id(i.customerId) === id(group.customerId) && i.destination.key === group.destination.key && id(i.warehousePalletId) === id(row.warehousePalletId))) - totals.set(id(row.dispatchItemId), (totals.get(id(row.dispatchItemId)) || 0) + Number(row.packages)) - } - io.check('普通安排逐物理行箱数完整守恒', null, totals.size === current.items.items.length && current.items.items.every(i => totals.get(id(i.dispatchItemId)) === Number(i.scheduledPackages))) - const body = { recordVersion: version(preview.recordVersion), idempotencyKey: io.key('native-arrangement'), cargoHash: preview.cargoHash, loadingCompletionHash: preview.loadingCompletionHash, groups } - proof.nativeArrangement = { mode: 'NATIVE', confirmationRequest: clone(body), status: 'CHECKING' }; await context.checkpoint() - const noDocuments = async () => { - for (const customerId of new Set(preview.groups.map(g => id(g.customerId)))) { - const rows = []; let total - for (let page = 1; ; page++) { - const data = await io.get(`/api/platform/v1/last-leg/outbound-orders?${new URLSearchParams({ customerId, page: String(page), size: '200' })}`, '普通安排拒绝前后按客户回查无出库写入') - io.check('普通安排出库分页完整稳定', null, Array.isArray(data?.records) && Number.isSafeInteger(Number(data.total)) && Number(data.total) >= 0 && Number(data.page) === page && Number(data.size) === 200 - && (total === undefined || total === Number(data.total)) && (data.records.length > 0 || rows.length === Number(data.total))) - total ??= Number(data.total); rows.push(...data.records); if (rows.length >= total) break - } - io.check('普通安排拒绝未生成本主单出库', null, rows.length === total && new Set(rows.map(r => r.outboundNo)).size === rows.length && rows.every(r => r.dispatchNo !== tripProof.dispatchNo)) - } - } - const reject = async (action, changed, pattern) => { - const before = await readDispatch(io, tripProof.dispatchNo) - await io.request('POST', `${path}/transfer-delivery`, { ...clone(body), ...changed, idempotencyKey: io.key(action) }, action, pattern) - await noDocuments(); io.check(`${action}不改整车版本及实物`, null, JSON.stringify(await readDispatch(io, tripProof.dispatchNo)) === JSON.stringify(before)) - } - await noDocuments() - const changedHash = hash => `${hash[0] === '0' ? '1' : '0'}${hash.slice(1)}` - await reject('普通安排陈旧货物摘要拒绝', { cargoHash: changedHash(body.cargoHash) }, /货物.*变化/) - const quoted = preview.groups.find(g => g.externalQuotes.length) - io.check('普通安排包含本轮已确认外接报价', null, Boolean(quoted)) - const stale = clone(groups); stale.find(g => g.groupKey === quoted.groupKey).externalFreightHash = changedHash(quoted.externalFreightHash) - await reject('普通安排陈旧外接报价摘要拒绝', { groups: stale }, /外接.*变化/) - const duplicate = clone(groups), quote = quoted.externalQuotes[0] - duplicate.find(g => g.groupKey === quoted.groupKey).fees.push({ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '本轮重复外接应收反例', currency: quote.currency, amount: quote.amount }) - await reject('普通安排重复外接应收拒绝', { groups: duplicate }, /重复|覆盖/) - const command = await io.request('POST', `${path}/transfer-delivery`, body, '普通原生模式一次安排全车全部客户目的地'), result = command?.result - io.check('普通原生命令完整返回真实客户出库和目的地派送', null, command?.dispatchNo === tripProof.dispatchNo && command.action === 'ARRANGE_DELIVERY' && command.status === 'ACTIVE' && command.stage === 'AFTER_LOADING' - && BigInt(version(command.recordVersion)) > BigInt(version(body.recordVersion)) && result?.dispatchNo === tripProof.dispatchNo && result.cargoHash === body.cargoHash - && result.deliveries?.length === preview.groups.length && new Set(result.deliveries.map(d => d.deliveryNo)).size === result.deliveries.length - && result.outbounds?.length === new Set(preview.groups.map(g => id(g.customerId))).size - && result.deliveries.every(d => d.deliveryNo && d.status === 'WAIT_DELIVERY' && preview.groups.some(g => g.groupKey === d.groupKey && id(g.customerId) === id(d.customerId) && g.destination.key === d.destination?.key - && g.cargo.reduce((n, c) => n + Number(c.packages), 0) === Number(d.packageCount)) && result.outbounds.some(o => o.outboundNo === d.outboundNo && id(o.customerId) === id(d.customerId))) - && result.outbounds.every(o => o.outboundNo && o.status === 'WAIT_OUTBOUND' && Number(o.packageCount) === result.deliveries.filter(d => d.outboundNo === o.outboundNo).reduce((n, d) => n + Number(d.packageCount), 0))) - const snapshots = {} - for (const ref of result.deliveries) { - const group = preview.groups.find(g => g.groupKey === ref.groupKey), input = groups.find(g => g.groupKey === ref.groupKey) - const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '普通安排回查客户目的地及明确费用') - const expectedFees = [...input.fees.map(feeIdentity), ...group.externalQuotes.filter(q => money(q.amount) > 0n).map(q => feeIdentity({ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: q.currency, amount: q.amount }))] - io.check('普通派送保留本客户本目的地费用,无伪零、重复或原费覆盖回执', null, detail?.order?.dispatchNo === tripProof.dispatchNo && detail.order.deliveryNo === ref.deliveryNo && id(detail.order.customerId) === id(ref.customerId) - && detail.order.outboundNo === ref.outboundNo && id(detail.order.destinationWarehouseCode || detail.order.destinationWarehouseId) === id(group.destination.warehouseCode || group.destination.warehouseId) - && (detail.feeCoverage == null || detail.feeCoverage.references?.length === 0) && Array.isArray(detail.fees) && new Set(detail.fees.map(f => id(f.id))).size === detail.fees.length - && detail.fees.every(f => money(f.amount) > 0n && f.sourceStatus === 'ACTIVE' && f.reviewStatus === 'APPROVED') - && stableRows(detail.fees.map(f => feeIdentity({ direction: f.feeDirection, feeType: f.feeType, currency: f.currency, amount: f.amount }))) === stableRows(expectedFees)) - snapshots[ref.deliveryNo] = { fees: clone(detail.fees), feeCoverage: clone(detail.feeCoverage) } - } - const again = await replay(io, `${path}/transfer-delivery`, body, command, '普通安排原键原载荷重放'); io.check('普通安排明确恢复原成功命令', null, again.replayed === true) - for (const ref of result.deliveries) { - const after = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '普通安排重放后核对不追加应收') - io.check('普通安排重放不增费不改覆盖快照', null, JSON.stringify({ fees: after.fees, feeCoverage: after.feeCoverage }) === JSON.stringify(snapshots[ref.deliveryNo])) - } - const after = await io.get(`${path}/delivery-arrangement`, '普通安排后回查报价与原费用候选未被改写') - const originalFacts = value => stableRows(value.groups.map(g => ({ groupKey: g.groupKey, externalQuotes: g.externalQuotes, feeSources: g.feeSources }))) - io.check('普通安排保持报价及原费用来源快照', null, originalFacts(after) === originalFacts(preview)) - Object.assign(proof.nativeArrangement, { status: 'VERIFIED', command, snapshots }) - return { confirmationRequest: body, deliveries: result.deliveries, outbounds: result.outbounds } -} - -/** 已确认实收来源只ADD_CARGO到原整车;复审、真实扫码、显式装车后一次安排全部客户。 - * options二选一:coverageCase为本轮PARTIAL;或arrangement={mode:'NATIVE',marker,dispatchNo, - * plannedDeliveryAt,deliveryMethod,pickupMethod,loadingType:'PALLET',appointmentAccount:'SELF', - * groups:[{customerId,destinationKey,fees:FeeItem[]}]}; 金额和费用覆盖均不自动推断。 - */ -export async function verifyFleetExternalDispatch(context, module, tripProof, { pickerToken, actualMeasurements, coverageCase, arrangement } = {}) { +/** 将本轮已实收外接货加入原车,复审及真实装车后读取自动派送。 */ +export async function verifyFleetExternalDispatch(context, module, tripProof, { pickerToken, actualMeasurements, coverageCase } = {}) { const receipt = tripProof?.externalReceipt, cases = context.createdResources?.fleetFeeCoverageCases if (!context.createdResources?.fleetTrips?.includes(tripProof) || receipt?.status !== 'VERIFIED' || tripProof.externalDispatch) throw Error('外接只接受本轮已运营确认且尚未装入本车的真实来货') - if (Boolean(coverageCase) === Boolean(arrangement)) throw Error('必须且只能选择同主单未扫码PARTIAL费用覆盖或普通NATIVE安排模式') - if (arrangement) validateNativeArrangement(context, tripProof, arrangement) if (coverageCase && (!cases?.includes(coverageCase) || coverageCase.marker !== context.marker || coverageCase.mode !== 'PARTIAL' || coverageCase.dispatchNo !== tripProof.dispatchNo || coverageCase.proof) || !pickerToken || typeof context.checkpoint !== 'function') throw Error('外接加货前须有同主单未扫码PARTIAL用例及本轮PDA身份;必须先外接后扫码安排') - const proof = tripProof.externalDispatch = { marker: context.marker, dispatchNo: tripProof.dispatchNo, arrangementMode: arrangement ? 'NATIVE' : 'FEE_COVERAGE', status: 'CHECKING', requests: [] } + const proof = tripProof.externalDispatch = { marker: context.marker, dispatchNo: tripProof.dispatchNo, fulfillmentMode: 'AUTOMATIC_DELIVERY', status: 'CHECKING', requests: [] } const io = fleetConsumerIo(context, module, proof, `EXTERNAL-${tripProof.dispatchNo}`) const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`, path = `${BASE}/${encodeURIComponent(tripProof.dispatchNo)}` const commandReplay = async (path, body, result, label) => { const again = await replay(io, path, body, result, label); io.check(`${label}明确恢复原结果`, null, again.replayed === true) } @@ -163,10 +41,6 @@ export async function verifyFleetExternalDispatch(context, module, tripProof, { io.check('候选仅为本次确认的全部真实外接来源', null, candidates.length === actual.length && candidates.every(c => c.destination.key === booking.cargo.destinationKey && actual.some(p => id(p.warehousePalletId) === id(c.selection.warehousePalletId) && p.palletNo === c.palletNo && Number(p.packages) === Number(c.availablePackages)))) const addition = createBody(context.marker, `EXTERNAL-${tripProof.dispatchNo}`, candidates) - if (arrangement) { - const actualGroups = new Set([...current.items.items.map(i => groupIdentity(i.customerId, i.destination?.key)), ...candidates.map(c => groupIdentity(c.customerId, c.destination?.key))]) - io.check('普通安排配置在加货前精确覆盖全部实际客户目的地', null, stableRows([...actualGroups]) === stableRows(arrangement.groups.map(g => groupIdentity(g.customerId, g.destinationKey)))) - } if (current.data.stage === 'LOADING') { proof.returnRequest = { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('explicit-return'), reason: '本轮零扫码外接实收显式退回装车前,保留同一整车' } const returned = await io.request('POST', `${path}/return-to-review`, proof.returnRequest, '零扫码明确退回审核') @@ -218,14 +92,10 @@ export async function verifyFleetExternalDispatch(context, module, tripProof, { const ready = await io.get(path, '核对两级审核恢复真实扫码资格') io.check('同主单两级审核通过且仍零扫码', null, ready.stage === 'LOADING' && ready.reviewState === 'APPROVED' && ready.reviews?.dispatchReview?.status === 'APPROVED' && ready.reviews?.loadingReview?.status === 'APPROVED' && Number(ready.counts.pickedPackageCount) === 0) - const execution = arrangement ? await arrangeNative(context, module, io, proof, tripProof, arrangement, pickerToken, actualMeasurements) - : await verifyFleetFeeCoverage(context, module, coverageCase, { pickerToken, actualMeasurements, externalTrip: tripProof }) - const groups = await io.get(`${path}/delivery-arrangement`, '安排后回查原生外接报价快照') - const quotes = groups.groups.flatMap(g => g.externalQuotes), expected = receipt.finalBooking - const matching = quotes.filter(q => q.bookingNo === receipt.bookingNo && q.inboundCode === receipt.incomingNo && id(q.customerId) === id(booking.customerId)) - io.check('原外接报价属于本次客户和来源且等于运营确认约定金额', null, matching.length === 1 && money(matching[0].amount) === money(expected.agreedAmount) - && matching[0].currency === expected.cargo.currency && matching[0].sourceHash && money(expected.agreedAmount) > 0n) - const deliveries = execution.deliveries.filter(d => groups.groups.some(g => g.groupKey === d.groupKey && g.externalQuotes.some(q => q.bookingNo === receipt.bookingNo))) + const execution = coverageCase ? await verifyFleetFeeCoverage(context, module, coverageCase, { pickerToken, actualMeasurements, externalTrip: tripProof }) + : await readAutomaticDelivery(io, await readyFleetDispatch(context, module, io, tripProof, { pickerToken, actualMeasurements }), tripProof) + const expected = receipt.finalBooking + const deliveries = execution.deliveries.filter(d => id(d.customerId) === id(booking.customerId) && d.destination?.key === booking.cargo.destinationKey) io.check('本次外接来源只落入其客户目的地派送', null, deliveries.length === 1 && id(deliveries[0].customerId) === id(booking.customerId)) proof.outboundNo = deliveries[0].outboundNo; proof.deliveryNo = deliveries[0].deliveryNo; proof.deliveries = execution.deliveries const delivery = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(proof.deliveryNo)}?customerId=${encodeURIComponent(id(booking.customerId))}`, '回查本客户外接应收来源') @@ -236,8 +106,8 @@ export async function verifyFleetExternalDispatch(context, module, tripProof, { && fees[0].originalCurrency === expected.cargo.currency && fees[0].reviewStatus === 'APPROVED' && id(fees[0].customerId) === id(booking.customerId) && quantity != null && Number(fees[0].chargeableQuantity) === Number(quantity) && money(fees[0].unitPrice) === money(expected.cargo.unitPrice) && fees[0].quantityUnit === (expected.cargo.priceBasis === 'PALLET' ? '托位' : '单') - && fees[0].businessNo === proof.deliveryNo && delivery.fees.some(f => id(f.id) === id(fees[0].id))) - proof.customerId = id(booking.customerId); proof.sourceFees = fees; proof.transferRequest = execution.confirmationRequest + && fees[0].businessNo === proof.deliveryNo && delivery.fees.some(f => id(f.id) === id(fees[0].id) && money(f.amount) === money(fees[0].originalAmount) && f.currency === fees[0].originalCurrency)) + proof.customerId = id(booking.customerId); proof.sourceFees = fees; proof.loadingRequest = execution.confirmationRequest proof.finalTripVersion = (await io.get(base, '交接同车资源版本')).trip.recordVersion proof.financeAcknowledgement = 'NOT_VERIFIED'; proof.status = 'VERIFIED'; await context.checkpoint?.(); return proof } catch (error) { proof.status = proof.outcomeUnknown || context.createdResources.dispatchWriteBoundary?.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message; await context.checkpoint?.(); throw error } diff --git a/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs index 1ad89c9f2..3581828b5 100644 --- a/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs @@ -1,122 +1,39 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import { verifyFleetFeeCoverage, coveragePlan } from './fleet-fee-coverage-acceptance.mjs' - -const BASE = '/api/platform/v1/last-leg/dispatches', PDA = '/api/pda/v1/last-leg/dispatches', customerId = '900719925474099301' -const clone = value => structuredClone(value) -function fixture(mode = 'FULL', fault = '', needsLoading = false) { - const cases = ['FULL', 'PARTIAL'].map((mode, index) => ({ marker: 'CURRENT-TEST', mode, dispatchNo: `DS-${index + 1}`, customerId, - transferBody: { plannedDeliveryAt: '2026-10-22T01:00:00Z', loadingType: 'PALLET', appointmentAccount: 'SELF', fees: [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '派送费', amount: 17.5, currency: 'USD' }] } })) - const source = cases.find(c => c.mode === mode), calls = [], assertions = [], ledger = new Map(), saved = new Map() - const states = cases.map((c, n) => ({ dispatchNo: c.dispatchNo, recordVersion: 0, stage: needsLoading && c === source ? 'LOADING' : 'AFTER_LOADING', items: [1, 2].map(i => ({ - dispatchItemId: `${n + 1}1${i}`, sourceKey: `PLAN:${n + 1}2${i}`, sourceVersion: 'a'.repeat(64), customerId, warehousePalletId: `${n + 1}3${i}`, planPalletId: `${n + 1}2${i}`, - inboundCode: `IN-${n + 1}-${i}`, destination: { key: `FBA:A${i}`, warehouseCode: `A${i}` }, scheduledPackages: 2, pickedPackages: needsLoading && c === source ? 0 : 2 })) })) - if (fault === 'overlap') states[1].items[0].warehousePalletId = states[0].items[0].warehousePalletId - const triples = cases.map((c, n) => ({ dispatchNo: c.dispatchNo, tripNo: `TR-${n + 1}`, warehouseId: '92', stops: [{ index: 0, destinationKey: 'ORIGIN:92' }, { index: 1, destinationKey: 'FBA:A1' }, { index: 2, destinationKey: 'FBA:A2' }] })) - let dirty = false, replayed = false - const totals = s => ({ itemCount: s.items.length, palletCount: s.items.length, packageCount: 4, pickedPackageCount: s.items.reduce((n, i) => n + i.pickedPackages, 0), shippedPackageCount: 0 }) - const detail = s => ({ dispatchNo: s.dispatchNo, status: 'ACTIVE', stage: s.stage, recordVersion: s.recordVersion, counts: totals(s), pickingReconciled: s.items.every(i => i.pickedPackages === 2), - dispatchInfo: { route: 'A1,A2' }, orderDetails: s.items.map(i => ({ customerId, sourceKey: i.sourceKey, inboundCode: i.inboundCode, destinationFbaWarehouse: i.destination.warehouseCode })) }) - const cargo = s => s.items.map((i, n) => ({ ...i, orderId: `4${s.dispatchNo.slice(-1)}${n + 1}`, sourceRegion: 'FIRSTLEG', sourceInboundId: `9007199254740994${s.dispatchNo.slice(-1)}${n + 1}`, packages: 2 })) - const groups = s => cargo(s).map((row, n) => ({ groupKey: `G-${s.dispatchNo}-${n}`, customerId, destination: row.destination, groupCargoHash: `${n + 1}`.repeat(64), externalFreightHash: 'b'.repeat(64), externalQuotes: [], feeCoverageCargoHash: `${n + 1}`.repeat(64), cargo: [row], blockReasons: [], requiredServices: ['DELIVERY_FEE'], - feeSources: fault === 'missing-source' && n === 1 ? [] : [{ financeFeeId: `9007199254740995${s.dispatchNo.slice(-1)}${n}`, customerId: fault === 'foreign-customer' && n === 0 ? '999' : customerId, - currency: 'CNY', amount: fault === 'mutated-original' && saved.has(s.dispatchNo) ? 201 : 200, sourceVersion: 1, sourceHash: `fee-hash-${n}`, status: 'AVAILABLE', services: ['DELIVERY_FEE'], sourceKeys: [row.sourceKey] }] })) - const ack = (s, action, result = {}) => ({ dispatchNo: s.dispatchNo, action, recordVersion: s.recordVersion, status: 'ACTIVE', stage: s.stage, counts: totals(s), result, replayed: false }) - const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data: clone(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' }) - const context = { marker: source.marker, createdResources: { fleetFeeCoverageCases: cases, fleetTrips: triples }, checkpoint: async () => {}, - assert(_module, name, _response, passed) { assertions.push({ name, passed }) }, addStep(_module, name, _response, passed) { assertions.push({ name, passed }) }, addCoverageGap() {}, apiSuccess(_m, _l, r) { return r.body?.data }, - async request(method, raw, body, token) { - calls.push({ method, path: raw, body: clone(body), token }) - const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), normalized = path.replace(PDA, BASE), number = normalized.slice(BASE.length + 1).split('/')[0] - const state = states.find(s => s.dispatchNo === number), commandKey = `${normalized}:${body?.idempotencyKey}` - if (method === 'POST' && ledger.has(commandKey)) { assert.deepEqual(body, ledger.get(commandKey).body); replayed = true; return response({ ...clone(ledger.get(commandKey).data), replayed: true }) } - const remember = data => { ledger.set(commandKey, { body: clone(body), data: clone(data) }); return response(data) } - if (method === 'GET' && path === BASE) return response({ records: states.filter(s => s.dispatchNo === params.get('dispatchNo')).map(s => ({ ...detail(s), warehouseId: '92' })) }) - if (path.includes('/outbound-orders?') || raw.includes('/outbound-orders?')) return response({ records: dirty ? [{ outboundNo: 'OUT-DIRTY', dispatchNo: source.dispatchNo }] : [], total: dirty ? 1 : 0, page: 1, size: 200 }) - if (path.match(/\/outbound-orders\/OUT-DS-[12]$/)) { const n = path.slice(path.lastIndexOf('OUT-') + 4), s = states.find(s => s.dispatchNo === n), p = saved.get(n); return response({ outbound: { outboundNo: `OUT-${n}`, dispatchNo: n, status: 'WAIT_OUTBOUND' }, deliveries: p.groups.map((g, i) => ({ deliveryNo: `DEL-${n}-${i}`, status: 'WAIT_DELIVERY' })) }) } - const delivery = path.match(/\/delivery-orders\/DEL-(DS-[12])-(\d)$/) - if (delivery) { - assert.equal(params.get('customerId'), customerId) - const s = states.find(s => s.dispatchNo === delivery[1]), index = Number(delivery[2]), group = groups(s)[index], input = saved.get(s.dispatchNo).groups[index] - const references = input.feeCoverage.map(selection => ({ source: { ...group.feeSources.find(f => f.financeFeeId === selection.financeFeeId), sourceType: 'INBOUND_FEE', sourceInboundId: group.cargo[0].sourceInboundId, - ...(fault === 'snapshot-source-amount' ? { amount: 999 } : {}) }, services: selection.services })) - const evidence = input.feeCoverage.length ? { cargoHash: group.feeCoverageCargoHash, cargo: fault === 'wrong-cargo' ? cargo(s) : group.cargo, - references: fault === 'missing-reference' ? [] : references, operatorId: 'OP', confirmedAt: replayed && fault === 'rewrite-confirmation' ? '2026-10-19T02:00:00Z' : '2026-10-19T01:00:00Z' } : null - let fees = input.fees.map(f => ({ id: `8${index}`, feeDirection: f.direction, feeType: f.feeType, currency: f.currency, amount: f.amount, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE' })) - if (fault === 'duplicate-new-fee' && index === 0 || fault === 'replay-adds-fee' && replayed) fees.push({ id: '999', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 17.5, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE' }) - if (fault === 'charge-covered-destination') fees = index === 0 ? [{ id: '999', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 17.5, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE' }] : [] - return response({ order: { dispatchNo: s.dispatchNo, deliveryNo: `DEL-${s.dispatchNo}-${index}`, customerId: fault === 'wrong-delivery-customer' ? '999' : customerId, outboundNo: `OUT-${s.dispatchNo}`, destinationWarehouseCode: group.destination.warehouseCode }, fees, feeCoverage: evidence }) - } - assert.ok(state, `${method} ${raw}`) - if (path.startsWith(PDA) && method === 'GET' && normalized.endsWith(number)) return response({ ...detail(state), items: state.items.map((i, n) => ({ ...i, positions: [{ positionId: `6${n}`, availablePackages: 2 }] })) }) - if (method === 'POST' && normalized.endsWith('/pick')) { assert.ok(token); if (body.recordVersion !== state.recordVersion) return response(null, '公共版本已变化'); const itemId = normalized.split('/').at(-2); state.items.find(i => i.dispatchItemId === itemId).pickedPackages += body.packages; state.recordVersion++; return remember(ack(state, 'PICK')) } - if (method === 'GET' && normalized.endsWith('/loading')) return response({ ...detail(state), manifestHash: 'c'.repeat(64), loadingComplete: { allowed: true }, pallets: state.items.map((i, n) => ({ ...i, palletNo: `PAL-${n}`, deliveryIndex: n + 1, palletSpaces: 1, weightKg: 20, volumeCbm: 0.2, - evidence: ['palletSpaces', 'weightKg', 'volumeCbm'].map(field => ({ field, value: field === 'palletSpaces' ? 1 : field === 'weightKg' ? 20 : 0.2, actual: true, sourceType: 'MEASURED', sourceBusinessNo: i.inboundCode, observedAt: '2026-10-19T01:00:00Z' })) })) }) - if (method === 'POST' && normalized.endsWith('/loading-complete')) { assert.equal(body.recordVersion, state.recordVersion); assert.equal(state.items.every(i => i.pickedPackages === 2), true); state.stage = 'AFTER_LOADING'; state.recordVersion++; return remember(ack(state, 'LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } - if (method === 'GET' && path.endsWith('/items')) return response({ dispatchNo: number, recordVersion: state.recordVersion, status: 'ACTIVE', stage: state.stage, items: state.items }) - if (method === 'GET' && path.endsWith('/route')) return response({ dispatchNo: number, recordVersion: state.recordVersion, stops: triples.find(t => t.dispatchNo === number).stops }) - if (method === 'GET' && path.endsWith('/delivery-arrangement')) return response({ ...detail(state), cargoHash: 'a'.repeat(64), loadingCompletionHash: 'c'.repeat(64), arrange: { allowed: !saved.has(number) }, groups: groups(state), arrangement: saved.has(number) ? { dispatchNo: number } : null }) - if (method === 'POST' && path.endsWith('/transfer-delivery')) { - assert.ok(body.idempotencyKey); assert.equal(body.recordVersion, state.recordVersion); assert.equal(state.stage, 'AFTER_LOADING') - if (body.cargoHash !== 'a'.repeat(64)) { if (fault === 'failed-command-writes') dirty = true; return response(null, '装车或实际货物已经变化') } - if (body.groups.some(g => g.feeCoverage.some(f => f.sourceVersion !== 1))) return response(null, '原费用版本已变化') - if (source.mode === 'FULL' && body.groups.some(g => g.fees.length)) return response(null, '本组服务已全部覆盖,不得重复登记应收') - if (source.mode === 'PARTIAL' && !body.groups.some(g => g.fees.length)) return response(null, '本组尚有未覆盖的服务费用,请核对') - saved.set(number, clone(body)); state.recordVersion++ - const result = ack(state, 'ARRANGE_DELIVERY', { dispatchNo: number, cargoHash: body.cargoHash, outbounds: [{ customerId, outboundNo: `OUT-${number}`, status: 'WAIT_OUTBOUND', packageCount: 4 }], - deliveries: groups(state).map((g, n) => ({ groupKey: g.groupKey, customerId, destination: g.destination, outboundNo: `OUT-${number}`, deliveryNo: `DEL-${number}-${n}`, status: 'WAIT_DELIVERY', packageCount: 2 })) }) - if (fault === 'unknown-write') return { http: 0, body: null, raw: 'connection reset' } - return remember(result) - } - assert.equal(method, 'GET', raw); assert.equal(path, `${BASE}/${number}`); return response(detail(state)) - } } - return { context, source, cases, calls, assertions, states, groups, clearFault() { fault = '' } } +import { verifyFleetFeeCoverage } from './fleet-fee-coverage-acceptance.mjs' +const BASE = '/api/platform/v1/last-leg/dispatches' +function fixture(fault = '') { + const cases = ['FULL','PARTIAL'].map((mode,i)=>({marker:'RUN',mode,dispatchNo:`DS-${i}`,customerId:'C'})) + const trips = cases.map(c=>({dispatchNo:c.dispatchNo,tripNo:`TR-${c.mode}`,warehouseId:'92',loadingCommand:{body:{idempotencyKey:`KEY-${c.mode}`},result:{action:'LOADING_COMPLETE',dispatchNo:c.dispatchNo,stage:'DELIVERING',result:{loadingCompletionHash:'HASH'}}}})) + const items = cases.map((c,i)=>[{customerId:'C',warehousePalletId:fault==='overlap'?'PAL':`PAL-${i}`,sourceKey:`SOURCE-${i}`,destination:{key:'FBA:A'},pickedPackages:2,scheduledPackages:2}]) + const docs = i=>({outbounds:[{customerId:'C',outboundNo:`OUT-${i}`,status:'OUTBOUNDED'}],deliveries:[{customerId:'C',outboundNo:`OUT-${i}`,deliveryNo:`DEL-${i}`,destination:{key:'FBA:A'},status:'DELIVERING',packageCount:2}]}) + const detail = i=>({dispatchNo:`DS-${i}`,recordVersion:7,status:'ACTIVE',stage:'DELIVERING',pickingReconciled:true,counts:{packageCount:2,pickedPackageCount:2,shippedPackageCount:2},documents:fault==='missing-docs'?{}:docs(i)}) + const calls=[] + const context={marker:'RUN',createdResources:{fleetFeeCoverageCases:cases,fleetTrips:trips},checkpoint:async()=>{},assert(_m,label,_r,ok){assert.ok(ok,label)},apiSuccess(_m,_l,r){return r.body.data},async request(method,raw){ + calls.push({method,raw});assert.equal(method,'GET') + const url=new URL(raw,'https://offline.invalid'),path=url.pathname + let data + if(path===BASE){const i=Number(url.searchParams.get('dispatchNo').slice(-1));data={records:[{...detail(i),warehouseId:'92'}]}} + else if(path.includes('/delivery-orders/')){const i=Number(path.slice(-1));assert.equal(url.searchParams.get('customerId'),'C');data={order:{dispatchNo:`DS-${i}`,deliveryNo:`DEL-${i}`,outboundNo:`OUT-${i}`,customerId:fault==='foreign-customer'?'OTHER':'C',packageCount:2,status:'DELIVERING'},fees:fault==='duplicate-fee'?[{id:'F'},{id:'F'}]:[],feeCoverage:null}} + else{const i=Number(path.split('/dispatches/')[1][3]);data=path.endsWith('/items')?{...detail(i),items:items[i]}:detail(i)} + return {http:200,body:{code:'200',data},requestId:'REQ',traceId:'TRACE'} + }} + return {context,cases,trips,calls} } - -for (const mode of ['FULL', 'PARTIAL']) test(`${mode} uses native grouped fees, immutable confirmation evidence and original-key replay`, async () => { - const f = fixture(mode), proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source) - assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.newReceivable, mode === 'FULL' ? 0 : 17.5); assert.equal(proof.deliveryNos.length, 2) - assert.ok(f.assertions.every(a => a.passed)); assert.ok(f.calls.every(c => !/cancel|unbind|pre-arrange|fee-coverage/.test(c.path))) - assert.equal(proof.confirmationRequest.recordVersion, 0); assert.equal(proof.confirmationRequest.groups.length, 2) - await assert.rejects(verifyFleetFeeCoverage(f.context, 'coverage', f.source), /未执行/) +for(const index of [0,1])test(`automatic delivery reads authoritative documents for fixture ${index}`,async()=>{ + const f=fixture(),proof=await verifyFleetFeeCoverage(f.context,'automatic',f.cases[index]) + assert.equal(proof.status,'VERIFIED');assert.deepEqual(proof.deliveryNos,[`DEL-${index}`]) + assert.equal(proof.feeCoverageSelection,'NOT_APPLICABLE_AUTOMATIC_DELIVERY') + assert.equal(Object.hasOwn(proof,'newReceivable'),false) + assert.deepEqual(proof.confirmationRequest,f.trips[index].loadingCommand.body) + assert.ok(f.calls.every(c=>c.method==='GET')) + assert.equal((await verifyFleetFeeCoverage(f.context,'automatic',f.cases[index],{reconcilePersisted:true})).status,'VERIFIED') + await assert.rejects(verifyFleetFeeCoverage(f.context,'automatic',f.cases[index]),/未执行/) }) - -test('LOADING performs actual PDA picks, immutable pick replay and explicit loading completion before arranging', async () => { - const f = fixture('FULL', '', true), proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source, { pickerToken: 'OFFLINE-ONLY' }) - assert.equal(proof.status, 'VERIFIED') - const loading = f.calls.findIndex(c => c.method === 'POST' && c.path.endsWith('/loading-complete')), arrangement = f.calls.findIndex(c => c.method === 'POST' && c.path.endsWith('/transfer-delivery')) - assert.ok(loading > f.calls.findIndex(c => c.path.endsWith('/pick'))); assert.ok(arrangement > loading) +for(const fault of ['overlap','missing-docs','foreign-customer','duplicate-fee'])test(`automatic proof rejects ${fault}`,async()=>{ + const f=fixture(fault);await assert.rejects(verifyFleetFeeCoverage(f.context,'automatic',f.cases[0]));assert.notEqual(f.cases[0].proof.status,'VERIFIED') }) - -test('reviewed persisted reconciliation only replays original successful native command after actual document readback', async () => { - const f = fixture('FULL', 'wrong-cargo'); await assert.rejects(verifyFleetFeeCoverage(f.context, 'coverage', f.source)); assert.ok(f.source.proof.outboundNo) - const original = clone(f.source.proof.confirmationRequest), before = f.calls.length; f.clearFault() - assert.equal((await verifyFleetFeeCoverage(f.context, 'coverage', f.source, { reconcilePersisted: true })).status, 'VERIFIED') - assert.ok(f.calls.slice(before).filter(c => c.method === 'POST').every(c => JSON.stringify(c.body) === JSON.stringify(original))) -}) - -for (const fault of ['foreign-customer', 'missing-source', 'failed-command-writes', 'wrong-cargo', 'missing-reference', 'duplicate-new-fee', 'rewrite-confirmation', 'mutated-original', 'charge-covered-destination', 'replay-adds-fee', 'wrong-delivery-customer', 'overlap', 'unknown-write', 'snapshot-source-amount']) test(`native fee proof rejects ${fault}`, async () => { - const f = fixture(fault === 'charge-covered-destination' ? 'PARTIAL' : 'FULL', fault) - await assert.rejects(verifyFleetFeeCoverage(f.context, 'coverage', f.source)); assert.notEqual(f.source.proof.status, 'VERIFIED') - if (['foreign-customer', 'missing-source', 'overlap'].includes(fault)) assert.equal(f.calls.some(c => c.method === 'POST'), false) - if (fault === 'unknown-write') { assert.equal(f.source.proof.status, 'OUTCOME_UNKNOWN'); const last = f.calls.at(-1); assert.equal(last.method, 'POST'); assert.equal(f.context.createdResources.dispatchWriteBoundary.stopped, true) } -}) - -test('two current registered source groups are required before HTTP; missing PDA and ambiguous mixed provenance never guess', async () => { - const missing = fixture(); missing.context.createdResources.fleetFeeCoverageCases.pop(); await assert.rejects(verifyFleetFeeCoverage(missing.context, 'coverage', missing.source), /两组不重叠/); assert.equal(missing.calls.length, 0) - const noPicker = fixture('FULL', '', true); await assert.rejects(verifyFleetFeeCoverage(noPicker.context, 'coverage', noPicker.source), /PDA身份/); assert.equal(noPicker.calls.some(c => c.method === 'POST'), false) - const f = fixture('PARTIAL'), groups = f.groups(f.states[1]); groups[0].cargo.push({ ...groups[0].cargo[0], sourceInboundId: '999' }); assert.throws(() => coveragePlan({ groups }, f.source), /不能唯一映射/) - const multi = fixture('PARTIAL'), many = multi.groups(multi.states[1]), third = clone(many[1]); third.groupKey = 'GROUP-THIRD'; third.cargo[0].sourceKey = 'PLAN:999'; third.cargo[0].sourceInboundId = '999'; third.feeSources[0].financeFeeId = '9999'; third.feeSources[0].sourceKeys = ['PLAN:999']; many.push(third) - assert.throws(() => coveragePlan({ groups: many }, multi.source), /明确逐组费用/) - multi.source.transferBody.groupFees = { [many[1].groupKey]: [{ ...multi.source.transferBody.fees[0], amount: 6 }] } - assert.throws(() => coveragePlan({ groups: many }, multi.source), /明确逐组费用/) - multi.source.transferBody.groupFees[third.groupKey] = [{ ...multi.source.transferBody.fees[0], amount: 7 }] - assert.deepEqual(coveragePlan({ groups: many }, multi.source).groups.map(g => g.fees.map(f => f.amount)), [[], [6], [7]]) - const unsafe = fixture(); unsafe.source.customerId = 9007199254740992; assert.throws(() => coveragePlan({ groups: unsafe.groups(unsafe.states[0]) }, unsafe.source), /ID精度无效/) -}) - -test('public native delivery DTO retains grouped fees and requires version and loading hashes', () => { - const dto = readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryModels.java', import.meta.url), 'utf8') - assert.ok(dto.includes('record ArrangeDelivery(Long recordVersion')); assert.ok(dto.includes('String loadingCompletionHash')); assert.ok(dto.includes('List groups')) +test('requires original current-run loading completion receipt and registered disjoint fixtures',async()=>{ + const f=fixture();delete f.trips[0].loadingCommand;await assert.rejects(verifyFleetFeeCoverage(f.context,'automatic',f.cases[0])) + const missing=fixture();missing.cases.pop();await assert.rejects(verifyFleetFeeCoverage(missing.context,'automatic',missing.cases[0]));assert.equal(missing.calls.length,0) }) diff --git a/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs index b678c5302..c9b228bd6 100644 --- a/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs @@ -1,15 +1,9 @@ -import { DISPATCH_BASE as BASE, id as stringId, version, commandKey, dispatchIo, replay, readDispatch } from './grouped-dispatch-acceptance.mjs' +import { DISPATCH_BASE as BASE, id as stringId, dispatchIo, readDispatch } from './grouped-dispatch-acceptance.mjs' import { pickAll } from './phase5-overseas-fulfillment.mjs' import { confirmFleetLoading } from './fleet-trip-fulfillment.mjs' export const stableRows = rows => JSON.stringify(rows.map(row => JSON.stringify(row)).sort()) -const clone = value => structuredClone(value) const id = value => { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('服务端ID精度无效,必须保留Long字符串'); return stringId(value) } -const positive = value => /^[1-9]\d*$/.test(id(value)) -const sha = value => /^[a-f0-9]{64}$/i.test(value || '') -const cents = value => { const text = String(value); if (!/^\d+(?:\.\d{1,2})?$/.test(text)) throw Error('费用必须为明确的非负两位金额'); const [whole, fraction = ''] = text.split('.'); return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0')) } -const cargoIdentity = row => ({ dispatchItemId: id(row.dispatchItemId), warehousePalletId: id(row.warehousePalletId), orderId: id(row.orderId), sourceInboundId: id(row.sourceInboundId), packages: Number(row.packages) }) -const feeIdentity = fee => ({ financeFeeId: id(fee.financeFeeId), customerId: id(fee.customerId), currency: fee.currency, amount: String(fee.amount), sourceVersion: fee.sourceVersion, sourceHash: fee.sourceHash, status: fee.status, sourceKeys: [...fee.sourceKeys].sort() }) export function fleetConsumerIo(context, module, proof, namespace) { const io = dispatchIo(context, module, proof, namespace) @@ -27,188 +21,65 @@ export async function readyFleetDispatch(context, module, io, trip, { pickerToke io.check('真实履约写入须有正式持久化检查点', null, typeof context.checkpoint === 'function') let current = await readDispatch(io, trip.dispatchNo) io.check('本轮实车仓库及未发车范围', null, context.createdResources.fleetTrips?.includes(trip) && trip.tripNo - && current.warehouseId === id(trip.warehouseId) && current.data.status === 'ACTIVE' && Number(current.data.counts.shippedPackageCount) === 0) + && current.warehouseId === id(trip.warehouseId) && current.data.status === 'ACTIVE' && ['LOADING', 'DELIVERING'].includes(current.data.stage)) if (current.data.stage === 'LOADING') { io.check('真实扫码需本轮PDA身份', null, typeof pickerToken === 'string' && pickerToken.length > 0) io.check('PDA逐来源真实扫码完成', null, await pickAll(context, pickerToken, trip.dispatchNo)) await confirmFleetLoading(context, module, trip, pickerToken, actualMeasurements) current = await readDispatch(io, trip.dispatchNo) } - io.check('安排前已真实扫码并显式装车完成', null, current.data.stage === 'AFTER_LOADING' && current.data.pickingReconciled === true + io.check('装车完成后自动派送并保留真实拣货', null, current.data.stage === 'DELIVERING' && current.data.pickingReconciled === true && Number(current.data.counts.packageCount) > 0 && Number(current.data.counts.pickedPackageCount) === Number(current.data.counts.packageCount) && current.items.items.every(i => Number(i.pickedPackages) === Number(i.scheduledPackages))) return current } -// FeeCandidate未暴露sourceInboundId,仅在sourceKeys可唯一映射到实际原入仓时选择,混托歧义直接阻断。 -export function coveragePlan(preview, source, externalReceipt) { - if (!preview.groups?.length) throw Error('缺少当前全车派送分组') - const firstleg = preview.groups.flatMap(g => g.cargo.filter(c => c.sourceRegion === 'FIRSTLEG')) - const inboundIds = [...new Set(firstleg.map(c => id(c.sourceInboundId)))].sort() - if (!inboundIds.length || inboundIds.some(v => !positive(v)) || source.mode === 'PARTIAL' && inboundIds.length < 2) throw Error('FULL/PARTIAL需要本轮真实原入仓,PARTIAL至少两票') - const selectedInbounds = new Set(source.mode === 'FULL' ? inboundIds : [inboundIds[0]]) - const originalFees = [], selections = new Map(), uncovered = [] - for (const group of preview.groups) { - if (!group.groupKey || !positive(group.customerId) || !group.destination?.key || !sha(group.groupCargoHash) || !sha(group.feeCoverageCargoHash) - || !sha(group.externalFreightHash) || !Array.isArray(group.blockReasons) || group.blockReasons.length || !Array.isArray(group.feeSources) || !Array.isArray(group.externalQuotes) - || !group.cargo.length || group.cargo.some(c => id(c.customerId) !== id(group.customerId) || c.destination?.key !== group.destination.key)) throw Error('客户目的地或费用预览范围不完整') - const own = group.cargo.filter(c => c.sourceRegion === 'FIRSTLEG') - if (own.some(c => id(c.customerId) !== id(source.customerId))) throw Error('原费用跨客户,不能沿其他客户确认覆盖') - if (group.externalQuotes.some(q => !externalReceipt || q.bookingNo !== externalReceipt.bookingNo || q.inboundCode !== externalReceipt.incomingNo - || id(q.customerId) !== id(group.customerId) || q.groupKey !== group.groupKey || q.sourceHash !== group.externalFreightHash - || q.currency !== externalReceipt.finalBooking.cargo.currency || cents(q.amount) !== cents(externalReceipt.finalBooking.agreedAmount))) throw Error('当前外接报价与本轮运营确认金额或来源摘要不一致') - for (const cargo of group.cargo.filter(c => c.sourceRegion !== 'FIRSTLEG')) { - if (!externalReceipt || cargo.inboundCode !== externalReceipt.incomingNo || !externalReceipt.createRequest - || id(cargo.customerId) !== id(externalReceipt.createRequest.customerId) - || !group.externalQuotes.some(q => q.bookingNo === externalReceipt.bookingNo && q.inboundCode === cargo.inboundCode && id(q.customerId) === id(group.customerId))) throw Error('非头程货物没有本轮已确认外接报价') - } - const byInbound = new Map() - for (const fee of group.feeSources) { - if (id(fee.customerId) !== id(group.customerId) || !positive(fee.financeFeeId) || fee.currency !== 'CNY' || !['AVAILABLE', 'SETTLED'].includes(fee.status) - || !Number.isSafeInteger(fee.sourceVersion) || fee.sourceVersion < 1 || !fee.sourceHash || !Array.isArray(fee.sourceKeys) || !fee.sourceKeys.length - || !fee.services?.includes('DELIVERY_FEE')) throw Error('原费用客户、版本或来源不可用') - cents(fee.amount) - const actual = own.filter(c => fee.sourceKeys.includes(c.sourceKey)), ids = new Set(actual.map(c => id(c.sourceInboundId))) - if (ids.size !== 1 || fee.sourceKeys.some(k => !actual.some(c => c.sourceKey === k))) throw Error('原费用sourceKeys不能唯一映射原入仓;需生产预览提供原费用sourceInboundId或无混托真实来源') - const inbound = [...ids][0] - if (!byInbound.has(inbound)) byInbound.set(inbound, fee) - originalFees.push({ groupKey: group.groupKey, ...feeIdentity(fee) }) - } - if (own.some(c => !byInbound.has(id(c.sourceInboundId)))) throw Error('本轮每票必须有实际原费用,不能借其他票补齐') - selections.set(group.groupKey, [...byInbound].filter(([inbound]) => selectedInbounds.has(inbound)).map(([, fee]) => ({ financeFeeId: id(fee.financeFeeId), sourceVersion: fee.sourceVersion, sourceHash: fee.sourceHash, services: ['DELIVERY_FEE'] }))) - if (own.some(c => !selectedInbounds.has(id(c.sourceInboundId)))) uncovered.push(group.groupKey) +/** 装车完成已生成单据并发车;仅读权威单据,不补人工费用或覆盖选择。 */ +export async function readAutomaticDelivery(io, current, trip) { + const documents = current.data.documents + const deliveries = documents?.deliveries, outbounds = documents?.outbounds + io.check('自动派送单据完整且客户箱数守恒', null, current.data.stage === 'DELIVERING' + && Array.isArray(deliveries) && deliveries.length > 0 && Array.isArray(outbounds) && outbounds.length > 0 + && new Set(deliveries.map(d => d.deliveryNo)).size === deliveries.length + && new Set(outbounds.map(o => o.outboundNo)).size === outbounds.length + && deliveries.every(d => d.deliveryNo && d.status === 'DELIVERING' && outbounds.some(o => o.outboundNo === d.outboundNo && id(o.customerId) === id(d.customerId))) + && outbounds.every(o => o.outboundNo && o.status === 'OUTBOUNDED') + && deliveries.reduce((sum, d) => sum + Number(d.packageCount), 0) === Number(current.data.counts.packageCount) + && current.items.items.every(i => deliveries.some(d => id(d.customerId) === id(i.customerId) && d.destination?.key === i.destination?.key))) + const snapshots = {}, feeRows = {} + for (const ref of deliveries) { + const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '回查自动生成派送的客户、目的地和费用事实') + io.check('自动生成单据归属和货量正确', null, detail?.order?.dispatchNo === trip.dispatchNo && detail.order.deliveryNo === ref.deliveryNo + && id(detail.order.customerId) === id(ref.customerId) && detail.order.outboundNo === ref.outboundNo + && Number(detail.order.packageCount) === Number(ref.packageCount) && detail.order.status === 'DELIVERING' + && Array.isArray(detail.fees) && new Set(detail.fees.map(f => id(f.id))).size === detail.fees.length) + snapshots[ref.deliveryNo] = detail.feeCoverage; feeRows[ref.deliveryNo] = stableRows(detail.fees) } - const template = source.transferBody - if (template?.loadingType !== 'PALLET' || template.appointmentAccount !== 'SELF' || template.fees?.length !== 1 - || template.fees[0].direction !== 'RECEIVABLE' || template.fees[0].feeType !== 'DELIVERY_FEE' || cents(template.fees[0].amount) <= 0n) throw Error('覆盖用例须有明确的未覆盖派送应收金额') - if (uncovered.length > 1 && uncovered.some(key => !Array.isArray(template.groupFees?.[key]) || template.groupFees[key].length !== 1)) throw Error('多个未覆盖目的地须由本轮用例明确逐组费用,不能自动分摊') - const groups = preview.groups.map(group => { - const fees = uncovered.includes(group.groupKey) ? clone(template.groupFees?.[group.groupKey] || template.fees) : [] - if (uncovered.includes(group.groupKey) && fees.length !== 1 || fees.some(f => f.direction !== 'RECEIVABLE' || f.feeType !== 'DELIVERY_FEE' || cents(f.amount) <= 0n || !['CNY', 'USD'].includes(f.currency) || f.currency !== template.fees[0].currency)) throw Error('逐组未覆盖应收不完整') - return { groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, plannedDeliveryAt: template.plannedDeliveryAt || new Date(Date.now() + 21600000).toISOString(), - deliveryMethod: template.deliveryMethod || 'TRUCK_PALLET', waybillNo: template.waybillNo || null, carrierName: template.carrierName || null, - pickupMethod: template.pickupMethod || 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: template.vehicleType || 'TRUCK', vehicleSize: template.vehicleSize || null, - appointmentAccount: 'SELF', sellerName: null, remark: template.remark || null, externalFreightHash: group.externalFreightHash, - feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: selections.get(group.groupKey), fees } - }) - return { groups, originalFees, selectedInbounds: [...selectedInbounds], inboundIds } + const command = trip.loadingCommand + io.check('本轮装车成功回执完整', null, command?.body?.idempotencyKey && command.result?.action === 'LOADING_COMPLETE' + && command.result.dispatchNo === trip.dispatchNo && command.result.stage === 'DELIVERING') + return { outbounds, deliveries, snapshots, feeRows, confirmationRequest: command.body, command: command.result } } -async function noDocuments(io, source, label) { - let total, rows = [] - for (let page = 1; ; page++) { - const data = await io.get(`/api/platform/v1/last-leg/outbound-orders?${new URLSearchParams({ customerId: id(source.customerId), size: '200', page: String(page) })}`, label) - io.check('客户出库分页完整稳定', null, Array.isArray(data?.records) && Number.isSafeInteger(Number(data.total)) && Number(data.page) === page && Number(data.size) === 200 - && Number(data.total) >= 0 && (total === undefined || total === Number(data.total)) && (data.records.length > 0 || rows.length === Number(data.total))) - total ??= Number(data.total); rows.push(...data.records); if (rows.length >= total) break - } - io.check('拒绝后无本排车出库写入', null, rows.length === total && new Set(rows.map(r => r.outboundNo)).size === rows.length && rows.every(r => r.dispatchNo !== source.dispatchNo)) -} - -/** 两个本轮不重叠来源用例必须先准备;从不取消已扫码FULL再复用同批实物。 */ export async function verifyFleetFeeCoverage(context, module, source, { reconcilePersisted = false, pickerToken, actualMeasurements, externalTrip } = {}) { const cases = context.createdResources?.fleetFeeCoverageCases - if (!cases?.includes(source) || source.marker !== context.marker || !['FULL', 'PARTIAL'].includes(source.mode) || source.proof && !reconcilePersisted) throw Error('原费用验收需要本轮专用、未执行的排车用例;保留原请求') + if (!cases?.includes(source) || source.marker !== context.marker || !['FULL', 'PARTIAL'].includes(source.mode) || source.proof && !reconcilePersisted) + throw Error('验收需要本轮专用、未执行排车用例;保留原请求') const pair = ['FULL', 'PARTIAL'].map(mode => cases.filter(c => c.marker === context.marker && c.mode === mode)) - if (pair.some(rows => rows.length !== 1) || pair[0][0].dispatchNo === pair[1][0].dispatchNo) throw Error('需本轮生产者预先准备FULL/PARTIAL两组不重叠真实排车,不能取消已扫码FULL复用同批实物') - if (reconcilePersisted && (!source.proof?.confirmationRequest || !source.proof?.outboundNo || !source.proof?.deliveryNos?.length)) throw Error('续验须有原成功命令和已持久化单据,不可重造') + if (pair.some(rows => rows.length !== 1) || pair[0][0].dispatchNo === pair[1][0].dispatchNo) throw Error('需本轮两组不重叠真实排车') const proof = reconcilePersisted ? source.proof : source.proof = { marker: context.marker, status: 'CHECKING', mode: source.mode, dispatchNo: source.dispatchNo, requests: [] } - const io = fleetConsumerIo(context, module, proof, `FEE-${source.mode}`), path = `${BASE}/${encodeURIComponent(source.dispatchNo)}` + const io = fleetConsumerIo(context, module, proof, `AUTO-${source.mode}`) try { const scopes = [] for (const [candidate] of pair) scopes.push(await readDispatch(io, candidate.dispatchNo)) io.check('两组真实来源和实托均不重叠', null, scopes.every(s => s.items.items.length > 0) && scopes[0].items.items.every(i => !scopes[1].items.items.some(j => id(i.warehousePalletId) === id(j.warehousePalletId) || i.sourceKey === j.sourceKey))) const trip = context.createdResources.fleetTrips?.find(t => t.dispatchNo === source.dispatchNo) - io.check('本排车有本轮正式车辆审核生产回执', null, Boolean(trip)) - const loaded = await readyFleetDispatch(context, module, io, trip, { pickerToken, actualMeasurements }) - const preview = await io.get(`${path}/delivery-arrangement`, '读取原生全车费用预览') - io.check('原生派送门禁、版本及实载证据完整', null, preview?.dispatchNo === source.dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'AFTER_LOADING' - && id(version(preview.recordVersion)) === id(version(loaded.data.recordVersion)) && sha(preview.cargoHash) && sha(preview.loadingCompletionHash) - && (reconcilePersisted ? Boolean(preview.arrangement) : preview.arrange?.allowed === true && !preview.arrangement)) - const receipt = externalTrip?.externalReceipt - if (externalTrip && (!context.createdResources.fleetTrips.includes(externalTrip) || externalTrip.dispatchNo !== source.dispatchNo || receipt?.status !== 'VERIFIED')) throw Error('外接费用只接受本轮同主单真实运营确认') - const plan = coveragePlan(preview, source, receipt) - const totals = new Map() - for (const c of preview.groups.flatMap(g => g.cargo)) totals.set(id(c.dispatchItemId), (totals.get(id(c.dispatchItemId)) || 0) + Number(c.packages)) - io.check('费用分组逐实物客户与箱数核平', null, totals.size === loaded.items.items.length && loaded.items.items.every(i => totals.get(id(i.dispatchItemId)) === Number(i.scheduledPackages) - && preview.groups.filter(g => g.cargo.some(c => id(c.dispatchItemId) === id(i.dispatchItemId))).every(g => id(g.customerId) === id(i.customerId) && g.destination.key === i.destination.key))) - const body = reconcilePersisted ? proof.confirmationRequest : { recordVersion: version(preview.recordVersion), idempotencyKey: commandKey(context.marker, `FEE-${source.mode}-${source.dispatchNo}`, 'arrange'), - cargoHash: preview.cargoHash, loadingCompletionHash: preview.loadingCompletionHash, groups: plan.groups } - if (!reconcilePersisted) { proof.confirmationRequest = clone(body); proof.originalFees = clone(plan.originalFees); await context.checkpoint?.() } - else io.check('原成功请求仍为同货物和实载', null, body.cargoHash === preview.cargoHash && body.loadingCompletionHash === preview.loadingCompletionHash) - const reject = async (label, changed, pattern) => { - const before = await readDispatch(io, source.dispatchNo) - await io.request('POST', `${path}/transfer-delivery`, { ...clone(body), ...changed, idempotencyKey: commandKey(context.marker, `${source.dispatchNo}-${source.mode}`, label) }, label, pattern) - for (const customerId of new Set(preview.groups.map(g => id(g.customerId)))) await noDocuments(io, { ...source, customerId }, `${label}后无单据`) - io.check(`${label}后整车及实物零漂移`, null, JSON.stringify(await readDispatch(io, source.dispatchNo)) === JSON.stringify(before)) - } - if (!reconcilePersisted) { - for (const customerId of new Set(preview.groups.map(g => id(g.customerId)))) await noDocuments(io, { ...source, customerId }, '执行前确认无出库单') - await reject('陈旧全车货物摘要拒绝', { cargoHash: `${body.cargoHash[0] === '0' ? '1' : '0'}${body.cargoHash.slice(1)}` }, /货物.*变化/) - const stale = clone(body.groups), selected = stale.find(g => g.feeCoverage.length).feeCoverage[0]; selected.sourceVersion = selected.sourceVersion === 2147483647 ? selected.sourceVersion - 1 : selected.sourceVersion + 1 - await reject('陈旧原费用版本拒绝', { groups: stale }, /原费用版本.*变化/) - const wrong = clone(body.groups) - if (source.mode === 'FULL') wrong[0].fees = clone(source.transferBody.fees) - else wrong.find(g => g.fees.length).fees = [] - await reject(source.mode === 'FULL' ? '全覆盖重复应收拒绝' : '部分覆盖漏填应收拒绝', { groups: wrong }, source.mode === 'FULL' ? /覆盖.*重复|覆盖.*登记/ : /未覆盖.*费用/) - if (externalTrip) { - const extra = clone(body.groups), quoted = preview.groups.find(g => g.externalQuotes.length), input = extra.find(g => g.groupKey === quoted.groupKey), quote = quoted.externalQuotes[0] - input.fees.push({ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '本轮重复外接派送应收反例', currency: quote.currency, amount: quote.amount }) - await reject('外接已有约定运费禁止追加重复应收', { groups: extra }, /重复|覆盖.*登记/) - } - } else { - const stored = await io.get(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(proof.outboundNo)}`, '先核对原成功出库和派送集合') - io.check('原单据完整且未执行', null, stored?.outbound?.dispatchNo === source.dispatchNo && stored.outbound.status === 'WAIT_OUTBOUND' - && stableRows((stored.deliveries || []).map(d => d.deliveryNo)) === stableRows(proof.deliveryNos) && stored.deliveries.every(d => d.status === 'WAIT_DELIVERY')) - } - const result = await io.request('POST', `${path}/transfer-delivery`, body, '按全车真实分组确认原费用并安排派送'), arranged = result?.result - io.check('原生命令返回完整客户出库和客户目的地派送', null, result?.dispatchNo === source.dispatchNo && result.action === 'ARRANGE_DELIVERY' && result.status === 'ACTIVE' && result.stage === 'AFTER_LOADING' - && BigInt(version(result.recordVersion)) > BigInt(version(body.recordVersion)) - && arranged?.dispatchNo === source.dispatchNo && arranged.cargoHash === body.cargoHash && arranged.deliveries?.length === preview.groups.length - && new Set(arranged.deliveries.map(d => d.deliveryNo)).size === arranged.deliveries.length - && arranged.outbounds?.length === new Set(preview.groups.map(g => id(g.customerId))).size - && arranged.outbounds.every(o => o.outboundNo && o.status === 'WAIT_OUTBOUND' && arranged.deliveries.filter(d => d.outboundNo === o.outboundNo).reduce((n, d) => n + Number(d.packageCount), 0) === Number(o.packageCount)) && arranged.deliveries.every(d => d.deliveryNo && d.status === 'WAIT_DELIVERY' - && preview.groups.some(g => g.groupKey === d.groupKey && id(g.customerId) === id(d.customerId) && g.destination.key === d.destination?.key - && g.cargo.reduce((n, c) => n + Number(c.packages), 0) === Number(d.packageCount)) - && arranged.outbounds.some(o => o.outboundNo === d.outboundNo && id(o.customerId) === id(d.customerId)))) - if (reconcilePersisted) io.check('仅恢复原出库和派送集合', null, result.replayed === true && stableRows(arranged.deliveries.map(d => d.deliveryNo)) === stableRows(proof.deliveryNos)) - proof.outbounds = arranged.outbounds; proof.deliveries = arranged.deliveries; proof.command = result - proof.outboundNo = arranged.outbounds.find(o => id(o.customerId) === id(source.customerId))?.outboundNo - proof.deliveryNos = arranged.deliveries.map(d => d.deliveryNo).sort() - const snapshots = {}, feeRows = {} - let added = 0n - for (const ref of arranged.deliveries) { - const group = preview.groups.find(g => g.groupKey === ref.groupKey), input = body.groups.find(g => g.groupKey === ref.groupKey) - const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '回查当前客户目的地费用和原费快照') - io.check('派送只归当前客户和目的地', null, detail?.order?.dispatchNo === source.dispatchNo && detail.order.deliveryNo === ref.deliveryNo - && id(detail.order.customerId) === id(group.customerId) && detail.order.outboundNo === ref.outboundNo - && id(detail.order.destinationWarehouseCode || detail.order.destinationWarehouseId) === id(group.destination.warehouseCode || group.destination.warehouseId)) - const evidence = detail.feeCoverage, expectedCargo = group.cargo.map(cargoIdentity) - if (input.feeCoverage.length) { - io.check('原费快照保留真实货物、确认人、时间和每票引用', null, evidence?.cargoHash === group.feeCoverageCargoHash && evidence.operatorId && Number.isFinite(Date.parse(evidence.confirmedAt)) - && stableRows(evidence.cargo.map(cargoIdentity)) === stableRows(expectedCargo) && Array.isArray(evidence.references) - && stableRows(evidence.references.map(r => ({ financeFeeId: id(r.source?.financeFeeId), sourceVersion: r.source?.sourceVersion, sourceHash: r.source?.sourceHash, services: r.services }))) === stableRows(input.feeCoverage) - && evidence.references.every(r => id(r.source.customerId) === id(group.customerId) && ['INBOUND_FEE', 'CONTAINER_FEE'].includes(r.source.sourceType) - && group.cargo.some(c => id(c.sourceInboundId) === id(r.source.sourceInboundId)) && group.feeSources.some(f => id(f.financeFeeId) === id(r.source.financeFeeId) - && f.currency === r.source.currency && cents(f.amount) === cents(r.source.amount) && f.status === r.source.status))) - } else io.check('未选原费不伪造覆盖证据', null, evidence == null || evidence.references?.length === 0) - io.check('费用为本客户明确正数应收列表', null, Array.isArray(detail.fees) && detail.fees.every(f => f.feeDirection && f.feeType)) - const receivables = detail.fees.filter(f => f.feeDirection === 'RECEIVABLE' && f.feeType === 'DELIVERY_FEE' && f.sourceStatus !== 'VOIDED') - const expectedAmounts = [...input.fees.map(f => `${f.currency}:${cents(f.amount)}`), ...group.externalQuotes.filter(q => cents(q.amount) > 0n).map(q => `${q.currency}:${cents(q.amount)}`)].sort() - io.check('仅未覆盖货物和原外接报价产生应收,禁止重复或零值', null, stableRows(receivables.map(f => `${f.currency}:${cents(f.amount)}`).sort()) === stableRows(expectedAmounts) - && receivables.every(f => cents(f.amount) > 0n && f.sourceStatus === 'ACTIVE' && f.reviewStatus === 'APPROVED')) - added += input.fees.reduce((n, f) => n + cents(f.amount), 0n) - snapshots[ref.deliveryNo] = detail.feeCoverage; feeRows[ref.deliveryNo] = stableRows(detail.fees) - } - const replayed = await replay(io, `${path}/transfer-delivery`, body, result, '费用确认原键原载荷重放') - io.check('费用安排重放明确恢复原成功命令', null, replayed.replayed === true) - for (const ref of arranged.deliveries) { - const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '原键重放后回查每客户费用') - io.check('重放不改确认快照也不新增应收', null, JSON.stringify(detail.feeCoverage) === JSON.stringify(snapshots[ref.deliveryNo]) && stableRows(detail.fees) === feeRows[ref.deliveryNo]) - } - const after = await io.get(`${path}/delivery-arrangement`, '回查原费用来源未被确认改变') - const currentFees = after.groups.flatMap(g => g.feeSources.map(f => ({ groupKey: g.groupKey, ...feeIdentity(f) }))) - io.check('原费用版本金额状态保持不变', null, stableRows(currentFees) === stableRows(proof.originalFees)) - proof.snapshots = snapshots; proof.newReceivable = Number(added) / 100; proof.status = 'VERIFIED'; await context.checkpoint?.(); return proof + io.check('本排车有本轮生产回执', null, Boolean(trip) && (!externalTrip || externalTrip === trip)) + const current = await readyFleetDispatch(context, module, io, trip, { pickerToken, actualMeasurements }) + const execution = await readAutomaticDelivery(io, current, trip) + if (reconcilePersisted) io.check('恢复仅读原生成单据集合', null, stableRows(proof.deliveryNos) === stableRows(execution.deliveries.map(d => d.deliveryNo))) + Object.assign(proof, execution, { outboundNo: execution.outbounds.find(o => id(o.customerId) === id(source.customerId))?.outboundNo, + deliveryNos: execution.deliveries.map(d => d.deliveryNo).sort(), feeCoverageSelection: 'NOT_APPLICABLE_AUTOMATIC_DELIVERY', status: 'VERIFIED' }) + await context.checkpoint?.(); return proof } catch (error) { proof.status = proof.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message; await context.checkpoint?.(); throw error } } diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs index 4ee2b7d9e..ec0f40de8 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs @@ -31,6 +31,7 @@ function fixture(fault = '') { const nativeItem = () => ({ dispatchItemId: '501', customerId: CUSTOMER, sourceKey: 'RETURN:300', sourceVersion: 'a'.repeat(64), sourceType: 'RETURN_RECEIPT', planPalletId: null, returnReceiptId: '300', warehousePalletId: '100', palletNo: context.palletCode, inboundCode: 'IN-A', destination, scheduledPackages: 2, pickedPackages: picked ? 2 : 0, positions: [{ positionId: '201', availablePackages: picked ? 0 : 2 }] }) const detail = next => ({ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW', recordVersion: next ? 0 : recordVersion, status: next && nextCancelled ? 'CANCELLED' : 'ACTIVE', stage: next ? 'BEFORE_LOADING' : newSigned ? 'DELIVERED' : stage, + documents: { deliveries: departed && !next ? [ref()] : [], outbounds: departed && !next ? [outbound()] : [] }, reviewState: review ? 'APPROVED' : 'DRAFT', reviews: { dispatchReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' }, loadingReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' } }, counts: counts(next), pickingReconciled: picked, vehicleInfo: { vehicle: { vehicleId: '601' }, driver: { driverId: '701' } }, dispatchInfo: { route: next ? '' : 'ONT8' }, orderDetails: next ? [] : [{ customerId: CUSTOMER, sourceKey: 'RETURN:300', inboundCode: 'IN-A', destinationFbaWarehouse: 'ONT8' }] }) const ref = () => ({ groupKey: 'GROUP-A', customerId: CUSTOMER, destination, outboundNo: 'OUT-NEW', deliveryNo: 'DL-NEW', status: departed ? 'DELIVERING' : 'WAIT_DELIVERY', packageCount: 2 }) @@ -41,7 +42,8 @@ function fixture(fault = '') { const stockLedger = () => returned ? [{ id: '901', customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE, inboundCode: 'IN-A', palletNo: context.palletCode, businessType: 'RETURN_STOCK_IN', businessOrderNo: 'EX-NEW', outboundOrderNo: 'OUT-ORIGINAL', deltaPackages: 2 }] : [] const envelope = (data, code = '200', message = '') => ({ http: 200, body: { code, message, data: copy(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' }) const context = { marker: 'FRESH', createdResources: { fleetTrips: [trip], fleetAcceptance: { marker: 'FRESH', customerId: CUSTOMER, warehouseId: WAREHOUSE, tripNo: trip.tripNo, - departure: { dispatchNo: trip.dispatchNo, action: 'DEPART', status: 'ACTIVE', stage: 'DELIVERING', result: { departedAt: '2026-10-19T01:00:00Z', deliveries: [{ customerId: CUSTOMER, deliveryNo: 'DL-ORIGINAL' }] } } } }, + departureDeliveries: [{ customerId: CUSTOMER, deliveryNo: 'DL-ORIGINAL' }], + departure: { dispatchNo: trip.dispatchNo, action: 'LOADING_COMPLETE', status: 'ACTIVE', stage: 'DELIVERING', result: { confirmedLoad: { manifestHash: 'ORIGINAL' } } } } }, assert(_m, label, _r, passed) { if (!passed) throw Error(label) }, addStep(_m, label, _r, passed) { if (!passed) throw Error(label) }, addCoverageGap() {}, apiSuccess(_m, _l, r) { return r.body?.data }, async checkpoint() { checkpoints.push(copy(trip.returnRedispatch)) }, async requestMultipart(method, path, file) { @@ -52,6 +54,7 @@ function fixture(fault = '') { async request(method, raw, body, token) { requests.push({ method, path: raw, body: copy(body), token }) const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), normalized = path.replace(PDA, DISPATCH), ledgerKey = `${method}:${normalized}:${body?.idempotencyKey}` + if (normalized.endsWith('/loading-complete') && !token && ledger.has(ledgerKey)) return envelope(null, 'A0403', '该请求标识不属于当前操作员') if (body?.idempotencyKey && ledger.has(ledgerKey) && !path.endsWith('/resources/release')) { const prior = ledger.get(ledgerKey); assert.deepEqual(body, prior.body); return envelope({ ...copy(prior.data), replayed: true }) } const remember = data => { if (body?.idempotencyKey) ledger.set(ledgerKey, { body: copy(body), data: copy(data) }); return envelope(data) } if (path === '/api/platform/inbounds') { const name = params.get('inboundCode').slice(-1), delivered = name === 'A' ? newSigned || fault === 'domestic-premature-A' && originalSigned : originalSigned && fault !== 'domestic-blocked-B'; return envelope({ records: [{ inboundCode: `IN-${name}`, processStatus: delivered ? 'DELIVERED' : 'LOADED' }] }) } @@ -83,7 +86,7 @@ function fixture(fault = '') { sourceKey: 'RETURN:300', sourceVersion: 'a'.repeat(64), customerId: CUSTOMER, warehouseId: WAREHOUSE, orderNo: 'LM-A', inboundCode: 'IN-A', palletNo: context.palletCode, destination, availablePackages: fault === 'wrong-candidate' ? 4 : 2, appointmentBatches: [], blockReasons: [] }] }) } if (path === DISPATCH && method === 'POST') { if (!body.cargo.length) { nextCreated = true; vehicleOwner = 'TR-NEXT'; driverOwner = 'TR-NEXT'; return remember(command('CREATE', {}, true)) }; assert.equal(body.cargo[0].sourceType, 'RETURN_RECEIPT'); assert.equal(body.cargo[0].planPalletId, null); created = true; return remember(command('CREATE')) } if (path === DISPATCH) return envelope({ records: [{ ...detail(params.get('dispatchNo') === 'DP-NEXT'), warehouseId: WAREHOUSE }] }) - if (path.endsWith('/master-data/vehicles')) { vehiclePlate = body.plateNumber; return envelope({ id: '601' }) } + if (path.endsWith('/master-data/vehicles')) { vehiclePlate = body.plateNumber.toUpperCase(); return envelope({ id: '601' }) } if (path.endsWith('/fleet/drivers') && method === 'POST') return envelope({ id: '701', recordVersion: 0 }) if (path.endsWith('/fleet/drivers/701/review')) return envelope({ reviewStatus: 'APPROVED' }) if (path === `${BASE}/fleet/trips`) { const trips = nextCreated ? [tripDetail(false).trip, tripDetail(true).trip] : [tripDetail(false).trip]; return envelope({ records: trips, page: 1, size: 200, total: trips.length }) } @@ -121,9 +124,7 @@ function fixture(fault = '') { assert.equal(body.recordVersion, recordVersion, raw) if (/\/(submit-review|dispatch-review|loading-review)$/.test(normalized)) { if (normalized.endsWith('/loading-review')) { review = true; stage = 'LOADING' }; recordVersion++; return remember(command('REVIEW')) } if (normalized.endsWith('/pick')) { assert.equal(token, 'private-pda-token'); picked = true; recordVersion++; return remember(command('PICK', { packages: 2 })) } - if (normalized.endsWith('/loading-complete')) { assert.equal(picked, true); stage = 'AFTER_LOADING'; recordVersion++; return remember(command('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } - if (normalized.endsWith('/transfer-delivery')) { assert.equal(stage, 'AFTER_LOADING'); arranged = true; recordVersion++; return remember(command('ARRANGE_DELIVERY', { dispatchNo: 'DP-NEW', cargoHash: body.cargoHash, outbounds: [outbound()], deliveries: [ref()] })) } - if (normalized.endsWith('/depart')) { assert.equal(arranged, true); departed = true; stage = 'DELIVERING'; recordVersion++; return remember(command('DEPART', { departedAt: '2026-10-19T03:00:00Z', shippedPackages: 2, outbounds: [outbound()], deliveries: [ref()] })) } + if (normalized.endsWith('/loading-complete')) { assert.equal(picked, true); stage = 'DELIVERING'; arranged = true; departed = true; recordVersion++; return remember(command('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } throw Error(`Unexpected ${method} ${raw}`) } } const dependencies = { wait: async () => {}, async onBeforeRedispatchSignature(proof) { assert.equal(newSigned, false); assert.equal(originalSigned, true); assert.equal(proof.preSignatureGate.status, 'WAITING'); if (fault === 'audit-gate') throw Error('Audit gate not approved'); return { sourceAEventCount: 0, sourceBEventCount: 1, auditEvidenceSha256: 'a'.repeat(64) } } } @@ -136,10 +137,11 @@ test('native one-main multi-source return redispatch executes real APIs through const f = fixture(), result = await run(f) assert.equal(result.status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED'); assert.equal(result.sourceEventsVerified, false); assert.equal(result.readOnlyEvidenceRequired.length, 3) assert.equal(result.beforeRedispatch.A.order.subOrderStatus, 'DELIVERY:WAIT_DELIVERY'); assert.equal(result.beforeRedispatch.B.order.subOrderStatus, 'DELIVERY:DELIVERED') - assert.equal(result.afterRedispatch.A.order.subOrderStatus, 'DELIVERY:DELIVERED'); assert.equal(result.afterRedispatch.departed.action, 'DEPART'); assert.equal(result.afterRedispatch.departed.result.shippedPackages, 2) + assert.equal(result.afterRedispatch.A.order.subOrderStatus, 'DELIVERY:DELIVERED'); assert.equal(result.afterRedispatch.departed.action, 'LOADING_COMPLETE'); assert.equal(result.afterRedispatch.departed.counts.shippedPackageCount, 2) assert.equal(result.inventory.map(s => s.ledger.length).every((n, i) => n === (i === 0 ? 0 : 1)), true) assert.ok(!JSON.stringify(f.checkpoints).includes('private-pda-token')); assert.ok(f.checkpoints.some(p => p.requests.at(-1)?.status === 'SENDING' && p.requests.at(-1)?.file?.sha256)) - assert.ok(f.requests.findIndex(r => r.path.endsWith('/loading-complete')) < f.requests.findIndex(r => r.path.endsWith('/transfer-delivery'))) + assert.ok(f.requests.some(r => r.path.endsWith('/loading-complete'))) + assert.equal(f.requests.some(r => /\/(transfer-delivery|depart)$/.test(r.path)), false) assert.equal(f.requests.some(r => r.path.includes('/pre-arrange')), false) }) diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs index 953e9a8d3..7305f827a 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs @@ -3,7 +3,7 @@ import {join,resolve} from 'node:path' import {redactEvidence} from '../fba-core-regression-test-env.mjs' import { randomUUID, createHash } from 'node:crypto' import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs' -import { pickAll, saveReceiptAndComplete, arrangeDispatchDelivery } from './phase5-overseas-fulfillment.mjs' +import { pickAll, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs' import { version, createBody, candidatePage, readDispatch, replay as replayCommand } from './grouped-dispatch-acceptance.mjs' const BASE = '/api/platform/v1/last-leg' @@ -34,17 +34,17 @@ export function selectReturnSources(rows) { return { a, b } } -/** Owned departure only; reviewed source-selection recovery preserves prior starts. Requests are checkpointed before dispatch. */ +/** 只消费本轮自动发运事实;来源恢复保留已启动派送,每次请求发送前持久记录。 */ export async function verifyFleetReturnRedispatch(context, module, trip, input, dependencies = {}) { const run = context.createdResources?.fleetAcceptance if (Object.keys(dependencies).some(name => !['wait', 'onBeforeRedispatchSignature'].includes(name))) throw Error('Only readonly audit and wait observers may be supplied; native production, picking and signature cannot be replaced') const departure = run?.departure if (!context.marker || !context.createdResources?.fleetTrips?.includes(trip) || run?.marker !== context.marker - || run.tripNo !== trip.tripNo || departure?.dispatchNo !== trip.dispatchNo || departure.status !== 'ACTIVE' || departure.stage !== 'DELIVERING' || departure.action !== 'DEPART' - || !departure.result?.departedAt || !input?.pickerToken + || run.tripNo !== trip.tripNo || departure?.dispatchNo !== trip.dispatchNo || departure.status !== 'ACTIVE' || departure.stage !== 'DELIVERING' || departure.action !== 'LOADING_COMPLETE' + || !departure.result?.confirmedLoad || !input?.pickerToken || id(run.customerId) !== id(input.customerId) || id(run.warehouseId) !== id(input.warehouseId) || JSON.stringify([departure.dispatchNo]) !== JSON.stringify([...(input.dispatchNos || [])].sort()) - || JSON.stringify((departure.result.deliveries || []).filter(row => id(row.customerId) === id(input.customerId)).map(row => row.deliveryNo).sort()) !== JSON.stringify([...(input.deliveryNos || [])].sort()) + || JSON.stringify((run.departureDeliveries || []).filter(row => id(row.customerId) === id(input.customerId)).map(row => row.deliveryNo).sort()) !== JSON.stringify([...(input.deliveryNos || [])].sort()) || !input.deliveryNos?.length || trip.returnRedispatch || typeof context.checkpoint !== 'function') throw Error('Redispatch requires a new owned departure, current PDA session and durable checkpoints; no automatic restart') const proof = trip.returnRedispatch = { marker: context.marker, status: 'CHECKING', requests: [], returnPackages: 2, @@ -271,20 +271,19 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, const picked = await inventory('RETURN_PICKED') check('Actual returned stock is picked once without rewriting the stock-in ledger', picked.available === 0 && picked.picked === 2 && JSON.stringify(picked.ledger) === JSON.stringify(stockIn.ledger)) await confirmFleetLoading(io, module, newTrip, input.pickerToken, input.actualMeasurements) - const transfer = await arrangeDispatchDelivery(io, arranged.dispatchNo) - check('Redispatch produces a new single destination delivery', transfer?.deliveries?.length === 1 && !input.deliveryNos.includes(transfer.deliveries[0].deliveryNo) - && id(transfer.deliveries[0].customerId) === customerId && Number(transfer.deliveries[0].packageCount) === 2) + // 装车完成已原子生成派送并发运,只回查单据,不重复发送退役安排和发车命令。 const current = await request('GET', `${BASE}/dispatches/${encodeURIComponent(arranged.dispatchNo)}`) - check('New whole is loaded and fully arranged before actual departure', current.status === 'ACTIVE' && current.stage === 'AFTER_LOADING' && Number(current.counts.pickedPackageCount) === 2 && Number(current.counts.shippedPackageCount) === 0) - const departBody = { recordVersion: version(current.recordVersion), idempotencyKey: key(), remark: '退回2箱重新实际发车' } - const departPath = `/api/pda/v1/last-leg/dispatches/${arranged.dispatchNo}/depart` - const departed = await request('POST', departPath, departBody, input.pickerToken) - check('New DEPART ships exactly two returned boxes', departed.dispatchNo === arranged.dispatchNo && departed.action === 'DEPART' && departed.status === 'ACTIVE' - && departed.stage === 'DELIVERING' && departed.result?.departedAt && Number(departed.result.shippedPackages) === 2 - && departed.result.deliveries?.length === 1 && departed.result.deliveries[0].deliveryNo === transfer.deliveries[0].deliveryNo) - const departedAgain = await request('POST', departPath, departBody, input.pickerToken) - const facts = value => { const result = copy(value); delete result.replayed; return JSON.stringify(result) } - check('Departure replay does not duplicate shipping', departedAgain.replayed === true && facts(departedAgain) === facts(departed)) + const transfer = current.documents + check('Redispatch produces a new single destination delivery', transfer?.deliveries?.length === 1 && !input.deliveryNos.includes(transfer.deliveries[0].deliveryNo) + && id(transfer.deliveries[0].customerId) === customerId && Number(transfer.deliveries[0].packageCount) === 2 + && transfer.deliveries[0].status === 'DELIVERING' && transfer.outbounds?.length === 1 + && transfer.outbounds[0].status === 'OUTBOUNDED' && transfer.outbounds[0].outboundNo === transfer.deliveries[0].outboundNo + && id(transfer.outbounds[0].customerId) === customerId) + const departed = newTrip.loadingCommand.result + check('Loading completion ships exactly two returned boxes', current.status === 'ACTIVE' && current.stage === 'DELIVERING' + && Number(current.counts.pickedPackageCount) === 2 && Number(current.counts.shippedPackageCount) === 2 + && departed.action === 'LOADING_COMPLETE' && departed.dispatchNo === arranged.dispatchNo + && version(current.recordVersion) === version(departed.recordVersion)) const shipped = await inventory('RETURN_DEPARTED'); check('Returned stock ships once and retains the original immutable stock-in ledger', shipped.available === 0 && shipped.picked === 0 && JSON.stringify(shipped.ledger) === JSON.stringify(stockIn.ledger)) const newDelivery = await delivery(transfer.deliveries[0].deliveryNo) proof.preSignatureGate = { status: 'WAITING', deliveryNo: newDelivery.order.deliveryNo, newTripNo: newTrip.tripNo, diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs index 145c61597..6d6b02987 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs @@ -145,7 +145,7 @@ function server(fault = '') { assert.equal(body.recordVersion, detail.recordVersion); assert.equal(body.manifestHash, 'REAL-MANIFEST') assert.deepEqual(body.measurements, [{ warehousePalletId: PALLET, palletSpaces: 2, weightKg: '187.25', volumeCbm: '1.375', deliveryIndex: 1 }]) loadingActors.set(body.idempotencyKey, token ? 'PDA' : 'PLATFORM') - detail.stage = 'AFTER_LOADING'; bump(); return remember('LOADING_COMPLETE', body, { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } }) + detail.stage = 'DELIVERING'; bump(); return remember('LOADING_COMPLETE', body, { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } }) } if (method === 'GET' && path.startsWith(`${F}/resource-occupancy?`)) { const query = new URL(`https://unused.test${path}`).searchParams, kind = query.get('kind') @@ -304,7 +304,7 @@ test('PDA original-actor replay restores loading while another platform actor is const beforeCount = s.detail.counts.pickedPackageCount const v = await confirmFleetLoading(s.context, 'native', proof, 'PDA-IN-MEMORY-ONLY', receipt()) assert.equal(v, '9007199254741004'); assert.equal(proof.reassignment.status, 'REJECTED_WITHOUT_MUTATION') - assert.equal(s.detail.counts.pickedPackageCount, beforeCount); assert.equal(s.detail.stage, 'AFTER_LOADING') + assert.equal(s.detail.counts.pickedPackageCount, beforeCount); assert.equal(s.detail.stage, 'DELIVERING') const writes = s.calls.filter(c => c.path.endsWith('/loading-complete')) assert.equal(writes.length, 3) assert.deepEqual(writes.map(c => c.body), [writes[0].body, writes[0].body, writes[0].body]) @@ -327,7 +327,7 @@ test('platform positive loading remains executable in its own scenario with orig assert.deepEqual(writes.map(c => [c.path, c.actor]), [[`${D}/loading-complete`, 'PLATFORM'], [`${D}/loading-complete`, 'PLATFORM']]) assert.equal(proof.loadingCommand.replay.replayed, true) assert.equal(proof.loadingCommand.platformPositiveLoading, 'VERIFIED'); assert.equal(proof.loadingCommand.platformRejection, undefined) - assert.equal(s.detail.stage, 'AFTER_LOADING') + assert.equal(s.detail.stage, 'DELIVERING') }) for (const fault of ['cross-actor-accepted', 'cross-actor-generic-forbidden', 'cross-actor-mutates-main', 'cross-actor-mutates-items', 'cross-actor-mutates-loading', 'cross-actor-unknown']) test(`cross-actor loading cannot claim isolation: ${fault}`, async () => { diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs index fb4e4ce17..492cf07db 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs @@ -219,7 +219,7 @@ async function verifyPreDepartureReassignment(context, module, proof) { state.status = 'REJECTED_WITHOUT_MUTATION' } -export async function confirmFleetLoading(context, module, proof, pdaToken, actualMeasurements) { +export async function confirmFleetLoading(context, module, proof, pdaToken, actualMeasurements, beforeComplete) { if (!proof?.dispatchNo || !proof.tripNo) throw new Error('缺少本轮原生排车及唯一资源记录证明') if (proof.loadingCommand) throw new Error('本轮装车命令已开始;保留原请求排查,不自动重试') const path = `${DISPATCH}/${encodeURIComponent(proof.dispatchNo)}`, pdaPath = `${PDA}/${encodeURIComponent(proof.dispatchNo)}` @@ -233,8 +233,9 @@ export async function confirmFleetLoading(context, module, proof, pdaToken, actu proof.loadingPreview = preview; proof.loadingMeasurementReceipt = actualMeasurements proof.loadingCommand = { path: `${pdaToken ? pdaPath : path}/loading-complete`, body: { recordVersion: preview.recordVersion, idempotencyKey: key(proof, 'LOADING_COMPLETE'), manifestHash: preview.manifestHash, measurements, remark: 'TEST本轮实际读取测量证据,不使用计费重量或固定值' } } + if (beforeComplete) await beforeComplete(proof.loadingCommand) const confirmed = await command(context, module, 'POST', proof.loadingCommand.path, proof.loadingCommand.body, '原生显式装车完成', pdaToken) - check(context, module, '显式装车才进入装车后', confirmed.dispatchNo === proof.dispatchNo && confirmed.status === 'ACTIVE' && confirmed.stage === 'AFTER_LOADING' + check(context, module, '装车完成即进入派送中', confirmed.dispatchNo === proof.dispatchNo && confirmed.status === 'ACTIVE' && confirmed.stage === 'DELIVERING' && confirmed.result?.confirmedLoad?.manifestHash === preview.manifestHash && version(confirmed.recordVersion) > version(preview.recordVersion)) const returned = confirmed.result.confirmedLoad.pallets check(context, module, '实载回执逐托保留原测量且不增加货量', returned?.length === measurements.length diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 8e2151f94..58e74a049 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -244,7 +244,8 @@ export async function verifyMeasurementApprovalReplay(context, input, payload, i inboundHistoryCount: after.facts.inbound.statusHistory.length } } -async function reusablePallets(context, warehouseId) { +/** 固定造数只允许指定托盘;显式通用模式才读取其他已有托盘。缺失不自动替换。 */ +export async function reusablePallets(context, warehouseId) { const cacheKey = asId(warehouseId) const allowAnyExistingPallet = context.context?.allowAnyExistingPallet === true const scopedCacheKey = `${cacheKey}:${allowAnyExistingPallet ? 'any' : 'fixed'}` @@ -272,10 +273,12 @@ async function reusablePallets(context, warehouseId) { reusablePalletsByWarehouse.set(scopedCacheKey, pallets) return pallets } - // 固定称重托盘是环境初始化样本;缺失时降级使用当前仓库已有托盘,测量与计费仍走真实接口。 + context.assert(MODULE, '固定称重托盘主数据完整', null, false, + `warehouseId=${warehouseId}, missing=${missingFixed.join(',')}`, OPTIONS) context.addCoverageGap?.(MODULE, 'measurement-pallet-fixture', '固定称重托盘主数据缺失', - `warehouseId=${warehouseId}, missing=${missingFixed.join(',')};改用当前仓库已有托盘继续`, - 'fixture', { blocking: false }) + `warehouseId=${warehouseId}, missing=${missingFixed.join(',')};停止固定托盘造数`, + 'fixture', { blocking: true }) + return null } const response = await context.request('GET', diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index b5f255651..e714f4955 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' +import { readFileSync } from 'node:fs' import { readInitialization, tableDefinitions, table, seedRows, roleAuthorities } from '../sql-baseline-contract-helpers.mjs' const cn = readInitialization() @@ -17,7 +18,8 @@ test('种子解析覆盖多行多值、函数括号及带逗号和引号的文 }) test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => { - for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 227], [overseas, overseasSchemas, domesticSchemas, 228]]) { + // 国内补齐订单/仓库流水表;海外含自有业务四表与当前运输、流水基线。 + for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 229], [overseas, overseasSchemas, domesticSchemas, 234]]) { assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed) const body = sql.replace(/^--.*$/gm, '') @@ -173,7 +175,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客 }) test('必要系统种子完整保留,但不写入示例资源与业务事实', () => { - for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 433, iam_role_permission: 614, + for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 440, iam_role_permission: 624, iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4, fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4, file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name) @@ -200,8 +202,15 @@ test('国内IAM权限与角色授权没有丢失或悬挂,客户模板仍完 for (const row of permissions) if (row.parent_id && row.parent_id !== '0') assert.ok(permissionIds.has(row.parent_id), 'missing parent ' + row.parent_id) const merchant = roleAuthorities(cn, 'MERCHANT_OWNER') for (const code of ['merchant:enterprise', 'merchant:order:quick-create', 'merchant:finance:statement', - 'merchant:warehouse:delivery:list', 'merchant:warehouse:delivery:create']) assert.ok(merchant.has(code), code) + 'merchant:warehouse:delivery:list']) assert.ok(merchant.has(code), code) + assert.ok(!merchant.has('merchant:warehouse:delivery:create'), '派送由排车履约生成,客户只读不能独立创建') const admin = roleAuthorities(cn, 'PLATFORM_ADMIN') + for (const domain of ['owned-business', 'outsourced-business']) { + for (const action of ['list', 'edit', 'upload']) { + assert.ok(admin.has(`platform:fleet:${domain}:${action}`)) + assert.ok(!merchant.has(`platform:fleet:${domain}:${action}`)) + } + } assert.ok(admin.has('platform:overseas-warehouse:discard:approve'), 'discard review endpoint must be grantable to platform administrator') for (const action of ['list', 'create', 'edit', 'status', 'review', 'upload']) { assert.ok(admin.has(`platform:overseas-master-data:driver:${action}`)) diff --git a/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs b/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs index 6048ff9c7..608eb264d 100644 --- a/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs +++ b/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs @@ -1,6 +1,35 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { reusablePallets } from './inbound-measurement.mjs' + +test('固定托盘缺失停止造数,不查询其他托盘;补齐后同仓可重新验证', async () => { + const requests = [], gaps = [], checks = [] + let complete = false + const context = { + context: {}, + async request(method, path) { + requests.push({ method, path }) + const query = new URL(path, 'http://offline').searchParams + const code = query.get('palletCode') + assert.ok(code, '固定模式禁止用无编号列表替换缺失资源') + return { records: complete || code === 'TP-001' + ? [{ id: code === 'TP-001' ? '1' : '2', palletCode: code, warehouseId: 'offline-fixed-pallet' }] : [] } + }, + apiSuccess(_module, _name, response) { return response }, + assert(_module, name, _response, passed) { checks.push({ name, passed }) }, + addCoverageGap(...args) { gaps.push(args) }, + } + assert.equal(await reusablePallets(context, 'offline-fixed-pallet'), null) + assert.equal(requests.length, 2) + assert.ok(checks.some(row => !row.passed)) + assert.equal(gaps[0].at(-1).blocking, true) + complete = true + assert.deepEqual(await reusablePallets(context, 'offline-fixed-pallet'), [ + { id: '1', code: 'TP-001' }, { id: '2', code: 'YW-TP-002' }, + ]) + assert.equal(requests.length, 4, '失败不得缓存为成功') +}) const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index f5b14cba4..e4b4b357f 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -830,15 +830,15 @@ Assert-Contains $overseasPdaAgents '排车拣货与发车' '海外 PDA 排车入 Assert-Contains $overseasPdaAgents '普通/一件代发出库' '海外 PDA 出库入口' # 排车新主流程按单号、公开版本和动作资格定位,不再指向独立车次发车请求。 -Assert-Contains $overseasPdaEntry 'departDispatch' '海外 PDA 当前发车调用入口' -Assert-Contains $overseasPdaIndex 'actions.PICK/LOADING_COMPLETE/DEPART' '海外 PDA 服务端动作资格' +Assert-Contains $overseasPdaEntry 'completeDispatchLoading' '海外 PDA 装车直接派送调用入口' +Assert-Contains $overseasPdaIndex 'actions.PICK/LOADING_COMPLETE' '海外 PDA 服务端动作资格' Assert-Contains $overseasPdaIndex 'recordVersion' '海外 PDA 排车公开版本' Assert-Contains $lastlegIndex 'docs/海外仓业务/排车列表统一作业改造契约.md' 'Lastleg 当前排车主契约' Assert-NotContains $overseasPdaAgents 'tripDepartureRequest' '海外 PDA 旧车次发车入口退出' Assert-NotContains $overseasPdaAgents 'tripRecordVersion' '海外 PDA 旧车次版本退出' Assert-Contains $lastlegEntry 'dispatch/controller/DispatchAggregateController.java' 'Lastleg 主指引平台整车入口' Assert-Contains $lastlegEntry 'dispatch/pda/controller/DispatchPdaController.java' 'Lastleg 主指引 PDA 整车入口' -Assert-Contains $lastlegEntry 'DispatchDepartureService.java' 'Lastleg 主指引共用发车服务' +Assert-Contains $lastlegEntry 'DispatchLoadingDepartureService.java' 'Lastleg 主指引装车直接发运服务' foreach ($lastlegDispatchGuide in @( @{ Name = 'Lastleg 主指引'; Text = $lastlegEntry }, @{ Name = 'Lastleg 详细索引'; Text = $lastlegIndex } @@ -849,9 +849,10 @@ foreach ($lastlegDispatchGuide in @( } } $lastlegPdaPublicController = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java') -Assert-Contains $lastlegPdaPublicController 'DispatchDepartureService' 'Lastleg PDA 真实入口使用共用发车服务' -Assert-Contains $lastlegPdaPublicController 'DispatchCommands.Depart' 'Lastleg PDA 真实入口使用公开发车命令' -Assert-Path 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDepartureService.java' +Assert-Contains $lastlegPdaPublicController 'DispatchLoadingDepartureService' 'Lastleg PDA 真实入口使用装车发运服务' +Assert-Contains $lastlegPdaPublicController 'DispatchCommands.LoadingComplete' 'Lastleg PDA 真实入口使用装车完成命令' +Assert-NotContains $lastlegPdaPublicController '@PostMapping("/{dispatchNo}/depart")' 'Lastleg PDA 独立发车入口退出' +Assert-Path 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java' # Admin 仅对静态资源过期做一次受控恢复,不能吞掉普通路由错误或覆盖未保存编辑。 $navigationRecovery = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/core/routing/navigationRecovery.ts') diff --git a/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs b/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs index 470a20cef..3360ecb72 100644 --- a/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs +++ b/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs @@ -58,20 +58,14 @@ test('eight-hour cutoff has coherent later departure delivery and driver end wit assert.deepEqual(grayUiSchedule(now + 999), schedule) assert.match(schedule.cutoffAt, /Z$/); assert.match(schedule.plannedDepartureAt, /Z$/) assert.doesNotMatch(schedule.plannedDeliveryAt, /Z$/) - const body = { idempotencyKey: 'stable', recordVersion: '9007199254740993', groups: [ - { groupKey: 'A:FBA:ONT8', plannedDeliveryAt: 'old', fees: [{ amount: 30 }] }, - { groupKey: 'B:FBA:ONT8', plannedDeliveryAt: 'other', fees: [{ amount: 20 }] }, - ] } - const applied = applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', body, schedule) - assert.deepEqual(applied.groups.map(group => group.plannedDeliveryAt), [schedule.plannedDeliveryAt, schedule.plannedDeliveryAt].map(value => new Date(value).toISOString())) + const body = { idempotencyKey: 'stable', recordVersion: '9007199254740993' } + const applied = applyGrayUiTransportWindow('PUT', '/api/platform/v1/last-leg/dispatches/DS-1', body, schedule) + assert.equal(applied.estimatedArrivalAt, new Date(schedule.plannedDeliveryAt).toISOString()) assert.equal(applied.idempotencyKey, body.idempotencyKey); assert.equal(applied.recordVersion, body.recordVersion) - assert.deepEqual(applied.groups.map(group => group.fees), body.groups.map(group => group.fees)) - assert.deepEqual(applied.groups.map(group => group.groupKey), body.groups.map(group => group.groupKey)) - assert.deepEqual(body.groups.map(group => group.plannedDeliveryAt), ['old', 'other']) - assert.throws(() => applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', - { idempotencyKey: 'stable', plannedDeliveryAt: 'old', fees: [{ amount: 30 }] }, schedule), /NATIVE_DELIVERY/) + assert.equal(Object.hasOwn(body, 'estimatedArrivalAt'), false) + assert.throws(() => applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches', body, null), /NATIVE_DELIVERY/) assert.equal(applyGrayUiTransportWindow('POST', '/api/auth/login', body, schedule), body) - assert.equal(applyGrayUiTransportWindow('GET', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', body, schedule), body) + assert.equal(applyGrayUiTransportWindow('GET', '/api/platform/v1/last-leg/dispatches/DS-1', body, schedule), body) assert.throws(() => grayUiSchedule(NaN), /CLOCK/) }) diff --git a/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs b/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs index 783df1e02..3f3f3c303 100644 --- a/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs +++ b/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs @@ -76,11 +76,9 @@ export function grayUiSchedule(now = Date.now()) { } export function applyGrayUiTransportWindow(method, path, body, schedule) { - if (method !== 'POST' || !/^\/api\/platform\/v1\/last-leg\/dispatches\/[^/]+\/transfer-delivery$/.test(path)) return body - demand(body && Array.isArray(body.groups) && body.groups.length && body.groups.every(group => group?.groupKey) - && schedule?.plannedDeliveryAt && Number.isFinite(Date.parse(schedule.plannedDeliveryAt)), 'GRAY_UI_NATIVE_DELIVERY_WINDOW_REQUIRED') - const plannedDeliveryAt = new Date(schedule.plannedDeliveryAt).toISOString() - return { ...body, groups: body.groups.map(group => ({ ...group, plannedDeliveryAt })) } + if (!['POST', 'PUT'].includes(method) || !/^\/api\/platform\/v1\/last-leg\/dispatches(?:\/[^/]+)?$/.test(path)) return body + demand(body && schedule?.plannedDeliveryAt && Number.isFinite(Date.parse(schedule.plannedDeliveryAt)), 'GRAY_UI_NATIVE_DELIVERY_WINDOW_REQUIRED') + return { ...body, estimatedArrivalAt: new Date(schedule.plannedDeliveryAt).toISOString() } } /** Private directory override is for isolated offline tests, never exposed as a CLI option. */ diff --git a/scripts/fleet-capacity-pool-ui-fixture-test-env.test.mjs b/scripts/fleet-capacity-pool-ui-fixture-test-env.test.mjs index c57a3c7b9..e3211a1e5 100644 --- a/scripts/fleet-capacity-pool-ui-fixture-test-env.test.mjs +++ b/scripts/fleet-capacity-pool-ui-fixture-test-env.test.mjs @@ -130,16 +130,12 @@ test('UI fixture prepares one shared native main and an explicitly planned 18-of }) -test('native GRAY delivery window preserves per-customer group identity and never adds a retired top-level ETA', () => { +test('GRAY dispatch resource update carries its ETA into automatic delivery without changing the command identity', () => { const schedule = grayUiSchedule(Date.parse('2030-01-01T00:00:00Z')) - const body = { recordVersion: '0', idempotencyKey: 'same-command', cargoHash: 'real-cargo', loadingCompletionHash: 'real-load', - groups: [{ groupKey: 'A:FBA:1', plannedDeliveryAt: 'old', fees: [{ amount: 30 }] }, { groupKey: 'B:FBA:1', plannedDeliveryAt: 'old', fees: [] }] } - const applied = applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', body, schedule) - assert.equal(applied.recordVersion, '0'); assert.equal(applied.idempotencyKey, body.idempotencyKey) - assert.equal(Object.hasOwn(applied, 'plannedDeliveryAt'), false) - assert.deepEqual(applied.groups.map(group => group.groupKey), body.groups.map(group => group.groupKey)) - assert.ok(applied.groups.every(group => group.plannedDeliveryAt === new Date(schedule.plannedDeliveryAt).toISOString())) - assert.deepEqual(applied.groups[0].fees, body.groups[0].fees) - assert.equal(body.groups[0].plannedDeliveryAt, 'old') - assert.throws(() => applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', { plannedDeliveryAt: 'old' }, schedule), /NATIVE_DELIVERY_WINDOW_REQUIRED/) + const body = { recordVersion: '0', idempotencyKey: 'same-command', vehicle: { plate: 'TEST' } } + const applied = applyGrayUiTransportWindow('PUT', '/api/platform/v1/last-leg/dispatches/DS-1', body, schedule) + assert.equal(applied.recordVersion, body.recordVersion); assert.equal(applied.idempotencyKey, body.idempotencyKey) + assert.equal(applied.estimatedArrivalAt, new Date(schedule.plannedDeliveryAt).toISOString()); assert.deepEqual(applied.vehicle, body.vehicle) + assert.equal(Object.hasOwn(body, 'estimatedArrivalAt'), false) + assert.throws(() => applyGrayUiTransportWindow('PUT', '/api/platform/v1/last-leg/dispatches/DS-1', body, null), /NATIVE_DELIVERY_WINDOW_REQUIRED/) }) diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index ab1c41f77..25f510c1f 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -25,9 +25,9 @@ const id = value => value == null ? '' : String(value) const success = response => response?.http === 200 && id(response?.body?.code) === '200' const key = () => `fleet-${randomUUID()}` const NATIVE_PROTOCOL = 'NATIVE_DISPATCH_V1' -const nativeDeparture = (value, dispatchNo) => value?.action === 'DEPART' && value.dispatchNo === dispatchNo - && value.status === 'ACTIVE' && value.stage === 'DELIVERING' && Number.isFinite(Date.parse(value.result?.departedAt)) - && Number.isSafeInteger(Number(value.result.shippedPackages)) && Number(value.result.shippedPackages) > 0 +const nativeDeparture = (value, dispatchNo) => value?.action === 'LOADING_COMPLETE' && value.dispatchNo === dispatchNo + && value.status === 'ACTIVE' && value.stage === 'DELIVERING' && value.result?.loadingCompletionHash + && Number.isFinite(Date.parse(value.result.loadingCompletedAt)) && value.result.confirmedLoad?.manifestHash const instant = value => typeof value === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d(?::\d\d(?:\.\d+)?)?$/.test(value) ? `${value}Z` : value const sameInstant = (a, b) => Number.isFinite(Date.parse(instant(a))) && Date.parse(instant(a)) === Date.parse(instant(b)) const deliveryFacts = rows => JSON.stringify(rows.map(row => [id(row.customerId), row.deliveryNo, row.outboundNo, Number(row.packageCount)]).sort()) @@ -342,15 +342,12 @@ export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { || resources.fleetFeeCoverageCases.length !== 2 || !proof.originalDispatch?.detail || proof.originalDispatch.detail.dispatchNo !== proof.dispatchNo) throw Error('Only a reviewed read-only FULL_COVERAGE failure can resume; mutated/unknown outcomes require case-specific reconciliation') const persisted = stage === 'FULL_COVERAGE_PERSISTED' - if (persisted ? (!full.proof.outboundNo || !full.proof.deliveryNos?.length || !full.proof.confirmationRequest?.idempotencyKey - || !full.proof.confirmationRequest.groups?.length || full.proof.confirmationRequest.recordVersion == null - || !full.proof.requests.some(row => row.method === 'POST' && row.path === `/api/platform/v1/last-leg/dispatches/${full.dispatchNo}/transfer-delivery` - && row.http === 200 && String(row.code) === '200' && row.status === 'RESPONDED' - && JSON.stringify(row.body) === JSON.stringify(full.proof.confirmationRequest) - && row.result?.action === 'ARRANGE_DELIVERY' && row.result.dispatchNo === full.dispatchNo - && row.result.status === 'ACTIVE' && row.result.stage === 'AFTER_LOADING' - && row.result.result?.outbounds?.some(o => o.outboundNo === full.proof.outboundNo) - && JSON.stringify(row.result.result?.deliveries?.map(d => d.deliveryNo).sort()) === JSON.stringify([...full.proof.deliveryNos].sort()))) + const fullTrip = resources.fleetTrips.find(row => row.dispatchNo === full.dispatchNo) + if (persisted ? (!full.proof.outboundNo || !full.proof.deliveryNos?.length || !fullTrip?.loadingCommand?.body?.idempotencyKey + || !fullTrip.loadingCommand.body.measurements?.length || fullTrip.loadingCommand.body.recordVersion == null + || !nativeDeparture(fullTrip.loadingCommand.result, full.dispatchNo) + || !fullTrip.loadingCommand.afterReplay?.main?.documents?.outbounds?.some(o => o.outboundNo === full.proof.outboundNo) + || JSON.stringify(fullTrip.loadingCommand.afterReplay.main.documents.deliveries.map(d => d.deliveryNo).sort()) !== JSON.stringify([...full.proof.deliveryNos].sort())) : (full.proof.requests.some(row => row.method !== 'GET') || full.proof.outboundNo)) throw Error('Mutated/unknown outcomes require the exact successful command and persisted document reconciliation') return { resources, marker: proof.marker, persisted } @@ -373,7 +370,7 @@ export async function verifyFleetTerminalState(context) { const detail=await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(proof.dispatchNo)}`) check('原生主单全部正常派送完成且数量守恒',detail.dispatchNo===proof.dispatchNo&&detail.status==='ACTIVE'&&detail.stage==='DELIVERED' && Number(detail.counts?.packageCount)===Number(detail.counts?.shippedPackageCount)&&Number(detail.counts?.pickedPackageCount)===Number(detail.counts?.shippedPackageCount) - && Number(detail.counts?.shippedPackageCount)===Number(proof.departure.result.shippedPackages)) + && Number(detail.counts?.shippedPackageCount)===Number(proof.departureSnapshot.detail.counts.shippedPackageCount)) evidence.dispatches.push(detail) const refs=nativeFleetDeliveries(detail) check('签收证明恰好覆盖主单每客户派送一次',JSON.stringify(proof.receipts.map(r=>`${r.customerId}:${r.deliveryNo}`).sort())===JSON.stringify(refs.map(r=>`${r.customerId}:${r.deliveryNo}`).sort())) @@ -408,7 +405,7 @@ export async function verifyDepartedRecovery(context, { costsAlreadyVerified = f } const current = await get(base) check(current.trip?.status === 'IN_TRANSIT' && id(current.trip.recordVersion) === id(expectedTrip.recordVersion) - && sameInstant(current.trip.departedAt, proof.departure.result.departedAt) && id(current.trip.warehouseId) === id(proof.warehouseId) + && sameInstant(current.trip.departedAt, proof.departureSnapshot.detail.dispatchInfo.departedAt) && id(current.trip.warehouseId) === id(proof.warehouseId) && id(current.trip.vehicleId) === id(expectedTrip.vehicleId) && id(current.trip.driverId) === id(expectedTrip.driverId) && !current.trip.vehicleRelease && !current.trip.driverRelease, 'trip version, resources or departure changed') const history = await get(`${base}/history?page=1&size=100`) @@ -474,7 +471,7 @@ export async function verifyDepartedRecovery(context, { costsAlreadyVerified = f && !order.signedAt&&!Number(order.signedPackages||0)&&!order.completedAt&&order.documentSummary?.categories?.every(r=>Number(r.fileCount)===0),'delivery has started, changed or been signed') evidence.deliveries.push(order) } - check(evidence.deliveries.reduce((n,row)=>n+Number(row.packageCount),0)===Number(proof.departure.result.shippedPackages),'delivery quantity mismatch') + check(evidence.deliveries.reduce((n,row)=>n+Number(row.packageCount),0)===Number(proof.departureSnapshot.detail.counts.shippedPackageCount),'delivery quantity mismatch') if (createdVehicle) { const vehicle = await get(`/api/platform/v1/last-leg/master-data/vehicles/${encodeURIComponent(createdVehicle.snapshot.id)}`) const canonical = value => value && typeof value === 'object' && !Array.isArray(value) @@ -782,33 +779,18 @@ export async function departNativeFleet(context, trip, pickerToken, run) { if (!pickerToken || !run || run.dispatchNo !== trip.dispatchNo || run.tripNo !== trip.tripNo || run.departureRequest) throw Error('Native departure requires owned main and a new original command') const path = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(trip.dispatchNo)}` - const before = await readNativeFleetBundle(context, trip.dispatchNo) - const refs = nativeFleetDeliveries(before.detail) - const task = await nativeRequest(context, 'GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(trip.dispatchNo)}`, undefined, pickerToken) - if (before.detail.status !== 'ACTIVE' || before.detail.stage !== 'AFTER_LOADING' || task.dispatchNo !== trip.dispatchNo || task.status !== 'ACTIVE' || task.stage !== 'AFTER_LOADING' - || id(task.warehouseId) !== id(trip.warehouseId) || id(task.recordVersion) !== id(before.detail.recordVersion) || task.actions?.DEPART?.allowed !== true - || !before.detail.pickingReconciled || !task.pickingReconciled || Number(before.detail.counts?.packageCount) !== Number(task.counts?.packageCount) - || Number(task.counts?.packageCount) !== Number(task.counts?.pickedPackageCount) || Number(task.counts?.shippedPackageCount) !== 0 - || before.items.items.some(row => Number(row.pickedPackages) !== Number(row.scheduledPackages)) - || before.items.items.reduce((n,row) => n + Number(row.scheduledPackages), 0) !== Number(task.counts?.packageCount) - || refs.reduce((n,d) => n + Number(d.packageCount), 0) !== Number(task.counts.packageCount)) throw Error('Native main is not fully loaded and arranged for both ends') - run.departureRequest = { recordVersion: task.recordVersion, idempotencyKey: key(), remark: 'TEST同一原生主单实际发车,不重复扣库存' } - await context.checkpoint?.() - const departed = await nativeRequest(context, 'POST', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(trip.dispatchNo)}/depart`, run.departureRequest, pickerToken) - if (!nativeDeparture(departed, trip.dispatchNo) || BigInt(dispatchVersion(departed.recordVersion)) <= BigInt(dispatchVersion(task.recordVersion)) - || Number(departed.result.shippedPackages) !== Number(task.counts.packageCount) - || !Array.isArray(departed.result.deliveries) || deliveryFacts(departed.result.deliveries) !== deliveryFacts(refs)) - throw Error('Native departure did not preserve all customer shipment identities and quantities') - run.departure = departed - const replayed = await nativeRequest(context, 'POST', `${path}/depart`, run.departureRequest) - if (!nativeDeparture(replayed, trip.dispatchNo) || replayed.replayed !== true || id(replayed.recordVersion) !== id(departed.recordVersion) || JSON.stringify(replayed.result) !== JSON.stringify(departed.result)) - throw Error('Native cross-end departure replay changed the original outcome') - run.departureReplay = replayed const after = await readNativeFleetBundle(context, trip.dispatchNo) - if (after.detail.status !== 'ACTIVE' || after.detail.stage !== 'DELIVERING' || id(after.detail.recordVersion) !== id(departed.recordVersion) - || Number(after.detail.counts.shippedPackageCount) !== Number(task.counts.packageCount) || Number(after.detail.counts.packageCount) !== Number(task.counts.packageCount) - || Number(after.detail.counts.pickedPackageCount) !== Number(after.detail.counts.shippedPackageCount) - || deliveryFacts(nativeFleetDeliveries(after.detail)) !== deliveryFacts(refs)) throw Error('Native departure readback did not conserve physical quantities') + const refs = nativeFleetDeliveries(after.detail), original = trip.loadingCommand + if (!nativeDeparture(original?.result, trip.dispatchNo) || !original.body?.idempotencyKey + || after.detail.status !== 'ACTIVE' || after.detail.stage !== 'DELIVERING' + || id(after.detail.recordVersion) !== id(original.result.recordVersion) + || Number(after.detail.counts?.shippedPackageCount) !== Number(after.detail.counts?.packageCount) + || refs.reduce((sum, d) => sum + Number(d.packageCount), 0) !== Number(after.detail.counts.packageCount)) + throw Error('Automatic loading completion must have an original success receipt and complete real delivery documents') + const departed = await nativeRequest(context, 'GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(trip.dispatchNo)}/commands/${encodeURIComponent(original.body.idempotencyKey)}`, undefined, pickerToken) + if (!nativeDeparture(departed, trip.dispatchNo) || id(departed.recordVersion) !== id(original.result.recordVersion) + || JSON.stringify(departed.result) !== JSON.stringify(original.result.result)) throw Error('Original loading command readback changed its outcome') + run.departureRequest = original.body; run.departure = departed; run.departureReplay = original.replay run.departureSnapshot = after; run.dispatchNos = [trip.dispatchNo] run.departureDeliveries = [] for (const ref of nativeFleetDeliveries(after.detail)) { diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs index 916e25c99..3f34fade6 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -13,12 +13,12 @@ function departedRecoveryFixture() { const measured=[{warehousePalletId:'P1',palletSpaces:2},{warehousePalletId:'P2',palletSpaces:1}] const departure={action:'DEPART',recordVersion:'27',snapshot:{trip:snapshotTrip,confirmedLoad:{manifestHash:'a'.repeat(64),cargo,pallets:measured}}} const refs=[{customerId:'C1',deliveryNo:'DL-OWN',outboundNo:'OUT-C1',packageCount:2},{customerId:'C1',deliveryNo:'DL-EXT',outboundNo:'OUT-C1',packageCount:1}] - const detail={dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'31',counts:{packageCount:3,pickedPackageCount:3,shippedPackageCount:3},documents:{deliveries:refs}} + const detail={dispatchInfo:{departedAt:snapshotTrip.departedAt},dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'31',counts:{packageCount:3,pickedPackageCount:3,shippedPackageCount:3},documents:{deliveries:refs}} const items={dispatchNo:'OWN',recordVersion:'31',items:cargo.map(row=>({dispatchItemId:row.dispatchItemId,customerId:row.customerId,warehousePalletId:row.warehousePalletId,scheduledPackages:row.packages,pickedPackages:row.packages}))} const orders=refs.map(ref=>({...ref,dispatchNo:'OWN',status:'DELIVERING',version:'1',documentSummary:{categories:[{fileCount:0}]}})) const proof={protocol:'NATIVE_DISPATCH_V1',marker:'RUN',tripNo:'TR-OWN',dispatchNo:'OWN',warehouseId:'W1',customerId:'C1',status:'CHECKING',stage:'TRIP_OPERATING_COSTS',checkpoints:[], dispatchNos:['OWN'],picker:{membershipId:'OLD-PDA'},departureRequest:{idempotencyKey:'old-key'},departureSnapshot:{detail:structuredClone(detail),items:structuredClone(items)},departureDeliveries:structuredClone(orders), - departure:{action:'DEPART',dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'31',result:{departedAt:snapshotTrip.departedAt,shippedPackages:3,deliveries:refs}}} + departure:{action:'LOADING_COMPLETE',dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'31',result:{loadingCompletionHash:'HASH',loadingCompletedAt:snapshotTrip.departedAt,confirmedLoad:{manifestHash:'a'.repeat(64)}}}} const trip={marker:'RUN',tripNo:'TR-OWN',dispatchNo:'OWN',warehouseId:'W1',confirmedLoad:{manifestHash:'a'.repeat(64),pallets:measured},costAcceptance:{marker:'RUN',status:'FAILED',costs:[],departure, requests:[{method:'GET',status:'RESPONDED',http:200,code:'200',requestId:'r',traceId:'t'}]},externalReceipt:{status:'VERIFIED',bookingNo:'BK',warehousePalletId:'P2'},externalDispatch:{dispatchNo:'OWN',deliveryNo:'DL-EXT'}} const report={success:false,steps:[{name:'独立预期覆盖全部实物且没有重叠托盘',passed:false},{name:'模块执行',passed:false}],createdResources:{fleetAcceptance:proof,fleetTrips:[trip],fleetCostActors:{status:'DISABLED'},fleetFeeCoverageCases:[{mode:'PARTIAL',dispatchNo:'OWN',proof:{deliveryNos:['DL-OWN','DL-EXT']}}]}} @@ -66,7 +66,7 @@ function returnSourceRecoveryFixture() { trip.returnRedispatch={marker:'RUN',status:'FAILED',failure:report.steps[0].evidence,originalDeliveries:originals, requests:originals.map(order=>({method:'GET',path:`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}?customerId=C1`,status:'RESPONDED',http:200,code:'200',requestId:'r',traceId:'t',result:{order:structuredClone(order)}}))} report.createdResources.fleetFeeCoverageCases[0].proof.deliveryNos=['DL-A','DL-B','DL-EXT'] - const detail={dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'31',counts:{packageCount:5,pickedPackageCount:5,shippedPackageCount:5},documents:{deliveries:originals.map(o=>({deliveryNo:o.deliveryNo,outboundNo:o.outboundNo,customerId:o.customerId,packageCount:o.packageCount}))}} + const detail={dispatchInfo:{departedAt:original.snapshot.trip.departedAt},dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'31',counts:{packageCount:5,pickedPackageCount:5,shippedPackageCount:5},documents:{deliveries:originals.map(o=>({deliveryNo:o.deliveryNo,outboundNo:o.outboundNo,customerId:o.customerId,packageCount:o.packageCount}))}} const items={dispatchNo:'OWN',recordVersion:'31',items:original.snapshot.confirmedLoad.cargo.map(c=>({dispatchItemId:c.dispatchItemId,customerId:c.customerId,warehousePalletId:c.warehousePalletId,scheduledPackages:c.packages,pickedPackages:c.packages}))} run.departureSnapshot={detail:structuredClone(detail),items:structuredClone(items)} responses.set('/api/platform/v1/last-leg/dispatches/OWN',detail);responses.set('/api/platform/v1/last-leg/dispatches/OWN/items',items) @@ -459,7 +459,7 @@ test('return redispatch is explicit, fresh-only and declares source-event proof }) test('handover stage exports only a verified same-run departure fixture before the optional UI checkpoint', async () => { - const order = [], departure = { action:'DEPART', dispatchNo:'PC-NEW', status:'ACTIVE', stage:'DELIVERING', result:{departedAt:'2026-10-19T00:00:00Z',shippedPackages:1} } + const order = [], departure = { action:'LOADING_COMPLETE', dispatchNo:'PC-NEW', status:'ACTIVE', stage:'DELIVERING', result:{loadingCompletionHash:'HASH',loadingCompletedAt:'2026-10-19T00:00:00Z',confirmedLoad:{manifestHash:'HASH'}} } const context = { marker: 'CURRENT', context: { saveFleetHandoverFixture(value) { order.push('save'); assert.equal(value.marker, 'CURRENT') } }, createdResources: { fleetAcceptance: { tripNo:'TR-NEW', dispatchNo:'PC-NEW', departure } } } const trip = { tripNo: 'TR-NEW', dispatchNo:'PC-NEW', warehouseId: '92', costAcceptance: { status: 'VERIFIED', marker: 'CURRENT', expected: { finalCny: '100.03', usd: '13.07' } } }, details = [{ dispatchNo: 'PC-NEW' }] @@ -706,10 +706,9 @@ test('checkpoint guard forbids repeating commands whose write outcome may be unk assert.throws(() => reviewedFullCheckpoint(mutated), /unknown outcomes/) assert.throws(() => reviewedFullCheckpoint(mutated, 'FULL_COVERAGE_PERSISTED'), /exact successful command/) Object.assign(mutated.createdResources.fleetFeeCoverageCases[0].proof, { deliveryNos: ['DEL-1'], confirmationRequest: { recordVersion:0,groups:[{groupKey:'G'}],idempotencyKey: 'original-key' } }) - mutated.createdResources.fleetFeeCoverageCases[0].proof.requests.push({ path:'/api/platform/v1/last-leg/dispatches/D/transfer-delivery',label: '正式保存本次原费用服务覆盖并转派送', method: 'POST',status:'RESPONDED',requestId:'r',traceId:'t',http: 200, code: '200', - body: structuredClone(mutated.createdResources.fleetFeeCoverageCases[0].proof.confirmationRequest), result: { action:'ARRANGE_DELIVERY',dispatchNo:'D',status:'ACTIVE',stage:'AFTER_LOADING',result:{outbounds:[{outboundNo:'OUT-1'}],deliveries:[{deliveryNo:'DEL-1'}]} } }) + mutated.createdResources.fleetTrips[1].loadingCommand={body:{recordVersion:0,idempotencyKey:'original-key',measurements:[{warehousePalletId:'P'}]},result:{action:'LOADING_COMPLETE',dispatchNo:'D',status:'ACTIVE',stage:'DELIVERING',result:{loadingCompletionHash:'HASH',loadingCompletedAt:'2026-10-02T20:00:00Z',confirmedLoad:{manifestHash:'HASH'}}},afterReplay:{main:{documents:{outbounds:[{outboundNo:'OUT-1'}],deliveries:[{deliveryNo:'DEL-1'}]}}}} assert.equal(reviewedFullCheckpoint(mutated, 'FULL_COVERAGE_PERSISTED').persisted, true) - mutated.createdResources.fleetFeeCoverageCases[0].proof.requests.at(-1).body.idempotencyKey = 'different-key' + delete mutated.createdResources.fleetTrips[1].loadingCommand.result.result.loadingCompletionHash assert.throws(() => reviewedFullCheckpoint(mutated, 'FULL_COVERAGE_PERSISTED'), /exact successful command/) }) @@ -760,7 +759,7 @@ function terminalFixture(fault = '') { const refs=[{customerId:'92',deliveryNo:'DEL-OWN',outboundNo:'OUT',packageCount:5},{customerId:'92',deliveryNo:'DEL-EXT',outboundNo:'OUT',packageCount:1}] const proof={protocol:'NATIVE_DISPATCH_V1',marker:'M',tripNo:'T',dispatchNo:'OWN',dispatchNos:['OWN'],customerId:'92',stage:'RECEIPTS_AND_RESOURCE_RELEASE',closure, receipts:refs.map(row=>({customerId:'92',deliveryNo:row.deliveryNo,signedAt:'2026-10-02T22:00:00'})), - departure:{action:'DEPART',dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',result:{departedAt:'2026-10-02T20:00:00Z',shippedPackages:6}},finance:{customerId:'92',status:'CONSUMER_PERSISTED',source:{sourceFeeId:'F1',sourceVersion:1}}} + departureSnapshot:{detail:{counts:{shippedPackageCount:6},dispatchInfo:{departedAt:'2026-10-02T20:00:00Z'}}},departure:{action:'LOADING_COMPLETE',dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',result:{loadingCompletionHash:'HASH',loadingCompletedAt:'2026-10-02T20:00:00Z',confirmedLoad:{manifestHash:'HASH'}}},finance:{customerId:'92',status:'CONSUMER_PERSISTED',source:{sourceFeeId:'F1',sourceVersion:1}}} const resources={fleetAcceptance:proof,fleetTrips:[trip],fleetFeeCoverageCases:[{mode:'PARTIAL',dispatchNo:'OWN',proof:{deliveryNos:['DEL-OWN','DEL-EXT']}}]} const calls=[],context={createdResources:resources,assert(){},async request(method,path){ calls.push({method,path});assert.equal(method,'GET');let data @@ -837,74 +836,32 @@ test('native delivery references deduplicate the shared external destination but assert.throws(() => nativeFleetDeliveries({ dispatchNo: 'MAIN', documents: { deliveries: [] } })) }) -function nativeDepartureFixture(fault = '') { - const refs = [{ customerId: '11', deliveryNo: 'DEL-A', outboundNo: 'OUT-A', packageCount: 2 }, - { customerId: '22', deliveryNo: 'DEL-B', outboundNo: 'OUT-B', packageCount: 1 }] - const before = { dispatchNo: 'MAIN', status: 'ACTIVE', stage: 'AFTER_LOADING', recordVersion: '9007199254740993', pickingReconciled: true, - counts: { packageCount: 3, pickedPackageCount: 3, shippedPackageCount: 0 }, documents: { deliveries: refs } } - const task = { ...structuredClone(before), warehouseId: '12', actions: { DEPART: { allowed: true } } } - const items = { dispatchNo: 'MAIN', recordVersion: before.recordVersion, items: [ - { dispatchItemId: '1', scheduledPackages: 2, pickedPackages: 2 }, { dispatchItemId: '2', scheduledPackages: 1, pickedPackages: 1 }] } - if (fault === 'stale-task') task.recordVersion = '9007199254740992' - if (fault === 'wrong-main') task.dispatchNo = 'OTHER' - if (fault === 'not-reconciled') task.pickingReconciled = false - if (fault === 'not-loaded') before.stage = 'LOADING' - if (fault === 'not-arranged') task.actions.DEPART.allowed = false - const command = { dispatchNo: 'MAIN', status: 'ACTIVE', stage: 'DELIVERING', action: 'DEPART', recordVersion: '9007199254740994', replayed: false, - result: { departedAt: '2026-10-19T01:00:00Z', shippedPackages: 3, deliveries: structuredClone(refs) } } - const after = { ...structuredClone(before), stage: 'DELIVERING', recordVersion: command.recordVersion, - counts: { packageCount: 3, pickedPackageCount: 3, shippedPackageCount: 3 } } - let departed = false - const calls = [], saved = [], run = { tripNo: 'TRIP', dispatchNo: 'MAIN' }, trip = { tripNo: 'TRIP', dispatchNo: 'MAIN', warehouseId: '12' } - const context = { async checkpoint() { saved.push(structuredClone(run)) }, async request(method, path, body, token) { - calls.push({ method, path, body: structuredClone(body), token }); let value - if (method === 'POST') { - assert.deepEqual(saved[0].departureRequest, body, 'original current-version command is durable before sending') - assert.equal(body.recordVersion, '9007199254740993') - if (path.startsWith('/api/pda/')) { - assert.equal(token, 'pda'); departed = true; value = structuredClone(command) - if (fault === 'changed-owner') value.result.deliveries[0].customerId = '99' - if (fault === 'changed-quantities') { value.result.deliveries[0].packageCount = 1; value.result.deliveries[1].packageCount = 2 } - } else { - assert.equal(token, undefined); assert.deepEqual(body, calls.find(row => row.method === 'POST').body) - value = { ...structuredClone(command), replayed: fault !== 'replay-miss' } - if (fault === 'replay-version') value.recordVersion = '9007199254740995' - } - } else if (path.startsWith('/api/pda/')) { assert.equal(token, 'pda'); value = task } - else if (path.endsWith('/items')) value = { ...items, recordVersion: departed ? after.recordVersion : before.recordVersion } - else if (path.includes('/delivery-orders/')) { - const ref = refs.find(row => path.includes('/' + row.deliveryNo + '?')) - assert.ok(path.endsWith('customerId=' + ref.customerId)) - value = { order: { ...ref, dispatchNo: 'MAIN', status: 'DELIVERING', version: '3' } } - if (fault === 'foreign-delivery') value.order.customerId = '99' - } else value = departed ? after : before - return { http: 200, body: { code: '200', data: structuredClone(value) }, requestId: 'r', traceId: 't' } - } } - return { context, trip, run, calls, saved } +function nativeDepartureFixture(fault='') { + const refs=[{customerId:'11',deliveryNo:'DEL-A',outboundNo:'OUT-A',packageCount:2},{customerId:'22',deliveryNo:'DEL-B',outboundNo:'OUT-B',packageCount:1}] + const result={action:'LOADING_COMPLETE',dispatchNo:'MAIN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'9007199254740994',result:{loadingCompletionHash:'HASH',loadingCompletedAt:'2026-10-19T01:00:00Z',confirmedLoad:{manifestHash:'HASH'}}} + const detail={dispatchNo:'MAIN',status:'ACTIVE',stage:fault==='not-loaded'?'LOADING':'DELIVERING',recordVersion:fault==='stale-version'?'1':result.recordVersion,counts:{packageCount:3,pickedPackageCount:3,shippedPackageCount:fault==='quantity'?2:3},documents:{deliveries:refs}} + const run={tripNo:'TRIP',dispatchNo:'MAIN'},trip={tripNo:'TRIP',dispatchNo:'MAIN',warehouseId:'12',loadingCommand:{body:{idempotencyKey:'original'},result:structuredClone(result),replay:{...result,replayed:true}}},calls=[] + if(fault==='missing-command')delete trip.loadingCommand + const context={async checkpoint(){},async request(method,path,body,token){ + calls.push({method,path,body,token});assert.equal(method,'GET');let data + if(path.includes('/commands/')){assert.equal(token,'pda');data=structuredClone(result);if(fault==='changed-command')data.result.loadingCompletionHash='OTHER'} + else if(path.endsWith('/items'))data={dispatchNo:'MAIN',recordVersion:detail.recordVersion,items:[]} + else if(path.includes('/delivery-orders/')){const ref=refs.find(r=>path.includes(r.deliveryNo));data={order:{...ref,dispatchNo:'MAIN',status:'DELIVERING',version:'3'}};if(fault==='foreign-delivery')data.order.customerId='OTHER'} + else data=detail + return {http:200,body:{code:'200',data:structuredClone(data)},requestId:'r',traceId:'t'} + }} + return {context,trip,run,calls} } - -test('one native current-version PDA DEPART replays exactly through platform and retains every customer delivery version', async () => { - const f = nativeDepartureFixture() - const result = await departNativeFleet(f.context, f.trip, 'pda', f.run) - assert.equal(result.departed, f.run.departure) - assert.deepEqual(f.run.dispatchNos, ['MAIN']) - assert.equal(result.dispatchDetails.length, 1) - assert.deepEqual(f.run.departureDeliveries.map(row => [row.customerId,row.version]), [['11','3'],['22','3']]) - assert.equal(f.calls.filter(row => row.method === 'POST').length, 2) - const before = f.calls.length - await assert.rejects(departNativeFleet(f.context, f.trip, 'pda', f.run), /original command/) - assert.equal(f.calls.length, before, 'no fresh key after an original command exists') +test('one completed loading command is recovered read-only with real customer delivery versions',async()=>{ + const f=nativeDepartureFixture(),result=await departNativeFleet(f.context,f.trip,'pda',f.run) + assert.equal(result.departed,f.run.departure);assert.deepEqual(f.run.dispatchNos,['MAIN']) + assert.deepEqual(f.run.departureDeliveries.map(r=>[r.customerId,r.version]),[['11','3'],['22','3']]);assert.ok(f.calls.every(c=>c.method==='GET')) + assert.deepEqual(f.run.departureRequest,f.trip.loadingCommand.body) + const before=f.calls.length;await assert.rejects(departNativeFleet(f.context,f.trip,'pda',f.run),/original command/);assert.equal(f.calls.length,before) +}) +for(const fault of ['not-loaded','stale-version','quantity','missing-command','changed-command','foreign-delivery'])test(`automatic departure readback rejects ${fault}`,async()=>{ + const f=nativeDepartureFixture(fault);await assert.rejects(departNativeFleet(f.context,f.trip,'pda',f.run));assert.ok(f.calls.every(c=>c.method==='GET')) }) - -for (const fault of ['stale-task','wrong-main','not-reconciled','not-loaded','not-arranged','changed-owner','changed-quantities','replay-miss','replay-version','foreign-delivery']) - test(`native departure refuses stale or conflicting actual evidence: ${fault}`, async () => { - const f = nativeDepartureFixture(fault) - await assert.rejects(departNativeFleet(f.context, f.trip, 'pda', f.run)) - if (['stale-task','wrong-main','not-reconciled','not-loaded','not-arranged'].includes(fault)) { - assert.equal(f.calls.filter(row => row.method === 'POST').length, 0) - assert.equal(f.saved.length, 0) - } else assert.ok(f.run.departureRequest?.idempotencyKey, 'sent requests retain their original key') - }) test('physical loading requires current-run actual per-field evidence and never fills pallet spaces from planned capacity', async () => { const calls = [], trip = { dispatchNo: 'MAIN', stops: [{ destinationKey: 'ORIGIN:12' }, { destinationKey: 'FBA:A' }] }