fix: merge multi-pallet dropship handover fragments

This commit is contained in:
2026-09-14 15:44:51 +08:00
parent fe888f9278
commit 13d2fe8e3a
4 changed files with 141 additions and 16 deletions
@@ -131,16 +131,17 @@ public class LoadingCompletedOutboxService {
Map<String, PalletManifest> palletManifests) { Map<String, PalletManifest> palletManifests) {
List<Row> sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList(); List<Row> sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList();
Row first = sorted.get(0); Row first = sorted.get(0);
List<FirstLegLoadingPlanCompletedV2.Item> items = sorted.stream().map(row -> String fulfillmentType = fulfillmentType(sorted);
new FirstLegLoadingPlanCompletedV2.Item(string(row.getSourceInboundDetailId()), row.getSkuCode(), List<FirstLegLoadingPlanCompletedV2.Item> items = dropship(fulfillmentType)
row.getProductNameZh(), row.getProductNameEn(), row.getPackageCount(), row.getProductQuantity(), ? eventItems(sorted, cartonMarks)
row.getPalletCount(), row.getGrossWeightKg(), row.getNetWeightKg(), row.getDimensionalWeightKg(), : sorted.stream().map(row -> new FirstLegLoadingPlanCompletedV2.Item(
row.getVolumeCbm(), string(row.getDestinationWarehouseId()), row.getDestinationWarehouseCode(), 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.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(),
row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark(), null, row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark(), null,
cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of()))) cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of()))).toList();
.toList();
String fulfillmentType = fulfillmentType(sorted);
String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG";
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = dropship(fulfillmentType) List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = dropship(fulfillmentType)
? palletSnapshots(sorted, palletRows, palletManifests) : standardLabels.stream() ? palletSnapshots(sorted, palletRows, palletManifests) : standardLabels.stream()
@@ -161,6 +162,34 @@ public class LoadingCompletedOutboxService {
destinationType(sorted), first.getLxCode()); destinationType(sorted), first.getLxCode());
} }
/** 一件代发装柜按托盘产生分配行,跨区订单明细仍须按来源入仓明细保持唯一。 */
private List<FirstLegLoadingPlanCompletedV2.Item> eventItems(List<Row> rows,
Map<Long, List<String>> cartonMarks) {
Map<Long, List<Row>> byDetail = rows.stream().collect(java.util.stream.Collectors.groupingBy(
Row::getSourceInboundDetailId, LinkedHashMap::new, java.util.stream.Collectors.toList()));
List<FirstLegLoadingPlanCompletedV2.Item> result = new ArrayList<>();
for (List<Row> 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<String> 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。 */ /** 只按 Warehouse 已分配的物理 palletId 计数;无分配事实时保持 null。 */
private static Integer actualPalletCount(List<PalletRow> palletRows) { private static Integer actualPalletCount(List<PalletRow> palletRows) {
Set<Long> palletIds = palletRows.stream().map(PalletRow::getPalletId) Set<Long> palletIds = palletRows.stream().map(PalletRow::getPalletId)
@@ -376,6 +376,32 @@ class LoadingCompletedOutboxServiceTest {
.doesNotStartWith("sha256:"); .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<InboundOutboxEntity> 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 @Test
void pricingBusinessTypeCannotTurnAStandardOrderIntoDropship() throws Exception { void pricingBusinessTypeCannotTurnAStandardOrderIntoDropship() throws Exception {
Row value = row(11, 100, 101); Row value = row(11, 100, 101);
@@ -234,6 +234,8 @@ public class LoadingCompletedConsumer {
String destinationType = destinationType(payload, source); String destinationType = destinationType(payload, source);
String customerId = owner.customerId(); String customerId = owner.customerId();
String customerName = owner.customerName(); String customerName = owner.customerName();
List<FirstLegLoadingPlanCompletedV1.Item> items = "ONE_CLICK_DROPSHIP".equals(orderType)
? canonicalItems(source.items()) : source.items();
List<DropshipRequests.Pallet> dropshipPallets = "ONE_CLICK_DROPSHIP".equals(orderType) List<DropshipRequests.Pallet> dropshipPallets = "ONE_CLICK_DROPSHIP".equals(orderType)
? pallets(source, rawSource) : List.of(); ? pallets(source, rawSource) : List.of();
if ("ONE_CLICK_DROPSHIP".equals(orderType) && dropshipPallets.isEmpty()) { if ("ONE_CLICK_DROPSHIP".equals(orderType) && dropshipPallets.isEmpty()) {
@@ -259,7 +261,7 @@ public class LoadingCompletedConsumer {
? dropshipPallets.size() : Objects.requireNonNullElse(source.palletCount(), 0)); ? dropshipPallets.size() : Objects.requireNonNullElse(source.palletCount(), 0));
order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm()); order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm());
order.setEstimatedWarehouseAt(parsePlannedArrival(payload.estimatedWarehouseArrivalAt())); 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)); .filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add));
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId()); order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId());
order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceLoadingPlanId(payload.loadingPlanId());
@@ -293,7 +295,7 @@ public class LoadingCompletedConsumer {
throw new IllegalArgumentException("散货来源托盘码数量与托盘数不一致"); throw new IllegalArgumentException("散货来源托盘码数量与托盘数不一致");
} }
} }
for(var item:source.items()) { for(var item:items) {
LastlegOrderDetail detail=new LastlegOrderDetail(); LastlegOrderDetail detail=new LastlegOrderDetail();
detail.setCustomerId(customerId); detail.setCustomerId(customerId);
detail.setOrderId(order.getId()); detail.setSourceInboundDetailId(item.sourceInboundDetailId()); detail.setSkuCode(item.skuCode()); detail.setOrderId(order.getId()); detail.setSourceInboundDetailId(item.sourceInboundDetailId()); detail.setSkuCode(item.skuCode());
@@ -377,11 +379,8 @@ public class LoadingCompletedConsumer {
private Map<String, List<String>> dropshipReceivingLabels(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) { private Map<String, List<String>> dropshipReceivingLabels(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
Map<String, FirstLegLoadingPlanCompletedV1.Item> itemsByDetail = new java.util.LinkedHashMap<>(); Map<String, FirstLegLoadingPlanCompletedV1.Item> itemsByDetail = new java.util.LinkedHashMap<>();
for (var item : source.items()) { for (var item : canonicalItems(source.items())) {
if (item.sourceInboundDetailId() == null || item.sourceInboundDetailId().isBlank() itemsByDetail.put(item.sourceInboundDetailId(), item);
|| itemsByDetail.putIfAbsent(item.sourceInboundDetailId(), item) != null) {
throw new IllegalArgumentException("一件代发来源明细标识缺失或重复");
}
} }
Map<String, List<String>> marksByDetail = new java.util.LinkedHashMap<>(); Map<String, List<String>> marksByDetail = new java.util.LinkedHashMap<>();
for (JsonNode pallet : rawSource.path("pallets")) { for (JsonNode pallet : rawSource.path("pallets")) {
@@ -395,7 +394,7 @@ public class LoadingCompletedConsumer {
sku.path("cartonMarks").forEach(mark -> marks.add(mark.asText())); 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()) { if (marksByDetail.getOrDefault(item.sourceInboundDetailId(), List.of()).size() != item.packageCount()) {
throw new IllegalArgumentException("一件代发托盘箱唛与来源明细箱数不一致"); throw new IllegalArgumentException("一件代发托盘箱唛与来源明细箱数不一致");
} }
@@ -403,6 +402,51 @@ public class LoadingCompletedConsumer {
return marksByDetail; return marksByDetail;
} }
/** 接受同一来源明细因多托装柜形成的多个分片,并在尾程落库前合并为唯一明细。 */
private List<FirstLegLoadingPlanCompletedV1.Item> canonicalItems(List<FirstLegLoadingPlanCompletedV1.Item> source) {
Map<String, List<FirstLegLoadingPlanCompletedV1.Item>> byDetail = new java.util.LinkedHashMap<>();
for (var item : source == null ? List.<FirstLegLoadingPlanCompletedV1.Item>of() : source) {
if (item.sourceInboundDetailId() == null || item.sourceInboundDetailId().isBlank()) {
throw new IllegalArgumentException("一件代发来源明细标识缺失");
}
byDetail.computeIfAbsent(item.sourceInboundDetailId(), ignored -> new ArrayList<>()).add(item);
}
List<FirstLegLoadingPlanCompletedV1.Item> result = new ArrayList<>();
for (List<FirstLegLoadingPlanCompletedV1.Item> 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<String> 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<FirstLegLoadingPlanCompletedV1.Item> values,
java.util.function.Function<FirstLegLoadingPlanCompletedV1.Item, Integer> getter) {
List<Integer> present = values.stream().map(getter).filter(Objects::nonNull).toList();
return present.isEmpty() ? null : present.stream().mapToInt(Integer::intValue).sum();
}
private static BigDecimal sumDecimal(List<FirstLegLoadingPlanCompletedV1.Item> values,
java.util.function.Function<FirstLegLoadingPlanCompletedV1.Item, BigDecimal> getter) {
List<BigDecimal> 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, private void createCartonLabels(String customerId, Long orderId, String destinationType,
FirstLegLoadingPlanCompletedV1.Item item, List<String> sourceMarks) { FirstLegLoadingPlanCompletedV1.Item item, List<String> sourceMarks) {
if (!"OVERSEAS".equals(destinationType)) return; if (!"OVERSEAS".equals(destinationType)) return;
@@ -147,6 +147,32 @@ class LoadingCompletedConsumerTest {
verify(cartons,never()).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class)); verify(cartons,never()).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class));
verify(orders,never()).insert(any(LastlegOrder.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() { @Test void mapsSameSkuDifferentSourceDetailsWithoutGuessing() {
EventEnvelope original=overseasDropshipEvent(false); EventEnvelope original=overseasDropshipEvent(false);
var node=(com.fasterxml.jackson.databind.node.ObjectNode)original.payload().deepCopy(); var node=(com.fasterxml.jackson.databind.node.ObjectNode)original.payload().deepCopy();