From 01333cbf03b0a6ad691492ff79a5a98169d371a4 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Tue, 11 Aug 2026 18:39:15 +0800 Subject: [PATCH] feat: add fail-open shadow planning boundary --- .../shadow/InboundShadowObservation.java | 19 +++ .../shadow/OutboundShadowObservation.java | 20 +++ .../shadow/ShadowInboundReference.java | 49 ++++++ .../shadow/ShadowObservationGateway.java | 83 ++++++++++ .../shadow/ShadowObservationProperties.java | 36 +++++ .../shadow/ShadowPlanningEngine.java | 152 ++++++++++++++++++ .../algorithm/shadow/ShadowSafetyGuard.java | 40 +++++ .../shadow/ShadowStockReference.java | 67 ++++++++ .../shadow/mapper/ShadowInputMapper.java | 23 +++ .../mapper/algorithm/ShadowInputMapper.xml | 55 +++++++ .../shadow/ShadowObservationGatewayTest.java | 49 ++++++ .../shadow/ShadowPlanningEngineTest.java | 74 +++++++++ .../shadow/ShadowSafetyGuardTest.java | 67 ++++++++ 13 files changed, 734 insertions(+) create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/InboundShadowObservation.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/OutboundShadowObservation.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowInboundReference.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationGateway.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationProperties.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuard.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowStockReference.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java create mode 100644 ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml create mode 100644 ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowObservationGatewayTest.java create mode 100644 ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java create mode 100644 ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuardTest.java diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/InboundShadowObservation.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/InboundShadowObservation.java new file mode 100644 index 0000000..da45ce3 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/InboundShadowObservation.java @@ -0,0 +1,19 @@ +package com.ruoyi.algorithm.shadow; + +public record InboundShadowObservation( + boolean observed, + String warehouseId, + String requestId, + String resultCode, + String proposedSpaceId, + String reason) { + + public static InboundShadowObservation empty() { + return new InboundShadowObservation(false, null, null, "NOT_OBSERVED", null, null); + } + + public static InboundShadowObservation unavailable(String warehouseId, String requestId, + String resultCode, String reason) { + return new InboundShadowObservation(true, warehouseId, requestId, resultCode, null, reason); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/OutboundShadowObservation.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/OutboundShadowObservation.java new file mode 100644 index 0000000..0546c14 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/OutboundShadowObservation.java @@ -0,0 +1,20 @@ +package com.ruoyi.algorithm.shadow; + +public record OutboundShadowObservation( + boolean observed, + String warehouseId, + String requestId, + String resultCode, + String proposedStockId, + String proposedSpaceId, + String reason) { + + public static OutboundShadowObservation empty() { + return new OutboundShadowObservation(false, null, null, "NOT_OBSERVED", null, null, null); + } + + public static OutboundShadowObservation unavailable(String warehouseId, String requestId, + String resultCode, String reason) { + return new OutboundShadowObservation(true, warehouseId, requestId, resultCode, null, null, reason); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowInboundReference.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowInboundReference.java new file mode 100644 index 0000000..018c668 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowInboundReference.java @@ -0,0 +1,49 @@ +package com.ruoyi.algorithm.shadow; + +public class ShadowInboundReference { + private String requestId; + private String trayNo; + private String projectNo; + private String productId; + private String lotNo; + + public String getRequestId() { + return requestId; + } + + public void setRequestId(String requestId) { + this.requestId = requestId; + } + + public String getTrayNo() { + return trayNo; + } + + public void setTrayNo(String trayNo) { + this.trayNo = trayNo; + } + + public String getProjectNo() { + return projectNo; + } + + public void setProjectNo(String projectNo) { + this.projectNo = projectNo; + } + + public String getProductId() { + return productId; + } + + public void setProductId(String productId) { + this.productId = productId; + } + + public String getLotNo() { + return lotNo; + } + + public void setLotNo(String lotNo) { + this.lotNo = lotNo; + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationGateway.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationGateway.java new file mode 100644 index 0000000..6edbe33 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationGateway.java @@ -0,0 +1,83 @@ +package com.ruoyi.algorithm.shadow; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.Objects; + +@Component +public class ShadowObservationGateway { + private static final Logger LOG = LoggerFactory.getLogger(ShadowObservationGateway.class); + + private final ShadowObservationProperties properties; + private final ShadowPlanningEngine engine; + + public ShadowObservationGateway(ShadowObservationProperties properties, ShadowPlanningEngine engine) { + this.properties = Objects.requireNonNull(properties, "properties"); + this.engine = engine; + } + + public InboundShadowObservation planInbound(String taskNo, String trayNo) { + if (!properties.isEnabled() || engine == null) { + return InboundShadowObservation.empty(); + } + try { + return engine.planInbound(taskNo, trayNo); + } catch (RuntimeException exception) { + LOG.warn("Shadow inbound planning failed open for task {}: {}", taskNo, exception.getMessage()); + return InboundShadowObservation.empty(); + } + } + + public OutboundShadowObservation planProjectOutbound(String stockoutDetailId) { + if (!properties.isEnabled() || engine == null) { + return OutboundShadowObservation.empty(); + } + try { + return engine.planProjectOutbound(stockoutDetailId); + } catch (RuntimeException exception) { + LOG.warn("Shadow outbound planning failed open for detail {}: {}", + stockoutDetailId, exception.getMessage()); + return OutboundShadowObservation.empty(); + } + } + + public OutboundShadowObservation planExactStockOutbound(String stockoutDetailId, String stockId) { + if (!properties.isEnabled() || engine == null) { + return OutboundShadowObservation.empty(); + } + try { + return engine.planExactStockOutbound(stockoutDetailId, stockId); + } catch (RuntimeException exception) { + LOG.warn("Shadow exact-stock planning failed open for detail {} stock {}: {}", + stockoutDetailId, stockId, exception.getMessage()); + return OutboundShadowObservation.empty(); + } + } + + public void compareInbound(InboundShadowObservation observation, String legacyResultCode, + String legacySpaceId) { + if (!properties.isEnabled() || engine == null || observation == null || !observation.observed()) { + return; + } + try { + engine.compareInbound(observation, legacyResultCode, legacySpaceId); + } catch (RuntimeException exception) { + LOG.warn("Shadow inbound comparison failed open for request {}: {}", + observation.requestId(), exception.getMessage()); + } + } + + public void compareOutbound(OutboundShadowObservation observation, String legacyResultCode) { + if (!properties.isEnabled() || engine == null || observation == null || !observation.observed()) { + return; + } + try { + engine.compareOutbound(observation, legacyResultCode); + } catch (RuntimeException exception) { + LOG.warn("Shadow outbound comparison failed open for request {}: {}", + observation.requestId(), exception.getMessage()); + } + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationProperties.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationProperties.java new file mode 100644 index 0000000..7d7588a --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationProperties.java @@ -0,0 +1,36 @@ +package com.ruoyi.algorithm.shadow; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "fortucky.shadow") +public class ShadowObservationProperties { + private boolean enabled; + private String warehouseId = ""; + private int timeoutSeconds = 3; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getWarehouseId() { + return warehouseId; + } + + public void setWarehouseId(String warehouseId) { + this.warehouseId = warehouseId == null ? "" : warehouseId.trim(); + } + + public int getTimeoutSeconds() { + return timeoutSeconds; + } + + public void setTimeoutSeconds(int timeoutSeconds) { + this.timeoutSeconds = timeoutSeconds; + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java new file mode 100644 index 0000000..5186557 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java @@ -0,0 +1,152 @@ +package com.ruoyi.algorithm.shadow; + +import com.ruoyi.algorithm.inbound.InboundPlan; +import com.ruoyi.algorithm.inbound.InboundRequest; +import com.ruoyi.algorithm.integration.SchedulingFacade; +import com.ruoyi.algorithm.outbound.OutboundPlan; +import com.ruoyi.algorithm.outbound.OutboundRequest; +import com.ruoyi.algorithm.service.InboundPlanningResult; +import com.ruoyi.algorithm.shadow.mapper.ShadowInputMapper; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +@Component +public class ShadowPlanningEngine { + private final SchedulingFacade schedulingFacade; + private final ShadowInputMapper inputMapper; + private final ShadowObservationProperties properties; + private final TransactionTemplate transactions; + + public ShadowPlanningEngine(SchedulingFacade schedulingFacade, ShadowInputMapper inputMapper, + ShadowObservationProperties properties, PlatformTransactionManager transactionManager) { + this.schedulingFacade = Objects.requireNonNull(schedulingFacade, "schedulingFacade"); + this.inputMapper = Objects.requireNonNull(inputMapper, "inputMapper"); + this.properties = Objects.requireNonNull(properties, "properties"); + this.transactions = new TransactionTemplate(Objects.requireNonNull(transactionManager, "transactionManager")); + this.transactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + this.transactions.setTimeout(properties.getTimeoutSeconds()); + } + + public InboundShadowObservation planInbound(String taskNo, String trayNo) { + return transactions.execute(status -> planInboundInsideTransaction(taskNo, trayNo)); + } + + public OutboundShadowObservation planProjectOutbound(String stockoutDetailId) { + return transactions.execute(status -> { + ShadowStockReference reference = inputMapper.selectProjectOutboundReference(stockoutDetailId); + String warehouseId = warehouseId(); + if (reference == null || isBlank(reference.getProjectNo())) { + return OutboundShadowObservation.unavailable(warehouseId, stockoutDetailId, + "SKIPPED_MISSING_PROJECT", "No project number was available for shadow planning"); + } + OutboundRequest request = new OutboundRequest(stockoutDetailId, null, + reference.getProjectNo(), reference.getProductId(), null, null, null, true); + return toOutboundObservation(warehouseId, reference, schedulingFacade.planOutbound(warehouseId, request)); + }); + } + + public OutboundShadowObservation planExactStockOutbound(String stockoutDetailId, String stockId) { + return transactions.execute(status -> { + ShadowStockReference reference = inputMapper.selectStockReference(stockId); + String warehouseId = warehouseId(); + if (reference == null || isBlank(reference.getTrayNo())) { + return OutboundShadowObservation.unavailable(warehouseId, stockoutDetailId, + "SKIPPED_MISSING_STOCK", "Selected stock was unavailable to shadow planning"); + } + OutboundRequest request = new OutboundRequest(stockoutDetailId, reference.getTrayNo(), + reference.getProjectNo(), reference.getProductId(), null, null, null, true); + return toOutboundObservation(warehouseId, reference, schedulingFacade.planOutbound(warehouseId, request)); + }); + } + + public void compareInbound(InboundShadowObservation observation, String legacyResultCode, + String legacySpaceId) { + transactions.executeWithoutResult(status -> inputMapper.insertComparison( + UUID.randomUUID().toString(), observation.warehouseId(), "INBOUND_COMPARE", + comparisonCode(observation.proposedSpaceId(), legacySpaceId), + "request=" + observation.requestId() + "; shadow=" + observation.resultCode() + + "; proposedSpace=" + nullSafe(observation.proposedSpaceId()) + + "; legacy=" + nullSafe(legacyResultCode) + + "; legacySpace=" + nullSafe(legacySpaceId))); + } + + public void compareOutbound(OutboundShadowObservation observation, String legacyResultCode) { + transactions.executeWithoutResult(status -> inputMapper.insertComparison( + UUID.randomUUID().toString(), observation.warehouseId(), "OUTBOUND_COMPARE", + "LEGACY_" + nullSafe(legacyResultCode), + "request=" + observation.requestId() + "; shadow=" + observation.resultCode() + + "; proposedStock=" + nullSafe(observation.proposedStockId()) + + "; proposedSpace=" + nullSafe(observation.proposedSpaceId()) + + "; legacy=" + nullSafe(legacyResultCode))); + } + + private InboundShadowObservation planInboundInsideTransaction(String taskNo, String trayNo) { + String warehouseId = warehouseId(); + ShadowInboundReference reference = inputMapper.selectInboundInput(taskNo, trayNo); + if (reference == null || isBlank(reference.getRequestId()) || isBlank(reference.getTrayNo())) { + return InboundShadowObservation.unavailable(warehouseId, taskNo, + "SKIPPED_MISSING_INPUT", "Inbound task details were unavailable to shadow planning"); + } + InboundRequest request = new InboundRequest(reference.getRequestId(), reference.getTrayNo(), + reference.getProjectNo(), reference.getProductId(), reference.getLotNo(), + null, null, null, Set.of(), null); + InboundPlanningResult result = schedulingFacade.planInbound(warehouseId, request); + InboundPlan plan = result.plan(); + if (plan.assignment().isPresent()) { + var assignment = plan.assignment().orElseThrow(); + return new InboundShadowObservation(true, warehouseId, reference.getRequestId(), + "READY", assignment.spaceId(), assignment.reason()); + } + var rejection = plan.rejection().orElseThrow(); + return InboundShadowObservation.unavailable(warehouseId, reference.getRequestId(), + "REJECTED_" + rejection.code().name(), rejection.reason()); + } + + private OutboundShadowObservation toOutboundObservation(String warehouseId, + ShadowStockReference reference, OutboundPlan plan) { + String requestId = reference.getRequestId(); + if (isBlank(requestId)) { + requestId = plan.assignment().map(assignment -> assignment.requestId()).orElse(null); + } + if (plan.assignment().isPresent()) { + var assignment = plan.assignment().orElseThrow(); + return new OutboundShadowObservation(true, warehouseId, requestId, "READY", + reference.getStockId(), assignment.sourceSpaceId(), assignment.reason()); + } + if (plan.waiting().isPresent()) { + var waiting = plan.waiting().orElseThrow(); + return OutboundShadowObservation.unavailable(warehouseId, requestId, + "WAITING_" + waiting.code().name(), waiting.reason()); + } + var error = plan.error().orElseThrow(); + return OutboundShadowObservation.unavailable(warehouseId, requestId, + "ERROR_" + error.code().name(), error.message()); + } + + private String warehouseId() { + return isBlank(properties.getWarehouseId()) + ? schedulingFacade.defaultWarehouseId() + : properties.getWarehouseId(); + } + + private String comparisonCode(String shadowSpaceId, String legacySpaceId) { + if (isBlank(shadowSpaceId) || isBlank(legacySpaceId)) { + return "NOT_COMPARABLE"; + } + return shadowSpaceId.equals(legacySpaceId) ? "MATCH" : "DIFFERENT"; + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private String nullSafe(String value) { + return value == null ? "" : value; + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuard.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuard.java new file mode 100644 index 0000000..6fbad1b --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuard.java @@ -0,0 +1,40 @@ +package com.ruoyi.algorithm.shadow; + +import com.ruoyi.algorithm.integration.AlgorithmProperties; +import com.ruoyi.algorithm.integration.SchedulingMode; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.stereotype.Component; + +import java.util.Objects; + +@Component +public class ShadowSafetyGuard implements InitializingBean { + private final ShadowObservationProperties shadowProperties; + private final AlgorithmProperties algorithmProperties; + + public ShadowSafetyGuard(ShadowObservationProperties shadowProperties, + AlgorithmProperties algorithmProperties) { + this.shadowProperties = Objects.requireNonNull(shadowProperties, "shadowProperties"); + this.algorithmProperties = Objects.requireNonNull(algorithmProperties, "algorithmProperties"); + } + + @Override + public void afterPropertiesSet() { + validate(); + } + + public void validate() { + if (!shadowProperties.isEnabled()) { + return; + } + if (algorithmProperties.getMode() != SchedulingMode.SHADOW) { + throw new IllegalStateException("Shadow observation requires FORTUCKY_ALGORITHM_MODE=SHADOW"); + } + if (!algorithmProperties.isDatabaseWritesEnabled()) { + throw new IllegalStateException("Shadow observation requires algorithm database writes for alg.* read models"); + } + if (shadowProperties.getTimeoutSeconds() <= 0) { + throw new IllegalStateException("Shadow observation timeout must be positive"); + } + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowStockReference.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowStockReference.java new file mode 100644 index 0000000..f8d5efe --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowStockReference.java @@ -0,0 +1,67 @@ +package com.ruoyi.algorithm.shadow; + +public class ShadowStockReference { + private String requestId; + private String stockId; + private String trayNo; + private String spaceId; + private String warehouseId; + private String projectNo; + private String productId; + + public String getRequestId() { + return requestId; + } + + public void setRequestId(String requestId) { + this.requestId = requestId; + } + + public String getStockId() { + return stockId; + } + + public void setStockId(String stockId) { + this.stockId = stockId; + } + + public String getTrayNo() { + return trayNo; + } + + public void setTrayNo(String trayNo) { + this.trayNo = trayNo; + } + + public String getSpaceId() { + return spaceId; + } + + public void setSpaceId(String spaceId) { + this.spaceId = spaceId; + } + + public String getWarehouseId() { + return warehouseId; + } + + public void setWarehouseId(String warehouseId) { + this.warehouseId = warehouseId; + } + + public String getProjectNo() { + return projectNo; + } + + public void setProjectNo(String projectNo) { + this.projectNo = projectNo; + } + + public String getProductId() { + return productId; + } + + public void setProductId(String productId) { + this.productId = productId; + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java new file mode 100644 index 0000000..8ec1fcc --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java @@ -0,0 +1,23 @@ +package com.ruoyi.algorithm.shadow.mapper; + +import com.ruoyi.algorithm.shadow.ShadowInboundReference; +import com.ruoyi.algorithm.shadow.ShadowStockReference; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface ShadowInputMapper { + ShadowInboundReference selectInboundInput(@Param("taskNo") String taskNo, + @Param("trayNo") String trayNo); + + ShadowStockReference selectProjectOutboundReference( + @Param("stockoutDetailId") String stockoutDetailId); + + ShadowStockReference selectStockReference(@Param("stockId") String stockId); + + int insertComparison(@Param("decisionId") String decisionId, + @Param("warehouseId") String warehouseId, + @Param("decisionType") String decisionType, + @Param("resultCode") String resultCode, + @Param("reason") String reason); +} diff --git a/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml b/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml new file mode 100644 index 0000000..8975b4f --- /dev/null +++ b/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + insert into alg.decision_log ( + decision_id, warehouse_id, decision_type, input_version, result_code, reason) + values ( + try_convert(uniqueidentifier, #{decisionId}), #{warehouseId}, #{decisionType}, + null, #{resultCode}, #{reason}) + + diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowObservationGatewayTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowObservationGatewayTest.java new file mode 100644 index 0000000..b81fd01 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowObservationGatewayTest.java @@ -0,0 +1,49 @@ +package com.ruoyi.algorithm.shadow; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +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 ShadowObservationGatewayTest { + + @Test + void disabledShadowNeverInvokesThePlanningEngine() { + ShadowObservationProperties properties = new ShadowObservationProperties(); + ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class); + ShadowObservationGateway gateway = new ShadowObservationGateway(properties, engine); + + InboundShadowObservation observation = gateway.planInbound("TASK-1", "TRAY-1"); + + assertThat(observation.observed()).isFalse(); + verify(engine, never()).planInbound("TASK-1", "TRAY-1"); + } + + @Test + void planningFailureIsConvertedToAnEmptyObservation() { + ShadowObservationProperties properties = new ShadowObservationProperties(); + properties.setEnabled(true); + ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class); + when(engine.planInbound("TASK-2", "TRAY-2")) + .thenThrow(new IllegalStateException("algorithm objects are unavailable")); + ShadowObservationGateway gateway = new ShadowObservationGateway(properties, engine); + + InboundShadowObservation observation = gateway.planInbound("TASK-2", "TRAY-2"); + + assertThat(observation.observed()).isFalse(); + } + + @Test + void missingEngineFailsOpenForOutboundObservation() { + ShadowObservationProperties properties = new ShadowObservationProperties(); + properties.setEnabled(true); + ShadowObservationGateway gateway = new ShadowObservationGateway(properties, null); + + OutboundShadowObservation observation = gateway.planExactStockOutbound("DETAIL-1", "STOCK-1"); + + assertThat(observation.observed()).isFalse(); + } +} diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java new file mode 100644 index 0000000..c380e0d --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java @@ -0,0 +1,74 @@ +package com.ruoyi.algorithm.shadow; + +import com.ruoyi.algorithm.inbound.InboundAssignment; +import com.ruoyi.algorithm.inbound.InboundBorrowType; +import com.ruoyi.algorithm.inbound.InboundLadderRung; +import com.ruoyi.algorithm.inbound.InboundPlan; +import com.ruoyi.algorithm.integration.SchedulingFacade; +import com.ruoyi.algorithm.repository.VersionToken; +import com.ruoyi.algorithm.service.InboundPlanningResult; +import com.ruoyi.algorithm.shadow.mapper.ShadowInputMapper; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.SimpleTransactionStatus; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ShadowPlanningEngineTest { + + @Test + void inboundPlanningUsesRequiresNewAndConfiguredTimeout() { + ShadowObservationProperties properties = new ShadowObservationProperties(); + properties.setWarehouseId("W01"); + properties.setTimeoutSeconds(7); + CapturingTransactionManager transactions = new CapturingTransactionManager(); + ShadowInputMapper mapper = mock(ShadowInputMapper.class); + SchedulingFacade facade = mock(SchedulingFacade.class); + ShadowInboundReference input = new ShadowInboundReference(); + input.setRequestId("JOB-1"); + input.setTrayNo("TRAY-1"); + input.setProjectNo("PROJECT-1"); + input.setProductId("PRODUCT-1"); + input.setLotNo("LOT-1"); + when(mapper.selectInboundInput("TASK-1", "TRAY-1")).thenReturn(input); + InboundAssignment assignment = new InboundAssignment( + "JOB-1", "SPACE-9", "LANE-2", 4, 2, + InboundLadderRung.CONTINUE, InboundBorrowType.NONE, null, + 1.0, 2.0, 3.0, 50.0, false, false, "shadow proposal"); + when(facade.planInbound(eq("W01"), any())) + .thenReturn(new InboundPlanningResult(InboundPlan.assigned(assignment), VersionToken.numeric(8))); + ShadowPlanningEngine engine = new ShadowPlanningEngine(facade, mapper, properties, transactions); + + InboundShadowObservation observation = engine.planInbound("TASK-1", "TRAY-1"); + + assertThat(transactions.definition.getPropagationBehavior()) + .isEqualTo(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + assertThat(transactions.definition.getTimeout()).isEqualTo(7); + assertThat(observation.observed()).isTrue(); + assertThat(observation.proposedSpaceId()).isEqualTo("SPACE-9"); + } + + private static final class CapturingTransactionManager implements PlatformTransactionManager { + private TransactionDefinition definition; + + @Override + public TransactionStatus getTransaction(TransactionDefinition definition) { + this.definition = definition; + return new SimpleTransactionStatus(); + } + + @Override + public void commit(TransactionStatus status) { + } + + @Override + public void rollback(TransactionStatus status) { + } + } +} diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuardTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuardTest.java new file mode 100644 index 0000000..e9979d5 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuardTest.java @@ -0,0 +1,67 @@ +package com.ruoyi.algorithm.shadow; + +import com.ruoyi.algorithm.integration.AlgorithmProperties; +import com.ruoyi.algorithm.integration.SchedulingMode; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ShadowSafetyGuardTest { + + @Test + void enabledShadowAcceptsOnlyShadowModeWithReadModelWrites() { + ShadowObservationProperties shadow = enabledShadow(); + AlgorithmProperties algorithm = new AlgorithmProperties(); + algorithm.setMode(SchedulingMode.SHADOW); + algorithm.setDatabaseWritesEnabled(true); + + assertThatCode(() -> new ShadowSafetyGuard(shadow, algorithm).validate()) + .doesNotThrowAnyException(); + } + + @Test + void enabledShadowRejectsActiveAndLegacyModes() { + ShadowObservationProperties shadow = enabledShadow(); + AlgorithmProperties algorithm = new AlgorithmProperties(); + + algorithm.setMode(SchedulingMode.ACTIVE); + assertThatThrownBy(() -> new ShadowSafetyGuard(shadow, algorithm).validate()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("SHADOW"); + + algorithm.setMode(SchedulingMode.LEGACY); + assertThatThrownBy(() -> new ShadowSafetyGuard(shadow, algorithm).validate()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("SHADOW"); + } + + @Test + void enabledShadowRejectsDisabledReadModelWrites() { + ShadowObservationProperties shadow = enabledShadow(); + AlgorithmProperties algorithm = new AlgorithmProperties(); + algorithm.setMode(SchedulingMode.SHADOW); + algorithm.setDatabaseWritesEnabled(false); + + assertThatThrownBy(() -> new ShadowSafetyGuard(shadow, algorithm).validate()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("database writes"); + } + + @Test + void disabledShadowDoesNotRestrictTheStableLegacyApplication() { + ShadowObservationProperties shadow = new ShadowObservationProperties(); + AlgorithmProperties algorithm = new AlgorithmProperties(); + algorithm.setMode(SchedulingMode.ACTIVE); + algorithm.setDatabaseWritesEnabled(false); + + assertThatCode(() -> new ShadowSafetyGuard(shadow, algorithm).validate()) + .doesNotThrowAnyException(); + } + + private ShadowObservationProperties enabledShadow() { + ShadowObservationProperties properties = new ShadowObservationProperties(); + properties.setEnabled(true); + return properties; + } +}