From 090591076f80e1158ca231c7e425e94f5d2bafdc Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 15:53:33 +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 --- AGENTS.md | 4 +- deploy/sql/海外区域统一增量-MySQL8.sql | 1 + .../file/service/BusinessFileCategory.java | 5 +- .../openapi/dispatch-evidence-v1.yaml | 4 +- .../service/DispatchEvidenceFileTest.java | 12 +- .../DispatchAggregateController.java | 6 + .../dispatch/entity/DispatchAggregate.java | 1 + .../gateway/DispatchEvidenceGateway.java | 2 +- .../gateway/HttpDispatchEvidenceGateway.java | 4 +- .../mapper/DispatchAggregateMapper.java | 2 +- .../dispatch/port/DispatchCommands.java | 7 + .../lastleg/dispatch/port/DispatchTypes.java | 4 +- .../lastleg/dispatch/port/DispatchViews.java | 6 +- .../service/DispatchAggregateService.java | 44 ++++++- .../service/DispatchCommandCodec.java | 2 +- .../lastleg/dto/ContainerArrivalTimeView.java | 3 +- .../lastleg/mapper/LastlegOrderMapper.java | 9 +- .../src/main/resources/db/lastleg.sql | 1 + .../openapi/overseas-business-v1.yaml | 122 +++++++++++++++++- .../calendar/WarehouseCalendarMapperTest.java | 12 ++ .../HttpDispatchEvidenceGatewayTest.java | 9 ++ .../DispatchExceptionPersistenceTest.java | 89 +++++++++++++ .../service/LastlegOrderServiceTest.java | 2 +- scripts/AGENTS.md | 2 +- scripts/README.md | 6 +- scripts/admin-test-env-acceptance.mjs | 2 + scripts/admin-test-env-modules/README.md | 4 +- .../appointment-work-contract.test.mjs | 21 ++- .../appointment-work.mjs | 21 ++- .../inbound-weight-ocr-contract.test.mjs | 17 +++ .../inbound-weight-ocr.mjs | 30 +++-- ...nt-qualification-fixture-contract.test.mjs | 2 +- .../merchant-qualification-fixture.mjs | 2 - .../pda-audit-sailing-contract.test.mjs | 4 + .../phase3-container-overview.mjs | 2 +- ...se3-container-regression-contract.test.mjs | 1 + ...phase4-overseas-business-contract.test.mjs | 3 +- ...se4-overseas-master-data-contract.test.mjs | 22 ++++ .../phase4-overseas-master-data.mjs | 16 ++- ...s-wallet-and-pallet-cost-contract.test.mjs | 7 +- ...phase4-overseas-wallet-and-pallet-cost.mjs | 9 +- 41 files changed, 459 insertions(+), 63 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchExceptionPersistenceTest.java diff --git a/AGENTS.md b/AGENTS.md index 16ebd4ca7..dd3922234 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,9 +133,9 @@ - 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。 - 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - 后端本地验证默认使用 `scripts/backend-check.ps1`,必须显式指定 `-Modules`,不传模块直接拒绝;命令及参数见 `scripts/README.md` 的“后端分层检查”。入口自动经 `local-check.ps1` 限流,`-DryRun` 只检查选择范围,不代表测试通过。 -- 开发迭代使用 `-Mode Focused -Tests <精确测试类名>`,覆盖修改行为及直接调用点;Java 签名变化至少使用 `-Mode Compile` 编译生产与测试源码(等价于 `mvn -pl -am test-compile`)。单服务行为变更收尾使用默认 `Module` 模式执行该服务及上游依赖测试;跨服务 DTO、接口或事件变化必须显式列入生产者和受影响消费者。`-am` 只补上游构建依赖,不自动包含下游服务或 HTTP/事件消费者;不能以单类测试代替模块收尾。 +- 局部逻辑使用 `-Mode Focused -Tests <精确测试类名>`,覆盖修改行为及必要边界即可交付,不因收尾再跑 `Module`。Java 签名变化须覆盖生产与测试源码编译;定向测试已编译时不重复跑 `-Mode Compile`。只有影响扩及服务内多个行为且定向覆盖不足才选 `Module`,先说明原因。跨服务 DTO、接口或事件变化显式列入生产者和实际消费者;`-am` 仅补上游构建依赖,不自动包含下游或 HTTP/事件消费者。 - 仅注释/文档调整不触发 Java 回归;脚本调整执行受影响脚本测试和登记合同。API、Mapper、AGENTS 等静态合同仍按各自触发条件执行。纯重构也应覆盖受影响行为,不能只用编译宣称回归通过。`-Dmaven.test.skip=true` 跳过测试编译,不得作为签名变更交付验证。 -- 全仓回归只在以下情况触发:根 POM/统一依赖版本/聚合拓扑/测试基础配置变化;公共认证、序列化、基础模型等影响广泛且不能可靠限定消费者;跨多域批次集成验收;用户明确要求全量回归。使用 `scripts/backend-full-regression.ps1`,保留当前 CI 全量验证。普通单服务任务、单次提交或对话收尾不自动触发全仓;已明确消费者的公共或跨服务变更按受影响模块验证。同批同一相关输入只跑一次,输入变化后补跑受影响范围,触及上述全量条件再重跑全量。 +- 全仓回归仅用于已确认影响广泛且定向覆盖不足的公共依赖/聚合拓扑/认证/序列化/基础模型变化,或用户要求的全量/跨域集成验收;先说明具体影响,不能因未查清范围而全跑。根 POM、依赖或测试配置改动先按实际影响选编译或测试,不一律全仓。入口为 `scripts/backend-full-regression.ps1`,CI 全量保留。普通提交、单服务任务或对话收尾不触发;有效结果复用,输入变化只补跑受影响范围。 - 不以缩短耗时为由删除金额、库存、权限隔离、状态流转、幂等和并发测试;冗余测试须根据重复场景及维护成本单独评估。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 - 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。 - Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 31e9ca721..347bc2820 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -429,6 +429,7 @@ CREATE TABLE ll_dispatch_order ( review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态', review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次', review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要', + exceptions_json JSON NULL COMMENT '派送异常登记记录及图片引用,不改变派送及库存状态', dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照', loading_review_json JSON NULL COMMENT '当前理货审核事实快照', resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照', 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 59407b4ee..94bdeb1af 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_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_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), @@ -79,9 +80,9 @@ enum BusinessFileCategory { && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台"); } - if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE) + if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE || this == OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE) && tenantId != 0) { - throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台"); + throw new BizException(ResultCode.BAD_REQUEST, "排车证据必须归属平台"); } if (this == FLEET_TRIP_OPERATION_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 f0be4156b..af84151d9 100644 --- a/file/src/main/resources/openapi/dispatch-evidence-v1.yaml +++ b/file/src/main/resources/openapi/dispatch-evidence-v1.yaml @@ -6,7 +6,7 @@ info: 复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。 三类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示, 不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用 - OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE,不可互换。司机本人照片DRIVER_PHOTO仅支持JPG/PNG;其他两类沿PDF/JPG/PNG。 + OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE,不可互换。司机本人照片DRIVER_PHOTO和排车异常图片EXCEPTION仅支持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, OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE] + enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE, OVERSEAS_DISPATCH_EXCEPTION_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 be8d7154d..82ac97a6e 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 EXCEPTION = "OVERSEAS_DISPATCH_EXCEPTION_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; @@ -43,7 +44,7 @@ class DispatchEvidenceFileTest { /** 三类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */ @ParameterizedTest - @ValueSource(strings = {VEHICLE, DRIVER, PHOTO}) + @ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION}) void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) { var files = mock(FileObjectMapper.class); var storage = mock(BusinessFileStorage.class); @@ -65,9 +66,10 @@ class DispatchEvidenceFileTest { verifyNoInteractions(files, storage); } - /** 司机本人照片使用独立平台类别,只保存真实图片,拒绝PDF和伪造图片。 */ - @org.junit.jupiter.api.Test void driverPhotoAcceptsOnlyRealImages() throws Exception { - var storage=storage();var category=BusinessFileCategory.require(PHOTO); + /** 司机照片与异常图片使用独立平台类别,拒绝PDF和伪造图片。 */ + @ParameterizedTest @ValueSource(strings = {PHOTO, EXCEPTION}) + void photoAcceptsOnlyRealImages(String type) throws Exception { + var storage=storage();var category=BusinessFileCategory.require(type); 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); @@ -162,7 +164,7 @@ class DispatchEvidenceFileTest { /** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */ @ParameterizedTest - @ValueSource(strings = {VEHICLE, DRIVER, PHOTO}) + @ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION}) 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 782f4583b..b0ea5d313 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,6 +77,12 @@ 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("/{dispatchNo}/exceptions") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.MarkException request) { + return ApiResponse.success(service.markException(DispatchActorFactory.platform(user),dispatchNo,request)); + } /** 本车证据先上传;维护和当前待审权限分别由服务端核验,引用随对应命令保存。 */ @PostMapping(value="/{dispatchNo}/evidence/{kind}",consumes=org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAnyAuthority('platform:overseas-warehouse:dispatch:edit','platform:fleet:trip:dispatch-review','platform:fleet:trip:loading-review')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java index c5975cb72..0fa048fba 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java @@ -24,6 +24,7 @@ public class DispatchAggregate { private String dispatchReviewJson; private String loadingReviewJson; private String resourcesJson; + private String exceptionsJson; private String vehiclePlate; private String route; private LocalDateTime plannedDepartureAt; 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 d901f837d..5d9dd30ab 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 @@ -4,7 +4,7 @@ import org.springframework.web.multipart.MultipartFile; /** 车辆、司机及审核补充证据属于本张整车,不能借司机主档或客户文件授权。 */ public interface DispatchEvidenceGateway { - enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO } + enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO, EXCEPTION } 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 0797486bc..579473683 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 @@ -83,9 +83,9 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway { || !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/图片证据规则。 */ + /** 司机本人照片与异常证据只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */ private static boolean supportedType(Kind kind,String contentType) { - return TYPES.contains(Objects.toString(contentType,"")) && (kind!=Kind.DRIVER_PHOTO || !"application/pdf".equals(contentType)); + return TYPES.contains(Objects.toString(contentType,"")) && ((kind!=Kind.DRIVER_PHOTO && kind!=Kind.EXCEPTION) || !"application/pdf".equals(contentType)); } /** 无效证据不能作为已核实资料。 */ private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java index c742a4cb4..b58b70e61 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java @@ -94,7 +94,7 @@ public interface DispatchAggregateMapper { UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion}, review_state=#{r.reviewState},review_round=#{r.reviewRound},review_basis_hash=#{r.reviewBasisHash}, dispatch_review_json=#{r.dispatchReviewJson},loading_review_json=#{r.loadingReviewJson}, - cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt}, + exceptions_json=#{r.exceptionsJson},cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt}, estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt}, loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey}, arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt}, 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 a66d4b0f7..17497319f 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 @@ -73,6 +73,13 @@ public final class DispatchCommands { public LoadingComplete { recordVersion = requireVersion(recordVersion); } } + /** 派送异常仅登记事实,不改变派送、签收或库存。 */ + public record MarkException(Long recordVersion, String idempotencyKey, ExceptionType exceptionType, + ReturnDirection returnDirection, String description, List imageFileIds) { + /** 必填版本用于阻止过期页面覆盖当前排车。 */ + public MarkException { recordVersion = requireVersion(recordVersion); } + } + public record Depart(Long recordVersion, String idempotencyKey, String remark) { /** 实际发车不得接受缺省版本。 */ public Depart { recordVersion = requireVersion(recordVersion); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java index 69a90951b..e4aa42451 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java @@ -9,6 +9,8 @@ public final class DispatchTypes { public enum Stage { BEFORE_LOADING, LOADING, AFTER_LOADING, DELIVERING, DELIVERED } public enum ReviewState { DRAFT, DISPATCH_PENDING, DISPATCH_REJECTED, LOADING_PENDING, LOADING_REJECTED, APPROVED } public enum ReviewStatus { NOT_SUBMITTED, PENDING, APPROVED, REJECTED } + public enum ExceptionType { REFUSED, PARTIAL_RECEIPT, VEHICLE_EXCEPTION } + public enum ReturnDirection { OVERSEAS_WAREHOUSE, OTHER } public enum Decision { APPROVED, REJECTED } public enum ResourceMode { MASTER, EXTERNAL } public enum ActorMode { PLATFORM, PDA } @@ -18,6 +20,6 @@ public final class DispatchTypes { public enum Effect { APPLIED, EXISTING } public enum Action { CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW, DISPATCH_REVIEW, - LOADING_REVIEW, RETURN_TO_REVIEW, PICK, LOADING_COMPLETE, ARRANGE_DELIVERY, DEPART + LOADING_REVIEW, RETURN_TO_REVIEW, PICK, LOADING_COMPLETE, ARRANGE_DELIVERY, DEPART, MARK_EXCEPTION } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java index 87cd844d6..2d662b366 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java @@ -44,7 +44,11 @@ public final class DispatchViews { public record DispatchInfo(String route, Instant plannedDepartureAt, Instant departedAt, Instant estimatedArrivalAt, List fbaAppointments, - List appointmentBatches) { } + List appointmentBatches, List exceptions) { } + + /** 排车级异常记录;退货去向只描述安排,不产生退货入库事实。 */ + public record ExceptionRecord(ExceptionType exceptionType, ReturnDirection returnDirection, + String description, List imageFileIds, Instant occurredAt) { } public record Documents(String dispatchNo, List outbounds, List deliveries, 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 0d5ffbc31..a97d8c45b 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 @@ -238,7 +238,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 上传只返回本车证据;待审按当前审核权限授权,客服阶段按维护权限授权。 */ 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()); + requirePlatform(actor);var row=required(actor,number,false); + if(kind==com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.EXCEPTION) { + requireExceptionAllowed(actor,row,row.getRecordVersion()); + return evidence.upload(row.getId(),kind,actor.operatorId(),file); + } + 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,"无当前阶段附件上传权限"); @@ -663,6 +668,40 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA for(var id:ids)evidence.metadata(dispatch,kind,id); } + /** 仅派送中登记异常;原键重试先恢复,避免追加重复记录。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult markException(Actor actor,String number,DispatchCommands.MarkException request) { + var row=lockPlatform(actor,number); + if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限"); + var identity=identity(actor,number,Action.MARK_EXCEPTION,request.recordVersion(),request.idempotencyKey(),request); + var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get(); + requireExceptionAllowed(actor,row,request.recordVersion()); + if(request.exceptionType()==null)throw bad("请选择异常类型"); + var description=trim(request.description()); + if(description!=null && description.length()>1000)throw bad("异常说明不能超过1000字"); + var ids=request.imageFileIds()==null?List.of():request.imageFileIds(); + if(ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("异常图片引用无效或超过20张"); + // 文件须属于本车异常类别,不能借用其他车辆或审核附件。 + for(var id:ids)evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.EXCEPTION,id); + var records=new ArrayList<>(exceptionRecords(row)); + records.add(new DispatchViews.ExceptionRecord(request.exceptionType(),request.returnDirection(),description,List.copyOf(ids),Instant.now())); + row.setExceptionsJson(write(records)); + return saveCommand(row,actor,identity,description); + } + + /** 异常登记复用排车维护权限;版本、任务类型与阶段由服务端再次核验。 */ + private void requireExceptionAllowed(Actor actor,DispatchAggregate row,long version) { + if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限"); + if(row.getRecordVersion()!=version)throw conflict("排车版本已变化,请刷新后再标记异常"); + if(!actions(actor,row).get(Action.MARK_EXCEPTION).allowed())throw conflict("仅派送中的仓库派送排车可以标记异常"); + } + + /** 尚未登记时为空集合,已登记记录按保存顺序完整回看。 */ + private List exceptionRecords(DispatchAggregate row) { + if(row.getExceptionsJson()==null)return List.of(); + return Arrays.asList(read(row.getExceptionsJson(),DispatchViews.ExceptionRecord[].class)); + } + /** 同一只读快照返回分页、总数和基集分布,不能在前端过滤当前页。 */ @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public DispatchViews.Page page(Actor actor, DispatchViews.Query query) { @@ -699,7 +738,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark()); return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()), - new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs), + new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs,exceptionRecords(row)), documents,orderDetails(cargo)); } @@ -817,6 +856,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA for(var action:Action.values()) { if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)continue; boolean state=switch(action) { + case MARK_EXCEPTION -> active && stage==Stage.DELIVERING; case CREATE -> actor.mode()==ActorMode.PLATFORM; case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0; case SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0 && canSubmitReview(row.getReviewState()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java index 29c7db77c..431d3562e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java @@ -73,7 +73,7 @@ public final class DispatchCommandCodec { case DEPART -> DispatchResults.Departed.class; case ARRANGE_DELIVERY -> DispatchDeliveryModels.ArrangementResult.class; case CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW, - DISPATCH_REVIEW, LOADING_REVIEW, RETURN_TO_REVIEW -> DispatchResults.Saved.class; + DISPATCH_REVIEW, LOADING_REVIEW, RETURN_TO_REVIEW, MARK_EXCEPTION -> DispatchResults.Saved.class; }; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerArrivalTimeView.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerArrivalTimeView.java index a8071e88b..72aa9e053 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerArrivalTimeView.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerArrivalTimeView.java @@ -7,5 +7,6 @@ public record ContainerArrivalTimeView( String sourceContainerBookingId, LocalDateTime estimatedWarehouseArrivalAt, LocalDateTime actualWarehouseArrivalAt, - int version + int version, + long planCount ) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 7d12730b1..801fa1ebd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -210,12 +210,19 @@ public interface LastlegOrderMapper extends BaseMapper { @Select("SELECT * FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE") List lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId); + /** 人工时间及计划冻结时间优先;返回计划数量供各页面统一判断默认日期。 */ @Select("") diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 6e4d49036..aa3604c51 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1907,6 +1907,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_order ( review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态', review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次', review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要', + exceptions_json JSON NULL COMMENT '派送异常登记记录及图片引用,不改变派送及库存状态', dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照', loading_review_json JSON NULL COMMENT '当前理货审核事实快照', resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照', 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 88b3221e9..448375c73 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1682,6 +1682,49 @@ paths: required: true schema: type: string + /api/platform/v1/last-leg/dispatches/{dispatchNo}/exceptions: + post: + summary: 登记派送异常 + description: 权限 platform:overseas-warehouse:dispatch:edit;仅有效仓库派送单DELIVERING可登记,保存后阶段不变,不产生退货、签收或库存变化。原键重试恢复原结果。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchCommandResult' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchMarkException' + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string /api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete: post: summary: 确认装车完成并自动进入派送中 @@ -1880,7 +1923,7 @@ paths: /api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}: post: summary: 上传本趟车辆或司机证据 - 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保存,不修改资源,不重启审核。 + description: 客服阶段使用 platform:overseas-warehouse:dispatch:edit;调度待审/理货待审分别使用 platform:fleet:trip:dispatch-review / platform:fleet:trip:loading-review,无需维护车辆权限。VEHICLE和DRIVER支持PDF/JPG/PNG,DRIVER_PHOTO和EXCEPTION仅JPG/PNG;EXCEPTION仅派送中使用排车edit权限,引用随异常登记保存。非空且不超过10MiB;平台、本车、类型复合归属。审核补充附件随ReviewCommand保存,不修改资源,不重启审核。 security: - bearerAuth: [] responses: @@ -1935,6 +1978,7 @@ paths: - VEHICLE - DRIVER - DRIVER_PHOTO + - EXCEPTION /api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}/{fileId}: get: summary: 读取本车证据元数据 @@ -1981,6 +2025,7 @@ paths: - VEHICLE - DRIVER - DRIVER_PHOTO + - EXCEPTION - name: fileId in: path required: true @@ -2024,6 +2069,7 @@ paths: - VEHICLE - DRIVER - DRIVER_PHOTO + - EXCEPTION - name: fileId in: path required: true @@ -3503,12 +3549,13 @@ components: remark: { type: string, maxLength: 500, nullable: true } ContainerArrivalTimeView: type: object - required: [sourceContainerBookingId, version] + required: [sourceContainerBookingId, version, planCount] properties: sourceContainerBookingId: { type: string } - estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true } + estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 人工维护或入库计划冻结的整柜时间;未维护未建计划时为空,由页面按到港当地日期加两个自然日展示默认日期 } actualWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 海外 PDA 首次开始收货时间 } - version: { type: integer, minimum: 0 } + version: { type: integer, minimum: -1, description: -1表示柜内时间版本不一致 } + planCount: { type: integer, format: int64, minimum: 0, description: 整柜已有入库计划数量;非零时禁止默认推算及改期 } ContainerPlanProgressDetail: type: object required: [summary, orders] @@ -5142,6 +5189,7 @@ components: - LOADING_COMPLETE - ARRANGE_DELIVERY - DEPART + - MARK_EXCEPTION DispatchGate: type: object x-java-record: DispatchContracts.Gate @@ -6314,6 +6362,10 @@ components: items: $ref: '#/components/schemas/DispatchBatchReference' nullable: true + exceptions: + type: array + items: + $ref: '#/components/schemas/DispatchExceptionRecord' required: - route - plannedDepartureAt @@ -6321,6 +6373,7 @@ components: - estimatedArrivalAt - fbaAppointments - appointmentBatches + - exceptions DispatchDocuments: type: object x-java-record: DispatchViews.Documents @@ -7940,5 +7993,66 @@ components: - sourceInboundOrderNo - sourceInboundCode - productQuantity + + DispatchExceptionType: + type: string + enum: [REFUSED, PARTIAL_RECEIPT, VEHICLE_EXCEPTION] + description: 拒收、部分签收、车辆异常。 + DispatchReturnDirection: + type: string + enum: [OVERSEAS_WAREHOUSE, OTHER] + description: 退回海外仓、其他;仅登记去向,不自动退货入库。 + DispatchMarkException: + type: object + x-java-record: DispatchCommands.MarkException + required: [recordVersion, idempotencyKey, exceptionType] + properties: + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + idempotencyKey: + type: string + exceptionType: + $ref: '#/components/schemas/DispatchExceptionType' + returnDirection: + allOf: + - $ref: '#/components/schemas/DispatchReturnDirection' + nullable: true + description: + type: string + maxLength: 1000 + nullable: true + imageFileIds: + type: array + maxItems: 20 + uniqueItems: true + items: + type: string + pattern: ^[1-9][0-9]*$ + DispatchExceptionRecord: + type: object + x-java-record: DispatchViews.ExceptionRecord + required: [exceptionType, returnDirection, description, imageFileIds, occurredAt] + properties: + exceptionType: + $ref: '#/components/schemas/DispatchExceptionType' + returnDirection: + allOf: + - $ref: '#/components/schemas/DispatchReturnDirection' + nullable: true + description: + type: string + nullable: true + imageFileIds: + type: array + items: + type: string + occurredAt: + type: string + format: date-time + securitySchemes: bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java index 2e84dc2b9..a71d9901c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.calendar; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; import org.apache.ibatis.mapping.Environment; import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; import org.h2.jdbcx.JdbcDataSource; @@ -28,12 +29,23 @@ class WarehouseCalendarMapperTest { } var config=new MybatisConfiguration(); config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(WarehouseCalendarMapper.class); + config.addMapper(LastlegOrderMapper.class); try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) { var rows=session.getMapper(WarehouseCalendarMapper.class).calendar(LocalDateTime.parse("2026-11-01T00:00:00"),LocalDateTime.parse("2026-12-01T00:00:00"),List.of("1")); assertEquals(3,rows.size()); assertEquals("1",rows.get(0).sourceContainerBookingId()); assertNull(rows.get(0).plannedAt()); assertEquals(2,rows.get(1).version()); assertEquals("2026-11-10T09:00",rows.get(1).plannedAt().toString()); assertEquals(1,rows.get(2).planCount()); assertNotNull(rows.get(2).plannedAt()); + // 清关提柜与日历必须读取相同的人工/冻结事实,默认日期由前端统一推算。 + var customs=session.getMapper(LastlegOrderMapper.class).findContainerArrivalTimes(List.of("1","2","3")); + assertEquals(3,customs.size()); + for(var calendar:rows) { + var arrival=customs.stream().filter(row -> row.sourceContainerBookingId().equals(calendar.sourceContainerBookingId())).findFirst().orElseThrow(); + assertEquals(calendar.plannedAt(),arrival.estimatedWarehouseArrivalAt()); + assertEquals(calendar.actualAt(),arrival.actualWarehouseArrivalAt()); + assertEquals(calendar.version(),arrival.version()); + assertEquals(calendar.planCount(),arrival.planCount()); + } } } } 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 dd375b1b1..235006c02 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 @@ -45,6 +45,15 @@ class HttpDispatchEvidenceGatewayTest { 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); } + /** 异常图片独立归属本车,审核照片及PDF均不能用作异常图片。 */ + @Test void exceptionImagesRejectOtherKindsCarsAndPdf() { + var photo=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE","9","exception.png","image/png",128L); + assertThat(HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),9,DispatchEvidenceGateway.Kind.EXCEPTION,3L).fileId()).isEqualTo(3L); + assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),10,DispatchEvidenceGateway.Kind.EXCEPTION,3L)).isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),9,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,3L)).isInstanceOf(ResponseStatusException.class); + var pdf=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE","9","exception.pdf","application/pdf",128L); + assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",pdf),9,DispatchEvidenceGateway.Kind.EXCEPTION,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/DispatchExceptionPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchExceptionPersistenceTest.java new file mode 100644 index 000000000..b1fdd40d8 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchExceptionPersistenceTest.java @@ -0,0 +1,89 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.Mockito.*; + +/** 通过真实Mapper和事务验证异常登记,不把登记事实解释成签收或退货。 */ +class DispatchExceptionPersistenceTest { + /** 登记使用排车独立维护权限。 */ + @BeforeEach void authorize() { SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator-test","","platform:overseas-warehouse:dispatch:edit")); } + /** 清除线程身份,避免影响其他权限回归。 */ + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + + /** 每种原型类型均可保存,原键重试不追加,最终完成后仍恢复原结果。 */ + @Test void recordsAllTypesAndReplaysWithoutChangingDeliveryFacts() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture();var no=create(f);long version=0; + var before=f.db.queryForMap("SELECT stage,shipped_package_count,departed_at,review_state FROM ll_dispatch_order"); + for(var type:ExceptionType.values()) { + var body=new DispatchCommands.MarkException(version,"exception-"+type,type,ReturnDirection.OVERSEAS_WAREHOUSE,"真实异常",List.of()); + var saved=f.tx.execute(t->f.service.markException(f.actor,no,body)); + var replay=f.tx.execute(t->f.service.markException(f.actor,no,body)); + assertThat(saved.stage()).isEqualTo(Stage.DELIVERING);assertThat(replay.replayed()).isTrue(); + assertThat(replay.recordVersion()).isEqualTo(saved.recordVersion());version=saved.recordVersion(); + } + var records=f.service.detail(f.actor,no).dispatchInfo().exceptions();assertThat(records).hasSize(3); + assertThat(records).allSatisfy(r->{assertThat(r.description()).isEqualTo("真实异常");assertThat(r.occurredAt()).isNotNull();}); + assertThat(f.db.queryForMap("SELECT stage,shipped_package_count,departed_at,review_state FROM ll_dispatch_order")).isEqualTo(before); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history WHERE action='MARK_EXCEPTION'",Long.class)).isEqualTo(3); + var first=new DispatchCommands.MarkException(0L,"exception-REFUSED",ExceptionType.REFUSED,ReturnDirection.OVERSEAS_WAREHOUSE,"真实异常",List.of()); + f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERED'"); + assertThat(f.tx.execute(t->f.service.markException(f.actor,no,first)).replayed()).isTrue(); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,new DispatchCommands.MarkException(0L,"exception-REFUSED",ExceptionType.REFUSED,null,"改了内容",List.of())))).isInstanceOf(RuntimeException.class); + assertThat(f.service.detail(f.actor,no).dispatchInfo().exceptions()).hasSize(3); + } + + /** 非派送阶段、过期版本、只读身份、PDA及运输任务不能登记。 */ + @Test void rejectsWrongStageVersionPermissionAndTask() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture();var no=create(f);var body=request(0,"blocked",List.of()); + for(var stage:List.of("BEFORE_LOADING","LOADING","AFTER_LOADING","DELIVERED")) { + f.db.update("UPDATE ll_dispatch_order SET stage=?",stage); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("仅派送中"); + } + f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING'"); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,request(9,"stale",List.of())))).hasMessageContaining("版本"); + SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator-test","","platform:overseas-warehouse:dispatch:list")); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("权限"); + authorize();f.db.update("UPDATE ll_dispatch_order SET task_type='LOCAL_TRUCK'"); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("仅派送中"); + f.db.update("UPDATE ll_dispatch_order SET task_type='DELIVERY',status='CANCELLED'"); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("仅派送中"); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(new DispatchContracts.Actor("pda",ActorMode.PDA,"9"),no,body))).hasMessageContaining("仅平台"); + assertThat(f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isZero(); + } + + /** 图片严格核对当前车和类别;附件失败或缺类型不保存任何业务记录。 */ + @Test void verifiesImageOwnershipAndKeepsFailedRequestUnchanged() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture();var no=create(f);var id=f.repository.find(no).getId(); + var files=mock(DispatchEvidenceGateway.class);ReflectionTestUtils.setField(f.service,"evidence",files); + when(files.metadata(id,DispatchEvidenceGateway.Kind.EXCEPTION,91L)).thenThrow(new IllegalArgumentException("文件不属于当前车")); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,request(0,"files",List.of(91L))))).hasMessageContaining("不属于"); + assertThat(f.service.detail(f.actor,no).dispatchInfo().exceptions()).isEmpty(); + var missingType=new DispatchCommands.MarkException(0L,"no-type",null,null,null,List.of()); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,missingType))).hasMessageContaining("请选择异常类型"); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,request(0,"duplicates",List.of(91L,91L))))).hasMessageContaining("图片引用"); + doReturn(new DispatchEvidenceGateway.File(91L,"现场.png","image/png",10L)).when(files).metadata(id,DispatchEvidenceGateway.Kind.EXCEPTION,91L); + f.tx.execute(t->f.service.markException(f.actor,no,request(0,"files",List.of(91L)))); + assertThat(f.service.detail(f.actor,no).dispatchInfo().exceptions().get(0).imageFileIds()).containsExactly(91L); + verify(files,times(2)).metadata(id,DispatchEvidenceGateway.Kind.EXCEPTION,91L); + } + + /** 创建真实排车后仅以SQL模拟已发车前置,不声称此用例执行装车流程。 */ + private String create(DispatchAggregatePersistenceTest.Fixture f) { + var created=f.tx.execute(t->f.service.create(f.actor,f.configured("create",List.of()))); + f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',shipped_package_count=3,departed_at=CURRENT_TIMESTAMP");return created.dispatchNo(); + } + /** 构造类型必填、其余选填的最小登记请求。 */ + private DispatchCommands.MarkException request(long version,String key,List images) { return new DispatchCommands.MarkException(version,key,ExceptionType.VEHICLE_EXCEPTION,null,null,images); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 61b4a81dd..ddd8685d2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -148,7 +148,7 @@ class LastlegOrderServiceTest { when(orders.countContainerPlans("BOOKING-1")).thenReturn(0L); when(orders.updateContainerExpectedWarehouseArrival("BOOKING-1", estimatedAt, 0)).thenReturn(2); when(orders.findContainerArrivalTimes(List.of("BOOKING-1"))).thenReturn(List.of( - new ContainerArrivalTimeView("BOOKING-1", estimatedAt, null, 1))); + new ContainerArrivalTimeView("BOOKING-1", estimatedAt, null, 1, 0))); ContainerArrivalTimeView result = service.changeExpectedWarehouseArrival("BOOKING-1", "arrival-1", new LastlegOrderRequests.ChangeExpectedWarehouseArrival(estimatedAt, 0, null), "admin"); diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 1d9b08948..0e74c2bb5 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -11,7 +11,7 @@ | 类别 | 正式入口 | 管理边界 | |---|---|---| | 本机重型检查限流 | `local-check.ps1` | 所有工作目录、对话共用两个用户级执行名额;PowerShell 7,状态/超时/并发与 Job Object 回收规则见外层 `docs/AI工程执行与协作约束.md`;只读状态不申请重型名额 | -| 后端分层检查 | `backend-check.ps1` | 默认指定模块回归,支持测试源码编译和精确测试类;必填模块,自动接入重型限流;全量触发条件见后端根指引,参数及副作用见 README | +| 后端分层检查 | `backend-check.ps1` | 局部任务显式选 Focused;模式缺省为 Module,不是日常必跑;必填模块并接入限流,范围见后端根指引,参数见 README | | 测试/灰度单票验收 | `prod-release-acceptance.mjs`、`prod-release-delivery-stage.mjs`、内部 `prod-release-order-stage.mjs` | 显式 test/gray、统一 Gateway/accessToken;同一票义乌到洛杉矶、前置同时核对东莞;full-flow 串行覆盖买单FBA正常链至签收回传及车次资源释放,保留分阶段入口与独立检查点;不调用测试造数模块,不创建账号/主档或删除数据;配置和边界见 README | | Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;默认模块覆盖平台全链路,定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 | | Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 | diff --git a/scripts/README.md b/scripts/README.md index 829609a8a..3bda1eebb 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -458,13 +458,13 @@ Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块 .\scripts\backend-check.ps1 -Modules order -Mode Compile # 开发迭代:精确类名,支持逗号分隔和全限定名。 .\scripts\backend-check.ps1 -Modules common-core -Mode Focused -Tests ApiResponseTest -# 单服务收尾:指定模块及上游依赖测试。 +# 服务级影响且定向覆盖不足:指定模块及上游依赖测试。 .\scripts\backend-check.ps1 -Modules order -# 跨服务收尾:显式列出受影响生产者、消费者。 +# 跨服务模块回归:显式列出受影响生产者、消费者。 .\scripts\backend-check.ps1 -Modules 'order,firstleg' ``` -三种模式均使用根 reactor 的 `-pl -am`,不会安装制品到本地仓库,不默认 `clean`。`Compile` 运行 `test-compile`;`Focused`/`Module` 运行 `test`。`-am` 只补上游构建依赖,HTTP/事件下游消费者须由调用者显式列入。公共模块变更也须核对下游,不能只测试公共模块自身。Focused 只用于迭代,不能替代有行为变化时的模块收尾;API、Mapper、索引等相关静态合同仍按其规则单独执行。 +三种模式均使用根 reactor 的 `-pl -am`,不会安装制品到本地仓库,不默认 `clean`。`Compile` 运行 `test-compile`;`Focused`/`Module` 运行 `test`。`-am` 只补上游构建依赖,HTTP/事件下游消费者须显式列入。局部逻辑使用 `Focused` 覆盖修改行为及必要边界即可交付,不因收尾改跑 `Module`;模块回归只用于定向覆盖不足的服务级影响。公共模块按实际消费者验证。模式缺省值为 `Module`,局部任务须显式选 `Focused`;API、Mapper、索引合同按各自条件执行。 Focused 不接受通配符或方法选择,每个类必须唯一命中所选模块的 `src/test/java`。为允许上游无同名测试,Maven 设置 `surefire.failIfNoSpecifiedTests=false`,随后逐类核对本轮更新的 Surefire XML;报告缺失、陈旧、全部跳过或含失败均不能通过,避免零测试假绿。 diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 9b5d5ddbe..b1e036567 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -57,6 +57,8 @@ export function planA0LocationIsolation(locations, requiredSlots = 1, } const MODULE_DEPENDENCIES = new Map([ + ['inbound-weight-ocr', ['orders']], + ['orders', ['merchant-qualification-fixture']], ['phase5-root-fulfillment', ['merchant-qualification-fixture']], ['pda-loading-link-regression', ['merchant-qualification-fixture']], ['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']], diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 1d33571e4..14a62e2cb 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -4,7 +4,9 @@ 固定托盘造数中,`inbound-measurement.mjs` 只复用 `TP-001`、`YW-TP-002` 指定池中的现有托盘;一只可复用,不要求两只同时存在,两只均缺失才阻断,不自动选择池外托盘。仅明确设置内部 `allowAnyExistingPallet` 的通用场景可读取当前仓库已有资源;两者均不创建托盘。 -OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖个人桌面路径。合成称重测试图只证明上传、识别响应及人工确认协议,不代表现场称重照片或设备验收。费用项目模块按受控编码验证112条正式基线,合法扩展项目保留并单独核对分页和筛选。散板普通状态接口仅验证取消及非法派车拒绝,原单须出现在待匹配需求池;需求池接车到签收另列覆盖缺口,不以手工跳状态冒充履约。 +OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖个人桌面路径;定向运行自动展开客户资质及订单前置。识别失败的空字段允许省略,但识别成功必须有有效重量与单位;人工修正和确认分别增加版本。合成称重测试图只证明上传、识别响应及人工确认协议,不代表现场称重照片或设备验收。费用项目模块按受控编码验证112条正式基线,合法扩展项目保留并单独核对分页和筛选。散板普通状态接口仅验证取消及非法派车拒绝,原单须出现在待匹配需求池;需求池接车到签收另列覆盖缺口,不以手工跳状态冒充履约。 + +预约工作查询在本轮业务写入后,仅对服务端明确返回的 A0409“预约批次或运输关联已变化,服务端正在协调完整工作清单”或“已开船工作范围变化,请先完成服务端同步”每 5 秒重读,最多等待 240 秒;超时保留失败,其他错误不重试。这是后台完整扫描的就绪等待,不是页面响应性能验收,也不触发同步写接口。 先运行 `node scripts/acceptance-overview.mjs`(后端根目录,无网络)查看当前默认与可选模块。范围来自真实注册表;本文件描述各模块用途与依赖,不代表所有模块默认执行或已通过。报告解读及跨端/专项边界见 [总索引](../README.md#先看范围再看证据),最新运行状态只查外层部署契约。 diff --git a/scripts/admin-test-env-modules/appointment-work-contract.test.mjs b/scripts/admin-test-env-modules/appointment-work-contract.test.mjs index 2a9827277..abc56814c 100644 --- a/scripts/admin-test-env-modules/appointment-work-contract.test.mjs +++ b/scripts/admin-test-env-modules/appointment-work-contract.test.mjs @@ -1,7 +1,26 @@ import assert from 'node:assert/strict' import test from 'node:test' import { readFileSync } from 'node:fs' -import { run } from './appointment-work.mjs' +import { run, readAfterSynchronization } from './appointment-work.mjs' + +test('同步就绪只重试明确协调状态,超限及其他错误原样保留', async () => { + const coordinating = {http:200,body:{code:'A0409',message:'预约批次或运输关联已变化,服务端正在协调完整工作清单'}} + const success = {http:200,body:{code:'200',data:{}}} + let calls = 0 + assert.equal(await readAfterSynchronization({request:async()=>++calls === 1 ? coordinating : success}, '/summary', {attempts:3,delay:async()=>{}}),success) + assert.equal(calls,2) + const scopeChanged = {http:200,body:{code:'A0409',message:'已开船工作范围变化,请先完成服务端同步'}} + calls=0 + assert.equal(await readAfterSynchronization({request:async()=>++calls===1?scopeChanged:success}, '/summary', {attempts:3,delay:async()=>{}}),success) + assert.equal(calls,2) + calls=0 + assert.equal(await readAfterSynchronization({request:async()=>{calls++;return coordinating}}, '/summary', {attempts:3,delay:async()=>{}}),coordinating) + assert.equal(calls,3) + const other={http:500,body:{code:'S0500'}} + assert.equal(await readAfterSynchronization({request:async()=>other}, '/summary', {delay:async()=>assert.fail('must not retry')}),other) + const conflict={http:200,body:{code:'A0409',message:'预约版本冲突'}} + assert.equal(await readAfterSynchronization({request:async()=>conflict}, '/summary', {delay:async()=>assert.fail('must not retry unrelated conflict')}),conflict) +}) const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const moduleSource = readFileSync(new URL('./appointment-work.mjs', import.meta.url), 'utf8') diff --git a/scripts/admin-test-env-modules/appointment-work.mjs b/scripts/admin-test-env-modules/appointment-work.mjs index 321290252..c8fbbb6fb 100644 --- a/scripts/admin-test-env-modules/appointment-work.mjs +++ b/scripts/admin-test-env-modules/appointment-work.mjs @@ -1,3 +1,18 @@ +import { setTimeout as sleep } from 'node:timers/promises' + +export async function readAfterSynchronization(context, path, { attempts = 49, delay = () => sleep(5000) } = {}) { + let response + for (let attempt = 1; attempt <= attempts; attempt += 1) { + response = await context.request('GET', path) + const coordinating = response.http === 200 && String(response.body?.code) === 'A0409' + && ['预约批次或运输关联已变化,服务端正在协调完整工作清单', + '已开船工作范围变化,请先完成服务端同步'].includes(response.body?.message) + if (!coordinating || attempt === attempts) return response + await delay() + } + return response +} + function count(value) { const number = Number(value) return Number.isFinite(number) ? number : NaN @@ -15,7 +30,7 @@ export async function run(context) { const from = shanghaiDate(-7) const to = shanghaiDate(7) - const calendarResponse = await context.request('GET', `${base}/calendar?from=${from}&to=${to}`) + const calendarResponse = await readAfterSynchronization(context, `${base}/calendar?from=${from}&to=${to}`) const calendar = context.apiSuccess(module, '查询预约工作日历', calendarResponse) const days = Array.isArray(calendar?.days) ? calendar.days : [] const dayTotal = days.reduce((sum, day) => sum + Number(day?.total || 0), 0) @@ -29,14 +44,14 @@ export async function run(context) { `date=${day?.date || 'missing'},total=${day?.total ?? 'missing'},typeTotal=${typeTotal}`) } - const summaryResponse = await context.request('GET', `${base}/summary`) + const summaryResponse = await readAfterSynchronization(context, `${base}/summary`) const summary = context.apiSuccess(module, '查询预约工作汇总', summaryResponse) const summaryFields = ['all', 'followToday', 'overdue', 'undated', 'review'] context.assert(module, '工作汇总五项计数完整', summaryResponse, summaryFields.every((field) => Number.isFinite(Number(summary?.[field]))), summaryFields.map((field) => `${field}=${summary?.[field] ?? 'missing'}`).join(',')) - const pageResponse = await context.request('GET', `${base}/items?view=ALL&page=1&size=20`) + const pageResponse = await readAfterSynchronization(context, `${base}/items?view=ALL&page=1&size=20`) const page = context.apiSuccess(module, '查询预约工作事项首页', pageResponse) const records = Array.isArray(page?.records) ? page.records : [] const total = count(page?.total) diff --git a/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs b/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs index eed42368e..c3eb62d36 100644 --- a/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs @@ -1,8 +1,10 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' import { + validRecognition, sameOptionalWeight, confirmedVersion, confirmedNetSummary, isTransientGatewayFailure, normalizeWeightPalletOptionPage, @@ -14,7 +16,22 @@ import { workspaceWeightMatchesBaseline, } from './inbound-weight-ocr.mjs' +test('OCR failure permits absent optional values but recognition requires a valid weight; correction and confirmation each advance version', () => { + assert.equal(validRecognition({ recognitionStatus: 'FAILED' }), true) + assert.equal(validRecognition({ recognitionStatus: 'RECOGNIZED' }), false) + assert.equal(validRecognition({ recognitionStatus: 'RECOGNIZED', weightValue: 45, weightUnit: 'KG', confidence: 0.9 }), true) + assert.equal(validRecognition({ recognitionStatus: 'RECOGNIZED', weightValue: 45, weightUnit: 'KG', confidence: 2 }), false) + assert.equal(sameOptionalWeight(undefined, null), true) + assert.equal(sameOptionalWeight(undefined, 0), false) + assert.equal(sameOptionalWeight(5, 6), false) + assert.equal(confirmedVersion(3, false), 4) + assert.equal(confirmedVersion(3, true), 5) +}) + test('retries only transient gateway failures for the unauthenticated upload probe', () => { + const modules = normalizeModuleOrder(['inbound-weight-ocr']) + assert.ok(modules.indexOf('merchant-qualification-fixture') < modules.indexOf('orders')) + assert.ok(modules.indexOf('orders') < modules.indexOf('inbound-weight-ocr')) assert.equal(isTransientGatewayFailure({ http: 0 }), true) assert.equal(isTransientGatewayFailure({ http: 502 }), true) assert.equal(isTransientGatewayFailure({ http: 503 }), true) diff --git a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs index 0b1912ed7..5fd02301e 100644 --- a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs +++ b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs @@ -22,6 +22,22 @@ function closeNumber(actual, expected, tolerance = 0.0005) { return Number.isFinite(Number(actual)) && Math.abs(Number(actual) - Number(expected)) < tolerance } +export function validRecognition(result) { + if (!result || !OCR_STATUSES.has(result.recognitionStatus)) return false + if (result.confidence != null && (!Number.isFinite(Number(result.confidence)) || Number(result.confidence) < 0 || Number(result.confidence) > 1)) return false + if (result.recognitionStatus === 'FAILED') return result.weightValue == null + return result.weightValue != null && Number(result.weightValue) > 0 + && normalizedGrossKg(result.weightValue, result.weightUnit) != null +} + +export function sameOptionalWeight(actual, expected) { + return actual == null || expected == null ? actual == null && expected == null : closeNumber(actual, expected) +} + +export function confirmedVersion(version, hasAdjustment) { + return Number(version) + (hasAdjustment ? 2 : 1) +} + function palletSnapshotComplete(pallet) { return Boolean(id(pallet?.palletId)) && typeof pallet?.palletCode === 'string' && pallet.palletCode.length > 0 @@ -254,10 +270,7 @@ async function createUploadedResult(context, target, pallet, image, overrides = Boolean(fileId) && typeof upload?.fileId === 'string', `fileIdType=${typeof upload?.fileId}, status=${upload?.recognitionStatus ?? 'missing'}`, SCENARIO_OPTIONS) context.assert(MODULE, 'OCR 上传响应字段完整且置信度受限', uploadResponse, - upload && OCR_STATUSES.has(upload.recognitionStatus) - && Object.hasOwn(upload, 'weightValue') && Object.hasOwn(upload, 'weightUnit') - && Object.hasOwn(upload, 'confidence') - && (upload.confidence == null || (Number(upload.confidence) >= 0 && Number(upload.confidence) <= 1)), + validRecognition(upload), `status=${upload?.recognitionStatus ?? 'missing'}, hasWeight=${upload?.weightValue != null}, confidence=${upload?.confidence ?? 'null'}`, SCENARIO_OPTIONS) if (!fileId) throw new Error('称重图片上传未返回 fileId,无法登记和清理测试资源') @@ -269,8 +282,7 @@ async function createUploadedResult(context, target, pallet, image, overrides = const retry = context.apiSuccess(MODULE, '重试当前称重图片 OCR', retryResponse, `fileId=${fileId}`, SCENARIO_OPTIONS) context.assert(MODULE, 'OCR 重试保持文件身份并返回识别契约', retryResponse, - id(retry?.fileId) === fileId && OCR_STATUSES.has(retry?.recognitionStatus) - && Object.hasOwn(retry || {}, 'weightValue') && Object.hasOwn(retry || {}, 'confidence'), + id(retry?.fileId) === fileId && validRecognition(retry), `sameFile=${id(retry?.fileId) === fileId}, status=${retry?.recognitionStatus ?? 'missing'}`, SCENARIO_OPTIONS) if (retry) recognized = retry @@ -474,8 +486,8 @@ export async function run(context) { const registeredRow = registered.workspace?.weightResults?.find((row) => id(row.id) === original.resultId) context.assert(MODULE, '工作区保留本轮 OCR 原值且未覆盖历史结果', registered.response, registeredRow && !baselineIds.has(original.resultId) - && closeNumber(registeredRow.ocrWeightValue, original.result.ocrWeightValue) - && registeredRow.ocrWeightUnit === original.result.ocrWeightUnit, + && sameOptionalWeight(registeredRow.ocrWeightValue, original.result.ocrWeightValue) + && (registeredRow.ocrWeightUnit ?? null) === (original.result.ocrWeightUnit ?? null), `resultFound=${Boolean(registeredRow)}, baselineCount=${baselineIds.size}, currentCount=${registered.workspace?.weightResults?.length ?? 'n/a'}`, SCENARIO_OPTIONS) @@ -506,7 +518,7 @@ export async function run(context) { const confirmedTare = Number(confirmed?.palletTareWeightKg) const confirmedNet = Number(confirmed?.netWeightKg) context.assert(MODULE, '确认结果返回托盘快照、毛重和去皮净重', confirmResponse, - confirmed?.recognitionStatus === 'CONFIRMED' && Number(confirmed?.version) === original.version + 1 + confirmed?.recognitionStatus === 'CONFIRMED' && Number(confirmed?.version) === confirmedVersion(original.version, !canConfirmOriginal) && id(confirmed?.palletId) === id(pallet.palletId) && confirmed?.palletCode === pallet.palletCode && confirmed?.palletType === pallet.palletType && closeNumber(confirmed?.palletCapacityCbm, pallet.capacityCbm) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index 636c72864..eb9860bcc 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -53,7 +53,7 @@ test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', () assert.match(fixture, /MERCHANT_TEST_PASSWORD/) assert.match(fixture, /context\.options\?\.customerName/) assert.match(fixture, /tenantName/) - assert.match(fixture, /expectedProcurementPrefix: customerName \? null : 'QLYSXDKH'/) + assert.doesNotMatch(fixture, /expectedProcurementPrefix/) assert.match(acceptance, /administrator-phone-availability\?phone=/) assert.match(acceptance, /attempt < 100/) assert.doesNotMatch(acceptance, /phoneSequence % 10/) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs index 07fa6f343..0207beac9 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs @@ -120,10 +120,8 @@ export async function run(context, { publishEnvironment = true } = {}) { } context.context.merchantQualificationFixture = { tenantId, tenantName, username, password, marker: context.marker, - expectedProcurementPrefix: customerName ? null : 'QLYSXDKH', } context.createdResources.merchantQualificationFixture = { tenantId, tenantName, username, marker: context.marker, - expectedProcurementPrefix: customerName ? null : 'QLYSXDKH', } } diff --git a/scripts/admin-test-env-modules/pda-audit-sailing-contract.test.mjs b/scripts/admin-test-env-modules/pda-audit-sailing-contract.test.mjs index 01e43a3a8..8875b71e9 100644 --- a/scripts/admin-test-env-modules/pda-audit-sailing-contract.test.mjs +++ b/scripts/admin-test-env-modules/pda-audit-sailing-contract.test.mjs @@ -111,4 +111,8 @@ test('船期日历模块注册为默认模块并核对月历、渠道、模板 assert.ok(ctx.calls[3].path.includes('/sailing-assignments/exceptions')) assert.ok(ctx.calls[4].path.endsWith('/11')) assert.ok(ctx.calls[5].path.endsWith('/999999999999999')) + delete sailing.etaAt + await runSailingCalendar(ctx) + sailing.etaAt = 'invalid-eta' + await assert.rejects(runSailingCalendar(ctx), /月历记录、汇总与筛选选项结构完整/) }) diff --git a/scripts/admin-test-env-modules/phase3-container-overview.mjs b/scripts/admin-test-env-modules/phase3-container-overview.mjs index 503ddfe26..09b88b72d 100644 --- a/scripts/admin-test-env-modules/phase3-container-overview.mjs +++ b/scripts/admin-test-env-modules/phase3-container-overview.mjs @@ -103,7 +103,7 @@ export async function run(context) { const clearance = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}/clearance`) context.assert(MODULE, '国内未出仓时目的港清关不生成国外工作台数据', clearance, clearance.http === 200 && String(clearance?.body?.code) === 'A0404' - && String(clearance?.body?.message || '').includes('国外工作台货柜不存在'), + && clearance?.body?.message === '货柜尚未离仓或实际开船,不能维护目的港清关资料', `HTTP=${clearance.http}, code=${clearance?.body?.code ?? 'missing'}, containerBookingId=${containerId}`, OPTIONS) const customs = await context.request('GET', `/api/platform/v1/first-leg/loading-lists/${encodeURIComponent(planId)}/customs?warehouseId=${encodeURIComponent(warehouseId)}`) if (fixture?.finalStatus === 'LOADED') { diff --git a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs index 211ec91ec..579927d24 100644 --- a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs @@ -55,6 +55,7 @@ test('overview regression requires workflowStatus and distinguishes 100 percent test('pre-departure overview rejects destination workbench documents explicitly', () => { assert.match(overviewAcceptance, /国内未出仓时 \$\{documentType\} 不生成国外工作台数据/) assert.match(overviewAcceptance, /国内未出仓时目的港清关不生成国外工作台数据/) + assert.match(overviewAcceptance, /货柜尚未离仓或实际开船,不能维护目的港清关资料/) assert.match(overviewAcceptance, /国外工作台货柜不存在/) }) 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 c78d8740c..27d6a21de 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 @@ -207,7 +207,8 @@ function fixture(options = {}) { test('module is context injected, blocks missing prerequisites and excludes forbidden surfaces', () => { assert.match(source, /export async function run\(context\)/) assert.doesNotMatch(source, /https?:\/\/|process\.env|fetch\(/) - assert.doesNotMatch(source, /addCoverageGap|coverageGap\(/) + assert.equal((source.match(/addCoverageGap\(/g) || []).length, 1) + assert.match(source, /looseboard-pool-fulfillment/) assert.match(source, /deploymentBlocker: true/) assert.match(source, /contactPhone: '\d+'/) assert.doesNotMatch(source, /contactPhone: '\+[^']+'/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs index ef7f9d3ca..91f3eae7b 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs @@ -5,6 +5,28 @@ import test from 'node:test' import { run, vehicleCapacityMatches } from './phase4-overseas-master-data.mjs' import { run as runCare } from './fleet-vehicle-care.mjs' +test('平台用户手机号已存在只换号重试,未知创建结果不得重发', async () => { + for (const collision of [true, false]) { + const { context } = fakeContext({ softAssertions: true }) + const original = context.request.bind(context) + const phones = [] + let sequence = 0 + context.findAvailablePhone = async () => `1380000${String(++sequence).padStart(4, '0')}` + context.request = async (method, path, body, ...rest) => { + if (method === 'POST' && path === '/api/platform/v1/iam/users') { + phones.push(body.phone) + if (phones.length === 1) return collision + ? {http:200,body:{code:'A0409',message:'手机号已存在'}} + : {http:500,body:{code:'S0500'}} + } + return original(method,path,body,...rest) + } + await run(context) + assert.equal(phones.length, collision ? 2 : 1) + if (collision) assert.notEqual(phones[0],phones[1]) + } +}) + test('运维专项接受NON_NULL真实响应但仍完成全部六类记录和两车停用', async () => { const { context, state } = fakeContext() const request = context.request.bind(context) diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs index 39bfb6f33..71c8a6838 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs @@ -375,13 +375,19 @@ async function verifyCanonicalPermissionDenial(context, resource) { if (!roleId) throw new Error('无主数据权限角色未返回ID') resource.permissionProbe = { roleId, membershipId: null, roleStatus: 'ACTIVE', userStatus: null } - const phone = await context.findAvailablePhone(MODULE) const username = `p4_md_deny_${suffix}`.slice(0, 64).toLowerCase() const password = `P4md${suffix.slice(-8)}A1!` - const userResponse = await context.request('POST', '/api/platform/v1/iam/users', { - username, displayName: `四期主数据拒绝-${suffix}`, phone, - email: `${username}@example.com`, tenantId: '0', roleIds: [roleId], password, status: 'ACTIVE', - }) + let userResponse + for (let attempt = 0; attempt < 3; attempt += 1) { + const phone = await context.findAvailablePhone(MODULE) + userResponse = await context.request('POST', '/api/platform/v1/iam/users', { + username, displayName: `四期主数据拒绝-${suffix}`, phone, + email: `${username}@example.com`, tenantId: '0', roleIds: [roleId], password, status: 'ACTIVE', + }) + // 客户管理员候选检查不覆盖平台用户;仅明确未创建的手机号冲突允许换号。 + if (!(userResponse.http === 200 && String(userResponse.body?.code) === 'A0409' + && userResponse.body?.message === '手机号已存在')) break + } const user = requireData(context, '创建无主数据权限用户', userResponse) const membershipId = asId(user?.membershipId) if (!membershipId) throw new Error('无主数据权限用户未返回membershipId') diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs index 9653b962e..73c20dc83 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs @@ -10,9 +10,10 @@ const pda = readFileSync(new URL('./phase4-overseas-pda.mjs', import.meta.url), test('采购单号按北京时间日期执行精确合同', () => { const instant = new Date('2026-09-05T16:30:00.000Z') assert.equal(beijingDate(instant), '20260906') - assert.equal(matchesProcurementNo('QLYSXDKH202609060001', 'QLYSXDKH', instant), true) - assert.equal(matchesProcurementNo('QLYSXDKH202609050001', 'QLYSXDKH', instant), false) - assert.equal(matchesProcurementNo('QLYSXDKH20260906001', 'QLYSXDKH', instant), false) + assert.equal(matchesProcurementNo('CG202609060001', instant), true) + assert.equal(matchesProcurementNo('CG202609050001', instant), false) + assert.equal(matchesProcurementNo('CG20260906001', instant), false) + assert.equal(matchesProcurementNo('QLYSXDKH202609060001', instant), false) }) test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释放', () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs index 3c473f159..e67eba4c2 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs @@ -28,9 +28,8 @@ export function beijingDate(now = new Date()) { return `${parts.year}${parts.month}${parts.day}` } -export function matchesProcurementNo(orderNo, prefix, now = new Date()) { - return typeof prefix === 'string' && prefix.length > 0 - && new RegExp(`^${prefix}${beijingDate(now)}\\d{4}$`).test(String(orderNo || '')) +export function matchesProcurementNo(orderNo, now = new Date()) { + return new RegExp(`^CG${beijingDate(now)}\\d{4}$`).test(String(orderNo || '')) } async function poll(action, predicate, attempts = 60, intervalMs = 1_000) { @@ -133,8 +132,8 @@ async function procurementFlow(context, fixture, token, warehouseId) { customerName: fixture.tenantName, customerToken: token, warehouseId, upload, readWallet: async () => { const result = await platformAccount(context, customerId); if (!success(result.response) || !result.account) throw new Error('本轮客户钱包查询失败'); return result.account } }) assert(context, '采购正常全链路及资金完成', null, proof.status === 'PASSED', `orderNo=${proof.orderNo}`) - assert(context, '采购单号日期和客户前缀正确', null, - fixture.expectedProcurementPrefix ? matchesProcurementNo(proof.orderNo, fixture.expectedProcurementPrefix) : /^[A-Z0-9]{1,8}\d{8}\d{4}$/.test(proof.orderNo), `orderNo=${proof.orderNo}`) + assert(context, '采购单号为CG加北京时间日期和流水', null, + matchesProcurementNo(proof.orderNo), `orderNo=${proof.orderNo}`) return { orderNo: proof.orderNo, walletBusinessNos: proof.walletBusinessNos, settledAmountCny: proof.settledAmountCny } } finally { for (const check of context.createdResources.procurementSample?.checks || []) assert(context, check.label, null, check.passed)