feat(fleet): integrate costs quotes operations and in-transit handovers
This commit is contained in:
@@ -34,6 +34,27 @@ class DirectEventClientTest {
|
||||
verifyNoInteractions(firstleg);
|
||||
}
|
||||
|
||||
@Test void companyVehicleCostsUseTheirOwnFinanceEndpoint() {
|
||||
var event = event(EventTypes.COMPANY_VEHICLE_COST_SUBMITTED);
|
||||
var ack = ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED));
|
||||
when(finance.vehicleCost("token", event)).thenReturn(ack);
|
||||
assertThat(client.receive("token", event)).isSameAs(ack);
|
||||
verify(finance).vehicleCost("token", event);
|
||||
verifyNoMoreInteractions(finance);
|
||||
verifyNoInteractions(firstleg);
|
||||
}
|
||||
|
||||
@Test void companyCostValidationConflictsArePermanentButDependencyFailuresRetry() {
|
||||
var event = event(EventTypes.COMPANY_VEHICLE_COST_SUBMITTED);
|
||||
for (String code : new String[]{"FIN_400", "FIN_404", "FIN_409", "FIN_503"}) {
|
||||
when(finance.vehicleCost("token", event)).thenReturn(new ApiResponse<>(code, "rejected", null, null, null, Instant.now()));
|
||||
assertThatThrownBy(() -> client.receive("token", event))
|
||||
.isInstanceOf(com.baiyue.common.web.EventDeliveryFailure.class)
|
||||
.satisfies(error -> assertThat(com.baiyue.common.web.EventDeliveryFailure.isPermanent((Exception) error)).isEqualTo(!"FIN_503".equals(code)));
|
||||
}
|
||||
verifyNoInteractions(firstleg);
|
||||
}
|
||||
|
||||
@Test void fulfillmentEventsAreSentOnlyToFirstleg() {
|
||||
for (String type : new String[]{EventTypes.OVERSEAS_CONTAINER_UNLOADED,
|
||||
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.OVERSEAS_DELIVERY_COMPLETED}) {
|
||||
@@ -57,6 +78,8 @@ class DirectEventClientTest {
|
||||
EventTypes.WALLET_COMMAND_REQUESTED);
|
||||
assertEndpoint(FinanceBusinessEventClient.class, "fee", "FINANCE_SERVICE_URL", "finance",
|
||||
EventTypes.BUSINESS_FEE_SOURCE_CHANGED);
|
||||
assertEndpoint(FinanceBusinessEventClient.class, "vehicleCost", "FINANCE_SERVICE_URL", "finance",
|
||||
EventTypes.COMPANY_VEHICLE_COST_SUBMITTED);
|
||||
}
|
||||
|
||||
private void assertEndpoint(Class<?> type, String method, String env, String property, String eventType) throws Exception {
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.lastleg.financefee.fleet;
|
||||
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
|
||||
import com.baiyue.lastleg.fleet.maintenance.VehicleCareFinanceGateway;
|
||||
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.transaction.IllegalTransactionStateException;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class VehicleCareFinanceAdapterTest {
|
||||
@Test void publicationCannotStartItsOwnTransactionAndConversionDelegatesToTheFeeOwner() {
|
||||
var converter = mock(OverseasFeeCurrencyConverter.class);
|
||||
var outbox = mock(LastlegEventOutboxMapper.class);
|
||||
var jdbc = mock(JdbcTemplate.class);
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:vehicle-adapter-" + UUID.randomUUID(), "sa", "");
|
||||
var adapter = new VehicleCareFinanceAdapter(converter, outbox, jdbc, new ObjectMapper());
|
||||
var proxy = new ProxyFactory(adapter); proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
|
||||
var gateway = (VehicleCareFinanceGateway) proxy.getProxy();
|
||||
assertThatThrownBy(() -> gateway.publish(null)).isInstanceOf(IllegalTransactionStateException.class);
|
||||
verifyNoInteractions(outbox);
|
||||
var date = LocalDate.of(2026, 1, 1);
|
||||
when(converter.convert("USD", BigDecimal.TEN, date)).thenReturn(new OverseasFeeCurrencyConverter.Conversion("USD", new BigDecimal("10.00"), new BigDecimal("7.000000"), "2026-01", new BigDecimal("70.00")));
|
||||
assertThat(gateway.convert("USD", BigDecimal.TEN, date)).isEqualTo(new VehicleCareFinanceGateway.Conversion("USD", new BigDecimal("10.00"), new BigDecimal("7.000000"), "2026-01", new BigDecimal("70.00")));
|
||||
verify(converter).convert("USD", BigDecimal.TEN, date);
|
||||
}
|
||||
@Test void deliveryStatusReadsAreBoundedAndKeepActualOutboxState() {
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:vehicle-delivery-" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "");
|
||||
var jdbc = new JdbcTemplate(ds);
|
||||
jdbc.execute("CREATE TABLE ll_event_outbox(event_key VARCHAR(128),status VARCHAR(32))");
|
||||
jdbc.update("INSERT INTO ll_event_outbox VALUES('one','PENDING'),('two','DEAD'),('other','PUBLISHED')");
|
||||
var adapter = new VehicleCareFinanceAdapter(mock(OverseasFeeCurrencyConverter.class), mock(LastlegEventOutboxMapper.class), jdbc, new ObjectMapper());
|
||||
assertThat(adapter.deliveryStates(List.of())).isEmpty();
|
||||
assertThat(adapter.deliveryStates(List.of("one", "two"))).containsExactlyInAnyOrderEntriesOf(Map.of("one", "PENDING", "two", "DEAD"));
|
||||
assertThatThrownBy(() -> adapter.deliveryStates(Collections.nCopies(101, "one"))).hasMessageContaining("最多100");
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package com.baiyue.lastleg.fleet.maintenance;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter;
|
||||
import com.baiyue.lastleg.financefee.fleet.VehicleCareFinanceAdapter;
|
||||
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
|
||||
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.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
class VehicleCareFinancePersistenceTest {
|
||||
private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
private JdbcTemplate jdbc;
|
||||
private VehicleCareFinanceService service;
|
||||
private VehicleCareMapper records;
|
||||
private VehicleCareFinanceClient finance;
|
||||
private VehicleCareSupplierClient suppliers;
|
||||
private VehicleCareEntity row;
|
||||
@BeforeEach void setup() throws Exception {
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:care-finance-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
jdbc = new JdbcTemplate(ds);
|
||||
jdbc.execute("CREATE TABLE ll_fleet_finance_source(id BIGINT PRIMARY KEY,record_id BIGINT NOT NULL,source_version BIGINT NOT NULL,record_version BIGINT NOT NULL,operator_id VARCHAR(64) NOT NULL,request_key VARCHAR(128) NOT NULL,request_hash VARCHAR(71) NOT NULL,event_id VARCHAR(128) NOT NULL,payload_json CLOB NOT NULL,created_at TIMESTAMP(3),UNIQUE(record_id,source_version),UNIQUE(record_id,operator_id,request_key),UNIQUE(event_id))");
|
||||
jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(128),event_type VARCHAR(64),event_version INT,event_key VARCHAR(255) UNIQUE,payload_json CLOB,status VARCHAR(32),retry_count INT,next_retry_at TIMESTAMP(3),published_at TIMESTAMP(3),locked_at TIMESTAMP(3),locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP(3))");
|
||||
var config = new MybatisConfiguration(); config.addMapper(LastlegEventOutboxMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config);
|
||||
var outbox = new SqlSessionTemplate(factory.getObject()).getMapper(LastlegEventOutboxMapper.class);
|
||||
records = mock(VehicleCareMapper.class); finance = mock(VehicleCareFinanceClient.class); suppliers = mock(VehicleCareSupplierClient.class);
|
||||
row = new VehicleCareEntity(); row.setId(1L); row.setVehicleId(71L); row.setVehiclePlate("VEH-A"); row.setRecordNo("VR-ONE"); row.setRecordType("REPAIR"); row.setRecordVersion(0L); row.setDetailsJson(json.writeValueAsString(draft("10", "PENDING")));
|
||||
when(records.lock(71, "VR-ONE")).thenReturn(row);
|
||||
when(records.findFinanceRecords(71, List.of("VR-ONE"))).thenReturn(List.of(row));
|
||||
when(finance.query(anyString(), any())).thenReturn(ApiResponse.success(List.of()));
|
||||
when(suppliers.get("test-token", 8, true)).thenReturn(ApiResponse.success(new VehicleCareSupplierClient.Supplier(8, "维修供应商", null, "ACTIVE")));
|
||||
var converter = mock(OverseasFeeCurrencyConverter.class);
|
||||
when(converter.convert(eq("USD"), any(), eq(LocalDate.of(2026, 1, 1)))).thenAnswer(call -> {
|
||||
BigDecimal amount = call.getArgument(1);
|
||||
return new OverseasFeeCurrencyConverter.Conversion("USD", amount.setScale(2), new BigDecimal("7.000000"), "2026-01", amount.multiply(new BigDecimal("7")).setScale(2));
|
||||
});
|
||||
var adapterProxy = new ProxyFactory(new VehicleCareFinanceAdapter(converter, outbox, jdbc, json)); adapterProxy.setProxyTargetClass(true);
|
||||
adapterProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
|
||||
var accounting = (VehicleCareFinanceGateway) adapterProxy.getProxy();
|
||||
var target = new VehicleCareFinanceService(records, jdbc, json, accounting, finance, suppliers, "test-token");
|
||||
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
|
||||
service = (VehicleCareFinanceService) proxy.getProxy();
|
||||
}
|
||||
private static VehicleCareModels.Draft draft(String amount, String processing) {
|
||||
return new VehicleCareModels.Draft("REPAIR", Instant.parse("2026-01-01T00:00:00Z"), null, null, null, null, null, null, null, null,
|
||||
new VehicleCareModels.Incident("轮胎损坏", "换轮胎", "维修站", "工作人员", processing, null, new BigDecimal(amount), "USD"));
|
||||
}
|
||||
private static VehicleCareFinanceModels.Submit command(String key) { return new VehicleCareFinanceModels.Submit(0L, 8L, LocalDate.of(2026, 1, 1), key); }
|
||||
private VehicleCareFinanceModels.Source source() throws Exception {
|
||||
return json.readValue(jdbc.queryForObject("SELECT payload_json FROM ll_fleet_finance_source ORDER BY source_version DESC LIMIT 1", String.class), VehicleCareFinanceModels.Source.class);
|
||||
}
|
||||
private void remote(String status) throws Exception {
|
||||
var source = source();
|
||||
when(finance.query(anyString(), any())).thenReturn(ApiResponse.success(List.of(new VehicleCareFinanceModels.Cost("VC-ONE", "PLATFORM_DEFAULT", source, "维修供应商", "CNY", status,
|
||||
"PAID".equals(status) ? source.amount() : BigDecimal.ZERO, "PAID".equals(status) ? BigDecimal.ZERO : source.amount(), 1))));
|
||||
}
|
||||
@Test void submitWritesImmutableSourceAndOutboxTogetherAndUnknownResponseReplaysTheSameEvent() throws Exception {
|
||||
var first = service.submit(71, "VR-ONE", "1", command("request-one"));
|
||||
var replay = service.submit(71, "VR-ONE", "1", command("request-one"));
|
||||
assertThat(first.status()).isEqualTo("PENDING_FINANCE"); assertThat(replay.replayed()).isTrue();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox", Integer.class)).isEqualTo(1);
|
||||
var envelope = json.readValue(jdbc.queryForObject("SELECT payload_json FROM ll_event_outbox", String.class), EventEnvelope.class);
|
||||
assertThat(envelope.eventType()).isEqualTo(EventTypes.COMPANY_VEHICLE_COST_SUBMITTED);
|
||||
assertThat(envelope.tenantId()).isEqualTo("0"); assertThat(envelope.payload().has("customerId")).isFalse();
|
||||
assertThat(source().amount()).isEqualByComparingTo("70.00");
|
||||
verify(finance, times(1)).query(anyString(), any());
|
||||
assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", new VehicleCareFinanceModels.Submit(0L, 9L, LocalDate.of(2026,1,1), "request-one"))).hasMessageContaining("其他内容");
|
||||
}
|
||||
@Test void outboxFailureRollsBackTheSourceAndAllowsTheOriginalRequestToRetry() {
|
||||
jdbc.execute("ALTER TABLE ll_event_outbox ADD CONSTRAINT reject_pending CHECK(status<>'PENDING')");
|
||||
assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", command("request-one"))).isInstanceOf(RuntimeException.class);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isZero();
|
||||
jdbc.execute("ALTER TABLE ll_event_outbox DROP CONSTRAINT reject_pending");
|
||||
assertThat(service.submit(71, "VR-ONE", "1", command("request-one")).sourceVersion()).isEqualTo(1);
|
||||
}
|
||||
@Test void statusesDistinguishUnlinkedDeliveryFailureAndRealFinancePayment() throws Exception {
|
||||
assertThat(service.statuses(71, List.of("VR-ONE")).get(0).status()).isEqualTo("UNLINKED");
|
||||
verifyNoInteractions(finance);
|
||||
service.submit(71, "VR-ONE", "1", command("request-one"));
|
||||
assertThat(service.statuses(71, List.of("VR-ONE")).get(0).status()).isEqualTo("PENDING_FINANCE");
|
||||
jdbc.update("UPDATE ll_event_outbox SET status='DEAD'");
|
||||
assertThat(service.statuses(71, List.of("VR-ONE")).get(0).status()).isEqualTo("FINANCE_ATTENTION");
|
||||
remote("PAID");
|
||||
var status = service.statuses(71, List.of("VR-ONE")).get(0);
|
||||
assertThat(status.status()).isEqualTo("PAID"); assertThat(status.finance().paidAmount()).isEqualByComparingTo("70");
|
||||
}
|
||||
@Test void submittedAmountsStayFrozenUntilFinancialVoidButProcessingUpdatesContinue() throws Exception {
|
||||
service.submit(71, "VR-ONE", "1", command("request-one")); remote("PENDING");
|
||||
assertThatThrownBy(() -> service.guardFinancialEdit(row, draft("20", "PENDING"))).hasMessageContaining("先在财务作废");
|
||||
clearInvocations(finance);
|
||||
service.guardFinancialEdit(row, draft("10", "COMPLETED")); verifyNoInteractions(finance);
|
||||
remote("PAID"); assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", command("request-two"))).hasMessageContaining("不可改写");
|
||||
remote("VOIDED"); service.guardFinancialEdit(row, draft("20", "PENDING"));
|
||||
row.setDetailsJson(json.writeValueAsString(draft("20", "PENDING")));
|
||||
assertThat(service.submit(71, "VR-ONE", "1", command("request-two")).sourceVersion()).isEqualTo(2);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isEqualTo(2);
|
||||
}
|
||||
@Test void wrongVehicleStaleVersionAndFinanceFailureCannotPretendToSubmit() {
|
||||
assertThatThrownBy(() -> service.submit(72, "VR-ONE", "1", command("request-one"))).hasMessageContaining("不存在");
|
||||
assertThatThrownBy(() -> service.statuses(72, List.of("VR-ONE"))).hasMessageContaining("不存在");
|
||||
assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", new VehicleCareFinanceModels.Submit(1L,8L,LocalDate.of(2026,1,1),"request-one"))).hasMessageContaining("已变化");
|
||||
when(finance.query(anyString(), any())).thenReturn(null);
|
||||
assertThatThrownBy(() -> service.submit(71, "VR-ONE", "1", command("request-one"))).hasMessageContaining("未确认入账或付款");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_finance_source", Integer.class)).isZero();
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -50,7 +50,7 @@ class VehicleCarePersistenceTest {
|
||||
files = mock(VehicleCareFileGateway.class);
|
||||
when(files.upload(anyLong(), anyString(), any())).thenReturn(new VehicleCareModels.File("9001", "photo.png", "image/png", 8));
|
||||
var target = new VehicleCareService(session.getMapper(VehicleCareMapper.class), session.getMapper(VehicleCareHistoryMapper.class), vehicles, files,
|
||||
new ObjectMapper().registerModule(new JavaTimeModule()));
|
||||
new ObjectMapper().registerModule(new JavaTimeModule()), mock(VehicleCareFinanceService.class));
|
||||
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource()));
|
||||
service = (VehicleCareService) proxy.getProxy();
|
||||
@@ -128,7 +128,7 @@ class VehicleCarePersistenceTest {
|
||||
if (List.of("REPAIR", "RESCUE").contains(type)) {
|
||||
assertThat(row.details().incident().item()).isEqualTo("更换轮胎");
|
||||
assertThat(row.details().incident().place()).isEqualTo("实际作业地点");
|
||||
assertThat(row.paymentStatus()).isEqualTo("UNLINKED");
|
||||
assertThat(row.paymentStatus()).isNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,7 @@ class VehicleCarePersistenceTest {
|
||||
var started = service.update(vehicleA, created.recordNo(), "2", new VehicleCareModels.Update(0L, "已安排维修", incident("REPAIR", "IN_PROGRESS", "维修人员", new BigDecimal("120.50"), "USD")));
|
||||
var completed = service.update(vehicleA, created.recordNo(), "2", new VehicleCareModels.Update(started.recordVersion(), "现场处理完成", incident("REPAIR", "COMPLETED", "维修人员", BigDecimal.ZERO, "USD")));
|
||||
assertThat(completed.details().incident().reportedCost()).isEqualByComparingTo("0");
|
||||
assertThat(completed.paymentStatus()).isEqualTo("UNLINKED");
|
||||
assertThat(completed.paymentStatus()).isNull();
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM ll_vehicle WHERE id=?", String.class, vehicleA)).isEqualTo("IN_SERVICE");
|
||||
var history = service.history(vehicleA, created.recordNo(), 1, 20).records();
|
||||
assertThat(history).hasSize(3);
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.lastleg.fleet.quote;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FleetQuoteDirectoryTest {
|
||||
@Test void saleUsesCustomerAndPurchaseRequiresAnActiveOverseasTruckingSupplier() {
|
||||
var customers=mock(FleetCustomerDirectory.class);var suppliers=mock(FleetQuoteSupplierClient.class);
|
||||
var directory=new FleetQuoteDirectory(customers,suppliers,"test-only-token");
|
||||
when(customers.required(1)).thenReturn(new FleetCustomerDirectory.Customer("1","C001","委托客户",null,null));
|
||||
assertThat(directory.name("SALE",1)).isEqualTo("委托客户");verifyNoInteractions(suppliers);
|
||||
when(suppliers.get("test-only-token",2,true,"TRUCKING")).thenReturn(ApiResponse.success(new FleetQuoteSupplierClient.Supplier(2,"国内拖车","TRUCKING","ACTIVE",List.of(),"DOMESTIC")));
|
||||
assertThatThrownBy(()->directory.name("PURCHASE",2)).hasMessageContaining("海外车队");
|
||||
when(suppliers.get("test-only-token",2,true,"TRUCKING")).thenReturn(ApiResponse.success(new FleetQuoteSupplierClient.Supplier(2,"海外车队","TRUCKING","ACTIVE",List.of(),"OVERSEAS")));
|
||||
assertThat(directory.name("PURCHASE",2)).isEqualTo("海外车队");
|
||||
}
|
||||
@Test void unavailableDirectoryDoesNotCreateFakeNames() {
|
||||
var suppliers=mock(FleetQuoteSupplierClient.class);var directory=new FleetQuoteDirectory(mock(FleetCustomerDirectory.class),suppliers,"test-only-token");
|
||||
when(suppliers.get(anyString(),anyLong(),anyBoolean(),anyString())).thenThrow(new IllegalStateException("unavailable"));
|
||||
assertThatThrownBy(()->directory.name("PURCHASE",2)).hasMessageContaining("目录暂不可用");
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.baiyue.lastleg.fleet.quote;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.*;
|
||||
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.nio.file.*;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FleetQuotePersistenceTest {
|
||||
private JdbcTemplate jdbc; private FleetQuoteService service;
|
||||
@BeforeEach void setup() throws Exception {
|
||||
var source=new DriverManagerDataSource("jdbc:h2:mem:quotes-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
jdbc=new JdbcTemplate(source);
|
||||
String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
|
||||
for(String table:List.of("ll_fleet_quote","ll_fleet_quote_history")) {
|
||||
var match=Pattern.compile("(?s)CREATE TABLE "+table+" \\(.*?;").matcher(schema);
|
||||
assertThat(match.find()).as("总控集成表结构 %s",table).isTrue();jdbc.execute(match.group().replace(" JSON"," LONGTEXT"));
|
||||
}
|
||||
var directory=mock(FleetQuoteDirectory.class);when(directory.name(anyString(),anyLong())).thenReturn("正式往来单位");
|
||||
var target=new FleetQuoteService(new FleetQuoteRepository(jdbc),directory,new ObjectMapper().registerModule(new JavaTimeModule()));
|
||||
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source),new AnnotationTransactionAttributeSource()));
|
||||
service=(FleetQuoteService)proxy.getProxy();
|
||||
}
|
||||
private FleetQuoteModels.Draft draft(String kind,String city,BigDecimal price) {
|
||||
return new FleetQuoteModels.Draft(kind,1L,city,"ont8",new BigDecimal("10.0"),"USD",price,null,null,LocalDate.of(2026,1,1),LocalDate.of(2026,12,31),null);
|
||||
}
|
||||
@Test void nullPriceDiffersFromZeroAndSalesNeverAliasPurchase() {
|
||||
assertThatThrownBy(()->service.create("1",new FleetQuoteModels.Create("invalid-key",draft("SALE","LA",null)))).hasMessageContaining("至少填写");
|
||||
var sale=service.create("1",new FleetQuoteModels.Create("sale-key",draft("SALE","LA",BigDecimal.ZERO)));
|
||||
var purchase=service.create("1",new FleetQuoteModels.Create("purchase-key",draft("PURCHASE","LA",BigDecimal.TEN)));
|
||||
assertThat(sale.details().selfPickupPrice()).isEqualByComparingTo("0");assertThat(sale.details().fullDeliveryPrice()).isNull();
|
||||
assertThat(purchase.quoteNo()).isNotEqualTo(sale.quoteNo());assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_quote",Integer.class)).isEqualTo(2);
|
||||
}
|
||||
@Test void replayAndCorrectionKeepVersionsAndRejectStaleWrites() {
|
||||
var request=new FleetQuoteModels.Create("stable-key",draft("SALE","LA",BigDecimal.TEN));var first=service.create("1",request);
|
||||
assertThat(service.create("1",request).quoteNo()).isEqualTo(first.quoteNo());
|
||||
assertThatThrownBy(()->service.create("1",new FleetQuoteModels.Create("stable-key",draft("SALE","LA",BigDecimal.ONE)))).hasMessageContaining("不同报价");
|
||||
service.update(first.quoteNo(),"1",new FleetQuoteModels.Update(0L,draft("SALE","LA",BigDecimal.ONE)));
|
||||
assertThat(service.create("1",request).details().selfPickupPrice()).isEqualByComparingTo("10");
|
||||
assertThat(service.history(first.quoteNo())).hasSize(2);assertThat(service.history(first.quoteNo()).get(1).snapshot().details().selfPickupPrice()).isEqualByComparingTo("10");
|
||||
assertThatThrownBy(()->service.status(first.quoteNo(),"1",new FleetQuoteModels.Status(0L,"INACTIVE"))).hasMessageContaining("已变化");
|
||||
}
|
||||
@Test void failedAtomicImportLeavesNoRowsAndChangedBatchCannotReuseKey() {
|
||||
var row=draft("SALE","LA",BigDecimal.ONE);
|
||||
assertThatThrownBy(()->service.importRows("1",new FleetQuoteModels.Import("batch-key",List.of(row,row)))).hasMessageContaining("重复");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_quote",Integer.class)).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_quote_history",Integer.class)).isZero();
|
||||
service.importRows("1",new FleetQuoteModels.Import("batch-key",List.of(row)));
|
||||
assertThatThrownBy(()->service.importRows("1",new FleetQuoteModels.Import("batch-key",List.of(row,draft("SALE","SF",BigDecimal.ONE))))).hasMessageContaining("不同报价");
|
||||
}
|
||||
@Test void deletePreservesHistoryAndExportEscapesSpreadsheetFormulas() {
|
||||
var created=service.create("1",new FleetQuoteModels.Create("formula-key",draft("SALE","=2+2",BigDecimal.ZERO)));
|
||||
assertThat(new String(service.exportCsv(new FleetQuoteModels.Query(1,20,null,null,null,null)),java.nio.charset.StandardCharsets.UTF_8)).contains("'=2+2");
|
||||
service.delete(created.quoteNo(),"1",0);assertThat(service.page(new FleetQuoteModels.Query(1,20,null,null,null,null)).total()).isZero();
|
||||
assertThat(service.history(created.quoteNo())).hasSize(2);
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.baiyue.lastleg.fleet.reporting;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.*;
|
||||
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.nio.file.*;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class TripOperationsRecordPersistenceTest {
|
||||
private JdbcTemplate jdbc;private TripOperationsRecordRepository repository;private TripOperationsRecordService service;
|
||||
@BeforeEach void setup() throws Exception {
|
||||
var source=new DriverManagerDataSource("jdbc:h2:mem:trip-measured-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");jdbc=new JdbcTemplate(source);
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY,status VARCHAR(32),departed_at DATETIME)");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip VALUES('TR-A','IN_TRANSIT','2026-10-01 00:00:00'),('TR-B','PLANNED',NULL)");
|
||||
var match=Pattern.compile("(?s)CREATE TABLE ll_fleet_trip_operations_record \\(.*?;").matcher(Files.readString(Path.of("src/main/resources/db/lastleg.sql")));
|
||||
assertThat(match.find()).as("总控集成实测记录表").isTrue();jdbc.execute(match.group().replace(" JSON"," LONGTEXT"));
|
||||
repository=spy(new TripOperationsRecordRepository(jdbc));var target=new TripOperationsRecordService(repository,new ObjectMapper().registerModule(new JavaTimeModule()));
|
||||
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source),new AnnotationTransactionAttributeSource()));service=(TripOperationsRecordService)proxy.getProxy();
|
||||
}
|
||||
private TripOperationsRecordModels.Save request(long version,String key,BigDecimal distance,BigDecimal fuel){return new TripOperationsRecordModels.Save(version,key,new TripOperationsRecordModels.Details(distance,fuel,null),null);}
|
||||
@Test void onlyDepartedTripsCanBeMeasuredAndMissingValuesAreNotZero() {
|
||||
assertThat(service.get("TR-A").details().actualDistanceMiles()).isNull();assertThat(service.get("TR-A").recordVersion()).isZero();
|
||||
assertThatThrownBy(()->service.save("TR-B","1",request(0,"before-depart",BigDecimal.ONE,null))).hasMessageContaining("实际已发车");
|
||||
var saved=service.save("TR-A","1",request(0,"save-zero",BigDecimal.ZERO,null));
|
||||
assertThat(saved.details().actualDistanceMiles()).isEqualByComparingTo("0");assertThat(saved.details().actualFuelLitres()).isNull();
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM ll_fleet_trip WHERE trip_no='TR-A'",String.class)).isEqualTo("IN_TRANSIT");
|
||||
}
|
||||
@Test void replayReturnsOriginalSnapshotAndStaleUpdatesCannotOverwriteNewFacts() {
|
||||
var first=request(0,"first-key",new BigDecimal("10.0"),BigDecimal.ONE);service.save("TR-A","1",first);
|
||||
service.save("TR-A","1",request(1,"second-key",new BigDecimal("20"),BigDecimal.TEN));
|
||||
assertThat(service.save("TR-A","1",first).details().actualDistanceMiles()).isEqualByComparingTo("10");
|
||||
assertThatThrownBy(()->service.save("TR-A","1",request(1,"stale-key",BigDecimal.ONE,null))).hasMessageContaining("已变化");
|
||||
assertThatThrownBy(()->service.save("TR-A","1",request(0,"first-key",BigDecimal.ONE,null))).hasMessageContaining("其他实测内容");
|
||||
assertThat(service.history("TR-A",1,1).total()).isEqualTo(2);assertThat(service.get("TR-A").details().actualDistanceMiles()).isEqualByComparingTo("20");
|
||||
}
|
||||
@Test void unchangedFactsDoNotInventExtraHistoryAndExplicitClearIsAudited() {
|
||||
service.save("TR-A","1",request(0,"create-key",new BigDecimal("10.0"),null));
|
||||
assertThat(service.save("TR-A","1",request(1,"no-change",new BigDecimal("10.00"),null)).recordVersion()).isEqualTo(1);
|
||||
service.save("TR-A","1",request(1,"clear-key",null,null));
|
||||
assertThat(service.history("TR-A",1,20).total()).isEqualTo(2);assertThat(service.get("TR-A").details().actualDistanceMiles()).isNull();
|
||||
}
|
||||
@Test void failedAppendRollsBackAllMeasuredFacts() {
|
||||
doAnswer(invocation->{invocation.callRealMethod();throw new IllegalStateException("audit-failure");}).when(repository).append(anyLong(),anyString(),anyString(),anyString(),any());
|
||||
assertThatThrownBy(()->service.save("TR-A","1",request(0,"failure-key",BigDecimal.ONE,null))).hasMessageContaining("audit-failure");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_operations_record",Integer.class)).isZero();
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.baiyue.lastleg.fleet.reporting;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetTripCostService;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetTripCostModels;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class TripOperationsServiceTest {
|
||||
static final String DEPARTURE="""
|
||||
{"trip":{"tripNo":"TR-A","departedAt":"2026-10-01T00:00:00Z","vehiclePlate":"OLD-PLATE","driverName":"发车司机","warehouseName":"起运仓"},
|
||||
"confirmedLoad":{"cargo":[
|
||||
{"dispatchNo":"D-A","customerId":"1","dispatchItemId":"I1","warehousePalletId":"P1","packages":10,"destinationKey":"FBA:ONT8","destinationLabel":"ONT8"},
|
||||
{"dispatchNo":"D-B","customerId":"2","dispatchItemId":"I2","warehousePalletId":"P1","packages":5,"destinationKey":"FBA:LAX9","destinationLabel":"LAX9"}],
|
||||
"pallets":[{"warehousePalletId":"P1","palletSpaces":2,"weightKg":100,"volumeCbm":2,"deliveryIndex":1}]},
|
||||
"externalBookings":[{"bookingNo":"FB1","status":"BOUND","customerId":"1","dispatchNo":"D-A","agreedAmount":0,"cargo":{"currency":"USD"},
|
||||
"receipt":{"source":{"receivedPackages":4,"pallets":[{"warehousePalletId":"P1","packages":4}]}}}]}
|
||||
""";
|
||||
@Test void mixedCustomersDestinationsAndCargoCountOneDepartureAndOnePhysicalPallet() {
|
||||
var repo=mock(TripOperationsRepository.class);var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class));
|
||||
var query=new TripOperationsModels.Query(1,1,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,null,null);
|
||||
when(repo.departures(query)).thenReturn(List.of(new TripOperationsRepository.Departure("TR-A","COMPLETED",Instant.parse("2026-10-02T00:00:00Z"),DEPARTURE,null)));
|
||||
var result=service.report(query,false);assertThat(result.summary().trips()).isEqualTo(1);assertThat(result.summary().physicalPallets()).isEqualTo(1);assertThat(result.summary().palletSpaces()).isEqualTo(2);
|
||||
assertThat(result.summary().externalPackages()).isEqualTo(4);assertThat(result.summary().ownPackages()).isEqualTo(11);
|
||||
var row=result.page().records().get(0);assertThat(row.vehiclePlate()).isEqualTo("OLD-PLATE");assertThat(row.financeStatus()).isEqualTo("NOT_QUERIED");assertThat(row.costStatus()).isEqualTo("FORBIDDEN");
|
||||
assertThat(row.externalAgreedAmounts().get(0).amount()).isEqualByComparingTo("0");verify(repo,times(1)).departures(query);
|
||||
}
|
||||
@Test void missingSnapshotAndOverstatedExternalQuantityFailInsteadOfInventingZero() {
|
||||
var service=new TripOperationsService(mock(TripOperationsRepository.class),new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class));
|
||||
assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,null,null))).hasMessageContaining("不可变历史");
|
||||
assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,DEPARTURE.replace("\"receivedPackages\":4","\"receivedPackages\":20").replace("\"packages\":4","\"packages\":20"),null))).hasMessageContaining("超过实际发车");
|
||||
}
|
||||
@Test void summaryUsesAllFilteredDeparturesNotCurrentPageAndSeparatesCurrencies() {
|
||||
var repo=mock(TripOperationsRepository.class);var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class));
|
||||
var query=new TripOperationsModels.Query(1,1,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,"EXTERNAL",null);
|
||||
when(repo.departures(query)).thenReturn(List.of(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,DEPARTURE,null),new TripOperationsRepository.Departure("TR-B","IN_TRANSIT",null,DEPARTURE.replace("USD","CNY").replace("TR-A","TR-B"),null)));
|
||||
var result=service.report(query,false);assertThat(result.page().records()).hasSize(1);assertThat(result.summary().trips()).isEqualTo(2);assertThat(result.summary().externalAgreedAmounts()).hasSize(2);
|
||||
assertThat(result.charts().physicalPallets().get(0).points().get(0).value()).isEqualByComparingTo("2");
|
||||
assertThat(result.charts().destinationTrips().get(0).points()).hasSize(2);
|
||||
assertThat(result.charts().externalPalletRates()).isEmpty();
|
||||
}
|
||||
@Test void perPalletChartUsesWeightedConfirmedQuantityAndExcludesOrderPricing() {
|
||||
var service=new TripOperationsService(mock(TripOperationsRepository.class),new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class));
|
||||
String a=DEPARTURE.replace("\"agreedAmount\":0","\"agreedAmount\":20").replace("\"currency\":\"USD\"","\"currency\":\"USD\",\"priceBasis\":\"PALLET\",\"palletSpaces\":2");
|
||||
String b=a.replace("\"agreedAmount\":20","\"agreedAmount\":10").replace("\"palletSpaces\":2","\"palletSpaces\":8");
|
||||
String order=DEPARTURE.replace("\"agreedAmount\":0","\"agreedAmount\":100").replace("\"currency\":\"USD\"","\"currency\":\"USD\",\"priceBasis\":\"ORDER\"");
|
||||
var charts=TripOperationsCharts.from(List.of(service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,a,null)),
|
||||
service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,b,null)),service.project(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,order,null))));
|
||||
assertThat(charts.externalPalletRates().get(0).points().get(0).value()).isEqualByComparingTo("3.00");
|
||||
assertThat(charts.externalAgreedAmounts().get(0).points().get(0).value()).isEqualByComparingTo("130");
|
||||
}
|
||||
@Test void costsComeFromOneBatchAndUnknownAllocationsNeverBecomeZero() {
|
||||
var repo=mock(TripOperationsRepository.class);var costs=mock(FleetTripCostService.class);
|
||||
var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),costs);
|
||||
var query=new TripOperationsModels.Query(1,20,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,null,null);
|
||||
when(repo.departures(query)).thenReturn(List.of(new TripOperationsRepository.Departure("TR-A","IN_TRANSIT",null,DEPARTURE,null),new TripOperationsRepository.Departure("TR-B","IN_TRANSIT",null,DEPARTURE.replace("TR-A","TR-B"),null)));
|
||||
var registered=new FleetTripCostModels.Summary(1L,"hash",null,"各票卸货站不同",List.of(new FleetTripCostModels.Total("USD",new java.math.BigDecimal("10.00"),List.of(),null,null)));
|
||||
var empty=new FleetTripCostModels.Summary(1L,"hash",null,"各票卸货站不同",List.of());
|
||||
when(costs.summaries(List.of("TR-A","TR-B"))).thenReturn(java.util.Map.of("TR-A",registered,"TR-B",empty));
|
||||
var report=service.report(query,true);verify(costs,times(1)).summaries(List.of("TR-A","TR-B"));
|
||||
assertThat(report.summary().costs().unrecordedTrips()).isEqualTo(1);assertThat(report.summary().costs().unallocatedTrips()).isEqualTo(1);
|
||||
assertThat(report.summary().costs().totals().get(0).directCost()).isEqualByComparingTo("10.00");assertThat(report.summary().costs().totals().get(0).ownCost()).isNull();
|
||||
assertThat(report.page().records().get(1).costStatus()).isEqualTo("NOT_RECORDED");
|
||||
clearInvocations(costs);assertThat(service.report(query,false).summary().costs().visible()).isFalse();verifyNoInteractions(costs);
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.*;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
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.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FleetTripCostContractTest {
|
||||
private static final String READ="platform:fleet:trip:list", CREATE="platform:fleet:trip:cost-create", EDIT="platform:fleet:trip:cost-edit";
|
||||
private void login(String... permissions) {
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("test", "unused",
|
||||
Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList()));
|
||||
}
|
||||
@Test void readingDoesNotGrantCostWritesAndPlanEditingDoesNotGrantCostEditing() {
|
||||
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller = context.getBean(PlatformFleetTripCostController.class);
|
||||
var service = context.getBean(FleetTripCostService.class);
|
||||
var user = mock(AuthenticatedUser.class); when(user.userId()).thenReturn(8L);
|
||||
var query = new FleetTripCostModels.Query(1, 10);
|
||||
var create = new FleetTripCostModels.Create("test-key", null);
|
||||
var update = new FleetTripCostModels.Update(0L, null, null);
|
||||
login(READ, "platform:fleet:trip:edit");
|
||||
controller.list("TR-ONE", query); controller.history("TR-ONE", "TC-ONE", query);
|
||||
assertThatThrownBy(() -> controller.create("TR-ONE", user, create)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.update("TR-ONE", "TC-ONE", user, update)).isInstanceOf(AccessDeniedException.class);
|
||||
login(CREATE); controller.create("TR-ONE", user, create);
|
||||
assertThatThrownBy(() -> controller.update("TR-ONE", "TC-ONE", user, update)).isInstanceOf(AccessDeniedException.class);
|
||||
login(EDIT); controller.update("TR-ONE", "TC-ONE", user, update);
|
||||
assertThatThrownBy(() -> controller.create("TR-ONE", user, create)).isInstanceOf(AccessDeniedException.class);
|
||||
login(); assertThatThrownBy(() -> controller.list("TR-ONE", query)).isInstanceOf(AccessDeniedException.class);
|
||||
verify(service).list("TR-ONE", query); verify(service).history("TR-ONE", "TC-ONE", query);
|
||||
verify(service).create("TR-ONE", "8", create); verify(service).update("TR-ONE", "TC-ONE", "8", update);
|
||||
verifyNoMoreInteractions(service);
|
||||
} finally { SecurityContextHolder.clearContext(); }
|
||||
}
|
||||
@Test void everyPublicOperationAndPayloadFieldHasAnExplicitContract() throws Exception {
|
||||
Map<?,?> document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-trip-costs-v1.yaml")));
|
||||
Map<?,?> paths = (Map<?,?>) document.get("paths");
|
||||
String base = PlatformFleetTripCostController.class.getAnnotation(RequestMapping.class).value()[0];
|
||||
for (var method : PlatformFleetTripCostController.class.getDeclaredMethods()) {
|
||||
String verb, suffix;
|
||||
if (method.isAnnotationPresent(GetMapping.class)) { verb="get"; var values=method.getAnnotation(GetMapping.class).value(); suffix=values.length==0 ? "" : values[0]; }
|
||||
else if (method.isAnnotationPresent(PostMapping.class)) { verb="post"; suffix=""; }
|
||||
else if (method.isAnnotationPresent(PutMapping.class)) { verb="put"; suffix=method.getAnnotation(PutMapping.class).value()[0]; }
|
||||
else continue;
|
||||
Map<?,?> operation = (Map<?,?>) ((Map<?,?>) paths.get(base+suffix)).get(verb);
|
||||
String expression = method.getAnnotation(PreAuthorize.class).value();
|
||||
for (String permission : List.of(READ, CREATE, EDIT)) if (expression.contains(permission))
|
||||
assertThat(operation.get("description").toString()).contains(permission);
|
||||
}
|
||||
Map<?,?> schemas = (Map<?,?>) ((Map<?,?>) document.get("components")).get("schemas");
|
||||
for (var type : List.of(FleetTripCostModels.Cost.class, FleetTripCostModels.Draft.class, FleetTripCostModels.Allocation.class,
|
||||
FleetTripCostModels.Summary.class, FleetTripCostModels.Total.class, FleetTripCostModels.Listing.class,
|
||||
FleetTripCostModels.Snapshot.class, FleetTripCostModels.History.class)) {
|
||||
Map<?,?> properties = (Map<?,?>) ((Map<?,?>) schemas.get(type.getSimpleName())).get("properties");
|
||||
assertThat(properties.keySet().stream().map(Object::toString).toList()).containsExactlyInAnyOrder(
|
||||
Arrays.stream(type.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName).toArray(String[]::new));
|
||||
}
|
||||
assertThat(FleetTripCostService.class.getMethod("list", String.class, FleetTripCostModels.Query.class).getAnnotation(Transactional.class).readOnly()).isTrue();
|
||||
}
|
||||
@Configuration @EnableMethodSecurity static class Config {
|
||||
@Bean FleetTripCostService service() { return mock(FleetTripCostService.class); }
|
||||
@Bean PlatformFleetTripCostController controller(FleetTripCostService service) { return new PlatformFleetTripCostController(service); }
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
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.nio.file.*;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.regex.Pattern;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
class FleetTripCostPersistenceTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private FleetTripCostRepository repository;
|
||||
private FleetTripCostService service;
|
||||
private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
private static final FleetTripCostModels.Query PAGE = new FleetTripCostModels.Query(1, 10);
|
||||
static final String DEPARTURE = """
|
||||
{"confirmedLoad":{"manifestHash":"frozen-departure", "cargo":[
|
||||
{"dispatchNo":"DS-A","customerId":"1","dispatchItemId":"I1","warehousePalletId":"P1","palletNo":"PAL-1","packages":3,"destinationKey":"FBA:A"},
|
||||
{"dispatchNo":"DS-B","customerId":"2","dispatchItemId":"I2","warehousePalletId":"P2","palletNo":"PAL-2","packages":2,"destinationKey":"FBA:A"}],
|
||||
"pallets":[{"warehousePalletId":"P1","palletSpaces":2,"weightKg":100,"volumeCbm":2,"deliveryIndex":1},
|
||||
{"warehousePalletId":"P2","palletSpaces":1,"weightKg":50,"volumeCbm":1,"deliveryIndex":1}]},
|
||||
"externalBookings":[{"bookingNo":"FB-B","customerId":"2","status":"BOUND","dispatchNo":"DS-B",
|
||||
"receipt":{"source":{"receivedPackages":2,"pallets":[{"warehousePalletId":"P2","packages":2}]}}}]}
|
||||
""";
|
||||
@BeforeEach void setup() throws Exception {
|
||||
var source = new DriverManagerDataSource("jdbc:h2:mem:trip-cost-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000", "sa", "");
|
||||
jdbc = new JdbcTemplate(source);
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),status VARCHAR(32))");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip_history(trip_id BIGINT,record_version BIGINT,action VARCHAR(32),snapshot_json LONGTEXT)");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,'TR-A','COMPLETED'),(2,'TR-B','PLANNED')");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,7,'DEPART',?)", DEPARTURE);
|
||||
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
|
||||
for (String name : List.of("ll_fleet_trip_cost", "ll_fleet_trip_cost_history")) {
|
||||
var match = Pattern.compile("(?s)CREATE TABLE " + name + " \\(.*?;").matcher(schema);
|
||||
assertThat(match.find()).isTrue(); jdbc.execute(match.group().replace(" JSON", " LONGTEXT"));
|
||||
}
|
||||
var config = new MybatisConfiguration(); config.addMapper(FleetTripMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(source); factory.setConfiguration(config);
|
||||
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(FleetTripMapper.class);
|
||||
repository = spy(new FleetTripCostRepository(jdbc));
|
||||
var proxy = new ProxyFactory(new FleetTripCostService(mapper, repository, json)); proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource()));
|
||||
service = (FleetTripCostService) proxy.getProxy();
|
||||
}
|
||||
private static FleetTripCostModels.Draft draft(String currency, String amount) {
|
||||
return new FleetTripCostModels.Draft("FUEL", currency, new BigDecimal(amount), Instant.parse("2026-01-01T00:00:00Z"), null);
|
||||
}
|
||||
private FleetTripCostModels.Cost create(String trip, String key, String currency, String amount) {
|
||||
return service.create(trip, "1", new FleetTripCostModels.Create(key, draft(currency, amount)));
|
||||
}
|
||||
@Test void departureOnlyWeightsConserveCentsAndNeverMixCurrencies() {
|
||||
create("TR-A", "CNY-ONE", "CNY", "100.00"); create("TR-A", "USD-ONE", "USD", "0.01");
|
||||
var listing = service.list("TR-A", PAGE);
|
||||
assertThat(listing.summary().departureVersion()).isEqualTo(7);
|
||||
assertThat(listing.summary().totals()).hasSize(2);
|
||||
var cny = listing.summary().totals().get(0);
|
||||
assertThat(cny.ownCost()).isEqualByComparingTo("66.67"); assertThat(cny.externalCost()).isEqualByComparingTo("33.33");
|
||||
for (var total : listing.summary().totals()) assertThat(total.allocations().stream().map(FleetTripCostModels.Allocation::amount).reduce(BigDecimal.ZERO, BigDecimal::add)).isEqualByComparingTo(total.directCost());
|
||||
assertThat(cny.allocations()).extracting(FleetTripCostModels.Allocation::businessNo).containsExactly("DS-A", "FB-B");
|
||||
assertThat(jdbc.queryForList("SELECT status FROM ll_fleet_trip WHERE id=1", String.class)).containsExactly("COMPLETED");
|
||||
assertThat(jdbc.queryForObject("SELECT snapshot_json FROM ll_fleet_trip_history WHERE trip_id=1", String.class)).isEqualTo(DEPARTURE);
|
||||
}
|
||||
@Test void batchSummaryReusesTheSameAmountsWithoutPerTripQueriesAndDistinguishesMissingTrips() {
|
||||
create("TR-A", "BATCH-COST", "CNY", "100.00");
|
||||
var expected = service.list("TR-A", PAGE).summary();
|
||||
clearInvocations(repository);
|
||||
var result = service.summaries(List.of("TR-A", "TR-B", "MISSING", "TR-A"));
|
||||
assertThat(result.keySet()).containsExactly("TR-A", "TR-B");
|
||||
assertThat(result.get("TR-A")).isEqualTo(expected);
|
||||
assertThat(result.get("TR-B").totals()).isEmpty();
|
||||
verify(repository, never()).departure(anyLong());
|
||||
verify(repository, never()).totals(anyLong());
|
||||
verify(repository, times(1)).trips(anyCollection());
|
||||
verify(repository, times(1)).departures(anyCollection());
|
||||
verify(repository, times(1)).totals(anyCollection());
|
||||
assertThat(service.summaries(List.of())).isEmpty();
|
||||
assertThatThrownBy(() -> service.summaries(Collections.nCopies(101, "TR-A"))).hasMessageContaining("最多100");
|
||||
assertThatThrownBy(() -> service.summaries(List.of(" "))).hasMessageContaining("有效车次");
|
||||
}
|
||||
@Test void createReplayAndConcurrentDuplicateCannotCreateTwoCosts() throws Exception {
|
||||
var executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var a = executor.submit(() -> create("TR-A", "SAME-KEY", "CNY", "5"));
|
||||
var b = executor.submit(() -> create("TR-A", "SAME-KEY", "CNY", "5"));
|
||||
assertThat(a.get(10, TimeUnit.SECONDS).costNo()).isEqualTo(b.get(10, TimeUnit.SECONDS).costNo());
|
||||
} finally { executor.shutdownNow(); }
|
||||
assertThat(service.list("TR-A", PAGE).page().total()).isEqualTo(1);
|
||||
assertThatThrownBy(() -> create("TR-A", "SAME-KEY", "CNY", "6")).hasMessageContaining("其他成本内容");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_cost_history", Integer.class)).isEqualTo(1);
|
||||
}
|
||||
@Test void correctionRetainsOriginalAggregateAndRejectsOtherTripsAndStaleVersions() {
|
||||
var original = create("TR-A", "COST-ONE", "USD", "30");
|
||||
var request = new FleetTripCostModels.Update(0L, draft("USD", "60"), "核对实际费用");
|
||||
assertThatThrownBy(() -> service.update("TR-B", original.costNo(), "1", request)).hasMessageContaining("不存在");
|
||||
service.update("TR-A", original.costNo(), "1", request);
|
||||
assertThatThrownBy(() -> service.update("TR-A", original.costNo(), "1", request)).hasMessageContaining("已变化");
|
||||
var history = service.history("TR-A", original.costNo(), PAGE).records();
|
||||
assertThat(history).hasSize(2); assertThat(history.get(0).reason()).isEqualTo("核对实际费用");
|
||||
assertThat(history.get(0).snapshot().summary().totals().get(0).directCost()).isEqualByComparingTo("60");
|
||||
assertThat(history.get(1).snapshot().summary().totals().get(0).directCost()).isEqualByComparingTo("30");
|
||||
}
|
||||
@Test void equivalentMoneyAndEmptyRemarksReplayWithoutAnotherCostOrHistory() {
|
||||
var original = create("TR-A", "EQUIVALENT-KEY", "USD", "30");
|
||||
var equivalent = new FleetTripCostModels.Draft("FUEL", "USD", new BigDecimal("30.00"), original.details().incurredAt(), " ");
|
||||
var replay = service.create("TR-A", "1", new FleetTripCostModels.Create("EQUIVALENT-KEY", equivalent));
|
||||
assertThat(replay.costNo()).isEqualTo(original.costNo());
|
||||
var unchanged = service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, equivalent, "仅说明不同"));
|
||||
assertThat(unchanged.recordVersion()).isZero();
|
||||
assertThat(service.history("TR-A", original.costNo(), PAGE).total()).isEqualTo(1);
|
||||
assertThat(service.list("TR-A", PAGE).page().total()).isEqualTo(1);
|
||||
service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, draft("USD", "31"), null));
|
||||
assertThatThrownBy(() -> service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, draft("USD", "31"), null)))
|
||||
.hasMessageContaining("已变化");
|
||||
}
|
||||
@Test void millisecondStorageAndReplayingCreationAfterCorrectionPreserveTheCurrentCost() {
|
||||
var input = new FleetTripCostModels.Draft("FUEL", "USD", new BigDecimal("12.50"), Instant.parse("2026-01-01T00:00:00.123456Z"), " 凭据说明 ");
|
||||
var command = new FleetTripCostModels.Create("PRECISE-TIME-KEY", input);
|
||||
var original = service.create("TR-A", "1", command);
|
||||
assertThat(original.details().incurredAt()).isEqualTo(Instant.parse("2026-01-01T00:00:00.123Z"));
|
||||
assertThat(original.details().remark()).isEqualTo("凭据说明");
|
||||
var unchanged = service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, input, null));
|
||||
assertThat(unchanged.recordVersion()).isZero();
|
||||
service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, draft("CNY", "20"), null));
|
||||
var replay = service.create("TR-A", "1", command);
|
||||
assertThat(replay.recordVersion()).isEqualTo(1);
|
||||
assertThat(replay.details().currency()).isEqualTo("CNY");
|
||||
assertThat(replay.details().amount()).isEqualByComparingTo("20");
|
||||
assertThat(service.history("TR-A", original.costNo(), PAGE).total()).isEqualTo(2);
|
||||
}
|
||||
@Test void auditFailureRollsBackCostAndAmountCorrection() {
|
||||
var original = create("TR-A", "ORIGINAL", "CNY", "30");
|
||||
doThrow(new IllegalStateException("history unavailable")).when(repository).audit(anyLong(), any(), anyString(), anyString(), anyString(), anyString());
|
||||
assertThatThrownBy(() -> create("TR-A", "REJECTED", "CNY", "90")).hasMessageContaining("history unavailable");
|
||||
assertThatThrownBy(() -> service.update("TR-A", original.costNo(), "1", new FleetTripCostModels.Update(0L, draft("CNY", "70"), null))).hasMessageContaining("history unavailable");
|
||||
var list = service.list("TR-A", PAGE);
|
||||
assertThat(list.page().total()).isEqualTo(1); assertThat(list.page().records().get(0).recordVersion()).isZero();
|
||||
assertThat(list.summary().totals().get(0).directCost()).isEqualByComparingTo("30");
|
||||
}
|
||||
@Test void noDepartureAndNoCostsAreUnknownAndDifferentStopsPreventInventedAllocations() {
|
||||
assertThat(service.list("TR-B", PAGE).summary().totals()).isEmpty();
|
||||
create("TR-B", "BEFORE-DEPART", "USD", "0");
|
||||
var before = service.list("TR-B", PAGE).summary();
|
||||
assertThat(before.allocationBlockReason()).contains("尚无实际发车"); assertThat(before.totals().get(0).ownCost()).isNull();
|
||||
jdbc.update("UPDATE ll_fleet_trip_history SET snapshot_json=? WHERE trip_id=1", DEPARTURE.replace("\"palletSpaces\":1,\"weightKg\":50,\"volumeCbm\":1,\"deliveryIndex\":1", "\"palletSpaces\":1,\"weightKg\":50,\"volumeCbm\":1,\"deliveryIndex\":2"));
|
||||
create("TR-A", "MULTI-STOP", "USD", "50");
|
||||
var summary = service.list("TR-A", PAGE).summary();
|
||||
assertThat(summary.allocationBlockReason()).contains("卸货站不同"); assertThat(summary.totals().get(0).allocations()).isEmpty();
|
||||
assertThat(summary.totals().get(0).directCost()).isEqualByComparingTo("50");
|
||||
}
|
||||
@Test void mismatchedExternalOwnerOrReceiptCannotBeClassifiedAsOwnCargo() throws Exception {
|
||||
var wrongCustomer = json.readValue(DEPARTURE.replace("\"customerId\":\"2\",\"status\"", "\"customerId\":\"3\",\"status\""), FleetTripModels.AuditSnapshot.class);
|
||||
assertThat(FleetTripCostBasis.from(wrongCustomer).blockReason()).contains("归属未核平");
|
||||
var wrongQuantity = json.readValue(DEPARTURE.replace("\"receivedPackages\":2", "\"receivedPackages\":3"), FleetTripModels.AuditSnapshot.class);
|
||||
assertThat(FleetTripCostBasis.from(wrongQuantity).blockReason()).contains("总量");
|
||||
}
|
||||
|
||||
@Test void activeUnboundExternalCargoCannotSilentlyBecomeOwnCargo() throws Exception {
|
||||
for (var status : List.of("CONFIRMED", "RECEIVED", "UNKNOWN")) {
|
||||
var snapshot = json.readValue(DEPARTURE.replace("\"status\":\"BOUND\"", "\"status\":\"" + status + "\""), FleetTripModels.AuditSnapshot.class);
|
||||
assertThat(FleetTripCostBasis.from(snapshot).blockReason()).contains("实收及排车依据");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void boundExternalCargoRequiresIdentityAndUniqueReceiptPallets() throws Exception {
|
||||
for (var malformed : List.of(
|
||||
DEPARTURE.replace("\"bookingNo\":\"FB-B\"", "\"bookingNo\":null"),
|
||||
DEPARTURE.replace("\"customerId\":\"2\",\"status\"", "\"customerId\":null,\"status\""),
|
||||
DEPARTURE.replace("\"status\":\"BOUND\",\"dispatchNo\":\"DS-B\"", "\"status\":\"BOUND\",\"dispatchNo\":null"))) {
|
||||
assertThat(FleetTripCostBasis.from(json.readValue(malformed, FleetTripModels.AuditSnapshot.class)).blockReason()).contains("实收及排车依据");
|
||||
}
|
||||
var duplicate = DEPARTURE.replace("\"warehousePalletId\":\"P2\",\"packages\":2}",
|
||||
"\"warehousePalletId\":\"P2\",\"packages\":2},{\"warehousePalletId\":\"P2\",\"packages\":2}");
|
||||
assertThat(FleetTripCostBasis.from(json.readValue(duplicate, FleetTripModels.AuditSnapshot.class)).blockReason()).contains("归属未核平");
|
||||
}
|
||||
}
|
||||
+3
@@ -476,6 +476,9 @@ class FleetTripPersistenceTest {
|
||||
assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order", String.class)).containsOnly("OUTBOUNDED");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(DISTINCT depart_at) FROM ll_dispatch_order", Integer.class)).isEqualTo(1);
|
||||
assertThat(service.detail(number).trip().status()).isEqualTo("IN_TRANSIT");
|
||||
assertThat(result.departedAt().getNano() % 1_000_000).isZero();
|
||||
assertThat(service.detail(number).trip().departedAt()).isEqualTo(result.departedAt());
|
||||
assertThat(service.history(number, 1, 20).records().get(0).snapshot().trip().departedAt()).isEqualTo(result.departedAt());
|
||||
assertThat(execution.depart("92", "DP-12", "1", request).replayed()).isTrue();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation WHERE operation_type='DEPART'", Integer.class)).isEqualTo(2);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='DEPART'", Integer.class)).isEqualTo(1);
|
||||
|
||||
+39
-1
@@ -13,7 +13,7 @@ import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** Exercises the production SQL with H2 adapters for MySQL's two JSON scalar functions. */
|
||||
/** Exercises the production SQL with H2 adapters for MySQL JSON scalar functions. */
|
||||
public class VehicleFulfillmentQueryTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private VehicleFulfillmentMapper mapper;
|
||||
@@ -22,19 +22,37 @@ public class VehicleFulfillmentQueryTest {
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
public static String jsonExtract(String source, String path) throws Exception {
|
||||
if (source == null) return null;
|
||||
if ("$".equals(path)) return source;
|
||||
var value = JSON.readTree(source);
|
||||
for (String field : path.substring(2).split("\\.")) value = value.path(field);
|
||||
return value.isMissingNode() ? null : value.toString();
|
||||
}
|
||||
public static String jsonUnquote(String value) throws Exception { return value == null ? null : JSON.readTree(value).asText(); }
|
||||
public static String jsonQuote(String value) throws Exception { return JSON.writeValueAsString(value); }
|
||||
public static int jsonContains(String source, String candidate, String path) throws Exception {
|
||||
String array = jsonExtract(source, path);
|
||||
if (array == null) return 0;
|
||||
var expected = JSON.readTree(candidate);
|
||||
for (var entry : JSON.readTree(array)) if (entry.equals(expected)) return 1;
|
||||
return 0;
|
||||
}
|
||||
public static String jsonSet(String source, String path, String value) throws Exception {
|
||||
var document = (com.fasterxml.jackson.databind.node.ObjectNode) JSON.readTree(source);
|
||||
document.set(path.substring(2), JSON.readTree(value));
|
||||
return document.toString();
|
||||
}
|
||||
@BeforeEach void setup() throws Exception {
|
||||
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
jdbc = new JdbcTemplate(dataSource);
|
||||
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonExtract'");
|
||||
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonUnquote'");
|
||||
jdbc.execute("CREATE ALIAS JSON_QUOTE FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonQuote'");
|
||||
jdbc.execute("CREATE ALIAS JSON_CONTAINS FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonContains'");
|
||||
jdbc.execute("CREATE ALIAS JSON_SET FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonSet'");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),vehicle_id BIGINT,status VARCHAR(32))");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT PRIMARY KEY,trip_id BIGINT,customer_id VARCHAR(32))");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip_history(id BIGINT PRIMARY KEY,trip_id BIGINT,record_version BIGINT,occurred_at TIMESTAMP,action VARCHAR(64),reason VARCHAR(255),snapshot_json CLOB,UNIQUE(trip_id,record_version))");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip_handover(id BIGINT PRIMARY KEY,trip_id BIGINT,trip_version BIGINT,snapshot_json CLOB,UNIQUE(trip_id,trip_version))");
|
||||
jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,delivery_no VARCHAR(64),dispatch_no VARCHAR(64),dispatch_order_id BIGINT,customer_id VARCHAR(32),customer_name VARCHAR(100),bol_no VARCHAR(64),destination_type VARCHAR(32),destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(100),delivery_address VARCHAR(255),planned_delivery_at TIMESTAMP,status VARCHAR(32),created_at TIMESTAMP)");
|
||||
var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); configuration.addMapper(VehicleFulfillmentMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(configuration);
|
||||
@@ -104,4 +122,24 @@ public class VehicleFulfillmentQueryTest {
|
||||
assertThatThrownBy(()->service.deliveries(404,new VehicleFulfillmentModels.Query(1,10))).hasMessageContaining("车辆不存在");
|
||||
assertThatThrownBy(()->service.changes(404,new VehicleFulfillmentModels.Query(1,10))).hasMessageContaining("车辆不存在");
|
||||
}
|
||||
@Test void handoverAssociatesOnlyActuallyCarriedDeliveriesAndScopesChangeHistory() throws Exception {
|
||||
history(0,"DEPART","8","18"); history(1,"HANDOVER","9","19");
|
||||
jdbc.update("UPDATE ll_fleet_trip SET vehicle_id=9,status='IN_TRANSIT'");
|
||||
String snapshot = JSON.writeValueAsString(Map.of("replacement", Map.of("vehicleId", "9"),
|
||||
"carriedDeliveries", List.of(Map.of("customerId", "CUSTOMER-B", "deliveryNo", "DL-22")),
|
||||
"cargo", Map.of("remainingCargo", List.of(Map.of("dispatchNo", "DP-12"), Map.of("dispatchNo", "DP-12")))));
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_handover VALUES(1,1,1,?)", snapshot);
|
||||
assertThat(service.deliveries(8,new VehicleFulfillmentModels.Query(1,10)).records()).hasSize(2);
|
||||
assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).records())
|
||||
.extracting(VehicleFulfillmentModels.Delivery::deliveryNo).containsExactly("DL-22");
|
||||
assertThat(service.changes(9,new VehicleFulfillmentModels.Query(1,10)).records()).singleElement().satisfies(change -> {
|
||||
assertThat(change.relationship()).isEqualTo("REPLACED_IN");
|
||||
assertThat(change.dispatchNos()).containsExactly("DP-12");
|
||||
});
|
||||
jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED' WHERE id=22");
|
||||
assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).total()).isEqualTo(1);
|
||||
// Matching delivery numbers alone must never associate another customer's document.
|
||||
jdbc.update("UPDATE ll_delivery_order SET customer_id='CUSTOMER-A' WHERE id=22");
|
||||
assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).total()).isZero();
|
||||
}
|
||||
}
|
||||
|
||||
+7
-5
@@ -16,7 +16,9 @@ import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class OutboxLeaseDatabaseTest {
|
||||
@Test void unloadCompletedOutboxRepositoryUsesAtomicAuditAndBackoff() throws Exception {
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"OverseasContainerUnloaded", "CompanyVehicleCostSubmitted"})
|
||||
void unloadCompletedOutboxRepositoryUsesAtomicAuditAndBackoff(String eventType) throws Exception {
|
||||
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
var jdbc = new JdbcTemplate(dataSource);
|
||||
jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_version INT,event_type VARCHAR(96),event_key VARCHAR(160),payload_json TEXT,status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)");
|
||||
@@ -30,9 +32,9 @@ class OutboxLeaseDatabaseTest {
|
||||
ReflectionTestUtils.setField(repository, "mapper", mapper);
|
||||
var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource));
|
||||
LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0);
|
||||
jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "OverseasContainerUnloaded", now.minusSeconds(1), now.minusMinutes(3));
|
||||
assertThat(journal.begin("old", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse();
|
||||
assertThat(journal.begin("old", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue();
|
||||
jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", eventType, now.minusSeconds(1), now.minusMinutes(3));
|
||||
assertThat(journal.begin("old", "lastleg-service", eventType, "1", null, "consumer", now.minusMinutes(2), () -> repository.claim(1L, "old", now.minusMinutes(2)))).isFalse();
|
||||
assertThat(journal.begin("old", "lastleg-service", eventType, "1", null, "consumer", now, () -> repository.claim(1L, "old", now))).isTrue();
|
||||
jdbc.update("UPDATE ll_event_outbox SET locked_at=? WHERE id=1", now.minusSeconds(61));
|
||||
assertThat(repository.claim(1L, "new", now)).isFalse();
|
||||
var next = now.plusSeconds(10);
|
||||
@@ -40,7 +42,7 @@ class OutboxLeaseDatabaseTest {
|
||||
() -> repository.expire(1L, "old", now.minusSeconds(60), 1, next, "lease expired"))).isTrue();
|
||||
assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox", Integer.class)).isEqualTo(1);
|
||||
assertThat(repository.claim(1L, "early", now)).isFalse();
|
||||
assertThat(journal.begin("new", "lastleg-service", "OverseasContainerUnloaded", "1", null, "consumer", next,
|
||||
assertThat(journal.begin("new", "lastleg-service", eventType, "1", null, "consumer", next,
|
||||
() -> repository.claim(1L, "new", next))).isTrue();
|
||||
assertThat(journal.complete("old", Outcome.DEAD, next, "RETRY_EXHAUSTED", null,
|
||||
() -> repository.dead(1L, "old", 12, "late"))).isFalse();
|
||||
|
||||
Reference in New Issue
Block a user