feat: add warehouse stock query MCP tool
This commit is contained in:
committed by
my_local_kimi_dev2
parent
798075f47e
commit
487a41df74
@@ -0,0 +1,45 @@
|
||||
# REQ-20260915-0011 代码评审报告
|
||||
|
||||
- 工具:`warehouse.stock.list.query`
|
||||
- 评审任务:`21775`
|
||||
- 评审结论:通过,无阻塞问题
|
||||
- 评审范围:库存实体与 Mapper、`WarehouseStockTools`、库存专用权限过滤、专项测试及工具文档
|
||||
|
||||
## 变更与设计一致性
|
||||
|
||||
实现与详细设计一致:工具保持只读,查询 `warehouse_stock_manage` 快照表;输入支持仓库、所属公司、物品名称、编号、分类和规格组合筛选;分页默认 1/20、上限 100;结果固定按库存量和记录 ID 倒序。返回值采用显式白名单,并对库存空值及修改时间进行规范化处理。
|
||||
|
||||
关键实现位于 `src/main/java/me/iiv/agent/mcp/tool/WarehouseStockTools.java:41` 至 `:125`。行级权限实现位于 `src/main/java/me/iiv/agent/permission/PermissionService.java:195` 至 `:213`。专项测试位于 `src/test/java/me/iiv/agent/mcp/tool/WarehouseStockToolsTest.java:55` 至 `:114` 和 `src/test/java/me/iiv/agent/permission/PermissionServiceWarehouseStockRowFilterTest.java:52` 至 `:90`。
|
||||
|
||||
## 五视角扫描结果
|
||||
|
||||
### 1. 攻击者视角
|
||||
|
||||
筛选值通过 MyBatis-Plus 查询构造器参数化绑定,名称、编号和规格未直接拼接进 SQL。唯一显式拼接的分页片段来自已规范化的整数 page/rows;rows 被限制为 100,offset 使用 long 参与乘法,未发现可控 SQL 结构注入路径。工具始终添加 `soft_delete = 0`,不会通过普通筛选读取软删记录。工具标注为 warehouse/read,方法中只执行 count 和 selectList,不包含库存新增、扣减、预占或回写逻辑。
|
||||
|
||||
### 2. 泄露者视角
|
||||
|
||||
代理权限在 `PermissionService.java:199-213` 中独立执行。总部身份不追加过滤;代理身份仅组合本人 agentId 与 `warehouse_agent_auth` 授权的 managerAgentId,并以括号化的 `agent_id IN (...) OR warehouse_owner_id IN (...)` 约束结果。代理用户缺少 agentId 时直接拒绝访问,未降级为总部权限。输出映射位于 `WarehouseStockTools.java:95-120`,只返回仓库、公司、物料和库存业务字段,不包含 createId、modifyId、softDelete 等内部维护信息。
|
||||
|
||||
### 3. 并发者视角
|
||||
|
||||
工具无共享可变业务状态,时间格式器为不可变的 `DateTimeFormatter`,每次调用均新建查询 wrapper 和返回 Map,不存在跨请求复用条件或结果的问题。count 与 list 是两个读取时点,库存并发变化时 total 与当前页可能短暂不完全一致;详细设计已明确该工具是运营查询而非事务级快照接口,此行为可接受。稳定排序在 `WarehouseStockTools.java:78-80` 使用 `current_inventory DESC, id DESC`,降低相同库存量下翻页顺序的不确定性。
|
||||
|
||||
### 4. 边界者视角
|
||||
|
||||
page 为空或小于 1 时归一为 1;rows 为空或小于 1 时归一为 20,超过 100 时截断并返回 notice。空 ID 集合不会生成 `IN ()`,空白字符串不参与筛选且有效字符串会去除首尾空格。库存字段为空时按 0 返回,`availableInventory = currentInventory - outOfStock`,负值不会被静默钳制,能够暴露异常库存。修改时间为空时返回 null,否则按 Asia/Shanghai 格式化。页码超出结果范围时 Mapper 正常返回空列表。
|
||||
|
||||
### 5. 依赖者视角
|
||||
|
||||
实体与 Mapper 使用现有 m2 数据源和 MyBatis-Plus 单表查询路径,没有引入远程服务、逐条富化或 N+1 查询。权限复用现有 `managedAuths` 读取授权关系,保持与其他仓库工具一致的软删约束。Spring AI schema 测试验证工具名及关键参数可发现;查询测试验证组合筛选、分页截断、稳定排序、库存派生值与上海时区;权限测试覆盖总部、代理自有/代管公司以及缺少 agentId 的 fail-closed 分支。
|
||||
|
||||
## 约定与质量检查
|
||||
|
||||
- 库存工具及权限专项测试通过。
|
||||
- 项目全量 Java 测试此前通过,共 224 项。
|
||||
- `git diff --check` 通过。
|
||||
- 当前工作区还混有入库、出库工具的未提交改动,因此本次评审不以提交或 PR 边界作为变更范围依据;评审范围按上述库存文件和对应文档限定。
|
||||
|
||||
## 发现汇总与结论
|
||||
|
||||
未发现 Critical、High、Medium 或阻塞级问题。行级权限与老接口语义一致,分页与稳定排序正确,返回字段白名单符合设计,空值处理和可用库存计算正确,专项测试覆盖关键正反例。评审结论为通过,可进入测试阶段。
|
||||
@@ -0,0 +1,263 @@
|
||||
# 库存物品分页查询 MCP 工具详细设计
|
||||
|
||||
> 需求:`REQ-20260915-0011`
|
||||
> 工具:`warehouse.stock.list.query`
|
||||
> 对齐接口:`POST /warehouseStatis/listByPage`
|
||||
> 工具属性:`warehouse` / `read`
|
||||
> 数据源:m2 `warehouse_stock_manage`
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
老系统库存物品列表只能通过页面接口查询,Agent 无法在统一鉴权、工具作用域和审计体系下读取库存。
|
||||
本需求将列表查询封装为只读 MCP 工具,使 Agent 能按仓库、所属公司和物品属性分页检索库存,并保持
|
||||
老接口的代理商行级权限语义。
|
||||
|
||||
目标如下:
|
||||
|
||||
1. 暴露稳定、可发现的库存分页查询契约。
|
||||
2. 保持总部与代理商的数据可见范围和老接口一致。
|
||||
3. 返回库存业务白名单字段,并补充可用库存派生值。
|
||||
4. 限制单次查询规模,避免 Agent 发起无界读取。
|
||||
|
||||
## 2. 范围与非目标
|
||||
|
||||
### 2.1 本期范围
|
||||
|
||||
- 新增 `warehouse.stock.list.query` MCP 工具。
|
||||
- 新增 `WarehouseStockManage` 只读映射和 `WarehouseStockManageMapper`。
|
||||
- 在 `PermissionService` 新增库存专用行级权限过滤。
|
||||
- 支持组合筛选、分页、稳定排序、库存空值归零和可用库存计算。
|
||||
- 补充工具清单、详细设计和单元测试。
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
- 不实现老系统 `/warehouseStatis/statisticsQuery` 的全量聚合;如需要,另建
|
||||
`warehouse.stock.summary.query` 并单独评估权限和查询成本。
|
||||
- 不新增、扣减、预占或校正库存,不修改任何业务表。
|
||||
- 不返回库存变更流水、入出库单详情或 SN 级资产明细。
|
||||
- 不改变老系统接口行为,也不复用其 HTTP Controller。
|
||||
|
||||
## 3. 总体设计
|
||||
|
||||
调用链如下:
|
||||
|
||||
```text
|
||||
MCP Client
|
||||
-> Token scope 校验
|
||||
-> WarehouseStockTools.warehouseStockListQuery
|
||||
-> 规范化分页和筛选参数
|
||||
-> PermissionService.applyWarehouseStockRowFilter
|
||||
-> WarehouseStockManageMapper.selectCount/selectList
|
||||
-> 字段白名单映射及派生值计算
|
||||
-> total/page/rows/notice/records
|
||||
```
|
||||
|
||||
工具直接查询库存快照表,避免跨服务调用和 N+1 富化。仓库、公司、物品名称等均使用表内快照字段,
|
||||
因此结果反映库存记录最近一次维护时的业务快照。
|
||||
|
||||
## 4. 工具契约
|
||||
|
||||
### 4.1 输入参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认值 | 规则 | 数据库条件 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `warehouseIds` | `array<long>` | 否 | - | 空数组视为不筛选 | `warehouse_id IN (...)` |
|
||||
| `warehouseOwnerId` | `long` | 否 | - | 精确匹配仓库所属公司 | `warehouse_owner_id = ?` |
|
||||
| `materialName` | `string` | 否 | - | 去除首尾空格;空白不筛选 | `materiel_full_name LIKE` |
|
||||
| `materialNo` | `string` | 否 | - | 去除首尾空格;空白不筛选 | `material_no LIKE` |
|
||||
| `materialCategoryIds` | `array<long>` | 否 | - | 空数组视为不筛选 | `materiel_index_id IN (...)` |
|
||||
| `specifications` | `string` | 否 | - | 去除首尾空格;空白不筛选 | `specifications LIKE` |
|
||||
| `page` | `integer` | 否 | `1` | 小于 1 时归一为 1 | SQL offset |
|
||||
| `rows` | `integer` | 否 | `20` | 小于 1 时归一为 20;大于 100 截断为 100 | SQL limit |
|
||||
|
||||
所有业务筛选条件为 AND 关系;行级权限条件作为一个括号分组追加。查询始终包含
|
||||
`soft_delete = 0`。
|
||||
|
||||
### 4.2 返回结构
|
||||
|
||||
顶层字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `total` | `long` | 权限及筛选条件命中的总记录数 |
|
||||
| `page` | `integer` | 归一化后的当前页码 |
|
||||
| `rows` | `integer` | 实际采用的每页条数 |
|
||||
| `notice` | `string` | 仅在 `rows > 100` 时返回截断提示 |
|
||||
| `records` | `array<object>` | 当前页库存记录;无数据时为空数组 |
|
||||
|
||||
`records` 字段白名单:
|
||||
|
||||
| 字段 | 来源/计算 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | `id` | 库存记录 ID |
|
||||
| `warehouseId` / `warehouseNo` / `warehouseName` | 同名仓库快照列 | 仓库信息 |
|
||||
| `agentId` / `agentName` | 管理公司快照列 | 库存管理公司 |
|
||||
| `warehouseOwnerId` / `warehouseOwnerName` | 所属公司快照列 | 仓库所属公司 |
|
||||
| `materialDefinitionId` | `materiel_definition_id` | 物料定义 ID |
|
||||
| `materialNo` / `materialName` | `material_no` / `materiel_full_name` | 物品编号和名称 |
|
||||
| `materialCategoryId` / `materialCategory` | `materiel_index_id` / `materiel_sort` | 分类 ID 和分类快照 |
|
||||
| `specifications` / `snCode` | 同名列 | 规格和库存表 SN 快照 |
|
||||
| `currentInventory` | `COALESCE(current_inventory, 0)` | 当前库存 |
|
||||
| `outOfStock` | `COALESCE(out_of_stock, 0)` | 出库中/预占库存 |
|
||||
| `availableInventory` | `currentInventory - outOfStock` | 可用库存;允许如实返回负数以暴露异常 |
|
||||
| `batteryInventory` | `COALESCE(battery_inventory, 0)` | 电池费用化库存 |
|
||||
| `updatedAt` | `modify_time` | 上海时区 `yyyy-MM-dd HH:mm:ss`;无值返回 null |
|
||||
|
||||
不返回 `create_id`、`create_time`、`modify_id`、`soft_delete` 等内部维护字段。
|
||||
|
||||
### 4.3 排序与分页
|
||||
|
||||
- 固定按 `current_inventory DESC, id DESC` 排序。
|
||||
- `id DESC` 作为同库存量下的稳定次序,减少翻页时顺序不确定。
|
||||
- offset 使用 `(page - 1) * rows`,计算过程使用 long,避免 int 乘法溢出。
|
||||
- count 与列表复用同一组过滤条件;排序和 limit 仅追加到列表查询。
|
||||
|
||||
返回示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"rows": 20,
|
||||
"records": [
|
||||
{
|
||||
"id": 901,
|
||||
"warehouseId": 8,
|
||||
"warehouseNo": "WH-008",
|
||||
"warehouseName": "一号仓",
|
||||
"agentId": 10,
|
||||
"agentName": "管理公司A",
|
||||
"warehouseOwnerId": 10,
|
||||
"warehouseOwnerName": "所属公司A",
|
||||
"materialDefinitionId": 31,
|
||||
"materialNo": "MAT-031",
|
||||
"materialName": "擦手纸",
|
||||
"materialCategoryId": 3,
|
||||
"materialCategory": "纸品",
|
||||
"specifications": "A型",
|
||||
"snCode": null,
|
||||
"currentInventory": 20,
|
||||
"outOfStock": 4,
|
||||
"availableInventory": 16,
|
||||
"batteryInventory": 0,
|
||||
"updatedAt": "2026-09-15 17:30:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 行级权限设计
|
||||
|
||||
权限对齐老系统 `WrapperUtils.getWarehouseAuth`,但在 Agent 服务中独立实现,避免依赖老系统静态工具。
|
||||
|
||||
| 当前身份 | 可见范围 | SQL 行过滤 |
|
||||
| --- | --- | --- |
|
||||
| 总部(`isagent=0`) | 全部未删除库存 | 不追加公司过滤 |
|
||||
| 代理(`isagent=1`) | 本公司,以及当前用户在 `warehouse_agent_auth` 中获授权代管的公司 | `agent_id IN (ids) OR warehouse_owner_id IN (ids)` |
|
||||
| 代理但 `user.agent_id` 为空 | 无 | fail closed,抛出拒绝访问异常 |
|
||||
|
||||
授权公司集合计算方式:
|
||||
|
||||
```text
|
||||
visibleAgentIds = { currentUser.agentId }
|
||||
visibleAgentIds += SELECT manager_agent_id
|
||||
FROM warehouse_agent_auth
|
||||
WHERE user_id = currentUser.id AND soft_delete = 0
|
||||
```
|
||||
|
||||
最终条件必须保留括号:
|
||||
|
||||
```sql
|
||||
AND (agent_id IN (:visibleAgentIds)
|
||||
OR warehouse_owner_id IN (:visibleAgentIds))
|
||||
```
|
||||
|
||||
直属下级代理不会因层级关系自动加入;只有本公司和显式 `warehouse_agent_auth` 授权公司可见。这一点
|
||||
与老接口实际实现一致。MCP 层还会先校验 Token 是否绑定该工具 scope,行级权限不能替代 scope 校验。
|
||||
|
||||
## 6. 查询与数据处理
|
||||
|
||||
逻辑伪代码:
|
||||
|
||||
```text
|
||||
pageNo = page is null or page < 1 ? 1 : page
|
||||
pageSize = rows is null or rows < 1 ? 20 : min(rows, 100)
|
||||
|
||||
conditions = [soft_delete = 0]
|
||||
append non-empty business filters
|
||||
append warehouse stock row-level permission
|
||||
|
||||
total = count(conditions)
|
||||
records = select(conditions)
|
||||
order by current_inventory desc, id desc
|
||||
limit offset, pageSize
|
||||
|
||||
map records to whitelist fields
|
||||
normalize nullable inventory values to 0
|
||||
calculate availableInventory
|
||||
```
|
||||
|
||||
字符串仅在 `StringUtils.hasText` 时参与查询,并使用 MyBatis-Plus 参数绑定生成 LIKE 条件;ID 集合为空时
|
||||
不生成 `IN ()`。分页 limit 只使用规范化后的整数计算结果。
|
||||
|
||||
## 7. 异常、边界与一致性
|
||||
|
||||
- 未传筛选条件:在权限范围内正常分页,不执行全量无分页返回。
|
||||
- 页码超出末页:`total` 保持真实值,`records=[]`。
|
||||
- `rows > 100`:成功返回,实际 `rows=100`,同时返回 `notice`。
|
||||
- 库存列为 null:按 0 输出;不回写数据库。
|
||||
- `availableInventory < 0`:不钳制,保留原始业务异常信号。
|
||||
- 权限基础数据缺失:代理用户缺少公司 ID 时拒绝查询,不能降级为总部权限。
|
||||
- 查询期间库存发生变化:count 和 list 可能反映不同瞬时快照;工具定位为运营查询,不提供事务级快照保证。
|
||||
|
||||
## 8. 性能与安全
|
||||
|
||||
- 单页上限 100,避免大结果占用数据库、MCP 序列化和模型上下文。
|
||||
- 查询仅访问单表,无远程调用、循环查询或逐条富化。
|
||||
- 所有筛选值由查询构造器参数化;仅 limit 使用归一化后的数值拼接。
|
||||
- 固定过滤软删数据,输出采用显式白名单。
|
||||
- 典型过滤列为 `warehouse_id`、`warehouse_owner_id`、`materiel_index_id`;现有生产数据量较小时无需
|
||||
本需求新增索引。若慢查询监控显示退化,再依据执行计划单独提交索引变更。
|
||||
|
||||
## 9. 代码改动
|
||||
|
||||
| 模块 | 文件/组件 | 职责 |
|
||||
| --- | --- | --- |
|
||||
| entity | `WarehouseStockManage` | m2 库存表字段映射 |
|
||||
| mapper | `WarehouseStockManageMapper` | MyBatis-Plus 单表查询 |
|
||||
| tool | `WarehouseStockTools` | MCP 契约、参数处理、分页及返回映射 |
|
||||
| permission | `PermissionService.applyWarehouseStockRowFilter` | 库存专用行级权限 |
|
||||
| test | `WarehouseStockToolsTest` | 筛选、分页、排序、映射和 schema |
|
||||
| test | `PermissionServiceWarehouseStockRowFilterTest` | 总部、代理授权和 fail-closed |
|
||||
| docs | README、工具参考、本文 | 工具发现和维护说明 |
|
||||
|
||||
## 10. 测试与验收
|
||||
|
||||
### 10.1 自动化测试
|
||||
|
||||
1. 组合筛选生成仓库、所属公司、名称、编号、分类和规格条件。
|
||||
2. `page < 1` 归一为 1,`rows > 100` 截断并产生 notice。
|
||||
3. 固定排序包含 `current_inventory DESC, id DESC`,limit 正确。
|
||||
4. 可用库存计算正确,电池库存 null 输出 0,时间按上海时区格式化。
|
||||
5. MCP schema 发布工具名及全部输入参数。
|
||||
6. 总部不追加权限过滤,也不查询代管授权。
|
||||
7. 代理权限覆盖本公司和代管公司,并同时作用于管理公司、所属公司两列。
|
||||
8. 代理用户缺少 `agentId` 时 fail closed。
|
||||
|
||||
### 10.2 验收标准
|
||||
|
||||
- `tools/list` 可发现 `warehouse.stock.list.query`,分类为 warehouse、类型为 read。
|
||||
- 有 scope 的总部 Token 可分页查询全部未删除库存,并正确使用所有筛选条件。
|
||||
- 代理 Token 不能读取权限集合之外公司的库存;授权代管后可读取对应记录。
|
||||
- 返回字段、空值处理、可用库存公式、排序和分页符合第 4 节契约。
|
||||
- `rows=101` 时按 100 返回并带提示;空结果返回空数组。
|
||||
- 工具调用不产生库存及其他业务数据写入。
|
||||
- 相关单测和项目全量 Java 测试通过。
|
||||
|
||||
## 11. 发布与回滚
|
||||
|
||||
发布后由工具同步机制注册元数据;新工具默认未绑定 Token scope,管理员需显式授权后才可调用。
|
||||
上线验证依次检查 `tools/list`、总部样例查询、代理权限正反例和分页上限。
|
||||
|
||||
如需回滚,先解除 Token 与工具 scope 的绑定以立即停止外部调用,再回滚服务版本。由于本需求无 DDL、
|
||||
无数据迁移且全程只读,不需要业务数据回滚。
|
||||
@@ -0,0 +1,29 @@
|
||||
# REQ-20260915-0011 测试报告
|
||||
|
||||
> 工具:`warehouse.stock.list.query`
|
||||
> 测试任务:`21777`
|
||||
> 日期:2026-09-15
|
||||
> 结论:通过
|
||||
|
||||
## 自动化测试结果
|
||||
|
||||
- 专项测试:`WarehouseStockToolsTest`、`PermissionServiceWarehouseStockRowFilterTest`,共 5 项,
|
||||
0 失败、0 错误。
|
||||
- 全量测试:在业务时区 `TZ=Asia/Shanghai` 下执行 `mvn test`,共 224 项,0 失败、0 错误。
|
||||
- 静态检查:`git diff --check` 通过。
|
||||
|
||||
## 覆盖范围
|
||||
|
||||
- MCP schema 与工具发现契约。
|
||||
- 组合筛选、分页归一、100 条上限及稳定排序。
|
||||
- 字段白名单、库存空值归零及 `availableInventory` 计算。
|
||||
- 总部权限、代理本公司与代管授权、缺少 `agentId` 时 fail closed。
|
||||
|
||||
## 环境说明
|
||||
|
||||
宿主时区为 `Australia/Adelaide` 时,项目中 3 个既有上海时区断言失败;使用项目业务时区
|
||||
`Asia/Shanghai` 后全量通过。这些断言来自同步实体和合同工具测试,与本库存需求无关。
|
||||
|
||||
## 验收结论
|
||||
|
||||
实现满足 `REQ-20260915-0011` 的自动化测试和静态检查验收标准,可以进入预发布验证。
|
||||
@@ -0,0 +1,46 @@
|
||||
package me.iiv.agent.entity.m2;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
/** m2 库存管理表。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@TableName("warehouse_stock_manage")
|
||||
public class WarehouseStockManage implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long createId;
|
||||
private Date createTime;
|
||||
private Long modifyId;
|
||||
private Date modifyTime;
|
||||
private Integer softDelete;
|
||||
private String warehouseName;
|
||||
private Long warehouseId;
|
||||
private String warehouseNo;
|
||||
private Integer currentInventory;
|
||||
private Integer batteryInventory;
|
||||
private Long agentId;
|
||||
private String agentName;
|
||||
private Long materielDefinitionId;
|
||||
private String materialNo;
|
||||
private String materielFullName;
|
||||
private String materielSort;
|
||||
private Long materielIndexId;
|
||||
private String specifications;
|
||||
private Integer outOfStock;
|
||||
private String snCode;
|
||||
private Long warehouseOwnerId;
|
||||
private String warehouseOwnerName;
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package me.iiv.agent.mapper.m2;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import me.iiv.agent.entity.m2.WarehouseStockManage;
|
||||
|
||||
/** 库存管理 Mapper。 */
|
||||
public interface WarehouseStockManageMapper extends BaseMapper<WarehouseStockManage> {
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package me.iiv.agent.mcp.tool;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.entity.m2.WarehouseStockManage;
|
||||
import me.iiv.agent.mapper.m2.WarehouseStockManageMapper;
|
||||
import me.iiv.agent.mcp.ToolObject;
|
||||
import me.iiv.agent.mcp.XqTool;
|
||||
import me.iiv.agent.permission.PermissionService;
|
||||
import me.iiv.agent.security.AuthContextHolder;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 库存物品查询工具,对齐老系统 /warehouseStatis/listByPage。 */
|
||||
@Component
|
||||
public class WarehouseStockTools implements ToolObject {
|
||||
|
||||
private static final int DEFAULT_ROWS = 20;
|
||||
private static final int MAX_ROWS = 100;
|
||||
private static final DateTimeFormatter TIME_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.of("Asia/Shanghai"));
|
||||
|
||||
final WarehouseStockManageMapper warehouseStockManageMapper;
|
||||
final PermissionService permissionService;
|
||||
|
||||
public WarehouseStockTools(WarehouseStockManageMapper warehouseStockManageMapper,
|
||||
PermissionService permissionService) {
|
||||
this.warehouseStockManageMapper = warehouseStockManageMapper;
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
@XqTool(category = "warehouse", kind = "read")
|
||||
@Tool(name = "warehouse.stock.list.query", description = "分页查询库存物品,对应老系统 /warehouseStatis/listByPage。"
|
||||
+ "总部可看全部;代理仅可看本公司或 warehouse_agent_auth 授权代管公司作为管理公司/所属公司的库存。"
|
||||
+ "支持仓库、所属公司、物品名称/编号/分类/规格筛选;page 默认1,rows 默认20、上限100。"
|
||||
+ "固定按 current_inventory DESC、id DESC 排序。")
|
||||
public Map<String, Object> warehouseStockListQuery(
|
||||
@ToolParam(required = false, description = "仓库 id 集合") List<Long> warehouseIds,
|
||||
@ToolParam(required = false, description = "仓库所属公司 id") Long warehouseOwnerId,
|
||||
@ToolParam(required = false, description = "物品名称,模糊匹配") String materialName,
|
||||
@ToolParam(required = false, description = "物品编号,模糊匹配") String materialNo,
|
||||
@ToolParam(required = false, description = "物品分类 id 集合") List<Long> materialCategoryIds,
|
||||
@ToolParam(required = false, description = "规格型号,模糊匹配") String specifications,
|
||||
@ToolParam(required = false, description = "页码,从1开始,默认1") Integer page,
|
||||
@ToolParam(required = false, description = "每页条数,默认20、上限100") Integer rows) {
|
||||
int pageNo = page == null || page < 1 ? 1 : page;
|
||||
boolean truncated = rows != null && rows > MAX_ROWS;
|
||||
int pageSize = rows == null || rows < 1 ? DEFAULT_ROWS : Math.min(rows, MAX_ROWS);
|
||||
|
||||
LambdaQueryWrapper<WarehouseStockManage> wrapper = new LambdaQueryWrapper<WarehouseStockManage>()
|
||||
.eq(WarehouseStockManage::getSoftDelete, 0)
|
||||
.in(warehouseIds != null && !warehouseIds.isEmpty(), WarehouseStockManage::getWarehouseId, warehouseIds)
|
||||
.eq(warehouseOwnerId != null, WarehouseStockManage::getWarehouseOwnerId, warehouseOwnerId)
|
||||
.in(materialCategoryIds != null && !materialCategoryIds.isEmpty(),
|
||||
WarehouseStockManage::getMaterielIndexId, materialCategoryIds);
|
||||
if (StringUtils.hasText(materialName)) {
|
||||
wrapper.like(WarehouseStockManage::getMaterielFullName, materialName.trim());
|
||||
}
|
||||
if (StringUtils.hasText(materialNo)) {
|
||||
wrapper.like(WarehouseStockManage::getMaterialNo, materialNo.trim());
|
||||
}
|
||||
if (StringUtils.hasText(specifications)) {
|
||||
wrapper.like(WarehouseStockManage::getSpecifications, specifications.trim());
|
||||
}
|
||||
|
||||
User user = AuthContextHolder.get().getUser();
|
||||
permissionService.applyWarehouseStockRowFilter(wrapper, user);
|
||||
Long total = warehouseStockManageMapper.selectCount(wrapper);
|
||||
wrapper.orderByDesc(WarehouseStockManage::getCurrentInventory)
|
||||
.orderByDesc(WarehouseStockManage::getId)
|
||||
.last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize);
|
||||
List<Map<String, Object>> records = warehouseStockManageMapper.selectList(wrapper).stream()
|
||||
.map(this::record).collect(Collectors.toList());
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("total", total);
|
||||
result.put("page", pageNo);
|
||||
result.put("rows", pageSize);
|
||||
if (truncated) {
|
||||
result.put("notice", "rows 超过上限 100,已截断为 100");
|
||||
}
|
||||
result.put("records", records);
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<String, Object> record(WarehouseStockManage stock) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", stock.getId());
|
||||
row.put("warehouseId", stock.getWarehouseId());
|
||||
row.put("warehouseNo", stock.getWarehouseNo());
|
||||
row.put("warehouseName", stock.getWarehouseName());
|
||||
row.put("agentId", stock.getAgentId());
|
||||
row.put("agentName", stock.getAgentName());
|
||||
row.put("warehouseOwnerId", stock.getWarehouseOwnerId());
|
||||
row.put("warehouseOwnerName", stock.getWarehouseOwnerName());
|
||||
row.put("materialDefinitionId", stock.getMaterielDefinitionId());
|
||||
row.put("materialNo", stock.getMaterialNo());
|
||||
row.put("materialName", stock.getMaterielFullName());
|
||||
row.put("materialCategoryId", stock.getMaterielIndexId());
|
||||
row.put("materialCategory", stock.getMaterielSort());
|
||||
row.put("specifications", stock.getSpecifications());
|
||||
row.put("snCode", stock.getSnCode());
|
||||
int currentInventory = zeroIfNull(stock.getCurrentInventory());
|
||||
int outOfStock = zeroIfNull(stock.getOutOfStock());
|
||||
row.put("currentInventory", currentInventory);
|
||||
row.put("outOfStock", outOfStock);
|
||||
row.put("availableInventory", currentInventory - outOfStock);
|
||||
row.put("batteryInventory", zeroIfNull(stock.getBatteryInventory()));
|
||||
row.put("updatedAt", stock.getModifyTime() == null ? null
|
||||
: TIME_FORMATTER.format(stock.getModifyTime().toInstant()));
|
||||
return row;
|
||||
}
|
||||
|
||||
private static int zeroIfNull(Integer value) {
|
||||
return value == null ? 0 : value;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import me.iiv.agent.entity.m2.ContractNew;
|
||||
import me.iiv.agent.entity.m2.WarehouseAgentAuth;
|
||||
import me.iiv.agent.entity.m2.WarehouseIn;
|
||||
import me.iiv.agent.entity.m2.WarehouseOut;
|
||||
import me.iiv.agent.entity.m2.WarehouseStockManage;
|
||||
import me.iiv.agent.mapper.m0.AgentManageDistrictAuthMapper;
|
||||
import me.iiv.agent.mapper.m0.AgentMapper;
|
||||
import me.iiv.agent.mapper.m0.AuthorityMapper;
|
||||
@@ -197,6 +198,26 @@ public class PermissionService {
|
||||
.collect(Collectors.joining(","));
|
||||
}
|
||||
|
||||
/**
|
||||
* 库存查询专用权限,1:1 对齐老系统 WrapperUtils.getWarehouseAuth:总部不加过滤;
|
||||
* 代理可看本公司及 warehouse_agent_auth 授权代管公司作为管理公司或所属公司的库存。
|
||||
*/
|
||||
public void applyWarehouseStockRowFilter(LambdaQueryWrapper<WarehouseStockManage> wrapper, User user) {
|
||||
if (!isAgentAuthority(user.getAuthorityId())) {
|
||||
return;
|
||||
}
|
||||
if (user.getAgentId() == null) {
|
||||
throw new IllegalStateException("代理商用户未关联代理商,拒绝访问业务数据");
|
||||
}
|
||||
Set<Long> ids = new LinkedHashSet<>();
|
||||
ids.add(user.getAgentId());
|
||||
ids.addAll(managedAuths(user.getId()).stream()
|
||||
.map(WarehouseAgentAuth::getManagerAgentId)
|
||||
.collect(Collectors.toList()));
|
||||
wrapper.and(w -> w.in(WarehouseStockManage::getAgentId, ids)
|
||||
.or().in(WarehouseStockManage::getWarehouseOwnerId, ids));
|
||||
}
|
||||
|
||||
/** 直属下级代理 id + 自己 agentId(老 :682-695,supervisor_id=自己,只取一层) */
|
||||
private List<Long> subordinateAndSelfIds(User user) {
|
||||
List<Long> ids = new ArrayList<>();
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package me.iiv.agent.mcp.tool;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.entity.m2.WarehouseStockManage;
|
||||
import me.iiv.agent.mapper.m2.WarehouseStockManageMapper;
|
||||
import me.iiv.agent.permission.PermissionService;
|
||||
import me.iiv.agent.security.AuthContext;
|
||||
import me.iiv.agent.security.AuthContextHolder;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WarehouseStockToolsTest {
|
||||
|
||||
@Mock WarehouseStockManageMapper mapper;
|
||||
@Mock PermissionService permissionService;
|
||||
|
||||
WarehouseStockTools tools;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
WarehouseStockManage.class);
|
||||
tools = new WarehouseStockTools(mapper, permissionService);
|
||||
User user = new User();
|
||||
user.setId(7L);
|
||||
AuthContextHolder.set(new AuthContext(null, user));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
AuthContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listAppliesFiltersPermissionPaginationAndMapsInventory() {
|
||||
WarehouseStockManage stock = new WarehouseStockManage();
|
||||
stock.setId(9L);
|
||||
stock.setWarehouseId(8L);
|
||||
stock.setWarehouseName("一号仓");
|
||||
stock.setMaterielFullName("擦手纸");
|
||||
stock.setMaterielIndexId(3L);
|
||||
stock.setCurrentInventory(20);
|
||||
stock.setOutOfStock(4);
|
||||
stock.setBatteryInventory(null);
|
||||
stock.setModifyTime(new Date(0));
|
||||
when(mapper.selectCount(any())).thenReturn(1L);
|
||||
when(mapper.selectList(any())).thenReturn(List.of(stock));
|
||||
|
||||
Map<String, Object> result = tools.warehouseStockListQuery(
|
||||
List.of(8L), 10L, " 擦手 ", " MAT ", List.of(3L), " A型 ", 0, 500);
|
||||
|
||||
assertEquals(1L, result.get("total"));
|
||||
assertEquals(1, result.get("page"));
|
||||
assertEquals(100, result.get("rows"));
|
||||
assertEquals("rows 超过上限 100,已截断为 100", result.get("notice"));
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> record = ((List<Map<String, Object>>) result.get("records")).get(0);
|
||||
assertEquals("擦手纸", record.get("materialName"));
|
||||
assertEquals(16, record.get("availableInventory"));
|
||||
assertEquals(0, record.get("batteryInventory"));
|
||||
assertEquals("1970-01-01 08:00:00", record.get("updatedAt"));
|
||||
verify(permissionService).applyWarehouseStockRowFilter(any(), any());
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<LambdaQueryWrapper<WarehouseStockManage>> captor =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(mapper).selectList(captor.capture());
|
||||
String sql = captor.getValue().getSqlSegment();
|
||||
assertTrue(sql.contains("warehouse_id IN"));
|
||||
assertTrue(sql.contains("warehouse_owner_id"));
|
||||
assertTrue(sql.contains("materiel_full_name LIKE"));
|
||||
assertTrue(sql.contains("material_no LIKE"));
|
||||
assertTrue(sql.contains("materiel_index_id IN"));
|
||||
assertTrue(sql.contains("specifications LIKE"));
|
||||
assertTrue(sql.contains("current_inventory DESC"));
|
||||
assertTrue(sql.contains("id DESC"));
|
||||
assertTrue(sql.contains("LIMIT 0,100"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void springAiPublishesWarehouseStockSchema() {
|
||||
var callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider.builder()
|
||||
.toolObjects(tools).build().getToolCallbacks();
|
||||
Map<String, String> schemas = Arrays.stream(callbacks).collect(java.util.stream.Collectors.toMap(
|
||||
callback -> callback.getToolDefinition().name(),
|
||||
callback -> callback.getToolDefinition().inputSchema()));
|
||||
|
||||
assertEquals(1, schemas.size());
|
||||
String schema = schemas.get("warehouse.stock.list.query");
|
||||
assertTrue(schema.contains("warehouseIds"));
|
||||
assertTrue(schema.contains("warehouseOwnerId"));
|
||||
assertTrue(schema.contains("materialCategoryIds"));
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package me.iiv.agent.permission;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import me.iiv.agent.entity.m0.Authority;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.entity.m2.WarehouseAgentAuth;
|
||||
import me.iiv.agent.entity.m2.WarehouseStockManage;
|
||||
import me.iiv.agent.mapper.m0.AgentManageDistrictAuthMapper;
|
||||
import me.iiv.agent.mapper.m0.AgentMapper;
|
||||
import me.iiv.agent.mapper.m0.AuthorityMapper;
|
||||
import me.iiv.agent.mapper.m0.ProperytMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseAgentAuthMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PermissionServiceWarehouseStockRowFilterTest {
|
||||
|
||||
@Mock AuthorityMapper authorityMapper;
|
||||
@Mock AgentMapper agentMapper;
|
||||
@Mock WarehouseAgentAuthMapper warehouseAgentAuthMapper;
|
||||
@Mock AgentManageDistrictAuthMapper agentManageDistrictAuthMapper;
|
||||
@Mock ProperytMapper properytMapper;
|
||||
@Mock StringRedisTemplate redisTemplate;
|
||||
|
||||
PermissionService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
WarehouseStockManage.class);
|
||||
service = new PermissionService(authorityMapper, agentMapper, warehouseAgentAuthMapper,
|
||||
agentManageDistrictAuthMapper, properytMapper, redisTemplate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void headquartersAddsNoFilter() {
|
||||
User user = user(7L, 9L, null);
|
||||
when(authorityMapper.selectById(9L)).thenReturn(authority(9L, 0));
|
||||
LambdaQueryWrapper<WarehouseStockManage> wrapper = new LambdaQueryWrapper<>();
|
||||
|
||||
service.applyWarehouseStockRowFilter(wrapper, user);
|
||||
|
||||
assertTrue(wrapper.getSqlSegment().isBlank());
|
||||
verifyNoInteractions(warehouseAgentAuthMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void agentCanSeeOwnOrManagedCompanyInEitherCompanyColumn() {
|
||||
User user = user(7L, 9L, 10L);
|
||||
when(authorityMapper.selectById(9L)).thenReturn(authority(9L, 1));
|
||||
WarehouseAgentAuth managed = new WarehouseAgentAuth();
|
||||
managed.setManagerAgentId(12L);
|
||||
when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of(managed));
|
||||
LambdaQueryWrapper<WarehouseStockManage> wrapper = new LambdaQueryWrapper<>();
|
||||
|
||||
service.applyWarehouseStockRowFilter(wrapper, user);
|
||||
|
||||
String sql = wrapper.getSqlSegment();
|
||||
Collection<Object> values = wrapper.getParamNameValuePairs().values();
|
||||
assertTrue(sql.contains("agent_id IN"));
|
||||
assertTrue(sql.contains("warehouse_owner_id IN"));
|
||||
assertTrue(sql.contains("OR"));
|
||||
assertTrue(values.containsAll(List.of(10L, 12L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void agentWithoutAgentIdFailsClosed() {
|
||||
User user = user(7L, 9L, null);
|
||||
when(authorityMapper.selectById(9L)).thenReturn(authority(9L, 1));
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> service.applyWarehouseStockRowFilter(new LambdaQueryWrapper<>(), user));
|
||||
}
|
||||
|
||||
private static User user(Long id, Long authorityId, Long agentId) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
user.setAuthorityId(authorityId);
|
||||
user.setAgentId(agentId);
|
||||
return user;
|
||||
}
|
||||
|
||||
private static Authority authority(Long id, int isAgent) {
|
||||
Authority authority = new Authority();
|
||||
authority.setId(id);
|
||||
authority.setIsagent(isAgent);
|
||||
authority.setSoftDelete(0);
|
||||
return authority;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user