208 lines
14 KiB
Java
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); }
|
|
}
|