From 3ea7216fdb2b5d7b457a75b0f2ad621e538f81f6 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Thu, 23 Jul 2026 15:30:18 +0800 Subject: [PATCH] feat: recover tidy workflows from WMS evidence --- .../wms/tidy/TidyWorkflowRecoveryService.java | 116 +++++++ .../tidy/TidyWorkflowRecoveryServiceTest.java | 311 ++++++++++++++++++ 2 files changed, 427 insertions(+) create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowRecoveryService.java create mode 100644 ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowRecoveryServiceTest.java diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowRecoveryService.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowRecoveryService.java new file mode 100644 index 0000000..6e04b9d --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowRecoveryService.java @@ -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 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(); + } +} diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowRecoveryServiceTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowRecoveryServiceTest.java new file mode 100644 index 0000000..f82cb41 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowRecoveryServiceTest.java @@ -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 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); + } + } +}