diff --git a/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/AlgorithmProperties.java b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/AlgorithmProperties.java new file mode 100644 index 0000000..2b95135 --- /dev/null +++ b/fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/AlgorithmProperties.java @@ -0,0 +1,71 @@ +package com.ruoyi.algorithm.integration; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "fortucky.algorithm") +public class AlgorithmProperties { + private SchedulingMode mode = SchedulingMode.SHADOW; + private String warehouseId = "01"; + private int pageSize = 100; + private boolean databaseWritesEnabled = true; + private boolean legacyFallbackOnTechnicalError; + private long tidyAutoScanMs = 600_000L; + private long tidyReconcileMs = 5_000L; + private int tidyClaimLeaseSeconds = 60; + private String tidyWcsSnapshotUrl = "http://localhost:8081/wcs2Wms/api/tidyVehicleSnapshot"; + private String replayRunId = ""; + private String replayDatabase = ""; + + public SchedulingMode getMode() { + return mode; + } + + public void setMode(SchedulingMode mode) { + this.mode = mode; + } + + public String getWarehouseId() { + return warehouseId; + } + + public void setWarehouseId(String warehouseId) { + this.warehouseId = warehouseId; + } + + public int getPageSize() { + return pageSize; + } + + public void setPageSize(int pageSize) { + this.pageSize = pageSize; + } + + public boolean isDatabaseWritesEnabled() { + return databaseWritesEnabled; + } + + public void setDatabaseWritesEnabled(boolean databaseWritesEnabled) { + this.databaseWritesEnabled = databaseWritesEnabled; + } + + public boolean isLegacyFallbackOnTechnicalError() { + return legacyFallbackOnTechnicalError; + } + + public void setLegacyFallbackOnTechnicalError(boolean legacyFallbackOnTechnicalError) { + this.legacyFallbackOnTechnicalError = legacyFallbackOnTechnicalError; + } + + public long getTidyAutoScanMs() { return tidyAutoScanMs; } + public void setTidyAutoScanMs(long value) { this.tidyAutoScanMs = value; } + public long getTidyReconcileMs() { return tidyReconcileMs; } + public void setTidyReconcileMs(long value) { this.tidyReconcileMs = value; } + public int getTidyClaimLeaseSeconds() { return tidyClaimLeaseSeconds; } + public void setTidyClaimLeaseSeconds(int value) { this.tidyClaimLeaseSeconds = value; } + public String getTidyWcsSnapshotUrl() { return tidyWcsSnapshotUrl; } + public void setTidyWcsSnapshotUrl(String value) { this.tidyWcsSnapshotUrl = value; } + public String getReplayRunId() { return replayRunId; } + public void setReplayRunId(String value) { this.replayRunId = value; } + public String getReplayDatabase() { return replayDatabase; } + public void setReplayDatabase(String value) { this.replayDatabase = value; } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/WmsAlgorithmMapper.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/WmsAlgorithmMapper.java index 80ca1bc..6216b91 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/WmsAlgorithmMapper.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/WmsAlgorithmMapper.java @@ -6,7 +6,6 @@ import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import java.util.List; -import com.ruoyi.algorithm.wms.tidy.TidyWorkflowState; import com.ruoyi.algorithm.wms.tidy.TidyRecoveryEvidence; @Mapper @@ -59,7 +58,6 @@ public interface WmsAlgorithmMapper { int pauseTidyWorkflow(@Param("workflowId") String workflowId, @Param("statusReason") String statusReason); int pauseTidyStep(@Param("workflowId") String workflowId, @Param("stepNo") int stepNo, @Param("statusReason") String statusReason); - List selectActiveTidyWorkflows(@Param("warehouseId") String warehouseId); List selectTidyRecoveryEvidence(@Param("warehouseId") String warehouseId); TidyModeSnapshot selectTidyMode(@Param("warehouseId") String warehouseId); diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowTransaction.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowTransaction.java new file mode 100644 index 0000000..79d1937 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowTransaction.java @@ -0,0 +1,80 @@ +package com.ruoyi.algorithm.wms.tidy; + +import com.ruoyi.algorithm.tidy.TidyCandidate; +import com.ruoyi.algorithm.tidy.TidyMove; +import com.ruoyi.algorithm.wms.WmsAlgorithmMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Service +public class TidyWorkflowTransaction implements TidyWorkflowStarter { + private final WmsAlgorithmMapper mapper; + private final ExecutionAdmissionService admission; + + public TidyWorkflowTransaction(WmsAlgorithmMapper mapper, ExecutionAdmissionService admission) { + this.mapper = mapper; + this.admission = admission; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public UUID startAutomatic(TidyLaneClaim claim, TidyCandidate candidate) { + return start(TidyOrigin.AUTO_TIDY, claim.warehouseId(), candidate, claim); + } + + @Transactional(rollbackFor = Exception.class) + public UUID start(TidyOrigin origin, String warehouseId, TidyCandidate candidate) { + return start(origin, warehouseId, candidate, null); + } + + private UUID start(TidyOrigin origin, String warehouseId, TidyCandidate candidate, TidyLaneClaim claim) { + admission.assertAllowed(warehouseId, origin == TidyOrigin.AUTO_TIDY + ? ExecutionWorkType.AUTO_TIDY : ExecutionWorkType.MANUAL_TIDY); + if (candidate == null || candidate.moves().isEmpty()) { + throw new IllegalArgumentException("TIDY_CANDIDATE_EMPTY"); + } + UUID workflowId = UUID.randomUUID(); + String laneCode = candidate.releasesLaneCode().orElse(""); + String token = claim == null ? UUID.randomUUID().toString() : claim.claimToken().toString(); + long dirtyVersion = claim == null ? 0 : claim.dirtyVersion(); + mapper.insertTidyWorkflow(workflowId.toString(), warehouseId, laneCode, origin.name(), + candidate.type().name(), candidate.moves().get(0).fromSpaceId(), + candidate.moves().get(candidate.moves().size() - 1).toSpaceId(), + candidate.moves().get(0).trayNo(), "CLAIMED", dirtyVersion, token); + int stepNo = 0; + String dependency = null; + for (TidyMove move : candidate.moves()) { + String stepId = workflowId + "-" + (++stepNo); + mapper.insertTidyStep(stepId, workflowId.toString(), stepNo, move.fromSpaceId(), + move.toSpaceId(), move.trayNo(), "PENDING", dependency); + mapper.lockTidyResource(UUID.randomUUID(), workflowId.toString(), warehouseId, + "SPACE", move.fromSpaceId()); + mapper.lockTidyResource(UUID.randomUUID(), workflowId.toString(), warehouseId, + "SPACE", move.toSpaceId()); + dependency = stepId; + } + mapper.insertTidyMoveJob(workflowId.toString(), workflowId + "-1"); + return workflowId; + } + + @Transactional(rollbackFor = Exception.class) + public boolean onJobFinished(String jobId, String callbackStage) { + if (jobId == null || jobId.isBlank()) return false; + String workflowId = mapper.selectTidyWorkflowIdByJob(jobId); + if (workflowId == null) return false; + String stepId = mapper.selectTidyStepIdByJob(jobId); + if (mapper.insertTidyCallbackEventIfAbsent(UUID.randomUUID(), workflowId, stepId, + jobId, callbackStage, "SUCCESS") == 0) return false; + mapper.completeTidyStep(stepId); + String next = mapper.selectNextTidyStepId(workflowId); + if (next == null) { + mapper.completeTidyWorkflow(workflowId); + mapper.releaseTidyResources(workflowId); + } else { + mapper.insertTidyMoveJob(workflowId, next); + } + return true; + } +} diff --git a/ruoyi-admin/src/main/resources/mapper/algorithm/WmsAlgorithmMapper.xml b/ruoyi-admin/src/main/resources/mapper/algorithm/WmsAlgorithmMapper.xml index cf9a878..9417673 100644 --- a/ruoyi-admin/src/main/resources/mapper/algorithm/WmsAlgorithmMapper.xml +++ b/ruoyi-admin/src/main/resources/mapper/algorithm/WmsAlgorithmMapper.xml @@ -159,46 +159,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and status in ('PENDING', 'DISPATCHED', 'RUNNING') - - - - - - - - - - - - - - - - mode.warehouse_id as warehouseId, mode.requested_enabled as requestedEnabled, diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/WmsAlgorithmMapperWorkflowSqlTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/WmsAlgorithmMapperWorkflowSqlTest.java index 5bb4d07..b67293d 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/WmsAlgorithmMapperWorkflowSqlTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/WmsAlgorithmMapperWorkflowSqlTest.java @@ -161,17 +161,6 @@ class WmsAlgorithmMapperWorkflowSqlTest { .doesNotContain("foreach"); } - @Test - void activeTidyWorkflowUsesAnExplicitDatabaseShapedConstructorMapping() throws IOException { - assertThat(statement("resultmap", "tidyWorkflowStateMap")) - .contains("") - .contains("column=\"workflowid\" javatype=\"java.lang.string\"") - .contains("column=\"origin\" javatype=\"com.ruoyi.algorithm.wms.tidy.tidyorigin\"") - .contains("column=\"activestep\" javatype=\"_int\""); - assertThat(statement("select", "selectActiveTidyWorkflows")) - .contains("resultmap=\"tidyworkflowstatemap\""); - } - @Test void tidyWorkflowCompletionDoesNotRewriteTerminalRows() throws IOException { assertThat(statement("update", "completeTidyWorkflow")) diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowTransactionTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowTransactionTest.java new file mode 100644 index 0000000..207cd62 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowTransactionTest.java @@ -0,0 +1,83 @@ +package com.ruoyi.algorithm.wms.tidy; + +import com.ruoyi.algorithm.tidy.TidyCandidate; +import com.ruoyi.algorithm.tidy.TidyCandidateType; +import com.ruoyi.algorithm.tidy.TidyMove; +import com.ruoyi.algorithm.wms.WmsAlgorithmMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TidyWorkflowTransactionTest { + @Test + void startsOrderedMovesAsSingleWorkflowWithFirstMoveJob() { + WmsAlgorithmMapper mapper = mock(WmsAlgorithmMapper.class); + ExecutionAdmissionService admission = mock(ExecutionAdmissionService.class); + TidyWorkflowTransaction tx = new TidyWorkflowTransaction(mapper, admission); + + UUID workflow = tx.start(TidyOrigin.MANUAL_TIDY, "01", candidate()); + + assertThat(workflow).isNotNull(); + verify(admission).assertAllowed("01", ExecutionWorkType.MANUAL_TIDY); + verify(mapper).insertTidyWorkflow(any(), eq("01"), any(), eq("MANUAL_TIDY"), + eq("CONSOLIDATE_INCUBATION"), eq("SPACE-A"), eq("SPACE-C"), eq("TRAY-1"), + eq("CLAIMED"), eq(0L), anyString()); + verify(mapper).insertTidyStep(any(), any(), eq(1), eq("SPACE-A"), eq("SPACE-B"), + eq("TRAY-1"), eq("PENDING"), eq(null)); + verify(mapper).insertTidyStep(any(), any(), eq(2), eq("SPACE-B"), eq("SPACE-C"), + eq("TRAY-2"), eq("PENDING"), any()); + verify(mapper).insertTidyMoveJob(any(), any()); + } + + @Test + void duplicateCallbackDoesNotCompleteOrDispatchAgain() { + WmsAlgorithmMapper mapper = mock(WmsAlgorithmMapper.class); + ExecutionAdmissionService admission = mock(ExecutionAdmissionService.class); + TidyWorkflowTransaction tx = new TidyWorkflowTransaction(mapper, admission); + when(mapper.selectTidyWorkflowIdByJob("JOB-1")).thenReturn("WF-1"); + when(mapper.selectTidyStepIdByJob("JOB-1")).thenReturn("STEP-1"); + when(mapper.insertTidyCallbackEventIfAbsent(any(), eq("WF-1"), eq("STEP-1"), + eq("JOB-1"), eq("FINISHED"), eq("SUCCESS"))).thenReturn(0); + + assertThat(tx.onJobFinished("JOB-1", "FINISHED")).isFalse(); + verify(mapper, never()).completeTidyStep(any()); + verify(mapper, never()).insertTidyMoveJob(any(), any()); + } + + @Test + void recoveryFinishedUsesTheNormalIdempotentAdvancementPath() { + WmsAlgorithmMapper mapper = mock(WmsAlgorithmMapper.class); + ExecutionAdmissionService admission = mock(ExecutionAdmissionService.class); + TidyWorkflowTransaction tx = new TidyWorkflowTransaction(mapper, admission); + when(mapper.selectTidyWorkflowIdByJob("JOB-1")).thenReturn("WF-1"); + when(mapper.selectTidyStepIdByJob("JOB-1")).thenReturn("STEP-1"); + when(mapper.insertTidyCallbackEventIfAbsent(any(), eq("WF-1"), eq("STEP-1"), + eq("JOB-1"), eq("RECOVERY_FINISHED"), eq("SUCCESS"))).thenReturn(1); + when(mapper.selectNextTidyStepId("WF-1")).thenReturn(null); + + assertThat(tx.onJobFinished("JOB-1", "RECOVERY_FINISHED")).isTrue(); + + verify(mapper).completeTidyStep("STEP-1"); + verify(mapper).completeTidyWorkflow("WF-1"); + verify(mapper).releaseTidyResources("WF-1"); + verify(mapper, never()).pauseTidyWorkflow(anyString(), anyString()); + } + + private TidyCandidate candidate() { + return new TidyCandidate(TidyCandidateType.CONSOLIDATE_INCUBATION, "P-1", + List.of(new TidyMove("TRAY-1", "SPACE-A", "SPACE-B"), + new TidyMove("TRAY-2", "SPACE-B", "SPACE-C")), + Optional.of("LANE-1"), "test"); + } +} diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/WcsTaskStateDependencyRemovalTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/WcsTaskStateDependencyRemovalTest.java new file mode 100644 index 0000000..2a84580 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/WcsTaskStateDependencyRemovalTest.java @@ -0,0 +1,35 @@ +package com.ruoyi.algorithm.wms.tidy; + +import com.ruoyi.algorithm.integration.AlgorithmProperties; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class WcsTaskStateDependencyRemovalTest { + @Test + void taskStateClassesPropertiesAndConfigurationAreAbsent() throws Exception { + assertThatThrownBy(() -> Class.forName( + "com.ruoyi.algorithm.wms.tidy.WcsTaskStateClient")) + .isInstanceOf(ClassNotFoundException.class); + assertThatThrownBy(() -> Class.forName( + "com.ruoyi.algorithm.wms.tidy.WcsTaskState")) + .isInstanceOf(ClassNotFoundException.class); + + assertThat(Arrays.stream(AlgorithmProperties.class.getMethods()) + .map(Method::getName)) + .noneMatch(name -> name.contains("TidyWcsTaskState")); + + try (InputStream stream = getClass().getResourceAsStream("/application.yml")) { + assertThat(stream).isNotNull(); + String yaml = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + assertThat(yaml).doesNotContain("tidy-wcs-task-state"); + assertThat(yaml).contains("tidy-wcs-snapshot-url"); + } + } +}