feat: enforce canonical container identity across regions
This commit is contained in:
+2
-2
@@ -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);
|
||||
|
||||
|
||||
+30
-10
@@ -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) {
|
||||
|
||||
+39
-11
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user