feat: plan shadow decisions from leased generation
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
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),
|
||||
"request=" + observation.requestId() + "; shadow=" + observation.resultCode()
|
||||
"snapshotGeneration=" + observation.snapshotGeneration()
|
||||
+ "; request=" + observation.requestId()
|
||||
+ "; shadow=" + observation.resultCode()
|
||||
+ "; proposedSpace=" + nullSafe(observation.proposedSpaceId())
|
||||
+ "; shadowReason=" + nullSafe(observation.reason())
|
||||
+ "; legacy=" + nullSafe(legacyResultCode)
|
||||
+ "; legacySpace=" + nullSafe(legacySpaceId)));
|
||||
+ "; legacySpace=" + nullSafe(legacySpaceId));
|
||||
});
|
||||
}
|
||||
|
||||
public void compareOutbound(OutboundShadowObservation observation, String legacyResultCode) {
|
||||
transactions.executeWithoutResult(status -> inputMapper.insertComparison(
|
||||
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",
|
||||
"LEGACY_" + nullSafe(legacyResultCode),
|
||||
"request=" + observation.requestId() + "; shadow=" + observation.resultCode()
|
||||
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)));
|
||||
+ "; 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;
|
||||
if (isBlank(warehouseId)) {
|
||||
throw new IllegalStateException("shadow warehouse is not configured");
|
||||
}
|
||||
return normalizedPreferred;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 > 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) > 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 > 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 > 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) > 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) > isnull(item.out_quantity, 0)
|
||||
</select>
|
||||
|
||||
<select id="claimInstance" resultType="boolean">
|
||||
if convert(int, #{staleSeconds}) <> 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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user