feat: recover tidy workflows from WMS evidence

This commit is contained in:
zgw
2026-07-23 15:30:18 +08:00
parent a545986409
commit 3ea7216fdb
2 changed files with 427 additions and 0 deletions

View File

@@ -0,0 +1,116 @@
package com.ruoyi.algorithm.wms.tidy;
import com.ruoyi.algorithm.wms.WmsAlgorithmMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Objects;
import java.util.Set;
@Service
public class TidyWorkflowRecoveryService {
private static final Set<String> ACTIVE_JOB_STATUSES = Set.of("1", "2", "3");
private final WmsAlgorithmMapper mapper;
private final TidyWorkflowTransaction transaction;
public TidyWorkflowRecoveryService(WmsAlgorithmMapper mapper,
TidyWorkflowTransaction transaction) {
this.mapper = mapper;
this.transaction = transaction;
}
@Transactional(rollbackFor = Exception.class)
public int reconcile(String warehouseId) {
int changed = 0;
for (TidyRecoveryEvidence evidence : mapper.selectTidyRecoveryEvidence(warehouseId)) {
if ("PAUSED_RECOVERY".equals(evidence.workflowStatus())) {
continue;
}
if (evidence.stepId() == null) {
if (evidence.activeTidyJobCount() == 0) {
mapper.completeTidyWorkflow(evidence.workflowId());
mapper.releaseTidyResources(evidence.workflowId());
changed++;
} else {
changed += pause(evidence, "RECOVERY_JOB_MISMATCH");
}
continue;
}
if (evidence.activeJobId() != null && evidence.finishedJobId() != null) {
changed += pause(evidence, "RECOVERY_FINISH_EVIDENCE_INVALID");
continue;
}
if (evidence.activeJobId() != null) {
if (!matchesStep(evidence, false)) {
changed += pause(evidence, "RECOVERY_JOB_MISMATCH");
} else if (ACTIVE_JOB_STATUSES.contains(evidence.activeJobStatus())
&& isPreMoveState(evidence)) {
continue;
} else {
changed += pause(evidence, "RECOVERY_ACTIVE_EVIDENCE_INVALID");
}
continue;
}
if (evidence.finishedJobId() != null) {
if (!matchesStep(evidence, true)) {
changed += pause(evidence, "RECOVERY_JOB_MISMATCH");
} else if ("4".equals(evidence.finishedJobStatus())
&& isTerminalMoveState(evidence)) {
if (transaction.onJobFinished(evidence.finishedJobId(), "RECOVERY_FINISHED")) {
changed++;
}
} else {
changed += pause(evidence, "RECOVERY_FINISH_EVIDENCE_INVALID");
}
continue;
}
changed += pause(evidence, "RECOVERY_JOB_MISSING");
}
return changed;
}
private boolean matchesStep(TidyRecoveryEvidence evidence, boolean finished) {
return "28".equals(finished
? evidence.finishedJobInOrOut() : evidence.activeJobInOrOut())
&& Objects.equals(evidence.trayCode(), finished
? evidence.finishedJobTrayCode() : evidence.activeJobTrayCode())
&& Objects.equals(evidence.sourceSpaceId(), finished
? evidence.finishedJobSourceSpaceId() : evidence.activeJobSourceSpaceId())
&& Objects.equals(evidence.targetSpaceId(), finished
? evidence.finishedJobTargetSpaceId() : evidence.activeJobTargetSpaceId());
}
private boolean isPreMoveState(TidyRecoveryEvidence evidence) {
return evidence.sourceStockRows() > 0
&& evidence.targetStockRows() == 0
&& "2".equals(evidence.sourceSpaceStatus())
&& Objects.equals(evidence.trayCode(), trimToNull(evidence.sourceSpaceTrayCode()))
&& "1".equals(evidence.targetSpaceStatus())
&& trimToNull(evidence.targetSpaceTrayCode()) == null;
}
private boolean isTerminalMoveState(TidyRecoveryEvidence evidence) {
return evidence.sourceStockRows() == 0
&& evidence.targetStockRows() > 0
&& "1".equals(evidence.sourceSpaceStatus())
&& trimToNull(evidence.sourceSpaceTrayCode()) == null
&& "2".equals(evidence.targetSpaceStatus())
&& Objects.equals(evidence.trayCode(), trimToNull(evidence.targetSpaceTrayCode()));
}
private int pause(TidyRecoveryEvidence evidence, String reason) {
mapper.pauseTidyWorkflow(evidence.workflowId(), reason);
if (evidence.stepNo() != null) {
mapper.pauseTidyStep(evidence.workflowId(), evidence.stepNo(), reason);
}
return 1;
}
private String trimToNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
}

View File

@@ -0,0 +1,311 @@
package com.ruoyi.algorithm.wms.tidy;
import com.ruoyi.algorithm.wms.WmsAlgorithmMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.List;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class TidyWorkflowRecoveryServiceTest {
private WmsAlgorithmMapper mapper;
private TidyWorkflowTransaction transaction;
private TidyWorkflowRecoveryService service;
@BeforeEach
void setUp() {
mapper = mock(WmsAlgorithmMapper.class);
transaction = mock(TidyWorkflowTransaction.class);
service = new TidyWorkflowRecoveryService(mapper, transaction);
}
@Test
void matchingActiveJobWithPreMoveEvidenceRemainsRunning() {
TidyRecoveryEvidence evidence = matching().build();
when(mapper.selectTidyRecoveryEvidence("01")).thenReturn(List.of(evidence));
assertThat(service.reconcile("01")).isZero();
verifyNoInteractions(transaction);
verify(mapper, never()).pauseTidyWorkflow(anyString(), anyString());
verify(mapper, never()).releaseTidyResources(anyString());
}
@Test
void matchingArchivedFinishAdvancesOnceWithoutMovingInventoryAgain() {
TidyRecoveryEvidence evidence = matching().finished().build();
when(mapper.selectTidyRecoveryEvidence("01")).thenReturn(List.of(evidence));
when(transaction.onJobFinished(evidence.finishedJobId(), "RECOVERY_FINISHED"))
.thenReturn(true, false);
assertThat(service.reconcile("01")).isEqualTo(1);
assertThat(service.reconcile("01")).isZero();
verify(transaction, times(2))
.onJobFinished(evidence.finishedJobId(), "RECOVERY_FINISHED");
verify(mapper, never()).releaseTidyResources(evidence.workflowId());
}
@Test
void completedStepsWithoutAnActiveTidyJobFinishWorkflowAndReleaseLocks() {
TidyRecoveryEvidence evidence = matching().allStepsCompleted().build();
when(mapper.selectTidyRecoveryEvidence("01")).thenReturn(List.of(evidence));
assertThat(service.reconcile("01")).isEqualTo(1);
verify(mapper).completeTidyWorkflow(evidence.workflowId());
verify(mapper).releaseTidyResources(evidence.workflowId());
}
@Test
void completedStepsWithALingeringActiveJobPauseWithoutReleasingLocks() {
TidyRecoveryEvidence evidence = matching().allStepsCompleted().activeTidyJobCount(1).build();
when(mapper.selectTidyRecoveryEvidence("01")).thenReturn(List.of(evidence));
assertThat(service.reconcile("01")).isEqualTo(1);
verify(mapper).pauseTidyWorkflow(evidence.workflowId(), "RECOVERY_JOB_MISMATCH");
verify(mapper, never()).pauseTidyStep(anyString(), org.mockito.ArgumentMatchers.anyInt(), anyString());
verify(mapper, never()).releaseTidyResources(anyString());
}
@ParameterizedTest(name = "{0}")
@MethodSource("pauseCases")
void inconsistentLocalEvidencePausesWithoutReleasingLocks(
String label, TidyRecoveryEvidence evidence, String expectedReason) {
when(mapper.selectTidyRecoveryEvidence("01")).thenReturn(List.of(evidence));
assertThat(service.reconcile("01")).as(label).isEqualTo(1);
verify(mapper).pauseTidyWorkflow(evidence.workflowId(), expectedReason);
verify(mapper).pauseTidyStep(evidence.workflowId(), evidence.stepNo(), expectedReason);
verify(mapper, never()).releaseTidyResources(anyString());
verifyNoInteractions(transaction);
}
private static Stream<Arguments> pauseCases() {
return Stream.of(
Arguments.of("invalid active inventory", matching().sourceStockRows(0).build(),
"RECOVERY_ACTIVE_EVIDENCE_INVALID"),
Arguments.of("invalid active status", matching().activeJobStatus("4").build(),
"RECOVERY_ACTIVE_EVIDENCE_INVALID"),
Arguments.of("missing active and finish jobs", matching().missingJobs().build(),
"RECOVERY_JOB_MISSING"),
Arguments.of("active job type mismatch", matching().activeJobInOrOut("27").build(),
"RECOVERY_JOB_MISMATCH"),
Arguments.of("active job tray mismatch", matching().activeJobTrayCode("OTHER").build(),
"RECOVERY_JOB_MISMATCH"),
Arguments.of("active job source mismatch", matching().activeJobSourceSpaceId("OTHER").build(),
"RECOVERY_JOB_MISMATCH"),
Arguments.of("finish status is not terminal",
matching().finished().finishedJobStatus("3").build(),
"RECOVERY_FINISH_EVIDENCE_INVALID"),
Arguments.of("finish job target mismatch",
matching().finished().finishedJobTargetSpaceId("OTHER").build(),
"RECOVERY_JOB_MISMATCH"),
Arguments.of("finish inventory is not terminal",
matching().finished().sourceStockRows(1).build(),
"RECOVERY_FINISH_EVIDENCE_INVALID"),
Arguments.of("active and finish rows coexist", matching().withBothJobs().build(),
"RECOVERY_FINISH_EVIDENCE_INVALID"));
}
@Test
void alreadyPausedWorkflowIsLeftUntouched() {
TidyRecoveryEvidence evidence = matching().workflowStatus("PAUSED_RECOVERY").build();
when(mapper.selectTidyRecoveryEvidence("01")).thenReturn(List.of(evidence));
assertThat(service.reconcile("01")).isZero();
verifyNoInteractions(transaction);
verify(mapper, never()).pauseTidyWorkflow(anyString(), anyString());
verify(mapper, never()).releaseTidyResources(anyString());
}
@Test
void queryFailurePropagatesWithoutWritingRecoveryState() {
when(mapper.selectTidyRecoveryEvidence("01"))
.thenThrow(new IllegalStateException("db unavailable"));
assertThatThrownBy(() -> service.reconcile("01"))
.isInstanceOf(IllegalStateException.class)
.hasMessage("db unavailable");
verify(mapper, never()).pauseTidyWorkflow(anyString(), anyString());
verify(mapper, never()).releaseTidyResources(anyString());
verifyNoInteractions(transaction);
}
private static EvidenceBuilder matching() {
return new EvidenceBuilder();
}
private static final class EvidenceBuilder {
private String workflowId = "WF-1";
private TidyOrigin origin = TidyOrigin.MANUAL_TIDY;
private String workflowStatus = "RUNNING";
private int activeLockCount = 2;
private String stepId = "WF-1-1";
private Integer stepNo = 1;
private String stepStatus = "PENDING";
private String sourceSpaceId = "SOURCE";
private String targetSpaceId = "TARGET";
private String trayCode = "TRAY-1";
private int activeTidyJobCount = 1;
private String activeJobId = "TDY-WF-1-WF-1-1";
private String activeJobStatus = "1";
private String activeJobInOrOut = "28";
private String activeJobTrayCode = "TRAY-1";
private String activeJobSourceSpaceId = "SOURCE";
private String activeJobTargetSpaceId = "TARGET";
private String finishedJobId;
private String finishedJobStatus;
private String finishedJobInOrOut;
private String finishedJobTrayCode;
private String finishedJobSourceSpaceId;
private String finishedJobTargetSpaceId;
private int sourceStockRows = 1;
private int targetStockRows;
private String sourceSpaceStatus = "2";
private String sourceSpaceTrayCode = "TRAY-1";
private String targetSpaceStatus = "1";
private String targetSpaceTrayCode = "";
EvidenceBuilder workflowStatus(String value) {
workflowStatus = value;
return this;
}
EvidenceBuilder activeTidyJobCount(int value) {
activeTidyJobCount = value;
return this;
}
EvidenceBuilder activeJobStatus(String value) {
activeJobStatus = value;
return this;
}
EvidenceBuilder activeJobInOrOut(String value) {
activeJobInOrOut = value;
return this;
}
EvidenceBuilder activeJobTrayCode(String value) {
activeJobTrayCode = value;
return this;
}
EvidenceBuilder activeJobSourceSpaceId(String value) {
activeJobSourceSpaceId = value;
return this;
}
EvidenceBuilder sourceStockRows(int value) {
sourceStockRows = value;
return this;
}
EvidenceBuilder finishedJobStatus(String value) {
finishedJobStatus = value;
return this;
}
EvidenceBuilder finishedJobTargetSpaceId(String value) {
finishedJobTargetSpaceId = value;
return this;
}
EvidenceBuilder finished() {
activeTidyJobCount = 0;
clearActiveJob();
finishedJobId = "TDY-WF-1-WF-1-1";
finishedJobStatus = "4";
finishedJobInOrOut = "28";
finishedJobTrayCode = trayCode;
finishedJobSourceSpaceId = sourceSpaceId;
finishedJobTargetSpaceId = targetSpaceId;
sourceStockRows = 0;
targetStockRows = 1;
sourceSpaceStatus = "1";
sourceSpaceTrayCode = "";
targetSpaceStatus = "2";
targetSpaceTrayCode = trayCode;
return this;
}
EvidenceBuilder missingJobs() {
activeTidyJobCount = 0;
clearActiveJob();
finishedJobId = null;
finishedJobStatus = null;
finishedJobInOrOut = null;
finishedJobTrayCode = null;
finishedJobSourceSpaceId = null;
finishedJobTargetSpaceId = null;
return this;
}
EvidenceBuilder withBothJobs() {
finishedJobId = activeJobId;
finishedJobStatus = "4";
finishedJobInOrOut = "28";
finishedJobTrayCode = trayCode;
finishedJobSourceSpaceId = sourceSpaceId;
finishedJobTargetSpaceId = targetSpaceId;
return this;
}
EvidenceBuilder allStepsCompleted() {
missingJobs();
stepId = null;
stepNo = null;
stepStatus = null;
sourceSpaceId = null;
targetSpaceId = null;
trayCode = null;
sourceStockRows = 0;
targetStockRows = 0;
sourceSpaceStatus = null;
sourceSpaceTrayCode = null;
targetSpaceStatus = null;
targetSpaceTrayCode = null;
return this;
}
private void clearActiveJob() {
activeJobId = null;
activeJobStatus = null;
activeJobInOrOut = null;
activeJobTrayCode = null;
activeJobSourceSpaceId = null;
activeJobTargetSpaceId = null;
}
TidyRecoveryEvidence build() {
return new TidyRecoveryEvidence(
workflowId, origin, workflowStatus, activeLockCount,
stepId, stepNo, stepStatus, sourceSpaceId, targetSpaceId, trayCode,
activeTidyJobCount,
activeJobId, activeJobStatus, activeJobInOrOut, activeJobTrayCode,
activeJobSourceSpaceId, activeJobTargetSpaceId,
finishedJobId, finishedJobStatus, finishedJobInOrOut, finishedJobTrayCode,
finishedJobSourceSpaceId, finishedJobTargetSpaceId,
sourceStockRows, targetStockRows,
sourceSpaceStatus, sourceSpaceTrayCode, targetSpaceStatus, targetSpaceTrayCode);
}
}
}