diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java index d4c8f3e8c..c1ed5a6c9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java @@ -25,7 +25,12 @@ public final class DispatchContracts { /** 临时资源证据只归本趟,不能据此自动建车辆或司机主档。 */ public record DriverEvidence(String licenseNo, LocalDate licenseExpiresOn, - List fileIds, Instant expectedTaskEndAt, List photoFileIds) { + List fileIds, Instant expectedTaskEndAt, List photoFileIds, + @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) Long masterLicenseFileId) { + /** 普通派送继续按既有主档证照语义构造,取货显式记录主档引用来源。 */ + public DriverEvidence(String licenseNo, LocalDate licenseExpiresOn, List fileIds, Instant expectedTaskEndAt, List photoFileIds) { + this(licenseNo, licenseExpiresOn, fileIds, expectedTaskEndAt, photoFileIds, null); + } /** 司机照片独立于驾驶证,缺省为空且引用须有界、有效、不重复。 */ public DriverEvidence { photoFileIds = ReviewAttachments.checked(photoFileIds); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 397f2b149..8f65aba51 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -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(); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotos.java new file mode 100644 index 000000000..dd9bb9a7b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotos.java @@ -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.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 old,List selected) { + if(old!=null && selected.stream().anyMatch(old::contains))throw conflict("车辆或司机已更换,请重新上传对应照片"); + } + /** 文件网关逐项校验平台、本车和文件类别,失效或跨单引用直接失败。 */ + private void verify(long dispatch,DispatchEvidenceGateway.Kind kind,List ids) { + for(long id:ids)files.metadata(dispatch,kind,id); + } + /** 返回可处理的业务冲突。 */ + private static ResponseStatusException conflict(String message) {return new ResponseStatusException(HttpStatus.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskController.java index 59ac4f516..f4287c7e9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskController.java @@ -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));} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java index ff74f2627..47a460c8e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java @@ -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 pickups, - List completionEvidence) { } + List 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) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java index 4a9b5085d..033c2a4c6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java @@ -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 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);}} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java index d661e2cb1..7368d9cb3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java @@ -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("车辆主档已变化,请重新维护车辆信息"); } /** 取消运输并保留原单、排车及资源审计。 */ diff --git a/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml b/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml index 15cb98986..622547914 100644 --- a/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml @@ -42,7 +42,7 @@ paths: - { name: number, in: path, required: true, schema: { type: string }, description: 真实PC排车业务号 } get: summary: 排车运输详情(list) - description: data包含dispatchNo、taskType(PICKUP/LOCAL_TRUCK)、execution(现有资源与执行详情)、customerId、orderNo、pickups、completionEvidence(已确认凭证)。 + description: data包含dispatchNo、taskType(PICKUP/LOCAL_TRUCK)、execution(现有资源与执行详情)、customerId、orderNo、pickups、completionEvidence(已确认凭证)、dispatchRecordVersion(排车审核版本)、resources(取货车辆司机照片快照;其他类型为null)。resources.driver.evidence.masterLicenseFileId仅表示司机主档驾驶证来源,包含在fileIds内;其他fileIds属于本车补传。 responses: *response /{number}/{action}: parameters: @@ -52,6 +52,10 @@ paths: summary: 分配、取消、资源可用性查询与实际任务释放 description: >- assign(edit):recordVersion、vehicleId、driverId、reason;cancel(cancel):recordVersion、reason。 + PICKUP分配还必须提交dispatchRecordVersion和reviewPhotos(vehicleFileIds、driverPhotoFileIds、licenseFileIds),每类最多20个唯一正整数ID。 + 车辆图片和司机照片各至少一张,驾驶证须有本车licenseFileIds或masterLicenseFileId;后者仅可引用所选司机当前有效主档驾驶证,不放入请求的licenseFileIds。 + 所有补传经dispatches/{number}/evidence/{VEHICLE|DRIVER_PHOTO|DRIVER}上传并校验本车类别归属;主档预览复用司机档案受权下载。 + 资源、照片与审核失效同事务,双版本过期或任一照片不合法则不分配资源。重新换车/司机不得沿用对应旧照片,照片修改必须重新两级审核,运输状态同步保留同一资源的照片。 resource-occupancy(list):kind=VEHICLE/DRIVER、resourceIds数组。 release(edit):recordVersion、resourceKind、actualEndAt、endLocation、vehicleStatus(车辆必填)、idempotencyKey、reason。 responses: *response diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotosTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotosTest.java new file mode 100644 index 000000000..127eea75e --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotosTest.java @@ -0,0 +1,73 @@ +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.*; +import com.baiyue.lastleg.fleet.trip.FleetTripEntity; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class TransportReviewPhotosTest { + final ObjectMapper json=new ObjectMapper().findAndRegisterModules(); + final DispatchEvidenceGateway files=mock(DispatchEvidenceGateway.class); + final DriverService drivers=mock(DriverService.class); + final TransportReviewPhotos service=new TransportReviewPhotos(files,drivers,json); + final DispatchAggregate row=new DispatchAggregate(); + final FleetTripEntity trip=new FleetTripEntity(); + /** 使用已审核海外司机及未来任务时间,不依赖运行中的服务。 */ + @BeforeEach void setup() { + row.setId(10L);row.setRecordVersion(9L); + trip.setVehicleId(1L);trip.setDriverId(2L);trip.setRecordVersion(4L);trip.setExpectedDriverEndAt(LocalDateTime.of(2099,1,1,0,0)); + var response=mock(DriverModels.Response.class,RETURNS_DEEP_STUBS); + when(response.details().region()).thenReturn("OVERSEAS");when(response.details().licenseNumber()).thenReturn("TEST"); + when(response.details().licenseExpiresOn()).thenReturn(LocalDate.of(2099,2,1));when(response.licenseFileId()).thenReturn("33"); + when(drivers.requireDispatchable(eq(2L),any())).thenReturn(response); + } + /** 必填照片不足时不能调用文件或资源保存。 */ + @Test void requiresAllThreeCategories() { + for(var photos:List.of(new ReviewAttachments(List.of(),List.of(22L),List.of(23L)), + new ReviewAttachments(List.of(21L),List.of(),List.of(23L)),new ReviewAttachments(List.of(21L),List.of(22L),List.of()))) { + assertThatThrownBy(()->service.validate(row,trip,request(photos,null))).hasMessageContaining("补齐"); + } + verifyNoInteractions(files,drivers); + } + /** 主档与本车补传走各自归属,禁止拿别人的证照冒充当前司机。 */ + @Test void distinguishesMasterLicenseFromDispatchUploads() { + var evidence=service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of()),33L)); + assertThat(evidence.fileIds()).containsExactly(33L);assertThat(evidence.masterLicenseFileId()).isEqualTo(33L); + verify(drivers).download(2L,33L);verify(files,never()).metadata(10L,DRIVER,33L); + var uploaded=service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of(23L)),null)); + assertThat(uploaded.masterLicenseFileId()).isNull();verify(files).metadata(10L,DRIVER,23L); + assertThatThrownBy(()->service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of()),99L))).hasMessageContaining("主档驾驶证已变化"); + } + /** 同车不同分类及跨排车文件由文件网关拒绝。 */ + @Test void rejectsForeignOrWrongCategoryPhoto() { + when(files.metadata(10L,DRIVER_PHOTO,22L)).thenThrow(new IllegalArgumentException("文件归属错误")); + assertThatThrownBy(()->service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of(23L)),null))).hasMessageContaining("归属错误"); + } + /** 换司机后旧人像不允许重复绑定。 */ + @Test void rejectsPhotosCarriedFromAnotherDriver() throws Exception { + row.setResourcesJson(json.writeValueAsString(new Resources(null,new DriverSnapshot(ResourceMode.MASTER,5L,"原司机","000", + new DriverEvidence(null,null,List.of(23L),null,List.of(22L)))))); + assertThatThrownBy(()->service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of(24L)),null))).hasMessageContaining("重新上传"); + } + /** 审核复验已保存证据,照片后续失效也不能通过。 */ + @Test void reviewRevalidatesPersistedPhotoOwnership() throws Exception { + row.setResourcesJson(json.writeValueAsString(new Resources(new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST",null,null,null,List.of(21L)), + new DriverSnapshot(ResourceMode.MASTER,2L,"司机","000",new DriverEvidence(null,null,List.of(23L),null,List.of(22L)))))); + service.verifyReview(row,trip);verify(files).metadata(10L,VEHICLE,21L); + when(files.metadata(10L,DRIVER,23L)).thenThrow(new IllegalArgumentException("附件失效")); + assertThatThrownBy(()->service.verifyReview(row,trip)).hasMessageContaining("附件失效"); + } + /** 构造同一车辆、司机的双版本保存请求。 */ + private TransportTaskModels.Assign request(ReviewAttachments photos,Long master) { + return new TransportTaskModels.Assign(4L,1L,2L,"维护照片",9L,photos,master); + } +}