feat(fleet): enforce reviewed trips and original fee coverage for dispatch
This commit is contained in:
+52
@@ -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);
|
||||
}
|
||||
}
|
||||
+60
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user