feat: recover tidy workflows from WMS evidence
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user