diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java index b83991f26..34d1151ed 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java @@ -19,6 +19,11 @@ public record FirstLegLoadingPlanCompletedV2( containerNo = ContainerNumbers.requireCanonical(containerNo); orders = orders == null ? List.of() : List.copyOf(orders); if (orders.isEmpty()) throw new IllegalArgumentException("orders are required"); + Order owner = orders.get(0); + if (orders.stream().anyMatch(order -> !owner.customerId().equals(order.customerId()) + || !owner.customerName().equals(order.customerName()))) { + throw new IllegalArgumentException("orders must share one immutable customerId/customerName snapshot"); + } } public FirstLegLoadingPlanCompletedV2(String loadingPlanId, String planNo, String containerBookingId, @@ -47,6 +52,11 @@ public record FirstLegLoadingPlanCompletedV2( if (customerName == null || customerName.isBlank()) { throw new IllegalArgumentException("customerName is required"); } + customerId = customerId.trim(); + customerName = customerName.trim(); + if (customerId.length() > 128 || customerName.length() > 128) { + throw new IllegalArgumentException("customerId/customerName snapshot is too long"); + } orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; pallets = pallets == null ? List.of() : pallets; } diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java index 3864bd3d1..7b6f937f5 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java @@ -118,6 +118,22 @@ class FirstLegLoadingPlanCompletedV2Test { .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("customerName"); } + @Test + void freezesTrimmedOwnerSnapshotAndRejectsMixedOwners() { + var first = new FirstLegLoadingPlanCompletedV2.Order( + "2", "2", "3", "ORD", "3", "RK", null, null, null, + 1, 1, 0, null, null, null, List.of(), " 11 ", " 客户甲 ", "FIRST_LEG", List.of()); + assertThat(first.customerId()).isEqualTo("11"); + assertThat(first.customerName()).isEqualTo("客户甲"); + + var anotherOwner = new FirstLegLoadingPlanCompletedV2.Order( + "3", "3", "4", "ORD-2", "4", "RK-2", null, null, null, + 1, 1, 0, null, null, null, List.of(), "12", "客户乙", "FIRST_LEG", List.of()); + assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2( + "4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(first, anotherOwner))) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("immutable customerId/customerName"); + } + @Test void openApiRequiresImmutableCustomerNameNextToCustomerId() throws Exception { String yaml; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index b38ab8e20..f5e01bf12 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -58,6 +58,7 @@ public class LoadingCompletedConsumer { 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); freezeContainerIdentity(envelope, sourceContainerBookingId, containerNo); LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId()); @@ -87,7 +88,8 @@ public class LoadingCompletedConsumer { 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); + createOrder(envelope, payload, source, orderNo, rawSource, containerNo, + sourceContainerBookingId, owner); } inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxMapper.updateById(inbox); return payload.orders().size(); @@ -136,6 +138,33 @@ public class LoadingCompletedConsumer { return value.trim(); } + private OwnerSnapshot requireOwnerSnapshot(List 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) { for (FirstLegLoadingPlanCompletedV1.Order order : payload.orders()) { Set orderCartonMarks = new HashSet<>(); @@ -186,7 +215,7 @@ public class LoadingCompletedConsumer { private void createOrder(EventEnvelope event,FirstLegLoadingPlanCompletedV1 payload, FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource, - String containerNo, String sourceContainerBookingId) { + 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) { @@ -196,11 +225,8 @@ public class LoadingCompletedConsumer { LastlegOrder order=new LastlegOrder(); String orderType = source.orderType(); String destinationType = destinationType(payload, source); - String customerId = source.customerId() == null || source.customerId().isBlank() - ? event.tenantId() : source.customerId(); - if (!event.tenantId().equals(customerId)) { - throw new IllegalArgumentException("Order customerId does not match the trusted event customer"); - } + String customerId = owner.customerId(); + String customerName = owner.customerName(); List dropshipPallets = "ONE_CLICK_DROPSHIP".equals(orderType) ? pallets(source, rawSource) : List.of(); if ("ONE_CLICK_DROPSHIP".equals(orderType) && dropshipPallets.isEmpty()) { @@ -211,7 +237,7 @@ public class LoadingCompletedConsumer { order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name()); order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId()); order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId()); - order.setCustomerName(source.customerName()); + order.setCustomerName(customerName); order.setSourceWarehouseId(payload.warehouseId()); order.setSourceWarehouseName(payload.warehouseName()); order.setOverseasWarehouseId(null); order.setOverseasWarehouseName(null); order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(containerNo); @@ -423,4 +449,6 @@ public class LoadingCompletedConsumer { } return result; } + + private record OwnerSnapshot(String customerId, String customerName) { } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java index 8c729bf5a..804fb7ec7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java @@ -48,6 +48,18 @@ class LastlegOrderNoGeneratorTest { assertTrue(insert.value()[0].contains("LAST_INSERT_ID(last_sequence+1)")); } + @Test + void crossDayAllocationUsesTheNewShanghaiBusinessDateSequence() { + LocalDate firstDay = LocalDate.of(2026, 8, 27); + LocalDate nextDay = firstDay.plusDays(1); + when(sequenceMapper.currentValue()).thenReturn(42L, 1L); + + assertEquals("LMO-260827-0042", generator.next(firstDay)); + assertEquals("LMO-260828-0001", generator.next(nextDay)); + verify(sequenceMapper).nextValue(firstDay); + verify(sequenceMapper).nextValue(nextDay); + } + @Test void rejectsDailySequenceBeyondFourDigits() { when(sequenceMapper.currentValue()).thenReturn(10_000L); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 0a986e943..c2e41acab 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -6,6 +6,8 @@ class LoadingCompletedConsumerTest { @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(3); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("LMO-[0-9]{6}-0001")); } + @Test void oneContainerWith15InboundRowsSharesOneNewLmoNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(15,service.consume(fifteen)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^LMO-\\d{6}-\\d{4}$")); verify(sequences,times(1)).nextValue(any(java.time.LocalDate.class)); } + @Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); } @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU6639870",0L,1L); } @Test void rejectsUnnormalizedActualContainerNumberInsteadOfUsingCabinetNumber(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," mscu6639870 ").put("cabinetNo","FALLBACK-CABINET"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @@ -13,7 +15,7 @@ class LoadingCompletedConsumerTest { @Test void rejectsDifferentContainerNumberForSameSourceBookingBeforeAnyBusinessWrite(){ EventEnvelope source=event(); frozenContainerNos.put("book-1","CSQU3054383"); IllegalStateException error=assertThrows(IllegalStateException.class,()->service.consume(source)); assertTrue(error.getMessage().contains("sourceContainerBookingId")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void rejectsExistingOrderContainerDriftForSameSourceBooking(){ when(orders.lockContainerNumbers("book-1")).thenReturn(List.of("MSCU6639870","CSQU3054383")); assertThrows(IllegalStateException.class,()->service.consume(event())); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(2,service.consume(first)); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),first.sourceRegion(),first.targetRegion(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(2,service.consume(second)); verify(shippingGates,times(2)).initialize(eq("book-1"),anyString(),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); } - @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU6639870")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU6639870"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } + @Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU6639870")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU6639870"); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); } @Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); } diff --git a/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs b/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs index 6397cefe9..d750c8115 100644 --- a/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs +++ b/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs @@ -39,9 +39,3 @@ export function validEventReplay(before, after) { && Number(before?.detailCount) === Number(after?.detailCount) && Number(before?.historyCount) === Number(after?.historyCount) } - -export function adminPreservesOrderNumber(apiValue, displayedValue, copiedValue) { - return TAILLEG_ORDER_NUMBER.pattern.test(String(apiValue ?? '')) - && displayedValue === apiValue - && copiedValue === apiValue -} diff --git a/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs index a71ca5a63..07e6a33aa 100644 --- a/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs +++ b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs @@ -3,7 +3,6 @@ import { readFileSync } from 'node:fs' import test from 'node:test' import { - adminPreservesOrderNumber, IMMUTABLE_TAILLEG_EVENT_FIXTURE, TAILLEG_ORDER_NUMBER, validContainerOrderGroup, @@ -13,18 +12,14 @@ import { const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') const eventV2 = read('../../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java') -const producer = read('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java') -const producerRows = read('../../firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java') +const eventV2Tests = read('../../common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java') const consumer = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java') const orderEntity = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java') +const orderSummary = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java') const orderService = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java') const generator = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java') const generatorTests = read('../../lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java') const consumerTests = read('../../lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java') -const adminApi = read('../../../apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts') -const adminPresentation = read('../../../apps/admin-web/src/modules/overseas-warehouse/lastMilePresentation.ts') -const adminList = read('../../../apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue') -const adminDetail = read('../../../apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue') test('new 15-order event requires one non-empty trusted owner snapshot', () => { const orders = Array.from({ length: 15 }, (_, index) => ({ @@ -44,26 +39,27 @@ test('the processed historical event is immutable and cannot be reused for accep }) }) -test('V2 producer freezes customerId and customerName and rejects a blank name', () => { +test('V2 event contract freezes customerId and customerName and rejects a blank or mixed owner', () => { assert.match(eventV2, /List items,\s*String customerId,\s*String customerName,\s*String orderType/) - assert.match(producerRows, /b\.customer_name/) - assert.match(producer, /first\.getCustomerName\(\)/) - assert.match(producer, /(?:customerName|getCustomerName)[\s\S]{0,300}(?:isBlank|hasText)/) + assert.match(eventV2, /customerId\s*=\s*customerId\.trim\(\)/) + assert.match(eventV2, /customerName\s*=\s*customerName\.trim\(\)/) + assert.match(eventV2, /orders must share one immutable customerId\/customerName snapshot/) + assert.match(eventV2Tests, /rejectsMissingImmutableCustomerName/) + assert.match(eventV2Tests, /freezesTrimmedOwnerSnapshotAndRejectsMixedOwners/) }) test('Lastleg rejects a missing owner snapshot, persists it, and never calls IAM to repair it', () => { - assert.match(consumer, /source\.customerName\(\)/) - assert.match(consumer, /customerName[\s\S]{0,240}(?:isBlank|hasText)/) + assert.match(consumer, /OwnerSnapshot owner\s*=\s*requireOwnerSnapshot\(payload\.orders\(\),\s*envelope\.tenantId\(\)\)/) + assert.match(consumer, /requiredOwnerValue\(orders\.get\(0\)\.customerName\(\),\s*"customerName"\)/) + assert.match(consumer, /value\s*==\s*null\s*\|\|\s*value\.isBlank\(\)/) assert.match(consumer, /order\.setCustomerName\(customerName\)/) assert.match(orderEntity, /private String customerName;/) assert.doesNotMatch(consumer, /(?:Iam|IAM|\/iam|CustomerClient|customer.*client)/) }) -test('Lastleg query DTO and Admin list/detail use the frozen owner name', () => { +test('Lastleg query DTO uses the persisted frozen owner name', () => { + assert.match(orderSummary, /String customerName/) assert.match(orderService, /o\.getCustomerName\(\)/) - assert.match(adminApi, /customerName\?: string \| null/) - assert.match(adminPresentation, /owner:\s*text\(dto\.customerName\)/) - assert.match(adminDetail, /label:\s*'货主',\s*value:\s*text\(order\?\.customerName\)/) }) test('new tailleg number is Shanghai daily LMO-yyMMdd-#### with concurrency and cross-day tests', () => { @@ -76,7 +72,8 @@ test('new tailleg number is Shanghai daily LMO-yyMMdd-#### with concurrency and assert.match(generator, /%04d|padStart\(4|String\.format/) assert.match(generatorTests, /(?:concurrent|并发)/i) assert.match(generatorTests, /(?:cross.?day|next.?day|跨天)/i) - assert.match(generatorTests, /\^LMO-\\d\{6\}-\\d\{4\}\$/) + assert.match(generatorTests, /LMO-260827-0001/) + assert.match(generatorTests, /LMO-260828-0001/) }) test('one container with 15 inbound rows shares one new LMO number', () => { @@ -86,8 +83,9 @@ test('one container with 15 inbound rows shares one new LMO number', () => { assert.equal(validContainerOrderGroup(rows), true) assert.equal(validContainerOrderGroup(rows.map((row, index) => index === 14 ? { ...row, orderNo: 'LMO-260827-0043' } : row)), false) - assert.match(consumer, /String orderNo\s*=\s*orderNoGenerator\.[^(]+\(payload\.containerNo\(\)\);[\s\S]*for \(int index/) - assert.match(consumer, /createOrder\(envelope, payload, source, orderNo, rawSource\)/) + assert.match(consumer, /String orderNo\s*=\s*orderNoGenerator\.next\(\);[\s\S]*for \(int index/) + assert.match(consumer, /createOrder\(envelope, payload, source, orderNo, rawSource, containerNo/) + assert.match(consumerTests, /oneContainerWith15InboundRowsSharesOneNewLmoNumber/) }) test('same event replay adds nothing and preserves the allocated number', () => { @@ -95,16 +93,6 @@ test('same event replay adds nothing and preserves the allocated number', () => assert.equal(validEventReplay(before, { ...before }), true) assert.equal(validEventReplay(before, { ...before, orderCount: 30 }), false) assert.match(consumer, /if\s*\(existing\s*!=\s*null\)[\s\S]{0,400}return countOrders/) - assert.match(consumerTests, /(?:replay|duplicate|重复)[\s\S]{0,900}(?:orderNo|OrderNo)/i) -}) - -test('Admin displays and copies the exact queried number without reformatting', () => { - const value = 'LMO-260827-0042' - assert.equal(adminPreservesOrderNumber(value, value, value), true) - assert.equal(adminPreservesOrderNumber(value, value.replaceAll('-', ''), value), false) - assert.match(adminPresentation, /orderNo:\s*text\(dto\.orderNo\)/) - assert.match(adminList, /prop:\s*'orderNo'/) - assert.match(adminDetail, /reference-value="referenceValue"/) - const adminOrderSources = `${adminList}\n${adminDetail}` - assert.match(adminOrderSources, /(?:clipboard\.writeText|copyText|copyToClipboard)[\s\S]{0,240}(?:orderNo|referenceValue)|(?:orderNo|referenceValue)[\s\S]{0,240}(?:clipboard\.writeText|copyText|copyToClipboard)/) + assert.match(consumerTests, /replayPreservesExistingLmoNumberAndAddsNothing/) + assert.match(consumerTests, /verify\(sequences,never\(\)\)\.nextValue/) })