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

This commit is contained in:
2026-10-03 04:18:52 +08:00
parent c50e9f97d1
commit 5e683da3b9
162 changed files with 9642 additions and 218 deletions
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.fleet.domain;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import static com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy.*;
import static org.assertj.core.api.Assertions.assertThat;
class DriverQualificationPolicyTest {
private final Instant now = Instant.parse("2026-10-02T16:00:00Z");
private final ZoneId zone = ZoneId.of("America/Los_Angeles");
private Driver driver(LocalDate expiry) { return new Driver(true, ReviewStatus.APPROVED, true, expiry, zone); }
@Test void expiringWithinThirtyDaysAllowsTripEndingBeforeExpiry() {
var result = evaluate(driver(LocalDate.of(2026, 10, 5)), now, now.plusSeconds(3600));
assertThat(result.canDispatch()).isTrue();
assertThat(result.expiringSoon()).isTrue();
}
@Test void thirtyDayWindowIncludesBoundaryButNotThirtyOneDays() {
assertThat(evaluate(driver(LocalDate.of(2026, 11, 1)), now, now.plusSeconds(60)).expiringSoon()).isTrue();
assertThat(evaluate(driver(LocalDate.of(2026, 11, 2)), now, now.plusSeconds(60)).expiringSoon()).isFalse();
}
@Test void usesLicenceZoneAndIncludesEntireExpiryDate() {
var d = driver(LocalDate.of(2026, 10, 2));
assertThat(evaluate(d, now, Instant.parse("2026-10-03T06:59:59Z")).canDispatch()).isTrue();
assertThat(evaluate(d, now, Instant.parse("2026-10-03T07:00:00Z")).reason()).isEqualTo(Reason.EXPIRES_DURING_TRIP);
}
@Test void daylightSavingBoundaryUsesCalendarDayInsteadOfFixedHours() {
var d = driver(LocalDate.of(2026, 11, 1));
Instant start = Instant.parse("2026-11-01T07:00:00Z");
assertThat(evaluate(d, start, Instant.parse("2026-11-02T07:59:59Z")).canDispatch()).isTrue();
assertThat(evaluate(d, start, Instant.parse("2026-11-02T08:00:00Z")).canDispatch()).isFalse();
}
@Test void rejectsExpiredAndUnknownDatesOrEndTimes() {
assertThat(evaluate(driver(LocalDate.of(2026, 10, 1)), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.EXPIRED);
assertThat(evaluate(driver(null), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.MISSING_EXPIRY);
assertThat(evaluate(driver(LocalDate.of(2027, 1, 1)), now, null).reason()).isEqualTo(Reason.MISSING_TRIP_END);
assertThat(evaluate(driver(LocalDate.of(2027, 1, 1)), now, now).reason()).isEqualTo(Reason.INVALID_TRIP_END);
}
@Test void reviewAndDocumentGatesCannotBeBypassedWithValidDate() {
LocalDate expiry = LocalDate.of(2027, 1, 1);
assertThat(evaluate(new Driver(false, ReviewStatus.APPROVED, true, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.INACTIVE);
assertThat(evaluate(new Driver(true, ReviewStatus.APPROVED, false, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.MISSING_LICENSE);
assertThat(evaluate(new Driver(true, ReviewStatus.PENDING, true, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.PENDING_REVIEW);
assertThat(evaluate(new Driver(true, ReviewStatus.REJECTED, true, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.REJECTED);
}
@Test void delayedTripIsReevaluatedRatherThanReusingAnEarlierApproval() {
var d = driver(LocalDate.of(2026, 10, 5));
assertThat(evaluate(d, now, now.plusSeconds(3600)).canDispatch()).isTrue();
assertThat(evaluate(d, now, Instant.parse("2026-10-06T09:00:00Z")).reason()).isEqualTo(Reason.EXPIRES_DURING_TRIP);
}
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.fleet.domain;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.*;
import static org.assertj.core.api.Assertions.*;
class TripCapacityPolicyTest {
private Load load(int pallets, String kg, String cbm) { return new Load(pallets, new BigDecimal(kg), new BigDecimal(cbm)); }
private final Load capacity = new Load(30, new BigDecimal("18000"), new BigDecimal("70"));
@Test void combinesOwnedAndExternalCargoWithinAllCapacityLimits() {
var result = calculate(2, capacity, List.of(new Booking("OWN-1", 0, 1, load(20, "10000", "40")),
new Booking("EXT-1", 0, 1, load(10, "8000", "30"))));
assertThat(result.get(0).remaining()).isEqualTo(load(0, "0", "0"));
}
@Test void releasesUnloadedCargoBeforeNextPickupAtSameStop() {
var result = calculate(3, capacity, List.of(new Booking("A", 0, 1, load(30, "18000", "70")),
new Booking("B", 1, 2, load(30, "18000", "70"))));
assertThat(result).hasSize(2);
assertThat(result).allSatisfy(segment -> assertThat(segment.occupied().palletSpaces()).isEqualTo(30));
}
@Test void rejectsOverlappingSegmentsEvenWhenSomeStopsHaveSpareCapacity() {
assertThatThrownBy(() -> calculate(4, capacity, List.of(new Booking("A", 0, 2, load(20, "8000", "20")),
new Booking("B", 1, 3, load(15, "5000", "20")))))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("第2段");
}
@Test void rejectsOverweightOrOversizeEvenWithFreePalletSpaces() {
assertThatThrownBy(() -> calculate(2, capacity, List.of(new Booking("A", 0, 1, load(1, "18001", "1")))))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> calculate(2, capacity, List.of(new Booking("A", 0, 1, load(1, "1", "70.01")))))
.isInstanceOf(IllegalArgumentException.class);
}
@Test void rejectsDuplicateBookingAndStopsOutsideRoute() {
var booking = new Booking("A", 0, 1, load(1, "10", "1"));
assertThatThrownBy(() -> calculate(2, capacity, List.of(booking, booking))).hasMessageContaining("重复");
assertThatThrownBy(() -> calculate(2, capacity, List.of(new Booking("B", 0, 2, load(1, "10", "1")))))
.hasMessageContaining("不属于");
}
@Test void refusesUnknownCapacityAndMissingCargoMeasurements() {
assertThatThrownBy(() -> calculate(2, load(30, "0", "70"), List.of())).hasMessageContaining("大于零");
assertThatThrownBy(() -> new Booking("A", 0, 1, load(1, "0", "1"))).hasMessageContaining("有效");
assertThatThrownBy(() -> new Booking("A", 1, 1, load(1, "10", "1"))).hasMessageContaining("之后");
assertThatThrownBy(() -> load(1, "-1", "1")).hasMessageContaining("非负");
}
@Test void emptyTripRetainsCapacityOnEachSegment() {
assertThat(calculate(3, capacity, List.of())).allSatisfy(segment -> assertThat(segment.remaining()).isEqualTo(capacity));
}
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.fleet.domain;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static com.baiyue.lastleg.fleet.domain.TripCostAllocationPolicy.*;
import static org.assertj.core.api.Assertions.*;
class TripCostAllocationPolicyTest {
@Test void allocatesTwentyOwnedAndTenExternalPalletsWithoutCreatingRevenue() {
assertThat(allocate(new BigDecimal("600.00"), "USD", List.of(new Share("OWN-1", 20), new Share("EXT-1", 10))))
.containsExactly(new Allocation("EXT-1", new BigDecimal("200.00")), new Allocation("OWN-1", new BigDecimal("400.00")));
}
@Test void roundingConservesTotalAndIsIndependentOfRequestOrder() {
var first = allocate(new BigDecimal("10.00"), "USD", List.of(new Share("C", 1), new Share("A", 1), new Share("B", 1)));
var second = allocate(new BigDecimal("10.00"), "USD", List.of(new Share("B", 1), new Share("C", 1), new Share("A", 1)));
assertThat(first).isEqualTo(second);
assertThat(first).containsExactly(new Allocation("A", new BigDecimal("3.34")),
new Allocation("B", new BigDecimal("3.33")), new Allocation("C", new BigDecimal("3.33")));
assertThat(first.stream().map(Allocation::amount).reduce(BigDecimal.ZERO, BigDecimal::add)).isEqualByComparingTo("10.00");
}
@Test void singleCentAndZeroCostRemainExact() {
var shares = List.of(new Share("A", 1), new Share("B", 2));
assertThat(allocate(new BigDecimal("0.01"), "CNY", shares))
.containsExactly(new Allocation("A", new BigDecimal("0.00")), new Allocation("B", new BigDecimal("0.01")));
assertThat(allocate(BigDecimal.ZERO, "CNY", shares)).allSatisfy(a -> assertThat(a.amount()).isZero());
}
@Test void rejectsUnmeasuredDuplicatedAndInvalidCostInputs() {
var shares = List.of(new Share("A", 1));
assertThatThrownBy(() -> allocate(new BigDecimal("1.001"), "USD", shares)).hasMessageContaining("两位");
assertThatThrownBy(() -> allocate(BigDecimal.ONE, "EUR", shares)).hasMessageContaining("币种");
assertThatThrownBy(() -> allocate(BigDecimal.ONE, null, shares)).hasMessageContaining("币种");
assertThatThrownBy(() -> allocate(BigDecimal.ONE.negate(), "USD", shares)).hasMessageContaining("负数");
assertThatThrownBy(() -> allocate(BigDecimal.ONE, "USD", List.of())).hasMessageContaining("没有");
assertThatThrownBy(() -> allocate(BigDecimal.ONE, "USD", List.of(new Share("A", 1), new Share("A", 2)))).hasMessageContaining("重复");
}
@Test void handlesLargeValuesWithoutIntegerOverflow() {
var amount = new BigDecimal("99999999999999.99");
var result = allocate(amount, "USD", List.of(new Share("A", Long.MAX_VALUE), new Share("B", Long.MAX_VALUE)));
assertThat(result.stream().map(Allocation::amount).reduce(BigDecimal.ZERO, BigDecimal::add)).isEqualByComparingTo(amount);
}
}