feat(fleet): integrate costs quotes operations and in-transit handovers

This commit is contained in:
2026-10-03 12:38:04 +08:00
parent 274ecac474
commit cb6c54ffe5
107 changed files with 7304 additions and 42 deletions
@@ -0,0 +1,61 @@
package com.baiyue.lastleg.fleet.exception;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.*;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import org.yaml.snakeyaml.Yaml;
import java.nio.file.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetHandoverContractTest {
@AfterEach void clearIdentity() { SecurityContextHolder.clearContext(); }
@Test void listPermissionCanInspectButCannotHandoverOrEndResources() {
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator", "unused",
List.of(new SimpleGrantedAuthority("platform:fleet:trip:list"))));
var controller = context.getBean(PlatformFleetHandoverController.class);
var service = context.getBean(FleetHandoverService.class);
controller.page("TR-1", new FleetHandoverModels.Query(1, 10));
controller.preview("TR-1", null);
verify(service).preview("TR-1", null);
assertThrows(AccessDeniedException.class, () -> controller.confirm("TR-1", null, null));
assertThrows(AccessDeniedException.class, () -> controller.release("TR-1", "HO-1", null, null));
}
}
@Test void everyPublicMethodHasAContractAndIndependentWriteAuthority() throws Exception {
Map<String, Object> document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-handovers-v1.yaml")));
Map<?, ?> paths = (Map<?, ?>) document.get("paths");
String base = PlatformFleetHandoverController.class.getAnnotation(RequestMapping.class).value()[0];
int count = 0;
for (var method : PlatformFleetHandoverController.class.getDeclaredMethods()) {
String suffix, verb;
if (method.isAnnotationPresent(GetMapping.class)) { var pathsOnMethod = method.getAnnotation(GetMapping.class).value(); suffix = pathsOnMethod.length == 0 ? "" : pathsOnMethod[0]; verb = "get"; }
else if (method.isAnnotationPresent(PostMapping.class)) { var pathsOnMethod = method.getAnnotation(PostMapping.class).value(); suffix = pathsOnMethod.length == 0 ? "" : pathsOnMethod[0]; verb = "post"; }
else continue;
var path = (Map<?, ?>) paths.get(base + suffix); assertNotNull(path, method.getName());
var operation = (Map<?, ?>) path.get(verb); assertNotNull(operation, method.getName());
var authority = method.getAnnotation(PreAuthorize.class); assertNotNull(authority);
String permission = authority.value().split("'")[1]; assertTrue(operation.get("description").toString().contains(permission));
count++;
}
assertEquals(4, count);
}
@Configuration
@EnableMethodSecurity
static class Config {
@Bean FleetHandoverService service() { return mock(FleetHandoverService.class); }
@Bean PlatformFleetHandoverController controller(FleetHandoverService service) { return new PlatformFleetHandoverController(service); }
}
}
@@ -0,0 +1,74 @@
package com.baiyue.lastleg.fleet.exception;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class FleetHandoverPdaQueryTest {
final FleetTripDispatchMapper bindings = mock(FleetTripDispatchMapper.class);
final FleetTripMapper trips = mock(FleetTripMapper.class);
final FleetTripHistoryMapper history = mock(FleetTripHistoryMapper.class);
final FleetDispatchGateway dispatches = mock(FleetDispatchGateway.class);
final FleetHandoverRepository handovers = mock(FleetHandoverRepository.class);
final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule());
final FleetHandoverPdaQuery service = new FleetHandoverPdaQuery(bindings, trips, history, dispatches, handovers, json);
@BeforeEach void setup() {
var binding = new FleetTripDispatch(); binding.setDispatchNo("PC-A"); binding.setCustomerId("CUSTOMER-A");
binding.setTripId(1L); binding.setTripNo("TR-ONE"); binding.setPackageCount(30);
var trip = new FleetTripEntity(); trip.setId(1L); trip.setTripNo("TR-ONE"); trip.setWarehouseId(92L);
trip.setStatus("IN_TRANSIT"); trip.setRecordVersion(10L);
when(bindings.findByDispatchNo("PC-A")).thenReturn(binding); when(trips.findByNo("TR-ONE")).thenReturn(trip);
var departed = new FleetTripHistory(); departed.setSnapshotJson("""
{"trip":{"tripNo":"TR-ONE","warehouseId":"92","vehiclePlate":"ORIGINAL","driverName":"原司机"}}
""");
when(history.selectList(any())).thenReturn(List.of(departed));
when(dispatches.transitEvidence("PC-A", false)).thenReturn(new FleetTransitEvidence("PC-A", "CUSTOMER-A", "92", 30, List.of(
new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-DONE", "FBA:ONE", 10, Instant.parse("2026-10-03T01:00:00Z")),
"DELIVERED", 2, new FleetTransitEvidence.Transport("ORIGINAL", "原司机", null)),
new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-PENDING", "FBA:TWO", 20, null),
"WAIT_DELIVERY", 3, new FleetTransitEvidence.Transport("REPLACEMENT", "接替司机", null)))));
}
@Test void crossWarehouseFailsBeforeAnyCargoOrHistoryQuery() {
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.status(93, "PC-A"));
verifyNoInteractions(dispatches, history, handovers);
}
@Test void selectedDispatchRetainsOriginalAndPerDeliveryResourcesWithoutInternalIdentifiers() throws Exception {
var result = service.status(92, "PC-A");
assertEquals("ORIGINAL", result.originalDeparture().vehiclePlate());
assertEquals("ORIGINAL", result.deliveries().get(0).transport().vehiclePlate());
assertEquals("REPLACEMENT", result.deliveries().get(1).transport().vehiclePlate());
String serialized = json.writeValueAsString(result);
assertFalse(serialized.contains("customerId")); assertFalse(serialized.contains("warehouseId")); assertFalse(serialized.contains("vehicleId"));
verify(handovers).latestForCargo(eq(1L), argThat(value -> value.contains("CUSTOMER-A") && value.contains("PC-A")));
verify(dispatches, never()).handover(any(), any());
}
@Test void staleBindingToAnotherCustomerCannotExposeDeliveryResources() {
when(dispatches.transitEvidence("PC-A", false)).thenReturn(new FleetTransitEvidence("PC-A", "CUSTOMER-B", "92", 30, List.of()));
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.status(92, "PC-A"));
verifyNoInteractions(history, handovers);
}
@Test void controllerHasOnlyReadRouteAndRequiresThePdaIdentityPolicy() {
var policy = PdaFleetHandoverController.class.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value();
assertTrue(policy.contains("lastleg:pda:operate")); assertTrue(policy.contains("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
// Coverage instrumentation may add synthetic methods. Count all HTTP mappings (including
// composed mappings), so a newly exposed write route still fails this read-only contract.
var routes = java.util.Arrays.stream(PdaFleetHandoverController.class.getDeclaredMethods())
.map(method -> org.springframework.core.annotation.AnnotatedElementUtils.findMergedAnnotation(
method, org.springframework.web.bind.annotation.RequestMapping.class))
.filter(java.util.Objects::nonNull).toList();
assertEquals(1, routes.size());
assertArrayEquals(new org.springframework.web.bind.annotation.RequestMethod[] { org.springframework.web.bind.annotation.RequestMethod.GET }, routes.get(0).method());
assertArrayEquals(new String[] { "/{dispatchNo}/handover-status" }, routes.get(0).path());
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> new PdaFleetHandoverController(service).status(null, "PC-A"));
}
}
@@ -0,0 +1,98 @@
package com.baiyue.lastleg.fleet.exception;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import static com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.*;
import static org.junit.jupiter.api.Assertions.*;
class FleetHandoverPolicyTest {
private final Instant departure = Instant.parse("2026-10-03T01:00:00Z");
private final Instant handover = departure.plusSeconds(7200);
private final Load capacity = load(2, 200, 4);
private final Cargo a = new Cargo("A", "PC-A", "1", "P1", "FBA:ONE", 10);
private final Cargo b = new Cargo("B", "PC-B", "2", "P1", "FBA:ONE", 20);
private final List<Pallet> measured = List.of(new Pallet("P1", load(1, 100, 2)));
@Test void sharedPalletKeepsCustomerOwnershipAndCountsCapacityOnce() {
var result = inspectBoth(delivery(a, null), delivery(b, null));
assertEquals(List.of(a, b), result.remainingCargo());
assertEquals(load(1, 100, 2), result.occupied());
assertEquals(1, result.remainingPalletIds().size());
}
@Test void signedCustomersCargoIsExcludedWithoutGuessingResidualWeight() {
var signed = delivery(a, departure.plusSeconds(60));
var result = inspectBoth(signed, delivery(b, null));
assertEquals(List.of(b), result.remainingCargo());
assertEquals(List.of(signed), result.completedDeliveries());
assertEquals(load(1, 100, 2), result.occupied());
}
@Test void unresolvedRefusalRemainsCargoUntilActualReturnFact() {
var result = inspectBoth(delivery(a, null), delivery(b, departure.plusSeconds(60)));
assertEquals(List.of(a), result.remainingCargo());
}
@Test void allTerminalCargoRequiresCompletionRatherThanEmptyHandover() {
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, handover), delivery(b, handover)));
}
@Test void sameDestinationCannotMixCustomerOrDispatchFacts() {
var wrong = new Delivery("A", "PC-B", "D-B", "FBA:ONE", 20, null);
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), wrong));
}
@Test void missingDuplicateAndChangedQuantityFactsAreRejected() {
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null)));
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), delivery(a, null), delivery(b, null)));
var wrong = new Delivery("B", "PC-B", "D-B", "FBA:ONE", 19, null);
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), wrong));
}
@Test void cannotBackdateHandoverAcrossLaterSignatureOrBeforeDeparture() {
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, handover.plusSeconds(1)), delivery(b, null)));
assertThrows(IllegalArgumentException.class, () -> inspect(departure, departure.minusSeconds(1), handover,
List.of(a), List.of(delivery(a, null)), measured, capacity));
assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover.plusSeconds(1), handover,
List.of(a), List.of(delivery(a, null)), measured, capacity));
}
@Test void rejectsAllThreeCapacityOverflows() {
for (Load tooSmall : List.of(load(1, 99, 2), load(1, 100, 1), load(0, 100, 2)))
assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover, handover,
List.of(a), List.of(delivery(a, null)), measured, tooSmall));
}
@Test void missingOrDuplicateMeasurementsAndMixedDestinationPalletAreRejected() {
assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover, handover,
List.of(a), List.of(delivery(a, null)), List.of(), capacity));
assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover, handover,
List.of(a), List.of(delivery(a, null)), List.of(measured.get(0), measured.get(0)), capacity));
var wrong = new Cargo("B", "PC-B", "2", "P1", "FBA:TWO", 20);
assertThrows(IllegalArgumentException.class, () -> inspect(departure, handover, handover,
List.of(a, wrong), List.of(delivery(a, null), delivery(wrong, null)), measured, capacity));
}
@Test void fullyUnloadedPalletDoesNotConsumeReplacementCapacity() {
var other = new Cargo("B", "PC-B", "2", "P2", "FBA:TWO", 20);
var result = inspect(departure, handover, handover, List.of(a, other),
List.of(delivery(a, handover), delivery(other, null)),
List.of(measured.get(0), new Pallet("P2", load(1, 50, 1))), load(1, 50, 1));
assertEquals(load(1, 50, 1), result.occupied());
assertEquals(List.of(other), result.remainingCargo());
}
private Inspection inspectBoth(Delivery... deliveries) {
return inspect(departure, handover, handover, List.of(a, b), List.of(deliveries), measured, capacity);
}
private static Delivery delivery(Cargo cargo, Instant terminalAt) {
return new Delivery(cargo.customerId(), cargo.dispatchNo(), "D-" + cargo.customerId(),
cargo.destinationKey(), cargo.packages(), terminalAt);
}
private static Load load(int pallets, int kg, int cbm) {
return new Load(pallets, BigDecimal.valueOf(kg), BigDecimal.valueOf(cbm));
}
}
@@ -0,0 +1,171 @@
package com.baiyue.lastleg.fleet.exception;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load;
import com.baiyue.lastleg.fleet.driver.*;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.*;
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 java.math.BigDecimal;
import java.time.*;
import java.time.temporal.ChronoUnit;
import java.util.*;
import static com.baiyue.lastleg.fleet.exception.FleetHandoverModels.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** Real transaction/repository/resource locks; credential, cargo and existing trip-history ports are isolated. */
class FleetHandoverTransactionTest {
private JdbcTemplate jdbc;
private FleetHandoverService service;
private FleetHandoverRepository repository;
private FleetTripService history;
private FleetDispatchGateway dispatches;
private DriverService drivers;
private VehicleApplicationService vehicleService;
private Instant at, end;
private Selection selection;
@BeforeEach void setup() throws Exception {
at = Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS); end = at.plusSeconds(3600);
var source = new DriverManagerDataSource("jdbc:h2:mem:handover-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(source);
jdbc.execute("CREATE TABLE trip_state(id BIGINT PRIMARY KEY,vehicle_id BIGINT,driver_id BIGINT,record_version BIGINT)");
jdbc.update("INSERT INTO trip_state VALUES(1,11,21,7)");
jdbc.execute("CREATE TABLE ll_fleet_resource_assignment(resource_kind VARCHAR(16),resource_id BIGINT,trip_id BIGINT,trip_no VARCHAR(32),PRIMARY KEY(resource_kind,resource_id))");
jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('VEHICLE',11,1,'TR-ONE'),('DRIVER',21,1,'TR-ONE')");
jdbc.execute("""
CREATE TABLE ll_fleet_trip_handover(
id BIGINT PRIMARY KEY,trip_id BIGINT NOT NULL,trip_version BIGINT NOT NULL,trip_no VARCHAR(32) NOT NULL,handover_no VARCHAR(32) NOT NULL UNIQUE,
request_key VARCHAR(128) NOT NULL,request_hash CHAR(64) NOT NULL,record_version BIGINT NOT NULL,
handed_over_at TIMESTAMP(3) NOT NULL,snapshot_json LONGTEXT NOT NULL,
vehicle_release_json LONGTEXT,driver_release_json LONGTEXT,created_by VARCHAR(64) NOT NULL,created_at TIMESTAMP(3) NOT NULL,
UNIQUE(trip_id,request_key),UNIQUE(trip_id,trip_version))
""");
var config = new MybatisConfiguration(); config.addMapper(FleetResourceMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(source); factory.setConfiguration(config);
var resources = new SqlSessionTemplate(factory.getObject()).getMapper(FleetResourceMapper.class);
var trips = mock(FleetTripMapper.class);
when(trips.lockByNo("TR-ONE")).thenAnswer(call -> state()); when(trips.findByNo("TR-ONE")).thenAnswer(call -> state());
var vehicles = mock(VehicleRepository.class);
when(vehicles.lockById(anyLong())).thenAnswer(call -> Optional.of(new VehicleRepository.VehicleSnapshot(call.getArgument(0),
"PLATE-" + call.getArgument(0), "TRUCK", BigDecimal.ONE, 10, null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null,
0, "1", LocalDateTime.now(), 2, BigDecimal.valueOf(200), BigDecimal.valueOf(4))));
drivers = mock(DriverService.class);
var qualification = new DriverQualificationPolicy.Qualification(true, true, DriverQualificationPolicy.Reason.ELIGIBLE, LocalDate.now().plusDays(15));
when(drivers.requireDispatchable(anyLong(), any())).thenAnswer(call -> new DriverModels.Response(Long.toString(call.getArgument(0)), "DR-NEW",
new DriverModels.Draft("接替司机", "phone", "OWNED", "OVERSEAS", "license", "C", null, LocalDate.now().plusDays(15), "UTC", null, null),
true, "APPROVED", "file", 0, "1", LocalDateTime.now(), qualification, "VALID", null, null));
var inspection = mock(FleetHandoverInspectionService.class);
var cargo = new FleetHandoverPolicy.Cargo("CUSTOMER-A", "PC-A", "item", "pallet", "FBA:ONT8", 10);
var delivery = new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-A", "FBA:ONT8", 10, null);
var evidence = new FleetTransitEvidence("PC-A", "CUSTOMER-A", "92", 10,
List.of(new FleetTransitEvidence.Delivery(delivery, "WAIT_DELIVERY", 1, new FleetTransitEvidence.Transport("PLATE-11", "司机", "phone"))));
when(inspection.prepare(anyString(), any(), any(), eq(true))).thenAnswer(call -> new FleetHandoverInspectionService.Prepared(state().getRecordVersion(),
new FleetHandoverPolicy.Inspection(List.of(cargo), List.of(), Set.of("pallet"), new Load(1, BigDecimal.valueOf(100), BigDecimal.valueOf(2))), List.of(evidence)));
history = mock(FleetTripService.class);
doAnswer(call -> {
FleetTripEntity trip = call.getArgument(0);
jdbc.update("UPDATE trip_state SET vehicle_id=?,driver_id=?,record_version=record_version+1 WHERE id=1", trip.getVehicleId(), trip.getDriverId());
return null;
}).when(history).recordExecution(any(), anyLong(), anyString(), anyString(), anyString());
dispatches = mock(FleetDispatchGateway.class); vehicleService = mock(VehicleApplicationService.class);
repository = new FleetHandoverRepository(jdbc);
var target = new FleetHandoverService(trips, resources, history, dispatches, vehicles, vehicleService, drivers, inspection, repository,
new ObjectMapper().registerModule(new JavaTimeModule()));
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource()));
service = (FleetHandoverService) proxy.getProxy();
selection = new Selection(7L, 12L, 22L, end, at);
}
@Test void handoverRetainsOldResourcesAndReplaysWithoutRepeatingDeliveryWrite() {
var command = command("handover-one");
var result = service.confirm("TR-ONE", "1", command);
assertEquals(4, countResources()); assertEquals(12L, state().getVehicleId()); assertEquals(22L, state().getDriverId());
assertEquals("11", result.snapshot().previous().vehicleId()); assertEquals("12", result.snapshot().replacement().vehicleId());
assertEquals(result.handoverNo(), service.confirm("TR-ONE", "another-operator", command).handoverNo());
verify(dispatches, times(1)).handover(any(), any());
verify(dispatches, never()).depart(anyString(), anyString(), anyString(), anyString(), any(), any());
assertEquals(1, repository.count(1));
}
@Test void failureAfterResourceAndHistoryWritesRollsBackEverything() {
var command = command("handover-fail");
doAnswer(call -> { jdbc.update("UPDATE trip_state SET vehicle_id=12 WHERE id=1"); throw new IllegalStateException("history failed"); })
.when(history).recordExecution(any(), anyLong(), eq("HANDOVER"), anyString(), anyString());
assertThrows(IllegalStateException.class, () -> service.confirm("TR-ONE", "1", command));
assertEquals(2, countResources()); assertEquals(0, repository.count(1)); assertEquals(11L, state().getVehicleId());
}
@Test void stalePreviewAndOccupiedReplacementDoNotWrite() {
var command = command("handover-stale");
jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('VEHICLE',12,2,'TR-TWO')");
assertThrows(RuntimeException.class, () -> service.confirm("TR-ONE", "1", command));
assertEquals(0, repository.count(1)); verifyNoInteractions(dispatches);
jdbc.update("DELETE FROM ll_fleet_resource_assignment WHERE resource_kind='VEHICLE' AND resource_id=12");
var stale = new Confirm(selection, "0".repeat(64), "交接地点", null, "handover-stale");
assertThrows(RuntimeException.class, () -> service.confirm("TR-ONE", "1", stale)); assertEquals(2, countResources());
}
@Test void sameKeyWithDifferentContentFailsBeforeAnySecondMutation() {
var command = command("handover-replay"); service.confirm("TR-ONE", "1", command);
var changed = new Confirm(selection, command.cargoHash(), "另一地点", null, command.idempotencyKey());
assertThrows(RuntimeException.class, () -> service.confirm("TR-ONE", "1", changed));
verify(dispatches, times(1)).handover(any(), any());
}
@Test void oldResourceReleaseReplayCannotReleaseItsNextTrip() {
var row = service.confirm("TR-ONE", "1", command("handover-release"));
var release = new Release(0L, "DRIVER", at.plusSeconds(1), "任务结束地点", null, "release-driver");
var released = service.release("TR-ONE", row.handoverNo(), "1", release);
assertNotNull(released.driverRelease()); assertEquals(3, countResources());
jdbc.update("INSERT INTO ll_fleet_resource_assignment VALUES('DRIVER',21,2,'TR-TWO')");
service.release("TR-ONE", row.handoverNo(), "1", release);
assertEquals(2L, jdbc.queryForObject("SELECT trip_id FROM ll_fleet_resource_assignment WHERE resource_kind='DRIVER' AND resource_id=21", Long.class));
}
@Test void failedVehicleStatusChangeRestoresOriginalReservation() {
var row = service.confirm("TR-ONE", "1", command("handover-vehicle"));
when(vehicleService.changeStatus(anyLong(), anyString(), any())).thenThrow(new IllegalStateException("status failed"));
assertThrows(IllegalStateException.class, () -> service.release("TR-ONE", row.handoverNo(), "1",
new Release(0L, "VEHICLE", at.plusSeconds(1), "维修站", "MAINTENANCE", "release-vehicle")));
assertEquals(4, countResources()); assertNull(repository.find(1, row.handoverNo()).vehicleRelease());
}
@Test void currentUnchangedVehicleCannotBeReleasedAsOldResource() {
selection = new Selection(7L, 11L, 22L, end, at);
var row = service.confirm("TR-ONE", "1", command("handover-driver-only"));
assertEquals(3, countResources());
assertThrows(RuntimeException.class, () -> service.release("TR-ONE", row.handoverNo(), "1",
new Release(0L, "VEHICLE", at.plusSeconds(1), "原地", "IN_SERVICE", "release-unchanged")));
}
private Confirm command(String key) {
var preview = service.preview("TR-ONE", selection);
assertTrue(preview.driverQualification().expiringSoon());
return new Confirm(selection, preview.cargoHash(), "交接地点", null, key);
}
private int countResources() { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class); }
private FleetTripEntity state() {
return jdbc.queryForObject("SELECT * FROM trip_state WHERE id=1", (r, ignored) -> {
var trip = new FleetTripEntity(); trip.setId(1L); trip.setTripNo("TR-ONE"); trip.setStatus("IN_TRANSIT"); trip.setWarehouseId(92L);
trip.setVehicleId(r.getLong("vehicle_id")); trip.setVehiclePlate("PLATE-" + r.getLong("vehicle_id"));
trip.setDriverId(r.getLong("driver_id")); trip.setDriverName("司机"); trip.setDriverPhone("phone");
trip.setRecordVersion(r.getLong("record_version")); trip.setDepartedAt(LocalDateTime.ofInstant(at.minusSeconds(3600), ZoneOffset.UTC));
return trip;
});
}
}
@@ -0,0 +1,116 @@
package com.baiyue.lastleg.fleet.exception;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter;
import com.baiyue.lastleg.dispatch.mapper.*;
import com.baiyue.lastleg.dispatch.pda.mapper.*;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeAll;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetTransitEvidenceTest {
@BeforeAll static void initializeDeliveryMetadata() {
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(new com.baomidou.mybatisplus.core.MybatisConfiguration(), "fleet-transit-evidence"),
DeliveryOrder.class);
}
final DispatchOrderMapper orders = mock(DispatchOrderMapper.class);
final OutboundOrderMapper outbounds = mock(OutboundOrderMapper.class);
final DeliveryOrderMapper deliveries = mock(DeliveryOrderMapper.class);
final DeliveryOperationMapper operations = mock(DeliveryOperationMapper.class);
final DispatchPdaWorkflowService workflow = mock(DispatchPdaWorkflowService.class);
final DispatchFleetAdapter adapter = new DispatchFleetAdapter(orders, mock(DispatchPdaTaskMapper.class),
outbounds, deliveries, mock(DispatchItemMapper.class), mock(DispatchPdaPickMapper.class), workflow, operations);
final DispatchOrder dispatch = new DispatchOrder();
final DeliveryOrder delivery = new DeliveryOrder();
FleetTransitEvidenceTest() {
dispatch.setId(11L); dispatch.setDispatchNo("PC-1"); dispatch.setCustomerId("CUSTOMER-A");
dispatch.setOverseasWarehouseId("21"); dispatch.setStatus("DEPARTED"); dispatch.setShippedPackageCount(10);
var outbound = new OutboundOrder(); outbound.setId(31L); outbound.setStatus("OUTBOUNDED"); outbound.setPackageCount(10);
delivery.setId(41L); delivery.setDeliveryNo("DL-1"); delivery.setCustomerId("CUSTOMER-A");
delivery.setDispatchOrderId(11L); delivery.setOutboundOrderId(31L); delivery.setOverseasWarehouseId("21");
delivery.setPackageCount(10); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L);
delivery.setDestinationType("FBA"); delivery.setDestinationWarehouseCode("ONT8");
when(orders.findGlobalByDispatchNo("PC-1")).thenReturn(dispatch);
when(orders.lockGlobalByDispatchNo("PC-1")).thenReturn(dispatch);
when(outbounds.findByDispatch("CUSTOMER-A", 11L)).thenReturn(outbound);
when(outbounds.lockByDispatch("CUSTOMER-A", 11L)).thenReturn(outbound);
when(deliveries.findByOutbound("CUSTOMER-A", 31L)).thenReturn(List.of(delivery));
when(deliveries.lockByOutbound("CUSTOMER-A", 31L)).thenReturn(List.of(delivery));
}
@Test void unresolvedReturnKeepsCargoAndUsesCustomerScopedLocks() {
delivery.setStatus("RETURN_PENDING");
var fact = adapter.transitEvidence("PC-1", true);
assertNull(fact.deliveries().get(0).cargo().terminalAt());
assertEquals("FBA:ONT8", fact.deliveries().get(0).cargo().destinationKey());
var order = inOrder(orders, outbounds, deliveries);
order.verify(orders).lockGlobalByDispatchNo("PC-1");
order.verify(outbounds).lockByDispatch("CUSTOMER-A", 11L);
order.verify(deliveries).lockByOutbound("CUSTOMER-A", 31L);
verifyNoInteractions(workflow);
}
@Test void priorSignatureRemainsTerminalAfterLaterReturnRequest() {
var signed = LocalDateTime.of(2026, 10, 3, 9, 0);
delivery.setStatus("RETURN_PENDING"); delivery.setSignedAt(signed);
var fact = adapter.transitEvidence("PC-1", false);
assertEquals(signed.atZone(ZoneId.systemDefault()).toInstant(), fact.deliveries().get(0).cargo().terminalAt());
verifyNoInteractions(operations, workflow);
}
@Test void unsignedReturnRequiresImmutableReturnOperation() {
delivery.setStatus("RETURNED"); delivery.setUpdatedAt(LocalDateTime.now());
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false));
var returned = LocalDateTime.of(2026, 10, 3, 10, 0);
when(operations.returnedAt("CUSTOMER-A", 41L)).thenReturn(returned);
assertEquals(returned.atZone(ZoneId.systemDefault()).toInstant(),
adapter.transitEvidence("PC-1", false).deliveries().get(0).cargo().terminalAt());
}
@Test void crossCustomerAndUnbalancedFactsFailClosed() {
delivery.setCustomerId("CUSTOMER-B");
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false));
delivery.setCustomerId("CUSTOMER-A"); delivery.setPackageCount(9);
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false));
verifyNoInteractions(workflow);
}
@Test void preDepartureAndCancelledDeliveryCannotBecomeHandoverCargo() {
dispatch.setStatus("READY_TO_DEPART");
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false));
dispatch.setStatus("DEPARTED"); delivery.setStatus("CANCELLED");
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false));
}
@Test void handoverUpdatesOnlyUnfinishedDeliveryAndNeverCallsDeparture() {
var evidence = adapter.transitEvidence("PC-1", true);
when(deliveries.lockByNo("CUSTOMER-A", "DL-1")).thenReturn(delivery);
when(deliveries.update(isNull(), any())).thenReturn(1);
adapter.handover(evidence, new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.Assignment("NEW-PLATE", "接替司机", "phone"));
verify(deliveries).update(isNull(), any());
verify(orders, never()).update(any(), any());
verifyNoInteractions(workflow);
}
@Test void signedCargoStaysOnOriginalAssignmentAndStaleVersionCannotOverwrite() {
delivery.setStatus("DELIVERED"); delivery.setSignedAt(LocalDateTime.of(2026, 10, 3, 9, 0));
var evidence = adapter.transitEvidence("PC-1", true);
var assignment = new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.Assignment("NEW-PLATE", "接替司机", "phone");
adapter.handover(evidence, assignment);
verify(deliveries, never()).update(any(), any());
delivery.setVersion(1L);
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.handover(evidence, assignment));
verifyNoInteractions(workflow);
}
}