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/56] 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/56] 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/56] 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/56] =?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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] =?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/56] 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/56] 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/56] 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")); From 78a73a1b0e5c8a685705fdabcb4d12b4a22769ce Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:07:06 +0800 Subject: [PATCH 27/56] feat(firstleg): expose loading item tax mode --- .../baiyue/firstleg/dto/LoadingListDtos.java | 2 + .../mapper/LoadingListQueryMapper.java | 11 ++++- .../repository/LoadingListRepository.java | 3 +- .../resources/openapi/loading-lists-v1.yaml | 2 + .../LoadingListControllerContractTest.java | 11 ++++- .../LoadingListQueryMapperContractTest.java | 5 +++ .../repository/LoadingListRepositoryTest.java | 44 +++++++++++++++++++ 7 files changed, 74 insertions(+), 4 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index 58dbe8e4e..ee98f5bc5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -177,6 +177,8 @@ public final class LoadingListDtos { Long itemId, Long accountId, String customerName, + String customsTaxMode, + Boolean taxRefundFlag, Long orderId, String orderNo, Long inboundId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index 97c77cfaa..db85de82e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -168,7 +168,14 @@ public interface LoadingListQueryMapper { @Param("operatorName") String operatorName); @Select(""" - SELECT i.id AS item_id, i.account_id, i.customer_name, i.order_id, i.order_no, + SELECT i.id AS item_id, i.account_id, i.customer_name, + inbound.tax_mode AS customs_tax_mode, + CASE inbound.tax_mode + WHEN 'TAX_REFUND' THEN TRUE + WHEN 'BUY_DECLARATION' THEN FALSE + ELSE NULL + END AS tax_refund_flag, + i.order_id, i.order_no, i.inbound_id, i.inbound_detail_id, i.inbound_code, b.container_no, i.sku_code, i.destination_warehouse_name, i.product_name_zh, product_name_en, product_image_url, i.package_count, @@ -409,6 +416,8 @@ public interface LoadingListQueryMapper { private Long itemId; private Long accountId; private String customerName; + private String customsTaxMode; + private Boolean taxRefundFlag; private Long orderId; private String orderNo; private Long inboundId; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java index 33289ea30..16503bed5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -184,7 +184,8 @@ public class LoadingListRepository { private List items(List rows) { return rows.stream().map(row -> new Item(row.getItemId(), row.getAccountId(), row.getCustomerName(), - row.getOrderId(), row.getOrderNo(), row.getInboundId(), row.getInboundDetailId(), + row.getCustomsTaxMode(), row.getTaxRefundFlag(), row.getOrderId(), row.getOrderNo(), + row.getInboundId(), row.getInboundDetailId(), row.getInboundCode(), row.getContainerNo(), row.getSkuCode(), row.getDestinationWarehouseName(), row.getProductNameZh(), row.getProductNameEn(), row.getProductImageUrl(), integer(row.getPackageCount()), integer(row.getPalletCount()), diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 4a9021268..8dce29cbf 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -361,6 +361,8 @@ components: itemId: {$ref: '#/components/schemas/LongId'} accountId: {$ref: '#/components/schemas/LongId'} customerName: {type: string, nullable: true, description: 客户名称快照} + customsTaxMode: {type: string, enum: [TAX_REFUND, BUY_DECLARATION], nullable: true, description: 订单/入仓片段冻结的报关方式;不根据退税文件推断} + taxRefundFlag: {type: boolean, nullable: true, description: customsTaxMode为TAX_REFUND时为true、BUY_DECLARATION时为false;缺少权威事实时为null} orderId: {$ref: '#/components/schemas/LongId'} orderNo: {type: string, description: 订单号快照} inboundId: {$ref: '#/components/schemas/LongId'} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index 21563a38f..aca53edea 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -16,6 +16,7 @@ import org.springframework.security.access.prepost.PreAuthorize; import java.lang.reflect.Method; import java.lang.reflect.RecordComponent; import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.Map; import jakarta.validation.constraints.NotNull; @@ -145,9 +146,15 @@ class LoadingListControllerContractTest { .doesNotContainKey("internalContainerNo"); assertThat(properties(schemas, "ConfirmedSummary")) .containsKeys("planId", "containerBookingId", "loadingListNo"); - assertThat(properties(schemas, "LoadingItem")).containsKeys( - "itemId", "accountId", "inboundId", "inboundDetailId", "skuCode", + Map loadingItem = properties(schemas, "LoadingItem"); + assertThat(loadingItem).containsKeys( + "itemId", "accountId", "customsTaxMode", "taxRefundFlag", "inboundId", "inboundDetailId", "skuCode", "destinationWarehouse", "productQuantity", "frozenAt"); + assertThat(stringMap(loadingItem.get("customsTaxMode"))) + .containsEntry("nullable", true) + .containsEntry("enum", List.of("TAX_REFUND", "BUY_DECLARATION")); + assertThat(stringMap(loadingItem.get("taxRefundFlag"))) + .containsEntry("nullable", true); assertThat(properties(schemas, "OperationHistory")).containsKeys( "historyId", "itemId", "operationId", "actionType", "fromStatus", "toStatus", "beforeSnapshot", "afterSnapshot", "operatorId", "occurredAt"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index f24d554ff..659703cb6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -59,6 +59,11 @@ class LoadingListQueryMapperContractTest { String items = sql("selectItems", Select.class); assertThat(items) .contains("i.order_id", "i.order_no", "b.container_no") + .contains("inbound.tax_mode AS customs_tax_mode") + .contains("WHEN 'TAX_REFUND' THEN TRUE") + .contains("WHEN 'BUY_DECLARATION' THEN FALSE") + .contains("ELSE NULL") + .doesNotContain("fl_customs_container_document", "fl_customs_container_document_link") .contains("b.record_status='ACTIVE'") .contains("NULLIF(i.pallet_count,0)") .contains("fl_inbound_weight_result weight") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java index f9996b3f3..63c6fffe0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java @@ -5,6 +5,7 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.mapper.LoadingListQueryMapper; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; +import com.baiyue.firstleg.mapper.LoadingListQueryMapper.ItemRow; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -96,4 +97,47 @@ class LoadingListRepositoryTest { var confirmed = repository.preloadDetail(row).summary().readiness(); assertThat(confirmed.readyForPda()).isTrue(); } + + @Test + void mapsFrozenCustomsTaxFactsPerInboundAcrossCustomers() { + AggregateRow aggregate = new AggregateRow(); + aggregate.setPlanId(41L); + aggregate.setContainerBookingId(61L); + aggregate.setPlanNo("LP-41"); + aggregate.setPlanStatus("COMPLETED"); + aggregate.setVersion(3); + ItemRow refund = item(101L, 11L, 201L, "IN-REFUND", "TAX_REFUND", true); + ItemRow declaration = item(102L, 12L, 202L, "IN-BUY", "BUY_DECLARATION", false); + ItemRow unknown = item(103L, 12L, 203L, "IN-UNKNOWN", null, null); + when(mapper.selectItems(41L)).thenReturn(List.of(refund, declaration, unknown)); + when(mapper.selectHistories(41L)).thenReturn(List.of()); + + var items = repository.confirmedDetail(aggregate).items(); + + assertThat(items).extracting(value -> value.inboundCode(), value -> value.accountId(), + value -> value.customsTaxMode(), value -> value.taxRefundFlag()) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("IN-REFUND", 11L, "TAX_REFUND", true), + org.assertj.core.groups.Tuple.tuple("IN-BUY", 12L, "BUY_DECLARATION", false), + org.assertj.core.groups.Tuple.tuple("IN-UNKNOWN", 12L, null, null)); + } + + private static ItemRow item(long itemId, long accountId, long inboundId, String inboundCode, + String customsTaxMode, Boolean taxRefundFlag) { + ItemRow row = new ItemRow(); + row.setItemId(itemId); + row.setAccountId(accountId); + row.setCustomerName("customer-" + accountId); + row.setCustomsTaxMode(customsTaxMode); + row.setTaxRefundFlag(taxRefundFlag); + row.setOrderId(300L + inboundId); + row.setOrderNo("ORDER-" + inboundId); + row.setInboundId(inboundId); + row.setInboundDetailId(400L + inboundId); + row.setInboundCode(inboundCode); + row.setPackageCount(1); + row.setLoadSequence(1); + row.setItemVersion(0); + return row; + } } From 2e60f2cd773a44f3ad64bc95bfc1b0c926c89d8e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:13:43 +0800 Subject: [PATCH 28/56] feat(firstleg): enforce pricing adjustment bounds --- .../baiyue/firstleg/dto/FbaPricingDtos.java | 8 +- .../baiyue/firstleg/dto/PriceBookDtos.java | 13 ++- .../firstleg/entity/InboundFeeEntity.java | 4 + .../pricing/ActivePriceBookEstimator.java | 18 ++++ .../repository/FbaPricingRepository.java | 2 +- .../firstleg/service/FbaPricingService.java | 72 ++++++++++++++- .../service/MerchantPriceBookService.java | 4 + .../resources/db/phase2-inbound-schema.sql | 28 ++++++ .../resources/openapi/fba-pricing-v1.yaml | 17 +++- .../service/FbaPricingServiceTest.java | 88 +++++++++++++++++-- .../service/MerchantPriceBookServiceTest.java | 1 + 11 files changed, 236 insertions(+), 19 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java index 6ceff5682..84aa64bde 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FbaPricingDtos.java @@ -7,6 +7,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.Size; import java.math.BigDecimal; @@ -80,7 +81,8 @@ public final class FbaPricingDtos { @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal chargeableQuantity, @NotNull @DecimalMin("0.00") BigDecimal unitPrice, @NotNull @DecimalMin("0.00") BigDecimal additionalAmount, - @NotBlank @Size(max = 500) String reason + @NotBlank @Size(max = 500) String reason, + @NotNull @AssertTrue Boolean toleranceConfirmed ) {} public record FeeSnapshotResponse( @@ -96,7 +98,9 @@ public final class FbaPricingDtos { @NotNull BigDecimal conversionFactor, @NotNull Integer roundingScale, @NotBlank String roundingMode, BigDecimal actualWeightKg, BigDecimal actualVolumeCbm, - BigDecimal convertedQuantity, BigDecimal chargeableQuantity, String chargeUnit, + BigDecimal convertedQuantity, BigDecimal baselineChargeableQuantity, + BigDecimal lowerChargeableQuantity, BigDecimal upperChargeableQuantity, + BigDecimal tolerancePercent, BigDecimal chargeableQuantity, String chargeUnit, BigDecimal unitPrice, BigDecimal subtotalAmount, BigDecimal additionalAmount, BigDecimal totalAmount, String currency, LocalDateTime rateEffectiveFrom, LocalDateTime rateEffectiveTo, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java index cad3a507c..b81b8b94f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java @@ -119,9 +119,20 @@ public final class PriceBookDtos { @Size(max = 128) String destinationName, PriceRegion destinationRegion, PricingTaxMode pricingTaxMode, + @Pattern(regexp = "KG|CBM") String chargeMode, @DecimalMin(value = "0", inclusive = true) BigDecimal actualWeightKg, @DecimalMin(value = "0", inclusive = true) BigDecimal actualVolumeCbm - ) {} + ) { + public EstimateRequest(Long originWarehouseId, String channelCode, String destinationCode, + PricingDestinationType destinationType, Long destinationWarehouseId, + String destinationName, PriceRegion destinationRegion, + PricingTaxMode pricingTaxMode, BigDecimal actualWeightKg, + BigDecimal actualVolumeCbm) { + this(originWarehouseId, channelCode, destinationCode, destinationType, + destinationWarehouseId, destinationName, destinationRegion, pricingTaxMode, + null, actualWeightKg, actualVolumeCbm); + } + } public record EstimateResponse( Long priceBookVersionId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java index 667d3a527..bf8158474 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeEntity.java @@ -54,6 +54,10 @@ public class InboundFeeEntity { private BigDecimal actualWeightKg; private BigDecimal actualVolumeCbm; private BigDecimal convertedQuantity; + /** 系统按权威测量与生效报价计算的原始计费量;人工调整不得改变。 */ + private BigDecimal baselineChargeableQuantity; + /** 人工计费量相对系统基准允许的最大偏差百分比。 */ + private BigDecimal tolerancePercent; private BigDecimal chargeableQuantity; private String chargeUnit; private String currency; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java index 9eba2b66d..aca7ccbcf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java @@ -65,6 +65,8 @@ public class ActivePriceBookEstimator { } else { throw validation("私人地址暂不支持参考估价"); } + String chargeMode = requiredChargeMode(request); + rates = rates.stream().filter(value -> chargeMode.equals(value.getChargeUnit())).toList(); RateRow rate = uniqueRate(rates); requireModel(rate, PriceModel.FBA_WAREHOUSE_RATE); var estimate = fbaEstimator.estimate(rate, request.actualWeightKg(), request.actualVolumeCbm()); @@ -81,6 +83,9 @@ public class ActivePriceBookEstimator { if (request.pricingTaxMode() == null) { throw validation("请选择包税或不包税报价"); } + if (StringUtils.hasText(request.chargeMode()) && !"KG".equals(request.chargeMode())) { + throw validation("区域重量阶梯仅支持KG计费"); + } PriceRegion region = request.destinationRegion() == null ? resolveWarehouseRegion(request.destinationWarehouseId()) : request.destinationRegion(); List tiers = repository.regionTiers(versionId, request.originWarehouseId(), channelCode, @@ -113,6 +118,19 @@ public class ActivePriceBookEstimator { return rates.get(0); } + private static String requiredChargeMode(EstimateRequest request) { + if (request.pricingTaxMode() == PricingTaxMode.TAX_INCLUDED) { + if (StringUtils.hasText(request.chargeMode()) && !"KG".equals(request.chargeMode())) { + throw validation("包税报价必须按KG计费"); + } + return "KG"; + } + if (!StringUtils.hasText(request.chargeMode())) { + throw validation("不包税报价请选择客户计费方式"); + } + return request.chargeMode(); + } + private static void requireModel(RateRow rate, PriceModel expected) { if (rate == null || !expected.name().equals(rate.getPriceModel())) { throw new BizException(ResultCode.CONFLICT, "价格模型与渠道配置不一致"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index 26c6ffcda..867e31cde 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -171,7 +171,7 @@ public class FbaPricingRepository { .eq(CustomerChargeRuleEntity::getTenantId, inbound.getTenantId()) .eq(CustomerChargeRuleEntity::getCustomerId, inbound.getTenantId()) .eq(CustomerChargeRuleEntity::getBusinessType, inbound.getBusinessType()) - .eq(CustomerChargeRuleEntity::getTaxMode, inbound.getTaxMode()) + .eq(CustomerChargeRuleEntity::getTaxMode, inbound.getPricingTaxMode()) .eq(CustomerChargeRuleEntity::getStatus, "ACTIVE") .le(CustomerChargeRuleEntity::getEffectiveFrom, at) .and(w -> w.isNull(CustomerChargeRuleEntity::getEffectiveTo) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 5a36673a5..7183beaba 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -35,6 +35,8 @@ import java.util.List; @Service public class FbaPricingService { private static final int MONEY_SCALE = 2; + private static final BigDecimal ADJUST_TOLERANCE_PERCENT = new BigDecimal("10.00"); + private static final BigDecimal ONE_HUNDRED = new BigDecimal("100"); private static final long AUTOMATIC_CONFIRM_OPERATOR_ID = 0L; private static final String AUTOMATIC_CONFIRM_OPERATOR_NAME = "装柜完成自动确认"; @Resource @@ -114,10 +116,12 @@ public class FbaPricingService { || !StringUtils.hasText(item.getCalculationRuleVersion()))); if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用"); } + PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt); ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest( inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(), destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null, - pricingTaxMode(inbound), selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg, + pricingTaxMode(inbound), policy.chargeMode(), + selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg, selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm)); RateRow rate = estimate.rate(); BigDecimal weight = (selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg).setScale(3, RoundingMode.HALF_UP); @@ -133,6 +137,13 @@ public class FbaPricingService { InboundFeeEntity fee = snapshot(tenantId, operatorId, inbound, estimate, rate, version, weight, volume, converted, chargeable, subtotal, additional, total, now); + if (policy.rule() != null) { + fee.setChargeRuleId(policy.rule().getId()); + fee.setChargeRuleVersion(policy.rule().getRuleVersion()); + fee.setRuleCode(policy.rule().getRuleCode()); + fee.setRuleEffectiveFrom(policy.rule().getEffectiveFrom()); + fee.setRuleEffectiveTo(policy.rule().getEffectiveTo()); + } fee.setMeasurementId(measurement == null ? null : measurement.getId()); fee.setMeasurementSource("OUTER".equalsIgnoreCase(volumeSource) ? "OUTER" : volumeSource); if (published != null) { @@ -283,7 +294,8 @@ public class FbaPricingService { } String discriminator = current.getId() + ":" + request.expectedFeeVersion() + ":" + request.chargeableQuantity() + ":" + request.unitPrice() + ":" - + request.additionalAmount() + ":" + request.reason().trim(); + + request.additionalAmount() + ":" + request.reason().trim() + ":" + + request.toleranceConfirmed(); InboundPricingIdempotencyEntity token = beginIdempotency(tenantId, operatorId, inboundId, "FEE_ADJUST", idempotencyKey, hash("ADJUST", inboundId, discriminator)); if ("COMPLETED".equals(token.getStatus())) { @@ -297,6 +309,17 @@ public class FbaPricingService { adjusted.setId(null); adjusted.setFeeNo("FBAF-" + inboundId + "-" + nextVersion); adjusted.setFeeVersion(nextVersion); + BigDecimal baseline = baseline(current); + ToleranceRange range = toleranceRange(baseline); + if (request.chargeableQuantity().compareTo(range.lower()) < 0 + || request.chargeableQuantity().compareTo(range.upper()) > 0) { + throw conflict("调整计费量必须在系统原计费量±10%范围内(含边界)"); + } + if (!Boolean.TRUE.equals(request.toleranceConfirmed())) { + throw conflict("请二次确认计费量调整范围后再提交"); + } + adjusted.setBaselineChargeableQuantity(baseline); + adjusted.setTolerancePercent(ADJUST_TOLERANCE_PERCENT); adjusted.setChargeableQuantity(request.chargeableQuantity().setScale(4, RoundingMode.HALF_UP)); adjusted.setConvertedQuantity(adjusted.getChargeableQuantity()); adjusted.setSubtotalAmount(adjusted.getChargeableQuantity().multiply(request.unitPrice()) @@ -401,6 +424,27 @@ public class FbaPricingService { } } + private PricingPolicy pricingPolicy(InboundPricingEntity inbound, LocalDateTime at) { + PricingTaxMode taxMode = pricingTaxMode(inbound); + if (taxMode == PricingTaxMode.TAX_INCLUDED) return new PricingPolicy("KG", null); + List rules = repository.matchRules(inbound, at); + if (rules == null || rules.isEmpty()) { + throw conflict("客户未配置当前不包税报价的计费方式"); + } + List defaults = rules.stream() + .filter(rule -> Boolean.TRUE.equals(rule.getDefaultRule())).toList(); + com.baiyue.firstleg.entity.CustomerChargeRuleEntity selected = defaults.size() == 1 + ? defaults.get(0) : rules.size() == 1 ? rules.get(0) : null; + if (selected == null) throw conflict("客户当前不包税计费规则匹配不唯一"); + if (!List.of("KG", "CBM").contains(selected.getChargeMode())) { + throw conflict("客户计费方式配置无效"); + } + return new PricingPolicy(selected.getChargeMode(), selected); + } + + private record PricingPolicy(String chargeMode, + com.baiyue.firstleg.entity.CustomerChargeRuleEntity rule) { } + private InboundPricingIdempotencyEntity beginIdempotency(long tenantId, long operatorId, long inboundId, String operation, String key, String hash) { @@ -456,6 +500,8 @@ public class FbaPricingService { fee.setRateEffectiveFrom(rate.getEffectiveFrom()); fee.setRateEffectiveTo(rate.getEffectiveTo()); fee.setChargeMode(estimate.chargeUnit()); fee.setConversionFactor(estimate.conversionFactor()); fee.setActualWeightKg(weight); fee.setActualVolumeCbm(volume); fee.setConvertedQuantity(converted); + fee.setBaselineChargeableQuantity(chargeable); + fee.setTolerancePercent(ADJUST_TOLERANCE_PERCENT); fee.setChargeableQuantity(chargeable); fee.setChargeUnit(estimate.chargeUnit()); fee.setCurrency(rate.getCurrency()); fee.setSubtotalAmount(subtotal); fee.setAdditionalAmount(additional); fee.setTotalAmount(total); fee.setFeeStatus("CALCULATED"); fee.setReviewStatus("NOT_SUBMITTED"); fee.setExceptionRelease(false); @@ -497,6 +543,8 @@ public class FbaPricingService { private FeeSnapshotResponse response(InboundPricingEntity inbound, InboundFeeEntity fee, BigDecimal unitPrice, String nextStatus) { + BigDecimal baseline = baseline(fee); + ToleranceRange range = toleranceRange(baseline); return new FeeSnapshotResponse(fee.getId(), fee.getFeeNo(), fee.getFeeVersion(), fee.getFeeStatus(), fee.getInboundId(), inbound == null ? null : inbound.getInboundCode(), fee.getMeasurementId(), fee.getMeasurementSource(), @@ -508,7 +556,9 @@ public class FbaPricingService { fee.getTaxMode(), fee.getPricingTaxMode(), fee.getChargeMode(), fee.getConversionFactor(), fee.getRoundingScale(), fee.getRoundingMode(), fee.getActualWeightKg(), fee.getActualVolumeCbm(), - fee.getConvertedQuantity(), fee.getChargeableQuantity(), fee.getChargeUnit(), unitPrice, + fee.getConvertedQuantity(), baseline, range.lower(), range.upper(), + fee.getTolerancePercent() == null ? ADJUST_TOLERANCE_PERCENT : fee.getTolerancePercent(), + fee.getChargeableQuantity(), fee.getChargeUnit(), unitPrice, fee.getSubtotalAmount(), fee.getAdditionalAmount(), fee.getTotalAmount(), fee.getCurrency(), fee.getRateEffectiveFrom(), fee.getRateEffectiveTo(), fee.getCalculatedAt(), fee.getConfirmedAt(), fee.getSentAt(), nextStatus, @@ -516,6 +566,22 @@ public class FbaPricingService { fee.getCustomerConfirmedBy(), fee.getCustomerConfirmedByName(), fee.getCustomerConfirmedAt()); } + private static BigDecimal baseline(InboundFeeEntity fee) { + BigDecimal value = fee.getBaselineChargeableQuantity(); + if (value == null) value = fee.getChargeableQuantity(); + if (value == null || value.signum() <= 0) throw conflict("系统原计费量必须大于零"); + return value.setScale(6, RoundingMode.HALF_UP); + } + + private static ToleranceRange toleranceRange(BigDecimal baseline) { + BigDecimal delta = baseline.multiply(ADJUST_TOLERANCE_PERCENT) + .divide(ONE_HUNDRED, 6, RoundingMode.HALF_UP); + return new ToleranceRange(baseline.subtract(delta).setScale(6, RoundingMode.HALF_UP), + baseline.add(delta).setScale(6, RoundingMode.HALF_UP)); + } + + private record ToleranceRange(BigDecimal lower, BigDecimal upper) { } + private static String hash(String operation, long inboundId, String discriminator) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java index d913613d4..a4e5e6a9d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java @@ -42,6 +42,10 @@ public class MerchantPriceBookService { public EstimateResponse estimate(long merchantTenantId, EstimateRequest request) { requireMerchant(merchantTenantId); + if (request.pricingTaxMode() == PricingTaxMode.TAX_INCLUDED + && request.chargeMode() != null && !"KG".equals(request.chargeMode())) { + throw validation("包税报价必须按KG计费"); + } ActivePriceBookEstimator.Estimate estimate = estimator.estimate(request); return new EstimateResponse(estimate.priceBookVersionId(), estimate.channelCode(), estimate.priceModel(), estimate.pricingTaxMode(), estimate.chargeableQuantity(), estimate.chargeUnit(), estimate.unitPrice(), diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 76de2bd92..894c8e38d 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -672,6 +672,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( actual_weight_kg DECIMAL(18,3) NOT NULL COMMENT '试算时实际重量kg', actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '试算时实际体积m3', converted_quantity DECIMAL(18,4) NOT NULL COMMENT '换算后的重量或体积', + baseline_chargeable_quantity DECIMAL(18,6) NOT NULL COMMENT '系统按权威测量与生效报价计算的原始计费量', + tolerance_percent DECIMAL(5,2) NOT NULL DEFAULT 10.00 COMMENT '人工调整允许的最大偏差百分比,边界包含', chargeable_quantity DECIMAL(18,4) NOT NULL COMMENT '最终计费量', charge_unit VARCHAR(16) NOT NULL COMMENT '计费单位', currency CHAR(3) NOT NULL COMMENT '币种', @@ -701,6 +703,32 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee ( CONSTRAINT fk_fee_carrier_rate FOREIGN KEY (carrier_rate_id) REFERENCES fl_carrier_fba_rate (id) ) ENGINE=InnoDB COMMENT='入库头程费用计算和确认快照'; +SET @fee_baseline_quantity_sql = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_inbound_fee' AND column_name='baseline_chargeable_quantity'), + 'SELECT 1', + 'ALTER TABLE fl_inbound_fee ADD COLUMN baseline_chargeable_quantity DECIMAL(18,6) NULL COMMENT ''系统按权威测量与生效报价计算的原始计费量'' AFTER converted_quantity' +); +PREPARE fee_baseline_quantity_stmt FROM @fee_baseline_quantity_sql; +EXECUTE fee_baseline_quantity_stmt; +DEALLOCATE PREPARE fee_baseline_quantity_stmt; +UPDATE fl_inbound_fee + SET baseline_chargeable_quantity = chargeable_quantity + WHERE baseline_chargeable_quantity IS NULL; +ALTER TABLE fl_inbound_fee + MODIFY COLUMN baseline_chargeable_quantity DECIMAL(18,6) NOT NULL + COMMENT '系统按权威测量与生效报价计算的原始计费量'; + +SET @fee_tolerance_percent_sql = IF( + EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_inbound_fee' AND column_name='tolerance_percent'), + 'SELECT 1', + 'ALTER TABLE fl_inbound_fee ADD COLUMN tolerance_percent DECIMAL(5,2) NOT NULL DEFAULT 10.00 COMMENT ''人工调整允许的最大偏差百分比,边界包含'' AFTER baseline_chargeable_quantity' +); +PREPARE fee_tolerance_percent_stmt FROM @fee_tolerance_percent_sql; +EXECUTE fee_tolerance_percent_stmt; +DEALLOCATE PREPARE fee_tolerance_percent_stmt; + CREATE TABLE IF NOT EXISTS fl_inbound_fee_item ( id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index ec62caa77..df24f0f7b 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -165,7 +165,7 @@ paths: /api/platform/v1/inbounds/{inboundId}/fees/published: put: summary: 调整初算或已发布费用 - description: 需要 firstleg:fba-pricing:adjust 权限;人工修改计费量、单价和附加费,小计与总额由服务端重算并生成新版本,不回退 WAIT_SPLIT 状态。调整 CALCULATED/CONFIRMED 时生成新的 CALCULATED 版本;调整 SENT 时生成新的 SENT 版本并发布调整事件。 + description: 需要 firstleg:fba-pricing:adjust 权限;计费量只能在系统原计费量上下10%范围内(含边界),请求必须携带当前费用版本、必填原因及二次确认标志,越界或并发冲突返回A0409。小计与总额由服务端重算并生成新版本,不回退 WAIT_SPLIT 状态。调整 CALCULATED/CONFIRMED 时生成新的 CALCULATED 版本;调整 SENT 时生成新的 SENT 版本并发布调整事件。 parameters: [{$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -330,6 +330,10 @@ components: actualWeightKg: {type: number, nullable: true} actualVolumeCbm: {type: number, nullable: true} convertedQuantity: {type: number, nullable: true} + baselineChargeableQuantity: {type: number, description: 系统按权威测量与生效报价计算的原计费量,人工调整后保持不变} + lowerChargeableQuantity: {type: number, description: 允许人工调整的计费量下界,包含边界} + upperChargeableQuantity: {type: number, description: 允许人工调整的计费量上界,包含边界} + tolerancePercent: {type: number, enum: [10.00], description: 相对系统原计费量允许的上下偏差百分比} chargeableQuantity: {type: number, nullable: true} chargeUnit: {type: string, nullable: true} unitPrice: {type: number, nullable: true} @@ -466,16 +470,22 @@ components: timestamp: {type: string, format: date-time, nullable: true} AdjustFeeRequest: type: object - required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason] + required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason, toleranceConfirmed] properties: expectedFeeVersion: {type: integer, minimum: 1} chargeableQuantity: {type: number, minimum: 0, exclusiveMinimum: true, description: 计费量,单位沿用当前费用快照的 chargeUnit} unitPrice: {type: number, minimum: 0, description: 人工确认单价} additionalAmount: {type: number, minimum: 0, multipleOf: 0.01} reason: {type: string, minLength: 1, maxLength: 500} + toleranceConfirmed: {type: boolean, enum: [true], description: 前端展示上下界并由操作员二次确认后固定传true;服务端仍会重新校验范围与版本} CalculateRequest: type: object - properties: {pricingAt: {type: string, format: date-time, description: 缺省为服务端当前时间}} + properties: + pricingAt: {type: string, format: date-time, description: 缺省为服务端当前时间} + selectedWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, description: 选定的权威重量快照} + selectedVolumeCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, description: 选定的权威体积快照} + weightSource: {type: string, maxLength: 32, nullable: true} + volumeSource: {type: string, maxLength: 32, nullable: true} CustomerRuleVersionRequest: type: object required: [ruleCode, customerId, customerName, businessType, chargeMode, taxMode, conversionFactor, roundingScale, roundingMode, currency, effectiveFrom] @@ -668,5 +678,6 @@ components: destinationName: {type: string, maxLength: 128, nullable: true} destinationRegion: {type: string, enum: [WEST, CENTRAL, EAST]} pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'} + chargeMode: {type: string, enum: [KG, CBM], description: 包税报价固定为KG;不包税报价必须与客户当前计费规则一致} actualWeightKg: {type: number, minimum: 0} actualVolumeCbm: {type: number, minimum: 0} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 19954f276..bd95d463f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -11,6 +11,7 @@ import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; +import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; import com.baiyue.firstleg.repository.FbaPricingRepository; import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; @@ -37,6 +38,7 @@ 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.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.never; @@ -217,6 +219,10 @@ class FbaPricingServiceTest { InboundPricingEntity inbound = inbound("363.001", "0.5000"); inbound.setPricingTaxMode("TAX_EXCLUDED"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity(); + rule.setId(601L); rule.setRuleCode("CBM-RULE"); rule.setRuleVersion(1); + rule.setChargeMode("CBM"); rule.setDefaultRule(true); + when(repository.matchRules(eq(inbound), any())).thenReturn(List.of(rule)); when(priceBookEstimator.estimate(any())).thenReturn( estimate("CBM", "1.0001", "100.000000", "100.01", "0.00")); @@ -228,6 +234,8 @@ class FbaPricingServiceTest { assertThat(response.conversionFactor()).isEqualByComparingTo("363"); assertThat(response.roundingScale()).isEqualTo(6); assertThat(response.roundingMode()).isEqualTo("HALF_UP"); + assertThat(response.chargeRuleId()).isEqualTo(601L); + assertThat(response.ruleCode()).isEqualTo("CBM-RULE"); } @Test @@ -483,15 +491,19 @@ class FbaPricingServiceTest { FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND, new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( - 1, new BigDecimal("12.5"), new BigDecimal("8.00"), - new BigDecimal("20.00"), "客服修正"), "adjust-1"); + 1, new BigDecimal("160"), new BigDecimal("8.00"), + new BigDecimal("20.00"), "客服修正", true), "adjust-1"); assertThat(response.feeVersion()).isEqualTo(2); assertThat(response.feeStatus()).isEqualTo("SENT"); - assertThat(response.totalAmount()).isEqualByComparingTo("120.00"); + assertThat(response.totalAmount()).isEqualByComparingTo("1300.00"); + assertThat(response.baselineChargeableQuantity()).isEqualByComparingTo("167.000000"); + assertThat(response.lowerChargeableQuantity()).isEqualByComparingTo("150.300000"); + assertThat(response.upperChargeableQuantity()).isEqualByComparingTo("183.700000"); + assertThat(response.tolerancePercent()).isEqualByComparingTo("10.00"); assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); assertThat(inbound.getProcessStatus()).isEqualTo("WAIT_SPLIT"); - assertThat(inserted.get().getChargeableQuantity()).isEqualByComparingTo("12.5000"); + assertThat(inserted.get().getChargeableQuantity()).isEqualByComparingTo("160.0000"); verify(repository).supersedeCurrent(TENANT, 501L, "SENT"); verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED), eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW)); @@ -538,7 +550,7 @@ class FbaPricingServiceTest { assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND, new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( 1, new BigDecimal("12.5"), new BigDecimal("8.00"), - BigDecimal.ZERO, "客服修正"), "locked-adjust")) + BigDecimal.ZERO, "客服修正", true), "locked-adjust")) .isInstanceOf(BizException.class) .hasMessageContaining("客户已确认"); @@ -574,11 +586,11 @@ class FbaPricingServiceTest { FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND, new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( - 1, new BigDecimal("12.5"), new BigDecimal("8.00"), - new BigDecimal("20.00"), "客服修正"), "adjust-calculated"); + 1, new BigDecimal("160"), new BigDecimal("8.00"), + new BigDecimal("20.00"), "客服修正", true), "adjust-calculated"); assertThat(response.feeStatus()).isEqualTo("CALCULATED"); - assertThat(response.totalAmount()).isEqualByComparingTo("120.00"); + assertThat(response.totalAmount()).isEqualByComparingTo("1300.00"); assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT"); verify(repository).supersedeCurrent(TENANT, 501L, "CALCULATED"); verify(feeDetails).materializeCalculatedFreight(TENANT, INBOUND, 503L, OPERATOR, NOW); @@ -598,7 +610,7 @@ class FbaPricingServiceTest { assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND, new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( 1, new BigDecimal("12.5"), new BigDecimal("8.00"), - BigDecimal.ZERO, "客服修正"), "stale-adjust")) + BigDecimal.ZERO, "客服修正", true), "stale-adjust")) .isInstanceOf(BizException.class) .extracting(error -> ((BizException) error).getResultCode()) .isEqualTo(ResultCode.CONFLICT); @@ -607,6 +619,64 @@ class FbaPricingServiceTest { verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any()); } + @Test + @DisplayName("人工计费量允许上下10%含边界") + void acceptsInclusiveToleranceBoundaries() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); + InboundFeeEntity current = savedFee("CALCULATED"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current); + when(repository.findIdempotency(TENANT, OPERATOR, "FEE_ADJUST", "lower-bound")).thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(903L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); + when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1); + when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("CALCULATED"), eq("KG"), any())) + .thenReturn(1); + doAnswer(invocation -> { ((InboundFeeEntity) invocation.getArgument(0)).setId(504L); return null; }) + .when(repository).insertFee(any()); + when(repository.completeIdempotency(any(), eq(504L), eq(NOW))).thenReturn(1); + when(repository.findFee(TENANT, 504L)).thenAnswer(invocation -> current); + + service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("150.3"), new BigDecimal("2"), BigDecimal.ZERO, + "下界确认", true), "lower-bound"); + + verify(repository).insertFee(argThat(fee -> + fee.getChargeableQuantity().compareTo(new BigDecimal("150.3000")) == 0)); + } + + @Test + @DisplayName("人工计费量越过10%或未二次确认返回冲突") + void rejectsOutOfToleranceOrUnconfirmedAdjustment() { + InboundPricingEntity inbound = inbound("10", "1"); + inbound.setProcessStatus("WAIT_SPLIT"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(savedFee("CALCULATED")); + when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null); + doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(904L); return null; }) + .when(repository).insertIdempotency(any()); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); + + assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("183.7001"), new BigDecimal("2"), BigDecimal.ZERO, + "越界", true), "outside")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND, + new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest( + 1, new BigDecimal("167"), new BigDecimal("2"), BigDecimal.ZERO, + "未确认", false), "unconfirmed")) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any()); + } + private void readyRepository() { when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null); doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; }) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java index 8f6329212..8ead0e597 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -292,6 +292,7 @@ class MerchantPriceBookServiceTest { String weight, String volume) { return new EstimateRequest(10L, channelCode, destinationCode, destinationType, destinationWarehouseId, null, destinationRegion, pricingTaxMode, + pricingTaxMode == PricingTaxMode.TAX_INCLUDED ? "KG" : "CBM", decimal(weight), decimal(volume)); } From e597bebafdaa3a35605c4112abca962a0782b147 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:17:57 +0800 Subject: [PATCH 29/56] feat(order): expose cargo shipment binding --- .../InternalOrderCargoController.java | 7 +-- .../src/main/resources/openapi/order-v1.yaml | 45 +++++++++++++++++++ .../InternalOrderCargoControllerTest.java | 4 ++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java index aff44bcb5..0f3d06b38 100644 --- a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java @@ -75,7 +75,7 @@ public class InternalOrderCargoController { return ApiResponse.success(orders.findCargoInheritedByOrder(tenantId, orderId).stream() .map(cargo -> new CargoLabelBinding(cargo.getId(), orderId, cargo.getItemNo(), cargo.getCargoName(), cargo.getSkuNo(), cargo.getPackageCount(), cargo.getLengthCm(), - cargo.getWidthCm(), cargo.getHeightCm())) + cargo.getWidthCm(), cargo.getHeightCm(), cargo.getShipmentId())) .toList()); } @@ -156,11 +156,12 @@ public class InternalOrderCargoController { public record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, String skuNo, Integer packageCount, java.math.BigDecimal lengthCm, - java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) { + java.math.BigDecimal widthCm, java.math.BigDecimal heightCm, + String shipmentId) { public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, Integer packageCount, java.math.BigDecimal lengthCm, java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) { - this(cargoId, orderId, itemNo, cargoName, null, packageCount, lengthCm, widthCm, heightCm); + this(cargoId, orderId, itemNo, cargoName, null, packageCount, lengthCm, widthCm, heightCm, null); } } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 5a2af485c..b1ea0db3c 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -6,6 +6,28 @@ info: security: - bearerAuth: [] paths: + /internal/order/v1/cargoes/{tenantId}/{orderId}: + get: + security: [] + summary: Firstleg 批量读取订单货物绑定 + description: >- + 仅接受内部服务令牌。按 tenantId 与 orderId 严格限定订单范围,并按订单货物顺序一次返回全部货物; + cargoId 是 Firstleg inboundDetailId 的绑定键,shipmentId 仅取对应货物行快照, + 不使用订单顶层货件号回退,也不提供逐货物查询替代本批量契约。 + parameters: + - name: X-Internal-Service-Token + in: header + required: true + schema: {type: string} + - {name: tenantId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': + description: 当前订单范围内的货物绑定列表 + content: + application/json: + schema: {$ref: '#/components/schemas/CargoLabelBindingListEnvelope'} + '403': {description: 内部服务令牌无效} /internal/order/v1/pending-review-orders: get: security: [] @@ -590,6 +612,29 @@ components: scheme: bearer bearerFormat: JWT schemas: + CargoLabelBindingListEnvelope: + type: object + required: [code, data] + properties: + code: {type: string, example: "200"} + message: {type: string, nullable: true} + data: + type: array + items: {$ref: '#/components/schemas/CargoLabelBinding'} + CargoLabelBinding: + type: object + required: [cargoId, orderId, itemNo, cargoName, packageCount] + properties: + cargoId: {type: string, pattern: '^[1-9][0-9]*$', description: Firstleg inboundDetailId 的绑定键} + orderId: {type: string, pattern: '^[1-9][0-9]*$'} + itemNo: {type: integer, minimum: 1} + cargoName: {type: string} + skuNo: {type: string, nullable: true} + packageCount: {type: integer, minimum: 1} + lengthCm: {type: number, nullable: true} + widthCm: {type: number, nullable: true} + heightCm: {type: number, nullable: true} + shipmentId: {type: string, nullable: true, description: 当前货物行的 FBA 货件号快照,不以订单级字段回退} DomesticWarehouseOrderExportBatchRequest: type: object required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit] diff --git a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java index 97be8050c..0de14a34c 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java @@ -89,6 +89,8 @@ class InternalOrderCargoControllerTest { void listsCargoInRepositoryOrderForStableCartonAllocation() { OrderCargoEntity first = cargo(31L, 1, 3, "60", "40", "30"); OrderCargoEntity second = cargo(32L, 2, 2, "50", "30", "20"); + first.setShipmentId("FBA-SHIPMENT-31"); + second.setShipmentId("FBA-SHIPMENT-32"); when(orders.findCargoInheritedByOrder(11L, 21L)).thenReturn(List.of(first, second)); var response = controller.listForCartonLabels("internal-token", 11L, 21L); @@ -98,6 +100,8 @@ class InternalOrderCargoControllerTest { .containsExactly(31L, 32L); assertThat(response.data().get(0).packageCount()).isEqualTo(3); assertThat(response.data().get(0).lengthCm()).isEqualByComparingTo("60"); + assertThat(response.data()).extracting(InternalOrderCargoController.CargoLabelBinding::shipmentId) + .containsExactly("FBA-SHIPMENT-31", "FBA-SHIPMENT-32"); verify(orders).findCargoInheritedByOrder(11L, 21L); } From ba81dece5e039ca8d4826ed5afa8d28771986921 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:24:29 +0800 Subject: [PATCH 30/56] feat(firstleg): expose authoritative measurement identifiers --- .../firstleg/client/OrderCargoClient.java | 7 +- .../PlatformInboundMeasurementController.java | 6 +- .../dto/InboundMeasurementResponses.java | 5 ++ .../service/InboundMeasurementService.java | 61 ++++++++++--- .../service/MeasurementBatchService.java | 13 +-- .../main/resources/openapi/firstleg-v1.yaml | 15 +++- ...easurementEvidenceOpenApiContractTest.java | 20 ++++- .../InboundMeasurementServiceTest.java | 88 ++++++++++++++++++- .../service/MeasurementBatchServiceTest.java | 21 +++-- 9 files changed, 190 insertions(+), 46 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java index ac30d0298..4508179b8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java @@ -63,12 +63,13 @@ public interface OrderCargoClient { } record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, - String skuNo, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm, - BigDecimal heightCm) { + String skuNo, Integer packageCount, BigDecimal lengthCm, + BigDecimal widthCm, BigDecimal heightCm, String shipmentId) { public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm) { - this(cargoId, orderId, itemNo, cargoName, null, packageCount, lengthCm, widthCm, heightCm); + this(cargoId, orderId, itemNo, cargoName, null, + packageCount, lengthCm, widthCm, heightCm, null); } } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index c83eb68ad..569cfa5e5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -49,7 +49,8 @@ public class PlatformInboundMeasurementController { @Resource private MeasurementSubmissionService submission; @Resource private CartonMeasurementService cartonMeasurements; - @Operation(summary = "查询入库测量详情", description = "返回原单、实测箱规、审核快照和 OCR 重量结果。") + @Operation(summary = "查询入库测量详情", description = "返回唯一入仓码、原单、实测箱规、审核快照和 OCR 重量结果;" + + "箱规行按 inboundDetailId 返回 Order 权威 FBA 货件编号,并保留独立设备实扫值。") @GetMapping("/measurement") @PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:view')") public ApiResponse detail( @@ -78,7 +79,8 @@ public class PlatformInboundMeasurementController { @BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND", resourceId = "#inboundId", description = "保存入库测量草稿") - @Operation(summary = "保存测量草稿", description = "保存人工测量批次和服务端计算明细,不推进入库状态。") + @Operation(summary = "保存测量草稿", description = "仅在待测量、测量中或审核驳回状态保存人工测量批次和服务端计算明细;" + + "进入待审核后批次冻结,确认箱数修正须使用 measurement/items/{itemId}/confirmed-package-count 专用接口。") @PutMapping("/measurement/draft") @PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:warehouse-suggestion:edit'," + " 'platform:firstleg:inbound:measure:edit')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java index 8d118da90..7934f7b4f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -75,6 +75,8 @@ public final class InboundMeasurementResponses { public record Item( Long id, + @Schema(description = "当前测量详情唯一权威入仓码,与根对象 inboundCode 一致") + String inboundCode, Long inboundDetailId, String itemNo, String boxSpecification, @@ -99,6 +101,9 @@ public final class InboundMeasurementResponses { String coefficientMode, String remark, String boxCode, + @Schema(description = "Order货物明细按inboundDetailId匹配的权威FBA货件编号;匹配失败为空,禁止回退设备实扫值") + String fbaShipmentNo, + @Schema(description = "设备从Amazon FBA箱标实扫的Shipment ID;仅作现场证据,不覆盖或回退权威货件编号") String scannedShipmentId, Integer confirmedPackageCount, String machineNo, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index 50e3a0a46..66c4c636a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -48,6 +48,8 @@ public class InboundMeasurementService { private static final String WEIGHT_PENDING_CONFIRM = "PENDING_CONFIRM"; /** OCR 结果无效或不完整,必须人工录入或修正。 */ private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL"; + private static final Set MEASUREMENT_DRAFT_EDITABLE_STATUSES = Set.of( + "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); private static final Set WEIGHT_EDITABLE_STATUSES = Set.of( "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); /** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */ @@ -100,7 +102,7 @@ public class InboundMeasurementService { com.baiyue.firstleg.entity.InboundPalletAllocationEntity::getWeightResultId, java.util.function.Function.identity(), (left, right) -> left)); MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inbound.getId()); - InboundMeasurementResponses.DeclarationSnapshot latestDeclaration = loadLatestDeclaration(inbound); + LatestDeclaration latestDeclaration = loadLatestDeclaration(inbound); return toDetail(inbound, measurement, items, weights, allocationSlots, allocationsByWeight, review, latestDeclaration); } @@ -122,6 +124,10 @@ public class InboundMeasurementService { long tenantId, long inboundId, long operatorId, InboundMeasurementRequests.SaveDraftRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + if (!MEASUREMENT_DRAFT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) { + throw new BizException(ResultCode.CONFLICT, + "测量已提交待审核,不能再保存整批测量数据;如需修改请先退回后重新提交"); + } validateInboundDetails(tenantId, inbound, request.items()); request.items().stream().map(InboundMeasurementRequests.ItemRequest::chargeRuleId) .filter(java.util.Objects::nonNull).distinct().forEach(ruleId -> { @@ -468,7 +474,8 @@ public class InboundMeasurementService { List allocationSlots, Map allocationsByWeight, MeasureReviewRecordEntity review, - InboundMeasurementResponses.DeclarationSnapshot latestDeclaration) { + LatestDeclaration latestDeclaration) { + InboundMeasurementResponses.DeclarationSnapshot declaration = latestDeclaration.snapshot(); return new InboundMeasurementResponses.Detail( inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(), inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(), @@ -480,21 +487,22 @@ public class InboundMeasurementService { inbound.getVersion(), measurement == null ? null : measurement.getVersion(), measurement != null && StringUtils.hasText(measurement.getOriginalDocumentNo()) - ? measurement.getOriginalDocumentNo() : latestDeclaration.referenceId(), + ? measurement.getOriginalDocumentNo() : declaration.referenceId(), measurement != null && measurement.getOriginalGrossWeightKg() != null - ? measurement.getOriginalGrossWeightKg() : latestDeclaration.grossWeightKg(), + ? measurement.getOriginalGrossWeightKg() : declaration.grossWeightKg(), measurement != null && measurement.getOriginalVolumeCbm() != null - ? measurement.getOriginalVolumeCbm() : latestDeclaration.volumeCbm(), - latestDeclaration.totalPackageCount(), + ? measurement.getOriginalVolumeCbm() : declaration.volumeCbm(), + declaration.totalPackageCount(), measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(), inbound.getActualGrossWeightKg(), inbound.getActualNetWeightKg(), measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(), measurement == null ? inbound.getVolumeWeightKg() : measurement.getVolumeWeightKg(), measurement != null && StringUtils.hasText(measurement.getActualGoodsName()) - ? measurement.getActualGoodsName() : latestDeclaration.goodsName(), - latestDeclaration, - items.stream().map(this::toItem).toList(), weights.stream() + ? measurement.getActualGoodsName() : declaration.goodsName(), + declaration, + items.stream().map(item -> toItem(item, inbound.getInboundCode(), item.getInboundDetailId() == null + ? null : latestDeclaration.fbaShipmentNos().get(item.getInboundDetailId()))).toList(), weights.stream() .map(weight -> toWeight(weight, allocationsByWeight.get(weight.getId()))).toList(), toPalletLocationGuide(allocationSlots)); } @@ -526,10 +534,14 @@ public class InboundMeasurementService { * Latest customer declaration is a live, read-only Order view. It must never be persisted over * the measurement batch, carton-label mapping, weight results or review decision. */ - private InboundMeasurementResponses.DeclarationSnapshot loadLatestDeclaration( + private LatestDeclaration loadLatestDeclaration( InboundOrderEntity inbound) { OrderCargoClient.MeasurementDeclarationSnapshot aggregate = loadDeclaration(inbound); List cargoes = loadDeclarationCargoes(inbound); + if (cargoes.stream().anyMatch(cargo -> cargo == null || cargo.cargoId() == null + || cargo.cargoId() <= 0 || !java.util.Objects.equals(cargo.orderId(), inbound.getOrderId()))) { + throw new BizException(ResultCode.CONFLICT, "订单服务返回的货物明细归属不一致"); + } long packages = cargoes.stream() .map(OrderCargoClient.CargoLabelBinding::packageCount) .filter(java.util.Objects::nonNull) @@ -538,7 +550,7 @@ public class InboundMeasurementService { if (packages > Integer.MAX_VALUE) { throw new BizException(ResultCode.VALIDATION_FAILED, "客户最新申报总箱数超过系统上限"); } - return new InboundMeasurementResponses.DeclarationSnapshot( + InboundMeasurementResponses.DeclarationSnapshot snapshot = new InboundMeasurementResponses.DeclarationSnapshot( aggregate.referenceId(), (int) packages, aggregate.originalGrossWeightKg(), aggregate.originalVolumeCbm(), aggregate.defaultActualGoodsName(), cargoes.stream() @@ -550,6 +562,19 @@ public class InboundMeasurementService { cargo.cargoId(), cargo.itemNo(), cargo.cargoName(), cargo.packageCount(), cargo.lengthCm(), cargo.widthCm(), cargo.heightCm())) .toList()); + Map fbaShipmentNos = cargoes.stream() + .filter(cargo -> cargo.cargoId() != null && StringUtils.hasText(cargo.shipmentId())) + .collect(java.util.stream.Collectors.toMap( + OrderCargoClient.CargoLabelBinding::cargoId, + cargo -> cargo.shipmentId().trim(), + (left, right) -> { + if (!java.util.Objects.equals(left, right)) { + throw new BizException(ResultCode.CONFLICT, + "订单同一货物明细返回多个FBA货件编号"); + } + return left; + }, java.util.LinkedHashMap::new)); + return new LatestDeclaration(snapshot, Map.copyOf(fbaShipmentNos)); } private List loadDeclarationCargoes(InboundOrderEntity inbound) { @@ -601,9 +626,11 @@ public class InboundMeasurementService { return value; } - private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item) { + private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item, + String inboundCode, + String fbaShipmentNo) { return new InboundMeasurementResponses.Item( - item.getId(), item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(), + item.getId(), inboundCode, item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(), item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getGirthCm(), item.getUnitVolumeCbm(), item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(), @@ -612,11 +639,17 @@ public class InboundMeasurementService { item.getOuterLengthCm(), item.getOuterWidthCm(), item.getOuterHeightCm(), item.getCalculationRuleVersion(), item.getChargeRuleId(), item.getCoefficientMode(), - item.getRemark(), item.getBoxCode(), item.getScannedShipmentId(), + item.getRemark(), item.getBoxCode(), fbaShipmentNo, item.getScannedShipmentId(), item.getPackageCount(), item.getMachineNo(), item.getDeviceMeasuredAt(), item.getUpdatedAt()); } + private record LatestDeclaration( + InboundMeasurementResponses.DeclarationSnapshot snapshot, + Map fbaShipmentNos + ) { + } + private void requireConfirmedPackageTotal(InboundOrderEntity inbound, List items) { if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java index 3246f5cdd..bad5a21ad 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -42,6 +42,8 @@ public class MeasurementBatchService { static final String STATUS_DRAFT = "DRAFT"; /** 已接收第三方测量数据,待平台提交审核。 */ static final String STATUS_RECEIVED = "RECEIVED"; + private static final Set EDITABLE_INBOUND_STATUSES = Set.of( + "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167"); private static final BigDecimal MAX_RAW_VOLUME_CBM = new BigDecimal("999999999999.999999"); private static final BigDecimal MAX_TOTAL_VOLUME_CBM = new BigDecimal("99999999999999.9999"); @@ -60,6 +62,10 @@ public class MeasurementBatchService { if (inbound == null) { throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); } + if (!EDITABLE_INBOUND_STATUSES.contains(inbound.getProcessStatus())) { + throw new BizException(ResultCode.CONFLICT, + "当前入库状态不允许保存测量批次,已提交批次必须保持冻结"); + } validateSource(command); List calculatedItems = calculateItems( command, dimensionWeighting.load(command.tenantId())); @@ -71,12 +77,7 @@ public class MeasurementBatchService { repository.insertMeasurement(measurement); } else { measurement = requireEditableBatch(command); - // 修正待测量或待审核资料时重新形成可提交的内部草稿;已入库订单只更新资料, - // 不回退其主状态,也不把已经审核的批次重新标成待审核。 - if (Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED") - .contains(inbound.getProcessStatus())) { - measurement.setStatus(STATUS_DRAFT); - } + measurement.setStatus(STATUS_DRAFT); applyHeader(measurement, command); applyTotals(measurement, calculatedItems); if (!repository.updateMeasurement(measurement)) { diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 1252ac4b4..bb9ebde90 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -916,6 +916,8 @@ paths: grossWeightKg/netWeightKg。两类证据不得合并为无明细归属的统一图片列表。 actualGrossWeightKg 是服务端确认后的实测毛重,actualNetWeightKg 是扣除托盘皮重后的货物净重; 两者语义独立,消费者不得互相回退,也不得根据图片或 OCR 值自行重算。 + inboundCode 是当前入库详情唯一入仓码;每条 items 按 inboundDetailId 返回 Order 权威 + fbaShipmentNo,并保留独立 scannedShipmentId,匹配失败返回空且禁止两者互相回退或覆盖。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -931,7 +933,9 @@ paths: summary: 保存测量草稿 description: >- 原单据号码、申报毛重和申报体积由服务端从客户订单生成快照,客户端不得提交覆盖; - actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。 + actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。仅允许 WAIT_MEASURE、MEASURING + 或 MEASURE_REJECTED 状态保存;进入 MEASURE_PENDING 后整批测量冻结,确认箱数修正使用 + measurement/items/{itemId}/confirmed-package-count 明细专用接口。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -1749,10 +1753,12 @@ components: type: object description: >- 单条尺寸测量明细。id 是稳定 measurementItemId;intelligentImageFileId 仅表示该明细的 - 尺寸测量原图,不得与 weightResults.fileId 混用。 + 尺寸测量原图,不得与 weightResults.fileId 混用。fbaShipmentNo 是按 inboundDetailId + 从 Order 货物明细实时读取的权威货件编号;scannedShipmentId 是独立设备实扫证据,二者不得回退或覆盖。 required: [id] properties: id: {type: string, pattern: '^[1-9][0-9]*$', description: 稳定 measurementItemId} + inboundCode: {type: string, nullable: true, description: 当前测量详情唯一权威入仓码,与根对象inboundCode一致} inboundDetailId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} itemNo: {type: string, nullable: true} boxSpecification: {type: string, nullable: true} @@ -1781,7 +1787,8 @@ components: coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY], nullable: true} remark: {type: string, nullable: true} boxCode: {type: string, nullable: true} - scannedShipmentId: {type: string, nullable: true} + fbaShipmentNo: {type: string, nullable: true, description: Order货物明细按inboundDetailId匹配的权威FBA货件编号;匹配失败为空} + scannedShipmentId: {type: string, nullable: true, description: 设备实扫Shipment ID;仅作现场证据,不覆盖或回退fbaShipmentNo} confirmedPackageCount: {type: integer, nullable: true} machineNo: {type: string, nullable: true} deviceMeasuredAt: {type: string, format: date-time, nullable: true} @@ -1793,7 +1800,7 @@ components: required: [inboundId, inboundVersion, items, weightResults] properties: inboundId: {type: string, pattern: '^[1-9][0-9]*$'} - inboundCode: {type: string, nullable: true} + inboundCode: {type: string, nullable: true, description: 当前测量详情唯一入仓码,UI业务主展示字段} companyName: {type: string, nullable: true} channelCode: {type: string, nullable: true} channelName: {type: string, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java index c9adf167b..b033f0119 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java @@ -32,8 +32,10 @@ class InboundMeasurementEvidenceOpenApiContractTest { Map schemas = stringMap(stringMap(document.get("components")).get("schemas")); Map detailProperties = properties(schemas, "InboundMeasurementDetail"); assertThat(detailProperties).containsKeys( - "actualGrossWeightKg", "actualNetWeightKg", "latestDeclaration", "items", "weightResults", + "inboundCode", "actualGrossWeightKg", "actualNetWeightKg", "latestDeclaration", "items", "weightResults", "palletLocationGuide"); + assertThat(stringMap(detailProperties.get("inboundCode")).get("description").toString()) + .contains("唯一入仓码", "UI业务主展示字段"); assertThat(stringMap(detailProperties.get("latestDeclaration"))) .containsEntry("$ref", "#/components/schemas/InboundMeasurementLatestDeclaration"); assertThat(properties(schemas, "InboundMeasurementLatestDeclaration")) @@ -49,8 +51,11 @@ class InboundMeasurementEvidenceOpenApiContractTest { Map draftProperties = properties(schemas, "InboundMeasurementSaveDraftRequest"); assertThat(draftProperties).containsKeys("measurementId", "version", "actualGoodsName", "items") .doesNotContainKeys("originalDocumentNo", "originalGrossWeightKg", "originalVolumeCbm"); - assertThat(paths).containsKey( - "/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/draft"); + String draftPath = "/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/draft"; + assertThat(paths).containsKey(draftPath); + Map draftOperation = stringMap(stringMap(paths.get(draftPath)).get("put")); + assertThat(draftOperation.get("description").toString()) + .contains("MEASURE_PENDING", "冻结", "package-count"); assertThat(stringMap(stringMap(draftProperties.get("items")).get("items"))) .containsEntry("$ref", "#/components/schemas/InboundMeasurementItemRequest"); assertThat(properties(schemas, "InboundMeasurementItemRequest")).containsKeys( @@ -58,7 +63,14 @@ class InboundMeasurementEvidenceOpenApiContractTest { "widthCm", "heightCm", "intelligentImageFileId", "remark"); Map itemProperties = properties(schemas, "InboundMeasurementItem"); - assertThat(itemProperties).containsKeys("id", "intelligentImageFileId"); + assertThat(itemProperties).containsKeys( + "id", "inboundCode", "inboundDetailId", "intelligentImageFileId", "fbaShipmentNo", "scannedShipmentId"); + assertThat(stringMap(itemProperties.get("inboundCode")).get("description").toString()) + .contains("唯一权威入仓码", "根对象inboundCode一致"); + assertThat(stringMap(itemProperties.get("fbaShipmentNo")).get("description").toString()) + .contains("Order货物明细", "inboundDetailId", "匹配失败为空"); + assertThat(stringMap(itemProperties.get("scannedShipmentId")).get("description").toString()) + .contains("设备实扫", "不覆盖或回退fbaShipmentNo"); Map weightProperties = properties(schemas, "InboundWeightResult"); assertThat(weightProperties).containsKeys( "id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index ab0c01789..65b4a70dd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -75,8 +75,8 @@ class InboundMeasurementServiceTest { "REF-50", new BigDecimal("2000"), new BigDecimal("20"), "123123"))); when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn( ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding( - 80001L, 80L, 1, "123123", 3, new BigDecimal("40"), - new BigDecimal("30"), new BigDecimal("20"))))); + 80001L, 80L, 1, "123123", null, 3, new BigDecimal("40"), + new BigDecimal("30"), new BigDecimal("20"), "FBA-DECLARED-001")))); } @Test @@ -196,7 +196,9 @@ class InboundMeasurementServiceTest { item.setId(40L); item.setTenantId(1L); item.setMeasurementId(30L); + item.setInboundDetailId(80001L); item.setSourceImageFileId(140L); + item.setScannedShipmentId("FBA-SCANNED-001"); item.setOuterLengthCm(new BigDecimal("64.000")); item.setOuterWidthCm(new BigDecimal("54.000")); item.setOuterHeightCm(new BigDecimal("54.000")); @@ -217,11 +219,15 @@ class InboundMeasurementServiceTest { InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + assertThat(result.inboundCode()).isEqualTo("RK-001"); assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("30.000"); assertThat(result.actualNetWeightKg()).isEqualByComparingTo("11.500"); assertThat(result.items()).singleElement().satisfies(dimension -> { assertThat(dimension.id()).isEqualTo(40L); + assertThat(dimension.inboundCode()).isEqualTo("RK-001"); assertThat(dimension.intelligentImageFileId()).isEqualTo(140L); + assertThat(dimension.fbaShipmentNo()).isEqualTo("FBA-DECLARED-001"); + assertThat(dimension.scannedShipmentId()).isEqualTo("FBA-SCANNED-001"); assertThat(dimension.outerLengthCm()).isNull(); assertThat(dimension.outerWidthCm()).isNull(); assertThat(dimension.outerHeightCm()).isNull(); @@ -246,6 +252,63 @@ class InboundMeasurementServiceTest { }); } + @Test + void detailMapsMultipleDeclaredShipmentIdsByInboundDetailWithoutFallingBackToScannedValue() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + InboundMeasurementItemEntity first = new InboundMeasurementItemEntity(); + first.setId(41L); + first.setInboundDetailId(80001L); + first.setScannedShipmentId("SCAN-DIFFERENT-001"); + InboundMeasurementItemEntity second = new InboundMeasurementItemEntity(); + second.setId(42L); + second.setInboundDetailId(80002L); + InboundMeasurementItemEntity unmatched = new InboundMeasurementItemEntity(); + unmatched.setId(43L); + unmatched.setInboundDetailId(89999L); + unmatched.setScannedShipmentId("SCAN-ONLY-999"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(first, second, unmatched)); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(List.of( + new OrderCargoClient.CargoLabelBinding(80001L, 80L, 1, "货物1", null, + 1, null, null, null, "FBA-DECLARED-001"), + new OrderCargoClient.CargoLabelBinding(80002L, 80L, 2, "货物2", null, + 1, null, null, null, "FBA-DECLARED-002")))); + + InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + + assertThat(result.inboundCode()).isEqualTo("RK-001"); + assertThat(result.items()) + .extracting(InboundMeasurementResponses.Item::inboundDetailId, + InboundMeasurementResponses.Item::inboundCode, + InboundMeasurementResponses.Item::fbaShipmentNo, + InboundMeasurementResponses.Item::scannedShipmentId) + .containsExactly( + tuple(80001L, "RK-001", "FBA-DECLARED-001", "SCAN-DIFFERENT-001"), + tuple(80002L, "RK-001", "FBA-DECLARED-002", null), + tuple(89999L, "RK-001", null, "SCAN-ONLY-999")); + verify(orderCargoClient, times(1)).listForCartonLabels("internal-token", 1L, 80L); + } + + @Test + void detailRejectsCargoBatchWhoseOrderOwnershipDoesNotMatchInbound() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding( + 80001L, 81L, 1, "其他订单货物", null, 1, null, null, null, + "FBA-OTHER-001")))); + + assertThatThrownBy(() -> service.detail(1L, 10L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("货物明细归属不一致"); + } + @Test void detailReturnsWarehouseOuterDimensionsOnlyAfterManualOverrideWasSaved() { InboundOrderEntity inbound = inbound("MEASURING"); @@ -333,8 +396,8 @@ class InboundMeasurementServiceTest { "NEW-REF", new BigDecimal("40.000"), new BigDecimal("0.8000"), "最新申报品名"))); when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn( ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding( - 80001L, 80L, 1, "最新申报品名", 4, new BigDecimal("50"), - new BigDecimal("40"), new BigDecimal("30"))))); + 80001L, 80L, 1, "最新申报品名", null, 4, new BigDecimal("50"), + new BigDecimal("40"), new BigDecimal("30"), "FBA-LATEST-001")))); InboundMeasurementResponses.Detail result = service.detail(1L, 10L); @@ -356,6 +419,7 @@ class InboundMeasurementServiceTest { assertThat(result.items()).singleElement().satisfies(measured -> { assertThat(measured.id()).isEqualTo(40L); assertThat(measured.boxCode()).isEqualTo("RK-001#2-1"); + assertThat(measured.fbaShipmentNo()).isEqualTo("FBA-LATEST-001"); assertThat(measured.packageCount()).isEqualTo(2); assertThat(measured.lengthCm()).isEqualByComparingTo("45"); }); @@ -1116,6 +1180,21 @@ class InboundMeasurementServiceTest { verify(orderCargoClient).detail("internal-token", 1L, 80L, 80001L); } + @Test + void savingDraftCannotReopenSubmittedMeasurementWhileReviewIsPending() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + inbound.setMeasureReviewStatus("PENDING"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + + assertThatThrownBy(() -> service.saveDraft(1L, 10L, 9L, + new InboundMeasurementRequests.SaveDraftRequest(30L, 10, null, List.of()))) + .isInstanceOf(BizException.class) + .hasMessageContaining("已提交待审核"); + + verifyNoInteractions(batchService); + verify(orderCargoClient, never()).detail(any(), anyLong(), anyLong(), anyLong()); + } + @Test void savingDraftRejectsOverlongDefaultGoodsNameBeforeDatabaseWrite() { InboundOrderEntity inbound = inbound("WAIT_MEASURE"); @@ -1157,6 +1236,7 @@ class InboundMeasurementServiceTest { inbound.setTenantId(1L); inbound.setOrderId(80L); inbound.setWarehouseId(100L); + inbound.setInboundCode("RK-001"); inbound.setProcessStatus(status); inbound.setTotalPackageCount(3); inbound.setVersion(0); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java index ebdb1dccc..4ba98e21f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java @@ -392,28 +392,31 @@ class MeasurementBatchServiceTest { } @Test - void approvedManualMeasurementCanBeEditedAsDraftBeforeReviewCompletes() { + void submittedMeasurementCannotBeResetToDraftWhileReviewIsPending() { InboundMeasurementEntity existing = new InboundMeasurementEntity(); existing.setId(100L); existing.setTenantId(1L); existing.setInboundId(10L); existing.setSourceType("MANUAL"); - existing.setStatus("APPROVED"); + existing.setStatus("SUBMITTED"); existing.setVersion(3); + InboundOrderEntity inbound = inbound(); + inbound.setProcessStatus("MEASURE_PENDING"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findMeasurement(1L, 10L, 100L)).thenReturn(existing); - when(repository.updateMeasurement(existing)).thenReturn(true); MeasurementBatchCommand command = new MeasurementBatchCommand( 1L, 10L, 100L, 3, "MANUAL", "PLATFORM", null, null, null, LocalDateTime.now(), null, "CKW-2", new BigDecimal("21"), new BigDecimal("0.2"), "服装", 9L, List.of(item(BigDecimal.ONE))); - InboundMeasurementEntity saved = service.saveBatch(command); - - assertThat(saved.getStatus()).isEqualTo("DRAFT"); - verify(repository).updateMeasurement(existing); - verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), - org.mockito.ArgumentMatchers.eq(100L), any()); + assertThatThrownBy(() -> service.saveBatch(command)) + .isInstanceOf(BizException.class) + .hasMessageContaining("已提交批次必须保持冻结"); + assertThat(existing.getStatus()).isEqualTo("SUBMITTED"); + verify(repository, never()).updateMeasurement(any()); + verify(repository, never()).replaceItems( + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), any()); } @Test From 6c2d169798e061dc80ccb2df20577dab58c5b48c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:24:48 +0800 Subject: [PATCH 31/56] feat: complete dropship palletization workflow --- AGENTS.md | 2 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 112 ++---- .../firstleg/client/OrderCargoClient.java | 5 +- ...atformDropshipPalletizationController.java | 41 ++- .../PlatformLoadingPdaController.java | 57 +-- .../PlatformSmartLoadingController.java | 4 + .../dto/DropshipPalletizationDtos.java | 55 ++- .../baiyue/firstleg/dto/LoadingListDtos.java | 4 + .../firstleg/dto/LoadingPalletDtos.java | 41 --- .../baiyue/firstleg/dto/LoadingPickDtos.java | 7 + .../baiyue/firstleg/dto/SmartLoadingDtos.java | 7 + .../entity/InboundCartonLabelEntity.java | 1 + .../entity/LoadingPlanItemEntity.java | 1 + .../entity/LoadingWorkPalletCartonEntity.java | 37 -- .../entity/LoadingWorkPalletEntity.java | 36 -- .../mapper/DropshipPalletizationMapper.java | 337 ++++++++---------- .../mapper/LoadingCompletedEventMapper.java | 4 +- .../mapper/LoadingListQueryMapper.java | 7 +- .../mapper/LoadingWorkPalletCartonMapper.java | 90 ----- .../mapper/LoadingWorkPalletMapper.java | 77 ---- .../mapper/SmartLoadingCandidateMapper.java | 4 +- .../repository/LoadingListRepository.java | 3 +- .../repository/SmartLoadingRepository.java | 12 + .../service/DropshipPalletizationService.java | 329 +++++++++-------- .../service/InboundCartonLabelService.java | 6 + .../firstleg/service/LoadingPickService.java | 54 ++- .../service/LoadingWorkPalletService.java | 210 ----------- .../MerchantInboundBillingService.java | 1 + .../firstleg/service/SmartLoadingService.java | 164 ++++++++- .../db/phase2-inbound-carton-label.sql | 1 + .../resources/db/phase2-inbound-schema.sql | 33 +- .../db/phase3-smart-loading-schema.sql | 75 +--- .../openapi/dropship-palletization-v1.yaml | 167 +++++++-- .../main/resources/openapi/firstleg-v1.yaml | 15 +- .../openapi/phase3-pda-convergence-v1.yaml | 93 ----- .../InboundMeasurementReviewContractTest.java | 3 +- ...tformLoadingPdaControllerContractTest.java | 9 +- .../UnifiedInboundOpenApiContractTest.java | 6 +- .../DropshipPalletizationSqlContractTest.java | 42 +++ .../InboundCartonLabelSqlContractTest.java | 3 + ...artLoadingCandidateMapperContractTest.java | 2 +- .../SmartLoadingSqlContractTest.java | 3 +- .../DropshipPalletizationServiceTest.java | 66 ++++ .../service/LoadingPickServiceTest.java | 3 + .../service/LoadingWorkPalletServiceTest.java | 87 ----- .../service/SmartLoadingServiceTest.java | 2 + .../client/FirstlegUnifiedOrderClient.java | 3 +- .../InternalOrderCargoController.java | 30 +- .../PlatformUnifiedOrderController.java | 2 +- .../baiyue/order/dto/UnifiedOrderQuery.java | 4 +- .../order/dto/UnifiedOrderResponse.java | 2 +- .../service/UnifiedOrderQueryService.java | 14 +- .../src/main/resources/openapi/order-v1.yaml | 21 +- .../InternalOrderCargoControllerTest.java | 61 ++++ .../UnifiedOrderApiContractTest.java | 26 +- .../service/UnifiedOrderQueryServiceTest.java | 68 +++- scripts/AGENTS.md | 2 +- scripts/README.md | 9 +- scripts/agents-index-contract.ps1 | 2 +- scripts/frontend-full-regression.ps1 | 30 +- .../full-regression-entry-contract.test.mjs | 8 + 61 files changed, 1242 insertions(+), 1358 deletions(-) delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPalletDtos.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletCartonEntity.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletEntity.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletCartonMapper.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletMapper.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java delete mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/LoadingWorkPalletServiceTest.java diff --git a/AGENTS.md b/AGENTS.md index 45cec9723..4033b96c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ | 海外仓排车查询、候选与智能预排 | `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` | | 海外异常退回与客户回访 | `lastleg-service/` | `exceptionorder/`、`customervisit/`;平台异常建档和关系解析、派送退回入仓、派送完成/状态、回访资料与附件均以 Lastleg 自身客户范围和状态为准 | -| 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | Order `InternalOrderCargoController` 提供强制 SKU;Firstleg `InboundCartonLabelService` 冻结 SKU、`LoadingWorkPalletService` 维护装柜工作托盘并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 | +| 一件代发 SKU/箱唛与暂扣跨区协同 | `order/`、`firstleg/`、`common-event-contract/`、`lastleg-service/` | Order `InternalOrderCargoController` 提供强制 SKU 与每箱数量;Firstleg `InboundCartonLabelService` 冻结箱唛 SKU,`DropshipPalletizationService` 完成清点、组托和封托,智能排柜只按 `SEALED` 整托分配,并消费 `OverseasInboundOrderDetainedChangedV1`;Lastleg `dropship/` 建立库存并发布暂扣变更 | | 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | | 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | | 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 | diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index ac44ef196..541d25995 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -2860,6 +2860,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变', + units_per_carton INT UNSIGNED NULL COMMENT '每箱SKU数量快照;一件代发必须为正数', detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照', inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', @@ -2877,7 +2878,6 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( PRIMARY KEY (id), UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code), UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index), - KEY idx_carton_label_active (tenant_id, inbound_id, batch_status, label_version), KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id), CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射'; @@ -5190,6 +5190,10 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item ( product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照', package_count INT UNSIGNED NOT NULL COMMENT '本次装柜箱数', product_quantity INT UNSIGNED NULL COMMENT '产品总数量', + unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE' COMMENT '分配单元:PACKAGE普通箱、SEALED_PALLET一件代发封托', + pallet_id BIGINT UNSIGNED NULL COMMENT '一件代发封托ID;普通箱为空', + pallet_no VARCHAR(32) NULL COMMENT '一件代发系统托盘码快照', + indivisible TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为不可拆整托单元', units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数', pallet_count INT UNSIGNED NULL COMMENT '托盘数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜毛重kg', @@ -5210,24 +5214,23 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item ( removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照', removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间', deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改', - active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS ( - CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END - ) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格', + active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END) STORED COMMENT '未删除明细查询键;删除后返回NULL', + active_assignment_key VARCHAR(96) GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN CONCAT(CASE WHEN pallet_id IS NULL THEN 'D:0' ELSE CONCAT('P:', pallet_id) END, ':', inbound_detail_id) ELSE NULL END) STORED 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 '装柜明细最后更新时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜明细最后更新时间', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), - UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id), + UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id, active_assignment_key), KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id), KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id), KEY idx_loading_item_account (account_id, created_at, id), KEY idx_loading_item_inbound (account_id, inbound_id, deleted), KEY idx_loading_item_candidate (account_id, inbound_id, inbound_detail_id, deleted), + KEY idx_loading_item_pallet (pallet_id, deleted), KEY idx_loading_item_order (account_id, order_id, deleted), CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), CONSTRAINT chk_loading_item_account CHECK (account_id > 0), @@ -5555,65 +5558,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history ( CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id) ) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史'; -CREATE TABLE IF NOT EXISTS fl_loading_work_pallet ( - id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场作业托盘ID', - pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID', - loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', - warehouse_pallet_id BIGINT UNSIGNED NULL COMMENT '仓库实物托盘ID;未绑定仓库资源时为空', - pallet_code VARCHAR(64) NOT NULL COMMENT '现场托盘号', - open_operation_id VARCHAR(128) NOT NULL COMMENT '开托幂等操作号', - seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作号', - pallet_status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '托盘状态:OPEN打托中、SEALED已封托、CANCELLED已取消', - created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', - created_by_name VARCHAR(64) NULL COMMENT '创建操作人名称快照', - sealed_by BIGINT UNSIGNED NULL COMMENT '封托操作人ID', - sealed_by_name VARCHAR(64) NULL COMMENT '封托操作人名称快照', - sealed_at DATETIME(3) NULL COMMENT '封托时间', - version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_loading_work_pallet_code (pick_task_id, pallet_code), - UNIQUE KEY uk_loading_work_pallet_open_operation (open_operation_id), - UNIQUE KEY uk_loading_work_pallet_seal_operation (seal_operation_id), - KEY idx_loading_work_pallet_plan (loading_plan_id, pallet_status, id), - CONSTRAINT fk_loading_work_pallet_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), - CONSTRAINT fk_loading_work_pallet_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), - CONSTRAINT chk_loading_work_pallet_status CHECK (pallet_status IN ('OPEN','SEALED','CANCELLED')) -) ENGINE=InnoDB COMMENT='一件代发PDA现场实际托盘;允许同托盘承载多个客户订单'; -CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton ( - id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场托盘箱唛绑定ID', - pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID', - work_pallet_id BIGINT UNSIGNED NOT NULL COMMENT '现场作业托盘ID', - loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划货物明细ID', - pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '箱唛所属客户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓单ID', - inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓货物明细ID', - carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID', - box_code VARCHAR(80) NOT NULL COMMENT '稳定箱唛箱码快照', - sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', - units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数量快照', - bound_by BIGINT UNSIGNED NOT NULL COMMENT '最后绑定操作人ID', - bound_by_name VARCHAR(64) NULL COMMENT '最后绑定操作人名称快照', - operation_id VARCHAR(128) NOT NULL COMMENT '最近一次绑定或移托幂等操作号', - version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次上托时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后移托时间', - PRIMARY KEY (id), - UNIQUE KEY uk_loading_work_carton_task_label (pick_task_id, carton_label_id), - UNIQUE KEY uk_loading_work_carton_operation (operation_id), - KEY idx_loading_work_carton_pallet (work_pallet_id, sku_code, id), - KEY idx_loading_work_carton_plan_item (loading_plan_item_id, id), - CONSTRAINT fk_loading_work_carton_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), - CONSTRAINT fk_loading_work_carton_pallet FOREIGN KEY (work_pallet_id) REFERENCES fl_loading_work_pallet (id), - CONSTRAINT fk_loading_work_carton_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id), - CONSTRAINT fk_loading_work_carton_pick_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id), - CONSTRAINT fk_loading_work_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label (id), - CONSTRAINT chk_loading_work_carton_units CHECK (units_per_carton IS NULL OR units_per_carton > 0) -) ENGINE=InnoDB COMMENT='一件代发PDA逐箱上托事实;客户订单不合并但实际托盘可混托'; DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys; DELIMITER $$ @@ -6276,12 +6221,13 @@ BEGIN 'destination_warehouse_name', 'product_name_zh', 'product_name_en', 'product_image_file_id', 'pallet_count', 'net_weight_kg', 'dimensional_weight_kg', 'product_quantity', 'units_per_carton', - 'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 17 THEN + 'unit_type', 'pallet_id', 'pallet_no', 'indivisible', 'active_assignment_key', + 'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 22 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失'; END IF; IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item' - AND index_name = 'uk_loading_item_plan_detail' AND non_unique = 0) <> 2 THEN + AND index_name = 'uk_loading_item_plan_assignment' AND non_unique = 0) <> 2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失'; END IF; IF (SELECT COUNT(*) FROM information_schema.columns @@ -17393,7 +17339,9 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_task ( warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', task_status VARCHAR(24) NOT NULL COMMENT '任务状态:PENDING待领取、CLAIMED已领取、COMPLETED已完成', total_cartons INT UNSIGNED NOT NULL COMMENT '测量确认总箱数', + counted_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已逐箱确认实收数量的箱数', allocated_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已上托箱数', + discrepancy_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已确认差异箱数', assignee_id BIGINT UNSIGNED NULL COMMENT '任务领取人ID', assignee_name VARCHAR(64) NULL COMMENT '任务领取人姓名快照', claimed_at DATETIME(3) NULL COMMENT '任务领取时间', @@ -17406,7 +17354,7 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_task ( KEY idx_dropship_task_operator (task_status,assignee_id,warehouse_id,created_at), CONSTRAINT fk_dropship_task_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order(id), CONSTRAINT chk_dropship_task_status CHECK (task_status IN ('PENDING','CLAIMED','COMPLETED')), - CONSTRAINT chk_dropship_task_cartons CHECK (allocated_cartons<=total_cartons) + CONSTRAINT chk_dropship_task_cartons CHECK (allocated_cartons<=counted_cartons AND counted_cartons<=total_cartons) ) ENGINE=InnoDB COMMENT='一件代发测量后PDA合托任务'; CREATE TABLE IF NOT EXISTS fl_dropship_pallet ( @@ -17415,8 +17363,8 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', - pallet_no VARCHAR(32) NULL COMMENT '封托时生成的DP托盘号', - pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN合托中、COMPLETED已封托', + pallet_no VARCHAR(32) NOT NULL COMMENT '现场扫描的系统DP托盘码', + pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN组托中、SEALED已封托', open_operation_id VARCHAR(128) NOT NULL COMMENT '创建托盘幂等操作ID', seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '托盘创建人ID', @@ -17433,7 +17381,7 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet ( UNIQUE KEY uk_dropship_pallet_seal_op (seal_operation_id), KEY idx_dropship_pallet_inbound (tenant_id,inbound_id,pallet_status), CONSTRAINT fk_dropship_pallet_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id), - CONSTRAINT chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','COMPLETED')) + CONSTRAINT chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','SEALED')) ) ENGINE=InnoDB COMMENT='一件代发合托形成的可打印托盘'; CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','id','柜次证据记录ID'); @@ -17500,23 +17448,33 @@ DROP TEMPORARY TABLE IF EXISTS tmp_table_comment_target; CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton ( id BIGINT UNSIGNED NOT NULL COMMENT '托盘箱明细ID', task_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托任务ID', - pallet_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托托盘ID', + pallet_id BIGINT UNSIGNED NULL COMMENT '所属组托托盘ID;已清点待绑托时为空', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID', inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '入仓货物明细ID', carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID', sku_code VARCHAR(128) NOT NULL COMMENT '箱唛冻结SKU编码', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', - box_code VARCHAR(64) NOT NULL COMMENT '稳定箱码', - operation_id VARCHAR(128) NOT NULL COMMENT '上托幂等操作ID', - bound_by BIGINT UNSIGNED NOT NULL COMMENT '上托操作人ID', + box_code VARCHAR(80) NOT NULL COMMENT '稳定箱码', + expected_sku_quantity INT UNSIGNED NOT NULL COMMENT '箱唛冻结的应收SKU数量', + actual_sku_quantity INT UNSIGNED NOT NULL COMMENT 'PDA确认的实收SKU数量', + discrepancy_status VARCHAR(24) NOT NULL COMMENT '差异状态:MATCH或ACCEPTED_VARIANCE', + discrepancy_remark VARCHAR(500) NULL COMMENT '差异接受说明', + count_operation_id VARCHAR(128) NOT NULL COMMENT '逐箱清点幂等操作ID', + bind_operation_id VARCHAR(128) NULL COMMENT '批量绑托幂等操作ID', + last_operation_id VARCHAR(128) NOT NULL COMMENT '最后一次绑托或换托操作幂等号', + counted_by BIGINT UNSIGNED NOT NULL COMMENT '清点操作人ID', + counted_by_name VARCHAR(64) NULL COMMENT '清点操作人姓名快照', + counted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清点时间', + bound_by BIGINT UNSIGNED NULL COMMENT '上托操作人ID', bound_by_name VARCHAR(64) NULL COMMENT '上托操作人姓名快照', - bound_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上托时间', + bound_at DATETIME(3) NULL COMMENT '上托时间', PRIMARY KEY (id), UNIQUE KEY uk_dropship_carton_task_label (task_id,carton_label_id), KEY idx_dropship_carton_pallet_sku (pallet_id,sku_code,id), KEY idx_dropship_carton_inbound (tenant_id,inbound_id,pallet_id), - KEY idx_dropship_carton_operation (operation_id), + UNIQUE KEY uk_dropship_carton_count_operation (count_operation_id), + KEY idx_dropship_carton_bind_operation (task_id,bind_operation_id), CONSTRAINT fk_dropship_carton_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id), CONSTRAINT fk_dropship_carton_pallet FOREIGN KEY (pallet_id) REFERENCES fl_dropship_pallet(id), CONSTRAINT fk_dropship_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java index ac30d0298..32554246d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java @@ -63,12 +63,13 @@ public interface OrderCargoClient { } record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, - String skuNo, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm, + String skuNo, Integer packageCount, Integer unitsPerCarton, + BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm) { public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm) { - this(cargoId, orderId, itemNo, cargoName, null, packageCount, lengthCm, widthCm, heightCm); + this(cargoId, orderId, itemNo, cargoName, null, packageCount, null, lengthCm, widthCm, heightCm); } } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java index 337dc2fb0..62f93446e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java @@ -62,23 +62,44 @@ public class PlatformDropshipPalletizationController { return ApiResponse.success(service.claim(taskId, request.warehouseId(), user.userId(), user.username())); } - @PostMapping("/tasks/{taskId}/pallets/open") - @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") - public ApiResponse open(@AuthenticationPrincipal AuthenticatedUser user, + @GetMapping("/tasks/{taskId}/cartons/resolve") + @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") + public ApiResponse resolveCarton( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long taskId, - @Valid @RequestBody DropshipPalletizationDtos.OpenPalletRequest request) { + @RequestParam @Positive long warehouseId, + @RequestParam @Size(max = 80) String boxCode) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.open(taskId, request, user.userId(), user.username())); + return ApiResponse.success(service.resolveCarton(taskId, warehouseId, boxCode, user.userId())); } - @PostMapping("/tasks/{taskId}/pallets/{palletId}/scan") + @PostMapping("/tasks/{taskId}/cartons/count") @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") - public ApiResponse scan( + public ApiResponse countCarton( @AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long taskId, @PathVariable @Positive long palletId, - @Valid @RequestBody DropshipPalletizationDtos.ScanRequest request) { + @PathVariable @Positive long taskId, + @Valid @RequestBody DropshipPalletizationDtos.CountCartonRequest request) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.scan(taskId, palletId, request, user.userId(), user.username())); + return ApiResponse.success(service.countCarton(taskId, request, user.userId(), user.username())); + } + + @PostMapping("/tasks/{taskId}/pallets/bind-pending") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse bindPending(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @Valid @RequestBody DropshipPalletizationDtos.BindPendingRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.bindPending(taskId, request, user.userId(), user.username())); + } + + @PostMapping("/tasks/{taskId}/cartons/{cartonId}/move") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse moveCarton(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @PathVariable @Positive long cartonId, + @Valid @RequestBody DropshipPalletizationDtos.MoveCartonRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.moveCarton(taskId, cartonId, request, user.userId(), user.username())); } @PostMapping("/tasks/{taskId}/pallets/{palletId}/seal") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java index 0449991c8..81cedd891 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.PalletPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest; @@ -24,11 +25,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.http.MediaType; import org.springframework.web.multipart.MultipartFile; import jakarta.validation.constraints.PositiveOrZero; -import com.baiyue.firstleg.service.LoadingWorkPalletService; -import com.baiyue.firstleg.dto.LoadingPalletDtos.OpenRequest; -import com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest; -import com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest; -import com.baiyue.firstleg.dto.LoadingPalletDtos.Pallet; import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -56,8 +52,6 @@ public class PlatformLoadingPdaController { private LoadingPickService service; @Resource private CabinetLoadingEvidenceService evidenceService; - @Resource - private LoadingWorkPalletService palletService; @GetMapping("/tasks") @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") @@ -168,6 +162,15 @@ public class PlatformLoadingPdaController { return ApiResponse.success(service.pick(taskId, itemId, request, user.userId(), user.username())); } + @PostMapping("/tasks/{taskId}/pallets/pick") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse pickPallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @Valid @RequestBody PalletPickRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.pickPallet(taskId, request, user.userId(), user.username())); + } + @PostMapping("/tasks/{taskId}/items/{itemId}/partial") @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") public ApiResponse partial(@AuthenticationPrincipal AuthenticatedUser user, @@ -226,44 +229,4 @@ public class PlatformLoadingPdaController { return ApiResponse.success(service.operations(taskId)); } - @GetMapping("/tasks/{taskId}/pallets") - @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") - public ApiResponse> pallets(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long taskId, - @RequestParam @Positive long warehouseId) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(palletService.list(taskId, warehouseId)); - } - - @PostMapping("/tasks/{taskId}/pallets/open") - @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") - public ApiResponse openPallet(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long taskId, - @Valid @RequestBody OpenRequest request) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(palletService.open(taskId, request, user.userId(), user.username())); - } - - /** 扫到其他未封托托盘上的箱唛时,同时完成按箱移托。 */ - @PostMapping("/tasks/{taskId}/pallets/{palletId}/cartons/scan") - @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") - public ApiResponse scanCarton(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long taskId, - @PathVariable @Positive long palletId, - @Valid @RequestBody ScanRequest request) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(palletService.scan(taskId, palletId, request, - user.userId(), user.username())); - } - - @PostMapping("/tasks/{taskId}/pallets/{palletId}/seal") - @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") - public ApiResponse sealPallet(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long taskId, - @PathVariable @Positive long palletId, - @Valid @RequestBody SealRequest request) { - FirstlegScopeGuard.platform(user); - return ApiResponse.success(palletService.seal(taskId, palletId, request, - user.userId(), user.username())); - } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java index 0772819e6..b1cf08c53 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java @@ -24,6 +24,10 @@ public class PlatformSmartLoadingController { public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));} @PostMapping("/plans/{planId}/inbound-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") public ApiResponse assignInbound(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignInboundRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignInbound(planId,r,u.userId(),u.username()));} + @PostMapping("/plans/{planId}/pallet-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") + public ApiResponse assignPallet(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignPalletRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignPallet(planId,r,u.userId(),u.username()));} + @DeleteMapping("/plans/{planId}/pallet-assignments/{palletId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')") + public ApiResponse removePallet(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long palletId,@Valid @RequestBody RemovePalletRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.removePallet(planId,palletId,r,u.userId(),u.username()));} @PostMapping("/plans/{planId}/pending-review-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") public ApiResponse assignPendingReview(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignPendingReviewRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignPendingReview(planId,r,u.userId(),u.username()));} @PostMapping("/plans/{planId}/group-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java index f03398ba2..75027404b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java @@ -1,7 +1,8 @@ package com.baiyue.firstleg.dto; -import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; @@ -14,13 +15,23 @@ public final class DropshipPalletizationDtos { public record ClaimRequest(@Positive long warehouseId) {} - public record OpenPalletRequest(@Positive long warehouseId, - @NotBlank @Size(max = 128) String operationId) {} + public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE } - public record ScanRequest(@Positive long warehouseId, - @NotBlank @Size(max = 64) String boxCode, - @Positive @Max(2000) int cartonCount, - @NotBlank @Size(max = 128) String operationId) {} + public record CountCartonRequest(@Positive long warehouseId, + @NotBlank @Size(max = 80) String boxCode, + @Positive int actualSkuQuantity, + @NotNull DiscrepancyAction discrepancyAction, + @Size(max = 500) String discrepancyRemark, + @Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo, + @NotBlank @Size(max = 128) String operationId) {} + + public record BindPendingRequest(@Positive long warehouseId, + @NotBlank @Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo, + @NotBlank @Size(max = 128) String operationId) {} + + public record MoveCartonRequest(@Positive long warehouseId, + @Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String targetPalletNo, + @NotBlank @Size(max = 128) String operationId) {} public record SealPalletRequest(@Positive long warehouseId, @NotBlank @Size(max = 128) String operationId, @@ -28,24 +39,34 @@ public final class DropshipPalletizationDtos { public record PendingTask(Long taskId, Long inboundId, String inboundCode, Long tenantId, String customerName, Long warehouseId, String warehouseName, - Integer totalCartons, Integer allocatedCartons, String taskStatus, + Integer totalCartons, Integer countedCartons, Integer boundCartons, + Integer unboundCartons, Integer discrepancyCartons, String taskStatus, LocalDateTime createdAt) {} public record Task(Long taskId, Long inboundId, String inboundCode, Long tenantId, String customerName, Long warehouseId, String warehouseName, - Integer totalCartons, Integer allocatedCartons, String taskStatus, + Integer totalCartons, Integer countedCartons, Integer boundCartons, + Integer unboundCartons, Integer discrepancyCartons, String taskStatus, Long assigneeId, String assigneeName, Integer version, - List pallets) {} + List pallets, List unbound) {} - public record Pallet(Long palletId, String palletNo, String palletStatus, - Integer cartonCount, Integer skuCount, Integer version, - List items, LocalDateTime completedAt) {} + public record Pallet(Long palletId, String palletNo, String status, + Integer cartonCount, Integer skuCount, Integer discrepancyCartons, + java.math.BigDecimal weightKg, java.math.BigDecimal volumeCbm, + Integer version, List items, List cartons, + LocalDateTime sealedAt) {} public record SkuItem(Long inboundId, String inboundCode, Long inboundDetailId, - String skuCode, String cargoName, Integer cartonCount, + String skuCode, String cargoName, Integer cartonCount, Integer skuQuantity, List cartonMarks) {} - public record ScanResult(String skuCode, String cargoName, String inboundCode, - int addedCartons, int allocatedCartons, int remainingCartons, - Pallet pallet) {} + public record Carton(Long cartonId, String boxCode, Long inboundId, String inboundCode, + Long inboundDetailId, String skuCode, String cargoName, + Integer expectedSkuQuantity, Integer actualSkuQuantity, + String discrepancyStatus, String discrepancyRemark, + String countStatus, Long palletId, String palletNo, + Boolean canCount, String blockReason) {} + + public record CountResult(Carton carton, Integer countedCartons, Integer boundCartons, + Integer unboundCartons, Integer remainingCartons, Pallet pallet) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index 58dbe8e4e..516115411 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -188,6 +188,10 @@ public final class LoadingListDtos { String chineseName, String englishName, String goodsImageUrl, + String unitType, + Long palletId, + String palletNo, + Boolean indivisible, int packageCount, int palletCount, BigDecimal grossWeightKg, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPalletDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPalletDtos.java deleted file mode 100644 index 3bb836838..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPalletDtos.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.baiyue.firstleg.dto; - -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -import java.util.List; - -/** 一件代发PDA现场打托接口模型。 */ -public final class LoadingPalletDtos { - private LoadingPalletDtos() {} - - public record OpenRequest( - @NotNull @Min(1) Long warehouseId, - @Min(1) Long warehousePalletId, - @NotBlank @Size(max = 64) String palletCode, - @NotBlank @Size(max = 128) String operationId - ) {} - - public record ScanRequest( - @NotNull @Min(1) Long warehouseId, - @NotBlank @Size(max = 80) String boxCode, - @NotBlank @Size(max = 128) String operationId - ) {} - - public record SealRequest( - @NotNull @Min(1) Long warehouseId, - @NotNull @Min(0) Integer expectedVersion, - @NotBlank @Size(max = 128) String operationId - ) {} - - public record Carton(Long id, String boxCode, String skuCode, Long tenantId, - Long inboundId, Long inboundDetailId, Integer unitsPerCarton) {} - - public record SkuSummary(String skuCode, Integer cartonCount, Long productQuantity) {} - - public record Pallet(Long id, String palletCode, Long warehousePalletId, String status, - Integer version, Integer cartonCount, Integer skuCount, - Integer customerCount, List skus, List cartons) {} -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java index bb7818725..436094503 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -23,6 +23,13 @@ public final class LoadingPickDtos { @NotBlank @Size(max = 128) String operationId ) {} + public record PalletPickRequest( + @NotNull @Min(1) Long warehouseId, + @NotBlank @Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo, + @NotNull @Min(0) Integer expectedTaskVersion, + @NotBlank @Size(max = 128) String operationId + ) {} + /** 部分拣货为该 PDA 明细的最终实际拣出箱数,不是增量。 */ public record PartialPickRequest( @NotNull @Min(1) Long warehouseId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index d8b8048fc..acca35f80 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -22,11 +22,14 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} public record GroupMemberCandidate(Long accountId, Long inboundId, Long orderId, String orderNo, String inboundCode, String customerName, Integer availablePackageCount, String orderStatusSnapshot, LocalDateTime expectedArrivalAt) {} + public record PalletCandidate(Long palletId,String palletNo,String unitType,Boolean indivisible, + Integer cartonCount,Integer skuCount,BigDecimal weightKg,BigDecimal volumeCbm) {} public record InboundCandidate(Long accountId, Long inboundId, Long orderId, String orderNo, String inboundCode, Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsSummary, Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm, + String unitType,Long palletId,String palletNo,Boolean indivisible, Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes, Boolean locked, Boolean canAssign, String blockReason, String orderStatusSnapshot, LocalDateTime expectedArrivalAt, @@ -66,6 +69,10 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} public record AssignGroupRequest(@NotNull @Positive Long inboundId, @NotNull @Min(0) Integer expectedVersion, @NotBlank @Size(max=128) String operationId) {} + public record AssignPalletRequest(@NotNull @Positive Long palletId,@NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max=128) String operationId) {} + public record RemovePalletRequest(@NotNull @Min(0) Integer expectedPlanVersion, + @NotBlank @Size(max=128) String operationId) {} public record RemoveGroupRequest(@NotNull @Min(0) Integer expectedPlanVersion, @NotBlank @Size(max=128) String operationId) {} public record RecommendRequest(@NotNull @Min(0) Integer expectedVersion, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundCartonLabelEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundCartonLabelEntity.java index 6e9d7d252..7703c74d3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundCartonLabelEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundCartonLabelEntity.java @@ -23,6 +23,7 @@ public class InboundCartonLabelEntity { private Integer cargoItemNo; private String cargoName; private String skuCode; + private Integer unitsPerCarton; private Integer detailPackageCount; private String inboundCode; private String boxCode; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java index 0f12680ca..967b686da 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingPlanItemEntity.java @@ -6,6 +6,7 @@ public class LoadingPlanItemEntity { private Long inboundId; private Long inboundDetailId; private Long orderId; private String orderNo; private String inboundCode; private Long destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; private String skuCode; private String productNameZh; private String productNameEn; private Long productImageFileId; private String productImageUrl; private Integer packageCount; private Integer productQuantity; private Integer unitsPerCarton; + private String unitType; private Long palletId; private String palletNo; private Boolean indivisible; private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; private String fbaTrackingNo; private String shipmentNo; private String shippingMark; private Integer loadSequence; private String positionSnapshot; private String internalRemark; private String customerRemark; private String warehouseRemark; private LocalDateTime frozenAt; private Long frozenBy; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletCartonEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletCartonEntity.java deleted file mode 100644 index a0072c7bc..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletCartonEntity.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.baiyue.firstleg.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import com.baomidou.mybatisplus.annotation.Version; -import lombok.Getter; -import lombok.Setter; - -import java.time.LocalDateTime; - -/** 一件代发箱唛与现场实际托盘的当前绑定事实。 */ -@Getter -@Setter -@TableName("fl_loading_work_pallet_carton") -public class LoadingWorkPalletCartonEntity { - @TableId(type = IdType.ASSIGN_ID) - private Long id; - private Long pickTaskId; - private Long workPalletId; - private Long loadingPlanItemId; - private Long pickItemId; - private Long tenantId; - private Long inboundId; - private Long inboundDetailId; - private Long cartonLabelId; - private String boxCode; - private String skuCode; - private Integer unitsPerCarton; - private Long boundBy; - private String boundByName; - private String operationId; - @Version - private Integer version; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletEntity.java deleted file mode 100644 index 169f984e2..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletEntity.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.baiyue.firstleg.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import com.baomidou.mybatisplus.annotation.Version; -import lombok.Getter; -import lombok.Setter; - -import java.time.LocalDateTime; - -/** 一件代发装柜现场的实际托盘。 */ -@Getter -@Setter -@TableName("fl_loading_work_pallet") -public class LoadingWorkPalletEntity { - @TableId(type = IdType.ASSIGN_ID) - private Long id; - private Long pickTaskId; - private Long loadingPlanId; - private Long warehouseId; - private Long warehousePalletId; - private String palletCode; - private String openOperationId; - private String sealOperationId; - private String palletStatus; - private Long createdBy; - private String createdByName; - private Long sealedBy; - private String sealedByName; - private LocalDateTime sealedAt; - @Version - private Integer version; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java index 86d5266e7..10bf417c4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java @@ -1,203 +1,182 @@ package com.baiyue.firstleg.mapper; import com.baiyue.firstleg.dto.DropshipPalletizationDtos.PendingTask; +import com.baiyue.firstleg.dto.SmartLoadingDtos.PalletCandidate; import lombok.Getter; import lombok.Setter; -import org.apache.ibatis.annotations.Insert; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; -import org.apache.ibatis.annotations.Update; +import org.apache.ibatis.annotations.*; import java.time.LocalDateTime; +import java.math.BigDecimal; import java.util.List; -/** 一件代发合托持久化;任务、托盘和逐箱绑定均独立于装柜拣货。 */ +/** 一件代发逐箱清点、待绑托与封托事实。 */ @Mapper public interface DropshipPalletizationMapper { - @Insert(""" - INSERT IGNORE INTO fl_dropship_pallet_task - (id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,allocated_cartons,version,created_at,updated_at) - VALUES(#{id},#{tenantId},#{inboundId},#{warehouseId},'PENDING',#{totalCartons},0,1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) - """) - int insertTask(@Param("id") long id, @Param("tenantId") long tenantId, - @Param("inboundId") long inboundId, @Param("warehouseId") long warehouseId, - @Param("totalCartons") int totalCartons); + @Insert(""" + INSERT IGNORE INTO fl_dropship_pallet_task + (id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,counted_cartons,allocated_cartons,discrepancy_cartons,version,created_at,updated_at) + VALUES(#{id},#{tenantId},#{inboundId},#{warehouseId},'PENDING',#{totalCartons},0,0,0,1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))""") + int insertTask(@Param("id") long id,@Param("tenantId") long tenantId,@Param("inboundId") long inboundId, + @Param("warehouseId") long warehouseId,@Param("totalCartons") int totalCartons); - @Select(""" - SELECT t.id task_id,t.inbound_id,o.inbound_code,t.tenant_id,o.customer_name,t.warehouse_id, - o.warehouse_name,t.total_cartons,t.allocated_cartons,t.task_status,t.created_at - FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id - WHERE t.task_status IN ('PENDING','CLAIMED') - AND (t.assignee_id IS NULL OR t.assignee_id=#{operatorId}) - AND (#{keyword} IS NULL OR o.inbound_code LIKE CONCAT('%',#{keyword},'%') - OR o.customer_name LIKE CONCAT('%',#{keyword},'%')) - ORDER BY t.created_at,t.id - """) - List pending(@Param("operatorId") long operatorId, @Param("keyword") String keyword); + @Select(""" + SELECT t.id task_id,t.inbound_id,o.inbound_code,t.tenant_id,o.customer_name,t.warehouse_id, + o.warehouse_name,t.total_cartons,t.counted_cartons,t.allocated_cartons bound_cartons, + t.counted_cartons-t.allocated_cartons unbound_cartons,t.discrepancy_cartons,t.task_status,t.created_at + FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id + WHERE t.task_status IN ('PENDING','CLAIMED') AND (t.assignee_id IS NULL OR t.assignee_id=#{operatorId}) + AND (#{keyword} IS NULL OR o.inbound_code LIKE CONCAT('%',#{keyword},'%') OR o.customer_name LIKE CONCAT('%',#{keyword},'%')) + ORDER BY t.created_at,t.id""") + List pending(@Param("operatorId") long operatorId,@Param("keyword") String keyword); - @Select(""" - SELECT t.*,o.inbound_code,o.customer_name,o.warehouse_name,o.process_status,o.fulfillment_type - FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id - WHERE t.id=#{taskId} AND t.warehouse_id=#{warehouseId} - """) - TaskRow task(@Param("taskId") long taskId, @Param("warehouseId") long warehouseId); + @Select(""" + SELECT t.*,o.inbound_code,o.customer_name,o.warehouse_name,o.process_status,o.fulfillment_type + FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id + WHERE t.id=#{taskId} AND t.warehouse_id=#{warehouseId}""") + TaskRow task(@Param("taskId") long taskId,@Param("warehouseId") long warehouseId); - @Update(""" - UPDATE fl_dropship_pallet_task - SET task_status='CLAIMED',assignee_id=#{operatorId},assignee_name=#{operatorName}, - claimed_at=COALESCE(claimed_at,CURRENT_TIMESTAMP(3)),version=version+1,updated_at=CURRENT_TIMESTAMP(3) - WHERE id=#{taskId} AND warehouse_id=#{warehouseId} AND task_status IN ('PENDING','CLAIMED') - AND (assignee_id IS NULL OR assignee_id=#{operatorId}) - """) - int claim(@Param("taskId") long taskId, @Param("warehouseId") long warehouseId, - @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + @Update(""" + UPDATE fl_dropship_pallet_task SET task_status='CLAIMED',assignee_id=#{operatorId},assignee_name=#{operatorName}, + claimed_at=COALESCE(claimed_at,CURRENT_TIMESTAMP(3)),version=version+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{taskId} AND warehouse_id=#{warehouseId} AND task_status IN ('PENDING','CLAIMED') + AND (assignee_id IS NULL OR assignee_id=#{operatorId})""") + int claim(@Param("taskId") long taskId,@Param("warehouseId") long warehouseId, + @Param("operatorId") long operatorId,@Param("operatorName") String operatorName); - @Insert(""" - INSERT INTO fl_dropship_pallet - (id,task_id,tenant_id,inbound_id,warehouse_id,pallet_status,open_operation_id,version,created_by,created_by_name,created_at,updated_at) - VALUES(#{id},#{taskId},#{tenantId},#{inboundId},#{warehouseId},'OPEN',#{operationId},1, - #{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) - """) - int insertPallet(@Param("id") long id, @Param("taskId") long taskId, - @Param("tenantId") long tenantId, @Param("inboundId") long inboundId, - @Param("warehouseId") long warehouseId, @Param("operationId") String operationId, - @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + @Insert(""" + INSERT INTO fl_dropship_pallet + (id,task_id,tenant_id,inbound_id,warehouse_id,pallet_no,pallet_status,open_operation_id,version,created_by,created_by_name,created_at,updated_at) + VALUES(#{id},#{taskId},#{tenantId},#{inboundId},#{warehouseId},#{palletNo},'OPEN',#{operationId},1, + #{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))""") + int insertPallet(@Param("id") long id,@Param("taskId") long taskId,@Param("tenantId") long tenantId, + @Param("inboundId") long inboundId,@Param("warehouseId") long warehouseId,@Param("palletNo") String palletNo, + @Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); - @Select("SELECT * FROM fl_dropship_pallet WHERE open_operation_id=#{operationId}") - PalletRow palletByOpenOperation(@Param("operationId") String operationId); + @Select("SELECT * FROM fl_dropship_pallet WHERE pallet_no=#{palletNo}") PalletRow palletByNo(@Param("palletNo") String palletNo); + @Select("SELECT * FROM fl_dropship_pallet WHERE seal_operation_id=#{operationId}") PalletRow palletBySealOperation(@Param("operationId") String operationId); + @Select("SELECT * FROM fl_dropship_pallet WHERE id=#{palletId} AND task_id=#{taskId} AND warehouse_id=#{warehouseId}") + PalletRow pallet(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("warehouseId") long warehouseId); + @Select("SELECT * FROM fl_dropship_pallet WHERE task_id=#{taskId} ORDER BY id") List pallets(@Param("taskId") long taskId); + @Select("SELECT p.* FROM fl_dropship_pallet p JOIN fl_dropship_pallet_task t ON t.id=p.task_id WHERE t.tenant_id=#{tenantId} AND t.inbound_id=#{inboundId} ORDER BY p.id") + List palletsByInbound(@Param("tenantId") long tenantId,@Param("inboundId") long inboundId); + @Select("SELECT inbound_code FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0") + String inboundCode(@Param("tenantId") long tenantId,@Param("inboundId") long inboundId); - @Select("SELECT * FROM fl_dropship_pallet WHERE seal_operation_id=#{operationId}") - PalletRow palletBySealOperation(@Param("operationId") String operationId); + @Select(""" + SELECT l.id carton_label_id,l.inbound_detail_id,l.sku_code,l.cargo_name,l.units_per_carton,l.inbound_code,l.box_code + FROM fl_inbound_carton_label l JOIN fl_dropship_pallet_task t ON t.inbound_id=l.inbound_id AND t.tenant_id=l.tenant_id + WHERE t.id=#{taskId} AND l.box_code=#{boxCode} AND l.batch_status='ACTIVE'""") + LabelRow label(@Param("taskId") long taskId,@Param("boxCode") String boxCode); + @Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.count_operation_id=#{operationId}") CartonRow cartonByCountOperation(@Param("operationId") String operationId); + @Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.id=#{cartonId}") CartonRow carton(@Param("taskId") long taskId,@Param("cartonId") long cartonId); + @Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.carton_label_id=#{labelId}") + CartonRow countedByLabel(@Param("taskId") long taskId,@Param("labelId") long labelId); - @Select("SELECT * FROM fl_dropship_pallet WHERE id=#{palletId} AND task_id=#{taskId} AND warehouse_id=#{warehouseId}") - PalletRow pallet(@Param("taskId") long taskId, @Param("palletId") long palletId, - @Param("warehouseId") long warehouseId); + @Insert(""" + INSERT INTO fl_dropship_pallet_carton + (id,task_id,pallet_id,tenant_id,inbound_id,inbound_detail_id,carton_label_id,sku_code,cargo_name,box_code, + expected_sku_quantity,actual_sku_quantity,discrepancy_status,discrepancy_remark,count_operation_id,last_operation_id, + counted_by,counted_by_name,counted_at,bound_by,bound_by_name,bound_at) + VALUES(#{id},#{taskId},#{palletId},#{tenantId},#{inboundId},#{detailId},#{labelId},#{skuCode},#{cargoName},#{boxCode}, + #{expectedQuantity},#{actualQuantity},#{discrepancyStatus},#{discrepancyRemark},#{operationId},#{operationId}, + #{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),CASE WHEN #{palletId} IS NULL THEN NULL ELSE #{operatorId} END, + CASE WHEN #{palletId} IS NULL THEN NULL ELSE #{operatorName} END,CASE WHEN #{palletId} IS NULL THEN NULL ELSE CURRENT_TIMESTAMP(3) END)""") + int insertCarton(@Param("id") long id,@Param("taskId") long taskId,@Param("palletId") Long palletId, + @Param("tenantId") long tenantId,@Param("inboundId") long inboundId,@Param("detailId") long detailId, + @Param("labelId") long labelId,@Param("skuCode") String skuCode,@Param("cargoName") String cargoName, + @Param("boxCode") String boxCode,@Param("expectedQuantity") int expectedQuantity,@Param("actualQuantity") int actualQuantity, + @Param("discrepancyStatus") String discrepancyStatus,@Param("discrepancyRemark") String discrepancyRemark, + @Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); - @Select("SELECT * FROM fl_dropship_pallet WHERE task_id=#{taskId} ORDER BY id") - List pallets(@Param("taskId") long taskId); + @Update(""" + UPDATE fl_dropship_pallet_task SET counted_cartons=counted_cartons+1,allocated_cartons=allocated_cartons+#{boundCount}, + discrepancy_cartons=discrepancy_cartons+#{discrepancyCount},version=version+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{taskId} AND task_status='CLAIMED' AND assignee_id=#{operatorId} AND counted_cartons#{operationId}""") + int moveCarton(@Param("taskId") long taskId,@Param("cartonId") long cartonId,@Param("targetPalletId") Long targetPalletId, + @Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); - @Select(""" - SELECT p.* FROM fl_dropship_pallet p JOIN fl_dropship_pallet_task t ON t.id=p.task_id - WHERE t.tenant_id=#{tenantId} AND t.inbound_id=#{inboundId} ORDER BY p.id - """) - List palletsByInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + @Update(""" + UPDATE fl_dropship_pallet SET pallet_status='SEALED',seal_operation_id=#{operationId},completed_by=#{operatorId}, + completed_by_name=#{operatorName},completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{palletId} AND task_id=#{taskId} AND pallet_status='OPEN' AND version=#{expectedVersion} + AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId}) + AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.task_id=#{taskId} AND c.pallet_id IS NULL)""") + int seal(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("expectedVersion") int expectedVersion, + @Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); + @Select("SELECT COUNT(*) FROM fl_dropship_pallet WHERE task_id=#{taskId} AND pallet_status='OPEN'") int openPalletCount(@Param("taskId") long taskId); + @Update(""" + UPDATE fl_dropship_pallet_task SET task_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{taskId} AND task_status='CLAIMED' AND counted_cartons=total_cartons AND allocated_cartons=total_cartons + AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet p WHERE p.task_id=#{taskId} AND p.pallet_status='OPEN')""") int completeTask(@Param("taskId") long taskId); + @Update(""" + UPDATE fl_inbound_order o SET o.process_status='WAIT_SPLIT',o.pallet_count=(SELECT COUNT(*) FROM fl_dropship_pallet p + WHERE p.inbound_id=o.id AND p.pallet_status='SEALED'),o.updated_by=#{operatorId},o.version=o.version+1 + WHERE o.id=#{inboundId} AND o.tenant_id=#{tenantId} AND o.process_status='WAIT_PALLETIZE' + AND EXISTS(SELECT 1 FROM fl_dropship_pallet_task t WHERE t.id=#{taskId} AND t.task_status='COMPLETED')""") + int completeInbound(@Param("taskId") long taskId,@Param("tenantId") long tenantId,@Param("inboundId") long inboundId,@Param("operatorId") long operatorId); - @Select("SELECT inbound_code FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0") - String inboundCode(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + @Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.pallet_id=#{palletId} ORDER BY c.sku_code,c.inbound_detail_id,c.id") List cartons(@Param("palletId") long palletId); + @Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.pallet_id IS NULL ORDER BY c.counted_at,c.id") List unboundCartons(@Param("taskId") long taskId); + @Select(""" + SELECT COALESCE(o.actual_gross_weight_kg*COUNT(c.id)/NULLIF(o.total_package_count,0),0) weight_kg, + COALESCE(o.actual_volume_cbm*COUNT(c.id)/NULLIF(o.total_package_count,0),0) volume_cbm + FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id + LEFT JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id WHERE p.id=#{palletId} + GROUP BY p.id,o.actual_gross_weight_kg,o.actual_volume_cbm,o.total_package_count""") + PalletMetrics metrics(@Param("palletId") long palletId); + @Select(""" + SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(c.id) carton_count, + COUNT(DISTINCT c.sku_code) sku_count, + COALESCE(o.actual_gross_weight_kg*COUNT(c.id)/NULLIF(o.total_package_count,0),0) weight_kg, + COALESCE(o.actual_volume_cbm*COUNT(c.id)/NULLIF(o.total_package_count,0),0) volume_cbm + FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id + JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id + WHERE p.inbound_id=#{inboundId} AND p.pallet_status='SEALED' + AND NOT EXISTS(SELECT 1 FROM fl_loading_plan_item i WHERE i.pallet_id=p.id AND i.deleted=0) + GROUP BY p.id,p.pallet_no,o.actual_gross_weight_kg,o.actual_volume_cbm,o.total_package_count ORDER BY p.id + """) List palletCandidates(@Param("inboundId") long inboundId); - @Select(""" - SELECT l.id carton_label_id,l.inbound_detail_id,l.sku_code,l.cargo_name,l.detail_package_count, - l.inbound_code,l.box_code - FROM fl_inbound_carton_label l - JOIN fl_dropship_pallet_task t ON t.inbound_id=l.inbound_id AND t.tenant_id=l.tenant_id - WHERE t.id=#{taskId} AND l.box_code=#{boxCode} AND l.batch_status='ACTIVE' - """) - LabelRow label(@Param("taskId") long taskId, @Param("boxCode") String boxCode); + @Select(""" + SELECT p.id pallet_id,p.pallet_no,p.pallet_status,p.tenant_id account_id,p.inbound_id,o.order_id,o.order_no, + o.inbound_code,o.warehouse_id,o.channel_code,o.channel_name,o.destination_warehouse_id, + o.destination_code destination_warehouse_code,o.destination_name destination_warehouse_name,o.customer_name, + c.inbound_detail_id,c.sku_code,MAX(c.cargo_name) cargo_name,COUNT(c.id) carton_count, + SUM(c.actual_sku_quantity) actual_sku_quantity, + COALESCE(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0),0) unit_gross_weight_kg, + COALESCE(o.actual_net_weight_kg/NULLIF(o.total_package_count,0),0) unit_net_weight_kg, + COALESCE(o.actual_volume_cbm/NULLIF(o.total_package_count,0),0) unit_volume_cbm + FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id + JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id WHERE p.id=#{palletId} + GROUP BY p.id,p.pallet_no,p.pallet_status,p.tenant_id,p.inbound_id,o.order_id,o.order_no,o.inbound_code, + o.warehouse_id,o.channel_code,o.channel_name,o.destination_warehouse_id,o.destination_code,o.destination_name, + o.customer_name,c.inbound_detail_id,c.sku_code,o.actual_gross_weight_kg,o.actual_net_weight_kg,o.actual_volume_cbm,o.total_package_count + ORDER BY c.inbound_detail_id + """) List sealedPalletLines(@Param("palletId") long palletId); - @Select(""" - SELECT l.id carton_label_id,l.inbound_detail_id,l.sku_code,l.cargo_name,l.detail_package_count, - l.inbound_code,l.box_code - FROM fl_inbound_carton_label l - WHERE l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId} - AND l.inbound_detail_id=#{detailId} AND l.batch_status='ACTIVE' - AND NOT EXISTS (SELECT 1 FROM fl_dropship_pallet_carton c - WHERE c.task_id=#{taskId} AND c.carton_label_id=l.id) - ORDER BY CASE WHEN l.box_code=#{preferredBoxCode} THEN 0 ELSE 1 END,l.carton_index,l.id - LIMIT #{count} - """) - List unboundLabels(@Param("taskId") long taskId, @Param("tenantId") long tenantId, - @Param("inboundId") long inboundId, @Param("detailId") long detailId, - @Param("preferredBoxCode") String preferredBoxCode, - @Param("count") int count); - - @Select("SELECT COUNT(*) FROM fl_dropship_pallet_carton WHERE operation_id=#{operationId}") - int operationCount(@Param("operationId") String operationId); - - @Insert(""" - INSERT INTO fl_dropship_pallet_carton - (id,task_id,pallet_id,tenant_id,inbound_id,inbound_detail_id,carton_label_id,sku_code,cargo_name, - box_code,operation_id,bound_by,bound_by_name,bound_at) - VALUES(#{id},#{taskId},#{palletId},#{tenantId},#{inboundId},#{detailId},#{labelId},#{skuCode},#{cargoName}, - #{boxCode},#{operationId},#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3)) - """) - int insertCarton(@Param("id") long id, @Param("taskId") long taskId, - @Param("palletId") long palletId, @Param("tenantId") long tenantId, - @Param("inboundId") long inboundId, @Param("detailId") long detailId, - @Param("labelId") long labelId, @Param("skuCode") String skuCode, - @Param("cargoName") String cargoName, @Param("boxCode") String boxCode, - @Param("operationId") String operationId, @Param("operatorId") long operatorId, - @Param("operatorName") String operatorName); - - @Update(""" - UPDATE fl_dropship_pallet_task - SET allocated_cartons=allocated_cartons+#{count},version=version+1,updated_at=CURRENT_TIMESTAMP(3) - WHERE id=#{taskId} AND task_status='CLAIMED' AND assignee_id=#{operatorId} - AND allocated_cartons+#{count}<=total_cartons - """) - int addAllocated(@Param("taskId") long taskId, @Param("operatorId") long operatorId, - @Param("count") int count); - - @Update(""" - UPDATE fl_dropship_pallet - SET pallet_no=#{palletNo},pallet_status='COMPLETED',seal_operation_id=#{operationId}, - completed_by=#{operatorId},completed_by_name=#{operatorName},completed_at=CURRENT_TIMESTAMP(3), - version=version+1,updated_at=CURRENT_TIMESTAMP(3) - WHERE id=#{palletId} AND task_id=#{taskId} AND pallet_status='OPEN' AND version=#{expectedVersion} - AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId}) - """) - int seal(@Param("taskId") long taskId, @Param("palletId") long palletId, - @Param("expectedVersion") int expectedVersion, @Param("palletNo") String palletNo, - @Param("operationId") String operationId, @Param("operatorId") long operatorId, - @Param("operatorName") String operatorName); - - @Select("SELECT COUNT(*) FROM fl_dropship_pallet WHERE task_id=#{taskId} AND pallet_status='OPEN'") - int openPalletCount(@Param("taskId") long taskId); - - @Update(""" - UPDATE fl_dropship_pallet_task - SET task_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3) - WHERE id=#{taskId} AND task_status='CLAIMED' AND allocated_cartons=total_cartons - AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet p WHERE p.task_id=#{taskId} AND p.pallet_status='OPEN') - """) - int completeTask(@Param("taskId") long taskId); - - @Update(""" - UPDATE fl_inbound_order o - SET o.process_status='WAIT_SPLIT',o.pallet_count=(SELECT COUNT(*) FROM fl_dropship_pallet p - WHERE p.inbound_id=o.id AND p.pallet_status='COMPLETED'),o.updated_by=#{operatorId},o.version=o.version+1 - WHERE o.id=#{inboundId} AND o.tenant_id=#{tenantId} AND o.process_status='WAIT_PALLETIZE' - AND EXISTS(SELECT 1 FROM fl_dropship_pallet_task t WHERE t.id=#{taskId} AND t.task_status='COMPLETED') - """) - int completeInbound(@Param("taskId") long taskId, @Param("tenantId") long tenantId, - @Param("inboundId") long inboundId, @Param("operatorId") long operatorId); - - @Select(""" - SELECT c.* FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId} - ORDER BY c.sku_code,c.inbound_detail_id,c.id - """) - List cartons(@Param("palletId") long palletId); - - @Getter @Setter class TaskRow { - private Long id; private Long tenantId; private Long inboundId; private Long warehouseId; - private String inboundCode; private String customerName; private String warehouseName; - private String taskStatus; private Integer totalCartons; private Integer allocatedCartons; - private Long assigneeId; private String assigneeName; private Integer version; - private String processStatus; private String fulfillmentType; - } - @Getter @Setter class PalletRow { - private Long id; private Long taskId; private Long tenantId; private Long inboundId; - private Long warehouseId; private String palletNo; private String palletStatus; - private String openOperationId; private String sealOperationId; private Integer version; - private LocalDateTime completedAt; - } - @Getter @Setter class LabelRow { - private Long cartonLabelId; private Long inboundDetailId; private String skuCode; - private String cargoName; private Integer detailPackageCount; private String inboundCode; - private String boxCode; - } - @Getter @Setter class CartonRow { - private Long inboundId; private Long inboundDetailId; private String skuCode; - private String cargoName; private String boxCode; - } + @Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,version; } + @Getter @Setter class PalletRow { private Long id,taskId,tenantId,inboundId,warehouseId; private String palletNo,palletStatus,sealOperationId; private Integer version; private LocalDateTime completedAt; } + @Getter @Setter class LabelRow { private Long cartonLabelId,inboundDetailId; private String skuCode,cargoName,inboundCode,boxCode; private Integer unitsPerCarton; } + @Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; } + record PalletMetrics(BigDecimal weightKg,BigDecimal volumeCbm) {} + @Getter @Setter class PalletLine { private Long palletId,tenantId,accountId,inboundId,orderId,warehouseId,destinationWarehouseId,inboundDetailId; private String palletNo,palletStatus,orderNo,inboundCode,channelCode,channelName,destinationWarehouseCode,destinationWarehouseName,customerName,skuCode,cargoName; private Integer cartonCount,actualSkuQuantity; private BigDecimal unitGrossWeightKg,unitNetWeightKg,unitVolumeCbm; } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index dc62041cb..9d4493422 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -91,7 +91,7 @@ public interface LoadingCompletedEventMapper { JOIN fl_loading_plan_item item ON item.loading_plan_id=#{planId} AND item.account_id=carton.tenant_id AND item.inbound_id=carton.inbound_id AND item.inbound_detail_id=carton.inbound_detail_id AND item.deleted=0 - WHERE pallet.pallet_status='COMPLETED' + WHERE pallet.pallet_status='SEALED' ORDER BY carton.tenant_id,carton.inbound_id,pallet.id,item.inbound_detail_id,carton.id """) List actualPalletRows(@Param("planId") long planId); @@ -99,7 +99,7 @@ public interface LoadingCompletedEventMapper { @Select(""" SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=p.tenant_id - AND i.inbound_id=p.inbound_id AND i.deleted=0 WHERE p.pallet_status='COMPLETED' + AND i.inbound_id=p.inbound_id AND i.deleted=0 WHERE p.pallet_status='SEALED' """) int workPalletCount(@Param("planId") long planId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index 97c77cfaa..57b0de2fd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -171,7 +171,8 @@ public interface LoadingListQueryMapper { SELECT i.id AS item_id, i.account_id, i.customer_name, i.order_id, i.order_no, i.inbound_id, i.inbound_detail_id, i.inbound_code, b.container_no, i.sku_code, i.destination_warehouse_name, i.product_name_zh, - product_name_en, product_image_url, i.package_count, + product_name_en, product_image_url, i.unit_type, i.pallet_id, + i.pallet_no, i.indivisible, i.package_count, COALESCE(NULLIF(i.pallet_count,0), CEIL(COALESCE(NULLIF(inbound.pallet_count,0), (SELECT COUNT(*) FROM fl_inbound_weight_result weight @@ -420,6 +421,10 @@ public interface LoadingListQueryMapper { private String productNameZh; private String productNameEn; private String productImageUrl; + private String unitType; + private Long palletId; + private String palletNo; + private Boolean indivisible; private Integer packageCount; private Integer palletCount; private BigDecimal grossWeightKg; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletCartonMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletCartonMapper.java deleted file mode 100644 index d8caf8f89..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletCartonMapper.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.baiyue.firstleg.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.firstleg.entity.LoadingWorkPalletCartonEntity; -import lombok.Getter; -import lombok.Setter; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; -import org.apache.ibatis.annotations.Update; - -import java.util.List; - -@Mapper -public interface LoadingWorkPalletCartonMapper extends BaseMapper { - @Select(""" - SELECT label.id carton_label_id,label.tenant_id,label.inbound_id,label.inbound_detail_id, - label.box_code,plan_item.id loading_plan_item_id,pick_item.id pick_item_id, - plan_item.sku_code,plan_item.units_per_carton - FROM fl_inbound_carton_label label - JOIN fl_loading_plan_item plan_item - ON plan_item.account_id=label.tenant_id AND plan_item.inbound_id=label.inbound_id - AND plan_item.inbound_detail_id=label.inbound_detail_id AND plan_item.deleted=0 - JOIN fl_loading_pick_item pick_item ON pick_item.loading_plan_item_id=plan_item.id - JOIN fl_loading_pick_task task ON task.id=pick_item.pick_task_id - AND task.loading_plan_id=plan_item.loading_plan_id - JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id - JOIN fl_container_booking booking ON booking.id=plan.container_booking_id - JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id - AND inbound_order.tenant_id=plan_item.account_id AND inbound_order.deleted=0 - WHERE task.id=#{taskId} AND task.task_status='LOADING' AND booking.warehouse_id=#{warehouseId} - AND label.box_code=#{boxCode} AND label.batch_status='ACTIVE' - AND inbound_order.fulfillment_type='ONE_CLICK_DROPSHIP' - AND pick_item.item_status<>'SKIPPED' - ORDER BY label.tenant_id,label.id - """) - List selectCandidates(@Param("taskId") long taskId, - @Param("warehouseId") long warehouseId, - @Param("boxCode") String boxCode); - - @Select("SELECT * FROM fl_loading_work_pallet_carton WHERE pick_task_id=#{taskId} AND carton_label_id=#{labelId}") - LoadingWorkPalletCartonEntity selectBinding(@Param("taskId") long taskId, - @Param("labelId") long labelId); - - @Select("SELECT * FROM fl_loading_work_pallet_carton WHERE operation_id=#{operationId}") - LoadingWorkPalletCartonEntity selectByOperation(@Param("operationId") String operationId); - - @Select("SELECT * FROM fl_loading_work_pallet_carton WHERE work_pallet_id=#{palletId} ORDER BY id") - List selectPalletCartons(@Param("palletId") long palletId); - - @Select("SELECT COUNT(*) FROM fl_loading_work_pallet_carton WHERE work_pallet_id=#{palletId}") - int countPalletCartons(@Param("palletId") long palletId); - - @Select(""" - SELECT COUNT(*) FROM ( - SELECT carton.pick_item_id - FROM fl_loading_work_pallet_carton carton - JOIN fl_loading_pick_item pick_item ON pick_item.id=carton.pick_item_id - WHERE carton.pick_task_id=#{taskId} - GROUP BY carton.pick_item_id,pick_item.picked_package_count - HAVING COUNT(*)>pick_item.picked_package_count - ) unreconciled - """) - int countUnreconciled(@Param("taskId") long taskId); - - @Update(""" - UPDATE fl_loading_work_pallet_carton - SET work_pallet_id=#{targetPalletId},bound_by=#{operatorId},bound_by_name=#{operatorName}, - operation_id=#{operationId},version=version+1 - WHERE id=#{id} AND version=#{version} - """) - int move(@Param("id") long id, @Param("version") int version, - @Param("targetPalletId") long targetPalletId, - @Param("operatorId") long operatorId, @Param("operatorName") String operatorName, - @Param("operationId") String operationId); - - @Getter - @Setter - class CartonCandidate { - private Long cartonLabelId; - private Long tenantId; - private Long inboundId; - private Long inboundDetailId; - private String boxCode; - private Long loadingPlanItemId; - private Long pickItemId; - private String skuCode; - private Integer unitsPerCarton; - } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletMapper.java deleted file mode 100644 index e1b6ba90c..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletMapper.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.baiyue.firstleg.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.firstleg.entity.LoadingWorkPalletEntity; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; -import org.apache.ibatis.annotations.Update; - -import java.util.List; - -@Mapper -public interface LoadingWorkPalletMapper extends BaseMapper { - @Select(""" - SELECT task.id task_id,task.loading_plan_id,task.task_status,booking.warehouse_id, - assignment.assignee_id, - SUM(CASE WHEN inbound_order.fulfillment_type='ONE_CLICK_DROPSHIP' THEN 1 ELSE 0 END) dropship_item_count - FROM fl_loading_pick_task task - JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id - JOIN fl_container_booking booking ON booking.id=plan.container_booking_id - JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id=task.id - JOIN fl_loading_plan_item plan_item ON plan_item.id=pick_item.loading_plan_item_id AND plan_item.deleted=0 - JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id - AND inbound_order.tenant_id=plan_item.account_id AND inbound_order.deleted=0 - LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id - WHERE task.id=#{taskId} AND booking.warehouse_id=#{warehouseId} - GROUP BY task.id,task.loading_plan_id,task.task_status,booking.warehouse_id,assignment.assignee_id - """) - TaskContext selectTaskContext(@Param("taskId") long taskId, @Param("warehouseId") long warehouseId); - - @Select("SELECT * FROM fl_loading_work_pallet WHERE open_operation_id=#{operationId}") - LoadingWorkPalletEntity selectByOpenOperation(@Param("operationId") String operationId); - - @Select("SELECT * FROM fl_loading_work_pallet WHERE seal_operation_id=#{operationId}") - LoadingWorkPalletEntity selectBySealOperation(@Param("operationId") String operationId); - - @Select(""" - SELECT pallet.* - FROM fl_loading_work_pallet pallet - JOIN fl_loading_pick_task task ON task.id=pallet.pick_task_id - JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id - JOIN fl_container_booking booking ON booking.id=plan.container_booking_id - WHERE pallet.pick_task_id=#{taskId} AND pallet.id=#{palletId} - AND booking.warehouse_id=#{warehouseId} - """) - LoadingWorkPalletEntity selectScoped(@Param("taskId") long taskId, - @Param("palletId") long palletId, - @Param("warehouseId") long warehouseId); - - @Select(""" - SELECT pallet.* - FROM fl_loading_work_pallet pallet - JOIN fl_loading_pick_task task ON task.id=pallet.pick_task_id - JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id - JOIN fl_container_booking booking ON booking.id=plan.container_booking_id - WHERE pallet.pick_task_id=#{taskId} AND booking.warehouse_id=#{warehouseId} - AND pallet.pallet_status<>'CANCELLED' - ORDER BY pallet.id - """) - List selectTaskPallets(@Param("taskId") long taskId, - @Param("warehouseId") long warehouseId); - - @Update(""" - UPDATE fl_loading_work_pallet - SET pallet_status='SEALED',seal_operation_id=#{operationId},sealed_by=#{operatorId}, - sealed_by_name=#{operatorName},sealed_at=CURRENT_TIMESTAMP(3),version=version+1 - WHERE id=#{palletId} AND pick_task_id=#{taskId} AND warehouse_id=#{warehouseId} - AND pallet_status='OPEN' AND version=#{expectedVersion} - """) - int seal(@Param("taskId") long taskId, @Param("palletId") long palletId, - @Param("warehouseId") long warehouseId, @Param("expectedVersion") int expectedVersion, - @Param("operationId") String operationId, @Param("operatorId") long operatorId, - @Param("operatorName") String operatorName); - - record TaskContext(Long taskId, Long loadingPlanId, String taskStatus, Long warehouseId, - Long assigneeId, Integer dropshipItemCount) {} -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index 418095224..e85a6daa8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -112,7 +112,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba SELECT carton.tenant_id,carton.inbound_id,batch.id,carton.inbound_detail_id,COUNT(*), COALESCE(batch.total_volume_cbm/NULLIF(source.total_package_count,0),0) FROM fl_dropship_pallet_carton carton - JOIN fl_dropship_pallet pallet ON pallet.id=carton.pallet_id AND pallet.pallet_status='COMPLETED' + JOIN fl_dropship_pallet pallet ON pallet.id=carton.pallet_id AND pallet.pallet_status='SEALED' JOIN fl_inbound_order source ON source.id=carton.inbound_id AND source.tenant_id=carton.tenant_id AND source.fulfillment_type='ONE_CLICK_DROPSHIP' JOIN fl_inbound_measurement batch ON batch.inbound_id=source.id AND batch.tenant_id=source.tenant_id @@ -399,7 +399,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba SELECT carton.tenant_id,carton.inbound_id,batch.id,carton.inbound_detail_id,COUNT(*), COALESCE(batch.total_volume_cbm/NULLIF(source.total_package_count,0),0) FROM fl_dropship_pallet_carton carton - JOIN fl_dropship_pallet pallet ON pallet.id=carton.pallet_id AND pallet.pallet_status='COMPLETED' + JOIN fl_dropship_pallet pallet ON pallet.id=carton.pallet_id AND pallet.pallet_status='SEALED' JOIN fl_inbound_order source ON source.id=carton.inbound_id AND source.tenant_id=carton.tenant_id AND source.fulfillment_type='ONE_CLICK_DROPSHIP' JOIN fl_inbound_measurement batch ON batch.inbound_id=source.id AND batch.tenant_id=source.tenant_id diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java index 33289ea30..bf5183015 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -187,7 +187,8 @@ public class LoadingListRepository { row.getOrderId(), row.getOrderNo(), row.getInboundId(), row.getInboundDetailId(), row.getInboundCode(), row.getContainerNo(), row.getSkuCode(), row.getDestinationWarehouseName(), row.getProductNameZh(), row.getProductNameEn(), - row.getProductImageUrl(), integer(row.getPackageCount()), integer(row.getPalletCount()), + row.getProductImageUrl(), row.getUnitType(), row.getPalletId(), row.getPalletNo(), + Boolean.TRUE.equals(row.getIndivisible()), integer(row.getPackageCount()), integer(row.getPalletCount()), decimal(row.getGrossWeightKg()), decimal(row.getNetWeightKg()), decimal(row.getVolumeCbm()), decimal(row.getDimensionalWeightKg()), value(row.getProductQuantity()), integer(row.getUnitsPerCarton()), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index e542d0273..5ebe83126 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -97,6 +97,11 @@ public class SmartLoadingRepository { .eq(LoadingPlanItemEntity::getInboundId, inboundId) .eq(LoadingPlanItemEntity::getDeleted, false)); } + public List activeItemsByPallet(long palletId) { + return items.selectList(new LambdaQueryWrapper() + .eq(LoadingPlanItemEntity::getPalletId, palletId) + .eq(LoadingPlanItemEntity::getDeleted, false)); + } public LoadingPlanEntity plan(long planId) { return plans.selectById(planId); } public LoadingPlanEntity planByBooking(long bookingId) { return plans.selectOne(new LambdaQueryWrapper() @@ -121,6 +126,13 @@ public class SmartLoadingRepository { .eq(LoadingPlanItemEntity::getInboundDetailId, detailId) .eq(LoadingPlanItemEntity::getDeleted, false)); } + public LoadingPlanItemEntity activePalletItem(long planId, long palletId, long detailId) { + return items.selectOne(new LambdaQueryWrapper() + .eq(LoadingPlanItemEntity::getLoadingPlanId, planId) + .eq(LoadingPlanItemEntity::getPalletId, palletId) + .eq(LoadingPlanItemEntity::getInboundDetailId, detailId) + .eq(LoadingPlanItemEntity::getDeleted, false)); + } public LoadingPlanHistoryEntity operation(String operationId) { return histories.selectOne(new LambdaQueryWrapper() .eq(LoadingPlanHistoryEntity::getOperationId, operationId)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java index 08253dbb3..7394ba725 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java @@ -4,194 +4,193 @@ 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.dto.DropshipPalletizationDtos; -import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet; -import com.baiyue.firstleg.dto.DropshipPalletizationDtos.ScanResult; -import com.baiyue.firstleg.dto.DropshipPalletizationDtos.SkuItem; -import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Task; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.*; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; -import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.CartonRow; -import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.LabelRow; -import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow; -import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.TaskRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.*; import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.math.BigDecimal; +import java.util.*; -/** 测量完成即下发的一件代发合托,不创建国内仓库托盘、库位或库存。 */ +/** 一件代发测量后的逐箱清点、待绑托和封托,不创建第二套装柜工作托盘。 */ @Service public class DropshipPalletizationService { - @Resource private DropshipPalletizationMapper mapper; - @Resource private FirstlegDocumentNumberGenerator numbers; + @Resource private DropshipPalletizationMapper mapper; - public void createTask(InboundOrderEntity inbound) { - if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return; - if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) { - throw invalid("一件代发测量完成箱数无效,不能下发合托任务"); - } - mapper.insertTask(IdWorker.getId(), inbound.getTenantId(), inbound.getId(), - inbound.getWarehouseId(), inbound.getTotalPackageCount()); - } + public void createTask(InboundOrderEntity inbound) { + if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return; + if (inbound.getTotalPackageCount()==null || inbound.getTotalPackageCount()<1) throw invalid("一件代发测量完成箱数无效,不能下发清点组托任务"); + mapper.insertTask(IdWorker.getId(),inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(),inbound.getTotalPackageCount()); + } - public List pending(long operatorId, String keyword) { - return mapper.pending(operatorId, normalize(keyword)); - } + public List pending(long operatorId,String keyword){ return mapper.pending(operatorId,normalize(keyword)); } - @Transactional - public Task claim(long taskId, long warehouseId, long operatorId, String operatorName) { - if (mapper.claim(taskId, warehouseId, operatorId, operatorName) != 1) { - throw conflict("任务已被其他人员领取或状态已变化"); - } - return task(taskId, warehouseId, operatorId); - } + @Transactional + public Task claim(long taskId,long warehouseId,long operatorId,String operatorName){ + if(mapper.claim(taskId,warehouseId,operatorId,operatorName)!=1) throw conflict("任务已被其他人员领取或状态已变化"); + return task(taskId,warehouseId,operatorId); + } - public Task task(long taskId, long warehouseId, long operatorId) { - TaskRow row = requireTask(taskId, warehouseId); - if (row.getAssigneeId() != null && row.getAssigneeId() != operatorId) { - throw conflict("任务已由其他人员领取"); - } - return view(row); - } + public Task task(long taskId,long warehouseId,long operatorId){ + TaskRow row=requireTask(taskId,warehouseId); + if(row.getAssigneeId()!=null && row.getAssigneeId()!=operatorId) throw conflict("任务已由其他人员领取"); + return view(row); + } - @Transactional - public Pallet open(long taskId, DropshipPalletizationDtos.OpenPalletRequest request, - long operatorId, String operatorName) { - TaskRow task = requireWorkingTask(taskId, request.warehouseId(), operatorId); - String operationId = request.operationId().trim(); - PalletRow replay = mapper.palletByOpenOperation(operationId); - if (replay != null) { - if (replay.getTaskId() != taskId) throw conflict("开托幂等号已用于其他任务"); - return palletView(replay, task.getInboundCode()); - } - long id = IdWorker.getId(); - mapper.insertPallet(id, taskId, task.getTenantId(), task.getInboundId(), task.getWarehouseId(), - operationId, operatorId, operatorName); - return palletView(mapper.pallet(taskId, id, request.warehouseId()), task.getInboundCode()); - } + public Carton resolveCarton(long taskId,long warehouseId,String boxCode,long operatorId){ + TaskRow task=requireTask(taskId,warehouseId); + if(task.getAssigneeId()!=null && task.getAssigneeId()!=operatorId) throw conflict("任务已由其他人员领取"); + LabelRow label=requireLabel(taskId,boxCode); + CartonRow counted=mapper.countedByLabel(taskId,label.getCartonLabelId()); + if(counted!=null) return cartonView(counted,task.getInboundCode(),false,"箱码已清点"); + String block=null; + if(!"CLAIMED".equals(task.getTaskStatus())) block="请先领取任务"; + else if(!Objects.equals(task.getAssigneeId(),operatorId)) block="任务不属于当前操作员"; + else if(!"WAIT_PALLETIZE".equals(task.getProcessStatus())) block="入仓单已不在待清点组托状态"; + return new Carton(null,label.getBoxCode(),task.getInboundId(),label.getInboundCode(),label.getInboundDetailId(), + label.getSkuCode(),label.getCargoName(),label.getUnitsPerCarton(),null,null,null,"UNCOUNTED",null,null, + block==null,block); + } - @Transactional - public ScanResult scan(long taskId, long palletId, DropshipPalletizationDtos.ScanRequest request, - long operatorId, String operatorName) { - TaskRow task = requireWorkingTask(taskId, request.warehouseId(), operatorId); - PalletRow pallet = requireOpenPallet(taskId, palletId, request.warehouseId()); - String operationId = request.operationId().trim(); - LabelRow scanned = mapper.label(taskId, request.boxCode().trim()); - if (scanned == null) throw new BizException(ResultCode.NOT_FOUND, "箱唛不属于当前一件代发入仓单"); - if (!StringUtils.hasText(scanned.getSkuCode())) throw conflict("箱唛缺少SKU,不能合托"); - if (mapper.operationCount(operationId) > 0) { - return scanResult(scanned, request.cartonCount(), task, pallet); - } - List labels = mapper.unboundLabels(taskId, task.getTenantId(), task.getInboundId(), - scanned.getInboundDetailId(), scanned.getBoxCode(), request.cartonCount()); - if (labels.size() != request.cartonCount()) { - throw conflict("本SKU剩余未上托箱数不足,请刷新任务后重试"); - } - for (LabelRow label : labels) { - mapper.insertCarton(IdWorker.getId(), taskId, palletId, task.getTenantId(), task.getInboundId(), - label.getInboundDetailId(), label.getCartonLabelId(), label.getSkuCode(), label.getCargoName(), - label.getBoxCode(), operationId, operatorId, operatorName); - } - if (mapper.addAllocated(taskId, operatorId, labels.size()) != 1) { - throw conflict("任务箱数已变化,请刷新后重试"); - } - TaskRow refreshed = requireTask(taskId, request.warehouseId()); - return scanResult(scanned, labels.size(), refreshed, - mapper.pallet(taskId, palletId, request.warehouseId())); - } + @Transactional + public CountResult countCarton(long taskId,CountCartonRequest request,long operatorId,String operatorName){ + TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId); + String operation=operation(request.operationId()); + CartonRow replay=mapper.cartonByCountOperation(operation); + if(replay!=null){ + if(!Objects.equals(replay.getTaskId(),taskId) || !Objects.equals(replay.getBoxCode(),request.boxCode().trim()) + || !Objects.equals(replay.getActualSkuQuantity(),request.actualSkuQuantity())) throw conflict("清点幂等号已用于不同操作"); + return countResult(task,replay); + } + LabelRow label=requireLabel(taskId,request.boxCode()); + if(mapper.countedByLabel(taskId,label.getCartonLabelId())!=null) throw conflict("箱码已清点,不能重复清点"); + int expected=requireExpectedQuantity(label); + boolean mismatch=expected!=request.actualSkuQuantity(); + String remark=normalize(request.discrepancyRemark()); + if(!mismatch && request.discrepancyAction()!=DiscrepancyAction.MATCH) throw invalid("实收数量一致时差异动作必须为 MATCH"); + if(mismatch && request.discrepancyAction()!=DiscrepancyAction.ACCEPT_VARIANCE) throw conflict("实收数量存在差异,请确认差异处理结果"); + if(mismatch && !StringUtils.hasText(remark)) throw invalid("接受数量差异时必须填写处理说明"); + PalletRow pallet=StringUtils.hasText(request.palletNo()) + ? getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName):null; + long cartonId=IdWorker.getId(); + mapper.insertCarton(cartonId,taskId,pallet==null?null:pallet.getId(),task.getTenantId(),task.getInboundId(), + label.getInboundDetailId(),label.getCartonLabelId(),label.getSkuCode(),label.getCargoName(),label.getBoxCode(), + expected,request.actualSkuQuantity(),mismatch?"ACCEPTED_VARIANCE":"MATCH",remark,operation,operatorId,operatorName); + if(mapper.addCounted(taskId,operatorId,pallet==null?0:1,mismatch?1:0)!=1) throw conflict("任务清点数量已变化,请刷新后重试"); + TaskRow refreshed=requireTask(taskId,request.warehouseId()); + return countResult(refreshed,mapper.carton(taskId,cartonId)); + } - @Transactional - public Task seal(long taskId, long palletId, DropshipPalletizationDtos.SealPalletRequest request, - long operatorId, String operatorName) { - TaskRow task = requireWorkingTask(taskId, request.warehouseId(), operatorId); - PalletRow replay = mapper.palletBySealOperation(request.operationId().trim()); - if (replay != null) { - if (replay.getTaskId() != taskId || replay.getId() != palletId) { - throw conflict("封托幂等号已用于其他托盘"); - } - return view(requireTask(taskId, request.warehouseId())); - } - requireOpenPallet(taskId, palletId, request.warehouseId()); - String palletNo = numbers.next("DP"); - if (mapper.seal(taskId, palletId, request.expectedVersion(), palletNo, request.operationId().trim(), - operatorId, operatorName) != 1) { - throw conflict("空托盘、托盘状态或版本已变化,请刷新后重试"); - } - TaskRow refreshed = requireTask(taskId, request.warehouseId()); - if (refreshed.getAllocatedCartons().equals(refreshed.getTotalCartons()) - && mapper.openPalletCount(taskId) == 0) { - if (mapper.completeTask(taskId) != 1 - || mapper.completeInbound(taskId, refreshed.getTenantId(), refreshed.getInboundId(), operatorId) != 1) { - throw conflict("合托完成状态已变化,请刷新后重试"); - } - } - return view(requireTask(taskId, request.warehouseId())); - } + @Transactional + public Task bindPending(long taskId,BindPendingRequest request,long operatorId,String operatorName){ + TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId); + String operation=operation(request.operationId()); + if(mapper.bindOperationCount(taskId,operation)>0) return view(requireTask(taskId,request.warehouseId())); + PalletRow pallet=getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName); + int expected=mapper.unboundCount(taskId); + if(expected==0) throw conflict("当前没有待绑托箱"); + int bound=mapper.bindPending(taskId,pallet.getId(),operation,operatorId,operatorName); + if(bound!=expected || mapper.adjustBound(taskId,operatorId,bound)!=1) throw conflict("待绑托箱已变化,请刷新后重试"); + return view(requireTask(taskId,request.warehouseId())); + } - public List inboundPallets(long tenantId, long inboundId) { - String inboundCode = mapper.inboundCode(tenantId, inboundId); - if (inboundCode == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); - return mapper.palletsByInbound(tenantId, inboundId).stream() - .map(row -> palletView(row, inboundCode)).toList(); - } + @Transactional + public Task moveCarton(long taskId,long cartonId,MoveCartonRequest request,long operatorId,String operatorName){ + TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId); + CartonRow carton=mapper.carton(taskId,cartonId); + if(carton==null) throw new BizException(ResultCode.NOT_FOUND,"清点箱不存在"); + if(carton.getPalletId()!=null) requireOpenPallet(taskId,carton.getPalletId(),request.warehouseId()); + PalletRow target=StringUtils.hasText(request.targetPalletNo()) + ? getOrCreatePallet(task,request.targetPalletNo().trim(),operation(request.operationId()),operatorId,operatorName):null; + if(Objects.equals(carton.getPalletId(),target==null?null:target.getId())) return view(task); + if(mapper.moveCarton(taskId,cartonId,target==null?null:target.getId(),operation(request.operationId()),operatorId,operatorName)!=1) + throw conflict("清点箱已被其他操作修改,请刷新后重试"); + int delta=carton.getPalletId()==null?1:(target==null?-1:0); + if(delta!=0 && mapper.adjustBound(taskId,operatorId,delta)!=1) throw conflict("任务绑托数量已变化,请刷新后重试"); + return view(requireTask(taskId,request.warehouseId())); + } - private ScanResult scanResult(LabelRow label, int added, TaskRow task, PalletRow pallet) { - return new ScanResult(label.getSkuCode(), label.getCargoName(), label.getInboundCode(), added, - task.getAllocatedCartons(), task.getTotalCartons() - task.getAllocatedCartons(), - palletView(pallet, task.getInboundCode())); - } + @Transactional + public Task seal(long taskId,long palletId,SealPalletRequest request,long operatorId,String operatorName){ + TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId); + PalletRow replay=mapper.palletBySealOperation(operation(request.operationId())); + if(replay!=null){ if(!Objects.equals(replay.getTaskId(),taskId)||!Objects.equals(replay.getId(),palletId)) throw conflict("封托幂等号已用于其他托盘"); return view(requireTask(taskId,request.warehouseId())); } + requireOpenPallet(taskId,palletId,request.warehouseId()); + if(mapper.unboundCount(taskId)>0) throw conflict("仍有已清点待绑托箱,不能封托"); + if(mapper.seal(taskId,palletId,request.expectedVersion(),operation(request.operationId()),operatorId,operatorName)!=1) + throw conflict("空托盘、托盘状态或版本已变化,请刷新后重试"); + TaskRow refreshed=requireTask(taskId,request.warehouseId()); + if(Objects.equals(refreshed.getCountedCartons(),refreshed.getTotalCartons()) && + Objects.equals(refreshed.getAllocatedCartons(),refreshed.getTotalCartons()) && mapper.openPalletCount(taskId)==0){ + if(mapper.completeTask(taskId)!=1 || mapper.completeInbound(taskId,refreshed.getTenantId(),refreshed.getInboundId(),operatorId)!=1) + throw conflict("组托完成状态已变化,请刷新后重试"); + } + return view(requireTask(taskId,request.warehouseId())); + } - private Task view(TaskRow row) { - return new Task(row.getId(), row.getInboundId(), row.getInboundCode(), row.getTenantId(), - row.getCustomerName(), row.getWarehouseId(), row.getWarehouseName(), row.getTotalCartons(), - row.getAllocatedCartons(), row.getTaskStatus(), row.getAssigneeId(), row.getAssigneeName(), - row.getVersion(), mapper.pallets(row.getId()).stream() - .map(pallet -> palletView(pallet, row.getInboundCode())).toList()); - } + public List inboundPallets(long tenantId,long inboundId){ + String code=mapper.inboundCode(tenantId,inboundId); + if(code==null) throw new BizException(ResultCode.NOT_FOUND,"入库单不存在"); + return mapper.palletsByInbound(tenantId,inboundId).stream().map(p->palletView(p,code)).toList(); + } - private Pallet palletView(PalletRow row, String inboundCode) { - List cartons = mapper.cartons(row.getId()); - Map> groups = new LinkedHashMap<>(); - for (CartonRow carton : cartons) { - groups.computeIfAbsent(carton.getInboundDetailId() + "|" + carton.getSkuCode(), ignored -> new ArrayList<>()) - .add(carton); - } - List items = groups.values().stream().map(values -> { - CartonRow first = values.get(0); - return new SkuItem(first.getInboundId(), inboundCode, first.getInboundDetailId(), first.getSkuCode(), - first.getCargoName(), values.size(), values.stream().map(CartonRow::getBoxCode).toList()); - }).toList(); - return new Pallet(row.getId(), row.getPalletNo(), row.getPalletStatus(), cartons.size(), items.size(), - row.getVersion(), items, row.getCompletedAt()); - } + private PalletRow getOrCreatePallet(TaskRow task,String palletNo,String operation,long operatorId,String operatorName){ + PalletRow existing=mapper.palletByNo(palletNo); + if(existing!=null){ + if(!Objects.equals(existing.getTaskId(),task.getId()) || !Objects.equals(existing.getTenantId(),task.getTenantId())) throw conflict("托盘码已被其他客户或入仓任务占用"); + if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱"); + return existing; + } + try{ mapper.insertPallet(IdWorker.getId(),task.getId(),task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName); } + catch(DuplicateKeyException e){ existing=mapper.palletByNo(palletNo); if(existing==null) throw conflict("托盘码已被并发占用"); } + existing=mapper.palletByNo(palletNo); + if(existing==null || !Objects.equals(existing.getTaskId(),task.getId())) throw conflict("托盘码已被其他任务占用"); + return existing; + } - private TaskRow requireWorkingTask(long taskId, long warehouseId, long operatorId) { - TaskRow task = requireTask(taskId, warehouseId); - if (!"CLAIMED".equals(task.getTaskStatus())) throw conflict("请先领取待合托任务"); - if (task.getAssigneeId() == null || task.getAssigneeId() != operatorId) throw conflict("任务不属于当前操作员"); - if (!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("入仓单已不在待合托状态"); - return task; - } + private CountResult countResult(TaskRow task,CartonRow carton){ + Pallet p=carton.getPalletId()==null?null:palletView(mapper.pallet(task.getId(),carton.getPalletId(),task.getWarehouseId()),task.getInboundCode()); + return new CountResult(cartonView(carton,task.getInboundCode(),false,null),task.getCountedCartons(),task.getAllocatedCartons(), + task.getCountedCartons()-task.getAllocatedCartons(),task.getTotalCartons()-task.getCountedCartons(),p); + } - private TaskRow requireTask(long taskId, long warehouseId) { - TaskRow task = mapper.task(taskId, warehouseId); - if (task == null) throw new BizException(ResultCode.NOT_FOUND, "未找到当前仓库的一件代发合托任务"); - return task; - } + private Task view(TaskRow row){ + return new Task(row.getId(),row.getInboundId(),row.getInboundCode(),row.getTenantId(),row.getCustomerName(),row.getWarehouseId(),row.getWarehouseName(), + row.getTotalCartons(),row.getCountedCartons(),row.getAllocatedCartons(),row.getCountedCartons()-row.getAllocatedCartons(),row.getDiscrepancyCartons(), + row.getTaskStatus(),row.getAssigneeId(),row.getAssigneeName(),row.getVersion(),mapper.pallets(row.getId()).stream().map(p->palletView(p,row.getInboundCode())).toList(), + mapper.unboundCartons(row.getId()).stream().map(c->cartonView(c,row.getInboundCode(),false,null)).toList()); + } - private PalletRow requireOpenPallet(long taskId, long palletId, long warehouseId) { - PalletRow pallet = mapper.pallet(taskId, palletId, warehouseId); - if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "托盘不存在"); - if (!"OPEN".equals(pallet.getPalletStatus())) throw conflict("托盘已完成,不能继续上托"); - return pallet; - } + private Pallet palletView(PalletRow row,String inboundCode){ + List cartons=mapper.cartons(row.getId()); Map> groups=new LinkedHashMap<>(); + for(CartonRow c:cartons) groups.computeIfAbsent(c.getInboundDetailId()+"|"+c.getSkuCode(),ignored->new ArrayList<>()).add(c); + List items=groups.values().stream().map(values->{CartonRow first=values.get(0);return new SkuItem(first.getInboundId(),inboundCode, + first.getInboundDetailId(),first.getSkuCode(),first.getCargoName(),values.size(),values.stream().mapToInt(v->v.getActualSkuQuantity()==null?0:v.getActualSkuQuantity()).sum(), + values.stream().map(CartonRow::getBoxCode).toList());}).toList(); + int discrepancies=(int)cartons.stream().filter(c->"ACCEPTED_VARIANCE".equals(c.getDiscrepancyStatus())).count(); + PalletMetrics metrics=mapper.metrics(row.getId()); + return new Pallet(row.getId(),row.getPalletNo(),row.getPalletStatus(),cartons.size(),items.size(),discrepancies, + metrics==null?BigDecimal.ZERO:metrics.weightKg(),metrics==null?BigDecimal.ZERO:metrics.volumeCbm(),row.getVersion(),items, + cartons.stream().map(c->cartonView(c,inboundCode,false,null)).toList(),row.getCompletedAt()); + } - private static String normalize(String value) { return StringUtils.hasText(value) ? value.trim() : null; } - private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } - private static BizException invalid(String message) { return new BizException(ResultCode.VALIDATION_FAILED, message); } + private Carton cartonView(CartonRow row,String inboundCode,boolean canCount,String blockReason){ + return new Carton(row.getId(),row.getBoxCode(),row.getInboundId(),inboundCode,row.getInboundDetailId(),row.getSkuCode(),row.getCargoName(), + row.getExpectedSkuQuantity(),row.getActualSkuQuantity(),row.getDiscrepancyStatus(),row.getDiscrepancyRemark(), + row.getPalletId()==null?"COUNTED_UNBOUND":"BOUND",row.getPalletId(),row.getPalletNo(),canCount,blockReason); + } + private LabelRow requireLabel(long taskId,String boxCode){ LabelRow label=mapper.label(taskId,boxCode.trim()); if(label==null) throw new BizException(ResultCode.NOT_FOUND,"箱码不属于当前一件代发入仓单"); if(!StringUtils.hasText(label.getSkuCode())) throw conflict("箱码缺少SKU快照,不能清点"); requireExpectedQuantity(label); return label; } + private int requireExpectedQuantity(LabelRow label){ if(label.getUnitsPerCarton()==null||label.getUnitsPerCarton()<1) throw conflict("箱码缺少每箱SKU数量快照,不能清点"); return label.getUnitsPerCarton(); } + private TaskRow requireWorkingTask(long taskId,long warehouseId,long operatorId){ TaskRow task=requireTask(taskId,warehouseId); if(!"CLAIMED".equals(task.getTaskStatus())) throw conflict("请先领取待清点组托任务"); if(!Objects.equals(task.getAssigneeId(),operatorId)) throw conflict("任务不属于当前操作员"); if(!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("入仓单已不在待清点组托状态"); return task; } + private TaskRow requireTask(long taskId,long warehouseId){ TaskRow task=mapper.task(taskId,warehouseId); if(task==null) throw new BizException(ResultCode.NOT_FOUND,"未找到当前仓库的一件代发清点组托任务"); return task; } + private PalletRow requireOpenPallet(long taskId,long palletId,long warehouseId){ PalletRow p=mapper.pallet(taskId,palletId,warehouseId); if(p==null) throw new BizException(ResultCode.NOT_FOUND,"托盘不存在"); if(!"OPEN".equals(p.getPalletStatus())) throw conflict("托盘已封托,不能修改"); return p; } + private static String operation(String v){String n=normalize(v);if(!StringUtils.hasText(n))throw invalid("操作幂等号不能为空");return n;} + private static String normalize(String v){return StringUtils.hasText(v)?v.trim():null;} + private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} + private static BizException invalid(String m){return new BizException(ResultCode.VALIDATION_FAILED,m);} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java index b73623379..3cbe48376 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java @@ -54,6 +54,10 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { && cargoes.stream().anyMatch(cargo -> cargo.skuNo() == null || cargo.skuNo().isBlank())) { throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发货物明细缺少SKU,不能生成箱唛"); } + if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType()) + && cargoes.stream().anyMatch(cargo -> cargo.unitsPerCarton() == null || cargo.unitsPerCarton() < 1)) { + throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发货物明细缺少每箱SKU数量,不能生成箱唛"); + } existing.forEach(label -> cargoes.stream() .filter(cargo -> java.util.Objects.equals(cargo.cargoId(), label.getInboundDetailId())) .findFirst().ifPresent(cargo -> repository.initializeLegacySku(tenantId, inboundId, @@ -211,6 +215,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { label.setCargoItemNo(cargo.itemNo()); label.setCargoName(cargo.cargoName()); label.setSkuCode(cargo.skuNo()); + label.setUnitsPerCarton(cargo.unitsPerCarton()); label.setDetailPackageCount(cargo.packageCount()); label.setInboundCode(inbound.getInboundCode()); label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index); @@ -267,6 +272,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { String source = cargoes.stream().map(cargo -> String.join("|", String.valueOf(cargo.itemNo()), cargo.cargoName() == null ? "" : cargo.cargoName().trim(), cargo.skuNo() == null ? "" : cargo.skuNo().trim(), String.valueOf(cargo.packageCount()), + String.valueOf(cargo.unitsPerCarton()), decimalOrEmpty(cargo.lengthCm()), decimalOrEmpty(cargo.widthCm()), decimalOrEmpty(cargo.heightCm()))) .collect(java.util.stream.Collectors.joining("\n")); try { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 8484f92cd..7782981bc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem; import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest; +import com.baiyue.firstleg.dto.LoadingPickDtos.PalletPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.Task; @@ -106,6 +107,8 @@ public class LoadingPickService { private InboundPalletAllocationRepository palletAllocations; @Resource private InboundHoldGuard inboundHoldGuard; + @Resource + private com.baiyue.firstleg.mapper.DropshipPalletizationMapper dropshipPalletizationMapper; /** Opens the field task after planning and ocean release; the real container number may be filled later. */ @Transactional @@ -326,6 +329,7 @@ public class LoadingPickService { if (item == null) { throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在"); } + requirePackageUnit(item); if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) { throw conflict("拣货数量超过计划箱数或明细版本已变化"); } @@ -344,6 +348,41 @@ public class LoadingPickService { return view(requiredTask(taskId), request.warehouseId()); } + /** 一件代发只扫描已封托托盘码,并在同一事务把该托全部计划行结算为已拣。 */ + @Transactional + public Task pickPallet(long taskId,PalletPickRequest request,long operatorId,String operatorName){ + inboundHoldGuard.requireTaskNotHeld(taskId); + String operationId=operation(request.operationId()); + String requestHash=hash("PICK_PALLET|"+taskId+'|'+request.warehouseId()+'|'+request.palletNo() + +'|'+request.expectedTaskVersion()); + Task replay=replayTaskOperation(taskId,operationId,requestHash,operatorId); if(replay!=null)return replay; + LoadingPickTaskEntity task=requiredAssignedLoadingTaskForUpdate(taskId,operatorId); + replay=replayTaskOperationForUpdate(taskId,operationId,requestHash,operatorId); if(replay!=null)return replay; + if(!Objects.equals(task.getVersion(),request.expectedTaskVersion()))throw conflict("PDA任务版本已变化,请刷新后重试"); + List planItems=smartLoadingRepository.items(task.getLoadingPlanId()).stream() + .filter(item->request.palletNo().equals(item.getPalletNo()) && item.getPalletId()!=null + && Boolean.TRUE.equals(item.getIndivisible())).toList(); + if(planItems.isEmpty())throw new BizException(ResultCode.NOT_FOUND,"托盘不属于当前装柜任务"); + Long palletId=planItems.get(0).getPalletId(); + if(planItems.stream().anyMatch(item->!Objects.equals(item.getPalletId(),palletId)))throw conflict("托盘装柜快照不一致"); + var physical=dropshipPalletizationMapper.sealedPalletLines(palletId); + if(physical.isEmpty()||physical.stream().anyMatch(line->!"SEALED".equals(line.getPalletStatus())))throw conflict("托盘已失效或尚未封托"); + for(LoadingPlanItemEntity planItem:planItems){ + LoadingPickItemEntity pickItem=repository.itemByPlanItem(planItem.getId()); + if(pickItem==null||!"PENDING".equals(pickItem.getItemStatus())||count(pickItem.getPickedPackageCount())!=0) + throw conflict("托盘已有部分作业或明细已变化,禁止拆托处理"); + if(repository.pick(pickItem.getId(),pickItem.getPlannedPackageCount(),pickItem.getVersion(),request.warehouseId())!=1) + throw conflict("托盘明细版本已变化,请刷新后重试"); + insertHistory(taskId,pickItem.getId(),"PAL-"+hash(operationId+'|'+pickItem.getId()).substring(0,48), + requestHash,"PICK_PALLET",pickItem.getPlannedPackageCount(),null,operatorId,operatorName); + } + startPickingIfNeeded(task,operatorId,operatorName); + if(repository.touchTask(taskId,request.expectedTaskVersion())!=1)throw conflict("PDA任务版本已变化,请刷新后重试"); + insertOperation(taskId,operationId,requestHash,"PICK_PALLET",operatorId,operatorName); + settleAndFinish(task,operatorId,operatorName); repository.syncInboundProjections(taskId,operatorId,operatorName); + return view(requiredTask(taskId),request.warehouseId()); + } + @Transactional public Task partial(long taskId, long itemId, PartialPickRequest request, long operatorId, String operatorName) { @@ -364,7 +403,8 @@ public class LoadingPickService { assertReplay(replay, taskId, itemId, requestHash); return view(task, request.warehouseId()); } - requireWarehouseItem(taskId, itemId, request.warehouseId()); + LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId()); + requirePackageUnit(item); if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(), request.warehouseId()) != 1) { throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化"); @@ -397,6 +437,7 @@ public class LoadingPickService { return view(task, request.warehouseId()); } LoadingPickItemEntity item = requireWarehouseItem(taskId, itemId, request.warehouseId()); + requirePackageUnit(item); if (repository.hasPickedOrPartialItem(taskId, item.getInboundId())) { throw conflict("同一入库单已存在已拣或部分拣货明细,不能跳拣并恢复智能装柜候选"); } @@ -443,6 +484,7 @@ public class LoadingPickService { List lines = validateBatchLines(task, request); validateInventorySnapshot(task, request, lines); + lines.forEach(line -> requirePackageUnit(line.item())); for (BatchLine line : lines) { applyBatchLine(taskId, line, operationId, operatorId, operatorName); } @@ -1213,6 +1255,16 @@ public class LoadingPickService { /** * 逐项拣货后仅更新任务版本与投影,不能自动结算或推进装柜计划。 */ + private void requirePackageUnit(LoadingPickItemEntity item) { + if (item.getLoadingPlanItemId() == null) return; + LoadingPlanItemEntity planItem = smartLoadingRepository.item( + requiredTask(item.getPickTaskId()).getLoadingPlanId(), item.getLoadingPlanItemId()); + if (planItem != null && (Boolean.TRUE.equals(planItem.getIndivisible()) + || planItem.getPalletId() != null)) { + throw conflict("一件代发已封托货物必须扫描托盘码整体拣货,不能按箱数处理"); + } + } + private void touchSettleFinishAndSync(LoadingPickTaskEntity task, long operatorId, String operatorName) { if (repository.touchTask(task.getId(), task.getVersion()) != 1) { throw conflict("PDA任务版本已变化"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java deleted file mode 100644 index a557e4c72..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java +++ /dev/null @@ -1,210 +0,0 @@ -package com.baiyue.firstleg.service; - -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.dto.LoadingPalletDtos.Carton; -import com.baiyue.firstleg.dto.LoadingPalletDtos.OpenRequest; -import com.baiyue.firstleg.dto.LoadingPalletDtos.Pallet; -import com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest; -import com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest; -import com.baiyue.firstleg.dto.LoadingPalletDtos.SkuSummary; -import com.baiyue.firstleg.entity.LoadingWorkPalletCartonEntity; -import com.baiyue.firstleg.entity.LoadingWorkPalletEntity; -import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper; -import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper.CartonCandidate; -import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper; -import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper.TaskContext; -import jakarta.annotation.Resource; -import org.springframework.dao.DuplicateKeyException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -/** 一件代发现场打托旁路;不改变既有拣货数量和仓库资源结算。 */ -@Service -public class LoadingWorkPalletService { - @Resource private LoadingWorkPalletMapper pallets; - @Resource private LoadingWorkPalletCartonMapper cartons; - @Resource private InboundHoldGuard inboundHoldGuard; - - @Transactional - public Pallet open(long taskId, OpenRequest request, long operatorId, String operatorName) { - inboundHoldGuard.requireTaskNotHeld(taskId); - TaskContext context = requireWorkTask(taskId, request.warehouseId(), operatorId); - String code = request.palletCode().trim(); - String operationId = request.operationId().trim(); - LoadingWorkPalletEntity replay = pallets.selectByOpenOperation(operationId); - if (replay != null) { - if (replay.getPickTaskId() != taskId || !replay.getPalletCode().equals(code)) { - throw conflict("开托幂等号已用于其他请求"); - } - return view(replay); - } - LoadingWorkPalletEntity value = new LoadingWorkPalletEntity(); - value.setPickTaskId(taskId); - value.setLoadingPlanId(context.loadingPlanId()); - value.setWarehouseId(request.warehouseId()); - value.setWarehousePalletId(request.warehousePalletId()); - value.setPalletCode(code); - value.setOpenOperationId(operationId); - value.setPalletStatus("OPEN"); - value.setCreatedBy(operatorId); - value.setCreatedByName(operatorName); - value.setVersion(0); - try { - pallets.insert(value); - } catch (DuplicateKeyException exception) { - replay = pallets.selectByOpenOperation(operationId); - if (replay == null || replay.getPickTaskId() != taskId || !replay.getPalletCode().equals(code)) { - throw conflict("托盘号已存在或开托操作重复"); - } - value = replay; - } - return view(value); - } - - @Transactional - public Pallet scan(long taskId, long palletId, ScanRequest request, - long operatorId, String operatorName) { - inboundHoldGuard.requireTaskNotHeld(taskId); - requireWorkTask(taskId, request.warehouseId(), operatorId); - LoadingWorkPalletEntity target = requireOpenPallet(taskId, palletId, request.warehouseId()); - String operationId = request.operationId().trim(); - LoadingWorkPalletCartonEntity replay = cartons.selectByOperation(operationId); - if (replay != null) { - if (replay.getPickTaskId() != taskId || replay.getWorkPalletId() != palletId - || !replay.getBoxCode().equals(request.boxCode().trim())) { - throw conflict("箱唛操作幂等号已用于其他请求"); - } - return view(target); - } - List candidates = cartons.selectCandidates(taskId, request.warehouseId(), - request.boxCode().trim()); - if (candidates.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "箱唛不属于当前任务的一件代发货物"); - if (candidates.size() > 1) throw conflict("箱唛在当前多客户任务中不唯一,请检查箱唛数据"); - CartonCandidate candidate = candidates.get(0); - if (candidate.getSkuCode() == null || candidate.getSkuCode().isBlank()) { - throw conflict("一件代发箱唛缺少SKU,不能上托"); - } - LoadingWorkPalletCartonEntity current = cartons.selectBinding(taskId, candidate.getCartonLabelId()); - if (current == null) { - current = binding(taskId, palletId, candidate, operationId, operatorId, operatorName); - try { - cartons.insert(current); - } catch (DuplicateKeyException exception) { - LoadingWorkPalletCartonEntity concurrent = cartons.selectBinding(taskId, candidate.getCartonLabelId()); - if (concurrent == null || concurrent.getWorkPalletId() != palletId) { - throw conflict("箱唛已被其他托盘绑定"); - } - } - return view(target); - } - if (current.getWorkPalletId() == palletId) return view(target); - LoadingWorkPalletEntity source = pallets.selectScoped(taskId, current.getWorkPalletId(), request.warehouseId()); - if (source == null || !"OPEN".equals(source.getPalletStatus())) { - throw conflict("来源托盘已封托,不能直接移箱"); - } - if (cartons.move(current.getId(), current.getVersion(), palletId, operatorId, operatorName, operationId) != 1) { - throw conflict("箱唛绑定已变化,请刷新后重试"); - } - return view(target); - } - - @Transactional - public Pallet seal(long taskId, long palletId, SealRequest request, - long operatorId, String operatorName) { - inboundHoldGuard.requireTaskNotHeld(taskId); - requireWorkTask(taskId, request.warehouseId(), operatorId); - LoadingWorkPalletEntity replay = pallets.selectBySealOperation(request.operationId().trim()); - if (replay != null) { - if (replay.getId() != palletId || replay.getPickTaskId() != taskId) { - throw conflict("封托幂等号已用于其他请求"); - } - return view(replay); - } - requireOpenPallet(taskId, palletId, request.warehouseId()); - if (cartons.countPalletCartons(palletId) == 0) throw conflict("空托盘不能封托"); - if (cartons.countUnreconciled(taskId) > 0) { - throw conflict("现场上托箱数超过已提交拣货箱数,请先完成原PDA拣货提交"); - } - if (pallets.seal(taskId, palletId, request.warehouseId(), request.expectedVersion(), - request.operationId().trim(), operatorId, operatorName) != 1) { - throw conflict("托盘状态或版本已变化,请刷新后重试"); - } - return view(pallets.selectScoped(taskId, palletId, request.warehouseId())); - } - - @Transactional(readOnly = true) - public List list(long taskId, long warehouseId) { - TaskContext context = pallets.selectTaskContext(taskId, warehouseId); - if (context == null) throw new BizException(ResultCode.NOT_FOUND, "未找到当前仓库的PDA任务"); - return pallets.selectTaskPallets(taskId, warehouseId).stream().map(this::view).toList(); - } - - private TaskContext requireWorkTask(long taskId, long warehouseId, long operatorId) { - TaskContext context = pallets.selectTaskContext(taskId, warehouseId); - if (context == null) throw new BizException(ResultCode.NOT_FOUND, "未找到当前仓库的PDA任务"); - if (!"LOADING".equals(context.taskStatus())) throw conflict("PDA任务已完成,不能继续打托"); - if (context.dropshipItemCount() == null || context.dropshipItemCount() == 0) { - throw conflict("当前任务没有一件代发货物"); - } - if (context.assigneeId() == null || context.assigneeId() != operatorId) { - throw conflict("请先领取PDA任务后再执行打托"); - } - return context; - } - - private LoadingWorkPalletEntity requireOpenPallet(long taskId, long palletId, long warehouseId) { - LoadingWorkPalletEntity pallet = pallets.selectScoped(taskId, palletId, warehouseId); - if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "现场托盘不存在"); - if (!"OPEN".equals(pallet.getPalletStatus())) throw conflict("托盘不是打托中状态"); - return pallet; - } - - private static LoadingWorkPalletCartonEntity binding(long taskId, long palletId, - CartonCandidate source, String operationId, - long operatorId, String operatorName) { - LoadingWorkPalletCartonEntity value = new LoadingWorkPalletCartonEntity(); - value.setPickTaskId(taskId); - value.setWorkPalletId(palletId); - value.setLoadingPlanItemId(source.getLoadingPlanItemId()); - value.setPickItemId(source.getPickItemId()); - value.setTenantId(source.getTenantId()); - value.setInboundId(source.getInboundId()); - value.setInboundDetailId(source.getInboundDetailId()); - value.setCartonLabelId(source.getCartonLabelId()); - value.setBoxCode(source.getBoxCode()); - value.setSkuCode(source.getSkuCode()); - value.setUnitsPerCarton(source.getUnitsPerCarton()); - value.setBoundBy(operatorId); - value.setBoundByName(operatorName); - value.setOperationId(operationId); - value.setVersion(0); - return value; - } - - private Pallet view(LoadingWorkPalletEntity pallet) { - List values = cartons.selectPalletCartons(pallet.getId()); - Map> bySku = values.stream().collect( - java.util.stream.Collectors.groupingBy(LoadingWorkPalletCartonEntity::getSkuCode, - LinkedHashMap::new, java.util.stream.Collectors.toList())); - List skus = bySku.entrySet().stream().map(entry -> new SkuSummary(entry.getKey(), - entry.getValue().size(), entry.getValue().stream().map(LoadingWorkPalletCartonEntity::getUnitsPerCarton) - .filter(Objects::nonNull).mapToLong(Integer::longValue).sum())).toList(); - List cartonViews = values.stream().map(value -> new Carton(value.getId(), value.getBoxCode(), - value.getSkuCode(), value.getTenantId(), value.getInboundId(), value.getInboundDetailId(), - value.getUnitsPerCarton())).toList(); - return new Pallet(pallet.getId(), pallet.getPalletCode(), pallet.getWarehousePalletId(), - pallet.getPalletStatus(), pallet.getVersion(), values.size(), bySku.size(), - (int) values.stream().map(LoadingWorkPalletCartonEntity::getTenantId).distinct().count(), - skus, cartonViews); - } - - private static BizException conflict(String message) { - return new BizException(ResultCode.CONFLICT, message); - } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java index e84e241f1..9aac856c1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java @@ -23,6 +23,7 @@ import java.math.BigDecimal; @Service public class MerchantInboundBillingService { private static final Set PUBLISHED_BILLING_PROCESS_STATUSES = Set.of( + InboundProcessStatus.WAIT_PALLETIZE, InboundProcessStatus.WAIT_SPLIT, InboundProcessStatus.LOADING, InboundProcessStatus.LOADED, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 3fea4c3c1..75fd69856 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -54,6 +54,7 @@ public class SmartLoadingService { "OA_NEW_YORK_TRUCK", Set.of("OA_NEW_YORK_TRUCK") ); @Resource private SmartLoadingRepository repository; + @Resource private com.baiyue.firstleg.mapper.DropshipPalletizationMapper dropshipPallets; @Resource private OrderCargoClient orderCargoClient; @Resource private PendingReviewOrderClient pendingReviewOrders; @Resource private InboundOrderCreationService inboundOrderCreationService; @@ -111,7 +112,7 @@ public class SmartLoadingService { Map> grouped = candidates(bookingId, warehouseId).stream() .collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new, java.util.stream.Collectors.toList())); - return grouped.values().stream().map(this::inboundCandidate).toList(); + return grouped.values().stream().flatMap(details -> inboundCandidates(details).stream()).toList(); } private List recommendationCandidates(ContainerBookingEntity booking) { @@ -121,7 +122,7 @@ public class SmartLoadingService { repository.planByBooking(booking.getId()), values).stream() .collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new, java.util.stream.Collectors.toList())); - return grouped.values().stream().map(this::inboundCandidate).toList(); + return grouped.values().stream().flatMap(details -> inboundCandidates(details).stream()).toList(); } /** Global workbench candidates. A co-load group is one paging unit and can never be split across pages. */ @@ -175,7 +176,7 @@ public class SmartLoadingService { List all = repository.globalCandidatesByUnits(unitPage.records()).stream() .collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new, java.util.stream.Collectors.toList())) - .values().stream().map(this::inboundCandidate).toList(); + .values().stream().flatMap(details -> inboundCandidates(details).stream()).toList(); Map units = new HashMap<>(); Map> grouped = all.stream() .filter(candidate -> candidate.coLoadGroupId() != null) @@ -216,6 +217,7 @@ public class SmartLoadingService { new InboundCandidate(row.customerId(), null, row.orderId(), row.orderNo(), row.inboundCode(), row.warehouseId(), row.channelCode(), row.channelName(), null, null, null, row.customerName(), null, row.totalPackageCount(), null, null, null, null, + "PACKAGE", null, null, false, null, null, null, null, false, true, null, "PENDING_REVIEW", row.expectedArrivalAt(), List.of())).toList(); return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages()); @@ -266,7 +268,7 @@ public class SmartLoadingService { return repository.workbenchContainers(query, actualChannels(query.channelGroupCodes())); } - private InboundCandidate inboundCandidate(List details) { + private List inboundCandidates(List details) { Candidate first = details.get(0); int packages = details.stream().map(Candidate::availablePackageCount) .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); @@ -279,13 +281,23 @@ public class SmartLoadingService { BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP); String goods = details.stream().map(Candidate::goodsName).filter(StringUtils::hasText) .map(String::trim).distinct().collect(java.util.stream.Collectors.joining("、")); - return new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(), + List pallets=dropshipPallets.palletCandidates(first.inboundId()); + if(!pallets.isEmpty()) return pallets.stream().map(pallet -> new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(), + first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(), + first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(), + first.customerName(), goods, pallet.cartonCount(), + pallet.cartonCount()==null||pallet.cartonCount()==0?null:pallet.weightKg().divide(BigDecimal.valueOf(pallet.cartonCount()),6,RoundingMode.HALF_UP), + first.unitNetWeightKg(), pallet.cartonCount()==null||pallet.cartonCount()==0?null:pallet.volumeCbm().divide(BigDecimal.valueOf(pallet.cartonCount()),6,RoundingMode.HALF_UP), + pallet.volumeCbm(),"SEALED_PALLET",pallet.palletId(),pallet.palletNo(),true, + first.coLoadGroupId(), first.groupNo(), first.memberCount(),first.groupTotalBoxes(), first.locked(), canAssign(first), capabilityBlock(first), + first.orderStatusSnapshot(),first.expectedArrivalAt(),List.of())).toList(); + return List.of(new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(), first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(), first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(), first.customerName(), goods, packages, first.unitGrossWeightKg(), first.unitNetWeightKg(), - averageVolume, volume, first.coLoadGroupId(), first.groupNo(), first.memberCount(), + averageVolume, volume,"PACKAGE",null,null,false, first.coLoadGroupId(), first.groupNo(), first.memberCount(), first.groupTotalBoxes(), first.locked(), canAssign(first), capabilityBlock(first), first.orderStatusSnapshot(), - first.expectedArrivalAt(), List.of()); + first.expectedArrivalAt(), List.of())); } private InboundCandidate globalGroupCandidate(List availableMembers) { @@ -317,7 +329,7 @@ public class SmartLoadingService { return new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(), first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(), first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(), - customers, goods, packages, gross, net, averageVolume, volume, metadata.coLoadGroupId(), metadata.groupNo(), + customers, goods, packages, gross, net, averageVolume, volume,"PACKAGE",null,null,false, metadata.coLoadGroupId(), metadata.groupNo(), metadata.memberCount(), metadata.groupTotalBoxes(), occupied, ordered.stream().allMatch(candidate -> Boolean.TRUE.equals(candidate.canAssign())), @@ -418,11 +430,52 @@ public class SmartLoadingService { @BusinessAudit(event = "SMART_LOADING_ITEM_ASSIGN", resourceType = "LOADING_PLAN", resourceId = "#planId", description = "分配智能装柜明细") public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) { - return assign(planId, request, operatorId, operatorName, false); + return assign(planId, request, operatorId, operatorName, false, null, null, null, false, null); + } + + @Transactional + @BusinessAudit(event = "SMART_LOADING_PALLET_ASSIGN", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "按已封托托盘分配智能装柜明细") + public Plan assignPallet(long planId, AssignPalletRequest request, long operatorId, String operatorName) { + String operation = operation(request.operationId()); + String requestHash = sha256("ASSIGN_PALLET|" + planId + '|' + request.palletId() + '|' + + request.expectedVersion()); + Plan replay = replay(operation, planId, "ADD_PALLET", null, requestHash); + if (replay != null) return replay; + LoadingPlanEntity original = editing(planId, request.expectedVersion(), operatorId, operatorName); + List lines = + dropshipPallets.sealedPalletLines(request.palletId()); + if (lines.isEmpty() || lines.stream().anyMatch(line -> !"SEALED".equals(line.getPalletStatus()))) { + throw conflict("托盘不存在或尚未封托"); + } + var first = lines.get(0); + inboundHoldGuard.requireInboundNotHeld(first.getAccountId(), first.getInboundId()); + if (!repository.activeItemsByPallet(request.palletId()).isEmpty()) throw conflict("托盘已被有效柜次占用"); + int sequence = 0; + for (var line : lines) { + LoadingPlanEntity current = repository.plan(planId); + Candidate candidate = new Candidate(line.getAccountId(), line.getInboundId(), line.getInboundDetailId(), + line.getOrderId(), line.getOrderNo(), line.getInboundCode(), line.getWarehouseId(), line.getChannelCode(), + line.getChannelName(), line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(), + line.getDestinationWarehouseName(), line.getCustomerName(), line.getCargoName(), line.getCartonCount(), + line.getUnitGrossWeightKg(), line.getUnitNetWeightKg(), line.getUnitVolumeCbm(), null, null, null, null, + false, null, "WAIT_SPLIT", null); + String childOperation = "PAL-" + sha256(operation + '|' + line.getInboundDetailId()).substring(0, 48); + assign(planId, new AssignRequest(line.getInboundId(), line.getInboundDetailId(), line.getCartonCount(), + current.getVersion(), childOperation), operatorId, operatorName, true, request.palletId(), + line.getPalletNo(), line.getActualSkuQuantity(), sequence++ == 0, candidate); + } + LoadingPlanEntity assigned = repository.plan(planId); + history(planId, null, "ADD_PALLET", original.getPlanStatus(), assigned.getPlanStatus(), operation, + json(values("palletId", request.palletId(), "version", request.expectedVersion())), + json(values("requestHash", requestHash, "palletNo", first.getPalletNo(), "lineCount", lines.size())), + operatorId, operatorName); + return view(assigned, requireBooking(assigned.getContainerBookingId())); } private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName, - boolean wholeGroupAssignment) { + boolean wholeGroupAssignment, Long palletId, String palletNo, + Integer actualSkuQuantity, boolean primaryPalletLine, Candidate frozenCandidate) { String operation = operation(request.operationId()); String requestHash = sha256("ASSIGN|" + planId + '|' + request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount() @@ -431,8 +484,14 @@ public class SmartLoadingService { if (replay != null) return replay; LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); - Candidate candidate = repository.candidate(planId, request.inboundId(), request.inboundDetailId()); + Candidate candidate = frozenCandidate == null + ? repository.candidate(planId, request.inboundId(), request.inboundDetailId()) : frozenCandidate; if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); + InboundOrderEntity candidateInbound = repository.inbound(candidate.accountId(), candidate.inboundId()); + if (!wholeGroupAssignment && candidateInbound != null + && "ONE_CLICK_DROPSHIP".equals(candidateInbound.getFulfillmentType())) { + throw conflict("一件代发必须按已封托托盘整体加入,不能按箱数拆分"); + } inboundHoldGuard.requireInboundNotHeld(candidate.accountId(), candidate.inboundId()); if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId()); requireSameCustomerScope(booking, candidate); @@ -445,7 +504,9 @@ public class SmartLoadingService { operatorId, operatorName); } OrderCargoClient.CargoBinding cargo = loadCargo(candidate); - LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId()); + LoadingPlanItemEntity existing = palletId == null + ? repository.activeItem(planId, candidate.accountId(), request.inboundDetailId()) + : repository.activePalletItem(planId, palletId, request.inboundDetailId()); int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount()); int availableIncludingCurrent = candidate.availablePackageCount() + (existing == null ? 0 : existing.getPackageCount()); @@ -457,6 +518,14 @@ public class SmartLoadingService { LoadingPlanItemEntity changed; if (existing == null) { LoadingPlanItemEntity item = snapshot(planId, candidate, cargo, request.packageCount(), operatorId, operatorName, lines.size() + 1); + if (palletId != null) { + item.setUnitType("SEALED_PALLET"); + item.setPalletId(palletId); + item.setPalletNo(palletNo); + item.setIndivisible(true); + item.setPalletCount(primaryPalletLine ? 1 : 0); + item.setProductQuantity(actualSkuQuantity); + } try { repository.insert(item); } catch (DuplicateKeyException e) { throw conflict("货物明细已被并发锁定"); } changed = item; } else { @@ -617,7 +686,7 @@ public class SmartLoadingService { .substring(0, 48); assign(planId, new AssignRequest(request.inboundId(), allocation.candidate().inboundDetailId(), allocation.packageCount(), current.getVersion(), childOperation), operatorId, operatorName, - wholeGroupAssignment); + wholeGroupAssignment, null, null, null, false, null); sequence++; } LoadingPlanEntity assigned = repository.plan(planId); @@ -859,6 +928,9 @@ public class SmartLoadingService { LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName); LoadingPlanItemEntity item = repository.item(planId, itemId); if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); + if (Boolean.TRUE.equals(item.getIndivisible()) || item.getPalletId() != null) { + throw conflict("已封托托盘不可调整箱数,只能整托移除"); + } inboundHoldGuard.requireInboundNotHeld(item.getAccountId(), item.getInboundId()); requireStandalone(item.getAccountId(), item.getInboundId()); if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突"); @@ -971,6 +1043,9 @@ public class SmartLoadingService { LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName); LoadingPlanItemEntity item = repository.item(planId, itemId); if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); + if (Boolean.TRUE.equals(item.getIndivisible()) || item.getPalletId() != null) { + throw conflict("已封托托盘不可删除单条明细,只能整托移除"); + } requireStandalone(item.getAccountId(), item.getInboundId()); loadingPickService.remove(itemId); touch(plan, operatorId, operatorName); @@ -982,6 +1057,34 @@ public class SmartLoadingService { return view(repository.plan(planId), requireBooking(plan.getContainerBookingId())); } + @Transactional + @BusinessAudit(event = "SMART_LOADING_PALLET_REMOVE", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "整托移除一件代发装柜明细") + public Plan removePallet(long planId,long palletId,RemovePalletRequest request,long operatorId,String operatorName){ + String operation=operation(request.operationId()); + String requestHash=sha256("REMOVE_PALLET|"+planId+'|'+palletId+'|'+request.expectedPlanVersion()); + Plan replay=replay(operation,planId,"REMOVE_PALLET",null,requestHash); if(replay!=null)return replay; + LoadingPlanEntity plan=editing(planId,request.expectedPlanVersion(),operatorId,operatorName); + List palletItems=repository.activeItemsByPallet(palletId).stream() + .filter(item->Objects.equals(item.getLoadingPlanId(),planId)).toList(); + if(palletItems.isEmpty())throw new BizException(ResultCode.NOT_FOUND,"装柜计划中不存在该托盘"); + LoadingPickService.MutationSnapshot picks=loadingPickService.mutationSnapshot(planId); + if(palletItems.stream().anyMatch(item->picks.pickedByPlanItem().getOrDefault(item.getId(),0)>0)) + throw conflict("托盘已发生实物拣货,不能移除"); + for(LoadingPlanItemEntity item:palletItems)loadingPickService.remove(item.getId()); + touch(plan,operatorId,operatorName); + for(LoadingPlanItemEntity item:palletItems){ + if(repository.remove(planId,item.getId(),item.getVersion(),operatorId,operatorName)!=1) + throw conflict("整托装柜明细已被并发修改"); + } + LoadingPlanItemEntity first=palletItems.get(0); + loadingModelBridge.removeIfUnallocated(planId,first.getAccountId(),first.getInboundId()); + history(planId,null,"REMOVE_PALLET",plan.getPlanStatus(),plan.getPlanStatus(),operation, + json(values("palletId",palletId,"version",request.expectedPlanVersion())), + json(values("requestHash",requestHash,"removedLineCount",palletItems.size())),operatorId,operatorName); + return view(repository.plan(planId),requireBooking(plan.getContainerBookingId())); + } + @Transactional @BusinessAudit(event = "SMART_LOADING_UNPICKED_REMOVE", resourceType = "LOADING_PLAN", resourceId = "#planId", description = "批量移除智能装柜未拣明细") @@ -1461,7 +1564,40 @@ public class SmartLoadingService { : "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" : "IN_PROGRESS"; return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(), b.getContainerNo(),b.getVoyageNo(),p.getVersion(),mutable,mutable,blockedReason,finalStatus, - task.taskId(),task.taskStatus(),task.taskVersion(),planCoLoadGroups(p.getId()),items); + task.taskId(),task.taskStatus(),task.taskVersion(),planCoLoadGroups(p.getId()),collapsePalletUnits(items)); + } + private List collapsePalletUnits(List items) { + List result = new ArrayList<>(); + Map> pallets = items.stream().filter(i -> i.getPalletId() != null) + .collect(java.util.stream.Collectors.groupingBy(LoadingPlanItemEntity::getPalletId, + LinkedHashMap::new, java.util.stream.Collectors.toList())); + items.stream().filter(i -> i.getPalletId() == null).forEach(result::add); + for (List lines : pallets.values()) { + LoadingPlanItemEntity unit = lines.get(0); + unit.setPackageCount(lines.stream().map(LoadingPlanItemEntity::getPackageCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); + unit.setProductQuantity(lines.stream().map(LoadingPlanItemEntity::getProductQuantity) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); + unit.setPalletCount(1); + unit.setGrossWeightKg(sumDecimal(lines, LoadingPlanItemEntity::getGrossWeightKg)); + unit.setNetWeightKg(sumDecimal(lines, LoadingPlanItemEntity::getNetWeightKg)); + unit.setVolumeCbm(sumDecimal(lines, LoadingPlanItemEntity::getVolumeCbm)); + unit.setPickedPackageCount(lines.stream().map(LoadingPlanItemEntity::getPickedPackageCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); + unit.setTotalPackageCount(unit.getPackageCount()); + unit.setRemainingPackageCount(0); + unit.setCanAdjust(false); + unit.setCanRemove(lines.stream().allMatch(i -> Boolean.TRUE.equals(i.getCanRemove()))); + result.add(unit); + } + return result.stream().sorted(Comparator + .comparing(LoadingPlanItemEntity::getLoadSequence, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(LoadingPlanItemEntity::getId, Comparator.nullsLast(Long::compareTo))) + .toList(); + } + private static BigDecimal sumDecimal(List lines, + java.util.function.Function getter) { + return lines.stream().map(getter).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); } private int adjustmentTotal(LoadingPlanItemEntity item) { int current = item.getPackageCount() == null ? 0 : item.getPackageCount(); diff --git a/firstleg/src/main/resources/db/phase2-inbound-carton-label.sql b/firstleg/src/main/resources/db/phase2-inbound-carton-label.sql index 1d93f2ec6..02f34e80f 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-carton-label.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-carton-label.sql @@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变', + units_per_carton INT UNSIGNED NULL COMMENT '每箱SKU数量快照;一件代发必须为正数', detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照', inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 76de2bd92..b51221198 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -209,6 +209,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变', + units_per_carton INT UNSIGNED NULL COMMENT '每箱SKU数量快照;一件代发必须为正数', detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照', inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照', box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', @@ -920,7 +921,9 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_task ( warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', task_status VARCHAR(24) NOT NULL COMMENT '任务状态:PENDING待领取、CLAIMED已领取、COMPLETED已完成', total_cartons INT UNSIGNED NOT NULL COMMENT '测量确认总箱数', + counted_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已逐箱确认实收数量的箱数', allocated_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已上托箱数', + discrepancy_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已确认差异箱数', assignee_id BIGINT UNSIGNED NULL COMMENT '任务领取人ID', assignee_name VARCHAR(64) NULL COMMENT '任务领取人姓名快照', claimed_at DATETIME(3) NULL COMMENT '任务领取时间', @@ -933,7 +936,7 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_task ( KEY idx_dropship_task_operator (task_status,assignee_id,warehouse_id,created_at), CONSTRAINT fk_dropship_task_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order(id), CONSTRAINT chk_dropship_task_status CHECK (task_status IN ('PENDING','CLAIMED','COMPLETED')), - CONSTRAINT chk_dropship_task_cartons CHECK (allocated_cartons<=total_cartons) + CONSTRAINT chk_dropship_task_cartons CHECK (allocated_cartons<=counted_cartons AND counted_cartons<=total_cartons) ) ENGINE=InnoDB COMMENT='一件代发测量后PDA合托任务'; CREATE TABLE IF NOT EXISTS fl_dropship_pallet ( @@ -942,8 +945,8 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', - pallet_no VARCHAR(32) NULL COMMENT '封托时生成的DP托盘号', - pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN合托中、COMPLETED已封托', + pallet_no VARCHAR(32) NOT NULL COMMENT '现场扫描的系统DP托盘码', + pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN组托中、SEALED已封托', open_operation_id VARCHAR(128) NOT NULL COMMENT '创建托盘幂等操作ID', seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作ID', created_by BIGINT UNSIGNED NOT NULL COMMENT '托盘创建人ID', @@ -960,29 +963,39 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet ( UNIQUE KEY uk_dropship_pallet_seal_op (seal_operation_id), KEY idx_dropship_pallet_inbound (tenant_id,inbound_id,pallet_status), CONSTRAINT fk_dropship_pallet_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id), - CONSTRAINT chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','COMPLETED')) + CONSTRAINT chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','SEALED')) ) ENGINE=InnoDB COMMENT='一件代发合托形成的可打印托盘'; CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton ( id BIGINT UNSIGNED NOT NULL COMMENT '托盘箱明细ID', task_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托任务ID', - pallet_id BIGINT UNSIGNED NOT NULL COMMENT '所属合托托盘ID', + pallet_id BIGINT UNSIGNED NULL COMMENT '所属组托托盘ID;已清点待绑托时为空', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID', inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '入仓货物明细ID', carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID', sku_code VARCHAR(128) NOT NULL COMMENT '箱唛冻结SKU编码', cargo_name VARCHAR(255) NULL COMMENT '货物名称快照', - box_code VARCHAR(64) NOT NULL COMMENT '稳定箱码', - operation_id VARCHAR(128) NOT NULL COMMENT '上托幂等操作ID', - bound_by BIGINT UNSIGNED NOT NULL COMMENT '上托操作人ID', + box_code VARCHAR(80) NOT NULL COMMENT '稳定箱码', + expected_sku_quantity INT UNSIGNED NOT NULL COMMENT '箱唛冻结的应收SKU数量', + actual_sku_quantity INT UNSIGNED NOT NULL COMMENT 'PDA确认的实收SKU数量', + discrepancy_status VARCHAR(24) NOT NULL COMMENT '差异状态:MATCH或ACCEPTED_VARIANCE', + discrepancy_remark VARCHAR(500) NULL COMMENT '差异接受说明', + count_operation_id VARCHAR(128) NOT NULL COMMENT '逐箱清点幂等操作ID', + bind_operation_id VARCHAR(128) NULL COMMENT '批量绑托幂等操作ID', + last_operation_id VARCHAR(128) NOT NULL COMMENT '最后一次绑托或换托操作幂等号', + counted_by BIGINT UNSIGNED NOT NULL COMMENT '清点操作人ID', + counted_by_name VARCHAR(64) NULL COMMENT '清点操作人姓名快照', + counted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清点时间', + bound_by BIGINT UNSIGNED NULL COMMENT '上托操作人ID', bound_by_name VARCHAR(64) NULL COMMENT '上托操作人姓名快照', - bound_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上托时间', + bound_at DATETIME(3) NULL COMMENT '上托时间', PRIMARY KEY (id), UNIQUE KEY uk_dropship_carton_task_label (task_id,carton_label_id), KEY idx_dropship_carton_pallet_sku (pallet_id,sku_code,id), KEY idx_dropship_carton_inbound (tenant_id,inbound_id,pallet_id), - KEY idx_dropship_carton_operation (operation_id), + UNIQUE KEY uk_dropship_carton_count_operation (count_operation_id), + KEY idx_dropship_carton_bind_operation (task_id,bind_operation_id), CONSTRAINT fk_dropship_carton_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id), CONSTRAINT fk_dropship_carton_pallet FOREIGN KEY (pallet_id) REFERENCES fl_dropship_pallet(id), CONSTRAINT fk_dropship_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id) diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 2c66be65f..1ffbfec38 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -267,6 +267,10 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item ( product_image_url VARCHAR(1000) NULL COMMENT '产品图片访问地址快照', package_count INT UNSIGNED NOT NULL COMMENT '本次装柜箱数', product_quantity INT UNSIGNED NULL COMMENT '产品总数量', + unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE' COMMENT '分配单元:PACKAGE普通箱、SEALED_PALLET一件代发封托', + pallet_id BIGINT UNSIGNED NULL COMMENT '一件代发封托ID;普通箱为空', + pallet_no VARCHAR(32) NULL COMMENT '一件代发系统托盘码快照', + indivisible TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为不可拆整托单元', units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数', pallet_count INT UNSIGNED NULL COMMENT '托盘数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '本次装柜毛重kg', @@ -287,7 +291,8 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item ( removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照', removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间', deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改', - active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格', + active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END) STORED COMMENT '未删除明细查询键;删除后返回NULL', + active_assignment_key VARCHAR(96) GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN CONCAT(CASE WHEN pallet_id IS NULL THEN 'D:0' ELSE CONCAT('P:', pallet_id) END, ':', inbound_detail_id) ELSE NULL END) STORED 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 '创建时间', @@ -296,12 +301,13 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜明细最后更新时间', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), - UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id), + UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id, active_assignment_key), KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id), KEY idx_loading_item_plan (loading_plan_id, deleted, load_sequence, id), KEY idx_loading_item_account (account_id, created_at, id), KEY idx_loading_item_inbound (account_id, inbound_id, deleted), KEY idx_loading_item_candidate (account_id, inbound_id, inbound_detail_id, deleted), + KEY idx_loading_item_pallet (pallet_id, deleted), KEY idx_loading_item_order (account_id, order_id, deleted), CONSTRAINT fk_loading_item_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), CONSTRAINT chk_loading_item_account CHECK (account_id > 0), @@ -669,66 +675,6 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history ( CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id) ) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史'; -CREATE TABLE IF NOT EXISTS fl_loading_work_pallet ( - id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场作业托盘ID', - pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID', - loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照', - warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', - warehouse_pallet_id BIGINT UNSIGNED NULL COMMENT '仓库实物托盘ID;未绑定仓库资源时为空', - pallet_code VARCHAR(64) NOT NULL COMMENT '现场托盘号', - open_operation_id VARCHAR(128) NOT NULL COMMENT '开托幂等操作号', - seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作号', - pallet_status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '托盘状态:OPEN打托中、SEALED已封托、CANCELLED已取消', - created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', - created_by_name VARCHAR(64) NULL COMMENT '创建操作人名称快照', - sealed_by BIGINT UNSIGNED NULL COMMENT '封托操作人ID', - sealed_by_name VARCHAR(64) NULL COMMENT '封托操作人名称快照', - sealed_at DATETIME(3) NULL COMMENT '封托时间', - version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_loading_work_pallet_code (pick_task_id, pallet_code), - UNIQUE KEY uk_loading_work_pallet_open_operation (open_operation_id), - UNIQUE KEY uk_loading_work_pallet_seal_operation (seal_operation_id), - KEY idx_loading_work_pallet_plan (loading_plan_id, pallet_status, id), - CONSTRAINT fk_loading_work_pallet_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), - CONSTRAINT fk_loading_work_pallet_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), - CONSTRAINT chk_loading_work_pallet_status CHECK (pallet_status IN ('OPEN','SEALED','CANCELLED')) -) ENGINE=InnoDB COMMENT='一件代发PDA现场实际托盘;允许同托盘承载多个客户订单'; - -CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton ( - id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场托盘箱唛绑定ID', - pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID', - work_pallet_id BIGINT UNSIGNED NOT NULL COMMENT '现场作业托盘ID', - loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划货物明细ID', - pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', - tenant_id BIGINT UNSIGNED NOT NULL COMMENT '箱唛所属客户ID', - inbound_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓单ID', - inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓货物明细ID', - carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID', - box_code VARCHAR(80) NOT NULL COMMENT '稳定箱唛箱码快照', - sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', - units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数量快照', - bound_by BIGINT UNSIGNED NOT NULL COMMENT '最后绑定操作人ID', - bound_by_name VARCHAR(64) NULL COMMENT '最后绑定操作人名称快照', - operation_id VARCHAR(128) NOT NULL COMMENT '最近一次绑定或移托幂等操作号', - version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次上托时间', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后移托时间', - PRIMARY KEY (id), - UNIQUE KEY uk_loading_work_carton_task_label (pick_task_id, carton_label_id), - UNIQUE KEY uk_loading_work_carton_operation (operation_id), - KEY idx_loading_work_carton_pallet (work_pallet_id, sku_code, id), - KEY idx_loading_work_carton_plan_item (loading_plan_item_id, id), - CONSTRAINT fk_loading_work_carton_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), - CONSTRAINT fk_loading_work_carton_pallet FOREIGN KEY (work_pallet_id) REFERENCES fl_loading_work_pallet (id), - CONSTRAINT fk_loading_work_carton_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id), - CONSTRAINT fk_loading_work_carton_pick_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id), - CONSTRAINT fk_loading_work_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label (id), - CONSTRAINT chk_loading_work_carton_units CHECK (units_per_carton IS NULL OR units_per_carton > 0) -) ENGINE=InnoDB COMMENT='一件代发PDA逐箱上托事实;客户订单不合并但实际托盘可混托'; - DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys; DELIMITER $$ CREATE PROCEDURE repair_phase3_loading_pick_task_foreign_keys() @@ -1478,12 +1424,13 @@ BEGIN 'destination_warehouse_name', 'product_name_zh', 'product_name_en', 'product_image_file_id', 'pallet_count', 'net_weight_kg', 'dimensional_weight_kg', 'product_quantity', 'units_per_carton', - 'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 17 THEN + 'unit_type', 'pallet_id', 'pallet_no', 'indivisible', 'active_assignment_key', + 'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 22 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失'; END IF; IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item' - AND index_name = 'uk_loading_item_plan_detail' AND non_unique = 0) <> 2 THEN + AND index_name = 'uk_loading_item_plan_assignment' AND non_unique = 0) <> 2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失'; END IF; IF (SELECT COUNT(*) FROM information_schema.columns diff --git a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml index 764600152..2e7c75c81 100644 --- a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml +++ b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml @@ -1,54 +1,153 @@ openapi: 3.0.3 info: - title: 一件代发 PDA 合托 API - version: 1.0.0 - description: 测量完成后独立下发;不创建国内库存。PDA 扫箱唛识别 SKU,并按箱数合托。 + title: 一件代发国内清点组托 API + version: 2.0.0 + description: 测量、计费后独立下发。箱码逐箱确认 SKU 实收数量;托盘码可先扫或后扫,封托后作为不可拆分柜单元。 paths: /api/platform/v1/first-leg/pda-dropship/tasks/pending: get: - summary: 查询待领取或本人已领取的一件代发合托任务 - responses: { '200': { description: OK } } + summary: 查询待领取或本人已领取任务 + x-required-authority: platform:firstleg:pda:view + responses: {'200': {description: OK}} /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}: get: - summary: 查询任务、托盘、入仓单、SKU 和箱数明细 + summary: 查询任务、已清点待绑箱及托盘结果 + x-required-authority: platform:firstleg:pda:view + parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/WarehouseId'}] + responses: {'200': {description: OK}} + /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/resolve: + get: + summary: 扫箱码解析入仓码、SKU 和应收数量,不返回全部未清点箱 + x-required-authority: platform:firstleg:pda:view parameters: - - { name: taskId, in: path, required: true, schema: { type: integer, format: int64 } } - - { name: warehouseId, in: query, required: true, schema: { type: integer, format: int64 } } - responses: { '200': { description: OK } } + - {$ref: '#/components/parameters/TaskId'} + - {$ref: '#/components/parameters/WarehouseId'} + - {name: boxCode, in: query, required: true, schema: {type: string, maxLength: 80}} + responses: {'200': {description: OK}} /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/claim: post: - summary: 领取合托任务 - parameters: [{ name: taskId, in: path, required: true, schema: { type: integer, format: int64 } }] - responses: { '200': { description: OK } } - /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/open: + summary: 领取任务 + x-required-authority: platform:firstleg:pda:work + parameters: [{$ref: '#/components/parameters/TaskId'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ClaimRequest'}}}} + responses: {'200': {description: OK}} + /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/count: post: - summary: 开启一个现场托盘 - parameters: [{ name: taskId, in: path, required: true, schema: { type: integer, format: int64 } }] - responses: { '200': { description: OK } } - /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/scan: + summary: 逐箱确认 SKU 实收数量,可同时绑定当前托盘 + description: palletNo 为空时进入 COUNTED_UNBOUND;不为空时幂等获取或创建 OPEN 托盘并绑定。数量不一致必须 ACCEPT_VARIANCE 且填写说明。 + x-required-authority: platform:firstleg:pda:work + parameters: [{$ref: '#/components/parameters/TaskId'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CountCartonRequest'}}}} + responses: {'200': {description: OK}} + /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/bind-pending: post: - summary: 扫箱唛识别 SKU 并填写上托箱数 + summary: 扫托盘码并把当前任务全部待绑箱绑定到该托盘 + x-required-authority: platform:firstleg:pda:work + parameters: [{$ref: '#/components/parameters/TaskId'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BindPendingRequest'}}}} + responses: {'200': {description: OK}} + /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/{cartonId}/move: + post: + summary: 换托,targetPalletNo 为空时退回待绑区 + x-required-authority: platform:firstleg:pda:work parameters: - - { name: taskId, in: path, required: true, schema: { type: integer, format: int64 } } - - { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } - responses: { '200': { description: OK } } + - {$ref: '#/components/parameters/TaskId'} + - {name: cartonId, in: path, required: true, schema: {type: integer, format: int64}} + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/MoveCartonRequest'}}}} + responses: {'200': {description: OK}} /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/seal: post: - summary: 完成合托并生成 DP 托盘号 + summary: 封托;禁止空托、待绑箱、版本冲突和重复归托 + x-required-authority: platform:firstleg:pda:work parameters: - - { name: taskId, in: path, required: true, schema: { type: integer, format: int64 } } - - { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } - responses: { '200': { description: OK } } + - {$ref: '#/components/parameters/TaskId'} + - {name: palletId, in: path, required: true, schema: {type: integer, format: int64}} + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/SealPalletRequest'}}}} + responses: {'200': {description: OK}} /api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets: get: - summary: 入仓单查看绑定的一个或多个托盘及各自箱数 - parameters: [{ name: inboundId, in: path, required: true, schema: { type: integer, format: int64 } }] - responses: { '200': { description: OK } } + summary: Admin 查询入仓单权威托盘结果 + x-required-authority: firstleg:inbound:list + responses: {'200': {description: OK}} /api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print: get: - summary: 打印托盘号、入仓码和 SKU 箱数托盘唛 - parameters: - - { name: inboundId, in: path, required: true, schema: { type: integer, format: int64 } } - - { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } - responses: - '200': { description: PDF, content: { application/pdf: { schema: { type: string, format: binary } } } } + summary: 打印托盘码、入仓码和 SKU 实收汇总 + x-required-authority: firstleg:inbound:list + responses: {'200': {description: PDF}} +components: + parameters: + TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64}} + WarehouseId: {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64}} + schemas: + Carton: + type: object + properties: + cartonId: {type: integer, format: int64, nullable: true} + boxCode: {type: string} + inboundId: {type: integer, format: int64} + inboundCode: {type: string} + inboundDetailId: {type: integer, format: int64} + skuCode: {type: string} + cargoName: {type: string, nullable: true} + expectedSkuQuantity: {type: integer, minimum: 1} + actualSkuQuantity: {type: integer, minimum: 1, nullable: true} + discrepancyStatus: {type: string, enum: [MATCH, ACCEPTED_VARIANCE], nullable: true} + discrepancyRemark: {type: string, nullable: true} + countStatus: {type: string, enum: [UNCOUNTED, COUNTED_UNBOUND, BOUND]} + palletId: {type: integer, format: int64, nullable: true} + palletNo: {$ref: '#/components/schemas/NullablePalletNo'} + canCount: {type: boolean} + blockReason: {type: string, nullable: true} + Pallet: + type: object + properties: + palletId: {type: integer, format: int64} + palletNo: {$ref: '#/components/schemas/PalletNo'} + status: {type: string, enum: [OPEN, SEALED]} + cartonCount: {type: integer, minimum: 0} + skuCount: {type: integer, minimum: 0} + discrepancyCartons: {type: integer, minimum: 0} + weightKg: {type: number, minimum: 0} + volumeCbm: {type: number, minimum: 0} + version: {type: integer, minimum: 1} + items: {type: array, items: {type: object}} + cartons: {type: array, items: {$ref: '#/components/schemas/Carton'}, description: 含稳定 cartonId,换托必须使用该 ID} + sealedAt: {type: string, format: date-time, nullable: true} + ClaimRequest: + type: object + required: [warehouseId] + properties: {warehouseId: {type: integer, format: int64}} + CountCartonRequest: + type: object + required: [warehouseId, boxCode, actualSkuQuantity, discrepancyAction, operationId] + properties: + warehouseId: {type: integer, format: int64} + boxCode: {type: string, maxLength: 80} + actualSkuQuantity: {type: integer, minimum: 1} + discrepancyAction: {type: string, enum: [MATCH, ACCEPT_VARIANCE]} + discrepancyRemark: {type: string, maxLength: 500, nullable: true} + palletNo: {$ref: '#/components/schemas/NullablePalletNo'} + operationId: {type: string, maxLength: 128} + BindPendingRequest: + type: object + required: [warehouseId, palletNo, operationId] + properties: + warehouseId: {type: integer, format: int64} + palletNo: {$ref: '#/components/schemas/PalletNo'} + operationId: {type: string, maxLength: 128} + MoveCartonRequest: + type: object + required: [warehouseId, operationId] + properties: + warehouseId: {type: integer, format: int64} + targetPalletNo: {$ref: '#/components/schemas/NullablePalletNo'} + operationId: {type: string, maxLength: 128} + SealPalletRequest: + type: object + required: [warehouseId, expectedVersion, operationId] + properties: + warehouseId: {type: integer, format: int64} + expectedVersion: {type: integer, minimum: 1} + operationId: {type: string, maxLength: 128} + PalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$'} + NullablePalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$', nullable: true} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 1252ac4b4..4cade3c87 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -296,7 +296,7 @@ paths: post: summary: 按统一订单条件分页查询履约摘要(Order 内部) description: >- - mainStatus 接受六态产品状态;WAIT_SPLIT 为入库完成后的唯一待分柜状态。 + mainStatus 接受七态产品状态;WAIT_PALLETIZE 为一件代发测量计费后的待清点组托状态,WAIT_SPLIT 为待分柜状态。 计费与入库处理中状态在完成前仍归并为 WAIT_MEASURE,不提前开放分柜。 security: - internalServiceToken: [] @@ -1133,7 +1133,7 @@ paths: /api/platform/v1/inbounds/{inboundId}/measurements/approve: post: summary: 审核通过测量 - description: 需要 firstleg:inbound:measurement-review 权限。审核通过后立即进入 WAIT_SPLIT,并按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和待分柜状态。 + description: 需要 firstleg:inbound:measurement-review 权限。标准头程审核通过后进入 WAIT_SPLIT;一件代发进入 WAIT_PALLETIZE,完成逐箱清点和封托后再进入 WAIT_SPLIT。审核时按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示。 parameters: - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -1150,7 +1150,7 @@ paths: expectedVersion: {type: integer, minimum: 0} reason: {type: string, maxLength: 1000} responses: - '200': {description: 成功或业务失败,按响应体 code 判断;审核成功 data.processStatus 固定为 WAIT_SPLIT,initialPricingSucceeded 表示是否同时生成初算费用,初算失败时 feeStatus=UNPRICED 且主流程仍成功} + '200': {description: 成功或业务失败,按响应体 code 判断;审核成功后标准头程 data.processStatus=WAIT_SPLIT,一件代发为 WAIT_PALLETIZE;initialPricingSucceeded 表示是否同时生成初算费用,未覆盖时 feeStatus=UNPRICED 且主流程仍成功} /api/platform/v1/inbounds/{inboundId}/measurements/submit: post: summary: 提交测量完成 @@ -1164,7 +1164,7 @@ paths: /api/merchant/v1/first-leg/orders/{orderId}/inbound-billing: get: summary: 客户查询已完成入库结果和最新已发布费用 - description: 需要 merchant:order:view 权限;客户仅能按登录态客户范围查看。入库流程达到 WAIT_SPLIT 后,费用在 LOADING、LOADED、DELIVERED 仍持续可见;始终要求 measureReviewStatus=APPROVED、feeStatus=SENT 且存在 PUBLISHED 入库运费明细。latestFee.customerConfirmationStatus 表示客户是否已确认当前费用版本。 + description: 需要 merchant:order:view 权限;客户仅能按登录态客户范围查看。一件代发在 WAIT_PALLETIZE 清点组托阶段即可查看并确认已发布费用,之后在 WAIT_SPLIT、LOADING、LOADED、DELIVERED 持续可见;始终要求 measureReviewStatus=APPROVED、feeStatus=SENT 且存在 PUBLISHED 入库运费明细。 parameters: - {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} responses: @@ -1383,8 +1383,8 @@ components: invalidatedFeeId: {type: integer, format: int64, nullable: true} UnifiedMainStatus: type: string - enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING, LOADED, DELIVERED] - description: 六态产品主状态;WAIT_SPLIT 为入库完成后的唯一待分柜状态 + enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED] + description: 七态产品主状态;WAIT_PALLETIZE 仅表示一件代发待清点组托 UnifiedInboundQuery: type: object properties: @@ -1393,7 +1393,7 @@ components: orderNo: {type: string, maxLength: 64, nullable: true} mainStatus: type: string - enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING, LOADED, DELIVERED] + enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED] nullable: true description: WAIT_SPLIT 为唯一待分柜筛选值 customerId: {type: integer, format: int64, nullable: true} @@ -1511,6 +1511,7 @@ components: deliveryWarehouseName: {type: string, nullable: true} businessType: {type: string, nullable: true} fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true} + lxCode: {type: string, nullable: true, description: 一件代发线路或履约产品编码快照} channelCode: {type: string, nullable: true} channelName: {type: string, nullable: true} taxMode: {type: string, nullable: true} diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index 909e5e753..495956511 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -170,104 +170,11 @@ paths: expectedVersion: {type: integer, minimum: 0} reason: {type: string, minLength: 1, maxLength: 500} responses: {'200': {description: 作废后柜级证据列表}} - /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets: - get: - summary: 查询一件代发现场实际托盘、SKU汇总和逐箱明细 - parameters: - - {$ref: '#/components/parameters/TaskId'} - - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} - responses: - '200': - description: 当前任务未取消的现场托盘 - content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/WorkPallet'}}}} - /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets/open: - post: - summary: 为已领取任务开启一个一件代发现场托盘 - parameters: [{$ref: '#/components/parameters/TaskId'}] - requestBody: - required: true - content: {application/json: {schema: {$ref: '#/components/schemas/OpenPalletRequest'}}} - responses: - '200': {description: 已开启托盘或相同operationId的幂等结果} - /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets/{palletId}/cartons/scan: - post: - summary: 扫描一件代发箱唛上托;箱唛已在其他未封托托盘时按箱移托 - description: 本操作只记录实际箱到托关系,不推进既有拣货数量、订单状态或仓库资源结算。 - parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/PalletId'}] - requestBody: - required: true - content: {application/json: {schema: {$ref: '#/components/schemas/ScanCartonRequest'}}} - responses: - '200': {description: 包含最新SKU与箱唛汇总的目标托盘} - /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets/{palletId}/seal: - post: - summary: 封托并校验现场上托箱数未超过既有PDA已提交拣货箱数 - parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/PalletId'}] - requestBody: - required: true - content: {application/json: {schema: {$ref: '#/components/schemas/SealPalletRequest'}}} - responses: - '200': {description: 已封托托盘或A0409数量/版本冲突} components: parameters: TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} ItemId: {name: itemId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} - PalletId: {name: palletId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} schemas: - OpenPalletRequest: - type: object - required: [warehouseId, palletCode, operationId] - properties: - warehouseId: {type: integer, format: int64, minimum: 1} - warehousePalletId: {type: integer, format: int64, minimum: 1, nullable: true} - palletCode: {type: string, maxLength: 64} - operationId: {type: string, maxLength: 128} - ScanCartonRequest: - type: object - required: [warehouseId, boxCode, operationId] - properties: - warehouseId: {type: integer, format: int64, minimum: 1} - boxCode: {type: string, maxLength: 80} - operationId: {type: string, maxLength: 128} - SealPalletRequest: - type: object - required: [warehouseId, expectedVersion, operationId] - properties: - warehouseId: {type: integer, format: int64, minimum: 1} - expectedVersion: {type: integer, minimum: 0} - operationId: {type: string, maxLength: 128} - WorkPallet: - type: object - required: [id, palletCode, status, version, cartonCount, skuCount, customerCount, skus, cartons] - properties: - id: {type: integer, format: int64} - palletCode: {type: string} - warehousePalletId: {type: integer, format: int64, nullable: true} - status: {type: string, enum: [OPEN, SEALED]} - version: {type: integer, minimum: 0} - cartonCount: {type: integer, minimum: 0} - skuCount: {type: integer, minimum: 0} - customerCount: {type: integer, minimum: 0} - skus: {type: array, items: {$ref: '#/components/schemas/WorkPalletSku'}} - cartons: {type: array, items: {$ref: '#/components/schemas/WorkPalletCarton'}} - WorkPalletSku: - type: object - required: [skuCode, cartonCount, productQuantity] - properties: - skuCode: {type: string} - cartonCount: {type: integer, minimum: 1} - productQuantity: {type: integer, format: int64, minimum: 0} - WorkPalletCarton: - type: object - required: [id, boxCode, skuCode, tenantId, inboundId, inboundDetailId] - properties: - id: {type: integer, format: int64} - boxCode: {type: string} - skuCode: {type: string} - tenantId: {type: integer, format: int64} - inboundId: {type: integer, format: int64} - inboundDetailId: {type: integer, format: int64} - unitsPerCarton: {type: integer, minimum: 1, nullable: true} Task: type: object required: [id, loadingPlanId, containerBookingId, inboundId, inboundVersion, loadingVersion, cabinetNo, sourceType, recordStatus, taskStatus, workflowStatus, version, items] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java index 9e0ac4d84..a67097ac3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java @@ -40,7 +40,8 @@ class InboundMeasurementReviewContractTest { .contains("/api/platform/v1/inbounds/{inboundId}/measurements/approve:") .contains("Idempotency-Key") .contains("初算异常只返回非阻断提示") - .contains("data.processStatus 固定为 WAIT_SPLIT") + .contains("标准头程审核通过后进入 WAIT_SPLIT") + .contains("一件代发进入 WAIT_PALLETIZE") .contains("feeStatus=UNPRICED 且主流程仍成功") .contains("name: inboundId") .contains("schema: {type: string, pattern:") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java index 6d477e85e..c544acbb8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java @@ -24,13 +24,8 @@ class PlatformLoadingPdaControllerContractTest { authority("review", "platform:firstleg:pda:review", AuthenticatedUser.class, long.class, ReviewRequest.class); authority("complete", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, CompleteLoadingRequest.class); authority("operations", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class); - authority("pallets", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class, long.class); - authority("openPallet", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, - com.baiyue.firstleg.dto.LoadingPalletDtos.OpenRequest.class); - authority("scanCarton", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, - com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest.class); - authority("sealPallet", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, - com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest.class); + authority("pickPallet", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, + PalletPickRequest.class); } private void authority(String name, String expected, Class... parameterTypes) throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java index 8665093a8..895f92b97 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java @@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat; class UnifiedInboundOpenApiContractTest { @Test - void documentsInternalUnifiedOrderEndpointsAndSixStatuses() throws Exception { + void documentsInternalUnifiedOrderEndpointsAndSevenStatuses() throws Exception { String yaml = new ClassPathResource("openapi/firstleg-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); Map document = stringMap(new Yaml().load(yaml)); @@ -25,8 +25,8 @@ class UnifiedInboundOpenApiContractTest { Map schemas = stringMap(stringMap(document.get("components")).get("schemas")); Map responseStatus = stringMap(schemas.get("UnifiedMainStatus")); assertThat(stringList(responseStatus.get("enum"))).containsExactly( - "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); - assertThat(responseStatus.get("description").toString()).contains("WAIT_SPLIT", "唯一待分柜状态"); + "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); + assertThat(responseStatus.get("description").toString()).contains("WAIT_PALLETIZE", "待清点组托"); Map query = stringMap(schemas.get("UnifiedInboundQuery")); Map queryStatus = stringMap(stringMap(query.get("properties")).get("mainStatus")); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java new file mode 100644 index 000000000..6dbdf88c1 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java @@ -0,0 +1,42 @@ +package com.baiyue.firstleg.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class DropshipPalletizationSqlContractTest { + @Test + void moduleAndUnifiedSchemasExposeCountingPalletAndWholePalletAssignmentFacts() throws Exception { + String inbound = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String loading = new ClassPathResource("db/phase3-smart-loading-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); + + for (String sql : new String[]{inbound, unified}) { + assertThat(sql) + .contains("counted_cartons INT UNSIGNED NOT NULL DEFAULT 0") + .contains("discrepancy_cartons INT UNSIGNED NOT NULL DEFAULT 0") + .contains("pallet_status VARCHAR(16) NOT NULL COMMENT '托盘状态:OPEN组托中、SEALED已封托'") + .contains("expected_sku_quantity INT UNSIGNED NOT NULL") + .contains("actual_sku_quantity INT UNSIGNED NOT NULL") + .contains("count_operation_id VARCHAR(128) NOT NULL") + .contains("bind_operation_id VARCHAR(128) NULL"); + } + for (String sql : new String[]{loading, unified}) { + assertThat(sql) + .contains("unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE'") + .contains("pallet_id BIGINT UNSIGNED NULL") + .contains("indivisible TINYINT(1) NOT NULL DEFAULT 0") + .contains("active_assignment_key VARCHAR(96) GENERATED ALWAYS AS") + .contains("UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id, active_assignment_key)") + .doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_work_pallet (") + .doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton ("); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java index 604b8b1a9..9fe3da95f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundCartonLabelSqlContractTest.java @@ -18,16 +18,19 @@ class InboundCartonLabelSqlContractTest { String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql")); assertThat(schema).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label") + .contains("units_per_carton INT UNSIGNED NULL") .contains("UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code)") .contains("UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index)") .contains("inbound_detail_id BIGINT UNSIGNED NOT NULL"); assertThat(migration).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label") + .contains("units_per_carton INT UNSIGNED NULL") .contains("固定二维码内容:入仓码#总箱数-箱下标") .contains("idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id)"); assertThat(versioningMigration).contains("phase2_carton_label_add_column('label_version'") .contains("phase2_carton_label_add_column('batch_status'") .contains("idx_carton_label_active (tenant_id, inbound_id, batch_status, label_version)"); assertThat(unified).contains("CREATE TABLE IF NOT EXISTS fl_inbound_carton_label") + .contains("units_per_carton INT UNSIGNED NULL") .contains("unified_carton_label_add_column('label_version'") .contains("unified_carton_label_add_column('batch_status'") .contains("unified_carton_label_add_column('cargo_fingerprint'") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index bf62e460f..d45182895 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -53,7 +53,7 @@ class SmartLoadingCandidateMapperContractTest { .contains("SUM(package_count) package_count") .contains("GROUP BY tenant_id,measurement_id,inbound_detail_id") .contains("fl_dropship_pallet_carton carton") - .contains("pallet.pallet_status='COMPLETED'") + .contains("pallet.pallet_status='SEALED'") .contains("source.fulfillment_type='ONE_CLICK_DROPSHIP'") .contains("i.inbound_id=o.id") .contains("o.warehouse_id=#{warehouseId}") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index abc985b0e..6cce0e3ec 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -67,7 +67,8 @@ class SmartLoadingSqlContractTest { assertThat(migration()) .contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS") .contains("CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END") - .contains("UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id)") + .contains("active_assignment_key VARCHAR(96) GENERATED ALWAYS AS") + .contains("UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id, active_assignment_key)") .contains("KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id)") .contains("UNIQUE KEY uk_container_booking_no (booking_no)") .contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_no)") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java new file mode 100644 index 000000000..51247d226 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java @@ -0,0 +1,66 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.CountCartonRequest; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.DiscrepancyAction; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.CartonRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.LabelRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.TaskRow; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class DropshipPalletizationServiceTest { + private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class); + private final DropshipPalletizationService service=new DropshipPalletizationService(); + + DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); } + + @Test void resolvesUncountedCartonBeforeQuantityConfirmation(){ + TaskRow task=task(); LabelRow label=label(); + when(mapper.task(10L,100L)).thenReturn(task); when(mapper.label(10L,"BOX-1")).thenReturn(label); + var result=service.resolveCarton(10L,100L,"BOX-1",7L); + assertThat(result.inboundCode()).isEqualTo("IN-1"); + assertThat(result.skuCode()).isEqualTo("SKU-1"); + assertThat(result.expectedSkuQuantity()).isEqualTo(12); + assertThat(result.countStatus()).isEqualTo("UNCOUNTED"); + assertThat(result.canCount()).isTrue(); + } + + @Test void mismatchCannotBeRecordedWithoutAcceptedVarianceAndRemark(){ + when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.label(10L,"BOX-1")).thenReturn(label()); + var wrongAction=new CountCartonRequest(100L,"BOX-1",11,DiscrepancyAction.MATCH,null,null,"op-1"); + assertThatThrownBy(()->service.countCarton(10L,wrongAction,7L,"张三")) + .isInstanceOf(BizException.class).hasMessageContaining("确认差异处理结果"); + var noRemark=new CountCartonRequest(100L,"BOX-1",11,DiscrepancyAction.ACCEPT_VARIANCE,null,null,"op-2"); + assertThatThrownBy(()->service.countCarton(10L,noRemark,7L,"张三")) + .isInstanceOf(BizException.class).hasMessageContaining("处理说明"); + verify(mapper,never()).insertCarton(anyLong(),anyLong(),any(),anyLong(),anyLong(),anyLong(),anyLong(),any(),any(),any(),anyInt(),anyInt(),any(),any(),any(),anyLong(),any()); + } + + @Test void countsOneCartonDirectlyIntoScannedPallet(){ + TaskRow before=task(),after=task(); after.setCountedCartons(1); after.setAllocatedCartons(1); + LabelRow label=label(); PalletRow pallet=pallet(); CartonRow carton=carton(); + when(mapper.task(10L,100L)).thenReturn(before,after); + when(mapper.label(10L,"BOX-1")).thenReturn(label); when(mapper.palletByNo("DP-260908-0001")).thenReturn(pallet); + when(mapper.addCounted(10L,7L,1,0)).thenReturn(1); when(mapper.carton(eq(10L),anyLong())).thenReturn(carton); + when(mapper.pallet(10L,20L,100L)).thenReturn(pallet); when(mapper.cartons(20L)).thenReturn(java.util.List.of(carton)); + when(mapper.metrics(20L)).thenReturn(new DropshipPalletizationMapper.PalletMetrics(java.math.BigDecimal.ONE,java.math.BigDecimal.TEN)); + var request=new CountCartonRequest(100L,"BOX-1",12,DiscrepancyAction.MATCH,null,"DP-260908-0001","op-1"); + var result=service.countCarton(10L,request,7L,"张三"); + assertThat(result.carton().countStatus()).isEqualTo("BOUND"); + assertThat(result.pallet().status()).isEqualTo("OPEN"); + verify(mapper).insertCarton(anyLong(),eq(10L),eq(20L),eq(1L),eq(30L),eq(40L),eq(50L),eq("SKU-1"), + eq("货物"),eq("BOX-1"),eq(12),eq(12),eq("MATCH"),isNull(),eq("op-1"),eq(7L),eq("张三")); + } + + private static TaskRow task(){ TaskRow t=new TaskRow();t.setId(10L);t.setTenantId(1L);t.setInboundId(30L);t.setWarehouseId(100L);t.setInboundCode("IN-1");t.setTaskStatus("CLAIMED");t.setAssigneeId(7L);t.setProcessStatus("WAIT_PALLETIZE");t.setTotalCartons(2);t.setCountedCartons(0);t.setAllocatedCartons(0);t.setDiscrepancyCartons(0);t.setVersion(1);return t; } + private static LabelRow label(){LabelRow l=new LabelRow();l.setCartonLabelId(50L);l.setInboundDetailId(40L);l.setSkuCode("SKU-1");l.setCargoName("货物");l.setUnitsPerCarton(12);l.setInboundCode("IN-1");l.setBoxCode("BOX-1");return l;} + private static PalletRow pallet(){PalletRow p=new PalletRow();p.setId(20L);p.setTaskId(10L);p.setTenantId(1L);p.setInboundId(30L);p.setWarehouseId(100L);p.setPalletNo("DP-260908-0001");p.setPalletStatus("OPEN");p.setVersion(1);return p;} + private static CartonRow carton(){CartonRow c=new CartonRow();c.setId(60L);c.setTaskId(10L);c.setPalletId(20L);c.setPalletNo("DP-260908-0001");c.setInboundId(30L);c.setInboundDetailId(40L);c.setSkuCode("SKU-1");c.setCargoName("货物");c.setBoxCode("BOX-1");c.setExpectedSkuQuantity(12);c.setActualSkuQuantity(12);c.setDiscrepancyStatus("MATCH");return c;} +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index dc043ee27..0ce9dc9f0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -30,6 +30,7 @@ import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; import com.baiyue.firstleg.mapper.LoadingPickItemMapper; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.InboundPalletAllocationRepository; @@ -103,6 +104,8 @@ class LoadingPickServiceTest { private InboundPalletAllocationRepository palletAllocations; @MockBean private InboundHoldGuard inboundHoldGuard; + @MockBean + private DropshipPalletizationMapper dropshipPallets; @Resource private LoadingPickService service; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingWorkPalletServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingWorkPalletServiceTest.java deleted file mode 100644 index f2ce1d0c2..000000000 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingWorkPalletServiceTest.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.baiyue.firstleg.service; - -import com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest; -import com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest; -import com.baiyue.firstleg.entity.LoadingWorkPalletCartonEntity; -import com.baiyue.firstleg.entity.LoadingWorkPalletEntity; -import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper; -import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper.CartonCandidate; -import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper; -import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper.TaskContext; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; - -import java.util.List; - -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.Mockito.doAnswer; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@SpringJUnitConfig(LoadingWorkPalletService.class) -class LoadingWorkPalletServiceTest { - @MockBean private LoadingWorkPalletMapper pallets; - @MockBean private LoadingWorkPalletCartonMapper cartons; - @MockBean private InboundHoldGuard inboundHoldGuard; - @Resource private LoadingWorkPalletService service; - - @Test - void scansDropshipCartonOntoMixedCustomerWorkPalletWithoutChangingPickWorkflow() { - when(pallets.selectTaskContext(10L, 20L)).thenReturn(context()); - LoadingWorkPalletEntity pallet = pallet(30L, "OPEN"); - when(pallets.selectScoped(10L, 30L, 20L)).thenReturn(pallet); - CartonCandidate candidate = candidate(); - when(cartons.selectCandidates(10L, 20L, "BOX-A")).thenReturn(List.of(candidate)); - doAnswer(invocation -> { - LoadingWorkPalletCartonEntity value = invocation.getArgument(0); - value.setId(50L); - return 1; - }).when(cartons).insert(any(LoadingWorkPalletCartonEntity.class)); - when(cartons.selectPalletCartons(30L)).thenAnswer(invocation -> List.of()); - - service.scan(10L, 30L, new ScanRequest(20L, "BOX-A", "SCAN-1"), 99L, "作业员"); - - var captor = org.mockito.ArgumentCaptor.forClass(LoadingWorkPalletCartonEntity.class); - verify(cartons).insert(captor.capture()); - assertThat(captor.getValue().getTenantId()).isEqualTo(88L); - assertThat(captor.getValue().getLoadingPlanItemId()).isEqualTo(41L); - assertThat(captor.getValue().getSkuCode()).isEqualTo("SKU-A"); - } - - @Test - void refusesToSealUntilScannedCartonsAreReconciledByExistingPickSubmission() { - when(pallets.selectTaskContext(10L, 20L)).thenReturn(context()); - when(pallets.selectScoped(10L, 30L, 20L)).thenReturn(pallet(30L, "OPEN")); - when(cartons.countPalletCartons(30L)).thenReturn(2); - when(cartons.countUnreconciled(10L)).thenReturn(1); - - assertThatThrownBy(() -> service.seal(10L, 30L, - new SealRequest(20L, 0, "SEAL-1"), 99L, "作业员")) - .hasMessageContaining("原PDA拣货提交"); - } - - private static TaskContext context() { - return new TaskContext(10L, 11L, "LOADING", 20L, 99L, 2); - } - - private static LoadingWorkPalletEntity pallet(long id, String status) { - LoadingWorkPalletEntity value = new LoadingWorkPalletEntity(); - value.setId(id); value.setPickTaskId(10L); value.setLoadingPlanId(11L); - value.setWarehouseId(20L); value.setPalletCode("PLT-01"); - value.setPalletStatus(status); value.setVersion(0); - return value; - } - - private static CartonCandidate candidate() { - CartonCandidate value = new CartonCandidate(); - value.setCartonLabelId(40L); value.setTenantId(88L); value.setInboundId(81L); - value.setInboundDetailId(82L); value.setBoxCode("BOX-A"); - value.setLoadingPlanItemId(41L); value.setPickItemId(42L); - value.setSkuCode("SKU-A"); value.setUnitsPerCarton(20); - return value; - } -} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index cdbed4b46..dcfc5b417 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -34,6 +34,7 @@ import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member; import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.ChannelContainerCountRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.CandidateUnit; import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper.PlanColoadMemberRow; import com.baiyue.firstleg.split.service.SplitPreloadService; @@ -95,6 +96,7 @@ class SmartLoadingServiceTest { @MockBean private ColoadGroupAssignmentPort coloadGroups; @MockBean private PriceBookQueryUseCase priceBooks; @MockBean private FirstlegDocumentNumberGenerator documentNumbers; + @MockBean private DropshipPalletizationMapper dropshipPallets; @Resource private SmartLoadingService service; @TestConfiguration diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java index 52074bc89..f9c2dbdbb 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java @@ -48,7 +48,8 @@ public interface FirstlegUnifiedOrderClient { Long deliveryWarehouseId, String deliveryWarehouseName, String channelCode, String channelName, Integer totalPackageCount, String locationCodes, LocalDateTime expectedArrivalAt, LocalDateTime requestedPickupAt, String feeReviewStatus, - String companyName, String businessType, String taxMode, String containerMode, + String companyName, String businessType, String fulfillmentType, String lxCode, + String taxMode, String containerMode, Boolean customsInspection, String destinationType, String destinationCode, Boolean holdFlag, String currentHoldReason, BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg, BigDecimal actualVolumeCbm, diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java index aff44bcb5..c208a7fd5 100644 --- a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java @@ -72,13 +72,27 @@ public class InternalOrderCargoController { if (tenantId <= 0 || orderId <= 0) { throw new BizException(ResultCode.BAD_REQUEST, "租户和订单ID必须大于0"); } + var order = orders.findOwned(tenantId, orderId); + if (order == null) { + throw new BizException(ResultCode.NOT_FOUND, "订单不存在或不属于当前客户"); + } + boolean oneClickDropship = "ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType()); return ApiResponse.success(orders.findCargoInheritedByOrder(tenantId, orderId).stream() .map(cargo -> new CargoLabelBinding(cargo.getId(), orderId, cargo.getItemNo(), - cargo.getCargoName(), cargo.getSkuNo(), cargo.getPackageCount(), cargo.getLengthCm(), - cargo.getWidthCm(), cargo.getHeightCm())) + cargo.getCargoName(), cargo.getSkuNo(), cargo.getPackageCount(), + unitsPerCarton(cargo, oneClickDropship), cargo.getLengthCm(), cargo.getWidthCm(), + cargo.getHeightCm())) .toList()); } + private Integer unitsPerCarton(OrderCargoEntity cargo, boolean oneClickDropship) { + Integer value = cargo.getUnitsPerPackage(); + if (oneClickDropship && (value == null || value <= 0)) { + throw new BizException(ResultCode.INTERNAL_ERROR, "一件代发货物每箱件数缺失或无效"); + } + return value; + } + @GetMapping("/{tenantId}/{orderId}/inbound-snapshot") public ApiResponse inboundSnapshot( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @@ -155,12 +169,20 @@ public class InternalOrderCargoController { } public record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, - String skuNo, Integer packageCount, java.math.BigDecimal lengthCm, + String skuNo, Integer packageCount, Integer unitsPerCarton, + java.math.BigDecimal lengthCm, java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) { public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, Integer packageCount, java.math.BigDecimal lengthCm, java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) { - this(cargoId, orderId, itemNo, cargoName, null, packageCount, lengthCm, widthCm, heightCm); + this(cargoId, orderId, itemNo, cargoName, null, packageCount, null, + lengthCm, widthCm, heightCm); + } + public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, + String skuNo, Integer packageCount, java.math.BigDecimal lengthCm, + java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) { + this(cargoId, orderId, itemNo, cargoName, skuNo, packageCount, null, + lengthCm, widthCm, heightCm); } } diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java index 33aab072f..60c4b8a48 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java @@ -42,7 +42,7 @@ public class PlatformUnifiedOrderController { @GetMapping @PreAuthorize("hasAuthority('platform:order:list')") - @Operation(summary = "统一订单列表", description = "返回七态产品主状态及测量、预装、装柜、派送摘要。") + @Operation(summary = "统一订单列表", description = "返回八态产品主状态及测量、组托、预装、装柜、派送摘要。") public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) Long tenantId, diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java index cc3d7d8ee..27b61e6ee 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java @@ -12,8 +12,8 @@ import java.time.LocalDateTime; public record UnifiedOrderQuery( @Size(max = 64) String keyword, @Size(max = 64) String orderNo, - @Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", - "LOADING", "LOADED", "DELIVERED"}) + @Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", + "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"}) @Size(max = 32) String mainStatus, Long customerId, Long receivingWarehouseId, diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java index 35cf62245..63de363b4 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java @@ -24,7 +24,7 @@ public final class UnifiedOrderResponse { BigDecimal estimatedVolumeCbm, BigDecimal expectedVolumeCbm, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm, LocalDateTime expectedArrivalAt, String feeReviewStatus, - String companyName, String businessType, String pickupMethod, + String companyName, String businessType, String fulfillmentType, String lxCode, String pickupMethod, String taxMode, String containerMode, Boolean customsInspection, String destinationType, String destinationCode, Integer totalPalletCount, BigDecimal chargeableWeightKg, BigDecimal chargeableVolumeCbm, diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 39caa2e28..a4a26c5d3 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -38,7 +38,8 @@ import java.util.stream.Collectors; @Service public class UnifiedOrderQueryService { private static final Set MAIN_STATUSES = Set.of( - "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); + "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", + "LOADING", "LOADED", "DELIVERED"); private static final Set REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED"); private static final List ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of( OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED, @@ -311,7 +312,8 @@ public class UnifiedOrderQueryService { order.getInboundCode(), order.getExpectedShipDate(), null, null, totalPackageCount(cargo), null, null, order.getEstimatedVolumeCbm(), volumeCbm(cargo), null, null, - order.getExpectedArrivalAt(), null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(), + order.getExpectedArrivalAt(), null, null, null, order.getFulfillmentType(), order.getLxCode(), + order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(), null, null, null, null, null, null, null, order.getExpressCompany(), order.getExpressTrackingNo(), StringUtils.hasText(creatorName) ? creatorName : string(order.getCreatedBy()), skuNos(cargo), totalQuantity(cargo), shipmentId(order, cargo), referenceId(order, cargo), @@ -334,7 +336,8 @@ public class UnifiedOrderQueryService { order.getEstimatedVolumeCbm(), volumeCbm(cargo), inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt() : inbound.expectedArrivalAt(), inbound.feeReviewStatus(), - inbound.companyName(), inbound.businessType(), order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), + inbound.companyName(), inbound.businessType(), inbound.fulfillmentType(), inbound.lxCode(), + order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(), @@ -361,7 +364,8 @@ public class UnifiedOrderQueryService { order == null ? null : order.getEstimatedVolumeCbm(), volumeCbm(cargo), inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), order != null && order.getExpectedArrivalAt() != null ? order.getExpectedArrivalAt() : inbound.expectedArrivalAt(), inbound.feeReviewStatus(), inbound.companyName(), - inbound.businessType(), order == null ? null : order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), + inbound.businessType(), inbound.fulfillmentType(), inbound.lxCode(), + order == null ? null : order.getPickupMethod(), inbound.taxMode(), inbound.containerMode(), inbound.customsInspection(), inbound.destinationType(), inbound.destinationCode(), inbound.palletCount(), inbound.chargeableWeightKg(), inbound.chargeableVolumeCbm(), inbound.volumeWeightKg(), inbound.actualNetWeightKg(), @@ -502,7 +506,7 @@ public class UnifiedOrderQueryService { return switch (status) { case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING" -> "WAIT_MEASURE"; - case "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED" -> status; + case "INBOUNDED", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED" -> status; default -> null; }; } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 5a2af485c..54e30b550 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -173,7 +173,7 @@ paths: get: summary: 平台统一订单列表 description: >- - 统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。 + 统一返回八态产品主状态、下一步动作及测量/组托/预装/装柜/派送摘要,需要 platform:order:list。 入仓码来源由全系统统一模式控制:AUTO 在订单提交时按物流渠道生成,格式为物流前缀+YYMMDD+当天全平台票序; LEGACY_MANUAL 在草稿中录入旧系统入仓码。两种模式下入仓码一经提交均固定;有入库单时同时返回 inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion @@ -617,7 +617,7 @@ components: tenantId: {type: string, pattern: '^[1-9][0-9]*$'} keyword: {type: string, maxLength: 64} orderNo: {type: string, maxLength: 64} - mainStatus: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED]} + mainStatus: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED]} customerId: {type: string, pattern: '^[1-9][0-9]*$'} receivingWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} @@ -693,9 +693,10 @@ components: asOf: {type: string, format: date-time} UnifiedMainStatus: type: string - enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED] + enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED] description: >- - 统一订单七态产品主状态。WAIT_SPLIT 为正式待分柜状态,INBOUNDED 仅兼容存量已入库记录。 + 统一订单八态产品主状态。ONE_CLICK_DROPSHIP 测量计费完成后进入 WAIT_PALLETIZE,封托完成后进入 WAIT_SPLIT; + STANDARD 保持既有流程,INBOUNDED 仅兼容存量已入库记录。 UnifiedOrderPageEnvelope: type: object properties: @@ -766,6 +767,8 @@ components: feeReviewStatus: {type: string, nullable: true, description: Firstleg 入仓费用审核快照;财务状态不在本字段表达} companyName: {type: string, nullable: true} businessType: {type: string, nullable: true} + fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true, description: 独立履约类型,不复用计费业务类型} + lxCode: {type: string, nullable: true, description: 一件代发可选 LX 编码;普通头程或未填写时为 null} pickupMethod: {type: string, nullable: true} requestedPickupAt: {type: string, format: date-time, nullable: true, description: Customer-requested pickup appointment from the pickup snapshot} taxMode: {type: string, nullable: true} @@ -961,13 +964,13 @@ components: fulfillmentType: type: string enum: [STANDARD, ONE_CLICK_DROPSHIP] + default: STANDARD + description: 独立履约类型;省略为普通头程,不复用业务/计费 businessType lxCode: type: string nullable: true maxLength: 128 description: 一件代发绑定的领星入库单 LX 代码 - default: STANDARD - description: 独立履约类型;省略为普通头程,不复用业务/计费 businessType inboundCode: type: string maxLength: 40 @@ -1194,17 +1197,17 @@ components: name: mainStatus in: query required: false - description: 统一订单七态主状态筛选;WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询 + description: 统一订单八态主状态筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询 schema: type: string - enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED, WAIT_SPLIT] + enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED] UnifiedInboundCode: {name: inboundCode, in: query, required: false, description: 订单创建时生成的入仓码,所有订单状态均可查询, schema: {type: string, maxLength: 40}} UnifiedInternalOrderStatus: name: internalOrderStatus in: query required: false - description: Order 内部生命周期状态;不是七态产品主状态 + description: Order 内部生命周期状态;不是八态产品主状态 schema: {type: string, enum: [DRAFT, SUBMITTED, APPROVED, ACCEPTED, REJECTED, CANCELLED, PROCESSING, COMPLETED]} UnifiedExpectedArrivalDate: {name: expectedArrivalDate, in: query, required: false, schema: {type: string, format: date}} diff --git a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java index 97be8050c..45a57c801 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java @@ -88,7 +88,10 @@ class InternalOrderCargoControllerTest { @Test void listsCargoInRepositoryOrderForStableCartonAllocation() { OrderCargoEntity first = cargo(31L, 1, 3, "60", "40", "30"); + first.setUnitsPerPackage(24); OrderCargoEntity second = cargo(32L, 2, 2, "50", "30", "20"); + OrderEntity order = new OrderEntity(); order.setId(21L); order.setFulfillmentType("STANDARD"); + when(orders.findOwned(11L, 21L)).thenReturn(order); when(orders.findCargoInheritedByOrder(11L, 21L)).thenReturn(List.of(first, second)); var response = controller.listForCartonLabels("internal-token", 11L, 21L); @@ -97,10 +100,68 @@ class InternalOrderCargoControllerTest { assertThat(response.data()).extracting(InternalOrderCargoController.CargoLabelBinding::cargoId) .containsExactly(31L, 32L); assertThat(response.data().get(0).packageCount()).isEqualTo(3); + assertThat(response.data().get(0).unitsPerCarton()).isEqualTo(24); + assertThat(response.data().get(1).unitsPerCarton()).isNull(); assertThat(response.data().get(0).lengthCm()).isEqualByComparingTo("60"); + verify(orders).findOwned(11L, 21L); verify(orders).findCargoInheritedByOrder(11L, 21L); } + @Test + void oneClickDropshipReturnsAuthoritativePositiveUnitsPerCarton() { + OrderEntity order = new OrderEntity(); + order.setId(21L); order.setFulfillmentType("ONE_CLICK_DROPSHIP"); + OrderCargoEntity cargo = cargo(31L, 1, 3, "60", "40", "30"); + cargo.setUnitsPerPackage(12); + when(orders.findOwned(11L, 21L)).thenReturn(order); + when(orders.findCargoInheritedByOrder(11L, 21L)).thenReturn(List.of(cargo)); + + var response = controller.listForCartonLabels("internal-token", 11L, 21L); + + assertThat(response.data()).singleElement() + .extracting(InternalOrderCargoController.CargoLabelBinding::unitsPerCarton) + .isEqualTo(12); + } + + @Test + void oneClickDropshipFailsClosedWhenUnitsPerCartonIsMissing() { + OrderEntity order = new OrderEntity(); + order.setId(21L); order.setFulfillmentType("ONE_CLICK_DROPSHIP"); + when(orders.findOwned(11L, 21L)).thenReturn(order); + when(orders.findCargoInheritedByOrder(11L, 21L)) + .thenReturn(List.of(cargo(31L, 1, 3, "60", "40", "30"))); + + assertThatThrownBy(() -> controller.listForCartonLabels("internal-token", 11L, 21L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("每箱件数缺失或无效"); + } + + @Test + void oneClickDropshipFailsClosedWhenUnitsPerCartonIsNotPositive() { + OrderEntity order = new OrderEntity(); + order.setId(21L); order.setFulfillmentType("ONE_CLICK_DROPSHIP"); + OrderCargoEntity cargo = cargo(31L, 1, 3, "60", "40", "30"); + cargo.setUnitsPerPackage(0); + when(orders.findOwned(11L, 21L)).thenReturn(order); + when(orders.findCargoInheritedByOrder(11L, 21L)).thenReturn(List.of(cargo)); + + assertThatThrownBy(() -> controller.listForCartonLabels("internal-token", 11L, 21L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("每箱件数缺失或无效"); + } + + @Test + void cartonLabelListDoesNotExposeCargoWhenOrderIsOutsideCustomerScope() { + when(orders.findOwned(12L, 21L)).thenReturn(null); + + assertThatThrownBy(() -> controller.listForCartonLabels("internal-token", 12L, 21L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("订单不存在或不属于当前客户"); + + verify(orders).findOwned(12L, 21L); + verifyNoMoreInteractions(orders); + } + @Test void childOrderCanInheritRootCargoBindingWithoutCrossCustomerLeak() { OrderCargoEntity cargo = cargo(31L, 1, 3, "60", "40", "30"); diff --git a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java index 8a8586b3d..90086d33e 100644 --- a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java @@ -2,6 +2,8 @@ package com.baiyue.order.controller; import com.baiyue.order.client.FirstlegUnifiedOrderClient; import com.baiyue.order.client.FirstlegColoadGroupClient; +import com.baiyue.order.dto.UnifiedOrderQuery; +import io.swagger.v3.oas.annotations.media.Schema; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.cloud.openfeign.FeignClient; @@ -26,7 +28,7 @@ class UnifiedOrderApiContractTest { } @Test - void documentsSevenProductMainStatuses() throws Exception { + void documentsEightProductMainStatuses() throws Exception { String yaml = new ClassPathResource("openapi/order-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); Map document = stringMap(new Yaml().load(yaml)); @@ -40,22 +42,32 @@ class UnifiedOrderApiContractTest { Map mainStatus = stringMap(parameters.get("UnifiedMainStatus")); Map schema = stringMap(mainStatus.get("schema")); assertThat(stringList(schema.get("enum"))).containsExactly( - "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED", "WAIT_SPLIT"); + "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", + "LOADING", "LOADED", "DELIVERED"); assertThat(mainStatus.get("description").toString()) - .contains("WAIT_SPLIT", "INBOUNDED"); + .contains("WAIT_PALLETIZE", "WAIT_SPLIT", "INBOUNDED"); Map schemas = stringMap(components.get("schemas")); Map responseStatus = stringMap(schemas.get("UnifiedMainStatus")); assertThat(stringList(responseStatus.get("enum"))).containsExactly( - "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); - assertThat(responseStatus.get("description").toString()).contains("WAIT_SPLIT", "INBOUNDED"); + "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", + "LOADING", "LOADED", "DELIVERED"); + assertThat(responseStatus.get("description").toString()) + .contains("WAIT_PALLETIZE", "WAIT_SPLIT", "INBOUNDED"); + Schema queryAnnotation = UnifiedOrderQuery.class.getDeclaredField("mainStatus") + .getAnnotation(Schema.class); + assertThat(queryAnnotation).isNotNull(); + assertThat(queryAnnotation.allowableValues()).containsExactly( + "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", + "LOADING", "LOADED", "DELIVERED"); Map row = stringMap(schemas.get("UnifiedOrderRow")); assertThat(stringMap(row.get("properties"))).containsKeys( "orderId", "mainStatus", "channelCode", "inboundId", "inboundVersion", "measurement", "preload", "loading", "delivery", "transport", "customerRemark", "latestInternalNote", "holdFlag", "currentHoldReason", - "expectedArrivalAt", "requestedPickupAt", "feeReviewStatus", "companyName", "businessType", "pickupMethod", + "expectedArrivalAt", "requestedPickupAt", "feeReviewStatus", "companyName", "businessType", + "fulfillmentType", "lxCode", "pickupMethod", "taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode", "totalPalletCount", "chargeableWeightKg", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg", "measurementCompletedAt", "inboundCompletedAt", "expressCompany", "expressTrackingNo", @@ -86,7 +98,7 @@ class UnifiedOrderApiContractTest { assertThat(page.getAnnotation(PostMapping.class).value()).containsExactly("/query"); assertThat(List.of(FirstlegUnifiedOrderClient.Inbound.class.getRecordComponents()).stream() .map(component -> component.getName())) - .contains("channelCode", "channelName", "holdFlag", "currentHoldReason") + .contains("channelCode", "channelName", "holdFlag", "currentHoldReason", "fulfillmentType", "lxCode") .doesNotContain("holdCargo", "holdReason"); } diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index a7b18a898..6489b0fc5 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -549,6 +549,70 @@ class UnifiedOrderQueryServiceTest { assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_SPLIT"); } + @Test + void waitPalletizeFilterReturnsDropshipProjectionAndForwardsToFirstleg() { + UnifiedOrderQuery query = query("WAIT_PALLETIZE"); + var summary = summary("WAIT_PALLETIZE", "PALLETIZE_DROPSHIP"); + when(firstleg.page(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + when(orders.findByIds(eq(10001L), eq(List.of(77L)))).thenReturn(List.of(order(OrderStatus.PROCESSING))); + + var result = service.page(10001L, query); + + assertThat(result.records()).singleElement().satisfies(row -> { + assertThat(row.mainStatus()).isEqualTo("WAIT_PALLETIZE"); + assertThat(row.nextAction()).isEqualTo("PALLETIZE_DROPSHIP"); + assertThat(row.fulfillmentType()).isEqualTo("ONE_CLICK_DROPSHIP"); + assertThat(row.lxCode()).isEqualTo("LX-20260908-01"); + }); + ArgumentCaptor captor = + ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); + verify(firstleg).page(eq("internal-token"), captor.capture()); + assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_PALLETIZE"); + } + + @Test + void orderOnlyProjectionUsesExistingFulfillmentFieldsWithoutCreatingInboundFacts() { + UnifiedOrderQuery query = query("PENDING_REVIEW"); + OrderEntity order = order(OrderStatus.SUBMITTED); + order.setFulfillmentType("ONE_CLICK_DROPSHIP"); + order.setLxCode("LX-ORDER-01"); + when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED)))) + .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); + + assertThat(service.page(10001L, query).records()).singleElement().satisfies(row -> { + assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW"); + assertThat(row.fulfillmentType()).isEqualTo("ONE_CLICK_DROPSHIP"); + assertThat(row.lxCode()).isEqualTo("LX-ORDER-01"); + assertThat(row.inboundId()).isNull(); + }); + } + + @Test + void detailMapsWaitPalletizeSummaryAndTimeline() { + OrderEntity order = order(OrderStatus.PROCESSING); + when(orders.findOwned(10001L, 77L)).thenReturn(order); + when(orders.findCargo(10001L, 77L)).thenReturn(List.of()); + when(orders.findParties(10001L, 77L)).thenReturn(List.of()); + when(orders.findNotes(10001L, 77L)).thenReturn(List.of()); + when(orders.findHistory(10001L, 77L)).thenReturn(List.of()); + var timeline = new FirstlegUnifiedOrderClient.Timeline( + "FIRSTLEG", "STATUS", "WAIT_MEASURE", "WAIT_PALLETIZE", "MEASUREMENT_APPROVED", + null, 9L, "平台操作员", LocalDateTime.of(2026, 9, 8, 10, 0)); + var summary = summary("WAIT_PALLETIZE", "PALLETIZE_DROPSHIP"); + when(firstleg.detail(eq("internal-token"), any())).thenReturn(ApiResponse.success( + new FirstlegUnifiedOrderClient.Detail(summary, List.of(timeline)))); + + var result = service.detail(10001L, 77L, false); + + assertThat(result.summary().mainStatus()).isEqualTo("WAIT_PALLETIZE"); + assertThat(result.summary().fulfillmentType()).isEqualTo("ONE_CLICK_DROPSHIP"); + assertThat(result.summary().lxCode()).isEqualTo("LX-20260908-01"); + assertThat(result.timeline()).singleElement() + .extracting(UnifiedOrderResponse.TimelineEvent::mainStatus) + .isEqualTo("WAIT_PALLETIZE"); + } + @Test void listKeepsLoadedFulfillmentAheadOfRawApprovedOrderStatus() { UnifiedOrderQuery query = query(null); @@ -728,7 +792,7 @@ class UnifiedOrderQueryServiceTest { @Test void fulfillmentTabsForwardStatusSearchAndPaginationToFirstleg() { List statuses = List.of( - "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED"); + "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); when(firstleg.page(eq("internal-token"), any())) .thenReturn(ApiResponse.success(new PageResult<>(List.of(), 3, 20, 0, 0))); @@ -840,6 +904,8 @@ class UnifiedOrderQueryServiceTest { when(inbound.locationCodes()).thenReturn("A0-01, B2-03"); when(inbound.companyName()).thenReturn("测试客户有限公司"); when(inbound.businessType()).thenReturn("SEA_LCL"); + when(inbound.fulfillmentType()).thenReturn("ONE_CLICK_DROPSHIP"); + when(inbound.lxCode()).thenReturn("LX-20260908-01"); when(inbound.palletCount()).thenReturn(2); when(inbound.actualGrossWeightKg()).thenReturn(new BigDecimal("1787.500")); when(inbound.actualNetWeightKg()).thenReturn(new BigDecimal("1747.500")); diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 2720ace8c..d7d9ade8f 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -21,7 +21,7 @@ | 订舱与拖车服务中心链路 | `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 单独识别 | +| 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`(含国内 PDA Node 全量测试)、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问开发或测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 | | 部署与基础设施 | `../deploy/scripts/`、`../deploy/rocketmq/` | 靠近部署组件管理,不移动到根脚本目录;部署、初始化数据库或发布配置必须获得当前轮明确授权 | | 服务局部合同 | `../firstleg/scripts/`、`../lastleg-service/scripts/`、`../warehouse/scripts/` | 只服务所属模块,入口与命令登记在服务 `AGENTS.md` 或局部 README;不得散落到 `src/test/scripts/` | diff --git a/scripts/README.md b/scripts/README.md index 5723e3f05..efa34b816 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -79,7 +79,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜 | 订单与客户本地验收 | `phase1-business-maturity-regression.ps1` | 订单与客户业务成熟度回归,调用本地服务并生成报告 | | 订单与客户本地验收 | `phase1-onboarding-order-e2e.ps1` | 注册、资质、审核到下单的本地 E2E,会创建客户、仓库、往来单位和订单 | | 本地质量 | `backend-full-regression.ps1` | Mapper 门禁后执行受影响后端 Maven 测试;`-DryRun` 只打印与校验 | -| 本地质量 | `frontend-full-regression.ps1` | 编排 Admin/Client 本地测试与类型检查,不访问 Gateway | +| 本地质量 | `frontend-full-regression.ps1` | 编排 Admin/Client 本地测试、类型检查及国内 PDA Node 全量测试,不访问 Gateway | | 本地质量 | `database-comment-audit.ps1` | 离线扫描数据库中文注释,不连接数据库 | | 本地质量 | `local-db-full-acceptance.ps1` | 只允许本机 MySQL 的受控 SQL 验收;非 dry-run 会写本地测试库 | | 本地质量 | `mapper-scan-contract.mjs` | 离线检查 Mapper 注解与扫描入口,不启动 Spring、不连接数据库 | @@ -153,8 +153,8 @@ node scripts/create-pending-review-orders.mjs | 检查 | 入口 | |---|---| | 后端 Maven 契约/单元测试 | `scripts/backend-full-regression.ps1` | -| Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` | -| PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` | +| Admin/Client/国内 PDA 本地全量回归 | `scripts/frontend-full-regression.ps1` | +| PDA 本地页面和状态定向测试 | `../apps/pda-uniapp/tests/*.test.mjs` | | 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` | | AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、海外仓业务范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口与排除目录 | | Mapper 注册静态合同 | `node scripts/mapper-scan-contract.mjs`、`node --test scripts/mapper-scan-contract.test.mjs`;自动核对全部持久化模块的 `@Mapper`、服务根包注解过滤扫描和唯一扫描入口,不启动 Spring、不连接数据库;`backend-full-regression.ps1` 在 Maven 前强制执行 | @@ -182,13 +182,14 @@ Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块 ## 前端全量回归 -`frontend-full-regression.ps1` 只编排本地 Admin/Client 的 `pnpm test` 与 `pnpm typecheck`,不调用 Gateway、不部署、不连接数据库。 +`frontend-full-regression.ps1` 编排本地 Admin/Client 的 `pnpm test`、`pnpm typecheck`,并默认执行国内 PDA 的 `node --test tests/*.test.mjs`;不调用 Gateway、不部署、不连接数据库。 ```powershell .\scripts\frontend-full-regression.ps1 -DryRun .\scripts\frontend-full-regression.ps1 .\scripts\frontend-full-regression.ps1 -SkipAdmin .\scripts\frontend-full-regression.ps1 -SkipClient +.\scripts\frontend-full-regression.ps1 -SkipPda ``` 脚本会检查 `pnpm` 和目标应用的 `package.json`;任一命令失败时立即透传退出码,不能同时跳过 Admin 和 Client。 diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index da6235346..3e2f0c40b 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -887,7 +887,7 @@ $criticalPaths = @( 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java', 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/OverseasInboundOrderDetainedChangedV1.java', - 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java', 'baiyue-firstleg/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java', diff --git a/scripts/frontend-full-regression.ps1 b/scripts/frontend-full-regression.ps1 index bacde7fa6..62da1863c 100644 --- a/scripts/frontend-full-regression.ps1 +++ b/scripts/frontend-full-regression.ps1 @@ -2,7 +2,8 @@ param( [switch]$DryRun, [switch]$SkipAdmin, - [switch]$SkipClient + [switch]$SkipClient, + [switch]$SkipPda ) $ErrorActionPreference = 'Stop' @@ -17,8 +18,22 @@ function Invoke-FrontendStep { if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } -if ($SkipAdmin -and $SkipClient) { Write-Error 'At least one of Admin or Client must be selected.'; exit 2 } -if (-not (Get-Command pnpm -ErrorAction SilentlyContinue)) { Write-Error 'pnpm was not found on PATH.'; exit 2 } +function Invoke-PdaTests { + param([string]$Directory) + $pattern = Join-Path $Directory 'tests/*.test.mjs' + Write-Host " [test] node --test `"$pattern`"" + if ($DryRun) { return } + & node --test $pattern + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} + +if ($SkipAdmin -and $SkipClient -and $SkipPda) { Write-Error 'At least one of Admin, Client or PDA must be selected.'; exit 2 } +if ((-not $SkipAdmin -or -not $SkipClient) -and -not (Get-Command pnpm -ErrorAction SilentlyContinue)) { + Write-Error 'pnpm was not found on PATH.'; exit 2 +} +if (-not $SkipPda -and -not (Get-Command node -ErrorAction SilentlyContinue)) { + Write-Error 'node was not found on PATH.'; exit 2 +} $targets = @() if (-not $SkipAdmin) { $targets += @{ Name = 'Admin'; Directory = Join-Path $repoRoot 'apps/admin-web' } } @@ -37,4 +52,13 @@ foreach ($target in $targets) { Invoke-FrontendStep 'typecheck' $target.Directory 'typecheck' } +if (-not $SkipPda) { + $pdaDirectory = Join-Path $repoRoot 'apps/pda-uniapp' + if (-not (Test-Path -LiteralPath (Join-Path $pdaDirectory 'tests'))) { + Write-Error "PDA tests directory not found: $pdaDirectory"; exit 2 + } + Write-Host '[PDA]' + Invoke-PdaTests $pdaDirectory +} + Write-Host 'Frontend regression completed.' diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index ef215bd33..747b9542f 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -7,8 +7,16 @@ import { normalizeModuleOrder } from './admin-test-env-acceptance.mjs' const scriptsDir = dirname(fileURLToPath(import.meta.url)) const backendScript = readFileSync(resolve(scriptsDir, 'backend-full-regression.ps1'), 'utf8') +const frontendScript = readFileSync(resolve(scriptsDir, 'frontend-full-regression.ps1'), 'utf8') const adminAcceptance = readFileSync(resolve(scriptsDir, 'admin-test-env-acceptance.mjs'), 'utf8') +test('frontend full regression includes domestic PDA node tests by default', () => { + assert.match(frontendScript, /\[switch\]\$SkipPda/) + assert.match(frontendScript, /apps\/pda-uniapp/) + assert.match(frontendScript, /node --test/) + assert.match(frontendScript, /tests\/\*\.test\.mjs/) +}) + test('backend full regression includes file reactor and critical file/container coverage', () => { assert.match(backendScript, /-pl["',\s\S]*order,firstleg,iam,gateway,file/) for (const required of [ From 442518c382e3c28c89e381b4312885fb71d406e9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:27:20 +0800 Subject: [PATCH 32/56] feat(firstleg): add private warehouse delivery fee --- deploy/sql/头程数据库统一增量-MySQL8.sql | 14 +++++++++++ .../service/InboundFeeDetailService.java | 9 ++++++++ ...se2-published-freight-detail-migration.sql | 23 +++++++++++++++---- .../resources/openapi/fba-pricing-v1.yaml | 2 +- .../FeeItemMigrationSqlContractTest.java | 2 ++ .../service/FeeManagementServiceTest.java | 23 +++++++++++++++++++ 6 files changed, 67 insertions(+), 6 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index ac44ef196..a1f23b92b 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -7681,6 +7681,15 @@ SELECT 193000000001000111, 0, 'FBA_HEAD_FREIGHT', '头程运费', 'INTERNATIONAL 0, 'system', '2026-08-14 00:00:00.000', 0, 'system', '2026-08-14 00:00:00.000' WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'FBA_HEAD_FREIGHT'); +INSERT INTO fl_fee_item (id, tenant_id, fee_code, category, trade_scope, fee_type_name, + pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, + allocation_fee, description, status, created_by, created_by_name, created_at, + updated_by, updated_by_name, updated_at, deleted, version) +SELECT 193000000001000112, 0, 'PRIVATE_LAST_MILE_DELIVERY', '尾程派送费', 'INTERNATIONAL', + '私人仓尾程派送费', 0, 0, 1, 1, 0, '仅用于destinationType=PRIVATE的入库单', + 'ACTIVE', 0, 'SYSTEM', CURRENT_TIMESTAMP(3), 0, 'SYSTEM', CURRENT_TIMESTAMP(3), 0, 0 +WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'PRIVATE_LAST_MILE_DELIVERY'); + -- ============================================================================ -- 2026-08-06:三期分柜、预装、拣货、装柜和派送当前基线 -- ============================================================================ @@ -15607,6 +15616,11 @@ BEGIN AND category = '头程运费' AND trade_scope = 'INTERNATIONAL' AND fee_type_name = 'FBA头程运费' AND pickup_payable = 0 AND pickup_receivable = 0 AND warehouse_payable = 0 AND warehouse_receivable = 1 AND allocation_fee = 0 AND status = 'ACTIVE' AND deleted = 0) <> 1 + OR (SELECT COUNT(*) FROM baiyue_firstleg.fl_fee_item + WHERE tenant_id = 0 AND fee_code = 'PRIVATE_LAST_MILE_DELIVERY' + AND category = '尾程派送费' AND trade_scope = 'INTERNATIONAL' + AND fee_type_name = '私人仓尾程派送费' AND warehouse_payable = 1 + AND warehouse_receivable = 1 AND status = 'ACTIVE' AND deleted = 0) <> 1 OR EXISTS ( SELECT 1 FROM baiyue_firstleg.fl_inbound_fee_detail detail diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java index b35a25a7a..de61d512c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java @@ -23,6 +23,7 @@ import java.util.List; @Service public class InboundFeeDetailService { private static final String FREIGHT_FEE_CODE = "FBA_HEAD_FREIGHT"; + private static final String PRIVATE_LAST_MILE_DELIVERY_FEE_CODE = "PRIVATE_LAST_MILE_DELIVERY"; @Resource private FeeItemRepository repository; @Resource @@ -379,6 +380,7 @@ public class InboundFeeDetailService { private void apply(InboundFeeDetailEntity value, InboundOrderEntity inbound, FeeItemEntity item, SaveRequest request, long operatorId) { + ensureDestinationApplicable(item, inbound); ensureWarehouseApplicable(item, request.direction()); value.setFeeItemId(item.getId()); value.setInboundCode(inbound.getInboundCode()); @@ -408,6 +410,13 @@ public class InboundFeeDetailService { value.setUpdatedBy(operatorId); } + private static void ensureDestinationApplicable(FeeItemEntity item, InboundOrderEntity inbound) { + if (PRIVATE_LAST_MILE_DELIVERY_FEE_CODE.equals(item.getFeeCode()) + && !"PRIVATE".equals(inbound.getDestinationType())) { + throw conflict("私人仓尾程派送费只能绑定目的地类型为PRIVATE的入库单"); + } + } + private void applySupplierSnapshot(InboundFeeDetailEntity value, FeeItemEntity item, String direction, Long supplierId) { if (!"PAYABLE".equals(direction)) { diff --git a/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql b/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql index 4fa48c82d..0e115c876 100644 --- a/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql +++ b/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql @@ -471,6 +471,15 @@ SELECT 193000000001000111, 0, 'FBA_HEAD_FREIGHT', '头程运费', 'INTERNATIONAL 0, 'system', '2026-08-14 00:00:00.000', 0, 'system', '2026-08-14 00:00:00.000' WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'FBA_HEAD_FREIGHT'); +INSERT INTO fl_fee_item (id, tenant_id, fee_code, category, trade_scope, fee_type_name, + pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, + allocation_fee, description, status, created_by, created_by_name, created_at, + updated_by, updated_by_name, updated_at, deleted, version) +SELECT 193000000001000112, 0, 'PRIVATE_LAST_MILE_DELIVERY', '尾程派送费', 'INTERNATIONAL', + '私人仓尾程派送费', 0, 0, 1, 1, 0, '仅用于destinationType=PRIVATE的入库单', + 'ACTIVE', 0, 'SYSTEM', CURRENT_TIMESTAMP(3), 0, 'SYSTEM', CURRENT_TIMESTAMP(3), 0, 0 +WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'PRIVATE_LAST_MILE_DELIVERY'); + DROP PROCEDURE IF EXISTS phase2_publish_assert_fee_schema; DELIMITER $$ CREATE PROCEDURE phase2_publish_assert_fee_schema() @@ -491,11 +500,15 @@ BEGIN OR (SELECT COUNT(*) FROM fl_fee_item WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0 AND id BETWEEN 193000000001000001 AND 193000000001000110 AND fee_code = CONCAT('FEE_TYPE_', LPAD(id - 193000000001000000, 3, '0'))) <> 110 - OR (SELECT COUNT(*) FROM fl_fee_item WHERE tenant_id = 0 AND fee_code = 'FBA_HEAD_FREIGHT' - AND category = '头程运费' AND trade_scope = 'INTERNATIONAL' AND fee_type_name = 'FBA头程运费' - AND pickup_payable = 0 AND pickup_receivable = 0 AND warehouse_payable = 0 - AND warehouse_receivable = 1 AND allocation_fee = 0 AND status = 'ACTIVE' AND deleted = 0) <> 1 - OR EXISTS (SELECT 1 FROM fl_inbound_fee_detail d JOIN fl_fee_item f ON f.id = d.fee_item_id + OR (SELECT COUNT(*) FROM fl_fee_item WHERE tenant_id = 0 AND fee_code = 'FBA_HEAD_FREIGHT' + AND category = '头程运费' AND trade_scope = 'INTERNATIONAL' AND fee_type_name = 'FBA头程运费' + AND pickup_payable = 0 AND pickup_receivable = 0 AND warehouse_payable = 0 + AND warehouse_receivable = 1 AND allocation_fee = 0 AND status = 'ACTIVE' AND deleted = 0) <> 1 + OR (SELECT COUNT(*) FROM fl_fee_item WHERE tenant_id = 0 AND fee_code = 'PRIVATE_LAST_MILE_DELIVERY' + AND category = '尾程派送费' AND trade_scope = 'INTERNATIONAL' + AND fee_type_name = '私人仓尾程派送费' AND warehouse_payable = 1 + AND warehouse_receivable = 1 AND status = 'ACTIVE' AND deleted = 0) <> 1 + OR EXISTS (SELECT 1 FROM fl_inbound_fee_detail d JOIN fl_fee_item f ON f.id = d.fee_item_id WHERE d.tenant_id = 0 OR f.tenant_id <> 0) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二期迁移失败:费用项目未完成平台公共作用域或明细跨客户引用校验'; END IF; diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index df24f0f7b..240b9e4e1 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -184,7 +184,7 @@ paths: responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailListApiResponse'}}}}, '403': {description: 无权限}} post: summary: 新增 MANUAL 其他费用 - description: 只允许创建 MANUAL 来源;FBA_PRICING 由系统从 fl_inbound_fee 派生。 + description: 只允许创建 MANUAL 来源;FBA_PRICING 由系统从 fl_inbound_fee 派生。费用项目 PRIVATE_LAST_MILE_DELIVERY 是独立的私人仓尾程派送费,仅 destinationType=PRIVATE 的入库单可创建;服务端按稳定 feeCode 校验,不按中文名称判断。 parameters: [{$ref: '#/components/parameters/AccountId'}, {$ref: '#/components/parameters/InboundId'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailSaveRequest'}}}} responses: {'200': {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/InboundFeeDetailApiResponse'}}}}, '403': {description: 无权限}} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java index 38a44e5ce..144a900f1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java @@ -24,6 +24,8 @@ class FeeItemMigrationSqlContractTest { .contains("matched_seed.trade_scope = f.trade_scope") .contains("193000000001000111, 0, 'FBA_HEAD_FREIGHT'") .contains("fee_code = 'FBA_HEAD_FREIGHT'") + .contains("193000000001000112, 0, 'PRIVATE_LAST_MILE_DELIVERY'") + .contains("fee_code = 'PRIVATE_LAST_MILE_DELIVERY'") .contains("warehouse_receivable = 1") .doesNotContain("LEFT JOIN tmp_fee_item_seed by_code") .doesNotContain("LEFT JOIN tmp_fee_item_seed by_semantic") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java index 6588c9a40..a0fdc7b00 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java @@ -337,6 +337,29 @@ class FeeManagementServiceTest { .hasMessageContaining("仓储应收/应付"); } + @Test + @DisplayName("私人仓尾程派送费只允许PRIVATE目的地") + void restrictsPrivateLastMileDeliveryFeeByDestinationType() { + InboundOrderEntity fbaInbound = inbound(); + fbaInbound.setDestinationType("FBA"); + FeeItemEntity privateDelivery = item(true, true); + privateDelivery.setFeeCode("PRIVATE_LAST_MILE_DELIVERY"); + when(repository.findInbound(10001L, 21L)).thenReturn(fbaInbound); + when(repository.findItem(11L)).thenReturn(privateDelivery); + + assertThatThrownBy(() -> details.create(10001L, 21L, 9L, + detailRequest("RECEIVABLE"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("目的地类型为PRIVATE"); + verify(repository, never()).insertDetail(any()); + + fbaInbound.setDestinationType("PRIVATE"); + doAnswer(invocation -> { ((InboundFeeDetailEntity) invocation.getArgument(0)).setId(32L); return null; }) + .when(repository).insertDetail(any()); + assertThat(details.create(10001L, 21L, 9L, detailRequest("RECEIVABLE")).getId()) + .isEqualTo(32L); + } + @Test @DisplayName("审核只更新待审核明细") void approvesPendingDetail() { From eead11e99daab1f1347aa8a2cda2f9b9c6f79cfe Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:32:56 +0800 Subject: [PATCH 33/56] feat(order): add batch review preview --- .../PlatformFirstLegOrderController.java | 40 +++++++- .../dto/BatchOrderReviewPreviewResponse.java | 28 ++++++ .../com/baiyue/order/dto/OrderRequests.java | 13 ++- .../BatchOrderReviewPreviewService.java | 63 +++++++++++++ .../service/OrderApplicationService.java | 9 ++ .../src/main/resources/openapi/order-v1.yaml | 87 +++++++++++++++++ .../BatchReviewOpenApiContractTest.java | 30 ++++++ .../PlatformOrderPageControllerTest.java | 29 +++++- .../dto/OrderRequestsValidationTest.java | 14 +++ .../BatchOrderReviewPreviewServiceTest.java | 93 +++++++++++++++++++ .../OrderApplicationServiceStateTest.java | 18 ++++ 11 files changed, 414 insertions(+), 10 deletions(-) create mode 100644 order/src/main/java/com/baiyue/order/dto/BatchOrderReviewPreviewResponse.java create mode 100644 order/src/main/java/com/baiyue/order/service/BatchOrderReviewPreviewService.java create mode 100644 order/src/test/java/com/baiyue/order/controller/BatchReviewOpenApiContractTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/BatchOrderReviewPreviewServiceTest.java diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 56b740cfc..3eab42d11 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.order.dto.BatchOrderReviewResponse; +import com.baiyue.order.dto.BatchOrderReviewPreviewResponse; import com.baiyue.order.dto.OrderDetailResponse; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.dto.InboundCodePolicyResponse; @@ -17,6 +18,7 @@ import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.service.OrderApplicationService; import com.baiyue.order.service.OrderScopeGuard; import com.baiyue.order.service.InboundCodeAssociationService; +import com.baiyue.order.service.BatchOrderReviewPreviewService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -51,6 +53,8 @@ public class PlatformFirstLegOrderController { private OrderApplicationService service; @Resource private InboundCodeAssociationService inboundCodeAssociations; + @Resource + private BatchOrderReviewPreviewService batchReviewPreview; @GetMapping("/inbound-code-policy") @PreAuthorize("hasAnyAuthority('platform:order:list', 'platform:order:create', 'platform:order:edit', 'platform:order:submit')") @@ -192,15 +196,26 @@ public class PlatformFirstLegOrderController { } @BusinessAudit(event = "ORDER_BATCH_APPROVE", resourceType = "FIRST_LEG_ORDER_BATCH", - resourceId = "#request.orderIds", tenantId = "#tenantId", description = "批量审核通过订单") - @Operation(summary = "批量审核通过订单", description = "单租户模式下逐单独立审核,返回成功和失败明细。") + resourceId = "#request.items", tenantId = "#tenantId", description = "批量审核通过订单") + @Operation(summary = "批量审核通过订单", description = "单租户模式下逐票校验权限、状态及预览版本并独立提交;允许部分成功,返回成功和失败明细。") @PostMapping("/batch-approve") @PreAuthorize("hasAuthority('platform:order:approve')") public ApiResponse batchApprove( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @Valid @RequestBody OrderRequests.BatchReviewRequest request) { - return batchTransit(user, tenantId, request.orderIds(), OrderStatus.APPROVED, null); + return batchApproveTransit(user, tenantId, request.items()); + } + + @Operation(summary = "批量审核预览", description = "一次批量查询返回审核弹窗所需订单与中文货物摘要,固定两次数据库查询,不逐票读取详情。") + @PostMapping("/batch-review-preview") + @PreAuthorize("hasAuthority('platform:order:approve')") + public ApiResponse batchReviewPreview( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @Valid @RequestBody OrderRequests.BatchReviewRequest request) { + long scopedTenantId = OrderScopeGuard.platform(user, tenantId); + return ApiResponse.success(batchReviewPreview.preview(scopedTenantId, request.items())); } @BusinessAudit(event = "ORDER_REJECT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Reject order / 驳回订单") @@ -320,4 +335,23 @@ public class PlatformFirstLegOrderController { return ApiResponse.success(new BatchOrderReviewResponse(orderIds.size(), successOrderIds.size(), failedOrders.size(), List.copyOf(successOrderIds), List.copyOf(failedOrders))); } + + private ApiResponse batchApproveTransit( + AuthenticatedUser user, long tenantId, List items) { + long scopedTenantId = OrderScopeGuard.platform(user, tenantId); + List successOrderIds = new ArrayList<>(); + List failedOrders = new ArrayList<>(); + for (OrderRequests.BatchReviewItem item : items) { + try { + service.transit(scopedTenantId, user.userId(), user.username(), item.orderId(), + OrderStatus.APPROVED, null, item.orderVersion()); + successOrderIds.add(item.orderId()); + } catch (BizException exception) { + failedOrders.add(new BatchOrderReviewResponse.FailedOrder( + item.orderId(), exception.getResultCode().code(), exception.getMessage())); + } + } + return ApiResponse.success(new BatchOrderReviewResponse(items.size(), successOrderIds.size(), + failedOrders.size(), List.copyOf(successOrderIds), List.copyOf(failedOrders))); + } } diff --git a/order/src/main/java/com/baiyue/order/dto/BatchOrderReviewPreviewResponse.java b/order/src/main/java/com/baiyue/order/dto/BatchOrderReviewPreviewResponse.java new file mode 100644 index 000000000..f2737c4f5 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/BatchOrderReviewPreviewResponse.java @@ -0,0 +1,28 @@ +package com.baiyue.order.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** 平台批量审核前的一次性稳定展示快照。 */ +@Schema(description = "订单批量审核预览") +public record BatchOrderReviewPreviewResponse( + int total, + int approvableCount, + List items +) { + public record Item( + Long orderId, + Integer orderVersion, + String inboundCode, + Long customerId, + String customerName, + List chineseGoodsNames, + String channelCode, + String channelName, + String destinationWarehouseCode, + String destinationWarehouseName, + boolean canApprove, + String blockReason + ) {} +} diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index 670a89c7d..70ef87ce4 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -268,15 +268,22 @@ public final class OrderRequests { @Schema(name = "FirstLegOrderBatchReviewRequest", description = "单租户订单批量审核请求") public record BatchReviewRequest( - @Schema(description = "订单 ID 列表,单次最多 100 条", requiredMode = Schema.RequiredMode.REQUIRED) - @NotEmpty @Size(max = 100) List<@NotNull @Min(1) Long> orderIds + @Schema(description = "订单及列表快照版本,单次最多 100 条", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty @Size(max = 100) List<@Valid @NotNull BatchReviewItem> items ) { @AssertTrue(message = "订单 ID 不能重复") public boolean hasDistinctOrderIds() { - return orderIds == null || orderIds.size() == orderIds.stream().distinct().count(); + return items == null || items.size() == items.stream() + .map(item -> item == null ? null : item.orderId()).distinct().count(); } } + @Schema(name = "FirstLegOrderBatchReviewItem", description = "批量审核订单及其列表快照版本") + public record BatchReviewItem( + @NotNull @Min(1) Long orderId, + @NotNull @Min(0) Integer orderVersion + ) {} + @Schema(name = "FirstLegOrderBatchRejectRequest", description = "单租户订单批量驳回请求") public record BatchRejectRequest( @Schema(description = "订单 ID 列表,单次最多 100 条", requiredMode = Schema.RequiredMode.REQUIRED) diff --git a/order/src/main/java/com/baiyue/order/service/BatchOrderReviewPreviewService.java b/order/src/main/java/com/baiyue/order/service/BatchOrderReviewPreviewService.java new file mode 100644 index 000000000..07b3fc6e8 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/BatchOrderReviewPreviewService.java @@ -0,0 +1,63 @@ +package com.baiyue.order.service; + +import com.baiyue.order.dto.BatchOrderReviewPreviewResponse; +import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.entity.OrderCargoEntity; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderStatus; +import com.baiyue.order.repository.OrderRepository; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** 用固定两次批量查询生成审核弹窗快照,禁止前端逐票查询详情。 */ +@Service +public class BatchOrderReviewPreviewService { + @Resource + private OrderRepository orders; + + public BatchOrderReviewPreviewResponse preview(long tenantId, List requested) { + List orderIds = requested.stream().map(OrderRequests.BatchReviewItem::orderId).toList(); + Map byId = new LinkedHashMap<>(); + orders.findByIds(tenantId, orderIds).forEach(order -> byId.put(order.getId(), order)); + + Map> goodsNames = new LinkedHashMap<>(); + for (OrderCargoEntity cargo : orders.findCargoByOrderIds(tenantId, orderIds)) { + String name = cargo.getCargoName() == null ? null : cargo.getCargoName().trim(); + if (name == null || name.isEmpty()) continue; + List names = goodsNames.computeIfAbsent(cargo.getOrderId(), ignored -> new ArrayList<>()); + if (!names.contains(name)) names.add(name); + } + + List items = requested.stream() + .map(item -> toItem(item, byId.get(item.orderId()), goodsNames.getOrDefault(item.orderId(), List.of()))) + .toList(); + int approvableCount = (int) items.stream().filter(BatchOrderReviewPreviewResponse.Item::canApprove).count(); + return new BatchOrderReviewPreviewResponse(items.size(), approvableCount, items); + } + + private static BatchOrderReviewPreviewResponse.Item toItem(OrderRequests.BatchReviewItem requested, + OrderEntity order, + List goodsNames) { + if (order == null) { + return new BatchOrderReviewPreviewResponse.Item(requested.orderId(), requested.orderVersion(), + null, null, null, List.of(), null, null, null, null, false, + "订单不存在或不属于当前客户"); + } + String blockReason = null; + if (order.getStatus() != OrderStatus.SUBMITTED) { + blockReason = "当前订单状态不可审核"; + } else if (!Objects.equals(order.getVersion(), requested.orderVersion())) { + blockReason = "订单版本已变化,请刷新后重试"; + } + return new BatchOrderReviewPreviewResponse.Item(order.getId(), order.getVersion(), order.getInboundCode(), + order.getCustomerId(), order.getCustomerName(), List.copyOf(goodsNames), order.getChannelCode(), + order.getChannelName(), order.getAmazonWarehouseCode(), order.getDestinationWarehouseName(), + blockReason == null, blockReason); + } +} diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index f7654985b..bbc2390d9 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -148,7 +148,16 @@ public class OrderApplicationService { @Transactional public void transit(long tenantId, long userId, String username, long id, OrderStatus target, String reason) { + transit(tenantId, userId, username, id, target, reason, null); + } + + @Transactional + public void transit(long tenantId, long userId, String username, long id, OrderStatus target, String reason, + Integer expectedVersion) { OrderEntity order = require(tenantId, id); + if (expectedVersion != null && !expectedVersion.equals(order.getVersion())) { + throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新后重试"); + } if (target == OrderStatus.SUBMITTED) eligibilityGuard.requireApproved(tenantId); OrderStatus from = order.getStatus(); if (!allowed(from, target)) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index b1ea0db3c..b69987464 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -491,6 +491,45 @@ paths: responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 需要 platform:order:create 权限} + /api/platform/v1/orders/first-leg/batch-review-preview: + post: + tags: [Platform first-leg orders] + summary: 批量审核预览 + description: >- + 在一个 HTTP 请求中按目标客户批量返回审核弹窗所需稳定快照。服务端固定使用订单与货物两次批量查询, + 中文品名按货物 itemNo 顺序去重;不得由前端逐票请求详情。orderId 仅供路由,主要展示使用 inboundCode; + canApprove=false 时必须展示 blockReason。请求中的 orderVersion 用于识别列表快照是否过期。 + parameters: + - $ref: '#/components/parameters/TenantId' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/BatchReviewRequest'} + responses: + '200': + description: 返回请求顺序一致的批量审核预览 + content: + application/json: + schema: {$ref: '#/components/schemas/BatchReviewPreviewEnvelope'} + '403': {description: 需要平台身份和 platform:order:approve 权限} + /api/platform/v1/orders/first-leg/batch-approve: + post: + tags: [Platform first-leg orders] + summary: 批量审核通过订单 + description: >- + 单客户范围内逐票重新校验权限、状态和 orderVersion,每票独立事务;允许部分成功, + failedOrders 返回未成功订单及业务原因,成功订单不会因其他订单失败而回滚。 + parameters: + - $ref: '#/components/parameters/TenantId' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/BatchReviewRequest'} + responses: + '200': {description: 返回部分成功语义的逐票处理汇总} + '403': {description: 需要平台身份和 platform:order:approve 权限} /api/platform/v1/orders/first-leg/tenant-statistics: get: tags: [Platform first-leg orders] @@ -635,6 +674,54 @@ components: widthCm: {type: number, nullable: true} heightCm: {type: number, nullable: true} shipmentId: {type: string, nullable: true, description: 当前货物行的 FBA 货件号快照,不以订单级字段回退} + BatchReviewRequest: + type: object + required: [items] + properties: + items: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: {$ref: '#/components/schemas/BatchReviewRequestItem'} + BatchReviewRequestItem: + type: object + required: [orderId, orderVersion] + properties: + orderId: {type: string, pattern: '^[1-9][0-9]*$', description: 仅供路由关联,不作为主要展示键} + orderVersion: {type: integer, minimum: 0, description: 统一订单列表返回的 Order 乐观锁版本} + BatchReviewPreviewEnvelope: + type: object + required: [code, data] + properties: + code: {type: string, example: "200"} + message: {type: string, nullable: true} + data: {$ref: '#/components/schemas/BatchReviewPreview'} + BatchReviewPreview: + type: object + required: [total, approvableCount, items] + properties: + total: {type: integer, minimum: 1, maximum: 100} + approvableCount: {type: integer, minimum: 0, maximum: 100} + items: + type: array + items: {$ref: '#/components/schemas/BatchReviewPreviewItem'} + BatchReviewPreviewItem: + type: object + required: [orderId, orderVersion, chineseGoodsNames, canApprove] + properties: + orderId: {type: string, pattern: '^[1-9][0-9]*$', description: 仅供路由关联,不作为主要展示键} + orderVersion: {type: integer, minimum: 0} + inboundCode: {type: string, nullable: true, description: 权威入仓码;缺失时为 null,不以内部 ID 回退} + customerId: {type: string, nullable: true, pattern: '^[1-9][0-9]*$'} + customerName: {type: string, nullable: true} + chineseGoodsNames: {type: array, items: {type: string}, description: 按订单货物顺序去重的中文品名} + channelCode: {type: string, nullable: true} + channelName: {type: string, nullable: true} + destinationWarehouseCode: {type: string, nullable: true, description: Order 保存的目的仓业务编码;缺失时不以内部 ID 回退} + destinationWarehouseName: {type: string, nullable: true} + canApprove: {type: boolean} + blockReason: {type: string, nullable: true} DomesticWarehouseOrderExportBatchRequest: type: object required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit] diff --git a/order/src/test/java/com/baiyue/order/controller/BatchReviewOpenApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/BatchReviewOpenApiContractTest.java new file mode 100644 index 000000000..e054e5759 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/BatchReviewOpenApiContractTest.java @@ -0,0 +1,30 @@ +package com.baiyue.order.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 BatchReviewOpenApiContractTest { + @Test + void documentsSingleRequestPreviewFieldsAndVersionedPartialApproval() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/order-v1.yaml")); + + assertThat(yaml) + .contains("/api/platform/v1/orders/first-leg/batch-review-preview:") + .contains("/api/platform/v1/orders/first-leg/batch-approve:") + .contains("inboundCode:") + .contains("customerName:") + .contains("chineseGoodsNames:") + .contains("channelCode:") + .contains("channelName:") + .contains("destinationWarehouseCode:") + .contains("destinationWarehouseName:") + .contains("orderVersion:") + .contains("canApprove:") + .contains("blockReason:") + .contains("允许部分成功"); + } +} diff --git a/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java index 320a1c5bc..28d51a583 100644 --- a/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java @@ -4,10 +4,12 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.order.dto.BatchOrderReviewResponse; +import com.baiyue.order.dto.BatchOrderReviewPreviewResponse; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.service.OrderApplicationService; import com.baiyue.order.service.InboundCodeAssociationService; +import com.baiyue.order.service.BatchOrderReviewPreviewService; import org.junit.jupiter.api.Test; import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; @@ -23,11 +25,13 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; @SpringJUnitConfig(PlatformFirstLegOrderController.class) class PlatformOrderPageControllerTest { @MockBean private OrderApplicationService service; @MockBean private InboundCodeAssociationService inboundCodeAssociations; + @MockBean private BatchOrderReviewPreviewService batchReviewPreview; @Resource private PlatformFirstLegOrderController controller; private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest( null, null, null, null, null, null, 1, 20); @@ -77,10 +81,13 @@ class PlatformOrderPageControllerTest { @Test void batchApproveReturnsPartialFailureAndContinuesRemainingOrders() { doThrow(new BizException(ResultCode.CONFLICT, "订单已审核")) - .when(service).transit(10001L, 1L, "operator", 102L, OrderStatus.APPROVED, null); + .when(service).transit(10001L, 1L, "operator", 102L, OrderStatus.APPROVED, null, 4); var response = controller.batchApprove(user("PLATFORM", 0L), 10001L, - new OrderRequests.BatchReviewRequest(List.of(101L, 102L, 103L))); + new OrderRequests.BatchReviewRequest(List.of( + new OrderRequests.BatchReviewItem(101L, 3), + new OrderRequests.BatchReviewItem(102L, 4), + new OrderRequests.BatchReviewItem(103L, 5)))); BatchOrderReviewResponse result = response.data(); assertThat(result.total()).isEqualTo(3); @@ -90,8 +97,8 @@ class PlatformOrderPageControllerTest { assertThat(failure.code()).isEqualTo(ResultCode.CONFLICT.code()); assertThat(failure.message()).isEqualTo("订单已审核"); }); - verify(service).transit(10001L, 1L, "operator", 101L, OrderStatus.APPROVED, null); - verify(service).transit(10001L, 1L, "operator", 103L, OrderStatus.APPROVED, null); + verify(service).transit(10001L, 1L, "operator", 101L, OrderStatus.APPROVED, null, 3); + verify(service).transit(10001L, 1L, "operator", 103L, OrderStatus.APPROVED, null, 5); } @Test @@ -103,6 +110,20 @@ class PlatformOrderPageControllerTest { verify(service).transit(10001L, 1L, "operator", 102L, OrderStatus.REJECTED, "资料不完整"); } + @Test + void batchReviewPreviewUsesOneControllerCallAndExactCustomerScope() { + var request = new OrderRequests.BatchReviewRequest(List.of( + new OrderRequests.BatchReviewItem(101L, 3), + new OrderRequests.BatchReviewItem(102L, 4))); + var preview = new BatchOrderReviewPreviewResponse(2, 1, List.of()); + when(batchReviewPreview.preview(10001L, request.items())).thenReturn(preview); + + var response = controller.batchReviewPreview(user("PLATFORM", 0L), 10001L, request); + + assertThat(response.data()).isSameAs(preview); + verify(batchReviewPreview).preview(10001L, request.items()); + } + private AuthenticatedUser user(String appCode, Long tenantId) { return new AuthenticatedUser(1L, tenantId, null, null, "operator", appCode, "WEB", Set.of()); } diff --git a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java index f58d48fe2..37f938c0b 100644 --- a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java +++ b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java @@ -12,6 +12,20 @@ import static org.assertj.core.api.Assertions.assertThat; class OrderRequestsValidationTest { private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + @Test + void batchReviewRequiresDistinctOrderIdsAndNonNegativeVersions() { + var duplicate = new OrderRequests.BatchReviewRequest(List.of( + new OrderRequests.BatchReviewItem(101L, 1), + new OrderRequests.BatchReviewItem(101L, 2))); + var invalidVersion = new OrderRequests.BatchReviewRequest(List.of( + new OrderRequests.BatchReviewItem(102L, -1))); + + assertThat(validator.validate(duplicate)) + .anyMatch(violation -> violation.getMessage().contains("订单 ID 不能重复")); + assertThat(validator.validate(invalidVersion)) + .anyMatch(violation -> violation.getPropertyPath().toString().contains("orderVersion")); + } + @Test void carrierSnapshotIsOptionalWhenCustomerCreatesAnOrderDraft() { assertThat(validator.validate(request(null, null, "TAX_INCLUDED"))).isEmpty(); diff --git a/order/src/test/java/com/baiyue/order/service/BatchOrderReviewPreviewServiceTest.java b/order/src/test/java/com/baiyue/order/service/BatchOrderReviewPreviewServiceTest.java new file mode 100644 index 000000000..5980456e3 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/BatchOrderReviewPreviewServiceTest.java @@ -0,0 +1,93 @@ +package com.baiyue.order.service; + +import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.entity.OrderCargoEntity; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderStatus; +import com.baiyue.order.repository.OrderRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +class BatchOrderReviewPreviewServiceTest { + private final OrderRepository orders = mock(OrderRepository.class); + private final BatchOrderReviewPreviewService service = new BatchOrderReviewPreviewService(); + + @BeforeEach + void setUp() { + org.springframework.test.util.ReflectionTestUtils.setField(service, "orders", orders); + } + + @Test + void returnsStableRowsWithOrderedDistinctChineseGoodsUsingTwoBatchQueries() { + OrderEntity eligible = order(101L, 3, OrderStatus.SUBMITTED, "RK-101", "客户甲"); + OrderEntity stale = order(102L, 8, OrderStatus.SUBMITTED, "RK-102", "客户乙"); + when(orders.findByIds(11L, List.of(101L, 102L))).thenReturn(List.of(stale, eligible)); + when(orders.findCargoByOrderIds(11L, List.of(101L, 102L))).thenReturn(List.of( + cargo(101L, " 蓝牙音箱 "), cargo(101L, "蓝牙音箱"), cargo(101L, "充电器"), + cargo(102L, "数据线"))); + + var result = service.preview(11L, List.of( + new OrderRequests.BatchReviewItem(101L, 3), + new OrderRequests.BatchReviewItem(102L, 7))); + + assertThat(result.total()).isEqualTo(2); + assertThat(result.approvableCount()).isEqualTo(1); + assertThat(result.items()).extracting(item -> item.orderId()).containsExactly(101L, 102L); + assertThat(result.items().get(0).chineseGoodsNames()).containsExactly("蓝牙音箱", "充电器"); + assertThat(result.items().get(0).destinationWarehouseCode()).isEqualTo("ONT8"); + assertThat(result.items().get(0).canApprove()).isTrue(); + assertThat(result.items().get(1).orderVersion()).isEqualTo(8); + assertThat(result.items().get(1).blockReason()).contains("版本已变化"); + verify(orders).findByIds(11L, List.of(101L, 102L)); + verify(orders).findCargoByOrderIds(11L, List.of(101L, 102L)); + verifyNoMoreInteractions(orders); + } + + @Test + void keepsUnknownAndWrongStatusRowsWithoutLeakingAnotherCustomer() { + OrderEntity approved = order(101L, 3, OrderStatus.APPROVED, "RK-101", "客户甲"); + when(orders.findByIds(11L, List.of(999L, 101L))).thenReturn(List.of(approved)); + when(orders.findCargoByOrderIds(11L, List.of(999L, 101L))).thenReturn(List.of()); + + var result = service.preview(11L, List.of( + new OrderRequests.BatchReviewItem(999L, 1), + new OrderRequests.BatchReviewItem(101L, 3))); + + assertThat(result.items().get(0).inboundCode()).isNull(); + assertThat(result.items().get(0).blockReason()).contains("不存在或不属于当前客户"); + assertThat(result.items().get(1).blockReason()).contains("状态不可审核"); + assertThat(result.approvableCount()).isZero(); + } + + private static OrderEntity order(long id, int version, OrderStatus status, + String inboundCode, String customerName) { + OrderEntity order = new OrderEntity(); + order.setId(id); + order.setTenantId(11L); + order.setVersion(version); + order.setStatus(status); + order.setInboundCode(inboundCode); + order.setCustomerId(11L); + order.setCustomerName(customerName); + order.setChannelCode("FBA-SEA"); + order.setChannelName("美森海运"); + order.setAmazonWarehouseCode("ONT8"); + order.setDestinationWarehouseName("ONT8 亚马逊仓"); + return order; + } + + private static OrderCargoEntity cargo(long orderId, String name) { + OrderCargoEntity cargo = new OrderCargoEntity(); + cargo.setOrderId(orderId); + cargo.setCargoName(name); + return cargo; + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 7675f999a..c959de48a 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -235,6 +235,24 @@ class OrderApplicationServiceStateTest { && event.getPayloadJson().contains("\"totalPackageCount\":4"))); } + @Test + void batchApprovalRejectsStalePreviewVersionBeforeChangingOrder() { + OrderRepository repository = mock(OrderRepository.class); + OrderEntity order = persistedOrder(OrderStatus.SUBMITTED); + order.setVersion(6); + when(repository.findOwned(10001L, 1L)).thenReturn(order); + + assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), + mock(OrderMasterDataGuard.class)).transit( + 10001L, 2L, "u", 1L, OrderStatus.APPROVED, null, 5)) + .isInstanceOf(BizException.class) + .hasMessage("订单版本已变化,请刷新后重试"); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED); + verify(repository, never()).saveOrder(anyLong(), any()); + verify(repository, never()).insertOutbox(any()); + } + @Test void submittingDraftGeneratesAndPersistsInboundCode() { OrderRepository repository = mock(OrderRepository.class); From 60a41ec3ea5ddc2af78e495e1fa4fc758f3c5df8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:33:23 +0800 Subject: [PATCH 34/56] fix: reserve stable inbound code for draft label printing --- .../client/PrintableOrderInboundClient.java | 18 +++ .../service/InboundCartonLabelService.java | 4 +- .../InboundCartonLabelServiceTest.java | 7 +- .../InternalOrderCargoController.java | 15 +++ .../order/repository/OrderRepository.java | 11 ++ .../PrintableInboundSnapshotService.java | 64 ++++++++++ .../src/main/resources/openapi/order-v1.yaml | 17 +++ .../InternalOrderCargoControllerTest.java | 22 ++++ ...bleInboundSnapshotOpenApiContractTest.java | 21 ++++ .../PrintableInboundSnapshotServiceTest.java | 113 ++++++++++++++++++ 10 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/client/PrintableOrderInboundClient.java create mode 100644 order/src/main/java/com/baiyue/order/service/PrintableInboundSnapshotService.java create mode 100644 order/src/test/java/com/baiyue/order/controller/PrintableInboundSnapshotOpenApiContractTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/PrintableInboundSnapshotServiceTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintableOrderInboundClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintableOrderInboundClient.java new file mode 100644 index 000000000..b28eedff7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintableOrderInboundClient.java @@ -0,0 +1,18 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient(name = "order-service", contextId = "printableOrderInboundClient", + url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/cargoes") +public interface PrintableOrderInboundClient { + @PostMapping("/{tenantId}/{orderId}/printable-inbound-snapshot") + ApiResponse reserve( + @RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, + @PathVariable long orderId); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java index b73623379..26f74f316 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java @@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.OrderCargoClient; +import com.baiyue.firstleg.client.PrintableOrderInboundClient; import com.baiyue.firstleg.dto.InboundCartonLabels; import com.baiyue.firstleg.entity.InboundCartonLabelEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; @@ -35,6 +36,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { @Resource private InboundCartonLabelRepository repository; @Resource private OrderCargoClient orderCargoClient; + @Resource private PrintableOrderInboundClient printableOrderInboundClient; @Resource private InboundOrderCreationService inboundOrderCreationService; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -101,7 +103,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { private InboundOrderEntity prepareMissingInbound(long tenantId, long orderId) { try { ApiResponse response = - orderCargoClient.inboundSnapshot(internalToken, tenantId, orderId); + printableOrderInboundClient.reserve(internalToken, tenantId, orderId); if (response == null || !response.successful() || response.data() == null) { throw new BizException(ResultCode.NOT_FOUND, "订单不存在或无法生成预入库单"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java index 868af9ece..a2a0b002a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.OrderCargoClient; +import com.baiyue.firstleg.client.PrintableOrderInboundClient; import com.baiyue.firstleg.entity.InboundCartonLabelEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.repository.InboundCartonLabelRepository; @@ -28,6 +29,7 @@ import static org.mockito.Mockito.when; class InboundCartonLabelServiceTest { @Mock private InboundCartonLabelRepository repository; @Mock private OrderCargoClient orderCargoClient; + @Mock private PrintableOrderInboundClient printableOrderInboundClient; @Mock private InboundOrderCreationService inboundOrderCreationService; private InboundCartonLabelService service; @@ -36,6 +38,7 @@ class InboundCartonLabelServiceTest { service = new InboundCartonLabelService(); ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "orderCargoClient", orderCargoClient); + ReflectionTestUtils.setField(service, "printableOrderInboundClient", printableOrderInboundClient); ReflectionTestUtils.setField(service, "inboundOrderCreationService", inboundOrderCreationService); ReflectionTestUtils.setField(service, "internalToken", "internal-token"); } @@ -222,7 +225,7 @@ class InboundCartonLabelServiceTest { @Test void rejectsAnOrderWithoutAnOwnedPreparingInbound() { when(repository.findByOrder(10001L, 60001L)).thenReturn(null); - when(orderCargoClient.inboundSnapshot("internal-token", 10001L, 60001L)) + when(printableOrderInboundClient.reserve("internal-token", 10001L, 60001L)) .thenThrow(org.mockito.Mockito.mock(feign.FeignException.class)); assertThatThrownBy(() -> service.requireInboundIdByOrder(10001L, 60001L)) @@ -239,7 +242,7 @@ class InboundCartonLabelServiceTest { null, null, null, 2); InboundOrderEntity repaired = inbound(); when(repository.findByOrder(10001L, 60001L)).thenReturn(null, repaired); - when(orderCargoClient.inboundSnapshot("internal-token", 10001L, 60001L)) + when(printableOrderInboundClient.reserve("internal-token", 10001L, 60001L)) .thenReturn(ApiResponse.success(snapshot)); assertThat(service.requireInboundIdByOrder(10001L, 60001L)).isEqualTo(70001L); diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java index 0f3d06b38..55e269500 100644 --- a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java @@ -9,10 +9,12 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.order.entity.OrderCargoEntity; import com.baiyue.order.repository.OrderRepository; import com.baiyue.order.service.OrderApplicationService; +import com.baiyue.order.service.PrintableInboundSnapshotService; import com.baiyue.order.client.FirstlegInboundClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -29,6 +31,7 @@ import java.util.stream.Collectors; @RequestMapping("/internal/order/v1/cargoes") public class InternalOrderCargoController { @Resource private OrderRepository orders; + @Resource private PrintableInboundSnapshotService printableInboundSnapshots; @Value("${baiyue.security.internal-service-token}") private String internalToken; private byte[] expectedToken; @@ -98,6 +101,18 @@ public class InternalOrderCargoController { return ApiResponse.success(OrderApplicationService.inboundCommand(order, packageCount)); } + @PostMapping("/{tenantId}/{orderId}/printable-inbound-snapshot") + public ApiResponse printableInboundSnapshot( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long tenantId, + @PathVariable long orderId) { + requireToken(token); + if (tenantId <= 0 || orderId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "租户和订单ID必须大于0"); + } + return ApiResponse.success(printableInboundSnapshots.reserve(tenantId, orderId)); + } + @GetMapping("/{tenantId}/{orderId}/measurement-declaration") public ApiResponse measurementDeclaration( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index e9987383c..40c1b715a 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -54,6 +54,17 @@ public class OrderRepository { .eq(OrderEntity::getId, order.getId()) .eq(OrderEntity::getTenantId, tenantId)); } + public int assignInboundCodeIfBlank(long tenantId, long orderId, String inboundCode) { + return orderMapper.update(null, new LambdaUpdateWrapper() + .eq(OrderEntity::getTenantId, tenantId) + .eq(OrderEntity::getId, orderId) + .eq(OrderEntity::getDeleted, false) + .and(wrapper -> wrapper.isNull(OrderEntity::getInboundCode) + .or().eq(OrderEntity::getInboundCode, "")) + .set(OrderEntity::getInboundCode, inboundCode) + .set(OrderEntity::getUpdatedAt, LocalDateTime.now()) + .setSql("version = version + 1")); + } public int updateDeclarationHeader(long tenantId, long orderId, int expectedVersion, long operatorId, String customerRemark, LocalDateTime expectedArrivalAt, java.math.BigDecimal estimatedVolumeCbm, diff --git a/order/src/main/java/com/baiyue/order/service/PrintableInboundSnapshotService.java b/order/src/main/java/com/baiyue/order/service/PrintableInboundSnapshotService.java new file mode 100644 index 000000000..a789cf392 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/PrintableInboundSnapshotService.java @@ -0,0 +1,64 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.client.FirstlegInboundClient; +import com.baiyue.order.config.InboundCodeModeProvider; +import com.baiyue.order.entity.OrderCargoEntity; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.repository.OrderRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +/** 为草稿箱唛打印预留稳定入仓码;订单提交时继续复用该码。 */ +@Service +public class PrintableInboundSnapshotService { + private final OrderRepository orders; + private final OrderPricingChannelGuard pricingChannels; + private final InboundCodeModeProvider inboundCodeMode; + private final OrderApplicationService orderApplication; + + public PrintableInboundSnapshotService(OrderRepository orders, + OrderPricingChannelGuard pricingChannels, + InboundCodeModeProvider inboundCodeMode, + OrderApplicationService orderApplication) { + this.orders = orders; + this.pricingChannels = pricingChannels; + this.inboundCodeMode = inboundCodeMode; + this.orderApplication = orderApplication; + } + + @Transactional + public FirstlegInboundClient.OrderApprovedInboundCommand reserve(long tenantId, long orderId) { + OrderEntity order = requireOwned(tenantId, orderId); + if (!StringUtils.hasText(order.getInboundCode())) { + if (inboundCodeMode.legacyManual()) { + throw new BizException(ResultCode.VALIDATION_FAILED, "当前系统使用旧系统入仓码,请先填写入仓码"); + } + String prefix = pricingChannels.requireAvailable(tenantId, order.getReceivingWarehouseId(), + order.getChannelCode(), order.getDestinationType()); + String allocated = orderApplication.nextInboundCode(prefix); + if (orders.assignInboundCodeIfBlank(tenantId, orderId, allocated) == 1) { + order.setInboundCode(allocated); + } else { + order = requireOwned(tenantId, orderId); + if (!StringUtils.hasText(order.getInboundCode())) { + throw new BizException(ResultCode.CONFLICT, "入仓码预留冲突,请刷新后重试"); + } + } + } + int packageCount = orders.findCargoInheritedByOrder(tenantId, orderId).stream() + .map(OrderCargoEntity::getPackageCount).filter(java.util.Objects::nonNull) + .mapToInt(Integer::intValue).sum(); + return OrderApplicationService.inboundCommand(order, packageCount); + } + + private OrderEntity requireOwned(long tenantId, long orderId) { + OrderEntity order = orders.findOwned(tenantId, orderId); + if (order == null) { + throw new BizException(ResultCode.NOT_FOUND, "订单不存在或不属于当前客户"); + } + return order; + } +} diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index b69987464..baf544bcd 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -28,6 +28,23 @@ paths: application/json: schema: {$ref: '#/components/schemas/CargoLabelBindingListEnvelope'} '403': {description: 内部服务令牌无效} + /internal/order/v1/cargoes/{tenantId}/{orderId}/printable-inbound-snapshot: + post: + security: [] + summary: 为 Firstleg 箱唛打印读取并预留稳定入仓快照 + description: >- + 仅接受内部服务令牌。若 AUTO 模式草稿尚无入仓码,则按订单渠道原子预留全局唯一入仓码; + 并发重放返回已胜出的同一码,后续订单提交继续复用,避免已打印二维码失效。 + parameters: + - name: X-Internal-Service-Token + in: header + required: true + schema: {type: string} + - {name: tenantId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': {description: 返回带稳定入仓码及总箱数的订单快照} + '403': {description: 内部服务令牌无效} /internal/order/v1/pending-review-orders: get: security: [] diff --git a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java index 0de14a34c..ccc5af982 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java @@ -5,6 +5,8 @@ import com.baiyue.order.entity.OrderCargoEntity; import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OrderInternalNoteEntity; import com.baiyue.order.repository.OrderRepository; +import com.baiyue.order.service.OrderApplicationService; +import com.baiyue.order.service.PrintableInboundSnapshotService; import org.junit.jupiter.api.Test; import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; @@ -23,11 +25,13 @@ import java.lang.reflect.Method; import java.util.List; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; @SpringJUnitConfig(InternalOrderCargoController.class) @TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class InternalOrderCargoControllerTest { @MockBean private OrderRepository orders; + @MockBean private PrintableInboundSnapshotService printableInboundSnapshots; @Resource private InternalOrderCargoController controller; @Test @@ -145,6 +149,24 @@ class InternalOrderCargoControllerTest { verify(orders).findOwned(11L, 21L); } + @Test + void reservesPrintableSnapshotThroughExplicitCommandRoute() throws Exception { + OrderEntity order = new OrderEntity(); + order.setTenantId(11L); order.setId(21L); order.setDestinationType("FBA"); + order.setTransportMode("SEA"); + var snapshot = OrderApplicationService.inboundCommand(order, 3); + when(printableInboundSnapshots.reserve(11L, 21L)).thenReturn(snapshot); + + var response = controller.printableInboundSnapshot("internal-token", 11L, 21L); + + assertThat(response.data()).isSameAs(snapshot); + verify(printableInboundSnapshots).reserve(11L, 21L); + Method route = InternalOrderCargoController.class.getMethod( + "printableInboundSnapshot", String.class, long.class, long.class); + assertThat(route.getAnnotation(PostMapping.class).value()) + .containsExactly("/{tenantId}/{orderId}/printable-inbound-snapshot"); + } + @Test void measurementDeclarationUsesOrderReferenceAndSumsCargoFacts() { OrderEntity order = new OrderEntity(); diff --git a/order/src/test/java/com/baiyue/order/controller/PrintableInboundSnapshotOpenApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/PrintableInboundSnapshotOpenApiContractTest.java new file mode 100644 index 000000000..c9a8e9554 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/PrintableInboundSnapshotOpenApiContractTest.java @@ -0,0 +1,21 @@ +package com.baiyue.order.controller; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class PrintableInboundSnapshotOpenApiContractTest { + @Test + void documentsTheMutatingInternalCommandAsPost() throws Exception { + try (var stream = getClass().getResourceAsStream("/openapi/order-v1.yaml")) { + assertThat(stream).isNotNull(); + String yaml = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + assertThat(yaml).contains( + "/internal/order/v1/cargoes/{tenantId}/{orderId}/printable-inbound-snapshot:", + "若 AUTO 模式草稿尚无入仓码,则按订单渠道原子预留全局唯一入仓码", + "后续订单提交继续复用"); + } + } +} diff --git a/order/src/test/java/com/baiyue/order/service/PrintableInboundSnapshotServiceTest.java b/order/src/test/java/com/baiyue/order/service/PrintableInboundSnapshotServiceTest.java new file mode 100644 index 000000000..5676c6cf7 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/PrintableInboundSnapshotServiceTest.java @@ -0,0 +1,113 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.config.InboundCodeModeProvider; +import com.baiyue.order.entity.OrderCargoEntity; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.repository.OrderRepository; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PrintableInboundSnapshotServiceTest { + @Test + void reservesStableCodeForDraftAndReturnsPrintableSnapshot() { + OrderRepository orders = mock(OrderRepository.class); + OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class); + OrderApplicationService application = mock(OrderApplicationService.class); + OrderEntity order = draft(); + OrderCargoEntity cargo = new OrderCargoEntity(); + cargo.setPackageCount(3); + when(orders.findOwned(11L, 21L)).thenReturn(order); + when(pricing.requireAvailable(11L, 41L, "CH", "FBA")).thenReturn("PCHK"); + when(application.nextInboundCode("PCHK")).thenReturn("PCHK2609080001"); + when(orders.assignInboundCodeIfBlank(11L, 21L, "PCHK2609080001")).thenReturn(1); + when(orders.findCargoInheritedByOrder(11L, 21L)).thenReturn(List.of(cargo)); + + var result = service(orders, pricing, application, "AUTO").reserve(11L, 21L); + + assertThat(result.inboundCode()).isEqualTo("PCHK2609080001"); + assertThat(result.totalPackageCount()).isEqualTo(3); + verify(orders).assignInboundCodeIfBlank(11L, 21L, "PCHK2609080001"); + } + + @Test + void reusesCodeAlreadyReservedByAConcurrentRequest() { + OrderRepository orders = mock(OrderRepository.class); + OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class); + OrderApplicationService application = mock(OrderApplicationService.class); + OrderEntity initial = draft(); + OrderEntity winner = draft(); + winner.setInboundCode("PCHK2609080002"); + when(orders.findOwned(11L, 21L)).thenReturn(initial, winner); + when(pricing.requireAvailable(11L, 41L, "CH", "FBA")).thenReturn("PCHK"); + when(application.nextInboundCode("PCHK")).thenReturn("PCHK2609080001"); + when(orders.assignInboundCodeIfBlank(11L, 21L, "PCHK2609080001")).thenReturn(0); + when(orders.findCargoInheritedByOrder(11L, 21L)).thenReturn(List.of()); + + var result = service(orders, pricing, application, "AUTO").reserve(11L, 21L); + + assertThat(result.inboundCode()).isEqualTo("PCHK2609080002"); + } + + @Test + void existingCodeDoesNotAllocateAnotherSequence() { + OrderRepository orders = mock(OrderRepository.class); + OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class); + OrderApplicationService application = mock(OrderApplicationService.class); + OrderEntity order = draft(); + order.setInboundCode("PCHK2609080001"); + when(orders.findOwned(11L, 21L)).thenReturn(order); + when(orders.findCargoInheritedByOrder(11L, 21L)).thenReturn(List.of()); + + var result = service(orders, pricing, application, "AUTO").reserve(11L, 21L); + + assertThat(result.inboundCode()).isEqualTo("PCHK2609080001"); + verify(application, never()).nextInboundCode(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void legacyManualModeRejectsMissingCodeWithoutWriting() { + OrderRepository orders = mock(OrderRepository.class); + OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class); + OrderApplicationService application = mock(OrderApplicationService.class); + when(orders.findOwned(11L, 21L)).thenReturn(draft()); + + assertThatThrownBy(() -> service(orders, pricing, application, "LEGACY_MANUAL").reserve(11L, 21L)) + .isInstanceOf(BizException.class) + .hasMessage("当前系统使用旧系统入仓码,请先填写入仓码"); + + verify(orders, never()).assignInboundCodeIfBlank( + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyString()); + } + + private PrintableInboundSnapshotService service(OrderRepository orders, + OrderPricingChannelGuard pricing, + OrderApplicationService application, + String mode) { + return new PrintableInboundSnapshotService( + orders, pricing, new InboundCodeModeProvider(mode), application); + } + + private OrderEntity draft() { + OrderEntity order = new OrderEntity(); + order.setId(21L); + order.setTenantId(11L); + order.setOrderNo("FL21"); + order.setCustomerId(11L); + order.setReceivingWarehouseId(41L); + order.setChannelCode("CH"); + order.setDestinationType("FBA"); + order.setTransportMode("SEA"); + order.setFulfillmentType("STANDARD"); + return order; + } +} From 751bdbf8802b04fc652ffef5079d25ce0d0c4a7b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:38:43 +0800 Subject: [PATCH 35/56] fix: converge dropship development schema --- deploy/sql/头程数据库统一增量-MySQL8.sql | 134 ++++++++++++++++++ .../firstleg/client/OrderCargoClient.java | 6 + .../DropshipPalletizationSqlContractTest.java | 5 + 3 files changed, 145 insertions(+) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 7b050e079..6c4bfd8b8 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -17494,6 +17494,140 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton ( CONSTRAINT fk_dropship_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id) ) ENGINE=InnoDB COMMENT='一件代发托盘内逐箱SKU和入仓单明细'; +-- 已存在开发库直接收敛到逐箱清点与整托分柜结构;新建库由上面的最新定义一次完成。 +DROP PROCEDURE IF EXISTS converge_dropship_palletization_schema; +DELIMITER $$ +CREATE PROCEDURE converge_dropship_palletization_schema() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='baiyue_firstleg' AND table_name='fl_inbound_carton_label' + AND column_name='units_per_carton') THEN + ALTER TABLE fl_inbound_carton_label + ADD COLUMN units_per_carton INT UNSIGNED NULL + COMMENT '每箱SKU数量快照;一件代发必须为正数' AFTER sku_code; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='baiyue_firstleg' AND table_name='fl_dropship_pallet_task' + AND column_name='counted_cartons') THEN + ALTER TABLE fl_dropship_pallet_task + ADD COLUMN counted_cartons INT UNSIGNED NOT NULL DEFAULT 0 + COMMENT '已逐箱确认实收数量的箱数' AFTER total_cartons; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='baiyue_firstleg' AND table_name='fl_dropship_pallet_task' + AND column_name='discrepancy_cartons') THEN + ALTER TABLE fl_dropship_pallet_task + ADD COLUMN discrepancy_cartons INT UNSIGNED NOT NULL DEFAULT 0 + COMMENT '已确认差异箱数' AFTER allocated_cartons; + END IF; + UPDATE fl_dropship_pallet_task + SET counted_cartons=GREATEST(counted_cartons,allocated_cartons); + IF EXISTS (SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema='baiyue_firstleg' AND table_name='fl_dropship_pallet_task' + AND constraint_name='chk_dropship_task_cartons') THEN + ALTER TABLE fl_dropship_pallet_task DROP CHECK chk_dropship_task_cartons; + END IF; + ALTER TABLE fl_dropship_pallet_task + ADD CONSTRAINT chk_dropship_task_cartons + CHECK (allocated_cartons<=counted_cartons AND counted_cartons<=total_cartons); + + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='baiyue_firstleg' AND table_name='fl_dropship_pallet_carton' + AND column_name='operation_id') + AND NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='baiyue_firstleg' AND table_name='fl_dropship_pallet_carton' + AND column_name='count_operation_id') THEN + ALTER TABLE fl_dropship_pallet_carton + CHANGE COLUMN operation_id count_operation_id VARCHAR(128) NOT NULL + COMMENT '逐箱清点幂等操作ID'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='baiyue_firstleg' AND table_name='fl_dropship_pallet_carton' + AND column_name='expected_sku_quantity') THEN + ALTER TABLE fl_dropship_pallet_carton + ADD COLUMN expected_sku_quantity INT UNSIGNED NULL AFTER box_code, + ADD COLUMN actual_sku_quantity INT UNSIGNED NULL AFTER expected_sku_quantity, + ADD COLUMN discrepancy_status VARCHAR(24) NULL AFTER actual_sku_quantity, + ADD COLUMN discrepancy_remark VARCHAR(500) NULL AFTER discrepancy_status, + ADD COLUMN bind_operation_id VARCHAR(128) NULL AFTER count_operation_id, + ADD COLUMN last_operation_id VARCHAR(128) NULL AFTER bind_operation_id, + ADD COLUMN counted_by BIGINT UNSIGNED NULL AFTER last_operation_id, + ADD COLUMN counted_by_name VARCHAR(64) NULL AFTER counted_by, + ADD COLUMN counted_at DATETIME(3) NULL AFTER counted_by_name; + END IF; + UPDATE fl_dropship_pallet_carton carton + LEFT JOIN fl_inbound_carton_label label ON label.id=carton.carton_label_id + SET carton.expected_sku_quantity=COALESCE(carton.expected_sku_quantity,label.units_per_carton,1), + carton.actual_sku_quantity=COALESCE(carton.actual_sku_quantity,label.units_per_carton,1), + carton.discrepancy_status=COALESCE(carton.discrepancy_status,'MATCH'), + carton.bind_operation_id=COALESCE(carton.bind_operation_id,carton.count_operation_id), + carton.last_operation_id=COALESCE(carton.last_operation_id,carton.count_operation_id), + carton.counted_by=COALESCE(carton.counted_by,carton.bound_by), + carton.counted_by_name=COALESCE(carton.counted_by_name,carton.bound_by_name), + carton.counted_at=COALESCE(carton.counted_at,carton.bound_at); + ALTER TABLE fl_dropship_pallet_carton + MODIFY COLUMN pallet_id BIGINT UNSIGNED NULL COMMENT '所属组托托盘ID;已清点待绑托时为空', + MODIFY COLUMN box_code VARCHAR(80) NOT NULL COMMENT '稳定箱码', + MODIFY COLUMN expected_sku_quantity INT UNSIGNED NOT NULL COMMENT '箱唛冻结的应收SKU数量', + MODIFY COLUMN actual_sku_quantity INT UNSIGNED NOT NULL COMMENT 'PDA确认的实收SKU数量', + MODIFY COLUMN discrepancy_status VARCHAR(24) NOT NULL COMMENT '差异状态:MATCH或ACCEPTED_VARIANCE', + MODIFY COLUMN last_operation_id VARCHAR(128) NOT NULL COMMENT '最后一次绑托或换托操作幂等号', + MODIFY COLUMN counted_by BIGINT UNSIGNED NOT NULL COMMENT '清点操作人ID', + MODIFY COLUMN counted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清点时间', + MODIFY COLUMN bound_by BIGINT UNSIGNED NULL COMMENT '上托操作人ID', + MODIFY COLUMN bound_at DATETIME(3) NULL COMMENT '上托时间'; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics + WHERE table_schema='baiyue_firstleg' AND table_name='fl_dropship_pallet_carton' + AND index_name='uk_dropship_carton_count_operation') THEN + ALTER TABLE fl_dropship_pallet_carton + ADD UNIQUE KEY uk_dropship_carton_count_operation (count_operation_id); + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics + WHERE table_schema='baiyue_firstleg' AND table_name='fl_dropship_pallet_carton' + AND index_name='idx_dropship_carton_bind_operation') THEN + ALTER TABLE fl_dropship_pallet_carton + ADD KEY idx_dropship_carton_bind_operation (task_id,bind_operation_id); + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item' + AND column_name='unit_type') THEN + ALTER TABLE fl_loading_plan_item + ADD COLUMN unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE' AFTER product_quantity, + ADD COLUMN pallet_id BIGINT UNSIGNED NULL AFTER unit_type, + ADD COLUMN pallet_no VARCHAR(32) NULL AFTER pallet_id, + ADD COLUMN indivisible TINYINT(1) NOT NULL DEFAULT 0 AFTER pallet_no; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item' + AND column_name='active_assignment_key') THEN + ALTER TABLE fl_loading_plan_item + ADD COLUMN active_assignment_key VARCHAR(96) + GENERATED ALWAYS AS (CASE WHEN deleted=0 THEN CONCAT(CASE WHEN pallet_id IS NULL THEN 'D:0' ELSE CONCAT('P:',pallet_id) END,':',inbound_detail_id) ELSE NULL END) STORED + COMMENT '普通明细或已封托托盘明细的有效唯一锁' AFTER active_inbound_detail_id; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.statistics + WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item' + AND index_name='uk_loading_item_plan_detail') THEN + ALTER TABLE fl_loading_plan_item DROP INDEX uk_loading_item_plan_detail; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics + WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item' + AND index_name='uk_loading_item_plan_assignment') THEN + ALTER TABLE fl_loading_plan_item + ADD UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id,active_assignment_key); + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics + WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item' + AND index_name='idx_loading_item_pallet') THEN + ALTER TABLE fl_loading_plan_item ADD KEY idx_loading_item_pallet (pallet_id,deleted); + END IF; +END$$ +DELIMITER ; +CALL converge_dropship_palletization_schema(); +DROP PROCEDURE IF EXISTS converge_dropship_palletization_schema; + -- Auth 登录手机号只从 IAM 中唯一归属的中国大陆手机号回填;存量重复号保持 NULL。 USE baiyue_auth; DROP PROCEDURE IF EXISTS auth_add_login_phone; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java index ed4ff14bd..ee11c2d60 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java @@ -72,5 +72,11 @@ public interface OrderCargoClient { this(cargoId, orderId, itemNo, cargoName, null, packageCount, null, lengthCm, widthCm, heightCm, null); } + public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, + String skuNo, Integer packageCount, BigDecimal lengthCm, + BigDecimal widthCm, BigDecimal heightCm, String shipmentId) { + this(cargoId, orderId, itemNo, cargoName, skuNo, + packageCount, null, lengthCm, widthCm, heightCm, shipmentId); + } } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java index 6dbdf88c1..212895723 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java @@ -38,5 +38,10 @@ class DropshipPalletizationSqlContractTest { .doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_work_pallet (") .doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton ("); } + assertThat(unified) + .contains("CREATE PROCEDURE converge_dropship_palletization_schema()") + .contains("CHANGE COLUMN operation_id count_operation_id") + .contains("DROP INDEX uk_loading_item_plan_detail") + .contains("ADD UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id,active_assignment_key)"); } } From b0fd797cd2c15715d39eb59cd935401a2ade9bf1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:42:20 +0800 Subject: [PATCH 36/56] feat(firstleg): support customer price books --- deploy/sql/头程数据库统一增量-MySQL8.sql | 2 +- .../PlatformPriceBookController.java | 6 ++- .../baiyue/firstleg/dto/PriceBookDtos.java | 1 + .../firstleg/entity/CarrierFbaRateEntity.java | 4 +- .../out/persistence/PriceBookQueryMapper.java | 20 ++++++--- .../application/PriceBookQueryService.java | 3 ++ .../port/in/PriceBookQueryUseCase.java | 1 + .../port/out/PriceBookQueryReader.java | 1 + .../pricing/ActivePriceBookEstimator.java | 6 ++- .../repository/PriceBookRepository.java | 33 +++++++++++---- .../firstleg/service/FbaPricingService.java | 2 +- .../service/MerchantPriceBookService.java | 6 +-- .../OrderChannelChangePricingService.java | 2 +- .../service/PlatformPriceBookService.java | 42 ++++++++++++++----- .../resources/db/phase2-inbound-schema.sql | 2 +- .../resources/openapi/fba-pricing-v1.yaml | 8 +++- .../PriceBookQueryServiceTest.java | 3 ++ .../service/FbaPricingServiceTest.java | 18 ++++---- .../service/MerchantPriceBookServiceTest.java | 19 +++++---- .../OrderChannelChangePricingServiceTest.java | 7 ++-- .../service/PlatformPriceBookServiceTest.java | 18 +++++++- 21 files changed, 145 insertions(+), 59 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a1f23b92b..fef4f9ee8 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3783,7 +3783,7 @@ CREATE TABLE IF NOT EXISTS fl_price_warehouse_mapping ( CREATE TABLE IF NOT EXISTS fl_price_book_version ( id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID', - tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共价格作用域固定为0', + tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '报价作用域:0平台公共,正数为客户专属', version_no INT UNSIGNED NOT NULL COMMENT '整表版本号', original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名', source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID', diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java index ecb4991ea..af13b112d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformPriceBookController.java @@ -38,9 +38,10 @@ public class PlatformPriceBookController { @Operation(summary = "查询船期价格表版本") public ApiResponse> list( @AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user, + @RequestParam(defaultValue = "0") long customerId, @RequestParam(required = false) PriceBookStatus status) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.list(status)); + return ApiResponse.success(service.list(customerId, status)); } @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -48,9 +49,10 @@ public class PlatformPriceBookController { @BusinessAudit(event = "FBA_PRICE_BOOK_UPLOAD", resourceType = "PRICE_BOOK_VERSION", description = "上传船期价格表并等待确认") public ApiResponse upload( @AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user, + @RequestParam(defaultValue = "0") long customerId, @RequestPart("file") MultipartFile file) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.upload(user.userId(), file)); + return ApiResponse.success(service.upload(user.userId(), customerId, file)); } @GetMapping("/{versionId}") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java index b81b8b94f..f0e0f4317 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/PriceBookDtos.java @@ -23,6 +23,7 @@ public final class PriceBookDtos { public record PriceBookVersionSummary( Long id, + Long customerId, Integer versionNo, String originalFileName, Long sourceFileId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java index 16956b682..0a82a59ba 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/CarrierFbaRateEntity.java @@ -10,12 +10,12 @@ import lombok.Setter; import java.math.BigDecimal; import java.time.LocalDateTime; -/** 船司 FBA 目的仓价格的不可变版本。 */ +/** 公共或客户专属的头程报价不可变版本。 */ @Getter @Setter @TableName("fl_carrier_fba_rate") public class CarrierFbaRateEntity extends BaseEntity { - /** 船司价格是平台公共主数据,不绑定任何客户。 */ + /** 0 为平台公共报价,正数为客户专属报价作用域。 */ public static final long PLATFORM_SCOPE = 0L; @TableId(type = IdType.ASSIGN_ID) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java index 8b708f74d..7c01a22c5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java @@ -32,6 +32,16 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader { """) Long findActiveVersionId(); + @Select(""" + SELECT id + FROM fl_price_book_version + WHERE tenant_id IN (0, #{customerId}) AND status = 'ACTIVE' AND deleted = 0 + ORDER BY CASE WHEN tenant_id = #{customerId} THEN 0 ELSE 1 END, + published_at DESC, version_no DESC + LIMIT 1 + """) + Long findEffectiveVersionId(@Param("customerId") long customerId); + @Select(""" SELECT c.price_book_version_id AS priceBookVersionId, c.channel_code AS channelCode, c.channel_name AS channelName, @@ -48,7 +58,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader { SELECT 1 FROM fl_carrier_fba_rate r WHERE r.price_book_version_id = c.price_book_version_id AND r.channel_code = c.channel_code - AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId} + AND r.origin_warehouse_id = #{originWarehouseId} AND r.status = 'ACTIVE' AND r.deleted = 0 ) ORDER BY c.id @@ -60,7 +70,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader { SELECT r.channel_code AS channelCode, r.pricing_tax_mode AS pricingTaxMode FROM fl_carrier_fba_rate r WHERE r.price_book_version_id = #{versionId} - AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId} + AND r.origin_warehouse_id = #{originWarehouseId} AND r.status = 'ACTIVE' AND r.deleted = 0 AND r.pricing_tax_mode IS NOT NULL GROUP BY r.channel_code, r.pricing_tax_mode @@ -83,7 +93,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader { r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo FROM fl_carrier_fba_rate r WHERE r.price_book_version_id = #{versionId} - AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId} + AND r.origin_warehouse_id = #{originWarehouseId} AND r.channel_code = #{channelCode} AND r.destination_type = 'FBA' AND UPPER(r.destination_code) = #{destinationCode} AND r.pricing_tax_mode = #{pricingTaxMode} @@ -109,7 +119,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader { r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo FROM fl_carrier_fba_rate r WHERE r.price_book_version_id = #{versionId} - AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId} + AND r.origin_warehouse_id = #{originWarehouseId} AND r.channel_code = #{channelCode} AND r.destination_type = 'OVERSEAS' AND r.destination_warehouse_id = #{destinationWarehouseId} AND r.pricing_tax_mode = #{pricingTaxMode} @@ -135,7 +145,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader { r.effective_from AS effectiveFrom, r.effective_to AS effectiveTo FROM fl_carrier_fba_rate r WHERE r.price_book_version_id = #{versionId} - AND r.tenant_id = 0 AND r.origin_warehouse_id = #{originWarehouseId} + AND r.origin_warehouse_id = #{originWarehouseId} AND r.channel_code = #{channelCode} AND r.price_model = 'REGION_WEIGHT_TIER' AND r.region_code = #{regionCode} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java index 094003ec2..e3f07440e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java @@ -16,6 +16,9 @@ public class PriceBookQueryService implements PriceBookQueryUseCase { @Resource private PriceBookQueryReader reader; @Override public Long activeVersionId() { return reader.findActiveVersionId(); } + @Override public Long effectiveVersionId(long customerId) { + return reader.findEffectiveVersionId(customerId); + } @Override public List channels(long versionId, long originWarehouseId) { return reader.findChannels(versionId, originWarehouseId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java index 980c86ad7..681279a2e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java @@ -9,6 +9,7 @@ import java.util.List; public interface PriceBookQueryUseCase { Long activeVersionId(); + Long effectiveVersionId(long customerId); List channels(long versionId, long originWarehouseId); List channelTaxModes(long versionId, long originWarehouseId); List fbaRates(long versionId, long originWarehouseId, String channelCode, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java index 04d852aab..d12366820 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java @@ -9,6 +9,7 @@ import java.util.List; public interface PriceBookQueryReader { Long findActiveVersionId(); + Long findEffectiveVersionId(long customerId); List findChannels(long versionId, long originWarehouseId); List findChannelTaxModes(long versionId, long originWarehouseId); List findFbaRates(long versionId, long originWarehouseId, String channelCode, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java index aca7ccbcf..6b7930f75 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java @@ -27,10 +27,14 @@ public class ActivePriceBookEstimator { @Resource private PriceBookWarehouseRegionResolver warehouseRegionResolver; public Estimate estimate(EstimateRequest request) { + return estimate(0L, request); + } + + public Estimate estimate(long customerId, EstimateRequest request) { if (request == null || request.originWarehouseId() == null || request.originWarehouseId() <= 0) { throw validation("请选择有效起运仓"); } - Long versionId = repository.activeVersionId(); + Long versionId = repository.effectiveVersionId(customerId); if (versionId == null) { throw new BizException(ResultCode.NOT_FOUND, "当前没有生效的船期价格版本"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java index 188b90172..7c03889ce 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookRepository.java @@ -32,39 +32,50 @@ public class PriceBookRepository { public int insertVersion(PriceBookVersionEntity value) { return versions.insert(value); } public int updateVersion(PriceBookVersionEntity value) { return versions.update(value, new LambdaUpdateWrapper() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getTenantId, value.getTenantId()) .eq(PriceBookVersionEntity::getId, value.getId())); } public PriceBookVersionEntity findVersion(long id) { return versions.selectOne(new LambdaQueryWrapper() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) .eq(PriceBookVersionEntity::getId, id) .eq(PriceBookVersionEntity::getDeleted, false)); } public List listVersions(String status) { + return listVersions(PriceBookVersionEntity.PLATFORM_SCOPE, status); + } + public List listVersions(long customerId, String status) { return versions.selectList(new LambdaQueryWrapper() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getTenantId, customerId) .eq(status != null && !status.isBlank(), PriceBookVersionEntity::getStatus, status) .eq(PriceBookVersionEntity::getDeleted, false) .orderByDesc(PriceBookVersionEntity::getCreatedAt)); } public PriceBookVersionEntity findActiveForUpdate() { + return findActiveForUpdate(PriceBookVersionEntity.PLATFORM_SCOPE); + } + public PriceBookVersionEntity findActiveForUpdate(long customerId) { return versions.selectOne(new LambdaQueryWrapper() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getTenantId, customerId) .eq(PriceBookVersionEntity::getStatus, "ACTIVE") .eq(PriceBookVersionEntity::getDeleted, false) .last("FOR UPDATE")); } public List listActiveForUpdate() { + return listActiveForUpdate(PriceBookVersionEntity.PLATFORM_SCOPE); + } + public List listActiveForUpdate(long customerId) { return versions.selectList(new LambdaQueryWrapper() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getTenantId, customerId) .eq(PriceBookVersionEntity::getStatus, "ACTIVE") .eq(PriceBookVersionEntity::getDeleted, false) .last("FOR UPDATE")); } public PriceBookVersionEntity findActive() { + return findActive(PriceBookVersionEntity.PLATFORM_SCOPE); + } + public PriceBookVersionEntity findActive(long customerId) { return versions.selectOne(new LambdaQueryWrapper() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getTenantId, customerId) .eq(PriceBookVersionEntity::getStatus, "ACTIVE") .eq(PriceBookVersionEntity::getDeleted, false)); } @@ -74,14 +85,20 @@ public class PriceBookRepository { * ACTIVE version exists (both transactions could otherwise pass the check). */ public void lockPlatformVersions() { + lockVersions(PriceBookVersionEntity.PLATFORM_SCOPE); + } + public void lockVersions(long customerId) { versions.selectList(new LambdaQueryWrapper() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getTenantId, customerId) .eq(PriceBookVersionEntity::getDeleted, false) .last("FOR UPDATE")); } public int nextVersionNo() { + return nextVersionNo(PriceBookVersionEntity.PLATFORM_SCOPE); + } + public int nextVersionNo(long customerId) { PriceBookVersionEntity latest = versions.selectOne(new LambdaQueryWrapper() - .eq(PriceBookVersionEntity::getTenantId, PriceBookVersionEntity.PLATFORM_SCOPE) + .eq(PriceBookVersionEntity::getTenantId, customerId) .orderByDesc(PriceBookVersionEntity::getVersionNo).last("LIMIT 1 FOR UPDATE")); return latest == null ? 1 : latest.getVersionNo() + 1; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 7183beaba..501fd42d8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -117,7 +117,7 @@ public class FbaPricingService { if (incomplete) throw conflict("外箱规数据或绑定的计算规则不完整,不能生成对客费用"); } PricingPolicy policy = pricingPolicy(inbound, requestedAt == null ? LocalDateTime.now(clock) : requestedAt); - ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(new EstimateRequest( + ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest( inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(), destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null, pricingTaxMode(inbound), policy.chargeMode(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java index a4e5e6a9d..250b57c32 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java @@ -32,7 +32,7 @@ public class MerchantPriceBookService { public List channels(long merchantTenantId, long originWarehouseId) { requireMerchant(merchantTenantId); if (originWarehouseId <= 0) throw validation("请选择有效起运仓"); - Long versionId = repository.activeVersionId(); + Long versionId = repository.effectiveVersionId(merchantTenantId); if (versionId == null) return List.of(); List channels = repository.channels(versionId, originWarehouseId); if (channels.isEmpty()) return List.of(); @@ -46,7 +46,7 @@ public class MerchantPriceBookService { && request.chargeMode() != null && !"KG".equals(request.chargeMode())) { throw validation("包税报价必须按KG计费"); } - ActivePriceBookEstimator.Estimate estimate = estimator.estimate(request); + ActivePriceBookEstimator.Estimate estimate = estimator.estimate(merchantTenantId, request); return new EstimateResponse(estimate.priceBookVersionId(), estimate.channelCode(), estimate.priceModel(), estimate.pricingTaxMode(), estimate.chargeableQuantity(), estimate.chargeUnit(), estimate.unitPrice(), estimate.amount(), estimate.currency(), DISCLAIMER); @@ -56,7 +56,7 @@ public class MerchantPriceBookService { String destinationType, boolean requireInboundCodePrefix) { requireMerchant(merchantTenantId); if (originWarehouseId <= 0) throw validation("请选择有效起运仓"); - Long versionId = repository.activeVersionId(); + Long versionId = repository.effectiveVersionId(merchantTenantId); String normalized = normalizeChannel(channelCode); if (versionId == null || !repository.channelAvailable(versionId, originWarehouseId, normalized)) { throw new BizException(ResultCode.CONFLICT, "所选渠道已下线,请重新选择"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java index 49614ba3d..eb2013660 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OrderChannelChangePricingService.java @@ -117,7 +117,7 @@ public class OrderChannelChangePricingService { request.channelCode(), request.destinationType(), false); BigDecimal weight = positive(inbound.getActualNetWeightKg(), ELIGIBILITY_WEIGHT); BigDecimal volume = positive(inbound.getActualVolumeCbm(), ELIGIBILITY_VOLUME); - return estimator.estimate(new EstimateRequest(request.originWarehouseId(), request.channelCode(), + return estimator.estimate(request.tenantId(), new EstimateRequest(request.originWarehouseId(), request.channelCode(), request.destinationCode(), PricingDestinationType.valueOf(request.destinationType()), request.destinationWarehouseId(), inbound.getDestinationName(), null, PricingTaxMode.valueOf(request.pricingTaxMode()), weight, volume)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java index ee121a0a2..9adcf4617 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PlatformPriceBookService.java @@ -72,13 +72,26 @@ public class PlatformPriceBookService { @Value("${baiyue.security.internal-service-token}") private String internalToken; public List list(PriceBookStatus status) { + return list(PriceBookVersionEntity.PLATFORM_SCOPE, status); + } + + public List list(long customerId, PriceBookStatus status) { + requireScope(customerId); String persistedStatus = status == null ? null : status.name(); - return repository.listVersions(persistedStatus).stream().map(this::summary).toList(); + return (customerId == PriceBookVersionEntity.PLATFORM_SCOPE + ? repository.listVersions(persistedStatus) : repository.listVersions(customerId, persistedStatus)) + .stream().map(this::summary).toList(); } public PriceBookDtos.PriceBookVersionDetail upload(long operatorId, MultipartFile file) { + return upload(operatorId, PriceBookVersionEntity.PLATFORM_SCOPE, file); + } + + public PriceBookDtos.PriceBookVersionDetail upload(long operatorId, long customerId, MultipartFile file) { + requireScope(customerId); requireFile(file); - PriceBookVersionEntity version = transactions.execute(status -> createVersion(operatorId, file.getOriginalFilename())); + PriceBookVersionEntity version = transactions.execute(status -> createVersion( + operatorId, customerId, file.getOriginalFilename())); if (version == null) throw internal("价格版本创建失败"); Path temp = null; try { @@ -123,7 +136,8 @@ public class PlatformPriceBookService { List channelRows = repository.listChannels(versionId); List issues = new ArrayList<>(readIssues(version.getParseIssuesJson())); if (PriceBookStatus.PENDING_CONFIRMATION.name().equals(version.getStatus())) { - PriceBookVersionEntity active = repository.findActive(); + PriceBookVersionEntity active = version.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE + ? repository.findActive() : repository.findActive(version.getTenantId()); if (active != null && !Objects.equals(active.getId(), versionId)) { Set current = channelRows.stream().map(value -> normalizeChannel(value.getChannelCode())).collect(java.util.stream.Collectors.toSet()); repository.listChannels(active.getId()).stream() @@ -140,7 +154,9 @@ public class PlatformPriceBookService { @Transactional(noRollbackFor = BizException.class) public PriceBookDtos.PriceBookVersionDetail publish(long versionId, long operatorId, PriceBookConfirmationRequest confirmation) { - repository.lockPlatformVersions(); + PriceBookVersionEntity candidate = requireVersion(versionId); + if (candidate.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE) repository.lockPlatformVersions(); + else repository.lockVersions(candidate.getTenantId()); PriceBookVersionEntity version = requirePendingConfirmation(versionId); List channels = repository.listChannels(versionId).stream().map(this::channelDto).toList(); List rates = repository.listRates(versionId).stream().map(this::rateDto).toList(); @@ -158,7 +174,8 @@ public class PlatformPriceBookService { version.setChannelCount(channels.size()); version.setRateCount(mapped.rates().size()); LocalDateTime now = LocalDateTime.now(); - repository.listActiveForUpdate().stream() + (version.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE + ? repository.listActiveForUpdate() : repository.listActiveForUpdate(version.getTenantId())).stream() .filter(active -> !Objects.equals(active.getId(), versionId)) .forEach(active -> repository.supersede(active, now, operatorId)); repository.activate(version, now, operatorId); @@ -182,10 +199,11 @@ public class PlatformPriceBookService { } } - private PriceBookVersionEntity createVersion(long operatorId, String originalFileName) { + private PriceBookVersionEntity createVersion(long operatorId, long customerId, String originalFileName) { PriceBookVersionEntity value = new PriceBookVersionEntity(); - value.setTenantId(PriceBookVersionEntity.PLATFORM_SCOPE); - value.setVersionNo(repository.nextVersionNo()); + value.setTenantId(customerId); + value.setVersionNo(customerId == PriceBookVersionEntity.PLATFORM_SCOPE + ? repository.nextVersionNo() : repository.nextVersionNo(customerId)); value.setOriginalFileName(safeFileName(originalFileName)); value.setStatus(PriceBookStatus.PENDING_CONFIRMATION.name()); value.setBlockingErrorCount(0); value.setWarningCount(0); value.setChannelCount(0); value.setRateCount(0); @@ -228,7 +246,7 @@ public class PlatformPriceBookService { private CarrierFbaRateEntity rateEntity(PriceBookVersionEntity version, PriceBookDtos.ParsedRate parsedRate, long operatorId, int number, LocalDateTime now) { CarrierFbaRateEntity value = new CarrierFbaRateEntity(); - value.setTenantId(CarrierFbaRateEntity.PLATFORM_SCOPE); value.setPriceBookVersionId(version.getId()); + value.setTenantId(version.getTenantId()); value.setPriceBookVersionId(version.getId()); value.setRateCode("PB-" + version.getVersionNo() + "-" + number); value.setRateVersion(1); value.setOriginWarehouseId(parsedRate.originWarehouseId()); value.setOriginWarehouseCode(parsedRate.originWarehouseCode().trim()); value.setOriginWarehouseName(parsedRate.originWarehouseName().trim()); value.setBusinessType("FBA_SEA"); @@ -540,12 +558,16 @@ public class PlatformPriceBookService { private PriceBookDtos.PriceBookVersionSummary summary(PriceBookVersionEntity value) { PriceBookStatus status = PriceBookStatus.valueOf(value.getStatus()); - return new PriceBookDtos.PriceBookVersionSummary(value.getId(), value.getVersionNo(), value.getOriginalFileName(), + return new PriceBookDtos.PriceBookVersionSummary(value.getId(), value.getTenantId(), value.getVersionNo(), value.getOriginalFileName(), value.getSourceFileId(), value.getSourceSha256(), status, value.getChannelCount(), value.getRateCount(), value.getBlockingErrorCount(), value.getWarningCount(), value.getParseIssuesJson(), value.getCreatedBy(), value.getCreatedAt(), value.getPublishedBy(), value.getPublishedAt(), value.getSupersededAt()); } + + private static void requireScope(long customerId) { + if (customerId < 0) throw new BizException(ResultCode.VALIDATION_FAILED, "客户ID不能为负数"); + } private PriceBookDtos.ParsedChannel channelDto(PriceBookChannelEntity value) { return new PriceBookDtos.ParsedChannel(value.getChannelCode(), value.getChannelName(), PriceModel.valueOf(value.getPriceModel()), value.getSourceSheetName(), value.getEstimatedDepartureDate(), value.getEstimatedDepartureText(), diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 894c8e38d..d6945fe11 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -533,7 +533,7 @@ CREATE TABLE IF NOT EXISTS fl_price_warehouse_mapping ( CREATE TABLE IF NOT EXISTS fl_price_book_version ( id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID', - tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共价格作用域固定为0', + tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '报价作用域:0平台公共,正数为客户专属', version_no INT UNSIGNED NOT NULL COMMENT '整表版本号', original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名', source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID', diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index 240b9e4e1..580aaad9b 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -72,6 +72,7 @@ paths: summary: 查询船期价格表版本 description: 需要 firstleg:fba-pricing:rate:view 权限;返回待确认上传、当前生效和历史替换版本。 parameters: + - {name: customerId, in: query, schema: {type: string, pattern: '^(0|[1-9][0-9]*)$', default: '0'}, description: 0 表示公共报价;正数表示客户专属报价} - {name: status, in: query, schema: {$ref: '#/components/schemas/PriceBookStatus'}} responses: '200': @@ -84,7 +85,9 @@ paths: '403': {description: 无权限} post: summary: 上传船期价格表并生成待确认记录 - description: 仅上传和解析,生成待确认上传记录,不改变当前生效版本;需要 firstleg:fba-pricing:rate:edit 权限。 + description: 仅上传和解析,生成待确认上传记录,不改变当前生效版本;customerId=0 为公共报价,customerId>0 为该客户的专属报价;需要 firstleg:fba-pricing:rate:edit 权限。 + parameters: + - {name: customerId, in: query, schema: {type: string, pattern: '^(0|[1-9][0-9]*)$', default: '0'}} requestBody: required: true content: @@ -105,7 +108,7 @@ paths: /api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/publish: post: summary: 确认并立即生效船期价格表版本 - description: 只确认上传表中的仓库映射,不允许在线改价;阻断错误必须为零。发布事务将上一 ACTIVE 版本标记为 SUPERSEDED,始终只保留一份 ACTIVE。 + description: 只确认上传表中的仓库映射,不允许在线改价;阻断错误必须为零。发布事务只替换同 customerId 作用域的上一 ACTIVE 版本。计价时优先使用客户专属 ACTIVE 版本,不存在时回退公共 ACTIVE 版本。 parameters: [{$ref: '#/components/parameters/VersionId'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceBookConfirmationRequest'}}}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} @@ -545,6 +548,7 @@ components: type: object properties: id: {type: string, pattern: '^[1-9][0-9]*$'} + customerId: {type: string, pattern: '^(0|[1-9][0-9]*)$', description: 0 为公共报价,正数为客户专属报价} versionNo: {type: integer} originalFileName: {type: string, maxLength: 255} sourceFileId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java index db339e20a..6ae21e583 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java @@ -25,11 +25,14 @@ class PriceBookQueryServiceTest { void delegatesStableReadOperationsToTheOutputPort() { ChannelPrefixRow prefix = new ChannelPrefixRow("SEA", "IN", "OUT"); when(reader.findActiveVersionId()).thenReturn(9L); + when(reader.findEffectiveVersionId(10001L)).thenReturn(10L); when(reader.findChannelPrefix("SEA")).thenReturn(prefix); assertThat(service.activeVersionId()).isEqualTo(9L); + assertThat(service.effectiveVersionId(10001L)).isEqualTo(10L); assertThat(service.channelPrefix("SEA")).isSameAs(prefix); verify(reader).findActiveVersionId(); + verify(reader).findEffectiveVersionId(10001L); verify(reader).findChannelPrefix("SEA"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index bd95d463f..5b206acf2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -122,7 +122,7 @@ class FbaPricingServiceTest { inbound.setCarrierId(null); inbound.setCarrierName(null); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("KG", "167.017", "2.345678", "391.77", "10.00")); FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-1"); @@ -164,7 +164,7 @@ class FbaPricingServiceTest { when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); when(measurementRepository.findLatestMeasurement(TENANT, INBOUND)).thenReturn(measurement); when(measurementRepository.listItems(TENANT, 301L)).thenReturn(List.of(item)); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("CBM", "1.0019", "100", "100.19", "0")); service.calculate(TENANT, OPERATOR, INBOUND, NOW, @@ -202,7 +202,7 @@ class FbaPricingServiceTest { InboundPricingEntity inbound = inbound("200.000", "0.2000"); inbound.setCustomerId(null); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("KG", "200.000", "2.000000", "400.00", "0.00")); assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-no-customer")) @@ -223,7 +223,7 @@ class FbaPricingServiceTest { rule.setId(601L); rule.setRuleCode("CBM-RULE"); rule.setRuleVersion(1); rule.setChargeMode("CBM"); rule.setDefaultRule(true); when(repository.matchRules(eq(inbound), any())).thenReturn(List.of(rule)); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("CBM", "1.0001", "100.000000", "100.01", "0.00")); FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-2"); @@ -245,7 +245,7 @@ class FbaPricingServiceTest { InboundPricingEntity inbound = inbound("100.000", "1.0000"); inbound.setProcessStatus("WAIT_SPLIT"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("KG", "167.000", "2.000000", "334.00", "0.00")); FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split"); @@ -304,11 +304,11 @@ class FbaPricingServiceTest { InboundPricingEntity inbound = inbound("10", "1"); inbound.setMeasureReviewStatus("REJECTED"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("CBM", "1.0000", "10", "10", "0")); assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3")).isNotNull(); - verify(priceBookEstimator).estimate(any()); + verify(priceBookEstimator).estimate(eq(TENANT), any()); } @Test @@ -323,7 +323,7 @@ class FbaPricingServiceTest { when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent); when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); when(repository.supersedeCurrent(TENANT, 501L, "SENT")).thenReturn(1); - when(priceBookEstimator.estimate(any())).thenReturn( + when(priceBookEstimator.estimate(anyLong(), any())).thenReturn( estimate("CBM", "1.0000", "10", "10", "0")); FeeSnapshotResponse response = service.calculate( @@ -365,7 +365,7 @@ class FbaPricingServiceTest { readyRepository(); InboundPricingEntity inbound = inbound("10", "1"); when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); - when(priceBookEstimator.estimate(any())).thenThrow( + when(priceBookEstimator.estimate(anyLong(), any())).thenThrow( new BizException(ResultCode.NOT_FOUND, "当前条件没有匹配价格")); assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "none")) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java index 8ead0e597..b660e0a4e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -29,6 +29,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -44,7 +45,7 @@ class MerchantPriceBookServiceTest { @Test void shouldResolveNormalAndOverseasInboundCodePrefixesFromChannelCatalog() { - when(repository.activeVersionId()).thenReturn(7L); + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); when(repository.channelAvailable(7L, 10L, "MATSON_CLX_TRUCK")).thenReturn(true); when(repository.channels(7L, 10L)).thenReturn(List.of( channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); @@ -59,7 +60,7 @@ class MerchantPriceBookServiceTest { @Test void shouldFallBackToNormalPrefixWhenChannelHasNoOverseasVariant() { - when(repository.activeVersionId()).thenReturn(7L); + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); when(repository.channelAvailable(7L, 10L, "OA_HOUSTON_TRUCK")).thenReturn(true); when(repository.channels(7L, 10L)).thenReturn(List.of( channel(7L, "OA_HOUSTON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); @@ -72,7 +73,7 @@ class MerchantPriceBookServiceTest { @Test void shouldValidateLegacyChannelWithoutRequiringAnInboundCodePrefix() { - when(repository.activeVersionId()).thenReturn(7L); + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); when(repository.channelAvailable(7L, 10L, "LEGACY_CHANNEL")).thenReturn(true); var response = service.requireAvailableChannel( 10001L, 10L, "LEGACY_CHANNEL", "FBA", false); @@ -84,7 +85,7 @@ class MerchantPriceBookServiceTest { @Test void shouldReturnEmptyChannelsWithoutActiveVersion() { - when(repository.activeVersionId()).thenReturn(null); + when(repository.effectiveVersionId(anyLong())).thenReturn(null); assertThat(service.channels(10001L, 10L)).isEmpty(); verify(repository, never()).channels(1L, 10L); @@ -93,7 +94,7 @@ class MerchantPriceBookServiceTest { @Test void shouldExposeOnlyChannelsFromLatestActiveVersionAfterSwitch() { - when(repository.activeVersionId()).thenReturn(1L, 2L); + when(repository.effectiveVersionId(anyLong())).thenReturn(1L, 2L); when(repository.channels(1L, 10L)).thenReturn(List.of(channel(1L, "OLD", PriceModel.FBA_WAREHOUSE_RATE))); when(repository.channels(2L, 10L)).thenReturn(List.of(channel(2L, "NEW", PriceModel.FBA_WAREHOUSE_RATE))); @@ -105,7 +106,7 @@ class MerchantPriceBookServiceTest { @Test void shouldExposeDistinctPricingTaxModesForActiveOriginChannels() { - when(repository.activeVersionId()).thenReturn(7L); + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); when(repository.channels(7L, 10L)).thenReturn(List.of( channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE), channel(7L, "OA_PARCEL", PriceModel.REGION_WEIGHT_TIER))); @@ -124,7 +125,7 @@ class MerchantPriceBookServiceTest { @Test void shouldKeepActiveChannelWhenItsTaxModeProjectionIsEmpty() { - when(repository.activeVersionId()).thenReturn(7L); + when(repository.effectiveVersionId(anyLong())).thenReturn(7L); when(repository.channels(7L, 10L)).thenReturn(List.of( channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); when(repository.channelTaxModes(7L, 10L)).thenReturn(List.of()); @@ -136,7 +137,7 @@ class MerchantPriceBookServiceTest { @Test void shouldRejectForgedOrRemovedChannel() { - when(repository.activeVersionId()).thenReturn(2L); + when(repository.effectiveVersionId(anyLong())).thenReturn(2L); when(repository.channels(2L, 10L)).thenReturn(List.of(channel(2L, "ACTIVE_CHANNEL", PriceModel.FBA_WAREHOUSE_RATE))); @@ -282,7 +283,7 @@ class MerchantPriceBookServiceTest { } private void stubChannel(long versionId, String code, PriceModel model) { - when(repository.activeVersionId()).thenReturn(versionId); + when(repository.effectiveVersionId(anyLong())).thenReturn(versionId); when(repository.channels(versionId, 10L)).thenReturn(List.of(channel(versionId, code, model))); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java index e21fcfdaf..ef8b72bd3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OrderChannelChangePricingServiceTest.java @@ -18,6 +18,7 @@ import java.math.BigDecimal; 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.anyLong; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -48,7 +49,7 @@ class OrderChannelChangePricingServiceTest { InboundPricingEntity inbound = inbound("CALCULATED"); when(repository.findInboundByOrder(10001L, 77L)).thenReturn(inbound); when(repository.findLatestFee(10001L, 88L)).thenReturn(fee(91L, "CALCULATED")); - when(estimator.estimate(any())).thenReturn(estimate()); + when(estimator.estimate(anyLong(), any())).thenReturn(estimate()); var response = service.validate(request(4)); @@ -64,7 +65,7 @@ class OrderChannelChangePricingServiceTest { InboundFeeEntity fee = fee(91L, "CALCULATED"); when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound); when(repository.findLatestFeeForUpdate(10001L, 88L)).thenReturn(fee); - when(estimator.estimate(any())).thenReturn(estimate()); + when(estimator.estimate(anyLong(), any())).thenReturn(estimate()); when(repository.supersedeCurrent(10001L, 91L, "CALCULATED")).thenReturn(1); when(repository.applyOrderChannelChange(inbound, "MATSON_NEW", "新渠道")).thenReturn(1); when(repository.completeIdempotency(any(), org.mockito.ArgumentMatchers.eq(4L), any())).thenReturn(1); @@ -88,7 +89,7 @@ class OrderChannelChangePricingServiceTest { token.setRequestHash(requestHash(request(4))); when(repository.findInboundByOrderForUpdate(10001L, 77L)).thenReturn(inbound); when(repository.findIdempotency(10001L, 0L, "ORDER_CHANNEL_CHANGE", "77:4")).thenReturn(token); - when(estimator.estimate(any())).thenReturn(estimate()); + when(estimator.estimate(anyLong(), any())).thenReturn(estimate()); assertThat(service.apply(request(4)).orderVersion()).isEqualTo(4L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java index 42652dc2d..5729b9a57 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PlatformPriceBookServiceTest.java @@ -64,6 +64,22 @@ class PlatformPriceBookServiceTest { ReflectionTestUtils.setField(service, "internalToken", "internal-token"); } + @Test + void listsCustomerSpecificVersionsWithinRequestedScope() { + PriceBookVersionEntity customerVersion = version(21L, PriceBookStatus.ACTIVE); + customerVersion.setTenantId(10001L); + when(repository.listVersions(10001L, PriceBookStatus.ACTIVE.name())) + .thenReturn(List.of(customerVersion)); + + var result = service.list(10001L, PriceBookStatus.ACTIVE); + + assertThat(result).singleElement().satisfies(summary -> { + assertThat(summary.id()).isEqualTo(21L); + assertThat(summary.customerId()).isEqualTo(10001L); + }); + verify(repository).listVersions(10001L, PriceBookStatus.ACTIVE.name()); + } + @Test void fileServiceFailureMarksAllocatedVersionFailed() { executeTransactionsImmediately(); @@ -121,7 +137,7 @@ class PlatformPriceBookServiceTest { assertThat(Arrays.stream(PlatformPriceBookService.class.getDeclaredMethods()) .filter(method -> java.lang.reflect.Modifier.isPublic(method.getModifiers())) .map(java.lang.reflect.Method::getName).toList()) - .containsExactlyInAnyOrder("list", "upload", "detail", "publish", "source"); + .containsOnly("list", "upload", "detail", "publish", "source"); } @Test From f3a0a45e5929aca41b8c6a975fe8a7fe4688cb9f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:55:29 +0800 Subject: [PATCH 37/56] fix(lastleg): stock receipts into scanned locations --- .../pda/service/InboundStockReceiptService.java | 11 ++++++++++- .../pda/service/InboundStockReceiptServiceTest.java | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) 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 e511e9880..3888863a8 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 @@ -15,6 +15,7 @@ import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; @@ -43,6 +44,7 @@ public class InboundStockReceiptService { @Resource private InboundStockReceiptMapper receipts; @Resource private ReceivingSkuReceiptMapper receivingSkuReceipts; @Resource private InventoryCommandService inventory; + @Resource private WarehouseResourceGateway warehouseGateway; @Resource private DropshipOrderService dropshipOrders; @Resource private LastlegOrderStatusHistoryMapper orderHistories; @@ -237,13 +239,20 @@ public class InboundStockReceiptService { if (overseasWarehouseId.equals(trim(order.getSourceWarehouseId()))) { throw conflict("Actual overseas warehouse must not use the domestic source warehouse"); } + WarehouseResourceGateway.WarehouseLocationReference location = warehouseGateway.resolveLocation( + Long.parseLong(overseasWarehouseId), pallet.getWarehouseLocationId()); + if (location == null || location.locationId() != pallet.getWarehouseLocationId()) { + throw conflict("Assigned warehouse location could not be resolved for stock-in"); + } + String warehouseLocationCode = required(location.locationCode(), + "Assigned warehouse location code is required for stock-in"); InventoryCommandRequests.StockIn stockIn = new InventoryCommandRequests.StockIn(null, customerName, 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(), - pallet.getWarehouseLocationId(), null, packages, order.getOrderNo(), + pallet.getWarehouseLocationId(), warehouseLocationCode, packages, order.getOrderNo(), order.getSourceEventId(), operationKey, "PDA inbound completion for " + line.getInboundCode()); InventoryCommandResponse result = Boolean.TRUE.equals(order.getDetained()) 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 c4035d8f9..12aabff78 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 @@ -14,6 +14,7 @@ import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingSkuReceiptMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; @@ -50,6 +51,7 @@ class InboundStockReceiptServiceTest { @Mock InboundStockReceiptMapper receipts; @Mock ReceivingSkuReceiptMapper receivingSkuReceipts; @Mock InventoryCommandService inventory; + @Mock WarehouseResourceGateway warehouseGateway; @Mock DropshipOrderService dropshipOrders; @Mock LastlegOrderStatusHistoryMapper orderHistories; private InboundStockReceiptService service; @@ -62,6 +64,12 @@ class InboundStockReceiptServiceTest { ReflectionTestUtils.setField(service, "receipts", receipts); ReflectionTestUtils.setField(service, "receivingSkuReceipts", receivingSkuReceipts); ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway); + org.mockito.Mockito.lenient().when(warehouseGateway.resolveLocation(eq(900L), anyLong())).thenAnswer(invocation -> { + long locationId = invocation.getArgument(1); + return new WarehouseResourceGateway.WarehouseLocationReference(locationId, + "PDA-LAX-" + locationId, "STORAGE", BigDecimal.TEN, "OCCUPIED"); + }); ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders); ReflectionTestUtils.setField(service, "orderHistories", orderHistories); when(receipts.listTask("100", 2L)).thenReturn(List.of()); @@ -88,6 +96,7 @@ class InboundStockReceiptServiceTest { assertEquals("900", request.getValue().overseasWarehouseId()); assertEquals("US-LAX-01", request.getValue().overseasWarehouseCode()); assertEquals("Los Angeles", request.getValue().overseasWarehouseName()); + assertEquals("PDA-LAX-3001", request.getValue().warehouseLocationCode()); assertEquals("PLT-21", request.getValue().palletNo()); ArgumentCaptor receipt = ArgumentCaptor.forClass(InboundStockReceipt.class); verify(receipts).insert(receipt.capture()); From 3aaba2d6a3ce3e344181ee044ff6502e5a471180 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 18:02:47 +0800 Subject: [PATCH 38/56] fix: order dropship schema convergence before assertions --- deploy/sql/头程数据库统一增量-MySQL8.sql | 69 +++++++++++++++++-- .../DropshipPalletizationSqlContractTest.java | 1 + 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index fb1ef1fef..a11bed90f 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -8000,6 +8000,57 @@ SELECT role.id, permission.id, role.tenant_id ) WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR'); +-- 已存在库必须先把装柜分配唯一键收敛到“普通明细或封托托盘”,再执行三期断言。 +USE baiyue_firstleg; +DROP PROCEDURE IF EXISTS converge_loading_assignment_schema; +DELIMITER $$ +CREATE PROCEDURE converge_loading_assignment_schema() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item' + AND column_name='unit_type') THEN + ALTER TABLE fl_loading_plan_item + ADD COLUMN unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE' + COMMENT '分配单元:PACKAGE普通箱、SEALED_PALLET一件代发封托' AFTER product_quantity, + ADD COLUMN pallet_id BIGINT UNSIGNED NULL COMMENT '一件代发封托ID;普通箱为空' AFTER unit_type, + ADD COLUMN pallet_no VARCHAR(32) NULL COMMENT '一件代发系统托盘码快照' AFTER pallet_id, + ADD COLUMN indivisible TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为不可拆整托单元' AFTER pallet_no; + END IF; + ALTER TABLE fl_loading_plan_item + MODIFY COLUMN unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE' + COMMENT '分配单元:PACKAGE普通箱、SEALED_PALLET一件代发封托', + MODIFY COLUMN pallet_id BIGINT UNSIGNED NULL COMMENT '一件代发封托ID;普通箱为空', + MODIFY COLUMN pallet_no VARCHAR(32) NULL COMMENT '一件代发系统托盘码快照', + MODIFY COLUMN indivisible TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为不可拆整托单元'; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item' + AND column_name='active_assignment_key') THEN + ALTER TABLE fl_loading_plan_item + ADD COLUMN active_assignment_key VARCHAR(96) + GENERATED ALWAYS AS (CASE WHEN deleted=0 THEN CONCAT(CASE WHEN pallet_id IS NULL THEN 'D:0' ELSE CONCAT('P:',pallet_id) END,':',inbound_detail_id) ELSE NULL END) STORED + COMMENT '普通明细或已封托托盘明细的有效唯一锁' AFTER active_inbound_detail_id; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.statistics + WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item' + AND index_name='uk_loading_item_plan_detail') THEN + ALTER TABLE fl_loading_plan_item DROP INDEX uk_loading_item_plan_detail; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics + WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item' + AND index_name='uk_loading_item_plan_assignment') THEN + ALTER TABLE fl_loading_plan_item + ADD UNIQUE KEY uk_loading_item_plan_assignment (loading_plan_id,active_assignment_key); + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics + WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item' + AND index_name='idx_loading_item_pallet') THEN + ALTER TABLE fl_loading_plan_item ADD KEY idx_loading_item_pallet (pallet_id,deleted); + END IF; +END$$ +DELIMITER ; +CALL converge_loading_assignment_schema(); +DROP PROCEDURE IF EXISTS converge_loading_assignment_schema; + -- 污染存量库必须先完成旧入库权限搬迁和两类授权恢复,再校验智能装柜权限。 USE baiyue_firstleg; CALL assert_phase3_smart_loading_schema(); @@ -17572,8 +17623,11 @@ BEGIN MODIFY COLUMN expected_sku_quantity INT UNSIGNED NOT NULL COMMENT '箱唛冻结的应收SKU数量', MODIFY COLUMN actual_sku_quantity INT UNSIGNED NOT NULL COMMENT 'PDA确认的实收SKU数量', MODIFY COLUMN discrepancy_status VARCHAR(24) NOT NULL COMMENT '差异状态:MATCH或ACCEPTED_VARIANCE', + MODIFY COLUMN discrepancy_remark VARCHAR(500) NULL COMMENT '差异接受说明', + MODIFY COLUMN bind_operation_id VARCHAR(128) NULL COMMENT '批量绑托幂等操作ID', MODIFY COLUMN last_operation_id VARCHAR(128) NOT NULL COMMENT '最后一次绑托或换托操作幂等号', MODIFY COLUMN counted_by BIGINT UNSIGNED NOT NULL COMMENT '清点操作人ID', + MODIFY COLUMN counted_by_name VARCHAR(64) NULL COMMENT '清点操作人姓名快照', MODIFY COLUMN counted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清点时间', MODIFY COLUMN bound_by BIGINT UNSIGNED NULL COMMENT '上托操作人ID', MODIFY COLUMN bound_at DATETIME(3) NULL COMMENT '上托时间'; @@ -17594,11 +17648,18 @@ BEGIN WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item' AND column_name='unit_type') THEN ALTER TABLE fl_loading_plan_item - ADD COLUMN unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE' AFTER product_quantity, - ADD COLUMN pallet_id BIGINT UNSIGNED NULL AFTER unit_type, - ADD COLUMN pallet_no VARCHAR(32) NULL AFTER pallet_id, - ADD COLUMN indivisible TINYINT(1) NOT NULL DEFAULT 0 AFTER pallet_no; + ADD COLUMN unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE' + COMMENT '分配单元:PACKAGE普通箱、SEALED_PALLET一件代发封托' AFTER product_quantity, + ADD COLUMN pallet_id BIGINT UNSIGNED NULL COMMENT '一件代发封托ID;普通箱为空' AFTER unit_type, + ADD COLUMN pallet_no VARCHAR(32) NULL COMMENT '一件代发系统托盘码快照' AFTER pallet_id, + ADD COLUMN indivisible TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为不可拆整托单元' AFTER pallet_no; END IF; + ALTER TABLE fl_loading_plan_item + MODIFY COLUMN unit_type VARCHAR(24) NOT NULL DEFAULT 'PACKAGE' + COMMENT '分配单元:PACKAGE普通箱、SEALED_PALLET一件代发封托', + MODIFY COLUMN pallet_id BIGINT UNSIGNED NULL COMMENT '一件代发封托ID;普通箱为空', + MODIFY COLUMN pallet_no VARCHAR(32) NULL COMMENT '一件代发系统托盘码快照', + MODIFY COLUMN indivisible TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为不可拆整托单元'; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item' AND column_name='active_assignment_key') THEN diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java index 212895723..ed3c1118b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/DropshipPalletizationSqlContractTest.java @@ -39,6 +39,7 @@ class DropshipPalletizationSqlContractTest { .doesNotContain("CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton ("); } assertThat(unified) + .contains("CREATE PROCEDURE converge_loading_assignment_schema()") .contains("CREATE PROCEDURE converge_dropship_palletization_schema()") .contains("CHANGE COLUMN operation_id count_operation_id") .contains("DROP INDEX uk_loading_item_plan_detail") From 1a1a19dea7c0ec715ee2946bdc9475102a4cdee3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 18:04:14 +0800 Subject: [PATCH 39/56] feat(firstleg): tier container customer freight --- .../mapper/ContainerTierPricingMapper.java | 46 +++++++++ .../pricing/ContainerTierAmountAllocator.java | 72 ++++++++++++++ .../repository/FbaPricingRepository.java | 7 ++ .../firstleg/service/FbaPricingService.java | 48 ++++++++- .../ContainerTierAmountAllocatorTest.java | 61 ++++++++++++ .../service/FbaPricingServiceTest.java | 98 +++++++++++++++++++ 6 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTierPricingMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocator.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocatorTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTierPricingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTierPricingMapper.java new file mode 100644 index 000000000..70553bdce --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTierPricingMapper.java @@ -0,0 +1,46 @@ +package com.baiyue.firstleg.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.math.BigDecimal; +import java.util.List; + +/** 同柜同客户区域重量阶梯计费查询。 */ +@Mapper +public interface ContainerTierPricingMapper { + + @Select(""" + + """) + List members(@Param("customerId") long customerId, + @Param("inboundId") long inboundId); + + record MemberRow(long containerBookingId, long inboundId, BigDecimal actualWeightKg, + BigDecimal actualVolumeCbm) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocator.java new file mode 100644 index 000000000..e8a23645c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocator.java @@ -0,0 +1,72 @@ +package com.baiyue.firstleg.pricing; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** 将同柜同客户的阶梯总额按各票计费重分摊,并保证分摊总额守恒。 */ +@Component +public class ContainerTierAmountAllocator { + private static final int MONEY_SCALE = 2; + private static final BigDecimal CENT = new BigDecimal("0.01"); + + public Allocation allocate(List source, BigDecimal unitPrice) { + if (source == null || source.isEmpty() || unitPrice == null || unitPrice.signum() < 0) { + throw validation("柜级阶梯计费参数无效"); + } + List members = source.stream() + .sorted(Comparator.comparingLong(Member::inboundId)) + .toList(); + if (members.stream().map(Member::inboundId).distinct().count() != members.size()) { + throw validation("柜级阶梯计费成员不能重复"); + } + BigDecimal totalQuantity = members.stream().map(Member::chargeableQuantity) + .peek(value -> { + if (value == null || value.signum() < 0) throw validation("计费重不能小于0"); + }) + .reduce(BigDecimal.ZERO, BigDecimal::add); + if (totalQuantity.signum() <= 0) throw validation("柜级合计计费重必须大于0"); + + BigDecimal totalAmount = totalQuantity.multiply(unitPrice).setScale(MONEY_SCALE, RoundingMode.HALF_UP); + List shares = new ArrayList<>(members.size()); + BigDecimal allocated = BigDecimal.ZERO.setScale(MONEY_SCALE); + for (Member member : members) { + BigDecimal exact = totalAmount.multiply(member.chargeableQuantity()) + .divide(totalQuantity, 12, RoundingMode.HALF_UP); + BigDecimal floor = exact.setScale(MONEY_SCALE, RoundingMode.DOWN); + allocated = allocated.add(floor); + shares.add(new Share(member.inboundId(), floor, exact.subtract(floor))); + } + int cents = totalAmount.subtract(allocated).movePointRight(MONEY_SCALE).intValueExact(); + shares.sort(Comparator.comparing(Share::remainder).reversed().thenComparingLong(Share::inboundId)); + for (int index = 0; index < cents; index++) { + Share share = shares.get(index % shares.size()); + shares.set(index % shares.size(), share.add(CENT)); + } + shares.sort(Comparator.comparingLong(Share::inboundId)); + Map amounts = new LinkedHashMap<>(); + shares.forEach(share -> amounts.put(share.inboundId(), share.amount())); + return new Allocation(totalQuantity, totalAmount, amounts); + } + + private static BizException validation(String message) { + return new BizException(ResultCode.VALIDATION_FAILED, message); + } + + public record Member(long inboundId, BigDecimal chargeableQuantity) {} + public record Allocation(BigDecimal totalQuantity, BigDecimal totalAmount, + Map memberAmounts) {} + private record Share(long inboundId, BigDecimal amount, BigDecimal remainder) { + private Share add(BigDecimal increment) { + return new Share(inboundId, amount.add(increment), remainder); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index 867e31cde..ae148006f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -18,6 +18,7 @@ import com.baiyue.firstleg.mapper.InboundFeeMapper; import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.InboundPricingIdempotencyMapper; import com.baiyue.firstleg.mapper.InboundPricingMapper; +import com.baiyue.firstleg.mapper.ContainerTierPricingMapper; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; @@ -40,6 +41,12 @@ public class FbaPricingRepository { private InboundPricingIdempotencyMapper idempotencies; @Resource private InboundOutboxMapper outbox; + @Resource + private ContainerTierPricingMapper containerTierPricing; + + public List containerTierMembers(long customerId, long inboundId) { + return containerTierPricing.members(customerId, inboundId); + } public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 501fd42d8..8ad0ebfea 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -14,6 +14,8 @@ import com.baiyue.firstleg.entity.InboundFeeItemEntity; import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; +import com.baiyue.firstleg.pricing.ContainerTierAmountAllocator; +import com.baiyue.firstleg.domain.PriceModel; import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import com.baiyue.firstleg.repository.FbaPricingRepository; import org.springframework.stereotype.Service; @@ -30,6 +32,8 @@ import java.time.Clock; import java.time.LocalDateTime; import java.util.HexFormat; import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; /** FBA 费用试算、确认和发送状态机。 */ @Service @@ -51,6 +55,8 @@ public class FbaPricingService { private InboundFeeDetailService feeDetails; @Resource private FinanceFeeSourceOutboxService financeOutbox; + @Resource + private ContainerTierAmountAllocator containerTierAllocator; @Transactional public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId, @@ -123,9 +129,10 @@ public class FbaPricingService { pricingTaxMode(inbound), policy.chargeMode(), selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg, selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm)); - RateRow rate = estimate.rate(); BigDecimal weight = (selectedWeightKg == null ? inbound.getActualNetWeightKg() : selectedWeightKg).setScale(3, RoundingMode.HALF_UP); BigDecimal volume = (selectedVolumeCbm == null ? inbound.getActualVolumeCbm() : selectedVolumeCbm).setScale(4, RoundingMode.HALF_UP); + estimate = applyContainerCustomerTier(inbound, estimate, weight, volume); + RateRow rate = estimate.rate(); BigDecimal chargeable = estimate.chargeableQuantity(); BigDecimal converted = chargeable; BigDecimal subtotal = estimate.amount().setScale(MONEY_SCALE, RoundingMode.HALF_UP); @@ -167,6 +174,45 @@ public class FbaPricingService { return response(inbound, fee, rate.getUnitPrice(), nextStatus); } + private ActivePriceBookEstimator.Estimate applyContainerCustomerTier( + InboundPricingEntity inbound, ActivePriceBookEstimator.Estimate individual, + BigDecimal currentWeight, BigDecimal currentVolume) { + if (individual.priceModel() != PriceModel.REGION_WEIGHT_TIER) return individual; + var rows = repository.containerTierMembers(inbound.getTenantId(), inbound.getId()); + if (rows == null || rows.isEmpty()) return individual; + long containerId = rows.get(0).containerBookingId(); + if (rows.stream().anyMatch(row -> row.containerBookingId() != containerId)) { + throw conflict("同一入仓单已分配到多个柜子,不能生成柜级阶梯费用"); + } + Map unique = new LinkedHashMap<>(); + for (var row : rows) { + BigDecimal weight = row.inboundId() == inbound.getId() ? currentWeight : row.actualWeightKg(); + BigDecimal volume = row.inboundId() == inbound.getId() ? currentVolume : row.actualVolumeCbm(); + BigDecimal quantity = chargeableWeight(weight, volume); + unique.putIfAbsent(row.inboundId(), new ContainerTierAmountAllocator.Member(row.inboundId(), quantity)); + } + if (!unique.containsKey(inbound.getId()) || unique.size() < 2) return individual; + BigDecimal aggregate = unique.values().stream().map(ContainerTierAmountAllocator.Member::chargeableQuantity) + .reduce(BigDecimal.ZERO, BigDecimal::add); + ActivePriceBookEstimator.Estimate group = priceBookEstimator.estimate(inbound.getTenantId(), new EstimateRequest( + inbound.getWarehouseId(), inbound.getChannelCode(), inbound.getDestinationCode(), + destinationType(inbound), inbound.getDestinationWarehouseId(), inbound.getDestinationName(), null, + pricingTaxMode(inbound), "KG", aggregate, BigDecimal.ZERO)); + var allocation = containerTierAllocator.allocate(List.copyOf(unique.values()), group.unitPrice()); + BigDecimal memberQuantity = unique.get(inbound.getId()).chargeableQuantity(); + return new ActivePriceBookEstimator.Estimate(group.priceBookVersionId(), group.channelCode(), + group.priceModel(), group.pricingTaxMode(), group.rate(), memberQuantity, group.chargeUnit(), + group.unitPrice(), allocation.memberAmounts().get(inbound.getId()), group.currency(), + group.conversionFactor(), group.roundingScale(), group.roundingMode()); + } + + private static BigDecimal chargeableWeight(BigDecimal weight, BigDecimal volume) { + BigDecimal safeWeight = weight == null ? BigDecimal.ZERO : weight; + BigDecimal safeVolume = volume == null ? BigDecimal.ZERO : volume; + return safeWeight.max(safeVolume.multiply(BigDecimal.valueOf(167))) + .setScale(6, RoundingMode.HALF_UP); + } + @Transactional public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId, String idempotencyKey) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocatorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocatorTest.java new file mode 100644 index 000000000..9a4d8e0f8 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricing/ContainerTierAmountAllocatorTest.java @@ -0,0 +1,61 @@ +package com.baiyue.firstleg.pricing; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ContainerTierAmountAllocatorTest { + private final ContainerTierAmountAllocator allocator = new ContainerTierAmountAllocator(); + + @Test + void allocatesOneGroupByChargeableWeightAndConservesRoundedTotal() { + var result = allocator.allocate(List.of( + member(31, "100"), member(11, "101"), member(21, "102") + ), new BigDecimal("9.99")); + + assertThat(result.totalQuantity()).isEqualByComparingTo("303"); + assertThat(result.totalAmount()).isEqualByComparingTo("3026.97"); + assertThat(result.memberAmounts()).containsExactly( + java.util.Map.entry(11L, new BigDecimal("1008.99")), + java.util.Map.entry(21L, new BigDecimal("1018.98")), + java.util.Map.entry(31L, new BigDecimal("999.00"))); + assertThat(result.memberAmounts().values().stream().reduce(BigDecimal.ZERO, BigDecimal::add)) + .isEqualByComparingTo(result.totalAmount()); + } + + @Test + void distributesRoundingCentsDeterministically() { + var result = allocator.allocate(List.of(member(3, "1"), member(2, "1"), member(1, "1")), + new BigDecimal("0.005")); + + assertThat(result.memberAmounts()).containsExactly( + java.util.Map.entry(1L, new BigDecimal("0.01")), + java.util.Map.entry(2L, new BigDecimal("0.01")), + java.util.Map.entry(3L, new BigDecimal("0.00"))); + assertThat(result.memberAmounts().values().stream().reduce(BigDecimal.ZERO, BigDecimal::add)) + .isEqualByComparingTo("0.02"); + } + + @Test + void keepsSingleInboundAmountAndQuantity() { + var result = allocator.allocate(List.of(member(11, "1.337")), new BigDecimal("10")); + + assertThat(result.totalQuantity()).isEqualByComparingTo("1.337"); + assertThat(result.totalAmount()).isEqualByComparingTo("13.37"); + assertThat(result.memberAmounts()).containsExactly(java.util.Map.entry(11L, new BigDecimal("13.37"))); + } + + @Test + void rejectsDuplicateInboundMembers() { + assertThatThrownBy(() -> allocator.allocate(List.of(member(11, "1"), member(11, "2")), BigDecimal.ONE)) + .hasMessageContaining("不能重复"); + } + + private static ContainerTierAmountAllocator.Member member(long inboundId, String weight) { + return new ContainerTierAmountAllocator.Member(inboundId, new BigDecimal(weight)); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 5b206acf2..c5e7d40c5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -12,13 +12,16 @@ import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.entity.InboundMeasurementEntity; import com.baiyue.firstleg.entity.InboundMeasurementItemEntity; import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; +import com.baiyue.firstleg.mapper.ContainerTierPricingMapper; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; +import com.baiyue.firstleg.pricing.ContainerTierAmountAllocator; import com.baiyue.firstleg.repository.FbaPricingRepository; import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.apache.ibatis.annotations.Select; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -31,6 +34,7 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.util.concurrent.atomic.AtomicReference; import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -58,6 +62,7 @@ class FbaPricingServiceTest { @MockBean private com.baiyue.firstleg.repository.InboundMeasurementRepository measurementRepository; @MockBean private InboundFeeDetailService feeDetails; @MockBean private ActivePriceBookEstimator priceBookEstimator; + @MockBean private ContainerTierAmountAllocator containerTierAllocator; @Resource private FbaPricingService service; @Test @@ -677,6 +682,80 @@ class FbaPricingServiceTest { verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any()); } + @Test + @DisplayName("同客户同柜区域阶梯按总计费重命中临界档并按票分摊") + void appliesContainerCustomerTierAndAllocatedAmount() { + readyRepository(); + InboundPricingEntity inbound = inbound("100", "0.1"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.containerTierMembers(TENANT, INBOUND)).thenReturn(List.of( + memberRow(901L, INBOUND, "100", "0.1"), + memberRow(901L, 30002L, "100", "0.1") + )); + when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn( + regionEstimate("100", "10", "1000"), regionEstimate("200", "8", "1600")); + when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn( + new ContainerTierAmountAllocator.Allocation(new BigDecimal("200"), new BigDecimal("1600"), + Map.of(INBOUND, new BigDecimal("800.00"), 30002L, new BigDecimal("800.00")))); + + FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "container-tier"); + + assertThat(response.chargeableQuantity()).isEqualByComparingTo("100"); + assertThat(response.unitPrice()).isEqualByComparingTo("8"); + assertThat(response.subtotalAmount()).isEqualByComparingTo("800.00"); + assertThat(response.totalAmount()).isEqualByComparingTo("800.00"); + ArgumentCaptor requests = + ArgumentCaptor.forClass(com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest.class); + verify(priceBookEstimator, times(2)).estimate(eq(TENANT), requests.capture()); + assertThat(requests.getAllValues().get(1).actualWeightKg()).isEqualByComparingTo("200"); + assertThat(requests.getAllValues().get(1).actualVolumeCbm()).isEqualByComparingTo(BigDecimal.ZERO); + } + + @Test + @DisplayName("单票柜次保持原区域阶梯结果,不进行无意义分摊") + void keepsIndividualResultForSingleInboundContainer() { + readyRepository(); + InboundPricingEntity inbound = inbound("100", "0.1"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.containerTierMembers(TENANT, INBOUND)).thenReturn(List.of( + memberRow(901L, INBOUND, "100", "0.1") + )); + when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("100", "10", "1000")); + + FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "single-container-tier"); + + assertThat(response.totalAmount()).isEqualByComparingTo("1000"); + verify(containerTierAllocator, never()).allocate(any(), any()); + verify(priceBookEstimator).estimate(eq(TENANT), any()); + } + + @Test + @DisplayName("无柜次关系不改变单票区域阶梯计价") + void keepsIndividualResultWithoutContainerRelation() { + readyRepository(); + InboundPricingEntity inbound = inbound("100", "0.1"); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.containerTierMembers(TENANT, INBOUND)).thenReturn(List.of()); + when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn(regionEstimate("100", "10", "1000")); + + assertThat(service.calculate(TENANT, OPERATOR, INBOUND, NOW, "no-container-tier").totalAmount()) + .isEqualByComparingTo("1000"); + verify(containerTierAllocator, never()).allocate(any(), any()); + } + + @Test + @DisplayName("柜次成员查询只按客户和同一柜次隔离") + void scopesContainerTierMembersByCustomerAndContainer() throws NoSuchMethodException { + String sql = ContainerTierPricingMapper.class.getMethod("members", long.class, long.class) + .getAnnotation(Select.class).value()[0]; + + assertThat(sql).contains("plan.container_booking_id=current_plan.container_booking_id") + .contains("grouped_item.account_id=#{customerId}") + .contains("grouped_inbound.tenant_id=#{customerId}") + .doesNotContain("grouped_inbound.destination_code") + .doesNotContain("grouped_inbound.destination_warehouse_id"); + } + private void readyRepository() { when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null); doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; }) @@ -725,6 +804,25 @@ class FbaPricingServiceTest { "KG".equals(unit) ? new BigDecimal("167") : new BigDecimal("363"), 6, "HALF_UP"); } + private ContainerTierPricingMapper.MemberRow memberRow(long containerId, long inboundId, + String weight, String volume) { + return new ContainerTierPricingMapper.MemberRow(containerId, inboundId, + new BigDecimal(weight), new BigDecimal(volume)); + } + + private ActivePriceBookEstimator.Estimate regionEstimate(String quantity, String unitPrice, String amount) { + RateRow rate = new RateRow(); + rate.setId(202L); rate.setPriceBookVersionId(701L); rate.setRateCode("PB-TIER-1"); rate.setRateVersion(1); + rate.setOriginWarehouseId(21L); rate.setOriginWarehouseName("义乌仓"); rate.setChannelCode("MATSON_TRUCK"); + rate.setPriceModel(PriceModel.REGION_WEIGHT_TIER.name()); rate.setDestinationType("FBA"); + rate.setDestinationCode("ONT8"); rate.setPricingTaxMode("TAX_INCLUDED"); rate.setChargeUnit("KG"); + rate.setUnitPrice(new BigDecimal(unitPrice)); rate.setCurrency("USD"); rate.setAdditionalFee(BigDecimal.ZERO); + rate.setEffectiveFrom(NOW.minusDays(1)); + return new ActivePriceBookEstimator.Estimate(701L, "MATSON_TRUCK", PriceModel.REGION_WEIGHT_TIER, + PricingTaxMode.TAX_INCLUDED, rate, new BigDecimal(quantity), "KG", new BigDecimal(unitPrice), + new BigDecimal(amount), "USD", new BigDecimal("167"), 6, "HALF_UP"); + } + private InboundPricingIdempotencyEntity completed(String operation, long feeId) { InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity(); value.setId(900L); value.setOperation(operation); value.setStatus("COMPLETED"); From fcb4c055e23d3c90e1493888e770e4aeb64503ff Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 18:16:54 +0800 Subject: [PATCH 40/56] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mapper/ContainerTierPricingMapper.java | 21 ++ .../repository/FbaPricingRepository.java | 18 ++ .../firstleg/service/FbaPricingService.java | 168 ++++++++++ .../service/FbaPricingServiceTest.java | 131 ++++++++ .../service/CustomerVisitService.java | 14 +- .../DeliveryTransferController.java | 9 + .../delivery/dto/DeliveryResponses.java | 5 +- .../delivery/model/DeliveryStatusPolicy.java | 37 +++ .../service/DeliveryTransferService.java | 47 ++- .../mapper/DropshipOrderProfileMapper.java | 40 +++ .../model/DropshipOutboundCandidate.java | 18 ++ .../controller/ExceptionOrderController.java | 32 ++ .../dto/ExceptionOrderRequests.java | 31 +- .../dto/ExceptionOrderResponses.java | 16 +- .../exceptionorder/entity/ExceptionOrder.java | 5 + .../entity/ExceptionOrderCommand.java | 21 ++ .../entity/ExceptionReturnItem.java | 23 ++ .../mapper/ExceptionOrderCommandMapper.java | 15 + .../mapper/ExceptionOrderMapper.java | 7 + .../mapper/ExceptionReturnItemMapper.java | 16 + .../service/ExceptionOrderService.java | 300 +++++++++++++++++- .../dto/ReturnedInventoryRestoreRequest.java | 21 ++ .../dto/ReturnedInventoryRestoreResponse.java | 19 ++ .../mapper/InventoryCommandMapper.java | 13 + .../lastleg/security/OverseasPermissions.java | 1 + .../openapi/overseas-business-v1.yaml | 46 ++- .../service/CustomerVisitServiceTest.java | 81 +++++ .../DeliveryLongIdJsonContractTest.java | 2 +- ...ipOutboundCandidateMapperContractTest.java | 40 +++ .../PlatformDispatchOutboundContractTest.java | 8 + .../model/DeliveryStatusPolicyTest.java | 26 ++ .../service/DeliveryTransferServiceTest.java | 54 +++- 32 files changed, 1239 insertions(+), 46 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipOutboundCandidate.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrderCommand.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderCommandMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ReturnedInventoryRestoreRequest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ReturnedInventoryRestoreResponse.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DropshipOutboundCandidateMapperContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTierPricingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTierPricingMapper.java index 70553bdce..7afc62583 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTierPricingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerTierPricingMapper.java @@ -41,6 +41,27 @@ public interface ContainerTierPricingMapper { List members(@Param("customerId") long customerId, @Param("inboundId") long inboundId); + /** 锁住当前柜次内某客户全部有效明细,供阶梯费用组重算使用。 */ + @Select(""" + SELECT plan.container_booking_id AS containerBookingId, + inbound.id AS inboundId, + inbound.actual_net_weight_kg AS actualWeightKg, + inbound.actual_volume_cbm AS actualVolumeCbm + FROM fl_loading_plan plan + JOIN fl_loading_plan_item item ON item.loading_plan_id=plan.id + AND item.deleted=0 + AND item.account_id=#{customerId} + JOIN fl_inbound_order inbound ON inbound.id=item.inbound_id + AND inbound.tenant_id=#{customerId} + AND inbound.deleted=0 + WHERE plan.container_booking_id=#{containerBookingId} + AND plan.plan_status IN ('EDITING','LOADING','COMPLETED') + ORDER BY item.inbound_id,item.id + FOR UPDATE + """) + List membersForUpdate(@Param("containerBookingId") long containerBookingId, + @Param("customerId") long customerId); + record MemberRow(long containerBookingId, long inboundId, BigDecimal actualWeightKg, BigDecimal actualVolumeCbm) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index ae148006f..64afbbf5c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -48,6 +48,11 @@ public class FbaPricingRepository { return containerTierPricing.members(customerId, inboundId); } + public List containerTierMembersForUpdate( + long containerBookingId, long customerId) { + return containerTierPricing.membersForUpdate(containerBookingId, customerId); + } + public void insertRule(CustomerChargeRuleEntity rule) { rules.insert(rule); } public int clearDefaultRule(long tenantId, long customerId, long operatorId) { @@ -311,6 +316,19 @@ public class FbaPricingRepository { return inbounds.update(null, update); } + public int markInboundContainerFeeRecalculated(long tenantId, long inboundId, String processStatus, + String chargeMode, java.math.BigDecimal chargeable) { + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .eq(InboundPricingEntity::getTenantId, tenantId) + .eq(InboundPricingEntity::getId, inboundId) + .eq(InboundPricingEntity::getProcessStatus, processStatus) + .in(InboundPricingEntity::getFeeStatus, List.of("CALCULATED", "CONFIRMED")) + .set(InboundPricingEntity::getFeeStatus, "CALCULATED"); + if ("KG".equals(chargeMode)) update.set(InboundPricingEntity::getChargeableWeightKg, chargeable); + else update.set(InboundPricingEntity::getChargeableVolumeCbm, chargeable); + return inbounds.update(null, update); + } + public int confirmFee(long tenantId, long feeId, long operatorId, LocalDateTime now) { return fees.update(null, new LambdaUpdateWrapper() .eq(InboundFeeEntity::getTenantId, tenantId).eq(InboundFeeEntity::getId, feeId) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 8ad0ebfea..2689a8f9e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -13,6 +13,7 @@ import com.baiyue.firstleg.entity.InboundFeeEntity; import com.baiyue.firstleg.entity.InboundFeeItemEntity; import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; +import com.baiyue.firstleg.mapper.ContainerTierPricingMapper; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; import com.baiyue.firstleg.pricing.ContainerTierAmountAllocator; import com.baiyue.firstleg.domain.PriceModel; @@ -30,6 +31,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.Clock; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.HexFormat; import java.util.List; import java.util.LinkedHashMap; @@ -43,6 +45,7 @@ public class FbaPricingService { private static final BigDecimal ONE_HUNDRED = new BigDecimal("100"); private static final long AUTOMATIC_CONFIRM_OPERATOR_ID = 0L; private static final String AUTOMATIC_CONFIRM_OPERATOR_NAME = "装柜完成自动确认"; + private static final String CONTAINER_RECALCULATE_OPERATION = "CONTAINER_FEE_RECALCULATE"; @Resource private FbaPricingRepository repository; @Resource @@ -213,6 +216,171 @@ public class FbaPricingService { .setScale(6, RoundingMode.HALF_UP); } + /** + * 柜内明细变更后,按最新有效柜内成员重新命中区域重量阶梯。 + * 仅使用当前费用快照冻结的选定重量、体积和测量来源,绝不回退到入库单实测值。 + */ + @Transactional + public ContainerFeeRecalculationResult recalculateUnlockedContainerCustomerFees( + long containerBookingId, long tenantId, String trigger) { + if (containerBookingId <= 0 || tenantId <= 0 || !StringUtils.hasText(trigger)) { + throw new BizException(ResultCode.BAD_REQUEST, "柜次、客户和重算触发标识必填"); + } + String normalizedTrigger = trigger.trim(); + String key = "CONTAINER:" + containerBookingId + ":" + normalizedTrigger; + if (key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "重算触发标识不能超过96字符"); + String requestHash = hash(CONTAINER_RECALCULATE_OPERATION, containerBookingId, normalizedTrigger); + InboundPricingIdempotencyEntity existing = repository.findIdempotency(tenantId, + AUTOMATIC_CONFIRM_OPERATOR_ID, CONTAINER_RECALCULATE_OPERATION, key); + if (existing != null) { + if (!requestHash.equals(existing.getRequestHash())) throw conflict("重算触发标识已用于不同柜次"); + if (!"COMPLETED".equals(existing.getStatus())) throw conflict("相同柜次费用重算正在处理中"); + return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.REPLAYED, 0); + } + + Map rows = new LinkedHashMap<>(); + for (var row : repository.containerTierMembersForUpdate(containerBookingId, tenantId)) { + rows.putIfAbsent(row.inboundId(), row); + } + LocalDateTime now = LocalDateTime.now(clock); + if (rows.isEmpty()) { + return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_READY, 0); + } + + List members = new ArrayList<>(); + boolean notReady = false; + for (var row : rows.values()) { + InboundPricingEntity inbound = repository.findInbound(tenantId, row.inboundId()); + if (inbound == null) throw conflict("柜内入库单已变化,请刷新后重试"); + InboundFeeEntity current = repository.findLatestFeeForUpdate(tenantId, row.inboundId()); + if (current == null) { + notReady = true; + continue; + } + if ("SENT".equals(current.getFeeStatus()) || current.getCustomerConfirmedAt() != null) { + throw conflict("柜内存在已发送或客户已确认费用,不能重算阶梯费用"); + } + if (!List.of("CALCULATED", "CONFIRMED").contains(current.getFeeStatus())) { + throw conflict("柜内费用状态已变化,不能重算阶梯费用"); + } + BigDecimal weight = requiredFrozenQuantity(current.getActualWeightKg(), "重量"); + BigDecimal volume = requiredFrozenQuantity(current.getActualVolumeCbm(), "体积"); + members.add(new ContainerFeeMember(inbound, current, + weight.setScale(3, RoundingMode.HALF_UP), volume.setScale(4, RoundingMode.HALF_UP), + chargeableWeight(weight, volume))); + } + if (notReady) return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_READY, 0); + InboundPricingIdempotencyEntity token = beginContainerRecalculation( + tenantId, containerBookingId, key, normalizedTrigger); + if ("COMPLETED".equals(token.getStatus())) { + return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.REPLAYED, 0); + } + BigDecimal aggregate = members.stream().map(ContainerFeeMember::chargeableQuantity) + .reduce(BigDecimal.ZERO, BigDecimal::add); + List allocationMembers = members.stream() + .map(member -> new ContainerTierAmountAllocator.Member(member.inbound().getId(), member.chargeableQuantity())) + .toList(); + List quotes = new ArrayList<>(); + for (ContainerFeeMember member : members) { + PricingPolicy policy = pricingPolicy(member.inbound(), now); + ActivePriceBookEstimator.Estimate estimate = priceBookEstimator.estimate(tenantId, new EstimateRequest( + member.inbound().getWarehouseId(), member.inbound().getChannelCode(), member.inbound().getDestinationCode(), + destinationType(member.inbound()), member.inbound().getDestinationWarehouseId(), + member.inbound().getDestinationName(), null, pricingTaxMode(member.inbound()), policy.chargeMode(), + aggregate, BigDecimal.ZERO)); + quotes.add(new ContainerFeeQuote(member, policy, estimate)); + } + boolean regionalTier = quotes.stream().anyMatch(quote -> quote.estimate().priceModel() == PriceModel.REGION_WEIGHT_TIER); + if (!regionalTier) { + complete(token, containerBookingId, now); + return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.NOT_APPLICABLE, 0); + } + if (quotes.stream().anyMatch(quote -> quote.estimate().priceModel() != PriceModel.REGION_WEIGHT_TIER)) { + throw conflict("柜内客户费用报价模型不一致,不能重算阶梯费用"); + } + for (ContainerFeeQuote quote : quotes) { + ContainerTierAmountAllocator.Allocation allocation = containerTierAllocator.allocate( + allocationMembers, quote.estimate().unitPrice()); + BigDecimal subtotal = allocation.memberAmounts().get(quote.member().inbound().getId()); + if (subtotal == null) throw conflict("柜级阶梯分摊结果不完整"); + persistContainerRecalculation(tenantId, quote, subtotal, now); + } + complete(token, containerBookingId, now); + return new ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus.RECALCULATED, quotes.size()); + } + + private void persistContainerRecalculation(long tenantId, ContainerFeeQuote quote, + BigDecimal subtotal, LocalDateTime now) { + ContainerFeeMember member = quote.member(); + ActivePriceBookEstimator.Estimate estimate = quote.estimate(); + RateRow rate = estimate.rate(); + int version = repository.nextFeeVersion(tenantId, member.inbound().getId()); + BigDecimal additional = rate.getAdditionalFee() == null ? BigDecimal.ZERO + : rate.getAdditionalFee().setScale(MONEY_SCALE, RoundingMode.HALF_UP); + InboundFeeEntity recalculated = snapshot(tenantId, AUTOMATIC_CONFIRM_OPERATOR_ID, member.inbound(), estimate, + rate, version, member.weight(), member.volume(), member.chargeableQuantity(), + member.chargeableQuantity(), subtotal.setScale(MONEY_SCALE, RoundingMode.HALF_UP), additional, + subtotal.add(additional).setScale(MONEY_SCALE, RoundingMode.HALF_UP), now); + PricingPolicy policy = quote.policy(); + if (policy.rule() != null) { + recalculated.setChargeRuleId(policy.rule().getId()); + recalculated.setChargeRuleVersion(policy.rule().getRuleVersion()); + recalculated.setRuleCode(policy.rule().getRuleCode()); + recalculated.setRuleEffectiveFrom(policy.rule().getEffectiveFrom()); + recalculated.setRuleEffectiveTo(policy.rule().getEffectiveTo()); + } + recalculated.setMeasurementId(member.current().getMeasurementId()); + recalculated.setMeasurementSource(member.current().getMeasurementSource()); + if (repository.supersedeCurrent(tenantId, member.current().getId(), member.current().getFeeStatus()) != 1) { + throw conflict("柜内费用已变化,不能重算阶梯费用"); + } + repository.insertFee(recalculated); + repository.insertItem(baseItem(tenantId, recalculated, rate, member.chargeableQuantity(), + recalculated.getSubtotalAmount(), now)); + if (additional.signum() > 0) repository.insertItem(additionalItem(tenantId, recalculated, additional, now)); + if (repository.markInboundContainerFeeRecalculated(tenantId, member.inbound().getId(), + member.inbound().getProcessStatus(), estimate.chargeUnit(), member.chargeableQuantity()) != 1) { + throw conflict("柜内入库单状态已变化,不能重算阶梯费用"); + } + feeDetails.materializeCalculatedFreight(tenantId, member.inbound().getId(), recalculated.getId(), + AUTOMATIC_CONFIRM_OPERATOR_ID, now); + } + + private InboundPricingIdempotencyEntity beginContainerRecalculation(long tenantId, long containerBookingId, + String key, String trigger) { + String requestHash = hash(CONTAINER_RECALCULATE_OPERATION, containerBookingId, trigger); + InboundPricingIdempotencyEntity existing = repository.findIdempotency(tenantId, + AUTOMATIC_CONFIRM_OPERATOR_ID, CONTAINER_RECALCULATE_OPERATION, key); + if (existing != null) { + if (!requestHash.equals(existing.getRequestHash())) throw conflict("重算触发标识已用于不同柜次"); + if (!"COMPLETED".equals(existing.getStatus())) throw conflict("相同柜次费用重算正在处理中"); + return existing; + } + InboundPricingIdempotencyEntity token = new InboundPricingIdempotencyEntity(); + token.setTenantId(tenantId); token.setOperation(CONTAINER_RECALCULATE_OPERATION); + token.setOperatorId(AUTOMATIC_CONFIRM_OPERATOR_ID); token.setInboundId(0L); + token.setIdempotencyKey(key); token.setRequestHash(requestHash); token.setStatus("PROCESSING"); + token.setCreatedAt(LocalDateTime.now(clock)); token.setUpdatedAt(LocalDateTime.now(clock)); + repository.insertIdempotency(token); + return token; + } + + private static BigDecimal requiredFrozenQuantity(BigDecimal value, String field) { + if (value == null || value.signum() < 0) throw conflict("费用快照缺少冻结" + field + ",不能重算阶梯费用"); + return value; + } + + private record ContainerFeeMember(InboundPricingEntity inbound, InboundFeeEntity current, + BigDecimal weight, BigDecimal volume, BigDecimal chargeableQuantity) { } + + private record ContainerFeeQuote(ContainerFeeMember member, PricingPolicy policy, + ActivePriceBookEstimator.Estimate estimate) { } + + public enum ContainerFeeRecalculationStatus { RECALCULATED, NOT_READY, NOT_APPLICABLE, REPLAYED } + + public record ContainerFeeRecalculationResult(ContainerFeeRecalculationStatus status, + int recalculatedCount) { } + @Transactional public FeeSnapshotResponse confirm(long tenantId, long operatorId, long inboundId, String idempotencyKey) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index c5e7d40c5..ce25a475a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -748,12 +748,129 @@ class FbaPricingServiceTest { void scopesContainerTierMembersByCustomerAndContainer() throws NoSuchMethodException { String sql = ContainerTierPricingMapper.class.getMethod("members", long.class, long.class) .getAnnotation(Select.class).value()[0]; + String lockedSql = ContainerTierPricingMapper.class.getMethod("membersForUpdate", long.class, long.class) + .getAnnotation(Select.class).value()[0]; assertThat(sql).contains("plan.container_booking_id=current_plan.container_booking_id") .contains("grouped_item.account_id=#{customerId}") .contains("grouped_inbound.tenant_id=#{customerId}") .doesNotContain("grouped_inbound.destination_code") .doesNotContain("grouped_inbound.destination_warehouse_id"); + assertThat(lockedSql).contains("plan.container_booking_id=#{containerBookingId}") + .contains("item.account_id=#{customerId}").contains("FOR UPDATE"); + } + + @Test + @DisplayName("柜内客户费用重算复用冻结选量和来源,并按统一阶梯分摊") + void recalculatesUnlockedContainerFeesFromFrozenSelections() { + readyRepository(); + InboundPricingEntity firstInbound = inbound("999", "9"); + InboundPricingEntity secondInbound = inbound("888", "8"); + secondInbound.setId(30002L); secondInbound.setInboundCode("RK-002"); + InboundFeeEntity first = savedFee("CALCULATED"); + first.setActualWeightKg(new BigDecimal("90.000")); first.setActualVolumeCbm(new BigDecimal("0.1000")); + first.setMeasurementId(301L); first.setMeasurementSource("OUTER"); + InboundFeeEntity second = savedFee("CONFIRMED"); + second.setId(502L); second.setInboundId(30002L); second.setActualWeightKg(new BigDecimal("110.000")); + second.setActualVolumeCbm(new BigDecimal("0.1000")); second.setMeasurementId(302L); + second.setMeasurementSource("ACTUAL"); + when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of( + memberRow(901L, INBOUND, "999", "9"), memberRow(901L, 30002L, "888", "8") + )); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(firstInbound); + when(repository.findInbound(TENANT, 30002L)).thenReturn(secondInbound); + when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(first); + when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(second); + when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2); + when(repository.nextFeeVersion(TENANT, 30002L)).thenReturn(2); + when(repository.completeIdempotency(any(), eq(901L), eq(NOW))).thenReturn(1); + when(repository.supersedeCurrent(eq(TENANT), anyLong(), anyString())).thenReturn(1); + when(repository.markInboundContainerFeeRecalculated(eq(TENANT), anyLong(), anyString(), anyString(), any())) + .thenReturn(1); + when(priceBookEstimator.estimate(eq(TENANT), any())).thenReturn( + regionEstimate("200", "8", "1600"), regionEstimate("200", "8", "1600")); + when(containerTierAllocator.allocate(any(), eq(new BigDecimal("8")))).thenReturn( + new ContainerTierAmountAllocator.Allocation(new BigDecimal("200"), new BigDecimal("1600"), + Map.of(INBOUND, new BigDecimal("720.00"), 30002L, new BigDecimal("880.00")))); + + service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-1"); + + ArgumentCaptor fees = ArgumentCaptor.forClass(InboundFeeEntity.class); + verify(repository, times(2)).insertFee(fees.capture()); + assertThat(fees.getAllValues().get(0).getActualWeightKg()).isEqualByComparingTo("90.000"); + assertThat(fees.getAllValues().get(0).getActualVolumeCbm()).isEqualByComparingTo("0.1000"); + assertThat(fees.getAllValues().get(0).getMeasurementId()).isEqualTo(301L); + assertThat(fees.getAllValues().get(0).getMeasurementSource()).isEqualTo("OUTER"); + assertThat(fees.getAllValues().get(0).getSubtotalAmount()).isEqualByComparingTo("720.00"); + assertThat(fees.getAllValues().get(1).getActualWeightKg()).isEqualByComparingTo("110.000"); + assertThat(fees.getAllValues().get(1).getMeasurementId()).isEqualTo(302L); + assertThat(fees.getAllValues().get(1).getMeasurementSource()).isEqualTo("ACTUAL"); + ArgumentCaptor requests = + ArgumentCaptor.forClass(com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest.class); + verify(priceBookEstimator, times(2)).estimate(eq(TENANT), requests.capture()); + assertThat(requests.getAllValues()).allSatisfy(request -> { + assertThat(request.actualWeightKg()).isEqualByComparingTo("200"); + assertThat(request.actualVolumeCbm()).isEqualByComparingTo(BigDecimal.ZERO); + }); + verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any()); + } + + @Test + @DisplayName("柜内任一已发送或客户确认费用阻断整组重算") + void rejectsContainerRecalculationWhenAnyFeeIsImmutable() { + readyRepository(); + InboundPricingEntity inbound = inbound("100", "0.1"); + InboundFeeEntity sent = savedFee("SENT"); + when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of( + memberRow(901L, INBOUND, "100", "0.1") + )); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound); + when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(sent); + + assertThatThrownBy(() -> service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "loading-change-2")) + .isInstanceOf(BizException.class).hasMessageContaining("已发送或客户已确认"); + + verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), anyString()); + verify(repository, never()).insertFee(any()); + verify(priceBookEstimator, never()).estimate(anyLong(), any()); + } + + @Test + @DisplayName("待审核或待测量成员无初算费用时保持占位并返回未就绪") + void keepsContainerRelationshipWhenAnyMemberFeeIsNotReady() { + readyRepository(); + InboundPricingEntity pricedInbound = inbound("100", "0.1"); + InboundPricingEntity pendingInbound = inbound("80", "0.1"); + pendingInbound.setId(30002L); pendingInbound.setInboundCode("RK-PENDING"); + InboundFeeEntity priced = savedFee("CALCULATED"); + priced.setActualWeightKg(new BigDecimal("100")); priced.setActualVolumeCbm(new BigDecimal("0.1")); + when(repository.containerTierMembersForUpdate(901L, TENANT)).thenReturn(List.of( + memberRow(901L, INBOUND, "100", "0.1"), memberRow(901L, 30002L, "80", "0.1") + )); + when(repository.findInbound(TENANT, INBOUND)).thenReturn(pricedInbound); + when(repository.findInbound(TENANT, 30002L)).thenReturn(pendingInbound); + when(repository.findLatestFeeForUpdate(TENANT, INBOUND)).thenReturn(priced); + when(repository.findLatestFeeForUpdate(TENANT, 30002L)).thenReturn(null); + + var result = service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "pending-member"); + + assertThat(result.status()).isEqualTo(FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY); + assertThat(result.recalculatedCount()).isZero(); + verify(repository, never()).insertFee(any()); + verify(priceBookEstimator, never()).estimate(anyLong(), any()); + } + + @Test + @DisplayName("相同柜次重算触发标识重放不重复创建费用版本") + void replaysCompletedContainerRecalculationTrigger() { + InboundPricingIdempotencyEntity completed = completedContainerRecalculation(901L, "same-trigger"); + when(repository.findIdempotency(TENANT, 0L, "CONTAINER_FEE_RECALCULATE", "CONTAINER:901:same-trigger")) + .thenReturn(completed); + + service.recalculateUnlockedContainerCustomerFees(901L, TENANT, "same-trigger"); + + verify(repository, never()).containerTierMembersForUpdate(anyLong(), anyLong()); + verify(repository, never()).insertFee(any()); } private void readyRepository() { @@ -834,6 +951,20 @@ class FbaPricingServiceTest { return value; } + private InboundPricingIdempotencyEntity completedContainerRecalculation(long containerBookingId, String trigger) { + InboundPricingIdempotencyEntity value = new InboundPricingIdempotencyEntity(); + value.setId(900L); value.setOperation("CONTAINER_FEE_RECALCULATE"); value.setStatus("COMPLETED"); + value.setResultResourceId(containerBookingId); + try { + value.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(("CONTAINER_FEE_RECALCULATE:" + containerBookingId + ':' + trigger) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new AssertionError(exception); + } + return value; + } + private InboundFeeEntity savedFee(String status) { InboundFeeEntity value = new InboundFeeEntity(); value.setId(501L); value.setTenantId(TENANT); value.setInboundId(INBOUND); value.setFeeNo("FBAF-30001-1"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java index 416d19088..d12c0f192 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java @@ -12,6 +12,7 @@ import com.baiyue.lastleg.delivery.entity.DeliveryOperation; import com.baiyue.lastleg.delivery.entity.DeliveryOrder; import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy; import com.baiyue.lastleg.entity.LastlegInboundOrder; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; @@ -82,10 +83,8 @@ public class CustomerVisitService { public CustomerVisitResponses.Completed completeDelivery(String customerId, String operatorId, String deliveryNo) { DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); if (delivery == null) throw notFound("派送单不存在"); - if (Set.of("RETURN_PENDING", "REJECTED", "CANCELLED").contains(delivery.getStatus())) { - throw conflict("当前派送状态不能标记为派送完成"); - } boolean replayed = "DELIVERED".equals(delivery.getStatus()); + if (!replayed) requireDeliveryTransition(delivery.getStatus(), "DELIVERED"); LocalDateTime now = replayed && delivery.getSignedAt() != null ? delivery.getSignedAt() : LocalDateTime.now(); if (!replayed) { delivery.setStatus("DELIVERED"); @@ -116,18 +115,18 @@ public class CustomerVisitService { String deliveryNo, CustomerVisitRequests.DeliveryStatus request) { String target = trim(request.status()).toUpperCase(Locale.ROOT); - if (!Set.of("WAIT_DELIVERY", "DELIVERING", "DELIVERED", "REJECTED", "CANCELLED").contains(target)) { + if (!DeliveryStatusPolicy.isEditableTarget(target)) { throw badRequest("不支持的派送状态"); } if ("DELIVERED".equals(target)) return completeDelivery(customerId, operatorId, deliveryNo); DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); if (delivery == null) throw notFound("派送单不存在"); - if ("RETURN_PENDING".equals(delivery.getStatus())) throw conflict("退货处理中的派送单不能直接修改状态"); if (target.equals(delivery.getStatus())) { CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId()); return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo, delivery.getStatus(), existing == null ? null : existing.getStatus(), true); } + requireDeliveryTransition(delivery.getStatus(), target); String from = delivery.getStatus(); LocalDateTime now = LocalDateTime.now(); delivery.setStatus(target); @@ -309,6 +308,11 @@ public class CustomerVisitService { } private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static void requireDeliveryTransition(String from, String target) { + if (!DeliveryStatusPolicy.canTransition(from, target)) { + throw conflict("派送状态不允许由 " + from + " 变更为 " + target); + } + } private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index d690de932..77deb14da 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -42,6 +42,15 @@ public class DeliveryTransferController { return ApiResponse.success(service.createOneClick(requiredCustomer(user, customerId), operator(user), request)); } + @GetMapping("/outbound-orders/one-click-dropship/candidates") + @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')") + @Operation(summary = "List server-authoritative one-click dropship outbound candidates") + public ApiResponse> oneClickCandidates( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @RequestParam(required = false) String warehouseId) { + return ApiResponse.success(service.oneClickCandidates(requiredCustomer(user, customerId), warehouseId)); + } + @GetMapping("/outbound-orders") @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')") public ApiResponse> outboundPage( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index eabea2350..2d1e51749 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -24,7 +24,7 @@ public final class DeliveryResponses { int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt, LocalDateTime expectedArrivalAt, LocalDateTime signedAt, - String remark, LocalDateTime createdAt) {} + String remark, LocalDateTime createdAt, List allowedActions) {} public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode, String receivingAddress, String shipmentNo, String fbaTrackingNo, @@ -44,6 +44,9 @@ public final class DeliveryResponses { public record OutboundPalletRow(String palletNo, String inboundCode, String destinationWarehouseCode, String destinationWarehouseName, String skuCode, int packageCount) {} public record OutboundDetail(OutboundRow outbound, List pallets, DeliveryRow delivery) {} + public record OneClickCandidate(Long inventoryPositionId, String overseasWarehouseId, + String overseasWarehouseName, String palletNo, String inboundCode, + String skuCode, String productName, long availablePackages) {} public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {} public record PdaTaskResolution(String workflow, String taskNo, String outboundNo, String dispatchNo) {} public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java new file mode 100644 index 000000000..b1b363525 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.delivery.model; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class DeliveryStatusPolicy { + private static final Set EDITABLE_TARGETS = Set.of( + "WAIT_DELIVERY", "DELIVERING", "DELIVERED", "REJECTED", "CANCELLED"); + private static final Map> TRANSITIONS = Map.of( + "WAIT_DELIVERY", Set.of("DELIVERING", "DELIVERED", "REJECTED", "CANCELLED"), + "DELIVERING", Set.of("DELIVERED", "REJECTED"), + "DELIVERED", Set.of(), + "REJECTED", Set.of(), + "RETURN_PENDING", Set.of(), + "CANCELLED", Set.of()); + + private DeliveryStatusPolicy() { } + + public static boolean isEditableTarget(String status) { + return EDITABLE_TARGETS.contains(status); + } + + public static boolean canTransition(String from, String target) { + return TRANSITIONS.getOrDefault(from, Set.of()).contains(target); + } + + public static List allowedActions(String status) { + return switch (status == null ? "" : status) { + case "WAIT_DELIVERY" -> List.of( + "START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY", "MARK_RETURN"); + case "DELIVERING" -> List.of("COMPLETE_DELIVERY", "REJECT_DELIVERY", "MARK_RETURN"); + case "DELIVERED", "REJECTED" -> List.of("MARK_RETURN"); + default -> List.of(); + }; + } +} 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 22545a256..a1d939a9e 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 @@ -12,6 +12,7 @@ import com.baiyue.lastleg.delivery.entity.DeliveryOperation; import com.baiyue.lastleg.delivery.entity.OutboundOrder; import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy; import com.baiyue.lastleg.delivery.mapper.DeliveryFeeMapper; import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; @@ -39,6 +40,7 @@ import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.model.DropshipOutboundCandidate; import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; @@ -240,32 +242,41 @@ public class DeliveryTransferService { return taskResult(outbound, task); } + public List oneClickCandidates(String customerId, String warehouseId) { + return dropshipProfiles.listOutboundCandidates(required(customerId, "Customer is required"), + StringUtils.hasText(warehouseId) ? warehouseId.trim() : null).stream() + .map(this::oneClickCandidate).toList(); + } + private String validateOneClickItems(String customerId, List items) { String warehouseId = null; Map requestedPackages = new LinkedHashMap<>(); - Map profiles = new HashMap<>(); + Map candidates = new HashMap<>(); for (DeliveryRequests.OneClickItem input : items) { - InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId()); - if (position == null || !customerId.equals(position.getCustomerId())) throw notFound("Inventory position not found"); - DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, position.getId()); - if (profile == null) throw conflict("Selected pallet is not one-click dropship inventory"); - if (warehouseId == null) warehouseId = position.getOverseasWarehouseId(); - else if (!Objects.equals(warehouseId, position.getOverseasWarehouseId())) { - throw conflict("One outbound order must use inventory from the same overseas warehouse"); + String sku = input.sku().trim(); + DropshipOutboundCandidate candidate = dropshipProfiles.lockOutboundCandidate(customerId, + input.inventoryPositionId(), sku); + if (candidate == null) { + throw conflict("所选库存不是可用的一件代发库存,或已被其他作业占用"); } - if (value(position.getAvailablePackages()) < input.packages()) { - throw conflict("Insufficient available inventory for pallet " + position.getPalletNo()); + if (warehouseId == null) warehouseId = candidate.getOverseasWarehouseId(); + else if (!Objects.equals(warehouseId, candidate.getOverseasWarehouseId())) { + throw conflict("一张出库单只能选择同一海外仓的库存"); } - String key = profile.getId() + "\u0000" + input.sku().trim(); + String key = candidate.getProfileId() + "\u0000" + sku; requestedPackages.merge(key, input.packages(), Long::sum); - profiles.put(key, profile); + candidates.put(key, candidate); } for (Map.Entry requested : requestedPackages.entrySet()) { String sku = requested.getKey().substring(requested.getKey().indexOf('\u0000') + 1); + DropshipOutboundCandidate candidate = candidates.get(requested.getKey()); + if (requested.getValue() > value(candidate.getAvailablePackages())) { + throw conflict("一件代发可用箱数不足:" + candidate.getPalletNo() + " / " + sku); + } List cartons = dropshipCartons.lockAvailable(customerId, - profiles.get(requested.getKey()).getId(), sku, requested.getValue()); + candidate.getProfileId(), sku, requested.getValue()); if (cartons.size() != requested.getValue()) { - throw conflict("Insufficient available one-click dropship cartons for SKU " + sku); + throw conflict("一件代发可用箱数不足:" + candidate.getPalletNo() + " / " + sku); } } return warehouseId; @@ -597,7 +608,13 @@ public class DeliveryTransferService { item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getSkuCode(), value(item.getPackageCount())); } - private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt()); } + private DeliveryResponses.OneClickCandidate oneClickCandidate(DropshipOutboundCandidate candidate) { + return new DeliveryResponses.OneClickCandidate(candidate.getInventoryPositionId(), + candidate.getOverseasWarehouseId(), candidate.getOverseasWarehouseName(), candidate.getPalletNo(), + candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(), + value(candidate.getAvailablePackages())); + } + private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), DeliveryStatusPolicy.allowedActions(d.getStatus())); } private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java index cc99b10ff..e9e623908 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java @@ -2,13 +2,53 @@ package com.baiyue.lastleg.dropship.mapper; import org.apache.ibatis.annotations.Mapper; import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baiyue.lastleg.dropship.model.DropshipOutboundCandidate; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.*; import java.util.List; @Mapper public interface DropshipOrderProfileMapper extends BaseMapper { + String OUTBOUND_CANDIDATE_SELECT = "SELECT pos.id inventory_position_id,p.id profile_id," + + "p.warehouse_pallet_id,pos.overseas_warehouse_id,b.overseas_warehouse_name," + + "p.pallet_no,b.inbound_code,s.sku_code,s.product_name," + + "LEAST(pos.available_packages,s.package_count,(SELECT COUNT(*) FROM ll_dropship_carton c " + + "WHERE c.customer_id=p.customer_id AND c.profile_id=p.id AND c.sku_code=s.sku_code " + + "AND c.status='AVAILABLE')) available_packages FROM ll_dropship_order_profile p " + + "JOIN ll_order o ON o.id=p.order_id AND o.customer_id=p.customer_id " + + "AND o.order_type='ONE_CLICK_DROPSHIP' AND COALESCE(o.detained,0)=0 " + + "JOIN ll_inventory_position pos ON pos.id=p.inventory_position_id AND pos.customer_id=p.customer_id " + + "AND pos.pallet_id=p.warehouse_pallet_id " + + "JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " + + "JOIN ll_dropship_sku_inventory s ON s.customer_id=p.customer_id AND s.order_id=p.order_id " + + "AND s.profile_id=p.id "; + String OUTBOUND_CANDIDATE_WHERE = "WHERE p.customer_id=#{customerId} AND p.status='ACTIVE' " + + "AND pos.status='OCCUPIED' AND pos.available_packages>0 AND s.package_count>0 " + + "AND EXISTS (SELECT 1 FROM ll_dropship_carton c WHERE c.customer_id=p.customer_id " + + "AND c.profile_id=p.id AND c.sku_code=s.sku_code AND c.status='AVAILABLE') " + + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock dl WHERE dl.customer_id=p.customer_id " + + "AND dl.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id) AND dl.status='ACTIVE' " + + "AND (dl.expires_at IS NULL OR dl.expires_at>CURRENT_TIMESTAMP(3))) " + + "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + + "WHERE ta.customer_id=p.customer_id AND ta.inventory_position_id=pos.id AND ta.status='RESERVED') " + + "AND NOT EXISTS (SELECT 1 FROM ll_outbound_pallet_item oi JOIN ll_outbound_order oo " + + "ON oo.id=oi.outbound_order_id AND oo.customer_id=oi.customer_id " + + "WHERE oi.customer_id=p.customer_id AND oi.warehouse_pallet_id=p.warehouse_pallet_id " + + "AND oi.sku_code=s.sku_code AND oo.status IN ('WAIT_PICK','PICKING','PICKED')) "; + @Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY source_pallet_no") List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); @Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId} LIMIT 1") DropshipOrderProfile findPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId); + + @Select("") + List listOutboundCandidates(@Param("customerId") String customerId, + @Param("warehouseId") String warehouseId); + + @Select(OUTBOUND_CANDIDATE_SELECT + OUTBOUND_CANDIDATE_WHERE + + "AND pos.id=#{positionId} AND s.sku_code=#{skuCode} LIMIT 1 FOR UPDATE") + DropshipOutboundCandidate lockOutboundCandidate(@Param("customerId") String customerId, + @Param("positionId") Long positionId, + @Param("skuCode") String skuCode); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipOutboundCandidate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipOutboundCandidate.java new file mode 100644 index 000000000..b5de0b755 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipOutboundCandidate.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.dropship.model; + +import lombok.Data; + +/** Server-authoritative one-click dropship inventory dimension eligible for outbound creation. */ +@Data +public class DropshipOutboundCandidate { + private Long inventoryPositionId; + private Long profileId; + private Long warehousePalletId; + private String overseasWarehouseId; + private String overseasWarehouseName; + private String palletNo; + private String inboundCode; + private String skuCode; + private String productName; + private Long availablePackages; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java index d96198178..9159b5fa4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java @@ -45,6 +45,38 @@ public class ExceptionOrderController { return ApiResponse.success(service.create(scope(user), operatorId(user), operatorName(user), request)); } + @GetMapping("/exception-orders/{exceptionNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_LIST + "')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable String exceptionNo) { + return ApiResponse.success(service.detail(requiredScope(user, customerId), exceptionNo)); + } + + @PostMapping("/exception-orders/{exceptionNo}/start-processing") + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')") + public ApiResponse startProcessing( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String exceptionNo, @Valid @RequestBody ExceptionOrderRequests.StartProcessing request) { + return ApiResponse.success(service.startProcessing(requiredScope(user, customerId), operatorId(user), exceptionNo, request)); + } + + @PostMapping("/exception-orders/{exceptionNo}/complete") + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')") + public ApiResponse complete( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String exceptionNo, @Valid @RequestBody ExceptionOrderRequests.Complete request) { + return ApiResponse.success(service.complete(requiredScope(user, customerId), operatorId(user), exceptionNo, request)); + } + + @PostMapping("/exception-orders/{exceptionNo}/cancel") + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')") + public ApiResponse cancel( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String exceptionNo, @Valid @RequestBody ExceptionOrderRequests.Cancel request) { + return ApiResponse.success(service.cancel(requiredScope(user, customerId), operatorId(user), exceptionNo, request)); + } + @GetMapping("/delivery-orders/{deliveryNo}/return-inbound-options") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_LIST + "')") public ApiResponse> returnOptions(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java index 8af47a000..662e3702a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java @@ -7,6 +7,7 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.time.LocalDateTime; +import java.util.List; public final class ExceptionOrderRequests { private ExceptionOrderRequests() {} @@ -29,6 +30,32 @@ public final class ExceptionOrderRequests { @NotBlank @Size(max = 1000) String reason, @NotBlank @Size(max = 32) String returnDirection, @Size(max = 128) String returnLogisticsNo, - @Min(1) Integer packageCount, - @Size(max = 128) String skuCode) {} + @NotNull @Size(min = 1, max = 200) List items, + @NotBlank @Size(max = 255) String idempotencyKey) {} + + public record ReturnItem(@NotNull Long outboundPalletItemId, + @NotNull Long inventoryPositionId, + @Min(1) long packageCount) {} + + public record StartProcessing(@Min(0) long expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String handlingAction) {} + + public record Complete(@Min(0) long expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey, + @NotBlank @Size(max = 2000) String handlingResult, + @NotNull @Size(min = 1, max = 20) List evidenceFileIds, + @Size(max = 200) List receipts) {} + + public record ReturnReceipt(@NotNull Long outboundPalletItemId, + @NotNull Long inventoryPositionId, + @Min(1) long receivedPackages, + @NotNull Long targetPalletId, + @NotBlank @Size(max = 128) String targetPalletNo, + @NotNull Long targetLocationId, + @NotBlank @Size(max = 128) String targetLocationCode) {} + + public record Cancel(@Min(0) long expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey, + @NotBlank @Size(max = 1000) String reason) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java index 5847820c8..4e3976701 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.exceptionorder.dto; import java.time.LocalDateTime; +import java.util.List; public final class ExceptionOrderResponses { private ExceptionOrderResponses() {} @@ -13,13 +14,22 @@ public final class ExceptionOrderResponses { Long deliveryOrderId, String deliveryNo, String outboundNo, String skuCode, Integer packageCount, String returnDirection, String returnLogisticsNo, String handlingAction, - String handlerName, LocalDateTime exceptionAt, LocalDateTime updatedAt) {} + String handlingResult, String handlerName, LocalDateTime exceptionAt, + LocalDateTime completedAt, LocalDateTime updatedAt, long version, + List evidenceFileIds, List allowedActions) {} public record Relation(Long lastMileOrderId, String lastMileOrderNo, Long inboundOrderId, String inboundCode, String customerId, String customerName, String overseasWarehouseId, String overseasWarehouseName, - String deliveryNo, String outboundNo, Integer availablePackages) {} + String deliveryNo, String outboundNo, Integer availablePackages, + List returnSources) {} - public record Created(Long id, String exceptionNo, String status) {} + public record ReturnSource(Long outboundPalletItemId, Long inventoryPositionId, + String palletNo, String skuCode, long maxReturnPackages) {} + public record Detail(Row order, List returnSources) {} + public record CommandResult(Long id, String exceptionNo, String status, long version, + Long inventoryAdjustmentId, long restoredPackages, + LocalDateTime completedAt, boolean replayed) {} + public record Created(Long id, String exceptionNo, String status, long version) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java index b60908bb5..653b11372 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java @@ -32,11 +32,16 @@ public class ExceptionOrder { private String returnDirection; private String returnLogisticsNo; private String handlingAction; + private String handlingResult; + private String evidenceFileIdsJson; + private Long inventoryAdjustmentId; private String handlerId; private String handlerName; private String sourceType; private String createdBy; private LocalDateTime exceptionAt; + private LocalDateTime completedAt; + private Long version; private LocalDateTime createdAt; private LocalDateTime updatedAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrderCommand.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrderCommand.java new file mode 100644 index 000000000..e32ccdecc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrderCommand.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.exceptionorder.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_exception_order_command") +public class ExceptionOrderCommand { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long exceptionOrderId; + private String action; + private String idempotencyKey; + private String requestHash; + private String resultJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java new file mode 100644 index 000000000..24bf07152 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.exceptionorder.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_exception_return_item") +public class ExceptionReturnItem { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long exceptionOrderId; + private Long outboundPalletItemId; + private Long inventoryPositionId; + private String palletNo; + private String skuCode; + private Long maxReturnPackages; + private Long approvedReturnPackages; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderCommandMapper.java new file mode 100644 index 000000000..7ab0e6b3d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderCommandMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.exceptionorder.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ExceptionOrderCommandMapper extends BaseMapper { + @Select("SELECT * FROM ll_exception_order_command WHERE customer_id=#{customerId} AND action=#{action} AND idempotency_key=#{key} LIMIT 1") + ExceptionOrderCommand findByKey(@Param("customerId") String customerId, + @Param("action") String action, + @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java index 39c25ca45..901c5e908 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java @@ -5,6 +5,7 @@ import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; @Mapper public interface ExceptionOrderMapper extends BaseMapper { @@ -12,4 +13,10 @@ public interface ExceptionOrderMapper extends BaseMapper { "AND exception_type='RETURN' LIMIT 1") ExceptionOrder findDeliveryReturn(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId); + + @Select("SELECT * FROM ll_exception_order WHERE customer_id=#{customerId} AND exception_no=#{exceptionNo} LIMIT 1 FOR UPDATE") + ExceptionOrder lockByNo(@Param("customerId") String customerId, @Param("exceptionNo") String exceptionNo); + + @Select("SELECT * FROM ll_exception_order WHERE customer_id=#{customerId} AND exception_no=#{exceptionNo} LIMIT 1") + ExceptionOrder findByNo(@Param("customerId") String customerId, @Param("exceptionNo") String exceptionNo); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java new file mode 100644 index 000000000..0b29a66e7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.exceptionorder.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface ExceptionReturnItemMapper extends BaseMapper { + @Select("SELECT * FROM ll_exception_return_item WHERE customer_id=#{customerId} AND exception_order_id=#{exceptionOrderId} ORDER BY id") + List findByException(@Param("customerId") String customerId, + @Param("exceptionOrderId") Long exceptionOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index b4a8c8469..dd7398779 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -8,15 +8,31 @@ import com.baiyue.lastleg.delivery.entity.DeliveryOperation; import com.baiyue.lastleg.delivery.entity.DeliveryOrder; import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.entity.OutboundPdaPick; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper; import com.baiyue.lastleg.entity.LastlegInboundOrder; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests; import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses; import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderCommandMapper; import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper; +import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest; +import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import jakarta.annotation.Resource; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -26,8 +42,16 @@ import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; @Service public class ExceptionOrderService { @@ -37,6 +61,13 @@ public class ExceptionOrderService { @Resource private LastlegInboundOrderMapper inboundOrders; @Resource private DeliveryOrderMapper deliveryOrders; @Resource private DeliveryOperationMapper deliveryOperations; + @Resource private OutboundOrderMapper outboundOrders; + @Resource private OutboundPalletItemMapper outboundPalletItems; + @Resource private OutboundPdaPickMapper outboundPdaPicks; + @Resource private ExceptionReturnItemMapper returnItems; + @Resource private ExceptionOrderCommandMapper commands; + @Resource private InventoryCommandService inventoryCommands; + @Resource private ObjectMapper objectMapper; public PageResult page(String scopedCustomerId, ExceptionOrderRequests.Query query) { String customerId = customerScope(scopedCustomerId, query.customerId()); @@ -65,6 +96,12 @@ public class ExceptionOrderService { return relation(scopedCustomerId, requestedCustomerId, inboundCode, lastMileOrderNo); } + public ExceptionOrderResponses.Detail detail(String customerId, String exceptionNo) { + ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo); + if (order == null) throw notFound("异常订单不存在"); + return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order)); + } + public List returnCandidates(String customerId, String deliveryNo) { DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); if (delivery == null) throw notFound("派送单不存在"); @@ -72,7 +109,8 @@ public class ExceptionOrderService { .eq(LastlegOrder::getCustomerId, customerId) .eq(LastlegOrder::getDeliveryOrderNo, deliveryNo) .orderByAsc(LastlegOrder::getOrderNo)).stream() - .map(order -> relation(customerId, customerId, order.getInboundCode(), order.getOrderNo())) + .map(order -> withReturnSources( + relation(customerId, customerId, order.getInboundCode(), order.getOrderNo()), delivery)) .distinct().toList(); } @@ -88,19 +126,40 @@ public class ExceptionOrderService { @Transactional public ExceptionOrderResponses.Created markReturn(String customerId, String operatorId, String operatorName, String deliveryNo, ExceptionOrderRequests.MarkReturn request) { + String requestHash = hash(request); + ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim()); + if (replay != null) return replayCreated(replay, requestHash); DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); if (delivery == null) throw notFound("派送单不存在"); ExceptionOrder existing = exceptionOrders.findDeliveryReturn(customerId, delivery.getId()); - if (existing != null) return new ExceptionOrderResponses.Created(existing.getId(), existing.getExceptionNo(), existing.getStatus()); + if (existing != null) throw conflict("该派送单已存在退回异常单"); List candidates = returnCandidates(customerId, deliveryNo); ExceptionOrderResponses.Relation selected = candidates.stream() .filter(candidate -> Objects.equals(candidate.inboundOrderId(), request.inboundOrderId())) .findFirst().orElseThrow(() -> conflict("所选入仓单不属于该派送单")); + List available = selected.returnSources(); + List requested = request.items(); + validateReturnItems(available, requested); + int packages = Math.toIntExact(requested.stream().mapToLong(ExceptionOrderRequests.ReturnItem::packageCount).sum()); + Set skus = available.stream().filter(source -> requested.stream().anyMatch(item -> sameSource(source, item))) + .map(ExceptionOrderResponses.ReturnSource::skuCode).filter(StringUtils::hasText).collect(java.util.stream.Collectors.toSet()); + String sku = skus.size() == 1 ? skus.iterator().next() : "MIXED"; ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(), - request.packageCount(), request.skuCode(), request.returnDirection(), request.returnLogisticsNo(), + packages, sku, request.returnDirection(), request.returnLogisticsNo(), "DELIVERY_RETURN", operatorId, operatorName); + for (ExceptionOrderRequests.ReturnItem requestedItem : requested) { + ExceptionOrderResponses.ReturnSource source = available.stream() + .filter(candidate -> sameSource(candidate, requestedItem)).findFirst().orElseThrow(); + ExceptionReturnItem item = new ExceptionReturnItem(); + item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setExceptionOrderId(created.id()); + item.setOutboundPalletItemId(source.outboundPalletItemId()); item.setInventoryPositionId(source.inventoryPositionId()); + item.setPalletNo(source.palletNo()); item.setSkuCode(source.skuCode()); + item.setMaxReturnPackages(source.maxReturnPackages()); item.setApprovedReturnPackages(requestedItem.packageCount()); + item.setCreatedAt(LocalDateTime.now()); returnItems.insert(item); + } + LocalDateTime now = LocalDateTime.now(); delivery.setStatus("RETURN_PENDING"); delivery.setUpdatedAt(now); @@ -117,9 +176,84 @@ public class ExceptionOrderService { operation.setOperationNode("MARK_RETURN"); operation.setOperatorId(operatorId); operation.setOperatorName(operatorName); operation.setResult("已生成异常退货单 " + created.exceptionNo()); operation.setCreatedAt(now); deliveryOperations.insert(operation); + recordCommand(customerId, created.id(), "MARK_RETURN", request.idempotencyKey(), requestHash, created); return created; } + @Transactional + public ExceptionOrderResponses.CommandResult startProcessing(String customerId, String operatorId, + String exceptionNo, + ExceptionOrderRequests.StartProcessing request) { + String requestHash = hash(request); + ExceptionOrderCommand replay = commands.findByKey(customerId, "START_PROCESSING", request.idempotencyKey().trim()); + if (replay != null) return replayResult(replay, requestHash); + ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PENDING"); + LocalDateTime now = LocalDateTime.now(); + order.setStatus("PROCESSING"); order.setHandlingAction(trim(request.handlingAction())); + order.setHandlerId(operatorId); order.setUpdatedAt(now); order.setVersion(version(order) + 1); + exceptionOrders.updateById(order); + ExceptionOrderResponses.CommandResult result = commandResult(order, 0, false); + recordCommand(customerId, order.getId(), "START_PROCESSING", request.idempotencyKey(), requestHash, result); + return result; + } + + @Transactional + public ExceptionOrderResponses.CommandResult cancel(String customerId, String operatorId, + String exceptionNo, + ExceptionOrderRequests.Cancel request) { + String requestHash = hash(request); + ExceptionOrderCommand replay = commands.findByKey(customerId, "CANCEL", request.idempotencyKey().trim()); + if (replay != null) return replayResult(replay, requestHash); + ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PENDING"); + LocalDateTime now = LocalDateTime.now(); + order.setStatus("CANCELLED"); order.setHandlingResult(trim(request.reason())); + order.setHandlerId(operatorId); order.setUpdatedAt(now); order.setVersion(version(order) + 1); + exceptionOrders.updateById(order); + ExceptionOrderResponses.CommandResult result = commandResult(order, 0, false); + recordCommand(customerId, order.getId(), "CANCEL", request.idempotencyKey(), requestHash, result); + return result; + } + + @Transactional + public ExceptionOrderResponses.CommandResult complete(String customerId, String operatorId, + String exceptionNo, + ExceptionOrderRequests.Complete request) { + String requestHash = hash(request); + ExceptionOrderCommand replay = commands.findByKey(customerId, "COMPLETE", request.idempotencyKey().trim()); + if (replay != null) return replayResult(replay, requestHash); + ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PROCESSING"); + ReturnedInventoryRestoreResponse restored = null; + if ("RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) { + List stored = returnItems.findByException(customerId, order.getId()); + if (stored.isEmpty()) throw conflict("退回异常单缺少已冻结的原出库库存明细"); + if (request.receipts() == null || request.receipts().isEmpty()) throw badRequest("请填写退回实收明细"); + restored = inventoryCommands.restoreReturnedInventory(customerId, operatorId, + new ReturnedInventoryRestoreRequest(order.getExceptionNo(), order.getOutboundNo(), + order.getOverseasWarehouseId(), order.getInboundOrderId(), + stored.stream().map(item -> new ReturnedInventoryRestoreRequest.SourceItem( + item.getOutboundPalletItemId(), item.getInventoryPositionId(), item.getSkuCode(), + Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList(), + request.receipts().stream().map(receipt -> new ReturnedInventoryRestoreRequest.Receipt( + receipt.outboundPalletItemId(), receipt.inventoryPositionId(), receipt.receivedPackages(), + receipt.targetPalletId(), receipt.targetPalletNo(), receipt.targetLocationId(), + receipt.targetLocationCode())).toList(), request.idempotencyKey().trim(), + request.evidenceFileIds())); + } else if (request.receipts() != null && !request.receipts().isEmpty()) { + throw badRequest("非退回海外仓异常不能提交库存实收明细"); + } + LocalDateTime now = restored == null ? LocalDateTime.now() : restored.completedAt(); + order.setStatus("COMPLETED"); order.setHandlingResult(trim(request.handlingResult())); + order.setEvidenceFileIdsJson(write(request.evidenceFileIds())); + order.setInventoryAdjustmentId(restored == null ? null : restored.adjustmentId()); + order.setHandlerId(operatorId); order.setCompletedAt(now); order.setUpdatedAt(now); + order.setVersion(version(order) + 1); exceptionOrders.updateById(order); + closeReturnedDelivery(order, operatorId, now); + long restoredPackages = restored == null ? 0 : restored.restoredPackages(); + ExceptionOrderResponses.CommandResult result = commandResult(order, restoredPackages, false); + recordCommand(customerId, order.getId(), "COMPLETE", request.idempotencyKey(), requestHash, result); + return result; + } + private ExceptionOrderResponses.Relation relation(String scopedCustomerId, String requestedCustomerId, String inboundCode, String lastMileOrderNo) { String customerId = customerScope(scopedCustomerId, requestedCustomerId); @@ -150,7 +284,7 @@ public class ExceptionOrderService { resolvedCustomer, tail == null ? null : tail.getCustomerName(), tail == null ? null : tail.getOverseasWarehouseId(), tail == null ? null : tail.getOverseasWarehouseName(), tail == null ? null : tail.getDeliveryOrderNo(), tail == null ? null : tail.getOutboundOrderNo(), - tail == null ? inboundOrder.getPackageCount() : tail.getPackageCount()); + tail == null ? inboundOrder.getPackageCount() : tail.getPackageCount(), List.of()); } private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery, @@ -180,10 +314,11 @@ public class ExceptionOrderService { value.setHandlerId(operatorId); value.setHandlerName(operatorName); } value.setSourceType(sourceType); value.setCreatedBy(operatorId); + value.setVersion(0L); value.setExceptionAt(now); value.setCreatedAt(now); value.setUpdatedAt(now); try { exceptionOrders.insert(value); } catch (DuplicateKeyException exception) { throw conflict("该关联单据已存在异常退货单"); } - return new ExceptionOrderResponses.Created(value.getId(), value.getExceptionNo(), value.getStatus()); + return new ExceptionOrderResponses.Created(value.getId(), value.getExceptionNo(), value.getStatus(), version(value)); } private LastlegOrder uniqueTail(String customerId, String orderNo, String inboundCode) { @@ -227,8 +362,159 @@ public class ExceptionOrderService { value.getOverseasWarehouseName(), value.getLastMileOrderId(), value.getLastMileOrderNo(), value.getInboundOrderId(), value.getInboundCode(), value.getDeliveryOrderId(), value.getDeliveryNo(), value.getOutboundNo(), value.getSkuCode(), value.getPackageCount(), value.getReturnDirection(), - value.getReturnLogisticsNo(), value.getHandlingAction(), value.getHandlerName(), - value.getExceptionAt(), value.getUpdatedAt()); + value.getReturnLogisticsNo(), value.getHandlingAction(), value.getHandlingResult(), value.getHandlerName(), + value.getExceptionAt(), value.getCompletedAt(), value.getUpdatedAt(), version(value), + readStringList(value.getEvidenceFileIdsJson()), allowedActions(value)); + } + + private ExceptionOrderResponses.Relation withReturnSources(ExceptionOrderResponses.Relation relation, + DeliveryOrder delivery) { + List sources = returnSources(delivery).stream() + .filter(source -> relation.inboundCode() == null || Objects.equals(source.inboundCode(), relation.inboundCode())) + .map(Source::response).toList(); + return new ExceptionOrderResponses.Relation(relation.lastMileOrderId(), relation.lastMileOrderNo(), + relation.inboundOrderId(), relation.inboundCode(), relation.customerId(), relation.customerName(), + relation.overseasWarehouseId(), relation.overseasWarehouseName(), relation.deliveryNo(), + relation.outboundNo(), relation.availablePackages(), sources); + } + + private List returnSources(DeliveryOrder delivery) { + OutboundOrder outbound = outboundOrders.findByNo(delivery.getCustomerId(), delivery.getOutboundNo()); + if (outbound == null) throw conflict("派送单缺少原出库单据"); + Map itemById = new LinkedHashMap<>(); + outboundPalletItems.findByOutbound(delivery.getCustomerId(), outbound.getId()) + .forEach(item -> itemById.put(item.getId(), item)); + Map aggregated = new LinkedHashMap<>(); + for (OutboundPdaPick pick : outboundPdaPicks.findByOutbound(delivery.getCustomerId(), outbound.getId())) { + OutboundPalletItem item = itemById.get(pick.getOutboundPalletItemId()); + if (item == null || pick.getInventoryPositionId() == null) continue; + String key = item.getId() + ":" + pick.getInventoryPositionId(); + aggregated.merge(key, new Source(item.getId(), pick.getInventoryPositionId(), item.getPalletNo(), + item.getSkuCode(), item.getInboundCode(), Objects.requireNonNullElse(pick.getPackages(), 0L)), + (left, right) -> left.withPackages(left.maxReturnPackages() + right.maxReturnPackages())); + } + return new ArrayList<>(aggregated.values()); + } + + private List storedReturnSources(ExceptionOrder order) { + return returnItems.findByException(order.getCustomerId(), order.getId()).stream() + .map(item -> new ExceptionOrderResponses.ReturnSource(item.getOutboundPalletItemId(), + item.getInventoryPositionId(), item.getPalletNo(), item.getSkuCode(), + Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList(); + } + + private void validateReturnItems(List available, + List requested) { + Set unique = new java.util.HashSet<>(); + for (ExceptionOrderRequests.ReturnItem item : requested) { + String key = item.outboundPalletItemId() + ":" + item.inventoryPositionId(); + if (!unique.add(key)) throw badRequest("退回库存明细不能重复"); + ExceptionOrderResponses.ReturnSource source = available.stream().filter(value -> sameSource(value, item)) + .findFirst().orElseThrow(() -> conflict("所选退回库存不属于该派送单和入仓单")); + if (item.packageCount() > source.maxReturnPackages()) throw conflict("退回箱数超过原出库箱数"); + } + } + + private boolean sameSource(ExceptionOrderResponses.ReturnSource source, ExceptionOrderRequests.ReturnItem item) { + return Objects.equals(source.outboundPalletItemId(), item.outboundPalletItemId()) + && Objects.equals(source.inventoryPositionId(), item.inventoryPositionId()); + } + + private ExceptionOrder lock(String customerId, String exceptionNo, long expectedVersion, String expectedStatus) { + ExceptionOrder order = exceptionOrders.lockByNo(customerId, exceptionNo); + if (order == null) throw notFound("异常订单不存在"); + if (version(order) != expectedVersion) throw conflict("异常订单版本已变化,请刷新后重试"); + if (!expectedStatus.equals(order.getStatus())) throw conflict("当前异常状态不允许执行该操作"); + return order; + } + + private void closeReturnedDelivery(ExceptionOrder order, String operatorId, LocalDateTime now) { + if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return; + DeliveryOrder delivery = deliveryOrders.findByNo(order.getCustomerId(), order.getDeliveryNo()); + if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) { + throw conflict("关联派送单不是退货处理中状态"); + } + delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); + for (LastlegOrder tail : lastMileOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, order.getCustomerId()) + .eq(LastlegOrder::getDeliveryOrderNo, order.getDeliveryNo()))) { + tail.setAbnormal(false); tail.setSubOrderStatus("DELIVERY:RETURNED"); tail.setUpdatedAt(now); + lastMileOrders.updateById(tail); + } + DeliveryOperation operation = new DeliveryOperation(); + operation.setCustomerId(order.getCustomerId()); operation.setDeliveryOrderId(delivery.getId()); + operation.setOperationNode("RETURN_COMPLETED"); operation.setOperatorId(operatorId); + operation.setOperatorName(operatorId); operation.setResult("异常退回处理完成:" + order.getExceptionNo()); + operation.setCreatedAt(now); deliveryOperations.insert(operation); + } + + private List allowedActions(ExceptionOrder order) { + return switch (Objects.requireNonNullElse(order.getStatus(), "")) { + case "PENDING" -> List.of("START_PROCESSING", "CANCEL_EXCEPTION"); + case "PROCESSING" -> List.of("COMPLETE_EXCEPTION"); + default -> List.of(); + }; + } + + private ExceptionOrderResponses.CommandResult commandResult(ExceptionOrder order, long restoredPackages, + boolean replayed) { + return new ExceptionOrderResponses.CommandResult(order.getId(), order.getExceptionNo(), order.getStatus(), + version(order), order.getInventoryAdjustmentId(), restoredPackages, order.getCompletedAt(), replayed); + } + + private void recordCommand(String customerId, Long orderId, String action, String idempotencyKey, + String requestHash, Object result) { + ExceptionOrderCommand command = new ExceptionOrderCommand(); + command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setExceptionOrderId(orderId); + command.setAction(action); command.setIdempotencyKey(idempotencyKey.trim()); + command.setRequestHash(requestHash); command.setResultJson(write(result)); command.setCreatedAt(LocalDateTime.now()); + commands.insert(command); + } + + private ExceptionOrderResponses.CommandResult replayResult(ExceptionOrderCommand command, String requestHash) { + requireReplayHash(command, requestHash); + try { + ExceptionOrderResponses.CommandResult result = objectMapper.readValue(command.getResultJson(), ExceptionOrderResponses.CommandResult.class); + return new ExceptionOrderResponses.CommandResult(result.id(), result.exceptionNo(), result.status(), result.version(), + result.inventoryAdjustmentId(), result.restoredPackages(), result.completedAt(), true); + } catch (JsonProcessingException exception) { throw new IllegalStateException("异常命令结果损坏", exception); } + } + + private ExceptionOrderResponses.Created replayCreated(ExceptionOrderCommand command, String requestHash) { + requireReplayHash(command, requestHash); + try { return objectMapper.readValue(command.getResultJson(), ExceptionOrderResponses.Created.class); } + catch (JsonProcessingException exception) { throw new IllegalStateException("异常命令结果损坏", exception); } + } + + private void requireReplayHash(ExceptionOrderCommand command, String requestHash) { + if (!Objects.equals(command.getRequestHash(), requestHash)) throw conflict("幂等键已用于不同请求"); + } + + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } + } + + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存异常处理结果", exception); } + } + + private List readStringList(String value) { + if (!StringUtils.hasText(value)) return List.of(); + try { return objectMapper.readValue(value, new TypeReference<>() { }); } + catch (JsonProcessingException exception) { throw new IllegalStateException("异常处理证据数据损坏", exception); } + } + + private static long version(ExceptionOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); } + + private record Source(Long outboundPalletItemId, Long inventoryPositionId, String palletNo, + String skuCode, String inboundCode, long maxReturnPackages) { + Source withPackages(long packages) { return new Source(outboundPalletItemId, inventoryPositionId, palletNo, skuCode, inboundCode, packages); } + ExceptionOrderResponses.ReturnSource response() { + return new ExceptionOrderResponses.ReturnSource(outboundPalletItemId, inventoryPositionId, + palletNo, skuCode, maxReturnPackages); + } } private String customerScope(String scopedCustomerId, String requestedCustomerId) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ReturnedInventoryRestoreRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ReturnedInventoryRestoreRequest.java new file mode 100644 index 000000000..e79f3bdb9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ReturnedInventoryRestoreRequest.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.inventory.command.dto; + +import java.util.List; + +public record ReturnedInventoryRestoreRequest( + String exceptionNo, + String outboundNo, + String overseasWarehouseId, + Long inboundOrderId, + List sources, + List receipts, + String idempotencyKey, + List evidenceFileIds +) { + public record SourceItem(Long outboundPalletItemId, Long inventoryPositionId, + String skuCode, long maxReturnPackages) { } + + public record Receipt(Long outboundPalletItemId, Long inventoryPositionId, long receivedPackages, + Long targetPalletId, String targetPalletNo, + Long targetLocationId, String targetLocationCode) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ReturnedInventoryRestoreResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ReturnedInventoryRestoreResponse.java new file mode 100644 index 000000000..ba9d4e691 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ReturnedInventoryRestoreResponse.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.inventory.command.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record ReturnedInventoryRestoreResponse( + long adjustmentId, + List ledgerIds, + long beforePackages, + long restoredPackages, + long afterPackages, + boolean replayed, + LocalDateTime completedAt +) { + public ReturnedInventoryRestoreResponse asReplay() { + return new ReturnedInventoryRestoreResponse(adjustmentId, ledgerIds, beforePackages, + restoredPackages, afterPackages, true, completedAt); + } +} 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 84b6975c4..34d97a640 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 @@ -47,6 +47,19 @@ public interface InventoryCommandMapper { "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 COALESCE(-SUM(delta_packages),0) FROM ll_inventory_ledger " + + "WHERE customer_id=#{customerId} AND position_id=#{positionId} AND outbound_order_no=#{outboundNo} " + + "AND sku_code=#{skuCode} AND business_type='SHIP' AND delta_packages<0") + long shippedPackages(@Param("customerId") String customerId, @Param("positionId") Long positionId, + @Param("outboundNo") String outboundNo, @Param("skuCode") String skuCode); + + @Select("SELECT COALESCE(SUM(delta_packages),0) FROM ll_inventory_ledger " + + "WHERE customer_id=#{customerId} AND source_event_id=#{sourceIdentity} " + + "AND sku_code=#{skuCode} AND business_type='RETURN_STOCK_IN' AND delta_packages>0") + long restoredPackages(@Param("customerId") String customerId, + @Param("sourceIdentity") String sourceIdentity, + @Param("skuCode") String skuCode); + @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/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java index 2206a1ce3..9f0025625 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -32,6 +32,7 @@ public final class OverseasPermissions { public static final String OUTBOUND_CANCEL = "platform:overseas-warehouse:outbound:cancel"; public static final String EXCEPTION_ORDER_LIST = "platform:overseas-warehouse:exception-order:list"; public static final String EXCEPTION_ORDER_CREATE = "platform:overseas-warehouse:exception-order:create"; + public static final String EXCEPTION_ORDER_PROCESS = "platform:overseas-warehouse:exception-order:process"; public static final String CUSTOMER_VISIT_LIST = "platform:overseas-warehouse:customer-visit:list"; public static final String CUSTOMER_VISIT_EDIT = "platform:overseas-warehouse:customer-visit:edit"; public static final String MERCHANT_DELIVERY_LIST = "merchant:warehouse:delivery:list"; 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 bc6f11000..1b74fed3d 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -579,6 +579,18 @@ paths: parameters: [{ name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/OneClickOutboundCreate' } } } } responses: { '200': { description: 一件代发出库 PDA 任务 }, '409': { description: 库存或幂等冲突 } } + /api/platform/v1/last-leg/outbound-orders/one-click-dropship/candidates: + get: + summary: 查询一件代发出库候选库存 + description: 权限 platform:overseas-warehouse:outbound:create;按客户及可选海外仓一次查询服务端权威候选,仅返回 ACTIVE 一件代发档案、OCCUPIED 正可用库存、AVAILABLE 箱唛与 SKU 余额共同覆盖且未被排车、转仓或待执行出库占用的维度。创建命令会再次校验,不信任前端候选快照。 + security: [{ bearerAuth: [] }] + parameters: + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + - { name: warehouseId, in: query, required: false, schema: { type: string } } + responses: + '200': + description: 最多 500 条一件代发出库候选;单次 SQL 返回,不逐行补查 + content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/OneClickOutboundCandidate' } } } } /api/platform/v1/last-leg/outbound-orders/{outboundNo}: get: summary: 查询出库单详情 @@ -597,13 +609,13 @@ paths: /api/platform/v1/last-leg/delivery-orders: get: summary: 分页查询平台派送单 - description: 权限 platform:overseas-warehouse:delivery:list;customerId 仅为可选平台筛选条件。 + description: 权限 platform:overseas-warehouse:delivery:list;customerId 仅为可选平台筛选条件。每行返回服务端计算的 allowedActions,页面不得自行推导状态门禁。 security: [{ bearerAuth: [] }] responses: { '200': { description: 派送单分页 } } /api/platform/v1/last-leg/delivery-orders/{deliveryNo}: get: summary: 查询平台派送单详情 - description: 权限 platform:overseas-warehouse:delivery:list;列表权限包含详情只读。 + description: 权限 platform:overseas-warehouse:delivery:list;列表权限包含详情只读;order.allowedActions 为当前可执行动作。 security: [{ bearerAuth: [] }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 派送单详情 }, '404': { description: 派送单不存在 } } @@ -643,18 +655,18 @@ paths: /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/complete: post: summary: 标记派送完成并生成回访记录 - description: 权限 platform:overseas-warehouse:delivery:status。 + description: 权限 platform:overseas-warehouse:delivery:status。仅 WAIT_DELIVERY/DELIVERING 可完成;DELIVERED 同状态重放成功,其他终态或倒退返回 409。 security: [{ bearerAuth: [] }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 派送状态与回访记录 } } /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/status: put: summary: 更新派送状态 - description: 权限 platform:overseas-warehouse:delivery:status。 + description: 权限 platform:overseas-warehouse:delivery:status。只允许 WAIT_DELIVERY→DELIVERING/DELIVERED/REJECTED/CANCELLED、DELIVERING→DELIVERED/REJECTED;同状态幂等,终态不可倒退。 security: [{ bearerAuth: [] }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] - requestBody: { required: true, content: { application/json: { schema: { type: object } } } } - responses: { '200': { description: 更新后的派送状态 } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryStatusChange' } } } } + responses: { '200': { description: 更新后的派送状态 }, '409': { description: 非法状态迁移 } } /api/platform/v1/last-leg/customer-visits: get: summary: 分页查询客户回访 @@ -1650,6 +1662,16 @@ components: sellerName: { type: string, nullable: true, maxLength: 128 } remark: { type: string, nullable: true, maxLength: 1000 } fees: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/DeliveryFeeInput' } } + DeliveryStatusChange: + type: object + required: [status] + properties: + status: { type: string, enum: [WAIT_DELIVERY, DELIVERING, DELIVERED, REJECTED, CANCELLED] } + remark: { type: string, nullable: true, maxLength: 500 } + DeliveryAllowedAction: + type: string + enum: [START_DELIVERY, COMPLETE_DELIVERY, REJECT_DELIVERY, CANCEL_DELIVERY, MARK_RETURN] + description: START/COMPLETE/REJECT/CANCEL 使用通用 status 接口;MARK_RETURN 使用 mark-return 接口。 DeliveryFeeInput: type: object required: [feeType, currency, amount] @@ -1676,6 +1698,18 @@ components: inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' } packages: { type: integer, format: int64, minimum: 1 } sku: { type: string, maxLength: 128 } + OneClickOutboundCandidate: + type: object + required: [inventoryPositionId, overseasWarehouseId, palletNo, skuCode, availablePackages] + properties: + inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$', description: 创建命令提交键,不作为用户展示编号 } + overseasWarehouseId: { type: string } + overseasWarehouseName: { type: string, nullable: true } + palletNo: { type: string } + inboundCode: { type: string, nullable: true } + skuCode: { type: string } + productName: { type: string, nullable: true } + availablePackages: { type: integer, format: int64, minimum: 1, description: 库存位置、SKU余额和AVAILABLE箱唛三者的可用最小值 } OutboundCancel: type: object required: [reason] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java new file mode 100644 index 000000000..0613add15 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java @@ -0,0 +1,81 @@ +package com.baiyue.lastleg.customervisit.service; + +import com.baiyue.lastleg.customervisit.dto.CustomerVisitRequests; +import com.baiyue.lastleg.customervisit.entity.CustomerVisit; +import com.baiyue.lastleg.customervisit.mapper.CustomerVisitMapper; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +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.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CustomerVisitServiceTest { + @Mock private CustomerVisitMapper visits; + @Mock private DeliveryOrderMapper deliveryOrders; + @Mock private DeliveryOperationMapper deliveryOperations; + private CustomerVisitService service; + + @BeforeEach + void setUp() { + service = new CustomerVisitService(); + ReflectionTestUtils.setField(service, "visits", visits); + ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders); + ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations); + } + + @Test + void rejectsRollbackFromDeliveredToWaitDeliveryWithoutWritingHistory() { + DeliveryOrder delivery = delivery("DELIVERED"); + when(deliveryOrders.findByNo("1001", "DL-1")).thenReturn(delivery); + + assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1", + new CustomerVisitRequests.DeliveryStatus("WAIT_DELIVERY", "错误倒退"))) + .isInstanceOf(ResponseStatusException.class) + .satisfies(error -> assertThat(((ResponseStatusException) error).getStatusCode().value()).isEqualTo(409)) + .hasMessageContaining("DELIVERED").hasMessageContaining("WAIT_DELIVERY"); + + verify(deliveryOrders, never()).updateById(delivery); + verifyNoInteractions(deliveryOperations); + } + + @Test + void replaysDeliveredToDeliveredWithoutChangingDeliveryOrAddingHistory() { + DeliveryOrder delivery = delivery("DELIVERED"); + CustomerVisit visit = new CustomerVisit(); + visit.setId(33L); + visit.setStatus("PENDING"); + when(deliveryOrders.findByNo("1001", "DL-1")).thenReturn(delivery); + when(visits.findByDelivery("1001", 11L)).thenReturn(visit); + + var result = service.updateDeliveryStatus("1001", "9001", "DL-1", + new CustomerVisitRequests.DeliveryStatus("DELIVERED", null)); + + assertThat(result.replayed()).isTrue(); + assertThat(result.deliveryStatus()).isEqualTo("DELIVERED"); + assertThat(result.visitId()).isEqualTo(33L); + verify(deliveryOrders, never()).updateById(delivery); + verifyNoInteractions(deliveryOperations); + } + + private DeliveryOrder delivery(String status) { + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(11L); + delivery.setCustomerId("1001"); + delivery.setDeliveryNo("DL-1"); + delivery.setStatus(status); + return delivery; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java index fd4e31675..105b3ac0a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java @@ -66,6 +66,6 @@ class DeliveryLongIdJsonContractTest { DELIVERY_ID, null, null, "PS-1", null, null, null, null, "CREATED", null, null, null, null, null, null, null, null, null, null, null, null, - 0, 0, null, null, null, null, null, null, null, null); + 0, 0, null, null, null, null, null, null, null, null, java.util.List.of()); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DropshipOutboundCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DropshipOutboundCandidateMapperContractTest.java new file mode 100644 index 000000000..bc3bb4a84 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DropshipOutboundCandidateMapperContractTest.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DropshipOutboundCandidateMapperContractTest { + @Test + void candidateQueryIsSingleStatementAndFailsClosedOnEveryEligibilityFact() throws Exception { + String sql = select("listOutboundCandidates", String.class, String.class); + for (String token : new String[] {"p.customer_id=#{customerId}", "p.status='ACTIVE'", + "o.order_type='ONE_CLICK_DROPSHIP'", "COALESCE(o.detained,0)=0", "pos.status='OCCUPIED'", + "pos.available_packages>0", "s.package_count>0", "c.status='AVAILABLE'", + "ll_dispatch_pallet_lock", "ll_warehouse_transfer_inventory_allocation", + "ll_outbound_pallet_item", "pos.overseas_warehouse_id=#{warehouseId}", "LIMIT 500"}) { + assertTrue(sql.contains(token), token); + } + assertTrue(sql.contains("LEAST(pos.available_packages,s.package_count")); + } + + @Test + void creationLockUsesTheSameEligibilityContractAndLocksOneSkuDimension() throws Exception { + String sql = select("lockOutboundCandidate", String.class, Long.class, String.class); + assertTrue(sql.contains("AND pos.id=#{positionId}")); + assertTrue(sql.contains("AND s.sku_code=#{skuCode}")); + assertTrue(sql.contains("LIMIT 1 FOR UPDATE")); + assertTrue(sql.contains("ll_dispatch_pallet_lock")); + assertTrue(sql.contains("ll_outbound_pallet_item")); + } + + private String select(String method, Class... parameterTypes) throws Exception { + Select annotation = DropshipOrderProfileMapper.class.getMethod(method, parameterTypes) + .getAnnotation(Select.class); + assertNotNull(annotation); + return String.join("", annotation.value()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java index 5096b6359..600e871ba 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java @@ -90,15 +90,20 @@ class PlatformDispatchOutboundContractTest { void oneClickRequiresCustomerWhileOutboundListKeepsOptionalFilter() throws Exception { Method create = DeliveryTransferController.class.getMethod("createOneClick", AuthenticatedUser.class, String.class, DeliveryRequests.OneClickOutbound.class); + Method candidates = DeliveryTransferController.class.getMethod("oneClickCandidates", AuthenticatedUser.class, + String.class, String.class); Method page = DeliveryTransferController.class.getMethod("outboundPage", AuthenticatedUser.class, String.class, DeliveryRequests.Query.class); assertPermission(create, OverseasPermissions.OUTBOUND_CREATE); + assertPermission(candidates, OverseasPermissions.OUTBOUND_CREATE); assertPermission(page, OverseasPermissions.OUTBOUND_LIST); RequestParam createCustomer = create.getParameters()[1].getAnnotation(RequestParam.class); RequestParam pageCustomer = page.getParameters()[1].getAnnotation(RequestParam.class); assertNotNull(createCustomer); assertTrue(createCustomer.required()); + assertTrue(candidates.getParameters()[1].getAnnotation(RequestParam.class).required()); + assertFalse(candidates.getParameters()[2].getAnnotation(RequestParam.class).required()); assertNotNull(pageCustomer); assertFalse(pageCustomer.required()); @@ -167,6 +172,9 @@ class PlatformDispatchOutboundContractTest { assertFalse(action.contains("name: customerId")); } assertTrue(outboundDetail.contains("pallets[].palletNo")); + assertTrue(yaml.contains("/api/platform/v1/last-leg/outbound-orders/one-click-dropship/candidates:")); + assertTrue(yaml.contains("OneClickOutboundCandidate")); + assertTrue(yaml.contains("单次 SQL 返回,不逐行补查")); assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:edit")); assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:cancel")); assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:transfer-delivery")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java new file mode 100644 index 000000000..3ea9fffe3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.delivery.model; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DeliveryStatusPolicyTest { + @Test + void exposesOnlyServerAuthorizedActionsForEachState() { + assertThat(DeliveryStatusPolicy.allowedActions("WAIT_DELIVERY")) + .containsExactly("START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY", "MARK_RETURN"); + assertThat(DeliveryStatusPolicy.allowedActions("DELIVERING")) + .containsExactly("COMPLETE_DELIVERY", "REJECT_DELIVERY", "MARK_RETURN"); + assertThat(DeliveryStatusPolicy.allowedActions("DELIVERED")).containsExactly("MARK_RETURN"); + assertThat(DeliveryStatusPolicy.allowedActions("RETURN_PENDING")).isEmpty(); + assertThat(DeliveryStatusPolicy.allowedActions("CANCELLED")).isEmpty(); + } + + @Test + void terminalStatesCannotReenterTheDeliveryFlow() { + assertThat(DeliveryStatusPolicy.canTransition("DELIVERED", "WAIT_DELIVERY")).isFalse(); + assertThat(DeliveryStatusPolicy.canTransition("DELIVERED", "DELIVERING")).isFalse(); + assertThat(DeliveryStatusPolicy.canTransition("REJECTED", "DELIVERING")).isFalse(); + assertThat(DeliveryStatusPolicy.canTransition("CANCELLED", "DELIVERED")).isFalse(); + } +} 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 0ea310a8f..abefdf621 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 @@ -34,6 +34,8 @@ import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; import com.baiyue.lastleg.dropship.entity.DropshipCarton; import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; +import com.baiyue.lastleg.dropship.model.DropshipOutboundCandidate; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -78,6 +80,7 @@ class DeliveryTransferServiceTest { @Mock InventoryBalanceMapper inventoryBalances; @Mock DropshipOrderProfileMapper dropshipProfiles; @Mock DropshipCartonMapper dropshipCartons; + @Mock DropshipSkuInventoryMapper dropshipSkuInventory; private DeliveryTransferService service; @@ -103,6 +106,7 @@ class DeliveryTransferServiceTest { ReflectionTestUtils.setField(service, "inventoryBalances", inventoryBalances); ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles); ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons); + ReflectionTestUtils.setField(service, "dropshipSkuInventory", dropshipSkuInventory); } @Test @@ -398,12 +402,8 @@ class DeliveryTransferServiceTest { @Test void oneClickOutboundRejectsSummarySkuWithoutAvailableDropshipCartons() { - InventoryPosition position = new InventoryPosition(); - position.setId(901L); position.setCustomerId("TENANT-A"); position.setOverseasWarehouseId("WH-1"); - position.setPalletId(501L); position.setPalletNo("PLT-501"); position.setAvailablePackages(1L); - DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L); - when(inventoryPositions.selectById(901L)).thenReturn(position); - when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile); + DropshipOutboundCandidate candidate = dropshipCandidate(901L, 1001L, "WH-1", "PLT-501", "MIXED", 1L); + when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 901L, "MIXED")).thenReturn(candidate); when(dropshipCartons.lockAvailable("TENANT-A", 1001L, "MIXED", 1L)).thenReturn(List.of()); ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.createOneClick( @@ -411,9 +411,43 @@ class DeliveryTransferServiceTest { new DeliveryRequests.OneClickItem(901L, 1L, "MIXED")), null, "one-click-mixed"))); assertEquals(409, exception.getStatusCode().value()); + assertTrue(exception.getReason().contains("一件代发可用箱数不足")); verify(outboundOrders, never()).insert(any(OutboundOrder.class)); } + @Test + void oneClickCandidatesUseOneAuthoritativeQueryAndExposeStableBusinessFields() { + DropshipOutboundCandidate candidate = dropshipCandidate(901L, 1001L, "WH-1", "DS-A002", "SKU-1", 3L); + candidate.setOverseasWarehouseName("Los Angeles"); candidate.setInboundCode("IN-1"); + candidate.setProductName("Product 1"); + when(dropshipProfiles.listOutboundCandidates("TENANT-A", "WH-1")).thenReturn(List.of(candidate)); + + List result = service.oneClickCandidates("TENANT-A", " WH-1 "); + + assertEquals(1, result.size()); + assertEquals(901L, result.get(0).inventoryPositionId()); + assertEquals("DS-A002", result.get(0).palletNo()); + assertEquals("SKU-1", result.get(0).skuCode()); + assertEquals(3L, result.get(0).availablePackages()); + verify(dropshipProfiles).listOutboundCandidates("TENANT-A", "WH-1"); + verify(inventoryPositions, never()).selectById(any()); + } + + @Test + void oneClickCreationRejectsOrdinaryInventoryBeforeAnyWrite() { + when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 901L, "MIXED")).thenReturn(null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.createOneClick( + "TENANT-A", "U-1", new DeliveryRequests.OneClickOutbound("recipient", List.of( + new DeliveryRequests.OneClickItem(901L, 1L, "MIXED")), null, "ordinary-stock"))); + + assertEquals(409, exception.getStatusCode().value()); + assertEquals("所选库存不是可用的一件代发库存,或已被其他作业占用", exception.getReason()); + verify(outboundOrders, never()).insert(any(OutboundOrder.class)); + verify(outboundPallets, never()).insert(any(OutboundPalletItem.class)); + verify(pdaTasks, never()).insert(any(OutboundPdaTask.class)); + } + @Test void outboundPdaListAndDetailUseOnlyBoundWarehouse() throws Exception { assertNotNull(DeliveryTransferService.class.getMethod("pdaPick", String.class, String.class, @@ -652,4 +686,12 @@ class DeliveryTransferServiceTest { value.setInboundCode(inboundCode); value.setOrderRefs("LMO-1"); value.setPackageCount(packages); return value; } + + private static DropshipOutboundCandidate dropshipCandidate(Long positionId, Long profileId, String warehouseId, + String palletNo, String sku, Long packages) { + DropshipOutboundCandidate value = new DropshipOutboundCandidate(); + value.setInventoryPositionId(positionId); value.setProfileId(profileId); value.setWarehousePalletId(501L); + value.setOverseasWarehouseId(warehouseId); value.setPalletNo(palletNo); value.setSkuCode(sku); + value.setAvailablePackages(packages); return value; + } } From 1fd2b544a5b45e710951949c94d48cf76014039a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 18:18:51 +0800 Subject: [PATCH 41/56] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...und-measurement-box-code-contract.test.mjs | 8 +- .../inbound-measurement.mjs | 104 +++++++++++++----- 2 files changed, 84 insertions(+), 28 deletions(-) diff --git a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs index f5c7e101c..01193adbc 100644 --- a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs @@ -100,9 +100,13 @@ test('approval freezes measurement and requires quantity weight and volume conse test('dropship measurement completes the formal palletization workflow before smart loading', () => { assert.match(source, /pda-dropship\/tasks\/pending/) assert.match(source, /pda-dropship\/tasks\/\$\{taskId\}\/claim/) - assert.match(source, /pallets\/open/) - assert.match(source, /pallets\/\$\{palletId\}\/scan/) + assert.match(source, /cartons\/resolve/) + assert.match(source, /cartons\/count/) + assert.match(source, /pallets\/bind-pending/) assert.match(source, /pallets\/\$\{palletId\}\/seal/) + assert.match(source, /重复清点操作按operationId幂等返回/) + assert.doesNotMatch(source, /pallets\/open/) + assert.doesNotMatch(source, /pallets\/\$\{palletId\}\/scan/) assert.match(source, /dropship \? \['WAIT_PALLETIZE'\] : \['WAIT_SPLIT'\]/) assert.match(source, /一件代发合托完成后进入智能分柜门禁/) }) diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index a5397e769..45363ca0a 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -134,35 +134,85 @@ async function completeDropshipPalletization(context, { inboundId, inboundCode, claimed?.taskStatus === 'CLAIMED' && asId(claimed?.inboundId) === inboundId, `taskStatus=${claimed?.taskStatus ?? 'missing'}`, OPTIONS) - const openResponse = await context.request('POST', - `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/open`, { - warehouseId, operationId: `${context.marker}-dropship-open`.slice(0, 128), - }) - let pallet = context.apiSuccess(MODULE, '开启一件代发工作托盘', openResponse, - `taskId=${taskId}`, OPTIONS) - const palletId = asId(pallet?.palletId) - if (!palletId) return null - const totalCartons = Number(measurement?.actualPackageCount) - let scannedCartons = 0 + const palletDate = new Date().toISOString().slice(2, 10).replace(/-/g, '') + let palletHash = 0 + for (const character of `${context.marker}:${taskId}`) { + palletHash = (Math.imul(palletHash, 31) + character.charCodeAt(0)) >>> 0 + } + const palletNo = `DP-${palletDate}-${String(palletHash % 10_000).padStart(4, '0')}` + let countedCartons = 0 + let pallet = null + for (let index = 0; index < totalCartons; index += 1) { const boxCode = `${inboundCode}#${totalCartons}-${index + 1}` - const scanResponse = await context.request('POST', - `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/${palletId}/scan`, { - warehouseId, boxCode, cartonCount: 1, - operationId: `${context.marker}-dropship-scan-${index + 1}`.slice(0, 128), - }) - const scanned = context.apiSuccess(MODULE, `合托扫描第${index + 1}箱冻结箱唛`, scanResponse, + const resolveResponse = await context.request('GET', + `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/cartons/resolve?warehouseId=${warehouseId}&boxCode=${encodeURIComponent(boxCode)}`) + const resolved = context.apiSuccess(MODULE, `解析第${index + 1}箱的冻结箱唛`, resolveResponse, `boxCode=${boxCode}`, OPTIONS) - context.assert(MODULE, `第${index + 1}箱进入工作托盘`, scanResponse, - Number(scanned?.addedCartons) === 1 && asId(scanned?.pallet?.palletId) === palletId, - `added=${scanned?.addedCartons ?? 'missing'}, remaining=${scanned?.remainingCartons ?? 'missing'}`, OPTIONS) - pallet = scanned?.pallet || pallet - if (Number(scanned?.addedCartons) === 1) scannedCartons += 1 + context.assert(MODULE, `第${index + 1}箱允许清点且每箱SKU数完整`, resolveResponse, + resolved?.canCount === true && Number(resolved?.expectedSkuQuantity) > 0, + `canCount=${resolved?.canCount ?? 'missing'}, expectedSkuQuantity=${resolved?.expectedSkuQuantity ?? 'missing'}`, OPTIONS) + if (resolved?.canCount !== true || Number(resolved?.expectedSkuQuantity) < 1) continue + + const countPayload = { + warehouseId, + boxCode, + actualSkuQuantity: Number(resolved.expectedSkuQuantity), + discrepancyAction: 'MATCH', + discrepancyRemark: null, + palletNo: index === 0 ? null : palletNo, + operationId: `${context.marker}-dropship-count-${index + 1}`.slice(0, 128), + } + const countResponse = await context.request('POST', + `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/cartons/count`, countPayload) + const counted = context.apiSuccess(MODULE, `清点第${index + 1}箱并冻结实收SKU数`, countResponse, + `boxCode=${boxCode}`, OPTIONS) + context.assert(MODULE, `第${index + 1}箱清点结果与冻结箱唛一致`, countResponse, + asId(counted?.carton?.cartonId) === asId(resolved?.cartonId) + && Number(counted?.carton?.actualSkuQuantity) === Number(resolved?.expectedSkuQuantity) + && counted?.carton?.discrepancyStatus === 'MATCHED', + `cartonId=${asId(counted?.carton?.cartonId) || 'missing'}, discrepancy=${counted?.carton?.discrepancyStatus ?? 'missing'}`, OPTIONS) + if (asId(counted?.carton?.cartonId) === asId(resolved?.cartonId)) countedCartons += 1 + + if (index === 0) { + context.assert(MODULE, '先扫箱模式将已清点箱暂存为待组托', countResponse, + Number(counted?.unboundCartons) === 1 && counted?.pallet == null, + `unboundCartons=${counted?.unboundCartons ?? 'missing'}`, OPTIONS) + const replayResponse = await context.request('POST', + `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/cartons/count`, countPayload) + const replayed = context.apiSuccess(MODULE, '重复清点操作按operationId幂等返回', replayResponse, + `boxCode=${boxCode}`, OPTIONS) + context.assert(MODULE, '重复清点没有重复累计箱数', replayResponse, + Number(replayed?.countedCartons) === Number(counted?.countedCartons) + && Number(replayed?.unboundCartons) === Number(counted?.unboundCartons), + `counted=${replayed?.countedCartons ?? 'missing'}, unbound=${replayed?.unboundCartons ?? 'missing'}`, OPTIONS) + + const bindResponse = await context.request('POST', + `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/bind-pending`, { + warehouseId, palletNo, + operationId: `${context.marker}-dropship-bind-pending`.slice(0, 128), + }) + const bound = context.apiSuccess(MODULE, '扫描托盘并绑定全部待组托箱', bindResponse, + `taskId=${taskId}, palletNo=${palletNo}`, OPTIONS) + pallet = bound?.pallets?.find((item) => item?.palletNo === palletNo) || null + context.assert(MODULE, '待组托箱绑定到OPEN托盘且不再游离', bindResponse, + Number(bound?.unboundCartons) === 0 && Number(pallet?.cartonCount) === 1 + && pallet?.status === 'OPEN', + `unbound=${bound?.unboundCartons ?? 'missing'}, palletStatus=${pallet?.status ?? 'missing'}`, OPTIONS) + } else { + pallet = counted?.pallet || pallet + context.assert(MODULE, `第${index + 1}箱直接进入当前OPEN托盘`, countResponse, + counted?.pallet?.palletNo === palletNo && counted?.pallet?.status === 'OPEN', + `palletNo=${counted?.pallet?.palletNo ?? 'missing'}, status=${counted?.pallet?.status ?? 'missing'}`, OPTIONS) + } } context.assert(MODULE, '一件代发工作托盘覆盖测量冻结的全部箱数', null, - totalCartons > 0 && scannedCartons === totalCartons, - `scanned=${scannedCartons}, expected=${totalCartons || 'missing'}`, OPTIONS) + totalCartons > 0 && countedCartons === totalCartons && Number(pallet?.cartonCount) === totalCartons, + `counted=${countedCartons}, palletCartons=${pallet?.cartonCount ?? 'missing'}, expected=${totalCartons || 'missing'}`, OPTIONS) + + const palletId = asId(pallet?.palletId) + if (!palletId) return null const sealResponse = await context.request('POST', `/api/platform/v1/first-leg/pda-dropship/tasks/${taskId}/pallets/${palletId}/seal`, { @@ -173,9 +223,11 @@ async function completeDropshipPalletization(context, { inboundId, inboundCode, `taskId=${taskId}, palletId=${palletId}`, OPTIONS) context.assert(MODULE, '全部箱数封托后入仓单允许进入WAIT_SPLIT', sealResponse, sealed?.taskStatus === 'COMPLETED' - && Number(sealed?.allocatedCartons) === Number(sealed?.totalCartons) - && sealed?.pallets?.every((item) => item?.palletStatus === 'COMPLETED'), - `taskStatus=${sealed?.taskStatus ?? 'missing'}, allocated=${sealed?.allocatedCartons ?? 'missing'}`, OPTIONS) + && Number(sealed?.countedCartons) === Number(sealed?.totalCartons) + && Number(sealed?.boundCartons) === Number(sealed?.totalCartons) + && Number(sealed?.unboundCartons) === 0 + && sealed?.pallets?.every((item) => item?.status === 'SEALED'), + `taskStatus=${sealed?.taskStatus ?? 'missing'}, counted=${sealed?.countedCartons ?? 'missing'}, bound=${sealed?.boundCartons ?? 'missing'}`, OPTIONS) return sealed } From cd47317e8a0befbfb237bce3aee66c09b5bd471b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 18:21:46 +0800 Subject: [PATCH 42/56] test(firstleg): provide container pricing mapper fixture --- .../firstleg/repository/FbaPricingRepositoryRuleListTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java index 48004d23f..e187069f8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java @@ -12,6 +12,7 @@ import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.mapper.CarrierFbaRateMapper; import com.baiyue.firstleg.mapper.CustomerChargeRuleMapper; +import com.baiyue.firstleg.mapper.ContainerTierPricingMapper; import com.baiyue.firstleg.mapper.InboundFeeItemMapper; import com.baiyue.firstleg.mapper.InboundFeeMapper; import com.baiyue.firstleg.mapper.InboundOutboxMapper; @@ -46,6 +47,7 @@ class FbaPricingRepositoryRuleListTest { @MockBean private InboundFeeItemMapper feeItems; @MockBean private InboundPricingIdempotencyMapper idempotency; @MockBean private InboundOutboxMapper outbox; + @MockBean private ContainerTierPricingMapper containerTierPricing; @Resource private FbaPricingRepository repository; @BeforeAll static void initializeEntityMetadata() { From 3b88e5f6ab6c9aceb226708beb61e85ca1909762 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 18:24:04 +0800 Subject: [PATCH 43/56] fix(firstleg): reprice container fee on loading changes --- .../firstleg/service/LoadingListService.java | 27 ++++++++++ .../firstleg/service/SmartLoadingService.java | 52 +++++++++++++++++-- .../service/LoadingListServiceTest.java | 28 ++++++++++ .../service/SmartLoadingServiceTest.java | 7 +++ 4 files changed, 110 insertions(+), 4 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 8875925df..db4f97006 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -74,6 +74,8 @@ public class LoadingListService { @Resource private LoadingCompletedOutboxService loadingCompletedOutboxService; @Resource + private FbaPricingService fbaPricingService; + @Resource private ObjectMapper objectMapper; @Resource private LoadingDocumentFileClient documentFiles; @@ -501,6 +503,7 @@ public class LoadingListService { AggregateRow before = requirePlan(planId); List links = loadingPickService.requirePreloadConfirmationReady(planId); + requireContainerFeesReadyForPreloadConfirmation(links, operationId); if (repository.touchForPreloadConfirmation(planId, request.expectedVersion(), operatorId, trim(operatorName)) != 1) { throw conflict("预装计划已被其他操作修改,请刷新后重试"); @@ -610,6 +613,30 @@ public class LoadingListService { } } + /** + * Pre-review/pre-measurement members may be reserved in an editing plan. Confirmation is the + * hard boundary: every customer group in the cabinet must have a ready, freshly recomputed fee. + */ + private void requireContainerFeesReadyForPreloadConfirmation(List links, String operationId) { + Map> tenantsByContainer = new LinkedHashMap<>(); + for (Link link : links) { + if (link.containerBookingId() == null || link.tenantId() == null) { + throw conflict("预装关联缺少柜次或客户归属,不能确认预装"); + } + tenantsByContainer.computeIfAbsent(link.containerBookingId(), ignored -> new java.util.LinkedHashSet<>()) + .add(link.tenantId()); + } + for (Map.Entry> entry : tenantsByContainer.entrySet()) { + for (Long tenantId : entry.getValue()) { + var result = fbaPricingService.recalculateUnlockedContainerCustomerFees(entry.getKey(), tenantId, + sha256("CONFIRM_PRELOAD_FEE|" + operationId + '|' + entry.getKey() + '|' + tenantId)); + if (result.status() == FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY) { + throw conflict("柜内存在尚未完成初始费用的入仓单,不能确认预装"); + } + } + } + } + private static void validateRange(java.time.LocalDateTime from, java.time.LocalDateTime to) { if (from != null && to != null && from.isAfter(to)) { throw badRequest("创建开始时间不能晚于结束时间"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 3fea4c3c1..f9d416997 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -62,6 +62,7 @@ public class SmartLoadingService { @Resource private LoadingModelBridgeService loadingModelBridge; @Resource private SplitPreloadService splitPreloadService; @Resource private ColoadGroupAssignmentPort coloadGroups; + @Resource private FbaPricingService fbaPricingService; @Resource private PriceBookQueryUseCase priceBooks; @Resource private FirstlegDocumentNumberGenerator documentNumbers; @Resource private ObjectMapper objectMapper; @@ -418,11 +419,16 @@ public class SmartLoadingService { @BusinessAudit(event = "SMART_LOADING_ITEM_ASSIGN", resourceType = "LOADING_PLAN", resourceId = "#planId", description = "分配智能装柜明细") public Plan assign(long planId, AssignRequest request, long operatorId, String operatorName) { - return assign(planId, request, operatorId, operatorName, false); + return assign(planId, request, operatorId, operatorName, false, true); } private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName, boolean wholeGroupAssignment) { + return assign(planId, request, operatorId, operatorName, wholeGroupAssignment, false); + } + + private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName, + boolean wholeGroupAssignment, boolean recalculateFees) { String operation = operation(request.operationId()); String requestHash = sha256("ASSIGN|" + planId + '|' + request.inboundId() + '|' + request.inboundDetailId() + '|' + request.packageCount() @@ -475,6 +481,9 @@ public class SmartLoadingService { loadingModelBridge.bindIncrementalAllocation(planId, candidate.accountId(), candidate.inboundId(), operatorId, operatorName, operation); loadingPickService.sync(planId, changed); + if (recalculateFees) { + recalculateAfterRelationshipChange(booking.getId(), Set.of(candidate.accountId()), operation); + } return view(repository.plan(planId), booking); } @@ -488,7 +497,7 @@ public class SmartLoadingService { resourceId = "#planId", description = "按入仓码分配智能装柜箱数") public Plan assignInbound(long planId, AssignInboundRequest request, long operatorId, String operatorName) { - return assignInbound(planId, request, operatorId, operatorName, false); + return assignInbound(planId, request, operatorId, operatorName, false, null, true); } /** @@ -568,17 +577,24 @@ public class SmartLoadingService { loadingModelBridge.bindIncrementalAllocation(planId, request.customerId(), inboundId, operatorId, operatorName, operation); created.forEach(item -> loadingPickService.sync(planId, item)); + recalculateAfterRelationshipChange(booking.getId(), Set.of(request.customerId()), operation); return view(repository.plan(planId), booking); } private Plan assignInbound(long planId, AssignInboundRequest request, long operatorId, String operatorName, boolean wholeGroupAssignment) { - return assignInbound(planId, request, operatorId, operatorName, wholeGroupAssignment, null); + return assignInbound(planId, request, operatorId, operatorName, wholeGroupAssignment, null, false); } private Plan assignInbound(long planId, AssignInboundRequest request, long operatorId, String operatorName, boolean wholeGroupAssignment, List frozenDetails) { + return assignInbound(planId, request, operatorId, operatorName, wholeGroupAssignment, frozenDetails, false); + } + + private Plan assignInbound(long planId, AssignInboundRequest request, + long operatorId, String operatorName, boolean wholeGroupAssignment, + List frozenDetails, boolean recalculateFees) { String operation = operation(request.operationId()); String requestHash = sha256("ASSIGN_INBOUND|" + planId + '|' + request.inboundId() + '|' + request.packageCount() + '|' + request.expectedVersion()); @@ -592,8 +608,13 @@ public class SmartLoadingService { if (!wholeGroupAssignment && projection != null && Set.of("PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING").contains(projection.getProcessStatus())) { - return assignDeclaredInbound(planId, request, projection, operation, requestHash, + Plan assigned = assignDeclaredInbound(planId, request, projection, operation, requestHash, operatorId, operatorName); + if (recalculateFees) { + recalculateAfterRelationshipChange(requireBooking(repository.plan(planId).getContainerBookingId()).getId(), + Set.of(projection.getTenantId()), operation); + } + return assigned; } LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); @@ -626,6 +647,9 @@ public class SmartLoadingService { json(values("requestHash", requestHash, "packageCount", request.packageCount(), "specificationLineCount", sequence, "cartonIdentityStatus", "UNVERIFIED")), operatorId, operatorName); + if (recalculateFees) { + recalculateAfterRelationshipChange(booking.getId(), Set.of(first.accountId()), operation); + } return view(assigned, booking); } @@ -756,6 +780,8 @@ public class SmartLoadingService { json(values("requestHash", requestHash, "groupId", locked.groupId(), "memberCount", locked.members().size(), "groupTotalBoxes", locked.fullPackageCount(), "candidateInboundCount", memberCandidates.size())), operatorId, operatorName); + recalculateAfterRelationshipChange(booking.getId(), locked.members().stream() + .map(Member::tenantId).collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)), operation); return view(assigned, booking); } @@ -795,6 +821,8 @@ public class SmartLoadingService { json(values("version", request.expectedPlanVersion(), "groupId", groupId)), json(values("requestHash", requestHash, "groupId", groupId, "deleted", true)), operatorId, operatorName); + recalculateAfterRelationshipChange(booking.getId(), group.members().stream() + .map(Member::tenantId).collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)), operation); return view(repository.plan(planId), booking); } @@ -979,6 +1007,7 @@ public class SmartLoadingService { history(planId, itemId, "REMOVE_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation, json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())), json(values("requestHash", requestHash, "deleted", true)), operatorId, operatorName); + recalculateAfterRelationshipChange(plan.getContainerBookingId(), Set.of(item.getAccountId()), operation); return view(repository.plan(planId), requireBooking(plan.getContainerBookingId())); } @@ -1057,6 +1086,10 @@ public class SmartLoadingService { json(values("version", request.expectedPlanVersion(), "itemCount", items.size())), json(values("requestHash", requestHash, "removedItemCount", removable.size(), "removedGroupCount", removableGroupIds.size())), operatorId, operatorName); + if (!removable.isEmpty()) { + recalculateAfterRelationshipChange(booking.getId(), removable.stream().map(LoadingPlanItemEntity::getAccountId) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)), operation); + } return view(repository.plan(planId), booking); } @@ -1526,6 +1559,17 @@ public class SmartLoadingService { private static void requireSameCustomerScope(ContainerBookingEntity booking, Candidate candidate) { if (!sameCustomerScope(booking, candidate)) throw forbidden(); } + /** + * A draft may legitimately contain pre-review or pre-measurement members without a fee snapshot. + * The pricing port reports that as NOT_READY, while any sent/customer-confirmed member fails and + * rolls this relation mutation back with the surrounding transaction. + */ + private void recalculateAfterRelationshipChange(long containerBookingId, Set tenantIds, + String operation) { + tenantIds.stream().filter(Objects::nonNull).filter(tenantId -> tenantId > 0).sorted() + .forEach(tenantId -> fbaPricingService.recalculateUnlockedContainerCustomerFees( + containerBookingId, tenantId, sha256("SMART_LOADING_RELATION|" + operation + '|' + tenantId))); + } private void touch(LoadingPlanEntity p,long op,String name){if(repository.touch(p.getId(),p.getVersion(),op,name)!=1)throw conflict("计划已被并发修改");} private Plan replay(String operation, long planId, String action, Long itemId, String requestHash) { LoadingPlanHistoryEntity history = repository.operation(operation); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 28c6ecf41..1467f7e94 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -64,6 +64,8 @@ class LoadingListServiceTest { @MockBean private LoadingCompletedOutboxService loadingCompletedOutboxService; @MockBean + private FbaPricingService fbaPricingService; + @MockBean private InboundHoldGuard inboundHoldGuard; @Resource private LoadingListService service; @@ -778,6 +780,9 @@ class LoadingListServiceTest { when(repository.history(anyString())).thenReturn(null); when(repository.aggregate(10L)).thenReturn(before, after); when(loadingPickService.requirePreloadConfirmationReady(10L)).thenReturn(links); + when(fbaPricingService.recalculateUnlockedContainerCustomerFees(eq(30L), anyLong(), anyString())) + .thenReturn(new FbaPricingService.ContainerFeeRecalculationResult( + FbaPricingService.ContainerFeeRecalculationStatus.RECALCULATED, 2)); when(repository.touchForPreloadConfirmation(10L, 3, 9L, "operator")).thenReturn(1); when(repository.confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator")).thenReturn(1); when(repository.confirmLinkedPreload(302L, "CONFIRMING", 5, 9L, "operator")).thenReturn(1); @@ -791,12 +796,35 @@ class LoadingListServiceTest { assertThat(response.readiness().pdaTaskId()).isEqualTo(80L); verify(repository).confirmLinkedPreload(301L, "PENDING", 2, 9L, "operator"); verify(repository).confirmLinkedPreload(302L, "CONFIRMING", 5, 9L, "operator"); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(30L), eq(101L), anyString()); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(30L), eq(102L), anyString()); verify(loadingPickService, times(1)).beginIfReady(30L, 9L, "operator"); verify(repository).appendHistory(eq(10L), anyString(), eq("CONFIRM_PRELOAD"), eq("EDITING"), eq("EDITING"), anyString(), anyString(), eq(9L), eq("operator"), org.mockito.ArgumentMatchers.isNull()); } + @Test + void refusesConfirmationWhenAContainerCustomerFeeIsNotReadyWithoutTouchingPlan() { + AggregateRow before = row("EDITING", 3); + List links = List.of(new Link(1L, 101L, 201L, 301L, "PENDING", 2, 10L, 30L, + "MSCU-001", "EDITING", 3, 4, null)); + when(repository.history(anyString())).thenReturn(null); + when(repository.aggregate(10L)).thenReturn(before); + when(loadingPickService.requirePreloadConfirmationReady(10L)).thenReturn(links); + when(fbaPricingService.recalculateUnlockedContainerCustomerFees(eq(30L), eq(101L), anyString())) + .thenReturn(new FbaPricingService.ContainerFeeRecalculationResult( + FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY, 0)); + + assertThatThrownBy(() -> service.confirmPreload( + 10L, 9L, "operator", "confirm-key", new ConfirmPreloadRequest(3))) + .isInstanceOf(BizException.class).hasMessageContaining("初始费用"); + + verify(repository, never()).touchForPreloadConfirmation(anyLong(), anyInt(), anyLong(), anyString()); + verify(repository, never()).confirmLinkedPreload(anyLong(), anyString(), anyInt(), anyLong(), anyString()); + verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString()); + } + @Test void exactConfirmationReplayReturnsFrozenResponseWithoutTouchingPlan() throws Exception { PreloadConfirmationResponse expected = new PreloadConfirmationResponse( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index cdbed4b46..a0b0a90b7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -93,6 +93,7 @@ class SmartLoadingServiceTest { @MockBean private LoadingModelBridgeService loadingModelBridge; @MockBean private SplitPreloadService splitPreloadService; @MockBean private ColoadGroupAssignmentPort coloadGroups; + @MockBean private FbaPricingService fbaPricingService; @MockBean private PriceBookQueryUseCase priceBooks; @MockBean private FirstlegDocumentNumberGenerator documentNumbers; @Resource private SmartLoadingService service; @@ -112,6 +113,9 @@ class SmartLoadingServiceTest { when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn( ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5, new BigDecimal("10"), new BigDecimal("2")))); + when(fbaPricingService.recalculateUnlockedContainerCustomerFees(anyLong(), anyLong(), anyString())) + .thenReturn(new FbaPricingService.ContainerFeeRecalculationResult( + FbaPricingService.ContainerFeeRecalculationStatus.NOT_READY, 0)); } @Test @@ -390,6 +394,7 @@ class SmartLoadingServiceTest { assertThat(item.getValue().getGrossWeightKg()).isEqualByComparingTo("50"); verify(splitPreloadService).ensurePendingForPreAllocation(customerId, 10L, 7L, "u"); verify(loadingModelBridge).bindIncrementalAllocation(1L, customerId, 10L, 7L, "u", "pending-1"); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(99L), eq(customerId), anyString()); } @Test @@ -864,6 +869,8 @@ class SmartLoadingServiceTest { verify(repository, times(2)).remove(eq(1L), anyLong(), eq(0), eq(7L), eq("u")); verify(coloadGroups).unlockWholeGroupAfterRemoval(eq(700L), eq(1L), org.mockito.ArgumentMatchers.startsWith("UNLOCK-"), eq(7L), eq("u")); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(99L), eq(9L), anyString()); + verify(fbaPricingService).recalculateUnlockedContainerCustomerFees(eq(99L), eq(19L), anyString()); } @Test From 047ed674b89a7a8623600794fdb7b3da625395f3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 18:26:26 +0800 Subject: [PATCH 44/56] feat(file): support overseas exception evidence --- file/AGENTS.md | 1 + .../file/service/BusinessFileCategory.java | 1 + file/src/main/resources/db/file.sql | 2 +- .../overseas-exception-evidence-v1.yaml | 109 ++++++++++++++++++ .../InternalBusinessFileServiceTest.java | 41 +++++++ ...OverseasExceptionEvidenceContractTest.java | 37 ++++++ 6 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 file/src/main/resources/openapi/overseas-exception-evidence-v1.yaml create mode 100644 file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java diff --git a/file/AGENTS.md b/file/AGENTS.md index 8b9d22a1a..f0fe0b229 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -16,6 +16,7 @@ | 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` | | 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` | | 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 | +| 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg | | 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 | | 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 | | 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=订舱customerId`、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 | diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index cd5b1f630..ae0b8cc5a 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -21,6 +21,7 @@ enum BusinessFileCategory { CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_UNLOAD_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), ORDER_BATCH_IMPORT_SOURCE(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO), diff --git a/file/src/main/resources/db/file.sql b/file/src/main/resources/db/file.sql index 7c11d291a..2a4a61cb0 100644 --- a/file/src/main/resources/db/file.sql +++ b/file/src/main/resources/db/file.sql @@ -6,7 +6,7 @@ USE baiyue_file; CREATE TABLE IF NOT EXISTS file_object ( id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '所有者范围键:0为平台,正整数为客户ID', - business_type VARCHAR(32) NULL COMMENT '关联业务类型', + business_type VARCHAR(32) NULL COMMENT '关联业务类型,海外异常证据固定为OVERSEAS_EXCEPTION_EVIDENCE', business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID;装柜证据固定为物理containerBookingId', original_name VARCHAR(255) NOT NULL COMMENT '原始文件名', object_key VARCHAR(512) NOT NULL COMMENT '对象存储键', diff --git a/file/src/main/resources/openapi/overseas-exception-evidence-v1.yaml b/file/src/main/resources/openapi/overseas-exception-evidence-v1.yaml new file mode 100644 index 000000000..858cef8d5 --- /dev/null +++ b/file/src/main/resources/openapi/overseas-exception-evidence-v1.yaml @@ -0,0 +1,109 @@ +openapi: 3.0.3 +info: + title: 海外异常订单证据文件内部契约 + version: 1.0.0 + description: >- + File 只保存文件对象并校验复合归属;异常订单状态、完成门禁和证据引用由 Lastleg 持有。 + 所有接口仅接受内部服务令牌,业务失败按统一响应体 code 判断。 +paths: + /internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}: + post: + summary: 上传海外异常订单证据 + description: >- + tenantId 固定为异常订单所属客户ID,exceptionOrderId 固定为异常订单内部ID。 + 单文件最大10MiB,仅支持TXT、PDF、JPG、PNG;File不校验异常状态。 + security: [] + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ExceptionOrderId' + - {name: X-Operator-Id, in: header, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: {type: string, format: binary} + responses: + '200': + description: 返回文件元数据 + content: + application/json: + schema: {$ref: '#/components/schemas/MetadataEnvelope'} + '403': {description: 内部服务令牌无效} + /internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}/{fileId}: + get: + summary: 校验并读取海外异常订单证据元数据 + description: 必须同时匹配客户ID、异常订单ID、文件类型与文件ID,否则返回不存在或归属不匹配。 + security: [] + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ExceptionOrderId' + - $ref: '#/components/parameters/FileId' + responses: + '200': + description: 返回匹配复合归属的文件元数据 + content: + application/json: + schema: {$ref: '#/components/schemas/MetadataEnvelope'} + '403': {description: 内部服务令牌无效} + /internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}/{fileId}/content: + get: + summary: 下载海外异常订单证据内容 + description: 下载前执行与元数据接口相同的复合归属校验,响应禁止缓存。 + security: [] + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ExceptionOrderId' + - $ref: '#/components/parameters/FileId' + responses: + '200': {description: 文件二进制;携带Content-Disposition并设置Cache-Control no-store} + '403': {description: 内部服务令牌无效} +components: + parameters: + InternalToken: + name: X-Internal-Service-Token + in: header + required: true + schema: {type: string} + TenantId: + name: tenantId + in: path + required: true + schema: {type: string, pattern: '^[1-9][0-9]*$'} + ExceptionOrderId: + name: exceptionOrderId + in: path + required: true + schema: {type: string, pattern: '^[1-9][0-9]*$'} + FileId: + name: fileId + in: path + required: true + schema: {type: string, pattern: '^[1-9][0-9]*$'} + schemas: + MetadataEnvelope: + type: object + required: [code, data] + properties: + code: {type: string, example: '200'} + message: {type: string, nullable: true} + data: {$ref: '#/components/schemas/Metadata'} + Metadata: + type: object + required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes] + properties: + fileId: {type: string, pattern: '^[1-9][0-9]*$'} + ownerScope: {type: string, enum: [CUSTOMER]} + tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + businessType: {type: string, enum: [OVERSEAS_EXCEPTION_EVIDENCE]} + businessId: {type: string, pattern: '^[1-9][0-9]*$', description: 异常订单内部ID} + fileName: {type: string} + sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + contentType: {type: string} + sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760} diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index 4aeac5255..0c1f0ee6a 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -315,4 +315,45 @@ class InternalBusinessFileServiceTest { .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); } + @Test void overseasExceptionEvidenceUsesExactCustomerAndExceptionOwnership() { + MockMultipartFile file = new MockMultipartFile( + "file", "异常处理证据.pdf", "application/pdf", "%PDF-1.4".getBytes()); + when(storage.store(10001L, "701", file, BusinessFileCategory.OVERSEAS_EXCEPTION_EVIDENCE)) + .thenReturn(new BusinessFileStorage.Stored("business-files/10001/701/evidence.pdf", + "application/pdf", file.getSize(), "b".repeat(64), "异常处理证据.pdf")); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + ((FileObjectEntity) invocation.getArgument(0)).setId(801L); + return 1; + }); + + var result = service.upload(10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 7L, file); + + assertThat(result).extracting(InternalBusinessFileService.Metadata::ownerScope, + InternalBusinessFileService.Metadata::tenantId, + InternalBusinessFileService.Metadata::businessType, + InternalBusinessFileService.Metadata::businessId) + .containsExactly("CUSTOMER", 10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "701"); + verify(storage).store(10001L, "701", file, BusinessFileCategory.OVERSEAS_EXCEPTION_EVIDENCE); + assertThatThrownBy(() -> service.upload(0L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); + } + + @Test void overseasExceptionEvidenceMetadataFailsClosedForEveryOwnershipMismatch() { + FileObjectEntity value = new FileObjectEntity(); + value.setId(801L); value.setTenantId(10001L); + value.setBusinessType("OVERSEAS_EXCEPTION_EVIDENCE"); value.setBusinessId("701"); + value.setOriginalName("evidence.pdf"); value.setObjectKey("business-files/10001/701/evidence.pdf"); + value.setContentType("application/pdf"); value.setSha256("b".repeat(64)); value.setSizeBytes(8L); + when(files.selectOne(any())).thenReturn(value, null, null, null); + + assertThat(service.metadata(10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 801L).fileId()) + .isEqualTo(801L); + assertThatThrownBy(() -> service.metadata(10002L, "OVERSEAS_EXCEPTION_EVIDENCE", "701", 801L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(10001L, "OVERSEAS_EXCEPTION_EVIDENCE", "702", 801L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(10001L, "INBOUND_HOLD_EVIDENCE", "701", 801L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + } + } diff --git a/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java b/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java new file mode 100644 index 000000000..f2f826bf9 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/OverseasExceptionEvidenceContractTest.java @@ -0,0 +1,37 @@ +package com.baiyue.file.service; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasExceptionEvidenceContractTest { + @Test + void categoryAndOpenApiFreezeCustomerExceptionOwnership() throws Exception { + BusinessFileCategory category = BusinessFileCategory.require("OVERSEAS_EXCEPTION_EVIDENCE"); + assertThat(category.maxSizeBytes()).isEqualTo(10L * 1024 * 1024); + assertThat(category.formatPolicy()).isEqualTo(BusinessFileCategory.FormatPolicy.STANDARD); + + try (var stream = getClass().getResourceAsStream( + "/openapi/overseas-exception-evidence-v1.yaml")) { + assertThat(stream).isNotNull(); + String yaml = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + assertThat(yaml).contains( + "/internal/file/v1/business-files/{tenantId}/OVERSEAS_EXCEPTION_EVIDENCE/{exceptionOrderId}:", + "ownerScope: {type: string, enum: [CUSTOMER]}", + "businessType: {type: string, enum: [OVERSEAS_EXCEPTION_EVIDENCE]}", + "maximum: 10485760"); + } + } + + @Test + void initializationSqlHasEnoughWidthAndDocumentsTheCategory() throws Exception { + try (var stream = getClass().getResourceAsStream("/db/file.sql")) { + assertThat(stream).isNotNull(); + String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + assertThat(sql).contains("business_type VARCHAR(32)", "OVERSEAS_EXCEPTION_EVIDENCE"); + assertThat("OVERSEAS_EXCEPTION_EVIDENCE").hasSizeLessThanOrEqualTo(32); + } + } +} From db98da568974f258ae6ee07e7c801b6195abf7c2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 18:30:04 +0800 Subject: [PATCH 45/56] fix(lastleg): restore returned inventory from shipment source --- .../service/InventoryCommandService.java | 232 +++++++++++++++++- .../command/InventoryCommandServiceTest.java | 104 +++++++- 2 files changed, 334 insertions(+), 2 deletions(-) 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 f36082193..9b7511135 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 @@ -3,6 +3,8 @@ package com.baiyue.lastleg.inventory.command.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest; +import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse; import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; import com.baiyue.lastleg.inventory.entity.InventoryBalance; import com.baiyue.lastleg.inventory.entity.InventoryLedger; @@ -27,7 +29,9 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; import java.util.HexFormat; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; @Service @@ -245,6 +249,175 @@ public class InventoryCommandService { }); } + @Transactional + public ReturnedInventoryRestoreResponse restoreReturnedInventory(String customerId, String operatorId, + ReturnedInventoryRestoreRequest request) { + if (request == null) throw badRequest("Returned inventory restore request is required"); + String exceptionNo = required(request.exceptionNo(), "Exception number is required"); + String outboundNo = required(request.outboundNo(), "Outbound number is required"); + String warehouseId = required(request.overseasWarehouseId(), "Overseas warehouse ID is required"); + long warehouseResourceId; + try { + warehouseResourceId = Long.parseLong(warehouseId); + } catch (NumberFormatException exception) { + throw badRequest("Overseas warehouse ID must be numeric"); + } + if (request.inboundOrderId() == null || request.inboundOrderId() <= 0) { + throw badRequest("Inbound order ID is required"); + } + if (request.sources() == null || request.sources().isEmpty()) { + throw badRequest("Returned inventory sources are required"); + } + if (request.receipts() == null || request.receipts().isEmpty()) { + throw badRequest("Returned inventory receipts are required"); + } + if (request.evidenceFileIds() == null || request.evidenceFileIds().isEmpty() + || request.evidenceFileIds().stream().anyMatch(value -> trim(value) == null)) { + throw badRequest("Return evidence file IDs are required"); + } + + Map sources = new LinkedHashMap<>(); + for (ReturnedInventoryRestoreRequest.SourceItem source : request.sources()) { + if (source == null || source.outboundPalletItemId() == null || source.outboundPalletItemId() <= 0 + || source.inventoryPositionId() == null || source.inventoryPositionId() <= 0 + || source.maxReturnPackages() <= 0) { + throw badRequest("A valid returned inventory source is required"); + } + String sourceSku = required(source.skuCode(), "Returned inventory source SKU is required"); + ReturnedInventoryRestoreRequest.SourceItem normalized = new ReturnedInventoryRestoreRequest.SourceItem( + source.outboundPalletItemId(), source.inventoryPositionId(), sourceSku, source.maxReturnPackages()); + if (sources.putIfAbsent(source.outboundPalletItemId(), normalized) != null) { + throw badRequest("Returned inventory source must be unique"); + } + } + Map requestedBySource = new LinkedHashMap<>(); + Map receiptTargets = new LinkedHashMap<>(); + for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) { + ReturnedInventoryRestoreRequest.SourceItem source = receipt == null ? null + : sources.get(receipt.outboundPalletItemId()); + if (source == null || !Objects.equals(source.inventoryPositionId(), receipt.inventoryPositionId())) { + throw badRequest("Return receipt must match an immutable outbound inventory source"); + } + if (receipt.receivedPackages() <= 0 || receipt.targetPalletId() == null || receipt.targetPalletId() <= 0 + || receipt.targetLocationId() == null || receipt.targetLocationId() <= 0) { + throw badRequest("A valid returned inventory receipt is required"); + } + required(receipt.targetPalletNo(), "Target pallet number is required"); + required(receipt.targetLocationCode(), "Target location code is required"); + String receiptTarget = receipt.outboundPalletItemId() + ":" + receipt.targetPalletId(); + if (receiptTargets.putIfAbsent(receiptTarget, Boolean.TRUE) != null) { + throw badRequest("Return receipt source and target pallet must be unique"); + } + requestedBySource.merge(source.outboundPalletItemId(), receipt.receivedPackages(), Long::sum); + } + for (ReturnedInventoryRestoreRequest.SourceItem source : sources.values()) { + long requested = requestedBySource.getOrDefault(source.outboundPalletItemId(), 0L); + if (requested <= 0 || requested > source.maxReturnPackages()) { + throw conflict("Returned packages exceed the approved source limit"); + } + } + + String normalizedKey = required(request.idempotencyKey(), "Idempotency key is required"); + String requestHash = hash(request); + InventoryOperation existing = operations.findIdempotency(customerId, normalizedKey); + if (existing != null) return replayReturned(existing, requestHash); + + InventoryOperation operation = new InventoryOperation(); + operation.setId(IdWorker.getId()); + operation.setCustomerId(customerId); + operation.setIdempotencyKey(normalizedKey); + operation.setOperationType("RETURN_STOCK_IN"); + operation.setRequestHash(requestHash); + operation.setStatus("PROCESSING"); + operation.setCreatedAt(LocalDateTime.now()); + operation.setUpdatedAt(operation.getCreatedAt()); + try { + operations.insert(operation); + } catch (DuplicateKeyException exception) { + InventoryOperation concurrent = operations.findIdempotency(customerId, normalizedKey); + if (concurrent != null) return replayReturned(concurrent, requestHash); + throw exception; + } + + Map lockedSources = new LinkedHashMap<>(); + Map balanceBefore = new LinkedHashMap<>(); + for (ReturnedInventoryRestoreRequest.SourceItem source : sources.values()) { + Locked locked = locked(customerId, source.inventoryPositionId()); + InventoryBalance balance = locked.balance(); + if (!Objects.equals(balance.getInboundOrderId(), request.inboundOrderId())) { + throw conflict("Returned inventory source inbound order does not match"); + } + if (!warehouseId.equals(trim(balance.getOverseasWarehouseId()))) { + throw conflict("Returned inventory source warehouse does not match"); + } + long shipped = commands.shippedPackages(customerId, source.inventoryPositionId(), outboundNo, + source.skuCode()); + long restored = commands.restoredPackages(customerId, returnSourceIdentity(source), source.skuCode()); + long requested = requestedBySource.get(source.outboundPalletItemId()); + if (shipped <= 0) throw conflict("Immutable outbound shipment source was not found"); + if (restored + requested > shipped) { + throw conflict("Returned packages exceed the remaining shipped packages"); + } + lockedSources.put(source.outboundPalletItemId(), locked); + balanceBefore.putIfAbsent(balance.getId(), value(balance.getAvailablePackages())); + } + + long restoredPackages = 0; + List ledgerIds = new java.util.ArrayList<>(); + for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) { + ReturnedInventoryRestoreRequest.SourceItem source = sources.get(receipt.outboundPalletItemId()); + Locked sourceLocked = lockedSources.get(receipt.outboundPalletItemId()); + InventoryBalance balance = sourceLocked.balance(); + WarehouseResourceGateway.WarehousePalletReference pallet = warehouseGateway.resolvePallet( + receipt.targetPalletId()); + if (pallet == null || pallet.palletId() != receipt.targetPalletId() + || pallet.warehouseId() != warehouseResourceId + || pallet.locationId() != receipt.targetLocationId() + || !required(receipt.targetPalletNo(), "Target pallet number is required").equals(pallet.palletNo())) { + throw conflict("Target pallet does not match the returned inventory warehouse and location"); + } + WarehouseResourceGateway.WarehouseLocationReference location = warehouseGateway.resolveLocation( + warehouseResourceId, receipt.targetLocationId()); + if (location == null || location.locationId() != receipt.targetLocationId() + || !required(receipt.targetLocationCode(), "Target location code is required") + .equals(location.locationCode())) { + throw conflict("Target location does not match the returned inventory warehouse"); + } + + InventoryPosition target = returnedPosition(customerId, balance, exceptionNo, receipt, location); + long before = value(target.getAvailablePackages()); + target.setAvailablePackages(before + receipt.receivedPackages()); + target.setStatus("OCCUPIED"); + target.setVersion(value(target.getVersion()) + 1); + target.setUpdatedAt(LocalDateTime.now()); + positions.updateById(target); + + balance.setAvailablePackages(value(balance.getAvailablePackages()) + receipt.receivedPackages()); + touch(balance); + balances.updateById(balance); + InventoryLedger ledger = ledger(customerId, operatorId, operation.getId(), balance, target, + source.skuCode(), "RETURN_STOCK_IN", "SHIPPED", "AVAILABLE", before, + receipt.receivedPackages(), before + receipt.receivedPackages(), exceptionNo, + balance.getInboundOrderNo(), outboundNo, returnSourceIdentity(source), + normalizedKey + ":" + receipt.outboundPalletItemId() + ":" + target.getId(), + "Exception return received with evidence " + request.evidenceFileIds().size()); + ledgerIds.add(ledger.getId()); + restoredPackages += receipt.receivedPackages(); + } + long beforePackages = balanceBefore.values().stream().mapToLong(Long::longValue).sum(); + long afterPackages = balanceBefore.keySet().stream() + .mapToLong(balanceId -> value(lockedSources.values().stream() + .map(Locked::balance).filter(balance -> balance.getId().equals(balanceId)).findFirst().orElseThrow() + .getAvailablePackages())).sum(); + ReturnedInventoryRestoreResponse response = new ReturnedInventoryRestoreResponse(operation.getId(), + List.copyOf(ledgerIds), beforePackages, restoredPackages, afterPackages, false, LocalDateTime.now()); + operation.setStatus("COMPLETED"); + operation.setResultJson(write(response)); + operation.setUpdatedAt(response.completedAt()); + operations.updateById(operation); + return response; + } + @Transactional public InventoryCommandResponse adjust(String customerId, String operatorId, InventoryCommandRequests.Adjustment request) { @@ -483,7 +656,63 @@ public class InventoryCommandService { } } - private void ledger(String customerId, String operatorId, Long operationId, InventoryBalance balance, + private InventoryPosition returnedPosition(String customerId, InventoryBalance balance, String exceptionNo, + ReturnedInventoryRestoreRequest.Receipt receipt, + WarehouseResourceGateway.WarehouseLocationReference location) { + String batchNo = "RETURN:" + exceptionNo; + InventoryPosition position = positions.lockPosition(customerId, balance.getId(), receipt.targetPalletId(), batchNo); + if (position != null) { + if (!Objects.equals(position.getWarehouseLocationId(), location.locationId()) + || !Objects.equals(position.getWarehouseLocationCode(), location.locationCode()) + || !Objects.equals(position.getPalletNo(), receipt.targetPalletNo())) { + throw conflict("Returned inventory target position snapshot does not match"); + } + return position; + } + position = new InventoryPosition(); + position.setId(IdWorker.getId()); + position.setCustomerId(customerId); + position.setBalanceId(balance.getId()); + position.setOverseasWarehouseId(balance.getOverseasWarehouseId()); + position.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); + position.setPalletId(receipt.targetPalletId()); + position.setPalletNo(receipt.targetPalletNo().trim()); + position.setBatchNo(batchNo); + position.setWarehouseLocationId(location.locationId()); + position.setWarehouseLocationCode(location.locationCode()); + position.setAvailablePackages(0L); + position.setHoldPackages(0L); + position.setAllocatedPackages(0L); + position.setPickedPackages(0L); + position.setReceivingPackages(0L); + position.setStatus("OCCUPIED"); + position.setVersion(0L); + position.setCreatedAt(LocalDateTime.now()); + position.setUpdatedAt(position.getCreatedAt()); + positions.insert(position); + return position; + } + + private ReturnedInventoryRestoreResponse replayReturned(InventoryOperation operation, String requestHash) { + if (!"RETURN_STOCK_IN".equals(operation.getOperationType()) + || !requestHash.equals(operation.getRequestHash())) { + throw conflict("Idempotency key was already used with a different inventory request"); + } + if (!"COMPLETED".equals(operation.getStatus()) || trim(operation.getResultJson()) == null) { + throw conflict("Inventory operation is still processing"); + } + try { + return objectMapper.readValue(operation.getResultJson(), ReturnedInventoryRestoreResponse.class).asReplay(); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot read returned inventory idempotency result", exception); + } + } + + private static String returnSourceIdentity(ReturnedInventoryRestoreRequest.SourceItem source) { + return "RETURN_SOURCE:" + source.outboundPalletItemId() + ":" + source.inventoryPositionId(); + } + + private InventoryLedger ledger(String customerId, String operatorId, Long operationId, InventoryBalance balance, InventoryPosition position, String skuCode, String businessType, String from, String to, long beforePackages, long deltaPackages, long afterPackages, String businessOrderNo, String inboundOrderNo, String outboundOrderNo, @@ -524,6 +753,7 @@ public class InventoryCommandService { ledger.setOccurredAt(LocalDateTime.now()); ledger.setCreatedAt(ledger.getOccurredAt()); ledgers.insert(ledger); + return ledger; } private static void mergeSkuSnapshot(InventoryBalance balance, String skuCode) { 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 6da77c0ff..ba5f4db32 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 @@ -2,6 +2,8 @@ package com.baiyue.lastleg.inventory.command; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest; +import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse; import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.inventory.entity.InventoryBalance; @@ -17,6 +19,7 @@ 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.apache.ibatis.annotations.Select; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -57,7 +60,7 @@ class InventoryCommandServiceTest { ReflectionTestUtils.setField(service, "operations", operations); ReflectionTestUtils.setField(service, "commands", commands); ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway); - ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); lenient().when(operations.findIdempotency(any(), any())).thenReturn(null); lenient().when(commands.lockInboundTransitBalances(any(), any(), any())).thenReturn(List.of()); } @@ -329,6 +332,97 @@ class InventoryCommandServiceTest { verify(operations).findIdempotency("200", "platform-hold-1"); } + @Test + void restoresReturnedInventoryFromImmutableShipmentSourceIntoVerifiedTarget() throws Exception { + assertTrue(InventoryCommandService.class.getMethod("restoreReturnedInventory", String.class, String.class, + ReturnedInventoryRestoreRequest.class).getAnnotation(Transactional.class) != null); + InventoryBalance balance = balance(200L, 2); + balance.setOverseasWarehouseId("900"); + InventoryPosition source = position(201L, 200L, 0); + when(commands.lockPosition("100", 201L)).thenReturn(source); + when(commands.lockBalance("100", 200L)).thenReturn(balance); + when(commands.shippedPackages("100", 201L, "OUT-1", "SKU-1")).thenReturn(8L); + when(commands.restoredPackages("100", "RETURN_SOURCE:301:201", "SKU-1")).thenReturn(1L); + when(warehouseGateway.resolvePallet(400L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference( + 400L, "PLT-RETURN", 900L, 500L, "IDLE", null, 0)); + when(warehouseGateway.resolveLocation(900L, 500L)).thenReturn( + new WarehouseResourceGateway.WarehouseLocationReference(500L, "LOC-RETURN", "STORAGE", null, "ACTIVE")); + + ReturnedInventoryRestoreResponse result = service.restoreReturnedInventory("100", "7", returnRequest(3)); + + assertEquals(2, result.beforePackages()); + assertEquals(3, result.restoredPackages()); + assertEquals(5, result.afterPackages()); + assertFalse(result.replayed()); + ArgumentCaptor target = ArgumentCaptor.forClass(InventoryPosition.class); + verify(positions).insert(target.capture()); + assertEquals("RETURN:EX-1", target.getValue().getBatchNo()); + assertEquals("LOC-RETURN", target.getValue().getWarehouseLocationCode()); + assertEquals(3L, target.getValue().getAvailablePackages()); + ArgumentCaptor ledger = ArgumentCaptor.forClass(InventoryLedger.class); + verify(ledgers).insert(ledger.capture()); + assertEquals("RETURN_STOCK_IN", ledger.getValue().getBusinessType()); + assertEquals("RETURN_SOURCE:301:201", ledger.getValue().getSourceEventId()); + assertEquals("OUT-1", ledger.getValue().getOutboundOrderNo()); + } + + @Test + void returnedInventoryReplayDoesNotMutateInventoryTwice() { + InventoryBalance balance = balance(200L, 0); + balance.setOverseasWarehouseId("900"); + InventoryPosition source = position(201L, 200L, 0); + when(commands.lockPosition("100", 201L)).thenReturn(source); + when(commands.lockBalance("100", 200L)).thenReturn(balance); + when(commands.shippedPackages("100", 201L, "OUT-1", "SKU-1")).thenReturn(8L); + when(commands.restoredPackages("100", "RETURN_SOURCE:301:201", "SKU-1")).thenReturn(0L); + when(warehouseGateway.resolvePallet(400L)).thenReturn(new WarehouseResourceGateway.WarehousePalletReference( + 400L, "PLT-RETURN", 900L, 500L, "IDLE", null, 0)); + when(warehouseGateway.resolveLocation(900L, 500L)).thenReturn( + new WarehouseResourceGateway.WarehouseLocationReference(500L, "LOC-RETURN", "STORAGE", null, "ACTIVE")); + ReturnedInventoryRestoreRequest request = returnRequest(3); + service.restoreReturnedInventory("100", "7", request); + ArgumentCaptor operation = ArgumentCaptor.forClass(InventoryOperation.class); + verify(operations).insert(operation.capture()); + when(operations.findIdempotency("100", "return-1")).thenReturn(operation.getValue()); + + ReturnedInventoryRestoreResponse replay = service.restoreReturnedInventory("100", "7", request); + + assertTrue(replay.replayed()); + verify(positions, times(1)).insert(any(InventoryPosition.class)); + verify(ledgers, times(1)).insert(any(InventoryLedger.class)); + } + + @Test + void rejectsReturnBeyondRemainingImmutableShipmentWithoutInventoryMutation() { + InventoryBalance balance = balance(200L, 0); + balance.setOverseasWarehouseId("900"); + when(commands.lockPosition("100", 201L)).thenReturn(position(201L, 200L, 0)); + when(commands.lockBalance("100", 200L)).thenReturn(balance); + when(commands.shippedPackages("100", 201L, "OUT-1", "SKU-1")).thenReturn(4L); + when(commands.restoredPackages("100", "RETURN_SOURCE:301:201", "SKU-1")).thenReturn(2L); + + assertThrows(ResponseStatusException.class, + () -> service.restoreReturnedInventory("100", "7", returnRequest(3))); + + verify(positions, never()).insert(any(InventoryPosition.class)); + verify(positions, never()).updateById(any(InventoryPosition.class)); + verify(balances, never()).updateById(any(InventoryBalance.class)); + verify(ledgers, never()).insert(any(InventoryLedger.class)); + } + + @Test + void returnRestoreSqlIsBoundToImmutableShipLedgerAndSourceIdentity() throws Exception { + String shippedSql = String.join(" ", InventoryCommandMapper.class.getMethod("shippedPackages", + String.class, Long.class, String.class, String.class).getAnnotation(Select.class).value()); + assertTrue(shippedSql.contains("position_id=#{positionId}")); + assertTrue(shippedSql.contains("outbound_order_no=#{outboundNo}")); + assertTrue(shippedSql.contains("business_type='SHIP'")); + String restoredSql = String.join(" ", InventoryCommandMapper.class.getMethod("restoredPackages", + String.class, String.class, String.class).getAnnotation(Select.class).value()); + assertTrue(restoredSql.contains("source_event_id=#{sourceIdentity}")); + assertTrue(restoredSql.contains("business_type='RETURN_STOCK_IN'")); + } + private InventoryCommandRequests.StockIn stockIn(String inbound, String key, long packages, long quantity) { return new InventoryCommandRequests.StockIn("100", "Customer 100", "WH-1", "WH1", "Warehouse 1", "DEST-1", "ONT8", "ONT8", 101L, inbound, "IN-CODE-1", "SKU-1", "Product 1", null, @@ -342,6 +436,14 @@ class InventoryCommandServiceTest { packages, "ORD-1", "EVT-1", key, null); } + private ReturnedInventoryRestoreRequest returnRequest(long packages) { + return new ReturnedInventoryRestoreRequest("EX-1", "OUT-1", "900", 101L, + List.of(new ReturnedInventoryRestoreRequest.SourceItem(301L, 201L, "SKU-1", 5)), + List.of(new ReturnedInventoryRestoreRequest.Receipt(301L, 201L, packages, + 400L, "PLT-RETURN", 500L, "LOC-RETURN")), + "return-1", List.of("FILE-1")); + } + private InventoryBalance balance(Long id, long available) { InventoryBalance value = new InventoryBalance(); value.setId(id); value.setCustomerId("100"); value.setCustomerName("Customer 100"); From f6b0bca2bf6352356665d11583affa8ec6fd591d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 18:32:59 +0800 Subject: [PATCH 46/56] test(firstleg): mock container tier mapper --- .../firstleg/repository/FbaPricingRepositoryRuleListTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java index 48004d23f..b90f80014 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java @@ -17,6 +17,7 @@ import com.baiyue.firstleg.mapper.InboundFeeMapper; import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.InboundPricingIdempotencyMapper; import com.baiyue.firstleg.mapper.InboundPricingMapper; +import com.baiyue.firstleg.mapper.ContainerTierPricingMapper; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.session.Configuration; import org.junit.jupiter.api.BeforeAll; @@ -46,6 +47,7 @@ class FbaPricingRepositoryRuleListTest { @MockBean private InboundFeeItemMapper feeItems; @MockBean private InboundPricingIdempotencyMapper idempotency; @MockBean private InboundOutboxMapper outbox; + @MockBean private ContainerTierPricingMapper containerTierPricing; @Resource private FbaPricingRepository repository; @BeforeAll static void initializeEntityMetadata() { From 6514634dcb5582141773395900a08da108dfbc42 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 18:41:00 +0800 Subject: [PATCH 47/56] fix(lastleg): align receiving warehouse checks --- .../service/ReceivingContainerService.java | 33 +++++++++++---- ...ReceivingContainerServiceBehaviorTest.java | 41 +++++++++++++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 38dd06617..3dd5353c4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -163,18 +163,19 @@ public class ReceivingContainerService { String activeOperatorKey = activeOperatorKey(operatorKey); ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey); requireSessionWarehouse(active, overseasWarehouseId); - if (active != null) { - if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one"); - WarehouseResourceGateway.WarehouseReference receivingWarehouse = - warehouse.requireActiveSharedWarehouse(overseasWarehouseId); - ReceivingContainerResponses.Session result = snapshot(active, containerPlans); - save(active, "PREPARE", request.idempotencyKey(), command, result); return result; + if (active != null && !containerNo.equals(active.getContainerNo())) { + throw conflict("Finish the current container before selecting another one"); } - if (sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) { + if (active == null && sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) { throw conflict("Container is already signed or prepared by another account"); } WarehouseResourceGateway.WarehouseReference receivingWarehouse = warehouse.requireActiveSharedWarehouse(overseasWarehouseId); + bindReceivingWarehouse(containerPlans, receivingWarehouse.warehouseId()); + if (active != null) { + ReceivingContainerResponses.Session result = snapshot(active, containerPlans); + save(active, "PREPARE", request.idempotencyKey(), command, result); return result; + } LocalDateTime now = LocalDateTime.now(); ReceivingContainerSession session = new ReceivingContainerSession(); session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo); @@ -890,7 +891,23 @@ public class ReceivingContainerService { if (assigned.size() > 1) { throw conflict("Container plans have inconsistent receiving warehouse assignments"); } - return assigned.size() == 1 && assigned.contains(overseasWarehouseId); + return assigned.isEmpty() || assigned.size() == 1 && assigned.contains(overseasWarehouseId); + } + + private void bindReceivingWarehouse(List containerPlans, long overseasWarehouseId) { + for (InboundPlan plan : containerPlans) { + if (plan.getOverseasWarehouseId() != null) { + if (!Objects.equals(plan.getOverseasWarehouseId(), overseasWarehouseId)) { + throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作"); + } + continue; + } + plan.setOverseasWarehouseId(overseasWarehouseId); + plan.setUpdatedAt(LocalDateTime.now()); + if (plans.updateById(plan) != 1) { + throw conflict("Receiving warehouse assignment changed concurrently"); + } + } } private void requireReceivingAvailable(String customerId, String containerNo) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 8452b51d5..0a72f267c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -216,6 +216,47 @@ class ReceivingContainerServiceBehaviorTest { .containsExactly("CONT-A", "CONT-B"); } + @Test + void visibleCandidateWithUnassignedPlanCanOpenDetailInTheSameWarehouse() { + InboundPlan unassigned = plan("TSTU0000204"); + unassigned.setOverseasWarehouseId(null); + InboundPlanLine planLine = line("FBA"); + LastlegOrder order = containerOrder(1L, "100", "TSTU0000204", "BOOKING-204", "IN-204"); + when(plans.selectList(any())).thenReturn(List.of(unassigned)); + when(lines.selectList(any())).thenReturn(List.of(planLine)); + when(orders.selectList(any())).thenReturn(List.of(order)); + when(orders.selectById(1L)).thenReturn(order); + when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference()); + + var candidate = service.candidates("7", 100L).get(0); + var detail = service.detail("7", 100L, "TSTU0000204"); + + assertThat(candidate.operable()).isTrue(); + assertThat(detail.overseasWarehouseId()).isEqualTo(100L); + assertThat(detail.overseasWarehouseCode()).isEqualTo("SHARED-LAX"); + } + + @Test + void prepareAtomicallyBindsUnassignedPlansToThePickerWarehouse() { + InboundPlan unassigned = plan("TSTU0195472"); + unassigned.setOverseasWarehouseId(null); + InboundPlanLine planLine = line("FBA"); + LastlegOrder order = containerOrder(1L, "100", "TSTU0195472", "BOOKING-472", "IN-472"); + when(plans.selectList(any())).thenReturn(List.of(unassigned)); + when(plans.updateById(unassigned)).thenReturn(1); + when(lines.selectList(any())).thenReturn(List.of(planLine)); + when(orders.selectList(any())).thenReturn(List.of(order)); + when(orders.selectById(1L)).thenReturn(order); + when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference()); + + var result = service.prepare("7", "receiver", 100L, "TSTU0195472", + new ReceivingContainerRequests.Action("bind-warehouse")); + + assertThat(result.overseasWarehouseId()).isEqualTo(100L); + assertThat(unassigned.getOverseasWarehouseId()).isEqualTo(100L); + verify(plans).updateById(unassigned); + } + @Test void candidatesHideContainersReservedByAnotherOperator() { InboundPlan available = plan("CONT-A"); From d42629d2894e1738d43eee6b62e2b888053653a9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 18:47:15 +0800 Subject: [PATCH 48/56] feat(lastleg): add direct inventory balance detail --- .../controller/InventoryQueryController.java | 10 +++ .../query/mapper/InventoryQueryMapper.java | 11 +++ .../query/service/InventoryQueryService.java | 10 +++ .../openapi/overseas-business-v1.yaml | 45 +++++++++++++ .../InventoryBalanceDetailContractTest.java | 67 +++++++++++++++++++ .../InventoryQueryControllerTest.java | 14 ++++ .../service/InventoryQueryServiceTest.java | 27 ++++++++ 7 files changed, 184 insertions(+) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InventoryBalanceDetailContractTest.java 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 945f00ec2..eec531970 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 @@ -56,6 +56,16 @@ public class InventoryQueryController { return ApiResponse.success(service.balances(platformScope(user, customerId), query)); } + @GetMapping(BASE_PATH + "/balances/{balanceId}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')") + @Operation(summary = "Get one inventory balance", description = "Returns one non-zero inventory balance by ID and optional customer scope. Required authority: " + OverseasPermissions.INVENTORY_LIST) + public ApiResponse balance( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long balanceId, + @RequestParam(required = false) String customerId) { + return ApiResponse.success(service.balance(platformScope(user, customerId), balanceId)); + } + @GetMapping(BASE_PATH + "/balances/{balanceId}/positions") @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')") @Operation(summary = "List pallet and batch positions for one inbound-order balance", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) 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 7b7aaee31..6386caf43 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 @@ -111,6 +111,17 @@ public interface InventoryQueryMapper { @Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} LIMIT 1") InventoryBalance findBalance(@Param("balanceId") long balanceId); + @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} " + + "AND (available_packages+hold_packages+allocated_packages+picked_packages+in_transit_packages+" + + "receiving_packages+procurement_packages)>0 LIMIT 1") + InventoryBalance ownedVisibleBalance(@Param("customerId") String customerId, + @Param("balanceId") long balanceId); + + @Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} " + + "AND (available_packages+hold_packages+allocated_packages+picked_packages+in_transit_packages+" + + "receiving_packages+procurement_packages)>0 LIMIT 1") + InventoryBalance visibleBalance(@Param("balanceId") long balanceId); + String LEDGER_FILTER = " WHERE 1=1" + " AND customer_id=#{customerId}" + " AND overseas_warehouse_id=#{q.overseasWarehouseId}" + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java index 2f84aed63..06eb0c14f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java @@ -57,6 +57,16 @@ public class InventoryQueryService { return page(records, query.page(), query.size(), total); } + public InventoryBalanceView balance(String customerId, long balanceId) { + InventoryBalance balance = customerId == null + ? mapper.visibleBalance(balanceId) + : mapper.ownedVisibleBalance(customerId, balanceId); + if (balance == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory balance not found"); + } + return balanceView(balance); + } + public List positions(String customerId, long balanceId, InventoryQueryRequests.PositionQuery query) { validateStatus(query.inventoryStatus(), POSITION_STATUSES); 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 1b74fed3d..4452d4383 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -3,6 +3,18 @@ info: title: Lastleg Overseas Business API version: 1.0.0 paths: + /api/platform/v1/last-leg/inventory/balances/{balanceId}: + get: + summary: 查询单条库存余额详情 + description: 仅平台且要求 lastleg:inventory:list;按 balanceId 直接查询非零库存余额,不依赖列表分页。可选 customerId 用于精确客户隔离,越权或不存在统一返回404。 + security: [{ bearerAuth: [] }] + parameters: + - { in: path, name: balanceId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$', description: 长整型ID按字符串传输 } } + - { in: query, name: customerId, required: false, schema: { type: string } } + responses: + '200': { description: 单条库存余额, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryBalanceApiResponse' } } } } + '403': { description: 非平台身份或缺少库存查看权限 } + '404': { description: 库存余额不存在、已归零或不属于指定客户 } /api/platform/v1/last-leg/inventory/inbound-code-relationships/{inboundCode}: get: summary: 按入仓码查询海外关系摘要 @@ -1103,6 +1115,39 @@ paths: responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } } components: schemas: + InventoryBalance: + type: object + required: [id, customerId, totalPackages, availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages, receivingPackages, procurementPackages] + properties: + id: { type: string, pattern: '^[1-9][0-9]*$', description: 库存余额长整型ID,按字符串传输 } + customerId: { type: string } + customerName: { type: string, nullable: true } + overseasWarehouseId: { type: string, nullable: true } + overseasWarehouseCode: { type: string, nullable: true } + overseasWarehouseName: { type: string, nullable: true } + destinationWarehouseId: { type: string, nullable: true } + destinationWarehouseCode: { type: string, nullable: true } + destinationWarehouseName: { type: string, nullable: true } + inboundOrderId: { type: string, nullable: true, pattern: '^[1-9][0-9]*$', description: 长整型ID按字符串传输 } + inboundOrderNo: { type: string, nullable: true } + inboundCode: { type: string, nullable: true } + skuCode: { type: string, nullable: true } + productNameZh: { type: string, nullable: true } + productNameEn: { type: string, nullable: true } + totalPackages: { type: integer, format: int64, minimum: 0 } + availablePackages: { type: integer, format: int64, minimum: 0 } + holdPackages: { type: integer, format: int64, minimum: 0 } + allocatedPackages: { type: integer, format: int64, minimum: 0 } + pickedPackages: { type: integer, format: int64, minimum: 0 } + inTransitPackages: { type: integer, format: int64, minimum: 0 } + receivingPackages: { type: integer, format: int64, minimum: 0 } + procurementPackages: { type: integer, format: int64, minimum: 0 } + updatedAt: { type: string, format: date-time, nullable: true } + InventoryBalanceApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + properties: { data: { $ref: '#/components/schemas/InventoryBalance' } } InboundCodeRelationship: type: object required: [inboundCode, found, taillegOrders, inboundPlans, palletCodes, inventory] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InventoryBalanceDetailContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InventoryBalanceDetailContractTest.java new file mode 100644 index 000000000..35b8d0222 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InventoryBalanceDetailContractTest.java @@ -0,0 +1,67 @@ +package com.baiyue.lastleg.inventory.query; + +import com.baiyue.common.web.LongIdJacksonConfiguration; +import com.baiyue.lastleg.inventory.query.controller.InventoryQueryController; +import com.baiyue.lastleg.inventory.query.dto.InventoryBalanceView; +import com.baiyue.lastleg.security.OverseasPermissions; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InventoryBalanceDetailContractTest { + private static final long BALANCE_ID = 19_000_000_000_000_001L; + private static final long INBOUND_ORDER_ID = 19_000_000_000_000_002L; + + @Test + void controllerFreezesDetailPathAndInventoryListPermission() throws Exception { + var method = InventoryQueryController.class.getMethod("balance", + com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class); + GetMapping mapping = method.getAnnotation(GetMapping.class); + PreAuthorize permission = method.getAnnotation(PreAuthorize.class); + + assertNotNull(mapping); + assertEquals("/api/platform/v1/last-leg/inventory/balances/{balanceId}", mapping.value()[0]); + assertNotNull(permission); + assertTrue(permission.value().contains(OverseasPermissions.INVENTORY_LIST)); + } + + @Test + void openApiDefinesDirectDetailResponseAndStringLongIds() throws Exception { + String yaml; + try (var stream = getClass().getClassLoader().getResourceAsStream("openapi/overseas-business-v1.yaml")) { + assertNotNull(stream); + yaml = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + + assertTrue(yaml.contains("/api/platform/v1/last-leg/inventory/balances/{balanceId}:")); + assertTrue(yaml.contains("$ref: '#/components/schemas/InventoryBalanceApiResponse'")); + assertTrue(yaml.contains("id: { type: string, pattern: '^[1-9][0-9]*$'")); + } + + @Test + void balanceAndInboundOrderIdsSerializeAsJsonStrings() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of( + JacksonAutoConfiguration.class, LongIdJacksonConfiguration.class)).run(context -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + InventoryBalanceView view = new InventoryBalanceView(BALANCE_ID, "100", "Customer", "900", + "US-LAX-01", "Los Angeles", "800", "ONT8", "ONT8", INBOUND_ORDER_ID, + "IBP-1", "RK-1", "SKU-1", "产品", "Product", 3, 3, 0, 0, 0, 0, 0, 0, null); + + JsonNode json = mapper.readTree(mapper.writeValueAsString(view)); + + assertEquals(Long.toString(BALANCE_ID), json.path("id").textValue()); + assertEquals(Long.toString(INBOUND_ORDER_ID), json.path("inboundOrderId").textValue()); + }); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java index c2847e1d0..dd20d98ac 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java @@ -34,6 +34,20 @@ class InventoryQueryControllerTest { verify(service).balances(eq("10001"), eq(query)); } + @Test + void platformBalanceDetailQueriesByIdWithoutPaging() { + controller.balance(user(0L, "PLATFORM"), 9007199254740993L, null); + + verify(service).balance(null, 9007199254740993L); + } + + @Test + void platformBalanceDetailUsesExplicitCustomerScope() { + controller.balance(user(0L, "PLATFORM"), 99L, " 10001 "); + + verify(service).balance("10001", 99L); + } + @Test void merchantCannotUsePlatformInventoryController() { assertThrows(ResponseStatusException.class, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java index c6eee959a..5003effd8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java @@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -115,6 +116,32 @@ class InventoryQueryServiceTest { verify(mapper).countBalances(null, query); } + @Test + void platformBalanceDetailReadsOneVisibleBalanceWithoutPaging() { + InventoryBalance balance = new InventoryBalance(); + balance.setId(99L); + balance.setCustomerId("200"); + balance.setAvailablePackages(3L); + when(mapper.visibleBalance(99L)).thenReturn(balance); + + var result = service.balance(null, 99L); + + assertEquals(99L, result.id()); + assertEquals("200", result.customerId()); + assertEquals(3L, result.totalPackages()); + verify(mapper).visibleBalance(99L); + verify(mapper, never()).countBalances(any(), any()); + verify(mapper, never()).balances(any(), any(), anyLong(), anyLong()); + } + + @Test + void scopedBalanceDetailDoesNotLeakAnotherCustomer() { + assertThrows(ResponseStatusException.class, () -> service.balance("100", 99L)); + + verify(mapper).ownedVisibleBalance("100", 99L); + verify(mapper, never()).visibleBalance(99L); + } + @Test void unknownStatusIsRejectedBeforeDatabaseQuery() { InventoryQueryRequests.BalanceQuery query = balanceQuery("DAMAGED"); From d1090671ce368134e3137dcbdd59f313b1289714 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 18:49:52 +0800 Subject: [PATCH 49/56] fix(lastleg): enforce delivery and return workflows --- lastleg-service/AGENTS.md | 4 +- .../controller/CustomerVisitController.java | 4 +- .../dto/CustomerVisitRequests.java | 1 + .../service/CustomerVisitService.java | 33 ++-- .../delivery/dto/DeliveryResponses.java | 2 +- .../delivery/entity/DeliveryOrder.java | 1 + .../delivery/mapper/DeliveryOrderMapper.java | 2 + .../delivery/model/DeliveryStatusPolicy.java | 3 +- .../service/DeliveryTransferService.java | 6 +- .../controller/ExceptionOrderController.java | 10 ++ .../dto/ExceptionOrderRequests.java | 20 +-- .../dto/ExceptionOrderResponses.java | 5 +- .../gateway/ExceptionEvidenceFileGateway.java | 10 ++ .../HttpExceptionEvidenceFileGateway.java | 86 ++++++++++ .../mapper/ExceptionOrderMapper.java | 1 - .../service/ExceptionOrderService.java | 60 +++++-- .../src/main/resources/db/lastleg.sql | 27 ++- .../openapi/overseas-business-v1.yaml | 114 ++++++++++++- .../service/CustomerVisitServiceTest.java | 45 ++++- .../DeliveryLongIdJsonContractTest.java | 2 +- .../model/DeliveryStatusPolicyTest.java | 2 +- .../service/ExceptionOrderLifecycleTest.java | 159 ++++++++++++++++++ .../Phase5AdminPermissionContractTest.java | 10 ++ 23 files changed, 546 insertions(+), 61 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/ExceptionEvidenceFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/HttpExceptionEvidenceFileGateway.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 73b848de5..fc7ed7694 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -40,8 +40,8 @@ | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | | 一件代发接收、SKU/箱唛库存 | 内部接收入口 `dropship/controller/DropshipOrderController.java` 的 `/internal/v1/lastleg/dropship/receive`,必须经 `InternalServiceTokenGuard`;版本化契约 `src/main/resources/openapi/lastleg-internal-dropship-v1.yaml`;处理 `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` | | 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` | -| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`;平台维护拜访、附件上传、派送完成/状态 | -| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`;异常订单建档、关联解析、派送退回候选与标记 | +| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等,完成后生成回访记录 | +| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 | | 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口要求 `merchant:cross-border:procurement` 且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`、`OverseasFeeOutboxRelay.java`;海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务,入仓记录和入仓码按事实选填;审核通过、调整或作废后以版本化完整快照经 Outbox/Event Bridge 进入独立 Finance,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` | | 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口要求 `merchant:cross-border:domestic-trucking` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java index f22fbe916..1707f2fef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java @@ -68,8 +68,8 @@ public class CustomerVisitController { @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_STATUS + "')") public ApiResponse completeDelivery( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, - @PathVariable String deliveryNo) { - return ApiResponse.success(service.completeDelivery(requiredScope(user, customerId), operator(user), deliveryNo)); + @PathVariable String deliveryNo, @Valid @RequestBody CustomerVisitRequests.DeliveryStatus request) { + return ApiResponse.success(service.completeDelivery(requiredScope(user, customerId), operator(user), deliveryNo, request)); } @PutMapping("/delivery-orders/{deliveryNo}/status") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitRequests.java index 5c7ffcb36..2153d08fd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitRequests.java @@ -31,5 +31,6 @@ public final class CustomerVisitRequests { @NotNull @Min(0) Long sizeBytes) { } public record DeliveryStatus(@NotBlank @Size(max = 32) String status, + @NotNull @Min(0) Long expectedVersion, @Size(max = 500) String remark) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java index d12c0f192..bc1d68090 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java @@ -15,10 +15,8 @@ import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy; import com.baiyue.lastleg.entity.LastlegInboundOrder; import com.baiyue.lastleg.entity.LastlegOrder; -import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; -import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -52,7 +50,6 @@ public class CustomerVisitService { @Resource private DeliveryOperationMapper deliveryOperations; @Resource private LastlegOrderMapper lastMileOrders; @Resource private LastlegInboundOrderMapper inboundOrders; - @Resource private LastlegOrderStatusHistoryMapper histories; @Resource private CustomerVisitFileGateway files; @Resource private ObjectMapper objectMapper; @@ -80,29 +77,31 @@ public class CustomerVisitService { } @Transactional - public CustomerVisitResponses.Completed completeDelivery(String customerId, String operatorId, String deliveryNo) { - DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); + public CustomerVisitResponses.Completed completeDelivery(String customerId, String operatorId, String deliveryNo, + CustomerVisitRequests.DeliveryStatus request) { + if (!"DELIVERED".equals(trim(request.status()).toUpperCase(Locale.ROOT))) { + throw badRequest("派送完成接口只接受 DELIVERED 状态"); + } + DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); if (delivery == null) throw notFound("派送单不存在"); boolean replayed = "DELIVERED".equals(delivery.getStatus()); - if (!replayed) requireDeliveryTransition(delivery.getStatus(), "DELIVERED"); + if (!replayed) { + requireVersion(delivery, request.expectedVersion()); + requireDeliveryTransition(delivery.getStatus(), "DELIVERED"); + } LocalDateTime now = replayed && delivery.getSignedAt() != null ? delivery.getSignedAt() : LocalDateTime.now(); if (!replayed) { delivery.setStatus("DELIVERED"); delivery.setSignedAt(now); delivery.setUpdatedAt(now); + delivery.setVersion(version(delivery) + 1); deliveryOrders.updateById(delivery); for (LastlegOrder order : lastMileOrders.selectList(new LambdaQueryWrapper() .eq(LastlegOrder::getCustomerId, customerId) .eq(LastlegOrder::getDeliveryOrderNo, deliveryNo))) { - String from = order.getCurrentStatus(); - order.setCurrentStatus("DELIVERED"); order.setSubOrderStatus("DELIVERY:DELIVERED"); order.setUpdatedAt(now); lastMileOrders.updateById(order); - LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); - history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); - history.setToStatus("DELIVERED"); history.setEventId("DELIVERY_COMPLETED:" + deliveryNo); - history.setRemark("派送完成"); history.setCreatedAt(now); histories.insert(history); } recordOperation(delivery, operatorId, "COMPLETE_DELIVERY", "派送完成并生成客户回访记录", now); } @@ -118,20 +117,22 @@ public class CustomerVisitService { if (!DeliveryStatusPolicy.isEditableTarget(target)) { throw badRequest("不支持的派送状态"); } - if ("DELIVERED".equals(target)) return completeDelivery(customerId, operatorId, deliveryNo); - DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); + if ("DELIVERED".equals(target)) return completeDelivery(customerId, operatorId, deliveryNo, request); + DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); if (delivery == null) throw notFound("派送单不存在"); if (target.equals(delivery.getStatus())) { CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId()); return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo, delivery.getStatus(), existing == null ? null : existing.getStatus(), true); } + requireVersion(delivery, request.expectedVersion()); requireDeliveryTransition(delivery.getStatus(), target); String from = delivery.getStatus(); LocalDateTime now = LocalDateTime.now(); delivery.setStatus(target); if ("DELIVERING".equals(target) && delivery.getDispatchedAt() == null) delivery.setDispatchedAt(now); delivery.setUpdatedAt(now); + delivery.setVersion(version(delivery) + 1); deliveryOrders.updateById(delivery); String result = "派送状态由 " + from + " 修改为 " + target; if (StringUtils.hasText(request.remark())) result += ",备注:" + trim(request.remark()); @@ -313,6 +314,10 @@ public class CustomerVisitService { throw conflict("派送状态不允许由 " + from + " 变更为 " + target); } } + private static void requireVersion(DeliveryOrder delivery, long expectedVersion) { + if (version(delivery) != expectedVersion) throw conflict("派送单版本已变化,请刷新后重试"); + } + private static long version(DeliveryOrder delivery) { return Objects.requireNonNullElse(delivery.getVersion(), 0L); } private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index 2d1e51749..9a4c5e39b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -24,7 +24,7 @@ public final class DeliveryResponses { int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt, LocalDateTime expectedArrivalAt, LocalDateTime signedAt, - String remark, LocalDateTime createdAt, List allowedActions) {} + String remark, LocalDateTime createdAt, long version, List allowedActions) {} public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode, String receivingAddress, String shipmentNo, String fbaTrackingNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java index 93dbf8173..bd839ea81 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java @@ -47,6 +47,7 @@ public class DeliveryOrder { private String remark; private String idempotencyKey; private String createdBy; + private Long version; private LocalDateTime createdAt; private LocalDateTime updatedAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java index 7d2641cf5..de1372e55 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java @@ -10,6 +10,8 @@ import org.apache.ibatis.annotations.Select; public interface DeliveryOrderMapper extends BaseMapper { @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1") DeliveryOrder findByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo); + @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1 FOR UPDATE") + DeliveryOrder lockByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo); @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") DeliveryOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key); @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java index b1b363525..2ae76d3bb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java @@ -13,6 +13,7 @@ public final class DeliveryStatusPolicy { "DELIVERED", Set.of(), "REJECTED", Set.of(), "RETURN_PENDING", Set.of(), + "RETURNED", Set.of(), "CANCELLED", Set.of()); private DeliveryStatusPolicy() { } @@ -28,7 +29,7 @@ public final class DeliveryStatusPolicy { public static List allowedActions(String status) { return switch (status == null ? "" : status) { case "WAIT_DELIVERY" -> List.of( - "START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY", "MARK_RETURN"); + "START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY"); case "DELIVERING" -> List.of("COMPLETE_DELIVERY", "REJECT_DELIVERY", "MARK_RETURN"); case "DELIVERED", "REJECTED" -> List.of("MARK_RETURN"); default -> List.of(); 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 a1d939a9e..01825ae80 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 @@ -170,7 +170,7 @@ public class DeliveryTransferService { delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo()); delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName()); LastlegOrder firstOrder = firstTailOrder(customerId, items); - delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); + delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L); delivery.setWaybillNo(trim(request.waybillNo())); delivery.setDeliveryMethod(request.deliveryMethod().trim()); delivery.setCarrierName(firstText(request.carrierName(), dispatch.getCarrierName())); delivery.setPickupMethod(trim(request.pickupMethod())); delivery.setLoadingType(trim(request.loadingType())); delivery.setVehicleType(trim(request.vehicleType())); delivery.setVehicleSize(trim(request.vehicleSize())); @@ -387,7 +387,7 @@ public class DeliveryTransferService { throw conflict("Outbound order cannot be cancelled while delivery is in progress"); LocalDateTime now = LocalDateTime.now(); DeliveryOrder delivery = existingDelivery; - if (delivery != null) { delivery.setStatus("CANCELLED"); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); } + if (delivery != null) { delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); } OutboundPdaTask task = pdaTasks.lockByOutbound(customerId, outbound.getId()); if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); } outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound); @@ -614,7 +614,7 @@ public class DeliveryTransferService { candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(), value(candidate.getAvailablePackages())); } - private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), DeliveryStatusPolicy.allowedActions(d.getStatus())); } + private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus())); } private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java index 9159b5fa4..718e71837 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java @@ -10,10 +10,12 @@ import com.baiyue.lastleg.security.OverseasPermissions; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -53,6 +55,14 @@ public class ExceptionOrderController { return ApiResponse.success(service.detail(requiredScope(user, customerId), exceptionNo)); } + @PostMapping(value = "/exception-orders/{exceptionNo}/evidence", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')") + public ApiResponse uploadEvidence( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String exceptionNo, @RequestPart("file") MultipartFile file) { + return ApiResponse.success(service.uploadEvidence(requiredScope(user, customerId), operatorId(user), exceptionNo, file)); + } + @PostMapping("/exception-orders/{exceptionNo}/start-processing") @PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')") public ApiResponse startProcessing( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java index 662e3702a..8aaa2d8b4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java @@ -26,15 +26,15 @@ public final class ExceptionOrderRequests { @Min(1) Integer packageCount, @Size(max = 128) String skuCode) {} - public record MarkReturn(@NotNull Long inboundOrderId, + public record MarkReturn(@NotNull @Min(1) Long inboundOrderId, @NotBlank @Size(max = 1000) String reason, @NotBlank @Size(max = 32) String returnDirection, @Size(max = 128) String returnLogisticsNo, - @NotNull @Size(min = 1, max = 200) List items, + @NotNull @Size(min = 1, max = 200) List<@NotNull ReturnItem> items, @NotBlank @Size(max = 255) String idempotencyKey) {} - public record ReturnItem(@NotNull Long outboundPalletItemId, - @NotNull Long inventoryPositionId, + public record ReturnItem(@NotNull @Min(1) Long outboundPalletItemId, + @NotNull @Min(1) Long inventoryPositionId, @Min(1) long packageCount) {} public record StartProcessing(@Min(0) long expectedVersion, @@ -44,15 +44,15 @@ public final class ExceptionOrderRequests { public record Complete(@Min(0) long expectedVersion, @NotBlank @Size(max = 255) String idempotencyKey, @NotBlank @Size(max = 2000) String handlingResult, - @NotNull @Size(min = 1, max = 20) List evidenceFileIds, - @Size(max = 200) List receipts) {} + @NotNull @Size(min = 1, max = 20) List<@NotNull @Min(1) Long> evidenceFileIds, + @Size(max = 200) List<@NotNull ReturnReceipt> receipts) {} - public record ReturnReceipt(@NotNull Long outboundPalletItemId, - @NotNull Long inventoryPositionId, + public record ReturnReceipt(@NotNull @Min(1) Long outboundPalletItemId, + @NotNull @Min(1) Long inventoryPositionId, @Min(1) long receivedPackages, - @NotNull Long targetPalletId, + @NotNull @Min(1) Long targetPalletId, @NotBlank @Size(max = 128) String targetPalletNo, - @NotNull Long targetLocationId, + @NotNull @Min(1) Long targetLocationId, @NotBlank @Size(max = 128) String targetLocationCode) {} public record Cancel(@Min(0) long expectedVersion, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java index 4e3976701..f7fa3ddf8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java @@ -16,7 +16,7 @@ public final class ExceptionOrderResponses { String returnLogisticsNo, String handlingAction, String handlingResult, String handlerName, LocalDateTime exceptionAt, LocalDateTime completedAt, LocalDateTime updatedAt, long version, - List evidenceFileIds, List allowedActions) {} + List evidenceFileIds, List allowedActions) {} public record Relation(Long lastMileOrderId, String lastMileOrderNo, Long inboundOrderId, String inboundCode, @@ -31,5 +31,6 @@ public final class ExceptionOrderResponses { public record CommandResult(Long id, String exceptionNo, String status, long version, Long inventoryAdjustmentId, long restoredPackages, LocalDateTime completedAt, boolean replayed) {} - public record Created(Long id, String exceptionNo, String status, long version) {} + public record UploadedEvidence(Long fileId, String fileName, String contentType, long sizeBytes) {} + public record Created(Long id, String exceptionNo, String status, long version, boolean replayed) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/ExceptionEvidenceFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/ExceptionEvidenceFileGateway.java new file mode 100644 index 000000000..81629f184 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/ExceptionEvidenceFileGateway.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.exceptionorder.gateway; + +import org.springframework.web.multipart.MultipartFile; + +public interface ExceptionEvidenceFileGateway { + Uploaded upload(String customerId, long exceptionOrderId, String operatorId, MultipartFile file); + void validate(String customerId, long exceptionOrderId, long fileId); + + record Uploaded(long fileId, String fileName, String contentType, long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/HttpExceptionEvidenceFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/HttpExceptionEvidenceFileGateway.java new file mode 100644 index 000000000..47ffa58e6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/HttpExceptionEvidenceFileGateway.java @@ -0,0 +1,86 @@ +package com.baiyue.lastleg.exceptionorder.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; + +@Component +public class HttpExceptionEvidenceFileGateway implements ExceptionEvidenceFileGateway { + private static final String BUSINESS_TYPE = "OVERSEAS_EXCEPTION_EVIDENCE"; + @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct + void initializeClient() { + client = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getCustomerVisitFile()); + } + + @Override + public Uploaded upload(String customerId, long exceptionOrderId, String operatorId, MultipartFile file) { + MultipartBodyBuilder body = new MultipartBodyBuilder(); + try { + body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(file.getContentType())); + } catch (IOException exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "无法读取异常处理证据", exception); } + MetadataEnvelope response = client.post() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", + tenantId(customerId), BUSINESS_TYPE, exceptionOrderId) + .header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", operatorId) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw new ResponseStatusException( + result.getStatusCode().is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "异常处理证据上传失败"); }).body(MetadataEnvelope.class); + Metadata data = require(response); + return new Uploaded(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes()); + } + + @Override + public void validate(String customerId, long exceptionOrderId, long fileId) { + long tenantId = tenantId(customerId); + MetadataEnvelope response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}", + tenantId, BUSINESS_TYPE, exceptionOrderId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw new ResponseStatusException( + result.getStatusCode().is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "异常处理证据不存在或归属不正确"); }).body(MetadataEnvelope.class); + Metadata data = require(response); + if (data.fileId() != fileId || data.tenantId() == null || data.tenantId() != tenantId + || !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(exceptionOrderId).equals(data.businessId())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "异常处理证据与当前异常单不匹配"); + } + } + + private static Metadata require(MetadataEnvelope response) { + if (response == null || !"200".equals(response.code()) || response.data() == null || response.data().fileId() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "文件服务未返回异常处理证据元数据"); + } + return response.data(); + } + private static long tenantId(String value) { + try { return Long.parseLong(value); } + catch (NumberFormatException exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "客户ID格式无效", exception); } + } + private record MetadataEnvelope(String code, String message, Metadata data) { } + private record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + String fileName, String contentType, Long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java index 901c5e908..be96b379c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java @@ -5,7 +5,6 @@ import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; -import org.apache.ibatis.annotations.Update; @Mapper public interface ExceptionOrderMapper extends BaseMapper { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index dd7398779..cb1be9ebc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -14,6 +14,7 @@ import com.baiyue.lastleg.delivery.entity.OutboundPdaPick; import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper; +import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy; import com.baiyue.lastleg.entity.LastlegInboundOrder; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests; @@ -21,6 +22,7 @@ import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses; import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder; import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand; import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem; +import com.baiyue.lastleg.exceptionorder.gateway.ExceptionEvidenceFileGateway; import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderCommandMapper; import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper; import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper; @@ -39,9 +41,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -67,6 +69,7 @@ public class ExceptionOrderService { @Resource private ExceptionReturnItemMapper returnItems; @Resource private ExceptionOrderCommandMapper commands; @Resource private InventoryCommandService inventoryCommands; + @Resource private ExceptionEvidenceFileGateway evidenceFiles; @Resource private ObjectMapper objectMapper; public PageResult page(String scopedCustomerId, ExceptionOrderRequests.Query query) { @@ -102,9 +105,28 @@ public class ExceptionOrderService { return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order)); } + public ExceptionOrderResponses.UploadedEvidence uploadEvidence(String customerId, String operatorId, + String exceptionNo, MultipartFile file) { + ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo); + if (order == null) throw notFound("异常订单不存在"); + if (file == null || file.isEmpty()) throw badRequest("请选择异常处理证据文件"); + ExceptionEvidenceFileGateway.Uploaded uploaded = evidenceFiles.upload(customerId, order.getId(), operatorId, file); + return new ExceptionOrderResponses.UploadedEvidence(uploaded.fileId(), uploaded.fileName(), + uploaded.contentType(), uploaded.sizeBytes()); + } + public List returnCandidates(String customerId, String deliveryNo) { DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); if (delivery == null) throw notFound("派送单不存在"); + if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) { + throw conflict("当前派送状态不允许标记退回"); + } + return returnCandidates(delivery); + } + + private List returnCandidates(DeliveryOrder delivery) { + String customerId = delivery.getCustomerId(); + String deliveryNo = delivery.getDeliveryNo(); return lastMileOrders.selectList(new LambdaQueryWrapper() .eq(LastlegOrder::getCustomerId, customerId) .eq(LastlegOrder::getDeliveryOrderNo, deliveryNo) @@ -129,12 +151,15 @@ public class ExceptionOrderService { String requestHash = hash(request); ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim()); if (replay != null) return replayCreated(replay, requestHash); - DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); + DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); if (delivery == null) throw notFound("派送单不存在"); + if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) { + throw conflict("当前派送状态不允许标记退回"); + } ExceptionOrder existing = exceptionOrders.findDeliveryReturn(customerId, delivery.getId()); if (existing != null) throw conflict("该派送单已存在退回异常单"); - List candidates = returnCandidates(customerId, deliveryNo); + List candidates = returnCandidates(delivery); ExceptionOrderResponses.Relation selected = candidates.stream() .filter(candidate -> Objects.equals(candidate.inboundOrderId(), request.inboundOrderId())) .findFirst().orElseThrow(() -> conflict("所选入仓单不属于该派送单")); @@ -145,8 +170,12 @@ public class ExceptionOrderService { Set skus = available.stream().filter(source -> requested.stream().anyMatch(item -> sameSource(source, item))) .map(ExceptionOrderResponses.ReturnSource::skuCode).filter(StringUtils::hasText).collect(java.util.stream.Collectors.toSet()); String sku = skus.size() == 1 ? skus.iterator().next() : "MIXED"; + String returnDirection = trim(request.returnDirection()).toUpperCase(java.util.Locale.ROOT); + if (!Set.of("OVERSEAS_WAREHOUSE", "CUSTOMER", "OTHER").contains(returnDirection)) { + throw badRequest("不支持的退回方向"); + } ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(), - packages, sku, request.returnDirection(), request.returnLogisticsNo(), + packages, sku, returnDirection, request.returnLogisticsNo(), "DELIVERY_RETURN", operatorId, operatorName); for (ExceptionOrderRequests.ReturnItem requestedItem : requested) { @@ -163,11 +192,12 @@ public class ExceptionOrderService { LocalDateTime now = LocalDateTime.now(); delivery.setStatus("RETURN_PENDING"); delivery.setUpdatedAt(now); + delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); deliveryOrders.updateById(delivery); LastlegOrder tail = lastMileOrders.selectById(selected.lastMileOrderId()); if (tail != null) { tail.setAbnormal(true); - tail.setSubOrderStatus("RETURN_PENDING"); + tail.setSubOrderStatus("DELIVERY:RETURN_PENDING"); tail.setUpdatedAt(now); lastMileOrders.updateById(tail); } @@ -222,6 +252,7 @@ public class ExceptionOrderService { ExceptionOrderCommand replay = commands.findByKey(customerId, "COMPLETE", request.idempotencyKey().trim()); if (replay != null) return replayResult(replay, requestHash); ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PROCESSING"); + request.evidenceFileIds().stream().distinct().forEach(fileId -> evidenceFiles.validate(customerId, order.getId(), fileId)); ReturnedInventoryRestoreResponse restored = null; if ("RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) { List stored = returnItems.findByException(customerId, order.getId()); @@ -237,7 +268,7 @@ public class ExceptionOrderService { receipt.outboundPalletItemId(), receipt.inventoryPositionId(), receipt.receivedPackages(), receipt.targetPalletId(), receipt.targetPalletNo(), receipt.targetLocationId(), receipt.targetLocationCode())).toList(), request.idempotencyKey().trim(), - request.evidenceFileIds())); + request.evidenceFileIds().stream().map(String::valueOf).toList())); } else if (request.receipts() != null && !request.receipts().isEmpty()) { throw badRequest("非退回海外仓异常不能提交库存实收明细"); } @@ -318,7 +349,7 @@ public class ExceptionOrderService { value.setExceptionAt(now); value.setCreatedAt(now); value.setUpdatedAt(now); try { exceptionOrders.insert(value); } catch (DuplicateKeyException exception) { throw conflict("该关联单据已存在异常退货单"); } - return new ExceptionOrderResponses.Created(value.getId(), value.getExceptionNo(), value.getStatus(), version(value)); + return new ExceptionOrderResponses.Created(value.getId(), value.getExceptionNo(), value.getStatus(), version(value), false); } private LastlegOrder uniqueTail(String customerId, String orderNo, String inboundCode) { @@ -364,7 +395,7 @@ public class ExceptionOrderService { value.getOutboundNo(), value.getSkuCode(), value.getPackageCount(), value.getReturnDirection(), value.getReturnLogisticsNo(), value.getHandlingAction(), value.getHandlingResult(), value.getHandlerName(), value.getExceptionAt(), value.getCompletedAt(), value.getUpdatedAt(), version(value), - readStringList(value.getEvidenceFileIdsJson()), allowedActions(value)); + readEvidenceFileIds(value.getEvidenceFileIdsJson()), allowedActions(value)); } private ExceptionOrderResponses.Relation withReturnSources(ExceptionOrderResponses.Relation relation, @@ -430,11 +461,13 @@ public class ExceptionOrderService { private void closeReturnedDelivery(ExceptionOrder order, String operatorId, LocalDateTime now) { if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return; - DeliveryOrder delivery = deliveryOrders.findByNo(order.getCustomerId(), order.getDeliveryNo()); + DeliveryOrder delivery = deliveryOrders.lockByNo(order.getCustomerId(), order.getDeliveryNo()); if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) { throw conflict("关联派送单不是退货处理中状态"); } - delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); + delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now); + delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); + deliveryOrders.updateById(delivery); for (LastlegOrder tail : lastMileOrders.selectList(new LambdaQueryWrapper() .eq(LastlegOrder::getCustomerId, order.getCustomerId()) .eq(LastlegOrder::getDeliveryOrderNo, order.getDeliveryNo()))) { @@ -482,7 +515,10 @@ public class ExceptionOrderService { private ExceptionOrderResponses.Created replayCreated(ExceptionOrderCommand command, String requestHash) { requireReplayHash(command, requestHash); - try { return objectMapper.readValue(command.getResultJson(), ExceptionOrderResponses.Created.class); } + try { + ExceptionOrderResponses.Created result = objectMapper.readValue(command.getResultJson(), ExceptionOrderResponses.Created.class); + return new ExceptionOrderResponses.Created(result.id(), result.exceptionNo(), result.status(), result.version(), true); + } catch (JsonProcessingException exception) { throw new IllegalStateException("异常命令结果损坏", exception); } } @@ -500,7 +536,7 @@ public class ExceptionOrderService { catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存异常处理结果", exception); } } - private List readStringList(String value) { + private List readEvidenceFileIds(String value) { if (!StringUtils.hasText(value)) return List.of(); try { return objectMapper.readValue(value, new TypeReference<>() { }); } catch (JsonProcessingException exception) { throw new IllegalStateException("异常处理证据数据损坏", exception); } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 63b2c2a6f..baf8ce0d3 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -282,6 +282,7 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order ( remark VARCHAR(1000) NULL COMMENT '派送备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', + version BIGINT NOT NULL DEFAULT 0 COMMENT '派送单乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), @@ -1919,9 +1920,12 @@ CREATE TABLE IF NOT EXISTS ll_exception_order ( delivery_order_id BIGINT NULL COMMENT '关联派送单ID', delivery_no VARCHAR(64) NULL COMMENT '关联派送单号', outbound_no VARCHAR(64) NULL COMMENT '关联出库单号', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID快照', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', sku_code VARCHAR(128) NULL COMMENT '异常货物SKU', package_count INT NULL COMMENT '异常箱数', return_direction VARCHAR(32) NULL COMMENT '退回方向', return_logistics_no VARCHAR(128) NULL COMMENT '退回物流单号', - handling_action VARCHAR(500) NULL COMMENT '异常处理措施', handler_id VARCHAR(128) NULL COMMENT '处理人ID', handler_name VARCHAR(128) NULL COMMENT '处理人名称', + handling_action VARCHAR(500) NULL COMMENT '异常处理措施', handling_result VARCHAR(2000) NULL COMMENT '异常处理结果', + evidence_file_ids_json JSON NULL COMMENT '异常处理证据文件ID列表', inventory_adjustment_id BIGINT NULL COMMENT '退回库存恢复操作ID', + handler_id VARCHAR(128) NULL COMMENT '处理人ID', handler_name VARCHAR(128) NULL COMMENT '处理人名称', source_type VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '异常来源类型', created_by VARCHAR(128) NULL COMMENT '创建人ID', - exception_at DATETIME(3) NOT NULL COMMENT '异常发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + exception_at DATETIME(3) NOT NULL COMMENT '异常发生时间', completed_at DATETIME(3) NULL COMMENT '异常处理完成时间', + version BIGINT NOT NULL DEFAULT 0 COMMENT '异常订单乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_no(customer_id,exception_no), UNIQUE KEY uk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type), @@ -1929,6 +1933,25 @@ CREATE TABLE IF NOT EXISTS ll_exception_order ( KEY idx_ll_exception_inbound(customer_id,inbound_code) ) ENGINE=InnoDB COMMENT='海外仓异常订单'; +CREATE TABLE IF NOT EXISTS ll_exception_return_item ( + id BIGINT NOT NULL COMMENT '异常退回明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细ID', + inventory_position_id BIGINT NOT NULL COMMENT '原出库库存位置ID', pallet_no VARCHAR(128) NULL COMMENT '原出库托盘码快照', + sku_code VARCHAR(128) NULL COMMENT '原出库SKU快照', max_return_packages BIGINT NOT NULL COMMENT '原出库可退箱数上限', + approved_return_packages BIGINT NOT NULL COMMENT '批准退回箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_return_source(customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id), + KEY idx_ll_exception_return_order(customer_id,exception_order_id) +) ENGINE=InnoDB COMMENT='异常退回原出库库存冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_exception_order_command ( + id BIGINT NOT NULL COMMENT '异常处理命令ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', action VARCHAR(32) NOT NULL COMMENT '异常处理动作', + idempotency_key VARCHAR(255) NOT NULL COMMENT '幂等键', request_hash VARCHAR(64) NOT NULL COMMENT '请求内容SHA256哈希', + result_json JSON NOT NULL COMMENT '命令结果快照', created_at DATETIME(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_command_key(customer_id,action,idempotency_key), + KEY idx_ll_exception_command_order(customer_id,exception_order_id,created_at) +) ENGINE=InnoDB COMMENT='异常处理命令幂等审计'; + CREATE TABLE IF NOT EXISTS ll_customer_visit ( id BIGINT NOT NULL COMMENT '客户回访记录ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', 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 4452d4383..9d99f9384 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -643,6 +643,49 @@ paths: security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { type: object } } } } responses: { '200': { description: 已创建异常订单 } } + /api/platform/v1/last-leg/exception-orders/{exceptionNo}: + get: + summary: 查询异常订单详情与冻结退回明细 + description: 权限 platform:overseas-warehouse:exception-order:list;返回 version 和服务端 allowedActions。 + security: [{ bearerAuth: [] }] + parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 异常订单详情 }, '404': { description: 异常订单不存在 } } + /api/platform/v1/last-leg/exception-orders/{exceptionNo}/start-processing: + post: + summary: 开始处理异常订单 + description: 权限 platform:overseas-warehouse:exception-order:process;仅 PENDING→PROCESSING,同版本与同幂等请求可重放。 + security: [{ bearerAuth: [] }] + parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionStartProcessing' } } } } + responses: { '200': { description: 异常处理命令结果 }, '409': { description: 版本、状态或幂等冲突 } } + /api/platform/v1/last-leg/exception-orders/{exceptionNo}/evidence: + post: + summary: 上传异常处理证据 + description: 权限 platform:overseas-warehouse:exception-order:process;文件按当前客户和异常订单绑定为 OVERSEAS_EXCEPTION_EVIDENCE。 + security: [{ bearerAuth: [] }] + parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }] + requestBody: + required: true + content: + multipart/form-data: + schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } + responses: { '200': { description: 异常处理证据元数据 }, '400': { description: 文件或归属无效 } } + /api/platform/v1/last-leg/exception-orders/{exceptionNo}/complete: + post: + summary: 完成异常处理并原子恢复退回库存 + description: 权限 platform:overseas-warehouse:exception-order:process;仅 PROCESSING→COMPLETED。退回海外仓必须提交证据和实收目标,库存 RETURN_STOCK_IN 成功后才完成异常单和派送退回。 + security: [{ bearerAuth: [] }] + parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionComplete' } } } } + responses: { '200': { description: 异常处理与库存恢复结果 }, '409': { description: 版本、状态、归属、数量或幂等冲突 } } + /api/platform/v1/last-leg/exception-orders/{exceptionNo}/cancel: + post: + summary: 取消未处理异常订单 + description: 权限 platform:overseas-warehouse:exception-order:process;仅 PENDING→CANCELLED。 + security: [{ bearerAuth: [] }] + parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionCancel' } } } } + responses: { '200': { description: 异常取消结果 }, '409': { description: 版本、状态或幂等冲突 } } /api/platform/v1/last-leg/exception-orders/resolve: get: summary: 解析异常订单关联关系 @@ -652,17 +695,17 @@ paths: /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/return-inbound-options: get: summary: 查询派送退回候选入仓单 - description: 权限 platform:overseas-warehouse:exception-order:list;属于异常订单列表只读辅助接口。 + description: 权限 platform:overseas-warehouse:exception-order:list;返回按原出库拣货事实解析的 returnSources,页面不得自由填写 SKU 或猜测库存位置。 security: [{ bearerAuth: [] }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 退回候选入仓单 } } /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/mark-return: post: summary: 将派送单标记为退回入仓 - description: 权限 platform:overseas-warehouse:delivery:return。 + description: 权限 platform:overseas-warehouse:delivery:return;items 必须来自 return-inbound-options,服务端冻结源出库明细与批准退回上限。 security: [{ bearerAuth: [] }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] - requestBody: { required: true, content: { application/json: { schema: { type: object } } } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryMarkReturn' } } } } responses: { '200': { description: 已创建退回异常单 } } /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/complete: post: @@ -670,7 +713,8 @@ paths: description: 权限 platform:overseas-warehouse:delivery:status。仅 WAIT_DELIVERY/DELIVERING 可完成;DELIVERED 同状态重放成功,其他终态或倒退返回 409。 security: [{ bearerAuth: [] }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: 派送状态与回访记录 } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryStatusChange' } } } } + responses: { '200': { description: 派送状态与回访记录 }, '409': { description: 版本或状态冲突 } } /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/status: put: summary: 更新派送状态 @@ -1709,14 +1753,74 @@ components: fees: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/DeliveryFeeInput' } } DeliveryStatusChange: type: object - required: [status] + required: [status, expectedVersion] properties: status: { type: string, enum: [WAIT_DELIVERY, DELIVERING, DELIVERED, REJECTED, CANCELLED] } + expectedVersion: { type: integer, format: int64, minimum: 0 } remark: { type: string, nullable: true, maxLength: 500 } DeliveryAllowedAction: type: string enum: [START_DELIVERY, COMPLETE_DELIVERY, REJECT_DELIVERY, CANCEL_DELIVERY, MARK_RETURN] description: START/COMPLETE/REJECT/CANCEL 使用通用 status 接口;MARK_RETURN 使用 mark-return 接口。 + DeliveryMarkReturn: + type: object + required: [inboundOrderId, reason, returnDirection, items, idempotencyKey] + properties: + inboundOrderId: { type: string, pattern: '^[1-9][0-9]*$' } + reason: { type: string, minLength: 1, maxLength: 1000 } + returnDirection: { type: string, enum: [OVERSEAS_WAREHOUSE, CUSTOMER, OTHER] } + returnLogisticsNo: { type: string, nullable: true, maxLength: 128 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + items: + type: array + minItems: 1 + maxItems: 200 + items: + type: object + required: [outboundPalletItemId, inventoryPositionId, packageCount] + properties: + outboundPalletItemId: { type: string, pattern: '^[1-9][0-9]*$' } + inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' } + packageCount: { type: integer, format: int64, minimum: 1 } + ExceptionStartProcessing: + type: object + required: [expectedVersion, idempotencyKey] + properties: + expectedVersion: { type: integer, format: int64, minimum: 0 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + handlingAction: { type: string, nullable: true, maxLength: 1000 } + ExceptionCancel: + type: object + required: [expectedVersion, idempotencyKey, reason] + properties: + expectedVersion: { type: integer, format: int64, minimum: 0 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + reason: { type: string, minLength: 1, maxLength: 1000 } + ExceptionComplete: + type: object + required: [expectedVersion, idempotencyKey, handlingResult, evidenceFileIds] + properties: + expectedVersion: { type: integer, format: int64, minimum: 0 } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + handlingResult: { type: string, minLength: 1, maxLength: 2000 } + evidenceFileIds: { type: array, minItems: 1, maxItems: 20, items: { type: string } } + receipts: + type: array + maxItems: 200 + items: + type: object + required: [outboundPalletItemId, inventoryPositionId, receivedPackages, targetPalletId, targetPalletNo, targetLocationId, targetLocationCode] + properties: + outboundPalletItemId: { type: string, pattern: '^[1-9][0-9]*$' } + inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' } + receivedPackages: { type: integer, format: int64, minimum: 1 } + targetPalletId: { type: string, pattern: '^[1-9][0-9]*$' } + targetPalletNo: { type: string, minLength: 1, maxLength: 128 } + targetLocationId: { type: string, pattern: '^[1-9][0-9]*$' } + targetLocationCode: { type: string, minLength: 1, maxLength: 128 } + ExceptionAllowedAction: + type: string + enum: [START_PROCESSING, COMPLETE_EXCEPTION, CANCEL_EXCEPTION] DeliveryFeeInput: type: object required: [feeType, currency, amount] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java index 0613add15..a2bb0717d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java @@ -6,6 +6,8 @@ import com.baiyue.lastleg.customervisit.mapper.CustomerVisitMapper; import com.baiyue.lastleg.delivery.entity.DeliveryOrder; import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +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; @@ -26,6 +28,7 @@ class CustomerVisitServiceTest { @Mock private CustomerVisitMapper visits; @Mock private DeliveryOrderMapper deliveryOrders; @Mock private DeliveryOperationMapper deliveryOperations; + @Mock private LastlegOrderMapper lastMileOrders; private CustomerVisitService service; @BeforeEach @@ -34,15 +37,16 @@ class CustomerVisitServiceTest { ReflectionTestUtils.setField(service, "visits", visits); ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders); ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations); + ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders); } @Test void rejectsRollbackFromDeliveredToWaitDeliveryWithoutWritingHistory() { DeliveryOrder delivery = delivery("DELIVERED"); - when(deliveryOrders.findByNo("1001", "DL-1")).thenReturn(delivery); + when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1", - new CustomerVisitRequests.DeliveryStatus("WAIT_DELIVERY", "错误倒退"))) + new CustomerVisitRequests.DeliveryStatus("WAIT_DELIVERY", 0L, "错误倒退"))) .isInstanceOf(ResponseStatusException.class) .satisfies(error -> assertThat(((ResponseStatusException) error).getStatusCode().value()).isEqualTo(409)) .hasMessageContaining("DELIVERED").hasMessageContaining("WAIT_DELIVERY"); @@ -57,11 +61,11 @@ class CustomerVisitServiceTest { CustomerVisit visit = new CustomerVisit(); visit.setId(33L); visit.setStatus("PENDING"); - when(deliveryOrders.findByNo("1001", "DL-1")).thenReturn(delivery); + when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); when(visits.findByDelivery("1001", 11L)).thenReturn(visit); var result = service.updateDeliveryStatus("1001", "9001", "DL-1", - new CustomerVisitRequests.DeliveryStatus("DELIVERED", null)); + new CustomerVisitRequests.DeliveryStatus("DELIVERED", 0L, null)); assertThat(result.replayed()).isTrue(); assertThat(result.deliveryStatus()).isEqualTo("DELIVERED"); @@ -70,6 +74,39 @@ class CustomerVisitServiceTest { verifyNoInteractions(deliveryOperations); } + @Test + void rejectsForwardTransitionWithStaleVersion() { + DeliveryOrder delivery = delivery("WAIT_DELIVERY"); + delivery.setVersion(3L); + when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); + + assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1", + new CustomerVisitRequests.DeliveryStatus("DELIVERING", 2L, null))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本"); + + verify(deliveryOrders, never()).updateById(delivery); + verifyNoInteractions(deliveryOperations); + } + + @Test + void completingDeliveryUpdatesOnlySubOrderStatusAndKeepsMainFlowStatus() { + DeliveryOrder delivery = delivery("DELIVERING"); + delivery.setVersion(1L); + LastlegOrder tail = new LastlegOrder(); + tail.setId(21L); tail.setCustomerId("1001"); tail.setCurrentStatus("INBOUND_COMPLETED"); + CustomerVisit visit = new CustomerVisit(); visit.setId(33L); visit.setStatus("PENDING"); + when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); + when(lastMileOrders.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(java.util.List.of(tail)); + when(visits.findByDelivery("1001", 11L)).thenReturn(visit); + + var result = service.updateDeliveryStatus("1001", "9001", "DL-1", + new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null)); + + assertThat(result.replayed()).isFalse(); + assertThat(tail.getCurrentStatus()).isEqualTo("INBOUND_COMPLETED"); + assertThat(tail.getSubOrderStatus()).isEqualTo("DELIVERY:DELIVERED"); + } + private DeliveryOrder delivery(String status) { DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(11L); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java index 105b3ac0a..a7a4680fe 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java @@ -66,6 +66,6 @@ class DeliveryLongIdJsonContractTest { DELIVERY_ID, null, null, "PS-1", null, null, null, null, "CREATED", null, null, null, null, null, null, null, null, null, null, null, null, - 0, 0, null, null, null, null, null, null, null, null, java.util.List.of()); + 0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of()); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java index 3ea9fffe3..69dc21cf3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java @@ -8,7 +8,7 @@ class DeliveryStatusPolicyTest { @Test void exposesOnlyServerAuthorizedActionsForEachState() { assertThat(DeliveryStatusPolicy.allowedActions("WAIT_DELIVERY")) - .containsExactly("START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY", "MARK_RETURN"); + .containsExactly("START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY"); assertThat(DeliveryStatusPolicy.allowedActions("DELIVERING")) .containsExactly("COMPLETE_DELIVERY", "REJECT_DELIVERY", "MARK_RETURN"); assertThat(DeliveryStatusPolicy.allowedActions("DELIVERED")).containsExactly("MARK_RETURN"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java new file mode 100644 index 000000000..5e828e45f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java @@ -0,0 +1,159 @@ +package com.baiyue.lastleg.exceptionorder.service; + +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests; +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem; +import com.baiyue.lastleg.exceptionorder.gateway.ExceptionEvidenceFileGateway; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderCommandMapper; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper; +import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest; +import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +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 org.springframework.web.server.ResponseStatusException; + +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.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.time.LocalDateTime; +import java.util.List; + +@ExtendWith(MockitoExtension.class) +class ExceptionOrderLifecycleTest { + @Mock private ExceptionOrderMapper exceptionOrders; + @Mock private ExceptionOrderCommandMapper commands; + @Mock private ExceptionReturnItemMapper returnItems; + @Mock private InventoryCommandService inventoryCommands; + @Mock private ExceptionEvidenceFileGateway evidenceFiles; + @Mock private DeliveryOrderMapper deliveryOrders; + @Mock private DeliveryOperationMapper deliveryOperations; + @Mock private LastlegOrderMapper lastMileOrders; + private ExceptionOrderService service; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + service = new ExceptionOrderService(); + objectMapper = new ObjectMapper().findAndRegisterModules(); + ReflectionTestUtils.setField(service, "exceptionOrders", exceptionOrders); + ReflectionTestUtils.setField(service, "commands", commands); + ReflectionTestUtils.setField(service, "returnItems", returnItems); + ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands); + ReflectionTestUtils.setField(service, "evidenceFiles", evidenceFiles); + ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders); + ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations); + ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + } + + @Test + void startsPendingExceptionWithVersionGateAndPersistsIdempotentResult() { + ExceptionOrder order = order("PENDING", 0); + when(commands.findByKey("1001", "START_PROCESSING", "start-1")).thenReturn(null); + when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order); + + var result = service.startProcessing("1001", "9001", "EX-1", + new ExceptionOrderRequests.StartProcessing(0, "start-1", "联系客户并核对退件")); + + assertThat(result.status()).isEqualTo("PROCESSING"); + assertThat(result.version()).isEqualTo(1); + assertThat(result.replayed()).isFalse(); + verify(exceptionOrders).updateById(order); + ArgumentCaptor command = ArgumentCaptor.forClass(ExceptionOrderCommand.class); + verify(commands).insert(command.capture()); + assertThat(command.getValue().getResultJson()).contains("PROCESSING"); + } + + @Test + void rejectsStaleVersionWithoutMutation() { + when(commands.findByKey("1001", "START_PROCESSING", "start-2")).thenReturn(null); + when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order("PENDING", 2)); + + assertThatThrownBy(() -> service.startProcessing("1001", "9001", "EX-1", + new ExceptionOrderRequests.StartProcessing(1, "start-2", null))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本"); + + verify(exceptionOrders, never()).updateById(any(ExceptionOrder.class)); + verify(commands, never()).insert(any(ExceptionOrderCommand.class)); + } + + @Test + void replaysSameCommandWithoutUpdatingAgain() throws Exception { + ExceptionOrderResponses.CommandResult saved = new ExceptionOrderResponses.CommandResult( + 1L, "EX-1", "PROCESSING", 1, null, 0, null, false); + ExceptionOrderRequests.StartProcessing request = + new ExceptionOrderRequests.StartProcessing(0, "start-3", null); + ExceptionOrderCommand command = new ExceptionOrderCommand(); + command.setRequestHash(hashFor(request)); + command.setResultJson(objectMapper.writeValueAsString(saved)); + when(commands.findByKey("1001", "START_PROCESSING", "start-3")).thenReturn(command); + + var replayed = service.startProcessing("1001", "9001", "EX-1", request); + + assertThat(replayed.replayed()).isTrue(); + assertThat(replayed.status()).isEqualTo("PROCESSING"); + verify(exceptionOrders, never()).lockByNo(any(), any()); + } + + @Test + void completesWarehouseReturnOnlyAfterInventoryRestoreSucceeds() { + ExceptionOrder order = order("PROCESSING", 1); + order.setExceptionType("RETURN"); order.setReturnDirection("OVERSEAS_WAREHOUSE"); + order.setOutboundNo("OUT-1"); order.setOverseasWarehouseId("WH-1"); order.setInboundOrderId(44L); + order.setDeliveryNo("DL-1"); + ExceptionReturnItem source = new ExceptionReturnItem(); + source.setOutboundPalletItemId(51L); source.setInventoryPositionId(61L); + source.setSkuCode("SKU-1"); source.setApprovedReturnPackages(2L); + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(71L); delivery.setCustomerId("1001"); delivery.setDeliveryNo("DL-1"); + delivery.setStatus("RETURN_PENDING"); delivery.setVersion(2L); + LocalDateTime completedAt = LocalDateTime.of(2026, 9, 8, 18, 30); + when(commands.findByKey("1001", "COMPLETE", "complete-1")).thenReturn(null); + when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order); + when(returnItems.findByException("1001", 1L)).thenReturn(List.of(source)); + when(inventoryCommands.restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class))) + .thenReturn(new ReturnedInventoryRestoreResponse(81L, List.of(91L), 0, 2, 2, false, completedAt)); + when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); + when(lastMileOrders.selectList(any())).thenReturn(List.of()); + ExceptionOrderRequests.Complete request = new ExceptionOrderRequests.Complete(1, "complete-1", "已退回入仓", + List.of(101L), List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, 2, 201L, + "PLT-201", 301L, "LOC-301"))); + + var result = service.complete("1001", "9001", "EX-1", request); + + assertThat(result.status()).isEqualTo("COMPLETED"); + assertThat(result.inventoryAdjustmentId()).isEqualTo(81L); + assertThat(result.restoredPackages()).isEqualTo(2); + assertThat(delivery.getStatus()).isEqualTo("RETURNED"); + verify(evidenceFiles).validate("1001", 1L, 101L); + verify(inventoryCommands).restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class)); + } + + private String hashFor(Object request) { + return (String) ReflectionTestUtils.invokeMethod(service, "hash", request); + } + + private ExceptionOrder order(String status, long version) { + ExceptionOrder order = new ExceptionOrder(); + order.setId(1L); order.setCustomerId("1001"); order.setExceptionNo("EX-1"); + order.setStatus(status); order.setVersion(version); + return order; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/Phase5AdminPermissionContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/Phase5AdminPermissionContractTest.java index ae844159c..7566c3960 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/Phase5AdminPermissionContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/Phase5AdminPermissionContractTest.java @@ -24,6 +24,11 @@ class Phase5AdminPermissionContractTest { assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("page")); assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("resolve")); assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_CREATE), permissions.get("create")); + assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("detail")); + assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("uploadEvidence")); + assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("startProcessing")); + assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("complete")); + assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("cancel")); assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("returnOptions")); assertEquals(authority(OverseasPermissions.DELIVERY_RETURN), permissions.get("markReturn")); } @@ -49,6 +54,7 @@ class Phase5AdminPermissionContractTest { OverseasPermissions.DISPATCH_LIST, OverseasPermissions.EXCEPTION_ORDER_LIST, OverseasPermissions.EXCEPTION_ORDER_CREATE, + OverseasPermissions.EXCEPTION_ORDER_PROCESS, OverseasPermissions.DELIVERY_RETURN, OverseasPermissions.DELIVERY_STATUS, OverseasPermissions.CUSTOMER_VISIT_LIST, @@ -59,6 +65,10 @@ class Phase5AdminPermissionContractTest { for (String path : new String[] { "/api/platform/v1/last-leg/dispatches:", "/api/platform/v1/last-leg/exception-orders:", + "/api/platform/v1/last-leg/exception-orders/{exceptionNo}:", + "/api/platform/v1/last-leg/exception-orders/{exceptionNo}/start-processing:", + "/api/platform/v1/last-leg/exception-orders/{exceptionNo}/complete:", + "/api/platform/v1/last-leg/exception-orders/{exceptionNo}/cancel:", "/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/mark-return:", "/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/status:", "/api/platform/v1/last-leg/customer-visits:", From 75e67fa980bd84a86927cc5a97a85c824c982da9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 18:51:22 +0800 Subject: [PATCH 50/56] feat(iam): add exception order process permission --- .../db/phase5-delivery-permissions.sql | 18 +++++++++--- iam/src/main/resources/openapi/iam-v1.yaml | 2 +- .../controller/IamOpenApiContractTest.java | 1 + ...ase5DeliveryPermissionSqlContractTest.java | 8 ++++-- ...se5DeliveryPermissionSqlExecutionTest.java | 28 +++++++++---------- 5 files changed, 36 insertions(+), 21 deletions(-) diff --git a/iam/src/main/resources/db/phase5-delivery-permissions.sql b/iam/src/main/resources/db/phase5-delivery-permissions.sql index 10a22894b..a39efd4a3 100644 --- a/iam/src/main/resources/db/phase5-delivery-permissions.sql +++ b/iam/src/main/resources/db/phase5-delivery-permissions.sql @@ -18,6 +18,7 @@ BEGIN OR (id = 5107093 AND permission_code <> 'platform:overseas-warehouse:outbound:cancel') OR (id = 5107070 AND permission_code <> 'platform:overseas-warehouse:exception-order:list') OR (id = 5107071 AND permission_code <> 'platform:overseas-warehouse:exception-order:create') + OR (id = 5107073 AND permission_code <> 'platform:overseas-warehouse:exception-order:process') OR (id = 5107072 AND permission_code <> 'platform:overseas-warehouse:delivery:return') OR (id = 5107080 AND permission_code <> 'platform:overseas-warehouse:customer-visit:list') OR (id = 5107081 AND permission_code <> 'platform:overseas-warehouse:customer-visit:edit') @@ -36,6 +37,7 @@ BEGIN OR (permission_code = 'platform:overseas-warehouse:outbound:cancel' AND id <> 5107093) OR (permission_code = 'platform:overseas-warehouse:exception-order:list' AND id <> 5107070) OR (permission_code = 'platform:overseas-warehouse:exception-order:create' AND id <> 5107071) + OR (permission_code = 'platform:overseas-warehouse:exception-order:process' AND id <> 5107073) OR (permission_code = 'platform:overseas-warehouse:delivery:return' AND id <> 5107072) OR (permission_code = 'platform:overseas-warehouse:customer-visit:list' AND id <> 5107080) OR (permission_code = 'platform:overseas-warehouse:customer-visit:edit' AND id <> 5107081) @@ -60,6 +62,7 @@ VALUES (5107018,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:transfer-delivery','排车转派送',NULL,'POST',718,'ACTIVE',1,0), (5107070,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET',770,'ACTIVE',1,0), (5107071,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST',771,'ACTIVE',1,0), + (5107073,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:process','处理海外异常订单',NULL,'POST',773,'ACTIVE',1,0), (5107072,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST',772,'ACTIVE',1,0), (5107080,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:customer-visit:list','客户回访','/overseas-warehouse/customer-visits','GET',780,'ACTIVE',1,0), (5107081,0,'PLATFORM','BUTTON',5107080,'platform:overseas-warehouse:customer-visit:edit','保存客户回访',NULL,'PUT',781,'ACTIVE',1,0), @@ -95,6 +98,7 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) 'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:customer-visit:list', @@ -119,6 +123,7 @@ INSERT INTO tmp_phase5_delivery_role_permission (role_code, permission_code) VAL ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:list'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:list'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:create'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:process'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:return'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:status'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:list'), @@ -147,6 +152,7 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM' 'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:customer-visit:list', @@ -194,12 +200,13 @@ BEGIN 'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:edit', 'merchant:warehouse:delivery:list', - 'merchant:warehouse:delivery:create')) <> 16 THEN + 'merchant:warehouse:delivery:create')) <> 17 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整'; END IF; IF (SELECT COUNT(*) FROM iam_role_permission grant_row @@ -217,10 +224,11 @@ BEGIN 'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:customer-visit:list', - 'platform:overseas-warehouse:customer-visit:edit')) <> 14 THEN + 'platform:overseas-warehouse:customer-visit:edit')) <> 15 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整'; END IF; IF EXISTS (SELECT 1 FROM iam_role role @@ -241,17 +249,18 @@ BEGIN 'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:customer-visit:list', - 'platform:overseas-warehouse:customer-visit:edit')) <> 14) THEN + 'platform:overseas-warehouse:customer-visit:edit')) <> 15) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台自定义管理员授权不完整'; END IF; SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0), COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0) INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count FROM tmp_phase5_delivery_role_permission; - IF v_warehouse_operator_permission_count <> 14 + IF v_warehouse_operator_permission_count <> 15 OR v_warehouse_tally_permission_count <> 5 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常'; END IF; @@ -270,6 +279,7 @@ BEGIN 'platform:overseas-warehouse:delivery:list', 'platform:overseas-warehouse:exception-order:list', 'platform:overseas-warehouse:exception-order:create', + 'platform:overseas-warehouse:exception-order:process', 'platform:overseas-warehouse:delivery:return', 'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:customer-visit:list', diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index efd7ce318..1260e76f6 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -124,7 +124,7 @@ paths: get: tags: [IAM Resources / IAM 资源] summary: 查询当前用户有效权限 - description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。列表权限包含详情和只读辅助接口,写操作独立授权。 + description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。列表权限包含详情和只读辅助接口,写操作独立授权。 responses: '200': description: 当前平台成员权限查询成功 diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index 18d1e3192..b7a4f5438 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -31,6 +31,7 @@ class IamOpenApiContractTest { .contains("platform:overseas-warehouse:delivery:status") .contains("platform:overseas-warehouse:exception-order:list") .contains("platform:overseas-warehouse:exception-order:create") + .contains("platform:overseas-warehouse:exception-order:process") .contains("platform:overseas-warehouse:customer-visit:list") .contains("platform:overseas-warehouse:customer-visit:edit") .contains("platform:overseas-warehouse:outbound:cancel") diff --git a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java index 894c6e748..f76aa3eaf 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java @@ -25,6 +25,7 @@ class Phase5DeliveryPermissionSqlContractTest { "platform:overseas-warehouse:delivery:list", "platform:overseas-warehouse:exception-order:list", "platform:overseas-warehouse:exception-order:create", + "platform:overseas-warehouse:exception-order:process", "platform:overseas-warehouse:delivery:return", "platform:overseas-warehouse:delivery:status", "platform:overseas-warehouse:customer-visit:list", @@ -54,6 +55,7 @@ class Phase5DeliveryPermissionSqlContractTest { "'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'DELETE'", "'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET'", "'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST'", + "'platform:overseas-warehouse:exception-order:process','处理海外异常订单',NULL,'POST'", "'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST'", "'platform:overseas-warehouse:delivery:status','编辑派送单状态',NULL,'PUT'", "'platform:overseas-warehouse:customer-visit:list','客户回访','/overseas-warehouse/customer-visits','GET'", @@ -75,6 +77,7 @@ class Phase5DeliveryPermissionSqlContractTest { "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:edit')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:create')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:create')", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:process')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:return')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:status')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:edit')", @@ -89,6 +92,7 @@ class Phase5DeliveryPermissionSqlContractTest { "('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:edit')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:create')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:exception-order:create')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:exception-order:process')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:return')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:status')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:customer-visit:edit')", @@ -106,7 +110,7 @@ class Phase5DeliveryPermissionSqlContractTest { "SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0)", "COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0)", "INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count", - "IF v_warehouse_operator_permission_count <> 14", + "IF v_warehouse_operator_permission_count <> 15", "OR v_warehouse_tally_permission_count <> 5 THEN", "IF EXISTS (SELECT 1 FROM iam_role role", "AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'", @@ -160,7 +164,7 @@ class Phase5DeliveryPermissionSqlContractTest { "海外排车、出库与派送权限", "测试和部署只执行固定入口,不再单独执行模块脚本", "排车列表/编辑/取消/转派送", - "异常订单列表/创建和客户回访列表/编辑十四项权限", + "异常订单列表/创建/处理和客户回访列表/编辑十五项权限", "merchant:warehouse:delivery:list", "merchant:warehouse:delivery:create", "PDA 岗位不得获得上述后台权限"); diff --git a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java index 0e7944937..c3a8217d6 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java @@ -37,12 +37,12 @@ class Phase5DeliveryPermissionSqlExecutionTest { assertThat(customAdministratorViolations(connection)).isEqualTo(1); executeGrant(connection); - assertThat(grantCount(connection, 11L)).isEqualTo(14); + assertThat(grantCount(connection, 11L)).isEqualTo(15); assertThat(customAdministratorViolations(connection)).isZero(); executeGrant(connection); - assertThat(grantCount(connection, 11L)).isEqualTo(14); - assertThat(totalGrantCount(connection)).isEqualTo(14); + assertThat(grantCount(connection, 11L)).isEqualTo(15); + assertThat(totalGrantCount(connection)).isEqualTo(15); } } @@ -51,14 +51,14 @@ class Phase5DeliveryPermissionSqlExecutionTest { try (Connection connection = database()) { insertRole(connection, 21L, "PLATFORM_CUSTOM_ADMIN"); insertRole(connection, 22L, "Platform_Custom_Admin"); - grantFirstPermissions(connection, 21L, 14); - grantFirstPermissions(connection, 22L, 13); + grantFirstPermissions(connection, 21L, 15); + grantFirstPermissions(connection, 22L, 14); assertThat(customAdministratorViolations(connection)).isEqualTo(1); executeGrant(connection); - assertThat(grantCount(connection, 21L)).isEqualTo(14); - assertThat(grantCount(connection, 22L)).isEqualTo(14); + assertThat(grantCount(connection, 21L)).isEqualTo(15); + assertThat(grantCount(connection, 22L)).isEqualTo(15); assertThat(customAdministratorViolations(connection)).isZero(); } } @@ -68,16 +68,16 @@ class Phase5DeliveryPermissionSqlExecutionTest { try (Connection connection = database()) { insertRole(connection, 31L, "PLATFORM_CUSTOM_ADMIN"); insertRole(connection, 32L, "platform_custom_admin"); - grantFirstPermissions(connection, 31L, 14); - grantFirstPermissions(connection, 32L, 14); + grantFirstPermissions(connection, 31L, 15); + grantFirstPermissions(connection, 32L, 15); assertThat(customAdministratorViolations(connection)).isZero(); executeGrant(connection); executeGrant(connection); - assertThat(grantCount(connection, 31L)).isEqualTo(14); - assertThat(grantCount(connection, 32L)).isEqualTo(14); - assertThat(totalGrantCount(connection)).isEqualTo(28); + assertThat(grantCount(connection, 31L)).isEqualTo(15); + assertThat(grantCount(connection, 32L)).isEqualTo(15); + assertThat(totalGrantCount(connection)).isEqualTo(30); } } @@ -182,7 +182,7 @@ class Phase5DeliveryPermissionSqlExecutionTest { AND (SELECT COUNT(*) FROM iam_role_permission grant_row JOIN iam_permission permission ON permission.id=grant_row.permission_id WHERE grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id - AND permission.permission_code IN (%s)) <> 14 + AND permission.permission_code IN (%s)) <> 15 """.formatted(placeholders); try (PreparedStatement query = connection.prepareStatement(sql)) { int index = 1; @@ -209,7 +209,7 @@ class Phase5DeliveryPermissionSqlExecutionTest { LinkedHashSet codes = new LinkedHashSet<>(); String grant = administratorGrantSql(); PLATFORM_CODE.matcher(grant).results().map(result -> result.group()).forEach(codes::add); - assertThat(codes).hasSize(14); + assertThat(codes).hasSize(15); return codes; } From b0c81e337d16a015aa650df71c666dc89fcde3a6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 19:09:08 +0800 Subject: [PATCH 51/56] feat(firstleg): expose batch carrier actual facts --- ...latformCarrierScheduleQueryController.java | 14 ++++++ .../dto/CarrierScheduleQueryDtos.java | 11 +++++ .../mapper/CarrierScheduleQueryMapper.java | 28 ++++++++++++ .../CarrierScheduleQueryRepository.java | 4 ++ .../service/CarrierScheduleQueryService.java | 14 ++++++ .../openapi/carrier-schedule-query-v1.yaml | 43 +++++++++++++++++++ ...rrierScheduleQueryOpenApiContractTest.java | 12 ++++++ ...erScheduleQueryControllerContractTest.java | 1 + .../CarrierScheduleQuerySqlContractTest.java | 25 +++++++++++ .../CarrierScheduleQueryServiceTest.java | 30 +++++++++++++ 10 files changed, 182 insertions(+) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java index 4b76ab3a0..de6190798 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java @@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; @@ -19,6 +20,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; @@ -28,6 +30,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; @@ -47,6 +50,17 @@ public class PlatformCarrierScheduleQueryController { return ApiResponse.success(service.carriers()); } + @Operation(summary = "按货柜批量查询实际船节点摘要", + description = "最多200柜;仅返回实际节点时间,不返回或推断预计时间;没有船节点任务的货柜不返回行。") + @GetMapping("/container-actual-facts") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") + public ApiResponse> containerActualFacts( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam("containerBookingIds") @Size(min = 1, max = 200) List<@Positive Long> containerBookingIds) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.containerActualFacts(containerBookingIds)); + } + @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java index 6ff80c834..0bbfb2713 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java @@ -80,6 +80,17 @@ public final class CarrierScheduleQueryDtos { public record QueryView(Task query, boolean expired) {} + /** Actual-only summary used by container workbenches; estimated facts are intentionally excluded. */ + public record ContainerActualFacts(Long containerBookingId, Long queryId, Integer queryVersion, + String queryStatus, String latestActualNode, + LocalDateTime latestActualAt, + LocalDateTime departedWarehouseAt, + LocalDateTime departedOriginPortAt, + LocalDateTime arrivedDestinationPortAt, + LocalDateTime pickupReadyAt, + LocalDateTime pickedUpAt, + LocalDateTime emptyReturnedAt) {} + public record NodeFact(Long id, String providerCode, String providerEventId, String providerNodeCode, String standardNode, int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java index 274bff0d6..283efa2e0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.portschedule.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -46,6 +47,33 @@ public interface CarrierScheduleQueryMapper extends BaseMapper + SELECT q.container_booking_id AS containerBookingId,q.id AS queryId,q.version AS queryVersion, + q.status AS queryStatus,q.latest_standard_node AS latestActualNode, + q.latest_node_observed_at AS latestActualAt, + MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE' THEN f.actual_at END) AS departedWarehouseAt, + MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) AS departedOriginPortAt, + MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT' THEN f.actual_at END) AS arrivedDestinationPortAt, + MAX(CASE WHEN f.standard_node='PICKUP_READY' THEN f.actual_at END) AS pickupReadyAt, + MAX(CASE WHEN f.standard_node='PICKED_UP' THEN f.actual_at END) AS pickedUpAt, + MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt + FROM fl_carrier_schedule_query q + JOIN ( + SELECT container_booking_id,MAX(id) AS query_id + FROM fl_carrier_schedule_query + WHERE container_booking_id IN + #{id} + GROUP BY container_booking_id + ) latest ON latest.query_id=q.id + LEFT JOIN fl_carrier_schedule_node_fact f ON f.query_id=q.id AND f.actual_at IS NOT NULL + GROUP BY q.container_booking_id,q.id,q.version,q.status,q.latest_standard_node,q.latest_node_observed_at + ORDER BY q.container_booking_id + + """) + List selectContainerActualFacts( + @Param("containerBookingIds") List containerBookingIds); + @Select(""" SELECT * FROM fl_carrier_schedule_query WHERE status IN ('READY','MANUAL_REQUIRED') diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java index 7df760374..c83cb6239 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQueryRepository.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity; @@ -40,6 +41,9 @@ public class CarrierScheduleQueryRepository { public CarrierScheduleQueryEntity findByBusinessScope(long customerId, long oceanBookingId, long containerBookingId) { return queries.findByBusinessScope(customerId, oceanBookingId, containerBookingId); } + public List containerActualFacts(List containerBookingIds) { + return queries.selectContainerActualFacts(containerBookingIds); + } public List due(LocalDateTime now, int limit) { return queries.selectDue(now, limit); } public boolean claimLease(long id, String owner, LocalDateTime now, LocalDateTime leaseUntil) { return queries.claimLease(id, owner, now, leaseUntil) == 1; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java index 9a0bc0916..308f71969 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java @@ -7,6 +7,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.event.ContainerNumbers; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; @@ -48,6 +49,7 @@ import java.time.Clock; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.HexFormat; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -76,6 +78,18 @@ public class CarrierScheduleQueryService { source.page(), source.size(), source.total(), source.pages()); } + public List containerActualFacts(List containerBookingIds) { + if (containerBookingIds == null || containerBookingIds.isEmpty() || containerBookingIds.size() > 200) { + throw badRequest("containerBookingIds数量必须在1到200之间"); + } + Set unique = new HashSet<>(); + for (Long id : containerBookingIds) { + if (id == null || id <= 0) throw badRequest("containerBookingIds必须全部为正数"); + if (!unique.add(id)) throw badRequest("containerBookingIds不能包含重复值"); + } + return repository.containerActualFacts(containerBookingIds); + } + public Detail detail(long id) { return detail(required(id)); } public List history(long id) { diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index bbc2d4d3f..37dea7a29 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -10,6 +10,25 @@ paths: x-permission: platform:firstleg:port-schedule-query:list responses: '200': {description: 'MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors'} + /api/platform/v1/carrier-schedule-queries/container-actual-facts: + get: + summary: Batch query actual carrier-node facts for container workbenches + description: Returns at most one row for each container that has a carrier-schedule query. Missing rows mean that no query exists. Estimated facts are never returned or inferred. + x-permission: platform:firstleg:port-schedule-query:list + parameters: + - name: containerBookingIds + in: query + required: true + schema: + type: array + minItems: 1 + maxItems: 200 + uniqueItems: true + items: {type: integer, format: int64, minimum: 1} + responses: + '200': + description: Actual-only summaries ordered by containerBookingId + content: {application/json: {schema: {$ref: '#/components/schemas/ContainerActualFactsListEnvelope'}}} /api/platform/v1/carrier-schedule-queries: get: summary: Query carrier schedule tasks @@ -175,6 +194,30 @@ components: properties: query: {$ref: '#/components/schemas/Task'} expired: {type: boolean} + ContainerActualFacts: + type: object + required: [containerBookingId, queryId, queryVersion, queryStatus] + description: Actual facts only. All node times are null until an immutable fact with actualAt exists; estimatedAt is intentionally absent. + properties: + containerBookingId: {type: integer, format: int64} + queryId: {type: integer, format: int64} + queryVersion: {type: integer, minimum: 0} + queryStatus: {$ref: '#/components/schemas/QueryStatus'} + latestActualNode: {allOf: [{$ref: '#/components/schemas/StandardNode'}], nullable: true} + latestActualAt: {type: string, format: date-time, nullable: true} + departedWarehouseAt: {type: string, format: date-time, nullable: true} + departedOriginPortAt: {type: string, format: date-time, nullable: true} + arrivedDestinationPortAt: {type: string, format: date-time, nullable: true} + pickupReadyAt: {type: string, format: date-time, nullable: true} + pickedUpAt: {type: string, format: date-time, nullable: true} + emptyReturnedAt: {type: string, format: date-time, nullable: true} + ContainerActualFactsListEnvelope: + type: object + required: [code, data] + properties: + code: {type: string, example: '200'} + message: {type: string, nullable: true} + data: {type: array, items: {$ref: '#/components/schemas/ContainerActualFacts'}} NodeFact: type: object required: [id, providerCode, providerEventId, providerNodeCode, standardNode, nodeSequence, observedAt, rawPayloadHash, createdAt] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java index 581673ddc..56915fc93 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java @@ -14,6 +14,18 @@ class CarrierScheduleQueryOpenApiContractTest { .getContentAsString(StandardCharsets.UTF_8); assertThat(yaml) .contains("/api/platform/v1/carrier-schedule-queries:") + .contains("/api/platform/v1/carrier-schedule-queries/container-actual-facts:") + .contains("name: containerBookingIds") + .contains("maxItems: 200") + .contains("uniqueItems: true") + .contains("ContainerActualFacts:") + .contains("departedWarehouseAt:") + .contains("departedOriginPortAt:") + .contains("arrivedDestinationPortAt:") + .contains("pickupReadyAt:") + .contains("pickedUpAt:") + .contains("emptyReturnedAt:") + .contains("Estimated facts are never returned or inferred") .contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:") .contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes:") .contains("x-permission: platform:firstleg:port-schedule-query:manual") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java index b113eaaac..64933838e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java @@ -18,6 +18,7 @@ class PlatformCarrierScheduleQueryControllerContractTest { .containsExactly("/api/platform/v1/carrier-schedule-queries"); Map permissions = Map.of( "carriers", "platform:firstleg:port-schedule-query:list", + "containerActualFacts", "platform:firstleg:port-schedule-query:list", "page", "platform:firstleg:port-schedule-query:list", "detail", "platform:firstleg:port-schedule-query:list", "history", "platform:firstleg:port-schedule-query:list", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java index 1c8463923..4e783961c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java @@ -9,10 +9,31 @@ import org.springframework.core.io.ClassPathResource; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; class CarrierScheduleQuerySqlContractTest { + @Test + void batchesActualContainerFactsWithoutEstimatedOrPerNodeQueries() throws Exception { + String sql = String.join("\n", CarrierScheduleQueryMapper.class + .getMethod("selectContainerActualFacts", List.class) + .getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("container_booking_id IN") + .contains("f.actual_at IS NOT NULL") + .contains("MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE'") + .contains("MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT'") + .contains("MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT'") + .contains("MAX(CASE WHEN f.standard_node='PICKUP_READY'") + .contains("MAX(CASE WHEN f.standard_node='PICKED_UP'") + .contains("MAX(CASE WHEN f.standard_node='EMPTY_RETURNED'") + .contains("ORDER BY q.container_booking_id") + .doesNotContain("estimated_at"); + assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(1); + } + @Test void annotationSqlUsesExecutableComparisonOperators() throws Exception { String selectDue = String.join("\n", CarrierScheduleQueryMapper.class @@ -149,4 +170,8 @@ class CarrierScheduleQuerySqlContractTest { return new ClassPathResource("db/phase3-carrier-schedule-query-schema.sql") .getContentAsString(StandardCharsets.UTF_8); } + + private static int count(String source, String needle) { + return source.split(java.util.regex.Pattern.quote(needle), -1).length - 1; + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java index 8ad349e24..0c0425841 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest; @@ -72,6 +73,35 @@ class CarrierScheduleQueryServiceTest { when(portSchedules.containerBooking(20L)).thenReturn(container); } + @Test + void returnsBatchActualFactsFromOneRepositoryCall() { + ContainerActualFacts facts = new ContainerActualFacts(20L, 30L, 2, "READY", + "PICKUP_READY", NOW, NOW.minusDays(12), NOW.minusDays(10), NOW.minusDays(2), + NOW, null, null); + when(repository.containerActualFacts(List.of(20L, 21L))).thenReturn(List.of(facts)); + + assertThat(service.containerActualFacts(List.of(20L, 21L))).containsExactly(facts); + verify(repository).containerActualFacts(List.of(20L, 21L)); + } + + @Test + void rejectsInvalidActualFactBatchBeforeRepositoryAccess() { + assertThatThrownBy(() -> service.containerActualFacts(null)) + .isInstanceOf(BizException.class).hasMessageContaining("1到200"); + assertThatThrownBy(() -> service.containerActualFacts(List.of())) + .isInstanceOf(BizException.class).hasMessageContaining("1到200"); + assertThatThrownBy(() -> service.containerActualFacts(java.util.Collections.nCopies(201, 1L))) + .isInstanceOf(BizException.class).hasMessageContaining("1到200"); + assertThatThrownBy(() -> service.containerActualFacts(java.util.Arrays.asList(1L, null))) + .isInstanceOf(BizException.class).hasMessageContaining("正数"); + assertThatThrownBy(() -> service.containerActualFacts(List.of(1L, 0L))) + .isInstanceOf(BizException.class).hasMessageContaining("正数"); + assertThatThrownBy(() -> service.containerActualFacts(List.of(1L, 1L))) + .isInstanceOf(BizException.class).hasMessageContaining("重复"); + + verify(repository, never()).containerActualFacts(any()); + } + @Test void createsTaskOnlyForBookingCustomerAndAllocatedContainer() { OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); From 19d9a896515de2a94691f0d968cc8dbfa76d318d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 19:11:29 +0800 Subject: [PATCH 52/56] feat(iam): split domestic and overseas container permissions --- .../db/container-workspace-permissions.sql | 139 ++++++++++++++++++ iam/src/main/resources/openapi/iam-v1.yaml | 2 +- .../controller/IamOpenApiContractTest.java | 8 + ...nerWorkspacePermissionSqlContractTest.java | 110 ++++++++++++++ 4 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 iam/src/main/resources/db/container-workspace-permissions.sql create mode 100644 iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java diff --git a/iam/src/main/resources/db/container-workspace-permissions.sql b/iam/src/main/resources/db/container-workspace-permissions.sql new file mode 100644 index 000000000..57b15c1fd --- /dev/null +++ b/iam/src/main/resources/db/container-workspace-permissions.sql @@ -0,0 +1,139 @@ +-- 国内送柜报关与国外清关提柜双工作台权限;国内沿用现有货柜权限,国外直接收敛到独立权限族。 +USE baiyue_iam; +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS migrate_container_workspace_permissions; +DELIMITER $$ +CREATE PROCEDURE migrate_container_workspace_permissions() +BEGIN + IF EXISTS ( + SELECT 1 FROM iam_permission + WHERE (id = 5102090 AND permission_code <> 'platform:firstleg:container:list') + OR (id = 5102092 AND permission_code <> 'platform:firstleg:container:create') + OR (id = 5102093 AND permission_code <> 'platform:firstleg:container:edit') + OR (id = 5102200 AND permission_code <> 'platform:firstleg:terminal-drayage:list') + OR (id = 5102201 AND permission_code <> 'platform:firstleg:terminal-drayage:document:edit') + OR (id = 5102202 AND permission_code <> 'platform:firstleg:terminal-drayage:fleet:edit') + OR (id = 5102203 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup-appointment:edit') + OR (id = 5102204 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup:confirm') + OR (id = 5102205 AND permission_code <> 'platform:firstleg:terminal-drayage:empty-return:confirm') + ) OR EXISTS ( + SELECT 1 FROM iam_permission + WHERE (permission_code = 'platform:firstleg:container:list' AND id <> 5102090) + OR (permission_code = 'platform:firstleg:container:create' AND id <> 5102092) + OR (permission_code = 'platform:firstleg:container:edit' AND id <> 5102093) + OR (permission_code = 'platform:firstleg:terminal-drayage:list' AND id <> 5102200) + OR (permission_code = 'platform:firstleg:terminal-drayage:document:edit' AND id <> 5102201) + OR (permission_code = 'platform:firstleg:terminal-drayage:fleet:edit' AND id <> 5102202) + OR (permission_code = 'platform:firstleg:terminal-drayage:pickup-appointment:edit' AND id <> 5102203) + OR (permission_code = 'platform:firstleg:terminal-drayage:pickup:confirm' AND id <> 5102204) + OR (permission_code = 'platform:firstleg:terminal-drayage:empty-return:confirm' AND id <> 5102205) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限ID或编码冲突'; + END IF; + + INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted) + VALUES + (5102090,0,'PLATFORM','MENU',5102000,'platform:firstleg:container:list', + '国内送柜报关','/container-loading/domestic','GET',40,'ACTIVE',1,0), + (5102092,0,'PLATFORM','BUTTON',5102090,'platform:firstleg:container:create', + '建立国内货柜',NULL,'POST',20,'ACTIVE',1,0), + (5102093,0,'PLATFORM','BUTTON',5102090,'platform:firstleg:container:edit', + '维护国内货柜资料',NULL,'PUT',30,'ACTIVE',1,0), + (5102200,0,'PLATFORM','MENU',5102000,'platform:firstleg:terminal-drayage:list', + '国外清关提柜','/container-loading/overseas','GET',41,'ACTIVE',1,0), + (5102201,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:document:edit', + '维护目的港清关与DO/PU',NULL,'POST',10,'ACTIVE',1,0), + (5102202,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:fleet:edit', + '维护目的港码头车队',NULL,'POST',20,'ACTIVE',1,0), + (5102203,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup-appointment:edit', + '预约目的港提柜',NULL,'POST',30,'ACTIVE',1,0), + (5102204,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup:confirm', + '确认目的港实际提柜',NULL,'POST',40,'ACTIVE',1,0), + (5102205,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:empty-return:confirm', + '确认目的港实际还柜',NULL,'POST',50,'ACTIVE',1,0) + ON DUPLICATE KEY UPDATE + permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), + permission_name=VALUES(permission_name), route_path=VALUES(route_path), + http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', + built_in=1, deleted=0; + + -- list 已包含详情只读;旧 container:view 不再作为别名或双权限入口。 + DELETE grant_row FROM iam_role_permission grant_row + JOIN iam_permission permission + ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id + WHERE permission.tenant_id=0 + AND permission.permission_code='platform:firstleg:container:view'; + UPDATE iam_permission + SET status='INACTIVE', deleted=1 + WHERE tenant_id=0 AND permission_code='platform:firstleg:container:view'; + + -- 平台超级管理员和现有订单运营保持货柜全链路能力;其他角色按两个菜单独立授权。 + INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) + SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) + FROM iam_role role + JOIN iam_permission permission + ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code IN ( + 'platform:firstleg:container:list', + 'platform:firstleg:container:create', + 'platform:firstleg:container:edit', + 'platform:firstleg:terminal-drayage:list', + 'platform:firstleg:terminal-drayage:document:edit', + 'platform:firstleg:terminal-drayage:fleet:edit', + 'platform:firstleg:terminal-drayage:pickup-appointment:edit', + 'platform:firstleg:terminal-drayage:pickup:confirm', + 'platform:firstleg:terminal-drayage:empty-return:confirm') + WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') + AND role.status='ACTIVE' AND role.deleted=0 + AND permission.status='ACTIVE' AND permission.deleted=0; + + IF (SELECT COUNT(*) FROM iam_permission + WHERE tenant_id=0 AND app_code='PLATFORM' AND status='ACTIVE' AND deleted=0 + AND permission_code IN ( + 'platform:firstleg:container:list', + 'platform:firstleg:container:create', + 'platform:firstleg:container:edit', + 'platform:firstleg:terminal-drayage:list', + 'platform:firstleg:terminal-drayage:document:edit', + 'platform:firstleg:terminal-drayage:fleet:edit', + 'platform:firstleg:terminal-drayage:pickup-appointment:edit', + 'platform:firstleg:terminal-drayage:pickup:confirm', + 'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 9 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限目录不完整'; + END IF; + + IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code)) + FROM iam_role_permission grant_row + JOIN iam_role role + ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id + JOIN iam_permission permission + ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id + WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') + AND permission.permission_code IN ( + 'platform:firstleg:container:list', + 'platform:firstleg:container:create', + 'platform:firstleg:container:edit', + 'platform:firstleg:terminal-drayage:list', + 'platform:firstleg:terminal-drayage:document:edit', + 'platform:firstleg:terminal-drayage:fleet:edit', + 'platform:firstleg:terminal-drayage:pickup-appointment:edit', + 'platform:firstleg:terminal-drayage:pickup:confirm', + 'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 18 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:平台默认角色授权不完整'; + END IF; + + IF EXISTS (SELECT 1 FROM iam_permission + WHERE tenant_id=0 AND permission_code='platform:firstleg:container:view' + AND (status='ACTIVE' OR deleted=0)) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:旧查看权限仍然有效'; + END IF; +END$$ +DELIMITER ; + +CALL migrate_container_workspace_permissions(); +DROP PROCEDURE IF EXISTS migrate_container_workspace_permissions; diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 1260e76f6..a7783983a 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -124,7 +124,7 @@ paths: get: tags: [IAM Resources / IAM 资源] summary: 查询当前用户有效权限 - description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。列表权限包含详情和只读辅助接口,写操作独立授权。 + description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:pickup:confirm`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。列表权限包含详情和只读辅助接口,写操作独立授权。 responses: '200': description: 当前平台成员权限查询成功 diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index b7a4f5438..84116ee8f 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -35,6 +35,14 @@ class IamOpenApiContractTest { .contains("platform:overseas-warehouse:customer-visit:list") .contains("platform:overseas-warehouse:customer-visit:edit") .contains("platform:overseas-warehouse:outbound:cancel") + .contains("platform:firstleg:container:list/create/edit") + .contains("platform:firstleg:terminal-drayage:list") + .contains("platform:firstleg:terminal-drayage:document:edit") + .contains("platform:firstleg:terminal-drayage:fleet:edit") + .contains("platform:firstleg:terminal-drayage:pickup-appointment:edit") + .contains("platform:firstleg:terminal-drayage:pickup:confirm") + .contains("platform:firstleg:terminal-drayage:empty-return:confirm") + .contains("不以 `container:list/edit` 兼容放行国外操作") .contains("merchant:warehouse:delivery:list") .contains("merchant:warehouse:delivery:create") .contains("merchant:cross-border:procurement") diff --git a/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java new file mode 100644 index 000000000..c1864ab61 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/ContainerWorkspacePermissionSqlContractTest.java @@ -0,0 +1,110 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +class ContainerWorkspacePermissionSqlContractTest { + private static final Pattern CODE = Pattern.compile("platform:firstleg:(?:container|terminal-drayage):[a-z0-9:-]+"); + private static final Set REQUIRED = Set.of( + "platform:firstleg:container:list", + "platform:firstleg:container:create", + "platform:firstleg:container:edit", + "platform:firstleg:terminal-drayage:list", + "platform:firstleg:terminal-drayage:document:edit", + "platform:firstleg:terminal-drayage:fleet:edit", + "platform:firstleg:terminal-drayage:pickup-appointment:edit", + "platform:firstleg:terminal-drayage:pickup:confirm", + "platform:firstleg:terminal-drayage:empty-return:confirm"); + + @Test + void definesSeparateDomesticAndOverseasMenusWithoutAliases() throws Exception { + String sql = sql(); + + assertThat(codes(catalog(sql))).containsExactlyInAnyOrderElementsOf(REQUIRED); + assertThat(sql).contains( + "'platform:firstleg:container:list',\n '国内送柜报关','/container-loading/domestic','GET'", + "'platform:firstleg:terminal-drayage:list',\n '国外清关提柜','/container-loading/overseas','GET'", + "permission.permission_code='platform:firstleg:container:view'", + "SET status='INACTIVE', deleted=1"); + assertThat(codes(catalog(sql))).noneMatch(code -> code.endsWith(":view")); + assertThat(sql).doesNotContain( + "platform:firstleg:domestic-container:", + "platform:firstleg:overseas-container:", + "platform:firstleg:terminal-drayage:edit"); + } + + @Test + void keepsDomesticWritesAndDefinesFineGrainedOverseasWrites() throws Exception { + assertThat(sql()).contains( + "'platform:firstleg:container:create',\n '建立国内货柜',NULL,'POST'", + "'platform:firstleg:container:edit',\n '维护国内货柜资料',NULL,'PUT'", + "'platform:firstleg:terminal-drayage:document:edit',\n '维护目的港清关与DO/PU',NULL,'POST'", + "'platform:firstleg:terminal-drayage:fleet:edit',\n '维护目的港码头车队',NULL,'POST'", + "'platform:firstleg:terminal-drayage:pickup-appointment:edit',\n '预约目的港提柜',NULL,'POST'", + "'platform:firstleg:terminal-drayage:pickup:confirm',\n '确认目的港实际提柜',NULL,'POST'", + "'platform:firstleg:terminal-drayage:empty-return:confirm',\n '确认目的港实际还柜',NULL,'POST'"); + } + + @Test + void grantsTheCompleteMatrixOnlyToTheExistingDefaultPlatformRoles() throws Exception { + String sql = sql(); + int grantStart = sql.indexOf("-- 平台超级管理员和现有订单运营保持货柜全链路能力"); + int assertionStart = sql.indexOf("IF (SELECT COUNT(*) FROM iam_permission", grantStart); + String grant = sql.substring(grantStart, assertionStart); + + assertThat(grant).contains( + "role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')", + "platform:firstleg:container:list", + "platform:firstleg:terminal-drayage:list", + "platform:firstleg:terminal-drayage:empty-return:confirm"); + assertThat(grant).doesNotContain( + "PLATFORM_CUSTOM_ADMIN", + "WAREHOUSE_OPERATOR", + "WAREHOUSE_TALLY", + "OVERSEAS_WAREHOUSE_PDA_OPERATOR", + "CN_FIRSTLEG_PDA_OPERATOR", + "MERCHANT_OWNER"); + assertThat(sql).contains( + "permission_code IN (", + ")) <> 9 THEN", + ")) <> 18 THEN"); + } + + @Test + void failsClosedForFixedIdentityConflictsAndRemainsReplaySafe() throws Exception { + assertThat(sql()).contains( + "migrate_container_workspace_permissions", + "id = 5102090 AND permission_code <> 'platform:firstleg:container:list'", + "id = 5102205 AND permission_code <> 'platform:firstleg:terminal-drayage:empty-return:confirm'", + "permission_code = 'platform:firstleg:terminal-drayage:list' AND id <> 5102200", + "SIGNAL SQLSTATE '45000'", + "ON DUPLICATE KEY UPDATE", + "INSERT IGNORE INTO iam_role_permission"); + } + + private static String sql() throws Exception { + return new ClassPathResource("db/container-workspace-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + } + + private static Set codes(String source) { + Set values = new LinkedHashSet<>(); + CODE.matcher(source).results().map(result -> result.group()).forEach(values::add); + return values; + } + + private static String catalog(String sql) { + int start = sql.indexOf("INSERT INTO iam_permission"); + int end = sql.indexOf("ON DUPLICATE KEY UPDATE", start); + assertThat(start).isGreaterThanOrEqualTo(0); + assertThat(end).isGreaterThan(start); + return sql.substring(start, end); + } +} From d94c902d756de245e0287e6d686e00e048597d39 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 19:48:16 +0800 Subject: [PATCH 53/56] feat(firstleg): split domestic and overseas container workbenches --- .../PlatformContainerController.java | 81 ++++++--- .../baiyue/firstleg/dto/ContainerDtos.java | 3 + .../firstleg/dto/ContainerOverviewDtos.java | 6 +- .../entity/ContainerBookingEntity.java | 7 +- .../mapper/ContainerBookingMapper.java | 59 +++++- .../mapper/ContainerOverviewMapper.java | 2 +- .../repository/ContainerRepository.java | 8 +- .../service/ContainerOverviewService.java | 16 +- .../firstleg/service/ContainerService.java | 20 +-- .../firstleg/service/LoadingListService.java | 6 +- .../OverseasContainerEventConsumer.java | 5 +- .../PlatformTerminalDrayageController.java | 30 ++-- .../dto/ContainerWorkbenchDtos.java | 26 +++ .../dto/TerminalDrayageDtos.java | 4 +- .../mapper/TerminalDrayageMapper.java | 43 +---- .../service/ContainerWorkbenchService.java | 165 +++++++++++++++++ .../service/TerminalDrayageService.java | 145 ++++++++++----- .../db/phase3-smart-loading-schema.sql | 55 +++++- .../main/resources/openapi/firstleg-v1.yaml | 168 ++++++++++++++---- .../openapi/phase3-container-overview-v1.yaml | 30 +++- .../openapi/terminal-drayage-v1.yaml | 33 +++- .../ContainerControllerContractTest.java | 29 +-- .../ContainerBookingMapperContractTest.java | 2 +- .../ContainerOverviewSqlContractTest.java | 5 +- .../ContainerWorkbenchSqlContractTest.java | 33 ++++ .../SmartLoadingSqlContractTest.java | 9 +- .../service/ContainerOverviewServiceTest.java | 17 ++ .../service/LoadingListServiceTest.java | 36 +--- .../OverseasContainerEventConsumerTest.java | 4 +- .../TerminalDrayageOpenApiContractTest.java | 27 ++- .../TerminalDrayageSqlContractTest.java | 3 +- .../ContainerWorkbenchServiceTest.java | 105 +++++++++++ .../service/TerminalDrayageServiceTest.java | 49 ++++- 33 files changed, 958 insertions(+), 273 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerWorkbenchDtos.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchService.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerWorkbenchSqlContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchServiceTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java index dd10cd1a4..e4e43d904 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -6,10 +6,12 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.Query; +import com.baiyue.firstleg.terminaldrayage.dto.ContainerWorkbenchDtos.WorkbenchItem; import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest; import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.service.ContainerService; +import com.baiyue.firstleg.terminaldrayage.service.ContainerWorkbenchService; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.LoadingListService; import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument; @@ -39,21 +41,50 @@ import org.springframework.web.multipart.MultipartFile; @RequestMapping("/api/platform/v1/containers") public class PlatformContainerController { @Resource private ContainerService service; + @Resource private ContainerWorkbenchService workbenches; @Resource private LoadingListService loadingLists; @Resource private ContainerOverviewService overview; - @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:container:list')") - public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, - @Valid Query query) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query)); + @GetMapping("/domestic") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") + public ApiResponse> domesticPage(@AuthenticationPrincipal AuthenticatedUser user, + @Valid Query query) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(workbenches.domesticPage(query, user.permissions())); } - @GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") - public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long id) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(id)); + @GetMapping("/overseas") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')") + public ApiResponse> overseasPage(@AuthenticationPrincipal AuthenticatedUser user, + @Valid Query query) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(workbenches.overseasPage(query, user.permissions())); + } + @GetMapping("/domestic/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") + public ApiResponse domesticDetail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long id) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(workbenches.domesticDetail(id, user.permissions())); + } + @GetMapping("/overseas/{id}") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')") + public ApiResponse overseasDetail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long id) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(workbenches.overseasDetail(id, user.permissions())); } @GetMapping("/{id}/overview") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") public ApiResponse overview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(overview.overview(id)); + FirstlegScopeGuard.platform(user); workbenches.domesticDetail(id, user.permissions()); + return ApiResponse.success(overview.overview(id)); + } + @GetMapping("/overseas/{id}/overview") + @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')") + public ApiResponse overseasOverview(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long id) { + FirstlegScopeGuard.platform(user); service.requireOverseas(id); + return ApiResponse.success(overview.overview(id)); + } + @PutMapping("/{id}/pickup-code") + @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')") + @BusinessAudit(event="CONTAINER_PICKUP_CODE_UPDATE", resourceType="CONTAINER", resourceId="#id", description="填写PU码") + public ApiResponse updatePickupCode(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long id, + @Valid @RequestBody PickupCodeRequest request) { + FirstlegScopeGuard.platform(user); + service.requireOverseas(id); + return ApiResponse.success(overview.updatePickupCode(id, user.userId(), user.username(), request)); } @GetMapping("/{id}/pda/photos/{fileId}/download") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") @@ -61,6 +92,7 @@ public class PlatformContainerController { @PathVariable @Positive long id, @PathVariable @Positive long fileId) { FirstlegScopeGuard.platform(user); + service.requireDomestic(id); var value = overview.downloadPhoto(id, fileId); return ResponseEntity.ok().cacheControl(CacheControl.noStore()) .header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.inline() @@ -71,20 +103,20 @@ public class PlatformContainerController { public ApiResponse markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ExceptionRequest request) { - FirstlegScopeGuard.platform(user); overview.mark(id,user.userId(),user.username(),key,request); return ApiResponse.success(null); + FirstlegScopeGuard.platform(user); service.requireDomestic(id); overview.mark(id,user.userId(),user.username(),key,request); return ApiResponse.success(null); } @DeleteMapping("/{id}/exception") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") public ApiResponse cancelException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ExceptionRequest request) { - FirstlegScopeGuard.platform(user); overview.cancel(id,user.userId(),user.username(),key,request); return ApiResponse.success(null); + FirstlegScopeGuard.platform(user); service.requireDomestic(id); overview.cancel(id,user.userId(),user.username(),key,request); return ApiResponse.success(null); } - @GetMapping("/{id}/documents/{documentType}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") - public ApiResponse logisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.containerLogisticsDocument(id,documentType));} - @PostMapping(value="/{id}/documents/{documentType}/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") - public ApiResponse uploadLogisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@RequestHeader("Idempotency-Key") String key,@RequestPart("file") MultipartFile file,@RequestParam("expectedVersion") @Min(0) int version,@RequestParam(value="operationReason",required=false) String reason){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.uploadContainerLogisticsDocument(id,documentType,user.userId(),user.username(),key,version,reason,file));} - @GetMapping("/{id}/documents/{documentType}/{fileId}/download") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") - public ResponseEntity downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@PathVariable @Positive long fileId){FirstlegScopeGuard.platform(user);var d=loadingLists.downloadContainerDocument(id,documentType,fileId);return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header(HttpHeaders.CONTENT_DISPOSITION,org.springframework.http.ContentDisposition.attachment().filename(d.fileName(),StandardCharsets.UTF_8).build().toString()).contentType(d.contentType()).contentLength(d.content().length).body(d.content());} + @GetMapping("/{id}/documents/{documentType}") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')") + public ApiResponse logisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType){FirstlegScopeGuard.platform(user);service.requireOverseas(id);return ApiResponse.success(loadingLists.containerLogisticsDocument(id,documentType));} + @PostMapping(value="/{id}/documents/{documentType}/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')") + public ApiResponse uploadLogisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@RequestHeader("Idempotency-Key") String key,@RequestPart("file") MultipartFile file,@RequestParam("expectedVersion") @Min(0) int version,@RequestParam(value="operationReason",required=false) String reason){FirstlegScopeGuard.platform(user);service.requireOverseas(id);return ApiResponse.success(loadingLists.uploadContainerLogisticsDocument(id,documentType,user.userId(),user.username(),key,version,reason,file));} + @GetMapping("/{id}/documents/{documentType}/{fileId}/download") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')") + public ResponseEntity downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@PathVariable @Positive long fileId){FirstlegScopeGuard.platform(user);service.requireOverseas(id);var d=loadingLists.downloadContainerDocument(id,documentType,fileId);return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header(HttpHeaders.CONTENT_DISPOSITION,org.springframework.http.ContentDisposition.attachment().filename(d.fileName(),StandardCharsets.UTF_8).build().toString()).contentType(d.contentType()).contentLength(d.content().length).body(d.content());} @PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')") @BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false) public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @@ -104,21 +136,23 @@ public class PlatformContainerController { return ApiResponse.success(service.updateDraft(id, user.userId(), user.username(), request)); } @GetMapping("/{id}/clearance") - @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')") @Operation(summary = "查询货柜目的港清关资料") public ApiResponse clearance(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id) { FirstlegScopeGuard.platform(user); + service.requireOverseas(id); return ApiResponse.success(loadingLists.containerClearance(id)); } @GetMapping("/{id}/clearance/document/{fileId}/download") - @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')") @Operation(summary = "下载货柜目的港清关附件", description = "校验当前清关附件和 File 业务归属,完成态仍可下载。") public ResponseEntity downloadClearance( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @PathVariable @Positive long fileId) { FirstlegScopeGuard.platform(user); + service.requireOverseas(id); var download = loadingLists.downloadContainerClearance(id, fileId); return ResponseEntity.ok().cacheControl(CacheControl.noStore()) .header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment() @@ -128,19 +162,20 @@ public class PlatformContainerController { } @PostMapping("/{id}/clearance/document") - @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')") @Operation(summary = "上传或替换货柜目的港清关资料") public ApiResponse attachClearance(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody DocumentAttachRequest request) { FirstlegScopeGuard.platform(user); + service.requireOverseas(id); return ApiResponse.success(loadingLists.attachContainerClearance( id, user.userId(), user.username(), key, request)); } @PostMapping(value = "/{id}/clearance/document/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')") @Operation(summary = "直接上传或替换货柜目的港清关资料") public ApiResponse uploadClearance( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @@ -149,18 +184,20 @@ public class PlatformContainerController { @RequestParam("expectedVersion") @Min(0) int expectedVersion, @RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) { FirstlegScopeGuard.platform(user); + service.requireOverseas(id); return ApiResponse.success(loadingLists.uploadContainerClearance( id, user.userId(), user.username(), key, expectedVersion, operationReason, file)); } @PostMapping("/{id}/clearance/complete") - @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')") @Operation(summary = "确认货柜目的港清关完成") public ApiResponse completeClearance(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody DocumentCompleteRequest request) { FirstlegScopeGuard.platform(user); + service.requireOverseas(id); return ApiResponse.success(loadingLists.completeContainerClearance( id, user.userId(), user.username(), key, request)); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java index 4a1cbb272..b20580354 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java @@ -19,6 +19,8 @@ import java.time.LocalDateTime; public final class ContainerDtos { private ContainerDtos() {} + public enum WorkbenchScope { ACTIVE, HISTORY } + @Schema(name = "ContainerQuery", description = "货柜分页筛选条件") public record Query( @Size(max = 64) String containerNo, @@ -35,6 +37,7 @@ public final class ContainerDtos { SmartLoadingStatus status, LocalDateTime estimatedSailingFrom, LocalDateTime estimatedSailingTo, + @NotNull WorkbenchScope scope, @Min(1) long page, @Min(1) @Max(200) long size ) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java index 07375297d..0a742df39 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.Pattern; import java.time.LocalDateTime; import java.util.List; @@ -21,7 +22,8 @@ public final class ContainerOverviewDtos { LocalDateTime estimatedWarehouseArrivalAt, LocalDateTime actualWarehouseArrivalAt, LocalDateTime unloadingCompletedAt, LocalDateTime unloadingPhotoUploadedAt, LocalDateTime estimatedEmptyReportedAt, LocalDateTime actualEmptyReportedAt, - Long emptyReportSourceVersion, + Long emptyReportSourceVersion, String pickupCode, Long pickupCodeUpdatedBy, + String pickupCodeUpdatedByName, LocalDateTime pickupCodeUpdatedAt, Integer version) {} public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus, String workflowStatus, Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount, @@ -35,4 +37,6 @@ public final class ContainerOverviewDtos { public record ExceptionHistory(Long id, String action, String reason, Long operatorId, String operatorName, LocalDateTime occurredAt) {} public record TimelineEntry(String eventType, String detail, Long operatorId, String operatorName, LocalDateTime occurredAt) {} public record ExceptionRequest(@NotNull @Min(0) Integer expectedVersion, @NotBlank @Size(max=500) String reason) {} + public record PickupCodeRequest(@NotBlank @Size(max=64) @Pattern(regexp="^[0-9]+$", message="PU码只能包含数字") String pickupCode, + @NotNull @Min(0) Integer expectedVersion) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java index 1279f8a48..6f6244d5c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java @@ -60,6 +60,10 @@ public class ContainerBookingEntity { private LocalDateTime estimatedEmptyReportedAt; private LocalDateTime actualEmptyReportedAt; private Long emptyReportSourceVersion; + private String pickupCode; + private Long pickupCodeUpdatedBy; + private String pickupCodeUpdatedByName; + private LocalDateTime pickupCodeUpdatedAt; private Long createdBy; private String createdByName; @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; @@ -76,5 +80,6 @@ public class ContainerBookingEntity { @TableField(exist = false) private String destinationClearanceDocumentStatus; @TableField(exist = false) private String customsDeclarationDocumentStatus; @TableField(exist = false) private String deliveryOrderDocumentStatus; - @TableField(exist = false) private String pickupOrderDocumentStatus; + @TableField(exist = false) private String outboundDispatchStatus; + @TableField(exist = false) private LocalDateTime outboundConfirmedAt; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index 138e5e138..16335a476 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -40,8 +40,14 @@ public interface ContainerBookingMapper extends BaseMapper """) - Page selectBookingPage(Page page, @Param("query") Query query); + Page selectBookingPage(Page page, + @Param("query") Query query, + @Param("workbench") String workbench); @Select(""" SELECT b.*, p.id AS plan_id, p.plan_no AS plan_no, @@ -100,8 +126,14 @@ public interface ContainerBookingMapper extends BaseMapper documents(@Param("id") long id); + @Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER') ORDER BY document_type") List documents(@Param("id") long id); @Select(""" SELECT history.id,plan.container_booking_id containerBookingId, JSON_UNQUOTE(JSON_EXTRACT(history.before_snapshot,'$.containerNo')) beforeContainerNo, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java index e6024bc4c..cfc60b9d2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java @@ -12,15 +12,16 @@ import org.springframework.stereotype.Repository; @Repository public class ContainerRepository { @Resource private ContainerBookingMapper mapper; - public PageResult page(Query query) { + public PageResult page(String workbench, Query query) { Page result = mapper.selectBookingPage( - new Page<>(query.page(), query.size()), query); + new Page<>(query.page(), query.size()), query, workbench); result.getRecords().forEach(this::resolveLoadingTime); return PageResult.from(result); } public ContainerBookingEntity find(long id) { return resolveLoadingTime(mapper.selectBooking(id)); } public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); } public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); } + public boolean departed(long id) { return mapper.hasActiveOutboundDeparture(id) > 0; } public boolean containerNoExists(String no, Long excludedId) { if (no == null) return false; return mapper.selectCount(new LambdaQueryWrapper() @@ -45,6 +46,9 @@ public class ContainerRepository { public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) { return mapper.updateDraft(value, version, operatorId, operatorName); } + public int updatePickupCode(long id, String pickupCode, int version, long operatorId, String operatorName) { + return mapper.updatePickupCode(id, pickupCode, version, operatorId, operatorName); + } public boolean hasLoadingPlan(long id) { return mapper.hasLoadingPlan(id) > 0; } public boolean hasActiveLoadingItems(long id) { return mapper.hasActiveLoadingItems(id) > 0; } public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java index 8192fba2b..6556b6a4c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java @@ -51,7 +51,9 @@ public class ContainerOverviewService { booking.getActualPortArrivalAt(), booking.getEstimatedWarehouseArrivalAt(), booking.getActualWarehouseArrivalAt(), booking.getUnloadingCompletedAt(), booking.getUnloadingPhotoUploadedAt(), booking.getEstimatedEmptyReportedAt(), - booking.getActualEmptyReportedAt(), booking.getEmptyReportSourceVersion(), booking.getVersion()), + booking.getActualEmptyReportedAt(), booking.getEmptyReportSourceVersion(), booking.getPickupCode(), + booking.getPickupCodeUpdatedBy(), booking.getPickupCodeUpdatedByName(), booking.getPickupCodeUpdatedAt(), + booking.getVersion()), task == null ? booking.getPlanId() : task.loadingPlanId(), pda, documents, new ExceptionState(latestException != null && "MARK".equals(latestException.action()), latestException == null ? null : latestException.reason(), @@ -72,6 +74,18 @@ public class ContainerOverviewService { changeException(id, operatorId, operatorName, idempotencyKey, request, "CANCEL"); } + @Transactional + public Overview updatePickupCode(long id, long operatorId, String operatorName, PickupCodeRequest request) { + ContainerBookingEntity booking = containers.findActiveForUpdate(id); + if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在"); + if (!request.expectedVersion().equals(booking.getVersion())) throw conflict("货柜版本已变化,请刷新后重试"); + String pickupCode = request.pickupCode().trim(); + if (containers.updatePickupCode(id, pickupCode, request.expectedVersion(), operatorId, operatorName) != 1) { + throw conflict("货柜版本已变化,请刷新后重试"); + } + return overview(id); + } + private void changeException(long id, long operatorId, String operatorName, String rawKey, ExceptionRequest request, String action) { String operationId = operationId(rawKey); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index afa4f201a..4fbae5f56 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -1,7 +1,6 @@ package com.baiyue.firstleg.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; @@ -9,7 +8,6 @@ import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.domain.SmartLoadingStatus; import com.baiyue.firstleg.domain.ContainerSpecificationCatalog; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; -import com.baiyue.firstleg.dto.ContainerDtos.Query; import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest; import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest; @@ -41,11 +39,15 @@ public class ContainerService { @Resource private SmartLoadingService smartLoadingService; @Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId; - public PageResult page(Query query) { - validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选"); - return repository.page(normalize(query)); + public void requireOverseas(long id) { + required(id); + if (!repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国外工作台货柜不存在"); + } + + public void requireDomestic(long id) { + required(id); + if (repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国内工作台货柜不存在"); } - public ContainerBookingEntity detail(long id) { return required(id); } @Transactional public ContainerBookingEntity createSimulation(long operatorId, String operatorName, SimulationCreateRequest request) { @@ -220,12 +222,6 @@ public class ContainerService { if (from != null && to != null && to.isBefore(from)) throw new BizException(ResultCode.VALIDATION_FAILED, label + "先后顺序不正确"); } - private static Query normalize(Query q) { - return new Query(trim(q.containerNo()), trim(q.cabinetNo()), trim(q.billOfLadingNo()), trim(q.keyword()), - q.customerId(), q.warehouseId(), trim(q.containerType()), q.shippingCompanyId(), trim(q.departurePortCode()), - trim(q.destinationPortCode()), trim(q.destinationWarehouseCode()), q.status(), q.estimatedSailingFrom(), - q.estimatedSailingTo(), q.page(), q.size()); - } private static String trim(String value) { if (value == null) return null; String result = value.trim(); return result.isEmpty() ? null : result; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index db4f97006..769a69693 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -56,10 +56,8 @@ public class LoadingListService { private static final Set PRELOAD_STATUSES = Set.of("EDITING", "LOADING"); public static final String DESTINATION_CLEARANCE = "DESTINATION_CLEARANCE"; public static final String DELIVERY_ORDER = "DELIVERY_ORDER"; - public static final String PICKUP_ORDER = "PICKUP_ORDER"; private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT"; private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER"; - private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER"; @Resource private LoadingListRepository repository; @Resource @@ -190,8 +188,8 @@ public class LoadingListService { } } public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {} - private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)&&!PICKUP_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER"); } - private static String fileType(String type){ return switch(type){case DELIVERY_ORDER->DO_FILE_TYPE; case PICKUP_ORDER->PU_FILE_TYPE; default->throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER");}; } + private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER"); } + private static String fileType(String type){ if (DELIVERY_ORDER.equals(type)) return DO_FILE_TYPE; throw badRequest("documentType只能是DELIVERY_ORDER"); } @Transactional @BusinessAudit(event = "CONTAINER_CLEARANCE_DOCUMENT_ATTACH", resourceType = "CONTAINER", diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java index 1873105dd..5bdd15cc2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java @@ -63,8 +63,9 @@ public class OverseasContainerEventConsumer { ContainerBookingEntity container = requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt()); - containers.applyEmptyReport(container.getId(), utc(payload.unloadingCompletedAt()), uploadedAt, - uploadedAt.plusDays(2), utc(payload.actualEmptyReportedAt()), event.aggregateVersion()); + LocalDateTime unloadingCompletedAt = utc(payload.unloadingCompletedAt()); + containers.applyEmptyReport(container.getId(), unloadingCompletedAt, uploadedAt, + unloadingCompletedAt.plusHours(72), utc(payload.actualEmptyReportedAt()), event.aggregateVersion()); row.setStatus("PROCESSED"); row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC)); inbox.updateById(row); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java index 777a9648f..3bcf2952d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java @@ -39,24 +39,24 @@ public class PlatformTerminalDrayageController { private final TerminalDrayageService service; @GetMapping - @PreAuthorize("hasAuthority('platform:firstleg:container:list')") + @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')") public ApiResponse> views(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Size(min = 1, max = 200) List<@Positive Long> containerBookingIds) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.views(containerBookingIds)); + return ApiResponse.success(service.authorize(service.views(containerBookingIds), user.permissions())); } @GetMapping("/{containerBookingId}") - @PreAuthorize("hasAuthority('platform:firstleg:container:list')") + @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long containerBookingId) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.detail(containerBookingId)); + return ApiResponse.success(service.authorize(service.detail(containerBookingId), user.permissions())); } @GetMapping("/{containerBookingId}/history") - @PreAuthorize("hasAuthority('platform:firstleg:container:list')") + @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')") public ApiResponse> history(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long containerBookingId) { FirstlegScopeGuard.platform(user); @@ -64,7 +64,7 @@ public class PlatformTerminalDrayageController { } @PostMapping("/{containerBookingId}/fleet") - @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") + @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:fleet:edit')") @Operation(summary = "选择或更换目的港码头车队") @BusinessAudit(event = "TERMINAL_DRAYAGE_SELECT_FLEET", resourceType = "CONTAINER", resourceId = "#containerBookingId", description = "选择目的港码头车队") @@ -73,11 +73,12 @@ public class PlatformTerminalDrayageController { @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody FleetSelectionRequest request) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.selectFleet(containerBookingId, user.userId(), user.username(), key, request)); + return ApiResponse.success(service.authorize(service.selectFleet( + containerBookingId, user.userId(), user.username(), key, request), user.permissions())); } @PostMapping("/{containerBookingId}/pickup-appointment") - @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") + @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:pickup-appointment:edit')") @Operation(summary = "预约目的港提柜时间") @BusinessAudit(event = "TERMINAL_DRAYAGE_SCHEDULE_PICKUP", resourceType = "CONTAINER", resourceId = "#containerBookingId", description = "预约目的港提柜") @@ -86,11 +87,12 @@ public class PlatformTerminalDrayageController { @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody PickupAppointmentRequest request) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.schedulePickup(containerBookingId, user.userId(), user.username(), key, request)); + return ApiResponse.success(service.authorize(service.schedulePickup( + containerBookingId, user.userId(), user.username(), key, request), user.permissions())); } @PostMapping("/{containerBookingId}/pickup") - @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") + @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:pickup:confirm')") @Operation(summary = "确认目的港实际提柜") @BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_PICKUP", resourceType = "CONTAINER", resourceId = "#containerBookingId", description = "确认目的港实际提柜") @@ -99,11 +101,12 @@ public class PlatformTerminalDrayageController { @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActualTimeRequest request) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.recordPickup(containerBookingId, user.userId(), user.username(), key, request)); + return ApiResponse.success(service.authorize(service.recordPickup( + containerBookingId, user.userId(), user.username(), key, request), user.permissions())); } @PostMapping("/{containerBookingId}/empty-return") - @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") + @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:empty-return:confirm')") @Operation(summary = "确认实际还空柜") @BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_EMPTY_RETURN", resourceType = "CONTAINER", resourceId = "#containerBookingId", description = "确认实际还空柜") @@ -112,6 +115,7 @@ public class PlatformTerminalDrayageController { @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActualTimeRequest request) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.recordEmptyReturn(containerBookingId, user.userId(), user.username(), key, request)); + return ApiResponse.success(service.authorize(service.recordEmptyReturn( + containerBookingId, user.userId(), user.username(), key, request), user.permissions())); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerWorkbenchDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerWorkbenchDtos.java new file mode 100644 index 000000000..969e9f8bb --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerWorkbenchDtos.java @@ -0,0 +1,26 @@ +package com.baiyue.firstleg.terminaldrayage.dto; + +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.util.List; + +/** 国内、国外货柜工作台共享只读模型。 */ +public final class ContainerWorkbenchDtos { + private ContainerWorkbenchDtos() {} + + @Schema(name = "ContainerWorkbenchStatusSummary", description = "柜级各履约域只读状态摘要") + public record StatusSummary(String loadingStatus, String customsStatus, String outboundStatus, + String destinationClearanceStatus, String deliveryOrderStatus, + String pickupCodeStatus, String latestActualNode, + LocalDateTime latestActualAt, String terminalDrayageStatus, + String emptyReturnWarningStatus) {} + + @Schema(name = "ContainerWorkbenchItem", description = "同一containerBookingId的国内或国外工作台读模型") + public record WorkbenchItem(ContainerBookingEntity container, String workbenchStage, + StatusSummary statusSummary, List allowedActions, + ContainerActualFacts actualFacts, View terminalDrayage) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java index b193cf6b5..2148b61b2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java @@ -7,6 +7,7 @@ import jakarta.validation.constraints.Size; import java.time.LocalDateTime; import java.util.List; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; public final class TerminalDrayageDtos { private TerminalDrayageDtos() {} @@ -33,7 +34,8 @@ public final class TerminalDrayageDtos { LocalDateTime pickupAppointmentAt, LocalDateTime actualPickupAt, LocalDateTime estimatedEmptyReturnAt, LocalDateTime actualEmptyReturnAt, LocalDateTime unloadingCompletedAt, Warning emptyReturnWarning, - List allowedActions, LocalDateTime updatedAt) {} + List allowedActions, ContainerActualFacts actualFacts, + LocalDateTime updatedAt) {} public record History(String action, String fromStatus, String toStatus, Integer fromVersion, Integer toVersion, String reason, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java index f9124be45..6bf23c0dd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java @@ -16,38 +16,16 @@ import java.util.List; public interface TerminalDrayageMapper extends BaseMapper { @Select(""" SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId, - b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt, - q.id AS queryId,q.latest_standard_node AS latestActualNode, - q.latest_node_observed_at AS latestActualAt,q.version AS queryVersion, - (SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id - AND f.standard_node='ARRIVED_DESTINATION_PORT') AS actualArrivalNodeAt, - (SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id - AND f.standard_node='PICKED_UP') AS actualPickupNodeAt, - (SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id - AND f.standard_node='EMPTY_RETURNED') AS actualEmptyReturnNodeAt + b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt FROM fl_container_booking b - LEFT JOIN fl_carrier_schedule_query q ON q.id=( - SELECT q2.id FROM fl_carrier_schedule_query q2 - WHERE q2.container_booking_id=b.id ORDER BY q2.id DESC LIMIT 1) WHERE b.id=#{containerBookingId} FOR UPDATE """) Context lockContext(@Param("containerBookingId") long containerBookingId); @Select(""" SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId, - b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt, - q.id AS queryId,q.latest_standard_node AS latestActualNode, - q.latest_node_observed_at AS latestActualAt,q.version AS queryVersion, - (SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id - AND f.standard_node='ARRIVED_DESTINATION_PORT') AS actualArrivalNodeAt, - (SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id - AND f.standard_node='PICKED_UP') AS actualPickupNodeAt, - (SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id - AND f.standard_node='EMPTY_RETURNED') AS actualEmptyReturnNodeAt + b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt FROM fl_container_booking b - LEFT JOIN fl_carrier_schedule_query q ON q.id=( - SELECT q2.id FROM fl_carrier_schedule_query q2 - WHERE q2.container_booking_id=b.id ORDER BY q2.id DESC LIMIT 1) WHERE b.id=#{containerBookingId} """) Context context(@Param("containerBookingId") long containerBookingId); @@ -55,19 +33,8 @@ public interface TerminalDrayageMapper extends BaseMapper @Select("""