From 8dbdd411bf5ce87a402ecfd8ece7ca0d61bf0f11 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Mon, 14 Sep 2026 10:31:17 +0800 Subject: [PATCH] fix: recognize unlocked N lanes in shadow aggregation --- .../resources/db/sqlserver/install-shadow.sql | 2 +- .../db/sqlserver/upgrade-shadow-v2-to-v3.sql | 2 +- scripts/shadow-lane-lock/README.md | 57 ++++++++ scripts/shadow-lane-lock/assertions.sql | 22 +++ scripts/shadow-lane-lock/fixture.sql | 43 ++++++ .../repair-shadow-lane-lock.cmd | 3 + .../repair-shadow-lane-lock.ps1 | 136 ++++++++++++++++++ .../test-shadow-lane-lock.ps1 | 104 ++++++++++++++ 8 files changed, 367 insertions(+), 2 deletions(-) create mode 100644 scripts/shadow-lane-lock/README.md create mode 100644 scripts/shadow-lane-lock/assertions.sql create mode 100644 scripts/shadow-lane-lock/fixture.sql create mode 100644 scripts/shadow-lane-lock/repair-shadow-lane-lock.cmd create mode 100644 scripts/shadow-lane-lock/repair-shadow-lane-lock.ps1 create mode 100644 scripts/shadow-lane-lock/test-shadow-lane-lock.ps1 diff --git a/fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql b/fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql index ad5ede1..5efa94c 100644 --- a/fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql +++ b/fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql @@ -756,7 +756,7 @@ BEGIN COUNT(*) AS space_count, SUM(CASE WHEN cargo.space_status = '2' OR ISNULL(stock.stock_detail_count, 0) > 0 THEN 1 ELSE 0 END) AS occupied_count, SUM(CASE WHEN cargo.space_status = '1' - AND NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL + AND (NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL OR LTRIM(RTRIM(cargo.locked)) IN ('0', 'N')) AND NULLIF(LTRIM(RTRIM(cargo.tray_code)), '') IS NULL AND ISNULL(stock.stock_detail_count, 0) = 0 AND ISNULL(role.role, 'STORAGE') = 'STORAGE' diff --git a/fortucky-algorithm/src/main/resources/db/sqlserver/upgrade-shadow-v2-to-v3.sql b/fortucky-algorithm/src/main/resources/db/sqlserver/upgrade-shadow-v2-to-v3.sql index f788b38..3be480e 100644 --- a/fortucky-algorithm/src/main/resources/db/sqlserver/upgrade-shadow-v2-to-v3.sql +++ b/fortucky-algorithm/src/main/resources/db/sqlserver/upgrade-shadow-v2-to-v3.sql @@ -775,7 +775,7 @@ BEGIN SELECT cargo.warehouse_id, cargo.lane_code, COUNT(*) AS space_count, SUM(CASE WHEN cargo.space_status = '2' OR ISNULL(stock.stock_detail_count, 0) > 0 THEN 1 ELSE 0 END) AS occupied_count, SUM(CASE WHEN cargo.space_status = '1' - AND NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL + AND (NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL OR LTRIM(RTRIM(cargo.locked)) IN ('0', 'N')) AND NULLIF(LTRIM(RTRIM(cargo.tray_code)), '') IS NULL AND ISNULL(stock.stock_detail_count, 0) = 0 AND ISNULL(role.role, 'STORAGE') = 'STORAGE' diff --git a/scripts/shadow-lane-lock/README.md b/scripts/shadow-lane-lock/README.md new file mode 100644 index 0000000..91d74b8 --- /dev/null +++ b/scripts/shadow-lane-lock/README.md @@ -0,0 +1,57 @@ +# 影子通道汇总锁定值修复(2026-09-14) + +现场 `locked=N` 表示未锁定;旧 `alg.usp_rebuild_lane_state` 仅接受 NULL/空白,导致通道可用数量归零。补丁让它与现有货位视图一致,接受 NULL、空白、`0`、`N`。其他占用、库存、角色、预留、出库与空洞限制全部保留。两个完整安装 SQL 也同步修正,避免以后安装重新引入问题。 + +## 对正在运行的业务有何影响 + +影子读取生产 `dbo` 数据,生成 `alg.*` 快照,再规划和记录比较结果。此次只 ALTER `alg.usp_rebuild_lane_state`,其运行结果写入 `alg.lane_state`。不修改生产货位的锁定值、货位状态、库存、订单或 Legacy 分配过程,不切换 ACTIVE,不需要替换 JAR 或重启 WMS/WCS。 + +现有三通道混放试点是另外一套已批准的业务规则,本补丁不更改其范围或开关。共用 SQL Server 仍会共享 CPU、I/O、内存及锁资源,不能承诺性能影响为零;补丁使用 1.5 秒锁等待上限,等待失败退出,不结束其他会话。 + +## 从 CMD 操作 + +在本目录执行,使用 Windows 集成认证;账号需要读取过程定义及 ALTER 此过程的权限。默认数据库仅支持 `FT546-wms`。 + +```bat +repair-shadow-lane-lock.cmd -Mode Check +``` + +记录返回的 `sha256`。确认状态为 `NEEDS_FIX` 后,将下面的占位文字换成该值;备份文件使用全新路径(不能覆盖已有文件): + +```bat +repair-shadow-lane-lock.cmd -Mode Apply -ExpectedSha256 检查返回的sha256 -BackupFile "C:\project\FT546WMS-shadow-lane-lock-20260914\original-procedure.json" +``` + +返回 `status=OK` 后,等待现有影子流程在业务退出后的安静窗口正常刷新;它不是固定每隔几分钟必刷。不要手动重建正在使用的快照,也不要为此运行完整 `shadow-v3-install.cmd`。补丁不改整包版本标识,用过程哈希确认状态。`ALREADY_FIXED` 表示无需重复更新;hash mismatch 表示现场定义有变化,应先核对。锁超时或失败时事务自动回滚,已有备份保留;重试采用新的备份文件名。 + +只读检查(SQL 查询窗口): + +```sql +SELECT snapshot_generation,snapshot_status,synchronized_at,last_error +FROM alg.shadow_snapshot_state WHERE warehouse_id='01'; +SELECT COUNT(*) candidate_lanes,SUM(l.available_count) aggregated_available_spaces +FROM alg.lane_state l JOIN alg.shadow_snapshot_state p + ON p.warehouse_id=l.warehouse_id AND p.snapshot_generation=l.snapshot_generation +WHERE l.warehouse_id='01' AND p.snapshot_status='PUBLISHED' + AND l.available_count>0 AND l.pending_outbound=0 AND l.has_gap=0; +``` + +候选通道恢复不代表任意托盘一定能分配,也不代表完整算法可以接管。仍需新发生的真实入库/出库比较和混放移库闭环证据。 + +## 回退 + +重新 `Check`,将返回的当前 `sha256` 填入命令,使用 Apply 保存的原始备份。只恢复此过程原文及 ANSI/quoted 设置;有后续改动时会拒绝覆盖: + +```bat +repair-shadow-lane-lock.cmd -Mode Rollback -ExpectedSha256 当前sha256 -BackupFile "C:\project\FT546WMS-shadow-lane-lock-20260914\original-procedure.json" +``` + +回退后下一次正常刷新会恢复旧汇总行为,包括旧的 `N` 误排除问题。既有影子记录不删除,业务数据不回退。 + +## 隔离回归 + +```bat +powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts\shadow-lane-lock\test-shadow-lane-lock.ps1 -TestRepair +``` + +从项目根目录执行。自动创建随机名称 `FT546WMS_LaneLock_Test_` 的小型测试库,只放人工数据;不会接收生产库作为测试目标。执行两个源 SQL 中真实的 rebuild 过程,指纹计算使用明确标注的测试桩。覆盖锁值、各排除规则、跨代次、跨仓库/通道、重复刷新、修复的精确范围、哈希拒绝、重复更新、DDL 失败回滚及恢复原文。退出时删除本次测试库;此测试不验证现场托盘的实际规划效果。 diff --git a/scripts/shadow-lane-lock/assertions.sql b/scripts/shadow-lane-lock/assertions.sql new file mode 100644 index 0000000..0944e93 --- /dev/null +++ b/scripts/shadow-lane-lock/assertions.sql @@ -0,0 +1,22 @@ +IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N' AND available_count=1) + THROW 51950,'locked=N must be available in the actual lane aggregate',1; +IF (SELECT COUNT(*) FROM alg.lane_state WHERE warehouse_id='01' AND lane_code LIKE 'UNLOCK_%' AND available_count=1 AND pending_outbound=0 AND has_gap=0 AND snapshot_generation=101)<>7 + THROW 51950,'NULL, blank, 0, N, trimmed N, released reservation and old generation must remain eligible',1; +IF EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code NOT LIKE 'UNLOCK_%' AND available_count<>0) + THROW 51950,'locked/unknown/occupied/tray/stock/role/reservation/outbound/gap exclusions must remain',1; +IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='OUTBOUND' AND pending_outbound=1 AND available_count=0) + THROW 51950,'outbound must exclude the entire lane',1; +IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='GAP' AND has_gap=1 AND available_count=0) + THROW 51950,'gap must exclude the entire lane',1; +IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N' AND space_count=1 AND occupied_count=0) + THROW 51950,'old generation must not pollute current counts',1; +DECLARE @stamp datetime2=(SELECT rebuilt_at FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N'); +EXEC alg.usp_rebuild_lane_state @warehouse_id='01'; +IF (SELECT rebuilt_at FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N')<>@stamp + THROW 51950,'unchanged repeat rebuild should not rewrite lane state',1; +EXEC alg.usp_rebuild_lane_state @warehouse_id='02'; +EXEC alg.usp_rebuild_lane_state @warehouse_id='01',@lane_code='UNLOCK_N'; +IF NOT EXISTS(SELECT 1 FROM alg.lane_state WHERE warehouse_id='02' AND lane_code='OTHER_WAREHOUSE' AND available_count=1 AND snapshot_generation=201) + THROW 51950,'scoped rebuild must preserve other warehouse',1; +IF (SELECT COUNT(*) FROM alg.lane_state WHERE warehouse_id='01')<>16 + THROW 51950,'scoped rebuild must preserve other lanes',1; diff --git a/scripts/shadow-lane-lock/fixture.sql b/scripts/shadow-lane-lock/fixture.sql new file mode 100644 index 0000000..d045bf8 --- /dev/null +++ b/scripts/shadow-lane-lock/fixture.sql @@ -0,0 +1,43 @@ +-- Only synthetic fixtures; this script is run exclusively in a randomly named scratch DB. +CREATE TABLE alg.cargo_space_snapshot(warehouse_id varchar(80),space_id varchar(80),lane_code varchar(80),depth_raw varchar(10),space_status varchar(10),locked varchar(10),tray_code varchar(80),snapshot_generation bigint); +CREATE TABLE alg.space_inventory_summary(warehouse_id varchar(80),space_id varchar(80),stock_detail_count int,snapshot_generation bigint); +CREATE TABLE alg.space_role(space_id varchar(80),role varchar(40)); +CREATE TABLE alg.active_job_snapshot(warehouse_id varchar(80),source_space_id varchar(80),in_or_out varchar(10),snapshot_generation bigint); +CREATE TABLE alg.reservation(reservation_id varchar(80),space_id varchar(80),status varchar(20)); +CREATE TABLE alg.shadow_snapshot_state(warehouse_id varchar(80),snapshot_generation bigint,snapshot_status varchar(20)); +CREATE TABLE alg.lane_state(warehouse_id varchar(80),lane_code varchar(80),space_count int,occupied_count int,available_count int,pending_outbound bit,has_gap bit,semantic_fingerprint varbinary(32),snapshot_generation bigint,rebuilt_at datetime2 DEFAULT SYSUTCDATETIME(),PRIMARY KEY(warehouse_id,lane_code)); +GO +-- Explicit test stub: fingerprint internals are outside this aggregation regression. +-- The actual rebuild procedure, cursor, guards and MERGE are always executed unmodified. +CREATE PROCEDURE alg.usp_compute_lane_semantic_fingerprint + @warehouse_id varchar(80),@lane_code varchar(80),@semantic_fingerprint varbinary(32) OUTPUT, + @inventory_source varchar(20),@snapshot_generation bigint +AS +SET @semantic_fingerprint=HASHBYTES('SHA2_256',@warehouse_id+':'+@lane_code+':'+CONVERT(varchar(30),@snapshot_generation)); +GO +INSERT alg.shadow_snapshot_state VALUES('01',101,'PUBLISHED'),('02',201,'PUBLISHED'); +INSERT alg.cargo_space_snapshot VALUES +('01','N','UNLOCK_N','1','1','N',NULL,101), +('01','ZERO','UNLOCK_ZERO','1','1','0',NULL,101), +('01','NULL','UNLOCK_NULL','1','1',NULL,NULL,101), +('01','BLANK','UNLOCK_BLANK','1','1',' ',NULL,101), +('01','TRIM','UNLOCK_TRIM','1','1',' N ',NULL,101), +('01','Y','LOCK_Y','1','1','Y',NULL,101), +('01','UNKNOWN','LOCK_UNKNOWN','1','1','?',NULL,101), +('01','STATUS','OCCUPIED','1','2','N',NULL,101), +('01','TRAY','HAS_TRAY','1','1','N','T1',101), +('01','STOCK','HAS_STOCK','1','1','N',NULL,101), +('01','ROLE','PROTECTED','1','1','N',NULL,101), +('01','RESERVED','RESERVED','1','1','N',NULL,101), +('01','RELEASED','UNLOCK_RELEASED','1','1','N',NULL,101), +('01','OUT1','OUTBOUND','1','1','N',NULL,101), +('01','OUT2','OUTBOUND','2','1','N',NULL,101), +('01','GAP1','GAP','1','1','N',NULL,101), +('01','GAP2','GAP','2','2','N','T2',101), +('01','HISTORY','UNLOCK_HISTORY','1','1','N',NULL,101), +('01','N','UNLOCK_N','1','2','Y','OLD',100), +('02','W2','OTHER_WAREHOUSE','1','1','N',NULL,201); +INSERT alg.space_inventory_summary VALUES('01','STOCK',1,101),('01','HISTORY',1,100); +INSERT alg.space_role VALUES('ROLE','BUFFER'); +INSERT alg.reservation VALUES('R1','RESERVED','ACTIVE'),('R2','RELEASED','RELEASED'); +INSERT alg.active_job_snapshot VALUES('01','OUT1','20',101),('01','HISTORY','20',100); diff --git a/scripts/shadow-lane-lock/repair-shadow-lane-lock.cmd b/scripts/shadow-lane-lock/repair-shadow-lane-lock.cmd new file mode 100644 index 0000000..d8777e6 --- /dev/null +++ b/scripts/shadow-lane-lock/repair-shadow-lane-lock.cmd @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0repair-shadow-lane-lock.ps1" %* +exit /b %errorlevel% diff --git a/scripts/shadow-lane-lock/repair-shadow-lane-lock.ps1 b/scripts/shadow-lane-lock/repair-shadow-lane-lock.ps1 new file mode 100644 index 0000000..aaf8897 --- /dev/null +++ b/scripts/shadow-lane-lock/repair-shadow-lane-lock.ps1 @@ -0,0 +1,136 @@ +param( + [string]$Server = 'lpc:.', + [string]$Database = 'FT546-wms', + [ValidateSet('Check','Apply','Rollback')][string]$Mode = 'Check', + [string]$ExpectedSha256, + [string]$BackupFile +) +$ErrorActionPreference='Stop' +[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false) +if($Database -ne 'FT546-wms' -and $Database -notmatch '^FT546WMS_LaneLock_Test_[a-f0-9]{32}$') { + throw 'Only FT546-wms or the dedicated scratch database is supported' +} +if($Mode -ne 'Check' -and ($ExpectedSha256 -notmatch '^[a-fA-F0-9]{64}$' -or -not $BackupFile)) { + throw 'Apply/Rollback requires ExpectedSha256 and BackupFile' +} +$old="NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL" +$new="(NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL OR LTRIM(RTRIM(cargo.locked)) IN ('0', 'N'))" +function Hash([string]$value) { + $sha=[Security.Cryptography.SHA256]::Create() + try { ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($value)))).Replace('-','').ToLowerInvariant() } + finally { $sha.Dispose() } +} +function Canonical([string]$value) { + if($value -notmatch '(?i)\A\s*(CREATE|ALTER)\s+PROCEDURE\s+alg\.usp_rebuild_lane_state\b') { + throw 'Unexpected procedure header' + } + [regex]::Replace($value,'(?i)\A\s*(CREATE|ALTER)\s+PROCEDURE','ALTER PROCEDURE') +} +$cs=[Data.SqlClient.SqlConnectionStringBuilder]::new() +$cs['Data Source']=$Server; $cs['Initial Catalog']=$Database; $cs['Integrated Security']=$true +$cs['Connect Timeout']=8; $cs['Application Name']='FT546ShadowLaneLockRepair' +$cn=[Data.SqlClient.SqlConnection]::new($cs.ConnectionString) +$transaction=$null +function Command([string]$sql) { + $cmd=$cn.CreateCommand(); $cmd.CommandTimeout=10; $cmd.CommandText=$sql + if($script:transaction){$cmd.Transaction=$script:transaction} + return $cmd +} +function Exec([string]$sql) { + $cmd=Command $sql + try { [void]$cmd.ExecuteNonQuery() } finally { $cmd.Dispose() } +} +function Scalar([string]$sql) { + $cmd=Command $sql + try { $cmd.ExecuteScalar() } finally { $cmd.Dispose() } +} +function ReadDefinition { + $cmd=Command @' +SELECT m.definition,m.uses_ansi_nulls,m.uses_quoted_identifier +FROM sys.sql_modules m JOIN sys.procedures p ON p.object_id=m.object_id +WHERE p.object_id=OBJECT_ID(N'alg.usp_rebuild_lane_state',N'P'); +'@ + try { + $reader=$cmd.ExecuteReader() + try { + if(-not $reader.Read() -or $reader.IsDBNull(0)){throw 'Procedure missing or encrypted'} + [pscustomobject]@{definition=$reader.GetString(0);ansi=$reader.GetBoolean(1);quoted=$reader.GetBoolean(2)} + } finally {$reader.Dispose()} + } finally {$cmd.Dispose()} +} +$cn.Open() +try { + Exec 'SET NOCOUNT ON; SET XACT_ABORT ON; SET LOCK_TIMEOUT 1500; SET DEADLOCK_PRIORITY LOW;' + $instance=[string](Scalar 'SELECT CONVERT(nvarchar(128),SERVERPROPERTY(''ServerName''))') + $before=ReadDefinition + $beforeHash=Hash $before.definition + if($ExpectedSha256 -and $beforeHash -ne $ExpectedSha256){throw 'Procedure hash mismatch; recapture and review before retrying'} + $null=Canonical $before.definition + $fixedCount=([regex]::Matches($before.definition,[regex]::Escape($new))).Count + $oldCount=([regex]::Matches($before.definition,[regex]::Escape($old))).Count + if($oldCount -ne 1 -or $fixedCount -gt 1){throw 'Unexpected lock predicate; no changes made'} + if($Mode -eq 'Check') { + [ordered]@{mode=$Mode;server=$instance;database=$Database;sha256=$beforeHash;state=$(if($fixedCount -eq 1){'ALREADY_FIXED'}else{'NEEDS_FIX'});ansi=$before.ansi;quoted=$before.quoted}|ConvertTo-Json -Compress + return + } + if($Mode -eq 'Apply' -and $fixedCount -eq 1) { + [ordered]@{mode=$Mode;status='ALREADY_FIXED';sha256=$beforeHash;database_written=$false}|ConvertTo-Json -Compress + return + } + if($Mode -eq 'Apply') { + $target=(Canonical $before.definition).Replace($old,$new) + $backup=[ordered]@{ + format='FT546WMS_SHADOW_LANE_LOCK_20260914';server=$instance;database=$Database + utc=[DateTime]::UtcNow.ToString('o');definition=$before.definition;original_sha256=$beforeHash + fixed_canonical_sha256=(Hash $target);ansi=$before.ansi;quoted=$before.quoted + } + # Backup is durable before beginning DDL; no existing file can be overwritten. + $stream=[IO.File]::Open($BackupFile,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) + try { + $bytes=[Text.Encoding]::UTF8.GetBytes(($backup|ConvertTo-Json -Depth 4)) + $stream.Write($bytes,0,$bytes.Length);$stream.Flush($true) + } finally {$stream.Dispose()} + } else { + $backup=[IO.File]::ReadAllText($BackupFile)|ConvertFrom-Json + if($backup.format -ne 'FT546WMS_SHADOW_LANE_LOCK_20260914' -or $backup.server -cne $instance -or $backup.database -cne $Database) { + throw 'Backup identity mismatch' + } + if((Hash $backup.definition) -ne $backup.original_sha256){throw 'Backup integrity mismatch'} + if($fixedCount -ne 1 -or (Hash (Canonical $before.definition)) -ne $backup.fixed_canonical_sha256) { + throw 'Rollback refuses drift from the backed-up repair' + } + $target=Canonical $backup.definition + } + # Only the alg procedure is changed. No rebuild of the currently published generation, + # no dbo DML, no application restart, no full migration and no active session is killed. + $transaction=$cn.BeginTransaction() + $lockResult=Scalar "DECLARE @r int; EXEC @r=sys.sp_getapplock @Resource=N'FT546WMS_ShadowLaneLockRepair',@LockMode='Exclusive',@LockOwner='Transaction',@LockTimeout=0; SELECT @r;" + if($lockResult -lt 0){throw 'Another lane-lock repair is running'} + $recheck=ReadDefinition + if((Hash $recheck.definition) -ne $beforeHash -or $recheck.ansi -ne $before.ansi -or $recheck.quoted -ne $before.quoted) { + throw 'Procedure changed during preparation; no changes made' + } + # Refuse unreviewed database DDL hooks that could make ALTER write business data. + if((Scalar 'SELECT COUNT(*) FROM sys.triggers WHERE parent_class=0 AND is_disabled=0') -gt 0) { + throw 'Enabled database DDL triggers require review before this repair' + } + $ansi=if($backup.ansi){'ON'}else{'OFF'} + $quoted=if($backup.quoted){'ON'}else{'OFF'} + Exec "SET ANSI_NULLS $ansi; SET QUOTED_IDENTIFIER $quoted;" + $watch=[Diagnostics.Stopwatch]::StartNew() + Exec $target + $after=ReadDefinition + if((Canonical $after.definition) -cne $target -or $after.ansi -ne $backup.ansi -or $after.quoted -ne $backup.quoted) { + throw 'Definition verification failed; rolling back' + } + $transaction.Commit();$transaction.Dispose();$transaction=$null + $watch.Stop() + [ordered]@{ + mode=$Mode;status='OK';server=$instance;database=$Database;object='alg.usp_rebuild_lane_state' + before_sha256=$beforeHash;after_sha256=(Hash $after.definition);ddl_ms=$watch.ElapsedMilliseconds + backup=$BackupFile;refresh_invoked=$false;utc=[DateTime]::UtcNow.ToString('o') + }|ConvertTo-Json -Compress +} finally { + if($transaction){try{$transaction.Rollback()}finally{$transaction.Dispose()}} + $cn.Dispose() +} diff --git a/scripts/shadow-lane-lock/test-shadow-lane-lock.ps1 b/scripts/shadow-lane-lock/test-shadow-lane-lock.ps1 new file mode 100644 index 0000000..1976531 --- /dev/null +++ b/scripts/shadow-lane-lock/test-shadow-lane-lock.ps1 @@ -0,0 +1,104 @@ +param( + [string]$Server = 'lpc:.', + [string]$SqlDirectory = "$PSScriptRoot/../../fortucky-algorithm/src/main/resources/db/sqlserver", + [switch]$TestRepair +) +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +# This runner accepts no database name: fixtures can only enter its own tiny scratch DB. +$testDb = 'FT546WMS_LaneLock_Test_' + [Guid]::NewGuid().ToString('N') +$cs = [Data.SqlClient.SqlConnectionStringBuilder]::new() +$cs['Data Source']=$Server; $cs['Initial Catalog']='master'; $cs['Integrated Security']=$true +$cs['Connect Timeout']=8; $cs['Application Name']='FT546ShadowLaneLockTests' +$cn = [Data.SqlClient.SqlConnection]::new($cs.ConnectionString) +function Exec([string]$sql) { + $cmd=$cn.CreateCommand(); $cmd.CommandTimeout=15; $cmd.CommandText=$sql + try { [void]$cmd.ExecuteNonQuery() } finally { $cmd.Dispose() } +} +function Scalar([string]$sql) { + $cmd=$cn.CreateCommand(); $cmd.CommandTimeout=15; $cmd.CommandText=$sql + try { $cmd.ExecuteScalar() } finally { $cmd.Dispose() } +} +function Assert([bool]$condition,[string]$message) { + if(-not $condition){throw "FAIL: $message"} + Write-Output "PASS: $message" +} +function Hash([string]$value) { + $sha=[Security.Cryptography.SHA256]::Create() + try { ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($value)))).Replace('-','').ToLowerInvariant() } + finally { $sha.Dispose() } +} +function Definition { [string](Scalar "SELECT OBJECT_DEFINITION(OBJECT_ID('alg.usp_rebuild_lane_state'))") } +function Canonical([string]$value) { [regex]::Replace($value,'(?i)\A\s*(CREATE|ALTER)\s+PROCEDURE','ALTER PROCEDURE') } +$created=$false +$cn.Open() +try { + $dataPath=([string](Scalar "SELECT CONVERT(nvarchar(4000),SERVERPROPERTY('InstanceDefaultDataPath'))")).Replace("'","''") + $logPath=([string](Scalar "SELECT CONVERT(nvarchar(4000),SERVERPROPERTY('InstanceDefaultLogPath'))")).Replace("'","''") + Exec "CREATE DATABASE [$testDb] ON PRIMARY (NAME=N'$testDb',FILENAME=N'${dataPath}$testDb.mdf',SIZE=8MB,MAXSIZE=64MB,FILEGROWTH=8MB) LOG ON (NAME=N'${testDb}_log',FILENAME=N'${logPath}${testDb}_log.ldf',SIZE=8MB,MAXSIZE=64MB,FILEGROWTH=8MB)" + $created=$true; $cn.ChangeDatabase($testDb) + Exec 'SET NOCOUNT ON; SET LOCK_TIMEOUT 1500; SET DEADLOCK_PRIORITY LOW;' + Exec 'CREATE SCHEMA alg' + foreach($batch in ([IO.File]::ReadAllText("$PSScriptRoot/fixture.sql") -split '(?im)^\s*GO\s*\r?$')) { + if($batch.Trim()){ Exec $batch } + } + foreach($source in @('install-shadow.sql','upgrade-shadow-v2-to-v3.sql')) { + $text=[IO.File]::ReadAllText((Join-Path $SqlDirectory $source)) + $bodies=@($text -split '(?im)^\s*GO\s*\r?$' | Where-Object {$_ -match '(?im)^\s*(CREATE|ALTER) PROCEDURE alg\.usp_rebuild_lane_state\b'}) + Assert ($bodies.Count -eq 1) "$source has exactly one real rebuild body" + $body=$bodies[0] + $start=[regex]::Match($body,'(?im)^\s*(CREATE|ALTER) PROCEDURE alg\.usp_rebuild_lane_state\b').Index + $body=$body.Substring($start).Trim() + Exec "IF OBJECT_ID('alg.usp_rebuild_lane_state') IS NOT NULL DROP PROCEDURE alg.usp_rebuild_lane_state" + Exec ([regex]::Replace($body,'(?i)\A(CREATE|ALTER) PROCEDURE','CREATE PROCEDURE')) + Exec 'DELETE alg.lane_state' + Exec "EXEC alg.usp_rebuild_lane_state @warehouse_id='01'" + Exec ([IO.File]::ReadAllText("$PSScriptRoot/assertions.sql")) + Write-Output "PASS: $source actual T-SQL aggregation, exclusions, repeat and scoping" + if($TestRepair) { + $old="NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL" + $new="(NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL OR LTRIM(RTRIM(cargo.locked)) IN ('0', 'N'))" + $original=(Canonical (Definition)).Replace($new,$old) + Exec $original + $before=Definition + $backup=Join-Path $PSScriptRoot "$testDb-$source.backup.json" + $repair=Join-Path $PSScriptRoot 'repair-shadow-lane-lock.ps1' + try { + $null=& $repair -Server $Server -Database $testDb -Mode Check -ExpectedSha256 (Hash $before) + Assert ((Definition) -ceq $before) 'check mode leaves procedure unchanged' + $null=& $repair -Server $Server -Database $testDb -Mode Apply -ExpectedSha256 (Hash $before) -BackupFile $backup + $fixed=Definition + Assert ((Canonical $fixed) -ceq (Canonical $before).Replace($old,$new)) 'repair changes only the lock predicate' + Exec "EXEC alg.usp_rebuild_lane_state @warehouse_id='01'" + Exec ([IO.File]::ReadAllText("$PSScriptRoot/assertions.sql")) + $null=& $repair -Server $Server -Database $testDb -Mode Apply -ExpectedSha256 (Hash $fixed) -BackupFile $backup + Assert ((Definition) -ceq $fixed) 'repeat apply is a no-op' + $refused=$false + try { $null=& $repair -Server $Server -Database $testDb -Mode Apply -ExpectedSha256 ('0'*64) -BackupFile $backup } + catch { $refused=$_.Exception.Message -like '*hash mismatch*' } + Assert $refused 'unexpected definition is refused' + # Invalid ALTER must roll back atomically, including connection transaction cleanup. + $tx=$cn.BeginTransaction() + $cmd=$cn.CreateCommand(); $cmd.Transaction=$tx; $cmd.CommandText='ALTER PROCEDURE alg.usp_rebuild_lane_state AS INVALID SQL HERE' + $failed=$false + try { [void]$cmd.ExecuteNonQuery() } catch { $failed=$true } finally { $tx.Rollback();$cmd.Dispose();$tx.Dispose() } + Assert ($failed -and (Definition) -ceq $fixed) 'failed ALTER preserves the published procedure' + $null=& $repair -Server $Server -Database $testDb -Mode Rollback -ExpectedSha256 (Hash $fixed) -BackupFile $backup + Assert ((Canonical (Definition)) -ceq (Canonical $before)) 'rollback restores the exact original body' + Exec "EXEC alg.usp_rebuild_lane_state @warehouse_id='01'" + Assert ((Scalar "SELECT available_count FROM alg.lane_state WHERE warehouse_id='01' AND lane_code='UNLOCK_N'") -eq 0) 'rollback reproduces the original N exclusion' + } finally { + if(Test-Path -LiteralPath $backup){ Remove-Item -LiteralPath $backup } + } + } + } +} finally { + if($created) { + $cn.ChangeDatabase('master') + [Data.SqlClient.SqlConnection]::ClearAllPools() + if($testDb -notmatch '^FT546WMS_LaneLock_Test_[a-f0-9]{32}$'){throw 'Invalid scratch database name'} + Exec "DROP DATABASE [$testDb]" + Write-Output "Scratch database removed: $testDb" + } + $cn.Dispose() +}