This commit is contained in:
2026-10-20 23:35:38 +08:00
70 changed files with 1726 additions and 386 deletions
@@ -442,6 +442,23 @@ public class DispatchAggregatePersistenceTest {
assertThat(f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())).counts().packageCount()).isEqualTo(10);
}
/** 到仓取货、本土卡车不制造货物明细,但仍须按原客户出现在现有整车排车列表。 */
@Test void customerFilterFindsPickupAndLocalTruckByTheirRealSourceLinks() throws Exception {
var f=new Fixture();
var pickup=f.tx.execute(s->f.service.create(f.actor,f.create("transport-pickup-list",List.of())));
var truck=f.tx.execute(s->f.service.create(f.actor,f.create("transport-truck-list",List.of())));
String pickupTrip=f.db.queryForObject("SELECT trip_no FROM ll_fleet_trip WHERE id=(SELECT trip_id FROM ll_dispatch_order WHERE dispatch_no=?)",String.class,pickup.dispatchNo());
String truckTrip=f.db.queryForObject("SELECT trip_no FROM ll_fleet_trip WHERE id=(SELECT trip_id FROM ll_dispatch_order WHERE dispatch_no=?)",String.class,truck.dispatchNo());
f.db.update("UPDATE ll_dispatch_order SET task_type='PICKUP' WHERE dispatch_no=?",pickup.dispatchNo());
f.db.update("UPDATE ll_dispatch_order SET task_type='LOCAL_TRUCK' WHERE dispatch_no=?",truck.dispatchNo());
f.db.update("INSERT INTO ll_fleet_pickup_task(customer_id,trip_no) VALUES('PICKUP-CUSTOMER',?)",pickupTrip);
f.db.update("INSERT INTO ll_dispatch_truck_task(dispatch_no,customer_id) VALUES(?,'TRUCK-CUSTOMER')",truck.dispatchNo());
var pickupPage=f.tx.execute(s->f.service.page(f.actor,new DispatchViews.Query(1,20,null,null,null,"PICKUP-CUSTOMER",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,null,null)));
var truckPage=f.tx.execute(s->f.service.page(f.actor,new DispatchViews.Query(1,20,null,null,null,"TRUCK-CUSTOMER",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,null,null)));
assertThat(pickupPage.total()).isOne();assertThat(pickupPage.records().get(0).dispatchNo()).isEqualTo(pickup.dispatchNo());assertThat(pickupPage.records().get(0).taskType()).isEqualTo("PICKUP");
assertThat(truckPage.total()).isOne();assertThat(truckPage.records().get(0).dispatchNo()).isEqualTo(truck.dispatchNo());assertThat(truckPage.records().get(0).taskType()).isEqualTo("LOCAL_TRUCK");
}
/** 第N项已被另一车占用时,早先本车/来源/资源/账本全部回滚。 */
@Test void laterCargoConflictRollsBackTruckResourcesEarlierLocksAndItems() throws Exception {
var f=new Fixture(); var a=f.source(1,"101",501,"ONT8",3); var b=f.source(2,"202",502,"LAX9",5);
@@ -492,6 +509,8 @@ public class DispatchAggregatePersistenceTest {
db.execute("ALTER TABLE ll_fleet_trip_dispatch ADD UNIQUE(trip_id)");
db.execute("ALTER TABLE ll_dispatch_physical_pallet_lock ADD UNIQUE(active_warehouse_pallet_id)");
db.execute("ALTER TABLE ll_dispatch_pallet_lock ADD UNIQUE(customer_id,active_key)");
db.execute("CREATE TABLE ll_dispatch_truck_task(dispatch_no VARCHAR(64),customer_id VARCHAR(128))");
db.execute("CREATE TABLE ll_fleet_pickup_task(customer_id VARCHAR(128),trip_no VARCHAR(64))");
db.execute("CREATE TABLE ll_dispatch_command(id BIGINT PRIMARY KEY,dispatch_id BIGINT,dispatch_no VARCHAR(64),action VARCHAR(40),operator_id VARCHAR(128),idempotency_key VARCHAR(255) UNIQUE,record_version BIGINT,command_hash CHAR(64),result_json CLOB,created_at TIMESTAMP(3))");
db.execute("CREATE TABLE ll_dispatch_history(id BIGINT PRIMARY KEY,dispatch_id BIGINT,record_version BIGINT,action VARCHAR(40),operator_id VARCHAR(128),reason VARCHAR(1000),snapshot_json CLOB,occurred_at TIMESTAMP(3))");
db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(64),dispatch_item_id BIGINT,inventory_position_id BIGINT,packages BIGINT,product_quantity BIGINT)");
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.dispatch.transport;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.dispatch.service.DispatchNumberGenerator;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository;
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
import com.baiyue.lastleg.fleet.trip.TransportTripChanged;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static org.assertj.core.api.Assertions.*;
class TransportTaskProjectionTest {
/** 旧取消入口产生的运输事实也必须撤销原单安排和资源投影。 */
@Test void legacyCancellationSynchronizesDispatchAndUnlocksSource(){
var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);var trucks=mock(UsTruckOrderRepository.class);
var projection=new TransportTaskProjection(roots,links,trucks,mock(DispatchNumberGenerator.class),new ObjectMapper().findAndRegisterModules());
var root=new DispatchAggregate();root.setId(2L);root.setDispatchNo("PC1");root.setRecordVersion(3L);root.setTaskType("LOCAL_TRUCK");
when(links.byTrip(1L)).thenReturn(root);when(roots.lock("PC1")).thenReturn(root);when(roots.saveState(root,3L)).thenReturn(1);
when(links.truck("PC1")).thenReturn(new TransportTaskModels.TruckLink("PC1","TR1","C1","U1"));
var order=new UsTruckOrder();order.setId(9L);order.setStatus("DISPATCHED");order.setVersion(5L);when(trucks.lockNo("C1","U1")).thenReturn(order);
var trip=new FleetTripEntity();trip.setId(1L);trip.setTripPurpose("LOCAL_TRUCK");trip.setStatus("CANCELLED");trip.setRecordVersion(4L);trip.setUpdatedAt(LocalDateTime.now());
projection.changed(new TransportTripChanged(trip,"CANCEL","取消运输","OP"));
assertThat(root.getStatus()).isEqualTo("CANCELLED");assertThat(root.getRecordVersion()).isEqualTo(4L);assertThat(order.getStatus()).isEqualTo("PENDING_DISPATCH");assertThat(order.getVersion()).isEqualTo(6L);
verify(links).cancel("PC1");verify(trucks).insertHistory(argThat(row->"DISPATCHED".equals(row.getFromStatus())&&"PENDING_DISPATCH".equals(row.getToStatus())&&"C1".equals(row.getCustomerId())));
}
}
@@ -0,0 +1,63 @@
package com.baiyue.lastleg.dispatch.transport;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
import com.baiyue.lastleg.fleet.pickup.*;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.fleet.driver.DriverService;
import com.baiyue.lastleg.fleet.reporting.TripOperationsEvidenceGateway;
import com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import java.time.*;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
/** 验证原单承运执行权、凭证隔离和成功请求重放,不能仅靠页面按钮门禁。 */
class TransportTaskServiceTest {
DispatchAggregateRepository roots;TransportTaskRepository links;FleetTripRepository tripRepo;FleetTripService trips;
UsTruckOrderRepository trucks;TripOperationsEvidenceGateway files;TransportTaskService service;FleetTripEntity trip;UsTruckOrder order;
final Map<String,String> commands=new HashMap<>();
@BeforeEach void setup(){
roots=mock(DispatchAggregateRepository.class);links=mock(TransportTaskRepository.class);tripRepo=mock(FleetTripRepository.class);trips=mock(FleetTripService.class);
trucks=mock(UsTruckOrderRepository.class);files=mock(TripOperationsEvidenceGateway.class);
service=new TransportTaskService(roots,links,mock(TransportTaskProjection.class),tripRepo,trips,mock(FleetPickupService.class),mock(FleetPickupRepository.class),trucks,mock(FleetPoolOrderQueries.class),mock(DriverService.class),mock(FleetTransportVehicleGuard.class),files,new ObjectMapper().findAndRegisterModules());
var root=new DispatchAggregate();root.setDispatchNo("PC1");root.setTaskType("LOCAL_TRUCK");root.setTripId(1L);
when(roots.find("PC1")).thenReturn(root);when(links.byTrip(1L)).thenReturn(root);when(links.tripNo(1L)).thenReturn("TR1");
when(links.truck("PC1")).thenReturn(new TransportTaskModels.TruckLink("PC1","TR1","C1","U1"));when(links.evidence("PC1")).thenReturn(List.of());
trip=new FleetTripEntity();trip.setId(1L);trip.setTripNo("TR1");trip.setTripPurpose("LOCAL_TRUCK");trip.setStatus("IN_TRANSIT");trip.setRecordVersion(3L);
when(tripRepo.lockTrip("TR1")).thenReturn(trip);when(tripRepo.findTrip("TR1")).thenReturn(trip);
order=new UsTruckOrder();order.setStatus("IN_TRANSIT");when(trucks.lockNo("C1","U1")).thenReturn(order);
when(links.commandHash(eq("PC1"),anyString())).thenAnswer(call->commands.get(call.getArgument(1)));
doAnswer(call->{commands.put(call.getArgument(1),call.getArgument(2));return null;}).when(links).command(eq("PC1"),anyString(),anyString(),anyString());
doAnswer(call->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}).when(trips).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());
}
/** 无凭证或跨任务文件均不能结束运输,也不能写成功账本。 */
@Test void completionRequiresOwnedEvidence(){
assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("key-missing",List.of()))).hasMessageContaining("凭证");
when(files.metadata(1L,99L)).thenThrow(new IllegalArgumentException("跨任务凭证"));
assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("key-foreign",List.of(99L)))).hasMessageContaining("跨任务");
verify(trips,never()).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());verify(links,never()).command(anyString(),anyString(),anyString(),anyString());
}
/** 同请求在版本变化后仍恢复原成功结果,不重复推进原单。 */
@Test void replayUsesOriginalSuccessfulCommandBeforeCurrentVersion(){
var body=command("complete-key",List.of(77L));service.truckCommand("PC1","OP","complete",body);service.truckCommand("PC1","OP","complete",body);
assertThat(trip.getStatus()).isEqualTo("COMPLETED");verify(trips,times(1)).recordExecution(any(),eq(3L),eq("LOCAL_TRUCK_COMPLETE"),anyString(),eq("OP"));
assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("complete-key",List.of(88L)))).hasMessageContaining("不同内容");
}
/** 旧版本和未发车原单均不能利用完成接口跳过真实执行。 */
@Test void staleVersionAndWrongOriginalStatusCannotComplete(){
assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",new TransportTaskModels.Command(2L,"stale-key","结束运输",List.of(77L)))).hasMessageContaining("刷新");
order.setStatus("DISPATCHED");assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("wrong-state",List.of(77L)))).hasMessageContaining("先确认发车");verifyNoInteractions(files);
}
/** 下载只允许已完成命令持久化的文件引用,不能凭共享文件 ID 访问。 */
@Test void evidenceDownloadRequiresPersistedConfirmation(){
assertThatThrownBy(()->service.evidence("PC1",77L)).hasMessageContaining("没有该完成凭证");verifyNoInteractions(files);
}
/** 构造固定版本的测试执行命令。 */
private TransportTaskModels.Command command(String key,List<Long> files){return new TransportTaskModels.Command(3L,key,"结束运输",files);}
}
@@ -0,0 +1,50 @@
package com.baiyue.lastleg.fleet.business;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class FleetLooseboardDemandPersistenceTest {
/** 真实 SQL 验证同客户关联、唯一来源、取消恢复候选和跨客户隔离。 */
@Test void originalDemandLeavesPoolUntilDraftIsCancelled() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var jdbc=new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_local_looseboard_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),order_no VARCHAR(64),status VARCHAR(32),lastleg_order_id BIGINT,origin VARCHAR(64),destination VARCHAR(64),created_at TIMESTAMP)");
jdbc.execute("CREATE TABLE ll_fleet_external_booking(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),source_looseboard_order_id BIGINT,status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_fleet_pool_order_link(customer_id VARCHAR(128),business_type VARCHAR(32),business_order_id BIGINT)");
jdbc.update("INSERT INTO ll_local_looseboard_order VALUES(1,'C-1','LB-1','PENDING_ACCEPTANCE',NULL,'LA','Ontario',CURRENT_TIMESTAMP)");
jdbc.update("INSERT INTO ll_local_looseboard_order VALUES(2,'C-1','OLD','PENDING_ACCEPTANCE',99,'LA','Ontario',CURRENT_TIMESTAMP)");
jdbc.update("INSERT INTO ll_fleet_external_booking VALUES(10,'C-1',NULL,'DRAFT'),(11,'C-2',NULL,'DRAFT')");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
config.addMapper(FleetLooseboardDemandMapper.class);config.addMapper(FleetPoolOrderMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var session=new SqlSessionTemplate(factory.getObject());
var mapper=session.getMapper(FleetLooseboardDemandMapper.class);
var links=session.getMapper(FleetPoolOrderMapper.class);
assertThat(mapper.pending(Page.of(1,20),"Ontario").getRecords()).extracting("orderNo").containsExactly("LB-1");
assertThat(mapper.attach(10,"C-2",1)).isZero();
assertThat(mapper.attach(10,"C-1",1)).isEqualTo(1);
assertThat(mapper.attach(10,"C-1",2)).isZero();
assertThat(mapper.active("C-1",1)).isEqualTo(1);
assertThat(mapper.active("C-2",1)).isNull();
assertThat(links.linked("C-1","LOCAL_LOOSEBOARD",1)).isEqualTo(1);
assertThat(mapper.source(10,"C-2")).isNull();
assertThat(mapper.source(10,"C-1").getOrderNo()).isEqualTo("LB-1");
assertThat(mapper.pending(Page.of(1,20),null).getRecords()).isEmpty();
jdbc.update("UPDATE ll_fleet_external_booking SET status='CANCELLED' WHERE id=10");
assertThat(mapper.active("C-1",1)).isNull();
assertThat(links.linked("C-1","LOCAL_LOOSEBOARD",1)).isZero();
assertThat(mapper.pending(Page.of(1,20),null).getRecords()).extracting("orderNo").containsExactly("LB-1");
assertThat(mapper.sourceNumbers(List.of(10L,11L))).extracting(FleetPoolOrderLink::getBusinessOrderNo).containsExactly("LB-1");
jdbc.update("UPDATE ll_fleet_external_booking SET source_looseboard_order_id=1 WHERE id=11");
assertThat(mapper.source(11,"C-2")).isNull();
assertThat(mapper.sourceNumbers(List.of(11L))).isEmpty();
}
}
@@ -0,0 +1,39 @@
package com.baiyue.lastleg.fleet.business;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetLooseboardDemandServiceTest {
final FleetLooseboardDemandRepository repository=mock(FleetLooseboardDemandRepository.class);
final FleetLooseboardDemandService service=new FleetLooseboardDemandService(repository);
/** 校验客户隔离、版本、已有匹配与独立履约都阻止重复接单。 */
@Test void rejectsMissingStaleMatchedAndPhysicalSources() {
assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("不存在");
var row=source();when(repository.lock("9","LB-1")).thenReturn(row);
assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",1L)).hasMessageContaining("变化");
assertThatThrownBy(()->service.prepare("9","US_TRUCK","LB-1",0L)).hasMessageContaining("散板");
when(repository.active("9",1)).thenReturn(true);
assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("已有承运");
when(repository.active("9",1)).thenReturn(false);row.setLastlegOrderId(7L);
assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("独立履约");
verify(repository,never()).attach(anyLong(),anyString(),anyLong());
}
/** 取消草稿使生效匹配清空,同张原单可重新选车且没有商业复制。 */
@Test void allowsRematchAfterDraftCancellationAndKeepsSourceImmutable() {
var row=source();when(repository.lock("9","LB-1")).thenReturn(row);
when(repository.active("9",1)).thenReturn(true,false);
assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("已有承运");
assertThat(service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).isSameAs(row);
when(repository.attach(7,"9",1)).thenReturn(1);service.attach(7,row);
when(repository.source(7,"9")).thenReturn(row);
service.validateEdit(7,"9","LOCAL_LOOSEBOARD",null);
assertThatThrownBy(()->service.validateEdit(7,"9","US_TRUCK",null)).hasMessageContaining("不能替换");
assertThatThrownBy(()->service.validateEdit(7,"9","LOCAL_LOOSEBOARD","LB-2")).hasMessageContaining("不能替换");
}
/** 构造待匹配原单。 */
private LocalLooseboardOrder source() {
var row=new LocalLooseboardOrder();row.setId(1L);row.setCustomerId("9");row.setOrderNo("LB-1");row.setVersion(0L);row.setStatus("PENDING_ACCEPTANCE");return row;
}
}
@@ -25,11 +25,13 @@ class FleetPoolOrderBridgeTest {
FleetPoolOrderNumberGenerator numbers=mock(FleetPoolOrderNumberGenerator.class);
FleetPoolDeliveryEvidence evidence=mock(FleetPoolDeliveryEvidence.class);
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker.class);
FleetLooseboardDemandRepository demands=mock(FleetLooseboardDemandRepository.class);
FleetPoolOrderBridge service;
FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder;
@BeforeEach void setup(){
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(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row);
when(links.lockIncoming("9","WT-7")).thenAnswer(a->row);
@@ -45,6 +47,23 @@ class FleetPoolOrderBridgeTest {
BigDecimal.TEN,BigDecimal.ONE,"USD","联系人","123456","本轮池订单");}
void incoming(){service.bindIncoming("FB-7","9","WT-7",77,"92","FBA:ONT8","op");}
/** 原单匹配后全链复用客户商业单,实收到签收均不另建商业单。 */
@Test void matchedSourceRemainsTheSameCommercialOrderThroughDelivery() {
looseOrder=new LocalLooseboardOrder();looseOrder.setId(31L);looseOrder.setCustomerId("9");looseOrder.setOrderNo("LB-ORIGINAL");looseOrder.setVersion(4L);looseOrder.setStatus("PENDING_ACCEPTANCE");
looseOrder.setOrigin("客户原始提货地址");looseOrder.setDestinationContactName("原始收货人");
when(demands.source(7,"9")).thenReturn(looseOrder);when(loose.lockNo("9","LB-ORIGINAL")).thenAnswer(a->looseOrder);
var original=looseOrder;
var confirmed=service.confirm(input("LOCAL_LOOSEBOARD"),"op");
assertThat(confirmed.businessOrderNo()).isEqualTo("LB-ORIGINAL");assertThat(row.getBusinessOrderId()).isEqualTo(31L);
assertThat(service.confirm(input("LOCAL_LOOSEBOARD"),"op")).isEqualTo(confirmed);
incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op");service.boundDispatch(7,"9","DISPATCH-7","op");
service.departed("9","DISPATCH-7",Map.of(77L,4),"op",LocalDateTime.now());
service.signed(delivery(4),Map.of(77L,4L),"op",LocalDateTime.now());
assertThat(looseOrder).isSameAs(original);assertThat(original.getStatus()).isEqualTo("COMPLETED");
assertThat(original.getLastlegOrderId()).isEqualTo(77L);assertThat(original.getDestinationContactName()).isEqualTo("原始收货人");
verify(loose,never()).insert(any(LocalLooseboardOrder.class));
}
@Test void materializesTheOriginalAppointmentAfterActualIncomingAssociationAndNeverOnReplay() {
service.confirm(input("LOCAL_LOOSEBOARD"),"op");
incoming();
@@ -47,7 +47,7 @@ class FleetTripApiContractTest {
count++;
}
}
assertThat(count).isEqualTo(46);
assertThat(count).isEqualTo(47);
verifyRefs(document, document);
verifyRefs(preparation, preparation);
verifyRefs(pool, pool);
@@ -81,6 +81,18 @@ class FleetTripAuthorizationTest {
assertThatThrownBy(()->pool.saveAndConfirm("FB-1",null)).isInstanceOf(AccessDeniedException.class);
} finally {SecurityContextHolder.clearContext();}
}
/** 需求含客户原单资料,仅列表权限不能读取待匹配需求。 */
@Test void pendingDemandRequiresManageOrListAndCreate() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(PlatformFleetCapacityOfferController.class);
var service=context.getBean(FleetCapacityOfferService.class);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("sales","unused",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST))));
assertThatThrownBy(()->controller.pendingDemands(null)).isInstanceOf(AccessDeniedException.class);
verifyNoInteractions(service);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("sales","unused",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST),new SimpleGrantedAuthority("platform:fleet:capacity-offer:inquiry-create"))));
controller.pendingDemands(null);verify(service).pendingDemands(null);
} finally {SecurityContextHolder.clearContext();}
}
@Test void externalArrangeRequiresCurrentCreateAndAllIndependentPermissions() {
var required=List.of("platform:fleet:capacity-offer:manage","platform:fleet:trip:booking-edit","platform:fleet:trip:edit","platform:overseas-warehouse:dispatch:create");
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
@@ -100,6 +112,10 @@ class FleetTripAuthorizationTest {
}
@Configuration @EnableMethodSecurity
static class Config {
/** 原单需求依赖使用替身,权限测试不访问数据库。 */
@Bean com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService looseboardDemands() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService.class); }
/** 桥接器的原单查询使用仓储替身。 */
@Bean com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository looseboardDemandRepository() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository.class); }
/** 既有取货能力依赖原仓储引用;权限测试使用只读替身,不连接数据库。 */
@Bean com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups() { return mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); }
@Bean com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workTripChanges() { return mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class); }
@@ -64,6 +64,9 @@ class LocalLooseboardServiceTest {
ReflectionTestUtils.setField(repository, "lastlegHistories", lastlegHistories);
ReflectionTestUtils.setField(service, "lastlegOrderNoGenerator", lastlegOrderNoGenerator);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
// 旧独立履约用例保留真实关联尾程,新增需求不创建该来源。
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().when(operations.findKey(anyString(), any(), anyString()))
@@ -94,12 +97,8 @@ class LocalLooseboardServiceTest {
assertEquals("家居用品", detail.summary().productName());
assertEquals(3, detail.summary().estimatedPalletCount());
assertEquals(LocalDateTime.of(2030, 1, 2, 9, 30), detail.summary().pickupAt());
verify(lastlegOrders).insert(org.mockito.ArgumentMatchers.<LastlegOrder>argThat(value ->
"LOCAL_LOOSEBOARD".equals(value.getOrderType())
&& value.getSourceOrderNo().equals("LB-WH-20260901-000001")
&& value.getInboundCode() == null
&& value.getPalletCount() == 0
&& "LOCAL_LOOSEBOARD:PENDING_ACCEPTANCE".equals(value.getSubOrderStatus())));
org.junit.jupiter.api.Assertions.assertNull(saved.get().getLastlegOrderId());
verify(lastlegOrders, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.<LastlegOrder>any());
verify(histories).insert(org.mockito.ArgumentMatchers.<LocalLooseboardHistory>argThat(value ->
"CREATE".equals(value.getOperationType())));
}
@@ -208,7 +207,7 @@ class LocalLooseboardServiceTest {
private LocalLooseboardOrder order(String status, long version) {
LocalLooseboardOrder order = new LocalLooseboardOrder();
order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1");
order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1"); order.setLastlegOrderId(99L);
order.setPalletRequired("YES");
order.setTransportType("WAREHOUSE_TO_WAREHOUSE"); order.setOrigin("Los Angeles");
order.setDestination("Ontario"); order.setPackageCount(12); order.setCurrency("USD");