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
@@ -75,7 +75,7 @@ class DispatchDeliveryArrangementPersistenceTest {
var repository=new DispatchDeliveryRepository(session.getMapper(DispatchDeliveryArrangementMapper.class),session.getMapper(OutboundOrderMapper.class),
session.getMapper(OutboundPalletItemMapper.class),session.getMapper(DeliveryOrderMapper.class),session.getMapper(DeliveryOperationMapper.class),
session.getMapper(LastlegOrderMapper.class),session.getMapper(LastlegOrderStatusHistoryMapper.class));
var numbers=mock(LastlegOrderNoGenerator.class);when(numbers.next(anyString(),anyString(),anyLong())).thenAnswer(a->a.getArgument(0)+"-"+a.getArgument(2));
var numbers=mock(LastlegOrderNoGenerator.class);when(numbers.next(anyString())).thenAnswer(a->a.getArgument(0)+"-"+com.baomidou.mybatisplus.core.toolkit.IdWorker.getId());
var target=new DispatchDeliveryService(execution,repository,grouping,fees,new NormalDeliveryCompletionPolicy(grouping),numbers,json);
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds),new AnnotationTransactionAttributeSource()));
service=(DispatchDeliveryService)proxy.getProxy();
@@ -39,7 +39,7 @@ class DispatchDeliveryServiceTest {
@BeforeEach void prepare() {
context=context(List.of(cargo(1,"101","ONT8",51,2),cargo(2,"101","LAX9",52,3),cargo(3,"102","ONT8",53,4)),false);
var numbers=mock(LastlegOrderNoGenerator.class);
when(numbers.next(anyString(),anyString(),anyLong())).thenAnswer(a->a.getArgument(0)+"-"+a.getArgument(2));
when(numbers.next(anyString())).thenAnswer(a->a.getArgument(0)+"-"+com.baomidou.mybatisplus.core.toolkit.IdWorker.getId());
service=new DispatchDeliveryService(execution,repository,grouping,fees,new NormalDeliveryCompletionPolicy(grouping),numbers,json);
when(execution.lockForArrangement(any(),eq("DP-10"))).thenAnswer(a->context);
when(execution.replay(any(),any(),any())).thenReturn(Optional.empty());
@@ -153,7 +153,7 @@ class DeliveryTransferServiceTest {
ReflectionTestUtils.setField(service, "planAllocations", planAllocations);
ReflectionTestUtils.setField(service, "planLines", planLines);
ReflectionTestUtils.setField(service, "inboundCartons", inboundCartons);
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders());
ReflectionTestUtils.setField(service, "tailOrders", tailOrders);
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "tailOrderDetails", tailOrderDetails);
@@ -72,7 +72,7 @@ class ReturnOutboundClosureTest {
ReflectionTestUtils.setField(service, "pdaPicks", picks);
ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications);
ReflectionTestUtils.setField(service, "relabelService", relabelService);
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders());
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
carton = new ReturnInventoryCarton(); carton.setId(10L); carton.setCartonMark("RC#1-1");
carton.setCustomerId("C1"); carton.setOverseasWarehouseId("WH1"); carton.setSourceReturnNo("RT1,RT2");
@@ -82,7 +82,8 @@ class ReturnOutboundPdaPersistenceTest {
stockProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
ReflectionTestUtils.setField(target, "returnStock", stockProxy.getProxy());
var numbers = mock(LastlegOrderNoGenerator.class);
when(numbers.next(anyString(), eq("WH-1"), anyLong())).thenAnswer(call -> "RETU-" + call.getArgument(2));
var testNumber = new java.util.concurrent.atomic.AtomicInteger();
when(numbers.next(anyString())).thenAnswer(call -> "THCK20261020" + String.format("%04d", testNumber.incrementAndGet()));
ReflectionTestUtils.setField(target, "orderNoGenerator", numbers);
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
@@ -39,7 +39,7 @@ class CargoDiscardOutboundServiceTest {
ReflectionTestUtils.setField(service, "cartons", cartons);
ReflectionTestUtils.setField(service, "profiles", profiles);
ReflectionTestUtils.setField(service, "returnCartons", returnCartons);
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders());
application = new CargoDiscardApplication(); application.setId(1L); application.setCustomerId("C1");
application.setDiscardNo("CD-TEST"); application.setOverseasWarehouseId("99");
application.setTotalPackages(5); application.setStatus("APPROVED");
@@ -66,7 +66,7 @@ class CargoDiscardOutboundServiceTest {
assertEquals("DISCARD_OUTBOUND", document.getOutboundType()); assertEquals("WAIT_PICK", document.getStatus());
assertNull(document.getLxCodes());
assertEquals(5, document.getPackageCount()); assertEquals(2, document.getPalletCount());
assertTrue(service.outboundNo(application).startsWith("OB-"));
assertTrue(service.outboundNo(application).matches("CK[0-9]{12}"));
assertEquals(List.of(3, 2), snapshots.stream().map(OutboundPalletItem::getPackageCount).toList());
verify(outbounds, times(1)).insert(any(OutboundOrder.class)); verify(pallets, times(2)).insert(any(OutboundPalletItem.class));
}
@@ -134,7 +134,7 @@ class CargoDiscardServiceTest {
ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "adjustments", adjustments);
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders());
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
ReflectionTestUtils.setField(service, "returnCartons", returnCartons);
@@ -38,7 +38,9 @@ class SmartDispatchOpenApiContractTest {
assertThat(map(paths.get(base+"/drafts"))).containsKey("get");
var candidate=map(map(paths.get(base+"/candidates")).get("get"));
var parameterNames=((List<?>)candidate.get("parameters")).stream().map(SmartDispatchOpenApiContractTest::map).map(p->p.get("name")).toList();
assertThat(parameterNames).contains("warehouseId","destinationWarehouseCode","draftToken").doesNotContain("customerId","fbaWarehouseCode");
assertThat(parameterNames).contains("warehouseId","destinationWarehouseCode","draftToken","page","size","warehouseTab").doesNotContain("customerId","fbaWarehouseCode");
assertThat(map(map(schemas.get("DispatchCandidateList")).get("properties")).keySet())
.containsExactlyInAnyOrder("candidates","snapshotAt","complete","total","page","size","pages");
var draft=map(map(schemas.get("DispatchDraft")).get("properties"));
assertThat(draft.keySet()).containsExactlyInAnyOrder("draftToken","recordVersion","status","warehouseId","containers","sources","expiresAt");
var vehicle=map(map(schemas.get("DispatchDraftVehicle")).get("properties"));assertThat(vehicle.keySet()).containsExactlyInAnyOrder("draftContainerId","dispatch");
@@ -0,0 +1,127 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery;
import org.apache.ibatis.scripting.defaults.DefaultParameterHandler;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.*;
import java.util.*;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
/** 显式指定TEST配置才运行;只读真实MySQL,不建表、不造数、不输出客户记录或连接凭据。 */
@EnabledIfSystemProperty(named="dispatch.mysql.profile",matches=".+")
class DispatchCandidateMySqlReadOnlyTest {
private Connection connect() throws Exception {
var profile=new HashMap<String,String>();
for(String line:Files.readAllLines(Path.of(System.getProperty("dispatch.mysql.profile")))) {
var match=java.util.regex.Pattern.compile("^\\s*(?:export\\s+)?([A-Z][A-Z0-9_]*)\\s*=\\s*(.*)$").matcher(line);
if(!match.matches())continue;
String value=match.group(2).trim();
if(value.length()>1&&(value.startsWith("\"")&&value.endsWith("\"")||value.startsWith("'")&&value.endsWith("'")))
value=value.substring(1,value.length()-1);
profile.put(match.group(1),value);
}
assertEquals("test",profile.get("CHONGCHONG_ENV"),"只允许显式TEST只读检查");
String url="jdbc:mysql://"+profile.get("CHONGCHONG_DB_HOST")+":"+profile.get("CHONGCHONG_DB_PORT")
+"/baiyue_lastleg?characterEncoding=UTF-8&connectTimeout=5000&socketTimeout=15000";
try {
var connection=DriverManager.getConnection(url,profile.get("CHONGCHONG_DB_USERNAME"),profile.get("CHONGCHONG_DB_PASSWORD"));
connection.setReadOnly(true);
return connection;
} catch(SQLException error) {
throw new AssertionError("MySQL connection failed; SQLState="+error.getSQLState()+", code="+error.getErrorCode());
}
}
/** 用纯SELECT构造冲突,确认原始UNION拒绝混合collation且实际生产投影保留中文/字符身份,无需修改测试库。 */
@Test void mixedCollationsAreNormalizedBeforeUnionForCountAndPage() throws Exception {
var fields=Arrays.stream(DispatchPalletCandidate.class.getDeclaredFields()).filter(f->f.getType()==String.class)
.map(java.lang.reflect.Field::getName).collect(Collectors.toSet());
var project=DispatchCandidatePageMapper.Sql.class.getDeclaredMethod("project",String.class,Set.class,boolean.class);
project.setAccessible(true);
String general="SELECT "+fields.stream().sorted().map(f->"_utf8mb4'洛杉矶-001' COLLATE utf8mb4_general_ci AS "+f).collect(Collectors.joining(","));
String unicode=general.replace("utf8mb4_general_ci","utf8mb4_unicode_ci");
try(var connection=connect();var statement=connection.createStatement()) {
statement.setQueryTimeout(10);
var failure=assertThrows(SQLException.class,()->statement.executeQuery(general+" UNION ALL "+unicode));
assertTrue(Set.of(1267,1271).contains(failure.getErrorCode()),"MySQL应报告collation冲突");
for(boolean count:List.of(true,false)) {
String sql=project.invoke(null,general,fields,count)+" UNION ALL "+project.invoke(null,unicode,fields,count);
try(var rows=statement.executeQuery(sql)) {
int total=0;
while(rows.next()) {
total++;
assertEquals("洛杉矶-001",rows.getString("overseasWarehouseId"));
assertEquals("洛杉矶-001",rows.getString("fbaWarehouseCode"));
if(!count) {
for(String field:fields)assertEquals("洛杉矶-001",rows.getString(field),field);
assertNull(rows.getObject("planPalletId"));
assertNull(rows.getObject("appointmentAt"));
assertNull(rows.getObject("grossWeightKg"));
}
}
assertEquals(2,total);
}
}
}
}
/** 执行MyBatis正式SQL和绑定参数,分别检查真实四来源count/page以及筛选分支;仅输出行数和SQL耗时。 */
@Test void actualCandidateQueriesExecuteOnMySql() throws Exception {
String warehouse=System.getProperty("dispatch.mysql.warehouse");
assertNotNull(warehouse,"指定只读验收仓");
var configuration=new Configuration();
configuration.addMapper(DispatchCandidatePageMapper.class);
List<CandidateQuery> queries=List.of(
new CandidateQuery(warehouse,null,null,null,null,null,1,20,"ALL"),
new CandidateQuery(warehouse,null,null,null,null,null,2,20,"ALL"),
new CandidateQuery(warehouse,null,null,null,"RELABEL",null,1,20,"ALL"),
new CandidateQuery(warehouse,null,null,null,"RECEIVED",null,1,20,"LAX"),
new CandidateQuery(warehouse,null,null,null,"PLAN",null,1,20,"NYC"),
new CandidateQuery(warehouse,null,null,"ONT8",null,null,1,20,"ALL"),
new CandidateQuery(warehouse,List.of(-1L),null,null,null,null,1,20,"ALL"));
try(var connection=connect()) {
try(var statement=connection.createStatement();var rows=statement.executeQuery("SELECT @@collation_connection,COLLATION(CAST(1 AS CHAR(128)))")) {
assertTrue(rows.next());
System.out.printf("MYSQL_COLLATION connection=%s castAsChar=%s%n",rows.getString(1),rows.getString(2));
}
try(var statement=connection.createStatement();var rows=statement.executeQuery(
"SELECT TABLE_NAME,COLUMN_NAME,COLLATION_NAME FROM information_schema.COLUMNS "
+"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('ll_order','ll_inbound_plan','ll_return_receipt','ll_relabel_application') "
+"AND COLUMN_NAME IN ('customer_id','overseas_warehouse_id','overseas_warehouse_name','overseas_warehouse_code') ORDER BY TABLE_NAME,COLUMN_NAME")) {
while(rows.next())System.out.printf("MYSQL_COLLATION %s.%s=%s%n",rows.getString(1),rows.getString(2),rows.getString(3));
}
for(var query:queries) {
var parameters=new HashMap<String,Object>();
parameters.put("query",query);parameters.put("offset",(long)(query.page()-1)*query.size());
long total=0;int size=0;long started=System.nanoTime();
for(String method:List.of("count","page")) {
var mapped=configuration.getMappedStatement(DispatchCandidatePageMapper.class.getName()+"."+method);
var bound=mapped.getBoundSql(parameters);
assertTrue(bound.getSql().stripLeading().startsWith("SELECT"));
try(var statement=connection.prepareStatement(bound.getSql())) {
statement.setQueryTimeout(10);
new DefaultParameterHandler(mapped,parameters,bound).setParameters(statement);
try(var rows=statement.executeQuery()) {
if(method.equals("count")) { assertTrue(rows.next());total=rows.getLong(1); }
else while(rows.next()) {
size++;assertEquals(warehouse,rows.getString("overseasWarehouseId"));
if(query.destinationWarehouseCode()!=null)assertEquals(query.destinationWarehouseCode(),rows.getString("fbaWarehouseCode"));
}
}
}
}
assertTrue(size<=query.size());assertTrue(size<=total);
System.out.printf(Locale.ROOT,"MYSQL_READ_ONLY page=%d stage=%s tab=%s total=%d rows=%d countAndPageMs=%.3f%n",
query.page(),query.sourceStage(),query.warehouseTab(),total,size,(System.nanoTime()-started)/1_000_000d);
}
}
}
}
@@ -536,6 +536,7 @@ public class DispatchAggregatePersistenceTest {
mock(com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper.class),mock(com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository.class),mock(DispatchAggregateService.class),json);
var vehicles=mock(VehicleRepository.class); var drivers=mock(DriverService.class);var reservations=mock(FleetBookingReservations.class);
var fleetService=new FleetTripService(fleetRepo,vehicles,drivers,warehouses,json,gateway,reservations);
org.springframework.test.util.ReflectionTestUtils.setField(fleetService, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
nativeFleetService=fleetService;
org.springframework.test.util.ReflectionTestUtils.setField(fleetService,"workChanges",mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class));
org.springframework.test.util.ReflectionTestUtils.setField(fleetService,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class));
@@ -22,6 +22,7 @@ class DispatchDraftResourceLockTest {
when(repository.lockTrip("TR-7")).thenReturn(first);when(repository.lockTrip("TR-2")).thenReturn(second);
when(repository.listBindings(7L)).thenReturn(List.of(a));when(repository.listBindings(2L)).thenReturn(List.of(b));
var service=new FleetTripService(repository,vehicles,mock(DriverService.class),mock(FleetWarehouseDirectory.class),new ObjectMapper(),mock(FleetDispatchGateway.class),mock(FleetBookingReservations.class));
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
service.lockExistingDispatchShells(List.of("DS-A","DS-B"));
var order=inOrder(repository,vehicles);
order.verify(repository).lockTrip("TR-2");order.verify(repository).lockTrip("TR-7");
@@ -37,6 +38,7 @@ class DispatchDraftResourceLockTest {
when(vehicles.lockById(anyLong())).thenReturn(Optional.of(mock(VehicleRepository.VehicleSnapshot.class)));
when(repository.lockDriverIdentity(anyLong())).thenAnswer(call->call.getArgument(0));
var service=new FleetTripService(repository,vehicles,mock(DriverService.class),mock(FleetWarehouseDirectory.class),new ObjectMapper(),mock(FleetDispatchGateway.class),mock(FleetBookingReservations.class));
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
var v9=new FleetExecutionModels.DispatchVehicle("MASTER",9L,null,null,null,null,null,List.of());
var v3=new FleetExecutionModels.DispatchVehicle("MASTER",3L,null,null,null,null,null,List.of());
var d8=new FleetExecutionModels.DispatchDriver("MASTER",8L,null,null,null,null,List.of(),null);
@@ -0,0 +1,70 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository;
import com.baiyue.lastleg.relabel.service.RelabelDispatchSourceService;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 真实排车、车次、命令和来源入口回归;未物化PLAN不得进入仅接受实托的执行锁。 */
class DispatchLoadingEligibilityPersistenceTest {
/** 全车在途和部分实托已拣的混合车均应业务拒绝,原单/命令/占用/扫码事实完全不变。 */
@ParameterizedTest
@ValueSource(booleans={false,true})
void unmaterializedPlanRejectsLoadingBeforePhysicalSourceLock(boolean mixed) throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var plan=f.source(1,"101",501,"ONT8",3);
var selections=mixed?List.of(plan,f.source(2,"202",502,"LAX9",2)):List.of(plan);
var created=f.tx.execute(t->f.service.create(f.actor,f.configured("unreceived-loading",selections)));
var approved=f.approve(created);
f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN' WHERE plan_pallet_id=1");
if(mixed) {
f.picked(2,"202",502,2);
f.db.update("UPDATE ll_dispatch_order SET picked_package_count=2");
}
var providerRows=mock(RelabelDispatchSourceRepository.class);
var provider=spy(new RelabelDispatchSourceService(providerRows,f.json));
var sources=mock(DispatchSourceRepository.class);
var rules=spy(new DispatchService());
ReflectionTestUtils.setField(rules,"relabelSources",provider);
ReflectionTestUtils.setField(rules,"wholeSources",sources);
ReflectionTestUtils.setField(f.service,"sourceRules",rules);
var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
var preview=f.tx.execute(t->f.service.loading(actor,created.dispatchNo()));
assertThat(preview.pickingReconciled()).isFalse();
assertThat(preview.loadingComplete().allowed()).isFalse();
assertThat(preview.loadingComplete().blockCode()).isEqualTo("PICKING_INCOMPLETE");
assertThat(preview.pallets()).hasSize(mixed?1:0);
var tables=List.of("ll_dispatch_order","ll_dispatch_item","ll_dispatch_command","ll_dispatch_history",
"ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_dispatch_pallet_lock","ll_dispatch_physical_pallet_lock",
"ll_fleet_trip","ll_fleet_trip_history");
var before=snapshot(f,tables);
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"unreceived-negative",preview.manifestHash(),List.of(),"未实收负例");
assertThatThrownBy(()->f.tx.execute(t->f.service.loadingComplete(actor,created.dispatchNo(),request)))
.isInstanceOfSatisfying(BizException.class,error->assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT))
.hasMessage("逐项真实拣货尚未核平");
assertThat(snapshot(f,tables)).isEqualTo(before);
verify(rules,never()).lockExecutionSources(anyString(),anyList());
verifyNoInteractions(provider,providerRows,sources,f.ownedBusinessOwnership);
}
/** 按实际列值读取CLOB及时间,避免比较JDBC临时对象身份。 */
private Object snapshot(DispatchAggregatePersistenceTest.Fixture f,List<String> tables) {
return tables.stream().map(table->f.db.query("SELECT * FROM "+table+" ORDER BY id",(row,index)->{
var values=new java.util.ArrayList<String>();
for(int column=1;column<=row.getMetaData().getColumnCount();column++)values.add(row.getString(column));
return values;
})).toList();
}
}
@@ -28,6 +28,45 @@ import static org.mockito.Mockito.*;
/** 真实提供者→完整组选货→主单/占用→实际库存命令/归属→PDA/装车的本机事务链。 */
class DispatchMergedRelabelPersistenceTest {
/** 原生退货分页按实箱去重计量,不能因申请多明细重复计箱或重量。 */
@Test void nativeReturnPageMeasuresOneCartonOnce() throws Exception {
var f=new Merged(false);var at=LocalDateTime.of(2026,1,1,0,0);
f.insert("ll_return_inventory_carton","id",901,"customer_id","101","overseas_warehouse_id","9",
"carton_mark","NATIVE-CARTON","pallet_code","NATIVE-PALLET","relabel_application_id",201,
"relabel_no","RL-A","status","RELABEL_PENDING","sku_types",1,"total_quantity",2,
"weight",12,"length",100,"width",100,"height",100,"created_at",at,"updated_at",at);
f.f.db.update("UPDATE ll_relabel_item SET source_inventory_position_id=NULL,return_carton_id=901 WHERE id=301");
f.insert("ll_relabel_item","id",303,"application_id",201,"customer_id","101","product_name_zh","同箱另一明细",
"total_package_count",1,"notified_package_count",1,"carton_mark","NATIVE-CARTON","operation_type","CARTON_LABEL",
"return_carton_id",901,"destination_fba_warehouse_code","ONT8");
var query=new DispatchPlanningModels.CandidateQuery("9",null,"RL-A",null,"RELABEL",null,1,20,"ALL");
var mapper=f.f.session.getMapper(DispatchCandidatePageMapper.class);
assertThat(mapper.count(query)).isOne();
var row=mapper.page(query,0).get(0);
assertThat(row.returnCartonSource()).isTrue();assertThat(row.packageCount()).isOne();
assertThat(row.grossWeightKg()).isEqualByComparingTo("12");assertThat(row.volumeCbm()).isEqualByComparingTo("1");
f.f.db.update("UPDATE ll_return_inventory_carton SET weight=NULL,height=NULL WHERE id=901");
row=mapper.page(query,0).get(0);
assertThat(row.grossWeightKg()).isNull();assertThat(row.volumeCbm()).isNull();
}
/** 数据库限页不能截断同托来源组;总数、翻页及服务端搜索必须一致。 */
@Test void candidatePagesRetainCompleteCrossPageSelectionGroup() throws Exception {
var f=new Merged(false);
var all=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,null,null,1,1,"ALL"));
assertThat(all.total()).isEqualTo(2);assertThat(all.rows()).hasSize(1);
var first=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null,1,1,"ALL"));
var second=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null,2,1,"ALL"));
assertThat(first.total()).isEqualTo(2);assertThat(second.total()).isEqualTo(2);
assertThat(first.rows()).hasSize(1);assertThat(second.rows()).hasSize(1);
assertThat(first.rows().get(0).orderRefs()).isNotEqualTo(second.rows().get(0).orderRefs());
var group=f.rules.selectionGroups(first.rows()).values().iterator().next();
assertThat(group.complete()).as(group.blockReasons().toString()).isTrue();
assertThat(group.sources()).extracting(DispatchPlanningModels.SelectionGroupSource::sourceBusinessNo).containsExactlyInAnyOrder("RL-A","RL-B");
var filtered=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,"RL-B","ONT8","RELABEL",null,1,1,"ALL"));
assertThat(filtered.total()).isOne();assertThat(filtered.rows()).extracting(r->r.orderRefs()).containsExactly("RL-B");
var empty=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("99",null,null,null,"RELABEL",null,1,1,"ALL"));
assertThat(empty.total()).isZero();assertThat(empty.rows()).isEmpty();
}
/** 同客户共享position与跨客户共享physical都只占一托,来源及SHIP严格独立。 */
@ParameterizedTest @ValueSource(booleans={false,true})
void mergedApplicationsUseOnePhysicalPalletAndKeepExactSources(boolean crossCustomer) throws Exception {
@@ -190,17 +229,23 @@ class DispatchMergedRelabelPersistenceTest {
for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard",
"ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet",
"ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet",
"ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate")) {
"ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate",
"ll_return_inventory_carton","ll_return_receipt","ll_return_stock_receipt","ll_exception_order","ll_delivery_order")) {
f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start);
if(start<0 || end<0)throw new IllegalStateException("正式初始化表缺失:"+table);
// H2的JSON、collation及约束命名空间与MySQL不同;保留实际列和等价四来源排他约束。
f.db.execute(schema.substring(start,end+1).replaceAll("(?i)\\bJSON\\b","CLOB").replaceAll("(?i)\\s+COLLATE\\s+\\w+","")
.replace("_utf8mb4'","'")
.replace("_utf8mb4", "")
.replaceAll("(?i)\\b(UNIQUE KEY|KEY|CONSTRAINT)\\s+(\\w+)","$1 "+table+"_$2")
.replaceAll("\\((receipt_id|return_receipt_id|return_carton_id|dispatch_item_source_id) IS NOT NULL\\)","(CASE WHEN $1 IS NOT NULL THEN 1 ELSE 0 END)"));
}
var provider=new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(f.session.getMapper(RelabelDispatchSourceMapper.class)),f.json);
var origins=new DispatchItemSourceRepository(f.session.getMapper(DispatchItemSourceMapper.class));var snapshots=new DispatchItemSourceService(origins,provider,f.json);
f.session.getConfiguration().addMapper(DispatchCandidatePageMapper.class);
// H2不实现MySQL的字符集转换;这里只验证分页/完整组,collation由真实MySQL只读测试覆盖。
configuration.setDefaultScriptingLanguage(H2CandidateLanguageDriver.class);
configuration.addMapper(DispatchCandidatePageMapper.class);
configuration.setDefaultScriptingLanguage(org.apache.ibatis.scripting.xmltags.XMLLanguageDriver.class);
var sourceRepository=new DispatchSourceRepository(f.session.getMapper(DispatchCandidateMapper.class),f.session.getMapper(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
ReflectionTestUtils.setField(sourceRepository,"pages",f.session.getMapper(DispatchCandidatePageMapper.class));
ReflectionTestUtils.setField(rules,"wholeSources",sourceRepository);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json));
@@ -247,4 +292,11 @@ class DispatchMergedRelabelPersistenceTest {
/** 历史由真实提供者解析,固定发生次序不依赖当前系统时钟。 */
void history(long id,long app,String customer,String action,Object detail,int day) throws Exception {insert("ll_relabel_pda_history","id",id,"task_id",1000+app,"application_id",app,"customer_id",customer,"action",action,"to_status","WAIT_REVIEW","operator_key","worker","operator_name","测试","detail_json",f.json.writeValueAsString(detail),"created_at",LocalDateTime.of(2026,1,day,0,0));}
}
public static class H2CandidateLanguageDriver extends org.apache.ibatis.scripting.xmltags.XMLLanguageDriver {
@Override public org.apache.ibatis.mapping.SqlSource createSqlSource(org.apache.ibatis.session.Configuration configuration,String script,Class<?> parameterType) {
return super.createSqlSource(configuration,script.replaceAll(
"CONVERT\\((s\\.\\w+|NULL) USING utf8mb4\\) COLLATE utf8mb4_unicode_ci","$1"),parameterType);
}
}
}
@@ -111,7 +111,7 @@ class DispatchRepairWorkflowTest {
db.execute("CREATE TABLE IF NOT EXISTS ll_physical_pallet_guard(warehouse_pallet_id BIGINT PRIMARY KEY,created_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3))");
for(var column:List.of("order_id BIGINT","warehouse_pallet_id BIGINT","inventory_position_id BIGINT","received_packages BIGINT"))db.execute("ALTER TABLE ll_inbound_stock_receipt ADD COLUMN IF NOT EXISTS "+column);
candidates=spy(session.getMapper(DispatchCandidateMapper.class));doReturn(List.of()).when(candidates).nativeReturnRelabelAvailable(any(),any(),any());dependencies.put(DispatchCandidateMapper.class,candidates);
dependencies.put(ObjectMapper.class,json);dependencies.put(LastlegOrderNoGenerator.class,new LastlegOrderNoGenerator());
dependencies.put(ObjectMapper.class,json);dependencies.put(LastlegOrderNoGenerator.class,com.baiyue.lastleg.service.TestDocumentNumbers.orders());
var sourceRepository=new com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository(candidates,session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class));
dependencies.put(com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository.class,sourceRepository);dependencies.put(DispatchCommandHasher.class,new DispatchCommandHasher(json));
var relabelSources=new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(mapper(RelabelDispatchSourceMapper.class)),json);
@@ -58,7 +58,7 @@ class ReturnCartonReturnPersistenceTest {
var pallets=mock(PalletPhysicalGateway.class);var warehouse=mock(WarehouseResourceGateway.class);
when(pallets.resolve("P-NEW")).thenReturn(new PalletPhysicalGateway.PalletReference(501,"P-NEW",92,301L,"OCCUPIED","ONT8",1));
when(warehouse.resolveLocation(92,"LOC-301")).thenReturn(new WarehouseResourceGateway.WarehouseLocationReference(301,"LOC-301","RACK",BigDecimal.TEN,"ACTIVE"));
service=new ReturnCartonReturnService(repo,pallets,warehouse,new ObjectMapper(),new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
service=new ReturnCartonReturnService(repo,pallets,warehouse,new ObjectMapper(),com.baiyue.lastleg.service.TestDocumentNumbers.orders());
db.update("INSERT INTO ll_exception_order(id,customer_id,exception_no,delivery_order_id,last_mile_order_id,outbound_no,status,return_direction,overseas_warehouse_id) VALUES(1,'C','EX-1',3,100,'OUT-1','PROCESSING','OVERSEAS_WAREHOUSE','92')");
db.update("INSERT INTO ll_delivery_order(id,customer_id,outbound_order_id) VALUES(3,'C',2)");
db.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,status) VALUES(2,'C','OUT-1',10,'OUTBOUNDED')");
@@ -183,7 +183,7 @@ class ReturnCartonReturnPersistenceTest {
org.springframework.test.util.ReflectionTestUtils.setField(business,"objectMapper",new ObjectMapper().findAndRegisterModules());
org.springframework.test.util.ReflectionTestUtils.setField(business,"evidenceFiles",mock(com.baiyue.lastleg.exceptionorder.gateway.ExceptionEvidenceFileGateway.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"nativeReturns",service);
org.springframework.test.util.ReflectionTestUtils.setField(business,"orderNoGenerator",new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
org.springframework.test.util.ReflectionTestUtils.setField(business,"orderNoGenerator",com.baiyue.lastleg.service.TestDocumentNumbers.orders());
org.springframework.test.util.ReflectionTestUtils.setField(business,"inboundOrders",map(com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"outboundOrders",map(OutboundOrderMapper.class));
org.springframework.test.util.ReflectionTestUtils.setField(business,"outboundPalletItems",map(OutboundPalletItemMapper.class));
@@ -22,7 +22,7 @@ class FleetPoolOrderBridgeTest {
LocalLooseboardHistoryMapper looseHistory=mock(LocalLooseboardHistoryMapper.class);
UsTruckOrderMapper trucks=mock(UsTruckOrderMapper.class);
UsTruckOrderStatusHistoryMapper truckHistory=mock(UsTruckOrderStatusHistoryMapper.class);
FleetPoolOrderNumberGenerator numbers=mock(FleetPoolOrderNumberGenerator.class);
com.baiyue.lastleg.service.LastlegDocumentNumberGenerator numbers=mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class);
FleetPoolDeliveryEvidence evidence=mock(FleetPoolDeliveryEvidence.class);
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker.class);
FleetLooseboardDemandRepository demands=mock(FleetLooseboardDemandRepository.class);
@@ -32,7 +32,7 @@ class FleetPoolOrderBridgeTest {
service=new FleetPoolOrderBridge(new FleetPoolOrderRepository(links,loose,looseHistory,trucks,truckHistory),numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources);
org.springframework.test.util.ReflectionTestUtils.setField(service,"demands",demands);
when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001");
when(numbers.next(anyString())).thenReturn("LOCAL-001");
when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row);
when(links.lockIncoming("9","WT-7")).thenAnswer(a->row);
when(links.lockDispatch("9","DISPATCH-7")).thenAnswer(a->row==null?List.of():List.of(row));
@@ -190,7 +190,10 @@ class FleetPoolOrderBridgeTest {
assertThat(method.getAnnotation(Transactional.class).propagation()).isEqualTo(Propagation.MANDATORY);
assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields()).map(f->f.getType().getSimpleName()))
.noneMatch(name->name.contains("FleetTrip")||name.contains("FleetBooking")||name.contains("Fee")||name.contains("Inventory"));
assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields()).map(f->f.getType().getPackageName()))
assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields())
// 只允许共享的编号基础能力;业务编排服务仍禁止反向依赖。
.filter(f -> f.getType() != com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class)
.map(f->f.getType().getPackageName()))
.noneMatch(name->name.startsWith("com.baiyue.lastleg.delivery")||name.equals("com.baiyue.lastleg.fleet.trip")
||name.equals("com.baiyue.lastleg.service"));
}
@@ -1,21 +0,0 @@
package com.baiyue.lastleg.fleet.business;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import static org.assertj.core.api.Assertions.*;
class FleetPoolOrderNumberGeneratorTest {
private final FleetPoolOrderNumberGenerator generator=new FleetPoolOrderNumberGenerator();
@Test void existingLooseboardAndTruckFormatLocationAndSuffixArePreserved(){
String day=LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
assertThat(generator.next("LOOSEBOARD","FBA:ONT8",1234567)).isEqualTo("LB-ONT-"+day+"-234567");
assertThat(generator.next("TRUCK","nyc",1000001)).isEqualTo("TR-NYC-"+day+"-000001");
}
@Test void existingPrivateDestinationAndShortCodeRulesArePreserved(){
String day=LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
assertThat(generator.next("TRUCK","private address",9)).isEqualTo("TR-WH-"+day+"-000009");
assertThat(generator.next("LOOSEBOARD","ab12",9)).isEqualTo("LB-AB12-"+day+"-000009");
assertThat(generator.next("TRUCK",null,-1)).isEqualTo("TR-WH-"+day+"-999999");
}
}
@@ -30,6 +30,10 @@ class VehicleCareAuthorizationTest {
}
private void authenticate(String code) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("user", "unused", List.of(new SimpleGrantedAuthority(code)))); }
@Configuration @EnableMethodSecurity static class Config {
/** 为控制器权限测试中的服务替身补齐编号依赖。 */
@Bean com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers() {
return com.baiyue.lastleg.service.TestDocumentNumbers.documents();
}
@Bean VehicleCareService service() { return mock(VehicleCareService.class); }
@Bean PlatformVehicleCareController controller(VehicleCareService service) { return new PlatformVehicleCareController(service); }
}
@@ -51,6 +51,7 @@ class VehicleCarePersistenceTest {
when(files.upload(anyLong(), anyString(), any())).thenReturn(new VehicleCareModels.File("9001", "photo.png", "image/png", 8));
var target = new VehicleCareService(session.getMapper(VehicleCareMapper.class), session.getMapper(VehicleCareHistoryMapper.class), vehicles, files,
new ObjectMapper().registerModule(new JavaTimeModule()), mock(VehicleCareFinanceService.class));
org.springframework.test.util.ReflectionTestUtils.setField(target, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource()));
service = (VehicleCareService) proxy.getProxy();
@@ -39,7 +39,7 @@ class OwnedBusinessScopePersistenceTest {
baseline.setup();var reader=mock(DispatchDetailReader.class);
when(reader.detail(any(),anyString())).thenAnswer(call->{
var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class);
return new DispatchViews.Detail(s.getDispatchNo(),DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(),
return new DispatchViews.Detail(s.getDispatchNo(),"DELIVERY",DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(),
DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of());
});
queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader));
@@ -32,6 +32,7 @@ class FleetCapacityOfferServiceTest {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST))));
service=new FleetCapacityOfferService(offers,mock(FleetCapacityOfferCommandMapper.class),trips,mock(FleetBookingMapper.class),mock(FleetBookingService.class),reservations,
execution,mock(FleetCustomerDirectory.class),mock(FleetPoolOrderBridge.class),new ObjectMapper().registerModule(new JavaTimeModule()),Clock.fixed(now,ZoneOffset.UTC));
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
}
@AfterEach void clear(){SecurityContextHolder.clearContext();}
@Test void ordinaryPageOnlyCalculatesRequestedDatabasePageAndPreservesAccurateTotal(){
@@ -112,6 +112,10 @@ class FleetTripAuthorizationTest {
}
@Configuration @EnableMethodSecurity
static class Config {
/** 为控制器权限测试中的服务替身补齐编号依赖。 */
@Bean com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers() {
return com.baiyue.lastleg.service.TestDocumentNumbers.documents();
}
/** 原单需求依赖使用替身,权限测试不访问数据库。 */
@Bean com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService looseboardDemands() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService.class); }
/** 桥接器的原单查询使用仓储替身。 */
@@ -164,6 +164,46 @@ class HttpWarehouseResourceGatewayTest {
server.verify();
}
/** 实际SHIP后报废的原托扫码仍拒绝;保留提供者业务码/原因,不伪装为服务故障或按ID重试。 */
@Test
void inspectPreservesScrappedPalletBusinessConflictWithoutFallback() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/D1747-1-001"))
.andRespond(withSuccess("""
{"code":"A0409","message":"托盘已报废","data":null}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.inspectPallet("D1747-1-001"))
.isInstanceOfSatisfying(ResponseStatusException.class, error -> {
assertThat(error.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
assertThat(error.getReason()).isEqualTo("托盘已报废");
});
server.verify();
}
/** 按ID读取使用同一响应边界,错误码即使带data也不能被当作合法实托。 */
@Test
void idLookupRejectsBusinessConflictEvenWithPayload() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/id/31"))
.andRespond(withSuccess("""
{"code":"A0409","message":"托盘状态冲突","data":{"palletId":31,"palletCode":"PAL-1",
"warehouseId":88,"locationId":40,"status":"IDLE","version":1}}
""", MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> gateway.resolvePallet(31L))
.isInstanceOfSatisfying(ResponseStatusException.class, error ->
assertThat(error.getStatusCode()).isEqualTo(HttpStatus.CONFLICT));
server.verify();
}
/** 同一个状态冲突无论通过HTTP状态还是业务信封传输,都不能降为400或503。 */
@Test
void inspectPreservesHttpConflict() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-CONFLICT"))
.andRespond(withStatus(HttpStatus.CONFLICT));
assertThatThrownBy(() -> gateway.inspectPallet("PAL-CONFLICT"))
.isInstanceOfSatisfying(ResponseStatusException.class, error ->
assertThat(error.getStatusCode()).isEqualTo(HttpStatus.CONFLICT));
server.verify();
}
@Test
void resolveReservedFallsBackToConfirmedPhysicalBindingForEarlierPlanDisplayCode() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/IP-ACY2-20260909-166786-P001"))
@@ -359,7 +359,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
new ContainerInboundPlanPreviewResponse.Group("ONE_CLICK_DROPSHIP", "31", "US-LAX-01", "Los Angeles",
1, 1, 1, 1, 0, 0, 1, 0, List.of(overseas))), null);
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class)))
when(numbers.next(eq("INBOUND_PLAN")))
.thenReturn("IP-FBA", "IP-OVERSEAS");
when(plans.sourcePalletNos("100", 11L, "ONE_CLICK_DROPSHIP")).thenReturn(List.of("MSCU6639870-ABE3-001"));
when(plans.sourcePalletNos("200", 12L, "ONE_CLICK_DROPSHIP")).thenReturn(List.of("MSCU6639870-US-LAX-01-001"));
@@ -392,7 +392,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
List.of(new ContainerInboundPlanPreviewResponse.Group("FIRST_LEG", "31", "US-LAX-01", "Los Angeles",
1, 1, 2, 0, 2, 0, 2, 0, List.of(overseas))), null);
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-OVERSEAS");
when(numbers.next(eq("INBOUND_PLAN"))).thenReturn("IP-OVERSEAS");
when(plans.sourcePalletNos("200", 12L, "FIRST_LEG")).thenReturn(List.of());
ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand",
@@ -424,7 +424,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2",
1, 1, 3, 2, 1, 0, 3, 0, List.of(order))), null);
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-ACY2");
when(numbers.next(eq("INBOUND_PLAN"))).thenReturn("IP-ACY2");
when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of(
"D0004-101-001", "D0004-101-002"));
@@ -475,7 +475,7 @@ class ContainerInboundPlanConversionCoordinatorTest {
assertEquals(21L, preparation.items().get(0).planId());
assertEquals("IP-MANUAL", preparation.items().get(0).planNo());
verify(numbers, never()).next(any(String.class), any(String.class), any(Long.class));
verify(numbers, never()).next(any(String.class));
}
private ContainerInboundPlanPreviewResponse.Order previewOrder(String customerId, String orderId,
@@ -262,6 +262,7 @@ class FbaAppointmentPersistenceTest {
@Bean com.baiyue.lastleg.mapper.LastlegOrderMapper orders(){var mock=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var row=new com.baiyue.lastleg.entity.LastlegOrder();row.setId(100L);row.setCustomerId("C");row.setInboundCode("WT-REAL");when(mock.selectById(100L)).thenReturn(row);return mock;}
@Bean AppointmentExternalSourceGateway bookings(){var mock=mock(AppointmentExternalSourceGateway.class);when(mock.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("C","B-1","TR-1","RECEIVED","100","FBA:ONT8",100L,10,2L)));return mock;}
@Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new InboundAppointmentSourceLinker(orders,items,bookings);}
@Bean com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers() { return com.baiyue.lastleg.service.TestDocumentNumbers.documents(); }
@Bean InboundFbaAppointmentService service(){return new InboundFbaAppointmentService();}
@Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper workMapper(SqlSessionTemplate session){return session.getMapper(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.class);}
/** 使用生产持久层,继续验证需求失效与预约写入的同事务行为。 */
@@ -33,6 +33,7 @@ class InboundFbaAppointmentServiceTest {
headers=mock(InboundFbaAppointmentMapper.class);items=mock(InboundFbaAppointmentItemMapper.class);query=mock(InboundFbaAppointmentQueryMapper.class);
commands=mock(FbaAppointmentCommandMapper.class);sources=mock(AppointmentSourceService.class);flags=mock(AppointmentBatchPreviewMapper.class);
service=new InboundFbaAppointmentService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
ReflectionTestUtils.setField(service,"receiving",mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class));
ReflectionTestUtils.setField(service,"receivingWork",mock(com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.class));
ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges.class));
@@ -95,7 +96,7 @@ class InboundFbaAppointmentServiceTest {
}
@Test void savesCrossCustomerArrangementBeforeAnyInboundPlanOrExternalNumberExists() {
var result=create("create");assertTrue(result.appointmentNo().startsWith("AP-"));assertNull(result.confirmationNo());assertNull(result.appointmentAt());
var result=create("create");assertTrue(result.appointmentNo().matches("YY[0-9]{12}"));assertNull(result.confirmationNo());assertNull(result.appointmentAt());
assertEquals(LocalDate.of(2026,10,15),result.plannedDate());assertEquals(2,result.selectedCount());assertEquals("PENDING",result.appointmentStatus());
assertEquals(List.of("A","B"),result.items().stream().map(FbaAppointmentResponse.Item::customerId).toList());verify(query,never()).selectedLines(anyList());
}
@@ -75,7 +75,7 @@ class InboundPlanServiceTest {
ReflectionTestUtils.setField(service, "commandMapper", commandMapper);
ReflectionTestUtils.setField(service, "sourcePalletMapper", sourcePalletMapper);
ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build());
ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator());
ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders());
org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(allocationMapper.selectList(any())).thenReturn(List.of());
@@ -47,7 +47,7 @@ class LocalLooseboardServiceTest {
@Mock LocalLooseboardOperationMapper operations;
@Mock LastlegOrderMapper lastlegOrders;
@Mock LastlegOrderStatusHistoryMapper lastlegHistories;
@Spy LastlegOrderNoGenerator lastlegOrderNoGenerator = new LastlegOrderNoGenerator();
@Spy LastlegOrderNoGenerator lastlegOrderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders();
private LocalLooseboardService service;
private final Map<String, LocalLooseboardOperation> operationRows = new HashMap<>();
@@ -67,8 +67,7 @@ class LocalLooseboardServiceTest {
// 旧独立履约用例保留真实关联尾程,新增需求不创建该来源。
var legacyTail = new LastlegOrder(); legacyTail.setId(99L); legacyTail.setCustomerId("100");
org.mockito.Mockito.lenient().when(lastlegOrders.selectById(99L)).thenReturn(legacyTail);
org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next(anyString(), any(), anyLong()))
.thenReturn("LB-WH-20260901-000001");
org.mockito.Mockito.lenient().doReturn("LB202610200001").when(lastlegOrderNoGenerator).next(anyString());
org.mockito.Mockito.lenient().when(operations.findKey(anyString(), any(), anyString()))
.thenAnswer(call -> operationRows.get(call.getArgument(2)));
org.mockito.Mockito.lenient().when(operations.insert(any(LocalLooseboardOperation.class))).thenAnswer(call -> {
@@ -27,6 +27,10 @@ class ProcurementLifecycleAuthorizationTest {
private final ProcurementLifecycleService service=context.getBean(ProcurementLifecycleService.class);
@Configuration @EnableMethodSecurity(proxyTargetClass=true)
static class Config {
/** 为控制器权限测试中的服务替身补齐编号依赖。 */
@Bean com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers() {
return com.baiyue.lastleg.service.TestDocumentNumbers.documents();
}
@Bean ProcurementLifecycleService service(){return mock(ProcurementLifecycleService.class);}
@Bean ProcurementLifecycleController controller(ProcurementLifecycleService service){return new ProcurementLifecycleController(service);}
@Bean ProcurementPhysicalService physicalService(){return mock(ProcurementPhysicalService.class);}
@@ -43,7 +43,7 @@ class OverseasProcurementServiceTest {
@Mock OverseasProcurementOrderMapper orders;
@Mock OverseasProcurementStatusHistoryMapper histories;
@Mock ProcurementWarehouseDirectory warehouseDirectory;
@Mock ProcurementOrderNumberGenerator orderNoGenerator;
@Mock com.baiyue.lastleg.service.LastlegDocumentNumberGenerator orderNoGenerator;
@Mock OverseasProcurementFileMapper procurementFiles;
@Mock OverseasProcurementFeeMapper procurementFees;
@Mock ProcurementFileGateway fileGateway;
@@ -62,7 +62,7 @@ class OverseasProcurementServiceTest {
ReflectionTestUtils.setField(service, "procurementFiles", procurementFiles);
ReflectionTestUtils.setField(service, "procurementFees", procurementFees);
ReflectionTestUtils.setField(service, "fileGateway", fileGateway);
lenient().when(orderNoGenerator.next(anyString(), any())).thenReturn("ACME202609050001");
lenient().when(orderNoGenerator.next(anyString())).thenReturn("ACME202609050001");
lenient().when(procurementFiles.selectList(any())).thenReturn(List.of());
lenient().when(procurementFees.selectList(any())).thenReturn(List.of());
}
@@ -58,8 +58,11 @@ class ProcurementChargePersistenceTest {
when(commands.publish(anyString(),anyLong(),anyString(),anyString(),any(),anyString(),anyLong())).thenAnswer(i->"cmd-"+events.incrementAndGet());
when(commands.publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong())).thenAnswer(i->"cmd-"+events.incrementAndGet());
var json=new ObjectMapper().findAndRegisterModules();finance=new ProcurementFinanceService(fees,files,orders,converter,sources,commands,jdbc,json);
org.springframework.test.util.ReflectionTestUtils.setField(finance, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
org.springframework.test.util.ReflectionTestUtils.setField(finance, "feeNumbers", new com.baiyue.lastleg.procurement.repository.ProcurementFeeNumberRepository(fees));
lifecycle=new ProcurementLifecycleService(orders,files,mock(ProcurementWarehouseDirectory.class),finance,converter,jdbc,json);
charges=new ProcurementChargeService(lifecycle,finance,jdbc);adjustments=new ProcurementAdjustmentService(lifecycle,finance,jdbc);
org.springframework.test.util.ReflectionTestUtils.setField(adjustments, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT","货款","ZERO",BigDecimal.ZERO,"CNY",LocalDate.of(2026,9,1),null,"free")));
estimateId=tx.execute(s->finance.confirm("9","PO-1","7",new ProcurementFinanceService.Confirmation(ProcurementFeePurpose.INTERNATIONAL_ESTIMATE,"INTERNATIONAL_ESTIMATE","预估","ZERO",BigDecimal.ZERO,"CNY",LocalDate.of(2026,9,1),null,"zero estimate"))).getId();
}
@@ -51,6 +51,7 @@ class ProcurementControlPersistenceTest {
when(warehouses.required(92)).thenReturn(new ReceivingWarehouse(92L,"NEW","Correct receiving warehouse","US","ACTIVE"));
var json=new ObjectMapper().findAndRegisterModules();lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,json);
service=new ProcurementControlService(lifecycle,finance,warehouses,jdbc,json);
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
}
@Test void purchasedButUnreceivedGoodsCanCloseOnlyWithAllSupplierDispositionAndCustomerEvidence() {
assertThat(service.read("9","PO-1").remaining()).containsExactly(new ProcurementControlService.Remaining("PURCHASE_REMAINDER",null,"PO-1",10));
@@ -68,6 +68,8 @@ class ProcurementFinancePersistenceTest {
when(commands.publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong()))
.thenAnswer(i -> "command-"+sequence.incrementAndGet());
service=new ProcurementFinanceService(fees,files,orders,converter,sources,commands,jdbc,new ObjectMapper().findAndRegisterModules());
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
org.springframework.test.util.ReflectionTestUtils.setField(service, "feeNumbers", new com.baiyue.lastleg.procurement.repository.ProcurementFeeNumberRepository(fees));
}
@Test void actualFeePublishesOnlyAfterSuccessfulConsumption() {
@@ -235,7 +237,13 @@ class ProcurementFinancePersistenceTest {
handle(result(fees.selectById(fee.getId()),"SETTLE","SUCCEEDED","SETTLED","100"));
jdbc.update("INSERT INTO ll_event_outbox VALUES('OVERSEAS_PROCUREMENT_FEE','901','PUBLISHED')");
var refund=tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","refund-key"));
assertThat(tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","refund-key")).getId()).isEqualTo(refund.getId());
assertThat(refund.getFeeNo()).matches("PF[0-9]{12}");
var replay = tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","refund-key"));
assertThat(replay.getId()).isEqualTo(refund.getId());
assertThat(replay.getFeeNo()).isEqualTo(refund.getFeeNo());
assertThatThrownBy(() -> tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("21"),1,"correction","refund-key")))
.hasMessageContaining("退款幂等键对应不同");
verify(commands, times(1)).publishRefund(anyLong(),anyString(),anyString(),any(),anyString(),anyString(),anyLong());
assertThatThrownBy(() -> tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","another-key")))
.hasMessageContaining("未完成的调整或退款");
handle(new WalletCommandCompletedV1(refund.getWalletCommandEventId(),"REFUND",9,"PROCUREMENT_FEE",refund.getFeeNo(),
@@ -1,17 +0,0 @@
package com.baiyue.lastleg.procurement.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ProcurementOrderNumberGeneratorTest {
@Test void convertsChineseCompanyNameToInitials() {
assertThat(ProcurementOrderNumberGenerator.initials("杭州积乐贸易有限公司")).isEqualTo("HZJLMYYX");
}
@Test void keepsOneInitialPerLatinWordAndUsesFallback() {
assertThat(ProcurementOrderNumberGenerator.initials("Great Ocean Trading LLC")).isEqualTo("GOTL");
assertThat(ProcurementOrderNumberGenerator.initials(" ")).isEqualTo("KH");
}
@Test void limitsInitialsToEightCharacters() {
assertThat(ProcurementOrderNumberGenerator.initials("一二三四五六七八九十")).hasSize(8);
}
}
@@ -55,6 +55,7 @@ class ProcurementPhysicalPersistenceTest {
when(finance.current(11,ProcurementFeePurpose.SAMPLE_PAYMENT,"SAMPLE_PAYMENT")).thenReturn(payment);when(finance.sourceLinked(payment)).thenReturn(true);
var lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,new ObjectMapper().findAndRegisterModules());
service=new ProcurementPhysicalService(lifecycle,warehouses,finance,jdbc);
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
}
@Test void splitReceiptsAndCartonSourcesRemainConservedWithoutWms() {
@@ -24,7 +24,9 @@ class ProcurementQueryServiceTest {
var m=Pattern.compile("CREATE TABLE IF NOT EXISTS "+name+" \\(.*?\\) ENGINE=InnoDB[^;]*;",Pattern.DOTALL).matcher(schema);assertThat(m.find()).isTrue();jdbc.execute(m.group().replaceFirst("\\) ENGINE=InnoDB[^;]*;",");"));
}
var physical=new ProcurementPhysicalService(fixture.lifecycle,fixture.warehouses,fixture.finance,jdbc);
org.springframework.test.util.ReflectionTestUtils.setField(physical, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
transport=new ProcurementTransportService(fixture.lifecycle,physical,fixture.finance,fixture.warehouses,jdbc,json);
org.springframework.test.util.ReflectionTestUtils.setField(transport, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
base=mock(OverseasProcurementService.class);
when(base.detail("9","PO-1")).thenReturn(new OverseasProcurementResponses.Detail(null,null,null,null,null,null,"internal remark","operator","reviewer","internal review",null,List.of(),List.of(new OverseasProcurementResponses.FileInfo(1L,"EXCEPTION",1001L,"private.pdf","application/pdf",10,null)),List.of()));
query=new ProcurementQueryService(fixture.lifecycle,physical,transport,fixture.finance,mock(ProcurementAdjustmentService.class),fixture.service,base,jdbc);
@@ -68,7 +68,9 @@ class ProcurementTransportPersistenceTest {
var json=new ObjectMapper().findAndRegisterModules();
var lifecycle=new ProcurementLifecycleService(orders,files,warehouses,finance,mock(OverseasFeeCurrencyConverter.class),jdbc,json);
var physical=new ProcurementPhysicalService(lifecycle,warehouses,finance,jdbc);
org.springframework.test.util.ReflectionTestUtils.setField(physical, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
service=new ProcurementTransportService(lifecycle,physical,finance,warehouses,jdbc,json);
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents());
}
@Test void entireBatchShipsOnceWithFrozenAddressLabelsAndSources() {
prepare();var request=shipRequest(Leg.OVERSEAS,"ship");
@@ -208,17 +208,17 @@ class RelabelApplicationServiceTest {
@Mock OverseasFeeSourceService feeSources;
@Mock ReturnOrderMapper returnOrders;
@Mock com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper pdaTasks;
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
@Spy LastlegOrderNoGenerator orderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders();
private RelabelApplicationService service;
private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers;
@BeforeEach
void setUp() {
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.CompactBusinessNumberService.class);
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class);
service = new RelabelApplicationService();
ReflectionTestUtils.setField(service, "compactNumbers", compactNumbers);
org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030");
org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030");
ReflectionTestUtils.setField(service, "bindingGuard", bindingGuard);
ReflectionTestUtils.setField(service, "returnPackingTraces", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.class));
ReflectionTestUtils.setField(service, "skuConversions", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.ReturnSkuConversionService.class));
@@ -108,7 +108,7 @@ class ReturnRelabelDeliveryTest {
when(customers.find("C1")).thenReturn(new FleetCustomerDirectory.Customer("C1", "C", "测试客户", null, null));
when(warehouses.required(100L, "OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("100", "WH", "洛杉矶测试仓", "OVERSEAS"));
ReflectionTestUtils.setField(service, "outboundTasks", mock(OutboundPdaTaskMapper.class));
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders());
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
var application = new RelabelApplication(); application.setId(1L); application.setCustomerId("C1");
application.setRelabelNo("REL1"); application.setOverseasWarehouseId("100"); application.setDeliveryMethod(deliveryMethod);
@@ -0,0 +1,76 @@
package com.baiyue.lastleg.repository;
import com.baiyue.lastleg.mapper.DocumentSequenceMapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.*;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */
class DocumentSequenceRepositoryTest {
private static class Fixture {
final JdbcTemplate jdbc;
final DataSourceTransactionManager transactions;
final DocumentSequenceMapper mapper;
final DocumentSequenceRepository repository;
/** 仅测试造表允许 JDBC;被测路径全部使用 Mapper。 */
Fixture() throws Exception {
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa","");
jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data);
jdbc.execute("CREATE TABLE ll_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)");
var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet();
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class);
var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource()));
repository=(DocumentSequenceRepository)proxy.getProxy();
}
}
/** 日期和前缀各自独立,重复分配持续递增。 */
@Test void dateAndPrefixHaveIndependentSequences() throws Exception {
var f=new Fixture();
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
assertThat(f.repository.next("BB:20261020")).isEqualTo(1);
assertThat(f.repository.next("AA:20261021")).isEqualTo(1);
}
/** 两个线程使用真实事务连接并发争用,不能产生重号。 */
@Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception {
var f=new Fixture();var pool=Executors.newFixedThreadPool(2);
try {
var work=new ArrayList<Callable<Long>>();
for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020"));
var values=new TreeSet<Long>();
for(var future:pool.invokeAll(work))values.add(future.get());
assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L);
} finally {pool.shutdownNow();}
}
/** 外层建单失败后不能把已经分配的单号再交给其他单据。 */
@Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception {
var f=new Fixture();
assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
throw new IllegalStateException("业务回滚");
})).isInstanceOf(IllegalStateException.class);
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
}
/** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */
@Test void connectionResultIsNeverCached() throws Exception {
var f=new Fixture();
new TransactionTemplate(f.transactions).executeWithoutResult(status->{
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class);
assertThat(f.mapper.allocatedValue()).isEqualTo(7L);
f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class);
assertThat(f.mapper.allocatedValue()).isEqualTo(37L);
});
}
}
@@ -281,7 +281,7 @@ class ReturnInventoryServiceTest {
receipt.setPalletCode("PAL-RETURN-1");
receipt.setUnitType("PALLET");
receipt.setUnitCode("PAL-RETURN-1");
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-1");
service.createReceiptCarton(order, receipt, null,
List.of(new ReturnInventoryService.ReceiptLine("SKU-A", "商品A", 2)));
@@ -339,7 +339,7 @@ class ReturnInventoryServiceTest {
when(cartons.listReceipt(21L)).thenReturn(List.of(source));
when(cartons.lockMark("RC-SOURCE")).thenReturn(source);
when(cartonItems.lockCarton(31L)).thenReturn(List.of(skuA, skuB));
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-MERGED");
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
service.merge(new PdaWarehouseIdentity("operator-1", "操作员", 42L),
@@ -382,13 +382,16 @@ class ReturnInventoryServiceTest {
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 6L)));
when(packingTrace.ownership("10001", "RC-SOURCE", "SKU-A")).thenReturn(List.of(
new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership("RT-1", 3L)));
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET");
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
service.merge(new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(quantity));
var target = ArgumentCaptor.forClass(ReturnInventoryCarton.class);
verify(cartons).insert(target.capture());
assertThat(target.getValue().getCartonMark()).isEqualTo(LastlegOrderNoGenerator.compactCartonMark(target.getValue().getId()));
verify(packingTrace).insert(anyLong(), eq("10001"), anyLong(), eq("RC-SOURCE"),
eq("RC-TARGET"), eq("RT-1"), eq("SKU-A"), eq(quantity), any());
eq(target.getValue().getCartonMark()), eq("RT-1"), eq("SKU-A"), eq(quantity), any());
verify(packingTrace, org.mockito.Mockito.times(1)).insert(anyLong(), anyString(), anyLong(),
anyString(), anyString(), anyString(), anyString(), anyLong(), any());
assertThat(source.getTotalQuantity()).isEqualTo(6L - quantity);
@@ -432,7 +435,7 @@ class ReturnInventoryServiceTest {
void fullyConsumedParcelSourceIsMergedAndCreatesOnePhysicalCarton() {
ReturnInventoryCarton locked = prepareConcurrentSource();
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET");
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
service.merge(new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(2));
assertThat(locked.getTotalQuantity()).isZero();
@@ -457,14 +460,14 @@ class ReturnInventoryServiceTest {
var source = prepareConcurrentSource();
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
when(cartons.completedSkuQuantity(31L, "SKU-A")).thenReturn(2L);
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET");
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
var relabel = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.mapper.RelabelItemMapper.class);
ReflectionTestUtils.setField(service, "relabelItems", relabel);
service.merge(new PdaWarehouseIdentity("operator-2", "name", 42L), mergeRequest(2));
var target = ArgumentCaptor.forClass(ReturnInventoryCarton.class);
verify(cartons).insert(target.capture());
verify(relabel).moveCompletedReturnCarton("10001", 31L, target.getValue().getId(), "RC-TARGET");
verify(relabel).moveCompletedReturnCarton("10001", 31L, target.getValue().getId(), target.getValue().getCartonMark());
assertThat(source.getTotalQuantity()).isZero();
}
@@ -579,7 +582,7 @@ class ReturnInventoryServiceTest {
when(cartonItems.lockSku(31L, "SKU-A")).thenReturn(line);
when(balances.reserveAvailable("10001", "42", "SKU-A", 2)).thenReturn(1);
when(balances.releaseAvailable("10001", "42", "SKU-A", 2)).thenReturn(1);
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("PLAN-1");
when(numbers.next(anyString())).thenReturn("PLAN-1");
var request = new ReturnInventoryRequests.PackingPlanCreate(List.of("RT-1"), "SKU",
List.of(new ReturnInventoryRequests.PlanBox(List.of(new ReturnInventoryRequests.PlanItem("SKU-A", 2)), null, null, null, null)), null, "plan-key");
var created = service.createPackingPlan("platform", request);
@@ -131,13 +131,13 @@ class ReturnReceiptServiceTest {
ReturnReceiptService service;
PdaWarehouseIdentity identity;
private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers;
@BeforeEach
void setUp() {
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.CompactBusinessNumberService.class);
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class);
service = new ReturnReceiptService();
ReflectionTestUtils.setField(service, "compactNumbers", compactNumbers);
org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030");
org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "202610200001");
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "orderItems", orderItems);
ReflectionTestUtils.setField(service, "receipts", receipts);
@@ -257,7 +257,7 @@ class ReturnReceiptServiceTest {
when(orderItems.lockOrder(1L)).thenReturn(List.of(item(11L, "SKU-A", 50, 50), item(12L, "SKU-B", 40, 40)));
when(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn(
new FbaWarehouseValidator.VerifiedFbaWarehouse(200L, "XLX6", "FBA XLX6", "US", "CA"));
when(compactNumbers.next(eq("TT"), eq("customer-1"))).thenReturn("RL-MULTI");
when(compactNumbers.next(eq("THHB"))).thenReturn("RL-MULTI");
when(lastlegOrders.selectById(99L)).thenReturn(tail);
when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(first, second));
when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod();
@@ -310,7 +310,7 @@ class ReturnReceiptServiceTest {
when(orderItems.lockOrder(1L)).thenReturn(List.of(item(11L, "SKU-A", 100, 100)));
when(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn(
new FbaWarehouseValidator.VerifiedFbaWarehouse(200L, "XLX6", "FBA XLX6", "US", "CA"));
when(compactNumbers.next(eq("TT"), eq("customer-1"))).thenReturn("RL-SECOND");
when(compactNumbers.next(eq("THHB"))).thenReturn("RL-SECOND");
when(lastlegOrders.selectById(99L)).thenReturn(tail);
when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(carton));
when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod();
@@ -365,7 +365,7 @@ class ReturnReceiptServiceTest {
void createForecastPersistsCompleteTailOrderSourceAndReturnsItsBusinessNumber() {
AtomicReference<ReturnOrder> saved = new AtomicReference<>();
when(orders.findKey("customer-1", "create-key")).thenReturn(null);
when(compactNumbers.next(eq("TT"), eq("customer-1"))).thenReturn("RT-1");
when(compactNumbers.next(eq("TH"))).thenReturn("RT-1");
when(orders.insert(any(ReturnOrder.class))).thenAnswer(invocation -> { saved.set(invocation.getArgument(0)); return 1; });
when(orders.findNo("customer-1", "RT-1")).thenAnswer(invocation -> saved.get());
when(orderItems.listOrder(anyLong())).thenReturn(List.of());
@@ -389,7 +389,7 @@ class ReturnReceiptServiceTest {
assertEquals("商品A", cargo.getValue().getProductNameZh());
assertEquals(2, cargo.getValue().getProductQuantity());
assertEquals(0, cargo.getValue().getPackageCount());
verify(numbers, never()).next(eq("RETURN_RELABEL"), anyString(), anyLong());
verify(numbers, never()).next(eq("RETURN_RELABEL"));
}
@Test
@@ -559,8 +559,8 @@ class ReturnReceiptServiceTest {
when(orders.lockNo("RT-1")).thenReturn(order);
when(orderItems.lockOrder(1L)).thenReturn(items);
when(orderItems.listOrder(1L)).thenReturn(items);
when(numbers.next(eq("RETURN_RECEIPT"), eq("10"), anyLong())).thenReturn("RR-1");
when(numbers.next(eq("RETURN_BATCH"), eq("10"), anyLong())).thenReturn("RB-1");
when(numbers.next(eq("RETURN_RECEIPT"))).thenReturn("RR-1");
when(numbers.next(eq("RETURN_BATCH"))).thenReturn("RB-1");
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setCartonMark(cartonMark);
when(inventory.createReceiptCarton(eq(order), any(ReturnReceipt.class), any(), any())).thenReturn(carton);
@@ -344,6 +344,60 @@ class DashboardSqlSemanticTest {
assertTrue(rendered.endsWith(finalSelect));rendered=rendered.substring(0,rendered.length()-finalSelect.length())
+"SELECT customer_code,sku_code,bucket_code FROM classified WHERE bucket_code IS NOT NULL";
}
String id="dashboard.literal."+(++sequence);config.addMappedStatement(new MappedStatement.Builder(config,id,new org.apache.ibatis.builder.StaticSqlSource(config,rendered),SqlCommandType.SELECT).resultMaps(original.getResultMaps()).build());return session.selectList(id);
// H2 会指数展开重复引用的 CTE;沿用周转测试的逐关系物化方式,
// 保留生产查询表达式和结果映射,不据此推定 MySQL 的执行计划。
var relations = new ArrayList<String>();
try {
String finalSql = materializeRelations(rendered, relations);
String id="dashboard.literal."+(++sequence);
config.addMappedStatement(new MappedStatement.Builder(config,id,
new org.apache.ibatis.builder.StaticSqlSource(config,finalSql),SqlCommandType.SELECT)
.resultMaps(original.getResultMaps()).build());
return session.selectList(id);
} catch (SQLException error) {
throw new AssertionError("本地统计 SQL 测试关系物化失败", error);
} finally {
Collections.reverse(relations);
for (String name : relations) {
try (var statement = session.getConnection().createStatement()) {
statement.execute("DROP TABLE " + name);
} catch (SQLException error) {
throw new AssertionError("本地统计 SQL 测试关系释放失败:" + name, error);
}
}
}
}
/** 只物化实际生产 CTE 的查询内容,临时表局限在当前隔离 H2 会话。 */
private String materializeRelations(String sql, List<String> relations) throws SQLException {
if (!sql.startsWith("WITH ")) return sql;
int cursor = 5;
while (true) {
int as = sql.indexOf(" AS (", cursor);
assertTrue(as >= cursor, "生产 CTE 应使用具名 AS 查询");
String name = sql.substring(cursor, as).trim();
assertTrue(name.matches("[A-Za-z_][A-Za-z0-9_]*"), "测试临时关系必须是固定 SQL 标识");
int start = as + 5, end = start, depth = 1;
boolean quoted = false;
while (depth > 0) {
char ch = sql.charAt(end++);
if (ch == '\'') {
if (quoted && end < sql.length() && sql.charAt(end) == '\'') { end++; continue; }
quoted = !quoted;
} else if (!quoted) {
if (ch == '(') depth++;
if (ch == ')') depth--;
}
}
try (var statement = session.getConnection().createStatement()) {
statement.execute("CREATE LOCAL TEMPORARY TABLE " + name + " AS " + sql.substring(start, end - 1));
}
relations.add(name);
cursor = end;
while (cursor < sql.length() && Character.isWhitespace(sql.charAt(cursor))) cursor++;
if (sql.charAt(cursor) != ',') return sql.substring(cursor);
cursor++;
while (cursor < sql.length() && Character.isWhitespace(sql.charAt(cursor))) cursor++;
}
}
}
@@ -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()); }
}
@@ -196,7 +196,7 @@ class WarehouseTransferServiceTest {
@Mock LastlegOrderDetailMapper orderDetails;
@Mock LastlegOrderStatusHistoryMapper orderHistories;
@Mock RelabelApplicationMapper relabelApplications;
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
@Spy LastlegOrderNoGenerator orderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders();
@Mock InventoryCommandService inventory;
@Mock InventoryCommandMapper inventoryCommands;
@Mock com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelService;
@@ -204,16 +204,16 @@ class WarehouseTransferServiceTest {
@Mock WarehouseTransferReceiptCoordinator receiptCoordinator;
@Mock com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory;
private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers;
@BeforeEach
void setUp() {
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.CompactBusinessNumberService.class);
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class);
service = new WarehouseTransferService();
var measurementRepository = new com.baiyue.lastleg.transfer.repository.TransferMeasurementRepository();
ReflectionTestUtils.setField(measurementRepository, "cartons", transferCartons);
ReflectionTestUtils.setField(service, "transferMeasurementRepository", measurementRepository);
ReflectionTestUtils.setField(service, "compactNumbers", compactNumbers);
org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030");
org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030");
ReflectionTestUtils.setField(service, "customerDirectory", customerDirectory);
lenient().when(customerDirectory.find("TENANT-A")).thenReturn(
new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer("TENANT-A", "C-A", "Customer A", null, null));
@@ -267,8 +267,7 @@ class WarehouseTransferServiceTest {
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
ReflectionTestUtils.setField(service, "relabelService", relabelService);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
lenient().when(orderNoGenerator.next(anyString(), any(), anyLong()))
.thenReturn("WT-0000000000000000000000001");
lenient().doReturn("ZY202610200001").when(orderNoGenerator).next(anyString());
lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of());
lenient().when(transferReceipts.selectList(any())).thenReturn(List.of());
lenient().when(transferCartons.listOrder(anyString(), anyLong())).thenReturn(List.of());
@@ -80,7 +80,7 @@ class UsTruckOrderServiceTest {
@Test
void createStoresBothContactsAndMinuteTimesWithoutInventingPallets() {
when(orderNoGenerator.next(anyString(), anyString(), anyLong())).thenReturn("TK1");
when(orderNoGenerator.next(anyString())).thenReturn("TK1");
when(histories.listOrder(anyString(), anyLong())).thenReturn(historyRows);
when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { order = call.getArgument(0); return 1; });
var request = create("PALLET_15", "new-truck");