fix(shadow): align outbound predicates with site data for v3.3

Use the business project and captured site predicates, validate single-stock proposals, and skip unsupported scopes without changing Legacy. Add the guarded V3.3 SQL upgrade, CMD operations, regression tests and deployment runbook.
This commit is contained in:
zgw
2026-09-07 12:43:43 +08:00
parent 79e0db29f9
commit 699310e61d
18 changed files with 1394 additions and 25 deletions

View File

@@ -1,5 +1,7 @@
# FT546WMS 影子 v3 现场操作手册 # FT546WMS 影子 v3 现场操作手册
已部署 V3/V3.1/V3.2 的现场升级到 V3.3,请先使用 [V3.3 升级和回滚步骤](SHADOW_V33_DEPLOYMENT.md)。下面保留最初 v2 到 v3 的历史操作说明,不要把“当前现场已有 v2”套用于已升级现场。
## 1. 这次部署做什么 ## 1. 这次部署做什么
目标分支:`test/ft546wms-shadow-v3-20260903`。 目标分支:`test/ft546wms-shadow-v3-20260903`。

View File

@@ -0,0 +1,114 @@
# FT546WMS 影子 V3.3:升级和回滚
适用现场:WMS 已运行影子 V3/V3.1/V3.2,数据库包版本为 `2026-09-03-3`。
分支仍为 `test/ft546wms-shadow-v3-20260903`。这不是正式算法切换。
## 这次改什么
自动出库影子请求的项目号改取出库明细 `spare4`,不再取仓库配置中的 `FT546`。
补齐产品、spare1/2/3、项目、fid 对电压、unit 对相别的可选筛选,以及库位锁定、状态、托盘一致性检查。
按计划数量减已分配数量观察。新旧输入通过 `predicate_version` 区分,不把历史样本重新认定为新版本通过。
共享规划器和 Legacy 存储过程、调用入口没有修改。升级只新增 `alg` 表的可空字段、替换影子刷新过程、使旧快照失效并更新影子包版本。保留决策日志和累计指标。
注意:影子后台仍会增加数据库读取和计算开销,不能承诺对性能绝对零影响。升级过程需要一次计划内短暂停机。
## 第一步:先准备,不停程序
让服务器 AI 核实 IDEA 当前运行的 **WMS** 项目目录、分支、提交号、环境变量和日志目录,备份本地配置,并导出现有影子日志。
不要操作 WCS 或 WCS 影子程序,也不要因进程里有 `wcs` 字样就停止它。
在已经确认的 WMS 项目根目录打开 CMD,执行:
```cmd
git status --short
git branch --show-current
git rev-parse HEAD
git fetch origin
```
保存当前提交号作为回退点。工作区有改动时先停止同步,让服务器 AI 保存和核对改动,不要强制覆盖。
获得本次已推送的交付提交号后,再执行 `git pull --ff-only` 并核对 `git rev-parse HEAD`。
本地未推送的代码不能靠服务器 `git pull` 获得,不要猜提交号。
IDEA 重新加载 Maven,使用当前可用的 JDK 17 和 Maven 3.9.11 编译测试。
首次可能需要下载 H2 测试依赖;H2 不进入运行包,依赖问题应在停机前解决。
程序运行期间不要点 Build Project、热替换或重启;可在独立准备目录做构建,确认完成后再安排维护窗口。
## 第二步:先在数据库备份副本演练
本地 Java 测试和 SQL 2016 语法解析不能代替 SQL Server 实际升级验证。
让服务器 AI/DBA 在隔离 SQL Server 上恢复备份(数据库名保持 `FT546-wms`),先验证升级成功、重复执行无破坏、升级失败可回滚。
不要在生产库创建“测试作业”,不要触发业务回放。演练失败就不进入下一步。
## 第三步:低峰时停止 WMS,升级影子表
1. 在 IDEA 停止当前那个 WMS 实例,确认其 PID 已退出。不要同时启动稳定 WMS 和影子 WMS。
2. 等待至少 130 秒,确认没有其他 WMS 影子实例仍在运行。
3. 在项目根目录的 CMD 执行下面命令(使用现有 Windows 集成认证):
```cmd
call scripts\shadow-v3-install.cmd "10.6.22.6,1433" "FT546-wms" v33
```
必须看到 `V33_UPGRADE_OK`(重复执行为 `V33_ALREADY_INSTALLED`)和 `INSTALL_OK mode=v33`。
现场已有 V3,**不要用 `clean`,不要卸载后重装,不要执行旧版 v2 升级来绕过错误**。
遇到心跳、清单、字段或锁超时错误,保存完整输出并停止。脚本对本次变更执行事务回滚;不要修改 manifest 绕过检查。
## 第四步:只启动一次,保持影子配置
沿用现场已有 Run Configuration,只核对下面两个值,其他连接、日志和限流设置不要随意覆盖:
```text
FORTUCKY_SHADOW_ENABLED=true
FORTUCKY_ALGORITHM_MODE=SHADOW
```
在 IDEA 启动一次 WMS。记录启动时间和时区。不要设置 `ACTIVE`。
旧快照已失效,后台会重新生成;刚启动还没有新快照或样本,不代表需要再次重启。
先检查登录、查询,再由现场人员按正常流程验证入库、自动出库、手动出库。
让服务器 AI 仅做只读核对:
```sql
SELECT package_id, package_version FROM alg.shadow_package_manifest;
SELECT warehouse_id, snapshot_generation, snapshot_status, synchronized_at, last_error
FROM alg.shadow_snapshot_state WHERE warehouse_id = '01';
SELECT TOP (20) stockout_detail_id, project_no, product_id, requested_quantity,
allocated_quantity, predicate_version, synchronized_at
FROM alg.shadow_outbound_request_snapshot
WHERE warehouse_id = '01' ORDER BY synchronized_at DESC;
SELECT TOP (30) * FROM alg.decision_log ORDER BY created_at DESC;
```
包版本应为 `2026-09-07-33`,新快照应为 `PUBLISHED`,正常长度的请求输入应有 `predicate_version=1`。
实际自动出库产生后,新日志应包含 `predicateVersion=1`;可用建议还应有真实的 `proposedStock` 和 `selectedProject`。
快照刷新由事件触发,没业务时同步时间不连续推进,不要仅凭这个重启。
## 出问题怎么回滚
最短路径:停止 WMS,在原 IDEA 配置里只把 `FORTUCKY_SHADOW_ENABLED` 改为 `false`,完整重启一次,再检查正常业务。
如仍异常,停止这个实例,使用第一步保留的原稳定源码和 Run Configuration 启动,仍保持影子关闭。
不要两个 WMS 同时运行;不要执行 `git reset --hard` 来覆盖现场配置。
紧急回滚不卸载数据库。新增的 `alg` 字段不参与 Legacy;保留它们和历史数据供排查。
若回到旧影子代码,也先关闭影子,避免用旧逻辑继续采集混杂样本。
确实需要完整卸载时,必须先备份影子数据、恢复稳定业务并停用影子,等待至少 130 秒,再由 DBA 执行:
```cmd
call scripts\shadow-v3-uninstall.cmd "10.6.22.6,1433" "FT546-wms" CONFIRM_UNINSTALL
```
卸载会删除全部本包 `alg` 对象及历史数据,只能从事先导出的证据/数据库备份恢复。它不是升级的必需步骤。
## 后续怎样判断
重新记录 V3.3 开始时间,只分析此次重启后的自然业务样本,建议覆盖至少 7 天及高峰。
继续使用 `shadow-v3-export.cmd`,让服务器 AI 同时提供准确分支、提交、IDEA 配置核对结果、完整日志和本次升级输出。
累计性能指标包含旧版本,必须用升级前后导出差值,不能当成 V3.3 单独指标。
本版保守跳过:多项目/多产品候选、候选超过 200 行、建议不在合格候选集、一托多库存行、单条库存不足以覆盖剩余数量,以及托盘已有作业。
现场动态 SQL 参数限长 80 字节;超长原始条件保留在快照,但标记版本 0,不作为有效输入。不会截断条件来凑匹配。
这些跳过不算通过。`READY` 只表示受支持范围内的库存建议通过核验,不证明完整出库流程、FIFO 效果、性能或正式切换已验收。

View File

@@ -2,7 +2,7 @@ SET NOCOUNT ON;
SET XACT_ABORT 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 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 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='2026-09-03-3')<>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'))<>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); DECLARE @expected_objects TABLE (object_name sysname NOT NULL PRIMARY KEY, object_type char(2) NOT NULL);
INSERT INTO @expected_objects VALUES 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'), (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'),
@@ -11,6 +11,7 @@ IF EXISTS (SELECT 1 FROM sys.objects object LEFT JOIN @expected_objects expected
IF EXISTS (SELECT 1 FROM @expected_objects 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 51014, 'FT546 shadow uninstall refused: expected object is missing or has wrong type', 1; IF EXISTS (SELECT 1 FROM @expected_objects 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 51014, 'FT546 shadow uninstall refused: expected object is missing or has wrong type', 1;
BEGIN TRY BEGIN TRY
BEGIN TRANSACTION; BEGIN TRANSACTION;
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 51015, 'FT546 shadow uninstall refused: recent heartbeat; stop WMS and wait over 120 seconds', 1;
DROP PROCEDURE alg.usp_refresh_shadow_snapshot; DROP PROCEDURE alg.usp_claim_shadow_instance; DROP PROCEDURE alg.usp_refresh_dirty_lanes; DROP PROCEDURE alg.usp_rebuild_lane_state; DROP PROCEDURE alg.usp_compute_lane_semantic_fingerprint; DROP PROCEDURE alg.usp_sync_inventory_snapshot; DROP PROCEDURE alg.usp_rebuild_space_topology; DROP PROCEDURE alg.usp_refresh_shadow_snapshot; DROP PROCEDURE alg.usp_claim_shadow_instance; DROP PROCEDURE alg.usp_refresh_dirty_lanes; DROP PROCEDURE alg.usp_rebuild_lane_state; DROP PROCEDURE alg.usp_compute_lane_semantic_fingerprint; DROP PROCEDURE alg.usp_sync_inventory_snapshot; DROP PROCEDURE alg.usp_rebuild_space_topology;
DROP VIEW alg.v_space_state; DROP VIEW alg.v_active_job; DROP VIEW alg.v_inventory_item; DROP VIEW alg.v_space_state; DROP VIEW alg.v_active_job; DROP VIEW alg.v_inventory_item;
DROP TABLE alg.shadow_instance_heartbeat; DROP TABLE alg.shadow_runtime_metric; DROP TABLE alg.shadow_outbound_request_snapshot; DROP TABLE alg.shadow_inbound_request_snapshot; DROP TABLE alg.active_job_snapshot; DROP TABLE alg.cargo_space_snapshot; DROP TABLE alg.shadow_snapshot_state; DROP TABLE alg.decision_log; DROP TABLE alg.deep_workflow_lock; DROP TABLE alg.reservation; DROP TABLE alg.lane_state; DROP TABLE alg.space_inventory_summary; DROP TABLE alg.inventory_item_snapshot; DROP TABLE alg.space_topology; DROP TABLE alg.space_role; DROP TABLE alg.project_policy; DROP TABLE alg.warehouse_policy; DROP TABLE alg.shadow_package_manifest; DROP TABLE alg.shadow_instance_heartbeat; DROP TABLE alg.shadow_runtime_metric; DROP TABLE alg.shadow_outbound_request_snapshot; DROP TABLE alg.shadow_inbound_request_snapshot; DROP TABLE alg.active_job_snapshot; DROP TABLE alg.cargo_space_snapshot; DROP TABLE alg.shadow_snapshot_state; DROP TABLE alg.decision_log; DROP TABLE alg.deep_workflow_lock; DROP TABLE alg.reservation; DROP TABLE alg.lane_state; DROP TABLE alg.space_inventory_summary; DROP TABLE alg.inventory_item_snapshot; DROP TABLE alg.space_topology; DROP TABLE alg.space_role; DROP TABLE alg.project_policy; DROP TABLE alg.warehouse_policy; DROP TABLE alg.shadow_package_manifest;

View File

@@ -0,0 +1,557 @@
/*
FT546WMS shadow V3/V3.1/V3.2 -> V3.3, 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.3 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.3 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'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);
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-03-3', '2026-09-07-33')
OR (SELECT COUNT(*) FROM alg.shadow_package_manifest) <> 1
THROW 51062, 'V3.3 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.3 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.3 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.3 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.3 upgrade refused: recent shadow heartbeat; stop WMS and wait over 120 seconds', 1;
IF @version = '2026-09-07-33'
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.3 upgrade refused: target columns are missing or incompatible', 1;
COMMIT TRANSACTION;
PRINT 'V33_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.3 upgrade refused: partial or foreign predicate columns already exist', 1;
ALTER TABLE alg.inventory_item_snapshot ADD
spare1 varchar(255) NULL, spare2 varchar(255) NULL, spare3 varchar(255) NULL,
voltage varchar(255) NULL, phase varchar(255) NULL, tray_job_count int NULL;
ALTER TABLE alg.shadow_outbound_request_snapshot ADD
spare1 varchar(255) NULL, spare2 varchar(255) NULL, spare3 varchar(255) NULL,
voltage varchar(255) NULL, phase varchar(255) NULL,
allocated_quantity decimal(38,6) 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
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) WHERE cargo.warehouse_id = @warehouse_id)
+ (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) WHERE job.warehouse_id = @warehouse_id)
+ (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
WHERE job.warehouse_id = @warehouse_id)
+ (SELECT COUNT_BIG(*) FROM dbo.in_stockin_detail inbound WITH (NOLOCK)
JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
JOIN dbo.in_stockin stockin WITH (NOLOCK) ON stockin.stockin_id = inbound.stockin_id
WHERE (job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '''')
AND (LTRIM(RTRIM(stockin.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockin.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockin.warehouse_id))) = @numeric_warehouse_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]
FROM dbo.bas_cargo_space cargo WITH (NOLOCK)
WHERE cargo.warehouse_id = @warehouse_id
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]
FROM dbo.mydo_job job WITH (NOLOCK)
WHERE job.warehouse_id = @warehouse_id
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]
FROM dbo.mydo_job_detail detail WITH (NOLOCK)
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
WHERE job.warehouse_id = @warehouse_id
ORDER BY detail.job_detail_id
FOR XML PATH(''job_detail''), ROOT(''job_details''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|INBOUND|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT inbound.stockin_detail_id AS [@stockin_detail_id], inbound.stockin_guid AS [@stockin_guid],
inbound.stockin_id AS [@stockin_id], inbound.lot_no AS [@lot_no],
inbound.product_id AS [@product_id], inbound.quantity AS [@quantity],
inbound.assign_num AS [@assign_num], inbound.spare4 AS [@project_no],
job.warehouse_id AS [@warehouse_id], stockin.warehouse_id AS [@source_warehouse_id],
job.task_no AS [@task_no],
job.tray_code AS [@tray_code], detail.job_id AS [@job_id],
detail.in_or_out_id AS [@request_id]
FROM dbo.in_stockin_detail inbound WITH (NOLOCK)
JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
JOIN dbo.in_stockin stockin WITH (NOLOCK) ON stockin.stockin_id = inbound.stockin_id
WHERE (job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '''')
AND (LTRIM(RTRIM(stockin.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockin.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockin.warehouse_id))) = @numeric_warehouse_id))
ORDER BY inbound.stockin_detail_id
FOR XML PATH(''inbound''), ROOT(''inbound_requests''), 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)
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
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_source AS (
SELECT @warehouse_id AS warehouse_id, job.task_no, job.tray_code,
detail.in_or_out_id AS request_id, job.warehouse_id AS raw_warehouse_id,
inbound.spare4 AS project_no, inbound.product_id, inbound.lot_no,
ROW_NUMBER() OVER (
PARTITION BY job.task_no, job.tray_code
ORDER BY inbound.stockin_detail_id, detail.job_detail_id) AS source_rank
FROM dbo.in_stockin_detail inbound WITH (NOLOCK)
JOIN dbo.mydo_job_detail detail WITH (NOLOCK)
ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
JOIN dbo.in_stockin stockin WITH (NOLOCK) ON stockin.stockin_id = inbound.stockin_id
WHERE (job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '''')
AND (LTRIM(RTRIM(stockin.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockin.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockin.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)
SELECT @warehouse_id, ISNULL(task_no, ''''), ISNULL(tray_code, ''''), request_id,
raw_warehouse_id, project_no, product_id, lot_no,
@snapshot_generation, @synchronized_at
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) WHERE cargo.warehouse_id = @warehouse_id)
+ (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) WHERE job.warehouse_id = @warehouse_id)
+ (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
WHERE job.warehouse_id = @warehouse_id)
+ (SELECT COUNT_BIG(*) FROM dbo.in_stockin_detail inbound WITH (NOLOCK)
JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
JOIN dbo.in_stockin stockin WITH (NOLOCK) ON stockin.stockin_id = inbound.stockin_id
WHERE (job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '''')
AND (LTRIM(RTRIM(stockin.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockin.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockin.warehouse_id))) = @numeric_warehouse_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]
FROM dbo.bas_cargo_space cargo WITH (NOLOCK)
WHERE cargo.warehouse_id = @warehouse_id
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]
FROM dbo.mydo_job job WITH (NOLOCK)
WHERE job.warehouse_id = @warehouse_id
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]
FROM dbo.mydo_job_detail detail WITH (NOLOCK)
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
WHERE job.warehouse_id = @warehouse_id
ORDER BY detail.job_detail_id
FOR XML PATH(''job_detail''), ROOT(''job_details''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|INBOUND|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT inbound.stockin_detail_id AS [@stockin_detail_id], inbound.stockin_guid AS [@stockin_guid],
inbound.stockin_id AS [@stockin_id], inbound.lot_no AS [@lot_no],
inbound.product_id AS [@product_id], inbound.quantity AS [@quantity],
inbound.assign_num AS [@assign_num], inbound.spare4 AS [@project_no],
job.warehouse_id AS [@warehouse_id], stockin.warehouse_id AS [@source_warehouse_id],
job.task_no AS [@task_no],
job.tray_code AS [@tray_code], detail.job_id AS [@job_id],
detail.in_or_out_id AS [@request_id]
FROM dbo.in_stockin_detail inbound WITH (NOLOCK)
JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
JOIN dbo.in_stockin stockin WITH (NOLOCK) ON stockin.stockin_id = inbound.stockin_id
WHERE (job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '''')
AND (LTRIM(RTRIM(stockin.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockin.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockin.warehouse_id))) = @numeric_warehouse_id))
ORDER BY inbound.stockin_detail_id
FOR XML PATH(''inbound''), ROOT(''inbound_requests''), 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-33'
WHERE package_id = 'FT546WMS_SHADOW_ONLY' AND package_version = '2026-09-03-3';
IF @@ROWCOUNT <> 1
THROW 51069, 'V3.3 upgrade refused: manifest changed during upgrade', 1;
COMMIT TRANSACTION;
PRINT 'V33_UPGRADE_OK';
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
THROW;
END CATCH;
GO

View File

@@ -0,0 +1,56 @@
package com.ruoyi.algorithm.repository;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import static org.assertj.core.api.Assertions.assertThat;
class ShadowV33MigrationTest {
private String migration() throws Exception {
try (var input = getClass().getResourceAsStream("/db/sqlserver/upgrade-shadow-v3-to-v33.sql")) {
assertThat(input).as("V3.3 migration resource").isNotNull();
return new String(input.readAllBytes(), StandardCharsets.UTF_8).toLowerCase(Locale.ROOT)
.replace("''", "'");
}
}
@Test
void guardsExactOwnershipAndStoppedInstanceBeforeTransactionalChanges() throws Exception {
String sql = migration();
assertThat(sql).contains("2026-09-03-3", "2026-09-07-33", "ft546wms_shadow_only",
"unknown object", "expected object", "updlock, holdlock", "last_seen_at", "claimed_at",
"dateadd(second, -120, sysutcdatetime())", "set lock_timeout 5000", "rollback transaction");
assertThat(sql.indexOf("unknown object")).isLessThan(sql.indexOf("alter table"));
assertThat(sql.indexOf("begin transaction")).isLessThan(sql.indexOf("alter table"));
assertThat(sql.indexOf("recent shadow heartbeat")).isLessThan(sql.indexOf("alter table"));
assertThat(sql).doesNotContain("alter table dbo.", "update dbo.", "delete from dbo.",
"truncate table", "drop table", "delete from alg.decision_log", "update alg.decision_log");
}
@Test
void bothSourceSignaturesCaptureAllNewPredicateInputs() throws Exception {
String sql = migration();
for (String field : new String[]{"spare1", "spare2", "spare3", "voltage", "phase"}) {
assertThat(sql.split("stock\\." + field + " as", -1)).hasSize(3);
}
for (String field : new String[]{"spare1", "spare2", "spare3", "spare4", "fid", "unit"}) {
assertThat(sql.split("outbound\\." + field + " as", -1)).hasSize(3);
}
assertThat(sql).contains("@pre_source_signature <> @post_source_signature", "source changed during refresh");
assertThat(sql.split(java.util.regex.Pattern.quote("as [@tray_job_count]"), -1)).hasSize(3);
}
@Test
void newSnapshotsUseBusinessProjectRemainingInputsAndVersionMarker() throws Exception {
String sql = migration();
assertThat(sql).contains("outbound.spare4, outbound.product_id", "outbound.allot_quantity",
"outbound.spare1, outbound.spare2, outbound.spare3, outbound.fid, outbound.unit",
"datalength(outbound.spare4)", "then 1 else 0 end",
"predicate_version int null", "allocated_quantity decimal(38,6) null", "tray_job_count int null");
assertThat(sql).doesNotContain("warehouse.project_no", "join dbo.bas_warehouse");
assertThat(sql).contains("snapshot_status = 'initializing'", "source_signature = null");
assertThat(sql).doesNotContain("set snapshot_generation = 0");
}
}

View File

@@ -131,6 +131,12 @@
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<!-- sql server驱动包 --> <!-- sql server驱动包 -->
<dependency> <dependency>
<groupId>com.microsoft.sqlserver</groupId> <groupId>com.microsoft.sqlserver</groupId>

View File

@@ -13,6 +13,7 @@ import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant; import java.time.Instant;
import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@@ -29,6 +30,7 @@ public class ShadowPlanningEngine {
private static final int RESULT_REASON_MAX_LENGTH = 600; private static final int RESULT_REASON_MAX_LENGTH = 600;
private static final int NUMBER_MAX_LENGTH = 64; private static final int NUMBER_MAX_LENGTH = 64;
private static final String PUBLISHED = "PUBLISHED"; private static final String PUBLISHED = "PUBLISHED";
private static final int MAX_OUTBOUND_CANDIDATES = 200;
private final SchedulingFacade schedulingFacade; private final SchedulingFacade schedulingFacade;
private final ShadowInputMapper inputMapper; private final ShadowInputMapper inputMapper;
@@ -87,19 +89,83 @@ public class ShadowPlanningEngine {
String warehouseId = snapshot.warehouseId(); String warehouseId = snapshot.warehouseId();
ShadowStockReference reference = inputMapper.selectProjectOutboundReference( ShadowStockReference reference = inputMapper.selectProjectOutboundReference(
warehouseId, snapshot.generation(), stockoutDetailId); warehouseId, snapshot.generation(), stockoutDetailId);
if (reference == null || isBlank(reference.getProjectNo())) { if (reference == null || !Integer.valueOf(1).equals(reference.getPredicateVersion())) {
return OutboundShadowObservation.unavailable(snapshot, stockoutDetailId, return unavailableProject(snapshot, stockoutDetailId, reference,
"SKIPPED_MISSING_PROJECT", "SKIPPED_MISSING_PREDICATE_INPUT", "Request is missing or predates predicate version 1");
outboundDiagnostic(snapshot, stockoutDetailId, reference,
"No project number was available for shadow planning"));
} }
if (reference.getRequestedQuantity() == null || reference.getRequestedQuantity().signum() <= 0) {
return unavailableProject(snapshot, stockoutDetailId, reference,
"SKIPPED_INVALID_REMAINING_QUANTITY", "No positive unallocated quantity was captured");
}
List<ShadowStockReference> candidates = inputMapper.selectProjectOutboundCandidates(
warehouseId, snapshot.generation(), stockoutDetailId);
if (candidates.size() > MAX_OUTBOUND_CANDIDATES) {
return unavailableProject(snapshot, stockoutDetailId, reference,
"SKIPPED_CANDIDATE_LIMIT", "Eligible candidate set exceeds 200 rows");
}
if (candidates.isEmpty()) {
return unavailableProject(snapshot, stockoutDetailId, reference,
"ERROR_STOCK_NOT_FOUND", "No inventory satisfies the captured outbound predicates");
}
var projects = candidates.stream().map(ShadowStockReference::getProjectNo).distinct().toList();
if (projects.size() != 1 || isBlank(projects.get(0))) {
return unavailableProject(snapshot, stockoutDetailId, reference,
"SKIPPED_UNSUPPORTED_PROJECT_SCOPE", "Planner requires one nonblank candidate project");
}
var products = candidates.stream().map(ShadowStockReference::getProductId).distinct().toList();
if (products.size() != 1 || isBlank(products.get(0))) {
return unavailableProject(snapshot, stockoutDetailId, reference,
"SKIPPED_UNSUPPORTED_PRODUCT_SCOPE", "Planner requires one nonblank candidate product");
}
// The shared planner is single-source and does not apply the site's optional predicates.
// Keep it unchanged; validate its proposal against the generation-qualified candidate set.
OutboundRequest request = new OutboundRequest(stockoutDetailId, null, OutboundRequest request = new OutboundRequest(stockoutDetailId, null,
reference.getProjectNo(), reference.getProductId(), null, null, null, true); projects.get(0), products.get(0), null, null, null, false);
return toOutboundObservation(snapshot, reference.getRequestId(), reference, OutboundPlan plan = schedulingFacade.planOutboundFromPublishedSnapshot(warehouseId, request);
schedulingFacade.planOutboundFromPublishedSnapshot(warehouseId, request)); if (plan.assignment().isEmpty()) {
return toOutboundObservation(snapshot, stockoutDetailId, reference, plan);
}
var assignment = plan.assignment().orElseThrow();
var selected = candidates.stream().filter(candidate ->
Objects.equals(candidate.getSpaceId(), assignment.sourceSpaceId())
&& Objects.equals(candidate.getTrayNo(), assignment.trayNo())
&& Objects.equals(candidate.getProjectNo(), assignment.projectNo())
&& Objects.equals(candidate.getProductId(), assignment.productId())).toList();
if (selected.size() != 1) {
return unavailableProject(snapshot, stockoutDetailId, reference,
"SKIPPED_INELIGIBLE_PROPOSAL", "Proposed source is not one unambiguous eligible stock row");
}
ShadowStockReference stock = selected.get(0);
if (!Integer.valueOf(0).equals(stock.getTrayJobCount())) {
return unavailableProject(snapshot, stockoutDetailId, reference,
"SKIPPED_EXISTING_TRAY_JOB", "Existing tray jobs require unsupported delivery compatibility checks");
}
if (!Integer.valueOf(1).equals(stock.getTrayItemCount())) {
return unavailableProject(snapshot, stockoutDetailId, reference,
"SKIPPED_MULTI_STOCK_TRAY", "Selected tray does not contain exactly one inventory row");
}
if (stock.getAvailableQuantity() == null
|| stock.getAvailableQuantity().compareTo(reference.getRequestedQuantity()) < 0) {
return unavailableProject(snapshot, stockoutDetailId, reference,
"SKIPPED_MULTI_STOCK_REQUEST", "Selected single stock cannot cover the remaining request quantity");
}
StringBuilder reason = new StringBuilder(outboundDiagnostic(snapshot, stockoutDetailId, reference,
assignment.reason()));
appendField(reason, "selectedProject", stock.getProjectNo(), WIDE_IDENTIFIER_MAX_LENGTH);
appendField(reason, "selectedTray", stock.getTrayNo(), WIDE_IDENTIFIER_MAX_LENGTH);
appendField(reason, "availableQuantity", objectOrEmpty(stock.getAvailableQuantity()), NUMBER_MAX_LENGTH);
return new OutboundShadowObservation(true, snapshot.generation(), warehouseId,
snapshot.synchronizedAt(), snapshot.sourceSignature(), stockoutDetailId,
"READY", stock.getStockId(), stock.getSpaceId(), reason.toString());
}); });
} }
private OutboundShadowObservation unavailableProject(PublishedShadowSnapshot snapshot, String requestId,
ShadowStockReference reference, String code, String reason) {
return OutboundShadowObservation.unavailable(snapshot, requestId, code,
outboundDiagnostic(snapshot, requestId, reference, reason));
}
public OutboundShadowObservation planExactStockOutbound(PublishedShadowSnapshot snapshot, public OutboundShadowObservation planExactStockOutbound(PublishedShadowSnapshot snapshot,
String stockoutDetailId, String stockId) { String stockoutDetailId, String stockId) {
return planWithStableSnapshot(snapshot, () -> { return planWithStableSnapshot(snapshot, () -> {
@@ -313,6 +379,15 @@ public class ShadowPlanningEngine {
reference == null ? null : reference.getSnapshotSynchronizedAt(), SNAPSHOT_SYNC_MAX_LENGTH); reference == null ? null : reference.getSnapshotSynchronizedAt(), SNAPSHOT_SYNC_MAX_LENGTH);
appendField(diagnostic, "shadowMatches", numberOrUnknown( appendField(diagnostic, "shadowMatches", numberOrUnknown(
reference == null ? null : reference.getShadowMatchCount()), NUMBER_MAX_LENGTH); reference == null ? null : reference.getShadowMatchCount()), NUMBER_MAX_LENGTH);
appendField(diagnostic, "predicateVersion", numberOrUnknown(
reference == null ? null : reference.getPredicateVersion()), NUMBER_MAX_LENGTH);
if (reference != null && reference.getPredicateVersion() != null) {
appendField(diagnostic, "spare1", reference.getSpare1(), 120);
appendField(diagnostic, "spare2", reference.getSpare2(), 120);
appendField(diagnostic, "spare3", reference.getSpare3(), 120);
appendField(diagnostic, "voltage", reference.getVoltage(), 120);
appendField(diagnostic, "phase", reference.getPhase(), 120);
}
appendField(diagnostic, "reason", resultReason, RESULT_REASON_MAX_LENGTH); appendField(diagnostic, "reason", resultReason, RESULT_REASON_MAX_LENGTH);
return diagnostic.toString(); return diagnostic.toString();
} }

View File

@@ -13,6 +13,35 @@ public class ShadowStockReference {
private String snapshotSynchronizedAt; private String snapshotSynchronizedAt;
private BigDecimal requestedQuantity; private BigDecimal requestedQuantity;
private Integer shadowMatchCount; private Integer shadowMatchCount;
private Integer predicateVersion;
private String spare1;
private String spare2;
private String spare3;
private String voltage;
private String phase;
private BigDecimal availableQuantity;
private Integer trayItemCount;
private Integer trayJobCount;
public Integer getTrayJobCount() { return trayJobCount; }
public void setTrayJobCount(Integer value) { trayJobCount = value; }
public Integer getPredicateVersion() { return predicateVersion; }
public void setPredicateVersion(Integer value) { predicateVersion = value; }
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 getVoltage() { return voltage; }
public void setVoltage(String value) { voltage = value; }
public String getPhase() { return phase; }
public void setPhase(String value) { phase = value; }
public BigDecimal getAvailableQuantity() { return availableQuantity; }
public void setAvailableQuantity(BigDecimal value) { availableQuantity = value; }
public Integer getTrayItemCount() { return trayItemCount; }
public void setTrayItemCount(Integer value) { trayItemCount = value; }
public String getRequestId() { public String getRequestId() {
return requestId; return requestId;

View File

@@ -7,6 +7,7 @@ import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import java.time.Instant; import java.time.Instant;
import java.util.List;
@Mapper @Mapper
public interface ShadowInputMapper { public interface ShadowInputMapper {
@@ -21,6 +22,9 @@ public interface ShadowInputMapper {
ShadowStockReference selectStockReference(@Param("warehouseId") String warehouseId, ShadowStockReference selectStockReference(@Param("warehouseId") String warehouseId,
@Param("generation") long generation, @Param("stockId") String stockId); @Param("generation") long generation, @Param("stockId") String stockId);
List<ShadowStockReference> selectProjectOutboundCandidates(@Param("warehouseId") String warehouseId,
@Param("generation") long generation, @Param("stockoutDetailId") String stockoutDetailId);
boolean claimInstance(@Param("warehouseId") String warehouseId, boolean claimInstance(@Param("warehouseId") String warehouseId,
@Param("instanceId") String instanceId, @Param("staleSeconds") int staleSeconds); @Param("instanceId") String instanceId, @Param("staleSeconds") int staleSeconds);

View File

@@ -25,6 +25,29 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and input.tray_no = #{trayNo} and input.tray_no = #{trayNo}
</select> </select>
<sql id="outboundCandidatePredicate">
item.warehouse_id = #{warehouseId}
and item.snapshot_generation = #{generation}
and cargo.warehouse_id = item.warehouse_id
and cargo.snapshot_generation = item.snapshot_generation
and cargo.space_id = item.space_id and cargo.tray_code = item.tray_code
and cargo.locked = 'N'
and ((cargo.space_status = '2' and item.out_quantity = 0)
or (cargo.space_status = '4' and item.quantity &gt; item.out_quantity))
and (nullif(input.project_no, '') is null or item.project_no = input.project_no)
and (nullif(input.product_id, '') is null or item.product_no = input.product_id)
and (nullif(input.spare1, '') is null or item.spare1 = input.spare1)
and (nullif(input.spare2, '') is null or item.spare2 = input.spare2)
and (nullif(input.spare3, '') is null or item.spare3 = input.spare3)
and (nullif(input.voltage, '') is null or item.voltage = input.voltage)
and (nullif(input.phase, '') is null or item.phase = input.phase)
and not exists (
select 1 from alg.active_job_snapshot job
where job.warehouse_id = item.warehouse_id
and job.snapshot_generation = item.snapshot_generation
and job.tray_code = item.tray_code and job.in_or_out = '26')
</sql>
<select id="selectProjectOutboundReference" <select id="selectProjectOutboundReference"
resultType="com.ruoyi.algorithm.shadow.ShadowStockReference"> resultType="com.ruoyi.algorithm.shadow.ShadowStockReference">
select top (1) select top (1)
@@ -32,21 +55,42 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
input.raw_warehouse_id as warehouseId, input.raw_warehouse_id as warehouseId,
input.project_no as projectNo, input.project_no as projectNo,
input.product_id as productId, input.product_id as productId,
input.requested_quantity as requestedQuantity, input.requested_quantity - input.allocated_quantity as requestedQuantity,
input.predicate_version as predicateVersion,
input.spare1, input.spare2, input.spare3, input.voltage, input.phase,
convert(varchar(40), input.synchronized_at, 126) as snapshotSynchronizedAt, convert(varchar(40), input.synchronized_at, 126) as snapshotSynchronizedAt,
(select count(*) (select count(*)
from alg.inventory_item_snapshot item from alg.inventory_item_snapshot item
where item.warehouse_id = #{warehouseId} join alg.cargo_space_snapshot cargo on cargo.space_id = item.space_id
and item.snapshot_generation = #{generation} where <include refid="outboundCandidatePredicate"/>)
and item.project_no = nullif(ltrim(rtrim(input.project_no)), '')
and (nullif(ltrim(rtrim(input.product_id)), '') is null
or item.product_no = nullif(ltrim(rtrim(input.product_id)), ''))
and isnull(item.quantity, 0) &gt; isnull(item.out_quantity, 0))
as shadowMatchCount as shadowMatchCount
from alg.shadow_outbound_request_snapshot input from alg.shadow_outbound_request_snapshot input
where input.warehouse_id = #{warehouseId} where input.warehouse_id = #{warehouseId}
and input.snapshot_generation = #{generation} and input.snapshot_generation = #{generation}
and input.stockout_detail_id = #{stockoutDetailId} and input.stockout_detail_id = #{stockoutDetailId}
and input.predicate_version = 1
</select>
<select id="selectProjectOutboundCandidates" resultType="com.ruoyi.algorithm.shadow.ShadowStockReference">
select top (201)
item.stock_id as stockId, item.tray_code as trayNo, item.space_id as spaceId,
item.project_no as projectNo, item.product_no as productId,
item.tray_job_count as trayJobCount,
item.quantity - item.out_quantity as availableQuantity,
(select count(*) from alg.inventory_item_snapshot trayItem
where trayItem.warehouse_id = item.warehouse_id
and trayItem.snapshot_generation = item.snapshot_generation
and trayItem.space_id = item.space_id and trayItem.tray_code = item.tray_code)
as trayItemCount
from alg.shadow_outbound_request_snapshot input
join alg.inventory_item_snapshot item on item.warehouse_id = input.warehouse_id
join alg.cargo_space_snapshot cargo on cargo.space_id = item.space_id
where input.warehouse_id = #{warehouseId}
and input.snapshot_generation = #{generation}
and input.stockout_detail_id = #{stockoutDetailId}
and input.predicate_version = 1
and <include refid="outboundCandidatePredicate"/>
order by item.stock_id
</select> </select>
<select id="selectStockReference" resultType="com.ruoyi.algorithm.shadow.ShadowStockReference"> <select id="selectStockReference" resultType="com.ruoyi.algorithm.shadow.ShadowStockReference">

View File

@@ -0,0 +1,283 @@
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.SqlSession;
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.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.io.InputStream;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class ShadowOutboundPredicateTest {
private JdbcTemplate jdbc;
private SqlSessionFactory sessions;
@AfterEach
void closeDatabase() {
jdbc.execute("shutdown");
}
@BeforeEach
void createIsolatedSnapshot() throws Exception {
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 alg");
jdbc.execute("""
create table alg.shadow_outbound_request_snapshot (
warehouse_id varchar(80), snapshot_generation bigint, stockout_detail_id varchar(80),
raw_warehouse_id varchar(80), project_no varchar(255), product_id varchar(255),
requested_quantity decimal(38,6), allocated_quantity decimal(38,6),
spare1 varchar(255), spare2 varchar(255), spare3 varchar(255),
voltage varchar(255), phase varchar(255), predicate_version int,
synchronized_at datetime2)
""");
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(255), project_no varchar(255),
product_no varchar(255), quantity decimal(38,6), out_quantity decimal(38,6),
spare1 varchar(255), spare2 varchar(255), spare3 varchar(255),
voltage varchar(255), phase varchar(255), synchronized_at datetime2, tray_job_count int)
""");
jdbc.execute("""
create table alg.cargo_space_snapshot (
warehouse_id varchar(80), snapshot_generation bigint, space_id varchar(80),
tray_code varchar(255), space_status varchar(20), locked varchar(20))
""");
jdbc.execute("""
create table alg.active_job_snapshot (
warehouse_id varchar(80), snapshot_generation bigint, job_id varchar(80),
tray_code varchar(255), in_or_out varchar(20))
""");
jdbc.update("""
insert into alg.shadow_outbound_request_snapshot values
('01',459,'CKD202609070015-1','','AD.L-00033.01.01','030210200000506',5,2,
'LEVEL','PARENT','TOP','220V','PCS',1,'2026-09-07 02:37:10')
""");
stock("MATCH", "AD.L-00033.01.01");
Configuration configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), dataSource));
try (InputStream input = getClass().getClassLoader().getResourceAsStream("mapper/algorithm/ShadowInputMapper.xml")) {
// H2 lacks SQL Server's timestamp style argument. Candidate SQL is unchanged.
String xml = new String(input.readAllBytes(), StandardCharsets.UTF_8)
.replace("convert(varchar(40), input.synchronized_at, 126)", "cast(input.synchronized_at as varchar(40))")
.replace("convert(varchar(40), item.synchronized_at, 126)", "cast(item.synchronized_at as varchar(40))");
new XMLMapperBuilder(new StringReader(xml), configuration, "mapper/algorithm/ShadowInputMapper.xml",
configuration.getSqlFragments()).parse();
}
sessions = new SqlSessionFactoryBuilder().build(configuration);
}
@Test
void capturedBusinessProjectNeverBecomesWarehouseIdentifier() {
stock("WAREHOUSE-PROJECT", "FT546");
ShadowStockReference input = reference();
assertThat(input.getProjectNo()).isEqualTo("AD.L-00033.01.01");
assertThat(input.getShadowMatchCount()).isEqualTo(1);
}
@Test
void allOptionalSitePredicatesRestrictCandidates() {
for (String column : new String[]{"spare1", "spare2", "spare3", "voltage", "phase"}) {
stock(column, "AD.L-00033.01.01");
jdbc.update("update alg.inventory_item_snapshot set " + column + "='WRONG' where stock_id=?", column);
}
assertThat(reference().getShadowMatchCount()).isEqualTo(1);
}
@Test
void nullProjectDoesNotInventWarehouseProjectOrRemoveOtherFilters() {
jdbc.update("update alg.shadow_outbound_request_snapshot set project_no=null");
stock("OTHER-PROJECT", "9204");
stock("WRONG-PRODUCT", "9204");
jdbc.update("update alg.inventory_item_snapshot set product_no='OTHER' where stock_id='WRONG-PRODUCT'");
assertThat(reference().getProjectNo()).isNull();
assertThat(reference().getShadowMatchCount()).isEqualTo(2);
}
@Test
void absentOptionalAttributesPermitInventoryWithoutThoseAttributes() {
jdbc.update("update alg.shadow_outbound_request_snapshot set project_no='',spare1=null,spare2='',spare3=null,voltage='',phase=null");
jdbc.update("update alg.inventory_item_snapshot set project_no=null,spare1=null,spare2=null,spare3=null,voltage=null,phase=null");
assertThat(reference().getShadowMatchCount()).isEqualTo(1);
}
@Test
void lockTrayAlignmentAndConsolidationJobAreRequired() {
for (String id : new String[]{"LOCKED", "WRONG-TRAY", "CONSOLIDATING", "INSPECTION", "ALLOCATED"}) {
stock(id, "AD.L-00033.01.01");
}
jdbc.update("update alg.cargo_space_snapshot set locked='Y' where space_id='LOCKED'");
jdbc.update("update alg.cargo_space_snapshot set tray_code='OTHER' where space_id='WRONG-TRAY'");
jdbc.update("insert into alg.active_job_snapshot values ('01',459,'JOB','CONSOLIDATING','26')");
jdbc.update("update alg.cargo_space_snapshot set space_status='3' where space_id='INSPECTION'");
jdbc.update("update alg.inventory_item_snapshot set out_quantity=1 where stock_id='ALLOCATED'");
assertThat(reference().getShadowMatchCount()).isEqualTo(1);
}
@Test
void statusFourWithRemainingStockIsCountedButWrongGenerationsAreNot() {
stock("PARTIAL", "AD.L-00033.01.01");
jdbc.update("update alg.cargo_space_snapshot set space_status='4' where space_id='PARTIAL'");
jdbc.update("update alg.inventory_item_snapshot set out_quantity=1 where stock_id='PARTIAL'");
stock("OLD", "AD.L-00033.01.01");
jdbc.update("update alg.cargo_space_snapshot set snapshot_generation=458 where space_id='OLD'");
assertThat(reference().getShadowMatchCount()).isEqualTo(2);
}
@Test
void planningQuantityIsUnallocatedRemainder() {
assertThat(reference().getRequestedQuantity()).isEqualByComparingTo("3");
}
@Test
void candidateListUsesSamePredicatesAndReturnsSourceQuantity() {
stock("WRONG", "FT546");
try (SqlSession session = sessions.openSession()) {
var candidates = session.getMapper(ShadowInputMapper.class)
.selectProjectOutboundCandidates("01", 459L, "CKD202609070015-1");
assertThat(candidates).hasSize(1);
assertThat(candidates.get(0).getStockId()).isEqualTo("MATCH");
assertThat(candidates.get(0).getAvailableQuantity()).isEqualByComparingTo("5");
assertThat(candidates.get(0).getTrayItemCount()).isEqualTo(1);
}
}
@Test
void leadingCharactersAreNotTrimmedAway() {
jdbc.update("update alg.shadow_outbound_request_snapshot set spare1=' LEVEL'");
assertThat(reference().getShadowMatchCount()).isZero();
}
@Test
void preUpgradeSnapshotCannotBeUsedAsNewPredicateEvidence() {
jdbc.update("update alg.shadow_outbound_request_snapshot set predicate_version=null");
assertThat(reference()).isNull();
}
@Test
void migrationProjectionCapturesRawRequestFieldsWithoutWarehouseProject() throws Exception {
createLegacyRequestFixture();
jdbc.execute(snapshotInsert("shadow_outbound_request_snapshot", "EXEC alg.usp_rebuild_space_topology"));
var input = reference();
assertThat(input.getProjectNo()).isEqualTo("AD.L-00033.01.01");
assertThat(input.getSpare1()).isEqualTo("LEVEL");
assertThat(input.getSpare2()).isEqualTo("PARENT");
assertThat(input.getSpare3()).isEqualTo("TOP");
assertThat(input.getVoltage()).isEqualTo("220V");
assertThat(input.getPhase()).isEqualTo("PCS");
assertThat(input.getRequestedQuantity()).isEqualByComparingTo("3");
assertThat(input.getPredicateVersion()).isEqualTo(1);
assertThat(input.getShadowMatchCount()).isEqualTo(1);
}
@Test
void oversizedRawSiteParameterIsPreservedButNotAdmitted() throws Exception {
createLegacyRequestFixture();
jdbc.update("update dbo.out_stockout_detail set spare1=?", "X".repeat(81));
jdbc.execute(snapshotInsert("shadow_outbound_request_snapshot", "EXEC alg.usp_rebuild_space_topology"));
assertThat(reference()).isNull();
assertThat(jdbc.queryForObject("select spare1 from alg.shadow_outbound_request_snapshot", String.class))
.hasSize(81);
assertThat(jdbc.queryForObject("select predicate_version from alg.shadow_outbound_request_snapshot", Integer.class))
.isZero();
}
@Test
void migrationInventoryProjectionCapturesEvenOtherWarehouseTrayJobs() throws Exception {
jdbc.execute("create schema dbo");
jdbc.execute("alter table alg.inventory_item_snapshot add lot_no varchar(80)");
jdbc.execute("alter table alg.inventory_item_snapshot add inbound_time_raw varchar(80)");
jdbc.execute("alter table alg.inventory_item_snapshot add inbound_at datetime2");
jdbc.execute("delete from alg.inventory_item_snapshot");
jdbc.execute("""
create table dbo.bas_stock (stock_id varchar(80), cargo_space_id varchar(80), tray_code varchar(255),
spare4 varchar(255), product_id varchar(255), lot_no varchar(80), in_date varchar(80),
quantity decimal(30,4), out_quantity decimal(30,4), spare1 varchar(255), spare2 varchar(255),
spare3 varchar(255), voltage varchar(255), phase varchar(255))
""");
jdbc.execute("create table dbo.bas_cargo_space (warehouse_id varchar(80), cargo_space_id varchar(80))");
jdbc.execute("create table dbo.mydo_job (warehouse_id varchar(80), tray_code varchar(255))");
jdbc.update("""
insert into dbo.bas_stock values ('MATCH','MATCH','MATCH','AD.L-00033.01.01','030210200000506',
null,'2026-09-05 16:39:14',5,0,'LEVEL','PARENT','TOP','220V','PCS')
""");
jdbc.update("insert into dbo.bas_cargo_space values ('01','MATCH')");
jdbc.update("insert into dbo.mydo_job values ('OTHER-WAREHOUSE','MATCH')");
jdbc.execute(snapshotInsert("inventory_item_snapshot", "INSERT INTO alg.active_job_snapshot"));
try (SqlSession session = sessions.openSession()) {
var matches = session.getMapper(ShadowInputMapper.class)
.selectProjectOutboundCandidates("01", 459L, "CKD202609070015-1");
assertThat(matches).hasSize(1);
assertThat(matches.get(0).getTrayJobCount()).isEqualTo(1);
}
}
private void createLegacyRequestFixture() {
jdbc.execute("create schema dbo");
jdbc.execute("delete from alg.shadow_outbound_request_snapshot");
jdbc.execute("""
create table dbo.out_stockout_detail (stockout_detail_id varchar(80), stockout_id varchar(80),
warehouse_id varchar(80), spare4 varchar(255), product_id varchar(255),
quantity decimal(30,4), allot_quantity decimal(30,4), spare1 varchar(255),
spare2 varchar(255), spare3 varchar(255), fid varchar(100), unit varchar(255))
""");
jdbc.execute("create table dbo.out_stockout (stockout_id varchar(80), warehouse_id varchar(80))");
jdbc.update("insert into dbo.out_stockout values ('ORDER','1')");
jdbc.update("""
insert into dbo.out_stockout_detail values
('CKD202609070015-1','ORDER','','AD.L-00033.01.01','030210200000506',5,2,
'LEVEL','PARENT','TOP','220V','PCS')
""");
}
private String snapshotInsert(String table, String nextStatement) throws Exception {
try (var input = getClass().getResourceAsStream("/db/sqlserver/upgrade-shadow-v3-to-v33.sql")) {
assertThat(input).isNotNull();
String migration = new String(input.readAllBytes(), StandardCharsets.UTF_8).replace("''", "'");
int start = migration.indexOf("INSERT INTO alg." + table + " (");
assertThat(start).isGreaterThan(0);
int end = migration.indexOf(nextStatement, start);
assertThat(end).isGreaterThan(start);
// Adapt only SQL Server syntax to H2; all source columns and predicates remain from the migration.
return migration.substring(start, end)
.replace("@warehouse_id", "'01'")
.replace("@snapshot_generation", "459")
.replace("@synchronized_at", "TIMESTAMP '2026-09-07 02:37:10'")
.replace("@numeric_warehouse_id", "1")
.replace("WITH (NOLOCK)", "")
.replace("DATALENGTH(", "OCTET_LENGTH(")
.replace("TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockout.warehouse_id)))",
"CAST(LTRIM(RTRIM(stockout.warehouse_id)) AS decimal(38,0))")
.replace("TRY_CONVERT(datetime2, stock.in_date)", "CAST(stock.in_date AS datetime2)");
}
}
private ShadowStockReference reference() {
try (SqlSession session = sessions.openSession()) {
return session.getMapper(ShadowInputMapper.class).selectProjectOutboundReference("01", 459L, "CKD202609070015-1");
}
}
private void stock(String id, String project) {
jdbc.update("""
insert into alg.inventory_item_snapshot values
('01',459,?,?,?,?,'030210200000506',5,0,
'LEVEL','PARENT','TOP','220V','PCS','2026-09-07 02:37:10',0)
""", id, id, id, project);
jdbc.update("insert into alg.cargo_space_snapshot values ('01',459,?,?,'2','N')", id, id);
}
}

View File

@@ -0,0 +1,158 @@
package com.ruoyi.algorithm.shadow;
import com.ruoyi.algorithm.integration.SchedulingFacade;
import com.ruoyi.algorithm.outbound.*;
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.math.BigDecimal;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ShadowOutboundValidationTest {
private final PublishedShadowSnapshot snapshot = new PublishedShadowSnapshot(459, "01",
Instant.parse("2026-09-07T02:37:10Z"), "ABC123");
private final ShadowInputMapper mapper = mock(ShadowInputMapper.class);
private final SchedulingFacade facade = mock(SchedulingFacade.class);
private ShadowPlanningEngine engine;
private ShadowStockReference input;
private ShadowStockReference candidate;
@BeforeEach
void setup() {
var properties = new ShadowObservationProperties();
properties.setWarehouseId("01");
var tx = mock(PlatformTransactionManager.class);
when(tx.getTransaction(any())).thenReturn(new SimpleTransactionStatus());
engine = new ShadowPlanningEngine(facade, mapper, properties, tx);
var descriptor = new ShadowSnapshotDescriptor();
descriptor.setWarehouseId("01");
descriptor.setSnapshotGeneration(459);
descriptor.setSnapshotStatus("PUBLISHED");
descriptor.setSynchronizedAt(snapshot.synchronizedAt());
descriptor.setSourceSignature(snapshot.sourceSignature());
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor);
input = new ShadowStockReference();
input.setRequestId("DETAIL");
input.setPredicateVersion(1);
input.setProjectNo("AD.L-00033.01.01");
input.setProductId("PRODUCT");
input.setRequestedQuantity(new BigDecimal("3"));
input.setShadowMatchCount(1);
candidate = new ShadowStockReference();
candidate.setStockId("STOCK");
candidate.setTrayNo("TRAY");
candidate.setSpaceId("SPACE");
candidate.setProjectNo(input.getProjectNo());
candidate.setProductId("PRODUCT");
candidate.setAvailableQuantity(new BigDecimal("5"));
candidate.setTrayItemCount(1);
candidate.setTrayJobCount(0);
when(mapper.selectProjectOutboundReference("01", 459L, "DETAIL")).thenReturn(input);
when(mapper.selectProjectOutboundCandidates("01", 459L, "DETAIL")).thenReturn(List.of(candidate));
when(facade.planOutboundFromPublishedSnapshot(eq("01"), any())).thenReturn(
OutboundPlan.ready(new OutboundAssignment("DETAIL", "TRAY", input.getProjectNo(), "PRODUCT",
"SPACE", 1, false, List.of(), List.of(), false, 0, 0, 0,
FifoDecision.off(), Optional.empty(), "proposal"), TaskCountEstimate.zero()));
}
@Test
void validProposalRecordsRealStockAndUsesPickingMode() {
var result = plan();
assertThat(result.resultCode()).isEqualTo("READY");
assertThat(result.proposedStockId()).isEqualTo("STOCK");
assertThat(result.reason()).contains("predicateVersion=1", "requestedQuantity=3", "selectedProject=AD.L-00033.01.01");
verify(facade).planOutboundFromPublishedSnapshot(eq("01"), argThat(r -> !r.wholeTrayOrDefault()));
verify(mapper, never()).refreshSnapshot(anyString(), anyLong(), anyString());
verify(facade, never()).planOutbound(anyString(), any());
}
@Test
void emptyProjectDerivesOnlyProvenCandidateScope() {
input.setProjectNo(null);
assertThat(plan().resultCode()).isEqualTo("READY");
verify(facade).planOutboundFromPublishedSnapshot(eq("01"), argThat(r -> "AD.L-00033.01.01".equals(r.projectNo())));
}
@Test
void ambiguousProjectScopeIsSkippedWithoutPlanning() {
input.setProjectNo(null);
var other = new ShadowStockReference();
other.setProjectNo("9204");
when(mapper.selectProjectOutboundCandidates("01", 459L, "DETAIL")).thenReturn(List.of(candidate, other));
assertThat(plan().resultCode()).isEqualTo("SKIPPED_UNSUPPORTED_PROJECT_SCOPE");
verifyNoInteractions(facade);
}
@Test
void oldSnapshotVersionIsNotAccepted() {
input.setPredicateVersion(null);
assertThat(plan().resultCode()).isEqualTo("SKIPPED_MISSING_PREDICATE_INPUT");
verifyNoInteractions(facade);
}
@Test
void fullyAllocatedRequestIsSkipped() {
input.setRequestedQuantity(BigDecimal.ZERO);
assertThat(plan().resultCode()).isEqualTo("SKIPPED_INVALID_REMAINING_QUANTITY");
verifyNoInteractions(facade);
}
@Test
void overflowDoesNotProduceTruncatedSuccessEvidence() {
when(mapper.selectProjectOutboundCandidates("01", 459L, "DETAIL"))
.thenReturn(Collections.nCopies(201, candidate));
assertThat(plan().resultCode()).isEqualTo("SKIPPED_CANDIDATE_LIMIT");
verifyNoInteractions(facade);
}
@Test
void insufficientSingleSourceIsNotCalledReady() {
candidate.setAvailableQuantity(BigDecimal.ONE);
assertThat(plan().resultCode()).isEqualTo("SKIPPED_MULTI_STOCK_REQUEST");
}
@Test
void plannerSourceOutsideFilteredCandidateSetIsNotCalledReady() {
candidate.setSpaceId("OTHER");
assertThat(plan().resultCode()).isEqualTo("SKIPPED_INELIGIBLE_PROPOSAL");
}
@Test
void sameSpaceWithDifferentTrayIsNotEligible() {
candidate.setTrayNo("OTHER");
assertThat(plan().resultCode()).isEqualTo("SKIPPED_INELIGIBLE_PROPOSAL");
}
@Test
void multipleRowsOnSelectedTrayAreExplicitlyUnsupported() {
candidate.setTrayItemCount(2);
assertThat(plan().resultCode()).isEqualTo("SKIPPED_MULTI_STOCK_TRAY");
}
@Test
void emptyEligibleSetIsAnErrorNotAReadyProposal() {
when(mapper.selectProjectOutboundCandidates("01", 459L, "DETAIL")).thenReturn(List.of());
assertThat(plan().resultCode()).isEqualTo("ERROR_STOCK_NOT_FOUND");
verifyNoInteractions(facade);
}
@Test
void existingTrayJobCannotBeAssumedCompatibleWithRequest() {
candidate.setTrayJobCount(1);
assertThat(plan().resultCode()).isEqualTo("SKIPPED_EXISTING_TRAY_JOB");
}
private OutboundShadowObservation plan() {
return engine.planProjectOutbound(snapshot, "DETAIL");
}
}

View File

@@ -84,6 +84,8 @@ class ShadowPlanningEngineTest {
ShadowInputMapper mapper = mock(ShadowInputMapper.class); ShadowInputMapper mapper = mock(ShadowInputMapper.class);
SchedulingFacade facade = mock(SchedulingFacade.class); SchedulingFacade facade = mock(SchedulingFacade.class);
ShadowStockReference reference = projectReference("W02"); ShadowStockReference reference = projectReference("W02");
when(mapper.selectProjectOutboundCandidates("01", 42L, "DETAIL-1"))
.thenReturn(java.util.List.of(stockReference("01")));
when(mapper.selectProjectOutboundReference("01", 42L, "DETAIL-1")).thenReturn(reference); when(mapper.selectProjectOutboundReference("01", 42L, "DETAIL-1")).thenReturn(reference);
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT)); when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(SNAPSHOT));
when(facade.planOutboundFromPublishedSnapshot(eq("01"), any())) when(facade.planOutboundFromPublishedSnapshot(eq("01"), any()))
@@ -384,6 +386,8 @@ class ShadowPlanningEngineTest {
reference.setProductId("PRODUCT-1"); reference.setProductId("PRODUCT-1");
reference.setSnapshotSynchronizedAt("2026-09-03T00:00:00Z"); reference.setSnapshotSynchronizedAt("2026-09-03T00:00:00Z");
reference.setShadowMatchCount(1); reference.setShadowMatchCount(1);
reference.setPredicateVersion(1);
reference.setRequestedQuantity(java.math.BigDecimal.ONE);
return reference; return reference;
} }
@@ -392,6 +396,9 @@ class ShadowPlanningEngineTest {
reference.setStockId("STOCK-1"); reference.setStockId("STOCK-1");
reference.setTrayNo("TRAY-1"); reference.setTrayNo("TRAY-1");
reference.setSpaceId("SPACE-9"); reference.setSpaceId("SPACE-9");
reference.setAvailableQuantity(java.math.BigDecimal.TEN);
reference.setTrayItemCount(1);
reference.setTrayJobCount(0);
return reference; return reference;
} }

View File

@@ -107,6 +107,24 @@ class ShadowV3OperationsPackageTest {
"insert ", "update ", "delete ", "merge ", "truncate ", "drop ", "alter ", "create ", "exec "); "insert ", "update ", "delete ", "merge ", "truncate ", "drop ", "alter ", "create ", "exec ");
} }
@Test
void v33InstallerChainsUpgradeAndPropagatesSecondStageFailure() throws Exception {
Assumptions.assumeTrue(isWindows(), "CMD behavior is verified on Windows");
Path bin = writeSqlcmd13Fixture(Files.createDirectories(tempDir.resolve("v33 fixture")));
Path script = root.resolve("scripts/shadow-v3-install.cmd");
CmdResult current = runCmd(script, bin, "SERVER", "FT546-wms", "v33");
assertThat(current.exitCode()).as(current.output()).isZero();
assertThat(current.output()).contains("FIXTURE_INPUT=upgrade-shadow-v3-to-v33.sql", "INSTALL_OK")
.doesNotContain("FIXTURE_INPUT=install-shadow.sql", "FIXTURE_INPUT=upgrade-shadow-v2-to-v3.sql");
CmdResult upgrade = runCmd(script, bin, "SERVER", "FT546-wms", "upgrade");
assertThat(upgrade.exitCode()).as(upgrade.output()).isZero();
assertThat(upgrade.output()).contains("FIXTURE_INPUT=upgrade-shadow-v2-to-v3.sql",
"FIXTURE_INPUT=upgrade-shadow-v3-to-v33.sql");
CmdResult failure = runCmd(script, bin, "SERVER", "FAIL_V33", "upgrade");
assertThat(failure.exitCode()).isEqualTo(1);
assertThat(failure.output()).doesNotContain("INSTALL_OK");
}
@Test @Test
void runbookAndServerAiPromptPreserveTheSafeFieldOrder() throws IOException { void runbookAndServerAiPromptPreserveTheSafeFieldOrder() throws IOException {
String runbook = text("doc/algorithm-shadow/README.md"); String runbook = text("doc/algorithm-shadow/README.md");
@@ -259,12 +277,12 @@ class ShadowV3OperationsPackageTest {
boolean exportScript = script.getFileName().toString().equals("shadow-v3-export.cmd"); boolean exportScript = script.getFileName().toString().equals("shadow-v3-export.cmd");
Path callerDirectory = exportScript ? root : tempDir; Path callerDirectory = exportScript ? root : tempDir;
Files.writeString(wrapper, "@echo off\r\n" Files.writeString(wrapper, "@echo off\r\n"
+ (exportScript ? "pushd " + quote(root.toString()) + "\r\n" : "") + "pushd " + quote(callerDirectory.toString()) + "\r\n"
+ command + "\r\nset \"RESULT=%errorlevel%\"\r\n" + command + "\r\nset \"RESULT=%errorlevel%\"\r\n"
+ "cd > " + quote(restored.toString()) + "\r\n" + "cd > " + quote(restored.toString()) + "\r\n"
+ (exportScript ? "popd\r\n" : "") + "exit /b %RESULT%\r\n"); + "popd\r\nexit /b %RESULT%\r\n");
ProcessBuilder builder = new ProcessBuilder("cmd.exe", "/d", "/c", "call " + quote(wrapper.toString())); ProcessBuilder builder = new ProcessBuilder("cmd.exe", "/d", "/c", "call " + quote(wrapper.toString()));
builder.directory(tempDir.toFile()); builder.directory(tempDir.getParent().toFile());
String inheritedPath = builder.environment().getOrDefault("PATH", ""); String inheritedPath = builder.environment().getOrDefault("PATH", "");
builder.environment().put("PATH", stubBin + File.pathSeparator + inheritedPath); builder.environment().put("PATH", stubBin + File.pathSeparator + inheritedPath);
builder.environment().put("FORTUCKY_ALGORITHM_WAREHOUSE_ID", "01"); builder.environment().put("FORTUCKY_ALGORITHM_WAREHOUSE_ID", "01");

View File

@@ -13,6 +13,10 @@ class Sqlcmd13OutputFixture
} }
int input = Array.IndexOf(args, "-i"); int input = Array.IndexOf(args, "-i");
if (input < 0) return 86; if (input < 0) return 86;
string inputName = Path.GetFileName(args[input + 1]);
Console.WriteLine("FIXTURE_INPUT=" + inputName);
if (inputName == "upgrade-shadow-v3-to-v33.sql" && Array.IndexOf(args, "FAIL_V33") >= 0)
return 33;
foreach (string line in File.ReadAllLines(args[input + 1])) foreach (string line in File.ReadAllLines(args[input + 1]))
{ {
if (!line.StartsWith(":out ")) continue; if (!line.StartsWith(":out ")) continue;

View File

@@ -9,24 +9,32 @@ set "SQL_SERVER=%~1"
set "DATABASE=%~2" set "DATABASE=%~2"
set "MODE=%~3" set "MODE=%~3"
set "SQL_FILE=" set "SQL_FILE="
set "V33_FILE=%~dp0..\fortucky-algorithm\src\main\resources\db\sqlserver\upgrade-shadow-v3-to-v33.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"=="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"=="clean" set "SQL_FILE=%~dp0..\fortucky-algorithm\src\main\resources\db\sqlserver\install-shadow.sql"
if not defined SQL_FILE (call :fail "mode must be upgrade or clean" & exit /b 1) 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 not exist "%SQL_FILE%" (call :fail "SQL package file is missing" & 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)
where sqlcmd >nul 2>nul where sqlcmd >nul 2>nul
if errorlevel 1 (call :fail "sqlcmd is not available on PATH" & exit /b 1) if errorlevel 1 (call :fail "sqlcmd is not available on PATH" & exit /b 1)
echo Installing FT546WMS shadow v3 in %MODE% mode... echo WARNING: stop the WMS shadow-enabled JVM and wait over 120 seconds first.
echo Installing FT546WMS shadow V3.3 in %MODE% mode...
sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%SQL_FILE%" sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%SQL_FILE%"
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
if /I "%MODE%"=="v33" goto :installed
sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%V33_FILE%"
if errorlevel 1 exit /b 1
:installed
echo INSTALL_OK mode=%MODE% echo INSTALL_OK mode=%MODE%
exit /b 0 exit /b 0
:usage :usage
echo Usage: %~nx0 SQL_SERVER DATABASE upgrade^|clean 1>&2 echo Usage: %~nx0 SQL_SERVER DATABASE upgrade^|clean^|v33 1>&2
exit /b 2 exit /b 2
:fail :fail

View File

@@ -1,5 +1,6 @@
@echo off @echo off
setlocal EnableExtensions DisableDelayedExpansion setlocal EnableExtensions DisableDelayedExpansion
set "PREFLIGHT_PUSHED="
if "%~1"=="" goto :usage if "%~1"=="" goto :usage
if "%~2"=="" goto :usage if "%~2"=="" goto :usage
@@ -23,6 +24,7 @@ if not exist "%REPO_ROOT%\.git" (call :fail "REPO_ROOT is not a Git repository"
pushd "%REPO_ROOT%" >nul pushd "%REPO_ROOT%" >nul
if errorlevel 1 (call :fail "cannot enter REPO_ROOT" & exit /b 1) if errorlevel 1 (call :fail "cannot enter REPO_ROOT" & exit /b 1)
set "PREFLIGHT_PUSHED=1"
git rev-parse --is-inside-work-tree >nul 2>nul git rev-parse --is-inside-work-tree >nul 2>nul
if errorlevel 1 (call :fail "REPO_ROOT is not a Git worktree" & exit /b 1) if errorlevel 1 (call :fail "REPO_ROOT is not a Git worktree" & exit /b 1)
@@ -42,6 +44,7 @@ if defined DIRTY (call :fail "Git worktree is not clean" & exit /b 1)
git merge-base --is-ancestor %MAIN_COMMIT% HEAD git merge-base --is-ancestor %MAIN_COMMIT% HEAD
if errorlevel 1 (call :fail "required main commit is not an ancestor" & exit /b 1) if errorlevel 1 (call :fail "required main commit is not an ancestor" & exit /b 1)
popd >nul popd >nul
set "PREFLIGHT_PUSHED="
set "WMS_PROCESS_COUNT=0" set "WMS_PROCESS_COUNT=0"
jps -lv >nul 2>nul jps -lv >nul 2>nul
@@ -49,7 +52,7 @@ 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" 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) 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 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')) 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='2026-09-03-3') 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')) 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"
sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -Q "%PREFLIGHT_SQL%" 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) if errorlevel 1 (call :fail "SQL connectivity or shadow package validation failed" & exit /b 1)
@@ -62,5 +65,5 @@ exit /b 2
:fail :fail
echo PREFLIGHT_FAILED %~1 1>&2 echo PREFLIGHT_FAILED %~1 1>&2
popd >nul 2>nul if defined PREFLIGHT_PUSHED popd >nul 2>nul
exit /b 1 exit /b 1

View File

@@ -15,7 +15,7 @@ where sqlcmd >nul 2>nul
if errorlevel 1 (call :fail "sqlcmd is not available on PATH" & exit /b 1) if errorlevel 1 (call :fail "sqlcmd is not available on PATH" & exit /b 1)
echo WARNING: export all alg.* evidence before uninstalling. echo WARNING: export all alg.* evidence before uninstalling.
echo The shadow application must be stopped and the stable service restored first. echo The shadow-enabled WMS must be stopped and the stable WMS instance restored first.
sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%SQL_FILE%" sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%SQL_FILE%"
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1