From 6b077c20db87c1320ac94be8b136b402734694b4 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 19 Aug 2026 09:28:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/FirstLegLoadingPlanCompletedV1.java | 46 +++++++++- .../FirstLegLoadingPlanCompletedV1Test.java | 21 +++++ .../mapper/LoadingCompletedEventMapper.java | 32 ++++++- .../LoadingCompletedOutboxService.java | 84 +++++++++++++++++-- .../LoadingCompletedOutboxServiceTest.java | 32 +++++++ 5 files changed, 206 insertions(+), 9 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 d396416d8..1b4978c53 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,7 +16,24 @@ public record FirstLegLoadingPlanCompletedV1( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items) { + List items, String customerId, String orderType, List pallets) { + public Order { + items = items == null ? List.of() : items; + orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; + pallets = pallets == null ? List.of() : pallets; + } + 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()); + } } public record Item( @@ -27,6 +44,31 @@ public record FirstLegLoadingPlanCompletedV1( BigDecimal dimensionalWeightKg, BigDecimal volumeCbm, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaTrackingNo, - String shipmentNo, String shippingMark) { + String shipmentNo, String shippingMark, String palletNo, List cartonMarks) { + public Item { + cartonMarks = cartonMarks == null ? List.of() : cartonMarks; + } + public Item(String sourceInboundDetailId, String skuCode, + String productNameZh, String productNameEn, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, + BigDecimal dimensionalWeightKg, BigDecimal volumeCbm, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, String fbaTrackingNo, + String shipmentNo, String shippingMark) { + this(sourceInboundDetailId, skuCode, productNameZh, productNameEn, packageCount, + productQuantity, palletCount, grossWeightKg, netWeightKg, + dimensionalWeightKg, volumeCbm, destinationWarehouseId, + destinationWarehouseCode, destinationWarehouseName, fbaTrackingNo, + shipmentNo, shippingMark, null, List.of()); + } } + + /** 一键代发入仓单的托盘冻结快照。 */ + public record PalletSnapshot(String palletNo, List skuPackages) {} + + /** 托盘下 SKU、箱数、数量及箱唛冻结快照。 */ + public record SkuPackageSnapshot(String skuCode, String productName, + int packageCount, int productQuantity, + List cartonMarks) {} } 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 2c9fb34cb..3b5b72757 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 @@ -26,4 +26,25 @@ class FirstLegLoadingPlanCompletedV1Test { assertThat(mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class)).isEqualTo(payload); assertThat(json).contains("2088000000000000003", "sourceInboundDetailId"); } + + @Test + void dropshipPayloadKeepsMultiplePalletsAndCartonMarks() throws Exception { + var sku1 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot( + "SKU-1", "产品1", 2, 20, List.of("BOX-001", "BOX-002")); + var sku2 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot( + "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", + 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, + "3", "海外仓", List.of(order)); + + String json = mapper.writeValueAsString(payload); + FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class); + + assertThat(restored).isEqualTo(payload); + assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId"); + } } 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 86735b3a3..d2f943b58 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -19,7 +19,7 @@ public interface LoadingCompletedEventMapper { 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, COALESCE(root_order.order_id, o.order_id) AS root_order_id, - o.inbound_code, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code, + o.inbound_code, o.business_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code, o.destination_name AS destination_warehouse_name, i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity, @@ -36,6 +36,27 @@ public interface LoadingCompletedEventMapper { """) List rows(@Param("planId") long planId); + /** 读取一键代发托盘及其箱唛快照;普通订单不会使用该结果。 */ + @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, + 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 + FROM fl_loading_plan_item i + JOIN fl_inbound_pallet_allocation a + ON a.tenant_id = i.account_id AND a.inbound_id = i.inbound_id + AND a.allocation_status IN ('CONFIRMED','TRANSFERRED','RELEASED') + AND a.picked_package_count > 0 + LEFT JOIN fl_inbound_carton_label label + ON label.tenant_id = i.account_id AND label.inbound_id = i.inbound_id + AND label.inbound_detail_id = i.inbound_detail_id AND label.batch_status = 'ACTIVE' + WHERE i.loading_plan_id = #{planId} AND i.deleted = 0 + ORDER BY i.account_id, i.inbound_id, a.allocation_no, + i.inbound_detail_id, label.carton_index, label.id + """) + List palletRows(@Param("planId") long planId); + @Getter @Setter class Row { private Long loadingPlanId; private String planNo; private Long containerBookingId; @@ -49,5 +70,14 @@ public interface LoadingCompletedEventMapper { private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; private String fbaTrackingNo; private String shipmentNo; private String shippingMark; + private String businessType; + } + + @Getter @Setter + class PalletRow { + private Long accountId; private Long sourceInboundId; 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 396341a2b..b913c1084 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -14,6 +14,7 @@ import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; +import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -29,6 +30,7 @@ import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; /** 在装柜完成事务内按客户写跨区业务 Outbox。 */ @Service @@ -41,17 +43,20 @@ public class LoadingCompletedOutboxService { public void publish(long planId) { List rows = snapshotMapper.rows(planId); if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件"); + List palletRows = snapshotMapper.palletRows(planId); rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId, java.util.TreeMap::new, java.util.stream.Collectors.toList())) - .forEach((accountId, accountRows) -> insert(envelope(accountId, accountRows))); + .forEach((accountId, accountRows) -> insert(envelope(accountId, accountRows, + palletRows.stream().filter(item -> accountId.equals(item.getAccountId())).toList()))); } - private EventEnvelope envelope(long accountId, List rows) { + private EventEnvelope envelope(long accountId, List rows, List palletRows) { Row first = rows.get(0); Map> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy( Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList())); List orders = byInbound.values().stream() - .map(this::order).toList(); + .map(value -> order(value, palletRows.stream() + .filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList())).toList(); var payload = new FirstLegLoadingPlanCompletedV1(String.valueOf(first.getLoadingPlanId()), first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(), string(first.getWarehouseId()), first.getWarehouseName(), orders); @@ -65,7 +70,7 @@ public class LoadingCompletedOutboxService { traceId, eventKey, null, payloadNode, hash).requireValid(); } - private FirstLegLoadingPlanCompletedV1.Order order(List values) { + private FirstLegLoadingPlanCompletedV1.Order order(List values, List palletRows) { List sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList(); Row first = sorted.get(0); List items = sorted.stream().map(row -> @@ -75,14 +80,81 @@ public class LoadingCompletedOutboxService { row.getVolumeCbm(), string(row.getDestinationWarehouseId()), row.getDestinationWarehouseCode(), row.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark())) .toList(); + String orderType = dropship(first.getBusinessType()) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; + List pallets = dropship(first.getBusinessType()) + ? palletSnapshots(sorted, palletRows) : List.of(); + if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) { + throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照"); + } return new FirstLegLoadingPlanCompletedV1.Order(string(first.getSourceInboundId()), 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), sumNullableInt(sorted, Row::getPalletCount), - sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), sum(sorted, Row::getVolumeCbm), items); + 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); } + private List palletSnapshots(List values, List source) { + if (source.isEmpty()) return List.of(); + Map> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy( + p -> (p.getAllocationNo() == null ? "" : p.getAllocationNo()) + "|" + p.getPalletCode(), + LinkedHashMap::new, java.util.stream.Collectors.toList())); + int loadedPackages = byPallet.values().stream().map(list -> list.get(0)).map(PalletRow::getAllocatedPackageCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); + if (loadedPackages != sumInt(values, Row::getPackageCount)) { + throw new BizException(ResultCode.CONFLICT, "一键代发实装箱数与托盘已拣箱数不一致"); + } + Map details = new LinkedHashMap<>(); + values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).forEach(row -> + details.putIfAbsent(row.getSourceInboundDetailId(), new DetailSnapshot(row.getSourceInboundDetailId(), + row.getSkuCode(), row.getProductNameZh(), row.getPackageCount(), row.getProductQuantity(), new ArrayList<>()))); + source.stream().filter(item -> item.getBoxCode() != null).forEach(item -> { + DetailSnapshot detail = details.get(item.getSourceInboundDetailId()); + if (detail != null && !detail.marks().contains(item.getBoxCode())) detail.marks().add(item.getBoxCode()); + }); + Map remainingPackages = details.values().stream().collect(java.util.stream.Collectors.toMap( + DetailSnapshot::detailId, DetailSnapshot::packageCount, (a,b)->a, LinkedHashMap::new)); + Map remainingMarks = details.values().stream().collect(java.util.stream.Collectors.toMap( + DetailSnapshot::detailId, ignored -> 0, (a,b)->a, LinkedHashMap::new)); + List result = new ArrayList<>(); + for (List palletRows : byPallet.values()) { + PalletRow first = palletRows.get(0); + int capacity = first.getAllocatedPackageCount() == null ? 0 : first.getAllocatedPackageCount(); + List sku = new ArrayList<>(); + for (DetailSnapshot detail : details.values()) { + int remaining = remainingPackages.getOrDefault(detail.detailId(), 0); + if (capacity <= 0 || remaining <= 0) continue; + int count = Math.min(capacity, remaining); + int totalQuantity = detail.productQuantity() == null ? count + : (int) Math.round((double) detail.productQuantity() * count / Math.max(1, detail.packageCount())); + int markStart = remainingMarks.getOrDefault(detail.detailId(), 0); + int markEnd = Math.min(detail.marks().size(), markStart + count); + List marks = new ArrayList<>(detail.marks().subList(markStart, markEnd)); + if (marks.size() != count) throw new BizException(ResultCode.CONFLICT, "一键代发箱唛数量与托盘箱数不一致"); + sku.add(new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot(detail.skuCode(), + detail.productName(), count, totalQuantity, marks)); + remainingPackages.put(detail.detailId(), remaining - count); + remainingMarks.put(detail.detailId(), markEnd); + capacity -= count; + } + if (!sku.isEmpty()) result.add(new FirstLegLoadingPlanCompletedV1.PalletSnapshot( + first.getPalletCode() == null ? "PALLET-" + first.getAllocationNo() : first.getPalletCode(), sku)); + } + if (remainingPackages.values().stream().mapToInt(Integer::intValue).sum() != 0) { + throw new BizException(ResultCode.CONFLICT, "一键代发托盘未覆盖全部实装货物明细"); + } + return result; + } + + private static boolean dropship(String value) { + return value != null && ("ONE_CLICK_DROPSHIP".equalsIgnoreCase(value) + || value.toUpperCase(java.util.Locale.ROOT).contains("DROPSHIP")); + } + + private record DetailSnapshot(Long detailId, String skuCode, String productName, + Integer packageCount, Integer productQuantity, List marks) {} + private void insert(EventEnvelope envelope) { InboundOutboxEntity value = new InboundOutboxEntity(); value.setId(IdWorker.getId()); value.setTenantId(Long.valueOf(envelope.tenantId())); 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 a513d6b4c..0f3116491 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -6,6 +6,7 @@ import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; +import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -48,6 +49,29 @@ class LoadingCompletedOutboxServiceTest { assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:1"); } + @Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception { + Row first = row(11, 100, 101); first.setBusinessType("ONE_CLICK_DROPSHIP"); + first.setPackageCount(2); first.setProductQuantity(20); first.setSkuCode("SKU-A"); + Row second = row(11, 100, 102); second.setBusinessType("ONE_CLICK_DROPSHIP"); + second.setPackageCount(1); second.setProductQuantity(5); second.setSkuCode("SKU-B"); + when(snapshots.rows(90L)).thenReturn(List.of(first, second)); + when(snapshots.palletRows(90L)).thenReturn(List.of( + pallet(11,100,1,"PLT-01",2,101,"SKU-A",2,20,"BOX-001"), + pallet(11,100,1,"PLT-01",2,101,"SKU-A",2,20,"BOX-002"), + pallet(11,100,2,"PLT-02",1,102,"SKU-B",1,5,"BOX-003"))); + + 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/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP"); + assertThat(event.payload().at("/orders/0/customerId").asText()).isEqualTo("11"); + assertThat(event.payload().at("/orders/0/pallets")).hasSize(2); + assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2); + assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2); + } + 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); @@ -58,4 +82,12 @@ class LoadingCompletedOutboxServiceTest { r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE); r.setNetWeightKg(BigDecimal.ONE); 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.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; + } }