chore: upload current WMS changes
This commit is contained in:
479
AGENTS.md
Normal file
479
AGENTS.md
Normal 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 密钥等),在生产环境部署前请务必修改这些配置。
|
||||
28
doc/algorithm-migration/IMPLEMENTATION_PLAN.md
Normal file
28
doc/algorithm-migration/IMPLEMENTATION_PLAN.md
Normal 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.
|
||||
197
doc/algorithm-migration/README.md
Normal file
197
doc/algorithm-migration/README.md
Normal 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
1
dosc/.WeDrive
Normal file
@@ -0,0 +1 @@
|
||||
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码
|
||||
258
dosc/Navbar.vue
Normal file
258
dosc/Navbar.vue
Normal 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
16
dosc/license.js
Normal 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
1
dosc/license/.WeDrive
Normal file
@@ -0,0 +1 @@
|
||||
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\license
|
||||
127
dosc/license/SysLicenseController.java
Normal file
127
dosc/license/SysLicenseController.java
Normal 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);
|
||||
}
|
||||
}
|
||||
1
dosc/ruoyi-license/.WeDrive
Normal file
1
dosc/ruoyi-license/.WeDrive
Normal file
@@ -0,0 +1 @@
|
||||
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license
|
||||
270
dosc/ruoyi-license/CHANGES.md
Normal file
270
dosc/ruoyi-license/CHANGES.md
Normal 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` | 修改 | 新增授权到期倒计时警告条 |
|
||||
250
dosc/ruoyi-license/DEPLOYMENT_CHECKLIST.md
Normal file
250
dosc/ruoyi-license/DEPLOYMENT_CHECKLIST.md
Normal 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. 性能问题 -> 调整定时任务频率
|
||||
|
||||
---
|
||||
|
||||
**检查人**: ___________
|
||||
**检查日期**: ___________
|
||||
**部署环境**: [ ] 开发 [ ] 测试 [ ] 生产
|
||||
**部署状态**: [ ] 成功 [ ] 失败 [ ] 部分成功
|
||||
231
dosc/ruoyi-license/INTEGRATION.md
Normal file
231
dosc/ruoyi-license/INTEGRATION.md
Normal 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官方文档
|
||||
168
dosc/ruoyi-license/LICENSE_FILTER_CONFIG.md
Normal file
168
dosc/ruoyi-license/LICENSE_FILTER_CONFIG.md
Normal 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 错误信息
|
||||
✅ 普通请求重定向到登录页
|
||||
193
dosc/ruoyi-license/README.md
Normal file
193
dosc/ruoyi-license/README.md
Normal 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官方文档。
|
||||
239
dosc/ruoyi-license/SUMMARY.md
Normal file
239
dosc/ruoyi-license/SUMMARY.md
Normal 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
|
||||
39
dosc/ruoyi-license/pom.xml
Normal file
39
dosc/ruoyi-license/pom.xml
Normal 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>
|
||||
@@ -0,0 +1 @@
|
||||
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license\src\main\java\com\ruoyi\license\config
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license\src\main\java\com\ruoyi\license\domain
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license\src\main\java\com\ruoyi\license\interceptor
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license\src\main\java\com\ruoyi\license\mapper
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license\src\main\java\com\ruoyi\license\service
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license\src\main\java\com\ruoyi\license\service\impl
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license\src\main\java\com\ruoyi\license\task
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license\src\main\java\com\ruoyi\license\util
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("========================================");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
C:\Users\李涛\Documents\WXWork\1688851319997782\WeDrive\福莱瑞达\软件产品中心\软件产品中心共享资料\集成授权码功能\vue版本集成\授权码\ruoyi-license\src\main\resources\mapper\license
|
||||
@@ -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
270
dosc/vue授权码.md
Normal 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` | 修改 | 新增授权到期倒计时警告条 |
|
||||
BIN
dosc/授权码vue版本配置手册.docx
Normal file
BIN
dosc/授权码vue版本配置手册.docx
Normal file
Binary file not shown.
48
fortucky-algorithm/pom.xml
Normal file
48
fortucky-algorithm/pom.xml
Normal 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>
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ruoyi.algorithm.inbound;
|
||||
|
||||
public enum BatchLaneMode {
|
||||
OPEN,
|
||||
SEALED
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ruoyi.algorithm.inbound;
|
||||
|
||||
public enum InboundBorrowType {
|
||||
NONE,
|
||||
CACHE_BORROWED,
|
||||
RESERVE_BORROWED,
|
||||
MIXED_IN
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ruoyi.algorithm.inbound;
|
||||
|
||||
public enum InboundRejectionCode {
|
||||
NO_FEASIBLE_LOCATION,
|
||||
NO_BATCH_COMPATIBLE_LANE
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.ruoyi.algorithm.inbound;
|
||||
|
||||
public record LadderTraceStep(InboundLadderRung rung, String reason) {
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ruoyi.algorithm.integration;
|
||||
|
||||
public enum SchedulingDecisionStatus {
|
||||
READY,
|
||||
WAITING,
|
||||
REJECTED,
|
||||
TECHNICAL_ERROR
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ruoyi.algorithm.integration;
|
||||
|
||||
public enum SchedulingMode {
|
||||
LEGACY,
|
||||
SHADOW,
|
||||
ACTIVE
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ruoyi.algorithm.model;
|
||||
|
||||
public enum SpaceRole {
|
||||
STORAGE,
|
||||
RELOCATION_BUFFER,
|
||||
OUTBOUND_CACHE,
|
||||
PROTECTED_CORE
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ruoyi.algorithm.model;
|
||||
|
||||
public enum SpaceStatus {
|
||||
EMPTY,
|
||||
OCCUPIED,
|
||||
DISABLED
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.ruoyi.algorithm.outbound;
|
||||
|
||||
public record FifoWarning(String code, String message) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ruoyi.algorithm.outbound;
|
||||
|
||||
public enum OutboundCapacityMode {
|
||||
BALANCED,
|
||||
WARNING,
|
||||
CAPACITY_FIRST
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ruoyi.algorithm.outbound;
|
||||
|
||||
public enum OutboundPlanStatus {
|
||||
READY,
|
||||
WAITING,
|
||||
ERROR
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ruoyi.algorithm.outbound;
|
||||
|
||||
public enum OutboundWaitingCode {
|
||||
WAITING_RELOCATION_BUFFER,
|
||||
WAITING_CHANNEL
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ruoyi.algorithm.outbound;
|
||||
|
||||
public enum OutboundWorkflowPhase {
|
||||
MOVE_OUT,
|
||||
OUTBOUND,
|
||||
RESTORE
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
public record DecisionLogRecord(String decisionId, String warehouseId, String decisionType,
|
||||
VersionToken inputVersion, String resultCode, String reason) {
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
public record InboundReservation(String reservationId, String warehouseId, String laneCode,
|
||||
String spaceId, String projectNo, VersionToken expectedVersion) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
public enum ReservationStatus { RESERVED, CONFLICT, RELEASED, NOT_FOUND }
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.ruoyi.algorithm.repository;
|
||||
|
||||
public enum SpaceConflict { TRAY, PROJECT, PRODUCT, LOT, SPACE_TRAY_MISMATCH }
|
||||
@@ -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
Reference in New Issue
Block a user