diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index b283e69fb..8c52fba4b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -369,7 +369,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.SUBMIT_REVIEW,request.recordVersion(),request.idempotencyKey(),request); var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); requireEditing(row,request.recordVersion()); - if(!Set.of(ReviewState.DRAFT.name(),ReviewState.DISPATCH_REJECTED.name(),ReviewState.LOADING_REJECTED.name()).contains(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对"); + if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对"); verifyReviewResources(row); if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核"); long round=row.getReviewRound()+1;String basis=basis(row); @@ -739,6 +739,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(!Status.ACTIVE.name().equals(row.getStatus()) || !Stage.BEFORE_LOADING.name().equals(row.getStage()) || n(row.getPickedPackageCount())>0 || n(row.getShippedPackageCount())>0) throw conflict("仅装车前且未扫码的有效排车可以调整货物"); } + /** 提交资格和写入共用可重提状态,已进入两级待审的整车不得再次显示提交入口。 */ + private static boolean canSubmitReview(String reviewState) { + return ReviewState.DRAFT.name().equals(reviewState) || ReviewState.DISPATCH_REJECTED.name().equals(reviewState) + || ReviewState.LOADING_REJECTED.name().equals(reviewState); + } + /** 动作快照只反映实际当前资格,写入仍须再次加锁校验。 */ Map actions(Actor actor,DispatchAggregate row) { var gates=new EnumMap(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus()); @@ -746,7 +752,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA for(var action:Action.values()) { boolean state=switch(action) { case CREATE -> actor.mode()==ActorMode.PLATFORM; - case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL,SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0; + case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0; + case SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0 && canSubmitReview(row.getReviewState()); case DISPATCH_REVIEW -> active && review==ReviewState.DISPATCH_PENDING; case LOADING_REVIEW -> active && review==ReviewState.LOADING_PENDING; case RETURN_TO_REVIEW -> active && stage==Stage.LOADING && n(row.getPickedPackageCount())==0; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index 29a1a83d0..356a5c095 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -287,6 +287,64 @@ public class DispatchAggregatePersistenceTest { assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"missing-rejected",preview.manifestHash(),List.of(),null)))).isInstanceOf(BizException.class).hasMessageContaining("核平"); assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING"); } + /** 已提交根的公开资格关闭;新键重复提交不改变事实,原键仍先于当前状态恢复结果。 */ + @Test void pendingSubmissionGateRejectsNewKeyWithoutMutationAndRestoresOriginalCommand() throws Exception { + var security=org.springframework.security.core.context.SecurityContextHolder.getContext();var previous=security.getAuthentication(); + security.setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("operator-test","", + "platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit")); + try { + 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("submission-gate-create",List.of(source)))); + var request=new DispatchCommands.SubmitReview(created.recordVersion(),"submission-gate-original",null); + var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),request)); + var detail=f.tx.execute(s->f.service.detail(f.actor,created.dispatchNo())); + assertThat(detail.reviewState()).isEqualTo(ReviewState.DISPATCH_PENDING); + assertThat(detail.actions().get(Action.SUBMIT_REVIEW).allowed()).isFalse(); + var before=f.json.writeValueAsString(f.repository.find(created.dispatchNo())); + var commands=f.db.queryForList("SELECT id,idempotency_key,record_version,command_hash FROM ll_dispatch_command ORDER BY id"); + assertThatThrownBy(()->f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(), + new DispatchCommands.SubmitReview(detail.recordVersion(),"submission-gate-duplicate",null)))) + .isInstanceOf(BizException.class).hasMessageContaining("当前审核已提交"); + assertThat(f.json.writeValueAsString(f.repository.find(created.dispatchNo()))).isEqualTo(before); + assertThat(f.db.queryForList("SELECT id,idempotency_key,record_version,command_hash FROM ll_dispatch_command ORDER BY id")).isEqualTo(commands); + var replay=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),request)); + assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(submitted.recordVersion()); + assertThat(replay.result()).isEqualTo(submitted.result()); + assertThat(f.json.writeValueAsString(f.repository.find(created.dispatchNo()))).isEqualTo(before); + assertThat(f.db.queryForList("SELECT id,idempotency_key,record_version,command_hash FROM ll_dispatch_command ORDER BY id")).isEqualTo(commands); + } finally { security.setAuthentication(previous); } + } + + /** 真实两级审核流证明三种可重提状态开放,而两种待审状态均关闭提交资格。 */ + @Test void submissionActionMatchesDraftRejectedAndPendingReviewStates() throws Exception { + var security=org.springframework.security.core.context.SecurityContextHolder.getContext();var previous=security.getAuthentication(); + security.setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("operator-test","", + "platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit", + "platform:fleet:trip:dispatch-review","platform:fleet:trip:loading-review")); + try { + 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("submission-states-create",List.of(source)))); + 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,"核对调度"))); + 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))); + 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,"核对装载"))); + 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); + } finally { security.setAuthentication(previous); } + } + + /** 从真实详情DTO读取动作资格,避免只测内部枚举或权限缺失导致的假阴性。 */ + private static void assertSubmissionGate(Fixture f,String number,ReviewState state,boolean allowed) { + var detail=f.tx.execute(s->f.service.detail(f.actor,number)); + assertThat(detail.reviewState()).isEqualTo(state);assertThat(detail.actions().get(Action.SUBMIT_REVIEW).allowed()).isEqualTo(allowed); + } + /** 两级顺序、驳回重提、原结果恢复和零扫码退回均实际提交到同一整车。 */ @Test void reviewsUseActualRootRoundAndImmutableCommandsBeforeAndAfterLoading() throws Exception { var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);