From c207a4d3b70b636a0fc5b42404ec497ed9d799ee Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 15 Sep 2026 16:10:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/海外区域统一增量-MySQL8.sql | 17 ++ lastleg-service/AGENTS.md | 2 +- .../controller/LastlegOrderController.java | 27 +++ .../lastleg/dto/ContainerArrivalTimeView.java | 11 + .../lastleg/dto/LastlegOrderRequests.java | 10 + .../entity/ContainerArrivalTimeOperation.java | 21 ++ .../baiyue/lastleg/entity/LastlegOrder.java | 1 + .../ContainerArrivalTimeOperationMapper.java | 13 ++ .../lastleg/mapper/LastlegOrderMapper.java | 24 +++ .../lastleg/service/LastlegOrderService.java | 75 +++++++ .../src/main/resources/db/lastleg.sql | 17 ++ .../openapi/overseas-business-v1.yaml | 75 ++++++- .../ContainerArrivalTimeContractTest.java | 24 +++ .../service/LastlegOrderServiceTest.java | 42 ++++ scripts/admin-test-env-acceptance.mjs | 3 +- scripts/admin-test-env-modules/README.md | 13 ++ .../phase4-overseas-return-followups.mjs | 191 ++++++++++++++++++ ...-overseas-return-receipt-contract.test.mjs | 37 ++++ .../phase4-overseas-return-receipt.mjs | 177 ++++++++-------- scripts/agents-index-contract.ps1 | 6 +- 20 files changed, 693 insertions(+), 93 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerArrivalTimeView.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/entity/ContainerArrivalTimeOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/ContainerArrivalTimeOperationMapper.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerArrivalTimeContractTest.java create mode 100644 scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 9744fe5ca..0b811ad37 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -176,6 +176,7 @@ CREATE TABLE IF NOT EXISTS ll_order ( estimated_arrival_at DATETIME(3) COMMENT '预计到港时间', actual_arrival_at DATETIME(3) COMMENT '实际到港时间', estimated_warehouse_at DATETIME(3) COMMENT '预计到仓时间', + arrival_time_version INT NOT NULL DEFAULT 0 COMMENT '柜级预计到仓时间乐观锁版本', inbound_order_no VARCHAR(128) COMMENT '入库单号', outbound_order_no VARCHAR(128) COMMENT '出库单号', delivery_order_no VARCHAR(128) COMMENT '派送单号', @@ -338,6 +339,20 @@ CREATE TABLE IF NOT EXISTS ll_receiving_warehouse_operation ( KEY idx_ll_receiving_warehouse_container(source_container_booking_id,created_at) ) ENGINE=InnoDB COMMENT='柜级海外接柜仓修改幂等审计记录'; +CREATE TABLE IF NOT EXISTS ll_container_arrival_time_operation ( + id BIGINT NOT NULL COMMENT '柜级预计到仓时间修改操作ID', + source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', + idempotency_key VARCHAR(255) NOT NULL COMMENT '平台请求幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '规范化修改请求SHA-256摘要', + result_version INT NOT NULL COMMENT '修改成功后的柜级时间版本', + operator_name VARCHAR(128) NOT NULL COMMENT '平台操作人名称', + remark VARCHAR(500) NULL COMMENT '预计到仓时间修改备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_container_arrival_time_idempotency(idempotency_key), + KEY idx_ll_container_arrival_time_container(source_container_booking_id,created_at) +) ENGINE=InnoDB COMMENT='柜级预计到仓时间修改幂等审计记录'; + -- 派送单列表与详情快照、转派送费用及操作记录。 CREATE TABLE IF NOT EXISTS ll_delivery_order ( id BIGINT NOT NULL COMMENT '派送单主键ID', @@ -481,6 +496,8 @@ ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等 ALTER TABLE ll_order COMMENT='尾程订单主表'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='arrival_time_version'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN arrival_time_version INT NOT NULL DEFAULT 0 COMMENT ''柜级预计到仓时间乐观锁版本'' AFTER estimated_warehouse_at')); +PREPARE lastleg_order_arrival_time_version_stmt FROM @ddl; EXECUTE lastleg_order_arrival_time_version_stmt; DEALLOCATE PREPARE lastleg_order_arrival_time_version_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_pending_container'), 'ALTER TABLE ll_order DROP INDEX idx_ll_order_pending_container', 'SELECT 1')); PREPARE lastleg_order_pending_container_idx_stmt FROM @ddl; EXECUTE lastleg_order_pending_container_idx_stmt; DEALLOCATE PREPARE lastleg_order_pending_container_idx_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_container_progress'), 'SELECT 1', 'ALTER TABLE ll_order ADD INDEX idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)')); diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index d86b7bf4b..b88552d6f 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -27,7 +27,7 @@ |---|---| | 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 | -| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划托数必须严格等于不可变头程封托托盘数,禁止保存可增减托数但无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 | +| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划托数必须严格等于不可变头程封托托盘数,禁止保存可增减托数但无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | | 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 | | 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 6904c147c..5a494a7ea 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -10,6 +10,7 @@ import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.dto.LastlegOrderCommandDtos; import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; import com.baiyue.lastleg.dto.ContainerPlanProgressSummary; +import com.baiyue.lastleg.dto.ContainerArrivalTimeView; import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.security.OverseasPermissions; @@ -40,6 +41,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; +import java.util.List; @RestController @Tag(name = "Tail-leg orders") @@ -112,6 +114,31 @@ public class LastlegOrderController { return ApiResponse.success(orderService.containerProgressDetail(sourceContainerBookingId)); } + @PostMapping("/api/platform/v1/last-leg/orders/container-arrival-times/query") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + @Operation(summary = "批量查询整柜预计与实际到仓时间", + description = "预计到仓取平台人工维护的整柜冻结值;实际到仓取海外PDA首次开始收货时间。") + public ApiResponse> containerArrivalTimes( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody LastlegOrderRequests.ContainerArrivalTimeQuery request) { + platformScope(user, null); + return ApiResponse.success(orderService.containerArrivalTimes(request)); + } + + @PutMapping("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/expected-arrival") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "修改整柜预计到仓时间", + description = "在入库计划生成前原子更新来源柜全部尾程订单;入库计划统一读取该柜时间。") + public ApiResponse changeExpectedWarehouseArrival( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String sourceContainerBookingId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody LastlegOrderRequests.ChangeExpectedWarehouseArrival request) { + platformScope(user, null); + return ApiResponse.success(orderService.changeExpectedWarehouseArrival(sourceContainerBookingId, + idempotencyKey, request, operatorName(user))); + } + @PutMapping("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/receiving-warehouse") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") @Operation(summary = "修改整柜海外接柜仓", diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerArrivalTimeView.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerArrivalTimeView.java new file mode 100644 index 000000000..a8071e88b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerArrivalTimeView.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.dto; + +import java.time.LocalDateTime; + +/** 平台柜子工作台使用的整柜到仓时间事实。 */ +public record ContainerArrivalTimeView( + String sourceContainerBookingId, + LocalDateTime estimatedWarehouseArrivalAt, + LocalDateTime actualWarehouseArrivalAt, + int version +) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java index ebcc8f9fc..b24fb456b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java @@ -61,6 +61,16 @@ public final class LastlegOrderRequests { @Size(max = 500) String remark ) { } + public record ContainerArrivalTimeQuery( + @NotEmpty @Size(max = 200) List<@NotBlank @Size(max = 128) String> sourceContainerBookingIds + ) { } + + public record ChangeExpectedWarehouseArrival( + @NotNull LocalDateTime estimatedWarehouseArrivalAt, + @NotNull @Min(0) Integer expectedVersion, + @Size(max = 500) String remark + ) { } + public record TransferDestination( @NotEmpty @Size(max = 200) List orderIds, @NotBlank @Size(max = 128) String warehouseId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/ContainerArrivalTimeOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/ContainerArrivalTimeOperation.java new file mode 100644 index 000000000..8de5ca798 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/ContainerArrivalTimeOperation.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_container_arrival_time_operation") +public class ContainerArrivalTimeOperation { + @TableId(type = IdType.INPUT) private Long id; + private String sourceContainerBookingId; + private String idempotencyKey; + private String requestHash; + private Integer resultVersion; + private String operatorName; + private String remark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java index 574def390..b56ab8473 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -52,6 +52,7 @@ public class LastlegOrder { private LocalDateTime estimatedArrivalAt; private LocalDateTime actualArrivalAt; private LocalDateTime estimatedWarehouseAt; + private Integer arrivalTimeVersion; private String inboundOrderNo; private String outboundOrderNo; private String deliveryOrderNo; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/ContainerArrivalTimeOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/ContainerArrivalTimeOperationMapper.java new file mode 100644 index 000000000..7dc028481 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/ContainerArrivalTimeOperationMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.ContainerArrivalTimeOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ContainerArrivalTimeOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_container_arrival_time_operation WHERE idempotency_key=#{key} LIMIT 1") + ContainerArrivalTimeOperation findByKey(@Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 4df23a15a..0e9bcf6c7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -5,6 +5,7 @@ import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; import com.baiyue.lastleg.dto.ContainerPlanProgressSummary; +import com.baiyue.lastleg.dto.ContainerArrivalTimeView; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @@ -138,6 +139,29 @@ public interface LastlegOrderMapper extends BaseMapper { @Select("SELECT * FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE") List lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId); + @Select("") + List findContainerArrivalTimes(@Param("bookingIds") List bookingIds); + + @Update("UPDATE ll_order SET estimated_warehouse_at=#{estimatedAt},arrival_time_version=arrival_time_version+1," + + "version=version+1,updated_at=NOW(3) WHERE source_container_booking_id=#{bookingId} " + + "AND arrival_time_version=#{expectedVersion}") + int updateContainerExpectedWarehouseArrival(@Param("bookingId") String bookingId, + @Param("estimatedAt") java.time.LocalDateTime estimatedAt, + @Param("expectedVersion") int expectedVersion); + + @Select("SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order o " + + "ON o.id=p.source_order_id AND o.customer_id=p.customer_id " + + "WHERE o.source_container_booking_id=#{bookingId}") + long countContainerPlans(@Param("bookingId") String bookingId); + @Select("SELECT COUNT(*) FROM ll_receiving_container_session WHERE source_container_booking_id=#{sourceContainerBookingId}") long countReceivingSessions(@Param("sourceContainerBookingId") String sourceContainerBookingId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index cdb6223ac..b459ffc7e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -11,12 +11,14 @@ import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; import com.baiyue.lastleg.dto.ContainerPlanProgressSummary; +import com.baiyue.lastleg.dto.ContainerArrivalTimeView; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.entity.LastlegOrderOperationLog; import com.baiyue.lastleg.entity.LastlegOrderManualNode; import com.baiyue.lastleg.entity.ReceivingWarehouseOperation; +import com.baiyue.lastleg.entity.ContainerArrivalTimeOperation; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; @@ -30,6 +32,7 @@ import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper; +import com.baiyue.lastleg.mapper.ContainerArrivalTimeOperationMapper; import com.baiyue.lastleg.model.LastlegOrderType; import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; @@ -49,6 +52,7 @@ import java.time.ZoneOffset; import java.security.MessageDigest; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -77,6 +81,7 @@ public class LastlegOrderService { @Resource private InventoryCommandService inventory; @Resource private InboundOrderDetainedOutboxService detainedOutbox; @Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations; + @Resource private ContainerArrivalTimeOperationMapper containerArrivalTimeOperations; @Resource private WarehouseResourceGateway warehouseResourceGateway; @Resource private ContainerShippingGateMapper containerShippingGates; @@ -126,6 +131,76 @@ public class LastlegOrderService { orderMapper.findContainerProgressItems(bookingId)); } + @Transactional(readOnly = true) + public List containerArrivalTimes( + LastlegOrderRequests.ContainerArrivalTimeQuery request) { + LinkedHashSet bookingIds = new LinkedHashSet<>(); + for (String value : request.sourceContainerBookingIds()) bookingIds.add(normalizeBookingId(value)); + if (bookingIds.size() != request.sourceContainerBookingIds().size()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能重复"); + } + return orderMapper.findContainerArrivalTimes(List.copyOf(bookingIds)); + } + + @Transactional + public ContainerArrivalTimeView changeExpectedWarehouseArrival(String sourceContainerBookingId, + String idempotencyKey, LastlegOrderRequests.ChangeExpectedWarehouseArrival request, + String operatorName) { + String bookingId = normalizeBookingId(sourceContainerBookingId); + String key = normalizeIdempotencyKey(idempotencyKey); + String requestHash = hash(bookingId + "\n" + request.estimatedWarehouseArrivalAt() + "\n" + + request.expectedVersion() + "\n" + Objects.toString(request.remark(), "")); + ContainerArrivalTimeOperation replay = containerArrivalTimeOperations.findByKey(key); + if (replay != null) { + if (!bookingId.equals(replay.getSourceContainerBookingId()) + || !requestHash.equals(replay.getRequestHash())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Idempotency-Key 已用于其他预计到仓时间修改请求"); + } + return requireContainerArrivalTime(bookingId); + } + + List orders = orderMapper.lockContainerOrders(bookingId); + if (orders.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在"); + int version = requireSingleArrivalTimeVersion(orders); + if (version != request.expectedVersion()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "预计到仓时间版本已变化,请刷新后重试"); + } + if (orderMapper.countContainerPlans(bookingId) > 0) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜已生成入库计划,预计到仓时间不可修改"); + } + int updated = orderMapper.updateContainerExpectedWarehouseArrival(bookingId, + request.estimatedWarehouseArrivalAt(), version); + if (updated != orders.size()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间发生并发变化"); + } + ContainerArrivalTimeOperation operation = new ContainerArrivalTimeOperation(); + operation.setId(IdWorker.getId()); operation.setSourceContainerBookingId(bookingId); + operation.setIdempotencyKey(key); operation.setRequestHash(requestHash); + operation.setResultVersion(version + 1); operation.setOperatorName(operatorName); + operation.setRemark(request.remark() == null ? null : request.remark().trim()); + operation.setCreatedAt(LocalDateTime.now()); containerArrivalTimeOperations.insert(operation); + return requireContainerArrivalTime(bookingId); + } + + private ContainerArrivalTimeView requireContainerArrivalTime(String bookingId) { + List values = orderMapper.findContainerArrivalTimes(List.of(bookingId)); + if (values.size() != 1) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在"); + if (values.get(0).version() < 0) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间版本不一致"); + } + return values.get(0); + } + + private int requireSingleArrivalTimeVersion(List orders) { + Set versions = orders.stream().map(LastlegOrder::getArrivalTimeVersion) + .map(value -> value == null ? 0 : value).collect(Collectors.toSet()); + if (versions.size() != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间版本不一致"); + } + return versions.iterator().next(); + } + @Transactional public ContainerPlanProgressSummary changeReceivingWarehouse(String sourceContainerBookingId, String idempotencyKey, LastlegOrderRequests.ChangeReceivingWarehouse request, String operatorName) { diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index c6b85a0c0..fdd1e69f5 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -83,6 +83,7 @@ CREATE TABLE IF NOT EXISTS ll_order ( estimated_arrival_at DATETIME(3) COMMENT '预计到港时间', actual_arrival_at DATETIME(3) COMMENT '实际到港时间', estimated_warehouse_at DATETIME(3) COMMENT '预计到仓时间', + arrival_time_version INT NOT NULL DEFAULT 0 COMMENT '柜级预计到仓时间乐观锁版本', inbound_order_no VARCHAR(128) COMMENT '入库单号', outbound_order_no VARCHAR(128) COMMENT '出库单号', delivery_order_no VARCHAR(128) COMMENT '派送单号', @@ -245,6 +246,20 @@ CREATE TABLE IF NOT EXISTS ll_receiving_warehouse_operation ( KEY idx_ll_receiving_warehouse_container(source_container_booking_id,created_at) ) ENGINE=InnoDB COMMENT='柜级海外接柜仓修改幂等审计记录'; +CREATE TABLE IF NOT EXISTS ll_container_arrival_time_operation ( + id BIGINT NOT NULL COMMENT '柜级预计到仓时间修改操作ID', + source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', + idempotency_key VARCHAR(255) NOT NULL COMMENT '平台请求幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '规范化修改请求SHA-256摘要', + result_version INT NOT NULL COMMENT '修改成功后的柜级时间版本', + operator_name VARCHAR(128) NOT NULL COMMENT '平台操作人名称', + remark VARCHAR(500) NULL COMMENT '预计到仓时间修改备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_container_arrival_time_idempotency(idempotency_key), + KEY idx_ll_container_arrival_time_container(source_container_booking_id,created_at) +) ENGINE=InnoDB COMMENT='柜级预计到仓时间修改幂等审计记录'; + -- 派送单列表与详情快照、转派送费用及操作记录。 CREATE TABLE IF NOT EXISTS ll_delivery_order ( id BIGINT NOT NULL COMMENT '派送单主键ID', @@ -388,6 +403,8 @@ ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等 ALTER TABLE ll_order COMMENT='尾程订单主表'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='arrival_time_version'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN arrival_time_version INT NOT NULL DEFAULT 0 COMMENT ''柜级预计到仓时间乐观锁版本'' AFTER estimated_warehouse_at')); +PREPARE lastleg_order_arrival_time_version_stmt FROM @ddl; EXECUTE lastleg_order_arrival_time_version_stmt; DEALLOCATE PREPARE lastleg_order_arrival_time_version_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_pending_container'), 'ALTER TABLE ll_order DROP INDEX idx_ll_order_pending_container', 'SELECT 1')); PREPARE lastleg_order_pending_container_idx_stmt FROM @ddl; EXECUTE lastleg_order_pending_container_idx_stmt; DEALLOCATE PREPARE lastleg_order_pending_container_idx_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_container_progress'), 'SELECT 1', 'ALTER TABLE ll_order ADD INDEX idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)')); 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 07bf4a5db..b9101b597 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -311,7 +311,7 @@ paths: /api/platform/v1/last-leg/orders/container-summaries: get: summary: 分页查询柜级入库计划进度 - description: 权限 platform:overseas-warehouse:last-mile-order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响,返回全部稳定来源柜;全部入仓码均已生成有效独立计划的柜仍保留,供柜级接柜仓查询与修改,生成计划动作由 allPlansReady/pendingPlanCount 控制。containerNo 只返回装柜完成事件携带并规范化冻结的真实集装箱号,不从 cabinetNo、订单号或内部 ID 推导。plannedArrivalAt 仅来自装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at;按该时间升序、null 最后、稳定柜 ID 升序。 + description: 权限 platform:overseas-warehouse:last-mile-order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响,返回全部稳定来源柜;全部入仓码均已生成有效独立计划的柜仍保留,供柜级接柜仓查询与修改,生成计划动作由 allPlansReady/pendingPlanCount 控制。containerNo 只返回装柜完成事件携带并规范化冻结的真实集装箱号,不从 cabinetNo、订单号或内部 ID 推导。plannedArrivalAt 取 ll_order.estimated_warehouse_at,可由装柜完成事件初始化,并在入库计划生成前由平台按整柜人工修正;按该时间升序、null 最后、稳定柜 ID 升序。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } @@ -381,6 +381,52 @@ paths: data: { $ref: '#/components/schemas/ContainerPlanProgressSummary' } '400': { description: 请求无效或目标不是有效 OVERSEAS 仓 } '409': { description: 版本冲突、幂等键冲突或PDA接柜会话已建立 } + /api/platform/v1/last-leg/orders/container-arrival-times/query: + post: + summary: 批量查询整柜预计与实际到仓时间 + description: 权限 platform:overseas-warehouse:last-mile-order:list;预计到仓取整柜人工冻结值,实际到仓取海外 PDA 首次开始收货时间。 + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ContainerArrivalTimeQuery' } + responses: + '200': + description: 柜级到仓时间列表 + content: + application/json: + schema: + type: object + properties: + code: { type: string, enum: ['200'] } + data: + type: array + items: { $ref: '#/components/schemas/ContainerArrivalTimeView' } + /api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/expected-arrival: + put: + summary: 修改整柜预计到仓时间 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;使用柜级时间版本与 Idempotency-Key,在入库计划生成前原子更新柜内全部尾程订单。 + security: [{ bearerAuth: [] }] + parameters: + - { name: sourceContainerBookingId, in: path, required: true, schema: { type: string } } + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 255 } } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ChangeExpectedWarehouseArrivalRequest' } + responses: + '200': + description: 更新后的柜级到仓时间 + content: + application/json: + schema: + type: object + properties: + code: { type: string, enum: ['200'] } + data: { $ref: '#/components/schemas/ContainerArrivalTimeView' } + '409': { description: 已生成入库计划、版本冲突或幂等键冲突 } /api/platform/v1/last-leg/orders/{id}/destination: put: summary: 将单票尾程订单转入 FBA 目的仓 @@ -1669,7 +1715,7 @@ components: plannedInboundCodeCount: { type: integer, format: int64 } pendingPlanCount: { type: integer, format: int64, description: 尚未生成有效计划的尾程订单数,即未操作数 } resourcePendingPlanCount: { type: integer, format: int64, description: 已有有效计划但尚未完整绑定海外仓真实托盘的订单数 } - plannedArrivalAt: { type: string, format: date-time, nullable: true, description: 仅来源于装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at } + plannedArrivalAt: { type: string, format: date-time, nullable: true, description: 整柜一致的 ll_order.estimated_warehouse_at,可由装柜事件初始化并在计划生成前人工维护 } allPlansReady: { type: boolean, description: 全柜每票均有入仓码且均存在有效独立计划时为 true } blockingReason: { type: string, nullable: true } ChangeReceivingWarehouseRequest: @@ -1679,6 +1725,31 @@ components: overseasWarehouseId: { type: integer, format: int64, minimum: 1 } expectedVersion: { type: integer, minimum: 0 } remark: { type: string, maxLength: 500, nullable: true } + ContainerArrivalTimeQuery: + type: object + required: [sourceContainerBookingIds] + properties: + sourceContainerBookingIds: + type: array + minItems: 1 + maxItems: 200 + uniqueItems: true + items: { type: string, minLength: 1, maxLength: 128 } + ChangeExpectedWarehouseArrivalRequest: + type: object + required: [estimatedWarehouseArrivalAt, expectedVersion] + properties: + estimatedWarehouseArrivalAt: { type: string, format: date-time } + expectedVersion: { type: integer, minimum: 0 } + remark: { type: string, maxLength: 500, nullable: true } + ContainerArrivalTimeView: + type: object + required: [sourceContainerBookingId, version] + properties: + sourceContainerBookingId: { type: string } + estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true } + actualWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 海外 PDA 首次开始收货时间 } + version: { type: integer, minimum: 0 } ContainerPlanProgressDetail: type: object required: [summary, orders] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerArrivalTimeContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerArrivalTimeContractTest.java new file mode 100644 index 000000000..1a7ba7ff3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerArrivalTimeContractTest.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.service; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ContainerArrivalTimeContractTest { + @Test + void freezesExpectedArrivalByContainerAndReadsActualArrivalFromPdaReceivingStart() throws Exception { + String mapper = Files.readString(Path.of("src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java")); + String api = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml")); + String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + + assertTrue(mapper.contains("arrival_time_version=arrival_time_version+1")); + assertTrue(mapper.contains("MIN(s.receiving_at)")); + assertTrue(api.contains("/api/platform/v1/last-leg/orders/container-arrival-times/query:")); + assertTrue(api.contains("/{sourceContainerBookingId}/expected-arrival:")); + assertTrue(schema.contains("ll_container_arrival_time_operation")); + assertTrue(schema.contains("arrival_time_version INT NOT NULL DEFAULT 0")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index f1252512e..71a8f047a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.service; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; import com.baiyue.lastleg.dto.ContainerPlanProgressSummary; +import com.baiyue.lastleg.dto.ContainerArrivalTimeView; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; @@ -17,6 +18,8 @@ import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper; +import com.baiyue.lastleg.mapper.ContainerArrivalTimeOperationMapper; +import com.baiyue.lastleg.entity.ContainerArrivalTimeOperation; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; @@ -42,6 +45,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.anyLong; @@ -65,6 +69,7 @@ class LastlegOrderServiceTest { @Mock InventoryCommandService inventory; @Mock InboundOrderDetainedOutboxService detainedOutbox; @Mock ReceivingWarehouseOperationMapper receivingWarehouseOperations; + @Mock ContainerArrivalTimeOperationMapper containerArrivalTimeOperations; @Mock WarehouseResourceGateway warehouseResourceGateway; @Mock ContainerShippingGateMapper containerShippingGates; LastlegOrderService service; @@ -97,10 +102,47 @@ class LastlegOrderServiceTest { ReflectionTestUtils.setField(service, "inventory", inventory); ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox); ReflectionTestUtils.setField(service, "receivingWarehouseOperations", receivingWarehouseOperations); + ReflectionTestUtils.setField(service, "containerArrivalTimeOperations", containerArrivalTimeOperations); ReflectionTestUtils.setField(service, "warehouseResourceGateway", warehouseResourceGateway); ReflectionTestUtils.setField(service, "containerShippingGates", containerShippingGates); } + @Test + void changesExpectedWarehouseArrivalForTheWholeContainerBeforePlanning() { + LastlegOrder first = new LastlegOrder(); first.setArrivalTimeVersion(0); + LastlegOrder second = new LastlegOrder(); second.setArrivalTimeVersion(0); + LocalDateTime estimatedAt = LocalDateTime.of(2026, 9, 25, 15, 31); + when(orders.lockContainerOrders("BOOKING-1")).thenReturn(List.of(first, second)); + when(orders.countContainerPlans("BOOKING-1")).thenReturn(0L); + when(orders.updateContainerExpectedWarehouseArrival("BOOKING-1", estimatedAt, 0)).thenReturn(2); + when(orders.findContainerArrivalTimes(List.of("BOOKING-1"))).thenReturn(List.of( + new ContainerArrivalTimeView("BOOKING-1", estimatedAt, null, 1))); + + ContainerArrivalTimeView result = service.changeExpectedWarehouseArrival("BOOKING-1", "arrival-1", + new LastlegOrderRequests.ChangeExpectedWarehouseArrival(estimatedAt, 0, null), "admin"); + + assertEquals(estimatedAt, result.estimatedWarehouseArrivalAt()); + ArgumentCaptor operation = ArgumentCaptor.forClass(ContainerArrivalTimeOperation.class); + verify(containerArrivalTimeOperations).insert(operation.capture()); + assertEquals(1, operation.getValue().getResultVersion()); + } + + @Test + void rejectsExpectedWarehouseArrivalChangesAfterAnyPlanExists() { + LastlegOrder order = new LastlegOrder(); order.setArrivalTimeVersion(0); + when(orders.lockContainerOrders("BOOKING-1")).thenReturn(List.of(order)); + when(orders.countContainerPlans("BOOKING-1")).thenReturn(1L); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.changeExpectedWarehouseArrival("BOOKING-1", "arrival-locked", + new LastlegOrderRequests.ChangeExpectedWarehouseArrival( + LocalDateTime.of(2026, 9, 25, 15, 31), 0, null), "admin")); + + assertEquals(409, error.getStatusCode().value()); + assertEquals("该柜已生成入库计划,预计到仓时间不可修改", error.getReason()); + verify(orders, never()).updateContainerExpectedWarehouseArrival(any(), any(), anyInt()); + } + @Test void changesReceivingWarehouseForAllPlansReadyContainerBeforePdaSession() { LastlegOrder first = order(1L, "100", "PENDING_ASSIGNMENT", null); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index d8b004768..0b9ee3e0c 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -6,7 +6,7 @@ import { pathToFileURL } from 'node:url' import process from 'node:process' const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics'] -const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitting-verify', +const OPTIONAL_MODULES = ['phase4-overseas-return-followups', 'manual-order-splitting-fixture', 'manual-order-splitting-verify', 'manual-preload-push-fixture', 'manual-preload-push-verify', 'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-pda-batch-fixture', 'manual-pda-batch-verify', @@ -42,6 +42,7 @@ export function planA0LocationIsolation(locations, requiredSlots = 1, } const MODULE_DEPENDENCIES = new Map([ + ['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']], ['dropship-pallet-matrix', ['merchant-qualification-fixture']], ['dropship-browser-fixture', ['merchant-qualification-fixture']], ['mixed-container-receiving', ['merchant-qualification-fixture']], diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 08ba8bb13..61f7f810f 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -4,6 +4,19 @@ 总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。 +退货专项只运行 `--modules phase4-overseas-return-receipt`,自动准备本轮客户;覆盖非法/重复预报、退货单号和 SKU 查询、非法/未知 SKU 实收、超收及失败不落账、分批签收完成、部分签收禁止合箱、合箱非法箱规/数量/来源、幂等重放前后库存快照一致、逐箱分页无重复遗漏、客户 SKU 数量守恒及标签。通过显式测试环境文件运行总入口,JSON 路径由 `--output` 指定;会创建隔离测试数据,finally 停用临时 PDA,不删除业务记录。失败保留报告,先核对记录再决定是否新批次重跑。 + +`phase4-overseas-return-followups.mjs` 是独立可选后续模块,只在显式 `--modules phase4-overseas-return-followups` 时依赖并运行退货前置,再关联换标单及执行退货出库;不计入退货专项通过结论。仅复用同次运行、同 marker 的退货资源,凭据只在内存中,finally 停用其独立临时 PDA。合同检查复用 `phase4-overseas-return-receipt-contract.test.mjs`;HTTP 必须有目标环境写入授权。 + +退货专项复跑命令(在后端根目录执行,每次使用新的报告名;不要与其他环境混用): + +```powershell +node --test scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs scripts/script-documentation-contract.test.mjs +node --env-file="$env:USERPROFILE/.codex/chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase4-overseas-return-receipt --output reports/return-<本轮批次>.json +``` + +PC/PDA 页面交互验收与 HTTP 独立取证,不用接口提交替代点击:PC 固定 1920×1080、缩放100%,验证空预报拦截后新建独立双SKU单(甲3乙2,不执行换标);PDA 固定390×848,验证不存在单号空态、按单号签收甲2、按SKU候选确认2/3与整单2/5、全部填入剩余甲1乙2并确认5/5;再合箱甲2乙1及30×20×10cm/1.2kg。PC 查询三箱3+1+1、两批真实来源、新箱明细、SKU汇总甲3乙2,并检查无结果查询后恢复正常列表。记录本轮业务单号、实际视口、截图与控制台信息;H5点击不代表真机扫码,标签HTTP不代表PDF分页或实体打印。人工重点确认布局、长字段换行、滚动、反馈和操作便利性。 + 需要为人工联调批量准备同一客户、同一国内仓和同一渠道的 `WAIT_SPLIT` 数据时,使用[《批量待分柜数据生成指南》](批量待分柜数据生成指南.md)。该入口通过正式下单、入库、称重和测量接口生成数据,支持 FBA、海外仓、私人地址及一件代发等稳定随机组合;不得直接写库或用于生产环境。 固定“IT测试”客户与“义乌仓”、其余线路随机并尽力生成最多 8,000 票时,独立编排入口是 `../it-test-wait-split-bulk.mjs`。它仍复用本目录的 `orders.mjs` 与 `inbound-measurement.mjs`,但逐票保存日志且单票失败后继续。测量前置只随机复用既有 `TP-001`、`YW-TP-002`,不得调用托盘新增接口;库位由正式仓储接口分配,普通位不足时使用公共位 `A0`,不得调用库位新增接口。 diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs new file mode 100644 index 000000000..bf43e5537 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs @@ -0,0 +1,191 @@ +import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs' + +const MODULE = 'phase4-overseas-return-followups' +const SCENARIO = 'phase4-overseas-return-followups' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const RETURN_ORDERS = '/api/merchant/v1/last-leg/return-orders' +const PDA_RECEIPTS = '/api/pda/v1/last-leg/return-receipts' +const RETURN_INVENTORY = '/api/merchant/v1/last-leg/return-inventory' + +function id(value) { return value == null ? '' : String(value) } +function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' } +function code(response) { return String(response?.body?.code ?? '') } +function success(response) { return response?.http === 200 && code(response) === '200' } +function conflict(response) { + return (response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response)))) + && nonBlank(response?.requestId) && nonBlank(response?.traceId) +} +function notFound(response) { + return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response)))) + && nonBlank(response?.requestId) && nonBlank(response?.traceId) +} +function records(value) { return Array.isArray(value?.records) ? value.records : Array.isArray(value) ? value : [] } +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${code(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function query(path, values) { + const params = new URLSearchParams() + for (const [name, value] of Object.entries(values || {})) { + if (value !== undefined && value !== null && String(value) !== '') params.set(name, String(value)) + } + return params.size ? `${path}?${params}` : path +} +function key(context, action) { + return `${context.marker}-RETURN-${action}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 255) +} +function markerSuffix(context) { + return String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase() +} + +function add(context, name, response, passed, evidence = '') { + context.addStep(MODULE, name, response, passed, `${evidence}${evidence ? '; ' : ''}${trace(response)}`, OPTIONS) +} + +async function overseas(context, token, method, name, path, body = undefined) { + const response = await context.requestOverseas(method, path, body, {}, token) + add(context, name, response, success(response)) + return { response, data: success(response) ? response.body?.data : null } +} + +async function resolveWarehouse(context) { + const existing = id(context.createdResources?.phase4OverseasPda?.overseasWarehouseId) + const response = await context.request('GET', + '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE') + const rows = records(response?.body?.data) + const fixedIds = new Set(['193000000000200003', '193000000000200004']) + const warehouse = rows.find((item) => id(item?.id) === existing) + || rows.find((item) => fixedIds.has(id(item?.id))) + || rows[0] + add(context, '读取可执行退货签收的海外仓', response, + success(response) && nonBlank(id(warehouse?.id)), `warehouseId=${id(warehouse?.id) || 'missing'}`) + return success(response) && warehouse ? { id: id(warehouse.id), name: warehouse.warehouseName || warehouse.name } : null +} + +async function loginMerchant(context) { + const fixture = context.context?.merchantQualificationFixture + if (fixture?.marker !== context.marker || !fixture?.username || !fixture?.password) { + add(context, '取得本轮客户账号', null, false, `marker=${fixture?.marker || 'missing'}`) + return null + } + const response = await context.request('POST', '/api/auth/login', { + loginId: fixture.username, password: fixture.password, + }, '') + const token = success(response) ? response.body?.data?.overseasAccessToken : null + add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`) + return nonBlank(token) ? token : null +} + +function itemMap(items) { + return new Map((Array.isArray(items) ? items : []).map((item) => [item.skuCode, item])) +} + +export async function run(context) { + const source = context.createdResources.phase4OverseasReturnReceipt + if (source?.marker !== context.marker || !source?.returnNo) { + add(context, '后续关联要求本轮退货签收合箱前置', null, false, '缺少本轮退货前置') + return + } + const merchantToken = await loginMerchant(context) + const warehouse = await resolveWarehouse(context) + const access = await resolvePickerAccess(context) + if (!merchantToken || !warehouse || !access) return + const picker = await createPicker(context, 92, warehouse.id, access) + if (!picker?.token) { + add(context, '创建后续关联PDA操作员', null, false, '未取得绑定仓PDA令牌') + if (picker?.membershipId) await disablePicker(context, picker, 92) + return + } + const returnNo = source.returnNo + const customerId = id(context.context?.merchantQualificationFixture?.tenantId) + const suffix = markerSuffix(context) + try { + const relabelCandidates = await overseas(context, merchantToken, 'GET', '客户查询本轮退货换标候选', + `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel-candidates`) + const relabelCandidate = (Array.isArray(relabelCandidates.data) ? relabelCandidates.data : []) + .find(row => nonBlank(row?.cartonMark) && row?.status === 'AVAILABLE') + context.assert(MODULE, '退货换标候选来自本轮已签收可用箱', relabelCandidates.response, + Boolean(relabelCandidate) && relabelCandidate.sourceReturnNo === returnNo, + `cartonMark=${relabelCandidate?.cartonMark || 'missing'}`, OPTIONS) + if (relabelCandidate) { + const relabelBody = { + cartonMarks: [relabelCandidate.cartonMark], idempotencyKey: key(context, 'RELABEL'), + contactName: '退货换标验收', contactPhone: '13800000008', deliveryMethod: 'TRUCK', remark: context.marker, + } + const relabel = await overseas(context, merchantToken, 'POST', '客户从退货箱创建换标单', + `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel`, relabelBody) + const relabelNo = id(relabel.data?.relabelNo) + context.assert(MODULE, '退货换标单绑定原退货单与选中箱', relabel.response, + nonBlank(relabelNo) && relabel.data?.returnNo === returnNo + && relabel.data?.status === 'PENDING_PROCESSING' + && relabel.data?.cartonMarks?.includes?.(relabelCandidate.cartonMark), + `relabelNo=${relabelNo || 'missing'}`, OPTIONS) + if (relabelNo) { + const form = new FormData() + form.append('file', new Blob([new TextEncoder().encode(`%PDF-1.4\n${context.marker}\n%%EOF`)], + { type: 'application/pdf' }), `${suffix}-return-relabel.pdf`) + const uploaded = await context.requestOverseasMultipart('POST', + `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files?scope=CARTON_TOTAL`, + form, {}, merchantToken) + add(context, '客户编辑退货换标总资料', uploaded, success(uploaded)) + const relabelDetail = await overseas(context, merchantToken, 'GET', '客户回查退货换标资料修改', + `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}`) + context.assert(MODULE, '退货换标详情持久化总资料且业务状态未漂移', relabelDetail.response, + relabelDetail.data?.application?.relabelNo === relabelNo + && relabelDetail.data?.application?.status === 'PENDING_PROCESSING' + && nonBlank(relabelDetail.data?.application?.totalFileId), trace(relabelDetail.response), OPTIONS) + context.createdResources.phase4OverseasReturnReceipt.relabelNo = relabelNo + } + } + + const outboundCandidates = await overseas(context, context.overseasToken, 'GET', '平台查询本轮退货出库候选', + query('/api/platform/v1/last-leg/outbound-orders/return/candidates', { + customerId, overseasWarehouseId: warehouse.id, + })) + const outboundCandidate = (Array.isArray(outboundCandidates.data) ? outboundCandidates.data : []) + .find(row => row?.returnNo === returnNo && nonBlank(row?.cartonMark) + && row.cartonMark !== relabelCandidate?.cartonMark && row.status === 'AVAILABLE') + context.assert(MODULE, '退货出库候选排除已冻结换标箱', outboundCandidates.response, + Boolean(outboundCandidate), `cartonMark=${outboundCandidate?.cartonMark || 'missing'}`, OPTIONS) + if (outboundCandidate) { + const outboundBody = { + recipient: `${context.marker} RETURN OUTBOUND`, overseasWarehouseId: warehouse.id, + returnNo, cartonMarks: [outboundCandidate.cartonMark], remark: context.marker, + idempotencyKey: key(context, 'OUTBOUND'), + } + const outbound = await overseas(context, context.overseasToken, 'POST', '平台创建独立退货出库单', + query('/api/platform/v1/last-leg/outbound-orders/return', { customerId }), outboundBody) + const taskNo = id(outbound.data?.taskNo) + const outboundNo = id(outbound.data?.outboundNo) + context.assert(MODULE, 'RETURN_OUTBOUND创建PDA待拣任务', outbound.response, + nonBlank(taskNo) && nonBlank(outboundNo) && outbound.data?.status === 'PENDING', + `outboundNo=${outboundNo || 'missing'}, taskNo=${taskNo || 'missing'}`, OPTIONS) + if (taskNo) { + const task = await overseas(context, picker.token, 'GET', 'PDA读取退货出库任务明细', + `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}`) + const taskItem = task.data?.items?.find?.(row => String(row?.cartonMarks || '').includes(outboundCandidate.cartonMark)) + || task.data?.items?.[0] + const pickBody = { outboundPalletItemId: taskItem?.outboundPalletItemId, + inventoryPositionId: null, packages: 1, cartonMarks: [outboundCandidate.cartonMark], + idempotencyKey: key(context, 'OUTBOUND-PICK') } + const picked = await overseas(context, picker.token, 'POST', 'PDA一次扫描退货箱完成拣货', + `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/pick`, pickBody) + context.assert(MODULE, '退货出库拣货推进为PICKED且任务进行中', picked.response, + picked.data?.outboundNo === outboundNo && picked.data?.status === 'IN_PROGRESS', trace(picked.response), OPTIONS) + const completed = await overseas(context, picker.token, 'POST', 'PDA完成退货出库', + `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/complete`, + { idempotencyKey: key(context, 'OUTBOUND-COMPLETE') }) + const outboundDetail = await overseas(context, context.overseasToken, 'GET', '平台回查退货出库最终状态', + query(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId })) + context.assert(MODULE, 'RETURN_OUTBOUND真实完成且箱唛只出库一次', outboundDetail.response, + completed.data?.status === 'COMPLETED' + && outboundDetail.data?.outbound?.outboundType === 'RETURN_OUTBOUND' + && outboundDetail.data?.outbound?.status === 'OUTBOUNDED' + && outboundDetail.data?.pallets?.some?.(row => row.cartonMarks === outboundCandidate.cartonMark + && Number(row.pickedPackageCount) === 1), trace(outboundDetail.response), OPTIONS) + context.createdResources.phase4OverseasReturnReceipt.returnOutboundNo = outboundNo + } + } + } finally { + await disablePicker(context, picker, 92) + } +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs index f8b2a421d..013e3b62b 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs @@ -1,6 +1,8 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' +import { validationFailure, inventorySnapshot } from './phase4-overseas-return-receipt.mjs' +import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' const moduleUrl = new URL('./phase4-overseas-return-receipt.mjs', import.meta.url) const acceptanceUrl = new URL('../admin-test-env-acceptance.mjs', import.meta.url) @@ -46,3 +48,38 @@ test('HTTP acceptance uses injected clients and never bypasses APIs with databas const source = await readFile(moduleUrl, 'utf8') assert.doesNotMatch(source, /process\.env|https?:\/\/|\bfetch\s*\(|\bmysql\b|\bSELECT\s|\bINSERT\s|\bUPDATE\s/i) }) + +test('receipt scope stops before relabel and outbound; followups are explicit and dependency ordered', async () => { + const source = await readFile(moduleUrl, 'utf8') + assert.doesNotMatch(source, /relabel-candidates|relabel-applications|outbound-orders|outbound-tasks/) + const ordered = normalizeModuleOrder(['phase4-overseas-return-followups']) + assert.ok(ordered.indexOf('phase4-overseas-return-receipt') < ordered.indexOf('phase4-overseas-return-followups')) + assert.ok(!normalizeModuleOrder(['phase4-overseas-return-receipt']).includes('phase4-overseas-return-followups')) +}) + +test('validation predicate rejects outages, permission failures, success and missing diagnostics', () => { + const response = { http: 200, body: { code: 'A0422' }, requestId: 'request', traceId: 'trace' } + assert.equal(validationFailure(response), true) + for (const code of ['200', 'A0403', 'A0409', 'S0500', 'S0503']) { + assert.equal(validationFailure({ ...response, body: { code } }), false) + } + assert.equal(validationFailure({ ...response, traceId: '' }), false) +}) + +test('inventory snapshot ignores ordering but detects quantity, status and source drift', () => { + const a = { cartonMark: 'A', totalQuantity: 2, status: 'AVAILABLE', sourceBatchNo: 'B1', sourceReturnNo: 'R1' } + const b = { ...a, cartonMark: 'B', totalQuantity: 3 } + const baseline = inventorySnapshot([a, b]) + assert.equal(inventorySnapshot({ records: [b, a] }), baseline) + for (const change of [{ totalQuantity: 1 }, { status: 'MERGED' }, { sourceBatchNo: 'B2' }, { sourceReturnNo: 'R2' }]) { + assert.notEqual(inventorySnapshot([{ ...a, ...change }, b]), baseline) + } +}) + +test('extended HTTP coverage includes negative cases and persisted no-mutation verification', async () => { + const source = await readFile(moduleUrl, 'utf8') + for (const label of ['重复预报SKU', '负实收数量', '未预报SKU', '部分签收退货单禁止合箱', + '超可用库存', '失败与幂等重放不改变箱数数量状态及来源', '跨页箱号无重复无遗漏', '合箱及异常后客户SKU仍为甲3乙2']) { + assert.ok(source.includes(label), label) + } +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs index 7a7940830..7af3fa6cb 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs @@ -19,6 +19,18 @@ function notFound(response) { return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response)))) && nonBlank(response?.requestId) && nonBlank(response?.traceId) } +export function validationFailure(response) { + return ((response?.http === 400 || response?.http === 422) + || (response?.http === 200 && ['400', '422', 'A0400', 'A0422'].includes(code(response)))) + && nonBlank(response?.requestId) && nonBlank(response?.traceId) +} + +export function inventorySnapshot(cartons) { + return JSON.stringify(records(cartons).map(row => ({ + cartonMark: row.cartonMark, status: row.status, quantity: Number(row.totalQuantity), + sourceBatchNo: row.sourceBatchNo, sourceReturnNo: row.sourceReturnNo, + })).sort((a, b) => a.cartonMark.localeCompare(b.cartonMark))) +} function records(value) { return Array.isArray(value?.records) ? value.records : Array.isArray(value) ? value : [] } function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${code(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` @@ -128,6 +140,26 @@ export async function run(context) { { ...createBody, remark: `${context.marker}-CHANGED` }, {}, merchantToken) add(context, '退货预报同键异载荷返回冲突', createConflict, conflict(createConflict)) + for (const [name, items] of [ + ['空SKU明细', []], + ['零预报数量', [{ skuCode: skuA, expectedQuantity: 0 }]], + ['负预报数量', [{ skuCode: skuA, expectedQuantity: -1 }]], + ['重复预报SKU', [createBody.items[0], createBody.items[0]]], + ]) { + const response = await context.requestOverseas('POST', RETURN_ORDERS, + { ...createBody, items, idempotencyKey: key(context, `INVALID-CREATE-${name}`) }, {}, merchantToken) + add(context, `拒绝${name}`, response, validationFailure(response)) + } + const onlyOrder = await overseas(context, merchantToken, 'GET', '非法预报后回查本轮订单数量', + query(RETURN_ORDERS, { page: 1, size: 20, skuCode: skuA })) + context.assert(MODULE, '非法预报不新增订单且原单仍为待签收', onlyOrder.response, + Number(onlyOrder.data?.total) === 1 && records(onlyOrder.data)[0]?.returnNo === returnNo + && records(onlyOrder.data)[0]?.status === 'PENDING_RECEIPT', trace(onlyOrder.response), OPTIONS) + + const missingOrder = await context.requestOverseas('POST', `${PDA_RECEIPTS}/start`, + { returnNo: `MISSING-${suffix}` }, {}, picker.token) + add(context, '不存在退货单返回可诊断未找到', missingOrder, notFound(missingOrder)) + const emptySku = await overseas(context, picker.token, 'POST', 'PDA查询不存在SKU返回正常空候选', `${PDA_RECEIPTS}/sku-candidates`, { skuCode: `MISSING-${suffix}` }) context.assert(MODULE, '不存在SKU不是业务错误', emptySku.response, @@ -177,6 +209,23 @@ export async function run(context) { }, {}, picker.token) add(context, '超收被拒绝且不得生成费用批次', overReceipt, conflict(overReceipt)) + for (const [name, changes, expected] of [ + ['零实收数量', { items: [{ skuCode: skuA, receivedQuantity: 0 }] }, validationFailure], + ['负实收数量', { items: [{ skuCode: skuA, receivedQuantity: -1 }] }, validationFailure], + ['空实收明细', { items: [] }, validationFailure], + ['非法签收方式', { startedBy: 'INVALID' }, validationFailure], + ['未预报SKU', { items: [{ skuCode: `UNKNOWN-${suffix}`, receivedQuantity: 1 }] }, conflict], + ]) { + const response = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, + { ...firstBody, ...changes, idempotencyKey: key(context, `INVALID-RECEIPT-${name}`) }, {}, picker.token) + add(context, `拒绝${name}`, response, expected(response)) + } + const earlyMerge = await context.requestOverseas('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', { + returnNos: [returnNo], items: [{ skuCode: skuA, quantity: 1 }], length: 30, width: 20, height: 10, + weight: 1, idempotencyKey: key(context, 'EARLY-MERGE'), + }, {}, picker.token) + add(context, '部分签收退货单禁止合箱', earlyMerge, conflict(earlyMerge)) + const afterPartial = await overseas(context, merchantToken, 'GET', '客户回查部分签收状态', `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`) context.assert(MODULE, '超收失败后订单、签收和费用批次数量均未变化', afterPartial.response, @@ -326,94 +375,50 @@ export async function run(context) { '/api/pda/v1/last-leg/return-inventory/cartons/merge', { ...mergeBody, weight: 3 }, {}, picker.token) add(context, '合箱同键异载荷返回冲突', mergeConflict, conflict(mergeConflict)) + + const beforeInvalid = await overseas(context, merchantToken, 'GET', '冻结合箱异常检查前库存快照', + query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 200, returnNo })) + for (const [name, changes, expected] of [ + ['零箱长', { length: 0 }, validationFailure], + ['负重量', { weight: -1 }, validationFailure], + ['空合箱明细', { items: [] }, validationFailure], + ['零合箱数量', { items: [{ skuCode: skuA, quantity: 0 }] }, validationFailure], + ['超可用库存', { items: [{ skuCode: skuA, quantity: 999 }] }, conflict], + ['不存在来源单', { returnNos: [`MISSING-${suffix}`] }, notFound], + ]) { + const response = await context.requestOverseas('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', + { ...mergeBody, ...changes, idempotencyKey: key(context, `INVALID-MERGE-${name}`) }, {}, picker.token) + add(context, `合箱拒绝${name}`, response, expected(response)) + } + await overseas(context, picker.token, 'POST', '异常检查后再重放原合箱请求', + '/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody) + const afterInvalid = await overseas(context, merchantToken, 'GET', '回查异常及重放后的全部箱库存', + query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 200, returnNo })) + context.assert(MODULE, '失败与幂等重放不改变箱数数量状态及来源', afterInvalid.response, + success(beforeInvalid.response) && success(afterInvalid.response) + && inventorySnapshot(beforeInvalid.data) === inventorySnapshot(afterInvalid.data) + && Number(afterInvalid.data?.total) === 3 + && records(afterInvalid.data).reduce((sum, row) => sum + Number(row.totalQuantity), 0) === 5, + trace(afterInvalid.response), OPTIONS) + const pagedMarks = [] + for (let page = 1; page <= 3; page += 1) { + const result = await overseas(context, merchantToken, 'GET', `实物箱逐页回查第${page}页`, + query(`${RETURN_INVENTORY}/cartons`, { page, size: 1, returnNo })) + context.assert(MODULE, `第${page}页保持总箱数且只有一个箱`, result.response, + Number(result.data?.total) === 3 && records(result.data).length === 1, trace(result.response), OPTIONS) + pagedMarks.push(...records(result.data).map(row => row.cartonMark)) + } + context.assert(MODULE, '跨页箱号无重复无遗漏', afterInvalid.response, + new Set(pagedMarks).size === 3 && JSON.stringify([...pagedMarks].sort()) + === JSON.stringify(records(afterInvalid.data).map(row => row.cartonMark).sort()), trace(afterInvalid.response), OPTIONS) + const merchantSummary = await overseas(context, merchantToken, 'GET', '客户合箱后回查SKU汇总', + query(`${RETURN_INVENTORY}/summary`, { page: 1, size: 20, skuCode: `RET-`, overseasWarehouseId: warehouse.id })) + const quantities = itemMap(records(merchantSummary.data)) + context.assert(MODULE, '合箱及异常后客户SKU仍为甲3乙2', merchantSummary.response, + Number(quantities.get(skuA)?.availableQuantity) === 3 && Number(quantities.get(skuB)?.availableQuantity) === 2, + trace(merchantSummary.response), OPTIONS) } - const relabelCandidates = await overseas(context, merchantToken, 'GET', '客户查询本轮退货换标候选', - `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel-candidates`) - const relabelCandidate = (Array.isArray(relabelCandidates.data) ? relabelCandidates.data : []) - .find(row => nonBlank(row?.cartonMark) && row?.status === 'AVAILABLE') - context.assert(MODULE, '退货换标候选来自本轮已签收可用箱', relabelCandidates.response, - Boolean(relabelCandidate) && relabelCandidate.sourceReturnNo === returnNo, - `cartonMark=${relabelCandidate?.cartonMark || 'missing'}`, OPTIONS) - if (relabelCandidate) { - const relabelBody = { - cartonMarks: [relabelCandidate.cartonMark], idempotencyKey: key(context, 'RELABEL'), - contactName: '退货换标验收', contactPhone: '13800000008', deliveryMethod: 'TRUCK', remark: context.marker, - } - const relabel = await overseas(context, merchantToken, 'POST', '客户从退货箱创建换标单', - `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel`, relabelBody) - const relabelNo = id(relabel.data?.relabelNo) - context.assert(MODULE, '退货换标单绑定原退货单与选中箱', relabel.response, - nonBlank(relabelNo) && relabel.data?.returnNo === returnNo - && relabel.data?.status === 'PENDING_PROCESSING' - && relabel.data?.cartonMarks?.includes?.(relabelCandidate.cartonMark), - `relabelNo=${relabelNo || 'missing'}`, OPTIONS) - if (relabelNo) { - const form = new FormData() - form.append('file', new Blob([new TextEncoder().encode(`%PDF-1.4\n${context.marker}\n%%EOF`)], - { type: 'application/pdf' }), `${suffix}-return-relabel.pdf`) - const uploaded = await context.requestOverseasMultipart('POST', - `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files?scope=CARTON_TOTAL`, - form, {}, merchantToken) - add(context, '客户编辑退货换标总资料', uploaded, success(uploaded)) - const relabelDetail = await overseas(context, merchantToken, 'GET', '客户回查退货换标资料修改', - `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}`) - context.assert(MODULE, '退货换标详情持久化总资料且业务状态未漂移', relabelDetail.response, - relabelDetail.data?.application?.relabelNo === relabelNo - && relabelDetail.data?.application?.status === 'PENDING_PROCESSING' - && nonBlank(relabelDetail.data?.application?.totalFileId), trace(relabelDetail.response), OPTIONS) - context.createdResources.phase4OverseasReturnReceipt.relabelNo = relabelNo - } - } - - const outboundCandidates = await overseas(context, context.overseasToken, 'GET', '平台查询本轮退货出库候选', - query('/api/platform/v1/last-leg/outbound-orders/return/candidates', { - customerId, overseasWarehouseId: warehouse.id, - })) - const outboundCandidate = (Array.isArray(outboundCandidates.data) ? outboundCandidates.data : []) - .find(row => row?.returnNo === returnNo && nonBlank(row?.cartonMark) - && row.cartonMark !== relabelCandidate?.cartonMark && row.status === 'AVAILABLE') - context.assert(MODULE, '退货出库候选排除已冻结换标箱', outboundCandidates.response, - Boolean(outboundCandidate), `cartonMark=${outboundCandidate?.cartonMark || 'missing'}`, OPTIONS) - if (outboundCandidate) { - const outboundBody = { - recipient: `${context.marker} RETURN OUTBOUND`, overseasWarehouseId: warehouse.id, - returnNo, cartonMarks: [outboundCandidate.cartonMark], remark: context.marker, - idempotencyKey: key(context, 'OUTBOUND'), - } - const outbound = await overseas(context, context.overseasToken, 'POST', '平台创建独立退货出库单', - query('/api/platform/v1/last-leg/outbound-orders/return', { customerId }), outboundBody) - const taskNo = id(outbound.data?.taskNo) - const outboundNo = id(outbound.data?.outboundNo) - context.assert(MODULE, 'RETURN_OUTBOUND创建PDA待拣任务', outbound.response, - nonBlank(taskNo) && nonBlank(outboundNo) && outbound.data?.status === 'PENDING', - `outboundNo=${outboundNo || 'missing'}, taskNo=${taskNo || 'missing'}`, OPTIONS) - if (taskNo) { - const task = await overseas(context, picker.token, 'GET', 'PDA读取退货出库任务明细', - `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}`) - const taskItem = task.data?.items?.find?.(row => String(row?.cartonMarks || '').includes(outboundCandidate.cartonMark)) - || task.data?.items?.[0] - const pickBody = { outboundPalletItemId: taskItem?.outboundPalletItemId, - inventoryPositionId: null, packages: 1, cartonMarks: [outboundCandidate.cartonMark], - idempotencyKey: key(context, 'OUTBOUND-PICK') } - const picked = await overseas(context, picker.token, 'POST', 'PDA一次扫描退货箱完成拣货', - `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/pick`, pickBody) - context.assert(MODULE, '退货出库拣货推进为PICKED且任务进行中', picked.response, - picked.data?.outboundNo === outboundNo && picked.data?.status === 'IN_PROGRESS', trace(picked.response), OPTIONS) - const completed = await overseas(context, picker.token, 'POST', 'PDA完成退货出库', - `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/complete`, - { idempotencyKey: key(context, 'OUTBOUND-COMPLETE') }) - const outboundDetail = await overseas(context, context.overseasToken, 'GET', '平台回查退货出库最终状态', - query(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId })) - context.assert(MODULE, 'RETURN_OUTBOUND真实完成且箱唛只出库一次', outboundDetail.response, - completed.data?.status === 'COMPLETED' - && outboundDetail.data?.outbound?.outboundType === 'RETURN_OUTBOUND' - && outboundDetail.data?.outbound?.status === 'OUTBOUNDED' - && outboundDetail.data?.pallets?.some?.(row => row.cartonMarks === outboundCandidate.cartonMark - && Number(row.pickedPackageCount) === 1), trace(outboundDetail.response), OPTIONS) - context.createdResources.phase4OverseasReturnReceipt.returnOutboundNo = outboundNo - } - } } finally { await disablePicker(context, picker, 91) } diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 2a9b71af6..a05c7d076 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -300,8 +300,8 @@ Assert-Contains $adminDeclarationEditor '仓库/PDA 实测数据不会被覆盖' Assert-Contains $businessIndex '统一列表仍固定显示 `PENDING_REVIEW` 并提供审核动作' '根待审核订单优先状态' Assert-Contains $orderAgents '统一主状态/下一动作仍固定为 `PENDING_REVIEW/APPROVE_ORDER`' 'Order 待审核优先状态' Assert-Contains $unifiedOrderQueryService 'order.getStatus() == OrderStatus.SUBMITTED' 'Order 待审核状态判断' -Assert-Contains $unifiedOrderQueryService 'pendingReview ? "PENDING_REVIEW" : summary.mainStatus()' 'Order 待审核主状态优先' -Assert-Contains $unifiedOrderQueryService 'pendingReview ? "APPROVE_ORDER" : summary.nextAction()' 'Order 待审核动作优先' +Assert-Contains $unifiedOrderQueryService 'pendingReview ? "PENDING_REVIEW" : completed ? "COMPLETED" : summary.mainStatus()' 'Order 待审核主状态优先于完成态与履约投影' +Assert-Contains $unifiedOrderQueryService 'pendingReview ? "APPROVE_ORDER" : completed ? null : summary.nextAction()' 'Order 待审核动作优先且完成态无下一动作' # 批量审核、客户专属价格簿和柜级阶梯计价已经成为稳定跨端能力,必须从根表直达且锁住服务端权威规则。 $batchReviewPreviewService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/BatchOrderReviewPreviewService.java') @@ -558,7 +558,7 @@ Assert-Matches $overseasWorkbench "(?s)name:\s*'托盘管理'.{0,300}?available: Assert-NotContains $overseasWorkbench "key: 'stocktake'" 'Overseas PDA 隐藏库存盘点入口' foreach ($enabledOperation in @( @{ Name = '商品移库'; Route = '/pages/move/move' }, - @{ Name = '换标收货'; Route = '/pages/relabel/relabel\?op=relabelReceive' }, + @{ Name = '换标列表'; Route = '/pages/relabel/relabel\?op=relabelReceive' }, @{ Name = '换标执行'; Route = '/pages/relabel/relabel\?op=relabelExecute' }, @{ Name = '换标复核'; Route = '/pages/relabel/relabel\?op=relabelReview' }, @{ Name = '库存查询'; Route = '/pages/query/query\?op=inventoryQuery' },