From 935d7af43d68c5ea84c387daf59ed660e5a3cb74 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Thu, 3 Sep 2026 14:28:38 +0800 Subject: [PATCH] feat: plan shadow decisions from leased generation --- .../shadow/InboundShadowObservation.java | 17 +- .../shadow/OutboundShadowObservation.java | 17 +- .../shadow/ShadowInboundReference.java | 9 + .../shadow/ShadowPlanningEngine.java | 339 +++++++++++----- .../shadow/ShadowSnapshotDescriptor.java | 51 +++ .../shadow/ShadowStockReference.java | 12 +- .../shadow/mapper/ShadowInputMapper.java | 19 +- .../mapper/algorithm/ShadowInputMapper.xml | 147 ++++--- .../shadow/ShadowPlanningEngineTest.java | 361 ++++++++++++++---- 9 files changed, 737 insertions(+), 235 deletions(-) create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSnapshotDescriptor.java diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/InboundShadowObservation.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/InboundShadowObservation.java index da45ce3..0ff8f18 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/InboundShadowObservation.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/InboundShadowObservation.java @@ -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); } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/OutboundShadowObservation.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/OutboundShadowObservation.java index 0546c14..4aef62d 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/OutboundShadowObservation.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/OutboundShadowObservation.java @@ -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); } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowInboundReference.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowInboundReference.java index 15b892c..2b5701d 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowInboundReference.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowInboundReference.java @@ -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; + } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java index 4f272ca..aa7c91e 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java @@ -12,12 +12,17 @@ import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.support.TransactionTemplate; +import java.time.Instant; import java.util.Objects; import java.util.Set; import java.util.UUID; +import java.util.function.Supplier; @Component public class ShadowPlanningEngine { + private static final int INSTANCE_STALE_SECONDS = 120; + private static final String PUBLISHED = "PUBLISHED"; + private final SchedulingFacade schedulingFacade; private final ShadowInputMapper inputMapper; private final ShadowObservationProperties properties; @@ -35,159 +40,313 @@ public class ShadowPlanningEngine { } public InboundShadowObservation planInbound(String taskNo, String trayNo) { - return transactions.execute(status -> planInboundInsideTransaction(taskNo, trayNo)); + throw leasedSnapshotRequired(); } public OutboundShadowObservation planProjectOutbound(String stockoutDetailId) { - return transactions.execute(status -> { - ShadowStockReference reference = inputMapper.selectProjectOutboundReference(stockoutDetailId); - String warehouseId = warehouseId(reference == null ? null : reference.getWarehouseId()); - if (reference == null || isBlank(reference.getProjectNo())) { - return OutboundShadowObservation.unavailable(warehouseId, stockoutDetailId, - "SKIPPED_MISSING_PROJECT", "No project number was available for shadow planning"); - } - OutboundRequest request = new OutboundRequest(stockoutDetailId, null, - reference.getProjectNo(), reference.getProductId(), null, null, null, true); - return toOutboundObservation(warehouseId, reference.getRequestId(), reference, - schedulingFacade.planOutbound(warehouseId, request)); - }); + throw leasedSnapshotRequired(); } public OutboundShadowObservation planExactStockOutbound(String stockoutDetailId, String stockId) { + throw leasedSnapshotRequired(); + } + + public PublishedShadowSnapshot refreshSnapshot(long generation, String instanceId) { + if (generation <= 0) { + throw new IllegalArgumentException("shadow snapshot generation must be positive"); + } + if (isBlank(instanceId)) { + throw new IllegalArgumentException("shadow snapshot instanceId is required"); + } + String warehouseId = configuredWarehouseId(); return transactions.execute(status -> { - ShadowStockReference reference = inputMapper.selectStockReference(stockId); - String warehouseId = warehouseId(reference == null ? null : reference.getWarehouseId()); + if (!inputMapper.claimInstance(warehouseId, instanceId, INSTANCE_STALE_SECONDS)) { + throw new IllegalStateException("could not claim shadow snapshot refresh for warehouse " + warehouseId); + } + inputMapper.refreshSnapshot(warehouseId, generation, instanceId); + return toPublishedSnapshot(inputMapper.selectPublishedSnapshot(warehouseId), generation, warehouseId); + }); + } + + public InboundShadowObservation planInbound(PublishedShadowSnapshot snapshot, + String taskNo, String trayNo) { + return planWithStableSnapshot(snapshot, + () -> planInboundInsideTransaction(snapshot, taskNo, trayNo)); + } + + public OutboundShadowObservation planProjectOutbound(PublishedShadowSnapshot snapshot, + String stockoutDetailId) { + return planWithStableSnapshot(snapshot, () -> { + String warehouseId = snapshot.warehouseId(); + ShadowStockReference reference = inputMapper.selectProjectOutboundReference( + warehouseId, snapshot.generation(), stockoutDetailId); + if (reference == null || isBlank(reference.getProjectNo())) { + return OutboundShadowObservation.unavailable(snapshot.generation(), warehouseId, + stockoutDetailId, "SKIPPED_MISSING_PROJECT", + outboundDiagnostic(snapshot, stockoutDetailId, reference, + "No project number was available for shadow planning")); + } + OutboundRequest request = new OutboundRequest(stockoutDetailId, null, + reference.getProjectNo(), reference.getProductId(), null, null, null, true); + return toOutboundObservation(snapshot, reference.getRequestId(), reference, + schedulingFacade.planOutboundFromPublishedSnapshot(warehouseId, request)); + }); + } + + public OutboundShadowObservation planExactStockOutbound(PublishedShadowSnapshot snapshot, + String stockoutDetailId, String stockId) { + return planWithStableSnapshot(snapshot, () -> { + String warehouseId = snapshot.warehouseId(); + ShadowStockReference reference = inputMapper.selectStockReference( + warehouseId, snapshot.generation(), stockId); if (reference == null || isBlank(reference.getTrayNo())) { - return OutboundShadowObservation.unavailable(warehouseId, stockoutDetailId, - "SKIPPED_MISSING_STOCK", "Selected stock was unavailable to shadow planning"); + return OutboundShadowObservation.unavailable(snapshot.generation(), warehouseId, + stockoutDetailId, "SKIPPED_MISSING_STOCK", + outboundDiagnostic(snapshot, stockoutDetailId, reference, + "Selected stock was unavailable to shadow planning")); } OutboundRequest request = new OutboundRequest(stockoutDetailId, reference.getTrayNo(), reference.getProjectNo(), reference.getProductId(), null, null, null, true); - return toOutboundObservation(warehouseId, stockoutDetailId, reference, - schedulingFacade.planOutbound(warehouseId, request)); + return toOutboundObservation(snapshot, stockoutDetailId, reference, + schedulingFacade.planOutboundFromPublishedSnapshot(warehouseId, request)); }); } public void compareInbound(InboundShadowObservation observation, String legacyResultCode, String legacySpaceId) { - transactions.executeWithoutResult(status -> inputMapper.insertComparison( - UUID.randomUUID().toString(), observation.warehouseId(), "INBOUND_COMPARE", - comparisonCode(observation.proposedSpaceId(), legacySpaceId), - "request=" + observation.requestId() + "; shadow=" + observation.resultCode() - + "; proposedSpace=" + nullSafe(observation.proposedSpaceId()) - + "; shadowReason=" + nullSafe(observation.reason()) - + "; legacy=" + nullSafe(legacyResultCode) - + "; legacySpace=" + nullSafe(legacySpaceId))); + Objects.requireNonNull(observation, "observation"); + transactions.executeWithoutResult(status -> { + requirePublishedGeneration(observation.snapshotGeneration(), observation.warehouseId(), + "published snapshot changed before comparison"); + inputMapper.insertComparison( + UUID.randomUUID().toString(), observation.warehouseId(), "INBOUND_COMPARE", + observation.snapshotGeneration(), + comparisonCode(observation.proposedSpaceId(), legacySpaceId), + "snapshotGeneration=" + observation.snapshotGeneration() + + "; request=" + observation.requestId() + + "; shadow=" + observation.resultCode() + + "; proposedSpace=" + nullSafe(observation.proposedSpaceId()) + + "; shadowReason=" + nullSafe(observation.reason()) + + "; legacy=" + nullSafe(legacyResultCode) + + "; legacySpace=" + nullSafe(legacySpaceId)); + }); } public void compareOutbound(OutboundShadowObservation observation, String legacyResultCode) { - transactions.executeWithoutResult(status -> inputMapper.insertComparison( - UUID.randomUUID().toString(), observation.warehouseId(), "OUTBOUND_COMPARE", - "LEGACY_" + nullSafe(legacyResultCode), - "request=" + observation.requestId() + "; shadow=" + observation.resultCode() - + "; proposedStock=" + nullSafe(observation.proposedStockId()) - + "; proposedSpace=" + nullSafe(observation.proposedSpaceId()) - + "; shadowReason=" + nullSafe(observation.reason()) - + "; legacy=" + nullSafe(legacyResultCode))); + Objects.requireNonNull(observation, "observation"); + transactions.executeWithoutResult(status -> { + requirePublishedGeneration(observation.snapshotGeneration(), observation.warehouseId(), + "published snapshot changed before comparison"); + inputMapper.insertComparison( + UUID.randomUUID().toString(), observation.warehouseId(), "OUTBOUND_COMPARE", + observation.snapshotGeneration(), "LEGACY_" + nullSafe(legacyResultCode), + "snapshotGeneration=" + observation.snapshotGeneration() + + "; request=" + observation.requestId() + + "; shadow=" + observation.resultCode() + + "; proposedStock=" + nullSafe(observation.proposedStockId()) + + "; proposedSpace=" + nullSafe(observation.proposedSpaceId()) + + "; shadowReason=" + nullSafe(observation.reason()) + + "; legacy=" + nullSafe(legacyResultCode)); + }); } - private InboundShadowObservation planInboundInsideTransaction(String taskNo, String trayNo) { - ShadowInboundReference reference = inputMapper.selectInboundInput(taskNo, trayNo); - String warehouseId = warehouseId(reference == null ? null : reference.getWarehouseId()); + private InboundShadowObservation planInboundInsideTransaction(PublishedShadowSnapshot snapshot, + String taskNo, String trayNo) { + String warehouseId = snapshot.warehouseId(); + ShadowInboundReference reference = inputMapper.selectInboundInput( + warehouseId, snapshot.generation(), taskNo, trayNo); if (reference == null || isBlank(reference.getRequestId()) || isBlank(reference.getTrayNo())) { - return InboundShadowObservation.unavailable(warehouseId, taskNo, - "SKIPPED_MISSING_INPUT", "Inbound task details were unavailable to shadow planning"); + return InboundShadowObservation.unavailable(snapshot.generation(), warehouseId, taskNo, + "SKIPPED_MISSING_INPUT", + inboundDiagnostic(snapshot, taskNo, trayNo, reference, + "Inbound task details were unavailable to shadow planning")); } InboundRequest request = new InboundRequest(reference.getRequestId(), reference.getTrayNo(), reference.getProjectNo(), reference.getProductId(), reference.getLotNo(), null, null, null, Set.of(), null); - InboundPlanningResult result = schedulingFacade.planInbound(warehouseId, request); + InboundPlanningResult result = schedulingFacade.planInboundFromPublishedSnapshot(warehouseId, request); InboundPlan plan = result.plan(); if (plan.assignment().isPresent()) { var assignment = plan.assignment().orElseThrow(); - return new InboundShadowObservation(true, warehouseId, reference.getRequestId(), - "READY", assignment.spaceId(), inboundDiagnostic(taskNo, trayNo, reference, assignment.reason())); + return new InboundShadowObservation(true, snapshot.generation(), warehouseId, + reference.getRequestId(), "READY", assignment.spaceId(), + inboundDiagnostic(snapshot, taskNo, trayNo, reference, assignment.reason())); } var rejection = plan.rejection().orElseThrow(); - return InboundShadowObservation.unavailable(warehouseId, reference.getRequestId(), - "REJECTED_" + rejection.code().name(), - inboundDiagnostic(taskNo, trayNo, reference, rejection.reason())); + return InboundShadowObservation.unavailable(snapshot.generation(), warehouseId, + reference.getRequestId(), "REJECTED_" + rejection.code().name(), + inboundDiagnostic(snapshot, taskNo, trayNo, reference, rejection.reason())); } - private OutboundShadowObservation toOutboundObservation(String warehouseId, String requestId, - ShadowStockReference reference, OutboundPlan plan) { + private OutboundShadowObservation toOutboundObservation(PublishedShadowSnapshot snapshot, + String requestId, ShadowStockReference reference, OutboundPlan plan) { if (isBlank(requestId)) { requestId = plan.assignment().map(assignment -> assignment.requestId()).orElse(null); } if (plan.assignment().isPresent()) { var assignment = plan.assignment().orElseThrow(); - return new OutboundShadowObservation(true, warehouseId, requestId, "READY", - reference.getStockId(), assignment.sourceSpaceId(), - outboundDiagnostic(requestId, reference, assignment.reason())); + return new OutboundShadowObservation(true, snapshot.generation(), snapshot.warehouseId(), + requestId, "READY", reference.getStockId(), assignment.sourceSpaceId(), + outboundDiagnostic(snapshot, requestId, reference, assignment.reason())); } if (plan.waiting().isPresent()) { var waiting = plan.waiting().orElseThrow(); - return OutboundShadowObservation.unavailable(warehouseId, requestId, - "WAITING_" + waiting.code().name(), outboundDiagnostic(requestId, reference, waiting.reason())); + return OutboundShadowObservation.unavailable(snapshot.generation(), snapshot.warehouseId(), + requestId, "WAITING_" + waiting.code().name(), + outboundDiagnostic(snapshot, requestId, reference, waiting.reason())); } var error = plan.error().orElseThrow(); - return OutboundShadowObservation.unavailable(warehouseId, requestId, - "ERROR_" + error.code().name(), outboundDiagnostic(requestId, reference, error.message())); + return OutboundShadowObservation.unavailable(snapshot.generation(), snapshot.warehouseId(), + requestId, "ERROR_" + error.code().name(), + outboundDiagnostic(snapshot, requestId, reference, error.message())); } - private String warehouseId(String preferredWarehouseId) { - String configuredWarehouseId = isBlank(properties.getWarehouseId()) + private T planWithStableSnapshot(PublishedShadowSnapshot snapshot, Supplier planner) { + validateLeasedSnapshot(snapshot); + return transactions.execute(status -> { + requirePublishedSnapshot(snapshot, "published snapshot changed during planning"); + T result = planner.get(); + requirePublishedSnapshot(snapshot, "published snapshot changed during planning"); + return result; + }); + } + + private void validateLeasedSnapshot(PublishedShadowSnapshot snapshot) { + Objects.requireNonNull(snapshot, "snapshot"); + if (snapshot.generation() <= 0) { + throw new IllegalArgumentException("leased shadow snapshot generation must be positive"); + } + if (isBlank(snapshot.warehouseId())) { + throw new IllegalArgumentException("leased shadow snapshot warehouse is required"); + } + if (!WarehouseIdNormalizer.equivalent(snapshot.warehouseId(), configuredWarehouseId())) { + throw new IllegalArgumentException("leased shadow snapshot warehouse does not match configured warehouse"); + } + requireUppercaseHexSignature(snapshot.sourceSignature(), "leased shadow snapshot source signature"); + } + + private PublishedShadowSnapshot toPublishedSnapshot(ShadowSnapshotDescriptor descriptor, + long expectedGeneration, String expectedWarehouseId) { + if (descriptor == null) { + throw new IllegalStateException("published snapshot descriptor is missing"); + } + if (!PUBLISHED.equals(descriptor.getSnapshotStatus())) { + throw new IllegalStateException("shadow snapshot descriptor must be PUBLISHED"); + } + if (descriptor.getSnapshotGeneration() != expectedGeneration) { + throw new IllegalStateException("published snapshot generation does not match refreshed generation"); + } + if (!WarehouseIdNormalizer.equivalent(descriptor.getWarehouseId(), expectedWarehouseId)) { + throw new IllegalStateException("published snapshot warehouse does not match refreshed warehouse"); + } + if (descriptor.getSynchronizedAt() == null) { + throw new IllegalStateException("published snapshot synchronization time is missing"); + } + requireUppercaseHexSignature(descriptor.getSourceSignature(), "published snapshot source signature"); + return new PublishedShadowSnapshot(descriptor.getSnapshotGeneration(), descriptor.getWarehouseId(), + descriptor.getSynchronizedAt(), descriptor.getSourceSignature()); + } + + private void requirePublishedSnapshot(PublishedShadowSnapshot snapshot, String message) { + if (!matches(snapshot, inputMapper.selectPublishedSnapshot(snapshot.warehouseId()))) { + throw new IllegalStateException(message); + } + } + + private void requirePublishedGeneration(long generation, String warehouseId, String message) { + if (generation <= 0 || isBlank(warehouseId)) { + throw new IllegalStateException(message); + } + ShadowSnapshotDescriptor descriptor = inputMapper.selectPublishedSnapshot(warehouseId); + if (descriptor == null + || !PUBLISHED.equals(descriptor.getSnapshotStatus()) + || descriptor.getSnapshotGeneration() != generation + || !WarehouseIdNormalizer.equivalent(descriptor.getWarehouseId(), warehouseId)) { + throw new IllegalStateException(message); + } + } + + private boolean matches(PublishedShadowSnapshot snapshot, ShadowSnapshotDescriptor descriptor) { + return descriptor != null + && PUBLISHED.equals(descriptor.getSnapshotStatus()) + && descriptor.getSnapshotGeneration() == snapshot.generation() + && WarehouseIdNormalizer.equivalent(descriptor.getWarehouseId(), snapshot.warehouseId()) + && Objects.equals(descriptor.getSynchronizedAt(), snapshot.synchronizedAt()) + && Objects.equals(descriptor.getSourceSignature(), snapshot.sourceSignature()); + } + + private String configuredWarehouseId() { + String warehouseId = isBlank(properties.getWarehouseId()) ? schedulingFacade.defaultWarehouseId() : properties.getWarehouseId(); - if (!isBlank(preferredWarehouseId)) { - String normalizedPreferred = preferredWarehouseId.trim(); - if (sameNumericWarehouse(normalizedPreferred, configuredWarehouseId)) { - return configuredWarehouseId; - } - return normalizedPreferred; + if (isBlank(warehouseId)) { + throw new IllegalStateException("shadow warehouse is not configured"); } - return configuredWarehouseId; + return warehouseId.trim(); } - private boolean sameNumericWarehouse(String first, String second) { - if (isBlank(first) || isBlank(second)) { - return false; - } - try { - return Integer.parseInt(first) == Integer.parseInt(second); - } catch (NumberFormatException ignored) { - return false; - } + private IllegalStateException leasedSnapshotRequired() { + return new IllegalStateException("published shadow snapshot lease is required"); } - private String outboundDiagnostic(String requestId, ShadowStockReference reference, String resultReason) { - return "detail=" + nullSafe(requestId) - + "; project=" + nullSafe(reference.getProjectNo()) - + "; product=" + nullSafe(reference.getProductId()) - + "; tray=" + nullSafe(reference.getTrayNo()) - + "; stock=" + nullSafe(reference.getStockId()) - + "; snapshotSync=" + nullSafe(reference.getSnapshotSynchronizedAt()) - + "; legacyMatches=" + numberOrUnknown(reference.getLiveMatchCount()) - + "; shadowMatches=" + numberOrUnknown(reference.getShadowMatchCount()) + private String outboundDiagnostic(PublishedShadowSnapshot snapshot, String requestId, + ShadowStockReference reference, String resultReason) { + return snapshotDiagnostic(snapshot) + + "; detail=" + nullSafe(requestId) + + "; rawWarehouse=" + nullSafe(reference == null ? null : reference.getWarehouseId()) + + "; project=" + nullSafe(reference == null ? null : reference.getProjectNo()) + + "; product=" + nullSafe(reference == null ? null : reference.getProductId()) + + "; tray=" + nullSafe(reference == null ? null : reference.getTrayNo()) + + "; stock=" + nullSafe(reference == null ? null : reference.getStockId()) + + "; space=" + nullSafe(reference == null ? null : reference.getSpaceId()) + + "; requestedQuantity=" + objectOrEmpty( + reference == null ? null : reference.getRequestedQuantity()) + + "; inputSnapshotSync=" + nullSafe( + reference == null ? null : reference.getSnapshotSynchronizedAt()) + + "; shadowMatches=" + numberOrUnknown( + reference == null ? null : reference.getShadowMatchCount()) + "; reason=" + abbreviate(nullSafe(resultReason), 600); } - private String inboundDiagnostic(String taskNo, String trayNo, ShadowInboundReference reference, - String resultReason) { - return "task=" + nullSafe(taskNo) + private String inboundDiagnostic(PublishedShadowSnapshot snapshot, String taskNo, String trayNo, + ShadowInboundReference reference, String resultReason) { + return snapshotDiagnostic(snapshot) + + "; task=" + nullSafe(taskNo) + "; tray=" + nullSafe(trayNo) - + "; request=" + nullSafe(reference.getRequestId()) - + "; warehouse=" + nullSafe(reference.getWarehouseId()) - + "; project=" + nullSafe(reference.getProjectNo()) - + "; product=" + nullSafe(reference.getProductId()) - + "; lot=" + nullSafe(reference.getLotNo()) + + "; request=" + nullSafe(reference == null ? null : reference.getRequestId()) + + "; rawWarehouse=" + nullSafe(reference == null ? null : reference.getWarehouseId()) + + "; project=" + nullSafe(reference == null ? null : reference.getProjectNo()) + + "; product=" + nullSafe(reference == null ? null : reference.getProductId()) + + "; lot=" + nullSafe(reference == null ? null : reference.getLotNo()) + + "; shadowCandidates=" + numberOrUnknown( + reference == null ? null : reference.getShadowCandidateCount()) + "; reason=" + abbreviate(nullSafe(resultReason), 600); } + private String snapshotDiagnostic(PublishedShadowSnapshot snapshot) { + return "snapshotGeneration=" + snapshot.generation() + + "; snapshotSync=" + instantOrEmpty(snapshot.synchronizedAt()) + + "; sourceSignature=" + nullSafe(snapshot.sourceSignature()); + } + + private void requireUppercaseHexSignature(String value, String description) { + if (isBlank(value) || !value.matches("[0-9A-F]+")) { + throw new IllegalStateException(description + " must be uppercase hexadecimal"); + } + } + private String numberOrUnknown(Integer value) { return value == null ? "" : value.toString(); } + private String objectOrEmpty(Object value) { + return value == null ? "" : value.toString(); + } + + private String instantOrEmpty(Instant value) { + return value == null ? "" : value.toString(); + } + private String abbreviate(String value, int maxLength) { return value.length() <= maxLength ? value : value.substring(0, maxLength); } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSnapshotDescriptor.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSnapshotDescriptor.java new file mode 100644 index 0000000..2e7d05d --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSnapshotDescriptor.java @@ -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; + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowStockReference.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowStockReference.java index 4ff57a1..fad13ab 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowStockReference.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowStockReference.java @@ -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() { diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java index 8ec1fcc..0599ab7 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java @@ -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); } diff --git a/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml b/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml index 7a7df18..c4463e0 100644 --- a/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml +++ b/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml @@ -6,77 +6,98 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - + 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} + + + + + exec alg.usp_refresh_shadow_snapshot + @warehouse_id = #{warehouseId}, + @snapshot_generation = #{generation}, + @instance_id = #{instanceId}; + + + @@ -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}) diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java index 68fef5a..65158b3 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java @@ -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 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 statementsById(Document document) { + Map 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 {