Files
baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java
T
2026-08-20 16:45:47 +08:00

208 lines
14 KiB
Java

package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventIdentifiers;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.FirstLegLoadingPlanCompletedV2;
import com.baiyue.common.event.RegionCode;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
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.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/** 在装柜完成事务内按客户写跨区业务 Outbox。 */
@Service
public class LoadingCompletedOutboxService {
private static final long HANDOVER_VERSION = 2L;
@Resource private LoadingCompletedEventMapper snapshotMapper;
@Resource private LoadingCartonManifestMapper cartonManifestMapper;
@Resource private InboundOutboxMapper outboxMapper;
@Resource private ObjectMapper objectMapper;
public void publish(long planId) {
List<Row> rows = snapshotMapper.rows(planId);
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
List<PalletRow> palletRows = snapshotMapper.palletRows(planId);
rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId,
java.util.TreeMap::new, java.util.stream.Collectors.toList()))
.forEach((accountId, accountRows) -> insert(envelope(accountId, accountRows,
palletRows.stream().filter(item -> accountId.equals(item.getAccountId())).toList(), cartonMarks)));
}
private EventEnvelope envelope(long accountId, List<Row> rows, List<PalletRow> palletRows,
Map<Long, List<String>> cartonMarks) {
Row first = rows.get(0);
Map<Long, List<Row>> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy(
Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList()));
List<FirstLegLoadingPlanCompletedV2.Order> orders = byInbound.values().stream()
.map(value -> order(value, palletRows.stream()
.filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList();
var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()),
first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(),
string(first.getWarehouseId()), first.getWarehouseName(), orders);
JsonNode payloadNode = objectMapper.valueToTree(payload);
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION;
String traceId = EventIdentifiers.newUuid();
return new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 2,
Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(accountId), "LOADING_PLAN",
String.valueOf(first.getLoadingPlanId()), first.getAggregateVersion(), HANDOVER_VERSION,
traceId, eventKey, null, payloadNode, hash).requireValid();
}
private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows,
Map<Long, List<String>> cartonMarks) {
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(),
row.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(),
row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark(), null,
cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of())))
.toList();
String orderType = dropship(first.getBusinessType()) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG";
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = dropship(first.getBusinessType())
? palletSnapshots(sorted, palletRows) : List.of();
if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照");
}
return new FirstLegLoadingPlanCompletedV2.Order(string(first.getSourceInboundId()),
string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(),
string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()),
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType)
? pallets.size() : 0, sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets);
}
/**
* 国内只确认本柜实装箱数,不扫每张箱唛。事件携带全量可用标签池,海外首扫才确权具体箱唛。
*/
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()) {
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()) {
throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛池包含空值或重复箱号");
}
if (labels.size() < Objects.requireNonNullElse(row.getPackageCount(), 0)) {
throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛数量少于实装箱数,不能生成海外交接事件");
}
result.put(row.getLoadingPlanItemId(), labels);
}
return result;
}
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(
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.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;
}
private static boolean dropship(String value) {
return value != null && ("ONE_CLICK_DROPSHIP".equalsIgnoreCase(value)
|| value.toUpperCase(java.util.Locale.ROOT).contains("DROPSHIP"));
}
private record DetailSnapshot(Long detailId, String skuCode, String productName,
Integer packageCount, Integer productQuantity, List<String> marks) {}
private void insert(EventEnvelope envelope) {
InboundOutboxEntity value = new InboundOutboxEntity();
value.setId(IdWorker.getId()); value.setTenantId(Long.valueOf(envelope.tenantId()));
value.setAggregateType(envelope.aggregateType()); value.setAggregateId(Long.valueOf(envelope.aggregateId()));
value.setEventType(envelope.eventType()); value.setEventKey(envelope.correlationId());
value.setEventVersion(envelope.eventVersion()); value.setAggregateSequence((int) envelope.aggregateVersion());
value.setPayloadHash(envelope.payloadHash()); value.setPayloadJson(json(envelope));
value.setPublishStatus("PENDING"); value.setRetryCount(0); value.setCreatedAt(LocalDateTime.now());
try { outboxMapper.insert(value); } catch (DuplicateKeyException ignored) { }
}
private String json(Object value) { try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException e) { throw new BizException(ResultCode.INTERNAL_ERROR, "装柜完成事件序列化失败"); } }
private static String string(Long value) { return value == null ? null : String.valueOf(value); }
private static int sumInt(List<Row> rows, java.util.function.Function<Row,Integer> getter) { return rows.stream().map(getter).filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum(); }
private static Integer sumNullableInt(List<Row> rows, java.util.function.Function<Row,Integer> getter) { List<Integer> values=rows.stream().map(getter).filter(java.util.Objects::nonNull).toList(); return values.isEmpty()?null:values.stream().mapToInt(Integer::intValue).sum(); }
private static BigDecimal sum(List<Row> rows, java.util.function.Function<Row,BigDecimal> getter) { List<BigDecimal> values=rows.stream().map(getter).filter(java.util.Objects::nonNull).toList(); return values.isEmpty()?null:values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); }
}