fix(firstleg): allocate handover carton marks per inbound

This commit is contained in:
2026-08-24 14:40:51 +08:00
parent 2f32f1d827
commit a8326455fc
3 changed files with 83 additions and 27 deletions
@@ -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<String> labelPool(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("inboundDetailId") long inboundDetailId);
List<LabelRow> labelPool(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Getter @Setter
class LabelRow {
private Long inboundDetailId;
private String boxCode;
}
}
@@ -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<Long, List<String>> cartonLabelPools(List<Row> rows) {
Map<Long, List<String>> result = new java.util.LinkedHashMap<>();
for (Row row : rows) {
if (dropship(row.getBusinessType())) continue;
if (row.getSkuCode() == null || row.getSkuCode().isBlank()) {
Map<LabelPoolKey, List<Row>> 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<LabelPoolKey, List<Row>> entry : byInbound.entrySet()) {
List<Row> 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<String> 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<LabelRow> 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<String> used = new HashSet<>();
for (Row row : inboundRows) {
int loadedPackages = Objects.requireNonNullElse(row.getPackageCount(), 0);
String seed = row.getLoadingPlanId() + ":" + row.getLoadingPlanItemId();
Comparator<LabelRow> stableOrder = Comparator.comparing(label -> EventPayloadHash.sha256(
(seed + ":" + label.getBoxCode()).getBytes(StandardCharsets.UTF_8)));
List<String> 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<String> 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<FirstLegLoadingPlanCompletedV2.PalletSnapshot> palletSnapshots(List<Row> values, List<PalletRow> source) {
if (source.isEmpty()) return List.of();
Map<String, List<PalletRow>> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy(
@@ -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<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox).insert(captor.capture());
EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
List<String> firstMarks = mapper.convertValue(event.payload().at("/orders/0/items/0/cartonMarks"), List.class);
List<String> 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;
}
}