This commit is contained in:
2026-10-20 21:47:51 +08:00
parent 34b90f311a
commit 137efc6809
54 changed files with 1434 additions and 63 deletions
@@ -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);
@@ -78,6 +78,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)) {
@@ -97,6 +109,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); }