This commit is contained in:
2026-10-21 17:52:28 +08:00
parent ae361879e5
commit 044812cd90
9 changed files with 218 additions and 11 deletions
@@ -25,7 +25,12 @@ public final class DispatchContracts {
/** 临时资源证据只归本趟,不能据此自动建车辆或司机主档。 */
public record DriverEvidence(String licenseNo, LocalDate licenseExpiresOn,
List<Long> fileIds, Instant expectedTaskEndAt, List<Long> photoFileIds) {
List<Long> fileIds, Instant expectedTaskEndAt, List<Long> photoFileIds,
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) Long masterLicenseFileId) {
/** 普通派送继续按既有主档证照语义构造,取货显式记录主档引用来源。 */
public DriverEvidence(String licenseNo, LocalDate licenseExpiresOn, List<Long> fileIds, Instant expectedTaskEndAt, List<Long> photoFileIds) {
this(licenseNo, licenseExpiresOn, fileIds, expectedTaskEndAt, photoFileIds, null);
}
/** 司机照片独立于驾驶证,缺省为空且引用须有界、有效、不重复。 */
public DriverEvidence { photoFileIds = ReviewAttachments.checked(photoFileIds); }
}
@@ -274,6 +274,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var resources=read(row.getResourcesJson(),Resources.class);var driver=resources==null?null:resources.driver();
if(driver==null || driver.mode()!=ResourceMode.MASTER || driver.evidence()==null || driver.evidence().fileIds()==null
|| !driver.evidence().fileIds().contains(fileId))return null;
// 取货允许补传本车驾驶证,仅明确冻结的主档引用才走司机归属。
if("PICKUP".equals(row.getTaskType()) && !Objects.equals(driver.evidence().masterLicenseFileId(),fileId))return null;
if(driver.driverId()==null || driver.driverId()<=0)throw bad("排车冻结司机档案身份无效,不能读取证照");
return driver.driverId();
}
@@ -0,0 +1,81 @@
package com.baiyue.lastleg.dispatch.transport;
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.fleet.driver.DriverService;
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.ZoneOffset;
import java.util.*;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.*;
/** 取货审核照片按本车分类校验,驾驶证主档引用明确区分于本车补传。 */
@Service
public class TransportReviewPhotos {
private final DispatchEvidenceGateway files;
private final DriverService drivers;
private final ObjectMapper json;
/** 注入正式文件归属与司机资格入口。 */
public TransportReviewPhotos(DispatchEvidenceGateway files, DriverService drivers, ObjectMapper json) {
this.files=files; this.drivers=drivers; this.json=json;
}
/** 读取已保存资源;空排车还没有照片。 */
public Resources resources(DispatchAggregate row) {
if(row.getResourcesJson()==null)return new Resources(null,null);
try{return json.readValue(row.getResourcesJson(),Resources.class);}
catch(Exception e){throw new IllegalStateException("取货照片快照读取失败",e);}
}
/** 校验三类照片及主档身份,换车或换司机不能沿用旧对象的照片。 */
public DriverEvidence validate(DispatchAggregate row, FleetTripEntity trip, TransportTaskModels.Assign request) {
var photos=request.reviewPhotos();
if(photos==null || photos.vehicleFileIds().isEmpty() || photos.driverPhotoFileIds().isEmpty()
|| photos.licenseFileIds().isEmpty() && request.masterLicenseFileId()==null)
throw conflict("请补齐车辆图片、司机照片和驾驶证照片后保存");
var old=resources(row);
if(old.vehicle()!=null && !Objects.equals(old.vehicle().vehicleId(),request.vehicleId()))
rejectReused(old.vehicle().evidenceFileIds(),photos.vehicleFileIds());
if(old.driver()!=null && old.driver().evidence()!=null && !Objects.equals(old.driver().driverId(),request.driverId())) {
rejectReused(old.driver().evidence().photoFileIds(),photos.driverPhotoFileIds());
rejectReused(old.driver().evidence().fileIds(),photos.licenseFileIds());
}
var driver=drivers.requireDispatchable(request.driverId(),trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));
if(!"OVERSEAS".equals(driver.details().region()))throw conflict("请选择海外司机");
verify(row.getId(),VEHICLE,photos.vehicleFileIds());
verify(row.getId(),DRIVER_PHOTO,photos.driverPhotoFileIds());
verify(row.getId(),DRIVER,photos.licenseFileIds());
var licenseIds=new ArrayList<>(photos.licenseFileIds());
if(request.masterLicenseFileId()!=null) {
if(!request.masterLicenseFileId().toString().equals(driver.licenseFileId()))throw conflict("司机主档驾驶证已变化,请重新选择司机");
drivers.download(request.driverId(),request.masterLicenseFileId());
if(licenseIds.contains(request.masterLicenseFileId()))throw conflict("驾驶证主档引用不能作为本车上传重复提交");
licenseIds.add(request.masterLicenseFileId());
}
return new DriverEvidence(driver.details().licenseNumber(),driver.details().licenseExpiresOn(),List.copyOf(licenseIds),
trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC),photos.driverPhotoFileIds(),request.masterLicenseFileId());
}
/** 提交和两级通过均复验照片,避免仅前端必填而绕过审核依据。 */
public void verifyReview(DispatchAggregate row, FleetTripEntity trip) {
var saved=resources(row);var driver=saved.driver();var vehicle=saved.vehicle();
if(vehicle==null || driver==null || driver.evidence()==null
|| !Objects.equals(vehicle.vehicleId(),trip.getVehicleId()) || !Objects.equals(driver.driverId(),trip.getDriverId()))
throw conflict("请维护车辆司机并补齐审核照片");
var e=driver.evidence();
var licenses=e.fileIds()==null?List.<Long>of():e.fileIds().stream().filter(id->!Objects.equals(id,e.masterLicenseFileId())).toList();
validate(row,trip,new TransportTaskModels.Assign(trip.getRecordVersion(),trip.getVehicleId(),trip.getDriverId(),"核验取货照片",
row.getRecordVersion(),new ReviewAttachments(vehicle.evidenceFileIds(),e.photoFileIds(),licenses),e.masterLicenseFileId()));
}
/** 不把同排车旧司机或车辆的上传当成新对象的证据。 */
private void rejectReused(List<Long> old,List<Long> selected) {
if(old!=null && selected.stream().anyMatch(old::contains))throw conflict("车辆或司机已更换,请重新上传对应照片");
}
/** 文件网关逐项校验平台、本车和文件类别,失效或跨单引用直接失败。 */
private void verify(long dispatch,DispatchEvidenceGateway.Kind kind,List<Long> ids) {
for(long id:ids)files.metadata(dispatch,kind,id);
}
/** 返回可处理的业务冲突。 */
private static ResponseStatusException conflict(String message) {return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -39,7 +39,7 @@ public class TransportTaskController {
public ApiResponse<?> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number){operator(user);return ApiResponse.success(service.detail(number));}
/** 分配或改派真实车辆司机。 */
@PostMapping("/{number}/assign") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<?> assign(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.Assign request){return ApiResponse.success(service.assign(number,operator(user),request));}
public ApiResponse<?> assign(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody TransportTaskModels.Assign request){return ApiResponse.success(service.assign(number,operator(user),request));}
/** 取消未发车任务,来源回到待安排。 */
@PostMapping("/{number}/cancel") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:cancel')")
public ApiResponse<?> cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.Cancel request){return ApiResponse.success(service.cancel(number,operator(user),request));}
@@ -18,6 +18,12 @@ public final class TransportTaskModels {
@NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { }
public record Detail(String dispatchNo,String taskType,FleetTripModels.Detail execution,
String customerId,String orderNo,List<FleetPickupModels.Task> pickups,
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> completionEvidence) { }
List<com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile> completionEvidence,
Long dispatchRecordVersion, com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources resources) { }
public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId,
@NotNull @Positive Long driverId, @NotBlank @Size(max=1000) String reason,
@Min(0) Long dispatchRecordVersion,
@Valid com.baiyue.lastleg.dispatch.port.DispatchContracts.ReviewAttachments reviewPhotos,
@Positive Long masterLicenseFileId) { }
public record TruckLink(String dispatchNo,String tripNo,String customerId,String orderNo) { }
}
@@ -95,7 +95,7 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.
try {
var old=json.readValue(value,Review.class);
return write(new Review(old.status()==ReviewStatus.REJECTED?ReviewStatus.REJECTED:ReviewStatus.NOT_SUBMITTED,
old.reason(),old.reviewerId(),old.reviewedAt(),round,null,"车辆、司机或计划已变更,请重新提交两级审核",old.attachments()));
old.reason(),old.reviewerId(),old.reviewedAt(),round,null,"车辆、司机、照片或计划已变更,请重新提交两级审核",old.attachments()));
} catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("取货审核记录读取失败",e);}
}
/** 本土卡车只回写真实运输状态并追加原业务不可变历史。 */
@@ -111,9 +111,32 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.
}
/** 主单仅展示真实主档分配,不伪造临时证照或仓库装载事实。 */
private void resources(DispatchAggregate row,FleetTripEntity trip) {
var old=readResources(row);
row.setVehiclePlate(trip.getVehiclePlate());row.setResourcesJson(write(new Resources(
trip.getVehicleId()==null?null:new VehicleSnapshot(ResourceMode.MASTER,trip.getVehicleId(),trip.getVehiclePlate(),null,null,new Capacity(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),List.of()),
trip.getDriverId()==null?null:new DriverSnapshot(ResourceMode.MASTER,trip.getDriverId(),trip.getDriverName(),trip.getDriverPhone(),null))));
trip.getVehicleId()==null?null:new VehicleSnapshot(ResourceMode.MASTER,trip.getVehicleId(),trip.getVehiclePlate(),null,null,new Capacity(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),
old.vehicle()!=null && Objects.equals(old.vehicle().vehicleId(),trip.getVehicleId())?old.vehicle().evidenceFileIds():List.of()),
trip.getDriverId()==null?null:new DriverSnapshot(ResourceMode.MASTER,trip.getDriverId(),trip.getDriverName(),trip.getDriverPhone(),
old.driver()!=null && Objects.equals(old.driver().driverId(),trip.getDriverId())?old.driver().evidence():null))));
}
/** 照片变更单独纳入主单版本和审核依据,资源分配失败时由外层事务一起回滚。 */
public void savePhotos(String number,String operator,List<Long> vehiclePhotos,DriverEvidence driverPhotos) {
var row=roots.lock(number);var old=readResources(row);var v=old.vehicle();var d=old.driver();
if(v==null || d==null)throw new IllegalStateException("请先完成车辆司机分配");
var next=new Resources(new VehicleSnapshot(v.mode(),v.vehicleId(),v.plate(),v.carrierName(),v.vehicleStatus(),v.capacity(),vehiclePhotos),
new DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),driverPhotos));
if(next.equals(old))return;
long version=row.getRecordVersion();row.setResourcesJson(write(next));row.setRecordVersion(version+1);
row.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setReviewState("DRAFT");row.setReviewBasisHash(null);row.setStage("BEFORE_LOADING");
row.setDispatchReviewJson(invalidated(row.getDispatchReviewJson(),row.getReviewRound()));
row.setLoadingReviewJson(invalidated(row.getLoadingReviewJson(),row.getReviewRound()));
if(roots.saveState(row,version)!=1)throw new IllegalStateException("取货照片保存版本冲突");
roots.history(IdWorker.getId(),row,"PICKUP_RESOURCE_PHOTOS",operator,"保存车辆、司机和驾驶证审核照片",write(row),row.getUpdatedAt());
}
/** 状态投影只保留同一资源的已保存照片,不能在运输流转中清空审核证据。 */
private Resources readResources(DispatchAggregate row) {
if(row.getResourcesJson()==null)return new Resources(null,null);
try{return json.readValue(row.getResourcesJson(),Resources.class);}
catch(Exception e){throw new IllegalStateException("运输资源照片读取失败",e);}
}
/** 保存明确的业务快照,失败时终止事务。 */
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("运输快照序列化失败",e);}}
@@ -35,6 +35,7 @@ public class TransportTaskService {
private final FleetTransportVehicleGuard vehicles;
private final TripOperationsEvidenceGateway files;
private final ObjectMapper json;
@jakarta.annotation.Resource private TransportReviewPhotos reviewPhotos;
/** 复用已有资格、占用、原单及凭证能力。 */
public TransportTaskService(DispatchAggregateRepository roots,TransportTaskRepository links,TransportTaskProjection projection,
FleetTripRepository tripRepo,FleetTripService trips,FleetPickupService pickups,FleetPickupRepository pickupFacts,
@@ -95,7 +96,8 @@ public class TransportTaskService {
var trip=transport(number,false);var row=links.byTrip(trip.getId());var link=links.truck(number);
return new TransportTaskModels.Detail(number,row.getTaskType(),trips.detail(trip.getTripNo()),link==null?null:link.customerId(),link==null?null:link.orderNo(),
"PICKUP".equals(row.getTaskType())?pickups.tasks(trip.getTripNo()):List.of(),
evidenceIds(number).stream().map(id->files.metadata(trip.getId(),id)).toList());
evidenceIds(number).stream().map(id->files.metadata(trip.getId(),id)).toList(),row.getRecordVersion(),
"PICKUP".equals(row.getTaskType())?reviewPhotos.resources(row):null);
}
/** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */
public byte[] evidence(String number,long id){var trip=transport(number,false);if(!evidenceIds(number).contains(id))throw missing("当前排车没有该完成凭证");files.metadata(trip.getId(),id);return files.download(trip.getId(),id);}
@@ -107,10 +109,20 @@ public class TransportTaskService {
if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(root.getTaskType(),"")))throw conflict("当前排车单不是运输任务");
String tripNo=links.tripNo(root.getTripId());var trip=lock?tripRepo.lockTrip(tripNo):tripRepo.findTrip(tripNo);if(trip==null)throw missing("运输资源记录不存在");return trip;
}
/** 分配实际资源,容量超限时整次分配回滚。 */
/** 资源与三类照片同事务保存;双版本阻止审核期间的旧页面覆盖。 */
@Transactional
public TransportTaskModels.Detail assign(String number,String operator,FleetTripModels.Assign request) {
var trip=transport(number,true);trips.assign(trip.getTripNo(),operator,request);
public TransportTaskModels.Detail assign(String number,String operator,TransportTaskModels.Assign request) {
var trip=transport(number,true);
com.baiyue.lastleg.dispatch.port.DispatchContracts.DriverEvidence photos=null;
if("PICKUP".equals(trip.getTripPurpose())) {
var row=roots.lock(number);
if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()) || request.dispatchRecordVersion()==null
|| !Objects.equals(row.getRecordVersion(),request.dispatchRecordVersion()))throw conflict("排车或审核信息已变化,请刷新后重试");
if(!"PLANNED".equals(trip.getStatus()) || pickups.tasks(trip.getTripNo()).stream().anyMatch(task->!"ASSIGNED".equals(task.status())))throw conflict("已取货的任务不能更换资源照片");
photos=reviewPhotos.validate(row,trip,request);
}
trips.assign(trip.getTripNo(),operator,new FleetTripModels.Assign(request.recordVersion(),request.vehicleId(),request.driverId(),request.reason()));
if(photos!=null)projection.savePhotos(number,operator,request.reviewPhotos().vehicleFileIds(),photos);
if("LOCAL_TRUCK".equals(trip.getTripPurpose()))capacity(tripRepo.findTrip(trip.getTripNo()),truck(number));return detail(number);
}
/** 取货提交与两级通过时复验真实占用、有效证照和车辆容量。 */
@@ -120,6 +132,7 @@ public class TransportTaskService {
if(trip.getPlannedDepartureAt()==null || trip.getExpectedDriverEndAt()==null || !trip.getExpectedDriverEndAt().isAfter(trip.getPlannedDepartureAt()) || !trip.getExpectedDriverEndAt().isAfter(now()))throw conflict("请维护有效的计划发车与任务结束时间");
var pallets=trip.getMaxPalletSpaces();var weight=trip.getMaxLoadKg();var volume=trip.getMaxVolumeCbm();
requireResources(trip);
reviewPhotos.verifyReview(roots.find(number),trip);
if(!Objects.equals(pallets,trip.getMaxPalletSpaces()) || !Objects.equals(weight,trip.getMaxLoadKg()) || !Objects.equals(volume,trip.getMaxVolumeCbm()))throw conflict("车辆主档已变化,请重新维护车辆信息");
}
/** 取消运输并保留原单、排车及资源审计。 */