feat: plan shadow decisions from leased generation

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

View File

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