feat(fleet): integrate costs quotes operations and in-transit handovers
This commit is contained in:
+94
@@ -0,0 +1,94 @@
|
||||
package com.baiyue.lastleg.fleet.exception;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load;
|
||||
import com.baiyue.lastleg.fleet.trip.*;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
|
||||
/** Internal preparation for the handover transaction. No stock, fee or departure command is called. */
|
||||
@Service
|
||||
public class FleetHandoverInspectionService {
|
||||
private final FleetTripMapper trips;
|
||||
private final FleetTripHistoryMapper history;
|
||||
private final FleetTripDispatchMapper bindings;
|
||||
private final FleetDispatchGateway dispatches;
|
||||
private final ObjectMapper json;
|
||||
|
||||
public FleetHandoverInspectionService(FleetTripMapper trips, FleetTripHistoryMapper history,
|
||||
FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, ObjectMapper json) {
|
||||
this.trips = trips; this.history = history; this.bindings = bindings; this.dispatches = dispatches; this.json = json;
|
||||
}
|
||||
|
||||
public record Prepared(long recordVersion, FleetHandoverPolicy.Inspection cargo,
|
||||
List<FleetTransitEvidence> executionEvidence) { }
|
||||
|
||||
/** Caller holds a transaction; command preparation locks trip -> customer dispatch -> outbound -> deliveries. */
|
||||
@Transactional(propagation = Propagation.MANDATORY)
|
||||
public Prepared prepare(String tripNo, Instant handedOverAt, Load replacementCapacity, boolean lock) {
|
||||
var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo);
|
||||
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
|
||||
if (!"IN_TRANSIT".equals(trip.getStatus()) || trip.getDepartedAt() == null || trip.getCompletionJson() != null)
|
||||
throw conflict("仅已发车且尚未结案的车次允许在途交接");
|
||||
var departures = history.selectList(new LambdaQueryWrapper<FleetTripHistory>()
|
||||
.eq(FleetTripHistory::getTripId, trip.getId()).eq(FleetTripHistory::getAction, "DEPART"));
|
||||
if (departures.size() != 1) throw conflict("车次缺少唯一原始发车快照");
|
||||
FleetTripModels.AuditSnapshot original;
|
||||
try { original = json.readValue(departures.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class); }
|
||||
catch (JsonProcessingException error) { throw new IllegalStateException("原始发车快照无法读取", error); }
|
||||
if (original.trip() == null || !tripNo.equals(original.trip().tripNo())
|
||||
|| !trip.getWarehouseId().toString().equals(original.trip().warehouseId())
|
||||
|| !trip.getDepartedAt().toInstant(ZoneOffset.UTC).equals(original.trip().departedAt())
|
||||
|| original.confirmedLoad() == null || original.confirmedLoad().cargo() == null
|
||||
|| original.confirmedLoad().pallets() == null || original.dispatches() == null)
|
||||
throw conflict("原始发车快照不完整或与车次不一致");
|
||||
Map<String, FleetTripModels.DispatchBinding> departedBindings = new TreeMap<>();
|
||||
for (var binding : original.dispatches()) {
|
||||
if (departedBindings.putIfAbsent(binding.dispatchNo(), binding) != null)
|
||||
throw conflict("原始发车快照包含重复客户排车");
|
||||
}
|
||||
var current = bindings.listByTrip(trip.getId());
|
||||
if (current.size() != departedBindings.size()) throw conflict("车次成员与原始发车不一致");
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (var binding : current) {
|
||||
var originalBinding = departedBindings.get(binding.getDispatchNo());
|
||||
if (!seen.add(binding.getDispatchNo()) || originalBinding == null
|
||||
|| !Objects.equals(originalBinding.customerId(), binding.getCustomerId())
|
||||
|| !Objects.equals(originalBinding.packageCount(), binding.getPackageCount()))
|
||||
throw conflict("客户排车归属或箱数与原始发车不一致");
|
||||
}
|
||||
List<FleetTransitEvidence> evidence = new ArrayList<>();
|
||||
for (var binding : departedBindings.values()) {
|
||||
var fact = dispatches.transitEvidence(binding.dispatchNo(), lock);
|
||||
if (!Objects.equals(binding.dispatchNo(), fact.dispatchNo())
|
||||
|| !Objects.equals(binding.customerId(), fact.customerId())
|
||||
|| !trip.getWarehouseId().toString().equals(fact.warehouseId())
|
||||
|| binding.packageCount() != fact.shippedPackages())
|
||||
throw conflict("实际发运与车次客户、仓库或箱数不一致");
|
||||
evidence.add(fact);
|
||||
}
|
||||
var load = original.confirmedLoad();
|
||||
var cargo = load.cargo().stream().map(row -> new FleetHandoverPolicy.Cargo(row.customerId(), row.dispatchNo(),
|
||||
row.dispatchItemId(), row.warehousePalletId(), row.destinationKey(), row.packages())).toList();
|
||||
var measured = load.pallets().stream().map(row -> new FleetHandoverPolicy.Pallet(row.warehousePalletId(),
|
||||
new Load(row.palletSpaces(), row.weightKg(), row.volumeCbm()))).toList();
|
||||
try {
|
||||
var inspection = FleetHandoverPolicy.inspect(original.trip().departedAt(), handedOverAt, Instant.now(), cargo,
|
||||
evidence.stream().flatMap(row -> row.deliveries().stream()).map(FleetTransitEvidence.Delivery::cargo).toList(),
|
||||
measured, replacementCapacity);
|
||||
return new Prepared(trip.getRecordVersion(), inspection, List.copyOf(evidence));
|
||||
} catch (IllegalArgumentException invalid) { throw conflict(invalid.getMessage()); }
|
||||
}
|
||||
|
||||
private static ResponseStatusException conflict(String message) {
|
||||
return new ResponseStatusException(HttpStatus.CONFLICT, message);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.lastleg.fleet.exception;
|
||||
|
||||
import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy;
|
||||
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public final class FleetHandoverModels {
|
||||
private FleetHandoverModels() { }
|
||||
public record Selection(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId,
|
||||
@NotNull @Positive Long driverId, @NotNull Instant expectedDriverEndAt, @NotNull Instant handedOverAt) { }
|
||||
public record Confirm(@NotNull @Valid Selection selection, @NotBlank @Pattern(regexp="[0-9a-f]{64}") String cargoHash,
|
||||
@NotBlank @Size(max=500) String location, @Size(max=1000) String reason,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
||||
public record Release(@NotNull @Min(0) Long recordVersion,
|
||||
@NotBlank @Pattern(regexp="VEHICLE|DRIVER") String resourceKind,
|
||||
@NotNull Instant actualEndAt, @NotBlank @Size(max=500) String location,
|
||||
@Pattern(regexp="IN_SERVICE|MAINTENANCE|INACTIVE|RETURNED") String vehicleStatus,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
||||
public record Resource(String vehicleId, String vehiclePlate, String driverId, String driverName, String driverPhone) { }
|
||||
public record Preview(String tripNo, long recordVersion, String cargoHash, Resource previous, Resource replacement,
|
||||
Load capacity, FleetHandoverPolicy.Inspection cargo, DriverQualificationPolicy.Qualification driverQualification) { }
|
||||
public record DeliveryReference(String customerId, String deliveryNo) { }
|
||||
public record Snapshot(Resource previous, Resource replacement, Instant handedOverAt, String location, String reason,
|
||||
Instant expectedDriverEndAt, Load capacity, FleetHandoverPolicy.Inspection cargo,
|
||||
List<FleetTransitEvidence> executionEvidence, List<DeliveryReference> carriedDeliveries, String operatorId, Instant recordedAt) { }
|
||||
public record Released(String idempotencyKey, String requestHash, Instant actualEndAt, String location,
|
||||
String vehicleStatus, String operatorId, Instant recordedAt) { }
|
||||
public record Response(String handoverNo, String tripNo, long recordVersion, Snapshot snapshot,
|
||||
Released vehicleRelease, Released driverRelease) { }
|
||||
public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size) {
|
||||
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
|
||||
public long offset() { return (page - 1L) * size; }
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.baiyue.lastleg.fleet.exception;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baiyue.lastleg.fleet.trip.*;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Isolation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Warehouse-scoped projection; exposes only the selected customer dispatch, never a whole-trip snapshot. */
|
||||
@Service
|
||||
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
|
||||
public class FleetHandoverPdaQuery {
|
||||
private final FleetTripDispatchMapper bindings;
|
||||
private final FleetTripMapper trips;
|
||||
private final FleetTripHistoryMapper history;
|
||||
private final FleetDispatchGateway dispatches;
|
||||
private final FleetHandoverRepository handovers;
|
||||
private final ObjectMapper json;
|
||||
public FleetHandoverPdaQuery(FleetTripDispatchMapper bindings, FleetTripMapper trips, FleetTripHistoryMapper history,
|
||||
FleetDispatchGateway dispatches, FleetHandoverRepository handovers, ObjectMapper json) {
|
||||
this.bindings = bindings; this.trips = trips; this.history = history; this.dispatches = dispatches; this.handovers = handovers; this.json = json;
|
||||
}
|
||||
public record Delivery(String deliveryNo, String status, int packages, Instant terminalAt, FleetTransitEvidence.Transport transport) { }
|
||||
public record Handover(String handoverNo, Instant handedOverAt, String location,
|
||||
FleetTransitEvidence.Transport previous, FleetTransitEvidence.Transport replacement) { }
|
||||
public record Status(String dispatchNo, String tripNo, String tripStatus, String tripRecordVersion,
|
||||
FleetTransitEvidence.Transport originalDeparture, List<Delivery> deliveries, Handover latestHandover) { }
|
||||
|
||||
public Status status(long warehouseId, String dispatchNo) {
|
||||
var binding = bindings.findByDispatchNo(dispatchNo);
|
||||
if (binding == null) throw notFound();
|
||||
var trip = trips.findByNo(binding.getTripNo());
|
||||
// Check token-bound warehouse before reading any customer's transport or historical data.
|
||||
if (trip == null || !Objects.equals(trip.getWarehouseId(), warehouseId) || !Objects.equals(binding.getTripId(), trip.getId())) throw notFound();
|
||||
if (!List.of("IN_TRANSIT", "COMPLETED").contains(trip.getStatus()))
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "尚未发车,请按原拣货发车流程作业");
|
||||
var evidence = dispatches.transitEvidence(dispatchNo, false);
|
||||
if (!Objects.equals(evidence.customerId(), binding.getCustomerId()) || !Long.toString(warehouseId).equals(evidence.warehouseId())
|
||||
|| !dispatchNo.equals(evidence.dispatchNo()) || !Objects.equals(binding.getPackageCount(), evidence.shippedPackages())) throw notFound();
|
||||
var original = history.selectList(new LambdaQueryWrapper<FleetTripHistory>().eq(FleetTripHistory::getTripId, trip.getId())
|
||||
.eq(FleetTripHistory::getAction, "DEPART"));
|
||||
if (original.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "缺少唯一原始发车记录");
|
||||
try {
|
||||
var departed = json.readValue(original.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class).trip();
|
||||
if (departed == null || !trip.getTripNo().equals(departed.tripNo()) || !Long.toString(warehouseId).equals(departed.warehouseId())) throw notFound();
|
||||
var originalTransport = new FleetTransitEvidence.Transport(departed.vehiclePlate(), departed.driverName(), departed.driverPhone());
|
||||
var rows = evidence.deliveries().stream().map(row -> new Delivery(row.cargo().deliveryNo(), row.status(), row.cargo().packages(),
|
||||
row.cargo().terminalAt(), row.transport())).toList();
|
||||
var latest = handovers.latestForCargo(trip.getId(), json.writeValueAsString(Map.of("customerId", binding.getCustomerId(), "dispatchNo", dispatchNo)));
|
||||
Handover handover = null;
|
||||
if (latest != null) {
|
||||
var snapshot = json.readValue(latest.snapshot(), FleetHandoverModels.Snapshot.class);
|
||||
handover = new Handover(latest.handoverNo(), snapshot.handedOverAt(), snapshot.location(),
|
||||
transport(snapshot.previous()), transport(snapshot.replacement()));
|
||||
}
|
||||
return new Status(dispatchNo, trip.getTripNo(), trip.getStatus(), trip.getRecordVersion().toString(), originalTransport, rows, handover);
|
||||
} catch (JsonProcessingException error) { throw new IllegalStateException("交接记录读取失败", error); }
|
||||
}
|
||||
private static FleetTransitEvidence.Transport transport(FleetHandoverModels.Resource resource) {
|
||||
return new FleetTransitEvidence.Transport(resource.vehiclePlate(), resource.driverName(), resource.driverPhone());
|
||||
}
|
||||
private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "当前仓库没有该排车交接记录"); }
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package com.baiyue.lastleg.fleet.exception;
|
||||
|
||||
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
/** Pure inspection of server-owned departure and delivery facts; never changes stock or fees. */
|
||||
public final class FleetHandoverPolicy {
|
||||
private FleetHandoverPolicy() { }
|
||||
|
||||
public record Cargo(String customerId, String dispatchNo, String itemId, String palletId,
|
||||
String destinationKey, int packages) { }
|
||||
/** terminalAt is a signed or RETURN_COMPLETED fact, not a mutable updatedAt. */
|
||||
public record Delivery(String customerId, String dispatchNo, String deliveryNo,
|
||||
String destinationKey, int packages, Instant terminalAt) { }
|
||||
public record Pallet(String palletId, Load measuredLoad) { }
|
||||
public record Inspection(List<Cargo> remainingCargo, List<Delivery> completedDeliveries,
|
||||
Set<String> remainingPalletIds, Load occupied) { }
|
||||
private record Destination(String customerId, String dispatchNo, String key) { }
|
||||
private record Item(String customerId, String dispatchNo, String itemId) { }
|
||||
private record DeliveryKey(String customerId, String deliveryNo) { }
|
||||
|
||||
/**
|
||||
* Caller locks trip, dispatches and deliveries and derives terminalAt from trusted execution facts.
|
||||
* An unresolved refusal has no terminalAt and remains aboard. A signed delivery later returned
|
||||
* uses its original signature. This policy does not invent per-box partial-signature facts.
|
||||
*/
|
||||
public static Inspection inspect(Instant departedAt, Instant handedOverAt, Instant now,
|
||||
List<Cargo> departureCargo, List<Delivery> deliveries,
|
||||
List<Pallet> departureMeasurements, Load replacementCapacity) {
|
||||
if (departedAt == null || handedOverAt == null || now == null
|
||||
|| handedOverAt.isBefore(departedAt) || handedOverAt.isAfter(now))
|
||||
throw invalid("交接时间须在实际发车之后且不晚于当前时间");
|
||||
if (departureCargo == null || departureCargo.isEmpty() || deliveries == null || deliveries.isEmpty())
|
||||
throw invalid("缺少原始发运或派送依据");
|
||||
Map<Destination, Integer> shipped = new HashMap<>();
|
||||
Map<String, String> palletDestinations = new HashMap<>();
|
||||
Set<Item> itemKeys = new HashSet<>();
|
||||
for (Cargo cargo : departureCargo) {
|
||||
if (cargo == null || blank(cargo.customerId()) || blank(cargo.dispatchNo()) || blank(cargo.itemId())
|
||||
|| blank(cargo.palletId()) || blank(cargo.destinationKey()) || cargo.packages() <= 0)
|
||||
throw invalid("原始发运缺少客户、排车、实托、目的地或箱数");
|
||||
if (!itemKeys.add(new Item(cargo.customerId(), cargo.dispatchNo(), cargo.itemId())))
|
||||
throw invalid("原始发运货物重复");
|
||||
shipped.merge(destination(cargo), cargo.packages(), Math::addExact);
|
||||
String previous = palletDestinations.putIfAbsent(cargo.palletId(), cargo.destinationKey());
|
||||
if (previous != null && !previous.equals(cargo.destinationKey()))
|
||||
throw invalid("同一实托包含不同卸货目的地,请先核对原始装载");
|
||||
}
|
||||
Map<Destination, Delivery> byDestination = new HashMap<>();
|
||||
Set<DeliveryKey> deliveryKeys = new HashSet<>();
|
||||
List<Delivery> completed = new ArrayList<>();
|
||||
for (Delivery delivery : deliveries) {
|
||||
if (delivery == null || blank(delivery.customerId()) || blank(delivery.dispatchNo())
|
||||
|| blank(delivery.deliveryNo()) || blank(delivery.destinationKey()) || delivery.packages() <= 0)
|
||||
throw invalid("派送事实缺少业务归属或箱数");
|
||||
var key = new Destination(delivery.customerId(), delivery.dispatchNo(), delivery.destinationKey());
|
||||
if (!deliveryKeys.add(new DeliveryKey(delivery.customerId(), delivery.deliveryNo()))
|
||||
|| byDestination.putIfAbsent(key, delivery) != null)
|
||||
throw invalid("派送事实重复或目的地拆分尚未核平");
|
||||
if (!Objects.equals(shipped.get(key), delivery.packages()))
|
||||
throw invalid("派送客户、排车、目的地或箱数与原始发运不一致");
|
||||
if (delivery.terminalAt() != null) {
|
||||
if (delivery.terminalAt().isBefore(departedAt) || delivery.terminalAt().isAfter(handedOverAt))
|
||||
throw invalid("交接时间与已发生的签收或退回事实冲突,请刷新核对");
|
||||
completed.add(delivery);
|
||||
}
|
||||
}
|
||||
if (!byDestination.keySet().equals(shipped.keySet())) throw invalid("派送事实未覆盖全部原始发运货物");
|
||||
Map<String, Load> measurements = new HashMap<>();
|
||||
if (departureMeasurements == null) throw invalid("缺少原始实测装载");
|
||||
for (Pallet pallet : departureMeasurements) {
|
||||
if (pallet == null || blank(pallet.palletId()) || !positive(pallet.measuredLoad())
|
||||
|| measurements.putIfAbsent(pallet.palletId(), pallet.measuredLoad()) != null)
|
||||
throw invalid("原始实托测量缺失或重复");
|
||||
}
|
||||
if (!measurements.keySet().equals(palletDestinations.keySet())) throw invalid("实测托盘与原始货物不一致");
|
||||
List<Cargo> remaining = departureCargo.stream()
|
||||
.filter(cargo -> byDestination.get(destination(cargo)).terminalAt() == null).toList();
|
||||
if (remaining.isEmpty()) throw invalid("全部货物已完成,请按结案和资源结束流程处理");
|
||||
Set<String> palletIds = new TreeSet<>();
|
||||
remaining.forEach(cargo -> palletIds.add(cargo.palletId()));
|
||||
Load occupied = new Load(0, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO);
|
||||
// Keep the measured whole-pallet load when only one customer's portion has been signed.
|
||||
// It is unsafe to infer residual weight/volume from package-count ratios.
|
||||
for (String palletId : palletIds) occupied = occupied.plus(measurements.get(palletId));
|
||||
if (!positive(replacementCapacity)) throw invalid("请确认接替车辆托位、载重和容积");
|
||||
if (occupied.palletSpaces() > replacementCapacity.palletSpaces()
|
||||
|| occupied.weightKg().compareTo(replacementCapacity.weightKg()) > 0
|
||||
|| occupied.volumeCbm().compareTo(replacementCapacity.volumeCbm()) > 0)
|
||||
throw invalid("未完成货物超过接替车辆容量");
|
||||
return new Inspection(List.copyOf(remaining), List.copyOf(completed),
|
||||
Collections.unmodifiableSet(palletIds), occupied);
|
||||
}
|
||||
|
||||
private static Destination destination(Cargo cargo) {
|
||||
return new Destination(cargo.customerId(), cargo.dispatchNo(), cargo.destinationKey());
|
||||
}
|
||||
private static boolean positive(Load load) {
|
||||
return load != null && load.palletSpaces() > 0 && load.weightKg().signum() > 0 && load.volumeCbm().signum() > 0;
|
||||
}
|
||||
private static boolean blank(String text) { return text == null || text.isBlank(); }
|
||||
private static IllegalArgumentException invalid(String message) { return new IllegalArgumentException(message); }
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.baiyue.lastleg.fleet.exception;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
|
||||
/** All mutations are serialized by the owning trip row lock. Stored snapshots are immutable. */
|
||||
@Repository
|
||||
public class FleetHandoverRepository {
|
||||
record Row(long id, long tripId, long tripVersion, String tripNo, String handoverNo, String requestKey, String requestHash,
|
||||
long version, Instant handedOverAt, String snapshot, String vehicleRelease, String driverRelease) { }
|
||||
private static final RowMapper<Row> ROW = (r, ignored) -> new Row(r.getLong("id"), r.getLong("trip_id"), r.getLong("trip_version"),
|
||||
r.getString("trip_no"), r.getString("handover_no"), r.getString("request_key"), r.getString("request_hash"),
|
||||
r.getLong("record_version"), r.getTimestamp("handed_over_at").toLocalDateTime().toInstant(ZoneOffset.UTC),
|
||||
r.getString("snapshot_json"), r.getString("vehicle_release_json"), r.getString("driver_release_json"));
|
||||
private final JdbcTemplate jdbc;
|
||||
public FleetHandoverRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; }
|
||||
Row created(long tripId, String key) {
|
||||
return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? AND request_key=?", ROW, tripId, key).stream().findFirst().orElse(null);
|
||||
}
|
||||
Row find(long tripId, String number) {
|
||||
return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? AND handover_no=?", ROW, tripId, number).stream().findFirst().orElse(null);
|
||||
}
|
||||
Row latest(long tripId) {
|
||||
return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? ORDER BY id DESC LIMIT 1", ROW, tripId).stream().findFirst().orElse(null);
|
||||
}
|
||||
Row latestForCargo(long tripId, String customerDispatchJson) {
|
||||
return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? AND JSON_CONTAINS(snapshot_json,?,'$.cargo.remainingCargo')=1 ORDER BY trip_version DESC LIMIT 1",
|
||||
ROW, tripId, customerDispatchJson).stream().findFirst().orElse(null);
|
||||
}
|
||||
long count(long tripId) { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_handover WHERE trip_id=?", Long.class, tripId); }
|
||||
List<Row> page(long tripId, FleetHandoverModels.Query query) {
|
||||
return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? ORDER BY id DESC LIMIT ? OFFSET ?", ROW, tripId, query.size(), query.offset());
|
||||
}
|
||||
int insert(Row row, String operator, Instant now) {
|
||||
return jdbc.update("INSERT INTO ll_fleet_trip_handover(id,trip_id,trip_version,trip_no,handover_no,request_key,request_hash,record_version,handed_over_at,snapshot_json,created_by,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
row.id(), row.tripId(), row.tripVersion(), row.tripNo(), row.handoverNo(), row.requestKey(), row.requestHash(), row.version(),
|
||||
LocalDateTime.ofInstant(row.handedOverAt(), ZoneOffset.UTC), row.snapshot(), operator, LocalDateTime.ofInstant(now, ZoneOffset.UTC));
|
||||
}
|
||||
int release(Row row, boolean vehicle, String result) {
|
||||
String column = vehicle ? "vehicle_release_json" : "driver_release_json";
|
||||
return jdbc.update("UPDATE ll_fleet_trip_handover SET " + column + "=?,record_version=record_version+1 WHERE id=? AND record_version=? AND " + column + " IS NULL",
|
||||
result, row.id(), row.version());
|
||||
}
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
package com.baiyue.lastleg.fleet.exception;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.fleet.driver.DriverModels;
|
||||
import com.baiyue.lastleg.fleet.driver.DriverService;
|
||||
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load;
|
||||
import com.baiyue.lastleg.fleet.trip.*;
|
||||
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.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Isolation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.fleet.exception.FleetHandoverModels.*;
|
||||
|
||||
@Service
|
||||
@Transactional(isolation = Isolation.READ_COMMITTED)
|
||||
public class FleetHandoverService {
|
||||
private final FleetTripMapper trips;
|
||||
private final FleetResourceMapper resources;
|
||||
private final FleetTripService history;
|
||||
private final FleetDispatchGateway dispatches;
|
||||
private final VehicleRepository vehicles;
|
||||
private final VehicleApplicationService vehicleService;
|
||||
private final DriverService drivers;
|
||||
private final FleetHandoverInspectionService inspection;
|
||||
private final FleetHandoverRepository repository;
|
||||
private final ObjectMapper json;
|
||||
|
||||
public FleetHandoverService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripService history,
|
||||
FleetDispatchGateway dispatches, VehicleRepository vehicles, VehicleApplicationService vehicleService,
|
||||
DriverService drivers, FleetHandoverInspectionService inspection, FleetHandoverRepository repository, ObjectMapper json) {
|
||||
this.trips = trips; this.resources = resources; this.history = history; this.dispatches = dispatches;
|
||||
this.vehicles = vehicles; this.vehicleService = vehicleService; this.drivers = drivers;
|
||||
this.inspection = inspection; this.repository = repository; this.json = json;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
|
||||
public PageResult<Response> page(String tripNo, Query query) {
|
||||
var trip = required(tripNo, false);
|
||||
long count = repository.count(trip.getId());
|
||||
return new PageResult<>(repository.page(trip.getId(), query).stream().map(this::response).toList(),
|
||||
query.page(), query.size(), count, count / query.size() + (count % query.size() == 0 ? 0 : 1));
|
||||
}
|
||||
|
||||
public Preview preview(String tripNo, Selection selection) {
|
||||
var trip = required(tripNo, true);
|
||||
return prepare(trip, selection).preview();
|
||||
}
|
||||
|
||||
public Response confirm(String tripNo, String operator, Confirm request) {
|
||||
var trip = required(tripNo, true);
|
||||
String requestHash = hash(request);
|
||||
var previous = repository.created(trip.getId(), request.idempotencyKey());
|
||||
if (previous != null) {
|
||||
if (!previous.requestHash().equals(requestHash)) throw conflict("交接请求标识已用于不同内容");
|
||||
return response(previous);
|
||||
}
|
||||
var prepared = prepare(trip, request.selection());
|
||||
if (!prepared.preview().cargoHash().equals(request.cargoHash())) throw conflict("交接货物、资源或派送状态已变化,请重新核对");
|
||||
if (request.location() == null || request.location().isBlank()) throw bad("请填写实际交接地点");
|
||||
reserve("VEHICLE", request.selection().vehicleId(), trip);
|
||||
reserve("DRIVER", request.selection().driverId(), trip);
|
||||
var replacement = prepared.preview().replacement();
|
||||
for (var evidence : prepared.evidence()) dispatches.handover(evidence,
|
||||
new FleetDispatchGateway.Assignment(replacement.vehiclePlate(), replacement.driverName(), replacement.driverPhone()));
|
||||
Instant recordedAt = Instant.now();
|
||||
String reason = request.reason() == null || request.reason().isBlank() ? "在途车辆司机交接" : request.reason().trim();
|
||||
var snapshot = new Snapshot(prepared.preview().previous(), replacement, request.selection().handedOverAt(),
|
||||
request.location().trim(), reason, request.selection().expectedDriverEndAt(), prepared.preview().capacity(),
|
||||
prepared.preview().cargo(), prepared.evidence(), prepared.evidence().stream().flatMap(item -> item.deliveries().stream())
|
||||
.filter(item -> item.cargo().terminalAt() == null).map(item -> new DeliveryReference(item.cargo().customerId(), item.cargo().deliveryNo())).toList(), operator, recordedAt);
|
||||
var row = new FleetHandoverRepository.Row(IdWorker.getId(), trip.getId(), request.selection().recordVersion() + 1, tripNo,
|
||||
"HO-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT),
|
||||
request.idempotencyKey(), requestHash, 0, request.selection().handedOverAt(), write(snapshot), null, null);
|
||||
if (repository.insert(row, operator, recordedAt) != 1) throw conflict("交接记录保存失败");
|
||||
trip.setVehicleId(request.selection().vehicleId()); trip.setVehiclePlate(replacement.vehiclePlate());
|
||||
trip.setDriverId(request.selection().driverId()); trip.setDriverName(replacement.driverName()); trip.setDriverPhone(replacement.driverPhone());
|
||||
trip.setMaxPalletSpaces(prepared.preview().capacity().palletSpaces()); trip.setMaxLoadKg(prepared.preview().capacity().weightKg());
|
||||
trip.setMaxVolumeCbm(prepared.preview().capacity().volumeCbm());
|
||||
trip.setExpectedDriverEndAt(LocalDateTime.ofInstant(request.selection().expectedDriverEndAt(), ZoneOffset.UTC));
|
||||
// Old resources remain reserved until their own actual task ends. DEPART and load_json are never rewritten.
|
||||
history.recordExecution(trip, request.selection().recordVersion(), "HANDOVER", reason, operator);
|
||||
return response(row);
|
||||
}
|
||||
|
||||
public Response release(String tripNo, String handoverNo, String operator, Release request) {
|
||||
var trip = required(tripNo, true);
|
||||
var row = repository.find(trip.getId(), handoverNo);
|
||||
if (row == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "交接记录不存在");
|
||||
if (!Set.of("VEHICLE", "DRIVER").contains(request.resourceKind())) throw bad("请选择车辆或司机任务");
|
||||
boolean vehicle = "VEHICLE".equals(request.resourceKind());
|
||||
String requestHash = hash(request), previous = vehicle ? row.vehicleRelease() : row.driverRelease();
|
||||
if (previous != null) {
|
||||
var released = read(previous, Released.class);
|
||||
if (!released.idempotencyKey().equals(request.idempotencyKey()) || !released.requestHash().equals(requestHash))
|
||||
throw conflict("原资源任务已结束,请核对原请求");
|
||||
return response(row);
|
||||
}
|
||||
if (row.version() != request.recordVersion()) throw conflict("交接记录已变化,请刷新");
|
||||
var snapshot = read(row.snapshot(), Snapshot.class);
|
||||
String oldId = vehicle ? snapshot.previous().vehicleId() : snapshot.previous().driverId();
|
||||
String nextId = vehicle ? snapshot.replacement().vehicleId() : snapshot.replacement().driverId();
|
||||
Long currentId = vehicle ? trip.getVehicleId() : trip.getDriverId();
|
||||
if (oldId.equals(nextId) || oldId.equals(Objects.toString(currentId, null)))
|
||||
throw conflict("该资源仍承担当前运输任务,不能通过旧交接结束");
|
||||
if (request.actualEndAt() == null || request.actualEndAt().isBefore(snapshot.handedOverAt())
|
||||
|| request.actualEndAt().isAfter(Instant.now()) || request.location() == null || request.location().isBlank())
|
||||
throw bad("请填写不早于交接且不晚于当前的实际结束时间和地点");
|
||||
if (vehicle && (request.vehicleStatus() == null || !Set.of("IN_SERVICE", "MAINTENANCE", "INACTIVE", "RETURNED").contains(request.vehicleStatus())))
|
||||
throw bad("请确认原车辆结束后的实际状态");
|
||||
if (!vehicle && request.vehicleStatus() != null) throw bad("司机任务结束不能修改车辆状态");
|
||||
long resourceId = Long.parseLong(oldId);
|
||||
if (vehicle) {
|
||||
var oldVehicle = vehicles.lockById(resourceId).orElseThrow(() -> conflict("原车辆档案不存在"));
|
||||
releaseResource(request.resourceKind(), resourceId, trip.getId());
|
||||
vehicleService.changeStatus(resourceId, operator, new VehicleModels.ChangeStatus(request.vehicleStatus(), oldVehicle.recordVersion()));
|
||||
} else releaseResource(request.resourceKind(), resourceId, trip.getId());
|
||||
var released = new Released(request.idempotencyKey(), requestHash, request.actualEndAt(), request.location().trim(),
|
||||
request.vehicleStatus(), operator, Instant.now());
|
||||
if (repository.release(row, vehicle, write(released)) != 1) throw conflict("原资源结束记录已变化");
|
||||
history.recordExecution(trip, trip.getRecordVersion(), vehicle ? "HANDOVER_RELEASE_VEHICLE" : "HANDOVER_RELEASE_DRIVER",
|
||||
"结束交接前资源任务", operator);
|
||||
return response(repository.find(trip.getId(), handoverNo));
|
||||
}
|
||||
|
||||
private record Prepared(Preview preview, List<FleetTransitEvidence> evidence) { }
|
||||
private record Basis(Selection selection, Resource previous, Resource replacement, Load capacity,
|
||||
com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy.Qualification qualification,
|
||||
FleetHandoverInspectionService.Prepared inspection) { }
|
||||
private Prepared prepare(FleetTripEntity trip, Selection selection) {
|
||||
if (selection.recordVersion() == null || trip.getRecordVersion().longValue() != selection.recordVersion())
|
||||
throw conflict("车次已变化,请刷新后重新核对");
|
||||
if (!"IN_TRANSIT".equals(trip.getStatus()) || trip.getVehicleId() == null || trip.getDriverId() == null)
|
||||
throw conflict("仅已发车且资源完整的车次可交接");
|
||||
if (Objects.equals(selection.vehicleId(), trip.getVehicleId()) && Objects.equals(selection.driverId(), trip.getDriverId()))
|
||||
throw bad("请选择需要接替的车辆或司机");
|
||||
if (selection.expectedDriverEndAt() == null || !selection.expectedDriverEndAt().isAfter(Instant.now())
|
||||
|| selection.handedOverAt() == null || !selection.expectedDriverEndAt().isAfter(selection.handedOverAt()))
|
||||
throw bad("司机预计任务结束须晚于交接和当前时间");
|
||||
var latest = repository.latest(trip.getId());
|
||||
if (latest != null && selection.handedOverAt().isBefore(read(latest.snapshot(), Snapshot.class).handedOverAt()))
|
||||
throw bad("交接时间不能早于上次交接");
|
||||
requireOwned("VEHICLE", trip.getVehicleId(), trip.getId()); requireOwned("DRIVER", trip.getDriverId(), trip.getId());
|
||||
var vehicle = vehicles.lockById(selection.vehicleId()).orElseThrow(() -> bad("接替车辆不存在"));
|
||||
if (!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status())) throw bad("请选择在役海外车辆");
|
||||
if (vehicle.maxPalletSpaces() == null || vehicle.maxLoadKg() == null || vehicle.maxVolumeCbm() == null)
|
||||
throw bad("请先确认接替车辆实际容量");
|
||||
DriverModels.Response driver = drivers.requireDispatchable(selection.driverId(), selection.expectedDriverEndAt());
|
||||
if (!"OVERSEAS".equals(driver.details().region())) throw bad("请选择海外司机");
|
||||
requireAvailable("VEHICLE", selection.vehicleId(), trip.getVehicleId(), trip.getId());
|
||||
requireAvailable("DRIVER", selection.driverId(), trip.getDriverId(), trip.getId());
|
||||
var capacity = new Load(vehicle.maxPalletSpaces(), vehicle.maxLoadKg(), vehicle.maxVolumeCbm());
|
||||
var checked = inspection.prepare(trip.getTripNo(), selection.handedOverAt(), capacity, true);
|
||||
var previous = new Resource(trip.getVehicleId().toString(), trip.getVehiclePlate(), trip.getDriverId().toString(), trip.getDriverName(), trip.getDriverPhone());
|
||||
var replacement = new Resource(Long.toString(vehicle.id()), vehicle.plateNumber(), driver.id(), driver.details().name(), driver.details().phone());
|
||||
String cargoHash = hash(new Basis(selection, previous, replacement, capacity, driver.qualification(), checked));
|
||||
return new Prepared(new Preview(trip.getTripNo(), trip.getRecordVersion(), cargoHash, previous, replacement,
|
||||
capacity, checked.cargo(), driver.qualification()), checked.executionEvidence());
|
||||
}
|
||||
private void requireOwned(String kind, long id, long tripId) {
|
||||
var owner = resources.find(kind, id);
|
||||
if (owner == null || owner.tripId() != tripId) throw conflict("当前资源占用与车次不一致");
|
||||
}
|
||||
private void requireAvailable(String kind, long id, long currentId, long tripId) {
|
||||
var owner = resources.find(kind, id);
|
||||
if (owner != null && (id != currentId || owner.tripId() != tripId))
|
||||
throw conflict("接替资源仍有未结束任务,请先核对实际任务结束");
|
||||
}
|
||||
private void reserve(String kind, long id, FleetTripEntity trip) {
|
||||
long current = "VEHICLE".equals(kind) ? trip.getVehicleId() : trip.getDriverId();
|
||||
if (id == current) { requireOwned(kind, id, trip.getId()); return; }
|
||||
try { if (resources.reserve(kind, id, trip.getId(), trip.getTripNo()) != 1) throw conflict("接替资源占用保存失败"); }
|
||||
catch (DuplicateKeyException occupied) { throw conflict("接替资源已被占用,请重新核对"); }
|
||||
}
|
||||
private void releaseResource(String kind, long id, long tripId) {
|
||||
if (resources.release(kind, id, tripId) != 1) throw conflict("原资源占用不一致,未结束任务");
|
||||
}
|
||||
private FleetTripEntity required(String tripNo, boolean lock) {
|
||||
var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo);
|
||||
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
|
||||
return trip;
|
||||
}
|
||||
private Response response(FleetHandoverRepository.Row row) {
|
||||
return new Response(row.handoverNo(), row.tripNo(), row.version(), read(row.snapshot(), Snapshot.class),
|
||||
row.vehicleRelease() == null ? null : read(row.vehicleRelease(), Released.class),
|
||||
row.driverRelease() == null ? null : read(row.driverRelease(), Released.class));
|
||||
}
|
||||
private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException error) { throw new IllegalStateException(error); } }
|
||||
private <T> T read(String value, Class<T> type) { try { return json.readValue(value, type); } catch (JsonProcessingException error) { throw new IllegalStateException(error); } }
|
||||
private String hash(Object value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); }
|
||||
}
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.baiyue.lastleg.fleet.exception;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Internal port response. Identity always comes from the original customer dispatch. */
|
||||
public record FleetTransitEvidence(String dispatchNo, String customerId, String warehouseId,
|
||||
int shippedPackages, List<Delivery> deliveries) {
|
||||
public record Transport(String vehiclePlate, String driverName, String driverPhone) { }
|
||||
public record Delivery(FleetHandoverPolicy.Delivery cargo, String status, long recordVersion, Transport transport) { }
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.baiyue.lastleg.fleet.exception;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@Validated
|
||||
@RequestMapping("/api/platform/v1/last-leg/fleet/trips/{tripNo}/handovers")
|
||||
public class PlatformFleetHandoverController {
|
||||
private final FleetHandoverService service;
|
||||
public PlatformFleetHandoverController(FleetHandoverService service) { this.service = service; }
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
|
||||
public ApiResponse<PageResult<FleetHandoverModels.Response>> page(@PathVariable String tripNo, @Valid FleetHandoverModels.Query query) {
|
||||
return ApiResponse.success(service.page(tripNo, query));
|
||||
}
|
||||
@PostMapping("/preview")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
|
||||
public ApiResponse<FleetHandoverModels.Preview> preview(@PathVariable String tripNo, @Valid @RequestBody FleetHandoverModels.Selection request) {
|
||||
return ApiResponse.success(service.preview(tripNo, request));
|
||||
}
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:handover')")
|
||||
public ApiResponse<FleetHandoverModels.Response> confirm(@PathVariable String tripNo, @AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody FleetHandoverModels.Confirm request) { return ApiResponse.success(service.confirm(tripNo, operator(user), request)); }
|
||||
@PostMapping("/{handoverNo}/resources/release")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:trip:release')")
|
||||
public ApiResponse<FleetHandoverModels.Response> release(@PathVariable String tripNo, @PathVariable String handoverNo,
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody FleetHandoverModels.Release request) {
|
||||
return ApiResponse.success(service.release(tripNo, handoverNo, operator(user), request));
|
||||
}
|
||||
private static String operator(AuthenticatedUser user) {
|
||||
if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录");
|
||||
return user.userId().toString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user