feat: plan shadow decisions from leased generation
This commit is contained in:
@@ -2,18 +2,25 @@ package com.ruoyi.algorithm.shadow;
|
|||||||
|
|
||||||
public record InboundShadowObservation(
|
public record InboundShadowObservation(
|
||||||
boolean observed,
|
boolean observed,
|
||||||
|
long snapshotGeneration,
|
||||||
String warehouseId,
|
String warehouseId,
|
||||||
String requestId,
|
String requestId,
|
||||||
String resultCode,
|
String resultCode,
|
||||||
String proposedSpaceId,
|
String proposedSpaceId,
|
||||||
String reason) {
|
String reason) {
|
||||||
|
|
||||||
public static InboundShadowObservation empty() {
|
public InboundShadowObservation(boolean observed, String warehouseId, String requestId,
|
||||||
return new InboundShadowObservation(false, null, null, "NOT_OBSERVED", null, null);
|
String resultCode, String proposedSpaceId, String reason) {
|
||||||
|
this(observed, 0L, warehouseId, requestId, resultCode, proposedSpaceId, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static InboundShadowObservation unavailable(String warehouseId, String requestId,
|
public static InboundShadowObservation empty() {
|
||||||
String resultCode, String reason) {
|
return new InboundShadowObservation(false, 0L, null, null, "NOT_OBSERVED", null, null);
|
||||||
return new InboundShadowObservation(true, warehouseId, requestId, resultCode, null, reason);
|
}
|
||||||
|
|
||||||
|
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(
|
public record OutboundShadowObservation(
|
||||||
boolean observed,
|
boolean observed,
|
||||||
|
long snapshotGeneration,
|
||||||
String warehouseId,
|
String warehouseId,
|
||||||
String requestId,
|
String requestId,
|
||||||
String resultCode,
|
String resultCode,
|
||||||
@@ -9,12 +10,18 @@ public record OutboundShadowObservation(
|
|||||||
String proposedSpaceId,
|
String proposedSpaceId,
|
||||||
String reason) {
|
String reason) {
|
||||||
|
|
||||||
public static OutboundShadowObservation empty() {
|
public OutboundShadowObservation(boolean observed, String warehouseId, String requestId,
|
||||||
return new OutboundShadowObservation(false, null, null, "NOT_OBSERVED", null, null, null);
|
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,
|
public static OutboundShadowObservation empty() {
|
||||||
String resultCode, String reason) {
|
return new OutboundShadowObservation(false, 0L, null, null, "NOT_OBSERVED", null, null, null);
|
||||||
return new OutboundShadowObservation(true, warehouseId, requestId, resultCode, null, null, reason);
|
}
|
||||||
|
|
||||||
|
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 projectNo;
|
||||||
private String productId;
|
private String productId;
|
||||||
private String lotNo;
|
private String lotNo;
|
||||||
|
private Integer shadowCandidateCount;
|
||||||
|
|
||||||
public String getRequestId() {
|
public String getRequestId() {
|
||||||
return requestId;
|
return requestId;
|
||||||
@@ -55,4 +56,12 @@ public class ShadowInboundReference {
|
|||||||
public void setLotNo(String lotNo) {
|
public void setLotNo(String lotNo) {
|
||||||
this.lotNo = 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.TransactionDefinition;
|
||||||
import org.springframework.transaction.support.TransactionTemplate;
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class ShadowPlanningEngine {
|
public class ShadowPlanningEngine {
|
||||||
|
private static final int INSTANCE_STALE_SECONDS = 120;
|
||||||
|
private static final String PUBLISHED = "PUBLISHED";
|
||||||
|
|
||||||
private final SchedulingFacade schedulingFacade;
|
private final SchedulingFacade schedulingFacade;
|
||||||
private final ShadowInputMapper inputMapper;
|
private final ShadowInputMapper inputMapper;
|
||||||
private final ShadowObservationProperties properties;
|
private final ShadowObservationProperties properties;
|
||||||
@@ -35,159 +40,313 @@ public class ShadowPlanningEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public InboundShadowObservation planInbound(String taskNo, String trayNo) {
|
public InboundShadowObservation planInbound(String taskNo, String trayNo) {
|
||||||
return transactions.execute(status -> planInboundInsideTransaction(taskNo, trayNo));
|
throw leasedSnapshotRequired();
|
||||||
}
|
}
|
||||||
|
|
||||||
public OutboundShadowObservation planProjectOutbound(String stockoutDetailId) {
|
public OutboundShadowObservation planProjectOutbound(String stockoutDetailId) {
|
||||||
return transactions.execute(status -> {
|
throw leasedSnapshotRequired();
|
||||||
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));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public OutboundShadowObservation planExactStockOutbound(String stockoutDetailId, String stockId) {
|
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 -> {
|
return transactions.execute(status -> {
|
||||||
ShadowStockReference reference = inputMapper.selectStockReference(stockId);
|
if (!inputMapper.claimInstance(warehouseId, instanceId, INSTANCE_STALE_SECONDS)) {
|
||||||
String warehouseId = warehouseId(reference == null ? null : reference.getWarehouseId());
|
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())) {
|
if (reference == null || isBlank(reference.getTrayNo())) {
|
||||||
return OutboundShadowObservation.unavailable(warehouseId, stockoutDetailId,
|
return OutboundShadowObservation.unavailable(snapshot.generation(), warehouseId,
|
||||||
"SKIPPED_MISSING_STOCK", "Selected stock was unavailable to shadow planning");
|
stockoutDetailId, "SKIPPED_MISSING_STOCK",
|
||||||
|
outboundDiagnostic(snapshot, stockoutDetailId, reference,
|
||||||
|
"Selected stock was unavailable to shadow planning"));
|
||||||
}
|
}
|
||||||
OutboundRequest request = new OutboundRequest(stockoutDetailId, reference.getTrayNo(),
|
OutboundRequest request = new OutboundRequest(stockoutDetailId, reference.getTrayNo(),
|
||||||
reference.getProjectNo(), reference.getProductId(), null, null, null, true);
|
reference.getProjectNo(), reference.getProductId(), null, null, null, true);
|
||||||
return toOutboundObservation(warehouseId, stockoutDetailId, reference,
|
return toOutboundObservation(snapshot, stockoutDetailId, reference,
|
||||||
schedulingFacade.planOutbound(warehouseId, request));
|
schedulingFacade.planOutboundFromPublishedSnapshot(warehouseId, request));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void compareInbound(InboundShadowObservation observation, String legacyResultCode,
|
public void compareInbound(InboundShadowObservation observation, String legacyResultCode,
|
||||||
String legacySpaceId) {
|
String legacySpaceId) {
|
||||||
transactions.executeWithoutResult(status -> inputMapper.insertComparison(
|
Objects.requireNonNull(observation, "observation");
|
||||||
UUID.randomUUID().toString(), observation.warehouseId(), "INBOUND_COMPARE",
|
transactions.executeWithoutResult(status -> {
|
||||||
comparisonCode(observation.proposedSpaceId(), legacySpaceId),
|
requirePublishedGeneration(observation.snapshotGeneration(), observation.warehouseId(),
|
||||||
"request=" + observation.requestId() + "; shadow=" + observation.resultCode()
|
"published snapshot changed before comparison");
|
||||||
+ "; proposedSpace=" + nullSafe(observation.proposedSpaceId())
|
inputMapper.insertComparison(
|
||||||
+ "; shadowReason=" + nullSafe(observation.reason())
|
UUID.randomUUID().toString(), observation.warehouseId(), "INBOUND_COMPARE",
|
||||||
+ "; legacy=" + nullSafe(legacyResultCode)
|
observation.snapshotGeneration(),
|
||||||
+ "; legacySpace=" + nullSafe(legacySpaceId)));
|
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) {
|
public void compareOutbound(OutboundShadowObservation observation, String legacyResultCode) {
|
||||||
transactions.executeWithoutResult(status -> inputMapper.insertComparison(
|
Objects.requireNonNull(observation, "observation");
|
||||||
UUID.randomUUID().toString(), observation.warehouseId(), "OUTBOUND_COMPARE",
|
transactions.executeWithoutResult(status -> {
|
||||||
"LEGACY_" + nullSafe(legacyResultCode),
|
requirePublishedGeneration(observation.snapshotGeneration(), observation.warehouseId(),
|
||||||
"request=" + observation.requestId() + "; shadow=" + observation.resultCode()
|
"published snapshot changed before comparison");
|
||||||
+ "; proposedStock=" + nullSafe(observation.proposedStockId())
|
inputMapper.insertComparison(
|
||||||
+ "; proposedSpace=" + nullSafe(observation.proposedSpaceId())
|
UUID.randomUUID().toString(), observation.warehouseId(), "OUTBOUND_COMPARE",
|
||||||
+ "; shadowReason=" + nullSafe(observation.reason())
|
observation.snapshotGeneration(), "LEGACY_" + nullSafe(legacyResultCode),
|
||||||
+ "; 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) {
|
private InboundShadowObservation planInboundInsideTransaction(PublishedShadowSnapshot snapshot,
|
||||||
ShadowInboundReference reference = inputMapper.selectInboundInput(taskNo, trayNo);
|
String taskNo, String trayNo) {
|
||||||
String warehouseId = warehouseId(reference == null ? null : reference.getWarehouseId());
|
String warehouseId = snapshot.warehouseId();
|
||||||
|
ShadowInboundReference reference = inputMapper.selectInboundInput(
|
||||||
|
warehouseId, snapshot.generation(), taskNo, trayNo);
|
||||||
if (reference == null || isBlank(reference.getRequestId()) || isBlank(reference.getTrayNo())) {
|
if (reference == null || isBlank(reference.getRequestId()) || isBlank(reference.getTrayNo())) {
|
||||||
return InboundShadowObservation.unavailable(warehouseId, taskNo,
|
return InboundShadowObservation.unavailable(snapshot.generation(), warehouseId, taskNo,
|
||||||
"SKIPPED_MISSING_INPUT", "Inbound task details were unavailable to shadow planning");
|
"SKIPPED_MISSING_INPUT",
|
||||||
|
inboundDiagnostic(snapshot, taskNo, trayNo, reference,
|
||||||
|
"Inbound task details were unavailable to shadow planning"));
|
||||||
}
|
}
|
||||||
InboundRequest request = new InboundRequest(reference.getRequestId(), reference.getTrayNo(),
|
InboundRequest request = new InboundRequest(reference.getRequestId(), reference.getTrayNo(),
|
||||||
reference.getProjectNo(), reference.getProductId(), reference.getLotNo(),
|
reference.getProjectNo(), reference.getProductId(), reference.getLotNo(),
|
||||||
null, null, null, Set.of(), null);
|
null, null, null, Set.of(), null);
|
||||||
InboundPlanningResult result = schedulingFacade.planInbound(warehouseId, request);
|
InboundPlanningResult result = schedulingFacade.planInboundFromPublishedSnapshot(warehouseId, request);
|
||||||
InboundPlan plan = result.plan();
|
InboundPlan plan = result.plan();
|
||||||
if (plan.assignment().isPresent()) {
|
if (plan.assignment().isPresent()) {
|
||||||
var assignment = plan.assignment().orElseThrow();
|
var assignment = plan.assignment().orElseThrow();
|
||||||
return new InboundShadowObservation(true, warehouseId, reference.getRequestId(),
|
return new InboundShadowObservation(true, snapshot.generation(), warehouseId,
|
||||||
"READY", assignment.spaceId(), inboundDiagnostic(taskNo, trayNo, reference, assignment.reason()));
|
reference.getRequestId(), "READY", assignment.spaceId(),
|
||||||
|
inboundDiagnostic(snapshot, taskNo, trayNo, reference, assignment.reason()));
|
||||||
}
|
}
|
||||||
var rejection = plan.rejection().orElseThrow();
|
var rejection = plan.rejection().orElseThrow();
|
||||||
return InboundShadowObservation.unavailable(warehouseId, reference.getRequestId(),
|
return InboundShadowObservation.unavailable(snapshot.generation(), warehouseId,
|
||||||
"REJECTED_" + rejection.code().name(),
|
reference.getRequestId(), "REJECTED_" + rejection.code().name(),
|
||||||
inboundDiagnostic(taskNo, trayNo, reference, rejection.reason()));
|
inboundDiagnostic(snapshot, taskNo, trayNo, reference, rejection.reason()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private OutboundShadowObservation toOutboundObservation(String warehouseId, String requestId,
|
private OutboundShadowObservation toOutboundObservation(PublishedShadowSnapshot snapshot,
|
||||||
ShadowStockReference reference, OutboundPlan plan) {
|
String requestId, ShadowStockReference reference, OutboundPlan plan) {
|
||||||
if (isBlank(requestId)) {
|
if (isBlank(requestId)) {
|
||||||
requestId = plan.assignment().map(assignment -> assignment.requestId()).orElse(null);
|
requestId = plan.assignment().map(assignment -> assignment.requestId()).orElse(null);
|
||||||
}
|
}
|
||||||
if (plan.assignment().isPresent()) {
|
if (plan.assignment().isPresent()) {
|
||||||
var assignment = plan.assignment().orElseThrow();
|
var assignment = plan.assignment().orElseThrow();
|
||||||
return new OutboundShadowObservation(true, warehouseId, requestId, "READY",
|
return new OutboundShadowObservation(true, snapshot.generation(), snapshot.warehouseId(),
|
||||||
reference.getStockId(), assignment.sourceSpaceId(),
|
requestId, "READY", reference.getStockId(), assignment.sourceSpaceId(),
|
||||||
outboundDiagnostic(requestId, reference, assignment.reason()));
|
outboundDiagnostic(snapshot, requestId, reference, assignment.reason()));
|
||||||
}
|
}
|
||||||
if (plan.waiting().isPresent()) {
|
if (plan.waiting().isPresent()) {
|
||||||
var waiting = plan.waiting().orElseThrow();
|
var waiting = plan.waiting().orElseThrow();
|
||||||
return OutboundShadowObservation.unavailable(warehouseId, requestId,
|
return OutboundShadowObservation.unavailable(snapshot.generation(), snapshot.warehouseId(),
|
||||||
"WAITING_" + waiting.code().name(), outboundDiagnostic(requestId, reference, waiting.reason()));
|
requestId, "WAITING_" + waiting.code().name(),
|
||||||
|
outboundDiagnostic(snapshot, requestId, reference, waiting.reason()));
|
||||||
}
|
}
|
||||||
var error = plan.error().orElseThrow();
|
var error = plan.error().orElseThrow();
|
||||||
return OutboundShadowObservation.unavailable(warehouseId, requestId,
|
return OutboundShadowObservation.unavailable(snapshot.generation(), snapshot.warehouseId(),
|
||||||
"ERROR_" + error.code().name(), outboundDiagnostic(requestId, reference, error.message()));
|
requestId, "ERROR_" + error.code().name(),
|
||||||
|
outboundDiagnostic(snapshot, requestId, reference, error.message()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String warehouseId(String preferredWarehouseId) {
|
private <T> T planWithStableSnapshot(PublishedShadowSnapshot snapshot, Supplier<T> planner) {
|
||||||
String configuredWarehouseId = isBlank(properties.getWarehouseId())
|
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()
|
? schedulingFacade.defaultWarehouseId()
|
||||||
: properties.getWarehouseId();
|
: properties.getWarehouseId();
|
||||||
if (!isBlank(preferredWarehouseId)) {
|
if (isBlank(warehouseId)) {
|
||||||
String normalizedPreferred = preferredWarehouseId.trim();
|
throw new IllegalStateException("shadow warehouse is not configured");
|
||||||
if (sameNumericWarehouse(normalizedPreferred, configuredWarehouseId)) {
|
|
||||||
return configuredWarehouseId;
|
|
||||||
}
|
|
||||||
return normalizedPreferred;
|
|
||||||
}
|
}
|
||||||
return configuredWarehouseId;
|
return warehouseId.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean sameNumericWarehouse(String first, String second) {
|
private IllegalStateException leasedSnapshotRequired() {
|
||||||
if (isBlank(first) || isBlank(second)) {
|
return new IllegalStateException("published shadow snapshot lease is required");
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return Integer.parseInt(first) == Integer.parseInt(second);
|
|
||||||
} catch (NumberFormatException ignored) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String outboundDiagnostic(String requestId, ShadowStockReference reference, String resultReason) {
|
private String outboundDiagnostic(PublishedShadowSnapshot snapshot, String requestId,
|
||||||
return "detail=" + nullSafe(requestId)
|
ShadowStockReference reference, String resultReason) {
|
||||||
+ "; project=" + nullSafe(reference.getProjectNo())
|
return snapshotDiagnostic(snapshot)
|
||||||
+ "; product=" + nullSafe(reference.getProductId())
|
+ "; detail=" + nullSafe(requestId)
|
||||||
+ "; tray=" + nullSafe(reference.getTrayNo())
|
+ "; rawWarehouse=" + nullSafe(reference == null ? null : reference.getWarehouseId())
|
||||||
+ "; stock=" + nullSafe(reference.getStockId())
|
+ "; project=" + nullSafe(reference == null ? null : reference.getProjectNo())
|
||||||
+ "; snapshotSync=" + nullSafe(reference.getSnapshotSynchronizedAt())
|
+ "; product=" + nullSafe(reference == null ? null : reference.getProductId())
|
||||||
+ "; legacyMatches=" + numberOrUnknown(reference.getLiveMatchCount())
|
+ "; tray=" + nullSafe(reference == null ? null : reference.getTrayNo())
|
||||||
+ "; shadowMatches=" + numberOrUnknown(reference.getShadowMatchCount())
|
+ "; 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);
|
+ "; reason=" + abbreviate(nullSafe(resultReason), 600);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String inboundDiagnostic(String taskNo, String trayNo, ShadowInboundReference reference,
|
private String inboundDiagnostic(PublishedShadowSnapshot snapshot, String taskNo, String trayNo,
|
||||||
String resultReason) {
|
ShadowInboundReference reference, String resultReason) {
|
||||||
return "task=" + nullSafe(taskNo)
|
return snapshotDiagnostic(snapshot)
|
||||||
|
+ "; task=" + nullSafe(taskNo)
|
||||||
+ "; tray=" + nullSafe(trayNo)
|
+ "; tray=" + nullSafe(trayNo)
|
||||||
+ "; request=" + nullSafe(reference.getRequestId())
|
+ "; request=" + nullSafe(reference == null ? null : reference.getRequestId())
|
||||||
+ "; warehouse=" + nullSafe(reference.getWarehouseId())
|
+ "; rawWarehouse=" + nullSafe(reference == null ? null : reference.getWarehouseId())
|
||||||
+ "; project=" + nullSafe(reference.getProjectNo())
|
+ "; project=" + nullSafe(reference == null ? null : reference.getProjectNo())
|
||||||
+ "; product=" + nullSafe(reference.getProductId())
|
+ "; product=" + nullSafe(reference == null ? null : reference.getProductId())
|
||||||
+ "; lot=" + nullSafe(reference.getLotNo())
|
+ "; lot=" + nullSafe(reference == null ? null : reference.getLotNo())
|
||||||
|
+ "; shadowCandidates=" + numberOrUnknown(
|
||||||
|
reference == null ? null : reference.getShadowCandidateCount())
|
||||||
+ "; reason=" + abbreviate(nullSafe(resultReason), 600);
|
+ "; 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) {
|
private String numberOrUnknown(Integer value) {
|
||||||
return value == null ? "" : value.toString();
|
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) {
|
private String abbreviate(String value, int maxLength) {
|
||||||
return value.length() <= maxLength ? value : value.substring(0, 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;
|
package com.ruoyi.algorithm.shadow;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
public class ShadowStockReference {
|
public class ShadowStockReference {
|
||||||
private String requestId;
|
private String requestId;
|
||||||
private String stockId;
|
private String stockId;
|
||||||
@@ -9,7 +11,7 @@ public class ShadowStockReference {
|
|||||||
private String projectNo;
|
private String projectNo;
|
||||||
private String productId;
|
private String productId;
|
||||||
private String snapshotSynchronizedAt;
|
private String snapshotSynchronizedAt;
|
||||||
private Integer liveMatchCount;
|
private BigDecimal requestedQuantity;
|
||||||
private Integer shadowMatchCount;
|
private Integer shadowMatchCount;
|
||||||
|
|
||||||
public String getRequestId() {
|
public String getRequestId() {
|
||||||
@@ -76,12 +78,12 @@ public class ShadowStockReference {
|
|||||||
this.snapshotSynchronizedAt = snapshotSynchronizedAt;
|
this.snapshotSynchronizedAt = snapshotSynchronizedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Integer getLiveMatchCount() {
|
public BigDecimal getRequestedQuantity() {
|
||||||
return liveMatchCount;
|
return requestedQuantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setLiveMatchCount(Integer liveMatchCount) {
|
public void setRequestedQuantity(BigDecimal requestedQuantity) {
|
||||||
this.liveMatchCount = liveMatchCount;
|
this.requestedQuantity = requestedQuantity;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Integer getShadowMatchCount() {
|
public Integer getShadowMatchCount() {
|
||||||
|
|||||||
@@ -1,23 +1,36 @@
|
|||||||
package com.ruoyi.algorithm.shadow.mapper;
|
package com.ruoyi.algorithm.shadow.mapper;
|
||||||
|
|
||||||
import com.ruoyi.algorithm.shadow.ShadowInboundReference;
|
import com.ruoyi.algorithm.shadow.ShadowInboundReference;
|
||||||
|
import com.ruoyi.algorithm.shadow.ShadowSnapshotDescriptor;
|
||||||
import com.ruoyi.algorithm.shadow.ShadowStockReference;
|
import com.ruoyi.algorithm.shadow.ShadowStockReference;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface ShadowInputMapper {
|
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);
|
@Param("trayNo") String trayNo);
|
||||||
|
|
||||||
ShadowStockReference selectProjectOutboundReference(
|
ShadowStockReference selectProjectOutboundReference(@Param("warehouseId") String warehouseId,
|
||||||
|
@Param("generation") long generation,
|
||||||
@Param("stockoutDetailId") String stockoutDetailId);
|
@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,
|
int insertComparison(@Param("decisionId") String decisionId,
|
||||||
@Param("warehouseId") String warehouseId,
|
@Param("warehouseId") String warehouseId,
|
||||||
@Param("decisionType") String decisionType,
|
@Param("decisionType") String decisionType,
|
||||||
|
@Param("inputVersion") long inputVersion,
|
||||||
@Param("resultCode") String resultCode,
|
@Param("resultCode") String resultCode,
|
||||||
@Param("reason") String reason);
|
@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 id="selectInboundInput" resultType="com.ruoyi.algorithm.shadow.ShadowInboundReference">
|
||||||
select top (1)
|
select top (1)
|
||||||
job.job_id as requestId,
|
input.request_id as requestId,
|
||||||
job.warehouse_id as warehouseId,
|
input.raw_warehouse_id as warehouseId,
|
||||||
job.tray_code as trayNo,
|
input.tray_no as trayNo,
|
||||||
coalesce(nullif(ltrim(rtrim(detail.spare4)), ''),
|
input.project_no as projectNo,
|
||||||
nullif(ltrim(rtrim(stock.spare4)), '')) as projectNo,
|
input.product_id as productId,
|
||||||
jobDetail.product_id as productId,
|
input.lot_no as lotNo,
|
||||||
jobDetail.lot_no as lotNo
|
convert(int, coalesce((
|
||||||
from mydo_job job
|
select sum(convert(bigint, lane.available_count))
|
||||||
left join mydo_job_detail jobDetail on jobDetail.job_id = job.job_id
|
from alg.lane_state lane
|
||||||
left join in_stockin_detail detail on detail.stockin_detail_id = jobDetail.stockin_detail_id
|
where lane.warehouse_id = #{warehouseId}
|
||||||
left join bas_stock stock on stock.stock_id = jobDetail.stock_id
|
and lane.snapshot_generation = #{generation}
|
||||||
where job.task_no = #{taskNo}
|
), 0)) as shadowCandidateCount
|
||||||
and job.tray_code = #{trayNo}
|
from alg.shadow_inbound_request_snapshot input
|
||||||
order by jobDetail.job_detail_id
|
where input.warehouse_id = #{warehouseId}
|
||||||
|
and input.snapshot_generation = #{generation}
|
||||||
|
and input.task_no = #{taskNo}
|
||||||
|
and input.tray_no = #{trayNo}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="selectProjectOutboundReference" resultType="com.ruoyi.algorithm.shadow.ShadowStockReference">
|
<select id="selectProjectOutboundReference"
|
||||||
select detail.stockout_detail_id as requestId,
|
resultType="com.ruoyi.algorithm.shadow.ShadowStockReference">
|
||||||
detail.warehouse_id as warehouseId,
|
select top (1)
|
||||||
nullif(ltrim(rtrim(detail.spare4)), '') as projectNo,
|
input.stockout_detail_id as requestId,
|
||||||
detail.product_id as productId,
|
input.raw_warehouse_id as warehouseId,
|
||||||
convert(varchar(40), (select max(snapshot.synchronized_at)
|
input.project_no as projectNo,
|
||||||
from alg.inventory_item_snapshot snapshot
|
input.product_id as productId,
|
||||||
where snapshot.warehouse_id = detail.warehouse_id), 126)
|
input.requested_quantity as requestedQuantity,
|
||||||
as snapshotSynchronizedAt,
|
convert(varchar(40), input.synchronized_at, 126) as snapshotSynchronizedAt,
|
||||||
(select count(*)
|
(select count(*)
|
||||||
from bas_stock live
|
from alg.inventory_item_snapshot item
|
||||||
where live.warehouse_id = detail.warehouse_id
|
where item.warehouse_id = #{warehouseId}
|
||||||
and live.stock_status = '1'
|
and item.snapshot_generation = #{generation}
|
||||||
and live.product_status = '1'
|
and item.project_no = nullif(ltrim(rtrim(input.project_no)), '')
|
||||||
and live.quantity > live.out_quantity
|
and (nullif(ltrim(rtrim(input.product_id)), '') is null
|
||||||
and (NULLIF(LTRIM(RTRIM(detail.product_id)), '') IS NULL
|
or item.product_no = nullif(ltrim(rtrim(input.product_id)), ''))
|
||||||
OR live.product_id = NULLIF(LTRIM(RTRIM(detail.product_id)), ''))) as liveMatchCount,
|
and isnull(item.quantity, 0) > isnull(item.out_quantity, 0))
|
||||||
(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)), '')))
|
|
||||||
as shadowMatchCount
|
as shadowMatchCount
|
||||||
from out_stockout_detail detail
|
from alg.shadow_outbound_request_snapshot input
|
||||||
where detail.stockout_detail_id = #{stockoutDetailId}
|
where input.warehouse_id = #{warehouseId}
|
||||||
|
and input.snapshot_generation = #{generation}
|
||||||
|
and input.stockout_detail_id = #{stockoutDetailId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="selectStockReference" resultType="com.ruoyi.algorithm.shadow.ShadowStockReference">
|
<select id="selectStockReference" resultType="com.ruoyi.algorithm.shadow.ShadowStockReference">
|
||||||
select top (1)
|
select top (1)
|
||||||
stock.stock_id as stockId,
|
item.stock_id as stockId,
|
||||||
stock.tray_code as trayNo,
|
item.tray_code as trayNo,
|
||||||
stock.cargo_space_id as spaceId,
|
item.space_id as spaceId,
|
||||||
stock.warehouse_id as warehouseId,
|
item.warehouse_id as warehouseId,
|
||||||
nullif(ltrim(rtrim(stock.spare4)), '') as projectNo,
|
nullif(ltrim(rtrim(item.project_no)), '') as projectNo,
|
||||||
stock.product_id as productId,
|
item.product_no as productId,
|
||||||
convert(varchar(40), (select max(snapshot.synchronized_at)
|
convert(varchar(40), item.synchronized_at, 126) as snapshotSynchronizedAt,
|
||||||
from alg.inventory_item_snapshot snapshot
|
|
||||||
where snapshot.warehouse_id = stock.warehouse_id), 126)
|
|
||||||
as snapshotSynchronizedAt,
|
|
||||||
(select count(*)
|
(select count(*)
|
||||||
from bas_stock live
|
from alg.inventory_item_snapshot candidate
|
||||||
where live.stock_id = stock.stock_id
|
where candidate.warehouse_id = #{warehouseId}
|
||||||
and live.stock_status = '1'
|
and candidate.snapshot_generation = #{generation}
|
||||||
and live.product_status = '1'
|
and candidate.stock_id = #{stockId}
|
||||||
and live.quantity > live.out_quantity) as liveMatchCount,
|
and isnull(candidate.quantity, 0) > isnull(candidate.out_quantity, 0))
|
||||||
(select count(*)
|
as shadowMatchCount
|
||||||
from alg.inventory_item_snapshot snapshot
|
from alg.inventory_item_snapshot item
|
||||||
where snapshot.stock_id = stock.stock_id
|
where item.warehouse_id = #{warehouseId}
|
||||||
and snapshot.warehouse_id = stock.warehouse_id) as shadowMatchCount
|
and item.snapshot_generation = #{generation}
|
||||||
from bas_stock stock
|
and item.stock_id = #{stockId}
|
||||||
where stock.stock_id = #{stockId}
|
and isnull(item.quantity, 0) > isnull(item.out_quantity, 0)
|
||||||
and stock.stock_status = '1'
|
</select>
|
||||||
and stock.product_status = '1'
|
|
||||||
and stock.quantity > stock.out_quantity
|
<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>
|
</select>
|
||||||
|
|
||||||
<insert id="insertComparison">
|
<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)
|
decision_id, warehouse_id, decision_type, input_version, result_code, reason)
|
||||||
values (
|
values (
|
||||||
try_convert(uniqueidentifier, #{decisionId}), #{warehouseId}, #{decisionType},
|
try_convert(uniqueidentifier, #{decisionId}), #{warehouseId}, #{decisionType},
|
||||||
null, #{resultCode}, #{reason})
|
convert(binary(8), #{inputVersion}), #{resultCode}, #{reason})
|
||||||
</insert>
|
</insert>
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import com.ruoyi.algorithm.inbound.InboundBorrowType;
|
|||||||
import com.ruoyi.algorithm.inbound.InboundLadderRung;
|
import com.ruoyi.algorithm.inbound.InboundLadderRung;
|
||||||
import com.ruoyi.algorithm.inbound.InboundPlan;
|
import com.ruoyi.algorithm.inbound.InboundPlan;
|
||||||
import com.ruoyi.algorithm.integration.SchedulingFacade;
|
import com.ruoyi.algorithm.integration.SchedulingFacade;
|
||||||
|
import com.ruoyi.algorithm.outbound.FifoDecision;
|
||||||
import com.ruoyi.algorithm.outbound.OutboundAssignment;
|
import com.ruoyi.algorithm.outbound.OutboundAssignment;
|
||||||
import com.ruoyi.algorithm.outbound.OutboundPlan;
|
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.repository.VersionToken;
|
||||||
import com.ruoyi.algorithm.service.InboundPlanningResult;
|
import com.ruoyi.algorithm.service.InboundPlanningResult;
|
||||||
import com.ruoyi.algorithm.shadow.mapper.ShadowInputMapper;
|
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.TransactionDefinition;
|
||||||
import org.springframework.transaction.TransactionStatus;
|
import org.springframework.transaction.TransactionStatus;
|
||||||
import org.springframework.transaction.support.SimpleTransactionStatus;
|
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.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.argThat;
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
class ShadowPlanningEngineTest {
|
class ShadowPlanningEngineTest {
|
||||||
|
private static final PublishedShadowSnapshot SNAPSHOT = new PublishedShadowSnapshot(
|
||||||
|
42L, "01", Instant.parse("2026-09-03T00:00:00Z"), "ABC123");
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void inboundPlanningUsesRequiresNewAndConfiguredTimeout() {
|
void inboundPlanningUsesLeasedGenerationPublishedFacadeAndConfiguredTransaction() {
|
||||||
ShadowObservationProperties properties = new ShadowObservationProperties();
|
ShadowObservationProperties properties = properties();
|
||||||
properties.setWarehouseId("FALLBACK");
|
|
||||||
properties.setTimeoutSeconds(7);
|
properties.setTimeoutSeconds(7);
|
||||||
CapturingTransactionManager transactions = new CapturingTransactionManager();
|
CapturingTransactionManager transactions = new CapturingTransactionManager();
|
||||||
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
|
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
|
||||||
SchedulingFacade facade = mock(SchedulingFacade.class);
|
SchedulingFacade facade = mock(SchedulingFacade.class);
|
||||||
ShadowInboundReference input = new ShadowInboundReference();
|
ShadowInboundReference input = inboundReference("W02");
|
||||||
input.setRequestId("JOB-1");
|
input.setShadowCandidateCount(3);
|
||||||
input.setWarehouseId("W01");
|
when(mapper.selectInboundInput("01", 42L, "TASK-1", "TRAY-1")).thenReturn(input);
|
||||||
input.setTrayNo("TRAY-1");
|
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT));
|
||||||
input.setProjectNo("PROJECT-1");
|
when(facade.planInboundFromPublishedSnapshot(eq("01"), any()))
|
||||||
input.setProductId("PRODUCT-1");
|
.thenReturn(new InboundPlanningResult(InboundPlan.assigned(inboundAssignment()),
|
||||||
input.setLotNo("LOT-1");
|
VersionToken.numeric(8)));
|
||||||
when(mapper.selectInboundInput("TASK-1", "TRAY-1")).thenReturn(input);
|
|
||||||
InboundAssignment assignment = new InboundAssignment(
|
|
||||||
"JOB-1", "SPACE-9", "LANE-2", 4, 2,
|
|
||||||
InboundLadderRung.CONTINUE, InboundBorrowType.NONE, null,
|
|
||||||
1.0, 2.0, 3.0, 50.0, false, false, "shadow proposal");
|
|
||||||
when(facade.planInbound(eq("W01"), any()))
|
|
||||||
.thenReturn(new InboundPlanningResult(InboundPlan.assigned(assignment), VersionToken.numeric(8)));
|
|
||||||
ShadowPlanningEngine engine = new ShadowPlanningEngine(facade, mapper, properties, transactions);
|
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())
|
assertThat(transactions.definition.getPropagationBehavior())
|
||||||
.isEqualTo(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
.isEqualTo(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||||
@@ -59,70 +65,297 @@ class ShadowPlanningEngineTest {
|
|||||||
.isEqualTo(TransactionDefinition.ISOLATION_READ_UNCOMMITTED);
|
.isEqualTo(TransactionDefinition.ISOLATION_READ_UNCOMMITTED);
|
||||||
assertThat(transactions.definition.getTimeout()).isEqualTo(7);
|
assertThat(transactions.definition.getTimeout()).isEqualTo(7);
|
||||||
assertThat(observation.observed()).isTrue();
|
assertThat(observation.observed()).isTrue();
|
||||||
|
assertThat(observation.snapshotGeneration()).isEqualTo(42L);
|
||||||
|
assertThat(observation.warehouseId()).isEqualTo("01");
|
||||||
assertThat(observation.proposedSpaceId()).isEqualTo("SPACE-9");
|
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
|
@Test
|
||||||
void outboundPlanningUsesReferenceWarehouseAndKeepsMatchDiagnostics() {
|
void projectOutboundKeepsRawWarehouseDiagnosticButPlansForPublishedWarehouse() {
|
||||||
ShadowObservationProperties properties = new ShadowObservationProperties();
|
|
||||||
properties.setWarehouseId("FALLBACK");
|
|
||||||
CapturingTransactionManager transactions = new CapturingTransactionManager();
|
|
||||||
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
|
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
|
||||||
SchedulingFacade facade = mock(SchedulingFacade.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();
|
ShadowStockReference reference = new ShadowStockReference();
|
||||||
reference.setRequestId("DETAIL-1");
|
reference.setRequestId("DETAIL-1");
|
||||||
reference.setWarehouseId("W02");
|
reference.setWarehouseId(rawWarehouse);
|
||||||
reference.setProjectNo("PROJECT-1");
|
reference.setProjectNo("PROJECT-1");
|
||||||
reference.setProductId("PRODUCT-1");
|
reference.setProductId("PRODUCT-1");
|
||||||
reference.setSnapshotSynchronizedAt("2026-08-28T02:53:45.133Z");
|
reference.setSnapshotSynchronizedAt("2026-09-03T00:00:00Z");
|
||||||
reference.setLiveMatchCount(2);
|
|
||||||
reference.setShadowMatchCount(1);
|
reference.setShadowMatchCount(1);
|
||||||
when(mapper.selectProjectOutboundReference("DETAIL-1")).thenReturn(reference);
|
return reference;
|
||||||
OutboundAssignment assignment = new OutboundAssignment(
|
}
|
||||||
|
|
||||||
|
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,
|
"DETAIL-1", "TRAY-1", "PROJECT-1", "PRODUCT-1", "SPACE-9", 1,
|
||||||
true, java.util.List.of(), java.util.List.of(), false,
|
true, java.util.List.of(), java.util.List.of(), false,
|
||||||
1.0, 2.0, 3.0, FifoDecision.off(), java.util.Optional.empty(),
|
1.0, 2.0, 3.0, FifoDecision.off(), java.util.Optional.empty(),
|
||||||
"shadow proposal");
|
"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
|
private static PublishedShadowSnapshot snapshot(long generation, String warehouseId) {
|
||||||
void outboundPlanningTreatsPaddedAndUnpaddedWarehouseIdsAsEquivalent() {
|
return new PublishedShadowSnapshot(generation, warehouseId,
|
||||||
ShadowObservationProperties properties = new ShadowObservationProperties();
|
Instant.parse("2026-09-03T00:00:00Z"), "ABC123");
|
||||||
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);
|
|
||||||
|
|
||||||
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 {
|
private static final class CapturingTransactionManager implements PlatformTransactionManager {
|
||||||
|
|||||||
Reference in New Issue
Block a user