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
@@ -48,6 +48,9 @@ public class VehiclePersistenceAdapter implements VehicleRepository {
record.setId(IdWorker.getId());
record.setPlateNumber(vehicle.plateNumber());
record.setModel(vehicle.model());
record.setMaxPalletSpaces(vehicle.maxPalletSpaces());
record.setMaxLoadKg(vehicle.maxLoadKg());
record.setMaxVolumeCbm(vehicle.maxVolumeCbm());
record.setFuelConsumptionPer100Km(vehicle.fuelConsumptionPer100Km());
record.setMileageKm(vehicle.mileageKm());
record.setDriverOrDispatchCompany(vehicle.driverOrDispatchCompany());
@@ -76,6 +79,9 @@ public class VehiclePersistenceAdapter implements VehicleRepository {
.eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion)
.set(VehicleRecord::getPlateNumber, changes.plateNumber())
.set(VehicleRecord::getModel, changes.model())
.set(VehicleRecord::getMaxPalletSpaces, changes.maxPalletSpaces())
.set(VehicleRecord::getMaxLoadKg, changes.maxLoadKg())
.set(VehicleRecord::getMaxVolumeCbm, changes.maxVolumeCbm())
.set(VehicleRecord::getFuelConsumptionPer100Km, changes.fuelConsumptionPer100Km())
.set(VehicleRecord::getMileageKm, changes.mileageKm())
.set(VehicleRecord::getDriverOrDispatchCompany, changes.driverOrDispatchCompany())
@@ -112,7 +118,7 @@ public class VehiclePersistenceAdapter implements VehicleRepository {
return new VehicleSnapshot(value.getId(), value.getPlateNumber(), value.getModel(),
value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(),
value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()),
value.getUpdatedBy(), value.getUpdatedAt());
value.getUpdatedBy(), value.getUpdatedAt(), value.getMaxPalletSpaces(), value.getMaxLoadKg(), value.getMaxVolumeCbm());
}
private static long version(Long value) {
@@ -14,6 +14,9 @@ public class VehicleRecord {
@TableId(type = IdType.INPUT) private Long id;
private String plateNumber;
private String model;
private Integer maxPalletSpaces;
private BigDecimal maxLoadKg;
private BigDecimal maxVolumeCbm;
private BigDecimal fuelConsumptionPer100Km;
private Long mileageKm;
private String driverOrDispatchCompany;
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.masterdata.vehicle.application;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleAssignmentGuard;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.DuplicatePlateException;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.NewVehicle;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.SearchCriteria;
@@ -24,9 +25,11 @@ public class VehicleApplicationService {
private static final Set<String> STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED");
private final VehicleRepository vehicles;
private final VehicleAssignmentGuard assignments;
public VehicleApplicationService(VehicleRepository vehicles) {
public VehicleApplicationService(VehicleRepository vehicles, VehicleAssignmentGuard assignments) {
this.vehicles = vehicles;
this.assignments = assignments;
}
public PageResult<VehicleModels.Response> page(VehicleModels.Query query) {
@@ -41,6 +44,7 @@ public class VehicleApplicationService {
@Transactional
public VehicleModels.Response create(String operator, VehicleModels.Create request) {
validateCapacity(request.maxPalletSpaces(), request.maxLoadKg(), request.maxVolumeCbm());
LocalDateTime now = LocalDateTime.now();
long id;
try {
@@ -48,7 +52,8 @@ public class VehicleApplicationService {
requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT),
requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(),
trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()),
ownership(request.ownership()), status(request.status()), trim(request.remark()), operator, now));
ownership(request.ownership()), status(request.status()), trim(request.remark()), operator, now,
request.maxPalletSpaces(), request.maxLoadKg(), request.maxVolumeCbm()));
} catch (DuplicatePlateException exception) {
throw conflict("车牌号已存在");
}
@@ -57,16 +62,25 @@ public class VehicleApplicationService {
@Transactional
public VehicleModels.Response update(long id, String operator, VehicleModels.Update request) {
validateCapacity(request.maxPalletSpaces(), request.maxLoadKg(), request.maxVolumeCbm());
VehicleSnapshot current = locked(id);
requireVersion(current, request.recordVersion());
if (request.mileageKm() < current.mileageKm()) throw badRequest("当前公里码不能小于已有公里码");
if (!current.plateNumber().equals(requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT))
|| !current.model().equals(requiredText(request.model(), "车型")) || !current.region().equals(region(request.region()))
|| !current.ownership().equals(ownership(request.ownership()))
|| !java.util.Objects.equals(current.maxPalletSpaces(), request.maxPalletSpaces())
|| !sameDecimal(current.maxLoadKg(), request.maxLoadKg()) || !sameDecimal(current.maxVolumeCbm(), request.maxVolumeCbm())) {
assignments.requireUnassigned(id);
}
boolean updated;
try {
updated = vehicles.update(id, request.recordVersion(), new VehicleChanges(
requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT),
requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(),
trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()),
ownership(request.ownership()), trim(request.remark()), operator, LocalDateTime.now()));
ownership(request.ownership()), trim(request.remark()), operator, LocalDateTime.now(),
request.maxPalletSpaces(), request.maxLoadKg(), request.maxVolumeCbm()));
} catch (DuplicatePlateException exception) {
throw conflict("车牌号已存在");
}
@@ -80,6 +94,7 @@ public class VehicleApplicationService {
requireVersion(current, request.recordVersion());
String status = status(request.status());
if (status.equals(current.status())) return response(current);
assignments.requireUnassigned(id);
boolean updated = vehicles.updateStatus(id, request.recordVersion(), status, operator, LocalDateTime.now());
if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试");
return detail(id);
@@ -87,11 +102,12 @@ public class VehicleApplicationService {
public byte[] exportCsv(VehicleModels.Query query) {
var rows = vehicles.list(criteria(query)).stream().map(this::response).toList();
StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n");
for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',')
StringBuilder csv = new StringBuilder("\uFEFF车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,最大托位,最大载重(kg),可用容积(m³),更新时间\r\n");
for (var row : rows) csv.append(cell(row.plateNumber())).append(',')
.append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',')
.append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',')
.append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',')
.append(cell(row.maxPalletSpaces())).append(',').append(cell(row.maxLoadKg())).append(',').append(cell(row.maxVolumeCbm())).append(',')
.append(row.updatedAt()).append("\r\n");
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
@@ -115,7 +131,17 @@ public class VehicleApplicationService {
return new VehicleModels.Response(String.valueOf(value.id()), value.plateNumber(), value.model(),
value.fuelConsumptionPer100Km(), value.mileageKm(), value.driverOrDispatchCompany(), value.phone(),
value.region(), value.ownership(), value.status(), value.remark(), value.recordVersion(),
value.updatedBy(), value.updatedAt());
value.updatedBy(), value.updatedAt(), value.maxPalletSpaces(), value.maxLoadKg(), value.maxVolumeCbm());
}
private static void validateCapacity(Integer pallets, java.math.BigDecimal weight, java.math.BigDecimal volume) {
if (pallets == null && weight == null && volume == null) return;
if (pallets == null || weight == null || volume == null || pallets < 1 || pallets > 10000
|| weight.signum() <= 0 || volume.signum() <= 0) throw badRequest("车辆托位、载重和容积须同时填写且大于零,或全部留空待确认");
}
private static boolean sameDecimal(java.math.BigDecimal first, java.math.BigDecimal second) {
return first == null ? second == null : second != null && first.compareTo(second) == 0;
}
private void requireVersion(VehicleSnapshot value, long expected) {
@@ -26,7 +26,10 @@ public final class VehicleModels {
@Size(max = 128) String driverOrDispatchCompany,
@Size(max = 64) String phone,
@NotBlank String region, @NotBlank String ownership,
@NotBlank String status, @Size(max = 1000) String remark) { }
@NotBlank String status, @Size(max = 1000) String remark,
@Min(1) @Max(10000) Integer maxPalletSpaces,
@DecimalMin(value = "0", inclusive = false) @Digits(integer = 9, fraction = 3) BigDecimal maxLoadKg,
@DecimalMin(value = "0", inclusive = false) @Digits(integer = 9, fraction = 3) BigDecimal maxVolumeCbm) { }
public record Update(@NotNull @Min(0) Long recordVersion,
@NotBlank @Size(max = 64) String plateNumber,
@@ -36,12 +39,16 @@ public final class VehicleModels {
@Size(max = 128) String driverOrDispatchCompany,
@Size(max = 64) String phone,
@NotBlank String region, @NotBlank String ownership,
@Size(max = 1000) String remark) { }
@Size(max = 1000) String remark,
@Min(1) @Max(10000) Integer maxPalletSpaces,
@DecimalMin(value = "0", inclusive = false) @Digits(integer = 9, fraction = 3) BigDecimal maxLoadKg,
@DecimalMin(value = "0", inclusive = false) @Digits(integer = 9, fraction = 3) BigDecimal maxVolumeCbm) { }
public record ChangeStatus(@NotBlank String status, @NotNull @Min(0) Long recordVersion) { }
public record Response(String id, String plateNumber, String model, BigDecimal fuelConsumptionPer100Km,
long mileageKm, String driverOrDispatchCompany, String phone, String region,
String ownership, String status, String remark, long recordVersion,
String updatedBy, LocalDateTime updatedAt) { }
String updatedBy, LocalDateTime updatedAt,
Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { }
}
@@ -0,0 +1,6 @@
package com.baiyue.lastleg.masterdata.vehicle.application.port.out;
/** Called while the vehicle row is locked; fleet owns the active assignment. */
public interface VehicleAssignmentGuard {
void requireUnassigned(long vehicleId);
}
@@ -26,16 +26,19 @@ public interface VehicleRepository {
record NewVehicle(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm,
String driverOrDispatchCompany, String phone, String region, String ownership, String status,
String remark, String operator, LocalDateTime createdAt) { }
String remark, String operator, LocalDateTime createdAt,
Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { }
record VehicleChanges(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm,
String driverOrDispatchCompany, String phone, String region, String ownership, String remark,
String operator, LocalDateTime updatedAt) { }
String operator, LocalDateTime updatedAt,
Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { }
record VehicleSnapshot(long id, String plateNumber, String model, BigDecimal fuelConsumptionPer100Km,
long mileageKm, String driverOrDispatchCompany, String phone, String region,
String ownership, String status, String remark, long recordVersion,
String updatedBy, LocalDateTime updatedAt) { }
String updatedBy, LocalDateTime updatedAt,
Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { }
final class DuplicatePlateException extends RuntimeException {
public DuplicatePlateException(Throwable cause) {