feat: add warehouse stock query MCP tool

This commit is contained in:
jiashumeng
2026-09-16 10:03:25 +08:00
committed by my_local_kimi_dev2
parent 798075f47e
commit 487a41df74
9 changed files with 760 additions and 0 deletions
@@ -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;
}
@@ -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<>();
@@ -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"));
}
}
@@ -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;
}
}