This commit is contained in:
2026-10-21 15:10:44 +08:00
parent 40a03978cd
commit 6fe632f28e
34 changed files with 372 additions and 110 deletions
@@ -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,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);
@@ -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明确拒绝归属或文件格式属于业务错误,不能伪装成服务不可用。 */
@@ -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; }
}
/** 只有零真实扫码时可退回,保留原单号与审核轮次历史。 */
@@ -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) { }
@@ -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())); }
/** 空单永远不能核平为装车完成。 */
@@ -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
@@ -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(
@@ -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) {
@@ -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);}
@@ -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);
}
}
@@ -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)));
}
}
@@ -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")));