From f6d99919551175dca308ee079356755cb210ca5c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 26 Oct 2026 18:52:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file/service/BusinessFileCategory.java | 2 +- .../fleet-owned-business-photos-v1.yaml | 2 +- .../file/service/BusinessFileStorageTest.java | 6 ++ .../service/DispatchAggregateService.java | 6 +- .../service/DispatchMasterPhotoService.java | 58 +++++++++++++++ .../gateway/HttpOwnedBusinessFileGateway.java | 15 ++-- .../port/DispatchLoadingPhotoPort.java | 4 + .../service/DispatchLoadingPhotoAdapter.java | 34 ++++++++- .../service/OwnedBusinessPhotoService.java | 2 +- .../dispatch-loading-documents-v1.yaml | 14 +++- .../openapi/fleet-outsourced-business-v1.yaml | 5 +- .../openapi/fleet-owned-business-v1.yaml | 5 +- .../DispatchAggregatePersistenceTest.java | 5 +- ...spatchLoadingDeparturePersistenceTest.java | 74 ++++++++++++++++++- .../DispatchMasterPhotoServiceTest.java | 50 +++++++++++++ ...DispatchReviewWorkflowPersistenceTest.java | 17 +++++ .../OwnedBusinessFileGatewayTest.java | 15 +++- 17 files changed, 288 insertions(+), 26 deletions(-) diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index 5de589c09..fe85e01e9 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -15,7 +15,7 @@ enum BusinessFileCategory { OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), - OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), diff --git a/file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml b/file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml index f1c9fd9f9..532fddedb 100644 --- a/file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml +++ b/file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml @@ -7,7 +7,7 @@ info: 复用InternalBusinessFileController,不增加公网File路径。 businessType分为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO, tenantId固定0,businessId固定原Lastleg排车内部主键,不接受车次主键、车牌或客户ID替代。 - 仅非空JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。 + 装车资料接受非空JPG/PNG/PDF(Lastleg仅允许司机驾驶证类别使用PDF),油耗仍仅接受JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。 Lastleg按公开dispatchNo解析原排车,负责车辆归属、可见阶段、读写权限、附加记录版本、 分类附件引用和历史。文件上传不代表装车确认、签收、实到或运营信息保存。 File不读取Lastleg表,只按客户范围、业务类别、业务ID和文件ID核对文件复合归属。 diff --git a/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java b/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java index a08d53383..64d8492df 100644 --- a/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java +++ b/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java @@ -22,6 +22,12 @@ import static org.mockito.Mockito.when; class BusinessFileStorageTest { @TempDir Path root; + @Test void loadingLicenseStoresPdfButFuelStillRejectsPdf() { + var file=new MockMultipartFile("file","license.pdf","application/pdf","%PDF-1.4\n%%EOF".getBytes()); + var stored=storage().store(0L,"51",file,BusinessFileCategory.OVERSEAS_DISPATCH_LOADING_PHOTO); + assertThat(stored.contentType()).isEqualTo("application/pdf"); + assertThatThrownBy(()->storage().store(0L,"51",file,BusinessFileCategory.OVERSEAS_DISPATCH_FUEL_PHOTO)).isInstanceOf(BizException.class); + } @Test void arbitraryCustomsFormatIsStoredAsAttachmentObject() { BusinessFileStorage storage = storage(); 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 3addb7c67..de28eb959 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 @@ -452,6 +452,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(savedDriver!=null && savedDriver.evidence()!=null)for(var id:savedDriver.evidence().fileIds()) { if(!Objects.equals(id,savedDriver.evidence().masterLicenseFileId()))evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,id); } + masterPhotos.invalidateLoading(row.getId(),actor.operatorId(),read(row.getResourcesJson(),Resources.class),savedResources); row.setResourcesJson(write(savedResources));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate()); row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark()); // 发布选择仅在建单时确定,维护车辆不得取消或重新发布需求。 @@ -504,7 +505,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var reviewed=new Review(request.decision()==Decision.APPROVED?ReviewStatus.APPROVED:ReviewStatus.REJECTED,trim(request.reason()),actor.operatorId(),Instant.now().truncatedTo(ChronoUnit.MILLIS),row.getReviewRound(),row.getReviewBasisHash(),null,request.attachments()); if(loading) { row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name()); - if(request.decision()==Decision.APPROVED)row.setStage(Stage.LOADING.name()); + if(request.decision()==Decision.APPROVED) { + row.setStage(Stage.LOADING.name()); + if(!"PICKUP".equals(row.getTaskType()))masterPhotos.populateLoading(row.getId(),actor.operatorId(),read(row.getResourcesJson(),Resources.class)); + } } else { row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name()); row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null,null):pending(row.getReviewRound()))); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoService.java index d0bc8d25b..9037a60ec 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoService.java @@ -8,6 +8,11 @@ import org.springframework.web.multipart.MultipartFile; import java.io.*; import java.nio.file.Files; import java.util.List; +import java.util.ArrayList; +import java.util.Objects; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot; +import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService; import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; import static com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode.MASTER; @@ -16,10 +21,63 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode.MASTER public class DispatchMasterPhotoService { private final FleetResourcePhotoPort photos; private final DispatchEvidenceGateway evidence; + @jakarta.annotation.Resource private DispatchLoadingPhotoPort loadingPhotos; + @jakarta.annotation.Resource private FleetTripExecutionService fleet; /** 只读主档并通过本车文件网关创建证据。 */ public DispatchMasterPhotoService(FleetResourcePhotoPort photos,DispatchEvidenceGateway evidence) { this.photos=photos; this.evidence=evidence; } + /** 资源保存时比较本趟资料来源;来源变化后旧类别不能继续充当新资源的凭证。 */ + public void invalidateLoading(long dispatchId,String operator,Resources before,Resources after) { + var slots=java.util.EnumSet.noneOf(Slot.class); + if(!Objects.equals(vehicleSource(before.vehicle()),vehicleSource(after.vehicle())))slots.add(Slot.LOADING_LIST); + if(!Objects.equals(driverSource(before.driver()),driverSource(after.driver())))slots.add(Slot.LOADING_SCENE); + if(!slots.isEmpty())loadingPhotos.invalidateResourcePhotos(dispatchId,operator,slots); + } + /** 只比较车辆身份及照片,不因容量或运营状态变更废弃现场资料。 */ + private Object vehicleSource(VehicleSnapshot value) { + return value==null?null:java.util.Arrays.asList(value.mode(),value.vehicleId(),value.plate(),sourceFiles(value.evidenceFileIds())); + } + /** 临时司机同样比较身份与证照;人像、电话和预计结束时间不属于驾驶证来源。 */ + private Object driverSource(DriverSnapshot value) { + if(value==null)return null; + var evidence=value.evidence(); + return java.util.Arrays.asList(value.mode(),value.driverId(),value.name(),evidence==null?null:evidence.licenseNo(), + evidence==null?null:sourceFiles(evidence.fileIds()),evidence==null?null:evidence.masterLicenseFileId()); + } + /** 空附件与未提供附件等价,调整顺序不改变资料来源。 */ + private java.util.Set sourceFiles(List ids) { return ids==null?java.util.Set.of():java.util.Set.copyOf(ids); } + /** 理货通过后把已审核的本趟快照复制进装车资料;不读取后来变更的主档、不复制司机人像。 */ + public void populateLoading(long dispatchId,String operator,Resources value) { + var saved=loadingPhotos.read(dispatchId).photos(); + var driver=value.driver(); + if(driver!=null && driver.evidence()!=null && saved.stream().noneMatch(p->p.slot()==Slot.LOADING_SCENE)) { + var files=new ArrayList(); + for(var id:driver.evidence().fileIds().stream().distinct().limit(5).toList()) { + if(driver.mode()==MASTER && driver.driverId()!=null && Objects.equals(id,driver.evidence().masterLicenseFileId())) { + var source=fleet.dispatchDriverLicense(driver.driverId(),id); + files.add(new SnapshotFile(source.metadata().fileName(),source.metadata().contentType(),source.content())); + } else files.add(snapshot(dispatchId,DispatchEvidenceGateway.Kind.DRIVER,id)); + } + loadingPhotos.importIfEmpty(dispatchId,operator,Slot.LOADING_SCENE,files); + } + var vehicle=value.vehicle(); + if(vehicle!=null && saved.stream().noneMatch(p->p.slot()==Slot.LOADING_LIST)) { + var files=new ArrayList(); + for(var id:vehicle.evidenceFileIds().stream().distinct().toList()) { + var metadata=evidence.metadata(dispatchId,DispatchEvidenceGateway.Kind.VEHICLE,id); + if(!List.of("image/jpeg","image/png").contains(metadata.contentType()))continue; + files.add(new SnapshotFile(metadata.fileName(),metadata.contentType(),evidence.download(dispatchId,DispatchEvidenceGateway.Kind.VEHICLE,id))); + if(files.size()==5)break; + } + loadingPhotos.importIfEmpty(dispatchId,operator,Slot.LOADING_LIST,files); + } + } + /** 复制前再次核验本趟及证据分类,不把外部文件编号直接绑定为装车资料。 */ + private MultipartFile snapshot(long dispatchId,DispatchEvidenceGateway.Kind kind,long id) { + var metadata=evidence.metadata(dispatchId,kind,id); + return new SnapshotFile(metadata.fileName(),metadata.contentType(),evidence.download(dispatchId,kind,id)); + } /** 保存前补入已有档案照片;临时资源和已有当趟照片保持原样。 */ public Resources populate(long dispatchId,String operator,Resources value) { var v=value.vehicle(); var d=value.driver(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java index a962aa57b..72eec6693 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java @@ -28,15 +28,15 @@ public class HttpOwnedBusinessFileGateway implements OwnedBusinessFileGateway { client=factory.build(builder,baseUrl,properties.getCustomerVisitFile());this.token=token; } /** MIME和字节签名一起校验,File仍执行其权威校验。 */ - public static void validate(Upload upload) { + public static void validate(PhotoSlot slot,Upload upload) { if(upload==null || upload.fileName()==null || upload.fileName().isBlank() || upload.fileName().length()>255 || upload.content()==null || upload.content().length==0 || upload.content().length>MAX_SIZE - || !image(upload.contentType()) || !signature(upload.contentType(),upload.content())) - throw bad("照片须为非空真实JPG/PNG,最大10MiB"); + || !accepted(slot,upload.contentType()) || !signature(upload.contentType(),upload.content())) + throw bad("资料须为真实JPG/PNG;司机驾驶证也支持PDF,最大10MiB"); } /** 使用原图片字节,未知结果不能换键或自动重传。 */ @Override public Evidence upload(long dispatchId,PhotoSlot slot,String operatorId,Upload upload) { - validate(upload);var body=new MultipartBodyBuilder(); + validate(slot,upload);var body=new MultipartBodyBuilder(); body.part("file",new ByteArrayResource(upload.content()){ /** 保留本轮原文件名,不生成占位证据。 */ @Override public String getFilename(){return upload.fileName();} @@ -64,15 +64,16 @@ public class HttpOwnedBusinessFileGateway implements OwnedBusinessFileGateway { var m=response.data(); if(m.fileId()==null || m.fileId()<=0 || !Long.valueOf(0).equals(m.tenantId()) || !slot.businessType().equals(m.businessType()) || !String.valueOf(dispatchId).equals(m.businessId()) || expected!=null && !expected.equals(m.fileId()) - || m.sizeBytes()==null || m.sizeBytes()<=0 || m.sizeBytes()>MAX_SIZE || !image(m.contentType()) + || m.sizeBytes()==null || m.sizeBytes()<=0 || m.sizeBytes()>MAX_SIZE || !accepted(slot,m.contentType()) || m.fileName()==null || m.fileName().isBlank() || m.createdAt()==null)throw bad("照片不属于本排车或分类,或文件格式无效"); return new Evidence(m.fileId(),m.fileName(),m.contentType(),m.sizeBytes(),m.createdAt().toString()); } - /** 不放开PDF、SVG或任意image类型。 */ - private static boolean image(String type){return Set.of("image/jpeg","image/png").contains(type==null?"":type);} + /** 仅驾驶证类别接受PDF,其他装车及油耗类别仍只接受JPG/PNG。 */ + private static boolean accepted(PhotoSlot slot,String type){return Set.of("image/jpeg","image/png").contains(type==null?"":type) || slot==PhotoSlot.LOADING_SCENE && "application/pdf".equals(type);} /** 使用真实文件头,不转换或伪造证据图片。 */ private static boolean signature(String type,byte[] bytes) { if(bytes==null)return false; + if("application/pdf".equals(type))return bytes.length>=5 && bytes[0]=='%' && bytes[1]=='P' && bytes[2]=='D' && bytes[3]=='F' && bytes[4]=='-'; if("image/jpeg".equals(type))return bytes.length>=3 && (bytes[0]&255)==255 && (bytes[1]&255)==216 && (bytes[2]&255)==255; byte[] png={(byte)137,80,78,71,13,10,26,10};if(!"image/png".equals(type) || bytes.length files); + /** 已持排车父锁;资源变化使对应当前资料失效,旧文件与版本历史保留。 */ + void invalidateResourcePhotos(long dispatchId,String operator,java.util.Set slots); /** 上传保存原请求意图;不提前计入已保存照片。 */ Photo upload(long dispatchId,String operator,Slot slot,String key,MultipartFile file); /** 仅原操作者可恢复原上传结果。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java index ba1ec9b42..7f9320fb3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java @@ -31,6 +31,38 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort { this.repository=repository;this.commands=commands;this.projection=projection;this.codec=codec;this.files=files; } public record Binding(Slot slot,BindPhoto request) {} + /** 与资源修改同事务失效旧凭证;不变类别及历史保留,旧资料版本不得继续保存。 */ + @Override @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED) + public void invalidateResourcePhotos(long id,String operator,Set slots) { + if(!Set.of(Slot.LOADING_SCENE,Slot.LOADING_LIST).containsAll(slots))throw bad("仅可失效车辆或司机资料"); + var row=repository.record(id); + if(row==null || slots.isEmpty())return; + row=repository.lock(row); + var current=projection.photos(row); + var kept=current.stream().filter(photo->slots.stream().noneMatch(slot->photo.slot()==slot(slot))).toList(); + if(kept.size()==current.size())return; + long version=row.getRecordVersion(); + row.setPhotosJson(codec.write(kept));row.setRecordVersion(Math.addExact(version,1)); + row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now())); + if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败"); + var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion()); + history.setAction("INVALIDATE_RESOURCE_PHOTOS");history.setOperatorId(operator);history.setReason("车辆或司机资料变化,原凭证失效");history.setOccurredAt(row.getUpdatedAt()); + history.setSnapshotJson(codec.write(new OwnedBusinessModels.Snapshot(projection.operations(row),kept,operator)));repository.history(history); + } + /** 自动带入只填空类;重复审核及已有现场照片不重复追加或覆盖。 */ + @Override @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED) + public void importIfEmpty(long id,String operator,Slot slot,List sources) { + var state=read(id); + if(state.photos().stream().anyMatch(photo->photo.slot()==slot) || sources.isEmpty())return; + if(sources.size()>5)throw bad("每类装车资料最多5份"); + for(var source:sources) { + try { + var payload=new OwnedBusinessFileGateway.Upload(source.getOriginalFilename(),source.getContentType(),source.getBytes()); + var uploaded=files.upload(id,slot(slot),operator,payload); + state=bind(id,operator,slot,new BindPhoto(state.recordVersion(),UUID.randomUUID().toString(),Long.toString(uploaded.fileId()))); + } catch(IOException failure) { throw bad("读取本趟资料失败,未完成带入"); } + } + } /** GET只读;首次保存前资料版本为0。 */ @Override public State read(long id) { var row=repository.record(id); @@ -43,7 +75,7 @@ public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort { if(multipart==null || multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB"); byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException failure){throw bad("无法读取待上传照片");} var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes); - HttpOwnedBusinessFileGateway.validate(payload); + HttpOwnedBusinessFileGateway.validate(originalSlot,payload); var identity=new OwnedBusinessPhotoService.UploadIdentity(originalSlot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes)); var reservation=commands.reserveUpload(id,operator(operator),key,identity); if(!reservation.created())return uploaded(reservation.command()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java index 0a6074143..de21ab92f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java @@ -43,7 +43,7 @@ public class OwnedBusinessPhotoService { if(multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB"); byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException error){throw bad("无法读取待上传照片");} var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes); - HttpOwnedBusinessFileGateway.validate(payload); + HttpOwnedBusinessFileGateway.validate(slot,payload); var identity=new UploadIdentity(slot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes)); var reservation=commands.reserveUpload(source.getId(),actor,key,identity); if(!reservation.created()) { diff --git a/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml index c791d967c..8205cab9c 100644 --- a/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: 排车装车资料(Admin/PDA共用) version: 1.0.0 - description: LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位沿用原文件归属,按顺序对应司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;每类1至5张,允许追加或指定replaceFileId精确替换,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。 + description: 理货审核通过时,将本趟已核验驾驶证和车辆照片复制到空的司机驾驶证、车牌照片类别(每类最多5份),来源未变的已有照片保持不变;保存车辆、司机或证照来源变更时,对应类别旧资料与资源保存同事务失效并保留历史,重审通过后带入新资料;PDF驾驶证保留原件并计为一份。GET只读,不对存量记录补写。LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位沿用原文件归属,按顺序对应司机驾驶证、车牌照片、装车完成、封车照片、BOL司机签字;每类1至5张,允许追加或指定replaceFileId精确替换,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。 security: - bearerAuth: [] paths: @@ -84,7 +84,7 @@ paths: file: type: string format: binary - description: 非空JPG/PNG,最大10MiB + description: 非空JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,最大10MiB idempotencyKey: type: string minLength: 8 @@ -124,7 +124,7 @@ paths: file: type: string format: binary - description: 非空JPG/PNG,最大10MiB + description: 非空JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,最大10MiB idempotencyKey: type: string minLength: 8 @@ -204,6 +204,10 @@ paths: schema: type: string format: binary + application/pdf: + schema: + type: string + format: binary image/png: schema: type: string @@ -225,6 +229,10 @@ paths: schema: type: string format: binary + application/pdf: + schema: + type: string + format: binary image/png: schema: type: string diff --git a/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml index 0a1cce063..58a4d0894 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml @@ -75,7 +75,7 @@ paths: type: object required: [file, idempotencyKey] properties: - file: { type: string, format: binary, description: "非空JPG/PNG,签名校验,最大10MiB;固定装车File分类" } + file: { type: string, format: binary, description: "非空JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,签名校验,最大10MiB;固定装车File分类" } idempotencyKey: { type: string, minLength: 8, maxLength: 128 } responses: "200": @@ -110,6 +110,7 @@ paths: description: "原始照片字节,Cache-Control=no-store,X-Content-Type-Options=nosniff;无引用不允许探测。" content: image/png: { schema: { type: string, format: binary } } + application/pdf: { schema: { type: string, format: binary } } image/jpeg: { schema: { type: string, format: binary } } /api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/commands/{idempotencyKey}: parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ] @@ -202,7 +203,7 @@ components: category: { type: string, enum: [LOADING] } fileId: { type: string } fileName: { type: string } - contentType: { type: string, enum: [image/png, image/jpeg] } + contentType: { type: string, enum: [image/png, image/jpeg, application/pdf] } sizeBytes: { type: string } uploadedAt: { type: string, format: date-time } savedAt: { type: string, format: date-time, nullable: true } diff --git a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml index c2171cddb..201a973f8 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml @@ -255,7 +255,7 @@ paths: file: type: "string" format: "binary" - description: "真实JPG/PNG,非空,最大10MiB;与独立绑定命令使用不同请求键。" + description: "真实JPG/PNG,司机驾驶证(LOADING_SCENE)也支持PDF,非空,最大10MiB;与独立绑定命令使用不同请求键。" "/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/photos/{slot}": parameters: - @@ -334,6 +334,8 @@ paths: schema: type: "string" format: "binary" + "application/pdf": + schema: { type: string, format: binary } "image/png": schema: type: "string" @@ -642,6 +644,7 @@ components: enum: - "image/jpeg" - "image/png" + - "application/pdf" sizeBytes: type: "string" pattern: "^[1-9][0-9]*$" diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index 864c60a93..911417afe 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -642,7 +642,10 @@ public class DispatchAggregatePersistenceTest { org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",dispatchEvidence); var profilePhotos=mock(com.baiyue.lastleg.fleet.photo.FleetResourcePhotoService.class); when(profilePhotos.current(any(),anyLong())).thenReturn(new com.baiyue.lastleg.fleet.photo.FleetResourcePhotoModels.Detail(0,null)); - org.springframework.test.util.ReflectionTestUtils.setField(service,"masterPhotos",new DispatchMasterPhotoService(profilePhotos,dispatchEvidence)); + var masterPhotos=org.mockito.Mockito.spy(new DispatchMasterPhotoService(profilePhotos,dispatchEvidence)); + org.springframework.test.util.ReflectionTestUtils.setField(masterPhotos,"loadingPhotos",mock(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.class)); + org.mockito.Mockito.doNothing().when(masterPhotos).populateLoading(anyLong(),anyString(),any()); + org.springframework.test.util.ReflectionTestUtils.setField(service,"masterPhotos",masterPhotos); org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership); org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service); org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java index 51e0a1ce8..e05023b01 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java @@ -30,6 +30,68 @@ import static org.mockito.ArgumentMatchers.*; /** 装车后五框门禁、保存发车共同事务及跨端竞争使用真实单据和版本。 */ class DispatchLoadingDeparturePersistenceTest { + /** 零扫码退回后换资源,旧凭证失效与资源保存原子提交;重审自动带入新资料供两端共用。 */ + @Test void changedResourcesInvalidateOldDocumentsAndReapprovalImportsNewEvidence() throws Exception { + authorize();var f=new Fixture(false);long id=f.f.repository.find(f.number).getId(); + for(int i=0;i<5;i++)f.bind(i,f.f.actor); + var old=f.photos.read(id);assertThat(DispatchLoadingDocumentService.complete(old.photos())).isTrue(); + var master=(DispatchMasterPhotoService)org.springframework.test.util.ReflectionTestUtils.getField(f.f.service,"masterPhotos"); + org.springframework.test.util.ReflectionTestUtils.setField(master,"loadingPhotos",f.photos); + doCallRealMethod().when(master).populateLoading(anyLong(),anyString(),any()); + var evidence=(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.f.service,"evidence"); + when(evidence.download(anyLong(),any(),anyLong())).thenReturn(new byte[]{1,2,3}); + var nextId=new java.util.concurrent.atomic.AtomicLong(3000); + when(f.files.upload(eq(id),any(),eq(f.f.actor.operatorId()),any())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence(nextId.incrementAndGet(),"new.png","image/png",3,"2026-10-26T00:00:00Z")); + var returned=f.f.tx.execute(s->f.f.service.returnToReview(f.f.actor,f.number,new DispatchCommands.ReturnToReview(f.version,"return-change-resources","更换车辆司机"))); + var input=f.f.configured("new-resources",List.of());var evidenceInput=input.driver().evidence(); + var vehicle=new DispatchCommands.Vehicle(ResourceMode.EXTERNAL,null,"NEW-TRUCK","新承运商",input.vehicle().capacity(),List.of(111L)); + var driver=new DispatchCommands.Driver(ResourceMode.EXTERNAL,null,"新司机","987654321",new DriverEvidence("NEW-LICENSE",evidenceInput.licenseExpiresOn(),List.of(222L),evidenceInput.expectedTaskEndAt(),List.of(333L))); + var request=new DispatchCommands.Update(returned.recordVersion(),"change-resources",vehicle,driver,input.plannedDepartureAt(),null,null,null); + var previousResources=f.f.repository.find(f.number).getResourcesJson(); + assertThatThrownBy(()->f.f.tx.execute(s->{f.f.service.update(f.f.actor,f.number,request);throw new IllegalStateException("模拟后续保存失败");})).hasMessage("模拟后续保存失败"); + assertThat(f.photos.read(id)).isEqualTo(old); + assertThat(f.f.repository.find(f.number).getResourcesJson()).isEqualTo(previousResources); + var updated=f.f.tx.execute(s->f.f.service.update(f.f.actor,f.number,request)); + var invalidated=f.photos.read(id);assertThat(invalidated.recordVersion()).isEqualTo("6"); + assertThat(invalidated.photos()).containsExactlyElementsOf(old.photos().subList(2,5)); + assertThat(DispatchLoadingDocumentService.complete(invalidated.photos())).isFalse(); + assertThat(f.f.tx.execute(s->f.f.service.update(f.f.actor,f.number,request)).replayed()).isTrue(); + assertThat(f.photos.read(id)).isEqualTo(invalidated); + assertThat(f.f.db.queryForObject("SELECT snapshot_json FROM ll_fleet_owned_business_history WHERE dispatch_id=? AND record_version=5",String.class,id)).contains("1000","1001"); + f.f.prepareAppointmentNumbers(f.number); + var submitted=f.f.tx.execute(s->f.f.service.submitReview(f.f.actor,f.number,new DispatchCommands.SubmitReview(updated.recordVersion(),"resubmit-changed",null))); + var reviewed=f.f.tx.execute(s->f.f.service.review(f.f.actor,f.number,false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch-changed",Decision.APPROVED,null,null))); + f.f.tx.execute(s->f.f.service.review(f.f.actor,f.number,true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"loading-changed",Decision.APPROVED,null,null))); + var admin=f.documents.get(f.f.actor,f.number);var pda=f.documents.get(new Actor("picker",ActorMode.PDA,"9"),f.number); + assertThat(admin.photos()).isEqualTo(pda.photos()).hasSize(5);assertThat(admin.complete()).isTrue(); + assertThat(admin.photos().stream().map(p->p.fileId())).containsExactlyInAnyOrder("3001","3002","1002","1003","1004"); + verify(evidence).download(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,222L); + verify(evidence).download(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,111L); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,Slot.LOADING_SCENE,new BindPhoto("5","stale-before-change","1000")))).hasMessageContaining("其他操作更新"); + f.f.tx.execute(s->f.f.service.review(f.f.actor,f.number,true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"loading-changed",Decision.APPROVED,null,null))); + verify(f.files,times(2)).upload(anyLong(),any(),anyString(),any()); + } + @Test void importedFilesAreSharedAcrossActorsAndCountTowardDepartureWithoutDuplicateCopies() throws Exception { + authorize();var f=new Fixture();long id=f.f.repository.find(f.number).getId(); + var image=new org.springframework.mock.web.MockMultipartFile("file","plate.png","image/png",new byte[]{1,2,3}); + when(f.files.upload(eq(id),any(),eq("operator"),any())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence( + call.getArgument(1)==com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot.LOADING_SCENE?2000:2001,"copied.png","image/png",3,"2026-10-26T00:00:00Z")); + f.f.tx.executeWithoutResult(s->{ + f.photos.importIfEmpty(id,"operator",Slot.LOADING_SCENE,List.of(image)); + f.photos.importIfEmpty(id,"operator",Slot.LOADING_LIST,List.of(image)); + }); + var admin=f.documents.get(f.f.actor,f.number); + var pda=f.documents.get(new Actor("picker",ActorMode.PDA,"9"),f.number); + assertThat(pda.photos()).isEqualTo(admin.photos()).hasSize(2); + assertThat(admin.complete()).isFalse();assertThat(admin.recordVersion()).isEqualTo("2"); + f.f.tx.executeWithoutResult(s->f.photos.importIfEmpty(id,"operator",Slot.LOADING_SCENE,List.of(image))); + verify(f.files,times(2)).upload(anyLong(),any(),anyString(),any()); + assertThat(f.photos.read(id).recordVersion()).isEqualTo("2"); + f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete-imported"))); + f.bind(2,f.f.actor);f.bind(3,f.f.actor); + assertThat(f.bind(4,new Actor("picker",ActorMode.PDA,"9")).stage()).isEqualTo("DELIVERING"); + verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString()); + } /** 每例恢复隔离身份,不影响其他服务权限测试。 */ @AfterEach void clearSecurity(){SecurityContextHolder.clearContext();} @@ -221,11 +283,15 @@ class DispatchLoadingDeparturePersistenceTest { final DispatchPdaWorkflowService sourceWorkflow=mock(DispatchPdaWorkflowService.class); final DispatchLoadingDepartureService service;final DispatchLoadingDocumentService documents;final String number;final long version;final String manifest; final List measurements; + final OwnedBusinessFileGateway files=mock(OwnedBusinessFileGateway.class); + final DispatchLoadingPhotoAdapter photos; /** 创建两客户货物并走真实两级审核、真实拣货归属,不伪造已装车状态。 */ - Fixture() throws Exception { + Fixture() throws Exception { this(true); } + /** 零扫码夹具用于退回重审;其余用例继续真实拣货。 */ + Fixture(boolean picked) throws Exception { var created=f.tx.execute(s->f.service.create(f.actor,f.configured("create",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"202",502,"LAX9",5))))); number=created.dispatchNo();version=f.approve(created).recordVersion(); - f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8"); + if(picked) {f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8");} f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0"); var preview=f.tx.execute(s->f.service.loading(f.actor,number));manifest=preview.manifestHash(); measurements=preview.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,BigDecimal.ONE,BigDecimal.ONE,p.deliveryIndex())).toList(); @@ -259,9 +325,9 @@ class DispatchLoadingDeparturePersistenceTest { f.db.execute(matcher.group().replace(" JSON"," LONGTEXT").replace(" COLLATE utf8mb4_bin","").replaceFirst("(?s) ENGINE=InnoDB.*?;$",";")); } var records=new OwnedBusinessRepository(f.session.getMapper(OwnedBusinessMapper.class),mock(OwnedBusinessQueryMapper.class)); - var codec=new OwnedBusinessSupport(f.json);var files=mock(OwnedBusinessFileGateway.class); + var codec=new OwnedBusinessSupport(f.json); when(files.metadata(anyLong(),any(),anyLong())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence(call.getArgument(2),"loading.png","image/png",12,"2026-10-21T00:00:00Z")); - var photos=new DispatchLoadingPhotoAdapter(records,new OwnedBusinessCommandService(records,codec),new OwnedBusinessProjection(codec),codec,files); + photos=new DispatchLoadingPhotoAdapter(records,new OwnedBusinessCommandService(records,codec),new OwnedBusinessProjection(codec),codec,files); service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes,photos); documents=new DispatchLoadingDocumentService(f.service,service,photos); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoServiceTest.java index 11822f660..d7b4b0e65 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMasterPhotoServiceTest.java @@ -14,6 +14,56 @@ import static org.mockito.Mockito.*; /** 自动带入不复用跨排车文件ID,也不覆盖已有当趟照片。 */ class DispatchMasterPhotoServiceTest { + /** 只失效来源变化的类别;相同来源重审、无关资料修改和文件重排保留现场替换。 */ + @Test void resourceChangesInvalidateOnlyAffectedCategories() { + var loading=mock(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.class); + org.springframework.test.util.ReflectionTestUtils.setField(service,"loadingPhotos",loading); + var vehicle=new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST",null,"IN_SERVICE",null,List.of(101L,102L)); + var driver=new DriverSnapshot(ResourceMode.MASTER,2L,"司机","电话",new DriverEvidence("LICENSE",null,List.of(8L),null,List.of(90L),8L)); + var original=new Resources(vehicle,driver,null); + service.invalidateLoading(9,"op",original,new Resources(new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST","新承运商","IN_SERVICE",new Capacity(10,null,null),List.of(102L,101L)), + new DriverSnapshot(ResourceMode.MASTER,2L,"司机","新电话",new DriverEvidence("LICENSE",null,List.of(8L),java.time.Instant.now(),List.of(91L),8L)),null)); + verifyNoInteractions(loading); + var changedVehicle=new VehicleSnapshot(ResourceMode.MASTER,3L,"OTHER",null,"IN_SERVICE",null,List.of(103L)); + service.invalidateLoading(9,"op",original,new Resources(changedVehicle,driver,null)); + verify(loading).invalidateResourcePhotos(9,"op",java.util.Set.of(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_LIST)); + clearInvocations(loading); + var renewedDriver=new DriverSnapshot(ResourceMode.MASTER,2L,"司机","电话",new DriverEvidence("LICENSE",null,List.of(9L),null,List.of(90L),9L)); + service.invalidateLoading(9,"op",original,new Resources(vehicle,renewedDriver,null)); + verify(loading).invalidateResourcePhotos(9,"op",java.util.Set.of(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_SCENE)); + clearInvocations(loading); + service.invalidateLoading(9,"op",original,new Resources(null,null,null)); + verify(loading).invalidateResourcePhotos(9,"op",java.util.Set.of(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_SCENE, + com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_LIST)); + } + @Test void importsFrozenLicenseAndVehicleIntoSharedLoadingCategoriesWithoutDriverPortrait() throws Exception { + var loading=mock(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.class); + var fleet=mock(com.baiyue.lastleg.fleet.trip.FleetTripExecutionService.class); + org.springframework.test.util.ReflectionTestUtils.setField(service,"loadingPhotos",loading); + org.springframework.test.util.ReflectionTestUtils.setField(service,"fleet",fleet); + when(loading.read(9)).thenReturn(new com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.State("0",List.of())); + var license=new com.baiyue.lastleg.fleet.driver.DriverModels.File("8","license.pdf","application/pdf",8); + when(fleet.dispatchDriverLicense(2,8)).thenReturn(new com.baiyue.lastleg.fleet.driver.DriverModels.Download(license,"%PDF-1.4".getBytes())); + when(evidence.metadata(9,DispatchEvidenceGateway.Kind.VEHICLE,101)).thenReturn(new DispatchEvidenceGateway.File(101L,"plate.png","image/png",3)); + when(evidence.download(9,DispatchEvidenceGateway.Kind.VEHICLE,101)).thenReturn(new byte[]{1,2,3}); + var resources=new Resources(new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST",null,"IN_SERVICE",null,List.of(101L)), + new DriverSnapshot(ResourceMode.MASTER,2L,"司机","电话",new DriverEvidence(null,null,List.of(8L),null,List.of(102L),8L)),null); + service.populateLoading(9,"op",resources); + @SuppressWarnings("unchecked") var files=(ArgumentCaptor>)(ArgumentCaptor)ArgumentCaptor.forClass(List.class); + verify(loading).importIfEmpty(eq(9L),eq("op"),eq(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_SCENE),files.capture()); + assertThat(files.getValue()).hasSize(1);assertThat(files.getValue().get(0).getBytes()).isEqualTo("%PDF-1.4".getBytes()); + verify(loading).importIfEmpty(eq(9L),eq("op"),eq(com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.LOADING_LIST),files.capture()); + assertThat(files.getValue().get(0).getOriginalFilename()).isEqualTo("plate.png"); + verify(evidence,never()).download(anyLong(),eq(DispatchEvidenceGateway.Kind.DRIVER_PHOTO),anyLong()); + verifyNoInteractions(photos); + var slots=com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot.values(); + var saved=java.util.Arrays.stream(slots).map(slot->new com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Photo(slot,"LOADING","900"+slot.ordinal(),"saved.png","image/png","3",null,null)).toList(); + when(loading.read(9)).thenReturn(new com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.State("5",saved)); + clearInvocations(loading,fleet,evidence); + service.populateLoading(9,"op",resources); + verify(loading,never()).importIfEmpty(anyLong(),anyString(),any(),anyList()); + verifyNoInteractions(fleet,evidence); + } final FleetResourcePhotoService photos=mock(FleetResourcePhotoService.class); final DispatchEvidenceGateway evidence=mock(DispatchEvidenceGateway.class); final DispatchMasterPhotoService service=new DispatchMasterPhotoService(photos,evidence); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java index 8bd3e1731..e4b118ed4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java @@ -11,6 +11,23 @@ import static org.assertj.core.api.Assertions.*; /** 审核两端共用真实持久主单,驳回回客服且不能复用上一轮批准。 */ class DispatchReviewWorkflowPersistenceTest { + @Test void approvedLoadingReviewImportsOnceAndCopyFailureRollsBackApproval() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("copy-create",List.of(f.source(1,"101",501,"ONT8",3))))); + var submitted=submit(f,created,"copy-submit"); + var approved=review(f,f.actor,submitted,false,"copy-dispatch",Decision.APPROVED,null); + var master=org.mockito.Mockito.mock(DispatchMasterPhotoService.class); + org.springframework.test.util.ReflectionTestUtils.setField(f.service,"masterPhotos",master); + org.mockito.Mockito.doThrow(new IllegalStateException("文件服务不可用")).doNothing().when(master).populateLoading(org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any()); + assertThatThrownBy(()->review(f,f.actor,approved,true,"copy-loading",Decision.APPROVED,null)).hasMessageContaining("文件服务不可用"); + assertThat(f.repository.find(created.dispatchNo()).getStage()).isEqualTo("BEFORE_LOADING"); + assertThat(f.repository.find(created.dispatchNo()).getReviewState()).isEqualTo("LOADING_PENDING"); + var loaded=review(f,f.actor,approved,true,"copy-loading",Decision.APPROVED,null); + assertThat(loaded.stage()).isEqualTo(Stage.LOADING); + org.mockito.Mockito.clearInvocations(master); + assertThat(review(f,f.actor,approved,true,"copy-loading",Decision.APPROVED,null).recordVersion()).isEqualTo(loaded.recordVersion()); + org.mockito.Mockito.verifyNoInteractions(master); + } /** 取货复用同轮两级审核与驳回重提,不创建仓内拣货任务。 */ @Test void pickupUsesBothReviewsAndRejectsSkippingOrStaleApproval() throws Exception { var f=new DispatchAggregatePersistenceTest.Fixture(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFileGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFileGatewayTest.java index 90b749a63..352720f4d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFileGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFileGatewayTest.java @@ -11,9 +11,18 @@ import static org.junit.jupiter.api.Assertions.*; class OwnedBusinessFileGatewayTest { /** 图片文本伪装、PDF及空内容均须在File调用前拒绝。 */ @Test void invalidImageSignaturesAndMimeAreRejected() { - assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.png","image/png","not-image".getBytes()))); - assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.pdf","application/pdf","%PDF".getBytes()))); - assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.jpg","image/jpeg",new byte[0]))); + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(PhotoSlot.LOADING_SCENE,new OwnedBusinessFileGateway.Upload("x.png","image/png","not-image".getBytes()))); + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(PhotoSlot.LOADING_SCENE,new OwnedBusinessFileGateway.Upload("x.pdf","application/pdf","%PDF".getBytes()))); + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(PhotoSlot.LOADING_SCENE,new OwnedBusinessFileGateway.Upload("x.jpg","image/jpeg",new byte[0]))); + } + @Test void onlyDriverLicenseAcceptsPdfAndChecksMetadataScope() { + var pdf=new OwnedBusinessFileGateway.Upload("license.pdf","application/pdf","%PDF-1.4".getBytes()); + assertDoesNotThrow(()->HttpOwnedBusinessFileGateway.validate(PhotoSlot.LOADING_SCENE,pdf)); + for(var slot:PhotoSlot.values())if(slot!=PhotoSlot.LOADING_SCENE) + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(slot,pdf)); + var meta=new HttpOwnedBusinessFileGateway.Metadata(9L,0L,"OVERSEAS_DISPATCH_LOADING_PHOTO","1","license.pdf","application/pdf",8L,LocalDateTime.now()); + assertEquals(9,HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",meta),1,PhotoSlot.LOADING_SCENE,9L).fileId()); + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",meta),1,PhotoSlot.LOADING_LIST,9L)); } /** 同一原dispatch内部ID、平台0与分类都匹配才接受元数据。 */ @Test void metadataCannotCrossCustomerDispatchOrCategory() {