feat(fleet): enforce reviewed trips and original fee coverage for dispatch

This commit is contained in:
2026-10-03 04:18:52 +08:00
parent c50e9f97d1
commit 5e683da3b9
162 changed files with 9642 additions and 218 deletions
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.fleet.customer;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetCustomerDirectoryTest {
@Test void exactActiveCustomerAndCurrentEligibilityAreRequired() {
var client=mock(FleetCustomerClient.class); var directory=new FleetCustomerDirectory(client,"internal-test-token");
when(client.customers("internal-test-token",1,30,null,"ACTIVE",9L)).thenReturn(ApiResponse.success(new PageResult<>(List.of(new FleetCustomerClient.Customer(9L,"00009","客户甲","ACTIVE",null,null)),1,30,1,1)));
when(client.eligibility("internal-test-token",9L)).thenReturn(ApiResponse.success(new FleetCustomerClient.Eligibility(true,"ACTIVE",null)));
assertThat(directory.required(9).code()).isEqualTo("00009");
when(client.eligibility("internal-test-token",9L)).thenReturn(ApiResponse.success(new FleetCustomerClient.Eligibility(false,"PENDING","待审核")));
assertThatThrownBy(()->directory.required(9)).hasMessageContaining("下单资格");
}
@Test void failedDirectoryDoesNotInventCustomerOrPermission() {
var client=mock(FleetCustomerClient.class); var directory=new FleetCustomerDirectory(client,"internal-test-token");
when(client.customers(anyString(),anyLong(),anyLong(),any(),anyString(),any())).thenThrow(new IllegalStateException("network"));
assertThatThrownBy(()->directory.required(9)).hasMessageContaining("暂不可用");
verify(client,never()).eligibility(anyString(),anyLong());
}
}
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.fleet.domain;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import static com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy.*;
import static org.assertj.core.api.Assertions.assertThat;
class DriverQualificationPolicyTest {
private final Instant now = Instant.parse("2026-10-02T16:00:00Z");
private final ZoneId zone = ZoneId.of("America/Los_Angeles");
private Driver driver(LocalDate expiry) { return new Driver(true, ReviewStatus.APPROVED, true, expiry, zone); }
@Test void expiringWithinThirtyDaysAllowsTripEndingBeforeExpiry() {
var result = evaluate(driver(LocalDate.of(2026, 10, 5)), now, now.plusSeconds(3600));
assertThat(result.canDispatch()).isTrue();
assertThat(result.expiringSoon()).isTrue();
}
@Test void thirtyDayWindowIncludesBoundaryButNotThirtyOneDays() {
assertThat(evaluate(driver(LocalDate.of(2026, 11, 1)), now, now.plusSeconds(60)).expiringSoon()).isTrue();
assertThat(evaluate(driver(LocalDate.of(2026, 11, 2)), now, now.plusSeconds(60)).expiringSoon()).isFalse();
}
@Test void usesLicenceZoneAndIncludesEntireExpiryDate() {
var d = driver(LocalDate.of(2026, 10, 2));
assertThat(evaluate(d, now, Instant.parse("2026-10-03T06:59:59Z")).canDispatch()).isTrue();
assertThat(evaluate(d, now, Instant.parse("2026-10-03T07:00:00Z")).reason()).isEqualTo(Reason.EXPIRES_DURING_TRIP);
}
@Test void daylightSavingBoundaryUsesCalendarDayInsteadOfFixedHours() {
var d = driver(LocalDate.of(2026, 11, 1));
Instant start = Instant.parse("2026-11-01T07:00:00Z");
assertThat(evaluate(d, start, Instant.parse("2026-11-02T07:59:59Z")).canDispatch()).isTrue();
assertThat(evaluate(d, start, Instant.parse("2026-11-02T08:00:00Z")).canDispatch()).isFalse();
}
@Test void rejectsExpiredAndUnknownDatesOrEndTimes() {
assertThat(evaluate(driver(LocalDate.of(2026, 10, 1)), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.EXPIRED);
assertThat(evaluate(driver(null), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.MISSING_EXPIRY);
assertThat(evaluate(driver(LocalDate.of(2027, 1, 1)), now, null).reason()).isEqualTo(Reason.MISSING_TRIP_END);
assertThat(evaluate(driver(LocalDate.of(2027, 1, 1)), now, now).reason()).isEqualTo(Reason.INVALID_TRIP_END);
}
@Test void reviewAndDocumentGatesCannotBeBypassedWithValidDate() {
LocalDate expiry = LocalDate.of(2027, 1, 1);
assertThat(evaluate(new Driver(false, ReviewStatus.APPROVED, true, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.INACTIVE);
assertThat(evaluate(new Driver(true, ReviewStatus.APPROVED, false, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.MISSING_LICENSE);
assertThat(evaluate(new Driver(true, ReviewStatus.PENDING, true, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.PENDING_REVIEW);
assertThat(evaluate(new Driver(true, ReviewStatus.REJECTED, true, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.REJECTED);
}
@Test void delayedTripIsReevaluatedRatherThanReusingAnEarlierApproval() {
var d = driver(LocalDate.of(2026, 10, 5));
assertThat(evaluate(d, now, now.plusSeconds(3600)).canDispatch()).isTrue();
assertThat(evaluate(d, now, Instant.parse("2026-10-06T09:00:00Z")).reason()).isEqualTo(Reason.EXPIRES_DURING_TRIP);
}
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.fleet.domain;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.*;
import static org.assertj.core.api.Assertions.*;
class TripCapacityPolicyTest {
private Load load(int pallets, String kg, String cbm) { return new Load(pallets, new BigDecimal(kg), new BigDecimal(cbm)); }
private final Load capacity = new Load(30, new BigDecimal("18000"), new BigDecimal("70"));
@Test void combinesOwnedAndExternalCargoWithinAllCapacityLimits() {
var result = calculate(2, capacity, List.of(new Booking("OWN-1", 0, 1, load(20, "10000", "40")),
new Booking("EXT-1", 0, 1, load(10, "8000", "30"))));
assertThat(result.get(0).remaining()).isEqualTo(load(0, "0", "0"));
}
@Test void releasesUnloadedCargoBeforeNextPickupAtSameStop() {
var result = calculate(3, capacity, List.of(new Booking("A", 0, 1, load(30, "18000", "70")),
new Booking("B", 1, 2, load(30, "18000", "70"))));
assertThat(result).hasSize(2);
assertThat(result).allSatisfy(segment -> assertThat(segment.occupied().palletSpaces()).isEqualTo(30));
}
@Test void rejectsOverlappingSegmentsEvenWhenSomeStopsHaveSpareCapacity() {
assertThatThrownBy(() -> calculate(4, capacity, List.of(new Booking("A", 0, 2, load(20, "8000", "20")),
new Booking("B", 1, 3, load(15, "5000", "20")))))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("第2段");
}
@Test void rejectsOverweightOrOversizeEvenWithFreePalletSpaces() {
assertThatThrownBy(() -> calculate(2, capacity, List.of(new Booking("A", 0, 1, load(1, "18001", "1")))))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> calculate(2, capacity, List.of(new Booking("A", 0, 1, load(1, "1", "70.01")))))
.isInstanceOf(IllegalArgumentException.class);
}
@Test void rejectsDuplicateBookingAndStopsOutsideRoute() {
var booking = new Booking("A", 0, 1, load(1, "10", "1"));
assertThatThrownBy(() -> calculate(2, capacity, List.of(booking, booking))).hasMessageContaining("重复");
assertThatThrownBy(() -> calculate(2, capacity, List.of(new Booking("B", 0, 2, load(1, "10", "1")))))
.hasMessageContaining("不属于");
}
@Test void refusesUnknownCapacityAndMissingCargoMeasurements() {
assertThatThrownBy(() -> calculate(2, load(30, "0", "70"), List.of())).hasMessageContaining("大于零");
assertThatThrownBy(() -> new Booking("A", 0, 1, load(1, "0", "1"))).hasMessageContaining("有效");
assertThatThrownBy(() -> new Booking("A", 1, 1, load(1, "10", "1"))).hasMessageContaining("之后");
assertThatThrownBy(() -> load(1, "-1", "1")).hasMessageContaining("非负");
}
@Test void emptyTripRetainsCapacityOnEachSegment() {
assertThat(calculate(3, capacity, List.of())).allSatisfy(segment -> assertThat(segment.remaining()).isEqualTo(capacity));
}
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.fleet.domain;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static com.baiyue.lastleg.fleet.domain.TripCostAllocationPolicy.*;
import static org.assertj.core.api.Assertions.*;
class TripCostAllocationPolicyTest {
@Test void allocatesTwentyOwnedAndTenExternalPalletsWithoutCreatingRevenue() {
assertThat(allocate(new BigDecimal("600.00"), "USD", List.of(new Share("OWN-1", 20), new Share("EXT-1", 10))))
.containsExactly(new Allocation("EXT-1", new BigDecimal("200.00")), new Allocation("OWN-1", new BigDecimal("400.00")));
}
@Test void roundingConservesTotalAndIsIndependentOfRequestOrder() {
var first = allocate(new BigDecimal("10.00"), "USD", List.of(new Share("C", 1), new Share("A", 1), new Share("B", 1)));
var second = allocate(new BigDecimal("10.00"), "USD", List.of(new Share("B", 1), new Share("C", 1), new Share("A", 1)));
assertThat(first).isEqualTo(second);
assertThat(first).containsExactly(new Allocation("A", new BigDecimal("3.34")),
new Allocation("B", new BigDecimal("3.33")), new Allocation("C", new BigDecimal("3.33")));
assertThat(first.stream().map(Allocation::amount).reduce(BigDecimal.ZERO, BigDecimal::add)).isEqualByComparingTo("10.00");
}
@Test void singleCentAndZeroCostRemainExact() {
var shares = List.of(new Share("A", 1), new Share("B", 2));
assertThat(allocate(new BigDecimal("0.01"), "CNY", shares))
.containsExactly(new Allocation("A", new BigDecimal("0.00")), new Allocation("B", new BigDecimal("0.01")));
assertThat(allocate(BigDecimal.ZERO, "CNY", shares)).allSatisfy(a -> assertThat(a.amount()).isZero());
}
@Test void rejectsUnmeasuredDuplicatedAndInvalidCostInputs() {
var shares = List.of(new Share("A", 1));
assertThatThrownBy(() -> allocate(new BigDecimal("1.001"), "USD", shares)).hasMessageContaining("两位");
assertThatThrownBy(() -> allocate(BigDecimal.ONE, "EUR", shares)).hasMessageContaining("币种");
assertThatThrownBy(() -> allocate(BigDecimal.ONE, null, shares)).hasMessageContaining("币种");
assertThatThrownBy(() -> allocate(BigDecimal.ONE.negate(), "USD", shares)).hasMessageContaining("负数");
assertThatThrownBy(() -> allocate(BigDecimal.ONE, "USD", List.of())).hasMessageContaining("没有");
assertThatThrownBy(() -> allocate(BigDecimal.ONE, "USD", List.of(new Share("A", 1), new Share("A", 2)))).hasMessageContaining("重复");
}
@Test void handlesLargeValuesWithoutIntegerOverflow() {
var amount = new BigDecimal("99999999999999.99");
var result = allocate(amount, "USD", List.of(new Share("A", Long.MAX_VALUE), new Share("B", Long.MAX_VALUE)));
assertThat(result.stream().map(Allocation::amount).reduce(BigDecimal.ZERO, BigDecimal::add)).isEqualByComparingTo(amount);
}
}
@@ -0,0 +1,71 @@
package com.baiyue.lastleg.fleet.driver;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.yaml.snakeyaml.Yaml;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
class DriverApiContractTest {
@Test void everyControllerOperationHasOpenApiAndRegisteredPermission() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/fleet-drivers-v1.yaml"));
Map<String, Object> document = new Yaml().load(yaml);
Map<?, ?> paths = (Map<?, ?>) document.get("paths");
String seeds = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql"));
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
String base = PlatformDriverController.class.getAnnotation(RequestMapping.class).value()[0];
int operationCount = 0;
for (var method : PlatformDriverController.class.getDeclaredMethods()) {
String verb = null;
String[] relative = null;
if (method.isAnnotationPresent(GetMapping.class)) { verb = "get"; relative = method.getAnnotation(GetMapping.class).value(); }
if (method.isAnnotationPresent(PostMapping.class)) { verb = "post"; relative = method.getAnnotation(PostMapping.class).value(); }
if (method.isAnnotationPresent(PutMapping.class)) { verb = "put"; relative = method.getAnnotation(PutMapping.class).value(); }
if (method.isAnnotationPresent(PatchMapping.class)) { verb = "patch"; relative = method.getAnnotation(PatchMapping.class).value(); }
if (verb == null) continue;
operationCount++;
String path = base + (relative.length == 0 ? "" : relative[0]);
assertThat(paths.containsKey(path)).as(path).isTrue();
Map<?, ?> operation = (Map<?, ?>) ((Map<?, ?>) paths.get(path)).get(verb);
assertThat(operation).as(verb + " " + path).isNotNull();
String expression = method.getAnnotation(PreAuthorize.class).value();
String permission = expression.substring(expression.indexOf('\'') + 1, expression.lastIndexOf('\''));
assertThat(operation.get("description").toString()).contains(permission);
assertThat(seeds).contains("'" + permission + "'");
assertThat(unified).contains("'" + permission + "'");
}
assertThat(operationCount).isEqualTo(12);
verifyRefs(document, document);
}
@Test void driverTablesMatchDeploymentBaselineAndKeepUniqueHistoryVersions() throws Exception {
String module = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
String deployed = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
for (String table : new String[]{"ll_fleet_driver", "ll_fleet_driver_history"}) {
var pattern = Pattern.compile("(?s)CREATE TABLE " + table + " \\(.*?;");
var a = pattern.matcher(module); var b = pattern.matcher(deployed);
assertThat(a.find()).isTrue(); assertThat(b.find()).isTrue();
assertThat(a.group().replace("\r\n", "\n")).isEqualTo(b.group().replace("\r\n", "\n"));
}
assertThat(deployed).contains("UNIQUE KEY uk_fleet_driver_history_version (driver_id,record_version)")
.contains("FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id)");
}
private static void verifyRefs(Object node, Map<String, Object> document) {
if (node instanceof Map<?, ?> map) {
if (map.get("$ref") instanceof String ref) {
assertThat(ref).startsWith("#/");
Object value = document;
for (String key : ref.substring(2).split("/")) value = ((Map<?, ?>) value).get(key);
assertThat(value).as(ref).isNotNull();
}
map.values().forEach(value -> verifyRefs(value, document));
} else if (node instanceof Iterable<?> values) values.forEach(value -> verifyRefs(value, document));
}
}
@@ -0,0 +1,61 @@
package com.baiyue.lastleg.fleet.driver;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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 java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class DriverAuthorizationTest {
@AfterEach void clearIdentity() { SecurityContextHolder.clearContext(); }
@Test void listPermissionReadsDetailsButCannotModifyOrApprove() {
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
var controller = context.getBean(PlatformDriverController.class);
var service = context.getBean(DriverService.class);
authenticate("platform:overseas-master-data:driver:list");
controller.detail(71, null);
verify(service).detail(71, null);
assertThatThrownBy(() -> controller.create(null, null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.update(null, 71, null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.review(null, 71, null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.status(null, 71, null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.submit(null, 71, null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.upload(null, 71, 0, null)).isInstanceOf(AccessDeniedException.class);
verifyNoMoreInteractions(service);
}
}
@Test void unrelatedPermissionCannotReadSensitiveDriverDetailsOrAttachments() {
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
var controller = context.getBean(PlatformDriverController.class);
authenticate("platform:overseas-master-data:vehicle:list");
assertThatThrownBy(() -> controller.detail(71, null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.history(71, 1, 20)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.download(71, 81)).isInstanceOf(AccessDeniedException.class);
verifyNoInteractions(context.getBean(DriverService.class));
}
}
private static void authenticate(String permission) {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator", "unused",
List.of(new SimpleGrantedAuthority(permission))));
}
@Configuration
@EnableMethodSecurity
static class Config {
@Bean DriverService service() { return mock(DriverService.class); }
@Bean PlatformDriverController controller(DriverService service) { return new PlatformDriverController(service); }
}
}
@@ -0,0 +1,130 @@
package com.baiyue.lastleg.fleet.driver;
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.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
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.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.mock;
class DriverPersistenceTest {
private JdbcTemplate jdbc;
private DriverService service;
@BeforeEach void createDatabaseAndTransactionalService() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds);
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for (String table : new String[]{"ll_fleet_driver", "ll_fleet_driver_history"}) {
var match = Pattern.compile("(?s)CREATE TABLE " + table + " \\(.*?;", Pattern.MULTILINE).matcher(schema);
assertThat(match.find()).isTrue();
// H2 JSON encodes bound strings differently; production schema is checked separately against MySQL baseline.
jdbc.execute(match.group().replace("snapshot_json JSON", "snapshot_json LONGTEXT"));
}
var configuration = new MybatisConfiguration();
var pagination = new com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor();
pagination.addInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));
configuration.addInterceptor(pagination);
configuration.addMapper(DriverMapper.class);
configuration.addMapper(DriverHistoryMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(ds);
factory.setConfiguration(configuration);
var session = new SqlSessionTemplate(factory.getObject());
var target = new DriverService(session.getMapper(DriverMapper.class), session.getMapper(DriverHistoryMapper.class),
mock(DriverFileGateway.class), new ObjectMapper().registerModule(new JavaTimeModule()),
Clock.fixed(Instant.parse("2026-10-02T12:00:00Z"), ZoneOffset.UTC), mock(FleetWarehouseDirectory.class));
var proxy = new ProxyFactory(target);
proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
service = (DriverService) proxy.getProxy();
}
@Test void historyFailureRollsBackDriverStatusAndVersion() {
var driver = service.create("operator", draft("初始备注"));
jdbc.execute("ALTER TABLE ll_fleet_driver_history ADD CONSTRAINT reject_deactivate CHECK(action <> 'DEACTIVATE')");
assertThatThrownBy(() -> service.changeStatus(Long.parseLong(driver.id()), "e", new DriverModels.ChangeStatus(0L, false, "暂停")))
.isInstanceOf(RuntimeException.class);
var stored = service.detail(Long.parseLong(driver.id()), null);
assertThat(stored.active()).isTrue();
assertThat(stored.recordVersion()).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_driver_history", Integer.class)).isEqualTo(1);
}
@Test void clearingOptionalFieldsPersistsAndHistoryRemainsImmutable() {
var driver = service.create("operator", draft("原始备注"));
long id = Long.parseLong(driver.id());
service.update(id, "e", new DriverModels.Update(0L, draft(null)));
assertThat(service.detail(id, null).details().remark()).isNull();
assertThat(service.history(id, 1, 20).records()).hasSize(2);
assertThat(service.history(id, 1, 20).records().get(1).snapshot().details().remark()).isEqualTo("原始备注");
}
@Test void concurrentSameVersionWritesProduceOneWinnerAndOneConflict() throws Exception {
var driver = service.create("operator", draft(null));
long id = Long.parseLong(driver.id());
var executor = Executors.newFixedThreadPool(2);
var start = new CountDownLatch(1);
Callable<Boolean> operation = () -> {
start.await(5, TimeUnit.SECONDS);
try { service.changeStatus(id, "e", new DriverModels.ChangeStatus(0L, false, "暂停")); return true; }
catch (org.springframework.web.server.ResponseStatusException e) {
assertThat(e.getStatusCode().value()).isEqualTo(409);
return false;
}
};
try {
var first = executor.submit(operation);
var second = executor.submit(operation);
start.countDown();
assertThat(java.util.List.of(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false);
assertThat(service.detail(id, null).recordVersion()).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_driver_history", Integer.class)).isEqualTo(2);
} finally { executor.shutdownNow(); }
}
@Test void summaryAndExpiryFilteringUseLicenseZoneAcrossPages() {
var eastern = new DriverModels.Draft("东侧日期", "123", "OWNED", "OVERSEAS", null, null, null,
java.time.LocalDate.of(2026, 10, 2), "Pacific/Kiritimati", null, null);
var western = new DriverModels.Draft("西侧日期", "123", "DISPATCHED", "OVERSEAS", null, null, null,
java.time.LocalDate.of(2026, 10, 2), "America/Los_Angeles", null, null);
service.create("operator", eastern);
service.create("operator", western);
service.create("operator", draft(null));
var all = service.page(new DriverModels.Query(1, 1, null, null, null, null, null, null, null));
assertThat(all.total()).isEqualTo(3);
assertThat(all.records()).hasSize(1);
assertThat(all.summary()).isEqualTo(new DriverModels.Summary(3, 2, 1, 1, 1));
var expired = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRED", null));
assertThat(expired.records()).extracting(row -> row.details().name()).containsExactly("东侧日期");
assertThat(expired.summary().expired()).isEqualTo(1);
var expiring = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRING", null));
assertThat(expiring.records()).extracting(DriverModels.Response::licenseState).containsExactly("EXPIRING");
var missing = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "MISSING", null));
assertThat(missing.total()).isEqualTo(1);
assertThat(missing.summary().expiringSoon()).isZero();
}
private static DriverModels.Draft draft(String remark) {
return new DriverModels.Draft("测试司机", "123", "OWNED", "OVERSEAS", null, null, null, null, "UTC", remark, null);
}
}
@@ -0,0 +1,196 @@
package com.baiyue.lastleg.fleet.driver;
import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy.Reason;
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 org.mockito.ArgumentCaptor;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class DriverServiceTest {
private final DriverMapper drivers = mock(DriverMapper.class);
private final DriverHistoryMapper history = mock(DriverHistoryMapper.class);
private final DriverFileGateway files = mock(DriverFileGateway.class);
private final Clock clock = Clock.fixed(Instant.parse("2026-10-02T12:00:00Z"), ZoneOffset.UTC);
private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule());
private final FleetWarehouseDirectory warehouses = mock(FleetWarehouseDirectory.class);
private final DriverService service = new DriverService(drivers, history, files, json, clock, warehouses);
@BeforeEach void writesSucceed() {
when(drivers.insert(any(DriverEntity.class))).thenReturn(1);
when(drivers.update(any(DriverEntity.class), any())).thenReturn(1);
when(history.insert(any(DriverHistoryEntity.class))).thenReturn(1);
}
@Test void draftCanBeSavedWithoutLicenseButCannotDispatchOrBeApproved() throws Exception {
var response = service.create("operator", draft(null));
assertThat(response.driverNo()).startsWith("DR-").isNotEqualTo(response.id());
assertThat(response.qualification().reason()).isEqualTo(Reason.MISSING_LICENSE);
assertThat(response.reviewStatus()).isEqualTo("PENDING");
var captured = ArgumentCaptor.forClass(DriverEntity.class);
verify(drivers).insert(captured.capture());
when(drivers.lockById(captured.getValue().getId())).thenReturn(captured.getValue());
assertThatThrownBy(() -> service.review(captured.getValue().getId(), "reviewer", new DriverModels.Review(0L, "APPROVED", null)))
.hasMessageContaining("补齐驾驶证");
var event = ArgumentCaptor.forClass(DriverHistoryEntity.class);
verify(history).insert(event.capture());
assertThat(json.readValue(event.getValue().getSnapshotJson(), DriverModels.Response.class).driverNo()).isEqualTo(response.driverNo());
verifyNoInteractions(files);
}
@Test void approvedDriverMayDepartWithReminderOnlyWhenTaskEndsBeforeExpiry() {
var row = row("APPROVED");
when(drivers.lockById(71)).thenReturn(row);
var response = service.requireDispatchable(71, Instant.parse("2026-10-20T12:00:00Z"));
assertThat(response.qualification().canDispatch()).isTrue();
assertThat(response.qualification().expiringSoon()).isTrue();
verify(files).metadata(71, 81);
assertThatThrownBy(() -> service.requireDispatchable(71, Instant.parse("2026-10-23T00:00:00Z")))
.hasMessageContaining("预计任务结束前到期");
assertThatThrownBy(() -> service.requireDispatchable(71, null)).hasMessageContaining("预计任务结束时间");
}
@Test void approvalVerifiesAttachmentAndPersistsReviewerAndSnapshot() throws Exception {
when(drivers.lockById(71)).thenReturn(row("PENDING"));
var response = service.review(71, "reviewer", new DriverModels.Review(2L, "APPROVED", "已核对"));
assertThat(response.reviewStatus()).isEqualTo("APPROVED");
assertThat(response.recordVersion()).isEqualTo(3);
verify(files).metadata(71, 81);
var event = ArgumentCaptor.forClass(DriverHistoryEntity.class);
verify(history).insert(event.capture());
assertThat(event.getValue().getOperatorId()).isEqualTo("reviewer");
assertThat(event.getValue().getReason()).isEqualTo("已核对");
assertThat(json.readValue(event.getValue().getSnapshotJson(), DriverModels.Response.class).reviewStatus()).isEqualTo("APPROVED");
}
@Test void failedFileVerificationCannotWriteApproval() {
when(drivers.lockById(71)).thenReturn(row("PENDING"));
when(files.metadata(71, 81)).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST, "错误文件归属"));
assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "APPROVED", null))).hasMessageContaining("错误文件归属");
verify(drivers, never()).update(any(DriverEntity.class), any());
verify(history, never()).insert(any(DriverHistoryEntity.class));
}
@Test void rejectsExpiredAndNotYetIssuedLicenseApproval() {
var row = row("PENDING");
row.setLicenseExpiresOn(LocalDate.of(2026, 10, 1));
when(drivers.lockById(71)).thenReturn(row);
assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "APPROVED", null))).hasMessageContaining("已过期");
row.setLicenseExpiresOn(LocalDate.of(2027, 10, 1));
row.setLicenseIssuedOn(LocalDate.of(2026, 10, 3));
assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "APPROVED", null))).hasMessageContaining("尚未生效");
verifyNoInteractions(files);
}
@Test void rejectRequiresReasonAndCanBeExplicitlyResubmitted() {
var row = row("PENDING");
when(drivers.lockById(71)).thenReturn(row);
assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "REJECTED", " "))).hasMessageContaining("打回原因");
assertThat(service.review(71, "r", new DriverModels.Review(2L, "REJECTED", "附件不清楚")).reviewStatus()).isEqualTo("REJECTED");
assertThat(service.submit(71, "editor", new DriverModels.Submit(3L)).reviewStatus()).isEqualTo("PENDING");
verify(files).metadata(71, 81);
}
@Test void credentialEditResetsApprovalButPhoneCorrectionDoesNot() {
var row = row("APPROVED");
when(drivers.lockById(71)).thenReturn(row);
var phoneOnly = new DriverModels.Draft("司机", "new-phone", "OWNED", "OVERSEAS", "LIC-1", "C",
null, row.getLicenseExpiresOn(), "UTC", null, null);
assertThat(service.update(71, "e", new DriverModels.Update(2L, phoneOnly)).reviewStatus()).isEqualTo("APPROVED");
var changedExpiry = new DriverModels.Draft("司机", "new-phone", "OWNED", "OVERSEAS", "LIC-1", "C",
null, LocalDate.of(2027, 10, 22), "UTC", null, null);
assertThat(service.update(71, "e", new DriverModels.Update(3L, changedExpiry)).reviewStatus()).isEqualTo("PENDING");
}
@Test void replacingLicenseBindsNewFileAndResetsApproval() {
when(drivers.lockById(71)).thenReturn(row("APPROVED"));
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-test".getBytes());
when(files.upload(71, "e", file)).thenReturn(new DriverModels.File("82", "license.pdf", "application/pdf", 9));
var response = service.uploadLicense(71, 2, "e", file);
assertThat(response.licenseFileId()).isEqualTo("82");
assertThat(response.reviewStatus()).isEqualTo("PENDING");
var event = ArgumentCaptor.forClass(DriverHistoryEntity.class);
verify(history).insert(event.capture());
assertThat(event.getValue().getLicenseFileId()).isEqualTo(82);
}
@Test void staleVersionFailsBeforeUploadOrReviewAndCasFailureHasNoHistory() {
when(drivers.lockById(71)).thenReturn(row("PENDING"));
assertThatThrownBy(() -> service.uploadLicense(71, 1, "e", null)).hasMessageContaining("已被修改");
assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(1L, "APPROVED", null))).hasMessageContaining("已被修改");
verifyNoInteractions(files);
when(drivers.update(any(DriverEntity.class), any())).thenReturn(0);
assertThatThrownBy(() -> service.changeStatus(71, "e", new DriverModels.ChangeStatus(2L, false, "暂停驾驶"))).hasMessageContaining("已被修改");
verify(history, never()).insert(any(DriverHistoryEntity.class));
}
@Test void unlinkedFileCannotBeReadEvenIfItExistsInFileService() {
when(drivers.selectById(71L)).thenReturn(row("APPROVED"));
when(history.selectCount(any())).thenReturn(0L);
assertThatThrownBy(() -> service.download(71, 99)).hasMessageContaining("未关联");
verifyNoInteractions(files);
}
@Test void historicalLinkedFileIsReadableWithExactDriverScope() {
when(drivers.selectById(71L)).thenReturn(row("APPROVED"));
when(history.selectCount(any())).thenReturn(1L);
when(files.metadata(71, 80)).thenReturn(new DriverModels.File("80", "old.pdf", "application/pdf", 8));
when(files.download(71, 80)).thenReturn(new byte[]{1, 2});
assertThat(service.download(71, 80).content()).containsExactly(1, 2);
}
@Test void invalidTimeZoneAndReversedDatesCannotBeSaved() {
var badZone = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null, null, null, "wrong-zone", null, null);
assertThatThrownBy(() -> service.create("e", badZone)).hasMessageContaining("时区");
var badDates = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null,
LocalDate.of(2027, 1, 1), LocalDate.of(2026, 1, 1), "UTC", null, null);
assertThatThrownBy(() -> service.create("e", badDates)).hasMessageContaining("不能早于");
verify(drivers, never()).insert(any(DriverEntity.class));
}
@Test void optionalProfileUsesAuthoritativeWarehouseSnapshotAndDoesNotInventQualificationRequirements() {
when(warehouses.required(92, "OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("92", "WH-US", "公共海外仓", "OVERSEAS"));
var profile = new DriverModels.Profile(92L, "53尺拖车", "中文 / 洛杉矶", LocalDate.of(2020, 1, 1), null, null);
var details = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", "LIC-1", "C", null,
LocalDate.of(2026, 10, 22), "UTC", null, profile);
var response = service.create("e", details);
assertThat(response.warehouseCode()).isEqualTo("WH-US");
assertThat(response.warehouseName()).isEqualTo("公共海外仓");
assertThat(response.details().profile().medicalExpiresOn()).isEqualTo(LocalDate.of(2020, 1, 1));
var captured = ArgumentCaptor.forClass(DriverEntity.class);
verify(drivers).insert(captured.capture());
var row = captured.getValue(); row.setLicenseFileId(81L);
when(drivers.lockById(row.getId())).thenReturn(row);
assertThat(service.review(row.getId(), "r", new DriverModels.Review(0L, "APPROVED", null)).reviewStatus()).isEqualTo("APPROVED");
}
@Test void invalidWarehouseCannotBePersistedAsFreeText() {
when(warehouses.required(92, "OVERSEAS")).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST, "仓库已停用"));
var details = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null, null, null, "UTC", null,
new DriverModels.Profile(92L, null, null, null, null, null));
assertThatThrownBy(() -> service.create("e", details)).hasMessageContaining("仓库已停用");
verify(drivers, never()).insert(any(DriverEntity.class));
}
private static DriverModels.Draft draft(LocalDate expiry) {
return new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null, null, expiry, "UTC", null, null);
}
private static DriverEntity row(String review) {
var row = new DriverEntity();
row.setId(71L); row.setDriverNo("DR-TEST"); row.setName("司机"); row.setPhone("123");
row.setOwnership("OWNED"); row.setRegion("OVERSEAS"); row.setActive(true); row.setReviewStatus(review);
row.setLicenseNumber("LIC-1"); row.setLicenseType("C"); row.setLicenseExpiresOn(LocalDate.of(2026, 10, 22));
row.setLicenseZone("UTC"); row.setLicenseFileId(81L); row.setRecordVersion(2L);
row.setUpdatedAt(LocalDateTime.of(2026, 10, 1, 0, 0)); row.setUpdatedBy("editor");
return row;
}
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.fleet.driver;
import com.baiyue.common.core.api.ApiResponse;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetWarehouseDirectoryTest {
private final FleetWarehouseClient client = mock(FleetWarehouseClient.class);
private final FleetWarehouseDirectory directory = new FleetWarehouseDirectory(client, "test-token");
@Test void onlyActiveMatchingOperatingWarehousesCanBeSelected() {
when(client.activeWarehouses("test-token")).thenReturn(ApiResponse.success(List.of(
new FleetWarehouseClient.Warehouse(1L, "CN", "国内仓", "DOMESTIC", "CN"),
new FleetWarehouseClient.Warehouse(2L, "US", "海外仓", "OVERSEAS", "US"),
new FleetWarehouseClient.Warehouse(3L, "FBA", "目的仓", "FBA", "US"),
new FleetWarehouseClient.Warehouse(4L, "PARTNER", "合作仓", "OVERSEAS_PARTNER", "US"))));
assertThat(directory.options("OVERSEAS")).extracting(DriverModels.WarehouseOption::id).containsExactly("2", "4");
assertThat(directory.required(1, "DOMESTIC").name()).isEqualTo("国内仓");
assertThatThrownBy(() -> directory.required(1, "OVERSEAS")).hasMessageContaining("区域不符");
assertThatThrownBy(() -> directory.required(3, "OVERSEAS")).hasMessageContaining("不存在");
assertThatThrownBy(() -> directory.required(99, "OVERSEAS")).hasMessageContaining("不存在");
}
@Test void failureOrInvalidRegionNeverProducesInventedWarehouseOptions() {
when(client.activeWarehouses("test-token")).thenThrow(new IllegalStateException("unavailable"));
assertThatThrownBy(() -> directory.options("OVERSEAS")).hasMessageContaining("暂不可用");
assertThatThrownBy(() -> directory.options("unknown")).hasMessageContaining("区域");
verify(client, times(1)).activeWarehouses("test-token");
}
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.fleet.driver;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class HttpDriverFileGatewayTest {
@Test void acceptsOnlyMatchingPlatformDriverLicenseMetadata() {
assertThat(HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 12L), 71, 81L).fileId())
.isEqualTo("81");
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(5L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于");
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "DELIVERY_BOL", "71", 81L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于");
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "72", 81L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于");
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 82L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于");
}
@Test void rejectsMissingOversizeOrUnsafeMetadataAndFailedEnvelope() {
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "text/html", 12L), 71, 81L)).hasMessageContaining("不是有效");
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 0L), 71, 81L)).hasMessageContaining("不是有效");
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 10485761L), 71, 81L)).hasMessageContaining("不是有效");
assertThatThrownBy(() -> HttpDriverFileGateway.verified(null, 71, 81L)).hasMessageContaining("不可用");
assertThatThrownBy(() -> HttpDriverFileGateway.verified(new HttpDriverFileGateway.Envelope("A0400", null), 71, 81L)).hasMessageContaining("不可用");
}
private static HttpDriverFileGateway.Envelope envelope(Long owner, String type, String driver, Long id, String contentType, Long size) {
return new HttpDriverFileGateway.Envelope("200", new HttpDriverFileGateway.Metadata(id, owner, type, driver, "license.pdf", contentType, size));
}
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.fleet.maintenance;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class HttpVehicleCareFileGatewayTest {
@Test void aSuccessfulHttpEnvelopeCannotBeReturnedAsImageBytes() {
var json = org.springframework.http.ResponseEntity.ok().contentType(org.springframework.http.MediaType.APPLICATION_JSON).body("{\"code\":\"A0404\"}".getBytes());
assertThatThrownBy(() -> HttpVehicleCareFileGateway.imageContent(json)).hasMessageContaining("不可用");
var image = org.springframework.http.ResponseEntity.ok().contentType(org.springframework.http.MediaType.IMAGE_PNG).body(new byte[]{1, 2, 3});
assertThat(HttpVehicleCareFileGateway.imageContent(image)).containsExactly(1, 2, 3);
}
@Test void metadataMustMatchPlatformRecordCategoryAndFileIdentity() {
assertThat(HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "71", 81, "image/png"), 71, 81L).fileId()).isEqualTo("81");
assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(9, "FLEET_VEHICLE_RECORD_PHOTO", "71", 81, "image/png"), 71, 81L)).hasMessageContaining("不属于");
assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_DRIVER_LICENSE", "71", 81, "image/png"), 71, 81L)).hasMessageContaining("不属于");
assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "72", 81, "image/png"), 71, 81L)).hasMessageContaining("不属于");
assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "71", 82, "image/png"), 71, 81L)).hasMessageContaining("不属于");
assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "71", 81, "text/html"), 71, 81L)).hasMessageContaining("有效照片");
}
private HttpVehicleCareFileGateway.Envelope envelope(long owner, String type, String record, long file, String mime) {
return new HttpVehicleCareFileGateway.Envelope("200", new HttpVehicleCareFileGateway.Metadata(file, owner, type, record, "meter.png", mime, 8L));
}
}
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.fleet.maintenance;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.yaml.snakeyaml.Yaml;
import java.nio.file.*;
import java.util.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
class VehicleCareApiContractTest {
@Test void operationsPermissionsAndSchemasMatchTheirPublishedContracts() throws Exception {
Map<String,Object> document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-vehicle-records-v1.yaml")));
Map<?,?> paths = (Map<?,?>)document.get("paths");
String permissionSql = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql"));
String initialization = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
String base = PlatformVehicleCareController.class.getAnnotation(RequestMapping.class).value()[0];
int count = 0;
for (var method : PlatformVehicleCareController.class.getDeclaredMethods()) {
String verb = null; String[] suffix = null;
if (method.isAnnotationPresent(GetMapping.class)) { verb = "get"; suffix = method.getAnnotation(GetMapping.class).value(); }
if (method.isAnnotationPresent(PostMapping.class)) { verb = "post"; suffix = method.getAnnotation(PostMapping.class).value(); }
if (method.isAnnotationPresent(PutMapping.class)) { verb = "put"; suffix = method.getAnnotation(PutMapping.class).value(); }
if (verb == null) continue;
String path = base + (suffix.length == 0 ? "" : suffix[0]);
assertThat(paths.containsKey(path)).as(path).isTrue();
Map<?,?> operation = (Map<?,?>)((Map<?,?>)paths.get(path)).get(verb);
String expression = method.getAnnotation(PreAuthorize.class).value();
String permission = expression.substring(expression.indexOf('\'') + 1, expression.lastIndexOf('\''));
assertThat(operation.get("description").toString()).contains(permission);
assertThat(permissionSql).contains("'" + permission + "'"); assertThat(initialization).contains("'" + permission + "'"); count++;
}
assertThat(count).isEqualTo(7);
for (String table : List.of("ll_fleet_vehicle_record", "ll_fleet_vehicle_record_history")) {
var pattern = Pattern.compile("(?s)CREATE TABLE " + table + " \\(.*?;");
var module = pattern.matcher(Files.readString(Path.of("src/main/resources/db/lastleg.sql")));
var deployed = pattern.matcher(Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")));
assertThat(module.find()).isTrue(); assertThat(deployed.find()).isTrue();
assertThat(module.group().replace("\r\n", "\n")).isEqualTo(deployed.group().replace("\r\n", "\n"));
}
}
}
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.fleet.maintenance;
import org.junit.jupiter.api.*;
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 java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class VehicleCareAuthorizationTest {
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
@Test void vehicleListReadsRecordsButDoesNotAuthorizeCreateEditOrPhotoUpload() {
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
var controller = context.getBean(PlatformVehicleCareController.class);
var service = context.getBean(VehicleCareService.class);
authenticate("platform:overseas-master-data:vehicle:list");
controller.detail(1, "VR-1"); verify(service).detail(1, "VR-1");
assertThatThrownBy(() -> controller.create(1, null, null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.update(1, "VR-1", null, null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.upload(1, "VR-1", null, 0, "key", null)).isInstanceOf(AccessDeniedException.class);
authenticate("platform:overseas-master-data:driver:list");
assertThatThrownBy(() -> controller.detail(1, "VR-1")).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.download(1, "VR-1", 1)).isInstanceOf(AccessDeniedException.class);
verifyNoMoreInteractions(service);
}
}
private void authenticate(String code) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("user", "unused", List.of(new SimpleGrantedAuthority(code)))); }
@Configuration @EnableMethodSecurity static class Config {
@Bean VehicleCareService service() { return mock(VehicleCareService.class); }
@Bean PlatformVehicleCareController controller(VehicleCareService service) { return new PlatformVehicleCareController(service); }
}
}
@@ -0,0 +1,166 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.*;
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.mock.web.MockMultipartFile;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import java.math.BigDecimal;
import java.nio.file.*;
import java.time.*;
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 VehicleCarePersistenceTest {
private JdbcTemplate jdbc;
private VehicleCareService service;
private VehicleCareFileGateway files;
private long vehicleA, vehicleB;
private final Instant occurred = Instant.parse("2026-01-01T00:00:00Z");
@BeforeEach void setup() throws Exception {
var source = new DriverManagerDataSource("jdbc:h2:mem:care-" + 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_vehicle", "ll_fleet_vehicle_record", "ll_fleet_vehicle_record_history")) {
var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema);
assertThat(match.find()).as(table).isTrue(); jdbc.execute(match.group().replace(" JSON", " LONGTEXT"));
}
var config = new MybatisConfiguration();
config.addMapper(VehicleMapper.class); config.addMapper(VehicleCareMapper.class); config.addMapper(VehicleCareHistoryMapper.class);
var interceptor = new com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));
config.addInterceptor(interceptor);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(source); factory.setConfiguration(config);
var session = new SqlSessionTemplate(factory.getObject());
var vehicles = new VehiclePersistenceAdapter(session.getMapper(VehicleMapper.class));
vehicleA = vehicles.create(vehicle("VEH-A")); vehicleB = vehicles.create(vehicle("VEH-B"));
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()));
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource()));
service = (VehicleCareService) proxy.getProxy();
}
@Test void recordsAreScopedToVehicleAndNeverRewriteMasterOdometerOrCreateAnotherRecordOnRetry() {
var request = new VehicleCareModels.Create("key", maintenance());
var a = service.create(vehicleA, "1", request);
assertThat(service.create(vehicleA, "1", request).recordNo()).isEqualTo(a.recordNo());
var b = service.create(vehicleB, "1", request);
assertThat(b.recordNo()).isNotEqualTo(a.recordNo());
assertThat(service.page(vehicleA, new VehicleCareModels.Query(1, 20, "MAINTENANCE")).records()).extracting(VehicleCareModels.Response::recordNo).containsExactly(a.recordNo());
assertThatThrownBy(() -> service.detail(vehicleB, a.recordNo())).hasMessageContaining("不存在");
assertThatThrownBy(() -> service.update(vehicleB, a.recordNo(), "1", new VehicleCareModels.Update(0L, "错误车辆", maintenance()))).hasMessageContaining("不存在");
assertThatThrownBy(() -> service.download(vehicleB, a.recordNo(), 9001)).hasMessageContaining("不存在");
assertThat(jdbc.queryForList("SELECT mileage_km FROM ll_vehicle", Long.class)).containsOnly(1000L);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record_history", Integer.class)).isEqualTo(2);
}
@Test void correctionKeepsImmutableHistoryAndRejectsStaleVersionOrChangedType() {
var row = service.create(vehicleA, "1", new VehicleCareModels.Create("key", maintenance()));
assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("key", fuel()))).hasMessageContaining("其他运维记录");
assertThatThrownBy(() -> service.update(vehicleA, row.recordNo(), "1", new VehicleCareModels.Update(0L, "变更类型", fuel()))).hasMessageContaining("不能改变");
service.update(vehicleA, row.recordNo(), "2", new VehicleCareModels.Update(0L, "补充核对", maintenance()));
assertThatThrownBy(() -> service.update(vehicleA, row.recordNo(), "1", new VehicleCareModels.Update(0L, "旧页面", maintenance()))).hasMessageContaining("已变化");
var history = service.history(vehicleA, row.recordNo(), 1, 20).records();
assertThat(history).extracting(VehicleCareModels.History::action).containsExactly("UPDATE", "CREATE");
assertThat(history.get(1).snapshot().updatedBy()).isEqualTo("1");
}
@Test void failedHistoryWriteRollsBackUpdate() {
var row = service.create(vehicleA, "1", new VehicleCareModels.Create("key", fuel()));
jdbc.execute("ALTER TABLE ll_fleet_vehicle_record_history ADD CONSTRAINT fail_update CHECK(action <> 'UPDATE')");
assertThatThrownBy(() -> service.update(vehicleA, row.recordNo(), "2", new VehicleCareModels.Update(0L, "更正", fuel()))).isInstanceOf(RuntimeException.class);
assertThat(service.detail(vehicleA, row.recordNo()).recordVersion()).isZero();
assertThat(service.detail(vehicleA, row.recordNo()).updatedBy()).isEqualTo("1");
}
@Test void uploadRetryReturnsOriginalAssociationAndRejectsAnotherImageOrForeignRecord() {
var row = service.create(vehicleA, "1", new VehicleCareModels.Create("key", fuel()));
var image = new MockMultipartFile("file", "photo.png", "image/png", new byte[]{1,2,3});
var uploaded = service.upload(vehicleA, row.recordNo(), 0, "upload-key", "1", image);
assertThat(uploaded.files()).hasSize(1);
assertThat(service.upload(vehicleA, row.recordNo(), 0, "upload-key", "1", image).recordVersion()).isEqualTo(1);
verify(files, times(1)).upload(anyLong(), eq("1"), any());
assertThatThrownBy(() -> service.upload(vehicleA, row.recordNo(), 1, "upload-key", "1",
new MockMultipartFile("file", "other.png", "image/png", new byte[]{4}))).hasMessageContaining("不同图片");
assertThatThrownBy(() -> service.upload(vehicleB, row.recordNo(), 1, "new-key", "1", image)).hasMessageContaining("不存在");
assertThatThrownBy(() -> service.download(vehicleA, row.recordNo(), 9999)).hasMessageContaining("不存在");
verify(files, never()).metadata(anyLong(), anyLong());
}
@Test void explicitUnitsAndTypedFieldsAreRequiredButHistoricalOdometersMayBeBelowTheMaster() {
var row = service.create(vehicleA, "1", new VehicleCareModels.Create("fuel", fuel()));
assertThat(row.details().distanceUnit()).isEqualTo("MI");
assertThat(row.details().fuelUnit()).isEqualTo("US_GAL");
assertThat(row.details().odometer()).isEqualByComparingTo("800");
var bad = new VehicleCareModels.Draft("FUEL", occurred, "MI", new BigDecimal("800"), null, null, new BigDecimal("10"), "GAL", "PENDING", null, null);
assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("bad", bad))).hasMessageContaining("明确");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record", Integer.class)).isEqualTo(1);
}
@Test void concurrentDuplicateCreationCreatesOneBusinessRecordAndOneHistory() throws Exception {
var request = new VehicleCareModels.Create("concurrent", fuel());
var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2);
Callable<String> task = () -> { start.await(5, TimeUnit.SECONDS); return service.create(vehicleA, "1", request).recordNo(); };
try {
var first = executor.submit(task); var second = executor.submit(task); start.countDown();
assertThat(first.get(15, TimeUnit.SECONDS)).isEqualTo(second.get(15, TimeUnit.SECONDS));
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record", Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record_history", Integer.class)).isEqualTo(1);
} finally { executor.shutdownNow(); }
}
@Test void incidentTypesKeepProjectsPlacesAndProcessingFactsIsolatedPerVehicle() {
for (String type : List.of("REPAIR", "BREAKDOWN", "RESCUE", "EXCEPTION")) {
var row = service.create(vehicleA, "1", new VehicleCareModels.Create(type, incident(type, "PENDING", null, null, null)));
assertThat(service.detail(vehicleA, row.recordNo()).details().incident()).isEqualTo(row.details().incident());
assertThat(service.page(vehicleA, new VehicleCareModels.Query(1, 20, type)).records()).hasSize(1);
assertThat(service.page(vehicleB, new VehicleCareModels.Query(1, 20, type)).total()).isZero();
assertThatThrownBy(() -> service.update(vehicleB, row.recordNo(), "1", new VehicleCareModels.Update(0L, "更新处理", row.details()))).hasMessageContaining("不存在");
if (List.of("REPAIR", "RESCUE").contains(type)) {
assertThat(row.details().incident().item()).isEqualTo("更换轮胎");
assertThat(row.details().incident().place()).isEqualTo("实际作业地点");
assertThat(row.paymentStatus()).isEqualTo("UNLINKED");
}
}
}
@Test void completedRepairDoesNotInventPaymentOrReleaseAndChangingCostKeepsHistory() {
var created = service.create(vehicleA, "1", new VehicleCareModels.Create("repair", incident("REPAIR", "PENDING", null, null, null)));
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(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);
assertThat(history.get(1).snapshot().details().incident().reportedCost()).isEqualByComparingTo("120.50");
assertThat(history.get(2).snapshot().details().incident().reportedCost()).isNull();
assertThatThrownBy(() -> service.update(vehicleA, created.recordNo(), "1", new VehicleCareModels.Update(0L, "旧请求", created.details()))).hasMessageContaining("已变化");
}
@Test void incidentValidationRejectsMissingHandlersCurrencyAndMixedFuelFields() {
assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("missing-handler", incident("RESCUE", "COMPLETED", null, null, null)))).hasMessageContaining("处理人");
assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("missing-currency", incident("RESCUE", "PENDING", null, BigDecimal.ZERO, null)))).hasMessageContaining("币种");
assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("invalid-currency", incident("RESCUE", "PENDING", null, BigDecimal.ONE, "ZZZ")))).hasMessageContaining("币种无效");
var detail = incident("EXCEPTION", "PENDING", null, null, null);
var mixed = new VehicleCareModels.Draft(detail.recordType(), occurred, "KM", BigDecimal.ONE, null, null, null, null, null, null, detail.incident());
assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("mixed", mixed))).hasMessageContaining("不能混入");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record", Integer.class)).isZero();
}
private VehicleCareModels.Draft incident(String type, String status, String handler, BigDecimal cost, String currency) {
boolean priced = List.of("REPAIR", "RESCUE").contains(type);
return new VehicleCareModels.Draft(type, occurred, null, null, null, null, null, null, null, null,
new VehicleCareModels.Incident("现场轮胎故障及处理情况", priced ? "更换轮胎" : null, priced ? "实际作业地点" : null,
handler, status, "EXCEPTION".equals(type) ? "VEHICLE_BREAKDOWN" : null, cost, currency));
}
private VehicleCareModels.Draft maintenance() { return new VehicleCareModels.Draft("MAINTENANCE", occurred, "KM", new BigDecimal("800"), new BigDecimal("500"), new BigDecimal("700"), null, null, "PENDING", "已超过计划保养里程", null); }
private VehicleCareModels.Draft fuel() { return new VehicleCareModels.Draft("FUEL", occurred, "MI", new BigDecimal("800"), null, null, new BigDecimal("28.6"), "US_GAL", "PENDING", null, null); }
private VehicleRepository.NewVehicle vehicle(String plate) { return new VehicleRepository.NewVehicle(plate, "Box Truck", new BigDecimal("12.5"), 1000L, null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null, "1", LocalDateTime.now(), 30, new BigDecimal("18000"), new BigDecimal("90")); }
}
@@ -0,0 +1,69 @@
package com.baiyue.lastleg.fleet.trip;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetBookingFreightTest {
final FleetBookingMapper bookings = mock(FleetBookingMapper.class);
final FleetTripDispatchMapper bindings = mock(FleetTripDispatchMapper.class);
final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
final FleetBookingReservations reservations = new FleetBookingReservations(bookings, json);
final FleetBookingFreight service = new FleetBookingFreight(bookings, bindings, reservations, json);
FleetBookingEntity booking;
final FleetDispatchGateway.SourceCargo source = new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 12, "FBA:ONT8");
@BeforeEach void setup() throws Exception {
booking = new FleetBookingEntity();
booking.setId(1L); booking.setTripId(2L); booking.setBookingNo("FB-1"); booking.setIncomingNo("WT-1");
booking.setDispatchNo("DP-1"); booking.setCustomerId("C-1"); booking.setCustomerName("客户甲");
booking.setDestinationKey("FBA:ONT8"); booking.setStatus("BOUND"); booking.setRecordVersion(4L);
booking.setAgreedAmount(new BigDecimal("60.00"));
booking.setCargoJson(json.writeValueAsString(new FleetBookingModels.Cargo("FBA:ONT8", "测试货物", 12, 2,
BigDecimal.TEN, BigDecimal.ONE, "PALLET", new BigDecimal("30.00"), "CNY", null, null, null)));
booking.setReceiptJson(json.writeValueAsString(new FleetBookingModels.AcceptedReceipt(
new FleetBookingIncomingGateway.Evidence("WT-1", "IN-1", "WH-1", "FBA:ONT8", "READY_FOR_DISPATCH", "receipt-1",
List.of(new FleetBookingIncomingGateway.Pallet("501", "PLT-501", 12, BigDecimal.TEN, BigDecimal.ONE)), 12),
List.of(new FleetBookingModels.PalletSpaces("501", 2)), "OP-1", Instant.parse("2026-10-01T00:00:00Z"))));
var binding = new FleetTripDispatch(); binding.setTripId(2L); binding.setDispatchId(3L);
when(bindings.findByDispatchNo("DP-1")).thenReturn(binding);
when(bookings.incomingForFreight("WT-1")).thenReturn(booking);
}
@Test void quoteUsesConfirmedActualPriceAndSeparatesOtherCargo() {
var preview = service.preview(3, "DP-1", "C-1", List.of(source));
assertFalse(preview.requiresDeliveryFee()); assertEquals(new BigDecimal("60.00"), preview.quotes().get(0).amount());
assertEquals("IN-1", preview.quotes().get(0).inboundCode());
var other = new FleetDispatchGateway.SourceCargo("IN-OTHER", "502", "PLT-502", 8, "FBA:LAX9");
var mixed = service.preview(3, "DP-1", "C-1", List.of(source, other));
assertTrue(mixed.requiresDeliveryFee()); assertEquals(1, mixed.quotes().size());
assertEquals(mixed.snapshotHash(), service.preview(3, "DP-1", "C-1", List.of(other, source)).snapshotHash());
}
@Test void rejectsUnboundDifferentCustomerOrWrongTrip() {
assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-2", List.of(source)));
booking.setStatus("RECEIVED");
assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of(source)));
booking.setStatus("BOUND"); booking.setTripId(99L);
assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of(source)));
}
@Test void rejectsPartialOrChangedDestinationAndDetectsNewQuoteVersion() {
var before = service.preview(3, "DP-1", "C-1", List.of(source));
assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of(
new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 11, "FBA:ONT8"))));
assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of(
new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 12, "FBA:LAX9"))));
booking.setRecordVersion(5L);
assertNotEquals(before.snapshotHash(), service.preview(3, "DP-1", "C-1", List.of(source)).snapshotHash());
}
@Test void cancellingTheBookingDoesNotEraseThePreviousFinancialSource() throws Exception {
booking.setStatus("CANCELLED"); booking.setDispatchNo(null);
var preview = service.preview(9, "DP-NEW", "C-1", List.of(source));
assertTrue(preview.requiresDeliveryFee()); assertTrue(preview.quotes().isEmpty());
assertEquals(List.of("IN-1"), preview.priorInboundCodes());
assertFalse(json.writeValueAsString(preview).contains("priorInboundCodes"));
}
}
@@ -0,0 +1,74 @@
package com.baiyue.lastleg.fleet.trip;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.yaml.snakeyaml.Yaml;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
class FleetTripApiContractTest {
@Test void allTripOperationsHaveOpenApiAndRealPermissions() throws Exception {
Map<String, Object> document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-trips-v1.yaml")));
Map<?, ?> paths = (Map<?, ?>) document.get("paths");
String module = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql"));
String deployed = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
int count = 0;
for (var controller : java.util.List.of(PlatformFleetTripController.class, PlatformFleetBookingController.class)) {
String base = controller.getAnnotation(RequestMapping.class).value()[0];
for (var method : controller.getDeclaredMethods()) {
String verb = null; String[] suffix = null;
if (method.isAnnotationPresent(GetMapping.class)) { verb = "get"; suffix = method.getAnnotation(GetMapping.class).value(); }
if (method.isAnnotationPresent(PostMapping.class)) { verb = "post"; suffix = method.getAnnotation(PostMapping.class).value(); }
if (method.isAnnotationPresent(PutMapping.class)) { verb = "put"; suffix = method.getAnnotation(PutMapping.class).value(); }
if (verb == null) continue;
String path = base + (suffix.length == 0 ? "" : suffix[0]);
assertThat(paths.containsKey(path)).as(path).isTrue();
Map<?, ?> operation = (Map<?, ?>) ((Map<?, ?>) paths.get(path)).get(verb);
String expression = method.getAnnotation(PreAuthorize.class).value();
var authorities = Pattern.compile("'([^']+)'").matcher(expression);
int permissionCount=0;
while(authorities.find()) {
String permission=authorities.group(1);
assertThat(operation.get("description").toString()).contains(permission);
assertThat(module).contains("'" + permission + "'");
assertThat(deployed).contains("'" + permission + "'");
permissionCount++;
}
assertThat(permissionCount).isPositive();
count++;
}
}
assertThat(count).isEqualTo(28);
verifyRefs(document, document);
}
@Test void schemasMatchAndUseUniqueResourceClaimsAndVersionedHistory() throws Exception {
String module = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
String deployed = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
for (String table : java.util.List.of("ll_fleet_trip", "ll_fleet_resource_assignment", "ll_fleet_trip_history", "ll_fleet_trip_dispatch", "ll_fleet_external_booking")) {
var pattern = Pattern.compile("(?s)CREATE TABLE " + table + " \\(.*?;");
var a = pattern.matcher(module); var b = pattern.matcher(deployed);
assertThat(a.find()).isTrue(); assertThat(b.find()).isTrue();
assertThat(a.group().replace("\r\n", "\n")).isEqualTo(b.group().replace("\r\n", "\n"));
}
assertThat(deployed).contains("PRIMARY KEY (resource_kind,resource_id)")
.contains("UNIQUE KEY uk_fleet_trip_create (created_by,idempotency_key)")
.contains("UNIQUE KEY uk_fleet_trip_history_version (trip_id,record_version)");
}
private static void verifyRefs(Object node, Map<String, Object> root) {
if (node instanceof Map<?, ?> map) {
if (map.get("$ref") instanceof String reference) {
assertThat(reference).startsWith("#/");
Object value = root;
for (String key : reference.substring(2).split("/")) value = ((Map<?, ?>) value).get(key);
assertThat(value).as(reference).isNotNull();
}
map.values().forEach(value -> verifyRefs(value, root));
} else if (node instanceof Iterable<?> values) values.forEach(value -> verifyRefs(value, root));
}
}
@@ -0,0 +1,55 @@
package com.baiyue.lastleg.fleet.trip;
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 java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetTripAuthorizationTest {
@Test void listPermissionCannotReserveOrReleaseResources() {
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(PlatformFleetTripController.class);
var service = context.getBean(FleetTripService.class);
controller.detail("TR-TEST"); verify(service).detail("TR-TEST");
assertThatThrownBy(() -> controller.create(null, null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.assign(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.cancel(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.plan(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.bind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.unbind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.confirmLoading(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.reviewDispatch(null, "TR-TEST", "DP-1", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.reviewLoading(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.complete(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.release(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.confirmCapacityPlan(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
var booking = context.getBean(PlatformFleetBookingController.class);
booking.list("TR-TEST"); verify(context.getBean(FleetBookingService.class)).list("TR-TEST");
assertThatThrownBy(() -> booking.create(null,"TR-TEST",null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> booking.update(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> booking.confirm(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> booking.cancel(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> booking.incoming(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> booking.receipt(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> booking.bind(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class);
verifyNoMoreInteractions(service);
} finally { SecurityContextHolder.clearContext(); }
}
@Configuration @EnableMethodSecurity
static class Config {
@Bean FleetBookingService bookingService() { return mock(FleetBookingService.class); }
@Bean com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customers() { return mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); }
@Bean PlatformFleetBookingController booking(FleetBookingService service, com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customers) { return new PlatformFleetBookingController(service,customers); }
@Bean FleetTripService service() { return mock(FleetTripService.class); }
@Bean FleetTripExecutionService execution() { return mock(FleetTripExecutionService.class); }
@Bean FleetTripCompletionService completion() { return mock(FleetTripCompletionService.class); }
@Bean PlatformFleetTripController controller(FleetTripService service, FleetTripExecutionService execution, FleetTripCompletionService completion) { return new PlatformFleetTripController(service, execution, completion); }
}
}
@@ -0,0 +1,866 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.fleet.driver.*;
import com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService;
import org.springframework.test.util.ReflectionTestUtils;
import com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.*;
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService;
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
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.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
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.mock.web.MockMultipartFile;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.*;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class FleetTripPersistenceTest {
private JdbcTemplate jdbc;
private FleetTripService service;
private DriverService drivers;
private VehicleRepository vehicles;
private VehicleApplicationService vehicleService;
private DataSourceTransactionManager transactions;
private FleetDispatchBindingGuard bindingGuard;
private FleetTripExecutionService execution;
private FleetTripCompletionService completion;
private FleetBookingService bookingService;
private FleetBookingIncomingGateway incoming;
private Instant departure, end;
@BeforeEach void initialize() throws Exception {
departure = Instant.now().plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.MILLIS);
end = departure.plus(12, ChronoUnit.HOURS);
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(dataSource); transactions = new DataSourceTransactionManager(dataSource);
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for (String table : List.of("ll_fleet_driver", "ll_fleet_driver_history", "ll_vehicle", "ll_fleet_trip", "ll_fleet_resource_assignment", "ll_fleet_trip_history",
"ll_fleet_external_booking", "ll_fleet_trip_dispatch", "ll_dispatch_order", "ll_dispatch_pda_task", "ll_outbound_order", "ll_delivery_order",
"ll_dispatch_item", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation", "ll_dispatch_pallet_lock", "ll_delivery_operation")) {
var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema);
assertThat(match.find()).as(table).isTrue();
jdbc.execute(match.group().replace(" JSON", " LONGTEXT"));
}
var configuration = new MybatisConfiguration();
for (var mapper : List.of(DriverMapper.class, DriverHistoryMapper.class, VehicleMapper.class, FleetTripMapper.class, FleetResourceMapper.class,
FleetTripHistoryMapper.class, FleetTripDispatchMapper.class, FleetBookingMapper.class, DispatchOrderMapper.class, DispatchPdaTaskMapper.class, OutboundOrderMapper.class, DeliveryOrderMapper.class,
DispatchItemMapper.class, DispatchPdaPickMapper.class, DispatchPdaOperationMapper.class, DispatchPalletLockMapper.class, DeliveryOperationMapper.class)) configuration.addMapper(mapper);
var pagination = new com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor();
pagination.addInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));
configuration.addInterceptor(pagination);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(configuration);
var session = new SqlSessionTemplate(factory.getObject());
var directory = mock(FleetWarehouseDirectory.class);
when(directory.required(92, "OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("92", "WH-US", "公共海外仓", "OVERSEAS"));
var files = mock(DriverFileGateway.class);
when(files.upload(anyLong(), anyString(), any())).thenAnswer(call -> new DriverModels.File(Long.toString(call.getArgument(0, Long.class) + 1000), "license.pdf", "application/pdf", 8));
when(files.metadata(anyLong(), anyLong())).thenAnswer(call -> new DriverModels.File(Long.toString(call.getArgument(1, Long.class)), "license.pdf", "application/pdf", 8));
var json = new ObjectMapper().registerModule(new JavaTimeModule());
drivers = transactional(new DriverService(session.getMapper(DriverMapper.class), session.getMapper(DriverHistoryMapper.class), files, json, directory), DriverService.class);
vehicles = new VehiclePersistenceAdapter(session.getMapper(VehicleMapper.class));
var resources = session.getMapper(FleetResourceMapper.class);
vehicleService = transactional(new VehicleApplicationService(vehicles, new FleetVehicleAssignmentGuard(resources)), VehicleApplicationService.class);
var bookingMapper = session.getMapper(FleetBookingMapper.class);
var reservations = new FleetBookingReservations(bookingMapper, json);
var bindingMapper = session.getMapper(FleetTripDispatchMapper.class);
bindingGuard = transactional(new FleetDispatchBindingGuard(bindingMapper, json), FleetDispatchBindingGuard.class);
var workflow = new DispatchPdaWorkflowService();
ReflectionTestUtils.setField(workflow, "orders", session.getMapper(DispatchOrderMapper.class));
ReflectionTestUtils.setField(workflow, "outboundOrders", session.getMapper(OutboundOrderMapper.class));
ReflectionTestUtils.setField(workflow, "items", session.getMapper(DispatchItemMapper.class));
ReflectionTestUtils.setField(workflow, "tasks", session.getMapper(DispatchPdaTaskMapper.class));
ReflectionTestUtils.setField(workflow, "picks", session.getMapper(DispatchPdaPickMapper.class));
ReflectionTestUtils.setField(workflow, "operations", session.getMapper(DispatchPdaOperationMapper.class));
ReflectionTestUtils.setField(workflow, "palletLocks", session.getMapper(DispatchPalletLockMapper.class));
ReflectionTestUtils.setField(workflow, "fleetBindings", bindingGuard);
ReflectionTestUtils.setField(workflow, "objectMapper", json);
ReflectionTestUtils.setField(workflow, "pickAttribution", mock(DispatchPickOrderAttributionService.class));
var dispatchGateway = transactional(new DispatchFleetAdapter(session.getMapper(DispatchOrderMapper.class), session.getMapper(DispatchPdaTaskMapper.class),
session.getMapper(OutboundOrderMapper.class), session.getMapper(DeliveryOrderMapper.class), session.getMapper(DispatchItemMapper.class), session.getMapper(DispatchPdaPickMapper.class),
transactional(workflow, DispatchPdaWorkflowService.class), session.getMapper(DeliveryOperationMapper.class)), DispatchFleetAdapter.class);
service = transactional(new FleetTripService(session.getMapper(FleetTripMapper.class), resources, session.getMapper(FleetTripHistoryMapper.class), vehicles, drivers, directory, json,
bindingMapper, dispatchGateway, reservations), FleetTripService.class);
execution = transactional(new FleetTripExecutionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, drivers, vehicles, json, reservations), FleetTripExecutionService.class);
var customerDirectory = mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class);
when(customerDirectory.required(anyLong())).thenAnswer(call -> new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer(
Long.toString(call.getArgument(0,Long.class)), "00001", "外接客户", null, null));
incoming=mock(FleetBookingIncomingGateway.class);
bookingService = transactional(new FleetBookingService(session.getMapper(FleetTripMapper.class), bookingMapper, reservations, service, execution, customerDirectory, json, incoming), FleetBookingService.class);
completion = transactional(new FleetTripCompletionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, vehicles, vehicleService, json), FleetTripCompletionService.class);
}
@Test void creationReplayUsesSameBusinessNumberAndRejectsChangedPayload() {
var request = createRequest("creation-one");
var first = service.create("1", request);
assertThat(service.create("1", request).trip().tripNo()).isEqualTo(first.trip().tripNo());
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip", Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history", Integer.class)).isEqualTo(1);
assertThatThrownBy(() -> service.create("1", new FleetTripModels.Create(92L, departure, end.plusSeconds(1), request.stops(), null, request.idempotencyKey())))
.hasMessageContaining("不同车次内容");
assertThat(service.create("2", request).trip().tripNo()).isNotEqualTo(first.trip().tripNo());
}
@Test void assignmentLocksBothResourcesAndCancellationReleasesThemWithoutRemovingHistory() {
long vehicle = vehicle(true), driver = driver(true);
String first = trip(), second = trip();
var assigned = service.assign(first, "1", new FleetTripModels.Assign(0L, vehicle, driver, "安排运输"));
assertThat(assigned.driverQualification().canDispatch()).isTrue();
assertThat(assigned.trip().maxPalletSpaces()).isEqualTo(30);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2);
assertThatThrownBy(() -> service.assign(second, "1", new FleetTripModels.Assign(0L, vehicle, driver, "重复分配"))).hasMessageContaining("占用");
assertThat(service.cancel(first, "1", new FleetTripModels.Cancel(1L, "取消安排")).trip().status()).isEqualTo("CANCELLED");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero();
assertThat(service.history(first, 1, 20).records()).extracting(FleetTripModels.History::action).containsExactly("CANCEL", "ASSIGN", "CREATE");
assertThat(service.assign(second, "1", new FleetTripModels.Assign(0L, vehicle, driver, "重新安排")).trip().vehicleId()).isEqualTo(Long.toString(vehicle));
}
@Test void failedReassignmentKeepsOldResourcesAndRollsBackTheNewVehicleClaim() {
long oldVehicle = vehicle(true), occupiedVehicle = vehicle(true), replacement = vehicle(true);
long oldDriver = driver(true), occupiedDriver = driver(true);
String first = trip(), second = trip();
service.assign(first, "1", new FleetTripModels.Assign(0L, oldVehicle, oldDriver, "安排"));
service.assign(second, "1", new FleetTripModels.Assign(0L, occupiedVehicle, occupiedDriver, "安排"));
assertThatThrownBy(() -> service.assign(first, "1", new FleetTripModels.Assign(1L, replacement, occupiedDriver, "改派"))).hasMessageContaining("占用");
var retained = service.detail(first).trip();
assertThat(retained.vehicleId()).isEqualTo(Long.toString(oldVehicle));
assertThat(retained.recordVersion()).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id=?", Integer.class, replacement)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(4);
}
@Test void missingCapacityOrUnapprovedDriverCannotBeAssigned() {
String number = trip();
long unknownCapacity = vehicle(false), validDriver = driver(true);
assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(0L, unknownCapacity, validDriver, "安排"))).hasMessageContaining("托位、载重和容积");
long validVehicle = vehicle(true), missingLicense = driver(false);
assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(0L, validVehicle, missingLicense, "安排"))).hasMessageContaining("驾驶证");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero();
assertThat(service.detail(number).trip().recordVersion()).isZero();
}
@Test void extendingPlanPastLicenseExpiryFailsAndKeepsTheOldPlan() {
String number = trip();
service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle(true), driver(true), "安排"));
assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(1L, departure, end.plus(60, ChronoUnit.DAYS), stops(), null, "延期")))
.hasMessageContaining("预计任务结束前到期");
assertThat(service.detail(number).trip().expectedDriverEndAt()).isEqualTo(end);
assertThat(service.detail(number).trip().recordVersion()).isEqualTo(1);
}
@Test void failedHistoryWriteRollsBackTripAndAllResourceClaims() {
String number = trip(); long vehicle = vehicle(true), driver = driver(true);
jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT reject_assignment CHECK(action <> 'ASSIGN')");
assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle, driver, "安排"))).isInstanceOf(RuntimeException.class);
assertThat(service.detail(number).trip().vehicleId()).isNull();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero();
assertThat(service.detail(number).trip().recordVersion()).isZero();
}
@Test void activeTripPreventsMasterDataFromChangingPlateCapacityOrStatus() {
long vehicle = vehicle(true); String number = trip();
service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle, driver(true), "安排"));
assertThatThrownBy(() -> vehicleService.changeStatus(vehicle, "1", new VehicleModels.ChangeStatus("MAINTENANCE", 0L))).hasMessageContaining(number);
var original = vehicles.findById(vehicle).orElseThrow();
assertThatThrownBy(() -> vehicleService.update(vehicle, "1", new VehicleModels.Update(0L, "CHANGED", original.model(), original.fuelConsumptionPer100Km(),
original.mileageKm(), null, null, original.region(), original.ownership(), null, 30, new BigDecimal("18000"), new BigDecimal("90")))).hasMessageContaining(number);
assertThatThrownBy(() -> vehicleService.update(vehicle, "1", new VehicleModels.Update(0L, original.plateNumber(), original.model(), original.fuelConsumptionPer100Km(),
original.mileageKm(), null, null, original.region(), original.ownership(), null, 20, new BigDecimal("18000"), new BigDecimal("90")))).hasMessageContaining(number);
service.cancel(number, "1", new FleetTripModels.Cancel(1L, "取消"));
assertThat(vehicleService.changeStatus(vehicle, "1", new VehicleModels.ChangeStatus("MAINTENANCE", 0L)).status()).isEqualTo("MAINTENANCE");
}
@Test void simultaneousVehicleAssignmentHasExactlyOneWinner() throws Exception {
String first = trip(), second = trip(); long vehicle = vehicle(true), driver1 = driver(true), driver2 = driver(true);
var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2);
try {
var a = executor.submit(() -> attempt(start, first, vehicle, driver1));
var b = executor.submit(() -> attempt(start, second, vehicle, driver2));
start.countDown();
assertThat(List.of(a.get(15, TimeUnit.SECONDS), b.get(15, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_kind='VEHICLE'", Integer.class)).isEqualTo(1);
} finally { executor.shutdownNow(); }
}
@Test void concurrentCreationWithTheSameKeyCreatesOneTripAndOneHistory() throws Exception {
var create = createRequest("concurrent-create");
var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2);
Callable<String> command = () -> { start.await(5, TimeUnit.SECONDS); return service.create("1", create).trip().tripNo(); };
try {
var first = executor.submit(command); var second = executor.submit(command); start.countDown();
assertThat(first.get(15, TimeUnit.SECONDS)).isEqualTo(second.get(15, TimeUnit.SECONDS));
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip", Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history", Integer.class)).isEqualTo(1);
} finally { executor.shutdownNow(); }
}
@Test void tripCombinesCustomersAndSynchronizesSnapshotsWithoutChangingTheirOwnershipOrArrivalTime() {
String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92");
delivery(11, "CLIENT-A"); delivery(12, "CLIENT-B");
service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入本车"));
var bound = service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入本车"));
assertThat(bound.dispatches()).extracting(FleetTripModels.DispatchBinding::customerId).containsExactly("CLIENT-A", "CLIENT-B");
long replacement = vehicle(true);
var reassigned = service.assign(number, "1", new FleetTripModels.Assign(3L, replacement, driver(true), "更换车辆司机"));
assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(reassigned.trip().vehiclePlate());
assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_delivery_order", String.class)).containsOnly(reassigned.trip().vehiclePlate());
assertThat(jdbc.queryForList("SELECT customer_id FROM ll_delivery_order ORDER BY id", String.class)).containsExactly("CLIENT-A", "CLIENT-B");
assertThat(jdbc.queryForList("SELECT version FROM ll_delivery_order", Long.class)).containsOnly(2L);
assertThat(jdbc.queryForObject("SELECT expected_arrival_at FROM ll_delivery_order WHERE id=2011", LocalDateTime.class)).isEqualTo(LocalDateTime.of(2030, 1, 1, 12, 0));
assertThat(service.history(number, 1, 20).records().get(0).snapshot().dispatches()).hasSize(2);
}
@Test void unbindClearsCurrentSnapshotsButKeepsHistoryAndRequiresUnpickedCargo() {
String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); delivery(11, "CLIENT-A");
service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入"));
assertThatThrownBy(() -> service.cancel(number, "1", new FleetTripModels.Cancel(2L, "取消"))).hasMessageContaining("先解除");
var tx = new org.springframework.transaction.support.TransactionTemplate(transactions);
assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requireUnbound(11); return null; })).hasMessageContaining(number);
assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requireCoordinatedDeparture(11); return null; })).hasMessageContaining("统一发车");
assertThat(execution.gate("92", "DP-11").tripNo()).isEqualTo(number);
var assigned = service.detail(number).trip();
var actual = new FleetDispatchGateway.Assignment(assigned.vehiclePlate(), assigned.driverName(), assigned.driverPhone());
assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requireMatchingAssignment(11, actual,
new FleetDispatchGateway.Assignment("OTHER-PLATE", null, null)); return null; })).hasMessageContaining("统一维护");
tx.execute(status -> { bindingGuard.requireMatchingAssignment(11, actual, new FleetDispatchGateway.Assignment(null, null, null)); return null; });
jdbc.update("UPDATE ll_dispatch_order SET picked_package_count=1 WHERE id=11");
assertThatThrownBy(() -> service.unbindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-11", "撤下"))).hasMessageContaining("已开始执行");
jdbc.update("UPDATE ll_dispatch_order SET picked_package_count=0 WHERE id=11");
service.unbindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-11", "撤下"));
assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_dispatch_order WHERE id=11", String.class)).isNull();
assertThat(jdbc.queryForObject("SELECT driver_name FROM ll_delivery_order WHERE id=2011", String.class)).isNull();
assertThat(service.history(number, 1, 20).records().get(1).snapshot().dispatches()).hasSize(1);
assertThat(service.detail(number).dispatches()).isEmpty();
service.cancel(number, "1", new FleetTripModels.Cancel(3L, "取消"));
}
@Test void wrongWarehouseAndStaleVersionCannotBindOrMutateCustomerDocuments() {
String number = assignedTrip(); dispatch(11, "CLIENT-A", "93");
assertThatThrownBy(() -> service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入"))).hasMessageContaining("同一起运仓");
jdbc.update("UPDATE ll_dispatch_order SET overseas_warehouse_id='92' WHERE id=11");
assertThatThrownBy(() -> service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(0L, "DP-11", "加入"))).hasMessageContaining("已被修改");
assertThat(service.detail(number).dispatches()).isEmpty();
assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_dispatch_order WHERE id=11", String.class)).isNull();
}
@Test void bindingHistoryFailureRollsBackAssociationAndEveryDownstreamSnapshot() {
String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); delivery(11, "CLIENT-A");
jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT reject_binding CHECK(action <> 'BIND_DISPATCH')");
assertThatThrownBy(() -> service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入"))).isInstanceOf(RuntimeException.class);
assertThat(service.detail(number).dispatches()).isEmpty();
assertThat(service.detail(number).trip().recordVersion()).isEqualTo(1L);
assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_dispatch_order WHERE id=11", String.class)).isNull();
assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_delivery_order WHERE id=2011", String.class)).isNull();
assertThat(jdbc.queryForObject("SELECT version FROM ll_delivery_order WHERE id=2011", Long.class)).isZero();
}
@Test void failedReassignmentOfOneExecutedMemberRollsBackOtherMembersAndResourceClaims() {
String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92");
service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入"));
service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入"));
String original = service.detail(number).trip().vehiclePlate();
jdbc.update("UPDATE ll_dispatch_order SET status='DEPARTED',shipped_package_count=10 WHERE id=12");
assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(3L, vehicle(true), driver(true), "改派"))).hasMessageContaining("已开始执行");
assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(original);
assertThat(service.detail(number).trip().recordVersion()).isEqualTo(3L);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2);
assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(3L, departure, end.plusSeconds(60), stops(), null, "延后")))
.hasMessageContaining("已开始执行");
}
@Test void concurrentBindingToDifferentTripsHasExactlyOneWinner() throws Exception {
String first = assignedTrip(), second = assignedTrip(); dispatch(11, "CLIENT-A", "92");
var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2);
try {
var a = executor.submit(() -> bindAttempt(start, first)); var b = executor.submit(() -> bindAttempt(start, second)); start.countDown();
assertThat(List.of(a.get(15, TimeUnit.SECONDS), b.get(15, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch", Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='BIND_DISPATCH'", Integer.class)).isEqualTo(1);
} finally { executor.shutdownNow(); }
}
@Test void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation() {
String number=readyTrip();var basis=execution.capacityPlan(number);
execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划"));
var draft=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(2),"receipt-create"));
var confirmed=bookingService.confirm(number,draft.booking().bookingNo(),"1",new FleetBookingModels.Command(draft.tripRecordVersion(),0L,"receipt-reserve",null));
var awaiting=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_PDA_RECEIVING","empty",List.of(),0);
when(incoming.create(eq("1"),any())).thenReturn(awaiting);
var request=new FleetBookingModels.StartIncoming(confirmed.tripRecordVersion(),confirmed.booking().recordVersion(),null,null,null,null,null,null,"incoming-start");
var started=bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request);
assertThat(bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request).replayed()).isTrue();
verify(incoming,times(1)).create(eq("1"),any());
assertThat(started.booking().status()).isEqualTo("CONFIRMED");
var actual=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_CUSTOMER_CONFIRMATION","actual-receipt",
List.of(new FleetBookingIncomingGateway.Pallet("902","PALLET-902",22,new BigDecimal("150"),new BigDecimal("1.5"))),22);
when(incoming.inspect("9","WT-EXT",true)).thenReturn(actual);
when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(actual);
assertThatThrownBy(()->bookingService.acceptReceipt(number,draft.booking().bookingNo(),"1",new FleetBookingModels.AcceptReceipt(started.tripRecordVersion(),started.booking().recordVersion(),actual.receiptHash(),
List.of(new FleetBookingModels.PalletSpaces("902",1)),new BigDecimal("55.00"),"accept-receipt"))).hasMessageContaining("实收确认未完成");
assertThat(bookingService.list(number).records().get(0).agreedAmount()).isEqualByComparingTo("100.00");
when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(new FleetBookingIncomingGateway.Evidence(actual.incomingNo(),actual.inboundCode(),actual.warehouseId(),actual.destinationKey(),"READY_FOR_DISPATCH",actual.receiptHash(),actual.pallets(),actual.receivedPackages()));
var accepted=bookingService.acceptReceipt(number,draft.booking().bookingNo(),"1",new FleetBookingModels.AcceptReceipt(started.tripRecordVersion(),started.booking().recordVersion(),actual.receiptHash(),
List.of(new FleetBookingModels.PalletSpaces("902",1)),new BigDecimal("55.00"),"accept-receipt"));
assertThat(accepted.booking().status()).isEqualTo("RECEIVED");assertThat(accepted.booking().cargo().packageCount()).isEqualTo(22);
assertThat(accepted.booking().agreedAmount()).isEqualByComparingTo("55.00");
assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28);
dispatch(13,"9","92");
jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,warehouse_pallet_id,pallet_no,package_count,source_stage,destination_warehouse_code,overseas_warehouse_id,inbound_code) VALUES(3013,13,'9',902,'PALLET-902',22,'RECEIVED','ONT8','92','WT-EXT')");
var linked=bookingService.bindDispatch(number,draft.booking().bookingNo(),"1",new FleetBookingModels.BindDispatch(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"DP-13","link-external"));
assertThat(linked.booking().status()).isEqualTo("BOUND");assertThat(linked.booking().dispatchNo()).isEqualTo("DP-13");
assertThat(execution.capacityPlan(number).planValid()).isFalse();
assertThat(service.detail(number).dispatches()).hasSize(3);
service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(linked.tripRecordVersion(),"DP-13","撤销未开工关联"));
assertThat(bookingService.list(number).records().get(0).status()).isEqualTo("RECEIVED");
assertThat(bookingService.list(number).records().get(0).dispatchNo()).isNull();
}
@Test void externalInquiryDoesNotReserveUntilConfirmedAndCancellationReleasesExactlyOnce() {
String number=readyTrip(); var actual=confirm(number); var basis=execution.capacityPlan(number);
execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划"));
var create=new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(10),"external-create-one");
var draft=bookingService.create(number,"1",create);
assertThat(bookingService.create(number,"1",create).booking().bookingNo()).isEqualTo(draft.booking().bookingNo());
assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(29);
var command=new FleetBookingModels.Command(draft.tripRecordVersion(),draft.booking().recordVersion(),"external-confirm-one",null);
var accepted=bookingService.confirm(number,draft.booking().bookingNo(),"1",command);
assertThat(accepted.booking().agreedAmount()).isEqualByComparingTo("500");
assertThat(bookingService.confirm(number,draft.booking().bookingNo(),"1",command).replayed()).isTrue();
assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(19);
assertThat(execution.gate("92","DP-11").blockReason()).contains("外接预订");
assertThat(execution.loading(number).confirmedLoad()).isEqualTo(actual.confirmedLoad());
var cancel=new FleetBookingModels.Command(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"external-cancel-one",null);
var cancelled=bookingService.cancel(number,draft.booking().bookingNo(),"1",cancel);
assertThat(bookingService.cancel(number,draft.booking().bookingNo(),"1",cancel).tripRecordVersion()).isEqualTo(cancelled.tripRecordVersion());
assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(29);
assertThat(execution.gate("92","DP-11").canDepart()).isTrue();
assertThat(service.history(number,1,20).records().stream().filter(row->row.action().equals("BOOKING_CONFIRM")).findFirst().orElseThrow()
.snapshot().externalBookings().get(0).status()).isEqualTo("CONFIRMED");
}
@Test void secondExternalBookingCannotOversellAndOwnPlanCannotConsumeCommittedExternalSpace() {
String number=readyTrip();var basis=execution.capacityPlan(number);
execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划"));
var first=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(20),"external-first"));
bookingService.confirm(number,first.booking().bookingNo(),"1",new FleetBookingModels.Command(first.tripRecordVersion(),0L,"confirm-first",null));
var second=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),10L,externalCargo(10),"external-second"));
assertThatThrownBy(()->bookingService.confirm(number,second.booking().bookingNo(),"1",new FleetBookingModels.Command(second.tripRecordVersion(),0L,"confirm-second",null))).hasMessageContaining("超过车辆");
assertThat(bookingService.list(number).records().get(1).status()).isEqualTo("DRAFT");
var excessive=new FleetExecutionModels.Measurement("901",11,new BigDecimal("100"),new BigDecimal("1"),1);
assertThatThrownBy(()->execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(second.tripRecordVersion(),basis.manifestHash(),List.of(excessive),"增加自有货计划"))).hasMessageContaining("超过车辆");
assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(9);
var changed = service.updatePlan(number,"1",new FleetTripModels.Plan(second.tripRecordVersion(),departure,end.plusSeconds(60),stops(),null,"调整时间"));
assertThat(execution.capacityPlan(number).capacityPlan()).isNull();
service.assign(number,"1",new FleetTripModels.Assign(changed.trip().recordVersion(),Long.valueOf(changed.trip().vehicleId()),driver(true),"同车换司机"));
assertThat(bookingService.list(number).records().get(0).status()).isEqualTo("CONFIRMED");
}
private static FleetBookingModels.Cargo externalCargo(int spaces){return new FleetBookingModels.Cargo("FBA:ONT8","普通货",20,spaces,new BigDecimal("1000"),new BigDecimal("10"),"PALLET",new BigDecimal("50.00"),"USD",null,null,null);}
@Test void capacityCanBePlannedBeforePickingWithoutCreatingActualLoadOrApprovals() {
String number = readyTrip();
jdbc.update("DELETE FROM ll_dispatch_pda_pick");
jdbc.update("UPDATE ll_dispatch_order SET status='PENDING_DEPARTURE',picked_package_count=0");
jdbc.update("UPDATE ll_dispatch_pda_task SET status='PDA_PENDING'");
jdbc.update("UPDATE ll_fleet_trip_dispatch SET review_json=NULL");
var basis = execution.capacityPlan(number);
assertThat(basis.planValid()).isFalse(); assertThat(basis.blockReason()).isNull();
var plan = execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(basis.recordVersion(), basis.manifestHash(), List.of(measurement()), "计划载荷"));
assertThat(plan.planValid()).isTrue();
assertThat(plan.capacityPlan().pallets()).hasSize(1);
assertThat(plan.capacityPlan().segments().get(0).remaining().palletSpaces()).isEqualTo(29);
assertThat(execution.loading(number).confirmedLoad()).isNull();
assertThat(execution.gate("92", "DP-11").canPick()).isFalse();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick", Integer.class)).isZero();
assertThat(service.history(number, 1, 20).records().get(0).snapshot().capacityPlan()).isEqualTo(plan.capacityPlan());
var unchanged = service.updatePlan(number, "1", new FleetTripModels.Plan(plan.recordVersion(), departure, end, stops(), "备注", "补充备注"));
assertThat(execution.capacityPlan(number).planValid()).isTrue();
service.assign(number, "1", new FleetTripModels.Assign(unchanged.trip().recordVersion(), vehicle(true), driver(true), "更换资源"));
assertThat(execution.capacityPlan(number).capacityPlan()).isNull();
}
@Test void capacityPlanRejectsStaleCargoAndOverbookingWithoutAffectingConfirmedActualLoad() {
String number = readyTrip(); var actual = confirm(number); var basis = execution.capacityPlan(number);
var tooHeavy = new FleetExecutionModels.Measurement("901", 1, new BigDecimal("19000"), new BigDecimal("1"), 1);
assertThatThrownBy(() -> execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(basis.recordVersion(), basis.manifestHash(), List.of(tooHeavy), "预估"))).hasMessageContaining("超过车辆");
var planned = execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(basis.recordVersion(), basis.manifestHash(), List.of(measurement()), "预估"));
assertThat(execution.loading(number).confirmedLoad()).isEqualTo(actual.confirmedLoad());
assertThat(service.detail(number).loadingReview().status()).isEqualTo("APPROVED");
jdbc.update("UPDATE ll_dispatch_item SET package_count=11 WHERE id=3011");
assertThat(execution.capacityPlan(number).planValid()).isFalse();
assertThatThrownBy(() -> execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(planned.recordVersion(), basis.manifestHash(), List.of(measurement()), "旧页面"))).hasMessageContaining("货物明细已变化");
assertThat(service.detail(number).trip().recordVersion()).isEqualTo(planned.recordVersion());
}
@Test void dispatchReviewsAreIndependentAndRejectionBlocksOnlyNewPickingForThatCustomer() {
String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92");
service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入"));
service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入"));
var tx = new org.springframework.transaction.support.TransactionTemplate(transactions);
assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requirePickingApproved(11); return null; })).hasMessageContaining("调度审核");
service.reviewDispatch(number, "DP-11", "1", new FleetTripModels.Review(3L, "APPROVED", null));
var gates = execution.gates("92", List.of("DP-11", "DP-12"));
assertThat(gates.get("DP-11").canPick()).isTrue();
assertThat(gates.get("DP-12").canPick()).isFalse();
tx.execute(status -> { bindingGuard.requirePickingApproved(11); return null; });
assertThatThrownBy(() -> service.reviewDispatch(number, "DP-12", "1", new FleetTripModels.Review(4L, "REJECTED", " "))).hasMessageContaining("打回原因");
service.reviewDispatch(number, "DP-12", "1", new FleetTripModels.Review(4L, "REJECTED", "核对预约时间"));
assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requirePickingApproved(12); return null; })).hasMessageContaining("核对预约时间");
service.reviewDispatch(number, "DP-12", "1", new FleetTripModels.Review(5L, "APPROVED", null));
assertThat(service.detail(number).dispatches()).allMatch(row -> "APPROVED".equals(row.review().status()));
}
@Test void loadingReviewRequiresActualLoadAndRemarkOnlyEditPreservesApprovals() {
String number = readyTrip(); var loading = execution.loading(number);
assertThatThrownBy(() -> execution.reviewLoading(number, "1", new FleetTripModels.Review(loading.recordVersion(), "APPROVED", null))).hasMessageContaining("确认本车实际装载");
var confirmed = execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(measurement()), "核对装载"));
assertThat(execution.gate("92", "DP-11").blockReason()).contains("理货审核");
assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "before-review", null))).hasMessageContaining("理货审核");
var rejected = execution.reviewLoading(number, "1", new FleetTripModels.Review(confirmed.recordVersion(), "REJECTED", "重新核对重量"));
assertThat(execution.gate("92", "DP-11").blockReason()).contains("重新核对重量");
var approved = execution.reviewLoading(number, "1", new FleetTripModels.Review(rejected.trip().recordVersion(), "APPROVED", null));
var changed = service.updatePlan(number, "1", new FleetTripModels.Plan(approved.trip().recordVersion(), departure, end, stops(), "补充备注", "更新备注"));
assertThat(changed.loadingReview().status()).isEqualTo("APPROVED");
assertThat(execution.gate("92", "DP-11").canDepart()).isTrue();
execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(changed.trip().recordVersion(), loading.manifestHash(), List.of(measurement()), "重新测量"));
assertThat(service.detail(number).loadingReview().status()).isEqualTo("PENDING");
assertThat(execution.gate("92", "DP-11").canDepart()).isFalse();
}
@Test void actualLoadingCountsSharedPhysicalPalletOnceAndDepartureShipsAllCustomersAtomically() {
String number = readyTrip();
assertThat(execution.gate("92", "DP-11").canDepart()).isFalse();
var loading = confirm(number);
assertThat(loading.confirmedLoad().cargo()).hasSize(2);
assertThat(loading.confirmedLoad().segments().get(0).occupied().palletSpaces()).isEqualTo(1);
assertThat(loading.confirmedLoad().segments().get(0).occupied().weightKg()).isEqualByComparingTo("100");
assertThat(execution.gates("92", List.of("DP-11", "DP-12")).values()).allMatch(FleetExecutionModels.Gate::canDepart);
assertThat(execution.gate("92", "DP-11").driverWarning()).contains("有效期覆盖本次预计任务");
var request = new FleetExecutionModels.Depart(loading.recordVersion(), "departure-first", "整车发车");
var result = execution.depart("92", "DP-11", "1", request);
assertThat(result.shippedPackages()).isEqualTo(20);
assertThat(result.dispatches()).extracting(FleetExecutionModels.DepartedDispatch::dispatchNo).containsExactly("DP-11", "DP-12");
assertThat(jdbc.queryForList("SELECT status FROM ll_dispatch_order", String.class)).containsOnly("DEPARTED");
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(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);
assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loading.recordVersion(), "departure-first", "changed")))
.hasMessageContaining("内容不一致");
assertThat(service.history(number, 1, 20).records().get(0).snapshot().confirmedLoad().cargo()).hasSize(2);
}
@Test void reassigningPickedTripPreservesWorkAndRequiresFreshLoadingOnTheReplacement() {
String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip();
var picksBefore = jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id");
var tasksBefore = jdbc.queryForList("SELECT * FROM ll_dispatch_pda_task ORDER BY id");
var arrivals = jdbc.queryForList("SELECT expected_arrival_at FROM ll_delivery_order ORDER BY id");
long replacement = vehicle(true), replacementDriver = driver(true, "替班司机", "456");
var changed = service.assign(number, "1", new FleetTripModels.Assign(loaded.recordVersion(), replacement, replacementDriver, "发车前换车及司机,重新核对装载"));
assertThat(changed.trip().status()).isEqualTo("PLANNED");
assertThat(changed.trip().vehicleId()).isEqualTo(Long.toString(replacement));
assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(changed.trip().vehiclePlate());
assertThat(jdbc.queryForList("SELECT driver_name FROM ll_delivery_order", String.class)).containsOnly("替班司机");
assertThat(jdbc.queryForList("SELECT driver_phone FROM ll_delivery_order", String.class)).containsOnly("456");
assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id")).isEqualTo(picksBefore);
assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_task ORDER BY id")).isEqualTo(tasksBefore);
assertThat(jdbc.queryForList("SELECT expected_arrival_at FROM ll_delivery_order ORDER BY id")).isEqualTo(arrivals);
assertThat(execution.loading(number).confirmedLoad()).isNull();
assertThat(changed.dispatches()).allMatch(row -> "PENDING".equals(row.review().status()));
assertThat(changed.loadingReview().status()).isEqualTo("PENDING");
assertThat(execution.gate("92", "DP-11").blockReason()).contains("调度审核");
approveDispatches(number);
assertThat(execution.gate("92", "DP-11").blockReason()).contains("确认本车实际装载");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id IN (?,?)", Integer.class,
Long.parseLong(original.vehicleId()), Long.parseLong(original.driverId()))).isZero();
var oldLoad = service.history(number, 1, 20).records().stream().filter(row -> row.action().equals("CONFIRM_LOAD")).findFirst().orElseThrow();
assertThat(oldLoad.snapshot().trip().vehicleId()).isEqualTo(original.vehicleId());
assertThat(oldLoad.snapshot().confirmedLoad()).isNotNull();
assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loaded.recordVersion(), "old-before-reassign", null))).hasMessageContaining("已被修改");
var reconfirmed = confirm(number);
assertThat(execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(reconfirmed.recordVersion(), "after-reassign", null)).shippedPackages()).isEqualTo(20);
assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id")).isEqualTo(picksBefore);
}
@Test void partialPickingAllowsResourceAndTimeChangesButKeepsRouteAndBindingsFrozen() {
String number = readyTrip(); var original = service.detail(number).trip();
jdbc.update("UPDATE ll_dispatch_order SET status='PENDING_DEPARTURE',picked_package_count=5 WHERE id=12");
jdbc.update("UPDATE ll_dispatch_pda_task SET status='PICKING',completed_at=NULL WHERE dispatch_order_id=12");
jdbc.update("UPDATE ll_dispatch_pda_pick SET packages=5 WHERE id=12");
var picksBefore = jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id");
var changed = service.assign(number, "1", new FleetTripModels.Assign(original.recordVersion(), vehicle(true), driver(true), "发车前更换资源")).trip();
var delayed = service.updatePlan(number, "1", new FleetTripModels.Plan(changed.recordVersion(), departure.plusSeconds(60), end.plusSeconds(60), stops(), null, "延后任务时间")).trip();
assertThat(delayed.expectedDriverEndAt()).isEqualTo(end.plusSeconds(60));
assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id")).isEqualTo(picksBefore);
assertThat(jdbc.queryForObject("SELECT status FROM ll_dispatch_pda_task WHERE dispatch_order_id=12", String.class)).isEqualTo("PICKING");
assertThatThrownBy(() -> service.unbindDispatch(number, "1", new FleetTripModels.BindingCommand(delayed.recordVersion(), "DP-12", "移走已拣单"))).hasMessageContaining("已开始");
var differentStops = List.of(stops().get(0), new FleetTripModels.Stop("另一目的地", "新地址", "FBA:LAX9"));
assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(delayed.recordVersion(), departure, end, differentStops, null, "更改路线"))).hasMessageContaining("已开始");
assertThat(service.detail(number).trip().stops()).isEqualTo(stops());
assertThat(service.detail(number).dispatches()).hasSize(2);
}
@Test void replacingConfirmedLoadWithInsufficientCapacityOrExpiredTaskCoverageChangesNothing() {
String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip();
long small = vehicle(true); var current = vehicles.findById(small).orElseThrow();
vehicleService.update(small, "1", new VehicleModels.Update(0L, current.plateNumber(), current.model(), current.fuelConsumptionPer100Km(), current.mileageKm(), null, null,
current.region(), current.ownership(), null, 30, new BigDecimal("50"), new BigDecimal("90")));
assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(loaded.recordVersion(), small, driver(true), "容量不足换车"))).hasMessageContaining("已确认装载");
assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(loaded.recordVersion(), departure, end.plus(60, ChronoUnit.DAYS), stops(), null, "延期跨过证照到期"))).hasMessageContaining("预计任务结束前到期");
assertThat(service.detail(number).trip().vehicleId()).isEqualTo(original.vehicleId());
assertThat(service.detail(number).trip().recordVersion()).isEqualTo(loaded.recordVersion());
assertThat(execution.loading(number).confirmedLoad()).isNotNull();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id=?", Integer.class, small)).isZero();
}
@Test void reassignmentHistoryFailureRestoresOriginalClaimsSnapshotsAndLoad() {
String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip();
long replacement = vehicle(true), replacementDriver = driver(true);
jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT reject_reassignment CHECK(operator_id <> 'reject-operator')");
assertThatThrownBy(() -> service.assign(number, "reject-operator", new FleetTripModels.Assign(loaded.recordVersion(), replacement, replacementDriver, "更换车辆司机")))
.isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
assertThat(service.detail(number).trip().vehicleId()).isEqualTo(original.vehicleId());
assertThat(service.detail(number).trip().recordVersion()).isEqualTo(loaded.recordVersion());
assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_delivery_order", String.class)).containsOnly(original.vehiclePlate());
assertThat(execution.loading(number).confirmedLoad()).isNotNull();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id IN (?,?)", Integer.class, replacement, replacementDriver)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2);
}
@Test void concurrentReassignmentAndDepartureCannotMixOldAndNewResources() throws Exception {
String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip();
long replacement = vehicle(true), replacementDriver = driver(true, "替班司机", "456");
var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2);
try {
var assign = executor.submit(() -> {
start.await(5, TimeUnit.SECONDS);
try {
service.assign(number, "1", new FleetTripModels.Assign(loaded.recordVersion(), replacement, replacementDriver, "临时改派"));
return true;
} catch (org.springframework.web.server.ResponseStatusException stale) {
assertThat(stale.getStatusCode().value()).isEqualTo(409); return false;
}
});
var depart = executor.submit(() -> {
start.await(5, TimeUnit.SECONDS);
try {
execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loaded.recordVersion(), "race-with-reassign", null));
return true;
} catch (org.springframework.web.server.ResponseStatusException stale) {
assertThat(stale.getStatusCode().value()).isEqualTo(409); return false;
}
});
start.countDown();
boolean assigned = assign.get(15, TimeUnit.SECONDS), departed = depart.get(15, TimeUnit.SECONDS);
assertThat(List.of(assigned, departed)).containsExactlyInAnyOrder(true, false);
var result = service.detail(number).trip();
assertThat(result.vehicleId()).isEqualTo(assigned ? Long.toString(replacement) : original.vehicleId());
assertThat(result.status()).isEqualTo(departed ? "IN_TRANSIT" : "PLANNED");
assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(result.vehiclePlate());
assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_delivery_order", String.class)).containsOnly(result.vehiclePlate());
assertThat(jdbc.queryForObject("SELECT SUM(shipped_package_count) FROM ll_dispatch_order", Integer.class)).isEqualTo(departed ? 20 : 0);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id=?", Integer.class,
Long.parseLong(result.vehicleId()))).isEqualTo(1);
if (assigned) assertThat(execution.loading(number).confirmedLoad()).isNull();
} finally { executor.shutdownNow(); }
}
@Test void physicalLoadingRejectsDuplicateMissingOverweightAndWrongDestinationMeasurements() {
String number = readyTrip(); var loading = execution.loading(number); var measurement = measurement();
assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(measurement, measurement), "核对")))
.hasMessageContaining("重复计量");
assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(), "核对")))
.hasMessageContaining("每个实物托盘");
assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(),
List.of(new FleetExecutionModels.Measurement("901", 1, new BigDecimal("18001"), new BigDecimal("1"), 1)), "核对"))).hasMessageContaining("超过车辆");
assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(),
List.of(new FleetExecutionModels.Measurement("901", 1, new BigDecimal("100"), new BigDecimal("1"), 2)), "核对"))).hasMessageContaining("卸货站");
jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='LAX9' WHERE dispatch_order_id=12");
var mixed = execution.loading(number);
assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(mixed.recordVersion(), mixed.manifestHash(), List.of(measurement), "核对")))
.hasMessageContaining("不同卸货目的地");
}
@Test void changedCargoAndUnreadyMemberBlockTheEntireVehicleBeforeAnyDepartureWrite() {
String number = readyTrip(); var confirmed = confirm(number);
jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='LAX9' WHERE dispatch_order_id=12");
assertThat(execution.gate("92", "DP-11").blockReason()).contains("货物发生变化");
assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "changed-cargo", null)))
.hasMessageContaining("货物发生变化");
jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='ONT8' WHERE dispatch_order_id=12");
jdbc.update("UPDATE ll_dispatch_pda_task SET status='PICKING' WHERE dispatch_order_id=12");
assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "unready-member", null)))
.hasMessageContaining("DP-12");
assertThat(jdbc.queryForList("SELECT status FROM ll_dispatch_order", String.class)).containsOnly("READY_TO_DEPART");
assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED");
}
@Test void secondCustomerFailureRollsBackFirstCustomerOutboundAndAllReplayRecords() {
String number = readyTrip(); var confirmed = confirm(number);
jdbc.execute("ALTER TABLE ll_dispatch_order ADD CONSTRAINT fail_second_depart CHECK(id <> 12 OR status <> 'DEPARTED')");
assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "rollback-whole-trip", null)))
.isInstanceOf(RuntimeException.class);
assertThat(jdbc.queryForList("SELECT status FROM ll_dispatch_order", String.class)).containsOnly("READY_TO_DEPART");
assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order", String.class)).containsOnly("WAIT_OUTBOUND");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='DEPART'", Integer.class)).isZero();
assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED");
assertThat(execution.gate("92", "DP-11").canDepart()).isTrue();
}
@Test void dispatchDepartureRechecksDriverAndRejectsWrongWarehouseAndStaleVersion() {
String number = readyTrip(); var confirmed = confirm(number);
assertThatThrownBy(() -> execution.depart("93", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "wrong-warehouse", null)))
.hasMessageContaining("当前绑定仓");
assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(0L, "stale-version", null)))
.hasMessageContaining("已被修改");
jdbc.update("UPDATE ll_fleet_driver SET license_expires_on=?", java.sql.Date.valueOf(LocalDate.now(ZoneOffset.UTC).minusDays(1)));
assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "expired-driver", null)))
.isInstanceOf(ResponseStatusException.class);
assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isZero();
}
@Test void simultaneousWholeTripDepartureReplaysOnceWithoutDoubleShipping() throws Exception {
String number = readyTrip(); var confirmed = confirm(number);
var request = new FleetExecutionModels.Depart(confirmed.recordVersion(), "concurrent-departure", null);
var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2);
Callable<Boolean> action = () -> { start.await(5, TimeUnit.SECONDS); return execution.depart("92", "DP-11", "1", request).replayed(); };
try {
var a = executor.submit(action); var b = executor.submit(action); start.countDown();
assertThat(List.of(a.get(15, TimeUnit.SECONDS), b.get(15, TimeUnit.SECONDS))).containsExactlyInAnyOrder(false, true);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isEqualTo(2);
} finally { executor.shutdownNow(); }
}
@Test void changingAssignedDriverToDomesticBlocksPreviewAndDepartureUntilCorrected() {
String number = readyTrip(); var confirmed = confirm(number);
long driverId = Long.parseLong(service.detail(number).trip().driverId());
var current = drivers.detail(driverId, end);
var original = current.details();
var domestic = new DriverModels.Draft(original.name(), original.phone(), original.ownership(), "DOMESTIC",
original.licenseNumber(), original.licenseType(), original.licenseIssuedOn(), original.licenseExpiresOn(),
original.licenseZone(), original.remark(), original.profile());
var changed = drivers.update(driverId, "1", new DriverModels.Update(current.recordVersion(), domestic));
assertThat(drivers.detail(driverId, end).qualification().canDispatch()).isTrue();
var gate = execution.gate("92", "DP-11");
assertThat(gate.canDepart()).isFalse();
assertThat(gate.blockReason()).contains("海外司机");
var request = new FleetExecutionModels.Depart(confirmed.recordVersion(), "driver-region-check", null);
assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", request)).hasMessageContaining("海外司机");
assertThat(service.detail(number).trip().recordVersion()).isEqualTo(confirmed.recordVersion());
assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED");
assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order", String.class)).containsOnly("WAIT_OUTBOUND");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isZero();
drivers.update(driverId, "1", new DriverModels.Update(changed.recordVersion(), original));
assertThat(execution.gate("92", "DP-11").canDepart()).isTrue();
assertThat(execution.depart("92", "DP-11", "1", request).status()).isEqualTo("IN_TRANSIT");
}
@Test void cargoCompletionDoesNotReleaseResourcesAndEachTaskEndsIndependently() {
String number = departedTrip(); markDeliveriesSigned();
var before = service.detail(number).trip();
var request = new FleetCompletionModels.Complete(before.recordVersion(), "complete-cargo", "全部货物已处理完");
var completed = completion.complete(number, "1", request).trip();
assertThat(completed.status()).isEqualTo("COMPLETED");
assertThat(completed.completedAt()).isNotNull();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2);
assertThat(completion.complete(number, "1", request).trip().recordVersion()).isEqualTo(completed.recordVersion());
assertThat(service.history(number, 1, 20).records().get(0).snapshot().completion().outcomes()).hasSize(2);
var vehicleEnded = completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", Instant.now(), "维修点", "MAINTENANCE", "release-vehicle", "车辆检修")).trip();
assertThat(vehicleEnded.vehicleRelease().vehicleStatus()).isEqualTo("MAINTENANCE");
assertThat(vehicleEnded.driverRelease()).isNull();
assertThat(vehicles.findById(Long.parseLong(vehicleEnded.vehicleId())).orElseThrow().status()).isEqualTo("MAINTENANCE");
assertThat(jdbc.queryForObject("SELECT resource_kind FROM ll_fleet_resource_assignment", String.class)).isEqualTo("DRIVER");
jdbc.update("UPDATE ll_fleet_driver SET license_expires_on=?", java.sql.Date.valueOf(LocalDate.now(ZoneOffset.UTC).minusDays(1)));
var driverEnded = completion.release(number, "1", new FleetCompletionModels.Release(vehicleEnded.recordVersion(), "DRIVER", Instant.now(), "交接点", null, "release-driver", "返程任务结束")).trip();
assertThat(driverEnded.driverRelease()).isNotNull();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero();
}
@Test void incompleteOrCancelledDeliveryDoesNotCountAsCargoCompletion() {
String number = departedTrip();
assertThat(completion.inspection(number).canComplete()).isFalse();
markDeliveriesSigned();
jdbc.update("UPDATE ll_delivery_order SET status='CANCELLED' WHERE id=2012");
assertThatThrownBy(() -> completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "incomplete-cargo", "核对"))).hasMessageContaining("DP-12");
assertThat(service.detail(number).trip().status()).isEqualTo("IN_TRANSIT");
jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED', package_count=9 WHERE id=2012");
assertThat(completion.inspection(number).blockReason()).contains("数量未覆盖");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2);
}
@Test void returnedStatusRequiresImmutableReturnCompletionEvidence() {
String number = departedTrip(); markDeliveriesSigned();
jdbc.update("UPDATE ll_delivery_order SET status='RETURNED',signed_at=NULL WHERE id=2012");
assertThat(completion.inspection(number).canComplete()).isFalse();
LocalDateTime returnedAt = LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS);
jdbc.update("INSERT INTO ll_delivery_operation(id,customer_id,delivery_order_id,operation_node,result,created_at) VALUES(1,'CLIENT-B',2012,'RETURN_COMPLETED','退回已完成',?)", returnedAt);
jdbc.update("UPDATE ll_delivery_order SET updated_at=? WHERE id=2012", returnedAt.plusDays(1));
assertThat(completion.inspection(number).canComplete()).isTrue();
var completed = completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "return-completion", "签收及退回已核对")).trip();
assertThat(completed.completedAt()).isEqualTo(returnedAt.atZone(ZoneId.systemDefault()).toInstant());
}
@Test void laterReturnRequestDoesNotReopenAlreadySignedOriginalTransport() {
String number = departedTrip(); markDeliveriesSigned();
var signedAt = jdbc.queryForObject("SELECT signed_at FROM ll_delivery_order WHERE id=2012", LocalDateTime.class);
jdbc.update("UPDATE ll_delivery_order SET status='RETURN_PENDING' WHERE id=2012");
var inspection = completion.inspection(number);
assertThat(inspection.canComplete()).isTrue();
assertThat(inspection.outcomes().get(1).status()).isEqualTo("DELIVERED");
assertThat(inspection.outcomes().get(1).terminalAt()).isEqualTo(signedAt.atZone(ZoneId.systemDefault()).toInstant());
completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "original-transport-complete", "原运输签收已完成"));
assertThat(jdbc.queryForObject("SELECT status FROM ll_delivery_order WHERE id=2012", String.class)).isEqualTo("RETURN_PENDING");
}
@Test void releaseChecksCompletionTimeAndRollsBackVehicleStatusAndClaimWhenHistoryFails() {
String number = departedTrip();
assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(service.detail(number).trip().recordVersion(), "VEHICLE", Instant.now(), "仓库", "IN_SERVICE", "premature-release", "结束")))
.hasMessageContaining("先核对全部货物");
markDeliveriesSigned();
var completed = completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "ready-completion", "核对")).trip();
assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", completed.completedAt().minusSeconds(1), "仓库", "IN_SERVICE", "early-release", "结束")))
.hasMessageContaining("实际任务结束时间");
assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "DRIVER", Instant.now().plusSeconds(60), "仓库", null, "future-release", "结束")))
.hasMessageContaining("实际任务结束时间");
jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT fail_release CHECK(action <> 'RELEASE_VEHICLE')");
assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", Instant.now(), "维修点", "MAINTENANCE", "failed-release", "维修")))
.isInstanceOf(RuntimeException.class);
assertThat(vehicles.findById(Long.parseLong(completed.vehicleId())).orElseThrow().status()).isEqualTo("IN_SERVICE");
assertThat(service.detail(number).trip().vehicleRelease()).isNull();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2);
}
@Test void oldReleaseRetryCannotReleaseAResourceAssignedToTheNextTrip() {
String number = departedTrip(); markDeliveriesSigned();
var completed = completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "completion-replay", "核对")).trip();
var release = new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", Instant.now(), "下一起运点", "IN_SERVICE", "vehicle-release-replay", "结束本次车辆任务");
completion.release(number, "1", release);
String next = trip(); service.assign(next, "1", new FleetTripModels.Assign(0L, Long.parseLong(completed.vehicleId()), driver(true), "下一任务"));
completion.release(number, "1", release);
assertThat(jdbc.queryForObject("SELECT trip_no FROM ll_fleet_resource_assignment WHERE resource_kind='VEHICLE' AND resource_id=?", String.class, Long.parseLong(completed.vehicleId()))).isEqualTo(next);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='RELEASE_VEHICLE'", Integer.class)).isEqualTo(1);
}
private String departedTrip() {
String number = readyTrip(); var loading = confirm(number);
execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loading.recordVersion(), "departure-for-completion", null));
return number;
}
private void markDeliveriesSigned() {
jdbc.update("UPDATE ll_outbound_order SET package_count=10");
jdbc.update("UPDATE ll_delivery_order SET package_count=10,status='DELIVERED',signed_at=?", LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS));
}
private String readyTrip() {
String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92");
delivery(11, "CLIENT-A"); delivery(12, "CLIENT-B");
service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入"));
service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入"));
approveDispatches(number);
for (long id : List.of(11L, 12L)) {
String customer = id == 11 ? "CLIENT-A" : "CLIENT-B";
jdbc.update("UPDATE ll_dispatch_order SET status='READY_TO_DEPART',picked_package_count=10 WHERE id=?", id);
jdbc.update("UPDATE ll_dispatch_pda_task SET status='COMPLETED' WHERE dispatch_order_id=?", id);
jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,warehouse_pallet_id,pallet_no,package_count,source_stage,destination_warehouse_code,overseas_warehouse_id) VALUES(?,?,?,901,'PALLET-901',10,'RECEIVED','ONT8','92')", id + 3000, id, customer);
jdbc.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,inventory_position_id,packages,product_quantity,idempotency_key,picked_at) VALUES(?,?,?,?,?,10,0,?,CURRENT_TIMESTAMP)", id, customer, id, id + 3000, id + 4000, "pick-" + id);
}
return number;
}
private FleetExecutionModels.Loading confirm(String number) {
var loading = execution.loading(number);
var confirmed = execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(measurement()), "实际装载核对"));
execution.reviewLoading(number, "1", new FleetTripModels.Review(confirmed.recordVersion(), "APPROVED", null));
return execution.loading(number);
}
private void approveDispatches(String number) {
for (var binding : service.detail(number).dispatches()) service.reviewDispatch(number, binding.dispatchNo(), "1",
new FleetTripModels.Review(service.detail(number).trip().recordVersion(), "APPROVED", null));
}
private static FleetExecutionModels.Measurement measurement() { return new FleetExecutionModels.Measurement("901", 1, new BigDecimal("100"), new BigDecimal("1"), 1); }
private boolean bindAttempt(CountDownLatch start, String number) throws Exception {
start.await(5, TimeUnit.SECONDS);
try { service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "并发关联")); return true; }
catch (ResponseStatusException conflict) { assertThat(conflict.getStatusCode().value()).isEqualTo(409); return false; }
}
private String assignedTrip() {
String number = trip(); service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle(true), driver(true), "安排")); return number;
}
private void dispatch(long id, String customer, String warehouse) {
jdbc.update("INSERT INTO ll_dispatch_order(id, customer_id, dispatch_no, overseas_warehouse_id, package_count) VALUES(?,?,?,?,10)", id, customer, "DP-" + id, warehouse);
jdbc.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id,task_no,status,warehouse_id,pushed_at) VALUES(?,?,?,?,'PDA_PENDING',?,CURRENT_TIMESTAMP)",
id, customer, id, "TASK-" + id, warehouse);
}
private void delivery(long dispatchId, String customer) {
jdbc.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,idempotency_key) VALUES(?,?,?,?,?)",
dispatchId + 1000, customer, "OUT-" + dispatchId, dispatchId, "out-key-" + dispatchId);
jdbc.update("INSERT INTO ll_delivery_order(id,customer_id,delivery_no,outbound_order_id,outbound_no,dispatch_order_id,delivery_group_key,idempotency_key,expected_arrival_at) VALUES(?,?,?,?,?,?,?,?,?)",
dispatchId + 2000, customer, "DL-" + dispatchId, dispatchId + 1000, "OUT-" + dispatchId, dispatchId, "DEST", "dl-key-" + dispatchId, LocalDateTime.of(2030, 1, 1, 12, 0));
}
private boolean attempt(CountDownLatch start, String number, long vehicle, long driver) throws Exception {
start.await(5, TimeUnit.SECONDS);
try { service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle, driver, "并发安排")); return true; }
catch (ResponseStatusException conflict) { assertThat(conflict.getStatusCode().value()).isEqualTo(409); return false; }
}
private String trip() { return service.create("1", createRequest(UUID.randomUUID().toString())).trip().tripNo(); }
private FleetTripModels.Create createRequest(String key) { return new FleetTripModels.Create(92L, departure, end, stops(), null, key); }
private static List<FleetTripModels.Stop> stops() { return List.of(new FleetTripModels.Stop("起运仓", "提货地址", null), new FleetTripModels.Stop("派送仓", "送货地址", "FBA:ONT8")); }
private long vehicle(boolean capacity) {
return vehicles.create(new VehicleRepository.NewVehicle("TEST-" + UUID.randomUUID(), "Box Truck", new BigDecimal("12.5"), 1000L, null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null, "1", LocalDateTime.now(), capacity ? 30 : null, capacity ? new BigDecimal("18000") : null, capacity ? new BigDecimal("90") : null));
}
private long driver(boolean approve) { return driver(approve, "司机", "123"); }
private long driver(boolean approve, String name, String phone) {
var draft = new DriverModels.Draft(name, phone, "OWNED", "OVERSEAS", "LIC-" + UUID.randomUUID(), "Class A", null, LocalDate.now(ZoneOffset.UTC).plusDays(30), "UTC", null, null);
var driver = drivers.create("1", draft); long id = Long.parseLong(driver.id());
if (approve) {
drivers.uploadLicense(id, 0, "1", new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7".getBytes()));
drivers.review(id, "1", new DriverModels.Review(1L, "APPROVED", null));
}
return id;
}
private <T> T transactional(T target, Class<T> type) {
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(transactions, new AnnotationTransactionAttributeSource()));
return type.cast(proxy.getProxy());
}
}