feat(fleet): integrate costs quotes operations and in-transit handovers
This commit is contained in:
+61
@@ -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); }
|
||||
}
|
||||
}
|
||||
+74
@@ -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"));
|
||||
}
|
||||
}
|
||||
+98
@@ -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));
|
||||
}
|
||||
}
|
||||
+171
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
+116
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user