From 6fe632f28eade0fdeec5d12d1da0d30e3c42df3b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 15:10:44 +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 | 3 +- .../openapi/dispatch-evidence-v1.yaml | 6 +- .../service/DispatchEvidenceFileTest.java | 19 +++- .../DispatchAggregateController.java | 4 +- .../gateway/DispatchEvidenceGateway.java | 4 +- .../gateway/HttpDispatchEvidenceGateway.java | 8 +- .../dispatch/port/DispatchCommands.java | 6 +- .../dispatch/port/DispatchContracts.java | 22 +++- .../service/DispatchAggregateService.java | 29 +++-- .../transport/TransportTaskProjection.java | 2 +- .../openapi/overseas-business-v1.yaml | 53 ++++++++- .../HttpDispatchEvidenceGatewayTest.java | 8 ++ .../DispatchAggregatePersistenceTest.java | 20 ++-- .../DispatchPdaReadPersistenceTest.java | 4 +- ...patchReviewAttachmentsPersistenceTest.java | 104 ++++++++++++++++++ ...DispatchReviewWorkflowPersistenceTest.java | 2 +- .../pda/PdaOutboundDispatchContractTest.java | 2 +- scripts/admin-test-env-modules/README.md | 4 +- .../fee-items-contract.test.mjs | 12 +- scripts/admin-test-env-modules/fee-items.mjs | 29 +++-- .../inbound-measurement.mjs | 4 +- .../inbound-weight-ocr.mjs | 4 +- .../it-test-wait-split-bulk-contract.test.mjs | 18 ++- .../phase3-carrier-schedule-queries.test.mjs | 3 +- .../phase3-warehouse-rbac.mjs | 5 +- ...canonical-warehouse-rbac-contract.test.mjs | 2 +- ...phase4-overseas-business-contract.test.mjs | 7 +- .../phase4-overseas-business.mjs | 58 +++++----- .../sailing-calendar.mjs | 3 +- ...ema-alignment-migrations-contract.test.mjs | 16 ++- scripts/fleet-trip-rollback-test-env.test.mjs | 10 +- scripts/java-quality-baseline.json | 2 +- scripts/java-quality-summary.test.mjs | 6 +- .../publish-nacos-config-contract.test.mjs | 3 +- 34 files changed, 372 insertions(+), 110 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewAttachmentsPersistenceTest.java 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 ed10c57fe..59407b4ee 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -11,6 +11,7 @@ enum BusinessFileCategory { FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_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_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), @@ -78,7 +79,7 @@ enum BusinessFileCategory { && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台"); } - if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE) + if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE) && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台"); } diff --git a/file/src/main/resources/openapi/dispatch-evidence-v1.yaml b/file/src/main/resources/openapi/dispatch-evidence-v1.yaml index e384ffb41..f0be4156b 100644 --- a/file/src/main/resources/openapi/dispatch-evidence-v1.yaml +++ b/file/src/main/resources/openapi/dispatch-evidence-v1.yaml @@ -4,9 +4,9 @@ info: version: 1.0.0 description: >- 复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。 - 两类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示, + 三类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示, 不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用 - OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE,不可互换。 + OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE,不可互换。司机本人照片DRIVER_PHOTO仅支持JPG/PNG;其他两类沿PDF/JPG/PNG。 File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、 PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId 联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。 @@ -126,7 +126,7 @@ components: description: 正数Long的十进制字符串,值不得超过9223372036854775807 DispatchEvidenceType: type: string - enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE] + enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE] MetadataEnvelope: type: object required: [code, data] diff --git a/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java b/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java index 2444e080c..be8d7154d 100644 --- a/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java +++ b/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java @@ -30,6 +30,7 @@ import static org.mockito.Mockito.*; class DispatchEvidenceFileTest { private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE"; + private static final String PHOTO = "OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE"; private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE"; private static final int MAX_BYTES = 10 * 1024 * 1024; @TempDir Path root; @@ -40,9 +41,9 @@ class DispatchEvidenceFileTest { FileObjectEntity.class); } - /** 两类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */ + /** 三类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */ @ParameterizedTest - @ValueSource(strings = {VEHICLE, DRIVER}) + @ValueSource(strings = {VEHICLE, DRIVER, PHOTO}) void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) { var files = mock(FileObjectMapper.class); var storage = mock(BusinessFileStorage.class); @@ -64,6 +65,18 @@ class DispatchEvidenceFileTest { verifyNoInteractions(files, storage); } + /** 司机本人照片使用独立平台类别,只保存真实图片,拒绝PDF和伪造图片。 */ + @org.junit.jupiter.api.Test void driverPhotoAcceptsOnlyRealImages() throws Exception { + var storage=storage();var category=BusinessFileCategory.require(PHOTO); + for(var format:new String[]{"jpeg","png"}) { + var upload=new MockMultipartFile("file","driver."+format,"image/"+format,image(format)); + var saved=storage.store(0,"71",upload,category); + assertThat(storage.read(saved.objectKey())).isEqualTo(upload.getBytes()); + } + assertThatThrownBy(()->storage.store(0,"71",pdf(),category)).isInstanceOf(BizException.class); + assertThatThrownBy(()->storage.store(0,"71",new MockMultipartFile("file","driver.png","image/png",pdf().getBytes()),category)).isInstanceOf(BizException.class); + } + /** 真实 PDF、JPG、PNG 均可写入并保持内容;上传不以扩展名代替 MIME 和签名。 */ @ParameterizedTest @ValueSource(strings = {VEHICLE, DRIVER}) @@ -149,7 +162,7 @@ class DispatchEvidenceFileTest { /** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */ @ParameterizedTest - @ValueSource(strings = {VEHICLE, DRIVER}) + @ValueSource(strings = {VEHICLE, DRIVER, PHOTO}) void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception { var files = mock(FileObjectMapper.class); var storage = mock(BusinessFileStorage.class); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java index a5909f6d6..782f4583b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java @@ -77,9 +77,9 @@ public class DispatchAggregateController { public ApiResponse loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) { return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request)); } - /** 本车证据先上传,主单引用随后走版本化编辑。 */ + /** 本车证据先上传;维护和当前待审权限分别由服务端核验,引用随对应命令保存。 */ @PostMapping(value="/{dispatchNo}/evidence/{kind}",consumes=org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) - @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + @PreAuthorize("hasAnyAuthority('platform:overseas-warehouse:dispatch:edit','platform:fleet:trip:dispatch-review','platform:fleet:trip:loading-review')") public ApiResponse uploadEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, @PathVariable com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,@RequestPart("file")org.springframework.web.multipart.MultipartFile file) { return ApiResponse.success(service.uploadEvidence(DispatchActorFactory.platform(user),dispatchNo,kind,file)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java index bedd86d03..d901f837d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java @@ -2,9 +2,9 @@ package com.baiyue.lastleg.dispatch.gateway; import org.springframework.web.multipart.MultipartFile; -/** 临时车辆和司机证据属于本张整车,不能借司机主档或客户文件授权。 */ +/** 车辆、司机及审核补充证据属于本张整车,不能借司机主档或客户文件授权。 */ public interface DispatchEvidenceGateway { - enum Kind { VEHICLE, DRIVER } + enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO } record File(Long fileId,String fileName,String contentType,long sizeBytes) { } /** 上传只写本车、本类证据。 */ File upload(long dispatchId,Kind kind,String operator,MultipartFile file); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java index 9f2516f8e..0797486bc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java @@ -29,7 +29,7 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway { private static String type(Kind kind) { return "OVERSEAS_DISPATCH_"+Objects.requireNonNull(kind).name()+"_EVIDENCE"; } /** 本车证据上传完成后仍以File返回元数据核对复合归属。 */ @Override public File upload(long dispatch,Kind kind,String operator,MultipartFile file) { - if(file==null || file.isEmpty() || file.getSize()>MAX || !TYPES.contains(Objects.toString(file.getContentType(),"")))throw invalid(); + if(file==null || file.isEmpty() || file.getSize()>MAX || !supportedType(kind,file.getContentType()))throw invalid(); var body=new MultipartBodyBuilder(); try { body.part("file",new ByteArrayResource(file.getBytes()) { /** 保留用户文件名供审阅,不把路径当业务事实。 */ @@ -80,9 +80,13 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway { var value=envelope.data(); if(!Objects.equals(value.tenantId(),0L) || !type(kind).equals(value.businessType()) || !Long.toString(dispatch).equals(value.businessId()) || value.fileId()==null || value.fileId()<=0 || id!=null&&!id.equals(value.fileId()) || value.fileName()==null || value.fileName().isBlank() - || !TYPES.contains(Objects.toString(value.contentType(),"")) || value.sizeBytes()==null || value.sizeBytes()<=0 || value.sizeBytes()>MAX)throw invalid(); + || !supportedType(kind,value.contentType()) || value.sizeBytes()==null || value.sizeBytes()<=0 || value.sizeBytes()>MAX)throw invalid(); return new File(value.fileId(),value.fileName(),value.contentType(),value.sizeBytes()); } + /** 司机本人照片只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */ + private static boolean supportedType(Kind kind,String contentType) { + return TYPES.contains(Objects.toString(contentType,"")) && (kind!=Kind.DRIVER_PHOTO || !"application/pdf".equals(contentType)); + } /** 无效证据不能作为已核实资料。 */ private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");} /** File明确拒绝归属或文件格式属于业务错误,不能伪装成服务不可用。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java index 8b1ca3dee..a66d4b0f7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java @@ -49,9 +49,9 @@ public final class DispatchCommands { } public record ReviewCommand(Long recordVersion, String idempotencyKey, Decision decision, - String reason) { - /** 两级审核都执行相同的必填版本约束。 */ - public ReviewCommand { recordVersion = requireVersion(recordVersion); } + String reason, ReviewAttachments attachments) { + /** 两级审核都执行相同的必填版本约束,未传附件表示没有本次补充。 */ + public ReviewCommand { recordVersion = requireVersion(recordVersion); attachments = attachments == null ? ReviewAttachments.empty() : attachments; } } /** 只有零真实扫码时可退回,保留原单号与审核轮次历史。 */ 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 b3940213d..1326b540b 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 @@ -41,7 +41,27 @@ public final class DispatchContracts { String contactName, String contactPhone) { } public record Review(ReviewStatus status, String reason, String reviewerId, Instant reviewedAt, - long reviewRound, String basisHash, String invalidatedReason) { } + long reviewRound, String basisHash, String invalidatedReason, ReviewAttachments attachments) { + /** 无补充附件时返回明确空集合,旧决定不得从资源快照自动继承。 */ + public Review { attachments = attachments == null ? ReviewAttachments.empty() : attachments; } + } + + /** 本次审核补充证据按分类归属本车,不修改车辆或司机业务资料。 */ + public record ReviewAttachments(List vehicleFileIds, List driverPhotoFileIds, List licenseFileIds) { + /** 每类附件有界、不可变且不允许空身份或重复引用。 */ + public ReviewAttachments { + vehicleFileIds = checked(vehicleFileIds); driverPhotoFileIds = checked(driverPhotoFileIds); licenseFileIds = checked(licenseFileIds); + } + /** 未传附件表示本次没有补充证据。 */ + public static ReviewAttachments empty() { return new ReviewAttachments(List.of(), List.of(), List.of()); } + /** 只规范引用形状;本车和类别归属由审核服务逐个验证。 */ + private static List checked(List ids) { + if (ids == null) return List.of(); + if (ids.size() > 20 || ids.stream().anyMatch(id -> id == null || id <= 0) || ids.stream().distinct().count() != ids.size()) + throw new IllegalArgumentException("每类审核附件最多20个,文件引用须有效且不重复"); + return List.copyOf(ids); + } + } public record Reviews(Review dispatchReview, Review loadingReview) { } 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 0bf073c27..0d5ffbc31 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 @@ -235,10 +235,13 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA return saveCommand(row,actor,identity,request.reason()); } - /** 上传不修改主单引用;返回本车文件ID后由公共版本编辑命令保存选择。 */ + /** 上传只返回本车证据;待审按当前审核权限授权,客服阶段按维护权限授权。 */ public com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File uploadEvidence(Actor actor,String number, com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,org.springframework.web.multipart.MultipartFile file) { requirePlatform(actor);var row=required(actor,number,false);requireEditing(row,row.getRecordVersion()); + Action permission = ReviewState.DISPATCH_PENDING.name().equals(row.getReviewState()) ? Action.DISPATCH_REVIEW + : ReviewState.LOADING_PENDING.name().equals(row.getReviewState()) ? Action.LOADING_REVIEW : Action.EDIT; + if(!hasPermission(actor,permission))throw new BizException(ResultCode.FORBIDDEN,"无当前阶段附件上传权限"); return evidence.upload(row.getId(),kind,actor.operatorId(),file); } /** 列表权限含详情只读;仅冻结MASTER证照走司机归属,其余保持本车附件复合归属。 */ @@ -392,7 +395,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核"); long round=row.getReviewRound()+1;String basis=basis(row); row.setReviewRound(round);row.setReviewBasisHash(basis);row.setReviewState(ReviewState.DISPATCH_PENDING.name()); - row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null))); + row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null,null))); row.setLoadingReviewJson(write(pending(round))); return saveCommand(row,actor,identity,request.remark()); } @@ -420,20 +423,21 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var dispatch=reviews(row).dispatchReview(); if(dispatch.status()!=ReviewStatus.APPROVED || dispatch.reviewRound()!=row.getReviewRound() || !Objects.equals(dispatch.basisHash(),row.getReviewBasisHash()))throw conflict("调度审核尚未通过或依据已失效"); } + verifyReviewAttachments(row.getId(),request.attachments()); if(request.decision()==Decision.APPROVED)verifyReviewResources(row); - 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); + 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()); } 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):pending(row.getReviewRound()))); + row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null,null):pending(row.getReviewRound()))); } if(request.decision()==Decision.REJECTED) { // 任一级驳回均回客服处理,旧调度批准不再作为可继续理货的有效依据。 row.setStage(Stage.BEFORE_LOADING.name());row.setReviewBasisHash(null); if(loading)row.setDispatchReviewJson(write(new Review(ReviewStatus.NOT_SUBMITTED,null,null,null, - row.getReviewRound(),null,"理货审核驳回,请客服修改后重新提交调度审核"))); + row.getReviewRound(),null,"理货审核驳回,请客服修改后重新提交调度审核",reviews(row).dispatchReview().attachments()))); } return saveCommand(row,actor,identity,request.reason()); } @@ -622,7 +626,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */ private void invalidate(DispatchAggregate row,String reason) { row.setReviewState(ReviewState.DRAFT.name());row.setReviewBasisHash(null); - var invalid=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,row.getReviewRound(),null,reason); + var invalid=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,row.getReviewRound(),null,reason,null); var previous=reviews(row); row.setDispatchReviewJson(write(retainRejection(previous.dispatchReview(),invalid))); row.setLoadingReviewJson(write(retainRejection(previous.loadingReview(),invalid))); @@ -630,9 +634,16 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 客服改资料期间保留本轮驳回原因;下一轮提交统一重置,不复用批准。 */ private static Review retainRejection(Review previous,Review invalid) { return previous.status()==ReviewStatus.REJECTED - ?new Review(previous.status(),previous.reason(),previous.reviewerId(),previous.reviewedAt(),previous.reviewRound(),null,invalid.invalidatedReason()) - :invalid; + ?new Review(previous.status(),previous.reason(),previous.reviewerId(),previous.reviewedAt(),previous.reviewRound(),null,invalid.invalidatedReason(),previous.attachments()) + :new Review(invalid.status(),null,null,null,invalid.reviewRound(),null,invalid.invalidatedReason(),previous.attachments()); } + /** 审核附件逐个验证本车与种类;MASTER档案证照不能冒充本次上传证据。 */ + private void verifyReviewAttachments(long dispatch,ReviewAttachments attachments) { + for(var id:attachments.vehicleFileIds())evidence.metadata(dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,id); + for(var id:attachments.driverPhotoFileIds())evidence.metadata(dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,id); + for(var id:attachments.licenseFileIds())evidence.metadata(dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,id); + } + /** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */ void verifyReviewResources(DispatchAggregate row) { var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver(); @@ -923,7 +934,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 当前审核记录,不以版本相等代替轮次有效性。 */ Reviews reviews(DispatchAggregate row) { return new Reviews(read(row.getDispatchReviewJson(),Review.class),read(row.getLoadingReviewJson(),Review.class)); } /** 未提交的审核不伪造待审核。 */ - private static Review pending(long round) { return new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,round,null,null); } + private static Review pending(long round) { return new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,round,null,null,null); } /** 整车真实统计。 */ private static Counts counts(DispatchAggregate row) { return new Counts(n(row.getItemCount()),n(row.getPalletCount()),n(row.getPackageCount()),n(row.getPickedPackageCount()),n(row.getShippedPackageCount())); } /** 空单永远不能核平为装车完成。 */ 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 831a9eb00..3566ebc32 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 @@ -36,7 +36,7 @@ public class TransportTaskProjection { row.setOverseasWarehouseCode(trip.getWarehouseCode());row.setOverseasWarehouseName(trip.getWarehouseName()); row.setStatus("ACTIVE");row.setStage("BEFORE_LOADING");row.setRecordVersion(trip.getRecordVersion());row.setCargoRevision(0L); row.setCargoHash(trip.getRequestHash());row.setReviewState("DRAFT");row.setReviewRound(0L); - var review=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,0,null,null); + var review=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,0,null,null,null); row.setDispatchReviewJson(write(review));row.setLoadingReviewJson(write(review)); resources(row,trip);row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt()); row.setRoute(("PICKUP".equals(trip.getTripPurpose())?"到仓取货":"本土卡车")+" · "+items+"单 · "+trip.getWarehouseCode());row.setItemCount(items);row.setPalletCount(pallets);row.setPackageCount(packages); diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 7cd0accd9..88b3221e9 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1880,7 +1880,7 @@ paths: /api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}: post: summary: 上传本趟车辆或司机证据 - description: 权限 platform:overseas-warehouse:dispatch:edit。PDF/JPG/PNG,非空且不超过10MiB;平台、本车、类型复合归属。上传后通过版本化EDIT保存引用。 + description: 客服阶段使用 platform:overseas-warehouse:dispatch:edit;调度待审/理货待审分别使用 platform:fleet:trip:dispatch-review / platform:fleet:trip:loading-review,无需维护车辆权限。VEHICLE和DRIVER支持PDF/JPG/PNG,DRIVER_PHOTO仅JPG/PNG,非空且不超过10MiB;平台、本车、类型复合归属。审核补充附件随ReviewCommand保存,不修改资源,不重启审核。 security: - bearerAuth: [] responses: @@ -1934,6 +1934,7 @@ paths: enum: - VEHICLE - DRIVER + - DRIVER_PHOTO /api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}/{fileId}: get: summary: 读取本车证据元数据 @@ -1979,6 +1980,7 @@ paths: enum: - VEHICLE - DRIVER + - DRIVER_PHOTO - name: fileId in: path required: true @@ -2021,6 +2023,7 @@ paths: enum: - VEHICLE - DRIVER + - DRIVER_PHOTO - name: fileId in: path required: true @@ -4151,7 +4154,10 @@ components: invalidatedReason: type: string nullable: true + attachments: + $ref: '#/components/schemas/DispatchReviewAttachments' required: + - attachments - status - reason - reviewerId @@ -5820,6 +5826,47 @@ components: required: - recordVersion - idempotencyKey + DispatchReviewAttachments: + type: object + description: 本次审核补充证据;省略或null为三组空数组。每类最多20个且不得重复;逐个验证本车和类别归属,不接受MASTER档案证照冒充本车审核上传。 + x-java-record: DispatchContracts.ReviewAttachments + properties: + vehicleFileIds: + type: array + maxItems: 20 + uniqueItems: true + description: 本车VEHICLE证据文件ID。 + items: + oneOf: + - type: string + pattern: ^[1-9][0-9]*$ + - type: integer + format: int64 + minimum: 1 + driverPhotoFileIds: + type: array + maxItems: 20 + uniqueItems: true + description: 本车DRIVER_PHOTO证据文件ID。 + items: + oneOf: + - type: string + pattern: ^[1-9][0-9]*$ + - type: integer + format: int64 + minimum: 1 + licenseFileIds: + type: array + maxItems: 20 + uniqueItems: true + description: 本车DRIVER证据文件ID。 + items: + oneOf: + - type: string + pattern: ^[1-9][0-9]*$ + - type: integer + format: int64 + minimum: 1 DispatchReviewCommand: type: object x-java-record: DispatchCommands.ReviewCommand @@ -5840,6 +5887,10 @@ components: reason: type: string nullable: true + attachments: + allOf: + - $ref: '#/components/schemas/DispatchReviewAttachments' + nullable: true required: - recordVersion - idempotencyKey diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGatewayTest.java index 8cad48d09..dd375b1b1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGatewayTest.java @@ -37,6 +37,14 @@ class HttpDispatchEvidenceGatewayTest { assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",own),9,DispatchEvidenceGateway.Kind.VEHICLE,3L)).isInstanceOf(ResponseStatusException.class); assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",own),9,DispatchEvidenceGateway.Kind.DRIVER,4L)).isInstanceOf(ResponseStatusException.class); } + /** 司机照片使用独立本车类别且禁止PDF,不与驾驶证证据串用。 */ + @Test void driverPhotoUsesDistinctKindAndImageOnly() { + var photo=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE","9","driver.png","image/png",128L); + assertThat(HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),9,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,3L).fileId()).isEqualTo(3L); + assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),9,DispatchEvidenceGateway.Kind.DRIVER,3L)).isInstanceOf(ResponseStatusException.class); + var pdf=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE","9","driver.pdf","application/pdf",128L); + assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",pdf),9,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,3L)).isInstanceOf(ResponseStatusException.class); + } /** 客户、旧主档或缺失媒体类型均拒绝,不因为文件本身存在而放行。 */ @Test void rejectsForeignTenantMasterScopeAndMalformedMetadata() { for(var value:java.util.List.of( 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 c9d17f466..d5dcc0e6f 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 @@ -340,12 +340,12 @@ public class DispatchAggregatePersistenceTest { assertSubmissionGate(f,created.dispatchNo(),ReviewState.DRAFT,true); var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submission-states-1",null))); assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false); - var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"submission-states-dispatch-rejected",Decision.REJECTED,"核对调度"))); + var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"submission-states-dispatch-rejected",Decision.REJECTED,"核对调度",null))); assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_REJECTED,true); var resubmitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"submission-states-2",null))); - var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"submission-states-dispatch-approved",Decision.APPROVED,null))); + var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"submission-states-dispatch-approved",Decision.APPROVED,null,null))); assertSubmissionGate(f,created.dispatchNo(),ReviewState.LOADING_PENDING,false); - var loadingRejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"submission-states-loading-rejected",Decision.REJECTED,"核对装载"))); + var loadingRejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"submission-states-loading-rejected",Decision.REJECTED,"核对装载",null))); assertSubmissionGate(f,created.dispatchNo(),ReviewState.LOADING_REJECTED,true); f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(loadingRejected.recordVersion(),"submission-states-3",null))); assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false); @@ -363,13 +363,13 @@ public class DispatchAggregatePersistenceTest { var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3); var created=f.tx.execute(s->f.service.create(f.actor,f.configured("review-create",List.of(source)))); var submit=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit-1",null))); - assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(submit.recordVersion(),"wrong-order",Decision.APPROVED,null)))).isInstanceOf(BizException.class); - var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划"))); + assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(submit.recordVersion(),"wrong-order",Decision.APPROVED,null,null)))).isInstanceOf(BizException.class); + var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划",null))); var resubmitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"submit-2",null))); - var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"dispatch-approved",Decision.APPROVED,null))); - var loading=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"loading-approved",Decision.APPROVED,null))); + var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"dispatch-approved",Decision.APPROVED,null,null))); + var loading=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"loading-approved",Decision.APPROVED,null,null))); assertThat(loading.stage()).isEqualTo(Stage.LOADING); - var replay=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划"))); + var replay=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划",null))); assertThat(replay.replayed()).isTrue();assertThat(replay.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(replay.recordVersion()).isEqualTo(rejected.recordVersion()); var returned=f.tx.execute(s->f.service.returnToReview(f.actor,created.dispatchNo(),new DispatchCommands.ReturnToReview(loading.recordVersion(),"return-zero","重新核实资源"))); assertThat(returned.stage()).isEqualTo(Stage.BEFORE_LOADING); @@ -598,8 +598,8 @@ public class DispatchAggregatePersistenceTest { /** 通过实际两级流程进入装车,测试不直接伪造通过状态。 */ public DispatchContracts.CommandResult approve(DispatchContracts.CommandResult created) { var submitted=tx.execute(s->service.submitReview(actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),created.dispatchNo()+"-submit",null))); - var reviewed=tx.execute(s->service.review(actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),created.dispatchNo()+"-dispatch",Decision.APPROVED,null))); - return tx.execute(s->service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),created.dispatchNo()+"-loading",Decision.APPROVED,null))); + var reviewed=tx.execute(s->service.review(actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),created.dispatchNo()+"-dispatch",Decision.APPROVED,null,null))); + return tx.execute(s->service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),created.dispatchNo()+"-loading",Decision.APPROVED,null,null))); } /** 只为实载端口夹具准备真实扫码/归属记录,不声称运行库存命令。 */ public void picked(long source,String customer,long pallet,int packages) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java index c1d05ea82..2792c8cf7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java @@ -28,12 +28,12 @@ class DispatchPdaReadPersistenceTest { assertThat(f.tx.>execute(s->queries.list(actor,null))).isEmpty(); var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null))); assertThat(f.tx.>execute(s->queries.list(actor,null))).isEmpty(); - var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null))); + var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null,null))); var list=f.tx.execute(s->queries.list(actor,null));assertThat(list).hasSize(1); var task=list.get(0);assertThat(task.stage()).isEqualTo(Stage.BEFORE_LOADING); assertThat(task.actions().get(Action.LOADING_REVIEW).allowed()).isTrue();assertThat(task.actions().get(Action.PICK).allowed()).isFalse(); assertThat(task.resources().vehicle().plate()).isEqualTo("TEST-TRUCK");assertThat(task.resources().driver().name()).isEqualTo("测试司机"); - f.tx.execute(s->f.service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"tally-reject",Decision.REJECTED,"车辆不符"))); + f.tx.execute(s->f.service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"tally-reject",Decision.REJECTED,"车辆不符",null))); assertThat(f.tx.>execute(s->queries.list(actor,null))).isEmpty(); assertThat(f.tx.execute(s->queries.task(actor,created.dispatchNo())).reviews().loadingReview().reason()).isEqualTo("车辆不符"); } finally {security.setAuthentication(previous);} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewAttachmentsPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewAttachmentsPersistenceTest.java new file mode 100644 index 000000000..44a1b5cd2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewAttachmentsPersistenceTest.java @@ -0,0 +1,104 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway; +import com.baiyue.lastleg.dispatch.port.*; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 审核附件以真实Mapper保存并随审核轮次流转,不能改写车辆和司机资源。 */ +class DispatchReviewAttachmentsPersistenceTest { + /** 两级附件随决定保存,理货驳回与客服改货保留证据,重提清当前但历史不丢。 */ + @Test void attachmentsSurviveRejectionAndEditsUntilResubmission() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("review-evidence",List.of(f.source(1,"101",501,"ONT8",3))))); + var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null))); + var before=f.repository.find(created.dispatchNo()).getResourcesJson(); + var first=new DispatchContracts.ReviewAttachments(List.of(91L),List.of(92L),List.of(93L)); + var request=new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null,first); + var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,request)); + assertThat(f.service.detail(f.actor,created.dispatchNo()).reviews().dispatchReview().attachments()).isEqualTo(first); + assertThat(f.repository.find(created.dispatchNo()).getResourcesJson()).isEqualTo(before); + var second=new DispatchContracts.ReviewAttachments(List.of(94L),List.of(95L),List.of(96L)); + var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"reject",Decision.REJECTED,"照片不清晰",second))); + var reviews=f.service.detail(f.actor,created.dispatchNo()).reviews(); + assertThat(reviews.dispatchReview().status()).isEqualTo(ReviewStatus.NOT_SUBMITTED); + assertThat(reviews.dispatchReview().attachments()).isEqualTo(first); + assertThat(reviews.loadingReview().attachments()).isEqualTo(second); + var changed=f.tx.execute(s->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(rejected.recordVersion(),"cargo",List.of(f.source(2,"101",502,"ONT8",2)),List.of()))); + reviews=f.service.detail(f.actor,created.dispatchNo()).reviews(); + assertThat(reviews.dispatchReview().attachments()).isEqualTo(first); + assertThat(reviews.loadingReview().attachments()).isEqualTo(second); + assertThat(reviews.loadingReview().reason()).isEqualTo("照片不清晰"); + f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(changed.recordVersion(),"resubmit",null))); + reviews=f.service.detail(f.actor,created.dispatchNo()).reviews(); + assertThat(reviews.dispatchReview().attachments()).isEqualTo(DispatchContracts.ReviewAttachments.empty()); + assertThat(reviews.loadingReview().attachments()).isEqualTo(DispatchContracts.ReviewAttachments.empty()); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history WHERE snapshot_json LIKE '%driverPhotoFileIds%92%'",Long.class)).isPositive(); + assertThat(f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,request)).replayed()).isTrue(); + assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null,second)))).hasMessageContaining("原请求标识"); + } + + /** 拒绝也逐文件核验本车分类;错误归属不提交状态和版本。 */ + @Test void rejectsForeignKindOrMasterLicenseAndKeepsOriginalVersion() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("verify-evidence",List.of(f.source(1,"101",501,"ONT8",3))))); + var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null))); + var gateway=(DispatchEvidenceGateway)ReflectionTestUtils.getField(f.service,"evidence"); + long id=f.repository.find(created.dispatchNo()).getId(); + var attachments=new DispatchContracts.ReviewAttachments(List.of(91L),List.of(92L),List.of(93L)); + doThrow(new IllegalArgumentException("本车分类归属不匹配")).when(gateway).metadata(id,DispatchEvidenceGateway.Kind.DRIVER,93L); + assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"bad",Decision.REJECTED,"需补资料",attachments)))).hasMessageContaining("归属不匹配"); + verify(gateway).metadata(id,DispatchEvidenceGateway.Kind.VEHICLE,91L); + verify(gateway).metadata(id,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,92L); + verify(gateway).metadata(id,DispatchEvidenceGateway.Kind.DRIVER,93L); + assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(submitted.recordVersion()); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='bad'",Long.class)).isZero(); + } + + /** 只有当前待审角色能上传;维护权限不能替代审核资格,上传不变更版本和依据。 */ + @Test void uploadUsesCurrentReviewAuthorityWithoutEditAndDoesNotMutateAggregate() throws Exception { + var security=SecurityContextHolder.getContext();var prior=security.getAuthentication(); + try { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("upload-evidence",List.of(f.source(1,"101",501,"ONT8",3))))); + var file=new MockMultipartFile("file","driver.png","image/png",new byte[]{1}); + var kind=DispatchEvidenceGateway.Kind.DRIVER_PHOTO; + security.setAuthentication(new TestingAuthenticationToken("operator","","platform:fleet:trip:dispatch-review")); + assertThatThrownBy(()->f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file)).hasMessageContaining("无当前阶段"); + var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null))); + var before=f.repository.find(created.dispatchNo()); + f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file); + security.setAuthentication(new TestingAuthenticationToken("operator","","platform:overseas-warehouse:dispatch:edit")); + assertThatThrownBy(()->f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file)).hasMessageContaining("无当前阶段"); + var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"approve",Decision.APPROVED,null,null))); + security.setAuthentication(new TestingAuthenticationToken("operator","","platform:fleet:trip:dispatch-review")); + assertThatThrownBy(()->f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file)).hasMessageContaining("无当前阶段"); + security.setAuthentication(new TestingAuthenticationToken("operator","","platform:fleet:trip:loading-review")); + f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file); + var after=f.repository.find(created.dispatchNo()); + assertThat(after.getRecordVersion()).isEqualTo(approved.recordVersion()); + assertThat(after.getReviewBasisHash()).isEqualTo(before.getReviewBasisHash()); + assertThat(after.getResourcesJson()).isEqualTo(before.getResourcesJson()); + var loaded=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"load",Decision.APPROVED,null,null))); + assertThatThrownBy(()->f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file)).hasMessageContaining("装车前"); + } finally {security.setAuthentication(prior);} + } + + /** 省略附件保持PDA原输入语义,非法或过多引用在进入业务前拒绝。 */ + @Test void optionalAttachmentShapeIsBoundedAndNormalized() throws Exception { + var json=new com.fasterxml.jackson.databind.ObjectMapper(); + var request=json.readValue("{\"recordVersion\":1,\"idempotencyKey\":\"pda\",\"decision\":\"APPROVED\"}",DispatchCommands.ReviewCommand.class); + assertThat(request.attachments()).isEqualTo(DispatchContracts.ReviewAttachments.empty()); + assertThatThrownBy(()->new DispatchContracts.ReviewAttachments(List.of(1L,1L),null,null)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(()->new DispatchContracts.ReviewAttachments(null,List.of(-1L),null)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(()->new DispatchContracts.ReviewAttachments(null,null,java.util.stream.LongStream.rangeClosed(1,21).boxed().toList())).isInstanceOf(IllegalArgumentException.class); + } +} 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 b1ca5fa4c..4c2a439f1 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 @@ -121,6 +121,6 @@ class DispatchReviewWorkflowPersistenceTest { /** 通过同一审核服务分别调用两端,版本和幂等键始终由请求明确提供。 */ private static DispatchContracts.CommandResult review(DispatchAggregatePersistenceTest.Fixture f,DispatchContracts.Actor actor,DispatchContracts.CommandResult prior,boolean loading,String key,Decision decision,String reason) { - return f.tx.execute(s->f.service.review(actor,prior.dispatchNo(),loading,new DispatchCommands.ReviewCommand(prior.recordVersion(),key,decision,reason))); + return f.tx.execute(s->f.service.review(actor,prior.dispatchNo(),loading,new DispatchCommands.ReviewCommand(prior.recordVersion(),key,decision,reason,null))); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java index c324fcb87..370e13e97 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java @@ -78,7 +78,7 @@ class PdaOutboundDispatchContractTest { var workflow=mock(com.baiyue.lastleg.dispatch.service.DispatchAggregateService.class); var controller=new DispatchPdaController();ReflectionTestUtils.setField(controller,"workflow",workflow); var user=pda(Set.of("lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"),900L); - var request=new DispatchCommands.ReviewCommand(2L,"pda-review",DispatchTypes.Decision.REJECTED,"司机资料待核对"); + var request=new DispatchCommands.ReviewCommand(2L,"pda-review",DispatchTypes.Decision.REJECTED,"司机资料待核对",null); controller.loadingReview(user,"PC202610210001",request); verify(workflow).review(new DispatchContracts.Actor("7",DispatchTypes.ActorMode.PDA,"900"),"PC202610210001",true,request); assertFalse(Arrays.stream(DispatchPdaController.class.getDeclaredMethods()).anyMatch(method->method.getName().equals("dispatchReview"))); diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 853e76efb..1d33571e4 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -2,7 +2,9 @@ `fleet-return-redispatch-acceptance.mjs` 的原出发证明读取本轮 `LOADING_COMPLETE` 回执与 `departureDeliveries`;重派在 PDA 装车完成后回查自动生成的出库/派送及两箱已发运事实,不再调用手工安排派送或确认发车。原键重放和跨操作员拒绝仍由共享装车验证覆盖,原签收不漂移、源订单完成事件审计及资源分别释放仍须独立核对。离线模拟不能代替匹配部署的真实 HTTP。 -固定托盘造数中,`inbound-measurement.mjs` 缺少 `TP-001` 或 `YW-TP-002` 时记录阻断并停止,不自动选择其他托盘。仅明确设置内部 `allowAnyExistingPallet` 的通用场景可读取当前仓库已有资源;两者均不创建托盘。 +固定托盘造数中,`inbound-measurement.mjs` 只复用 `TP-001`、`YW-TP-002` 指定池中的现有托盘;一只可复用,不要求两只同时存在,两只均缺失才阻断,不自动选择池外托盘。仅明确设置内部 `allowAnyExistingPallet` 的通用场景可读取当前仓库已有资源;两者均不创建托盘。 + +OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖个人桌面路径。合成称重测试图只证明上传、识别响应及人工确认协议,不代表现场称重照片或设备验收。费用项目模块按受控编码验证112条正式基线,合法扩展项目保留并单独核对分页和筛选。散板普通状态接口仅验证取消及非法派车拒绝,原单须出现在待匹配需求池;需求池接车到签收另列覆盖缺口,不以手工跳状态冒充履约。 先运行 `node scripts/acceptance-overview.mjs`(后端根目录,无网络)查看当前默认与可选模块。范围来自真实注册表;本文件描述各模块用途与依赖,不代表所有模块默认执行或已通过。报告解读及跨端/专项边界见 [总索引](../README.md#先看范围再看证据),最新运行状态只查外层部署契约。 diff --git a/scripts/admin-test-env-modules/fee-items-contract.test.mjs b/scripts/admin-test-env-modules/fee-items-contract.test.mjs index 41d120019..ed563e8ef 100644 --- a/scripts/admin-test-env-modules/fee-items-contract.test.mjs +++ b/scripts/admin-test-env-modules/fee-items-contract.test.mjs @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs' import test from 'node:test' import { normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs' -import { cleanupSucceeded, isAcceptanceFeeItem } from './fee-items.mjs' +import { cleanupSucceeded, isAcceptanceFeeItem, formalFeeItems } from './fee-items.mjs' const source = readFileSync(new URL('./fee-items.mjs', import.meta.url), 'utf8') @@ -31,7 +31,15 @@ test('stale cleanup only recognizes the acceptance-owned code and name pair', () test('baseline checks run only after stale acceptance cleanup', () => { assert.ok(source.indexOf('cleanupStaleAcceptanceItems(context)') - < source.indexOf("'正式目录112条且默认20条对应6页'")) + < source.indexOf("'全部目录默认20条分页与完整列表一致'")) +}) + +test('正式费用按编码识别,扩展停用项目保留且不掩盖缺失正式编码', () => { + const rows = [{ feeCode: 'FBA_HEAD_FREIGHT' }, { feeCode: 'FEE_TYPE_001' }, + { feeCode: 'PRIVATE_LAST_MILE_DELIVERY' }, { feeCode: 'FEE_EXT_OTHER', status: 'DISABLED' }] + assert.deepEqual(formalFeeItems(rows), rows.slice(0, 3)) + assert.equal(rows.length, 4) + assert.equal(formalFeeItems([{ feeCode: 'FEE_TYPE_111' }]).length, 0) }) test('formal baseline includes both system fee items in addition to the 110 controlled items', () => { diff --git a/scripts/admin-test-env-modules/fee-items.mjs b/scripts/admin-test-env-modules/fee-items.mjs index 2778893b7..60469b57f 100644 --- a/scripts/admin-test-env-modules/fee-items.mjs +++ b/scripts/admin-test-env-modules/fee-items.mjs @@ -51,6 +51,11 @@ function expectedCodes() { ...Array.from({ length: 110 }, (_, index) => `FEE_TYPE_${String(index + 1).padStart(3, '0')}`), ].sort() } +/** 正式基线按受控编码识别;其他业务合法扩展不计入112条基线。 */ +export function formalFeeItems(rows) { + const codes = new Set(expectedCodes()) + return rows.filter(row => codes.has(row.feeCode)) +} export function isAcceptanceFeeItem(row) { return /^ACPT_\d+$/.test(String(row?.feeCode || '')) && String(row?.feeTypeName || '').startsWith('全量验收费用-') @@ -91,16 +96,16 @@ export async function run(context) { const initialPageResponse = await context.request('GET', query({ page: 1, size: 20 })) const initialPage = context.apiSuccess(MODULE, '费用项目查看权限与分页查询', initialPageResponse, 'page=1,size=20', OPTIONS) - context.assert(MODULE, '正式目录112条且默认20条对应6页', initialPageResponse, - records(initialPage).length === 20 && Number(initialPage?.total) === BASELINE_COUNT - && Number(initialPage?.pages) === 6 && Number(initialPage?.page) === 1, + const initial = await listAll(context) + context.assert(MODULE, '全部目录默认20条分页与完整列表一致', initialPageResponse, + records(initialPage).length === Math.min(20, initial.rows.length) && Number(initialPage?.total) === initial.rows.length + && Number(initialPage?.pages) === Math.ceil(initial.rows.length / 20) && Number(initialPage?.page) === 1, `records=${records(initialPage).length}, total=${initialPage?.total ?? 'n/a'}, pages=${initialPage?.pages ?? 'n/a'}`, OPTIONS) context.assert(MODULE, '费用项目查询不传 accountId 仍成功', initialPageResponse, success(initialPageResponse) && !String(initialPageResponse.path || initialPageResponse.request?.path || '').includes('accountId'), `path=${initialPageResponse.path || 'missing'}`, OPTIONS) - const initial = await listAll(context) - const initialRows = initial.rows + const initialRows = formalFeeItems(initial.rows) const domestic = initialRows.filter((row) => row.tradeScope === 'DOMESTIC') const international = initialRows.filter((row) => row.tradeScope === 'INTERNATIONAL') const actualCodes = initialRows.map((row) => row.feeCode).sort() @@ -126,7 +131,8 @@ export async function run(context) { const response = await context.request('GET', query({ tradeScope: scope, page: 1, size: 200 })) const page = context.apiSuccess(MODULE, `按 ${scope} 贸易范围筛选`, response, '', OPTIONS) context.assert(MODULE, `${scope} 筛选数量和范围正确`, response, - Number(page?.total) === expected && records(page).every((row) => row.tradeScope === scope), + Number(page?.total) === initial.rows.filter(row => row.tradeScope === scope).length + && formalFeeItems(records(page)).length === expected && records(page).every((row) => row.tradeScope === scope), `total=${page?.total ?? 'n/a'}, records=${records(page).length}`, OPTIONS) } @@ -142,8 +148,9 @@ export async function run(context) { const activeResponse = await context.request('GET', query({ status: 'ACTIVE', page: 1, size: 200 })) const activePage = context.apiSuccess(MODULE, '按 ACTIVE 状态筛选费用项目', activeResponse, '', OPTIONS) - context.assert(MODULE, 'ACTIVE 筛选返回112条且无停用记录', activeResponse, - Number(activePage?.total) === BASELINE_COUNT && records(activePage).every((row) => row.status === 'ACTIVE'), + context.assert(MODULE, 'ACTIVE 筛选覆盖112条正式项目且无停用记录', activeResponse, + Number(activePage?.total) === initial.rows.filter(row => row.status === 'ACTIVE').length + && formalFeeItems(records(activePage)).length === BASELINE_COUNT && records(activePage).every((row) => row.status === 'ACTIVE'), `total=${activePage?.total ?? 'n/a'}`, OPTIONS) const merchantLoginResponse = await context.request('POST', '/api/auth/login', { @@ -273,8 +280,10 @@ export async function run(context) { `feeItemId=${feeItemId}`, OPTIONS) } const final = await listAll(context) - context.assert(MODULE, '清理后恢复112条正式基线且无临时编码或名称', final.response, - final.rows.length === BASELINE_COUNT && !final.rows.some((row) => row.feeCode === createPayload.feeCode + context.assert(MODULE, '清理后保留原目录与112条正式基线且无本轮临时项目', final.response, + formalFeeItems(final.rows).length === BASELINE_COUNT + && final.rows.map(row => row.feeCode).sort().join(',') === initial.rows.map(row => row.feeCode).sort().join(',') + && !final.rows.some((row) => row.feeCode === createPayload.feeCode || String(row.feeTypeName).includes(suffix)), `rows=${final.rows.length}, temporary=${final.rows.filter((row) => row.feeCode === createPayload.feeCode).length}`, OPTIONS) context.createdResources.feeItems = { diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 58e74a049..f4c7273b1 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -269,11 +269,11 @@ export async function reusablePallets(context, warehouseId) { } pallets.push({ id: asId(exact.id), code: palletCode }) } - if (missingFixed.length === 0) { + if (pallets.length > 0) { reusablePalletsByWarehouse.set(scopedCacheKey, pallets) return pallets } - context.assert(MODULE, '固定称重托盘主数据完整', null, false, + context.assert(MODULE, '指定称重托盘池至少存在一只可复用托盘', null, false, `warehouseId=${warehouseId}, missing=${missingFixed.join(',')}`, OPTIONS) context.addCoverageGap?.(MODULE, 'measurement-pallet-fixture', '固定称重托盘主数据缺失', `warehouseId=${warehouseId}, missing=${missingFixed.join(',')};停止固定托盘造数`, diff --git a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs index e6920b8b8..0b1912ed7 100644 --- a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs +++ b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs @@ -5,7 +5,6 @@ import process from 'node:process' const MODULE = 'inbound-weight-ocr' const SCENARIO = 'weight-image-recognition-and-confirmation' export const WEIGHT_PALLET_OPTIONS_PAGE_SIZE = 50 -const DEFAULT_IMAGE = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG' const EDITABLE_STATUSES = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED']) const OCR_STATUSES = new Set(['RECOGNIZED', 'FAILED']) const NEW_CONTRACT_OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } @@ -392,7 +391,8 @@ export async function run(context) { tenantId: target.tenantId, inboundId: target.inboundId, resultIds: [], cleaned: false, } - const imagePath = process.env.ADMIN_TEST_OCR_IMAGE || DEFAULT_IMAGE + const imagePath = process.env.ADMIN_TEST_OCR_IMAGE + if (!imagePath) throw new Error('请显式配置 ADMIN_TEST_OCR_IMAGE 为本轮称重验收图片,禁止依赖个人桌面路径') const imageBytes = await readFile(imagePath) const image = { bytes: imageBytes, name: basename(imagePath), contentType: 'image/jpeg' } const initial = await loadWorkspace(context, target, '读取 OCR 测试前重量工作区') diff --git a/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs b/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs index 608eb264d..3ec8be83a 100644 --- a/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs +++ b/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs @@ -13,7 +13,7 @@ test('固定托盘缺失停止造数,不查询其他托盘;补齐后同仓 const query = new URL(path, 'http://offline').searchParams const code = query.get('palletCode') assert.ok(code, '固定模式禁止用无编号列表替换缺失资源') - return { records: complete || code === 'TP-001' + return { records: complete ? [{ id: code === 'TP-001' ? '1' : '2', palletCode: code, warehouseId: 'offline-fixed-pallet' }] : [] } }, apiSuccess(_module, _name, response) { return response }, @@ -31,6 +31,20 @@ test('固定托盘缺失停止造数,不查询其他托盘;补齐后同仓 assert.equal(requests.length, 4, '失败不得缓存为成功') }) +test('指定托盘池仅剩一只时可复用,不要求同时存在两只', async () => { + const context = { + context: {}, + async request(_method, path) { + const code = new URL(path, 'http://offline').searchParams.get('palletCode') + assert.ok(['TP-001', 'YW-TP-002'].includes(code)) + return { records: code === 'YW-TP-002' ? [{ id: '2', palletCode: code, warehouseId: 'offline-single-pallet' }] : [] } + }, + apiSuccess(_module, _name, response) { return response }, + assert(_module, _name, _response, passed) { assert.equal(passed, true) }, + } + assert.deepEqual(await reusablePallets(context, 'offline-single-pallet'), [{ id: '2', code: 'YW-TP-002' }]) +}) + const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') const measurement = readFileSync(new URL('./inbound-measurement.mjs', import.meta.url), 'utf8') @@ -62,7 +76,7 @@ test('称重只随机复用固定托盘且不创建新托盘', () => { assert.match(measurement, /context\.request\('GET',[\s\S]*\/api\/platform\/v1\/pallets\?page=1&size=20/) assert.match(measurement, /randomlyReusePalletIds\(candidates, palletCount/) assert.match(measurement, /随机复用原有称重托盘/) - assert.match(measurement, /固定称重托盘主数据完整/) + assert.match(measurement, /指定称重托盘池至少存在一只可复用托盘/) assert.doesNotMatch(measurement, /context\.request\('POST', '\/api\/platform\/v1\/pallets/) assert.doesNotMatch(acceptance, /createPhase3FreshMeasurementPallet/) }) diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs index 1a98665b4..ae4f161ba 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs @@ -54,7 +54,8 @@ test('phase3 script only discovers the current outbound task and blocks missing assert.match(source, /providerEventId/) assert.match(source, /\^\\d\{16,/) assert.doesNotMatch(source, /request\(context,\s*'POST',\s*BASE/) - assert.doesNotMatch(source, /manual-nodes|manual-results|BILL_OF_LADING/) + // 覆盖缺口说明可以提及人工接口;禁止的是构造这些接口路径。 + assert.doesNotMatch(source, /\/manual-(?:nodes|results)(?=['"`/?])|BILL_OF_LADING/) assert.doesNotMatch(source, /records\[0\]/) }) diff --git a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs index e112f93b4..794b48429 100644 --- a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs +++ b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs @@ -14,6 +14,9 @@ const PHASE4_READ_CODES = [ ] const PHASE4_WRITE_SUFFIXES = [':create', ':edit', ':status', ':export', ':adjust', ':delete', ':review'] const PHASE5_OPERATOR_CODES = [ + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:list', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', @@ -90,7 +93,7 @@ export async function run(context) { context.assert(MODULE, '操作员具备国内入仓权限、四期只读权限与五期履约权限', rolesResponse, operatorCodes.has('platform:firstleg:inbound:loading:view') && operatorCodes.has('platform:firstleg:inbound:loading:edit') - && operatorCodes.size === 31 + && operatorCodes.size === 34 && PHASE4_READ_CODES.every((code) => operatorCodes.has(code)) && PHASE5_OPERATOR_CODES.every((code) => operatorCodes.has(code)) && !operatorCodes.has('platform:firstleg:inbound:loading:complete') diff --git a/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs b/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs index 56733ff62..ff8b5db23 100644 --- a/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs @@ -26,7 +26,7 @@ test('warehouse operator and tally use the exact phase-four and phase-five permi assert.match(iamSql, new RegExp(`'WAREHOUSE_OPERATOR','${code}'`)) assert.match(iamSql, new RegExp(`'WAREHOUSE_TALLY','${code}'`)) } - assert.match(acceptance, /operatorCodes\.size === 31/) + assert.match(acceptance, /operatorCodes\.size === 34/) assert.match(acceptance, /tallyCodes\.size === 27/) assert.match(acceptance, /PHASE4_WRITE_SUFFIXES/) assert.match(iamSql, /assert_phase4_warehouse_role_minimum/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs index e8c7834a1..c78d8740c 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs @@ -152,8 +152,7 @@ function fixture(options = {}) { const replay = state.looseboardStatuses.get(body.idempotencyKey) if (replay) return replay.hash === hash ? response(replay.result) : response(null, 'A0409') if (Number(body.version) !== state.looseboard.version) return response(null, 'A0409') - const allowed = (state.looseboard.status === 'PENDING_ACCEPTANCE' && ['DISPATCHED', 'CANCELLED'].includes(body.status)) - || (state.looseboard.status === 'DISPATCHED' && body.status === 'CANCELLED') + const allowed = state.looseboard.status === 'PENDING_ACCEPTANCE' && body.status === 'CANCELLED' if (!allowed) return response(null, 'A0409') const from = state.looseboard.status state.looseboard = { ...state.looseboard, status: body.status, version: state.looseboard.version + 1 } @@ -162,6 +161,7 @@ function fixture(options = {}) { state.looseboardStatuses.set(body.idempotencyKey, { hash, result }) return response(result) } + if (requestPath.includes('/pending-looseboard-demands?') && method === 'GET') return response(page([state.looseboard])) if (requestPath.includes('/local-looseboard-orders/LB1?') && method === 'GET') return response(looseboardDetail()) if (requestPath.includes('/local-looseboard-orders/LB1?') && method === 'PUT') { if (Number(body.version) !== state.looseboard.version) return response(null, 'A0409') @@ -222,7 +222,8 @@ test('procurement creation review boundaries and transport cancellation paths pa await run(context) assert.equal(context.steps.every((step) => step.passed), true, context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n')) - assert.equal(context.gaps.length, 0) + assert.equal(context.gaps.length, 1) + assert.equal(context.gaps[0][1], 'looseboard-pool-fulfillment') assert.equal(context.blockers.length, 0) assert.equal(context.calls.filter(call => call.path === '/api/auth/login').length, 1) assert.equal(context.calls.filter(call => call.path === '/api/auth/logout').length, 1) diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index 9f00735d3..a8f628708 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -373,7 +373,8 @@ async function truckFlow(context, customerId) { after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1 && after?.status === 'PENDING_REVIEW', `version=${after?.version ?? 'missing'}, destination=${after?.destination || 'missing'}; ${trace(edited.response)}`, OPTIONS) - const stale = await context.request('PATCH', path, { version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' }) + const stale = await context.request('PATCH', path, { ...payload, idempotencyKey: undefined, + version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' }) expectedConflict(context, '卡车旧 version 编辑返回 A0409', stale, true) const afterStale = await overseas(context, 'GET', '卡车旧 version 冲突后零漂移回查', path) const afterStaleSummary = summary(afterStale.data) @@ -456,38 +457,33 @@ async function looseboardFlow(context, customerId) { `destination=${afterStaleSummary?.destination || 'missing'}, version=${afterStaleSummary?.version ?? 'missing'}; ${trace(afterStale.response)}`, OPTIONS) const statusPath = query(`${base}/${encodeURIComponent(orderNo)}/status`, { customerId }) - const forwarded = await overseas(context, 'PATCH', '散板状态正向推进到已派车', statusPath, - { version: Number(afterStaleSummary?.version), status: 'DISPATCHED', - idempotencyKey: `${context.marker}-looseboard-status-dispatched`.slice(0, 255), - remark: `${context.marker}-FORWARD` }) - const forwardedSummary = summary(forwarded.data) - context.assert(MODULE, '散板状态正向推进成功', forwarded.response, - forwardedSummary?.status === 'DISPATCHED' - && Number(forwardedSummary?.version) === Number(afterStaleSummary?.version) + 1, - `status=${forwardedSummary?.status || 'missing'}, version=${forwardedSummary?.version ?? 'missing'}; ${trace(forwarded.response)}`, OPTIONS) const forwardBody = { version: Number(afterStaleSummary?.version), status: 'DISPATCHED', - idempotencyKey: `${context.marker}-looseboard-status-dispatched`.slice(0, 255), - remark: `${context.marker}-FORWARD` } - const forwardReplay = await overseas(context, 'PATCH', '散板状态同键同载荷精确重放', statusPath, - forwardBody) - context.assert(MODULE, '散板状态重放复用已派车终态且版本不漂移', forwardReplay.response, - summary(forwardReplay.data)?.status === 'DISPATCHED' - && Number(summary(forwardReplay.data)?.version) === Number(forwardedSummary?.version), - trace(forwardReplay.response), OPTIONS) - const forwardMismatch = await context.request('PATCH', statusPath, { ...forwardBody, remark: 'DIFFERENT-DO-NOT-APPLY' }) - expectedConflict(context, '散板状态同键异载荷返回冲突', forwardMismatch) - const reversed = await context.request('PATCH', statusPath, { version: Number(forwardedSummary?.version), status: 'PENDING_ACCEPTANCE', - idempotencyKey: `${context.marker}-looseboard-status-terminal-reverse`.slice(0, 255), - remark: 'REVERSE-DO-NOT-APPLY' }) - expectedConflict(context, '散板状态逆向回退返回冲突', reversed) - const history = await overseas(context, 'GET', '散板状态冲突后流水回查', path) + idempotencyKey: `${context.marker}-looseboard-status-dispatched`.slice(0, 255), remark: `${context.marker}-FORWARD` } + // 散板状态由需求池匹配和真实履约驱动,普通状态接口只能取消。 + const beforeRejected = JSON.stringify(afterStale.data) + for (const [name, body] of [ + ['散板禁止绕过需求池直接派车', forwardBody], + ['散板被拒命令同键重放仍拒绝', forwardBody], + ['散板被拒命令异载荷仍拒绝', { ...forwardBody, remark: 'DIFFERENT-DO-NOT-APPLY' }], + ['散板状态逆向回退返回冲突', { ...forwardBody, status: 'PENDING_ACCEPTANCE', + idempotencyKey: `${context.marker}-looseboard-status-terminal-reverse`.slice(0, 255) }], + ]) { + const rejected = await context.request('PATCH', statusPath, body) + expectedConflict(context, name, rejected) + } + const history = await overseas(context, 'GET', '散板被拒命令后完整详情与流水回查', path) const historySummary = summary(history.data) - const histories = Array.isArray(history.data?.histories) ? history.data.histories : [] - const operations = new Set(histories.map((item) => item?.operationType)) - context.assert(MODULE, '散板流水覆盖创建编辑与正向状态', history.response, - historySummary?.status === 'DISPATCHED' && ['CREATE', 'UPDATE', 'STATUS'].every((type) => operations.has(type)) - && histories.some((item) => String(item?.remark || item?.changeDetail || '').includes(context.marker)), - `status=${historySummary?.status || 'missing'}, historyCount=${histories.length}, operations=${[...operations].join(',') || 'missing'}; ${trace(history.response)}`, OPTIONS) + context.assert(MODULE, '散板非法推进未改变完整详情及流水', history.response, + JSON.stringify(history.data) === beforeRejected && historySummary?.status === 'PENDING_ACCEPTANCE', + trace(history.response), OPTIONS) + const pending = await overseas(context, 'GET', '散板原单进入待匹配需求池', + query(`${BASE}/fleet/capacity-offers/pending-looseboard-demands`, { page: 1, size: 100, keyword: orderNo })) + context.assert(MODULE, '待匹配需求保持本轮原单客户与版本', pending.response, + records(pending.data).some(row => row.orderNo === orderNo && id(row.customerId) === customerId + && Number(row.version) === Number(historySummary?.version)), + trace(pending.response), OPTIONS) + context.addCoverageGap(MODULE, 'looseboard-pool-fulfillment', '散板需求池接车至签收', + '本模块验证原单进入需求池、禁止手工跳状态及取消;接车、实收、装车、签收需本轮独立车次生产链,未以手工改状态代替。', 'fixture') Object.assign(registry.looseboard, { version: Number(historySummary?.version), finalStatus: historySummary?.status }) } finally { await cancelMarkerOrder(context, registry.looseboard, base, ['PENDING_ACCEPTANCE', 'DISPATCHED'], '散板') diff --git a/scripts/admin-test-env-modules/sailing-calendar.mjs b/scripts/admin-test-env-modules/sailing-calendar.mjs index cc4173f36..8f04f39d5 100644 --- a/scripts/admin-test-env-modules/sailing-calendar.mjs +++ b/scripts/admin-test-env-modules/sailing-calendar.mjs @@ -28,7 +28,8 @@ export async function run(context) { const summary = calendar?.summary const filterOptions = calendar?.filterOptions context.assert(module, '月历记录、汇总与筛选选项结构完整', calendarResponse, - records.every((row) => id(row.id) && id(row.sailingNo) && 'etaAt' in row) + records.every((row) => id(row.id) && id(row.sailingNo) + && (row.etaAt == null || Number.isFinite(Date.parse(row.etaAt)))) && summary && Number.isFinite(count(summary.sailingCount)) && filterOptions && Array.isArray(filterOptions.warehouses) && Array.isArray(filterOptions.channels), `month=${month},records=${records.length},sailingCount=${summary?.sailingCount ?? 'missing'}`) diff --git a/scripts/database-schema-alignment-migrations-contract.test.mjs b/scripts/database-schema-alignment-migrations-contract.test.mjs index 625f13a9a..b96f69e3a 100644 --- a/scripts/database-schema-alignment-migrations-contract.test.mjs +++ b/scripts/database-schema-alignment-migrations-contract.test.mjs @@ -121,20 +121,24 @@ test('数据库迁移计划包含授权版本和事件可靠投递的正式目 commit: null, }) const targets = Object.fromEntries(plan.regions.flatMap((region) => region.schemas.map((schema) => [schema.schema, schema]))) - assert.equal(targets.baiyue_iam.target, '4') + assert.equal(targets.baiyue_iam.target, '6') assert.equal(targets.baiyue_iam.migrations.at(-1).path, - 'migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql') - assert.equal(targets.baiyue_firstleg.target, '7') + 'migrations/cn/baiyue_iam/V6__cargo_discard_customer_confirmation_permission.sql') + assert.equal(targets.baiyue_firstleg.target, '9') + assert.equal(targets.baiyue_firstleg.migrations.at(-1).path, + 'migrations/cn/baiyue_firstleg/V9__drop_chargeable_weight_confirmation.sql') assert.equal(targets.baiyue_finance.target, '2') - assert.equal(targets.baiyue_lastleg.target, '13') - assert.deepEqual(targets.baiyue_lastleg.migrations.slice(-4).map(migration => migration.path), [ + assert.equal(targets.baiyue_lastleg.target, '33') + assert.equal(targets.baiyue_lastleg.migrations.at(-1).path, + 'migrations/overseas/baiyue_lastleg/V33__looseboard_pool_demand.sql') + assert.deepEqual(targets.baiyue_lastleg.migrations.filter(migration => /\/V1[0-3]__/.test(migration.path)).map(migration => migration.path), [ 'migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql', 'migrations/overseas/baiyue_lastleg/V11__align_capacity_pool_collation.sql', 'migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql', 'migrations/overseas/baiyue_lastleg/V13__widen_inbound_plan_creation_mode.sql', ]) assert.equal(targets.baiyue_lastlag_warehouse.target, '2') - assert.deepEqual(targets.baiyue_firstleg.migrations.slice(-3).map((migration) => migration.path), [ + assert.deepEqual(targets.baiyue_firstleg.migrations.filter(migration => /\/V[5-7]__/.test(migration.path)).map((migration) => migration.path), [ 'migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql', 'migrations/cn/baiyue_firstleg/V6__event_delivery_attempt.sql', 'migrations/cn/baiyue_firstleg/V7__settle_partial_loaded_cargo.sql', diff --git a/scripts/fleet-trip-rollback-test-env.test.mjs b/scripts/fleet-trip-rollback-test-env.test.mjs index 7025bac55..5da348213 100644 --- a/scripts/fleet-trip-rollback-test-env.test.mjs +++ b/scripts/fleet-trip-rollback-test-env.test.mjs @@ -284,8 +284,14 @@ test('offline verifier preserves original command and requires all phases before const saved = JSON.parse(fs.readFileSync('reports/fleet-cross-offline/rollback.json', 'utf8')) assert.equal(saved.verified, true); assert.ok(saved.finishedAt) const caller = fs.readFileSync(new URL('./admin-test-env-modules/fleet-cross-customer-acceptance.mjs', import.meta.url), 'utf8') - assert.ok(caller.indexOf('await verifyDepartureRollback(') < caller.indexOf("const departed = await request('POST', departPath, departBody")) - assert.match(caller, /Platform exact replay of PDA native departure/); assert.match(caller, /This customer independently signed/) + assert.match(caller, /await confirmFleetLoading\([^\n]+async command => \{/) + assert.match(caller, /await verifyDepartureRollback\(\{ context, fixture, ready, departPath: command.path, departBody: command.body, snapshot \}\)/) + assert.match(caller, /Rollback restores both complete inventory snapshots before loading completion/) + const loading = fs.readFileSync(new URL('./admin-test-env-modules/fleet-trip-fulfillment.mjs', import.meta.url), 'utf8') + const hook = loading.indexOf('if (beforeComplete) await beforeComplete(proof.loadingCommand)') + const completion = loading.indexOf("const confirmed = await command(context, module, 'POST', proof.loadingCommand.path") + assert.ok(hook >= 0 && completion > hook, '回滚验证必须在真正提交装车完成命令前结束') + assert.match(caller, /Original PDA actor exact replay of loading completion/); assert.match(caller, /This customer independently signed/) } finally { process.chdir(original) assert.ok(resolve(temp).startsWith(resolve(tmpdir()) + '\\') || resolve(temp).startsWith(resolve(tmpdir()) + '/')) diff --git a/scripts/java-quality-baseline.json b/scripts/java-quality-baseline.json index d8bb15b80..117bfdec5 100644 --- a/scripts/java-quality-baseline.json +++ b/scripts/java-quality-baseline.json @@ -11,7 +11,7 @@ "services": 13 }, "openapi": { - "documents": 100, + "documents": 109, "coveredServices": 11, "codeFirstServices": 2 }, diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs index 89ab9fb46..48af828a6 100644 --- a/scripts/java-quality-summary.test.mjs +++ b/scripts/java-quality-summary.test.mjs @@ -65,7 +65,11 @@ test('repository freezes the current topology and non-blocking aggregate summary const summary = await buildQualitySummary(repositoryRoot) const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8')) assert.deepEqual(summary.topology, { modules: 20, codeModules: 19, services: 13 }) - assert.equal(summary.openapi.documents, 100) + assert.equal(summary.openapi.documents, 109) + for (const name of ['cargo-discard-statistics', 'dispatch-transport-tasks', 'fleet-outsourced-business', 'fleet-owned-business', 'order-workflow', 'relabel-quantities', 'return-order-statistics', 'warehouse-transfer-quantities']) { + assert.ok(summary.openapi.files.includes(`lastleg-service/src/main/resources/openapi/${name}-v1.yaml`)) + } + assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml')) for (const name of ['lastleg-exports', 'return-packing', 'fleet-capacity-offers']) { assert.ok(summary.openapi.files.includes(`lastleg-service/src/main/resources/openapi/${name}-v1.yaml`)) } diff --git a/scripts/publish-nacos-config-contract.test.mjs b/scripts/publish-nacos-config-contract.test.mjs index 176d28e2f..d379bc576 100644 --- a/scripts/publish-nacos-config-contract.test.mjs +++ b/scripts/publish-nacos-config-contract.test.mjs @@ -59,7 +59,8 @@ try { } catch { $failed = $true } [ordered]@{failed=$failed; calls=$global:calls; published=$global:published} | ConvertTo-Json -Compress ` - const result = spawnSync(process.platform === 'win32' ? 'powershell.exe' : 'pwsh', + // 使用项目统一的 PowerShell 7,避免子进程回落 Windows PowerShell 5 的模块路径与解析环境。 + const result = spawnSync('pwsh', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(command, 'utf16le').toString('base64')], { encoding: 'utf8', timeout: 30_000 }) assert.equal(result.status, 0, result.stderr || result.error?.message)