fix(shadow): capture and validate inbound task evidence for v3.4

This commit is contained in:
zgw
2026-09-08 10:37:14 +08:00
parent 699310e61d
commit c387ba0b50
17 changed files with 1552 additions and 10 deletions

View File

@@ -0,0 +1,183 @@
package com.ruoyi.algorithm.shadow;
import com.ruoyi.algorithm.shadow.mapper.ShadowInputMapper;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class ShadowInboundEligibilityTest {
private JdbcTemplate jdbc;
private SqlSessionFactory sessions;
@BeforeEach
void prepare() throws Exception {
var dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MSSQLServer;DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(dataSource);
jdbc.execute("create schema alg");
jdbc.execute("""
create table alg.shadow_inbound_request_snapshot (
warehouse_id varchar(80), snapshot_generation bigint, task_no varchar(80), tray_no varchar(80),
request_id varchar(80), raw_warehouse_id varchar(80), product_id varchar(255), lot_no varchar(80),
project_no varchar(255), spare1 varchar(255), spare2 varchar(255), spare3 varchar(255),
fault_flag varchar(255), detail_count int, field_group_count int, product_scope_count int,
tray_job_count int, legacy_scope_complete int, predicate_version int)
""");
jdbc.execute("""
create table alg.cargo_space_snapshot (
warehouse_id varchar(80), snapshot_generation bigint, space_id varchar(80),
lane_code varchar(80), floor_no varchar(80), space_status varchar(20), locked varchar(20),
tray_code varchar(80), this_cargo_area varchar(255), cargo_area_id varchar(80))
""");
jdbc.execute("""
create table alg.inventory_item_snapshot (
warehouse_id varchar(80), snapshot_generation bigint, stock_id varchar(80), space_id varchar(80),
tray_code varchar(80), project_no varchar(255), spare1 varchar(255), spare2 varchar(255), spare3 varchar(255))
""");
jdbc.execute("create table alg.lane_state (warehouse_id varchar(80), snapshot_generation bigint, available_count int)");
jdbc.update("""
insert into alg.shadow_inbound_request_snapshot values
('01',42,'31331','TRAY','HPR-1','01','P1','LOT','PROJECT','201','','','0',1,1,1,1,1,1)
""");
space("TARGET", "LANE", "1", "1", "N", null, "C");
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), dataSource));
try (var stream = getClass().getClassLoader().getResourceAsStream("mapper/algorithm/ShadowInputMapper.xml")) {
new XMLMapperBuilder(stream, configuration, "mapper/algorithm/ShadowInputMapper.xml",
configuration.getSqlFragments()).parse();
}
sessions = new SqlSessionFactoryBuilder().build(configuration);
}
@AfterEach
void close() { jdbc.execute("shutdown"); }
@Test
void emptyProjectLaneIsEligibleAndInputFieldsSurviveMapping() {
assertThat(eligible()).isEqualTo(1);
try (var session = sessions.openSession()) {
var input = session.getMapper(ShadowInputMapper.class).selectInboundInput("01", 42, "31331", "TRAY");
assertThat(input.getProjectNo()).isEqualTo("PROJECT");
assertThat(input.getSpare1()).isEqualTo("201");
assertThat(input.getFaultFlag()).isEqualTo("0");
assertThat(input.getFieldGroupCount()).isEqualTo(1);
assertThat(input.getPredicateVersion()).isEqualTo(1);
}
}
@Test
void candidateMustBeEmptyUnlockedAndPhysicallyVacant() {
for (String state : new String[]{"2", "3", "4"}) {
jdbc.update("update alg.cargo_space_snapshot set space_status=?", state);
assertThat(eligible()).isZero();
}
jdbc.update("update alg.cargo_space_snapshot set space_status='1',locked='Y'");
assertThat(eligible()).isZero();
jdbc.update("update alg.cargo_space_snapshot set locked='N',tray_code='ORPHAN'");
assertThat(eligible()).isZero();
jdbc.update("update alg.cargo_space_snapshot set tray_code=null");
stock("TARGET", "ORPHAN");
assertThat(eligible()).isZero();
}
@Test
void actualAreaIsNotCargoAreaId() {
jdbc.update("update alg.cargo_space_snapshot set cargo_area_id='C',this_cargo_area='B'");
assertThat(eligible()).isZero();
jdbc.update("update alg.cargo_space_snapshot set cargo_area_id='B',this_cargo_area='A'");
assertThat(eligible()).isEqualTo(1);
}
@Test
void inboundWorkElsewhereOnSameFloorBlocksProjectCandidate() {
space("BUSY", "OTHER", "1", "3", "N", null, "C");
assertThat(eligible()).isZero();
jdbc.update("update alg.cargo_space_snapshot set floor_no='2' where space_id='BUSY'");
assertThat(eligible()).isEqualTo(1);
}
@Test
void busyOrLockedLaneIsNotAnEmptyLane() {
space("BUSY", "LANE", "1", "4", "N", "RESIDENT", "C");
stock("BUSY", "RESIDENT");
assertThat(eligible()).isZero();
jdbc.update("update alg.cargo_space_snapshot set space_status='2',locked='Y' where space_id='BUSY'");
assertThat(eligible()).isZero();
}
@Test
void continuationRequiresRawSpareFieldsProjectAndTrayAlignment() {
space("RESIDENT", "LANE", "1", "2", "N", "OLD", "C");
stock("RESIDENT", "OLD");
assertThat(eligible()).isEqualTo(1);
for (String field : new String[]{"spare1", "spare2", "spare3", "project_no", "tray_code"}) {
String original = jdbc.queryForObject("select " + field + " from alg.inventory_item_snapshot", String.class);
jdbc.update("update alg.inventory_item_snapshot set " + field + "='WRONG'");
assertThat(eligible()).as(field).isZero();
jdbc.update("update alg.inventory_item_snapshot set " + field + "=?", original);
}
}
@Test
void nullSpareIsNotConvertedToEmptyForContinuation() {
space("RESIDENT", "LANE", "1", "2", "N", "OLD", "C");
stock("RESIDENT", "OLD");
jdbc.update("update alg.inventory_item_snapshot set spare2=null");
assertThat(eligible()).isZero();
}
@Test
void olderGenerationCannotSupplyCandidateOrResidentEvidence() {
jdbc.update("update alg.cargo_space_snapshot set snapshot_generation=41");
assertThat(eligible()).isZero();
jdbc.update("update alg.cargo_space_snapshot set snapshot_generation=42");
space("RESIDENT", "LANE", "1", "2", "N", "OLD", "C");
stock("RESIDENT", "OLD");
jdbc.update("update alg.inventory_item_snapshot set snapshot_generation=41");
assertThat(eligible()).isZero();
}
@Test
void ambiguousInputAndOldPredicateVersionNeverPassSqlGuard() {
for (String field : new String[]{"predicate_version", "field_group_count", "product_scope_count",
"tray_job_count", "legacy_scope_complete"}) {
jdbc.update("update alg.shadow_inbound_request_snapshot set " + field + "=0");
assertThat(eligible()).as(field).isZero();
jdbc.update("update alg.shadow_inbound_request_snapshot set " + field + "=1");
}
}
@Test
void unknownLaneOrFloorDoesNotPassValidation() {
jdbc.update("update alg.cargo_space_snapshot set lane_code=null");
assertThat(eligible()).isZero();
jdbc.update("update alg.cargo_space_snapshot set lane_code='LANE',floor_no=null");
assertThat(eligible()).isZero();
}
private int eligible() {
try (var session = sessions.openSession()) {
return session.getMapper(ShadowInputMapper.class).countInboundEligibleSpace("01", 42, "31331", "TRAY", "TARGET");
}
}
private void space(String id, String lane, String floor, String status, String locked, String tray, String area) {
jdbc.update("insert into alg.cargo_space_snapshot values ('01',42,?,?,?,?,?,?,?,'UNRELATED')",
id, lane, floor, status, locked, tray, area);
}
private void stock(String space, String tray) {
jdbc.update("insert into alg.inventory_item_snapshot values ('01',42,'STOCK',?,?,'PROJECT','201','','')", space, tray);
}
}

View File

@@ -0,0 +1,187 @@
package com.ruoyi.algorithm.shadow;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.jdbc.core.JdbcTemplate;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class ShadowInboundProjectionTest {
private JdbcTemplate jdbc;
@BeforeEach
void createFixture() {
JdbcDataSource dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MSSQLServer;DB_CLOSE_DELAY=-1");
jdbc = new JdbcTemplate(dataSource);
jdbc.execute("create schema dbo");
jdbc.execute("create schema alg");
jdbc.execute("""
create table dbo.mydo_job (job_id varchar(80), task_no varchar(80), tray_code varchar(80),
warehouse_id varchar(80), finish_time varchar(80), in_or_out int, line_id varchar(255),
fault_flag varchar(255), cargo_space_id varchar(80))
""");
jdbc.execute("""
create table dbo.mydo_job_detail (job_detail_id varchar(80), job_id varchar(80),
stockin_detail_id varchar(80), in_or_out_id varchar(80),
spare1 varchar(255), spare2 varchar(255), spare3 varchar(255),
product_id varchar(255), lot_no varchar(80))
""");
jdbc.execute("""
create table dbo.in_stockin_detail (stockin_detail_id varchar(80), stockin_id varchar(80),
spare4 varchar(255), product_id varchar(255), lot_no varchar(80))
""");
jdbc.execute("create table dbo.in_stockin (stockin_id varchar(80), warehouse_id varchar(80))");
jdbc.execute("create table dbo.bas_cargo_space (warehouse_id varchar(80))");
jdbc.update("insert into dbo.bas_cargo_space values ('01')");
jdbc.execute("""
create table alg.shadow_inbound_request_snapshot (
warehouse_id varchar(80), task_no varchar(80), tray_no varchar(255), request_id varchar(80),
raw_warehouse_id varchar(80), project_no varchar(255), product_id varchar(255), lot_no varchar(80),
snapshot_generation bigint, synchronized_at datetime2,
spare1 varchar(255), spare2 varchar(255), spare3 varchar(255), fault_flag varchar(255),
detail_count int, field_group_count int, product_scope_count int, tray_job_count int,
legacy_scope_complete int, predicate_version int)
""");
jdbc.update("insert into dbo.mydo_job values ('HPR-1','31331','L0000282','01',null,2,'PROJECT-JOB','0',null)");
detail("1", "201", "P1", null, "");
}
@AfterEach
void closeFixture() {
jdbc.execute("shutdown");
}
@Test
void taskWithoutInboundDocumentIsStillCapturedWithJobIdentityAndProject() throws Exception {
refreshInbound();
var rows = jdbc.queryForList("select * from alg.shadow_inbound_request_snapshot");
assertThat(rows).hasSize(1);
assertThat(rows.get(0)).containsEntry("REQUEST_ID", "HPR-1")
.containsEntry("PROJECT_NO", "PROJECT-JOB")
.containsEntry("SPARE1", "201").containsEntry("FAULT_FLAG", "0")
.containsEntry("DETAIL_COUNT", 1).containsEntry("PREDICATE_VERSION", 1);
}
@Test
void linkedDocumentDoesNotOverrideTheActualLegacyTaskFields() throws Exception {
jdbc.update("update dbo.mydo_job_detail set stockin_detail_id='RKD-1',in_or_out_id='RKD'");
jdbc.update("insert into dbo.in_stockin_detail values ('RKD-1','RKD','WRONG-PROJECT','WRONG-PRODUCT','WRONG-LOT')");
jdbc.update("insert into dbo.in_stockin values ('RKD','1')");
refreshInbound();
var input = jdbc.queryForMap("select * from alg.shadow_inbound_request_snapshot");
assertThat(input).containsEntry("PROJECT_NO", "PROJECT-JOB")
.containsEntry("PRODUCT_ID", "P1").containsEntry("LOT_NO", "LOT")
.containsEntry("REQUEST_ID", "HPR-1");
}
@Test
void mixedDetailFieldsAreCountedInsteadOfHiddenByFirstRowSelection() throws Exception {
detail("2", "", "P2", "ARCHIVED-DOCUMENT", null);
refreshInbound();
var input = jdbc.queryForMap("select * from alg.shadow_inbound_request_snapshot");
assertThat(input).containsEntry("DETAIL_COUNT", 2).containsEntry("FIELD_GROUP_COUNT", 2)
.containsEntry("PRODUCT_SCOPE_COUNT", 2);
}
@Test
void nullAndEmptyGroupingValuesRemainDifferent() throws Exception {
detail("2", null, "P1", null, null);
refreshInbound();
assertThat(jdbc.queryForObject("select field_group_count from alg.shadow_inbound_request_snapshot", Integer.class))
.isEqualTo(2);
}
@Test
void multipleProductsWithSameLegacyGroupingStillHaveExplicitScope() throws Exception {
detail("2", "201", "P2", null, null);
refreshInbound();
var input = jdbc.queryForMap("select * from alg.shadow_inbound_request_snapshot");
assertThat(input).containsEntry("DETAIL_COUNT", 2).containsEntry("FIELD_GROUP_COUNT", 1)
.containsEntry("PRODUCT_SCOPE_COUNT", 2);
}
@Test
void missingDetailsAreCapturedForDiagnosticsNotInvented() throws Exception {
jdbc.execute("delete from dbo.mydo_job_detail");
refreshInbound();
var input = jdbc.queryForMap("select * from alg.shadow_inbound_request_snapshot");
assertThat(input).containsEntry("DETAIL_COUNT", 0).containsEntry("FIELD_GROUP_COUNT", 0);
}
@Test
void completedOutboundAndOtherWarehouseJobsAreNotInboundInputs() throws Exception {
jdbc.update("insert into dbo.mydo_job values ('DONE','2','T2','01','2026-09-07',2,'P','0',null)");
jdbc.update("insert into dbo.mydo_job values ('OUT','3','T3','01',null,20,'P','0',null)");
jdbc.update("insert into dbo.mydo_job values ('OTHER','4','T4','02',null,2,'P','0',null)");
refreshInbound();
assertThat(jdbc.queryForList("select request_id from alg.shadow_inbound_request_snapshot", String.class))
.containsExactly("HPR-1");
}
@Test
void duplicateTrayInAnotherWarehouseCannotLookUnambiguous() throws Exception {
jdbc.update("insert into dbo.mydo_job values ('OTHER','4','L0000282','02',null,2,'P','0',null)");
refreshInbound();
assertThat(jdbc.queryForObject("select tray_job_count from alg.shadow_inbound_request_snapshot", Integer.class))
.isEqualTo(2);
}
@Test
void oversizedLegacyFieldIsPreservedButNotValidated() throws Exception {
jdbc.update("update dbo.mydo_job_detail set spare1=?", "X".repeat(81));
refreshInbound();
var input = jdbc.queryForMap("select * from alg.shadow_inbound_request_snapshot");
assertThat(input).containsEntry("SPARE1", "X".repeat(81)).containsEntry("PREDICATE_VERSION", 0);
}
@ParameterizedTest
@NullSource
@ValueSource(strings = {"02", "1"})
void cargoOutsideSnapshotScopeMakesGlobalLegacyValidationUnavailable(String warehouse) throws Exception {
jdbc.update("insert into dbo.bas_cargo_space values (?)", warehouse);
refreshInbound();
assertThat(jdbc.queryForObject("select legacy_scope_complete from alg.shadow_inbound_request_snapshot", Integer.class))
.isZero();
}
private void detail(String id, String spare1, String product, String link, String document) {
jdbc.update("insert into dbo.mydo_job_detail values (?,'HPR-1',?,?,?,'','',?,'LOT')",
id, link, document, spare1, product);
}
private void refreshInbound() throws Exception {
String resource = System.getProperty("inbound.migration", "/db/sqlserver/upgrade-shadow-v33-to-v34.sql");
try (var stream = getClass().getResourceAsStream(resource)) {
assertThat(stream).isNotNull();
String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8).replace("''", "'");
int insert = sql.indexOf("INSERT INTO alg.shadow_inbound_request_snapshot (");
int start = sql.lastIndexOf(";WITH", insert);
int end = sql.indexOf("INSERT INTO alg.shadow_outbound_request_snapshot (", insert);
assertThat(start).isGreaterThan(0);
// Only translate SQL Server syntax. Execute the migration's actual projection/predicates.
sql = sql.substring(start, end).replace("WITH (NOLOCK)", "")
.replace("@warehouse_id", "'01'").replace("@snapshot_generation", "42")
.replace("@synchronized_at", "TIMESTAMP '2026-09-07 08:20:17'")
.replace("@numeric_warehouse_id", "1")
.replace("DATALENGTH(", "OCTET_LENGTH(")
.replace("TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockin.warehouse_id)))",
"CAST(LTRIM(RTRIM(stockin.warehouse_id)) AS decimal(38,0))")
.replace("TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(job.warehouse_id)))",
"CAST(LTRIM(RTRIM(job.warehouse_id)) AS decimal(38,0))");
// H2 accepts INSERT ... WITH ... SELECT, SQL Server requires WITH ... INSERT ... SELECT.
int insertStart = sql.indexOf("INSERT INTO");
int selectStart = sql.indexOf("SELECT", insertStart);
jdbc.execute(sql.substring(insertStart, selectStart) + sql.substring(1, insertStart)
+ sql.substring(selectStart));
}
}
}

View File

@@ -0,0 +1,158 @@
package com.ruoyi.algorithm.shadow;
import com.ruoyi.algorithm.inbound.InboundAssignment;
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.repository.VersionToken;
import com.ruoyi.algorithm.service.InboundPlanningResult;
import com.ruoyi.algorithm.shadow.mapper.ShadowInputMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.SimpleTransactionStatus;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ShadowInboundValidationTest {
private final PublishedShadowSnapshot snapshot = new PublishedShadowSnapshot(
42, "01", Instant.parse("2026-09-07T08:20:17Z"), "ABC123");
private ShadowInputMapper mapper;
private SchedulingFacade facade;
private ShadowPlanningEngine engine;
private ShadowInboundReference reference;
@BeforeEach
void prepare() {
mapper = mock(ShadowInputMapper.class);
facade = mock(SchedulingFacade.class);
var transactions = mock(PlatformTransactionManager.class);
when(transactions.getTransaction(any())).thenReturn(new SimpleTransactionStatus());
var properties = new ShadowObservationProperties();
properties.setWarehouseId("01");
engine = new ShadowPlanningEngine(facade, mapper, properties, transactions);
var descriptor = new ShadowSnapshotDescriptor();
descriptor.setWarehouseId("01");
descriptor.setSnapshotGeneration(42);
descriptor.setSnapshotStatus("PUBLISHED");
descriptor.setSynchronizedAt(snapshot.synchronizedAt());
descriptor.setSourceSignature("ABC123");
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor);
reference = new ShadowInboundReference();
reference.setRequestId("HPR-1");
reference.setTrayNo("TRAY-1");
reference.setWarehouseId("01");
reference.setProjectNo("PROJECT-JOB");
reference.setProductId("PRODUCT-1");
reference.setLotNo("LOT");
reference.setSpare1("201");
reference.setSpare2("");
reference.setSpare3("");
reference.setFaultFlag("0");
reference.setPredicateVersion(1);
reference.setDetailCount(1);
reference.setFieldGroupCount(1);
reference.setProductScopeCount(1);
reference.setTrayJobCount(1);
reference.setLegacyScopeComplete(1);
when(mapper.selectInboundInput("01", 42, "31331", "TRAY-1")).thenReturn(reference);
when(mapper.countInboundEligibleSpace("01", 42, "31331", "TRAY-1", "SPACE-9")).thenReturn(1);
var assignment = new InboundAssignment("HPR-1", "SPACE-9", "LANE", 1, 1,
InboundLadderRung.CONTINUE, InboundBorrowType.NONE, null,
1, 2, 3, 0, false, false, "fixture proposal");
when(facade.planInboundFromPublishedSnapshot(eq("01"), any()))
.thenReturn(new InboundPlanningResult(InboundPlan.assigned(assignment), VersionToken.numeric(8)));
when(mapper.insertComparison(any(), any(), any(), anyLong(), any(), any(), any(), any())).thenReturn(1);
}
@Test
void oldSnapshotCannotBecomeValidatedEvidence() {
reference.setPredicateVersion(null);
assertSkipped("SKIPPED_MISSING_PREDICATE_INPUT");
}
@Test
void noDetailsAreNotReportedAsReady() {
reference.setDetailCount(0);
assertSkipped("SKIPPED_MISSING_TASK_DETAILS");
}
@Test
void heterogeneousSpareFieldsCannotImpersonateLegacyUnorderedTopOne() {
reference.setDetailCount(45);
reference.setFieldGroupCount(2);
reference.setProductScopeCount(45);
assertSkipped("SKIPPED_AMBIGUOUS_LEGACY_FIELDS");
}
@Test
void multiProductTrayDoesNotSilentlyUseOneRepresentativeProduct() {
reference.setProductScopeCount(2);
assertSkipped("SKIPPED_MULTI_PRODUCT_INPUT");
}
@Test
void duplicateJobsPreventUnambiguousLegacyComparison() {
reference.setTrayJobCount(2);
assertSkipped("SKIPPED_AMBIGUOUS_TRAY_JOB");
}
@Test
void incompleteGlobalLegacyScopeIsNotClaimedEquivalent() {
reference.setLegacyScopeComplete(0);
assertSkipped("SKIPPED_INCOMPLETE_LEGACY_SCOPE");
}
@Test
void commonProjectNeedsExplicitPolicyMappingNotAnInventedProject() {
for (String project : new String[]{null, "", "999", "COMMON"}) {
reference.setProjectNo(project);
assertSkipped("SKIPPED_UNSUPPORTED_COMMON_PROJECT");
}
}
@Test
void validatedProposalUsesTaskProjectAndPreservesFieldEvidence() {
var result = plan();
assertThat(result.resultCode()).isEqualTo("READY");
assertThat(result.reason()).contains("inboundPredicateVersion=1", "spare1=201", "faultFlag=0",
"detailCount=1", "fieldGroupCount=1", "productScopeCount=1", "legacyEligibility=STRICT_GROUP_OR_EMPTY");
verify(facade).planInboundFromPublishedSnapshot(eq("01"), argThat(request ->
request.requestId().equals("HPR-1") && request.projectNo().equals("PROJECT-JOB")));
verify(facade, never()).planInbound(any(), any());
}
@Test
void ineligibleProposalCannotBecomeMatchEvenWhenSameAsLegacy() {
when(mapper.countInboundEligibleSpace("01", 42, "31331", "TRAY-1", "SPACE-9")).thenReturn(0);
var result = plan();
assertThat(result.resultCode()).isEqualTo("SKIPPED_LEGACY_CONSTRAINT");
assertThat(result.proposedSpaceId()).isNull();
assertThat(result.reason()).contains("unverifiedProposal=SPACE-9");
engine.compareInbound(result, "10", "SPACE-9");
verify(mapper).insertComparison(any(), eq("01"), eq("INBOUND_COMPARE"), eq(42L), any(), any(),
eq("NOT_COMPARABLE"), any());
}
@Test
void failedLegacyAllocationCannotBecomeMatch() {
engine.compareInbound(plan(), "2", "SPACE-9");
verify(mapper).insertComparison(any(), eq("01"), eq("INBOUND_COMPARE"), eq(42L), any(), any(),
eq("NOT_COMPARABLE"), any());
}
private InboundShadowObservation plan() { return engine.planInbound(snapshot, "31331", "TRAY-1"); }
private void assertSkipped(String code) {
var result = plan();
assertThat(result.resultCode()).isEqualTo(code);
assertThat(result.observed()).isTrue(); // The skip itself is recorded, not a usable proposal.
assertThat(result.proposedSpaceId()).isNull();
verify(facade, never()).planInboundFromPublishedSnapshot(any(), any());
}
}

View File

@@ -49,9 +49,10 @@ class ShadowPlanningEngineTest {
CapturingTransactionManager transactions = new CapturingTransactionManager();
ShadowInputMapper mapper = mock(ShadowInputMapper.class);
SchedulingFacade facade = mock(SchedulingFacade.class);
ShadowInboundReference input = inboundReference("W02");
ShadowInboundReference input = inboundReference("1");
input.setShadowCandidateCount(3);
when(mapper.selectInboundInput("01", 42L, "TASK-1", "TRAY-1")).thenReturn(input);
when(mapper.countInboundEligibleSpace("01", 42L, "TASK-1", "TRAY-1", "SPACE-9")).thenReturn(1);
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT));
when(facade.planInboundFromPublishedSnapshot(eq("01"), any()))
.thenReturn(new InboundPlanningResult(InboundPlan.assigned(inboundAssignment()),
@@ -72,7 +73,7 @@ class ShadowPlanningEngineTest {
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",
"request=JOB-1", "rawWarehouse=1", "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());
@@ -171,7 +172,7 @@ class ShadowPlanningEngineTest {
true, 42L, "01", SNAPSHOT.synchronizedAt(), SNAPSHOT.sourceSignature(),
"JOB-1", "READY", "SPACE-9", "shadow diagnostic");
engine.compareInbound(observation, "READY", "SPACE-9");
engine.compareInbound(observation, "10", "SPACE-9");
verify(mapper).insertComparison(any(), eq("01"), eq("INBOUND_COMPARE"), eq(42L),
eq(SNAPSHOT.synchronizedAt()), eq("ABC123"), eq("MATCH"),
@@ -375,6 +376,12 @@ class ShadowPlanningEngineTest {
input.setProjectNo("PROJECT-1");
input.setProductId("PRODUCT-1");
input.setLotNo("LOT-1");
input.setPredicateVersion(1);
input.setDetailCount(1);
input.setFieldGroupCount(1);
input.setProductScopeCount(1);
input.setTrayJobCount(1);
input.setLegacyScopeComplete(1);
return input;
}

View File

@@ -0,0 +1,117 @@
package com.ruoyi.algorithm.shadow;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.jdbc.core.JdbcTemplate;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class ShadowSchemaCheckTest {
private JdbcTemplate jdbc;
private String validation;
@BeforeEach
void createCatalogFixture() throws Exception {
JdbcDataSource source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:" + UUID.randomUUID()
+ ";MODE=MSSQLServer;DB_CLOSE_DELAY=-1;NON_KEYWORDS=PRECISION,SCALE");
jdbc = new JdbcTemplate(source);
jdbc.execute("create schema sys");
jdbc.execute("""
create table sys.columns (object_id varchar(150), name varchar(80), column_id int,
user_type_id varchar(80), max_length int, precision int, scale int, is_nullable int)
""");
jdbc.execute("""
create table expected_columns (table_name varchar(80), column_name varchar(80),
type_name varchar(80), max_length int, precision_value int, scale_value int)
""");
for (String table : new String[]{"inventory_item_snapshot", "shadow_outbound_request_snapshot"}) {
for (String column : new String[]{"spare1", "spare2", "spare3", "voltage", "phase"}) {
column(table, column, "varchar", 255, 0, 0);
}
}
column("inventory_item_snapshot", "tray_job_count", "int", 4, 10, 0);
column("shadow_outbound_request_snapshot", "predicate_version", "int", 4, 10, 0);
column("shadow_outbound_request_snapshot", "allocated_quantity", "decimal", 17, 38, 6);
column("cargo_space_snapshot", "this_cargo_area", "varchar", 255, 0, 0);
for (String name : new String[]{"spare1", "spare2", "spare3", "fault_flag"}) {
column("shadow_inbound_request_snapshot", name, "varchar", 255, 0, 0);
}
for (String name : new String[]{"detail_count", "field_group_count", "product_scope_count",
"tray_job_count", "legacy_scope_complete", "predicate_version"}) {
column("shadow_inbound_request_snapshot", name, "int", 4, 10, 0);
}
Path root = Path.of(System.getProperty("user.dir")).toAbsolutePath();
while (root != null && !Files.isDirectory(root.resolve("scripts"))) root = root.getParent();
assertThat(root).isNotNull();
String sql = Files.readString(root.resolve("scripts/shadow-v34-schema-check.sql")).replace("\r\n", "\n");
int insertStart = sql.indexOf("INSERT INTO @columns VALUES");
jdbc.execute(sql.substring(insertStart, sql.indexOf(';', insertStart) + 1)
.replace("@columns", "expected_columns"));
int selectStart = sql.indexOf("SELECT 1 FROM @columns expected");
validation = sql.substring(selectStart, sql.indexOf(")\n THROW 51074", selectStart))
.replace("@columns", "expected_columns")
// Model catalog functions with fixture identifiers; retain the production predicate.
.replace("OBJECT_ID(N'alg.' + expected.table_name)", "('alg.' + expected.table_name)")
.replace("TYPE_NAME(column_info.user_type_id)", "column_info.user_type_id");
}
@AfterEach
void closeCatalogFixture() {
if (jdbc != null) jdbc.execute("shutdown");
}
@Test
void completeCompatibleCatalogHasNoViolations() {
assertThat(jdbc.queryForList(validation)).isEmpty();
}
@ParameterizedTest
@ValueSource(strings = {"spare1", "spare2", "spare3", "fault_flag", "detail_count", "field_group_count",
"product_scope_count", "tray_job_count", "legacy_scope_complete", "predicate_version"})
void missingInboundColumnIsRejected(String name) {
jdbc.update("delete from sys.columns where object_id='alg.shadow_inbound_request_snapshot' and name=?", name);
assertThat(jdbc.queryForList(validation)).hasSize(1);
}
@ParameterizedTest
@ValueSource(strings = {"user_type_id='nvarchar'", "max_length=80", "precision=1", "scale=1", "is_nullable=0"})
void incompatibleColumnMetadataIsRejected(String change) {
jdbc.execute("update sys.columns set " + change + " where object_id='alg.cargo_space_snapshot'");
assertThat(jdbc.queryForList(validation)).hasSize(1);
}
@Test
void missingV33PrerequisiteIsRejected() {
jdbc.execute("delete from sys.columns where object_id='alg.inventory_item_snapshot' and name='spare2'");
assertThat(jdbc.queryForList(validation)).hasSize(1);
}
@ParameterizedTest
@ValueSource(strings = {"-- INBOUND_JOB_FIELDS_V1", "-- INBOUND_JOB_FIELDS_V1 field_group_count"})
void repeatedUpgradeRejectsProcedureWithIncompleteVersionEvidence(String definition) throws Exception {
try (var stream = getClass().getResourceAsStream("/db/sqlserver/upgrade-shadow-v33-to-v34.sql")) {
assertThat(stream).isNotNull();
String sql = new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
int start = sql.indexOf("IF @installed_definition IS NULL") + 3;
int end = sql.indexOf("THROW 51071", start);
String predicate = sql.substring(start, end)
.replace("@installed_definition", "'" + definition + "'");
assertThat(jdbc.queryForObject("select case when " + predicate + " then 1 else 0 end", Integer.class))
.isEqualTo(1);
}
}
private void column(String table, String name, String type, int length, int precision, int scale) {
jdbc.update("insert into sys.columns values (?,?,1,?,?,?,?,1)",
"alg." + table, name, type, length, precision, scale);
}
}

View File

@@ -125,6 +125,40 @@ class ShadowV3OperationsPackageTest {
assertThat(failure.output()).doesNotContain("INSTALL_OK");
}
@Test
void preflightCannotApprovePackageWhenSchemaValidationFails() throws Exception {
Assumptions.assumeTrue(isWindows(), "CMD behavior is verified on Windows");
Path fixture = Files.createDirectories(tempDir.resolve("schema guard fixture"));
Path repo = fixture.resolve("repo with spaces");
cloneRepository(repo);
Path bin = writeSqlcmd13Fixture(fixture);
Path script = root.resolve("scripts/shadow-v3-preflight.cmd");
CmdResult failed = runCmd(script, bin, repo.toString(), gitHead(repo), "SERVER", "FAIL_SCHEMA");
assertThat(failed.exitCode()).as(failed.output()).isEqualTo(1);
assertThat(failed.output()).contains("schema validation failed").doesNotContain("PREFLIGHT_OK");
CmdResult valid = runCmd(script, bin, repo.toString(), gitHead(repo), "SERVER", "FT546-wms");
assertThat(valid.exitCode()).as(valid.output()).isZero();
assertThat(valid.output()).contains("FIXTURE_INPUT=shadow-v34-schema-check.sql", "PREFLIGHT_OK");
}
@Test
void v34InstallerSupportsDirectUpgradeAndPropagatesLastStageFailure() throws Exception {
Assumptions.assumeTrue(isWindows(), "CMD behavior is verified on Windows");
Path bin = writeSqlcmd13Fixture(Files.createDirectories(tempDir.resolve("v34 fixture")));
Path script = root.resolve("scripts/shadow-v3-install.cmd");
CmdResult direct = runCmd(script, bin, "SERVER", "FT546-wms", "v34");
assertThat(direct.exitCode()).as(direct.output()).isZero();
assertThat(direct.output()).contains("FIXTURE_INPUT=upgrade-shadow-v33-to-v34.sql", "INSTALL_OK")
.doesNotContain("FIXTURE_INPUT=upgrade-shadow-v3-to-v33.sql", "FIXTURE_INPUT=install-shadow.sql");
CmdResult clean = runCmd(script, bin, "SERVER", "FT546-wms", "clean");
assertThat(clean.exitCode()).as(clean.output()).isZero();
assertThat(clean.output()).contains("FIXTURE_INPUT=install-shadow.sql",
"FIXTURE_INPUT=upgrade-shadow-v3-to-v33.sql", "FIXTURE_INPUT=upgrade-shadow-v33-to-v34.sql");
CmdResult failed = runCmd(script, bin, "SERVER", "FAIL_V34", "clean");
assertThat(failed.exitCode()).isEqualTo(1);
assertThat(failed.output()).doesNotContain("INSTALL_OK");
}
@Test
void runbookAndServerAiPromptPreserveTheSafeFieldOrder() throws IOException {
String runbook = text("doc/algorithm-shadow/README.md");

View File

@@ -11,12 +11,17 @@ class Sqlcmd13OutputFixture
Console.WriteLine("123 sun.tools.jps.Jps -lv");
return 0;
}
if (Array.IndexOf(args, "-Q") >= 0) return 0;
int input = Array.IndexOf(args, "-i");
if (input < 0) return 86;
string inputName = Path.GetFileName(args[input + 1]);
Console.WriteLine("FIXTURE_INPUT=" + inputName);
if (inputName == "shadow-v34-schema-check.sql")
return Array.IndexOf(args, "FAIL_SCHEMA") >= 0 ? 67 : 0;
if (inputName == "upgrade-shadow-v3-to-v33.sql" && Array.IndexOf(args, "FAIL_V33") >= 0)
return 33;
if (inputName == "upgrade-shadow-v33-to-v34.sql" && Array.IndexOf(args, "FAIL_V34") >= 0)
return 34;
foreach (string line in File.ReadAllLines(args[input + 1]))
{
if (!line.StartsWith(":out ")) continue;