Compare commits
10 Commits
01333cbf03
...
2868cb85fa
| Author | SHA1 | Date | |
|---|---|---|---|
| 2868cb85fa | |||
| 548359d07f | |||
| bb3b93a79f | |||
| 34c4d54669 | |||
| 6738eff609 | |||
| 6a870601b5 | |||
| 2fbb20c5b1 | |||
| 8b050ac9d6 | |||
| aab6b8e601 | |||
| 023f3f5e02 |
302
doc/algorithm-shadow/README.md
Normal file
302
doc/algorithm-shadow/README.md
Normal 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 触发器、每次规划前全量刷新”的代价,也是现场必须先小流量验证、随时可切回稳定源码的原因。
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}: {}",
|
||||
stockoutDetailId, exception.getMessage());
|
||||
return OutboundShadowObservation.empty();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
public OutboundShadowObservation planExactStockOutbound(String stockoutDetailId, String stockId) {
|
||||
if (!properties.isEnabled() || engine == null) {
|
||||
return OutboundShadowObservation.empty();
|
||||
private OutboundShadowCompletion observeOutbound(String stockoutDetailId,
|
||||
OutboundPlanner planner) {
|
||||
if (!available()) {
|
||||
return OutboundShadowCompletion.rejected();
|
||||
}
|
||||
OutboundShadowCompletion completion =
|
||||
OutboundShadowCompletion.accepted(properties.getTimeoutSeconds());
|
||||
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();
|
||||
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 completion;
|
||||
} catch (RejectedExecutionException exception) {
|
||||
LOG.debug("Shadow outbound observation skipped because worker is busy for detail {}", stockoutDetailId);
|
||||
return OutboundShadowCompletion.rejected();
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
private boolean available() {
|
||||
return properties.isEnabled() && engine != null && !executor.isShutdown();
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
@PreDestroy
|
||||
public void shutdown() {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface OutboundPlanner {
|
||||
OutboundShadowObservation plan();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user