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) {
List<Row> sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList();
Row first = sorted.get(0);
List<FirstLegLoadingPlanCompletedV2.Item> 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<FirstLegLoadingPlanCompletedV2.Item> 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<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = dropship(fulfillmentType)
? palletSnapshots(sorted, palletRows, palletManifests) : standardLabels.stream()
@@ -161,6 +162,34 @@ public class LoadingCompletedOutboxService {
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。 */
private static Integer actualPalletCount(List<PalletRow> palletRows) {
Set<Long> palletIds = palletRows.stream().map(PalletRow::getPalletId)
@@ -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<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
void pricingBusinessTypeCannotTurnAStandardOrderIntoDropship() throws Exception {
Row value = row(11, 100, 101);
@@ -234,6 +234,8 @@ public class LoadingCompletedConsumer {
String destinationType = destinationType(payload, source);
String customerId = owner.customerId();
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)
? 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<String, List<String>> dropshipReceivingLabels(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
Map<String, FirstLegLoadingPlanCompletedV1.Item> 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<String, List<String>> 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<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,
FirstLegLoadingPlanCompletedV1.Item item, List<String> sourceMarks) {
if (!"OVERSEAS".equals(destinationType)) return;
@@ -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();