Align submit review action eligibility with persisted review state

This commit is contained in:
2026-10-20 11:24:19 +08:00
parent c76b9888f5
commit 6c59525dec
2 changed files with 67 additions and 2 deletions
@@ -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<Action,Gate> actions(Actor actor,DispatchAggregate row) {
var gates=new EnumMap<Action,Gate>(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;
@@ -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);