diff --git a/doc/algorithm-shadow/SHADOW_V34_DEPLOYMENT.md b/doc/algorithm-shadow/SHADOW_V34_DEPLOYMENT.md
new file mode 100644
index 0000000..b7add0f
--- /dev/null
+++ b/doc/algorithm-shadow/SHADOW_V34_DEPLOYMENT.md
@@ -0,0 +1,80 @@
+# FT546WMS 入库影子 V3.4
+
+这是影子采集与比较修复,不是正式算法切换。现场继续由 `dbo.proc_allot_space` 分配货位。
+
+## 修复范围
+
+- 入库输入从 `mydo_job` 和 `mydo_job_detail` 采集,不再依赖入库单是否仍在活动表。
+- 影子请求标识采用 `job_id`,项目采用任务 `line_id`;采集明细 `spare1/2/3` 和任务 `fault_flag`。
+- 保存明细总数、不同分组字段组合数、不同产品/批次组合数、重复任务数量,不用第一条明细代表整个托盘。
+- 货位快照新增真正的 `this_cargo_area`,不把 `cargo_area_id` 当成 A/B/C/D 库区。
+- 新输入标记 `predicate_version=1`;决策原因标记 `inboundPredicateVersion=1`。旧样本不能重新认定为新版本通过。
+- 共享规划器、业务调用入口、Legacy 存储过程和出库影子规则不变。
+
+## 比较口径与限制
+
+新算法仍按原优化策略提出建议。只有建议通过保守的 Legacy 可选范围核验,且 Legacy 返回成功码 `10`,才可能记为 `MATCH` 或 `DIFFERENT`。
+
+目前可核验的是单产品/批次、分组字段一致的项目入库:候选空闲、未锁定、无托盘/库存、A/C 区、楼层无入库作业、通道无入出库作业或锁定,并且整条通道为空,或通道内有托盘一致且项目及 spare1/2/3 全匹配的库存。
+
+这是 `STRICT_GROUP_OR_EMPTY` 保守子集,不声称完整复刻 Legacy 排序。Legacy 的项目数量分支有按 `group_coding` 分组后赋值的歧义;严格分组匹配在两个分支内均属可选范围。`fault_flag=0` 的 C 区优先属于旧排序偏好,保留字段供分析,不替换共享规划器排序。
+
+Legacy 的部分货位约束和重复任务判断不限定仓库,因此刷新前后的货位、任务及任务明细签名覆盖全局。其他仓库变动也可能使本轮影子快照失效;存在快照之外的货位时,入库只记录跳过。此限制有意保留,不能靠删除检查来扩大通过率。现场验收还需测量全局读取增加的开销。
+
+以下情况记录具体原因,不能算通过:
+
+| 原因码 | 含义 |
+|---|---|
+| `SKIPPED_MISSING_PREDICATE_INPUT` | 旧快照或字段超过 Legacy 参数长度 |
+| `SKIPPED_MISSING_TASK_DETAILS` | 采到了任务,但没有任务明细 |
+| `SKIPPED_AMBIGUOUS_LEGACY_FIELDS` | 明细 spare1/2/3 不一致,无法复现 Legacy 无排序的 TOP (1) |
+| `SKIPPED_MULTI_PRODUCT_INPUT` | 单产品请求无法表示整个多产品/批次托盘,或缺少产品 |
+| `SKIPPED_AMBIGUOUS_TRAY_JOB` | 同托盘或任务号存在多个任务 |
+| `SKIPPED_INCOMPLETE_LEGACY_SCOPE` | 存在快照之外的仓库货位,不能用局部快照核验全局 Legacy 约束 |
+| `SKIPPED_UNSUPPORTED_COMMON_PROJECT` | 空项目、999 或 COMMON 的通用件映射及单深池策略尚需单独验证 |
+| `SKIPPED_LEGACY_CONSTRAINT` | 建议不在保守可选范围内;原因保留 `unverifiedProposal` |
+
+受控混放、借池和放宽分组等优化建议可能落在此范围外。这不等于建议必然错误,但不能在尚未确认这些规则的情况下当作兼容性通过。
+
+2026-09-07 的任务 31331 / 31333 分别有 45 / 35 条明细且 spare1 有不同值。修复后这类任务不再因缺少入库单被漏采,但仍应标为明细歧义,而不是强行改成通过。需要另行确定多明细业务规则后才能扩大验证范围。
+
+## 部署准备
+
+1. 先确认交付的提交号和已验证的 JAR,不要使用尚未推送的本地修改,也不要把带开发配置的原始 `target/ruoyi-admin.jar` 直接用于现场。
+2. 沿用现场离线 JAR 加外部配置方式,保留现有 JAR、外部配置和 IDEA 启动项用于回退。新 JAR 应按既有交付流程剥离内置连接配置并核对 SHA-256。
+3. 在隔离 SQL Server 上演练升级、重复执行和失败回滚。本地 H2 投影/Mapper 测试及 SQL 2016 解析不能代替真实 SQL Server 升级测试。
+4. 记录升级前的数据库包版本、影子指标、启动时间和业务基线。本包只从 `2026-09-07-33` 升至 `2026-09-07-34`,增加 11 个可空列,保留影子历史日志。
+
+## 维护窗口操作
+
+停止 WMS 的影子启用实例,确认退出并等待至少 130 秒。不要停止 WCS 或 WCS 影子程序,不要同时运行两个 WMS。
+
+在服务器已确认的新版本源码目录,用 CMD 执行:
+
+```cmd
+call scripts\shadow-v3-install.cmd "lpc:." "FT546-wms" v34
+```
+
+必须看到 `V34_UPGRADE_OK`(重复执行为 `V34_ALREADY_INSTALLED`)以及 `INSTALL_OK mode=v34`。
+已有 V3.3 不要用 `clean`、不要先卸载,也不要改清单绕过心跳检查。失败则停止后续部署并保存输出。
+`clean`/`upgrade` 模式会依次安装历史基础包、V3.3、V3.4,每个阶段单独提交;中间失败不代表之前阶段已回滚。`v34` 单步升级为一个事务。
+
+启动前再执行只读结构检查,必须返回 `V34_SCHEMA_OK`:
+
+```cmd
+sqlcmd -S "lpc:." -d "FT546-wms" -E -b -r1 -i "scripts\shadow-v34-schema-check.sql"
+```
+
+该检查核对 V3.4 对象、继承列及新增列的类型、长度、精度和可空性,并检查刷新过程的版本标记。没有成功标记就不要继续。原 `shadow-v3-preflight.cmd` 也包含这项检查,但它还要求新鲜的已发布快照,不适合直接用在升级完成但尚未启动的 `INITIALIZING` 状态。
+
+确认数据库完成后,再启动新 JAR,仅保留 `FORTUCKY_SHADOW_ENABLED=true`、`FORTUCKY_ALGORITHM_MODE=SHADOW`,不启用 ACTIVE。现有连接、内存、限流和日志配置不随意覆盖。
+
+## 启动后检查与回滚
+
+检查包版本为 `2026-09-07-34`,新快照为 `PUBLISHED`。通过自然业务观察入库,不制造生产测试单,不回放已完成任务。快照在事件触发时更新,没有新任务时输入表为空不等于故障。
+
+继续用现有 `shadow-v3-export.cmd` 导出,提供准确升级时间、提交、JAR 哈希和日志;只分析含 `inboundPredicateVersion=1` 的升级后记录。累计性能指标用升级前后差值评估,关注后台读取增加的开销。
+
+有异常时:停止新 WMS,将影子关闭,使用保留的原 JAR/源码及外部配置启动一个 WMS。无需卸载数据库,新列不参与 Legacy。回到旧代码也保持影子关闭,避免继续采集旧口径样本。
+
+本次代码修复不代表具备正式切换条件。所有跳过均需单独统计,不能用连续运行天数代替支持范围、样本覆盖和性能验收。
diff --git a/fortucky-algorithm/src/main/resources/db/sqlserver/uninstall-shadow.sql b/fortucky-algorithm/src/main/resources/db/sqlserver/uninstall-shadow.sql
index d5aaf6c..100074b 100644
--- a/fortucky-algorithm/src/main/resources/db/sqlserver/uninstall-shadow.sql
+++ b/fortucky-algorithm/src/main/resources/db/sqlserver/uninstall-shadow.sql
@@ -2,7 +2,7 @@ SET NOCOUNT ON;
SET XACT_ABORT ON;
IF SCHEMA_ID(N'alg') IS NULL THROW 51010, 'FT546 shadow uninstall refused: alg schema does not exist', 1;
IF OBJECT_ID(N'alg.shadow_package_manifest', N'U') IS NULL THROW 51011, 'FT546 shadow uninstall refused: shadow package manifest does not match', 1;
-IF (SELECT COUNT(*) FROM alg.shadow_package_manifest WHERE package_id='FT546WMS_SHADOW_ONLY' AND package_version IN ('2026-09-03-3','2026-09-07-33'))<>1 OR (SELECT COUNT(*) FROM alg.shadow_package_manifest)<>1 THROW 51012, 'FT546 shadow uninstall refused: shadow package manifest does not match', 1;
+IF (SELECT COUNT(*) FROM alg.shadow_package_manifest WHERE package_id='FT546WMS_SHADOW_ONLY' AND package_version IN ('2026-09-03-3','2026-09-07-33','2026-09-07-34'))<>1 OR (SELECT COUNT(*) FROM alg.shadow_package_manifest)<>1 THROW 51012, 'FT546 shadow uninstall refused: shadow package manifest does not match', 1;
DECLARE @expected_objects TABLE (object_name sysname NOT NULL PRIMARY KEY, object_type char(2) NOT NULL);
INSERT INTO @expected_objects VALUES
(N'shadow_package_manifest','U'),(N'warehouse_policy','U'),(N'project_policy','U'),(N'space_role','U'),(N'space_topology','U'),(N'inventory_item_snapshot','U'),(N'space_inventory_summary','U'),(N'lane_state','U'),(N'reservation','U'),(N'deep_workflow_lock','U'),(N'decision_log','U'),(N'shadow_snapshot_state','U'),(N'cargo_space_snapshot','U'),(N'active_job_snapshot','U'),(N'shadow_inbound_request_snapshot','U'),(N'shadow_outbound_request_snapshot','U'),(N'shadow_runtime_metric','U'),(N'shadow_instance_heartbeat','U'),
diff --git a/fortucky-algorithm/src/main/resources/db/sqlserver/upgrade-shadow-v33-to-v34.sql b/fortucky-algorithm/src/main/resources/db/sqlserver/upgrade-shadow-v33-to-v34.sql
new file mode 100644
index 0000000..a1ac5d9
--- /dev/null
+++ b/fortucky-algorithm/src/main/resources/db/sqlserver/upgrade-shadow-v33-to-v34.sql
@@ -0,0 +1,531 @@
+/*
+ FT546WMS shadow V3.3 -> V3.4, SQL Server 2016 RTM compatible.
+ Run only after stopping the WMS shadow-enabled JVM and waiting >120 seconds.
+ Changes only alg.*. Never executes or alters a Legacy procedure.
+ One transaction: any failure rolls back columns, procedure, state and manifest.
+*/
+SET NOCOUNT ON;
+SET XACT_ABORT ON;
+SET LOCK_TIMEOUT 5000;
+SET DEADLOCK_PRIORITY LOW;
+
+IF DB_NAME() <> N'FT546-wms'
+ THROW 51060, 'V3.4 upgrade refused: expected FT546-wms database', 1;
+IF SCHEMA_ID(N'alg') IS NULL OR OBJECT_ID(N'alg.shadow_package_manifest', N'U') IS NULL
+ THROW 51061, 'V3.4 upgrade refused: shadow package manifest is missing', 1;
+
+DECLARE @expected TABLE (object_name sysname PRIMARY KEY, object_type char(2) NOT NULL);
+INSERT INTO @expected VALUES
+(N'shadow_package_manifest','U'),(N'warehouse_policy','U'),(N'project_policy','U'),
+(N'space_role','U'),(N'space_topology','U'),(N'inventory_item_snapshot','U'),
+(N'space_inventory_summary','U'),(N'lane_state','U'),(N'reservation','U'),
+(N'deep_workflow_lock','U'),(N'decision_log','U'),(N'shadow_snapshot_state','U'),
+(N'cargo_space_snapshot','U'),(N'active_job_snapshot','U'),
+(N'shadow_inbound_request_snapshot','U'),(N'shadow_outbound_request_snapshot','U'),
+(N'shadow_runtime_metric','U'),(N'shadow_instance_heartbeat','U'),
+(N'v_inventory_item','V'),(N'v_active_job','V'),(N'v_space_state','V'),
+(N'usp_rebuild_space_topology','P'),(N'usp_sync_inventory_snapshot','P'),
+(N'usp_compute_lane_semantic_fingerprint','P'),(N'usp_rebuild_lane_state','P'),
+(N'usp_refresh_dirty_lanes','P'),(N'usp_claim_shadow_instance','P'),
+(N'usp_refresh_shadow_snapshot','P');
+
+DECLARE @columns TABLE (
+ table_name sysname, column_name sysname, type_name sysname, max_length smallint,
+ precision_value tinyint, scale_value tinyint, PRIMARY KEY (table_name, column_name));
+INSERT INTO @columns VALUES
+(N'cargo_space_snapshot',N'this_cargo_area',N'varchar',255,0,0),
+(N'shadow_inbound_request_snapshot',N'spare1',N'varchar',255,0,0),
+(N'shadow_inbound_request_snapshot',N'spare2',N'varchar',255,0,0),
+(N'shadow_inbound_request_snapshot',N'spare3',N'varchar',255,0,0),
+(N'shadow_inbound_request_snapshot',N'fault_flag',N'varchar',255,0,0),
+(N'shadow_inbound_request_snapshot',N'detail_count',N'int',4,10,0),
+(N'shadow_inbound_request_snapshot',N'field_group_count',N'int',4,10,0),
+(N'shadow_inbound_request_snapshot',N'product_scope_count',N'int',4,10,0),
+(N'shadow_inbound_request_snapshot',N'tray_job_count',N'int',4,10,0),
+(N'shadow_inbound_request_snapshot',N'legacy_scope_complete',N'int',4,10,0),
+(N'shadow_inbound_request_snapshot',N'predicate_version',N'int',4,10,0);
+
+BEGIN TRY
+ BEGIN TRANSACTION;
+ DECLARE @version varchar(40);
+ SELECT @version = package_version
+ FROM alg.shadow_package_manifest WITH (UPDLOCK, HOLDLOCK)
+ WHERE package_id = 'FT546WMS_SHADOW_ONLY';
+ IF @version IS NULL OR @version NOT IN ('2026-09-07-33', '2026-09-07-34')
+ OR (SELECT COUNT(*) FROM alg.shadow_package_manifest) <> 1
+ THROW 51062, 'V3.4 upgrade refused: exact shadow manifest does not match', 1;
+
+ IF EXISTS (
+ SELECT 1 FROM sys.objects object
+ LEFT JOIN @expected expected ON expected.object_name = object.name
+ AND expected.object_type = object.type COLLATE DATABASE_DEFAULT
+ WHERE object.schema_id = SCHEMA_ID(N'alg') AND object.is_ms_shipped = 0
+ AND object.parent_object_id = 0 AND expected.object_name IS NULL)
+ THROW 51063, 'V3.4 upgrade refused: unknown object exists in alg schema', 1;
+ IF EXISTS (
+ SELECT 1 FROM @expected expected
+ LEFT JOIN sys.objects object ON object.schema_id = SCHEMA_ID(N'alg')
+ AND object.name = expected.object_name
+ AND object.type COLLATE DATABASE_DEFAULT = expected.object_type
+ WHERE object.object_id IS NULL)
+ THROW 51064, 'V3.4 upgrade refused: expected object is missing or has wrong type', 1;
+ IF EXISTS (SELECT 1 FROM sys.triggers trigger_object
+ JOIN sys.objects parent_object ON parent_object.object_id = trigger_object.parent_id
+ WHERE parent_object.schema_id = SCHEMA_ID(N'alg'))
+ THROW 51065, 'V3.4 upgrade refused: unexpected trigger on shadow table', 1;
+
+ IF EXISTS (SELECT 1 FROM alg.shadow_instance_heartbeat WITH (UPDLOCK, HOLDLOCK)
+ WHERE last_seen_at >= DATEADD(SECOND, -120, SYSUTCDATETIME())
+ OR claimed_at >= DATEADD(SECOND, -120, SYSUTCDATETIME()))
+ THROW 51066, 'V3.4 upgrade refused: recent shadow heartbeat; stop WMS and wait over 120 seconds', 1;
+
+ IF @version = '2026-09-07-34'
+ BEGIN
+ IF EXISTS (
+ SELECT 1 FROM @columns expected
+ LEFT JOIN sys.columns column_info
+ ON column_info.object_id = OBJECT_ID(N'alg.' + expected.table_name)
+ AND column_info.name = expected.column_name
+ WHERE column_info.column_id IS NULL
+ OR TYPE_NAME(column_info.user_type_id) <> expected.type_name
+ OR column_info.max_length <> expected.max_length
+ OR column_info.precision <> expected.precision_value
+ OR column_info.scale <> expected.scale_value
+ OR column_info.is_nullable <> 1)
+ THROW 51067, 'V3.4 upgrade refused: target columns are missing or incompatible', 1;
+ DECLARE @installed_definition nvarchar(max) = OBJECT_DEFINITION(OBJECT_ID(N'alg.usp_refresh_shadow_snapshot', N'P'));
+ IF @installed_definition IS NULL OR CHARINDEX(N'INBOUND_JOB_FIELDS_V1', @installed_definition) = 0
+ OR CHARINDEX(N'field_group_count', @installed_definition) = 0
+ OR CHARINDEX(N'this_cargo_area', @installed_definition) = 0
+ THROW 51071, 'V3.4 upgrade refused: refresh procedure is missing, unreadable or obsolete', 1;
+ COMMIT TRANSACTION;
+ PRINT 'V34_ALREADY_INSTALLED';
+ RETURN;
+ END;
+
+ IF EXISTS (
+ SELECT 1 FROM @columns expected JOIN sys.columns column_info
+ ON column_info.object_id = OBJECT_ID(N'alg.' + expected.table_name)
+ AND column_info.name = expected.column_name)
+ THROW 51068, 'V3.4 upgrade refused: partial or foreign predicate columns already exist', 1;
+
+ IF COL_LENGTH(N'alg.inventory_item_snapshot', N'spare1') IS NULL
+ OR COL_LENGTH(N'alg.shadow_outbound_request_snapshot', N'predicate_version') IS NULL
+ THROW 51070, 'V3.4 upgrade refused: V3.3 predicate columns are missing', 1;
+
+ ALTER TABLE alg.cargo_space_snapshot ADD this_cargo_area varchar(255) NULL;
+ ALTER TABLE alg.shadow_inbound_request_snapshot ADD
+ spare1 varchar(255) NULL, spare2 varchar(255) NULL, spare3 varchar(255) NULL,
+ fault_flag varchar(255) NULL, detail_count int NULL, field_group_count int NULL,
+ product_scope_count int NULL, tray_job_count int NULL, legacy_scope_complete int NULL,
+ predicate_version int NULL;
+
+ DECLARE @refresh_sql nvarchar(max) = N'ALTER PROCEDURE alg.usp_refresh_shadow_snapshot
+ @warehouse_id varchar(80),
+ @snapshot_generation bigint,
+ @instance_id varchar(80)
+AS
+BEGIN
+ -- INBOUND_JOB_FIELDS_V1: task-derived inbound fields and conservative eligibility evidence.
+ SET NOCOUNT ON;
+ SET XACT_ABORT ON;
+
+ IF NULLIF(LTRIM(RTRIM(@warehouse_id)), '''') IS NULL
+ THROW 51006, ''warehouse_id is required'', 1;
+ IF NULLIF(LTRIM(RTRIM(@instance_id)), '''') IS NULL
+ THROW 51007, ''instance_id is required'', 1;
+ IF @snapshot_generation <= 0
+ THROW 51008, ''snapshot_generation must be positive'', 1;
+
+ DECLARE @pre_source_count bigint, @post_source_count bigint,
+ @pre_source_signature varbinary(32), @post_source_signature varbinary(32),
+ @source_payload nvarchar(max), @synchronized_at datetime2,
+ @numeric_warehouse_id decimal(38,0),
+ @current_generation bigint;
+
+ IF LTRIM(RTRIM(@warehouse_id)) NOT LIKE ''%[^0-9]%''
+ SET @numeric_warehouse_id = TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(@warehouse_id)));
+
+ BEGIN TRY
+ BEGIN TRANSACTION;
+
+ SELECT @current_generation = state.snapshot_generation
+ FROM alg.shadow_snapshot_state state WITH (UPDLOCK, HOLDLOCK)
+ WHERE state.warehouse_id = @warehouse_id;
+
+ IF @current_generation IS NOT NULL AND @snapshot_generation <= @current_generation
+ THROW 51009, ''snapshot_generation must advance the published generation'', 1;
+
+ SELECT @pre_source_count =
+ (SELECT COUNT_BIG(*) FROM dbo.bas_cargo_space cargo WITH (NOLOCK))
+ + (SELECT COUNT_BIG(*) FROM dbo.bas_stock stock WITH (NOLOCK)
+ JOIN dbo.bas_cargo_space cargo WITH (NOLOCK) ON cargo.cargo_space_id = stock.cargo_space_id
+ WHERE cargo.warehouse_id = @warehouse_id)
+ + (SELECT COUNT_BIG(*) FROM dbo.mydo_job job WITH (NOLOCK))
+ + (SELECT COUNT_BIG(*) FROM dbo.mydo_job_detail detail WITH (NOLOCK)
+ JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id)
+ + (SELECT COUNT_BIG(*) FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
+ JOIN dbo.out_stockout stockout WITH (NOLOCK) ON stockout.stockout_id = outbound.stockout_id
+ WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)
+ AND (LTRIM(RTRIM(stockout.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
+ OR (@numeric_warehouse_id IS NOT NULL
+ AND LTRIM(RTRIM(stockout.warehouse_id)) NOT LIKE ''%[^0-9]%''
+ AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockout.warehouse_id))) = @numeric_warehouse_id)));
+
+ SELECT @source_payload = ISNULL(CONVERT(nvarchar(max), (
+ SELECT cargo.cargo_space_id AS [@space_id], cargo.warehouse_id AS [@warehouse_id],
+ cargo.group_coding_no AS [@lane_code], cargo.cargo_area_id AS [@cargo_area_id],
+ cargo.roadway_id AS [@roadway_id], cargo.cargo_space_name AS [@display_name],
+ cargo.the_platoon AS [@platoon], cargo.the_column AS [@column_no],
+ cargo.the_floor AS [@floor_no], cargo.group_coding AS [@depth_raw],
+ cargo.status AS [@space_status], cargo.locked AS [@locked], cargo.tray_code AS [@tray_code],
+ cargo.this_cargo_area AS [@this_cargo_area]
+ FROM dbo.bas_cargo_space cargo WITH (NOLOCK)
+ ORDER BY cargo.cargo_space_id
+ FOR XML PATH(''cargo''), ROOT(''cargo_spaces''), TYPE
+ )), '''');
+
+ SET @source_payload = @source_payload + ''|STOCK|'' + ISNULL(CONVERT(nvarchar(max), (
+ SELECT stock.stock_id AS [@stock_id], stock.cargo_space_id AS [@space_id],
+ stock.warehouse_id AS [@warehouse_id], stock.tray_code AS [@tray_code],
+ stock.spare4 AS [@project_no], stock.product_id AS [@product_no],
+ stock.lot_no AS [@lot_no], stock.in_date AS [@in_date],
+ stock.quantity AS [@quantity], stock.out_quantity AS [@out_quantity],
+ stock.spare1 AS [@spare1], stock.spare2 AS [@spare2], stock.spare3 AS [@spare3],
+ stock.voltage AS [@voltage], stock.phase AS [@phase],
+ (SELECT COUNT(*) FROM dbo.mydo_job trayJob WITH (NOLOCK)
+ WHERE trayJob.tray_code = stock.tray_code) AS [@tray_job_count]
+ FROM dbo.bas_stock stock WITH (NOLOCK)
+ JOIN dbo.bas_cargo_space cargo WITH (NOLOCK) ON cargo.cargo_space_id = stock.cargo_space_id
+ WHERE cargo.warehouse_id = @warehouse_id
+ ORDER BY stock.stock_id
+ FOR XML PATH(''stock''), ROOT(''stocks''), TYPE
+ )), '''');
+
+ SET @source_payload = @source_payload + ''|JOB|'' + ISNULL(CONVERT(nvarchar(max), (
+ SELECT job.job_id AS [@job_id], job.status AS [@status], job.task_no AS [@task_no],
+ job.tray_code AS [@tray_code], job.cargo_space_id AS [@source_space_id],
+ job.target_cargo_space_id AS [@target_space_id], job.in_or_out AS [@in_or_out],
+ job.create_time AS [@create_time], job.finish_time AS [@finish_time],
+ job.warehouse_id AS [@warehouse_id], job.line_id AS [@line_id], job.fault_flag AS [@fault_flag]
+ FROM dbo.mydo_job job WITH (NOLOCK)
+ ORDER BY job.job_id
+ FOR XML PATH(''job''), ROOT(''jobs''), TYPE
+ )), '''');
+
+ SET @source_payload = @source_payload + ''|JOB_DETAIL|'' + ISNULL(CONVERT(nvarchar(max), (
+ SELECT detail.job_detail_id AS [@job_detail_id], detail.job_id AS [@job_id],
+ detail.stock_id AS [@stock_id], detail.lot_no AS [@lot_no],
+ detail.in_date AS [@in_date], detail.in_or_out_id AS [@request_id],
+ detail.in_or_out_d_id AS [@request_detail_id], detail.product_id AS [@product_id],
+ detail.stock_status AS [@stock_status], detail.in_quantity AS [@in_quantity],
+ detail.stock_quantity AS [@stock_quantity], detail.out_quantity AS [@out_quantity],
+ detail.pick_quantity AS [@pick_quantity], detail.stockin_detail_id AS [@stockin_detail_id],
+ detail.spare1 AS [@spare1], detail.spare2 AS [@spare2], detail.spare3 AS [@spare3]
+ FROM dbo.mydo_job_detail detail WITH (NOLOCK)
+ JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
+ ORDER BY detail.job_detail_id
+ FOR XML PATH(''job_detail''), ROOT(''job_details''), TYPE
+ )), '''');
+
+ SET @source_payload = @source_payload + ''|OUTBOUND|'' + ISNULL(CONVERT(nvarchar(max), (
+ SELECT outbound.stockout_detail_id AS [@stockout_detail_id], outbound.warehouse_id AS [@warehouse_id],
+ stockout.warehouse_id AS [@source_warehouse_id],
+ outbound.spare4 AS [@project_no],
+ outbound.spare1 AS [@spare1], outbound.spare2 AS [@spare2], outbound.spare3 AS [@spare3],
+ outbound.fid AS [@voltage], outbound.unit AS [@phase],
+ outbound.product_id AS [@product_id], outbound.lot_no AS [@lot_no],
+ outbound.quantity AS [@quantity], outbound.assign_num AS [@assign_num],
+ outbound.allot_quantity AS [@allot_quantity]
+ FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
+ JOIN dbo.out_stockout stockout WITH (NOLOCK) ON stockout.stockout_id = outbound.stockout_id
+ WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)
+ AND (LTRIM(RTRIM(stockout.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
+ OR (@numeric_warehouse_id IS NOT NULL
+ AND LTRIM(RTRIM(stockout.warehouse_id)) NOT LIKE ''%[^0-9]%''
+ AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockout.warehouse_id))) = @numeric_warehouse_id))
+ ORDER BY outbound.stockout_detail_id
+ FOR XML PATH(''outbound''), ROOT(''outbound_requests''), TYPE
+ )), '''');
+
+ SET @pre_source_signature = HASHBYTES(''SHA2_256'', CONVERT(varbinary(max), @source_payload));
+
+ DELETE FROM alg.cargo_space_snapshot WHERE warehouse_id = @warehouse_id;
+ DELETE FROM alg.inventory_item_snapshot WHERE warehouse_id = @warehouse_id;
+ DELETE FROM alg.active_job_snapshot WHERE warehouse_id = @warehouse_id;
+ DELETE FROM alg.shadow_inbound_request_snapshot WHERE warehouse_id = @warehouse_id;
+ DELETE FROM alg.shadow_outbound_request_snapshot WHERE warehouse_id = @warehouse_id;
+ DELETE FROM alg.space_topology WHERE warehouse_id = @warehouse_id;
+ DELETE FROM alg.space_inventory_summary WHERE warehouse_id = @warehouse_id;
+ DELETE FROM alg.lane_state WHERE warehouse_id = @warehouse_id;
+
+ SET @synchronized_at = SYSUTCDATETIME();
+
+ INSERT INTO alg.cargo_space_snapshot (
+ warehouse_id, space_id, lane_code, cargo_area_id, roadway_id, display_name,
+ platoon, column_no, floor_no, depth_raw, space_status, locked, tray_code,
+ snapshot_generation, synchronized_at, this_cargo_area)
+ SELECT cargo.warehouse_id, cargo.cargo_space_id, cargo.group_coding_no,
+ cargo.cargo_area_id, cargo.roadway_id, cargo.cargo_space_name,
+ cargo.the_platoon, cargo.the_column, cargo.the_floor, cargo.group_coding,
+ cargo.status, cargo.locked, cargo.tray_code,
+ @snapshot_generation, @synchronized_at, cargo.this_cargo_area
+ FROM dbo.bas_cargo_space cargo WITH (NOLOCK)
+ WHERE cargo.warehouse_id = @warehouse_id;
+
+ INSERT INTO alg.inventory_item_snapshot (
+ warehouse_id, stock_id, space_id, tray_code, project_no, product_no, lot_no,
+ inbound_time_raw, inbound_at, quantity, out_quantity, snapshot_generation, synchronized_at,
+ spare1, spare2, spare3, voltage, phase, tray_job_count)
+ SELECT cargo.warehouse_id, stock.stock_id, stock.cargo_space_id, stock.tray_code,
+ stock.spare4, stock.product_id, stock.lot_no, stock.in_date,
+ TRY_CONVERT(datetime2, stock.in_date), stock.quantity, stock.out_quantity,
+ @snapshot_generation, @synchronized_at,
+ stock.spare1, stock.spare2, stock.spare3, stock.voltage, stock.phase,
+ (SELECT COUNT(*) FROM dbo.mydo_job trayJob WITH (NOLOCK)
+ WHERE trayJob.tray_code = stock.tray_code)
+ FROM dbo.bas_stock stock WITH (NOLOCK)
+ JOIN dbo.bas_cargo_space cargo WITH (NOLOCK) ON cargo.cargo_space_id = stock.cargo_space_id
+ WHERE cargo.warehouse_id = @warehouse_id;
+
+ INSERT INTO alg.active_job_snapshot (
+ warehouse_id, job_id, task_no, tray_code, source_space_id, target_space_id,
+ status, in_or_out, create_time_raw, snapshot_generation, synchronized_at)
+ SELECT job.warehouse_id, job.job_id, job.task_no, job.tray_code,
+ NULLIF(LTRIM(RTRIM(job.cargo_space_id)), ''''),
+ NULLIF(LTRIM(RTRIM(job.target_cargo_space_id)), ''''), job.status,
+ job.in_or_out, job.create_time, @snapshot_generation, @synchronized_at
+ FROM dbo.mydo_job job WITH (NOLOCK)
+ WHERE job.warehouse_id = @warehouse_id
+ AND (job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '''');
+
+ ;WITH inbound_detail_groups AS (
+ SELECT detail.job_id, detail.spare1, detail.spare2, detail.spare3
+ FROM dbo.mydo_job_detail detail WITH (NOLOCK)
+ GROUP BY detail.job_id, detail.spare1, detail.spare2, detail.spare3
+ ), inbound_product_scopes AS (
+ SELECT detail.job_id, detail.product_id, detail.lot_no
+ FROM dbo.mydo_job_detail detail WITH (NOLOCK)
+ GROUP BY detail.job_id, detail.product_id, detail.lot_no
+ ), inbound_details AS (
+ SELECT detail.job_id, COUNT(*) AS detail_count,
+ MIN(detail.spare1) AS spare1, MIN(detail.spare2) AS spare2, MIN(detail.spare3) AS spare3,
+ MIN(detail.product_id) AS product_id, MIN(detail.lot_no) AS lot_no,
+ MAX(ISNULL(DATALENGTH(detail.spare1), 0)) AS spare1_bytes,
+ MAX(ISNULL(DATALENGTH(detail.spare2), 0)) AS spare2_bytes,
+ MAX(ISNULL(DATALENGTH(detail.spare3), 0)) AS spare3_bytes
+ FROM dbo.mydo_job_detail detail WITH (NOLOCK)
+ GROUP BY detail.job_id
+ ), inbound_source AS (
+ SELECT job.task_no, job.tray_code, job.job_id AS request_id,
+ job.warehouse_id AS raw_warehouse_id, job.line_id AS project_no, job.fault_flag,
+ detail.product_id, detail.lot_no, detail.spare1, detail.spare2, detail.spare3,
+ ISNULL(detail.detail_count, 0) AS detail_count,
+ (SELECT COUNT(*) FROM inbound_detail_groups groups WHERE groups.job_id = job.job_id) AS field_group_count,
+ (SELECT COUNT(*) FROM inbound_product_scopes scopes WHERE scopes.job_id = job.job_id) AS product_scope_count,
+ (SELECT COUNT(*) FROM dbo.mydo_job trayJob WITH (NOLOCK)
+ WHERE trayJob.tray_code = job.tray_code OR trayJob.task_no = job.task_no) AS tray_job_count,
+ CASE WHEN EXISTS (SELECT 1 FROM dbo.bas_cargo_space scopeCargo WITH (NOLOCK)
+ WHERE scopeCargo.warehouse_id IS NULL OR scopeCargo.warehouse_id <> @warehouse_id)
+ THEN 0 ELSE 1 END AS legacy_scope_complete,
+ CASE WHEN ISNULL(DATALENGTH(job.line_id), 0) <= 80
+ AND ISNULL(DATALENGTH(job.fault_flag), 0) <= 5
+ AND ISNULL(detail.spare1_bytes, 0) <= 80
+ AND ISNULL(detail.spare2_bytes, 0) <= 80
+ AND ISNULL(detail.spare3_bytes, 0) <= 80 THEN 1 ELSE 0 END AS predicate_version,
+ ROW_NUMBER() OVER (
+ PARTITION BY job.task_no, job.tray_code
+ ORDER BY job.job_id) AS source_rank
+ FROM dbo.mydo_job job WITH (NOLOCK)
+ LEFT JOIN inbound_details detail ON detail.job_id = job.job_id
+ WHERE job.in_or_out < 20
+ AND (job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '''')
+ AND (LTRIM(RTRIM(job.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
+ OR (@numeric_warehouse_id IS NOT NULL
+ AND LTRIM(RTRIM(job.warehouse_id)) NOT LIKE ''%[^0-9]%''
+ AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(job.warehouse_id))) = @numeric_warehouse_id))
+ )
+ INSERT INTO alg.shadow_inbound_request_snapshot (
+ warehouse_id, task_no, tray_no, request_id, raw_warehouse_id,
+ project_no, product_id, lot_no, snapshot_generation, synchronized_at,
+ spare1, spare2, spare3, fault_flag, detail_count, field_group_count,
+ product_scope_count, tray_job_count, legacy_scope_complete, predicate_version)
+ SELECT @warehouse_id, ISNULL(task_no, ''''), ISNULL(tray_code, ''''), request_id,
+ raw_warehouse_id, project_no, product_id, lot_no,
+ @snapshot_generation, @synchronized_at,
+ spare1, spare2, spare3, fault_flag, detail_count, field_group_count,
+ product_scope_count, tray_job_count, legacy_scope_complete, predicate_version
+ FROM inbound_source
+ WHERE source_rank = 1;
+
+ INSERT INTO alg.shadow_outbound_request_snapshot (
+ warehouse_id, stockout_detail_id, raw_warehouse_id, project_no, product_id,
+ requested_quantity, snapshot_generation, synchronized_at,
+ allocated_quantity, spare1, spare2, spare3, voltage, phase, predicate_version)
+ SELECT @warehouse_id, outbound.stockout_detail_id, outbound.warehouse_id,
+ outbound.spare4, outbound.product_id,
+ CONVERT(decimal(38,6), outbound.quantity),
+ @snapshot_generation, @synchronized_at,
+ CONVERT(decimal(38,6), outbound.allot_quantity),
+ outbound.spare1, outbound.spare2, outbound.spare3, outbound.fid, outbound.unit,
+ CASE WHEN ISNULL(DATALENGTH(outbound.product_id), 0) <= 80
+ AND ISNULL(DATALENGTH(outbound.spare1), 0) <= 80
+ AND ISNULL(DATALENGTH(outbound.spare2), 0) <= 80
+ AND ISNULL(DATALENGTH(outbound.spare3), 0) <= 80
+ AND ISNULL(DATALENGTH(outbound.spare4), 0) <= 80
+ AND ISNULL(DATALENGTH(outbound.fid), 0) <= 80
+ AND ISNULL(DATALENGTH(outbound.unit), 0) <= 80
+ THEN 1 ELSE 0 END
+ FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
+ JOIN dbo.out_stockout stockout WITH (NOLOCK) ON stockout.stockout_id = outbound.stockout_id
+ WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)
+ AND (LTRIM(RTRIM(stockout.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
+ OR (@numeric_warehouse_id IS NOT NULL
+ AND LTRIM(RTRIM(stockout.warehouse_id)) NOT LIKE ''%[^0-9]%''
+ AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockout.warehouse_id))) = @numeric_warehouse_id));
+
+ EXEC alg.usp_rebuild_space_topology @warehouse_id = @warehouse_id,
+ @snapshot_generation = @snapshot_generation;
+ EXEC alg.usp_sync_inventory_snapshot @warehouse_id = @warehouse_id,
+ @snapshot_generation = @snapshot_generation;
+ EXEC alg.usp_rebuild_lane_state @warehouse_id = @warehouse_id,
+ @snapshot_generation = @snapshot_generation;
+
+ SELECT @post_source_count =
+ (SELECT COUNT_BIG(*) FROM dbo.bas_cargo_space cargo WITH (NOLOCK))
+ + (SELECT COUNT_BIG(*) FROM dbo.bas_stock stock WITH (NOLOCK)
+ JOIN dbo.bas_cargo_space cargo WITH (NOLOCK) ON cargo.cargo_space_id = stock.cargo_space_id
+ WHERE cargo.warehouse_id = @warehouse_id)
+ + (SELECT COUNT_BIG(*) FROM dbo.mydo_job job WITH (NOLOCK))
+ + (SELECT COUNT_BIG(*) FROM dbo.mydo_job_detail detail WITH (NOLOCK)
+ JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id)
+ + (SELECT COUNT_BIG(*) FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
+ JOIN dbo.out_stockout stockout WITH (NOLOCK) ON stockout.stockout_id = outbound.stockout_id
+ WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)
+ AND (LTRIM(RTRIM(stockout.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
+ OR (@numeric_warehouse_id IS NOT NULL
+ AND LTRIM(RTRIM(stockout.warehouse_id)) NOT LIKE ''%[^0-9]%''
+ AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockout.warehouse_id))) = @numeric_warehouse_id)));
+
+ SELECT @source_payload = ISNULL(CONVERT(nvarchar(max), (
+ SELECT cargo.cargo_space_id AS [@space_id], cargo.warehouse_id AS [@warehouse_id],
+ cargo.group_coding_no AS [@lane_code], cargo.cargo_area_id AS [@cargo_area_id],
+ cargo.roadway_id AS [@roadway_id], cargo.cargo_space_name AS [@display_name],
+ cargo.the_platoon AS [@platoon], cargo.the_column AS [@column_no],
+ cargo.the_floor AS [@floor_no], cargo.group_coding AS [@depth_raw],
+ cargo.status AS [@space_status], cargo.locked AS [@locked], cargo.tray_code AS [@tray_code],
+ cargo.this_cargo_area AS [@this_cargo_area]
+ FROM dbo.bas_cargo_space cargo WITH (NOLOCK)
+ ORDER BY cargo.cargo_space_id
+ FOR XML PATH(''cargo''), ROOT(''cargo_spaces''), TYPE
+ )), '''');
+ SET @source_payload = @source_payload + ''|STOCK|'' + ISNULL(CONVERT(nvarchar(max), (
+ SELECT stock.stock_id AS [@stock_id], stock.cargo_space_id AS [@space_id],
+ stock.warehouse_id AS [@warehouse_id], stock.tray_code AS [@tray_code],
+ stock.spare4 AS [@project_no], stock.product_id AS [@product_no],
+ stock.lot_no AS [@lot_no], stock.in_date AS [@in_date],
+ stock.quantity AS [@quantity], stock.out_quantity AS [@out_quantity],
+ stock.spare1 AS [@spare1], stock.spare2 AS [@spare2], stock.spare3 AS [@spare3],
+ stock.voltage AS [@voltage], stock.phase AS [@phase],
+ (SELECT COUNT(*) FROM dbo.mydo_job trayJob WITH (NOLOCK)
+ WHERE trayJob.tray_code = stock.tray_code) AS [@tray_job_count]
+ FROM dbo.bas_stock stock WITH (NOLOCK)
+ JOIN dbo.bas_cargo_space cargo WITH (NOLOCK) ON cargo.cargo_space_id = stock.cargo_space_id
+ WHERE cargo.warehouse_id = @warehouse_id
+ ORDER BY stock.stock_id
+ FOR XML PATH(''stock''), ROOT(''stocks''), TYPE
+ )), '''');
+ SET @source_payload = @source_payload + ''|JOB|'' + ISNULL(CONVERT(nvarchar(max), (
+ SELECT job.job_id AS [@job_id], job.status AS [@status], job.task_no AS [@task_no],
+ job.tray_code AS [@tray_code], job.cargo_space_id AS [@source_space_id],
+ job.target_cargo_space_id AS [@target_space_id], job.in_or_out AS [@in_or_out],
+ job.create_time AS [@create_time], job.finish_time AS [@finish_time],
+ job.warehouse_id AS [@warehouse_id], job.line_id AS [@line_id], job.fault_flag AS [@fault_flag]
+ FROM dbo.mydo_job job WITH (NOLOCK)
+ ORDER BY job.job_id
+ FOR XML PATH(''job''), ROOT(''jobs''), TYPE
+ )), '''');
+ SET @source_payload = @source_payload + ''|JOB_DETAIL|'' + ISNULL(CONVERT(nvarchar(max), (
+ SELECT detail.job_detail_id AS [@job_detail_id], detail.job_id AS [@job_id],
+ detail.stock_id AS [@stock_id], detail.lot_no AS [@lot_no],
+ detail.in_date AS [@in_date], detail.in_or_out_id AS [@request_id],
+ detail.in_or_out_d_id AS [@request_detail_id], detail.product_id AS [@product_id],
+ detail.stock_status AS [@stock_status], detail.in_quantity AS [@in_quantity],
+ detail.stock_quantity AS [@stock_quantity], detail.out_quantity AS [@out_quantity],
+ detail.pick_quantity AS [@pick_quantity], detail.stockin_detail_id AS [@stockin_detail_id],
+ detail.spare1 AS [@spare1], detail.spare2 AS [@spare2], detail.spare3 AS [@spare3]
+ FROM dbo.mydo_job_detail detail WITH (NOLOCK)
+ JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
+ ORDER BY detail.job_detail_id
+ FOR XML PATH(''job_detail''), ROOT(''job_details''), TYPE
+ )), '''');
+ SET @source_payload = @source_payload + ''|OUTBOUND|'' + ISNULL(CONVERT(nvarchar(max), (
+ SELECT outbound.stockout_detail_id AS [@stockout_detail_id], outbound.warehouse_id AS [@warehouse_id],
+ stockout.warehouse_id AS [@source_warehouse_id],
+ outbound.spare4 AS [@project_no],
+ outbound.spare1 AS [@spare1], outbound.spare2 AS [@spare2], outbound.spare3 AS [@spare3],
+ outbound.fid AS [@voltage], outbound.unit AS [@phase],
+ outbound.product_id AS [@product_id], outbound.lot_no AS [@lot_no],
+ outbound.quantity AS [@quantity], outbound.assign_num AS [@assign_num],
+ outbound.allot_quantity AS [@allot_quantity]
+ FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
+ JOIN dbo.out_stockout stockout WITH (NOLOCK) ON stockout.stockout_id = outbound.stockout_id
+ WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)
+ AND (LTRIM(RTRIM(stockout.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
+ OR (@numeric_warehouse_id IS NOT NULL
+ AND LTRIM(RTRIM(stockout.warehouse_id)) NOT LIKE ''%[^0-9]%''
+ AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockout.warehouse_id))) = @numeric_warehouse_id))
+ ORDER BY outbound.stockout_detail_id
+ FOR XML PATH(''outbound''), ROOT(''outbound_requests''), TYPE
+ )), '''');
+ SET @post_source_signature = HASHBYTES(''SHA2_256'', CONVERT(varbinary(max), @source_payload));
+
+ IF @pre_source_count <> @post_source_count
+ OR @pre_source_signature <> @post_source_signature
+ THROW 51025, ''FT546 shadow refresh refused: source changed during refresh'', 1;
+
+ MERGE alg.shadow_snapshot_state WITH (HOLDLOCK) AS target
+ USING (SELECT @warehouse_id AS warehouse_id) AS source
+ ON source.warehouse_id = target.warehouse_id
+ WHEN MATCHED THEN UPDATE SET
+ snapshot_generation = @snapshot_generation,
+ snapshot_status = ''PUBLISHED'',
+ synchronized_at = SYSUTCDATETIME(),
+ source_signature = @post_source_signature,
+ source_row_count = @post_source_count,
+ instance_id = @instance_id,
+ last_error = NULL
+ WHEN NOT MATCHED THEN INSERT (
+ warehouse_id, snapshot_generation, snapshot_status, synchronized_at,
+ source_signature, source_row_count, instance_id, last_error)
+ VALUES (
+ @warehouse_id, @snapshot_generation, ''PUBLISHED'', SYSUTCDATETIME(),
+ @post_source_signature, @post_source_count, @instance_id, NULL);
+
+ COMMIT TRANSACTION;
+ END TRY
+ BEGIN CATCH
+ IF @@TRANCOUNT > 0
+ ROLLBACK TRANSACTION;
+ THROW;
+ END CATCH;
+END;';
+ EXEC sys.sp_executesql @refresh_sql;
+
+ -- Keep the generation monotonic; old rows cannot become predicate-version-1 evidence.
+ UPDATE alg.shadow_snapshot_state WITH (UPDLOCK, HOLDLOCK)
+ SET snapshot_status = 'INITIALIZING', source_signature = NULL, synchronized_at = NULL,
+ last_error = NULL;
+ UPDATE alg.shadow_package_manifest
+ SET package_version = '2026-09-07-34'
+ WHERE package_id = 'FT546WMS_SHADOW_ONLY' AND package_version = '2026-09-07-33';
+ IF @@ROWCOUNT <> 1
+ THROW 51069, 'V3.4 upgrade refused: manifest changed during upgrade', 1;
+ COMMIT TRANSACTION;
+ PRINT 'V34_UPGRADE_OK';
+END TRY
+BEGIN CATCH
+ IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
+ THROW;
+END CATCH;
+GO
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowInboundReference.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowInboundReference.java
index 2b5701d..608e254 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowInboundReference.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowInboundReference.java
@@ -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;
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java
index b5d2ab9..c6c5efb 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java
@@ -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();
}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java
index 142ccef..799d34d 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java
@@ -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);
diff --git a/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml b/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml
index 465f443..684ab42 100644
--- a/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml
+++ b/ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml
@@ -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}
+
+
+
item.warehouse_id = #{warehouseId}
and item.snapshot_generation = #{generation}
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowInboundEligibilityTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowInboundEligibilityTest.java
new file mode 100644
index 0000000..98e3fd4
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowInboundEligibilityTest.java
@@ -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);
+ }
+}
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowInboundProjectionTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowInboundProjectionTest.java
new file mode 100644
index 0000000..4e0d6a2
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowInboundProjectionTest.java
@@ -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));
+ }
+ }
+}
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowInboundValidationTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowInboundValidationTest.java
new file mode 100644
index 0000000..b8dec1d
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowInboundValidationTest.java
@@ -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());
+ }
+}
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java
index 90921ca..fcc2c39 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java
@@ -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;
}
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSchemaCheckTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSchemaCheckTest.java
new file mode 100644
index 0000000..f7187e0
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSchemaCheckTest.java
@@ -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);
+ }
+}
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowV3OperationsPackageTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowV3OperationsPackageTest.java
index 351e261..f349222 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowV3OperationsPackageTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowV3OperationsPackageTest.java
@@ -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");
diff --git a/ruoyi-admin/src/test/resources/shadow/Sqlcmd13OutputFixture.cs b/ruoyi-admin/src/test/resources/shadow/Sqlcmd13OutputFixture.cs
index 17f564e..68a9992 100644
--- a/ruoyi-admin/src/test/resources/shadow/Sqlcmd13OutputFixture.cs
+++ b/ruoyi-admin/src/test/resources/shadow/Sqlcmd13OutputFixture.cs
@@ -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;
diff --git a/scripts/shadow-v3-install.cmd b/scripts/shadow-v3-install.cmd
index 88a3c22..2206372 100644
--- a/scripts/shadow-v3-install.cmd
+++ b/scripts/shadow-v3-install.cmd
@@ -10,31 +10,37 @@ set "DATABASE=%~2"
set "MODE=%~3"
set "SQL_FILE="
set "V33_FILE=%~dp0..\fortucky-algorithm\src\main\resources\db\sqlserver\upgrade-shadow-v3-to-v33.sql"
+set "V34_FILE=%~dp0..\fortucky-algorithm\src\main\resources\db\sqlserver\upgrade-shadow-v33-to-v34.sql"
if /I "%~3"=="upgrade" set "SQL_FILE=%~dp0..\fortucky-algorithm\src\main\resources\db\sqlserver\upgrade-shadow-v2-to-v3.sql"
if /I "%~3"=="clean" set "SQL_FILE=%~dp0..\fortucky-algorithm\src\main\resources\db\sqlserver\install-shadow.sql"
if /I "%~3"=="v33" set "SQL_FILE=%V33_FILE%"
-if not defined SQL_FILE (call :fail "mode must be upgrade, clean or v33" & exit /b 1)
+if /I "%~3"=="v34" set "SQL_FILE=%V34_FILE%"
+if not defined SQL_FILE (call :fail "mode must be upgrade, clean, v33 or v34" & exit /b 1)
if not exist "%SQL_FILE%" (call :fail "SQL package file is missing" & exit /b 1)
if not exist "%V33_FILE%" (call :fail "V3.3 SQL package file is missing" & exit /b 1)
+if not exist "%V34_FILE%" (call :fail "V3.4 SQL package file is missing" & exit /b 1)
where sqlcmd >nul 2>nul
if errorlevel 1 (call :fail "sqlcmd is not available on PATH" & exit /b 1)
echo WARNING: stop the WMS shadow-enabled JVM and wait over 120 seconds first.
-echo Installing FT546WMS shadow V3.3 in %MODE% mode...
+echo Installing FT546WMS shadow package in %MODE% mode...
sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%SQL_FILE%"
if errorlevel 1 exit /b 1
if /I "%MODE%"=="v33" goto :installed
+if /I "%MODE%"=="v34" goto :installed
sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%V33_FILE%"
if errorlevel 1 exit /b 1
+sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%V34_FILE%"
+if errorlevel 1 exit /b 1
:installed
echo INSTALL_OK mode=%MODE%
exit /b 0
:usage
-echo Usage: %~nx0 SQL_SERVER DATABASE upgrade^|clean^|v33 1>&2
+echo Usage: %~nx0 SQL_SERVER DATABASE upgrade^|clean^|v33^|v34 1>&2
exit /b 2
:fail
diff --git a/scripts/shadow-v3-preflight.cmd b/scripts/shadow-v3-preflight.cmd
index c46c58e..16ef022 100644
--- a/scripts/shadow-v3-preflight.cmd
+++ b/scripts/shadow-v3-preflight.cmd
@@ -52,10 +52,13 @@ if errorlevel 1 (call :fail "jps could not inspect running JVMs" & exit /b 1)
for /f %%I in ('jps -lv 2^>nul ^| findstr /R /I /C:"^[0-9][0-9]* com\.ruoyi\.RuoYiApplication" ^| find /C /V ""') do set "WMS_PROCESS_COUNT=%%I"
if %WMS_PROCESS_COUNT% GTR 1 (call :fail "more than one FT546WMS JVM is running" & exit /b 1)
-set "PREFLIGHT_SQL=SET NOCOUNT ON; IF SCHEMA_ID(N'alg') IS NULL SELECT N'CLEAN_INSTALL_ELIGIBLE' AS package_state; ELSE BEGIN IF OBJECT_ID(N'alg.shadow_package_manifest', N'U') IS NULL BEGIN; THROW 51050, 'shadow manifest is missing', 1; END; IF (SELECT COUNT(*) FROM alg.shadow_package_manifest) <> 1 OR NOT EXISTS (SELECT 1 FROM alg.shadow_package_manifest WHERE package_id='FT546WMS_SHADOW_ONLY' AND package_version IN ('2026-08-12-1','2026-09-03-3','2026-09-07-33')) BEGIN; THROW 51051, 'shadow manifest is not a supported version', 1; END; IF EXISTS (SELECT 1 FROM alg.shadow_package_manifest WHERE package_id='FT546WMS_SHADOW_ONLY' AND package_version IN ('2026-09-03-3','2026-09-07-33')) BEGIN IF OBJECT_ID(N'alg.shadow_snapshot_state', N'U') IS NULL BEGIN; THROW 51052, 'snapshot state is missing', 1; END; IF OBJECT_ID(N'alg.shadow_instance_heartbeat', N'U') IS NULL BEGIN; THROW 51055, 'shadow instance heartbeat is missing', 1; END; IF NOT EXISTS (SELECT 1 FROM alg.shadow_snapshot_state WHERE warehouse_id='01' AND snapshot_generation>0 AND snapshot_status='PUBLISHED' AND synchronized_at IS NOT NULL AND DATEDIFF(SECOND,synchronized_at,SYSUTCDATETIME()) BETWEEN 0 AND 300 AND source_signature IS NOT NULL AND last_error IS NULL) BEGIN; THROW 51053, 'published warehouse 01 snapshot is unusable', 1; END; IF EXISTS (SELECT 1 FROM alg.shadow_instance_heartbeat WHERE warehouse_id='01' AND ((DATEDIFF(SECOND,last_seen_at,SYSUTCDATETIME()) BETWEEN 0 AND 120) OR (DATEDIFF(SECOND,claimed_at,SYSUTCDATETIME()) BETWEEN 0 AND 120))) BEGIN; THROW 51054, 'a recent shadow instance heartbeat or claim exists', 1; END; END; SELECT package_id, package_version, installed_at FROM alg.shadow_package_manifest; IF OBJECT_ID(N'alg.shadow_snapshot_state', N'U') IS NOT NULL SELECT warehouse_id, snapshot_generation, snapshot_status, synchronized_at, CONVERT(varchar(64), source_signature, 2) AS source_signature, last_error FROM alg.shadow_snapshot_state; END"
+set "PREFLIGHT_SQL=SET NOCOUNT ON; IF SCHEMA_ID(N'alg') IS NULL SELECT N'CLEAN_INSTALL_ELIGIBLE' AS package_state; ELSE BEGIN IF OBJECT_ID(N'alg.shadow_package_manifest', N'U') IS NULL BEGIN; THROW 51050, 'shadow manifest is missing', 1; END; IF (SELECT COUNT(*) FROM alg.shadow_package_manifest) <> 1 OR NOT EXISTS (SELECT 1 FROM alg.shadow_package_manifest WHERE package_id='FT546WMS_SHADOW_ONLY' AND package_version IN ('2026-08-12-1','2026-09-03-3','2026-09-07-33','2026-09-07-34')) BEGIN; THROW 51051, 'shadow manifest is not a supported version', 1; END; IF EXISTS (SELECT 1 FROM alg.shadow_package_manifest WHERE package_id='FT546WMS_SHADOW_ONLY' AND package_version IN ('2026-09-03-3','2026-09-07-33','2026-09-07-34')) BEGIN IF OBJECT_ID(N'alg.shadow_snapshot_state', N'U') IS NULL BEGIN; THROW 51052, 'snapshot state is missing', 1; END; IF OBJECT_ID(N'alg.shadow_instance_heartbeat', N'U') IS NULL BEGIN; THROW 51055, 'shadow instance heartbeat is missing', 1; END; IF NOT EXISTS (SELECT 1 FROM alg.shadow_snapshot_state WHERE warehouse_id='01' AND snapshot_generation>0 AND snapshot_status='PUBLISHED' AND synchronized_at IS NOT NULL AND DATEDIFF(SECOND,synchronized_at,SYSUTCDATETIME()) BETWEEN 0 AND 300 AND source_signature IS NOT NULL AND last_error IS NULL) BEGIN; THROW 51053, 'published warehouse 01 snapshot is unusable', 1; END; IF EXISTS (SELECT 1 FROM alg.shadow_instance_heartbeat WHERE warehouse_id='01' AND ((DATEDIFF(SECOND,last_seen_at,SYSUTCDATETIME()) BETWEEN 0 AND 120) OR (DATEDIFF(SECOND,claimed_at,SYSUTCDATETIME()) BETWEEN 0 AND 120))) BEGIN; THROW 51054, 'a recent shadow instance heartbeat or claim exists', 1; END; END; SELECT package_id, package_version, installed_at FROM alg.shadow_package_manifest; IF OBJECT_ID(N'alg.shadow_snapshot_state', N'U') IS NOT NULL SELECT warehouse_id, snapshot_generation, snapshot_status, synchronized_at, CONVERT(varchar(64), source_signature, 2) AS source_signature, last_error FROM alg.shadow_snapshot_state; END"
sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -Q "%PREFLIGHT_SQL%"
if errorlevel 1 (call :fail "SQL connectivity or shadow package validation failed" & exit /b 1)
+sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%~dp0shadow-v34-schema-check.sql"
+if errorlevel 1 (call :fail "shadow package schema validation failed" & exit /b 1)
+
echo PREFLIGHT_OK branch=%CURRENT_BRANCH% commit=%CURRENT_COMMIT% wmsProcesses=%WMS_PROCESS_COUNT%
exit /b 0
diff --git a/scripts/shadow-v34-schema-check.sql b/scripts/shadow-v34-schema-check.sql
new file mode 100644
index 0000000..8c62b30
--- /dev/null
+++ b/scripts/shadow-v34-schema-check.sql
@@ -0,0 +1,85 @@
+/* Read-only structural gate for installed V3.4 packages. No refresh or business procedure calls. */
+SET NOCOUNT ON;
+SET LOCK_TIMEOUT 5000;
+SET DEADLOCK_PRIORITY LOW;
+
+IF OBJECT_ID(N'alg.shadow_package_manifest', N'U') IS NULL RETURN;
+IF NOT EXISTS (SELECT 1 FROM alg.shadow_package_manifest
+ WHERE package_id = 'FT546WMS_SHADOW_ONLY' AND package_version = '2026-09-07-34') RETURN;
+
+DECLARE @expected TABLE (object_name sysname PRIMARY KEY, object_type char(2) NOT NULL);
+INSERT INTO @expected VALUES
+(N'shadow_package_manifest','U'),(N'warehouse_policy','U'),(N'project_policy','U'),
+(N'space_role','U'),(N'space_topology','U'),(N'inventory_item_snapshot','U'),
+(N'space_inventory_summary','U'),(N'lane_state','U'),(N'reservation','U'),
+(N'deep_workflow_lock','U'),(N'decision_log','U'),(N'shadow_snapshot_state','U'),
+(N'cargo_space_snapshot','U'),(N'active_job_snapshot','U'),
+(N'shadow_inbound_request_snapshot','U'),(N'shadow_outbound_request_snapshot','U'),
+(N'shadow_runtime_metric','U'),(N'shadow_instance_heartbeat','U'),
+(N'v_inventory_item','V'),(N'v_active_job','V'),(N'v_space_state','V'),
+(N'usp_rebuild_space_topology','P'),(N'usp_sync_inventory_snapshot','P'),
+(N'usp_compute_lane_semantic_fingerprint','P'),(N'usp_rebuild_lane_state','P'),
+(N'usp_refresh_dirty_lanes','P'),(N'usp_claim_shadow_instance','P'),
+(N'usp_refresh_shadow_snapshot','P');
+
+IF EXISTS (SELECT 1 FROM sys.objects actual LEFT JOIN @expected expected
+ ON expected.object_name = actual.name AND expected.object_type = actual.type COLLATE DATABASE_DEFAULT
+ WHERE actual.schema_id = SCHEMA_ID(N'alg') AND actual.is_ms_shipped = 0
+ AND actual.parent_object_id = 0 AND expected.object_name IS NULL)
+ THROW 51071, 'V3.4 schema check failed: unknown alg object', 1;
+IF EXISTS (SELECT 1 FROM @expected expected LEFT JOIN sys.objects actual
+ ON actual.schema_id = SCHEMA_ID(N'alg') AND actual.name = expected.object_name
+ AND actual.type COLLATE DATABASE_DEFAULT = expected.object_type
+ WHERE actual.object_id IS NULL)
+ THROW 51072, 'V3.4 schema check failed: expected alg object missing or wrong type', 1;
+IF EXISTS (SELECT 1 FROM sys.triggers t JOIN sys.objects parent ON parent.object_id = t.parent_id
+ WHERE parent.schema_id = SCHEMA_ID(N'alg'))
+ THROW 51073, 'V3.4 schema check failed: unexpected shadow trigger', 1;
+
+DECLARE @columns TABLE (
+ table_name sysname, column_name sysname, type_name sysname, max_length smallint,
+ precision_value tinyint, scale_value tinyint, PRIMARY KEY (table_name, column_name));
+INSERT INTO @columns VALUES
+(N'inventory_item_snapshot',N'spare1',N'varchar',255,0,0),
+(N'inventory_item_snapshot',N'spare2',N'varchar',255,0,0),
+(N'inventory_item_snapshot',N'spare3',N'varchar',255,0,0),
+(N'inventory_item_snapshot',N'voltage',N'varchar',255,0,0),
+(N'inventory_item_snapshot',N'phase',N'varchar',255,0,0),
+(N'inventory_item_snapshot',N'tray_job_count',N'int',4,10,0),
+(N'shadow_outbound_request_snapshot',N'spare1',N'varchar',255,0,0),
+(N'shadow_outbound_request_snapshot',N'spare2',N'varchar',255,0,0),
+(N'shadow_outbound_request_snapshot',N'spare3',N'varchar',255,0,0),
+(N'shadow_outbound_request_snapshot',N'voltage',N'varchar',255,0,0),
+(N'shadow_outbound_request_snapshot',N'phase',N'varchar',255,0,0),
+(N'shadow_outbound_request_snapshot',N'allocated_quantity',N'decimal',17,38,6),
+(N'shadow_outbound_request_snapshot',N'predicate_version',N'int',4,10,0),
+(N'cargo_space_snapshot',N'this_cargo_area',N'varchar',255,0,0),
+(N'shadow_inbound_request_snapshot',N'spare1',N'varchar',255,0,0),
+(N'shadow_inbound_request_snapshot',N'spare2',N'varchar',255,0,0),
+(N'shadow_inbound_request_snapshot',N'spare3',N'varchar',255,0,0),
+(N'shadow_inbound_request_snapshot',N'fault_flag',N'varchar',255,0,0),
+(N'shadow_inbound_request_snapshot',N'detail_count',N'int',4,10,0),
+(N'shadow_inbound_request_snapshot',N'field_group_count',N'int',4,10,0),
+(N'shadow_inbound_request_snapshot',N'product_scope_count',N'int',4,10,0),
+(N'shadow_inbound_request_snapshot',N'tray_job_count',N'int',4,10,0),
+(N'shadow_inbound_request_snapshot',N'legacy_scope_complete',N'int',4,10,0),
+(N'shadow_inbound_request_snapshot',N'predicate_version',N'int',4,10,0);
+
+IF EXISTS (
+ SELECT 1 FROM @columns expected
+ LEFT JOIN sys.columns column_info
+ ON column_info.object_id = OBJECT_ID(N'alg.' + expected.table_name)
+ AND column_info.name = expected.column_name
+ WHERE column_info.column_id IS NULL
+ OR TYPE_NAME(column_info.user_type_id) <> expected.type_name
+ OR column_info.max_length <> expected.max_length
+ OR column_info.precision <> expected.precision_value
+ OR column_info.scale <> expected.scale_value
+ OR column_info.is_nullable <> 1)
+ THROW 51074, 'V3.4 schema check failed: predicate columns missing or incompatible', 1;
+
+DECLARE @definition nvarchar(max) = OBJECT_DEFINITION(OBJECT_ID(N'alg.usp_refresh_shadow_snapshot', N'P'));
+IF @definition IS NULL OR CHARINDEX(N'INBOUND_JOB_FIELDS_V1', @definition) = 0
+ OR CHARINDEX(N'field_group_count', @definition) = 0 OR CHARINDEX(N'this_cargo_area', @definition) = 0
+ THROW 51075, 'V3.4 schema check failed: refresh procedure missing, unreadable or obsolete', 1;
+PRINT 'V34_SCHEMA_OK';