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
@@ -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")));