63 Commits

Author SHA1 Message Date
zgw
38744fddd8 docs: record layer 1 pilot artifact upload 2026-09-16 14:17:06 +08:00
zgw
1225cf7287 feat: prepare warehouse 01 floor 1 inbound pilot 2026-09-16 14:12:12 +08:00
zgw
2632a57d73 fix: make ERP exception menu SQL Server 2016 safe 2026-09-15 17:38:35 +08:00
zgw
67251a7562 feat: add ERP push failure recovery 2026-09-15 14:31:16 +08:00
zgw
58e9832b61 fix: hold blocked outbound before WCS dispatch 2026-09-15 11:04:07 +08:00
zgw
a0af05bfa2 fix: separate shadow refresh transaction timeout 2026-09-15 10:58:59 +08:00
zgw
acc6994385 fix: classify equivalent outbound no-stock results 2026-09-15 10:56:11 +08:00
zgw
8dbdd411bf fix: recognize unlocked N lanes in shadow aggregation 2026-09-14 10:31:17 +08:00
zgw
ae7f8c08f4 feat(wms): add controlled three-channel mixed-storage pilot 2026-09-10 16:03:20 +08:00
zgw
63ed8f72b1 fix(shadow): classify source-change refresh as stale 2026-09-10 11:15:28 +08:00
zgw
0bd47e2b2c fix: repair shadow candidate planning and site runtime errors 2026-09-09 09:57:28 +08:00
zgw
1a34d72cbb fix(pda): ignore empty identifiers when checking half-tray jobs 2026-09-08 14:35:56 +08:00
zgw
827083e7d6 fix(pda): return business error when outbound order is missing 2026-09-08 12:01:15 +08:00
zgw
c387ba0b50 fix(shadow): capture and validate inbound task evidence for v3.4 2026-09-08 10:37:14 +08:00
zgw
699310e61d fix(shadow): align outbound predicates with site data for v3.3
Use the business project and captured site predicates, validate single-stock proposals, and skip unsupported scopes without changing Legacy. Add the guarded V3.3 SQL upgrade, CMD operations, regression tests and deployment runbook.
2026-09-07 12:43:43 +08:00
zgw
79e0db29f9 fix(shadow): repair UTC sampling and persist v3 diagnostics 2026-09-07 10:12:41 +08:00
zgw
5ce6393337 fix: suppress unsupported Druid SQL parsing 2026-09-04 14:12:47 +08:00
zgw
58c7fff4a9 fix: bound dashboard monitoring queries 2026-09-04 13:30:45 +08:00
zgw
731c140d24 test: bypass dashboard bill list query 2026-09-04 12:59:07 +08:00
zgw
cab1151fef fix: support deployed outbound detail schema 2026-09-04 12:07:03 +08:00
zgw
b940bf0eb1 fix: support database collation in shadow scripts 2026-09-04 11:47:01 +08:00
zgw
4de09dd693 merge: integrate latest field updates into shadow v3 2026-09-03 18:00:46 +08:00
zgw
064d8de0dc chore: remove local review reports from branch 2026-09-03 17:54:04 +08:00
zgw
c8fa9f767f fix: isolate legacy from shadow admission errors 2026-09-03 17:49:55 +08:00
zgw
81888f5150 fix: complete shadow v3 operations safeguards 2026-09-03 17:37:58 +08:00
zgw
22cf1f9a03 fix: harden shadow v3 preflight and export guards 2026-09-03 17:23:03 +08:00
zgw
47190451ff docs: add shadow v3 field operations package 2026-09-03 17:08:13 +08:00
zgw
690eae9985 feat: update frontend authorization and operations 2026-09-03 16:56:57 +08:00
zgw
169b64850d test: cover inbound legacy warning completion 2026-09-03 16:50:13 +08:00
zgw
af7e00c9a0 fix: close shadow activity on legacy failures 2026-09-03 16:46:54 +08:00
zgw
ffd3cc04f3 fix: observe legacy failures in shadow worker 2026-09-03 16:32:48 +08:00
zgw
87700246d8 feat: run shadow v3 without legacy waiting 2026-09-03 16:24:39 +08:00
zgw
3ec0931047 feat: add license authorization module 2026-09-03 16:23:38 +08:00
zgw
9617038e4c fix: preserve required shadow comparison evidence 2026-09-03 15:52:52 +08:00
zgw
746a6b5647 fix: scope shadow inputs to authoritative warehouse 2026-09-03 15:40:12 +08:00
zgw
d77fac1d56 fix: enforce stable shadow snapshot boundaries 2026-09-03 15:07:37 +08:00
zgw
935d7af43d feat: plan shadow decisions from leased generation 2026-09-03 14:28:38 +08:00
zgw
749bd49c50 fix: initialize shadow snapshot state 2026-09-03 13:52:52 +08:00
zgw
3e9f61487c feat: publish atomic shadow snapshot generations 2026-09-03 13:34:52 +08:00
zgw
21cf0a5151 feat: plan from published read model 2026-09-03 11:54:31 +08:00
zgw
5ed39162cc fix: clarify shadow refresh recovery contract 2026-09-03 11:39:40 +08:00
zgw
c6a8b4f3b2 feat: coordinate stable shadow snapshots 2026-09-03 11:27:26 +08:00
zgw
03ddf3f386 feat: validate shadow v3 snapshot settings 2026-09-03 10:52:01 +08:00
zgw
8816ffe7f4 docs: plan stable snapshot shadow v3 2026-09-03 10:40:58 +08:00
zgw
8f5dea31ed docs: clarify shadow warehouse authority 2026-09-03 10:28:43 +08:00
zgw
e0eb507d7e docs: design stable snapshot shadow v3 2026-09-03 10:18:44 +08:00
zgw
3f7ed3718d fix: improve shadow diagnostic matching 2026-08-28 11:55:43 +08:00
zgw
da5a0a1561 feat: add shadow export MCP tooling 2026-08-28 11:14:26 +08:00
zgw
2868cb85fa docs: clarify partial shadow install recovery 2026-08-12 09:27:10 +08:00
zgw
548359d07f fix: verify shadow SQL package ownership 2026-08-12 09:26:18 +08:00
zgw
bb3b93a79f fix: keep shadow work off legacy threads 2026-08-12 09:19:59 +08:00
zgw
34c4d54669 docs: define zero-wait shadow safety fixes 2026-08-12 09:07:05 +08:00
zgw
6738eff609 fix: cap concurrent shadow planning 2026-08-11 20:04:31 +08:00
zgw
6a870601b5 test: support algorithm contract in linked worktrees 2026-08-11 19:44:18 +08:00
zgw
2fbb20c5b1 docs: add IDEA shadow deployment and rollback guide 2026-08-11 19:38:26 +08:00
zgw
8b050ac9d6 feat: add shadow-only SQL Server package 2026-08-11 19:24:06 +08:00
zgw
aab6b8e601 feat: observe outbound allocation without changing legacy 2026-08-11 18:50:37 +08:00
zgw
023f3f5e02 feat: observe inbound allocation without changing legacy 2026-08-11 18:44:25 +08:00
zgw
01333cbf03 feat: add fail-open shadow planning boundary 2026-08-11 18:39:15 +08:00
zgw
86fc240b84 feat: add algorithm core for shadow observation 2026-08-11 16:59:43 +08:00
zgw
9480882752 docs: plan WMS shadow-only implementation 2026-08-11 16:53:19 +08:00
zgw
21dcac8932 docs: define WMS shadow-only safety design 2026-08-11 16:49:45 +08:00
local
916ac6bda3 本地最新代码:同步所有修改及新增模块 2026-08-11 13:34:15 +08:00
449 changed files with 100300 additions and 2213 deletions

2
.gitignore vendored
View File

@@ -27,3 +27,5 @@ hs_err_pid*
**/target/
/log/
# Local MCP credentials and connection settings.
.cursorrules-mcp.json

479
AGENTS.md Normal file
View File

@@ -0,0 +1,479 @@
# FT546MWS 项目说明
## 项目概述
FT546MWS 是一个基于 **若依(RuoYi)** 框架定制的制造执行系统(MES)和仓库管理系统(WMS),采用前后端分离架构。项目基于若依 v3.9.0 进行二次开发,集成了工业物联网(IIoT)功能和 AI 能力。
### 核心技术栈
#### 后端技术
- **框架**: Spring Boot 3.5.4
- **Java 版本**: Java 17
- **数据库**: SQL Server 2019 (主库), MariaDB (从库, 可选)
- **ORM**: MyBatis 3.0.4 + MyBatis-Plus 3.5.14
- **连接池**: Druid 1.2.23
- **缓存**: Redis
- **权限认证**: JWT + Spring Security
- **API 文档**: SpringDoc OpenAPI (Swagger)
- **定时任务**: Quartz
- **代码生成**: Velocity 模板引擎
- **工业通信**: HslCommunication 3.7.0 (支持 PLC、Modbus 等工业协议)
- **AI 集成**: Spring AI 1.0.1 (OpenAI 兼容接口, 向量数据库)
- **MCP 协议**: Spring AI MCP Client
- **工具库**: Hutool 5.8.27, FastJSON2 2.0.57, POI 4.1.2
#### 前端技术
- **框架**: Vue 2.6.12
- **UI 组件库**: Element UI 2.15.14
- **状态管理**: Vuex 3.6.0
- **路由**: Vue Router 3.4.9
- **图表**: ECharts 5.4.0
- **国际化**: Vue I18n 7.3.2
- **构建工具**: Vue CLI 4.4.6
- **其他**: Axios, Quill (富文本编辑器), VueCropper (图片裁剪)
### 项目架构
这是一个典型的前后端分离的 Maven 多模块项目,包含以下模块:
```
FT546MWS/
├── ruoyi-admin/ # Web 服务入口模块(启动类、控制器)
├── ruoyi-framework/ # 框架核心模块(安全配置、拦截器、工具类)
├── ruoyi-system/ # 系统管理模块(用户、角色、菜单、部门等)
├── ruoyi-common/ # 通用工具模块(工具类、常量、注解)
├── ruoyi-generator/ # 代码生成器模块
├── ruoyi-quartz/ # 定时任务模块
├── ruoyi-ui/ # 前端 Vue 项目
├── sql/ # 数据库脚本
├── bin/ # 批处理脚本(构建、运行、清理)
└── pom.xml # Maven 父 POM 配置
```
### 业务模块
前端业务模块(位于 `ruoyi-ui/src/views/`):
- **system**: 系统管理(用户、角色、菜单、部门、岗位、字典、参数、通知)
- **monitor**: 系统监控(在线用户、操作日志、登录日志、服务监控、缓存监控、定时任务)
- **tool**: 系统工具(表单构建、代码生成、系统接口)
- **bas**: 基础数据管理
- **in**: 入库管理
- **out**: 出库管理
- **stock**: 库存管理
- **check**: 盘点管理
- **job**: 作业管理
- **kpi**: 绩效管理
- **ai**: AI 功能集成
- **search**: 搜索功能
- **requestInterface**: 接口请求管理
- **dashboard**: 仪表盘/数据看板
## 环境配置
### 数据库配置
#### 主数据库(SQL Server)
- **地址**: 172.16.2.170:1433
- **数据库名**: FT546-wms
- **用户名**: sa
- **密码**: Flrd0914.
- **驱动**: com.microsoft.sqlserver.jdbc.SQLServerDriver
#### 从数据库(MariaDB)- 已禁用
- **地址**: 172.16.2.170:3307
- **数据库名**: mydb
- **用户名**: root
- **密码**: Flrd0914.
### Redis 配置
- **地址**: 172.16.2.170:6379
- **数据库索引**: 0
- **密码**: 123456
- **超时**: 10s
### AI 配置
- **提供商**: SiliconFlow (OpenAI 兼容)
- **Base URL**: https://api.siliconflow.cn
- **对话模型**: deepseek-ai/DeepSeek-V3.1
- **嵌入模型**: Qwen/Qwen3-Embedding-8B (1024 维度)
- **向量数据库**: MariaDB (已配置但未完全启用)
### Druid 监控
- **访问地址**: http://localhost:8080/druid/
- **用户名**: ruoyi
- **密码**: 123456
### API 文档
- **Swagger UI**: http://localhost:8080/swagger-ui.html
- **OpenAPI JSON**: http://localhost:8080/v3/api-docs
## 构建和运行
### 后端构建和运行
#### 方式一:使用 Maven 命令
```bash
# 进入项目根目录
cd D:\workSpace\PROJECT_SPACE\FT546\FT546MWS
# 清理并编译
mvn clean compile
# 打包(跳过测试)
mvn clean package -DskipTests
# 运行
java -jar ruoyi-admin/target/ruoyi-admin.jar
# 或使用 JVM 参数运行
java -Dname=ruoyi-admin.jar -Duser.timezone=Asia/Shanghai -Xms512m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m -jar ruoyi-admin/target/ruoyi-admin.jar
```
#### 方式二:使用批处理脚本
```bash
# 进入 bin 目录
cd bin
# 启动服务
ry.bat
# 选择 [1] 启动 ruoyi-admin.jar
# 停止服务
ry.bat
# 选择 [2] 关闭 ruoyi-admin.jar
# 重启服务
ry.bat
# 选择 [3] 重启 ruoyi-admin.jar
# 查看状态
ry.bat
# 选择 [4] 查看状态
```
#### 方式三:使用 IDE
1. 使用 IntelliJ IDEA 或 Eclipse 导入项目(Maven 项目)
2. 找到启动类:`com.ruoyi.RuoYiApplication`(位于 ruoyi-admin 模块)
3. 运行启动类
### 前端构建和运行
#### 开发环境
```bash
# 进入前端目录
cd ruoyi-ui
# 安装依赖(使用 npm 镜像加速)
npm install --registry=https://registry.npmmirror.com
# 或直接安装
npm install
# 启动开发服务器
npm run dev
```
前端服务启动后,浏览器访问:http://localhost:80
默认账号:
- 用户名: admin
- 密码: admin123
#### 生产环境构建
```bash
# 构建测试环境
npm run build:stage
# 构建生产环境
npm run build:prod
```
构建产物将输出到 `ruoyi-ui/dist` 目录。
#### 使用批处理脚本
```bash
# 进入前端 bin 目录
cd ruoyi-ui\bin
# 运行前端开发服务器
run-web.bat
# 打包前端
package.bat
# 清理构建产物
clean.bat
```
## 开发规范
### 后端开发规范
#### 包结构规范
```
com.ruoyi
├── common/ # 通用模块
│ ├── annotation/ # 自定义注解
│ ├── constant/ # 常量定义
│ ├── core/ # 核心配置
│ ├── enums/ # 枚举类
│ ├── exception/ # 异常处理
│ └── utils/ # 工具类
├── framework/ # 框架核心
│ ├── aspectj/ # 切面
│ ├── config/ # 配置类
│ ├── interceptor/ # 拦截器
│ ├── security/ # 安全相关
│ └── web/ # Web 配置
├── system/ # 系统管理
│ ├── controller/ # 控制器
│ ├── domain/ # 实体类
│ ├── mapper/ # 数据访问层
│ └── service/ # 业务逻辑层
│ └── impl/ # 业务实现
└── web/ # Web 控制器(其他业务模块)
```
#### 代码生成规范
项目使用代码生成器(`ruoyi-generator` 模块)快速生成 CRUD 代码:
1. 在数据库中创建表
2. 登录系统 → 系统工具 → 代码生成
3. 导入表并配置生成信息
4. 生成代码并下载
5. 将生成的代码复制到对应模块
生成的代码包括:
- Java 实体类(domain)
- Mapper 接口和 XML(mapper)
- Service 接口和实现(service/impl)
- Controller 控制器(controller)
- Vue 页面(前端 views)
- API 接口定义(前端 api)
#### 控制器规范
- 使用 `@RestController` 注解
- 统一返回格式:`AjaxResult`(包装 success、error、data)
- 权限控制:使用 `@PreAuthorize("@ss.hasPermi('system:user:list')")`
- 操作日志:使用 `@Log(title = "用户管理", businessType = BusinessType.INSERT)`
- 分页查询:使用 `startPage()` 方法(继承 BaseController)
#### Service 规范
- 接口定义在 `service` 包
- 实现类在 `service/impl` 包
- 使用 `@Service` 注解
- 业务逻辑方法命名:`selectXxxList`(查询)、`insertXxx`(新增)、`updateXxx`(修改)、`deleteXxxByIds`(删除)
- 缓存注解:`@Cacheable`(查询)、`@CacheEvict`(更新/删除)
#### Mapper 规范
- 使用 MyBatis-Plus 注解或 XML 配置
- XML 文件位于 `resources/mapper/` 目录
- 继承 `BaseMapper<T>` 接口
- 复杂查询使用 XML 文件编写 SQL
### 前端开发规范
#### 目录结构规范
```
src/
├── api/ # API 接口定义
│ ├── login.js # 登录相关
│ ├── system/ # 系统管理
│ ├── monitor/ # 监控模块
│ └── ... # 其他业务模块
├── assets/ # 静态资源
│ ├── images/ # 图片
│ ├── icons/ # 图标
│ ├── logo/ # Logo
│ └── styles/ # 样式文件
├── components/ # 公共组件
│ ├── Pagination/ # 分页组件
│ ├── RightToolbar/ # 工具栏
│ ├── Editor/ # 富文本编辑器
│ └── ... # 其他组件
├── directive/ # 自定义指令
├── layout/ # 布局组件
├── plugins/ # 插件
├── router/ # 路由配置
├── store/ # Vuex 状态管理
├── utils/ # 工具函数
├── views/ # 页面组件
│ ├── system/ # 系统管理页面
│ ├── monitor/ # 监控页面
│ └── ... # 其他业务页面
├── App.vue # 根组件
├── main.js # 入口文件
├── permission.js # 权限控制
└── settings.js # 全局配置
```
#### 组件命名规范
- 组件名使用 PascalCase(大驼峰)
- 文件名与组件名保持一致
- 例如:`UserList.vue`
#### API 调用规范
- API 接口定义在 `src/api/` 目录
- 使用 Axios 发送请求(已封装)
- 请求路径配置在 `.env.development` 等环境变量文件中
- 统一使用 `request.js` 封装的请求方法
#### 路由规范
- 路由配置在 `src/router/index.js`
- 使用 Vue Router 的路由懒加载
- 需要权限的路由在路由配置中添加 `meta` 字段
- 例如:`meta: { title: '用户管理', icon: 'user', noCache: true }`
#### 样式规范
- 使用 SCSS 预处理器
- 全局样式放在 `src/assets/styles/` 目录
- 组件样式使用 `<style scoped>` 限定作用域
- 遵循 BEM 命名规范
## 测试
### 后端测试
```bash
# 运行所有测试
mvn test
# 运行指定测试类
mvn test -Dtest=UserServiceTest
# 运行指定测试方法
mvn test -Dtest=UserServiceTest#testGetUser
```
### 前端测试
目前项目未配置前端单元测试,建议添加 Jest 或 Mocha。
## 部署
### 后端部署
```bash
# 打包
mvn clean package -DskipTests
# 生成的 JAR 文件位于:ruoyi-admin/target/ruoyi-admin.jar
# 上传到服务器后,使用脚本或命令启动
nohup java -jar ruoyi-admin.jar > app.log 2>&1 &
# 或使用提供的脚本
./ry.bat # Windows
./ry.sh # Linux
```
### 前端部署
```bash
# 构建生产版本
npm run build:prod
# 生成的静态文件位于:dist/ 目录
# 将 dist 目录内容部署到 Nginx 或其他 Web 服务器
# Nginx 配置示例:
server {
listen 80;
server_name your-domain.com;
root /path/to/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /prod-api/ {
proxy_pass http://backend-server:8080/;
}
}
```
## 常见问题
### 1. 端口冲突
- 后端默认端口:8080
- 前端默认端口:80
- 如需修改,请编辑 `application.yml`(后端)和 `vue.config.js`(前端)
### 2. 数据库连接失败
- 检查数据库服务是否启动
- 检查 `application-druid.yml` 中的数据库配置
- 确保网络可访问数据库服务器
### 3. Redis 连接失败
- 检查 Redis 服务是否启动
- 检查 `application.yml` 中的 Redis 配置
- 确保网络可访问 Redis 服务器
### 4. 前端跨域问题
- 开发环境已配置代理(`vue.config.js` 中的 `proxy`)
- 生产环境需要在 Nginx 或后端配置 CORS
### 5. 代码生成器无法使用
- 确保数据库表已创建
- 检查表是否有主键
- 检查字段注释是否完整
### 6. 文件上传失败
- 检查 `application.yml` 中的文件路径配置
- 确保目录存在且有写入权限
- 检查文件大小限制(默认 20MB)
## 项目特色
### 1. 工业物联网集成
- 使用 HslCommunication 库支持多种工业协议
- 可与 PLC、Modbus 设备通信
- 适用于制造业数据采集和设备控制
### 2. AI 能力
- 集成 Spring AI 框架
- 支持 OpenAI 兼容的大模型接口
- 支持向量数据库和文档检索
- 可用于智能问答、数据分析等场景
### 3. MCP 协议支持
- 支持 MCP(Model Context Protocol)客户端
- 可连接 MCP 服务进行上下文管理
### 4. 多数据库支持
- 主数据库:SQL Server
- 从数据库:MariaDB(可选)
- 支持主从读写分离
### 5. 代码生成器
- 一键生成前后端 CRUD 代码
- 支持自定义模板
- 大幅提升开发效率
### 6. 完善的权限控制
- 基于 RBAC 的权限模型
- 支持数据权限控制
- 支持按钮级别的权限控制
### 7. 系统监控
- 在线用户监控
- 操作日志记录
- 系统性能监控
- 缓存监控
- 定时任务管理
## 联系方式
- 若依官方文档:http://doc.ruoyi.vip
- 若依官方演示:http://vue.ruoyi.vip
- 项目仓库:http://106.13.86.63:10082/zyr/FT546MWS.git
## 许可证
本项目基于 MIT 许可证开源,详见 [LICENSE](LICENSE) 文件。
---
**注意**: 本项目包含敏感信息(数据库密码、API 密钥等),在生产环境部署前请务必修改这些配置。

View File

@@ -0,0 +1,193 @@
# FT546WMS 影子 v3 现场操作手册
已部署 V3/V3.1/V3.2 的现场升级到 V3.3,请先使用 [V3.3 升级和回滚步骤](SHADOW_V33_DEPLOYMENT.md)。下面保留最初 v2 到 v3 的历史操作说明,不要把“当前现场已有 v2”套用于已升级现场。
## 1. 这次部署做什么
目标分支:`test/ft546wms-shadow-v3-20260903`。
影子 v3 只观察算法建议,不改变 Legacy 分配结果。入库、自动出库、手动出库仍由原存储过程执行一次,原返回码、返回消息、库位和异常保持不变。正常业务请求只做固定大小的内存登记,不等待影子线程,也不在调用线程执行影子数据库、文件或同步日志操作。
影子后台会读取业务表,并且只写 `alg.*` 对象。它可能增加数据库 CPU 和 I/O,因此仍需低峰部署、监控和随时回滚。
## 2. 固定安全边界
- 现场只允许一个 FT546WMS JVM,稳定版和影子版不能同时运行。
- 仓库固定为 `01`;原始数据中的 `1` 与 `01` 等价,`W02` 只用于诊断,不能覆盖配置。
- SQL Server 必须使用 Windows 集成认证;脚本不接收用户名和密码。
- 当前现场已有 v2 包,安装必须使用 `upgrade`,不能使用 `clean`。
- v2 到 v3 升级保留 `alg.decision_log`;发现未知 `alg` 对象、清单不完整或 manifest 不匹配时脚本会停止。
- 影子失败、过期、并发、超时、配置错误或输入缺失均 fail-open:Legacy 继续,影子样本作废。
- 不执行正式 ACTIVE 切换,不增加 WCS/PDA/理库/预留/回放功能。
## 3. 部署前准备
现场负责人先记录:
- SQL Server 名称和数据库名;
- 当前 v2 测试开始时间和时区;
- IDEA 当前 Run Configuration;
- 当前稳定实例 PID 和端口;
- 本次交付给出的精确 v3 提交号 `<V3_COMMIT>`;
- 应用日志目录。
部署前先导出现有 v2 证据。不要卸载 v2:
```cmd
cd /d C:\project\FT546WMS-SHANDOW
call scripts\shadow-v3-export.cmd C:\project\FT546WMS-SHANDOW "<SQL_SERVER>" "<DATABASE>" "<V2_START_UTC>" "C:\project\FT546WMS-v2-before-v3" "<LOG_DIR>"
```
如果当前代码还没有 v3 导出脚本,先用既有导出方式保存 `alg.decision_log`、诊断结果、日志、分支和提交号,再继续。
## 4. 按顺序部署 v3
### 第一步:停止 IDEA 中的 v2 稳定实例
记录停止时间,在 IDEA 点 Stop。用下面命令确认最多只有一个相关 JVM,最好为 0:
```cmd
jps -lv | findstr /I "RuoYiApplication FT546WMS"
```
停止和重新启动之间会有一次短暂停机,这是计划内中断。不要同时启动两个实例来消除停机,否则可能重复执行定时任务、消费或外部回调。
### 第二步:同步精确分支和提交
```cmd
cd /d C:\project\FT546WMS-SHANDOW
git fetch origin
git switch test/ft546wms-shadow-v3-20260903
git pull --ff-only
git rev-parse HEAD
git status --porcelain
```
`git rev-parse HEAD` 必须等于交付的 `<V3_COMMIT>`,`git status --porcelain` 必须没有输出。若不一致,停止部署。
### 第三步:执行只读预检
```cmd
call scripts\shadow-v3-preflight.cmd C:\project\FT546WMS-SHANDOW <V3_COMMIT> "<SQL_SERVER>" "<DATABASE>"
```
只有看到 `PREFLIGHT_OK` 才能继续。预检检查分支、精确提交、干净工作树、main 祖先、JVM 数量、SQL 连接、v2/v3 manifest 和当前快照;它不修改数据库。
### 第四步:升级数据库包
当前现场是 v2,只执行:
```cmd
call scripts\shadow-v3-install.cmd "<SQL_SERVER>" "<DATABASE>" upgrade
```
只有全新测试数据库、确认没有 `alg` schema 时才允许使用 `clean`。现场生产库不要使用 `clean`。
### 第五步:设置 IDEA 环境变量
在影子 Run Configuration 中使用:
```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_SHADOW_SNAPSHOT_MAX_AGE_SECONDS=60
FORTUCKY_SHADOW_QUIET_INTERVAL_MILLIS=1000
```
不要设置 `ACTIVE`,不要添加数据库密码,不要设置 replay、tidy、reservation 或 WCS callback 开关。
### 第六步:只启动一次影子实例
在 IDEA 启动 v3。不要重复点击 Run。启动时后台会认领唯一实例并建立第一代快照;认领或刷新失败只会关闭影子可用性,不应阻止 Legacy 启动。
启动后检查:
```cmd
jps -lv | findstr /I "RuoYiApplication FT546WMS"
```
只能看到一个 FT546WMS JVM。
### 第七步:做只读和受控业务检查
先完成登录、页面查询等只读检查,再由现场人员执行一笔受控入库、一笔自动出库和一笔手动出库。确认:
- Legacy 返回码、消息、库位和设备动作与稳定流程一致;
- 没有相同存储过程重复执行;
- `alg.shadow_snapshot_state` 为仓库 `01` 的 `PUBLISHED`;
- `alg.shadow_instance_heartbeat` 只有当前实例;
- `alg.decision_log` 出现新的计划/比较记录;
- 应用日志中的影子 warning 不包含业务载荷或异常消息。
## 5. 立即停止条件
出现任一情况立即执行第 7 节回滚:
- Legacy 结果、响应码、消息、库位或设备动作改变;
- 同一个 Legacy 存储过程疑似执行两次;
- 出现第二个 FT546WMS JVM;
- `alg` 之外出现本次部署造成的数据写入或新对象;
- WMS、WCS、PDA 或现场作业异常;
- 正常接口延迟或数据库 CPU、I/O、锁等待超过现场阈值;
- 快照长时间不是 `PUBLISHED`,或 `ERROR_*` 指标持续增长。
单条 `shadow_failure` 表示该影子样本失败开放,不等于 Legacy 失败;但必须记录时间并纳入分析。
## 6. 连续观察和七天导出
至少连续观察 7 天,覆盖正常业务高峰、入库、自动出库和手动出库。期间不要重放业务来补影子样本。
观察结束后运行:
```cmd
cd /d C:\project\FT546WMS-SHANDOW
call scripts\shadow-v3-export.cmd C:\project\FT546WMS-SHANDOW "<SQL_SERVER>" "<DATABASE>" "<V3_START_UTC>" "C:\project\FT546WMS-shadow-v3-export" "<LOG_DIR>"
```
成功时显示 `EXPORT_OK`。输出目录包含:
- `deployment.txt`:分支、提交、工作树;
- `runtime-processes.txt`:JVM 进程;
- `environment-variable-names.txt`:仅环境变量名,不含值;
- `sql-diagnostics.txt`:manifest、快照、心跳、输入覆盖、阻塞;
- `comparison-detail.txt`:比较汇总和完整决策行;
- `runtime-metrics.txt`:准入、跳过、错误和时延桶;
- `inventory-parity.txt`:Legacy 与已发布快照库存差异计数;
- `shadow-warnings.txt`:日志目录中的结构化影子 warning。
把整个输出目录压缩后交给分析,不要包含密码、令牌或与测试无关的个人信息。只有七天数据通过分析后,才讨论正式切换;本分支本身不执行 ACTIVE 切换。
## 7. 回滚
首选回滚只关闭影子,不卸载数据库对象:
1. 在 IDEA 停止 v3,记录停止时间;
2. 设置 `FORTUCKY_SHADOW_ENABLED=false`;
3. 完整重启一次;
4. 检查只有一个 JVM;
5. 做登录、查询、受控入库和出库冒烟检查。
更彻底的应用回滚是停止 v3,切回现场原稳定源码/提交并用原 Run Configuration 启动。无论哪种方式,都先恢复业务服务,再处理数据库对象。
`alg.*` 可以保留供取证,稳定 Legacy 不依赖它。不要把卸载当作紧急回滚步骤。
## 8. 可选卸载
只有满足全部条件才卸载:影子实例已停止、稳定服务已恢复、七天证据已导出并备份、DBA 已确认维护窗口。
```cmd
call scripts\shadow-v3-uninstall.cmd "<SQL_SERVER>" "<DATABASE>" CONFIRM_UNINSTALL
```
卸载器只接受精确口令 `CONFIRM_UNINSTALL`,并由 SQL 包再次校验 v3 manifest 和完整对象白名单。发现未知/缺失对象时会在删除前停止。不要修改 manifest 或脚本绕过保护。
## 9. 自动化验证与残余风险
- 本地单元/静态测试不能替代 SQL Server 2016 备份副本演练。
- `READ UNCOMMITTED` 只用于观察,可能看到业务事务中间状态。
- 单线程容量一队列限制影子后台并发,但不能消除数据库资源竞争。
- 3 秒 worker 等待超时不等于 JDBC/SQL Server 一定在 3 秒内完成取消或回滚。
- 影子调用线程不等待后台,但主机和数据库资源竞争仍可能间接影响 Legacy,需要现场监控。

View File

@@ -0,0 +1,81 @@
# 影子 V3.2 更新、检查和回滚
本次只修复影子 UTC 时间映射、影子告警保存和 CMD 导出。Legacy 仍负责实际业务,不是正式算法切换。本次不需要安装、升级或卸载数据库对象,也不修改全局 JVM 时区。
## 第一步:选好短暂停机时间
现场由这一份 WMS 程序继续运行 Legacy,重启会短暂中断 WMS 服务。请选业务空闲的维护窗口,不要在作业处理中直接重启。
只停止 IDEA 中 `C:\project\FT546WMS-SHANDOW` 对应的 **WMS**。不要停止 WCS、WCS 影子或其他 Java 进程,不使用 `taskkill /IM java.exe`。
## 第二步:记录旧版本并更新代码
在 CMD 中逐行执行:
```cmd
cd /d C:\project\FT546WMS-SHANDOW
git branch --show-current
git status --porcelain
git rev-parse HEAD
```
分支必须是 `test/ft546wms-shadow-v3-20260903`,第二条 Git 检查必须没有输出。否则停止更新,让服务器 AI 汇报现有改动,不覆盖、不清理文件。
确认后,逐行执行;任何命令失败都停止:
```cmd
git tag backup/ft546wms-before-v32-20260907 HEAD
git fetch origin
git pull --ff-only origin test/ft546wms-shadow-v3-20260903
git rev-parse HEAD
git status --porcelain
```
如果备份标签已存在,不覆盖它,先让服务器 AI 核对。更新后的提交号应与本次交付提交一致,工作区仍应干净。
## 第三步:在 IDEA 重新编译并启动
保持原 Run Configuration、端口、数据库、许可证和环境变量。不要新开第二个 WMS。执行 Build / Rebuild Project,成功后启动原 WMS 配置。
关键变量仍然是:
```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_SHADOW_SNAPSHOT_MAX_AGE_SECONDS=60
FORTUCKY_SHADOW_QUIET_INTERVAL_MILLIS=1000
```
其他现有变量原样保留,不把这段当作整份环境变量的替代。不要添加 `-Duser.timezone=UTC`,不要放大快照有效期。
## 第四步:先确认采样,再累计测试时间
记录这次真正启动成功的北京时间和 UTC 时间。等现场自然发生正常业务,不制造、重放或重复提交出入库请求。让服务器 AI 按 `SHADOW_V3_SERVER_AI_EXPORT_PROMPT.md` 只读导出,将开始时间填为这次 V3.2 启动时间的 UTC 值。
检查以下证据:
- `ADMITTED` 在真实业务后增加;`ADMISSION_US` 只是检查次数,不代表采样成功。
- `INBOUND_COMPARE` / `OUTBOUND_COMPARE` 开始产生,包含快照时间、代次和输入诊断。没有某类业务时不能要求对应计数增长。
- `logs\shadow-diagnostics.log` 保存影子 WARN/ERROR,时间明确为 UTC。文件为空只表示本文件没有记录,不能单独证明影子可用。
- 快照同步时间和新写入的 `flushed_at` 使用 UTC。旧累计指标不清零;旧行在首次新刷新前仍可能保留旧的本地时间,不能把所有历史时间强行减八小时。
`EXPORT_OK` 仅表示文件导出成功。`NO_COMPARISONS` 表示本窗口尚无入出库比较,不能批准切换。空闲后第一笔请求因快照超过 60 秒而跳过仍可能正常;不要为增加样本而关闭过期或并发保护。
时间映射修复有本地回归测试,但现场还需确认刷新错误、业务频率等其他因素是否影响采样。开始产生有效样本之后再累计观察期,仍按连续七天、至少 100 个有效入库和 100 个有效出库比较及原性能门槛评估,不按程序开了几天直接切换。
## 出现问题时回滚
如果更新后影响现有使用,先停止这一份 WMS,WCS 不动。只在工作区干净时执行:
```cmd
cd /d C:\project\FT546WMS-SHANDOW
git status --porcelain
git switch --detach backup/ft546wms-before-v32-20260907
```
在 IDEA Rebuild Project 后,用原配置启动,恢复更新前代码。若有未提交改动或命令报错,停止操作,不用 reset/clean 强制覆盖。
另一种隔离方式是在维护窗口将原配置中的 `FORTUCKY_SHADOW_ENABLED` 改为 `false` 后重启 WMS,停止影子观察,Legacy 继续运行。两种方式均不回滚业务数据、不卸载 `alg` 表,保留日志和影子数据用于排查。

View File

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

View File

@@ -0,0 +1,80 @@
# FT546WMS 入库影子 V3.4
这是影子采集与比较修复,不是正式算法切换。现场继续由 `dbo.proc_allot_space` 分配货位。
## 修复范围
- 入库输入从 `mydo_job` 和 `mydo_job_detail` 采集,不再依赖入库单是否仍在活动表。
- 影子请求标识采用 `job_id`,项目采用任务 `line_id`;采集明细 `spare1/2/3` 和任务 `fault_flag`。
- 保存明细总数、不同分组字段组合数、不同产品/批次组合数、重复任务数量,不用第一条明细代表整个托盘。
- 货位快照新增真正的 `this_cargo_area`,不把 `cargo_area_id` 当成 A/B/C/D 库区。
- 新输入标记 `predicate_version=1`;决策原因标记 `inboundPredicateVersion=1`。旧样本不能重新认定为新版本通过。
- 共享规划器、业务调用入口、Legacy 存储过程和出库影子规则不变。
## 比较口径与限制
新算法仍按原优化策略提出建议。只有建议通过保守的 Legacy 可选范围核验,且 Legacy 返回成功码 `10`,才可能记为 `MATCH` 或 `DIFFERENT`。
目前可核验的是单产品/批次、分组字段一致的项目入库:候选空闲、未锁定、无托盘/库存、A/C 区、楼层无入库作业、通道无入出库作业或锁定,并且整条通道为空,或通道内有托盘一致且项目及 spare1/2/3 全匹配的库存。
这是 `STRICT_GROUP_OR_EMPTY` 保守子集,不声称完整复刻 Legacy 排序。Legacy 的项目数量分支有按 `group_coding` 分组后赋值的歧义;严格分组匹配在两个分支内均属可选范围。`fault_flag=0` 的 C 区优先属于旧排序偏好,保留字段供分析,不替换共享规划器排序。
Legacy 的部分货位约束和重复任务判断不限定仓库,因此刷新前后的货位、任务及任务明细签名覆盖全局。其他仓库变动也可能使本轮影子快照失效;存在快照之外的货位时,入库只记录跳过。此限制有意保留,不能靠删除检查来扩大通过率。现场验收还需测量全局读取增加的开销。
以下情况记录具体原因,不能算通过:
| 原因码 | 含义 |
|---|---|
| `SKIPPED_MISSING_PREDICATE_INPUT` | 旧快照或字段超过 Legacy 参数长度 |
| `SKIPPED_MISSING_TASK_DETAILS` | 采到了任务,但没有任务明细 |
| `SKIPPED_AMBIGUOUS_LEGACY_FIELDS` | 明细 spare1/2/3 不一致,无法复现 Legacy 无排序的 TOP (1) |
| `SKIPPED_MULTI_PRODUCT_INPUT` | 单产品请求无法表示整个多产品/批次托盘,或缺少产品 |
| `SKIPPED_AMBIGUOUS_TRAY_JOB` | 同托盘或任务号存在多个任务 |
| `SKIPPED_INCOMPLETE_LEGACY_SCOPE` | 存在快照之外的仓库货位,不能用局部快照核验全局 Legacy 约束 |
| `SKIPPED_UNSUPPORTED_COMMON_PROJECT` | 空项目、999 或 COMMON 的通用件映射及单深池策略尚需单独验证 |
| `SKIPPED_LEGACY_CONSTRAINT` | 建议不在保守可选范围内;原因保留 `unverifiedProposal` |
受控混放、借池和放宽分组等优化建议可能落在此范围外。这不等于建议必然错误,但不能在尚未确认这些规则的情况下当作兼容性通过。
2026-09-07 的任务 31331 / 31333 分别有 45 / 35 条明细且 spare1 有不同值。修复后这类任务不再因缺少入库单被漏采,但仍应标为明细歧义,而不是强行改成通过。需要另行确定多明细业务规则后才能扩大验证范围。
## 部署准备
1. 先确认交付的提交号和已验证的 JAR,不要使用尚未推送的本地修改,也不要把带开发配置的原始 `target/ruoyi-admin.jar` 直接用于现场。
2. 沿用现场离线 JAR 加外部配置方式,保留现有 JAR、外部配置和 IDEA 启动项用于回退。新 JAR 应按既有交付流程剥离内置连接配置并核对 SHA-256。
3. 在隔离 SQL Server 上演练升级、重复执行和失败回滚。本地 H2 投影/Mapper 测试及 SQL 2016 解析不能代替真实 SQL Server 升级测试。
4. 记录升级前的数据库包版本、影子指标、启动时间和业务基线。本包只从 `2026-09-07-33` 升至 `2026-09-07-34`,增加 11 个可空列,保留影子历史日志。
## 维护窗口操作
停止 WMS 的影子启用实例,确认退出并等待至少 130 秒。不要停止 WCS 或 WCS 影子程序,不要同时运行两个 WMS。
在服务器已确认的新版本源码目录,用 CMD 执行:
```cmd
call scripts\shadow-v3-install.cmd "lpc:." "FT546-wms" v34
```
必须看到 `V34_UPGRADE_OK`(重复执行为 `V34_ALREADY_INSTALLED`)以及 `INSTALL_OK mode=v34`。
已有 V3.3 不要用 `clean`、不要先卸载,也不要改清单绕过心跳检查。失败则停止后续部署并保存输出。
`clean`/`upgrade` 模式会依次安装历史基础包、V3.3、V3.4,每个阶段单独提交;中间失败不代表之前阶段已回滚。`v34` 单步升级为一个事务。
启动前再执行只读结构检查,必须返回 `V34_SCHEMA_OK`:
```cmd
sqlcmd -S "lpc:." -d "FT546-wms" -E -b -r1 -i "scripts\shadow-v34-schema-check.sql"
```
该检查核对 V3.4 对象、继承列及新增列的类型、长度、精度和可空性,并检查刷新过程的版本标记。没有成功标记就不要继续。原 `shadow-v3-preflight.cmd` 也包含这项检查,但它还要求新鲜的已发布快照,不适合直接用在升级完成但尚未启动的 `INITIALIZING` 状态。
确认数据库完成后,再启动新 JAR,仅保留 `FORTUCKY_SHADOW_ENABLED=true`、`FORTUCKY_ALGORITHM_MODE=SHADOW`,不启用 ACTIVE。现有连接、内存、限流和日志配置不随意覆盖。
## 启动后检查与回滚
检查包版本为 `2026-09-07-34`,新快照为 `PUBLISHED`。通过自然业务观察入库,不制造生产测试单,不回放已完成任务。快照在事件触发时更新,没有新任务时输入表为空不等于故障。
继续用现有 `shadow-v3-export.cmd` 导出,提供准确升级时间、提交、JAR 哈希和日志;只分析含 `inboundPredicateVersion=1` 的升级后记录。累计性能指标用升级前后差值评估,关注后台读取增加的开销。
有异常时:停止新 WMS,将影子关闭,使用保留的原 JAR/源码及外部配置启动一个 WMS。无需卸载数据库,新列不参与 Legacy。回到旧代码也保持影子关闭,避免继续采集旧口径样本。
本次代码修复不代表具备正式切换条件。所有跳过均需单独统计,不能用连续运行天数代替支持范围、样本覆盖和性能验收。

View File

@@ -0,0 +1,66 @@
# 给服务器 AI 的 FT546WMS 影子 v3 导出指令
把下面整段交给服务器上的 AI。先替换尖括号内容,不要把密码放进提示词。
```text
你在 Windows CMD 环境中操作 FT546WMS 影子 v3。你的任务只导出证据,不修改数据库、不切换分支、不重启服务、不卸载任何对象。
固定信息:
- 仓库目录:C:\project\FT546WMS-SHANDOW
- SQL Server:<SQL_SERVER>
- 数据库:<DATABASE>
- 本次修复实际启动 UTC:<V3_START_UTC,例如 2026-09-07T02:00:00>
- 应用日志目录:<LOG_DIR>
- 输出目录:C:\project\FT546WMS-shadow-v3-export(必须为空或不存在;重复导出用新的带时间目录)
- 期望分支:test/ft546wms-shadow-v3-20260903
- 期望提交:<V3_COMMIT>
安全规则:
1. 只能使用 cmd.exe,不使用 PowerShell。
2. 数据库只能使用 Windows 集成认证,不询问、保存或输出密码。
3. 不执行 INSERT、UPDATE、DELETE、MERGE、TRUNCATE、DROP、ALTER、CREATE、EXEC。
4. 不运行 install、upgrade、uninstall,不停止或启动 IDEA/Java。
5. 任一步失败就停止,保留错误原文,不猜测、不绕过保护。
6. 不修改仓库文件,不执行 git checkout/switch/reset/clean/pull。
先在 CMD 执行只读核对:
cd /d C:\project\FT546WMS-SHANDOW
git branch --show-current
git rev-parse HEAD
git status --porcelain
jps -lv | findstr /I "RuoYiApplication FT546WMS"
必须满足:分支等于期望分支,HEAD 等于期望提交,git status 没有输出,目标 WMS 正常运行且只有一个实例。结合源码目录和 IDEA 配置辨认,不把 WCS 或 WCS 影子算作 WMS;无法辨认就报告,不停止任何进程。
然后执行仓库自带的只读导出器:
call scripts\shadow-v3-export.cmd C:\project\FT546WMS-SHANDOW "<SQL_SERVER>" "<DATABASE>" "<V3_START_UTC>" "C:\project\FT546WMS-shadow-v3-export" "<LOG_DIR>"
必须看到 EXPORT_OK。此标记仅表示导出完成,不代表样本合格或可以正式切换。导出器已兼容 sqlcmd 13,无需改写 :out 或单独执行 SQL 文件。
随后检查输出文件:
dir /b C:\project\FT546WMS-shadow-v3-export
期望至少包含:
deployment.txt
runtime-processes.txt
environment-variable-names.txt
sql-diagnostics.txt
comparison-detail.txt
runtime-metrics.txt
inventory-parity.txt
shadow-warnings.txt
最后报告:
- 实际分支和提交号;
- JVM 数量;
- EXPORT_OK 是否出现;
- 输出目录;
- 文件名和大小;
- 任何错误。
- comparison-detail.txt 汇总中的四项计数及 sample_status;NO_COMPARISONS 表示尚无比较样本,不当成测试通过。
- 日志目录是否存在 shadow-diagnostics.log;没有报错时允许为空,不人为制造业务异常验证。
- IDEA 中实际 WMS 启动时间、时区和 Run Configuration 是否可确认;不能确认的写明未知,不从启动 CMD 环境推断运行进程继承情况。
runtime-metrics.txt 是累计指标,不受本次开始时间过滤。V3.2 新写入 flushed_at 为 UTC;未被新刷新更新的旧行仍可能是旧本地时间。不要清零计数、改写旧时间或自行得出性能无影响的结论。
不要在回复中粘贴环境变量值、数据库凭据或完整业务数据。不要压缩以外的改写;由现场人员把整个输出目录压缩后交给分析。
```

View File

@@ -0,0 +1,31 @@
# 2026-09-09 影子修复包切换
本包仍是 V3.4 数据库配套的 SHADOW 程序。无需执行数据库升级或卸载。Legacy 继续负责正式入出库。
## 现场操作
1. 在 IDEA 的运行配置列表确认能看到 `FT546WMS-V34-REPAIR-0909`。先不要启动。
2. 在协调好的时间停止当前 WMS:`FT546WMS-V34-HALFTRAY-FIX`。不要停止 WCS 或 WCS 影子程序。
3. 确认旧 WMS 完全退出,8080 不再监听。CMD 可执行 `netstat -ano | findstr :8080`;旧 PID 没有 LISTENING 才继续。
4. 选择 `FT546WMS-V34-REPAIR-0909`,点运行。等日志出现 `Started RuoYiApplication`,检查登录和查询。不要同时启动两个 WMS。
5. 记录启动时间,在正常业务发生后复核新的影子比较和电子屏发送情况。无需为测试制造业务单据。
新 JAR 目录:`C:\project\FT546WMS-v34-repair-20260909`。
继续使用服务器原有外置配置 `C:\project\FT546WMS-v34-jar-20260908\config`,不修改密码、数据库连接或影子开关。新增启动项不执行 Maven,不覆盖运行中的 JAR。
## 回退
如果新程序启动失败或影响操作,停止新 WMS,确认进程退出后启动旧配置 `FT546WMS-V34-HALFTRAY-FIX`。数据库没有变更,不需要回退 SQL。若需要完全关闭影子,使用已留存的 `FT546WMS-ROLLBACK-SOURCE-NO-BUILD`,先核对其 `FORTUCKY_SHADOW_ENABLED=false`。
## 本次修复与观察边界
- 出库从同代快照的候选库存行保留项目/物料,支持一个足量库存行位于多明细托盘。保留真实阻挡、锁定、预留、拓扑及数量校验。需要多个库存行凑量、候选跨多个项目/物料、已有托盘作业等仍明确标记不支持。
- 普通项目多物料入库采用项目级 OPEN 输入,不把任意一条明细当成全部。提前筛选 Legacy 保守可用货位后排名。
- 同一任务 spare1/2/3 有多组值时,只评估整条空巷道;记录 `READY_CONSERVATIVE` 与 `MATCH_CONSERVATIVE` / `DIFFERENT_CONSERVATIVE`。它们不是完整 Legacy 等价验证,验收时不能混入普通 MATCH。
- 出库原因新增候选证据、集合 SHA-256、完整性标记。`candidateEvidenceComplete=false` 表示原因字段空间不足,只保留部分明细,摘要不能代替完整历史回放。
- 非法 `stockinId[limit]` 请求返回参数格式提示且不查询明细;合法接口和服务端异常处理保留原行为。
- 电子屏两个定时任务独立持有消息和 socket;争用原 6666 端口时下一周期再发送,失败清理资源,保留 5 次协议重试。
- SQL 51025 仍表示源数据在刷新中变化而拒绝发布;不屏蔽该一致性保护,不增加影子刷新超时。
这次修复不等于批准新算法接管正式业务。两个历史出库代次已被覆盖,尚不能完整回放;请以本包启动后的新样本继续评估。至少按既有观察要求收集同版本有效出入库样本,并单独复核保守比较、跳过、差异和性能。

View File

@@ -0,0 +1,203 @@
# FT546 WMS Shadow-Only Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**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 `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.
## Global Constraints
- Base commit is `origin/main` commit `916ac6bda32a5b102a3181cc8c5dbd5319c5656d`.
- 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.
---
### Task 1: Import the algorithm core without unrelated WMS integration
**Files:**
- Create: `fortucky-algorithm/pom.xml`
- Create: `fortucky-algorithm/src/main/java/com/ruoyi/algorithm/**`
- Create: `fortucky-algorithm/src/test/java/com/ruoyi/algorithm/**`
- Modify: `pom.xml`
- Modify: `ruoyi-admin/pom.xml`
- Test: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowModuleStructureTest.java`
**Interfaces:**
- Produces: `WarehouseApplicationService`, `AlgorithmProperties`, and JDBC read-model repository beans.
- [ ] Add a Maven structure test that expects `fortucky-algorithm` in the reactor and `ruoyi-admin` dependency graph.
- [ ] Run `mvn -pl ruoyi-admin -am -Dtest=ShadowModuleStructureTest test` and confirm RED because the module is absent.
- [ ] Import only the algorithm module from `8b84459072fb21a65e11f96e97df14c5b9a340ba`; do not import `ruoyi-license`, UI, WMS workflow, or application bootstrap changes.
- [ ] Add the module and dependency using the existing Maven compiler and test plugin versions from `origin/main`.
- [ ] Run the focused test and compile reactor; confirm GREEN.
- [ ] Commit as `feat: add algorithm core for shadow observation`.
### Task 2: Add the fail-open shadow planning boundary
**Files:**
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationProperties.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuard.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationGateway.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/InboundShadowObservation.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/OutboundShadowObservation.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowStockReference.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java`
- Create: `ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml`
- Test: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowObservationGatewayTest.java`
- Test: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java`
- Test: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuardTest.java`
**Interfaces:**
- Produces: fail-open `planInbound`, `planProjectOutbound`, `planExactStockOutbound`, and comparison methods.
- Guarantees: runtime failures return empty observations; planning uses `PROPAGATION_REQUIRES_NEW` with configured timeout.
- [ ] Write gateway tests proving disabled/missing engine and thrown planning exceptions return empty observations.
- [ ] Run focused tests and confirm RED because gateway types do not exist.
- [ ] Implement immutable observation records and the fail-open gateway.
- [ ] Run gateway tests and confirm GREEN.
- [ ] Write engine tests capturing the transaction definition and asserting `REQUIRES_NEW` plus timeout.
- [ ] Run engine tests and confirm RED.
- [ ] Implement the planning engine, minimal input mapper, and algorithm request conversion.
- [ ] Run engine tests and confirm GREEN.
- [ ] Write guard tests proving enabled SHADOW starts while ACTIVE, LEGACY, and disabled database writes are rejected.
- [ ] Run guard tests RED, implement the guard, then run GREEN.
- [ ] Commit as `feat: add fail-open shadow planning boundary`.
### Task 3: Preserve inbound Legacy behavior
**Files:**
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/service/impl/ToWmsServiceImpl.java`
- Test: `ruoyi-admin/src/test/java/com/ruoyi/api/service/impl/ToWmsServiceImplShadowTest.java`
**Interfaces:**
- Consumes: `ShadowObservationGateway.planInbound` and `compareInbound`.
- Preserves: `MydoJobMapper.allotInJobSpace` invocation count, arguments, return handling, and reloaded Legacy job.
- [ ] Write a success test that asserts order: shadow plan, Legacy procedure once, Legacy reload, comparison; assert returned data is the Legacy job.
- [ ] Write a failure test where the shadow gateway throws and assert the same Legacy success result and one procedure call.
- [ ] Run both tests and confirm RED because the service has no shadow hook.
- [ ] Add only pre-procedure observation and post-result comparison around the existing Legacy block; catch gateway failures without changing Legacy branches.
- [ ] Run both tests and confirm GREEN.
- [ ] Commit as `feat: observe inbound allocation without changing legacy`.
### Task 4: Preserve outbound Legacy behavior
**Files:**
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/out/outStockout/service/impl/OutStockoutServiceImpl.java`
- Test: `ruoyi-admin/src/test/java/com/ruoyi/out/outStockout/service/impl/OutStockoutServiceImplShadowTest.java`
**Interfaces:**
- Consumes: project/manual shadow planning and comparison methods.
- Preserves: `autoAllot` and `manualAllot` calls, loop behavior, warning messages, and return codes.
- [ ] Write automatic allocation tests proving missing project data and thrown shadow failures still call `autoAllot` exactly once and preserve the Legacy response.
- [ ] Write manual allocation tests proving missing shadow stock data still calls `manualAllot` once per selected stock and preserves the Legacy response.
- [ ] Run focused tests and confirm RED.
- [ ] Add observation before each original stored-procedure call and comparison afterward without branching on shadow output.
- [ ] Run focused tests and confirm GREEN.
- [ ] Commit as `feat: observe outbound allocation without changing legacy`.
### Task 5: Add the shadow-only database package
**Files:**
- Create: `fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql`
- Create: `fortucky-algorithm/src/main/resources/db/sqlserver/uninstall-shadow.sql`
- Create: `fortucky-algorithm/src/main/resources/db/sqlserver/diagnostics-shadow.sql`
- Test: `fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/ShadowSqlServerMigrationScriptTest.java`
**Interfaces:**
- 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, 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.
- [ ] Run focused tests and confirm GREEN.
- [ ] Commit as `feat: add shadow-only SQL Server package`.
### Task 6: Add field configuration and operating guide
**Files:**
- Modify: `ruoyi-admin/src/main/resources/application.yml`
- Create: `doc/algorithm-shadow/README.md`
- Test: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowConfigurationTest.java`
**Interfaces:**
- Produces: disabled-by-default settings and exact IDEA deploy/rollback instructions.
- [ ] Write configuration test requiring disabled default, SHADOW mode, write-enabled read model, warehouse fallback, and three-second timeout.
- [ ] Run test RED, add configuration, then run GREEN.
- [ ] 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: 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`.
**Interfaces:**
- Produces: pushed branch `test/ft546wms-shadow-from-main-20260811`.
- [ ] Run focused shadow tests.
- [ ] Run full Maven tests with Maven 3.9.11.
- [ ] Run `mvn package -DskipTests` and calculate the JAR SHA-256.
- [ ] Audit `git diff --name-status origin/main...HEAD` and confirm no forbidden runtime areas changed.
- [ ] Request independent code review; fix every Critical and Important finding.
- [ ] Re-run full tests and package after review fixes.
- [ ] Push the branch to `origin` and retain the worktree for field-test feedback.

View File

@@ -0,0 +1,975 @@
# FT546 WMS Shadow v3 Stable-Snapshot Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build an observation-only v3 shadow pipeline that compares inbound and outbound decisions against a proven pre-Legacy snapshot without ever making Legacy wait or changing its result.
**Architecture:** A lock-free JVM coordinator leases one already-published SQL read-model generation to at most one observation and marks concurrent samples invalid. The existing algorithm receives new explicit “published snapshot” planning entry points that skip live refresh, while one background worker compares the Legacy result and refreshes the next generation only after a quiet window. SQL Server owns atomic generation refresh/publication and immutable input capture; field scripts verify, upgrade, export, disable, and uninstall only the shadow package.
**Tech Stack:** Java 17, Spring Boot, Spring transactions, MyBatis, SQL Server 2016 RTM, JUnit 5, Mockito, AssertJ, Maven 3.9.11, Windows CMD, Node.js syntax checks.
**Spec:** `docs/superpowers/specs/2026-09-03-ft546wms-shadow-v3-design.md`
## Global Constraints
- Work only on branch `test/ft546wms-shadow-v3-20260903` based on v2 commit `3f7ed3718defef2c4ab37e1deb2534d2329747e4`; verified main commit `916ac6bda32a5b102a3181cc8c5dbd5319c5656d` must remain an ancestor.
- Legacy allocation procedures execute exactly once with unchanged arguments, transaction ownership, result handling, response codes, and response fields.
- A Legacy request performs no shadow database/file access, synchronous shadow log write, monitor wait, sleep, `Future.get`, or executor fallback work.
- Caller-side shadow admission is fixed-size in-memory bookkeeping with a field p99 budget of 1 ms; queue rejection returns immediately.
- Shadow failure, staleness, concurrency, timeout, bad configuration, or missing input always fails open to Legacy and fails closed for observation validity.
- The supported field topology is exactly one FT546WMS JVM; a second process or recent different shadow-instance claim prevents v3 shadow enablement but never prevents Legacy startup.
- `FORTUCKY_ALGORITHM_WAREHOUSE_ID=01` is authoritative. Numeric `1` equals `01`; raw `W02` is diagnostic only and does not override or by itself reject the `01` plan.
- Snapshot maximum age defaults to 60 seconds and accepts 5–300 seconds. Quiet interval defaults to 1000 ms and accepts 100–10000 ms. Worker timeout defaults to 3 seconds and accepts 1–30 seconds.
- SQL uses SQL Server 2016-compatible syntax, `READ UNCOMMITTED` source reads, shadow-owned transactions, and only `alg.*` writes. No trigger or Legacy business-table DML may be introduced.
- v2 decision history is preserved during the recognized v2-to-v3 upgrade. Unknown `alg` objects, an unexpected manifest, or partial ownership must stop install/upgrade/uninstall.
- No ACTIVE controller, routing flag, reservation, tidy, replay, WCS callback, PDA behavior, frontend behavior, or unrelated production code is added or changed.
---
### Task 1: Add Validated v3 Configuration and Warehouse Normalization
**Files:**
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/WarehouseIdNormalizer.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationProperties.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuard.java`
- Modify: `ruoyi-admin/src/main/resources/application.yml`
- Test: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowConfigurationTest.java`
- Test: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuardTest.java`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/WarehouseIdNormalizerTest.java`
**Interfaces:**
- Produces: `int getSnapshotMaxAgeSeconds()`, `int getQuietIntervalMillis()`, and exact startup range validation on `ShadowObservationProperties`.
- Produces: `WarehouseIdNormalizer.equivalent(String, String)` for numeric leading-zero equivalence and exact trimmed non-numeric comparison.
- Preserves: `fortucky.shadow.enabled=false` default and current fallback from blank `fortucky.shadow.warehouse-id` to `SchedulingFacade.defaultWarehouseId()`.
- [ ] **Step 1: Add failing configuration and guard tests.**
```java
assertThat(shadow.getSnapshotMaxAgeSeconds()).isEqualTo(60);
assertThat(shadow.getQuietIntervalMillis()).isEqualTo(1000);
shadow.setSnapshotMaxAgeSeconds(4);
assertThatThrownBy(() -> new ShadowSafetyGuard(shadow, algorithm).validate())
.hasMessageContaining("between 5 and 300");
shadow.setSnapshotMaxAgeSeconds(60);
shadow.setQuietIntervalMillis(10001);
assertThatThrownBy(() -> new ShadowSafetyGuard(shadow, algorithm).validate())
.hasMessageContaining("between 100 and 10000");
```
- [ ] **Step 2: Run the focused tests and verify RED because the new properties do not exist.**
Run:
```bash
mvn -pl ruoyi-admin -Dtest=ShadowConfigurationTest,ShadowSafetyGuardTest,WarehouseIdNormalizerTest test
```
Expected: compilation failure for the missing getters/class.
- [ ] **Step 3: Implement the exact property defaults, YAML bindings, and validation.**
```java
private int snapshotMaxAgeSeconds = 60;
private int quietIntervalMillis = 1000;
if (shadowProperties.getTimeoutSeconds() < 1 || shadowProperties.getTimeoutSeconds() > 30) {
throw new IllegalStateException("Shadow observation timeout must be between 1 and 30 seconds");
}
if (shadowProperties.getSnapshotMaxAgeSeconds() < 5
|| shadowProperties.getSnapshotMaxAgeSeconds() > 300) {
throw new IllegalStateException("Shadow snapshot maximum age must be between 5 and 300 seconds");
}
if (shadowProperties.getQuietIntervalMillis() < 100
|| shadowProperties.getQuietIntervalMillis() > 10000) {
throw new IllegalStateException("Shadow quiet interval must be between 100 and 10000 milliseconds");
}
```
Add these exact YAML keys:
```yaml
snapshot-max-age-seconds: ${FORTUCKY_SHADOW_SNAPSHOT_MAX_AGE_SECONDS:60}
quiet-interval-millis: ${FORTUCKY_SHADOW_QUIET_INTERVAL_MILLIS:1000}
```
- [ ] **Step 4: Add normalization tests and implement the utility.**
```java
assertThat(WarehouseIdNormalizer.equivalent("1", "01")).isTrue();
assertThat(WarehouseIdNormalizer.equivalent(" 01 ", "1")).isTrue();
assertThat(WarehouseIdNormalizer.equivalent("W02", "01")).isFalse();
assertThat(WarehouseIdNormalizer.equivalent(" W02 ", "W02")).isTrue();
```
Implementation contract:
```java
final class WarehouseIdNormalizer {
static boolean equivalent(String first, String second) {
if (first == null || second == null) {
return false;
}
String left = first.trim();
String right = second.trim();
if (left.matches("[0-9]+") && right.matches("[0-9]+")) {
return new java.math.BigInteger(left).equals(new java.math.BigInteger(right));
}
return left.equals(right);
}
private WarehouseIdNormalizer() { }
}
```
- [ ] **Step 5: Run the focused tests and verify GREEN.**
Run the command from Step 2. Expected: all selected tests pass.
- [ ] **Step 6: Commit the validated configuration boundary.**
```bash
git add ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/WarehouseIdNormalizer.java ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationProperties.java ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuard.java ruoyi-admin/src/main/resources/application.yml ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowConfigurationTest.java ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuardTest.java ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/WarehouseIdNormalizerTest.java
git commit -m "feat: validate shadow v3 snapshot settings"
```
### Task 2: Build the Lock-Free Snapshot Coordinator
**Files:**
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/PublishedShadowSnapshot.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSkipReason.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSnapshotCoordinator.java`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSnapshotCoordinatorTest.java`
**Interfaces:**
- Produces: `PublishedShadowSnapshot(long generation, String warehouseId, Instant synchronizedAt, String sourceSignature)`.
- Produces: `ShadowSnapshotCoordinator.Admission enter(Instant now)`, `void exit(Admission admission)`, and `void finishObservation(Admission admission)`; every enabled Legacy call exits activity exactly once while worker lease cleanup remains separately idempotent.
- Produces: `Optional<RefreshTicket> tryBeginRefresh(Instant now)`, `boolean publish(RefreshTicket, PublishedShadowSnapshot, Instant)`, and `void failRefresh(RefreshTicket, String)`.
- Guarantees: `Admission.accepted()` is true only for one fresh `READY` snapshot; any later Legacy entry changes the epoch and makes the admitted sample non-comparable.
- [ ] **Step 1: Write failing tests for startup, one lease, busy skipping, and exact-once exit.**
```java
ShadowSnapshotCoordinator coordinator = coordinatorAt("2026-09-03T00:00:00Z");
assertThat(coordinator.enter(now()).skipReason()).isEqualTo(ShadowSkipReason.NOT_READY);
publish(coordinator, snapshot(1, "01", now()));
var first = coordinator.enter(now());
var second = coordinator.enter(now());
assertThat(first.accepted()).isTrue();
assertThat(second.accepted()).isFalse();
assertThat(second.skipReason()).isEqualTo(ShadowSkipReason.BUSY);
coordinator.exit(second);
coordinator.exit(first);
assertThat(coordinator.activeLegacyCount()).isZero();
```
- [ ] **Step 2: Write the validation-before-publication race test.**
```java
var refresh = coordinator.tryBeginRefresh(now()).orElseThrow();
var concurrent = coordinator.enter(now());
assertThat(coordinator.publish(refresh, snapshot(2, "01", now()), now())).isFalse();
assertThat(coordinator.phase()).isNotEqualTo(ShadowSnapshotCoordinator.Phase.READY);
coordinator.exit(concurrent);
```
Also assert that a second Legacy entry after an accepted admission makes `coordinator.isComparable(first)` false.
- [ ] **Step 3: Write stale and warehouse-mismatch tests.**
```java
publish(coordinator, snapshot(3, "01", now().minusSeconds(61)));
assertThat(coordinator.enter(now()).skipReason()).isEqualTo(ShadowSkipReason.STALE);
publish(coordinator, snapshot(4, "02", now()));
assertThat(coordinator.enter(now()).skipReason()).isEqualTo(ShadowSkipReason.WAREHOUSE);
```
- [ ] **Step 4: Run the coordinator test and verify RED.**
```bash
mvn -pl ruoyi-admin -Dtest=ShadowSnapshotCoordinatorTest test
```
Expected: compilation failure because coordinator types are absent.
- [ ] **Step 5: Implement one atomic stamped state and idempotent admissions.**
Use one `AtomicReference<State>` where `State` contains `Phase`, `epoch`, `activeLegacyCount`, the published snapshot, and quiet-since time. `enter` increments epoch/active before Legacy, `exit` uses an `AtomicBoolean` on the admission to decrement once, and `publish` uses compare-and-set against the refresh ticket's epoch/state stamp.
```java
private record State(
Phase phase,
long epoch,
int activeLegacyCount,
PublishedShadowSnapshot published,
Long leasedEpoch,
Instant quietSince,
String lastFailure) { }
```
Implement the concrete API named in the Interfaces section with CAS loops over one `AtomicReference<State>`. `enter` must first advance epoch/active count in the same replacement state, then lease only a matching fresh `READY` snapshot. `exit` decrements only Legacy activity; `finishObservation` releases only the worker lease. `publish` must compare the ticket's full stamped state, not only its generation.
- [ ] **Step 6: Run the coordinator tests repeatedly and verify GREEN.**
```bash
mvn -pl ruoyi-admin -Dtest=ShadowSnapshotCoordinatorTest test
mvn -pl ruoyi-admin -Dtest=ShadowSnapshotCoordinatorTest test
```
Expected: both runs pass without timing sleeps.
- [ ] **Step 7: Commit the isolated state machine.**
```bash
git add ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/PublishedShadowSnapshot.java ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSkipReason.java ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSnapshotCoordinator.java ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowSnapshotCoordinatorTest.java
git commit -m "feat: coordinate stable shadow snapshots"
```
### Task 3: Add Explicit No-Refresh Algorithm Planning Entry Points
**Files:**
- Modify: `fortucky-algorithm/src/main/java/com/ruoyi/algorithm/service/WarehouseApplicationService.java`
- Modify: `fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingFacade.java`
- Test: `fortucky-algorithm/src/test/java/com/ruoyi/algorithm/service/WarehouseApplicationServiceTest.java`
- Create: `fortucky-algorithm/src/test/java/com/ruoyi/algorithm/integration/SchedulingFacadePublishedSnapshotTest.java`
**Interfaces:**
- Produces: `InboundPlanningResult planInboundFromPublishedSnapshot(String warehouseId, InboundRequest request)`.
- Produces: `OutboundPlan planOutboundFromPublishedSnapshot(String warehouseId, OutboundRequest request)`.
- Preserves: existing `planInbound` and `planOutbound` refresh-before-read behavior for every non-v3 consumer.
- [ ] **Step 1: Add failing service tests proving the new methods never refresh.**
```java
service.planInboundFromPublishedSnapshot("W1", inbound("I-STABLE"));
verify(repository, never()).refreshChangedLanes(any());
verify(repository).loadPolicy("W1");
service.planOutboundFromPublishedSnapshot("W1", outboundRequest);
verify(repository, never()).refreshChangedLanes(any());
verify(repository).searchOutboundStock(eq("W1"), any());
```
Keep the existing tests that verify `planInbound` and `planOutbound` call `refreshChangedLanes`.
- [ ] **Step 2: Add failing facade tests proving SHADOW can call only the published-snapshot methods and LEGACY cannot.**
```java
facade.planInboundFromPublishedSnapshot("W1", request);
verify(applicationService).planInboundFromPublishedSnapshot("W1", request);
properties.setMode(SchedulingMode.LEGACY);
assertThatThrownBy(() -> facade.planOutboundFromPublishedSnapshot("W1", outboundRequest))
.hasMessageContaining("disabled in LEGACY mode");
```
- [ ] **Step 3: Run the focused algorithm tests and verify RED.**
```bash
mvn -pl fortucky-algorithm -Dtest=WarehouseApplicationServiceTest,SchedulingFacadePublishedSnapshotTest test
```
- [ ] **Step 4: Extract private refresh-controlled implementations without changing planner logic.**
```java
public InboundPlanningResult planInbound(String warehouseId, InboundRequest request) {
return planInbound(warehouseId, request, true);
}
public InboundPlanningResult planInboundFromPublishedSnapshot(String warehouseId, InboundRequest request) {
return planInbound(warehouseId, request, false);
}
private InboundPlanningResult planInbound(String warehouseId, InboundRequest request, boolean refresh) {
if (refresh) {
repository.refreshChangedLanes(warehouseId);
}
return planInboundFromCurrentReadModel(warehouseId, request);
}
```
Move the current body after `refreshChangedLanes` into `planInboundFromCurrentReadModel`; do not edit its statements. Apply the same named extraction to outbound with `planOutboundFromCurrentReadModel`. Do not change tidy, reservation, or workflow methods.
- [ ] **Step 5: Add facade delegates guarded by the existing `requireAlgorithmMode()`.**
```java
public InboundPlanningResult planInboundFromPublishedSnapshot(String warehouseId, InboundRequest request) {
requireAlgorithmMode();
return applicationService.planInboundFromPublishedSnapshot(warehouseId, request);
}
```
Add the equivalent outbound delegate.
- [ ] **Step 6: Run all algorithm service/facade tests and verify GREEN.**
```bash
mvn -pl fortucky-algorithm -Dtest=WarehouseApplicationServiceTest,SchedulingFacadePublishedSnapshotTest test
```
- [ ] **Step 7: Commit the explicit planning seam.**
```bash
git add fortucky-algorithm/src/main/java/com/ruoyi/algorithm/service/WarehouseApplicationService.java fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingFacade.java fortucky-algorithm/src/test/java/com/ruoyi/algorithm/service/WarehouseApplicationServiceTest.java fortucky-algorithm/src/test/java/com/ruoyi/algorithm/integration/SchedulingFacadePublishedSnapshotTest.java
git commit -m "feat: plan from published read model"
```
### Task 4: Upgrade the SQL Package to Atomic v3 Generations
**Files:**
- Modify: `fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql`
- Create: `fortucky-algorithm/src/main/resources/db/sqlserver/upgrade-shadow-v2-to-v3.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`
**Interfaces:**
- Produces manifest `FT546WMS_SHADOW_ONLY / 2026-09-03-3`.
- Produces tables `alg.shadow_snapshot_state`, `alg.cargo_space_snapshot`, `alg.active_job_snapshot`, `alg.shadow_inbound_request_snapshot`, `alg.shadow_outbound_request_snapshot`, `alg.shadow_runtime_metric`, and `alg.shadow_instance_heartbeat`.
- Produces procedures `alg.usp_claim_shadow_instance` and `alg.usp_refresh_shadow_snapshot`.
- Preserves existing v2 `alg.decision_log` rows during the recognized upgrade.
- Guarantees all current read-model rows and input rows carry one `snapshot_generation`, and v3 views expose only the published warehouse generation.
- [ ] **Step 1: Extend static migration tests with the exact v3 object allow-list and manifest.**
```java
assertThat(createdTables).contains(
"shadow_snapshot_state", "cargo_space_snapshot", "active_job_snapshot",
"shadow_inbound_request_snapshot",
"shadow_outbound_request_snapshot", "shadow_runtime_metric",
"shadow_instance_heartbeat");
assertThat(createdProcedures).contains("usp_claim_shadow_instance", "usp_refresh_shadow_snapshot");
assertThat(installSql).contains("'FT546WMS_SHADOW_ONLY', '2026-09-03-3'");
assertThat(upgradeSql).contains("'2026-08-12-1'").contains("'2026-09-03-3'");
assertThat(uninstallSql).contains("'2026-09-03-3'");
```
- [ ] **Step 2: Add tests rejecting Legacy DML, triggers, mismatched allow-lists, and non-transactional publication.**
Require `usp_refresh_shadow_snapshot` to contain `SET XACT_ABORT ON`, `BEGIN TRANSACTION`, the component refreshes, a final `UPDATE alg.shadow_snapshot_state`, and `COMMIT TRANSACTION` in that order. Require the upgrade to validate the exact v2 object set before any `ALTER`, and the uninstaller to list every v3 object exactly once.
```java
assertThat(normalizedSql).doesNotContain("insert into dbo.", "update dbo.", "delete from dbo.", "merge dbo.");
assertThat(normalizedSql).doesNotContain("create trigger");
assertThat(indexOfPublish).isGreaterThan(indexOfInputRefresh);
assertThat(indexOfCommit).isGreaterThan(indexOfPublish);
```
Extract the `CREATE VIEW alg.v_space_state` and `CREATE VIEW alg.v_active_job` bodies and assert they contain no `dbo.` reference. `dbo.*` reads are allowed only inside the controlled refresh/source-signature procedures and the initial input-copy statements.
- [ ] **Step 3: Run the migration test and verify RED.**
```bash
mvn -pl fortucky-algorithm -Dtest=ShadowSqlServerMigrationScriptTest test
```
- [ ] **Step 4: Add generation metadata and immutable planning-input tables to clean install.**
Use warehouse-scoped primary keys and explicit generation columns:
```sql
CREATE TABLE alg.shadow_snapshot_state (
warehouse_id varchar(80) NOT NULL PRIMARY KEY,
snapshot_generation bigint NOT NULL,
snapshot_status varchar(20) NOT NULL,
synchronized_at datetime2 NULL,
source_signature varbinary(32) NULL,
source_row_count bigint NULL,
instance_id varchar(80) NULL,
last_error varchar(1000) NULL
);
CREATE TABLE alg.shadow_inbound_request_snapshot (
warehouse_id varchar(80) NOT NULL,
task_no varchar(80) NOT NULL,
tray_no varchar(255) NOT NULL,
request_id varchar(80) NULL,
raw_warehouse_id varchar(80) NULL,
project_no varchar(255) NULL,
product_id varchar(255) NULL,
lot_no varchar(80) NULL,
snapshot_generation bigint NOT NULL,
synchronized_at datetime2 NOT NULL,
CONSTRAINT PK_alg_shadow_inbound_request_snapshot PRIMARY KEY (warehouse_id, task_no, tray_no)
);
```
Create the outbound input table with `stockout_detail_id`, `raw_warehouse_id`, `project_no`, `product_id`, `requested_quantity`, `snapshot_generation`, and `synchronized_at`. Add `snapshot_generation` to topology, inventory, inventory summary, and lane state. Do not run an uncontrolled initial refresh at install time; leave state `INITIALIZING` for the v3 worker.
Use these remaining snapshot columns:
```sql
CREATE TABLE alg.cargo_space_snapshot (
warehouse_id varchar(80) NOT NULL,
space_id varchar(80) NOT NULL,
lane_code varchar(80) NULL,
cargo_area_id varchar(80) NULL,
roadway_id varchar(80) NULL,
display_name varchar(255) NULL,
platoon int NULL,
column_no int NULL,
floor_no int NULL,
depth_raw varchar(80) NULL,
space_status varchar(20) NULL,
locked varchar(20) NULL,
tray_code varchar(255) NULL,
snapshot_generation bigint NOT NULL,
synchronized_at datetime2 NOT NULL,
CONSTRAINT PK_alg_cargo_space_snapshot PRIMARY KEY (warehouse_id, space_id)
);
CREATE TABLE alg.active_job_snapshot (
warehouse_id varchar(80) NOT NULL,
job_id varchar(80) NOT NULL,
task_no varchar(80) NULL,
tray_code varchar(255) NULL,
source_space_id varchar(80) NULL,
target_space_id varchar(80) NULL,
status varchar(20) NULL,
in_or_out varchar(20) NULL,
create_time_raw varchar(80) NULL,
snapshot_generation bigint NOT NULL,
synchronized_at datetime2 NOT NULL,
CONSTRAINT PK_alg_active_job_snapshot PRIMARY KEY (warehouse_id, job_id)
);
CREATE TABLE alg.shadow_outbound_request_snapshot (
warehouse_id varchar(80) NOT NULL,
stockout_detail_id varchar(80) NOT NULL,
raw_warehouse_id varchar(80) NULL,
project_no varchar(255) NULL,
product_id varchar(255) NULL,
requested_quantity decimal(38,6) NULL,
snapshot_generation bigint NOT NULL,
synchronized_at datetime2 NOT NULL,
CONSTRAINT PK_alg_shadow_outbound_request_snapshot PRIMARY KEY (warehouse_id, stockout_detail_id)
);
```
`alg.shadow_runtime_metric` uses `(warehouse_id, metric_name, bucket_upper_micros)` as its primary key plus `metric_count`, `metric_total_micros`, `metric_max_micros`, and `flushed_at`. `alg.shadow_instance_heartbeat` uses `warehouse_id` as primary key plus `instance_id`, `claimed_at`, and `last_seen_at`. Rewrite `v_space_state`, `v_active_job`, topology rebuild, and snapshot-mode lane fingerprinting to read only generation-qualified `alg` snapshots; no planner-facing view may read `dbo.*`.
- [ ] **Step 5: Implement background-only instance claim and atomic refresh procedures.**
`usp_claim_shadow_instance` uses `UPDLOCK, HOLDLOCK` only on `alg.shadow_instance_heartbeat`, accepts a 120-second stale threshold, and returns `claimed=0/1`. `usp_refresh_shadow_snapshot` accepts warehouse, next generation, and instance id; it computes pre/post row-count plus SHA2-256 signatures over the planning fields, copies cargo spaces, inventory, active jobs, inbound inputs, and outbound inputs before rebuilding topology/lane state in one transaction, throws on signature mismatch, and updates the publish marker last.
The final validation and publication block uses this exact form:
```sql
IF @pre_source_count <> @post_source_count
OR @pre_source_signature <> @post_source_signature
THROW 51025, 'FT546 shadow refresh refused: source changed during refresh', 1;
MERGE alg.shadow_snapshot_state WITH (HOLDLOCK) AS target
USING (SELECT @warehouse_id AS warehouse_id) AS source
ON source.warehouse_id = target.warehouse_id
WHEN MATCHED THEN UPDATE SET
snapshot_generation = @snapshot_generation,
snapshot_status = 'PUBLISHED',
synchronized_at = SYSUTCDATETIME(),
source_signature = @post_source_signature,
source_row_count = @post_source_count,
instance_id = @instance_id,
last_error = NULL
WHEN NOT MATCHED THEN INSERT (
warehouse_id, snapshot_generation, snapshot_status, synchronized_at,
source_signature, source_row_count, instance_id, last_error)
VALUES (
@warehouse_id, @snapshot_generation, 'PUBLISHED', SYSUTCDATETIME(),
@post_source_signature, @post_source_count, @instance_id, NULL);
COMMIT TRANSACTION;
```
Before this block, copy the exact planning fields from `dbo.bas_cargo_space`, `dbo.bas_stock`, `dbo.mydo_job`, `dbo.mydo_job_detail`, `dbo.in_stockin_detail`, and `dbo.out_stockout_detail` into their generation-qualified `alg` snapshots, then run the topology and lane-state statements only over those copied rows. No dynamic SQL is permitted.
- [ ] **Step 6: Implement a strict v2-to-v3 upgrade and matching v3 uninstall.**
The upgrade validates package id/version `2026-08-12-1`, validates the complete v2 allow-list, applies DDL in a transaction, creates v3 procedures/views, updates the manifest last, and leaves existing `decision_log` rows intact. When the exact `2026-09-03-3` manifest and complete v3 allow-list are already present, rerunning the upgrade exits successfully without DDL. The uninstaller validates only `2026-09-03-3`, drops all v3 procedures/views/tables in dependency order, and refuses unknown/missing objects.
- [ ] **Step 7: Extend diagnostics for generation, heartbeat, metrics, input coverage, and blocking.**
Add read-only result sets for manifest, published generation age/status/signature, input row counts per generation, recent instance claim, admission histogram, comparison completeness, inventory parity, and sessions blocked by `alg.*` statements. Diagnostics must not update any object.
- [ ] **Step 8: Run migration tests and verify GREEN.**
```bash
mvn -pl fortucky-algorithm -Dtest=ShadowSqlServerMigrationScriptTest test
```
- [ ] **Step 9: Commit the v3 SQL package.**
```bash
git add fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql fortucky-algorithm/src/main/resources/db/sqlserver/upgrade-shadow-v2-to-v3.sql fortucky-algorithm/src/main/resources/db/sqlserver/uninstall-shadow.sql fortucky-algorithm/src/main/resources/db/sqlserver/diagnostics-shadow.sql fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/ShadowSqlServerMigrationScriptTest.java
git commit -m "feat: publish atomic shadow snapshot generations"
```
### Task 5: Read and Plan Exclusively From the Leased Generation
**Files:**
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSnapshotDescriptor.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowInboundReference.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowStockReference.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/InboundShadowObservation.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/OutboundShadowObservation.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/mapper/ShadowInputMapper.java`
- Modify: `ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java`
- Test: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java`
**Interfaces:**
- Produces: `boolean claimInstance(String warehouseId, String instanceId)`, `void refreshSnapshot(String warehouseId, long generation, String instanceId)`, and `ShadowSnapshotDescriptor selectPublishedSnapshot(String warehouseId)` mapper operations.
- Produces engine methods `refreshSnapshot(long generation, String instanceId)`, `planInbound(PublishedShadowSnapshot, String, String)`, `planProjectOutbound(PublishedShadowSnapshot, String)`, and `planExactStockOutbound(PublishedShadowSnapshot, String, String)`.
- Consumes: Task 3 facade methods that never call `refreshChangedLanes`.
- Guarantees: input mapper planning selects read only `alg.shadow_*_request_snapshot` and `alg.inventory_item_snapshot`, while algorithm repository views read only `alg.cargo_space_snapshot`, `alg.active_job_snapshot`, and the other published read-model tables; every path is warehouse- and generation-qualified and raw warehouse remains diagnostic only.
- Guarantees: the engine reads `selectPublishedSnapshot` before and after planning and rejects the observation unless both descriptors match the leased generation, warehouse, synchronization time, and source signature.
`ShadowSnapshotDescriptor` is a MyBatis JavaBean with `long snapshotGeneration`, `String warehouseId`, `String snapshotStatus`, `Instant synchronizedAt`, and uppercase hexadecimal `String sourceSignature`, with ordinary getters/setters. `refreshSnapshot` converts a validated descriptor into the immutable `PublishedShadowSnapshot` record from Task 2.
- [ ] **Step 1: Replace current engine tests with failing stable-generation tests while retaining transaction assertions.**
```java
PublishedShadowSnapshot snapshot = new PublishedShadowSnapshot(
42L, "01", Instant.parse("2026-09-03T00:00:00Z"), "ABC123");
when(mapper.selectInboundInput("01", 42L, "TASK-1", "TRAY-1")).thenReturn(input);
InboundShadowObservation observation = engine.planInbound(snapshot, "TASK-1", "TRAY-1");
verify(facade).planInboundFromPublishedSnapshot(eq("01"), any());
verify(mapper).selectInboundInput("01", 42L, "TASK-1", "TRAY-1");
assertThat(observation.snapshotGeneration()).isEqualTo(42L);
assertThat(observation.warehouseId()).isEqualTo("01");
```
- [ ] **Step 2: Add tests proving raw `W02` stays diagnostic while planning uses configured/published `01`.**
```java
reference.setWarehouseId("W02");
engine.planProjectOutbound(snapshot, "DETAIL-1");
verify(facade).planOutboundFromPublishedSnapshot(eq("01"), any());
assertThat(observation.reason()).contains("rawWarehouse=W02");
```
Also test raw `1` normalizes to `01`, exact-stock lookup uses `(warehouseId, generation, stockId)`, and no planning mapper query contains `from bas_stock`, `from mydo_job`, or `from out_stockout_detail`.
- [ ] **Step 3: Add a failing test for generation change during planning.**
```java
when(mapper.selectPublishedSnapshot("01"))
.thenReturn(descriptor(42L), descriptor(43L));
assertThatThrownBy(() -> engine.planInbound(snapshot, "TASK-1", "TRAY-1"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("published snapshot changed during planning");
verify(mapper, never()).insertComparison(any(), any(), any(), any(), any(), any());
```
- [ ] **Step 4: Add refresh/claim tests.**
```java
when(mapper.claimInstance("01", "INSTANCE-1", 120)).thenReturn(true);
when(mapper.selectPublishedSnapshot("01")).thenReturn(descriptor(43L));
PublishedShadowSnapshot refreshed = engine.refreshSnapshot(43L, "INSTANCE-1");
verify(mapper).refreshSnapshot("01", 43L, "INSTANCE-1");
assertThat(refreshed.generation()).isEqualTo(43L);
```
Reject null, non-`PUBLISHED`, wrong generation, or non-equivalent descriptor warehouse.
- [ ] **Step 5: Run planning tests and verify RED.**
```bash
mvn -pl ruoyi-admin -am -Dtest=ShadowPlanningEngineTest -Dsurefire.failIfNoSpecifiedTests=false test
```
- [ ] **Step 6: Implement fixed mapper signatures and generation-qualified SQL.**
```java
ShadowInboundReference selectInboundInput(@Param("warehouseId") String warehouseId,
@Param("generation") long generation, @Param("taskNo") String taskNo,
@Param("trayNo") String trayNo);
ShadowStockReference selectProjectOutboundReference(@Param("warehouseId") String warehouseId,
@Param("generation") long generation, @Param("stockoutDetailId") String detailId);
ShadowStockReference selectStockReference(@Param("warehouseId") String warehouseId,
@Param("generation") long generation, @Param("stockId") String stockId);
boolean claimInstance(@Param("warehouseId") String warehouseId,
@Param("instanceId") String instanceId, @Param("staleSeconds") int staleSeconds);
void refreshSnapshot(@Param("warehouseId") String warehouseId,
@Param("generation") long generation, @Param("instanceId") String instanceId);
ShadowSnapshotDescriptor selectPublishedSnapshot(@Param("warehouseId") String warehouseId);
int insertComparison(@Param("decisionId") String decisionId,
@Param("warehouseId") String warehouseId,
@Param("decisionType") String decisionType,
@Param("inputVersion") long inputVersion,
@Param("resultCode") String resultCode,
@Param("reason") String reason);
```
- [ ] **Step 7: Implement stable engine methods and remove per-plan refresh from the shadow path.**
Use `snapshot.warehouseId()` as the algorithm warehouse, preserve `reference.getWarehouseId()` as `rawWarehouse`, call only `plan*FromPublishedSnapshot`, and put `snapshotGeneration`, `snapshotSync`, `sourceSignature`, input fields, candidate counts, and raw warehouse in every comparison reason. Add `shadowCandidateCount` to `ShadowInboundReference`; populate it from the generation-qualified lane-state read model. Keep `shadowMatchCount` for outbound. Pass `snapshot.generation()` into `input_version` using `CONVERT(binary(8), #{inputVersion})`.
- [ ] **Step 8: Run planning tests and verify GREEN.**
Run the command from Step 5. Expected: all selected tests pass.
- [ ] **Step 9: Commit generation-qualified planning.**
```bash
git add ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java
git commit -m "feat: plan shadow decisions from leased generation"
```
### Task 6: Integrate Coordinator, Completion Tokens, Metrics, and Recovery
**Files:**
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowRuntimeMetrics.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowFailureStage.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/InboundShadowCompletion.java`
- Modify: `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/algorithm/shadow/mapper/ShadowInputMapper.java`
- Modify: `ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml`
- Test: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowObservationGatewayTest.java`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowRuntimeMetricsTest.java`
**Interfaces:**
- Completion tokens retain `accepted()` and existing success completion methods, and add non-throwing `fail(Throwable)`; success/failure both close one coordinator admission exactly once.
- `ShadowRuntimeMetrics.recordAdmission(ShadowSkipReason, long elapsedNanos)`, `recordLegacyDuration(long)`, `increment(ShadowSkipReason)`, and `recordError(ShadowFailureStage)` update fixed counters/histogram buckets only; `drain()` returns one bounded immutable batch for worker-side mapper flush.
- Gateway startup performs asynchronous instance claim and initial refresh. One admitted task plans, awaits Legacy only on the worker, validates comparability, writes comparison, and refreshes before `READY` can return.
- Worker-only warnings contain request id, configured warehouse, snapshot generation, fixed failure stage, exception class, and elapsed time; they exclude credentials, full payloads, and exception messages that may contain business data.
- [ ] **Step 1: Add failing completion tests for accepted, skipped, failed, and duplicate completion.**
```java
completion.complete("10", "SPACE-1");
completion.complete("10", "SPACE-2");
verify(onLegacyExit, times(1)).run();
completion.fail(new IllegalStateException("legacy failed"));
assertThatCode(() -> completion.fail(new IllegalStateException("again")))
.doesNotThrowAnyException();
verify(onLegacyExit, times(1)).run();
```
Add equivalent outbound assertions.
- [ ] **Step 2: Add failing gateway tests for initial refresh and valid generation flow.**
```java
gateway.start();
verify(engine, timeout(1000)).refreshSnapshot(eq(1L), anyString());
InboundShadowCompletion token = gateway.observeInbound("TASK-1", "TRAY-1");
assertThat(token.accepted()).isTrue();
token.complete("10", "LEGACY-SPACE");
verify(engine, timeout(1000)).planInbound(eq(snapshot), eq("TASK-1"), eq("TRAY-1"));
verify(engine, timeout(1000)).compareInbound(any(), eq("10"), eq("LEGACY-SPACE"));
```
- [ ] **Step 3: Add failing gateway tests for zero-wait skipping and invalid concurrent samples.**
Block the worker with latches, submit a second Legacy call, and assert the second token returns before a 1-second test bound, is not accepted, never calls a planner, closes activity on completion, and causes the first observation to be recorded as `SKIP_CONCURRENT` rather than a valid comparison. Test rejected execution, worker timeout, planning error, comparison error, refresh failure, three retries with 1/2/4-second injected backoff, and one-minute recovery coalescing using fake sleeper/clock collaborators rather than real sleeps.
- [ ] **Step 4: Add failing metric histogram tests.**
```java
metrics.recordAdmission(ShadowSkipReason.BUSY, 750_000L);
metrics.recordAdmission(null, 1_500_000L);
var batch = metrics.drain();
assertThat(batch.count("SKIP_BUSY")).isEqualTo(1);
assertThat(batch.bucketCount("ADMISSION_US", 1000)).isEqualTo(1);
assertThat(batch.bucketCount("ADMISSION_US", 2500)).isEqualTo(1);
assertThat(metrics.drain().totalCount()).isZero();
```
- [ ] **Step 5: Run gateway/metrics tests and verify RED.**
```bash
mvn -pl ruoyi-admin -am -Dtest=ShadowObservationGatewayTest,ShadowRuntimeMetricsTest,ShadowSnapshotCoordinatorTest -Dsurefire.failIfNoSpecifiedTests=false test
```
- [ ] **Step 6: Implement idempotent tracked completion tokens.**
```java
public void fail(Throwable failure) {
finish(new LegacyResult(null, null, failure));
}
private void finish(LegacyResult result) {
if (finished.compareAndSet(false, true)) {
try { legacyResult.complete(result); }
finally { onLegacyExit.run(); }
}
}
```
Rejected tokens used while shadow is fully disabled remain allocation-free singletons. Enabled-but-skipped calls receive tracked tokens so their Legacy activity exits in `complete`/`fail`.
- [ ] **Step 7: Implement bounded metrics and worker-only persistence.**
Use atomic counters and fixed admission/Legacy-duration microsecond buckets `100, 250, 500, 1000, 2500, 5000, 10000, overflow`, extending duration buckets to `25000, 50000, 100000, 250000, 500000, 1000000, overflow`. Add fixed mapper upserts into `alg.shadow_runtime_metric`; no mapper call may occur from `observe*`, `complete`, or `fail`. Completion records Legacy duration using its captured `System.nanoTime()` start without blocking.
- [ ] **Step 8: Rewrite gateway orchestration around the coordinator.**
Use a one-thread executor with capacity one and `AbortPolicy`; expose package-private constructor parameters for `Clock`, nested `Sleeper`, executor, and instance id so tests are deterministic. Define `@FunctionalInterface interface Sleeper { void sleep(Duration delay) throws InterruptedException; }` inside the gateway. `start()` only schedules work. `observe*` calls `coordinator.enter`, records elapsed nanos, immediately returns tracked skip on non-admission, and submits accepted work without logging on the caller.
Inbound worker control flow:
```java
try {
InboundShadowObservation observation =
engine.planInbound(admission.snapshot(), taskNo, trayNo);
InboundShadowCompletion.LegacyResult legacy = completion.awaitLegacyResult();
if (coordinator.isComparable(admission)) {
engine.compareInbound(observation, legacy.resultCode(), legacy.spaceId());
} else {
metrics.increment(ShadowSkipReason.CONCURRENT);
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
} catch (Exception shadowFailure) {
metrics.recordError(stage(shadowFailure));
} finally {
coordinator.finishObservation(admission);
refreshWithBoundedRecovery();
}
```
Use the same control flow for project and exact-stock outbound with `OutboundShadowObservation`, `completion.awaitLegacyResult()`, and `engine.compareOutbound(observation, legacyResult)`. `stage(Throwable)` returns one fixed enum value from the currently executing worker stage; it never includes payload text.
The actual implementation must keep admission exit owned by caller completion and worker lease cleanup separately idempotent, so worker timeout cannot falsely reduce the active Legacy count. Emit the required structured warning only from this worker catch block.
- [ ] **Step 9: Run gateway/metrics tests twice and verify GREEN.**
Run the Step 5 command twice. Expected: deterministic pass with no timing sleeps in tests.
- [ ] **Step 10: Commit the v3 asynchronous pipeline.**
```bash
git add ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow ruoyi-admin/src/main/resources/mapper/algorithm/ShadowInputMapper.xml ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow
git commit -m "feat: run shadow v3 without legacy waiting"
```
### Task 7: Guarantee Legacy Activity Exit on Every Service Path
**Files:**
- 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`
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/api/service/impl/ToWmsServiceImplShadowTest.java`
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/out/outStockout/service/impl/OutStockoutServiceImplShadowTest.java`
**Interfaces:**
- Consumes: idempotent `complete(...)` and `fail(Throwable)` completion tokens from Task 6.
- Guarantees: stored-procedure success, warning, early return, runtime failure, and error paths all close shadow activity without swallowing or replacing Legacy behavior.
- [ ] **Step 1: Add failing inbound Legacy exception and response-branch tests.**
```java
IllegalStateException legacyFailure = new IllegalStateException("legacy database failure");
doThrow(legacyFailure).when(mydoJobMapper).allotInJobSpace(any());
assertThatThrownBy(() -> service.requestEnterByTaskNo(request)).isSameAs(legacyFailure);
verify(shadowCompletion).fail(legacyFailure);
verify(mydoJobMapper, times(1)).allotInJobSpace(any());
```
Retain success and warning assertions and verify exactly one `complete` or `fail` call.
- [ ] **Step 2: Add failing automatic/manual outbound Legacy exception tests.**
For both `autoAllot` and `manualAllot`, throw the same mapper exception, assert the exact exception escapes unchanged, assert the stored procedure was called once for the affected item, and verify `shadowCompletion.fail(sameException)` exactly once.
- [ ] **Step 3: Run service tests and verify RED.**
```bash
mvn -pl ruoyi-admin -am -Dtest=ToWmsServiceImplShadowTest,OutStockoutServiceImplShadowTest -Dsurefire.failIfNoSpecifiedTests=false test
```
- [ ] **Step 4: Wrap each original Legacy call/result block with failure notification and unchanged rethrow.**
```java
try {
mydoJobMapper.allotInJobSpace(map);
} catch (RuntimeException | Error legacyFailure) {
shadowCompletion.fail(legacyFailure);
throw legacyFailure;
}
```
Use the same shape around each `autoAllot` and `manualAllot`. Keep normal `complete` at the same post-Legacy point. Remove synchronous shadow warning logs from caller-side submission catches; they return rejected tokens and increment only in-memory metrics inside the gateway.
- [ ] **Step 5: Run service tests and verify GREEN, including original ordering.**
Run the Step 3 command. Verify existing order remains lookup → observe → Legacy procedure once → Legacy result/reload → completion.
- [ ] **Step 6: Commit exact-once Legacy exit handling.**
```bash
git add ruoyi-admin/src/main/java/com/ruoyi/api/service/impl/ToWmsServiceImpl.java ruoyi-admin/src/main/java/com/ruoyi/out/outStockout/service/impl/OutStockoutServiceImpl.java ruoyi-admin/src/test/java/com/ruoyi/api/service/impl/ToWmsServiceImplShadowTest.java ruoyi-admin/src/test/java/com/ruoyi/out/outStockout/service/impl/OutStockoutServiceImplShadowTest.java
git commit -m "fix: close shadow activity on legacy failures"
```
### Task 8: Add CMD Deployment, Upgrade, Rollback, and Export Tooling
**Files:**
- Create: `scripts/shadow-v3-preflight.cmd`
- Create: `scripts/shadow-v3-install.cmd`
- Create: `scripts/shadow-v3-uninstall.cmd`
- Create: `scripts/shadow-v3-export.cmd`
- Create: `scripts/shadow-v3-export.sql`
- Modify: `doc/algorithm-shadow/README.md`
- Create: `doc/algorithm-shadow/SHADOW_V3_SERVER_AI_EXPORT_PROMPT.md`
- Create: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowV3OperationsPackageTest.java`
**Interfaces:**
- `shadow-v3-preflight.cmd REPO_ROOT EXPECTED_COMMIT SQL_SERVER DATABASE` verifies branch, exact commit, clean worktree, main ancestry, one FT546WMS process maximum, SQL Server connectivity, manifest, and current snapshot state without changing data.
- `shadow-v3-install.cmd SQL_SERVER DATABASE upgrade` runs only the recognized v2-to-v3 upgrade; mode `clean` runs the clean v3 installer. Both use Windows authentication, `sqlcmd -b -r1`, and stop on error.
- `shadow-v3-uninstall.cmd SQL_SERVER DATABASE CONFIRM_UNINSTALL` requires the literal confirmation and calls only the verified v3 uninstaller.
- `shadow-v3-export.cmd REPO_ROOT SQL_SERVER DATABASE START_UTC OUTPUT_DIR LOG_DIR` produces deployment, SQL diagnostics, comparison detail, metric, and warning-log files without database writes.
- [ ] **Step 1: Add failing operations-package tests for files, arguments, and safety guards.**
```java
assertThat(preflight).contains("git status --porcelain", "git merge-base --is-ancestor", "jps -lv");
assertThat(installer).contains("if /I \"%~3\"==\"upgrade\"", "sqlcmd", "-b", "-r1");
assertThat(uninstaller).contains("CONFIRM_UNINSTALL");
assertThat(exporter).contains("git rev-parse HEAD", "shadow-v3-export.sql");
assertThat(exportSql.toLowerCase()).doesNotContain("insert ", "update ", "delete ", "merge ");
```
- [ ] **Step 2: Run the operations-package test and verify RED.**
```bash
mvn -pl ruoyi-admin -Dtest=ShadowV3OperationsPackageTest test
```
- [ ] **Step 3: Implement preflight and install/uninstall CMD scripts with quoted paths and error propagation.**
Every script begins with `@echo off` and `setlocal`, validates non-empty positional arguments, uses `%~dp0` for repository-owned SQL paths, checks `errorlevel`, and exits nonzero on failure. Never use PowerShell. The uninstaller prints that `alg.*` evidence must be exported first and refuses every confirmation other than `CONFIRM_UNINSTALL`.
Installer command shape:
```cmd
sqlcmd -S "%~1" -d "%~2" -E -b -r1 -i "%SQL_FILE%"
if errorlevel 1 exit /b 1
```
- [ ] **Step 4: Implement one read-only SQL export and CMD collector.**
The SQL accepts sqlcmd variables `WarehouseId` and `StartUtc`, uses only `SELECT`, and outputs manifest, snapshot state/signature, heartbeat, input coverage, runtime metric buckets, valid/skip/error outcome counts, complete comparison rows, inventory parity, and blocking evidence. The CMD collector writes Git branch/commit/status, `jps -lv`, environment variable names with secrets excluded, diagnostics output, and matching application log lines into `OUTPUT_DIR`.
- [ ] **Step 5: Update the Chinese runbook and server-AI prompt with exact v3 order.**
Document: export v2 evidence → stop v2 IDEA run → `git fetch`/checkout exact v3 commit → preflight → install `upgrade` → set the two new environment variables → start IDEA once → read-only health checks → seven-day export → disable/restart rollback → optional confirmed uninstall. State explicitly that IDEA restart creates a short planned outage and that normal requests never wait after startup.
- [ ] **Step 6: Run operations tests and CMD parser checks.**
```bash
mvn -pl ruoyi-admin -Dtest=ShadowV3OperationsPackageTest test
cmd.exe /d /c "call scripts\shadow-v3-preflight.cmd 2>NUL & if not errorlevel 1 exit /b 9"
cmd.exe /d /c "call scripts\shadow-v3-uninstall.cmd 2>NUL & if not errorlevel 1 exit /b 9"
```
Expected: JUnit passes; both no-argument script calls reject input with a nonzero exit code and do not run SQL.
- [ ] **Step 7: Commit the field operations package.**
```bash
git add scripts/shadow-v3-preflight.cmd scripts/shadow-v3-install.cmd scripts/shadow-v3-uninstall.cmd scripts/shadow-v3-export.cmd scripts/shadow-v3-export.sql doc/algorithm-shadow/README.md doc/algorithm-shadow/SHADOW_V3_SERVER_AI_EXPORT_PROMPT.md ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowV3OperationsPackageTest.java
git commit -m "docs: add shadow v3 field operations package"
```
### Task 9: Full Verification, Independent Review, and Branch Publication
**Files:**
- Modify only when a verification failure is directly caused by v3 changes; return to the owning task's test-first loop before editing.
- Review: every file changed from `3f7ed3718defef2c4ab37e1deb2534d2329747e4` through `HEAD`.
**Interfaces:**
- Produces: one clean, pushed branch with all automated evidence passing.
- Does not produce: a formal ACTIVE cutover, production database execution, or field deployment.
- [ ] **Step 1: Verify the exact branch ancestry and worktree scope.**
```bash
git branch --show-current
git merge-base --is-ancestor 916ac6bda32a5b102a3181cc8c5dbd5319c5656d HEAD
git status --short
git diff --check 3f7ed3718defef2c4ab37e1deb2534d2329747e4...HEAD
```
Expected: branch is `test/ft546wms-shadow-v3-20260903`, ancestry exits 0, status is clean, and diff check is empty.
- [ ] **Step 2: Run all algorithm tests.**
```bash
mvn -pl fortucky-algorithm test
```
Expected: BUILD SUCCESS with no failed/error tests.
- [ ] **Step 3: Run all admin shadow and Legacy integration tests.**
```bash
mvn -pl ruoyi-admin -am -Dtest=ShadowConfigurationTest,ShadowSafetyGuardTest,WarehouseIdNormalizerTest,ShadowSnapshotCoordinatorTest,ShadowPlanningEngineTest,ShadowObservationGatewayTest,ShadowRuntimeMetricsTest,ShadowV3OperationsPackageTest,ToWmsServiceImplShadowTest,OutStockoutServiceImplShadowTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: BUILD SUCCESS with no failed/error tests.
- [ ] **Step 4: Run the full reactor tests and package.**
```bash
mvn test
mvn -DskipTests package
```
Expected: both commands report BUILD SUCCESS.
- [ ] **Step 5: Run script syntax/static checks.**
```bash
node --check scripts/shadow-export-mcp.js
node --check scripts/shadow-followup-mcp.js
git diff --check 3f7ed3718defef2c4ab37e1deb2534d2329747e4...HEAD
```
Expected: no output except normal command headers and exit code 0.
- [ ] **Step 6: Perform an independent code review against the spec.**
Invoke `superpowers:requesting-code-review` and require the reviewer to check: Legacy exact-once behavior; no caller-side blocking/I/O/logging; all completion exits; refresh/compare concurrency validity; SQL object/DML allow-list; v2 upgrade rollback; warehouse `01` authority; CMD quoting and destructive confirmation; and missing tests. Resolve every P0/P1 issue and rerun the owning focused tests.
- [ ] **Step 7: Record the final verification commit without amending earlier task commits.**
If review corrections were required, commit them with their precise scope. If no tracked files changed, do not create an empty commit.
```bash
git status --short --branch
git log --oneline --decorate 3f7ed3718defef2c4ab37e1deb2534d2329747e4..HEAD
```
- [ ] **Step 8: Push the v3 branch only after all evidence is green.**
```bash
git push -u origin test/ft546wms-shadow-v3-20260903
```
- [ ] **Step 9: Hand off the exact field deployment values.**
Report the pushed commit hash, branch, local test counts, package result, SQL manifest version, and these environment values:
```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_SHADOW_SNAPSHOT_MAX_AGE_SECONDS=60
FORTUCKY_SHADOW_QUIET_INTERVAL_MILLIS=1000
```
State that field execution has not been performed locally, use the v2-to-v3 `upgrade` mode at the current site, and give the exact disable/restart rollback before any optional uninstall instruction.

View File

@@ -0,0 +1,61 @@
# Shadow v3 Observation Repair
> Execute inline with test-driven-development and verification-before-completion. Subagents explore and review only, per project instructions.
**Goal:** Restore timezone-independent shadow sampling and exportable diagnostics without changing Legacy.
**Architecture:** Use a statement-scoped UTC Instant handler, a bounded shadow-only log, and literal sqlcmd output paths. Preserve all scheduling, freshness, transaction and mode guards.
**Tech Stack:** Java 17, MyBatis 3.5.19, Microsoft JDBC 12.10.1, Logback, Windows CMD, SQL Server 2016 RTM.
**Spec:** `docs/superpowers/specs/2026-09-03-ft546wms-shadow-v3-design.md`.
## Constraints
- No global timezone/type-handler override; no Legacy code or business SQL changes.
- Keep SHADOW mode, 60-second snapshot freshness and existing single-worker coordination.
- Do not restart field processes or migrate SQL objects. Keep historical metric counters.
- Export with Windows integrated authentication, read-only SQL, and no overwriting existing evidence.
## 1. UTC Mapping
- [x] Add `ShadowUtcMappingTest` loading the real mapper. Exercise UTC and Asia/Shanghai reads through the actual driver's datetime2 conversion, preserve 100ns precision, and verify fresh/stale coordinator admission.
- [x] Run the test and observe the eight-hour failure.
- [x] Add `mapper/ShadowUtcInstantTypeHandler.java`; use `getTimestamp(..., Calendar.getInstance(TimeZone.getTimeZone("UTC")))` and `setTimestamp(..., Timestamp.from(value), utcCalendar)`.
- [x] Add an explicit snapshot result map and bind the handler only to `snapshotSynchronizedAt` and both `flushedAt` placeholders in `ShadowInputMapper.xml`.
- [x] Verify parameter writes, nulls, both JVM zones, and unchanged default MyBatis handler.
## 2. Shadow Diagnostics
- [x] Add an actual Logback routing test: a shadow WARN must reach a file; unrelated business WARN and INFO must not enter it.
- [x] Observe failure with the current configuration.
- [x] Add a shadow-only WARN-and-higher rolling appender with full dates, 10MB files, 14-day history and 100MB archive cap.
- [x] Add bounded exception metadata (cause class, SQL state/vendor code, source frame) without messages, SQL or payloads; keep logging on the existing worker paths.
- [x] Verify routing, error context, and existing caller isolation tests.
## 3. Export Compatibility
- [x] Add CMD behavior tests with spaced paths, literal SQL output destinations, success/failure, working-directory restoration and evidence preservation.
- [x] Run and observe the existing output-path failure. Drain test process output without pipe deadlock.
- [x] Run sqlcmd from `OUTPUT_DIR`, use literal `:out` names, balance pushd/popd and check each Git command failure.
- [x] Label local export time honestly; add explicit zero-comparison status using read-only SQL.
- [x] Update server AI instructions and add maintenance-window deployment/rollback steps, with no SQL reinstall.
## Review Notes
- UTC read/write and nanosecond mapping reviewed independently; no confirmed defect found.
- Actual Logback configuration test passed with no ERROR status. The existing two root declarations are accepted by the installed Logback; changing them is unnecessary scope expansion.
- Logger additivity intentionally preserves existing console/error behavior. The new file receives only shadow WARN/ERROR, confirmed by file-routing tests.
- SQL diagnostics intentionally select the deepest SQLException within eight cause levels; exception messages and SQL are never logged. The worker test verifies SQLState, code, source and payload exclusion.
- CMD fixture executes the real scripts but does not execute SQL. No live SQL Server or field deployment was performed locally; those remain explicit verification limits.
## Verification and Delivery
- [x] Run focused tests, then reactor tests and package with Maven 3.9.11 / JDK 17; run existing Node guards.
- [x] Independent read-only review, address findings, rerun affected tests and `git diff --check`.
- [x] Fetch remote again and verify no concurrent remote commits before preparing delivery.
- [x] Prepare simple CMD update/rollback steps in `doc/algorithm-shadow/SHADOW_V32_UPDATE.md`.
Final verification: 138 algorithm tests + 78 admin tests = 216 passed, zero failures/errors/skips. Maven reactor `package` succeeded on 2026-09-07; three existing Node scripts passed `node --check`. Existing Lombok/deprecation and JVM test-instrumentation warnings remain. No live SQL Server validation or field restart was performed.
Delivery: commit and push only `test/ft546wms-shadow-v3-20260903`, verify the remote hash, and report it in the handoff. Do not merge into main or switch to ACTIVE.

View File

@@ -0,0 +1,12 @@
# 现场修复实施计划
设计:../specs/2026-09-09-shadow-repair-design.md
- [x] 现场证据和根因调查;干净隔离工作树基线测试通过。
- [x] 添加查询绑定、UDP、入出库候选投影回归测试,确认失败。
- [x] 实现无数据库升级的修复;逐组测试。
- [x] 完整离线构建和全部 JUnit 测试,独立审查;审查修正后的最终构建成功。
- [ ] 生成版本化部署包并上传,新增启动项;不重启。
- [ ] 交付切换步骤、回退方式和仍需现场验证的边界。
后续上传与现场配置结果以独立交付目录中的 build-verification.json / site-preparation.json 为准,避免预先声明部署完成。

View File

@@ -0,0 +1,105 @@
# FT546WMS Takeover Readiness Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Produce a testable candidate that reports equivalent outbound failures correctly, refreshes reliably, and never dispatches a blocked rear outbound before its relocation completes.
**Architecture:** Keep Legacy as the production executor while hardening observation and dispatch safety. Separate refresh timeout from request comparison timeout; classify outbound comparisons by business meaning; enforce the relocation dependency both before WMS HTTP dispatch and, where the WCS contract permits, before WCS execution. ACTIVE takeover remains disabled until a dedicated mutation adapter and field evidence exist.
**Tech Stack:** Java 17, Spring Boot, MyBatis, SQL Server 2016, JUnit 5, Mockito, PowerShell integration fixtures.
**Spec:** User instruction in this thread: relocation precedes associated outbound; completion and inventory update release outbound; WCS must guard bypasses; unrelated channels continue normally.
## Global Constraints
- No production switch, restart, synthetic production task, or business-data mutation during implementation.
- Existing mixed-pilot lane scope and Legacy allocation behavior remain unchanged.
- Every behavior change follows red-green testing and is independently revertible.
- SQL Server 2016 syntax only; no online dependency downloads are assumed.
---
### Task 1: Outbound comparison semantics
**Files:**
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java`
**Interfaces:**
- Consumes: `OutboundShadowObservation.resultCode()` and Legacy numeric result code.
- Produces: stable `MATCH`, `DIFFERENT`, `NOT_COMPARABLE` comparison codes while retaining raw evidence in `reason`.
- [ ] Add a failing test proving shadow `ERROR_STOCK_NOT_FOUND` plus Legacy `3` is an equivalent no-inventory outcome, and successful/technical/skipped cases are not falsely matched.
- [ ] Run only `ShadowPlanningEngineTest` and observe the exact `LEGACY_3` failure.
- [ ] Add a private outbound comparison classifier; do not alter planning or Legacy execution.
- [ ] Re-run focused and shadow-module tests.
- [ ] Commit independently.
### Task 2: Dedicated refresh timeout
**Files:**
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowConfigurationTest.java`
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngineTest.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowObservationProperties.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowPlanningEngine.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/shadow/ShadowSafetyGuard.java`
- Modify: `ruoyi-admin/src/main/resources/application.yml`
**Interfaces:**
- Consumes: `FORTUCKY_SHADOW_REFRESH_TIMEOUT_SECONDS`, default 10 seconds.
- Produces: a refresh-only transaction timeout; the 3-second Legacy comparison deadline stays unchanged.
- [ ] Add failing property/guard tests and a transaction-manager test that distinguishes planning timeout from refresh timeout.
- [ ] Run focused tests and observe missing property/incorrect timeout failure.
- [ ] Use separate transaction templates or per-refresh timeout without sharing mutable template state.
- [ ] Re-run focused and shadow-module tests.
- [ ] Commit independently.
### Task 3: WMS dispatch dependency gate
**Files:**
- Create or modify focused mapper/service files under `ruoyi-admin/src/main/java/com/ruoyi/api` and `ruoyi-admin/src/main/resources/mapper` after locating the existing job mapper contract.
- Create: focused JUnit test for `ToWcsServiceImpl.pushOutMydoJob()`.
- Modify: `scripts/mixed-pilot/tests/behavior.sql` only if an SQL regression is required by the confirmed race.
**Interfaces:**
- Consumes: candidate outbound job plus current lane obstruction / unresolved relocation state.
- Produces: dispatch decision `READY` or `WAITING_FOR_RELOCATION`; only READY calls WCS and advances status.
- [ ] Add a failing service test: blocked rear outbound makes zero HTTP calls, unrelated/front outbounds still dispatch, and completing relocation makes the rear job dispatch exactly once.
- [ ] Reproduce the task-creation race in the SQL scratch fixture if it bypasses the existing status-8 trigger.
- [ ] Add the smallest read-only pre-dispatch guard and atomic claim/update boundary supported by current service patterns.
- [ ] Re-run Java and SQL mixed-pilot regression suites.
- [ ] Commit independently.
### Task 4: WCS defense and release candidate
**Files:**
- Modify only the actual FT546-WCS receiving/execution module identified by repository audit.
- Add focused WCS tests at its receive or execution seam.
- Add deployment and rollback notes under `scripts/` in each affected repository.
**Interfaces:**
- Consumes: WMS job payload and WCS current/declared dependency evidence.
- Produces: explicit accepted/waiting/rejected behavior without treating HTTP receipt as physical completion.
- [ ] Add a failing WCS test proving a blocked rear task cannot enter executable state, including manual/replayed payloads.
- [ ] Implement the minimal defense compatible with the existing WMS/WCS contract; if WCS lacks authoritative obstruction data, reject ambiguous rear tasks rather than inventing state.
- [ ] Run both repository test suites and inspect package contents offline.
- [ ] Prepare versioned artifacts and rollback scripts; do not deploy or switch without a maintenance decision.
- [ ] Request independent code review and address findings.
### Task 5: ACTIVE takeover gate
**Files:**
- No production code change until Tasks 1-4 and field evidence pass.
- Update readiness documentation with exact remaining mutation adapter and rollback requirements.
**Interfaces:**
- Consumes: verified planner result and explicit operator mode.
- Produces: a future single-writer execution path, never simultaneous Legacy and algorithm allocation.
- [ ] Preserve `ShadowSafetyGuard` rejection of ACTIVE in this candidate.
- [ ] Define endpoint-by-endpoint single-writer tests before adding any ACTIVE mutation adapter.
- [ ] Require fresh inbound, successful outbound, no-inventory outbound, refresh and relocation field evidence before implementation approval.
- [ ] Do not claim formal takeover readiness merely because the candidate builds.

View File

@@ -0,0 +1,38 @@
# FT546WMS Floor 1 Inbound Pilot Implementation Plan
**Goal:** Prepare, but do not activate, a warehouse `01` floor `1` inbound pilot with atomic mutation and automatic Legacy fallback.
**Constraints:** No field restart, process switch, SQL installation, or production business-data write. SQL Server 2016 syntax only. The independent pilot switch remains disabled in every prepared startup file.
## Task 1: Routing contract
- [x] Add failing configuration tests for the disabled default and explicit pilot properties.
- [x] Add failing service tests for out-of-scope Legacy routing, successful pilot single-writer routing, conflict fallback, and exception fallback.
- [x] Add the pilot properties, result contract, and the minimal `ToWmsServiceImpl` routing seam.
## Task 2: Floor-constrained bounded planning
- [x] Add a failing planning test proving `targetFloor=1` is forwarded to the algorithm.
- [x] Add a synchronous pilot planner that accepts only a current published warehouse `01` snapshot.
- [x] Run planning in a dedicated single-thread daemon executor with a bounded wait; timeout/cancellation remains read-only.
- [x] Reject missing, stale, changing, non-ready, or non-assigned plans and route them to Legacy.
## Task 3: Atomic production mutation
- [x] Add mapper contract tests and static SQL safety tests first.
- [x] Add `dbo.proc_allot_layer1_inbound_pilot` with one transaction, `XACT_ABORT`, update/range locks, live floor/lane/space checks, guarded row counts, and LED parity.
- [x] Add Java mutation result handling and post-exception assignment resolution so an uncertain commit never triggers a second writer.
- [x] Record best-effort `INBOUND_PILOT` evidence in existing `alg.decision_log`.
## Task 4: Operations package
- [x] Add SQL Server 2016 install, read-only check, rollback, and CMD wrappers.
- [x] Add a disabled IDEA run configuration template and a concise field runbook.
- [x] Ensure every prepared configuration sets `FORTUCKY_INBOUND_PILOT_ENABLED=false`.
## Task 5: Verification and delivery
- [x] Run focused red/green tests, all affected module tests, and offline packaging.
- [x] Inspect the JAR for the new classes/configuration and verify no secret is packaged by this change.
- [x] Request an independent code review and address critical or important findings.
- [x] Prepare and upload versioned artifacts only; do not install, restart, switch, or activate.

View File

@@ -0,0 +1,97 @@
# FT546 WMS Shadow-Only Design
## Goal
Create a clean field-test branch directly from `origin/main` that runs the Java storage algorithm only as an observer. The existing Legacy WMS stored procedures remain the sole owners of inbound and outbound decisions and business writes.
## Safety invariants
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 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
The existing `fortucky-algorithm` planner and JDBC read model are reused as an internal dependency. A new `ruoyi-admin` shadow adapter owns the integration boundary:
- `ShadowObservationProperties` controls enablement, warehouse fallback, and timeout.
- `ShadowSafetyGuard` validates the effective mode at startup.
- `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.
## Data flow
### Inbound
1. Load the same inbound job that `origin/main` already loads.
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. 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, 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. 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`, `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.
## Shadow database package
`install-shadow.sql` creates only:
- policy/read-model/diagnostic tables required by inbound and outbound planning;
- read-only views over WMS business data;
- 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.
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
The IDEA field-test configuration sets:
```text
FORTUCKY_SHADOW_ENABLED=true
FORTUCKY_ALGORITHM_MODE=SHADOW
FORTUCKY_ALGORITHM_DATABASE_WRITES_ENABLED=true
FORTUCKY_ALGORITHM_WAREHOUSE_ID=<field warehouse>
FORTUCKY_SHADOW_TIMEOUT_SECONDS=3
```
Replay variables remain unset. The stable `origin/main` Run Configuration is kept in a separate source directory for immediate rollback.
## Verification
Automated tests prove:
- observer failure still invokes Legacy exactly once;
- 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 `alg` objects, no triggers, and no WMS business-table DML;
- install/uninstall object lists are symmetrical;
- existing project tests and packaging still succeed.
Field verification compares inbound/outbound shadow logs with Legacy results, monitors SQL latency and locks, and stops immediately if normal WMS behavior differs from `origin/main`.

View File

@@ -0,0 +1,180 @@
# FT546 WMS Shadow v3 Stable-Snapshot Design
## Goal
Produce trustworthy pre-Legacy inbound and outbound comparison samples while preserving the production behavior of the current Legacy WMS. Shadow v3 remains observation-only: it cannot select a business location, change a Legacy result, or write a Legacy business table.
The field-test branch is `test/ft546wms-shadow-v3-20260903`, based on the deployed shadow v2 commit `3f7ed3718defef2c4ab37e1deb2534d2329747e4`. At branch creation, verified main commit `916ac6b` is an ancestor of that v2 commit. Deployment tooling must verify the expected branch, commit, clean worktree, and main ancestry again instead of relying on a moving `origin/main` name.
## Non-negotiable safety invariants
1. Legacy stored procedures remain the sole owners of inbound and outbound decisions and business writes. Each procedure is invoked exactly once with the same inputs and its result remains authoritative.
2. The Legacy caller never waits for shadow planning, comparison, snapshot refresh, retry, or logging. Submission performs only bounded in-memory bookkeeping; a busy, unavailable, or failed shadow path returns a no-op completion token immediately.
3. No shadow exception may escape into a Legacy request, participate in its transaction, change its response, or cause its transaction to roll back.
4. Shadow code writes only package-owned `alg.*` read-model and diagnostic objects. It performs no insert, update, delete, reservation, callback, or workflow action against Legacy WMS business tables.
5. Shadow v3 exposes no ACTIVE routing flag or controller. Turning shadow off restores the application to Legacy-only behavior without a database rollback.
6. Data quality is conservative: an observation is skipped when its preconditions cannot be proven. An uncertain result is never reported as a valid comparison.
7. This v3 field design supports one FT546WMS JVM connected to the shadow package. The deployment check must refuse to enable shadow when another FT546WMS process or recent shadow-instance heartbeat is present. Multi-instance support requires a later cross-instance coordination design.
## Why v3 is required
Shadow v2 refreshes its SQL read model inside every planning call. Field refreshes took about one second. Although the task is submitted before the Legacy procedure, thread scheduling commonly lets Legacy mutate the source rows before the background refresh reads them. This produced post-Legacy or incomplete observations, including outbound stock-not-found and waiting-channel results with missing snapshot diagnostics.
Shadow v3 separates snapshot refresh from planning. A planner can read only a previously completed, immutable stable snapshot; it never refreshes from live Legacy tables as part of the plan.
## Architecture
### Snapshot coordinator
A JVM-local coordinator owns the lifecycle of the single installed warehouse snapshot. Its states are:
- `INITIALIZING`: startup refresh has not produced a usable snapshot.
- `READY`: a completed stable snapshot may be leased by one observation.
- `BUSY`: a request has leased the snapshot, Legacy activity is in progress, or refresh is pending.
- `UNAVAILABLE`: refresh failed or the snapshot cannot be proven stable.
The coordinator stores a monotonically increasing Legacy activity epoch, active-Legacy count, snapshot generation, warehouse id, completion time, and last failure. Admission, activity changes, final epoch validation, and generation publication use one lock-free, stamped state transition protocol. A new Legacy entry changes the stamp, so a concurrent publish compare-and-set must fail. The Legacy path performs no database access, file access, synchronous logging, monitor acquisition, sleep, or wait.
Exactly one request may atomically enter Legacy activity, change `READY` to `BUSY`, and lease the current generation. While v3 shadow is enabled, every inbound/outbound Legacy invocation enters the activity protocol, including requests whose observation is stale, busy, or rejected. The matching exit is guaranteed by a `finally` block. Requests arriving in every other state receive a no-op observation token while still contributing to activity invalidation. With shadow disabled the gateway remains an immediate no-op. This sacrifices sample volume during bursts in exchange for trustworthy samples and zero Legacy waiting.
The caller-side path has a one-millisecond p99 engineering budget and uses only fixed-size immutable metadata plus non-waiting atomic operations. Worker admission uses a bounded queue with immediate rejection; it never runs work on the caller. Tests enforce structurally that admission contains no blocking API and record timing under contention without treating JVM pauses as functional failures. The field export reports admission maximum and percentile timings.
### Planning engine
Inbound and outbound planning receive an explicit snapshot generation and use that generation without calling `refreshChangedLanes`. The application service gains a stable-snapshot planning entry point; existing non-shadow behavior is left unchanged.
The SQL read model publishes a new generation only after a complete refresh succeeds. The refresh of topology, inventory, lane state, preserved inbound/outbound planning inputs, and metadata runs in one shadow-owned SQL transaction. The final metadata update is the publish marker and rolls back with every component on failure. Because the one worker serializes planning and refresh, no planner runs while those tables are being replaced; a planner also verifies the published marker before and after its reads. Published data is logically immutable until the lease completes. Planning is rejected when the requested generation, configured warehouse, or snapshot metadata does not match the published generation.
The refresh reads Legacy sources with `READ UNCOMMITTED` semantics so it does not hold shared locks that delay Legacy writers. Consistency is guarded by the application activity stamp for the supported single-JVM allocation entry points plus pre/post source row-count and checksum signatures over the planning fields. If the stamp or signature changes during refresh, the new generation is discarded and never becomes a valid sample source. This adds background reads but replaces v2's per-request refreshes, so total refresh frequency is bounded and expected to decrease. The design does not enable a database-wide isolation option.
### Background worker
One daemon worker continues to own all shadow work. The executor has one worker and at most one pending control task; rejection is immediate and never uses the Legacy caller as a fallback executor. Only one refresh may be scheduled at a time. For an admitted observation it:
1. plans against the leased stable snapshot;
2. waits in the background for the Legacy completion token;
3. writes the comparison and complete diagnostics;
4. waits for a quiet Legacy window;
5. refreshes and validates the next snapshot generation;
6. publishes `READY` only when the refresh is stable.
The Legacy completion wait is bounded by `FORTUCKY_SHADOW_TIMEOUT_SECONDS`, which applies only on the worker. Timeout, interruption, planning failure, comparison failure, and diagnostic failure release the observation lease in `finally`, invalidate the consumed generation, and continue to asynchronous refresh/recovery. If Legacy itself remains active beyond the deadline, shadow remains unavailable until the activity count returns to zero; it does not accumulate request payloads or additional observation tasks.
## Request data flow
### Common admission flow
1. At the existing pre-Legacy integration point, atomically enter Legacy activity, advance the epoch, and attempt to lease the current snapshot.
2. If shadow is disabled, the snapshot is not `READY`, it is stale, the published snapshot warehouse is wrong, or another observation owns it, return a no-op token immediately. Skip counts are updated in fixed-size in-memory counters and flushed by the worker; the caller performs no database or synchronous log write.
3. If admitted, copy only the immutable request identifiers already in memory and submit the worker task.
4. Execute the original Legacy procedure immediately, regardless of the shadow admission result.
5. In a `finally` block, complete the token with either the Legacy outcome or failure marker and exit Legacy activity. Completion never waits for the worker.
### Inbound
The admitted worker resolves and plans the inbound request exclusively from the leased snapshot. It records the shadow-selected space, the Legacy-selected space, snapshot generation and time, warehouse, candidate counts, outcome, timings, and skip/error reason.
### Outbound
The admitted worker resolves project, product, requested quantity, and stock candidates from the leased snapshot before recording the comparison. The diagnostic record contains an immutable copy of these inputs, so deletion or mutation of live outbound detail rows by Legacy cannot erase the evidence used by shadow planning.
## Refresh sequencing and concurrency
Snapshot refresh is asynchronous and can never delay a Legacy request.
1. Startup begins in `INITIALIZING`; all requests remain Legacy-only until the first refresh is validated.
2. After an admitted Legacy operation completes, the coordinator remains `BUSY`. Requests during this period run Legacy normally and are logged as skipped.
3. The worker waits until the local active-Legacy count reaches zero and remains quiet for the configured quiet interval.
4. It captures the activity epoch, refreshes the complete SQL read model in one shadow-owned transaction, then atomically publishes only if the active count is still zero, the epoch is unchanged, and the stamped coordinator state still represents that refresh. The same protocol prevents a request that starts between validation and publication from being missed.
5. If activity occurred during refresh, that generation is not published. The worker retries only in the background after another quiet interval.
6. Refresh is single-flight. It retries at most three times with one-, two-, and four-second background backoff. If refresh fails or the retry budget is exhausted, state becomes `UNAVAILABLE`; the last completed generation is retained for diagnosis but cannot be leased. Recovery is rate-limited to one cycle per minute. Business requests continue through Legacy throughout.
This rule also covers skipped requests that execute while an earlier sample owns the snapshot: the next snapshot cannot become `READY` until those Legacy calls have completed and a quiet refresh has succeeded.
## Readiness and staleness
A snapshot is usable only when all of the following are true:
- its warehouse matches the configured algorithm warehouse;
- its refresh completed successfully and is the currently published generation;
- its completion time is no older than the configured maximum age;
- no Legacy activity has invalidated the coordinator state;
- all required topology, inventory, lane-state, and outbound-input read-model components belong to the same generation.
The default maximum age is 60 seconds and accepts values from 5 through 300 seconds. An expired snapshot causes the current request to skip and raises one coalesced asynchronous refresh signal. The default quiet interval is one second and accepts values from 100 through 10,000 milliseconds. Invalid values fail shadow initialization closed while Legacy remains available. Configuration is read at startup only. These are operational controls and never enable ACTIVE behavior.
## Warehouse normalization
`FORTUCKY_ALGORITHM_WAREHOUSE_ID=01` is authoritative for this field test. A raw request value such as `W02` is retained in diagnostics but cannot override the configured algorithm warehouse and does not by itself skip the observation. Planning still uses the published `01` snapshot. An observation is skipped only when the published snapshot warehouse does not match the configured warehouse after normalization.
Purely numeric warehouse identifiers are compared after removing leading zeroes, so `1` and `01` are equivalent. Non-numeric identifiers are compared as exact trimmed values; `W02` is not equal to `01`.
## Failure handling
Every shadow-only failure has one of these outcomes:
- `SKIP_NOT_READY`: startup or recovery has no published snapshot.
- `SKIP_BUSY`: another observation or refresh owns the coordinator.
- `SKIP_STALE`: the published snapshot exceeded its maximum age.
- `SKIP_WAREHOUSE`: the published snapshot warehouse does not match the configured warehouse; Legacy still runs normally.
- `SKIP_CONCURRENT`: Legacy activity changed during refresh or invalidated the sample window.
- `ERROR_PLAN` or `ERROR_COMPARE`: the current observation is invalid, its consumed generation cannot return to `READY`, the lease is released in `finally`, and asynchronous refresh begins.
- `ERROR_REFRESH`: the candidate generation is rolled back; after bounded retries the coordinator becomes `UNAVAILABLE` and retains the previous generation for diagnosis only.
Errors are caught at the shadow boundary and logged with request id, warehouse, snapshot generation, stage, exception class, and elapsed time. Logs must not contain credentials or full payloads. A planning or comparison error leads to refresh/recovery; it never retries the Legacy procedure.
## SQL package changes
The shadow installer upgrades only package-owned `alg.*` objects. It adds snapshot-generation metadata and the read-model fields required to preserve inbound and outbound planning inputs. Refresh replaces the read model and publish marker in one transaction and publishes only after all components succeed. SQL remains compatible with SQL Server 2016 RTM.
The installer is idempotent for the recognized shadow package version and refuses to take ownership of unknown `alg` objects. The installer and uninstaller share one explicit allow-list of tables, views, procedures, functions, constraints, and indexes; they verify package id/version and object ownership before mutation. Installation is transactional and rolls back partial upgrades. The uninstaller removes only allow-listed shadow objects after manifest verification. Static SQL checks reject triggers and reject `INSERT`, `UPDATE`, `DELETE`, `MERGE`, or procedure calls targeting `dbo.*` or any non-`alg` schema. The Java shadow repository exposes only fixed, reviewed SQL statements for those allow-listed `alg.*` writes and required Legacy reads; it cannot accept a dynamic table, procedure, or SQL fragment.
## Configuration
Existing v2 variables remain valid. v3 adds bounded snapshot controls with these defaults:
```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_SHADOW_SNAPSHOT_MAX_AGE_SECONDS=60
FORTUCKY_SHADOW_QUIET_INTERVAL_MILLIS=1000
```
`FORTUCKY_SHADOW_TIMEOUT_SECONDS` accepts 1 through 30 seconds and limits only background worker stages; it is never a caller wait timeout. `FORTUCKY_ALGORITHM_DATABASE_WRITES_ENABLED=true` authorizes the existing shadow repository only when mode is exactly `SHADOW`; the startup guard rejects every other mode, and the module exposes no active business writer entry point. SQL allow-list and integration tests prove that these writes target only package-owned `alg.*` objects. No formal-cutover or ACTIVE variable is introduced.
## Verification strategy
Automated tests must prove:
1. disabled, busy, stale, unavailable, rejected, timed-out, and failing shadow paths still invoke Legacy exactly once and preserve its response;
2. no Legacy test thread waits for planning, comparison, refresh, or recovery, and the admission path meets its one-millisecond budget under contention;
3. planning never calls live refresh and accepts only its leased generation;
4. all admitted and skipped Legacy requests enter and exit activity in `finally`; concurrent starts, including a start between final validation and publication, prevent publication of an invalid refresh;
5. a refresh generation is published only when all components succeed, the Legacy epoch stays unchanged, and the pre/post source signatures match;
6. inbound/outbound input rows carry the same generation and outbound inputs remain in diagnostics after simulated deletion of live detail rows;
7. `1` equals `01`, while `W02` does not override configured warehouse `01`;
8. SQL install/uninstall ownership checks remain symmetrical and contain no Legacy business-table DML;
9. the full Maven test suite, packaging, and existing Node script checks pass.
## Field acceptance criteria
Shadow v3 is not sufficient by itself to authorize formal cutover. A new field run must meet all of these conditions:
- at least seven consecutive days with zero exception whose stack originates in the shadow package and escapes into a Legacy request, and zero difference in Legacy response codes or response fields compared with the approved Legacy baseline;
- inventory parity has zero missing/extra warehouse `01` rows and exact equality for space, product, project, quantity, lock/reservation, availability, lane, and occupancy fields included by the export;
- at least 100 valid inbound and 100 valid outbound comparisons, excluding explicitly skipped requests;
- every valid comparison has snapshot generation/time, immutable input diagnostics, candidate counts, and both outcomes;
- zero valid sample has a post-Legacy snapshot, unexplained stock-not-found, unexplained waiting-channel result, or uses a published snapshot from a warehouse other than configured `01`;
- the export records zero Legacy request blocked by an `alg.*` session; compared with the approved Legacy-only baseline under the same shift/load class, Legacy p95 latency increases by no more than 5% and p99 by no more than 10%; caller-side shadow admission p99 remains at or below one millisecond;
- differing recommended locations are reviewed as algorithm choices rather than treated as automatic failures.
An unexplained result is any valid sample whose reason cannot be reproduced from its recorded snapshot generation, immutable inputs, candidate counts, and policy trace. If volume cannot reach the sample minimum, the observation period is extended. Formal routing is a separate change with a separate approval, design, deployment, and rollback decision.
## Deployment and rollback boundary
The v3 source replaces the running v2 shadow source only during an agreed IDEA restart window. Before enabling shadow, the deployment script verifies exactly one FT546WMS JVM and no second recent shadow-instance heartbeat. The stable Legacy behavior inside the application remains authoritative after restart. Operators retain the current known-good source directory and IDEA Run Configuration.
Rollback is application-only: stop v3 and restart the known-good v2 or stable-main configuration. If immediate isolation is preferred, restart v3 with `FORTUCKY_SHADOW_ENABLED=false`. Because v3 never writes Legacy business tables, no business-data rollback is required. Shadow SQL objects may remain installed for diagnosis and should be removed only with the verified v3 uninstaller after exporting evidence.

View File

@@ -0,0 +1,25 @@
# 2026-09-09 现场问题修复
用户已授权修复现场检查发现的问题;继续 Legacy 执行业务、SHADOW 观察,准备交付但不重启现场。
## 证据与边界
现场 29 条记录中,5 条入库都不可比;3 次 Legacy 成功出库有 2 次影子缺货、1 次多明细跳过。历史库存代次已覆盖,修复机制需要回归测试和新样本,不宣称已重放两条历史作业。候选 SQL 的 include 已限制仓库及代次,不存在探查报告提到的跨仓跨代缺口。
## 设计
1. 出库增加仅在 SHADOW 可用的候选库存规划入口。传递快照筛选出的 stockId,以库存行投影项目/物料;保留巷道全部物理货位、阻挡、锁定、预留及缓冲条件。单行足量、多明细托盘可参与;多行合并数量、已有托盘作业仍明确不支持。限定最多 200 个候选。记录有界候选明细和完整集合摘要,不把 Legacy=10 当作匹配通过。
2. 入库在排名前限定同代 Legacy 保守可用货位。普通项目 OPEN 模式下,多物料/批次按项目级任务规划,不虚构单个物料。字段组唯一时保留严格同组或空巷道;字段组不唯一时只允许空巷道,单列保守比较结果,不作为完整 Legacy 等价验证。保留公共项目、范围不全、作业歧义等保护。
3. 查询参数绑定错误返回明确的参数格式提示,不执行无筛选明细查询。
4. 电子屏每次发送拥有独立消息和 socket。固定本地 6666 端口用非阻塞所有权保护,冲突时保留未发送状态待下个周期。try-with-resources 释放;socket 构造失败传播,接收信息为实例字段。保留屏幕协议、内容与重试次数。
5. SQL 51025 是源数据在刷新期间变化的拒绝保护;继续保留,不放松一致性或提高生产超时。本次不改数据库结构或生产过程。
## 验证与交付
先执行失败回归,再实现修复。覆盖真实 Spring 参数绑定、UDP 本机回环隔离/失败释放、混合托盘及阻挡、同代候选、入库分组/空巷道与保守比较。完整离线构建并显式 Surefire 执行 JUnit 5。独立代码审查后,新目录上传净化 JAR,新增 IDEA 启动项;核对摘要及现有进程未变化。用户择机停止旧 WMS 并切换,保留旧启动项回退。
## 核验和审查处置
2026-09-09 只读取得现场 `dbo.proc_allot_space`,普通项目两个分支均 UNION 整条空巷道,且均要求 `locked='N'`。不按共享模型对 0/null 的宽松解释放宽现场 SQL 谓词。完整定义保留本机 analysis/site-review-20260909/inbound-procedure.json。
独立审查要求保留瞬时 UDP 发送/接收错误的重试,已在协议循环内恢复有界重试并补回归;InvalidPropertyException 仅在实际 ModelAttribute 参数绑定且非法数组参数名来自请求时返回400,内部异常维持500,补真实 MockMvc 回归。候选跨多个项目/物料和多行凑量保留明确 skip;支持的是匹配单个项目/物料的库存行处于多项目/多物料托盘的情况,不声称覆盖任意多项目订单。

View File

@@ -0,0 +1,56 @@
# FT546WMS Warehouse 01 Floor 1 Inbound Pilot Design
## Goal
Prepare a narrowly scoped inbound pilot for warehouse `01`, floor `1`, without enabling it or changing the running field system. The candidate remains Legacy-only by default. A later operator-controlled restart may enable the pilot after the database procedure has been installed and checked.
## Safety invariants
1. `FORTUCKY_INBOUND_PILOT_ENABLED` defaults to `false` and is independent of global algorithm mode.
2. Only a request with `RequestEnter.floor == "1"` and a job whose warehouse is equivalent to `01` is eligible.
3. Non-eligible requests execute the existing Legacy path unchanged.
4. Planning is read-only, uses one published shadow snapshot, is restricted to floor `1`, and has a bounded caller wait.
5. The algorithm writer and `dbo.proc_allot_space` are mutually exclusive. A committed pilot result never calls Legacy.
6. Missing, stale, changing, rejected, timed-out, or failed planning falls back to Legacy.
7. The production mutation is one SQL Server transaction. It revalidates the job, tray, warehouse, floor, snapshot generation and age, selected space, lane continuity, outbound obstruction, duplicate assignment, and live occupancy before updating anything.
8. A mutation conflict or a proven rollback falls back to Legacy. An ambiguous client failure is resolved by rereading the job; an existing assignment is returned rather than risking a second writer.
9. The mutation updates only the same business state owned by the Legacy inbound allocation path: the selected `mydo_job`, selected `bas_cargo_space`, and entry LED row for `L1004` or `L1005`.
10. Every eligible attempt writes best-effort diagnostic evidence to existing `alg.decision_log`; diagnostic failure cannot change the business result.
11. Outbound, relocation, other warehouses, and other floors remain unchanged.
## Request flow
1. Load the inbound job using the existing task number lookup.
2. If it is already allocated, return it unchanged.
3. Ask the floor-1 pilot router for an allocation.
4. When the pilot is disabled or the request is outside scope, immediately continue with the existing shadow-observation plus Legacy allocation path.
5. For an eligible request, load and validate the current published snapshot, then plan asynchronously with `targetFloor=1` and a bounded wait.
6. If planning returns one conservative eligible proposal, call the dedicated atomic mutation procedure once.
7. On procedure success or a reread that proves an assignment already exists, return the reloaded job and do not call Legacy.
8. For every other outcome, invoke the existing Legacy procedure exactly once and preserve its response.
## Atomic mutation contract
The prepared procedure is `dbo.proc_allot_layer1_inbound_pilot`. It accepts task, tray, device, selected space, warehouse, floor, snapshot generation, and maximum snapshot age. It returns a numeric result, message, and final space.
Within `SET XACT_ABORT ON` and one transaction it uses update/range locks to prove:
- exactly one unfinished inbound job matches task and tray;
- the job does not already point at another space;
- the requested published snapshot is still current and fresh;
- the selected live cargo space belongs to warehouse `01`, floor `1`, area A/C, is empty, unlocked, and has no stock;
- no active job already owns or targets that space;
- the lane has no locked or moving (`3`/`4`) space and no active outbound job;
- no physically inner free space exists, so the proposal remains the next legal slot.
The guarded updates must each affect exactly one row. Any failed guard rolls back and returns a non-success code. SQL exceptions roll back and escape to the Java resolver.
## Evidence
`alg.decision_log` uses decision type `INBOUND_PILOT`. The reason records task, tray, proposal, final space, elapsed time, outcome, and fallback reason. The snapshot generation is stored in `input_version`. Evidence is diagnostic only and never participates in the business transaction.
## Deployment boundary
This change prepares source, tests, SQL install/check/rollback files, a JAR, and a disabled IDEA configuration. Preparation does not execute the SQL installer, upload over the running JAR, restart WMS, or set the pilot switch to true.
Later activation requires this order: stop the candidate WMS during an agreed window, install and check the procedure, start the prepared JAR with shadow enabled and pilot disabled, verify Legacy operation, then perform a separate restart with the floor-1 pilot enabled. Rollback is a restart with the pilot disabled; the procedure may remain installed or be removed only after the pilot is disabled.

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi</artifactId>
<version>3.9.0</version>
</parent>
<artifactId>fortucky-algorithm</artifactId>
<packaging>jar</packaging>
<description>FT546 密集库 Java 调度算法与 WMS 接入门面</description>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,79 @@
package com.ruoyi.algorithm;
import com.ruoyi.algorithm.model.LaneState;
import com.ruoyi.algorithm.model.SpaceState;
import com.ruoyi.algorithm.model.SpaceStatus;
import com.ruoyi.algorithm.policy.SchedulingPolicy;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
public final class LaneAnalyzer {
public LaneState analyze(List<SpaceState> laneSpaces, SchedulingPolicy policy) {
List<SpaceState> spaces = laneSpaces.stream()
.sorted(Comparator.comparingInt(SpaceState::depthOrder))
.toList();
boolean emptySeen = false;
boolean physicallyContinuous = true;
boolean invalidInventoryState = false;
int segmentCount = 0;
String currentSegmentKey = null;
String residentProject = null;
String guestProject = null;
for (SpaceState space : spaces) {
boolean occupied = space.occupied();
if (!occupied) {
emptySeen = true;
continue;
}
if (emptySeen) {
physicallyContinuous = false;
}
if (space.inventoryProjectConflict()
|| (space.status() == SpaceStatus.EMPTY && space.hasTray())) {
invalidInventoryState = true;
}
String segmentKey = space.projectNo() == null ? "__UNKNOWN__" : space.projectNo();
if (!segmentKey.equals(currentSegmentKey)) {
currentSegmentKey = segmentKey;
segmentCount++;
if (segmentCount == 1) {
residentProject = space.projectNo();
} else if (segmentCount == 2) {
guestProject = space.projectNo();
}
}
}
boolean corrupt = invalidInventoryState || !physicallyContinuous || segmentCount > 2;
Optional<SpaceState> nextSlot = corrupt
? Optional.empty()
: spaces.stream().filter(SpaceState::availableEmpty).findFirst();
int outermostOccupiedDepth = spaces.stream()
.filter(SpaceState::occupied)
.mapToInt(SpaceState::depthOrder)
.max()
.orElse(0);
Optional<SpaceState> outerSlot = spaces.stream()
.filter(SpaceState::availableEmpty)
.filter(space -> space.depthOrder() > outermostOccupiedDepth)
.findFirst();
return new LaneState(
spaces,
physicallyContinuous,
corrupt,
segmentCount,
Optional.ofNullable(residentProject),
Optional.ofNullable(guestProject),
nextSlot,
outerSlot
);
}
}

View File

@@ -0,0 +1,6 @@
package com.ruoyi.algorithm.inbound;
public enum BatchLaneMode {
OPEN,
SEALED
}

View File

@@ -0,0 +1,20 @@
package com.ruoyi.algorithm.inbound;
public record InboundAssignment(
String requestId,
String spaceId,
String laneCode,
int laneCapacity,
int depthOrder,
InboundLadderRung ladderRung,
InboundBorrowType borrowType,
String mixedIntoResidentProject,
double capacityScore,
double efficiencyScore,
double totalScore,
double fillRatePercent,
boolean batchLaneApplied,
boolean openedNewLane,
String reason
) {
}

View File

@@ -0,0 +1,8 @@
package com.ruoyi.algorithm.inbound;
public enum InboundBorrowType {
NONE,
CACHE_BORROWED,
RESERVE_BORROWED,
MIXED_IN
}

View File

@@ -0,0 +1,19 @@
package com.ruoyi.algorithm.inbound;
import com.ruoyi.algorithm.model.SpaceState;
import java.util.List;
public record InboundContext(
List<SpaceState> spaces,
InboundPolicy policy,
List<PendingOutbound> pendingOutbounds,
int occupiedCount
) {
public InboundContext {
spaces = spaces == null ? List.of() : List.copyOf(spaces);
policy = policy == null ? InboundPolicy.defaults() : policy;
pendingOutbounds = pendingOutbounds == null ? List.of() : List.copyOf(pendingOutbounds);
if (occupiedCount < 0) throw new IllegalArgumentException("occupiedCount cannot be negative");
}
}

View File

@@ -0,0 +1,12 @@
package com.ruoyi.algorithm.inbound;
public enum InboundLadderRung {
CONTINUE,
OPEN_GRADED,
OPEN_ADJACENT,
MIXED_GUEST,
POOL_BORROW,
COMMON_SINGLE,
COMMON_LANE_CONTINUE,
COMMON_LANE_OPEN
}

View File

@@ -0,0 +1,21 @@
package com.ruoyi.algorithm.inbound;
import java.util.Optional;
public record InboundPlan(Optional<InboundAssignment> assignment, Optional<InboundRejection> rejection) {
public InboundPlan {
assignment = assignment == null ? Optional.empty() : assignment;
rejection = rejection == null ? Optional.empty() : rejection;
if (assignment.isPresent() == rejection.isPresent()) {
throw new IllegalArgumentException("plan must contain exactly one assignment or rejection");
}
}
public static InboundPlan assigned(InboundAssignment assignment) {
return new InboundPlan(Optional.of(assignment), Optional.empty());
}
public static InboundPlan rejected(InboundRejection rejection) {
return new InboundPlan(Optional.empty(), Optional.of(rejection));
}
}

View File

@@ -0,0 +1,675 @@
package com.ruoyi.algorithm.inbound;
import com.ruoyi.algorithm.model.SpaceRole;
import com.ruoyi.algorithm.model.SpaceState;
import com.ruoyi.algorithm.model.SpaceStatus;
import com.ruoyi.algorithm.policy.SchedulingPolicy;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
public final class InboundPlanner {
private static final Map<Integer, List<Integer>> ADJACENT_DEPTH_ORDER = Map.of(
2, List.of(3, 5),
3, List.of(5, 2),
5, List.of(3, 2)
);
public InboundPlan plan(InboundRequest request, InboundContext context) {
Objects.requireNonNull(request, "request");
Objects.requireNonNull(context, "context");
validateManualReservations(context);
List<Lane> lanes = buildLanes(context);
Optional<Candidate> selected = context.policy().isCommon(request.projectNo())
? chooseCommon(request, context, lanes)
: chooseProject(request, context, lanes);
if (selected.isPresent()) {
return InboundPlan.assigned(toAssignment(request, selected.orElseThrow()));
}
List<LadderTraceStep> trace = traceFor(request, context, lanes);
boolean sealedProjectRequest = request.batchLaneMode() == BatchLaneMode.SEALED
&& !context.policy().isCommon(request.projectNo());
InboundRejectionCode code = sealedProjectRequest
? InboundRejectionCode.NO_BATCH_COMPATIBLE_LANE
: InboundRejectionCode.NO_FEASIBLE_LOCATION;
String reason = sealedProjectRequest
? "No lane can continue the same project, product and lot, and no completely empty lane remains."
: "Inbound admission ladder exhausted every legal location; no feasible location remains.";
return InboundPlan.rejected(new InboundRejection(
code, reason, request.projectNo(), request.productId(), request.lotNo(), trace));
}
public PagedSession startPaged(InboundRequest request, InboundPolicy policy,
List<PendingOutbound> pendingOutbounds, int occupiedCount) {
return new PagedSession(request, policy, pendingOutbounds, occupiedCount);
}
public final class PagedSession {
private final InboundRequest request;
private final InboundPolicy policy;
private final List<PendingOutbound> pendingOutbounds;
private final int occupiedCount;
private final Map<String, Candidate> retained = new LinkedHashMap<>();
private final java.util.Set<String> missingManualLanes;
private long freeSinglePool;
private PagedSession(InboundRequest request, InboundPolicy policy,
List<PendingOutbound> pendingOutbounds, int occupiedCount) {
this.request = Objects.requireNonNull(request, "request");
this.policy = Objects.requireNonNull(policy, "policy");
this.pendingOutbounds = List.copyOf(pendingOutbounds == null ? List.of() : pendingOutbounds);
this.occupiedCount = occupiedCount;
this.missingManualLanes = policy.manualReservedLanes().stream()
.map(ManualLaneReservation::laneCode)
.collect(Collectors.toCollection(java.util.LinkedHashSet::new));
}
public void accept(List<SpaceState> pageSpaces) {
InboundContext context = new InboundContext(pageSpaces, policy, pendingOutbounds, occupiedCount);
List<Lane> lanes = buildLanes(context);
lanes.forEach(lane -> missingManualLanes.remove(lane.code()));
if (policy.isCommon(request.projectNo())) {
acceptCommon(context, lanes);
} else {
acceptProject(context, lanes);
}
}
public Optional<InboundAssignment> currentAssignment() {
return selected().map(candidate -> toAssignment(request, candidate));
}
public java.util.Set<String> retainedLaneCodes() {
return retained.values().stream().map(candidate -> candidate.lane().code())
.collect(Collectors.toUnmodifiableSet());
}
public InboundPlan finish() {
if (!missingManualLanes.isEmpty()) {
throw new IllegalArgumentException("manualReservedLanes references unknown lane "
+ missingManualLanes.iterator().next());
}
Optional<Candidate> candidate = selected();
if (candidate.isPresent()) return InboundPlan.assigned(toAssignment(request, candidate.orElseThrow()));
boolean sealed = request.batchLaneMode() == BatchLaneMode.SEALED
&& !policy.isCommon(request.projectNo());
InboundRejectionCode code = sealed ? InboundRejectionCode.NO_BATCH_COMPATIBLE_LANE
: InboundRejectionCode.NO_FEASIBLE_LOCATION;
String reason = sealed
? "No lane can continue the same project, product and lot, and no completely empty lane remains."
: "Inbound admission ladder exhausted every legal location; no feasible location remains.";
return InboundPlan.rejected(new InboundRejection(code, reason, request.projectNo(),
request.productId(), request.lotNo(), pagedTrace(sealed)));
}
private void acceptProject(InboundContext context, List<Lane> lanes) {
retain("CONTINUE", best(lanes.stream()
.filter(lane -> lane.usableForInbound() && !lane.occupied().isEmpty())
.map(lane -> continueCandidate(request, context, lane)).flatMap(Optional::stream).toList()),
candidateComparator());
int targetDepth = gradedTargetDepth(request, context);
retain("GRADED", best(emptyLaneCandidates(request, context, lanes, targetDepth,
InboundLadderRung.OPEN_GRADED)), candidateComparator());
for (int depth : ADJACENT_DEPTH_ORDER.getOrDefault(targetDepth, List.of())) {
retain("ADJACENT:" + depth, best(emptyLaneCandidates(request, context, lanes, depth,
InboundLadderRung.OPEN_ADJACENT)), candidateComparator());
}
if (request.batchLaneMode() != BatchLaneMode.SEALED && request.projectNo() != null
&& !policy.isIsolated(request.projectNo()) && !policy.isCommon(request.projectNo())
&& policy.allowsMixed(request.projectNo())) {
Comparator<Candidate> mixedOrder = Comparator.comparingInt(Candidate::remaining)
.thenComparing(Candidate::residentNewest).thenComparing(candidateComparator());
retain("MIXED", bestMixed(request, context, lanes), mixedOrder);
}
acceptPool(context, lanes);
}
private void acceptPool(InboundContext context, List<Lane> lanes) {
List<Lane> pool = lanes.stream().filter(lane -> lane.depth() == 1)
.filter(lane -> lane.spaces().stream().allMatch(space -> space.role() == SpaceRole.STORAGE))
.toList();
freeSinglePool += pool.stream().filter(Lane::isEmptyLane).count();
List<PoolTier> tiers = new ArrayList<>();
tiers.add(new PoolTier("FLEX", InboundBorrowType.NONE));
tiers.add(new PoolTier("CACHE", InboundBorrowType.CACHE_BORROWED));
if (occupiedCount >= policy.schedulingPolicy().capacityFirstOccupied()) {
tiers.add(new PoolTier("RESERVE", InboundBorrowType.RESERVE_BORROWED));
}
for (PoolTier tier : tiers) {
retain("POOL:" + tier.zone(), best(pool.stream().filter(Lane::usableForInbound)
.filter(Lane::isEmptyLane)
.filter(lane -> tier.zone().equalsIgnoreCase(lane.spaces().get(0).zoneCode()))
.filter(lane -> usableSlot(lane, lane.spaces().get(0), request, context))
.map(lane -> score(lane, lane.spaces().get(0), request, context,
InboundLadderRung.POOL_BORROW, tier.borrowType(), null, true)).toList()),
candidateComparator());
}
}
private void acceptCommon(InboundContext context, List<Lane> lanes) {
for (String zone : List.of("FLEX", "CACHE", "RESERVE")) {
retain("COMMON_SINGLE:" + zone, best(lanes.stream().filter(Lane::usableForInbound)
.filter(lane -> lane.depth() == 1 && lane.isEmptyLane())
.filter(lane -> zone.equalsIgnoreCase(lane.spaces().get(0).zoneCode()))
.filter(lane -> usableSlot(lane, lane.spaces().get(0), request, context))
.map(lane -> score(lane, lane.spaces().get(0), request, context,
InboundLadderRung.COMMON_SINGLE, InboundBorrowType.NONE, null, true)).toList()),
candidateComparator());
}
retain("COMMON_CONTINUE", best(lanes.stream().filter(Lane::usableForInbound)
.filter(lane -> lane.depth() == 2 && !lane.occupied().isEmpty())
.filter(lane -> lane.nextSlot().isPresent())
.filter(lane -> lane.occupied().stream().allMatch(space -> commonCompatible(space, request, context)))
.filter(lane -> usableSlot(lane, lane.nextSlot().orElseThrow(), request, context))
.map(lane -> score(lane, lane.nextSlot().orElseThrow(), request, context,
InboundLadderRung.COMMON_LANE_CONTINUE, InboundBorrowType.NONE, null, false)).toList()),
candidateComparator());
retain("COMMON_OPEN", best(emptyLaneCandidates(request, context, lanes, 2,
InboundLadderRung.COMMON_LANE_OPEN)), candidateComparator());
}
private Optional<Candidate> selected() {
if (policy.isCommon(request.projectNo())) {
for (String zone : List.of("FLEX", "CACHE", "RESERVE")) {
Candidate candidate = retained.get("COMMON_SINGLE:" + zone);
if (candidate != null) return Optional.of(candidate);
}
if (retained.containsKey("COMMON_CONTINUE")) return Optional.of(retained.get("COMMON_CONTINUE"));
return Optional.ofNullable(retained.get("COMMON_OPEN"));
}
if (retained.containsKey("CONTINUE")) return Optional.of(retained.get("CONTINUE"));
if (retained.containsKey("GRADED")) return Optional.of(retained.get("GRADED"));
int targetDepth = gradedTargetDepth(request,
new InboundContext(List.of(), policy, pendingOutbounds, occupiedCount));
if (targetDepth == 2) {
Optional<Candidate> pool = selectedPool();
if (pool.isPresent()) return pool;
}
for (int depth : ADJACENT_DEPTH_ORDER.getOrDefault(targetDepth, List.of())) {
Candidate candidate = retained.get("ADJACENT:" + depth);
if (candidate != null) return Optional.of(candidate);
}
if (retained.containsKey("MIXED")) return Optional.of(retained.get("MIXED"));
return selectedPool();
}
private Optional<Candidate> selectedPool() {
if (policy.commonSinglePoolReserve() > 0
&& freeSinglePool - 1 < policy.commonSinglePoolReserve()) return Optional.empty();
for (String zone : List.of("FLEX", "CACHE", "RESERVE")) {
Candidate candidate = retained.get("POOL:" + zone);
if (candidate != null) return Optional.of(candidate);
}
return Optional.empty();
}
private void retain(String key, Optional<Candidate> candidate, Comparator<Candidate> comparator) {
candidate.ifPresent(value -> retained.merge(key, value,
(current, proposed) -> comparator.compare(proposed, current) < 0 ? proposed : current));
}
private List<LadderTraceStep> pagedTrace(boolean sealed) {
if (policy.isCommon(request.projectNo())) {
return List.of(
new LadderTraceStep(InboundLadderRung.COMMON_SINGLE,
"No outbound-free and reservation-compatible FLEX, CACHE or RESERVE single slot."),
new LadderTraceStep(InboundLadderRung.COMMON_LANE_CONTINUE,
"No physically continuous outbound-free double lane contains only the same common product."),
new LadderTraceStep(InboundLadderRung.COMMON_LANE_OPEN,
"No completely empty outbound-free double lane is available."));
}
int targetDepth = gradedTargetDepth(request,
new InboundContext(List.of(), policy, pendingOutbounds, occupiedCount));
boolean guard = policy.commonSinglePoolReserve() > 0
&& freeSinglePool - 1 < policy.commonSinglePoolReserve();
return List.of(
new LadderTraceStep(InboundLadderRung.CONTINUE,
"No physically continuous outbound-free lane can legally continue this project/cohort."),
new LadderTraceStep(InboundLadderRung.OPEN_GRADED,
"No completely empty legal lane at graded depth " + targetDepth + "."),
new LadderTraceStep(InboundLadderRung.OPEN_ADJACENT,
"No completely empty legal lane in adjacent order "
+ ADJACENT_DEPTH_ORDER.getOrDefault(targetDepth, List.of()) + "."),
new LadderTraceStep(InboundLadderRung.MIXED_GUEST,
sealed ? "SEALED admission never mixes projects or lots."
: "No one-segment non-isolated resident lane without pending outbound can accept one outer guest."),
new LadderTraceStep(InboundLadderRung.POOL_BORROW,
guard ? "Single-pool common-parts watermark would be breached."
: "No legal FLEX/CACHE single slot, or high-water RESERVE slot, is available."));
}
}
private void validateManualReservations(InboundContext context) {
List<String> laneCodes = context.spaces().stream().map(SpaceState::laneCode).distinct().toList();
context.policy().manualReservedLanes().stream()
.filter(reservation -> !laneCodes.contains(reservation.laneCode()))
.findFirst()
.ifPresent(reservation -> {
throw new IllegalArgumentException(
"manualReservedLanes references unknown lane " + reservation.laneCode());
});
}
private Optional<Candidate> chooseProject(InboundRequest request, InboundContext context, List<Lane> lanes) {
Optional<Candidate> candidate = best(lanes.stream()
.filter(lane -> lane.usableForInbound() && !lane.occupied().isEmpty())
.map(lane -> continueCandidate(request, context, lane))
.flatMap(Optional::stream)
.toList());
if (candidate.isPresent()) return candidate;
int targetDepth = gradedTargetDepth(request, context);
candidate = best(emptyLaneCandidates(request, context, lanes, targetDepth, InboundLadderRung.OPEN_GRADED));
if (candidate.isPresent()) return candidate;
if (targetDepth == 2) {
candidate = bestPoolSingle(request, context, lanes).candidate();
if (candidate.isPresent()) return candidate;
}
for (int depth : ADJACENT_DEPTH_ORDER.getOrDefault(targetDepth, List.of())) {
candidate = best(emptyLaneCandidates(request, context, lanes, depth, InboundLadderRung.OPEN_ADJACENT));
if (candidate.isPresent()) return candidate;
}
if (request.batchLaneMode() != BatchLaneMode.SEALED
&& request.projectNo() != null
&& !context.policy().isIsolated(request.projectNo())
&& !context.policy().isCommon(request.projectNo())
&& context.policy().allowsMixed(request.projectNo())) {
candidate = bestMixed(request, context, lanes);
if (candidate.isPresent()) return candidate;
}
return bestPoolSingle(request, context, lanes).candidate();
}
private Optional<Candidate> chooseCommon(InboundRequest request, InboundContext context, List<Lane> lanes) {
for (String zone : List.of("FLEX", "CACHE", "RESERVE")) {
Optional<Candidate> single = best(lanes.stream()
.filter(Lane::usableForInbound)
.filter(lane -> lane.depth() == 1 && lane.isEmptyLane())
.filter(lane -> zone.equalsIgnoreCase(lane.spaces().get(0).zoneCode()))
.filter(lane -> usableSlot(lane, lane.spaces().get(0), request, context))
.map(lane -> score(lane, lane.spaces().get(0), request, context,
InboundLadderRung.COMMON_SINGLE, InboundBorrowType.NONE, null, true))
.toList());
if (single.isPresent()) return single;
}
Optional<Candidate> continuation = best(lanes.stream()
.filter(Lane::usableForInbound)
.filter(lane -> lane.depth() == 2 && !lane.occupied().isEmpty())
.filter(lane -> lane.nextSlot().isPresent())
.filter(lane -> lane.occupied().stream().allMatch(space -> commonCompatible(space, request, context)))
.filter(lane -> usableSlot(lane, lane.nextSlot().orElseThrow(), request, context))
.map(lane -> score(lane, lane.nextSlot().orElseThrow(), request, context,
InboundLadderRung.COMMON_LANE_CONTINUE, InboundBorrowType.NONE, null, false))
.toList());
if (continuation.isPresent()) return continuation;
return best(emptyLaneCandidates(request, context, lanes, 2, InboundLadderRung.COMMON_LANE_OPEN));
}
private boolean commonCompatible(SpaceState space, InboundRequest request, InboundContext context) {
return context.policy().isCommon(space.projectNo())
&& Objects.equals(space.productId(), request.productId());
}
private Optional<Candidate> continueCandidate(InboundRequest request, InboundContext context, Lane lane) {
if (lane.segments().isEmpty()) return Optional.empty();
Segment resident = lane.segments().get(0);
boolean residentMatch = lane.segments().size() == 1
&& Objects.equals(resident.projectNo(), request.projectNo());
boolean guestMatch = lane.segments().size() == 2
&& Objects.equals(lane.segments().get(1).projectNo(), request.projectNo());
if (!residentMatch && !guestMatch) return Optional.empty();
Optional<SpaceState> slot = guestMatch ? lane.outerSlot() : lane.nextSlot();
if (slot.isEmpty() || !usableSlot(lane, slot.orElseThrow(), request, context)) return Optional.empty();
if (guestMatch && (!context.policy().allowsMixed(request.projectNo())
|| !context.policy().allowsMixed(resident.projectNo()))) {
return Optional.empty();
}
if (request.batchLaneMode() == BatchLaneMode.SEALED && lane.occupied().stream().anyMatch(space ->
!Objects.equals(space.projectNo(), request.projectNo())
|| !Objects.equals(space.productId(), request.productId())
|| !Objects.equals(space.lotNo(), request.lotNo()))) {
return Optional.empty();
}
return Optional.of(score(lane, slot.orElseThrow(), request, context, InboundLadderRung.CONTINUE,
guestMatch ? InboundBorrowType.MIXED_IN : InboundBorrowType.NONE,
guestMatch ? resident.projectNo() : null, false));
}
private List<Candidate> emptyLaneCandidates(InboundRequest request, InboundContext context,
List<Lane> lanes, int depth, InboundLadderRung rung) {
return lanes.stream()
.filter(Lane::usableForInbound)
.filter(lane -> lane.depth() == depth && lane.isEmptyLane())
.filter(lane -> lane.spaces().stream().allMatch(space -> space.role() == SpaceRole.STORAGE))
.filter(lane -> usableSlot(lane, lane.spaces().get(0), request, context))
.map(lane -> score(lane, lane.spaces().get(0), request, context, rung,
InboundBorrowType.NONE, null, true))
.toList();
}
private PoolChoice bestPoolSingle(InboundRequest request, InboundContext context, List<Lane> lanes) {
List<Lane> pool = lanes.stream()
.filter(lane -> lane.depth() == 1)
.filter(lane -> lane.spaces().stream().allMatch(space -> space.role() == SpaceRole.STORAGE))
.toList();
long freeTotal = pool.stream().filter(Lane::isEmptyLane).count();
if (context.policy().commonSinglePoolReserve() > 0
&& freeTotal - 1 < context.policy().commonSinglePoolReserve()) {
return new PoolChoice(Optional.empty(), true);
}
List<PoolTier> tiers = new ArrayList<>();
tiers.add(new PoolTier("FLEX", InboundBorrowType.NONE));
tiers.add(new PoolTier("CACHE", InboundBorrowType.CACHE_BORROWED));
if (context.occupiedCount() >= context.policy().schedulingPolicy().capacityFirstOccupied()) {
tiers.add(new PoolTier("RESERVE", InboundBorrowType.RESERVE_BORROWED));
}
for (PoolTier tier : tiers) {
Optional<Candidate> found = best(pool.stream()
.filter(Lane::usableForInbound)
.filter(Lane::isEmptyLane)
.filter(lane -> tier.zone().equalsIgnoreCase(lane.spaces().get(0).zoneCode()))
.filter(lane -> usableSlot(lane, lane.spaces().get(0), request, context))
.map(lane -> score(lane, lane.spaces().get(0), request, context,
InboundLadderRung.POOL_BORROW, tier.borrowType(), null, true))
.toList());
if (found.isPresent()) return new PoolChoice(found, false);
}
return new PoolChoice(Optional.empty(), false);
}
private Optional<Candidate> bestMixed(InboundRequest request, InboundContext context, List<Lane> lanes) {
Comparator<Candidate> mixedOrder = Comparator.comparingInt(Candidate::remaining)
.thenComparing(Candidate::residentNewest)
.thenComparing(candidateComparator());
return lanes.stream()
.filter(Lane::usableForInbound)
.filter(lane -> lane.depth() >= 2 && lane.segments().size() == 1 && lane.outerSlot().isPresent())
.filter(lane -> lane.segments().get(0).projectNo() != null)
.filter(lane -> !Objects.equals(lane.segments().get(0).projectNo(), request.projectNo()))
.filter(lane -> !context.policy().isIsolated(lane.segments().get(0).projectNo()))
.filter(lane -> !context.policy().isCommon(lane.segments().get(0).projectNo()))
.filter(lane -> context.policy().allowsMixed(request.projectNo()))
.filter(lane -> context.policy().allowsMixed(lane.segments().get(0).projectNo()))
.filter(lane -> usableSlot(lane, lane.outerSlot().orElseThrow(), request, context))
.map(lane -> score(lane, lane.outerSlot().orElseThrow(), request, context,
InboundLadderRung.MIXED_GUEST, InboundBorrowType.MIXED_IN,
lane.segments().get(0).projectNo(), false))
.min(mixedOrder);
}
private List<LadderTraceStep> traceFor(InboundRequest request, InboundContext context, List<Lane> lanes) {
if (context.policy().isCommon(request.projectNo())) {
return List.of(
new LadderTraceStep(InboundLadderRung.COMMON_SINGLE,
"No outbound-free and reservation-compatible FLEX, CACHE or RESERVE single slot."),
new LadderTraceStep(InboundLadderRung.COMMON_LANE_CONTINUE,
"No physically continuous outbound-free double lane contains only the same common product."),
new LadderTraceStep(InboundLadderRung.COMMON_LANE_OPEN,
"No completely empty outbound-free double lane is available."));
}
int targetDepth = gradedTargetDepth(request, context);
PoolChoice pool = bestPoolSingle(request, context, lanes);
String poolReason = pool.guardBlocked()
? "Single-pool common-parts watermark would be breached."
: "No legal FLEX/CACHE single slot, or high-water RESERVE slot, is available.";
return List.of(
new LadderTraceStep(InboundLadderRung.CONTINUE,
"No physically continuous outbound-free lane can legally continue this project/cohort."),
new LadderTraceStep(InboundLadderRung.OPEN_GRADED,
"No completely empty legal lane at graded depth " + targetDepth + "."),
new LadderTraceStep(InboundLadderRung.OPEN_ADJACENT,
"No completely empty legal lane in adjacent order " + ADJACENT_DEPTH_ORDER.getOrDefault(targetDepth, List.of()) + "."),
new LadderTraceStep(InboundLadderRung.MIXED_GUEST,
request.batchLaneMode() == BatchLaneMode.SEALED
? "SEALED admission never mixes projects or lots."
: "No one-segment non-isolated resident lane without pending outbound can accept one outer guest."),
new LadderTraceStep(InboundLadderRung.POOL_BORROW, poolReason));
}
private int gradedTargetDepth(InboundRequest request, InboundContext context) {
if (request.expectedProjectPallets() != null) {
if (request.expectedProjectPallets() <= 2) return 2;
if (request.expectedProjectPallets() == 3) return 3;
return 5;
}
long current = context.spaces().stream()
.filter(SpaceState::occupied)
.filter(space -> !space.outboundTaskActive())
.filter(space -> Objects.equals(space.projectNo(), request.projectNo()))
.count();
return current <= 1 ? 2 : 5;
}
private boolean pendingHitsPallet(InboundContext context, SpaceState pallet) {
return context.pendingOutbounds().stream().anyMatch(pending -> {
boolean exactTray = pending.trayNo() != null && !pending.trayNo().isBlank()
&& Objects.equals(pending.trayNo(), pallet.trayNo());
boolean resolvedLane = pending.hasResolvedLane()
&& Objects.equals(pending.sourceLaneCode(), pallet.laneCode());
return exactTray || resolvedLane;
});
}
private boolean usableSlot(Lane lane, SpaceState space, InboundRequest request, InboundContext context) {
if (request.eligibleSpaceIds() != null && !request.eligibleSpaceIds().contains(space.spaceId())) return false;
if (!lane.usableForInbound() || !space.availableEmpty() || space.role() != SpaceRole.STORAGE) return false;
if (request.targetFloor() != null && space.floorNo() != request.targetFloor()) return false;
if (request.targetFloor() == null && !request.allowedFloors().isEmpty()
&& !request.allowedFloors().contains(space.floorNo())) return false;
return !reservationBlocks(lane.code(), request.projectNo(), context.policy());
}
private boolean reservationBlocks(String laneCode, String projectNo, InboundPolicy policy) {
Optional<ManualLaneReservation> reservation = policy.manualReservedLanes().stream()
.filter(item -> item.laneCode().equals(laneCode))
.findFirst();
if (reservation.isEmpty()) return false;
if (reservation.orElseThrow().projectNo() != null) {
return !Objects.equals(reservation.orElseThrow().projectNo(), projectNo);
}
return !policy.isIsolated(projectNo);
}
private Candidate score(Lane lane, SpaceState space, InboundRequest request, InboundContext context,
InboundLadderRung rung, InboundBorrowType borrowType,
String mixedIntoResidentProject, boolean openedNewLane) {
long sameProject = lane.occupied().stream()
.filter(item -> request.projectNo() != null && Objects.equals(item.projectNo(), request.projectNo()))
.count();
double fillRate = lane.spaces().isEmpty() ? 0 : (double) lane.occupied().size() / lane.spaces().size();
double codingProgress = (space.laneCapacity() - space.groupCoding() + 1.0) / Math.max(1, space.laneCapacity());
double zoneBonus = "MAIN".equalsIgnoreCase(space.zoneCode()) ? 8
: "FLEX".equalsIgnoreCase(space.zoneCode()) ? 5 : 0;
double capacityScore = clamp(20 + fillRate * 52 + (sameProject > 0 ? 20 : 0)
+ codingProgress * 15 + zoneBonus);
int entryFloor = Math.max(1, Math.min(8, request.entryFloor() == null ? 1 : request.entryFloor()));
int aisleDistance = Math.min(Math.abs(space.aisleNo() - 6), Math.abs(space.aisleNo() - 10));
double efficiencyScore = clamp(105 - Math.abs(space.floorNo() - entryFloor) * 12
- aisleDistance * 3 - numericRow(space.rowCode()) * 0.7);
SchedulingPolicy scheduling = context.policy().schedulingPolicy();
double capacityWeight = scheduling.capacityWeight();
double efficiencyWeight = scheduling.efficiencyWeight();
if (context.occupiedCount() >= scheduling.capacityFirstOccupied()) {
capacityWeight = 0.8;
efficiencyWeight = 0.2;
}
double sum = capacityWeight + efficiencyWeight;
if (sum == 0) {
capacityWeight = efficiencyWeight = 0.5;
sum = 1;
}
double totalScore = capacityScore * capacityWeight / sum + efficiencyScore * efficiencyWeight / sum;
Instant residentNewest = lane.occupied().stream()
.map(SpaceState::inboundAt)
.filter(Objects::nonNull)
.max(Comparator.naturalOrder())
.orElse(Instant.MAX);
if (lane.occupied().stream().anyMatch(item -> item.inboundAt() == null)) residentNewest = Instant.MAX;
return new Candidate(space, lane, rung, borrowType, mixedIntoResidentProject, openedNewLane,
round1(capacityScore), round1(efficiencyScore), round1(totalScore), round1(fillRate * 100),
lane.spaces().size() - lane.occupied().size(), residentNewest);
}
private Optional<Candidate> best(List<Candidate> candidates) {
return candidates.stream().min(candidateComparator());
}
private Comparator<Candidate> candidateComparator() {
return Comparator.comparingDouble(Candidate::totalScore).reversed()
.thenComparing(Comparator.comparingDouble(Candidate::capacityScore).reversed())
.thenComparing(candidate -> candidate.space().spaceId());
}
private InboundAssignment toAssignment(InboundRequest request, Candidate candidate) {
SpaceState space = candidate.space();
String reason = "Selected the innermost legal slot " + space.spaceId() + " in lane "
+ candidate.lane().code() + " at rung " + candidate.rung()
+ "; fill=" + candidate.fillRatePercent() + "%, capacity=" + candidate.capacityScore()
+ ", efficiency=" + candidate.efficiencyScore() + ", total=" + candidate.totalScore() + ".";
return new InboundAssignment(request.requestId(), space.spaceId(), candidate.lane().code(),
space.laneCapacity(), space.depthOrder(), candidate.rung(), candidate.borrowType(),
candidate.mixedIntoResidentProject(), candidate.capacityScore(), candidate.efficiencyScore(),
candidate.totalScore(), candidate.fillRatePercent(), request.batchLaneMode() == BatchLaneMode.SEALED,
candidate.openedNewLane(), reason);
}
private List<Lane> buildLanes(InboundContext context) {
Map<String, List<SpaceState>> grouped = context.spaces().stream().collect(Collectors.groupingBy(
SpaceState::laneCode, LinkedHashMap::new, Collectors.toList()));
return grouped.entrySet().stream().map(entry -> analyzeLane(entry.getKey(), entry.getValue(), context))
.toList();
}
private Lane analyzeLane(String code, List<SpaceState> input, InboundContext context) {
List<SpaceState> spaces = input.stream()
.sorted(Comparator.comparingInt(SpaceState::depthOrder).thenComparing(SpaceState::spaceId))
.toList();
List<SpaceState> occupied = spaces.stream().filter(SpaceState::occupied).toList();
boolean gapSeen = false;
boolean continuous = true;
boolean invalid = false;
for (SpaceState space : spaces) {
if (!space.occupied()) {
gapSeen = true;
} else if (gapSeen) {
continuous = false;
}
if (space.inventoryProjectConflict()
|| (space.status() == SpaceStatus.EMPTY && space.hasTray())) invalid = true;
}
List<Segment> segments = new ArrayList<>();
for (SpaceState space : occupied) {
String key = segmentKey(space, context.policy());
if (segments.isEmpty() || !segments.get(segments.size() - 1).key().equals(key)) {
segments.add(new Segment(key, space.projectNo(), space.productId()));
}
}
int depth = spaces.stream().mapToInt(SpaceState::laneCapacity).max().orElse(0);
boolean completeTopology = spaces.size() == depth;
for (int index = 0; index < spaces.size(); index++) {
SpaceState space = spaces.get(index);
if (space.depthOrder() != index + 1 || space.laneCapacity() != depth) {
completeTopology = false;
break;
}
}
boolean corrupt = invalid || !continuous || !completeTopology || segments.size() > 2;
int maxOccupiedDepth = occupied.stream().mapToInt(SpaceState::depthOrder).max().orElse(0);
Optional<SpaceState> immediateOuterSlot = corrupt ? Optional.empty()
: spaces.stream().filter(SpaceState::availableEmpty)
.filter(space -> space.depthOrder() == maxOccupiedDepth + 1).findFirst();
boolean emptyLane = occupied.isEmpty() && spaces.stream().allMatch(SpaceState::availableEmpty);
boolean outboundBlocked = spaces.stream().anyMatch(SpaceState::outboundTaskActive)
|| occupied.stream().anyMatch(space -> pendingHitsPallet(context, space));
return new Lane(code, depth, spaces, occupied, segments, corrupt, outboundBlocked,
emptyLane, immediateOuterSlot, immediateOuterSlot);
}
private String segmentKey(SpaceState space, InboundPolicy policy) {
if (space.projectNo() == null) return "P|__UNKNOWN__";
if (policy.isCommon(space.projectNo())) {
return "C|" + space.projectNo() + "|" + Objects.toString(space.productId(), "");
}
return "P|" + space.projectNo();
}
private double numericRow(String rowCode) {
try {
return Double.parseDouble(rowCode);
} catch (RuntimeException ignored) {
return 0;
}
}
private double clamp(double value) {
return Math.max(0, Math.min(100, value));
}
private double round1(double value) {
return Math.round(value * 10.0) / 10.0;
}
private record Segment(String key, String projectNo, String productId) {
}
private record Lane(
String code,
int depth,
List<SpaceState> spaces,
List<SpaceState> occupied,
List<Segment> segments,
boolean corrupt,
boolean outboundBlocked,
boolean isEmptyLane,
Optional<SpaceState> nextSlot,
Optional<SpaceState> outerSlot
) {
private boolean usableForInbound() {
return !corrupt && !outboundBlocked;
}
}
private record Candidate(
SpaceState space,
Lane lane,
InboundLadderRung rung,
InboundBorrowType borrowType,
String mixedIntoResidentProject,
boolean openedNewLane,
double capacityScore,
double efficiencyScore,
double totalScore,
double fillRatePercent,
int remaining,
Instant residentNewest
) {
}
private record PoolTier(String zone, InboundBorrowType borrowType) {
}
private record PoolChoice(Optional<Candidate> candidate, boolean guardBlocked) {
}
}

View File

@@ -0,0 +1,51 @@
package com.ruoyi.algorithm.inbound;
import com.ruoyi.algorithm.policy.SchedulingPolicy;
import java.util.List;
import java.util.Set;
public record InboundPolicy(
SchedulingPolicy schedulingPolicy,
Set<String> isolatedProjects,
Set<String> commonProjectNos,
List<ManualLaneReservation> manualReservedLanes,
int commonSinglePoolReserve,
Set<String> mixedDeniedProjects
) {
public InboundPolicy {
schedulingPolicy = schedulingPolicy == null ? SchedulingPolicy.ft546Defaults() : schedulingPolicy;
isolatedProjects = isolatedProjects == null ? Set.of() : Set.copyOf(isolatedProjects);
commonProjectNos = commonProjectNos == null ? Set.of("COMMON") : Set.copyOf(commonProjectNos);
manualReservedLanes = manualReservedLanes == null ? List.of() : List.copyOf(manualReservedLanes);
mixedDeniedProjects = mixedDeniedProjects == null ? Set.of() : Set.copyOf(mixedDeniedProjects);
if (commonSinglePoolReserve < 0) throw new IllegalArgumentException("commonSinglePoolReserve cannot be negative");
long distinctLanes = manualReservedLanes.stream().map(ManualLaneReservation::laneCode).distinct().count();
if (distinctLanes != manualReservedLanes.size()) {
throw new IllegalArgumentException("manualReservedLanes contains a duplicate lane");
}
}
public InboundPolicy(SchedulingPolicy schedulingPolicy, Set<String> isolatedProjects,
Set<String> commonProjectNos, List<ManualLaneReservation> manualReservedLanes,
int commonSinglePoolReserve) {
this(schedulingPolicy, isolatedProjects, commonProjectNos, manualReservedLanes,
commonSinglePoolReserve, Set.of());
}
public static InboundPolicy defaults() {
return new InboundPolicy(SchedulingPolicy.ft546Defaults(), Set.of(), Set.of("COMMON"), List.of(), 0);
}
public boolean isIsolated(String projectNo) {
return projectNo != null && isolatedProjects.contains(projectNo);
}
public boolean isCommon(String projectNo) {
return projectNo != null && commonProjectNos.contains(projectNo);
}
public boolean allowsMixed(String projectNo) {
return projectNo != null && !mixedDeniedProjects.contains(projectNo);
}
}

View File

@@ -0,0 +1,16 @@
package com.ruoyi.algorithm.inbound;
import java.util.List;
public record InboundRejection(
InboundRejectionCode code,
String reason,
String projectNo,
String productId,
String lotNo,
List<LadderTraceStep> ladderTrace
) {
public InboundRejection {
ladderTrace = List.copyOf(ladderTrace);
}
}

View File

@@ -0,0 +1,6 @@
package com.ruoyi.algorithm.inbound;
public enum InboundRejectionCode {
NO_FEASIBLE_LOCATION,
NO_BATCH_COMPATIBLE_LANE
}

View File

@@ -0,0 +1,36 @@
package com.ruoyi.algorithm.inbound;
import java.util.Set;
public record InboundRequest(
String requestId,
String trayNo,
String projectNo,
String productId,
String lotNo,
Integer expectedProjectPallets,
Integer entryFloor,
Integer targetFloor,
Set<Integer> allowedFloors,
BatchLaneMode batchLaneMode,
Set<String> eligibleSpaceIds
) {
public InboundRequest(String requestId, String trayNo, String projectNo, String productId, String lotNo,
Integer expectedProjectPallets, Integer entryFloor, Integer targetFloor,
Set<Integer> allowedFloors, BatchLaneMode batchLaneMode) {
this(requestId, trayNo, projectNo, productId, lotNo, expectedProjectPallets, entryFloor, targetFloor,
allowedFloors, batchLaneMode, null);
}
public InboundRequest {
if (requestId == null || requestId.isBlank()) throw new IllegalArgumentException("requestId is required");
if (trayNo == null || trayNo.isBlank()) throw new IllegalArgumentException("trayNo is required");
if (expectedProjectPallets != null && expectedProjectPallets <= 0) {
throw new IllegalArgumentException("expectedProjectPallets must be positive");
}
allowedFloors = allowedFloors == null ? Set.of() : Set.copyOf(allowedFloors);
batchLaneMode = batchLaneMode == null ? BatchLaneMode.OPEN : batchLaneMode;
// null means unrestricted; an empty captured set means no eligible destination.
eligibleSpaceIds = eligibleSpaceIds == null ? null : Set.copyOf(eligibleSpaceIds);
}
}

View File

@@ -0,0 +1,4 @@
package com.ruoyi.algorithm.inbound;
public record LadderTraceStep(InboundLadderRung rung, String reason) {
}

View File

@@ -0,0 +1,9 @@
package com.ruoyi.algorithm.inbound;
public record ManualLaneReservation(String laneCode, String projectNo) {
public ManualLaneReservation {
if (laneCode == null || laneCode.isBlank()) {
throw new IllegalArgumentException("laneCode is required");
}
}
}

View File

@@ -0,0 +1,17 @@
package com.ruoyi.algorithm.inbound;
public record PendingOutbound(String projectNo, String productId, String trayNo,
String sourceLaneCode, String sourceResolution) {
public PendingOutbound(String projectNo, String productId, String trayNo) {
this(projectNo, productId, trayNo, null, "UNRESOLVED");
}
public boolean hasResolvedLane() {
if (sourceLaneCode == null || sourceLaneCode.isBlank()
|| sourceResolution == null || sourceResolution.isBlank()) {
return false;
}
return !"UNRESOLVED".equalsIgnoreCase(sourceResolution)
&& !"AMBIGUOUS_TRAY".equalsIgnoreCase(sourceResolution);
}
}

View File

@@ -0,0 +1,45 @@
package com.ruoyi.algorithm.integration;
import com.ruoyi.algorithm.inbound.InboundPlanner;
import com.ruoyi.algorithm.outbound.OutboundPlanner;
import com.ruoyi.algorithm.repository.JdbcWarehouseRepository;
import com.ruoyi.algorithm.repository.RepositoryDialect;
import com.ruoyi.algorithm.repository.WarehouseRepository;
import com.ruoyi.algorithm.service.WarehouseApplicationService;
import com.ruoyi.algorithm.tidy.TidyPlanner;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import javax.sql.DataSource;
@Configuration
@EnableConfigurationProperties(AlgorithmProperties.class)
public class AlgorithmConfiguration {
@Bean
WarehouseRepository algorithmWarehouseRepository(DataSource dataSource, AlgorithmProperties properties,
PlatformTransactionManager transactionManager) {
if (transactionManager instanceof DataSourceTransactionManager dataSourceTransactionManager
&& dataSourceTransactionManager.getDataSource() != dataSource) {
throw new IllegalStateException("Algorithm repository and WMS must use the same DataSource");
}
return new JdbcWarehouseRepository(dataSource, RepositoryDialect.sqlServer(),
properties.isDatabaseWritesEnabled(), transactionManager);
}
@Bean
WarehouseApplicationService algorithmApplicationService(WarehouseRepository algorithmWarehouseRepository,
AlgorithmProperties properties) {
return new WarehouseApplicationService(algorithmWarehouseRepository, new InboundPlanner(),
new OutboundPlanner(), new TidyPlanner(), properties.getPageSize());
}
@Bean
SchedulingFacade schedulingFacade(WarehouseApplicationService algorithmApplicationService,
AlgorithmProperties properties) {
return new SchedulingFacade(algorithmApplicationService, properties);
}
}

View File

@@ -0,0 +1,74 @@
package com.ruoyi.algorithm.integration;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "fortucky.algorithm")
public class AlgorithmProperties {
private SchedulingMode mode = SchedulingMode.SHADOW;
private String warehouseId = "01";
private int pageSize = 100;
private boolean databaseWritesEnabled = true;
private boolean legacyFallbackOnTechnicalError;
private long tidyAutoScanMs = 600_000L;
private long tidyReconcileMs = 5_000L;
private int tidyClaimLeaseSeconds = 60;
private int tidyManualMaxConcurrency = 3;
private String tidyWcsSnapshotUrl = "http://localhost:8081/wcs2Wms/api/tidyVehicleSnapshot";
private String replayRunId = "";
private String replayDatabase = "";
public SchedulingMode getMode() {
return mode;
}
public void setMode(SchedulingMode mode) {
this.mode = mode;
}
public String getWarehouseId() {
return warehouseId;
}
public void setWarehouseId(String warehouseId) {
this.warehouseId = warehouseId;
}
public int getPageSize() {
return pageSize;
}
public void setPageSize(int pageSize) {
this.pageSize = pageSize;
}
public boolean isDatabaseWritesEnabled() {
return databaseWritesEnabled;
}
public void setDatabaseWritesEnabled(boolean databaseWritesEnabled) {
this.databaseWritesEnabled = databaseWritesEnabled;
}
public boolean isLegacyFallbackOnTechnicalError() {
return legacyFallbackOnTechnicalError;
}
public void setLegacyFallbackOnTechnicalError(boolean legacyFallbackOnTechnicalError) {
this.legacyFallbackOnTechnicalError = legacyFallbackOnTechnicalError;
}
public long getTidyAutoScanMs() { return tidyAutoScanMs; }
public void setTidyAutoScanMs(long value) { this.tidyAutoScanMs = value; }
public long getTidyReconcileMs() { return tidyReconcileMs; }
public void setTidyReconcileMs(long value) { this.tidyReconcileMs = value; }
public int getTidyClaimLeaseSeconds() { return tidyClaimLeaseSeconds; }
public void setTidyClaimLeaseSeconds(int value) { this.tidyClaimLeaseSeconds = value; }
public int getTidyManualMaxConcurrency() { return tidyManualMaxConcurrency; }
public void setTidyManualMaxConcurrency(int value) { this.tidyManualMaxConcurrency = value; }
public String getTidyWcsSnapshotUrl() { return tidyWcsSnapshotUrl; }
public void setTidyWcsSnapshotUrl(String value) { this.tidyWcsSnapshotUrl = value; }
public String getReplayRunId() { return replayRunId; }
public void setReplayRunId(String value) { this.replayRunId = value; }
public String getReplayDatabase() { return replayDatabase; }
public void setReplayDatabase(String value) { this.replayDatabase = value; }
}

View File

@@ -0,0 +1,8 @@
package com.ruoyi.algorithm.integration;
public enum SchedulingDecisionStatus {
READY,
WAITING,
REJECTED,
TECHNICAL_ERROR
}

View File

@@ -0,0 +1,100 @@
package com.ruoyi.algorithm.integration;
import com.ruoyi.algorithm.inbound.InboundRequest;
import com.ruoyi.algorithm.outbound.OutboundPlan;
import com.ruoyi.algorithm.outbound.OutboundRequest;
import com.ruoyi.algorithm.repository.DeepWorkflowMutationResult;
import com.ruoyi.algorithm.repository.ReservationResult;
import com.ruoyi.algorithm.service.InboundPlanningResult;
import com.ruoyi.algorithm.service.InboundReservationOutcome;
import com.ruoyi.algorithm.service.WarehouseApplicationService;
import com.ruoyi.algorithm.tidy.TidyPlan;
import java.time.Instant;
import java.util.Objects;
public final class SchedulingFacade {
private final WarehouseApplicationService applicationService;
private final AlgorithmProperties properties;
private final SchedulingRoutingPolicy routingPolicy;
public SchedulingFacade(WarehouseApplicationService applicationService, AlgorithmProperties properties) {
this.applicationService = Objects.requireNonNull(applicationService, "applicationService");
this.properties = Objects.requireNonNull(properties, "properties");
this.routingPolicy = new SchedulingRoutingPolicy(properties.getMode(),
properties.isLegacyFallbackOnTechnicalError());
}
public SchedulingRoutingPolicy routingPolicy() {
return routingPolicy;
}
public String defaultWarehouseId() {
return properties.getWarehouseId();
}
public InboundPlanningResult planInbound(String warehouseId, InboundRequest request) {
requireAlgorithmMode();
return applicationService.planInbound(warehouseId, request);
}
public InboundPlanningResult planInboundFromPublishedSnapshot(String warehouseId, InboundRequest request) {
requireAlgorithmMode();
return applicationService.planInboundFromPublishedSnapshot(warehouseId, request);
}
public InboundReservationOutcome reserveInbound(String reservationId, String warehouseId,
String projectNo, InboundPlanningResult plan) {
requireActiveMode();
return applicationService.reserveInbound(reservationId, warehouseId, projectNo, plan);
}
public OutboundPlan planOutbound(String warehouseId, OutboundRequest request) {
requireAlgorithmMode();
return applicationService.planOutbound(warehouseId, request);
}
public OutboundPlan planOutboundFromPublishedSnapshot(String warehouseId, OutboundRequest request) {
requireAlgorithmMode();
return applicationService.planOutboundFromPublishedSnapshot(warehouseId, request);
}
public OutboundPlan planShadowOutboundCandidates(String warehouseId, OutboundRequest request,
java.util.Set<String> eligibleStockIds) {
if (properties.getMode() != SchedulingMode.SHADOW) {
throw new IllegalStateException("Captured candidate planning requires SHADOW mode");
}
return applicationService.planShadowOutboundCandidates(warehouseId, request, eligibleStockIds);
}
public TidyPlan planTidy(String warehouseId, Instant now) {
requireAlgorithmMode();
return applicationService.planTidy(warehouseId, now);
}
public ReservationResult releaseInboundReservation(String warehouseId, String reservationId) {
return applicationService.releaseReservation(warehouseId, reservationId);
}
public DeepWorkflowMutationResult acquireDeepWorkflowLock(String warehouseId, String requestId, int floorNo) {
requireActiveMode();
return applicationService.acquireDeepWorkflowLock(warehouseId, requestId, floorNo);
}
public DeepWorkflowMutationResult releaseDeepWorkflowLock(String warehouseId, String requestId, int floorNo) {
return applicationService.releaseDeepWorkflowLock(warehouseId, requestId, floorNo);
}
private void requireAlgorithmMode() {
if (!routingPolicy.runAlgorithm()) {
throw new IllegalStateException("Java scheduling is disabled in LEGACY mode");
}
}
private void requireActiveMode() {
if (!routingPolicy.algorithmOwnsDecision()) {
throw new IllegalStateException("Algorithm writes require ACTIVE mode");
}
}
}

View File

@@ -0,0 +1,7 @@
package com.ruoyi.algorithm.integration;
public enum SchedulingMode {
LEGACY,
SHADOW,
ACTIVE
}

View File

@@ -0,0 +1,27 @@
package com.ruoyi.algorithm.integration;
import java.util.Objects;
public record SchedulingRoutingPolicy(SchedulingMode mode, boolean legacyFallbackOnTechnicalError) {
public SchedulingRoutingPolicy {
mode = Objects.requireNonNull(mode, "mode");
}
public boolean runAlgorithm() {
return mode != SchedulingMode.LEGACY;
}
public boolean algorithmOwnsDecision() {
return mode == SchedulingMode.ACTIVE;
}
public boolean runLegacy(SchedulingDecisionStatus status) {
Objects.requireNonNull(status, "status");
return switch (mode) {
case LEGACY, SHADOW -> true;
case ACTIVE -> status == SchedulingDecisionStatus.TECHNICAL_ERROR
&& legacyFallbackOnTechnicalError;
};
}
}

View File

@@ -0,0 +1,19 @@
package com.ruoyi.algorithm.model;
import java.util.List;
import java.util.Optional;
public record LaneState(
List<SpaceState> spaces,
boolean physicallyContinuous,
boolean corrupt,
int segmentCount,
Optional<String> residentProjectNo,
Optional<String> guestProjectNo,
Optional<SpaceState> nextSlot,
Optional<SpaceState> outerSlot
) {
public LaneState {
spaces = List.copyOf(spaces);
}
}

View File

@@ -0,0 +1,8 @@
package com.ruoyi.algorithm.model;
public enum SpaceRole {
STORAGE,
RELOCATION_BUFFER,
OUTBOUND_CACHE,
PROTECTED_CORE
}

View File

@@ -0,0 +1,54 @@
package com.ruoyi.algorithm.model;
import java.time.Instant;
public record SpaceState(
String spaceId,
String zoneCode,
String laneCode,
int depthOrder,
int laneCapacity,
int floorNo,
int aisleNo,
int groupCoding,
String rowCode,
SpaceRole role,
SpaceStatus status,
boolean outboundTaskActive,
boolean inventoryProjectConflict,
String trayNo,
String projectNo,
String productId,
String lotNo,
Instant inboundAt,
Instant outboundAt,
boolean reserved,
String rowVersion,
boolean physicalStockPresent
) {
public SpaceState(String spaceId, String zoneCode, String laneCode, int depthOrder, int laneCapacity,
int floorNo, int aisleNo, int groupCoding, String rowCode, SpaceRole role,
SpaceStatus status, boolean outboundTaskActive, boolean inventoryProjectConflict,
String trayNo, String projectNo, String productId, String lotNo, Instant inboundAt,
Instant outboundAt, boolean reserved, String rowVersion) {
this(spaceId, zoneCode, laneCode, depthOrder, laneCapacity, floorNo, aisleNo, groupCoding, rowCode,
role, status, outboundTaskActive, inventoryProjectConflict, trayNo, projectNo, productId,
lotNo, inboundAt, outboundAt, reserved, rowVersion, false);
}
public boolean hasTray() {
return trayNo != null && !trayNo.isBlank();
}
public boolean occupied() {
return status == SpaceStatus.OCCUPIED || hasTray() || physicalStockPresent;
}
public boolean availableEmpty() {
return status == SpaceStatus.EMPTY && !reserved && !hasTray() && !physicalStockPresent;
}
public boolean outboundSelectable() {
return status == SpaceStatus.OCCUPIED && !reserved && hasTray();
}
}

View File

@@ -0,0 +1,7 @@
package com.ruoyi.algorithm.model;
public enum SpaceStatus {
EMPTY,
OCCUPIED,
DISABLED
}

View File

@@ -0,0 +1,25 @@
package com.ruoyi.algorithm.outbound;
import java.time.Instant;
import java.util.Optional;
public record FifoDecision(
boolean fifoApplied,
String fifoMode,
Integer fifoRank,
Instant oldestInboundTime,
Instant selectedInboundTime,
int fifoDeviationMinutes,
boolean fifoViolation,
String reason,
Optional<FifoWarning> warning
) {
public FifoDecision {
warning = warning == null ? Optional.empty() : warning;
}
public static FifoDecision off() {
return new FifoDecision(false, "OFF", null, null, null, 0, false,
"FIFO is disabled", Optional.empty());
}
}

View File

@@ -0,0 +1,4 @@
package com.ruoyi.algorithm.outbound;
public record FifoWarning(String code, String message) {
}

View File

@@ -0,0 +1,29 @@
package com.ruoyi.algorithm.outbound;
import java.util.List;
import java.util.Optional;
public record OutboundAssignment(
String requestId,
String trayNo,
String projectNo,
String productId,
String sourceSpaceId,
int sourceFloor,
boolean wholeTray,
List<String> blockerSpaceIds,
List<String> blockingTrayNos,
boolean laneReleasing,
double capacityScore,
double efficiencyScore,
double totalScore,
FifoDecision fifo,
Optional<OutboundWorkflow> workflow,
String reason
) {
public OutboundAssignment {
blockerSpaceIds = List.copyOf(blockerSpaceIds);
blockingTrayNos = List.copyOf(blockingTrayNos);
workflow = workflow == null ? Optional.empty() : workflow;
}
}

View File

@@ -0,0 +1,7 @@
package com.ruoyi.algorithm.outbound;
public enum OutboundCapacityMode {
BALANCED,
WARNING,
CAPACITY_FIRST
}

View File

@@ -0,0 +1,33 @@
package com.ruoyi.algorithm.outbound;
import com.ruoyi.algorithm.model.SpaceState;
import java.util.List;
import java.util.Map;
import java.util.Set;
public record OutboundContext(
List<SpaceState> spaces,
Map<String, String> rawInboundTimes,
OutboundCapacityMode capacityMode,
Set<Integer> activeDeepWorkflowFloors
) {
public OutboundContext {
spaces = List.copyOf(spaces);
rawInboundTimes = Map.copyOf(rawInboundTimes);
capacityMode = capacityMode == null ? OutboundCapacityMode.BALANCED : capacityMode;
activeDeepWorkflowFloors = activeDeepWorkflowFloors == null ? Set.of() : Set.copyOf(activeDeepWorkflowFloors);
}
public OutboundContext(List<SpaceState> spaces, Map<String, String> rawInboundTimes,
OutboundCapacityMode capacityMode) {
this(spaces, rawInboundTimes, capacityMode, Set.of());
}
public String rawInboundTime(SpaceState space) {
if (rawInboundTimes.containsKey(space.spaceId())) {
return rawInboundTimes.get(space.spaceId());
}
return space.inboundAt() == null ? null : space.inboundAt().toString();
}
}

View File

@@ -0,0 +1,9 @@
package com.ruoyi.algorithm.outbound;
import java.util.Map;
public record OutboundError(OutboundErrorCode code, String message, Map<String, Object> details) {
public OutboundError {
details = Map.copyOf(details);
}
}

View File

@@ -0,0 +1,12 @@
package com.ruoyi.algorithm.outbound;
public enum OutboundErrorCode {
STOCK_NOT_FOUND,
AMBIGUOUS_TRAY,
OUTBOUND_SCOPE_REQUIRED,
FIFO_INVALID_MODE,
FIFO_INVALID_TOLERANCE,
FIFO_SCOPE_REQUIRED,
FIFO_DATA_MISSING,
FIFO_INVALID_TIME
}

View File

@@ -0,0 +1,32 @@
package com.ruoyi.algorithm.outbound;
import java.util.Optional;
public record OutboundPlan(
OutboundPlanStatus status,
Optional<OutboundAssignment> assignment,
Optional<OutboundWaiting> waiting,
Optional<OutboundError> error,
TaskCountEstimate taskCountEstimate
) {
public OutboundPlan {
assignment = assignment == null ? Optional.empty() : assignment;
waiting = waiting == null ? Optional.empty() : waiting;
error = error == null ? Optional.empty() : error;
}
public static OutboundPlan ready(OutboundAssignment assignment, TaskCountEstimate estimate) {
return new OutboundPlan(OutboundPlanStatus.READY, Optional.of(assignment), Optional.empty(),
Optional.empty(), estimate);
}
public static OutboundPlan waiting(OutboundWaiting waiting) {
return new OutboundPlan(OutboundPlanStatus.WAITING, Optional.empty(), Optional.of(waiting),
Optional.empty(), TaskCountEstimate.zero());
}
public static OutboundPlan error(OutboundError error) {
return new OutboundPlan(OutboundPlanStatus.ERROR, Optional.empty(), Optional.empty(),
Optional.of(error), TaskCountEstimate.zero());
}
}

View File

@@ -0,0 +1,7 @@
package com.ruoyi.algorithm.outbound;
public enum OutboundPlanStatus {
READY,
WAITING,
ERROR
}

View File

@@ -0,0 +1,332 @@
package com.ruoyi.algorithm.outbound;
import com.ruoyi.algorithm.model.SpaceRole;
import com.ruoyi.algorithm.model.SpaceState;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
public final class OutboundPlanner {
public OutboundPlan plan(OutboundRequest request, OutboundContext context) {
return plan(request, context, null);
}
public OutboundPlan plan(OutboundRequest request, OutboundContext context, java.util.Set<String> eligibleSpaceIds) {
String mode = normalizedMode(request.fifoMode());
if (eligibleSpaceIds != null && !"OFF".equals(mode)) {
throw new IllegalArgumentException("Captured candidate planning supports FIFO OFF only");
}
if (!List.of("OFF", "STRICT", "FLEXIBLE").contains(mode)) {
return error(OutboundErrorCode.FIFO_INVALID_MODE, "Unsupported FIFO mode",
Map.of("fifoMode", String.valueOf(request.fifoMode())));
}
int tolerance = request.fifoToleranceMinutes() == null ? 0 : request.fifoToleranceMinutes();
if (tolerance < 0) {
return error(OutboundErrorCode.FIFO_INVALID_TOLERANCE,
"FIFO tolerance must be a non-negative integer", Map.of("fifoToleranceMinutes", tolerance));
}
if (blank(request.trayNo()) && blank(request.projectNo())) {
return error(OutboundErrorCode.OUTBOUND_SCOPE_REQUIRED,
"Outbound without a specified tray requires projectNo", Map.of("requestId", request.requestId()));
}
List<SpaceState> physicalMatches = context.spaces().stream()
.filter(SpaceState::occupied)
.filter(space -> eligibleSpaceIds == null || eligibleSpaceIds.contains(space.spaceId()))
.filter(space -> matchesRequest(space, request))
.toList();
if (physicalMatches.isEmpty()) {
return error(OutboundErrorCode.STOCK_NOT_FOUND, "No matching stock was found",
Map.of("requestId", request.requestId()));
}
if (!blank(request.trayNo()) && physicalMatches.stream().noneMatch(SpaceState::outboundSelectable)) {
return unavailableSource(physicalMatches.get(0));
}
List<SpaceState> businessMatches = physicalMatches.stream()
.filter(SpaceState::outboundSelectable)
.toList();
if (businessMatches.isEmpty()) {
return unavailableSource(physicalMatches.get(0));
}
Selection selection = mode.equals("OFF")
? selectOff(businessMatches, request, context)
: selectFifo(businessMatches, request, context, mode, tolerance);
if (selection.error() != null) return OutboundPlan.error(selection.error());
SpaceState source = selection.source();
List<SpaceState> blockers = actualBlockers(source, context.spaces());
List<SpaceState> unavailableBlockers = blockers.stream()
.filter(blocker -> !blocker.outboundSelectable())
.toList();
if (!unavailableBlockers.isEmpty()) {
return OutboundPlan.waiting(new OutboundWaiting(OutboundWaitingCode.WAITING_CHANNEL,
blockers.size(), 0,
"A physical outer pallet is allocated, inspected, locked, or reserved; wait and recalculate",
true, Map.of("sourceSpaceId", source.spaceId(), "laneCode", source.laneCode(),
"blockingSpaceIds", blockers.stream().map(SpaceState::spaceId).toList(),
"blockingTrayNos", blockers.stream().map(SpaceState::trayNo).toList(),
"unavailableBlockingSpaceIds", unavailableBlockers.stream().map(SpaceState::spaceId).toList())));
}
List<SpaceState> buffers = availableBuffers(source.floorNo(), context.spaces());
if (!blockers.isEmpty() && context.activeDeepWorkflowFloors().contains(source.floorNo())) {
return OutboundPlan.waiting(new OutboundWaiting(
OutboundWaitingCode.WAITING_RELOCATION_BUFFER,
blockers.size(), buffers.size(),
"An active same-floor deep workflow owns the relocation buffer; wait and recalculate",
true, Map.of("floor", source.floorNo(), "sourceSpaceId", source.spaceId(),
"laneCode", source.laneCode())));
}
if (buffers.size() < blockers.size()) {
List<String> blockerIds = blockers.stream().map(SpaceState::spaceId).toList();
List<String> blockerTrays = blockers.stream().map(SpaceState::trayNo).toList();
Map<String, Object> details = new HashMap<>();
details.put("floor", source.floorNo());
details.put("required", blockers.size());
details.put("available", buffers.size());
details.put("sourceSpaceId", source.spaceId());
details.put("laneCode", source.laneCode());
details.put("blockingSpaceIds", blockerIds);
details.put("blockingTrayNos", blockerTrays);
return OutboundPlan.waiting(new OutboundWaiting(
OutboundWaitingCode.WAITING_RELOCATION_BUFFER,
blockers.size(), buffers.size(),
"Insufficient same-floor relocation buffer; wait and recalculate", true, details));
}
boolean laneReleasing = context.capacityMode() != OutboundCapacityMode.BALANCED
&& occupiedInLane(source, context.spaces()) == 1;
Optional<OutboundWorkflow> workflow = blockers.isEmpty()
? Optional.empty()
: Optional.of(buildWorkflow(request, source, blockers, buffers.subList(0, blockers.size()), context.spaces()));
OutboundAssignment assignment = new OutboundAssignment(
request.requestId(), source.trayNo(), source.projectNo(), source.productId(), source.spaceId(),
source.floorNo(), request.wholeTrayOrDefault(), blockers.stream().map(SpaceState::spaceId).toList(),
blockers.stream().map(SpaceState::trayNo).toList(), laneReleasing, 100.0,
efficiencyScore(source, request, context), 100.0, selection.fifo(), workflow,
"Outbound " + source.spaceId() + " with " + blockers.size() + " blocking tray(s)");
TaskCountEstimate estimate = new TaskCountEstimate(0, 1, blockers.size() * 2,
source.floorNo() == request.exitFloorOrDefault() ? 0 : 1);
return OutboundPlan.ready(assignment, estimate);
}
private Selection selectOff(List<SpaceState> matches, OutboundRequest request, OutboundContext context) {
Comparator<SpaceState> comparator = Comparator
.comparingInt((SpaceState space) -> actualBlockers(space, context.spaces()).size())
.thenComparingInt(space -> laneReleaseRank(space, context))
.thenComparingInt(SpaceState::floorNo)
.thenComparing(SpaceState::spaceId);
return new Selection(matches.stream().min(comparator).orElseThrow(), FifoDecision.off(), null);
}
private Selection selectFifo(List<SpaceState> requestMatches, OutboundRequest request,
OutboundContext context, String mode, int tolerance) {
SpaceState specified = blank(request.trayNo()) ? null : requestMatches.get(0);
String project = specified == null ? request.projectNo() : specified.projectNo();
String product = specified == null ? request.productId() : specified.productId();
if (blank(project)) {
return failed(OutboundErrorCode.FIFO_SCOPE_REQUIRED,
"FIFO scope requires a project or a tray that identifies one", Map.of("requestId", request.requestId()));
}
List<SpaceState> scope = context.spaces().stream()
.filter(SpaceState::occupied)
.filter(space -> project.equals(space.projectNo()))
.filter(space -> blank(product) || product.equals(space.productId()))
.toList();
List<String> missing = scope.stream()
.filter(space -> blank(context.rawInboundTime(space)))
.map(SpaceState::spaceId)
.sorted()
.toList();
if (!missing.isEmpty()) {
return failed(OutboundErrorCode.FIFO_DATA_MISSING,
"FIFO scope contains stock without inbound time", Map.of("spaceIds", missing));
}
List<TimedStock> timed = new ArrayList<>();
for (SpaceState space : scope) {
String raw = context.rawInboundTime(space);
Instant timestamp;
try {
timestamp = OffsetDateTime.parse(raw).toInstant();
} catch (DateTimeParseException first) {
try {
timestamp = Instant.parse(raw);
} catch (DateTimeParseException second) {
return failed(OutboundErrorCode.FIFO_INVALID_TIME, "Stock has an invalid inbound time",
Map.of("spaceId", space.spaceId(), "inboundTime", raw));
}
}
timed.add(new TimedStock(space, timestamp, actualBlockers(space, context.spaces()).size(),
laneReleaseRank(space, context), efficiencyScore(space, request, context)));
}
Instant oldest = timed.stream().map(TimedStock::timestamp).min(Instant::compareTo).orElseThrow();
Comparator<TimedStock> costComparator = Comparator.comparingInt(TimedStock::blockerCount)
.thenComparingInt(TimedStock::releaseRank)
.thenComparing(Comparator.comparingInt(TimedStock::efficiencyScore).reversed())
.thenComparing(TimedStock::timestamp)
.thenComparing(item -> item.space().spaceId());
TimedStock selected;
if (specified != null) {
selected = timed.stream().filter(item -> item.space().spaceId().equals(specified.spaceId()))
.findFirst().orElseThrow();
} else if (mode.equals("STRICT")) {
selected = timed.stream().filter(item -> item.timestamp().equals(oldest)).min(costComparator).orElseThrow();
} else {
Instant latest = oldest.plus(tolerance, ChronoUnit.MINUTES);
selected = timed.stream().filter(item -> !item.timestamp().isAfter(latest)).min(costComparator).orElseThrow();
}
int rank = 1 + (int) timed.stream().filter(item -> item.timestamp().isBefore(selected.timestamp())).count();
int deviation = Math.toIntExact(ChronoUnit.MINUTES.between(oldest, selected.timestamp()));
boolean overridden = specified != null;
boolean violation = overridden && selected.timestamp().isAfter(oldest);
Optional<FifoWarning> warning = overridden
? Optional.of(new FifoWarning("FIFO_OVERRIDDEN_BY_TRAY", violation
? "Specified tray is not the oldest stock; FIFO was explicitly overridden"
: "Specified tray is in the oldest FIFO cohort"))
: Optional.empty();
String reason = overridden
? "Specified tray retained; real FIFO rank reported"
: mode.equals("STRICT")
? "Global oldest stock selected"
: "Lowest handling cost selected inside the FIFO tolerance window";
return new Selection(selected.space(), new FifoDecision(true, mode, rank, oldest, selected.timestamp(),
deviation, violation, reason, warning), null);
}
private OutboundWorkflow buildWorkflow(OutboundRequest request, SpaceState source, List<SpaceState> blockers,
List<SpaceState> buffers, List<SpaceState> spaces) {
List<DraftStep> drafts = new ArrayList<>();
for (int index = 0; index < blockers.size(); index++) {
drafts.add(new DraftStep(id(request.requestId(), "MOVE-OUT", index), OutboundWorkflowPhase.MOVE_OUT,
blockers.get(index).spaceId(), buffers.get(index).spaceId()));
}
drafts.add(new DraftStep(id(request.requestId(), "OUTBOUND", 0), OutboundWorkflowPhase.OUTBOUND,
source.spaceId(), "DIRECT_OUT"));
List<SpaceState> ascending = new ArrayList<>(blockers);
ascending.sort(Comparator.comparingInt(SpaceState::groupCoding).thenComparing(SpaceState::spaceId));
List<SpaceState> compactTargets = spaces.stream()
.filter(space -> space.laneCode().equals(source.laneCode()))
.filter(space -> space.groupCoding() >= source.groupCoding())
.sorted(Comparator.comparingInt(SpaceState::groupCoding).thenComparing(SpaceState::spaceId))
.limit(blockers.size())
.toList();
for (int index = 0; index < ascending.size(); index++) {
SpaceState blocker = ascending.get(index);
int bufferIndex = blockers.indexOf(blocker);
String target = request.wholeTrayOrDefault() ? compactTargets.get(index).spaceId() : blocker.spaceId();
drafts.add(new DraftStep(id(request.requestId(), "RESTORE", index), OutboundWorkflowPhase.RESTORE,
buffers.get(bufferIndex).spaceId(), target));
}
List<OutboundWorkflowStep> steps = new ArrayList<>();
for (int index = 0; index < drafts.size(); index++) {
DraftStep draft = drafts.get(index);
steps.add(new OutboundWorkflowStep(draft.assignmentId(), draft.phase(), draft.sourceSpaceId(),
draft.targetSpaceId(), index + 1, drafts.size(), index == 0 ? null : drafts.get(index - 1).assignmentId()));
}
List<String> laneLocks = spaces.stream()
.filter(space -> space.laneCode().equals(source.laneCode()))
.sorted(Comparator.comparingInt(SpaceState::groupCoding).thenComparing(SpaceState::spaceId))
.map(SpaceState::spaceId)
.toList();
return new OutboundWorkflow(request.requestId(), source.spaceId(), source.laneCode(), source.floorNo(), steps,
laneLocks, buffers.stream().map(SpaceState::spaceId).toList(), "RELEASE_AFTER_FINAL_RESTORE");
}
private List<SpaceState> actualBlockers(SpaceState source, List<SpaceState> spaces) {
return spaces.stream()
.filter(space -> space.laneCode().equals(source.laneCode()))
.filter(space -> space.groupCoding() > source.groupCoding())
.filter(SpaceState::occupied)
.sorted(Comparator.comparingInt(SpaceState::groupCoding).reversed()
.thenComparing(SpaceState::spaceId))
.toList();
}
private List<SpaceState> availableBuffers(int floor, List<SpaceState> spaces) {
return spaces.stream()
.filter(space -> space.floorNo() == floor)
.filter(space -> space.role() == SpaceRole.RELOCATION_BUFFER)
.filter(SpaceState::availableEmpty)
.sorted(Comparator.comparing(SpaceState::spaceId))
.toList();
}
private boolean matchesRequest(SpaceState space, OutboundRequest request) {
if (!blank(request.trayNo())) return request.trayNo().equals(space.trayNo());
return request.projectNo().equals(space.projectNo())
&& (blank(request.productId()) || request.productId().equals(space.productId()));
}
private int laneReleaseRank(SpaceState space, OutboundContext context) {
return context.capacityMode() != OutboundCapacityMode.BALANCED
&& occupiedInLane(space, context.spaces()) == 1 ? 0 : 1;
}
private int occupiedInLane(SpaceState source, List<SpaceState> spaces) {
return (int) spaces.stream()
.filter(space -> space.laneCode().equals(source.laneCode()))
.filter(SpaceState::occupied)
.count();
}
private int efficiencyScore(SpaceState space, OutboundRequest request, OutboundContext context) {
return 100 - Math.abs(space.floorNo() - request.exitFloorOrDefault()) * 12
- actualBlockers(space, context.spaces()).size() * 10;
}
private OutboundPlan error(OutboundErrorCode code, String message, Map<String, Object> details) {
return OutboundPlan.error(new OutboundError(code, message, details));
}
private OutboundPlan unavailableSource(SpaceState source) {
Map<String, Object> details = new HashMap<>();
details.put("sourceSpaceId", source.spaceId());
details.put("laneCode", source.laneCode());
if (!blank(source.trayNo())) details.put("trayNo", source.trayNo());
return OutboundPlan.waiting(new OutboundWaiting(OutboundWaitingCode.WAITING_CHANNEL, 0, 0,
"Requested stock is allocated, inspected, locked, or reserved; wait and recalculate", true,
details));
}
private Selection failed(OutboundErrorCode code, String message, Map<String, Object> details) {
return new Selection(null, null, new OutboundError(code, message, details));
}
private String normalizedMode(String value) {
return blank(value) ? "OFF" : value.trim().toUpperCase(Locale.ROOT);
}
private String id(String requestId, String phase, int index) {
return requestId + "-" + phase + "-" + String.format(Locale.ROOT, "%02d", index + 1);
}
private boolean blank(String value) {
return value == null || value.isBlank();
}
private record Selection(SpaceState source, FifoDecision fifo, OutboundError error) {
}
private record TimedStock(SpaceState space, Instant timestamp, int blockerCount, int releaseRank,
int efficiencyScore) {
}
private record DraftStep(String assignmentId, OutboundWorkflowPhase phase, String sourceSpaceId,
String targetSpaceId) {
}
}

View File

@@ -0,0 +1,20 @@
package com.ruoyi.algorithm.outbound;
public record OutboundRequest(
String requestId,
String trayNo,
String projectNo,
String productId,
String fifoMode,
Integer fifoToleranceMinutes,
Integer exitFloor,
Boolean wholeTray
) {
public boolean wholeTrayOrDefault() {
return wholeTray == null || wholeTray;
}
public int exitFloorOrDefault() {
return exitFloor == null ? 1 : exitFloor;
}
}

View File

@@ -0,0 +1,16 @@
package com.ruoyi.algorithm.outbound;
import java.util.Map;
public record OutboundWaiting(
OutboundWaitingCode code,
int required,
int available,
String reason,
boolean retryable,
Map<String, Object> details
) {
public OutboundWaiting {
details = Map.copyOf(details);
}
}

View File

@@ -0,0 +1,6 @@
package com.ruoyi.algorithm.outbound;
public enum OutboundWaitingCode {
WAITING_RELOCATION_BUFFER,
WAITING_CHANNEL
}

View File

@@ -0,0 +1,20 @@
package com.ruoyi.algorithm.outbound;
import java.util.List;
public record OutboundWorkflow(
String workflowId,
String sourceSpaceId,
String laneCode,
int floorNo,
List<OutboundWorkflowStep> steps,
List<String> lockedLaneSpaceIds,
List<String> lockedBufferSpaceIds,
String lockReleaseRule
) {
public OutboundWorkflow {
steps = List.copyOf(steps);
lockedLaneSpaceIds = List.copyOf(lockedLaneSpaceIds);
lockedBufferSpaceIds = List.copyOf(lockedBufferSpaceIds);
}
}

View File

@@ -0,0 +1,7 @@
package com.ruoyi.algorithm.outbound;
public enum OutboundWorkflowPhase {
MOVE_OUT,
OUTBOUND,
RESTORE
}

View File

@@ -0,0 +1,12 @@
package com.ruoyi.algorithm.outbound;
public record OutboundWorkflowStep(
String assignmentId,
OutboundWorkflowPhase phase,
String sourceSpaceId,
String targetSpaceId,
int workflowStep,
int workflowTotalSteps,
String dependsOnAssignmentId
) {
}

View File

@@ -0,0 +1,7 @@
package com.ruoyi.algorithm.outbound;
public record TaskCountEstimate(int inbound, int outbound, int relocation, int floorChange) {
public static TaskCountEstimate zero() {
return new TaskCountEstimate(0, 0, 0, 0);
}
}

View File

@@ -0,0 +1,16 @@
package com.ruoyi.algorithm.policy;
public record SchedulingPolicy(
double capacityWeight,
double efficiencyWeight,
int warningOccupied,
int capacityFirstOccupied,
int maxOccupied,
int relocationBuffersPerFloor,
int outboundCachesPerFloor,
int maxTidyCandidates
) {
public static SchedulingPolicy ft546Defaults() {
return new SchedulingPolicy(0.55, 0.45, 564, 597, 615, 4, 2, 10);
}
}

View File

@@ -0,0 +1,7 @@
package com.ruoyi.algorithm.repository;
import java.time.Instant;
public record DecisionLogEntry(String decisionId, String warehouseId, String decisionType,
VersionToken inputVersion, String resultCode, String reason, Instant createdAt) {
}

View File

@@ -0,0 +1,5 @@
package com.ruoyi.algorithm.repository;
public record DecisionLogRecord(String decisionId, String warehouseId, String decisionType,
VersionToken inputVersion, String resultCode, String reason) {
}

View File

@@ -0,0 +1,9 @@
package com.ruoyi.algorithm.repository;
public record DeepWorkflowMutationResult(boolean changed, String reasonCode) {
public static DeepWorkflowMutationResult succeeded() { return new DeepWorkflowMutationResult(true, null); }
public static DeepWorkflowMutationResult unchanged() { return new DeepWorkflowMutationResult(false, null); }
public static DeepWorkflowMutationResult writesDisabled() {
return new DeepWorkflowMutationResult(false, "WRITES_DISABLED");
}
}

View File

@@ -0,0 +1,61 @@
package com.ruoyi.algorithm.repository;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.Objects;
final class H2TopologyReadModel {
private final JdbcTemplate jdbc;
H2TopologyReadModel(JdbcTemplate jdbc) {
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
}
boolean changed(String warehouseId) {
String expectedMismatch = "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 BAS_CARGO_SPACE WHERE 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), EXPECTED AS (SELECT C.CARGO_SPACE_ID AS SPACE_ID,C.WAREHOUSE_ID,"
+ "C.GROUP_CODING_NO AS LANE_CODE,R.LANE_DEPTH,CASE WHEN R.LANE_DEPTH=1 "
+ "AND R.SINGLE_DEPTH_RANK<=80 THEN 'CACHE' WHEN R.LANE_DEPTH=1 AND R.SINGLE_DEPTH_RANK<=133 "
+ "THEN 'RESERVE' WHEN R.LANE_DEPTH=5 THEN 'MAIN' ELSE 'FLEX' END AS BASE_ZONE "
+ "FROM BAS_CARGO_SPACE C JOIN RANKED_LANES R ON R.WAREHOUSE_ID=C.WAREHOUSE_ID "
+ "AND R.LANE_CODE=C.GROUP_CODING_NO WHERE C.WAREHOUSE_ID=?) "
+ "SELECT COUNT(*) FROM EXPECTED E LEFT JOIN ALG_SPACE_TOPOLOGY T ON T.SPACE_ID=E.SPACE_ID "
+ "WHERE T.SPACE_ID IS NULL OR T.WAREHOUSE_ID<>E.WAREHOUSE_ID OR T.LANE_CODE<>E.LANE_CODE "
+ "OR T.LANE_DEPTH<>E.LANE_DEPTH OR T.BASE_ZONE<>E.BASE_ZONE";
Integer mismatched = jdbc.queryForObject(expectedMismatch, Integer.class, warehouseId, warehouseId);
if (mismatched != null && mismatched > 0) {
return true;
}
Integer stale = jdbc.queryForObject("SELECT COUNT(*) FROM ALG_SPACE_TOPOLOGY T LEFT JOIN BAS_CARGO_SPACE C "
+ "ON C.CARGO_SPACE_ID=T.SPACE_ID AND C.WAREHOUSE_ID=T.WAREHOUSE_ID "
+ "WHERE T.WAREHOUSE_ID=? AND C.CARGO_SPACE_ID IS NULL",
Integer.class, warehouseId);
return stale != null && stale > 0;
}
void rebuild(String warehouseId) {
jdbc.update("DELETE FROM ALG_SPACE_TOPOLOGY WHERE WAREHOUSE_ID=?", warehouseId);
jdbc.update("INSERT INTO ALG_SPACE_TOPOLOGY (WAREHOUSE_ID,SPACE_ID,LANE_CODE,LANE_DEPTH,BASE_ZONE) "
+ "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 BAS_CARGO_SPACE "
+ "WHERE 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) SELECT C.WAREHOUSE_ID,C.CARGO_SPACE_ID,C.GROUP_CODING_NO,"
+ "R.LANE_DEPTH,CASE WHEN R.LANE_DEPTH=1 AND R.SINGLE_DEPTH_RANK<=80 THEN 'CACHE' "
+ "WHEN R.LANE_DEPTH=1 AND R.SINGLE_DEPTH_RANK<=133 THEN 'RESERVE' "
+ "WHEN R.LANE_DEPTH=5 THEN 'MAIN' ELSE 'FLEX' END FROM BAS_CARGO_SPACE C "
+ "JOIN RANKED_LANES R ON R.WAREHOUSE_ID=C.WAREHOUSE_ID "
+ "AND R.LANE_CODE=C.GROUP_CODING_NO WHERE C.WAREHOUSE_ID=?",
warehouseId, warehouseId);
}
}

View File

@@ -0,0 +1,5 @@
package com.ruoyi.algorithm.repository;
public record InboundReservation(String reservationId, String warehouseId, String laneCode,
String spaceId, String projectNo, VersionToken expectedVersion) {
}

View File

@@ -0,0 +1,9 @@
package com.ruoyi.algorithm.repository;
import java.math.BigDecimal;
import java.time.Instant;
public record InventoryItem(String stockId, String spaceId, String trayNo, String projectNo, String productNo,
String lotNo, String inboundTimeRaw, Instant inboundAt,
BigDecimal quantity, BigDecimal outboundQuantity) {
}

View File

@@ -0,0 +1,868 @@
package com.ruoyi.algorithm.repository;
import com.ruoyi.algorithm.inbound.InboundPolicy;
import com.ruoyi.algorithm.model.SpaceRole;
import com.ruoyi.algorithm.model.SpaceState;
import com.ruoyi.algorithm.model.SpaceStatus;
import com.ruoyi.algorithm.policy.SchedulingPolicy;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import javax.sql.DataSource;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.sql.Timestamp;
import java.time.Instant;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
public final class JdbcWarehouseRepository implements WarehouseRepository {
public static final int MAX_PAGE_SIZE = 200;
private final RepositoryDialect dialect;
private final JdbcTemplate jdbc;
private final NamedParameterJdbcTemplate named;
private final TransactionTemplate transactions;
private final H2TopologyReadModel h2Topology;
private final boolean writesEnabled;
public JdbcWarehouseRepository(DataSource dataSource, RepositoryDialect dialect) {
this(dataSource, dialect, true);
}
public JdbcWarehouseRepository(DataSource dataSource, RepositoryDialect dialect, boolean writesEnabled) {
this(dataSource, dialect, writesEnabled, new DataSourceTransactionManager(dataSource));
}
public JdbcWarehouseRepository(DataSource dataSource, RepositoryDialect dialect, boolean writesEnabled,
PlatformTransactionManager transactionManager) {
this.dialect = Objects.requireNonNull(dialect, "dialect");
this.jdbc = new JdbcTemplate(dataSource);
this.named = new NamedParameterJdbcTemplate(dataSource);
this.transactions = new TransactionTemplate(Objects.requireNonNull(transactionManager, "transactionManager"));
this.h2Topology = new H2TopologyReadModel(jdbc);
this.writesEnabled = writesEnabled;
}
@Override
public void synchronizeReadModel(String warehouseId) {
if (!writesEnabled) throw new IllegalStateException("Database writes are disabled");
if (blankToNull(warehouseId) == null) throw new IllegalArgumentException("warehouseId is required");
if (!dialect.h2Database()) {
jdbc.update("EXEC alg.usp_sync_inventory_snapshot @warehouse_id=?", warehouseId);
jdbc.update("EXEC alg.usp_rebuild_lane_state @warehouse_id=?", warehouseId);
return;
}
transactions.executeWithoutResult(status -> synchronizeH2ReadModel(warehouseId));
}
@Override
public void refreshChangedLanes(String warehouseId) {
if (!writesEnabled) throw new IllegalStateException("Database writes are disabled");
if (blankToNull(warehouseId) == null) throw new IllegalArgumentException("warehouseId is required");
if (!dialect.h2Database()) {
jdbc.update("EXEC alg.usp_refresh_dirty_lanes @warehouse_id=?", warehouseId);
return;
}
transactions.executeWithoutResult(status -> refreshChangedLanesH2(warehouseId));
}
private void refreshChangedLanesH2(String warehouseId) {
LinkedHashSet<String> laneCodes = new LinkedHashSet<>(jdbc.queryForList(
"SELECT GROUP_CODING_NO FROM BAS_CARGO_SPACE WHERE WAREHOUSE_ID=? "
+ "UNION SELECT LANE_CODE FROM ALG_LANE_STATE WHERE WAREHOUSE_ID=? ORDER BY 1",
String.class, warehouseId, warehouseId));
if (h2Topology.changed(warehouseId)) {
h2Topology.rebuild(warehouseId);
laneCodes.addAll(jdbc.queryForList("SELECT DISTINCT GROUP_CODING_NO FROM BAS_CARGO_SPACE "
+ "WHERE WAREHOUSE_ID=? ORDER BY GROUP_CODING_NO", String.class, warehouseId));
jdbc.update("DELETE FROM ALG_INVENTORY_ITEM_SNAPSHOT WHERE WAREHOUSE_ID=? AND SPACE_ID NOT IN "
+ "(SELECT CARGO_SPACE_ID FROM BAS_CARGO_SPACE WHERE WAREHOUSE_ID=?)",
warehouseId, warehouseId);
jdbc.update("DELETE FROM ALG_SPACE_INVENTORY_SUMMARY WHERE WAREHOUSE_ID=? AND SPACE_ID NOT IN "
+ "(SELECT CARGO_SPACE_ID FROM BAS_CARGO_SPACE WHERE WAREHOUSE_ID=?)",
warehouseId, warehouseId);
}
List<String> dirtyLanes = laneCodes.stream().filter(laneCode -> {
List<String> stored = jdbc.queryForList("SELECT SEMANTIC_FINGERPRINT FROM ALG_LANE_STATE "
+ "WHERE WAREHOUSE_ID=? AND LANE_CODE=?",
String.class, warehouseId, laneCode);
return stored.isEmpty() || !Objects.equals(stored.get(0),
h2SemanticFingerprint(warehouseId, laneCode, InventoryFingerprintSource.LIVE));
}).toList();
dirtyLanes.forEach(laneCode -> refreshH2InventoryLane(warehouseId, laneCode));
dirtyLanes.forEach(laneCode -> refreshH2LaneState(warehouseId, laneCode));
}
private void refreshH2InventoryLane(String warehouseId, String laneCode) {
jdbc.update("DELETE FROM ALG_SPACE_INVENTORY_SUMMARY WHERE WAREHOUSE_ID=? AND SPACE_ID IN "
+ "(SELECT CARGO_SPACE_ID FROM BAS_CARGO_SPACE WHERE WAREHOUSE_ID=? AND GROUP_CODING_NO=?)",
warehouseId, warehouseId, laneCode);
jdbc.update("DELETE FROM ALG_INVENTORY_ITEM_SNAPSHOT WHERE WAREHOUSE_ID=? AND SPACE_ID IN "
+ "(SELECT CARGO_SPACE_ID FROM BAS_CARGO_SPACE WHERE WAREHOUSE_ID=? AND GROUP_CODING_NO=?)",
warehouseId, warehouseId, laneCode);
jdbc.update("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) "
+ "SELECT C.WAREHOUSE_ID,S.STOCK_ID,S.CARGO_SPACE_ID,S.TRAY_CODE,S.SPARE4,S.PRODUCT_ID,"
+ "S.LOT_NO,S.IN_DATE,ALG_TRY_TIMESTAMP(S.IN_DATE),S.QUANTITY,S.OUT_QUANTITY "
+ "FROM BAS_STOCK S JOIN BAS_CARGO_SPACE C ON C.CARGO_SPACE_ID=S.CARGO_SPACE_ID "
+ "WHERE C.WAREHOUSE_ID=? AND C.GROUP_CODING_NO=?",
warehouseId, laneCode);
jdbc.update("INSERT INTO ALG_SPACE_INVENTORY_SUMMARY "
+ "(WAREHOUSE_ID,SPACE_ID,STOCK_DETAIL_COUNT,STOCK_TRAY_COUNT,STOCK_PROJECT_COUNT,STOCK_PRODUCT_COUNT) "
+ "SELECT I.WAREHOUSE_ID,I.SPACE_ID,COUNT(*),COUNT(DISTINCT NULLIF(TRIM(I.TRAY_CODE),'')),"
+ "COUNT(DISTINCT NULLIF(TRIM(I.PROJECT_NO),'')),COUNT(DISTINCT NULLIF(TRIM(I.PRODUCT_NO),'')) "
+ "FROM ALG_INVENTORY_ITEM_SNAPSHOT I JOIN BAS_CARGO_SPACE C ON C.CARGO_SPACE_ID=I.SPACE_ID "
+ "AND C.WAREHOUSE_ID=I.WAREHOUSE_ID WHERE I.WAREHOUSE_ID=? AND C.GROUP_CODING_NO=? "
+ "GROUP BY I.WAREHOUSE_ID,I.SPACE_ID",
warehouseId, laneCode);
}
private void refreshH2LaneState(String warehouseId, String laneCode) {
List<Map<String, Object>> source = jdbc.queryForList("SELECT WAREHOUSE_ID,LANE_CODE,COUNT(*) SPACE_COUNT,"
+ "SUM(CASE WHEN SPACE_STATUS='2' OR STOCK_DETAIL_COUNT>0 THEN 1 ELSE 0 END) OCCUPIED_COUNT,"
+ "SUM(CASE WHEN INBOUND_AVAILABLE=TRUE THEN 1 ELSE 0 END) AVAILABLE_COUNT,"
+ "MAX(CASE WHEN PENDING_OUTBOUND=TRUE THEN 1 ELSE 0 END) PENDING_OUTBOUND,"
+ "MAX(CASE WHEN HAS_GAP=TRUE THEN 1 ELSE 0 END) HAS_GAP "
+ "FROM ALG_V_SPACE_STATE WHERE WAREHOUSE_ID=? AND LANE_CODE=? "
+ "GROUP BY WAREHOUSE_ID,LANE_CODE",
warehouseId, laneCode);
if (source.isEmpty()) {
jdbc.update("DELETE FROM ALG_LANE_STATE WHERE WAREHOUSE_ID=? AND LANE_CODE=?",
warehouseId, laneCode);
return;
}
Map<String, Object> lane = source.get(0);
int spaces = number(lane, "SPACE_COUNT");
int occupied = number(lane, "OCCUPIED_COUNT");
int available = number(lane, "AVAILABLE_COUNT");
boolean pending = number(lane, "PENDING_OUTBOUND") != 0;
boolean gap = number(lane, "HAS_GAP") != 0;
String fingerprint = h2SemanticFingerprint(warehouseId, laneCode,
InventoryFingerprintSource.SNAPSHOT);
List<Map<String, Object>> existing = jdbc.queryForList(
"SELECT SPACE_COUNT,OCCUPIED_COUNT,AVAILABLE_COUNT,PENDING_OUTBOUND,HAS_GAP,"
+ "SEMANTIC_FINGERPRINT FROM ALG_LANE_STATE WHERE WAREHOUSE_ID=? AND LANE_CODE=?",
warehouseId, laneCode);
if (existing.isEmpty()) {
jdbc.update("INSERT INTO ALG_LANE_STATE (WAREHOUSE_ID,LANE_CODE,SPACE_COUNT,OCCUPIED_COUNT,"
+ "AVAILABLE_COUNT,PENDING_OUTBOUND,HAS_GAP,SEMANTIC_FINGERPRINT,STATE_VERSION) "
+ "VALUES (?,?,?,?,?,?,?,?,1)",
warehouseId, laneCode, spaces, occupied, available, pending, gap, fingerprint);
return;
}
Map<String, Object> old = existing.get(0);
boolean changed = number(old, "SPACE_COUNT") != spaces || number(old, "OCCUPIED_COUNT") != occupied
|| number(old, "AVAILABLE_COUNT") != available
|| booleanValue(old.get("PENDING_OUTBOUND")) != pending
|| booleanValue(old.get("HAS_GAP")) != gap
|| !Objects.equals(old.get("SEMANTIC_FINGERPRINT"), fingerprint);
if (changed) {
jdbc.update("UPDATE ALG_LANE_STATE SET SPACE_COUNT=?,OCCUPIED_COUNT=?,AVAILABLE_COUNT=?,"
+ "PENDING_OUTBOUND=?,HAS_GAP=?,SEMANTIC_FINGERPRINT=?,STATE_VERSION=STATE_VERSION+1,"
+ "REBUILT_AT=CURRENT_TIMESTAMP WHERE WAREHOUSE_ID=? AND LANE_CODE=?",
spaces, occupied, available, pending, gap, fingerprint, warehouseId, laneCode);
}
}
private void synchronizeH2ReadModel(String warehouseId) {
jdbc.update("DELETE FROM ALG_SPACE_INVENTORY_SUMMARY WHERE WAREHOUSE_ID = ?", warehouseId);
jdbc.update("DELETE FROM ALG_INVENTORY_ITEM_SNAPSHOT WHERE WAREHOUSE_ID = ?", warehouseId);
jdbc.update("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) "
+ "SELECT C.WAREHOUSE_ID,S.STOCK_ID,S.CARGO_SPACE_ID,S.TRAY_CODE,S.SPARE4,S.PRODUCT_ID,"
+ "S.LOT_NO,S.IN_DATE,ALG_TRY_TIMESTAMP(S.IN_DATE),S.QUANTITY,S.OUT_QUANTITY "
+ "FROM BAS_STOCK S JOIN BAS_CARGO_SPACE C ON C.CARGO_SPACE_ID=S.CARGO_SPACE_ID "
+ "WHERE C.WAREHOUSE_ID = ?", warehouseId);
jdbc.update("INSERT INTO ALG_SPACE_INVENTORY_SUMMARY "
+ "(WAREHOUSE_ID,SPACE_ID,STOCK_DETAIL_COUNT,STOCK_TRAY_COUNT,STOCK_PROJECT_COUNT,STOCK_PRODUCT_COUNT) "
+ "SELECT WAREHOUSE_ID,SPACE_ID,COUNT(*),COUNT(DISTINCT NULLIF(TRIM(TRAY_CODE),'')),"
+ "COUNT(DISTINCT NULLIF(TRIM(PROJECT_NO),'')),COUNT(DISTINCT NULLIF(TRIM(PRODUCT_NO),'')) "
+ "FROM ALG_INVENTORY_ITEM_SNAPSHOT WHERE WAREHOUSE_ID = ? GROUP BY WAREHOUSE_ID,SPACE_ID",
warehouseId);
List<Map<String, Object>> source = jdbc.queryForList("SELECT WAREHOUSE_ID,LANE_CODE,COUNT(*) SPACE_COUNT,"
+ "SUM(CASE WHEN SPACE_STATUS='2' OR STOCK_DETAIL_COUNT>0 THEN 1 ELSE 0 END) OCCUPIED_COUNT,"
+ "SUM(CASE WHEN INBOUND_AVAILABLE=TRUE THEN 1 ELSE 0 END) AVAILABLE_COUNT,"
+ "MAX(CASE WHEN PENDING_OUTBOUND=TRUE THEN 1 ELSE 0 END) PENDING_OUTBOUND,"
+ "MAX(CASE WHEN HAS_GAP=TRUE THEN 1 ELSE 0 END) HAS_GAP "
+ "FROM ALG_V_SPACE_STATE WHERE WAREHOUSE_ID=? GROUP BY WAREHOUSE_ID,LANE_CODE ORDER BY LANE_CODE",
warehouseId);
Set<String> currentLanes = new LinkedHashSet<>();
for (Map<String, Object> lane : source) {
String laneCode = lane.get("LANE_CODE").toString();
currentLanes.add(laneCode);
String fingerprint = h2SemanticFingerprint(warehouseId, laneCode,
InventoryFingerprintSource.SNAPSHOT);
List<Map<String, Object>> existing = jdbc.queryForList("SELECT SPACE_COUNT,OCCUPIED_COUNT,AVAILABLE_COUNT,"
+ "PENDING_OUTBOUND,HAS_GAP,SEMANTIC_FINGERPRINT,STATE_VERSION FROM ALG_LANE_STATE "
+ "WHERE WAREHOUSE_ID=? AND LANE_CODE=?", warehouseId, laneCode);
int spaces = number(lane, "SPACE_COUNT");
int occupied = number(lane, "OCCUPIED_COUNT");
int available = number(lane, "AVAILABLE_COUNT");
boolean pending = number(lane, "PENDING_OUTBOUND") != 0;
boolean gap = number(lane, "HAS_GAP") != 0;
if (existing.isEmpty()) {
jdbc.update("INSERT INTO ALG_LANE_STATE (WAREHOUSE_ID,LANE_CODE,SPACE_COUNT,OCCUPIED_COUNT,"
+ "AVAILABLE_COUNT,PENDING_OUTBOUND,HAS_GAP,SEMANTIC_FINGERPRINT,STATE_VERSION) "
+ "VALUES (?,?,?,?,?,?,?,?,1)", warehouseId, laneCode, spaces, occupied, available,
pending, gap, fingerprint);
continue;
}
Map<String, Object> old = existing.get(0);
boolean changed = number(old, "SPACE_COUNT") != spaces || number(old, "OCCUPIED_COUNT") != occupied
|| number(old, "AVAILABLE_COUNT") != available
|| booleanValue(old.get("PENDING_OUTBOUND")) != pending
|| booleanValue(old.get("HAS_GAP")) != gap
|| !fingerprint.equals(Objects.toString(old.get("SEMANTIC_FINGERPRINT"), ""));
if (changed) {
jdbc.update("UPDATE ALG_LANE_STATE SET SPACE_COUNT=?,OCCUPIED_COUNT=?,AVAILABLE_COUNT=?,"
+ "PENDING_OUTBOUND=?,HAS_GAP=?,SEMANTIC_FINGERPRINT=?,STATE_VERSION=STATE_VERSION+1,"
+ "REBUILT_AT=CURRENT_TIMESTAMP WHERE WAREHOUSE_ID=? AND LANE_CODE=?",
spaces, occupied, available, pending, gap, fingerprint, warehouseId, laneCode);
}
}
jdbc.queryForList("SELECT LANE_CODE FROM ALG_LANE_STATE WHERE WAREHOUSE_ID=?", String.class, warehouseId)
.stream().filter(lane -> !currentLanes.contains(lane))
.forEach(lane -> jdbc.update("DELETE FROM ALG_LANE_STATE WHERE WAREHOUSE_ID=? AND LANE_CODE=?",
warehouseId, lane));
}
private String h2SemanticFingerprint(String warehouseId, String laneCode,
InventoryFingerprintSource inventorySource) {
StringBuilder payload = new StringBuilder();
appendRows(payload, "SELECT C.CARGO_SPACE_ID,C.STATUS,C.LOCKED,C.TRAY_CODE,C.GROUP_CODING,"
+ "C.THE_PLATOON,C.THE_COLUMN,C.THE_FLOOR,T.LANE_DEPTH,T.BASE_ZONE,COALESCE(R.ROLE,'STORAGE') "
+ "FROM BAS_CARGO_SPACE C LEFT JOIN ALG_SPACE_TOPOLOGY T ON T.SPACE_ID=C.CARGO_SPACE_ID "
+ "LEFT JOIN ALG_SPACE_ROLE R ON R.SPACE_ID=C.CARGO_SPACE_ID "
+ "WHERE C.WAREHOUSE_ID=? AND C.GROUP_CODING_NO=? "
+ "ORDER BY CASE WHEN C.GROUP_CODING REGEXP '^[0-9]+$' THEN CAST(C.GROUP_CODING AS INT) END,C.CARGO_SPACE_ID",
warehouseId, laneCode);
String inventorySql = inventorySource == InventoryFingerprintSource.SNAPSHOT
? "SELECT S.STOCK_ID,S.SPACE_ID,S.TRAY_CODE,S.PROJECT_NO,S.PRODUCT_NO,S.LOT_NO,"
+ "S.INBOUND_TIME_RAW,CAST(S.QUANTITY AS DECIMAL(38,6)),CAST(S.OUT_QUANTITY AS DECIMAL(38,6)) "
+ "FROM ALG_INVENTORY_ITEM_SNAPSHOT S JOIN BAS_CARGO_SPACE C "
+ "ON C.CARGO_SPACE_ID=S.SPACE_ID AND C.WAREHOUSE_ID=S.WAREHOUSE_ID "
+ "WHERE C.WAREHOUSE_ID=? AND C.GROUP_CODING_NO=? ORDER BY S.SPACE_ID,S.STOCK_ID"
: "SELECT S.STOCK_ID,S.CARGO_SPACE_ID,S.TRAY_CODE,S.SPARE4,S.PRODUCT_ID,S.LOT_NO,"
+ "S.IN_DATE,CAST(S.QUANTITY AS DECIMAL(38,6)),CAST(S.OUT_QUANTITY AS DECIMAL(38,6)) "
+ "FROM BAS_STOCK S JOIN BAS_CARGO_SPACE C ON C.CARGO_SPACE_ID=S.CARGO_SPACE_ID "
+ "WHERE C.WAREHOUSE_ID=? AND C.GROUP_CODING_NO=? ORDER BY S.CARGO_SPACE_ID,S.STOCK_ID";
appendRows(payload, inventorySql, warehouseId, laneCode);
appendRows(payload, "SELECT JOB_ID,TASK_NO,TRAY_CODE,SOURCE_SPACE_ID,TARGET_CARGO_SPACE_ID,STATUS,IN_OR_OUT,CREATE_TIME_RAW "
+ "FROM ALG_V_ACTIVE_JOB WHERE WAREHOUSE_ID=? AND SOURCE_LANE_CODE=? ORDER BY JOB_ID",
warehouseId, laneCode);
appendRows(payload, "SELECT PROJECT_NO,IS_ISOLATED,IS_COMMON,ALLOW_MIXED FROM ALG_PROJECT_POLICY ORDER BY PROJECT_NO");
appendRows(payload, "SELECT WAREHOUSE_ID,TARGET_FILL_RATE,FIFO_TOLERANCE_HOURS FROM ALG_WAREHOUSE_POLICY WHERE WAREHOUSE_ID=?",
warehouseId);
appendRows(payload, "SELECT RESERVATION_ID,SPACE_ID,PROJECT_NO,STATUS FROM ALG_RESERVATION "
+ "WHERE WAREHOUSE_ID=? AND LANE_CODE=? AND STATUS='ACTIVE' ORDER BY SPACE_ID,RESERVATION_ID",
warehouseId, laneCode);
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(payload.toString().getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
private void appendRows(StringBuilder payload, String sql, Object... arguments) {
payload.append('|');
jdbc.query(sql, rs -> {
int columns = rs.getMetaData().getColumnCount();
payload.append('[');
for (int index = 1; index <= columns; index++) {
String value = rs.getString(index);
payload.append(value == null ? -1 : value.length()).append(':');
if (value != null) payload.append(value);
payload.append(';');
}
payload.append(']');
}, arguments);
}
private int number(Map<String, Object> row, String column) {
Object value = row.get(column);
return value == null ? 0 : ((Number) value).intValue();
}
private boolean booleanValue(Object value) {
return value instanceof Boolean bool ? bool : value instanceof Number number && number.intValue() != 0;
}
@Override
public WarehouseSummary loadSummary(String warehouseId) {
String sql = "SELECT COUNT(DISTINCT lane_code) AS lane_count, 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 = " + dialect.trueLiteral() + " THEN 1 ELSE 0 END) AS available_count, "
+ "COUNT(DISTINCT CASE WHEN pending_outbound = " + dialect.trueLiteral() + " THEN lane_code END) AS pending_lane_count, "
+ "SUM(CASE WHEN tray_conflict = " + dialect.trueLiteral() + " OR project_conflict = " + dialect.trueLiteral()
+ " OR product_conflict = " + dialect.trueLiteral() + " THEN 1 ELSE 0 END) AS conflict_count, "
+ "SUM(CASE WHEN reserved = " + dialect.trueLiteral() + " THEN 1 ELSE 0 END) AS reserved_count "
+ "FROM " + dialect.spaceStateView() + " WHERE warehouse_id = ?";
return jdbc.queryForObject(sql, (rs, row) -> new WarehouseSummary(warehouseId,
rs.getInt("lane_count"), rs.getInt("space_count"), rs.getInt("occupied_count"),
rs.getInt("available_count"), rs.getInt("pending_lane_count"), rs.getInt("conflict_count"),
rs.getInt("reserved_count")), warehouseId);
}
@Override
public LanePage pageCandidateLanes(String warehouseId, String afterLaneCode, int pageSize) {
return pageLanes(warehouseId, afterLaneCode, pageSize,
" AND available_count > 0 AND pending_outbound = " + dialect.falseLiteral()
+ " AND has_gap = " + dialect.falseLiteral());
}
@Override
public LanePage pageWarehouseLanes(String warehouseId, String afterLaneCode, int pageSize) {
return pageLanes(warehouseId, afterLaneCode, pageSize, "");
}
private LanePage pageLanes(String warehouseId, String afterLaneCode, int requestedSize, String hardFacts) {
int size = Math.max(1, Math.min(MAX_PAGE_SIZE, requestedSize));
String projection = "warehouse_id, lane_code, space_count, occupied_count, available_count, "
+ "pending_outbound, has_gap, state_version";
String sql = "SELECT TOP (:limit) " + projection + " FROM " + dialect.laneState()
+ " WHERE warehouse_id = :warehouse AND lane_code > :after" + hardFacts
+ " ORDER BY lane_code";
List<LaneCandidate> lanes = named.query(sql, new MapSqlParameterSource()
.addValue("warehouse", warehouseId).addValue("after", blankToEmpty(afterLaneCode))
.addValue("limit", size), laneCandidateMapper());
String next = lanes.size() == size ? lanes.get(lanes.size() - 1).laneCode() : null;
return new LanePage(lanes, next);
}
@Override
public List<LaneSnapshot> loadLaneSnapshots(String warehouseId, Collection<String> laneCodes) {
return loadLaneSnapshots(warehouseId, laneCodes, Set.of());
}
@Override
public List<LaneSnapshot> loadShadowOutboundLaneSnapshots(String warehouseId, Collection<String> laneCodes,
Set<String> candidateSpaceIds) {
return loadLaneSnapshots(warehouseId, laneCodes, Set.copyOf(candidateSpaceIds));
}
private List<LaneSnapshot> loadLaneSnapshots(String warehouseId, Collection<String> laneCodes,
Set<String> mixedProjectSources) {
LinkedHashSet<String> codes = laneCodes == null ? new LinkedHashSet<>()
: laneCodes.stream().filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
if (codes.isEmpty()) return List.of();
MapSqlParameterSource parameters = new MapSqlParameterSource()
.addValue("warehouse", warehouseId).addValue("lanes", codes);
Map<String, VersionToken> versions = new LinkedHashMap<>();
named.query("SELECT lane_code, state_version FROM " + dialect.laneState()
+ " WHERE warehouse_id = :warehouse AND lane_code IN (:lanes)", parameters,
(rs, row) -> Map.entry(rs.getString("lane_code"),
VersionToken.fromJdbc(rs.getObject("state_version"))))
.forEach(entry -> versions.put(entry.getKey(), entry.getValue()));
List<RawSpace> rawSpaces = named.query("SELECT space_id, warehouse_id, lane_code, area_id, zone_code, roadway_id, "
+ "platoon, column_no, floor_no, depth, space_status, locked, space_tray_code, role, "
+ "tray_conflict, project_conflict, product_conflict, pending_outbound, reserved, has_gap, inbound_available "
+ "FROM " + dialect.spaceStateView()
+ " WHERE warehouse_id = :warehouse AND lane_code IN (:lanes) "
+ "ORDER BY lane_code, depth, space_id", parameters, rawSpaceMapper());
List<InventoryItem> inventory = named.query("SELECT i.stock_id, i.space_id, i.tray_code, i.project_no, "
+ "i.product_no, i.lot_no, i.inbound_time_raw, i.inbound_at, i.quantity, i.out_quantity "
+ "FROM " + dialect.inventoryView() + " i JOIN " + dialect.spaceStateView()
+ " s ON s.warehouse_id = i.warehouse_id AND s.snapshot_generation = i.snapshot_generation "
+ "AND s.space_id = i.space_id "
+ "WHERE s.warehouse_id = :warehouse AND s.lane_code IN (:lanes) "
+ "ORDER BY i.space_id, i.stock_id", parameters, inventoryMapper());
Map<String, List<InventoryItem>> inventoryBySpace = inventory.stream()
.collect(Collectors.groupingBy(InventoryItem::spaceId, LinkedHashMap::new, Collectors.toList()));
Map<String, List<RawSpace>> byLane = rawSpaces.stream()
.collect(Collectors.groupingBy(RawSpace::laneCode, LinkedHashMap::new, Collectors.toList()));
List<LaneSnapshot> result = new ArrayList<>();
byLane.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> {
int capacity = entry.getValue().size();
List<SpaceSnapshot> spaces = entry.getValue().stream()
.map(raw -> mapSpace(raw, capacity, versions.get(entry.getKey()),
inventoryBySpace.getOrDefault(raw.spaceId(), List.of()), mixedProjectSources.contains(raw.spaceId())))
.sorted(Comparator.comparingInt((SpaceSnapshot value) -> value.state().depthOrder())
.thenComparing(SpaceSnapshot::spaceId)).toList();
result.add(new LaneSnapshot(warehouseId, entry.getKey(), versions.get(entry.getKey()), spaces));
});
return List.copyOf(result);
}
@Override
public List<SpaceSnapshot> loadRelocationBuffers(String warehouseId, Set<Integer> floors) {
if (floors == null || floors.isEmpty()) return List.of();
List<String> laneCodes = named.queryForList("SELECT DISTINCT lane_code FROM " + dialect.spaceStateView()
+ " WHERE warehouse_id = :warehouse AND role = 'RELOCATION_BUFFER' AND floor_no IN (:floors) "
+ "ORDER BY lane_code", new MapSqlParameterSource().addValue("warehouse", warehouseId)
.addValue("floors", floors), String.class);
return loadLaneSnapshots(warehouseId, laneCodes).stream().flatMap(lane -> lane.spaces().stream())
.filter(space -> space.state().role() == SpaceRole.RELOCATION_BUFFER)
.filter(space -> floors.contains(space.state().floorNo())).toList();
}
@Override
public Set<Integer> loadActiveDeepWorkflowFloors(String warehouseId, String excludingRequestId) {
String sql = "SELECT floor_no FROM " + dialect.deepWorkflowLock()
+ " WHERE warehouse_id = ? AND status = 'ACTIVE' AND (? IS NULL OR request_id <> ?) ORDER BY floor_no";
String excluded = blankToNull(excludingRequestId);
return new LinkedHashSet<>(jdbc.queryForList(sql, Integer.class, warehouseId, excluded, excluded));
}
@Override
public boolean acquireDeepWorkflowLock(String requestId, String warehouseId, int floorNo) {
return acquireDeepWorkflowLockResult(requestId, warehouseId, floorNo).changed();
}
@Override
public DeepWorkflowMutationResult acquireDeepWorkflowLockResult(String requestId, String warehouseId, int floorNo) {
if (!writesEnabled) return DeepWorkflowMutationResult.writesDisabled();
validateWorkflowLock(requestId, warehouseId, floorNo);
boolean acquired = dialect.h2Database() ? acquireDeepWorkflowH2(requestId, warehouseId, floorNo)
: named.queryForObject("EXEC " + dialect.acquireDeepWorkflowProcedure()
+ " @request_id=:request, @warehouse_id=:warehouse, @floor_no=:floor",
new MapSqlParameterSource().addValue("request", requestId).addValue("warehouse", warehouseId)
.addValue("floor", floorNo), (rs, row) -> rs.getBoolean("acquired"));
return acquired ? DeepWorkflowMutationResult.succeeded() : DeepWorkflowMutationResult.unchanged();
}
@Override
public boolean releaseDeepWorkflowLock(String requestId, String warehouseId, int floorNo) {
return releaseDeepWorkflowLockResult(requestId, warehouseId, floorNo).changed();
}
@Override
public DeepWorkflowMutationResult releaseDeepWorkflowLockResult(String requestId, String warehouseId, int floorNo) {
if (!writesEnabled) return DeepWorkflowMutationResult.writesDisabled();
validateWorkflowLock(requestId, warehouseId, floorNo);
boolean released = dialect.h2Database() ? releaseDeepWorkflowH2(requestId, warehouseId, floorNo)
: named.queryForObject("EXEC " + dialect.releaseDeepWorkflowProcedure()
+ " @request_id=:request, @warehouse_id=:warehouse, @floor_no=:floor",
new MapSqlParameterSource().addValue("request", requestId).addValue("warehouse", warehouseId)
.addValue("floor", floorNo), (rs, row) -> rs.getBoolean("released"));
return released ? DeepWorkflowMutationResult.succeeded() : DeepWorkflowMutationResult.unchanged();
}
@Override
public List<PendingOutboundJob> loadPendingOutboundJobs(String warehouseId) {
String sql = "SELECT job_id, task_no, tray_code, project_no, product_no, source_space_id, warehouse_id, source_lane_code, "
+ "source_resolution, status, in_or_out, create_time_raw FROM " + dialect.activeJobView()
+ " WHERE warehouse_id = ? AND in_or_out >= 20 ORDER BY job_id";
return jdbc.query(sql, (rs, row) -> new PendingOutboundJob(rs.getString("job_id"), rs.getString("task_no"),
rs.getString("tray_code"), rs.getString("project_no"), rs.getString("product_no"),
rs.getString("source_space_id"), rs.getString("warehouse_id"), rs.getString("source_lane_code"),
rs.getString("source_resolution"), rs.getString("status"), rs.getInt("in_or_out"),
rs.getString("create_time_raw")), warehouseId);
}
@Override
public List<OutboundStockMatch> searchOutboundStock(String warehouseId, OutboundStockSearch search) {
String predicate = search.trayNo() != null ? "i.tray_code = :tray"
: "i.project_no = :project AND (:product IS NULL OR i.product_no = :product)";
MapSqlParameterSource parameters = new MapSqlParameterSource().addValue("warehouse", warehouseId)
.addValue("tray", search.trayNo()).addValue("project", search.projectNo())
.addValue("product", blankToNull(search.productNo()));
String sql = "SELECT i.stock_id, i.space_id, s.lane_code, s.floor_no, i.tray_code, i.project_no, "
+ "i.product_no, i.lot_no, i.inbound_time_raw, i.inbound_at FROM " + dialect.inventoryView()
+ " i JOIN " + dialect.spaceStateView()
+ " s ON s.warehouse_id = i.warehouse_id AND s.snapshot_generation = i.snapshot_generation "
+ "AND s.space_id = i.space_id "
+ "WHERE s.warehouse_id = :warehouse AND " + predicate
+ " ORDER BY i.inbound_at, i.inbound_time_raw, i.stock_id";
List<OutboundStockMatch> matches = named.query(sql, parameters, (rs, row) -> new OutboundStockMatch(
rs.getString("stock_id"), rs.getString("space_id"), rs.getString("lane_code"),
rs.getInt("floor_no"), rs.getString("tray_code"), rs.getString("project_no"),
rs.getString("product_no"), rs.getString("lot_no"), rs.getString("inbound_time_raw"),
instant(rs, "inbound_at")));
if (search.trayNo() == null) return matches;
List<OutboundStockMatch> cargoMatches = named.query("SELECT space_id, lane_code, floor_no, space_tray_code FROM "
+ dialect.spaceStateView() + " WHERE warehouse_id = :warehouse "
+ "AND LTRIM(RTRIM(space_tray_code)) = :tray ORDER BY space_id",
parameters, (rs, row) -> new OutboundStockMatch("CARGO_SPACE:" + rs.getString("space_id"),
rs.getString("space_id"), rs.getString("lane_code"), rs.getInt("floor_no"),
rs.getString("space_tray_code"), null, null, null, null, null));
Set<String> matchedSpaces = matches.stream().map(OutboundStockMatch::spaceId)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<OutboundStockMatch> combined = new ArrayList<>(matches);
cargoMatches.stream().filter(match -> matchedSpaces.add(match.spaceId())).forEach(combined::add);
return List.copyOf(combined);
}
@Override
public int countProjectPallets(String warehouseId, String projectNo) {
String usableTray = "CASE WHEN s.stock_tray_count = 1 THEN NULLIF(LTRIM(RTRIM(i.tray_code)), '') "
+ "WHEN s.stock_tray_count = 0 THEN NULLIF(LTRIM(RTRIM(s.space_tray_code)), '') END";
Integer count = named.queryForObject("SELECT COUNT(DISTINCT " + usableTray + ") FROM "
+ dialect.spaceStateView() + " s JOIN " + dialect.inventoryView()
+ " i ON i.warehouse_id = s.warehouse_id "
+ "AND i.snapshot_generation = s.snapshot_generation AND i.space_id = s.space_id "
+ "WHERE s.warehouse_id = :warehouse "
+ "AND NULLIF(LTRIM(RTRIM(i.project_no)), '') = :project AND s.space_status = '2' "
+ "AND s.role = 'STORAGE' AND s.depth IS NOT NULL AND s.pending_outbound = "
+ dialect.falseLiteral() + " AND s.reserved = " + dialect.falseLiteral()
+ " AND s.has_gap = " + dialect.falseLiteral() + " AND s.tray_conflict = "
+ dialect.falseLiteral() + " AND s.project_conflict = " + dialect.falseLiteral()
+ " AND (NULLIF(LTRIM(RTRIM(s.locked)), '') IS NULL OR LTRIM(RTRIM(s.locked)) IN ('0', 'N')) "
+ "AND (NULLIF(LTRIM(RTRIM(s.space_tray_code)), '') IS NULL "
+ "OR NULLIF(LTRIM(RTRIM(i.tray_code)), '') IS NULL "
+ "OR LTRIM(RTRIM(s.space_tray_code)) = LTRIM(RTRIM(i.tray_code)))",
new MapSqlParameterSource().addValue("warehouse", warehouseId).addValue("project", projectNo),
Integer.class);
return count == null ? 0 : count;
}
@Override
public RepositoryPolicy loadPolicy(String warehouseId) {
List<Map<String, Object>> warehouses = jdbc.queryForList("SELECT target_fill_rate, fifo_tolerance_hours FROM "
+ dialect.warehousePolicy() + " WHERE warehouse_id = ?", warehouseId);
double targetFill = warehouses.isEmpty() || warehouses.get(0).get("TARGET_FILL_RATE") == null ? 0.0
: ((Number) warehouses.get(0).get("TARGET_FILL_RATE")).doubleValue();
int toleranceMinutes = warehouses.isEmpty() || warehouses.get(0).get("FIFO_TOLERANCE_HOURS") == null ? 0
: ((Number) warehouses.get(0).get("FIFO_TOLERANCE_HOURS")).intValue() * 60;
Set<String> isolated = new LinkedHashSet<>();
Set<String> common = new LinkedHashSet<>();
Set<String> mixedDenied = new LinkedHashSet<>();
jdbc.query("SELECT project_no, is_isolated, is_common, allow_mixed FROM " + dialect.projectPolicy(), rs -> {
if (rs.getBoolean("is_isolated")) isolated.add(rs.getString("project_no"));
if (rs.getBoolean("is_common")) common.add(rs.getString("project_no"));
if (!rs.getBoolean("allow_mixed")) mixedDenied.add(rs.getString("project_no"));
});
if (common.isEmpty()) common.add("COMMON");
SchedulingPolicy scheduling = SchedulingPolicy.ft546Defaults();
return new RepositoryPolicy(new InboundPolicy(scheduling, isolated, common, List.of(), 0, mixedDenied),
scheduling, toleranceMinutes, targetFill);
}
@Override
public DecisionLogEntry insertDecision(DecisionLogRecord record) {
String id = blankToNull(record.decisionId()) == null ? UUID.randomUUID().toString() : record.decisionId();
if (!writesEnabled) {
return new DecisionLogEntry(id, record.warehouseId(), record.decisionType(), record.inputVersion(),
record.resultCode(), abbreviate(record.reason(), 2000), Instant.now());
}
named.update("INSERT INTO " + dialect.decisionLog()
+ " (decision_id, warehouse_id, decision_type, input_version, result_code, reason) "
+ "VALUES (:id, :warehouse, :type, :version, :result, :reason)",
new MapSqlParameterSource().addValue("id", sqlId(id)).addValue("warehouse", record.warehouseId())
.addValue("type", record.decisionType()).addValue("version", versionValue(record.inputVersion()),
decisionVersionSqlType(dialect))
.addValue("result", record.resultCode()).addValue("reason", abbreviate(record.reason(), 2000)));
return findDecision(id).orElseThrow();
}
@Override
public Optional<DecisionLogEntry> findDecision(String decisionId) {
List<DecisionLogEntry> values = jdbc.query("SELECT decision_id, warehouse_id, decision_type, input_version, "
+ "result_code, reason, created_at FROM " + dialect.decisionLog() + " WHERE decision_id = ?",
(rs, row) -> new DecisionLogEntry(rs.getString("decision_id"), rs.getString("warehouse_id"),
rs.getString("decision_type"), VersionToken.fromJdbc(rs.getObject("input_version")),
rs.getString("result_code"), rs.getString("reason"), instant(rs, "created_at")), sqlId(decisionId));
return values.stream().findFirst();
}
@Override
public ReservationResult tryReserveInbound(InboundReservation reservation) {
if (!writesEnabled) return ReservationResult.conflict("WRITES_DISABLED", reservation.reservationId());
sqlId(reservation.reservationId());
if (reservation.expectedVersion() == null) {
return ReservationResult.conflict("LANE_VERSION_MISMATCH", reservation.reservationId());
}
return dialect.h2Database() ? reserveH2(reservation) : reserveSqlServer(reservation);
}
@Override
public ReservationResult releaseReservation(String warehouseId, String reservationId) {
if (!writesEnabled) return ReservationResult.conflict("WRITES_DISABLED", reservationId);
return dialect.h2Database() ? releaseH2(warehouseId, reservationId) : releaseSqlServer(warehouseId, reservationId);
}
private ReservationResult reserveH2(InboundReservation reservation) {
return transactions.execute(status -> {
List<LaneReservationState> values = jdbc.query("SELECT state_version, semantic_fingerprint FROM " + dialect.laneState()
+ " WHERE warehouse_id = ? AND lane_code = ? FOR UPDATE",
(rs, row) -> new LaneReservationState(VersionToken.fromJdbc(rs.getObject("state_version")),
rs.getString("semantic_fingerprint")),
reservation.warehouseId(), reservation.laneCode());
if (values.isEmpty() || !reservation.expectedVersion().equals(values.get(0).version())) {
return ReservationResult.conflict("LANE_VERSION_MISMATCH", reservation.reservationId());
}
String storedFingerprint = values.get(0).semanticFingerprint();
if (storedFingerprint != null && !storedFingerprint.isBlank()
&& !storedFingerprint.equals(h2SemanticFingerprint(
reservation.warehouseId(), reservation.laneCode(), InventoryFingerprintSource.LIVE))) {
return ReservationResult.conflict("LANE_VERSION_MISMATCH", reservation.reservationId());
}
Integer pending = jdbc.queryForObject("SELECT COUNT(*) FROM " + dialect.activeJobView()
+ " WHERE warehouse_id = ? AND in_or_out >= 20 AND source_lane_code = ?",
Integer.class, reservation.warehouseId(), reservation.laneCode());
if (pending != null && pending > 0) {
return ReservationResult.conflict("PENDING_OUTBOUND", reservation.reservationId());
}
Integer active = jdbc.queryForObject("SELECT COUNT(*) FROM " + dialect.reservation()
+ " WHERE space_id = ? AND status = 'ACTIVE'", Integer.class, reservation.spaceId());
if (active != null && active > 0) {
return ReservationResult.conflict("ACTIVE_RESERVATION_EXISTS", reservation.reservationId());
}
Integer eligible = jdbc.queryForObject("SELECT COUNT(*) FROM " + dialect.spaceStateView()
+ " WHERE warehouse_id = ? AND lane_code = ? AND space_id = ? AND inbound_available = TRUE",
Integer.class, reservation.warehouseId(), reservation.laneCode(), reservation.spaceId());
if (eligible == null || eligible == 0) {
return ReservationResult.conflict("SPACE_NOT_ELIGIBLE", reservation.reservationId());
}
try {
jdbc.update("INSERT INTO " + dialect.reservation()
+ " (reservation_id, warehouse_id, lane_code, space_id, project_no, status) "
+ "VALUES (?, ?, ?, ?, ?, 'ACTIVE')", sqlId(reservation.reservationId()),
reservation.warehouseId(), reservation.laneCode(), reservation.spaceId(), reservation.projectNo());
} catch (DuplicateKeyException exception) {
return ReservationResult.conflict("ACTIVE_RESERVATION_EXISTS", reservation.reservationId());
}
rebuildH2Lane(reservation.warehouseId(), reservation.laneCode());
return ReservationResult.reserved(reservation.reservationId());
});
}
private boolean acquireDeepWorkflowH2(String requestId, String warehouseId, int floorNo) {
return Boolean.TRUE.equals(transactions.execute(status -> {
List<String> owners = jdbc.query("SELECT request_id FROM " + dialect.deepWorkflowLock()
+ " WHERE warehouse_id = ? AND floor_no = ? AND status = 'ACTIVE' FOR UPDATE",
(rs, row) -> rs.getString("request_id"), warehouseId, floorNo);
if (!owners.isEmpty()) return requestId.equals(owners.get(0));
try {
jdbc.update("INSERT INTO " + dialect.deepWorkflowLock()
+ " (lock_id, request_id, warehouse_id, floor_no, status) VALUES (?, ?, ?, ?, 'ACTIVE')",
UUID.randomUUID().toString(), requestId, warehouseId, floorNo);
return true;
} catch (DuplicateKeyException exception) {
return false;
}
}));
}
private boolean releaseDeepWorkflowH2(String requestId, String warehouseId, int floorNo) {
return Boolean.TRUE.equals(transactions.execute(status -> jdbc.update("UPDATE " + dialect.deepWorkflowLock()
+ " SET status = 'RELEASED', released_at = CURRENT_TIMESTAMP WHERE request_id = ? "
+ "AND warehouse_id = ? AND floor_no = ? AND status = 'ACTIVE'",
requestId, warehouseId, floorNo) == 1));
}
private ReservationResult releaseH2(String warehouseId, String reservationId) {
return transactions.execute(status -> {
Object reservationKey = sqlId(reservationId);
List<Map<String, Object>> rows = jdbc.queryForList("SELECT warehouse_id, lane_code FROM "
+ dialect.reservation() + " WHERE reservation_id = ? AND warehouse_id = ? AND status = 'ACTIVE' FOR UPDATE",
reservationKey, warehouseId);
if (rows.isEmpty()) return ReservationResult.notFound(reservationId);
jdbc.update("UPDATE " + dialect.reservation()
+ " SET status = 'RELEASED', released_at = CURRENT_TIMESTAMP WHERE reservation_id = ? AND warehouse_id = ?",
reservationKey, warehouseId);
String warehouse = Objects.toString(rows.get(0).get("WAREHOUSE_ID"), null);
String lane = Objects.toString(rows.get(0).get("LANE_CODE"), null);
rebuildH2Lane(warehouse, lane);
return ReservationResult.released(reservationId);
});
}
private void rebuildH2Lane(String warehouseId, String laneCode) {
String fingerprint = h2SemanticFingerprint(warehouseId, laneCode,
InventoryFingerprintSource.SNAPSHOT);
jdbc.update("UPDATE " + dialect.laneState() + " SET "
+ "space_count = (SELECT COUNT(*) FROM " + dialect.spaceStateView() + " WHERE warehouse_id = ? AND lane_code = ?), "
+ "occupied_count = (SELECT COUNT(*) FROM " + dialect.spaceStateView() + " WHERE warehouse_id = ? AND lane_code = ? AND (space_status = '2' OR stock_detail_count > 0)), "
+ "available_count = (SELECT COUNT(*) FROM " + dialect.spaceStateView() + " WHERE warehouse_id = ? AND lane_code = ? AND inbound_available = TRUE), "
+ "pending_outbound = CASE WHEN EXISTS (SELECT 1 FROM " + dialect.spaceStateView() + " WHERE warehouse_id = ? AND lane_code = ? AND pending_outbound = TRUE) THEN TRUE ELSE FALSE END, "
+ "has_gap = CASE WHEN EXISTS (SELECT 1 FROM " + dialect.spaceStateView() + " WHERE warehouse_id = ? AND lane_code = ? AND has_gap = TRUE) THEN TRUE ELSE FALSE END, "
+ "semantic_fingerprint = ?, state_version = state_version + 1, rebuilt_at = CURRENT_TIMESTAMP "
+ "WHERE warehouse_id = ? AND lane_code = ?",
warehouseId, laneCode, warehouseId, laneCode, warehouseId, laneCode,
warehouseId, laneCode, warehouseId, laneCode, fingerprint, warehouseId, laneCode);
}
private ReservationResult reserveSqlServer(InboundReservation reservation) {
String sql = "EXEC " + dialect.tryReserveProcedure()
+ " @warehouse_id=:warehouse, @lane_code=:lane, @space_id=:space, @project_no=:project, "
+ "@expected_state_version=:version, @reservation_id=:reservation";
return named.queryForObject(sql, new MapSqlParameterSource().addValue("warehouse", reservation.warehouseId())
.addValue("lane", reservation.laneCode()).addValue("space", reservation.spaceId())
.addValue("project", reservation.projectNo()).addValue("version", versionValue(reservation.expectedVersion()))
.addValue("reservation", sqlId(reservation.reservationId())), (rs, row) -> reservationResult(rs));
}
private ReservationResult releaseSqlServer(String warehouseId, String reservationId) {
return named.queryForObject("EXEC " + dialect.releaseProcedure()
+ " @warehouse_id=:warehouse, @reservation_id=:reservation",
new MapSqlParameterSource().addValue("warehouse", warehouseId)
.addValue("reservation", sqlId(reservationId)), (rs, row) -> reservationResult(rs));
}
private ReservationResult reservationResult(ResultSet rs) throws SQLException {
String result = rs.getString("result");
String id = rs.getString("reservation_id");
return switch (result) {
case "RESERVED" -> ReservationResult.reserved(id);
case "RELEASED" -> ReservationResult.released(id);
case "NOT_FOUND" -> ReservationResult.notFound(id);
default -> ReservationResult.conflict(rs.getString("reason"), id);
};
}
private SpaceSnapshot mapSpace(RawSpace raw, int capacity, VersionToken version, List<InventoryItem> inventory) {
return mapSpace(raw, capacity, version, inventory, false);
}
private SpaceSnapshot mapSpace(RawSpace raw, int capacity, VersionToken version,
List<InventoryItem> inventory, boolean allowMixedProjectSource) {
EnumSet<SpaceConflict> conflicts = EnumSet.noneOf(SpaceConflict.class);
if (raw.trayConflict()) conflicts.add(SpaceConflict.TRAY);
if (raw.projectConflict()) conflicts.add(SpaceConflict.PROJECT);
String inventoryTray = unique(inventory, InventoryItem::trayNo);
String tray = blankToNull(raw.spaceTrayNo()) != null ? raw.spaceTrayNo() : inventoryTray;
if (blankToNull(raw.spaceTrayNo()) != null && inventoryTray != null && !raw.spaceTrayNo().equals(inventoryTray)) {
conflicts.add(SpaceConflict.SPACE_TRAY_MISMATCH);
}
String project = unique(inventory, InventoryItem::projectNo);
String product = unique(inventory, InventoryItem::productNo);
String lot = unique(inventory, InventoryItem::lotNo);
Instant inboundAt = inventory.stream().map(InventoryItem::inboundAt).filter(Objects::nonNull)
.min(Comparator.naturalOrder()).orElse(null);
boolean invalid = locked(raw.locked()) || raw.reserved() || raw.hasGap() || raw.depth() == null
|| conflicts.stream().anyMatch(conflict -> conflict != SpaceConflict.PROJECT || !allowMixedProjectSource)
|| ("1".equals(raw.spaceStatus()) && (!inventory.isEmpty() || tray != null));
SpaceStatus status = invalid ? SpaceStatus.DISABLED : switch (Objects.toString(raw.spaceStatus(), "")) {
case "1" -> SpaceStatus.EMPTY;
case "2" -> SpaceStatus.OCCUPIED;
default -> SpaceStatus.DISABLED;
};
SpaceState state = new SpaceState(raw.spaceId(), raw.zoneCode(), raw.laneCode(),
raw.depth() == null ? 0 : raw.depth(), capacity, raw.floorNo(), raw.columnNo(),
raw.depth() == null ? 0 : raw.depth(), raw.platoon() == null ? null : raw.platoon().toString(),
mapRole(raw.role()), status,
raw.pendingOutbound(), !conflicts.isEmpty(), tray, project, product, lot, inboundAt, null,
raw.reserved(), version == null ? null : version.externalForm(), !inventory.isEmpty());
String diagnostic = invalid ? diagnostic(raw, conflicts) : null;
return new SpaceSnapshot(state, inventory, conflicts, raw.inboundAvailable(), raw.hasGap(), diagnostic);
}
private String diagnostic(RawSpace raw, Set<SpaceConflict> conflicts) {
if (!conflicts.isEmpty()) return "INVENTORY_CONFLICT:" + conflicts;
if (raw.reserved()) return "RESERVED";
if (raw.hasGap()) return "LANE_GAP";
if (raw.depth() == null) return "INVALID_TOPOLOGY";
if (locked(raw.locked())) return "LOCKED";
return "NON_AVAILABLE_STATUS:" + raw.spaceStatus();
}
private SpaceRole mapRole(String role) {
return switch (role) {
case "STORAGE" -> SpaceRole.STORAGE;
case "RELOCATION_BUFFER" -> SpaceRole.RELOCATION_BUFFER;
case "OUTBOUND_BUFFER" -> SpaceRole.OUTBOUND_CACHE;
case "PROTECTED_CORE" -> SpaceRole.PROTECTED_CORE;
default -> throw new IllegalStateException("Unknown space role: " + role);
};
}
private RowMapper<LaneCandidate> laneCandidateMapper() {
return (rs, row) -> new LaneCandidate(rs.getString("warehouse_id"), rs.getString("lane_code"),
rs.getInt("space_count"), rs.getInt("occupied_count"), rs.getInt("available_count"),
rs.getBoolean("pending_outbound"), rs.getBoolean("has_gap"),
VersionToken.fromJdbc(rs.getObject("state_version")));
}
private RowMapper<RawSpace> rawSpaceMapper() {
return (rs, row) -> new RawSpace(rs.getString("space_id"), rs.getString("lane_code"),
rs.getString("area_id"), rs.getString("zone_code"), rs.getString("roadway_id"),
nullableInt(rs, "platoon"), rs.getInt("column_no"), rs.getInt("floor_no"),
nullableInt(rs, "depth"), rs.getString("space_status"),
rs.getString("locked"), rs.getString("space_tray_code"), rs.getString("role"),
rs.getBoolean("tray_conflict"), rs.getBoolean("project_conflict"),
rs.getBoolean("product_conflict"), rs.getBoolean("pending_outbound"),
rs.getBoolean("reserved"), rs.getBoolean("has_gap"), rs.getBoolean("inbound_available"));
}
private RowMapper<InventoryItem> inventoryMapper() {
return (rs, row) -> new InventoryItem(rs.getString("stock_id"), rs.getString("space_id"),
rs.getString("tray_code"), rs.getString("project_no"), rs.getString("product_no"),
rs.getString("lot_no"), rs.getString("inbound_time_raw"), instant(rs, "inbound_at"),
rs.getBigDecimal("quantity"), rs.getBigDecimal("out_quantity"));
}
private Object sqlId(String id) {
UUID value = UUID.fromString(id);
return dialect.h2Database() ? value.toString() : value;
}
private void validateWorkflowLock(String requestId, String warehouseId, int floorNo) {
if (blankToNull(requestId) == null) throw new IllegalArgumentException("requestId is required");
if (blankToNull(warehouseId) == null) throw new IllegalArgumentException("warehouseId is required");
if (floorNo <= 0) throw new IllegalArgumentException("floorNo must be positive");
}
private Object versionValue(VersionToken token) { return token == null ? null : token.jdbcValue(); }
static int decisionVersionSqlType(RepositoryDialect dialect) {
return dialect.h2Database() ? Types.BIGINT : Types.BINARY;
}
private String abbreviate(String value, int maximum) {
return value == null || value.length() <= maximum ? value : value.substring(0, maximum);
}
private boolean locked(String value) {
String normalized = blankToNull(value);
return normalized != null && !"0".equalsIgnoreCase(normalized) && !"N".equalsIgnoreCase(normalized);
}
private String blankToEmpty(String value) { return value == null ? "" : value; }
private String blankToNull(String value) { return value == null || value.isBlank() ? null : value; }
private Integer nullableInt(ResultSet rs, String column) throws SQLException {
int value = rs.getInt(column);
return rs.wasNull() ? null : value;
}
private Instant instant(ResultSet rs, String column) throws SQLException {
Timestamp timestamp = rs.getTimestamp(column);
return timestamp == null ? null : timestamp.toInstant();
}
private <T> String unique(List<T> values, Function<T, String> extractor) {
Set<String> distinct = values.stream().map(extractor).map(this::blankToNull)
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
return distinct.size() == 1 ? distinct.iterator().next() : null;
}
private <T> long distinctCount(List<T> values, Function<T, String> extractor) {
return values.stream().map(extractor).map(this::blankToNull).filter(Objects::nonNull).distinct().count();
}
private record RawSpace(String spaceId, String laneCode, String areaId, String zoneCode, String roadwayId,
Integer platoon, int columnNo, int floorNo, Integer depth, String spaceStatus,
String locked, String spaceTrayNo,
String role, boolean trayConflict, boolean projectConflict, boolean productConflict,
boolean pendingOutbound, boolean reserved, boolean hasGap, boolean inboundAvailable) {
}
private record LaneReservationState(VersionToken version, String semanticFingerprint) {
}
private enum InventoryFingerprintSource {
SNAPSHOT,
LIVE
}
}

View File

@@ -0,0 +1,5 @@
package com.ruoyi.algorithm.repository;
public record LaneCandidate(String warehouseId, String laneCode, int spaceCount, int occupiedCount,
int availableCount, boolean pendingOutbound, boolean hasGap, VersionToken version) {
}

View File

@@ -0,0 +1,7 @@
package com.ruoyi.algorithm.repository;
import java.util.List;
public record LanePage(List<LaneCandidate> lanes, String nextLaneCode) {
public LanePage { lanes = List.copyOf(lanes); }
}

View File

@@ -0,0 +1,10 @@
package com.ruoyi.algorithm.repository;
import com.ruoyi.algorithm.model.SpaceState;
import java.util.List;
public record LaneSnapshot(String warehouseId, String laneCode, VersionToken version, List<SpaceSnapshot> spaces) {
public LaneSnapshot { spaces = List.copyOf(spaces); }
public List<SpaceState> spaceStates() { return spaces.stream().map(SpaceSnapshot::state).toList(); }
}

View File

@@ -0,0 +1,8 @@
package com.ruoyi.algorithm.repository;
import java.time.Instant;
public record OutboundStockMatch(String stockId, String spaceId, String laneCode, int floorNo, String trayNo,
String projectNo, String productNo, String lotNo,
String inboundTimeRaw, Instant inboundAt) {
}

View File

@@ -0,0 +1,13 @@
package com.ruoyi.algorithm.repository;
public record OutboundStockSearch(String trayNo, String projectNo, String productNo) {
public OutboundStockSearch {
boolean hasTray = trayNo != null && !trayNo.isBlank();
boolean hasProject = projectNo != null && !projectNo.isBlank();
if (hasTray == hasProject) throw new IllegalArgumentException("Specify exact tray or project, but not both");
}
public static OutboundStockSearch exactTray(String trayNo) { return new OutboundStockSearch(trayNo, null, null); }
public static OutboundStockSearch project(String projectNo, String productNo) {
return new OutboundStockSearch(null, projectNo, productNo);
}
}

View File

@@ -0,0 +1,7 @@
package com.ruoyi.algorithm.repository;
public record PendingOutboundJob(String jobId, String taskNo, String trayNo, String projectNo,
String productNo, String sourceSpaceId, String warehouseId,
String sourceLaneCode, String sourceResolution, String status,
int direction, String createTimeRaw) {
}

View File

@@ -0,0 +1,74 @@
package com.ruoyi.algorithm.repository;
public final class RepositoryDialect {
private final boolean h2Database;
private final String laneState;
private final String spaceStateView;
private final String inventoryView;
private final String activeJobView;
private final String warehousePolicy;
private final String projectPolicy;
private final String reservation;
private final String decisionLog;
private final String deepWorkflowLock;
private final String tryReserveProcedure;
private final String releaseProcedure;
private final String acquireDeepWorkflowProcedure;
private final String releaseDeepWorkflowProcedure;
private final String trueLiteral;
private final String falseLiteral;
private RepositoryDialect(boolean h2Database, String laneState, String spaceStateView, String inventoryView,
String activeJobView, String warehousePolicy, String projectPolicy,
String reservation, String decisionLog, String deepWorkflowLock,
String tryReserveProcedure, String releaseProcedure,
String acquireDeepWorkflowProcedure, String releaseDeepWorkflowProcedure,
String trueLiteral, String falseLiteral) {
this.h2Database = h2Database;
this.laneState = laneState;
this.spaceStateView = spaceStateView;
this.inventoryView = inventoryView;
this.activeJobView = activeJobView;
this.warehousePolicy = warehousePolicy;
this.projectPolicy = projectPolicy;
this.reservation = reservation;
this.decisionLog = decisionLog;
this.deepWorkflowLock = deepWorkflowLock;
this.tryReserveProcedure = tryReserveProcedure;
this.releaseProcedure = releaseProcedure;
this.acquireDeepWorkflowProcedure = acquireDeepWorkflowProcedure;
this.releaseDeepWorkflowProcedure = releaseDeepWorkflowProcedure;
this.trueLiteral = trueLiteral;
this.falseLiteral = falseLiteral;
}
public static RepositoryDialect h2() {
return new RepositoryDialect(true, "ALG_LANE_STATE", "ALG_V_SPACE_STATE", "ALG_V_INVENTORY_ITEM",
"ALG_V_ACTIVE_JOB", "ALG_WAREHOUSE_POLICY", "ALG_PROJECT_POLICY", "ALG_RESERVATION",
"ALG_DECISION_LOG", "ALG_DEEP_WORKFLOW_LOCK", null, null, null, null, "TRUE", "FALSE");
}
public static RepositoryDialect sqlServer() {
return new RepositoryDialect(false, "alg.lane_state", "alg.v_space_state", "alg.v_inventory_item",
"alg.v_active_job", "alg.warehouse_policy", "alg.project_policy", "alg.reservation",
"alg.decision_log", "alg.deep_workflow_lock", "alg.usp_try_reserve_inbound",
"alg.usp_release_reservation", "alg.usp_acquire_deep_workflow_lock",
"alg.usp_release_deep_workflow_lock", "1", "0");
}
public boolean h2Database() { return h2Database; }
public String laneState() { return laneState; }
public String spaceStateView() { return spaceStateView; }
public String inventoryView() { return inventoryView; }
public String activeJobView() { return activeJobView; }
public String warehousePolicy() { return warehousePolicy; }
public String projectPolicy() { return projectPolicy; }
public String reservation() { return reservation; }
public String decisionLog() { return decisionLog; }
public String deepWorkflowLock() { return deepWorkflowLock; }
public String tryReserveProcedure() { return tryReserveProcedure; }
public String releaseProcedure() { return releaseProcedure; }
public String acquireDeepWorkflowProcedure() { return acquireDeepWorkflowProcedure; }
public String releaseDeepWorkflowProcedure() { return releaseDeepWorkflowProcedure; }
public String trueLiteral() { return trueLiteral; }
public String falseLiteral() { return falseLiteral; }
}

View File

@@ -0,0 +1,11 @@
package com.ruoyi.algorithm.repository;
import com.ruoyi.algorithm.inbound.InboundPolicy;
import com.ruoyi.algorithm.policy.SchedulingPolicy;
public record RepositoryPolicy(InboundPolicy inboundPolicy, SchedulingPolicy schedulingPolicy,
int fifoToleranceMinutes, double targetFillRate) {
public static RepositoryPolicy defaults() {
return new RepositoryPolicy(InboundPolicy.defaults(), SchedulingPolicy.ft546Defaults(), 0, 0.0);
}
}

View File

@@ -0,0 +1,8 @@
package com.ruoyi.algorithm.repository;
public record ReservationResult(ReservationStatus status, String reasonCode, String reservationId) {
public static ReservationResult reserved(String id) { return new ReservationResult(ReservationStatus.RESERVED, null, id); }
public static ReservationResult conflict(String reason, String id) { return new ReservationResult(ReservationStatus.CONFLICT, reason, id); }
public static ReservationResult released(String id) { return new ReservationResult(ReservationStatus.RELEASED, null, id); }
public static ReservationResult notFound(String id) { return new ReservationResult(ReservationStatus.NOT_FOUND, "NOT_FOUND", id); }
}

View File

@@ -0,0 +1,3 @@
package com.ruoyi.algorithm.repository;
public enum ReservationStatus { RESERVED, CONFLICT, RELEASED, NOT_FOUND }

View File

@@ -0,0 +1,3 @@
package com.ruoyi.algorithm.repository;
public enum SpaceConflict { TRAY, PROJECT, PRODUCT, LOT, SPACE_TRAY_MISMATCH }

View File

@@ -0,0 +1,15 @@
package com.ruoyi.algorithm.repository;
import com.ruoyi.algorithm.model.SpaceState;
import java.util.List;
import java.util.Set;
public record SpaceSnapshot(SpaceState state, List<InventoryItem> inventory, Set<SpaceConflict> conflicts,
boolean inboundAvailable, boolean hasGap, String diagnostic) {
public SpaceSnapshot {
inventory = List.copyOf(inventory);
conflicts = Set.copyOf(conflicts);
}
public String spaceId() { return state.spaceId(); }
}

View File

@@ -0,0 +1,50 @@
package com.ruoyi.algorithm.repository;
import java.util.Arrays;
import java.util.Base64;
import java.util.Objects;
public final class VersionToken {
public enum Kind { NUMERIC, BINARY }
private final Kind kind;
private final Long numeric;
private final byte[] binary;
private VersionToken(Kind kind, Long numeric, byte[] binary) {
this.kind = kind;
this.numeric = numeric;
this.binary = binary == null ? null : binary.clone();
}
public static VersionToken numeric(long value) {
return new VersionToken(Kind.NUMERIC, value, null);
}
public static VersionToken binary(byte[] value) {
Objects.requireNonNull(value, "value");
return new VersionToken(Kind.BINARY, null, value);
}
public static VersionToken fromJdbc(Object value) {
if (value == null) return null;
if (value instanceof byte[] bytes) return binary(bytes);
if (value instanceof Number number) return numeric(number.longValue());
throw new IllegalArgumentException("Unsupported version token type: " + value.getClass().getName());
}
public Kind kind() { return kind; }
public Object jdbcValue() { return kind == Kind.BINARY ? binary.clone() : numeric; }
public String externalForm() {
return kind == Kind.BINARY ? Base64.getEncoder().encodeToString(binary) : Long.toString(numeric);
}
@Override public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof VersionToken token) || kind != token.kind) return false;
return Objects.equals(numeric, token.numeric) && Arrays.equals(binary, token.binary);
}
@Override public int hashCode() { return 31 * Objects.hash(kind, numeric) + Arrays.hashCode(binary); }
@Override public String toString() { return externalForm(); }
}

View File

@@ -0,0 +1,43 @@
package com.ruoyi.algorithm.repository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
public interface WarehouseRepository {
void synchronizeReadModel(String warehouseId);
default void refreshChangedLanes(String warehouseId) {
synchronizeReadModel(warehouseId);
}
WarehouseSummary loadSummary(String warehouseId);
LanePage pageCandidateLanes(String warehouseId, String afterLaneCode, int pageSize);
LanePage pageWarehouseLanes(String warehouseId, String afterLaneCode, int pageSize);
List<LaneSnapshot> loadLaneSnapshots(String warehouseId, Collection<String> laneCodes);
default List<LaneSnapshot> loadShadowOutboundLaneSnapshots(String warehouseId, Collection<String> laneCodes,
Set<String> candidateSpaceIds) {
return loadLaneSnapshots(warehouseId, laneCodes);
}
List<SpaceSnapshot> loadRelocationBuffers(String warehouseId, Set<Integer> floors);
Set<Integer> loadActiveDeepWorkflowFloors(String warehouseId, String excludingRequestId);
boolean acquireDeepWorkflowLock(String requestId, String warehouseId, int floorNo);
boolean releaseDeepWorkflowLock(String requestId, String warehouseId, int floorNo);
default DeepWorkflowMutationResult acquireDeepWorkflowLockResult(String requestId, String warehouseId, int floorNo) {
return acquireDeepWorkflowLock(requestId, warehouseId, floorNo)
? DeepWorkflowMutationResult.succeeded() : DeepWorkflowMutationResult.unchanged();
}
default DeepWorkflowMutationResult releaseDeepWorkflowLockResult(String requestId, String warehouseId, int floorNo) {
return releaseDeepWorkflowLock(requestId, warehouseId, floorNo)
? DeepWorkflowMutationResult.succeeded() : DeepWorkflowMutationResult.unchanged();
}
List<PendingOutboundJob> loadPendingOutboundJobs(String warehouseId);
List<OutboundStockMatch> searchOutboundStock(String warehouseId, OutboundStockSearch search);
int countProjectPallets(String warehouseId, String projectNo);
RepositoryPolicy loadPolicy(String warehouseId);
DecisionLogEntry insertDecision(DecisionLogRecord record);
Optional<DecisionLogEntry> findDecision(String decisionId);
ReservationResult tryReserveInbound(InboundReservation reservation);
ReservationResult releaseReservation(String warehouseId, String reservationId);
}

View File

@@ -0,0 +1,6 @@
package com.ruoyi.algorithm.repository;
public record WarehouseSummary(String warehouseId, int laneCount, int spaceCount, int occupiedCount,
int availableCount, int pendingLaneCount, int conflictSpaceCount,
int reservedSpaceCount) {
}

View File

@@ -0,0 +1,7 @@
package com.ruoyi.algorithm.service;
import com.ruoyi.algorithm.inbound.InboundPlan;
import com.ruoyi.algorithm.repository.VersionToken;
public record InboundPlanningResult(InboundPlan plan, VersionToken laneVersion) {
}

View File

@@ -0,0 +1,6 @@
package com.ruoyi.algorithm.service;
import com.ruoyi.algorithm.repository.ReservationResult;
public record InboundReservationOutcome(String resultCode, ReservationResult reservation) {
}

View File

@@ -0,0 +1,356 @@
package com.ruoyi.algorithm.service;
import com.ruoyi.algorithm.inbound.InboundContext;
import com.ruoyi.algorithm.inbound.InboundPlan;
import com.ruoyi.algorithm.inbound.InboundPlanner;
import com.ruoyi.algorithm.inbound.InboundRequest;
import com.ruoyi.algorithm.inbound.PendingOutbound;
import com.ruoyi.algorithm.model.SpaceState;
import com.ruoyi.algorithm.outbound.OutboundCapacityMode;
import com.ruoyi.algorithm.outbound.OutboundContext;
import com.ruoyi.algorithm.outbound.OutboundError;
import com.ruoyi.algorithm.outbound.OutboundErrorCode;
import com.ruoyi.algorithm.outbound.OutboundPlan;
import com.ruoyi.algorithm.outbound.OutboundPlanner;
import com.ruoyi.algorithm.outbound.OutboundRequest;
import com.ruoyi.algorithm.tidy.TidyPendingOutbound;
import com.ruoyi.algorithm.tidy.TidyPlan;
import com.ruoyi.algorithm.tidy.TidyPlanner;
import com.ruoyi.algorithm.tidy.TidyRequest;
import com.ruoyi.algorithm.repository.DecisionLogRecord;
import com.ruoyi.algorithm.repository.DeepWorkflowMutationResult;
import com.ruoyi.algorithm.repository.InboundReservation;
import com.ruoyi.algorithm.repository.LaneCandidate;
import com.ruoyi.algorithm.repository.LanePage;
import com.ruoyi.algorithm.repository.LaneSnapshot;
import com.ruoyi.algorithm.repository.OutboundStockMatch;
import com.ruoyi.algorithm.repository.OutboundStockSearch;
import com.ruoyi.algorithm.repository.PendingOutboundJob;
import com.ruoyi.algorithm.repository.RepositoryPolicy;
import com.ruoyi.algorithm.repository.ReservationResult;
import com.ruoyi.algorithm.repository.ReservationStatus;
import com.ruoyi.algorithm.repository.SpaceSnapshot;
import com.ruoyi.algorithm.repository.VersionToken;
import com.ruoyi.algorithm.repository.WarehouseRepository;
import com.ruoyi.algorithm.repository.WarehouseSummary;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
public final class WarehouseApplicationService {
private final WarehouseRepository repository;
private final InboundPlanner inboundPlanner;
private final OutboundPlanner outboundPlanner;
private final TidyPlanner tidyPlanner;
private final int pageSize;
public WarehouseApplicationService(WarehouseRepository repository, InboundPlanner inboundPlanner,
OutboundPlanner outboundPlanner, TidyPlanner tidyPlanner, int pageSize) {
this.repository = Objects.requireNonNull(repository, "repository");
this.inboundPlanner = Objects.requireNonNull(inboundPlanner, "inboundPlanner");
this.outboundPlanner = Objects.requireNonNull(outboundPlanner, "outboundPlanner");
this.tidyPlanner = Objects.requireNonNull(tidyPlanner, "tidyPlanner");
this.pageSize = Math.max(1, Math.min(200, pageSize));
}
public WarehouseSummary summary(String warehouseId) {
return repository.loadSummary(warehouseId);
}
public WarehouseDiagnostics diagnostics(String warehouseId) {
WarehouseSummary summary = repository.loadSummary(warehouseId);
List<String> issues = new ArrayList<>();
forEachLanePage(warehouseId, false, snapshots -> snapshots.stream()
.flatMap(lane -> lane.spaces().stream())
.filter(space -> space.diagnostic() != null)
.forEach(space -> issues.add(space.spaceId() + ":" + space.diagnostic())));
return new WarehouseDiagnostics(summary, issues.stream().sorted().toList());
}
public InboundPlanningResult planInbound(String warehouseId, InboundRequest request) {
return planInbound(warehouseId, request, true);
}
public InboundPlanningResult planInboundFromPublishedSnapshot(String warehouseId, InboundRequest request) {
return planInbound(warehouseId, request, false);
}
private InboundPlanningResult planInbound(String warehouseId, InboundRequest request, boolean refresh) {
if (refresh) {
repository.refreshChangedLanes(warehouseId);
}
return planInboundFromCurrentReadModel(warehouseId, request);
}
private InboundPlanningResult planInboundFromCurrentReadModel(String warehouseId, InboundRequest request) {
RepositoryPolicy policy = repository.loadPolicy(warehouseId);
WarehouseSummary summary = repository.loadSummary(warehouseId);
InboundRequest effectiveRequest = request;
if (request.expectedProjectPallets() == null && nonBlank(request.projectNo())) {
int currentProjectPallets = repository.countProjectPallets(warehouseId, request.projectNo());
effectiveRequest = new InboundRequest(request.requestId(), request.trayNo(), request.projectNo(),
request.productId(), request.lotNo(), currentProjectPallets <= 1 ? 2 : 4,
request.entryFloor(), request.targetFloor(), request.allowedFloors(), request.batchLaneMode(),
request.eligibleSpaceIds());
}
List<PendingOutbound> pending = repository.loadPendingOutboundJobs(warehouseId).stream()
.map(job -> new PendingOutbound(job.projectNo(), job.productNo(), job.trayNo(),
job.sourceLaneCode(), job.sourceResolution())).toList();
InboundPlanner.PagedSession session = inboundPlanner.startPaged(effectiveRequest,
policy.inboundPolicy(), pending, summary.occupiedCount());
Map<String, VersionToken> versions = new LinkedHashMap<>();
String cursor = null;
do {
LanePage page = repository.pageCandidateLanes(warehouseId, cursor, pageSize);
List<String> laneCodes = page.lanes().stream().map(LaneCandidate::laneCode).toList();
if (!laneCodes.isEmpty()) {
List<LaneSnapshot> snapshots = repository.loadLaneSnapshots(warehouseId, laneCodes);
snapshots.forEach(lane -> versions.put(lane.laneCode(), lane.version()));
session.accept(snapshots.stream().flatMap(lane -> lane.spaceStates().stream()).toList());
versions.keySet().retainAll(session.retainedLaneCodes());
}
cursor = page.nextLaneCode();
} while (cursor != null);
InboundPlan plan = session.finish();
VersionToken version = plan.assignment().map(value -> versions.get(value.laneCode())).orElse(null);
String resultCode = plan.assignment().isPresent() ? "READY"
: "REJECTED_" + plan.rejection().orElseThrow().code().name();
String reason = plan.assignment().map(value -> value.reason())
.orElseGet(() -> plan.rejection().orElseThrow().reason());
log(warehouseId, "INBOUND", version, resultCode, reason);
return new InboundPlanningResult(plan, version);
}
public OutboundPlan planOutbound(String warehouseId, OutboundRequest request) {
return planOutbound(warehouseId, request, true);
}
public OutboundPlan planOutboundFromPublishedSnapshot(String warehouseId, OutboundRequest request) {
return planOutbound(warehouseId, request, false);
}
/** The caller holds a published snapshot lease and supplies the site's eligible inventory IDs. */
public OutboundPlan planShadowOutboundCandidates(String warehouseId, OutboundRequest request,
Set<String> eligibleStockIds) {
Objects.requireNonNull(eligibleStockIds, "eligibleStockIds");
if (eligibleStockIds.size() > 200 || request.wholeTrayOrDefault() || nonBlank(request.trayNo())
|| !nonBlank(request.projectNo()) || !nonBlank(request.productId())
|| (nonBlank(request.fifoMode()) && !"OFF".equalsIgnoreCase(request.fifoMode()))) {
throw new IllegalArgumentException("Shadow candidates require a bounded project/product picking scope with FIFO OFF");
}
return planOutboundFromCurrentReadModel(warehouseId, request, Set.copyOf(eligibleStockIds));
}
private OutboundPlan planOutbound(String warehouseId, OutboundRequest request, boolean refresh) {
if (refresh) {
repository.refreshChangedLanes(warehouseId);
}
return planOutboundFromCurrentReadModel(warehouseId, request);
}
private OutboundPlan planOutboundFromCurrentReadModel(String warehouseId, OutboundRequest request) {
return planOutboundFromCurrentReadModel(warehouseId, request, null);
}
private OutboundPlan planOutboundFromCurrentReadModel(String warehouseId, OutboundRequest request,
Set<String> eligibleStockIds) {
RepositoryPolicy policy = repository.loadPolicy(warehouseId);
WarehouseSummary summary = repository.loadSummary(warehouseId);
OutboundRequest effectiveRequest = request.fifoToleranceMinutes() == null && policy.fifoToleranceMinutes() > 0
? new OutboundRequest(request.requestId(), request.trayNo(), request.projectNo(), request.productId(),
request.fifoMode(), policy.fifoToleranceMinutes(), request.exitFloor(), request.wholeTray())
: request;
List<OutboundStockMatch> matches = new ArrayList<>();
String matchedProductScope = null;
if (nonBlank(request.trayNo())) {
matches.addAll(repository.searchOutboundStock(warehouseId, OutboundStockSearch.exactTray(request.trayNo())));
boolean fifoEnabled = nonBlank(request.fifoMode()) && !"OFF".equalsIgnoreCase(request.fifoMode());
Set<String> spaces = matches.stream().map(OutboundStockMatch::spaceId).filter(this::nonBlank)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
Set<String> projects = matches.stream().map(OutboundStockMatch::projectNo).filter(this::nonBlank)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
Set<String> products = matches.stream().map(OutboundStockMatch::productNo).filter(this::nonBlank)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
if (spaces.size() > 1 || projects.size() > 1) {
OutboundPlan ambiguous = OutboundPlan.error(new OutboundError(OutboundErrorCode.AMBIGUOUS_TRAY,
"Exact tray resolves to multiple spaces or inventory scopes.",
Map.of("spaceCount", spaces.size(), "projectCount", projects.size(),
"productCount", products.size())));
log(warehouseId, "OUTBOUND", null, "ERROR_AMBIGUOUS_TRAY",
ambiguous.error().orElseThrow().message());
return ambiguous;
}
if (fifoEnabled && projects.size() == 1) {
String project = projects.iterator().next();
String product = products.size() == 1 ? products.iterator().next() : null;
matchedProductScope = product;
List<OutboundStockMatch> scoped = repository.searchOutboundStock(warehouseId,
OutboundStockSearch.project(project, product));
scoped.forEach(match -> {
if (matches.stream().noneMatch(existing -> existing.stockId().equals(match.stockId()))) {
matches.add(match);
}
});
}
} else if (nonBlank(request.projectNo())) {
matchedProductScope = nonBlank(request.productId()) ? request.productId() : null;
matches.addAll(repository.searchOutboundStock(warehouseId,
OutboundStockSearch.project(request.projectNo(), request.productId())));
}
if (eligibleStockIds != null) {
matches.removeIf(match -> !eligibleStockIds.contains(match.stockId()));
Set<String> resolved = matches.stream().map(OutboundStockMatch::stockId).collect(java.util.stream.Collectors.toSet());
if (!resolved.equals(eligibleStockIds)) {
throw new IllegalStateException("Captured outbound candidate is missing from the published inventory");
}
}
Set<String> matchedSpaceIds = matches.stream().map(OutboundStockMatch::spaceId)
.filter(this::nonBlank).collect(java.util.stream.Collectors.toSet());
List<String> laneCodes = matches.stream().map(OutboundStockMatch::laneCode).filter(Objects::nonNull)
.distinct().sorted().toList();
List<LaneSnapshot> lanes = eligibleStockIds == null ? repository.loadLaneSnapshots(warehouseId, laneCodes)
: repository.loadShadowOutboundLaneSnapshots(warehouseId, laneCodes, matchedSpaceIds);
Set<Integer> floors = matches.stream().map(OutboundStockMatch::floorNo)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
List<SpaceSnapshot> buffers = repository.loadRelocationBuffers(warehouseId, floors);
Map<String, SpaceState> uniqueSpaces = new LinkedHashMap<>();
String productScope = matchedProductScope;
lanes.stream().flatMap(lane -> lane.spaceStates().stream()).forEach(space -> uniqueSpaces.put(space.spaceId(),
eligibleStockIds != null && matchedSpaceIds.contains(space.spaceId())
? withInventoryScope(space, request.projectNo(), request.productId())
: productScope != null && matchedSpaceIds.contains(space.spaceId())
? withProductScope(space, productScope) : space));
buffers.stream().map(SpaceSnapshot::state).forEach(space -> uniqueSpaces.put(space.spaceId(), space));
Map<String, String> rawInboundTimes = new LinkedHashMap<>();
matches.stream().filter(match -> match.inboundTimeRaw() != null)
.forEach(match -> rawInboundTimes.putIfAbsent(match.spaceId(), match.inboundTimeRaw()));
lanes.stream().flatMap(lane -> lane.spaces().stream()).forEach(space -> space.inventory().stream()
.filter(item -> item.inboundTimeRaw() != null)
.forEach(item -> rawInboundTimes.putIfAbsent(space.spaceId(), item.inboundTimeRaw())));
OutboundPlan plan = outboundPlanner.plan(effectiveRequest,
new OutboundContext(List.copyOf(uniqueSpaces.values()), rawInboundTimes,
capacityMode(summary, policy),
repository.loadActiveDeepWorkflowFloors(warehouseId, request.requestId())),
eligibleStockIds == null ? null : matchedSpaceIds);
String resultCode = switch (plan.status()) {
case READY -> "READY";
case WAITING -> "WAITING_" + plan.waiting().orElseThrow().code().name();
case ERROR -> "ERROR_" + plan.error().orElseThrow().code().name();
};
String reason = plan.assignment().map(value -> value.reason())
.or(() -> plan.waiting().map(value -> value.reason()))
.or(() -> plan.error().map(value -> value.message())).orElse(resultCode);
log(warehouseId, "OUTBOUND", null, resultCode, reason);
return plan;
}
public TidyPlan planTidy(String warehouseId, Instant now) {
RepositoryPolicy policy = repository.loadPolicy(warehouseId);
List<SpaceState> spaces = new ArrayList<>();
forEachLanePage(warehouseId, false,
snapshots -> snapshots.forEach(lane -> spaces.addAll(lane.spaceStates())));
List<TidyPendingOutbound> pending = repository.loadPendingOutboundJobs(warehouseId).stream()
.map(job -> new TidyPendingOutbound(job.projectNo(), job.productNo(), job.trayNo(),
job.sourceLaneCode())).toList();
TidyPlan plan = tidyPlanner.plan(new TidyRequest(spaces, pending, now,
policy.schedulingPolicy(), policy.inboundPolicy().commonProjectNos()));
String result = plan.candidates().isEmpty() ? "EMPTY" : "READY";
log(warehouseId, "TIDY", null, result,
plan.diagnostics().isEmpty() ? "candidates=" + plan.candidates().size()
: String.join("; ", plan.diagnostics()));
return plan;
}
public InboundReservationOutcome reserveInbound(String reservationId, String warehouseId, String projectNo,
InboundPlanningResult planned) {
var assignment = planned.plan().assignment().orElseThrow(
() -> new IllegalArgumentException("Only an accepted inbound plan can be reserved"));
ReservationResult result = repository.tryReserveInbound(new InboundReservation(reservationId, warehouseId,
assignment.laneCode(), assignment.spaceId(), projectNo, planned.laneVersion()));
String resultCode = result.status() == ReservationStatus.CONFLICT ? "REPLAN_REQUIRED" : result.status().name();
log(warehouseId, "INBOUND_RESERVATION", planned.laneVersion(), resultCode,
result.reasonCode() == null ? resultCode : result.reasonCode());
return new InboundReservationOutcome(resultCode, result);
}
public InboundReservationOutcome reserveInbound(InboundReservation reservation) {
ReservationResult result = repository.tryReserveInbound(reservation);
String resultCode = result.status() == ReservationStatus.CONFLICT ? "REPLAN_REQUIRED" : result.status().name();
log(reservation.warehouseId(), "INBOUND_RESERVATION", reservation.expectedVersion(), resultCode,
result.reasonCode() == null ? resultCode : result.reasonCode());
return new InboundReservationOutcome(resultCode, result);
}
public ReservationResult releaseReservation(String warehouseId, String reservationId) {
ReservationResult result = repository.releaseReservation(warehouseId, reservationId);
log(warehouseId, "RESERVATION_RELEASE", null, result.status().name(), result.reasonCode());
return result;
}
public DeepWorkflowMutationResult acquireDeepWorkflowLock(String warehouseId, String requestId, int floorNo) {
DeepWorkflowMutationResult result = repository.acquireDeepWorkflowLockResult(requestId, warehouseId, floorNo);
log(warehouseId, "DEEP_WORKFLOW_LOCK", null, result.changed() ? "ACQUIRED"
: result.reasonCode() == null ? "CONFLICT" : result.reasonCode(),
"request=" + requestId + ", floor=" + floorNo);
return result;
}
public DeepWorkflowMutationResult releaseDeepWorkflowLock(String warehouseId, String requestId, int floorNo) {
DeepWorkflowMutationResult result = repository.releaseDeepWorkflowLockResult(requestId, warehouseId, floorNo);
log(warehouseId, "DEEP_WORKFLOW_RELEASE", null, result.changed() ? "RELEASED"
: result.reasonCode() == null ? "NOT_FOUND" : result.reasonCode(),
"request=" + requestId + ", floor=" + floorNo);
return result;
}
private void forEachLanePage(String warehouseId, boolean candidatesOnly,
java.util.function.Consumer<List<LaneSnapshot>> consumer) {
String cursor = null;
do {
LanePage page = candidatesOnly
? repository.pageCandidateLanes(warehouseId, cursor, pageSize)
: repository.pageWarehouseLanes(warehouseId, cursor, pageSize);
List<String> laneCodes = page.lanes().stream().map(LaneCandidate::laneCode).toList();
if (!laneCodes.isEmpty()) consumer.accept(repository.loadLaneSnapshots(warehouseId, laneCodes));
cursor = page.nextLaneCode();
} while (cursor != null);
}
private void log(String warehouseId, String type, VersionToken version, String resultCode, String reason) {
repository.insertDecision(new DecisionLogRecord(UUID.randomUUID().toString(), warehouseId, type,
version, resultCode, reason));
}
private OutboundCapacityMode capacityMode(WarehouseSummary summary, RepositoryPolicy policy) {
int occupied = summary.occupiedCount();
if (occupied >= policy.schedulingPolicy().capacityFirstOccupied()) {
return OutboundCapacityMode.CAPACITY_FIRST;
}
if (occupied >= policy.schedulingPolicy().warningOccupied()) {
return OutboundCapacityMode.WARNING;
}
return OutboundCapacityMode.BALANCED;
}
private SpaceState withProductScope(SpaceState space, String productId) {
return withInventoryScope(space, space.projectNo(), productId);
}
private SpaceState withInventoryScope(SpaceState space, String projectNo, String productId) {
return new SpaceState(space.spaceId(), space.zoneCode(), space.laneCode(), space.depthOrder(),
space.laneCapacity(), space.floorNo(), space.aisleNo(), space.groupCoding(), space.rowCode(),
space.role(), space.status(), space.outboundTaskActive(), space.inventoryProjectConflict(),
space.trayNo(), projectNo, productId, space.lotNo(), space.inboundAt(), space.outboundAt(),
space.reserved(), space.rowVersion(), space.physicalStockPresent());
}
private boolean nonBlank(String value) { return value != null && !value.isBlank(); }
}

View File

@@ -0,0 +1,9 @@
package com.ruoyi.algorithm.service;
import com.ruoyi.algorithm.repository.WarehouseSummary;
import java.util.List;
public record WarehouseDiagnostics(WarehouseSummary summary, List<String> issues) {
public WarehouseDiagnostics { issues = List.copyOf(issues); }
}

View File

@@ -0,0 +1,17 @@
package com.ruoyi.algorithm.tidy;
import java.util.List;
import java.util.Optional;
public record TidyCandidate(
TidyCandidateType type,
String projectNo,
List<TidyMove> moves,
Optional<String> releasesLaneCode,
String reason
) {
public TidyCandidate {
moves = List.copyOf(moves);
releasesLaneCode = releasesLaneCode == null ? Optional.empty() : releasesLaneCode;
}
}

View File

@@ -0,0 +1,7 @@
package com.ruoyi.algorithm.tidy;
public enum TidyCandidateType {
GUEST_RETURN,
DOWNGRADE_MERGE,
CONSOLIDATE_INCUBATION
}

View File

@@ -0,0 +1,4 @@
package com.ruoyi.algorithm.tidy;
public record TidyMove(String trayNo, String fromSpaceId, String toSpaceId) {
}

View File

@@ -0,0 +1,4 @@
package com.ruoyi.algorithm.tidy;
public record TidyPendingOutbound(String projectNo, String productId, String trayNo, String laneCode) {
}

View File

@@ -0,0 +1,10 @@
package com.ruoyi.algorithm.tidy;
import java.util.List;
public record TidyPlan(List<TidyCandidate> candidates, List<String> diagnostics) {
public TidyPlan {
candidates = List.copyOf(candidates);
diagnostics = List.copyOf(diagnostics);
}
}

View File

@@ -0,0 +1,358 @@
package com.ruoyi.algorithm.tidy;
import com.ruoyi.algorithm.model.SpaceRole;
import com.ruoyi.algorithm.model.SpaceState;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
public final class TidyPlanner {
private static final Duration STAGNANT_AGE = Duration.ofHours(72);
public TidyPlan plan(TidyRequest request) {
Map<String, LaneView> lanes = laneViews(request.spaces());
Set<String> pendingLanes = pendingLaneCodes(request);
Set<String> usedSources = new HashSet<>();
Set<String> usedTargets = new HashSet<>();
List<TidyCandidate> candidates = new ArrayList<>();
List<String> diagnostics = new ArrayList<>();
Map<String, Instant> newestInboundByProject = newestInboundByProject(request.spaces());
addGuestReturns(lanes, pendingLanes, usedSources, usedTargets, candidates);
if (request.now() == null) {
diagnostics.add("No current time supplied; DOWNGRADE_MERGE candidates were skipped");
} else {
addDowngrades(lanes, pendingLanes, request.now(), newestInboundByProject,
request.commonProjectNos(), usedSources, usedTargets, candidates);
}
addConsolidations(lanes, pendingLanes, request.commonProjectNos(),
usedSources, usedTargets, candidates);
candidates.sort(Comparator.comparingInt((TidyCandidate candidate) -> candidate.type().ordinal())
.thenComparingInt(candidate -> candidate.releasesLaneCode().isPresent() ? 0 : 1)
.thenComparingInt(candidate -> candidate.moves().size())
.thenComparing(TidyCandidate::projectNo, Comparator.nullsLast(String::compareTo)));
int maximum = Math.max(0, request.policy().maxTidyCandidates());
if (candidates.size() > maximum) {
diagnostics.add("Tidy candidates exceeded policy maximum; retained first " + maximum);
candidates = new ArrayList<>(candidates.subList(0, maximum));
}
return new TidyPlan(candidates, diagnostics);
}
private void addGuestReturns(Map<String, LaneView> lanes, Set<String> pendingLanes,
Set<String> usedSources, Set<String> usedTargets,
List<TidyCandidate> candidates) {
lanes.values().stream().sorted(Comparator.comparing(LaneView::laneCode)).forEach(source -> {
if (source.corrupt() || source.guestProject() == null || pendingLanes.contains(source.laneCode())
|| laneTouched(source, usedSources, usedTargets)) return;
List<SpaceState> guests = source.guestSpaces().stream()
.sorted(Comparator.comparingInt(SpaceState::groupCoding).reversed()
.thenComparing(SpaceState::spaceId))
.toList();
if (guests.stream().anyMatch(space -> usedSources.contains(space.spaceId()))) return;
LaneView target = lanes.values().stream()
.filter(lane -> !lane.laneCode().equals(source.laneCode()))
.filter(lane -> !lane.corrupt() && lane.guestProject() == null)
.filter(lane -> source.guestProject().equals(lane.residentProject()))
.filter(lane -> !pendingLanes.contains(lane.laneCode()))
.filter(lane -> !laneTouched(lane, usedSources, usedTargets))
.filter(lane -> availableTargets(lane, usedTargets).size() >= guests.size())
.min(Comparator.comparingInt((LaneView lane) -> Math.abs(lane.floorNo() - source.floorNo()))
.thenComparing(Comparator.comparingInt((LaneView lane) -> lane.occupied().size()).reversed())
.thenComparing(LaneView::laneCode))
.orElse(null);
if (target == null) {
target = fallbackGuestDestination(source, source.guestProject(), guests.size(), lanes,
pendingLanes, usedSources, usedTargets);
}
if (target == null) return;
List<SpaceState> targetSpaces = availableTargets(target, usedTargets).subList(0, guests.size());
List<TidyMove> moves = moves(guests, targetSpaces);
reserve(moves, usedSources, usedTargets);
candidates.add(new TidyCandidate(TidyCandidateType.GUEST_RETURN, source.guestProject(), moves,
Optional.empty(), "Return guest project stock to its own lane"));
});
}
private void addDowngrades(Map<String, LaneView> lanes, Set<String> pendingLanes, Instant now,
Map<String, Instant> newestInboundByProject, Set<String> commonProjectNos,
Set<String> usedSources, Set<String> usedTargets,
List<TidyCandidate> candidates) {
Instant threshold = now.minus(STAGNANT_AGE);
lanes.values().stream().sorted(Comparator.comparing(LaneView::laneCode)).forEach(source -> {
if (source.capacity() != 5 || source.corrupt() || source.guestProject() != null
|| source.residentProject() == null || source.occupied().isEmpty()
|| source.occupied().size() > 2 || pendingLanes.contains(source.laneCode())
|| commonProjectNos.contains(source.residentProject())
|| laneTouched(source, usedSources, usedTargets)) return;
Instant projectNewest = newestInboundByProject.get(source.residentProject());
if (projectNewest == null || projectNewest.isAfter(threshold)) return;
LaneView target = source.occupied().size() == 1 ? lanes.values().stream()
.filter(lane -> lane.capacity() == 1 && lane.occupied().isEmpty() && !lane.corrupt())
.filter(lane -> singlePoolTier(lane) < 2)
.filter(lane -> !pendingLanes.contains(lane.laneCode()))
.filter(lane -> !laneTouched(lane, usedSources, usedTargets))
.filter(lane -> availableTargets(lane, usedTargets).size() == 1)
.min(Comparator.comparingInt(this::singlePoolTier)
.thenComparingInt(lane -> Math.abs(lane.floorNo() - source.floorNo()))
.thenComparing(LaneView::laneCode))
.orElse(null) : null;
if (target == null) target = lanes.values().stream()
.filter(lane -> lane.capacity() == 2 && lane.occupied().isEmpty() && !lane.corrupt())
.filter(lane -> !pendingLanes.contains(lane.laneCode()))
.filter(lane -> !laneTouched(lane, usedSources, usedTargets))
.filter(lane -> availableTargets(lane, usedTargets).size() >= source.occupied().size())
.min(Comparator.comparingInt((LaneView lane) -> Math.abs(lane.floorNo() - source.floorNo()))
.thenComparing(LaneView::laneCode))
.orElse(null);
if (target == null) return;
List<SpaceState> ordered = source.occupied().stream()
.sorted(Comparator.comparingInt(SpaceState::groupCoding).reversed()
.thenComparing(SpaceState::spaceId))
.toList();
List<TidyMove> moves = moves(ordered,
availableTargets(target, usedTargets).subList(0, ordered.size()));
reserve(moves, usedSources, usedTargets);
candidates.add(new TidyCandidate(TidyCandidateType.DOWNGRADE_MERGE, source.residentProject(), moves,
Optional.of(source.laneCode()), "Move stagnant stock to a shallower lane and release the deep lane"));
});
}
private void addConsolidations(Map<String, LaneView> lanes, Set<String> pendingLanes,
Set<String> commonProjectNos,
Set<String> usedSources, Set<String> usedTargets,
List<TidyCandidate> candidates) {
Set<String> projects = new HashSet<>();
lanes.values().forEach(lane -> {
if (lane.capacity() == 5 && !lane.corrupt() && lane.guestProject() == null
&& lane.residentProject() != null && !lane.occupied().isEmpty()
&& lane.occupied().stream().noneMatch(space -> usedSources.contains(space.spaceId()))
&& lane.spaces().stream().noneMatch(space -> usedTargets.contains(space.spaceId()))
&& !commonProjectNos.contains(lane.residentProject())
&& !availableTargets(lane, usedTargets).isEmpty() && !pendingLanes.contains(lane.laneCode())) {
projects.add(lane.residentProject());
}
});
projects.stream().sorted().forEach(project -> {
if (commonProjectNos.contains(project)) return;
List<LaneView> deep = lanes.values().stream()
.filter(lane -> lane.capacity() == 5 && !lane.corrupt() && lane.guestProject() == null)
.filter(lane -> project.equals(lane.residentProject()))
.filter(lane -> lane.occupied().stream().noneMatch(space -> usedSources.contains(space.spaceId())))
.filter(lane -> lane.spaces().stream().noneMatch(space -> usedTargets.contains(space.spaceId())))
.filter(lane -> !pendingLanes.contains(lane.laneCode()))
.sorted(Comparator.comparingInt((LaneView lane) -> lane.occupied().size()).reversed()
.thenComparing(LaneView::laneCode))
.toList();
if (deep.isEmpty()) return;
List<SpaceState> shallow = lanes.values().stream()
.filter(lane -> lane.capacity() <= 2 && !pendingLanes.contains(lane.laneCode()))
.filter(lane -> !laneTouched(lane, usedSources, usedTargets))
.flatMap(lane -> accessibleInPhysicalOrder(lane).stream())
.filter(space -> project.equals(space.projectNo()))
.filter(space -> !usedSources.contains(space.spaceId()))
.sorted(Comparator.comparing(SpaceState::laneCode)
.thenComparing(Comparator.comparingInt(SpaceState::groupCoding).reversed())
.thenComparing(SpaceState::spaceId))
.toList();
if (shallow.isEmpty()) return;
List<TidyMove> moves = new ArrayList<>();
for (SpaceState source : shallow) {
SpaceState target = deep.stream().flatMap(lane -> availableTargets(lane, usedTargets).stream())
.findFirst().orElse(null);
if (target == null) break;
TidyMove move = new TidyMove(source.trayNo(), source.spaceId(), target.spaceId());
moves.add(move);
reserve(List.of(move), usedSources, usedTargets);
}
if (moves.isEmpty()) return;
Optional<String> released = lanes.values().stream()
.filter(lane -> lane.capacity() <= 2)
.filter(lane -> lane.occupied().stream().allMatch(space -> usedSources.contains(space.spaceId())))
.filter(lane -> lane.occupied().stream().anyMatch(space -> project.equals(space.projectNo())))
.map(LaneView::laneCode).sorted().findFirst();
candidates.add(new TidyCandidate(TidyCandidateType.CONSOLIDATE_INCUBATION, project, moves, released,
"Consolidate accessible shallow stock into a current deep project lane"));
});
}
private LaneView fallbackGuestDestination(LaneView source, String projectNo, int requiredSlots,
Map<String, LaneView> lanes, Set<String> pendingLanes,
Set<String> usedSources, Set<String> usedTargets) {
int projectPallets = (int) lanes.values().stream()
.flatMap(lane -> lane.occupied().stream())
.filter(space -> projectNo.equals(space.projectNo()))
.count();
int[] depthOrder = projectPallets >= 2 ? new int[]{5, 3, 2} : new int[]{2, 3};
for (int depth : depthOrder) {
LaneView empty = lanes.values().stream()
.filter(lane -> lane.capacity() == depth && lane.occupied().isEmpty() && !lane.corrupt())
.filter(lane -> !pendingLanes.contains(lane.laneCode()))
.filter(lane -> !laneTouched(lane, usedSources, usedTargets))
.filter(lane -> lane.spaces().stream().allMatch(this::ordinaryAvailableStorage))
.filter(lane -> availableTargets(lane, usedTargets).size() >= requiredSlots)
.min(Comparator.comparingInt((LaneView lane) -> Math.abs(lane.floorNo() - source.floorNo()))
.thenComparing(LaneView::laneCode))
.orElse(null);
if (empty != null) return empty;
}
if (requiredSlots != 1) return null;
return lanes.values().stream()
.filter(lane -> lane.capacity() == 1 && lane.occupied().isEmpty() && !lane.corrupt())
.filter(lane -> !pendingLanes.contains(lane.laneCode()))
.filter(lane -> !laneTouched(lane, usedSources, usedTargets))
.filter(lane -> lane.spaces().stream().allMatch(this::ordinaryAvailableStorage))
.min(Comparator.comparingInt(this::singlePoolTier)
.thenComparingInt(lane -> Math.abs(lane.floorNo() - source.floorNo()))
.thenComparing(LaneView::laneCode))
.orElse(null);
}
private boolean ordinaryAvailableStorage(SpaceState space) {
return space.role() == SpaceRole.STORAGE && space.availableEmpty();
}
private int singlePoolTier(LaneView lane) {
String zone = lane.spaces().get(0).zoneCode();
if ("FLEX".equals(zone)) return 0;
if ("CACHE".equals(zone)) return 1;
return 2;
}
private boolean laneTouched(LaneView lane, Set<String> usedSources, Set<String> usedTargets) {
return lane.spaces().stream().anyMatch(space -> usedSources.contains(space.spaceId())
|| usedTargets.contains(space.spaceId()));
}
private Map<String, Instant> newestInboundByProject(List<SpaceState> spaces) {
Map<String, Instant> result = new HashMap<>();
spaces.stream().filter(SpaceState::occupied)
.filter(space -> !blank(space.projectNo()) && space.inboundAt() != null)
.forEach(space -> result.merge(space.projectNo(), space.inboundAt(),
(left, right) -> left.isAfter(right) ? left : right));
return result;
}
private List<SpaceState> accessibleInPhysicalOrder(LaneView lane) {
if (lane.corrupt() || lane.occupied().isEmpty()) return List.of();
return lane.occupied().stream()
.sorted(Comparator.comparingInt(SpaceState::groupCoding).reversed()
.thenComparing(SpaceState::spaceId))
.toList();
}
private List<TidyMove> moves(List<SpaceState> sources, List<SpaceState> targets) {
List<TidyMove> result = new ArrayList<>();
for (int index = 0; index < sources.size(); index++) {
result.add(new TidyMove(sources.get(index).trayNo(), sources.get(index).spaceId(), targets.get(index).spaceId()));
}
return result;
}
private void reserve(List<TidyMove> moves, Set<String> usedSources, Set<String> usedTargets) {
moves.forEach(move -> {
usedSources.add(move.fromSpaceId());
usedTargets.add(move.toSpaceId());
});
}
private List<SpaceState> availableTargets(LaneView lane, Set<String> usedTargets) {
if (lane.corrupt()) return List.of();
return lane.spaces().stream()
.filter(space -> space.groupCoding() > lane.occupied().size())
.filter(space -> space.role() == SpaceRole.STORAGE && space.availableEmpty())
.filter(space -> !usedTargets.contains(space.spaceId()))
.sorted(Comparator.comparingInt(SpaceState::groupCoding).thenComparing(SpaceState::spaceId))
.toList();
}
private Set<String> pendingLaneCodes(TidyRequest request) {
Set<String> result = new HashSet<>();
request.spaces().stream().filter(SpaceState::outboundTaskActive)
.map(SpaceState::laneCode).forEach(result::add);
for (TidyPendingOutbound pending : request.pendingOutbounds()) {
if (!blank(pending.laneCode())) result.add(pending.laneCode());
request.spaces().stream().filter(SpaceState::occupied).filter(space -> pendingMatches(space, pending))
.map(SpaceState::laneCode).forEach(result::add);
}
return result;
}
private boolean pendingMatches(SpaceState space, TidyPendingOutbound pending) {
if (!blank(pending.trayNo())) return pending.trayNo().equals(space.trayNo());
if (blank(pending.projectNo())) return false;
return pending.projectNo().equals(space.projectNo())
&& (blank(pending.productId()) || pending.productId().equals(space.productId()));
}
private Map<String, LaneView> laneViews(List<SpaceState> spaces) {
Map<String, List<SpaceState>> grouped = new LinkedHashMap<>();
spaces.forEach(space -> grouped.computeIfAbsent(space.laneCode(), ignored -> new ArrayList<>()).add(space));
Map<String, LaneView> result = new HashMap<>();
grouped.forEach((laneCode, members) -> result.put(laneCode, analyze(laneCode, members)));
return result;
}
private LaneView analyze(String laneCode, List<SpaceState> members) {
List<SpaceState> ordered = members.stream()
.sorted(Comparator.comparingInt(SpaceState::groupCoding).thenComparing(SpaceState::spaceId))
.toList();
int capacity = ordered.stream().mapToInt(SpaceState::laneCapacity).max().orElse(0);
List<SpaceState> occupied = ordered.stream().filter(SpaceState::occupied).toList();
boolean corrupt = ordered.size() != capacity;
boolean emptySeen = false;
List<String> segments = new ArrayList<>();
String last = null;
for (SpaceState space : ordered) {
if (!space.occupied()) {
emptySeen = true;
continue;
}
if (emptySeen || space.inventoryProjectConflict() || !space.outboundSelectable()) corrupt = true;
String projectNo = space.projectNo();
if (blank(projectNo)) {
corrupt = true;
continue;
}
if (!projectNo.equals(last)) {
segments.add(projectNo);
last = projectNo;
}
}
if (segments.size() > 2) corrupt = true;
String resident = segments.isEmpty() ? null : segments.get(0);
String guest = segments.size() < 2 ? null : segments.get(1);
String finalGuest = guest;
List<SpaceState> guests = guest == null ? List.of()
: occupied.stream().filter(space -> finalGuest.equals(space.projectNo())).toList();
int floor = ordered.isEmpty() ? 0 : ordered.get(0).floorNo();
return new LaneView(laneCode, capacity, floor, ordered, occupied, corrupt, resident, guest, guests);
}
private boolean blank(String value) {
return value == null || value.isBlank();
}
private record LaneView(
String laneCode,
int capacity,
int floorNo,
List<SpaceState> spaces,
List<SpaceState> occupied,
boolean corrupt,
String residentProject,
String guestProject,
List<SpaceState> guestSpaces
) {
}
}

View File

@@ -0,0 +1,28 @@
package com.ruoyi.algorithm.tidy;
import com.ruoyi.algorithm.model.SpaceState;
import com.ruoyi.algorithm.policy.SchedulingPolicy;
import java.time.Instant;
import java.util.List;
import java.util.Set;
public record TidyRequest(
List<SpaceState> spaces,
List<TidyPendingOutbound> pendingOutbounds,
Instant now,
SchedulingPolicy policy,
Set<String> commonProjectNos
) {
public TidyRequest {
spaces = List.copyOf(spaces);
pendingOutbounds = pendingOutbounds == null ? List.of() : List.copyOf(pendingOutbounds);
policy = policy == null ? SchedulingPolicy.ft546Defaults() : policy;
commonProjectNos = commonProjectNos == null ? Set.of("COMMON") : Set.copyOf(commonProjectNos);
}
public TidyRequest(List<SpaceState> spaces, List<TidyPendingOutbound> pendingOutbounds,
Instant now, SchedulingPolicy policy) {
this(spaces, pendingOutbounds, now, policy, Set.of("COMMON"));
}
}

View File

@@ -0,0 +1,89 @@
/* Read-only field diagnostics and export queries for the FT546 shadow package. */
SET NOCOUNT ON;
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT package_id, package_version, installed_at FROM alg.shadow_package_manifest;
SELECT warehouse_id, snapshot_generation, snapshot_status, synchronized_at, source_signature, DATEDIFF(SECOND, synchronized_at, SYSUTCDATETIME()) AS generation_age_seconds FROM alg.shadow_snapshot_state;
SELECT warehouse_id, snapshot_generation, COUNT_BIG(*) AS input_row_count FROM alg.shadow_inbound_request_snapshot GROUP BY warehouse_id, snapshot_generation;
SELECT warehouse_id, instance_id, claimed_at, last_seen_at FROM alg.shadow_instance_heartbeat;
SELECT warehouse_id, metric_name, bucket_upper_micros, metric_count, metric_total_micros, metric_max_micros FROM alg.shadow_runtime_metric;
SELECT warehouse_id, snapshot_generation, COUNT_BIG(*) AS comparison_completeness FROM alg.shadow_outbound_request_snapshot GROUP BY warehouse_id, snapshot_generation;
SELECT warehouse_id, COUNT_BIG(*) AS inventory_parity FROM alg.inventory_item_snapshot GROUP BY warehouse_id;
SELECT request_session_id, blocking_session_id, wait_type, resource_description FROM sys.dm_exec_requests WHERE wait_type IS NOT NULL AND resource_description LIKE '%alg.%';
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;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
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 IN ('2026-09-03-3','2026-09-07-33','2026-09-07-34'))<>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 VALUES
(N'shadow_package_manifest','U'),(N'warehouse_policy','U'),(N'project_policy','U'),(N'space_role','U'),(N'space_topology','U'),(N'inventory_item_snapshot','U'),(N'space_inventory_summary','U'),(N'lane_state','U'),(N'reservation','U'),(N'deep_workflow_lock','U'),(N'decision_log','U'),(N'shadow_snapshot_state','U'),(N'cargo_space_snapshot','U'),(N'active_job_snapshot','U'),(N'shadow_inbound_request_snapshot','U'),(N'shadow_outbound_request_snapshot','U'),(N'shadow_runtime_metric','U'),(N'shadow_instance_heartbeat','U'),
(N'v_inventory_item','V'),(N'v_active_job','V'),(N'v_space_state','V'),(N'usp_rebuild_space_topology','P'),(N'usp_sync_inventory_snapshot','P'),(N'usp_compute_lane_semantic_fingerprint','P'),(N'usp_rebuild_lane_state','P'),(N'usp_refresh_dirty_lanes','P'),(N'usp_claim_shadow_instance','P'),(N'usp_refresh_shadow_snapshot','P');
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 COLLATE DATABASE_DEFAULT WHERE object.schema_id = SCHEMA_ID(N'alg') AND object.is_ms_shipped = 0 AND object.parent_object_id = 0 AND expected.object_name IS NULL) THROW 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 COLLATE DATABASE_DEFAULT=expected.object_type WHERE object.object_id IS NULL) THROW 51014, 'FT546 shadow uninstall refused: expected object is missing or has wrong type', 1;
BEGIN TRY
BEGIN TRANSACTION;
IF EXISTS (SELECT 1 FROM alg.shadow_instance_heartbeat WITH (UPDLOCK, HOLDLOCK) WHERE last_seen_at >= DATEADD(SECOND,-120,SYSUTCDATETIME()) OR claimed_at >= DATEADD(SECOND,-120,SYSUTCDATETIME())) THROW 51015, 'FT546 shadow uninstall refused: recent heartbeat; stop WMS and wait over 120 seconds', 1;
DROP PROCEDURE alg.usp_refresh_shadow_snapshot; DROP PROCEDURE alg.usp_claim_shadow_instance; DROP PROCEDURE alg.usp_refresh_dirty_lanes; DROP PROCEDURE alg.usp_rebuild_lane_state; DROP PROCEDURE alg.usp_compute_lane_semantic_fingerprint; DROP PROCEDURE alg.usp_sync_inventory_snapshot; DROP PROCEDURE alg.usp_rebuild_space_topology;
DROP VIEW alg.v_space_state; DROP VIEW alg.v_active_job; DROP VIEW alg.v_inventory_item;
DROP TABLE alg.shadow_instance_heartbeat; DROP TABLE alg.shadow_runtime_metric; DROP TABLE alg.shadow_outbound_request_snapshot; DROP TABLE alg.shadow_inbound_request_snapshot; DROP TABLE alg.active_job_snapshot; DROP TABLE alg.cargo_space_snapshot; DROP TABLE alg.shadow_snapshot_state; DROP TABLE alg.decision_log; DROP TABLE alg.deep_workflow_lock; DROP TABLE alg.reservation; DROP TABLE alg.lane_state; DROP TABLE alg.space_inventory_summary; DROP TABLE alg.inventory_item_snapshot; DROP TABLE alg.space_topology; DROP TABLE alg.space_role; DROP TABLE alg.project_policy; DROP TABLE alg.warehouse_policy; DROP TABLE alg.shadow_package_manifest;
DROP SCHEMA alg;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT>0 ROLLBACK TRANSACTION;
THROW;
END CATCH;
GO

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,557 @@
/*
FT546WMS shadow V3/V3.1/V3.2 -> V3.3, SQL Server 2016 RTM compatible.
Run only after stopping the WMS shadow-enabled JVM and waiting >120 seconds.
Changes only alg.*. Never executes or alters a Legacy procedure.
One transaction: any failure rolls back columns, procedure, state and manifest.
*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET LOCK_TIMEOUT 5000;
SET DEADLOCK_PRIORITY LOW;
IF DB_NAME() <> N'FT546-wms'
THROW 51060, 'V3.3 upgrade refused: expected FT546-wms database', 1;
IF SCHEMA_ID(N'alg') IS NULL OR OBJECT_ID(N'alg.shadow_package_manifest', N'U') IS NULL
THROW 51061, 'V3.3 upgrade refused: shadow package manifest is missing', 1;
DECLARE @expected TABLE (object_name sysname PRIMARY KEY, object_type char(2) NOT NULL);
INSERT INTO @expected VALUES
(N'shadow_package_manifest','U'),(N'warehouse_policy','U'),(N'project_policy','U'),
(N'space_role','U'),(N'space_topology','U'),(N'inventory_item_snapshot','U'),
(N'space_inventory_summary','U'),(N'lane_state','U'),(N'reservation','U'),
(N'deep_workflow_lock','U'),(N'decision_log','U'),(N'shadow_snapshot_state','U'),
(N'cargo_space_snapshot','U'),(N'active_job_snapshot','U'),
(N'shadow_inbound_request_snapshot','U'),(N'shadow_outbound_request_snapshot','U'),
(N'shadow_runtime_metric','U'),(N'shadow_instance_heartbeat','U'),
(N'v_inventory_item','V'),(N'v_active_job','V'),(N'v_space_state','V'),
(N'usp_rebuild_space_topology','P'),(N'usp_sync_inventory_snapshot','P'),
(N'usp_compute_lane_semantic_fingerprint','P'),(N'usp_rebuild_lane_state','P'),
(N'usp_refresh_dirty_lanes','P'),(N'usp_claim_shadow_instance','P'),
(N'usp_refresh_shadow_snapshot','P');
DECLARE @columns TABLE (
table_name sysname, column_name sysname, type_name sysname, max_length smallint,
precision_value tinyint, scale_value tinyint, PRIMARY KEY (table_name, column_name));
INSERT INTO @columns VALUES
(N'inventory_item_snapshot',N'spare1',N'varchar',255,0,0),
(N'inventory_item_snapshot',N'spare2',N'varchar',255,0,0),
(N'inventory_item_snapshot',N'spare3',N'varchar',255,0,0),
(N'inventory_item_snapshot',N'voltage',N'varchar',255,0,0),
(N'inventory_item_snapshot',N'phase',N'varchar',255,0,0),
(N'inventory_item_snapshot',N'tray_job_count',N'int',4,10,0),
(N'shadow_outbound_request_snapshot',N'spare1',N'varchar',255,0,0),
(N'shadow_outbound_request_snapshot',N'spare2',N'varchar',255,0,0),
(N'shadow_outbound_request_snapshot',N'spare3',N'varchar',255,0,0),
(N'shadow_outbound_request_snapshot',N'voltage',N'varchar',255,0,0),
(N'shadow_outbound_request_snapshot',N'phase',N'varchar',255,0,0),
(N'shadow_outbound_request_snapshot',N'allocated_quantity',N'decimal',17,38,6),
(N'shadow_outbound_request_snapshot',N'predicate_version',N'int',4,10,0);
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @version varchar(40);
SELECT @version = package_version
FROM alg.shadow_package_manifest WITH (UPDLOCK, HOLDLOCK)
WHERE package_id = 'FT546WMS_SHADOW_ONLY';
IF @version IS NULL OR @version NOT IN ('2026-09-03-3', '2026-09-07-33')
OR (SELECT COUNT(*) FROM alg.shadow_package_manifest) <> 1
THROW 51062, 'V3.3 upgrade refused: exact shadow manifest does not match', 1;
IF EXISTS (
SELECT 1 FROM sys.objects object
LEFT JOIN @expected expected ON expected.object_name = object.name
AND expected.object_type = object.type COLLATE DATABASE_DEFAULT
WHERE object.schema_id = SCHEMA_ID(N'alg') AND object.is_ms_shipped = 0
AND object.parent_object_id = 0 AND expected.object_name IS NULL)
THROW 51063, 'V3.3 upgrade refused: unknown object exists in alg schema', 1;
IF EXISTS (
SELECT 1 FROM @expected expected
LEFT JOIN sys.objects object ON object.schema_id = SCHEMA_ID(N'alg')
AND object.name = expected.object_name
AND object.type COLLATE DATABASE_DEFAULT = expected.object_type
WHERE object.object_id IS NULL)
THROW 51064, 'V3.3 upgrade refused: expected object is missing or has wrong type', 1;
IF EXISTS (SELECT 1 FROM sys.triggers trigger_object
JOIN sys.objects parent_object ON parent_object.object_id = trigger_object.parent_id
WHERE parent_object.schema_id = SCHEMA_ID(N'alg'))
THROW 51065, 'V3.3 upgrade refused: unexpected trigger on shadow table', 1;
IF EXISTS (SELECT 1 FROM alg.shadow_instance_heartbeat WITH (UPDLOCK, HOLDLOCK)
WHERE last_seen_at >= DATEADD(SECOND, -120, SYSUTCDATETIME())
OR claimed_at >= DATEADD(SECOND, -120, SYSUTCDATETIME()))
THROW 51066, 'V3.3 upgrade refused: recent shadow heartbeat; stop WMS and wait over 120 seconds', 1;
IF @version = '2026-09-07-33'
BEGIN
IF EXISTS (
SELECT 1 FROM @columns expected
LEFT JOIN sys.columns column_info
ON column_info.object_id = OBJECT_ID(N'alg.' + expected.table_name)
AND column_info.name = expected.column_name
WHERE column_info.column_id IS NULL
OR TYPE_NAME(column_info.user_type_id) <> expected.type_name
OR column_info.max_length <> expected.max_length
OR column_info.precision <> expected.precision_value
OR column_info.scale <> expected.scale_value
OR column_info.is_nullable <> 1)
THROW 51067, 'V3.3 upgrade refused: target columns are missing or incompatible', 1;
COMMIT TRANSACTION;
PRINT 'V33_ALREADY_INSTALLED';
RETURN;
END;
IF EXISTS (
SELECT 1 FROM @columns expected JOIN sys.columns column_info
ON column_info.object_id = OBJECT_ID(N'alg.' + expected.table_name)
AND column_info.name = expected.column_name)
THROW 51068, 'V3.3 upgrade refused: partial or foreign predicate columns already exist', 1;
ALTER TABLE alg.inventory_item_snapshot ADD
spare1 varchar(255) NULL, spare2 varchar(255) NULL, spare3 varchar(255) NULL,
voltage varchar(255) NULL, phase varchar(255) NULL, tray_job_count int NULL;
ALTER TABLE alg.shadow_outbound_request_snapshot ADD
spare1 varchar(255) NULL, spare2 varchar(255) NULL, spare3 varchar(255) NULL,
voltage varchar(255) NULL, phase varchar(255) NULL,
allocated_quantity decimal(38,6) NULL, predicate_version int NULL;
DECLARE @refresh_sql nvarchar(max) = N'ALTER PROCEDURE alg.usp_refresh_shadow_snapshot
@warehouse_id varchar(80),
@snapshot_generation bigint,
@instance_id varchar(80)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF NULLIF(LTRIM(RTRIM(@warehouse_id)), '''') IS NULL
THROW 51006, ''warehouse_id is required'', 1;
IF NULLIF(LTRIM(RTRIM(@instance_id)), '''') IS NULL
THROW 51007, ''instance_id is required'', 1;
IF @snapshot_generation <= 0
THROW 51008, ''snapshot_generation must be positive'', 1;
DECLARE @pre_source_count bigint, @post_source_count bigint,
@pre_source_signature varbinary(32), @post_source_signature varbinary(32),
@source_payload nvarchar(max), @synchronized_at datetime2,
@numeric_warehouse_id decimal(38,0),
@current_generation bigint;
IF LTRIM(RTRIM(@warehouse_id)) NOT LIKE ''%[^0-9]%''
SET @numeric_warehouse_id = TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(@warehouse_id)));
BEGIN TRY
BEGIN TRANSACTION;
SELECT @current_generation = state.snapshot_generation
FROM alg.shadow_snapshot_state state WITH (UPDLOCK, HOLDLOCK)
WHERE state.warehouse_id = @warehouse_id;
IF @current_generation IS NOT NULL AND @snapshot_generation <= @current_generation
THROW 51009, ''snapshot_generation must advance the published generation'', 1;
SELECT @pre_source_count =
(SELECT COUNT_BIG(*) FROM dbo.bas_cargo_space cargo WITH (NOLOCK) WHERE cargo.warehouse_id = @warehouse_id)
+ (SELECT COUNT_BIG(*) FROM dbo.bas_stock stock WITH (NOLOCK)
JOIN dbo.bas_cargo_space cargo WITH (NOLOCK) ON cargo.cargo_space_id = stock.cargo_space_id
WHERE cargo.warehouse_id = @warehouse_id)
+ (SELECT COUNT_BIG(*) FROM dbo.mydo_job job WITH (NOLOCK) WHERE job.warehouse_id = @warehouse_id)
+ (SELECT COUNT_BIG(*) FROM dbo.mydo_job_detail detail WITH (NOLOCK)
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
WHERE job.warehouse_id = @warehouse_id)
+ (SELECT COUNT_BIG(*) FROM dbo.in_stockin_detail inbound WITH (NOLOCK)
JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
JOIN dbo.in_stockin stockin WITH (NOLOCK) ON stockin.stockin_id = inbound.stockin_id
WHERE (job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '''')
AND (LTRIM(RTRIM(stockin.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockin.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockin.warehouse_id))) = @numeric_warehouse_id)))
+ (SELECT COUNT_BIG(*) FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
JOIN dbo.out_stockout stockout WITH (NOLOCK) ON stockout.stockout_id = outbound.stockout_id
WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)
AND (LTRIM(RTRIM(stockout.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockout.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockout.warehouse_id))) = @numeric_warehouse_id)));
SELECT @source_payload = ISNULL(CONVERT(nvarchar(max), (
SELECT cargo.cargo_space_id AS [@space_id], cargo.warehouse_id AS [@warehouse_id],
cargo.group_coding_no AS [@lane_code], cargo.cargo_area_id AS [@cargo_area_id],
cargo.roadway_id AS [@roadway_id], cargo.cargo_space_name AS [@display_name],
cargo.the_platoon AS [@platoon], cargo.the_column AS [@column_no],
cargo.the_floor AS [@floor_no], cargo.group_coding AS [@depth_raw],
cargo.status AS [@space_status], cargo.locked AS [@locked], cargo.tray_code AS [@tray_code]
FROM dbo.bas_cargo_space cargo WITH (NOLOCK)
WHERE cargo.warehouse_id = @warehouse_id
ORDER BY cargo.cargo_space_id
FOR XML PATH(''cargo''), ROOT(''cargo_spaces''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|STOCK|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT stock.stock_id AS [@stock_id], stock.cargo_space_id AS [@space_id],
stock.warehouse_id AS [@warehouse_id], stock.tray_code AS [@tray_code],
stock.spare4 AS [@project_no], stock.product_id AS [@product_no],
stock.lot_no AS [@lot_no], stock.in_date AS [@in_date],
stock.quantity AS [@quantity], stock.out_quantity AS [@out_quantity],
stock.spare1 AS [@spare1], stock.spare2 AS [@spare2], stock.spare3 AS [@spare3],
stock.voltage AS [@voltage], stock.phase AS [@phase],
(SELECT COUNT(*) FROM dbo.mydo_job trayJob WITH (NOLOCK)
WHERE trayJob.tray_code = stock.tray_code) AS [@tray_job_count]
FROM dbo.bas_stock stock WITH (NOLOCK)
JOIN dbo.bas_cargo_space cargo WITH (NOLOCK) ON cargo.cargo_space_id = stock.cargo_space_id
WHERE cargo.warehouse_id = @warehouse_id
ORDER BY stock.stock_id
FOR XML PATH(''stock''), ROOT(''stocks''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|JOB|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT job.job_id AS [@job_id], job.status AS [@status], job.task_no AS [@task_no],
job.tray_code AS [@tray_code], job.cargo_space_id AS [@source_space_id],
job.target_cargo_space_id AS [@target_space_id], job.in_or_out AS [@in_or_out],
job.create_time AS [@create_time], job.finish_time AS [@finish_time],
job.warehouse_id AS [@warehouse_id]
FROM dbo.mydo_job job WITH (NOLOCK)
WHERE job.warehouse_id = @warehouse_id
ORDER BY job.job_id
FOR XML PATH(''job''), ROOT(''jobs''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|JOB_DETAIL|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT detail.job_detail_id AS [@job_detail_id], detail.job_id AS [@job_id],
detail.stock_id AS [@stock_id], detail.lot_no AS [@lot_no],
detail.in_date AS [@in_date], detail.in_or_out_id AS [@request_id],
detail.in_or_out_d_id AS [@request_detail_id], detail.product_id AS [@product_id],
detail.stock_status AS [@stock_status], detail.in_quantity AS [@in_quantity],
detail.stock_quantity AS [@stock_quantity], detail.out_quantity AS [@out_quantity],
detail.pick_quantity AS [@pick_quantity], detail.stockin_detail_id AS [@stockin_detail_id]
FROM dbo.mydo_job_detail detail WITH (NOLOCK)
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
WHERE job.warehouse_id = @warehouse_id
ORDER BY detail.job_detail_id
FOR XML PATH(''job_detail''), ROOT(''job_details''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|INBOUND|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT inbound.stockin_detail_id AS [@stockin_detail_id], inbound.stockin_guid AS [@stockin_guid],
inbound.stockin_id AS [@stockin_id], inbound.lot_no AS [@lot_no],
inbound.product_id AS [@product_id], inbound.quantity AS [@quantity],
inbound.assign_num AS [@assign_num], inbound.spare4 AS [@project_no],
job.warehouse_id AS [@warehouse_id], stockin.warehouse_id AS [@source_warehouse_id],
job.task_no AS [@task_no],
job.tray_code AS [@tray_code], detail.job_id AS [@job_id],
detail.in_or_out_id AS [@request_id]
FROM dbo.in_stockin_detail inbound WITH (NOLOCK)
JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
JOIN dbo.in_stockin stockin WITH (NOLOCK) ON stockin.stockin_id = inbound.stockin_id
WHERE (job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '''')
AND (LTRIM(RTRIM(stockin.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockin.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockin.warehouse_id))) = @numeric_warehouse_id))
ORDER BY inbound.stockin_detail_id
FOR XML PATH(''inbound''), ROOT(''inbound_requests''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|OUTBOUND|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT outbound.stockout_detail_id AS [@stockout_detail_id], outbound.warehouse_id AS [@warehouse_id],
stockout.warehouse_id AS [@source_warehouse_id],
outbound.spare4 AS [@project_no],
outbound.spare1 AS [@spare1], outbound.spare2 AS [@spare2], outbound.spare3 AS [@spare3],
outbound.fid AS [@voltage], outbound.unit AS [@phase],
outbound.product_id AS [@product_id], outbound.lot_no AS [@lot_no],
outbound.quantity AS [@quantity], outbound.assign_num AS [@assign_num],
outbound.allot_quantity AS [@allot_quantity]
FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
JOIN dbo.out_stockout stockout WITH (NOLOCK) ON stockout.stockout_id = outbound.stockout_id
WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)
AND (LTRIM(RTRIM(stockout.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockout.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockout.warehouse_id))) = @numeric_warehouse_id))
ORDER BY outbound.stockout_detail_id
FOR XML PATH(''outbound''), ROOT(''outbound_requests''), TYPE
)), '''');
SET @pre_source_signature = HASHBYTES(''SHA2_256'', CONVERT(varbinary(max), @source_payload));
DELETE FROM alg.cargo_space_snapshot WHERE warehouse_id = @warehouse_id;
DELETE FROM alg.inventory_item_snapshot WHERE warehouse_id = @warehouse_id;
DELETE FROM alg.active_job_snapshot WHERE warehouse_id = @warehouse_id;
DELETE FROM alg.shadow_inbound_request_snapshot WHERE warehouse_id = @warehouse_id;
DELETE FROM alg.shadow_outbound_request_snapshot WHERE warehouse_id = @warehouse_id;
DELETE FROM alg.space_topology WHERE warehouse_id = @warehouse_id;
DELETE FROM alg.space_inventory_summary WHERE warehouse_id = @warehouse_id;
DELETE FROM alg.lane_state WHERE warehouse_id = @warehouse_id;
SET @synchronized_at = SYSUTCDATETIME();
INSERT INTO alg.cargo_space_snapshot (
warehouse_id, space_id, lane_code, cargo_area_id, roadway_id, display_name,
platoon, column_no, floor_no, depth_raw, space_status, locked, tray_code,
snapshot_generation, synchronized_at)
SELECT cargo.warehouse_id, cargo.cargo_space_id, cargo.group_coding_no,
cargo.cargo_area_id, cargo.roadway_id, cargo.cargo_space_name,
cargo.the_platoon, cargo.the_column, cargo.the_floor, cargo.group_coding,
cargo.status, cargo.locked, cargo.tray_code,
@snapshot_generation, @synchronized_at
FROM dbo.bas_cargo_space cargo WITH (NOLOCK)
WHERE cargo.warehouse_id = @warehouse_id;
INSERT INTO alg.inventory_item_snapshot (
warehouse_id, stock_id, space_id, tray_code, project_no, product_no, lot_no,
inbound_time_raw, inbound_at, quantity, out_quantity, snapshot_generation, synchronized_at,
spare1, spare2, spare3, voltage, phase, tray_job_count)
SELECT cargo.warehouse_id, stock.stock_id, stock.cargo_space_id, stock.tray_code,
stock.spare4, stock.product_id, stock.lot_no, stock.in_date,
TRY_CONVERT(datetime2, stock.in_date), stock.quantity, stock.out_quantity,
@snapshot_generation, @synchronized_at,
stock.spare1, stock.spare2, stock.spare3, stock.voltage, stock.phase,
(SELECT COUNT(*) FROM dbo.mydo_job trayJob WITH (NOLOCK)
WHERE trayJob.tray_code = stock.tray_code)
FROM dbo.bas_stock stock WITH (NOLOCK)
JOIN dbo.bas_cargo_space cargo WITH (NOLOCK) ON cargo.cargo_space_id = stock.cargo_space_id
WHERE cargo.warehouse_id = @warehouse_id;
INSERT INTO alg.active_job_snapshot (
warehouse_id, job_id, task_no, tray_code, source_space_id, target_space_id,
status, in_or_out, create_time_raw, snapshot_generation, synchronized_at)
SELECT job.warehouse_id, job.job_id, job.task_no, job.tray_code,
NULLIF(LTRIM(RTRIM(job.cargo_space_id)), ''''),
NULLIF(LTRIM(RTRIM(job.target_cargo_space_id)), ''''), job.status,
job.in_or_out, job.create_time, @snapshot_generation, @synchronized_at
FROM dbo.mydo_job job WITH (NOLOCK)
WHERE job.warehouse_id = @warehouse_id
AND (job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '''');
;WITH inbound_source AS (
SELECT @warehouse_id AS warehouse_id, job.task_no, job.tray_code,
detail.in_or_out_id AS request_id, job.warehouse_id AS raw_warehouse_id,
inbound.spare4 AS project_no, inbound.product_id, inbound.lot_no,
ROW_NUMBER() OVER (
PARTITION BY job.task_no, job.tray_code
ORDER BY inbound.stockin_detail_id, detail.job_detail_id) AS source_rank
FROM dbo.in_stockin_detail inbound WITH (NOLOCK)
JOIN dbo.mydo_job_detail detail WITH (NOLOCK)
ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
JOIN dbo.in_stockin stockin WITH (NOLOCK) ON stockin.stockin_id = inbound.stockin_id
WHERE (job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '''')
AND (LTRIM(RTRIM(stockin.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockin.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockin.warehouse_id))) = @numeric_warehouse_id))
)
INSERT INTO alg.shadow_inbound_request_snapshot (
warehouse_id, task_no, tray_no, request_id, raw_warehouse_id,
project_no, product_id, lot_no, snapshot_generation, synchronized_at)
SELECT @warehouse_id, ISNULL(task_no, ''''), ISNULL(tray_code, ''''), request_id,
raw_warehouse_id, project_no, product_id, lot_no,
@snapshot_generation, @synchronized_at
FROM inbound_source
WHERE source_rank = 1;
INSERT INTO alg.shadow_outbound_request_snapshot (
warehouse_id, stockout_detail_id, raw_warehouse_id, project_no, product_id,
requested_quantity, snapshot_generation, synchronized_at,
allocated_quantity, spare1, spare2, spare3, voltage, phase, predicate_version)
SELECT @warehouse_id, outbound.stockout_detail_id, outbound.warehouse_id,
outbound.spare4, outbound.product_id,
CONVERT(decimal(38,6), outbound.quantity),
@snapshot_generation, @synchronized_at,
CONVERT(decimal(38,6), outbound.allot_quantity),
outbound.spare1, outbound.spare2, outbound.spare3, outbound.fid, outbound.unit,
CASE WHEN ISNULL(DATALENGTH(outbound.product_id), 0) <= 80
AND ISNULL(DATALENGTH(outbound.spare1), 0) <= 80
AND ISNULL(DATALENGTH(outbound.spare2), 0) <= 80
AND ISNULL(DATALENGTH(outbound.spare3), 0) <= 80
AND ISNULL(DATALENGTH(outbound.spare4), 0) <= 80
AND ISNULL(DATALENGTH(outbound.fid), 0) <= 80
AND ISNULL(DATALENGTH(outbound.unit), 0) <= 80
THEN 1 ELSE 0 END
FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
JOIN dbo.out_stockout stockout WITH (NOLOCK) ON stockout.stockout_id = outbound.stockout_id
WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)
AND (LTRIM(RTRIM(stockout.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockout.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockout.warehouse_id))) = @numeric_warehouse_id));
EXEC alg.usp_rebuild_space_topology @warehouse_id = @warehouse_id,
@snapshot_generation = @snapshot_generation;
EXEC alg.usp_sync_inventory_snapshot @warehouse_id = @warehouse_id,
@snapshot_generation = @snapshot_generation;
EXEC alg.usp_rebuild_lane_state @warehouse_id = @warehouse_id,
@snapshot_generation = @snapshot_generation;
SELECT @post_source_count =
(SELECT COUNT_BIG(*) FROM dbo.bas_cargo_space cargo WITH (NOLOCK) WHERE cargo.warehouse_id = @warehouse_id)
+ (SELECT COUNT_BIG(*) FROM dbo.bas_stock stock WITH (NOLOCK)
JOIN dbo.bas_cargo_space cargo WITH (NOLOCK) ON cargo.cargo_space_id = stock.cargo_space_id
WHERE cargo.warehouse_id = @warehouse_id)
+ (SELECT COUNT_BIG(*) FROM dbo.mydo_job job WITH (NOLOCK) WHERE job.warehouse_id = @warehouse_id)
+ (SELECT COUNT_BIG(*) FROM dbo.mydo_job_detail detail WITH (NOLOCK)
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
WHERE job.warehouse_id = @warehouse_id)
+ (SELECT COUNT_BIG(*) FROM dbo.in_stockin_detail inbound WITH (NOLOCK)
JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
JOIN dbo.in_stockin stockin WITH (NOLOCK) ON stockin.stockin_id = inbound.stockin_id
WHERE (job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '''')
AND (LTRIM(RTRIM(stockin.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockin.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockin.warehouse_id))) = @numeric_warehouse_id)))
+ (SELECT COUNT_BIG(*) FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
JOIN dbo.out_stockout stockout WITH (NOLOCK) ON stockout.stockout_id = outbound.stockout_id
WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)
AND (LTRIM(RTRIM(stockout.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockout.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockout.warehouse_id))) = @numeric_warehouse_id)));
SELECT @source_payload = ISNULL(CONVERT(nvarchar(max), (
SELECT cargo.cargo_space_id AS [@space_id], cargo.warehouse_id AS [@warehouse_id],
cargo.group_coding_no AS [@lane_code], cargo.cargo_area_id AS [@cargo_area_id],
cargo.roadway_id AS [@roadway_id], cargo.cargo_space_name AS [@display_name],
cargo.the_platoon AS [@platoon], cargo.the_column AS [@column_no],
cargo.the_floor AS [@floor_no], cargo.group_coding AS [@depth_raw],
cargo.status AS [@space_status], cargo.locked AS [@locked], cargo.tray_code AS [@tray_code]
FROM dbo.bas_cargo_space cargo WITH (NOLOCK)
WHERE cargo.warehouse_id = @warehouse_id
ORDER BY cargo.cargo_space_id
FOR XML PATH(''cargo''), ROOT(''cargo_spaces''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|STOCK|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT stock.stock_id AS [@stock_id], stock.cargo_space_id AS [@space_id],
stock.warehouse_id AS [@warehouse_id], stock.tray_code AS [@tray_code],
stock.spare4 AS [@project_no], stock.product_id AS [@product_no],
stock.lot_no AS [@lot_no], stock.in_date AS [@in_date],
stock.quantity AS [@quantity], stock.out_quantity AS [@out_quantity],
stock.spare1 AS [@spare1], stock.spare2 AS [@spare2], stock.spare3 AS [@spare3],
stock.voltage AS [@voltage], stock.phase AS [@phase],
(SELECT COUNT(*) FROM dbo.mydo_job trayJob WITH (NOLOCK)
WHERE trayJob.tray_code = stock.tray_code) AS [@tray_job_count]
FROM dbo.bas_stock stock WITH (NOLOCK)
JOIN dbo.bas_cargo_space cargo WITH (NOLOCK) ON cargo.cargo_space_id = stock.cargo_space_id
WHERE cargo.warehouse_id = @warehouse_id
ORDER BY stock.stock_id
FOR XML PATH(''stock''), ROOT(''stocks''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|JOB|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT job.job_id AS [@job_id], job.status AS [@status], job.task_no AS [@task_no],
job.tray_code AS [@tray_code], job.cargo_space_id AS [@source_space_id],
job.target_cargo_space_id AS [@target_space_id], job.in_or_out AS [@in_or_out],
job.create_time AS [@create_time], job.finish_time AS [@finish_time],
job.warehouse_id AS [@warehouse_id]
FROM dbo.mydo_job job WITH (NOLOCK)
WHERE job.warehouse_id = @warehouse_id
ORDER BY job.job_id
FOR XML PATH(''job''), ROOT(''jobs''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|JOB_DETAIL|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT detail.job_detail_id AS [@job_detail_id], detail.job_id AS [@job_id],
detail.stock_id AS [@stock_id], detail.lot_no AS [@lot_no],
detail.in_date AS [@in_date], detail.in_or_out_id AS [@request_id],
detail.in_or_out_d_id AS [@request_detail_id], detail.product_id AS [@product_id],
detail.stock_status AS [@stock_status], detail.in_quantity AS [@in_quantity],
detail.stock_quantity AS [@stock_quantity], detail.out_quantity AS [@out_quantity],
detail.pick_quantity AS [@pick_quantity], detail.stockin_detail_id AS [@stockin_detail_id]
FROM dbo.mydo_job_detail detail WITH (NOLOCK)
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
WHERE job.warehouse_id = @warehouse_id
ORDER BY detail.job_detail_id
FOR XML PATH(''job_detail''), ROOT(''job_details''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|INBOUND|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT inbound.stockin_detail_id AS [@stockin_detail_id], inbound.stockin_guid AS [@stockin_guid],
inbound.stockin_id AS [@stockin_id], inbound.lot_no AS [@lot_no],
inbound.product_id AS [@product_id], inbound.quantity AS [@quantity],
inbound.assign_num AS [@assign_num], inbound.spare4 AS [@project_no],
job.warehouse_id AS [@warehouse_id], stockin.warehouse_id AS [@source_warehouse_id],
job.task_no AS [@task_no],
job.tray_code AS [@tray_code], detail.job_id AS [@job_id],
detail.in_or_out_id AS [@request_id]
FROM dbo.in_stockin_detail inbound WITH (NOLOCK)
JOIN dbo.mydo_job_detail detail WITH (NOLOCK) ON detail.stockin_detail_id = inbound.stockin_detail_id
JOIN dbo.mydo_job job WITH (NOLOCK) ON job.job_id = detail.job_id
JOIN dbo.in_stockin stockin WITH (NOLOCK) ON stockin.stockin_id = inbound.stockin_id
WHERE (job.finish_time IS NULL OR LTRIM(RTRIM(job.finish_time)) = '''')
AND (LTRIM(RTRIM(stockin.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockin.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockin.warehouse_id))) = @numeric_warehouse_id))
ORDER BY inbound.stockin_detail_id
FOR XML PATH(''inbound''), ROOT(''inbound_requests''), TYPE
)), '''');
SET @source_payload = @source_payload + ''|OUTBOUND|'' + ISNULL(CONVERT(nvarchar(max), (
SELECT outbound.stockout_detail_id AS [@stockout_detail_id], outbound.warehouse_id AS [@warehouse_id],
stockout.warehouse_id AS [@source_warehouse_id],
outbound.spare4 AS [@project_no],
outbound.spare1 AS [@spare1], outbound.spare2 AS [@spare2], outbound.spare3 AS [@spare3],
outbound.fid AS [@voltage], outbound.unit AS [@phase],
outbound.product_id AS [@product_id], outbound.lot_no AS [@lot_no],
outbound.quantity AS [@quantity], outbound.assign_num AS [@assign_num],
outbound.allot_quantity AS [@allot_quantity]
FROM dbo.out_stockout_detail outbound WITH (NOLOCK)
JOIN dbo.out_stockout stockout WITH (NOLOCK) ON stockout.stockout_id = outbound.stockout_id
WHERE ISNULL(outbound.quantity, 0) > ISNULL(outbound.allot_quantity, 0)
AND (LTRIM(RTRIM(stockout.warehouse_id)) = LTRIM(RTRIM(@warehouse_id))
OR (@numeric_warehouse_id IS NOT NULL
AND LTRIM(RTRIM(stockout.warehouse_id)) NOT LIKE ''%[^0-9]%''
AND TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(stockout.warehouse_id))) = @numeric_warehouse_id))
ORDER BY outbound.stockout_detail_id
FOR XML PATH(''outbound''), ROOT(''outbound_requests''), TYPE
)), '''');
SET @post_source_signature = HASHBYTES(''SHA2_256'', CONVERT(varbinary(max), @source_payload));
IF @pre_source_count <> @post_source_count
OR @pre_source_signature <> @post_source_signature
THROW 51025, ''FT546 shadow refresh refused: source changed during refresh'', 1;
MERGE alg.shadow_snapshot_state WITH (HOLDLOCK) AS target
USING (SELECT @warehouse_id AS warehouse_id) AS source
ON source.warehouse_id = target.warehouse_id
WHEN MATCHED THEN UPDATE SET
snapshot_generation = @snapshot_generation,
snapshot_status = ''PUBLISHED'',
synchronized_at = SYSUTCDATETIME(),
source_signature = @post_source_signature,
source_row_count = @post_source_count,
instance_id = @instance_id,
last_error = NULL
WHEN NOT MATCHED THEN INSERT (
warehouse_id, snapshot_generation, snapshot_status, synchronized_at,
source_signature, source_row_count, instance_id, last_error)
VALUES (
@warehouse_id, @snapshot_generation, ''PUBLISHED'', SYSUTCDATETIME(),
@post_source_signature, @post_source_count, @instance_id, NULL);
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION;
THROW;
END CATCH;
END;';
EXEC sys.sp_executesql @refresh_sql;
-- Keep the generation monotonic; old rows cannot become predicate-version-1 evidence.
UPDATE alg.shadow_snapshot_state WITH (UPDLOCK, HOLDLOCK)
SET snapshot_status = 'INITIALIZING', source_signature = NULL, synchronized_at = NULL,
last_error = NULL;
UPDATE alg.shadow_package_manifest
SET package_version = '2026-09-07-33'
WHERE package_id = 'FT546WMS_SHADOW_ONLY' AND package_version = '2026-09-03-3';
IF @@ROWCOUNT <> 1
THROW 51069, 'V3.3 upgrade refused: manifest changed during upgrade', 1;
COMMIT TRANSACTION;
PRINT 'V33_UPGRADE_OK';
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
THROW;
END CATCH;
GO

Some files were not shown because too many files have changed in this diff Show More