优化
This commit is contained in:
-2
@@ -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("实际发运与车次客户、仓库或箱数不一致");
|
||||
|
||||
+2
-2
@@ -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
-2
@@ -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) { }
|
||||
|
||||
+3
-3
@@ -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);
|
||||
|
||||
+17
-5
@@ -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("来货单引用不一致");
|
||||
|
||||
+6
-5
@@ -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)
|
||||
|
||||
+9
-12
@@ -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);
|
||||
}
|
||||
|
||||
+1
-3
@@ -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);
|
||||
}
|
||||
|
||||
+9
-12
@@ -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})")
|
||||
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
|
||||
+14
@@ -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}")
|
||||
|
||||
+107
-88
@@ -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,
|
||||
|
||||
-81
@@ -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); }
|
||||
/** 唯一键防止车辆或司机同时承接两趟任务。 */
|
||||
|
||||
+285
-94
@@ -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 业务时间。 */
|
||||
|
||||
+1
-1
@@ -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')")
|
||||
|
||||
+8
-49
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user