优化
This commit is contained in:
@@ -133,9 +133,9 @@
|
|||||||
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
|
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
|
||||||
- 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
- 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
||||||
- 后端本地验证默认使用 `scripts/backend-check.ps1`,必须显式指定 `-Modules`,不传模块直接拒绝;命令及参数见 `scripts/README.md` 的“后端分层检查”。入口自动经 `local-check.ps1` 限流,`-DryRun` 只检查选择范围,不代表测试通过。
|
- 后端本地验证默认使用 `scripts/backend-check.ps1`,必须显式指定 `-Modules`,不传模块直接拒绝;命令及参数见 `scripts/README.md` 的“后端分层检查”。入口自动经 `local-check.ps1` 限流,`-DryRun` 只检查选择范围,不代表测试通过。
|
||||||
- 开发迭代使用 `-Mode Focused -Tests <精确测试类名>`,覆盖修改行为及直接调用点;Java 签名变化至少使用 `-Mode Compile` 编译生产与测试源码(等价于 `mvn -pl <modules> -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` 跳过测试编译,不得作为签名变更交付验证。
|
- 仅注释/文档调整不触发 Java 回归;脚本调整执行受影响脚本测试和登记合同。API、Mapper、AGENTS 等静态合同仍按各自触发条件执行。纯重构也应覆盖受影响行为,不能只用编译宣称回归通过。`-Dmaven.test.skip=true` 跳过测试编译,不得作为签名变更交付验证。
|
||||||
- 全仓回归只在以下情况触发:根 POM/统一依赖版本/聚合拓扑/测试基础配置变化;公共认证、序列化、基础模型等影响广泛且不能可靠限定消费者;跨多域批次集成验收;用户明确要求全量回归。使用 `scripts/backend-full-regression.ps1`,保留当前 CI 全量验证。普通单服务任务、单次提交或对话收尾不自动触发全仓;已明确消费者的公共或跨服务变更按受影响模块验证。同批同一相关输入只跑一次,输入变化后补跑受影响范围,触及上述全量条件再重跑全量。
|
- 全仓回归仅用于已确认影响广泛且定向覆盖不足的公共依赖/聚合拓扑/认证/序列化/基础模型变化,或用户要求的全量/跨域集成验收;先说明具体影响,不能因未查清范围而全跑。根 POM、依赖或测试配置改动先按实际影响选编译或测试,不一律全仓。入口为 `scripts/backend-full-regression.ps1`,CI 全量保留。普通提交、单服务任务或对话收尾不触发;有效结果复用,输入变化只补跑受影响范围。
|
||||||
- 不以缩短耗时为由删除金额、库存、权限隔离、状态流转、幂等和并发测试;冗余测试须根据重复场景及维护成本单独评估。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
- 不以缩短耗时为由删除金额、库存、权限隔离、状态流转、幂等和并发测试;冗余测试须根据重复场景及维护成本单独评估。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
||||||
- 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。
|
- 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。
|
||||||
- Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。
|
- Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。
|
||||||
|
|||||||
@@ -429,6 +429,7 @@ CREATE TABLE ll_dispatch_order (
|
|||||||
review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态',
|
review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态',
|
||||||
review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次',
|
review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次',
|
||||||
review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要',
|
review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要',
|
||||||
|
exceptions_json JSON NULL COMMENT '派送异常登记记录及图片引用,不改变派送及库存状态',
|
||||||
dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照',
|
dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照',
|
||||||
loading_review_json JSON NULL COMMENT '当前理货审核事实快照',
|
loading_review_json JSON NULL COMMENT '当前理货审核事实快照',
|
||||||
resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照',
|
resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ enum BusinessFileCategory {
|
|||||||
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
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_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_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_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_DISPATCH_FUEL_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) {
|
&& tenantId != 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
|
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) {
|
&& tenantId != 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台");
|
throw new BizException(ResultCode.BAD_REQUEST, "排车证据必须归属平台");
|
||||||
}
|
}
|
||||||
if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) {
|
if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台");
|
throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台");
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ info:
|
|||||||
复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。
|
复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。
|
||||||
三类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
|
三类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
|
||||||
不使用车辆 ID、司机 ID、车牌或客户 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 字节,包含边界)、
|
File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、
|
||||||
PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId
|
PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId
|
||||||
联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。
|
联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。
|
||||||
@@ -126,7 +126,7 @@ components:
|
|||||||
description: 正数Long的十进制字符串,值不得超过9223372036854775807
|
description: 正数Long的十进制字符串,值不得超过9223372036854775807
|
||||||
DispatchEvidenceType:
|
DispatchEvidenceType:
|
||||||
type: string
|
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:
|
MetadataEnvelope:
|
||||||
type: object
|
type: object
|
||||||
required: [code, data]
|
required: [code, data]
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import static org.mockito.Mockito.*;
|
|||||||
|
|
||||||
class DispatchEvidenceFileTest {
|
class DispatchEvidenceFileTest {
|
||||||
private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE";
|
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 PHOTO = "OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE";
|
||||||
private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE";
|
private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE";
|
||||||
private static final int MAX_BYTES = 10 * 1024 * 1024;
|
private static final int MAX_BYTES = 10 * 1024 * 1024;
|
||||||
@@ -43,7 +44,7 @@ class DispatchEvidenceFileTest {
|
|||||||
|
|
||||||
/** 三类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
|
/** 三类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO})
|
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION})
|
||||||
void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) {
|
void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) {
|
||||||
var files = mock(FileObjectMapper.class);
|
var files = mock(FileObjectMapper.class);
|
||||||
var storage = mock(BusinessFileStorage.class);
|
var storage = mock(BusinessFileStorage.class);
|
||||||
@@ -65,9 +66,10 @@ class DispatchEvidenceFileTest {
|
|||||||
verifyNoInteractions(files, storage);
|
verifyNoInteractions(files, storage);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 司机本人照片使用独立平台类别,只保存真实图片,拒绝PDF和伪造图片。 */
|
/** 司机照片与异常图片使用独立平台类别,拒绝PDF和伪造图片。 */
|
||||||
@org.junit.jupiter.api.Test void driverPhotoAcceptsOnlyRealImages() throws Exception {
|
@ParameterizedTest @ValueSource(strings = {PHOTO, EXCEPTION})
|
||||||
var storage=storage();var category=BusinessFileCategory.require(PHOTO);
|
void photoAcceptsOnlyRealImages(String type) throws Exception {
|
||||||
|
var storage=storage();var category=BusinessFileCategory.require(type);
|
||||||
for(var format:new String[]{"jpeg","png"}) {
|
for(var format:new String[]{"jpeg","png"}) {
|
||||||
var upload=new MockMultipartFile("file","driver."+format,"image/"+format,image(format));
|
var upload=new MockMultipartFile("file","driver."+format,"image/"+format,image(format));
|
||||||
var saved=storage.store(0,"71",upload,category);
|
var saved=storage.store(0,"71",upload,category);
|
||||||
@@ -162,7 +164,7 @@ class DispatchEvidenceFileTest {
|
|||||||
|
|
||||||
/** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */
|
/** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO})
|
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION})
|
||||||
void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception {
|
void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception {
|
||||||
var files = mock(FileObjectMapper.class);
|
var files = mock(FileObjectMapper.class);
|
||||||
var storage = mock(BusinessFileStorage.class);
|
var storage = mock(BusinessFileStorage.class);
|
||||||
|
|||||||
+6
@@ -77,6 +77,12 @@ public class DispatchAggregateController {
|
|||||||
public ApiResponse<CommandResult> loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) {
|
public ApiResponse<CommandResult> loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) {
|
||||||
return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request));
|
return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request));
|
||||||
}
|
}
|
||||||
|
/** 派送异常登记不触发签收或退货入库。 */
|
||||||
|
@PostMapping("/{dispatchNo}/exceptions")
|
||||||
|
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
|
||||||
|
public ApiResponse<CommandResult> 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)
|
@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')")
|
@PreAuthorize("hasAnyAuthority('platform:overseas-warehouse:dispatch:edit','platform:fleet:trip:dispatch-review','platform:fleet:trip:loading-review')")
|
||||||
|
|||||||
+1
@@ -24,6 +24,7 @@ public class DispatchAggregate {
|
|||||||
private String dispatchReviewJson;
|
private String dispatchReviewJson;
|
||||||
private String loadingReviewJson;
|
private String loadingReviewJson;
|
||||||
private String resourcesJson;
|
private String resourcesJson;
|
||||||
|
private String exceptionsJson;
|
||||||
private String vehiclePlate;
|
private String vehiclePlate;
|
||||||
private String route;
|
private String route;
|
||||||
private LocalDateTime plannedDepartureAt;
|
private LocalDateTime plannedDepartureAt;
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
|
|
||||||
/** 车辆、司机及审核补充证据属于本张整车,不能借司机主档或客户文件授权。 */
|
/** 车辆、司机及审核补充证据属于本张整车,不能借司机主档或客户文件授权。 */
|
||||||
public interface DispatchEvidenceGateway {
|
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) { }
|
record File(Long fileId,String fileName,String contentType,long sizeBytes) { }
|
||||||
/** 上传只写本车、本类证据。 */
|
/** 上传只写本车、本类证据。 */
|
||||||
File upload(long dispatchId,Kind kind,String operator,MultipartFile file);
|
File upload(long dispatchId,Kind kind,String operator,MultipartFile file);
|
||||||
|
|||||||
+2
-2
@@ -83,9 +83,9 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway {
|
|||||||
|| !supportedType(kind,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());
|
return new File(value.fileId(),value.fileName(),value.contentType(),value.sizeBytes());
|
||||||
}
|
}
|
||||||
/** 司机本人照片只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */
|
/** 司机本人照片与异常证据只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */
|
||||||
private static boolean supportedType(Kind kind,String contentType) {
|
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");}
|
private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");}
|
||||||
|
|||||||
+1
-1
@@ -94,7 +94,7 @@ public interface DispatchAggregateMapper {
|
|||||||
UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion},
|
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},
|
review_state=#{r.reviewState},review_round=#{r.reviewRound},review_basis_hash=#{r.reviewBasisHash},
|
||||||
dispatch_review_json=#{r.dispatchReviewJson},loading_review_json=#{r.loadingReviewJson},
|
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},
|
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},
|
loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey},
|
||||||
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt},
|
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt},
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ public final class DispatchCommands {
|
|||||||
public LoadingComplete { recordVersion = requireVersion(recordVersion); }
|
public LoadingComplete { recordVersion = requireVersion(recordVersion); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 派送异常仅登记事实,不改变派送、签收或库存。 */
|
||||||
|
public record MarkException(Long recordVersion, String idempotencyKey, ExceptionType exceptionType,
|
||||||
|
ReturnDirection returnDirection, String description, List<Long> imageFileIds) {
|
||||||
|
/** 必填版本用于阻止过期页面覆盖当前排车。 */
|
||||||
|
public MarkException { recordVersion = requireVersion(recordVersion); }
|
||||||
|
}
|
||||||
|
|
||||||
public record Depart(Long recordVersion, String idempotencyKey, String remark) {
|
public record Depart(Long recordVersion, String idempotencyKey, String remark) {
|
||||||
/** 实际发车不得接受缺省版本。 */
|
/** 实际发车不得接受缺省版本。 */
|
||||||
public Depart { recordVersion = requireVersion(recordVersion); }
|
public Depart { recordVersion = requireVersion(recordVersion); }
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ public final class DispatchTypes {
|
|||||||
public enum Stage { BEFORE_LOADING, LOADING, AFTER_LOADING, DELIVERING, DELIVERED }
|
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 ReviewState { DRAFT, DISPATCH_PENDING, DISPATCH_REJECTED, LOADING_PENDING, LOADING_REJECTED, APPROVED }
|
||||||
public enum ReviewStatus { NOT_SUBMITTED, PENDING, APPROVED, REJECTED }
|
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 Decision { APPROVED, REJECTED }
|
||||||
public enum ResourceMode { MASTER, EXTERNAL }
|
public enum ResourceMode { MASTER, EXTERNAL }
|
||||||
public enum ActorMode { PLATFORM, PDA }
|
public enum ActorMode { PLATFORM, PDA }
|
||||||
@@ -18,6 +20,6 @@ public final class DispatchTypes {
|
|||||||
public enum Effect { APPLIED, EXISTING }
|
public enum Effect { APPLIED, EXISTING }
|
||||||
public enum Action {
|
public enum Action {
|
||||||
CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW, DISPATCH_REVIEW,
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ public final class DispatchViews {
|
|||||||
|
|
||||||
public record DispatchInfo(String route, Instant plannedDepartureAt, Instant departedAt,
|
public record DispatchInfo(String route, Instant plannedDepartureAt, Instant departedAt,
|
||||||
Instant estimatedArrivalAt, List<FbaAppointment> fbaAppointments,
|
Instant estimatedArrivalAt, List<FbaAppointment> fbaAppointments,
|
||||||
List<BatchReference> appointmentBatches) { }
|
List<BatchReference> appointmentBatches, List<ExceptionRecord> exceptions) { }
|
||||||
|
|
||||||
|
/** 排车级异常记录;退货去向只描述安排,不产生退货入库事实。 */
|
||||||
|
public record ExceptionRecord(ExceptionType exceptionType, ReturnDirection returnDirection,
|
||||||
|
String description, List<Long> imageFileIds, Instant occurredAt) { }
|
||||||
|
|
||||||
public record Documents(String dispatchNo, List<DispatchDeliveryModels.OutboundReference> outbounds,
|
public record Documents(String dispatchNo, List<DispatchDeliveryModels.OutboundReference> outbounds,
|
||||||
List<DispatchDeliveryModels.DeliveryReference> deliveries,
|
List<DispatchDeliveryModels.DeliveryReference> deliveries,
|
||||||
|
|||||||
+42
-2
@@ -238,7 +238,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
/** 上传只返回本车证据;待审按当前审核权限授权,客服阶段按维护权限授权。 */
|
/** 上传只返回本车证据;待审按当前审核权限授权,客服阶段按维护权限授权。 */
|
||||||
public com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File uploadEvidence(Actor actor,String number,
|
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) {
|
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
|
Action permission = ReviewState.DISPATCH_PENDING.name().equals(row.getReviewState()) ? Action.DISPATCH_REVIEW
|
||||||
: ReviewState.LOADING_PENDING.name().equals(row.getReviewState()) ? Action.LOADING_REVIEW : Action.EDIT;
|
: ReviewState.LOADING_PENDING.name().equals(row.getReviewState()) ? Action.LOADING_REVIEW : Action.EDIT;
|
||||||
if(!hasPermission(actor,permission))throw new BizException(ResultCode.FORBIDDEN,"无当前阶段附件上传权限");
|
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);
|
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.<Long>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<DispatchViews.ExceptionRecord> 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)
|
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||||
public DispatchViews.Page page(Actor actor, DispatchViews.Query query) {
|
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());
|
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()),
|
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()),
|
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));
|
documents,orderDetails(cargo));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -817,6 +856,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
|||||||
for(var action:Action.values()) {
|
for(var action:Action.values()) {
|
||||||
if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)continue;
|
if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)continue;
|
||||||
boolean state=switch(action) {
|
boolean state=switch(action) {
|
||||||
|
case MARK_EXCEPTION -> active && stage==Stage.DELIVERING;
|
||||||
case CREATE -> actor.mode()==ActorMode.PLATFORM;
|
case CREATE -> actor.mode()==ActorMode.PLATFORM;
|
||||||
case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0;
|
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());
|
case SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0 && canSubmitReview(row.getReviewState());
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@ public final class DispatchCommandCodec {
|
|||||||
case DEPART -> DispatchResults.Departed.class;
|
case DEPART -> DispatchResults.Departed.class;
|
||||||
case ARRANGE_DELIVERY -> DispatchDeliveryModels.ArrangementResult.class;
|
case ARRANGE_DELIVERY -> DispatchDeliveryModels.ArrangementResult.class;
|
||||||
case CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW,
|
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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ public record ContainerArrivalTimeView(
|
|||||||
String sourceContainerBookingId,
|
String sourceContainerBookingId,
|
||||||
LocalDateTime estimatedWarehouseArrivalAt,
|
LocalDateTime estimatedWarehouseArrivalAt,
|
||||||
LocalDateTime actualWarehouseArrivalAt,
|
LocalDateTime actualWarehouseArrivalAt,
|
||||||
int version
|
int version,
|
||||||
|
long planCount
|
||||||
) { }
|
) { }
|
||||||
|
|||||||
@@ -210,12 +210,19 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
|||||||
@Select("SELECT * FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE")
|
@Select("SELECT * FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE")
|
||||||
List<LastlegOrder> lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId);
|
List<LastlegOrder> lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId);
|
||||||
|
|
||||||
|
/** 人工时间及计划冻结时间优先;返回计划数量供各页面统一判断默认日期。 */
|
||||||
@Select("<script>SELECT o.source_container_booking_id AS sourceContainerBookingId," +
|
@Select("<script>SELECT o.source_container_booking_id AS sourceContainerBookingId," +
|
||||||
"CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) AND COUNT(DISTINCT o.estimated_warehouse_at)=1 " +
|
"CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) AND COUNT(DISTINCT o.estimated_warehouse_at)=1 " +
|
||||||
|
"AND (MAX(o.arrival_time_version)>0 OR EXISTS(SELECT 1 FROM ll_inbound_plan p " +
|
||||||
|
"JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id " +
|
||||||
|
"WHERE po.source_container_booking_id=o.source_container_booking_id)) " +
|
||||||
"THEN MAX(o.estimated_warehouse_at) ELSE NULL END AS estimatedWarehouseArrivalAt," +
|
"THEN MAX(o.estimated_warehouse_at) ELSE NULL END AS estimatedWarehouseArrivalAt," +
|
||||||
"(SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s " +
|
"(SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s " +
|
||||||
"WHERE s.source_container_booking_id=o.source_container_booking_id AND s.receiving_at IS NOT NULL) AS actualWarehouseArrivalAt," +
|
"WHERE s.source_container_booking_id=o.source_container_booking_id AND s.receiving_at IS NOT NULL) AS actualWarehouseArrivalAt," +
|
||||||
"CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END AS version " +
|
"CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END AS version," +
|
||||||
|
"(SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order po " +
|
||||||
|
"ON po.id=p.source_order_id AND po.customer_id=p.customer_id " +
|
||||||
|
"WHERE po.source_container_booking_id=o.source_container_booking_id) AS planCount " +
|
||||||
"FROM ll_order o WHERE o.source_container_booking_id IN " +
|
"FROM ll_order o WHERE o.source_container_booking_id IN " +
|
||||||
"<foreach collection='bookingIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
"<foreach collection='bookingIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||||
"GROUP BY o.source_container_booking_id</script>")
|
"GROUP BY o.source_container_booking_id</script>")
|
||||||
|
|||||||
@@ -1907,6 +1907,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_order (
|
|||||||
review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态',
|
review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态',
|
||||||
review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次',
|
review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次',
|
||||||
review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要',
|
review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要',
|
||||||
|
exceptions_json JSON NULL COMMENT '派送异常登记记录及图片引用,不改变派送及库存状态',
|
||||||
dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照',
|
dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照',
|
||||||
loading_review_json JSON NULL COMMENT '当前理货审核事实快照',
|
loading_review_json JSON NULL COMMENT '当前理货审核事实快照',
|
||||||
resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照',
|
resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照',
|
||||||
|
|||||||
@@ -1682,6 +1682,49 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
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:
|
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
|
||||||
post:
|
post:
|
||||||
summary: 确认装车完成并自动进入派送中
|
summary: 确认装车完成并自动进入派送中
|
||||||
@@ -1880,7 +1923,7 @@ paths:
|
|||||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}:
|
/api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}:
|
||||||
post:
|
post:
|
||||||
summary: 上传本趟车辆或司机证据
|
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:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
responses:
|
responses:
|
||||||
@@ -1935,6 +1978,7 @@ paths:
|
|||||||
- VEHICLE
|
- VEHICLE
|
||||||
- DRIVER
|
- DRIVER
|
||||||
- DRIVER_PHOTO
|
- DRIVER_PHOTO
|
||||||
|
- EXCEPTION
|
||||||
/api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}/{fileId}:
|
/api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}/{fileId}:
|
||||||
get:
|
get:
|
||||||
summary: 读取本车证据元数据
|
summary: 读取本车证据元数据
|
||||||
@@ -1981,6 +2025,7 @@ paths:
|
|||||||
- VEHICLE
|
- VEHICLE
|
||||||
- DRIVER
|
- DRIVER
|
||||||
- DRIVER_PHOTO
|
- DRIVER_PHOTO
|
||||||
|
- EXCEPTION
|
||||||
- name: fileId
|
- name: fileId
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
@@ -2024,6 +2069,7 @@ paths:
|
|||||||
- VEHICLE
|
- VEHICLE
|
||||||
- DRIVER
|
- DRIVER
|
||||||
- DRIVER_PHOTO
|
- DRIVER_PHOTO
|
||||||
|
- EXCEPTION
|
||||||
- name: fileId
|
- name: fileId
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
@@ -3503,12 +3549,13 @@ components:
|
|||||||
remark: { type: string, maxLength: 500, nullable: true }
|
remark: { type: string, maxLength: 500, nullable: true }
|
||||||
ContainerArrivalTimeView:
|
ContainerArrivalTimeView:
|
||||||
type: object
|
type: object
|
||||||
required: [sourceContainerBookingId, version]
|
required: [sourceContainerBookingId, version, planCount]
|
||||||
properties:
|
properties:
|
||||||
sourceContainerBookingId: { type: string }
|
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 首次开始收货时间 }
|
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:
|
ContainerPlanProgressDetail:
|
||||||
type: object
|
type: object
|
||||||
required: [summary, orders]
|
required: [summary, orders]
|
||||||
@@ -5142,6 +5189,7 @@ components:
|
|||||||
- LOADING_COMPLETE
|
- LOADING_COMPLETE
|
||||||
- ARRANGE_DELIVERY
|
- ARRANGE_DELIVERY
|
||||||
- DEPART
|
- DEPART
|
||||||
|
- MARK_EXCEPTION
|
||||||
DispatchGate:
|
DispatchGate:
|
||||||
type: object
|
type: object
|
||||||
x-java-record: DispatchContracts.Gate
|
x-java-record: DispatchContracts.Gate
|
||||||
@@ -6314,6 +6362,10 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/DispatchBatchReference'
|
$ref: '#/components/schemas/DispatchBatchReference'
|
||||||
nullable: true
|
nullable: true
|
||||||
|
exceptions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/DispatchExceptionRecord'
|
||||||
required:
|
required:
|
||||||
- route
|
- route
|
||||||
- plannedDepartureAt
|
- plannedDepartureAt
|
||||||
@@ -6321,6 +6373,7 @@ components:
|
|||||||
- estimatedArrivalAt
|
- estimatedArrivalAt
|
||||||
- fbaAppointments
|
- fbaAppointments
|
||||||
- appointmentBatches
|
- appointmentBatches
|
||||||
|
- exceptions
|
||||||
DispatchDocuments:
|
DispatchDocuments:
|
||||||
type: object
|
type: object
|
||||||
x-java-record: DispatchViews.Documents
|
x-java-record: DispatchViews.Documents
|
||||||
@@ -7940,5 +7993,66 @@ components:
|
|||||||
- sourceInboundOrderNo
|
- sourceInboundOrderNo
|
||||||
- sourceInboundCode
|
- sourceInboundCode
|
||||||
- productQuantity
|
- 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:
|
securitySchemes:
|
||||||
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
|
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
|
||||||
|
|||||||
+12
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.calendar;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
||||||
|
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||||
import org.apache.ibatis.mapping.Environment;
|
import org.apache.ibatis.mapping.Environment;
|
||||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||||
import org.h2.jdbcx.JdbcDataSource;
|
import org.h2.jdbcx.JdbcDataSource;
|
||||||
@@ -28,12 +29,23 @@ class WarehouseCalendarMapperTest {
|
|||||||
}
|
}
|
||||||
var config=new MybatisConfiguration(); config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));
|
var config=new MybatisConfiguration(); config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));
|
||||||
config.addMapper(WarehouseCalendarMapper.class);
|
config.addMapper(WarehouseCalendarMapper.class);
|
||||||
|
config.addMapper(LastlegOrderMapper.class);
|
||||||
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
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"));
|
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(3,rows.size());
|
||||||
assertEquals("1",rows.get(0).sourceContainerBookingId()); assertNull(rows.get(0).plannedAt());
|
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(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());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -45,6 +45,15 @@ class HttpDispatchEvidenceGatewayTest {
|
|||||||
var pdf=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE","9","driver.pdf","application/pdf",128L);
|
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);
|
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() {
|
@Test void rejectsForeignTenantMasterScopeAndMalformedMetadata() {
|
||||||
for(var value:java.util.List.of(
|
for(var value:java.util.List.of(
|
||||||
|
|||||||
+89
@@ -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<Long> images) { return new DispatchCommands.MarkException(version,key,ExceptionType.VEHICLE_EXCEPTION,null,null,images); }
|
||||||
|
}
|
||||||
+1
-1
@@ -148,7 +148,7 @@ class LastlegOrderServiceTest {
|
|||||||
when(orders.countContainerPlans("BOOKING-1")).thenReturn(0L);
|
when(orders.countContainerPlans("BOOKING-1")).thenReturn(0L);
|
||||||
when(orders.updateContainerExpectedWarehouseArrival("BOOKING-1", estimatedAt, 0)).thenReturn(2);
|
when(orders.updateContainerExpectedWarehouseArrival("BOOKING-1", estimatedAt, 0)).thenReturn(2);
|
||||||
when(orders.findContainerArrivalTimes(List.of("BOOKING-1"))).thenReturn(List.of(
|
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",
|
ContainerArrivalTimeView result = service.changeExpectedWarehouseArrival("BOOKING-1", "arrival-1",
|
||||||
new LastlegOrderRequests.ChangeExpectedWarehouseArrival(estimatedAt, 0, null), "admin");
|
new LastlegOrderRequests.ChangeExpectedWarehouseArrival(estimatedAt, 0, null), "admin");
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
| 类别 | 正式入口 | 管理边界 |
|
| 类别 | 正式入口 | 管理边界 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 本机重型检查限流 | `local-check.ps1` | 所有工作目录、对话共用两个用户级执行名额;PowerShell 7,状态/超时/并发与 Job Object 回收规则见外层 `docs/AI工程执行与协作约束.md`;只读状态不申请重型名额 |
|
| 本机重型检查限流 | `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 |
|
| 测试/灰度单票验收 | `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` 为准 |
|
| 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` 只是入库测量与计费专项,不替代全量入口 |
|
| Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 |
|
||||||
|
|||||||
+3
-3
@@ -458,13 +458,13 @@ Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块
|
|||||||
.\scripts\backend-check.ps1 -Modules order -Mode Compile
|
.\scripts\backend-check.ps1 -Modules order -Mode Compile
|
||||||
# 开发迭代:精确类名,支持逗号分隔和全限定名。
|
# 开发迭代:精确类名,支持逗号分隔和全限定名。
|
||||||
.\scripts\backend-check.ps1 -Modules common-core -Mode Focused -Tests ApiResponseTest
|
.\scripts\backend-check.ps1 -Modules common-core -Mode Focused -Tests ApiResponseTest
|
||||||
# 单服务收尾:指定模块及上游依赖测试。
|
# 服务级影响且定向覆盖不足:指定模块及上游依赖测试。
|
||||||
.\scripts\backend-check.ps1 -Modules order
|
.\scripts\backend-check.ps1 -Modules order
|
||||||
# 跨服务收尾:显式列出受影响生产者、消费者。
|
# 跨服务模块回归:显式列出受影响生产者、消费者。
|
||||||
.\scripts\backend-check.ps1 -Modules 'order,firstleg'
|
.\scripts\backend-check.ps1 -Modules 'order,firstleg'
|
||||||
```
|
```
|
||||||
|
|
||||||
三种模式均使用根 reactor 的 `-pl <modules> -am`,不会安装制品到本地仓库,不默认 `clean`。`Compile` 运行 `test-compile`;`Focused`/`Module` 运行 `test`。`-am` 只补上游构建依赖,HTTP/事件下游消费者须由调用者显式列入。公共模块变更也须核对下游,不能只测试公共模块自身。Focused 只用于迭代,不能替代有行为变化时的模块收尾;API、Mapper、索引等相关静态合同仍按其规则单独执行。
|
三种模式均使用根 reactor 的 `-pl <modules> -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;报告缺失、陈旧、全部跳过或含失败均不能通过,避免零测试假绿。
|
Focused 不接受通配符或方法选择,每个类必须唯一命中所选模块的 `src/test/java`。为允许上游无同名测试,Maven 设置 `surefire.failIfNoSpecifiedTests=false`,随后逐类核对本轮更新的 Surefire XML;报告缺失、陈旧、全部跳过或含失败均不能通过,避免零测试假绿。
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export function planA0LocationIsolation(locations, requiredSlots = 1,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MODULE_DEPENDENCIES = new Map([
|
const MODULE_DEPENDENCIES = new Map([
|
||||||
|
['inbound-weight-ocr', ['orders']],
|
||||||
|
['orders', ['merchant-qualification-fixture']],
|
||||||
['phase5-root-fulfillment', ['merchant-qualification-fixture']],
|
['phase5-root-fulfillment', ['merchant-qualification-fixture']],
|
||||||
['pda-loading-link-regression', ['merchant-qualification-fixture']],
|
['pda-loading-link-regression', ['merchant-qualification-fixture']],
|
||||||
['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']],
|
['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']],
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
|
|
||||||
固定托盘造数中,`inbound-measurement.mjs` 只复用 `TP-001`、`YW-TP-002` 指定池中的现有托盘;一只可复用,不要求两只同时存在,两只均缺失才阻断,不自动选择池外托盘。仅明确设置内部 `allowAnyExistingPallet` 的通用场景可读取当前仓库已有资源;两者均不创建托盘。
|
固定托盘造数中,`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#先看范围再看证据),最新运行状态只查外层部署契约。
|
先运行 `node scripts/acceptance-overview.mjs`(后端根目录,无网络)查看当前默认与可选模块。范围来自真实注册表;本文件描述各模块用途与依赖,不代表所有模块默认执行或已通过。报告解读及跨端/专项边界见 [总索引](../README.md#先看范围再看证据),最新运行状态只查外层部署契约。
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,26 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import { readFileSync } from 'node:fs'
|
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 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')
|
const moduleSource = readFileSync(new URL('./appointment-work.mjs', import.meta.url), 'utf8')
|
||||||
|
|||||||
@@ -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) {
|
function count(value) {
|
||||||
const number = Number(value)
|
const number = Number(value)
|
||||||
return Number.isFinite(number) ? number : NaN
|
return Number.isFinite(number) ? number : NaN
|
||||||
@@ -15,7 +30,7 @@ export async function run(context) {
|
|||||||
const from = shanghaiDate(-7)
|
const from = shanghaiDate(-7)
|
||||||
const to = 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 calendar = context.apiSuccess(module, '查询预约工作日历', calendarResponse)
|
||||||
const days = Array.isArray(calendar?.days) ? calendar.days : []
|
const days = Array.isArray(calendar?.days) ? calendar.days : []
|
||||||
const dayTotal = days.reduce((sum, day) => sum + Number(day?.total || 0), 0)
|
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}`)
|
`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 summary = context.apiSuccess(module, '查询预约工作汇总', summaryResponse)
|
||||||
const summaryFields = ['all', 'followToday', 'overdue', 'undated', 'review']
|
const summaryFields = ['all', 'followToday', 'overdue', 'undated', 'review']
|
||||||
context.assert(module, '工作汇总五项计数完整', summaryResponse,
|
context.assert(module, '工作汇总五项计数完整', summaryResponse,
|
||||||
summaryFields.every((field) => Number.isFinite(Number(summary?.[field]))),
|
summaryFields.every((field) => Number.isFinite(Number(summary?.[field]))),
|
||||||
summaryFields.map((field) => `${field}=${summary?.[field] ?? 'missing'}`).join(','))
|
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 page = context.apiSuccess(module, '查询预约工作事项首页', pageResponse)
|
||||||
const records = Array.isArray(page?.records) ? page.records : []
|
const records = Array.isArray(page?.records) ? page.records : []
|
||||||
const total = count(page?.total)
|
const total = count(page?.total)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
validRecognition, sameOptionalWeight, confirmedVersion,
|
||||||
confirmedNetSummary,
|
confirmedNetSummary,
|
||||||
isTransientGatewayFailure,
|
isTransientGatewayFailure,
|
||||||
normalizeWeightPalletOptionPage,
|
normalizeWeightPalletOptionPage,
|
||||||
@@ -14,7 +16,22 @@ import {
|
|||||||
workspaceWeightMatchesBaseline,
|
workspaceWeightMatchesBaseline,
|
||||||
} from './inbound-weight-ocr.mjs'
|
} 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', () => {
|
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: 0 }), true)
|
||||||
assert.equal(isTransientGatewayFailure({ http: 502 }), true)
|
assert.equal(isTransientGatewayFailure({ http: 502 }), true)
|
||||||
assert.equal(isTransientGatewayFailure({ http: 503 }), true)
|
assert.equal(isTransientGatewayFailure({ http: 503 }), true)
|
||||||
|
|||||||
@@ -22,6 +22,22 @@ function closeNumber(actual, expected, tolerance = 0.0005) {
|
|||||||
return Number.isFinite(Number(actual)) && Math.abs(Number(actual) - Number(expected)) < tolerance
|
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) {
|
function palletSnapshotComplete(pallet) {
|
||||||
return Boolean(id(pallet?.palletId))
|
return Boolean(id(pallet?.palletId))
|
||||||
&& typeof pallet?.palletCode === 'string' && pallet.palletCode.length > 0
|
&& 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',
|
Boolean(fileId) && typeof upload?.fileId === 'string',
|
||||||
`fileIdType=${typeof upload?.fileId}, status=${upload?.recognitionStatus ?? 'missing'}`, SCENARIO_OPTIONS)
|
`fileIdType=${typeof upload?.fileId}, status=${upload?.recognitionStatus ?? 'missing'}`, SCENARIO_OPTIONS)
|
||||||
context.assert(MODULE, 'OCR 上传响应字段完整且置信度受限', uploadResponse,
|
context.assert(MODULE, 'OCR 上传响应字段完整且置信度受限', uploadResponse,
|
||||||
upload && OCR_STATUSES.has(upload.recognitionStatus)
|
validRecognition(upload),
|
||||||
&& Object.hasOwn(upload, 'weightValue') && Object.hasOwn(upload, 'weightUnit')
|
|
||||||
&& Object.hasOwn(upload, 'confidence')
|
|
||||||
&& (upload.confidence == null || (Number(upload.confidence) >= 0 && Number(upload.confidence) <= 1)),
|
|
||||||
`status=${upload?.recognitionStatus ?? 'missing'}, hasWeight=${upload?.weightValue != null}, confidence=${upload?.confidence ?? 'null'}`,
|
`status=${upload?.recognitionStatus ?? 'missing'}, hasWeight=${upload?.weightValue != null}, confidence=${upload?.confidence ?? 'null'}`,
|
||||||
SCENARIO_OPTIONS)
|
SCENARIO_OPTIONS)
|
||||||
if (!fileId) throw new Error('称重图片上传未返回 fileId,无法登记和清理测试资源')
|
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,
|
const retry = context.apiSuccess(MODULE, '重试当前称重图片 OCR', retryResponse,
|
||||||
`fileId=${fileId}`, SCENARIO_OPTIONS)
|
`fileId=${fileId}`, SCENARIO_OPTIONS)
|
||||||
context.assert(MODULE, 'OCR 重试保持文件身份并返回识别契约', retryResponse,
|
context.assert(MODULE, 'OCR 重试保持文件身份并返回识别契约', retryResponse,
|
||||||
id(retry?.fileId) === fileId && OCR_STATUSES.has(retry?.recognitionStatus)
|
id(retry?.fileId) === fileId && validRecognition(retry),
|
||||||
&& Object.hasOwn(retry || {}, 'weightValue') && Object.hasOwn(retry || {}, 'confidence'),
|
|
||||||
`sameFile=${id(retry?.fileId) === fileId}, status=${retry?.recognitionStatus ?? 'missing'}`,
|
`sameFile=${id(retry?.fileId) === fileId}, status=${retry?.recognitionStatus ?? 'missing'}`,
|
||||||
SCENARIO_OPTIONS)
|
SCENARIO_OPTIONS)
|
||||||
if (retry) recognized = retry
|
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)
|
const registeredRow = registered.workspace?.weightResults?.find((row) => id(row.id) === original.resultId)
|
||||||
context.assert(MODULE, '工作区保留本轮 OCR 原值且未覆盖历史结果', registered.response,
|
context.assert(MODULE, '工作区保留本轮 OCR 原值且未覆盖历史结果', registered.response,
|
||||||
registeredRow && !baselineIds.has(original.resultId)
|
registeredRow && !baselineIds.has(original.resultId)
|
||||||
&& closeNumber(registeredRow.ocrWeightValue, original.result.ocrWeightValue)
|
&& sameOptionalWeight(registeredRow.ocrWeightValue, original.result.ocrWeightValue)
|
||||||
&& registeredRow.ocrWeightUnit === original.result.ocrWeightUnit,
|
&& (registeredRow.ocrWeightUnit ?? null) === (original.result.ocrWeightUnit ?? null),
|
||||||
`resultFound=${Boolean(registeredRow)}, baselineCount=${baselineIds.size}, currentCount=${registered.workspace?.weightResults?.length ?? 'n/a'}`,
|
`resultFound=${Boolean(registeredRow)}, baselineCount=${baselineIds.size}, currentCount=${registered.workspace?.weightResults?.length ?? 'n/a'}`,
|
||||||
SCENARIO_OPTIONS)
|
SCENARIO_OPTIONS)
|
||||||
|
|
||||||
@@ -506,7 +518,7 @@ export async function run(context) {
|
|||||||
const confirmedTare = Number(confirmed?.palletTareWeightKg)
|
const confirmedTare = Number(confirmed?.palletTareWeightKg)
|
||||||
const confirmedNet = Number(confirmed?.netWeightKg)
|
const confirmedNet = Number(confirmed?.netWeightKg)
|
||||||
context.assert(MODULE, '确认结果返回托盘快照、毛重和去皮净重', confirmResponse,
|
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)
|
&& id(confirmed?.palletId) === id(pallet.palletId)
|
||||||
&& confirmed?.palletCode === pallet.palletCode && confirmed?.palletType === pallet.palletType
|
&& confirmed?.palletCode === pallet.palletCode && confirmed?.palletType === pallet.palletType
|
||||||
&& closeNumber(confirmed?.palletCapacityCbm, pallet.capacityCbm)
|
&& closeNumber(confirmed?.palletCapacityCbm, pallet.capacityCbm)
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', ()
|
|||||||
assert.match(fixture, /MERCHANT_TEST_PASSWORD/)
|
assert.match(fixture, /MERCHANT_TEST_PASSWORD/)
|
||||||
assert.match(fixture, /context\.options\?\.customerName/)
|
assert.match(fixture, /context\.options\?\.customerName/)
|
||||||
assert.match(fixture, /tenantName/)
|
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, /administrator-phone-availability\?phone=/)
|
||||||
assert.match(acceptance, /attempt < 100/)
|
assert.match(acceptance, /attempt < 100/)
|
||||||
assert.doesNotMatch(acceptance, /phoneSequence % 10/)
|
assert.doesNotMatch(acceptance, /phoneSequence % 10/)
|
||||||
|
|||||||
@@ -120,10 +120,8 @@ export async function run(context, { publishEnvironment = true } = {}) {
|
|||||||
}
|
}
|
||||||
context.context.merchantQualificationFixture = {
|
context.context.merchantQualificationFixture = {
|
||||||
tenantId, tenantName, username, password, marker: context.marker,
|
tenantId, tenantName, username, password, marker: context.marker,
|
||||||
expectedProcurementPrefix: customerName ? null : 'QLYSXDKH',
|
|
||||||
}
|
}
|
||||||
context.createdResources.merchantQualificationFixture = {
|
context.createdResources.merchantQualificationFixture = {
|
||||||
tenantId, tenantName, username, marker: context.marker,
|
tenantId, tenantName, username, marker: context.marker,
|
||||||
expectedProcurementPrefix: customerName ? null : 'QLYSXDKH',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,4 +111,8 @@ test('船期日历模块注册为默认模块并核对月历、渠道、模板
|
|||||||
assert.ok(ctx.calls[3].path.includes('/sailing-assignments/exceptions'))
|
assert.ok(ctx.calls[3].path.includes('/sailing-assignments/exceptions'))
|
||||||
assert.ok(ctx.calls[4].path.endsWith('/11'))
|
assert.ok(ctx.calls[4].path.endsWith('/11'))
|
||||||
assert.ok(ctx.calls[5].path.endsWith('/999999999999999'))
|
assert.ok(ctx.calls[5].path.endsWith('/999999999999999'))
|
||||||
|
delete sailing.etaAt
|
||||||
|
await runSailingCalendar(ctx)
|
||||||
|
sailing.etaAt = 'invalid-eta'
|
||||||
|
await assert.rejects(runSailingCalendar(ctx), /月历记录、汇总与筛选选项结构完整/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export async function run(context) {
|
|||||||
const clearance = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}/clearance`)
|
const clearance = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}/clearance`)
|
||||||
context.assert(MODULE, '国内未出仓时目的港清关不生成国外工作台数据', clearance,
|
context.assert(MODULE, '国内未出仓时目的港清关不生成国外工作台数据', clearance,
|
||||||
clearance.http === 200 && String(clearance?.body?.code) === 'A0404'
|
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)
|
`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)}`)
|
const customs = await context.request('GET', `/api/platform/v1/first-leg/loading-lists/${encodeURIComponent(planId)}/customs?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||||
if (fixture?.finalStatus === 'LOADED') {
|
if (fixture?.finalStatus === 'LOADED') {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ test('overview regression requires workflowStatus and distinguishes 100 percent
|
|||||||
test('pre-departure overview rejects destination workbench documents explicitly', () => {
|
test('pre-departure overview rejects destination workbench documents explicitly', () => {
|
||||||
assert.match(overviewAcceptance, /国内未出仓时 \$\{documentType\} 不生成国外工作台数据/)
|
assert.match(overviewAcceptance, /国内未出仓时 \$\{documentType\} 不生成国外工作台数据/)
|
||||||
assert.match(overviewAcceptance, /国内未出仓时目的港清关不生成国外工作台数据/)
|
assert.match(overviewAcceptance, /国内未出仓时目的港清关不生成国外工作台数据/)
|
||||||
|
assert.match(overviewAcceptance, /货柜尚未离仓或实际开船,不能维护目的港清关资料/)
|
||||||
assert.match(overviewAcceptance, /国外工作台货柜不存在/)
|
assert.match(overviewAcceptance, /国外工作台货柜不存在/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -207,7 +207,8 @@ function fixture(options = {}) {
|
|||||||
test('module is context injected, blocks missing prerequisites and excludes forbidden surfaces', () => {
|
test('module is context injected, blocks missing prerequisites and excludes forbidden surfaces', () => {
|
||||||
assert.match(source, /export async function run\(context\)/)
|
assert.match(source, /export async function run\(context\)/)
|
||||||
assert.doesNotMatch(source, /https?:\/\/|process\.env|fetch\(/)
|
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, /deploymentBlocker: true/)
|
||||||
assert.match(source, /contactPhone: '\d+'/)
|
assert.match(source, /contactPhone: '\d+'/)
|
||||||
assert.doesNotMatch(source, /contactPhone: '\+[^']+'/)
|
assert.doesNotMatch(source, /contactPhone: '\+[^']+'/)
|
||||||
|
|||||||
@@ -5,6 +5,28 @@ import test from 'node:test'
|
|||||||
import { run, vehicleCapacityMatches } from './phase4-overseas-master-data.mjs'
|
import { run, vehicleCapacityMatches } from './phase4-overseas-master-data.mjs'
|
||||||
import { run as runCare } from './fleet-vehicle-care.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 () => {
|
test('运维专项接受NON_NULL真实响应但仍完成全部六类记录和两车停用', async () => {
|
||||||
const { context, state } = fakeContext()
|
const { context, state } = fakeContext()
|
||||||
const request = context.request.bind(context)
|
const request = context.request.bind(context)
|
||||||
|
|||||||
@@ -375,13 +375,19 @@ async function verifyCanonicalPermissionDenial(context, resource) {
|
|||||||
if (!roleId) throw new Error('无主数据权限角色未返回ID')
|
if (!roleId) throw new Error('无主数据权限角色未返回ID')
|
||||||
resource.permissionProbe = { roleId, membershipId: null, roleStatus: 'ACTIVE', userStatus: null }
|
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 username = `p4_md_deny_${suffix}`.slice(0, 64).toLowerCase()
|
||||||
const password = `P4md${suffix.slice(-8)}A1!`
|
const password = `P4md${suffix.slice(-8)}A1!`
|
||||||
const userResponse = await context.request('POST', '/api/platform/v1/iam/users', {
|
let userResponse
|
||||||
username, displayName: `四期主数据拒绝-${suffix}`, phone,
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
email: `${username}@example.com`, tenantId: '0', roleIds: [roleId], password, status: 'ACTIVE',
|
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 user = requireData(context, '创建无主数据权限用户', userResponse)
|
||||||
const membershipId = asId(user?.membershipId)
|
const membershipId = asId(user?.membershipId)
|
||||||
if (!membershipId) throw new Error('无主数据权限用户未返回membershipId')
|
if (!membershipId) throw new Error('无主数据权限用户未返回membershipId')
|
||||||
|
|||||||
+4
-3
@@ -10,9 +10,10 @@ const pda = readFileSync(new URL('./phase4-overseas-pda.mjs', import.meta.url),
|
|||||||
test('采购单号按北京时间日期执行精确合同', () => {
|
test('采购单号按北京时间日期执行精确合同', () => {
|
||||||
const instant = new Date('2026-09-05T16:30:00.000Z')
|
const instant = new Date('2026-09-05T16:30:00.000Z')
|
||||||
assert.equal(beijingDate(instant), '20260906')
|
assert.equal(beijingDate(instant), '20260906')
|
||||||
assert.equal(matchesProcurementNo('QLYSXDKH202609060001', 'QLYSXDKH', instant), true)
|
assert.equal(matchesProcurementNo('CG202609060001', instant), true)
|
||||||
assert.equal(matchesProcurementNo('QLYSXDKH202609050001', 'QLYSXDKH', instant), false)
|
assert.equal(matchesProcurementNo('CG202609050001', instant), false)
|
||||||
assert.equal(matchesProcurementNo('QLYSXDKH20260906001', 'QLYSXDKH', instant), false)
|
assert.equal(matchesProcurementNo('CG20260906001', instant), false)
|
||||||
|
assert.equal(matchesProcurementNo('QLYSXDKH202609060001', instant), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释放', () => {
|
test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释放', () => {
|
||||||
|
|||||||
@@ -28,9 +28,8 @@ export function beijingDate(now = new Date()) {
|
|||||||
return `${parts.year}${parts.month}${parts.day}`
|
return `${parts.year}${parts.month}${parts.day}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchesProcurementNo(orderNo, prefix, now = new Date()) {
|
export function matchesProcurementNo(orderNo, now = new Date()) {
|
||||||
return typeof prefix === 'string' && prefix.length > 0
|
return new RegExp(`^CG${beijingDate(now)}\\d{4}$`).test(String(orderNo || ''))
|
||||||
&& new RegExp(`^${prefix}${beijingDate(now)}\\d{4}$`).test(String(orderNo || ''))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function poll(action, predicate, attempts = 60, intervalMs = 1_000) {
|
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,
|
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 } })
|
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, proof.status === 'PASSED', `orderNo=${proof.orderNo}`)
|
||||||
assert(context, '采购单号日期和客户前缀正确', null,
|
assert(context, '采购单号为CG加北京时间日期和流水', null,
|
||||||
fixture.expectedProcurementPrefix ? matchesProcurementNo(proof.orderNo, fixture.expectedProcurementPrefix) : /^[A-Z0-9]{1,8}\d{8}\d{4}$/.test(proof.orderNo), `orderNo=${proof.orderNo}`)
|
matchesProcurementNo(proof.orderNo), `orderNo=${proof.orderNo}`)
|
||||||
return { orderNo: proof.orderNo, walletBusinessNos: proof.walletBusinessNos, settledAmountCny: proof.settledAmountCny }
|
return { orderNo: proof.orderNo, walletBusinessNos: proof.walletBusinessNos, settledAmountCny: proof.settledAmountCny }
|
||||||
} finally {
|
} finally {
|
||||||
for (const check of context.createdResources.procurementSample?.checks || []) assert(context, check.label, null, check.passed)
|
for (const check of context.createdResources.procurementSample?.checks || []) assert(context, check.label, null, check.passed)
|
||||||
|
|||||||
Reference in New Issue
Block a user