From a8326455fca3d211e0bd2a4004448c042a01b028 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 14:40:51 +0800 Subject: [PATCH] fix(firstleg): allocate handover carton marks per inbound --- .../mapper/LoadingCartonManifestMapper.java | 15 +++-- .../LoadingCompletedOutboxService.java | 60 +++++++++++++------ .../LoadingCompletedOutboxServiceTest.java | 35 +++++++++-- 3 files changed, 83 insertions(+), 27 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java index 70eecdaf7..3c30e0806 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java @@ -3,6 +3,8 @@ package com.baiyue.firstleg.mapper; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import lombok.Getter; +import lombok.Setter; import java.util.List; @@ -10,12 +12,15 @@ import java.util.List; @Mapper public interface LoadingCartonManifestMapper { @Select(""" - SELECT box_code FROM fl_inbound_carton_label - WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND inbound_detail_id=#{inboundDetailId} - AND batch_status='ACTIVE' + SELECT inbound_detail_id, box_code FROM fl_inbound_carton_label + WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND batch_status='ACTIVE' ORDER BY carton_index,id """) - List labelPool(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, - @Param("inboundDetailId") long inboundDetailId); + List labelPool(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + @Getter @Setter + class LabelRow { + private Long inboundDetailId; + 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 2249da91b..102e4ca39 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -16,6 +16,7 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; +import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -30,9 +31,11 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; /** 在装柜完成事务内按客户写跨区业务 Outbox。 */ @Service @@ -106,33 +109,54 @@ public class LoadingCompletedOutboxService { /** 国内按实装箱数从可用标签池稳定随机抽取箱唛,重复发布同一计划时结果不变。 */ private Map> cartonLabelPools(List rows) { Map> result = new java.util.LinkedHashMap<>(); - for (Row row : rows) { - if (dropship(row.getBusinessType())) continue; - if (row.getSkuCode() == null || row.getSkuCode().isBlank()) { + Map> byInbound = rows.stream().filter(row -> !dropship(row.getBusinessType())) + .collect(java.util.stream.Collectors.groupingBy( + row -> new LabelPoolKey(row.getAccountId(), row.getSourceInboundId()), + LinkedHashMap::new, java.util.stream.Collectors.toList())); + for (Map.Entry> entry : byInbound.entrySet()) { + List inboundRows = entry.getValue().stream() + .sorted(Comparator.comparing(Row::getLoadingPlanItemId)).toList(); + if (inboundRows.stream().anyMatch(row -> row.getSkuCode() == null || row.getSkuCode().isBlank())) { throw new BizException(ResultCode.CONFLICT, "装柜明细缺少 SKU,不能建立箱唛与 SKU 的对应关系"); } - List labels = Objects.requireNonNullElse(cartonManifestMapper.labelPool(row.getAccountId(), - row.getSourceInboundId(), row.getSourceInboundDetailId()), List.of()); - if (labels.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜明细没有已打印的可用箱唛池,不能生成海外交接事件"); - if (labels.stream().anyMatch(label -> label == null || label.isBlank()) - || labels.stream().distinct().count() != labels.size()) { + List labels = Objects.requireNonNullElse(cartonManifestMapper.labelPool( + entry.getKey().accountId(), entry.getKey().inboundId()), List.of()); + if (labels.isEmpty()) { + throw new BizException(ResultCode.CONFLICT, "装柜明细没有已打印的可用箱唛池,不能生成海外交接事件"); + } + if (labels.stream().anyMatch(label -> label == null || label.getBoxCode() == null + || label.getBoxCode().isBlank()) + || labels.stream().map(LabelRow::getBoxCode).distinct().count() != labels.size()) { throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛池包含空值或重复箱号"); } - if (labels.size() < Objects.requireNonNullElse(row.getPackageCount(), 0)) { - throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛数量少于实装箱数,不能生成海外交接事件"); + Set used = new HashSet<>(); + for (Row row : inboundRows) { + int loadedPackages = Objects.requireNonNullElse(row.getPackageCount(), 0); + String seed = row.getLoadingPlanId() + ":" + row.getLoadingPlanItemId(); + Comparator stableOrder = Comparator.comparing(label -> EventPayloadHash.sha256( + (seed + ":" + label.getBoxCode()).getBytes(StandardCharsets.UTF_8))); + List selected = new ArrayList<>(); + labels.stream().filter(label -> !used.contains(label.getBoxCode()) + && Objects.equals(label.getInboundDetailId(), row.getSourceInboundDetailId())) + .sorted(stableOrder).limit(loadedPackages).map(LabelRow::getBoxCode).forEach(selected::add); + if (selected.size() < loadedPackages) { + labels.stream().filter(label -> !used.contains(label.getBoxCode()) + && !selected.contains(label.getBoxCode())) + .sorted(stableOrder).limit(loadedPackages - selected.size()) + .map(LabelRow::getBoxCode).forEach(selected::add); + } + if (selected.size() < loadedPackages) { + throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛数量少于实装箱数,不能生成海外交接事件"); + } + used.addAll(selected); + result.put(row.getLoadingPlanItemId(), List.copyOf(selected)); } - int loadedPackages = Objects.requireNonNullElse(row.getPackageCount(), 0); - String seed = row.getLoadingPlanId() + ":" + row.getLoadingPlanItemId(); - List selected = labels.stream() - .sorted(Comparator.comparing(label -> EventPayloadHash.sha256( - (seed + ":" + label).getBytes(StandardCharsets.UTF_8)))) - .limit(loadedPackages) - .toList(); - result.put(row.getLoadingPlanItemId(), selected); } return result; } + private record LabelPoolKey(long accountId, long inboundId) { } + private List palletSnapshots(List values, List source) { if (source.isEmpty()) return List.of(); Map> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy( 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 0a3156576..457052c45 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -8,6 +8,7 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; +import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -37,7 +38,9 @@ class LoadingCompletedOutboxServiceTest { ReflectionTestUtils.setField(service,"cartonManifestMapper",manifests); ReflectionTestUtils.setField(service,"outboxMapper",outbox); ReflectionTestUtils.setField(service,"objectMapper",mapper); - when(manifests.labelPool(anyLong(), anyLong(), anyLong())).thenReturn(List.of("BOX-POOL-1")); + when(manifests.labelPool(anyLong(), anyLong())).thenAnswer(invocation -> List.of( + label(101, "BOX-101"), label(102, "BOX-102"), + label(202, "BOX-202"), label(302, "BOX-302"))); } @Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception { @@ -54,7 +57,7 @@ class LoadingCompletedOutboxServiceTest { assertThat(first.payload().at("/orders/0/sourceInboundId").asText()).isEqualTo("100"); assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101"); assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101"); - assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-POOL-1"); + assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101"); assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2"); assertThat(first.eventVersion()).isEqualTo(2); } @@ -72,8 +75,9 @@ class LoadingCompletedOutboxServiceTest { Row value = row(11, 100, 101); value.setPackageCount(2); when(snapshots.rows(90L)).thenReturn(List.of(value)); - when(manifests.labelPool(11L, 100L, 101L)).thenReturn( - List.of("BOX-001", "BOX-002", "BOX-003", "BOX-004")); + when(manifests.labelPool(11L, 100L)).thenReturn(List.of( + label(101, "BOX-001"), label(101, "BOX-002"), + label(101, "BOX-003"), label(101, "BOX-004"))); service.publish(90L); service.publish(90L); @@ -87,6 +91,25 @@ class LoadingCompletedOutboxServiceTest { .isEqualTo(second.payload().at("/orders/0/items/0/cartonMarks")); } + @Test void fillsCollapsedMeasurementDetailFromUnusedInboundLabelsWithoutDuplicates() throws Exception { + Row first = row(11, 100, 101); first.setPackageCount(1); + Row second = row(11, 100, 101); second.setLoadingPlanItemId(102L); second.setPackageCount(2); + when(snapshots.rows(90L)).thenReturn(List.of(first, second)); + when(manifests.labelPool(11L, 100L)).thenReturn(List.of( + label(201, "BOX-001"), label(202, "BOX-002"), label(203, "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); + List firstMarks = mapper.convertValue(event.payload().at("/orders/0/items/0/cartonMarks"), List.class); + List secondMarks = mapper.convertValue(event.payload().at("/orders/0/items/1/cartonMarks"), List.class); + assertThat(firstMarks).hasSize(1); + assertThat(secondMarks).hasSize(2); + assertThat(firstMarks).doesNotContainAnyElementsOf(secondMarks); + } + @Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception { String sql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class) .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); @@ -137,4 +160,8 @@ class LoadingCompletedOutboxServiceTest { row.setSourceInboundDetailId(detail); row.setSkuCode(sku); row.setProductNameZh("产品"); row.setPackageCount(packages); row.setProductQuantity(quantity); row.setBoxCode(boxCode); return row; } + + private LabelRow label(long detailId, String boxCode) { + LabelRow row = new LabelRow(); row.setInboundDetailId(detailId); row.setBoxCode(boxCode); return row; + } }