From 61a97cd057ed32a6376f6bfe5b67d08cd912b3f6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 16:42:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/FirstLegLoadingPlanCompletedV1.java | 24 +++++++++- .../event/FirstLegLoadingPlanCompletedV2.java | 26 ++++++++++- .../openapi/cross-region-events-v1.yaml | 11 ++++- .../FirstLegLoadingPlanCompletedV1Test.java | 11 ++++- .../FirstLegLoadingPlanCompletedV2Test.java | 27 ++++++++++- .../mapper/LoadingCompletedEventMapper.java | 11 ++++- .../LoadingCompletedOutboxService.java | 18 ++++++-- .../LoadingCompletedOutboxServiceTest.java | 46 ++++++++++++++++++- .../TaillegRealProducerAcceptanceTest.java | 36 +++++++++++---- 9 files changed, 187 insertions(+), 23 deletions(-) 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 8c9a4578d..da09521dc 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 @@ -7,7 +7,14 @@ import java.util.List; public record FirstLegLoadingPlanCompletedV1( String loadingPlanId, String planNo, String containerBookingId, String containerNo, String cabinetNo, String warehouseId, String warehouseName, + String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt, List orders) { + public FirstLegLoadingPlanCompletedV1(String loadingPlanId, String planNo, String containerBookingId, + String containerNo, String cabinetNo, String warehouseId, + String warehouseName, List orders) { + this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId, + warehouseName, null, null, null, orders); + } public record Order( String sourceInboundId, String rootInboundId, @@ -16,6 +23,7 @@ public record FirstLegLoadingPlanCompletedV1( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + BigDecimal volumeWeightKg, List items, String customerId, String customerName, String orderType, List pallets, String destinationType) { public Order { @@ -40,9 +48,23 @@ public record FirstLegLoadingPlanCompletedV1( this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, packageCount, productQuantity, palletCount, - grossWeightKg, netWeightKg, volumeCbm, items, customerId, customerName, + grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName, 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, String customerId, String customerName, + String orderType, List pallets, String destinationType) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, packageCount, productQuantity, palletCount, + grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName, + orderType, pallets, destinationType); + } } public record Item( 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 cf908f2f6..261939432 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 @@ -7,7 +7,15 @@ import java.util.List; public record FirstLegLoadingPlanCompletedV2( String loadingPlanId, String planNo, String containerBookingId, String containerNo, String cabinetNo, String warehouseId, String warehouseName, + String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt, List orders) { + public FirstLegLoadingPlanCompletedV2(String loadingPlanId, String planNo, String containerBookingId, + String containerNo, String cabinetNo, String warehouseId, + String warehouseName, List orders) { + this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId, + warehouseName, null, null, null, orders); + } + public record Order( String sourceInboundId, String rootInboundId, String sourceOrderId, String sourceOrderNo, String rootOrderId, @@ -15,6 +23,7 @@ public record FirstLegLoadingPlanCompletedV2( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + BigDecimal volumeWeightKg, List items, String customerId, String customerName, String orderType, List pallets, /** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */ String destinationType) { @@ -41,9 +50,24 @@ public record FirstLegLoadingPlanCompletedV2( this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, inboundCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, packageCount, productQuantity, palletCount, - grossWeightKg, netWeightKg, volumeCbm, items, customerId, customerName, + grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName, 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, String customerId, String customerName, + String orderType, List pallets, String destinationType) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, packageCount, productQuantity, palletCount, + grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName, + orderType, pallets, destinationType); + } } public record Item( 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 index aadee97be..39b7d97f6 100644 --- 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 @@ -7,7 +7,7 @@ components: schemas: FirstLegLoadingPlanCompletedV2: type: object - required: [loadingPlanId, containerBookingId, orders] + required: [loadingPlanId, containerBookingId, estimatedPortArrivalAt, actualPortArrivalAt, estimatedWarehouseArrivalAt, orders] properties: loadingPlanId: {type: string} containerBookingId: {type: string} @@ -15,12 +15,15 @@ components: cabinetNo: {type: string} warehouseId: {type: string} warehouseName: {type: string} + estimatedPortArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local estimated port arrival; null when Firstleg has no fact} + actualPortArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local actual port arrival; never copied from an estimate} + estimatedWarehouseArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local estimated warehouse arrival; null when Firstleg has no fact} orders: type: array items: {$ref: '#/components/schemas/FirstLegLoadingPlanCompletedOrderV2'} FirstLegLoadingPlanCompletedOrderV2: type: object - required: [sourceInboundId, customerId, customerName, packageCount, items, orderType, pallets, destinationType] + required: [sourceInboundId, customerId, customerName, palletCount, packageCount, productQuantity, volumeCbm, volumeWeightKg, items, orderType, pallets, destinationType] properties: sourceInboundId: {type: string} sourceOrderId: {type: string} @@ -29,6 +32,10 @@ components: 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} + productQuantity: {type: [integer, 'null'], minimum: 0, description: Final loaded product quantity; null when unavailable} + palletCount: {type: [integer, 'null'], minimum: 1, description: Distinct physical pallet count from Firstleg allocation pallet identity; never a planned zero placeholder} + volumeCbm: {type: [number, 'null'], minimum: 0, description: Final loaded volume in cubic metres} + volumeWeightKg: {type: [number, 'null'], minimum: 0, description: Sum of final loaded item dimensional weight facts; null when unavailable} items: {type: array, items: {type: object}} orderType: {type: string, enum: [FIRST_LEG, ONE_CLICK_DROPSHIP]} pallets: {type: array, items: {type: object}} 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 fe22ac20c..c364654b2 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 @@ -52,13 +52,20 @@ class FirstLegLoadingPlanCompletedV1Test { void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception { String json = """ {"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1", - "warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100", - "destinationWarehouseId":"3","packageCount":1,"items":[],"customerId":"9001", + "warehouseId":"3","warehouseName":"海外仓","estimatedPortArrivalAt":"2026-09-30T12:00", + "actualPortArrivalAt":null,"estimatedWarehouseArrivalAt":"2026-10-03T09:00", + "orders":[{"sourceInboundId":"100","destinationWarehouseId":"3","packageCount":1, + "palletCount":2,"volumeWeightKg":12.500,"items":[],"customerId":"9001", "customerName":"客户甲","destinationType":"OVERSEAS"}]} """; FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class); assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS"); + assertThat(restored.estimatedPortArrivalAt()).isEqualTo("2026-09-30T12:00"); + assertThat(restored.actualPortArrivalAt()).isNull(); + assertThat(restored.estimatedWarehouseArrivalAt()).isEqualTo("2026-10-03T09:00"); + assertThat(restored.orders().get(0).palletCount()).isEqualTo(2); + assertThat(restored.orders().get(0).volumeWeightKg()).isEqualByComparingTo("12.500"); } } 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 812c1603e..d90ea1c4e 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,12 +4,35 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import java.util.List; +import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class FirstLegLoadingPlanCompletedV2Test { + @Test + void carriesOnlyFrozenContainerTimesAndActualOrderAggregates() { + var order = new FirstLegLoadingPlanCompletedV2.Order( + "2", "2", "3", "ORD", "3", "RK", null, "ONT8", "ONT8", + 12, 120, 3, null, null, new BigDecimal("2.500"), new BigDecimal("18.750"), + List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "OVERSEAS"); + var payload = new FirstLegLoadingPlanCompletedV2( + "4", "P", "5", "CONT", "CAB", "6", "WH", + "2026-09-30T12:00", null, "2026-10-03T09:00", List.of(order)); + + var json = new ObjectMapper().valueToTree(payload); + + assertThat(json.at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00"); + assertThat(json.at("/actualPortArrivalAt").isNull()).isTrue(); + assertThat(json.at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00"); + assertThat(json.at("/orders/0/palletCount").asInt()).isEqualTo(3); + assertThat(json.at("/orders/0/packageCount").asInt()).isEqualTo(12); + assertThat(json.at("/orders/0/productQuantity").asInt()).isEqualTo(120); + assertThat(json.at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("2.500"); + assertThat(json.at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("18.750"); + } + @Test void carriesTheThreeItemRemarks() throws Exception { var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1, @@ -92,6 +115,8 @@ class FirstLegLoadingPlanCompletedV2Test { .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"); + .contains("consumers must not query domestic IAM") + .contains("estimatedPortArrivalAt", "actualPortArrivalAt", "estimatedWarehouseArrivalAt") + .contains("palletCount", "volumeWeightKg", "never a planned zero placeholder"); } } 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 d75cc6c33..e0cab29ac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -7,6 +7,7 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import java.math.BigDecimal; +import java.time.LocalDateTime; import java.util.List; /** 读取 PDA 结算后的最终实装入仓单和冻结货物快照。 */ @@ -16,6 +17,8 @@ public interface LoadingCompletedEventMapper { 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, + b.estimated_port_arrival_at, b.actual_port_arrival_at, + b.estimated_warehouse_arrival_at, 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, @@ -50,7 +53,8 @@ public interface LoadingCompletedEventMapper { /** 读取一键代发托盘及其箱唛快照;普通订单不会使用该结果。 */ @Select(""" SELECT i.account_id, i.inbound_id AS source_inbound_id, - a.pallet_code, a.allocation_no, a.picked_package_count AS allocated_package_count, + a.pallet_id, a.reservation_id, a.pallet_code, a.allocation_no, + a.picked_package_count AS allocated_package_count, i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, i.product_name_zh, i.package_count, i.product_quantity, label.box_code @@ -82,6 +86,8 @@ public interface LoadingCompletedEventMapper { private Integer aggregateVersion; private String containerNo; private String cabinetNo; private Long warehouseId; private String warehouseName; private Long bookingCustomerId; private String customerName; private Long accountId; + private LocalDateTime estimatedPortArrivalAt; private LocalDateTime actualPortArrivalAt; + private LocalDateTime estimatedWarehouseArrivalAt; private Long sourceInboundId; private Long rootInboundId; private Long sourceOrderId; private Long loadingPlanItemId; private String sourceOrderNo; private Long rootOrderId; private String inboundCode; @@ -97,7 +103,8 @@ public interface LoadingCompletedEventMapper { @Getter @Setter class PalletRow { - private Long accountId; private Long sourceInboundId; private String palletCode; + private Long accountId; private Long sourceInboundId; private Long palletId; + private Long reservationId; private String palletCode; private Integer allocationNo; private Integer allocatedPackageCount; private Long sourceInboundDetailId; private String skuCode; private String productNameZh; private Integer packageCount; private Integer productQuantity; private String boxCode; 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 361e2bb67..6dd1b3330 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -68,7 +68,8 @@ public class LoadingCompletedOutboxService { .filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList(); var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()), first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(), - string(first.getWarehouseId()), first.getWarehouseName(), orders); + string(first.getWarehouseId()), first.getWarehouseName(), iso(first.getEstimatedPortArrivalAt()), + iso(first.getActualPortArrivalAt()), iso(first.getEstimatedWarehouseArrivalAt()), orders); JsonNode payloadNode = objectMapper.valueToTree(payload); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION; @@ -103,12 +104,20 @@ public class LoadingCompletedOutboxService { string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(), string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()), 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()), first.getCustomerName().trim(), orderType, pallets, + sumNullableInt(sorted, Row::getProductQuantity), actualPalletCount(palletRows), + sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), + sum(sorted, Row::getVolumeCbm), sum(sorted, Row::getDimensionalWeightKg), items, + string(first.getAccountId()), first.getCustomerName().trim(), orderType, pallets, destinationType(sorted)); } + /** 只按 Warehouse 已分配的物理 palletId 计数;无分配事实时保持 null。 */ + private static Integer actualPalletCount(List palletRows) { + Set palletIds = palletRows.stream().map(PalletRow::getPalletId) + .filter(Objects::nonNull).filter(value -> value > 0).collect(java.util.stream.Collectors.toSet()); + return palletIds.isEmpty() ? null : palletIds.size(); + } + private void validateCustomerSnapshots(List rows) { for (Row row : rows) { if (row.getAccountId() == null || !Objects.equals(row.getAccountId(), row.getBookingCustomerId())) { @@ -275,6 +284,7 @@ public class LoadingCompletedOutboxService { private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (JsonProcessingException e) { throw new BizException(ResultCode.INTERNAL_ERROR, "装柜完成事件序列化失败"); } } private static String string(Long value) { return value == null ? null : String.valueOf(value); } + private static String iso(java.time.LocalDateTime value) { return value == null ? null : value.toString(); } private static int sumInt(List rows, java.util.function.Function getter) { return rows.stream().map(getter).filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum(); } private static Integer sumNullableInt(List rows, java.util.function.Function getter) { List values=rows.stream().map(getter).filter(java.util.Objects::nonNull).toList(); return values.isEmpty()?null:values.stream().mapToInt(Integer::intValue).sum(); } private static BigDecimal sum(List rows, java.util.function.Function getter) { List values=rows.stream().map(getter).filter(java.util.Objects::nonNull).toList(); return values.isEmpty()?null:values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); } 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 4d89c4602..8128fcda0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -45,6 +45,10 @@ class LoadingCompletedOutboxServiceTest { @Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception { when(snapshots.rows(90L)).thenReturn(List.of(row(22,300,302),row(11,200,202),row(11,100,102),row(11,100,101))); + when(snapshots.palletRows(90L)).thenReturn(List.of( + pallet(11,100,1,"PLT-01",1,101,"SKU101",1,10,"BOX-101"), + pallet(11,100,1,"PLT-01",1,102,"SKU102",1,10,"BOX-102"), + pallet(11,100,2,"PLT-02",1,102,"SKU102",1,10,"BOX-102"))); service.publish(90L); ArgumentCaptor captor=ArgumentCaptor.forClass(InboundOutboxEntity.class); verify(outbox,org.mockito.Mockito.times(2)).insert(captor.capture()); @@ -60,6 +64,14 @@ class LoadingCompletedOutboxServiceTest { 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(first.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2); + assertThat(first.payload().at("/orders/0/packageCount").asInt()).isEqualTo(2); + assertThat(first.payload().at("/orders/0/productQuantity").asInt()).isEqualTo(20); + assertThat(first.payload().at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("2"); + assertThat(first.payload().at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("4"); + assertThat(first.payload().at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00"); + assertThat(first.payload().at("/actualPortArrivalAt").asText()).isEqualTo("2026-10-01T08:30"); + assertThat(first.payload().at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00"); assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2"); assertThat(first.eventVersion()).isEqualTo(2); } @@ -148,6 +160,28 @@ class LoadingCompletedOutboxServiceTest { .isNotEqualTo(captor.getAllValues().get(1).getPayloadHash()); } + @Test void keepsMissingPalletAndMeasurementFactsNullInsteadOfInventingZero() throws Exception { + Row value = row(11, 100, 101); + value.setProductQuantity(null); value.setVolumeCbm(null); value.setDimensionalWeightKg(null); + value.setEstimatedPortArrivalAt(null); value.setActualPortArrivalAt(null); + value.setEstimatedWarehouseArrivalAt(null); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + when(snapshots.palletRows(90L)).thenReturn(List.of()); + + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox).insert(captor.capture()); + EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + assertThat(event.payload().at("/orders/0/palletCount").isNull()).isTrue(); + assertThat(event.payload().at("/orders/0/productQuantity").isNull()).isTrue(); + assertThat(event.payload().at("/orders/0/volumeCbm").isNull()).isTrue(); + assertThat(event.payload().at("/orders/0/volumeWeightKg").isNull()).isTrue(); + assertThat(event.payload().at("/estimatedPortArrivalAt").isNull()).isTrue(); + assertThat(event.payload().at("/actualPortArrivalAt").isNull()).isTrue(); + assertThat(event.payload().at("/estimatedWarehouseArrivalAt").isNull()).isTrue(); + } + @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); @@ -176,7 +210,9 @@ class LoadingCompletedOutboxServiceTest { assertThat(rowsSql).contains("p.plan_status = 'COMPLETED'") .contains("o.process_status = 'LOADED'") .contains("b.customer_id AS booking_customer_id") - .contains("b.customer_name"); + .contains("b.customer_name") + .contains("b.estimated_port_arrival_at", "b.actual_port_arrival_at") + .contains("b.estimated_warehouse_arrival_at"); for (String sql : List.of(rowsSql, palletsSql)) { assertThat(sql) .contains("actual_pick.loading_plan_item_id = i.id") @@ -190,6 +226,7 @@ class LoadingCompletedOutboxServiceTest { .doesNotContain("'PICKING'"); } assertThat(palletsSql) + .contains("a.pallet_id", "a.reservation_id") .contains("label.tenant_id = i.account_id") .contains("label.inbound_detail_id = i.inbound_detail_id") .contains("label.batch_status = 'ACTIVE'") @@ -252,6 +289,9 @@ class LoadingCompletedOutboxServiceTest { 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.setBookingCustomerId(account); r.setCustomerName("客户-" + account); + r.setEstimatedPortArrivalAt(java.time.LocalDateTime.of(2026, 9, 30, 12, 0)); + r.setActualPortArrivalAt(java.time.LocalDateTime.of(2026, 10, 1, 8, 30)); + r.setEstimatedWarehouseArrivalAt(java.time.LocalDateTime.of(2026, 10, 3, 9, 0)); 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"); @@ -259,13 +299,15 @@ class LoadingCompletedOutboxServiceTest { r.setDestinationType("OVERSEAS"); r.setFulfillmentType("STANDARD"); r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE); - r.setNetWeightKg(BigDecimal.ONE); r.setVolumeCbm(BigDecimal.ONE); return r; + r.setNetWeightKg(BigDecimal.ONE); r.setDimensionalWeightKg(new BigDecimal("2")); + r.setVolumeCbm(BigDecimal.ONE); return r; } private PalletRow pallet(long account,long inbound,int allocation,String palletNo,int allocated, long detail,String sku,int packages,int quantity,String boxCode) { PalletRow row = new PalletRow(); row.setAccountId(account); row.setSourceInboundId(inbound); + row.setPalletId(inbound * 100 + allocation); row.setReservationId(inbound * 1000 + allocation); row.setAllocationNo(allocation); row.setPalletCode(palletNo); row.setAllocatedPackageCount(allocated); row.setSourceInboundDetailId(detail); row.setSkuCode(sku); row.setProductNameZh("产品"); row.setPackageCount(packages); row.setProductQuantity(quantity); row.setBoxCode(boxCode); return row; 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 085ce11eb..ef55c558c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java @@ -11,6 +11,7 @@ import org.springframework.context.annotation.Import; import org.springframework.jdbc.core.JdbcTemplate; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; @SpringBootTest(classes = TaillegRealProducerAcceptanceTest.AcceptanceApplication.class, webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = { @@ -53,13 +54,26 @@ class TaillegRealProducerAcceptanceTest { assertEquals("真实验收客户", jdbc.queryForObject( "SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].customerName')) " + "FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey)); + assertEquals("2026-09-30T12:00", jdbc.queryForObject( + "SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.estimatedPortArrivalAt')) " + + "FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey)); + assertNull(jdbc.queryForObject( + "SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.actualPortArrivalAt')) " + + "FROM fl_inbound_outbox WHERE event_key=?", String.class, eventKey)); + assertEquals(1, jdbc.queryForObject( + "SELECT JSON_EXTRACT(payload_json,'$.payload.orders[0].palletCount') " + + "FROM fl_inbound_outbox WHERE event_key=?", Integer.class, eventKey)); + assertEquals("35.0", jdbc.queryForObject( + "SELECT JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.orders[0].volumeWeightKg')) " + + "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,customer_id,customer_name,container_type,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,1,1)", + "warehouse_name,customer_id,customer_name,estimated_port_arrival_at,container_type,created_by,updated_by) " + + "VALUES(?,?,?,?,?,?,?,?,?,?,1,1)", BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", - CUSTOMER, "真实验收客户", "40HQ"); + CUSTOMER, "真实验收客户", java.time.LocalDateTime.of(2026, 9, 30, 12, 0), "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"); @@ -70,12 +84,8 @@ class TaillegRealProducerAcceptanceTest { MARKER + "-FIRST-IN", "FIRST-BOX-" + i, i, 3); for (int i = 1; i <= 2; i++) seedLabel(990022550L + i, DROPSHIP_INBOUND, 990022222L, 990022422L, MARKER + "-DROP-IN", "DROP-BOX-" + i, i, 2); - jdbc.update("INSERT IGNORE INTO fl_inbound_pallet_allocation(id,tenant_id,inbound_id,allocation_no," + - "warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," + - "allocated_volume_cbm,allocated_package_count,picked_package_count,location_id,location_code," + - "reservation_no,allocation_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED')", - 990022621L, CUSTOMER, DROPSHIP_INBOUND, 1, 92L, 0L, 990022721L, MARKER + "-PALLET-D", - 1.2, 0.4, 2, 2, 990022821L, "A-01", MARKER + "-RES"); + seedAllocation(990022620L, FIRST_INBOUND, 990022720L, 990022820L, 3, "F"); + seedAllocation(990022621L, DROPSHIP_INBOUND, 990022721L, 990022821L, 2, "D"); jdbc.update("INSERT INTO fl_loading_pick_task(id,loading_plan_id,container_booking_id,cabinet_no," + "container_no,task_status,started_by,started_by_name) VALUES(?,?,?,?,?,'LOADING',1,'真实验收')", 990022901L, PLAN, BOOKING, MARKER + "-CAB", MARKER + "-CNTR"); @@ -84,6 +94,16 @@ class TaillegRealProducerAcceptanceTest { jdbc.update("UPDATE fl_loading_plan SET plan_status='COMPLETED',confirmed_at=NOW(3),version=version+1 WHERE id=?", PLAN); } + private void seedAllocation(long id, long inboundId, long palletId, long locationId, + int packages, String suffix) { + jdbc.update("INSERT IGNORE INTO fl_inbound_pallet_allocation(id,tenant_id,inbound_id,allocation_no," + + "warehouse_id,warehouse_resource_tenant_id,pallet_id,pallet_code,pallet_capacity_cbm," + + "allocated_volume_cbm,allocated_package_count,picked_package_count,location_id,location_code," + + "reservation_id,reservation_no,allocation_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'CONFIRMED')", + id, CUSTOMER, inboundId, 1, 92L, 0L, palletId, MARKER + "-PALLET-" + suffix, + 1.2, 0.4, packages, packages, locationId, "A-01", id + 1000, MARKER + "-RES-" + suffix); + } + private void seedPickItem(long id, long planItemId, long inboundId, long detailId, int packages) { jdbc.update("INSERT INTO fl_loading_pick_item(id,pick_task_id,loading_plan_item_id,inbound_id," + "inbound_detail_id,planned_package_count,picked_package_count,item_status) VALUES(?,?,?,?,?,?,?,'PICKED')",