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 06296b222..3e5b242a1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -131,16 +131,17 @@ public class LoadingCompletedOutboxService { Map palletManifests) { List sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList(); Row first = sorted.get(0); - List items = sorted.stream().map(row -> - new FirstLegLoadingPlanCompletedV2.Item(string(row.getSourceInboundDetailId()), row.getSkuCode(), - row.getProductNameZh(), row.getProductNameEn(), row.getPackageCount(), row.getProductQuantity(), - row.getPalletCount(), row.getGrossWeightKg(), row.getNetWeightKg(), row.getDimensionalWeightKg(), - row.getVolumeCbm(), string(row.getDestinationWarehouseId()), row.getDestinationWarehouseCode(), + String fulfillmentType = fulfillmentType(sorted); + List items = dropship(fulfillmentType) + ? eventItems(sorted, cartonMarks) + : sorted.stream().map(row -> new FirstLegLoadingPlanCompletedV2.Item( + string(row.getSourceInboundDetailId()), row.getSkuCode(), row.getProductNameZh(), + row.getProductNameEn(), row.getPackageCount(), row.getProductQuantity(), row.getPalletCount(), + row.getGrossWeightKg(), row.getNetWeightKg(), row.getDimensionalWeightKg(), row.getVolumeCbm(), + string(row.getDestinationWarehouseId()), row.getDestinationWarehouseCode(), row.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(), row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark(), null, - cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of()))) - .toList(); - String fulfillmentType = fulfillmentType(sorted); + cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of()))).toList(); String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; List pallets = dropship(fulfillmentType) ? palletSnapshots(sorted, palletRows, palletManifests) : standardLabels.stream() @@ -161,6 +162,34 @@ public class LoadingCompletedOutboxService { destinationType(sorted), first.getLxCode()); } + /** 一件代发装柜按托盘产生分配行,跨区订单明细仍须按来源入仓明细保持唯一。 */ + private List eventItems(List rows, + Map> cartonMarks) { + Map> byDetail = rows.stream().collect(java.util.stream.Collectors.groupingBy( + Row::getSourceInboundDetailId, LinkedHashMap::new, java.util.stream.Collectors.toList())); + List result = new ArrayList<>(); + for (List fragments : byDetail.values()) { + Row first = fragments.get(0); + if (fragments.stream().anyMatch(row -> !Objects.equals(first.getSkuCode(), row.getSkuCode()) + || !Objects.equals(first.getDestinationWarehouseId(), row.getDestinationWarehouseId()) + || !Objects.equals(first.getDestinationWarehouseCode(), row.getDestinationWarehouseCode()))) { + throw new BizException(ResultCode.CONFLICT, "同一入仓明细的装柜分片快照不一致"); + } + List marks = fragments.stream() + .flatMap(row -> cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of()).stream()).toList(); + result.add(new FirstLegLoadingPlanCompletedV2.Item(string(first.getSourceInboundDetailId()), + first.getSkuCode(), first.getProductNameZh(), first.getProductNameEn(), + sumInt(fragments, Row::getPackageCount), sumNullableInt(fragments, Row::getProductQuantity), + sumNullableInt(fragments, Row::getPalletCount), sum(fragments, Row::getGrossWeightKg), + sum(fragments, Row::getNetWeightKg), sum(fragments, Row::getDimensionalWeightKg), + sum(fragments, Row::getVolumeCbm), string(first.getDestinationWarehouseId()), + first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), first.getFbaTrackingNo(), + first.getShipmentNo(), first.getShippingMark(), first.getCustomerRemark(), first.getInternalRemark(), + first.getWarehouseRemark(), null, marks)); + } + return List.copyOf(result); + } + /** 只按 Warehouse 已分配的物理 palletId 计数;无分配事实时保持 null。 */ private static Integer actualPalletCount(List palletRows) { Set palletIds = palletRows.stream().map(PalletRow::getPalletId) 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 b8b30d798..b74f3e744 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -376,6 +376,32 @@ class LoadingCompletedOutboxServiceTest { .doesNotStartWith("sha256:"); } + @Test void mergesOneDropshipSourceDetailSplitAcrossTwoPalletsBeforePublishing() throws Exception { + Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP"); + Row second = row(11, 100, 101); second.setFulfillmentType("ONE_CLICK_DROPSHIP"); + second.setLoadingPlanItemId(102L); + when(snapshots.rows(90L)).thenReturn(List.of(first, second)); + when(snapshots.workPalletCount(90L)).thenReturn(2); + when(snapshots.actualPalletRows(90L)).thenReturn(List.of( + actualPallet(11,100,"DP-260914-0021",101,"SKU101","BOX-001",10), + actualPallet(11,100,"DP-260914-0022",101,"SKU101","BOX-002",10))); + + 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/items")).hasSize(1); + assertThat(event.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101"); + assertThat(event.payload().at("/orders/0/items/0/packageCount").asInt()).isEqualTo(2); + assertThat(event.payload().at("/orders/0/items/0/productQuantity").asInt()).isEqualTo(20); + assertThat(event.payload().at("/orders/0/pallets")).hasSize(2); + assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/sourceInboundDetailId").asText()) + .isEqualTo("101"); + assertThat(event.payload().at("/orders/0/pallets/1/skuPackages/0/sourceInboundDetailId").asText()) + .isEqualTo("101"); + } + @Test void pricingBusinessTypeCannotTurnAStandardOrderIntoDropship() throws Exception { Row value = row(11, 100, 101); 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 80b5175d1..4c745c739 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 @@ -234,6 +234,8 @@ public class LoadingCompletedConsumer { String destinationType = destinationType(payload, source); String customerId = owner.customerId(); String customerName = owner.customerName(); + List items = "ONE_CLICK_DROPSHIP".equals(orderType) + ? canonicalItems(source.items()) : source.items(); List dropshipPallets = "ONE_CLICK_DROPSHIP".equals(orderType) ? pallets(source, rawSource) : List.of(); if ("ONE_CLICK_DROPSHIP".equals(orderType) && dropshipPallets.isEmpty()) { @@ -259,7 +261,7 @@ public class LoadingCompletedConsumer { ? dropshipPallets.size() : Objects.requireNonNullElse(source.palletCount(), 0)); order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm()); order.setEstimatedWarehouseAt(parsePlannedArrival(payload.estimatedWarehouseArrivalAt())); - order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg) + order.setVolumeWeightKg(items.stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg) .filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add)); order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId()); order.setSourceLoadingPlanId(payload.loadingPlanId()); @@ -293,7 +295,7 @@ public class LoadingCompletedConsumer { throw new IllegalArgumentException("散货来源托盘码数量与托盘数不一致"); } } - for(var item:source.items()) { + for(var item:items) { LastlegOrderDetail detail=new LastlegOrderDetail(); detail.setCustomerId(customerId); detail.setOrderId(order.getId()); detail.setSourceInboundDetailId(item.sourceInboundDetailId()); detail.setSkuCode(item.skuCode()); @@ -377,11 +379,8 @@ public class LoadingCompletedConsumer { private Map> dropshipReceivingLabels(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) { Map itemsByDetail = new java.util.LinkedHashMap<>(); - for (var item : source.items()) { - if (item.sourceInboundDetailId() == null || item.sourceInboundDetailId().isBlank() - || itemsByDetail.putIfAbsent(item.sourceInboundDetailId(), item) != null) { - throw new IllegalArgumentException("一件代发来源明细标识缺失或重复"); - } + for (var item : canonicalItems(source.items())) { + itemsByDetail.put(item.sourceInboundDetailId(), item); } Map> marksByDetail = new java.util.LinkedHashMap<>(); for (JsonNode pallet : rawSource.path("pallets")) { @@ -395,7 +394,7 @@ public class LoadingCompletedConsumer { sku.path("cartonMarks").forEach(mark -> marks.add(mark.asText())); } } - for (var item : source.items()) { + for (var item : itemsByDetail.values()) { if (marksByDetail.getOrDefault(item.sourceInboundDetailId(), List.of()).size() != item.packageCount()) { throw new IllegalArgumentException("一件代发托盘箱唛与来源明细箱数不一致"); } @@ -403,6 +402,51 @@ public class LoadingCompletedConsumer { return marksByDetail; } + /** 接受同一来源明细因多托装柜形成的多个分片,并在尾程落库前合并为唯一明细。 */ + private List canonicalItems(List source) { + Map> byDetail = new java.util.LinkedHashMap<>(); + for (var item : source == null ? List.of() : source) { + if (item.sourceInboundDetailId() == null || item.sourceInboundDetailId().isBlank()) { + throw new IllegalArgumentException("一件代发来源明细标识缺失"); + } + byDetail.computeIfAbsent(item.sourceInboundDetailId(), ignored -> new ArrayList<>()).add(item); + } + List result = new ArrayList<>(); + for (List fragments : byDetail.values()) { + var first = fragments.get(0); + if (fragments.stream().anyMatch(item -> !Objects.equals(first.skuCode(), item.skuCode()) + || !Objects.equals(first.destinationWarehouseId(), item.destinationWarehouseId()) + || !Objects.equals(first.destinationWarehouseCode(), item.destinationWarehouseCode()))) { + throw new IllegalArgumentException("同一来源明细的尾程事件分片快照不一致"); + } + List marks = fragments.stream().flatMap(item -> item.cartonMarks().stream()).toList(); + result.add(new FirstLegLoadingPlanCompletedV1.Item(first.sourceInboundDetailId(), first.skuCode(), + first.productNameZh(), first.productNameEn(), + fragments.stream().mapToInt(FirstLegLoadingPlanCompletedV1.Item::packageCount).sum(), + sumNullableInt(fragments, FirstLegLoadingPlanCompletedV1.Item::productQuantity), + sumNullableInt(fragments, FirstLegLoadingPlanCompletedV1.Item::palletCount), + sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::grossWeightKg), + sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::netWeightKg), + sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg), + sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::volumeCbm), + first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(), + first.fbaTrackingNo(), first.shipmentNo(), first.shippingMark(), first.palletNo(), marks)); + } + return List.copyOf(result); + } + + private static Integer sumNullableInt(List values, + java.util.function.Function getter) { + List present = values.stream().map(getter).filter(Objects::nonNull).toList(); + return present.isEmpty() ? null : present.stream().mapToInt(Integer::intValue).sum(); + } + + private static BigDecimal sumDecimal(List values, + java.util.function.Function getter) { + List present = values.stream().map(getter).filter(Objects::nonNull).toList(); + return present.isEmpty() ? null : present.stream().reduce(BigDecimal.ZERO, BigDecimal::add); + } + private void createCartonLabels(String customerId, Long orderId, String destinationType, FirstLegLoadingPlanCompletedV1.Item item, List sourceMarks) { if (!"OVERSEAS".equals(destinationType)) return; 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 38aad853f..df85a8cc4 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 @@ -147,6 +147,32 @@ class LoadingCompletedConsumerTest { verify(cartons,never()).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } + @Test void consumesOneDropshipSourceDetailSplitAcrossTwoPallets() { + EventEnvelope original=overseasDropshipEvent(false); + var node=(com.fasterxml.jackson.databind.node.ObjectNode)original.payload().deepCopy(); + var order=(com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0); + var secondItem=(com.fasterxml.jackson.databind.node.ObjectNode)order.path("items").get(0).deepCopy(); + ((com.fasterxml.jackson.databind.node.ArrayNode)order.path("items")).add(secondItem); + order.put("packageCount",10).put("productQuantity",100).put("palletCount",2); + var secondPallet=(com.fasterxml.jackson.databind.node.ObjectNode)order.path("pallets").get(0).deepCopy(); + secondPallet.put("palletNo","DP-260914-0022"); + var secondSku=(com.fasterxml.jackson.databind.node.ObjectNode)secondPallet.path("skuPackages").get(0); + var marks=secondSku.putArray("cartonMarks"); for(int n=6;n<=10;n++)marks.add("RK1#10-"+n); + ((com.fasterxml.jackson.databind.node.ArrayNode)order.path("pallets")).add(secondPallet); + + service.consume(rehash(original,node)); + + var detailCaptor=ArgumentCaptor.forClass(LastlegOrderDetail.class); + verify(details).insert(detailCaptor.capture()); + assertEquals("d-1",detailCaptor.getValue().getSourceInboundDetailId()); + assertEquals(10,detailCaptor.getValue().getPackageCount()); + assertEquals(100,detailCaptor.getValue().getProductQuantity()); + verify(cartons,times(10)).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class)); + var received=ArgumentCaptor.forClass(DropshipRequests.Receive.class); + verify(dropshipOrders).receive(received.capture()); + assertEquals(List.of("DP-260909-0143","DP-260914-0022"), + received.getValue().pallets().stream().map(DropshipRequests.Pallet::palletNo).toList()); + } @Test void mapsSameSkuDifferentSourceDetailsWithoutGuessing() { EventEnvelope original=overseasDropshipEvent(false); var node=(com.fasterxml.jackson.databind.node.ObjectNode)original.payload().deepCopy();