feat: plan shadow decisions from leased generation

This commit is contained in:
zgw
2026-09-03 14:28:38 +08:00
parent 749bd49c50
commit 935d7af43d
9 changed files with 737 additions and 235 deletions

View File

@@ -2,18 +2,25 @@ package com.ruoyi.algorithm.shadow;
public record InboundShadowObservation(
boolean observed,
long snapshotGeneration,
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 InboundShadowObservation(boolean observed, String warehouseId, String requestId,
String resultCode, String proposedSpaceId, String reason) {
this(observed, 0L, warehouseId, requestId, resultCode, proposedSpaceId, reason);
}
public static InboundShadowObservation unavailable(String warehouseId, String requestId,
String resultCode, String reason) {
return new InboundShadowObservation(true, warehouseId, requestId, resultCode, null, reason);
public static InboundShadowObservation empty() {
return new InboundShadowObservation(false, 0L, null, null, "NOT_OBSERVED", null, null);
}
public static InboundShadowObservation unavailable(long snapshotGeneration, String warehouseId,
String requestId, String resultCode, String reason) {
return new InboundShadowObservation(true, snapshotGeneration, warehouseId, requestId,
resultCode, null, reason);
}
}

View File

@@ -2,6 +2,7 @@ package com.ruoyi.algorithm.shadow;
public record OutboundShadowObservation(
boolean observed,
long snapshotGeneration,
String warehouseId,
String requestId,
String resultCode,
@@ -9,12 +10,18 @@ public record OutboundShadowObservation(
String proposedSpaceId,
String reason) {
public static OutboundShadowObservation empty() {
return new OutboundShadowObservation(false, null, null, "NOT_OBSERVED", null, null, null);
public OutboundShadowObservation(boolean observed, String warehouseId, String requestId,
String resultCode, String proposedStockId, String proposedSpaceId, String reason) {
this(observed, 0L, warehouseId, requestId, resultCode, proposedStockId, proposedSpaceId, reason);
}
public static OutboundShadowObservation unavailable(String warehouseId, String requestId,
String resultCode, String reason) {
return new OutboundShadowObservation(true, warehouseId, requestId, resultCode, null, null, reason);
public static OutboundShadowObservation empty() {
return new OutboundShadowObservation(false, 0L, null, null, "NOT_OBSERVED", null, null, null);
}
public static OutboundShadowObservation unavailable(long snapshotGeneration, String warehouseId,
String requestId, String resultCode, String reason) {
return new OutboundShadowObservation(true, snapshotGeneration, warehouseId, requestId,
resultCode, null, null, reason);
}
}

View File

@@ -7,6 +7,7 @@ public class ShadowInboundReference {
private String projectNo;
private String productId;
private String lotNo;
private Integer shadowCandidateCount;
public String getRequestId() {
return requestId;
@@ -55,4 +56,12 @@ public class ShadowInboundReference {
public void setLotNo(String lotNo) {
this.lotNo = lotNo;
}
public Integer getShadowCandidateCount() {
return shadowCandidateCount;
}
public void setShadowCandidateCount(Integer shadowCandidateCount) {
this.shadowCandidateCount = shadowCandidateCount;
}
}

View File

@@ -12,12 +12,17 @@ import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Supplier;
@Component
public class ShadowPlanningEngine {
private static final int INSTANCE_STALE_SECONDS = 120;
private static final String PUBLISHED = "PUBLISHED";
private final SchedulingFacade schedulingFacade;
private final ShadowInputMapper inputMapper;
private final ShadowObservationProperties properties;
@@ -35,159 +40,313 @@ public class ShadowPlanningEngine {
}
public InboundShadowObservation planInbound(String taskNo, String trayNo) {
return transactions.execute(status -> planInboundInsideTransaction(taskNo, trayNo));
throw leasedSnapshotRequired();
}
public OutboundShadowObservation planProjectOutbound(String stockoutDetailId) {
return transactions.execute(status -> {
ShadowStockReference reference = inputMapper.selectProjectOutboundReference(stockoutDetailId);
String warehouseId = warehouseId(reference == null ? null : reference.getWarehouseId());
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.getRequestId(), reference,
schedulingFacade.planOutbound(warehouseId, request));
});
throw leasedSnapshotRequired();
}
public OutboundShadowObservation planExactStockOutbound(String stockoutDetailId, String stockId) {
throw leasedSnapshotRequired();
}
public PublishedShadowSnapshot refreshSnapshot(long generation, String instanceId) {
if (generation <= 0) {
throw new IllegalArgumentException("shadow snapshot generation must be positive");
}
if (isBlank(instanceId)) {
throw new IllegalArgumentException("shadow snapshot instanceId is required");
}
String warehouseId = configuredWarehouseId();
return transactions.execute(status -> {
ShadowStockReference reference = inputMapper.selectStockReference(stockId);
String warehouseId = warehouseId(reference == null ? null : reference.getWarehouseId());
if (!inputMapper.claimInstance(warehouseId, instanceId, INSTANCE_STALE_SECONDS)) {
throw new IllegalStateException("could not claim shadow snapshot refresh for warehouse " + warehouseId);
}
inputMapper.refreshSnapshot(warehouseId, generation, instanceId);
return toPublishedSnapshot(inputMapper.selectPublishedSnapshot(warehouseId), generation, warehouseId);
});
}
public InboundShadowObservation planInbound(PublishedShadowSnapshot snapshot,
String taskNo, String trayNo) {
return planWithStableSnapshot(snapshot,
() -> planInboundInsideTransaction(snapshot, taskNo, trayNo));
}
public OutboundShadowObservation planProjectOutbound(PublishedShadowSnapshot snapshot,
String stockoutDetailId) {
return planWithStableSnapshot(snapshot, () -> {
String warehouseId = snapshot.warehouseId();
ShadowStockReference reference = inputMapper.selectProjectOutboundReference(
warehouseId, snapshot.generation(), stockoutDetailId);
if (reference == null || isBlank(reference.getProjectNo())) {
return OutboundShadowObservation.unavailable(snapshot.generation(), warehouseId,
stockoutDetailId, "SKIPPED_MISSING_PROJECT",
outboundDiagnostic(snapshot, stockoutDetailId, reference,
"No project number was available for shadow planning"));
}
OutboundRequest request = new OutboundRequest(stockoutDetailId, null,
reference.getProjectNo(), reference.getProductId(), null, null, null, true);
return toOutboundObservation(snapshot, reference.getRequestId(), reference,
schedulingFacade.planOutboundFromPublishedSnapshot(warehouseId, request));
});
}
public OutboundShadowObservation planExactStockOutbound(PublishedShadowSnapshot snapshot,
String stockoutDetailId, String stockId) {
return planWithStableSnapshot(snapshot, () -> {
String warehouseId = snapshot.warehouseId();
ShadowStockReference reference = inputMapper.selectStockReference(
warehouseId, snapshot.generation(), stockId);
if (reference == null || isBlank(reference.getTrayNo())) {
return OutboundShadowObservation.unavailable(warehouseId, stockoutDetailId,
"SKIPPED_MISSING_STOCK", "Selected stock was unavailable to shadow planning");
return OutboundShadowObservation.unavailable(snapshot.generation(), warehouseId,
stockoutDetailId, "SKIPPED_MISSING_STOCK",
outboundDiagnostic(snapshot, stockoutDetailId, reference,
"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, stockoutDetailId, reference,
schedulingFacade.planOutbound(warehouseId, request));
return toOutboundObservation(snapshot, stockoutDetailId, reference,
schedulingFacade.planOutboundFromPublishedSnapshot(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())
+ "; shadowReason=" + nullSafe(observation.reason())
+ "; legacy=" + nullSafe(legacyResultCode)
+ "; legacySpace=" + nullSafe(legacySpaceId)));
Objects.requireNonNull(observation, "observation");
transactions.executeWithoutResult(status -> {
requirePublishedGeneration(observation.snapshotGeneration(), observation.warehouseId(),
"published snapshot changed before comparison");
inputMapper.insertComparison(
UUID.randomUUID().toString(), observation.warehouseId(), "INBOUND_COMPARE",
observation.snapshotGeneration(),
comparisonCode(observation.proposedSpaceId(), legacySpaceId),
"snapshotGeneration=" + observation.snapshotGeneration()
+ "; request=" + observation.requestId()
+ "; shadow=" + observation.resultCode()
+ "; proposedSpace=" + nullSafe(observation.proposedSpaceId())
+ "; shadowReason=" + nullSafe(observation.reason())
+ "; 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())
+ "; shadowReason=" + nullSafe(observation.reason())
+ "; legacy=" + nullSafe(legacyResultCode)));
Objects.requireNonNull(observation, "observation");
transactions.executeWithoutResult(status -> {
requirePublishedGeneration(observation.snapshotGeneration(), observation.warehouseId(),
"published snapshot changed before comparison");
inputMapper.insertComparison(
UUID.randomUUID().toString(), observation.warehouseId(), "OUTBOUND_COMPARE",
observation.snapshotGeneration(), "LEGACY_" + nullSafe(legacyResultCode),
"snapshotGeneration=" + observation.snapshotGeneration()
+ "; request=" + observation.requestId()
+ "; shadow=" + observation.resultCode()
+ "; proposedStock=" + nullSafe(observation.proposedStockId())
+ "; proposedSpace=" + nullSafe(observation.proposedSpaceId())
+ "; shadowReason=" + nullSafe(observation.reason())
+ "; legacy=" + nullSafe(legacyResultCode));
});
}
private InboundShadowObservation planInboundInsideTransaction(String taskNo, String trayNo) {
ShadowInboundReference reference = inputMapper.selectInboundInput(taskNo, trayNo);
String warehouseId = warehouseId(reference == null ? null : reference.getWarehouseId());
private InboundShadowObservation planInboundInsideTransaction(PublishedShadowSnapshot snapshot,
String taskNo, String trayNo) {
String warehouseId = snapshot.warehouseId();
ShadowInboundReference reference = inputMapper.selectInboundInput(
warehouseId, snapshot.generation(), 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");
return InboundShadowObservation.unavailable(snapshot.generation(), warehouseId, taskNo,
"SKIPPED_MISSING_INPUT",
inboundDiagnostic(snapshot, taskNo, trayNo, reference,
"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);
InboundPlanningResult result = schedulingFacade.planInboundFromPublishedSnapshot(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(), inboundDiagnostic(taskNo, trayNo, reference, assignment.reason()));
return new InboundShadowObservation(true, snapshot.generation(), warehouseId,
reference.getRequestId(), "READY", assignment.spaceId(),
inboundDiagnostic(snapshot, taskNo, trayNo, reference, assignment.reason()));
}
var rejection = plan.rejection().orElseThrow();
return InboundShadowObservation.unavailable(warehouseId, reference.getRequestId(),
"REJECTED_" + rejection.code().name(),
inboundDiagnostic(taskNo, trayNo, reference, rejection.reason()));
return InboundShadowObservation.unavailable(snapshot.generation(), warehouseId,
reference.getRequestId(), "REJECTED_" + rejection.code().name(),
inboundDiagnostic(snapshot, taskNo, trayNo, reference, rejection.reason()));
}
private OutboundShadowObservation toOutboundObservation(String warehouseId, String requestId,
ShadowStockReference reference, OutboundPlan plan) {
private OutboundShadowObservation toOutboundObservation(PublishedShadowSnapshot snapshot,
String requestId, ShadowStockReference reference, OutboundPlan plan) {
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(),
outboundDiagnostic(requestId, reference, assignment.reason()));
return new OutboundShadowObservation(true, snapshot.generation(), snapshot.warehouseId(),
requestId, "READY", reference.getStockId(), assignment.sourceSpaceId(),
outboundDiagnostic(snapshot, requestId, reference, assignment.reason()));
}
if (plan.waiting().isPresent()) {
var waiting = plan.waiting().orElseThrow();
return OutboundShadowObservation.unavailable(warehouseId, requestId,
"WAITING_" + waiting.code().name(), outboundDiagnostic(requestId, reference, waiting.reason()));
return OutboundShadowObservation.unavailable(snapshot.generation(), snapshot.warehouseId(),
requestId, "WAITING_" + waiting.code().name(),
outboundDiagnostic(snapshot, requestId, reference, waiting.reason()));
}
var error = plan.error().orElseThrow();
return OutboundShadowObservation.unavailable(warehouseId, requestId,
"ERROR_" + error.code().name(), outboundDiagnostic(requestId, reference, error.message()));
return OutboundShadowObservation.unavailable(snapshot.generation(), snapshot.warehouseId(),
requestId, "ERROR_" + error.code().name(),
outboundDiagnostic(snapshot, requestId, reference, error.message()));
}
private String warehouseId(String preferredWarehouseId) {
String configuredWarehouseId = isBlank(properties.getWarehouseId())
private <T> T planWithStableSnapshot(PublishedShadowSnapshot snapshot, Supplier<T> planner) {
validateLeasedSnapshot(snapshot);
return transactions.execute(status -> {
requirePublishedSnapshot(snapshot, "published snapshot changed during planning");
T result = planner.get();
requirePublishedSnapshot(snapshot, "published snapshot changed during planning");
return result;
});
}
private void validateLeasedSnapshot(PublishedShadowSnapshot snapshot) {
Objects.requireNonNull(snapshot, "snapshot");
if (snapshot.generation() <= 0) {
throw new IllegalArgumentException("leased shadow snapshot generation must be positive");
}
if (isBlank(snapshot.warehouseId())) {
throw new IllegalArgumentException("leased shadow snapshot warehouse is required");
}
if (!WarehouseIdNormalizer.equivalent(snapshot.warehouseId(), configuredWarehouseId())) {
throw new IllegalArgumentException("leased shadow snapshot warehouse does not match configured warehouse");
}
requireUppercaseHexSignature(snapshot.sourceSignature(), "leased shadow snapshot source signature");
}
private PublishedShadowSnapshot toPublishedSnapshot(ShadowSnapshotDescriptor descriptor,
long expectedGeneration, String expectedWarehouseId) {
if (descriptor == null) {
throw new IllegalStateException("published snapshot descriptor is missing");
}
if (!PUBLISHED.equals(descriptor.getSnapshotStatus())) {
throw new IllegalStateException("shadow snapshot descriptor must be PUBLISHED");
}
if (descriptor.getSnapshotGeneration() != expectedGeneration) {
throw new IllegalStateException("published snapshot generation does not match refreshed generation");
}
if (!WarehouseIdNormalizer.equivalent(descriptor.getWarehouseId(), expectedWarehouseId)) {
throw new IllegalStateException("published snapshot warehouse does not match refreshed warehouse");
}
if (descriptor.getSynchronizedAt() == null) {
throw new IllegalStateException("published snapshot synchronization time is missing");
}
requireUppercaseHexSignature(descriptor.getSourceSignature(), "published snapshot source signature");
return new PublishedShadowSnapshot(descriptor.getSnapshotGeneration(), descriptor.getWarehouseId(),
descriptor.getSynchronizedAt(), descriptor.getSourceSignature());
}
private void requirePublishedSnapshot(PublishedShadowSnapshot snapshot, String message) {
if (!matches(snapshot, inputMapper.selectPublishedSnapshot(snapshot.warehouseId()))) {
throw new IllegalStateException(message);
}
}
private void requirePublishedGeneration(long generation, String warehouseId, String message) {
if (generation <= 0 || isBlank(warehouseId)) {
throw new IllegalStateException(message);
}
ShadowSnapshotDescriptor descriptor = inputMapper.selectPublishedSnapshot(warehouseId);
if (descriptor == null
|| !PUBLISHED.equals(descriptor.getSnapshotStatus())
|| descriptor.getSnapshotGeneration() != generation
|| !WarehouseIdNormalizer.equivalent(descriptor.getWarehouseId(), warehouseId)) {
throw new IllegalStateException(message);
}
}
private boolean matches(PublishedShadowSnapshot snapshot, ShadowSnapshotDescriptor descriptor) {
return descriptor != null
&& PUBLISHED.equals(descriptor.getSnapshotStatus())
&& descriptor.getSnapshotGeneration() == snapshot.generation()
&& WarehouseIdNormalizer.equivalent(descriptor.getWarehouseId(), snapshot.warehouseId())
&& Objects.equals(descriptor.getSynchronizedAt(), snapshot.synchronizedAt())
&& Objects.equals(descriptor.getSourceSignature(), snapshot.sourceSignature());
}
private String configuredWarehouseId() {
String warehouseId = isBlank(properties.getWarehouseId())
? schedulingFacade.defaultWarehouseId()
: properties.getWarehouseId();
if (!isBlank(preferredWarehouseId)) {
String normalizedPreferred = preferredWarehouseId.trim();
if (sameNumericWarehouse(normalizedPreferred, configuredWarehouseId)) {
return configuredWarehouseId;
}
return normalizedPreferred;
if (isBlank(warehouseId)) {
throw new IllegalStateException("shadow warehouse is not configured");
}
return configuredWarehouseId;
return warehouseId.trim();
}
private boolean sameNumericWarehouse(String first, String second) {
if (isBlank(first) || isBlank(second)) {
return false;
}
try {
return Integer.parseInt(first) == Integer.parseInt(second);
} catch (NumberFormatException ignored) {
return false;
}
private IllegalStateException leasedSnapshotRequired() {
return new IllegalStateException("published shadow snapshot lease is required");
}
private String outboundDiagnostic(String requestId, ShadowStockReference reference, String resultReason) {
return "detail=" + nullSafe(requestId)
+ "; project=" + nullSafe(reference.getProjectNo())
+ "; product=" + nullSafe(reference.getProductId())
+ "; tray=" + nullSafe(reference.getTrayNo())
+ "; stock=" + nullSafe(reference.getStockId())
+ "; snapshotSync=" + nullSafe(reference.getSnapshotSynchronizedAt())
+ "; legacyMatches=" + numberOrUnknown(reference.getLiveMatchCount())
+ "; shadowMatches=" + numberOrUnknown(reference.getShadowMatchCount())
private String outboundDiagnostic(PublishedShadowSnapshot snapshot, String requestId,
ShadowStockReference reference, String resultReason) {
return snapshotDiagnostic(snapshot)
+ "; detail=" + nullSafe(requestId)
+ "; rawWarehouse=" + nullSafe(reference == null ? null : reference.getWarehouseId())
+ "; project=" + nullSafe(reference == null ? null : reference.getProjectNo())
+ "; product=" + nullSafe(reference == null ? null : reference.getProductId())
+ "; tray=" + nullSafe(reference == null ? null : reference.getTrayNo())
+ "; stock=" + nullSafe(reference == null ? null : reference.getStockId())
+ "; space=" + nullSafe(reference == null ? null : reference.getSpaceId())
+ "; requestedQuantity=" + objectOrEmpty(
reference == null ? null : reference.getRequestedQuantity())
+ "; inputSnapshotSync=" + nullSafe(
reference == null ? null : reference.getSnapshotSynchronizedAt())
+ "; shadowMatches=" + numberOrUnknown(
reference == null ? null : reference.getShadowMatchCount())
+ "; reason=" + abbreviate(nullSafe(resultReason), 600);
}
private String inboundDiagnostic(String taskNo, String trayNo, ShadowInboundReference reference,
String resultReason) {
return "task=" + nullSafe(taskNo)
private String inboundDiagnostic(PublishedShadowSnapshot snapshot, String taskNo, String trayNo,
ShadowInboundReference reference, String resultReason) {
return snapshotDiagnostic(snapshot)
+ "; task=" + nullSafe(taskNo)
+ "; tray=" + nullSafe(trayNo)
+ "; request=" + nullSafe(reference.getRequestId())
+ "; warehouse=" + nullSafe(reference.getWarehouseId())
+ "; project=" + nullSafe(reference.getProjectNo())
+ "; product=" + nullSafe(reference.getProductId())
+ "; lot=" + nullSafe(reference.getLotNo())
+ "; request=" + nullSafe(reference == null ? null : reference.getRequestId())
+ "; rawWarehouse=" + nullSafe(reference == null ? null : reference.getWarehouseId())
+ "; project=" + nullSafe(reference == null ? null : reference.getProjectNo())
+ "; product=" + nullSafe(reference == null ? null : reference.getProductId())
+ "; lot=" + nullSafe(reference == null ? null : reference.getLotNo())
+ "; shadowCandidates=" + numberOrUnknown(
reference == null ? null : reference.getShadowCandidateCount())
+ "; reason=" + abbreviate(nullSafe(resultReason), 600);
}
private String snapshotDiagnostic(PublishedShadowSnapshot snapshot) {
return "snapshotGeneration=" + snapshot.generation()
+ "; snapshotSync=" + instantOrEmpty(snapshot.synchronizedAt())
+ "; sourceSignature=" + nullSafe(snapshot.sourceSignature());
}
private void requireUppercaseHexSignature(String value, String description) {
if (isBlank(value) || !value.matches("[0-9A-F]+")) {
throw new IllegalStateException(description + " must be uppercase hexadecimal");
}
}
private String numberOrUnknown(Integer value) {
return value == null ? "" : value.toString();
}
private String objectOrEmpty(Object value) {
return value == null ? "" : value.toString();
}
private String instantOrEmpty(Instant value) {
return value == null ? "" : value.toString();
}
private String abbreviate(String value, int maxLength) {
return value.length() <= maxLength ? value : value.substring(0, maxLength);
}

View File

@@ -0,0 +1,51 @@
package com.ruoyi.algorithm.shadow;
import java.time.Instant;
public class ShadowSnapshotDescriptor {
private long snapshotGeneration;
private String warehouseId;
private String snapshotStatus;
private Instant synchronizedAt;
private String sourceSignature;
public long getSnapshotGeneration() {
return snapshotGeneration;
}
public void setSnapshotGeneration(long snapshotGeneration) {
this.snapshotGeneration = snapshotGeneration;
}
public String getWarehouseId() {
return warehouseId;
}
public void setWarehouseId(String warehouseId) {
this.warehouseId = warehouseId;
}
public String getSnapshotStatus() {
return snapshotStatus;
}
public void setSnapshotStatus(String snapshotStatus) {
this.snapshotStatus = snapshotStatus;
}
public Instant getSynchronizedAt() {
return synchronizedAt;
}
public void setSynchronizedAt(Instant synchronizedAt) {
this.synchronizedAt = synchronizedAt;
}
public String getSourceSignature() {
return sourceSignature;
}
public void setSourceSignature(String sourceSignature) {
this.sourceSignature = sourceSignature;
}
}

View File

@@ -1,5 +1,7 @@
package com.ruoyi.algorithm.shadow;
import java.math.BigDecimal;
public class ShadowStockReference {
private String requestId;
private String stockId;
@@ -9,7 +11,7 @@ public class ShadowStockReference {
private String projectNo;
private String productId;
private String snapshotSynchronizedAt;
private Integer liveMatchCount;
private BigDecimal requestedQuantity;
private Integer shadowMatchCount;
public String getRequestId() {
@@ -76,12 +78,12 @@ public class ShadowStockReference {
this.snapshotSynchronizedAt = snapshotSynchronizedAt;
}
public Integer getLiveMatchCount() {
return liveMatchCount;
public BigDecimal getRequestedQuantity() {
return requestedQuantity;
}
public void setLiveMatchCount(Integer liveMatchCount) {
this.liveMatchCount = liveMatchCount;
public void setRequestedQuantity(BigDecimal requestedQuantity) {
this.requestedQuantity = requestedQuantity;
}
public Integer getShadowMatchCount() {

View File

@@ -1,23 +1,36 @@
package com.ruoyi.algorithm.shadow.mapper;
import com.ruoyi.algorithm.shadow.ShadowInboundReference;
import com.ruoyi.algorithm.shadow.ShadowSnapshotDescriptor;
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,
ShadowInboundReference selectInboundInput(@Param("warehouseId") String warehouseId,
@Param("generation") long generation, @Param("taskNo") String taskNo,
@Param("trayNo") String trayNo);
ShadowStockReference selectProjectOutboundReference(
ShadowStockReference selectProjectOutboundReference(@Param("warehouseId") String warehouseId,
@Param("generation") long generation,
@Param("stockoutDetailId") String stockoutDetailId);
ShadowStockReference selectStockReference(@Param("stockId") String stockId);
ShadowStockReference selectStockReference(@Param("warehouseId") String warehouseId,
@Param("generation") long generation, @Param("stockId") String stockId);
boolean claimInstance(@Param("warehouseId") String warehouseId,
@Param("instanceId") String instanceId, @Param("staleSeconds") int staleSeconds);
void refreshSnapshot(@Param("warehouseId") String warehouseId,
@Param("generation") long generation, @Param("instanceId") String instanceId);
ShadowSnapshotDescriptor selectPublishedSnapshot(@Param("warehouseId") String warehouseId);
int insertComparison(@Param("decisionId") String decisionId,
@Param("warehouseId") String warehouseId,
@Param("decisionType") String decisionType,
@Param("inputVersion") long inputVersion,
@Param("resultCode") String resultCode,
@Param("reason") String reason);
}

View File

@@ -6,77 +6,98 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectInboundInput" resultType="com.ruoyi.algorithm.shadow.ShadowInboundReference">
select top (1)
job.job_id as requestId,
job.warehouse_id as warehouseId,
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
input.request_id as requestId,
input.raw_warehouse_id as warehouseId,
input.tray_no as trayNo,
input.project_no as projectNo,
input.product_id as productId,
input.lot_no as lotNo,
convert(int, coalesce((
select sum(convert(bigint, lane.available_count))
from alg.lane_state lane
where lane.warehouse_id = #{warehouseId}
and lane.snapshot_generation = #{generation}
), 0)) as shadowCandidateCount
from alg.shadow_inbound_request_snapshot input
where input.warehouse_id = #{warehouseId}
and input.snapshot_generation = #{generation}
and input.task_no = #{taskNo}
and input.tray_no = #{trayNo}
</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,
convert(varchar(40), (select max(snapshot.synchronized_at)
from alg.inventory_item_snapshot snapshot
where snapshot.warehouse_id = detail.warehouse_id), 126)
as snapshotSynchronizedAt,
<select id="selectProjectOutboundReference"
resultType="com.ruoyi.algorithm.shadow.ShadowStockReference">
select top (1)
input.stockout_detail_id as requestId,
input.raw_warehouse_id as warehouseId,
input.project_no as projectNo,
input.product_id as productId,
input.requested_quantity as requestedQuantity,
convert(varchar(40), input.synchronized_at, 126) as snapshotSynchronizedAt,
(select count(*)
from bas_stock live
where live.warehouse_id = detail.warehouse_id
and live.stock_status = '1'
and live.product_status = '1'
and live.quantity &gt; live.out_quantity
and (NULLIF(LTRIM(RTRIM(detail.product_id)), '') IS NULL
OR live.product_id = NULLIF(LTRIM(RTRIM(detail.product_id)), ''))) as liveMatchCount,
(select count(*)
from alg.inventory_item_snapshot snapshot
where snapshot.warehouse_id = detail.warehouse_id
and snapshot.project_no = nullif(ltrim(rtrim(detail.spare4)), '')
and (NULLIF(LTRIM(RTRIM(detail.product_id)), '') IS NULL
OR snapshot.product_no = NULLIF(LTRIM(RTRIM(detail.product_id)), '')))
from alg.inventory_item_snapshot item
where item.warehouse_id = #{warehouseId}
and item.snapshot_generation = #{generation}
and item.project_no = nullif(ltrim(rtrim(input.project_no)), '')
and (nullif(ltrim(rtrim(input.product_id)), '') is null
or item.product_no = nullif(ltrim(rtrim(input.product_id)), ''))
and isnull(item.quantity, 0) &gt; isnull(item.out_quantity, 0))
as shadowMatchCount
from out_stockout_detail detail
where detail.stockout_detail_id = #{stockoutDetailId}
from alg.shadow_outbound_request_snapshot input
where input.warehouse_id = #{warehouseId}
and input.snapshot_generation = #{generation}
and input.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,
convert(varchar(40), (select max(snapshot.synchronized_at)
from alg.inventory_item_snapshot snapshot
where snapshot.warehouse_id = stock.warehouse_id), 126)
as snapshotSynchronizedAt,
item.stock_id as stockId,
item.tray_code as trayNo,
item.space_id as spaceId,
item.warehouse_id as warehouseId,
nullif(ltrim(rtrim(item.project_no)), '') as projectNo,
item.product_no as productId,
convert(varchar(40), item.synchronized_at, 126) as snapshotSynchronizedAt,
(select count(*)
from bas_stock live
where live.stock_id = stock.stock_id
and live.stock_status = '1'
and live.product_status = '1'
and live.quantity &gt; live.out_quantity) as liveMatchCount,
(select count(*)
from alg.inventory_item_snapshot snapshot
where snapshot.stock_id = stock.stock_id
and snapshot.warehouse_id = stock.warehouse_id) as shadowMatchCount
from bas_stock stock
where stock.stock_id = #{stockId}
and stock.stock_status = '1'
and stock.product_status = '1'
and stock.quantity &gt; stock.out_quantity
from alg.inventory_item_snapshot candidate
where candidate.warehouse_id = #{warehouseId}
and candidate.snapshot_generation = #{generation}
and candidate.stock_id = #{stockId}
and isnull(candidate.quantity, 0) &gt; isnull(candidate.out_quantity, 0))
as shadowMatchCount
from alg.inventory_item_snapshot item
where item.warehouse_id = #{warehouseId}
and item.snapshot_generation = #{generation}
and item.stock_id = #{stockId}
and isnull(item.quantity, 0) &gt; isnull(item.out_quantity, 0)
</select>
<select id="claimInstance" resultType="boolean">
if convert(int, #{staleSeconds}) &lt;&gt; 120
throw 51026, 'shadow instance stale threshold must be 120 seconds', 1;
declare @claimed bit;
exec alg.usp_claim_shadow_instance
@warehouse_id = #{warehouseId},
@instance_id = #{instanceId},
@claimed = @claimed output;
</select>
<update id="refreshSnapshot">
exec alg.usp_refresh_shadow_snapshot
@warehouse_id = #{warehouseId},
@snapshot_generation = #{generation},
@instance_id = #{instanceId};
</update>
<select id="selectPublishedSnapshot"
resultType="com.ruoyi.algorithm.shadow.ShadowSnapshotDescriptor">
select state.snapshot_generation as snapshotGeneration,
state.warehouse_id as warehouseId,
state.snapshot_status as snapshotStatus,
state.synchronized_at as synchronizedAt,
convert(varchar(64), state.source_signature, 2) as sourceSignature
from alg.shadow_snapshot_state state
where state.warehouse_id = #{warehouseId}
</select>
<insert id="insertComparison">
@@ -84,6 +105,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
decision_id, warehouse_id, decision_type, input_version, result_code, reason)
values (
try_convert(uniqueidentifier, #{decisionId}), #{warehouseId}, #{decisionType},
null, #{resultCode}, #{reason})
convert(binary(8), #{inputVersion}), #{resultCode}, #{reason})
</insert>
</mapper>

View File

@@ -5,9 +5,10 @@ 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.outbound.FifoDecision;
import com.ruoyi.algorithm.outbound.OutboundAssignment;
import com.ruoyi.algorithm.outbound.OutboundPlan;
import com.ruoyi.algorithm.outbound.FifoDecision;
import com.ruoyi.algorithm.outbound.TaskCountEstimate;
import com.ruoyi.algorithm.repository.VersionToken;
import com.ruoyi.algorithm.service.InboundPlanningResult;
import com.ruoyi.algorithm.shadow.mapper.ShadowInputMapper;
@@ -16,42 +17,47 @@ import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.SimpleTransactionStatus;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.InputStream;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
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 ShadowPlanningEngineTest {
private static final PublishedShadowSnapshot SNAPSHOT = new PublishedShadowSnapshot(
42L, "01", Instant.parse("2026-09-03T00:00:00Z"), "ABC123");
@Test
void inboundPlanningUsesRequiresNewAndConfiguredTimeout() {
ShadowObservationProperties properties = new ShadowObservationProperties();
properties.setWarehouseId("FALLBACK");
void inboundPlanningUsesLeasedGenerationPublishedFacadeAndConfiguredTransaction() {
ShadowObservationProperties properties = properties();
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.setWarehouseId("W01");
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)));
ShadowInboundReference input = inboundReference("W02");
input.setShadowCandidateCount(3);
when(mapper.selectInboundInput("01", 42L, "TASK-1", "TRAY-1")).thenReturn(input);
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT));
when(facade.planInboundFromPublishedSnapshot(eq("01"), any()))
.thenReturn(new InboundPlanningResult(InboundPlan.assigned(inboundAssignment()),
VersionToken.numeric(8)));
ShadowPlanningEngine engine = new ShadowPlanningEngine(facade, mapper, properties, transactions);
InboundShadowObservation observation = engine.planInbound("TASK-1", "TRAY-1");
InboundShadowObservation observation = engine.planInbound(SNAPSHOT, "TASK-1", "TRAY-1");
assertThat(transactions.definition.getPropagationBehavior())
.isEqualTo(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
@@ -59,70 +65,297 @@ class ShadowPlanningEngineTest {
.isEqualTo(TransactionDefinition.ISOLATION_READ_UNCOMMITTED);
assertThat(transactions.definition.getTimeout()).isEqualTo(7);
assertThat(observation.observed()).isTrue();
assertThat(observation.snapshotGeneration()).isEqualTo(42L);
assertThat(observation.warehouseId()).isEqualTo("01");
assertThat(observation.proposedSpaceId()).isEqualTo("SPACE-9");
assertThat(observation.reason())
.contains("snapshotGeneration=42", "snapshotSync=2026-09-03T00:00:00Z",
"sourceSignature=ABC123", "task=TASK-1", "tray=TRAY-1",
"request=JOB-1", "rawWarehouse=W02", "project=PROJECT-1",
"product=PRODUCT-1", "lot=LOT-1", "shadowCandidates=3");
verify(mapper).selectInboundInput("01", 42L, "TASK-1", "TRAY-1");
verify(facade).planInboundFromPublishedSnapshot(eq("01"), any());
verify(facade, never()).planInbound(any(), any());
}
@Test
void outboundPlanningUsesReferenceWarehouseAndKeepsMatchDiagnostics() {
ShadowObservationProperties properties = new ShadowObservationProperties();
properties.setWarehouseId("FALLBACK");
CapturingTransactionManager transactions = new CapturingTransactionManager();
void projectOutboundKeepsRawWarehouseDiagnosticButPlansForPublishedWarehouse() {
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
SchedulingFacade facade = mock(SchedulingFacade.class);
ShadowStockReference reference = projectReference("W02");
when(mapper.selectProjectOutboundReference("01", 42L, "DETAIL-1")).thenReturn(reference);
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT));
when(facade.planOutboundFromPublishedSnapshot(eq("01"), any()))
.thenReturn(OutboundPlan.ready(outboundAssignment(), TaskCountEstimate.zero()));
ShadowPlanningEngine engine = engine(facade, mapper);
OutboundShadowObservation observation = engine.planProjectOutbound(SNAPSHOT, "DETAIL-1");
assertThat(observation.snapshotGeneration()).isEqualTo(42L);
assertThat(observation.warehouseId()).isEqualTo("01");
assertThat(observation.requestId()).isEqualTo("DETAIL-1");
assertThat(observation.proposedSpaceId()).isEqualTo("SPACE-9");
assertThat(observation.reason())
.contains("snapshotGeneration=42", "snapshotSync=2026-09-03T00:00:00Z",
"sourceSignature=ABC123", "detail=DETAIL-1", "rawWarehouse=W02",
"project=PROJECT-1", "product=PRODUCT-1", "shadowMatches=1");
verify(mapper).selectProjectOutboundReference("01", 42L, "DETAIL-1");
verify(facade).planOutboundFromPublishedSnapshot(eq("01"), any());
verify(facade, never()).planOutbound(any(), any());
}
@Test
void exactStockLookupUsesPublishedWarehouseAndGenerationWhenRawWarehouseIsUnpadded() {
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
SchedulingFacade facade = mock(SchedulingFacade.class);
ShadowStockReference reference = stockReference("1");
when(mapper.selectStockReference("01", 42L, "STOCK-1")).thenReturn(reference);
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT));
when(facade.planOutboundFromPublishedSnapshot(eq("01"), any()))
.thenReturn(OutboundPlan.ready(outboundAssignment(), TaskCountEstimate.zero()));
ShadowPlanningEngine engine = engine(facade, mapper);
OutboundShadowObservation observation =
engine.planExactStockOutbound(SNAPSHOT, "DETAIL-1", "STOCK-1");
assertThat(observation.snapshotGeneration()).isEqualTo(42L);
assertThat(observation.warehouseId()).isEqualTo("01");
assertThat(observation.reason()).contains("rawWarehouse=1", "stock=STOCK-1");
verify(mapper).selectStockReference("01", 42L, "STOCK-1");
verify(facade).planOutboundFromPublishedSnapshot(eq("01"), any());
}
@Test
void planningRejectsObservationWhenPublishedGenerationChangesDuringWork() {
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
SchedulingFacade facade = mock(SchedulingFacade.class);
when(mapper.selectInboundInput("01", 42L, "TASK-1", "TRAY-1"))
.thenReturn(inboundReference("01"));
when(mapper.selectPublishedSnapshot("01"))
.thenReturn(descriptor(SNAPSHOT), descriptor(snapshot(43L, "01")));
when(facade.planInboundFromPublishedSnapshot(eq("01"), any()))
.thenReturn(new InboundPlanningResult(InboundPlan.assigned(inboundAssignment()),
VersionToken.numeric(8)));
ShadowPlanningEngine engine = engine(facade, mapper);
assertThatThrownBy(() -> engine.planInbound(SNAPSHOT, "TASK-1", "TRAY-1"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("published snapshot changed during planning");
verify(mapper, never()).insertComparison(any(), any(), any(), any(Long.class), any(), any());
}
@Test
void missingPlanningInputStillVerifiesDescriptorAfterLookup() {
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
SchedulingFacade facade = mock(SchedulingFacade.class);
when(mapper.selectPublishedSnapshot("01"))
.thenReturn(descriptor(SNAPSHOT), descriptor(snapshot(43L, "01")));
ShadowPlanningEngine engine = engine(facade, mapper);
assertThatThrownBy(() -> engine.planProjectOutbound(SNAPSHOT, "MISSING"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("published snapshot changed during planning");
}
@Test
void comparisonPersistsLeasedGenerationAsInputVersion() {
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT));
ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper);
InboundShadowObservation observation = new InboundShadowObservation(
true, 42L, "01", "JOB-1", "READY", "SPACE-9", "shadow diagnostic");
engine.compareInbound(observation, "READY", "SPACE-9");
verify(mapper).insertComparison(any(), eq("01"), eq("INBOUND_COMPARE"), eq(42L),
eq("MATCH"), argThat(reason -> reason.contains("request=JOB-1")));
}
@Test
void comparisonDoesNotPersistAfterPublishedGenerationChanges() {
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
when(mapper.selectPublishedSnapshot("01"))
.thenReturn(descriptor(snapshot(43L, "01")));
ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper);
InboundShadowObservation observation = new InboundShadowObservation(
true, 42L, "01", "JOB-1", "READY", "SPACE-9", "shadow diagnostic");
assertThatThrownBy(() -> engine.compareInbound(observation, "READY", "SPACE-9"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("published snapshot changed before comparison");
verify(mapper, never()).insertComparison(any(), any(), any(), any(Long.class), any(), any());
}
@Test
void refreshClaimsInstanceAndReturnsValidatedPublishedSnapshot() {
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
when(mapper.claimInstance("01", "INSTANCE-1", 120)).thenReturn(true);
PublishedShadowSnapshot refreshedSnapshot = snapshot(43L, "01");
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(refreshedSnapshot));
ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper);
PublishedShadowSnapshot refreshed = engine.refreshSnapshot(43L, "INSTANCE-1");
verify(mapper).claimInstance("01", "INSTANCE-1", 120);
verify(mapper).refreshSnapshot("01", 43L, "INSTANCE-1");
assertThat(refreshed).isEqualTo(refreshedSnapshot);
}
@Test
void refreshRejectsUnclaimedInstanceBeforeCallingRefreshProcedure() {
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
when(mapper.claimInstance("01", "INSTANCE-2", 120)).thenReturn(false);
ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper);
assertThatThrownBy(() -> engine.refreshSnapshot(43L, "INSTANCE-2"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("could not claim shadow snapshot refresh");
verify(mapper, never()).refreshSnapshot(any(), any(Long.class), any());
}
@Test
void refreshRejectsInvalidPublishedDescriptors() {
assertInvalidRefreshDescriptor(null, "missing");
assertInvalidRefreshDescriptor(descriptor(snapshot(43L, "01"), "REFRESHING"), "PUBLISHED");
assertInvalidRefreshDescriptor(descriptor(snapshot(44L, "01")), "generation");
assertInvalidRefreshDescriptor(descriptor(snapshot(43L, "W02")), "warehouse");
}
@Test
void planningMapperSqlUsesOnlyGenerationQualifiedShadowReadModels() throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
Document document;
try (InputStream stream = requireResource("mapper/algorithm/ShadowInputMapper.xml")) {
document = factory.newDocumentBuilder().parse(stream);
}
Map<String, String> statements = statementsById(document);
for (String id : new String[]{"selectInboundInput", "selectProjectOutboundReference",
"selectStockReference"}) {
assertThat(statements.get(id)).as(id)
.contains("#{warehouseid}", "#{generation}")
.doesNotContain("dbo.", "from bas_stock", "from mydo_job",
"from out_stockout_detail");
}
assertThat(statements.get("selectInboundInput"))
.contains("alg.shadow_inbound_request_snapshot", "alg.lane_state");
assertThat(statements.get("selectProjectOutboundReference"))
.contains("alg.shadow_outbound_request_snapshot", "alg.inventory_item_snapshot");
assertThat(statements.get("selectStockReference"))
.contains("alg.inventory_item_snapshot", "stock_id = #{stockid}");
assertThat(statements.get("insertComparison"))
.contains("convert(binary(8), #{inputversion})");
assertThat(statements.get("selectPublishedSnapshot"))
.contains("alg.shadow_snapshot_state", "convert(varchar(64),",
"source_signature", ", 2)");
}
private void assertInvalidRefreshDescriptor(ShadowSnapshotDescriptor descriptor, String message) {
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
when(mapper.claimInstance("01", "INSTANCE-1", 120)).thenReturn(true);
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor);
ShadowPlanningEngine engine = engine(mock(SchedulingFacade.class), mapper);
assertThatThrownBy(() -> engine.refreshSnapshot(43L, "INSTANCE-1"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining(message);
}
private static ShadowPlanningEngine engine(SchedulingFacade facade, ShadowInputMapper mapper) {
return new ShadowPlanningEngine(facade, mapper, properties(), new CapturingTransactionManager());
}
private static ShadowObservationProperties properties() {
ShadowObservationProperties properties = new ShadowObservationProperties();
properties.setWarehouseId("01");
return properties;
}
private static ShadowInboundReference inboundReference(String rawWarehouse) {
ShadowInboundReference input = new ShadowInboundReference();
input.setRequestId("JOB-1");
input.setWarehouseId(rawWarehouse);
input.setTrayNo("TRAY-1");
input.setProjectNo("PROJECT-1");
input.setProductId("PRODUCT-1");
input.setLotNo("LOT-1");
return input;
}
private static ShadowStockReference projectReference(String rawWarehouse) {
ShadowStockReference reference = new ShadowStockReference();
reference.setRequestId("DETAIL-1");
reference.setWarehouseId("W02");
reference.setWarehouseId(rawWarehouse);
reference.setProjectNo("PROJECT-1");
reference.setProductId("PRODUCT-1");
reference.setSnapshotSynchronizedAt("2026-08-28T02:53:45.133Z");
reference.setLiveMatchCount(2);
reference.setSnapshotSynchronizedAt("2026-09-03T00:00:00Z");
reference.setShadowMatchCount(1);
when(mapper.selectProjectOutboundReference("DETAIL-1")).thenReturn(reference);
OutboundAssignment assignment = new OutboundAssignment(
return reference;
}
private static ShadowStockReference stockReference(String rawWarehouse) {
ShadowStockReference reference = projectReference(rawWarehouse);
reference.setStockId("STOCK-1");
reference.setTrayNo("TRAY-1");
reference.setSpaceId("SPACE-9");
return reference;
}
private static InboundAssignment inboundAssignment() {
return 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");
}
private static OutboundAssignment outboundAssignment() {
return new OutboundAssignment(
"DETAIL-1", "TRAY-1", "PROJECT-1", "PRODUCT-1", "SPACE-9", 1,
true, java.util.List.of(), java.util.List.of(), false,
1.0, 2.0, 3.0, FifoDecision.off(), java.util.Optional.empty(),
"shadow proposal");
when(facade.planOutbound(eq("W02"), any()))
.thenReturn(OutboundPlan.ready(assignment, com.ruoyi.algorithm.outbound.TaskCountEstimate.zero()));
ShadowPlanningEngine engine = new ShadowPlanningEngine(facade, mapper, properties, transactions);
OutboundShadowObservation observation = engine.planProjectOutbound("DETAIL-1");
engine.compareOutbound(observation, "10");
assertThat(observation.warehouseId()).isEqualTo("W02");
assertThat(observation.requestId()).isEqualTo("DETAIL-1");
assertThat(observation.proposedSpaceId()).isEqualTo("SPACE-9");
verify(mapper).insertComparison(any(), eq("W02"), eq("OUTBOUND_COMPARE"), eq("LEGACY_10"),
argThat(reason -> reason.contains("detail=DETAIL-1")
&& reason.contains("project=PROJECT-1")
&& reason.contains("product=PRODUCT-1")
&& reason.contains("snapshotSync=2026-08-28T02:53:45.133Z")
&& reason.contains("legacyMatches=2")
&& reason.contains("shadowMatches=1")));
}
@Test
void outboundPlanningTreatsPaddedAndUnpaddedWarehouseIdsAsEquivalent() {
ShadowObservationProperties properties = new ShadowObservationProperties();
properties.setWarehouseId("01");
CapturingTransactionManager transactions = new CapturingTransactionManager();
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
SchedulingFacade facade = mock(SchedulingFacade.class);
ShadowStockReference reference = new ShadowStockReference();
reference.setRequestId("DETAIL-2");
reference.setWarehouseId("1");
reference.setProjectNo("PROJECT-2");
when(mapper.selectProjectOutboundReference("DETAIL-2")).thenReturn(reference);
when(facade.planOutbound(eq("01"), any())).thenReturn(
OutboundPlan.error(new com.ruoyi.algorithm.outbound.OutboundError(
com.ruoyi.algorithm.outbound.OutboundErrorCode.STOCK_NOT_FOUND,
"no stock", java.util.Map.of())));
ShadowPlanningEngine engine = new ShadowPlanningEngine(facade, mapper, properties, transactions);
private static PublishedShadowSnapshot snapshot(long generation, String warehouseId) {
return new PublishedShadowSnapshot(generation, warehouseId,
Instant.parse("2026-09-03T00:00:00Z"), "ABC123");
}
OutboundShadowObservation observation = engine.planProjectOutbound("DETAIL-2");
private static ShadowSnapshotDescriptor descriptor(PublishedShadowSnapshot snapshot) {
return descriptor(snapshot, "PUBLISHED");
}
assertThat(observation.warehouseId()).isEqualTo("01");
private static ShadowSnapshotDescriptor descriptor(PublishedShadowSnapshot snapshot, String status) {
ShadowSnapshotDescriptor descriptor = new ShadowSnapshotDescriptor();
descriptor.setSnapshotGeneration(snapshot.generation());
descriptor.setWarehouseId(snapshot.warehouseId());
descriptor.setSnapshotStatus(status);
descriptor.setSynchronizedAt(snapshot.synchronizedAt());
descriptor.setSourceSignature(snapshot.sourceSignature());
return descriptor;
}
private static InputStream requireResource(String name) {
InputStream stream = ShadowPlanningEngineTest.class.getClassLoader().getResourceAsStream(name);
if (stream == null) {
throw new IllegalStateException("Missing test resource " + name);
}
return stream;
}
private static Map<String, String> statementsById(Document document) {
Map<String, String> statements = new HashMap<>();
NodeList children = document.getDocumentElement().getChildNodes();
for (int index = 0; index < children.getLength(); index++) {
if (children.item(index) instanceof Element element && element.hasAttribute("id")) {
statements.put(element.getAttribute("id"),
element.getTextContent().replaceAll("\\s+", " ").trim().toLowerCase());
}
}
return statements;
}
private static final class CapturingTransactionManager implements PlatformTransactionManager {