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
@@ -12,9 +12,9 @@ import java.util.List;
@Mapper
public interface InboundPlanOrderMapper {
record ContainerOrderRef(String customerId, Long orderId, String currentStatus) { }
record ContainerOrderRef(String customerId, Long orderId, String currentStatus, String containerNo) { }
@Select("SELECT customer_id AS customerId,id AS orderId,current_status AS currentStatus " +
@Select("SELECT customer_id AS customerId,id AS orderId,current_status AS currentStatus,container_no AS containerNo " +
"FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE")
List<ContainerOrderRef> lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId);
@@ -19,6 +19,7 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanQueryMapper;
import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.common.event.ContainerNumbers;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@@ -67,10 +68,10 @@ public class InboundPlanService {
if (orders == null || orders.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found");
}
String containerNo = trimToNull(orders.get(0).containerNo());
if (containerNo == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Inbound order container number is empty");
String containerNo = requiredContainerNo(orders.get(0).containerNo());
InboundPlan existing = planMapper.findBySourceOrder(customerId, selectedOrderId);
if (existing != null) {
requirePlanContainer(existing, containerNo);
return detail(customerId, existing);
}
validateOrders(orders);
@@ -79,11 +80,7 @@ public class InboundPlanService {
private InboundPlanResponse createPlan(String customerId, Long selectedOrderId,
List<SourceOrderSnapshot> orders) {
String containerNo = trimToNull(orders.get(0).containerNo());
if (containerNo == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Inbound order container number is empty");
}
String containerNo = requiredContainerNo(orders.get(0).containerNo());
LocalDateTime now = LocalDateTime.now();
InboundPlan plan = new InboundPlan();
plan.setId(IdWorker.getId());
@@ -138,6 +135,14 @@ public class InboundPlanService {
if (bookingId == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空");
List<InboundPlanOrderMapper.ContainerOrderRef> refs = orderMapper.lockContainerOrders(bookingId);
if (refs.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜没有尾程订单");
String containerNo = null;
for (InboundPlanOrderMapper.ContainerOrderRef ref : refs) {
String current = requiredContainerNo(ref.containerNo());
if (containerNo == null) containerNo = current;
if (!Objects.equals(containerNo, current)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单柜号不一致");
}
}
List<InboundPlan> existingPlans = refs.stream()
.map(ref -> planMapper.findBySourceOrder(ref.customerId(), ref.orderId()))
.toList();
@@ -145,6 +150,7 @@ public class InboundPlanService {
if (existingCount == refs.size()) {
List<InboundPlanResponse> replay = new ArrayList<>(refs.size());
for (int index = 0; index < refs.size(); index++) {
requirePlanContainer(existingPlans.get(index), containerNo);
replay.add(detail(refs.get(index).customerId(), existingPlans.get(index)));
}
return List.copyOf(replay);
@@ -158,15 +164,13 @@ public class InboundPlanService {
}
List<List<SourceOrderSnapshot>> snapshots = new ArrayList<>(refs.size());
String containerNo = null;
for (InboundPlanOrderMapper.ContainerOrderRef ref : refs) {
List<SourceOrderSnapshot> orders = orderMapper.lockOrder(ref.customerId(), ref.orderId());
if (orders == null || orders.isEmpty()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜订单在锁定后不存在");
}
validateOrders(orders);
String currentContainerNo = trimToNull(orders.get(0).containerNo());
if (containerNo == null) containerNo = currentContainerNo;
String currentContainerNo = requiredContainerNo(orders.get(0).containerNo());
if (!Objects.equals(containerNo, currentContainerNo)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单柜号不一致");
}
@@ -181,6 +185,22 @@ public class InboundPlanService {
return List.copyOf(created);
}
private String requiredContainerNo(String value) {
try {
return ContainerNumbers.requireCanonical(value);
} catch (IllegalArgumentException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"来源柜真实集装箱号缺失或格式无效", exception);
}
}
private void requirePlanContainer(InboundPlan plan, String containerNo) {
String frozen = requiredContainerNo(plan.getContainerNo());
if (!Objects.equals(containerNo, frozen)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划柜号快照与来源柜不一致");
}
}
@Transactional(readOnly = true)
public InboundPlanResponse detailByPlanNo(String customerId, String planNo) {
@@ -84,6 +84,11 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
@Param("sourceRegion") String sourceRegion,
@Param("sourceInboundId") String sourceInboundId);
@Select("SELECT container_no FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} " +
"ORDER BY customer_id,id FOR UPDATE")
List<String> lockContainerNumbers(
@Param("sourceContainerBookingId") String sourceContainerBookingId);
@Select("SELECT COUNT(*) FROM ll_order WHERE customer_id=#{customerId} AND container_no=#{containerNo}")
long countContainer(@Param("customerId") String customerId, @Param("containerNo") String containerNo);
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.event.ContainerNumbers;
import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
@@ -59,7 +60,8 @@ public class LastlegOrderService {
}
long total = orderMapper.countContainerProgress(query);
List<ContainerPlanProgressSummary> records = total == 0 ? List.of()
: orderMapper.pageContainerProgress(query, (query.page() - 1) * query.size(), query.size());
: orderMapper.pageContainerProgress(query, (query.page() - 1) * query.size(), query.size())
.stream().map(this::requireFrozenContainer).toList();
long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
return new PageResult<>(records, query.page(), query.size(), total, pages);
}
@@ -74,7 +76,18 @@ public class LastlegOrderService {
if (summary == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在");
}
return new ContainerPlanProgressDetail(summary, orderMapper.findContainerProgressItems(bookingId));
return new ContainerPlanProgressDetail(requireFrozenContainer(summary),
orderMapper.findContainerProgressItems(bookingId));
}
private ContainerPlanProgressSummary requireFrozenContainer(ContainerPlanProgressSummary summary) {
try {
ContainerNumbers.requireCanonical(summary.containerNo());
return summary;
} catch (IllegalArgumentException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"来源柜真实集装箱号缺失、格式无效或存在漂移", exception);
}
}
public LastlegOrderDetailResponse detail(String customerId, long orderId) {
@@ -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());