10 Commits

24 changed files with 2006 additions and 2491 deletions

View File

@@ -0,0 +1,302 @@
# FT546WMS 现场影子测试操作手册
## 1. 适用范围
本手册用于分支 `test/ft546wms-shadow-from-main-20260811` 的现场测试。该分支基于远端稳定版本:
```text
origin/main = 916ac6bda32a5b102a3181cc8c5dbd5319c5656d
```
影子算法只提出候选结果并记录比较数据。原有 Legacy 存储过程仍执行一次且仅执行一次,Legacy 的库位分配、返回码和响应内容始终是最终结果。
影子模式仍会读取 Legacy 表,并会在 `alg` schema 中刷新读模型和写入决策日志。因此“只观察”表示不改变 Legacy 业务数据和决策权,不表示完全没有数据库 CPU、I/O 或执行时间开销。
## 2. 安全边界
- 默认配置 `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 模式也禁止进入这些写入口。
## 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
```
只使用名称带 `-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
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 不是干净状态" }
```
两个 IDEA 窗口分别打开:
```text
稳定版:D:\workSpace\PROJECT_SPACE\FT546\FT546MWS\worktrees\stable-origin-main-20260811
影子版:D:\workSpace\PROJECT_SPACE\FT546\FT546MWS\worktrees\shadow-from-main-20260811
```
为两个窗口分别创建 Run Configuration:
- `FT546WMS-STABLE-origin-main`:完全沿用当前稳定现场参数,不增加任何 `FORTUCKY_*` 环境变量。
- `FT546WMS-SHADOW`:复制稳定配置的 JDK、profile、端口、数据库和 Redis 参数,只增加下一节列出的环境变量。
禁止同时运行两个实例。即使使用不同 HTTP 端口,两个 WMS 实例也可能重复执行定时任务、消费任务或发送外部回调。切换时必须先停止当前实例,确认进程退出和端口释放,再启动另一个实例。
## 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;
```
预期 `alg_schema_id` 为 `NULL`。安装脚本发现任何既有 `alg` schema 会以错误 51000 拒绝执行。如果现场已经存在 `alg` schema 或同名对象,停止安装并先确认所有权;不要覆盖,也不要执行本分支卸载脚本。
把两份查询结果连同执行时间保存为安装前基线。现场可能已有 Legacy 自身的触发器,安装后的全库 DML 触发器集合必须与安装前完全一致;用户对象清单只能新增本包定义的 `alg.*` 对象。
### 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 "<SQL Server>" -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 中设置:
```text
FORTUCKY_SHADOW_ENABLED=true
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
```
将 `01` 替换为现场实际仓库号。保持 `FORTUCKY_SHADOW_WAREHOUSE_ID` 未设置,使影子入口统一回退到 `FORTUCKY_ALGORITHM_WAREHOUSE_ID`。不要设置 replay、tidy、ACTIVE 或 WCS callback 相关变量。
`FORTUCKY_ALGORITHM_DATABASE_WRITES_ENABLED=true` 是影子读模型与 `alg.decision_log` 所必需的应用开关;当前影子代码路径只执行已安装的 `alg.*` 写入,它不把 Legacy 决策权交给算法。该开关不是数据库权限隔离,安全性还依赖本分支的 SHADOW 启动保护、删减后的 SQL 包和现场变更审计。
切换步骤:
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
```
出现单条告警不等于 Legacy 失败,但应记录时间和业务单号。测试前由现场负责人和 DBA 根据稳定时段基线写下响应延迟、SQL CPU/I/O、锁等待、连接数和 Shadow 告警频率阈值;未约定阈值时,只允许少量受控业务,不进入连续观察。满足任一条件立即停止影子测试并执行第 10 节回滚:
- Legacy 入库或出库结果、返回码、消息或设备动作与稳定版不同;
- 正常接口达到预先约定的延迟阈值,或同类 3 秒超时告警重复出现;
- SQL Server CPU、I/O、锁等待或连接数出现不可接受增长;
- `alg` 之外出现任何由本次测试新增的表、过程、触发器或数据修改;
- 同一 Legacy 存储过程疑似被重复调用;
- WMS、WCS、PDA 或现场操作出现异常。
实际进入影子规划的请求每次都会按仓库全量刷新读模型。应用在单个 JVM 内将观察周期上限限制为 1、队列容量为 0,其他并发观察立即跳过;这能限制重叠刷新,但 `READ_UNCOMMITTED` 和单并发都无法消除全表扫描带来的 CPU 和 I/O,所以必须从少量受控业务开始,不能直接长时间全量开放。若部署多个影子 JVM,该上限分别生效,现场仍只允许启动一个 WMS 实例。
## 9. 数据采集与交付
测试开始和结束时各执行一次:
```text
fortucky-algorithm/src/main/resources/db/sqlserver/diagnostics-shadow.sql
```
在 SSMS 中选择“结果输出到文件”,保留完整结果。该脚本只读,包含对象清单、读模型统计、Legacy 表行数/校验和、当前锁和决策日志。Legacy 表校验和会扫描数据,只在低业务量时执行,不要高频轮询。
测试结束、卸载之前单独导出原始决策记录:
```sql
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
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;
```
交付分析的数据包至少包括:
- 决策日志导出文件,保持原始列名和时间字段;
- `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 触发器、每次规划前全量刷新”的代价,也是现场必须先小流量验证、随时可切回稳定源码的原因。

View File

@@ -4,7 +4,7 @@
**Goal:** Build a clean `origin/main`-based branch that observes inbound and outbound allocation with the Java algorithm while preserving every Legacy decision and business write.
**Architecture:** Reuse the existing `fortucky-algorithm` planner as an internal module, but expose it to WMS only through a fail-open shadow gateway. The gateway plans in a separate timed transaction; the original Legacy procedures remain unchanged and authoritative. A reduced SQL package installs only read-model and diagnostic objects.
**Architecture:** Reuse the existing `fortucky-algorithm` planner as an internal module, but expose it to WMS only through a fail-open shadow gateway. The gateway plans in a separate timed `READ_UNCOMMITTED` transaction; the original Legacy procedures remain unchanged and authoritative. A reduced SQL package installs only read-model and diagnostic objects, creates no Legacy-table triggers, and refreshes its read model in full before planning.
**Tech Stack:** Java 17, Spring Boot, Spring JDBC transactions, MyBatis, SQL Server 2016, JUnit 5, Mockito, AssertJ, Maven 3.9.11.
@@ -14,7 +14,11 @@
- No license, TLS, Security, frontend, tidy, replay, PDA, WCS callback, reservation, or ACTIVE workflow integration changes.
- Legacy procedures execute exactly once and their current response construction remains authoritative.
- Shadow observation is disabled by default and fails open on every runtime error.
- Legacy request threads never execute, queue behind, or wait for shadow database work.
- A single daemon worker with zero queue capacity admits at most one shadow observation cycle per JVM; busy submissions are dropped.
- Installation refuses an existing `alg` schema; uninstall requires the exact package manifest and object allow-list.
- Shadow database scripts never insert, update, or delete WMS business rows.
- Shadow database scripts never create triggers on WMS business tables.
---
@@ -113,10 +117,10 @@
- Test: `fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/ShadowSqlServerMigrationScriptTest.java`
**Interfaces:**
- Produces: read-model tables/views/procedures, decision log, dirty-lane queue, and three enqueue-only business-table triggers.
- Produces: read-model tables/views/procedures and decision log; planning performs a full warehouse-scoped read-model refresh.
- Excludes: ACTIVE mutation procedures and WMS business-table DML.
- [ ] Write static SQL tests with an allow-list for tables, views, procedures, and triggers; reject tidy/workflow/replay/reservation mutation names and business-table DML.
- [ ] Write static SQL tests with an allow-list for tables, views, and procedures; reject all triggers, dirty queues, tidy/workflow/replay/reservation mutation names, and business-table DML.
- [ ] Add an install/uninstall symmetry test and a test requiring initial synchronization plus diagnostic queries.
- [ ] Run focused tests and confirm RED because scripts are absent.
- [ ] Build the reduced scripts from the reviewed SQL Server 2016-compatible read-model portions.
@@ -138,7 +142,51 @@
- [ ] Document backup-copy rehearsal, IDEA dual-workspace deployment, startup checks, data export, fast application rollback, and full SQL uninstall rollback.
- [ ] Commit as `docs: add IDEA shadow deployment and rollback guide`.
### Task 7: Review, verify, and publish
### Task 7: Remove shadow latency from Legacy threads
**Files:**
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/InboundShadowCompletion.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/OutboundShadowCompletion.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationGateway.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/service/impl/ToWmsServiceImpl.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/out/outStockout/service/impl/OutStockoutServiceImpl.java`
- Test: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowObservationGatewayTest.java`
- Test: `ruoyi-admin/src/test/java/com/ruoyi/api/service/impl/ToWmsServiceImplShadowTest.java`
- Test: `ruoyi-admin/src/test/java/com/ruoyi/out/outStockout/service/impl/OutStockoutServiceImplShadowTest.java`
**Interfaces:**
- Produces: `observeInbound`, `observeProjectOutbound`, and `observeExactStockOutbound`, each returning a nonblocking completion token.
- Guarantees: one daemon worker, `SynchronousQueue`, `AbortPolicy`, no caller-runs fallback, zero queued observations, and background-only comparison.
- [ ] Add gateway tests that block the planning engine and prove submission returns before planning completes, a concurrent submission is dropped, and Legacy completion is accepted without waiting.
- [ ] Update service tests to require order: observation submission, Legacy procedure exactly once, Legacy reload/result construction, completion-token notification.
- [ ] Run focused tests and confirm RED against the synchronous gateway.
- [ ] Implement completion tokens and the dedicated executor; catch rejection, timeout, interruption, and runtime failures inside the gateway.
- [ ] Replace synchronous plan/compare calls in both Legacy services with submit/complete calls only.
- [ ] Run focused tests and confirm GREEN.
- [ ] Commit as `fix: keep shadow work off legacy threads`.
### Task 8: Protect SQL package ownership
**Files:**
- Modify: `fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql`
- Modify: `fortucky-algorithm/src/main/resources/db/sqlserver/uninstall-shadow.sql`
- Modify: `fortucky-algorithm/src/main/resources/db/sqlserver/diagnostics-shadow.sql`
- Modify: `fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/ShadowSqlServerMigrationScriptTest.java`
- Modify: `doc/algorithm-shadow/README.md`
**Interfaces:**
- Produces: manifest id `FT546WMS_SHADOW_ONLY` and version `2026-08-12-1`.
- Guarantees: install refuses any existing `alg` schema; uninstall refuses missing/wrong manifest or any unknown top-level `alg` object.
- [ ] Add static tests requiring the exact manifest, fail-fast existing-schema guard, uninstall manifest validation, and unknown-object rejection.
- [ ] Run the SQL migration test and confirm RED.
- [ ] Add `alg.shadow_package_manifest`, fail-fast install guard, and refusal-first uninstall validation before any `DROP`.
- [ ] Add manifest output to diagnostics and exact recovery instructions to the field guide.
- [ ] Run the SQL migration test and confirm GREEN.
- [ ] Commit as `fix: verify shadow SQL package ownership`.
### Task 9: Review, verify, and publish
**Files:**
- Review all changes relative to `origin/main`.

View File

@@ -8,11 +8,12 @@ Create a clean field-test branch directly from `origin/main` that runs the Java
1. The branch is based on `origin/main` and contains no license, UI, TLS, tidy, replay, PDA, WCS callback, or ACTIVE workflow integration changes.
2. Legacy stored procedures are called exactly once with the same inputs and their return values remain authoritative.
3. Shadow planning runs before the Legacy allocation so it observes the pre-decision state, but it runs in a separate `REQUIRES_NEW` transaction with a configurable timeout.
3. Shadow planning is submitted immediately before Legacy allocation to maximize pre-decision observation, but runs only on a dedicated background worker in a separate `REQUIRES_NEW`, `READ_UNCOMMITTED` transaction with a configurable timeout. The Legacy thread never waits for planning or comparison.
4. A shadow exception, timeout, missing optional input, missing algorithm object, or comparison logging failure is caught and logged; it cannot skip, replace, roll back, or rewrite the Legacy result.
5. No ACTIVE entry point is exposed by `ruoyi-admin`. Startup rejects any algorithm mode other than `SHADOW` when shadow observation is enabled.
6. Shadow mode writes only `alg.*` read-model and diagnostic objects. It never calls reservation, tidy, deep-workflow, replay, move-completion, or other procedures that write WMS business tables.
7. Shadow observation is disabled by default and must be enabled explicitly in the IDEA Run Configuration.
8. At most one shadow observation cycle runs per JVM. The dedicated executor has one daemon worker, zero queue capacity, and rejects busy submissions immediately without running work on the Legacy thread.
## Architecture
@@ -20,8 +21,8 @@ The existing `fortucky-algorithm` planner and JDBC read model are reused as an i
- `ShadowObservationProperties` controls enablement, warehouse fallback, and timeout.
- `ShadowSafetyGuard` validates the effective mode at startup.
- `ShadowPlanningObserver` performs fail-open inbound/outbound planning in an independent transaction and returns an immutable observation token.
- Legacy services invoke the observer immediately before their existing stored procedure, execute the original Legacy code unchanged, then submit the Legacy result for comparison logging.
- `ShadowObservationGateway` submits fail-open inbound/outbound planning to a dedicated single-thread, zero-queue executor and returns a nonblocking completion token.
- Legacy services submit the observation immediately before their existing stored procedure, execute the original Legacy code unchanged, then complete the in-memory token with the Legacy result. The background worker performs comparison only if both results arrive within the configured observation deadline.
There are no algorithm controllers, ACTIVE schedulers, workflow callbacks, reservation calls, or changes to the frontend.
@@ -30,22 +31,24 @@ There are no algorithm controllers, ACTIVE schedulers, workflow callbacks, reser
### Inbound
1. Load the same inbound job that `origin/main` already loads.
2. Ask the shadow observer to compute a proposed location. Failures become a `shadow planning failed` log entry.
2. Submit shadow planning to the background worker without waiting. Busy/rejected submissions become no-op completion tokens.
3. Invoke the existing `allotInJobSpace` stored procedure exactly once.
4. Reload and return the Legacy job exactly as `origin/main` does.
5. Log the proposed shadow location beside the Legacy location.
5. Complete the in-memory token with the Legacy location; the background worker logs the comparison if planning and Legacy results both complete before the observation deadline.
### Outbound
1. Before each existing automatic or manual Legacy allocation call, ask the observer for a plan.
1. Before each existing automatic or manual Legacy allocation call, submit a background observation without waiting.
2. Missing project/stock data is recorded as a shadow rejection; it never blocks the Legacy procedure.
3. Invoke the original `autoAllot` or `manualAllot` procedure exactly once.
4. Preserve the original success/warning response construction.
5. Log the shadow status/code beside the Legacy procedure code.
5. Complete the in-memory token with the Legacy procedure code; comparison logging remains background-only.
## Transaction and failure isolation
Shadow planning uses `TransactionTemplate` with `PROPAGATION_REQUIRES_NEW`, read-model writes enabled, and a short timeout. The transaction completes or rolls back before Legacy allocation starts, so algorithm locks and rollback state cannot leak into the Legacy transaction. The adapter catches runtime failures at its public boundary and returns an empty observation.
Shadow planning uses `TransactionTemplate` with `PROPAGATION_REQUIRES_NEW`, `ISOLATION_READ_UNCOMMITTED`, read-model writes enabled, and a short timeout. A dedicated `ThreadPoolExecutor` has one daemon worker, a `SynchronousQueue`, and `AbortPolicy`: no task queues, rejection never falls back to the Legacy caller, and at most one observation cycle is active per JVM. The submitted task plans, waits only in the background for the Legacy completion token, and writes the comparison. Runtime failures, rejection, interruption, and observation-deadline expiry are caught in the gateway.
Because the Legacy call starts immediately after submission, thread scheduling and database timing can let a shadow read overlap or follow the Legacy write. This is an accepted data-quality limitation of zero-wait shadowing. Shadow results are diagnostic only and must not be interpreted as a guaranteed pre-Legacy snapshot.
The integration never queries `alg.tidy_mode` and never uses an update lock for admission control.
@@ -55,13 +58,15 @@ The integration never queries `alg.tidy_mode` and never uses an update lock for
- policy/read-model/diagnostic tables required by inbound and outbound planning;
- read-only views over WMS business data;
- topology, snapshot, lane-state, dirty-lane refresh, and decision-log support;
- three narrowly scoped `dbo` triggers that only enqueue affected lanes into `alg.dirty_lane_queue`;
- topology, snapshot, lane-state, full-refresh, and decision-log support;
- no triggers on any Legacy WMS table and no dirty-lane queue;
- initial read-model synchronization.
It excludes tidy, replay, reservation mutation procedures, deep workflow, outbound workflow, WCS completion, PDA callback, and any stored procedure that inserts, updates, or deletes WMS business rows.
`uninstall-shadow.sql` first removes the three `dbo` triggers, then removes only shadow-owned `alg` objects and the `alg` schema. It never modifies WMS business rows. Operators must export `alg.decision_log` before uninstalling.
Installation refuses to run if the `alg` schema already exists. It creates an `alg.shadow_package_manifest` row with the exact package id and version before creating the remaining objects.
`uninstall-shadow.sql` refuses to run unless the manifest id/version matches and every top-level object in `alg` is on the package allow-list. Only then does it remove the shadow-owned objects and schema. It never modifies WMS business rows. Operators must export `alg.decision_log` before uninstalling.
## Configuration
@@ -85,7 +90,7 @@ Automated tests prove:
- missing project/stock input still invokes Legacy exactly once;
- Legacy result and response structure remain authoritative;
- SHADOW configuration rejects ACTIVE mode;
- shadow SQL contains only allow-listed objects and no WMS business-table DML;
- shadow SQL contains only allow-listed `alg` objects, no triggers, and no WMS business-table DML;
- install/uninstall object lists are symmetrical;
- existing project tests and packaging still succeed.

View File

@@ -0,0 +1,80 @@
/* Read-only field diagnostics and export queries for the FT546 shadow package. */
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;
SELECT package_id, package_version, installed_at
FROM alg.shadow_package_manifest;
SELECT schema_name(object.schema_id) AS schema_name,
object.name AS object_name,
object.type_desc
FROM sys.objects object
WHERE schema_name(object.schema_id) = N'alg'
ORDER BY schema_name(object.schema_id), object.type_desc, object.name;
SELECT warehouse_id,
COUNT(*) AS lane_state_count,
SUM(CASE WHEN pending_outbound = 1 THEN 1 ELSE 0 END) AS pending_lane_count,
SUM(CASE WHEN has_gap = 1 THEN 1 ELSE 0 END) AS gap_lane_count,
MAX(rebuilt_at) AS latest_rebuild_at
FROM alg.lane_state
GROUP BY warehouse_id
ORDER BY warehouse_id;
SELECT warehouse_id,
COUNT(*) AS inventory_item_count,
COUNT(DISTINCT space_id) AS inventory_space_count,
MAX(synchronized_at) AS latest_inventory_sync_at
FROM alg.inventory_item_snapshot
GROUP BY warehouse_id
ORDER BY warehouse_id;
SELECT warehouse_id,
COUNT(*) AS space_state_count,
SUM(CASE WHEN inbound_available = 1 THEN 1 ELSE 0 END) AS inbound_available_count,
SUM(CASE WHEN pending_outbound = 1 THEN 1 ELSE 0 END) AS pending_outbound_count,
SUM(CASE WHEN tray_conflict = 1 OR project_conflict = 1 OR product_conflict = 1
THEN 1 ELSE 0 END) AS conflict_count
FROM alg.v_space_state
GROUP BY warehouse_id
ORDER BY warehouse_id;
/* Legacy checksums should remain stable apart from normal live traffic. */
SELECT 'bas_cargo_space' AS table_name,
COUNT_BIG(*) AS row_count,
CHECKSUM_AGG(BINARY_CHECKSUM(cargo_space_id, warehouse_id, group_coding_no,
group_coding, status, tray_code)) AS row_checksum
FROM dbo.bas_cargo_space
UNION ALL
SELECT 'bas_stock', COUNT_BIG(*),
CHECKSUM_AGG(BINARY_CHECKSUM(stock_id, cargo_space_id, tray_code, spare4,
product_id, lot_no, in_date))
FROM dbo.bas_stock
UNION ALL
SELECT 'mydo_job', COUNT_BIG(*),
CHECKSUM_AGG(BINARY_CHECKSUM(job_id, status, task_no, tray_code,
cargo_space_id, target_cargo_space_id,
in_or_out, warehouse_id))
FROM dbo.mydo_job;
/* Locks held on the three observed Legacy tables. */
SELECT request_session_id, resource_type, request_mode, request_status,
object_name(partition.object_id, lock.resource_database_id) AS object_name
FROM sys.dm_tran_locks lock
LEFT JOIN sys.partitions partition
ON partition.hobt_id = lock.resource_associated_entity_id
WHERE lock.resource_database_id = DB_ID()
AND object_name(partition.object_id, lock.resource_database_id)
IN (N'bas_cargo_space', N'bas_stock', N'mydo_job')
ORDER BY request_session_id, object_name, request_mode;
/* Export this result before uninstalling. */
SELECT decision_id, warehouse_id, decision_type, input_version,
result_code, reason, created_at
FROM alg.decision_log
WHERE decision_type IN ('INBOUND', 'OUTBOUND', 'INBOUND_COMPARE', 'OUTBOUND_COMPARE')
ORDER BY created_at, decision_id;

View File

@@ -1,224 +0,0 @@
/* Read-only validation queries for a SQL Server 2016 installation. */
SET NOCOUNT ON;
SELECT CAST(SERVERPROPERTY('ProductVersion') AS varchar(30)) AS product_version,
DB_NAME() AS database_name;
SELECT o.type_desc, COUNT(*) AS object_count
FROM sys.objects o
JOIN sys.schemas s ON s.schema_id = o.schema_id
WHERE s.name = N'alg' AND o.type IN ('U', 'V', 'P')
GROUP BY o.type_desc
ORDER BY o.type_desc;
SELECT installed.object_kind, installed.object_name
FROM (
SELECT o.type_desc AS object_kind, o.name AS object_name
FROM sys.objects o
JOIN sys.schemas s ON s.schema_id = o.schema_id
WHERE s.name = N'alg' AND o.type IN ('U', 'V', 'P')
UNION ALL
SELECT 'INDEX' AS object_kind, i.name AS object_name
FROM sys.indexes i
JOIN sys.objects o ON o.object_id = i.object_id
JOIN sys.schemas s ON s.schema_id = o.schema_id
WHERE s.name = N'alg' AND i.name IS NOT NULL
) installed
ORDER BY installed.object_kind, installed.object_name;
SELECT warehouse_id,
COUNT_BIG(*) AS excluded_from_algorithm_topology_count,
SUM(CASE WHEN group_coding_no IS NULL OR LTRIM(RTRIM(group_coding_no)) = ''
THEN 1 ELSE 0 END) AS missing_lane_code_count,
SUM(CASE WHEN TRY_CONVERT(int, group_coding) IS NULL
THEN 1 ELSE 0 END) AS invalid_depth_count
FROM dbo.bas_cargo_space
WHERE group_coding_no IS NULL OR LTRIM(RTRIM(group_coding_no)) = ''
OR TRY_CONVERT(int, group_coding) IS NULL
GROUP BY warehouse_id
ORDER BY warehouse_id;
SELECT warehouse_id,
COUNT(*) AS space_state_count,
SUM(CASE WHEN space_status = 2 THEN 1 ELSE 0 END) AS full_space_count,
SUM(CASE WHEN inbound_available = 1 THEN 1 ELSE 0 END) AS inbound_available_count,
SUM(CASE WHEN pending_outbound = 1 THEN 1 ELSE 0 END) AS pending_outbound_space_count,
SUM(CASE WHEN has_gap = 1 THEN 1 ELSE 0 END) AS gap_space_count,
SUM(CASE WHEN tray_conflict = 1 OR project_conflict = 1 OR product_conflict = 1 THEN 1 ELSE 0 END) AS conflict_space_count
FROM alg.v_space_state
GROUP BY warehouse_id
ORDER BY warehouse_id;
SELECT warehouse_id, COUNT(*) AS topology_count
FROM alg.space_topology
GROUP BY warehouse_id
ORDER BY warehouse_id;
SELECT warehouse_id,
COUNT(*) AS inventory_detail_snapshot_count,
COUNT(DISTINCT space_id) AS inventory_space_snapshot_count,
MAX(synchronized_at) AS last_inventory_synchronized_at
FROM alg.inventory_item_snapshot
GROUP BY warehouse_id
ORDER BY warehouse_id;
;WITH warehouse_floors AS (
SELECT DISTINCT warehouse_id, the_floor AS floor_no
FROM dbo.bas_cargo_space
), role_counts AS (
SELECT cargo.warehouse_id, cargo.the_floor AS floor_no,
SUM(CASE WHEN role.role = 'RELOCATION_BUFFER' THEN 1 ELSE 0 END) AS relocation_buffer_count,
SUM(CASE WHEN role.role = 'OUTBOUND_BUFFER' THEN 1 ELSE 0 END) AS outbound_buffer_count,
SUM(CASE WHEN role.role = 'PROTECTED_CORE' THEN 1 ELSE 0 END) AS protected_core_count
FROM dbo.bas_cargo_space cargo
LEFT JOIN alg.space_role role ON role.space_id = cargo.cargo_space_id
GROUP BY cargo.warehouse_id, cargo.the_floor
)
SELECT floors.warehouse_id, floors.floor_no,
ISNULL(counts.relocation_buffer_count, 0) AS relocation_buffer_count,
CAST(4 AS int) AS relocation_buffer_target,
CASE WHEN ISNULL(counts.relocation_buffer_count, 0) >= 4 THEN 0
ELSE 4 - ISNULL(counts.relocation_buffer_count, 0) END AS relocation_buffer_shortage,
ISNULL(counts.outbound_buffer_count, 0) AS outbound_buffer_count,
ISNULL(counts.protected_core_count, 0) AS protected_core_count
FROM warehouse_floors floors
LEFT JOIN role_counts counts ON counts.warehouse_id = floors.warehouse_id
AND counts.floor_no = floors.floor_no
ORDER BY floors.warehouse_id, floors.floor_no;
SELECT cargo.warehouse_id, cargo.the_floor AS floor_no, role.space_id, role.role, role.updated_at
FROM alg.space_role role
JOIN dbo.bas_cargo_space cargo ON cargo.cargo_space_id = role.space_id
WHERE role.role IN ('RELOCATION_BUFFER', 'OUTBOUND_BUFFER', 'PROTECTED_CORE')
ORDER BY cargo.warehouse_id, cargo.the_floor, role.role, role.space_id;
SELECT warehouse_id, COUNT(*) AS lane_state_count,
SUM(CASE WHEN pending_outbound = 1 THEN 1 ELSE 0 END) AS pending_lane_count,
SUM(CASE WHEN has_gap = 1 THEN 1 ELSE 0 END) AS gap_lane_count
FROM alg.lane_state
GROUP BY warehouse_id
ORDER BY warehouse_id;
SELECT 'bas_cargo_space' AS table_name, COUNT_BIG(*) AS row_count,
CHECKSUM_AGG(BINARY_CHECKSUM(cargo_space_id, warehouse_id, group_coding_no, group_coding, status, tray_code)) AS row_checksum
FROM dbo.bas_cargo_space
UNION ALL
SELECT 'bas_stock', COUNT_BIG(*),
CHECKSUM_AGG(BINARY_CHECKSUM(stock_id, cargo_space_id, tray_code, spare4, product_id, lot_no, in_date))
FROM dbo.bas_stock
UNION ALL
SELECT 'mydo_job', COUNT_BIG(*),
CHECKSUM_AGG(BINARY_CHECKSUM(job_id, status, task_no, tray_code, cargo_space_id, target_cargo_space_id, in_or_out, warehouse_id))
FROM dbo.mydo_job;
;WITH pallet_details AS (
SELECT cargo_space_id,
MIN(NULLIF(LTRIM(RTRIM(tray_code)), '')) AS tray_code,
MIN(NULLIF(LTRIM(RTRIM(spare4)), '')) AS project_no,
MIN(NULLIF(LTRIM(RTRIM(product_id)), '')) AS first_product_no,
MAX(NULLIF(LTRIM(RTRIM(product_id)), '')) AS last_product_no,
COUNT(DISTINCT NULLIF(LTRIM(RTRIM(product_id)), '')) AS product_count
FROM dbo.bas_stock
GROUP BY cargo_space_id
)
SELECT TOP 5 state.space_id, details.tray_code, details.project_no,
details.first_product_no, details.last_product_no, details.product_count
FROM pallet_details details
JOIN alg.v_space_state state ON state.space_id = details.cargo_space_id
WHERE details.product_count > 1
AND state.space_status = '2'
AND state.stock_tray_count = 1
AND state.stock_project_count = 1
AND state.tray_conflict = 0
AND state.project_conflict = 0
AND state.pending_outbound = 0
AND state.reserved = 0
AND state.has_gap = 0
ORDER BY state.space_id;
SELECT space_id, warehouse_id, lane_code, depth, space_status, role,
tray_conflict, project_conflict, product_conflict, pending_outbound, reserved, has_gap,
inbound_available
FROM alg.v_space_state
WHERE tray_conflict = 1 OR project_conflict = 1 OR product_conflict = 1
OR pending_outbound = 1 OR has_gap = 1
ORDER BY warehouse_id, lane_code, depth;
SELECT reservation_id, warehouse_id, lane_code, space_id, project_no, status,
created_at, released_at
FROM alg.reservation
ORDER BY created_at DESC;
SELECT lock_id, request_id, warehouse_id, floor_no, status, created_at, released_at
FROM alg.deep_workflow_lock
ORDER BY created_at DESC;
SELECT warehouse_id, lane_code, topology_dirty, reason_mask, change_count,
enqueued_at, last_changed_at
FROM alg.dirty_lane_queue
ORDER BY last_changed_at, warehouse_id, lane_code;
SELECT workflow_id, request_id, stockout_detail_id, warehouse_id, source_stock_id,
selected_tray_code, source_space_id, lane_code, floor_no, exit_id,
status, current_step, total_steps, created_at, updated_at, completed_at
FROM alg.outbound_workflow
WHERE status <> 'COMPLETED'
ORDER BY created_at, workflow_id;
SELECT step.workflow_id, step.assignment_id, step.phase, step.source_space_id,
step.target_space_id, step.workflow_step, step.workflow_total_steps,
step.depends_on_assignment_id, step.stock_id, step.tray_code, step.status,
step.wms_job_id, step.created_at, step.started_at, step.at_exit_at, step.completed_at
FROM alg.outbound_workflow_step step
JOIN alg.outbound_workflow workflow ON workflow.workflow_id = step.workflow_id
WHERE workflow.status <> 'COMPLETED' OR step.status <> 'COMPLETED'
ORDER BY step.workflow_id, step.workflow_step;
SELECT mode.warehouse_id, mode.requested_enabled, mode.effective_state,
mode.status_reason, mode.mode_version, mode.updated_by, mode.updated_at
FROM alg.tidy_mode mode
ORDER BY mode.warehouse_id;
SELECT queue.warehouse_id, queue.lane_code, queue.dirty_version, queue.queue_status,
queue.reason_mask, queue.change_count, queue.last_dirty_at, queue.claimed_version,
queue.claimed_by, queue.claimed_at, queue.lease_expires_at, queue.last_error
FROM alg.tidy_lane_queue queue
ORDER BY queue.last_dirty_at, queue.warehouse_id, queue.lane_code;
SELECT workflow.workflow_id, workflow.warehouse_id, workflow.lane_code, workflow.origin,
workflow.candidate_type, workflow.source_space_id, workflow.target_space_id,
workflow.stock_id, workflow.tray_code, workflow.status, workflow.claimed_dirty_version,
workflow.vehicle_id, workflow.wcs_task_id, workflow.status_reason,
workflow.created_at, workflow.updated_at, workflow.dispatched_at, workflow.completed_at
FROM alg.tidy_workflow workflow
WHERE workflow.completed_at IS NULL
OR workflow.status IN ('PAUSED_RECOVERY', 'CANCELLED_RECONCILED')
ORDER BY workflow.created_at, workflow.workflow_id;
SELECT step.workflow_id, step.step_id, step.step_no, step.source_space_id,
step.target_space_id, step.stock_id, step.tray_code, step.status,
step.wms_job_id, step.wcs_task_id, step.expected_event_stage,
step.created_at, step.started_at, step.completed_at
FROM alg.tidy_step step
JOIN alg.tidy_workflow workflow ON workflow.workflow_id = step.workflow_id
WHERE workflow.completed_at IS NULL OR step.completed_at IS NULL
ORDER BY step.workflow_id, step.step_no;
SELECT lock.workflow_id, lock.warehouse_id, lock.resource_type, lock.resource_id,
lock.status, lock.locked_at, lock.released_at
FROM alg.tidy_space_lock lock
WHERE lock.status = 'ACTIVE'
ORDER BY lock.warehouse_id, lock.resource_type, lock.resource_id;
SELECT event.workflow_id, event.step_id, event.wcs_task_id, event.event_stage,
COUNT(*) AS callback_count, MIN(event.received_at) AS first_received_at,
MAX(event.received_at) AS last_received_at
FROM alg.tidy_callback_event event
GROUP BY event.workflow_id, event.step_id, event.wcs_task_id, event.event_stage
HAVING COUNT(*) > 1
ORDER BY event.workflow_id, event.step_id, event.event_stage;
SELECT workflow.origin, workflow.status, COUNT(*) AS workflow_count
FROM alg.tidy_workflow workflow
GROUP BY workflow.origin, workflow.status
ORDER BY workflow.origin, workflow.status;

View File

@@ -0,0 +1,667 @@
/*
FT546 shadow-only read model for SQL Server 2016 RTM (13.0).
This package creates no Legacy-table triggers and never inserts, updates,
or deletes Legacy WMS business rows.
*/
SET NOCOUNT ON;
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
IF SCHEMA_ID(N'alg') IS NOT NULL
THROW 51000, 'FT546 shadow install refused: alg schema already exists', 1;
EXEC(N'CREATE SCHEMA alg');
GO
CREATE TABLE alg.shadow_package_manifest (
package_id varchar(80) NOT NULL CONSTRAINT PK_alg_shadow_package_manifest PRIMARY KEY,
package_version varchar(40) NOT NULL,
installed_at datetime2 NOT NULL CONSTRAINT DF_alg_shadow_package_manifest_installed_at DEFAULT SYSUTCDATETIME()
);
INSERT INTO alg.shadow_package_manifest (package_id, package_version)
VALUES ('FT546WMS_SHADOW_ONLY', '2026-08-12-1');
GO
IF OBJECT_ID(N'alg.warehouse_policy', N'U') IS NULL
CREATE TABLE alg.warehouse_policy (
warehouse_id varchar(80) NOT NULL PRIMARY KEY,
target_fill_rate decimal(9,4) NULL,
fifo_tolerance_hours int NULL,
updated_at datetime2 NOT NULL CONSTRAINT DF_alg_warehouse_policy_updated_at DEFAULT SYSUTCDATETIME()
);
GO
IF OBJECT_ID(N'alg.project_policy', N'U') IS NULL
CREATE TABLE alg.project_policy (
project_no varchar(255) NOT NULL PRIMARY KEY,
is_isolated bit NOT NULL CONSTRAINT DF_alg_project_policy_isolated DEFAULT 0,
is_common bit NOT NULL CONSTRAINT DF_alg_project_policy_common DEFAULT 0,
allow_mixed bit NOT NULL CONSTRAINT DF_alg_project_policy_mixed DEFAULT 0,
updated_at datetime2 NOT NULL CONSTRAINT DF_alg_project_policy_updated_at DEFAULT SYSUTCDATETIME()
);
GO
IF OBJECT_ID(N'alg.space_role', N'U') IS NULL
CREATE TABLE alg.space_role (
space_id varchar(80) NOT NULL PRIMARY KEY,
role varchar(40) NOT NULL,
updated_at datetime2 NOT NULL CONSTRAINT DF_alg_space_role_updated_at DEFAULT SYSUTCDATETIME(),
CONSTRAINT CK_alg_space_role_role CHECK (role IN ('STORAGE', 'RELOCATION_BUFFER', 'OUTBOUND_BUFFER', 'PROTECTED_CORE'))
);
GO
IF OBJECT_ID(N'alg.space_topology', N'U') IS NULL
CREATE TABLE alg.space_topology (
warehouse_id varchar(80) NOT NULL,
space_id varchar(80) NOT NULL CONSTRAINT PK_alg_space_topology PRIMARY KEY,
lane_code varchar(80) NOT NULL,
lane_depth int NOT NULL,
base_zone varchar(20) NOT NULL,
rebuilt_at datetime2 NOT NULL CONSTRAINT DF_alg_space_topology_rebuilt_at DEFAULT SYSUTCDATETIME(),
CONSTRAINT CK_alg_space_topology_base_zone CHECK (base_zone IN ('CACHE', 'RESERVE', 'MAIN', 'FLEX'))
);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'alg.space_topology') AND name = N'IX_alg_space_topology_warehouse_lane')
CREATE INDEX IX_alg_space_topology_warehouse_lane ON alg.space_topology(warehouse_id, lane_code);
GO
IF OBJECT_ID(N'alg.inventory_item_snapshot', N'U') IS NULL
CREATE TABLE alg.inventory_item_snapshot (
warehouse_id varchar(80) NOT NULL,
stock_id varchar(80) NOT NULL,
space_id varchar(80) NOT NULL,
tray_code varchar(255) NULL,
project_no varchar(255) NULL,
product_no varchar(255) NULL,
lot_no varchar(80) NULL,
inbound_time_raw varchar(80) NULL,
inbound_at datetime2 NULL,
quantity decimal(38,6) NULL,
out_quantity decimal(38,6) NULL,
synchronized_at datetime2 NOT NULL CONSTRAINT DF_alg_inventory_item_snapshot_sync DEFAULT SYSUTCDATETIME(),
CONSTRAINT PK_alg_inventory_item_snapshot PRIMARY KEY (warehouse_id, stock_id)
);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'alg.inventory_item_snapshot') AND name = N'IX_alg_inventory_item_snapshot_space')
CREATE INDEX IX_alg_inventory_item_snapshot_space ON alg.inventory_item_snapshot(warehouse_id, space_id);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'alg.inventory_item_snapshot') AND name = N'IX_alg_inventory_item_snapshot_tray')
CREATE INDEX IX_alg_inventory_item_snapshot_tray ON alg.inventory_item_snapshot(warehouse_id, tray_code) INCLUDE (space_id, project_no, product_no);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'alg.inventory_item_snapshot') AND name = N'IX_alg_inventory_item_snapshot_project_product')
CREATE INDEX IX_alg_inventory_item_snapshot_project_product ON alg.inventory_item_snapshot(warehouse_id, project_no, product_no) INCLUDE (space_id, tray_code, inbound_at);
GO
IF OBJECT_ID(N'alg.space_inventory_summary', N'U') IS NULL
CREATE TABLE alg.space_inventory_summary (
warehouse_id varchar(80) NOT NULL,
space_id varchar(80) NOT NULL,
stock_detail_count int NOT NULL,
stock_tray_count int NOT NULL,
stock_project_count int NOT NULL,
stock_product_count int NOT NULL,
synchronized_at datetime2 NOT NULL CONSTRAINT DF_alg_space_inventory_summary_sync DEFAULT SYSUTCDATETIME(),
CONSTRAINT PK_alg_space_inventory_summary PRIMARY KEY (warehouse_id, space_id)
);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'alg.space_inventory_summary') AND name = N'IX_alg_space_inventory_summary_space')
CREATE INDEX IX_alg_space_inventory_summary_space ON alg.space_inventory_summary(space_id, warehouse_id);
GO
IF OBJECT_ID(N'alg.lane_state', N'U') IS NULL
CREATE TABLE alg.lane_state (
warehouse_id varchar(80) NOT NULL,
lane_code varchar(80) NOT NULL,
space_count int NOT NULL CONSTRAINT DF_alg_lane_state_space_count DEFAULT 0,
occupied_count int NOT NULL CONSTRAINT DF_alg_lane_state_occupied_count DEFAULT 0,
available_count int NOT NULL CONSTRAINT DF_alg_lane_state_available_count DEFAULT 0,
pending_outbound bit NOT NULL CONSTRAINT DF_alg_lane_state_pending_outbound DEFAULT 0,
has_gap bit NOT NULL CONSTRAINT DF_alg_lane_state_has_gap DEFAULT 0,
semantic_fingerprint varbinary(32) NULL,
state_version rowversion NOT NULL,
rebuilt_at datetime2 NOT NULL CONSTRAINT DF_alg_lane_state_rebuilt_at DEFAULT SYSUTCDATETIME(),
CONSTRAINT PK_alg_lane_state PRIMARY KEY (warehouse_id, lane_code)
);
GO
IF COL_LENGTH(N'alg.lane_state', N'semantic_fingerprint') IS NULL
ALTER TABLE alg.lane_state ADD semantic_fingerprint varbinary(32) NULL;
GO
IF OBJECT_ID(N'alg.reservation', N'U') IS NULL
CREATE TABLE alg.reservation (
reservation_id uniqueidentifier NOT NULL CONSTRAINT PK_alg_reservation PRIMARY KEY,
warehouse_id varchar(80) NOT NULL,
lane_code varchar(80) NOT NULL,
space_id varchar(80) NOT NULL,
project_no varchar(255) NULL,
status varchar(20) NOT NULL,
created_at datetime2 NOT NULL CONSTRAINT DF_alg_reservation_created_at DEFAULT SYSUTCDATETIME(),
released_at datetime2 NULL,
CONSTRAINT CK_alg_reservation_status CHECK (status IN ('ACTIVE', 'RELEASED', 'EXPIRED'))
);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'alg.reservation') AND name = N'UX_alg_reservation_active_space')
CREATE UNIQUE INDEX UX_alg_reservation_active_space ON alg.reservation(space_id) WHERE status = 'ACTIVE';
GO
IF OBJECT_ID(N'alg.deep_workflow_lock', N'U') IS NULL
CREATE TABLE alg.deep_workflow_lock (
lock_id uniqueidentifier NOT NULL CONSTRAINT PK_alg_deep_workflow_lock PRIMARY KEY,
request_id varchar(80) NOT NULL,
warehouse_id varchar(80) NOT NULL,
floor_no int NOT NULL,
status varchar(20) NOT NULL,
created_at datetime2 NOT NULL CONSTRAINT DF_alg_deep_workflow_lock_created_at DEFAULT SYSUTCDATETIME(),
released_at datetime2 NULL,
CONSTRAINT CK_alg_deep_workflow_lock_status CHECK (status IN ('ACTIVE', 'RELEASED'))
);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'alg.deep_workflow_lock') AND name = N'UX_alg_deep_workflow_lock_active_floor')
CREATE UNIQUE INDEX UX_alg_deep_workflow_lock_active_floor
ON alg.deep_workflow_lock(warehouse_id, floor_no) WHERE status = 'ACTIVE';
GO
IF OBJECT_ID(N'alg.decision_log', N'U') IS NULL
CREATE TABLE alg.decision_log (
decision_id uniqueidentifier NOT NULL CONSTRAINT PK_alg_decision_log PRIMARY KEY,
warehouse_id varchar(80) NULL,
decision_type varchar(40) NOT NULL,
input_version binary(8) NULL,
result_code varchar(80) NULL,
reason varchar(2000) NULL,
created_at datetime2 NOT NULL CONSTRAINT DF_alg_decision_log_created_at DEFAULT SYSUTCDATETIME()
);
GO
IF OBJECT_ID(N'alg.usp_rebuild_space_topology', N'P') IS NOT NULL DROP PROCEDURE alg.usp_rebuild_space_topology;
GO
EXEC(N'
CREATE PROCEDURE alg.usp_rebuild_space_topology
@warehouse_id varchar(80) = NULL
AS
BEGIN
SET NOCOUNT ON;
;WITH lane_topology AS (
SELECT warehouse_id, group_coding_no AS lane_code,
COUNT(*) AS lane_depth,
MAX(the_column) AS column_no,
MIN(the_platoon) AS row_no,
MIN(the_floor) AS floor_no,
MIN(cargo_space_id) AS stable_id
FROM dbo.bas_cargo_space
WHERE NULLIF(LTRIM(RTRIM(group_coding_no)), '''') IS NOT NULL
AND TRY_CONVERT(int, group_coding) IS NOT NULL
AND (@warehouse_id IS NULL OR warehouse_id = @warehouse_id)
GROUP BY warehouse_id, group_coding_no
), ranked_lanes AS (
SELECT lane_topology.*,
CASE WHEN lane_depth = 1 THEN ROW_NUMBER() OVER (
PARTITION BY warehouse_id
ORDER BY CASE WHEN lane_depth = 1 THEN 0 ELSE 1 END,
column_no DESC, row_no ASC, floor_no ASC, stable_id ASC
) END AS single_depth_rank
FROM lane_topology
), source AS (
SELECT c.warehouse_id, c.cargo_space_id AS space_id, c.group_coding_no AS lane_code,
ranked_lanes.lane_depth,
CASE WHEN ranked_lanes.lane_depth = 1 AND ranked_lanes.single_depth_rank <= 80 THEN ''CACHE''
WHEN ranked_lanes.lane_depth = 1 AND ranked_lanes.single_depth_rank <= 133 THEN ''RESERVE''
WHEN ranked_lanes.lane_depth = 5 THEN ''MAIN''
ELSE ''FLEX'' END AS base_zone
FROM dbo.bas_cargo_space c
JOIN ranked_lanes ON ranked_lanes.warehouse_id = c.warehouse_id
AND ranked_lanes.lane_code = c.group_coding_no
WHERE NULLIF(LTRIM(RTRIM(c.group_coding_no)), '''') IS NOT NULL
AND TRY_CONVERT(int, c.group_coding) IS NOT NULL
AND (@warehouse_id IS NULL OR c.warehouse_id = @warehouse_id)
)
MERGE alg.space_topology AS target
USING source ON source.space_id = target.space_id
WHEN MATCHED AND (target.warehouse_id <> source.warehouse_id OR target.lane_code <> source.lane_code
OR target.lane_depth <> source.lane_depth OR target.base_zone <> source.base_zone)
THEN UPDATE SET warehouse_id = source.warehouse_id, lane_code = source.lane_code,
lane_depth = source.lane_depth, base_zone = source.base_zone,
rebuilt_at = SYSUTCDATETIME()
WHEN NOT MATCHED BY TARGET THEN
INSERT (warehouse_id, space_id, lane_code, lane_depth, base_zone)
VALUES (source.warehouse_id, source.space_id, source.lane_code, source.lane_depth, source.base_zone)
WHEN NOT MATCHED BY SOURCE AND (@warehouse_id IS NULL OR target.warehouse_id = @warehouse_id)
THEN DELETE;
END;');
GO
IF OBJECT_ID(N'alg.usp_sync_inventory_snapshot', N'P') IS NOT NULL DROP PROCEDURE alg.usp_sync_inventory_snapshot;
GO
EXEC(N'
CREATE PROCEDURE alg.usp_sync_inventory_snapshot
@warehouse_id varchar(80) = NULL
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
DELETE summary
FROM alg.space_inventory_summary summary
WHERE @warehouse_id IS NULL OR summary.warehouse_id = @warehouse_id;
DELETE item
FROM alg.inventory_item_snapshot item
WHERE @warehouse_id IS NULL OR item.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, synchronized_at)
SELECT space.warehouse_id, stock.stock_id, stock.cargo_space_id, stock.tray_code,
stock.spare4, stock.product_id, stock.lot_no, CONVERT(varchar(80), stock.in_date),
TRY_CONVERT(datetime2, stock.in_date), stock.quantity, stock.out_quantity, SYSUTCDATETIME()
FROM dbo.bas_stock stock
JOIN dbo.bas_cargo_space space ON space.cargo_space_id = stock.cargo_space_id
WHERE @warehouse_id IS NULL OR space.warehouse_id = @warehouse_id;
INSERT INTO alg.space_inventory_summary (
warehouse_id, space_id, stock_detail_count, stock_tray_count,
stock_project_count, stock_product_count, synchronized_at)
SELECT item.warehouse_id, item.space_id, COUNT(*),
COUNT(DISTINCT NULLIF(LTRIM(RTRIM(item.tray_code)), '''')),
COUNT(DISTINCT NULLIF(LTRIM(RTRIM(item.project_no)), '''')),
COUNT(DISTINCT NULLIF(LTRIM(RTRIM(item.product_no)), '''')),
SYSUTCDATETIME()
FROM alg.inventory_item_snapshot item
WHERE @warehouse_id IS NULL OR item.warehouse_id = @warehouse_id
GROUP BY item.warehouse_id, item.space_id;
COMMIT TRANSACTION;
END;');
GO
IF OBJECT_ID(N'alg.v_inventory_item', N'V') IS NOT NULL DROP VIEW alg.v_inventory_item;
GO
EXEC(N'
CREATE VIEW alg.v_inventory_item AS
SELECT stock_id, space_id, tray_code, project_no, product_no, lot_no,
inbound_time_raw, inbound_at, quantity, out_quantity
FROM alg.inventory_item_snapshot;');
GO
IF OBJECT_ID(N'alg.v_active_job', N'V') IS NOT NULL DROP VIEW alg.v_active_job;
GO
EXEC(N'
CREATE VIEW alg.v_active_job AS
WITH tray_candidates AS (
SELECT j.job_id,
COUNT(DISTINCT s.space_id) AS space_count,
MIN(s.space_id) AS cargo_space_id,
COUNT(DISTINCT NULLIF(LTRIM(RTRIM(s.project_no)), '''')) AS project_count,
MIN(NULLIF(LTRIM(RTRIM(s.project_no)), '''')) AS project_no,
COUNT(DISTINCT NULLIF(LTRIM(RTRIM(s.product_no)), '''')) AS product_count,
MIN(NULLIF(LTRIM(RTRIM(s.product_no)), '''')) AS product_no
FROM dbo.mydo_job j
JOIN alg.inventory_item_snapshot s ON s.warehouse_id = j.warehouse_id
AND s.tray_code = NULLIF(LTRIM(RTRIM(j.tray_code)), '''')
GROUP BY j.job_id
), resolved_jobs AS (
SELECT j.job_id, j.task_no, j.tray_code, j.target_cargo_space_id, j.warehouse_id,
j.status, j.in_or_out, j.create_time AS create_time_raw,
COALESCE(source_space.cargo_space_id,
CASE WHEN tray_candidates.space_count = 1 THEN tray_candidates.cargo_space_id END) AS source_space_id,
CASE WHEN source_space.cargo_space_id IS NOT NULL THEN ''EXPLICIT''
WHEN tray_candidates.space_count = 1 THEN ''TRAY''
WHEN tray_candidates.space_count > 1 THEN ''AMBIGUOUS_TRAY''
ELSE ''UNRESOLVED'' END AS source_resolution
FROM dbo.mydo_job j
LEFT JOIN dbo.bas_cargo_space source_space
ON source_space.cargo_space_id = NULLIF(LTRIM(RTRIM(j.cargo_space_id)), '''')
AND source_space.warehouse_id = j.warehouse_id
LEFT JOIN tray_candidates ON tray_candidates.job_id = j.job_id
WHERE j.finish_time IS NULL OR LTRIM(RTRIM(j.finish_time)) = ''''
), source_scope AS (
SELECT resolved_jobs.job_id,
COUNT(DISTINCT NULLIF(LTRIM(RTRIM(stock.project_no)), '''')) AS project_count,
MIN(NULLIF(LTRIM(RTRIM(stock.project_no)), '''')) AS project_no,
COUNT(DISTINCT NULLIF(LTRIM(RTRIM(stock.product_no)), '''')) AS product_count,
MIN(NULLIF(LTRIM(RTRIM(stock.product_no)), '''')) AS product_no
FROM resolved_jobs
JOIN alg.inventory_item_snapshot stock ON stock.warehouse_id = resolved_jobs.warehouse_id
AND stock.space_id = resolved_jobs.source_space_id
GROUP BY resolved_jobs.job_id
)
SELECT resolved_jobs.job_id, resolved_jobs.task_no, resolved_jobs.tray_code,
CASE WHEN resolved_jobs.source_space_id IS NOT NULL
THEN CASE WHEN source_scope.project_count = 1 THEN source_scope.project_no END
ELSE CASE WHEN tray_candidates.project_count = 1 THEN tray_candidates.project_no END END AS project_no,
CASE WHEN resolved_jobs.source_space_id IS NOT NULL
THEN CASE WHEN source_scope.product_count = 1 THEN source_scope.product_no END
ELSE CASE WHEN tray_candidates.product_count = 1 THEN tray_candidates.product_no END END AS product_no,
resolved_jobs.source_space_id, resolved_jobs.target_cargo_space_id, resolved_jobs.warehouse_id,
resolved_jobs.status, resolved_jobs.in_or_out, resolved_jobs.create_time_raw,
resolved_space.group_coding_no AS source_lane_code, resolved_jobs.source_resolution
FROM resolved_jobs
LEFT JOIN tray_candidates ON tray_candidates.job_id = resolved_jobs.job_id
LEFT JOIN source_scope ON source_scope.job_id = resolved_jobs.job_id
LEFT JOIN dbo.bas_cargo_space resolved_space ON resolved_space.cargo_space_id = resolved_jobs.source_space_id;');
GO
IF OBJECT_ID(N'alg.v_space_state', N'V') IS NOT NULL DROP VIEW alg.v_space_state;
GO
EXEC(N'
CREATE VIEW alg.v_space_state AS
WITH outbound_lanes AS (
SELECT DISTINCT warehouse_id, source_lane_code AS lane_code
FROM alg.v_active_job
WHERE in_or_out >= 20 AND source_lane_code IS NOT NULL
), lane_gaps AS (
SELECT DISTINCT inner_space.warehouse_id, inner_space.group_coding_no AS lane_code
FROM dbo.bas_cargo_space inner_space
JOIN dbo.bas_cargo_space outer_space
ON outer_space.warehouse_id = inner_space.warehouse_id
AND outer_space.group_coding_no = inner_space.group_coding_no
AND TRY_CONVERT(int, outer_space.group_coding) > TRY_CONVERT(int, inner_space.group_coding)
WHERE TRY_CONVERT(int, inner_space.group_coding) IS NOT NULL
AND TRY_CONVERT(int, outer_space.group_coding) IS NOT NULL
AND NULLIF(LTRIM(RTRIM(inner_space.tray_code)), '''') IS NULL
AND NOT EXISTS (
SELECT 1 FROM alg.inventory_item_snapshot inner_stock
WHERE inner_stock.warehouse_id = inner_space.warehouse_id
AND inner_stock.space_id = inner_space.cargo_space_id
)
AND (
NULLIF(LTRIM(RTRIM(outer_space.tray_code)), '''') IS NOT NULL
OR EXISTS (
SELECT 1 FROM alg.inventory_item_snapshot outer_stock
WHERE outer_stock.warehouse_id = outer_space.warehouse_id
AND outer_stock.space_id = outer_space.cargo_space_id
)
)
)
SELECT c.cargo_space_id AS space_id,
c.warehouse_id,
c.group_coding_no AS lane_code,
c.cargo_area_id AS area_id,
CASE
WHEN ISNULL(role.role, ''STORAGE'') IN (''RELOCATION_BUFFER'', ''OUTBOUND_BUFFER'') THEN ''CACHE''
WHEN ISNULL(role.role, ''STORAGE'') = ''PROTECTED_CORE'' THEN ''RESERVE''
ELSE topology.base_zone
END AS zone_code,
c.roadway_id,
c.cargo_space_name AS display_name,
c.the_platoon AS platoon,
c.the_column AS column_no,
c.the_floor AS floor_no,
c.group_coding AS depth_raw,
TRY_CONVERT(int, c.group_coding) AS depth,
c.status AS space_status,
c.locked,
c.tray_code AS space_tray_code,
ISNULL(role.role, ''STORAGE'') AS role,
ISNULL(stock.stock_detail_count, 0) AS stock_detail_count,
ISNULL(stock.stock_tray_count, 0) AS stock_tray_count,
ISNULL(stock.stock_project_count, 0) AS stock_project_count,
ISNULL(stock.stock_product_count, 0) AS stock_product_count,
CONVERT(bit, CASE WHEN ISNULL(stock.stock_tray_count, 0) > 1 THEN 1 ELSE 0 END) AS tray_conflict,
CONVERT(bit, CASE WHEN ISNULL(stock.stock_project_count, 0) > 1 THEN 1 ELSE 0 END) AS project_conflict,
CONVERT(bit, 0) AS product_conflict,
CONVERT(bit, CASE WHEN outbound_lanes.lane_code IS NULL THEN 0 ELSE 1 END) AS pending_outbound,
active_reservations.reservation_id,
CONVERT(bit, CASE WHEN active_reservations.reservation_id IS NULL THEN 0 ELSE 1 END) AS reserved,
CONVERT(bit, CASE WHEN lane_gaps.lane_code IS NULL THEN 0 ELSE 1 END) AS has_gap,
CONVERT(bit, CASE
WHEN c.status IS NULL OR c.status <> ''1'' THEN 0
WHEN NULLIF(LTRIM(RTRIM(c.locked)), '''') IS NOT NULL AND LTRIM(RTRIM(c.locked)) NOT IN (''0'', ''N'') THEN 0
WHEN NULLIF(LTRIM(RTRIM(c.tray_code)), '''') IS NOT NULL THEN 0
WHEN ISNULL(stock.stock_detail_count, 0) <> 0 THEN 0
WHEN TRY_CONVERT(int, c.group_coding) IS NULL THEN 0
WHEN ISNULL(role.role, ''STORAGE'') <> ''STORAGE'' THEN 0
WHEN outbound_lanes.lane_code IS NOT NULL THEN 0
WHEN active_reservations.reservation_id IS NOT NULL THEN 0
WHEN lane_gaps.lane_code IS NOT NULL THEN 0
WHEN EXISTS (
SELECT 1
FROM dbo.bas_cargo_space inner_candidate
LEFT JOIN alg.space_inventory_summary inner_stock
ON inner_stock.warehouse_id = inner_candidate.warehouse_id
AND inner_stock.space_id = inner_candidate.cargo_space_id
WHERE inner_candidate.warehouse_id = c.warehouse_id
AND inner_candidate.group_coding_no = c.group_coding_no
AND TRY_CONVERT(int, inner_candidate.group_coding) < TRY_CONVERT(int, c.group_coding)
AND inner_candidate.status = ''1''
AND NULLIF(LTRIM(RTRIM(inner_candidate.tray_code)), '''') IS NULL
AND inner_stock.space_id IS NULL
) THEN 0
ELSE 1 END) AS inbound_available
FROM dbo.bas_cargo_space c
JOIN alg.space_topology topology ON topology.space_id = c.cargo_space_id
LEFT JOIN alg.space_inventory_summary stock ON stock.warehouse_id = c.warehouse_id
AND stock.space_id = c.cargo_space_id
LEFT JOIN alg.space_role role ON role.space_id = c.cargo_space_id
LEFT JOIN outbound_lanes ON outbound_lanes.warehouse_id = c.warehouse_id AND outbound_lanes.lane_code = c.group_coding_no
LEFT JOIN alg.reservation active_reservations ON active_reservations.space_id = c.cargo_space_id AND active_reservations.status = ''ACTIVE''
LEFT JOIN lane_gaps ON lane_gaps.warehouse_id = c.warehouse_id AND lane_gaps.lane_code = c.group_coding_no;');
GO
IF OBJECT_ID(N'alg.usp_compute_lane_semantic_fingerprint', N'P') IS NOT NULL DROP PROCEDURE alg.usp_compute_lane_semantic_fingerprint;
GO
EXEC(N'
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) = ''LIVE''
AS
BEGIN
SET NOCOUNT ON;
DECLARE @payload nvarchar(max);
CREATE TABLE #fingerprint_inventory (
stock_id varchar(80) NOT NULL,
space_id varchar(80) NOT NULL,
tray_code varchar(255) NULL,
project_no varchar(255) NULL,
product_no varchar(255) NULL,
lot_no varchar(80) NULL,
inbound_time varchar(80) NULL,
quantity decimal(38,6) NULL,
out_quantity decimal(38,6) NULL
);
IF @inventory_source = ''SNAPSHOT''
BEGIN
INSERT INTO #fingerprint_inventory (
stock_id, space_id, tray_code, project_no, product_no, lot_no,
inbound_time, quantity, out_quantity)
SELECT stock.stock_id, stock.space_id, stock.tray_code, stock.project_no,
stock.product_no, stock.lot_no, stock.inbound_time_raw,
CONVERT(decimal(38,6), stock.quantity), CONVERT(decimal(38,6), stock.out_quantity)
FROM alg.inventory_item_snapshot stock
JOIN dbo.bas_cargo_space cargo ON cargo.cargo_space_id = stock.space_id
AND cargo.warehouse_id = stock.warehouse_id
WHERE cargo.warehouse_id = @warehouse_id AND cargo.group_coding_no = @lane_code;
END
ELSE IF @inventory_source = ''LIVE''
BEGIN
INSERT INTO #fingerprint_inventory (
stock_id, space_id, tray_code, project_no, product_no, lot_no,
inbound_time, quantity, out_quantity)
SELECT stock.stock_id, stock.cargo_space_id, stock.tray_code, stock.spare4,
stock.product_id, stock.lot_no, CONVERT(varchar(80), stock.in_date),
CONVERT(decimal(38,6), stock.quantity), CONVERT(decimal(38,6), stock.out_quantity)
FROM dbo.bas_stock stock
JOIN dbo.bas_cargo_space cargo ON cargo.cargo_space_id = stock.cargo_space_id
WHERE cargo.warehouse_id = @warehouse_id AND cargo.group_coding_no = @lane_code;
END
ELSE
BEGIN
THROW 50001, ''Unsupported inventory fingerprint source'', 1;
END;
SELECT @payload = ISNULL(CONVERT(nvarchar(max), (
SELECT cargo.cargo_space_id AS [@space_id], cargo.status AS [@status],
cargo.locked AS [@locked], cargo.tray_code AS [@tray_code],
cargo.group_coding AS [@depth], cargo.the_platoon AS [@platoon],
cargo.the_column AS [@column], cargo.the_floor AS [@floor],
topology.lane_depth AS [@lane_depth], topology.base_zone AS [@base_zone],
ISNULL(role.role, ''STORAGE'') AS [@role]
FROM dbo.bas_cargo_space cargo
LEFT JOIN alg.space_topology topology ON topology.space_id = cargo.cargo_space_id
LEFT JOIN alg.space_role role ON role.space_id = cargo.cargo_space_id
WHERE cargo.warehouse_id = @warehouse_id AND cargo.group_coding_no = @lane_code
ORDER BY TRY_CONVERT(int, cargo.group_coding), cargo.cargo_space_id
FOR XML PATH(''space''), ROOT(''spaces''), TYPE
)), N'''');
SET @payload = @payload + N''|INVENTORY|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT stock.stock_id AS [@stock_id], stock.space_id AS [@space_id],
stock.tray_code AS [@tray_code], stock.project_no AS [@project_no],
stock.product_no AS [@product_no], stock.lot_no AS [@lot_no],
stock.inbound_time AS [@inbound_time],
stock.quantity AS [@quantity], stock.out_quantity AS [@out_quantity]
FROM #fingerprint_inventory stock
ORDER BY stock.space_id, stock.stock_id
FOR XML PATH(''item''), ROOT(''inventory''), TYPE
)), N'''');
SET @payload = @payload + N''|ACTIVE_JOBS|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT job.job_id AS [@job_id], job.task_no AS [@task_no], job.tray_code AS [@tray_code],
job.source_space_id AS [@source_space_id], job.target_cargo_space_id AS [@target_space_id],
job.status AS [@status], job.in_or_out AS [@in_or_out],
job.create_time_raw AS [@create_time]
FROM alg.v_active_job job
WHERE job.warehouse_id = @warehouse_id AND job.source_lane_code = @lane_code
ORDER BY job.job_id
FOR XML PATH(''job''), ROOT(''jobs''), TYPE
)), N'''');
SET @payload = @payload + N''|PROJECT_POLICY|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT policy.project_no AS [@project_no], policy.is_isolated AS [@is_isolated],
policy.is_common AS [@is_common], policy.allow_mixed AS [@allow_mixed]
FROM alg.project_policy policy
ORDER BY policy.project_no
FOR XML PATH(''project''), ROOT(''project_policy''), TYPE
)), N'''');
SET @payload = @payload + N''|WAREHOUSE_POLICY|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT policy.warehouse_id AS [@warehouse_id], policy.target_fill_rate AS [@target_fill_rate],
policy.fifo_tolerance_hours AS [@fifo_tolerance_hours]
FROM alg.warehouse_policy policy
WHERE policy.warehouse_id = @warehouse_id
FOR XML PATH(''warehouse''), ROOT(''warehouse_policy''), TYPE
)), N'''');
SET @payload = @payload + N''|RESERVATIONS|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT reservation.reservation_id AS [@reservation_id], reservation.space_id AS [@space_id],
reservation.project_no AS [@project_no], reservation.status AS [@status]
FROM alg.reservation reservation
WHERE reservation.warehouse_id = @warehouse_id AND reservation.lane_code = @lane_code
AND reservation.status = ''ACTIVE''
ORDER BY reservation.space_id, reservation.reservation_id
FOR XML PATH(''reservation''), ROOT(''reservations''), TYPE
)), N'''');
SET @semantic_fingerprint = HASHBYTES(''SHA2_256'', CONVERT(varbinary(max), @payload));
END;');
GO
IF OBJECT_ID(N'alg.usp_rebuild_lane_state', N'P') IS NOT NULL DROP PROCEDURE alg.usp_rebuild_lane_state;
GO
EXEC(N'
CREATE PROCEDURE alg.usp_rebuild_lane_state
@warehouse_id varchar(80) = NULL,
@lane_code varchar(80) = NULL
AS
BEGIN
SET NOCOUNT ON;
SELECT warehouse_id, lane_code,
COUNT(*) AS space_count,
SUM(CASE WHEN space_status = ''2'' OR stock_detail_count > 0 THEN 1 ELSE 0 END) AS occupied_count,
SUM(CASE WHEN inbound_available = 1 THEN 1 ELSE 0 END) AS available_count,
MAX(CASE WHEN pending_outbound = 1 THEN 1 ELSE 0 END) AS pending_outbound,
MAX(CASE WHEN has_gap = 1 THEN 1 ELSE 0 END) AS has_gap,
CAST(NULL AS varbinary(32)) AS semantic_fingerprint
INTO #source_state
FROM alg.v_space_state
WHERE (@warehouse_id IS NULL OR warehouse_id = @warehouse_id)
AND (@lane_code IS NULL OR lane_code = @lane_code)
GROUP BY warehouse_id, lane_code;
DECLARE @fingerprint_warehouse varchar(80), @fingerprint_lane varchar(80),
@fingerprint varbinary(32);
DECLARE fingerprint_cursor CURSOR LOCAL FAST_FORWARD FOR
SELECT warehouse_id, lane_code FROM #source_state ORDER BY warehouse_id, lane_code;
OPEN fingerprint_cursor;
FETCH NEXT FROM fingerprint_cursor INTO @fingerprint_warehouse, @fingerprint_lane;
WHILE @@FETCH_STATUS = 0
BEGIN
EXEC alg.usp_compute_lane_semantic_fingerprint
@warehouse_id = @fingerprint_warehouse,
@lane_code = @fingerprint_lane,
@semantic_fingerprint = @fingerprint OUTPUT,
@inventory_source = ''SNAPSHOT'';
UPDATE #source_state SET semantic_fingerprint = @fingerprint
WHERE warehouse_id = @fingerprint_warehouse AND lane_code = @fingerprint_lane;
FETCH NEXT FROM fingerprint_cursor INTO @fingerprint_warehouse, @fingerprint_lane;
END;
CLOSE fingerprint_cursor;
DEALLOCATE fingerprint_cursor;
MERGE alg.lane_state WITH (HOLDLOCK) AS target
USING #source_state AS source
ON target.warehouse_id = source.warehouse_id AND target.lane_code = source.lane_code
WHEN MATCHED AND (target.space_count <> source.space_count OR
target.occupied_count <> source.occupied_count OR
target.available_count <> source.available_count OR
target.pending_outbound <> source.pending_outbound OR
target.has_gap <> source.has_gap OR
target.semantic_fingerprint IS NULL OR
target.semantic_fingerprint <> source.semantic_fingerprint)
THEN UPDATE SET space_count = source.space_count,
occupied_count = source.occupied_count,
available_count = source.available_count,
pending_outbound = source.pending_outbound,
has_gap = source.has_gap,
semantic_fingerprint = source.semantic_fingerprint,
rebuilt_at = SYSUTCDATETIME()
WHEN NOT MATCHED BY TARGET THEN
INSERT (warehouse_id, lane_code, space_count, occupied_count, available_count,
pending_outbound, has_gap, semantic_fingerprint)
VALUES (source.warehouse_id, source.lane_code, source.space_count, source.occupied_count,
source.available_count, source.pending_outbound, source.has_gap, source.semantic_fingerprint)
WHEN NOT MATCHED BY SOURCE
AND (@warehouse_id IS NULL OR target.warehouse_id = @warehouse_id)
AND (@lane_code IS NULL OR target.lane_code = @lane_code)
THEN DELETE;
END;');
GO
IF OBJECT_ID(N'alg.usp_refresh_dirty_lanes', N'P') IS NOT NULL DROP PROCEDURE alg.usp_refresh_dirty_lanes;
GO
EXEC(N'
CREATE PROCEDURE alg.usp_refresh_dirty_lanes
@warehouse_id varchar(80)
AS
BEGIN
SET NOCOUNT ON;
IF NULLIF(LTRIM(RTRIM(@warehouse_id)), '''') IS NULL
THROW 50002, ''warehouse_id is required'', 1;
EXEC alg.usp_rebuild_space_topology @warehouse_id = @warehouse_id;
EXEC alg.usp_sync_inventory_snapshot @warehouse_id = @warehouse_id;
EXEC alg.usp_rebuild_lane_state @warehouse_id = @warehouse_id;
END;');
GO
IF NOT EXISTS (SELECT 1 FROM alg.space_topology)
AND NOT EXISTS (SELECT 1 FROM alg.inventory_item_snapshot)
AND NOT EXISTS (SELECT 1 FROM alg.lane_state)
BEGIN
EXEC alg.usp_rebuild_space_topology;
EXEC alg.usp_sync_inventory_snapshot;
EXEC alg.usp_rebuild_lane_state;
END;
GO

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
/*
Removes only the exact FT546 shadow package version after ownership checks.
Export alg.decision_log and stop the shadow application first.
No Legacy WMS business row is modified by this script.
*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF SCHEMA_ID(N'alg') IS NULL
THROW 51010, 'FT546 shadow uninstall refused: alg schema does not exist', 1;
IF OBJECT_ID(N'alg.shadow_package_manifest', N'U') IS NULL
THROW 51011, 'FT546 shadow uninstall refused: shadow package manifest does not match', 1;
IF (SELECT COUNT(*) FROM alg.shadow_package_manifest
WHERE package_id = 'FT546WMS_SHADOW_ONLY'
AND package_version = '2026-08-12-1') <> 1
OR (SELECT COUNT(*) FROM alg.shadow_package_manifest) <> 1
THROW 51012, 'FT546 shadow uninstall refused: shadow package manifest does not match', 1;
DECLARE @expected_objects TABLE (
object_name sysname NOT NULL PRIMARY KEY,
object_type char(2) NOT NULL
);
INSERT INTO @expected_objects (object_name, object_type) 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'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');
IF EXISTS (
SELECT 1
FROM sys.objects object
LEFT JOIN @expected_objects expected
ON expected.object_name = object.name AND expected.object_type = object.type
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 51013, 'FT546 shadow uninstall refused: unknown object exists in alg schema', 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 = 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 TRANSACTION;
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 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;
EXEC(N'DROP SCHEMA alg');
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION;
THROW;
END CATCH;
GO

View File

@@ -1,53 +0,0 @@
/* Removes only objects owned by schema alg. Run independently of install.sql. */
SET NOCOUNT ON;
IF OBJECT_ID(N'dbo.trg_alg_dirty_bas_stock', N'TR') IS NOT NULL DROP TRIGGER dbo.trg_alg_dirty_bas_stock;
IF OBJECT_ID(N'dbo.trg_alg_dirty_bas_cargo_space', N'TR') IS NOT NULL DROP TRIGGER dbo.trg_alg_dirty_bas_cargo_space;
IF OBJECT_ID(N'dbo.trg_alg_dirty_mydo_job', N'TR') IS NOT NULL DROP TRIGGER dbo.trg_alg_dirty_mydo_job;
IF OBJECT_ID(N'alg.trg_alg_dirty_space_role', N'TR') IS NOT NULL DROP TRIGGER alg.trg_alg_dirty_space_role;
IF OBJECT_ID(N'alg.trg_alg_dirty_project_policy', N'TR') IS NOT NULL DROP TRIGGER alg.trg_alg_dirty_project_policy;
IF OBJECT_ID(N'alg.trg_alg_dirty_warehouse_policy', N'TR') IS NOT NULL DROP TRIGGER alg.trg_alg_dirty_warehouse_policy;
GO
IF OBJECT_ID(N'alg.usp_refresh_dirty_lanes', N'P') IS NOT NULL DROP PROCEDURE alg.usp_refresh_dirty_lanes;
IF OBJECT_ID(N'alg.usp_mark_dirty_lane', N'P') IS NOT NULL DROP PROCEDURE alg.usp_mark_dirty_lane;
IF OBJECT_ID(N'alg.usp_claim_tidy_lane', N'P') IS NOT NULL DROP PROCEDURE alg.usp_claim_tidy_lane;
IF OBJECT_ID(N'alg.usp_release_tidy_lane', N'P') IS NOT NULL DROP PROCEDURE alg.usp_release_tidy_lane;
IF OBJECT_ID(N'alg.usp_complete_move_job', N'P') IS NOT NULL DROP PROCEDURE alg.usp_complete_move_job;
IF OBJECT_ID(N'alg.usp_sync_replay_sequences', N'P') IS NOT NULL DROP PROCEDURE alg.usp_sync_replay_sequences;
IF OBJECT_ID(N'alg.usp_release_reservation', N'P') IS NOT NULL DROP PROCEDURE alg.usp_release_reservation;
IF OBJECT_ID(N'alg.usp_try_reserve_inbound', N'P') IS NOT NULL DROP PROCEDURE alg.usp_try_reserve_inbound;
IF OBJECT_ID(N'alg.usp_apply_space_role', N'P') IS NOT NULL DROP PROCEDURE alg.usp_apply_space_role;
IF OBJECT_ID(N'alg.usp_release_deep_workflow_lock', N'P') IS NOT NULL DROP PROCEDURE alg.usp_release_deep_workflow_lock;
IF OBJECT_ID(N'alg.usp_acquire_deep_workflow_lock', N'P') IS NOT NULL DROP PROCEDURE alg.usp_acquire_deep_workflow_lock;
IF OBJECT_ID(N'alg.usp_rebuild_lane_state', N'P') IS NOT NULL DROP PROCEDURE alg.usp_rebuild_lane_state;
IF OBJECT_ID(N'alg.usp_compute_lane_semantic_fingerprint', N'P') IS NOT NULL DROP PROCEDURE alg.usp_compute_lane_semantic_fingerprint;
IF OBJECT_ID(N'alg.usp_sync_inventory_snapshot', N'P') IS NOT NULL DROP PROCEDURE alg.usp_sync_inventory_snapshot;
IF OBJECT_ID(N'alg.usp_rebuild_space_topology', N'P') IS NOT NULL DROP PROCEDURE alg.usp_rebuild_space_topology;
GO
IF OBJECT_ID(N'alg.v_space_state', N'V') IS NOT NULL DROP VIEW alg.v_space_state;
IF OBJECT_ID(N'alg.v_active_job', N'V') IS NOT NULL DROP VIEW alg.v_active_job;
IF OBJECT_ID(N'alg.v_inventory_item', N'V') IS NOT NULL DROP VIEW alg.v_inventory_item;
GO
IF OBJECT_ID(N'alg.outbound_workflow_space', N'U') IS NOT NULL DROP TABLE alg.outbound_workflow_space;
IF OBJECT_ID(N'alg.outbound_workflow_step', N'U') IS NOT NULL DROP TABLE alg.outbound_workflow_step;
IF OBJECT_ID(N'alg.outbound_workflow', N'U') IS NOT NULL DROP TABLE alg.outbound_workflow;
IF OBJECT_ID(N'alg.tidy_callback_event', N'U') IS NOT NULL DROP TABLE alg.tidy_callback_event;
IF OBJECT_ID(N'alg.tidy_space_lock', N'U') IS NOT NULL DROP TABLE alg.tidy_space_lock;
IF OBJECT_ID(N'alg.tidy_step', N'U') IS NOT NULL DROP TABLE alg.tidy_step;
IF OBJECT_ID(N'alg.tidy_workflow', N'U') IS NOT NULL DROP TABLE alg.tidy_workflow;
IF OBJECT_ID(N'alg.tidy_lane_queue', N'U') IS NOT NULL DROP TABLE alg.tidy_lane_queue;
IF OBJECT_ID(N'alg.tidy_mode', N'U') IS NOT NULL DROP TABLE alg.tidy_mode;
IF OBJECT_ID(N'alg.decision_log', N'U') IS NOT NULL DROP TABLE alg.decision_log;
IF OBJECT_ID(N'alg.deep_workflow_lock', N'U') IS NOT NULL DROP TABLE alg.deep_workflow_lock;
IF OBJECT_ID(N'alg.reservation', N'U') IS NOT NULL DROP TABLE alg.reservation;
IF OBJECT_ID(N'alg.dirty_lane_queue', N'U') IS NOT NULL DROP TABLE alg.dirty_lane_queue;
IF OBJECT_ID(N'alg.lane_state', N'U') IS NOT NULL DROP TABLE alg.lane_state;
IF OBJECT_ID(N'alg.space_inventory_summary', N'U') IS NOT NULL DROP TABLE alg.space_inventory_summary;
IF OBJECT_ID(N'alg.inventory_item_snapshot', N'U') IS NOT NULL DROP TABLE alg.inventory_item_snapshot;
IF OBJECT_ID(N'alg.space_topology', N'U') IS NOT NULL DROP TABLE alg.space_topology;
IF OBJECT_ID(N'alg.space_role', N'U') IS NOT NULL DROP TABLE alg.space_role;
IF OBJECT_ID(N'alg.project_policy', N'U') IS NOT NULL DROP TABLE alg.project_policy;
IF OBJECT_ID(N'alg.warehouse_policy', N'U') IS NOT NULL DROP TABLE alg.warehouse_policy;
GO
IF SCHEMA_ID(N'alg') IS NOT NULL EXEC(N'DROP SCHEMA alg');
GO

View File

@@ -40,7 +40,7 @@ class JsAlgorithmContractTest {
assertThat(stream).as("run tools/export-js-contracts.js first").isNotNull();
JsonNode root = mapper.readTree(stream);
assertThat(root.path("sourceAlgorithmVersion").asText()).isEqualTo("1.2.0");
Path source = Path.of("..", "..", "..", "ft546-storage-scheduling-algorithm.js");
Path source = findSourceAlgorithm();
assertThat(root.path("sourceSha256").asText()).isEqualTo(sha256(Files.readAllBytes(source)));
assertThat(root.path("fixtures")).hasSize(4);
for (JsonNode fixture : root.path("fixtures")) {
@@ -134,4 +134,17 @@ class JsAlgorithmContractTest {
private String sha256(byte[] value) throws Exception {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value));
}
private Path findSourceAlgorithm() {
Path directory = Path.of("").toAbsolutePath();
while (directory != null) {
Path candidate = directory.resolve("ft546-storage-scheduling-algorithm.js");
if (Files.isRegularFile(candidate)) {
return candidate;
}
directory = directory.getParent();
}
throw new IllegalStateException("Cannot find ft546-storage-scheduling-algorithm.js above "
+ Path.of("").toAbsolutePath());
}
}

View File

@@ -0,0 +1,174 @@
package com.ruoyi.algorithm.repository;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
class ShadowSqlServerMigrationScriptTest {
private static final Set<String> ALLOWED_INSTALL_OBJECTS = Set.of(
"table alg.shadow_package_manifest",
"table alg.warehouse_policy",
"table alg.project_policy",
"table alg.space_role",
"table alg.space_topology",
"table alg.inventory_item_snapshot",
"table alg.space_inventory_summary",
"table alg.lane_state",
"table alg.reservation",
"table alg.deep_workflow_lock",
"table alg.decision_log",
"view alg.v_inventory_item",
"view alg.v_active_job",
"view alg.v_space_state",
"procedure alg.usp_rebuild_space_topology",
"procedure alg.usp_sync_inventory_snapshot",
"procedure alg.usp_compute_lane_semantic_fingerprint",
"procedure alg.usp_rebuild_lane_state",
"procedure alg.usp_refresh_dirty_lanes");
@Test
void installCreatesOnlyAllowListedShadowObjects() throws IOException {
String install = resource("db/sqlserver/install-shadow.sql");
assertThat(createdObjects(install)).containsExactlyInAnyOrderElementsOf(ALLOWED_INSTALL_OBJECTS);
assertThat(install.toLowerCase(Locale.ROOT)).doesNotContain(
"usp_try_reserve_inbound",
"usp_release_reservation",
"usp_acquire_deep_workflow_lock",
"usp_release_deep_workflow_lock",
"usp_complete_move_job",
"usp_sync_replay_sequences",
"tidy_",
"outbound_workflow",
"proc_yk_complete");
}
@Test
void installNeverMutatesLegacyBusinessTables() throws IOException {
String install = normalize(resource("db/sqlserver/install-shadow.sql"));
assertThat(install).doesNotContain(
"insert into dbo.",
"update dbo.",
"delete from dbo.",
"merge dbo.",
"create trigger",
"on dbo.",
"dirty_lane_queue");
}
@Test
void installAndUninstallOwnExactlyTheSameObjects() throws IOException {
Set<String> installed = createdObjects(resource("db/sqlserver/install-shadow.sql"));
Set<String> removed = droppedObjects(resource("db/sqlserver/uninstall-shadow.sql"));
assertThat(removed).containsExactlyInAnyOrderElementsOf(installed);
}
@Test
void refreshPerformsFullSynchronizationWithoutLegacyTriggers() throws IOException {
String install = normalize(resource("db/sqlserver/install-shadow.sql"));
String refresh = install.substring(install.indexOf("create procedure alg.usp_refresh_dirty_lanes"));
assertThat(refresh).contains(
"exec alg.usp_rebuild_space_topology",
"exec alg.usp_sync_inventory_snapshot",
"exec alg.usp_rebuild_lane_state");
assertThat(refresh).doesNotContain("dirty_lane_queue", "tidy_lane_queue");
assertThat(install).contains(
"exec alg.usp_rebuild_space_topology",
"exec alg.usp_sync_inventory_snapshot",
"exec alg.usp_rebuild_lane_state");
}
@Test
void diagnosticsSupportsSafetyChecksAndDecisionExport() throws IOException {
String diagnostics = normalize(resource("db/sqlserver/diagnostics-shadow.sql"));
assertThat(diagnostics).contains(
"from alg.decision_log",
"inbound_compare",
"outbound_compare",
"from sys.dm_tran_locks",
"from dbo.bas_cargo_space",
"from dbo.bas_stock",
"from dbo.mydo_job");
assertThat(diagnostics).doesNotContain("insert ", "update ", "delete ", "merge ");
}
@Test
void installationAndDiagnosticsReadLegacyWithoutTakingSharedLocks() throws IOException {
String readUncommitted = "set transaction isolation level read uncommitted";
assertThat(normalize(resource("db/sqlserver/install-shadow.sql"))).contains(readUncommitted);
assertThat(normalize(resource("db/sqlserver/diagnostics-shadow.sql"))).contains(readUncommitted);
}
@Test
void installRefusesAnyExistingAlgorithmSchemaAndCreatesExactManifest() throws IOException {
String install = normalize(resource("db/sqlserver/install-shadow.sql"));
assertThat(install).contains(
"if schema_id(n'alg') is not null",
"throw 51000",
"create table alg.shadow_package_manifest",
"ft546wms_shadow_only",
"2026-08-12-1");
assertThat(install.indexOf("throw 51000")).isLessThan(install.indexOf("create schema alg"));
}
@Test
void uninstallValidatesManifestAndObjectAllowListBeforeAnyDrop() throws IOException {
String uninstall = normalize(resource("db/sqlserver/uninstall-shadow.sql"));
int firstDrop = uninstall.indexOf("drop procedure");
assertThat(uninstall).contains(
"ft546wms_shadow_only",
"2026-08-12-1",
"shadow package manifest does not match",
"unknown object exists in alg schema",
"object.parent_object_id = 0",
"begin transaction",
"rollback transaction");
assertThat(uninstall.indexOf("shadow package manifest does not match")).isLessThan(firstDrop);
assertThat(uninstall.indexOf("unknown object exists in alg schema")).isLessThan(firstDrop);
}
private Set<String> createdObjects(String sql) {
return objects(sql, Pattern.compile(
"(?i)create\\s+(table|view|procedure|trigger)\\s+([a-z0-9_]+\\.[a-z0-9_]+)"));
}
private Set<String> droppedObjects(String sql) {
return objects(sql, Pattern.compile(
"(?i)drop\\s+(table|view|procedure|trigger)\\s+([a-z0-9_]+\\.[a-z0-9_]+)"));
}
private Set<String> objects(String sql, Pattern pattern) {
Matcher matcher = pattern.matcher(sql);
Set<String> objects = new LinkedHashSet<>();
while (matcher.find()) {
objects.add((matcher.group(1) + " " + matcher.group(2)).toLowerCase(Locale.ROOT));
}
return objects;
}
private String normalize(String sql) {
return sql.toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
}
private String resource(String name) throws IOException {
try (var stream = getClass().getClassLoader().getResourceAsStream(name)) {
assertThat(stream).as("resource %s", name).isNotNull();
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
}
}

View File

@@ -1,200 +0,0 @@
package com.ruoyi.algorithm.repository;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class SqlServerMigrationScriptTest {
@Test
void installDefinesPersistentOutboundWorkflowSchema() throws IOException {
String install = resource("db/sqlserver/install.sql");
assertThat(install).contains(
"CREATE TABLE alg.outbound_workflow (",
"CREATE TABLE alg.outbound_workflow_step (",
"CREATE TABLE alg.outbound_workflow_space (",
"CK_alg_outbound_workflow_status",
"CK_alg_outbound_workflow_step_phase",
"CK_alg_outbound_workflow_step_status",
"UX_alg_outbound_workflow_active_request",
"UX_alg_outbound_workflow_step_wms_job",
"UX_alg_outbound_workflow_space_active");
}
@Test
void installDefinesDirtyLaneQueueRefreshAndAllMutationSources() throws IOException {
String install = resource("db/sqlserver/install.sql");
assertThat(install).contains(
"CREATE TABLE alg.dirty_lane_queue (",
"CREATE PROCEDURE alg.usp_mark_dirty_lane",
"CREATE PROCEDURE alg.usp_refresh_dirty_lanes",
"CREATE TRIGGER dbo.trg_alg_dirty_bas_stock",
"CREATE TRIGGER dbo.trg_alg_dirty_bas_cargo_space",
"CREATE TRIGGER dbo.trg_alg_dirty_mydo_job",
"CREATE TRIGGER alg.trg_alg_dirty_space_role",
"CREATE TRIGGER alg.trg_alg_dirty_project_policy",
"CREATE TRIGGER alg.trg_alg_dirty_warehouse_policy");
}
@Test
void installDefinesPersistentTidyRuntimeSchemaAndConstraints() throws IOException {
String install = resource("db/sqlserver/install.sql");
assertThat(install).contains(
"CREATE TABLE alg.tidy_mode (",
"CREATE TABLE alg.tidy_lane_queue (",
"CREATE TABLE alg.tidy_workflow (",
"CREATE TABLE alg.tidy_step (",
"CREATE TABLE alg.tidy_space_lock (",
"CREATE TABLE alg.tidy_callback_event (",
"CK_alg_tidy_mode_effective_state",
"CK_alg_tidy_workflow_origin",
"CK_alg_tidy_workflow_status",
"CK_alg_tidy_space_lock_resource_type",
"UX_alg_tidy_space_lock_active",
"UX_alg_tidy_callback_event_key",
"UX_alg_tidy_workflow_active_auto",
"FK_alg_tidy_callback_event_step_workflow");
}
@Test
void installDefinesVersionedTidyLaneClaimAndSequenceSynchronization() throws IOException {
String install = resource("db/sqlserver/install.sql");
assertThat(install).contains(
"CREATE PROCEDURE alg.usp_claim_tidy_lane",
"@eligible_lane_codes varchar(max) = NULL",
"STRING_SPLIT(@eligible_lane_codes, '','')",
"CREATE PROCEDURE alg.usp_release_tidy_lane",
"CREATE PROCEDURE alg.usp_sync_replay_sequences",
"sp_getapplock",
"CK202607210002");
}
@Test
void installCompletesAlgorithmOwnedMoveJobsWithoutChangingTheLegacyProcedure() throws IOException {
String install = resource("db/sqlserver/install.sql");
assertThat(install).contains(
"CREATE PROCEDURE alg.usp_complete_move_job",
"in_or_out = ''28''",
"UPDATE dbo.bas_stock",
"INSERT INTO dbo.mydo_job_finish",
"DELETE FROM dbo.mydo_job");
assertThat(install).doesNotContain("ALTER PROCEDURE dbo.proc_yk_complete");
}
@Test
void dirtyLaneRefreshPublishesRebuiltLanesToPersistentTidyQueue() throws IOException {
String install = resource("db/sqlserver/install.sql");
String procedure = install.substring(
install.indexOf("CREATE PROCEDURE alg.usp_refresh_dirty_lanes"),
install.indexOf("IF OBJECT_ID(N'alg.usp_try_reserve_inbound'"));
int rebuildLaneState = procedure.lastIndexOf("EXEC alg.usp_rebuild_lane_state");
int publishTidyLane = procedure.indexOf("MERGE alg.tidy_lane_queue WITH (HOLDLOCK)");
assertThat(rebuildLaneState).isGreaterThanOrEqualTo(0);
assertThat(publishTidyLane).isGreaterThan(rebuildLaneState);
assertThat(procedure).contains(
"dirty_version = target.dirty_version + 1",
"queue_status = ''PENDING''");
}
@Test
void dirtyLaneRefreshReleasesQueueLocksBeforeReadingWarehouseTablesAndRequeuesFailures()
throws IOException {
String install = resource("db/sqlserver/install.sql");
String procedure = install.substring(
install.indexOf("CREATE PROCEDURE alg.usp_refresh_dirty_lanes"),
install.indexOf("IF OBJECT_ID(N'alg.usp_try_reserve_inbound'"));
int claimDelete = procedure.indexOf("DELETE queue");
int claimCommit = procedure.indexOf("COMMIT TRANSACTION;", claimDelete);
int warehouseRead = procedure.indexOf("FROM dbo.bas_cargo_space", claimDelete);
assertThat(claimDelete).isGreaterThanOrEqualTo(0);
assertThat(claimCommit).isBetween(claimDelete + 1, warehouseRead - 1);
assertThat(procedure).contains(
"BEGIN TRY",
"BEGIN CATCH",
"MERGE alg.dirty_lane_queue WITH (HOLDLOCK) AS target",
"THROW;");
}
@Test
void topologyRebuildExcludesUnconfiguredCargoSpacesAndDiagnosticsReportsThem()
throws IOException {
String install = resource("db/sqlserver/install.sql");
String diagnostics = resource("db/sqlserver/diagnostics.sql");
String procedure = install.substring(
install.indexOf("CREATE PROCEDURE alg.usp_rebuild_space_topology"),
install.indexOf("IF OBJECT_ID(N'alg.usp_sync_inventory_snapshot'"));
assertThat(procedure).contains(
"NULLIF(LTRIM(RTRIM(group_coding_no)), '''') IS NOT NULL",
"TRY_CONVERT(int, group_coding) IS NOT NULL");
assertThat(diagnostics).contains(
"excluded_from_algorithm_topology_count",
"group_coding_no IS NULL OR LTRIM(RTRIM(group_coding_no)) = ''",
"TRY_CONVERT(int, group_coding) IS NULL");
}
@Test
void diagnosticsAndUninstallCoverNewRuntimeObjects() throws IOException {
String diagnostics = resource("db/sqlserver/diagnostics.sql");
String uninstall = resource("db/sqlserver/uninstall.sql");
assertThat(diagnostics).contains(
"FROM alg.dirty_lane_queue",
"FROM alg.outbound_workflow",
"FROM alg.tidy_mode",
"FROM alg.tidy_lane_queue",
"FROM alg.tidy_workflow",
"FROM alg.tidy_space_lock",
"PAUSED_RECOVERY");
assertThat(uninstall).contains(
"DROP TRIGGER dbo.trg_alg_dirty_bas_stock",
"DROP PROCEDURE alg.usp_refresh_dirty_lanes",
"DROP PROCEDURE alg.usp_claim_tidy_lane",
"DROP PROCEDURE alg.usp_release_tidy_lane",
"DROP PROCEDURE alg.usp_sync_replay_sequences",
"DROP TABLE alg.tidy_callback_event",
"DROP TABLE alg.tidy_space_lock",
"DROP TABLE alg.tidy_step",
"DROP TABLE alg.tidy_workflow",
"DROP TABLE alg.tidy_lane_queue",
"DROP TABLE alg.tidy_mode",
"DROP TABLE alg.outbound_workflow_space",
"DROP TABLE alg.outbound_workflow_step",
"DROP TABLE alg.outbound_workflow",
"DROP TABLE alg.dirty_lane_queue");
}
@Test
void repeatedInstallDoesNotDiscardPendingDirtyLanes() throws IOException {
String install = resource("db/sqlserver/install.sql");
String initialization = install.substring(install.lastIndexOf("IF NOT EXISTS (SELECT 1 FROM alg.space_topology)"));
assertThat(initialization).contains(
"AND NOT EXISTS (SELECT 1 FROM alg.inventory_item_snapshot)",
"AND NOT EXISTS (SELECT 1 FROM alg.lane_state)");
assertThat(initialization.indexOf("DELETE FROM alg.dirty_lane_queue"))
.isLessThan(initialization.indexOf("END;"));
assertThat(initialization.substring(initialization.indexOf("END;") + 4))
.doesNotContain("DELETE FROM alg.dirty_lane_queue");
}
private String resource(String name) throws IOException {
try (var stream = getClass().getClassLoader().getResourceAsStream(name)) {
if (stream == null) {
throw new IOException("Missing test resource " + name);
}
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
}
}

View File

@@ -0,0 +1,49 @@
package com.ruoyi.algorithm.shadow;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class InboundShadowCompletion {
private static final InboundShadowCompletion REJECTED =
new InboundShadowCompletion(false, 0L);
private final boolean accepted;
private final long deadlineNanos;
private final CompletableFuture<LegacyResult> legacyResult = new CompletableFuture<>();
static InboundShadowCompletion accepted(int timeoutSeconds) {
return new InboundShadowCompletion(
true, System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds));
}
public static InboundShadowCompletion rejected() {
return REJECTED;
}
private InboundShadowCompletion(boolean accepted, long deadlineNanos) {
this.accepted = accepted;
this.deadlineNanos = deadlineNanos;
}
public boolean accepted() {
return accepted;
}
public void complete(String resultCode, String spaceId) {
if (accepted) {
legacyResult.complete(new LegacyResult(resultCode, spaceId));
}
}
LegacyResult awaitLegacyResult() throws Exception {
long remainingNanos = deadlineNanos - System.nanoTime();
if (remainingNanos <= 0) {
throw new TimeoutException("Shadow inbound observation deadline expired");
}
return legacyResult.get(remainingNanos, TimeUnit.NANOSECONDS);
}
record LegacyResult(String resultCode, String spaceId) {
}
}

View File

@@ -0,0 +1,46 @@
package com.ruoyi.algorithm.shadow;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class OutboundShadowCompletion {
private static final OutboundShadowCompletion REJECTED =
new OutboundShadowCompletion(false, 0L);
private final boolean accepted;
private final long deadlineNanos;
private final CompletableFuture<String> legacyResult = new CompletableFuture<>();
static OutboundShadowCompletion accepted(int timeoutSeconds) {
return new OutboundShadowCompletion(
true, System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds));
}
public static OutboundShadowCompletion rejected() {
return REJECTED;
}
private OutboundShadowCompletion(boolean accepted, long deadlineNanos) {
this.accepted = accepted;
this.deadlineNanos = deadlineNanos;
}
public boolean accepted() {
return accepted;
}
public void complete(String resultCode) {
if (accepted) {
legacyResult.complete(resultCode);
}
}
String awaitLegacyResult() throws Exception {
long remainingNanos = deadlineNanos - System.nanoTime();
if (remainingNanos <= 0) {
throw new TimeoutException("Shadow outbound observation deadline expired");
}
return legacyResult.get(remainingNanos, TimeUnit.NANOSECONDS);
}
}

View File

@@ -1,10 +1,15 @@
package com.ruoyi.algorithm.shadow;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.Objects;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
@Component
public class ShadowObservationGateway {
@@ -12,72 +17,95 @@ public class ShadowObservationGateway {
private final ShadowObservationProperties properties;
private final ShadowPlanningEngine engine;
private final ThreadPoolExecutor executor;
public ShadowObservationGateway(ShadowObservationProperties properties, ShadowPlanningEngine engine) {
this.properties = Objects.requireNonNull(properties, "properties");
this.engine = engine;
this.executor = new ThreadPoolExecutor(
1, 1, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>(), runnable -> {
Thread thread = new Thread(runnable, "ft546-shadow-observer");
thread.setDaemon(true);
return thread;
}, new ThreadPoolExecutor.AbortPolicy());
}
public InboundShadowObservation planInbound(String taskNo, String trayNo) {
if (!properties.isEnabled() || engine == null) {
return InboundShadowObservation.empty();
public InboundShadowCompletion observeInbound(String taskNo, String trayNo) {
if (!available()) {
return InboundShadowCompletion.rejected();
}
InboundShadowCompletion completion =
InboundShadowCompletion.accepted(properties.getTimeoutSeconds());
try {
return engine.planInbound(taskNo, trayNo);
} catch (RuntimeException exception) {
LOG.warn("Shadow inbound planning failed open for task {}: {}", taskNo, exception.getMessage());
return InboundShadowObservation.empty();
executor.execute(() -> observeInbound(taskNo, trayNo, completion));
return completion;
} catch (RejectedExecutionException exception) {
LOG.debug("Shadow inbound observation skipped because worker is busy for task {}", taskNo);
return InboundShadowCompletion.rejected();
}
}
public OutboundShadowObservation planProjectOutbound(String stockoutDetailId) {
if (!properties.isEnabled() || engine == null) {
return OutboundShadowObservation.empty();
public OutboundShadowCompletion observeProjectOutbound(String stockoutDetailId) {
return observeOutbound(stockoutDetailId,
() -> engine.planProjectOutbound(stockoutDetailId));
}
public OutboundShadowCompletion observeExactStockOutbound(String stockoutDetailId, String stockId) {
return observeOutbound(stockoutDetailId,
() -> engine.planExactStockOutbound(stockoutDetailId, stockId));
}
private void observeInbound(String taskNo, String trayNo, InboundShadowCompletion completion) {
try {
return engine.planProjectOutbound(stockoutDetailId);
} catch (RuntimeException exception) {
LOG.warn("Shadow outbound planning failed open for detail {}: {}",
InboundShadowObservation observation = engine.planInbound(taskNo, trayNo);
InboundShadowCompletion.LegacyResult legacy = completion.awaitLegacyResult();
engine.compareInbound(observation, legacy.resultCode(), legacy.spaceId());
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
LOG.warn("Shadow inbound observation interrupted for task {}", taskNo);
} catch (Exception exception) {
LOG.warn("Shadow inbound observation failed open for task {}: {}", taskNo, exception.getMessage());
}
}
private OutboundShadowCompletion observeOutbound(String stockoutDetailId,
OutboundPlanner planner) {
if (!available()) {
return OutboundShadowCompletion.rejected();
}
OutboundShadowCompletion completion =
OutboundShadowCompletion.accepted(properties.getTimeoutSeconds());
try {
executor.execute(() -> {
try {
OutboundShadowObservation observation = planner.plan();
engine.compareOutbound(observation, completion.awaitLegacyResult());
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
LOG.warn("Shadow outbound observation interrupted for detail {}", stockoutDetailId);
} catch (Exception exception) {
LOG.warn("Shadow outbound observation failed open for detail {}: {}",
stockoutDetailId, exception.getMessage());
return OutboundShadowObservation.empty();
}
});
return completion;
} catch (RejectedExecutionException exception) {
LOG.debug("Shadow outbound observation skipped because worker is busy for detail {}", stockoutDetailId);
return OutboundShadowCompletion.rejected();
}
}
public OutboundShadowObservation planExactStockOutbound(String stockoutDetailId, String stockId) {
if (!properties.isEnabled() || engine == null) {
return OutboundShadowObservation.empty();
}
try {
return engine.planExactStockOutbound(stockoutDetailId, stockId);
} catch (RuntimeException exception) {
LOG.warn("Shadow exact-stock planning failed open for detail {} stock {}: {}",
stockoutDetailId, stockId, exception.getMessage());
return OutboundShadowObservation.empty();
}
private boolean available() {
return properties.isEnabled() && engine != null && !executor.isShutdown();
}
public void compareInbound(InboundShadowObservation observation, String legacyResultCode,
String legacySpaceId) {
if (!properties.isEnabled() || engine == null || observation == null || !observation.observed()) {
return;
}
try {
engine.compareInbound(observation, legacyResultCode, legacySpaceId);
} catch (RuntimeException exception) {
LOG.warn("Shadow inbound comparison failed open for request {}: {}",
observation.requestId(), exception.getMessage());
}
@PreDestroy
public void shutdown() {
executor.shutdownNow();
}
public void compareOutbound(OutboundShadowObservation observation, String legacyResultCode) {
if (!properties.isEnabled() || engine == null || observation == null || !observation.observed()) {
return;
}
try {
engine.compareOutbound(observation, legacyResultCode);
} catch (RuntimeException exception) {
LOG.warn("Shadow outbound comparison failed open for request {}: {}",
observation.requestId(), exception.getMessage());
}
@FunctionalInterface
private interface OutboundPlanner {
OutboundShadowObservation plan();
}
}

View File

@@ -30,6 +30,7 @@ public class ShadowPlanningEngine {
this.properties = Objects.requireNonNull(properties, "properties");
this.transactions = new TransactionTemplate(Objects.requireNonNull(transactionManager, "transactionManager"));
this.transactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
this.transactions.setIsolationLevel(TransactionDefinition.ISOLATION_READ_UNCOMMITTED);
this.transactions.setTimeout(properties.getTimeoutSeconds());
}

View File

@@ -6,6 +6,8 @@ import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.ruoyi.algorithm.shadow.InboundShadowCompletion;
import com.ruoyi.algorithm.shadow.ShadowObservationGateway;
import com.ruoyi.api.domain.*;
import com.ruoyi.api.service.IToErpService;
import com.ruoyi.api.service.IToWmsService;
@@ -90,6 +92,8 @@ public class ToWmsServiceImpl implements IToWmsService {
private IBasStockService stockService;
@Autowired
private MydoJobMapper mydoJobMapper;
@Autowired
private ShadowObservationGateway shadowObservationGateway;
@Override
@@ -412,11 +416,22 @@ public class ToWmsServiceImpl implements IToWmsService {
map.put("v_tray_code", inJob.getTrayCode());
map.put("v_code", deviceCode);
InboundShadowCompletion shadowCompletion = InboundShadowCompletion.rejected();
try {
shadowCompletion = shadowObservationGateway.observeInbound(taskNo, inJob.getTrayCode());
} catch (RuntimeException exception) {
log.warn("Inbound shadow submission failed open for task {}: {}",
taskNo, exception.getMessage());
}
mydoJobMapper.allotInJobSpace(map);
if (!"10".equals(map.get("v_result").toString())) {
String legacyResultCode = map.get("v_result").toString();
if (!"10".equals(legacyResultCode)) {
shadowCompletion.complete(legacyResultCode, null);
return AjaxResult.error(map.get("v_resultmsg").toString());
}else{
MydoJob inJob2 = mydoJobMapper.selectInJobByTaskNo(taskNo);
shadowCompletion.complete(legacyResultCode,
inJob2 == null ? null : inJob2.getCargoSpaceId());
return AjaxResult.success(inJob2);
}
}
@@ -425,5 +440,4 @@ public class ToWmsServiceImpl implements IToWmsService {
}
}
}

View File

@@ -1,6 +1,8 @@
package com.ruoyi.out.outStockout.service.impl;
import cn.hutool.core.util.StrUtil;
import com.ruoyi.algorithm.shadow.OutboundShadowCompletion;
import com.ruoyi.algorithm.shadow.ShadowObservationGateway;
import com.ruoyi.api.service.IToWcsService;
import com.ruoyi.bas.basJobId.service.IBasJobIdService;
import com.ruoyi.common.annotation.DataScope;
@@ -14,6 +16,7 @@ import com.ruoyi.out.outStockout.service.IOutStockoutService;
import com.ruoyi.out.outStockoutDetail.domain.OutStockoutDetail;
import com.ruoyi.out.outStockoutDetail.mapper.OutStockoutDetailMapper;
import com.ruoyi.out.outStockoutDetail.service.IOutStockoutDetailService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
@@ -32,6 +35,7 @@ import java.util.List;
* @date 2025-05-20
*/
@Service
@Slf4j
public class OutStockoutServiceImpl implements IOutStockoutService
{
@Autowired
@@ -45,6 +49,8 @@ public class OutStockoutServiceImpl implements IOutStockoutService
private IOutStockoutDetailService outStockoutDetailService;
@Autowired
private IToWcsService toWcsService;
@Autowired
private ShadowObservationGateway shadowObservationGateway;
/**
* 查询出库主单
@@ -182,7 +188,10 @@ public class OutStockoutServiceImpl implements IOutStockoutService
map.put("v_stockout_detail_id", detail.getStockoutDetailId());
map.put("v_result", null);
map.put("v_resultmsg", null);
OutboundShadowCompletion shadowCompletion =
observeProjectOutboundShadow(detail.getStockoutDetailId());
outStockoutMapper.autoAllot(map);
shadowCompletion.complete(map.get("v_result"));
if (!"10".equals(map.get("v_result"))){
stringBuilder.append(",").append(detail.getStockoutDetailId()+map.get("v_resultmsg"));
}
@@ -209,7 +218,10 @@ public class OutStockoutServiceImpl implements IOutStockoutService
for (String stock : stockList) {
map.put("v_stockout_detail_id", stockoutDetailId);
map.put("v_stock_id", stock);
OutboundShadowCompletion shadowCompletion =
observeExactStockOutboundShadow(stockoutDetailId, stock);
outStockoutMapper.manualAllot(map);
shadowCompletion.complete(map.get("v_result"));
//toWcsService.pushOutMydoJob();
if (!"10".equals(map.get("v_result"))){
stringBuilder.append(",").append(stockoutDetailId+map.get("v_resultString"));
@@ -221,6 +233,26 @@ public class OutStockoutServiceImpl implements IOutStockoutService
return AjaxResult.success();
}
private OutboundShadowCompletion observeProjectOutboundShadow(String stockoutDetailId) {
try {
return shadowObservationGateway.observeProjectOutbound(stockoutDetailId);
} catch (RuntimeException exception) {
log.warn("Project outbound shadow submission failed open for detail {}: {}",
stockoutDetailId, exception.getMessage());
return OutboundShadowCompletion.rejected();
}
}
private OutboundShadowCompletion observeExactStockOutboundShadow(String stockoutDetailId, String stockId) {
try {
return shadowObservationGateway.observeExactStockOutbound(stockoutDetailId, stockId);
} catch (RuntimeException exception) {
log.warn("Exact-stock shadow submission failed open for detail {} stock {}: {}",
stockoutDetailId, stockId, exception.getMessage());
return OutboundShadowCompletion.rejected();
}
}
@Override
public void updateOutStockouts(OutStockout mainOut) {
outStockoutMapper.updateOutStockouts(mainOut);

View File

@@ -178,3 +178,15 @@ xss:
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# 影子算法默认关闭;现场测试仅通过 IDEA 环境变量显式开启
fortucky:
shadow:
enabled: ${FORTUCKY_SHADOW_ENABLED:false}
warehouse-id: ${FORTUCKY_SHADOW_WAREHOUSE_ID:}
timeout-seconds: ${FORTUCKY_SHADOW_TIMEOUT_SECONDS:3}
algorithm:
mode: ${FORTUCKY_ALGORITHM_MODE:SHADOW}
warehouse-id: ${FORTUCKY_ALGORITHM_WAREHOUSE_ID:01}
database-writes-enabled: ${FORTUCKY_ALGORITHM_DATABASE_WRITES_ENABLED:true}
legacy-fallback-on-technical-error: ${FORTUCKY_ALGORITHM_LEGACY_FALLBACK_ON_TECHNICAL_ERROR:false}

View File

@@ -0,0 +1,60 @@
package com.ruoyi.algorithm.shadow;
import com.ruoyi.algorithm.integration.AlgorithmProperties;
import com.ruoyi.algorithm.integration.SchedulingMode;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.env.MutablePropertySources;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.mock.env.MockEnvironment;
import java.io.IOException;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class ShadowConfigurationTest {
@Test
void applicationDefaultsKeepShadowDisabledAndReadyForExplicitShadowEnablement() throws IOException {
MockEnvironment environment = new MockEnvironment();
Binder binder = applicationBinder(environment);
ShadowObservationProperties shadow = binder
.bind("fortucky.shadow", Bindable.of(ShadowObservationProperties.class))
.orElseThrow(() -> new IllegalStateException("fortucky.shadow configuration is missing"));
AlgorithmProperties algorithm = binder
.bind("fortucky.algorithm", Bindable.of(AlgorithmProperties.class))
.orElseThrow(() -> new IllegalStateException("fortucky.algorithm configuration is missing"));
assertThat(shadow.isEnabled()).isFalse();
assertThat(shadow.getWarehouseId()).isEmpty();
assertThat(shadow.getTimeoutSeconds()).isEqualTo(3);
assertThat(algorithm.getMode()).isEqualTo(SchedulingMode.SHADOW);
assertThat(algorithm.isDatabaseWritesEnabled()).isTrue();
assertThat(algorithm.getWarehouseId()).isEqualTo("01");
}
@Test
void standardLegacyFallbackEnvironmentVariableOverridesTheSafeDefault() throws IOException {
MockEnvironment environment = new MockEnvironment()
.withProperty("FORTUCKY_ALGORITHM_LEGACY_FALLBACK_ON_TECHNICAL_ERROR", "true");
AlgorithmProperties algorithm = applicationBinder(environment)
.bind("fortucky.algorithm", Bindable.of(AlgorithmProperties.class))
.orElseThrow(() -> new IllegalStateException("fortucky.algorithm configuration is missing"));
assertThat(algorithm.isLegacyFallbackOnTechnicalError()).isTrue();
}
private Binder applicationBinder(MockEnvironment environment) throws IOException {
MutablePropertySources propertySources = environment.getPropertySources();
List<PropertySource<?>> yaml = new YamlPropertySourceLoader()
.load("application", new ClassPathResource("application.yml"));
yaml.forEach(propertySources::addLast);
return Binder.get(environment);
}
}

View File

@@ -1,49 +1,116 @@
package com.ruoyi.algorithm.shadow;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ShadowObservationGatewayTest {
private ShadowObservationGateway gateway;
@AfterEach
void shutDownGateway() {
if (gateway != null) {
gateway.shutdown();
}
}
@Test
void disabledShadowNeverInvokesThePlanningEngine() {
ShadowObservationProperties properties = new ShadowObservationProperties();
ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class);
ShadowObservationGateway gateway = new ShadowObservationGateway(properties, engine);
gateway = new ShadowObservationGateway(properties, engine);
InboundShadowObservation observation = gateway.planInbound("TASK-1", "TRAY-1");
InboundShadowCompletion completion = gateway.observeInbound("TASK-1", "TRAY-1");
completion.complete("10", "SPACE-1");
assertThat(observation.observed()).isFalse();
verify(engine, never()).planInbound("TASK-1", "TRAY-1");
}
@Test
void planningFailureIsConvertedToAnEmptyObservation() {
ShadowObservationProperties properties = new ShadowObservationProperties();
properties.setEnabled(true);
void planningFailureIsContainedOnTheBackgroundWorker() {
ShadowObservationProperties properties = enabledProperties();
ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class);
when(engine.planInbound("TASK-2", "TRAY-2"))
.thenThrow(new IllegalStateException("algorithm objects are unavailable"));
ShadowObservationGateway gateway = new ShadowObservationGateway(properties, engine);
gateway = new ShadowObservationGateway(properties, engine);
InboundShadowObservation observation = gateway.planInbound("TASK-2", "TRAY-2");
InboundShadowCompletion completion = gateway.observeInbound("TASK-2", "TRAY-2");
completion.complete("10", "SPACE-2");
assertThat(observation.observed()).isFalse();
verify(engine, timeout(1000)).planInbound("TASK-2", "TRAY-2");
verify(engine, never()).compareInbound(
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any());
}
@Test
void missingEngineFailsOpenForOutboundObservation() {
gateway = new ShadowObservationGateway(enabledProperties(), null);
OutboundShadowCompletion completion =
gateway.observeExactStockOutbound("DETAIL-1", "STOCK-1");
completion.complete("10");
assertThat(completion.accepted()).isFalse();
}
@Test
void planningSubmissionNeverWaitsAndConcurrentObservationIsDropped() throws Exception {
ShadowPlanningEngine engine = mock(ShadowPlanningEngine.class);
CountDownLatch planningStarted = new CountDownLatch(1);
CountDownLatch releasePlanning = new CountDownLatch(1);
InboundShadowObservation observation = new InboundShadowObservation(
true, "W01", "TASK-3", "READY", "SPACE-SHADOW", "proposal");
when(engine.planInbound("TASK-3", "TRAY-3")).thenAnswer(invocation -> {
planningStarted.countDown();
if (!releasePlanning.await(5, TimeUnit.SECONDS)) {
throw new IllegalStateException("test planning was not released");
}
return observation;
});
gateway = new ShadowObservationGateway(enabledProperties(), engine);
ExecutorService legacyCaller = Executors.newSingleThreadExecutor();
try {
Future<InboundShadowCompletion> submitted =
legacyCaller.submit(() -> gateway.observeInbound("TASK-3", "TRAY-3"));
InboundShadowCompletion first = submitted.get(1, TimeUnit.SECONDS);
assertThat(first.accepted()).isTrue();
assertThat(planningStarted.await(1, TimeUnit.SECONDS)).isTrue();
OutboundShadowCompletion concurrent = gateway.observeProjectOutbound("DETAIL-3");
assertThat(concurrent.accepted()).isFalse();
concurrent.complete("10");
verify(engine, never()).planProjectOutbound("DETAIL-3");
first.complete("10", "SPACE-LEGACY");
releasePlanning.countDown();
verify(engine, timeout(2000)).compareInbound(observation, "10", "SPACE-LEGACY");
} finally {
releasePlanning.countDown();
legacyCaller.shutdownNow();
}
}
private ShadowObservationProperties enabledProperties() {
ShadowObservationProperties properties = new ShadowObservationProperties();
properties.setEnabled(true);
ShadowObservationGateway gateway = new ShadowObservationGateway(properties, null);
OutboundShadowObservation observation = gateway.planExactStockOutbound("DETAIL-1", "STOCK-1");
assertThat(observation.observed()).isFalse();
properties.setTimeoutSeconds(3);
return properties;
}
}

View File

@@ -49,6 +49,8 @@ class ShadowPlanningEngineTest {
assertThat(transactions.definition.getPropagationBehavior())
.isEqualTo(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
assertThat(transactions.definition.getIsolationLevel())
.isEqualTo(TransactionDefinition.ISOLATION_READ_UNCOMMITTED);
assertThat(transactions.definition.getTimeout()).isEqualTo(7);
assertThat(observation.observed()).isTrue();
assertThat(observation.proposedSpaceId()).isEqualTo("SPACE-9");

View File

@@ -0,0 +1,95 @@
package com.ruoyi.api.service.impl;
import com.ruoyi.algorithm.shadow.InboundShadowCompletion;
import com.ruoyi.algorithm.shadow.ShadowObservationGateway;
import com.ruoyi.api.domain.RequestEnter;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.job.mydojob.domain.MydoJob;
import com.ruoyi.job.mydojob.mapper.MydoJobMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.HashMap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ToWmsServiceImplShadowTest {
@Mock
private MydoJobMapper mydoJobMapper;
@Mock
private ShadowObservationGateway shadowObservationGateway;
@Mock
private InboundShadowCompletion shadowCompletion;
@InjectMocks
private ToWmsServiceImpl service;
private RequestEnter request;
private MydoJob beforeLegacy;
private MydoJob legacyResult;
@BeforeEach
void setUp() {
request = new RequestEnter();
request.setTaskNo("TASK-1");
request.setDeviceCode("ENTRY-1");
beforeLegacy = inboundJob("TRAY-1", null);
legacyResult = inboundJob("TRAY-1", "SPACE-LEGACY");
when(mydoJobMapper.selectInJobByTaskNo("TASK-1")).thenReturn(beforeLegacy, legacyResult);
doAnswer(invocation -> {
@SuppressWarnings("unchecked")
HashMap<String, Object> parameters = invocation.getArgument(0);
parameters.put("v_result", "10");
parameters.put("v_resultmsg", "ok");
return null;
}).when(mydoJobMapper).allotInJobSpace(any());
}
@Test
void submitsObservationBeforeLegacyAndReturnsTheReloadedLegacyJob() {
when(shadowObservationGateway.observeInbound("TASK-1", "TRAY-1"))
.thenReturn(shadowCompletion);
AjaxResult result = service.requestEnterByTaskNo(request);
assertThat(result.get(AjaxResult.DATA_TAG)).isSameAs(legacyResult);
InOrder order = inOrder(mydoJobMapper, shadowObservationGateway, shadowCompletion);
order.verify(mydoJobMapper).selectInJobByTaskNo("TASK-1");
order.verify(shadowObservationGateway).observeInbound("TASK-1", "TRAY-1");
order.verify(mydoJobMapper).allotInJobSpace(any());
order.verify(mydoJobMapper).selectInJobByTaskNo("TASK-1");
order.verify(shadowCompletion).complete("10", "SPACE-LEGACY");
verify(mydoJobMapper, times(1)).allotInJobSpace(any());
}
@Test
void shadowExceptionCannotSkipOrReplaceLegacyAllocation() {
when(shadowObservationGateway.observeInbound("TASK-1", "TRAY-1"))
.thenThrow(new IllegalStateException("shadow unavailable"));
AjaxResult result = service.requestEnterByTaskNo(request);
assertThat(result.get(AjaxResult.DATA_TAG)).isSameAs(legacyResult);
verify(shadowObservationGateway).observeInbound("TASK-1", "TRAY-1");
verify(mydoJobMapper, times(1)).allotInJobSpace(any());
}
private MydoJob inboundJob(String trayNo, String cargoSpaceId) {
MydoJob job = new MydoJob();
job.setInOrOut("10");
job.setTrayCode(trayNo);
job.setCargoSpaceId(cargoSpaceId);
return job;
}
}

View File

@@ -0,0 +1,130 @@
package com.ruoyi.out.outStockout.service.impl;
import com.ruoyi.algorithm.shadow.OutboundShadowCompletion;
import com.ruoyi.algorithm.shadow.ShadowObservationGateway;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.out.outStockout.domain.OutStockout;
import com.ruoyi.out.outStockout.mapper.OutStockoutMapper;
import com.ruoyi.out.outStockoutDetail.domain.OutStockoutDetail;
import com.ruoyi.out.outStockoutDetail.service.IOutStockoutDetailService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class OutStockoutServiceImplShadowTest {
@Mock
private OutStockoutMapper outStockoutMapper;
@Mock
private IOutStockoutDetailService outStockoutDetailService;
@Mock
private ShadowObservationGateway shadowObservationGateway;
@Mock
private OutboundShadowCompletion shadowCompletion;
@InjectMocks
private OutStockoutServiceImpl service;
private OutStockoutDetail pendingDetail;
@BeforeEach
void setUp() {
pendingDetail = new OutStockoutDetail();
pendingDetail.setStockoutDetailId("DETAIL-1");
pendingDetail.setQuantity(BigDecimal.TEN);
pendingDetail.setAllotQuantity(BigDecimal.ZERO);
}
@Test
void missingShadowProjectStillRunsAutoAllotAndPreservesLegacyWarning() {
prepareAutoAllot();
when(shadowObservationGateway.observeProjectOutbound("DETAIL-1"))
.thenReturn(shadowCompletion);
doAnswer(invocation -> {
@SuppressWarnings("unchecked")
HashMap<String, String> parameters = invocation.getArgument(0);
parameters.put("v_result", "20");
parameters.put("v_resultmsg", "LEGACY-WARNING");
return null;
}).when(outStockoutMapper).autoAllot(any());
AjaxResult result = service.autoAllot("OUT-1");
assertThat(result.get(AjaxResult.MSG_TAG)).isEqualTo(",DETAIL-1LEGACY-WARNING");
InOrder order = inOrder(shadowObservationGateway, outStockoutMapper, shadowCompletion);
order.verify(shadowObservationGateway).observeProjectOutbound("DETAIL-1");
order.verify(outStockoutMapper).autoAllot(any());
order.verify(shadowCompletion).complete("20");
verify(outStockoutMapper, times(1)).autoAllot(any());
}
@Test
void shadowExceptionStillRunsAutoAllotAndPreservesLegacySuccess() {
prepareAutoAllot();
when(shadowObservationGateway.observeProjectOutbound("DETAIL-1"))
.thenThrow(new IllegalStateException("shadow unavailable"));
doAnswer(invocation -> {
@SuppressWarnings("unchecked")
HashMap<String, String> parameters = invocation.getArgument(0);
parameters.put("v_result", "10");
parameters.put("v_resultmsg", "ok");
return null;
}).when(outStockoutMapper).autoAllot(any());
AjaxResult result = service.autoAllot("OUT-1");
assertThat(result.isSuccess()).isTrue();
verify(shadowObservationGateway).observeProjectOutbound("DETAIL-1");
verify(outStockoutMapper, times(1)).autoAllot(any());
}
@Test
void missingShadowStockStillRunsManualAllotOncePerSelectedStock() {
OutboundShadowCompletion firstCompletion = mock(OutboundShadowCompletion.class);
OutboundShadowCompletion secondCompletion = mock(OutboundShadowCompletion.class);
when(shadowObservationGateway.observeExactStockOutbound("DETAIL-1", "STOCK-1"))
.thenReturn(firstCompletion);
when(shadowObservationGateway.observeExactStockOutbound("DETAIL-1", "STOCK-2"))
.thenReturn(secondCompletion);
doAnswer(invocation -> {
@SuppressWarnings("unchecked")
HashMap<String, String> parameters = invocation.getArgument(0);
parameters.put("v_result", "10");
parameters.put("v_resultString", "ok");
return null;
}).when(outStockoutMapper).manualAllot(any());
AjaxResult result = service.manualAllot(new String[]{"STOCK-1", "STOCK-2"}, "DETAIL-1");
assertThat(result.isSuccess()).isTrue();
verify(outStockoutMapper, times(2)).manualAllot(any());
verify(shadowObservationGateway).observeExactStockOutbound("DETAIL-1", "STOCK-1");
verify(shadowObservationGateway).observeExactStockOutbound("DETAIL-1", "STOCK-2");
verify(firstCompletion).complete("10");
verify(secondCompletion).complete("10");
}
private void prepareAutoAllot() {
OutStockout bill = new OutStockout();
bill.setStatus("2");
when(outStockoutMapper.selectOutStockoutByStockoutId("OUT-1")).thenReturn(bill);
when(outStockoutDetailService.selectOutStockoutDetailList(any()))
.thenReturn(List.of(pendingDetail));
}
}