This commit is contained in:
2026-10-29 14:29:02 +08:00
parent 1b27d7e6c2
commit 849f8dc5f2
13 changed files with 129 additions and 27 deletions
@@ -28,6 +28,7 @@ class DispatchCompletionPersistenceTest {
assertThat(changed.changed()).isTrue();assertThat(changed.stage()).isEqualTo(Stage.DELIVERED);
assertThat(repeated.changed()).isFalse();assertThat(repeated.recordVersion()).isEqualTo(changed.recordVersion());
assertThat(fixture.completedHistory()).isOne();
verify(fixture.completion,atLeastOnce()).completeDelivered(anyString(),eq("SYSTEM_DELIVERY_RECONCILE"));
assertThat(fixture.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
}
/** 部分签收只确认本信号,不把异常或返回数凑作全签收;后续版本独立触发完成。 */
@@ -44,6 +45,7 @@ class DispatchCompletionPersistenceTest {
private static final class CompletionFixture {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
final DispatchDeliveryPort deliveries=mock(DispatchDeliveryPort.class);final DispatchSignalPort signals=mock(DispatchSignalPort.class);
final com.baiyue.lastleg.fleet.trip.FleetTripCompletionService completion=mock(com.baiyue.lastleg.fleet.trip.FleetTripCompletionService.class);
final DispatchCompletionPort port;final String number;final Signal signal;final Instant signedAt=Instant.now().minusSeconds(5);
/** 使用真实Spring事务代理,REQUIRES_NEW与回滚均按生产注解执行。 */
CompletionFixture() throws Exception {
@@ -51,7 +53,7 @@ class DispatchCompletionPersistenceTest {
f.picked(1,"101",501,3);f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',picked_package_count=3,shipped_package_count=3,departed_at=?",LocalDateTime.ofInstant(signedAt.minusSeconds(60),ZoneOffset.UTC));
f.db.execute("CREATE TABLE completion_test_signal(id BIGINT PRIMARY KEY,state VARCHAR(32))");f.db.update("INSERT INTO completion_test_signal VALUES(1,'PENDING')");
signal=new Signal(1L,number,"DELIVERY-A",1,0,Instant.now());
var target=new DispatchCompletionService(f.repository,f.executionReads,f.fleet,f.service,deliveries,signals,f.json);
var target=new DispatchCompletionService(f.repository,f.executionReads,f.fleet,f.service,deliveries,signals,f.json,completion);
var proxy=new ProxyFactory(target);proxy.addAdvice(new TransactionInterceptor(f.transactions,new AnnotationTransactionAttributeSource()));port=(DispatchCompletionPort)proxy.getProxy();
}
/** 父事务中的信号SQL参与同一个DataSource事务。 */
@@ -55,6 +55,7 @@ class DriverAuthorizationTest {
@Configuration
@EnableMethodSecurity
static class Config {
@Bean com.baiyue.lastleg.fleet.port.DriverAvailability availability() { return mock(com.baiyue.lastleg.fleet.port.DriverAvailability.class); }
@Bean DriverService service() { return mock(DriverService.class); }
@Bean PlatformDriverController controller(DriverService service) { return new PlatformDriverController(service); }
}
@@ -72,7 +72,7 @@ class DriverPersistenceTest {
var result = service.page(new DriverModels.Query(1,1,null,null,null,null,null,null,null,"PC1"));
assertThat(result.total()).isEqualTo(1);
assertThat(result.records()).extracting(DriverModels.Response::id).containsExactly(own.id());
assertThat(service.page(new DriverModels.Query(1,20,null,null,null,null,null,null,null)).total()).isEqualTo(2);
assertThat(service.page(new DriverModels.Query(1,20,null,null,null,null,null,null,null, null)).total()).isEqualTo(2);
}
@Test void historyFailureRollsBackDriverStatusAndVersion() {
@@ -126,16 +126,16 @@ class DriverPersistenceTest {
service.create("operator", eastern);
service.create("operator", western);
service.create("operator", draft(null));
var all = service.page(new DriverModels.Query(1, 1, null, null, null, null, null, null, null));
var all = service.page(new DriverModels.Query(1, 1, null, null, null, null, null, null, null, null));
assertThat(all.total()).isEqualTo(3);
assertThat(all.records()).hasSize(1);
assertThat(all.summary()).isEqualTo(new DriverModels.Summary(3, 2, 1, 1, 1));
var expired = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRED", null));
var expired = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRED", null, null));
assertThat(expired.records()).extracting(row -> row.details().name()).containsExactly("东侧日期");
assertThat(expired.summary().expired()).isEqualTo(1);
var expiring = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRING", null));
var expiring = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRING", null, null));
assertThat(expiring.records()).extracting(DriverModels.Response::licenseState).containsExactly("EXPIRING");
var missing = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "MISSING", null));
var missing = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "MISSING", null, null));
assertThat(missing.total()).isEqualTo(1);
assertThat(missing.summary().expiringSoon()).isZero();
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.fleet.driver;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** 经真实 MVC 参数绑定验证列表入口,不能用直接调用 Service 代替。 */
class DriverQueryBindingTest {
@Test void emptyListQueryBindsDefaults() throws Exception {
var service = mock(DriverService.class);
var mvc = MockMvcBuilders.standaloneSetup(new PlatformDriverController(service)).build();
mvc.perform(get("/api/platform/v1/last-leg/fleet/drivers")).andExpect(status().isOk());
var argument = ArgumentCaptor.forClass(DriverModels.Query.class);
verify(service).page(argument.capture());
assertThat(argument.getValue().page()).isEqualTo(1);
assertThat(argument.getValue().size()).isEqualTo(20);
assertThat(argument.getValue().dispatchNo()).isNull();
}
@Test void dispatchCandidatesBindFiltersAndExpectedEnd() throws Exception {
var service = mock(DriverService.class);
var mvc = MockMvcBuilders.standaloneSetup(new PlatformDriverController(service)).build();
mvc.perform(get("/api/platform/v1/last-leg/fleet/drivers")
.param("page", "2").param("size", "100").param("keyword", "司机")
.param("active", "true").param("reviewStatus", "APPROVED")
.param("region", "OVERSEAS").param("dispatchNo", "PC1")
.param("expectedEnd", "2026-10-30T12:00:00Z")).andExpect(status().isOk());
var argument = ArgumentCaptor.forClass(DriverModels.Query.class);
verify(service).page(argument.capture());
var query = argument.getValue();
assertThat(query.page()).isEqualTo(2);
assertThat(query.size()).isEqualTo(100);
assertThat(query.keyword()).isEqualTo("司机");
assertThat(query.active()).isTrue();
assertThat(query.reviewStatus()).isEqualTo("APPROVED");
assertThat(query.region()).isEqualTo("OVERSEAS");
assertThat(query.dispatchNo()).isEqualTo("PC1");
assertThat(query.expectedEnd()).isEqualTo(Instant.parse("2026-10-30T12:00:00Z"));
}
}
@@ -48,21 +48,34 @@ class FleetTripPersistenceTest {
assertThatThrownBy(()->f.tx.execute(s->f.nativeFleetService.updatePlan(trip(f),"operator",new FleetTripModels.Plan(detail.trip().recordVersion(),Instant.now().plusSeconds(60),Instant.now().plusSeconds(120),List.of(new FleetTripModels.Stop("起点",null,null),new FleetTripModels.Stop("终点",null,"FBA:ONT8")),null,"改计划")))).hasMessageContaining("排车主单");
assertThat(f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isZero();
}
@Test void externalCargoCompletionAndTwoTaskEndsAreIndependentAndReplayable() throws Exception {
@Test void completionReleasesBothResourcesAndReplayKeepsLaterOccupancy() throws Exception {
var scenario=new CompletedCargo();var f=scenario.f;var inspection=scenario.completion.inspection(trip(f));assertThat(inspection.canComplete()).isTrue();
var request=new FleetCompletionModels.Complete(inspection.recordVersion(),"complete-original","全部货物实际履约");
var complete=f.tx.execute(s->scenario.completion.complete(trip(f),"operator",request));assertThat(complete.trip().status()).isEqualTo("COMPLETED");
FleetTripModels.Detail recovered=f.tx.execute(s->scenario.completion.complete(trip(f),"operator",request));assertThat(recovered).isEqualTo(complete);
assertThat(complete.trip().vehicleRelease()).isNull();assertThat(complete.trip().driverRelease()).isNull();
var end=Instant.now().minusSeconds(10);var vehicle=new FleetCompletionModels.Release(complete.trip().recordVersion(),"VEHICLE",end,"实际交车点","RETURNED","vehicle-end-original","外配车实际结束");
var released=f.tx.execute(s->scenario.completion.release(trip(f),"operator",vehicle));assertThat(released.trip().vehicleRelease()).isNotNull();assertThat(released.trip().driverRelease()).isNull();
var driver=new FleetCompletionModels.Release(released.trip().recordVersion(),"DRIVER",end,"实际任务终点",null,"driver-end-original","司机实际结束");
var finished=f.tx.execute(s->scenario.completion.release(trip(f),"operator",driver));assertThat(finished.trip().driverRelease()).isNotNull();
assertThat(f.tx.execute(s->scenario.completion.release(trip(f),"operator",vehicle)).trip().recordVersion()).isEqualTo(finished.trip().recordVersion());
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isZero();verifyNoInteractions(scenario.vehicleService);
assertThat(complete.trip().vehicleRelease()).isNotNull();assertThat(complete.trip().driverRelease()).isNotNull();
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isZero();
f.db.update("INSERT INTO ll_fleet_resource_assignment(resource_kind,resource_id,trip_id,trip_no) VALUES('DRIVER',2,999,'NEXT-TRIP')");
f.tx.execute(s->{scenario.completion.completeDelivered(trip(f),"SYSTEM");return null;});
assertThat(f.db.queryForObject("SELECT trip_no FROM ll_fleet_resource_assignment WHERE resource_kind='DRIVER' AND resource_id=2",String.class)).isEqualTo("NEXT-TRIP");
verifyNoInteractions(scenario.vehicleService);
}
/** 自动完成与资源占用、历史共同回滚,重试才真正释放。 */
@Test void automaticDeliveryCompletionRollsBackWithItsCallerAndRetries() throws Exception {
var scenario=new CompletedCargo();var f=scenario.f;
assertThatThrownBy(()->f.tx.execute(s->{scenario.completion.completeDelivered(trip(f),"SYSTEM");throw new IllegalStateException("模拟后续失败");})).isInstanceOf(IllegalStateException.class);
assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT");
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isEqualTo(2);
f.tx.execute(s->{scenario.completion.completeDelivered(trip(f),"SYSTEM");return null;});
var result=f.nativeFleetService.detail(trip(f));
assertThat(result.trip().status()).isEqualTo("COMPLETED");
assertThat(result.trip().vehicleRelease()).isNotNull();assertThat(result.trip().driverRelease()).isNotNull();
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isZero();
}
@Test void partialSignatureCannotCloseCargoOrEndExternalTasks() throws Exception {
var scenario=new CompletedCargo();var f=scenario.f;f.db.update("UPDATE ll_delivery_order SET signed_at=NULL,signed_packages=NULL,status='DELIVERING'");
f.tx.execute(s->{scenario.completion.completeDelivered(trip(f),"SYSTEM");return null;});
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Long.class)).isEqualTo(2);
var inspection=scenario.completion.inspection(trip(f));assertThat(inspection.canComplete()).isFalse();
assertThatThrownBy(()->f.tx.execute(s->scenario.completion.complete(trip(f),"operator",new FleetCompletionModels.Complete(inspection.recordVersion(),"partial-cannot-close","未全签")))).isInstanceOf(RuntimeException.class);
assertThatThrownBy(()->f.tx.execute(s->scenario.completion.release(trip(f),"operator",new FleetCompletionModels.Release(inspection.recordVersion(),"DRIVER",Instant.now().minusSeconds(1),"未结束",null,"cannot-end-driver","尚未履约")))).isInstanceOf(RuntimeException.class);
@@ -81,6 +94,12 @@ class FleetTripPersistenceTest {
f=new Fixture();var created=f.tx.execute(s->f.service.create(f.actor,f.configured("fleet-completion",List.of(f.source(1,"101",501,"ONT8",3)))));f.approve(created);f.picked(1,"101",501,3);
f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',picked_package_count=3,shipped_package_count=3,departed_at=?",LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10));
f.db.update("UPDATE ll_fleet_trip SET status='IN_TRANSIT',departed_at=?",LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10));
// 为自动释放建立真实占用行,避免只验证没有资源的外配车次。
var drivers=(com.baiyue.lastleg.fleet.driver.DriverService)org.springframework.test.util.ReflectionTestUtils.getField(f.nativeFleetService,"drivers");
when(drivers.detail(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.any())).thenReturn(mock(com.baiyue.lastleg.fleet.driver.DriverModels.Response.class));
f.db.update("UPDATE ll_fleet_trip SET vehicle_id=1,driver_id=2");
f.db.update("INSERT INTO ll_fleet_resource_assignment SELECT 'VEHICLE',1,id,trip_no FROM ll_fleet_trip");
f.db.update("INSERT INTO ll_fleet_resource_assignment SELECT 'DRIVER',2,id,trip_no FROM ll_fleet_trip");
var root=f.repository.find(created.dispatchNo());var outbound=new OutboundOrder();outbound.setId(600L);outbound.setCustomerId("101");outbound.setOutboundNo("OB-REAL");outbound.setDispatchOrderId(root.getId());outbound.setOverseasWarehouseId("9");outbound.setStatus("OUTBOUNDED");outbound.setPackageCount(3);f.session.getMapper(OutboundOrderMapper.class).insert(outbound);
var delivery=new DeliveryOrder();delivery.setId(700L);delivery.setCustomerId("101");delivery.setDeliveryNo("DL-REAL");delivery.setDispatchOrderId(root.getId());delivery.setOutboundOrderId(600L);delivery.setOverseasWarehouseId("9");delivery.setStatus("DELIVERED");delivery.setPackageCount(3);delivery.setSignedPackages(3);delivery.setSignedAt(LocalDateTime.now().minusMinutes(5));delivery.setVersion(0L);delivery.setDestinationType("FBA");delivery.setDestinationWarehouseCode("ONT8");f.session.getMapper(DeliveryOrderMapper.class).insert(delivery);
completion=new FleetTripCompletionService(f.session.getMapper(FleetTripMapper.class),f.session.getMapper(FleetTripDispatchMapper.class),f.session.getMapper(FleetResourceMapper.class),(FleetDispatchGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.nativeFleetService,"dispatches"),f.nativeFleetService,mock(VehicleRepository.class),vehicleService,f.json);