This commit is contained in:
2026-10-19 17:56:36 +08:00
parent bb917c954c
commit dcfba1053c
232 changed files with 15655 additions and 6005 deletions
@@ -61,7 +61,6 @@ public class FleetHandoverInspectionService {
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("客户排车归属或箱数与原始发车不一致");
}
@@ -69,7 +68,6 @@ public class FleetHandoverInspectionService {
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("实际发运与车次客户、仓库或箱数不一致");
@@ -43,7 +43,7 @@ public class FleetHandoverPdaQuery {
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())
if (!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"));
@@ -55,7 +55,7 @@ public class FleetHandoverPdaQuery {
var rows = evidence.deliveries().stream().map(row -> new Delivery(row.cargo().deliveryNo(), row.status(), row.cargo().packages(),
row.cargo().terminalAt() != null ? 0 : row.cargo().packages() - row.cargo().returnedCargo().stream().mapToInt(FleetHandoverPolicy.ReturnedCargo::packages).sum(),
row.cargo().terminalAt(), row.transport())).toList();
var latest = handovers.latestForCargo(trip.getId(), json.writeValueAsString(Map.of("customerId", binding.getCustomerId(), "dispatchNo", dispatchNo)));
var latest = handovers.latestForCargo(trip.getId(), json.writeValueAsString(Map.of("dispatchNo", dispatchNo)));
Handover handover = null;
if (latest != null) {
var snapshot = json.readValue(latest.snapshot(), FleetHandoverModels.Snapshot.class);
@@ -2,8 +2,8 @@ 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,
/** 整车发运事实;真实客户由每条Delivery.cargo携带。 */
public record FleetTransitEvidence(String dispatchNo, 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) { }
@@ -64,12 +64,12 @@ public class FleetBookingReservations {
public FleetBookingModels.AcceptedReceipt receipt(FleetBookingEntity row){return row.getReceiptJson()==null?null:read(row.getReceiptJson(),FleetBookingModels.AcceptedReceipt.class);}
private static boolean unbound(FleetBookingEntity row){return Set.of("CONFIRMED","RECEIVED").contains(row.getStatus());}
/** 按实际入仓来源绑定整票实收,合托不能掩盖外接承诺。 */
public void bindIncoming(FleetTripEntity trip,FleetDispatchGateway.Snapshot dispatch,List<FleetDispatchGateway.SourceCargo> sources,String operator){
public void bindIncoming(FleetTripEntity trip,FleetDispatchGateway.Snapshot dispatch,String customerId,List<FleetDispatchGateway.SourceCargo> sources,String operator){
var bySource=sources.stream().filter(row->row.inboundCode()!=null).collect(java.util.stream.Collectors.groupingBy(FleetDispatchGateway.SourceCargo::inboundCode));
for(var entry:bySource.entrySet()){
var row=new FleetBookingCargoRepository(bookings).forInboundSource(dispatch.customerId(),entry.getKey());if(row==null||"CANCELLED".equals(row.getStatus()))continue;
var row=new FleetBookingCargoRepository(bookings).forInboundSource(customerId,entry.getKey());if(row==null||"CANCELLED".equals(row.getStatus()))continue;
if(!Objects.equals(row.getTripId(),trip.getId()))throw conflict("此外接货已承诺给其他车次,请先调整原预订");
if(!Objects.equals(row.getCustomerId(),dispatch.customerId())||!"RECEIVED".equals(row.getStatus()))throw conflict("外接货须先确认实收货量及运费,再关联排车");
if(!Objects.equals(row.getCustomerId(),customerId)||!"RECEIVED".equals(row.getStatus()))throw conflict("外接货须先确认实收货量及运费,再关联排车");
var accepted=receipt(row);if(accepted==null)throw conflict("外接货缺少实收确认");
var expected=new TreeMap<String,Integer>();
for(var pallet:accepted.source().pallets())expected.merge(pallet.warehousePalletId(),pallet.packages(),Math::addExact);
@@ -38,6 +38,7 @@ public class FleetBookingService {
private final FleetQuoteCandidateService quotes;
@jakarta.annotation.Resource private FleetPoolOrderBridge poolOrders;
@jakarta.annotation.Resource private FleetDispatchPlanningGateway planning;
@jakarta.annotation.Resource private FleetDispatchGateway dispatches;
@jakarta.annotation.Resource private FleetCapacityOfferCommandMapper commands;
public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations,
FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming,
@@ -201,10 +202,11 @@ public class FleetBookingService {
planning.validateIncomingDispatch(request.dispatchNo(),row.getCustomerId(),evidence);
}
row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row);
var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(request.tripRecordVersion(),request.dispatchNo(),"关联外接预订 "+bookingNo));
bindToWhole(trip,row,request.dispatchNo(),operator);
history.recordExecution(trip,trip.getRecordVersion(),"BOOKING_BIND","关联外接预订 "+bookingNo,operator);
var result=booking(trip,bookingNo);
if(!"BOUND".equals(result.getStatus())||!Objects.equals(result.getDispatchNo(),request.dispatchNo().trim()))throw conflict("所选排车未包含此外接来货单的实际货物");
return new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(result),false);
return new FleetBookingModels.Result(trip.getRecordVersion(),reservations.response(result),false);
}
@Transactional
public FleetBookingModels.Result arrangeDispatch(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request) {
@@ -225,17 +227,27 @@ public class FleetBookingService {
var plan=execution.capacityPlan(tripNo);
if(!plan.planValid()||plan.blockReason()!=null)throw conflict("本车计划容量待核对,暂不能排入");
reservations.segments(trip,plan.capacityPlan(),row.getId(),row);
String dispatchNo=planning.resolveIncoming(row.getCustomerId(),evidence,operator);
var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(trip.getRecordVersion(),dispatchNo,"实收核对后排入本车 "+bookingNo));
String dispatchNo=planning.resolveIncoming(tripNo,row.getCustomerId(),evidence,operator,"FLEET_INCOMING:"+hash(java.util.List.of(bookingNo,request.idempotencyKey())));
trip=trip(tripNo,true);bindToWhole(trip,row,dispatchNo,operator);
history.recordExecution(trip,trip.getRecordVersion(),"BOOKING_ARRANGE","实收核对后排入本车 "+bookingNo,operator);
row=booking(trip,bookingNo);
if(!"BOUND".equals(row.getStatus())||!Objects.equals(row.getDispatchNo(),dispatchNo))throw conflict("未完整关联本票实收来源");
row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row);
var result=new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(row),false);
var result=new FleetBookingModels.Result(trip.getRecordVersion(),reservations.response(row),false);
var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(trip.getId());command.setOperatorId(operator);
command.setAction("BOOKING_ARRANGE");command.setScopeNo(bookingNo);command.setIdempotencyKey(request.idempotencyKey());command.setRequestHash(hash);
command.setResultJson(write(result));command.setCreatedAt(now());commands.insert(command);
return result;
}
/** 外接来源只能绑定该车已有的一对一主单,不能将另一整车挂到当前车次。 */
private void bindToWhole(FleetTripEntity trip,FleetBookingEntity booking,String number,String operator) {
var identity=history.dispatchIdentity(number);
if(identity.tripId()!=trip.getId())throw conflict("外接实收只能加入本车唯一排车主单");
var accepted=reservations.receipt(booking);if(accepted==null)throw conflict("此外接预订缺少实际收货依据");
planning.validateIncomingDispatch(number,booking.getCustomerId(),accepted.source());
var snapshot=dispatches.lockForPlanning(number);
reservations.bindIncoming(trip,snapshot,booking.getCustomerId(),dispatches.sourceCargo(number,booking.getCustomerId()),operator);
}
private static void requireEvidence(FleetTripEntity trip,FleetBookingEntity row,FleetBookingIncomingGateway.Evidence evidence){
if(evidence==null||evidence.incomingNo()==null||!Objects.equals(trip.getWarehouseId().toString(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey()))throw conflict("来货单仓库或目的地与外接承运约定不一致");
if(row.getIncomingNo()!=null&&!Objects.equals(row.getIncomingNo(),evidence.incomingNo()))throw conflict("来货单引用不一致");
@@ -9,17 +9,18 @@ import org.springframework.web.server.ResponseStatusException;
@Service
public class FleetDispatchBindingGuard {
private final FleetTripDispatchMapper bindings;
private final com.fasterxml.jackson.databind.ObjectMapper json;
public FleetDispatchBindingGuard(FleetTripDispatchMapper bindings, com.fasterxml.jackson.databind.ObjectMapper json) { this.bindings = bindings; this.json = json; }
private final FleetDispatchGateway dispatches;
public FleetDispatchBindingGuard(FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches) { this.bindings = bindings; this.dispatches = dispatches; }
/** Dispatch -> task -> binding locks; never acquire the parent trip lock from a PDA pick. */
@Transactional(propagation = Propagation.MANDATORY)
public void requirePickingApproved(long dispatchId) {
var binding = bindings.lockByDispatch(dispatchId);
if (binding == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "请先关联车次并通过调度审核");
var review = FleetTripReview.read(json, binding.getReviewJson());
if (!"APPROVED".equals(review.status())) throw new ResponseStatusException(HttpStatus.CONFLICT,
"REJECTED".equals(review.status()) ? "调度审核已打回:" + review.reason() : "请先通过调度审核再拣货");
var fact=dispatches.workflows(java.util.List.of(binding.getDispatchNo())).get(binding.getDispatchNo());
if(fact==null || !"ACTIVE".equals(fact.status()) || !"LOADING".equals(fact.stage())
|| !"APPROVED".equals(fact.dispatchReview().status()) || !"APPROVED".equals(fact.loadingReview().status()))
throw new ResponseStatusException(HttpStatus.CONFLICT,"请先完成本车两级审核再扫码");
}
@Transactional(propagation = Propagation.MANDATORY)
@@ -4,9 +4,14 @@ import java.time.Instant;
/** Customer dispatches remain owned by dispatch; fleet only orchestrates their physical trip. */
public interface FleetDispatchGateway {
record Snapshot(long dispatchId, String dispatchNo, String customerId, String warehouseId, int packageCount) { }
/** 审核唯一事实来自整车主单,Fleet只持有物理资源关联。 */
record Workflow(String status,String stage,long recordVersion,
FleetTripModels.ReviewState dispatchReview,FleetTripModels.ReviewState loadingReview) { }
java.util.Map<String,Workflow> workflows(java.util.List<String> dispatchNos);
record Snapshot(long dispatchId, String dispatchNo, String warehouseId, int packageCount) { }
record SourceCargo(String inboundCode,String warehousePalletId,String palletNo,int packages,String destinationKey) { }
java.util.List<SourceCargo> sourceCargo(String dispatchNo);
java.util.List<String> customers(String dispatchNo);
java.util.List<SourceCargo> sourceCargo(String dispatchNo,String customerId);
record Assignment(String vehiclePlate, String driverName, String driverPhone) { }
static String destinationKey(String type,String code,String fba,String address,String postalCode) {
@@ -19,23 +24,17 @@ public interface FleetDispatchGateway {
/** Locks the dispatch and rejects any picking or departure already performed. */
Snapshot lockForPlanning(String dispatchNo);
/** Allows pre-departure resource/time changes without changing cargo or recorded picking. */
Snapshot lockForAssignment(String dispatchNo);
/** Original dispatch command; caller must first detach the dispatch from its vehicle. */
void removeItem(String dispatchNo,long itemId);
/** Caller holds the dispatch lock for the entire transaction, including downstream snapshots. */
void assign(Snapshot dispatch, Assignment assignment);
FleetExecutionModels.Manifest manifest(String dispatchNo, boolean lock);
FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock);
record ReturnedReceipt(String itemId,String palletId,int packages,Instant receivedAt) { }
record TerminalDelivery(String customerId,String deliveryNo,String destinationKey,int packages,
Instant terminalAt,String status,long recordVersion,java.util.List<ReturnedReceipt> receipts) { }
record TerminalEvidence(String dispatchNo,String customerId,String warehouseId,int shippedPackages,
record TerminalEvidence(String dispatchNo,String warehouseId,int shippedPackages,
java.util.List<TerminalDelivery> deliveries) { }
/** Narrow execution facts for appointment eligibility, derived from the original verified evidence. */
default TerminalEvidence terminalEvidence(String dispatchNo,boolean lock) {
var evidence=transitEvidence(dispatchNo,lock);
return new TerminalEvidence(evidence.dispatchNo(),evidence.customerId(),evidence.warehouseId(),evidence.shippedPackages(),
return new TerminalEvidence(evidence.dispatchNo(),evidence.warehouseId(),evidence.shippedPackages(),
evidence.deliveries().stream().map(d->new TerminalDelivery(d.cargo().customerId(),d.cargo().deliveryNo(),
d.cargo().destinationKey(),d.cargo().packages(),d.cargo().terminalAt(),d.status(),d.recordVersion(),
d.cargo().returnedCargo().stream().map(r->new ReturnedReceipt(r.itemId(),r.palletId(),r.packages(),r.receivedAt())).toList())).toList());
@@ -44,6 +43,4 @@ public interface FleetDispatchGateway {
com.baiyue.lastleg.fleet.exception.FleetTransitEvidence transitEvidence(String dispatchNo, boolean lock);
/** Updates only unfinished delivery resource snapshots; departure and financial facts stay immutable. */
void handover(com.baiyue.lastleg.fleet.exception.FleetTransitEvidence evidence, Assignment assignment);
FleetExecutionModels.DepartedDispatch depart(String warehouseId, String dispatchNo, String operator,
String idempotencyKey, String remark, Instant departedAt);
}
@@ -4,8 +4,6 @@ import com.baiyue.common.core.api.PageResult;
/** Dispatch owns candidate eligibility and creation; fleet orchestrates the trip transaction. */
public interface FleetDispatchPlanningGateway {
record Candidate(String dispatchNo,String customerId,String warehouseId,int packageCount,String route) { }
PageResult<Candidate> candidates(String tripNo,String warehouseId,String keyword,int page,int size);
void validateIncomingDispatch(String dispatchNo,String customerId,FleetBookingIncomingGateway.Evidence receipt);
String resolveIncoming(String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator);
String resolveIncoming(String tripNo,String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator,String commandKey);
}
@@ -13,31 +13,28 @@ public class FleetDispatchWorkflowQuery {
public record Review(String status, String reason, java.time.Instant reviewedAt) { }
public record Workflow(String tripNo, Review dispatchReview, Review loadingReview) { }
private final FleetTripDispatchMapper bindings;
private final FleetTripMapper trips;
private final ObjectMapper json;
private final FleetDispatchGateway dispatches;
public FleetDispatchWorkflowQuery(FleetTripDispatchMapper bindings, FleetTripMapper trips, ObjectMapper json) {
this.bindings = bindings; this.trips = trips; this.json = json;
public FleetDispatchWorkflowQuery(FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches) {
this.bindings = bindings; this.dispatches = dispatches;
}
public Map<String, Workflow> read(List<String> dispatchNos) {
if (dispatchNos.isEmpty()) return Map.of();
var members = bindings.findByDispatchNos(dispatchNos);
if (members.isEmpty()) return Map.of();
var parents = trips.selectBatchIds(members.stream().map(FleetTripDispatch::getTripId).distinct().toList())
.stream().collect(Collectors.toMap(FleetTripEntity::getId, trip -> trip));
var facts=dispatches.workflows(members.stream().map(FleetTripDispatch::getDispatchNo).toList());
Map<String, Workflow> result = new HashMap<>();
for (var member : members) {
var trip = parents.get(member.getTripId());
if (trip == null) throw new IllegalStateException("关联车次不存在");
var fact = facts.get(member.getDispatchNo());
if (fact == null) throw new IllegalStateException("关联整车不存在");
result.put(member.getDispatchNo(), new Workflow(member.getTripNo(),
review(member.getReviewJson()), review(trip.getLoadingReviewJson())));
review(fact.dispatchReview()), review(fact.loadingReview())));
}
return result;
}
private Review review(String value) {
var state = FleetTripReview.read(json, value);
return new Review(state.status(), state.reason(), state.reviewedAt());
private Review review(FleetTripModels.ReviewState value) {
return new Review(value.status(), value.reason(), value.reviewedAt());
}
}
@@ -9,6 +9,21 @@ import java.util.List;
public final class FleetExecutionModels {
private FleetExecutionModels() { }
/** 整车主单自动建立资源记录的原生输入,不引用排车持久化实体。 */
public record DispatchVehicle(String mode, Long vehicleId, String plate, String carrierName,
Integer palletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm,
List<Long> evidenceFileIds) { }
public record DispatchDriver(String mode, Long driverId, String name, String phone, String licenseNo,
java.time.LocalDate licenseExpiresOn, List<Long> evidenceFileIds,
Instant expectedTaskEndAt) { }
public record DispatchShellRequest(long dispatchId, String dispatchNo, long warehouseId,
String idempotencyKey, String commandHash, Instant plannedDepartureAt,
DispatchVehicle vehicle, DispatchDriver driver, String remark) { }
public record DispatchShell(long tripId, String tripNo, String warehouseId, String warehouseCode,
String warehouseName, DispatchVehicle vehicle, DispatchDriver driver,
String vehicleStatus) { }
public record DispatchShellLock(long tripId, String tripNo, String warehouseId) { }
public record DispatchStop(String name, String address, String destinationKey) { }
public record Cargo(String dispatchNo, String customerId, String dispatchItemId, String warehousePalletId,
String palletNo, int packages, String destinationKey, String destinationLabel) { }
public record Manifest(String dispatchNo, String warehouseId, String vehiclePlate, String driverName, String driverPhone,
@@ -12,6 +12,9 @@ public interface FleetResourceMapper {
List<Assignment> candidates(@Param("kind") String kind, @Param("ids") List<Long> ids);
@Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id}")
Assignment find(@Param("kind") String kind, @Param("id") long id);
/** 草稿批量转换先锁齐资源占用身份,不能在第一个组来源之后再等下一组资源。 */
@Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id} FOR UPDATE")
Assignment lock(@Param("kind")String kind,@Param("id")long id);
@Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE trip_id=#{tripId} ORDER BY resource_kind,resource_id")
List<Assignment> byTrip(@Param("tripId") long tripId);
@Insert("INSERT INTO ll_fleet_resource_assignment(resource_kind,resource_id,trip_id,trip_no) VALUES(#{kind},#{id},#{tripId},#{tripNo})")
@@ -86,11 +86,11 @@ public class FleetTripCompletionService {
if (vehicle && (request.vehicleStatus() == null || !Set.of("IN_SERVICE", "MAINTENANCE", "INACTIVE", "RETURNED").contains(request.vehicleStatus())))
throw bad("请确认车辆结束任务后的实际状态");
if (!vehicle && request.vehicleStatus() != null) throw bad("结束司机任务不能修改车辆状态");
if (vehicle) {
if (vehicle && trip.getVehicleId()!=null) {
var current = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> conflict("车辆档案不存在"));
requireRelease("VEHICLE", trip.getVehicleId(), trip.getId());
vehicleService.changeStatus(trip.getVehicleId(), operator, new VehicleModels.ChangeStatus(request.vehicleStatus(), current.recordVersion()));
} else {
} else if(!vehicle && trip.getDriverId()!=null) {
// A now-expired or disabled driver may end their task; this never re-enables their qualification.
requireRelease("DRIVER", trip.getDriverId(), trip.getId());
}
@@ -12,12 +12,9 @@ public class FleetTripDispatch {
@TableId(type = IdType.INPUT)
private Long dispatchId;
private String dispatchNo;
private String customerId;
private Long tripId;
private String tripNo;
private Integer packageCount;
private String boundBy;
private LocalDateTime boundAt;
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
private String reviewJson;
}
@@ -6,6 +6,20 @@ import java.util.List;
@Mapper
public interface FleetTripDispatchMapper extends BaseMapper<FleetTripDispatch> {
/** 整车主单一对一关联,无客户头部或绑定级审核。 */
@Insert("INSERT INTO ll_fleet_trip_dispatch(dispatch_id,dispatch_no,trip_id,trip_no,package_count,bound_by,bound_at) "
+ "VALUES(#{dispatchId},#{dispatchNo},#{tripId},#{tripNo},0,#{operator},#{at})")
int insertWholeDispatch(@Param("dispatchId") long dispatchId, @Param("dispatchNo") String dispatchNo,
@Param("tripId") long tripId, @Param("tripNo") String tripNo,
@Param("operator") String operator, @Param("at") java.time.LocalDateTime at);
/** 只锁司机档案身份,历史命令恢复不以当前证照资格重新执行。 */
@Select("SELECT id FROM ll_fleet_driver WHERE id=#{id} FOR UPDATE")
Long lockDriverIdentity(@Param("id") long id);
/** 主单货量和唯一资源关系在父锁内同步,不按客户子单累加。 */
@Update("UPDATE ll_fleet_trip_dispatch SET package_count=#{packages} WHERE dispatch_no=#{number}")
int updateWholeQuantity(@Param("number") String dispatchNo, @Param("packages") int packages);
@Select("SELECT * FROM ll_fleet_trip_dispatch WHERE trip_id=#{tripId} ORDER BY dispatch_id")
List<FleetTripDispatch> listByTrip(@Param("tripId") long tripId);
@Select("SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_no=#{dispatchNo}")
@@ -41,6 +41,98 @@ public class FleetTripExecutionService {
this.history = history; this.drivers = drivers; this.vehicles = vehicles; this.json = json; this.bookingReservations = bookingReservations;
}
/** 对排车域暴露原生资源创建入口,不暴露Fleet实体和Mapper。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public FleetExecutionModels.DispatchShell createDispatchShell(String operator, FleetExecutionModels.DispatchShellRequest request) {
return history.createDispatchShell(operator, request);
}
/** 资源批量预锁不得在第一组来源锁之后才执行。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void lockDispatchCreationResources(List<FleetExecutionModels.DispatchVehicle> vehicles,List<FleetExecutionModels.DispatchDriver> drivers){history.lockDispatchCreationResources(vehicles,drivers);}
/** 建立主单后的唯一关系,不让前端执行旧绑定动作。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void linkDispatchShell(String operator,long dispatchId,String dispatchNo,String tripNo) {
history.linkDispatchShell(operator,dispatchId,dispatchNo,tripNo);
}
/** 保持排车域只依赖Fleet原生应用入口。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public FleetExecutionModels.DispatchShellLock lockDispatchShell(String dispatchNo) { return history.lockDispatchShell(dispatchNo); }
/** 全范围父级和资源预锁,调用方随后一次收齐全部dispatch。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void lockExistingDispatchShells(List<String> dispatchNos){history.lockExistingDispatchShells(dispatchNos);}
/** 只读返回唯一资源身份。 */
public FleetExecutionModels.DispatchShellLock dispatchIdentity(String no){return history.dispatchIdentity(no);}
/** 汇总仅取得资源父身份锁,不申请车辆司机主档锁或释放资源。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public FleetExecutionModels.DispatchShellLock lockDispatchCompletion(String no){return history.lockDispatchCompletion(no);}
/** 装车读数与真实站序共同展示。 */
public List<FleetExecutionModels.DispatchStop> dispatchStops(String no){return history.dispatchStops(no);}
/** 按排车父事务维护真实站序。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void reorderDispatchStops(String no,String operator,List<String> keys,String reason){history.reorderDispatchStops(no,operator,keys,reason);}
/** 主单取消与资源占用释放同事务协调。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void cancelDispatchShell(String no,String operator,String reason){history.cancelDispatchShell(no,operator,reason);}
/** 整车主流程已持父锁;容量沿原策略校验,保存实载不会扣库存或发车。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void confirmDispatchLoad(String no,String operator,String manifestHash,List<FleetExecutionModels.Cargo> cargo,
List<FleetExecutionModels.Measurement> measurements,String reason) {
var identity=history.dispatchIdentity(no);var trip=requireTrip(identity.tripNo(),true);
if(!"PLANNED".equals(trip.getStatus()))throw conflict("本车已开始运输");
var ownSegments=capacity(trip,cargo,measurements);
var plan=new FleetExecutionModels.PlannedLoad(manifestHash,cargo,measurements,ownSegments,operator,Instant.now());
var segments=bookingReservations.segments(trip,plan,null,null);
// 同时核对原有外接预订占用;不因新主流程绕过已确认容量。
var load=new FleetExecutionModels.ConfirmedLoad(manifestHash,cargo,measurements,segments,operator,Instant.now());
trip.setLoadJson(write(load));history.recordExecution(trip,trip.getRecordVersion(),"DISPATCH_CONFIRM_LOAD",reason,operator);
}
/** 已锁父级后复验已确认实载及原预约资格,不在此扣库存或锁客户单据。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void requireDispatchDeparture(String no,String manifestHash) {
var identity=history.dispatchIdentity(no);var trip=requireTrip(identity.tripNo(),true);
var load=load(trip);if(!"PLANNED".equals(trip.getStatus()) || load==null || !Objects.equals(load.manifestHash(),manifestHash))throw conflict("本车实载确认已变化,请重新核对");
if(trip.getVehicleId()!=null)requireResource("VEHICLE",trip.getVehicleId(),trip.getId());
if(trip.getDriverId()!=null)requireResource("DRIVER",trip.getDriverId(),trip.getId());
bookingReservations.segments(trip,new FleetExecutionModels.PlannedLoad(load.manifestHash(),load.cargo(),load.pallets(),load.segments(),load.operatorId(),load.confirmedAt()),null,null);
appointmentAllocations.requireDeparture(no);
}
/** 客户单据和来源均已核对后记录实际运输状态,资源占用保留到原任务结束入口。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void departDispatchShell(String no,String operator,Instant at,String reason) {
var identity=history.dispatchIdentity(no);var trip=requireTrip(identity.tripNo(),true);
if(!"PLANNED".equals(trip.getStatus()))throw conflict("本车资源任务已开始执行");
trip.setStatus("IN_TRANSIT");trip.setDepartedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));
history.recordExecution(trip,trip.getRecordVersion(),"DEPART",reason,operator);
}
/** 编辑先锁齐全部新旧身份,保持全局资源锁序。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public FleetExecutionModels.DispatchShellLock lockDispatchShell(String no,Long vehicle,Long driver) { return history.lockDispatchShell(no,vehicle,driver); }
/** 当前资格读取不借主单头客户或客户端主档显示字段。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public FleetExecutionModels.DispatchShell dispatchResources(String no,FleetExecutionModels.DispatchVehicle vehicle,FleetExecutionModels.DispatchDriver driver) {
return history.dispatchResources(no,vehicle,driver);
}
/** 同事务保存已核实的新资源与计划。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void saveDispatchResources(String no,String operator,FleetExecutionModels.DispatchShell actual,Instant plannedAt,String remark) {
history.saveDispatchResources(no,operator,actual,plannedAt,remark);
}
/** 父事务内更新一车货量与真实目的地。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void updateDispatchCargo(String dispatchNo, int packages, List<FleetExecutionModels.DispatchStop> stops) {
history.updateDispatchCargo(dispatchNo,packages,stops);
}
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public FleetExecutionModels.Planning capacityPlan(String tripNo) {
var trip = requireTrip(tripNo, false);
@@ -76,7 +168,6 @@ public class FleetTripExecutionService {
private String planningBlockReason(FleetTripEntity trip, List<FleetExecutionModels.Cargo> cargo) {
if (!"PLANNED".equals(trip.getStatus())) return "仅计划中的车次允许核对计划容量";
if (trip.getVehicleId() == null) return "请先分配车辆,明确可用托位、载重和容积";
String vehicleReason = currentVehicleBlockReason(trip, false);
if (vehicleReason != null) return vehicleReason;
if (cargo.isEmpty()) return "请先关联本车自有货排车单";
@@ -87,7 +178,10 @@ public class FleetTripExecutionService {
/** Caller holds the parent trip lock before requesting a vehicle write lock. */
public String currentVehicleBlockReason(FleetTripEntity trip, boolean lock) {
if (trip.getVehicleId() == null) return "请先分配车辆";
if (trip.getVehicleId() == null) return trip.getVehiclePlate()!=null && !trip.getVehiclePlate().isBlank()
&& trip.getMaxPalletSpaces()!=null && trip.getMaxPalletSpaces()>0
&& trip.getMaxLoadKg()!=null && trip.getMaxLoadKg().signum()>0
&& trip.getMaxVolumeCbm()!=null && trip.getMaxVolumeCbm().signum()>0?null:"请先确认本趟临时车辆资料和实际容量";
var vehicle = (lock ? vehicles.lockById(trip.getVehicleId()) : vehicles.findById(trip.getVehicleId())).orElse(null);
if (vehicle == null || !"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region()))
return "车辆当前不可用,请重新分配车辆并核对计划容量";
@@ -107,22 +201,7 @@ public class FleetTripExecutionService {
return new FleetExecutionModels.Loading(tripNo, trip.getRecordVersion(), cargo, hash(write(cargo)), load(trip), blockReason(trip, manifest));
}
@Transactional
public FleetExecutionModels.Loading confirmLoad(String tripNo, String operator, FleetExecutionModels.Confirm request) {
var trip = requireTrip(tripNo, true);
requireVersionAndPlan(trip, request.recordVersion());
var manifest = manifests(trip, true);
String block = blockReason(trip, manifest);
if (block != null) throw conflict(block);
var cargo = cargo(manifest); String fingerprint = hash(write(cargo));
if (!fingerprint.equals(request.manifestHash())) throw conflict("货物明细已变化,请刷新后重新核对装载");
var pallets = request.pallets().stream().sorted(Comparator.comparing(FleetExecutionModels.Measurement::warehousePalletId)).toList();
var segments = capacity(trip, cargo, pallets);
var confirmed = new FleetExecutionModels.ConfirmedLoad(fingerprint, cargo, pallets, segments, operator, Instant.now());
trip.setLoadJson(write(confirmed));
history.recordExecution(trip, request.recordVersion(), "CONFIRM_LOAD", request.reason(), operator);
return new FleetExecutionModels.Loading(tripNo, trip.getRecordVersion(), cargo, fingerprint, confirmed, null);
}
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public FleetExecutionModels.Gate gate(String warehouseId, String dispatchNo) {
@@ -170,83 +249,22 @@ public class FleetTripExecutionService {
}
private FleetExecutionModels.Gate pickingGate(FleetExecutionModels.Gate gate, FleetTripDispatch binding) {
if (!gate.canPick()) return gate;
var review = FleetTripReview.read(json, binding.getReviewJson());
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 review = FleetTripReview.read(json, trip.getLoadingReviewJson());
return "APPROVED".equals(review.status()) ? null : "REJECTED".equals(review.status()) ? "理货审核已打回:" + review.reason() : "请先通过理货审核再发车";
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:"请先通过本车理货审核再发车";
}
@Transactional
public FleetTripModels.Detail reviewLoading(String tripNo, String operator, FleetTripModels.Review request) {
var trip = requireTrip(tripNo, true);
requireVersionAndPlan(trip, request.recordVersion());
String reason = FleetTripReview.reason(request);
if ("APPROVED".equals(request.decision())) {
if (trip.getDriverId() == null) throw conflict("请先分配司机");
var driver = drivers.requireDispatchable(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));
String driverBlock = driverBlockReason(trip, driver);
if (driverBlock != null) throw conflict(driverBlock);
var manifest = manifests(trip, true);
String block = blockReason(trip, manifest);
if (block == null) block = loadBlockReason(trip, cargo(manifest));
if (block != null) throw conflict(block);
}
trip.setLoadingReviewJson(write(new FleetTripModels.ReviewState(request.decision(), reason, operator, Instant.now(), request.recordVersion())));
history.recordExecution(trip, request.recordVersion(), "LOADING_REVIEW", reason, operator);
return history.detail(tripNo);
}
@Transactional
public FleetExecutionModels.Departed depart(String warehouseId, String dispatchNo, String operator, FleetExecutionModels.Depart request) {
var binding = bindings.findByDispatchNo(dispatchNo);
if (binding == null) throw conflict("请先在平台将排车单关联车次");
var trip = requireTrip(binding.getTripNo(), true);
requireWarehouse(trip, warehouseId);
// The binding may have changed while waiting for the trip lock. Never ship a stale parent.
var members = bindings.listByTrip(trip.getId());
if (members.stream().noneMatch(member -> member.getDispatchNo().equals(dispatchNo))) throw conflict("排车单所属车次已变化,请刷新");
String requestHash = hash(write(request));
if (trip.getDepartureKey() != null) {
if (!trip.getDepartureKey().equals(request.idempotencyKey()) || !trip.getDepartureHash().equals(requestHash))
throw conflict("车次已发车,发车请求标识或内容不一致");
return read(trip.getDepartureResultJson(), FleetExecutionModels.Departed.class).withReplay();
}
requireVersionAndPlan(trip, request.recordVersion());
// Resource lock order matches trip assignment: trip -> vehicle -> driver -> customer dispatches.
if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机");
var vehicle = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> conflict("车辆档案不存在"));
if (!"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region())
|| !Objects.equals(vehicle.plateNumber(), trip.getVehiclePlate()) || !Objects.equals(vehicle.maxPalletSpaces(), trip.getMaxPalletSpaces())
|| !equalNumber(vehicle.maxLoadKg(), trip.getMaxLoadKg()) || !equalNumber(vehicle.maxVolumeCbm(), trip.getMaxVolumeCbm()))
throw conflict("车辆状态或容量已变化,请重新确认车次");
requireResource("VEHICLE", trip.getVehicleId(), trip.getId()); requireResource("DRIVER", trip.getDriverId(), trip.getId());
var driver = drivers.requireDispatchable(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));
String driverBlock = driverBlockReason(trip, driver);
if (driverBlock != null) throw conflict(driverBlock);
var manifest = manifests(trip, true);
String reason = blockReason(trip, manifest);
if (reason == null) reason = loadBlockReason(trip, cargo(manifest));
if (reason == null) reason = loadingReviewBlockReason(trip);
if (reason != null) throw conflict(reason);
// Store the same precision in DATETIME(3), the immutable DEPART snapshot and replay result.
for(var member:members) appointmentAllocations.requireDeparture(member.getDispatchNo());
Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS);
trip.setStatus("DEPARTING");
if (trips.updateById(trip) != 1) throw conflict("车次状态已变化");
var results = new ArrayList<FleetExecutionModels.DepartedDispatch>();
for (var member : members) results.add(dispatches.depart(warehouseId, member.getDispatchNo(), operator,
trip.getTripNo() + ":" + member.getDispatchNo(), request.remark(), now));
trip.setStatus("IN_TRANSIT"); trip.setDepartedAt(LocalDateTime.ofInstant(now, ZoneOffset.UTC));
var result = new FleetExecutionModels.Departed(trip.getTripNo(), "IN_TRANSIT", now,
results.stream().mapToLong(FleetExecutionModels.DepartedDispatch::shippedPackages).sum(), List.copyOf(results), false);
trip.setDepartureKey(request.idempotencyKey()); trip.setDepartureHash(requestHash); trip.setDepartureResultJson(write(result));
history.recordExecution(trip, request.recordVersion(), "DEPART", request.remark(), operator);
return result;
}
private List<FleetExecutionModels.Manifest> manifests(FleetTripEntity trip, boolean lock) {
return bindings.listByTrip(trip.getId()).stream().map(binding -> dispatches.manifest(binding.getDispatchNo(), lock)).toList();
@@ -261,7 +279,8 @@ public class FleetTripExecutionService {
if (manifests.isEmpty()) return "车次尚未关联排车单";
if (bookingReservations.hasConfirmed(trip.getId())) return "已确认外接预订尚未关联实际发运货物,请先完成来货与排车关联";
for (var binding : bindings.listByTrip(trip.getId())) {
if (!FleetTripReview.approved(json, binding.getReviewJson())) return "排车单 " + binding.getDispatchNo() + " 尚未通过调度审核";
var fact=dispatches.workflows(List.of(binding.getDispatchNo())).get(binding.getDispatchNo());
if (fact==null || !"APPROVED".equals(fact.dispatchReview().status())) return "排车单 " + binding.getDispatchNo() + " 尚未通过调度审核";
}
for (var manifest : manifests) {
if (!trip.getWarehouseId().toString().equals(manifest.warehouseId())) return "排车单起运仓与车次不一致";
@@ -36,10 +36,10 @@ public final class FleetTripModels {
public record ReviewState(String status, String reason, String reviewerId, Instant reviewedAt, Long recordVersion) {
public static ReviewState pending() { return new ReviewState("PENDING", null, null, null, null); }
}
public record DispatchBinding(String dispatchNo, String customerId, int packageCount, String boundBy, Instant boundAt, ReviewState review,
public record DispatchBinding(String dispatchNo, int packageCount, String boundBy, Instant boundAt, ReviewState review,
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference> appointmentBatches) { }
/** Original bound references only; these quantities never authorize current allocation. */
public record PreviousDispatch(String dispatchNo,String customerId,long recordVersion,Instant recordedAt,
public record PreviousDispatch(String dispatchNo,long recordVersion,Instant recordedAt,
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference> appointmentBatches) { }
public record Response(String tripNo, String status, String warehouseId, String warehouseCode, String warehouseName,
String vehicleId, String vehiclePlate, String driverId, String driverName, String driverPhone,
@@ -1,81 +0,0 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.*;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
/** Shared by dispatch and appointment consumers; all business writes commit together. */
@Service
public class FleetTripPreparationService {
public record Prepare(@Size(max=64) String tripNo,@Min(0) Long recordVersion,
@Valid FleetTripModels.Create plan,@Positive Long vehicleId,@Positive Long driverId,
@NotEmpty @Size(max=100) List<@NotBlank @Size(max=64) String> dispatchNos,
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
private final FleetTripService service;
private final FleetTripMapper trips;
private final FleetCapacityOfferCommandMapper commands;
private final ObjectMapper json;
public FleetTripPreparationService(FleetTripService service,FleetTripMapper trips,FleetCapacityOfferCommandMapper commands,ObjectMapper json) {
this.service=service;this.trips=trips;this.commands=commands;this.json=json;
}
@Transactional
public FleetTripModels.Detail prepare(String operator,Prepare request) {
boolean create=request.tripNo()==null||request.tripNo().isBlank();
if(create) { permission("platform:fleet:trip:create");permission("platform:fleet:trip:assign"); }
permission("platform:fleet:trip:edit");
if(new HashSet<>(request.dispatchNos()).size()!=request.dispatchNos().size())throw conflict("排车单不能重复选择");
if(create && (request.plan()==null||request.vehicleId()==null||request.driverId()==null))throw conflict("新建车次请填写计划、车辆和司机");
if(!create && (request.plan()!=null||request.vehicleId()!=null||request.driverId()!=null||request.recordVersion()==null))throw conflict("加入已有车次请提交车次版本,不重复修改资源或计划");
var initial=create?trips.findCreated(operator,request.idempotencyKey()):trips.findByNo(request.tripNo());
if(!create&&initial==null)throw conflict("所选车次不存在");
String payload=write(request);
if(initial!=null) {
var locked=trips.lockByNo(initial.getTripNo());
var previous=commands.find(locked.getId(),operator,"PREPARE_TRIP",locked.getTripNo(),request.idempotencyKey());
if(previous!=null) {
if(!Objects.equals(previous.getRequestHash(),hash(payload)))throw conflict("同一安排请求不能更换内容");
try{return json.readValue(previous.getResultJson(),FleetTripModels.Detail.class);}catch(Exception e){throw new IllegalStateException(e);}
}
if(create)throw conflict("该新建请求已被其他操作使用,请核对原车次");
}
FleetTripModels.Detail detail;
if(create) {
var p=request.plan();
detail=service.create(operator,new FleetTripModels.Create(p.warehouseId(),p.plannedDepartureAt(),p.expectedDriverEndAt(),p.stops(),p.remark(),request.idempotencyKey()));
// Concurrent same-key create may have committed while waiting for its unique constraint.
var locked=trips.lockByNo(detail.trip().tripNo());
var previous=commands.find(locked.getId(),operator,"PREPARE_TRIP",locked.getTripNo(),request.idempotencyKey());
if(previous!=null) {
if(!Objects.equals(previous.getRequestHash(),hash(payload)))throw conflict("同一安排请求不能更换内容");
try{return json.readValue(previous.getResultJson(),FleetTripModels.Detail.class);}catch(Exception e){throw new IllegalStateException(e);}
}
detail=service.assign(detail.trip().tripNo(),operator,new FleetTripModels.Assign(detail.trip().recordVersion(),request.vehicleId(),request.driverId(),"安排车次时分配资源"));
} else {
detail=service.detail(request.tripNo());
if(detail.trip().recordVersion()!=request.recordVersion())throw conflict("车次已变化,请重新核对");
}
for(String number:request.dispatchNos().stream().sorted().toList())
detail=service.bindDispatch(detail.trip().tripNo(),operator,new FleetTripModels.BindingCommand(detail.trip().recordVersion(),number,"勾选待运货物安排车次"));
var row=trips.findByNo(detail.trip().tripNo());
var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(row.getId());command.setOperatorId(operator);
command.setAction("PREPARE_TRIP");command.setScopeNo(row.getTripNo());command.setIdempotencyKey(request.idempotencyKey());command.setRequestHash(hash(payload));
command.setResultJson(write(detail));command.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));commands.insert(command);
return detail;
}
private static void permission(String name) {
var auth=SecurityContextHolder.getContext().getAuthentication();
if(auth==null||auth.getAuthorities().stream().noneMatch(a->name.equals(a.getAuthority())))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少安排车次所需权限");
}
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}}
private static String hash(String value){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}}
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -24,6 +24,16 @@ public class FleetTripRepository {
public FleetTripEntity lockCreated(String operator,String key) { return trips.lockCreated(operator,key); }
/** 新增车次记录。 */
public int insertTrip(FleetTripEntity row) { return trips.insert(row); }
/** 保存整车的一对一资源关系,不伪造客户或子排车。 */
public int insertWholeDispatch(long dispatchId, String dispatchNo, FleetTripEntity trip, String operator) {
return bindings.insertWholeDispatch(dispatchId, dispatchNo, trip.getId(), trip.getTripNo(), operator, trip.getCreatedAt());
}
/** 普通读定位唯一整车关系,父锁后必须重新验证。 */
public FleetTripDispatch wholeBinding(String number) { return bindings.findByDispatchNo(number); }
/** 只锁司机身份,不因资格过期阻断原命令恢复。 */
public Long lockDriverIdentity(long id) { return bindings.lockDriverIdentity(id); }
/** 同步整车资源快照货量。 */
public int wholeQuantity(String number, int packages) { return bindings.updateWholeQuantity(number,packages); }
/** 按业务号查询车次。 */
public FleetTripEntity findTrip(String number) { return trips.findByNo(number); }
/** 按业务号加锁读取车次。 */
@@ -50,6 +60,8 @@ public class FleetTripRepository {
public int insertHistory(FleetTripHistory row) { return histories.insert(row); }
/** 查询资源实时占用。 */
public FleetResourceMapper.Assignment resource(String kind,long id) { return resources.find(kind,id); }
/** 批量正式建车的资源占用在取来源锁之前统一锁齐。 */
public FleetResourceMapper.Assignment lockResource(String kind,long id){return resources.lock(kind,id);}
/** 批量读取候选资源占用,避免列表逐项查询。 */
public List<FleetResourceMapper.Assignment> resourceOccupancy(String kind,List<Long> ids) { return resources.candidates(kind,ids); }
/** 唯一键防止车辆或司机同时承接两趟任务。 */
@@ -93,11 +93,7 @@ public class FleetTripService {
}).toList();
}
/** 创建派送用途车次,保留原有请求幂等语义。 */
@Transactional
public FleetTripModels.Detail create(String operator, FleetTripModels.Create request) {
return createPlan(operator, request, "DELIVERY");
}
/** 取货车次首次历史即标识取货用途,复用统一的资源和计划规则。 */
@Transactional
@@ -105,6 +101,264 @@ public class FleetTripService {
return createPlan(operator, request, "PICKUP");
}
/** 主单创建事务内建立空资源记录,允许未知计划/临时资料,审核时再核齐。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public FleetExecutionModels.DispatchShell createDispatchShell(String operator, FleetExecutionModels.DispatchShellRequest request) {
var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS");
var trip = new FleetTripEntity();
trip.setId(IdWorker.getId());
trip.setTripNo("TR-" + new java.math.BigInteger(UUID.randomUUID().toString().replace("-", ""), 16).toString(36).toUpperCase(Locale.ROOT));
trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(request.commandHash());
trip.setStatus("PLANNED"); trip.setTripPurpose("DELIVERY"); trip.setWarehouseId(request.warehouseId());
trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name());
trip.setPlannedDepartureAt(request.plannedDepartureAt() == null ? null : utc(request.plannedDepartureAt()));
trip.setStopsJson("[]"); trip.setRemark(request.remark()); trip.setRecordVersion(0L);
trip.setCreatedBy(operator); trip.setUpdatedBy(operator); trip.setCreatedAt(now()); trip.setUpdatedAt(trip.getCreatedAt());
if (repository.insertTrip(trip) != 1) throw conflict("整车资源记录创建失败");
// 创建记录首先取得本trip写锁,随后只按车辆→司机/资源的顺序取锁。
var vehicle = request.vehicle();
String vehicleStatus = null;
if (vehicle != null && "MASTER".equals(vehicle.mode())) {
if (vehicle.vehicleId() == null) throw bad("请选择车辆档案");
var actual = vehicles.lockById(vehicle.vehicleId()).orElseThrow(() -> bad("车辆档案不存在"));
if (!"OVERSEAS".equals(actual.region()) || !"IN_SERVICE".equals(actual.status())) throw bad("请选择在役海外车辆");
vehicle = new FleetExecutionModels.DispatchVehicle("MASTER", actual.id(), actual.plateNumber(),
actual.driverOrDispatchCompany(), actual.maxPalletSpaces(), actual.maxLoadKg(), actual.maxVolumeCbm(), List.of());
vehicleStatus = actual.status();
} else if (vehicle != null && (!"EXTERNAL".equals(vehicle.mode()) || vehicle.vehicleId() != null)) {
throw bad("临时车辆不得关联或伪造车辆档案");
}
var driver = request.driver();
if (driver != null && "MASTER".equals(driver.mode())) {
if (driver.driverId() == null) throw bad("请选择司机档案");
var actual = drivers.requireDispatchable(driver.driverId(), driver.expectedTaskEndAt());
if (!"OVERSEAS".equals(actual.details().region())) throw bad("请选择海外司机");
driver = new FleetExecutionModels.DispatchDriver("MASTER", Long.valueOf(actual.id()), actual.details().name(),
actual.details().phone(), actual.details().licenseNumber(), actual.details().licenseExpiresOn(),
actual.licenseFileId() == null ? List.of() : List.of(Long.valueOf(actual.licenseFileId())), driver.expectedTaskEndAt());
} else if (driver != null && (!"EXTERNAL".equals(driver.mode()) || driver.driverId() != null)) {
throw bad("临时司机不得关联或伪造司机档案");
}
if (vehicle != null) {
if (vehicle.vehicleId() != null) reserve("VEHICLE", vehicle.vehicleId(), trip);
trip.setVehicleId(vehicle.vehicleId()); trip.setVehiclePlate(vehicle.plate());
trip.setMaxPalletSpaces(vehicle.palletSpaces()); trip.setMaxLoadKg(vehicle.maxLoadKg()); trip.setMaxVolumeCbm(vehicle.maxVolumeCbm());
}
if (driver != null) {
if (driver.driverId() != null) reserve("DRIVER", driver.driverId(), trip);
trip.setDriverId(driver.driverId()); trip.setDriverName(driver.name()); trip.setDriverPhone(driver.phone());
trip.setExpectedDriverEndAt(driver.expectedTaskEndAt() == null ? null : utc(driver.expectedTaskEndAt()));
}
if (repository.updateTrip(trip, new LambdaUpdateWrapper<FleetTripEntity>().eq(FleetTripEntity::getId, trip.getId())
.eq(FleetTripEntity::getRecordVersion, 0L)) != 1)
throw conflict("整车资源关联保存失败");
return new FleetExecutionModels.DispatchShell(trip.getId(), trip.getTripNo(), warehouse.id(), warehouse.code(),
warehouse.name(), vehicle, driver, vehicleStatus);
}
/** 批量草稿转换先统一锁车辆、司机和占用,后续新trip为事务内新身份且不可被其他会话引用。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void lockDispatchCreationResources(List<FleetExecutionModels.DispatchVehicle> requestedVehicles,List<FleetExecutionModels.DispatchDriver> requestedDrivers) {
var vehicleIds=new TreeSet<Long>();var driverIds=new TreeSet<Long>();
for(var vehicle:requestedVehicles)if(vehicle!=null && "MASTER".equals(vehicle.mode())) {
if(vehicle.vehicleId()==null || vehicle.vehicleId()<=0)throw bad("请选择真实车辆档案");vehicleIds.add(vehicle.vehicleId());
}
for(var driver:requestedDrivers)if(driver!=null && "MASTER".equals(driver.mode())) {
if(driver.driverId()==null || driver.driverId()<=0)throw bad("请选择真实司机档案");driverIds.add(driver.driverId());
}
for(var id:vehicleIds)if(vehicles.lockById(id).isEmpty())throw bad("车辆档案不存在");
for(var id:driverIds)if(repository.lockDriverIdentity(id)==null)throw bad("司机档案不存在");
for(var id:vehicleIds)repository.lockResource("VEHICLE",id);
for(var id:driverIds)repository.lockResource("DRIVER",id);
}
/** 主单插入后再建立关系,允许数据库同时约束trip和dispatch两个外键。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void linkDispatchShell(String operator,long dispatchId,String dispatchNo,String tripNo) {
var trip=repository.lockTrip(tripNo);
if(trip==null || !"DELIVERY".equals(trip.getTripPurpose()) || !repository.listBindings(trip.getId()).isEmpty())
throw conflict("整车资源已关联其他主单");
if(repository.insertWholeDispatch(dispatchId,dispatchNo,trip,operator)!=1) throw conflict("整车唯一资源关系未保存");
}
/** 新主流程先锁trip、车辆、司机身份,再由调用方锁dispatch。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public FleetExecutionModels.DispatchShellLock lockDispatchShell(String dispatchNo) {
return lockDispatchShell(dispatchNo,null,null);
}
/** 原预约跨车改量先收齐所有trip,再收齐全部车辆、司机和占用,尚不锁dispatch。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void lockExistingDispatchShells(List<String> numbers) {
if(numbers==null || numbers.isEmpty() || numbers.size()>1000 || numbers.stream().anyMatch(n->n==null || n.isBlank()) || new HashSet<>(numbers).size()!=numbers.size())throw bad("请选择完整且不重复的原排车范围");
var observed=new TreeMap<Long,FleetTripDispatch>();
for(var number:numbers) {
var binding=repository.wholeBinding(number);
if(binding==null || observed.putIfAbsent(binding.getTripId(),binding)!=null)throw conflict("原排车资源范围缺失或非一对一关联");
}
var locked=new ArrayList<FleetTripEntity>();
for(var entry:observed.entrySet()) {
var trip=repository.lockTrip(entry.getValue().getTripNo());
if(trip==null || !entry.getKey().equals(trip.getId()))throw conflict("原排车资源范围已变化");
locked.add(trip);
}
for(var trip:locked) {
var bindings=repository.listBindings(trip.getId());var original=observed.get(trip.getId());
if(bindings.size()!=1 || !Objects.equals(bindings.get(0).getDispatchId(),original.getDispatchId()) || !Objects.equals(bindings.get(0).getDispatchNo(),original.getDispatchNo()))throw conflict("原排车一对一关联已变化,请重读完整范围");
}
var vehicleIds=new TreeSet<Long>();var driverIds=new TreeSet<Long>();
for(var trip:locked){if(trip.getVehicleId()!=null)vehicleIds.add(trip.getVehicleId());if(trip.getDriverId()!=null)driverIds.add(trip.getDriverId());}
for(var id:vehicleIds)vehicles.lockById(id);
for(var id:driverIds)repository.lockDriverIdentity(id);
for(var id:vehicleIds)repository.lockResource("VEHICLE",id);
for(var id:driverIds)repository.lockResource("DRIVER",id);
}
/** 只读定位本车资源身份,不取得写资格。 */
@Transactional(readOnly=true)
public FleetExecutionModels.DispatchShellLock dispatchIdentity(String dispatchNo) {
var binding=repository.wholeBinding(dispatchNo);if(binding==null)throw conflict("整车资源关系缺失");
var trip=repository.findTrip(binding.getTripNo());if(trip==null)throw conflict("整车资源记录缺失");
return new FleetExecutionModels.DispatchShellLock(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString());
}
/** 签收汇总只锁资源父记录身份,不变更或释放车辆司机,不申请其主档锁。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public FleetExecutionModels.DispatchShellLock lockDispatchCompletion(String no) {
var observed=repository.wholeBinding(no);if(observed==null)throw conflict("整车资源关系缺失");
var trip=repository.lockTrip(observed.getTripNo());if(trip==null)throw conflict("整车资源记录缺失");
var bindings=repository.listBindings(trip.getId());if(bindings.size()!=1 || !no.equals(bindings.get(0).getDispatchNo()))throw conflict("整车资源关系已变化");
return new FleetExecutionModels.DispatchShellLock(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString());
}
/** 实载确认沿现有站序核对卸货位置,不能由客户端自由编造站点。 */
@Transactional(readOnly=true)
public List<FleetExecutionModels.DispatchStop> dispatchStops(String no) {
var identity=dispatchIdentity(no);var trip=repository.findTrip(identity.tripNo());
return read(trip.getStopsJson(),new TypeReference<List<FleetTripModels.Stop>>(){}).stream()
.map(stop->new FleetExecutionModels.DispatchStop(stop.name(),stop.address(),stop.destinationKey())).toList();
}
/** 仅重排现有真实目的地,保留起点和外接预订;不接受客户端伪造名称或地址。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void reorderDispatchStops(String no,String operator,List<String> destinationKeys,String reason) {
var identity=dispatchIdentity(no);var trip=repository.lockTrip(identity.tripNo());
if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅未发车的路线可调整");
var previous=read(trip.getStopsJson(),new TypeReference<List<FleetTripModels.Stop>>(){});
if(previous.isEmpty())throw conflict("本车缺少真实起运仓");
var byKey=new LinkedHashMap<String,FleetTripModels.Stop>();
for(int index=1;index<previous.size();index++) {
var stop=previous.get(index);if(stop.destinationKey()==null || byKey.putIfAbsent(stop.destinationKey(),stop)!=null)throw conflict("本车目的地事实不完整");
}
if(destinationKeys==null || destinationKeys.size()!=byKey.size() || destinationKeys.size()>199
|| destinationKeys.stream().anyMatch(Objects::isNull) || new HashSet<>(destinationKeys).size()!=destinationKeys.size()
|| !new HashSet<>(destinationKeys).equals(byKey.keySet()))throw bad("请完整重排本车既有目的地,保留外接卸货点");
var stops=new ArrayList<FleetTripModels.Stop>();stops.add(previous.get(0));destinationKeys.forEach(key->stops.add(byKey.get(key)));
bookingReservations.requireRoute(trip,stops);long version=trip.getRecordVersion();
if(repository.updateTrip(null,new LambdaUpdateWrapper<FleetTripEntity>().eq(FleetTripEntity::getId,trip.getId()).eq(FleetTripEntity::getRecordVersion,version)
.set(FleetTripEntity::getStopsJson,write(stops)).set(FleetTripEntity::getPlannedLoadJson,null).set(FleetTripEntity::getLoadJson,null)
.set(FleetTripEntity::getLoadingReviewJson,null).set(FleetTripEntity::getRecordVersion,version+1).set(FleetTripEntity::getUpdatedBy,operator).set(FleetTripEntity::getUpdatedAt,now()))!=1)throw conflict("资源路线版本已变化");
record(repository.findTrip(identity.tripNo()),"DISPATCH_ROUTE",reason,operator);
}
/** 主单零扫码取消时协调本车资源,外接已确认业务沿原取消前置保留。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void cancelDispatchShell(String no,String operator,String reason) {
var identity=dispatchIdentity(no);var trip=repository.lockTrip(identity.tripNo());
if(!"PLANNED".equals(trip.getStatus()))throw conflict("资源任务已开始执行,不能取消");
if(bookingReservations.hasConfirmed(trip.getId()))throw conflict("请先取消已确认的外接预订,再取消排车");
if(trip.getVehicleId()!=null)release("VEHICLE",trip.getVehicleId(),trip.getId());
if(trip.getDriverId()!=null)release("DRIVER",trip.getDriverId(),trip.getId());
trip.setStatus("CANCELLED");persist(trip,trip.getRecordVersion(),"DISPATCH_CANCEL",reason,operator);
}
/** 编辑资源时先锁齐旧新车辆,再锁齐旧新司机,禁止持司机锁回头抢新车辆。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public FleetExecutionModels.DispatchShellLock lockDispatchShell(String dispatchNo,Long vehicleId,Long driverId) {
var observed = repository.wholeBinding(dispatchNo);
if (observed == null) throw conflict("整车资源关联缺失,请核对原排车单");
var trip = repository.lockTrip(observed.getTripNo());
if (trip == null) throw conflict("整车资源记录缺失");
var bindings = repository.listBindings(trip.getId());
if (bindings.size() != 1 || !dispatchNo.equals(bindings.get(0).getDispatchNo())) throw conflict("整车资源关联已变化");
var vehicleIds=new TreeSet<Long>(); if(trip.getVehicleId()!=null)vehicleIds.add(trip.getVehicleId());if(vehicleId!=null)vehicleIds.add(vehicleId);
for(var id:vehicleIds) vehicles.lockById(id);
var driverIds=new TreeSet<Long>();if(trip.getDriverId()!=null)driverIds.add(trip.getDriverId());if(driverId!=null)driverIds.add(driverId);
for(var id:driverIds) repository.lockDriverIdentity(id);
for(var id:vehicleIds)repository.lockResource("VEHICLE",id);
for(var id:driverIds)repository.lockResource("DRIVER",id);
return new FleetExecutionModels.DispatchShellLock(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString());
}
/** 已持父级全部身份锁后,复验资源实际资格;快照不信任请求中的主档名称和容量。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public FleetExecutionModels.DispatchShell dispatchResources(String dispatchNo,FleetExecutionModels.DispatchVehicle vehicle,
FleetExecutionModels.DispatchDriver driver) {
var binding=repository.wholeBinding(dispatchNo);if(binding==null)throw conflict("整车资源关系缺失");
var trip=repository.lockTrip(binding.getTripNo());if(trip==null)throw conflict("整车资源记录缺失");
String vehicleStatus=null;
if(vehicle!=null && "MASTER".equals(vehicle.mode())) {
if(vehicle.vehicleId()==null)throw bad("请选择车辆档案");
var actual=vehicles.findById(vehicle.vehicleId()).orElseThrow(()->bad("车辆档案不存在"));
if(!"OVERSEAS".equals(actual.region()) || !"IN_SERVICE".equals(actual.status()))throw conflict("车辆当前不可执行海外任务");
var assignment=repository.resource("VEHICLE",actual.id());
if(assignment!=null && assignment.tripId()!=trip.getId())throw conflict("车辆已被其他车次占用");
vehicle=new FleetExecutionModels.DispatchVehicle("MASTER",actual.id(),actual.plateNumber(),actual.driverOrDispatchCompany(),
actual.maxPalletSpaces(),actual.maxLoadKg(),actual.maxVolumeCbm(),List.of());vehicleStatus=actual.status();
} else if(vehicle!=null && (!"EXTERNAL".equals(vehicle.mode()) || vehicle.vehicleId()!=null)) throw bad("临时车辆不得关联车辆档案");
if(driver!=null && "MASTER".equals(driver.mode())) {
if(driver.driverId()==null)throw bad("请选择司机档案");
var actual=drivers.requireDispatchable(driver.driverId(),driver.expectedTaskEndAt());
if(!"OVERSEAS".equals(actual.details().region()))throw conflict("司机当前不可执行海外任务");
var assignment=repository.resource("DRIVER",driver.driverId());
if(assignment!=null && assignment.tripId()!=trip.getId())throw conflict("司机已被其他车次占用");
driver=new FleetExecutionModels.DispatchDriver("MASTER",Long.valueOf(actual.id()),actual.details().name(),actual.details().phone(),
actual.details().licenseNumber(),actual.details().licenseExpiresOn(),actual.licenseFileId()==null?List.of():List.of(Long.valueOf(actual.licenseFileId())),driver.expectedTaskEndAt());
} else if(driver!=null && (!"EXTERNAL".equals(driver.mode()) || driver.driverId()!=null))throw bad("临时司机不得关联司机档案");
return new FleetExecutionModels.DispatchShell(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString(),trip.getWarehouseCode(),trip.getWarehouseName(),vehicle,driver,vehicleStatus);
}
/** 已核对来源的更新原子替换资源占用,保留本趟附加能力和旧资源历史。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void saveDispatchResources(String dispatchNo,String operator,FleetExecutionModels.DispatchShell actual,Instant plannedAt,String remark) {
var trip=repository.lockTrip(actual.tripNo());if(trip==null || !"PLANNED".equals(trip.getStatus()))throw conflict("本车资源任务已开始执行");
var v=actual.vehicle();var d=actual.driver();Long vehicleId=v==null?null:v.vehicleId(),driverId=d==null?null:d.driverId();
if(trip.getVehicleId()!=null && !Objects.equals(trip.getVehicleId(),vehicleId))release("VEHICLE",trip.getVehicleId(),trip.getId());
if(trip.getDriverId()!=null && !Objects.equals(trip.getDriverId(),driverId))release("DRIVER",trip.getDriverId(),trip.getId());
if(vehicleId!=null)reserve("VEHICLE",vehicleId,trip);if(driverId!=null)reserve("DRIVER",driverId,trip);
long version=trip.getRecordVersion();
var update=new LambdaUpdateWrapper<FleetTripEntity>().eq(FleetTripEntity::getId,trip.getId()).eq(FleetTripEntity::getRecordVersion,version)
.set(FleetTripEntity::getVehicleId,vehicleId).set(FleetTripEntity::getVehiclePlate,v==null?null:v.plate())
.set(FleetTripEntity::getMaxPalletSpaces,v==null?null:v.palletSpaces()).set(FleetTripEntity::getMaxLoadKg,v==null?null:v.maxLoadKg())
.set(FleetTripEntity::getMaxVolumeCbm,v==null?null:v.maxVolumeCbm()).set(FleetTripEntity::getDriverId,driverId)
.set(FleetTripEntity::getDriverName,d==null?null:d.name()).set(FleetTripEntity::getDriverPhone,d==null?null:d.phone())
.set(FleetTripEntity::getExpectedDriverEndAt,d==null||d.expectedTaskEndAt()==null?null:utc(d.expectedTaskEndAt()))
.set(FleetTripEntity::getPlannedDepartureAt,plannedAt==null?null:utc(plannedAt)).set(FleetTripEntity::getRemark,remark)
.set(FleetTripEntity::getPlannedLoadJson,null).set(FleetTripEntity::getLoadJson,null).set(FleetTripEntity::getLoadingReviewJson,null)
.set(FleetTripEntity::getRecordVersion,version+1).set(FleetTripEntity::getUpdatedAt,now()).set(FleetTripEntity::getUpdatedBy,operator);
if(repository.updateTrip(null,update)!=1)throw conflict("整车资源版本已变化");
record(repository.findTrip(actual.tripNo()),"DISPATCH_EDIT",remark,operator);
}
/** 父锁内同步真实货量和目的地;已确认外接站点顺序不得重排。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void updateDispatchCargo(String dispatchNo, int packages, List<FleetExecutionModels.DispatchStop> destinations) {
var binding = repository.wholeBinding(dispatchNo);
if (binding == null) throw conflict("整车关联缺失");
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()));
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
|| repository.wholeQuantity(dispatchNo,packages) != 1) throw conflict("整车货物快照保存失败");
}
/** 两类车次共用建档逻辑,幂等摘要包含用途以防串用。 */
private FleetTripModels.Detail createPlan(String operator, FleetTripModels.Create request, String purpose) {
String hash = hash(("PICKUP".equals(purpose) ? "PICKUP:" : "") + write(request));
@@ -136,6 +390,7 @@ public class FleetTripService {
@Transactional
public FleetTripModels.Detail assign(String tripNo, String operator, FleetTripModels.Assign request) {
FleetTripEntity trip = planned(tripNo, request.recordVersion());
if(!repository.listBindings(trip.getId()).isEmpty())throw conflict("本车资源请通过排车主单调整并重新审核");
requireReason(request.reason());
// 分配始终先锁车辆再锁司机,唯一占用记录作为并发分配的最终约束。
var vehicle = vehicles.lockById(request.vehicleId()).orElseThrow(() -> bad("车辆档案不存在"));
@@ -161,21 +416,17 @@ public class FleetTripService {
trip.setVehicleId(vehicle.id()); trip.setVehiclePlate(vehicle.plateNumber());
trip.setMaxPalletSpaces(vehicle.maxPalletSpaces()); trip.setMaxLoadKg(vehicle.maxLoadKg()); trip.setMaxVolumeCbm(vehicle.maxVolumeCbm());
trip.setDriverId(Long.valueOf(driver.id())); trip.setDriverName(driver.details().name()); trip.setDriverPhone(driver.details().phone());
for (var binding : repository.listBindings(trip.getId())) {
var dispatch = dispatches.lockForAssignment(binding.getDispatchNo());
if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw conflict("排车单与车次起运仓不一致");
dispatches.assign(dispatch, assignment(trip));
}
persist(trip, request.recordVersion(), "ASSIGN", request.reason(), operator);
var currentBindings=bindingResponses(trip);
var history=previousDispatches(trip,currentBindings);
return new FleetTripModels.Detail(response(trip), driver.qualification(), currentBindings, FleetTripReview.read(json, trip.getLoadingReviewJson()),history.dispatches(),history.unavailableReason());
return new FleetTripModels.Detail(response(trip), driver.qualification(), currentBindings, loadingReview(trip),history.dispatches(),history.unavailableReason());
}
/** 修改未发车计划;取货车次保留原单取货点和目的仓。 */
@Transactional
public FleetTripModels.Detail updatePlan(String tripNo, String operator, FleetTripModels.Plan request) {
FleetTripEntity trip = planned(tripNo, request.recordVersion());
if(!repository.listBindings(trip.getId()).isEmpty())throw conflict("本车计划请通过排车主单调整并重新审核");
requireReason(request.reason());
validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops());
bookingReservations.requireRoute(trip, request.stops());
@@ -184,37 +435,13 @@ public class FleetTripService {
if ("PICKUP".equals(trip.getTripPurpose()) && !unchangedRoute) throw conflict("取货路线由原单地址和入仓仓库确定,不能修改为出库路线");
boolean unchangedPlan = unchangedRoute && trip.getPlannedDepartureAt().equals(utc(request.plannedDepartureAt()))
&& trip.getExpectedDriverEndAt().equals(utc(request.expectedDriverEndAt()));
for (var binding : repository.listBindings(trip.getId())) {
if (unchangedRoute) dispatches.lockForAssignment(binding.getDispatchNo());
else dispatches.lockForPlanning(binding.getDispatchNo());
}
trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt()));
trip.setStopsJson(write(request.stops())); trip.setRemark(trim(request.remark()));
persist(trip, request.recordVersion(), unchangedPlan ? "REMARK" : "PLAN", request.reason(), operator);
return detail(trip);
}
/** 逐张审核客户排车单,不以同车其他客户审核代替本单。 */
@Transactional
public FleetTripModels.Detail reviewDispatch(String tripNo, String dispatchNo, String operator, FleetTripModels.Review request) {
var trip = planned(tripNo, request.recordVersion());
String reason = FleetTripReview.reason(request);
if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机");
if ("APPROVED".equals(request.decision())) {
var vehicle = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> bad("车辆档案不存在"));
if (!"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region())) throw conflict("车辆当前不可派车");
var driver = drivers.requireDispatchable(trip.getDriverId(), instant(trip.getExpectedDriverEndAt()));
if (!"OVERSEAS".equals(driver.details().region())) throw conflict("请选择海外司机");
}
var dispatch = dispatches.lockForAssignment(dispatchNo);
var binding = repository.lockBinding(dispatch.dispatchId());
if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次");
binding.setReviewJson(write(new FleetTripModels.ReviewState(request.decision(), reason, operator, clock.instant(), request.recordVersion())));
if (repository.updateBinding(binding) != 1) throw conflict("排车单审核保存失败");
trip.setLoadingReviewJson(null);
persist(trip, request.recordVersion(), "DISPATCH_REVIEW", reason, operator);
return detail(trip);
}
/** 取消未执行车次,释放资源并将取货原单退回待安排。 */
@Transactional
@@ -238,43 +465,9 @@ public class FleetTripService {
return detail(trip);
}
@jakarta.annotation.Resource private FleetDispatchPlanningGateway planning;
/** 读取当前车次可关联的客户排车候选。 */
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public com.baiyue.common.core.api.PageResult<FleetDispatchPlanningGateway.Candidate> dispatchCandidates(String tripNo,String keyword,int page,int size) {
var trip=repository.findTrip(tripNo);
if(trip==null)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND,"车次不存在");
if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次可以添加待运货物");
return planning.candidates(tripNo,trip.getWarehouseId().toString(),keyword,page,size);
}
/** 关联同仓客户排车单并同步车辆司机,禁止取货车次混用。 */
@Transactional
public FleetTripModels.Detail bindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) {
var trip = planned(tripNo, request.recordVersion());
if ("PICKUP".equals(trip.getTripPurpose())) throw conflict("取货车次不能关联出库排车单");
requireReason(request.reason());
if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机");
var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim());
appointmentAllocations.requireTripBinding(dispatch.dispatchNo(),trip.getId());
if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw bad("排车单与车次必须使用同一起运仓");
var bound = repository.lockBinding(dispatch.dispatchId());
if (bound != null) throw conflict("排车单已关联车次 " + bound.getTripNo());
var binding = new FleetTripDispatch();
binding.setDispatchId(dispatch.dispatchId()); binding.setDispatchNo(dispatch.dispatchNo()); binding.setCustomerId(dispatch.customerId());
binding.setTripId(trip.getId()); binding.setTripNo(tripNo); binding.setPackageCount(dispatch.packageCount());
binding.setBoundBy(operator); binding.setBoundAt(now());
try { if (repository.insertBinding(binding) != 1) throw conflict("排车单关联失败"); }
catch (DuplicateKeyException duplicate) { throw conflict("排车单已关联其他车次,请刷新后重试"); }
bookingReservations.bindIncoming(trip, dispatch, dispatches.sourceCargo(dispatch.dispatchNo()), operator);
var route = matchedStops(response(trip).stops(), dispatches.manifest(dispatch.dispatchNo(), false).cargo());
validatePlan(instant(trip.getPlannedDepartureAt()), instant(trip.getExpectedDriverEndAt()), route);
bookingReservations.requireRoute(trip, route);
trip.setStopsJson(write(route));
dispatches.assign(dispatch, assignment(trip));
persist(trip, request.recordVersion(), "BIND_DISPATCH", request.reason(), operator);
return detail(trip);
}
static List<FleetTripModels.Stop> matchedStops(List<FleetTripModels.Stop> existing, List<FleetExecutionModels.Cargo> cargo) {
// Keep established stop indexes/order: confirmed bookings may refer to them.
@@ -297,20 +490,7 @@ public class FleetTripService {
return List.copyOf(result);
}
/** 解除尚未执行的排车关联并使原装载审核失效。 */
@Transactional
public FleetTripModels.Detail unbindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) {
var trip = planned(tripNo, request.recordVersion());
requireReason(request.reason());
var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim());
var binding = repository.lockBinding(dispatch.dispatchId());
if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次");
dispatches.assign(dispatch, new FleetDispatchGateway.Assignment(null, null, null));
if (repository.deleteBinding(binding.getDispatchId()) != 1) throw conflict("排车单关联已变化,请刷新后重试");
bookingReservations.unbindIncoming(trip, dispatch.dispatchNo(), operator);
persist(trip, request.recordVersion(), "UNBIND_DISPATCH", request.reason(), operator);
return detail(trip);
}
/** 分页读取不可变车次历史,用于追溯计划和执行变更。 */
public PageResult<FleetTripModels.History> history(String tripNo, int page, int size) {
@@ -362,14 +542,25 @@ public class FleetTripService {
var currentBindings=bindingResponses(trip);
var history=previousDispatches(trip,currentBindings);
return new FleetTripModels.Detail(response(trip), trip.getDriverId() == null ? null
: drivers.detail(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())).qualification(), currentBindings, FleetTripReview.read(json, trip.getLoadingReviewJson()),history.dispatches(),history.unavailableReason());
: drivers.detail(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())).qualification(), currentBindings, loadingReview(trip),history.dispatches(),history.unavailableReason());
}
/** 将客户隔离的排车关联转换为详情展示模型。 */
/** Fleet只展示主单当前审核事实;没有主单的取货记录没有排车审核。 */
private FleetTripModels.ReviewState loadingReview(FleetTripEntity trip) {
var members=repository.listBindings(trip.getId());
if(members.isEmpty())return new FleetTripModels.ReviewState("NOT_SUBMITTED",null,null,null,null);
if(members.size()!=1)throw conflict("车次必须唯一关联整车主单");
var fact=dispatches.workflows(List.of(members.get(0).getDispatchNo())).get(members.get(0).getDispatchNo());
if(fact==null)throw conflict("资源关联缺少真实整车审核事实");
return fact.loadingReview();
}
/** 将整车关联和逐客户预约引用转换为详情展示模型。 */
private List<FleetTripModels.DispatchBinding> bindingResponses(FleetTripEntity trip) {
var current=repository.listBindings(trip.getId());
var references=appointmentAllocations.references(current.stream().map(FleetTripDispatch::getDispatchId).toList());
var facts=dispatches.workflows(current.stream().map(FleetTripDispatch::getDispatchNo).toList());
if(current.stream().anyMatch(b->!facts.containsKey(b.getDispatchNo())))throw conflict("资源关联缺少真实整车审核事实");
return current.stream().map(binding -> new FleetTripModels.DispatchBinding(binding.getDispatchNo(),
binding.getCustomerId(), binding.getPackageCount(), binding.getBoundBy(), instant(binding.getBoundAt()), FleetTripReview.read(json, binding.getReviewJson()),references.getOrDefault(binding.getDispatchId(),List.of()))).toList();
binding.getPackageCount(), binding.getBoundBy(), instant(binding.getBoundAt()), facts.get(binding.getDispatchNo()).dispatchReview(),references.getOrDefault(binding.getDispatchId(),List.of()))).toList();
}
/** 组装当前车次关联单据和预约追溯信息。 */
private record AppointmentHistory(List<FleetTripModels.PreviousDispatch> dispatches,String unavailableReason) { }
@@ -389,9 +580,9 @@ public class FleetTripService {
var references=new ArrayList<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference>();
for(var batch:binding.appointmentBatches()) {
var missing=batch.sources().stream().filter(source->seen.add(referenceKey(batch,source))).toList();
if(!missing.isEmpty()) references.add(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference(batch.batchKey(),batch.batchNo(),batch.customerId(),missing));
if(!missing.isEmpty()) references.add(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference(batch.batchKey(),batch.batchNo(),batch.recordVersion(),batch.customerId(),missing));
}
if(!references.isEmpty()) previous.add(new FleetTripModels.PreviousDispatch(binding.dispatchNo(),binding.customerId(),event.getRecordVersion(),instant(event.getOccurredAt()),List.copyOf(references)));
if(!references.isEmpty()) previous.add(new FleetTripModels.PreviousDispatch(binding.dispatchNo(),event.getRecordVersion(),instant(event.getOccurredAt()),List.copyOf(references)));
}
}
return new AppointmentHistory(List.copyOf(previous),unavailableReason);
@@ -430,8 +621,6 @@ public class FleetTripService {
if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setPlannedLoadJson(null);
if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setLoadJson(null);
if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL", "CONFIRM_LOAD").contains(action)) trip.setLoadingReviewJson(null);
if (Set.of("ASSIGN", "PLAN").contains(action)) repository.updateBindings(new LambdaUpdateWrapper<FleetTripDispatch>()
.eq(FleetTripDispatch::getTripId, trip.getId()).set(FleetTripDispatch::getReviewJson, null));
trip.setRecordVersion(version + 1); trip.setUpdatedAt(now()); trip.setUpdatedBy(operator);
if (repository.updateTrip(trip, new LambdaUpdateWrapper<FleetTripEntity>().eq(FleetTripEntity::getId, trip.getId()).eq(FleetTripEntity::getRecordVersion, version)) != 1)
throw conflict("车次已被修改,请刷新后重试");
@@ -444,7 +633,7 @@ public class FleetTripService {
event.setAction(action); event.setReason(trim(reason)); event.setOperatorId(operator); event.setOccurredAt(trip.getUpdatedAt());
event.setSnapshotJson(write(new FleetTripModels.AuditSnapshot(response(trip), bindingResponses(trip), trip.getLoadJson() == null ? null
: read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class), trip.getCompletionJson() == null ? null
: read(trip.getCompletionJson(), FleetCompletionModels.Completed.class), FleetTripReview.read(json, trip.getLoadingReviewJson()),
: read(trip.getCompletionJson(), FleetCompletionModels.Completed.class), loadingReview(trip),
trip.getPlannedLoadJson() == null ? null : read(trip.getPlannedLoadJson(), FleetExecutionModels.PlannedLoad.class), bookingReservations.responses(trip.getId()))));
if (repository.insertHistory(event) != 1) throw conflict("车次历史保存失败");
}
@@ -463,6 +652,8 @@ public class FleetTripService {
}
/** 按指定模型解析历史 JSON,损坏记录明确报错。 */
private <T> T read(String value, Class<T> type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException("车次历史读取失败", e); } }
/** 按显式集合类型读取资源站点,禁止损坏数据回退为假路线。 */
private <T> T read(String value, TypeReference<T> type) { try { return json.readValue(value,type); } catch (JsonProcessingException error) { throw new IllegalStateException("车次集合事实读取失败",error); } }
/** 为幂等请求生成不可逆内容摘要。 */
private static String hash(String value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } }
/** 使用统一时钟获取 UTC 业务时间。 */
@@ -51,7 +51,7 @@ public class PlatformFleetCapacityOfferController {
public ApiResponse<FleetBookingModels.Result> incoming(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.StartIncoming request){return ApiResponse.success(service.arrangeIncoming(bookingNo,request));}
@PostMapping("/inquiries/{bookingNo}/receipt-confirmation") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-confirm')")
public ApiResponse<FleetBookingModels.Result> receipt(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.AcceptReceipt request){return ApiResponse.success(service.acceptReceipt(bookingNo,request));}
@PostMapping("/inquiries/{bookingNo}/arrange-dispatch") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-edit') and hasAuthority('platform:fleet:trip:edit') and hasAuthority('platform:overseas-warehouse:inbound-order:smart-dispatch')")
@PostMapping("/inquiries/{bookingNo}/arrange-dispatch") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-edit') and hasAuthority('platform:fleet:trip:edit') and hasAuthority('platform:overseas-warehouse:dispatch:create')")
public ApiResponse<FleetBookingModels.Result> arrange(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Command request){return ApiResponse.success(service.arrangeDispatch(bookingNo,request));}
@PostMapping("/inquiries/{bookingNo}/save-and-confirm") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
@@ -27,13 +27,7 @@ public class PlatformFleetTripController {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台主体可读取车次日历");
return ApiResponse.success(calendar.calendar(from,to));
}
@jakarta.annotation.Resource private FleetTripPreparationService preparation;
@PostMapping("/prepare")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetTripModels.Detail> prepare(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody FleetTripPreparationService.Prepare request) {
return ApiResponse.success(preparation.prepare(operator(user),request));
}
private final FleetTripService service;
private final FleetTripExecutionService execution;
private final FleetTripCompletionService completion;
@@ -47,11 +41,7 @@ public class PlatformFleetTripController {
@GetMapping("/{tripNo}")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<FleetTripModels.Detail> detail(@PathVariable String tripNo) { return ApiResponse.success(service.detail(tripNo)); }
@PostMapping
@PreAuthorize("hasAuthority('platform:fleet:trip:create')")
public ApiResponse<FleetTripModels.Detail> create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody FleetTripModels.Create request) {
return ApiResponse.success(service.create(operator(user), request));
}
@PutMapping("/{tripNo}/plan")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetTripModels.Detail> plan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Plan request) {
@@ -78,43 +68,17 @@ public class PlatformFleetTripController {
public ApiResponse<FleetTripModels.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Cancel request) {
return ApiResponse.success(service.cancel(tripNo, operator(user), request));
}
@GetMapping("/{tripNo}/dispatch-candidates")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<PageResult<FleetDispatchPlanningGateway.Candidate>> dispatchCandidates(@PathVariable String tripNo,
@RequestParam(required=false) String keyword,@RequestParam(defaultValue="1") @Min(1) int page,
@RequestParam(defaultValue="20") @Min(1) @Max(100) int size) {
return ApiResponse.success(service.dispatchCandidates(tripNo,keyword,page,size));
}
@PostMapping("/{tripNo}/dispatches/bind")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetTripModels.Detail> bind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@Valid @RequestBody FleetTripModels.BindingCommand request) {
return ApiResponse.success(service.bindDispatch(tripNo, operator(user), request));
}
@PostMapping("/{tripNo}/dispatches/unbind")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetTripModels.Detail> unbind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@Valid @RequestBody FleetTripModels.BindingCommand request) {
return ApiResponse.success(service.unbindDispatch(tripNo, operator(user), request));
}
@GetMapping("/{tripNo}/history")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<PageResult<FleetTripModels.History>> history(@PathVariable String tripNo,
@RequestParam(defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(200) int size) {
return ApiResponse.success(service.history(tripNo, page, size));
}
@PostMapping("/{tripNo}/dispatches/{dispatchNo}/review")
@PreAuthorize("hasAuthority('platform:fleet:trip:dispatch-review')")
public ApiResponse<FleetTripModels.Detail> reviewDispatch(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@PathVariable String dispatchNo, @Valid @RequestBody FleetTripModels.Review request) {
return ApiResponse.success(service.reviewDispatch(tripNo, dispatchNo, operator(user), request));
}
@PostMapping("/{tripNo}/loading-review")
@PreAuthorize("hasAuthority('platform:fleet:trip:loading-review')")
public ApiResponse<FleetTripModels.Detail> reviewLoading(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@Valid @RequestBody FleetTripModels.Review request) {
return ApiResponse.success(execution.reviewLoading(tripNo, operator(user), request));
}
@GetMapping("/{tripNo}/loading")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<FleetExecutionModels.Loading> loading(@PathVariable String tripNo) { return ApiResponse.success(execution.loading(tripNo)); }
@@ -127,12 +91,7 @@ public class PlatformFleetTripController {
@Valid @RequestBody FleetExecutionModels.Confirm request) {
return ApiResponse.success(execution.confirmCapacityPlan(tripNo, operator(user), request));
}
@PostMapping("/{tripNo}/loading")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetExecutionModels.Loading> confirmLoading(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@Valid @RequestBody FleetExecutionModels.Confirm request) {
return ApiResponse.success(execution.confirmLoad(tripNo, operator(user), request));
}
private static String operator(AuthenticatedUser user) {
if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录");
return user.userId().toString();