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,52 @@
package com.baiyue.lastleg.fleet.domain;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
/** Re-evaluated by the server when planning, changing or departing a trip. */
public final class DriverQualificationPolicy {
private DriverQualificationPolicy() { }
public enum ReviewStatus { PENDING, APPROVED, REJECTED }
public enum Reason {
ELIGIBLE, INACTIVE, MISSING_LICENSE, MISSING_EXPIRY, PENDING_REVIEW, REJECTED,
EXPIRED, MISSING_TRIP_END, INVALID_TRIP_END, EXPIRES_DURING_TRIP
}
public record Driver(boolean active, ReviewStatus reviewStatus, boolean hasLicense,
LocalDate licenseExpiresOn, ZoneId licenseZone) {
public Driver {
Objects.requireNonNull(reviewStatus, "reviewStatus");
Objects.requireNonNull(licenseZone, "licenseZone");
}
}
public record Qualification(boolean canDispatch, boolean expiringSoon, Reason reason,
LocalDate licenseExpiresOn) { }
public static Qualification evaluate(Driver driver, Instant now, Instant expectedEnd) {
Objects.requireNonNull(driver, "driver");
Objects.requireNonNull(now, "now");
LocalDate today = now.atZone(driver.licenseZone()).toLocalDate();
LocalDate expiry = driver.licenseExpiresOn();
long days = expiry == null ? Long.MAX_VALUE : ChronoUnit.DAYS.between(today, expiry);
boolean expiring = days >= 0 && days <= 30;
Reason reason;
if (!driver.active()) reason = Reason.INACTIVE;
else if (!driver.hasLicense()) reason = Reason.MISSING_LICENSE;
else if (expiry == null) reason = Reason.MISSING_EXPIRY;
else if (driver.reviewStatus() == ReviewStatus.REJECTED) reason = Reason.REJECTED;
else if (driver.reviewStatus() != ReviewStatus.APPROVED) reason = Reason.PENDING_REVIEW;
else if (days < 0) reason = Reason.EXPIRED;
else if (expectedEnd == null) reason = Reason.MISSING_TRIP_END;
else if (!expectedEnd.isAfter(now)) reason = Reason.INVALID_TRIP_END;
// A date-only licence is valid through that calendar day in its recorded zone.
else if (!expectedEnd.isBefore(expiry.plusDays(1).atStartOfDay(driver.licenseZone()).toInstant())) {
reason = Reason.EXPIRES_DURING_TRIP;
} else reason = Reason.ELIGIBLE;
return new Qualification(reason == Reason.ELIGIBLE, expiring, reason, expiry);
}
}
@@ -0,0 +1,69 @@
package com.baiyue.lastleg.fleet.domain;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
/** Pure capacity calculation; the caller must lock the trip before confirming reservations. */
public final class TripCapacityPolicy {
private TripCapacityPolicy() { }
public record Load(int palletSpaces, BigDecimal weightKg, BigDecimal volumeCbm) {
public Load {
if (palletSpaces < 0 || weightKg == null || volumeCbm == null
|| weightKg.signum() < 0 || volumeCbm.signum() < 0) {
throw new IllegalArgumentException("托位、重量和体积须为非负数");
}
}
public Load plus(Load other) {
return new Load(Math.addExact(palletSpaces, other.palletSpaces),
weightKg.add(other.weightKg), volumeCbm.add(other.volumeCbm));
}
}
public record Booking(String bookingNo, int pickupIndex, int deliveryIndex, Load load) {
public Booking {
if (bookingNo == null || bookingNo.isBlank()) throw new IllegalArgumentException("缺少承运业务编号");
if (pickupIndex < 0 || deliveryIndex <= pickupIndex) throw new IllegalArgumentException("卸货站必须在提货站之后");
Objects.requireNonNull(load, "load");
if (load.palletSpaces() == 0 || load.weightKg().signum() == 0 || load.volumeCbm().signum() == 0) {
throw new IllegalArgumentException("承运货物须有有效托位、重量和体积");
}
}
}
public record Segment(int fromIndex, int toIndex, Load occupied, Load remaining) { }
public static List<Segment> calculate(int stopCount, Load capacity, List<Booking> bookings) {
Objects.requireNonNull(capacity, "capacity");
Objects.requireNonNull(bookings, "bookings");
if (stopCount < 2 || stopCount > 200) throw new IllegalArgumentException("车次须有2至200个站点");
if (capacity.palletSpaces() <= 0 || capacity.weightKg().signum() <= 0 || capacity.volumeCbm().signum() <= 0) {
throw new IllegalArgumentException("车辆容量必须大于零");
}
var numbers = new HashSet<String>();
for (Booking booking : bookings) {
Objects.requireNonNull(booking, "booking");
if (!numbers.add(booking.bookingNo())) throw new IllegalArgumentException("承运明细重复分配");
if (booking.deliveryIndex() >= stopCount) throw new IllegalArgumentException("承运站点不属于当前路线");
}
List<Segment> segments = new ArrayList<>();
for (int index = 0; index < stopCount - 1; index++) {
Load occupied = new Load(0, BigDecimal.ZERO, BigDecimal.ZERO);
for (Booking booking : bookings) {
if (booking.pickupIndex() <= index && index < booking.deliveryIndex()) occupied = occupied.plus(booking.load());
}
if (occupied.palletSpaces() > capacity.palletSpaces()
|| occupied.weightKg().compareTo(capacity.weightKg()) > 0
|| occupied.volumeCbm().compareTo(capacity.volumeCbm()) > 0) {
throw new IllegalArgumentException("第" + (index + 1) + "段路线超过车辆托位、重量或容积限制");
}
Load remaining = new Load(capacity.palletSpaces() - occupied.palletSpaces(),
capacity.weightKg().subtract(occupied.weightKg()), capacity.volumeCbm().subtract(occupied.volumeCbm()));
segments.add(new Segment(index, index + 1, occupied, remaining));
}
return List.copyOf(segments);
}
}
@@ -0,0 +1,60 @@
package com.baiyue.lastleg.fleet.domain;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
/** Operating-cost allocation, not customer billing. One currency per calculation. */
public final class TripCostAllocationPolicy {
private TripCostAllocationPolicy() { }
public record Share(String bookingNo, long palletSpaces) {
public Share {
if (bookingNo == null || bookingNo.isBlank() || palletSpaces <= 0) {
throw new IllegalArgumentException("成本分摊须有承运编号及正数托位");
}
}
}
public record Allocation(String bookingNo, BigDecimal amount) { }
private record Fraction(String bookingNo, BigInteger cents, BigInteger remainder) { }
public static List<Allocation> allocate(BigDecimal total, String currency, List<Share> shares) {
Objects.requireNonNull(total, "total");
Objects.requireNonNull(shares, "shares");
if (!("USD".equals(currency) || "CNY".equals(currency))) throw new IllegalArgumentException("不支持的分摊币种");
if (total.signum() < 0) throw new IllegalArgumentException("直接成本不能为负数");
BigInteger cents;
try { cents = total.setScale(2, RoundingMode.UNNECESSARY).movePointRight(2).toBigIntegerExact(); }
catch (ArithmeticException e) { throw new IllegalArgumentException("金额最多两位小数", e); }
if (shares.isEmpty()) throw new IllegalArgumentException("没有可分摊的实运货物");
var numbers = new HashSet<String>();
BigInteger spaces = BigInteger.ZERO;
for (Share share : shares) {
if (!numbers.add(share.bookingNo())) throw new IllegalArgumentException("成本分摊承运编号重复");
spaces = spaces.add(BigInteger.valueOf(share.palletSpaces()));
}
List<Fraction> fractions = new ArrayList<>();
BigInteger assigned = BigInteger.ZERO;
for (Share share : shares) {
BigInteger[] result = cents.multiply(BigInteger.valueOf(share.palletSpaces())).divideAndRemainder(spaces);
fractions.add(new Fraction(share.bookingNo(), result[0], result[1]));
assigned = assigned.add(result[0]);
}
// Largest remainder with a stable business-number tie-break conserves every cent.
fractions.sort(Comparator.comparing(Fraction::remainder).reversed().thenComparing(Fraction::bookingNo));
int extraCents = cents.subtract(assigned).intValueExact();
List<Allocation> result = new ArrayList<>();
for (int i = 0; i < fractions.size(); i++) {
Fraction fraction = fractions.get(i);
BigInteger amount = fraction.cents().add(i < extraCents ? BigInteger.ONE : BigInteger.ZERO);
result.add(new Allocation(fraction.bookingNo(), new BigDecimal(amount, 2)));
}
result.sort(Comparator.comparing(Allocation::bookingNo));
return List.copyOf(result);
}
}