This commit is contained in:
2026-10-20 10:35:52 +08:00
parent ce189f062f
commit c13175bf77
202 changed files with 38397 additions and 7138 deletions
@@ -9,7 +9,7 @@ public interface FleetAppointmentImpactMapper {
@Select("""
<script>SELECT DISTINCT t.trip_no,t.status,t.planned_departure_at FROM ll_fleet_trip_dispatch d
JOIN ll_fleet_trip t ON t.id=d.trip_id WHERE t.status NOT IN ('CANCELLED','COMPLETED') AND
<foreach collection='rows' item='r' open='(' separator=' OR ' close=')'>(d.customer_id=#{r.customerId} AND d.dispatch_no=#{r.dispatchNo})</foreach>
<foreach collection='rows' item='r' open='(' separator=' OR ' close=')'>(d.dispatch_no=#{r.dispatchNo} AND EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.dispatch_id AND i.customer_id=#{r.customerId}))</foreach>
ORDER BY t.trip_no</script>
""")
List<Trip> trips(@Param("rows") List<Dispatch> rows);
@@ -14,7 +14,7 @@ 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. */
/** 由PDA绑定仓读取本车交接状态;实际派送归属逐行核验。 */
@Service
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public class FleetHandoverPdaQuery {
@@ -45,6 +45,12 @@ public class FleetHandoverPdaQuery {
var evidence = dispatches.transitEvidence(dispatchNo, false);
if (!Long.toString(warehouseId).equals(evidence.warehouseId())
|| !dispatchNo.equals(evidence.dispatchNo()) || !Objects.equals(binding.getPackageCount(), evidence.shippedPackages())) throw notFound();
var deliveryNumbers=new java.util.HashSet<String>();
if(evidence.deliveries()==null || evidence.deliveries().isEmpty() || evidence.deliveries().stream().anyMatch(row ->
row==null || row.cargo()==null || row.cargo().customerId()==null || row.cargo().customerId().isBlank()
|| !dispatchNo.equals(row.cargo().dispatchNo()) || row.cargo().deliveryNo()==null
|| !deliveryNumbers.add(row.cargo().deliveryNo()) || row.cargo().packages()<=0)
|| evidence.deliveries().stream().mapToLong(row->row.cargo().packages()).sum()!=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, "缺少唯一原始发车记录");
@@ -90,8 +90,17 @@ public class FleetBookingReservations {
}
}
private void touch(FleetBookingEntity row,String operator){row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedBy(operator);row.setUpdatedAt(java.time.LocalDateTime.now(ZoneOffset.UTC));if(bookings.updateById(row)!=1)throw conflict("外接关联已变化");}
/** 父车次锁下读取有效外接占位支持的卸货目的地,不用展示响应推导。 */
public Set<String> requiredRouteDestinations(long tripId) {
var keys=new LinkedHashSet<String>();
for(var row:bookings.list(tripId)) if(unbound(row)||"BOUND".equals(row.getStatus())) {
if(row.getDestinationKey()==null || row.getDestinationKey().isBlank())throw conflict("已确认外接预订缺少真实卸货目的地");
keys.add(row.getDestinationKey());
}
return Collections.unmodifiableSet(keys);
}
public void requireRoute(FleetTripEntity trip,List<FleetTripModels.Stop> stops) {
for(var row:bookings.list(trip.getId())) if((unbound(row)||"BOUND".equals(row.getStatus())) && index(stops,row.getDestinationKey())<1)
for(var key:requiredRouteDestinations(trip.getId())) if(index(stops,key)<1)
throw conflict("路线仍须包含已确认外接预订的卸货站");
}
private void add(List<TripCapacityPolicy.Booking> loads,List<FleetTripModels.Stop> stops,FleetBookingEntity row){
@@ -34,6 +34,11 @@ public class FleetTripExecutionService {
private final ObjectMapper json;
private final FleetBookingReservations bookingReservations;
/** 排车域先核对冻结司机及附件引用;本域继续校验该司机当前或历史证照归属。 */
public DriverModels.Download dispatchDriverLicense(long driverId,long fileId) {
return drivers.download(driverId,fileId);
}
public FleetTripExecutionService(FleetTripMapper trips, FleetTripDispatchMapper bindings, FleetResourceMapper resources,
FleetDispatchGateway dispatches, FleetTripService history, DriverService drivers,
VehicleRepository vehicles, ObjectMapper json, FleetBookingReservations bookingReservations) {
@@ -203,69 +208,6 @@ public class FleetTripExecutionService {
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public FleetExecutionModels.Gate gate(String warehouseId, String dispatchNo) {
var binding = bindings.findByDispatchNo(dispatchNo);
if (binding == null) return missingGate();
return pickingGate(gateForTrip(warehouseId, binding.getTripNo()), binding);
}
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public Map<String, FleetExecutionModels.Gate> gates(String warehouseId, List<String> numbers) {
if (numbers.isEmpty()) return Map.of();
var result = new HashMap<String, FleetExecutionModels.Gate>();
numbers.forEach(number -> result.put(number, missingGate()));
var byTrip = new HashMap<String, FleetExecutionModels.Gate>();
for (var binding : bindings.findByDispatchNos(numbers)) result.put(binding.getDispatchNo(), pickingGate(
byTrip.computeIfAbsent(binding.getTripNo(), number -> gateForTrip(warehouseId, number)), binding));
return result;
}
private FleetExecutionModels.Gate gateForTrip(String warehouseId, String tripNo) {
var trip = requireTrip(tripNo, false);
requireWarehouse(trip, warehouseId);
var manifest = manifests(trip, false);
String reason = blockReason(trip, manifest);
String warning = null;
if (reason == null) reason = loadBlockReason(trip, cargo(manifest));
if (reason == null) reason = loadingReviewBlockReason(trip);
if (reason == null) for (var member : bindings.listByTrip(trip.getId())) {
reason = appointmentAllocations.departureBlockReason(member.getDispatchNo());
if (reason != null) break;
}
if (reason == null) {
var driver = drivers.detail(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));
if (!driver.qualification().canDispatch()) reason = "司机资格不满足本次任务,请在平台核对";
else reason = driverBlockReason(trip, driver);
if (reason == null && driver.qualification().expiringSoon()) warning = "司机驾驶证将于 " + driver.qualification().licenseExpiresOn() + " 到期;有效期覆盖本次预计任务,请留意延期风险";
}
boolean canPick = "PLANNED".equals(trip.getStatus()) && trip.getVehicleId() != null && trip.getDriverId() != null;
return new FleetExecutionModels.Gate(trip.getTripNo(), trip.getRecordVersion(), manifest.size(), reason == null, reason, warning,
canPick, canPick ? null : "当前车次不允许继续拣货");
}
private static FleetExecutionModels.Gate missingGate() {
return new FleetExecutionModels.Gate(null, null, 0, false, "请先在平台将排车单关联车次", null, false, "请先关联车次并通过调度审核");
}
private FleetExecutionModels.Gate pickingGate(FleetExecutionModels.Gate gate, FleetTripDispatch binding) {
if (!gate.canPick()) return gate;
var fact=dispatches.workflows(List.of(binding.getDispatchNo())).get(binding.getDispatchNo());
if(fact==null)return gate.withPicking(false,"整车审核事实缺失");
var review = fact.dispatchReview();
String reason = "APPROVED".equals(review.status()) ? null : "REJECTED".equals(review.status())
? "调度审核已打回:" + review.reason() : "请先通过调度审核再拣货";
return gate.withPicking(reason == null, reason);
}
private String loadingReviewBlockReason(FleetTripEntity trip) {
var members=bindings.listByTrip(trip.getId());
var facts=dispatches.workflows(members.stream().map(FleetTripDispatch::getDispatchNo).toList());
return !members.isEmpty() && members.stream().allMatch(b->facts.containsKey(b.getDispatchNo()) && "APPROVED".equals(facts.get(b.getDispatchNo()).loadingReview().status()))?null:"请先通过本车理货审核再发车";
}
private List<FleetExecutionModels.Manifest> manifests(FleetTripEntity trip, boolean lock) {
return bindings.listByTrip(trip.getId()).stream().map(binding -> dispatches.manifest(binding.getDispatchNo(), lock)).toList();
}
@@ -275,7 +217,8 @@ public class FleetTripExecutionService {
}
private String blockReason(FleetTripEntity trip, List<FleetExecutionModels.Manifest> manifests) {
if (!"PLANNED".equals(trip.getStatus())) return "当前车次不是待发车计划";
if (trip.getVehicleId() == null || trip.getDriverId() == null) return "请先分配车辆和司机";
String vehicleReason=currentVehicleBlockReason(trip,false);if(vehicleReason!=null)return vehicleReason;
if(trip.getDriverName()==null || trip.getDriverName().isBlank())return "请先确认本趟司机资料";
if (manifests.isEmpty()) return "车次尚未关联排车单";
if (bookingReservations.hasConfirmed(trip.getId())) return "已确认外接预订尚未关联实际发运货物,请先完成来货与排车关联";
for (var binding : bindings.listByTrip(trip.getId())) {
@@ -290,13 +233,6 @@ public class FleetTripExecutionService {
}
return null;
}
private String loadBlockReason(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo) {
var load = load(trip);
if (load == null) return "请在平台确认本车实际装载";
if (!load.manifestHash().equals(hash(write(cargo)))) return "装载确认后货物发生变化,请重新确认";
try { capacity(trip, cargo, load.pallets()); return null; }
catch (ResponseStatusException error) { return error.getReason(); }
}
private List<TripCapacityPolicy.Segment> capacity(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo,
List<FleetExecutionModels.Measurement> pallets) {
List<FleetTripModels.Stop> stops;
@@ -333,19 +269,11 @@ public class FleetTripExecutionService {
var claim = resources.find(kind, id);
if (claim == null || claim.tripId() != tripId) throw conflict("车次车辆或司机占用已变化");
}
private static String driverBlockReason(FleetTripEntity trip, DriverModels.Response driver) {
if (!"OVERSEAS".equals(driver.details().region())) return "本车次须使用海外司机,请核对当前司机档案";
if (!Objects.equals(driver.details().name(), trip.getDriverName())) return "司机档案已变化,请核对车次司机快照";
return null;
}
private FleetTripEntity requireTrip(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 static void requireWarehouse(FleetTripEntity trip, String warehouseId) {
if (!trip.getWarehouseId().toString().equals(warehouseId)) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "当前绑定仓无此车次");
}
/** 出库装载只能用于派送车次,取货必须沿专用运输链执行。 */
private static void requireVersionAndPlan(FleetTripEntity trip, long version) {
if ("PICKUP".equals(trip.getTripPurpose())) throw conflict("取货车次请使用取货任务操作");
@@ -349,13 +349,28 @@ public class FleetTripService {
var trip = repository.lockTrip(binding.getTripNo());
if (trip == null || !"PLANNED".equals(trip.getStatus())) throw conflict("仅未发车整车可以调整货物");
List<FleetTripModels.Stop> existing = read(trip.getStopsJson(),new TypeReference<List<FleetTripModels.Stop>>() { });
var stops = new ArrayList<>(existing);
if (stops.isEmpty()) stops.add(new FleetTripModels.Stop(trip.getWarehouseName(),null,"ORIGIN:"+trip.getWarehouseId()));
var keys = new HashSet<String>(); stops.forEach(stop -> keys.add(stop.destinationKey()));
for (var stop : destinations) if (keys.add(stop.destinationKey())) stops.add(new FleetTripModels.Stop(stop.name(),stop.address(),stop.destinationKey()));
String originKey="ORIGIN:"+trip.getWarehouseId();
var origin=existing.isEmpty()?new FleetTripModels.Stop(trip.getWarehouseName(),null,originKey):existing.get(0);
if(!originKey.equals(origin.destinationKey()))throw conflict("本车缺少与起运仓一致的路线起点");
var requested=new LinkedHashMap<String,FleetExecutionModels.DispatchStop>();
for(var stop:destinations) {
if(stop==null || stop.destinationKey()==null || stop.destinationKey().isBlank() || originKey.equals(stop.destinationKey()))throw conflict("实际货物缺少有效卸货目的地");
requested.putIfAbsent(stop.destinationKey(),stop);
}
var supported=new HashSet<>(bookingReservations.requiredRouteDestinations(trip.getId()));
supported.addAll(requested.keySet());
var stops=new ArrayList<FleetTripModels.Stop>();stops.add(origin);
var keys=new HashSet<String>();keys.add(originKey);
// 仅删除失去货物和有效预订支持的站;保留其余站的原相对顺序与事实快照。
for(var stop:existing.subList(Math.min(1,existing.size()),existing.size()))
if(supported.contains(stop.destinationKey()) && keys.add(stop.destinationKey()))stops.add(stop);
for(var stop:requested.values()) if(keys.add(stop.destinationKey()))stops.add(new FleetTripModels.Stop(stop.name(),stop.address(),stop.destinationKey()));
// 不能为缺失的外接站编造名称/地址;异常承诺必须阻断整笔主单事务。
bookingReservations.requireRoute(trip,stops);
long version=trip.getRecordVersion();
trip.setStopsJson(write(stops)); trip.setPlannedLoadJson(null); trip.setLoadJson(null); trip.setLoadingReviewJson(null);
trip.setRecordVersion(trip.getRecordVersion()+1); trip.setUpdatedAt(now());
if (repository.updateTrip(trip,new LambdaUpdateWrapper<FleetTripEntity>().eq(FleetTripEntity::getId,trip.getId())) != 1
trip.setRecordVersion(Math.addExact(version,1)); trip.setUpdatedAt(now());
if (repository.updateTrip(trip,new LambdaUpdateWrapper<FleetTripEntity>().eq(FleetTripEntity::getId,trip.getId()).eq(FleetTripEntity::getRecordVersion,version)) != 1
|| repository.wholeQuantity(dispatchNo,packages) != 1) throw conflict("整车货物快照保存失败");
}
@@ -15,7 +15,7 @@ public interface VehicleFulfillmentMapper {
+ "AND JSON_CONTAINS(ho.snapshot_json,CONCAT('{\"customerId\":',JSON_QUOTE(d.customer_id),',\"deliveryNo\":',JSON_QUOTE(d.delivery_no),'}'),'$.carriedDeliveries')=1)";
String DEPARTED = "(" + ORIGINAL_DEPARTURE + " OR " + HANDOVER + ")";
String DELIVERIES = " FROM ll_delivery_order d JOIN ll_fleet_trip_dispatch b "
+ "ON b.dispatch_id=d.dispatch_order_id AND CAST(b.customer_id AS BINARY(128))=CAST(d.customer_id AS BINARY(128)) "
+ "ON b.dispatch_id=d.dispatch_order_id AND EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=b.dispatch_id AND CAST(i.customer_id AS BINARY(128))=CAST(d.customer_id AS BINARY(128))) "
+ "JOIN ll_fleet_trip t ON t.id=b.trip_id WHERE ((t.status='PLANNED' AND t.vehicle_id=#{vehicleId}) OR " + DEPARTED + ")";
String PREVIOUS_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(p.snapshot_json,'$.trip.vehicleId'))";
String NEXT_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))";