Compare commits
46 Commits
0bd47e2b2c
...
test/ft546
| Author | SHA1 | Date | |
|---|---|---|---|
| cdb47fde3f | |||
| 8b84459072 | |||
| 947edbdbc8 | |||
| 445f47b286 | |||
| 89a26e6d42 | |||
| d6179297ab | |||
| bb682623db | |||
| 98db537218 | |||
| 3789aa6420 | |||
| 41e412f214 | |||
| 7b63e7863f | |||
| 303beb5d42 | |||
| 3ea7216fdb | |||
| a545986409 | |||
| 2a329a9312 | |||
| 590eea6e9b | |||
| 0cc4596f07 | |||
| b7ee435d38 | |||
|
|
b32da3e05f | ||
|
|
49ddd8fd21 | ||
|
|
368d615674 | ||
|
|
eba8434f68 | ||
|
|
7984ccc7f1 | ||
|
|
95656dccc4 | ||
|
|
6851a898ca | ||
|
|
411e3a2c4f | ||
|
|
aad8c71466 | ||
|
|
6ce9f469e0 | ||
|
|
52ce9135da | ||
|
|
3729d0df5f | ||
|
|
fa00020c38 | ||
|
|
c9cd7c772e | ||
|
|
eb26dc9a65 | ||
|
|
10fa4b3b15 | ||
|
|
f25bb2a549 | ||
|
|
d9da0de59e | ||
|
|
e65b5933ad | ||
|
|
76517dc3a5 | ||
|
|
969949adf4 | ||
|
|
4384eb5b7a | ||
|
|
d652a29bba | ||
|
|
1ad5a15b6f | ||
|
|
38f10b676d | ||
|
|
83db499c85 | ||
|
|
a33998553e | ||
|
|
88d4caa017 |
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 安装、触发器并发和存储过程回滚演练。
|
||||
824
docs/superpowers/plans/2026-07-22-ft546-tidy-and-batch-replay.md
Normal file
824
docs/superpowers/plans/2026-07-22-ft546-tidy-and-batch-replay.md
Normal file
@@ -0,0 +1,824 @@
|
||||
# FT546 Tidy Workflow and Historical Batch Replay Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add persisted automatic/manual tidy execution with exclusive manual mode, then prove inbound, outbound, relocation, and tidy correctness by replaying site history through simulated PDA/WCS interfaces.
|
||||
|
||||
**Architecture:** Keep `TidyPlanner` pure in `fortucky-algorithm` and add WMS-specific persistence/orchestration under `ruoyi-admin/com/ruoyi/algorithm/wms/tidy`. SQL Server is authoritative for mode, queues, workflows, callback events, and locks; all legacy and algorithm allocation routes pass through one admission service. WCS exposes a read-only vehicle snapshot, while a run-scoped simulator and PowerShell harness drive the development-database replay and reconciliation.
|
||||
|
||||
**Tech Stack:** Java 17, Spring Boot, Spring transactions/scheduling, MyBatis, SQL Server, JUnit 5, Mockito, AssertJ, Vue 2 + Element UI, Node.js built-in HTTP server, PowerShell 7/System.Data.SqlClient.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Work in `D:/workSpace/PROJECT_SPACE/FT546/FT546MWS/FT546MWS` for WMS and `D:/workSpace/PROJECT_SPACE/FT546/FT546-WCS` for WCS; both are dirty repositories, so never revert or stage unrelated changes.
|
||||
- Preserve the `fortucky-algorithm` module boundary and the configured WMS primary `DataSource`/`PlatformTransactionManager`.
|
||||
- Keep the normal deployment default `FORTUCKY_ALGORITHM_MODE=SHADOW`; only the confirmed development database may use the run-scoped replay override.
|
||||
- Automatic tidy runs only in `NORMAL`, never alongside inbound/outbound/outbound-relocation work, and has a global maximum of one active workflow.
|
||||
- Manual mode follows `NORMAL -> ENTERING_TIDY -> TIDY_ACTIVE -> EXITING_TIDY -> NORMAL`; business documents remain accepted but execution allocation is blocked outside `NORMAL`.
|
||||
- Manual capacity is `online + healthy + (idle or already assigned to manual tidy)` minus active manual workflows; safety predicates and resource locks override capacity filling.
|
||||
- Every move job with `in_or_out=28` must resolve to exactly one origin: `AUTO_TIDY`, `MANUAL_TIDY`, or `OUTBOUND_RELOCATION`.
|
||||
- Do not delete or mutate imported finish tables. Before live replay, create and verify a new restorable backup after migrations; restore it after evidence generation.
|
||||
- Implement every production change test-first and commit only exact task paths.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### WMS algorithm and persistence
|
||||
|
||||
- Modify `fortucky-algorithm/src/main/resources/db/sqlserver/install.sql`: idempotent outbound/tidy schema, queue handoff, resource locking, and sequence synchronization procedures.
|
||||
- Modify `fortucky-algorithm/src/main/resources/db/sqlserver/diagnostics.sql`: mode/workflow/queue/origin/lock integrity diagnostics.
|
||||
- Modify `fortucky-algorithm/src/main/resources/db/sqlserver/uninstall.sql`: reverse only new algorithm objects in dependency order.
|
||||
- Modify `fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/SqlServerMigrationScriptTest.java`: migration contract tests.
|
||||
- Modify `fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/AlgorithmProperties.java`: tidy scan/reconcile/lease and WCS snapshot URL configuration.
|
||||
- Modify `fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingFacade.java`: expose `planTidy` without adding WMS persistence to the algorithm module.
|
||||
|
||||
### WMS tidy runtime
|
||||
|
||||
- Create `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyEffectiveState.java`: persisted mode enum.
|
||||
- Create `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyOrigin.java`: job/workflow origin enum.
|
||||
- Create `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/ExecutionWorkType.java`: gate input enum.
|
||||
- Create `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyModeSnapshot.java`: immutable status DTO.
|
||||
- Create `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowState.java`: mapper projection for workflow/step state.
|
||||
- Create `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/VehicleSnapshot.java`: per-vehicle WCS contract.
|
||||
- Create `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyVehicleCapacity.java`: capacity calculation.
|
||||
- Create `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyModeService.java`: switch transitions and startup reconciliation.
|
||||
- Create `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/ExecutionAdmissionService.java`: shared business/tidy admission gate.
|
||||
- Create `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/ExecutionBlockedByTidyModeException.java`: stable blocked-allocation exception.
|
||||
- Create `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowStarter.java`: scheduler-to-coordinator boundary.
|
||||
- Create `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/WcsTaskState.java`: restart-reconciliation WCS task state.
|
||||
- Create `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowTransaction.java`: transactional claim, job creation, callback, recovery, and release.
|
||||
- Create `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowCoordinator.java`: automatic singleton and manual capacity filling.
|
||||
- Create `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyScheduler.java`: 10-minute automatic scan and short mode reconciliation.
|
||||
- Create `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/WcsVehicleSnapshotClient.java`: production WCS snapshot adapter.
|
||||
- Create `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/ReplayExecutionGuard.java`: validates a run-scoped ACTIVE process against `DB_NAME()` before writes.
|
||||
- Modify `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/WmsAlgorithmMapper.java` and `ruoyi-admin/src/main/resources/mapper/algorithm/WmsAlgorithmMapper.xml`: all tidy SQL operations and origin queries.
|
||||
- Modify `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/WmsInboundAllocationService.java` and `WmsOutboundAllocationService.java`: shared gate before algorithm and legacy paths.
|
||||
- Modify `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/OutboundWorkflowTransaction.java`: origin linkage and shared resource admission.
|
||||
- Modify `ruoyi-admin/src/main/java/com/ruoyi/api/service/impl/ToWmsServiceImpl.java`: route idempotent job-finish callbacks to outbound then tidy coordinators.
|
||||
|
||||
### WCS capacity endpoint
|
||||
|
||||
- Create `FT546-WCS/ruoyi-admin/src/main/java/com/ruoyi/api/domain/wcs2wms/SrgvVehicleSnapshot.java`: read-only transport DTO.
|
||||
- Modify `FT546-WCS/ruoyi-admin/src/main/java/com/ruoyi/api/controller/Wcs2WmsController.java`: `POST /wcs2Wms/api/tidyVehicleSnapshot`.
|
||||
- Modify `FT546-WCS/ruoyi-admin/src/main/java/com/ruoyi/api/service/IApiService.java` and `impl/ApiServiceImpl.java`: derive online/healthy/idle state from `device_srgv`.
|
||||
|
||||
### Operator control
|
||||
|
||||
- Create `ruoyi-admin/src/main/java/com/ruoyi/algorithm/controller/TidyControlController.java`: authenticated status and switch API.
|
||||
- Create `ruoyi-admin/src/main/java/com/ruoyi/algorithm/controller/TidyModeRequest.java`: switch request DTO.
|
||||
- Create `ruoyi-ui/src/api/algorithm/tidy.js`: status/switch calls.
|
||||
- Create `ruoyi-ui/src/views/algorithm/tidy/index.vue`: compact operational status and switch page.
|
||||
- Create `sql/algorithm_tidy_menu.sql`: idempotent dynamic menu and permissions.
|
||||
|
||||
### Replay and evidence
|
||||
|
||||
- Create `tools/history-replay/simulators/ft546-wcs-simulator.mjs`: three-vehicle WCS snapshot/task simulator with controllable failures.
|
||||
- Create `tools/history-replay/sql/select-batch-fixtures.sql`: deterministic eligible history and mapping decisions.
|
||||
- Create `tools/history-replay/sql/reconcile-tidy-batch.sql`: per-run inventory, cargo-space, workflow, origin, and lock evidence.
|
||||
- Create `tools/history-replay/Invoke-Ft546TidyBatchReplay.ps1`: backup, migration, fixture execution, HTTP simulation, reconciliation, reports, and restore.
|
||||
- Modify `tools/history-replay/README.md`: exact preflight/run/resume commands and artifact contract.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Idempotent SQL Server Tidy Schema
|
||||
|
||||
**Files:**
|
||||
- Modify: `fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/SqlServerMigrationScriptTest.java`
|
||||
- Modify: `fortucky-algorithm/src/main/resources/db/sqlserver/install.sql`
|
||||
- Modify: `fortucky-algorithm/src/main/resources/db/sqlserver/diagnostics.sql`
|
||||
- Modify: `fortucky-algorithm/src/main/resources/db/sqlserver/uninstall.sql`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `alg.tidy_mode`, `alg.tidy_lane_queue`, `alg.tidy_workflow`, `alg.tidy_step`, `alg.tidy_space_lock`, `alg.tidy_callback_event`.
|
||||
- Produces: `alg.usp_claim_tidy_lane`, `alg.usp_release_tidy_lane`, `alg.usp_sync_replay_sequences`.
|
||||
- Preserves: existing `alg.dirty_lane_queue` as the rebuild queue and hands rebuilt lanes to `alg.tidy_lane_queue` only after successful rebuild.
|
||||
|
||||
- [ ] **Step 1: Extend migration contract tests and verify red**
|
||||
|
||||
Add assertions that require these exact fragments:
|
||||
|
||||
```java
|
||||
assertThat(install).contains("CREATE TABLE alg.tidy_mode");
|
||||
assertThat(install).contains("CREATE TABLE alg.tidy_lane_queue");
|
||||
assertThat(install).contains("CREATE TABLE alg.tidy_workflow");
|
||||
assertThat(install).contains("CREATE TABLE alg.tidy_step");
|
||||
assertThat(install).contains("CREATE TABLE alg.tidy_space_lock");
|
||||
assertThat(install).contains("CREATE TABLE alg.tidy_callback_event");
|
||||
assertThat(install).contains("CREATE PROCEDURE alg.usp_claim_tidy_lane");
|
||||
assertThat(install).contains("sp_getapplock");
|
||||
assertThat(install).contains("OUTBOUND_RELOCATION");
|
||||
assertThat(diagnostics).contains("PAUSED_RECOVERY");
|
||||
assertThat(uninstall).contains("DROP TABLE alg.tidy_mode");
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl fortucky-algorithm -Dtest=SqlServerMigrationScriptTest test
|
||||
```
|
||||
|
||||
Expected: FAIL because tidy objects do not exist.
|
||||
|
||||
- [ ] **Step 2: Add tables, keys, constraints, and queue handoff**
|
||||
|
||||
Use these persisted values:
|
||||
|
||||
```sql
|
||||
effective_state varchar(24) CHECK (effective_state IN
|
||||
('NORMAL','ENTERING_TIDY','TIDY_ACTIVE','EXITING_TIDY'))
|
||||
origin varchar(24) CHECK (origin IN ('AUTO_TIDY','MANUAL_TIDY'))
|
||||
workflow_status varchar(24) CHECK (workflow_status IN
|
||||
('CLAIMED','WAITING_DISPATCH','DISPATCHED','RUNNING','PAUSED_RECOVERY','COMPLETED','CANCELLED_RECONCILED'))
|
||||
resource_type varchar(8) CHECK (resource_type IN ('SPACE','LANE','FLOOR'))
|
||||
```
|
||||
|
||||
Create filtered unique indexes for active resources, WCS task IDs, callback event keys, and singleton active automatic workflow. Add `origin='OUTBOUND_RELOCATION'` to `alg.outbound_workflow`. At the successful end of `alg.usp_refresh_dirty_lanes`, `MERGE` each row from `#dirty` into `alg.tidy_lane_queue`, incrementing `dirty_version`; retain existing catch/requeue behavior.
|
||||
|
||||
- [ ] **Step 3: Add claim/release and replay-sequence procedures**
|
||||
|
||||
`alg.usp_claim_tidy_lane` must claim one `PENDING` row using `UPDLOCK, READPAST, ROWLOCK`, ordered by `last_dirty_at, warehouse_id, lane_code`, and set a lease token/expiry. `alg.usp_release_tidy_lane` must clear only the claimed version; if a newer version exists, return it to `PENDING`.
|
||||
|
||||
`alg.usp_sync_replay_sequences` must run under `sp_getapplock`, compare active and finish tables, and advance `bas_job_id`/`bas_task_no` without decreasing them. It must include `CK202607210002` as a regression fixture in the SQL test.
|
||||
|
||||
- [ ] **Step 4: Verify migration scripts**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl fortucky-algorithm -Dtest=SqlServerMigrationScriptTest test
|
||||
```
|
||||
|
||||
Expected: PASS with all install/diagnostics/uninstall assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit only migration files**
|
||||
|
||||
```bash
|
||||
git add fortucky-algorithm/src/main/resources/db/sqlserver/install.sql \
|
||||
fortucky-algorithm/src/main/resources/db/sqlserver/diagnostics.sql \
|
||||
fortucky-algorithm/src/main/resources/db/sqlserver/uninstall.sql \
|
||||
fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/SqlServerMigrationScriptTest.java
|
||||
git commit -m "feat: add persisted tidy workflow schema"
|
||||
```
|
||||
|
||||
### Task 2: Implement Persisted Mode and Shared Admission
|
||||
|
||||
**Files:**
|
||||
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyEffectiveState.java`
|
||||
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/ExecutionWorkType.java`
|
||||
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyModeSnapshot.java`
|
||||
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyModeService.java`
|
||||
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/ExecutionAdmissionService.java`
|
||||
- Create: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidyModeServiceTest.java`
|
||||
- Create: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/ExecutionAdmissionServiceTest.java`
|
||||
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/WmsAlgorithmMapper.java`
|
||||
- Modify: `ruoyi-admin/src/main/resources/mapper/algorithm/WmsAlgorithmMapper.xml`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `TidyModeSnapshot status(String warehouseId)`.
|
||||
- Produces: `TidyModeSnapshot requestManualMode(String warehouseId, boolean enabled, String actor)`.
|
||||
- Produces: `TidyModeSnapshot reconcile(String warehouseId)`.
|
||||
- Produces: `void assertAllowed(String warehouseId, ExecutionWorkType workType)`.
|
||||
- Consumes mapper operations: `selectTidyModeForUpdate`, `setTidyModeRequest`, `countActiveBusinessExecution`, `countActiveManualTidy`.
|
||||
|
||||
- [ ] **Step 1: Write failing state-machine and admission tests**
|
||||
|
||||
Cover the exact table:
|
||||
|
||||
```java
|
||||
assertThat(service.requestManualMode("01", true, "tester").effectiveState())
|
||||
.isEqualTo(TidyEffectiveState.ENTERING_TIDY);
|
||||
when(mapper.countActiveBusinessExecution("01")).thenReturn(0);
|
||||
assertThat(service.reconcile("01").effectiveState())
|
||||
.isEqualTo(TidyEffectiveState.TIDY_ACTIVE);
|
||||
assertThatThrownBy(() -> gate.assertAllowed("01", ExecutionWorkType.INBOUND))
|
||||
.isInstanceOf(ExecutionBlockedByTidyModeException.class);
|
||||
gate.assertAllowed("01", ExecutionWorkType.MANUAL_TIDY);
|
||||
```
|
||||
|
||||
Also test idempotent repeated enable/disable, `EXITING_TIDY` drain, optimistic-version failure, and acceptance of `DOCUMENT_RECEIPT` in every state.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ruoyi-admin -Dtest='TidyModeServiceTest,ExecutionAdmissionServiceTest' test
|
||||
```
|
||||
|
||||
Expected: FAIL because types/services/mappers are missing.
|
||||
|
||||
- [ ] **Step 2: Add the domain types and service logic**
|
||||
|
||||
Use this gate contract:
|
||||
|
||||
```java
|
||||
public enum ExecutionWorkType {
|
||||
DOCUMENT_RECEIPT, INBOUND, OUTBOUND, OUTBOUND_RELOCATION, AUTO_TIDY, MANUAL_TIDY;
|
||||
|
||||
public boolean allowedIn(TidyEffectiveState state) {
|
||||
if (this == DOCUMENT_RECEIPT) return true;
|
||||
if (state == TidyEffectiveState.NORMAL) return this != MANUAL_TIDY;
|
||||
return state == TidyEffectiveState.TIDY_ACTIVE && this == MANUAL_TIDY;
|
||||
}
|
||||
}
|
||||
|
||||
public final class ExecutionAdmissionService {
|
||||
private final TidyModeService modeService;
|
||||
|
||||
public ExecutionAdmissionService(TidyModeService modeService) {
|
||||
this.modeService = modeService;
|
||||
}
|
||||
|
||||
public void assertAllowed(String warehouseId, ExecutionWorkType workType) {
|
||||
TidyEffectiveState state = modeService.status(warehouseId).effectiveState();
|
||||
if (!workType.allowedIn(state)) {
|
||||
throw new ExecutionBlockedByTidyModeException(state.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`NORMAL` allows business execution and auto tidy; `ENTERING_TIDY` and `EXITING_TIDY` allow only document receipt and drain callbacks; `TIDY_ACTIVE` allows document receipt, manual tidy, and callbacks. State changes and counts must be read inside the WMS transaction manager.
|
||||
|
||||
- [ ] **Step 3: Implement mapper state locking and status projection**
|
||||
|
||||
`selectTidyModeForUpdate` uses `WITH (UPDLOCK,HOLDLOCK)`. The status projection includes requested switch, effective state, drain count, active tidy count, status reason, version, updater, and timestamp.
|
||||
|
||||
- [ ] **Step 4: Run focused tests**
|
||||
|
||||
```bash
|
||||
mvn -pl ruoyi-admin -Dtest='TidyModeServiceTest,ExecutionAdmissionServiceTest,WmsAlgorithmMapperRegistrationTest' test
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit exact mode files**
|
||||
|
||||
```bash
|
||||
git add ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyEffectiveState.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/ExecutionWorkType.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyModeSnapshot.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyModeService.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/ExecutionAdmissionService.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/ExecutionBlockedByTidyModeException.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/WmsAlgorithmMapper.java \
|
||||
ruoyi-admin/src/main/resources/mapper/algorithm/WmsAlgorithmMapper.xml \
|
||||
ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidyModeServiceTest.java \
|
||||
ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/ExecutionAdmissionServiceTest.java
|
||||
git commit -m "feat: persist tidy mode admission state"
|
||||
```
|
||||
|
||||
### Task 3: Gate Every Inbound and Outbound Allocation Route
|
||||
|
||||
**Files:**
|
||||
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/WmsInboundAllocationService.java`
|
||||
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/WmsOutboundAllocationService.java`
|
||||
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/WmsInboundAllocationServiceTest.java`
|
||||
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/WmsOutboundAllocationServiceTest.java`
|
||||
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/AlgorithmShadowConfigurationTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ExecutionAdmissionService.assertAllowed(warehouseId, INBOUND|OUTBOUND)`.
|
||||
- Guarantees: the gate runs before both `SchedulingFacade` planning and legacy stored-procedure fallback.
|
||||
|
||||
- [ ] **Step 1: Add failing router tests**
|
||||
|
||||
For `allocate`, `reallocate`, `allocateProject`, and `allocateExactStock`, make the gate throw and verify neither algorithm nor legacy mapper is invoked:
|
||||
|
||||
```java
|
||||
doThrow(new ExecutionBlockedByTidyModeException("TIDY_ACTIVE"))
|
||||
.when(admission).assertAllowed("01", ExecutionWorkType.OUTBOUND);
|
||||
assertThatThrownBy(() -> service.allocateProject(stockout, detail))
|
||||
.isInstanceOf(ExecutionBlockedByTidyModeException.class);
|
||||
verifyNoInteractions(facade, stockoutMapper, workflowCoordinator);
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ruoyi-admin -Dtest='WmsInboundAllocationServiceTest,WmsOutboundAllocationServiceTest' test
|
||||
```
|
||||
|
||||
Expected: FAIL because constructors and gate calls are absent.
|
||||
|
||||
- [ ] **Step 2: Inject and call the shared gate**
|
||||
|
||||
Call the gate once at the top of each shared router after resolving warehouse ID and before any reservation release, planner call, or legacy procedure. Do not add controller-only checks. `ToWmsServiceImpl.reMaterial` remains covered because it routes through `OutStockoutServiceImpl.autoAllot -> WmsOutboundAllocationService.allocateProject`.
|
||||
|
||||
- [ ] **Step 3: Verify callback paths remain admitted**
|
||||
|
||||
Add tests proving `jobFinish`, `completeInbound`, outbound confirmation, and existing in-flight advancement do not call the creation gate. They are drain/recovery operations and must remain executable in non-normal states.
|
||||
|
||||
- [ ] **Step 4: Run WMS allocation regression**
|
||||
|
||||
```bash
|
||||
mvn -pl ruoyi-admin -Dtest='WmsInboundAllocationServiceTest,InboundAllocationTransactionTest,WmsOutboundAllocationServiceTest,OutboundWorkflowTransactionTest,PersistentOutboundWorkflowCoordinatorTest,AlgorithmShadowConfigurationTest' test
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit allocation gate changes**
|
||||
|
||||
```bash
|
||||
git add ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/WmsInboundAllocationService.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/WmsOutboundAllocationService.java \
|
||||
ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/WmsInboundAllocationServiceTest.java \
|
||||
ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/WmsOutboundAllocationServiceTest.java \
|
||||
ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/AlgorithmShadowConfigurationTest.java
|
||||
git commit -m "feat: gate allocation during manual tidy"
|
||||
```
|
||||
|
||||
### Task 4: Add Persistent Tidy-Lane Scheduling
|
||||
|
||||
**Files:**
|
||||
- Modify: `fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/AlgorithmProperties.java`
|
||||
- Modify: `fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingFacade.java`
|
||||
- Modify: `fortucky-algorithm/src/test/java/com/ruoyi/algorithm/integration/AlgorithmConfigurationTest.java`
|
||||
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyLaneClaim.java`
|
||||
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowStarter.java`
|
||||
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/ReplayExecutionGuard.java`
|
||||
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyScheduler.java`
|
||||
- Create: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidySchedulerTest.java`
|
||||
- Create: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/ReplayExecutionGuardTest.java`
|
||||
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/WmsAlgorithmMapper.java`
|
||||
- Modify: `ruoyi-admin/src/main/resources/mapper/algorithm/WmsAlgorithmMapper.xml`
|
||||
- Modify: `ruoyi-admin/src/main/resources/application.yml`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `TidyPlan SchedulingFacade.planTidy(String warehouseId, Instant now)`.
|
||||
- Produces: `Optional<TidyLaneClaim> claimTidyLane(String warehouseId, String owner, Instant expiresAt)`.
|
||||
- Produces: `UUID TidyWorkflowStarter.startAutomatic(TidyLaneClaim claim, TidyCandidate candidate)` as the scheduler boundary; Task 5 makes the coordinator implement it.
|
||||
|
||||
- [ ] **Step 1: Add failing scheduler tests**
|
||||
|
||||
Test these cases explicitly: not `NORMAL`; active inbound; active outbound relocation; one active tidy; no queue claim; stale candidate; successful single start; lease failure requeues.
|
||||
|
||||
```java
|
||||
when(mode.status("01")).thenReturn(normal);
|
||||
when(mapper.countActiveBusinessExecution("01")).thenReturn(1);
|
||||
scheduler.scanAutomatic();
|
||||
verifyNoInteractions(coordinator);
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl fortucky-algorithm -Dtest=AlgorithmConfigurationTest test
|
||||
mvn -pl ruoyi-admin -Dtest=TidySchedulerTest test
|
||||
```
|
||||
|
||||
Expected: FAIL because facade/scheduler APIs are absent.
|
||||
|
||||
- [ ] **Step 2: Expose pure tidy planning and properties**
|
||||
|
||||
Add properties with these defaults:
|
||||
|
||||
```yaml
|
||||
fortucky:
|
||||
algorithm:
|
||||
tidy-auto-scan-ms: 600000
|
||||
tidy-reconcile-ms: 5000
|
||||
tidy-claim-lease-seconds: 60
|
||||
tidy-wcs-snapshot-url: ${FORTUCKY_TIDY_WCS_SNAPSHOT_URL:http://localhost:8081/wcs2Wms/api/tidyVehicleSnapshot}
|
||||
replay-run-id: ${FT546_REPLAY_RUN_ID:}
|
||||
replay-database: ${FT546_REPLAY_DATABASE:}
|
||||
```
|
||||
|
||||
`SchedulingFacade.planTidy` may run in `SHADOW` for diagnostics, but workflow creation still requires the run-scoped write gate and `ExecutionWorkType.AUTO_TIDY`.
|
||||
|
||||
`ReplayExecutionGuard` runs at application startup. When `replay-run-id` is nonblank, it requires routing mode `ACTIVE`, database writes enabled, and mapper `selectDatabaseName()` exactly equal to `replay-database`; otherwise startup fails before schedulers or HTTP writers run.
|
||||
|
||||
- [ ] **Step 3: Implement scheduler and versioned queue mapper**
|
||||
|
||||
Use `@Scheduled(fixedDelayString = "${fortucky.algorithm.tidy-auto-scan-ms:600000}")`. Lock/claim the oldest queue row, invoke the planner, select only a candidate touching the claimed lane, and release/requeue using the claim token and dirty version.
|
||||
|
||||
- [ ] **Step 4: Run planner and scheduler suites**
|
||||
|
||||
```bash
|
||||
mvn -pl fortucky-algorithm -Dtest='TidyPlannerTest,WarehouseApplicationServiceTest,AlgorithmConfigurationTest' test
|
||||
mvn -pl ruoyi-admin -Dtest='TidySchedulerTest,ReplayExecutionGuardTest,WmsAlgorithmMapperRegistrationTest' test
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit scheduling changes**
|
||||
|
||||
```bash
|
||||
git add fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/AlgorithmProperties.java \
|
||||
fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/SchedulingFacade.java \
|
||||
fortucky-algorithm/src/test/java/com/ruoyi/algorithm/integration/AlgorithmConfigurationTest.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyLaneClaim.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowStarter.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/ReplayExecutionGuard.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyScheduler.java \
|
||||
ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidySchedulerTest.java \
|
||||
ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/ReplayExecutionGuardTest.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/WmsAlgorithmMapper.java \
|
||||
ruoyi-admin/src/main/resources/mapper/algorithm/WmsAlgorithmMapper.xml \
|
||||
ruoyi-admin/src/main/resources/application.yml
|
||||
git commit -m "feat: schedule automatic tidy from dirty lanes"
|
||||
```
|
||||
|
||||
### Task 5: Execute Tidy Through the Existing Move-Job Chain
|
||||
|
||||
> Superseded on 2026-07-23: WCS task-state lookup and the UNKNOWN-task replay case are replaced by WMS-native evidence recovery. See `docs/superpowers/specs/2026-07-23-wms-native-tidy-recovery-design.md` and `docs/superpowers/plans/2026-07-23-wms-native-tidy-recovery.md`. Vehicle snapshot behavior is not superseded.
|
||||
|
||||
**Files:**
|
||||
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyOrigin.java`
|
||||
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowState.java`
|
||||
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/WcsTaskState.java`
|
||||
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowTransaction.java`
|
||||
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowCoordinator.java`
|
||||
- Create: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowTransactionTest.java`
|
||||
- Create: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowCoordinatorTest.java`
|
||||
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/WmsAlgorithmMapper.java`
|
||||
- Modify: `ruoyi-admin/src/main/resources/mapper/algorithm/WmsAlgorithmMapper.xml`
|
||||
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/OutboundWorkflowTransaction.java`
|
||||
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/service/impl/ToWmsServiceImpl.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `UUID start(TidyOrigin origin, String warehouseId, TidyCandidate candidate)`.
|
||||
- Produces: `boolean onJobFinished(String jobId, String callbackStage)`.
|
||||
- Produces: `TidyWorkflowState reconcile(UUID workflowId, WcsTaskState taskState)`.
|
||||
- Implements: `UUID TidyWorkflowStarter.startAutomatic(TidyLaneClaim claim, TidyCandidate candidate)`.
|
||||
- Guarantees: one `TidyMove` becomes one ordered `in_or_out=28` job; only the next dependent step is active.
|
||||
|
||||
- [ ] **Step 1: Add failing workflow transaction tests**
|
||||
|
||||
Cover: current pallet/space revalidation; transaction-scoped `sp_getapplock`; disjoint `SPACE/LANE/FLOOR` resources; ordered multi-move candidate; one job per move; duplicate callback event; out-of-order rejection; terminal release; `PAUSED_RECOVERY` retained locks.
|
||||
|
||||
Define tidy workflow origin narrowly:
|
||||
|
||||
```java
|
||||
public enum TidyOrigin {
|
||||
AUTO_TIDY, MANUAL_TIDY
|
||||
}
|
||||
```
|
||||
|
||||
Outbound workflows persist the separate constant `OUTBOUND_RELOCATION`; job-origin queries union the two owning workflow types and never pass that value into `TidyWorkflowTransaction.start`.
|
||||
|
||||
```java
|
||||
UUID workflow = transaction.start(TidyOrigin.MANUAL_TIDY, "01", candidate);
|
||||
verify(mapper).insertTidyMoveJob(argThat(step -> step.inOrOut() == 28));
|
||||
assertThat(transaction.onJobFinished("TIDY-1", "FINISHED")).isTrue();
|
||||
assertThat(transaction.onJobFinished("TIDY-1", "FINISHED")).isFalse();
|
||||
verify(mapper, times(1)).completeTidyStep(anyString());
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ruoyi-admin -Dtest='TidyWorkflowTransactionTest,TidyWorkflowCoordinatorTest' test
|
||||
```
|
||||
|
||||
Expected: FAIL because workflow types/mapper methods are missing.
|
||||
|
||||
- [ ] **Step 2: Implement transactional workflow creation**
|
||||
|
||||
Inside one `@Transactional` method:
|
||||
|
||||
```java
|
||||
admission.assertAllowed(warehouseId,
|
||||
origin == TidyOrigin.AUTO_TIDY ? ExecutionWorkType.AUTO_TIDY : ExecutionWorkType.MANUAL_TIDY);
|
||||
mapper.acquireAlgorithmResourceAppLock(warehouseId, resourceKeys);
|
||||
mapper.revalidateTidyCandidate(candidate);
|
||||
mapper.insertTidyWorkflow(...);
|
||||
mapper.insertTidySteps(...);
|
||||
mapper.lockTidyResources(...);
|
||||
mapper.insertTidyMoveJob(firstStep);
|
||||
```
|
||||
|
||||
Each subsequent callback inserts `alg.tidy_callback_event` first. A duplicate unique-key event returns the current state without applying inventory/job transitions twice.
|
||||
|
||||
- [ ] **Step 3: Link outbound relocation origin and callback routing**
|
||||
|
||||
Persist `OUTBOUND_RELOCATION` on outbound workflows. Extend job query DTOs by workflow join. In `ToWmsServiceImpl.jobFinish`, try the existing outbound coordinator first, then the tidy coordinator only when outbound returns `noMatch`; callbacks for unrelated jobs retain existing behavior.
|
||||
|
||||
- [ ] **Step 4: Run outbound and tidy workflow regression**
|
||||
|
||||
```bash
|
||||
mvn -pl ruoyi-admin -Dtest='OutboundWorkflowTransactionTest,PersistentOutboundWorkflowCoordinatorTest,WmsAlgorithmMapperWorkflowSqlTest,TidyWorkflowTransactionTest,TidyWorkflowCoordinatorTest' test
|
||||
```
|
||||
|
||||
Expected: PASS, including `N MOVE_OUT + OUTBOUND + N RESTORE` assertions and tidy callback idempotency.
|
||||
|
||||
- [ ] **Step 5: Commit workflow files**
|
||||
|
||||
```bash
|
||||
git add ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyOrigin.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowState.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/WcsTaskState.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowTransaction.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowCoordinator.java \
|
||||
ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowTransactionTest.java \
|
||||
ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowCoordinatorTest.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/WmsAlgorithmMapper.java \
|
||||
ruoyi-admin/src/main/resources/mapper/algorithm/WmsAlgorithmMapper.xml \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/OutboundWorkflowTransaction.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/api/service/impl/ToWmsServiceImpl.java
|
||||
git commit -m "feat: execute persisted tidy move workflows"
|
||||
```
|
||||
|
||||
### Task 6: Add WCS Vehicle Snapshot and Manual Capacity Filling
|
||||
|
||||
**Files:**
|
||||
- Create: `D:/workSpace/PROJECT_SPACE/FT546/FT546-WCS/ruoyi-admin/src/main/java/com/ruoyi/api/domain/wcs2wms/SrgvVehicleSnapshot.java`
|
||||
- Modify: `D:/workSpace/PROJECT_SPACE/FT546/FT546-WCS/ruoyi-admin/src/main/java/com/ruoyi/api/controller/Wcs2WmsController.java`
|
||||
- Modify: `D:/workSpace/PROJECT_SPACE/FT546/FT546-WCS/ruoyi-admin/src/main/java/com/ruoyi/api/service/IApiService.java`
|
||||
- Modify: `D:/workSpace/PROJECT_SPACE/FT546/FT546-WCS/ruoyi-admin/src/main/java/com/ruoyi/api/service/impl/ApiServiceImpl.java`
|
||||
- Create: `D:/workSpace/PROJECT_SPACE/FT546/FT546-WCS/ruoyi-admin/src/test/java/com/ruoyi/api/service/ApiServiceVehicleSnapshotTest.java`
|
||||
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/VehicleSnapshot.java`
|
||||
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyVehicleCapacity.java`
|
||||
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/WcsVehicleSnapshotClient.java`
|
||||
- Create: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidyVehicleCapacityTest.java`
|
||||
- Create: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidyModeRecoveryTest.java`
|
||||
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyModeService.java`
|
||||
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowCoordinator.java`
|
||||
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyScheduler.java`
|
||||
|
||||
**Interfaces:**
|
||||
- WCS produces: `List<SrgvVehicleSnapshot> tidyVehicleSnapshot()`.
|
||||
- WMS consumes: `List<VehicleSnapshot> fetch()`.
|
||||
- Produces: nested `TidyVehicleCapacity.CapacityResult` and `CapacityResult calculate(List<VehicleSnapshot> vehicles, int activeManualWorkflows, Set<String> assignedManualVehicleIds)`, where `newSlots=max(0,effectiveCapacity-activeManualWorkflows)`.
|
||||
|
||||
- [ ] **Step 1: Write capacity and mode-recovery tests**
|
||||
|
||||
Use exactly these semantics:
|
||||
|
||||
```java
|
||||
VehicleSnapshot healthyIdle = new VehicleSnapshot("1", true, true, true, null);
|
||||
VehicleSnapshot healthyTidy = new VehicleSnapshot("2", true, true, false, "MANUAL_TIDY");
|
||||
VehicleSnapshot busyInbound = new VehicleSnapshot("3", true, true, false, "INBOUND");
|
||||
CapacityResult result = capacity.calculate(
|
||||
List.of(healthyIdle, healthyTidy, busyInbound), 1, Set.of("2"));
|
||||
assertThat(result.effectiveCapacity()).isEqualTo(2);
|
||||
assertThat(result.newSlots()).isEqualTo(1);
|
||||
```
|
||||
|
||||
Test `ENTERING_TIDY` drain to active, continuous replacement, capacity reduction without cancelling running work, switch-off cancellation of undispatched work, restart at all three non-normal checkpoints, and unknown WCS task -> `PAUSED_RECOVERY`. With three effective vehicles but only two mutually safe candidates, assert two workflows plus status reason `NO_SAFE_CANDIDATE`; the switch must remain enabled.
|
||||
|
||||
- [ ] **Step 2: Add the WCS read-only endpoint**
|
||||
|
||||
Derive fields from `device_srgv`:
|
||||
|
||||
```java
|
||||
boolean online = "1".equals(device.getAgvStatus());
|
||||
boolean healthy = online && value(device.getAlarm()) <= 1 && value(device.getErrMsg()) == 0;
|
||||
boolean idle = healthy && value(device.getTaskNo()) == 0
|
||||
&& value(device.getLocked()) == 0 && value(device.getChargeFlag()) == 0;
|
||||
```
|
||||
|
||||
Return stable vehicle ID and current task number/type. Add `ApiServiceVehicleSnapshotTest` for offline, alarmed, charging, locked, busy, and idle vehicles. Do not write WCS device or queue state from this endpoint.
|
||||
|
||||
- [ ] **Step 3: Add the WMS client and capacity reconciliation**
|
||||
|
||||
Use configurable `${fortucky.algorithm.tidy-wcs-snapshot-url:http://localhost:8081/wcs2Wms/api/tidyVehicleSnapshot}`. During `TIDY_ACTIVE`, fill `newSlots` with mutually disjoint candidates. During `EXITING_TIDY`, cancel only `CLAIMED/WAITING_DISPATCH` rows without a WCS task and drain dispatched/running rows.
|
||||
|
||||
- [ ] **Step 4: Verify WCS and WMS modules**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git -C D:/workSpace/PROJECT_SPACE/FT546/FT546-WCS diff --check
|
||||
mvn -f D:/workSpace/PROJECT_SPACE/FT546/FT546-WCS/pom.xml -pl ruoyi-admin -am -Dtest=ApiServiceVehicleSnapshotTest test
|
||||
mvn -pl ruoyi-admin -Dtest='TidyVehicleCapacityTest,TidyModeRecoveryTest,TidyWorkflowCoordinatorTest' test
|
||||
```
|
||||
|
||||
Expected: both Maven commands PASS.
|
||||
|
||||
- [ ] **Step 5: Commit exact WCS and WMS capacity files separately**
|
||||
|
||||
```bash
|
||||
git -C D:/workSpace/PROJECT_SPACE/FT546/FT546-WCS add \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/api/domain/wcs2wms/SrgvVehicleSnapshot.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/api/controller/Wcs2WmsController.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/api/service/IApiService.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/api/service/impl/ApiServiceImpl.java \
|
||||
ruoyi-admin/src/test/java/com/ruoyi/api/service/ApiServiceVehicleSnapshotTest.java
|
||||
git -C D:/workSpace/PROJECT_SPACE/FT546/FT546-WCS commit -m "feat: expose tidy vehicle capacity"
|
||||
|
||||
git add ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/VehicleSnapshot.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyVehicleCapacity.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/WcsVehicleSnapshotClient.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyModeService.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowCoordinator.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyScheduler.java \
|
||||
ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidyVehicleCapacityTest.java \
|
||||
ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidyModeRecoveryTest.java \
|
||||
ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidyWorkflowCoordinatorTest.java
|
||||
git commit -m "feat: fill manual tidy vehicle capacity"
|
||||
```
|
||||
|
||||
### Task 7: Add Operator Switch and Status Page
|
||||
|
||||
**Files:**
|
||||
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/controller/TidyModeRequest.java`
|
||||
- Create: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/controller/TidyControlController.java`
|
||||
- Create: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/controller/TidyControlControllerTest.java`
|
||||
- Create: `ruoyi-ui/src/api/algorithm/tidy.js`
|
||||
- Create: `ruoyi-ui/src/views/algorithm/tidy/index.vue`
|
||||
- Create: `sql/algorithm_tidy_menu.sql`
|
||||
|
||||
**Interfaces:**
|
||||
- `GET /algorithm/tidy/status?warehouseId=01` with permission `algorithm:tidy:query`.
|
||||
- `PUT /algorithm/tidy/mode` body `{ "warehouseId": "01", "enabled": true }` with permission `algorithm:tidy:edit`.
|
||||
|
||||
- [ ] **Step 1: Write failing controller tests**
|
||||
|
||||
```java
|
||||
mockMvc.perform(get("/algorithm/tidy/status").param("warehouseId", "01"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.effectiveState").value("NORMAL"));
|
||||
mockMvc.perform(put("/algorithm/tidy/mode")
|
||||
.contentType(APPLICATION_JSON)
|
||||
.content("{\"warehouseId\":\"01\",\"enabled\":true}"))
|
||||
.andExpect(status().isOk());
|
||||
verify(modeService).requestManualMode(eq("01"), eq(true), anyString());
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl ruoyi-admin -Dtest=TidyControlControllerTest test
|
||||
```
|
||||
|
||||
Expected: FAIL because controller is absent.
|
||||
|
||||
- [ ] **Step 2: Implement secured API and idempotent menu SQL**
|
||||
|
||||
Controller methods return `AjaxResult.success(snapshot)` and use `@PreAuthorize("@ss.hasPermi('algorithm:tidy:query')")` / `algorithm:tidy:edit`. The menu script uses `IF NOT EXISTS` by permission string and component `algorithm/tidy/index`.
|
||||
|
||||
- [ ] **Step 3: Implement compact operational page**
|
||||
|
||||
Use `<el-switch>` with confirmation and rollback on error. Show effective state, requested switch, drain count, active tidy, healthy/available vehicles, candidate count, and last reason. Poll every 5 seconds only while `document.visibilityState === 'visible'`; clear timer/listener in `beforeDestroy`.
|
||||
|
||||
- [ ] **Step 4: Verify backend and frontend build**
|
||||
|
||||
```bash
|
||||
mvn -pl ruoyi-admin -Dtest=TidyControlControllerTest test
|
||||
npm --prefix ruoyi-ui run build:prod
|
||||
```
|
||||
|
||||
Expected: controller PASS and Vue production build exits 0.
|
||||
|
||||
- [ ] **Step 5: Browser-check the page**
|
||||
|
||||
Start WMS and UI, then use Playwright at desktop `1440x900` and mobile `390x844`. Verify switch confirmation, failed-switch rollback, polling, no overlapping text, and status updates through `ENTERING_TIDY/TIDY_ACTIVE/EXITING_TIDY`.
|
||||
|
||||
- [ ] **Step 6: Commit UI/control files**
|
||||
|
||||
```bash
|
||||
git add ruoyi-admin/src/main/java/com/ruoyi/algorithm/controller/TidyModeRequest.java \
|
||||
ruoyi-admin/src/main/java/com/ruoyi/algorithm/controller/TidyControlController.java \
|
||||
ruoyi-admin/src/test/java/com/ruoyi/algorithm/controller/TidyControlControllerTest.java \
|
||||
ruoyi-ui/src/api/algorithm/tidy.js ruoyi-ui/src/views/algorithm/tidy/index.vue \
|
||||
sql/algorithm_tidy_menu.sql
|
||||
git commit -m "feat: add manual tidy control page"
|
||||
```
|
||||
|
||||
### Task 8: Build Deterministic PDA/WCS Batch Replay Harness
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/history-replay/simulators/ft546-wcs-simulator.mjs`
|
||||
- Create: `tools/history-replay/sql/select-batch-fixtures.sql`
|
||||
- Create: `tools/history-replay/sql/reconcile-tidy-batch.sql`
|
||||
- Create: `tools/history-replay/Invoke-Ft546TidyBatchReplay.ps1`
|
||||
- Create: `tools/history-replay/tests/Invoke-Ft546TidyBatchReplay.Tests.ps1`
|
||||
- Modify: `tools/history-replay/README.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Simulator: `POST /wcs2Wms/api/tidyVehicleSnapshot`, `/receiveInMydoJob`, `/receiveOutMydoJob`, `/sim/vehicles`, `/sim/complete`, `/sim/unknown-task`.
|
||||
- Runner: `-PreflightOnly`, `-ConfirmReplay`, `-KeepFailedState`, `-WmsBaseUrl`, `-SimulatorPort`.
|
||||
- Outputs: `inbound-reconciliation.csv`, `outbound-reconciliation.csv`, `tidy-reconciliation.csv`, `events.json`, `report.md`.
|
||||
|
||||
- [ ] **Step 1: Write failing Pester/static harness tests**
|
||||
|
||||
Require source hashes/counts, closed warehouse mapping, orphan exclusion, deterministic ordering, 100/300 inbound boundary, 4x25 outbound strata, at least 50 planner-selected cases, callback duplication/out-of-order cases, backup verification, run-scoped override removal, and final restore proof.
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
Invoke-Pester .\tools\history-replay\tests\Invoke-Ft546TidyBatchReplay.Tests.ps1 -Output Detailed
|
||||
```
|
||||
|
||||
Expected: FAIL because runner/simulator/queries do not exist.
|
||||
|
||||
- [ ] **Step 2: Implement deterministic fixture SQL**
|
||||
|
||||
Eligibility must be expressed in SQL and returned with `mapping_decision`:
|
||||
|
||||
```sql
|
||||
CASE
|
||||
WHEN NULLIF(LTRIM(RTRIM(source_warehouse)), '') IS NULL THEN 'MAP_BLANK_TO_01'
|
||||
WHEN source_warehouse = '07' THEN 'MAP_07_TO_01'
|
||||
WHEN source_warehouse = '01' THEN 'KEEP_01'
|
||||
ELSE 'UNSUPPORTED_SOURCE_WAREHOUSE'
|
||||
END
|
||||
```
|
||||
|
||||
Order by completion time, document ID, detail ID. Exclude `CKD202605140015-1`. Never update finish rows.
|
||||
|
||||
- [ ] **Step 3: Implement WCS simulator and event journal**
|
||||
|
||||
Use Node built-in `http`; default to three online healthy idle vehicles. Every received task and callback gets an immutable sequence number and timestamp. `/sim/unknown-task` makes the next WMS recovery lookup return unknown without deleting the journal.
|
||||
|
||||
- [ ] **Step 4: Implement guarded replay orchestration**
|
||||
|
||||
Reuse the existing `System.Data.SqlClient` helpers and exact six source-count checks. Flow: preflight -> install migration -> `COPY_ONLY, CHECKSUM` backup -> `RESTORE VERIFYONLY` -> sequence sync -> create run ID -> start simulator -> start an isolated WMS process -> inbound HTTP loop -> outbound just-in-time strata loop -> automatic tidy cases -> manual three-vehicle/capacity/restart cases -> reconcile -> write artifacts -> stop writers -> restore backup -> verify baseline.
|
||||
|
||||
The isolated WMS process receives these process-local values and uses a non-conflicting HTTP port:
|
||||
|
||||
```powershell
|
||||
$env:FORTUCKY_ALGORITHM_MODE = 'ACTIVE'
|
||||
$env:FORTUCKY_ALGORITHM_DATABASE_WRITES = 'true'
|
||||
$env:FT546_REPLAY_RUN_ID = $runId
|
||||
$env:FT546_REPLAY_DATABASE = 'FT546-wms'
|
||||
$env:FORTUCKY_TIDY_WCS_SNAPSHOT_URL = "http://127.0.0.1:$SimulatorPort/wcs2Wms/api/tidyVehicleSnapshot"
|
||||
```
|
||||
|
||||
Before starting Java, the runner proves the resolved JDBC database is exactly `FT546-wms`, no unrelated active work exists, and every generated business identifier carries `$runId`. A `finally` block stops only the process IDs it started and removes these environment values; the normal deployment configuration remains `SHADOW`.
|
||||
|
||||
Every HTTP call records request, response code/body, and related DB snapshot. A mismatch marks the run failed but does not remove its row. `-KeepFailedState` skips restore only when explicitly supplied for debugging and prints the verified backup path prominently.
|
||||
|
||||
- [ ] **Step 5: Run harness tests and preflight**
|
||||
|
||||
```powershell
|
||||
Invoke-Pester .\tools\history-replay\tests\Invoke-Ft546TidyBatchReplay.Tests.ps1 -Output Detailed
|
||||
pwsh -File .\tools\history-replay\Invoke-Ft546TidyBatchReplay.ps1 -PreflightOnly
|
||||
```
|
||||
|
||||
Expected: Pester PASS; preflight prints target `FT546-wms`, source counts/hashes, topology count, active-work count, migration status, and no mutations.
|
||||
|
||||
- [ ] **Step 6: Commit replay tooling**
|
||||
|
||||
```bash
|
||||
git add tools/history-replay/simulators/ft546-wcs-simulator.mjs \
|
||||
tools/history-replay/sql/select-batch-fixtures.sql \
|
||||
tools/history-replay/sql/reconcile-tidy-batch.sql \
|
||||
tools/history-replay/Invoke-Ft546TidyBatchReplay.ps1 \
|
||||
tools/history-replay/tests/Invoke-Ft546TidyBatchReplay.Tests.ps1 \
|
||||
tools/history-replay/README.md
|
||||
git commit -m "test: add historical tidy batch replay harness"
|
||||
```
|
||||
|
||||
### Task 9: Run Full Regression and Development-Database Replay
|
||||
|
||||
**Files:**
|
||||
- Generate only: `outputs/ft546-tidy-batch-replay-<run-id>/*`
|
||||
- Update if defects are found: only the owning task files and their tests.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes all preceding tasks.
|
||||
- Produces the final pass/fail evidence and restores the imported-history baseline.
|
||||
|
||||
- [ ] **Step 1: Run all automated Java and frontend checks**
|
||||
|
||||
```bash
|
||||
mvn -pl fortucky-algorithm,ruoyi-admin -am test
|
||||
mvn test
|
||||
mvn package -DskipTests
|
||||
npm --prefix ruoyi-ui run build:prod
|
||||
mvn -f D:/workSpace/PROJECT_SPACE/FT546/FT546-WCS/pom.xml -pl ruoyi-admin -am test
|
||||
```
|
||||
|
||||
Expected: all commands exit 0; record exact test totals and package SHA-256 values in `report.md`.
|
||||
|
||||
- [ ] **Step 2: Run SQL diagnostics before destructive replay**
|
||||
|
||||
Execute `install.sql` and `diagnostics.sql` against the confirmed development `FT546-wms`. Stop on any active job/workflow, orphan lock, invalid topology, wrong database identity, unsupported source count, or failed backup verification.
|
||||
|
||||
- [ ] **Step 3: Execute the full replay**
|
||||
|
||||
```powershell
|
||||
pwsh -File .\tools\history-replay\Invoke-Ft546TidyBatchReplay.ps1 -ConfirmReplay
|
||||
```
|
||||
|
||||
Expected acceptance:
|
||||
|
||||
```text
|
||||
Inbound attempted >= 100, mismatches = 0
|
||||
Outbound successful = 100 (0 blockers=25, 1=25, 2=25, 3+=25)
|
||||
Planner-selected outbound >= 50
|
||||
Automatic tidy max concurrency = 1
|
||||
Manual tidy capacity: 3 -> refill -> 2 -> no unsafe overfill
|
||||
Duplicate callback side effects = 0
|
||||
Restart checkpoints = 4/4 passed
|
||||
Unexpected PAUSED_RECOVERY = 0
|
||||
Restore verification = PASS
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Investigate failures test-first and rerun from restored baseline**
|
||||
|
||||
For each failing reconciliation row, first add the smallest reproducing unit/integration test to the owning task, observe red, implement the fix, run focused green, then rerun the full automated suite and replay. Never patch database evidence manually.
|
||||
|
||||
- [ ] **Step 5: Verify final artifacts and clean runtime processes**
|
||||
|
||||
Confirm all five artifact files exist and contain the run ID, source mapping, per-row evidence, failures (including zero-count sections), backup path, restore counts, and database fingerprint. Stop WMS, simulator, and temporary Redis processes started by the run; do not stop unrelated user processes.
|
||||
|
||||
- [ ] **Step 6: Commit only defect fixes and final documentation**
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Do not commit `outputs/` unless the repository's existing policy tracks test evidence. Report WMS and WCS commit IDs, test totals, replay run ID, artifact path, and restored database counts.
|
||||
1034
docs/superpowers/plans/2026-07-23-wms-native-tidy-recovery.md
Normal file
1034
docs/superpowers/plans/2026-07-23-wms-native-tidy-recovery.md
Normal file
File diff suppressed because it is too large
Load Diff
161
docs/superpowers/plans/2026-07-24-wms-manual-tidy-capacity.md
Normal file
161
docs/superpowers/plans/2026-07-24-wms-manual-tidy-capacity.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# WMS Manual Tidy Capacity Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make WMS use the WCS snapshot only as a dynamic manual-tidy admission signal while WCS remains the sole owner of physical vehicle selection.
|
||||
|
||||
**Architecture:** `TidyVehicleCapacity` combines current healthy online idle vehicles with a configured active-workflow ceiling. `ManualTidyScheduler` starts planner candidates up to that slot count without selecting or persisting a vehicle identifier. Existing schema columns remain for compatibility, but the runtime mapper binding operations are removed.
|
||||
|
||||
**Tech Stack:** Java 17, Spring Boot configuration properties, MyBatis XML, JUnit 5, AssertJ, Mockito, Maven.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- WCS remains the sole owner of physical vehicle dispatch.
|
||||
- Keep the WCS snapshot fail-closed: missing or invalid snapshots create zero new manual workflows.
|
||||
- Add `fortucky.algorithm.tidy-manual-max-concurrency` with default `3`.
|
||||
- Keep the `alg.tidy_workflow.vehicle_id` database column and diagnostic projection for compatibility.
|
||||
- Do not change automatic tidy scheduling or WCS APIs.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Dynamic Capacity Contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/TidyVehicleCapacityTest.java`
|
||||
- Modify: `fortucky-algorithm/src/test/java/com/ruoyi/algorithm/integration/AlgorithmConfigurationTest.java`
|
||||
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/TidyVehicleCapacity.java`
|
||||
- Modify: `fortucky-algorithm/src/main/java/com/ruoyi/algorithm/integration/AlgorithmProperties.java`
|
||||
- Modify: `ruoyi-admin/src/main/resources/application.yml`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `CapacityResult calculate(List<VehicleSnapshot> vehicles, int activeManualWorkflows, int maxConcurrency)`.
|
||||
- Produces: `int AlgorithmProperties.getTidyManualMaxConcurrency()` and `void setTidyManualMaxConcurrency(int value)`.
|
||||
|
||||
- [x] **Step 1: Write failing capacity and default tests**
|
||||
|
||||
Replace assigned-vehicle assertions with literal cases proving:
|
||||
|
||||
```java
|
||||
assertThat(capacity.calculate(vehiclesWithTwoIdleAndOneBusy, 1, 3).newSlots()).isEqualTo(2);
|
||||
assertThat(capacity.calculate(threeIdleVehicles, 1, 2).newSlots()).isEqualTo(1);
|
||||
assertThat(capacity.calculate(threeIdleVehicles, 2, 2).newSlots()).isZero();
|
||||
assertThat(capacity.calculate(unavailableVehicles, 0, 3).newSlots()).isZero();
|
||||
assertThat(new AlgorithmProperties().getTidyManualMaxConcurrency()).isEqualTo(3);
|
||||
```
|
||||
|
||||
- [x] **Step 2: Run tests and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
mvn -pl ruoyi-admin -am -Dtest=TidyVehicleCapacityTest,AlgorithmConfigurationTest -Dsurefire.failIfNoSpecifiedTests=false test
|
||||
```
|
||||
|
||||
Expected: test compilation fails because the new capacity signature and property getter do not exist.
|
||||
|
||||
- [x] **Step 3: Implement the minimal capacity and configuration changes**
|
||||
|
||||
Implement:
|
||||
|
||||
```java
|
||||
int idle = (int) vehicles.stream().filter(VehicleSnapshot::availableForManualTidy).count();
|
||||
int remainingByLimit = Math.max(0, maxConcurrency - Math.max(0, activeManualWorkflows));
|
||||
int newSlots = Math.min(idle, remainingByLimit);
|
||||
```
|
||||
|
||||
Add `tidyManualMaxConcurrency = 3` plus getter/setter to `AlgorithmProperties`, and bind:
|
||||
|
||||
```yaml
|
||||
tidy-manual-max-concurrency: ${FORTUCKY_TIDY_MANUAL_MAX_CONCURRENCY:3}
|
||||
```
|
||||
|
||||
- [x] **Step 4: Run focused tests and verify GREEN**
|
||||
|
||||
Run the command from Step 2. Expected: all specified tests pass.
|
||||
|
||||
### Task 2: Scheduler Ownership and Binding Removal
|
||||
|
||||
**Files:**
|
||||
- Create: `ruoyi-admin/src/test/java/com/ruoyi/algorithm/wms/tidy/ManualTidySchedulerTest.java`
|
||||
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/tidy/ManualTidyScheduler.java`
|
||||
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/algorithm/wms/WmsAlgorithmMapper.java`
|
||||
- Modify: `ruoyi-admin/src/main/resources/mapper/algorithm/WmsAlgorithmMapper.xml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `TidyVehicleCapacity.calculate(vehicles, activeWorkflows, properties.getTidyManualMaxConcurrency())`.
|
||||
- Removes: `selectActiveManualTidyVehicleIds` and `assignTidyWorkflowVehicle` runtime mapper operations.
|
||||
|
||||
- [x] **Step 1: Write a failing scheduler behavior test**
|
||||
|
||||
Create a scheduler with mocked database/external boundaries. Return two healthy idle vehicles, one active workflow, maximum concurrency `3`, and two planner candidates. Assert that `TidyWorkflowTransaction.start` receives both candidates in planner order. The existing implementation starts only one because it subtracts the active workflow from the already-idle snapshot.
|
||||
|
||||
- [x] **Step 2: Run the scheduler test and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
mvn -pl ruoyi-admin -am -Dtest=ManualTidySchedulerTest -Dsurefire.failIfNoSpecifiedTests=false test
|
||||
```
|
||||
|
||||
Expected: FAIL because only one workflow is started or compilation fails until the scheduler adopts the new capacity signature.
|
||||
|
||||
- [x] **Step 3: Implement scheduler ownership changes**
|
||||
|
||||
Remove assigned-vehicle collection and the vehicle iterator. Calculate slots with the configured ceiling, iterate only planner candidates, and call:
|
||||
|
||||
```java
|
||||
workflows.start(TidyOrigin.MANUAL_TIDY, warehouseId, candidate);
|
||||
```
|
||||
|
||||
Do not update a workflow vehicle. Log a candidate-oriented warning if workflow creation fails.
|
||||
|
||||
Remove the two unused vehicle-binding methods from `WmsAlgorithmMapper` and their MyBatis XML statements. Leave the schema column and diagnostics unchanged.
|
||||
|
||||
- [x] **Step 4: Run focused tidy tests and verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
mvn -pl ruoyi-admin -am -Dtest=TidyVehicleCapacityTest,ManualTidySchedulerTest,WcsVehicleSnapshotClientTest,AlgorithmConfigurationTest -Dsurefire.failIfNoSpecifiedTests=false test
|
||||
```
|
||||
|
||||
Expected: all specified tests pass.
|
||||
|
||||
### Task 3: Documentation, Full Verification, and Commit
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/specs/2026-07-24-wms-manual-tidy-capacity-design.md` only if implementation details changed.
|
||||
- Modify outside the WMS Git repository: `D:/workSpace/PROJECT_SPACE/FT546/docs/FT546-WMS-history-replay-handoff-2026-07-22.md`.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: deployable `ruoyi-admin/target/ruoyi-admin.jar` and an updated handoff record.
|
||||
|
||||
- [x] **Step 1: Run source and diff checks**
|
||||
|
||||
```powershell
|
||||
git diff --check
|
||||
```
|
||||
|
||||
- [x] **Step 2: Run the full reactor tests**
|
||||
|
||||
```powershell
|
||||
mvn test
|
||||
```
|
||||
|
||||
Expected: all reactor tests pass with zero failures and errors.
|
||||
|
||||
- [x] **Step 3: Build the deployment artifact**
|
||||
|
||||
```powershell
|
||||
mvn -DskipTests package
|
||||
```
|
||||
|
||||
Expected: all reactor modules report `SUCCESS`.
|
||||
|
||||
- [x] **Step 4: Update the handoff record**
|
||||
|
||||
Record the capacity-ownership change, verification totals, JAR size, and current SHA-256. Preserve the existing statement that an actual-WCS read-only snapshot smoke test remains a deployment step.
|
||||
|
||||
- [x] **Step 5: Review and commit exact files**
|
||||
|
||||
Run `git diff --cached --check`, inspect the complete staged diff, and commit only the implementation, tests, configuration, spec/plan files that belong to this change. Verify `git show --name-status HEAD` after committing.
|
||||
@@ -0,0 +1,405 @@
|
||||
# FT546 WMS Tidy Workflow and Historical Batch Replay Design
|
||||
|
||||
Date: 2026-07-22
|
||||
Status: Approved design, pending implementation plan
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This change completes the missing tidy trigger and execution workflow, then uses the imported site history to validate the real WMS allocation and execution paths in batches. PDA and WCS interactions are simulated through the existing HTTP interfaces; allocation, workflow persistence, inventory changes, and locking still run through production WMS services.
|
||||
|
||||
The validation must answer four questions with per-record evidence:
|
||||
|
||||
1. Does inbound allocate the correct cargo space and create the correct pallet inventory?
|
||||
2. Does outbound select the correct source pallet?
|
||||
3. Does deep-lane outbound identify the correct blockers and naturally create the correct relocation and restoration work?
|
||||
4. Do automatic and manual tidy modes create safe, executable work without starving or conflicting with inbound and outbound?
|
||||
|
||||
## 2. Scope
|
||||
|
||||
### 2.1 Included
|
||||
|
||||
- Automatic tidy triggered from lanes made dirty by completed inbound, outbound, or outbound relocation.
|
||||
- Manual tidy controlled by an operator switch.
|
||||
- A persisted tidy mode state machine and restart recovery.
|
||||
- A dedicated tidy workflow that reuses the existing `in_or_out=28` move-job execution chain.
|
||||
- A unified admission gate for every entry point that can allocate or create inbound, outbound, outbound-relocation, or tidy work.
|
||||
- Dynamic manual-tidy capacity based on online, healthy WCS vehicles.
|
||||
- Idempotent WCS callbacks and explicit lock/failure handling.
|
||||
- SQL Server migration for missing outbound algorithm objects and new tidy objects.
|
||||
- Historical batch replay with simulated PDA/WCS calls and per-record reconciliation reports.
|
||||
|
||||
### 2.2 Excluded
|
||||
|
||||
- Changing the three existing tidy-planning policies or their priority order except where a confirmed defect prevents safe execution.
|
||||
- Interrupting a pallet already being transported.
|
||||
- Replacing the existing WCS protocol or PDA pages.
|
||||
- Running the new algorithm in production `ACTIVE` mode.
|
||||
- Deleting or modifying imported historical finish tables as part of normal execution.
|
||||
- Forecasting a fixed number of relocation jobs before an outbound source pallet and its current blockers are known.
|
||||
|
||||
## 3. Confirmed Business Rules
|
||||
|
||||
### 3.1 Outbound relocation is demand-driven
|
||||
|
||||
Relocation is not an independent planned batch. It is created only when a selected outbound pallet is blocked in a deep lane.
|
||||
|
||||
For a selected source pallet with `N` current blocking pallets, one outbound workflow must contain:
|
||||
|
||||
- `N` `MOVE_OUT` steps;
|
||||
- one formal outbound step;
|
||||
- `N` `RESTORE` steps.
|
||||
|
||||
Each workflow step creates or resolves exactly one legacy job: every `MOVE_OUT` and `RESTORE` step creates one `in_or_out=28` relocation job, while the `OUTBOUND` step resolves the one formal outbound job created by exact-stock allocation. Counts are scoped to the single owning outbound workflow. Therefore its total step count and linked legacy-job count are both `2N+1`; exactly `2N` of those jobs have origin `OUTBOUND_RELOCATION`. When `N=0`, the shallow path contains only the formal outbound job and no outbound workflow relocation steps.
|
||||
|
||||
### 3.2 Automatic tidy
|
||||
|
||||
- Completed inbound, outbound, and outbound-relocation workflows upsert the affected lane into the existing dirty-lane rebuild queue. Successful state rebuilding then versions the lane in the persistent tidy-lane queue.
|
||||
- A scheduler scans pending lanes every 10 minutes.
|
||||
- Automatic tidy is allowed only in `NORMAL` mode.
|
||||
- At most one automatic tidy workflow may be active across the WMS at a time.
|
||||
- Existing inbound, outbound, or outbound-relocation execution has priority. If any such workflow is active, the scheduler does not start a new automatic tidy workflow.
|
||||
- A stale plan or a plan that loses a lock race is discarded and recalculated; stale cargo-space choices are never reused.
|
||||
|
||||
### 3.3 Manual tidy
|
||||
|
||||
- The operator controls manual tidy with an on/off switch.
|
||||
- Turning the switch on immediately blocks creation of new inbound, outbound, and outbound-relocation execution jobs, but still permits receipt and persistence of business documents.
|
||||
- Existing inbound, outbound, and outbound-relocation jobs drain normally. In-flight pallets are never cancelled or interrupted.
|
||||
- After the drain completes, manual tidy becomes exclusive and continuously fills `effectiveTidyCapacity` with safely admitted candidates.
|
||||
- Capacity is calculated dynamically and is never hard-coded to three. `effectiveTidyCapacity` equals the number of online, healthy vehicles that are either idle or already assigned to a non-terminal manual-tidy workflow; a vehicle busy with any other task does not contribute capacity. `newTidySlots = max(0, effectiveTidyCapacity - activeManualTidyWorkflows)`, where active includes claimed, waiting-dispatch, dispatched, and running manual workflows.
|
||||
- If three simulated vehicles are online and healthy, the coordinator targets three concurrent manual-tidy workflows. If only two safe candidates exist, it creates two; safety takes priority over vehicle saturation.
|
||||
- Completing one manual-tidy workflow causes the coordinator to plan a replacement while the switch remains on.
|
||||
- Turning the switch off stops creation of new tidy workflows. Already-dispatched tidy work drains before inbound and outbound admission resumes.
|
||||
- A safe candidate must still have the same source pallet and inventory, a free compatible destination, a valid current access path, and a resource set disjoint from every active inbound, outbound, relocation, and tidy workflow. The resource set contains the source/destination spaces, intervening access spaces, touched storage lanes, required buffers, and any floor exclusivity required by deep movement. All predicates and locks are rechecked atomically before admission.
|
||||
- If no safe candidate exists, manual mode remains enabled and reports `NO_SAFE_CANDIDATE`; it does not create conflicting work merely to fill vehicles.
|
||||
|
||||
## 4. Mode State Machine
|
||||
|
||||
The persisted system mode is one of:
|
||||
|
||||
```text
|
||||
NORMAL -> ENTERING_TIDY -> TIDY_ACTIVE -> EXITING_TIDY -> NORMAL
|
||||
```
|
||||
|
||||
| State | New business documents | New business execution jobs | Existing business execution jobs | New manual tidy jobs | New automatic tidy jobs |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `NORMAL` | Accept | Allow | Continue, including outbound relocation | Deny | Allow under auto rules |
|
||||
| `ENTERING_TIDY` | Accept | Deny, including new outbound relocation | Drain inbound, outbound, and outbound relocation | Deny | Deny |
|
||||
| `TIDY_ACTIVE` | Accept | Deny, including new outbound relocation | All three business execution types must be zero | Fill safe WCS capacity | Deny |
|
||||
| `EXITING_TIDY` | Accept | Deny, including new outbound relocation | All three business execution types must be zero | Deny | Deny |
|
||||
|
||||
Transitions are conditional:
|
||||
|
||||
- `NORMAL -> ENTERING_TIDY`: the operator turns the switch on.
|
||||
- `ENTERING_TIDY -> TIDY_ACTIVE`: no active or queued execution job remains for inbound, outbound, or outbound relocation.
|
||||
- `TIDY_ACTIVE -> EXITING_TIDY`: the operator turns the switch off. Claimed/planned work with no WCS task is cancelled and its reservations are released; dispatched or running work drains.
|
||||
- `EXITING_TIDY -> NORMAL`: all dispatched or running manual-tidy workflows reach a terminal state and their locks are released.
|
||||
|
||||
The requested switch value and effective state are stored separately. Repeated requests to set the same value are idempotent.
|
||||
|
||||
## 5. Architecture
|
||||
|
||||
### 5.1 Components
|
||||
|
||||
#### `TidyModeService`
|
||||
|
||||
Owns the persisted switch and state transitions. It exposes the effective mode, requested switch value, drain counts, active tidy count, vehicle capacity, candidate count, and last blocking reason.
|
||||
|
||||
#### Unified execution admission gate
|
||||
|
||||
Every code path that can allocate or create execution work must call one shared gate before beginning its transaction. This includes ERP/API, PDA, scheduled, automatic-allocation, manual-allocation, and retry paths.
|
||||
|
||||
The gate makes an admission decision for a work origin, not merely for a controller endpoint. This prevents an internal service or retry path from bypassing manual-tidy exclusivity.
|
||||
|
||||
Business documents may be accepted in every mode. In `ENTERING_TIDY`, `TIDY_ACTIVE`, and `EXITING_TIDY`, their allocation/execution state remains pending until the mode returns to `NORMAL`.
|
||||
|
||||
#### `TidyScheduler`
|
||||
|
||||
Performs the 10-minute automatic scan and short-interval mode reconciliation. It must use a database-backed singleton claim so multiple WMS instances cannot start duplicate automatic tidy work.
|
||||
|
||||
#### `TidyWorkflowCoordinator`
|
||||
|
||||
Requests candidates from the existing `TidyPlanner`, validates current capacity and mode, claims non-conflicting candidates, and creates workflows. Manual coordination fills the current capacity; automatic coordination is limited to one workflow.
|
||||
|
||||
#### `TidyWorkflowTransaction`
|
||||
|
||||
Creates and advances the persistent workflow, locks source and destination spaces, creates the legacy `in_or_out=28` move job, handles callbacks, updates lane state, and releases locks only at the defined terminal point.
|
||||
|
||||
#### WCS vehicle-capacity adapter
|
||||
|
||||
Returns a stable vehicle ID plus online, fault, and current-assignment state for each vehicle; aggregate counts are derived from that snapshot. Production uses the WCS/device source; batch testing uses a simulator implementing the same adapter contract. A vehicle is healthy only when it is online and has no active fault. It is idle only when WCS reports no current assignment. Workflow admission uses the `effectiveTidyCapacity` and `newTidySlots` formulas in section 3.3.
|
||||
|
||||
#### Tidy control API and operator view
|
||||
|
||||
The WMS exposes status retrieval and idempotent switch update operations. The view shows the switch, effective state, drain progress, active tidy workflows, online healthy vehicle capacity, safe-candidate count, and the last failure or blocking reason. Authorization and audit fields follow existing WMS management conventions.
|
||||
|
||||
### 5.2 Reused planning behavior
|
||||
|
||||
The existing `TidyPlanner` remains the source of candidates:
|
||||
|
||||
- `GUEST_RETURN`;
|
||||
- `DOWNGRADE_MERGE`;
|
||||
- `CONSOLIDATE_INCUBATION`.
|
||||
|
||||
The coordinator consumes planner output but does not trust it as a reservation. All candidate preconditions and locks are checked again inside the workflow-creation transaction.
|
||||
|
||||
### 5.3 Work origin
|
||||
|
||||
Every `in_or_out=28` job must be traceable to exactly one origin:
|
||||
|
||||
- `AUTO_TIDY`;
|
||||
- `MANUAL_TIDY`;
|
||||
- `OUTBOUND_RELOCATION`.
|
||||
|
||||
The origin is persisted on the owning algorithm workflow and exposed on job-query DTOs by joining the workflow relationship. The legacy `in_or_out` value is not overloaded with new numeric meanings.
|
||||
|
||||
## 6. Persistence
|
||||
|
||||
The SQL Server migration is idempotent and additive. It creates missing algorithm objects without deleting business data.
|
||||
|
||||
### 6.1 Existing algorithm objects required in the target database
|
||||
|
||||
The migration must install the version-pinned definitions maintained in the same commit under `fortucky-algorithm/src/main/resources/db/sqlserver/install.sql` when absent:
|
||||
|
||||
- `alg.outbound_workflow`;
|
||||
- `alg.outbound_workflow_step`;
|
||||
- `alg.outbound_workflow_space`;
|
||||
- `alg.dirty_lane_queue`.
|
||||
|
||||
These objects are in scope because deep outbound allocation, natural blocker relocation, restart recovery, and the required outbound reconciliation cannot execute without them. `alg.dirty_lane_queue` retains its existing responsibility as a short-lived topology/inventory/lane-state rebuild queue; it is not reused as the persistent tidy work queue. Their existing behavior is not otherwise redesigned by this feature.
|
||||
|
||||
### 6.2 New tidy objects
|
||||
|
||||
#### `alg.tidy_mode`
|
||||
|
||||
One persisted mode row per controlled WMS scope, containing the requested switch value, effective state, optimistic version, status reason, updater, and timestamps.
|
||||
|
||||
#### `alg.tidy_workflow`
|
||||
|
||||
One row per tidy candidate execution, containing warehouse/lane, origin, planner candidate type, source pallet and space, destination space, workflow status, plan version, related move-job identifier, failure reason, and lifecycle timestamps.
|
||||
|
||||
#### `alg.tidy_step`
|
||||
|
||||
Ordered execution steps and callback progress for a tidy workflow. A unique workflow-and-sequence constraint prevents duplicate step creation.
|
||||
|
||||
#### `alg.tidy_lane_queue`
|
||||
|
||||
Persistent, versioned tidy eligibility per warehouse and storage lane. It stores `dirty_version`, `PENDING`/`CLAIMED` state, claim owner/lease, retry time/reason, and latest business-change metadata. It survives consumption of the existing rebuild queue and application restarts.
|
||||
|
||||
#### `alg.tidy_space_lock`
|
||||
|
||||
Database-enforced active resource locks for each tidy workflow. Despite the legacy table name, each row has `resource_type` (`SPACE`, `LANE`, or `FLOOR`) and `resource_key`. The claimed set contains source/destination spaces, intervening access spaces, touched storage lanes, required buffers, and floor exclusivity when a deep workflow uses shared floor resources. An active unique key on warehouse, resource type, and resource key prevents conflicting tidy workflows.
|
||||
|
||||
Cross-origin admission is serialized with a SQL Server transaction-scoped application lock for the warehouse/resource keys. While holding it, the transaction checks both existing outbound workflow locks and tidy resource locks before persisting either workflow. This closes the race between outbound relocation and tidy even though their persisted lock tables differ.
|
||||
|
||||
### 6.3 Required constraints
|
||||
|
||||
- One effective `alg.tidy_mode` row per controlled scope.
|
||||
- A move job belongs to at most one tidy workflow step.
|
||||
- A space, lane, or floor resource has at most one active tidy lock, and the cross-origin transaction check prevents overlap with active outbound locks.
|
||||
- Callback event keys are unique within a workflow step.
|
||||
- Automatic workflow claim is singleton across application instances.
|
||||
- Workflow status transitions use optimistic version checks.
|
||||
|
||||
## 7. Runtime Data Flow
|
||||
|
||||
### 7.1 Dirty-lane marking
|
||||
|
||||
After an inbound, outbound, or relocation workflow commits its inventory and cargo-space result, the existing triggers upsert each affected lane in `alg.dirty_lane_queue`. For inbound this is the destination lane; for outbound it is the source lane; for relocation it is every storage lane whose occupancy changed on move-out or restore. Ports and relocation-buffer pseudo-lanes are excluded unless they are configured as storage lanes.
|
||||
|
||||
The existing dirty-lane processor first rebuilds topology, inventory snapshots, and lane state. In the same successful processing transaction, it upserts every rebuilt storage lane into `alg.tidy_lane_queue`; only then may it consume the corresponding `alg.dirty_lane_queue` row. A rebuild failure follows the existing requeue behavior and does not expose stale derived state to the tidy planner.
|
||||
|
||||
The tidy queue key is warehouse plus lane. Each upsert increments `dirty_version` and records latest reason/time. Queue state is `PENDING` or `CLAIMED`, with claim owner and lease expiry. A planner failure or expired claim returns the same version to `PENDING` with `retry_after` and reason. Completion clears the row only when its claimed `dirty_version` still equals the current version; a newer dirty event leaves it `PENDING`. This makes duplicate events, failures, restarts, and concurrent completions deterministic.
|
||||
|
||||
### 7.2 Automatic tidy flow
|
||||
|
||||
1. The scheduler confirms `NORMAL` mode, no active inbound, outbound, or outbound-relocation execution, and no active tidy workflow.
|
||||
2. In one transaction it locks the mode row, verifies that no manual switch transition has begun, and claims the oldest eligible `PENDING` row from `alg.tidy_lane_queue` by `last_dirty_at`, then warehouse/lane code as the deterministic tie-break.
|
||||
3. The planner calculates a candidate from current inventory and topology.
|
||||
4. The transaction revalidates the candidate and claims all required space, lane, and floor resources.
|
||||
5. The workflow and its `in_or_out=28` move job are committed together.
|
||||
6. Simulated or real WCS advances the existing movement callback chain.
|
||||
7. Inventory, cargo-space state, lane queue, workflow state, and locks are reconciled at completion.
|
||||
|
||||
### 7.3 Manual tidy flow
|
||||
|
||||
1. The operator enables the switch; mode becomes `ENTERING_TIDY` and the shared gate blocks new execution jobs.
|
||||
2. The coordinator waits for existing inbound, outbound, and outbound-relocation work to drain.
|
||||
3. Mode becomes `TIDY_ACTIVE`.
|
||||
4. The capacity adapter returns the current per-vehicle snapshot and the coordinator derives `effectiveTidyCapacity`.
|
||||
5. The coordinator calculates `newTidySlots` and safely claims that many mutually disjoint candidates. A candidate passes only when every safety predicate and resource claim defined in section 3.3 succeeds in the creation transaction.
|
||||
6. Each completion, capacity change, or periodic reconciliation triggers another fill attempt.
|
||||
7. On switch-off, mode becomes `EXITING_TIDY`; no replacement tidy work is created. Claimed or waiting-dispatch workflows without a WCS task are cancelled and released immediately; workflows with a WCS task drain.
|
||||
8. After active tidy workflows drain, all locks are reconciled and mode returns to `NORMAL`, allowing pending business documents to allocate.
|
||||
|
||||
## 8. Concurrency, Idempotency, and Recovery
|
||||
|
||||
### 8.1 Planning and lock races
|
||||
|
||||
A plan has no authority until its workflow transaction acquires all required locks and revalidates inventory, cargo-space status, pallet position, lane geometry, and mode version. On conflict, the plan is discarded and recalculated from current state.
|
||||
|
||||
### 8.2 WCS callback idempotency
|
||||
|
||||
Callbacks are keyed by workflow, step, WCS task, and event stage. A duplicate callback returns the prior successful result and cannot create a second job, move inventory twice, or release a lock early. Out-of-order callbacks are rejected with the current expected stage recorded for diagnosis.
|
||||
|
||||
### 8.3 Vehicle state changes
|
||||
|
||||
An offline or faulty vehicle immediately reduces future manual-tidy capacity. A workflow that has already begun physical movement is not cancelled automatically. A planned but undispatched workflow may be returned to the candidate pool only after its reservations are safely released.
|
||||
|
||||
> Superseded on 2026-07-23: WCS task-state lookup and the UNKNOWN-task replay case are replaced by WMS-native evidence recovery. See `docs/superpowers/specs/2026-07-23-wms-native-tidy-recovery-design.md` and `docs/superpowers/plans/2026-07-23-wms-native-tidy-recovery.md`. Vehicle snapshot behavior is not superseded.
|
||||
|
||||
### 8.4 Application restart
|
||||
|
||||
Mode, workflows, steps, and locks are authoritative in SQL Server. The WCS adapter supplies authoritative task state by WCS task ID and retains that simulated state across a WMS-only restart. Startup reconciliation:
|
||||
|
||||
1. reloads the effective mode and requested switch;
|
||||
2. compares non-terminal workflows with legacy jobs and WCS task state;
|
||||
3. resumes a workflow when the database and WCS agree on its non-terminal stage;
|
||||
4. moves a workflow to non-terminal `PAUSED_RECOVERY` when its database stage has no matching authoritative WCS task, or when pallet/inventory/space evidence permits more than one physical interpretation;
|
||||
5. repairs a stale lock only when its owner is terminal, or when the owner is missing and there is no active legacy/WCS task and inventory identifies one unambiguous stable pallet space;
|
||||
6. leaves all locks in place for `PAUSED_RECOVERY` and requires an audited operator reconciliation before retry or cancellation;
|
||||
7. resumes the appropriate drain or capacity-fill loop only after the preceding reconciliation finishes.
|
||||
|
||||
No in-memory flag is allowed to be the sole source of exclusivity.
|
||||
|
||||
### 8.5 Failed movement
|
||||
|
||||
A movement failure moves its workflow to `PAUSED_RECOVERY` and retains the locks needed to represent the uncertain physical state without a time-based auto-release. An operator or recovery service must reconcile the pallet's physical position with WMS before retry or cancellation. The only successful terminal states are `COMPLETED` and `CANCELLED_RECONCILED`. Cancellation is allowed only when inventory, cargo-space state, legacy job state, and locks can be moved to `CANCELLED_RECONCILED` in one transaction. An unexpected `PAUSED_RECOVERY` row makes the batch run fail; a deliberately injected recovery scenario passes only when the expected pause and retained locks are observed, followed by a successful audited reconciliation.
|
||||
|
||||
## 9. Historical Replay Design
|
||||
|
||||
### 9.1 Fixtures and preservation
|
||||
|
||||
The following imported finish data is the immutable historical fixture source:
|
||||
|
||||
- `in_stockin_finish.sql`;
|
||||
- `in_stockin_detail_finish.sql`;
|
||||
- `mydo_job_finish.sql`;
|
||||
- `mydo_job_detail_finish.sql`;
|
||||
- `out_stockout_finish.sql`;
|
||||
- `out_stockout_detail_finish.sql`.
|
||||
|
||||
The replay creates new run-scoped open documents and jobs. It does not turn finish rows back into active rows and does not update the source files.
|
||||
|
||||
Historical warehouse identifiers `07` or blank are mapped to the available topology warehouse `01`; an original value of `01` is retained as `01`. Any other warehouse value is excluded from the eligible set and reported as `UNSUPPORTED_SOURCE_WAREHOUSE`; it is never silently mapped. Every considered row retains its original warehouse value and mapping decision in the replay report. The known orphan outbound detail `CKD202605140015-1` is excluded from executable samples and reported as a fixture-integrity exception.
|
||||
|
||||
An eligible inbound detail has an existing historical header, positive quantity, resolvable product/master data, an original warehouse covered by the closed mapping rule above, and a tray that is unique within the replay run. Each historical detail may be replayed at most once per run. Eligible rows are ordered by historical completion time, document number, and detail number so the 100/300 sample boundary is reproducible. A failed execution counts as an attempted reconciliation row but not as a passed sample.
|
||||
|
||||
### 9.2 Identifier safety
|
||||
|
||||
Before replay, document, job, task, and inventory generators must consider both active and finish tables under a concurrency-safe allocation mechanism. The existing collision around `CK202607210002` is a mandatory regression case. Replay business identifiers also carry a unique run prefix so cleanup and reconciliation cannot select unrelated data.
|
||||
|
||||
### 9.3 Test environment sequence
|
||||
|
||||
1. Confirm the exact SQL Server instance and `FT546-wms` development database.
|
||||
2. Re-query baseline row counts, cargo-space state, active work, vehicle state, and algorithm schema; do not rely on recorded counts alone.
|
||||
3. Apply and verify the idempotent algorithm migration.
|
||||
4. Ensure no active business execution exists and validate the cargo-space baseline. The recorded 692-space count is a reference: if the live configured count differs, preflight stops for explicit operator validation. Consistency means every configured space has one valid state, every occupied storage space has matching pallet inventory, every inventory row resolves to one configured occupied space, and no active workflow/resource lock remains.
|
||||
5. Create a new restorable pre-test backup after migration and before replay.
|
||||
6. Start WMS plus PDA/WCS simulators and assign a unique replay run ID.
|
||||
7. Keep normal deployment default at `SHADOW`, but enable a run-scoped execution override only for the confirmed development database and replay run ID. Execute inbound, outbound, automatic-tidy, and manual-tidy phases through real application services and HTTP callbacks, then remove the override before final restoration.
|
||||
8. Freeze execution, generate reconciliation reports, and retain logs and failing payloads.
|
||||
9. Stop writers and restore the pre-test backup to return to the imported-history baseline with the migrated schema.
|
||||
10. Re-query counts and integrity checks to prove restoration.
|
||||
|
||||
No destructive cleanup or restore runs unless the resolved target is the confirmed development database and the new pre-test backup has been verified.
|
||||
|
||||
## 10. Batch Test Matrix
|
||||
|
||||
### 10.1 Inbound phase
|
||||
|
||||
Replay the first 100 eligible historical inbound details in the deterministic order from section 9.1. Compute outbound strata from a committed database snapshot. If any stratum has fewer than 25 eligible source pallets, replay the next deterministic inbound block and recompute, stopping when every stratum has 25 candidates or 300 unique eligible details have been attempted. Reaching 300, or exhausting the eligible set earlier, without all four strata is a run-level `OUTBOUND_STRATUM_COVERAGE_FAILED`; remaining phases may collect diagnostics but the run cannot pass.
|
||||
|
||||
For every inbound detail, record and assert:
|
||||
|
||||
- original historical document/detail and normalized replay identifiers;
|
||||
- product, batch, pallet, and requested quantity;
|
||||
- assigned cargo space exists in warehouse `01` and satisfies planner/topology rules;
|
||||
- allocation order does not create an invalid inner/outer accessibility state;
|
||||
- exactly one current inventory aggregate for the replay pallet/product/batch/location key, with no duplicate active stock rows representing the same physical pallet content;
|
||||
- inventory product, batch, quantity, pallet, and cargo-space code match the completed movement;
|
||||
- cargo-space occupancy and all workflow/space/floor locks match the terminal state;
|
||||
- duplicate PDA/WCS completion calls do not duplicate inventory.
|
||||
|
||||
### 10.2 Outbound phase
|
||||
|
||||
An eligible outbound fixture has an existing historical header, positive quantity, resolvable product/master data, a warehouse covered by section 9.1, and sufficient matching replay-created inventory at selection time. The orphan fixture is never eligible. Fixtures are considered by historical completion time, document number, and detail number and used at most once per run.
|
||||
|
||||
Execute exactly 25 successful outbound details in each current-blocker stratum `0`, `1`, `2`, and `3+` for a minimum of 100 successful cases. Selection is just-in-time from a committed snapshot because preceding outbounds can change accessibility. At least 50 cases must omit a tray override and therefore exercise planner source selection. Remaining stratum-coverage cases may request a historical source tray only when the imported finished job/detail relationship proves that tray belonged to the same historical outbound fixture and that tray exists in replay-created inventory. The report labels each case `PLANNER_SELECTED` or `HISTORICAL_TRAY_OVERRIDE`. Failed attempts remain in the report and are replaced from the same stratum, but still make the overall run fail. Do not fabricate blockers or directly edit cargo-space positions.
|
||||
|
||||
The source-pallet oracle uses the same published contract as `OutboundPlanner`, evaluated independently from the committed pre-allocation snapshot:
|
||||
|
||||
- an explicitly requested tray selects that exact eligible tray;
|
||||
- with FIFO off: lowest blocker count, then lane-release rank, floor number, and cargo-space ID;
|
||||
- with strict FIFO: oldest inbound timestamp, then blocker count, lane-release rank, efficiency score descending, timestamp, and cargo-space ID;
|
||||
- with tolerant FIFO: restrict to timestamps no later than oldest plus configured tolerance, then apply the same cost ordering as strict FIFO.
|
||||
|
||||
All comparisons and the final cargo-space-ID tie-break are recorded in the report. The production planner result must equal this independently calculated oracle result.
|
||||
|
||||
For every outbound detail, record and assert:
|
||||
|
||||
- requested product, batch/selection constraints, and quantity;
|
||||
- selected source pallet existed and held eligible stock; `PLANNER_SELECTED` cases must equal the independent oracle, while `HISTORICAL_TRAY_OVERRIDE` cases must equal the proven historical tray;
|
||||
- blockers are derived from the committed current topology after source selection;
|
||||
- for `N` blockers, the owning workflow has exactly `N` `MOVE_OUT`, one `OUTBOUND`, and `N` `RESTORE` steps, each resolving to exactly one distinct linked legacy job; exactly `2N` linked jobs have origin `OUTBOUND_RELOCATION` and one is the formal outbound job;
|
||||
- each blocker identity and temporary/restore destination is correct and non-conflicting;
|
||||
- only the formal outbound quantity is deducted from inventory;
|
||||
- blocking pallets retain correct inventory and end in the planned restored/final spaces;
|
||||
- source and blocking cargo-space states match physical completion;
|
||||
- workflow reservations, space locks, and floor locks are fully released at terminal completion;
|
||||
- repeated and out-of-order WCS callbacks follow the idempotency contract.
|
||||
|
||||
An outbound test cannot pass merely because the document closes; its source pallet, blockers, job counts, inventory delta, final pallet positions, and lock state must all reconcile.
|
||||
|
||||
### 10.3 Automatic tidy phase
|
||||
|
||||
- Create dirty-lane events through completed replay work, not by inserting a tidy workflow directly.
|
||||
- Verify the 10-minute scan or its test-controlled clock selects pending lanes.
|
||||
- Keep inbound/outbound work active and prove no automatic tidy starts.
|
||||
- Drain business work and prove exactly one automatic tidy may run.
|
||||
- Present multiple safe candidates and prove a second automatic tidy does not start until the first is terminal.
|
||||
- Force a lock race or stale candidate and prove replanning occurs without using the old destination.
|
||||
- Verify origin `AUTO_TIDY`, inventory/cargo-space correctness, and lock release. A claimed dirty version becomes clean only after successful tidy; a newer dirty version or failed plan remains `PENDING` under the queue rules in section 7.1.
|
||||
|
||||
### 10.4 Manual tidy phase
|
||||
|
||||
- Simulate three online, healthy vehicles and enough independent candidates; prove three manual-tidy workflows are admitted after the business drain.
|
||||
- Complete one workflow and prove one replacement is created while the switch remains on.
|
||||
- Reduce healthy capacity from three to two and prove no new work exceeds two active workflows after current physical work naturally drains.
|
||||
- Provide only two safe candidates with three healthy vehicles and prove only two workflows are created with `NO_SAFE_CANDIDATE`/capacity status visible for the unfilled slot.
|
||||
- Turn the switch on while inbound/outbound work exists and prove new execution allocation is blocked while existing work drains.
|
||||
- Submit new business documents during `TIDY_ACTIVE` and prove they are accepted but remain unallocated.
|
||||
- Turn the switch off and prove no replacement tidy is created, active tidy drains, mode returns to `NORMAL`, and pending business documents resume allocation.
|
||||
- Restart WMS at three reproducible non-normal checkpoints: `ENTERING_TIDY` with one held inbound callback, `TIDY_ACTIVE` with one running WCS task plus one waiting-dispatch workflow, and `EXITING_TIDY` with one running tidy task. After restart, verify respectively that admission remains blocked and the drain reaches active mode; WCS-agreed work resumes without duplicate dispatch and safe waiting work is reconciled; and no replacement starts before the running task completes and mode returns to `NORMAL`.
|
||||
- Add one recovery-negative scenario in which WCS deliberately returns unknown for a database-running task. Verify `PAUSED_RECOVERY`, retained resource locks, no replacement job, then audited reconciliation to `COMPLETED` or `CANCELLED_RECONCILED`.
|
||||
|
||||
## 11. Reporting and Acceptance
|
||||
|
||||
Each run writes artifacts under `outputs/ft546-tidy-batch-replay-<run-id>/`:
|
||||
|
||||
- `inbound-reconciliation.csv`: one row per inbound detail and each asserted field;
|
||||
- `outbound-reconciliation.csv`: one row per outbound detail, source pallet, blocker count, expected/actual step counts, inventory delta, and lock result;
|
||||
- `tidy-reconciliation.csv`: one row per automatic/manual tidy workflow, origin, candidate type, vehicle assignment, positions, inventory, and terminal state;
|
||||
- `events.json`: ordered request, callback, state-transition, and database evidence with secrets removed;
|
||||
- `report.md`: environment fingerprint, migration result, sample counts, stratum coverage, pass/fail summary, exceptions, restoration proof, and links to raw artifacts.
|
||||
|
||||
Acceptance requires all of the following:
|
||||
|
||||
- at least 100 inbound details and 100 successful outbound details executed through the real allocation/workflow path, with every attempted eligible inbound required to reconcile successfully;
|
||||
- exactly 25 successful cases in each of the four outbound blocker strata; `OUTBOUND_STRATUM_COVERAGE_FAILED` is a run failure and is shown with available/required counts by stratum;
|
||||
- zero unexplained inventory, pallet-position, cargo-space, workflow-step, or lock mismatches;
|
||||
- automatic tidy never exceeds one active workflow and never starts while business execution is active;
|
||||
- manual tidy respects effective healthy-vehicle capacity and mode exclusivity;
|
||||
- duplicate callbacks produce no duplicate side effects;
|
||||
- service restart recovery passes for all non-normal modes;
|
||||
- every unexpected failure is represented as a failed reconciliation row with evidence, never omitted from aggregate percentages; controlled negative scenarios are separately labelled and pass only when their exact expected state, locks, and recovery outcome match;
|
||||
- the database is restored and verified against the pre-test imported-history baseline.
|
||||
|
||||
## 12. Implementation Boundaries
|
||||
|
||||
- Preserve the `fortucky-algorithm` module boundary and existing WMS transaction manager/data source integration.
|
||||
- Keep the normal deployment default at `SHADOW`. The batch harness may use only the run-scoped development-database execution override defined in section 9.3; it must remove that override before restoration. Promotion to `CANARY` or `ACTIVE` remains a separate decision after SQL Server diagnostics, concurrency, idempotency, recovery, backup, and rollback gates pass.
|
||||
- Add only focused changes required for admission, tidy orchestration, persistence, UI control/status, simulation, and reconciliation.
|
||||
- Deliver in three ordered implementation phases: (A) migrations, origin tracing, and the unified admission gate; (B) persisted tidy state/workflow, WCS capacity, minimal operator switch/status, and recovery; (C) simulators, historical replay, reconciliation, and restoration tooling. Each phase has its own automated tests, but the feature is accepted only after the complete batch matrix passes.
|
||||
- Do not revert or overwrite unrelated uncommitted work already present in the repository.
|
||||
- Implementation must follow test-driven development: failing unit/integration tests first, then production changes, followed by SQL Server replay verification.
|
||||
@@ -0,0 +1,210 @@
|
||||
# WMS-Native Tidy Recovery Design
|
||||
|
||||
> Date: 2026-07-23
|
||||
> Status: Approved
|
||||
> Supersedes: the WCS task-state recovery portions of `2026-07-22-ft546-tidy-and-batch-replay-design.md`
|
||||
|
||||
## Context
|
||||
|
||||
A tidy step is executed as a normal WMS move job with `in_or_out=28`. The WMS
|
||||
creates that job, completes inventory movement inside `alg.usp_complete_move_job`,
|
||||
archives it to `mydo_job_finish`, advances `alg.tidy_step`, and releases workflow
|
||||
locks through the transactional `jobFinish` callback path.
|
||||
|
||||
The current restart reconciler also polls a WCS `tidyTaskState` endpoint every
|
||||
five seconds. The production WCS does not implement that endpoint; only the
|
||||
replay simulator does. More importantly, WCS task state is not the authoritative
|
||||
source for WMS job or tidy-workflow completion.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Make WMS persisted state the only authority for tidy completion and restart
|
||||
recovery.
|
||||
2. Preserve normal `in_or_out=28` job creation and callback execution.
|
||||
3. Repair a locally provable completed step idempotently after restart.
|
||||
4. Pause a workflow only when WMS-local evidence is missing or contradictory.
|
||||
5. Retain active locks for `PAUSED_RECOVERY` workflows.
|
||||
6. Remove the unused WCS task-state HTTP dependency and configuration.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not change the WCS vehicle snapshot endpoint; manual capacity still needs
|
||||
live vehicle availability.
|
||||
- Do not infer physical completion from vehicle telemetry.
|
||||
- Do not auto-complete a move without a matching WMS finish job and matching
|
||||
inventory/space evidence.
|
||||
- Do not repair unrelated legacy WMS/WCS endpoint mismatches in this change.
|
||||
- Do not change outbound-relocation workflow recovery.
|
||||
|
||||
## Considered Approaches
|
||||
|
||||
### A. WMS-native evidence recovery (selected)
|
||||
|
||||
Use `alg.tidy_workflow`, `alg.tidy_step`, `mydo_job`, `mydo_job_finish`,
|
||||
`bas_stock`, `bas_cargo_space`, and `alg.tidy_space_lock`. This keeps one source
|
||||
of truth, requires no new WCS API, and can distinguish safe repair from a true
|
||||
local inconsistency.
|
||||
|
||||
### B. Keep WCS state as advisory data
|
||||
|
||||
Query WCS only after a timeout and compare it with WMS state. This improves
|
||||
cross-system diagnostics but still requires a new production WCS endpoint,
|
||||
defines a second state contract, and cannot replace WMS transactional evidence.
|
||||
|
||||
### C. Callback-only recovery
|
||||
|
||||
Keep locks and wait forever for a callback. This is simple but leaves orphaned
|
||||
or partially imported records with no deterministic diagnosis or repair path.
|
||||
|
||||
Approach A is selected. WCS task querying can be introduced later as operational
|
||||
telemetry without making it a completion authority.
|
||||
|
||||
## Authoritative Data
|
||||
|
||||
For each active tidy workflow, recovery evaluates its first incomplete step and
|
||||
the deterministic move-job ID:
|
||||
|
||||
```text
|
||||
TDY-<workflowId>-<stepId>
|
||||
```
|
||||
|
||||
The recovery projection contains:
|
||||
|
||||
- workflow ID, workflow status, origin, and active lock count;
|
||||
- step ID, step number, step status, source space, target space, and tray code;
|
||||
- matching active `mydo_job` presence, status, type, source, target, and tray;
|
||||
- matching `mydo_job_finish` presence, status, type, source, target, and tray;
|
||||
- stock location and source/target cargo-space occupancy.
|
||||
|
||||
An active or finished job is a match only when all of these values agree with
|
||||
the tidy step:
|
||||
|
||||
```text
|
||||
job_id, in_or_out=28, tray_code, cargo_space_id, target_cargo_space_id
|
||||
```
|
||||
|
||||
## Recovery Decision Table
|
||||
|
||||
| WMS-local evidence | Action |
|
||||
| --- | --- |
|
||||
| Workflow already `PAUSED_RECOVERY` | Leave unchanged; do not poll or release locks. |
|
||||
| All workflow steps are `COMPLETED`, and no deterministic tidy job remains active | Idempotently complete the workflow and release its locks. |
|
||||
| Matching active move job has status `1`, `2`, or `3`; stock remains at source; source contains the tray; target does not contain it | Leave the step and locks active; normal dispatch/callback processing continues. |
|
||||
| Matching active move job exists but stock or cargo-space evidence is not in the expected pre-move state | Pause with reason `RECOVERY_ACTIVE_EVIDENCE_INVALID`; retain locks. |
|
||||
| No active job; matching finish job has status `4`; stock is at target; source is empty; target contains the tray | Replay only the tidy step/workflow advancement using callback stage `RECOVERY_FINISHED`. Do not execute inventory movement again. |
|
||||
| Active or finished job exists but its type, tray, source, or target differs | Pause the workflow and active step with reason `RECOVERY_JOB_MISMATCH`; retain locks. |
|
||||
| Finish job exists but status or inventory/space evidence is not terminal and consistent | Pause with reason `RECOVERY_FINISH_EVIDENCE_INVALID`; retain locks. |
|
||||
| Neither matching active nor matching finish job exists | Pause with reason `RECOVERY_JOB_MISSING`; retain locks. |
|
||||
|
||||
Database/query failure is not evidence of inconsistency. The transaction rolls
|
||||
back, the scheduler logs the failure, and the workflow remains unchanged until
|
||||
the next reconciliation cycle.
|
||||
|
||||
## Components
|
||||
|
||||
### `TidyWorkflowRecoveryService`
|
||||
|
||||
The service remains the restart and periodic reconciliation boundary, but it
|
||||
depends only on `WmsAlgorithmMapper` and `TidyWorkflowTransaction`. It never
|
||||
performs HTTP. It evaluates mapper projections inside the WMS transaction
|
||||
manager and applies exactly one decision per workflow per cycle.
|
||||
|
||||
### `TidyRecoveryEvidence`
|
||||
|
||||
A new immutable mapper projection carries the authoritative fields listed
|
||||
above. It contains data only; decision logic stays in the recovery service.
|
||||
|
||||
### `TidyWorkflowTransaction`
|
||||
|
||||
Normal `MOVE_FINISHED` and `OUTBOUND_FINISHED` callback behavior remains
|
||||
unchanged. A locally proven finish job is advanced through the same idempotent
|
||||
path with callback stage `RECOVERY_FINISHED`. The special `UNKNOWN` callback
|
||||
branch is removed; only the recovery service may enter `PAUSED_RECOVERY`.
|
||||
|
||||
### Mapper SQL
|
||||
|
||||
Replace the WCS-task-oriented recovery selection with WMS-local evidence SQL.
|
||||
The query must use the deterministic job ID and left joins to both active and
|
||||
finish job tables. Finished evidence must also project stock and cargo-space
|
||||
state. Existing complete-step, complete-workflow, release-lock, pause-workflow,
|
||||
and pause-step operations remain the mutation boundary.
|
||||
|
||||
### Configuration and deleted types
|
||||
|
||||
Delete:
|
||||
|
||||
- `WcsTaskState`;
|
||||
- `WcsTaskStateClient`;
|
||||
- `tidy-wcs-task-state-url` and `tidy-wcs-task-state-timeout-ms` properties;
|
||||
- `FORTUCKY_TIDY_WCS_TASK_STATE_URL` and timeout overrides.
|
||||
|
||||
Keep `tidy-wcs-snapshot-url` and `WcsVehicleSnapshotClient` unchanged.
|
||||
|
||||
## Concurrency and Idempotency
|
||||
|
||||
- Recovery runs in one WMS transaction.
|
||||
- The deterministic job ID prevents a second move job for the same step.
|
||||
- Callback-event insertion remains idempotent by workflow, step, job, and stage.
|
||||
- A finish-job repair never calls `alg.usp_complete_move_job`; inventory is not
|
||||
moved twice.
|
||||
- Concurrent duplicate callbacks may observe an already completed step, but
|
||||
next-job creation and resource release remain idempotent.
|
||||
- `PAUSED_RECOVERY` never releases locks automatically.
|
||||
|
||||
## Replay Changes
|
||||
|
||||
Remove the simulator `/sim/task-state` and `/sim/unknown-task` endpoints and the
|
||||
`FORTUCKY_TIDY_WCS_TASK_STATE_URL` replay override.
|
||||
|
||||
The replay will add two WMS-local restart cases while the database is protected
|
||||
by the existing verified backup:
|
||||
|
||||
1. **Repairable completion:** stop WMS after creating a tidy move job, complete
|
||||
the move through `alg.usp_complete_move_job` without advancing the tidy step,
|
||||
restart WMS, and prove the step/workflow advances without a second inventory
|
||||
move.
|
||||
2. **Missing local job:** stop WMS, remove the active replay move job while
|
||||
leaving its workflow/step/locks, restart WMS, and prove
|
||||
`PAUSED_RECOVERY`, reason `RECOVERY_JOB_MISSING`, and retained locks.
|
||||
|
||||
Both fixture mutations are run-scoped, journaled, and removed by the existing
|
||||
database restore. They are test setup, not evidence patching.
|
||||
|
||||
## Tests and Acceptance
|
||||
|
||||
Unit and mapper tests must prove:
|
||||
|
||||
- active matching job remains active and causes no mutation;
|
||||
- matching terminal finish evidence advances exactly once;
|
||||
- completed steps with no remaining active tidy job finish the workflow and
|
||||
release locks;
|
||||
- active jobs with invalid pre-move inventory evidence pause safely;
|
||||
- missing job pauses workflow and step while retaining locks;
|
||||
- mismatched or invalid finish evidence pauses with the correct reason;
|
||||
- an already paused workflow causes no repeated action;
|
||||
- database/client configuration contains no WCS task-state dependency;
|
||||
- normal and duplicate completion callbacks still pass.
|
||||
|
||||
Replay acceptance must prove:
|
||||
|
||||
- normal automatic and manual tidy behavior remains unchanged;
|
||||
- repairable local completion is recovered after restart;
|
||||
- missing local job enters `PAUSED_RECOVERY` with retained locks;
|
||||
- no task-state HTTP request appears in the event journal;
|
||||
- database restoration still reports `mismatches=[]`.
|
||||
|
||||
## Documentation
|
||||
|
||||
The existing 2026-07-22 design and plan remain historical records. Add a
|
||||
supersession note to their WCS task-state recovery sections, update the handoff
|
||||
document to describe WMS-native recovery, and generate a new final replay report
|
||||
instead of rewriting old evidence.
|
||||
|
||||
## Risks
|
||||
|
||||
- Legacy or manually edited finish rows may look terminal but disagree with
|
||||
inventory. The design pauses rather than guessing.
|
||||
- A workflow can remain paused until an operator repairs or reconciles the local
|
||||
inconsistency. Automatic lock release is intentionally prohibited.
|
||||
- The replay uses controlled SQL mutations to reproduce crash boundaries; every
|
||||
such mutation must be logged and covered by final baseline restoration.
|
||||
@@ -0,0 +1,74 @@
|
||||
# WMS Manual Tidy Dynamic Capacity Design
|
||||
|
||||
## Goal
|
||||
|
||||
Keep WCS as the sole owner of physical vehicle dispatch while allowing WMS to throttle manual tidy workflow creation from the current WCS vehicle snapshot and a configured concurrency ceiling.
|
||||
|
||||
## Current Problem
|
||||
|
||||
WMS currently pairs every new manual tidy workflow with a specific idle vehicle and stores that identifier in `alg.tidy_workflow.vehicle_id`. The generated `mydo_job` and the WMS-to-WCS contract do not contain a vehicle identifier, so WCS cannot honor that pairing and independently selects a vehicle.
|
||||
|
||||
The current capacity calculation also subtracts active workflows from a snapshot that already contains only idle vehicles. A vehicle executing an active workflow is therefore excluded once by `idle=false` and the workflow can consume capacity a second time. This can leave an actually idle vehicle unused.
|
||||
|
||||
## Decision
|
||||
|
||||
1. WCS remains responsible for selecting the physical vehicle for every tidy move job.
|
||||
2. WMS no longer assigns a vehicle identifier when it creates a manual tidy workflow.
|
||||
3. The WCS snapshot remains a fail-closed dynamic admission signal, not a vehicle reservation contract.
|
||||
4. WMS adds `fortucky.algorithm.tidy-manual-max-concurrency`, defaulting to `3`, as a hard ceiling for active manual tidy workflows.
|
||||
5. WMS calculates new workflow slots as:
|
||||
|
||||
```text
|
||||
min(healthy online idle vehicles, max(0, configured maximum - active manual workflows))
|
||||
```
|
||||
|
||||
6. A non-positive configured maximum disables creation of new manual tidy workflows.
|
||||
|
||||
## Runtime Flow
|
||||
|
||||
On every manual tidy reconciliation cycle, WMS first performs workflow recovery and confirms that the warehouse is actively in manual tidy mode. It then fetches the WCS vehicle snapshot, counts vehicles satisfying `online && healthy && idle`, reads the number of active manual tidy workflows, and applies the configured ceiling.
|
||||
|
||||
WMS asks the planner for candidates only when at least one slot exists. It starts at most the calculated number of workflows in planner order. No candidate is paired with a snapshot row, and no `vehicle_id` update is issued. WCS later receives the ordinary move job and chooses the actual vehicle using its existing dispatch rules.
|
||||
|
||||
The manual scheduling cycle is one database transaction. `TidyModeService.reconcile` reads the warehouse's `alg.tidy_mode` row with `UPDLOCK, HOLDLOCK`; keeping that lock until the scheduling transaction commits serializes capacity calculation and workflow creation for the same warehouse across WMS instances. Different warehouses continue to use different mode rows.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
If the WCS snapshot URL is absent, the request fails, the response is empty, or the response cannot be parsed, the snapshot client returns an empty list. The calculated idle count is then zero, so WMS creates no new manual tidy workflows during that cycle. Existing workflows and WCS queues continue independently and can still complete.
|
||||
|
||||
This fail-closed behavior avoids building an unbounded WCS queue when real-time fleet availability is unknown. Operators can distinguish this condition through the existing snapshot warning log.
|
||||
|
||||
If any workflow creation fails, the scheduler logs the candidate type and propagates the exception. The complete scheduling batch rolls back so a partial batch cannot consume capacity outside the locked count. The next scheduled cycle can retry from a consistent database state.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The existing `alg.tidy_workflow.vehicle_id` column, SQL Server index include, and diagnostic projection remain in place for schema and historical-data compatibility. Runtime code stops reading or writing the column for new manual tidy scheduling. No WCS API or database migration is required.
|
||||
|
||||
Automatic tidy scheduling is unchanged and continues to run only in `NORMAL` mode without consulting the WCS vehicle snapshot.
|
||||
|
||||
## Configuration
|
||||
|
||||
`AlgorithmProperties` exposes `tidyManualMaxConcurrency` with a default value of `3`.
|
||||
|
||||
`application.yml` binds:
|
||||
|
||||
```yaml
|
||||
tidy-manual-max-concurrency: ${FORTUCKY_TIDY_MANUAL_MAX_CONCURRENCY:3}
|
||||
```
|
||||
|
||||
The environment variable allows each warehouse to lower or raise the ceiling without rebuilding WMS.
|
||||
|
||||
## Tests
|
||||
|
||||
The implementation must prove the following behavior with tests written before production changes:
|
||||
|
||||
1. One busy manual workflow does not consume an already-filtered idle vehicle slot twice.
|
||||
2. The configured ceiling limits new slots even when more vehicles are idle.
|
||||
3. Active workflows at or above the configured ceiling produce zero new slots.
|
||||
4. Offline, unhealthy, and busy vehicles do not produce slots.
|
||||
5. Manual scheduling starts workflows in planner order without reading or writing vehicle bindings.
|
||||
6. The default configured maximum is `3`.
|
||||
7. The scheduling cycle has a rollback transaction that holds the existing warehouse mode-row lock.
|
||||
8. Workflow creation failures propagate and prevent later candidates in the same batch from starting.
|
||||
|
||||
Focused tests must pass before running the full Maven reactor test suite and package build.
|
||||
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,74 @@
|
||||
package com.ruoyi.algorithm.integration;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "fortucky.algorithm")
|
||||
public class AlgorithmProperties {
|
||||
private SchedulingMode mode = SchedulingMode.SHADOW;
|
||||
private String warehouseId = "01";
|
||||
private int pageSize = 100;
|
||||
private boolean databaseWritesEnabled = true;
|
||||
private boolean legacyFallbackOnTechnicalError;
|
||||
private long tidyAutoScanMs = 600_000L;
|
||||
private long tidyReconcileMs = 5_000L;
|
||||
private int tidyClaimLeaseSeconds = 60;
|
||||
private int tidyManualMaxConcurrency = 3;
|
||||
private String tidyWcsSnapshotUrl = "http://localhost:8081/wcs2Wms/api/tidyVehicleSnapshot";
|
||||
private String replayRunId = "";
|
||||
private String replayDatabase = "";
|
||||
|
||||
public SchedulingMode getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
public void setMode(SchedulingMode mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public String getWarehouseId() {
|
||||
return warehouseId;
|
||||
}
|
||||
|
||||
public void setWarehouseId(String warehouseId) {
|
||||
this.warehouseId = warehouseId;
|
||||
}
|
||||
|
||||
public int getPageSize() {
|
||||
return pageSize;
|
||||
}
|
||||
|
||||
public void setPageSize(int pageSize) {
|
||||
this.pageSize = pageSize;
|
||||
}
|
||||
|
||||
public boolean isDatabaseWritesEnabled() {
|
||||
return databaseWritesEnabled;
|
||||
}
|
||||
|
||||
public void setDatabaseWritesEnabled(boolean databaseWritesEnabled) {
|
||||
this.databaseWritesEnabled = databaseWritesEnabled;
|
||||
}
|
||||
|
||||
public boolean isLegacyFallbackOnTechnicalError() {
|
||||
return legacyFallbackOnTechnicalError;
|
||||
}
|
||||
|
||||
public void setLegacyFallbackOnTechnicalError(boolean legacyFallbackOnTechnicalError) {
|
||||
this.legacyFallbackOnTechnicalError = legacyFallbackOnTechnicalError;
|
||||
}
|
||||
|
||||
public long getTidyAutoScanMs() { return tidyAutoScanMs; }
|
||||
public void setTidyAutoScanMs(long value) { this.tidyAutoScanMs = value; }
|
||||
public long getTidyReconcileMs() { return tidyReconcileMs; }
|
||||
public void setTidyReconcileMs(long value) { this.tidyReconcileMs = value; }
|
||||
public int getTidyClaimLeaseSeconds() { return tidyClaimLeaseSeconds; }
|
||||
public void setTidyClaimLeaseSeconds(int value) { this.tidyClaimLeaseSeconds = value; }
|
||||
public int getTidyManualMaxConcurrency() { return tidyManualMaxConcurrency; }
|
||||
public void setTidyManualMaxConcurrency(int value) { this.tidyManualMaxConcurrency = value; }
|
||||
public String getTidyWcsSnapshotUrl() { return tidyWcsSnapshotUrl; }
|
||||
public void setTidyWcsSnapshotUrl(String value) { this.tidyWcsSnapshotUrl = value; }
|
||||
public String getReplayRunId() { return replayRunId; }
|
||||
public void setReplayRunId(String value) { this.replayRunId = value; }
|
||||
public String getReplayDatabase() { return replayDatabase; }
|
||||
public void setReplayDatabase(String value) { this.replayDatabase = value; }
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user