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("车辆主档已变化,请重新维护车辆信息");
}
/** 取消运输并保留原单、排车及资源审计。 */
@@ -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
@@ -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);
}
}