fix(shadow): capture and validate inbound task evidence for v3.4
This commit is contained in:
@@ -8,6 +8,37 @@ public class ShadowInboundReference {
|
||||
private String productId;
|
||||
private String lotNo;
|
||||
private Integer shadowCandidateCount;
|
||||
private String spare1;
|
||||
private String spare2;
|
||||
private String spare3;
|
||||
private String faultFlag;
|
||||
private Integer detailCount;
|
||||
private Integer fieldGroupCount;
|
||||
private Integer productScopeCount;
|
||||
private Integer trayJobCount;
|
||||
private Integer legacyScopeComplete;
|
||||
private Integer predicateVersion;
|
||||
|
||||
public String getSpare1() { return spare1; }
|
||||
public void setSpare1(String value) { spare1 = value; }
|
||||
public String getSpare2() { return spare2; }
|
||||
public void setSpare2(String value) { spare2 = value; }
|
||||
public String getSpare3() { return spare3; }
|
||||
public void setSpare3(String value) { spare3 = value; }
|
||||
public String getFaultFlag() { return faultFlag; }
|
||||
public void setFaultFlag(String value) { faultFlag = value; }
|
||||
public Integer getDetailCount() { return detailCount; }
|
||||
public void setDetailCount(Integer value) { detailCount = value; }
|
||||
public Integer getFieldGroupCount() { return fieldGroupCount; }
|
||||
public void setFieldGroupCount(Integer value) { fieldGroupCount = value; }
|
||||
public Integer getProductScopeCount() { return productScopeCount; }
|
||||
public void setProductScopeCount(Integer value) { productScopeCount = value; }
|
||||
public Integer getTrayJobCount() { return trayJobCount; }
|
||||
public void setTrayJobCount(Integer value) { trayJobCount = value; }
|
||||
public Integer getLegacyScopeComplete() { return legacyScopeComplete; }
|
||||
public void setLegacyScopeComplete(Integer value) { legacyScopeComplete = value; }
|
||||
public Integer getPredicateVersion() { return predicateVersion; }
|
||||
public void setPredicateVersion(Integer value) { predicateVersion = value; }
|
||||
|
||||
public String getRequestId() {
|
||||
return requestId;
|
||||
|
||||
@@ -195,7 +195,8 @@ public class ShadowPlanningEngine {
|
||||
UUID.randomUUID().toString(), observation.warehouseId(), "INBOUND_COMPARE",
|
||||
observation.snapshotGeneration(), observation.snapshotSynchronizedAt(),
|
||||
observation.sourceSignature(),
|
||||
comparisonCode(observation.proposedSpaceId(), legacySpaceId),
|
||||
observation.observed() && "READY".equals(observation.resultCode()) && "10".equals(legacyResultCode)
|
||||
? comparisonCode(observation.proposedSpaceId(), legacySpaceId) : "NOT_COMPARABLE",
|
||||
inboundComparisonReason(observation, legacyResultCode, legacySpaceId));
|
||||
requireComparisonInserted(inserted);
|
||||
});
|
||||
@@ -226,6 +227,36 @@ public class ShadowPlanningEngine {
|
||||
inboundDiagnostic(snapshot, taskNo, trayNo, reference,
|
||||
"Inbound task details were unavailable to shadow planning"));
|
||||
}
|
||||
if (!Integer.valueOf(1).equals(reference.getPredicateVersion())) {
|
||||
return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_MISSING_PREDICATE_INPUT",
|
||||
"Task input is missing or predates inbound predicate version 1");
|
||||
}
|
||||
if (!WarehouseIdNormalizer.equivalent(reference.getWarehouseId(), warehouseId)
|
||||
|| !Integer.valueOf(1).equals(reference.getLegacyScopeComplete())) {
|
||||
return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_INCOMPLETE_LEGACY_SCOPE",
|
||||
"Warehouse snapshot does not cover the global Legacy allocation scope");
|
||||
}
|
||||
if (!Integer.valueOf(1).equals(reference.getTrayJobCount())) {
|
||||
return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_AMBIGUOUS_TRAY_JOB",
|
||||
"Task or tray identifies more than one Legacy job");
|
||||
}
|
||||
if (reference.getDetailCount() == null || reference.getDetailCount() <= 0) {
|
||||
return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_MISSING_TASK_DETAILS",
|
||||
"Job was captured but has no task details");
|
||||
}
|
||||
if (!Integer.valueOf(1).equals(reference.getFieldGroupCount())) {
|
||||
return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_AMBIGUOUS_LEGACY_FIELDS",
|
||||
"Legacy TOP (1) has no ordering and task details have different spare1/2/3 values");
|
||||
}
|
||||
if (!Integer.valueOf(1).equals(reference.getProductScopeCount()) || isBlank(reference.getProductId())) {
|
||||
return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_MULTI_PRODUCT_INPUT",
|
||||
"Single-product planner cannot represent this task product/lot scope");
|
||||
}
|
||||
if (isBlank(reference.getProjectNo()) || "999".equals(reference.getProjectNo().trim())
|
||||
|| "COMMON".equalsIgnoreCase(reference.getProjectNo().trim())) {
|
||||
return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_UNSUPPORTED_COMMON_PROJECT",
|
||||
"Common-project mapping and pool policy require separate validation");
|
||||
}
|
||||
InboundRequest request = new InboundRequest(reference.getRequestId(), reference.getTrayNo(),
|
||||
reference.getProjectNo(), reference.getProductId(), reference.getLotNo(),
|
||||
null, null, null, Set.of(), null);
|
||||
@@ -233,10 +264,17 @@ public class ShadowPlanningEngine {
|
||||
InboundPlan plan = result.plan();
|
||||
if (plan.assignment().isPresent()) {
|
||||
var assignment = plan.assignment().orElseThrow();
|
||||
if (inputMapper.countInboundEligibleSpace(warehouseId, snapshot.generation(),
|
||||
taskNo, trayNo, assignment.spaceId()) != 1) {
|
||||
return unavailableInbound(snapshot, taskNo, trayNo, reference, "SKIPPED_LEGACY_CONSTRAINT",
|
||||
"unverifiedProposal=" + abbreviate(nullSafe(assignment.spaceId()), IDENTIFIER_MAX_LENGTH)
|
||||
+ "; outside conservative Legacy eligibility subset; " + assignment.reason());
|
||||
}
|
||||
return new InboundShadowObservation(true, snapshot.generation(), warehouseId,
|
||||
snapshot.synchronizedAt(), snapshot.sourceSignature(),
|
||||
reference.getRequestId(), "READY", assignment.spaceId(),
|
||||
inboundDiagnostic(snapshot, taskNo, trayNo, reference, assignment.reason()));
|
||||
inboundDiagnostic(snapshot, taskNo, trayNo, reference,
|
||||
"legacyEligibility=STRICT_GROUP_OR_EMPTY; " + assignment.reason()));
|
||||
}
|
||||
var rejection = plan.rejection().orElseThrow();
|
||||
return InboundShadowObservation.unavailable(snapshot,
|
||||
@@ -244,6 +282,12 @@ public class ShadowPlanningEngine {
|
||||
inboundDiagnostic(snapshot, taskNo, trayNo, reference, rejection.reason()));
|
||||
}
|
||||
|
||||
private InboundShadowObservation unavailableInbound(PublishedShadowSnapshot snapshot, String taskNo,
|
||||
String trayNo, ShadowInboundReference reference, String code, String reason) {
|
||||
return InboundShadowObservation.unavailable(snapshot, reference.getRequestId(), code,
|
||||
inboundDiagnostic(snapshot, taskNo, trayNo, reference, reason));
|
||||
}
|
||||
|
||||
private OutboundShadowObservation toOutboundObservation(PublishedShadowSnapshot snapshot,
|
||||
String requestId, ShadowStockReference reference, OutboundPlan plan) {
|
||||
if (isBlank(requestId)) {
|
||||
@@ -409,6 +453,18 @@ public class ShadowPlanningEngine {
|
||||
IDENTIFIER_MAX_LENGTH);
|
||||
appendField(diagnostic, "shadowCandidates", numberOrUnknown(
|
||||
reference == null ? null : reference.getShadowCandidateCount()), NUMBER_MAX_LENGTH);
|
||||
if (reference != null) {
|
||||
appendField(diagnostic, "inboundPredicateVersion", numberOrUnknown(reference.getPredicateVersion()), NUMBER_MAX_LENGTH);
|
||||
appendField(diagnostic, "detailCount", numberOrUnknown(reference.getDetailCount()), NUMBER_MAX_LENGTH);
|
||||
appendField(diagnostic, "fieldGroupCount", numberOrUnknown(reference.getFieldGroupCount()), NUMBER_MAX_LENGTH);
|
||||
appendField(diagnostic, "productScopeCount", numberOrUnknown(reference.getProductScopeCount()), NUMBER_MAX_LENGTH);
|
||||
appendField(diagnostic, "trayJobCount", numberOrUnknown(reference.getTrayJobCount()), NUMBER_MAX_LENGTH);
|
||||
appendField(diagnostic, "legacyScopeComplete", numberOrUnknown(reference.getLegacyScopeComplete()), NUMBER_MAX_LENGTH);
|
||||
appendField(diagnostic, "spare1", reference.getSpare1(), 120);
|
||||
appendField(diagnostic, "spare2", reference.getSpare2(), 120);
|
||||
appendField(diagnostic, "spare3", reference.getSpare3(), 120);
|
||||
appendField(diagnostic, "faultFlag", reference.getFaultFlag(), 20);
|
||||
}
|
||||
appendField(diagnostic, "reason", resultReason, RESULT_REASON_MAX_LENGTH);
|
||||
return diagnostic.toString();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ public interface ShadowInputMapper {
|
||||
@Param("generation") long generation, @Param("taskNo") String taskNo,
|
||||
@Param("trayNo") String trayNo);
|
||||
|
||||
int countInboundEligibleSpace(@Param("warehouseId") String warehouseId,
|
||||
@Param("generation") long generation, @Param("taskNo") String taskNo,
|
||||
@Param("trayNo") String trayNo, @Param("spaceId") String spaceId);
|
||||
|
||||
ShadowStockReference selectProjectOutboundReference(@Param("warehouseId") String warehouseId,
|
||||
@Param("generation") long generation,
|
||||
@Param("stockoutDetailId") String stockoutDetailId);
|
||||
|
||||
@@ -12,6 +12,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
input.project_no as projectNo,
|
||||
input.product_id as productId,
|
||||
input.lot_no as lotNo,
|
||||
input.spare1, input.spare2, input.spare3, input.fault_flag as faultFlag,
|
||||
input.detail_count as detailCount, input.field_group_count as fieldGroupCount,
|
||||
input.product_scope_count as productScopeCount, input.tray_job_count as trayJobCount,
|
||||
input.legacy_scope_complete as legacyScopeComplete, input.predicate_version as predicateVersion,
|
||||
convert(int, coalesce((
|
||||
select sum(convert(bigint, lane.available_count))
|
||||
from alg.lane_state lane
|
||||
@@ -25,6 +29,57 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
and input.tray_no = #{trayNo}
|
||||
</select>
|
||||
|
||||
<!-- Conservative subset valid in either Legacy project-count branch. Ranking remains in the planner. -->
|
||||
<select id="countInboundEligibleSpace" resultType="int">
|
||||
select count(*)
|
||||
from alg.shadow_inbound_request_snapshot input
|
||||
join alg.cargo_space_snapshot cargo
|
||||
on cargo.warehouse_id = input.warehouse_id
|
||||
and cargo.snapshot_generation = input.snapshot_generation
|
||||
where input.warehouse_id = #{warehouseId} and input.snapshot_generation = #{generation}
|
||||
and input.task_no = #{taskNo} and input.tray_no = #{trayNo}
|
||||
and input.predicate_version = 1 and input.detail_count > 0
|
||||
and input.field_group_count = 1 and input.product_scope_count = 1
|
||||
and input.tray_job_count = 1 and input.legacy_scope_complete = 1
|
||||
and nullif(ltrim(rtrim(input.project_no)), '') is not null
|
||||
and input.project_no not in ('999', 'COMMON')
|
||||
and cargo.space_id = #{spaceId} and cargo.space_status = '1' and cargo.locked = 'N'
|
||||
and nullif(ltrim(rtrim(cargo.tray_code)), '') is null
|
||||
and cargo.this_cargo_area in ('A', 'C')
|
||||
and nullif(ltrim(rtrim(cargo.lane_code)), '') is not null
|
||||
and nullif(ltrim(rtrim(cargo.floor_no)), '') is not null
|
||||
and not exists (
|
||||
select 1 from alg.inventory_item_snapshot occupied
|
||||
where occupied.warehouse_id = cargo.warehouse_id
|
||||
and occupied.snapshot_generation = cargo.snapshot_generation and occupied.space_id = cargo.space_id)
|
||||
and not exists (
|
||||
select 1 from alg.cargo_space_snapshot busy
|
||||
where busy.warehouse_id = cargo.warehouse_id and busy.snapshot_generation = cargo.snapshot_generation
|
||||
and ((busy.floor_no = cargo.floor_no and busy.space_status = '3')
|
||||
or (busy.lane_code = cargo.lane_code and (busy.space_status in ('3', '4') or busy.locked = 'Y'))))
|
||||
and (
|
||||
not exists (
|
||||
select 1 from alg.cargo_space_snapshot lane
|
||||
where lane.warehouse_id = cargo.warehouse_id and lane.snapshot_generation = cargo.snapshot_generation
|
||||
and lane.lane_code = cargo.lane_code
|
||||
and (lane.space_status is null or lane.space_status <> '1'
|
||||
or lane.locked is null or lane.locked <> 'N'
|
||||
or nullif(ltrim(rtrim(lane.tray_code)), '') is not null
|
||||
or exists (select 1 from alg.inventory_item_snapshot stock
|
||||
where stock.warehouse_id = lane.warehouse_id
|
||||
and stock.snapshot_generation = lane.snapshot_generation and stock.space_id = lane.space_id)))
|
||||
or exists (
|
||||
select 1 from alg.cargo_space_snapshot resident
|
||||
join alg.inventory_item_snapshot stock
|
||||
on stock.warehouse_id = resident.warehouse_id and stock.snapshot_generation = resident.snapshot_generation
|
||||
and stock.space_id = resident.space_id and stock.tray_code = resident.tray_code
|
||||
where resident.warehouse_id = cargo.warehouse_id and resident.snapshot_generation = cargo.snapshot_generation
|
||||
and resident.lane_code = cargo.lane_code and resident.space_status = '2'
|
||||
and resident.this_cargo_area in ('A', 'C')
|
||||
and stock.project_no = input.project_no
|
||||
and stock.spare1 = input.spare1 and stock.spare2 = input.spare2 and stock.spare3 = input.spare3))
|
||||
</select>
|
||||
|
||||
<sql id="outboundCandidatePredicate">
|
||||
item.warehouse_id = #{warehouseId}
|
||||
and item.snapshot_generation = #{generation}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user