feat(fleet): enforce reviewed trips and original fee coverage for dispatch
This commit is contained in:
+23
@@ -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());
|
||||
}
|
||||
}
|
||||
+60
@@ -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);
|
||||
}
|
||||
}
|
||||
+56
@@ -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));
|
||||
}
|
||||
}
|
||||
+46
@@ -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);
|
||||
}
|
||||
}
|
||||
+71
@@ -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));
|
||||
}
|
||||
}
|
||||
+61
@@ -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); }
|
||||
}
|
||||
}
|
||||
+130
@@ -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;
|
||||
}
|
||||
}
|
||||
+32
@@ -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");
|
||||
}
|
||||
}
|
||||
+26
@@ -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));
|
||||
}
|
||||
}
|
||||
+24
@@ -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));
|
||||
}
|
||||
}
|
||||
+43
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -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); }
|
||||
}
|
||||
}
|
||||
+166
@@ -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")); }
|
||||
}
|
||||
+69
@@ -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"));
|
||||
}
|
||||
}
|
||||
+74
@@ -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));
|
||||
}
|
||||
}
|
||||
+55
@@ -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); }
|
||||
}
|
||||
}
|
||||
+866
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user