From fcf17719679c68bed2b9b950509b32fd623c87f1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 7 Sep 2026 12:30:17 +0800 Subject: [PATCH 01/26] fix: restore overseas list queries and identifiers --- deploy/sql/海外区域统一增量-MySQL8.sql | 7 +++++ .../delivery/dto/DeliveryRequests.java | 6 ----- .../lastleg/dto/LastlegOrderSummary.java | 1 + .../inboundplan/dto/InboundPlanRequests.java | 9 ------- .../service/LocalLooseboardService.java | 2 +- .../service/RelabelApplicationService.java | 2 +- .../lastleg/service/LastlegOrderService.java | 10 +++++-- .../src/main/resources/db/lastleg.sql | 15 ++++++++--- .../openapi/overseas-business-v1.yaml | 2 +- .../lastleg/QueryBindingContractTest.java | 16 ++++++++++++ .../MerchantDeliveryControllerTest.java | 2 +- .../controller/InboundPlanControllerTest.java | 2 +- .../service/InboundPlanPaginationTest.java | 4 +-- .../service/InboundPlanServiceTest.java | 2 +- .../service/LocalLooseboardServiceTest.java | 2 ++ ...rchantOverseasCustomerApiContractTest.java | 2 +- .../lastleg/relabel/RelabelContractTest.java | 9 +++++++ .../RelabelApplicationServiceTest.java | 7 ++--- .../service/LastlegOrderServiceTest.java | 26 +++++++++++++++++++ 19 files changed, 93 insertions(+), 33 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/QueryBindingContractTest.java diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index d2bf626c5..52b004e03 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -517,6 +517,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'ALTER TABLE ll_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type')); PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt; +ALTER TABLE ll_order + MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT '真实来源入仓码;无入仓关系的海外本土业务为空'; CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence ( business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)', @@ -1754,6 +1756,11 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='wallet_error_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_error_code VARCHAR(64) NULL COMMENT ''资金错误码'' AFTER wallet_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='wallet_error_message'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_error_message VARCHAR(500) NULL COMMENT ''资金错误说明'' AFTER wallet_error_code')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='wallet_command_event_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_command_event_id VARCHAR(64) NULL COMMENT ''当前资金指令事件ID'' AFTER wallet_error_message')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='remark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''货物明细备注'' AFTER label_requirement')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN carton_mark VARCHAR(255) NULL COMMENT ''客户自有箱唛'' AFTER relabel_file_url')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''关联FBA箱唛'' AFTER carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''该箱目的海外仓ID'' AFTER fba_carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_destination'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id)')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java index 29bb07ab8..5b78e009d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -41,12 +41,6 @@ public final class DeliveryRequests { String driverName, String deliveryMethod, LocalDateTime plannedFrom, LocalDateTime plannedTo) { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } - public Query(long page, long size, String documentNo, String warehouseId, String status, - String dispatchNo, String waybillNo, String vehiclePlate, String driverName, - String deliveryMethod, LocalDateTime plannedFrom, LocalDateTime plannedTo) { - this(page, size, documentNo, null, warehouseId, status, dispatchNo, waybillNo, vehiclePlate, - driverName, deliveryMethod, plannedFrom, plannedTo); - } } public record PalletQuery(@Min(1) long page, @Min(1) @Max(200) long size, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index 74f5b8425..f87cf6c36 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -15,6 +15,7 @@ public record LastlegOrderSummary( @Schema(description = "跨区冻结目的类型", allowableValues = {"FBA", "OVERSEAS"}) String destinationType, String sourceInboundId, + @Schema(description = "真实来源入仓码;无入仓关系的海外本土业务为空") String inboundCode, String lxCode, String sourceOrderNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java index 7e36da893..a1c844d0d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java @@ -28,15 +28,6 @@ public final class InboundPlanRequests { LocalDateTime createdFrom, LocalDateTime createdTo ) { - public Query(long page, long size, String planNo, String inboundCode, String status, - String overseasWarehouseId, String destinationWarehouseCode, String warehouse, - String fbaWarehouseCode, String appointmentStatus, String dispatchStatus, - LocalDateTime createdFrom, LocalDateTime createdTo) { - this(page, size, planNo, inboundCode, null, status, overseasWarehouseId, - destinationWarehouseCode, warehouse, fbaWarehouseCode, appointmentStatus, - dispatchStatus, createdFrom, createdTo); - } - public Query { if (page == 0) page = 1; if (size == 0) size = 20; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java index 105563c63..30b6936be 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java @@ -268,7 +268,7 @@ public class LocalLooseboardService { order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", source.getDestination(), order.getId())); order.setCustomerId(source.getCustomerId()); order.setOrderType("LOCAL_LOOSEBOARD"); - order.setInboundCode(source.getOrderNo()); + order.setInboundCode(null); order.setSourceRegion("OVERSEAS_LOCAL"); order.setSourceInboundId("LOCAL_LOOSEBOARD:" + source.getId()); order.setSourceOrderId(String.valueOf(source.getId())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 7373256ff..d28f8dcd6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -287,7 +287,7 @@ public class RelabelApplicationService implements WalletResultHandler { order.setId(IdWorker.getId()); order.setOrderNo(orderNoGenerator.next("RELABEL", warehouseId, order.getId())); order.setCustomerId(customerId); order.setOrderType("RELABEL_APPLICATION"); - order.setInboundCode(relabelNo); order.setSourceRegion("CUSTOMER"); + order.setInboundCode(null); order.setSourceRegion("CUSTOMER"); order.setSourceInboundId(relabelNo + ":" + warehouseId); order.setSourceOrderNo(request.customerOrderNo().trim()); order.setOverseasWarehouseId(warehouseId); order.setPalletCount(0); order.setPackageCount(packageCount); order.setPickedPackageCount(0); 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 43a6569e4..ce14c46de 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 @@ -358,7 +358,7 @@ public class LastlegOrderService { for (LastlegOrder order : orders) { String line = String.join(",", List.of( csv(order.getId()), csv(order.getOrderNo()), csv(LastlegOrderType.labelOf(order.getOrderType())), - csv(order.getInboundCode()), + csv(displayInboundCode(order)), csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()), csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()), csv(order.getContainerNo()), csv(order.getContainerStatus()), csv(order.getAbnormal()), csv(order.getDetained()), @@ -433,7 +433,7 @@ public class LastlegOrderService { List related = relations == null ? List.of() : relations; return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(), LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), - o.getInboundCode(), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(), + displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(), o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(), o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), @@ -445,6 +445,12 @@ public class LastlegOrderService { o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt()); } + private static String displayInboundCode(LastlegOrder order) { + String orderType = order.getOrderType(); + return "RELABEL_APPLICATION".equals(orderType) || "LOCAL_LOOSEBOARD".equals(orderType) + ? null : order.getInboundCode(); + } + private static String csv(Object value) { if (value == null) return ""; String text = String.valueOf(value); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 46f4de3eb..c5c0c55dc 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -48,7 +48,7 @@ CREATE TABLE IF NOT EXISTS ll_order ( order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型:FIRST_LEG头程订单、RELABEL_APPLICATION换标下单、WAREHOUSE_TRANSFER仓储转运、LOCAL_LOOSEBOARD本土散板、RETURN_RELABEL退货换标、ONE_CLICK_DROPSHIP一件代发', lx_code VARCHAR(128) NULL COMMENT '一件代发领星入库单代码', destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', - inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码', + inbound_code VARCHAR(128) NULL COMMENT '真实来源入仓码;无入仓关系的海外本土业务为空', source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', root_inbound_id VARCHAR(128) COMMENT '根入仓单 ID', @@ -349,7 +349,7 @@ ALTER TABLE ll_order MODIFY COLUMN order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', MODIFY COLUMN order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型', - MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码', + MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT '真实来源入仓码;无入仓关系的海外本土业务为空', MODIFY COLUMN source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', MODIFY COLUMN root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', @@ -1465,13 +1465,20 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item ( relabel_file_url VARCHAR(1000) NULL COMMENT '换标文件地址', carton_mark VARCHAR(255) NULL COMMENT '客户自有箱唛', fba_carton_mark VARCHAR(255) NULL COMMENT '关联FBA箱唛', - destination_warehouse_id VARCHAR(128) NOT NULL COMMENT '该箱目的海外仓ID', + destination_warehouse_id VARCHAR(128) NULL COMMENT '该箱目的海外仓ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), KEY idx_ll_relabel_item_application (customer_id,application_id), - KEY idx_ll_relabel_item_sku (customer_id,sku_code) + KEY idx_ll_relabel_item_sku (customer_id,sku_code), + KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id) ) ENGINE=InnoDB COMMENT='换标货物冻结明细'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='remark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''货物明细备注'' AFTER label_requirement')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN carton_mark VARCHAR(255) NULL COMMENT ''客户自有箱唛'' AFTER relabel_file_url')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''关联FBA箱唛'' AFTER carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''该箱目的海外仓ID'' AFTER fba_carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_destination'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id)')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + CREATE TABLE IF NOT EXISTS ll_relabel_status_history ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户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 adcd5ee27..96e0b554a 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -166,7 +166,7 @@ paths: - { name: detained, in: query, schema: { type: boolean } } - { name: createdFrom, in: query, schema: { type: string, format: date-time } } - { name: createdTo, in: query, schema: { type: string, format: date-time } } - responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS } } + responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS;inboundCode 仅返回真实来源入仓码,无入仓关系的换标申请和本土散板为空 } } /api/platform/v1/last-leg/orders/{id}: get: summary: 查询尾程订单详情 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/QueryBindingContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/QueryBindingContractTest.java new file mode 100644 index 000000000..911464195 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/QueryBindingContractTest.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg; + +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class QueryBindingContractTest { + @Test + void mvcQueryRecordsExposeOneResolvableConstructor() { + assertDoesNotThrow(() -> BeanUtils.getResolvableConstructor(DeliveryRequests.Query.class)); + assertDoesNotThrow(() -> BeanUtils.getResolvableConstructor(InboundPlanRequests.Query.class)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java index 6c4e33bd2..91e429644 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java @@ -34,7 +34,7 @@ class MerchantDeliveryControllerTest { private final DeliveryTransferService service = mock(DeliveryTransferService.class); private final MerchantDeliveryController controller = controller(); private final DeliveryRequests.Query query = new DeliveryRequests.Query( - 1, 20, null, null, null, null, null, null, null, null, null, null); + 1, 20, null, null, null, null, null, null, null, null, null, null, null); @Test void fixedRoutesUseIndependentListAndCreatePermissionsWithoutCustomerParameter() throws Exception { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java index ab9ad7916..5bed91126 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java @@ -41,7 +41,7 @@ class InboundPlanControllerTest { @Test void platformPageAllowsAllCustomersWhenCustomerIdIsOmitted() { InboundPlanRequests.Query query = new InboundPlanRequests.Query(1, 20, null, null, null, - null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null); when(service.page(null, query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); controller.page(platform(), null, query); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java index 3fdd7c816..633e2bf3e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java @@ -45,7 +45,7 @@ class InboundPlanPaginationTest { @Test void pagesAtLineGranularityAndPassesTenantAndAllFiltersToDatabase() { InboundPlanRequests.Query query = new InboundPlanRequests.Query(2, 10, "LIP", "IN-1", - "PENDING_ASSIGNMENT", null, null, "ONT", "ONT8", "UNAPPOINTED", "UNSCHEDULED", + null, "PENDING_ASSIGNMENT", null, null, "ONT", "ONT8", "UNAPPOINTED", "UNSCHEDULED", LocalDateTime.of(2026, 8, 1, 0, 0), LocalDateTime.of(2026, 8, 31, 23, 59)); InboundPlanListResponse row = new InboundPlanListResponse(9007199254740993L, 2L, "LIP2", "100", 3L, "IN-1", "FIRST_LEG", "W1", "ONT", "安大略仓", "ONT8", "客户A", 10, @@ -78,7 +78,7 @@ class InboundPlanPaginationTest { } private static InboundPlanRequests.Query query(long page, long size) { - return new InboundPlanRequests.Query(page, size, null, null, null, null, null, + return new InboundPlanRequests.Query(page, size, null, null, null, null, null, null, null, null, null, null, null, null); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index 1e52b7615..60ed38933 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -151,7 +151,7 @@ class InboundPlanServiceTest { @Test void rejectsReversedInboundPlanDateRange() { InboundPlanRequests.Query query = new InboundPlanRequests.Query(1, 20, null, null, null, null, - null, null, null, null, null, LocalDateTime.of(2026, 8, 20, 0, 0), + null, null, null, null, null, null, LocalDateTime.of(2026, 8, 20, 0, 0), LocalDateTime.of(2026, 8, 19, 0, 0)); assertThrows(ResponseStatusException.class, () -> service.page("100", query)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java index d5460b792..cc5d4f4aa 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java @@ -87,6 +87,8 @@ class LocalLooseboardServiceTest { assertEquals("SHARED_TRUCK", detail.summary().transportMode()); verify(lastlegOrders).insert(org.mockito.ArgumentMatchers.argThat(value -> "LOCAL_LOOSEBOARD".equals(value.getOrderType()) + && value.getSourceOrderNo().equals("LB-WH-20260901-000001") + && value.getInboundCode() == null && "LOCAL_LOOSEBOARD:PENDING_ACCEPTANCE".equals(value.getSubOrderStatus()))); verify(histories).insert(org.mockito.ArgumentMatchers.argThat(value -> "CREATE".equals(value.getOperationType()))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java index 70f644340..76efc56d0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java @@ -60,7 +60,7 @@ class MerchantOverseasCustomerApiContractTest { LocalLooseboardRequests.MerchantQuery looseboardQuery = new LocalLooseboardRequests.MerchantQuery(1, 20, null, null, null, null, null, null); InboundPlanRequests.Query inboundQuery = new InboundPlanRequests.Query(1, 20, null, null, null, - null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null); looseboard.page(merchant, looseboardQuery); inbound.page(merchant, inboundQuery); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java index 82af72c70..741598a1b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java @@ -28,6 +28,8 @@ class RelabelContractTest { void schemasDefineRelabelApplicationItemsAndHistoryContracts() throws Exception { String baseline = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8).toLowerCase(); String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase(); + String deployment = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), + StandardCharsets.UTF_8).toLowerCase(); for (String schema : new String[] { baseline, migration }) { assertTrue(schema.contains("ll_relabel_application")); assertTrue(schema.contains("ll_relabel_item")); @@ -36,6 +38,13 @@ class RelabelContractTest { assertTrue(schema.contains("idempotency_key")); assertTrue(schema.contains("request_hash")); } + for (String schema : new String[] { baseline, migration, deployment }) { + assertTrue(schema.contains("ll_relabel_item")); + assertTrue(schema.contains("remark")); + assertTrue(schema.contains("carton_mark")); + assertTrue(schema.contains("fba_carton_mark")); + assertTrue(schema.contains("destination_warehouse_id")); + } } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index d80d9e66c..20f31281f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -32,6 +32,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -99,9 +100,9 @@ class RelabelApplicationServiceTest { assertEquals("RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus()); assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion()); assertTrue(tailOrder.getValue().getOrderNo().matches("RL-WH-\\d{8}-\\d{6}")); - assertEquals(tailOrder.getValue().getInboundCode() + ":WH-1", tailOrder.getValue().getSourceInboundId()); - assertEquals(tailOrder.getValue().getInboundCode(), application.getValue().getRelabelNo()); - assertTrue(tailOrder.getValue().getInboundCode().startsWith("RL-")); + assertNull(tailOrder.getValue().getInboundCode()); + assertEquals(application.getValue().getRelabelNo() + ":WH-1", tailOrder.getValue().getSourceInboundId()); + assertEquals(tailOrder.getValue().getRelabelOrderNo(), application.getValue().getRelabelNo()); assertEquals(10, tailOrder.getValue().getPackageCount()); assertEquals(6, application.getValue().getNotifiedPackageCount()); assertEquals("SKU-A", relabelItem.getValue().getSkuCode()); 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 3031783ff..882dad619 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 @@ -28,6 +28,7 @@ import java.time.LocalDateTime; import java.math.BigDecimal; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -306,6 +307,31 @@ class LastlegOrderServiceTest { assertEquals(List.of("INBOUND-2", "INBOUND-3"), result.records().get(0).palletMergeInboundNos()); } + @Test + void pageDoesNotExposeBusinessOrderNumbersAsInboundCodes() { + LastlegOrder relabel = order(11L, "100", "PENDING_ASSIGNMENT", null); + relabel.setOrderType("RELABEL_APPLICATION"); + relabel.setInboundCode("RL-WH-20260906-701570"); + LastlegOrder looseboard = order(12L, "100", "PENDING_ASSIGNMENT", null); + looseboard.setOrderType("LOCAL_LOOSEBOARD"); + looseboard.setInboundCode("LB-WH-20260906-187457"); + LastlegOrder firstleg = order(13L, "100", "PENDING_ASSIGNMENT", null); + firstleg.setOrderType("FIRST_LEG"); + firstleg.setInboundCode("NYEXX2609060063"); + var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page(1, 10); + resultPage.setRecords(List.of(relabel, looseboard, firstleg)); + resultPage.setTotal(3); + when(orders.selectPage(any(), any())).thenReturn(resultPage); + var query = new LastlegOrderRequests.Query(1L, 10L, null, null, null, null, + null, null, null, null, null, null, null, null, null, null); + + var result = service.page(null, query); + + assertNull(result.records().get(0).inboundCode()); + assertNull(result.records().get(1).inboundCode()); + assertEquals("NYEXX2609060063", result.records().get(2).inboundCode()); + } + @Test void holdingBeforeInboundPersistsTheOrderMarkerAndOperationRemark() { LastlegOrder order = order(11L, "100", "PENDING_INBOUND", null); From 2c5fda33e77136dc0af2cfc700e40d79c95e3f60 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 7 Sep 2026 14:03:00 +0800 Subject: [PATCH 02/26] fix: complete overseas fulfillment acceptance --- .../service/DeliveryTransferService.java | 12 +++++++++++- .../service/DeliveryTransferServiceTest.java | 19 +++++++++++++++++++ ...overseas-client-services-contract.test.mjs | 2 ++ .../phase4-overseas-client-services.mjs | 2 ++ ...-overseas-pda-operations-contract.test.mjs | 3 ++- .../phase4-overseas-pda-operations.mjs | 4 +++- ...s-wallet-and-pallet-cost-contract.test.mjs | 2 ++ ...phase4-overseas-wallet-and-pallet-cost.mjs | 4 +++- 8 files changed, 44 insertions(+), 4 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 2d139ea87..de4652049 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -128,7 +128,7 @@ public class DeliveryTransferService { outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", dispatch.getOverseasWarehouseId(), outbound.getId())); outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo()); outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); outbound.setOutboundType("DISPATCH_DELIVERY"); - outbound.setLxCodes(items.stream().map(DispatchItem::getInboundCode).filter(StringUtils::hasText).map(code -> tailOrders.selectOne(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getInboundCode, code))).filter(Objects::nonNull).map(LastlegOrder::getLxCode).filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining(","))); + outbound.setLxCodes(resolveLxCodes(customerId, items)); outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); outbound.setPalletCount(items.size()); outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); @@ -562,6 +562,16 @@ public class DeliveryTransferService { private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); } private LastlegOrder firstTailOrder(String customerId, List items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); } + private String resolveLxCodes(String customerId, List items) { + List inboundCodes = items.stream().map(DispatchItem::getInboundCode) + .filter(StringUtils::hasText).map(String::trim).distinct().toList(); + if (inboundCodes.isEmpty()) return ""; + return tailOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId) + .in(LastlegOrder::getInboundCode, inboundCodes)) + .stream().map(LastlegOrder::getLxCode).filter(StringUtils::hasText) + .map(String::trim).distinct().collect(java.util.stream.Collectors.joining(",")); + } private BigDecimal totalWeight(String customerId, List items) { List ids = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) return BigDecimal.ZERO; return tailOrderDetails.selectList(new LambdaQueryWrapper().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, ids)).stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); } private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } private static String required(String value, String message) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index be897d926..45efda206 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -140,6 +140,25 @@ class DeliveryTransferServiceTest { assertTrue(!result.replayed()); } + @Test + void transferCollectsLxCodesWhenAnInboundCodeBelongsToMultipleTailOrders() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( + item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12))); + LastlegOrder primary = new LastlegOrder(); + primary.setCustomerId("TENANT-A"); primary.setInboundCode("IN-A"); primary.setLxCode("LX-1"); + LastlegOrder derived = new LastlegOrder(); + derived.setCustomerId("TENANT-A"); derived.setInboundCode("IN-A"); derived.setLxCode(null); + when(tailOrders.selectList(any())).thenReturn(List.of(primary, derived), List.of()); + + service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-duplicate-inbound", null)); + + ArgumentCaptor outbound = ArgumentCaptor.forClass(OutboundOrder.class); + verify(outboundOrders).insert(outbound.capture()); + assertEquals("LX-1", outbound.getValue().getLxCodes()); + } + @Test void sameIdempotencyKeyReturnsExistingDocumentsWithoutWrites() { DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs index 7c1e3fb22..69bc6e35d 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs @@ -30,6 +30,8 @@ test('module never borrows historical order or inventory candidates', () => { test('relabel transfer and discard cover create replay conflict list detail and legal initial states', () => { assert.match(source, /chargeAmount: 0\.01/) + assert.match(source, /cartonMark:/) + assert.match(source, /fbaCartonMark:/) for (const token of [ '客户创建贴标申请', '客户贴标申请幂等重放', '贴标同幂等键异载荷返回A0409', '客户查询自身贴标申请列表', '客户查询自身贴标申请详情', 'PENDING_PROCESSING', diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs index 3b01ca857..c9490fe62 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs @@ -101,6 +101,8 @@ async function verifyRelabel(context, token, order) { shipmentNo: cargo.shipmentNo || `${context.marker}-RELABEL-SHIP`, trackingCode: cargo.fbaTrackingNo || null, newSkuCode: `${cargo.skuCode}-NEW`.slice(0, 128), labelRequirement: context.marker, relabelFileId: null, relabelFileName: null, relabelFileUrl: null, + cartonMark: `CM-${String(context.marker).slice(-32)}`, + fbaCartonMark: `FBA-${String(context.marker).slice(-32)}`, }], } const created = await merchant(context, token, 'POST', '客户创建贴标申请', RELABEL, body) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs index 440fdc203..28fb043bb 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs @@ -46,7 +46,8 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho ]) assert.ok(source.includes(endpoint), endpoint) for (const marker of ['按原数计数', '同键重放', '同键异载荷A0409', '旧版本A0409', 'SUBMITTED', '移回原库位', 'WAIT_REVIEW', 'REWORK', 'APPROVE', 'COMPLETED', - 'chargeAmount: 0.01', 'P5换标费用冻结后开放PDA作业', 'P5换标完成后钱包结算收敛']) { + 'chargeAmount: 0.01', 'cartonMark:', 'fbaCartonMark:', + 'P5换标费用冻结后开放PDA作业', 'P5换标完成后钱包结算收敛']) { assert.ok(source.includes(marker), marker) } for (const forbidden of ['/outbound', '/returns/outbound', '/dispatch', '/delivery', '/internal/']) { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs index 7044ab273..3a173ffd3 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -333,7 +333,9 @@ async function verifyRelabel(context, token, fixture) { idempotencyKey: key(context, 'RELABEL-CREATE'), items: [{ skuCode: inventory.skuCode, productNameZh: inventory.productName || '本轮库存商品', totalPackageCount: 3, notifiedPackageCount: 3, shipmentNo: `SHIP-${context.marker}`.slice(0, 128), - newSkuCode: newSku, labelRequirement: context.marker }], + newSkuCode: newSku, labelRequirement: context.marker, + cartonMark: `CM-${String(context.marker).slice(-32)}`, + fbaCartonMark: `FBA-${String(context.marker).slice(-32)}` }], } const createdResponse = await context.requestOverseas('POST', query(relabelBase, { customerId }), createBody) diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs index c9f633df0..e6027aaed 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs @@ -30,6 +30,8 @@ test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释 "terminalStatus === 'COMPLETED'", "'CANCELLED'", 'relabelPayload(context, warehouseId, suffix, 0.01)', + 'cartonMark:', + 'fbaCartonMark:', ]) assert.ok(source.includes(token), token) assert.match(source, /actualBalance[\s\S]*frozenBalance[\s\S]*availableBalance[\s\S]*creditLimit[\s\S]*creditUsed[\s\S]*availableCredit/) assert.match(source, /voucherFileId: id\(files\.find\(\(item\) => item\.category === 'VOUCHER'\)\?\.fileId\)/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs index 761526154..93e37ce3f 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs @@ -178,7 +178,9 @@ function relabelPayload(context, warehouseId, suffix, chargeAmount) { remark: context.marker, idempotencyKey: `${context.marker}-${suffix}`.slice(0, 255), items: [{ skuCode: `OLD-${value}-${suffix}`.slice(0, 128), productNameZh: `${context.marker} 换标商品`, totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: `SHIP-${value}-${suffix}`.slice(0, 128), - newSkuCode: `NEW-${value}-${suffix}`.slice(0, 128), labelRequirement: context.marker }], + newSkuCode: `NEW-${value}-${suffix}`.slice(0, 128), labelRequirement: context.marker, + cartonMark: `CM-${value}-${suffix}`.slice(0, 255), + fbaCartonMark: `FBA-${value}-${suffix}`.slice(0, 255) }], } } From 693e10188c89da9c5df133ec567235bc2abb9ca2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 7 Sep 2026 14:22:26 +0800 Subject: [PATCH 03/26] fix: refresh overseas authorization on user status change --- .../java/com/baiyue/iam/service/MemberService.java | 2 ++ .../iam/service/PlatformInternalUserService.java | 2 ++ .../iam/service/PlatformUserManagementService.java | 2 ++ .../com/baiyue/iam/service/MemberServiceTest.java | 2 ++ .../service/PlatformInternalUserServiceTest.java | 13 +++++++++++++ .../service/PlatformUserManagementServiceTest.java | 14 ++++++++++++++ 6 files changed, 35 insertions(+) diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index 46813a762..43f79000e 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -170,6 +170,8 @@ public class MemberService { membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); user.setStatus(request.status()); repository.saveUser(user); repository.save(membership); securityEvents.userStatus(user.getAuthUserId(), tenantId, "MERCHANT", request.status()); + securityEvents.permissionChanged(user.getAuthUserId(), tenantId, "MERCHANT", + "MEMBER_STATUS_CHANGED"); } catch (RuntimeException localFailure) { try { authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldStatus)); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java index 032898b2c..8f94a889d 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java @@ -147,6 +147,8 @@ public class PlatformInternalUserService { repository.saveUser(user); repository.save(membership); securityEvents.userStatus(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", request.status()); + securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", + "PLATFORM_INTERNAL_USER_STATUS_CHANGED"); return response(membership, user, authAccount.username(), roleIds); } catch (RuntimeException localFailure) { compensateStatus(user.getAuthUserId(), authAccount.status(), localFailure); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index a277858ae..fa73ece19 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -203,6 +203,8 @@ public class PlatformUserManagementService { members.save(membership); securityEvents.userStatus(user.getAuthUserId(), membership.getTenantId(), appCode(membership.getTenantId()), request.status()); + securityEvents.permissionChanged(user.getAuthUserId(), membership.getTenantId(), + appCode(membership.getTenantId()), "PLATFORM_USER_STATUS_CHANGED"); } catch (RuntimeException localFailure) { call(() -> authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldAccount.status()))); diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index 29f97b03f..b190038a9 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -106,6 +106,8 @@ class MemberServiceTest { assertEquals("DISABLED", result.status()); verify(repository).save(member); verify(repository).saveUser(user); + verify(securityEvents).userStatus(40L, 100L, "MERCHANT", "DISABLED"); + verify(securityEvents).permissionChanged(40L, 100L, "MERCHANT", "MEMBER_STATUS_CHANGED"); } @Test void shouldHandleCase05() { diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java index 6e8da47c5..ede6e98a7 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java @@ -142,6 +142,19 @@ class PlatformInternalUserServiceTest { verify(auth, never()).status(anyString(), anyLong(), any()); } + @Test + void statusChangePublishesMatchingAuthorizationSnapshot() { + when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); + + InternalUserResponse result = service.changeStatus(30L, + new ChangeInternalUserStatusRequest("DISABLED"), 99L); + + assertEquals("DISABLED", result.status()); + verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "DISABLED"); + verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", + "PLATFORM_INTERNAL_USER_STATUS_CHANGED"); + } + @Test void shouldHandleCase05() { when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index 606955222..67b7cfc39 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -224,6 +224,20 @@ class PlatformUserManagementServiceTest { verify(auth, never()).status(anyString(), anyLong(), any()); } + @Test + void statusChangePublishesMatchingAuthorizationSnapshot() { + when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); + when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(10L, "operator", "平台运营", "ACTIVE"))); + when(members.findRoleIds(0L, 30L)).thenReturn(List.of(40001L)); + + UserResponse result = service.changeStatus(30L, new ChangeStatusRequest("ACTIVE"), 99L); + + assertEquals("ACTIVE", result.status()); + verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "ACTIVE"); + verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", "PLATFORM_USER_STATUS_CHANGED"); + } + @Test void builtInPlatformAdministratorCannotBeDisabled() { IamRole platformAdmin = new IamRole(); From 1b47158770298f3d037ea00491d3ec1098445ca7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 7 Sep 2026 15:07:34 +0800 Subject: [PATCH 04/26] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 +- deploy/sql/海外区域统一增量-MySQL8.sql | 66 +++++++++++++ .../controller/LastlegOrderController.java | 14 +++ .../mapper/DispatchCandidateMapper.java | 16 +++- .../dispatch/service/DispatchService.java | 12 ++- .../dto/ContainerPlanProgressSummary.java | 16 ++++ .../lastleg/dto/LastlegOrderRequests.java | 6 ++ .../lastleg/dto/LastlegOrderSummary.java | 1 + .../baiyue/lastleg/entity/LastlegOrder.java | 2 + .../entity/ReceivingWarehouseOperation.java | 21 +++++ .../dto/InboundPlanListResponse.java | 18 +--- .../inboundplan/dto/InboundPlanResponse.java | 18 +--- .../mapper/InboundPlanOrderMapper.java | 14 ++- .../mapper/InboundPlanQueryMapper.java | 3 +- .../model/SourceOrderSnapshot.java | 25 +---- .../pda/dto/ReceivingContainerResponses.java | 32 ++++++- .../pda/entity/ReceivingContainerSession.java | 1 + .../service/ReceivingContainerService.java | 52 ++++------- .../service/InboundPlanService.java | 20 +++- .../lastleg/mapper/LastlegOrderMapper.java | 33 +++++-- .../ReceivingWarehouseOperationMapper.java | 13 +++ .../lastleg/service/LastlegOrderService.java | 93 ++++++++++++++++++- .../service/LoadingCompletedConsumer.java | 8 +- .../src/main/resources/db/lastleg.sql | 30 ++++-- .../openapi/overseas-business-v1.yaml | 86 +++++++++++++++-- .../DispatchCandidateMapperContractTest.java | 14 +++ .../dispatch/service/DispatchServiceTest.java | 15 +++ .../InboundPlanOrderMapperContractTest.java | 16 ++++ ...ReceivingContainerServiceBehaviorTest.java | 12 +-- .../service/InboundPlanPaginationTest.java | 12 ++- .../service/InboundPlanServiceTest.java | 50 +++++++++- .../MerchantOverseasOpenApiContractTest.java | 6 ++ .../SchemaChineseCommentContractTest.java | 9 +- .../ContainerPlanProgressContractTest.java | 4 +- .../service/LastlegOrderServiceTest.java | 52 ++++++++++- .../service/LoadingCompletedConsumerTest.java | 2 +- scripts/agents-index-contract.ps1 | 34 ++++++- 37 files changed, 688 insertions(+), 144 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/entity/ReceivingWarehouseOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/ReceivingWarehouseOperationMapper.java diff --git a/AGENTS.md b/AGENTS.md index 92f34dace..bd585b504 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ | 任务关键词 | 首选服务 | 先看入口 | |---|---|---| -| 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` | +| 登录、Token、密码、注册与顶层授权快照 | `auth/` | `auth/AGENTS.md`;海外 Token 只携带稳定身份与版本,角色、权限和冻结海外仓从登录/刷新响应顶层快照读取,海外 Gateway 再按 IAM 安全投影失败关闭校验 | | 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` | | 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 | | 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 | @@ -26,7 +26,8 @@ | 客户头程订单中文批量导入 | `order/` | `MerchantBatchImportService.java`、`MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`、`order-v1.yaml`;模板和导入都按当前客户读取动态地址、全部有效仓库与渠道,最多 50 行,只创建草稿,支持 `PARTIAL/ATOMIC` | | 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 | | 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md`;客户四档箱规与 `ADD/MULTIPLY` 默认算法从 `CustomerDimensionConfigService` 进入,单箱测量和最终计价分别由 `CustomerDimensionWeightingService`、`FbaPricingService` 保存并消费服务端快照 | -| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | Firstleg `MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。首张称重确认分配整票块,测量提交只复用;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 | +| 入库扣货、解除、履约门禁与凭证 | `firstleg/`、`file/` | Firstleg `InboundHoldService.java`、`InboundHoldGuard.java`、`InboundHoldEvidenceService.java`;File 业务类型 `INBOUND_HOLD_EVIDENCE`。扣货阻断物理履约写入,已发生实物作业后禁止手工切换,命令按入库版本和幂等键执行 | +| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | Firstleg `MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。称重确认尽力建块、提交时再次补齐,库位指引失败不阻断测量主流程;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 | | Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | | Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 | | 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` | @@ -36,6 +37,7 @@ | 客户入库结果与头程费用确认 | `firstleg/` | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`,接口 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing` 与 `/confirm`;客户按登录态范围读取并以费用版本和幂等键确认当前 `SENT` 费用,确认后锁定 | | 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` | | 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;客户账单行 `inboundCode` 由 `FinanceQueryService` 从费用来源快照提供,Client 复用头程入仓关系详情;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` | +| 客户资金账户、充值、授信、冻结与结算 | `finance/` | `FinanceWalletService.java`、`ClientFinanceWalletController.java`、`PlatformFinanceWalletController.java`、`InternalFinanceWalletController.java`、`finance-wallet-v1.yaml`;Finance 持有 CNY 主账和不可变流水,业务服务只能经内部命令或跨区 Wallet 事件冻结、结算、释放 | | 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | Firstleg `PlatformContainerFeeDetailController.java`、`ContainerFeeDetailService.java`、`container-fees-v1.yaml` 按柜内真实客户与订单原子创建 `allocation-groups`;金额合计必须等于总额,分组审核、驳回、调整、作废均整组处理,Finance 只消费各客户不可变来源快照 | | 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | Lastleg `financefee/` 审核五类海外费用并发布 `BUSINESS_FEE_SOURCE_CHANGED` 完整快照;Event Bridge 对 `targetRegion=CN` 定向投递 Finance `/internal/finance/v1/business-fee-source-events`;Finance 按事件 ID、Payload 哈希、来源系统/费用 ID/版本幂等入池,`PAYABLE + DIRECT_PAID` 单独形成已确认成本且不进入 AP 待结算 | | 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 | diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 52b004e03..d21a9c28c 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1776,3 +1776,69 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation ( PRIMARY KEY(id), UNIQUE KEY uk_ll_destuff_customer(cost_id,customer_id), UNIQUE KEY uk_ll_destuff_fee_source(fee_source_id), CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0) ) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊'; + + +-- 海外接柜仓由平台在尾程建单时预分配;历史测试数据不回填,缺失事实由业务接口失败关闭。 +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_order' AND column_name='overseas_warehouse_code'), + 'ALTER TABLE ll_order MODIFY COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT ''海外接柜仓业务编码快照''', + 'ALTER TABLE ll_order ADD COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT ''海外接柜仓业务编码快照'' AFTER overseas_warehouse_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_order' AND column_name='receiving_warehouse_version'), + 'ALTER TABLE ll_order MODIFY COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT ''柜级接柜仓分配乐观锁版本''', + 'ALTER TABLE ll_order ADD COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT ''柜级接柜仓分配乐观锁版本'' AFTER overseas_warehouse_name')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +ALTER TABLE ll_order + MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外接柜仓ID;新建尾程订单默认洛杉矶仓', + MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外接柜仓名称快照'; + +ALTER TABLE ll_inbound_plan + MODIFY COLUMN overseas_warehouse_id BIGINT NULL COMMENT '从尾程订单继承的平台预分配海外接柜仓ID'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_code'), + 'ALTER TABLE ll_receiving_container_session MODIFY COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT ''海外接柜仓业务编码快照''', + 'ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT ''海外接柜仓业务编码快照'' AFTER overseas_warehouse_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +ALTER TABLE ll_receiving_container_session + MODIFY COLUMN overseas_warehouse_id BIGINT NOT NULL COMMENT '平台预分配并由操作员Token匹配的海外接柜仓ID', + MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外接柜仓名称快照'; + +CREATE TABLE IF NOT EXISTS ll_receiving_warehouse_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_receiving_warehouse_idempotency(idempotency_key), + KEY idx_ll_receiving_warehouse_container(source_container_booking_id,created_at) +) ENGINE=InnoDB COMMENT='柜级海外接柜仓修改幂等审计记录'; + +DROP PROCEDURE IF EXISTS assert_receiving_warehouse_assignment_ready; +DELIMITER $$ +CREATE PROCEDURE assert_receiving_warehouse_assignment_ready() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' + AND table_name='ll_order' AND column_name IN ('overseas_warehouse_code','receiving_warehouse_version')) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='尾程订单海外接柜仓字段未完整部署'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' + AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_code') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA接柜会话仓库编码字段未部署'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_lastleg' + AND table_name='ll_receiving_warehouse_operation') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='柜级海外接柜仓修改幂等审计表未部署'; + END IF; +END$$ +DELIMITER ; +CALL assert_receiving_warehouse_assignment_ready(); +DROP PROCEDURE assert_receiving_warehouse_assignment_ready; + +SELECT 'PASS' AS migration_status, '海外接柜仓预分配与柜级修改结构已就绪' AS message; 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 a559f0cfc..9e369889d 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 @@ -102,6 +102,20 @@ public class LastlegOrderController { return ApiResponse.success(orderService.containerProgressDetail(sourceContainerBookingId)); } + @PutMapping("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/receiving-warehouse") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "修改整柜海外接柜仓", + description = "仅在海外PDA建立接柜会话前允许修改;按 expectedVersion 乐观锁原子同步柜内订单与入库计划。") + public ApiResponse changeReceivingWarehouse( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String sourceContainerBookingId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody LastlegOrderRequests.ChangeReceivingWarehouse request) { + platformScope(user, null); + return ApiResponse.success(orderService.changeReceivingWarehouse(sourceContainerBookingId, + idempotencyKey, request, operatorName(user))); + } + @GetMapping("/api/platform/v1/last-leg/orders/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") @Operation(summary = "Get one inbound-code-level tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index e5e4f5875..8746a23e3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -36,7 +36,13 @@ public interface DispatchCandidateMapper { "(SELECT MAX(fa.expected_loading_at) FROM ll_inbound_fba_appointment_item fi " + "JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " + "(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," + - "MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," + + "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " + + "FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " + + "AND pos.available_packages > 0) ELSE CAST(ip.overseas_warehouse_id AS CHAR) END AS overseasWarehouseId," + + "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " + + "FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " + + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " + + "ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," + "GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," + "p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," + "p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," + @@ -94,7 +100,13 @@ public interface DispatchCandidateMapper { "(SELECT MAX(fa.expected_loading_at) FROM ll_inbound_fba_appointment_item fi " + "JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " + "(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," + - "MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," + + "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " + + "FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " + + "AND pos.available_packages > 0) ELSE CAST(ip.overseas_warehouse_id AS CHAR) END AS overseasWarehouseId," + + "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " + + "FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " + + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " + + "ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," + "GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," + "p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," + "p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index e326e2166..984896219 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -220,10 +220,16 @@ public class DispatchService { if (selected.size() != palletIds.size()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant"); } + boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) + .anyMatch(value -> value == null || value.isBlank()); + if (missingWarehouse) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "所选托盘中存在尚未关联海外仓的托盘"); + } Set warehouseIds = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) - .filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).collect(Collectors.toSet()); - if (warehouseIds.size() != 1 || selected.stream().anyMatch(candidate -> candidate.overseasWarehouseId() == null)) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "A dispatch must belong to exactly one overseas warehouse"); + .map(String::trim).collect(Collectors.toSet()); + if (warehouseIds.size() != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "一次排车只能包含同一海外仓的托盘"); } String draftToken = trimToNull(request.draftToken()); for (DispatchPalletCandidate candidate : selected) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java index 226f4bb40..ebd55205b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java @@ -6,6 +6,12 @@ import java.time.LocalDateTime; public record ContainerPlanProgressSummary( String sourceContainerBookingId, String containerNo, + String overseasWarehouseId, + String overseasWarehouseCode, + String overseasWarehouseName, + int assignmentVersion, + boolean canChangeReceivingWarehouse, + String receivingWarehouseLockReason, long totalOrderCount, long inboundCodeCount, long missingInboundCodeCount, @@ -16,4 +22,14 @@ public record ContainerPlanProgressSummary( boolean allPlansReady, String blockingReason ) { + public ContainerPlanProgressSummary(String sourceContainerBookingId, String containerNo, + long totalOrderCount, long inboundCodeCount, + long missingInboundCodeCount, long planCount, + long plannedInboundCodeCount, long pendingPlanCount, + LocalDateTime plannedArrivalAt, boolean allPlansReady, + String blockingReason) { + this(sourceContainerBookingId, containerNo, null, null, null, -1, false, null, + totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, + plannedInboundCodeCount, pendingPlanCount, plannedArrivalAt, allPlansReady, blockingReason); + } } 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 604d581c9..cab84e090 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 @@ -54,6 +54,12 @@ public final class LastlegOrderRequests { } } + public record ChangeReceivingWarehouse( + @NotNull Long overseasWarehouseId, + @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/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index f87cf6c36..a87b5a39c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -22,6 +22,7 @@ public record LastlegOrderSummary( String customerId, String customerName, String overseasWarehouseId, + String overseasWarehouseCode, String overseasWarehouseName, String transferWarehouseId, String transferWarehouseCode, 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 360cf1313..93a4757ef 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 @@ -28,7 +28,9 @@ public class LastlegOrder { private String sourceWarehouseId; private String sourceWarehouseName; private String overseasWarehouseId; + private String overseasWarehouseCode; private String overseasWarehouseName; + private Integer receivingWarehouseVersion; private String fbaWarehouseCode; private String containerNo; private String transferWarehouseId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/ReceivingWarehouseOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/ReceivingWarehouseOperation.java new file mode 100644 index 000000000..22b3c0117 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/ReceivingWarehouseOperation.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_receiving_warehouse_operation") +public class ReceivingWarehouseOperation { + @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/inboundplan/dto/InboundPlanListResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java index 87a48969b..91840b731 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java @@ -12,6 +12,8 @@ public record InboundPlanListResponse( String inboundCode, String lxCode, String orderType, + String overseasWarehouseCode, + String overseasWarehouseName, String warehouseId, String warehouseCode, String warehouseName, @@ -29,18 +31,4 @@ public record InboundPlanListResponse( String dispatchStatus, LocalDateTime createdAt, Integer version -) { - public InboundPlanListResponse(Long lineId, Long planId, String planNo, String customerId, - Long orderId, String inboundCode, String orderType, - String warehouseId, String warehouseCode, String warehouseName, - String fbaWarehouseCode, String customerName, Integer packageCount, - BigDecimal volumeCbm, Integer palletCount, String status, - String appointmentNo, String appointmentStatus, - LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt, - String dispatchStatus, LocalDateTime createdAt) { - this(lineId, planId, planNo, customerId, orderId, inboundCode, null, orderType, warehouseId, - warehouseCode, warehouseName, fbaWarehouseCode, customerName, packageCount, 0, - volumeCbm, palletCount, status, appointmentNo, appointmentStatus, appointmentAt, - expectedLoadingAt, dispatchStatus, createdAt, 0); - } -} +) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java index 0fd8cd3e6..f7c7ece04 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java @@ -10,6 +10,8 @@ public record InboundPlanResponse( String customerId, Long sourceOrderId, String containerNo, + String overseasWarehouseCode, + String overseasWarehouseName, String status, int lineCount, int palletCount, @@ -21,22 +23,6 @@ public record InboundPlanResponse( List allocations, int version ) { - public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId, - String containerNo, String status, int lineCount, int palletCount, - BigDecimal totalVolumeCbm, List lines, List pallets, - List allocations) { - this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount, - totalVolumeCbm, lines, pallets, allocations, 0); - } - - public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId, - String containerNo, String status, int lineCount, int palletCount, - BigDecimal totalVolumeCbm, List lines, List pallets, - List allocations, int version) { - this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount, - totalVolumeCbm, lines.stream().mapToInt(Line::requiredPackageCount).sum(), - allocations.stream().mapToInt(Allocation::pickedPackageCount).sum(), lines, pallets, allocations, version); - } public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index f7669d5f6..8c180a6e8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -14,6 +14,8 @@ import java.util.List; public interface InboundPlanOrderMapper { record ContainerOrderRef(String customerId, Long orderId, String currentStatus, String containerNo, String orderType) { } + record ReceivingWarehouseSnapshot(String overseasWarehouseId, String overseasWarehouseCode, + String overseasWarehouseName) { } @Select("SELECT customer_id AS customerId,id AS orderId,current_status AS currentStatus,container_no AS containerNo," + "order_type AS orderType " + @@ -26,7 +28,8 @@ public interface InboundPlanOrderMapper { @Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," + "o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," + "COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," + - "COALESCE(o.pallet_count,0) AS recommendedPalletCount," + + "COALESCE(o.pallet_count,0) AS recommendedPalletCount,o.overseas_warehouse_id AS overseasWarehouseId," + + "o.overseas_warehouse_code AS overseasWarehouseCode,o.overseas_warehouse_name AS overseasWarehouseName," + "NULLIF(o.fba_warehouse_code,'') AS fbaWarehouseCode," + "COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," + "COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," + @@ -36,12 +39,19 @@ public interface InboundPlanOrderMapper { "0 AS destinationMismatchCount " + "FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " + "WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " + - "GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.pallet_count,o.fba_warehouse_code," + + "GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.pallet_count," + + "o.overseas_warehouse_id,o.overseas_warehouse_code,o.overseas_warehouse_name,o.fba_warehouse_code," + "o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " + "ORDER BY o.id FOR UPDATE") List lockOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); + @Select("SELECT overseas_warehouse_id AS overseasWarehouseId," + + "overseas_warehouse_code AS overseasWarehouseCode,overseas_warehouse_name AS overseasWarehouseName " + + "FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1") + ReceivingWarehouseSnapshot findReceivingWarehouse(@Param("customerId") String customerId, + @Param("orderId") Long orderId); + @Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," + "pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " + "WHERE customer_id=#{customerId} AND id=#{orderId}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java index 7ee4ee6fb..86358277c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java @@ -25,7 +25,7 @@ public interface InboundPlanQueryMapper { " AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + " AND o.lx_code LIKE CONCAT('%',#{q.lxCode},'%')" + " AND p.status=#{q.status}" + - " AND l.destination_warehouse_id=#{q.overseasWarehouseId}" + + " AND o.overseas_warehouse_id=#{q.overseasWarehouseId}" + " AND l.destination_warehouse_code=#{q.destinationWarehouseCode}" + " AND (l.destination_warehouse_id LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_code LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_name LIKE CONCAT('%',#{q.warehouse},'%'))" + " AND l.fba_warehouse_code LIKE CONCAT('%',#{q.fbaWarehouseCode},'%')" + @@ -38,6 +38,7 @@ public interface InboundPlanQueryMapper { @Select(" + """) + List contexts(@Param("containerBookingIds") List containerBookingIds); + + @Select("SELECT * FROM fl_terminal_drayage WHERE container_booking_id=#{containerBookingId}") + TerminalDrayageEntity find(@Param("containerBookingId") long containerBookingId); + + @Select("SELECT * FROM fl_terminal_drayage WHERE container_booking_id=#{containerBookingId} FOR UPDATE") + TerminalDrayageEntity lock(@Param("containerBookingId") long containerBookingId); + + @Select(""" + + """) + List findAll(@Param("containerBookingIds") List containerBookingIds); + + @Update(""" + UPDATE fl_terminal_drayage SET fleet_supplier_id=#{value.fleetSupplierId}, + fleet_supplier_name=#{value.fleetSupplierName},pickup_appointment_at=#{value.pickupAppointmentAt}, + actual_pickup_at=#{value.actualPickupAt},estimated_empty_return_at=#{value.estimatedEmptyReturnAt}, + actual_empty_return_at=#{value.actualEmptyReturnAt},updated_by=#{operatorId}, + updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion} + """) + int updateAggregate(@Param("value") TerminalDrayageEntity value, + @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Select(""" + SELECT action,from_status AS fromStatus,to_status AS toStatus,from_version AS fromVersion, + to_version AS toVersion,reason,operator_id AS operatorId,operator_name AS operatorName,created_at AS createdAt + FROM fl_terminal_drayage_history WHERE container_booking_id=#{containerBookingId} + ORDER BY created_at DESC,id DESC + """) + List histories(@Param("containerBookingId") long containerBookingId); + + @Insert(""" + INSERT INTO fl_terminal_drayage_history(id,drayage_id,container_booking_id,action,from_status,to_status, + from_version,to_version,reason,operator_id,operator_name) + VALUES(#{id},#{drayageId},#{containerBookingId},#{action},#{fromStatus},#{toStatus},#{fromVersion}, + #{toVersion},#{reason},#{operatorId},#{operatorName}) + """) + int insertHistory(@Param("id") long id, @Param("drayageId") long drayageId, + @Param("containerBookingId") long containerBookingId, + @Param("action") String action, @Param("fromStatus") String fromStatus, + @Param("toStatus") String toStatus, @Param("fromVersion") int fromVersion, + @Param("toVersion") int toVersion, @Param("reason") String reason, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + + @Select(""" + SELECT operation,request_hash AS requestHash,result_version AS resultVersion + FROM fl_terminal_drayage_operation + WHERE container_booking_id=#{containerBookingId} AND idempotency_key=#{key} + """) + Operation operation(@Param("containerBookingId") long containerBookingId, @Param("key") String key); + + @Insert(""" + INSERT INTO fl_terminal_drayage_operation(id,container_booking_id,operation,idempotency_key,request_hash, + result_version,operator_id) VALUES(#{id},#{containerBookingId},#{operation},#{key},#{hash}, + #{resultVersion},#{operatorId}) + """) + int insertOperation(@Param("id") long id, @Param("containerBookingId") long containerBookingId, + @Param("operation") String operation, @Param("key") String key, + @Param("hash") String hash, @Param("resultVersion") int resultVersion, + @Param("operatorId") long operatorId); + + record Context(Long containerBookingId, String containerNo, Long oceanBookingId, String recordStatus, + LocalDateTime unloadingCompletedAt, Long queryId, String latestActualNode, + LocalDateTime latestActualAt, Integer queryVersion, LocalDateTime actualArrivalNodeAt, + LocalDateTime actualPickupNodeAt, LocalDateTime actualEmptyReturnNodeAt) {} + record Operation(String operation, String requestHash, Integer resultVersion) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java new file mode 100644 index 000000000..177e4ab7c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java @@ -0,0 +1,339 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectionRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.PickupAppointmentRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.Warning; +import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Operation; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class TerminalDrayageService { + private static final int EMPTY_RETURN_WARNING_HOURS = 72; + private final TerminalDrayageMapper mapper; + private final WarehouseSupplierSnapshotService suppliers; + private final CarrierScheduleQueryService carrierSchedules; + private final ObjectMapper objectMapper; + private Clock clock = Clock.systemDefaultZone(); + + public View detail(long containerBookingId) { + return view(requiredContext(containerBookingId), mapper.find(containerBookingId)); + } + + public List views(List containerBookingIds) { + if (containerBookingIds == null || containerBookingIds.isEmpty()) return List.of(); + List ids = containerBookingIds.stream().filter(Objects::nonNull).distinct().toList(); + if (ids.size() != containerBookingIds.size()) throw badRequest("containerBookingIds不能包含空值或重复值"); + Map rows = new HashMap<>(); + mapper.findAll(ids).forEach(value -> rows.put(value.getContainerBookingId(), value)); + return mapper.contexts(ids).stream().map(value -> view(value, rows.get(value.containerBookingId()))).toList(); + } + + public List histories(long containerBookingId) { + requiredContext(containerBookingId); + return mapper.histories(containerBookingId); + } + + @Transactional + public View selectFleet(long containerBookingId, long operatorId, String operatorName, String key, + FleetSelectionRequest request) { + String action = "SELECT_FLEET"; + Context context = requiredLockedContext(containerBookingId); + View replay = replay(context, action, key, request); + if (replay != null) return replay; + requireArrived(context); + TerminalDrayageEntity value = mapper.lock(containerBookingId); + requireVersion(value, request.expectedVersion()); + if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可更换码头车队"); + var fleet = suppliers.trucking(request.fleetSupplierId(), request.fleetSupplierName()); + int fromVersion = version(value); + String fromStatus = status(context, value); + if (value == null) value = create(containerBookingId, operatorId, operatorName); + value.setFleetSupplierId(fleet.supplierId()); + value.setFleetSupplierName(fleet.name()); + persist(value, fromVersion, operatorId, operatorName); + return finish(context, value, action, key, request, fromStatus, fromVersion, + request.reason(), operatorId, operatorName); + } + + @Transactional + public View schedulePickup(long containerBookingId, long operatorId, String operatorName, String key, + PickupAppointmentRequest request) { + String action = "SCHEDULE_PICKUP"; + Context context = requiredLockedContext(containerBookingId); + View replay = replay(context, action, key, request); + if (replay != null) return replay; + requireArrived(context); + TerminalDrayageEntity value = requiredAggregate(containerBookingId); + requireVersion(value, request.expectedVersion()); + if (value.getFleetSupplierId() == null) throw conflict("请先选择有效码头车队"); + if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可修改预约提柜时间"); + if (context.actualArrivalNodeAt() != null && request.pickupAppointmentAt().isBefore(context.actualArrivalNodeAt())) { + throw badRequest("预约提柜时间不能早于实际到港时间"); + } + int fromVersion = version(value); + String fromStatus = status(context, value); + value.setPickupAppointmentAt(request.pickupAppointmentAt()); + persist(value, fromVersion, operatorId, operatorName); + return finish(context, value, action, key, request, fromStatus, fromVersion, + request.reason(), operatorId, operatorName); + } + + @Transactional + public View recordPickup(long containerBookingId, long operatorId, String operatorName, String key, + ActualTimeRequest request) { + String action = "RECORD_PICKUP"; + Context context = requiredLockedContext(containerBookingId); + View replay = replay(context, action, key, request); + if (replay != null) return replay; + requireAtLeast(context, CarrierScheduleNode.PICKUP_READY, "货柜尚未清关放行,不允许确认实际提柜"); + TerminalDrayageEntity value = requiredAggregate(containerBookingId); + requireVersion(value, request.expectedVersion()); + if (value.getFleetSupplierId() == null || value.getPickupAppointmentAt() == null) { + throw conflict("选择码头车队并预约提柜后才能确认实际提柜"); + } + if (actualPickupAt(context, value) != null) throw conflict("实际提柜时间已确认"); + if (context.latestActualAt() != null && request.actualAt().isBefore(context.latestActualAt())) { + throw badRequest("实际提柜时间不能早于当前实际船节点时间"); + } + advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key, + request.reason(), operatorId, operatorName); + int fromVersion = version(value); + String fromStatus = status(context, value); + value.setActualPickupAt(request.actualAt()); + applyAuthoritativeDeadline(context, value); + persist(value, fromVersion, operatorId, operatorName); + return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus, + fromVersion, request.reason(), operatorId, operatorName); + } + + @Transactional + public View recordEmptyReturn(long containerBookingId, long operatorId, String operatorName, String key, + ActualTimeRequest request) { + String action = "RECORD_EMPTY_RETURN"; + Context context = requiredLockedContext(containerBookingId); + View replay = replay(context, action, key, request); + if (replay != null) return replay; + TerminalDrayageEntity value = requiredAggregate(containerBookingId); + requireVersion(value, request.expectedVersion()); + LocalDateTime pickupAt = actualPickupAt(context, value); + if (pickupAt == null) throw conflict("实际提柜后才能确认还柜"); + if (context.unloadingCompletedAt() == null) throw conflict("海外卸柜收货完成后才能确认还柜"); + LocalDateTime earliest = pickupAt.isAfter(context.unloadingCompletedAt()) + ? pickupAt : context.unloadingCompletedAt(); + if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间"); + if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认"); + advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key, + request.reason(), operatorId, operatorName); + int fromVersion = version(value); + String fromStatus = status(context, value); + applyAuthoritativeDeadline(context, value); + value.setActualEmptyReturnAt(request.actualAt()); + persist(value, fromVersion, operatorId, operatorName); + return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus, + fromVersion, request.reason(), operatorId, operatorName); + } + + private void advanceNode(Context context, CarrierScheduleNode target, LocalDateTime actualAt, + String action, String key, String reason, long operatorId, String operatorName) { + if (context.queryId() == null || context.queryVersion() == null) { + throw conflict("货柜尚未建立船节点跟踪任务"); + } + CarrierScheduleNode current = node(context.latestActualNode()); + if (current != null && current.sequence() >= target.sequence()) return; + carrierSchedules.addManualNode(context.queryId(), operatorId, operatorName, + "td-" + hash(context.containerBookingId() + "|" + action + "|" + key.trim()), + new ManualNodeRequest(context.queryVersion(), target.name(), LocalDateTime.now(clock), null, + actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步")); + } + + private View finish(Context context, TerminalDrayageEntity value, String action, String key, Object request, + String fromStatus, int fromVersion, String reason, + long operatorId, String operatorName) { + TerminalDrayageEntity stored = mapper.find(context.containerBookingId()); + Context currentContext = requiredContext(context.containerBookingId()); + String toStatus = status(currentContext, stored); + mapper.insertHistory(IdWorker.getId(), stored.getId(), context.containerBookingId(), action, + fromStatus, toStatus, fromVersion, version(stored), trim(reason), operatorId, operatorName); + mapper.insertOperation(IdWorker.getId(), context.containerBookingId(), action, key.trim(), hash(request), + version(stored), operatorId); + return view(currentContext, stored); + } + + private View replay(Context context, String action, String key, Object request) { + if (!StringUtils.hasText(key) || key.length() > 255) throw badRequest("Idempotency-Key不能为空且不能超过255字符"); + Operation operation = mapper.operation(context.containerBookingId(), key.trim()); + if (operation == null) return null; + if (!action.equals(operation.operation()) || !hash(request).equals(operation.requestHash())) { + throw conflict("Idempotency-Key已被不同的码头拖车操作占用"); + } + return view(context, mapper.find(context.containerBookingId())); + } + + private TerminalDrayageEntity create(long containerBookingId, long operatorId, String operatorName) { + TerminalDrayageEntity value = new TerminalDrayageEntity(); + value.setId(IdWorker.getId()); value.setContainerBookingId(containerBookingId); value.setVersion(1); + value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); + value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); + return value; + } + + private void persist(TerminalDrayageEntity value, int expectedVersion, long operatorId, String operatorName) { + if (expectedVersion == 0) { + mapper.insert(value); + return; + } + if (mapper.updateAggregate(value, expectedVersion, operatorId, operatorName) != 1) throw concurrent(); + } + + private void applyAuthoritativeDeadline(Context context, TerminalDrayageEntity value) { + if (context.unloadingCompletedAt() != null) { + value.setEstimatedEmptyReturnAt(context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS)); + } + } + + private View view(Context context, TerminalDrayageEntity value) { + String status = status(context, value); + LocalDateTime deadline = context.unloadingCompletedAt() == null ? null + : context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS); + LocalDateTime returnedAt = actualEmptyReturnAt(context, value); + String warningStatus; + long overdueHours = 0; + if (returnedAt != null) warningStatus = "RESOLVED"; + else if (deadline == null) warningStatus = "NOT_APPLICABLE"; + else if (LocalDateTime.now(clock).isAfter(deadline)) { + warningStatus = "OVERDUE"; + overdueHours = Math.max(0, Duration.between(deadline, LocalDateTime.now(clock)).toHours()); + } else warningStatus = "PENDING"; + Warning warning = new Warning(warningStatus, deadline, overdueHours, overdueHours / 24); + return new View(context.containerBookingId(), context.containerNo(), context.oceanBookingId(), status, + version(value), value == null ? null : value.getFleetSupplierId(), + value == null ? null : value.getFleetSupplierName(), + value == null ? null : value.getPickupAppointmentAt(), + actualPickupAt(context, value), deadline, returnedAt, + context.unloadingCompletedAt(), warning, allowedActions(context, value), + value == null ? null : value.getUpdatedAt()); + } + + private List allowedActions(Context context, TerminalDrayageEntity value) { + List result = new ArrayList<>(); + CarrierScheduleNode current = node(context.latestActualNode()); + if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence() + && current.sequence() < CarrierScheduleNode.PICKED_UP.sequence() + && actualPickupAt(context, value) == null) { + result.add("SELECT_FLEET"); + if (value != null && value.getFleetSupplierId() != null) result.add("SCHEDULE_PICKUP"); + } + if (current != null && current.sequence() >= CarrierScheduleNode.PICKUP_READY.sequence() + && value != null && value.getFleetSupplierId() != null && value.getPickupAppointmentAt() != null + && actualPickupAt(context, value) == null) result.add("RECORD_PICKUP"); + if (context.unloadingCompletedAt() != null && actualPickupAt(context, value) != null + && actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN"); + return List.copyOf(result); + } + + private String status(Context context, TerminalDrayageEntity value) { + if (actualEmptyReturnAt(context, value) != null) return "EMPTY_RETURNED"; + if (context.unloadingCompletedAt() != null && actualPickupAt(context, value) != null) return "UNLOADED"; + if (actualPickupAt(context, value) != null) return "PICKED_UP"; + if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED"; + CarrierScheduleNode current = node(context.latestActualNode()); + return current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence() + ? "ARRIVED" : "WAITING_FOR_ARRIVAL"; + } + + private void requireArrived(Context context) { + requireAtLeast(context, CarrierScheduleNode.ARRIVED_DESTINATION_PORT, "货柜实际到达目的港后才能安排码头车队"); + } + + private void requireAtLeast(Context context, CarrierScheduleNode required, String message) { + CarrierScheduleNode current = node(context.latestActualNode()); + if (current == null || current.sequence() < required.sequence()) throw conflict(message); + } + + private CarrierScheduleNode node(String value) { + return StringUtils.hasText(value) ? CarrierScheduleNode.require(value) : null; + } + + private LocalDateTime actualPickupAt(Context context, TerminalDrayageEntity value) { + return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt() : context.actualPickupNodeAt(); + } + + private LocalDateTime actualEmptyReturnAt(Context context, TerminalDrayageEntity value) { + return value != null && value.getActualEmptyReturnAt() != null + ? value.getActualEmptyReturnAt() : context.actualEmptyReturnNodeAt(); + } + + private Context requiredContext(long id) { + Context value = mapper.context(id); + if (value == null || !"ACTIVE".equals(value.recordStatus())) throw notFound(); + return value; + } + + private Context requiredLockedContext(long id) { + Context value = mapper.lockContext(id); + if (value == null || !"ACTIVE".equals(value.recordStatus())) throw notFound(); + return value; + } + + private TerminalDrayageEntity requiredAggregate(long id) { + TerminalDrayageEntity value = mapper.lock(id); + if (value == null) throw conflict("请先选择码头车队"); + return value; + } + + private void requireVersion(TerminalDrayageEntity value, int expectedVersion) { + if (version(value) != expectedVersion) throw concurrent(); + } + + private int version(TerminalDrayageEntity value) { + return value == null || value.getVersion() == null ? 0 : value.getVersion(); + } + + private String hash(Object request) { + try { + byte[] json = objectMapper.writeValueAsBytes(request); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json)); + } catch (JsonProcessingException | NoSuchAlgorithmException exception) { + throw new IllegalStateException("无法计算码头拖车请求摘要", exception); + } + } + + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static BizException badRequest(String message) { return new BizException(ResultCode.BAD_REQUEST, message); } + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } + private static BizException concurrent() { return conflict("码头拖车记录已变化,请刷新后重试"); } + private static BizException notFound() { return new BizException(ResultCode.NOT_FOUND, "货柜不存在或不可用"); } + + void setClock(Clock clock) { this.clock = clock; } +} diff --git a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql new file mode 100644 index 000000000..47d88d067 --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql @@ -0,0 +1,63 @@ +SET NAMES utf8mb4; +USE baiyue_firstleg; + +CREATE TABLE IF NOT EXISTS fl_terminal_drayage ( + id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID', + fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', + fleet_supplier_name VARCHAR(128) NOT NULL COMMENT 'Warehouse供应商名称快照', + pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间', + actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间', + estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间', + actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间', + version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后更新人ID', + updated_by_name VARCHAR(64) NULL COMMENT '最后更新人名称快照', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_terminal_drayage_container(container_booking_id), + KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at), + CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id), + CONSTRAINT chk_terminal_drayage_times CHECK( + (actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) AND + (actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL) AND + (actual_empty_return_at IS NULL OR actual_empty_return_at >= actual_pickup_at)) +) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实'; + +CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '历史ID', + drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照', + action VARCHAR(32) NOT NULL COMMENT 'SELECT_FLEET/SCHEDULE_PICKUP/RECORD_PICKUP/RECORD_EMPTY_RETURN', + from_status VARCHAR(32) NOT NULL COMMENT '操作前状态', + to_status VARCHAR(32) NOT NULL COMMENT '操作后状态', + from_version INT UNSIGNED NOT NULL COMMENT '操作前版本', + to_version INT UNSIGNED NOT NULL COMMENT '操作后版本', + reason VARCHAR(500) NULL COMMENT '操作说明', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY(id), + KEY idx_terminal_drayage_history(container_booking_id,created_at,id), + CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id), + CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN + ('SELECT_FLEET','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')) +) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史'; + +CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation ( + id BIGINT UNSIGNED NOT NULL COMMENT '幂等操作ID', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID', + operation VARCHAR(32) NOT NULL COMMENT '业务动作', + idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求载荷SHA-256', + result_version INT UNSIGNED NOT NULL COMMENT '成功结果版本', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完成时间', + PRIMARY KEY(id), + UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key), + KEY idx_terminal_drayage_operation(container_booking_id,created_at), + CONSTRAINT fk_terminal_drayage_operation_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id) +) ENGINE=InnoDB COMMENT='目的港码头拖车成功幂等操作'; diff --git a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml new file mode 100644 index 000000000..454e6c00d --- /dev/null +++ b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml @@ -0,0 +1,127 @@ +openapi: 3.0.3 +info: {title: Firstleg Terminal Drayage API, version: 1.0.0} +paths: + /api/platform/v1/terminal-drayages: + get: + summary: 批量查询货柜码头拖车及服务端报空预警 + x-permission: platform:firstleg:container:list + parameters: + - name: containerBookingIds + in: query + required: true + schema: {type: array, minItems: 1, maxItems: 200, items: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: {'200': {$ref: '#/components/responses/ViewsResponse'}} + /api/platform/v1/terminal-drayages/{containerBookingId}: + get: + summary: 查询单柜码头拖车详情 + x-permission: platform:firstleg:container:list + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] + responses: {'200': {$ref: '#/components/responses/ViewResponse'}} + /api/platform/v1/terminal-drayages/{containerBookingId}/history: + get: + summary: 查询单柜码头拖车操作历史 + x-permission: platform:firstleg:container:list + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] + responses: {'200': {$ref: '#/components/responses/HistoryResponse'}} + /api/platform/v1/terminal-drayages/{containerBookingId}/fleet: + post: + summary: 到港后选择或更换码头车队 + x-permission: platform:firstleg:container:edit + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/FleetSelectionRequest'}}}} + responses: {'200': {$ref: '#/components/responses/ViewResponse'}} + /api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment: + post: + summary: 预约目的港提柜时间 + x-permission: platform:firstleg:container:edit + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PickupAppointmentRequest'}}}} + responses: {'200': {$ref: '#/components/responses/ViewResponse'}} + /api/platform/v1/terminal-drayages/{containerBookingId}/pickup: + post: + summary: 清关可提柜后确认实际提柜并推进PICKED_UP节点 + x-permission: platform:firstleg:container:edit + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}} + responses: {'200': {$ref: '#/components/responses/ViewResponse'}} + /api/platform/v1/terminal-drayages/{containerBookingId}/empty-return: + post: + summary: 海外卸柜收货完成后确认实际还柜并推进EMPTY_RETURNED节点 + x-permission: platform:firstleg:container:edit + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}} + responses: {'200': {$ref: '#/components/responses/ViewResponse'}} +components: + parameters: + ContainerBookingId: {name: containerBookingId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 255}} + schemas: + FleetSelectionRequest: + type: object + required: [expectedVersion, fleetSupplierId] + properties: + expectedVersion: {type: integer, minimum: 0} + fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', description: Warehouse ACTIVE/TRUCKING供应商ID} + fleetSupplierName: {type: string, maxLength: 128, nullable: true, description: 可选一致性提示,持久化名称以服务端快照为准} + reason: {type: string, maxLength: 500, nullable: true} + PickupAppointmentRequest: + type: object + required: [expectedVersion, pickupAppointmentAt] + properties: + expectedVersion: {type: integer, minimum: 0} + pickupAppointmentAt: {type: string, format: date-time} + reason: {type: string, maxLength: 500, nullable: true} + ActualTimeRequest: + type: object + required: [expectedVersion, actualAt] + properties: + expectedVersion: {type: integer, minimum: 0} + actualAt: {type: string, format: date-time} + reason: {type: string, maxLength: 500, nullable: true} + Warning: + type: object + required: [status, overdueHours, overdueDays] + properties: + status: {type: string, enum: [NOT_APPLICABLE, PENDING, OVERDUE, RESOLVED]} + deadlineAt: {type: string, format: date-time, nullable: true, description: 卸柜完成时间加72小时} + overdueHours: {type: integer, format: int64, minimum: 0} + overdueDays: {type: integer, format: int64, minimum: 0} + TerminalDrayageView: + type: object + required: [containerBookingId, status, version, emptyReturnWarning, allowedActions] + properties: + containerBookingId: {type: string, pattern: '^[1-9][0-9]*$'} + containerNo: {type: string, nullable: true} + oceanBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + status: {type: string, enum: [WAITING_FOR_ARRIVAL, ARRIVED, PICKUP_SCHEDULED, PICKED_UP, UNLOADED, EMPTY_RETURNED]} + version: {type: integer, minimum: 0} + fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + fleetSupplierName: {type: string, nullable: true} + pickupAppointmentAt: {type: string, format: date-time, nullable: true} + actualPickupAt: {type: string, format: date-time, nullable: true} + estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: 服务端权威值,卸柜完成时间加72小时} + actualEmptyReturnAt: {type: string, format: date-time, nullable: true} + unloadingCompletedAt: {type: string, format: date-time, nullable: true} + emptyReturnWarning: {$ref: '#/components/schemas/Warning'} + allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}} + updatedAt: {type: string, format: date-time, nullable: true} + History: + type: object + required: [action, fromStatus, toStatus, fromVersion, toVersion, operatorId, createdAt] + properties: + action: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]} + fromStatus: {type: string} + toStatus: {type: string} + fromVersion: {type: integer, minimum: 0} + toVersion: {type: integer, minimum: 1} + reason: {type: string, nullable: true} + operatorId: {type: string, pattern: '^[1-9][0-9]*$'} + operatorName: {type: string, nullable: true} + createdAt: {type: string, format: date-time} + ApiView: {type: object, required: [code, data], properties: {code: {type: string}, message: {type: string}, data: {$ref: '#/components/schemas/TerminalDrayageView'}}} + ApiViews: {type: object, required: [code, data], properties: {code: {type: string}, message: {type: string}, data: {type: array, items: {$ref: '#/components/schemas/TerminalDrayageView'}}}} + ApiHistory: {type: object, required: [code, data], properties: {code: {type: string}, message: {type: string}, data: {type: array, items: {$ref: '#/components/schemas/History'}}}} + responses: + ViewResponse: {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiView'}}}} + ViewsResponse: {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiViews'}}}} + HistoryResponse: {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiHistory'}}}} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java new file mode 100644 index 000000000..9400ccb73 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.terminaldrayage.controller; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class TerminalDrayageOpenApiContractTest { + @Test + void freezesActionsPermissionsAndServerWarningFields() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/terminal-drayage-v1.yaml")); + assertThat(yaml).contains( + "/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:", + "/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:", + "/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:", + "/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:", + "platform:firstleg:container:list", "platform:firstleg:container:edit", + "expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt", + "emptyReturnWarning", "deadlineAt", "overdueHours", "overdueDays"); + assertThat(yaml).contains("description: 卸柜完成时间加72小时"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java new file mode 100644 index 000000000..28da7a5b2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java @@ -0,0 +1,27 @@ +package com.baiyue.firstleg.terminaldrayage.mapper; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class TerminalDrayageSqlContractTest { + @Test + void schemaKeepsOneAggregatePerContainerAndImmutableAudit() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql")); + String mapper = Files.readString(Path.of( + "src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java")); + assertThat(sql).contains( + "CREATE TABLE IF NOT EXISTS fl_terminal_drayage (", + "UNIQUE KEY uk_terminal_drayage_container(container_booking_id)", + "CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (", + "CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (", + "UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key)", + "服务端按卸柜完成时间加72小时计算的预计还柜时间"); + assertThat(sql).doesNotContain("ON DELETE CASCADE"); + assertThat(mapper).contains("WHERE b.id=#{containerBookingId} FOR UPDATE", + "WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java new file mode 100644 index 000000000..d48b14f53 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java @@ -0,0 +1,251 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehouseSupplierClient; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectionRequest; +import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Operation; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TerminalDrayageServiceTest { + private static final LocalDateTime ARRIVED_AT = LocalDateTime.of(2026, 9, 1, 8, 0); + private TerminalDrayageMapper mapper; + private WarehouseSupplierSnapshotService suppliers; + private CarrierScheduleQueryService carrierSchedules; + private TerminalDrayageService service; + + @BeforeEach + void setUp() { + mapper = mock(TerminalDrayageMapper.class); + suppliers = mock(WarehouseSupplierSnapshotService.class); + carrierSchedules = mock(CarrierScheduleQueryService.class); + service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules()); + service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC)); + } + + @Test + void rejectsFleetSelectionBeforeActualDestinationArrival() { + when(mapper.lockContext(11L)).thenReturn(context("IN_TRANSIT", null)); + + assertThatThrownBy(() -> service.selectFleet(11L, 7L, "admin", "fleet-1", + new FleetSelectionRequest(0, 91L, null, null))) + .isInstanceOf(BizException.class) + .hasMessage("货柜实际到达目的港后才能安排码头车队"); + verify(suppliers, never()).trucking(anyLong(), any()); + } + + @Test + void selectsActiveTruckingSupplierAndPersistsServerSnapshot() { + Context context = context("ARRIVED_DESTINATION_PORT", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.context(11L)).thenReturn(context); + when(mapper.lock(11L)).thenReturn(null); + when(mapper.operation(11L, "fleet-1")).thenReturn(null); + when(suppliers.trucking(91L, "用户提示名")).thenReturn( + new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of())); + AtomicReference stored = new AtomicReference<>(); + when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> { + TerminalDrayageEntity value = invocation.getArgument(0); + stored.set(value); + return 1; + }); + when(mapper.find(11L)).thenAnswer(ignored -> stored.get()); + + var result = service.selectFleet(11L, 7L, "admin", "fleet-1", + new FleetSelectionRequest(0, 91L, "用户提示名", "安排提柜")); + + assertThat(result.version()).isEqualTo(1); + assertThat(result.fleetSupplierId()).isEqualTo(91L); + assertThat(result.fleetSupplierName()).isEqualTo("服务端车队"); + assertThat(result.allowedActions()).containsExactlyInAnyOrder("SELECT_FLEET", "SCHEDULE_PICKUP"); + verify(mapper).insertHistory(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString(), + anyInt(), anyInt(), any(), anyLong(), anyString()); + verify(mapper).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong()); + } + + @Test + void recordsPickupOnceAndAdvancesCanonicalNode() { + Context before = context("PICKUP_READY", null); + Context after = context("PICKED_UP", null); + when(mapper.lockContext(11L)).thenReturn(before); + when(mapper.context(11L)).thenReturn(after); + when(mapper.operation(11L, "pickup-1")).thenReturn(null); + TerminalDrayageEntity row = row(2); + row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0)); + when(mapper.lock(11L)).thenReturn(row); + when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> { + row.setVersion(3); + return 1; + }); + when(mapper.find(11L)).thenReturn(row); + + var result = service.recordPickup(11L, 7L, "admin", "pickup-1", + new ActualTimeRequest(2, LocalDateTime.of(2026, 9, 2, 10, 0), "码头提柜")); + + assertThat(result.status()).isEqualTo("PICKED_UP"); + assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0)); + ArgumentCaptor node = ArgumentCaptor.forClass(ManualNodeRequest.class); + verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); + assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP"); + verify(mapper, times(1)).updateAggregate(any(), anyInt(), anyLong(), anyString()); + } + + @Test + void requiresOverseasUnloadBeforeEmptyReturn() { + Context context = context("PICKED_UP", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.operation(11L, "return-1")).thenReturn(null); + TerminalDrayageEntity row = row(3); + row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0)); + when(mapper.lock(11L)).thenReturn(row); + + assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-1", + new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null))) + .isInstanceOf(BizException.class) + .hasMessage("海外卸柜收货完成后才能确认还柜"); + verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any()); + } + + @Test + void computesSeventyTwoHourWarningAndClearsItAfterReturn() { + LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 1, 0, 0); + Context context = context("PICKED_UP", unloadedAt); + TerminalDrayageEntity row = row(3); + row.setActualPickupAt(LocalDateTime.of(2026, 8, 31, 18, 0)); + when(mapper.context(11L)).thenReturn(context); + when(mapper.find(11L)).thenReturn(row); + + var overdue = service.detail(11L); + assertThat(overdue.estimatedEmptyReturnAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 0, 0)); + assertThat(overdue.emptyReturnWarning().status()).isEqualTo("OVERDUE"); + assertThat(overdue.emptyReturnWarning().overdueHours()).isEqualTo(26); + assertThat(overdue.emptyReturnWarning().overdueDays()).isEqualTo(1); + + row.setActualEmptyReturnAt(LocalDateTime.of(2026, 9, 4, 1, 0)); + assertThat(service.detail(11L).emptyReturnWarning().status()).isEqualTo("RESOLVED"); + assertThat(service.detail(11L).emptyReturnWarning().overdueHours()).isZero(); + } + + @Test + void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutWriting() { + Context context = context("ARRIVED_DESTINATION_PORT", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.operation(11L, "same-key")).thenReturn(new Operation("SELECT_FLEET", "different-hash", 1)); + + assertThatThrownBy(() -> service.selectFleet(11L, 7L, "admin", "same-key", + new FleetSelectionRequest(0, 91L, null, null))) + .isInstanceOf(BizException.class) + .hasMessage("Idempotency-Key已被不同的码头拖车操作占用"); + verify(mapper, never()).insert(any(TerminalDrayageEntity.class)); + verify(mapper, never()).updateAggregate(any(), anyInt(), anyLong(), anyString()); + } + + @Test + void sameIdempotencyKeyAndPayloadReplaysWithoutSecondWrite() { + Context context = context("ARRIVED_DESTINATION_PORT", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.context(11L)).thenReturn(context); + when(mapper.lock(11L)).thenReturn(null); + when(suppliers.trucking(91L, null)).thenReturn( + new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of())); + AtomicReference stored = new AtomicReference<>(); + AtomicReference operation = new AtomicReference<>(); + when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> { + stored.set(invocation.getArgument(0)); + return 1; + }); + when(mapper.find(11L)).thenAnswer(ignored -> stored.get()); + when(mapper.operation(11L, "fleet-replay")).thenAnswer(ignored -> operation.get()); + when(mapper.insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong())) + .thenAnswer(invocation -> { + operation.set(new Operation(invocation.getArgument(2), invocation.getArgument(4), invocation.getArgument(5))); + return 1; + }); + FleetSelectionRequest request = new FleetSelectionRequest(0, 91L, null, null); + + var first = service.selectFleet(11L, 7L, "admin", "fleet-replay", request); + var replay = service.selectFleet(11L, 7L, "admin", "fleet-replay", request); + + assertThat(replay).isEqualTo(first); + verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class)); + verify(mapper, times(1)).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong()); + verify(suppliers, times(1)).trucking(91L, null); + } + + @Test + void recordsEmptyReturnAndResolvesWarning() { + LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0); + Context before = context("PICKED_UP", unloadedAt); + Context after = context("EMPTY_RETURNED", unloadedAt); + when(mapper.lockContext(11L)).thenReturn(before); + when(mapper.context(11L)).thenReturn(after); + when(mapper.operation(11L, "return-2")).thenReturn(null); + TerminalDrayageEntity row = row(3); + row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0)); + row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0)); + when(mapper.lock(11L)).thenReturn(row); + when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> { + row.setVersion(4); + return 1; + }); + when(mapper.find(11L)).thenReturn(row); + + var result = service.recordEmptyReturn(11L, 7L, "admin", "return-2", + new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 9, 0), "已还柜")); + + assertThat(result.status()).isEqualTo("EMPTY_RETURNED"); + assertThat(result.estimatedEmptyReturnAt()).isEqualTo(unloadedAt.plusHours(72)); + assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED"); + ArgumentCaptor node = ArgumentCaptor.forClass(ManualNodeRequest.class); + verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); + assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED"); + } + + private Context context(String node, LocalDateTime unloadedAt) { + int sequenceVersion = switch (node) { + case "PICKED_UP" -> 5; + case "PICKUP_READY" -> 4; + case "ARRIVED_DESTINATION_PORT" -> 3; + default -> 2; + }; + return new Context(11L, "MSCU1234567", 21L, "ACTIVE", unloadedAt, + 31L, node, ARRIVED_AT, sequenceVersion, ARRIVED_AT, + "PICKED_UP".equals(node) || "EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(1) : null, + "EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(3) : null); + } + + private TerminalDrayageEntity row(int version) { + TerminalDrayageEntity value = new TerminalDrayageEntity(); + value.setId(41L); value.setContainerBookingId(11L); value.setVersion(version); + value.setFleetSupplierId(91L); value.setFleetSupplierName("服务端车队"); + return value; + } +} diff --git a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json index 4ffb1a909..89298259e 100644 --- a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json +++ b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json @@ -7,6 +7,7 @@ "booking-customs-trucking->outbound", "booking-customs-trucking->pricebook-query", "booking-customs-trucking->shared-technical", + "booking-customs-trucking->shipping-nodes", "booking-customs-trucking->split-loading", "customer-statistics->shared-technical", "finance-integration->inbound", 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 9e369889d..0984202f9 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 @@ -7,12 +7,14 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderRequests; 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.entity.LastlegOrderFee; import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.security.OverseasPermissions; import com.baiyue.lastleg.service.LastlegOrderService; +import com.baiyue.lastleg.service.LastlegOrderCommandService; import com.baiyue.lastleg.service.LoadingCompletedConsumer; import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; import com.baiyue.common.event.EventTypes; @@ -45,6 +47,7 @@ public class LastlegOrderController { @Resource private LoadingCompletedConsumer consumer; @Resource private ContainerShippingNodeConsumer shippingNodeConsumer; @Resource private LastlegOrderService orderService; + @Resource private LastlegOrderCommandService commandService; @Resource private InternalServiceTokenGuard tokenGuard; @Resource private WalletCommandCompletedConsumer walletCommandCompletedConsumer; @@ -137,17 +140,43 @@ public class LastlegOrderController { return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request, operatorName(user))); } - @PutMapping("/api/platform/v1/last-leg/orders/{id}/detained") + @PutMapping("/api/platform/v1/last-leg/orders/{id}/hold") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") - @Operation(summary = "Set tail-leg order hold status", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) - public ApiResponse setDetained( + @Operation(summary = "暂扣或解除单票尾程订单", + description = "要求版本与幂等键;暂扣只作用于该票并阻断其出库推进。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) + public ApiResponse hold( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable long id, - @Valid @RequestBody LastlegOrderRequests.SetDetained request) { - orderService.setDetained(platformScope(user, customerId), id, request, - String.valueOf(user.userId()), operatorName(user)); - return ApiResponse.success(null); + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody LastlegOrderCommandDtos.HoldRequest request) { + return ApiResponse.success(commandService.hold(platformScope(user, customerId), id, idempotencyKey, + request, operatorId(user), operatorName(user))); + } + + @PutMapping("/api/platform/v1/last-leg/orders/{id}/manual-nodes") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "保存单票人工节点覆盖", + description = "首次保存后该票永久使用 MANUAL 节点,不再回退柜级节点;当前无清除或恢复继承接口。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) + public ApiResponse saveManualNodes( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody LastlegOrderCommandDtos.ManualNodesRequest request) { + return ApiResponse.success(commandService.saveManualNodes(platformScope(user, customerId), id, + idempotencyKey, request, operatorId(user), operatorName(user))); + } + + @GetMapping("/api/platform/v1/last-leg/orders/{id}/manual-nodes") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + @Operation(summary = "查询单票人工节点覆盖", + description = "只返回人工填写节点及 MANUAL 来源。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) + public ApiResponse manualNodes( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id) { + return ApiResponse.success(commandService.manualNodes(platformScope(user, customerId), id)); } @PutMapping("/api/platform/v1/last-leg/orders/{id}/pallet-merge-required") @@ -227,4 +256,8 @@ public class LastlegOrderController { private String operatorName(AuthenticatedUser user) { return user.username() == null || user.username().isBlank() ? String.valueOf(user.userId()) : user.username(); } + + private String operatorId(AuthenticatedUser user) { + return user.userId() == null ? operatorName(user) : String.valueOf(user.userId()); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index 8746a23e3..227e8ad57 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -66,6 +66,10 @@ public interface DispatchCandidateMapper { " AND l.fba_warehouse_code=#{fbaWarehouseCode} " + " AND " + "CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage} " + + "AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " + + "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + "AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " + @@ -124,6 +128,10 @@ public interface DispatchCandidateMapper { "LEFT JOIN ll_order o ON o.id=l.order_id " + "WHERE ip.customer_id=#{customerId} AND p.id IN " + "#{id} " + + "AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " + + "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + "AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index b41154dda..05a5e000a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -22,6 +22,8 @@ import com.baiyue.lastleg.dispatch.dto.DispatchDraftResponse; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; @@ -53,6 +55,7 @@ public class DispatchService { @Resource private DispatchCandidateMapper candidates; @Resource private DispatchPdaTaskMapper pdaTasks; @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private LastlegOrderMapper tailOrders; public String resolveCustomerScopeForPlans(List planIds) { return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")), @@ -221,6 +224,7 @@ public class DispatchService { if (selected.size() != palletIds.size()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant"); } + assertNoDetainedOrders(customerId, selected); boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) .anyMatch(value -> value == null || value.isBlank()); if (missingWarehouse) { @@ -351,6 +355,23 @@ public class DispatchService { return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); } + private void assertNoDetainedOrders(String customerId, List selected) { + List orderIds = selected.stream().map(DispatchPalletCandidate::orderId) + .filter(Objects::nonNull).distinct().sorted().toList(); + if (orderIds.isEmpty()) return; + List scoped = tailOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId) + .in(LastlegOrder::getId, orderIds) + .orderByAsc(LastlegOrder::getId) + .last("FOR UPDATE")); + List detainedNos = scoped.stream().filter(order -> Boolean.TRUE.equals(order.getDetained())) + .map(LastlegOrder::getOrderNo).filter(StringUtils::hasText).distinct().toList(); + if (!detainedNos.isEmpty()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "暂扣订单不可创建排车或出库计划:" + String.join("、", detainedNos)); + } + } + public PageResult page(String customerId, DispatchRequests.Query query) { if (!StringUtils.hasText(customerId)) { return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderCommandDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderCommandDtos.java new file mode 100644 index 000000000..d6e4a546f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderCommandDtos.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.Min; +import java.time.LocalDateTime; +import java.util.List; + +public final class LastlegOrderCommandDtos { + private LastlegOrderCommandDtos() { } + + public record HoldRequest(@NotNull Boolean held, @NotNull Boolean chargeable, + @NotBlank @Size(max = 500) String reason, + @NotNull @Min(0) Integer expectedVersion) { } + public record HoldResponse(long orderId, boolean held, boolean chargeable, String reason, int version) { } + + public record ManualNode(@NotBlank @Size(max = 32) String nodeCode, + LocalDateTime nodeAt) { } + public record ManualNodesRequest(@NotNull @Min(0) Integer expectedVersion, + @NotEmpty @Size(min = 8, max = 8) List<@Valid ManualNode> nodes) { } + public record ManualNodesResponse(long orderId, boolean manualOverrideEnabled, + List nodes, int version) { } + public record ManualNodeView(String nodeCode, LocalDateTime nodeAt, String source) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java index 27650b7b0..7be87d591 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java @@ -9,6 +9,7 @@ import java.util.List; public record LastlegOrderDetailResponse( LastlegOrderSummary order, + List manualNodes, List cargo, List statusHistory, List fees, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index a87b5a39c..88f84bda9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -32,6 +32,11 @@ public record LastlegOrderSummary( String containerStatus, Boolean abnormal, Boolean detained, + Boolean heldChargeable, + String holdReason, + boolean manualOverrideEnabled, + String nodeSource, + int version, Integer palletCount, Integer packageCount, BigDecimal volumeCbm, 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 93a4757ef..b86b2a53f 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 @@ -39,6 +39,10 @@ public class LastlegOrder { private String containerStatus; private Boolean abnormal; private Boolean detained; + private Boolean holdChargeable; + private String holdReason; + private Boolean manualNodeOverride; + private Integer version; private Integer palletCount; private Integer packageCount; private Integer pickedPackageCount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderCommandRecord.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderCommandRecord.java new file mode 100644 index 000000000..9f026da84 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderCommandRecord.java @@ -0,0 +1,23 @@ +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_order_command_record") +public class LastlegOrderCommandRecord { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private String action; + private String operatorId; + private String idempotencyKey; + private String requestHash; + private String resultRef; + private String resultJson; + private Integer resultVersion; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderHoldHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderHoldHistory.java new file mode 100644 index 000000000..2abb9b1c9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderHoldHistory.java @@ -0,0 +1,23 @@ +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_order_hold_history") +public class LastlegOrderHoldHistory { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private Boolean held; + private Boolean chargeable; + private String reason; + private Integer orderVersion; + private String operatorId; + private String operatorName; + private String idempotencyKey; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNode.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNode.java new file mode 100644 index 000000000..323fc685c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNode.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_order_manual_node") +public class LastlegOrderManualNode { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private String nodeCode; + private LocalDateTime nodeAt; + private String source; + private Integer orderVersion; + private String updatedBy; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNodeHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNodeHistory.java new file mode 100644 index 000000000..cc3ec6899 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNodeHistory.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_order_manual_node_history") +public class LastlegOrderManualNodeHistory { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private String nodesJson; + private Integer orderVersion; + private String operatorId; + private String operatorName; + private String idempotencyKey; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 849239c28..b626a54b2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -3,8 +3,8 @@ package com.baiyue.lastleg.inboundplan.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; @@ -44,14 +44,19 @@ public class InboundPlanController { return ApiResponse.success(service.page(scope(user, customerId), query)); } - @PostMapping("/convert") + @PostMapping("/manual") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") - /** 由平台人工将待分配尾程订单转换为入仓计划。 */ - @Operation(summary = "人工创建入仓计划", description = "头程装柜完成只生成待分配尾程订单;平台确认后调用本接口创建入仓计划。Required authority: " + @Operation(summary = "手工填写并创建单票入仓计划", description = "要求订单版本和 Idempotency-Key;仓库主档、入仓码及客户归属均由服务端校验。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) - public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody ConvertInboundPlanRequest request) { - return ApiResponse.success(service.convert(convertScope(user, request.customerId()), request.orderId())); + public ApiResponse manual(@AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody ManualInboundPlanRequest request) { + if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可手工创建入仓计划"); + } + convertScope(user, request.customerId()); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(service.convertManual(request, idempotencyKey, operatorId)); } @PostMapping("/convert-container") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java deleted file mode 100644 index 615215fa4..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.baiyue.lastleg.inboundplan.dto; - -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -public record ConvertInboundPlanRequest( - @NotNull Long orderId, - @Size(max = 128) String customerId -) { -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java index f7c7ece04..8188d0a75 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.dto; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.List; public record InboundPlanResponse( @@ -21,7 +22,14 @@ public record InboundPlanResponse( List lines, List pallets, List allocations, - int version + int version, + LocalDate plannedArrivalDate, + String palletType, + boolean assigned, + boolean mergeRequired, + boolean urgent, + String creationMode, + String remark ) { public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, @@ -32,7 +40,7 @@ public record InboundPlanResponse( String destinationWarehouseCode, String destinationWarehouseName, BigDecimal capacityCbm, BigDecimal allocatedVolumeCbm, int sequenceNo, Long warehousePalletId, Long warehouseLocationId, Long warehouseResourceLockId, - String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm, + String palletType, String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm, int actualPackageCount) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java new file mode 100644 index 000000000..fb8bd05e8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; + +public record ManualInboundPlanRequest( + @NotBlank @Size(max = 128) String customerId, + @NotNull Long orderId, + @NotNull @Min(0) Integer expectedOrderVersion, + @NotNull Long plannedWarehouseId, + @NotBlank @Size(max = 128) String plannedWarehouseCode, + @NotBlank @Size(max = 255) String plannedWarehouseName, + @NotNull LocalDate plannedArrivalDate, + @NotBlank @Size(max = 128) String inboundCode, + @NotNull @Min(1) @Max(999) Integer palletCount, + @NotBlank @Size(max = 32) String palletType, + @NotNull Boolean assigned, + @NotNull Boolean mergeRequired, + @NotNull Boolean urgent, + @Size(max = 1000) String remark +) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java index 51c30b470..5a675332c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java @@ -8,6 +8,7 @@ import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.LocalDate; @Data @TableName("ll_inbound_plan") @@ -19,6 +20,13 @@ public class InboundPlan { private Long sourceOrderId; private String containerNo; private Long overseasWarehouseId; + private LocalDate plannedArrivalDate; + private String palletType; + private Boolean assignedFlag; + private Boolean mergeRequired; + private Boolean urgentFlag; + private String creationMode; + private String planRemark; private String status; private Integer lineCount; private Integer palletCount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java index fbd1be83b..5e2085168 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java @@ -17,6 +17,7 @@ public class InboundPlanPallet { private Long planId; private String palletNo; private String sourcePalletNo; + private String palletType; private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index 8c180a6e8..77bfc983d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -36,12 +36,12 @@ public interface InboundPlanOrderMapper { "COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," + "CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 " + "ELSE COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) END AS destinationCount," + - "0 AS destinationMismatchCount " + + "0 AS destinationMismatchCount,o.detained AS detained,o.version AS version " + "FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " + "WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " + "GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.pallet_count," + "o.overseas_warehouse_id,o.overseas_warehouse_code,o.overseas_warehouse_name,o.fba_warehouse_code," + - "o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " + + "o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name,o.detained,o.version " + "ORDER BY o.id FOR UPDATE") List lockOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); @@ -59,6 +59,17 @@ public interface InboundPlanOrderMapper { @Param("planNo") String planNo, @Param("palletCount") int palletCount, @Param("updatedAt") LocalDateTime updatedAt); + @Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," + + "pallet_merge_flag=CASE WHEN #{mergeRequired}=1 THEN 'MERGE_REQUIRED' ELSE NULL END," + + "pallet_count=#{palletCount},version=version+1,updated_at=#{updatedAt} " + + "WHERE customer_id=#{customerId} AND id=#{orderId} AND version=#{expectedVersion} AND detained=0 " + + "AND current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') AND inbound_order_no IS NULL") + int markPlannedManual(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("planNo") String planNo, @Param("palletCount") int palletCount, + @Param("mergeRequired") boolean mergeRequired, + @Param("expectedVersion") int expectedVersion, + @Param("updatedAt") LocalDateTime updatedAt); + @Insert("INSERT INTO ll_loading_order_status_history(customer_id,order_id,from_status,to_status,remark,created_at) " + "VALUES(#{customerId},#{orderId},#{fromStatus},'INBOUND_PLANNED',#{remark},#{createdAt})") int insertHistory(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("fromStatus") String fromStatus, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java index a816826c0..dd67b0d6d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java @@ -20,5 +20,19 @@ public record SourceOrderSnapshot( String destinationWarehouseCode, String destinationWarehouseName, int destinationCount, - int destinationMismatchCount -) { } + int destinationMismatchCount, + boolean detained, + int version +) { + public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode, + String currentStatus, String inboundOrderNo, BigDecimal volumeCbm, + int packageCount, int recommendedPalletCount, String overseasWarehouseId, + String overseasWarehouseCode, String overseasWarehouseName, String fbaWarehouseCode, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, int destinationCount, int destinationMismatchCount) { + this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm, + packageCount, recommendedPalletCount, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, + fbaWarehouseCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, + destinationCount, destinationMismatchCount, false, 0); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 4c5f08a41..ff07150f4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest; import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; @@ -23,6 +24,11 @@ import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.common.event.ContainerNumbers; +import com.baiyue.lastleg.entity.LastlegOrderCommandRecord; +import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -31,6 +37,10 @@ import org.springframework.web.server.ResponseStatusException; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -50,6 +60,64 @@ public class InboundPlanService { @Resource private InboundPlanQueryMapper queryMapper; @Resource private InventoryBalanceMapper inventoryBalanceMapper; @Resource private DropshipOrderService dropshipOrders; + @Resource private WarehouseResourceGateway warehouseGateway; + @Resource private LastlegOrderCommandRecordMapper commandMapper; + @Resource private ObjectMapper objectMapper; + + @Transactional + public InboundPlanResponse convertManual(ManualInboundPlanRequest request, String idempotencyKey, + String operatorId) { + String customerId = trimToNull(request.customerId()); + if (customerId == null || "0".equals(customerId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "平台人工计划必须指定客户"); + } + String key = trimToNull(idempotencyKey); + if (key == null || key.length() > 128) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency-Key 必须为1至128个字符"); + } + List snapshots = orderMapper.lockOrder(customerId, request.orderId()); + if (snapshots == null || snapshots.size() != 1) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在"); + } + SourceOrderSnapshot order = snapshots.get(0); + String requestHash = sha256(request); + LastlegOrderCommandRecord replay = commandMapper.find(customerId, request.orderId(), "MANUAL_PLAN", operatorId, key); + if (replay != null) { + if (!MessageDigest.isEqual(replay.getRequestHash().getBytes(StandardCharsets.UTF_8), + requestHash.getBytes(StandardCharsets.UTF_8))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "同一幂等键不能用于不同请求"); + } + return detailByPlanNo(customerId, replay.getResultRef()); + } + if (planMapper.findBySourceOrder(customerId, request.orderId()) != null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单已生成入库计划"); + } + if (order.version() != request.expectedOrderVersion()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单版本已变化,请刷新后重试"); + } + if (order.detained()) throw new ResponseStatusException(HttpStatus.CONFLICT, "暂扣尾程订单不能生成入库计划"); + if (!Objects.equals(trimToNull(order.inboundCode()), trimToNull(request.inboundCode()))) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "入仓码与尾程订单冻结事实不一致"); + } + WarehouseResourceGateway.WarehouseReference warehouse = + warehouseGateway.requireActiveSharedWarehouse(request.plannedWarehouseId()); + if (!Objects.equals(String.valueOf(warehouse.warehouseId()), trimToNull(order.overseasWarehouseId())) + || !Objects.equals(warehouse.warehouseCode(), request.plannedWarehouseCode().trim()) + || !Objects.equals(warehouse.warehouseName(), request.plannedWarehouseName().trim()) + || !Objects.equals(warehouse.warehouseCode(), trimToNull(order.overseasWarehouseCode())) + || !Objects.equals(warehouse.warehouseName(), trimToNull(order.overseasWarehouseName()))) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "计划入仓仓库不存在、已停用或与订单预分配事实不一致"); + } + validateOrders(snapshots); + InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null, request); + LastlegOrderCommandRecord command = new LastlegOrderCommandRecord(); + command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setOrderId(request.orderId()); + command.setAction("MANUAL_PLAN"); command.setOperatorId(operatorId); command.setIdempotencyKey(key); + command.setRequestHash(requestHash); command.setResultRef(response.planNo()); + command.setResultJson(json(response)); command.setResultVersion(request.expectedOrderVersion() + 1); + command.setCreatedAt(LocalDateTime.now()); commandMapper.insert(command); + return response; + } @Transactional(readOnly = true) public PageResult page(String customerId, InboundPlanRequests.Query query) { @@ -112,12 +180,12 @@ public class InboundPlanService { return detail(customerId, existing); } validateOrders(orders); - return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets); + return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, null); } private InboundPlanResponse createPlan(String customerId, Long selectedOrderId, List orders, List sourcePalletNos, - List wholePallets) { + List wholePallets, ManualInboundPlanRequest manual) { String containerNo = requiredContainerNo(orders.get(0).containerNo()); LocalDateTime now = LocalDateTime.now(); InboundPlan plan = new InboundPlan(); @@ -136,6 +204,13 @@ public class InboundPlanService { throw new ResponseStatusException(HttpStatus.CONFLICT, "接柜仓ID格式无效", exception); } plan.setStatus("PENDING_ASSIGNMENT"); + plan.setPlannedArrivalDate(manual == null ? null : manual.plannedArrivalDate()); + plan.setPalletType(manual == null ? null : manual.palletType().trim().toUpperCase()); + plan.setAssignedFlag(manual != null && manual.assigned()); + plan.setMergeRequired(manual != null && manual.mergeRequired()); + plan.setUrgentFlag(manual != null && manual.urgent()); + plan.setCreationMode(manual == null ? "AUTO" : "MANUAL"); + plan.setPlanRemark(manual == null ? null : trimToNull(manual.remark())); plan.setLineCount(orders.size()); plan.setTotalVolumeCbm(orders.stream().map(SourceOrderSnapshot::volumeCbm) .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); @@ -145,16 +220,20 @@ public class InboundPlanService { Map orderById = orders.stream() .collect(Collectors.toMap(SourceOrderSnapshot::orderId, value -> value)); Map lineByOrder = createLines(plan, orders); - PalletAllocator.Result allocation = wholePallets == null ? PalletAllocator.allocate(orders.stream() + PalletAllocator.Result allocation = wholePallets == null && manual == null ? PalletAllocator.allocate(orders.stream() .map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order), order.packageCount(), order.recommendedPalletCount())).toList()) : null; if (wholePallets != null) validateWholePallets(orders, wholePallets); - plan.setPalletCount(wholePallets == null ? allocation.pallets().size() : wholePallets.size()); + plan.setPalletCount(manual != null ? manual.palletCount() + : wholePallets == null ? allocation.pallets().size() : wholePallets.size()); planMapper.insert(plan); lineByOrder.values().forEach(lineMapper::insert); Map palletCounts; - if (wholePallets == null) { + if (manual != null) { + createManualPallets(plan, orders.get(0), lineByOrder.get(orders.get(0).orderId()), manual); + palletCounts = Map.of(orders.get(0).orderId(), manual.palletCount()); + } else if (wholePallets == null) { Map persistedPallets = createPallets(plan, allocation, orderById, sourcePalletNos == null ? List.of() : sourcePalletNos); createAllocations(plan, allocation, lineByOrder, persistedPallets); @@ -171,7 +250,11 @@ public class InboundPlanService { InboundPlanLine line = lineByOrder.get(order.orderId()); line.setPlannedPalletCount(count); lineMapper.updateById(line); - if (orderMapper.markPlanned(customerId, order.orderId(), plan.getPlanNo(), count, now) != 1) { + int updated = manual == null + ? orderMapper.markPlanned(customerId, order.orderId(), plan.getPlanNo(), count, now) + : orderMapper.markPlannedManual(customerId, order.orderId(), plan.getPlanNo(), count, + manual.mergeRequired(), manual.expectedOrderVersion(), now); + if (updated != 1) { throw new IllegalStateException("Failed to update order " + order.orderId()); } inventoryBalanceMapper.bindInboundPlan(customerId, order.orderId(), order.inboundCode(), @@ -248,7 +331,7 @@ public class InboundPlanService { for (int index = 0; index < refs.size(); index++) { InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index); created.add(createPlan(ref.customerId(), ref.orderId(), snapshots.get(index), List.of(), - wholePallets.get(index))); + wholePallets.get(index), null)); } return List.copyOf(created); } @@ -257,6 +340,43 @@ public class InboundPlanService { return new WholePalletInput(pallet.sourcePalletNo(), Math.toIntExact(pallet.packageCount())); } + private void createManualPallets(InboundPlan plan, SourceOrderSnapshot order, InboundPlanLine line, + ManualInboundPlanRequest request) { + if (order.packageCount() > 0 && request.palletCount() > order.packageCount()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "人工计划托盘数不能大于订单箱数"); + } + BigDecimal remainingVolume = volume(order); + int remainingPackages = order.packageCount(); + for (int index = 0; index < request.palletCount(); index++) { + int slots = request.palletCount() - index; + int packages = slots == 1 ? remainingPackages : remainingPackages / slots; + BigDecimal palletVolume = slots == 1 ? remainingVolume + : remainingVolume.divide(BigDecimal.valueOf(slots), 4, java.math.RoundingMode.HALF_UP); + InboundPlanPallet pallet = new InboundPlanPallet(); + pallet.setId(IdWorker.getId()); pallet.setCustomerId(plan.getCustomerId()); pallet.setPlanId(plan.getId()); + pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", index + 1)); + pallet.setPalletType(request.palletType().trim().toUpperCase()); + pallet.setDestinationWarehouseId(order.destinationWarehouseId()); + pallet.setDestinationWarehouseCode(order.destinationWarehouseCode()); + pallet.setDestinationWarehouseName(order.destinationWarehouseName()); + pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); pallet.setAllocatedVolumeCbm(palletVolume); + pallet.setSequenceNo(index + 1); pallet.setAssignmentStatus("UNASSIGNED"); + pallet.setActualVolumeCbm(BigDecimal.ZERO); pallet.setActualPackageCount(0); palletMapper.insert(pallet); + + InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); + allocation.setId(IdWorker.getId()); allocation.setCustomerId(plan.getCustomerId()); allocation.setPlanId(plan.getId()); + allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); + allocation.setAllocatedVolumeCbm(palletVolume); allocation.setPlannedPackageCount(packages); + allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0); + allocation.setActualVolumeCbm(BigDecimal.ZERO); allocation.setStatus("PENDING_COUNT"); allocation.setVersion(0); + allocationMapper.insert(allocation); + remainingPackages -= packages; remainingVolume = remainingVolume.subtract(palletVolume); + } + if (remainingPackages != 0 || remainingVolume.signum() != 0) { + throw new IllegalStateException("人工计划托盘分配未保持箱数或体积守恒"); + } + } + private String requiredContainerNo(String value) { try { return ContainerNumbers.requireCanonical(value); @@ -395,7 +515,7 @@ public class InboundPlanService { plan.setPalletCount(plan.getPalletCount() + 1); plan.setUpdatedAt(LocalDateTime.now()); planMapper.updateById(plan); return new InboundPlanResponse.Pallet(pallet.getId(), pallet.getPalletNo(), pallet.getDestinationWarehouseId(), pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(), pallet.getCapacityCbm(), - pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(), null, null, null, pallet.getAssignmentStatus(), + pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(), null, null, null, pallet.getPalletType(), pallet.getAssignmentStatus(), null, pallet.getActualVolumeCbm(), pallet.getActualPackageCount()); } @@ -533,12 +653,14 @@ public class InboundPlanService { pallet.getDestinationWarehouseId(), pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(), pallet.getCapacityCbm(), pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(), pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId(), - pallet.getAssignmentStatus(), pallet.getLockedAt(), pallet.getActualVolumeCbm(), + pallet.getPalletType(), pallet.getAssignmentStatus(), pallet.getLockedAt(), pallet.getActualVolumeCbm(), pallet.getActualPackageCount())).toList(), allocations.stream().map(item -> new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(), item.getAllocatedVolumeCbm(), item.getPlannedPackageCount(), item.getCountedPackageCount(), item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList(), - plan.getVersion() == null ? 0 : plan.getVersion()); + plan.getVersion() == null ? 0 : plan.getVersion(), plan.getPlannedArrivalDate(), plan.getPalletType(), + Boolean.TRUE.equals(plan.getAssignedFlag()), Boolean.TRUE.equals(plan.getMergeRequired()), + Boolean.TRUE.equals(plan.getUrgentFlag()), plan.getCreationMode(), plan.getPlanRemark()); } private BigDecimal volume(SourceOrderSnapshot order) { @@ -554,4 +676,18 @@ public class InboundPlanService { private static String trimToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); } + + private String sha256(Object value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsBytes(value))); + } catch (NoSuchAlgorithmException | JsonProcessingException exception) { + throw new IllegalStateException("无法生成命令摘要", exception); + } + } + + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存命令结果", exception); } + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderCommandRecordMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderCommandRecordMapper.java new file mode 100644 index 000000000..45929d17a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderCommandRecordMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrderCommandRecord; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface LastlegOrderCommandRecordMapper extends BaseMapper { + @Select("SELECT * FROM ll_order_command_record WHERE customer_id=#{customerId} AND order_id=#{orderId} " + + "AND action=#{action} AND operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1") + LastlegOrderCommandRecord find(@Param("customerId") String customerId, @Param("orderId") long orderId, + @Param("action") String action, @Param("operatorId") String operatorId, + @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderHoldHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderHoldHistoryMapper.java new file mode 100644 index 000000000..16f0dc36f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderHoldHistoryMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrderHoldHistory; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LastlegOrderHoldHistoryMapper extends BaseMapper { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeHistoryMapper.java new file mode 100644 index 000000000..3ec9721b9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeHistoryMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrderManualNodeHistory; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LastlegOrderManualNodeHistoryMapper extends BaseMapper { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeMapper.java new file mode 100644 index 000000000..66910511f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrderManualNode; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface LastlegOrderManualNodeMapper extends BaseMapper { + @Delete("DELETE FROM ll_order_manual_node WHERE customer_id=#{customerId} AND order_id=#{orderId}") + int deleteCurrent(@Param("customerId") String customerId, @Param("orderId") long orderId); +} 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 57e3ac02d..1c629aedb 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 @@ -146,8 +146,27 @@ public interface LastlegOrderMapper extends BaseMapper { @Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND id=#{id} FOR UPDATE") LastlegOrder lockById(@Param("customerId") String customerId, @Param("id") Long id); + @Update("UPDATE ll_order SET detained=#{held},hold_chargeable=#{chargeable}," + + "hold_reason=CASE WHEN #{held}=1 THEN #{reason} ELSE NULL END,version=version+1,updated_at=#{updatedAt} " + + "WHERE customer_id=#{customerId} AND id=#{id} AND version=#{expectedVersion}") + int updateHold(@Param("customerId") String customerId, @Param("id") long id, + @Param("held") boolean held, @Param("chargeable") boolean chargeable, + @Param("reason") String reason, @Param("expectedVersion") int expectedVersion, + @Param("updatedAt") java.time.LocalDateTime updatedAt); + + @Update("UPDATE ll_order SET manual_node_override=1,estimated_arrival_at=#{estimatedArrivalAt}," + + "actual_arrival_at=#{actualArrivalAt},estimated_warehouse_at=#{estimatedWarehouseAt}," + + "version=version+1,updated_at=#{updatedAt} WHERE customer_id=#{customerId} AND id=#{id} " + + "AND version=#{expectedVersion}") + int updateManualNodes(@Param("customerId") String customerId, @Param("id") long id, + @Param("estimatedArrivalAt") java.time.LocalDateTime estimatedArrivalAt, + @Param("actualArrivalAt") java.time.LocalDateTime actualArrivalAt, + @Param("estimatedWarehouseAt") java.time.LocalDateTime estimatedWarehouseAt, + @Param("expectedVersion") int expectedVersion, + @Param("updatedAt") java.time.LocalDateTime updatedAt); + @Update("UPDATE ll_order SET picked_package_count=picked_package_count+#{packages},updated_at=NOW() " + - "WHERE customer_id=#{customerId} AND id=#{id} " + + "WHERE customer_id=#{customerId} AND id=#{id} AND detained=0 " + "AND package_count-shipped_package_count-picked_package_count >= #{packages}") int increasePicked(@Param("customerId") String customerId, @Param("id") Long id, @Param("packages") long packages); @@ -156,7 +175,7 @@ public interface LastlegOrderMapper extends BaseMapper { "THEN 'COMPLETED' ELSE 'PARTIALLY_SHIPPED' END," + "picked_package_count=picked_package_count-#{packages}," + "shipped_package_count=shipped_package_count+#{packages},outbound_order_no=#{outboundNo},updated_at=NOW() " + - "WHERE customer_id=#{customerId} AND id=#{id} AND picked_package_count >= #{packages} " + + "WHERE customer_id=#{customerId} AND id=#{id} AND detained=0 AND picked_package_count >= #{packages} " + "AND shipped_package_count+#{packages} <= package_count") int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id, @Param("packages") long packages, @Param("outboundNo") String outboundNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java new file mode 100644 index 000000000..4b71d885c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java @@ -0,0 +1,240 @@ +package com.baiyue.lastleg.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.dto.LastlegOrderCommandDtos; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderCommandRecord; +import com.baiyue.lastleg.entity.LastlegOrderHoldHistory; +import com.baiyue.lastleg.entity.LastlegOrderManualNode; +import com.baiyue.lastleg.entity.LastlegOrderManualNodeHistory; +import com.baiyue.lastleg.entity.LastlegOrderOperationLog; +import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper; +import com.baiyue.lastleg.mapper.LastlegOrderHoldHistoryMapper; +import com.baiyue.lastleg.mapper.LastlegOrderManualNodeHistoryMapper; +import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +public class LastlegOrderCommandService { + private static final String HOLD = "HOLD"; + private static final String MANUAL_NODES = "MANUAL_NODES"; + private static final List NODE_CODES = List.of( + "DOMESTIC_CUSTOMS_COMPLETED", "ESTIMATED_DEPARTURE", "ACTUAL_DEPARTURE", "ESTIMATED_ARRIVAL", + "OVERSEAS_CUSTOMS_COMPLETED", "ACTUAL_ARRIVAL_WAITING_UNLOAD", "PICKUP_APPOINTED", "UNLOADED"); + + @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderCommandRecordMapper commands; + @Resource private LastlegOrderHoldHistoryMapper holdHistory; + @Resource private LastlegOrderManualNodeMapper manualNodes; + @Resource private LastlegOrderManualNodeHistoryMapper manualNodeHistory; + @Resource private LastlegOrderOperationLogMapper operationLogs; + @Resource private InboundOrderDetainedOutboxService detainedOutbox; + @Resource private ObjectMapper objectMapper; + + @Transactional + public LastlegOrderCommandDtos.HoldResponse hold(String requestedCustomerId, long orderId, + String idempotencyKey, + LastlegOrderCommandDtos.HoldRequest request, + String operatorId, String operatorName) { + String key = key(idempotencyKey); + LastlegOrder order = lockOwned(requestedCustomerId, orderId); + String hash = hash(request); + LastlegOrderCommandRecord replay = commands.find(order.getCustomerId(), orderId, HOLD, operatorId, key); + if (replay != null) return replay(replay, hash, LastlegOrderCommandDtos.HoldResponse.class); + int version = version(order); + if (request.expectedVersion() != version) throw conflict("尾程订单版本已变化,请刷新后重试"); + boolean held = request.held(); + if (Boolean.TRUE.equals(order.getDetained()) == held) { + throw conflict(held ? "尾程订单已暂扣" : "尾程订单未处于暂扣状态"); + } + if (held && (positive(order.getPickedPackageCount()) || positive(order.getShippedPackageCount()) + || text(order.getOutboundOrderNo()) != null)) { + throw conflict("尾程订单已进入出库流程,不能新增暂扣"); + } + LocalDateTime now = LocalDateTime.now(); + boolean chargeable = held && request.chargeable(); + String reason = request.reason().trim(); + if (orders.updateHold(order.getCustomerId(), orderId, held, chargeable, reason, version, now) != 1) { + throw conflict("尾程订单版本已变化,请刷新后重试"); + } + int nextVersion = version + 1; + LastlegOrderCommandDtos.HoldResponse response = new LastlegOrderCommandDtos.HoldResponse( + orderId, held, chargeable, held ? reason : null, nextVersion); + LastlegOrderHoldHistory history = new LastlegOrderHoldHistory(); + history.setId(IdWorker.getId()); history.setCustomerId(order.getCustomerId()); history.setOrderId(orderId); + history.setHeld(held); history.setChargeable(chargeable); history.setReason(reason); + history.setOrderVersion(nextVersion); history.setOperatorId(operatorId); history.setOperatorName(operatorName); + history.setIdempotencyKey(key); history.setCreatedAt(now); holdHistory.insert(history); + operation(order, held ? "暂扣" : "解除暂扣", operatorName, + (chargeable ? "计费;" : "不计费;") + reason, now); + saveCommand(order, HOLD, operatorId, key, hash, null, response, nextVersion, now); + order.setDetained(held); + order.setHoldChargeable(chargeable); + order.setHoldReason(held ? reason : null); + order.setVersion(nextVersion); + if (text(order.getSourceInboundId()) != null) { + detainedOutbox.publish(order, held, reason, operatorId, operatorName, + now.toInstant(ZoneOffset.UTC), history.getId()); + } + return response; + } + + @Transactional + public LastlegOrderCommandDtos.ManualNodesResponse saveManualNodes(String requestedCustomerId, long orderId, + String idempotencyKey, + LastlegOrderCommandDtos.ManualNodesRequest request, + String operatorId, String operatorName) { + String key = key(idempotencyKey); + LastlegOrder order = lockOwned(requestedCustomerId, orderId); + List normalized = normalizeNodes(request.nodes()); + String hash = hash(new LastlegOrderCommandDtos.ManualNodesRequest(request.expectedVersion(), normalized)); + LastlegOrderCommandRecord replay = commands.find(order.getCustomerId(), orderId, MANUAL_NODES, operatorId, key); + if (replay != null) return replay(replay, hash, LastlegOrderCommandDtos.ManualNodesResponse.class); + int version = version(order); + if (request.expectedVersion() != version) throw conflict("尾程订单版本已变化,请刷新后重试"); + Map values = new HashMap<>(); + normalized.forEach(node -> values.put(node.nodeCode(), node.nodeAt())); + LocalDateTime now = LocalDateTime.now(); + if (orders.updateManualNodes(order.getCustomerId(), orderId, + values.get("ESTIMATED_ARRIVAL"), values.get("ACTUAL_ARRIVAL_WAITING_UNLOAD"), + null, version, now) != 1) { + throw conflict("尾程订单版本已变化,请刷新后重试"); + } + int nextVersion = version + 1; + manualNodes.deleteCurrent(order.getCustomerId(), orderId); + List views = new ArrayList<>(normalized.size()); + for (LastlegOrderCommandDtos.ManualNode node : normalized) { + LastlegOrderManualNode entity = new LastlegOrderManualNode(); + entity.setId(IdWorker.getId()); entity.setCustomerId(order.getCustomerId()); entity.setOrderId(orderId); + entity.setNodeCode(node.nodeCode()); entity.setNodeAt(node.nodeAt()); entity.setSource("MANUAL"); + entity.setOrderVersion(nextVersion); entity.setUpdatedBy(operatorId); entity.setUpdatedAt(now); + manualNodes.insert(entity); + views.add(new LastlegOrderCommandDtos.ManualNodeView(node.nodeCode(), node.nodeAt(), "MANUAL")); + } + LastlegOrderCommandDtos.ManualNodesResponse response = new LastlegOrderCommandDtos.ManualNodesResponse( + orderId, true, List.copyOf(views), nextVersion); + LastlegOrderManualNodeHistory history = new LastlegOrderManualNodeHistory(); + history.setId(IdWorker.getId()); history.setCustomerId(order.getCustomerId()); history.setOrderId(orderId); + history.setNodesJson(json(views)); history.setOrderVersion(nextVersion); history.setOperatorId(operatorId); + history.setOperatorName(operatorName); history.setIdempotencyKey(key); history.setCreatedAt(now); + manualNodeHistory.insert(history); + operation(order, "人工计划节点", operatorName, "保存" + views.size() + "个人工节点", now); + saveCommand(order, MANUAL_NODES, operatorId, key, hash, null, response, nextVersion, now); + return response; + } + + @Transactional(readOnly = true) + public LastlegOrderCommandDtos.ManualNodesResponse manualNodes(String requestedCustomerId, long orderId) { + LastlegOrder order = findOwned(requestedCustomerId, orderId); + List nodes = manualNodes.selectList( + new LambdaQueryWrapper() + .eq(LastlegOrderManualNode::getCustomerId, order.getCustomerId()) + .eq(LastlegOrderManualNode::getOrderId, orderId) + .orderByAsc(LastlegOrderManualNode::getNodeCode)) + .stream().map(node -> new LastlegOrderCommandDtos.ManualNodeView( + node.getNodeCode(), node.getNodeAt(), node.getSource())) + .sorted(java.util.Comparator.comparingInt(node -> NODE_CODES.indexOf(node.nodeCode()))).toList(); + return new LastlegOrderCommandDtos.ManualNodesResponse(orderId, + Boolean.TRUE.equals(order.getManualNodeOverride()), nodes, version(order)); + } + + private List normalizeNodes(List nodes) { + Set seen = new HashSet<>(); + boolean anyTime = false; + List result = new ArrayList<>(); + for (LastlegOrderCommandDtos.ManualNode node : nodes) { + String code = node.nodeCode().trim().toUpperCase(); + if (!NODE_CODES.contains(code)) throw badRequest("不支持的人工展示节点:" + code); + if (!seen.add(code)) throw badRequest("人工节点不能重复:" + code); + result.add(new LastlegOrderCommandDtos.ManualNode(code, node.nodeAt())); + anyTime |= node.nodeAt() != null; + } + if (!seen.equals(Set.copyOf(NODE_CODES))) throw badRequest("必须提交完整8行人工时间快照"); + if (!anyTime) throw badRequest("人工时间快照至少填写一个节点时间"); + result.sort(java.util.Comparator.comparingInt(node -> NODE_CODES.indexOf(node.nodeCode()))); + return List.copyOf(result); + } + + private LastlegOrder lockOwned(String requestedCustomerId, long orderId) { + LastlegOrder found = findOwned(requestedCustomerId, orderId); + LastlegOrder locked = orders.lockById(found.getCustomerId(), orderId); + if (locked == null) throw notFound(); + return locked; + } + + private LastlegOrder findOwned(String requestedCustomerId, long orderId) { + LastlegOrder order = orders.selectById(orderId); + if (order == null || (text(requestedCustomerId) != null + && !requestedCustomerId.trim().equals(order.getCustomerId()))) throw notFound(); + return order; + } + + private void operation(LastlegOrder order, String node, String operatorName, String result, LocalDateTime now) { + LastlegOrderOperationLog log = new LastlegOrderOperationLog(); + log.setId(IdWorker.getId()); log.setCustomerId(order.getCustomerId()); log.setOrderId(order.getId()); + log.setOperationNode(node); log.setOperatorName(operatorName); log.setResult(result); log.setCreatedAt(now); + operationLogs.insert(log); + } + + private void saveCommand(LastlegOrder order, String action, String operatorId, String key, String hash, + String resultRef, Object response, int resultVersion, LocalDateTime now) { + LastlegOrderCommandRecord record = new LastlegOrderCommandRecord(); + record.setId(IdWorker.getId()); record.setCustomerId(order.getCustomerId()); record.setOrderId(order.getId()); + record.setAction(action); record.setOperatorId(operatorId); record.setIdempotencyKey(key); + record.setRequestHash(hash); record.setResultRef(resultRef); record.setResultJson(json(response)); + record.setResultVersion(resultVersion); record.setCreatedAt(now); commands.insert(record); + } + + private T replay(LastlegOrderCommandRecord record, String hash, Class type) { + if (!MessageDigest.isEqual(record.getRequestHash().getBytes(StandardCharsets.UTF_8), + hash.getBytes(StandardCharsets.UTF_8))) throw conflict("同一幂等键不能用于不同请求"); + try { return objectMapper.readValue(record.getResultJson(), type); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法读取已保存命令结果", exception); } + } + + private String hash(Object request) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsBytes(request))); + } catch (NoSuchAlgorithmException | JsonProcessingException exception) { + throw new IllegalStateException("无法生成命令摘要", exception); + } + } + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存命令结果", exception); } + } + private String key(String value) { + String key = text(value); + if (key == null || key.length() > 128) throw badRequest("Idempotency-Key 必须为1至128个字符"); + return key; + } + private int version(LastlegOrder order) { return order.getVersion() == null ? 0 : order.getVersion(); } + private boolean positive(Integer value) { return value != null && value > 0; } + private String text(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在"); } + private ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} 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 b6ff873e0..8490574b0 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 @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.event.ContainerNumbers; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; +import com.baiyue.lastleg.dto.LastlegOrderCommandDtos; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; @@ -15,6 +16,7 @@ import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.entity.LastlegOrderFee; import com.baiyue.lastleg.entity.LastlegOrderOperationLog; +import com.baiyue.lastleg.entity.LastlegOrderManualNode; import com.baiyue.lastleg.entity.ReceivingWarehouseOperation; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; @@ -25,6 +27,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.mapper.LastlegOrderFeeMapper; import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; +import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper; import com.baiyue.lastleg.model.LastlegOrderType; import jakarta.annotation.Resource; @@ -54,12 +57,16 @@ public class LastlegOrderService { public static final String INBOUND_PLANNED = "INBOUND_PLANNED"; public static final String PALLET_MERGE_REQUIRED = "REQUIRED"; public static final String PALLET_MERGED = "MERGED"; + private static final List MANUAL_NODE_CODES = List.of( + "DOMESTIC_CUSTOMS_COMPLETED", "ESTIMATED_DEPARTURE", "ACTUAL_DEPARTURE", "ESTIMATED_ARRIVAL", + "OVERSEAS_CUSTOMS_COMPLETED", "ACTUAL_ARRIVAL_WAITING_UNLOAD", "PICKUP_APPOINTED", "UNLOADED"); @Resource private LastlegOrderMapper orderMapper; @Resource private LastlegOrderDetailMapper detailMapper; @Resource private LastlegOrderStatusHistoryMapper historyMapper; @Resource private LastlegOrderFeeMapper feeMapper; @Resource private LastlegOrderOperationLogMapper operationLogMapper; + @Resource private LastlegOrderManualNodeMapper manualNodeMapper; @Resource private InboundPlanCartonMapper cartonMapper; @Resource private FbaWarehouseValidator fbaWarehouseValidator; @Resource private InventoryCommandService inventory; @@ -243,7 +250,15 @@ public class LastlegOrderService { .eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId()) .eq(LastlegOrderOperationLog::getOrderId, orderId) .orderByAsc(LastlegOrderOperationLog::getCreatedAt)); - return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId())), cargo, history.stream() + List manualNodes = manualNodeMapper.selectList( + new LambdaQueryWrapper() + .eq(LastlegOrderManualNode::getCustomerId, order.getCustomerId()) + .eq(LastlegOrderManualNode::getOrderId, orderId) + .orderByAsc(LastlegOrderManualNode::getId)).stream() + .map(value -> new LastlegOrderCommandDtos.ManualNodeView( + value.getNodeCode(), value.getNodeAt(), value.getSource())) + .sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList(); + return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream() .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream() .map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(), @@ -552,7 +567,9 @@ public class LastlegOrderService { displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(), o.getOverseasWarehouseCode(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(), o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(), - o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), + o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(), + Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER", + o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(), o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(), o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 8749cd660..63b2c2a6f 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -70,6 +70,10 @@ CREATE TABLE IF NOT EXISTS ll_order ( container_status VARCHAR(32) DEFAULT '待转柜' COMMENT '货柜状态', abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是', detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', + hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT '当前暂扣是否计费:0否1是', + hold_reason VARCHAR(500) NULL COMMENT '当前暂扣原因;解除后为空', + manual_node_override TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用单票人工计划节点覆盖:0否1是,启用后不再继承柜级节点', + version INT NOT NULL DEFAULT 0 COMMENT '尾程订单命令乐观锁版本', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已拣箱数', @@ -110,6 +114,14 @@ CREATE TABLE IF NOT EXISTS ll_order ( SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag')); PREPARE ll_order_merge_batch_ddl FROM @ddl; EXECUTE ll_order_merge_batch_ddl; DEALLOCATE PREPARE ll_order_merge_batch_ddl; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_chargeable'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT ''当前暂扣是否计费:0否1是'' AFTER detained')); +PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_reason'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_reason VARCHAR(500) NULL COMMENT ''当前暂扣原因;解除后为空'' AFTER hold_chargeable')); +PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='manual_node_override'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN manual_node_override TINYINT NOT NULL DEFAULT 0 COMMENT ''是否启用单票人工计划节点覆盖:0否1是'' AFTER hold_reason')); +PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''尾程订单命令乐观锁版本'' AFTER manual_node_override')); +PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl; CREATE TABLE IF NOT EXISTS ll_order_detail ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', @@ -179,6 +191,47 @@ CREATE TABLE IF NOT EXISTS ll_order_operation_log ( KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='尾程订单操作记录'; +CREATE TABLE IF NOT EXISTS ll_order_command_record ( + id BIGINT NOT NULL COMMENT '尾程订单命令幂等记录ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', action VARCHAR(32) NOT NULL COMMENT '命令动作:MANUAL_PLAN、HOLD、MANUAL_NODES', + operator_id VARCHAR(128) NOT NULL COMMENT '平台操作人ID', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要', result_ref VARCHAR(128) NULL COMMENT '结果业务引用', + result_json LONGTEXT NOT NULL COMMENT '首次成功响应JSON快照', result_version INT NOT NULL COMMENT '命令成功后的订单版本', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_command_key(customer_id,order_id,action,operator_id,idempotency_key), + KEY idx_ll_order_command_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单写命令幂等与结果审计记录'; + +CREATE TABLE IF NOT EXISTS ll_order_hold_history ( + id BIGINT NOT NULL COMMENT '尾程订单暂扣历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', held TINYINT NOT NULL COMMENT '操作后是否暂扣:0否1是', + chargeable TINYINT NOT NULL COMMENT '本次暂扣是否计费:0否1是', reason VARCHAR(500) NOT NULL COMMENT '暂扣或解除原因', + order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', + created_at DATETIME(3) NOT NULL COMMENT '操作时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_hold_key(customer_id,order_id,operator_id,idempotency_key), + KEY idx_ll_order_hold_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单暂扣与解除不可变历史'; + +CREATE TABLE IF NOT EXISTS ll_order_manual_node ( + id BIGINT NOT NULL COMMENT '尾程订单人工节点ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', node_code VARCHAR(32) NOT NULL COMMENT '八行人工展示节点稳定代码,与Carrier标准节点隔离', + node_at DATETIME(3) NULL COMMENT '人工填写节点时间,允许为空', source VARCHAR(16) NOT NULL DEFAULT 'MANUAL' COMMENT '节点来源,固定MANUAL', + order_version INT NOT NULL COMMENT '最近编辑后的订单版本', updated_by VARCHAR(128) NOT NULL COMMENT '最近操作人ID', + updated_at DATETIME(3) NOT NULL COMMENT '最近编辑时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_manual_node(customer_id,order_id,node_code), KEY idx_ll_order_manual_node_order(customer_id,order_id,node_at) +) ENGINE=InnoDB COMMENT='尾程订单当前人工节点覆盖事实'; + +CREATE TABLE IF NOT EXISTS ll_order_manual_node_history ( + id BIGINT NOT NULL COMMENT '尾程订单人工节点历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', nodes_json LONGTEXT NOT NULL COMMENT '本次全量人工节点JSON快照', + order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', + created_at DATETIME(3) NOT NULL COMMENT '操作时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_manual_node_history_key(customer_id,order_id,operator_id,idempotency_key), + KEY idx_ll_order_manual_node_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单人工节点覆盖不可变历史'; + CREATE TABLE IF NOT EXISTS ll_receiving_warehouse_operation ( id BIGINT NOT NULL COMMENT '柜级接柜仓修改操作ID', source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', @@ -504,6 +557,13 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan ( source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL DEFAULT 193000000000200003 COMMENT '平台预分配的海外实际收货共享物理仓ID', + planned_arrival_date DATE NULL COMMENT '人工计划预计到仓日期', + pallet_type VARCHAR(32) NULL COMMENT '人工计划托盘类型', + assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否已分配:0否1是', + merge_required TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否要求合托:0否1是', + urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否加急:0否1是', + creation_mode VARCHAR(16) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工', + plan_remark VARCHAR(1000) NULL COMMENT '入仓计划备注', status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量', @@ -557,6 +617,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( plan_id BIGINT NOT NULL COMMENT '入库计划ID', pallet_no VARCHAR(64) NOT NULL COMMENT '当前托盘编号:分配前为计划占位码,分配后为海外仓实物托盘码', source_pallet_no VARCHAR(128) NULL COMMENT '一件代发不可变来源托盘号快照', + pallet_type VARCHAR(32) NULL COMMENT '人工计划托盘类型', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', @@ -588,6 +649,20 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='source_order_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN source_order_id BIGINT NOT NULL DEFAULT 0 COMMENT ''来源尾程订单ID,一张入仓单对应一张入库计划'' AFTER customer_id')); PREPARE inbound_plan_source_order_stmt FROM @ddl; EXECUTE inbound_plan_source_order_stmt; DEALLOCATE PREPARE inbound_plan_source_order_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='planned_arrival_date'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN planned_arrival_date DATE NULL COMMENT ''人工计划预计到仓日期'' AFTER overseas_warehouse_id')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER planned_arrival_date')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='assigned_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否已分配:0否1是'' AFTER pallet_type')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='merge_required'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN merge_required TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否要求合托:0否1是'' AFTER assigned_flag')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='urgent_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否加急:0否1是'' AFTER merge_required')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='creation_mode'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN creation_mode VARCHAR(16) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工'' AFTER urgent_flag')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='plan_remark'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN plan_remark VARCHAR(1000) NULL COMMENT ''入仓计划备注'' AFTER creation_mode')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; ALTER TABLE ll_inbound_plan MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', MODIFY COLUMN plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', @@ -640,6 +715,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''海外仓实物托盘ID''')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER pallet_no')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_location_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_location_id BIGINT NULL COMMENT ''海外仓库位ID''')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_resource_lock_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''海外仓资源锁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 83f5a212a..9c24059fc 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -192,25 +192,46 @@ paths: responses: '200': { description: 新增后的费用明细, content: { application/json: { schema: { $ref: '#/components/schemas/LastMileFeeApiResponse' } } } } '404': { description: 尾程订单不存在或不属于操作客户 } - /api/platform/v1/last-leg/orders/{id}/detained: + /api/platform/v1/last-leg/orders/{id}/hold: put: summary: 设置或解除尾程订单暂扣 - description: 权限 platform:overseas-warehouse:last-mile-order:edit;库存同步转入或释放暂扣,并通过跨区事件回写国内入仓单。 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;使用订单版本和 Idempotency-Key 原子写入计费标记、原因及不可变历史。已进入出库流程的订单不得新增暂扣;暂扣只作用于当前票,不影响同柜其他订单。 security: [{ bearerAuth: [] }] parameters: - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } requestBody: required: true content: application/json: - schema: - type: object - required: [detained, remark] - properties: - detained: { type: boolean } - remark: { type: string, minLength: 1, maxLength: 500 } - responses: { '200': { description: 暂扣状态已更新 } } + schema: { $ref: '#/components/schemas/LastlegOrderHoldRequest' } + responses: + '200': { description: 暂扣状态已更新, content: { application/json: { schema: { type: object, properties: { code: { type: string }, data: { $ref: '#/components/schemas/LastlegOrderHoldResponse' } } } } } } + '409': { description: 版本冲突、幂等键冲突、重复状态或订单已进入出库流程 } + /api/platform/v1/last-leg/orders/{id}/manual-nodes: + get: + summary: 查询尾程订单人工展示节点 + description: 权限 platform:overseas-warehouse:last-mile-order:list;人工覆盖开启后返回完整且固定顺序的8行快照,行值允许为空。 + security: [{ bearerAuth: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + responses: { '200': { description: 完整人工展示节点快照 } } + put: + summary: 永久启用并保存尾程订单人工展示节点 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;提交完整8行快照且至少一个时间非空,使用订单版本和 Idempotency-Key;保存后 nodeSource 永久为 MANUAL,不回写柜节点,也不提供清除或恢复继承接口。 + security: [{ bearerAuth: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/LastlegOrderManualNodesRequest' } } } + responses: + '200': { description: 更新后的完整8行人工展示节点快照 } + '409': { description: 订单版本或幂等键冲突 } /api/platform/v1/last-leg/orders/{id}/pallet-merge-required: put: summary: 设置尾程订单合托标记 @@ -381,22 +402,22 @@ paths: responses: '200': { description: 平台入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } } '404': { description: 入库订单不存在或不属于指定客户 } - /api/platform/v1/last-leg/inbound-plans/convert: + /api/platform/v1/last-leg/inbound-plans/manual: post: - summary: 平台生成入库计划 - description: 权限 platform:overseas-warehouse:inbound-order:create;必须同时传入真实 customerId 与 orderId,服务端按该客户和订单 ID 锁定归属,缺失 customerId 返回 A0400。 + summary: 平台按单票手工生成入库计划 + description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、暂扣状态、真实入仓码、客户归属及有效公共接柜仓。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。 security: [{ bearerAuth: [] }] + parameters: + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } requestBody: required: true content: application/json: - schema: - type: object - required: [customerId, orderId] - properties: - customerId: { type: string, pattern: '^[1-9][0-9]*$' } - orderId: { type: string, pattern: '^[1-9][0-9]*$' } - responses: { '200': { description: 统一 ApiResponse 包装的入库计划详情 } } + schema: { $ref: '#/components/schemas/ManualInboundPlanRequest' } + responses: + '200': { description: 统一 ApiResponse 包装的入库计划详情 } + '400': { description: 客户、入仓码、仓库主数据或请求字段无效 } + '409': { description: 订单版本、状态、暂扣状态、重复计划或幂等键冲突 } /api/platform/v1/last-leg/inbound-plans/convert-container: post: summary: 按整柜批量生成入库计划 @@ -1796,6 +1817,61 @@ components: - { $ref: '#/components/schemas/MerchantApiResponse' } - type: object properties: { data: { $ref: '#/components/schemas/InboundPlanDetail' } } + LastlegOrderHoldRequest: + type: object + required: [held, chargeable, reason, expectedVersion] + properties: + held: { type: boolean } + chargeable: { type: boolean, description: 仅 held=true 时生效;解除暂扣后固定为 false } + reason: { type: string, minLength: 1, maxLength: 500 } + expectedVersion: { type: integer, minimum: 0 } + LastlegOrderHoldResponse: + type: object + required: [orderId, held, chargeable, version] + properties: + orderId: { type: integer, format: int64 } + held: { type: boolean } + chargeable: { type: boolean } + reason: { type: string, nullable: true } + version: { type: integer, minimum: 0 } + LastlegOrderManualNodeCode: + type: string + enum: [DOMESTIC_CUSTOMS_COMPLETED, ESTIMATED_DEPARTURE, ACTUAL_DEPARTURE, ESTIMATED_ARRIVAL, OVERSEAS_CUSTOMS_COMPLETED, ACTUAL_ARRIVAL_WAITING_UNLOAD, PICKUP_APPOINTED, UNLOADED] + LastlegOrderManualNode: + type: object + required: [nodeCode] + properties: + nodeCode: { $ref: '#/components/schemas/LastlegOrderManualNodeCode' } + nodeAt: { type: string, format: date-time, nullable: true } + LastlegOrderManualNodesRequest: + type: object + required: [expectedVersion, nodes] + properties: + expectedVersion: { type: integer, minimum: 0 } + nodes: + type: array + minItems: 8 + maxItems: 8 + description: 必须包含8个枚举节点且各出现一次,至少一个 nodeAt 非空。 + items: { $ref: '#/components/schemas/LastlegOrderManualNode' } + ManualInboundPlanRequest: + type: object + required: [customerId, orderId, expectedOrderVersion, plannedWarehouseId, plannedWarehouseCode, plannedWarehouseName, plannedArrivalDate, inboundCode, palletCount, palletType, assigned, mergeRequired, urgent] + properties: + customerId: { type: string, pattern: '^[1-9][0-9]*$' } + orderId: { type: integer, format: int64, minimum: 1 } + expectedOrderVersion: { type: integer, minimum: 0 } + plannedWarehouseId: { type: integer, format: int64, minimum: 1 } + plannedWarehouseCode: { type: string, minLength: 1, maxLength: 64 } + plannedWarehouseName: { type: string, minLength: 1, maxLength: 255 } + plannedArrivalDate: { type: string, format: date } + inboundCode: { type: string, minLength: 1, maxLength: 128 } + palletCount: { type: integer, minimum: 1, maximum: 999 } + palletType: { type: string, minLength: 1, maxLength: 64 } + assigned: { type: boolean } + mergeRequired: { type: boolean } + urgent: { type: boolean } + remark: { type: string, nullable: true, maxLength: 1000 } PageMetadata: type: object required: [page, size, total, pages] @@ -2022,6 +2098,13 @@ components: totalVolumeCbm: { type: number } expectedPackageCount: { type: integer } pickedPackageCount: { type: integer } + plannedArrivalDate: { type: string, format: date, nullable: true } + palletType: { type: string, nullable: true } + assigned: { type: boolean } + mergeRequired: { type: boolean } + urgent: { type: boolean } + creationMode: { type: string, enum: [AUTO, MANUAL] } + remark: { type: string, nullable: true } lines: { type: array, items: { $ref: '#/components/schemas/InboundPlanLine' } } pallets: { type: array, items: { $ref: '#/components/schemas/InboundPlanPallet' } } allocations: { type: array, items: { $ref: '#/components/schemas/InboundPlanAllocation' } } @@ -2044,6 +2127,7 @@ components: properties: id: { type: string, pattern: '^[0-9]+$' } palletNo: { type: string } + palletType: { type: string, nullable: true } destinationWarehouseId: { type: string, nullable: true } destinationWarehouseCode: { type: string, nullable: true } destinationWarehouseName: { type: string, nullable: true } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java index a039d62b0..f3ca58fc2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -9,6 +9,8 @@ import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.service.LastlegOrderService; +import com.baiyue.lastleg.service.LastlegOrderCommandService; +import com.baiyue.lastleg.dto.LastlegOrderCommandDtos; import com.baiyue.lastleg.service.LoadingCompletedConsumer; import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; import org.junit.jupiter.api.Test; @@ -32,6 +34,7 @@ import static org.mockito.Mockito.when; class LastlegOrderControllerTest { // Controller contract tests intentionally instantiate the controller without a Spring context here. private final LastlegOrderService service = mock(LastlegOrderService.class); + private final LastlegOrderCommandService commandService = mock(LastlegOrderCommandService.class); private final LastlegOrderController controller = controller(); private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( 1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null); @@ -59,6 +62,23 @@ class LastlegOrderControllerTest { verify(service).page(eq("200"), eq(query)); } + @Test + void holdCommandPassesVersionedIdempotentRequestToSingleOrderService() { + var request = new LastlegOrderCommandDtos.HoldRequest(true, true, "海关查验", 2); + + controller.hold(user(0L, "PLATFORM"), "100", 11L, "hold-11", request); + + verify(commandService).hold("100", 11L, "hold-11", request, "1", "tester"); + } + + @Test + void manualNodeQueryUsesOrderScopeRatherThanContainerScope() { + controller.manualNodes(user(0L, "PLATFORM"), "100", 11L); + + verify(commandService).manualNodes("100", 11L); + verify(service, never()).containerProgressDetail(org.mockito.ArgumentMatchers.anyString()); + } + @Test void platformOperatorCanQueryCrossCustomerContainerSummaries() { var summaryQuery = new LastlegOrderRequests.ContainerProgressQuery(1, 20, "CONT-", null, null); @@ -130,6 +150,7 @@ class LastlegOrderControllerTest { ReflectionTestUtils.setField(controller, "consumer", mock(LoadingCompletedConsumer.class)); ReflectionTestUtils.setField(controller, "shippingNodeConsumer", mock(ContainerShippingNodeConsumer.class)); ReflectionTestUtils.setField(controller, "orderService", service); + ReflectionTestUtils.setField(controller, "commandService", commandService); ReflectionTestUtils.setField(controller, "tokenGuard", mock(InternalServiceTokenGuard.class)); return controller; } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index 15669c6d1..aa328d90d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -18,6 +18,8 @@ import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -52,6 +54,7 @@ class DispatchServiceTest { @Mock DispatchDraftMapper drafts; @Mock DispatchDraftContainerMapper draftContainers; @Mock DispatchPdaTaskMapper pdaTasks; + @Mock LastlegOrderMapper tailOrders; private DispatchService service; @BeforeEach @@ -64,8 +67,26 @@ class DispatchServiceTest { ReflectionTestUtils.setField(service, "drafts", drafts); ReflectionTestUtils.setField(service, "draftContainers", draftContainers); ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks); + ReflectionTestUtils.setField(service, "tailOrders", tailOrders); ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of()); + lenient().when(tailOrders.selectList(any())).thenReturn(List.of()); + } + + @Test + void preArrangeRejectsAHeldTailOrderBeforeCreatingDispatchDocuments() { + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); + LastlegOrder held = new LastlegOrder(); + held.setId(2L); held.setCustomerId("100"); held.setOrderNo("LMO-HOLD-1"); held.setDetained(true); + when(tailOrders.selectList(any())).thenReturn(List.of(held)); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, + null, null, null))); + + assertEquals(409, exception.getStatusCode().value()); + assertEquals("暂扣订单不可创建排车或出库计划:LMO-HOLD-1", exception.getReason()); + verify(orders, never()).insert(any(DispatchOrder.class)); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java index cb4671a44..9624107b7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java @@ -27,6 +27,13 @@ class InboundPlanSchemaContractTest { assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment")); assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)")); assertTrue(schema.contains("expected_loading_at")); + assertTrue(schema.contains("planned_arrival_date")); + assertTrue(schema.contains("creation_mode")); + assertTrue(schema.contains("pallet_type")); + assertTrue(schema.contains("create table if not exists ll_order_command_record")); + assertTrue(schema.contains("create table if not exists ll_order_hold_history")); + assertTrue(schema.contains("create table if not exists ll_order_manual_node")); + assertTrue(schema.contains("create table if not exists ll_order_manual_node_history")); assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment_item")); assertTrue(schema.contains("fba_warehouse_code")); assertFalse(schema.contains("cabinet_no")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java new file mode 100644 index 000000000..435a92e0c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.inboundplan; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TailPlanCommandOpenApiContractTest { + @Test + void exposesOnlyLatestManualPlanHoldAndEightRowNodeContracts() throws Exception { + try (var stream = getClass().getClassLoader() + .getResourceAsStream("openapi/overseas-business-v1.yaml")) { + assertTrue(stream != null); + String api = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + assertTrue(api.contains("/api/platform/v1/last-leg/inbound-plans/manual:")); + assertFalse(api.contains("/api/platform/v1/last-leg/inbound-plans/convert:")); + assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/hold:")); + assertFalse(api.contains("/api/platform/v1/last-leg/orders/{id}/detained:")); + assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/manual-nodes:")); + assertTrue(api.contains("minItems: 8")); + assertTrue(api.contains("maxItems: 8")); + assertTrue(api.contains("ACTUAL_ARRIVAL_WAITING_UNLOAD")); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java index 5bed91126..a540f63ed 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java @@ -3,8 +3,8 @@ package com.baiyue.lastleg.inboundplan.controller; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; -import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; @@ -19,6 +19,7 @@ import org.springframework.web.server.ResponseStatusException; import java.util.List; import java.util.Set; import java.time.LocalDateTime; +import java.time.LocalDate; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.verify; @@ -61,31 +62,19 @@ class InboundPlanControllerTest { } @Test - void platformConversionRequiresAnExplicitCustomer() { - assertThatThrownBy(() -> controller.convert(platform(), new ConvertInboundPlanRequest(2L, null))) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("平台生成入库计划必须指定客户"); - verify(service, never()).convert(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + void platformManualPlanPassesVersionedIdempotentCommand() { + ManualInboundPlanRequest request = manualRequest(); + controller.manual(platform(), "manual-key", request); + verify(service).convertManual(request, "manual-key", "7"); } @Test - void platformConversionPassesTheProvidedLongCustomerId() { - ConvertInboundPlanRequest request = new ConvertInboundPlanRequest(9223372036854775807L, - "193000000000200003"); - - controller.convert(platform(), request); - - verify(service).convert("193000000000200003", 9223372036854775807L); - } - - @Test - void customerConversionIgnoresRequestedCustomerAndUsesAuthenticatedScope() { + void merchantCannotCreateManualPlan() { AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of()); - ConvertInboundPlanRequest request = new ConvertInboundPlanRequest(2L, "200"); - - controller.convert(customer, request); - - verify(service).convert("100", 2L); + assertThatThrownBy(() -> controller.manual(customer, "manual-key", manualRequest())) + .isInstanceOf(ResponseStatusException.class); + verify(service, never()).convertManual(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } @Test @@ -109,4 +98,10 @@ class InboundPlanControllerTest { return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of("platform:overseas-warehouse:inbound-order:list")); } + + private static ManualInboundPlanRequest manualRequest() { + return new ManualInboundPlanRequest("193000000000200003", 9223372036854775807L, 0, + 193000000000200003L, "US-LAX-01", "洛杉矶仓", LocalDate.of(2026, 9, 8), + "FBA-001", 2, "STANDARD", false, true, true, "加急入仓"); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java index 0fe426b28..49ebc5462 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java @@ -33,7 +33,7 @@ class InboundPlanPermissionContractTest { @Test void conversionUsesCanonicalInboundOrderCreateAuthority() { Method method = Arrays.stream(InboundPlanController.class.getDeclaredMethods()) - .filter(candidate -> candidate.getName().equals("convert")) + .filter(candidate -> candidate.getName().equals("manual")) .findFirst() .orElseThrow(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index 0908f48d0..ce969a403 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.service; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; @@ -15,6 +16,9 @@ import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -28,11 +32,13 @@ import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.util.List; import java.time.LocalDateTime; +import java.time.LocalDate; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -47,6 +53,8 @@ class InboundPlanServiceTest { @Mock InboundPlanOrderMapper orderMapper; @Mock InventoryBalanceMapper inventoryBalanceMapper; @Mock DropshipOrderService dropshipOrders; + @Mock WarehouseResourceGateway warehouseGateway; + @Mock LastlegOrderCommandRecordMapper commandMapper; private InboundPlanService service; @@ -60,6 +68,9 @@ class InboundPlanServiceTest { ReflectionTestUtils.setField(service, "orderMapper", orderMapper); ReflectionTestUtils.setField(service, "inventoryBalanceMapper", inventoryBalanceMapper); ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders); + ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway); + ReflectionTestUtils.setField(service, "commandMapper", commandMapper); + ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build()); ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of()); org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of()); @@ -112,6 +123,52 @@ class InboundPlanServiceTest { assertEquals("洛杉矶仓", result.overseasWarehouseName()); } + @Test + void manuallyCreatesOneIndependentPlanWithFrozenMetadata() { + SourceOrderSnapshot source = order(2L, "RK-2", "1.0000", "WH-A"); + when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source)); + when(warehouseGateway.requireActiveSharedWarehouse(193000000000200003L)).thenReturn( + new WarehouseResourceGateway.WarehouseReference( + 193000000000200003L, "US-LAX-01", "洛杉矶仓", "ACTIVE")); + when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(2), eq(true), eq(0), any())) + .thenReturn(1); + ManualInboundPlanRequest request = new ManualInboundPlanRequest("100", 2L, 0, + 193000000000200003L, "US-LAX-01", "洛杉矶仓", LocalDate.of(2026, 9, 15), + "RK-2", 2, "STANDARD", true, true, true, "优先收货"); + + var result = service.convertManual(request, "manual-2", "900"); + + ArgumentCaptor plan = ArgumentCaptor.forClass(InboundPlan.class); + verify(planMapper).insert(plan.capture()); + assertEquals(2L, plan.getValue().getSourceOrderId()); + assertEquals(LocalDate.of(2026, 9, 15), plan.getValue().getPlannedArrivalDate()); + assertEquals("STANDARD", plan.getValue().getPalletType()); + assertEquals("MANUAL", plan.getValue().getCreationMode()); + assertEquals(2, result.palletCount()); + verify(palletMapper, times(2)).insert(any(InboundPlanPallet.class)); + verify(orderMapper).markPlannedManual(eq("100"), eq(2L), any(), eq(2), eq(true), eq(0), any()); + verify(commandMapper).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class)); + verify(orderMapper, never()).lockContainerOrders(any()); + } + + @Test + void refusesManualPlanForHeldOrderBeforeWriting() { + SourceOrderSnapshot held = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2", + "PENDING_ASSIGNMENT", null, new BigDecimal("1.0000"), 10, 0, + "193000000000200003", "US-LAX-01", "洛杉矶仓", null, + null, "WH-A", "仓A", 1, 0, true, 5); + when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(held)); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual( + new ManualInboundPlanRequest("100", 2L, 5, 193000000000200003L, "US-LAX-01", "洛杉矶仓", + LocalDate.of(2026, 9, 15), "RK-2", 1, "STANDARD", false, false, false, null), + "manual-held", "900")); + + assertEquals(409, error.getStatusCode().value()); + verify(planMapper, never()).insert(any(InboundPlan.class)); + verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class)); + } + @Test void convertsAConsistentDestinationAssignedOrder() { SourceOrderSnapshot assigned = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderCommandServiceTest.java new file mode 100644 index 000000000..697f6ffaf --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderCommandServiceTest.java @@ -0,0 +1,161 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.dto.LastlegOrderCommandDtos; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderCommandRecord; +import com.baiyue.lastleg.entity.LastlegOrderHoldHistory; +import com.baiyue.lastleg.entity.LastlegOrderManualNodeHistory; +import com.baiyue.lastleg.entity.LastlegOrderManualNode; +import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper; +import com.baiyue.lastleg.mapper.LastlegOrderHoldHistoryMapper; +import com.baiyue.lastleg.mapper.LastlegOrderManualNodeHistoryMapper; +import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LastlegOrderCommandServiceTest { + @Mock LastlegOrderMapper orders; + @Mock LastlegOrderCommandRecordMapper commands; + @Mock LastlegOrderHoldHistoryMapper holdHistory; + @Mock LastlegOrderManualNodeMapper manualNodes; + @Mock LastlegOrderManualNodeHistoryMapper manualNodeHistory; + @Mock LastlegOrderOperationLogMapper operationLogs; + @Mock InboundOrderDetainedOutboxService detainedOutbox; + LastlegOrderCommandService service; + + @BeforeEach + void setUp() { + service = new LastlegOrderCommandService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "commands", commands); + ReflectionTestUtils.setField(service, "holdHistory", holdHistory); + ReflectionTestUtils.setField(service, "manualNodes", manualNodes); + ReflectionTestUtils.setField(service, "manualNodeHistory", manualNodeHistory); + ReflectionTestUtils.setField(service, "operationLogs", operationLogs); + ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox); + ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build()); + } + + @Test + void holdsOnlySelectedOrderWithVersionAndImmutableHistory() { + LastlegOrder order = order(11L, 3); + when(orders.selectById(11L)).thenReturn(order); + when(orders.lockById("100", 11L)).thenReturn(order); + when(orders.updateHold(eq("100"), eq(11L), eq(true), eq(true), eq("海关查验"), eq(3), + any(LocalDateTime.class))).thenReturn(1); + + var response = service.hold("100", 11L, "hold-11", + new LastlegOrderCommandDtos.HoldRequest(true, true, "海关查验", 3), "900", "管理员"); + + assertTrue(response.held()); + assertTrue(response.chargeable()); + assertEquals(4, response.version()); + verify(orders).updateHold(eq("100"), eq(11L), eq(true), eq(true), eq("海关查验"), eq(3), any()); + verify(holdHistory).insert(any(LastlegOrderHoldHistory.class)); + verify(commands).insert(any(LastlegOrderCommandRecord.class)); + verify(orders, never()).lockContainerOrders(any()); + } + + @Test + void rejectsStaleHoldWithoutAnyWrite() { + LastlegOrder order = order(11L, 4); + when(orders.selectById(11L)).thenReturn(order); + when(orders.lockById("100", 11L)).thenReturn(order); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.hold("100", 11L, + "hold-stale", new LastlegOrderCommandDtos.HoldRequest(true, false, "待核对", 3), "900", "管理员")); + + assertEquals(409, error.getStatusCode().value()); + verify(orders, never()).updateHold(anyString(), anyLong(), anyBoolean(), anyBoolean(), + nullable(String.class), anyInt(), any(LocalDateTime.class)); + verify(commands, never()).insert(any(LastlegOrderCommandRecord.class)); + } + + @Test + void savesExactEightRowManualSnapshotInFrozenDisplayOrder() { + LastlegOrder order = order(12L, 0); + when(orders.selectById(12L)).thenReturn(order); + when(orders.lockById("100", 12L)).thenReturn(order); + when(orders.updateManualNodes(eq("100"), eq(12L), any(), any(), any(), eq(0), any())).thenReturn(1); + LocalDateTime filled = LocalDateTime.of(2026, 9, 8, 10, 0); + List nodes = List.of( + node("UNLOADED", null), node("PICKUP_APPOINTED", null), + node("ACTUAL_ARRIVAL_WAITING_UNLOAD", null), node("OVERSEAS_CUSTOMS_COMPLETED", null), + node("ESTIMATED_ARRIVAL", filled), node("ACTUAL_DEPARTURE", null), + node("ESTIMATED_DEPARTURE", null), node("DOMESTIC_CUSTOMS_COMPLETED", null)); + + var response = service.saveManualNodes("100", 12L, "nodes-12", + new LastlegOrderCommandDtos.ManualNodesRequest(0, nodes), "900", "管理员"); + + assertTrue(response.manualOverrideEnabled()); + assertEquals(8, response.nodes().size()); + assertEquals("DOMESTIC_CUSTOMS_COMPLETED", response.nodes().get(0).nodeCode()); + assertEquals("UNLOADED", response.nodes().get(7).nodeCode()); + ArgumentCaptor rows = ArgumentCaptor.forClass(LastlegOrderManualNode.class); + verify(manualNodes, org.mockito.Mockito.times(8)).insert(rows.capture()); + assertEquals(8, rows.getAllValues().size()); + verify(manualNodeHistory).insert(any(LastlegOrderManualNodeHistory.class)); + } + + @Test + void rejectsIncompleteOrFullyEmptyManualSnapshot() { + LastlegOrder order = order(12L, 0); + when(orders.selectById(12L)).thenReturn(order); + when(orders.lockById("100", 12L)).thenReturn(order); + List nodes = List.of( + node("DOMESTIC_CUSTOMS_COMPLETED", null), node("ESTIMATED_DEPARTURE", null), + node("ACTUAL_DEPARTURE", null), node("ESTIMATED_ARRIVAL", null), + node("OVERSEAS_CUSTOMS_COMPLETED", null), node("ACTUAL_ARRIVAL_WAITING_UNLOAD", null), + node("PICKUP_APPOINTED", null), node("UNLOADED", null)); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> + service.saveManualNodes("100", 12L, "nodes-empty", + new LastlegOrderCommandDtos.ManualNodesRequest(0, nodes), "900", "管理员")); + + assertEquals(400, error.getStatusCode().value()); + verify(orders, never()).updateManualNodes(anyString(), anyLong(), nullable(LocalDateTime.class), + nullable(LocalDateTime.class), nullable(LocalDateTime.class), anyInt(), any(LocalDateTime.class)); + } + + private LastlegOrderCommandDtos.ManualNode node(String code, LocalDateTime time) { + return new LastlegOrderCommandDtos.ManualNode(code, time); + } + + private LastlegOrder order(long id, int version) { + LastlegOrder order = new LastlegOrder(); + order.setId(id); + order.setCustomerId("100"); + order.setOrderNo("LL-" + id); + order.setCurrentStatus("PENDING_ASSIGNMENT"); + order.setDetained(false); + order.setVersion(version); + return order; + } +} 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 73a4f0565..a47ffcc73 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 @@ -14,6 +14,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.mapper.LastlegOrderFeeMapper; import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; +import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import org.junit.jupiter.api.BeforeEach; @@ -54,6 +55,7 @@ class LastlegOrderServiceTest { @Mock LastlegOrderStatusHistoryMapper histories; @Mock LastlegOrderFeeMapper fees; @Mock LastlegOrderOperationLogMapper operations; + @Mock LastlegOrderManualNodeMapper manualNodes; @Mock InboundPlanCartonMapper cartons; @Mock FbaWarehouseValidator fbaWarehouses; @Mock InventoryCommandService inventory; @@ -70,6 +72,7 @@ class LastlegOrderServiceTest { ReflectionTestUtils.setField(service, "historyMapper", histories); ReflectionTestUtils.setField(service, "feeMapper", fees); ReflectionTestUtils.setField(service, "operationLogMapper", operations); + ReflectionTestUtils.setField(service, "manualNodeMapper", manualNodes); ReflectionTestUtils.setField(service, "cartonMapper", cartons); ReflectionTestUtils.setField(service, "fbaWarehouseValidator", fbaWarehouses); lenient().when(fbaWarehouses.required(any(), any(), any())).thenAnswer(invocation -> @@ -133,6 +136,7 @@ class LastlegOrderServiceTest { when(histories.selectList(any())).thenReturn(List.of()); when(fees.selectList(any())).thenReturn(List.of()); when(operations.selectList(any())).thenReturn(List.of()); + when(manualNodes.selectList(any())).thenReturn(List.of()); var response = service.detail("100", 11L); From eeb087e453b04f7f9b654de30c7f60e14697b7a8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 00:11:31 +0800 Subject: [PATCH 14/26] fix: make terminal drayage migration restartable --- deploy/sql/头程数据库统一增量-MySQL8.sql | 78 ++++++++++++++----- .../TerminalDrayageSqlContractTest.java | 10 +++ 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 30d910930..ac44ef196 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -18022,14 +18022,14 @@ DROP PROCEDURE IF EXISTS assert_wallet_event_schema; -- 目的港码头拖车、提柜与还空闭环(模块来源:phase3-terminal-drayage-schema.sql) USE baiyue_firstleg; CREATE TABLE IF NOT EXISTS fl_terminal_drayage ( - id BIGINT UNSIGNED NOT NULL, container_booking_id BIGINT UNSIGNED NOT NULL, - fleet_supplier_id BIGINT UNSIGNED NOT NULL, fleet_supplier_name VARCHAR(128) NOT NULL, - pickup_appointment_at DATETIME(3) NULL, actual_pickup_at DATETIME(3) NULL, - estimated_empty_return_at DATETIME(3) NULL, actual_empty_return_at DATETIME(3) NULL, - version INT UNSIGNED NOT NULL DEFAULT 1, created_by BIGINT UNSIGNED NOT NULL, - created_by_name VARCHAR(64) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_by BIGINT UNSIGNED NOT NULL, updated_by_name VARCHAR(64) NULL, - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID', + fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', fleet_supplier_name VARCHAR(128) NOT NULL COMMENT 'Warehouse供应商名称快照', + pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间', actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间', + estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间', actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间', + version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后更新人ID', updated_by_name VARCHAR(64) NULL COMMENT '最后更新人名称快照', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), UNIQUE KEY uk_terminal_drayage_container(container_booking_id), KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at), CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id), @@ -18038,27 +18038,69 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage ( AND (actual_empty_return_at IS NULL OR actual_empty_return_at >= actual_pickup_at)) ) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实'; CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history ( - id BIGINT UNSIGNED NOT NULL, drayage_id BIGINT UNSIGNED NOT NULL, - container_booking_id BIGINT UNSIGNED NOT NULL, action VARCHAR(32) NOT NULL, - from_status VARCHAR(32) NOT NULL, to_status VARCHAR(32) NOT NULL, - from_version INT UNSIGNED NOT NULL, to_version INT UNSIGNED NOT NULL, - reason VARCHAR(500) NULL, operator_id BIGINT UNSIGNED NOT NULL, operator_name VARCHAR(64) NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), + id BIGINT UNSIGNED NOT NULL COMMENT '历史ID', drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照', action VARCHAR(32) NOT NULL COMMENT '业务动作', + from_status VARCHAR(32) NOT NULL COMMENT '操作前状态', to_status VARCHAR(32) NOT NULL COMMENT '操作后状态', + from_version INT UNSIGNED NOT NULL COMMENT '操作前版本', to_version INT UNSIGNED NOT NULL COMMENT '操作后版本', + reason VARCHAR(500) NULL COMMENT '操作说明', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', PRIMARY KEY(id), KEY idx_terminal_drayage_history(container_booking_id,created_at,id), CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id), CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN ('SELECT_FLEET','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')) ) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史'; CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation ( - id BIGINT UNSIGNED NOT NULL, container_booking_id BIGINT UNSIGNED NOT NULL, - operation VARCHAR(32) NOT NULL, idempotency_key VARCHAR(255) NOT NULL, - request_hash CHAR(64) NOT NULL, result_version INT UNSIGNED NOT NULL, - operator_id BIGINT UNSIGNED NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + id BIGINT UNSIGNED NOT NULL COMMENT '幂等操作ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID', + operation VARCHAR(32) NOT NULL COMMENT '业务动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求载荷SHA-256', result_version INT UNSIGNED NOT NULL COMMENT '成功结果版本', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完成时间', PRIMARY KEY(id), UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key), KEY idx_terminal_drayage_operation(container_booking_id,created_at), CONSTRAINT fk_terminal_drayage_operation_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id) ) ENGINE=InnoDB COMMENT='目的港码头拖车成功幂等操作'; +-- MySQL DDL 会隐式提交;如果前次执行在末段校验失败,重跑时需把已创建表的列注释收敛到当前基线。 +ALTER TABLE fl_terminal_drayage + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID', + MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID', + MODIFY COLUMN fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', + MODIFY COLUMN fleet_supplier_name VARCHAR(128) NOT NULL COMMENT 'Warehouse供应商名称快照', + MODIFY COLUMN pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间', + MODIFY COLUMN actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间', + MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间', + MODIFY COLUMN actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间', + MODIFY COLUMN version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号', + MODIFY COLUMN created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + MODIFY COLUMN created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后更新人ID', + MODIFY COLUMN updated_by_name VARCHAR(64) NULL COMMENT '最后更新人名称快照', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间'; + +ALTER TABLE fl_terminal_drayage_history + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '历史ID', + MODIFY COLUMN drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID', + MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照', + MODIFY COLUMN action VARCHAR(32) NOT NULL COMMENT '业务动作', + MODIFY COLUMN from_status VARCHAR(32) NOT NULL COMMENT '操作前状态', + MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT '操作后状态', + MODIFY COLUMN from_version INT UNSIGNED NOT NULL COMMENT '操作前版本', + MODIFY COLUMN to_version INT UNSIGNED NOT NULL COMMENT '操作后版本', + MODIFY COLUMN reason VARCHAR(500) NULL COMMENT '操作说明', + MODIFY COLUMN operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + MODIFY COLUMN operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间'; + +ALTER TABLE fl_terminal_drayage_operation + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '幂等操作ID', + MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID', + MODIFY COLUMN operation VARCHAR(32) NOT NULL COMMENT '业务动作', + MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', + MODIFY COLUMN request_hash CHAR(64) NOT NULL COMMENT '请求载荷SHA-256', + MODIFY COLUMN result_version INT UNSIGNED NOT NULL COMMENT '成功结果版本', + MODIFY COLUMN operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完成时间'; + DROP PROCEDURE IF EXISTS assert_terminal_drayage_schema; DELIMITER $$ CREATE PROCEDURE assert_terminal_drayage_schema() diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java index 28da7a5b2..41c95d782 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java @@ -24,4 +24,14 @@ class TerminalDrayageSqlContractTest { assertThat(mapper).contains("WHERE b.id=#{containerBookingId} FOR UPDATE", "WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}"); } + + @Test + void unifiedMigrationRepairsColumnsAfterAPartialDdlRun() throws Exception { + String sql = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + assertThat(sql).contains( + "ALTER TABLE fl_terminal_drayage", + "ALTER TABLE fl_terminal_drayage_history", + "ALTER TABLE fl_terminal_drayage_operation", + "MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间'"); + } } From bb68e8a58036330e3c780e31bf8257b5b029f3b7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 00:23:40 +0800 Subject: [PATCH 15/26] fix: route terminal drayage through gateway --- gateway/src/main/resources/application.yml | 2 +- .../gateway/config/FirstlegContainerRouteContractTest.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index 141cf31ef..fbd66bd91 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -77,7 +77,7 @@ spring: - id: firstleg-service uri: lb://firstleg-service predicates: - - Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/containers/**,/api/platform/v1/customers/{customerId}/container-transit-summary,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/v1/loading-plans/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/** + - Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/containers/**,/api/platform/v1/terminal-drayages/**,/api/platform/v1/customers/{customerId}/container-transit-summary,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/v1/loading-plans/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/** - id: finance-service uri: lb://finance-service predicates: diff --git a/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java index 4584d620f..262b95956 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java @@ -15,6 +15,7 @@ class FirstlegContainerRouteContractTest { assertThat(yaml) .contains("/api/platform/v1/containers/**") + .contains("/api/platform/v1/terminal-drayages/**") .contains("/api/platform/v1/loading-plans/**"); } From 287c0f3ce850f1891b15087c78c475edc4bbc533 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 00:51:32 +0800 Subject: [PATCH 16/26] fix: publish holds for local overseas orders --- .../InboundOrderDetainedOutboxService.java | 4 +- ...InboundOrderDetainedOutboxServiceTest.java | 52 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxServiceTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java index f3bcdc4f0..52bf9194a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java @@ -38,11 +38,13 @@ public class InboundOrderDetainedOutboxService { JsonNode payloadNode = objectMapper.valueToTree(payload); String eventKey = EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED + ':' + order.getSourceInboundId() + ':' + changeVersion; + String causationId = EventIdentifiers.isSupported(order.getSourceEventId()) + ? order.getSourceEventId() : null; EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, changedAt, RegionCode.OVERSEAS, RegionCode.CN, order.getCustomerId(), "INBOUND_ORDER", order.getSourceInboundId(), changeVersion, 1L, EventIdentifiers.newUuid(), - eventKey, order.getSourceEventId(), payloadNode, + eventKey, causationId, payloadNode, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); LastlegEventOutbox event = new LastlegEventOutbox(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxServiceTest.java new file mode 100644 index 000000000..5df840907 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxServiceTest.java @@ -0,0 +1,52 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class InboundOrderDetainedOutboxServiceTest { + @Mock private LastlegEventOutboxMapper mapper; + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + private InboundOrderDetainedOutboxService service; + + @BeforeEach + void setUp() { + service = new InboundOrderDetainedOutboxService(); + ReflectionTestUtils.setField(service, "mapper", mapper); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + } + + @Test + void localSyntheticSourceReferenceIsNotWrittenAsEventCausationId() throws Exception { + LastlegOrder order = new LastlegOrder(); + order.setId(101L); + order.setCustomerId("201"); + order.setOrderNo("WT-WH-20260907-972865"); + order.setSourceInboundId("WAREHOUSE_TRANSFER:WT-WH-20260907-778561"); + order.setInboundCode("NYEXX2609070253"); + order.setSourceEventId("WAREHOUSE_TRANSFER:WT-WH-20260907-778561"); + + service.publish(order, true, "等待质检", "301", "operator", Instant.parse("2026-09-07T16:00:00Z"), 1L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LastlegEventOutbox.class); + verify(mapper).insert(captor.capture()); + JsonNode envelope = objectMapper.readTree(captor.getValue().getPayloadJson()); + assertThat(envelope.path("causationId").isNull()).isTrue(); + assertThat(envelope.path("aggregateId").asText()).isEqualTo(order.getSourceInboundId()); + } +} From 61ffff1be0c86c1c8efa968df0ae0ca103e97e0e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 07:39:17 +0800 Subject: [PATCH 17/26] fix: keep terminal drayage actions executable --- .../service/TerminalDrayageService.java | 2 +- .../service/TerminalDrayageServiceTest.java | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java index 177e4ab7c..47224afc3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java @@ -257,7 +257,7 @@ public class TerminalDrayageService { if (current != null && current.sequence() >= CarrierScheduleNode.PICKUP_READY.sequence() && value != null && value.getFleetSupplierId() != null && value.getPickupAppointmentAt() != null && actualPickupAt(context, value) == null) result.add("RECORD_PICKUP"); - if (context.unloadingCompletedAt() != null && actualPickupAt(context, value) != null + if (value != null && context.unloadingCompletedAt() != null && actualPickupAt(context, value) != null && actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN"); return List.copyOf(result); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java index d48b14f53..03e270d7d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java @@ -154,6 +154,19 @@ class TerminalDrayageServiceTest { assertThat(service.detail(11L).emptyReturnWarning().overdueHours()).isZero(); } + @Test + void doesNotOfferEmptyReturnWithoutExecutableDrayageAggregate() { + LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0); + Context context = context("PICKED_UP", unloadedAt); + when(mapper.context(11L)).thenReturn(context); + when(mapper.find(11L)).thenReturn(null); + + var result = service.detail(11L); + + assertThat(result.status()).isEqualTo("UNLOADED"); + assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN"); + } + @Test void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutWriting() { Context context = context("ARRIVED_DESTINATION_PORT", null); From cc6df5993f09c2d36f2086566bf3e7688709d8d8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 11:00:39 +0800 Subject: [PATCH 18/26] fix(lastleg): enforce container warehouse plan facts --- .../dto/ManualInboundPlanRequest.java | 4 - .../mapper/InboundPlanOrderMapper.java | 11 +++ .../service/InboundPlanService.java | 73 +++++++++++++----- .../openapi/overseas-business-v1.yaml | 7 +- .../TailPlanCommandOpenApiContractTest.java | 10 +++ .../controller/InboundPlanControllerTest.java | 3 +- .../InboundPlanOrderMapperContractTest.java | 13 ++++ .../service/InboundPlanServiceTest.java | 75 ++++++++++++++++++- 8 files changed, 161 insertions(+), 35 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java index fb8bd05e8..ce7be8228 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java @@ -11,11 +11,7 @@ public record ManualInboundPlanRequest( @NotBlank @Size(max = 128) String customerId, @NotNull Long orderId, @NotNull @Min(0) Integer expectedOrderVersion, - @NotNull Long plannedWarehouseId, - @NotBlank @Size(max = 128) String plannedWarehouseCode, - @NotBlank @Size(max = 255) String plannedWarehouseName, @NotNull LocalDate plannedArrivalDate, - @NotBlank @Size(max = 128) String inboundCode, @NotNull @Min(1) @Max(999) Integer palletCount, @NotBlank @Size(max = 32) String palletType, @NotNull Boolean assigned, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index 77bfc983d..8aedf41ff 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -16,6 +16,17 @@ public interface InboundPlanOrderMapper { String orderType) { } record ReceivingWarehouseSnapshot(String overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName) { } + record ContainerWarehouseRef(String customerId, Long orderId, String sourceContainerBookingId, + String overseasWarehouseId) { } + + @Select("SELECT c.customer_id AS customerId,c.id AS orderId," + + "c.source_container_booking_id AS sourceContainerBookingId,c.overseas_warehouse_id AS overseasWarehouseId " + + "FROM ll_order target JOIN ll_order c ON c.source_container_booking_id=target.source_container_booking_id " + + "WHERE target.customer_id=#{customerId} AND target.id=#{orderId} " + + "AND target.source_container_booking_id IS NOT NULL AND target.source_container_booking_id!='' " + + "ORDER BY c.customer_id,c.id FOR UPDATE") + List lockContainerWarehouseFacts(@Param("customerId") String customerId, + @Param("orderId") Long orderId); @Select("SELECT customer_id AS customerId,id AS orderId,current_status AS currentStatus,container_no AS containerNo," + "order_type AS orderType " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index ff07150f4..bce2f9900 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -75,6 +75,13 @@ public class InboundPlanService { if (key == null || key.length() > 128) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency-Key 必须为1至128个字符"); } + List containerFacts = + orderMapper.lockContainerWarehouseFacts(customerId, request.orderId()); + if (containerFacts == null || containerFacts.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在或未绑定来源柜"); + } + String containerWarehouseId = requireSingleWarehouseId(containerFacts.stream() + .map(InboundPlanOrderMapper.ContainerWarehouseRef::overseasWarehouseId).toList()); List snapshots = orderMapper.lockOrder(customerId, request.orderId()); if (snapshots == null || snapshots.size() != 1) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在"); @@ -96,20 +103,14 @@ public class InboundPlanService { throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单版本已变化,请刷新后重试"); } if (order.detained()) throw new ResponseStatusException(HttpStatus.CONFLICT, "暂扣尾程订单不能生成入库计划"); - if (!Objects.equals(trimToNull(order.inboundCode()), trimToNull(request.inboundCode()))) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "入仓码与尾程订单冻结事实不一致"); - } - WarehouseResourceGateway.WarehouseReference warehouse = - warehouseGateway.requireActiveSharedWarehouse(request.plannedWarehouseId()); - if (!Objects.equals(String.valueOf(warehouse.warehouseId()), trimToNull(order.overseasWarehouseId())) - || !Objects.equals(warehouse.warehouseCode(), request.plannedWarehouseCode().trim()) - || !Objects.equals(warehouse.warehouseName(), request.plannedWarehouseName().trim()) - || !Objects.equals(warehouse.warehouseCode(), trimToNull(order.overseasWarehouseCode())) - || !Objects.equals(warehouse.warehouseName(), trimToNull(order.overseasWarehouseName()))) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "计划入仓仓库不存在、已停用或与订单预分配事实不一致"); + if (!Objects.equals(containerWarehouseId, trimToNull(order.overseasWarehouseId()))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜接柜仓冻结事实已变化,请刷新后重试"); } + WarehouseResourceGateway.WarehouseReference receivingWarehouse = + requiredReceivingWarehouse(containerWarehouseId); validateOrders(snapshots); - InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null, request); + InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null, + request, receivingWarehouse); LastlegOrderCommandRecord command = new LastlegOrderCommandRecord(); command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setOrderId(request.orderId()); command.setAction("MANUAL_PLAN"); command.setOperatorId(operatorId); command.setIdempotencyKey(key); @@ -180,12 +181,13 @@ public class InboundPlanService { return detail(customerId, existing); } validateOrders(orders); - return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, null); + return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, null, null); } private InboundPlanResponse createPlan(String customerId, Long selectedOrderId, List orders, List sourcePalletNos, - List wholePallets, ManualInboundPlanRequest manual) { + List wholePallets, ManualInboundPlanRequest manual, + WarehouseResourceGateway.WarehouseReference receivingWarehouse) { String containerNo = requiredContainerNo(orders.get(0).containerNo()); LocalDateTime now = LocalDateTime.now(); InboundPlan plan = new InboundPlan(); @@ -262,7 +264,7 @@ public class InboundPlanService { orderMapper.insertHistory(customerId, order.orderId(), order.currentStatus(), "Inbound order converted to inbound plan " + plan.getPlanNo(), now); } - return detail(customerId, plan); + return detail(customerId, plan, receivingWarehouse); } /** 同一来源柜的每个尾程订单独立建计划;整个柜级批次共用一个事务。 */ @@ -326,12 +328,14 @@ public class InboundPlanService { } wholePallets.add(sourcePallets); } + requireSingleWarehouseId(snapshots.stream().flatMap(List::stream) + .map(SourceOrderSnapshot::overseasWarehouseId).toList()); List created = new ArrayList<>(refs.size()); for (int index = 0; index < refs.size(); index++) { InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index); created.add(createPlan(ref.customerId(), ref.orderId(), snapshots.get(index), List.of(), - wholePallets.get(index), null)); + wholePallets.get(index), null, null)); } return List.copyOf(created); } @@ -623,16 +627,21 @@ public class InboundPlanService { } private InboundPlanResponse detail(String customerId, InboundPlan plan) { + return detail(customerId, plan, null); + } + + private InboundPlanResponse detail(String customerId, InboundPlan plan, + WarehouseResourceGateway.WarehouseReference resolvedWarehouse) { if (customerId != null && !customerId.equals(plan.getCustomerId())) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); } InboundPlanOrderMapper.ReceivingWarehouseSnapshot receivingWarehouse = orderMapper.findReceivingWarehouse( plan.getCustomerId(), plan.getSourceOrderId()); - if (receivingWarehouse == null || trimToNull(receivingWarehouse.overseasWarehouseId()) == null - || trimToNull(receivingWarehouse.overseasWarehouseCode()) == null - || trimToNull(receivingWarehouse.overseasWarehouseName()) == null) { + if (receivingWarehouse == null || trimToNull(receivingWarehouse.overseasWarehouseId()) == null) { throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划关联订单缺少接柜仓预分配事实"); } + WarehouseResourceGateway.WarehouseReference authoritativeWarehouse = resolvedWarehouse == null + ? requiredReceivingWarehouse(receivingWarehouse.overseasWarehouseId()) : resolvedWarehouse; List lines = lineMapper.selectList(new LambdaQueryWrapper() .eq(InboundPlanLine::getPlanId, plan.getId()).orderByAsc(InboundPlanLine::getId)); List pallets = palletMapper.selectList(new LambdaQueryWrapper() @@ -642,7 +651,7 @@ public class InboundPlanService { .eq(InboundPlanPalletAllocation::getPlanId, plan.getId()) .orderByAsc(InboundPlanPalletAllocation::getId)); return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getSourceOrderId(), - plan.getContainerNo(), receivingWarehouse.overseasWarehouseCode(), receivingWarehouse.overseasWarehouseName(), + plan.getContainerNo(), authoritativeWarehouse.warehouseCode(), authoritativeWarehouse.warehouseName(), plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(), lines.stream().mapToInt(InboundPlanLine::getRequiredPackageCount).sum(), allocations.stream().mapToInt(InboundPlanPalletAllocation::getPickedPackageCount).sum(), @@ -663,6 +672,30 @@ public class InboundPlanService { Boolean.TRUE.equals(plan.getUrgentFlag()), plan.getCreationMode(), plan.getPlanRemark()); } + private WarehouseResourceGateway.WarehouseReference requiredReceivingWarehouse(String warehouseId) { + String stableId = trimToNull(warehouseId); + if (stableId == null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓未预分配"); + } + try { + return warehouseGateway.requireActiveSharedWarehouse(Long.parseLong(stableId)); + } catch (NumberFormatException exception) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓ID格式无效", exception); + } + } + + private String requireSingleWarehouseId(List warehouseIds) { + List normalized = warehouseIds.stream().map(InboundPlanService::trimToNull).toList(); + if (normalized.stream().anyMatch(Objects::isNull)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在未分配接柜仓的尾程订单"); + } + java.util.Set distinct = new java.util.HashSet<>(normalized); + if (distinct.size() != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单接柜仓不一致"); + } + return distinct.iterator().next(); + } + private BigDecimal volume(SourceOrderSnapshot order) { return Objects.requireNonNullElse(order.volumeCbm(), BigDecimal.ZERO); } 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 9c24059fc..d6ef2bc94 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1856,16 +1856,13 @@ components: items: { $ref: '#/components/schemas/LastlegOrderManualNode' } ManualInboundPlanRequest: type: object - required: [customerId, orderId, expectedOrderVersion, plannedWarehouseId, plannedWarehouseCode, plannedWarehouseName, plannedArrivalDate, inboundCode, palletCount, palletType, assigned, mergeRequired, urgent] + description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。 + required: [customerId, orderId, expectedOrderVersion, plannedArrivalDate, palletCount, palletType, assigned, mergeRequired, urgent] properties: customerId: { type: string, pattern: '^[1-9][0-9]*$' } orderId: { type: integer, format: int64, minimum: 1 } expectedOrderVersion: { type: integer, minimum: 0 } - plannedWarehouseId: { type: integer, format: int64, minimum: 1 } - plannedWarehouseCode: { type: string, minLength: 1, maxLength: 64 } - plannedWarehouseName: { type: string, minLength: 1, maxLength: 255 } plannedArrivalDate: { type: string, format: date } - inboundCode: { type: string, minLength: 1, maxLength: 128 } palletCount: { type: integer, minimum: 1, maximum: 999 } palletType: { type: string, minLength: 1, maxLength: 64 } assigned: { type: boolean } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java index 435a92e0c..2256edd4c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java @@ -1,5 +1,6 @@ package com.baiyue.lastleg.inboundplan; +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; @@ -16,6 +17,15 @@ class TailPlanCommandOpenApiContractTest { String api = new String(stream.readAllBytes(), StandardCharsets.UTF_8); assertTrue(api.contains("/api/platform/v1/last-leg/inbound-plans/manual:")); assertFalse(api.contains("/api/platform/v1/last-leg/inbound-plans/convert:")); + assertFalse(api.contains("plannedWarehouseId")); + assertFalse(api.contains("plannedWarehouseCode")); + assertFalse(api.contains("plannedWarehouseName")); + int manualSchemaStart = api.indexOf(" ManualInboundPlanRequest:"); + int manualSchemaEnd = api.indexOf("\n PageMetadata:", manualSchemaStart); + assertTrue(manualSchemaStart >= 0 && manualSchemaEnd > manualSchemaStart); + assertFalse(api.substring(manualSchemaStart, manualSchemaEnd).contains("inboundCode")); + assertFalse(java.util.Arrays.stream(ManualInboundPlanRequest.class.getRecordComponents()) + .anyMatch(component -> "inboundCode".equals(component.getName()))); assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/hold:")); assertFalse(api.contains("/api/platform/v1/last-leg/orders/{id}/detained:")); assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/manual-nodes:")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java index a540f63ed..d33ad32c2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java @@ -101,7 +101,6 @@ class InboundPlanControllerTest { private static ManualInboundPlanRequest manualRequest() { return new ManualInboundPlanRequest("193000000000200003", 9223372036854775807L, 0, - 193000000000200003L, "US-LAX-01", "洛杉矶仓", LocalDate.of(2026, 9, 8), - "FBA-001", 2, "STANDARD", false, true, true, "加急入仓"); + LocalDate.of(2026, 9, 8), 2, "STANDARD", false, true, true, "加急入仓"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java index b7718d8e8..e9b5e1e48 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java @@ -34,4 +34,17 @@ class InboundPlanOrderMapperContractTest { assertTrue(sql.contains("customer_id=#{customerId} AND id=#{orderId}")); assertTrue(!sql.contains("destination_warehouse")); } + + @Test + void manualPlanLocksEveryOrderInTheSelectedStableContainer() throws Exception { + Select select = InboundPlanOrderMapper.class + .getMethod("lockContainerWarehouseFacts", String.class, Long.class) + .getAnnotation(Select.class); + String sql = String.join(" ", select.value()); + + assertTrue(sql.contains("c.source_container_booking_id=target.source_container_booking_id")); + assertTrue(sql.contains("target.customer_id=#{customerId} AND target.id=#{orderId}")); + assertTrue(sql.contains("c.overseas_warehouse_id AS overseasWarehouseId")); + assertTrue(sql.contains("ORDER BY c.customer_id,c.id FOR UPDATE")); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index ce969a403..f12f3c52f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -78,6 +78,12 @@ class InboundPlanServiceTest { org.mockito.Mockito.lenient().when(orderMapper.findReceivingWarehouse(any(), any())).thenReturn( new InboundPlanOrderMapper.ReceivingWarehouseSnapshot( "193000000000200003", "US-LAX-01", "洛杉矶仓")); + org.mockito.Mockito.lenient().when(warehouseGateway.requireActiveSharedWarehouse(193000000000200003L)).thenReturn( + new WarehouseResourceGateway.WarehouseReference( + 193000000000200003L, "US-LAX-01", "洛杉矶仓", "ACTIVE")); + org.mockito.Mockito.lenient().when(orderMapper.lockContainerWarehouseFacts(any(), any())).thenReturn(List.of( + new InboundPlanOrderMapper.ContainerWarehouseRef( + "100", 2L, "BOOKING-1", "193000000000200003"))); } @Test @@ -133,8 +139,7 @@ class InboundPlanServiceTest { when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(2), eq(true), eq(0), any())) .thenReturn(1); ManualInboundPlanRequest request = new ManualInboundPlanRequest("100", 2L, 0, - 193000000000200003L, "US-LAX-01", "洛杉矶仓", LocalDate.of(2026, 9, 15), - "RK-2", 2, "STANDARD", true, true, true, "优先收货"); + LocalDate.of(2026, 9, 15), 2, "STANDARD", true, true, true, "优先收货"); var result = service.convertManual(request, "manual-2", "900"); @@ -149,6 +154,46 @@ class InboundPlanServiceTest { verify(orderMapper).markPlannedManual(eq("100"), eq(2L), any(), eq(2), eq(true), eq(0), any()); verify(commandMapper).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class)); verify(orderMapper, never()).lockContainerOrders(any()); + verify(orderMapper).lockContainerWarehouseFacts("100", 2L); + } + + @Test + void manuallyCreatesPlanWhenStableWarehouseIdExistsWithoutDisplaySnapshots() { + SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2", + "PENDING_ASSIGNMENT", null, BigDecimal.ONE, 10, 0, + "193000000000200003", null, null, null, + "WH-A", "WH-A", "仓A", 1, 0, false, 0); + when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source)); + when(warehouseGateway.requireActiveSharedWarehouse(193000000000200003L)).thenReturn( + new WarehouseResourceGateway.WarehouseReference( + 193000000000200003L, "US-LAX-01", "洛杉矶仓", "ACTIVE")); + when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(3), eq(false), eq(0), any())) + .thenReturn(1); + + var result = service.convertManual(new ManualInboundPlanRequest("100", 2L, 0, + LocalDate.of(2026, 9, 15), 3, "STANDARD", true, false, false, null), + "manual-id-only", "900"); + + assertEquals("US-LAX-01", result.overseasWarehouseCode()); + assertEquals("洛杉矶仓", result.overseasWarehouseName()); + verify(warehouseGateway).requireActiveSharedWarehouse(193000000000200003L); + verify(planMapper).insert(any(InboundPlan.class)); + } + + @Test + void manualPlanRejectsMixedContainerWarehousesBeforeLockingOrderOrWriting() { + when(orderMapper.lockContainerWarehouseFacts("100", 2L)).thenReturn(List.of( + new InboundPlanOrderMapper.ContainerWarehouseRef("100", 2L, "BOOKING-1", "193000000000200003"), + new InboundPlanOrderMapper.ContainerWarehouseRef("200", 3L, "BOOKING-1", "193000000000200004"))); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual( + new ManualInboundPlanRequest("100", 2L, 0, LocalDate.of(2026, 9, 15), + 1, "STANDARD", false, false, false, null), "mixed-warehouse", "900")); + + assertEquals(409, error.getStatusCode().value()); + verify(orderMapper, never()).lockOrder(any(), any()); + verify(planMapper, never()).insert(any(InboundPlan.class)); + verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class)); } @Test @@ -160,8 +205,8 @@ class InboundPlanServiceTest { when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(held)); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual( - new ManualInboundPlanRequest("100", 2L, 5, 193000000000200003L, "US-LAX-01", "洛杉矶仓", - LocalDate.of(2026, 9, 15), "RK-2", 1, "STANDARD", false, false, false, null), + new ManualInboundPlanRequest("100", 2L, 5, LocalDate.of(2026, 9, 15), + 1, "STANDARD", false, false, false, null), "manual-held", "900")); assertEquals(409, error.getStatusCode().value()); @@ -217,6 +262,8 @@ class InboundPlanServiceTest { when(planMapper.selectOne(any())).thenReturn(plan); when(orderMapper.findReceivingWarehouse("100", 1L)).thenReturn( new InboundPlanOrderMapper.ReceivingWarehouseSnapshot("88", "US-NY-01", "纽约仓")); + when(warehouseGateway.requireActiveSharedWarehouse(88L)).thenReturn( + new WarehouseResourceGateway.WarehouseReference(88L, "US-NY-01", "纽约仓", "ACTIVE")); var result = service.detailByPlanNo("100", "LIP-1"); @@ -440,6 +487,26 @@ class InboundPlanServiceTest { verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any()); } + @Test + void containerConversionRejectsMixedWarehouseSnapshotsBeforeCreatingAnyPlan() { + when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( + new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT", "MSCU6639870", "STANDARD"), + new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "PENDING_ASSIGNMENT", "MSCU6639870", "STANDARD"))); + when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order("100", 1L, "RK-1", "WH-A"))); + SourceOrderSnapshot differentWarehouse = new SourceOrderSnapshot(2L, "200", "MSCU6639870", "RK-2", + "PENDING_ASSIGNMENT", null, BigDecimal.ONE, 10, 0, + "193000000000200004", null, null, null, "WH-B", "WH-B", "仓B", 1, 0, false, 0); + when(orderMapper.lockOrder("200", 2L)).thenReturn(List.of(differentWarehouse)); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.convertContainer("BOOKING-1")); + + assertEquals(409, error.getStatusCode().value()); + verify(planMapper, never()).insert(any(InboundPlan.class)); + verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); + verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any()); + } + @Test void completeContainerReplayReturnsStableExistingPlansWithoutWriting() { var refs = List.of( From 847df6a85f479e7bf530aaf5f296ae655aac7f81 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 11:51:04 +0800 Subject: [PATCH 19/26] fix(lastleg): keep completed containers editable --- .../lastleg/mapper/LastlegOrderMapper.java | 5 +--- .../openapi/overseas-business-v1.yaml | 12 ++++---- .../LastlegOrderControllerTest.java | 19 ++++++++++++ .../ContainerPlanProgressContractTest.java | 16 ++++++---- .../service/LastlegOrderServiceTest.java | 29 +++++++++++++++++-- 5 files changed, 63 insertions(+), 18 deletions(-) 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 1c629aedb..b44ff99e3 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 @@ -20,10 +20,7 @@ public interface LastlegOrderMapper extends BaseMapper { "AND o.container_no LIKE CONCAT('%',#{q.containerNo},'%') " + "AND o.created_at >=#{q.createdFrom} " + "AND o.created_at <=#{q.createdTo} " + - "GROUP BY o.source_container_booking_id " + - "HAVING COUNT(DISTINCT CASE WHEN p.id IS NOT NULL AND NULLIF(TRIM(p.plan_no),'') IS NOT NULL " + - "AND NULLIF(TRIM(p.status),'') IS NOT NULL THEN NULLIF(TRIM(o.inbound_code),'') END) " + - "< COUNT(DISTINCT NULLIF(TRIM(o.inbound_code),'')) "; + "GROUP BY o.source_container_booking_id "; String CONTAINER_PROGRESS_COLUMNS = "SELECT o.source_container_booking_id AS sourceContainerBookingId," + "CASE WHEN COUNT(NULLIF(o.container_no,''))=COUNT(*) AND COUNT(DISTINCT NULLIF(o.container_no,''))=1 " + 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 d6ef2bc94..f51b51f67 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -149,7 +149,7 @@ paths: /api/platform/v1/last-leg/orders: get: summary: 分页查询尾程订单 - description: 权限 platform:overseas-warehouse:order:list;平台管理员省略 customerId 时查询全部客户。 + description: 权限 platform:overseas-warehouse:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } @@ -274,7 +274,7 @@ paths: /api/platform/v1/last-leg/orders/container-summaries: get: summary: 分页查询柜级入库计划进度 - description: 权限 platform:overseas-warehouse:order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响,仅返回仍有入仓码待生成有效独立计划的柜,全部入仓码均已生成有效独立计划的柜在分页前排除。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 仅来自装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at;按该时间升序、null 最后、稳定柜 ID 升序。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } @@ -304,7 +304,7 @@ paths: /api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}: get: summary: 查询柜级入库计划进度详情 - description: 权限 platform:overseas-warehouse:order:list;按稳定柜 ID 返回柜内全部尾程订单、入仓码及各自计划状态,不受订单分页截断。 + description: 权限 platform:overseas-warehouse:last-mile-order:list;按稳定柜 ID 返回柜内全部尾程订单、入仓码及各自计划状态,不受订单分页截断。 security: [{ bearerAuth: [] }] parameters: - { name: sourceContainerBookingId, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } @@ -322,7 +322,7 @@ paths: /api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/receiving-warehouse: put: summary: 修改整柜海外接柜仓 - description: 权限 platform:overseas-warehouse:order:edit;目标必须为国内 Warehouse Service 有效 OVERSEAS 仓。使用柜级版本和 Idempotency-Key,事务内原子同步柜内全部尾程订单及已有入库计划;海外PDA一旦建立接柜会话即永久锁定。 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;目标必须为国内 Warehouse Service 有效 OVERSEAS 仓。使用柜级版本和 Idempotency-Key,事务内原子同步柜内全部尾程订单及已有入库计划;海外PDA一旦建立接柜会话即永久锁定。 security: [{ bearerAuth: [] }] parameters: - { name: sourceContainerBookingId, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } @@ -339,7 +339,7 @@ paths: /api/platform/v1/last-leg/orders/{id}/destination: put: summary: 将单票尾程订单转入 FBA 目的仓 - description: 权限 platform:overseas-warehouse:order:edit;目标必须来自国内 Warehouse Service 平台公共 ACTIVE FBA 主档,warehouseId/warehouseCode/warehouseName 必须与权威记录一致。实际海外收货仓、普通国内中转仓、停用仓或伪造字段均返回 A0400“所选FBA仓不存在或已停用”。 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;目标必须来自国内 Warehouse Service 平台公共 ACTIVE FBA 主档,warehouseId/warehouseCode/warehouseName 必须与权威记录一致。实际海外收货仓、普通国内中转仓、停用仓或伪造字段均返回 A0400“所选FBA仓不存在或已停用”。 security: [{ bearerAuth: [] }] parameters: - { name: id, in: path, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } @@ -356,7 +356,7 @@ paths: /api/platform/v1/last-leg/orders/destination: put: summary: 批量将尾程订单转入 FBA 目的仓 - description: 权限 platform:overseas-warehouse:order:edit;一次校验国内 Warehouse Service 权威 FBA 主档后,在 Lastleg 事务内更新所选订单及冻结明细。目标仓必须为平台公共 ACTIVE FBA 且 ID/编码/名称完全一致。 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;一次校验国内 Warehouse Service 权威 FBA 主档后,在 Lastleg 事务内更新所选订单及冻结明细。目标仓必须为平台公共 ACTIVE FBA 且 ID/编码/名称完全一致。 security: [{ bearerAuth: [] }] parameters: - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java index f3ca58fc2..9f311cfbb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -16,6 +16,7 @@ import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.beans.BeanUtils; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.server.ResponseStatusException; import java.util.Set; @@ -88,6 +89,24 @@ class LastlegOrderControllerTest { verify(service).containerProgressPage(summaryQuery); } + @Test + void containerEndpointsUseCanonicalLastMileOrderAuthorities() throws Exception { + var list = LastlegOrderController.class.getMethod("containerSummaries", + AuthenticatedUser.class, LastlegOrderRequests.ContainerProgressQuery.class); + var detail = LastlegOrderController.class.getMethod("containerSummaryDetail", + AuthenticatedUser.class, String.class); + var change = LastlegOrderController.class.getMethod("changeReceivingWarehouse", + AuthenticatedUser.class, String.class, String.class, + LastlegOrderRequests.ChangeReceivingWarehouse.class); + + assertEquals("hasAuthority('platform:overseas-warehouse:last-mile-order:list')", + list.getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAuthority('platform:overseas-warehouse:last-mile-order:list')", + detail.getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAuthority('platform:overseas-warehouse:last-mile-order:edit')", + change.getAnnotation(PreAuthorize.class).value()); + } + @Test void containerSummariesRejectMerchantIdentity() { var summaryQuery = new LastlegOrderRequests.ContainerProgressQuery(1, 20, null, null, null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java index bc544e078..74eb585ae 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java @@ -18,6 +18,7 @@ import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class ContainerPlanProgressContractTest { @@ -40,8 +41,8 @@ class ContainerPlanProgressContractTest { String sql = String.join("", rows.value()).toLowerCase(); assertTrue(sql.contains("left join ll_inbound_plan")); assertTrue(sql.contains("o.estimated_warehouse_at")); - assertTrue(sql.contains("having count(distinct case when p.id is not null")); - assertTrue(sql.contains("< count(distinct nullif(trim(o.inbound_code),''))")); + assertTrue(sql.contains("group by o.source_container_booking_id")); + assertFalse(sql.contains("having ")); assertTrue(sql.contains("progress.plannedarrivalat asc")); assertTrue(sql.contains("progress.sourcecontainerbookingid asc")); assertTrue(sql.contains("limit #{limit} offset #{offset}")); @@ -58,7 +59,12 @@ class ContainerPlanProgressContractTest { assertTrue(openApi.contains("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}:")); assertTrue(openApi.contains("ContainerPlanProgressSummary:")); assertTrue(openApi.contains("allPlansReady:")); - assertTrue(openApi.contains("仅返回仍有入仓码待生成有效独立计划的柜")); + assertTrue(openApi.contains("返回全部稳定来源柜")); + assertTrue(openApi.contains("全部入仓码均已生成有效独立计划的柜仍保留")); + assertTrue(openApi.contains("权限 platform:overseas-warehouse:last-mile-order:list")); + assertTrue(openApi.contains("权限 platform:overseas-warehouse:last-mile-order:edit")); + assertFalse(openApi.contains("platform:overseas-warehouse:order:list")); + assertFalse(openApi.contains("platform:overseas-warehouse:order:edit")); assertTrue(openApi.contains("ll_order.estimated_warehouse_at")); assertTrue(schema.contains("idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)")); assertTrue(deployment.contains("idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)")); @@ -77,8 +83,8 @@ class ContainerPlanProgressContractTest { assertTrue(sql.contains("source_container_booking_id<>''")); assertTrue(sql.contains("LEFT JOIN ll_inbound_plan")); - assertTrue(sql.contains("HAVING COUNT(DISTINCT CASE WHEN p.id IS NOT NULL")); - assertTrue(sql.contains("< COUNT(DISTINCT NULLIF(TRIM(o.inbound_code),''))")); + assertTrue(sql.contains("GROUP BY o.source_container_booking_id")); + assertFalse(sql.contains("HAVING ")); assertTrue(!sql.contains("<")); } } 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 a47ffcc73..0acce48a0 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 @@ -86,7 +86,7 @@ class LastlegOrderServiceTest { } @Test - void changesReceivingWarehouseForWholeContainerBeforePdaSession() { + void changesReceivingWarehouseForAllPlansReadyContainerBeforePdaSession() { LastlegOrder first = order(1L, "100", "PENDING_ASSIGNMENT", null); LastlegOrder second = order(2L, "200", "PENDING_ASSIGNMENT", null); for (LastlegOrder value : List.of(first, second)) { @@ -94,8 +94,8 @@ class LastlegOrderServiceTest { value.setOverseasWarehouseCode("US-LAX-01"); value.setOverseasWarehouseName("洛杉矶仓"); } var updated = new ContainerPlanProgressSummary("BOOKING-1", "MSCU6639870", "900", - "US-NYC-01", "纽约仓", 1, true, null, 2, 2, 0, 0, 0, 2, - null, false, "存在2票尚未生成有效入库计划"); + "US-NYC-01", "纽约仓", 1, true, null, 2, 2, 0, 2, 2, 0, + null, true, null); when(orders.lockContainerOrders("BOOKING-1")).thenReturn(List.of(first, second)); when(warehouseResourceGateway.requireActiveSharedWarehouse(900L)).thenReturn( new WarehouseResourceGateway.WarehouseReference(900L, "US-NYC-01", "纽约仓", "ACTIVE")); @@ -107,6 +107,8 @@ class LastlegOrderServiceTest { new LastlegOrderRequests.ChangeReceivingWarehouse(900L, 0, "改派纽约"), "admin"); assertEquals("US-NYC-01", result.overseasWarehouseCode()); + assertTrue(result.allPlansReady()); + assertTrue(result.canChangeReceivingWarehouse()); verify(orders).updateContainerPlanReceivingWarehouse("BOOKING-1", 900L); verify(receivingWarehouseOperations).insert(any(com.baiyue.lastleg.entity.ReceivingWarehouseOperation.class)); } @@ -360,6 +362,27 @@ class LastlegOrderServiceTest { assertTrue(result.records().get(1).canChangeReceivingWarehouse()); } + @Test + void containerProgressPageKeepsReadyAndPdaLockedContainersVisible() { + var query = new LastlegOrderRequests.ContainerProgressQuery(1, 10, null, null, null); + var ready = summary(true, 0, null); + var locked = new ContainerPlanProgressSummary("BOOKING-2", "TSTU0000061", + "193000000000200003", "US-LAX-01", "洛杉矶仓", 4, false, + "海外PDA已建立接柜会话,接柜仓已永久锁定", 1, 1, 0, 1, 1, 0, + LocalDateTime.of(2026, 10, 4, 9, 0), true, null); + when(orders.countContainerProgress(query)).thenReturn(2L); + when(orders.pageContainerProgress(query, 0, 10)).thenReturn(List.of(ready, locked)); + + var result = service.containerProgressPage(query); + + assertEquals(List.of(ready, locked), result.records()); + assertTrue(result.records().get(0).allPlansReady()); + assertTrue(result.records().get(0).canChangeReceivingWarehouse()); + assertFalse(result.records().get(1).canChangeReceivingWarehouse()); + assertEquals("海外PDA已建立接柜会话,接柜仓已永久锁定", + result.records().get(1).receivingWarehouseLockReason()); + } + private static ContainerPlanProgressSummary summary(boolean ready, long pending, String reason) { return new ContainerPlanProgressSummary("BOOKING-1", "MSCU6639870", "193000000000200003", "US-LAX-01", "洛杉矶仓", 0, true, null, 15, 15, 0, From 096ab8d48b8c3d93c9bee9877c52df697d10c4c5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 12:45:46 +0800 Subject: [PATCH 20/26] fix(test): align receiving pallets with FBA destinations --- ...-overseas-pda-operations-contract.test.mjs | 12 +++++++++- .../phase4-overseas-pda-operations.mjs | 23 +++++++++++++++---- .../phase4-overseas-pda.mjs | 4 +++- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs index 28fb043bb..1a741c306 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs @@ -5,7 +5,7 @@ import test from 'node:test' import { fileURLToPath } from 'node:url' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' -import { run } from './phase4-overseas-pda-operations.mjs' +import { auxiliaryPalletFbaWarehouseCode, run } from './phase4-overseas-pda-operations.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda-operations.mjs'), 'utf8') @@ -58,6 +58,16 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho assert.match(source, /traceId=/) }) +test('auxiliary pallets use the authoritative inbound FBA code and never the business destination fallback', () => { + assert.equal(auxiliaryPalletFbaWarehouseCode('FBA', ' ABE3 '), 'ABE3') + assert.equal(auxiliaryPalletFbaWarehouseCode('OVERSEAS', 'ABE3'), null) + assert.equal(auxiliaryPalletFbaWarehouseCode('FBA', ''), null) + assert.match(receivingSource, + /destinationType: inbound\.destinationType,[\s\S]*fbaWarehouseCode: inbound\.destinationType === 'FBA' \? inbound\.fbaWarehouseCode : null/) + assert.match(source, /fbaWarehouseCode,\s*\n\s*remark:/) + assert.doesNotMatch(source, /createEmptyPallet\([^\n]*main\.destinationWarehouseCode/) +}) + test('fixture failure still disables both picker accounts and removes all transient tokens', async () => { const cleanups = [] const gaps = [] diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs index 3a173ffd3..272642a7e 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -95,13 +95,26 @@ async function createLocation(context, warehouseId, suffix) { return success(response) ? { locationId, locationCode } : null } -async function createEmptyPallet(context, token, fixture, suffix, destinationWarehouseCode) { +export function auxiliaryPalletFbaWarehouseCode(destinationType, fbaWarehouseCode) { + if (destinationType === 'OVERSEAS') return null + if (destinationType === 'FBA' && nonBlank(fbaWarehouseCode)) return fbaWarehouseCode.trim() + return null +} + +async function createEmptyPallet(context, token, fixture, suffix) { + const fbaWarehouseCode = auxiliaryPalletFbaWarehouseCode( + fixture.destinationType, fixture.fbaWarehouseCode) + if (fixture.destinationType === 'FBA' && !fbaWarehouseCode) { + blocker(context, `${suffix}辅助空托盘`, + `当前FBA入仓码 ${fixture.inboundCode || 'missing'} 缺少权威fbaWarehouseCode`) + return null + } const palletCode = `PDAOPS-${context.marker}-${suffix}`.replace(/[^A-Za-z0-9_-]/g, '').slice(-120) const response = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.overseasWarehouseId)}/pallets`, { palletCode, palletType: '木托盘', capacityCbm: 10, unitPrice: 0, status: 'IDLE', locationId: fixture.location.locationId, - fbaWarehouseCode: destinationWarehouseCode || null, + fbaWarehouseCode, remark: `${context.marker}-pda-operations-${suffix}`, }) context.addStep(MODULE, `创建${suffix}辅助空托盘`, response, success(response), trace(response), OPTIONS) @@ -234,9 +247,9 @@ async function verifyPalletOperations(context, token, fixture) { '主托盘只有一箱可用库存,解绑会清空唯一业务托盘;必须由本轮生产矩阵提供至少两箱') return false } - const swapTarget = await createEmptyPallet(context, token, fixture, 'SWAP', main.destinationWarehouseCode) - const splitTarget = await createEmptyPallet(context, token, fixture, 'SPLIT', main.destinationWarehouseCode) - const unbindTarget = await createEmptyPallet(context, token, fixture, 'UNBIND', main.destinationWarehouseCode) + const swapTarget = await createEmptyPallet(context, token, fixture, 'SWAP') + const splitTarget = await createEmptyPallet(context, token, fixture, 'SPLIT') + const unbindTarget = await createEmptyPallet(context, token, fixture, 'UNBIND') if (!swapTarget || !splitTarget || !unbindTarget) { blocker(context, 'P4辅助实物托盘', '无法创建换托、拆托和解绑所需辅助托盘') return false diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index cb6b1c6b8..ee1b299f6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -669,7 +669,9 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe pallets.get(inbound.inboundCode), inbound.destinationType) if (warehouseFacts && context.context?.phase4OverseasPdaOperations) { context.context.phase4OverseasPdaOperations.completedFixtures.push({ - containerNo, order, ...warehouseFacts, + containerNo, order, destinationType: inbound.destinationType, + fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null, + ...warehouseFacts, }) } } From 3c05525e27a357590cc2b4cd31300c0ac0cc8af7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 13:14:13 +0800 Subject: [PATCH 21/26] fix(lastleg): search frozen warehouse code and name --- .../lastleg/mapper/LastlegOrderMapper.java | 3 ++ .../lastleg/service/LastlegOrderService.java | 7 +-- .../openapi/overseas-business-v1.yaml | 2 +- ...astlegOrderWarehouseQueryContractTest.java | 19 ++++++++ .../service/LastlegOrderServiceTest.java | 46 +++++++++++++++++++ 5 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/mapper/LastlegOrderWarehouseQueryContractTest.java 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 b44ff99e3..cae686970 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 @@ -14,6 +14,9 @@ import java.util.List; @Mapper public interface LastlegOrderMapper extends BaseMapper { + String WAREHOUSE_FILTER_SQL = "(LOWER(TRIM(overseas_warehouse_code)) LIKE CONCAT('%',LOWER({0}),'%') " + + "OR LOWER(TRIM(overseas_warehouse_name)) LIKE CONCAT('%',LOWER({0}),'%'))"; + String CONTAINER_PROGRESS_FILTER = " FROM ll_order o LEFT JOIN ll_inbound_plan p " + "ON p.customer_id=o.customer_id AND p.source_order_id=o.id " + "WHERE o.source_container_booking_id IS NOT NULL AND o.source_container_booking_id<>'' " + 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 8490574b0..9c965b470 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 @@ -511,6 +511,7 @@ public class LastlegOrderService { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo"); } String currentStatus = StringUtils.hasText(query.currentStatus()) ? query.currentStatus() : query.status(); + String warehouse = StringUtils.hasText(query.warehouse()) ? query.warehouse().trim() : null; return new LambdaQueryWrapper() .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) .like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo()) @@ -520,11 +521,7 @@ public class LastlegOrderService { .like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo()) .like(StringUtils.hasText(query.containerNo()), LastlegOrder::getContainerNo, query.containerNo()) .eq(StringUtils.hasText(currentStatus), LastlegOrder::getCurrentStatus, currentStatus) - .and(StringUtils.hasText(query.warehouse()), wrapper -> wrapper - .like(LastlegOrder::getOverseasWarehouseId, query.warehouse()) - .or().like(LastlegOrder::getOverseasWarehouseName, query.warehouse()) - .or().like(LastlegOrder::getTransferWarehouseCode, query.warehouse()) - .or().like(LastlegOrder::getTransferWarehouseName, query.warehouse())) + .apply(StringUtils.hasText(warehouse), LastlegOrderMapper.WAREHOUSE_FILTER_SQL, warehouse) .eq(StringUtils.hasText(query.transferWarehouseCode()), LastlegOrder::getTransferWarehouseCode, query.transferWarehouseCode()) .eq(query.abnormal() != null, LastlegOrder::getAbnormal, query.abnormal()) 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 f51b51f67..887c14925 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -160,7 +160,7 @@ paths: - { name: inboundCode, in: query, schema: { type: string } } - { name: sourceOrderNo, in: query, schema: { type: string } } - { name: containerNo, in: query, schema: { type: string } } - - { name: warehouse, in: query, schema: { type: string } } + - { name: warehouse, in: query, description: 按尾程订单冻结的海外接柜仓名称或编码进行大小写无关的包含匹配, schema: { type: string } } - { name: transferWarehouseCode, in: query, schema: { type: string } } - { name: abnormal, in: query, schema: { type: boolean } } - { name: detained, in: query, schema: { type: boolean } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/mapper/LastlegOrderWarehouseQueryContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/mapper/LastlegOrderWarehouseQueryContractTest.java new file mode 100644 index 000000000..81f692eac --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/mapper/LastlegOrderWarehouseQueryContractTest.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.mapper; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LastlegOrderWarehouseQueryContractTest { + @Test + void warehouseFilterUsesOnlyFrozenReceivingWarehouseCodeAndName() { + String sql = LastlegOrderMapper.WAREHOUSE_FILTER_SQL; + + assertTrue(sql.contains("LOWER(TRIM(overseas_warehouse_code))")); + assertTrue(sql.contains("LOWER(TRIM(overseas_warehouse_name))")); + assertTrue(sql.contains("LIKE CONCAT('%',LOWER({0}),'%')")); + assertFalse(sql.contains("overseas_warehouse_id")); + assertFalse(sql.contains("transfer_warehouse")); + } +} 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 0acce48a0..bd7fd3eaa 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 @@ -282,6 +282,26 @@ class LastlegOrderServiceTest { assertEquals(25, page.getValue().getSize()); } + @Test + void pageWarehouseFilterMatchesFrozenWarehouseName() { + assertWarehouseFilter("洛杉矶仓", "洛杉矶仓", true); + } + + @Test + void pageWarehouseFilterMatchesFrozenWarehouseCodeIgnoringCaseAndSpaces() { + assertWarehouseFilter(" us-lax-01 ", "us-lax-01", true); + } + + @Test + void pageWarehouseFilterMatchesPartialFrozenWarehouseCode() { + assertWarehouseFilter("LAX-0", "LAX-0", true); + } + + @Test + void pageWarehouseFilterReturnsEmptyPageWhenNothingMatches() { + assertWarehouseFilter("WAREHOUSE-NOT-FOUND", "WAREHOUSE-NOT-FOUND", false); + } + @Test void containerProgressPageUsesServerSideAggregatePagination() { var query = new LastlegOrderRequests.ContainerProgressQuery(2, 10, "CONT-", null, null); @@ -480,4 +500,30 @@ class LastlegOrderServiceTest { return order; } + @SuppressWarnings({"rawtypes", "unchecked"}) + private void assertWarehouseFilter(String input, String normalized, boolean matched) { + var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page(1, 10); + LastlegOrder row = order(11L, "100", "PENDING_ASSIGNMENT", null); + row.setOverseasWarehouseCode("US-LAX-01"); + row.setOverseasWarehouseName("洛杉矶仓"); + resultPage.setRecords(matched ? List.of(row) : List.of()); + resultPage.setTotal(matched ? 1 : 0); + when(orders.selectPage(any(), any())).thenReturn(resultPage); + + var result = service.page(null, new LastlegOrderRequests.Query(1L, 10L, null, null, null, null, + null, null, null, null, input, null, null, null, null, null)); + + ArgumentCaptor> filter = + ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class); + verify(orders).selectPage(any(), filter.capture()); + var wrapper = (com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper) filter.getValue(); + String warehouseSql = wrapper.getExpression().getNormal().getSqlSegment(); + assertTrue(warehouseSql.contains("LOWER(TRIM(overseas_warehouse_code))")); + assertTrue(warehouseSql.contains("LOWER(TRIM(overseas_warehouse_name))")); + assertTrue(wrapper.getParamNameValuePairs().containsValue(normalized), + wrapper.getParamNameValuePairs().toString()); + assertEquals(matched ? 1 : 0, result.total()); + assertEquals(matched ? 1 : 0, result.records().size()); + } + } From d318f4a4505439e0c7852cd58c49d9302a22b1d4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 13:52:37 +0800 Subject: [PATCH 22/26] fix(lastleg): create remaining container plans --- .../controller/InboundPlanController.java | 2 +- .../service/InboundPlanService.java | 66 +++++++++++-------- .../openapi/overseas-business-v1.yaml | 4 +- .../service/InboundPlanServiceTest.java | 40 ++++++++++- 4 files changed, 79 insertions(+), 33 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index b626a54b2..3a3c83bef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -61,7 +61,7 @@ public class InboundPlanController { @PostMapping("/convert-container") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") - @Operation(summary = "按整柜批量生成入库计划", description = "平台按 sourceContainerBookingId 锁定整柜订单;每个入仓码独立生成计划,整批原子且可幂等重放。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) + @Operation(summary = "按整柜批量生成剩余入库计划", description = "平台按 sourceContainerBookingId 锁定整柜订单;保留已有有效计划,只为剩余入仓码分别生成独立计划,整批原子且可幂等重放。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) public ApiResponse> convertContainer( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ConvertContainerInboundPlanRequest request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index bce2f9900..14ccb5533 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -285,26 +285,24 @@ public class InboundPlanService { List existingPlans = refs.stream() .map(ref -> planMapper.findBySourceOrder(ref.customerId(), ref.orderId())) .toList(); - long existingCount = existingPlans.stream().filter(Objects::nonNull).count(); - if (existingCount == refs.size()) { - List replay = new ArrayList<>(refs.size()); - for (int index = 0; index < refs.size(); index++) { - requirePlanContainer(existingPlans.get(index), containerNo); - replay.add(detail(refs.get(index).customerId(), existingPlans.get(index))); + List warehouseIds = new ArrayList<>(refs.size()); + List pendingIndexes = new ArrayList<>(); + List> snapshots = new ArrayList<>(); + List> wholePallets = new ArrayList<>(); + for (int index = 0; index < refs.size(); index++) { + InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index); + InboundPlan existing = existingPlans.get(index); + if (existing != null) { + requireValidExistingPlan(existing, containerNo); + InboundPlanOrderMapper.ReceivingWarehouseSnapshot warehouse = + orderMapper.findReceivingWarehouse(ref.customerId(), ref.orderId()); + warehouseIds.add(warehouse == null ? null : warehouse.overseasWarehouseId()); + continue; + } + if (!("PENDING_ASSIGNMENT".equals(ref.currentStatus()) + || "DESTINATION_ASSIGNED".equals(ref.currentStatus()))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在不能转换的尾程订单"); } - return List.copyOf(replay); - } - if (existingCount != 0) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜仅部分尾程订单已有入库计划"); - } - if (refs.stream().anyMatch(ref -> !("PENDING_ASSIGNMENT".equals(ref.currentStatus()) - || "DESTINATION_ASSIGNED".equals(ref.currentStatus())))) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在不能转换的尾程订单"); - } - - List> snapshots = new ArrayList<>(refs.size()); - List> wholePallets = new ArrayList<>(refs.size()); - for (InboundPlanOrderMapper.ContainerOrderRef ref : refs) { List orders = orderMapper.lockOrder(ref.customerId(), ref.orderId()); if (orders == null || orders.isEmpty()) { throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜订单在锁定后不存在"); @@ -314,7 +312,9 @@ public class InboundPlanService { if (!Objects.equals(containerNo, currentContainerNo)) { throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单柜号不一致"); } + pendingIndexes.add(index); snapshots.add(orders); + warehouseIds.addAll(orders.stream().map(SourceOrderSnapshot::overseasWarehouseId).toList()); List sourcePallets = "ONE_CLICK_DROPSHIP".equals(ref.orderType()) ? dropshipOrders.palletSnapshots(ref.customerId(), ref.orderId()).stream() .map(this::wholePallet).toList() @@ -328,16 +328,21 @@ public class InboundPlanService { } wholePallets.add(sourcePallets); } - requireSingleWarehouseId(snapshots.stream().flatMap(List::stream) - .map(SourceOrderSnapshot::overseasWarehouseId).toList()); + requireSingleWarehouseId(warehouseIds); - List created = new ArrayList<>(refs.size()); - for (int index = 0; index < refs.size(); index++) { - InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index); - created.add(createPlan(ref.customerId(), ref.orderId(), snapshots.get(index), List.of(), - wholePallets.get(index), null, null)); + Map created = new HashMap<>(); + for (int pendingIndex = 0; pendingIndex < pendingIndexes.size(); pendingIndex++) { + int refIndex = pendingIndexes.get(pendingIndex); + InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(refIndex); + created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), List.of(), + wholePallets.get(pendingIndex), null, null)); } - return List.copyOf(created); + List result = new ArrayList<>(refs.size()); + for (int index = 0; index < refs.size(); index++) { + InboundPlan existing = existingPlans.get(index); + result.add(existing == null ? created.get(index) : detail(refs.get(index).customerId(), existing)); + } + return List.copyOf(result); } private WholePalletInput wholePallet(DropshipPalletSnapshot pallet) { @@ -397,6 +402,13 @@ public class InboundPlanService { } } + private void requireValidExistingPlan(InboundPlan plan, String containerNo) { + requirePlanContainer(plan, containerNo); + if (trimToNull(plan.getPlanNo()) == null || trimToNull(plan.getStatus()) == null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在无效入库计划,不能继续按柜生成"); + } + } + private void validateWholePallets(List orders, List pallets) { if (orders.size() != 1) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, 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 887c14925..bc6f11000 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -420,8 +420,8 @@ paths: '409': { description: 订单版本、状态、暂扣状态、重复计划或幂等键冲突 } /api/platform/v1/last-leg/inbound-plans/convert-container: post: - summary: 按整柜批量生成入库计划 - description: 权限 platform:overseas-warehouse:inbound-order:create;使用稳定 sourceContainerBookingId 锁定整柜订单,并要求全柜尾程订单及既有计划的真实 containerNo 快照一致。每个尾程订单/入仓码独立生成一个入库计划,整批原子且幂等;任何柜号漂移均冲突且不部分写入。 + summary: 按整柜批量生成剩余入库计划 + description: 权限 platform:overseas-warehouse:inbound-order:create;使用稳定 sourceContainerBookingId 锁定整柜订单,并要求全柜尾程订单及既有有效计划的真实 containerNo 与接柜仓快照一致。已有有效计划保持不变,只为剩余尾程订单/入仓码分别生成独立计划;响应按柜内订单稳定顺序返回全部已有及新增计划。整批原子且幂等,剩余为0时直接返回已有结果;任何快照漂移均冲突且不部分写入。 security: [{ bearerAuth: [] }] requestBody: required: true diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index f12f3c52f..f7ed9407a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -524,16 +524,50 @@ class InboundPlanServiceTest { } @Test - void partialExistingPlansAreRejectedBeforeAnyWrite() { + void partialExistingPlansArePreservedWhileOnlyRemainingOrdersAreCreated() { when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED", "MSCU6639870", "STANDARD"), new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT", "MSCU6639870", "STANDARD"))); when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1")); + when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(order("100", 2L, "RK-2", "WH-B"))); + when(orderMapper.markPlanned(eq("100"), eq(2L), any(), org.mockito.ArgumentMatchers.anyInt(), any())) + .thenReturn(1); - assertThrows(ResponseStatusException.class, () -> service.convertContainer("BOOKING-1")); + var result = service.convertContainer("BOOKING-1"); - verify(orderMapper, never()).lockOrder(any(), any()); + assertEquals(2, result.size()); + assertEquals("LIP-1", result.get(0).planNo()); + assertEquals(2L, result.get(1).sourceOrderId()); + verify(orderMapper, never()).lockOrder("100", 1L); + verify(orderMapper).lockOrder("100", 2L); + verify(planMapper).insert(any(InboundPlan.class)); + verify(orderMapper, never()).markPlanned(eq("100"), eq(1L), any(), + org.mockito.ArgumentMatchers.anyInt(), any()); + verify(orderMapper).markPlanned(eq("100"), eq(2L), any(), + org.mockito.ArgumentMatchers.anyInt(), any()); + } + + @Test + void partialExistingPlansWithMixedWarehouseFactsCauseZeroNewWrites() { + when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( + new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED", "MSCU6639870", "STANDARD"), + new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT", "MSCU6639870", "STANDARD"))); + when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1")); + when(orderMapper.findReceivingWarehouse("100", 1L)).thenReturn( + new InboundPlanOrderMapper.ReceivingWarehouseSnapshot( + "193000000000200003", "US-LAX-01", "洛杉矶仓")); + SourceOrderSnapshot differentWarehouse = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2", + "PENDING_ASSIGNMENT", null, BigDecimal.ONE, 10, 0, + "193000000000200004", null, null, null, "WH-B", "WH-B", "仓B", 1, 0, false, 0); + when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(differentWarehouse)); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.convertContainer("BOOKING-1")); + + assertEquals(409, error.getStatusCode().value()); verify(planMapper, never()).insert(any(InboundPlan.class)); + verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); + verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any()); } @Test From 07688005a13fb2ac1288d6e75255f9c6c71d8546 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 14:07:31 +0800 Subject: [PATCH 23/26] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 +- lastleg-service/AGENTS.md | 5 +- scripts/AGENTS.md | 1 + scripts/admin-test-env-acceptance.mjs | 18 +- scripts/admin-test-env-modules/README.md | 9 +- ...-phase4-producer-fixture-contract.test.mjs | 2 + .../phase4-carrier-nodes-contract.test.mjs | 40 +-- .../phase4-carrier-nodes.mjs | 61 +--- ...ase4-tail-order-controls-contract.test.mjs | 59 +++ .../phase4-tail-order-controls.mjs | 336 ++++++++++++++++++ .../phase5-terminal-drayage-contract.test.mjs | 48 +++ .../phase5-terminal-drayage.mjs | 147 ++++++++ ...ainer-inbound-plan-batch-contract.test.mjs | 4 +- scripts/agents-index-contract.ps1 | 44 ++- scripts/java-quality-summary.test.mjs | 2 +- 15 files changed, 695 insertions(+), 87 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase4-tail-order-controls.mjs create mode 100644 scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase5-terminal-drayage.mjs diff --git a/AGENTS.md b/AGENTS.md index bd585b504..6e0afca0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ | 全局智能装柜候选、柜次、占位与移除 | `firstleg/`、`order/` | Firstleg `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/containers`、`/plans/{planId}/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,业务编排 `service/SmartLoadingService.java`;待审核事实由 `client/PendingReviewOrderClient.java` 经内部令牌查询 Order `/internal/order/v1/pending-review-orders` 与 `PendingReviewOrderQueryService.java` 的权威 `SUBMITTED/PENDING_REVIEW` 行。候选四态只用于筛选展示,资格以 `canAssign` 为准;待审核或待测量可先占位,确认预装和启动 PDA 前仍须进入 `WAIT_SPLIT`;计划明细总量/余量由 Order 原始申报或最新已审核测量补齐,调整时重验权威数量;人工加入允许跨渠道,智能推荐仍按柜次渠道;同柜组整组分页、分配和移除 | | 客户入库结果与头程费用确认 | `firstleg/` | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`,接口 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing` 与 `/confirm`;客户按登录态范围读取并以费用版本和幂等键确认当前 `SENT` 费用,确认后锁定 | | 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` | +| 目的港码头拖车与还空预警 | `firstleg/` | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`src/main/resources/openapi/terminal-drayage-v1.yaml`;按 `containerBookingId` 聚合并与订舱级拖车分离,引用 Warehouse `ACTIVE + TRUCKING` 供应商,提柜/还柜复用船节点,服务端按海外卸柜完成时间加 72 小时计算还空预警 | | 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;客户账单行 `inboundCode` 由 `FinanceQueryService` 从费用来源快照提供,Client 复用头程入仓关系详情;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` | | 客户资金账户、充值、授信、冻结与结算 | `finance/` | `FinanceWalletService.java`、`ClientFinanceWalletController.java`、`PlatformFinanceWalletController.java`、`InternalFinanceWalletController.java`、`finance-wallet-v1.yaml`;Finance 持有 CNY 主账和不可变流水,业务服务只能经内部命令或跨区 Wallet 事件冻结、结算、释放 | | 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | Firstleg `PlatformContainerFeeDetailController.java`、`ContainerFeeDetailService.java`、`container-fees-v1.yaml` 按柜内真实客户与订单原子创建 `allocation-groups`;金额合计必须等于总额,分组审核、驳回、调整、作废均整组处理,Finance 只消费各客户不可变来源快照 | @@ -50,8 +51,8 @@ | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | | 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` | -| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | -| 柜级入仓计划进度与整柜转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合,分页前排除全部入仓码均已生成有效独立计划的柜,只分页返回仍有入仓码待生成有效独立计划的柜;详情返回柜内全部订单/入仓码/计划且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结事实并升序、空值最后;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等 | +| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,尾程订单控制从 `LastlegOrderController.java`、`LastlegOrderCommandService.java` 进入,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | +| 尾程订单控制与柜级入仓计划转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合,分页前排除全部入仓码均已生成有效独立计划的柜,详情返回全柜数据;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等。单票完整入库计划进入 `InboundPlanService.java`,暂扣/解除和固定八行人工节点进入 `LastlegOrderCommandService.java`;命令按订单版本、客户范围和幂等键执行,暂扣中的订单不得进入排车或出库 | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | | 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` | @@ -112,6 +113,7 @@ - 固定客户待审核订单使用 `scripts/create-pending-review-orders.mjs`;固定“IT测试”与“义乌仓”、随机真实线路并推进至 `WAIT_SPLIT` 使用 `scripts/it-test-wait-split-bulk.mjs`。二者都是造数入口,不属于全量验证;会写目标环境且不自动清理成功数据。 - Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules `,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。 - 订舱与拖车跨服务完整状态链由 Admin 注册模块 `service-center-flow` 验证,必须复用本轮已放舱订舱并由总入口展开依赖,不得直接执行模块文件或借历史数据。 +- 尾程公网控制由 `phase4-tail-order-controls` 在海外 PDA 前验证整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、暂扣/解除、八个人工节点和逐票人工计划;目的港码头拖车由 `phase5-terminal-drayage` 在卸柜后验证选车队、预约提柜、实际提柜和实际还柜,`phase4-carrier-nodes` 只读核验最终船节点终态。 - 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。 - Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。 diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index a8e63a666..175f3f47b 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -3,8 +3,8 @@ ## 职责与边界 - Mapper 统一显式使用 `@Mapper`,仅由 `BaiyueLastlegApplication.java` 对 `com.baiyue.lastleg` 根包按 `annotationClass = Mapper.class` 扫描;各业务切片不得新增 `*MapperConfiguration` 或维护包白名单。静态门禁见根 `scripts/mapper-scan-contract.mjs`,模块内门禁见 `MapperRegistrationArchitectureTest.java`。 -- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立待分配尾程订单、明细冻结快照和初始状态历史;事件不得自动创建入仓计划,平台人工调用 `/api/platform/v1/last-leg/inbound-plans/convert` 后才生成计划并推进订单。 -- 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。 +- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立待分配尾程订单、明细冻结快照和初始状态历史;事件不得自动创建入仓计划,平台人工调用 `/api/platform/v1/last-leg/inbound-plans/convert-container` 后才按柜内每票生成独立计划并推进订单。 +- 负责尾程订单列表、详情、导出、完整入库计划、暂扣/解除、人工节点和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。 - 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。 ## 快速索引 @@ -13,6 +13,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` 服务端聚合全柜订单、入仓码和有效计划,分页前排除全部入仓码均已生成有效独立计划的柜,`/{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、当前仓库主档与幂等键;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | | 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 | | 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 4699ddf4b..2720ace8c 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -19,6 +19,7 @@ | 入仓测量与整票库位块专项 | `admin-test-env-modules/inbound-measurement.mjs`、`admin-test-env-modules/README.md` | 仍从 Admin 总入口按 `orders,inbound-measurement` 依赖链执行;开启 `--verify-pallet-location-guide true` 后逐张确认回查块扩容只能追加,既有槽身份不得漂移,并验证删除图片后空槽保留与替代图片复用;会写目标环境,必须有对应环境授权 | | Finance 扩展 HTTP 模块 | `admin-test-env-modules/firstleg-container-fee-allocation.mjs`、`finance-direct-paid-isolation.mjs`、`phase4-overseas-finance-cross-region.mjs` | 分别验证柜费跨客户分摊、海外仓服务直接已付隔离、五类海外费用跨区入池;必须由 Admin 总入口按已登记依赖运行,使用本轮资源且禁止借历史数据,真实 HTTP 仍须当前轮授权 | | 订舱与拖车服务中心链路 | `admin-test-env-modules/service-center-flow.mjs` | 复用本轮 `RELEASED` 订舱,创建订舱级拖车并验证附件、完整状态链、历史和稳定关联;由 Admin 总入口按依赖运行,会写目标环境 | +| 尾程单控制与目的港码头拖车 | `admin-test-env-modules/phase4-tail-order-controls.mjs`、`admin-test-env-modules/phase5-terminal-drayage.mjs`、`admin-test-env-modules/phase4-carrier-nodes.mjs` | PDA 前覆盖整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、单票暂扣/解除、八个人工节点和逐票人工计划;接柜仓仅复用公共主档且恢复原值。PDA 卸柜后经正式码头拖车接口完成选车队、预约、提柜、还柜,船节点模块只读核对最终回写,不直接伪造最后两个节点 | | 定向 HTTP/诊断 | 以 `README.md` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 | | 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问开发或测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 | | 部署与基础设施 | `../deploy/scripts/`、`../deploy/rocketmq/` | 靠近部署组件管理,不移动到根脚本目录;部署、初始化数据库或发布配置必须获得当前轮明确授权 | diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 1f4419fcb..4e05e0ffe 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path' 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-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', '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 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-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', 'manual-preload-push-fixture', 'manual-preload-push-verify', 'manual-pda-ready-fixture', 'manual-pda-ready-verify', @@ -71,14 +71,16 @@ const MODULE_DEPENDENCIES = new Map([ ['phase3-order-splitting', ['phase3-loading-finalize']], ['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings']], ['phase4-carrier-pickup-ready', ['phase3-carrier-schedule-queries']], - ['phase4-overseas-pda', ['phase4-carrier-pickup-ready']], + ['phase4-tail-order-controls', ['phase4-carrier-pickup-ready']], + ['phase4-overseas-pda', ['phase4-tail-order-controls']], ['phase4-overseas-pda-operations', ['phase4-overseas-pda']], ['phase4-overseas-inbound-operations', ['phase4-overseas-pda-operations']], ['phase4-overseas-client-services', ['phase4-overseas-pda-operations']], ['phase4-overseas-dispatch', ['phase4-overseas-pda-operations']], ['phase5-overseas-fulfillment', ['phase4-overseas-dispatch']], + ['phase5-terminal-drayage', ['phase4-overseas-pda']], ['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda-operations']], - ['phase4-carrier-nodes', ['phase4-overseas-pda-operations']], + ['phase4-carrier-nodes', ['phase4-overseas-pda-operations', 'phase5-terminal-drayage']], ['phase4-overseas-business', ['merchant-qualification-fixture']], ['firstleg-container-fee-allocation', []], ['finance-direct-paid-isolation', ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']], @@ -1498,13 +1500,16 @@ export class AcceptanceRun { const documents = await import(new URL('./admin-test-env-modules/phase3-container-documents.mjs', import.meta.url)) await this.executeModule(`phase3-container-documents-phase4-${label}`, () => documents.run(this)) const producerKey = this.createdResources.phase3LoadingCompletedProducer?.resourceKey - const writeShippingEvidence = (shippingReady, carrierScheduleQueryId = null) => { + const writeShippingEvidence = (shippingReady, carrierScheduleQueryId = null, + carrierLatestActualAt = null) => { const matrix = this.createdResources.phase4InboundProducerFixtures const records = [matrix?.containers, matrix?.byDestinationType?.FBA, matrix?.byDestinationType?.OVERSEAS].filter(Array.isArray) for (const values of records) { const target = values.find((item) => item?.resourceKey === producerKey) - if (target) Object.assign(target, { shippingReady, carrierScheduleQueryId }) + if (target) Object.assign(target, { + shippingReady, carrierScheduleQueryId, carrierLatestActualAt, + }) } } if (producerKey) writeShippingEvidence(false) @@ -1530,7 +1535,8 @@ export class AcceptanceRun { const pickup = this.createdResources.phase4CarrierPickupReady const shippingReady = pickup?.marker === variantMarker && pickup?.ready === true && Boolean(pickup?.queryId) - writeShippingEvidence(shippingReady, shippingReady ? String(pickup.queryId) : null) + writeShippingEvidence(shippingReady, shippingReady ? String(pickup.queryId) : null, + shippingReady ? pickup.latestActualAt : null) this.assert('phase4-overseas-pda-fixture', `${label}通过真实出仓与船节点进入PICKUP_READY`, null, shippingReady, `resourceKey=${producerKey}, queryId=${pickup?.queryId || 'missing'}, ready=${pickup?.ready === true}`, diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index e5f75afee..385eaa211 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -86,6 +86,7 @@ export async function run(context) { | 客户钱包与拆柜打托费自动闭环 | `phase4-overseas-wallet-and-pallet-cost.mjs`(授信初始化、平台充值、采购凭证/费用冻结结算、换标冻结结算/释放、Client 余额账本、国内/海外托盘汇总、按体积分摊及跨区金额守恒) | `phase4-overseas-wallet-and-pallet-cost-contract.test.mjs`;默认全量已启用,定向入口自动展开已审核客户与海外 PDA 实收链 | | 海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` | | 海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行盘点、整托移库、托盘拆装换绑和换标复核;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` | +| 尾程单公网控制与人工计划 | `phase4-tail-order-controls.mjs`(PDA 收货前对本轮柜执行整柜接柜仓变更/恢复与幂等冲突、单/批 FBA 目的仓变更、单/批合托标记并取消、CSV 导出、订单费用回查、单票暂扣/解除、八个人工节点和逐票人工计划) | `phase4-tail-order-controls-contract.test.mjs`;只复用现有公共接柜仓/FBA 主档,不新增仓库、托盘或库位;为目标柜全部入仓码创建 `MANUAL` 计划后交给 PDA 继续消费,不借历史订单 | | 海外入仓扩展、客户服务与排车 | `phase4-overseas-inbound-operations.mjs`、`phase4-overseas-client-services.mjs`、`phase4-overseas-dispatch.mjs` | 对应同名 `*-contract.test.mjs`;只消费本轮前置资源,缺失时 fail-closed | | 海外仓业务跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` | | 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(四类柜资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` | @@ -93,7 +94,7 @@ export async function run(context) { | 人工状态矩阵 | `manual-state-matrix.mjs` | `manual-state-matrix-contract.test.mjs`;验证装柜完成、报关和订舱相关状态组合 | | 海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | | 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` | -| 海外仓业务船节点安全冒烟 | `phase4-carrier-nodes.mjs`(standalone 对历史任务只读;仅复用当前 marker 的 `phase3CarrierScheduleQuery` 时补录预计事实) | `phase4-carrier-nodes-contract.test.mjs` | +| 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜后复用 `ACTIVE + TRUCKING` 主数据,覆盖选车队、预约提柜、实际提柜、实际还柜及四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 | | 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮海外仓已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、转出库/派送、PDA 全量拣货/发车、派送完成、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` | | 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` | @@ -103,6 +104,12 @@ export async function run(context) { 节点全链定向入口为 `--modules phase4-overseas-pda`。依赖展开固定经过本轮 `LOADED` 柜次的 `DESTINATION_CLEARANCE`、`CUSTOMS_DECLARATION`、`DELIVERY_ORDER`、`PICKUP_ORDER` 四类资料生命周期,再确认国内出仓、核对自动创建的 `OUTBOUND_CONFIRM` 船节点任务、人工补录至实际 `PICKUP_READY`,最后验证海外 PDA 候选可见。四类资料模块只写本轮 marker 柜次,覆盖上传/完成重放、同键异载荷 `A0409`、冲突后零漂移和聚合回读;任一 fixture 或 Trace 缺失均为 deployment blocker。 +尾程单公网控制与目的港码头拖车定向入口为 `--modules phase4-carrier-nodes`。依赖会在海外 PDA 前插入 `phase4-tail-order-controls`,先对本轮柜验证接柜仓变更/恢复、目的仓变更、合托标记、CSV 导出、订单费用、暂扣阻断、解除和八个人工节点,再对目标柜全部尾程订单生成人工计划;PDA 卸柜完成后再运行 `phase5-terminal-drayage`,只复用公共 `ACTIVE + TRUCKING` 供应商,依次推进选车队、预约、提柜和还柜。最后的船节点模块仅回读两次业务动作同步生成的 `PICKED_UP/EMPTY_RETURNED`,不得直接补录这两个节点。 + +```powershell +node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase4-carrier-nodes +``` + 海外 PDA 收货统一扫描冻结箱码:`FBA` 与 `OVERSEAS` 都必须由 `POST /api/pda/v1/last-leg/receiving-containers/current/cartons/resolve` 返回 HTTP 200、业务码 `200` 和当前柜唯一 `inboundCode`,不展示、扫描或提交 SKU。仅实收阶段分流:FBA 使用不含 `skuCode` 的 `/current/receipts/bulk`,逐箱接口必须精确返回 `A0409 FBA destination must use bulk count`;OVERSEAS 使用 `/current/receipts/carton`,bulk 必须精确返回 `A0409 OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden`。实机旧行为反证为 requestId `5e925a3d-8184-41e1-b0d2-f4e1aae86639`、traceId `62fdf603b65fd7f43ec452bb8a6ca185`。跨区广播消费只保留观察证据,不再单独形成 deployment blocker;实际 `PICKUP_READY` 候选门禁仍必须通过。 FBA 无 `ll_inbound_plan_carton` manifest 时,服务端只取完整扫描值的第一个 `#` 前缀,并在当前锁定柜中精确匹配唯一 `inboundCode`;不校验 SKU、`#` 后缀或箱序号,也不能要求前端拆分或自行决定归属。无 `#`、空前缀、未命中、跨柜或同柜多匹配必须拒绝。OVERSEAS 仍按完整箱码精确匹配真实 manifest 行。HTTP 合同使用任意 FBA 后缀验证前缀解析,覆盖上述负向与 OVERSEAS 缺 manifest,并在正负向 resolve 后回查 current 的状态、版本与收货数量零漂移。2026-08-26 最新已部署实机尚返回 `A0404 Carton mark was not found`,requestId `a716e2c2-dc10-4d07-8014-7438afb63090`、traceId `6ab8fcfcd812056ad6570d3b1652157b`;该证据保留为后端 FBA 入仓码前缀解析落盘及重新部署后的回归反证,不据此放宽成功合同。 diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index f893a0667..03d5dba66 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -123,6 +123,8 @@ test('only PDA-operated variants traverse real outbound and carrier nodes before assert.match(entry, /for \(const key of createdKeys\) delete this\.createdResources\[key\]/) assert.match(pickupChain, /shippingReady/) assert.match(entry, /carrierScheduleQueryId/) + assert.match(entry, /carrierLatestActualAt/) + assert.match(pickupChain, /pickup\.latestActualAt/) assert.match(pickupChain, /pickup\?\.marker === variantMarker/) assert.doesNotMatch(pickupChain, /internal\/event-bridge|ll_container_shipping_gate|requestOverseas/) assert.match(entry, /dropshipReady\?\.shippingReady === true/) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index b8aba6357..a4a33b8a2 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -5,7 +5,7 @@ import test from 'node:test' import { fileURLToPath } from 'node:url' import { EXCEPTION_CODES, NODE_SEQUENCES, QUERY_STATUSES, STANDARD_NODES, - manualNodePayload, markerOwnedCarrierQuery, run } from './phase4-carrier-nodes.mjs' + manualNodePayload, markerOwnedCarrierQuery, run, runUntilPickupReady } from './phase4-carrier-nodes.mjs' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) @@ -32,22 +32,25 @@ test('focused carrier node execution injects the complete outbound-confirm produ 'phase3-loading-finalize', 'phase3-customs', 'ocean-bookings', 'phase3-port-schedules', 'truck-appointments', 'service-center-flow', 'phase3-outbound-dispatch', 'phase3-carrier-schedule-queries', 'phase4-carrier-pickup-ready', - 'phase4-overseas-pda', 'phase4-carrier-nodes'] + 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase5-terminal-drayage', + 'phase4-carrier-nodes'] for (const module of required) assert.ok(modules.includes(module), module) for (let index = 1; index < required.length; index += 1) { assert.ok(modules.indexOf(required[index - 1]) < modules.indexOf(required[index]), required[index]) } }) -test('real lifecycle opens the shipping gate before PDA and records pickup and empty return afterwards', () => { +test('real lifecycle opens the shipping gate before PDA and delegates pickup and empty return to terminal drayage', () => { const modules = normalizeModuleOrder(['phase4-overseas-pda', 'phase4-carrier-nodes']) const pickup = modules.indexOf('phase4-carrier-pickup-ready') const pda = modules.indexOf('phase4-overseas-pda') + const drayage = modules.indexOf('phase5-terminal-drayage') const completion = modules.indexOf('phase4-carrier-nodes') assert.ok(pickup >= 0 && pickup < pda) - assert.ok(pda < completion) + assert.ok(pda < drayage && drayage < completion) assert.match(source, /phase4CarrierPickupReady/) - assert.match(source, /STANDARD_NODES\.slice\(STANDARD_NODES\.indexOf\('PICKED_UP'\)\)/) + assert.match(source, /phase5TerminalDrayage/) + assert.doesNotMatch(source, /PDA完成后补录实际节点/) }) test('canonical states, ten ordered nodes, exception codes and complete payload are frozen', () => { @@ -100,16 +103,11 @@ test('ownership requires current marker, long query id and outbound-confirm sour } }), null) }) -test('missing phase3 outbound query is an explicit deployment blocker and performs no write', async () => { +test('missing terminal drayage completion is an explicit deployment blocker and performs no write', async () => { const calls = []; const steps = [] const context = { marker: MARKER, token: 'token', createdResources: {}, calls, - request: async (method, requestPath) => { - calls.push({ method, path: requestPath }) - if (requestPath === `${BASE}/carriers`) return ok([]) - if (requestPath === `${BASE}?page=1&size=20`) return ok({ records: [], page: 1, size: 20, total: 0, pages: 0 }) - throw new Error(`unexpected path ${requestPath}`) - }, + request: async (method, requestPath) => { calls.push({ method, path: requestPath }); throw new Error('unexpected request') }, apiSuccess: (_module, _name, response) => response.body.data, assert: (_module, name, _response, condition, evidence) => assert.ok(condition, `${name}: ${evidence}`), addStep: (_module, name, _response, passed, evidence, options) => steps.push({ name, passed, evidence, options }), @@ -117,11 +115,11 @@ test('missing phase3 outbound query is an explicit deployment blocker and perfor await run(context) - assert.deepEqual(calls.map((item) => item.method), ['GET', 'GET']) + assert.deepEqual(calls, []) assert.equal(steps.length, 1) assert.equal(steps[0].passed, false) assert.equal(steps[0].options.deploymentBlocker, true) - assert.match(steps[0].name, /禁止借历史任务写节点/) + assert.match(steps[0].name, /取得本轮码头拖车还柜终态/) }) function markerOwnedContext() { @@ -225,13 +223,13 @@ function markerOwnedContext() { return { context, task, nodes, exceptions, histories, steps, assertions } } -test('marker-owned chain covers idempotency conflicts, all actual nodes, exception DTO and empty-return stop', async () => { +test('marker-owned pre-PDA chain covers idempotency conflicts through pickup-ready', async () => { const fixture = markerOwnedContext() - await run(fixture.context) + await runUntilPickupReady(fixture.context) const posts = fixture.context.calls.filter((item) => item.method === 'POST') - assert.equal(posts.length, 17) + assert.equal(posts.length, 15) assert.ok(posts.every((item) => item.path === `${BASE}/${QUERY_ID}/manual-nodes`)) assert.ok(posts.every((item) => item.headers['Idempotency-Key'])) assert.ok(posts.every((item) => Object.keys(item.body).length === 8)) @@ -240,11 +238,11 @@ test('marker-owned chain covers idempotency conflicts, all actual nodes, excepti assert.ok(fixture.assertions.some((item) => /更早实际节点A0409后task、node、history零漂移/.test(item.name))) assert.ok(fixture.steps.some((item) => /同级actual失败请求同键重试.*operation claim零残留/.test(item.name))) assert.ok(fixture.steps.some((item) => /更早actual失败请求同键重试.*operation claim零残留/.test(item.name))) - assert.equal(fixture.task.status, 'STOPPED') - assert.equal(fixture.task.latestActualNode, 'EMPTY_RETURNED') - assert.equal(fixture.task.stopReason, '已还柜') + assert.equal(fixture.task.status, 'READY') + assert.equal(fixture.task.latestActualNode, 'PICKUP_READY') + assert.equal(fixture.task.stopReason, null) const actualNodes = new Set(fixture.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode)) - assert.deepEqual([...actualNodes].sort(), [...STANDARD_NODES].sort()) + assert.deepEqual([...actualNodes].sort(), [...STANDARD_NODES.slice(0, 8)].sort()) assert.equal(fixture.exceptions[0].exceptionCode, 'CUSTOMS_HOLD') assert.ok(fixture.nodes.every((item) => /^\d{16,}$/.test(item.id))) assert.equal(fixture.context.calls.some((item) => /attempts|scheduler|internal|\/stop/.test(item.path)), false) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index 99675ccae..21a1e2586 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -312,63 +312,32 @@ export async function runUntilPickupReady(context) { } export async function run(context) { - let pickup = context.createdResources?.phase4CarrierPickupReady - if (pickup?.marker !== context.marker || pickup?.ready !== true) { - await runUntilPickupReady(context) - pickup = context.createdResources?.phase4CarrierPickupReady - } - if (!pickup?.queryId) return - - const queryId = id(pickup.queryId) - const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`) - let detail = context.apiSuccess(MODULE, 'PDA收货完成后恢复本轮船节点任务', detailResponse, - `queryId=${queryId}`, OPTIONS) - assertDetail(context, detailResponse, detail, '恢复任务详情DTO完整') - const pickupTask = detail?.query?.query - if (pickupTask?.latestActualNode !== 'PICKUP_READY' || pickupTask?.status === 'STOPPED') { - blocker(context, 'PDA收货后船节点未停留在PICKUP_READY,禁止伪造后续节点', - `latest=${pickupTask?.latestActualNode || 'missing'}, status=${pickupTask?.status || 'missing'}`, detailResponse) + const pickup = context.createdResources?.phase4CarrierPickupReady + const drayage = context.createdResources?.phase5TerminalDrayage + if (pickup?.marker !== context.marker || pickup?.ready !== true || !pickup?.queryId + || drayage?.marker !== context.marker || drayage?.finalStatus !== 'EMPTY_RETURNED') { + blocker(context, '取得本轮码头拖车还柜终态', + `pickup=${pickup?.ready === true}, drayageMarker=${drayage?.marker || 'missing'}, drayageStatus=${drayage?.finalStatus || 'missing'}`) return } - let version = Number(pickupTask.version) - const frozenLatestActualMs = Date.parse(`${pickupTask.latestActualAt}Z`) - const baseActual = (Number.isNaN(frozenLatestActualMs) ? Date.now() : frozenLatestActualMs) + 60 * 60_000 - const remainingNodes = STANDARD_NODES.slice(STANDARD_NODES.indexOf('PICKED_UP')) - for (let index = 0; index < remainingNodes.length; index += 1) { - const standardNode = remainingNodes[index] - const actualAt = localIso(new Date(baseActual + index * 60_000)) - const body = manualNodePayload({ expectedVersion: version, standardNode, - observedAt: localIso(new Date()), actualAt, marker: context.marker, - index: STANDARD_NODES.indexOf(standardNode) + 1 }) - const key = `${context.marker}-carrier-actual-${String(STANDARD_NODES.indexOf(standardNode) + 1).padStart(2, '0')}`.slice(0, 128) - const response = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, body, - context.token, { 'Idempotency-Key': key }) - const updated = context.apiSuccess(MODULE, `PDA完成后补录实际节点${standardNode}`, response, - `queryId=${queryId}`, OPTIONS) - assertDetail(context, response, updated, `实际节点${standardNode}响应DTO完整`) - const updatedTask = updated?.query?.query - const isEmptyReturned = standardNode === 'EMPTY_RETURNED' - context.assert(MODULE, `${standardNode}实际事实单调推进且停止语义正确`, response, - updatedTask?.latestActualNode === standardNode && updatedTask?.latestActualAt === actualAt - && (isEmptyReturned - ? updatedTask.status === 'STOPPED' && updatedTask.stopReason === '已还柜' && updatedTask.stoppedAt != null - : updatedTask.status !== 'STOPPED'), - `status=${updatedTask?.status || 'missing'}, latest=${updatedTask?.latestActualNode || 'none'}`, OPTIONS) - version = Number(updatedTask.version) - detail = updated - } + const queryId = id(pickup.queryId) + const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`) + const detail = context.apiSuccess(MODULE, '码头拖车完成后回查本轮船节点任务', detailResponse, + `queryId=${queryId}`, OPTIONS) + assertDetail(context, detailResponse, detail, '码头拖车回写后任务详情DTO完整') const finalTask = detail.query.query const actualNodes = new Set(detail.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode)) - context.assert(MODULE, '最终详情覆盖十标准实际节点且只有EMPTY_RETURNED触发停止', detailResponse, + context.assert(MODULE, '拖车业务回写后覆盖十标准实际节点且EMPTY_RETURNED停止', detailResponse, STANDARD_NODES.every((node) => actualNodes.has(node)) && finalTask.status === 'STOPPED' - && finalTask.latestActualNode === 'EMPTY_RETURNED' && finalTask.stopReason === '已还柜', + && finalTask.latestActualNode === 'EMPTY_RETURNED' && finalTask.stopReason === '已还柜' + && id(finalTask.containerBookingId) === id(drayage.containerBookingId), `actualNodes=${[...actualNodes].join(',')}, status=${finalTask.status}`, OPTIONS) const historyResponse = await context.request('GET', `${BASE}/${queryId}/history`) const history = context.apiSuccess(MODULE, '查询本轮任务不可变历史', historyResponse, `queryId=${queryId}`, OPTIONS) - context.assert(MODULE, '历史只含业务动作且人工节点完整留痕', historyResponse, + context.assert(MODULE, '历史包含码头拖车回写节点且结构完整', historyResponse, Array.isArray(history) && history.every(validHistory) && history.filter((item) => item.action === 'MANUAL_NODE').length >= STANDARD_NODES.length, `history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs new file mode 100644 index 000000000..9dd87cda2 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' + +const source = await readFile(fileURLToPath(new URL('./phase4-tail-order-controls.mjs', import.meta.url)), 'utf8') + +test('tail order controls execute after current producer and before overseas PDA', () => { + const modules = normalizeModuleOrder(['phase4-overseas-pda']) + const controls = modules.indexOf('phase4-tail-order-controls') + assert.ok(controls > modules.indexOf('phase4-carrier-pickup-ready')) + assert.ok(controls < modules.indexOf('phase4-overseas-pda')) +}) + +test('module covers the public tail-order write surface before PDA receiving', () => { + for (const path of ['/receiving-warehouse', '/destination', '/pallet-merge-required', '/fees', '/export', + '/hold', '/manual-nodes', '/api/platform/v1/last-leg/inbound-plans', '/manual']) { + assert.ok(source.includes(path), `missing ${path}`) + } + for (const evidence of [ + '接柜仓变更同键同载荷重放', '接柜仓变更同键异载荷返回A0409', '接柜仓变更旧版本返回A0409', + '整柜恢复原公共接柜仓', '单票变更FBA目的仓', '批量变更FBA目的仓', + '单票开启PDA合托提示', '批量开启PDA合托分组', '批量取消PDA合托分组', + '尾程费用详情可追溯', '导出本轮尾程订单CSV', + '暂扣命令同键同载荷重放', '暂扣同键异载荷返回A0409', '暂扣尾程订单不能生成人工计划', + '解除本轮单票尾程暂扣', '保存尾程八个人工节点快照', '人工节点同键同载荷重放', + '人工节点同键异载荷返回A0409', '人工节点读取固定为MANUAL且不回退柜级节点', + '人工计划按订单稳定接柜仓回读当前主档并冻结入仓码、托数和操作标志', '人工计划同键异载荷返回A0409', + ]) assert.ok(source.includes(evidence), `missing ${evidence}`) + assert.match(source, /nodes\.data\?\.nodes\?\.length === 8/) + assert.match(source, /creationMode === 'MANUAL'/) + assert.match(source, /requestOverseasBinary\('GET'/) + assert.match(source, /palletMergeBatchId/) +}) + +test('receiving warehouse verification reuses public master data and restores the original assignment', () => { + assert.match(source, /tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE/) + assert.match(source, /id\(row\.id\) !== originalWarehouseId/) + assert.match(source, /overseasWarehouseId: originalWarehouseId, expectedVersion: Number\(changed\.data\.assignmentVersion\)/) + assert.doesNotMatch(source, /request\('POST', '\/api\/platform\/v1\/warehouse-admin'/) +}) + +test('module is marker-owned and never borrows historical tail orders', () => { + assert.match(source, /matrix\?\.marker !== context\.marker/) + assert.match(source, /containerNo: producer\.containerNo/) + assert.match(source, /customerId\) === id\(producer\.tenantId\)/) + assert.match(source, /禁止借历史尾程订单/) + assert.doesNotMatch(source, /records\([^)]*\)\[0\]/) +}) + +test('manual plans preserve long identifiers and are retained for PDA consumption', () => { + assert.match(source, /orderId: id\(order\.id\)/) + assert.doesNotMatch(source, /plannedWarehouse(?:Id|Code|Name):/) + assert.match(source, /!order\.overseasWarehouseId/) + assert.match(source, /planNos: plans/) + assert.match(source, /MANUAL_PLANS_READY/) + assert.match(source, /remain as the marker-owned prerequisites consumed by overseas PDA receiving/) +}) diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs new file mode 100644 index 000000000..7909af6ba --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs @@ -0,0 +1,336 @@ +const MODULE = 'phase4-tail-order-controls' +const SCENARIO = 'phase4-tail-order-controls-lifecycle' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const ORDERS = '/api/platform/v1/last-leg/orders' +const PLANS = '/api/platform/v1/last-leg/inbound-plans' +const NODE_CODES = Object.freeze([ + 'DOMESTIC_CUSTOMS_COMPLETED', 'ESTIMATED_DEPARTURE', 'ACTUAL_DEPARTURE', 'ESTIMATED_ARRIVAL', + 'OVERSEAS_CUSTOMS_COMPLETED', 'ACTUAL_ARRIVAL_WAITING_UNLOAD', 'PICKUP_APPOINTED', 'UNLOADED', +]) + +function id(value) { return value == null ? '' : String(value) } +function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) } +function success(response) { return response?.http === 200 && codeOf(response) === '200' } +function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') } +function records(value) { return Array.isArray(value?.records) ? value.records : [] } +function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 } +function localIso(value) { return value.toISOString().slice(0, 19) } +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function query(path, values) { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(values || {})) { + if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value)) + } + return params.size ? `${path}?${params}` : path +} +async function api(context, method, name, path, body, headers = {}) { + const response = await context.requestOverseas(method, path, body, headers, context.overseasToken) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} +async function domesticApi(context, name, path) { + const response = await context.request('GET', path) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} +function blocker(context, name, evidence, response = null) { + context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS) + context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true }) +} +function targetProducer(context) { + const matrix = context.createdResources?.phase4InboundProducerFixtures + if (matrix?.marker !== context.marker) return null + return matrix.containers?.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP') + || matrix.byDestinationType?.FBA?.[0] + || matrix.byDestinationType?.OVERSEAS?.[0] + || null +} +async function markerOrders(context, producer) { + let last = null + for (let attempt = 1; attempt <= 60; attempt += 1) { + last = await api(context, 'GET', `等待本轮尾程订单落地(${attempt}/60)`, + query(ORDERS, { page: 1, size: 200, containerNo: producer.containerNo })) + const matched = records(last.data).filter((row) => id(row?.containerNo) === id(producer.containerNo) + && id(row?.customerId) === id(producer.tenantId) && id(row?.id)) + if (matched.length > 0) return { rows: matched, response: last.response } + if (attempt < 60) await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + return { rows: [], response: last?.response || null } +} +async function detail(context, row, name = '读取尾程订单即时详情') { + return api(context, 'GET', name, + query(`${ORDERS}/${encodeURIComponent(id(row.id))}`, { customerId: id(row.customerId) })) +} +async function verifyReceivingWarehouseChange(context, producer, details) { + const bookingId = id(producer.containerBookingId) + if (!bookingId) { + blocker(context, '取得本轮来源柜预订ID', `containerNo=${producer.containerNo}`) + return false + } + const progressPath = `${ORDERS}/container-summaries/${encodeURIComponent(bookingId)}` + const current = await api(context, 'GET', '读取本轮柜级入仓计划进度', progressPath) + const summary = current.data?.summary + const originalWarehouseId = id(summary?.overseasWarehouseId) + if (!success(current.response) || !originalWarehouseId || summary?.canChangeReceivingWarehouse !== true) { + blocker(context, '柜级接柜仓可变更前置', + `bookingId=${bookingId}, warehouseId=${originalWarehouseId || 'missing'}, canChange=${summary?.canChangeReceivingWarehouse}`, current.response) + return false + } + const warehouses = await domesticApi(context, '查询可用公共海外接柜仓', + '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE') + const alternate = records(warehouses.data).find((row) => id(row?.id) && id(row.id) !== originalWarehouseId + && row?.warehouseType === 'OVERSEAS' && row?.status === 'ACTIVE') + if (!alternate) { + blocker(context, '柜级接柜仓真实切换候选', + `originalWarehouseId=${originalWarehouseId}; 至少需要2个ACTIVE公共OVERSEAS接柜仓`, warehouses.response) + return false + } + const changePath = `${progressPath}/receiving-warehouse` + const changeKey = `${context.marker}-receiving-warehouse-change`.slice(0, 128) + const changeBody = { overseasWarehouseId: id(alternate.id), expectedVersion: Number(summary.assignmentVersion), remark: context.marker } + const changed = await api(context, 'PUT', '整柜切换到另一公共接柜仓', changePath, changeBody, + { 'Idempotency-Key': changeKey }) + context.assert(MODULE, '接柜仓变更原子推进版本并保持柜内订单数', changed.response, + id(changed.data?.overseasWarehouseId) === id(alternate.id) + && Number(changed.data?.assignmentVersion) === Number(summary.assignmentVersion) + 1 + && Number(changed.data?.totalOrderCount) === details.length, + trace(changed.response), OPTIONS) + if (!success(changed.response)) return false + const replay = await api(context, 'PUT', '接柜仓变更同键同载荷重放', changePath, changeBody, + { 'Idempotency-Key': changeKey }) + context.assert(MODULE, '接柜仓变更重放版本零漂移', replay.response, + Number(replay.data?.assignmentVersion) === Number(changed.data?.assignmentVersion) + && id(replay.data?.overseasWarehouseId) === id(alternate.id), trace(replay.response), OPTIONS) + const hashConflict = await context.requestOverseas('PUT', changePath, { ...changeBody, remark: `${context.marker}-DIFFERENT` }, + { 'Idempotency-Key': changeKey }, context.overseasToken) + context.addStep(MODULE, '接柜仓变更同键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS) + const stale = await context.requestOverseas('PUT', changePath, + { overseasWarehouseId: originalWarehouseId, expectedVersion: Number(summary.assignmentVersion), remark: context.marker }, + { 'Idempotency-Key': `${context.marker}-receiving-warehouse-stale`.slice(0, 128) }, context.overseasToken) + context.addStep(MODULE, '接柜仓变更旧版本返回A0409', stale, conflict(stale), trace(stale), OPTIONS) + const restored = await api(context, 'PUT', '整柜恢复原公共接柜仓', changePath, + { overseasWarehouseId: originalWarehouseId, expectedVersion: Number(changed.data.assignmentVersion), remark: `${context.marker}-restore` }, + { 'Idempotency-Key': `${context.marker}-receiving-warehouse-restore`.slice(0, 128) }) + context.assert(MODULE, '接柜仓恢复后供后续PDA消费', restored.response, + id(restored.data?.overseasWarehouseId) === originalWarehouseId + && Number(restored.data?.assignmentVersion) === Number(changed.data.assignmentVersion) + 1, + trace(restored.response), OPTIONS) + return success(restored.response) +} + +async function verifyDestinationMergeFeeAndExport(context, details, producer) { + const fbaResult = await domesticApi(context, '查询公共ACTIVE FBA目的仓', + '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&warehouseType=FBA&status=ACTIVE') + const fba = records(fbaResult.data).find((row) => id(row?.id) && nonBlank(row?.warehouseCode) + && nonBlank(row?.warehouseName) && row?.warehouseType === 'FBA' && row?.status === 'ACTIVE') + if (!fba) { + blocker(context, '尾程目的仓变更权威主档', '国内Warehouse未返回公共ACTIVE FBA仓', fbaResult.response) + return false + } + const destination = { warehouseId: id(fba.id), warehouseCode: fba.warehouseCode, + warehouseName: fba.warehouseName, remark: context.marker } + const representative = details[0] + const one = await api(context, 'PUT', '单票变更FBA目的仓', + query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/destination`, { customerId: id(representative.customerId) }), destination) + context.assert(MODULE, '单票目的仓变更返回1', one.response, Number(one.data) === 1, trace(one.response), OPTIONS) + if (!success(one.response)) return false + const batchOrders = details.slice(1).length > 0 ? details.slice(1) : [representative] + const batch = await api(context, 'PUT', '批量变更FBA目的仓', + query(`${ORDERS}/destination`, { customerId: id(representative.customerId) }), + { ...destination, orderIds: batchOrders.map((row) => id(row.id)) }) + context.assert(MODULE, '批量目的仓变更数量精确', batch.response, + Number(batch.data) === batchOrders.length, trace(batch.response), OPTIONS) + if (!success(batch.response)) return false + + const mergePath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/pallet-merge-required`, { + customerId: id(representative.customerId), + }) + const marked = await api(context, 'PUT', '单票开启PDA合托提示', mergePath, { palletMergeRequired: true }) + context.assert(MODULE, '单票合托标记写入成功', marked.response, marked.data == null, trace(marked.response), OPTIONS) + const markedRead = await detail(context, representative, '回查单票合托标记') + context.assert(MODULE, '单票合托标记为REQUIRED', markedRead.response, + markedRead.data?.order?.palletMergeFlag === 'REQUIRED', trace(markedRead.response), OPTIONS) + const unmarked = await api(context, 'PUT', '单票取消PDA合托提示', mergePath, { palletMergeRequired: false }) + context.assert(MODULE, '单票合托标记可取消', unmarked.response, unmarked.data == null, trace(unmarked.response), OPTIONS) + + const allIds = details.map((row) => id(row.id)) + const batchMergePath = query(`${ORDERS}/pallet-merge-required/batch`, { customerId: id(representative.customerId) }) + const batchMarked = await api(context, 'PUT', '批量开启PDA合托分组', batchMergePath, + { orderIds: allIds, palletMergeRequired: true }) + context.assert(MODULE, '批量合托分组写入成功', batchMarked.response, batchMarked.data == null, trace(batchMarked.response), OPTIONS) + const groupRead = await detail(context, representative, '回查批量合托关系') + context.assert(MODULE, '批量合托关系包含本轮订单', groupRead.response, + groupRead.data?.order?.palletMergeFlag === 'REQUIRED' + && nonBlank(groupRead.data?.order?.palletMergeBatchId), trace(groupRead.response), OPTIONS) + const batchCleared = await api(context, 'PUT', '批量取消PDA合托分组', batchMergePath, + { orderIds: allIds, palletMergeRequired: false }) + context.assert(MODULE, '批量合托分组取消成功', batchCleared.response, batchCleared.data == null, trace(batchCleared.response), OPTIONS) + + const feeItem = `${context.marker}-TAIL-FEE`.slice(0, 128) + const fee = await api(context, 'POST', '新增本轮尾程订单费用', + query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/fees`, { customerId: id(representative.customerId) }), + { feeItem, currency: 'USD', amount: 0.01, status: 'PENDING_REVIEW', supplierDescription: context.marker }) + context.assert(MODULE, '尾程费用冻结币种金额和审核状态', fee.response, + id(fee.data?.orderId) === id(representative.id) && fee.data?.feeItem === feeItem + && fee.data?.currency === 'USD' && Number(fee.data?.amount) === 0.01 && fee.data?.status === 'PENDING_REVIEW', + trace(fee.response), OPTIONS) + const feeRead = await detail(context, representative, '回查尾程订单费用明细') + context.assert(MODULE, '尾程费用详情可追溯', feeRead.response, + feeRead.data?.fees?.some((row) => row?.feeItem === feeItem && Number(row?.amount) === 0.01), trace(feeRead.response), OPTIONS) + + const exported = await context.requestOverseasBinary('GET', query(`${ORDERS}/export`, { + customerId: id(representative.customerId), containerNo: producer.containerNo, + })) + const csv = new TextDecoder().decode(exported.bytes || new Uint8Array()) + context.addStep(MODULE, '导出本轮尾程订单CSV', exported, + exported?.http === 200 && String(exported?.contentType || '').toLowerCase().includes('csv') + && csv.includes(producer.containerNo) && csv.includes('尾程单号'), + `bytes=${exported?.bytes?.length ?? 0}, containerNo=${producer.containerNo}; ${trace(exported)}`, OPTIONS) + return success(batchCleared.response) && success(fee.response) && exported?.http === 200 +} +function manualPlanBody(order, version, marker) { + return { + customerId: id(order.customerId), orderId: id(order.id), expectedOrderVersion: Number(version), + plannedArrivalDate: new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10), + inboundCode: order.inboundCode, + palletCount: Math.min(999, Math.max(1, Number(order.palletCount) || 1)), + palletType: 'STANDARD', assigned: false, mergeRequired: false, urgent: true, + remark: marker, + } +} +function completeManualNodes(marker) { + const base = Date.now() - 8 * 60 * 60_000 + return NODE_CODES.map((nodeCode, index) => ({ + nodeCode, nodeAt: localIso(new Date(base + index * 60 * 60_000)), + })).map((node, index) => index === 3 ? { ...node, nodeAt: null } : node) +} + +export async function run(context) { + context.createdResources.phase4TailOrderControls = { + marker: context.marker, containerNo: null, orderIds: [], planNos: [], representativeOrderId: null, + finalStatus: null, + cleanup: 'Manual inbound plans remain as the marker-owned prerequisites consumed by overseas PDA receiving', + } + const producer = targetProducer(context) + if (!producer?.containerNo || !producer?.tenantId) { + blocker(context, '取得本轮尾程控制目标柜', '缺少本轮phase4入仓生产者,禁止借历史尾程订单') + return + } + const listed = await markerOrders(context, producer) + if (listed.rows.length === 0) { + blocker(context, '取得本轮尾程订单', `containerNo=${producer.containerNo}, customerId=${producer.tenantId}`, listed.response) + return + } + const details = [] + for (const row of listed.rows) { + const current = await detail(context, row) + if (success(current.response) && id(current.data?.order?.id) === id(row.id)) details.push(current.data.order) + } + if (details.length !== listed.rows.length || details.some((order) => !order.inboundCode + || !order.overseasWarehouseId)) { + blocker(context, '尾程订单人工计划冻结事实完整', `listed=${listed.rows.length}, detailed=${details.length}`) + return + } + if (!await verifyReceivingWarehouseChange(context, producer, details)) return + if (!await verifyDestinationMergeFeeAndExport(context, details, producer)) return + const refreshedDetails = [] + for (const original of details) { + const refreshed = await detail(context, original, `管理命令后刷新订单${id(original.id)}版本`) + if (!success(refreshed.response) || !refreshed.data?.order) return + refreshedDetails.push(refreshed.data.order) + } + const representative = refreshedDetails[0] + const orderPath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/hold`, { + customerId: id(representative.customerId), + }) + const holdKey = `${context.marker}-tail-hold`.slice(0, 128) + const holdBody = { held: true, chargeable: true, reason: `${context.marker} 暂扣验收`, expectedVersion: Number(representative.version) } + const held = await api(context, 'PUT', '暂扣本轮单票尾程订单', orderPath, holdBody, { 'Idempotency-Key': holdKey }) + context.assert(MODULE, '暂扣只推进单票版本并保留计费原因', held.response, + held.data?.held === true && held.data?.chargeable === true + && id(held.data?.orderId) === id(representative.id) && Number(held.data?.version) === Number(representative.version) + 1, + trace(held.response), OPTIONS) + if (!success(held.response)) return + const heldReplay = await api(context, 'PUT', '暂扣命令同键同载荷重放', orderPath, holdBody, { 'Idempotency-Key': holdKey }) + context.assert(MODULE, '暂扣重放版本零漂移', heldReplay.response, + JSON.stringify(heldReplay.data) === JSON.stringify(held.data), trace(heldReplay.response), OPTIONS) + const heldConflict = await context.requestOverseas('PUT', orderPath, + { ...holdBody, reason: `${holdBody.reason}-DIFFERENT` }, { 'Idempotency-Key': holdKey }, context.overseasToken) + context.addStep(MODULE, '暂扣同键异载荷返回A0409', heldConflict, conflict(heldConflict), trace(heldConflict), OPTIONS) + + const blockedPlanBody = manualPlanBody(representative, held.data.version, context.marker) + const blockedPlan = await context.requestOverseas('POST', `${PLANS}/manual`, blockedPlanBody, + { 'Idempotency-Key': `${context.marker}-held-plan`.slice(0, 128) }, context.overseasToken) + context.addStep(MODULE, '暂扣尾程订单不能生成人工计划', blockedPlan, conflict(blockedPlan), trace(blockedPlan), OPTIONS) + + const releaseKey = `${context.marker}-tail-release`.slice(0, 128) + const releaseBody = { held: false, chargeable: false, reason: `${context.marker} 解除暂扣`, expectedVersion: Number(held.data.version) } + const released = await api(context, 'PUT', '解除本轮单票尾程暂扣', orderPath, releaseBody, { 'Idempotency-Key': releaseKey }) + context.assert(MODULE, '解除暂扣清空计费与原因并推进版本', released.response, + released.data?.held === false && released.data?.chargeable === false && released.data?.reason == null + && Number(released.data?.version) === Number(held.data.version) + 1, trace(released.response), OPTIONS) + if (!success(released.response)) return + + const nodesPath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/manual-nodes`, { + customerId: id(representative.customerId), + }) + const nodesKey = `${context.marker}-tail-manual-nodes`.slice(0, 128) + const nodesBody = { expectedVersion: Number(released.data.version), nodes: completeManualNodes(context.marker) } + const nodes = await api(context, 'PUT', '保存尾程八个人工节点快照', nodesPath, nodesBody, + { 'Idempotency-Key': nodesKey }) + context.assert(MODULE, '人工节点永久切换MANUAL且完整返回八行', nodes.response, + nodes.data?.manualOverrideEnabled === true && nodes.data?.nodes?.length === 8 + && nodes.data.nodes.every((node) => node?.source === 'MANUAL') + && Number(nodes.data?.version) === Number(released.data.version) + 1, + `nodes=${nodes.data?.nodes?.length ?? 'missing'}; ${trace(nodes.response)}`, OPTIONS) + if (!success(nodes.response)) return + const nodesReplay = await api(context, 'PUT', '人工节点同键同载荷重放', nodesPath, nodesBody, + { 'Idempotency-Key': nodesKey }) + context.assert(MODULE, '人工节点重放版本与八行快照零漂移', nodesReplay.response, + JSON.stringify(nodesReplay.data) === JSON.stringify(nodes.data), trace(nodesReplay.response), OPTIONS) + const nodesConflict = await context.requestOverseas('PUT', nodesPath, + { ...nodesBody, nodes: nodesBody.nodes.map((node, index) => index === 0 ? { ...node, nodeAt: localIso(new Date()) } : node) }, + { 'Idempotency-Key': nodesKey }, context.overseasToken) + context.addStep(MODULE, '人工节点同键异载荷返回A0409', nodesConflict, conflict(nodesConflict), trace(nodesConflict), OPTIONS) + const nodesRead = await api(context, 'GET', '回查尾程人工节点来源', nodesPath) + context.assert(MODULE, '人工节点读取固定为MANUAL且不回退柜级节点', nodesRead.response, + nodesRead.data?.manualOverrideEnabled === true && nodesRead.data?.nodes?.length === 8 + && nodesRead.data.nodes.every((node) => node?.source === 'MANUAL'), trace(nodesRead.response), OPTIONS) + + const plans = [] + for (const original of refreshedDetails) { + const refreshed = await detail(context, original, `创建人工计划前刷新订单${id(original.id)}版本`) + const order = refreshed.data?.order + if (!success(refreshed.response) || !order) return + const body = manualPlanBody(order, order.version, context.marker) + const key = `${context.marker}-manual-plan-${id(order.id)}`.slice(0, 128) + const created = await api(context, 'POST', `为本轮尾程订单${id(order.id)}创建人工计划`, `${PLANS}/manual`, body, + { 'Idempotency-Key': key }) + context.assert(MODULE, '人工计划按订单稳定接柜仓回读当前主档并冻结入仓码、托数和操作标志', created.response, + Boolean(created.data?.planNo) && created.data?.creationMode === 'MANUAL' + && Boolean(created.data?.overseasWarehouseCode) && Boolean(created.data?.overseasWarehouseName) + && created.data?.plannedArrivalDate === body.plannedArrivalDate + && created.data?.palletType === body.palletType && created.data?.urgent === true + && Number(created.data?.palletCount) === body.palletCount + && created.data?.lines?.some((line) => id(line?.orderId) === id(order.id) && line?.inboundCode === order.inboundCode), + `orderId=${id(order.id)}, planNo=${created.data?.planNo || 'missing'}; ${trace(created.response)}`, OPTIONS) + if (!success(created.response) || !created.data?.planNo) return + const replay = await api(context, 'POST', `人工计划${created.data.planNo}幂等重放`, `${PLANS}/manual`, body, + { 'Idempotency-Key': key }) + context.assert(MODULE, '人工计划重放复用同一计划且零新增', replay.response, + replay.data?.planNo === created.data.planNo, trace(replay.response), OPTIONS) + if (plans.length === 0) { + const hashConflict = await context.requestOverseas('POST', `${PLANS}/manual`, + { ...body, remark: `${context.marker}-DIFFERENT` }, { 'Idempotency-Key': key }, context.overseasToken) + context.addStep(MODULE, '人工计划同键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS) + } + plans.push(created.data.planNo) + } + context.createdResources.phase4TailOrderControls = { + ...context.createdResources.phase4TailOrderControls, + containerNo: producer.containerNo, orderIds: refreshedDetails.map((order) => id(order.id)), + planNos: plans, representativeOrderId: id(representative.id), finalStatus: 'MANUAL_PLANS_READY', + } +} diff --git a/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs b/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs new file mode 100644 index 000000000..04935c24b --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-terminal-drayage-contract.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' + +const source = await readFile(fileURLToPath(new URL('./phase5-terminal-drayage.mjs', import.meta.url)), 'utf8') + +test('terminal drayage runs after pickup-ready and overseas unloading but before final carrier validation', () => { + const modules = normalizeModuleOrder(['phase4-carrier-nodes']) + const pickup = modules.indexOf('phase4-carrier-pickup-ready') + const pda = modules.indexOf('phase4-overseas-pda') + const drayage = modules.indexOf('phase5-terminal-drayage') + const finalCarrier = modules.indexOf('phase4-carrier-nodes') + assert.ok(pickup >= 0 && pickup < pda) + assert.ok(pda < drayage && drayage < finalCarrier) +}) + +test('module uses only public business endpoints and reuses public trucking master data', () => { + for (const path of [ + '/api/platform/v1/terminal-drayages', "'fleet'", "'pickup-appointment'", "'pickup'", "'empty-return'", '/history', + 'category=TRUCKING', + ]) assert.ok(source.includes(path), `missing ${path}`) + assert.doesNotMatch(source, /\/internal\//) + assert.doesNotMatch(source, /POST[^\n]*\/api\/platform\/v1\/suppliers/) + assert.match(source, /脚本不临时创建主数据/) +}) + +test('all four commands cover idempotent replay hash conflict and lifecycle semantics', () => { + for (const token of [ + '选择车队', '预约提柜', '确认实际提柜', '确认实际还柜', + '同键重放', '同键异载荷返回A0409', 'PICKED_UP', 'UNLOADED', 'EMPTY_RETURNED', + 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN', 'RESOLVED', + ]) assert.ok(source.includes(token), `missing ${token}`) + assert.match(source, /JSON\.stringify\(replay\.data\) === JSON\.stringify\(first\.data\)/) +}) + +test('target is strictly current-marker pickup and completed-PDA container', () => { + assert.match(source, /matrix\?\.marker !== context\.marker/) + assert.match(source, /pda\?\.marker !== context\.marker/) + assert.match(source, /pda\?\.finalStatus !== 'COMPLETED'/) + assert.match(source, /completedContainerFacts/) + assert.match(source, /item\?\.shippingReady === true/) + assert.match(source, /carrierScheduleQueryId/) + assert.match(source, /carrierLatestActualAt/) + assert.match(source, /phase4CarrierPickupReady =/) + assert.match(source, /禁止借历史货柜/) +}) diff --git a/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs b/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs new file mode 100644 index 000000000..b0ffb1391 --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-terminal-drayage.mjs @@ -0,0 +1,147 @@ +const MODULE = 'phase5-terminal-drayage' +const SCENARIO = 'phase5-terminal-drayage-lifecycle' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const BASE = '/api/platform/v1/terminal-drayages' + +function id(value) { return value == null ? '' : String(value) } +function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) } +function success(response) { return response?.http === 200 && codeOf(response) === '200' } +function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') } +function localIso(value) { return value.toISOString().slice(0, 19) } +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +async function api(context, method, name, path, body, headers = {}) { + const response = await context.request(method, path, body, context.token, headers) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} +function blocker(context, name, evidence, response = null) { + context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS) + context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true }) +} +function fixture(context) { + const matrix = context.createdResources?.phase4InboundProducerFixtures + const pda = context.createdResources?.phase4OverseasPda + if (matrix?.marker !== context.marker || pda?.marker !== context.marker + || pda?.finalStatus !== 'COMPLETED') return null + const producers = Array.isArray(matrix.containers) ? matrix.containers : [] + for (const completed of pda.completedContainerFacts || []) { + const producer = producers.find((item) => id(item?.containerBookingId) === id(completed?.containerBookingId) + && id(item?.containerNo) === id(completed?.containerNo) + && item?.shippingReady === true && id(item?.carrierScheduleQueryId) + && item?.carrierLatestActualAt) + if (producer) return { + matrix, producer, pda, completed, containerBookingId: id(producer.containerBookingId), + } + } + return null +} +async function command(context, target, action, path, body) { + const key = `${context.marker}-terminal-${action}`.slice(0, 128) + const first = await api(context, 'POST', `目的港码头拖车${action}`, `${BASE}/${target.containerBookingId}/${path}`, + body, { 'Idempotency-Key': key }) + if (!success(first.response)) return first + const replay = await api(context, 'POST', `目的港码头拖车${action}同键重放`, + `${BASE}/${target.containerBookingId}/${path}`, body, { 'Idempotency-Key': key }) + context.assert(MODULE, `${action}重放状态和版本零漂移`, replay.response, + JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + const changed = path === 'fleet' ? { ...body, reason: `${body.reason}-DIFFERENT` } + : { ...body, reason: `${body.reason}-DIFFERENT` } + const hashConflict = await context.request('POST', `${BASE}/${target.containerBookingId}/${path}`, + changed, context.token, { 'Idempotency-Key': key }) + context.addStep(MODULE, `${action}同键异载荷返回A0409`, hashConflict, + conflict(hashConflict), trace(hashConflict), OPTIONS) + return first +} + +export async function run(context) { + context.createdResources.phase5TerminalDrayage = { + marker: context.marker, containerBookingId: null, containerNo: null, finalStatus: null, + cleanup: 'Terminal drayage ends at immutable EMPTY_RETURNED and is retained as acceptance evidence', + } + const target = fixture(context) + if (!target) { + blocker(context, '取得本轮PICKUP_READY且已卸柜货柜', + `matrix=${context.createdResources?.phase4InboundProducerFixtures?.marker || 'missing'}, shippingReady=${context.createdResources?.phase4InboundProducerFixtures?.containers?.filter((item) => item?.shippingReady === true).length ?? 0}, pda=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}; 禁止借历史货柜`) + return + } + const path = `${BASE}/${encodeURIComponent(target.containerBookingId)}` + const initial = await api(context, 'GET', '读取目的港码头拖车初始聚合', path) + context.assert(MODULE, '初始聚合绑定本轮货柜且允许选择车队', initial.response, + id(initial.data?.containerBookingId) === target.containerBookingId + && id(initial.data?.containerNo) === id(target.completed.containerNo) + && Number(initial.data?.version) === 0 && initial.data?.allowedActions?.includes('SELECT_FLEET'), + `status=${initial.data?.status || 'missing'}, actions=${initial.data?.allowedActions?.join(',') || 'missing'}; ${trace(initial.response)}`, OPTIONS) + if (!success(initial.response)) return + + const suppliers = await api(context, 'GET', '查询ACTIVE码头拖车供应商', + '/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=100') + const fleet = suppliers.data?.records?.find((item) => id(item?.id) && item?.status === 'ACTIVE') + if (!fleet) { + blocker(context, '取得有效码头拖车供应商', '没有可复用的ACTIVE + TRUCKING公共供应商;脚本不临时创建主数据', suppliers.response) + return + } + const selected = await command(context, target, '选择车队', 'fleet', { + expectedVersion: Number(initial.data.version), fleetSupplierId: id(fleet.id), + fleetSupplierName: fleet.name, reason: context.marker, + }) + context.assert(MODULE, '选车队后只绑定公共供应商并开放预约', selected.response, + id(selected.data?.fleetSupplierId) === id(fleet.id) && selected.data?.fleetSupplierName === fleet.name + && selected.data?.allowedActions?.includes('SCHEDULE_PICKUP'), trace(selected.response), OPTIONS) + if (!success(selected.response)) return + + const latestActual = Date.parse(`${target.producer.carrierLatestActualAt}Z`) + const appointmentAt = localIso(new Date((Number.isNaN(latestActual) ? Date.now() : latestActual) + 60 * 60_000)) + const scheduled = await command(context, target, '预约提柜', 'pickup-appointment', { + expectedVersion: Number(selected.data.version), pickupAppointmentAt: appointmentAt, reason: context.marker, + }) + context.assert(MODULE, '预约提柜时间和版本持久化', scheduled.response, + scheduled.data?.pickupAppointmentAt === appointmentAt + && Number(scheduled.data?.version) === Number(selected.data.version) + 1, + trace(scheduled.response), OPTIONS) + if (!success(scheduled.response)) return + + const pickupAt = localIso(new Date(Date.parse(`${appointmentAt}Z`) + 60 * 60_000)) + const picked = await command(context, target, '确认实际提柜', 'pickup', { + expectedVersion: Number(scheduled.data.version), actualAt: pickupAt, reason: context.marker, + }) + context.assert(MODULE, '实际提柜同步PICKED_UP事实并在已卸柜聚合开放还柜', picked.response, + picked.data?.status === 'UNLOADED' && picked.data?.actualPickupAt === pickupAt + && picked.data?.allowedActions?.includes('RECORD_EMPTY_RETURN'), trace(picked.response), OPTIONS) + if (!success(picked.response)) return + + const unloadingAt = Date.parse(`${picked.data?.unloadingCompletedAt}Z`) + const returnBase = Math.max(Date.parse(`${pickupAt}Z`), Number.isNaN(unloadingAt) ? 0 : unloadingAt) + const returnedAt = localIso(new Date(returnBase + 60 * 60_000)) + const returned = await command(context, target, '确认实际还柜', 'empty-return', { + expectedVersion: Number(picked.data.version), actualAt: returnedAt, reason: context.marker, + }) + context.assert(MODULE, '实际还柜同步EMPTY_RETURNED并关闭全部动作', returned.response, + returned.data?.status === 'EMPTY_RETURNED' && returned.data?.actualEmptyReturnAt === returnedAt + && Array.isArray(returned.data?.allowedActions) && returned.data.allowedActions.length === 0 + && returned.data?.emptyReturnWarning?.status === 'RESOLVED', trace(returned.response), OPTIONS) + if (!success(returned.response)) return + + const list = await api(context, 'GET', '批量回读本轮码头拖车聚合', + `${BASE}?containerBookingIds=${encodeURIComponent(target.containerBookingId)}`) + context.assert(MODULE, '批量读取只命中本轮货柜终态', list.response, + Array.isArray(list.data) && list.data.length === 1 + && id(list.data[0]?.containerBookingId) === target.containerBookingId + && list.data[0]?.status === 'EMPTY_RETURNED', trace(list.response), OPTIONS) + const history = await api(context, 'GET', '读取目的港码头拖车不可变历史', `${path}/history`) + const actions = new Set((Array.isArray(history.data) ? history.data : []).map((item) => item?.action)) + context.assert(MODULE, '历史完整记录四个业务动作和连续版本', history.response, + ['SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN'].every((action) => actions.has(action)) + && history.data.every((item) => Number(item?.toVersion) === Number(item?.fromVersion) + 1), + `actions=${[...actions].join(',')}; ${trace(history.response)}`, OPTIONS) + context.createdResources.phase5TerminalDrayage = { + ...context.createdResources.phase5TerminalDrayage, + containerBookingId: target.containerBookingId, containerNo: target.completed.containerNo, + finalStatus: 'EMPTY_RETURNED', version: Number(returned.data.version), + } + context.createdResources.phase4CarrierPickupReady = { + marker: context.marker, queryId: id(target.producer.carrierScheduleQueryId), + latestActualAt: target.producer.carrierLatestActualAt, ready: true, + } +} diff --git a/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs index 472e02f9b..8b82ad51c 100644 --- a/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs +++ b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs @@ -96,8 +96,8 @@ test('Admin consumes a server-paged container summary and never derives 15 from assert.match(adminApi, /listContainerPlanProgress/) assert.match(adminApi, /container-summaries/) assert.match(adminView, /listContainerPlanProgress\(/) - assert.match(adminView, /const pendingRecords\s*=\s*result\.records\.filter/) - assert.match(adminView, /containerRows\.value\s*=\s*pendingRecords/) + assert.match(adminView, /containerRows\.value\s*=\s*result\.records/) + assert.match(adminView, /containerTotal\.value\s*=\s*result\.total/) assert.match(adminView, /containerOptions\.value\s*=\s*result\.records/) assert.match(adminView, /if \(request !== containerOptionRequest\) return/) assert.doesNotMatch(adminView, /(?:rows|result\.records)\.value?\.(?:reduce|filter|forEach)[\s\S]{0,300}containerNo/) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 07a8f930f..298e953aa 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -127,13 +127,13 @@ Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境 Assert-Contains $testTaskRegistry 'projectId `c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试登记保存项目' Assert-Contains $testTaskRegistry 'C:\Users\BY-OY\Desktop\code\chongchong-global`' '测试登记工作目录' Assert-Contains $testTaskRegistry '已重新启用测试环境总控调度' '测试登记启用状态' +Assert-Contains $testTaskRegistry '`01a05b10-a5fc-7d51-8203-c671f3a823ad`' '测试环境全流程总控' Assert-Contains $testTaskRegistry '`01a046ea-cfc4-73a1-85f0-c69c714bff22`' '测试环境海外接柜仓总控' -Assert-Contains $testTaskRegistry '【测试专项】海外接柜后端' '测试环境海外接柜后端专项' -Assert-Contains $testTaskRegistry '【测试专项】海外接柜Admin' '测试环境海外接柜 Admin 专项' -Assert-Contains $testTaskRegistry '【测试专项】海外接柜Client' '测试环境海外接柜 Client 专项' -Assert-Contains $testTaskRegistry '【测试专项】海外接柜PDA' '测试环境海外接柜 PDA 专项' +Assert-Contains $testTaskRegistry '| 海外接柜后端 |' '测试环境海外接柜后端专项' +Assert-Contains $testTaskRegistry '| 海外接柜Admin前端 |' '测试环境海外接柜 Admin 专项' +Assert-Contains $testTaskRegistry '| 海外接柜Client前端 |' '测试环境海外接柜 Client 专项' +Assert-Contains $testTaskRegistry '| 海外接柜PDA前端 |' '测试环境海外接柜 PDA 专项' Assert-NotContains $testTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '测试登记历史总控' -Assert-NotContains $testTaskRegistry '01a05b10-a5fc-7d51-8203-c671f3a823ad' '测试登记开发总控' Assert-NotContains $testTaskRegistry '01a06c0b-0515-7c60-b329-77342b54e666' '测试登记当前开发总控' Assert-NotContains $testTaskRegistry '6f874ec3-d3b2-49bd-8625-327f4d685f98' '测试登记开发 projectId' Assert-Contains $devTaskRegistry 'projectId `6f874ec3-d3b2-49bd-8625-327f4d685f98`' '开发登记保存项目' @@ -166,6 +166,13 @@ Assert-Contains $clientAgents '客户资金账户、授信与充值申请' 'Clie Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结与结算' '后端客户资金账户路由' Assert-Contains $rootAgents '顶层授权快照' '根海外顶层授权快照' Assert-Contains $backendAgents '顶层授权快照' '后端海外顶层授权快照' +Assert-Contains $rootAgents '目的港码头拖车与还空预警' '根目的港码头拖车路由' +Assert-Contains $rootAgents '与订舱级拖车预约是不同生命周期' '根两类拖车边界' +Assert-Contains $rootAgents '尾程订单控制与柜级入仓计划转换' '根尾程订单控制路由' +Assert-Contains $adminAgents '目的港码头拖车与还空预警' 'Admin 目的港码头拖车路由' +Assert-Contains $adminAgents '尾程订单控制与柜级入仓计划转换' 'Admin 尾程订单控制路由' +Assert-Contains $backendAgents '目的港码头拖车与还空预警' '后端目的港码头拖车路由' +Assert-Contains $backendAgents '尾程订单控制与柜级入仓计划转换' '后端尾程订单控制路由' $testTaskIds = @([regex]::Matches($testTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value }) $devTaskIds = @([regex]::Matches($devTaskRegistry, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value }) foreach ($sharedTaskId in @($testTaskIds | Where-Object { $devTaskIds -contains $_ } | Select-Object -Unique)) { @@ -174,6 +181,8 @@ foreach ($sharedTaskId in @($testTaskIds | Where-Object { $devTaskIds -contains } Assert-Contains $scriptsAgents 'client-quick-order-test-env-acceptance.mjs' '脚本指引 Client 快速下单入口' Assert-Contains $scriptsAgents 'admin-test-env-modules/service-center-flow.mjs' '脚本指引服务中心验收模块' +Assert-Contains $scriptsAgents 'admin-test-env-modules/phase4-tail-order-controls.mjs' '脚本指引尾程单控制模块' +Assert-Contains $scriptsAgents 'admin-test-env-modules/phase5-terminal-drayage.mjs' '脚本指引目的港码头拖车模块' Assert-Contains $scriptsAgents 'script-documentation-contract.test.mjs' '脚本说明防漏门禁' Assert-Contains $scriptsAgents '不得散落到 `src/test/scripts/`' '服务局部脚本目录边界' Assert-Path 'baiyue-firstleg/scripts/AGENTS.md' @@ -182,6 +191,7 @@ Assert-Path 'baiyue-firstleg/scripts/script-documentation-contract.test.mjs' Assert-Contains $mapperScanContract 'annotationClass = Mapper.class' 'Mapper 根包注解过滤合同' Assert-Contains $mapperScanContract '禁止分散维护 MapperScan' 'Mapper 单一扫描入口合同' $firstlegAgents = Read-RequiredText (Join-Path $backendRoot 'firstleg/AGENTS.md') +$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md') $warehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse/AGENTS.md') $coloadOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/coload-groups-v1.yaml') Assert-Contains $rootAgents 'modules/unified-orders/api/coLoadGroups.ts' '根同柜不可拆组入口' @@ -201,6 +211,9 @@ $orderAgents = Read-RequiredText (Join-Path $backendRoot 'order/AGENTS.md') Assert-Contains $orderAgents '`channelCode` 直接来自 Order/Firstleg 权威快照' 'Order 稳定渠道编码' Assert-Contains $orderAgents 'InboundCodeAssociationService.java' 'Order 入仓码业务关系入口' Assert-Contains $firstlegAgents '/loading-plans/{planId}/lifecycle' 'Firstleg 装柜生命周期入口' +Assert-Contains $lastlegAgents 'LastlegOrderCommandService.java' 'Lastleg 尾程订单命令入口' +Assert-Contains $lastlegAgents '固定八行完整快照' 'Lastleg 人工节点快照边界' +Assert-Contains $lastlegAgents '/api/platform/v1/last-leg/inbound-plans/convert-container' 'Lastleg 整柜转换正式入口' # 本轮新增的批量下单、测量计费、智能装柜、账单联查与导出重试必须从最近指引直达。 $fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md') @@ -349,6 +362,11 @@ Assert-Contains $qualificationOpenApi 'BUSINESS_LICENSE 不允许走此入口' ' Assert-Contains $clientCompanyProfile '法人身份证已保存,尚未提交审核' 'Client 法人身份证单独上传反馈' Assert-NotContains $clientCompanyProfile '当前文件服务尚未开放法人身份证单独上传或替换' 'Client 已移除法人身份证旧阻断' $truckOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/truck-appointments-v1.yaml') +$terminalDrayageOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml') +$terminalDrayageService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java') +$lastlegOrderCommandService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java') +$adminTerminalDrayages = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/container-loading/api/terminalDrayages.ts') +$adminLastMileOrders = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts') $loadingListsOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/loading-lists-v1.yaml') $pdaLoadingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/api/pda-loading.js') $cabinetEvidenceService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java') @@ -368,6 +386,20 @@ Assert-NotContains $truckOpenApi 'loadingPlanId:' '拖车 OpenAPI 装柜计划 Assert-Contains $truckOpenApi 'Each booking may have at most one non-terminal appointment' '拖车订舱级唯一性' Assert-Contains $truckOpenApi 'PDA loading completion does not change a booking-level truck appointment' '拖车与 PDA 生命周期解耦' Assert-Contains $truckOpenApi 'excluding bookings that already have a non-terminal truck appointment' '拖车创建候选过滤' +Assert-Contains $firstlegAgents '目的港码头拖车与还空预警' 'Firstleg 目的港码头拖车入口' +Assert-Contains $firstlegAgents '按卸柜完成时间加 72 小时' 'Firstleg 还空预警口径' +Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:' '码头拖车车队命令 OpenAPI' +Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:' '码头拖车还柜命令 OpenAPI' +Assert-Contains $terminalDrayageService 'EMPTY_RETURN_WARNING_HOURS = 72' '码头拖车 72 小时预警实现' +Assert-Contains $terminalDrayageService 'context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS)' '码头拖车权威截止时间实现' +Assert-Contains $adminTerminalDrayages 'allowedActions' 'Admin 码头拖车服务端动作消费' +Assert-Contains $lastlegOrderCommandService '必须提交完整8行人工时间快照' '尾程人工节点完整快照实现' +Assert-Contains $lastlegOrderCommandService '尾程订单已进入出库流程,不能新增暂扣' '尾程暂扣出库门禁实现' +Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold' 'Admin 尾程暂扣入口' +Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes' 'Admin 尾程人工节点入口' +Assert-Contains $adminAgents '入仓码只读展示并提交订单冻结值' 'Admin 尾程计划冻结入仓码边界' +Assert-Contains $lastlegAgents '接柜仓编码/名称以该 ID 当前目录快照为准' 'Lastleg 稳定接柜仓身份边界' +Assert-Contains $lastlegOrderCommandService '同一幂等键不能用于不同请求' '尾程命令请求哈希重放边界' Assert-Contains $loadingListsOpenApi '真实集装箱号可在PDA确认装柜完成前补录' '预装真实柜号晚录门禁' Assert-Contains $loadingListsOpenApi '可在 EDITING 或 LOADING 阶段首次补录真实集装箱号' '预装真实柜号首次补录阶段' Assert-Contains $adminAgents '创建柜次时自动生成唯一预装计划' 'Admin 自动预装计划流程' @@ -651,7 +683,7 @@ Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/orders/contai Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans/convert-container:' 'Lastleg 整柜计划转换契约' Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份' Assert-Contains $adminAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口' -Assert-Contains $rootAgents '柜级入仓计划进度与转换' '根柜级计划入口' +Assert-Contains $rootAgents '尾程订单控制与柜级入仓计划转换' '根柜级计划入口' Assert-Contains $backendAgents '分页前排除全部入仓码均已生成有效独立计划的柜' '后端柜级计划待处理筛选' Assert-Contains $adminAgents '返回仍有待生成计划入仓码的柜' 'Admin 柜级计划待处理筛选' Assert-Contains $lastlegAgents '分页前排除全部入仓码均已生成有效独立计划的柜' 'Lastleg 柜级计划待处理筛选' diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs index a9cb41cec..bf77a3e19 100644 --- a/scripts/java-quality-summary.test.mjs +++ b/scripts/java-quality-summary.test.mjs @@ -65,7 +65,7 @@ test('repository freezes the current topology and non-blocking aggregate summary const summary = await buildQualitySummary(repositoryRoot) const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8')) assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 }) - assert.equal(summary.openapi.documents, 39) + assert.equal(summary.openapi.documents, 40) assert.equal(summary.openapi.coveredServices.length, 8) assert.equal(summary.openapi.codeFirstServices.length, 7) assert.equal(baseline.mode, 'report-only') From a0ad3382af76d9c0c78bc3d0a1552adb65bd8ac2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 14:47:35 +0800 Subject: [PATCH 24/26] fix(warehouse-overseas): enforce pallet location invariant --- warehouse-overseas-service/AGENTS.md | 2 + .../dto/WarehousePalletResolveResponse.java | 15 ++- .../WarehousePalletTransitionMapper.java | 15 ++- .../mapper/WarehouseResourceLockMapper.java | 2 +- .../WarehousePalletTransitionService.java | 105 ++++++++++++++++-- .../service/WarehouseResourceService.java | 29 +++-- ...usePalletTransitionMapperContractTest.java | 15 ++- .../WarehousePalletTransitionServiceTest.java | 78 ++++++++++++- .../service/WarehouseResourceServiceTest.java | 40 +++++++ 9 files changed, 276 insertions(+), 25 deletions(-) diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index 2ccd82a07..0662bfcfc 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -25,6 +25,8 @@ - 平台和商户接口必须分别执行访问域与租户范围校验;客户端传入的 `tenantId` 不能作为唯一授权依据。 - 平台维护的海外库位与实物托盘是公共资源,固定使用 `customer_id=0`;平台资源 API 不接收目标客户参数。资源锁记录仍使用真实业务客户 ID 隔离,但其托盘、库位查找和状态更新必须落到公共资源作用域。 - 资源锁定以业务幂等键和 reservation 标识防重;释放/报废操作必须验证当前状态。 +- 可预留托盘必须满足 `IDLE`,且其绑定库位为 `VIRTUAL + ACTIVE` 或真实库位 `IDLE`;internal resolve 通过 `reservable`、`reservationBlockReason`、`locationType`、`locationStatus` 明示该事实,不得仅凭托盘状态宣称可预留。 +- PDA 托盘状态流转必须在同一事务内锁定托盘及源/目标库位:真实库位与托盘同步 `IDLE/OCCUPIED`,移库原子释放源库位并占用目标库位;虚拟库位始终保持 `ACTIVE`。释放后的可复用托盘保留已回到 `IDLE` 的真实库位关联。 - 内部资源接口必须校验服务令牌;普通用户不得直接调用。 - 本服务不得注册仓库主档创建、编辑、状态或商户仓库查询 Controller;所有资源写入先以相同 `warehouseId` 校验国内只读目录。 diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java index 9cd58aea6..97cac56b1 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletResolveResponse.java @@ -8,11 +8,22 @@ public record WarehousePalletResolveResponse( String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, - int version + int version, + String locationType, + String locationStatus, + boolean reservable, + String reservationBlockReason ) { public WarehousePalletResolveResponse(long palletId, String palletCode, long warehouseId, long locationId, String status, String fbaWarehouseCode, int version) { this(palletId, palletCode, warehouseId, locationId, status, fbaWarehouseCode, - java.math.BigDecimal.ZERO, version); + java.math.BigDecimal.ZERO, version, null, null, false, "LOCATION_STATE_UNKNOWN"); + } + + public WarehousePalletResolveResponse(long palletId, String palletCode, long warehouseId, long locationId, + String status, String fbaWarehouseCode, + java.math.BigDecimal unitPrice, int version) { + this(palletId, palletCode, warehouseId, locationId, status, fbaWarehouseCode, + unitPrice, version, null, null, false, "LOCATION_STATE_UNKNOWN"); } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java index 08a059dd7..3840ea4c2 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java @@ -4,6 +4,7 @@ import org.apache.ibatis.annotations.Mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.entity.WarehousePalletOperationEntity; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @@ -22,9 +23,17 @@ public interface WarehousePalletTransitionMapper extends BaseMapper'INACTIVE' AND deleted=0") - long countActiveLocation(@Param("warehouseId") long warehouseId, @Param("locationId") long locationId); + @Select("SELECT * FROM wh_location WHERE id=#{locationId} AND customer_id=0 " + + "AND warehouse_id=#{warehouseId} AND deleted=0 LIMIT 1 FOR UPDATE") + WarehouseLocationEntity lockPublicLocation(@Param("warehouseId") long warehouseId, + @Param("locationId") long locationId); + + @Update("UPDATE wh_location SET status=#{targetStatus},version=version+1,updated_at=CURRENT_TIMESTAMP(3) " + + "WHERE id=#{locationId} AND customer_id=0 AND warehouse_id=#{warehouseId} " + + "AND status=#{expectedStatus} AND deleted=0") + int transitionLocation(@Param("warehouseId") long warehouseId, @Param("locationId") long locationId, + @Param("expectedStatus") String expectedStatus, + @Param("targetStatus") String targetStatus); @Update("UPDATE wh_pallet SET status=#{targetStatus},location_id=#{targetLocationId},version=version+1," + "updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{palletId} AND customer_id=0 " + diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java index 18718c9b5..491353fea 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java @@ -43,7 +43,7 @@ public interface WarehouseResourceLockMapper extends BaseMapper pallets(long customerId, long warehouseId, PalletRequests.QueryRequest query) { requireWarehouse(customerId, warehouseId); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapperContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapperContractTest.java index 06f4c4d57..257490922 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapperContractTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapperContractTest.java @@ -15,7 +15,7 @@ class WarehousePalletTransitionMapperContractTest { @Test void annotationSqlUsesNativeComparisonOperatorsInsteadOfXmlEntities() throws Exception { Method locationQuery = WarehousePalletTransitionMapper.class.getMethod( - "countActiveLocation", long.class, long.class); + "lockPublicLocation", long.class, long.class); String selectSql = String.join(" ", locationQuery.getAnnotation(Select.class).value()); Method transition = Arrays.stream(WarehousePalletTransitionMapper.class.getMethods()) @@ -24,9 +24,20 @@ class WarehousePalletTransitionMapperContractTest { .orElseThrow(); String updateSql = String.join(" ", transition.getAnnotation(Update.class).value()); - assertTrue(selectSql.contains("status<>'INACTIVE'")); + assertTrue(selectSql.contains("FOR UPDATE")); assertTrue(updateSql.contains("status<>'SCRAPPED'")); assertFalse(selectSql.contains("<") || selectSql.contains(">")); assertFalse(updateSql.contains("<") || updateSql.contains(">")); } + + @Test + void normalResourceReleaseKeepsPalletBoundToTheNowIdleLocation() throws Exception { + Method release = WarehouseResourceLockMapper.class.getMethod("releasePallet", + long.class, long.class, long.class, long.class); + String sql = String.join(" ", release.getAnnotation(Update.class).value()); + + assertTrue(sql.contains("SET status = 'IDLE'")); + assertFalse(sql.contains("location_id = NULL")); + assertTrue(sql.contains("location_id = #{locationId}")); + } } diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java index 5f998e23c..f0b5bb896 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java @@ -2,6 +2,7 @@ package com.baiyue.warehouse.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.PalletTransitionRequests; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.entity.WarehousePalletEntity; import com.baiyue.warehouse.entity.WarehousePalletOperationEntity; import com.baiyue.warehouse.mapper.WarehousePalletTransitionMapper; @@ -30,7 +31,9 @@ class WarehousePalletTransitionServiceTest { @Test void appliesVersionedPublicPalletTransitionAndPersistsCompensationReceipt() { WarehousePalletEntity pallet = pallet("P-1", "IDLE", 4); when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet); - when(mapper.countActiveLocation(88L, 20L)).thenReturn(1L); + when(mapper.lockPublicLocation(88L, 11L)).thenReturn(location(11L, "STORAGE", "IDLE")); + when(mapper.lockPublicLocation(88L, 20L)).thenReturn(location(20L, "STORAGE", "IDLE")); + when(mapper.transitionLocation(88L, 20L, "IDLE", "OCCUPIED")).thenReturn(1); when(mapper.transition(88L, 10L, "OCCUPIED", 20L, 4)).thenReturn(1); var response = service.apply(10L, new PalletTransitionRequests.Apply( @@ -56,9 +59,26 @@ class WarehousePalletTransitionServiceTest { verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt()); } + @Test void concurrentExactReplayAfterPalletLockDoesNotMutatePhysicalStateTwice() { + WarehousePalletOperationEntity existing = operation(); + when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "OCCUPIED", 5, 20L)); + when(mapper.lockOperation("operation-1")).thenReturn(existing); + + var response = service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 4, "OCCUPIED", 20L, "operation-1", "assemble")); + + assertTrue(response.replayed()); + verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt()); + verify(mapper, never()).transitionLocation(anyLong(), anyLong(), anyString(), anyString()); + } + @Test void compensationRequiresExactAppliedVersionAndRestoresBeforeState() { WarehousePalletOperationEntity existing = operation(); when(mapper.lockOperation("operation-1")).thenReturn(existing); + when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "OCCUPIED", 5, 20L)); + when(mapper.lockPublicLocation(88L, 11L)).thenReturn(location(11L, "STORAGE", "IDLE")); + when(mapper.lockPublicLocation(88L, 20L)).thenReturn(location(20L, "STORAGE", "OCCUPIED")); + when(mapper.transitionLocation(88L, 20L, "OCCUPIED", "IDLE")).thenReturn(1); when(mapper.transition(88L, 10L, "IDLE", 11L, 5)).thenReturn(1); var response = service.compensate(10L, "operation-1", new PalletTransitionRequests.Compensate(88L)); @@ -67,6 +87,53 @@ class WarehousePalletTransitionServiceTest { verify(mapper).updateById(existing); } + @Test void unbindAtomicallyReturnsRealLocationToIdleWhileKeepingAssociation() { + when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "OCCUPIED", 4)); + when(mapper.lockPublicLocation(88L, 11L)).thenReturn(location(11L, "STORAGE", "OCCUPIED")); + when(mapper.transitionLocation(88L, 11L, "OCCUPIED", "IDLE")).thenReturn(1); + when(mapper.transition(88L, 10L, "IDLE", 11L, 4)).thenReturn(1); + + service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 4, "IDLE", 11L, "unbind-1", "unbind")); + + verify(mapper).transitionLocation(88L, 11L, "OCCUPIED", "IDLE"); + verify(mapper).transition(88L, 10L, "IDLE", 11L, 4); + } + + @Test void virtualLocationRemainsActiveAcrossPalletStatusChanges() { + when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "OCCUPIED", 4)); + when(mapper.lockPublicLocation(88L, 11L)).thenReturn(location(11L, "VIRTUAL", "ACTIVE")); + when(mapper.transition(88L, 10L, "IDLE", 11L, 4)).thenReturn(1); + + service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 4, "IDLE", 11L, "virtual-unbind", null)); + + verify(mapper, never()).transitionLocation(anyLong(), anyLong(), anyString(), anyString()); + } + + @Test void inconsistentIdlePalletAndOccupiedLocationIsRejectedBeforeMutation() { + when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "IDLE", 4)); + when(mapper.lockPublicLocation(88L, 11L)).thenReturn(location(11L, "STORAGE", "OCCUPIED")); + + assertThrows(BizException.class, () -> service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 4, "OCCUPIED", 11L, "broken-state", null))); + + verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt()); + verify(mapper, never()).transitionLocation(anyLong(), anyLong(), anyString(), anyString()); + } + + @Test void locationConflictStopsPalletMutationSoTransactionCanRollBackCleanly() { + when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "OCCUPIED", 4)); + when(mapper.lockPublicLocation(88L, 11L)).thenReturn(location(11L, "STORAGE", "OCCUPIED")); + when(mapper.transitionLocation(88L, 11L, "OCCUPIED", "IDLE")).thenReturn(0); + + assertThrows(BizException.class, () -> service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 4, "IDLE", 11L, "location-race", null))); + + verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt()); + verify(mapper, never()).insert(any(WarehousePalletOperationEntity.class)); + } + @Test void staleVersionIsRejectedWithoutPhysicalMutation() { when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "IDLE", 5)); assertThrows(BizException.class, () -> service.apply(10L, new PalletTransitionRequests.Apply( @@ -75,10 +142,17 @@ class WarehousePalletTransitionServiceTest { } private static WarehousePalletEntity pallet(String code, String status, int version) { + return pallet(code, status, version, 11L); + } + private static WarehousePalletEntity pallet(String code, String status, int version, long locationId) { WarehousePalletEntity value = new WarehousePalletEntity(); value.setId(10L); value.setCustomerId(0L); - value.setWarehouseId(88L); value.setLocationId(11L); value.setPalletCode(code); + value.setWarehouseId(88L); value.setLocationId(locationId); value.setPalletCode(code); value.setStatus(status); value.setVersion(version); return value; } + private static WarehouseLocationEntity location(long id, String type, String status) { + WarehouseLocationEntity value = new WarehouseLocationEntity(); value.setId(id); value.setCustomerId(0L); + value.setWarehouseId(88L); value.setLocationType(type); value.setStatus(status); return value; + } private static WarehousePalletOperationEntity operation() { WarehousePalletOperationEntity value = new WarehousePalletOperationEntity(); value.setId(1L); value.setOperationKey("operation-1"); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java index c56bcbd9e..1d6615537 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -18,6 +18,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -68,6 +69,34 @@ class WarehouseResourceServiceTest { assertThat(result.locationCode()).isEqualTo("A-01"); } + @Test + void resolvePalletExplicitlyReportsReservableRealLocation() { + WarehousePalletEntity pallet = pallet(40001L, "IDLE", 30001L); + WarehouseLocationEntity location = location(30001L, "STORAGE", "IDLE"); + when(repository.findPublicPalletsByCode("PLT-01")).thenReturn(List.of(pallet)); + when(repository.findLocation(0L, WAREHOUSE, 30001L)).thenReturn(location); + + var result = service.resolvePublicPallet(" PLT-01 "); + + assertThat(result.reservable()).isTrue(); + assertThat(result.locationType()).isEqualTo("STORAGE"); + assertThat(result.locationStatus()).isEqualTo("IDLE"); + assertThat(result.reservationBlockReason()).isNull(); + } + + @Test + void resolvePalletDoesNotAdvertiseBrokenIdlePalletAsReservable() { + WarehousePalletEntity pallet = pallet(40001L, "IDLE", 30001L); + WarehouseLocationEntity location = location(30001L, "STORAGE", "OCCUPIED"); + when(repository.findPublicPalletsByCode("PLT-01")).thenReturn(List.of(pallet)); + when(repository.findLocation(0L, WAREHOUSE, 30001L)).thenReturn(location); + + var result = service.resolvePublicPallet("PLT-01"); + + assertThat(result.reservable()).isFalse(); + assertThat(result.reservationBlockReason()).isEqualTo("LOCATION_NOT_IDLE"); + } + @Test void createsLocationWithSnowflakeAssignedByMapper() { doAnswer(invocation -> { @@ -333,4 +362,15 @@ class WarehouseResourceServiceTest { private static SharedWarehouseReference warehouse() { return new SharedWarehouseReference(WAREHOUSE, "US-LAX-01", "Los Angeles", "ACTIVE"); } + + private static WarehousePalletEntity pallet(long id, String status, long locationId) { + WarehousePalletEntity value = new WarehousePalletEntity(); value.setId(id); value.setCustomerId(0L); + value.setWarehouseId(WAREHOUSE); value.setLocationId(locationId); value.setPalletCode("PLT-01"); + value.setStatus(status); value.setVersion(3); value.setUnitPrice(BigDecimal.ONE); return value; + } + + private static WarehouseLocationEntity location(long id, String type, String status) { + WarehouseLocationEntity value = new WarehouseLocationEntity(); value.setId(id); value.setCustomerId(0L); + value.setWarehouseId(WAREHOUSE); value.setLocationType(type); value.setStatus(status); return value; + } } From 6908969307169e4863007ce38da0a0ab79971cbf Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 14:52:08 +0800 Subject: [PATCH 25/26] fix(lastleg): reject unavailable receiving pallets --- .../gateway/HttpWarehouseResourceGateway.java | 42 +++++++++-- .../HttpWarehouseResourceGatewayTest.java | 71 ++++++++++++++++++- 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java index db2b47740..a5683449d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -59,10 +59,20 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { "businessId", businessId, "reservationKey", reservationKey)) .retrieve() .onStatus(HttpStatusCode::isError, (request, result) -> { - throw new IllegalStateException("Warehouse rejected pallet reservation: HTTP " + result.getStatusCode()); + throw warehouseReservationFailure(result.getStatusCode()); }) .body(ReserveEnvelope.class); - if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty lock response"); + if (response == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Warehouse resource reservation returned no response"); + } + if (response.data() == null) { + HttpStatus status = "A0409".equals(response.code()) ? HttpStatus.CONFLICT + : "A0404".equals(response.code()) ? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE; + throw new ResponseStatusException(status, + response.message() == null || response.message().isBlank() + ? "Warehouse resource reservation was rejected" : response.message()); + } return response.data().lockId(); } @@ -120,6 +130,7 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Warehouse pallet service returned no usable response"); } + requireReservable(response.data()); return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().warehouseId(), response.data().locationId(), response.data().status(), response.data().fbaWarehouseCode(), response.data().unitPrice(), response.data().version()); @@ -133,6 +144,7 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { .body(WarehousePalletResponse.class); if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Warehouse pallet service returned no usable response"); + requireReservable(response.data()); return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().warehouseId(), response.data().locationId(), response.data().status(), response.data().fbaWarehouseCode(), response.data().unitPrice(), response.data().version()); @@ -144,6 +156,26 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { status.value() == 404 ? "Warehouse pallet was not found" : "Warehouse pallet lookup failed"); } + private ResponseStatusException warehouseReservationFailure(HttpStatusCode status) { + HttpStatus mapped = status.value() == 409 ? HttpStatus.CONFLICT + : status.value() == 404 ? HttpStatus.NOT_FOUND + : status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE; + return new ResponseStatusException(mapped, "Warehouse resource reservation was rejected"); + } + + private void requireReservable(WarehousePalletData pallet) { + if (pallet.reservable()) return; + String reason = switch (pallet.reservationBlockReason() == null + ? "RESOURCE_STATE_UNKNOWN" : pallet.reservationBlockReason()) { + case "PALLET_NOT_IDLE" -> "Pallet is not idle"; + case "LOCATION_NOT_FOUND" -> "Pallet location was not found"; + case "VIRTUAL_LOCATION_NOT_ACTIVE" -> "Pallet virtual location is not active"; + case "LOCATION_NOT_IDLE" -> "Pallet location is not idle"; + default -> "Pallet resource availability could not be verified"; + }; + throw new ResponseStatusException(HttpStatus.CONFLICT, reason); + } + @Override public WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) { WarehouseLocationResponse response = client.get() @@ -200,8 +232,10 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { private record WarehousePalletResponse(String code, String message, WarehousePalletData data) {} private record WarehousePalletData(@JsonAlias("palletId") long id, String palletCode, long warehouseId, long locationId, - String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) {} - private record ReserveEnvelope(ReserveData data) {} + String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version, + String locationType, String locationStatus, boolean reservable, + String reservationBlockReason) {} + private record ReserveEnvelope(String code, String message, ReserveData data) {} private record ReserveData(long lockId) {} private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {} private record WarehouseLocationResponse(String code, String message, WarehouseLocationReference data) {} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java index 8d6acc50c..be03e6fa4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java @@ -11,6 +11,7 @@ import org.springframework.web.server.ResponseStatusException; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; class HttpWarehouseResourceGatewayTest { @@ -32,7 +33,9 @@ class HttpWarehouseResourceGatewayTest { .andRespond(withSuccess(""" {"code":"200","data":{"palletId":2092040891261427713,"palletCode":"PAL-1", "warehouseId":2092040869757231105,"locationId":2092040891076878337, - "status":"IDLE","fbaWarehouseCode":"ABE3","version":3}} + "status":"IDLE","fbaWarehouseCode":"ABE3","version":3, + "locationType":"STORAGE","locationStatus":"IDLE","reservable":true, + "reservationBlockReason":null}} """, MediaType.APPLICATION_JSON)); WarehouseResourceGateway.WarehousePalletReference result = gateway.resolvePallet("PAL-1"); @@ -43,6 +46,40 @@ class HttpWarehouseResourceGatewayTest { server.verify(); } + @Test + void resolveRejectsPalletWhoseLocationIsNotReservable() { + server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-BLOCKED")) + .andRespond(withSuccess(""" + {"code":"200","data":{"palletId":2092040891261427713,"palletCode":"PAL-BLOCKED", + "warehouseId":2092040869757231105,"locationId":2092040891076878337, + "status":"IDLE","fbaWarehouseCode":"ABE3","version":3, + "locationType":"STORAGE","locationStatus":"OCCUPIED","reservable":false, + "reservationBlockReason":"LOCATION_NOT_IDLE"}} + """, MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> gateway.resolvePallet("PAL-BLOCKED")) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("409") + .hasMessageContaining("Pallet location is not idle"); + server.verify(); + } + + @Test + void resolveFailsClosedWhenWarehouseOmitsReservationFacts() { + server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-UNKNOWN")) + .andRespond(withSuccess(""" + {"code":"200","data":{"palletId":2092040891261427713,"palletCode":"PAL-UNKNOWN", + "warehouseId":2092040869757231105,"locationId":2092040891076878337, + "status":"IDLE","fbaWarehouseCode":"ABE3","version":3}} + """, MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> gateway.resolvePallet("PAL-UNKNOWN")) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("409") + .hasMessageContaining("availability could not be verified"); + server.verify(); + } + @Test void ensureDropshipStillMapsWarehouseEntityId() { server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/ensure-dropship")) @@ -72,4 +109,36 @@ class HttpWarehouseResourceGatewayTest { server.verify(); } + @Test + void reserveMapsWarehouseLocationConflictToStableConflict() { + server.expect(requestTo("http://warehouse/internal/v1/warehouse-resource-locks")) + .andRespond(withStatus(org.springframework.http.HttpStatus.CONFLICT) + .contentType(MediaType.APPLICATION_JSON) + .body(""" + {"code":"A0409","message":"Location is not idle","data":null} + """)); + + assertThatThrownBy(() -> gateway.reserve("1", 2L, 3L, 4L, + "LASTLEG_INBOUND", "plan:5", "reservation-1")) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("409") + .hasMessageContaining("Warehouse resource reservation was rejected"); + server.verify(); + } + + @Test + void reserveMapsBusinessConflictEnvelopeToStableConflict() { + server.expect(requestTo("http://warehouse/internal/v1/warehouse-resource-locks")) + .andRespond(withSuccess(""" + {"code":"A0409","message":"Location is not idle","data":null} + """, MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> gateway.reserve("1", 2L, 3L, 4L, + "LASTLEG_INBOUND", "plan:5", "reservation-1")) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("409") + .hasMessageContaining("Location is not idle"); + server.verify(); + } + } From 40fe0ba0e1c42a4f553c966e8062c72636fae498 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 16:16:20 +0800 Subject: [PATCH 26/26] fix(lastleg): keep inventory warehouse snapshots canonical --- lastleg-service/AGENTS.md | 1 + .../service/InboundStockReceiptService.java | 5 +- .../service/InboundPlanService.java | 4 +- .../mapper/InventoryCommandMapper.java | 15 ++++ .../service/InventoryCommandService.java | 76 +++++++++++++++++-- .../mapper/InventoryBalanceMapper.java | 4 +- .../controller/InventoryQueryController.java | 6 +- .../query/mapper/InventoryQueryMapper.java | 6 +- .../lastleg/service/LastlegOrderService.java | 4 + .../service/LoadingCompletedConsumer.java | 5 +- .../InboundStockReceiptServiceTest.java | 4 +- .../service/InboundPlanServiceTest.java | 9 ++- .../InventorySchemaContractTest.java | 18 +++++ .../command/InventoryCommandServiceTest.java | 36 +++++++++ .../service/LastlegOrderServiceTest.java | 4 + .../service/LoadingCompletedConsumerTest.java | 6 ++ 16 files changed, 182 insertions(+), 21 deletions(-) diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 175f3f47b..73b848de5 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -99,6 +99,7 @@ - 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。 - 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。 - 常规入仓库存余额以客户、海外仓、目的仓和入仓单为唯一业务维度并汇总箱数;SKU 继续保存在箱唛、实收和流水快照中,余额 SKU 仅作单 SKU 展示或以 `MIXED` 标识多 SKU,不参与当前唯一维度。 +- `FIRST_LEG` 在途、计划转换与 PDA 入账统一使用尾程订单冻结的实际接柜海外仓 ID/编码/名称,禁止使用国内来源仓或 FBA 目的仓冒充;接柜仓变更必须在同一事务内修订尚未形成实物仓位的在途余额并写零差额审计流水。余额分页、汇总和 CSV 默认排除所有数量桶均为零的维度,跨仓入账清空的无仓位来源余额必须原子删除。 - 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 - 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index c2a92f054..e511e9880 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -228,6 +228,8 @@ public class InboundStockReceiptService { "Actual overseas warehouse is required for stock-in"); String overseasWarehouseName = required(order.getOverseasWarehouseName(), "Actual overseas warehouse name is required for stock-in"); + String overseasWarehouseCode = required(order.getOverseasWarehouseCode(), + "Actual overseas warehouse code is required for stock-in"); if (plan.getOverseasWarehouseId() == null || !overseasWarehouseId.equals(String.valueOf(plan.getOverseasWarehouseId()))) { throw conflict("Inbound plan and tail-leg order overseas warehouses must match for stock-in"); @@ -236,7 +238,8 @@ public class InboundStockReceiptService { throw conflict("Actual overseas warehouse must not use the domestic source warehouse"); } InventoryCommandRequests.StockIn stockIn = new InventoryCommandRequests.StockIn(null, customerName, - overseasWarehouseId, null, overseasWarehouseName, line.getDestinationWarehouseId(), destinationCode, + overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, + line.getDestinationWarehouseId(), destinationCode, line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(), sku.skuCode(), sku.productNameZh(), sku.productNameEn(), pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 14ccb5533..8f8c9864e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -259,8 +259,8 @@ public class InboundPlanService { if (updated != 1) { throw new IllegalStateException("Failed to update order " + order.orderId()); } - inventoryBalanceMapper.bindInboundPlan(customerId, order.orderId(), order.inboundCode(), - plan.getPlanNo(), now); + inventoryBalanceMapper.bindInboundPlan(customerId, receivingWarehouseId, order.orderId(), + order.inboundCode(), plan.getPlanNo(), now); orderMapper.insertHistory(customerId, order.orderId(), order.currentStatus(), "Inbound order converted to inbound plan " + plan.getPlanNo(), now); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java index 343491524..84b6975c4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.inventory.command.mapper; import com.baiyue.lastleg.inventory.entity.InventoryBalance; import com.baiyue.lastleg.inventory.entity.InventoryPosition; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Delete; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @@ -32,6 +33,20 @@ public interface InventoryCommandMapper { @Param("inboundOrderId") Long inboundOrderId, @Param("inboundCode") String inboundCode); + @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " + + "AND inbound_order_id=#{inboundOrderId} ORDER BY id FOR UPDATE") + List lockInboundBalances(@Param("customerId") String customerId, + @Param("inboundOrderId") Long inboundOrderId); + + @Select("SELECT COUNT(*) FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId}") + long countPositions(@Param("customerId") String customerId, @Param("balanceId") Long balanceId); + + @Delete("DELETE FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} " + + "AND available_packages=0 AND hold_packages=0 AND allocated_packages=0 AND picked_packages=0 " + + "AND in_transit_packages=0 AND receiving_packages=0 AND procurement_packages=0 " + + "AND NOT EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id)") + int deleteEmptyBalance(@Param("customerId") String customerId, @Param("balanceId") Long balanceId); + @Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " + "WHERE p.customer_id=#{customerId} AND b.inbound_order_id=#{inboundOrderId} " + "AND p.hold_packages>0 ORDER BY p.id FOR UPDATE") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index 860677428..f36082193 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -28,6 +28,7 @@ import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; import java.util.HexFormat; import java.util.List; +import java.util.Objects; @Service public class InventoryCommandService { @@ -57,6 +58,44 @@ public class InventoryCommandService { }); } + @Transactional + public void rebindReceivingWarehouse(String customerId, Long inboundOrderId, long warehouseId, + String warehouseCode, String warehouseName, + String operatorId, String idempotencyKey) { + String targetId = Long.toString(warehouseId); + String targetCode = required(warehouseCode, "Receiving warehouse code is required"); + String targetName = required(warehouseName, "Receiving warehouse name is required"); + Long auditOperationId = IdWorker.getId(); + for (InventoryBalance balance : commands.lockInboundBalances(customerId, inboundOrderId)) { + if (value(balance.getAvailablePackages()) != 0 || value(balance.getHoldPackages()) != 0 + || value(balance.getAllocatedPackages()) != 0 || value(balance.getPickedPackages()) != 0 + || value(balance.getReceivingPackages()) != 0 || value(balance.getProcurementPackages()) != 0 + || commands.countPositions(customerId, balance.getId()) != 0) { + throw conflict("Receiving warehouse cannot change after physical inventory exists"); + } + InventoryBalance target = balances.lockDimension(customerId, targetId, + balance.getDestinationWarehouseCode(), balance.getInboundOrderId(), balance.getInboundCode()); + if (target != null && !target.getId().equals(balance.getId())) { + throw conflict("Receiving warehouse change would create a duplicate inventory dimension"); + } + String sourceId = balance.getOverseasWarehouseId(); + if (Objects.equals(sourceId, targetId) + && Objects.equals(balance.getOverseasWarehouseCode(), targetCode) + && Objects.equals(balance.getOverseasWarehouseName(), targetName)) continue; + long packages = value(balance.getInTransitPackages()); + balance.setOverseasWarehouseId(targetId); + balance.setOverseasWarehouseCode(targetCode); + balance.setOverseasWarehouseName(targetName); + touch(balance); + balances.updateById(balance); + ledger(customerId, operatorId, auditOperationId, balance, null, balance.getSkuCode(), + "RECEIVING_WAREHOUSE_CHANGED", "IN_TRANSIT", "IN_TRANSIT", + packages, 0, packages, null, balance.getInboundOrderNo(), null, null, + idempotencyKey + ":BALANCE:" + balance.getId(), + "In-transit receiving warehouse changed from " + sourceId + " to " + targetId); + } + } + @Transactional public InventoryCommandResponse stockIn(String customerId, String operatorId, InventoryCommandRequests.StockIn request) { @@ -126,6 +165,9 @@ public class InventoryCommandService { transferred += moved; pending -= moved; if (targetDimension) continue; + if (commands.countPositions(customerId, source.getId()) != 0) { + throw conflict("In-transit source balance unexpectedly has physical positions"); + } touch(source); balances.updateById(source); ledger(customerId, operatorId, operationId, source, null, sku(request.skuCode()), @@ -133,6 +175,9 @@ public class InventoryCommandService { sourceBefore - moved, request.businessOrderNo(), request.inboundOrderNo(), null, request.sourceEventId(), request.idempotencyKey() + ":TRANSIT:" + source.getId(), "In-transit inventory transferred to overseas warehouse " + target.getOverseasWarehouseId()); + if (empty(source) && commands.deleteEmptyBalance(customerId, source.getId()) != 1) { + throw conflict("Empty in-transit source balance changed concurrently"); + } } return transferred; } @@ -287,6 +332,9 @@ public class InventoryCommandService { String inboundCode, String requestedSkuCode, String productNameZh, String productNameEn) { String frozenCustomerName = required(customerName, "Customer name snapshot is required"); + String frozenWarehouseId = required(overseasWarehouseId, "Overseas warehouse ID snapshot is required"); + String frozenWarehouseCode = required(overseasWarehouseCode, "Overseas warehouse code snapshot is required"); + String frozenWarehouseName = required(overseasWarehouseName, "Overseas warehouse name snapshot is required"); String skuCode = sku(requestedSkuCode); String destinationId = trim(destinationWarehouseId); String destinationCode = trim(destinationWarehouseCode); @@ -294,10 +342,11 @@ public class InventoryCommandService { throw badRequest("Destination warehouse ID or code is required"); } if (destinationCode == null) destinationCode = ""; - InventoryBalance balance = balances.lockDimension(customerId, overseasWarehouseId, + InventoryBalance balance = balances.lockDimension(customerId, frozenWarehouseId, destinationCode, inboundOrderId, inboundCode); if (balance != null) { requireCustomerSnapshot(balance, frozenCustomerName); + requireWarehouseSnapshot(balance, frozenWarehouseId, frozenWarehouseCode, frozenWarehouseName); mergeSkuSnapshot(balance, skuCode); return balance; } @@ -305,9 +354,9 @@ public class InventoryCommandService { balance.setId(IdWorker.getId()); balance.setCustomerId(customerId); balance.setCustomerName(frozenCustomerName); - balance.setOverseasWarehouseId(overseasWarehouseId.trim()); - balance.setOverseasWarehouseCode(trim(overseasWarehouseCode)); - balance.setOverseasWarehouseName(trim(overseasWarehouseName)); + balance.setOverseasWarehouseId(frozenWarehouseId); + balance.setOverseasWarehouseCode(frozenWarehouseCode); + balance.setOverseasWarehouseName(frozenWarehouseName); balance.setDestinationWarehouseId(destinationId); balance.setDestinationWarehouseCode(destinationCode); balance.setDestinationWarehouseName(trim(destinationWarehouseName)); @@ -325,10 +374,11 @@ public class InventoryCommandService { balances.insert(balance); return balance; } catch (DuplicateKeyException exception) { - InventoryBalance concurrent = balances.lockDimension(customerId, overseasWarehouseId, + InventoryBalance concurrent = balances.lockDimension(customerId, frozenWarehouseId, destinationCode, inboundOrderId, inboundCode); if (concurrent != null) { requireCustomerSnapshot(concurrent, frozenCustomerName); + requireWarehouseSnapshot(concurrent, frozenWarehouseId, frozenWarehouseCode, frozenWarehouseName); mergeSkuSnapshot(concurrent, skuCode); return concurrent; } @@ -505,6 +555,13 @@ public class InventoryCommandService { balance.setProcurementPackages(0L); } + private static boolean empty(InventoryBalance balance) { + return value(balance.getAvailablePackages()) == 0 && value(balance.getHoldPackages()) == 0 + && value(balance.getAllocatedPackages()) == 0 && value(balance.getPickedPackages()) == 0 + && value(balance.getInTransitPackages()) == 0 && value(balance.getReceivingPackages()) == 0 + && value(balance.getProcurementPackages()) == 0; + } + private static long positionPackages(InventoryPosition position) { return value(position.getAvailablePackages()) + value(position.getHoldPackages()) + value(position.getAllocatedPackages()) + value(position.getPickedPackages()) @@ -583,6 +640,15 @@ public class InventoryCommandService { } } + private static void requireWarehouseSnapshot(InventoryBalance balance, String requestedWarehouseId, + String requestedWarehouseCode, String requestedWarehouseName) { + if (!requestedWarehouseId.equals(trim(balance.getOverseasWarehouseId())) + || !requestedWarehouseCode.equals(trim(balance.getOverseasWarehouseCode())) + || !requestedWarehouseName.equals(trim(balance.getOverseasWarehouseName()))) { + throw conflict("Inventory balance overseas warehouse snapshot does not match"); + } + } + private static String required(String value, String message) { String normalized = trim(value); if (normalized == null) throw badRequest(message); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java index b2cb58169..da0ee47ea 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java @@ -35,8 +35,10 @@ public interface InventoryBalanceMapper extends BaseMapper { @Param("inboundCode") String inboundCode); @Update("UPDATE ll_inventory_balance SET inbound_order_no=#{planNo},updated_at=#{updatedAt} " + - "WHERE customer_id=#{customerId} AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode}") + "WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{overseasWarehouseId} " + + "AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode}") int bindInboundPlan(@Param("customerId") String customerId, + @Param("overseasWarehouseId") String overseasWarehouseId, @Param("inboundOrderId") Long inboundOrderId, @Param("inboundCode") String inboundCode, @Param("planNo") String planNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java index 5133e9791..945f00ec2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -38,7 +38,7 @@ public class InventoryQueryController { @GetMapping(BASE_PATH + "/summary") @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')") - @Operation(summary = "Get inbound-order package inventory totals", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) + @Operation(summary = "Get inbound-order package inventory totals", description = "Zero-balance dimensions are excluded. Required authority: " + OverseasPermissions.INVENTORY_LIST) public ApiResponse summary( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -48,7 +48,7 @@ public class InventoryQueryController { @GetMapping(BASE_PATH + "/balances") @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')") - @Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) + @Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Returns only non-zero dimensions with the frozen receiving overseas warehouse ID/code/name. Required authority: " + OverseasPermissions.INVENTORY_LIST) public ApiResponse> balances( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -79,7 +79,7 @@ public class InventoryQueryController { @GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')") - @Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT) + @Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Uses the same non-zero balance filter and frozen receiving warehouse snapshots as the page API. Required authority: " + OverseasPermissions.INVENTORY_EXPORT) public ResponseEntity exportBalances( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java index cb1aac0a7..7b7aaee31 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java @@ -14,7 +14,8 @@ import java.util.List; @Mapper public interface InventoryQueryMapper { - String BALANCE_FILTER = " WHERE 1=1" + + String BALANCE_FILTER = " WHERE (available_packages+hold_packages+allocated_packages+picked_packages+" + + "in_transit_packages+receiving_packages+procurement_packages)>0" + " AND customer_id=#{customerId}" + " AND customer_name LIKE CONCAT('%',#{q.customerName},'%')" + " AND overseas_warehouse_id=#{q.overseasWarehouseId}" + @@ -37,7 +38,8 @@ public interface InventoryQueryMapper { " AND procurement_packages > 0" + ""; - String JOINED_BALANCE_FILTER = " WHERE 1=1" + + String JOINED_BALANCE_FILTER = " WHERE (b.available_packages+b.hold_packages+b.allocated_packages+" + + "b.picked_packages+b.in_transit_packages+b.receiving_packages+b.procurement_packages)>0" + " AND b.customer_id=#{customerId}" + " AND b.customer_name LIKE CONCAT('%',#{q.customerName},'%')" + " AND b.overseas_warehouse_id=#{q.overseasWarehouseId}" + 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 9c965b470..82308b8d9 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 @@ -140,6 +140,10 @@ public class LastlegOrderService { } WarehouseResourceGateway.WarehouseReference warehouse = warehouseResourceGateway.requireActiveSharedWarehouse(request.overseasWarehouseId()); + for (LastlegOrder order : orders) { + inventory.rebindReceivingWarehouse(order.getCustomerId(), order.getId(), warehouse.warehouseId(), + warehouse.warehouseCode(), warehouse.warehouseName(), operatorName, key); + } int updated = orderMapper.updateContainerReceivingWarehouse(bookingId, Long.toString(warehouse.warehouseId()), warehouse.warehouseCode(), warehouse.warehouseName(), version); if (updated != orders.size()) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index d024176b5..e4ceb686b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -341,8 +341,9 @@ public class LoadingCompletedConsumer { FirstLegLoadingPlanCompletedV1.Item item = source.items().get(index); if (item.packageCount() <= 0) continue; inventory.registerInTransit(order.getCustomerId(), "SYSTEM", - new InventoryCommandRequests.InTransit(order.getCustomerName(), payload.warehouseId(), null, - payload.warehouseName(), item.destinationWarehouseId(), item.destinationWarehouseCode(), + new InventoryCommandRequests.InTransit(order.getCustomerName(), order.getOverseasWarehouseId(), + order.getOverseasWarehouseCode(), order.getOverseasWarehouseName(), + item.destinationWarehouseId(), item.destinationWarehouseCode(), item.destinationWarehouseName(), order.getId(), inboundOrderNo, source.inboundCode(), item.skuCode(), item.productNameZh(), item.productNameEn(), item.packageCount(), order.getOrderNo(), event.eventId(), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index a6278e98c..c4035d8f9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -86,6 +86,7 @@ class InboundStockReceiptServiceTest { assertEquals("SKU-1", request.getValue().skuCode()); assertEquals("Customer", request.getValue().customerName()); assertEquals("900", request.getValue().overseasWarehouseId()); + assertEquals("US-LAX-01", request.getValue().overseasWarehouseCode()); assertEquals("Los Angeles", request.getValue().overseasWarehouseName()); assertEquals("PLT-21", request.getValue().palletNo()); ArgumentCaptor receipt = ArgumentCaptor.forClass(InboundStockReceipt.class); @@ -297,7 +298,8 @@ class InboundStockReceiptServiceTest { line.setRequiredPackageCount(required); line.setVolumeCbm(new BigDecimal("1.0")); LastlegOrder order = new LastlegOrder(); order.setId(101L); order.setCustomerId("100"); order.setOrderNo("LMO-1"); order.setCustomerName("Customer"); order.setSourceWarehouseId("100"); - order.setOverseasWarehouseId("900"); order.setOverseasWarehouseName("Los Angeles"); + order.setOverseasWarehouseId("900"); order.setOverseasWarehouseCode("US-LAX-01"); + order.setOverseasWarehouseName("Los Angeles"); order.setSourceEventId("evt-1"); order.setFbaWarehouseCode("ONT8"); order.setCurrentStatus("INBOUND_PLANNED"); org.mockito.Mockito.lenient().when(orders.lockById("100", 101L)).thenReturn(order); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index f7ed9407a..84fc1f422 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -119,6 +119,7 @@ class InboundPlanServiceTest { .filter(item -> "PENDING_COUNT".equals(item.getStatus())).count()); verify(orderMapper).markPlanned(any(), org.mockito.ArgumentMatchers.eq(2L), any(), org.mockito.ArgumentMatchers.anyInt(), any()); verify(inventoryBalanceMapper).bindInboundPlan(org.mockito.ArgumentMatchers.eq("100"), + org.mockito.ArgumentMatchers.eq("193000000000200003"), org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.eq("RK-2"), org.mockito.ArgumentMatchers.eq(result.planNo()), any()); verify(orderMapper).insertHistory(any(), org.mockito.ArgumentMatchers.eq(2L), any(), any(), any()); @@ -253,7 +254,7 @@ class InboundPlanServiceTest { assertEquals(1L, result.sourceOrderId()); verify(planMapper, never()).insert(any(InboundPlan.class)); verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); - verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any()); + verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any(), any()); } @Test @@ -484,7 +485,7 @@ class InboundPlanServiceTest { verify(planMapper, never()).insert(any(InboundPlan.class)); verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); - verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any()); + verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any(), any()); } @Test @@ -504,7 +505,7 @@ class InboundPlanServiceTest { assertEquals(409, error.getStatusCode().value()); verify(planMapper, never()).insert(any(InboundPlan.class)); verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); - verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any()); + verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any(), any()); } @Test @@ -567,7 +568,7 @@ class InboundPlanServiceTest { assertEquals(409, error.getStatusCode().value()); verify(planMapper, never()).insert(any(InboundPlan.class)); verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); - verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any()); + verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any(), any()); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java index 176cbba79..ab4d4b9b1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java @@ -9,6 +9,8 @@ import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Update; import org.junit.jupiter.api.Test; import java.io.IOException; @@ -82,6 +84,22 @@ class InventorySchemaContractTest { assertFalse(sql.contains("sku_code")); } + @Test + void balanceQueriesHideZeroRowsAndMutationsKeepWarehouseDimensionCanonical() throws Exception { + assertTrue(InventoryQueryMapper.BALANCE_FILTER.contains("procurement_packages)>0")); + assertTrue(InventoryQueryMapper.JOINED_BALANCE_FILTER.contains("b.procurement_packages)>0")); + String bindSql = String.join(" ", com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper.class + .getMethod("bindInboundPlan", String.class, String.class, Long.class, String.class, + String.class, java.time.LocalDateTime.class) + .getAnnotation(Update.class).value()); + assertTrue(bindSql.contains("overseas_warehouse_id=#{overseasWarehouseId}")); + String deleteSql = String.join(" ", com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper.class + .getMethod("deleteEmptyBalance", String.class, Long.class) + .getAnnotation(Delete.class).value()); + assertTrue(deleteSql.contains("in_transit_packages=0")); + assertTrue(deleteSql.contains("NOT EXISTS")); + } + @Test void incrementalMigrationIsAdditiveAndRerunnable() throws IOException { String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java index bdc374e24..6da77c0ff 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -140,6 +140,7 @@ class InventoryCommandServiceTest { source.setInTransitPackages(12L); when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(target); when(commands.lockInboundTransitBalances("100", 101L, "IN-CODE-1")).thenReturn(List.of(source)); + when(commands.deleteEmptyBalance("100", 201L)).thenReturn(1); InventoryCommandResponse result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-cross-warehouse", 12, 24)); @@ -152,6 +153,40 @@ class InventoryCommandServiceTest { assertEquals(-12L, written.getAllValues().get(0).getDeltaPackages()); assertEquals("STOCK_IN", written.getAllValues().get(1).getBusinessType()); assertEquals("IN_TRANSIT", written.getAllValues().get(1).getStatusFrom()); + verify(commands).deleteEmptyBalance("100", 201L); + } + + @Test + void receivingWarehouseChangeRebindsOnlyInTransitBalanceAndWritesZeroDeltaAudit() { + InventoryBalance balance = balance(200L, 0); + balance.setInTransitPackages(12L); + when(commands.lockInboundBalances("100", 101L)).thenReturn(List.of(balance)); + when(balances.lockDimension("100", "900", "ONT8", 101L, "IN-CODE-1")).thenReturn(null); + + service.rebindReceivingWarehouse("100", 101L, 900L, "US-NYC-01", "纽约仓", + "admin", "change-1"); + + assertEquals("900", balance.getOverseasWarehouseId()); + assertEquals("US-NYC-01", balance.getOverseasWarehouseCode()); + assertEquals("纽约仓", balance.getOverseasWarehouseName()); + ArgumentCaptor audit = ArgumentCaptor.forClass(InventoryLedger.class); + verify(ledgers).insert(audit.capture()); + assertEquals("RECEIVING_WAREHOUSE_CHANGED", audit.getValue().getBusinessType()); + assertEquals(0L, audit.getValue().getDeltaPackages()); + assertEquals(12L, audit.getValue().getBeforePackages()); + assertEquals(12L, audit.getValue().getAfterPackages()); + } + + @Test + void receivingWarehouseChangeRejectsPhysicalInventoryBeforeAnySnapshotMutation() { + InventoryBalance balance = balance(200L, 1); + when(commands.lockInboundBalances("100", 101L)).thenReturn(List.of(balance)); + + assertThrows(ResponseStatusException.class, () -> service.rebindReceivingWarehouse( + "100", 101L, 900L, "US-NYC-01", "纽约仓", "admin", "change-physical")); + + verify(balances, never()).updateById(any(InventoryBalance.class)); + verify(ledgers, never()).insert(any(InventoryLedger.class)); } @Test @@ -311,6 +346,7 @@ class InventoryCommandServiceTest { InventoryBalance value = new InventoryBalance(); value.setId(id); value.setCustomerId("100"); value.setCustomerName("Customer 100"); value.setOverseasWarehouseId("WH-1"); + value.setOverseasWarehouseCode("WH1"); value.setOverseasWarehouseName("Warehouse 1"); value.setDestinationWarehouseCode("ONT8"); value.setInboundOrderId(101L); value.setInboundOrderNo("IN-1"); value.setInboundCode("IN-CODE-1"); value.setAvailablePackages(available); 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 bd7fd3eaa..971ffa434 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 @@ -109,6 +109,10 @@ class LastlegOrderServiceTest { assertEquals("US-NYC-01", result.overseasWarehouseCode()); assertTrue(result.allPlansReady()); assertTrue(result.canChangeReceivingWarehouse()); + verify(inventory).rebindReceivingWarehouse("100", 1L, 900L, + "US-NYC-01", "纽约仓", "admin", "change-1"); + verify(inventory).rebindReceivingWarehouse("200", 2L, 900L, + "US-NYC-01", "纽约仓", "admin", "change-1"); verify(orders).updateContainerPlanReceivingWarehouse("BOOKING-1", 900L); verify(receivingWarehouseOperations).insert(any(com.baiyue.lastleg.entity.ReceivingWarehouseOperation.class)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 1fc89e9f7..4fa5b1133 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -27,6 +27,12 @@ class LoadingCompletedConsumerTest { } @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } + @Test void freezesReceivingWarehouseInInitialInTransitInventory(){ + service.consume(event()); + ArgumentCaptor captured=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); + verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),captured.capture()); + assertTrue(captured.getAllValues().stream().allMatch(value->"193000000000200003".equals(value.overseasWarehouseId())&&"US-LAX-01".equals(value.overseasWarehouseCode())&&"洛杉矶仓".equals(value.overseasWarehouseName()))); + } @Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){ var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2")); var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3"));