fix(dropship): project sealed carton identities into overseas receiving
This commit is contained in:
+56
-12
@@ -20,6 +20,7 @@ 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;
|
||||
@@ -62,7 +63,7 @@ public class LoadingCompletedConsumer {
|
||||
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);
|
||||
if (envelope.eventVersion() == 2) validateCartonSkuMappings(payload, envelope.payload());
|
||||
freezeContainerIdentity(envelope, sourceContainerBookingId, containerNo);
|
||||
LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId());
|
||||
if(existing!=null) {
|
||||
@@ -102,8 +103,8 @@ public class LoadingCompletedConsumer {
|
||||
try {
|
||||
ObjectMapper compatibleMapper = objectMapper.copy()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
// V2 only adds immutable item remarks; the current tail-leg snapshot model
|
||||
// intentionally ignores those fields until its schema exposes them.
|
||||
// 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); }
|
||||
@@ -168,8 +169,9 @@ public class LoadingCompletedConsumer {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private void validateCartonSkuMappings(FirstLegLoadingPlanCompletedV1 payload) {
|
||||
for (FirstLegLoadingPlanCompletedV1.Order order : payload.orders()) {
|
||||
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()) {
|
||||
@@ -177,6 +179,7 @@ public class LoadingCompletedConsumer {
|
||||
requireSkuCartonMapping(sku.skuCode(), sku.packageCount(), sku.cartonMarks(), orderCartonMarks);
|
||||
}
|
||||
}
|
||||
dropshipReceivingLabels(order, rawPayload.path("orders").get(index));
|
||||
continue;
|
||||
}
|
||||
String destinationType = destinationType(payload, order);
|
||||
@@ -273,6 +276,8 @@ public class LoadingCompletedConsumer {
|
||||
}
|
||||
createInboundOrderSnapshot(event, payload, source, order, orderHash);
|
||||
|
||||
Map<String, List<String>> dropshipLabels = "ONE_CLICK_DROPSHIP".equals(orderType)
|
||||
&& "OVERSEAS".equals(destinationType) ? dropshipReceivingLabels(source, rawSource) : Map.of();
|
||||
for(var item:source.items()) {
|
||||
LastlegOrderDetail detail=new LastlegOrderDetail();
|
||||
detail.setCustomerId(customerId);
|
||||
@@ -283,7 +288,9 @@ public class LoadingCompletedConsumer {
|
||||
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);
|
||||
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());
|
||||
@@ -353,10 +360,38 @@ public class LoadingCompletedConsumer {
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, List<String>> dropshipReceivingLabels(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
|
||||
Map<String, FirstLegLoadingPlanCompletedV1.Item> itemsByDetail = new java.util.LinkedHashMap<>();
|
||||
for (var item : source.items()) {
|
||||
if (item.sourceInboundDetailId() == null || item.sourceInboundDetailId().isBlank()
|
||||
|| itemsByDetail.putIfAbsent(item.sourceInboundDetailId(), item) != null) {
|
||||
throw new IllegalArgumentException("一件代发来源明细标识缺失或重复");
|
||||
}
|
||||
}
|
||||
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 : source.items()) {
|
||||
if (marksByDetail.getOrDefault(item.sourceInboundDetailId(), List.of()).size() != item.packageCount()) {
|
||||
throw new IllegalArgumentException("一件代发托盘箱唛与来源明细箱数不一致");
|
||||
}
|
||||
}
|
||||
return marksByDetail;
|
||||
}
|
||||
|
||||
private void createCartonLabels(String customerId, Long orderId, String destinationType,
|
||||
FirstLegLoadingPlanCompletedV1.Item item) {
|
||||
FirstLegLoadingPlanCompletedV1.Item item, List<String> sourceMarks) {
|
||||
if (!"OVERSEAS".equals(destinationType)) return;
|
||||
List<String> marks = item.cartonMarks() == null ? List.of() : item.cartonMarks();
|
||||
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("装柜事件的箱唛池不可包含空值或重复箱唛");
|
||||
@@ -409,10 +444,19 @@ public class LoadingCompletedConsumer {
|
||||
|
||||
private List<DropshipRequests.Pallet> pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) {
|
||||
if (source.pallets() != null && !source.pallets().isEmpty()) {
|
||||
return source.pallets().stream().map(pallet -> new DropshipRequests.Pallet(pallet.palletNo(),
|
||||
pallet.skuPackages().stream().map(sku -> new DropshipRequests.SkuPackages(
|
||||
sku.skuCode(), sku.productName(), sku.packageCount(), sku.productQuantity(),
|
||||
sku.cartonMarks())).toList())).toList();
|
||||
return source.pallets().stream().map(pallet -> {
|
||||
Map<String, DropshipRequests.SkuPackages> inventorySkus = new java.util.LinkedHashMap<>();
|
||||
for (var sku : pallet.skuPackages()) {
|
||||
inventorySkus.merge(sku.skuCode(), new DropshipRequests.SkuPackages(sku.skuCode(),
|
||||
sku.productName(), sku.packageCount(), sku.productQuantity(), sku.cartonMarks()), (left, right) -> {
|
||||
List<String> marks = new ArrayList<>(left.cartonMarks()); marks.addAll(right.cartonMarks());
|
||||
return new DropshipRequests.SkuPackages(left.skuCode(), left.productName(),
|
||||
Math.addExact(left.packageCount(), right.packageCount()),
|
||||
Math.addExact(left.productQuantity(), right.productQuantity()), List.copyOf(marks));
|
||||
});
|
||||
}
|
||||
return new DropshipRequests.Pallet(pallet.palletNo(), List.copyOf(inventorySkus.values()));
|
||||
}).toList();
|
||||
}
|
||||
return legacyPallets(rawSource);
|
||||
}
|
||||
|
||||
+19
@@ -724,6 +724,25 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
.containsExactly("SKU-A", "SKU-B", "SKU-C");
|
||||
}
|
||||
|
||||
@Test
|
||||
void overseasDropshipResolvesOnlyFrozenCartonsAndNeverCreatesLabelsWhileResolving() {
|
||||
stubCurrent("OVERSEAS", "CONT-A");
|
||||
LastlegOrder dropship = order("OVERSEAS"); dropship.setOrderType("ONE_CLICK_DROPSHIP");
|
||||
when(orders.selectById(1L)).thenReturn(dropship);
|
||||
InboundPlanCarton frozen = carton(1L, "IN-1#2-1");
|
||||
frozen.setCustomerId("100"); frozen.setSourceInboundDetailId("d-1"); frozen.setSkuCode("SKU-1");
|
||||
when(cartons.selectOne(any())).thenReturn(frozen);
|
||||
var resolved = service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("IN-1#2-1"));
|
||||
assertThat(resolved.inboundCode()).isEqualTo("IN-1");
|
||||
assertThat(resolved.destinationType()).isEqualTo("OVERSEAS");
|
||||
verify(cartons, never()).insert(any(InboundPlanCarton.class));
|
||||
verifyNoInteractions(warehouse);
|
||||
when(cartons.selectOne(any())).thenReturn(null);
|
||||
assertThatThrownBy(() -> service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("IN-1#2-9")))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("404");
|
||||
verify(cartons, never()).insert(any(InboundPlanCarton.class));
|
||||
}
|
||||
|
||||
private void stubCurrent(String destinationType, String containerNo) {
|
||||
InboundPlan plan = plan(containerNo);
|
||||
InboundPlanLine line = line(destinationType);
|
||||
|
||||
+71
@@ -128,6 +128,77 @@ class LoadingCompletedConsumerTest {
|
||||
ArgumentCaptor<LastlegOrder> captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captured.capture());
|
||||
assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType())));
|
||||
verify(cartons,times(8)).insert(any(InboundPlanCarton.class));
|
||||
}
|
||||
@Test void projectsOverseasDropshipFrozenCartonsIntoReceivingLabelPool() {
|
||||
service.consume(overseasDropshipEvent(false));
|
||||
var captured=ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class);
|
||||
verify(cartons,times(5)).insert(captured.capture());
|
||||
assertEquals(List.of("RK1#5-1","RK1#5-2","RK1#5-3","RK1#5-4","RK1#5-5"),captured.getAllValues().stream().map(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton::getCartonMark).toList());
|
||||
for(var carton:captured.getAllValues()) {
|
||||
assertEquals("100",carton.getCustomerId()); assertEquals("d-1",carton.getSourceInboundDetailId());
|
||||
assertEquals("SKU-1",carton.getSkuCode()); assertEquals("UNASSIGNED",carton.getStatus());
|
||||
assertNull(carton.getPlanId()); assertNull(carton.getLineId());
|
||||
}
|
||||
verify(dropshipOrders).receive(any());
|
||||
}
|
||||
@Test void rejectsAmbiguousDropshipSkuSourceBeforeWritingReceivingLabels() {
|
||||
var failure=assertThrows(IllegalArgumentException.class,()->service.consume(overseasDropshipEvent(true)));
|
||||
assertTrue(failure.getMessage().contains("来源明细"));
|
||||
verify(cartons,never()).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class));
|
||||
verify(orders,never()).insert(any(LastlegOrder.class));
|
||||
}
|
||||
@Test void mapsSameSkuDifferentSourceDetailsWithoutGuessing() {
|
||||
EventEnvelope original=overseasDropshipEvent(false);
|
||||
var node=(com.fasterxml.jackson.databind.node.ObjectNode)original.payload().deepCopy();
|
||||
var order=(com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0);
|
||||
var second=(com.fasterxml.jackson.databind.node.ObjectNode)order.path("items").get(0).deepCopy();
|
||||
second.put("sourceInboundDetailId","d-2");
|
||||
((com.fasterxml.jackson.databind.node.ArrayNode)order.path("items")).add(second);
|
||||
order.put("packageCount",10);
|
||||
var skus=(com.fasterxml.jackson.databind.node.ArrayNode)order.path("pallets").get(0).path("skuPackages");
|
||||
var secondSku=(com.fasterxml.jackson.databind.node.ObjectNode)skus.get(0).deepCopy();
|
||||
secondSku.put("sourceInboundDetailId","d-2");
|
||||
var marks=secondSku.putArray("cartonMarks"); for(int n=6;n<=10;n++)marks.add("RK1#10-"+n);
|
||||
skus.add(secondSku);
|
||||
service.consume(rehash(original,node));
|
||||
var captured=ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class);
|
||||
verify(cartons,times(10)).insert(captured.capture());
|
||||
assertEquals(5,captured.getAllValues().stream().filter(c->"d-1".equals(c.getSourceInboundDetailId())).count());
|
||||
assertEquals(5,captured.getAllValues().stream().filter(c->"d-2".equals(c.getSourceInboundDetailId())).count());
|
||||
assertTrue(captured.getAllValues().stream().filter(c->c.getCartonMark().contains("#10-")).allMatch(c->"d-2".equals(c.getSourceInboundDetailId())));
|
||||
var received=ArgumentCaptor.forClass(DropshipRequests.Receive.class);
|
||||
verify(dropshipOrders).receive(received.capture());
|
||||
var inventoryRows=received.getValue().pallets().get(0).skuPackages();
|
||||
assertEquals(1,inventoryRows.size()); assertEquals(10,inventoryRows.get(0).packageCount());
|
||||
assertEquals(100,inventoryRows.get(0).productQuantity()); assertEquals(10,inventoryRows.get(0).cartonMarks().size());
|
||||
}
|
||||
@Test void rejectsMissingOrForeignPalletSourceBeforeAnyOrderWrite() {
|
||||
for(String detailId:List.of("", "foreign-detail")) {
|
||||
EventEnvelope original=overseasDropshipEvent(false);
|
||||
var node=(com.fasterxml.jackson.databind.node.ObjectNode)original.payload().deepCopy();
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0).path("pallets").get(0).path("skuPackages").get(0)).put("sourceInboundDetailId",detailId);
|
||||
assertThrows(IllegalArgumentException.class,()->service.consume(rehash(original,node)));
|
||||
}
|
||||
verify(orders,never()).insert(any(LastlegOrder.class));
|
||||
verify(cartons,never()).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class));
|
||||
}
|
||||
private EventEnvelope rehash(EventEnvelope original,JsonNode node) {
|
||||
return new EventEnvelope(original.eventId(),original.eventType(),original.eventVersion(),original.occurredAt(),original.sourceRegion(),original.targetRegion(),original.tenantId(),original.aggregateType(),original.aggregateId(),original.aggregateVersion(),original.handoverVersion(),original.traceId(),original.correlationId(),original.causationId(),node,EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)));
|
||||
}
|
||||
private EventEnvelope overseasDropshipEvent(boolean duplicateSku) {
|
||||
EventEnvelope original=event();
|
||||
var node=(com.fasterxml.jackson.databind.node.ObjectNode)v2Payload(original);
|
||||
node.put("warehouseId","92");
|
||||
var sourceOrder=(com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0);
|
||||
sourceOrder.put("orderType","ONE_CLICK_DROPSHIP").put("destinationType","OVERSEAS");
|
||||
var sku=json.createObjectNode().put("sourceInboundDetailId","d-1").put("skuCode","SKU-1").put("productName","产品").put("packageCount",5).put("productQuantity",50);
|
||||
var marks=sku.putArray("cartonMarks"); for(int n=1;n<=5;n++)marks.add("RK1#5-"+n);
|
||||
var pallet=json.createObjectNode().put("palletNo","DP-260909-0143"); pallet.putArray("skuPackages").add(sku);
|
||||
sourceOrder.putArray("pallets").add(pallet);
|
||||
if(duplicateSku) ((com.fasterxml.jackson.databind.node.ArrayNode)sourceOrder.path("items")).add(sourceOrder.path("items").get(0).deepCopy());
|
||||
node.putArray("orders").add(sourceOrder);
|
||||
String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||
return new EventEnvelope(original.eventId(),original.eventType(),2,original.occurredAt(),original.sourceRegion(),original.targetRegion(),original.tenantId(),original.aggregateType(),original.aggregateId(),original.aggregateVersion(),2L,original.traceId(),original.correlationId(),original.causationId(),node,hash);
|
||||
}
|
||||
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU6639870","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1),"100","Customer 100","FIRST_LEG",List.of()),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2),"100","Customer 100","FIRST_LEG",List.of()))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
|
||||
private JsonNode v2Payload(EventEnvelope source){ com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); node.set("sourceContainerBookingId",node.remove("containerBookingId")); return node; }
|
||||
|
||||
Reference in New Issue
Block a user