diff --git a/doc/algorithm-shadow/README.md b/doc/algorithm-shadow/README.md index 918093a..7c192a2 100644 --- a/doc/algorithm-shadow/README.md +++ b/doc/algorithm-shadow/README.md @@ -1,166 +1,89 @@ -# FT546WMS 现场影子测试操作手册 +# FT546WMS 影子 v3 现场操作手册 -## 1. 适用范围 +## 1. 这次部署做什么 -本手册用于分支 `test/ft546wms-shadow-from-main-20260811` 的现场测试。该分支基于远端稳定版本: +目标分支:`test/ft546wms-shadow-v3-20260903`。 -```text -origin/main = 916ac6bda32a5b102a3181cc8c5dbd5319c5656d +影子 v3 只观察算法建议,不改变 Legacy 分配结果。入库、自动出库、手动出库仍由原存储过程执行一次,原返回码、返回消息、库位和异常保持不变。正常业务请求只做固定大小的内存登记,不等待影子线程,也不在调用线程执行影子数据库、文件或同步日志操作。 + +影子后台会读取业务表,并且只写 `alg.*` 对象。它可能增加数据库 CPU 和 I/O,因此仍需低峰部署、监控和随时回滚。 + +## 2. 固定安全边界 + +- 现场只允许一个 FT546WMS JVM,稳定版和影子版不能同时运行。 +- 仓库固定为 `01`;原始数据中的 `1` 与 `01` 等价,`W02` 只用于诊断,不能覆盖配置。 +- SQL Server 必须使用 Windows 集成认证;脚本不接收用户名和密码。 +- 当前现场已有 v2 包,安装必须使用 `upgrade`,不能使用 `clean`。 +- v2 到 v3 升级保留 `alg.decision_log`;发现未知 `alg` 对象、清单不完整或 manifest 不匹配时脚本会停止。 +- 影子失败、过期、并发、超时、配置错误或输入缺失均 fail-open:Legacy 继续,影子样本作废。 +- 不执行正式 ACTIVE 切换,不增加 WCS/PDA/理库/预留/回放功能。 + +## 3. 部署前准备 + +现场负责人先记录: + +- SQL Server 名称和数据库名; +- 当前 v2 测试开始时间和时区; +- IDEA 当前 Run Configuration; +- 当前稳定实例 PID 和端口; +- 本次交付给出的精确 v3 提交号 ``; +- 应用日志目录。 + +部署前先导出现有 v2 证据。不要卸载 v2: + +```cmd +cd /d C:\project\FT546WMS-SHANDOW +call scripts\shadow-v3-export.cmd C:\project\FT546WMS-SHANDOW "" "" "" "C:\project\FT546WMS-v2-before-v3" "" ``` -影子算法只提出候选结果并记录比较数据。原有 Legacy 存储过程仍执行一次且仅执行一次,Legacy 的库位分配、返回码和响应内容始终是最终结果。 +如果当前代码还没有 v3 导出脚本,先用既有导出方式保存 `alg.decision_log`、诊断结果、日志、分支和提交号,再继续。 -影子模式仍会读取 Legacy 表,并会在 `alg` schema 中刷新读模型和写入决策日志。因此“只观察”表示不改变 Legacy 业务数据和决策权,不表示完全没有数据库 CPU、I/O 或执行时间开销。 +## 4. 按顺序部署 v3 -## 2. 安全边界 +### 第一步:停止 IDEA 中的 v2 稳定实例 -- 默认配置 `FORTUCKY_SHADOW_ENABLED=false`,未显式开启时不执行影子规划。 -- 启用时只允许 `FORTUCKY_ALGORITHM_MODE=SHADOW`;误设 `ACTIVE` 或 `LEGACY` 会导致应用拒绝启动。 -- 影子事务使用 `REQUIRES_NEW`、`READ_UNCOMMITTED` 和 3 秒超时,并且只在名为 `ft546-shadow-observer` 的 daemon 工作线程执行。 -- 专用执行器只有一个线程、队列容量为 0,拒绝策略不会回落到 Legacy 请求线程;忙时其他观察立即跳过。 -- Legacy 线程仅提交任务和通知内存结果,不等待影子规划或比较,也不执行任何影子数据库操作。 -- 影子异常、超时、输入缺失和比较日志写入失败都会 fail-open,不跳过 Legacy 流程。 -- 安装包不在 `dbo.bas_cargo_space`、`dbo.bas_stock`、`dbo.mydo_job` 或其他 Legacy 表上创建触发器。 -- 安装包不对 Legacy 业务表执行 `INSERT`、`UPDATE`、`DELETE` 或 `MERGE`。 -- 影子模式不调用预留、深位锁、理库、回放、移库完成、WCS 完成或 PDA 回调写入过程。 -- `alg.reservation` 和 `alg.deep_workflow_lock` 是规划器读取兼容表;影子包不安装修改它们的过程,SHADOW 模式也禁止进入这些写入口。 +记录停止时间,在 IDEA 点 Stop。用下面命令确认最多只有一个相关 JVM,最好为 0: -## 3. 交付文件 - -```text -fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql -fortucky-algorithm/src/main/resources/db/sqlserver/diagnostics-shadow.sql -fortucky-algorithm/src/main/resources/db/sqlserver/uninstall-shadow.sql +```cmd +jps -lv | findstr /I "RuoYiApplication FT546WMS" ``` -只使用名称带 `-shadow` 的脚本。该分支已移除原算法通用的 `install.sql`、`diagnostics.sql` 和 `uninstall.sql`,避免现场误装 ACTIVE 数据库对象。 +停止和重新启动之间会有一次短暂停机,这是计划内中断。不要同时启动两个实例来消除停机,否则可能重复执行定时任务、消费或外部回调。 -## 4. 上现场前必须完成的演练 +### 第二步:同步精确分支和提交 -1. 在现场数据库的最近备份副本上启动远端 `origin/main`,完成一次入库和一次出库基线测试。 -2. 在备份副本执行 `install-shadow.sql`,确认脚本全部成功。 -3. 用影子分支完成同样的入库和出库,确认业务结果仍来自 Legacy。 -4. 导出 `alg.decision_log`,然后停止影子程序并执行 `uninstall-shadow.sql`。 -5. 再次启动远端 `origin/main`,重复基线测试。 - -未完成数据库副本演练,不要直接在现场生产库安装。 - -## 5. 准备两个独立 IDEA 工作区 - -当前开发机的本地 `main` 已与 `origin/main` 分叉,不能作为稳定版回滚源。先执行 `git fetch origin`,确认 `origin/main` 的提交号,再从远端提交准备只读稳定工作区。 - -在仓库主目录执行。目标目录必须不存在;如果已存在,先人工确认它的来源,不要覆盖或删除: - -```powershell +```cmd +cd /d C:\project\FT546WMS-SHANDOW git fetch origin -$ExpectedStableCommit = "916ac6bda32a5b102a3181cc8c5dbd5319c5656d" -$RemoteMainCommit = git rev-parse origin/main -if ($RemoteMainCommit -ne $ExpectedStableCommit) { throw "origin/main 已变化,停止部署并重新合并验证" } - -$StableWorktree = "D:\workSpace\PROJECT_SPACE\FT546\FT546MWS\worktrees\stable-origin-main-20260811" -if (Test-Path -LiteralPath $StableWorktree) { throw "稳定版 worktree 目标目录已存在,请人工核对" } -git worktree add --detach $StableWorktree origin/main - -$CheckedOutCommit = git -C $StableWorktree rev-parse HEAD -if ($CheckedOutCommit -ne $ExpectedStableCommit) { throw "稳定版 worktree 提交校验失败" } -if (git -C $StableWorktree status --porcelain) { throw "稳定版 worktree 不是干净状态" } +git switch test/ft546wms-shadow-v3-20260903 +git pull --ff-only +git rev-parse HEAD +git status --porcelain ``` -两个 IDEA 窗口分别打开: +`git rev-parse HEAD` 必须等于交付的 ``,`git status --porcelain` 必须没有输出。若不一致,停止部署。 -```text -稳定版:D:\workSpace\PROJECT_SPACE\FT546\FT546MWS\worktrees\stable-origin-main-20260811 -影子版:D:\workSpace\PROJECT_SPACE\FT546\FT546MWS\worktrees\shadow-from-main-20260811 +### 第三步:执行只读预检 + +```cmd +call scripts\shadow-v3-preflight.cmd C:\project\FT546WMS-SHANDOW "" "" ``` -为两个窗口分别创建 Run Configuration: +只有看到 `PREFLIGHT_OK` 才能继续。预检检查分支、精确提交、干净工作树、main 祖先、JVM 数量、SQL 连接、v2/v3 manifest 和当前快照;它不修改数据库。 -- `FT546WMS-STABLE-origin-main`:完全沿用当前稳定现场参数,不增加任何 `FORTUCKY_*` 环境变量。 -- `FT546WMS-SHADOW`:复制稳定配置的 JDK、profile、端口、数据库和 Redis 参数,只增加下一节列出的环境变量。 +### 第四步:升级数据库包 -禁止同时运行两个实例。即使使用不同 HTTP 端口,两个 WMS 实例也可能重复执行定时任务、消费任务或发送外部回调。切换时必须先停止当前实例,确认进程退出和端口释放,再启动另一个实例。 +当前现场是 v2,只执行: -## 6. 安装影子数据库包 - -### 6.1 安装前检查 - -由 DBA 对现场数据库做可恢复的完整备份,并记录备份文件、时间和恢复负责人。随后执行: - -```sql -SELECT SCHEMA_ID(N'alg') AS alg_schema_id; - -SELECT trigger_object.name AS trigger_name, - schema_name(trigger_object.schema_id) AS trigger_schema, - schema_name(parent_object.schema_id) AS parent_schema, - parent_object.name AS parent_table -FROM sys.triggers trigger_object -JOIN sys.objects parent_object ON parent_object.object_id = trigger_object.parent_id -WHERE trigger_object.is_ms_shipped = 0 -ORDER BY parent_schema, parent_object.name, trigger_schema, trigger_object.name; - -SELECT schema_name(object.schema_id) AS schema_name, - object.name AS object_name, - object.type_desc -FROM sys.objects object -WHERE object.is_ms_shipped = 0 -ORDER BY schema_name(object.schema_id), object.type_desc, object.name; +```cmd +call scripts\shadow-v3-install.cmd "" "" upgrade ``` -预期 `alg_schema_id` 为 `NULL`。安装脚本发现任何既有 `alg` schema 会以错误 51000 拒绝执行。如果现场已经存在 `alg` schema 或同名对象,停止安装并先确认所有权;不要覆盖,也不要执行本分支卸载脚本。 +只有全新测试数据库、确认没有 `alg` schema 时才允许使用 `clean`。现场生产库不要使用 `clean`。 -把两份查询结果连同执行时间保存为安装前基线。现场可能已有 Legacy 自身的触发器,安装后的全库 DML 触发器集合必须与安装前完全一致;用户对象清单只能新增本包定义的 `alg.*` 对象。 +### 第五步:设置 IDEA 环境变量 -### 6.2 执行安装 - -在低业务量时段使用 SQL Server Management Studio 打开并执行: - -```text -fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql -``` - -脚本使用 `GO` 分批执行并在当前会话设置 `READ_UNCOMMITTED`。首次安装会全量建立读模型,可能扫描较多现场数据;如果副本演练时间超出可接受窗口,现场不要继续。 - -也可以使用已配置 Windows 集成认证的 `sqlcmd`: - -```powershell -sqlcmd -S "" -d "<数据库>" -E -b -r1 -i "<仓库>\fortucky-algorithm\src\main\resources\db\sqlserver\install-shadow.sql" -``` - -不要把数据库密码写进脚本、IDEA Run Configuration 截图或命令历史。 - -### 6.3 安装后检查 - -重新执行安装前的两份元数据查询。全库 DML 触发器结果必须完全一致,用户对象清单只能新增预期的 `alg.*` 对象。保存安装后结果和执行时间,再执行: - -```sql -SELECT schema_name(object.schema_id) AS schema_name, - object.name, - object.type_desc -FROM sys.objects object -WHERE schema_name(object.schema_id) = N'alg' -ORDER BY object.type_desc, object.name; - -SELECT warehouse_id, - COUNT(*) AS lane_count, - MAX(rebuilt_at) AS latest_rebuild_at -FROM alg.lane_state -GROUP BY warehouse_id; -``` - -本次新增对象只能位于 `alg` schema;`lane_state` 应存在目标仓库的数据。如果安装部分失败,不要启动影子程序,也不要执行本分支卸载脚本:拒绝式卸载会因对象缺失而报错 51014。保留完整输出和安装前后对象清单,由 DBA 决定从备份恢复,或依据清单人工清理本次未完成安装。不要补写 manifest、补建空对象或修改卸载脚本来绕过保护;清理完成且 `SCHEMA_ID(N'alg')` 再次为 `NULL` 后,才能重新完整安装。 - -确认包标记唯一且版本准确: - -```sql -SELECT package_id, package_version, installed_at -FROM alg.shadow_package_manifest; -``` - -必须只有一行:`FT546WMS_SHADOW_ONLY` / `2026-08-12-1`。不一致时不要启动影子程序,也不要手工修改标记。 - -## 7. IDEA 启用影子模式 - -在 `FT546WMS-SHADOW` 的 Environment variables 中设置: +在影子 Run Configuration 中使用: ```text FORTUCKY_SHADOW_ENABLED=true @@ -168,137 +91,101 @@ FORTUCKY_ALGORITHM_MODE=SHADOW FORTUCKY_ALGORITHM_DATABASE_WRITES_ENABLED=true FORTUCKY_ALGORITHM_WAREHOUSE_ID=01 FORTUCKY_SHADOW_TIMEOUT_SECONDS=3 -FORTUCKY_ALGORITHM_LEGACY_FALLBACK_ON_TECHNICAL_ERROR=false +FORTUCKY_SHADOW_SNAPSHOT_MAX_AGE_SECONDS=60 +FORTUCKY_SHADOW_QUIET_INTERVAL_MILLIS=1000 ``` -将 `01` 替换为现场实际仓库号。保持 `FORTUCKY_SHADOW_WAREHOUSE_ID` 未设置,使影子入口统一回退到 `FORTUCKY_ALGORITHM_WAREHOUSE_ID`。不要设置 replay、tidy、ACTIVE 或 WCS callback 相关变量。 +不要设置 `ACTIVE`,不要添加数据库密码,不要设置 replay、tidy、reservation 或 WCS callback 开关。 -`FORTUCKY_ALGORITHM_DATABASE_WRITES_ENABLED=true` 是影子读模型与 `alg.decision_log` 所必需的应用开关;当前影子代码路径只执行已安装的 `alg.*` 写入,它不把 Legacy 决策权交给算法。该开关不是数据库权限隔离,安全性还依赖本分支的 SHADOW 启动保护、删减后的 SQL 包和现场变更审计。 +### 第六步:只启动一次影子实例 -切换步骤: +在 IDEA 启动 v3。不要重复点击 Run。启动时后台会认领唯一实例并建立第一代快照;认领或刷新失败只会关闭影子可用性,不应阻止 Legacy 启动。 -1. 记录稳定实例停止时间,停止 `FT546WMS-STABLE-origin-main`。 -2. 确认 Java 进程退出、端口释放,且没有第二个 WMS 实例运行。 -3. 启动 `FT546WMS-SHADOW`。 -4. 如果出现 `Shadow observation requires ...`,不要修改保护条件,直接回滚到稳定实例。 -5. 先做只读登录和查询冒烟检查,再由现场人员执行一笔受控入库和一笔受控出库。 -6. 确认 Legacy 返回码、库位和现场设备动作与稳定流程一致。 -7. 确认 `alg.decision_log` 出现 `INBOUND`/`OUTBOUND` 记录,并至少有受控业务出现相应的 `*_COMPARE` 记录。 +启动后检查: -影子任务在 Legacy 前提交,但 Legacy 不等待后台线程。线程调度和数据库时序可能导致影子读取与 Legacy 写入重叠,甚至在 Legacy 写入后读取;影子结果不是严格的“Legacy 前快照”,分析时必须结合时间和业务结果解释。 - -单线程零队列执行器忙时,被跳过的请求不会调用算法,也没有对应的计划/比较决策日志;如果规划或 Legacy 超过观察期限,也可能只有计划日志而没有比较日志。Legacy 存储过程仍按原流程立即执行。这是保护业务延迟和数据库负载的预期行为,不要据此重放业务请求。 - -## 8. 观察与停止条件 - -应用日志中的以下文本表示后台影子已失败开放,Legacy 仍继续执行: - -```text -Shadow inbound observation failed open -Shadow outbound observation failed open -Shadow inbound observation interrupted -Shadow outbound observation interrupted -Inbound shadow submission failed open -Project outbound shadow submission failed open -Exact-stock shadow submission failed open +```cmd +jps -lv | findstr /I "RuoYiApplication FT546WMS" ``` -出现单条告警不等于 Legacy 失败,但应记录时间和业务单号。测试前由现场负责人和 DBA 根据稳定时段基线写下响应延迟、SQL CPU/I/O、锁等待、连接数和 Shadow 告警频率阈值;未约定阈值时,只允许少量受控业务,不进入连续观察。满足任一条件立即停止影子测试并执行第 10 节回滚: +只能看到一个 FT546WMS JVM。 -- Legacy 入库或出库结果、返回码、消息或设备动作与稳定版不同; -- 正常接口达到预先约定的延迟阈值,或同类 3 秒超时告警重复出现; -- SQL Server CPU、I/O、锁等待或连接数出现不可接受增长; -- `alg` 之外出现任何由本次测试新增的表、过程、触发器或数据修改; -- 同一 Legacy 存储过程疑似被重复调用; -- WMS、WCS、PDA 或现场操作出现异常。 +### 第七步:做只读和受控业务检查 -实际进入影子规划的请求每次都会按仓库全量刷新读模型。应用在单个 JVM 内将观察周期上限限制为 1、队列容量为 0,其他并发观察立即跳过;这能限制重叠刷新,但 `READ_UNCOMMITTED` 和单并发都无法消除全表扫描带来的 CPU 和 I/O,所以必须从少量受控业务开始,不能直接长时间全量开放。若部署多个影子 JVM,该上限分别生效,现场仍只允许启动一个 WMS 实例。 +先完成登录、页面查询等只读检查,再由现场人员执行一笔受控入库、一笔自动出库和一笔手动出库。确认: -## 9. 数据采集与交付 +- Legacy 返回码、消息、库位和设备动作与稳定流程一致; +- 没有相同存储过程重复执行; +- `alg.shadow_snapshot_state` 为仓库 `01` 的 `PUBLISHED`; +- `alg.shadow_instance_heartbeat` 只有当前实例; +- `alg.decision_log` 出现新的计划/比较记录; +- 应用日志中的影子 warning 不包含业务载荷或异常消息。 -测试开始和结束时各执行一次: +## 5. 立即停止条件 -```text -fortucky-algorithm/src/main/resources/db/sqlserver/diagnostics-shadow.sql +出现任一情况立即执行第 7 节回滚: + +- Legacy 结果、响应码、消息、库位或设备动作改变; +- 同一个 Legacy 存储过程疑似执行两次; +- 出现第二个 FT546WMS JVM; +- `alg` 之外出现本次部署造成的数据写入或新对象; +- WMS、WCS、PDA 或现场作业异常; +- 正常接口延迟或数据库 CPU、I/O、锁等待超过现场阈值; +- 快照长时间不是 `PUBLISHED`,或 `ERROR_*` 指标持续增长。 + +单条 `shadow_failure` 表示该影子样本失败开放,不等于 Legacy 失败;但必须记录时间并纳入分析。 + +## 6. 连续观察和七天导出 + +至少连续观察 7 天,覆盖正常业务高峰、入库、自动出库和手动出库。期间不要重放业务来补影子样本。 + +观察结束后运行: + +```cmd +cd /d C:\project\FT546WMS-SHANDOW +call scripts\shadow-v3-export.cmd C:\project\FT546WMS-SHANDOW "" "" "" "C:\project\FT546WMS-shadow-v3-export" "" ``` -在 SSMS 中选择“结果输出到文件”,保留完整结果。该脚本只读,包含对象清单、读模型统计、Legacy 表行数/校验和、当前锁和决策日志。Legacy 表校验和会扫描数据,只在低业务量时执行,不要高频轮询。 +成功时显示 `EXPORT_OK`。输出目录包含: -测试结束、卸载之前单独导出原始决策记录: +- `deployment.txt`:分支、提交、工作树; +- `runtime-processes.txt`:JVM 进程; +- `environment-variable-names.txt`:仅环境变量名,不含值; +- `sql-diagnostics.txt`:manifest、快照、心跳、输入覆盖、阻塞; +- `comparison-detail.txt`:比较汇总和完整决策行; +- `runtime-metrics.txt`:准入、跳过、错误和时延桶; +- `inventory-parity.txt`:Legacy 与已发布快照库存差异计数; +- `shadow-warnings.txt`:日志目录中的结构化影子 warning。 -```sql -SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; +把整个输出目录压缩后交给分析,不要包含密码、令牌或与测试无关的个人信息。只有七天数据通过分析后,才讨论正式切换;本分支本身不执行 ACTIVE 切换。 -SELECT decision_id, warehouse_id, decision_type, input_version, - result_code, reason, created_at -FROM alg.decision_log -WHERE created_at >= '<测试开始时间>' - AND created_at < '<测试结束时间>' -ORDER BY created_at, decision_id; +## 7. 回滚 + +首选回滚只关闭影子,不卸载数据库对象: + +1. 在 IDEA 停止 v3,记录停止时间; +2. 设置 `FORTUCKY_SHADOW_ENABLED=false`; +3. 完整重启一次; +4. 检查只有一个 JVM; +5. 做登录、查询、受控入库和出库冒烟检查。 + +更彻底的应用回滚是停止 v3,切回现场原稳定源码/提交并用原 Run Configuration 启动。无论哪种方式,都先恢复业务服务,再处理数据库对象。 + +`alg.*` 可以保留供取证,稳定 Legacy 不依赖它。不要把卸载当作紧急回滚步骤。 + +## 8. 可选卸载 + +只有满足全部条件才卸载:影子实例已停止、稳定服务已恢复、七天证据已导出并备份、DBA 已确认维护窗口。 + +```cmd +call scripts\shadow-v3-uninstall.cmd "" "" CONFIRM_UNINSTALL ``` -出库对比记录的 `reason` 会带出 `detail`、`project`、`product`、`tray`、`stock`、`snapshotSync`、`legacyMatches` 和 `shadowMatches`。其中 `legacyMatches` 是按 Legacy 有效库存条件计算的实时数量,`shadowMatches` 是影子快照中按项目/产品条件计算的数量;两者不一致时先检查快照同步时间,再判断业务映射。 +卸载器只接受精确口令 `CONFIRM_UNINSTALL`,并由 SQL 包再次校验 v3 manifest 和完整对象白名单。发现未知/缺失对象时会在删除前停止。不要修改 manifest 或脚本绕过保护。 -交付分析的数据包至少包括: +## 9. 自动化验证与残余风险 -- 决策日志导出文件,保持原始列名和时间字段; -- `diagnostics-shadow.sql` 开始和结束两份结果; -- 应用日志中测试时间段的 Shadow 告警及其前后异常栈; -- 测试开始/结束时间、时区、仓库号、入库次数、自动出库次数、手动出库次数; -- 现场人员确认的 Legacy 最终库位、返回码及异常说明; -- 当前分支提交号和构建 JAR 的 SHA-256。 - -不要提供数据库密码、访问令牌或与分析无关的个人信息。 - -## 10. 快速回滚 - -### 10.1 首选:切回远端稳定源码 - -1. 停止 `FT546WMS-SHADOW`,记录停止时间。 -2. 确认进程退出和端口释放。 -3. 在独立稳定工作区启动 `FT546WMS-STABLE-origin-main`。 -4. 执行登录、查询、受控入库和受控出库冒烟检查。 - -稳定版不引用 `alg.*`,因此应用回滚时无需立即卸载数据库对象。先恢复生产服务,再在维护窗口导出数据并卸载。 - -### 10.2 备用:仅关闭影子开关 - -如果稳定工作区暂时无法启动,可在影子 Run Configuration 中设置: - -```text -FORTUCKY_SHADOW_ENABLED=false -``` - -然后完整重启应用。该方式保留影子分支代码,但所有影子入口会直接返回空观察结果。它不替代稳定版回滚,只用于临时恢复。 - -## 11. 完整卸载 - -仅在影子应用已停止、`alg.decision_log` 已导出且稳定版已恢复后执行。 - -使用 SSMS 打开并执行: - -```text -fortucky-algorithm/src/main/resources/db/sqlserver/uninstall-shadow.sql -``` - -卸载脚本先校验包标记必须精确等于 `FT546WMS_SHADOW_ONLY` / `2026-08-12-1`,再校验 `alg` 顶层对象的名称和类型必须与本包白名单完全一致。发现未知对象、缺失对象、类型不符或标记不符时会在第一个 `DROP` 前拒绝执行,不删除任何对象。校验通过后才在事务内删除本分支拥有的 `alg` 过程、视图、表和 schema;失败会回滚,不修改 Legacy 业务行。执行后验证: - -```sql -SELECT SCHEMA_ID(N'alg') AS alg_schema_id; -``` - -预期结果为 `NULL`。再次查询全库 DML 触发器集合,结果应与安装前保存的基线一致。 - -不要在影子应用运行时卸载。如果脚本报错 51011-51014,保存错误和对象清单,停止手工删除,由 DBA 判断恢复或清理方案。不要修改 manifest 来绕过保护。卸载后如需再次测试,必须重新执行完整安装和检查流程。 - -## 12. 已知残余风险 - -- 当前自动化验证是 Java 单元测试和 SQL 静态审计,不能替代真实 SQL Server 2016 副本演练。 -- `READ_UNCOMMITTED` 可能读到业务事务的中间状态;该结果只用于影子比较,不参与 Legacy 决策。 -- 零等待异步提交不保证严格的 Legacy 前置快照;影子读取可能与 Legacy 写入重叠或发生在其后。 -- 3 秒事务超时会触发 fail-open,但 JDBC 取消和 SQL Server 回滚完成时间不保证严格等于 3 秒。 -- 每次影子规划前的全量刷新可能增加数据库 CPU 和 I/O;现场必须监控并设置短测试窗口。 -- JVM 内单线程零队列执行器只避免同一实例的重叠刷新,不限制其他进程或数据库会话;禁止同时运行稳定版和影子版,也禁止运行多个影子实例。 -- 应用级 fail-open 能保护 Legacy 控制流,但不能消除数据库、网络或主机资源争用。 - -这些残余风险是采用“零 Legacy 触发器、每次规划前全量刷新”的代价,也是现场必须先小流量验证、随时可切回稳定源码的原因。 +- 本地单元/静态测试不能替代 SQL Server 2016 备份副本演练。 +- `READ UNCOMMITTED` 只用于观察,可能看到业务事务中间状态。 +- 单线程容量一队列限制影子后台并发,但不能消除数据库资源竞争。 +- 3 秒 worker 等待超时不等于 JDBC/SQL Server 一定在 3 秒内完成取消或回滚。 +- 影子调用线程不等待后台,但主机和数据库资源竞争仍可能间接影响 Legacy,需要现场监控。 diff --git a/doc/algorithm-shadow/SHADOW_V3_SERVER_AI_EXPORT_PROMPT.md b/doc/algorithm-shadow/SHADOW_V3_SERVER_AI_EXPORT_PROMPT.md new file mode 100644 index 0000000..27ecead --- /dev/null +++ b/doc/algorithm-shadow/SHADOW_V3_SERVER_AI_EXPORT_PROMPT.md @@ -0,0 +1,60 @@ +# 给服务器 AI 的 FT546WMS 影子 v3 导出指令 + +把下面整段交给服务器上的 AI。先替换尖括号内容,不要把密码放进提示词。 + +```text +你在 Windows CMD 环境中操作 FT546WMS 影子 v3。你的任务只导出证据,不修改数据库、不切换分支、不重启服务、不卸载任何对象。 + +固定信息: +- 仓库目录:C:\project\FT546WMS-SHANDOW +- SQL Server: +- 数据库: +- v3 测试开始 UTC: +- 应用日志目录: +- 输出目录:C:\project\FT546WMS-shadow-v3-export +- 期望分支:test/ft546wms-shadow-v3-20260903 +- 期望提交: + +安全规则: +1. 只能使用 cmd.exe,不使用 PowerShell。 +2. 数据库只能使用 Windows 集成认证,不询问、保存或输出密码。 +3. 不执行 INSERT、UPDATE、DELETE、MERGE、TRUNCATE、DROP、ALTER、CREATE、EXEC。 +4. 不运行 install、upgrade、uninstall,不停止或启动 IDEA/Java。 +5. 任一步失败就停止,保留错误原文,不猜测、不绕过保护。 +6. 不修改仓库文件,不执行 git checkout/switch/reset/clean/pull。 + +先在 CMD 执行只读核对: +cd /d C:\project\FT546WMS-SHANDOW +git branch --show-current +git rev-parse HEAD +git status --porcelain +jps -lv | findstr /I "RuoYiApplication FT546WMS" + +必须满足:分支等于期望分支,HEAD 等于期望提交,git status 没有输出,相关 JVM 最多一个。否则停止并报告。 + +然后执行仓库自带的只读导出器: +call scripts\shadow-v3-export.cmd C:\project\FT546WMS-SHANDOW "" "" "" "C:\project\FT546WMS-shadow-v3-export" "" + +必须看到 EXPORT_OK。随后只检查输出文件是否存在,不打开或改写内容: +dir /b C:\project\FT546WMS-shadow-v3-export + +期望至少包含: +deployment.txt +runtime-processes.txt +environment-variable-names.txt +sql-diagnostics.txt +comparison-detail.txt +runtime-metrics.txt +inventory-parity.txt +shadow-warnings.txt + +最后报告: +- 实际分支和提交号; +- JVM 数量; +- EXPORT_OK 是否出现; +- 输出目录; +- 文件名和大小; +- 任何错误。 + +不要在回复中粘贴环境变量值、数据库凭据或完整业务数据。不要压缩以外的改写;由现场人员把整个输出目录压缩后交给分析。 +``` diff --git a/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowV3OperationsPackageTest.java b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowV3OperationsPackageTest.java new file mode 100644 index 0000000..0c09e3d --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowV3OperationsPackageTest.java @@ -0,0 +1,143 @@ +package com.ruoyi.algorithm.shadow; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; + +import static org.assertj.core.api.Assertions.assertThat; + +class ShadowV3OperationsPackageTest { + private final Path root = projectRoot(); + + @Test + void packageContainsAllCmdOnlyOperationsFiles() { + assertThat(root.resolve("scripts/shadow-v3-preflight.cmd")).isRegularFile(); + assertThat(root.resolve("scripts/shadow-v3-install.cmd")).isRegularFile(); + assertThat(root.resolve("scripts/shadow-v3-uninstall.cmd")).isRegularFile(); + assertThat(root.resolve("scripts/shadow-v3-export.cmd")).isRegularFile(); + assertThat(root.resolve("scripts/shadow-v3-export.sql")).isRegularFile(); + assertThat(root.resolve("doc/algorithm-shadow/SHADOW_V3_SERVER_AI_EXPORT_PROMPT.md")) + .isRegularFile(); + } + + @Test + void preflightIsReadOnlyAndChecksExactSourceAndSingleProcess() throws IOException { + String script = text("scripts/shadow-v3-preflight.cmd"); + + assertThat(script).startsWith("@echo off").contains( + "setlocal", + "git status --porcelain", + "git merge-base --is-ancestor", + "git branch --show-current", + "git rev-parse HEAD", + "jps -lv", + "sqlcmd", + "shadow_snapshot_state", + "FT546WMS_SHADOW_ONLY", + "2026-09-03-3", + "exit /b 1"); + assertThat(script.toLowerCase(Locale.ROOT)).doesNotContain( + "git checkout", "git switch", "git reset", "git clean", "powershell"); + } + + @Test + void installAndUninstallAreFailFastAndStrictlyGuarded() throws IOException { + String installer = text("scripts/shadow-v3-install.cmd"); + String uninstaller = text("scripts/shadow-v3-uninstall.cmd"); + + assertThat(installer).startsWith("@echo off").contains( + "if /I \"%~3\"==\"upgrade\"", + "if /I \"%~3\"==\"clean\"", + "upgrade-shadow-v2-to-v3.sql", + "install-shadow.sql", + "sqlcmd", "-E", "-b", "-r1", + "if errorlevel 1 exit /b 1"); + assertThat(uninstaller).startsWith("@echo off").contains( + "CONFIRM_UNINSTALL", + "uninstall-shadow.sql", + "sqlcmd", "-E", "-b", "-r1", + "if errorlevel 1 exit /b 1"); + assertThat(installer.toLowerCase(Locale.ROOT)).doesNotContain("powershell"); + assertThat(uninstaller.toLowerCase(Locale.ROOT)).doesNotContain("powershell"); + } + + @Test + void exportCollectsSourceRuntimeSqlAndWarningEvidence() throws IOException { + String exporter = text("scripts/shadow-v3-export.cmd"); + String exportSql = text("scripts/shadow-v3-export.sql").toLowerCase(Locale.ROOT); + + assertThat(exporter).startsWith("@echo off").contains( + "git rev-parse HEAD", + "git branch --show-current", + "git status --porcelain", + "jps -lv", + "shadow-v3-export.sql", + "sqlcmd", "-E", "-b", "-r1", + "FORTUCKY_SHADOW_", + "shadow_failure"); + assertThat(exportSql).contains( + "$(warehouseid)", + "$(startutc)", + "from alg.shadow_package_manifest", + "from alg.shadow_snapshot_state", + "from alg.shadow_instance_heartbeat", + "from alg.shadow_runtime_metric", + "from alg.decision_log", + "from sys.dm_exec_requests"); + assertThat(exportSql).doesNotContain( + "insert ", "update ", "delete ", "merge ", "truncate ", "drop ", "alter ", "create ", "exec "); + } + + @Test + void runbookAndServerAiPromptPreserveTheSafeFieldOrder() throws IOException { + String runbook = text("doc/algorithm-shadow/README.md"); + String aiPrompt = text("doc/algorithm-shadow/SHADOW_V3_SERVER_AI_EXPORT_PROMPT.md"); + + assertThat(runbook).contains( + "test/ft546wms-shadow-v3-20260903", + "短暂停机", + "正常业务请求只做固定大小的内存登记,不等待影子线程", + "git fetch origin", + "shadow-v3-preflight.cmd", + "shadow-v3-install.cmd \"\" \"\" upgrade", + "FORTUCKY_SHADOW_ENABLED=true", + "FORTUCKY_SHADOW_SNAPSHOT_MAX_AGE_SECONDS=60", + "FORTUCKY_SHADOW_QUIET_INTERVAL_MILLIS=1000", + "至少连续观察 7 天", + "FORTUCKY_SHADOW_ENABLED=false", + "CONFIRM_UNINSTALL"); + assertThat(runbook.indexOf("停止 IDEA 中的 v2 稳定实例")) + .isLessThan(runbook.indexOf("git fetch origin")); + assertThat(runbook.indexOf("shadow-v3-preflight.cmd")) + .isLessThan(runbook.lastIndexOf("shadow-v3-install.cmd")); + assertThat(runbook.indexOf("至少连续观察 7 天")) + .isLessThan(runbook.indexOf("## 7. 回滚")); + assertThat(runbook.indexOf("## 7. 回滚")) + .isLessThan(runbook.indexOf("## 8. 可选卸载")); + + assertThat(aiPrompt).contains( + "你的任务只导出证据", + "不修改数据库", + "不切换分支", + "不重启服务", + "shadow-v3-export.cmd", + "EXPORT_OK"); + } + + private String text(String relativePath) throws IOException { + return Files.readString(root.resolve(relativePath), StandardCharsets.UTF_8); + } + + private static Path projectRoot() { + Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize(); + while (current != null && !Files.isDirectory(current.resolve("scripts"))) { + current = current.getParent(); + } + assertThat(current).as("project root containing scripts").isNotNull(); + return current; + } +} diff --git a/scripts/shadow-v3-export.cmd b/scripts/shadow-v3-export.cmd new file mode 100644 index 0000000..e1f3e5b --- /dev/null +++ b/scripts/shadow-v3-export.cmd @@ -0,0 +1,86 @@ +@echo off +setlocal EnableExtensions DisableDelayedExpansion + +if "%~1"=="" goto :usage +if "%~2"=="" goto :usage +if "%~3"=="" goto :usage +if "%~4"=="" goto :usage +if "%~5"=="" goto :usage +if "%~6"=="" goto :usage + +set "REPO_ROOT=%~f1" +set "SQL_SERVER=%~2" +set "DATABASE=%~3" +set "START_UTC=%~4" +set "OUTPUT_DIR=%~f5" +set "LOG_DIR=%~f6" +set "SQL_FILE=%~dp0shadow-v3-export.sql" +set "WAREHOUSE_ID=%FORTUCKY_ALGORITHM_WAREHOUSE_ID%" +if not defined WAREHOUSE_ID set "WAREHOUSE_ID=01" +if "%WAREHOUSE_ID%"=="1" set "WAREHOUSE_ID=01" +rem FORTUCKY_SHADOW_ variables are collected by the FORTUCKY_ prefix scan below. + +if not "%WAREHOUSE_ID%"=="01" (call :fail "FORTUCKY_ALGORITHM_WAREHOUSE_ID must be 01" & exit /b 1) +if not exist "%REPO_ROOT%\.git" (call :fail "REPO_ROOT is not a Git repository" & exit /b 1) +if not exist "%LOG_DIR%" (call :fail "LOG_DIR does not exist" & exit /b 1) +if not exist "%SQL_FILE%" (call :fail "export SQL file is missing" & exit /b 1) +where git >nul 2>nul +if errorlevel 1 (call :fail "git is not available on PATH" & exit /b 1) +where jps >nul 2>nul +if errorlevel 1 (call :fail "jps is not available on PATH" & exit /b 1) +where sqlcmd >nul 2>nul +if errorlevel 1 (call :fail "sqlcmd is not available on PATH" & exit /b 1) + +if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%" +if errorlevel 1 (call :fail "cannot create OUTPUT_DIR" & exit /b 1) + +pushd "%REPO_ROOT%" >nul +if errorlevel 1 (call :fail "cannot enter REPO_ROOT" & exit /b 1) +( + echo export_started_utc=%DATE% %TIME% + echo expected_branch=test/ft546wms-shadow-v3-20260903 + git branch --show-current + git rev-parse HEAD + git status --porcelain +) > "%OUTPUT_DIR%\deployment.txt" 2>&1 +if errorlevel 1 (call :fail "cannot collect Git deployment evidence" & exit /b 1) +popd >nul + +jps -lv > "%OUTPUT_DIR%\runtime-processes.txt" 2>&1 +if errorlevel 1 (call :fail "cannot collect JVM process evidence" & exit /b 1) + +( + for /f "tokens=1 delims==" %%E in ('set FORTUCKY_ 2^>nul') do echo %%E +) > "%OUTPUT_DIR%\environment-variable-names.txt" + +sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -w 65535 -y 0 -Y 0 -v WarehouseId="%WAREHOUSE_ID%" StartUtc="%START_UTC%" OutputDir="%OUTPUT_DIR%" -i "%SQL_FILE%" +if errorlevel 1 (call :fail "read-only SQL export failed" & exit /b 1) + +findstr /S /I /N /C:"shadow_failure" "%LOG_DIR%\*.log" "%LOG_DIR%\*.txt" > "%OUTPUT_DIR%\shadow-warnings.txt" 2>nul + +call :require_output "sql-diagnostics.txt" +if errorlevel 1 exit /b 1 +call :require_output "comparison-detail.txt" +if errorlevel 1 exit /b 1 +call :require_output "runtime-metrics.txt" +if errorlevel 1 exit /b 1 +call :require_output "inventory-parity.txt" +if errorlevel 1 exit /b 1 +call :require_output "shadow-warnings.txt" +if errorlevel 1 exit /b 1 + +echo EXPORT_OK output=%OUTPUT_DIR% +exit /b 0 + +:require_output +if not exist "%OUTPUT_DIR%\%~1" (call :fail "expected export file is missing" & exit /b 1) +exit /b 0 + +:usage +echo Usage: %~nx0 REPO_ROOT SQL_SERVER DATABASE START_UTC OUTPUT_DIR LOG_DIR 1>&2 +exit /b 2 + +:fail +echo EXPORT_FAILED %~1 1>&2 +popd >nul 2>nul +exit /b 1 diff --git a/scripts/shadow-v3-export.sql b/scripts/shadow-v3-export.sql new file mode 100644 index 0000000..ec48586 --- /dev/null +++ b/scripts/shadow-v3-export.sql @@ -0,0 +1,190 @@ +:on error exit + +:out "$(OutputDir)\sql-diagnostics.txt" +SET NOCOUNT ON; +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT CAST(SERVERPROPERTY('ProductVersion') AS varchar(30)) AS product_version, + DB_NAME() AS database_name, + SYSUTCDATETIME() AS checked_at_utc, + CONVERT(datetime2(3), '$(StartUtc)', 127) AS export_start_utc, + '$(WarehouseId)' AS warehouse_id; + +SELECT package_id, package_version, installed_at +FROM alg.shadow_package_manifest; + +SELECT warehouse_id, snapshot_generation, snapshot_status, synchronized_at, + DATEDIFF(SECOND, synchronized_at, SYSUTCDATETIME()) AS snapshot_age_seconds, + CONVERT(varchar(64), source_signature, 2) AS source_signature, + source_row_count, instance_id, last_error +FROM alg.shadow_snapshot_state +WHERE warehouse_id = '$(WarehouseId)'; + +SELECT warehouse_id, instance_id, claimed_at, last_seen_at, + DATEDIFF(SECOND, last_seen_at, SYSUTCDATETIME()) AS heartbeat_age_seconds +FROM alg.shadow_instance_heartbeat +WHERE warehouse_id = '$(WarehouseId)'; + +SELECT 'cargo_space_snapshot' AS entity_name, state.snapshot_generation, + COUNT_BIG(snapshot.space_id) AS row_count +FROM alg.shadow_snapshot_state state +LEFT JOIN alg.cargo_space_snapshot snapshot + ON snapshot.warehouse_id = state.warehouse_id + AND snapshot.snapshot_generation = state.snapshot_generation +WHERE state.warehouse_id = '$(WarehouseId)' +GROUP BY state.snapshot_generation +UNION ALL +SELECT 'active_job_snapshot', state.snapshot_generation, COUNT_BIG(snapshot.job_id) +FROM alg.shadow_snapshot_state state +LEFT JOIN alg.active_job_snapshot snapshot + ON snapshot.warehouse_id = state.warehouse_id + AND snapshot.snapshot_generation = state.snapshot_generation +WHERE state.warehouse_id = '$(WarehouseId)' +GROUP BY state.snapshot_generation +UNION ALL +SELECT 'shadow_inbound_request_snapshot', state.snapshot_generation, COUNT_BIG(snapshot.task_no) +FROM alg.shadow_snapshot_state state +LEFT JOIN alg.shadow_inbound_request_snapshot snapshot + ON snapshot.warehouse_id = state.warehouse_id + AND snapshot.snapshot_generation = state.snapshot_generation +WHERE state.warehouse_id = '$(WarehouseId)' +GROUP BY state.snapshot_generation +UNION ALL +SELECT 'shadow_outbound_request_snapshot', state.snapshot_generation, COUNT_BIG(snapshot.stockout_detail_id) +FROM alg.shadow_snapshot_state state +LEFT JOIN alg.shadow_outbound_request_snapshot snapshot + ON snapshot.warehouse_id = state.warehouse_id + AND snapshot.snapshot_generation = state.snapshot_generation +WHERE state.warehouse_id = '$(WarehouseId)' +GROUP BY state.snapshot_generation; + +SELECT request.session_id, request.blocking_session_id, request.status, + request.wait_type, request.wait_time, request.command, + SUBSTRING(sql_text.text, + (request.statement_start_offset / 2) + 1, + ((CASE request.statement_end_offset + WHEN -1 THEN DATALENGTH(sql_text.text) + ELSE request.statement_end_offset + END - request.statement_start_offset) / 2) + 1) AS statement_text +FROM sys.dm_exec_requests request +OUTER APPLY sys.dm_exec_sql_text(request.sql_handle) sql_text +WHERE request.database_id = DB_ID() + AND (request.blocking_session_id <> 0 OR sql_text.text LIKE '%alg.%') +ORDER BY request.session_id; +GO + +:out "$(OutputDir)\comparison-detail.txt" +SET NOCOUNT ON; +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT decision_type, result_code, + CASE + WHEN result_code = 'MATCH' THEN 'VALID_MATCH' + WHEN result_code = 'DIFFERENT' THEN 'VALID_DIFFERENT' + WHEN result_code LIKE 'SKIP_%' THEN 'SKIP' + WHEN result_code LIKE 'ERROR_%' THEN 'ERROR' + ELSE 'OTHER' + END AS outcome_group, + COUNT_BIG(*) AS row_count, + MIN(created_at) AS first_created_at, + MAX(created_at) AS last_created_at +FROM alg.decision_log +WHERE warehouse_id = '$(WarehouseId)' + AND created_at >= CONVERT(datetime2(3), '$(StartUtc)', 127) +GROUP BY decision_type, result_code, + CASE + WHEN result_code = 'MATCH' THEN 'VALID_MATCH' + WHEN result_code = 'DIFFERENT' THEN 'VALID_DIFFERENT' + WHEN result_code LIKE 'SKIP_%' THEN 'SKIP' + WHEN result_code LIKE 'ERROR_%' THEN 'ERROR' + ELSE 'OTHER' + END +ORDER BY decision_type, result_code; + +SELECT decision_id, warehouse_id, decision_type, input_version, + result_code, reason, created_at +FROM alg.decision_log +WHERE warehouse_id = '$(WarehouseId)' + AND created_at >= CONVERT(datetime2(3), '$(StartUtc)', 127) +ORDER BY created_at, decision_id; + +SELECT SUM(CASE WHEN decision_type = 'INBOUND' THEN 1 ELSE 0 END) AS inbound_plan_count, + SUM(CASE WHEN decision_type = 'INBOUND_COMPARE' THEN 1 ELSE 0 END) AS inbound_compare_count, + SUM(CASE WHEN decision_type = 'OUTBOUND' THEN 1 ELSE 0 END) AS outbound_plan_count, + SUM(CASE WHEN decision_type = 'OUTBOUND_COMPARE' THEN 1 ELSE 0 END) AS outbound_compare_count +FROM alg.decision_log +WHERE warehouse_id = '$(WarehouseId)' + AND created_at >= CONVERT(datetime2(3), '$(StartUtc)', 127); +GO + +:out "$(OutputDir)\runtime-metrics.txt" +SET NOCOUNT ON; +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +SELECT warehouse_id, metric_name, bucket_upper_micros, metric_count, + metric_total_micros, metric_max_micros, flushed_at +FROM alg.shadow_runtime_metric +WHERE warehouse_id = '$(WarehouseId)' +ORDER BY metric_name, bucket_upper_micros; + +SELECT CASE + WHEN metric_name LIKE 'SKIP_%' THEN 'SKIP' + WHEN metric_name LIKE 'ERROR_%' THEN 'ERROR' + WHEN metric_name = 'ADMITTED' THEN 'ADMITTED' + ELSE 'HISTOGRAM' + END AS metric_group, + SUM(metric_count) AS metric_count, + MAX(flushed_at) AS latest_flush_at +FROM alg.shadow_runtime_metric +WHERE warehouse_id = '$(WarehouseId)' +GROUP BY CASE + WHEN metric_name LIKE 'SKIP_%' THEN 'SKIP' + WHEN metric_name LIKE 'ERROR_%' THEN 'ERROR' + WHEN metric_name = 'ADMITTED' THEN 'ADMITTED' + ELSE 'HISTOGRAM' + END +ORDER BY metric_group; +GO + +:out "$(OutputDir)\inventory-parity.txt" +SET NOCOUNT ON; +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +WITH legacy_stock AS ( + SELECT stock.stock_id, stock.cargo_space_id, stock.tray_code, + stock.spare4 AS project_no, stock.product_id AS product_no, + stock.lot_no, stock.quantity, stock.out_quantity + FROM dbo.bas_stock stock WITH (NOLOCK) + INNER JOIN dbo.bas_cargo_space space WITH (NOLOCK) + ON space.cargo_space_id = stock.cargo_space_id + WHERE TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(space.warehouse_id))) = + TRY_CONVERT(decimal(38,0), '$(WarehouseId)') +), shadow_stock AS ( + SELECT snapshot.stock_id, snapshot.space_id AS cargo_space_id, + snapshot.tray_code, snapshot.project_no, snapshot.product_no, + snapshot.lot_no, snapshot.quantity, snapshot.out_quantity + FROM alg.inventory_item_snapshot snapshot + INNER JOIN alg.shadow_snapshot_state state + ON state.warehouse_id = snapshot.warehouse_id + AND state.snapshot_generation = snapshot.snapshot_generation + AND state.snapshot_status = 'PUBLISHED' + WHERE snapshot.warehouse_id = '$(WarehouseId)' +) +SELECT SUM(CASE WHEN legacy.stock_id IS NULL THEN 1 ELSE 0 END) AS shadow_only_count, + SUM(CASE WHEN shadow.stock_id IS NULL THEN 1 ELSE 0 END) AS legacy_only_count, + SUM(CASE WHEN legacy.stock_id IS NOT NULL AND shadow.stock_id IS NOT NULL + AND (ISNULL(legacy.cargo_space_id, '') <> ISNULL(shadow.cargo_space_id, '') + OR ISNULL(legacy.tray_code, '') <> ISNULL(shadow.tray_code, '') + OR ISNULL(legacy.project_no, '') <> ISNULL(shadow.project_no, '') + OR ISNULL(legacy.product_no, '') <> ISNULL(shadow.product_no, '') + OR ISNULL(legacy.lot_no, '') <> ISNULL(shadow.lot_no, '') + OR ISNULL(legacy.quantity, 0) <> ISNULL(shadow.quantity, 0) + OR ISNULL(legacy.out_quantity, 0) <> ISNULL(shadow.out_quantity, 0)) + THEN 1 ELSE 0 END) AS differing_row_count, + COUNT_BIG(*) AS compared_stock_count +FROM legacy_stock legacy +FULL OUTER JOIN shadow_stock shadow ON shadow.stock_id = legacy.stock_id; +GO + +:out stdout +SELECT 'EXPORT_SQL_OK' AS export_status, SYSUTCDATETIME() AS completed_at_utc; diff --git a/scripts/shadow-v3-install.cmd b/scripts/shadow-v3-install.cmd new file mode 100644 index 0000000..17a5279 --- /dev/null +++ b/scripts/shadow-v3-install.cmd @@ -0,0 +1,34 @@ +@echo off +setlocal EnableExtensions DisableDelayedExpansion + +if "%~1"=="" goto :usage +if "%~2"=="" goto :usage +if "%~3"=="" goto :usage + +set "SQL_SERVER=%~1" +set "DATABASE=%~2" +set "MODE=%~3" +set "SQL_FILE=" + +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 not defined SQL_FILE (call :fail "mode must be upgrade or clean" & exit /b 1) +if not exist "%SQL_FILE%" (call :fail "SQL package file is missing" & exit /b 1) + +where sqlcmd >nul 2>nul +if errorlevel 1 (call :fail "sqlcmd is not available on PATH" & exit /b 1) + +echo Installing FT546WMS shadow v3 in %MODE% mode... +sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%SQL_FILE%" +if errorlevel 1 exit /b 1 + +echo INSTALL_OK mode=%MODE% +exit /b 0 + +:usage +echo Usage: %~nx0 SQL_SERVER DATABASE upgrade^|clean 1>&2 +exit /b 2 + +:fail +echo INSTALL_FAILED %~1 1>&2 +exit /b 1 diff --git a/scripts/shadow-v3-preflight.cmd b/scripts/shadow-v3-preflight.cmd new file mode 100644 index 0000000..c996d9c --- /dev/null +++ b/scripts/shadow-v3-preflight.cmd @@ -0,0 +1,64 @@ +@echo off +setlocal EnableExtensions DisableDelayedExpansion + +if "%~1"=="" goto :usage +if "%~2"=="" goto :usage +if "%~3"=="" goto :usage +if "%~4"=="" goto :usage + +set "REPO_ROOT=%~f1" +set "EXPECTED_COMMIT=%~2" +set "SQL_SERVER=%~3" +set "DATABASE=%~4" +set "EXPECTED_BRANCH=test/ft546wms-shadow-v3-20260903" +set "MAIN_COMMIT=916ac6bda32a5b102a3181cc8c5dbd5319c5656d" + +where git >nul 2>nul +if errorlevel 1 (call :fail "git is not available on PATH" & exit /b 1) +where jps >nul 2>nul +if errorlevel 1 (call :fail "jps is not available on PATH" & exit /b 1) +where sqlcmd >nul 2>nul +if errorlevel 1 (call :fail "sqlcmd is not available on PATH" & exit /b 1) +if not exist "%REPO_ROOT%\.git" (call :fail "REPO_ROOT is not a Git repository" & exit /b 1) + +pushd "%REPO_ROOT%" >nul +if errorlevel 1 (call :fail "cannot enter REPO_ROOT" & exit /b 1) + +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) + +set "CURRENT_BRANCH=" +for /f "usebackq delims=" %%I in (`git branch --show-current`) do set "CURRENT_BRANCH=%%I" +if /I not "%CURRENT_BRANCH%"=="%EXPECTED_BRANCH%" (call :fail "unexpected branch" & exit /b 1) + +set "CURRENT_COMMIT=" +for /f "usebackq delims=" %%I in (`git rev-parse HEAD`) do set "CURRENT_COMMIT=%%I" +if /I not "%CURRENT_COMMIT%"=="%EXPECTED_COMMIT%" (call :fail "HEAD does not match EXPECTED_COMMIT" & exit /b 1) + +set "DIRTY=" +for /f "usebackq delims=" %%I in (`git status --porcelain`) do set "DIRTY=1" +if defined DIRTY (call :fail "Git worktree is not clean" & exit /b 1) + +git merge-base --is-ancestor %MAIN_COMMIT% HEAD +if errorlevel 1 (call :fail "required main commit is not an ancestor" & exit /b 1) +popd >nul + +set "WMS_PROCESS_COUNT=0" +for /f %%I in ('jps -lv 2^>nul ^| findstr /I /C:"com.ruoyi.RuoYiApplication" /C:"FT546WMS" ^| find /C /V ""') do set "WMS_PROCESS_COUNT=%%I" +if %WMS_PROCESS_COUNT% GTR 1 (call :fail "more than one FT546WMS JVM is running" & exit /b 1) + +set "PREFLIGHT_SQL=SET NOCOUNT ON; IF SCHEMA_ID(N'alg') IS NULL SELECT N'CLEAN_INSTALL_ELIGIBLE' AS package_state; ELSE BEGIN IF OBJECT_ID(N'alg.shadow_package_manifest', N'U') IS NULL BEGIN; THROW 51050, 'shadow manifest is missing', 1; END; IF 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; 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 FROM alg.shadow_snapshot_state; END" +sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -Q "%PREFLIGHT_SQL%" +if errorlevel 1 (call :fail "SQL connectivity or shadow package validation failed" & exit /b 1) + +echo PREFLIGHT_OK branch=%CURRENT_BRANCH% commit=%CURRENT_COMMIT% wmsProcesses=%WMS_PROCESS_COUNT% +exit /b 0 + +:usage +echo Usage: %~nx0 REPO_ROOT EXPECTED_COMMIT SQL_SERVER DATABASE 1>&2 +exit /b 2 + +:fail +echo PREFLIGHT_FAILED %~1 1>&2 +popd >nul 2>nul +exit /b 1 diff --git a/scripts/shadow-v3-uninstall.cmd b/scripts/shadow-v3-uninstall.cmd new file mode 100644 index 0000000..a68297b --- /dev/null +++ b/scripts/shadow-v3-uninstall.cmd @@ -0,0 +1,35 @@ +@echo off +setlocal EnableExtensions DisableDelayedExpansion + +if "%~1"=="" goto :usage +if "%~2"=="" goto :usage +if "%~3"=="" goto :usage +if not "%~3"=="CONFIRM_UNINSTALL" goto :refuse + +set "SQL_SERVER=%~1" +set "DATABASE=%~2" +set "SQL_FILE=%~dp0..\fortucky-algorithm\src\main\resources\db\sqlserver\uninstall-shadow.sql" + +if not exist "%SQL_FILE%" (call :fail "uninstall SQL file is missing" & exit /b 1) +where sqlcmd >nul 2>nul +if errorlevel 1 (call :fail "sqlcmd is not available on PATH" & exit /b 1) + +echo WARNING: export all alg.* evidence before uninstalling. +echo The shadow application must be stopped and the stable service restored first. +sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%SQL_FILE%" +if errorlevel 1 exit /b 1 + +echo UNINSTALL_OK +exit /b 0 + +:usage +echo Usage: %~nx0 SQL_SERVER DATABASE CONFIRM_UNINSTALL 1>&2 +exit /b 2 + +:refuse +echo UNINSTALL_REFUSED third argument must be CONFIRM_UNINSTALL 1>&2 +exit /b 3 + +:fail +echo UNINSTALL_FAILED %~1 1>&2 +exit /b 1