This commit is contained in:
2026-10-21 07:53:31 +08:00
parent af5cf1e3bd
commit f00d0e16cf
147 changed files with 1696 additions and 872 deletions
@@ -1,41 +0,0 @@
package com.baiyue.lastleg.service;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class CompactBusinessNumberServiceTest {
@Test void usesCustomerInitialsAndShanghaiSecondsWithoutSequenceSuffix() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
var customers = mock(ReturnOrderReferenceResolver.class);
when(customers.customerName("42")).thenReturn("柏悦物流");
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class))
.thenAnswer(call -> Instant.now().getEpochSecond());
String number = new CompactBusinessNumberService(jdbc, customers).next("TB", "42");
assertTrue(number.matches("TB-BYWL-[0-9]{12}"), number);
assertEquals("AB", CompactBusinessNumberService.initials("Alpha Beta"));
}
@Test void usesDatabaseLogicalSecondAcrossInstancesAndClockRollback() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
var first = new CompactBusinessNumberService(jdbc, null);
var second = new CompactBusinessNumberService(jdbc, null);
Instant now = Instant.parse("2026-10-16T02:30:00Z");
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class))
.thenReturn(now.getEpochSecond(), now.getEpochSecond() + 1, now.getEpochSecond() + 2);
assertEquals("ZY-ABC-261016103000", first.allocate("ZY", "ABC", now));
assertEquals("ZY-ABC-261016103001", second.allocate("ZY", "ABC", now));
assertEquals("ZY-ABC-261016103002", first.allocate("ZY", "ABC", now.minusSeconds(60)));
}
@Test void physicalLabelKeepsEveryIdBitAndIsCompact() {
long id = 2087379674555461633L;
String mark = LastlegOrderNoGenerator.compactCartonMark(id);
assertTrue(mark.length() <= 16);
assertEquals(id, Long.parseLong(mark.substring(3), 36));
assertNotEquals(mark, LastlegOrderNoGenerator.compactCartonMark(id + 1_000_000));
assertEquals(mark, new LastlegOrderNoGenerator().next("RETURN_CARTON", "92", id));
}
}
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.repository.DocumentSequenceRepository;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import java.time.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class LastlegDocumentNumberGeneratorTest {
private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC);
/** 多构造器情况下正式依赖可以被 Spring 正确选择。 */
@Test void springCreatesGeneratorWithRepository() {
try(var context=new AnnotationConfigApplicationContext()) {
context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class));
context.register(LastlegDocumentNumberGenerator.class);context.refresh();
assertThat(context.getBean(LastlegDocumentNumberGenerator.class)).isNotNull();
}
}
/** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */
@Test void usesShanghaiDateAndIndependentSequenceKey() {
var sequences=mock(DocumentSequenceRepository.class);
when(sequences.next("YY:20261020")).thenReturn(1L,2L,9999L);
var numbers=new LastlegDocumentNumberGenerator(sequences,CLOCK);
assertThat(numbers.next("YY")).isEqualTo("YY202610200001");
assertThat(numbers.next("YY")).isEqualTo("YY202610200002");
assertThat(numbers.next("YY")).isEqualTo("YY202610209999");
verify(sequences,times(3)).next("YY:20261020");
}
/** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */
@Test void rejectsUnknownTypeAndOverflow() {
var sequences=mock(DocumentSequenceRepository.class);
var numbers=new LastlegDocumentNumberGenerator(sequences,CLOCK);
assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class);
verifyNoInteractions(sequences);
when(sequences.next("YY:20261020")).thenReturn(10000L);
assertThatThrownBy(()->numbers.next("YY")).isInstanceOf(BizException.class);
}
}
@@ -1,94 +1,30 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.mapper.LastlegOrderNoDailySequenceMapper;
import org.apache.ibatis.annotations.Insert;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LastlegOrderNoGeneratorTest {
@Mock LastlegOrderNoDailySequenceMapper sequenceMapper;
private LastlegOrderNoGenerator generator;
@BeforeEach
void setUp() {
generator = new LastlegOrderNoGenerator();
ReflectionTestUtils.setField(generator, "sequenceMapper", sequenceMapper);
/** 各作业类型使用不同前缀,同类型第二次调用必须递增。 */
@Test void routesEveryOperationalTypeToItsDailySequence() {
var generator=TestDocumentNumbers.orders();
Map.ofEntries(Map.entry("OUTBOUND","CK"),Map.entry("ONE_CLICK_DROPSHIP","DFCK"),
Map.entry("RELABEL_OUTBOUND","HBCK"),Map.entry("RETURN_OUTBOUND","THCK"),
Map.entry("DELIVERY","DL"),Map.entry("TRUCK","TR"),Map.entry("LOOSEBOARD","LB"),
Map.entry("EXCEPTION","EX"),Map.entry("DISCARD","CD"),Map.entry("INBOUND_PLAN","IP"),
Map.entry("RETURN_RECEIPT","RR"),Map.entry("RETURN_BATCH","RB"),Map.entry("RETURN_PACKING_PLAN","HX"))
.forEach((type,prefix)->{
assertEquals(prefix+"202610200001",generator.next(type));
assertEquals(prefix+"202610200002",generator.next(type));
});
assertThrows(IllegalArgumentException.class,()->generator.next("UNKNOWN"));
}
@Test
void usesShanghaiBusinessDateAndDatabaseAllocatedSequence() {
LocalDate date = LocalDate.of(2026, 8, 27);
when(sequenceMapper.currentValue()).thenReturn(1L, 2L);
assertEquals("LMO-260827-0001", generator.next(date));
assertEquals("LMO-260827-0002", generator.next(date));
verify(sequenceMapper, times(2)).nextValue(date);
}
@Test
void mapperUsesOneAtomicDatabaseOperationForConcurrentInstances() throws Exception {
Insert insert = LastlegOrderNoDailySequenceMapper.class
.getMethod("nextValue", LocalDate.class).getAnnotation(Insert.class);
assertTrue(insert.value()[0].contains("ON DUPLICATE KEY UPDATE"));
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);
assertThrows(IllegalStateException.class, () -> generator.next(LocalDate.of(2026, 8, 27)));
}
@Test void actualContainerNumberIsStableAndShared() {
LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator();
assertTrue(generator.forContainerNo("2087379674555461633").matches("FL-WH-\\d{8}-\\d{6}"));
assertEquals(generator.forContainerNo("MSCU1"), new LastlegOrderNoGenerator().forContainerNo("MSCU1"));
assertNotEquals(generator.forContainerNo("MSCU1"), generator.forContainerNo("CAB1"));
assertTrue(generator.forContainerNo("MSCU1234567890").length() <= 32);
assertThrows(IllegalArgumentException.class, () -> generator.forContainerNo(" "));
}
@Test void formatsBusinessWarehouseAndDate() {
String value = new LastlegOrderNoGenerator().next("OUTBOUND", "warehouse-LAX-01", 123L);
assertTrue(value.matches("OB-LAX-\\d{8}-000123"), value);
assertTrue(value.length() <= 24);
}
@Test void transferNumbersDoNotCollideWhenDatabaseIdSuffixesRepeat() {
String first = generator.next("TRANSFER", "warehouse-LAX-01", 123L);
String second = generator.next("TRANSFER", "warehouse-LAX-01", 1_000_123L);
String repeatedId = generator.next("WAREHOUSE_TRANSFER", "warehouse-LAX-01", 123L);
assertTrue(first.matches("WT-[0-9A-Z]{25}"), first);
assertTrue(first.length() <= 32);
assertNotEquals(first, second);
assertNotEquals(first, repeatedId);
/** 头程来源尾程号未纳入独立改号,保持同柜分组关系。 */
@Test void actualContainerNumberRemainsStableAndShared() {
var generator=TestDocumentNumbers.orders();
assertTrue(generator.forContainerNo("MSCU6639870").matches("FL-WH-\\d{8}-\\d{6}"));
assertEquals(generator.forContainerNo("MSCU6639870"),TestDocumentNumbers.orders().forContainerNo("MSCU6639870"));
assertNotEquals(generator.forContainerNo("MSCU6639870"),generator.forContainerNo("CSQU3054383"));
assertThrows(IllegalArgumentException.class,()->generator.forContainerNo(" "));
}
}
@@ -3,10 +3,10 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class LoadingCompletedConsumerTest {
private final Map<String,String> frozenContainerNos=new HashMap<>();
@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 com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @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,"loadingEvidence",loadingEvidence); 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(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); 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; }); }
@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 com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @Mock LastlegDocumentNumberGenerator 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,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); 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(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); 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(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> 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<LastlegOrder> 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())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor<LastlegInboundOrder> 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("FL-WH-[0-9]{8}-[0-9]{6}")); }
@Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ 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.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor<LastlegOrder> 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("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); }
@Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ 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.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor<LastlegOrder> 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("^FL-WH-\\d{8}-\\d{6}$")); verifyNoInteractions(sequences); }
@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.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.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor<LastlegOrder> 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","","MSCU6639870",0L,1L); }
@@ -15,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(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(first).outcome()); 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(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(second).outcome()); verify(shippingGates,times(2)).initialize(eq("book-1"),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor<LastlegOrder> 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 replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); row.setStatus("PROCESSED"); when(inbox.selectById(event.eventId())).thenReturn(row); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE, service.consume(event).outcome()); 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 replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); row.setStatus("PROCESSED"); when(inbox.selectById(event.eventId())).thenReturn(row); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE, service.consume(event).outcome()); verifyNoInteractions(sequences); 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.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()); }
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.service;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class PhysicalCartonNumberTest {
/** 箱码不属于本次改号,完整保留内部 ID 的每一位以保证扫码唯一性。 */
@Test void preservesEveryIdBit() {
long id = 2087379674555461633L;
String mark = LastlegOrderNoGenerator.compactCartonMark(id);
assertTrue(mark.length() <= 16);
assertEquals(id, Long.parseLong(mark.substring(3), 36));
assertNotEquals(mark, LastlegOrderNoGenerator.compactCartonMark(id + 1_000_000));
assertEquals(mark, LastlegOrderNoGenerator.compactCartonMark(id));
assertThrows(IllegalArgumentException.class, () -> LastlegOrderNoGenerator.compactCartonMark(0));
}
}
@@ -1,60 +1,27 @@
package com.baiyue.lastleg.service;
import org.junit.jupiter.api.Test;
import java.math.BigInteger;
import java.util.List;
import java.util.HashSet;
import java.util.Map;
import java.util.UUID;
import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.*;
class ReturnBusinessNumberTest {
// 实物箱唛采用稳定BX编码,由CompactBusinessNumberServiceTest校验;此处仅验证独立业务编号。
private static final Map<String, String> TYPES = Map.of("RETURN", "RT", "RETURN_RECEIPT", "RR",
"RETURN_BATCH", "RB", "RETURN_RELABEL", "RETU");
@Test
void formerlyCollidingInternalIdsAndRepeatedIdsNoLongerDetermineBusinessNumbers() {
LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator();
long first = 2099800000000781889L, second = first + 1_000_000L;
assertEquals(Math.floorMod(first, 1_000_000), Math.floorMod(second, 1_000_000));
TYPES.forEach((type, prefix) -> {
String a = generator.next(type, "warehouse-LAX-01", first);
String b = generator.next(type, "warehouse-LAX-01", second);
String c = generator.next(type, "warehouse-LAX-01", first);
assertEquals(3, new HashSet<>(java.util.List.of(a, b, c)).size());
for (String value : java.util.List.of(a, b, c)) {
assertTrue(value.matches(prefix + "-[0-9A-Z]{25}"), value);
assertTrue(value.length() <= 32);
assertFalse(value.contains(Long.toString(first)));
}
});
}
@Test
void encodingPreservesAll128BitsIncludingLeadingZerosAndSignBits() {
for (UUID uuid : java.util.List.of(new UUID(0, 0), new UUID(-1, -1), new UUID(Long.MIN_VALUE, 1),
UUID.fromString("00000000-0000-4000-8000-000000000001"),
UUID.fromString("ffffffff-ffff-4fff-bfff-000000000001"))) {
String encoded = LastlegOrderNoGenerator.encodeReturnUuid(uuid);
assertEquals(25, encoded.length());
assertEquals(new BigInteger(uuid.toString().replace("-", ""), 16), new BigInteger(encoded, 36));
/** 独立单据不再依赖客户简称和内部 ID,各前缀从一开始。 */
@Test void businessPrefixesHaveIndependentFourDigitSequences() {
var generator=TestDocumentNumbers.documents();
for(String prefix:List.of("TH","THHB","TB","ZY","RR","RB","HX","CG","PR","PJ","PF","PN","PX","PG","CT","FB","FO","VR")) {
var values=List.of(generator.next(prefix),generator.next(prefix),generator.next(prefix));
assertEquals(3,new HashSet<>(values).size());
assertEquals(prefix+"202610200001",values.get(0));
assertEquals(prefix+"202610200003",values.get(2));
}
}
@Test
void independentInstancesGenerateDistinctNumbersDuringConcurrentBurst() {
var values = IntStream.range(0, 20_000).parallel()
.mapToObj(i -> new LastlegOrderNoGenerator().next("RETURN_RECEIPT", "42", 123L)).toList();
assertEquals(values.size(), new HashSet<>(values).size());
}
@Test
void nonReturnRulesRemainUnchangedAndReturnNormalizationIsLocaleIndependent() {
LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator();
for (String type : java.util.List.of("OUTBOUND", "RELABEL", "PROCUREMENT", "DISPATCH")) {
assertEquals(generator.next(type, "LAX", 123), generator.next(type, "LAX", 1_000_123));
}
assertTrue(generator.next(" return_receipt ", "42", 1).matches("RR-[0-9A-Z]{25}"));
/** 商业池和普通作业入口必须共享卡车及散板同类型序列。 */
@Test void commercialAndOperationalEntriesShareTheSameCounters() {
var documents=TestDocumentNumbers.documents();var operations=new LastlegOrderNoGenerator(documents);
assertEquals("TR202610200001",documents.next("TR"));
assertEquals("TR202610200002",operations.next("TRUCK"));
assertEquals("LB202610200001",operations.next("LOOSEBOARD"));
assertEquals("LB202610200002",documents.next("LB"));
}
}
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.service;
import com.baiyue.lastleg.repository.DocumentSequenceRepository;
import java.time.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/** 业务单测使用真实格式及隔离计数器;数据库并发由 Repository 集成测试覆盖。 */
public final class TestDocumentNumbers {
/** 测试工具不实例化。 */
private TestDocumentNumbers() {}
/** 每个测试生成器独立计数,类型前缀之间互不占号。 */
public static LastlegDocumentNumberGenerator documents() {
var sequences=mock(DocumentSequenceRepository.class);
var counters=new ConcurrentHashMap<String,AtomicLong>();
lenient().when(sequences.next(anyString())).thenAnswer(call->counters.computeIfAbsent(call.getArgument(0),key->new AtomicLong()).incrementAndGet());
return new LastlegDocumentNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-20T00:00:00Z"),ZoneOffset.UTC));
}
/** 海外类型映射同样使用生产生成器。 */
public static LastlegOrderNoGenerator orders() { return new LastlegOrderNoGenerator(documents()); }
}