chore: upload current WMS changes

This commit is contained in:
zgw
2026-07-24 08:51:42 +08:00
parent 3789aa6420
commit 98db537218
408 changed files with 91747 additions and 2107 deletions

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,28 @@
# FT546 B Algorithm WMS Integration Plan
## Goal
Move the verified Java B algorithm into FT546MWS while keeping WMS as the owner of business transactions and keeping the existing WCS, shuttle, and lift execution policy unchanged.
## Global Constraints
- Inbound fills a dense lane from inner to outer positions.
- Outbound removes a dense lane from outer to inner positions.
- A lane with a pending outbound task cannot accept inbound.
- A deep exact-tray request executes `MOVE_OUT -> OUTBOUND -> RESTORE`.
- Buffer or floor-lock contention returns `WAITING`; it must not fall back or reject the business task.
- Only inbound with no legal space may be rejected.
- Project outbound matches project number only and defaults to whole-tray outbound.
- Production starts in `SHADOW`; `ACTIVE` requires data diagnostics, concurrency checks, and operator approval.
## Tasks
1. [Complete] Persist and advance deep outbound workflows, creating only the next executable WMS job.
2. [Complete] Connect move/outbound completion callbacks from WCS and whole-tray confirmation from PDA.
3. [Complete] Replace whole-warehouse refresh on each request with dirty-lane incremental refresh.
4. [Complete] Complete SQL Server install, diagnostics, uninstall, configuration, and operator documentation.
5. [Complete] Run focused tests, the algorithm suite, the WMS suite, a full Maven build, and final review.
## Transaction Boundary
The algorithm produces a plan. A Spring transaction in `ruoyi-admin` validates live WMS rows, persists the workflow and space locks, and creates one executable `mydo_job` row. WCS continues consuming the existing job table. Each completion callback advances at most one workflow step. The algorithm repository is constructed with the same WMS `PlatformTransactionManager` and `DataSource`; startup rejects a mismatched transaction manager.

View File

@@ -0,0 +1,197 @@
# FT546 B 算法迁移与上线手册
## 1. 当前状态
`javaTest` 中验证过的 B 算法已经迁入 FT546MWS,Maven 模块名为
`fortucky-algorithm`。算法已经接入入库扫码、重新分配、项目出库、指定托盘出库、
WCS 作业完成回调和 PDA 整托出库确认。
当前生产默认模式为 `SHADOW`。在该模式下,B 算法会计算和记录结果,但原 WMS 流程仍
负责业务写入。只有完成本手册的实库诊断、影子比对和并发演练后,才允许切换到
`ACTIVE`。
算法没有修改 WCS 取任务规则、小车调度、提升机调度或同层批处理策略。它只决定货位、
库存和深位搬移步骤,执行仍走现有 `mydo_job` 和 WCS 回调。
## 2. 不可变业务规则
- 入库按密集通道由里到外分配。
- 出库按由外到里执行。
- 有待出库任务的通道禁止新入库。
- 指定深位托盘执行 `MOVE_OUT -> OUTBOUND -> RESTORE`。
- 移库缓存或楼层锁不足时返回 `WAITING`,不能拒绝任务,也不能绕回旧算法跳过约束。
- 只有入库确实没有任何合法货位时才允许拒绝。
- 项目出库只匹配项目号,默认整托出库。
- 深位工作流一次只创建下一条可执行 WMS 作业。
- WCS 出库完成后只到 `AT_EXIT`;PDA 整托确认成功后才开始恢复阻挡托盘。
## 3. 代码接入位置
| 位置 | 作用 |
|---|---|
| `fortucky-algorithm` | 纯 Java 入库、出库、理货算法,Repository、路由和应用服务 |
| `ToWmsServiceImpl.requestEnterByTaskNo` | 入库扫码规划和 ACTIVE 模式原子占位 |
| `ToWmsServiceImpl.reAssign` | 入库任务重新分配 |
| `OutStockoutServiceImpl.autoAllot` | 项目号和指定托盘出库规划 |
| `ToWmsServiceImpl.jobFinish` | WCS 移库/出库完成后推进深位工作流 |
| `PdaServiceImp.outMakeSure` | PDA 整托确认成功后创建首条恢复作业 |
| `PersistentOutboundWorkflowCoordinator` | LEGACY/SHADOW 隔离和 ACTIVE 工作流推进 |
| `OutboundWorkflowTransaction` | 工作流、货位锁、楼层锁和单条 WMS 作业的事务写入 |
## 4. 数据库对象说明
算法对象统一放在 `alg` 架构中。WMS 业务表仍在 `dbo`,算法安装脚本不会改变
`dbo.bas_stock`、`dbo.bas_cargo_space` 和 `dbo.mydo_job` 的字段结构。
| 对象 | 作用 |
|---|---|
| `alg.space_topology` | 货位到通道、深度和基础分区的拓扑映射 |
| `alg.inventory_item_snapshot` | 算法使用的库存明细快照 |
| `alg.space_inventory_summary` | 每个货位的库存、托盘、项目和产品汇总 |
| `alg.lane_state` | 通道占用、可用、待出库、断层和 `rowversion` |
| `alg.dirty_lane_queue` | 记录哪些通道发生变化,避免每次扫码全仓刷新 |
| `alg.reservation` | ACTIVE 模式入库货位预占 |
| `alg.deep_workflow_lock` | 同一仓库同一楼层的深位工作流互斥锁 |
| `alg.outbound_workflow` | 深位出库工作流头 |
| `alg.outbound_workflow_step` | MOVE_OUT、OUTBOUND、RESTORE 步骤及 WMS 作业关联 |
| `alg.outbound_workflow_space` | 工作流持有的源货位和移库缓存货位锁 |
| `alg.space_role` | 移库缓存、出库缓存、保护区等人工配置角色 |
| `alg.warehouse_policy` | 仓库级装载率和 FIFO 容差 |
| `alg.project_policy` | 项目隔离、公共项目和混放策略 |
| `alg.decision_log` | B 算法决策、等待/拒绝原因和输入版本审计 |
`alg.lane_state.state_version` 是 SQL Server `rowversion`。它只递增,不需要减小或
重置;数据库会自动维护,正常使用不需要担心字段耗尽。
## 5. 大仓库增量刷新
规划前,Java 只调用 `alg.usp_refresh_dirty_lanes`。存储过程先用短事务领取当前仓库的
脏通道并立即释放队列锁,再在独立事务中重建这些通道的库存快照、货位汇总和通道状态。
这样可以避免刷新过程与 WMS 业务表触发器形成相反的锁顺序。刷新期间新产生的变化会
保留在队列中;刷新失败时,已领取的通道会合并回队列,不会丢失变更。
以下变化会自动标记通道:
- `bas_stock` 库存新增、修改、移动和删除;
- `bas_cargo_space` 状态、锁、托盘号和结构变化;
- `mydo_job` 新建、状态变化、完成和删除;
- `alg.space_role` 缓存/保护角色变化;
- `alg.project_policy` 和 `alg.warehouse_policy` 策略变化。
普通库存、货位状态和作业变化不会重建全仓拓扑。只有新增/删除货位、换仓库、换通道、
列/排/层等结构变化才设置 `topology_dirty=1` 并重建该仓库拓扑。策略变更很少发生,
为了保证版本一致,会标记策略影响范围内的所有通道。
## 6. 部署步骤
### 6.1 维护窗口前准备
1. 备份 FT546 WMS 数据库。
2. 记录当前 WMS JAR、配置文件和数据库版本。
3. 确认每个启用深位出库的楼层至少配置 4 个 `RELOCATION_BUFFER`。
4. 清理或确认货位状态、货位托盘号和库存托盘号不一致的数据。
5. 确认不存在未处理的密集通道断层;存在断层的通道会保持不可入库。
### 6.2 安装数据库对象
连接 FT546 WMS 主库后执行:
1. `fortucky-algorithm/src/main/resources/db/sqlserver/install.sql`
2. `fortucky-algorithm/src/main/resources/db/sqlserver/diagnostics.sql`
推荐使用 SSMS 或 Azure Data Studio 执行,因为脚本使用 `GO` 分批。Navicat 可以连接、
查看和维护这些表;如果当前 Navicat 版本不能识别 `GO`,安装脚本改用 SSMS 执行,
不要手工删除 `GO` 后整段执行。
`install.sql` 首次安装会全量建立拓扑和快照。之后每次扫码只刷新脏通道。
### 6.3 部署 WMS
`application.yml` 已提供以下默认配置:
```yaml
fortucky:
algorithm:
mode: ${FORTUCKY_ALGORITHM_MODE:SHADOW}
warehouse-id: ${FORTUCKY_ALGORITHM_WAREHOUSE_ID:01}
page-size: ${FORTUCKY_ALGORITHM_PAGE_SIZE:100}
database-writes-enabled: ${FORTUCKY_ALGORITHM_DATABASE_WRITES_ENABLED:true}
legacy-fallback-on-technical-error: ${FORTUCKY_ALGORITHM_LEGACY_FALLBACK:false}
```
首次部署不要设置 `FORTUCKY_ALGORITHM_MODE=ACTIVE`。保持 `SHADOW`,打包并部署新的
`ruoyi-admin.jar`。
单据未带仓库号时,算法使用 `FORTUCKY_ALGORITHM_WAREHOUSE_ID`,入库、浅位出库和深位
工作流提交阶段都会复用同一个有效仓库号。算法 Repository 必须使用 WMS 注入的同一
`PlatformTransactionManager` 和主 `DataSource`;数据源不一致时应用启动失败,避免
预占与货位提交落在不同事务中。
## 7. SHADOW 验收
至少覆盖以下任务:
1. 空通道连续入库,检查货位是否由里到外。
2. 有待出库作业的通道再次入库,检查 B 算法是否排除该通道。
3. 项目号整托出库,检查是否从匹配项目的最外托盘开始。
4. 指定最外托盘出库,检查是否直接生成出库计划。
5. 指定深位托盘出库,检查计划是否为 `MOVE_OUT -> OUTBOUND -> RESTORE`。
6. 移库缓存不足或楼层已有深位流程,检查结果是否为 `WAITING`,任务仍保留。
7. 重复发送 WCS 完成回调和 PDA 确认,检查工作流不会重复推进。
8. 修改一个通道的库存,检查 `alg.dirty_lane_queue` 被消费,其他通道版本不变化。
在 Navicat 中重点查看:
```sql
SELECT TOP 100 * FROM alg.decision_log ORDER BY created_at DESC;
SELECT * FROM alg.dirty_lane_queue ORDER BY last_changed_at;
SELECT * FROM alg.outbound_workflow ORDER BY created_at DESC;
SELECT * FROM alg.outbound_workflow_step ORDER BY workflow_id, workflow_step;
SELECT * FROM alg.outbound_workflow_space WHERE status = 'ACTIVE';
SELECT * FROM alg.deep_workflow_lock WHERE status = 'ACTIVE';
```
只有满足以下条件才能切换 ACTIVE:
- `diagnostics.sql` 没有未解释的数据冲突;
- 所有启用楼层的移库缓存数量合格;
- SHADOW 中 B 方案结果符合业务规则;
- 并发入库不会重复占位;
- 重复 WCS/PDA 回调保持幂等;
- 深位流程可以完成全部恢复并释放货位锁和楼层锁;
- 已完成一轮数据库备份和回滚演练。
切换时设置环境变量 `FORTUCKY_ALGORITHM_MODE=ACTIVE` 并重启 WMS。建议先选一个仓库、
一个班次灰度,确认后再统一推广。
## 8. 回滚
业务异常时,先设置 `FORTUCKY_ALGORITHM_MODE=LEGACY` 并重启 WMS,让新任务立即回到旧
流程。不要在存在 `ACTIVE` 深位工作流时直接卸载数据库对象;先完成或人工处置工作流、
清空活动货位锁和楼层锁。
确认不再需要算法对象后,执行:
`fortucky-algorithm/src/main/resources/db/sqlserver/uninstall.sql`
该脚本只删除算法触发器和 `alg` 架构对象,不删除 WMS 业务库存、货位和作业数据。
## 9. 开发验证命令
在 FT546MWS 根目录执行:
```powershell
& 'D:\BDevTools\maven\apache-maven-3.9.11\bin\mvn.cmd' -pl fortucky-algorithm test
& 'D:\BDevTools\maven\apache-maven-3.9.11\bin\mvn.cmd' -pl ruoyi-admin -am test
& 'D:\BDevTools\maven\apache-maven-3.9.11\bin\mvn.cmd' test
& 'D:\BDevTools\maven\apache-maven-3.9.11\bin\mvn.cmd' package
```
本地 H2 测试覆盖算法、版本、增量刷新和工作流状态机。真实 SQL Server 的脚本安装、
触发器并发和存储过程事务仍必须在 FT546 测试库执行后才能批准 ACTIVE。
当前源码验证结果:`fortucky-algorithm` 117 项测试通过,WMS 接入测试 71 项通过,
`mvn test` 和 `mvn package` 均成功。生成的 `ruoyi-admin/target/ruoyi-admin.jar` 已确认
包含 `BOOT-INF/lib/fortucky-algorithm-3.9.0.jar`;本次产物 SHA-256 为
`8fe0816b86bba007b1070007a2bfb5ff51e7941587d0dbc811f7c69b59dca492`。这些是本地构建证据,
不替代真实 SQL Server 安装、触发器并发和存储过程回滚演练。

1
dosc/.WeDrive Normal file
View File

@@ -0,0 +1 @@
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码

258
dosc/Navbar.vue Normal file
View File

@@ -0,0 +1,258 @@
<template>
<div class="navbar">
<hamburger id="hamburger-container" :is-active="sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
<breadcrumb v-if="!topNav" id="breadcrumb-container" class="breadcrumb-container" />
<top-nav v-if="topNav" id="topmenu-container" class="topmenu-container" />
<div v-if="licenseWarning" class="license-warning">
<i class="el-icon-warning-outline" />
系统授权将于 {{ licenseExpireText }} 后过期,请及时联系管理员续期
</div>
<div class="right-menu">
<template v-if="device!=='mobile'">
<search id="header-search" class="right-menu-item" />
<el-tooltip content="联系我们" effect="dark" placement="bottom">
<ruo-yi-doc id="ruoyi-doc" class="right-menu-item hover-effect" />
</el-tooltip>
<screenfull id="screenfull" class="right-menu-item hover-effect" />
<el-tooltip content="布局大小" effect="dark" placement="bottom">
<size-select id="size-select" class="right-menu-item hover-effect" />
</el-tooltip>
</template>
<el-dropdown class="avatar-container right-menu-item hover-effect" trigger="click">
<div class="avatar-wrapper">
<img :src="avatar" class="user-avatar">
<i class="el-icon-caret-bottom" />
</div>
<el-dropdown-menu slot="dropdown">
<router-link to="/user/profile">
<el-dropdown-item>个人中心</el-dropdown-item>
</router-link>
<el-dropdown-item @click.native="setting = true">
<span>布局设置</span>
</el-dropdown-item>
<el-dropdown-item divided @click.native="logout">
<span>退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import Breadcrumb from '@/components/Breadcrumb'
import TopNav from '@/components/TopNav'
import Hamburger from '@/components/Hamburger'
import Screenfull from '@/components/Screenfull'
import SizeSelect from '@/components/SizeSelect'
import Search from '@/components/HeaderSearch'
import RuoYiGit from '@/components/RuoYi/Git'
import RuoYiDoc from '@/components/RuoYi/Doc'
import { getLicenseInfo } from '@/api/license'
export default {
components: {
Breadcrumb,
TopNav,
Hamburger,
Screenfull,
SizeSelect,
Search,
RuoYiGit,
RuoYiDoc
},
data() {
return {
licenseWarning: false,
licenseExpireText: '',
licenseTimer: null
}
},
computed: {
...mapGetters([
'sidebar',
'avatar',
'device'
]),
setting: {
get() {
return this.$store.state.settings.showSettings
},
set(val) {
this.$store.dispatch('settings/changeSetting', {
key: 'showSettings',
value: val
})
}
},
topNav: {
get() {
return this.$store.state.settings.topNav
}
}
},
mounted() {
this.checkLicenseExpire()
this.licenseTimer = setInterval(() => this.checkLicenseExpire(), 60 * 1000)
},
beforeDestroy() {
if (this.licenseTimer) {
clearInterval(this.licenseTimer)
}
},
methods: {
checkLicenseExpire() {
getLicenseInfo().then(res => {
if (res.code === 200 && res.data && res.data.expireTime) {
const expireTime = new Date(res.data.expireTime).getTime()
const now = Date.now()
const diffMs = expireTime - now
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
if (diffMs > 0 && diffDays <= 30) {
this.licenseWarning = true
if (diffMs < 1000 * 60 * 60 * 24) {
const hours = Math.floor(diffMs / (1000 * 60 * 60))
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
if (hours > 0) {
this.licenseExpireText = hours + ' 小时 ' + minutes + ' 分钟'
} else {
this.licenseExpireText = minutes + ' 分钟'
}
} else if (diffDays < 7) {
this.licenseExpireText = diffDays + ' 天'
} else {
this.licenseExpireText = Math.ceil(diffDays / 7) + ' 周'
}
}
}
}).catch(() => {})
},
toggleSideBar() {
this.$store.dispatch('app/toggleSideBar')
},
logout() {
this.$confirm('确定注销并退出系统吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.$store.dispatch('LogOut').then(() => {
location.href = '/index'
})
}).catch(() => {})
}
}
}
</script>
<style lang="scss" scoped>
.navbar {
height: 50px;
overflow: hidden;
position: relative;
background: #fff;
box-shadow: 0 1px 4px rgba(0,21,41,.08);
.license-warning {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: 0;
height: 50px;
line-height: 50px;
font-size: 13px;
color: #e6a23c;
font-weight: 500;
pointer-events: none;
i { margin-right: 4px; }
}
.hamburger-container {
line-height: 46px;
height: 100%;
float: left;
cursor: pointer;
transition: background .3s;
-webkit-tap-highlight-color:transparent;
&:hover {
background: rgba(0, 0, 0, .025)
}
}
.breadcrumb-container {
float: left;
}
.topmenu-container {
position: absolute;
left: 50px;
}
.errLog-container {
display: inline-block;
vertical-align: top;
}
.right-menu {
float: right;
height: 100%;
line-height: 50px;
&:focus {
outline: none;
}
.right-menu-item {
display: inline-block;
padding: 0 8px;
height: 100%;
font-size: 18px;
color: #5a5e66;
vertical-align: text-bottom;
&.hover-effect {
cursor: pointer;
transition: background .3s;
&:hover {
background: rgba(0, 0, 0, .025)
}
}
}
.avatar-container {
margin-right: 30px;
.avatar-wrapper {
margin-top: 5px;
position: relative;
.user-avatar {
cursor: pointer;
width: 40px;
height: 40px;
border-radius: 10px;
}
.el-icon-caret-bottom {
cursor: pointer;
position: absolute;
right: -20px;
top: 25px;
font-size: 12px;
}
}
}
}
}
</style>

16
dosc/license.js Normal file
View File

@@ -0,0 +1,16 @@
import request from '@/utils/request'
export function activeLicense(licenseCode) {
return request({
url: '/system/license/active',
method: 'post',
params: { licenseCode }
})
}
export function getLicenseInfo() {
return request({
url: '/system/license/info',
method: 'get'
})
}

1
dosc/license/.WeDrive Normal file
View File

@@ -0,0 +1 @@
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\license

View File

@@ -0,0 +1,127 @@
package com.ruoyi.web.controller.system.license;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.license.domain.SysLicense;
import com.ruoyi.license.service.ISysLicenseService;
import com.ruoyi.license.util.LicenseUtils;
/**
* 授权码Controller
*
* @author ruoyi
*/
@RestController
@RequestMapping("/system/license")
public class SysLicenseController extends BaseController
{
@Autowired
private ISysLicenseService sysLicenseService;
/**
* 查询授权码列表
*/
@PreAuthorize("@ss.hasPermi('system:license:list')")
@GetMapping("/list")
public TableDataInfo list(SysLicense sysLicense)
{
startPage();
List<SysLicense> list = sysLicenseService.selectSysLicenseList(sysLicense);
return getDataTable(list);
}
/**
* 新增授权码
*/
@PreAuthorize("@ss.hasPermi('system:license:add')")
@Log(title = "授权码", businessType = BusinessType.INSERT)
@PostMapping("/add")
public AjaxResult add(SysLicense sysLicense)
{
return toAjax(sysLicenseService.insertSysLicense(sysLicense));
}
/**
* 修改授权码
*/
@PreAuthorize("@ss.hasPermi('system:license:edit')")
@Log(title = "授权码", businessType = BusinessType.UPDATE)
@PostMapping("/edit")
public AjaxResult edit(SysLicense sysLicense)
{
return toAjax(sysLicenseService.updateSysLicense(sysLicense));
}
/**
* 删除授权码
*/
@PreAuthorize("@ss.hasPermi('system:license:remove')")
@Log(title = "授权码", businessType = BusinessType.DELETE)
@PostMapping("/remove")
public AjaxResult remove(String ids)
{
return toAjax(sysLicenseService.deleteSysLicenseByIds(ids));
}
/**
* 激活授权码(无需登录)
*/
@Log(title = "激活授权码", businessType = BusinessType.UPDATE)
@PostMapping("/active")
public AjaxResult active(String licenseCode)
{
if (!LicenseUtils.validateFormat(licenseCode))
{
return error("授权码格式不正确");
}
int result = sysLicenseService.activeLicense(licenseCode);
if (result > 0)
{
return success("授权码激活成功");
}
return error("授权码激活失败,请检查授权码是否正确或已过期");
}
/**
* 获取当前授权信息
* license 未开启或无有效授权时返回 code=200 data=null,前端静默处理
*/
@PreAuthorize("@ss.hasPermi('system:license:list')")
@GetMapping("/info")
public AjaxResult info()
{
SysLicense license = sysLicenseService.getActiveLicense();
return AjaxResult.success(license);
}
/**
* 生成授权码
*/
@PreAuthorize("@ss.hasPermi('system:license:add')")
@GetMapping("/generate")
public AjaxResult generate(String type, String projectNo)
{
if (StringUtils.isEmpty(type) || StringUtils.isEmpty(projectNo))
{
return error("授权类型和项目编号不能为空");
}
String licenseCode = LicenseUtils.generateLicense(type, projectNo);
if (licenseCode == null)
{
return error("授权码生成失败");
}
return AjaxResult.success("授权码生成成功", licenseCode);
}
}

View File

@@ -0,0 +1 @@
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license

View File

@@ -0,0 +1,270 @@
# 授权码模块集成改动说明
> 本文档记录引入 `ruoyi-license` 授权码模块时,对原有项目文件的所有改动(不含 `ruoyi-license` 模块本身及 `SysLicenseController`)。
---
## 1. 根模块 `pom.xml`
**文件路径:** `wms/pom.xml`
**改动内容:**
1. `<modules>` 中注册 `ruoyi-license` 子模块:
```xml
<modules>
<module>ruoyi-admin</module>
<module>ruoyi-framework</module>
<module>ruoyi-system</module>
<module>ruoyi-quartz</module>
<module>ruoyi-generator</module>
<module>ruoyi-common</module>
<module>ruoyi-license</module> <!-- 新增 -->
</modules>
```
2. `<dependencyManagement>` 中声明 `ruoyi-license` 依赖版本:
```xml
<!-- 授权码模块 -->
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-license</artifactId>
<version>${ruoyi.version}</version>
</dependency>
```
---
## 2. `ruoyi-admin/pom.xml`
**文件路径:** `wms/ruoyi-admin/pom.xml`
**改动内容:**
引入 `ruoyi-license` 依赖,使授权拦截器和配置能被 Spring Boot 扫描加载:
```xml
<!-- 授权码模块 -->
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-license</artifactId>
</dependency>
```
---
## 3. `SecurityConfig.java`
**文件路径:** `wms/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java`
**改动内容:**
在 Spring Security 匿名白名单中加入 `/system/license/active`,允许未登录用户调用激活授权码接口:
```java
// 改动前
requests.antMatchers("/login", "/register", "/captchaImage").permitAll()
// 改动后
requests.antMatchers("/login", "/register", "/captchaImage", "/system/license/active").permitAll()
```
---
## 4. 前端 `api/license.js`(新建)
**文件路径:** `wms/ui/src/api/license.js`
**改动内容:**
新建文件,封装授权码相关接口:
```js
import request from '@/utils/request'
// 激活授权码(无需登录)
export function activeLicense(licenseCode) {
return request({
url: '/system/license/active',
method: 'post',
params: { licenseCode }
})
}
// 查询当前授权信息(需登录)
export function getLicenseInfo() {
return request({
url: '/system/license/info',
method: 'get'
})
}
```
---
## 5. 登录页 `login.vue`
**文件路径:** `wms/ui/src/views/login.vue`
**改动内容:**
### 5.1 模板部分
在登录按钮下方新增"激活授权码"按钮,以及激活弹窗:
```html
<!-- 激活授权码按钮 -->
<el-form-item style="width:100%;">
<el-button
size="medium"
type="warning"
style="width:100%;"
@click.native.prevent="openLicenseDialog"
>激活授权码</el-button>
</el-form-item>
<!-- 激活授权码弹窗 -->
<el-dialog title="激活授权码" :visible.sync="licenseDialogVisible" width="420px" append-to-body>
<el-input v-model="licenseCode" type="textarea" :rows="4" placeholder="请输入授权码" />
<span slot="footer">
<el-button @click="licenseDialogVisible = false">取 消</el-button>
<el-button type="primary" :loading="licenseLoading" @click="handleActiveLicense">激 活</el-button>
</span>
</el-dialog>
```
### 5.2 Script 部分
新增 import、data 字段和方法:
```js
// 新增 import
import { activeLicense } from "@/api/license"
// 新增 data 字段
licenseDialogVisible: false,
licenseCode: '',
licenseLoading: false
// 新增方法
openLicenseDialog() {
this.licenseCode = ''
this.licenseDialogVisible = true
},
handleActiveLicense() {
if (!this.licenseCode.trim()) {
this.$message.warning('请输入授权码')
return
}
this.licenseLoading = true
activeLicense(this.licenseCode.trim()).then(res => {
if (res.code === 200) {
this.$message.success(res.msg || '授权码激活成功')
this.licenseDialogVisible = false
} else {
this.$message.error(res.msg || '授权码激活失败')
}
}).catch(() => {
this.$message.error('授权码激活失败')
}).finally(() => {
this.licenseLoading = false
})
}
```
---
## 6. 顶部导航栏 `Navbar.vue`
**文件路径:** `wms/ui/src/layout/components/Navbar.vue`
**改动内容:**
### 6.1 模板部分
在 navbar 中间插入授权到期警告条(剩余不足 30 天时显示):
```html
<div v-if="licenseWarning" class="license-warning">
<i class="el-icon-warning-outline" />
系统授权将于 {{ licenseExpireText }} 后过期,请及时联系管理员续期
</div>
```
### 6.2 Script 部分
```js
// 新增 import
import { getLicenseInfo } from '@/api/license'
// 新增 data 字段
licenseWarning: false,
licenseExpireText: '',
licenseTimer: null
// 新增生命周期钩子
mounted() {
this.checkLicenseExpire()
this.licenseTimer = setInterval(() => this.checkLicenseExpire(), 60 * 1000)
},
beforeDestroy() {
if (this.licenseTimer) clearInterval(this.licenseTimer)
},
// 新增方法
checkLicenseExpire() {
getLicenseInfo().then(res => {
if (res.code === 200 && res.data && res.data.expireTime) {
const expireTime = new Date(res.data.expireTime).getTime()
const now = Date.now()
const diffMs = expireTime - now
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
if (diffMs > 0 && diffDays <= 30) {
this.licenseWarning = true
if (diffMs < 1000 * 60 * 60 * 24) {
const hours = Math.floor(diffMs / (1000 * 60 * 60))
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
this.licenseExpireText = hours > 0 ? `${hours} 小时 ${minutes} 分钟` : `${minutes} 分钟`
} else if (diffDays < 7) {
this.licenseExpireText = diffDays + ' 天'
} else {
this.licenseExpireText = Math.ceil(diffDays / 7) + ' 周'
}
}
}
}).catch(() => {})
}
```
### 6.3 样式部分
```scss
.license-warning {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: 0;
height: 50px;
line-height: 50px;
font-size: 13px;
color: #e6a23c;
font-weight: 500;
pointer-events: none;
i { margin-right: 4px; }
}
```
---
## 改动文件汇总
| 文件 | 类型 | 说明 |
|---|---|---|
| `wms/pom.xml` | 修改 | 注册模块 + 声明依赖版本 |
| `wms/ruoyi-admin/pom.xml` | 修改 | 引入 ruoyi-license 依赖 |
| `ruoyi-framework/.../SecurityConfig.java` | 修改 | 白名单加入激活授权码接口 |
| `ui/src/api/license.js` | 新建 | 封装激活和查询授权信息接口 |
| `ui/src/views/login.vue` | 修改 | 新增激活授权码按钮和弹窗 |
| `ui/src/layout/components/Navbar.vue` | 修改 | 新增授权到期倒计时警告条 |

View File

@@ -0,0 +1,250 @@
# 授权码模块部署检查清单
## 📋 部署前检查
### 1. 文件完整性检查
- [ ] ruoyi-license 模块目录存在
- [ ] pom.xml 已添加 license 模块
- [ ] ruoyi-admin/pom.xml 已添加 license 依赖
- [ ] SysLicenseController.java 已创建
- [ ] license.html 和 active.html 已创建
- [ ] login.html 和 login.js 已修改
- [ ] sys_license.sql 脚本已准备
### 2. 数据库准备
- [ ] 创建 sys_license 表
- [ ] 插入授权管理菜单数据
- [ ] 验证表结构正确
- [ ] 检查菜单权限配置
### 3. 代码配置
- [ ] 修改 LicenseUtils.java 中的密钥(生产环境必须)
- [ ] 确认拦截器路径配置正确
- [ ] 确认定时任务cron表达式
- [ ] 启动类添加 @EnableScheduling 注解
## 🔧 部署步骤
### 步骤1: 执行数据库脚本
```bash
mysql -u root -p your_database < sql/sys_license.sql
```
- [ ] 执行成功
- [ ] 表创建成功
- [ ] 菜单数据插入成功
### 步骤2: 修改启动类
```java
@SpringBootApplication
@EnableScheduling // 添加此注解
public class RuoYiApplication {
// ...
}
```
- [ ] 注解已添加
### 步骤3: 编译项目
```bash
mvn clean install
```
- [ ] 编译成功
- [ ] 无错误信息
- [ ] JAR包生成
### 步骤4: 启动应用
```bash
java -jar ruoyi-admin.jar
```
- [ ] 启动成功
- [ ] 无异常日志
- [ ] 端口正常监听
### 步骤5: 生成授权码
**方式A: 使用独立工具**
```bash
java com.ruoyi.license.util.LicenseGenerator
```
- [ ] 工具运行成功
- [ ] 授权码生成成功
- [ ] 授权码已保存
**方式B: 使用系统界面**
- [ ] 登录系统
- [ ] 访问授权管理
- [ ] 点击生成授权码
- [ ] 输入天数并生成
### 步骤6: 激活授权码
- [ ] 访问登录页
- [ ] 点击"激活授权码"
- [ ] 输入授权码
- [ ] 激活成功
## ✅ 功能测试
### 测试1: 授权码生成
- [ ] 系统界面生成授权码成功
- [ ] 独立工具生成授权码成功
- [ ] 授权码格式正确
### 测试2: 授权码激活
- [ ] 登录页激活成功
- [ ] 系统内激活成功
- [ ] 数据库记录正确
### 测试3: 授权验证
- [ ] 有效授权可正常登录
- [ ] 无授权无法登录
- [ ] 过期授权无法登录
### 测试4: 授权拦截
- [ ] 未授权访问被拦截
- [ ] 有效授权可正常访问
- [ ] 静态资源不被拦截
### 测试5: 定时检查
- [ ] 定时任务正常执行
- [ ] 授权过期自动更新状态
- [ ] 在线用户被踢出
### 测试6: 授权管理
- [ ] 授权列表查询正常
- [ ] 授权信息显示正确
- [ ] 授权删除功能正常
## 🔍 问题排查
### 问题1: 编译失败
**检查项:**
- [ ] Maven依赖是否正确
- [ ] JDK版本是否匹配
- [ ] pom.xml配置是否正确
**解决方案:**
```bash
mvn clean
mvn dependency:resolve
mvn install
```
### 问题2: 启动失败
**检查项:**
- [ ] 数据库连接是否正常
- [ ] 端口是否被占用
- [ ] 配置文件是否正确
**解决方案:**
- 检查 application.yml 配置
- 查看启动日志错误信息
- 确认数据库表已创建
### 问题3: 无法激活授权码
**检查项:**
- [ ] 授权码格式是否正确
- [ ] 授权码是否已过期
- [ ] 密钥是否一致
**解决方案:**
- 重新生成授权码
- 检查 LicenseUtils 密钥配置
- 查看后台错误日志
### 问题4: 授权验证不生效
**检查项:**
- [ ] 拦截器是否注册
- [ ] 数据库授权记录是否正确
- [ ] 定时任务是否执行
**解决方案:**
- 检查 LicenseConfig 配置
- 验证数据库 status 字段
- 确认 @EnableScheduling 已添加
### 问题5: 在线用户未被踢出
**检查项:**
- [ ] SessionDAO 是否注入
- [ ] 定时任务是否执行
- [ ] Shiro配置是否正确
**解决方案:**
- 检查 LicenseCheckTask 日志
- 验证 SessionDAO bean
- 重启应用服务器
## 📊 监控指标
### 运行时监控
- [ ] 授权状态正常
- [ ] 定时任务执行正常
- [ ] 无异常日志
- [ ] 内存使用正常
- [ ] CPU使用正常
### 业务监控
- [ ] 授权激活次数
- [ ] 授权过期提醒
- [ ] 用户登录成功率
- [ ] 授权拦截次数
## 🔐 安全检查
### 配置安全
- [ ] 生产环境已修改默认密钥
- [ ] 数据库密码已加密
- [ ] 授权码传输使用HTTPS
- [ ] 敏感日志已脱敏
### 权限安全
- [ ] 授权管理菜单仅管理员可见
- [ ] 授权激活操作有日志记录
- [ ] 授权码生成有权限控制
- [ ] 数据库访问有权限限制
### 数据安全
- [ ] 授权码加密存储
- [ ] 定期备份授权数据
- [ ] 授权日志完整保存
- [ ] 敏感信息不输出到日志
## 📝 上线清单
### 上线前
- [ ] 所有测试通过
- [ ] 代码已提交版本库
- [ ] 数据库脚本已准备
- [ ] 部署文档已编写
- [ ] 回滚方案已准备
### 上线中
- [ ] 备份现有数据
- [ ] 执行数据库脚本
- [ ] 部署新版本代码
- [ ] 生成初始授权码
- [ ] 激活授权码
### 上线后
- [ ] 验证功能正常
- [ ] 检查日志无异常
- [ ] 监控系统运行状态
- [ ] 通知相关人员
- [ ] 更新运维文档
## 📞 应急联系
### 技术支持
- 开发负责人: ___________
- 运维负责人: ___________
- 数据库管理员: ___________
### 应急预案
1. 授权失效 -> 立即激活新授权码
2. 系统无法登录 -> 检查授权状态
3. 数据丢失 -> 从备份恢复
4. 性能问题 -> 调整定时任务频率
---
**检查人**: ___________
**检查日期**: ___________
**部署环境**: [ ] 开发 [ ] 测试 [ ] 生产
**部署状态**: [ ] 成功 [ ] 失败 [ ] 部分成功

View File

@@ -0,0 +1,231 @@
# RuoYi 授权码模块集成指南
## 一、模块说明
本授权码模块已完成开发,包含以下文件:
```
ruoyi-license/ # 授权模块(独立模块)
├── src/main/java/com/ruoyi/license/
│ ├── config/
│ │ └── LicenseConfig.java # 拦截器配置
│ ├── domain/
│ │ └── SysLicense.java # 授权实体
│ ├── interceptor/
│ │ └── LicenseInterceptor.java # 授权拦截器
│ ├── mapper/
│ │ └── SysLicenseMapper.java # 数据访问接口
│ ├── service/
│ │ ├── ISysLicenseService.java # 服务接口
│ │ └── impl/
│ │ └── SysLicenseServiceImpl.java # 服务实现
│ ├── task/
│ │ └── LicenseCheckTask.java # 定时检查任务
│ └── util/
│ ├── LicenseUtils.java # 授权工具类
│ └── LicenseGenerator.java # 授权码生成器
├── src/main/resources/mapper/license/
│ └── SysLicenseMapper.xml # MyBatis映射
├── pom.xml # 模块依赖
└── README.md # 使用说明
sql/
└── sys_license.sql # 数据库脚本
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/license/
└── SysLicenseController.java # 授权控制器
ruoyi-admin/src/main/resources/templates/system/license/
├── license.html # 授权列表页
└── active.html # 激活页面
修改的文件:
- pom.xml # 添加license模块
- ruoyi-admin/pom.xml # 添加license依赖
- login.html # 添加激活入口
- login.js # 添加激活功能
```
## 二、部署步骤
### 1. 执行数据库脚本
```bash
# 在MySQL中执行
mysql -u root -p your_database < sql/sys_license.sql
```
或手动执行 `sql/sys_license.sql` 中的SQL语句。
### 2. 启用定时任务
在启动类上添加 `@EnableScheduling` 注解:
```java
@SpringBootApplication
@EnableScheduling // 添加此注解
public class RuoYiApplication {
public static void main(String[] args) {
SpringApplication.run(RuoYiApplication.class, args);
}
}
```
### 3. 编译项目
```bash
cd d:\fortucky\project\RuoYi-master
mvn clean install
```
### 4. 生成授权码
**方式一:使用独立工具生成**
```bash
cd ruoyi-license/src/main/java
javac com/ruoyi/license/util/LicenseUtils.java
javac com/ruoyi/license/util/LicenseGenerator.java
java com.ruoyi.license.util.LicenseGenerator
```
**方式二:启动系统后在界面生成**
- 登录系统
- 进入"系统管理 -> 授权管理"
- 点击"生成授权码"按钮
### 5. 激活授权码
**首次激活(登录页):**
1. 访问登录页
2. 点击"激活授权码"链接
3. 输入生成的授权码
4. 提交激活
**系统内激活:**
1. 登录系统(需要先有有效授权)
2. 进入"系统管理 -> 授权管理"
3. 点击"激活授权码"
4. 输入新的授权码
## 三、功能验证
### 1. 验证授权拦截
- 未激活授权码时,访问任何页面应跳转到登录页并提示"授权已过期"
- 激活授权码后,可正常访问系统
### 2. 验证定时检查
- 修改数据库中授权码的 `expire_time` 为过去时间
- 等待5分钟(或重启系统)
- 在线用户应被强制退出
- 尝试登录应提示授权过期
### 3. 验证授权管理
- 访问"系统管理 -> 授权管理"
- 查看授权列表
- 生成新授权码
- 激活授权码
- 删除过期授权
## 四、复用到其他项目
### 方式一:Maven依赖(推荐)
1. 将 `ruoyi-license` 模块复制到目标项目
2. 在父pom.xml添加模块声明
3. 在需要的模块添加依赖
4. 执行数据库脚本
5. 复制Controller和前端页面
### 方式二:JAR包依赖
1. 打包license模块:
```bash
cd ruoyi-license
mvn clean package
```
2. 安装到本地仓库:
```bash
mvn install:install-file -Dfile=target/ruoyi-license-4.8.2.jar \
-DgroupId=com.ruoyi -DartifactId=ruoyi-license \
-Dversion=4.8.2 -Dpackaging=jar
```
3. 在目标项目添加依赖:
```xml
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-license</artifactId>
<version>4.8.2</version>
</dependency>
```
## 五、配置说明
### 1. 修改密钥(重要)
生产环境必须修改默认密钥:
```java
// LicenseUtils.java
private static final String SECRET_KEY = "YourCustomKey123"; // 改为16位自定义密钥
```
### 2. 调整检查频率
```java
// LicenseCheckTask.java
@Scheduled(cron = "0 */5 * * * ?") // 每5分钟,可根据需要调整
```
### 3. 自定义拦截路径
```java
// LicenseConfig.java
.excludePathPatterns(
"/login",
"/your-path/**" // 添加需要排除的路径
)
```
## 六、注意事项
1. **首次部署**:必须先生成并激活授权码,否则无法登录
2. **时间同步**:确保服务器时间准确,避免授权判断错误
3. **密钥安全**:生产环境务必修改默认密钥
4. **数据备份**:定期备份 `sys_license` 表数据
5. **权限控制**:授权管理菜单仅授予超级管理员
## 七、故障排查
### 问题1:无法激活授权码
- 检查授权码格式是否正确
- 检查授权码是否已过期
- 查看后台日志错误信息
### 问题2:授权有效但仍被拦截
- 检查数据库中授权状态是否为"1"
- 检查 `expire_time` 是否大于当前时间
- 重启应用服务器
### 问题3:定时任务不执行
- 确认启动类添加了 `@EnableScheduling`
- 检查日志是否有定时任务执行记录
### 问题4:在线用户未被踢出
- 检查 `SessionDAO` 是否正确注入
- 查看定时任务是否正常执行
- 检查Shiro配置是否正确
## 八、技术支持
如遇到问题,请提供以下信息:
1. 错误日志
2. 数据库授权记录
3. 系统环境信息
4. 操作步骤
联系方式:参考RuoYi官方文档

View File

@@ -0,0 +1,168 @@
# 授权码全局拦截配置说明
## 功能说明
通过自定义 Shiro Filter,实现授权码失效时,**所有接口**(包括 `anon` 匿名访问的接口)都无法访问。
## 实现原理
1. 创建 `LicenseValidateFilter` 继承 `AccessControlFilter`
2. 在 Shiro 过滤链的**最前面**添加此过滤器
3. 所有请求都会先经过授权码验证,验证失败则拒绝访问
## 已完成的修改
### 1. 新增文件
- `ruoyi-framework/src/main/java/com/ruoyi/framework/shiro/web/filter/license/LicenseValidateFilter.java`
### 2. 修改文件
- `ruoyi-framework/src/main/java/com/ruoyi/framework/config/ShiroConfig.java`
- 导入 `LicenseValidateFilter`
- 注册过滤器:`filters.put("licenseValidate", licenseValidateFilter())`
- 添加到过滤链:`filterChainDefinitionMap.put("/**", "licenseValidate,user,kickout,onlineSession,syncOnlineSession")`
## 白名单配置
以下路径即使授权失效也可以访问(必要的登录和静态资源):
```java
private static final String[] WHITE_LIST = {
"/login", // 登录页
"/captcha/captchaImage", // 验证码
"/css/", // 样式文件
"/js/", // 脚本文件
"/img/", // 图片资源
"/fonts/", // 字体文件
"/ajax/", // Ajax资源
"/ruoyi/", // 若依资源
"/favicon.ico", // 网站图标
"/system/license/active" // 授权激活页面(必须放行)
};
```
## 自定义白名单
如果需要添加其他路径到白名单,修改 `LicenseValidateFilter.java`:
```java
private static final String[] WHITE_LIST = {
// ... 原有配置
"/your-custom-path/**", // 添加你的自定义路径
};
```
## 拦截效果
### 授权有效时
- 所有接口正常访问(包括 `/board/**`、`/agv/api/**` 等 anon 接口)
### 授权失效时
- **普通请求**:重定向到 `/login?license=expired`
- **Ajax请求**:返回 JSON `{"code":401,"msg":"授权已过期,请联系管理员激活授权码"}`
- **白名单路径**:仍可访问(用于激活授权码)
## 测试步骤
### 1. 启用授权功能
在 `application.yml` 中配置:
```yaml
license:
enabled: true
```
### 2. 配置项目编号
在数据库 `sys_config` 表中添加:
```sql
INSERT INTO sys_config (config_name, config_key, config_value, config_type, create_by, create_time, remark)
VALUES ('项目编号', 'fortucky.project.no', 'FT507', 'Y', 'admin', NOW(), '用于授权码验证');
```
### 3. 测试授权失效场景
**方式一:不激活授权码**
- 启动系统
- 访问 `/board/**` 接口
- 预期:被拦截,重定向到登录页
**方式二:授权码过期**
- 生成5分钟测试授权码并激活
- 等待5分钟后访问 `/board/**` 接口
- 预期:被拦截
### 4. 测试授权有效场景
- 生成并激活有效授权码
- 访问 `/board/**` 接口
- 预期:正常访问
## 注意事项
1. **白名单必须包含授权激活页面**
- `/system/license/active` 必须在白名单中
- 否则授权失效后无法激活新授权码
2. **静态资源必须放行**
- CSS、JS、图片等资源必须在白名单
- 否则登录页面无法正常显示
3. **过滤器顺序很重要**
- `licenseValidate` 必须在最前面
- 确保所有请求都先经过授权验证
4. **开发环境可关闭授权**
```yaml
license:
enabled: false # 开发时可关闭
```
## 与原有拦截器的区别
### 原有 LicenseInterceptor(Spring MVC 拦截器)
- 在 Spring MVC 层拦截
- 只拦截进入 Controller 的请求
- 无法拦截 Shiro 已放行的 `anon` 接口
### 新增 LicenseValidateFilter(Shiro 过滤器)
- 在 Shiro 层拦截
- 拦截所有请求(包括 `anon` 接口)
- 优先级高于 Shiro 的其他过滤器
## 建议
1. **保留两个拦截器**
- `LicenseValidateFilter`:全局拦截(包括 anon 接口)
- `LicenseInterceptor`:作为二次验证(可选)
2. **生产环境必须启用**
```yaml
license:
enabled: true
```
3. **定期检查白名单**
- 确保白名单路径最小化
- 避免过度放行
## 故障排查
### 问题1:所有请求都被拦截
- 检查 `license.enabled` 是否为 `true`
- 检查是否有有效授权码
- 查看后台日志 `[授权验证]` 相关信息
### 问题2:anon 接口仍可访问
- 检查过滤器是否注册:`filters.put("licenseValidate", ...)`
- 检查过滤链配置:`"licenseValidate,user,..."`
- 确认 `licenseValidate` 在最前面
### 问题3:无法激活授权码
- 检查 `/system/license/active` 是否在白名单
- 检查白名单匹配逻辑是否正确
## 完成标志
✅ 授权失效时,`/board/**` 接口无法访问
✅ 授权失效时,`/agv/api/**` 接口无法访问
✅ 授权失效时,仍可访问登录页和激活页
✅ 授权有效时,所有接口正常访问
✅ Ajax 请求返回 JSON 错误信息
✅ 普通请求重定向到登录页

View File

@@ -0,0 +1,193 @@
# RuoYi 授权码模块
## 模块简介
ruoyi-license 是一个独立的授权码管理模块,用于控制系统的使用期限。当授权码过期后,系统将无法登录,已登录用户会被强制退出。
## 功能特性
- ✅ 授权码加密存储
- ✅ 登录时验证授权有效性
- ✅ 定时检查授权状态(每5分钟)
- ✅ 授权过期自动踢出在线用户
- ✅ 系统管理界面查看/激活授权
- ✅ 登录页面激活授权码
- ✅ 授权码生成工具
- ✅ 独立模块,方便其他项目复用
## 快速开始
### 1. 执行数据库脚本
```sql
-- 执行 sql/sys_license.sql 创建授权表和菜单
```
### 2. 添加模块依赖
在需要使用授权功能的项目中,添加依赖:
```xml
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-license</artifactId>
<version>4.8.2</version>
</dependency>
```
### 3. 配置包扫描
确保Spring能扫描到授权模块的包:
```java
@SpringBootApplication
@ComponentScan(basePackages = {"com.ruoyi"})
public class RuoYiApplication {
// ...
}
```
### 4. 生成授权码
访问系统管理 -> 授权管理,点击"生成授权码"按钮,输入授权天数(如365天),系统会生成加密的授权码。
### 5. 激活授权码
**方式一:登录页激活**
- 在登录页面点击"激活授权码"链接
- 输入授权码并提交
**方式二:系统内激活**
- 登录系统后,进入"系统管理 -> 授权管理"
- 点击"激活授权码"按钮
- 输入授权码并提交
## 核心类说明
### 1. 实体类
- `SysLicense`: 授权码实体,包含授权码、开始时间、结束时间、状态等信息
### 2. 工具类
- `LicenseUtils`: 授权码工具类
- `generateLicense()`: 生成授权码
- `encrypt()`: 加密数据
- `decrypt()`: 解密数据
- `validateFormat()`: 验证授权码格式
### 3. 服务类
- `ISysLicenseService`: 授权服务接口
- `activeLicense()`: 激活授权码
- `checkLicenseValid()`: 检查授权是否有效
- `getActiveLicense()`: 获取当前有效授权
### 4. 拦截器
- `LicenseInterceptor`: 授权拦截器,在每次请求时验证授权有效性
### 5. 定时任务
- `LicenseCheckTask`: 每5分钟检查授权状态,过期自动踢出在线用户
## 授权码格式
授权码采用AES加密,格式为:
```
加密(开始时间戳|结束时间戳)
```
示例:
```
原始数据: 1704067200000|1735689600000
加密后: Xj8K9mN2pQ5rT7vW1yZ3aB4cD6eF8gH0iJ2kL4mN6oP8qR0sT2uV4wX6yZ8
```
## 集成到其他项目
### 步骤1:复制模块
将 `ruoyi-license` 文件夹复制到目标项目根目录
### 步骤2:修改pom.xml
在父pom.xml中添加:
```xml
<modules>
<module>ruoyi-license</module>
</modules>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-license</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
```
### 步骤3:添加依赖
在启动模块的pom.xml中添加:
```xml
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-license</artifactId>
</dependency>
```
### 步骤4:执行SQL脚本
执行 `sql/sys_license.sql` 创建数据库表
### 步骤5:配置Controller
将 `SysLicenseController.java` 复制到项目的controller包中
### 步骤6:配置前端页面
将 `templates/system/license/` 目录复制到项目的templates目录
### 步骤7:修改登录页面
参考 `login.html` 和 `login.js` 的修改,添加授权码激活入口
## 自定义配置
### 修改密钥
编辑 `LicenseUtils.java`:
```java
private static final String SECRET_KEY = "YourSecretKey123"; // 16位密钥
```
### 修改检查频率
编辑 `LicenseCheckTask.java`:
```java
@Scheduled(cron = "0 */10 * * * ?") // 改为每10分钟检查
```
### 自定义拦截规则
编辑 `LicenseConfig.java`,添加或排除拦截路径:
```java
.excludePathPatterns(
"/login",
"/your-custom-path/**"
)
```
## 注意事项
1. **密钥安全**:生产环境务必修改默认密钥
2. **时间同步**:确保服务器时间准确
3. **数据备份**:定期备份授权表数据
4. **权限控制**:授权管理菜单仅授予管理员
5. **测试环境**:建议先在测试环境验证功能
## 常见问题
**Q: 授权码激活失败?**
A: 检查授权码格式是否正确,是否已过期
**Q: 如何延长授权期限?**
A: 生成新的授权码并激活即可
**Q: 授权过期后如何恢复?**
A: 在登录页点击"激活授权码",输入新的授权码
**Q: 可以同时激活多个授权码吗?**
A: 系统只使用最新激活且未过期的授权码
## 技术支持
如有问题,请联系技术支持或查看RuoYi官方文档。

View File

@@ -0,0 +1,239 @@
# RuoYi 授权码模块 - 项目总结
## 📦 已完成的工作
### 1. 核心模块开发 ✅
**ruoyi-license 独立模块**
- ✅ Maven模块配置 (pom.xml)
- ✅ 授权实体类 (SysLicense.java)
- ✅ 数据访问层 (SysLicenseMapper.java + XML)
- ✅ 业务逻辑层 (ISysLicenseService.java + Impl)
- ✅ 授权工具类 (LicenseUtils.java)
- ✅ 授权拦截器 (LicenseInterceptor.java)
- ✅ 拦截器配置 (LicenseConfig.java)
- ✅ 定时检查任务 (LicenseCheckTask.java)
- ✅ 授权码生成器 (LicenseGenerator.java)
- ✅ 测试工具 (LicenseTest.java)
### 2. 控制器和前端 ✅
**后端控制器**
- ✅ SysLicenseController.java (授权管理控制器)
- 授权列表查询
- 授权码激活
- 授权码生成
- 授权信息查看
- 授权删除
**前端页面**
- ✅ license.html (授权列表页)
- ✅ active.html (激活页面)
- ✅ login.html (添加激活入口)
- ✅ login.js (添加激活功能)
### 3. 数据库脚本 ✅
- ✅ sys_license.sql
- 授权表结构
- 菜单权限配置
### 4. 项目集成 ✅
- ✅ 主pom.xml (添加license模块)
- ✅ ruoyi-admin/pom.xml (添加license依赖)
### 5. 文档编写 ✅
- ✅ README.md (使用说明)
- ✅ INTEGRATION.md (集成指南)
## 🎯 功能特性
### 核心功能
1. **授权码加密存储** - AES加密,安全可靠
2. **登录验证** - 登录时自动验证授权有效性
3. **拦截器保护** - 所有请求都经过授权验证
4. **定时检查** - 每5分钟自动检查授权状态
5. **自动踢出** - 授权过期自动踢出所有在线用户
6. **管理界面** - 完整的授权管理功能
7. **登录页激活** - 无需登录即可激活授权码
8. **授权码生成** - 内置生成工具,支持自定义天数
### 技术亮点
- 🔒 AES加密算法保护授权码
- 🔄 Spring拦截器实现全局授权验证
- ⏰ Spring定时任务自动检查授权
- 🎨 完整的前后端交互界面
- 📦 独立模块设计,方便复用
- 🛠️ 提供测试工具和生成工具
## 📂 文件清单
```
ruoyi-license/
├── src/main/java/com/ruoyi/license/
│ ├── config/
│ │ └── LicenseConfig.java (拦截器配置)
│ ├── domain/
│ │ └── SysLicense.java (授权实体)
│ ├── interceptor/
│ │ └── LicenseInterceptor.java (授权拦截器)
│ ├── mapper/
│ │ └── SysLicenseMapper.java (Mapper接口)
│ ├── service/
│ │ ├── ISysLicenseService.java (服务接口)
│ │ └── impl/
│ │ └── SysLicenseServiceImpl.java (服务实现)
│ ├── task/
│ │ └── LicenseCheckTask.java (定时任务)
│ └── util/
│ ├── LicenseUtils.java (工具类)
│ ├── LicenseGenerator.java (生成器)
│ └── LicenseTest.java (测试工具)
├── src/main/resources/mapper/license/
│ └── SysLicenseMapper.xml (MyBatis映射)
├── pom.xml (模块配置)
├── README.md (使用说明)
└── INTEGRATION.md (集成指南)
sql/
└── sys_license.sql (数据库脚本)
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/license/
└── SysLicenseController.java (控制器)
ruoyi-admin/src/main/resources/templates/system/license/
├── license.html (列表页)
└── active.html (激活页)
修改的文件:
├── pom.xml (添加模块)
├── ruoyi-admin/pom.xml (添加依赖)
├── ruoyi-admin/src/main/resources/templates/login.html
└── ruoyi-admin/src/main/resources/static/ruoyi/login.js
```
## 🚀 快速开始
### 1. 执行数据库脚本
```sql
source sql/sys_license.sql;
```
### 2. 启用定时任务
在 RuoYiApplication.java 添加 @EnableScheduling
### 3. 编译项目
```bash
mvn clean install
```
### 4. 生成授权码
运行 LicenseGenerator.java 或在系统界面生成
### 5. 激活授权码
登录页点击"激活授权码"输入授权码
## 🔧 使用场景
### 场景1:软件交付
- 开发完成后生成授权码
- 交付给客户时提供授权码
- 客户激活后可使用指定期限
### 场景2:试用版本
- 生成30天试用授权码
- 客户试用满意后购买正式授权
- 激活正式授权码延长使用期限
### 场景3:订阅服务
- 按年生成授权码
- 到期前提醒客户续费
- 续费后激活新授权码
### 场景4:多项目复用
- 将license模块复制到其他项目
- 修改密钥和配置
- 快速实现授权功能
## ⚙️ 自定义配置
### 修改密钥(必须)
```java
// LicenseUtils.java
private static final String SECRET_KEY = "YourKey16Chars!!";
```
### 调整检查频率
```java
// LicenseCheckTask.java
@Scheduled(cron = "0 */10 * * * ?") // 改为10分钟
```
### 自定义拦截路径
```java
// LicenseConfig.java
.excludePathPatterns("/api/**", "/public/**")
```
## 📊 数据库表结构
```sql
sys_license (授权表)
├── license_id (授权ID - 主键)
├── license_code (授权码 - 加密存储)
├── start_time (开始时间)
├── expire_time (结束时间)
├── status (状态: 0未激活 1已激活 2已过期)
├── active_time (激活时间)
├── active_by (激活人)
├── create_by (创建者)
├── create_time (创建时间)
├── update_by (更新者)
├── update_time (更新时间)
└── remark (备注)
```
## 🔐 安全建议
1. **生产环境必须修改默认密钥**
2. **授权码通过安全渠道传递**
3. **定期备份授权表数据**
4. **限制授权管理菜单权限**
5. **监控授权激活日志**
## 📝 后续优化建议
1. **硬件绑定** - 绑定服务器MAC地址或CPU序列号
2. **在线验证** - 连接授权服务器在线验证
3. **授权分级** - 不同授权码对应不同功能权限
4. **自动续费** - 集成支付接口自动续费
5. **授权统计** - 统计授权使用情况和到期提醒
## ✅ 测试清单
- [ ] 数据库表创建成功
- [ ] 菜单权限配置正确
- [ ] 授权码生成功能正常
- [ ] 授权码加密解密正确
- [ ] 登录页激活功能正常
- [ ] 系统内激活功能正常
- [ ] 授权拦截器生效
- [ ] 定时任务执行正常
- [ ] 授权过期自动踢出
- [ ] 授权列表查询正常
## 📞 技术支持
如有问题,请查看:
1. README.md - 使用说明
2. INTEGRATION.md - 集成指南
3. RuoYi官方文档
---
**开发完成时间**: 2024
**模块版本**: 1.0.0
**适用框架**: RuoYi v4.8.2
**技术栈**: SpringBoot + MyBatis + Shiro + Thymeleaf

View File

@@ -0,0 +1,39 @@
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>ruoyi</artifactId>
<groupId>com.ruoyi</groupId>
<version>3.8.9</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>ruoyi-license</artifactId>
<description>
授权码管理模块
</description>
<dependencies>
<!-- 通用工具-->
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-common</artifactId>
</dependency>
<!-- 系统模块-->
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-system</artifactId>
</dependency>
<!-- SpringBoot Web-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1 @@
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license\src\main\java\com\ruoyi\license\config

View File

@@ -0,0 +1,61 @@
package com.ruoyi.license.config;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import com.ruoyi.framework.config.properties.PermitAllUrlProperties;
import com.ruoyi.license.interceptor.LicenseInterceptor;
/**
* 授权模块配置
*
* @author ruoyi
*/
@Configuration
public class LicenseConfig implements WebMvcConfigurer
{
@Autowired
private LicenseInterceptor licenseInterceptor;
@Autowired
private PermitAllUrlProperties permitAllUrl;
@Value("${license.enabled:false}")
private boolean licenseEnabled;
private static final List<String> BASE_EXCLUDE = Arrays.asList(
"/login",
"/logout",
"/register",
"/captchaImage",
"/system/license/active",
"/swagger-ui.html",
"/swagger-resources/**",
"/webjars/**",
"/*/api-docs",
"/druid/**",
"/*.html",
"/**/*.html",
"/**/*.css",
"/**/*.js",
"/profile/**"
);
@Override
public void addInterceptors(InterceptorRegistry registry)
{
if (licenseEnabled)
{
List<String> excludeList = new ArrayList<>(BASE_EXCLUDE);
excludeList.addAll(permitAllUrl.getUrls());
registry.addInterceptor(licenseInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(excludeList);
}
}
}

View File

@@ -0,0 +1 @@
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license\src\main\java\com\ruoyi\license\domain

View File

@@ -0,0 +1,105 @@
package com.ruoyi.license.domain;
import com.ruoyi.common.core.domain.BaseEntity;
import java.util.Date;
/**
* 系统授权码对象 sys_license
*
* @author ruoyi
*/
public class SysLicense extends BaseEntity
{
private static final long serialVersionUID = 1L;
/** 授权ID */
private Long licenseId;
/** 授权码 */
private String licenseCode;
/** 授权开始时间 */
private Date startTime;
/** 授权结束时间 */
private Date expireTime;
/** 授权状态(0未激活 1已激活 2已过期) */
private String status;
/** 激活时间 */
private Date activeTime;
/** 激活人 */
private String activeBy;
public Long getLicenseId()
{
return licenseId;
}
public void setLicenseId(Long licenseId)
{
this.licenseId = licenseId;
}
public String getLicenseCode()
{
return licenseCode;
}
public void setLicenseCode(String licenseCode)
{
this.licenseCode = licenseCode;
}
public Date getStartTime()
{
return startTime;
}
public void setStartTime(Date startTime)
{
this.startTime = startTime;
}
public Date getExpireTime()
{
return expireTime;
}
public void setExpireTime(Date expireTime)
{
this.expireTime = expireTime;
}
public String getStatus()
{
return status;
}
public void setStatus(String status)
{
this.status = status;
}
public Date getActiveTime()
{
return activeTime;
}
public void setActiveTime(Date activeTime)
{
this.activeTime = activeTime;
}
public String getActiveBy()
{
return activeBy;
}
public void setActiveBy(String activeBy)
{
this.activeBy = activeBy;
}
}

View File

@@ -0,0 +1 @@
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license\src\main\java\com\ruoyi\license\interceptor

View File

@@ -0,0 +1,46 @@
package com.ruoyi.license.interceptor;
import java.io.PrintWriter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import com.ruoyi.license.service.ISysLicenseService;
/**
* 授权拦截器
*
* @author ruoyi
*/
@Component
public class LicenseInterceptor implements HandlerInterceptor
{
@Autowired
private ISysLicenseService licenseService;
@Value("${license.enabled:false}")
private boolean licenseEnabled;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception
{
if (!licenseEnabled)
{
return true;
}
if (!licenseService.checkLicenseValid())
{
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json;charset=UTF-8");
PrintWriter writer = response.getWriter();
writer.write("{\"code\":500,\"msg\":\"\u7cfb\u7edf\u6388\u6743\u5df2\u8fc7\u671f\uff0c\u8bf7\u8054\u7cfb\u7ba1\u7406\u5458\u6fc0\u6d3b\u6388\u6743\u7801\"}");
writer.flush();
return false;
}
return true;
}
}

View File

@@ -0,0 +1 @@
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license\src\main\java\com\ruoyi\license\mapper

View File

@@ -0,0 +1,67 @@
package com.ruoyi.license.mapper;
import com.ruoyi.license.domain.SysLicense;
import java.util.List;
/**
* 授权码Mapper接口
*
* @author ruoyi
*/
public interface SysLicenseMapper
{
/**
* 查询授权码
*
* @param licenseId 授权码ID
* @return 授权码
*/
public SysLicense selectSysLicenseById(Long licenseId);
/**
* 查询授权码列表
*
* @param sysLicense 授权码
* @return 授权码集合
*/
public List<SysLicense> selectSysLicenseList(SysLicense sysLicense);
/**
* 查询当前有效的授权码
*
* @return 授权码
*/
public SysLicense selectActiveLicense();
/**
* 新增授权码
*
* @param sysLicense 授权码
* @return 结果
*/
public int insertSysLicense(SysLicense sysLicense);
/**
* 修改授权码
*
* @param sysLicense 授权码
* @return 结果
*/
public int updateSysLicense(SysLicense sysLicense);
/**
* 删除授权码
*
* @param licenseId 授权码ID
* @return 结果
*/
public int deleteSysLicenseById(Long licenseId);
/**
* 批量删除授权码
*
* @param licenseIds 需要删除的数据ID
* @return 结果
*/
public int deleteSysLicenseByIds(String[] licenseIds);
}

View File

@@ -0,0 +1 @@
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license\src\main\java\com\ruoyi\license\service

View File

@@ -0,0 +1,87 @@
package com.ruoyi.license.service;
import com.ruoyi.license.domain.SysLicense;
import java.util.List;
/**
* 授权码Service接口
*
* @author ruoyi
*/
public interface ISysLicenseService
{
/**
* 查询授权码
*
* @param licenseId 授权码ID
* @return 授权码
*/
public SysLicense selectSysLicenseById(Long licenseId);
/**
* 查询授权码列表
*
* @param sysLicense 授权码
* @return 授权码集合
*/
public List<SysLicense> selectSysLicenseList(SysLicense sysLicense);
/**
* 新增授权码
*
* @param sysLicense 授权码
* @return 结果
*/
public int insertSysLicense(SysLicense sysLicense);
/**
* 修改授权码
*
* @param sysLicense 授权码
* @return 结果
*/
public int updateSysLicense(SysLicense sysLicense);
/**
* 批量删除授权码
*
* @param ids 需要删除的数据ID
* @return 结果
*/
public int deleteSysLicenseByIds(String ids);
/**
* 删除授权码信息
*
* @param licenseId 授权码ID
* @return 结果
*/
public int deleteSysLicenseById(Long licenseId);
/**
* 激活授权码
*
* @param licenseCode 授权码
* @return 结果
*/
public int activeLicense(String licenseCode);
/**
* 检查授权是否有效
*
* @return true有效 false无效
*/
public boolean checkLicenseValid();
/**
* 获取当前有效授权信息
*
* @return 授权信息
*/
public SysLicense getActiveLicense();
/**
* 更新过期授权码状态
*/
public void updateExpiredLicenses();
}

View File

@@ -0,0 +1 @@
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license\src\main\java\com\ruoyi\license\service\impl

View File

@@ -0,0 +1,266 @@
package com.ruoyi.license.service.impl;
import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.common.core.text.Convert;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.license.domain.SysLicense;
import com.ruoyi.license.mapper.SysLicenseMapper;
import com.ruoyi.license.service.ISysLicenseService;
import com.ruoyi.license.util.LicenseUtils;
/**
* 授权码Service业务层处理
*
* @author ruoyi
*/
@Service
public class SysLicenseServiceImpl implements ISysLicenseService
{
@Autowired
private SysLicenseMapper sysLicenseMapper;
@Autowired
private com.ruoyi.system.mapper.SysConfigMapper sysConfigMapper;
@org.springframework.beans.factory.annotation.Value("${license.enabled:false}")
private boolean licenseEnabled;
/**
* 查询授权码
*
* @param licenseId 授权码ID
* @return 授权码
*/
@Override
public SysLicense selectSysLicenseById(Long licenseId)
{
return sysLicenseMapper.selectSysLicenseById(licenseId);
}
/**
* 查询授权码列表
*
* @param sysLicense 授权码
* @return 授权码
*/
@Override
public List<SysLicense> selectSysLicenseList(SysLicense sysLicense)
{
return sysLicenseMapper.selectSysLicenseList(sysLicense);
}
/**
* 新增授权码
*
* @param sysLicense 授权码
* @return 结果
*/
@Override
public int insertSysLicense(SysLicense sysLicense)
{
sysLicense.setCreateTime(new Date());
return sysLicenseMapper.insertSysLicense(sysLicense);
}
/**
* 修改授权码
*
* @param sysLicense 授权码
* @return 结果
*/
@Override
public int updateSysLicense(SysLicense sysLicense)
{
sysLicense.setUpdateTime(new Date());
return sysLicenseMapper.updateSysLicense(sysLicense);
}
/**
* 删除授权码对象
*
* @param ids 需要删除的数据ID
* @return 结果
*/
@Override
public int deleteSysLicenseByIds(String ids)
{
return sysLicenseMapper.deleteSysLicenseByIds(Convert.toStrArray(ids));
}
/**
* 删除授权码信息
*
* @param licenseId 授权码ID
* @return 结果
*/
@Override
public int deleteSysLicenseById(Long licenseId)
{
return sysLicenseMapper.deleteSysLicenseById(licenseId);
}
/**
* 激活授权码
*
* @param licenseCode 授权码
* @return 结果
*/
@Override
public int activeLicense(String licenseCode)
{
if (StringUtils.isEmpty(licenseCode))
{
return 0;
}
// 验证授权码格式
if (!LicenseUtils.validateFormat(licenseCode))
{
return 0;
}
try
{
// 从配置表获取项目编号
String projectNo = sysConfigMapper.selectConfigByKey("fortucky.project.no");
if (StringUtils.isEmpty(projectNo))
{
return 0;
}
// 解析授权码
long expireTime = LicenseUtils.parseLicense(licenseCode, projectNo);
if (expireTime == 0)
{
return 0;
}
Date now = new Date();
Date startTime = now;
Date expireDate = new Date(expireTime);
// 验证是否已过期
if (expireDate.before(now))
{
return 0;
}
// 创建授权记录
SysLicense license = new SysLicense();
license.setLicenseCode(licenseCode);
license.setStartTime(startTime);
license.setExpireTime(expireDate);
license.setStatus("1");
license.setActiveTime(now);
license.setCreateTime(now);
return sysLicenseMapper.insertSysLicense(license);
}
catch (Exception e)
{
return 0;
}
}
/**
* 检查授权是否有效
*
* @return true有效 false无效
*/
@Override
public boolean checkLicenseValid()
{
// 如果授权码功能未启用,直接返回true
if (!licenseEnabled)
{
return true;
}
SysLicense license = sysLicenseMapper.selectActiveLicense();
if (license == null)
{
System.out.println("[授权检查] 没有找到激活的授权码");
return false;
}
// 从配置表获取项目编号
String projectNo = sysConfigMapper.selectConfigByKey("fortucky.project.no");
if (StringUtils.isEmpty(projectNo))
{
System.out.println("[授权检查] 项目编号未配置");
return false;
}
System.out.println("[授权检查] 授权码: " + license.getLicenseCode());
System.out.println("[授权检查] 项目编号: " + projectNo);
// 从授权码解析过期时间
long expireTime = LicenseUtils.parseLicense(license.getLicenseCode(), projectNo);
if (expireTime == 0)
{
System.out.println("[授权检查] 授权码解析失败");
return false;
}
Date now = new Date();
System.out.println("[授权检查] 当前时间: " + now.getTime());
System.out.println("[授权检查] 过期时间: " + expireTime);
if (expireTime < now.getTime())
{
System.out.println("[授权检查] 授权已过期");
// 更新为过期状态
license.setStatus("2");
sysLicenseMapper.updateSysLicense(license);
return false;
}
return true;
}
/**
* 获取当前有效授权信息
*
* @return 授权信息
*/
@Override
public SysLicense getActiveLicense()
{
return sysLicenseMapper.selectActiveLicense();
}
/**
* 更新过期授权码状态
*/
@Override
public void updateExpiredLicenses()
{
SysLicense query = new SysLicense();
query.setStatus("1");
List<SysLicense> licenses = sysLicenseMapper.selectSysLicenseList(query);
// 从配置表获取项目编号
String projectNo = sysConfigMapper.selectConfigByKey("fortucky.project.no");
if (StringUtils.isEmpty(projectNo))
{
return;
}
Date now = new Date();
for (SysLicense license : licenses)
{
// 从授权码解析过期时间
long expireTime = LicenseUtils.parseLicense(license.getLicenseCode(), projectNo);
if (expireTime > 0 && expireTime < now.getTime())
{
license.setStatus("2");
license.setUpdateTime(now);
sysLicenseMapper.updateSysLicense(license);
}
}
}
}

View File

@@ -0,0 +1 @@
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license\src\main\java\com\ruoyi\license\task

View File

@@ -0,0 +1,47 @@
package com.ruoyi.license.task;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.ruoyi.common.constant.CacheConstants;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.license.service.ISysLicenseService;
/**
* 授权检查定时任务
*
* @author ruoyi
*/
@Component
public class LicenseCheckTask
{
@Autowired
private ISysLicenseService licenseService;
@Autowired
private RedisCache redisCache;
@Value("${license.enabled:false}")
private boolean licenseEnabled;
/**
* 每5分钟检查一次授权状态
*/
@Scheduled(cron = "0 */5 * * * ?")
public void checkLicense()
{
if (!licenseEnabled)
{
return;
}
if (!licenseService.checkLicenseValid())
{
// 授权无效,删除 Redis 中所有登录 token,强制踢出所有在线用户
redisCache.deleteObject(redisCache.keys(CacheConstants.LOGIN_TOKEN_KEY + "*"));
}
licenseService.updateExpiredLicenses();
}
}

View File

@@ -0,0 +1 @@
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license\src\main\java\com\ruoyi\license\util

View File

@@ -0,0 +1,127 @@
package com.ruoyi.license.util;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Scanner;
/**
* 授权码生成工具(独立运行)
*
* @author ruoyi
*/
public class LicenseGenerator
{
public static void main(String[] args)
{
Scanner scanner = new Scanner(System.in);
System.out.println("========================================");
System.out.println(" RuoYi 授权码生成工具");
System.out.println("========================================");
System.out.println();
try
{
// 选择授权类型
System.out.println("请选择授权类型:");
System.out.println("1. 5分钟测试");
System.out.println("2. 1个月授权");
System.out.println("3. 3个月授权");
System.out.println("4. 1年授权");
System.out.println("5. 5年授权");
System.out.println("6. 长期授权");
System.out.print("请输入选项(1-6): ");
String typeChoice = scanner.nextLine().trim();
String type;
String typeDesc;
long expireTime;
switch (typeChoice)
{
case "1":
type = LicenseUtils.TYPE_TEST;
typeDesc = "5分钟测试";
expireTime = System.currentTimeMillis() + 5 * 60 * 1000L;
break;
case "2":
type = LicenseUtils.TYPE_MONTH;
typeDesc = "1个月授权";
expireTime = System.currentTimeMillis() + 30L * 24 * 60 * 60 * 1000;
break;
case "3":
type = LicenseUtils.TYPE_THREE;
typeDesc = "3个月授权";
expireTime = System.currentTimeMillis() + 90L * 24 * 60 * 60 * 1000;
break;
case "4":
type = LicenseUtils.TYPE_YEAR;
typeDesc = "1年授权";
expireTime = System.currentTimeMillis() + 365L * 24 * 60 * 60 * 1000;
break;
case "5":
type = LicenseUtils.TYPE_FIVE;
typeDesc = "5年授权";
expireTime = System.currentTimeMillis() + 5 * 365L * 24 * 60 * 60 * 1000;
break;
case "6":
type = LicenseUtils.TYPE_FOREVER;
typeDesc = "长期授权";
expireTime = System.currentTimeMillis() + 100 * 365L * 24 * 60 * 60 * 1000;
break;
default:
System.out.println("错误:无效的选项");
return;
}
// 输入项目编号
System.out.print("请输入项目编号: ");
String projectNo = scanner.nextLine().trim();
if (projectNo.isEmpty())
{
System.out.println("错误:项目编号不能为空");
return;
}
// 生成授权码
String licenseCode = LicenseUtils.generateLicense(type, projectNo);
if (licenseCode == null)
{
System.out.println("错误:授权码生成失败");
return;
}
// 格式化日期
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date now = new Date();
Date expire = new Date(expireTime);
System.out.println();
System.out.println("========================================");
System.out.println("授权码生成成功!");
System.out.println("========================================");
System.out.println("授权类型: " + typeDesc);
System.out.println("项目编号: " + projectNo);
System.out.println("当前时间: " + sdf.format(now));
System.out.println("过期时间: " + sdf.format(expire));
System.out.println("----------------------------------------");
System.out.println("授权码:");
System.out.println(licenseCode);
System.out.println("========================================");
System.out.println();
System.out.println("请妥善保管授权码,在系统中激活后即可使用。");
System.out.println("注意:系统配置中的项目编号必须与此处一致才能激活。");
}
catch (Exception e)
{
System.out.println("错误:" + e.getMessage());
e.printStackTrace();
}
finally
{
scanner.close();
}
}
}

View File

@@ -0,0 +1,89 @@
package com.ruoyi.license.util;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* 授权码测试工具
*
* @author ruoyi
*/
public class LicenseTest
{
public static void main(String[] args)
{
System.out.println("========================================");
System.out.println(" 授权码功能测试");
System.out.println("========================================");
System.out.println();
String projectNo = "TEST001";
// 测试1:生成各类型授权码
System.out.println("【测试1】生成各类型授权码");
String testCode = LicenseUtils.generateLicense(LicenseUtils.TYPE_TEST, projectNo);
String yearCode = LicenseUtils.generateLicense(LicenseUtils.TYPE_YEAR, projectNo);
String fiveCode = LicenseUtils.generateLicense(LicenseUtils.TYPE_FIVE, projectNo);
String foreverCode = LicenseUtils.generateLicense(LicenseUtils.TYPE_FOREVER, projectNo);
System.out.println("5分钟测试: " + testCode);
System.out.println("1年授权: " + yearCode);
System.out.println("5年授权: " + fiveCode);
System.out.println("长期授权: " + foreverCode);
System.out.println("结果: ✓ 通过");
System.out.println();
// 测试2:验证授权码格式
System.out.println("【测试2】验证授权码格式");
boolean isValid = LicenseUtils.validateFormat(yearCode);
System.out.println("格式验证: " + (isValid ? "✓ 通过" : "✗ 失败"));
System.out.println();
// 测试3:解析授权码
System.out.println("【测试3】解析授权码");
long expireTime = LicenseUtils.parseLicense(yearCode, projectNo);
if (expireTime > 0)
{
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date expire = new Date(expireTime);
System.out.println("过期时间: " + sdf.format(expire));
System.out.println("结果: ✓ 通过");
}
else
{
System.out.println("结果: ✗ 失败");
}
System.out.println();
// 测试4:项目编号验证
System.out.println("【测试4】项目编号验证");
long validResult = LicenseUtils.parseLicense(yearCode, projectNo);
long invalidResult = LicenseUtils.parseLicense(yearCode, "WRONG001");
System.out.println("正确项目编号: " + (validResult > 0 ? "✓ 通过" : "✗ 失败"));
System.out.println("错误项目编号: " + (invalidResult == 0 ? "✓ 通过(正确拒绝)" : "✗ 失败"));
System.out.println();
// 测试5:授权码格式展示
System.out.println("【测试5】授权码格式展示");
System.out.println("授权码: " + yearCode);
String[] parts = yearCode.split("_");
if (parts.length == 5)
{
System.out.println("前缀: " + parts[0]);
System.out.println("类型: " + parts[1] + " (" + LicenseUtils.getTypeDesc(parts[1]) + ")");
System.out.println("项目编号: " + parts[2]);
System.out.println("加密时间: " + parts[3]);
System.out.println("校验码: " + parts[4]);
System.out.println("结果: ✓ 通过");
}
else
{
System.out.println("结果: ✗ 失败");
}
System.out.println();
System.out.println("========================================");
System.out.println("测试完成!");
System.out.println("========================================");
}
}

View File

@@ -0,0 +1,275 @@
package com.ruoyi.license.util;
import java.util.Base64;
import java.util.Date;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
/**
* 授权码工具类
* 格式: FORTUCKY_授权类型_项目编号_加密时间_校验码
*
* @author ruoyi
*/
public class LicenseUtils
{
// 密钥(16位)
private static final String SECRET_KEY = "RuoYiLicense2024";
// 授权类型
public static final String TYPE_TEST = "TEST"; // 5分钟测试
public static final String TYPE_MONTH = "MONTH"; // 1个月
public static final String TYPE_THREE = "THREE"; // 3个月
public static final String TYPE_YEAR = "YEAR"; // 1年
public static final String TYPE_FIVE = "FIVE"; // 5年
public static final String TYPE_FOREVER = "FOREVER"; // 长期
/**
* 生成授权码
*
* @param type 授权类型
* @param projectNo 项目编号
* @return 授权码
*/
public static String generateLicense(String type, String projectNo)
{
try
{
long expireTime = calculateExpireTime(type);
if (expireTime == 0)
{
return null;
}
// 加密过期时间
String encryptedTime = encrypt(String.valueOf(expireTime));
if (encryptedTime == null)
{
return null;
}
// 生成校验码(取加密时间的前8位MD5)
String checkCode = generateCheckCode(type, projectNo, encryptedTime);
// 拼接授权码: FORTUCKY_类型_项目编号_加密时间_校验码
return "FORTUCKY_" + type + "_" + projectNo + "_" + encryptedTime + "_" + checkCode;
}
catch (Exception e)
{
return null;
}
}
/**
* 验证授权码格式
*
* @param licenseCode 授权码
* @return true有效 false无效
*/
public static boolean validateFormat(String licenseCode)
{
if (licenseCode == null || licenseCode.trim().isEmpty())
{
return false;
}
String[] parts = licenseCode.split("_");
if (parts.length != 5)
{
return false;
}
if (!"FORTUCKY".equals(parts[0]))
{
return false;
}
// 验证授权类型
String type = parts[1];
if (!TYPE_TEST.equals(type) && !TYPE_MONTH.equals(type) && !TYPE_THREE.equals(type) &&
!TYPE_YEAR.equals(type) && !TYPE_FIVE.equals(type) && !TYPE_FOREVER.equals(type))
{
return false;
}
return true;
}
/**
* 解析授权码
*
* @param licenseCode 授权码
* @param projectNo 系统项目编号
* @return 过期时间戳,0表示解析失败
*/
public static long parseLicense(String licenseCode, String projectNo)
{
try
{
if (!validateFormat(licenseCode))
{
return 0;
}
String[] parts = licenseCode.split("_");
String type = parts[1];
String codeProjectNo = parts[2];
String encryptedTime = parts[3];
String checkCode = parts[4];
// 验证项目编号
if (!codeProjectNo.equals(projectNo))
{
return 0;
}
// 验证校验码
String expectedCheckCode = generateCheckCode(type, codeProjectNo, encryptedTime);
if (!checkCode.equals(expectedCheckCode))
{
return 0;
}
// 解密时间
String decrypted = decrypt(encryptedTime);
if (decrypted == null)
{
return 0;
}
return Long.parseLong(decrypted);
}
catch (Exception e)
{
return 0;
}
}
/**
* 获取授权类型描述
*
* @param type 授权类型
* @return 描述
*/
public static String getTypeDesc(String type)
{
switch (type)
{
case TYPE_TEST: return "5分钟测试";
case TYPE_MONTH: return "1个月授权";
case TYPE_THREE: return "3个月授权";
case TYPE_YEAR: return "1年授权";
case TYPE_FIVE: return "5年授权";
case TYPE_FOREVER: return "长期授权";
default: return "未知";
}
}
/**
* 计算过期时间
*
* @param type 授权类型
* @return 过期时间戳
*/
private static long calculateExpireTime(String type)
{
long now = System.currentTimeMillis();
switch (type)
{
case TYPE_TEST:
return now + 5 * 60 * 1000L; // 5分钟
case TYPE_MONTH:
return now + 30L * 24 * 60 * 60 * 1000; // 1个月
case TYPE_THREE:
return now + 90L * 24 * 60 * 60 * 1000; // 3个月
case TYPE_YEAR:
return now + 365L * 24 * 60 * 60 * 1000; // 1年
case TYPE_FIVE:
return now + 5 * 365L * 24 * 60 * 60 * 1000; // 5年
case TYPE_FOREVER:
return now + 100 * 365L * 24 * 60 * 60 * 1000; // 100年(视为长期)
default:
return 0;
}
}
/**
* 生成校验码
*
* @param type 授权类型
* @param projectNo 项目编号
* @param encryptedTime 加密时间
* @return 校验码
*/
private static String generateCheckCode(String type, String projectNo, String encryptedTime)
{
try
{
String data = type + projectNo + encryptedTime;
java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
byte[] hash = md.digest(data.getBytes("UTF-8"));
StringBuilder sb = new StringBuilder();
for (int i = 0; i < Math.min(4, hash.length); i++)
{
sb.append(String.format("%02x", hash[i]));
}
return sb.toString().toUpperCase();
}
catch (Exception e)
{
return "00000000";
}
}
/**
* 加密
*
* @param data 原始数据
* @return 加密后的数据
*/
private static String encrypt(String data)
{
try
{
SecretKeySpec key = new SecretKeySpec(SECRET_KEY.getBytes("UTF-8"), "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] encrypted = cipher.doFinal(data.getBytes("UTF-8"));
return Base64.getEncoder().encodeToString(encrypted).replace("+", "-").replace("/", ".").replace("=", "");
}
catch (Exception e)
{
return null;
}
}
/**
* 解密
*
* @param encryptedData 加密数据
* @return 解密后的数据
*/
private static String decrypt(String encryptedData)
{
try
{
String base64Data = encryptedData.replace("-", "+").replace(".", "/");
int padding = (4 - base64Data.length() % 4) % 4;
for (int i = 0; i < padding; i++)
{
base64Data += "=";
}
SecretKeySpec key = new SecretKeySpec(SECRET_KEY.getBytes("UTF-8"), "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, key);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(base64Data));
return new String(decrypted, "UTF-8");
}
catch (Exception e)
{
return null;
}
}
}

View File

@@ -0,0 +1 @@
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license\src\main\resources\mapper\license

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.license.mapper.SysLicenseMapper">
<resultMap type="com.ruoyi.license.domain.SysLicense" id="SysLicenseResult">
<result property="licenseId" column="license_id" />
<result property="licenseCode" column="license_code" />
<result property="startTime" column="start_time" />
<result property="expireTime" column="expire_time" />
<result property="status" column="status" />
<result property="activeTime" column="active_time" />
<result property="activeBy" column="active_by" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
<result property="remark" column="remark" />
</resultMap>
<sql id="selectSysLicenseVo">
select license_id, license_code, start_time, expire_time, status, active_time, active_by, create_by, create_time, update_by, update_time, remark from sys_license
</sql>
<select id="selectSysLicenseList" parameterType="com.ruoyi.license.domain.SysLicense" resultMap="SysLicenseResult">
<include refid="selectSysLicenseVo"/>
<where>
<if test="licenseCode != null and licenseCode != ''"> and license_code = #{licenseCode}</if>
<if test="startTime != null "> and start_time = #{startTime}</if>
<if test="expireTime != null "> and expire_time = #{expireTime}</if>
<if test="status != null and status != ''"> and status = #{status}</if>
</where>
order by create_time desc
</select>
<select id="selectSysLicenseById" parameterType="Long" resultMap="SysLicenseResult">
<include refid="selectSysLicenseVo"/>
where license_id = #{licenseId}
</select>
<select id="selectActiveLicense" resultMap="SysLicenseResult">
<include refid="selectSysLicenseVo"/>
where status = '1'
order by create_time desc
OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY
</select>
<insert id="insertSysLicense" parameterType="com.ruoyi.license.domain.SysLicense" useGeneratedKeys="true" keyProperty="licenseId">
insert into sys_license
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="licenseCode != null and licenseCode != ''">license_code,</if>
<if test="startTime != null">start_time,</if>
<if test="expireTime != null">expire_time,</if>
<if test="status != null and status != ''">status,</if>
<if test="activeTime != null">active_time,</if>
<if test="activeBy != null">active_by,</if>
<if test="createBy != null">create_by,</if>
<if test="createTime != null">create_time,</if>
<if test="updateBy != null">update_by,</if>
<if test="updateTime != null">update_time,</if>
<if test="remark != null">remark,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="licenseCode != null and licenseCode != ''">#{licenseCode},</if>
<if test="startTime != null">#{startTime},</if>
<if test="expireTime != null">#{expireTime},</if>
<if test="status != null and status != ''">#{status},</if>
<if test="activeTime != null">#{activeTime},</if>
<if test="activeBy != null">#{activeBy},</if>
<if test="createBy != null">#{createBy},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateBy != null">#{updateBy},</if>
<if test="updateTime != null">#{updateTime},</if>
<if test="remark != null">#{remark},</if>
</trim>
</insert>
<update id="updateSysLicense" parameterType="com.ruoyi.license.domain.SysLicense">
update sys_license
<trim prefix="SET" suffixOverrides=",">
<if test="licenseCode != null and licenseCode != ''">license_code = #{licenseCode},</if>
<if test="startTime != null">start_time = #{startTime},</if>
<if test="expireTime != null">expire_time = #{expireTime},</if>
<if test="status != null and status != ''">status = #{status},</if>
<if test="activeTime != null">active_time = #{activeTime},</if>
<if test="activeBy != null">active_by = #{activeBy},</if>
<if test="createBy != null">create_by = #{createBy},</if>
<if test="createTime != null">create_time = #{createTime},</if>
<if test="updateBy != null">update_by = #{updateBy},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="remark != null">remark = #{remark},</if>
</trim>
where license_id = #{licenseId}
</update>
<delete id="deleteSysLicenseById" parameterType="Long">
delete from sys_license where license_id = #{licenseId}
</delete>
<delete id="deleteSysLicenseByIds" parameterType="String">
delete from sys_license where license_id in
<foreach item="licenseId" collection="array" open="(" separator="," close=")">
#{licenseId}
</foreach>
</delete>
</mapper>

270
dosc/vue授权码.md Normal file
View File

@@ -0,0 +1,270 @@
# 授权码模块集成改动说明
> 本文档记录引入 `ruoyi-license` 授权码模块时,对原有项目文件的所有改动(不含 `ruoyi-license` 模块本身及 `SysLicenseController`)。
---
## 1. 根模块 `pom.xml`
**文件路径:** `wms/pom.xml`
**改动内容:**
1. `<modules>` 中注册 `ruoyi-license` 子模块:
```xml
<modules>
<module>ruoyi-admin</module>
<module>ruoyi-framework</module>
<module>ruoyi-system</module>
<module>ruoyi-quartz</module>
<module>ruoyi-generator</module>
<module>ruoyi-common</module>
<module>ruoyi-license</module> <!-- 新增 -->
</modules>
```
2. `<dependencyManagement>` 中声明 `ruoyi-license` 依赖版本:
```xml
<!-- 授权码模块 -->
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-license</artifactId>
<version>${ruoyi.version}</version>
</dependency>
```
---
## 2. `ruoyi-admin/pom.xml`
**文件路径:** `wms/ruoyi-admin/pom.xml`
**改动内容:**
引入 `ruoyi-license` 依赖,使授权拦截器和配置能被 Spring Boot 扫描加载:
```xml
<!-- 授权码模块 -->
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-license</artifactId>
</dependency>
```
---
## 3. `SecurityConfig.java`
**文件路径:** `wms/ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java`
**改动内容:**
在 Spring Security 匿名白名单中加入 `/system/license/active`,允许未登录用户调用激活授权码接口:
```java
// 改动前
requests.antMatchers("/login", "/register", "/captchaImage").permitAll()
// 改动后
requests.antMatchers("/login", "/register", "/captchaImage", "/system/license/active").permitAll()
```
---
## 4. 前端 `api/license.js`(新建)
**文件路径:** `wms/ui/src/api/license.js`
**改动内容:**
新建文件,封装授权码相关接口:
```js
import request from '@/utils/request'
// 激活授权码(无需登录)
export function activeLicense(licenseCode) {
return request({
url: '/system/license/active',
method: 'post',
params: { licenseCode }
})
}
// 查询当前授权信息(需登录)
export function getLicenseInfo() {
return request({
url: '/system/license/info',
method: 'get'
})
}
```
---
## 5. 登录页 `login.vue`
**文件路径:** `wms/ui/src/views/login.vue`
**改动内容:**
### 5.1 模板部分
在登录按钮下方新增"激活授权码"按钮,以及激活弹窗:
```html
<!-- 激活授权码按钮 -->
<el-form-item style="width:100%;">
<el-button
size="medium"
type="warning"
style="width:100%;"
@click.native.prevent="openLicenseDialog"
>激活授权码</el-button>
</el-form-item>
<!-- 激活授权码弹窗 -->
<el-dialog title="激活授权码" :visible.sync="licenseDialogVisible" width="420px" append-to-body>
<el-input v-model="licenseCode" type="textarea" :rows="4" placeholder="请输入授权码" />
<span slot="footer">
<el-button @click="licenseDialogVisible = false">取 消</el-button>
<el-button type="primary" :loading="licenseLoading" @click="handleActiveLicense">激 活</el-button>
</span>
</el-dialog>
```
### 5.2 Script 部分
新增 import、data 字段和方法:
```js
// 新增 import
import { activeLicense } from "@/api/license"
// 新增 data 字段
licenseDialogVisible: false,
licenseCode: '',
licenseLoading: false
// 新增方法
openLicenseDialog() {
this.licenseCode = ''
this.licenseDialogVisible = true
},
handleActiveLicense() {
if (!this.licenseCode.trim()) {
this.$message.warning('请输入授权码')
return
}
this.licenseLoading = true
activeLicense(this.licenseCode.trim()).then(res => {
if (res.code === 200) {
this.$message.success(res.msg || '授权码激活成功')
this.licenseDialogVisible = false
} else {
this.$message.error(res.msg || '授权码激活失败')
}
}).catch(() => {
this.$message.error('授权码激活失败')
}).finally(() => {
this.licenseLoading = false
})
}
```
---
## 6. 顶部导航栏 `Navbar.vue`
**文件路径:** `wms/ui/src/layout/components/Navbar.vue`
**改动内容:**
### 6.1 模板部分
在 navbar 中间插入授权到期警告条(剩余不足 30 天时显示):
```html
<div v-if="licenseWarning" class="license-warning">
<i class="el-icon-warning-outline" />
系统授权将于 {{ licenseExpireText }} 后过期,请及时联系管理员续期
</div>
```
### 6.2 Script 部分
```js
// 新增 import
import { getLicenseInfo } from '@/api/license'
// 新增 data 字段
licenseWarning: false,
licenseExpireText: '',
licenseTimer: null
// 新增生命周期钩子
mounted() {
this.checkLicenseExpire()
this.licenseTimer = setInterval(() => this.checkLicenseExpire(), 60 * 1000)
},
beforeDestroy() {
if (this.licenseTimer) clearInterval(this.licenseTimer)
},
// 新增方法
checkLicenseExpire() {
getLicenseInfo().then(res => {
if (res.code === 200 && res.data && res.data.expireTime) {
const expireTime = new Date(res.data.expireTime).getTime()
const now = Date.now()
const diffMs = expireTime - now
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
if (diffMs > 0 && diffDays <= 30) {
this.licenseWarning = true
if (diffMs < 1000 * 60 * 60 * 24) {
const hours = Math.floor(diffMs / (1000 * 60 * 60))
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
this.licenseExpireText = hours > 0 ? `${hours} 小时 ${minutes} 分钟` : `${minutes} 分钟`
} else if (diffDays < 7) {
this.licenseExpireText = diffDays + ' 天'
} else {
this.licenseExpireText = Math.ceil(diffDays / 7) + ' 周'
}
}
}
}).catch(() => {})
}
```
### 6.3 样式部分
```scss
.license-warning {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: 0;
height: 50px;
line-height: 50px;
font-size: 13px;
color: #e6a23c;
font-weight: 500;
pointer-events: none;
i { margin-right: 4px; }
}
```
---
## 改动文件汇总
| 文件 | 类型 | 说明 |
|---|---|---|
| `wms/pom.xml` | 修改 | 注册模块 + 声明依赖版本 |
| `wms/ruoyi-admin/pom.xml` | 修改 | 引入 ruoyi-license 依赖 |
| `ruoyi-framework/.../SecurityConfig.java` | 修改 | 白名单加入激活授权码接口 |
| `ui/src/api/license.js` | 新建 | 封装激活和查询授权信息接口 |
| `ui/src/views/login.vue` | 修改 | 新增激活授权码按钮和弹窗 |
| `ui/src/layout/components/Navbar.vue` | 修改 | 新增授权到期倒计时警告条 |

Binary file not shown.

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,674 @@
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 (!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,26 @@
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
) {
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;
}
}

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,8 @@
package com.ruoyi.algorithm.integration;
public enum SchedulingDecisionStatus {
READY,
WAITING,
REJECTED,
TECHNICAL_ERROR
}

View File

@@ -0,0 +1,82 @@
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 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 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,324 @@
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) {
String mode = normalizedMode(request.fifoMode());
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 -> 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,845 @@
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) {
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.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())))
.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.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.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) {
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.isEmpty() || ("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(); }
}

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