diff --git a/xiaoqu-agent-service/doc/warehouse-stock-mcp-tool-cr.md b/xiaoqu-agent-service/doc/warehouse-stock-mcp-tool-cr.md new file mode 100644 index 0000000..fc0ecc7 --- /dev/null +++ b/xiaoqu-agent-service/doc/warehouse-stock-mcp-tool-cr.md @@ -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 或阻塞级问题。行级权限与老接口语义一致,分页与稳定排序正确,返回字段白名单符合设计,空值处理和可用库存计算正确,专项测试覆盖关键正反例。评审结论为通过,可进入测试阶段。 diff --git a/xiaoqu-agent-service/doc/warehouse-stock-mcp-tool-design.md b/xiaoqu-agent-service/doc/warehouse-stock-mcp-tool-design.md new file mode 100644 index 0000000..c60e7eb --- /dev/null +++ b/xiaoqu-agent-service/doc/warehouse-stock-mcp-tool-design.md @@ -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` | 否 | - | 空数组视为不筛选 | `warehouse_id IN (...)` | +| `warehouseOwnerId` | `long` | 否 | - | 精确匹配仓库所属公司 | `warehouse_owner_id = ?` | +| `materialName` | `string` | 否 | - | 去除首尾空格;空白不筛选 | `materiel_full_name LIKE` | +| `materialNo` | `string` | 否 | - | 去除首尾空格;空白不筛选 | `material_no LIKE` | +| `materialCategoryIds` | `array` | 否 | - | 空数组视为不筛选 | `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` | 当前页库存记录;无数据时为空数组 | + +`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、 +无数据迁移且全程只读,不需要业务数据回滚。 diff --git a/xiaoqu-agent-service/doc/warehouse-stock-mcp-tool-test-report.md b/xiaoqu-agent-service/doc/warehouse-stock-mcp-tool-test-report.md new file mode 100644 index 0000000..4235e61 --- /dev/null +++ b/xiaoqu-agent-service/doc/warehouse-stock-mcp-tool-test-report.md @@ -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` 的自动化测试和静态检查验收标准,可以进入预发布验证。 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseStockManage.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseStockManage.java new file mode 100644 index 0000000..3419084 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseStockManage.java @@ -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; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseStockManageMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseStockManageMapper.java new file mode 100644 index 0000000..a72d549 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseStockManageMapper.java @@ -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 { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseStockTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseStockTools.java new file mode 100644 index 0000000..0900f11 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseStockTools.java @@ -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 warehouseStockListQuery( + @ToolParam(required = false, description = "仓库 id 集合") List 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 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 wrapper = new LambdaQueryWrapper() + .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> records = warehouseStockManageMapper.selectList(wrapper).stream() + .map(this::record).collect(Collectors.toList()); + + Map 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 record(WarehouseStockManage stock) { + Map 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; + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java index d4d1f80..7520178 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java @@ -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 wrapper, User user) { + if (!isAgentAuthority(user.getAuthorityId())) { + return; + } + if (user.getAgentId() == null) { + throw new IllegalStateException("代理商用户未关联代理商,拒绝访问业务数据"); + } + Set 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 subordinateAndSelfIds(User user) { List ids = new ArrayList<>(); diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseStockToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseStockToolsTest.java new file mode 100644 index 0000000..5a1df4c --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseStockToolsTest.java @@ -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 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 record = ((List>) 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> 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 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")); + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceWarehouseStockRowFilterTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceWarehouseStockRowFilterTest.java new file mode 100644 index 0000000..457b4a6 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceWarehouseStockRowFilterTest.java @@ -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 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 wrapper = new LambdaQueryWrapper<>(); + + service.applyWarehouseStockRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + Collection 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; + } +}