fix(dropship): preserve carton quantities and shared pallet outbound ownership

This commit is contained in:
2026-09-14 20:55:09 +08:00
parent 6dbcb2128c
commit 4f13ee2ec3
24 changed files with 302 additions and 99 deletions
@@ -293,61 +293,28 @@ public class LoadingCompletedOutboxService {
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> palletSnapshots(List<Row> values, List<PalletRow> source,
Map<String, PalletManifest> manifests) {
if (source.isEmpty()) return List.of();
if (source.stream().anyMatch(item -> Boolean.TRUE.equals(item.getActualBinding()))) {
return actualPalletSnapshots(values, source, manifests);
if (source.isEmpty() || source.stream().anyMatch(item -> !Boolean.TRUE.equals(item.getActualBinding()))) {
throw new BizException(ResultCode.CONFLICT, "一件代发必须提供真实逐箱实收托盘快照");
}
Map<String, List<PalletRow>> 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<Long, DetailSnapshot> 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<Long, Integer> remainingPackages = details.values().stream().collect(java.util.stream.Collectors.toMap(
DetailSnapshot::detailId, DetailSnapshot::packageCount, (a,b)->a, LinkedHashMap::new));
Map<Long, Integer> remainingMarks = details.values().stream().collect(java.util.stream.Collectors.toMap(
DetailSnapshot::detailId, ignored -> 0, (a,b)->a, LinkedHashMap::new));
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> result = new ArrayList<>();
for (List<PalletRow> palletRows : byPallet.values()) {
PalletRow first = palletRows.get(0);
int capacity = first.getAllocatedPackageCount() == null ? 0 : first.getAllocatedPackageCount();
List<FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot> 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<String> marks = new ArrayList<>(detail.marks().subList(markStart, markEnd));
if (marks.size() != count) throw new BizException(ResultCode.CONFLICT, "一键代发箱唛数量与托盘箱数不一致");
sku.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.detailId().toString(), 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 FirstLegLoadingPlanCompletedV2.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;
return actualPalletSnapshots(values, source, manifests);
}
/** 现场逐箱扫描已给出精确箱到托关系,不再使用旧的按托盘容量顺序推算。 */
private Map<String, Integer> cartonQuantities(List<PalletRow> rows, List<String> marks) {
Map<String, Integer> quantities = new LinkedHashMap<>();
for (PalletRow row : rows) {
if (!marks.contains(row.getBoxCode())) continue;
Integer quantity = row.getActualSkuQuantity();
if (quantity == null || quantity <= 0 || quantities.putIfAbsent(row.getBoxCode(), quantity) != null) {
throw new BizException(ResultCode.CONFLICT, "一件代发箱唛缺少有效的逐箱实点 SKU 数量");
}
}
if (quantities.size() != marks.size()) {
throw new BizException(ResultCode.CONFLICT, "一件代发逐箱实点 SKU 数量未覆盖全部箱唛");
}
return quantities;
}
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> actualPalletSnapshots(
List<Row> values, List<PalletRow> source, Map<String, PalletManifest> manifests) {
Map<Long, Row> details = values.stream().collect(java.util.stream.Collectors.toMap(
@@ -373,7 +340,7 @@ public class LoadingCompletedOutboxService {
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
if (quantity <= 0) throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘缺少实点 SKU 数量");
skus.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.getSourceInboundDetailId().toString(), detail.getSkuCode(),
detail.getProductNameZh(), marks.size(), quantity, marks));
detail.getProductNameZh(), marks.size(), quantity, marks, cartonQuantities(detailRows.getValue(), marks)));
actualPackages += marks.size();
}
PalletManifest manifest = manifests.get(pallet.getKey());
@@ -415,8 +382,6 @@ public class LoadingCompletedOutboxService {
return "ONE_CLICK_DROPSHIP".equals(value);
}
private record DetailSnapshot(Long detailId, String skuCode, String productName,
Integer packageCount, Integer productQuantity, List<String> marks) {}
private record PalletManifest(int version, int packageCount, int fragmentCount, String hash) {}
private void insert(EventEnvelope envelope) {
@@ -290,7 +290,7 @@ class LoadingCompletedOutboxServiceTest {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {true, false})
void freezesRepeatedSkuCartonsBySourceDetailForBothPalletSources(boolean actualBinding) throws Exception {
void freezesActualSkuCartonsAndRejectsInferredPalletSources(boolean actualBinding) throws Exception {
Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP");
first.setSkuCode("SAME-SKU"); first.setProductQuantity(10);
Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP");
@@ -305,6 +305,10 @@ class LoadingCompletedOutboxServiceTest {
when(snapshots.palletRows(90L)).thenReturn(List.of(
pallet(11,100,1,"DP-260909-0001",2,102,"SAME-SKU",1,20,"BOX-102"),
pallet(11,100,1,"DP-260909-0001",2,101,"SAME-SKU",1,10,"BOX-101")));
assertThatThrownBy(() -> service.publish(90L)).isInstanceOf(com.baiyue.common.core.exception.BizException.class)
.hasMessageContaining("真实逐箱实收");
verify(outbox, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
return;
}
service.publish(90L);
service.publish(90L);
@@ -320,6 +324,7 @@ class LoadingCompletedOutboxServiceTest {
assertThat(sku.path("cartonMarks")).hasSize(1);
assertThat(sku.path("cartonMarks").get(0).asText()).isEqualTo("BOX-" + detail);
assertThat(sku.path("productQuantity").asInt()).isEqualTo("101".equals(detail) ? 10 : 20);
assertThat(sku.path("cartonQuantities").path("BOX-" + detail).asInt()).isEqualTo("101".equals(detail) ? 10 : 20);
}
assertThat(frozen.get(0).path("sourceInboundDetailId"))
.isNotEqualTo(frozen.get(1).path("sourceInboundDetailId"));
@@ -332,10 +337,11 @@ class LoadingCompletedOutboxServiceTest {
Row second = row(11, 100, 102); second.setFulfillmentType("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")));
when(snapshots.workPalletCount(90L)).thenReturn(2);
when(snapshots.actualPalletRows(90L)).thenReturn(List.of(
actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-001",7),
actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-002",13),
actualPallet(11,100,"PLT-02",102,"SKU-B","BOX-003",5)));
service.publish(90L);
@@ -346,6 +352,8 @@ class LoadingCompletedOutboxServiceTest {
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/pallets/0/skuPackages/0/cartonQuantities/BOX-001").asInt()).isEqualTo(7);
assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonQuantities/BOX-002").asInt()).isEqualTo(13);
assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2);
}
@@ -463,6 +471,7 @@ class LoadingCompletedOutboxServiceTest {
String sku,String boxCode,int actualSkuQuantity) {
PalletRow row = pallet(account,inbound,0,palletNo,1,detail,sku,1,1,boxCode);
row.setActualBinding(true);
row.setPalletId(Integer.toUnsignedLong(palletNo.hashCode()) + 1L);
row.setActualSkuQuantity(actualSkuQuantity);
return row;
}