feat: enforce canonical container identity across regions

This commit is contained in:
2026-08-31 12:13:20 +08:00
parent b386f35ab4
commit 942dbace19
78 changed files with 1203 additions and 730 deletions
@@ -9,6 +9,7 @@ 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;
@@ -54,14 +55,15 @@ public class LoadingCompletedConsumer {
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()),
envelope.payloadHash())) throw new IllegalArgumentException("payloadHash 与尾程事件负载不一致");
FirstLegLoadingPlanCompletedV1 payload = parsePayload(envelope);
if(payload.containerNo()==null||payload.containerNo().isBlank())
throw new IllegalArgumentException("事件没有实际集装箱号 containerNo");
String containerNo = ContainerNumbers.requireCanonical(payload.containerNo());
String sourceContainerBookingId = sourceContainerBookingId(envelope, payload);
if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单");
if (envelope.eventVersion() == 2) validateCartonSkuMappings(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,payload.containerNo());
return countOrders(envelope,containerNo);
}
LastlegEventInbox inbox=new LastlegEventInbox();
@@ -74,21 +76,18 @@ public class LoadingCompletedConsumer {
|| !raced.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
throw new IllegalArgumentException("事件摘要冲突", exception);
}
return countOrders(envelope,payload.containerNo());
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.
shippingGates.initialize(payload.containerBookingId(), envelope.tenantId(), "",
payload.containerNo(), 0, envelope.handoverVersion());
String orderNo = orderNoGenerator.next();
JsonNode rawOrders = envelope.payload().path("orders");
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);
createOrder(envelope, payload, source, orderNo, rawSource, containerNo, sourceContainerBookingId);
}
inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxMapper.updateById(inbox);
return payload.orders().size();
@@ -109,6 +108,34 @@ public class LoadingCompletedConsumer {
return Math.toIntExact(orderMapper.countContainer(envelope.tenantId(),containerNo));
}
private void freezeContainerIdentity(EventEnvelope envelope, String sourceContainerBookingId,
String containerNo) {
shippingGates.initialize(sourceContainerBookingId, envelope.tenantId(), "",
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 void validateCartonSkuMappings(FirstLegLoadingPlanCompletedV1 payload) {
for (FirstLegLoadingPlanCompletedV1.Order order : payload.orders()) {
Set<String> orderCartonMarks = new HashSet<>();
@@ -158,7 +185,8 @@ public class LoadingCompletedConsumer {
}
private void createOrder(EventEnvelope event,FirstLegLoadingPlanCompletedV1 payload,
FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource) {
FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource,
String containerNo, String sourceContainerBookingId) {
String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(rawSource));
LastlegOrder existing=orderMapper.findSource(event.tenantId(),event.sourceRegion().name(),source.sourceInboundId());
if(existing!=null) {
@@ -186,7 +214,7 @@ public class LoadingCompletedConsumer {
order.setCustomerName(source.customerName());
order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName());
order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null);
order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo());
order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(containerNo);
order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false);
order.setPackageCount(source.packageCount()); order.setPalletCount(dropshipPallets.size());
order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm());
@@ -195,7 +223,7 @@ public class LoadingCompletedConsumer {
.filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add));
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId());
order.setSourceLoadingPlanId(payload.loadingPlanId());
order.setSourceContainerBookingId(payload.containerBookingId());
order.setSourceContainerBookingId(sourceContainerBookingId);
order.setSourceLoadingPlanVersion(event.aggregateVersion());
order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash);
order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now());