feat: add fail-open shadow planning boundary
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.algorithm.shadow.mapper.ShadowInputMapper">
|
||||
|
||||
<select id="selectInboundInput" resultType="com.ruoyi.algorithm.shadow.ShadowInboundReference">
|
||||
select top (1)
|
||||
job.job_id as requestId,
|
||||
job.tray_code as trayNo,
|
||||
coalesce(nullif(ltrim(rtrim(detail.spare4)), ''),
|
||||
nullif(ltrim(rtrim(stock.spare4)), '')) as projectNo,
|
||||
jobDetail.product_id as productId,
|
||||
jobDetail.lot_no as lotNo
|
||||
from mydo_job job
|
||||
left join mydo_job_detail jobDetail on jobDetail.job_id = job.job_id
|
||||
left join in_stockin_detail detail on detail.stockin_detail_id = jobDetail.stockin_detail_id
|
||||
left join bas_stock stock on stock.stock_id = jobDetail.stock_id
|
||||
where job.task_no = #{taskNo}
|
||||
and job.tray_code = #{trayNo}
|
||||
order by jobDetail.job_detail_id
|
||||
</select>
|
||||
|
||||
<select id="selectProjectOutboundReference" resultType="com.ruoyi.algorithm.shadow.ShadowStockReference">
|
||||
select detail.stockout_detail_id as requestId,
|
||||
detail.warehouse_id as warehouseId,
|
||||
nullif(ltrim(rtrim(detail.spare4)), '') as projectNo,
|
||||
detail.product_id as productId
|
||||
from out_stockout_detail detail
|
||||
where detail.stockout_detail_id = #{stockoutDetailId}
|
||||
</select>
|
||||
|
||||
<select id="selectStockReference" resultType="com.ruoyi.algorithm.shadow.ShadowStockReference">
|
||||
select top (1)
|
||||
stock.stock_id as stockId,
|
||||
stock.tray_code as trayNo,
|
||||
stock.cargo_space_id as spaceId,
|
||||
stock.warehouse_id as warehouseId,
|
||||
nullif(ltrim(rtrim(stock.spare4)), '') as projectNo,
|
||||
stock.product_id as productId
|
||||
from bas_stock stock
|
||||
where stock.stock_id = #{stockId}
|
||||
and stock.stock_status = '1'
|
||||
and stock.product_status = '1'
|
||||
and stock.quantity > stock.out_quantity
|
||||
</select>
|
||||
|
||||
<insert id="insertComparison">
|
||||
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})
|
||||
</insert>
|
||||
</mapper>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user