615 lines
39 KiB
Java
615 lines
39 KiB
Java
package com.baiyue.lastleg.service;
|
|
|
|
import com.baiyue.common.event.*;
|
|
import com.baiyue.lastleg.entity.*;
|
|
import com.baiyue.lastleg.mapper.*;
|
|
import com.baiyue.lastleg.dropship.dto.DropshipRequests;
|
|
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
|
|
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
|
|
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
|
|
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
|
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
|
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
|
|
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
|
import jakarta.annotation.Resource;
|
|
import org.springframework.dao.DuplicateKeyException;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
import org.springframework.transaction.annotation.Isolation;
|
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
|
import java.math.BigDecimal;
|
|
import java.util.Map;
|
|
import java.time.LocalDateTime;
|
|
import java.util.Objects;
|
|
import java.util.ArrayList;
|
|
import java.util.HashSet;
|
|
import java.util.List;
|
|
import java.util.Set;
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
|
|
@Service
|
|
public class LoadingCompletedConsumer {
|
|
static final String DEFAULT_RECEIVING_WAREHOUSE_ID = "193000000000200003";
|
|
static final String DEFAULT_RECEIVING_WAREHOUSE_CODE = "US-LAX-01";
|
|
static final String DEFAULT_RECEIVING_WAREHOUSE_NAME = "洛杉矶仓";
|
|
@Resource private LastlegEventInboxMapper inboxMapper;
|
|
@Resource private LastlegOrderMapper orderMapper;
|
|
@Resource private LastlegInboundOrderMapper inboundOrderMapper;
|
|
@Resource private LastlegOrderDetailMapper detailMapper;
|
|
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
|
|
@Resource private InboundPlanCartonMapper cartonMapper;
|
|
@Resource private ObjectMapper objectMapper;
|
|
@Resource private LastlegOrderNoGenerator orderNoGenerator;
|
|
@Resource private DropshipOrderService dropshipOrders;
|
|
@Resource private InventoryCommandService inventory;
|
|
@Resource private ContainerShippingGateMapper shippingGates;
|
|
@Resource private LastlegSourcePalletMapper sourcePallets;
|
|
|
|
@Transactional(isolation = Isolation.READ_COMMITTED)
|
|
public int consume(EventEnvelope envelope) {
|
|
envelope.requireValid();
|
|
if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType())
|
|
|| (envelope.eventVersion() != 1 && envelope.eventVersion() != 2))
|
|
throw new IllegalArgumentException("不支持的尾程交接事件");
|
|
if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS)
|
|
throw new IllegalArgumentException("尾程交接事件区域无效");
|
|
if (!"LOADING_PLAN".equals(envelope.aggregateType()))
|
|
throw new IllegalArgumentException("尾程交接事件聚合类型无效");
|
|
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()),
|
|
envelope.payloadHash())) throw new IllegalArgumentException("payloadHash 与尾程事件负载不一致");
|
|
FirstLegLoadingPlanCompletedV1 payload = parsePayload(envelope);
|
|
String containerNo = ContainerNumbers.requireCanonical(payload.containerNo());
|
|
String sourceContainerBookingId = sourceContainerBookingId(envelope, payload);
|
|
if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单");
|
|
OwnerSnapshot owner = requireOwnerSnapshot(payload.orders(), envelope.tenantId());
|
|
if (envelope.eventVersion() == 2) validateCartonSkuMappings(payload, envelope.payload());
|
|
freezeContainerIdentity(envelope, sourceContainerBookingId, containerNo);
|
|
LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId());
|
|
if(existing!=null) {
|
|
if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new IllegalArgumentException("事件摘要冲突");
|
|
return countOrders(envelope,containerNo);
|
|
}
|
|
|
|
LastlegEventInbox inbox=new LastlegEventInbox();
|
|
inbox.setEventId(envelope.eventId()); inbox.setCustomerId(envelope.tenantId()); inbox.setEventType(envelope.eventType());
|
|
inbox.setEventVersion(envelope.eventVersion()); inbox.setPayloadHash(envelope.payloadHash());
|
|
inbox.setPayloadJson(envelope.payload().toString()); inbox.setStatus("RECEIVED"); inbox.setReceivedAt(LocalDateTime.now());
|
|
try { inboxMapper.insert(inbox); } catch(DuplicateKeyException exception) {
|
|
LastlegEventInbox raced = inboxMapper.selectById(envelope.eventId());
|
|
if (raced == null || raced.getPayloadHash() == null
|
|
|| !raced.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
|
|
throw new IllegalArgumentException("事件摘要冲突", exception);
|
|
}
|
|
return countOrders(envelope,containerNo);
|
|
}
|
|
|
|
// The loading-plan and container-shipping events are independent aggregate streams.
|
|
// Seeding the shipping version from the loading-plan version makes a valid first node
|
|
// (aggregateVersion=0) look stale whenever the loading plan has a higher version.
|
|
JsonNode rawOrders = envelope.payload().path("orders");
|
|
String orderNo = orderNoGenerator.forContainerNo(payload.containerNo());
|
|
for (int index = 0; index < payload.orders().size(); index++) {
|
|
var source = payload.orders().get(index);
|
|
JsonNode rawSource = rawOrders.isArray() && index < rawOrders.size() ? rawOrders.get(index) : objectMapper.createObjectNode();
|
|
createOrder(envelope, payload, source, orderNo, rawSource, containerNo,
|
|
sourceContainerBookingId, owner);
|
|
}
|
|
inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxMapper.updateById(inbox);
|
|
return payload.orders().size();
|
|
}
|
|
|
|
private FirstLegLoadingPlanCompletedV1 parsePayload(EventEnvelope envelope) {
|
|
try {
|
|
ObjectMapper compatibleMapper = objectMapper.copy()
|
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
|
// The internal order model keeps the shared fields. V2 pallet source-detail
|
|
// identity is validated separately from the original immutable payload.
|
|
return compatibleMapper.treeToValue(envelope.payload(), FirstLegLoadingPlanCompletedV1.class);
|
|
}
|
|
catch(Exception exception) { throw new IllegalArgumentException("尾程事件负载无效",exception); }
|
|
}
|
|
|
|
private int countOrders(EventEnvelope envelope,String containerNo) {
|
|
return Math.toIntExact(orderMapper.countContainer(envelope.tenantId(),containerNo));
|
|
}
|
|
|
|
private void freezeContainerIdentity(EventEnvelope envelope, String sourceContainerBookingId,
|
|
String containerNo) {
|
|
shippingGates.initialize(sourceContainerBookingId, "",
|
|
containerNo, 0, envelope.handoverVersion());
|
|
ContainerShippingGate frozen = shippingGates.lock(sourceContainerBookingId);
|
|
if (frozen == null) {
|
|
throw new IllegalStateException("来源柜真实集装箱号冻结失败");
|
|
}
|
|
if (!containerNo.equals(ContainerNumbers.requireCanonical(frozen.getContainerNo()))) {
|
|
throw new IllegalStateException("同一 sourceContainerBookingId 的真实集装箱号冲突");
|
|
}
|
|
for (String existing : orderMapper.lockContainerNumbers(sourceContainerBookingId)) {
|
|
if (!containerNo.equals(ContainerNumbers.requireCanonical(existing))) {
|
|
throw new IllegalStateException("同一 sourceContainerBookingId 的尾程订单柜号冲突");
|
|
}
|
|
}
|
|
}
|
|
|
|
private String sourceContainerBookingId(EventEnvelope envelope, FirstLegLoadingPlanCompletedV1 payload) {
|
|
String value = envelope.eventVersion() == 2
|
|
? envelope.payload().path("sourceContainerBookingId").asText(null)
|
|
: payload.containerBookingId();
|
|
if (value == null || value.isBlank() || value.trim().length() > 128) {
|
|
throw new IllegalArgumentException("事件缺少稳定来源柜预订 ID sourceContainerBookingId");
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
private OwnerSnapshot requireOwnerSnapshot(List<FirstLegLoadingPlanCompletedV1.Order> orders,
|
|
String trustedCustomerId) {
|
|
String customerId = requiredOwnerValue(orders.get(0).customerId(), "customerId");
|
|
String customerName = requiredOwnerValue(orders.get(0).customerName(), "customerName");
|
|
if (!trustedCustomerId.equals(customerId)) {
|
|
throw new IllegalArgumentException("Order customerId does not match the trusted event customer");
|
|
}
|
|
for (FirstLegLoadingPlanCompletedV1.Order order : orders) {
|
|
if (!customerId.equals(requiredOwnerValue(order.customerId(), "customerId"))
|
|
|| !customerName.equals(requiredOwnerValue(order.customerName(), "customerName"))) {
|
|
throw new IllegalArgumentException("同一装柜完成事件的货主快照不一致");
|
|
}
|
|
}
|
|
return new OwnerSnapshot(customerId, customerName);
|
|
}
|
|
|
|
private String requiredOwnerValue(String value, String field) {
|
|
if (value == null || value.isBlank()) {
|
|
throw new IllegalArgumentException("尾程事件缺少不可变货主快照 " + field);
|
|
}
|
|
String normalized = value.trim();
|
|
if (normalized.length() > 128) {
|
|
throw new IllegalArgumentException("尾程事件货主快照过长 " + field);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
private void validateCartonSkuMappings(FirstLegLoadingPlanCompletedV1 payload, JsonNode rawPayload) {
|
|
for (int index = 0; index < payload.orders().size(); index++) {
|
|
FirstLegLoadingPlanCompletedV1.Order order = payload.orders().get(index);
|
|
Set<String> orderCartonMarks = new HashSet<>();
|
|
if ("ONE_CLICK_DROPSHIP".equals(order.orderType())) {
|
|
for (FirstLegLoadingPlanCompletedV1.PalletSnapshot pallet : order.pallets()) {
|
|
for (FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot sku : pallet.skuPackages()) {
|
|
requireSkuCartonMapping(sku.skuCode(), sku.packageCount(), sku.cartonMarks(), orderCartonMarks);
|
|
}
|
|
}
|
|
dropshipReceivingLabels(order, rawPayload.path("orders").get(index));
|
|
continue;
|
|
}
|
|
String destinationType = destinationType(payload, order);
|
|
for (FirstLegLoadingPlanCompletedV1.Item item : order.items()) {
|
|
if ("OVERSEAS".equals(destinationType)) {
|
|
requireSkuCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks);
|
|
} else {
|
|
validateOptionalFbaCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void validateOptionalFbaCartonMapping(String skuCode, int loadedPackages, List<String> cartonMarks,
|
|
Set<String> orderCartonMarks) {
|
|
List<String> marks = cartonMarks == null ? List.of() : cartonMarks;
|
|
if (skuCode == null || skuCode.isBlank()) {
|
|
throw new IllegalArgumentException("V2 尾程交接事件缺少 SKU");
|
|
}
|
|
if (marks.isEmpty()) return;
|
|
requireSkuCartonMapping(skuCode, loadedPackages, marks, orderCartonMarks);
|
|
}
|
|
|
|
private void requireSkuCartonMapping(String skuCode, int loadedPackages, List<String> cartonMarks,
|
|
Set<String> orderCartonMarks) {
|
|
if (skuCode == null || skuCode.isBlank()) {
|
|
throw new IllegalArgumentException("V2 尾程交接事件的箱唛映射缺少 SKU");
|
|
}
|
|
List<String> marks = cartonMarks == null ? List.of() : cartonMarks;
|
|
if (marks.size() != loadedPackages) {
|
|
throw new IllegalArgumentException("V2 尾程交接事件的 SKU 箱唛数量必须等于实装箱数: " + skuCode);
|
|
}
|
|
for (String mark : marks) {
|
|
if (mark == null || mark.isBlank() || !orderCartonMarks.add(mark)) {
|
|
throw new IllegalArgumentException("V2 尾程交接事件包含空值或重复箱唛: " + skuCode);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void createOrder(EventEnvelope event,FirstLegLoadingPlanCompletedV1 payload,
|
|
FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource,
|
|
String containerNo, String sourceContainerBookingId, OwnerSnapshot owner) {
|
|
String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(rawSource));
|
|
LastlegOrder existing=orderMapper.findSource(event.tenantId(),event.sourceRegion().name(),source.sourceInboundId());
|
|
if(existing!=null) {
|
|
if(!Objects.equals(existing.getSourcePayloadHash(),orderHash)) throw new IllegalStateException("来源入仓单快照冲突: "+source.sourceInboundId());
|
|
return;
|
|
}
|
|
LastlegOrder order=new LastlegOrder();
|
|
String orderType = source.orderType();
|
|
String destinationType = destinationType(payload, source);
|
|
String customerId = owner.customerId();
|
|
String customerName = owner.customerName();
|
|
List<FirstLegLoadingPlanCompletedV1.Item> items = "ONE_CLICK_DROPSHIP".equals(orderType)
|
|
? canonicalItems(source.items()) : source.items();
|
|
List<DropshipRequests.Pallet> dropshipPallets = "ONE_CLICK_DROPSHIP".equals(orderType)
|
|
? pallets(source, rawSource) : List.of();
|
|
if ("ONE_CLICK_DROPSHIP".equals(orderType) && dropshipPallets.isEmpty()) {
|
|
throw new IllegalArgumentException("One-click dropship event has no pallet snapshot");
|
|
}
|
|
order.setId(IdWorker.getId()); order.setOrderNo(orderNo); order.setCustomerId(customerId); order.setOrderType(orderType);
|
|
order.setDestinationType(destinationType);
|
|
order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name());
|
|
order.setLxCode("ONE_CLICK_DROPSHIP".equals(orderType) ? source.lxCode() : null);
|
|
order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId());
|
|
order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId());
|
|
order.setCustomerName(customerName);
|
|
order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName());
|
|
order.setOverseasWarehouseId(DEFAULT_RECEIVING_WAREHOUSE_ID);
|
|
order.setOverseasWarehouseCode(DEFAULT_RECEIVING_WAREHOUSE_CODE);
|
|
order.setOverseasWarehouseName(DEFAULT_RECEIVING_WAREHOUSE_NAME);
|
|
order.setReceivingWarehouseVersion(0);
|
|
order.setFbaWarehouseCode("FBA".equals(destinationType) ? source.destinationWarehouseCode() : null);
|
|
order.setContainerNo(containerNo);
|
|
order.setAbnormal(false); order.setDetained(false);
|
|
order.setPackageCount(source.packageCount());
|
|
order.setPalletCount("ONE_CLICK_DROPSHIP".equals(orderType)
|
|
? dropshipPallets.size() : Objects.requireNonNullElse(source.palletCount(), 0));
|
|
order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm());
|
|
order.setEstimatedWarehouseAt(parsePlannedArrival(payload.estimatedWarehouseArrivalAt()));
|
|
order.setVolumeWeightKg(items.stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
|
|
.filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add));
|
|
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId());
|
|
order.setSourceLoadingPlanId(payload.loadingPlanId());
|
|
order.setSourceContainerBookingId(sourceContainerBookingId);
|
|
order.setSourceLoadingPlanVersion(event.aggregateVersion());
|
|
order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash);
|
|
order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now());
|
|
try {
|
|
orderMapper.insert(order);
|
|
} catch (DuplicateKeyException exception) {
|
|
LastlegOrder raced = orderMapper.findSource(event.tenantId(), event.sourceRegion().name(),
|
|
source.sourceInboundId());
|
|
if (raced != null && Objects.equals(raced.getSourcePayloadHash(), orderHash)) return;
|
|
throw exception;
|
|
}
|
|
createInboundOrderSnapshot(event, payload, source, order, orderHash);
|
|
|
|
Map<String, List<String>> dropshipLabels = "ONE_CLICK_DROPSHIP".equals(orderType)
|
|
&& "OVERSEAS".equals(destinationType) ? dropshipReceivingLabels(source, rawSource) : Map.of();
|
|
if ("FIRST_LEG".equals(orderType) && event.eventVersion() >= 2) {
|
|
List<FirstLegLoadingPlanCompletedV1.PalletSnapshot> pallets = source.pallets() == null ? List.of() : source.pallets();
|
|
Set<String> palletNos = new HashSet<>();
|
|
for (int palletIndex = 0; palletIndex < pallets.size(); palletIndex++) {
|
|
String palletNo = pallets.get(palletIndex).palletNo();
|
|
if (palletNo == null || palletNo.isBlank() || palletNo.length() > 128 || !palletNos.add(palletNo)) {
|
|
throw new IllegalArgumentException("散货来源托盘码为空、重复或过长");
|
|
}
|
|
sourcePallets.insert(IdWorker.getId(), customerId, order.getId(), palletNo.trim(), palletIndex + 1, event.eventId());
|
|
}
|
|
if (!pallets.isEmpty() && order.getPalletCount() > 0 && pallets.size() != order.getPalletCount()) {
|
|
throw new IllegalArgumentException("散货来源托盘码数量与托盘数不一致");
|
|
}
|
|
}
|
|
for(var item:items) {
|
|
LastlegOrderDetail detail=new LastlegOrderDetail();
|
|
detail.setCustomerId(customerId);
|
|
detail.setOrderId(order.getId()); detail.setSourceInboundDetailId(item.sourceInboundDetailId()); detail.setSkuCode(item.skuCode());
|
|
detail.setProductNameZh(item.productNameZh()); detail.setProductNameEn(item.productNameEn()); detail.setPackageCount(item.packageCount());
|
|
detail.setProductQuantity(item.productQuantity()); detail.setPalletCount(item.palletCount()); detail.setGrossWeightKg(item.grossWeightKg());
|
|
detail.setNetWeightKg(item.netWeightKg()); detail.setDimensionalWeightKg(item.dimensionalWeightKg()); detail.setVolumeCbm(item.volumeCbm());
|
|
detail.setDestinationWarehouseId(item.destinationWarehouseId()); detail.setDestinationWarehouseCode(item.destinationWarehouseCode());
|
|
detail.setDestinationWarehouseName(item.destinationWarehouseName()); detail.setFbaTrackingNo(item.fbaTrackingNo());
|
|
detail.setShipmentNo(item.shipmentNo()); detail.setShippingMark(item.shippingMark()); detailMapper.insert(detail);
|
|
createCartonLabels(customerId, order.getId(), destinationType, item,
|
|
"ONE_CLICK_DROPSHIP".equals(orderType) ? dropshipLabels.getOrDefault(item.sourceInboundDetailId(), List.of())
|
|
: item.cartonMarks());
|
|
}
|
|
LastlegOrderStatusHistory history=new LastlegOrderStatusHistory(); history.setCustomerId(customerId);
|
|
history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId());
|
|
history.setRemark("头程装柜完成自动生成"); history.setCreatedAt(LocalDateTime.now()); historyMapper.insert(history);
|
|
if ("FIRST_LEG".equals(orderType)) {
|
|
// The cross-region handover freezes the source inbound code only. An inbound plan is
|
|
// created later by an explicit platform conversion, which replaces this display snapshot.
|
|
registerInTransit(event, payload, source, order, source.inboundCode());
|
|
}
|
|
if ("ONE_CLICK_DROPSHIP".equals(orderType)) {
|
|
long warehouseId;
|
|
try { warehouseId = Long.parseLong(payload.warehouseId()); }
|
|
catch (Exception exception) { throw new IllegalArgumentException("One-click dropship warehouseId is invalid", exception); }
|
|
dropshipOrders.receive(new DropshipRequests.Receive(customerId, order.getId(), warehouseId,
|
|
event.eventId() + ":DROPSHIP:" + source.sourceInboundId(), dropshipPallets));
|
|
}
|
|
}
|
|
|
|
private LocalDateTime parsePlannedArrival(String value) {
|
|
if (value == null || value.isBlank()) return null;
|
|
try {
|
|
return LocalDateTime.parse(value.trim());
|
|
} catch (java.time.format.DateTimeParseException exception) {
|
|
throw new IllegalArgumentException("事件计划到仓时间 estimatedWarehouseArrivalAt 无效", exception);
|
|
}
|
|
}
|
|
|
|
private void createInboundOrderSnapshot(EventEnvelope event, FirstLegLoadingPlanCompletedV1 payload,
|
|
FirstLegLoadingPlanCompletedV1.Order source, LastlegOrder order,
|
|
String orderHash) {
|
|
LastlegInboundOrder snapshot = new LastlegInboundOrder();
|
|
snapshot.setId(order.getId());
|
|
snapshot.setCustomerId(order.getCustomerId()); snapshot.setSourceRegion(event.sourceRegion().name());
|
|
snapshot.setSourceInboundId(source.sourceInboundId()); snapshot.setRootInboundId(source.rootInboundId());
|
|
snapshot.setSourceOrderId(source.sourceOrderId()); snapshot.setSourceOrderNo(source.sourceOrderNo());
|
|
snapshot.setRootOrderId(source.rootOrderId()); snapshot.setInboundCode(source.inboundCode());
|
|
snapshot.setLxCode("ONE_CLICK_DROPSHIP".equals(order.getOrderType()) ? source.lxCode() : null);
|
|
snapshot.setDestinationWarehouseId(source.destinationWarehouseId());
|
|
snapshot.setDestinationWarehouseCode(source.destinationWarehouseCode());
|
|
snapshot.setDestinationWarehouseName(source.destinationWarehouseName());
|
|
snapshot.setDestinationType(order.getDestinationType());
|
|
snapshot.setPackageCount(source.packageCount()); snapshot.setProductQuantity(source.productQuantity());
|
|
snapshot.setPalletCount(source.palletCount()); snapshot.setGrossWeightKg(source.grossWeightKg());
|
|
snapshot.setNetWeightKg(source.netWeightKg()); snapshot.setVolumeCbm(source.volumeCbm());
|
|
snapshot.setSourceEventId(event.eventId()); snapshot.setSourceLoadingPlanId(payload.loadingPlanId());
|
|
snapshot.setSourceLoadingPlanVersion(event.aggregateVersion()); snapshot.setHandoverVersion(event.handoverVersion());
|
|
snapshot.setPayloadHash(orderHash); snapshot.setStatus("READY"); snapshot.setExceptionFlag("NONE");
|
|
snapshot.setCreatedAt(order.getCreatedAt()); snapshot.setUpdatedAt(order.getUpdatedAt());
|
|
inboundOrderMapper.insert(snapshot);
|
|
}
|
|
|
|
private void registerInTransit(EventEnvelope event, FirstLegLoadingPlanCompletedV1 payload,
|
|
FirstLegLoadingPlanCompletedV1.Order source, LastlegOrder order,
|
|
String inboundOrderNo) {
|
|
for (int index = 0; index < source.items().size(); index++) {
|
|
FirstLegLoadingPlanCompletedV1.Item item = source.items().get(index);
|
|
if (item.packageCount() <= 0) continue;
|
|
inventory.registerInTransit(order.getCustomerId(), "SYSTEM",
|
|
new InventoryCommandRequests.InTransit(order.getCustomerName(), order.getOverseasWarehouseId(),
|
|
order.getOverseasWarehouseCode(), order.getOverseasWarehouseName(),
|
|
item.destinationWarehouseId(), item.destinationWarehouseCode(),
|
|
item.destinationWarehouseName(), order.getId(), inboundOrderNo, source.inboundCode(),
|
|
item.skuCode(), item.productNameZh(), item.productNameEn(), item.packageCount(),
|
|
order.getOrderNo(), event.eventId(),
|
|
"TAIL_IN_TRANSIT:" + event.eventId() + ":" + order.getId() + ":" + index,
|
|
"Tail-leg order created from completed loading plan"));
|
|
}
|
|
}
|
|
|
|
private Map<String, List<String>> dropshipReceivingLabels(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
|
|
Map<String, FirstLegLoadingPlanCompletedV1.Item> itemsByDetail = new java.util.LinkedHashMap<>();
|
|
for (var item : canonicalItems(source.items())) {
|
|
itemsByDetail.put(item.sourceInboundDetailId(), item);
|
|
}
|
|
Map<String, List<String>> marksByDetail = new java.util.LinkedHashMap<>();
|
|
for (JsonNode pallet : rawSource.path("pallets")) {
|
|
for (JsonNode sku : pallet.path("skuPackages")) {
|
|
String detailId = sku.path("sourceInboundDetailId").asText(null);
|
|
var item = itemsByDetail.get(detailId);
|
|
if (item == null || !Objects.equals(item.skuCode(), sku.path("skuCode").asText(null))) {
|
|
throw new IllegalArgumentException("一件代发托盘来源明细缺失或与 SKU 不一致");
|
|
}
|
|
List<String> marks = marksByDetail.computeIfAbsent(detailId, ignored -> new java.util.ArrayList<>());
|
|
sku.path("cartonMarks").forEach(mark -> marks.add(mark.asText()));
|
|
}
|
|
}
|
|
for (var item : itemsByDetail.values()) {
|
|
if (marksByDetail.getOrDefault(item.sourceInboundDetailId(), List.of()).size() != item.packageCount()) {
|
|
throw new IllegalArgumentException("一件代发托盘箱唛与来源明细箱数不一致");
|
|
}
|
|
}
|
|
return marksByDetail;
|
|
}
|
|
|
|
/** 接受同一来源明细因多托装柜形成的多个分片,并在尾程落库前合并为唯一明细。 */
|
|
private List<FirstLegLoadingPlanCompletedV1.Item> canonicalItems(List<FirstLegLoadingPlanCompletedV1.Item> source) {
|
|
Map<String, List<FirstLegLoadingPlanCompletedV1.Item>> byDetail = new java.util.LinkedHashMap<>();
|
|
for (var item : source == null ? List.<FirstLegLoadingPlanCompletedV1.Item>of() : source) {
|
|
if (item.sourceInboundDetailId() == null || item.sourceInboundDetailId().isBlank()) {
|
|
throw new IllegalArgumentException("一件代发来源明细标识缺失");
|
|
}
|
|
byDetail.computeIfAbsent(item.sourceInboundDetailId(), ignored -> new ArrayList<>()).add(item);
|
|
}
|
|
List<FirstLegLoadingPlanCompletedV1.Item> result = new ArrayList<>();
|
|
for (List<FirstLegLoadingPlanCompletedV1.Item> fragments : byDetail.values()) {
|
|
var first = fragments.get(0);
|
|
if (fragments.stream().anyMatch(item -> !Objects.equals(first.skuCode(), item.skuCode())
|
|
|| !Objects.equals(first.destinationWarehouseId(), item.destinationWarehouseId())
|
|
|| !Objects.equals(first.destinationWarehouseCode(), item.destinationWarehouseCode()))) {
|
|
throw new IllegalArgumentException("同一来源明细的尾程事件分片快照不一致");
|
|
}
|
|
List<String> marks = fragments.stream().flatMap(item -> item.cartonMarks().stream()).toList();
|
|
result.add(new FirstLegLoadingPlanCompletedV1.Item(first.sourceInboundDetailId(), first.skuCode(),
|
|
first.productNameZh(), first.productNameEn(),
|
|
fragments.stream().mapToInt(FirstLegLoadingPlanCompletedV1.Item::packageCount).sum(),
|
|
sumNullableInt(fragments, FirstLegLoadingPlanCompletedV1.Item::productQuantity),
|
|
sumNullableInt(fragments, FirstLegLoadingPlanCompletedV1.Item::palletCount),
|
|
sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::grossWeightKg),
|
|
sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::netWeightKg),
|
|
sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg),
|
|
sumDecimal(fragments, FirstLegLoadingPlanCompletedV1.Item::volumeCbm),
|
|
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
|
|
first.fbaTrackingNo(), first.shipmentNo(), first.shippingMark(), first.palletNo(), marks));
|
|
}
|
|
return List.copyOf(result);
|
|
}
|
|
|
|
private static Integer sumNullableInt(List<FirstLegLoadingPlanCompletedV1.Item> values,
|
|
java.util.function.Function<FirstLegLoadingPlanCompletedV1.Item, Integer> getter) {
|
|
List<Integer> present = values.stream().map(getter).filter(Objects::nonNull).toList();
|
|
return present.isEmpty() ? null : present.stream().mapToInt(Integer::intValue).sum();
|
|
}
|
|
|
|
private static BigDecimal sumDecimal(List<FirstLegLoadingPlanCompletedV1.Item> values,
|
|
java.util.function.Function<FirstLegLoadingPlanCompletedV1.Item, BigDecimal> getter) {
|
|
List<BigDecimal> present = values.stream().map(getter).filter(Objects::nonNull).toList();
|
|
return present.isEmpty() ? null : present.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
}
|
|
|
|
private void createCartonLabels(String customerId, Long orderId, String destinationType,
|
|
FirstLegLoadingPlanCompletedV1.Item item, List<String> sourceMarks) {
|
|
if (!"OVERSEAS".equals(destinationType)) return;
|
|
List<String> marks = sourceMarks == null ? List.of() : sourceMarks;
|
|
if (marks.isEmpty()) return;
|
|
if (marks.stream().anyMatch(mark -> mark == null || mark.isBlank()) || marks.stream().distinct().count() != marks.size()) {
|
|
throw new IllegalArgumentException("装柜事件的箱唛池不可包含空值或重复箱唛");
|
|
}
|
|
for (String mark : marks) {
|
|
InboundPlanCarton carton = new InboundPlanCarton();
|
|
carton.setId(IdWorker.getId()); carton.setCustomerId(customerId); carton.setOrderId(orderId);
|
|
carton.setSourceInboundDetailId(item.sourceInboundDetailId()); carton.setSkuCode(item.skuCode());
|
|
carton.setCartonMark(mark); carton.setDestinationWarehouseId(item.destinationWarehouseId());
|
|
carton.setDestinationWarehouseCode(item.destinationWarehouseCode());
|
|
carton.setDestinationWarehouseName(item.destinationWarehouseName()); carton.setStatus("UNASSIGNED");
|
|
carton.setVersion(0); carton.setUpdatedAt(LocalDateTime.now());
|
|
try { cartonMapper.insert(carton); } catch (DuplicateKeyException ignored) { }
|
|
}
|
|
}
|
|
|
|
private String destinationType(FirstLegLoadingPlanCompletedV1 payload,
|
|
FirstLegLoadingPlanCompletedV1.Order order) {
|
|
String explicit = normalizeDestinationType(order.destinationType());
|
|
if (explicit != null) return explicit;
|
|
|
|
boolean fbaEvidence = order.items().stream().anyMatch(item -> hasText(item.fbaTrackingNo())
|
|
|| hasText(item.shipmentNo()) || startsWithFba(item.destinationWarehouseName()));
|
|
boolean overseasWarehouseIdentity = hasText(payload.warehouseId())
|
|
&& Objects.equals(payload.warehouseId(), order.destinationWarehouseId());
|
|
if (fbaEvidence && overseasWarehouseIdentity) {
|
|
throw new IllegalArgumentException("历史尾程事件目的类型证据冲突: " + order.sourceInboundId());
|
|
}
|
|
if (fbaEvidence) return "FBA";
|
|
if (overseasWarehouseIdentity) return "OVERSEAS";
|
|
throw new IllegalArgumentException("历史尾程事件缺少 destinationType 且无法安全推断: " + order.sourceInboundId());
|
|
}
|
|
|
|
private String normalizeDestinationType(String value) {
|
|
if (!hasText(value)) return null;
|
|
String normalized = value.trim().toUpperCase(java.util.Locale.ROOT);
|
|
if (!"FBA".equals(normalized) && !"OVERSEAS".equals(normalized)) {
|
|
throw new IllegalArgumentException("尾程事件 destinationType 必须为 FBA 或 OVERSEAS");
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
private boolean startsWithFba(String value) {
|
|
return hasText(value) && value.trim().toUpperCase(java.util.Locale.ROOT).startsWith("FBA");
|
|
}
|
|
|
|
private boolean hasText(String value) {
|
|
return value != null && !value.isBlank();
|
|
}
|
|
|
|
private List<DropshipRequests.Pallet> pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
|
|
if (source.pallets() != null && !source.pallets().isEmpty()) {
|
|
List<DropshipRequests.Pallet> result = new ArrayList<>();
|
|
JsonNode rawPallets = rawSource.path("pallets");
|
|
for (int palletIndex = 0; palletIndex < source.pallets().size(); palletIndex++) {
|
|
FirstLegLoadingPlanCompletedV1.PalletSnapshot pallet = source.pallets().get(palletIndex);
|
|
JsonNode rawPallet = rawPallets.isArray() && palletIndex < rawPallets.size()
|
|
? rawPallets.get(palletIndex) : objectMapper.createObjectNode();
|
|
Map<String, DropshipRequests.SkuPackages> inventorySkus = new java.util.LinkedHashMap<>();
|
|
for (int skuIndex = 0; skuIndex < pallet.skuPackages().size(); skuIndex++) {
|
|
var sku = pallet.skuPackages().get(skuIndex);
|
|
inventorySkus.merge(sku.skuCode(), new DropshipRequests.SkuPackages(sku.skuCode(),
|
|
sku.productName(), sku.packageCount(), sku.productQuantity(), sku.cartonMarks(),
|
|
cartonQuantities(rawPallet.path("skuPackages").path(skuIndex))), (left, right) -> {
|
|
List<String> marks = new ArrayList<>(left.cartonMarks()); marks.addAll(right.cartonMarks());
|
|
Map<String, Long> quantities = new java.util.LinkedHashMap<>(left.cartonQuantities());
|
|
right.cartonQuantities().forEach((mark, quantity) -> {
|
|
if (quantities.putIfAbsent(mark, quantity) != null) throw new IllegalArgumentException("Duplicate carton quantity: " + mark);
|
|
});
|
|
return new DropshipRequests.SkuPackages(left.skuCode(), left.productName(),
|
|
Math.addExact(left.packageCount(), right.packageCount()),
|
|
Math.addExact(left.productQuantity(), right.productQuantity()), List.copyOf(marks), Map.copyOf(quantities));
|
|
});
|
|
}
|
|
result.add(new DropshipRequests.Pallet(pallet.palletNo(), List.copyOf(inventorySkus.values()),
|
|
positiveManifest(rawPallet, "manifestVersion"), positiveManifest(rawPallet, "manifestPackageCount"),
|
|
positiveManifest(rawPallet, "manifestFragmentCount"), requiredManifestHash(rawPallet)));
|
|
}
|
|
return List.copyOf(result);
|
|
}
|
|
return legacyPallets(rawSource);
|
|
}
|
|
|
|
private List<DropshipRequests.Pallet> legacyPallets(JsonNode source) {
|
|
List<DropshipRequests.Pallet> result = new ArrayList<>();
|
|
JsonNode nodes = source.path("pallets");
|
|
if (nodes.isArray()) {
|
|
for (JsonNode pallet : nodes) {
|
|
String palletNo = pallet.path("palletNo").asText(pallet.path("sourcePalletNo").asText(null));
|
|
List<DropshipRequests.SkuPackages> skuRows = skuPackages(pallet);
|
|
if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) {
|
|
result.add(new DropshipRequests.Pallet(palletNo, skuRows,
|
|
positiveManifest(pallet, "manifestVersion"), positiveManifest(pallet, "manifestPackageCount"),
|
|
positiveManifest(pallet, "manifestFragmentCount"), requiredManifestHash(pallet)));
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
String palletNo = source.path("palletNo").asText(source.path("sourcePalletNo").asText(null));
|
|
List<DropshipRequests.SkuPackages> skuRows = skuPackages(source);
|
|
if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) {
|
|
result.add(new DropshipRequests.Pallet(palletNo, skuRows,
|
|
positiveManifest(source, "manifestVersion"), positiveManifest(source, "manifestPackageCount"),
|
|
positiveManifest(source, "manifestFragmentCount"), requiredManifestHash(source)));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private int positiveManifest(JsonNode source, String field) {
|
|
int value = source.path(field).asInt(0);
|
|
if (value <= 0) throw new IllegalArgumentException("One-click dropship pallet is missing " + field);
|
|
return value;
|
|
}
|
|
|
|
private String requiredManifestHash(JsonNode source) {
|
|
String value = source.path("manifestHash").asText(null);
|
|
if (value == null || !value.matches("(?i)[0-9a-f]{64}")) {
|
|
throw new IllegalArgumentException("One-click dropship pallet manifestHash is invalid");
|
|
}
|
|
return value.toLowerCase(java.util.Locale.ROOT);
|
|
}
|
|
|
|
private Map<String, Long> cartonQuantities(JsonNode sku) {
|
|
JsonNode node = sku.path("cartonQuantities");
|
|
if (!node.isObject()) throw new IllegalArgumentException("One-click dropship cartonQuantities is required");
|
|
Map<String, Long> result = new java.util.LinkedHashMap<>();
|
|
node.fields().forEachRemaining(entry -> {
|
|
if (!entry.getValue().isIntegralNumber() || !entry.getValue().canConvertToLong() || entry.getValue().longValue() <= 0) {
|
|
throw new IllegalArgumentException("Invalid carton quantity: " + entry.getKey());
|
|
}
|
|
result.put(entry.getKey(), entry.getValue().longValue());
|
|
});
|
|
return Map.copyOf(result);
|
|
}
|
|
|
|
private List<DropshipRequests.SkuPackages> skuPackages(JsonNode source) {
|
|
List<DropshipRequests.SkuPackages> result = new ArrayList<>();
|
|
JsonNode nodes = source.path("skuPackages");
|
|
if (!nodes.isArray()) nodes = source.path("items");
|
|
if (!nodes.isArray()) return result;
|
|
for (JsonNode item : nodes) {
|
|
String sku = item.path("skuCode").asText(null);
|
|
long packageCount = item.path("packageCount").asLong(0);
|
|
long productQuantity = item.path("productQuantity").asLong(0);
|
|
JsonNode markNodes = item.path("cartonMarks");
|
|
if (!markNodes.isArray()) markNodes = item.path("boxMarks");
|
|
if (!markNodes.isArray()) markNodes = item.path("cartonNos");
|
|
List<String> marks = new ArrayList<>();
|
|
if (markNodes.isArray()) {
|
|
for (JsonNode mark : markNodes) if (!mark.asText().isBlank()) marks.add(mark.asText());
|
|
}
|
|
if (sku != null && !sku.isBlank() && packageCount > 0 && productQuantity > 0 && !marks.isEmpty()) {
|
|
String productName = item.path("productName").asText(item.path("productNameZh").asText(null));
|
|
result.add(new DropshipRequests.SkuPackages(sku, productName, packageCount, productQuantity, marks, cartonQuantities(item)));
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private record OwnerSnapshot(String customerId, String customerName) { }
|
|
}
|