diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java index 7d8dd65bd..8c9a4578d 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java @@ -16,10 +16,16 @@ public record FirstLegLoadingPlanCompletedV1( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items, String customerId, String orderType, List pallets, + List items, String customerId, String customerName, String orderType, List pallets, String destinationType) { public Order { items = items == null ? List.of() : items; + if (customerId == null || customerId.isBlank()) { + throw new IllegalArgumentException("customerId is required"); + } + if (customerName == null || customerName.isBlank()) { + throw new IllegalArgumentException("customerName is required"); + } orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; pallets = pallets == null ? List.of() : pallets; } @@ -29,23 +35,13 @@ public record FirstLegLoadingPlanCompletedV1( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items, String customerId, String orderType, List pallets) { + List items, String customerId, String customerName, + String orderType, List pallets) { this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, packageCount, productQuantity, palletCount, - grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null); - } - public Order(String sourceInboundId, String rootInboundId, - String sourceOrderId, String sourceOrderNo, String rootOrderId, - String inboundCode, String destinationWarehouseId, - String destinationWarehouseCode, String destinationWarehouseName, - int packageCount, Integer productQuantity, Integer palletCount, - BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items) { - this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, - inboundCode, destinationWarehouseId, destinationWarehouseCode, - destinationWarehouseName, packageCount, productQuantity, palletCount, - grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null); + grossWeightKg, netWeightKg, volumeCbm, items, customerId, customerName, + orderType, pallets, null); } } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java index 786ebae4c..cf908f2f6 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java @@ -15,11 +15,17 @@ public record FirstLegLoadingPlanCompletedV2( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items, String customerId, String orderType, List pallets, + List items, String customerId, String customerName, String orderType, List pallets, /** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */ String destinationType) { public Order { items = items == null ? List.of() : items; + if (customerId == null || customerId.isBlank()) { + throw new IllegalArgumentException("customerId is required"); + } + if (customerName == null || customerName.isBlank()) { + throw new IllegalArgumentException("customerName is required"); + } orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; pallets = pallets == null ? List.of() : pallets; } @@ -30,24 +36,13 @@ public record FirstLegLoadingPlanCompletedV2( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items, String customerId, String orderType, List pallets) { + List items, String customerId, String customerName, + String orderType, List pallets) { this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, packageCount, productQuantity, palletCount, - grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null); - } - - public Order(String sourceInboundId, String rootInboundId, - String sourceOrderId, String sourceOrderNo, String rootOrderId, - String inboundCode, String destinationWarehouseId, - String destinationWarehouseCode, String destinationWarehouseName, - int packageCount, Integer productQuantity, Integer palletCount, - BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items) { - this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, - inboundCode, destinationWarehouseId, destinationWarehouseCode, - destinationWarehouseName, packageCount, productQuantity, palletCount, - grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null); + grossWeightKg, netWeightKg, volumeCbm, items, customerId, customerName, + orderType, pallets, null); } } diff --git a/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml b/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml new file mode 100644 index 000000000..aadee97be --- /dev/null +++ b/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml @@ -0,0 +1,35 @@ +openapi: 3.1.0 +info: + title: Baiyue Cross-region Event Contracts + version: 1.0.0 +paths: {} +components: + schemas: + FirstLegLoadingPlanCompletedV2: + type: object + required: [loadingPlanId, containerBookingId, orders] + properties: + loadingPlanId: {type: string} + containerBookingId: {type: string} + containerNo: {type: string} + cabinetNo: {type: string} + warehouseId: {type: string} + warehouseName: {type: string} + orders: + type: array + items: {$ref: '#/components/schemas/FirstLegLoadingPlanCompletedOrderV2'} + FirstLegLoadingPlanCompletedOrderV2: + type: object + required: [sourceInboundId, customerId, customerName, packageCount, items, orderType, pallets, destinationType] + properties: + sourceInboundId: {type: string} + sourceOrderId: {type: string} + sourceOrderNo: {type: string} + inboundCode: {type: string} + customerId: {type: string, minLength: 1, description: Trusted customer identity frozen by Firstleg} + customerName: {type: string, minLength: 1, maxLength: 128, description: Immutable trusted container/customer name snapshot; consumers must not query domestic IAM} + packageCount: {type: integer, minimum: 1} + items: {type: array, items: {type: object}} + orderType: {type: string, enum: [FIRST_LEG, ONE_CLICK_DROPSHIP]} + pallets: {type: array, items: {type: object}} + destinationType: {type: string, enum: [FBA, OVERSEAS]} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java index 95a6b394b..fe22ac20c 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java @@ -19,7 +19,7 @@ class FirstLegLoadingPlanCompletedV1Test { var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004", "2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002", "ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"), - new BigDecimal("0.200"), List.of(item)); + new BigDecimal("0.200"), List.of(item), "9001", "客户甲", "FIRST_LEG", List.of()); var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008", "CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order)); String json = mapper.writeValueAsString(payload); @@ -35,7 +35,7 @@ class FirstLegLoadingPlanCompletedV1Test { "SKU-2", "产品2", 1, 5, List.of("BOX-003")); var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200", "RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE, - BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "ONE_CLICK_DROPSHIP", + BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "客户甲", "ONE_CLICK_DROPSHIP", List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)), new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2)))); var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null, @@ -53,7 +53,8 @@ class FirstLegLoadingPlanCompletedV1Test { String json = """ {"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1", "warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100", - "destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]} + "destinationWarehouseId":"3","packageCount":1,"items":[],"customerId":"9001", + "customerName":"客户甲","destinationType":"OVERSEAS"}]} """; FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class); diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java index d15900021..812c1603e 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java @@ -4,8 +4,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import java.util.List; +import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class FirstLegLoadingPlanCompletedV2Test { @Test @@ -14,7 +16,8 @@ class FirstLegLoadingPlanCompletedV2Test { 2, null, null, null, null, null, null, null, null, null, null, null, "客户备注", "内部备注", "仓库备注"); var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK", - null, null, null, 1, 2, null, null, null, null, List.of(item)); + null, null, null, 1, 2, null, null, null, null, List.of(item), + "11", "客户甲", "FIRST_LEG", List.of()); var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)); String json = new ObjectMapper().writeValueAsString(payload); assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark"); @@ -29,13 +32,14 @@ class FirstLegLoadingPlanCompletedV2Test { var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku)); var order = new FirstLegLoadingPlanCompletedV2.Order( "2", "2", "3", "ORD", "3", "RK", null, null, null, - 2, 20, 1, null, null, null, List.of(), "11", "ONE_CLICK_DROPSHIP", List.of(pallet)); + 2, 20, 1, null, null, null, List.of(), "11", "客户甲", "ONE_CLICK_DROPSHIP", List.of(pallet)); var payload = new FirstLegLoadingPlanCompletedV2( "4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)); var json = new ObjectMapper().valueToTree(payload); assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11"); + assertThat(json.at("/orders/0/customerName").asText()).isEqualTo("客户甲"); assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP"); assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2); } @@ -44,7 +48,7 @@ class FirstLegLoadingPlanCompletedV2Test { void carriesFrozenDestinationType() { var order = new FirstLegLoadingPlanCompletedV2.Order( "2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8", - 2, 20, 0, null, null, null, List.of(), "11", "FIRST_LEG", List.of(), "FBA"); + 2, 20, 0, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "FBA"); var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2( "4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order))); @@ -58,7 +62,8 @@ class FirstLegLoadingPlanCompletedV2Test { 20, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02")); var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK", - null, null, null, 2, 20, null, null, null, null, List.of(item)); + null, null, null, 2, 20, null, null, null, null, List.of(item), + "11", "客户甲", "FIRST_LEG", List.of()); var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2( "4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order))); @@ -66,4 +71,27 @@ class FirstLegLoadingPlanCompletedV2Test { assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2"); assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2); } + + @Test + void rejectsMissingImmutableCustomerName() { + assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.Order( + "2", "2", "3", "ORD", "3", "RK", null, null, null, + 1, 1, 0, null, null, null, List.of(), "11", " ", "FIRST_LEG", List.of())) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("customerName"); + } + + @Test + void openApiRequiresImmutableCustomerNameNextToCustomerId() throws Exception { + String yaml; + try (var input = FirstLegLoadingPlanCompletedV2Test.class.getResourceAsStream( + "/openapi/cross-region-events-v1.yaml")) { + assertThat(input).isNotNull(); + yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + assertThat(yaml) + .contains("required: [sourceInboundId, customerId, customerName") + .contains("customerId: {type: string, minLength: 1") + .contains("customerName: {type: string, minLength: 1, maxLength: 128") + .contains("consumers must not query domestic IAM"); + } } diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index f7a4db970..a1f20dcae 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -498,6 +498,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab '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; +CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence ( + business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)', + last_sequence INT NOT NULL COMMENT '当日已分配尾程单流水号', + PRIMARY KEY(business_date) +) ENGINE=InnoDB COMMENT='尾程单号按日原子流水'; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id')); 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 2016fbc7d..519fa5485 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -125,12 +125,18 @@ public final class LoadingPickDtos { Long operatorId, String operatorName, LocalDateTime createdAt) { } + public record CompletionCommand(Long id, String operationId, String status, Integer expectedTaskVersion, String remark, + Integer attemptCount, LocalDateTime nextRetryAt, String failureStage, + String failureCode, String requestId, String traceId, + LocalDateTime completedAt) { + } + public record Task(Long id, Long loadingPlanId, Long containerBookingId, Long inboundId, Integer inboundVersion, Integer loadingVersion, String cabinetNo, String currentContainerNo, String sourceType, String recordStatus, Long mergedIntoContainerBookingId, String taskStatus, String workflowStatus, Integer version, Assignment assignment, - Review review, List items) { + Review review, CompletionCommand completionCommand, List items) { } /** PDA 工作台只展示当前作业员可领取或已领取的进行中任务。 */ 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 84786775d..d75cc6c33 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -15,6 +15,7 @@ public interface LoadingCompletedEventMapper { @Select(""" SELECT p.id AS loading_plan_id, p.plan_no, p.container_booking_id, p.version AS aggregate_version, b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name, + b.customer_id AS booking_customer_id, b.customer_name, i.account_id, i.inbound_id AS source_inbound_id, COALESCE(o.root_inbound_id, o.id) AS root_inbound_id, o.order_id AS source_order_id, o.order_no AS source_order_no, @@ -79,7 +80,8 @@ public interface LoadingCompletedEventMapper { class Row { private Long loadingPlanId; private String planNo; private Long containerBookingId; private Integer aggregateVersion; private String containerNo; private String cabinetNo; - private Long warehouseId; private String warehouseName; private Long accountId; + private Long warehouseId; private String warehouseName; private Long bookingCustomerId; + private String customerName; private Long accountId; private Long sourceInboundId; private Long rootInboundId; private Long sourceOrderId; private Long loadingPlanItemId; private String sourceOrderNo; private Long rootOrderId; private String inboundCode; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java index 777523447..3fe11e0b5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java @@ -33,6 +33,9 @@ public interface LoadingCompletionCommandMapper extends BaseMapper due(int limit) { return mapper.findDue(limit); } public boolean claim(long id, String worker, LocalDateTime leaseUntil) { return mapper.claim(id, worker, leaseUntil) == 1; } public boolean renew(long id, String worker, LocalDateTime leaseUntil) { return mapper.renew(id, worker, leaseUntil) == 1; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index d2c706779..361e2bb67 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -37,7 +37,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; -/** 在装柜完成事务内按客户写跨区业务 Outbox。 */ +/** 在装柜完成事务内按客户及可信客户名称快照写跨区业务 Outbox。 */ @Service public class LoadingCompletedOutboxService { private static final long HANDOVER_VERSION = 2L; @@ -49,6 +49,7 @@ public class LoadingCompletedOutboxService { public void publish(long planId) { List rows = snapshotMapper.rows(planId); if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件"); + validateCustomerSnapshots(rows); Map> cartonMarks = cartonLabelPools(rows); List palletRows = snapshotMapper.palletRows(planId); rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId, @@ -104,10 +105,28 @@ public class LoadingCompletedOutboxService { first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount), sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType) ? pallets.size() : 0, sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), - sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets, + sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), first.getCustomerName().trim(), orderType, pallets, destinationType(sorted)); } + private void validateCustomerSnapshots(List rows) { + for (Row row : rows) { + if (row.getAccountId() == null || !Objects.equals(row.getAccountId(), row.getBookingCustomerId())) { + throw new BizException(ResultCode.CONFLICT, "装柜计划客户与柜次客户快照不一致,不能生成尾程交接事件"); + } + if (row.getCustomerName() == null || row.getCustomerName().isBlank()) { + throw new BizException(ResultCode.CONFLICT, "柜次缺少客户名称快照,不能生成尾程交接事件"); + } + } + rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId)).forEach((accountId, values) -> { + Set names = values.stream().map(Row::getCustomerName).map(String::trim) + .collect(java.util.stream.Collectors.toSet()); + if (names.size() != 1) { + throw new BizException(ResultCode.CONFLICT, "同一客户事件存在多个客户名称快照"); + } + }); + } + /** 国内按实装箱数从可用标签池稳定随机抽取箱唛,重复发布同一计划时结果不变。 */ private Map> cartonLabelPools(List rows) { Map> result = new java.util.LinkedHashMap<>(); 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 e0eb96dd0..eb919c242 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -16,6 +16,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.Operation; +import com.baiyue.firstleg.dto.LoadingPickDtos.CompletionCommand; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest; import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryResource; @@ -837,12 +838,17 @@ public class LoadingPickService { .filter(value -> detailInboundId.equals(value.inboundId())).findFirst().orElse(null); var inbound = owner == null || owner.tenantId() == null || owner.inboundId() == null ? null : repository.inbound(owner.tenantId(), owner.inboundId()); + LoadingCompletionCommandEntity command = completionCommands.latest(task.getId()); + CompletionCommand completionCommand = command == null ? null : new CompletionCommand(command.getId(), + command.getOperationId(), command.getCommandStatus(), command.getExpectedTaskVersion(), command.getRemark(), + command.getAttemptCount(), command.getNextRetryAt(), command.getFailureStage(), command.getFailureCode(), + command.getFailureRequestId(), command.getFailureTraceId(), command.getCompletedAt()); return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerBookingId(), owner == null ? null : owner.inboundId(), inbound == null ? null : inbound.version(), inbound == null ? null : inbound.version(), task.getCabinetNo(), booking.getContainerNo(), booking.getSourceType(), booking.getRecordStatus(), booking.getMergedIntoContainerBookingId(), task.getTaskStatus(), - workflowStatus, task.getVersion(), assignment, review, items); + workflowStatus, task.getVersion(), assignment, review, completionCommand, items); } private List validateBatchLines(LoadingPickTaskEntity task, BatchRequest request) { 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 cabdc8daf..7df494d3d 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -141,6 +141,7 @@ components: mergedIntoContainerBookingId: {type: integer, format: int64, nullable: true, description: Always null for the surviving ACTIVE operational container; populated only on hidden MERGED source history} taskStatus: {type: string, enum: [LOADING, COMPLETING, COMPLETION_FAILED, COMPLETED]} workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, PROCESSING, COMPLETION_FAILED, COMPLETED]} + completionCommand: {$ref: '#/components/schemas/CompletionCommand', nullable: true} version: {type: integer, minimum: 0} assignment: {type: object, nullable: true} review: {type: object, nullable: true} @@ -236,6 +237,22 @@ components: expectedTaskVersion: {type: integer, minimum: 0} operationId: {type: string, maxLength: 128} remark: {type: string, maxLength: 500} + CompletionCommand: + type: object + required: [id, operationId, status, expectedTaskVersion, attemptCount] + properties: + id: {type: integer, format: int64} + operationId: {type: string, maxLength: 128, description: Retry must reuse this exact identity} + status: {type: string, enum: [PROCESSING, RETRYABLE, FAILED, COMPLETED]} + expectedTaskVersion: {type: integer, minimum: 0, description: Retry must preserve this request-hashed version} + remark: {type: string, maxLength: 500, nullable: true, description: Retry must preserve this request-hashed remark} + attemptCount: {type: integer, minimum: 0} + nextRetryAt: {type: string, format: date-time, nullable: true} + failureStage: {type: string, nullable: true} + failureCode: {type: string, nullable: true} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + completedAt: {type: string, format: date-time, nullable: true} CompleteLoadingRequest: type: object required: [expectedTaskVersion, operationId] 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 e5c1ddc08..c7cb7aa7b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -308,7 +308,7 @@ class SmartLoadingSqlContractTest { .contains("container_booking_id BIGINT UNSIGNED NOT NULL") .contains("cabinet_no VARCHAR(64) NOT NULL") .contains("container_no VARCHAR(64) NULL") - .contains("task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING'") + .contains("task_status VARCHAR(24) NOT NULL DEFAULT 'LOADING'") .contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)") .contains("UNIQUE KEY uk_loading_pick_task_booking (container_booking_id)") .contains("KEY idx_loading_pick_container (container_no, task_status, started_at)") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 50ff2452d..4d89c4602 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -59,6 +59,7 @@ class LoadingCompletedOutboxServiceTest { assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101"); assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101"); assertThat(first.payload().at("/orders/0/destinationType").asText()).isEqualTo("OVERSEAS"); + assertThat(first.payload().at("/orders/0/customerName").asText()).isEqualTo("客户-11"); assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2"); assertThat(first.eventVersion()).isEqualTo(2); } @@ -72,6 +73,22 @@ class LoadingCompletedOutboxServiceTest { .hasMessageContaining("缺少 SKU"); } + @Test void rejectsMissingTrustedBookingCustomerName() { + Row value = row(11, 100, 101); + value.setCustomerName(" "); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + + assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("缺少客户名称快照"); + } + + @Test void rejectsBookingCustomerIdThatDoesNotMatchAccountEvent() { + Row value = row(11, 100, 101); + value.setBookingCustomerId(22L); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + + assertThatThrownBy(() -> service.publish(90L)).hasMessageContaining("客户与柜次客户快照不一致"); + } + @Test void fbaFreezesPackageCountWithoutRequiringCartonLabels() throws Exception { Row value = row(11, 100, 101); value.setDestinationType("FBA"); @@ -118,6 +135,19 @@ class LoadingCompletedOutboxServiceTest { .isEqualTo(second.payload().at("/orders/0/items/0/cartonMarks")); } + @Test void immutableCustomerNameParticipatesInPayloadHash() { + Row value = row(11, 100, 101); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + service.publish(90L); + value.setCustomerName("客户甲-更名快照"); + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox, org.mockito.Mockito.times(2)).insert(captor.capture()); + assertThat(captor.getAllValues().get(0).getPayloadHash()) + .isNotEqualTo(captor.getAllValues().get(1).getPayloadHash()); + } + @Test void fillsCollapsedMeasurementDetailFromUnusedInboundLabelsWithoutDuplicates() throws Exception { Row first = row(11, 100, 101); first.setPackageCount(1); Row second = row(11, 100, 101); second.setLoadingPlanItemId(102L); second.setPackageCount(2); @@ -144,7 +174,9 @@ class LoadingCompletedOutboxServiceTest { .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); assertThat(rowsSql).contains("p.plan_status = 'COMPLETED'") - .contains("o.process_status = 'LOADED'"); + .contains("o.process_status = 'LOADED'") + .contains("b.customer_id AS booking_customer_id") + .contains("b.customer_name"); for (String sql : List.of(rowsSql, palletsSql)) { assertThat(sql) .contains("actual_pick.loading_plan_item_id = i.id") @@ -219,7 +251,8 @@ class LoadingCompletedOutboxServiceTest { private Row row(long account,long inbound,long detail) { Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L); r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L); - r.setWarehouseName("WH"); r.setAccountId(account); r.setSourceInboundId(inbound); r.setRootInboundId(99L); + r.setWarehouseName("WH"); r.setBookingCustomerId(account); r.setCustomerName("客户-" + account); + r.setAccountId(account); r.setSourceInboundId(inbound); r.setRootInboundId(99L); r.setSourceOrderId(inbound+1000); r.setSourceOrderNo("FL"+inbound); r.setRootOrderId(1099L); r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8"); r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java index e593f1aa2..085ce11eb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java @@ -50,12 +50,16 @@ class TaillegRealProducerAcceptanceTest { assertEquals(2, jdbc.queryForObject("SELECT JSON_LENGTH(JSON_EXTRACT(payload_json,'$.payload.orders')) " + "FROM fl_inbound_outbox WHERE event_key=?", Integer.class, eventKey)); + assertEquals("真实验收客户", jdbc.queryForObject( + "SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].customerName')) " + + "FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey)); } private void seedCompletedLoadingPlan() { jdbc.update("INSERT IGNORE INTO fl_container_booking(id,booking_no,container_no,cabinet_no,warehouse_id," + - "warehouse_name,container_type,created_by,updated_by) VALUES(?,?,?,?,?,?,?,1,1)", - BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", "40HQ"); + "warehouse_name,customer_id,customer_name,container_type,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,1,1)", + BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", + CUSTOMER, "真实验收客户", "40HQ"); jdbc.update("INSERT IGNORE INTO fl_loading_plan(id,plan_no,container_booking_id,plan_status,created_by," + "updated_by,version) VALUES(?,?,?,'LOADING',1,1,0)", PLAN, MARKER + "-PLAN", BOOKING); seedInbound(FIRST_INBOUND, 990022221L, MARKER + "-FIRST-IN", "FBA_SEA", "STANDARD", "LOADED", "ONT8"); 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 3f8c3da74..841d24fd6 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 @@ -49,7 +49,7 @@ public class InboundPlanController { + OverseasPermissions.INBOUND_ORDER_CREATE) public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ConvertInboundPlanRequest request) { - return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId())); + return ApiResponse.success(service.convert(convertScope(user, request.customerId()), request.orderId())); } @GetMapping("/{planNo}") @@ -118,4 +118,12 @@ public class InboundPlanController { } return String.valueOf(user.tenantId()); } + + private String convertScope(AuthenticatedUser user, String requestedCustomer) { + if (user != null && "PLATFORM".equals(user.appCode()) && user.tenantId() != null && user.tenantId() == 0L + && (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer.trim()))) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "平台生成入库计划必须指定客户"); + } + return scope(user, requestedCustomer); + } } 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 index 8d4a6a20b..615215fa4 100644 --- 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 @@ -1,9 +1,10 @@ package com.baiyue.lastleg.inboundplan.dto; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; public record ConvertInboundPlanRequest( @NotNull Long orderId, - String customerId + @Size(max = 128) String customerId ) { } 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 ec121e6d7..5509c1510 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 @@ -59,6 +59,9 @@ public class InboundPlanService { @Transactional public InboundPlanResponse convert(String customerId, Long selectedOrderId) { + if (customerId == null || customerId.isBlank() || "0".equals(customerId.trim())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "平台生成入库计划必须指定客户"); + } List orders = orderMapper.lockOrder(customerId, selectedOrderId); if (orders == null || orders.isEmpty()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderNoDailySequenceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderNoDailySequenceMapper.java new file mode 100644 index 000000000..7f18718cd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderNoDailySequenceMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.mapper; + +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 java.time.LocalDate; + +@Mapper +public interface LastlegOrderNoDailySequenceMapper { + @Insert("INSERT INTO ll_order_no_daily_sequence(business_date,last_sequence) " + + "VALUES(#{businessDate},LAST_INSERT_ID(1)) " + + "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)") + int nextValue(@Param("businessDate") LocalDate businessDate); + + @Select("SELECT LAST_INSERT_ID()") + long currentValue(); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java index 86b89766f..766152347 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java @@ -1,35 +1,29 @@ package com.baiyue.lastleg.service; -import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.mapper.LastlegOrderNoDailySequenceMapper; +import jakarta.annotation.Resource; import org.springframework.stereotype.Component; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; @Component public class LastlegOrderNoGenerator { + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final DateTimeFormatter BUSINESS_DATE = DateTimeFormatter.ofPattern("yyMMdd"); + @Resource private LastlegOrderNoDailySequenceMapper sequenceMapper; + public String next() { - return "LMO" + IdWorker.getIdStr(); + return next(LocalDate.now(BUSINESS_ZONE)); } - /** The physical container number is the tail-leg business order grouping boundary. */ - public String forContainerNo(String containerNo) { - if (containerNo == null || containerNo.isBlank()) { - throw new IllegalArgumentException("containerNo is required"); - } - String normalized = containerNo.trim(); - if (normalized.matches("[0-9]{1,29}")) { - return "LMO" + normalized; - } - try { - byte[] digest = MessageDigest.getInstance("SHA-256") - .digest(normalized.getBytes(StandardCharsets.UTF_8)); - String digits = new BigInteger(1, digest).toString(); - return "LMO" + digits.substring(0, Math.min(29, digits.length())); - } catch (NoSuchAlgorithmException exception) { - throw new IllegalStateException("SHA-256 is unavailable", exception); + String next(LocalDate businessDate) { + sequenceMapper.nextValue(businessDate); + long sequence = sequenceMapper.currentValue(); + if (sequence < 1 || sequence > 9999) { + throw new IllegalStateException("尾程单日流水超出 0001-9999 范围: " + businessDate); } + return "LMO-" + BUSINESS_DATE.format(businessDate) + "-" + String.format("%04d", sequence); } } 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 0bf133005..8cdd78592 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 @@ -83,7 +83,7 @@ public class LoadingCompletedConsumer { shippingGates.initialize(payload.containerBookingId(), envelope.tenantId(), "", payload.containerNo(), 0, envelope.handoverVersion()); - String orderNo = orderNoGenerator.forContainerNo(payload.containerNo()); + String orderNo = orderNoGenerator.next(); JsonNode rawOrders = envelope.payload().path("orders"); for (int index = 0; index < payload.orders().size(); index++) { var source = payload.orders().get(index); @@ -183,6 +183,7 @@ public class LoadingCompletedConsumer { order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name()); order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId()); order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId()); + order.setCustomerName(source.customerName()); order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName()); order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null); order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo()); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 44d82c7d3..2a5382912 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -35,6 +35,12 @@ CREATE TABLE IF NOT EXISTS ll_container_shipping_gate ( KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence) ) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照'; +CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence ( + business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)', + last_sequence INT NOT NULL COMMENT '当日已分配尾程单流水号', + PRIMARY KEY(business_date) +) ENGINE=InnoDB COMMENT='尾程单号按日原子流水'; + CREATE TABLE IF NOT EXISTS ll_order ( id BIGINT NOT NULL COMMENT '雪花 ID', order_no VARCHAR(32) 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 40e76a822..222db0c4b 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -156,6 +156,22 @@ paths: - { 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 } } + /api/platform/v1/last-leg/inbound-plans/convert: + post: + summary: 平台生成入库计划 + description: 权限 platform:overseas-warehouse:inbound-order:create;必须同时传入真实 customerId 与 orderId,服务端按该客户和订单 ID 锁定归属,缺失 customerId 返回 A0400。 + security: [{ bearerAuth: [] }] + 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 包装的入库计划详情 } } /api/platform/v1/last-leg/dispatches: get: summary: 分页查询排车单 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 e95ead766..8a72de63b 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,6 +3,7 @@ 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.UpdateFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; @@ -20,6 +21,7 @@ import java.time.LocalDateTime; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -57,6 +59,34 @@ class InboundPlanControllerTest { verify(appointmentService).update("100", "APT-1", request); } + @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()); + } + + @Test + void platformConversionPassesTheProvidedLongCustomerId() { + ConvertInboundPlanRequest request = new ConvertInboundPlanRequest(9223372036854775807L, + "193000000000200003"); + + controller.convert(platform(), request); + + verify(service).convert("193000000000200003", 9223372036854775807L); + } + + @Test + void customerConversionIgnoresRequestedCustomerAndUsesAuthenticatedScope() { + 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); + } + private static AuthenticatedUser platform() { return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of("platform:overseas-warehouse:inbound-order:list")); 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 be0a0504c..1229d01ce 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 @@ -161,6 +161,26 @@ class InboundPlanServiceTest { verify(planMapper, never()).insert(any(InboundPlan.class)); } + @Test + void refusesMissingCustomerScopeBeforeLookingUpAnOrder() { + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convert(null, 2L)); + + assertEquals(400, error.getStatusCode().value()); + assertEquals("平台生成入库计划必须指定客户", error.getReason()); + verify(orderMapper, never()).lockOrder(any(), any()); + } + + @Test + void wrongCustomerCannotResolveAnOrderOutsideItsScope() { + when(orderMapper.lockOrder("200", 2L)).thenReturn(List.of()); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convert("200", 2L)); + + assertEquals(404, error.getStatusCode().value()); + verify(orderMapper).lockOrder("200", 2L); + verify(planMapper, never()).insert(any(InboundPlan.class)); + } + @Test void rejectsTransferWarehouseThatDiffersFromCargoSnapshots() { SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java index 410b21505..8c729bf5a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java @@ -1,31 +1,56 @@ package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.mapper.LastlegOrderNoDailySequenceMapper; +import org.apache.ibatis.annotations.Insert; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.util.Set; -import java.util.concurrent.*; -import java.util.stream.IntStream; -import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; + +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.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) class LastlegOrderNoGeneratorTest { - @Test void actualContainerNumberIsStableAndShared() { - LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator(); - assertEquals("LMO2087379674555461633", generator.forContainerNo("2087379674555461633")); - assertEquals(generator.forContainerNo("MSCU1"), new LastlegOrderNoGenerator().forContainerNo("MSCU1")); - assertNotEquals(generator.forContainerNo("MSCU1"), generator.forContainerNo("CAB1")); - assertTrue(generator.forContainerNo("MSCU1234567890").length() <= 32); - assertThrows(IllegalArgumentException.class, () -> generator.forContainerNo(" ")); + @Mock LastlegOrderNoDailySequenceMapper sequenceMapper; + private LastlegOrderNoGenerator generator; + + @BeforeEach + void setUp() { + generator = new LastlegOrderNoGenerator(); + ReflectionTestUtils.setField(generator, "sequenceMapper", sequenceMapper); } - @Test void remainsUniqueAcrossInstancesAndConcurrentCalls() throws Exception { - LastlegOrderNoGenerator beforeRestart=new LastlegOrderNoGenerator(); - String first=beforeRestart.next(); - LastlegOrderNoGenerator afterRestart=new LastlegOrderNoGenerator(); - assertNotEquals(first,afterRestart.next()); - ExecutorService pool=Executors.newFixedThreadPool(8); - try { - var tasks=IntStream.range(0,1000).mapToObj(i->(Callable)afterRestart::next).toList(); - Set ids=pool.invokeAll(tasks).stream().map(future->{ - try { return future.get(); } catch(Exception exception) { throw new RuntimeException(exception); } - }).collect(java.util.stream.Collectors.toSet()); - assertEquals(1000,ids.size()); - } finally { pool.shutdownNow(); } + @Test + void usesShanghaiBusinessDateAndDatabaseAllocatedSequence() { + LocalDate date = LocalDate.of(2026, 8, 27); + when(sequenceMapper.currentValue()).thenReturn(1L, 2L); + + assertEquals("LMO-260827-0001", generator.next(date)); + assertEquals("LMO-260827-0002", generator.next(date)); + verify(sequenceMapper, times(2)).nextValue(date); + } + + @Test + void mapperUsesOneAtomicDatabaseOperationForConcurrentInstances() throws Exception { + Insert insert = LastlegOrderNoDailySequenceMapper.class + .getMethod("nextValue", LocalDate.class).getAnnotation(Insert.class); + + assertTrue(insert.value()[0].contains("ON DUPLICATE KEY UPDATE")); + assertTrue(insert.value()[0].contains("LAST_INSERT_ID(last_sequence+1)")); + } + + @Test + void rejectsDailySequenceBeyondFourDigits() { + when(sequenceMapper.currentValue()).thenReturn(10_000L); + assertThrows(IllegalStateException.class, () -> generator.next(LocalDate.of(2026, 8, 27))); } } 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 bd54604ef..9fe37253e 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 @@ -2,9 +2,9 @@ package com.baiyue.lastleg.service; import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) class LoadingCompletedConsumerTest { - @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; - @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); } - @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } + @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; + @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); } + @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("LMO-[0-9]{6}-0001")); } @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",0L,1L); } @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @@ -17,14 +17,14 @@ class LoadingCompletedConsumerTest { assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id")); } @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","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","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(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 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","CONT-D","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(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 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")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of( new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","产品A",2,20,List.of("BOX-A1","BOX-A2")), new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-B","产品B",3,15,List.of("BOX-B1","BOX-B2","BOX-B3")))); - var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); + var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); EventEnvelope event=eventForPayload(new FirstLegLoadingPlanCompletedV1("plan-multi","LP-MULTI","book-multi","CONT-MULTI","CAB-MULTI","92","Warehouse",List.of(order)),2); assertEquals(1,service.consume(event)); @@ -112,6 +112,6 @@ class LoadingCompletedConsumerTest { assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType()))); verify(cartons,times(8)).insert(any(InboundPlanCarton.class)); } - private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } + private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1),"100","Customer 100","FIRST_LEG",List.of()),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2),"100","Customer 100","FIRST_LEG",List.of()))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); } }