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