Compare commits

..
Author SHA1 Message Date
my_local_kimi_dev2 f51e41a189 slark WIP snapshot (fence 1789540747913) 2026-09-16 14:39:11 +08:00
my_local_kimi_dev2 12de320459 feat(admin): scope tools dialog with category tabs and table
- top category tabs with per-category counts and selected badges
- table layout: checkbox, tool name, description, type, status
- header checkbox for select-all with indeterminate state
- search filter and selected-only toggle
- footer summary of cross-category selections
- keep existing data-testid hooks for E2E tests
2026-09-16 11:35:17 +08:00
jiashumengandmy_local_kimi_dev2 487a41df74 feat: add warehouse stock query MCP tool 2026-09-16 10:03:25 +08:00
my_local_kimi_dev2 798075f47e docs: add MCP external integration spec v1.0 2026-09-16 09:55:19 +08:00
my_local_kimi_dev2 a575736c81 feat(admin): paginate tools list endpoint and console UI
- GET /tools returns ToolPageView{total,page,pageSize,records}, pageSize=0 keeps full-list mode
- App.vue adds pager below tools table; scope/audit/category pickers use allTools
- add backend pagination tests and front-end pager contract test
2026-09-16 09:52:19 +08:00
shumeng 6e8cf60fd3 Merge pull request 'feat(sync): expose contract display context' (#17) from codex/mcp-source-domain-identity into main 2026-09-16 09:08:06 +08:00
16 changed files with 1194 additions and 30 deletions
@@ -0,0 +1,133 @@
# 装机工单 MCP 工具设计(install.order.list.query / install.order.detail.query)
> 状态:已评审(2026-09-16 王恒确认) | 对应老接口:`POST /installOrder/pageList`、`POST /installOrder/lookDetails`(xiaoqu-franchisee `InstallOrderController.java:2004` / `:229`)
> 只读工具,不改老系统数据;数据源 m2(业务库)为主,详情聚合 m1(商城订单/地址)。
## 1. 工具清单
| 工具名 | category | kind | 用途 |
|---|---|---|---|
| `install.order.list.query` | workorder | read | 分页查询装机工单列表 |
| `install.order.detail.query` | workorder | read | 按 id 或装机单号查询工单详情(主单+明细+人员+流程) |
## 2. install.order.list.query
### 2.1 入参
对齐老 pageList,命名转 camelCase,匹配方式保持老语义(String 模糊、数字精确、时间区间):
| 参数 | 类型 | 必填 | 匹配 | 说明 |
|---|---|---|---|---|
| installOrderNo | string | 否 | 模糊 | 装机单号 |
| mallOrderNo | string | 否 | 模糊 | 商城销售订单号 |
| deliveryOrderNo | string | 否 | 模糊 | 送货工单号 |
| pickupOrderNo | string | 否 | 模糊 | 取货工单号 |
| scheduleTransferOrder | string | 否 | 模糊 | 待办划转单号 |
| installType | int | 否 | 精确 | 0=正式装机 1=测试装机 |
| agentId | int | 否 | 精确 | 所属代理公司 id |
| districtName | string | 否 | 模糊 | 项目/站点名称 |
| applyUserName | string | 否 | 模糊 | 申请人 |
| maintenanceUserName | string | 否 | 模糊 | 运维人员 |
| cooperateUserName | string | 否 | 模糊 | 协同人员 |
| orderState | int | 否 | 精确 | 0待审核 1被驳回 2调度中 3待出库 4待送货 5作业中 6待取回 7已结束 8已取消 |
| applyTimeFrom / applyTimeTo | string | 否 | 区间 | 申请时间,yyyy-MM-dd(To 含当天,服务端补 23:59:59) |
| page | int | 否 | — | 默认 1 |
| rows | int | 否 | — | 默认 20,上限 100,超出截断并在 notice 提示 |
裁剪说明(已评审确认):老接口的 7 组时间区间(finish/audit/dispatch/allocation/signature/pickup/warehousing)使用频率低,首版**只保留申请时间一组**;如确有需要再扩展。老接口的 `sidx/sord` 自由排序存在 SQL 注入面,**不移植**,固定排序 `apply_time DESC, id DESC`。
### 2.2 返回
沿用 MCP 统一分页结构:
```json
{
"total": 123, "page": 1, "rows": 20,
"notice": "rows 超过上限已截断为 100(仅超限时出现)",
"records": [ { "id": 1, "installOrderNo": "...", "installType": 0, "installTypeText": "正式装机",
"orderState": 5, "orderStateText": "作业中",
"agentId": 1, "agentName": "...", "propertyId": 1, "propertyName": "...",
"districtId": 1, "districtName": "...", "projectAddress": "...",
"applyUserName": "...", "maintenanceUserName": "...", "cooperateUserName": "...",
"hopeInstallDate": "...", "trueInstallDate": "...",
"applyTime": "yyyy-MM-dd HH:mm:ss", "finishDate": "...",
"mallOrderNo": "...", "deliveryOrderNo": "...", "pickupOrderNo": "...",
"totalInstallCount": 3 } ]
}
```
`totalInstallCount` 同老接口:按 `install_order_details` 汇总 install_count(分页记录逐条汇总,与老行为一致)。`orderStateText/installTypeText` 为附带文案,免查字典。
### 2.3 行级权限(新增 `PermissionService.applyInstallOrderRowFilter`)
老逻辑在 `xiaoqu-franchisee utils/WrapperUtils.java:127-264`(`getWorkOrderAuth`,pageList 传 `propertyFlag=true, className=property_id, orderNoName=install_order_no, type=0`),1:1 移植:
1. **总部**(权限组 `isagent != 1`):不过滤,看全部。
2. **代理销售非负责人**(Redis 字典 authDeptId 含本部门且不含负责人岗位,复用现有 `isSalesNonLeader` 语义):`property_id IN(自己创建的物业 ∪ 被授权代管公司名下的物业)`,集合为空则 `IN (0)`;命中后互斥 return,不进规则 3。
3. **其他代理用户**:
- `ids = 自己 agentId ∪ warehouse_agent_auth(user_id=自己).manager_agent_id`(本人 + 授权代管公司,**不含下级代理**,与现有 `visibleAgentIds` 不同,不可复用);
- 参与者单号 `orderNos = install_order_process(user_id=自己) ∪ install_order_user(user_id=自己)`(让运维/协同人员在调度阶段即可见工单);
- 代管物业 `propertyIds = agent_manage_district_auth(manage_agent_id IN ids).property_id`;
- 最终:`AND (apply_agent_id IN ids OR agent_id IN ids OR property_id IN propertyIds[如有] OR install_order_no IN orderNos[如有])`。
4. 未配置权限组的用户按现有约定直接拒绝(同 `isAgentAuthority`)。
## 3. install.order.detail.query
对齐老 `/installOrder/lookDetails`(`InstallOrderController.java:229`),返回 `InstallOrderApplyLookOutput` 的 MCP 版。商城订单聚合已评审确认**首版即带**(跨 m1 库)。
### 3.1 入参
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| id | int | 二选一 | 工单主键,>0 优先 |
| installOrderNo | string | 二选一 | 装机单号,精确匹配 |
| includeProcess | bool | 否 | 是否返回流程记录,默认 true |
| processLimit | int | 否 | 流程记录上限,默认 100,最大 500 |
### 3.2 返回
- `record`:主单全字段(同列表字段 + `customerContact/contactMobile/projectMemo/auditTime/dispatchTime/...` 等完整时间链与经手人);
- `details`:`install_order_details` 明细(物料、数量、安装位置等);
- `maintenanceUsers` / `cooperateUsers`:`install_order_user`(userType 0/1);
- `addressInfo`:收货地址(m1 `mall_shipping_address`,addressId 非空时);
- `mallOrder`:商城订单+订单明细+物料(m1,mallOrderNo 非空时);
- `processes`:流程记录,`schedule_state=2`(已办)、排除抄送(operationTheme!=12),按 `operation_time` 升序;调度/配发节点附人员,配发节点附 SN(`install_distribute_details`),出入库节点附 `install_to_out_warehouse` 记录与图片凭证 OSS URL(m1 `mall_logistics_pic` / m2 `warehouse_in_out_pic`)。
### 3.3 权限与防枚举
与列表**复用同一 `applyInstallOrderRowFilter`**:先按 id/单号查出主单,再套上同一个行级过滤 wrapper 校验可见性;不可见与不存在统一返回 `record=null` + `notice="工单不存在或无权查看"`,防止枚举越权工单(与 warehouse.out.detail.query 同一约定)。
## 4. 实现范围(变更文件清单)
新增(xiaoqu-agent-service):
- `entity/m2/InstallOrder.java`、`InstallOrderDetails.java`、`InstallOrderProcess.java`、`InstallOrderUser.java`、`InstallDistributeDetails.java`、`InstallToOutWarehouse.java`(只保留查询需要的字段)
- `mapper/m2/InstallOrderMapper.java` 等对应 Mapper(m2 分包已被 `M2DataSourceConfig` 扫描)
- `mcp/tool/InstallOrderTools.java`(两个 `@XqTool(category="workorder", kind="read")` 方法)
- `permission/PermissionService.java` 新增 `applyInstallOrderRowFilter`
- m1 侧复用现有商城订单/地址/物流 Mapper(warehouse.out.detail 已引入则直接复用,缺则新增)
- codegen 表白名单 `CodeGenerator.java` 登记新表
- 测试:`InstallOrderToolsTest`(结构/分页/排序/schema 发布)+ `PermissionServiceInstallOrderRowFilterTest`(总部/销售/代理三角色)
- 文档:`doc/agent-mcp-tools-reference.md` 新增章节、README 工具表登记
不改:老系统任何代码与数据;svc 库表结构(tool 表由 ToolSyncService 启动自动同步)。
## 5. 边界与性能
- 列表 `selectCount + LIMIT` 手写分页;固定排序,无注入面。
- 不做无界聚合;装机统计(数量/状态分布)如有需要,后续独立设计 `install.order.summary.query`。
- 详情的子表均按主单单号/id 精确查询,processLimit 兜底;图片只回传 OSS URL,不内嵌内容。
- 时间统一 `yyyy-MM-dd HH:mm:ss`,Asia/Shanghai。
## 6. 测试与验收
1. 单测全量通过(现有 236 + 新增)。
2. 三角色权限用例:总部无过滤;代理只见 自己+代管公司+代管物业+本人参与 的工单;销售非负责人只见 自己创建物业+代管物业;越权查详情返回 record=null 且提示与不存在一致。
3. rows=500 截断为 100 且带 notice;installOrderNo 模糊、orderState 精确、applyTime 区间生效。
4. 发布:合并 main → `deploy/deploy.sh remote-deploy` → 健康检查 POST /mcp 401 → svc 库 tool 表出现两个新工具 → 给目标 scope 绑定 scope_tool → 线上 tools/list 可见性验证。
## 7. 评审结论(2026-09-16 王恒确认)
1. 时间区间只保留申请时间一组(applyTimeFrom/To),其余 7 组不移植。
2. 详情首版即带商城订单聚合(跨 m1 库)。
3. category 用 `workorder`(不新建 `install`,也不并入 `warehouse`)。
@@ -0,0 +1,140 @@
# 小趣 MCP 外部接入规范
> 版本:v1.0(2026-09-15)
> 适用对象:CRM 等外部系统的后端集成方
> 状态:当前生效。认证换发流程(第 3 节)为小趣自定义扩展,后续可能升级为 OAuth 2.1 / MCP 标准授权,届时本规范会发新版并保留兼容期。
## 1. 服务边界
小趣 MCP 只做三件事:**供数据、供身份、守权限**。
- 供数据:查询工具(QUERY)+ 增量同步工具(SYNC),保证准确、一致、可恢复
- 供身份:Token 签发 / 吊销 / 自检
- 守权限:服务 Token 按授权 scope 白名单限工具,个人 Token 实时 ACL 行过滤
到期规则计算、AI 决策、消息推送、经营事项流转**不属于** MCP 职责,由接入方或小趣 AI 系统自行实现,MCP 不提供也不预留相关字段。
## 2. 接入端点与协议
| 项 | 值 |
|---|---|
| MCP 端点(生产) | `https://agent.ququclean.com/mcp` |
| 传输协议 | MCP Streamable HTTP(JSON-RPC 2.0) |
| 协议版本头 | `MCP-Protocol-Version: 2025-06-18` |
| 响应格式 | SSE(`text/event-stream`),取 `data:` 行解析为 JSON-RPC 消息 |
| 会话 | `initialize` 响应头返回 `Mcp-Session-Id`,后续请求必须携带 |
### 2.1 标准握手序列
```text
POST /mcp initialize(JSON-RPC,带 id)
← 响应头取 Mcp-Session-Id,data: 行为 initialize 结果
POST /mcp notifications/initialized(无 id,带 Mcp-Session-Id)
← 202,无响应体
POST /mcp tools/list # 自动发现工具及输入 JSON Schema
POST /mcp tools/call # 按 Schema 调用具体工具
```
请求统一头:
```http
Authorization: Bearer <xqak-...>
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2025-06-18
Mcp-Session-Id: <initialize 返回的会话 ID>
X-Mcp-Schema-Version: 2
```
注意:`notifications/initialized` 生效后再发 `tools/list`,否则偶发空响应,重试即可。
## 3. 认证与凭证管理(自定义扩展)
### 3.1 两类凭证
| 凭证 | 形态 | 用途 | 获取方式 |
|---|---|---|---|
| 服务密钥 `X-Open-Key` | `xqok-...` | 换取访问 Token,**不是** MCP Token | 管理员在管理后台「服务密钥」页创建,明文只展示一次 |
| 访问 Token | `xqak-...` | 放进 `Authorization: Bearer` 调 `/mcp` | 用服务密钥调 ensure 接口换取,长期有效 |
### 3.2 换取 Token
```http
POST https://agent.ququclean.com/api/open/token/ensure
X-Open-Key: <服务密钥>
Content-Type: application/json
{"scopeCode":"sync","principalType":"service","serviceName":"<你的系统名>"}
```
响应 `token` 字段即访问 Token。该接口幂等:同一 subject 重复调用返回同一 Token。
个人 Token(前台点查场景):同接口传 `{"mobile": "<人员手机号>"}`,幂等返回该人员的个人 Token(`user_delegated`),人员停用后 60 秒内自动失效。
### 3.3 凭证安全要求
- 服务密钥与 Token 只允许存接入方后端的密钥管理设施或环境变量,**禁止**下发浏览器/App、禁止写入日志、禁止提交代码库
- 密钥泄露:通知小趣管理员在管理后台执行「轮换」(旧密钥立即失效)或「吊销」
- 数据库只存密钥哈希,创建/轮换弹窗关闭后明文不可找回
## 4. 工具分类与调用约定
### 4.1 工具发现
`tools/list` 返回当前 Token 可见的全部工具及入参 JSON Schema。**可见性由 Token 绑定的 scope 白名单决定**——未授权的工具不出现在列表中,接入方应按 `tools/list` 结果动态适配,不要硬编码工具清单。
### 4.2 统一输出信封
所有工具返回 `structuredContent`,`X-Mcp-Schema-Version: 2` 时为新信封:
```json
{ "schema_version": 2, "request_id": "...", "data": { ... } }
```
- 不带该请求头返回 v1 老结构(兼容期内有效)
- `request_id` 必须记入接入方日志,双向排查用;Token 与手机号明文不落日志
### 4.3 通用入参约束
- 所有工具**禁止传 `tenant_id` / `scope`**,租户从 Token 推导,传了返回 `PERMISSION_DENIED`
- 分页类工具 `limit` 上限 200,超出截断并在响应带 notice
## 5. 错误码与重试规则
| 错误码 | 含义 | 接入方动作 |
|---|---|---|
| `UNAUTHENTICATED` | Token 吊销 / 人员停用 | 重新 ensure 取 Token;仍失败则告警人工介入 |
| `PERMISSION_DENIED` | 工具不在 Token scope 白名单,或传了 tenant_id | 检查绑定与入参,**不要**重试 |
| `RATE_LIMITED` | 触发限流 | 指数退避重试(1s/2s/4s…上限 60s) |
| `SYNC_CURSOR_EXPIRED` | 同步快照/游标过期或被篡改 | 清空本地镜像,`syncToken=null` 全量重建 |
| `INVALID_ARGUMENT` | 入参不符合 Schema | 修正后重发,不要原样重试 |
限流分档:SYNC 工具独立配额档,与 QUERY 互不占配额。
## 6. 增量同步协议(SYNC 工具)
工具:`users.changes.list` / `properties.changes.list` / `contracts.changes.list`。
入参仅三个:`syncToken`、`cursor`、`limit`。
协议语义:
- **快照**:每轮同步签发 `snapshot_id` + `data_cutoff_at`,同一快照内翻页结果稳定
- **游标**:页内 `(modify_time, id)` 复合游标;`has_more=true` 时带 `next_cursor` 翻页
- **续传**:仅末页(`has_more=false`)签发 `next_sync_token`;中断后从本地保存的 `sync_token + cursor` 继续,不重不漏
- **tombstone**:`deleted_at` 非空为删除标记,本地镜像应标记删除
- **版本字段**:`version` 由 `modify_time` 派生,仅用于调试核对,禁止用作并发控制
接入方必须持久化每页的 `sync_token + cursor`(断点恢复),实现 `SYNC_CURSOR_EXPIRED` 全量重建分支,并在扫描启动前用 `integration.context.get` 校验 `principal_type=tenant_service` 且 capability 含 SYNC,拿错个人 Token 时拒绝执行扫描。
## 7. 交付物与责任划分
| 小趣侧交付 | 接入方自负责 |
|---|---|
| 本规范(契约定义) | MCP 客户端实现(可用任意标准 MCP Client SDK) |
| 管理后台服务密钥创建/轮换/吊销 | 密钥与 Token 的安全存储 |
| 工具清单及入参 Schema(`tools/list` 自动发现) | 本地镜像 upsert / 逻辑删除 |
| 错误码语义与限流档位 | 定时任务调度、游标持久化、重试退避 |
| `request_id` 双侧排查支持 | 业务规则、AI 决策、推送 |
详细逐步接入示例(含报文样例与伪代码)见《CRM 接入手册》`doc/crm-integration-guide.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 或阻塞级问题。行级权限与老接口语义一致,分页与稳定排序正确,返回字段白名单符合设计,空值处理和可用库存计算正确,专项测试覆盖关键正反例。评审结论为通过,可进入测试阶段。
@@ -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` 的自动化测试和静态检查验收标准,可以进入预发布验证。
+101 -15
View File
@@ -24,8 +24,10 @@ const tokens = ref([])
const openClients = ref([])
const scopes = ref([])
const tools = ref([])
const allTools = ref([])
const audits = ref([])
const toolFilter = reactive({ category: '', kind: '', keyword: '' })
const toolPage = reactive({ page: 1, pageSize: 20, total: 0 })
const auditFilter = reactive({ person: '', tokenPrefix: '', tool: '', result: '', client: '', from: '', to: '' })
const tokenDialog = ref(false)
const tokenForm = reactive({ id: null, memo: '', scopeIds: [], scopeId: null, expireTime: null })
@@ -38,14 +40,38 @@ const scopeForm = reactive({ id: null, name: '', code: '', memo: '' })
const scopeToolsDialog = ref(false)
const activeScope = ref(null)
const selectedToolIds = ref([])
const scopeToolsTab = ref('')
const scopeToolsKeyword = ref('')
const scopeToolsOnlySelected = ref(false)
const title = computed(() => menus.find(item => item.id === current.value)?.label || '')
const categories = computed(() => [...new Set(tools.value.map(tool => tool.category))])
const groupedTools = computed(() => tools.value.reduce((groups, tool) => {
const categories = computed(() => [...new Set(allTools.value.map(tool => tool.category))])
const groupedTools = computed(() => allTools.value.reduce((groups, tool) => {
const category = tool.category || '其他'
;(groups[category] ||= []).push(tool)
return groups
}, {}))
const scopeToolTabs = computed(() => Object.entries(groupedTools.value).map(([category, items]) => ({
category,
total: items.length,
selected: items.filter(tool => selectedToolIds.value.includes(tool.id)).length,
})))
const scopeToolsVisible = computed(() => {
const keyword = scopeToolsKeyword.value.trim().toLowerCase()
const items = scopeToolsTab.value ? (groupedTools.value[scopeToolsTab.value] || []) : allTools.value
return items.filter(tool => {
if (scopeToolsOnlySelected.value && !selectedToolIds.value.includes(tool.id)) return false
if (!keyword) return true
return tool.name.toLowerCase().includes(keyword) || (tool.description || '').toLowerCase().includes(keyword)
})
})
const scopeToolsSummary = computed(() => {
const detail = scopeToolTabs.value.filter(tab => tab.selected).map(tab => `${tab.category} ×${tab.selected}`).join(' · ')
return { selected: selectedToolIds.value.length, total: allTools.value.length, detail }
})
const scopeToolsAllVisibleSelected = computed(() => scopeToolsVisible.value.length > 0
&& scopeToolsVisible.value.every(tool => selectedToolIds.value.includes(tool.id)))
const scopeToolsSomeVisibleSelected = computed(() => scopeToolsVisible.value.some(tool => selectedToolIds.value.includes(tool.id)))
const issuedKeyCurl = computed(() => {
if (!issuedOpenKey.value) return ''
const payload = JSON.stringify({
@@ -96,9 +122,9 @@ async function switchMenu(id) {
current.value = id
if (id === 'tokens') await loadUsers()
if (id === 'clients') await loadOpenClients()
if (id === 'tools') await loadTools()
if (id === 'tools') await Promise.all([loadTools(), loadAllTools()])
if (id === 'scopes') await loadScopes()
if (id === 'audit') await Promise.all([loadAudit(), api('/tools').then(data => { tools.value = data })])
if (id === 'audit') await Promise.all([loadAudit(), loadAllTools()])
}
async function loadUsers(resetPage = false) {
@@ -214,8 +240,21 @@ async function rotateClient(client) {
}).catch(() => {})
}
async function loadTools() {
tools.value = await api(`/tools${qs(toolFilter)}`)
async function loadTools(resetPage = false) {
if (resetPage) toolPage.page = 1
const data = await api(`/tools${qs({ ...toolFilter, page: toolPage.page, pageSize: toolPage.pageSize })}`)
tools.value = data.records
toolPage.total = data.total
}
async function loadAllTools() {
const data = await api('/tools?pageSize=0')
allTools.value = data.records
}
async function changeToolPage(page) {
toolPage.page = page
await run(loadTools)
}
async function setToolStatus(tool) {
@@ -261,10 +300,23 @@ async function deleteScope(scope) {
async function openScopeTools(scope) {
activeScope.value = scope
;[selectedToolIds.value, tools.value] = await Promise.all([api(`/scopes/${scope.id}/tools`), api('/tools')])
scopeToolsTab.value = ''; scopeToolsKeyword.value = ''; scopeToolsOnlySelected.value = false
;[selectedToolIds.value] = await Promise.all([api(`/scopes/${scope.id}/tools`), loadAllTools()])
scopeToolsDialog.value = true
}
function toggleScopeTool(id, checked) {
const set = new Set(selectedToolIds.value)
if (checked) set.add(id); else set.delete(id)
selectedToolIds.value = [...set]
}
function toggleScopeToolsVisible(checked) {
const set = new Set(selectedToolIds.value)
scopeToolsVisible.value.forEach(tool => { if (checked) set.add(tool.id); else set.delete(tool.id) })
selectedToolIds.value = [...set]
}
async function saveScopeTools() {
await run(async () => {
await api(`/scopes/${activeScope.value.id}/tools`, { method: 'PUT', body: JSON.stringify({ toolIds: selectedToolIds.value }) })
@@ -411,8 +463,8 @@ onMounted(async () => {
<div class="toolbar">
<el-select v-model="toolFilter.category" clearable placeholder="全部分类" aria-label="工具分类" data-testid="admin-tool-category"><el-option v-for="item in categories" :key="item" :label="item" :value="item" /></el-select>
<el-select v-model="toolFilter.kind" clearable placeholder="全部类型" aria-label="工具类型" data-testid="admin-tool-kind"><el-option label="读" value="read" /><el-option label="写" value="write" /></el-select>
<el-input v-model="toolFilter.keyword" clearable placeholder="工具名 / 描述" aria-label="工具关键词" data-testid="admin-tool-keyword" @keyup.enter="run(loadTools)" />
<el-button type="primary" data-testid="admin-tool-search" @click="run(loadTools)">查询</el-button>
<el-input v-model="toolFilter.keyword" clearable placeholder="工具名 / 描述" aria-label="工具关键词" data-testid="admin-tool-keyword" @keyup.enter="run(() => loadTools(true))" />
<el-button type="primary" data-testid="admin-tool-search" @click="run(() => loadTools(true))">查询</el-button>
</div>
</article>
<article class="card">
@@ -427,6 +479,9 @@ onMounted(async () => {
<el-table-column label="同步时间" min-width="145"><template #default="{ row }">{{ date(row.modifyTime) }}</template></el-table-column>
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button link :type="row.status === 1 ? 'danger' : 'primary'" :data-testid="`admin-tool-status-${row.id}`" @click="setToolStatus(row)">{{ row.status === 1 ? '停用' : '启用' }}</el-button></template></el-table-column>
</el-table>
<el-pagination class="user-pager" layout="prev, pager, next, total" :total="toolPage.total"
:page-size="toolPage.pageSize" :current-page="toolPage.page" small background
data-testid="admin-tool-pager" @current-change="changeToolPage" />
<p class="hint">“停用”是全局紧急开关,生效后任何 Token 都无法调用该工具。</p>
</article>
</template>
@@ -456,7 +511,7 @@ onMounted(async () => {
<div class="toolbar audit-toolbar">
<el-input v-model="auditFilter.person" clearable placeholder="姓名 / 手机号" aria-label="人员" data-testid="admin-audit-person" />
<el-input v-model="auditFilter.tokenPrefix" clearable placeholder="Token 前缀" aria-label="Token 前缀" data-testid="admin-audit-token" />
<el-select v-model="auditFilter.tool" clearable filterable placeholder="全部工具" aria-label="工具名" data-testid="admin-audit-tool"><el-option v-for="item in tools" :key="item.id" :label="item.name" :value="item.name" /></el-select>
<el-select v-model="auditFilter.tool" clearable filterable placeholder="全部工具" aria-label="工具名" data-testid="admin-audit-tool"><el-option v-for="item in allTools" :key="item.id" :label="item.name" :value="item.name" /></el-select>
<el-select v-model="auditFilter.result" clearable placeholder="全部结果" aria-label="调用结果" data-testid="admin-audit-result"><el-option label="成功" value="success" /><el-option label="拒绝" value="denied" /><el-option label="错误" value="error" /></el-select>
<el-select v-model="auditFilter.client" clearable placeholder="全部客户端" aria-label="客户端" data-testid="admin-audit-client"><el-option label="Kimi" value="mcp-kimi" /><el-option label="Claude" value="mcp-claude" /><el-option label="CLI" value="cli" /><el-option label="未知" value="unknown" /></el-select>
<el-date-picker v-model="auditFilter.from" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" aria-label="开始日期" data-testid="admin-audit-from" />
@@ -530,11 +585,42 @@ onMounted(async () => {
<template #footer><el-button data-testid="admin-client-cancel" @click="clientDialog = false">取消</el-button><el-button type="primary" data-testid="admin-client-save" @click="saveClient">保存</el-button></template>
</el-dialog>
<el-dialog v-model="scopeToolsDialog" :title="`配置工具 · ${activeScope?.name || ''}`" width="680px" destroy-on-close>
<el-checkbox-group v-model="selectedToolIds" class="tool-groups" data-testid="admin-scope-tool-options">
<section v-for="(items, category) in groupedTools" :key="category" class="tool-group"><h3>{{ category }}</h3><el-checkbox v-for="tool in items" :key="tool.id" :value="tool.id" :data-testid="`admin-scope-tool-${tool.id}`"><code>{{ tool.name }}</code><small>{{ tool.description }}</small></el-checkbox></section>
</el-checkbox-group>
<template #footer><el-button data-testid="admin-scope-tools-cancel" @click="scopeToolsDialog = false">取消</el-button><el-button type="primary" data-testid="admin-scope-tools-save" @click="saveScopeTools">保存配置</el-button></template>
<el-dialog v-model="scopeToolsDialog" :title="`配置工具 · ${activeScope?.name || ''}`" width="780px" destroy-on-close>
<div class="scope-tools-toolbar">
<el-input v-model="scopeToolsKeyword" clearable placeholder="搜索工具名 / 描述" aria-label="搜索工具" data-testid="admin-scope-tools-search" />
<el-checkbox v-model="scopeToolsOnlySelected" data-testid="admin-scope-tools-only-selected">仅看已选</el-checkbox>
</div>
<el-tabs v-model="scopeToolsTab" class="scope-tools-tabs" data-testid="admin-scope-tools-tabs">
<el-tab-pane name="">
<template #label>全部({{ allTools.length }})<span v-if="scopeToolsSummary.selected" class="tab-badge">{{ scopeToolsSummary.selected }}</span></template>
</el-tab-pane>
<el-tab-pane v-for="tab in scopeToolTabs" :key="tab.category" :name="tab.category">
<template #label>{{ tab.category }}({{ tab.total }})<span v-if="tab.selected" class="tab-badge">{{ tab.selected }}</span></template>
</el-tab-pane>
</el-tabs>
<el-table :data="scopeToolsVisible" height="360" empty-text="没有匹配的工具" data-testid="admin-scope-tool-options"
:row-class-name="({ row }) => selectedToolIds.includes(row.id) ? 'row-selected' : ''">
<el-table-column width="48" align="center">
<template #header>
<el-checkbox :model-value="scopeToolsAllVisibleSelected" :indeterminate="scopeToolsSomeVisibleSelected && !scopeToolsAllVisibleSelected"
aria-label="全选当前列表" data-testid="admin-scope-tool-select-all" @change="toggleScopeToolsVisible" />
</template>
<template #default="{ row }">
<el-checkbox :model-value="selectedToolIds.includes(row.id)" :aria-label="row.name"
:data-testid="`admin-scope-tool-${row.id}`" @change="checked => toggleScopeTool(row.id, checked)" />
</template>
</el-table-column>
<el-table-column label="工具名" min-width="200"><template #default="{ row }"><code>{{ row.name }}</code></template></el-table-column>
<el-table-column prop="description" label="描述" min-width="220" show-overflow-tooltip />
<el-table-column label="类型" width="70"><template #default="{ row }"><el-tag :type="row.kind === 'write' ? 'danger' : 'success'" size="small">{{ row.kind === 'write' ? '写' : '读' }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="80"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">{{ row.status === 1 ? '启用' : '已停用' }}</el-tag></template></el-table-column>
</el-table>
<template #footer>
<div class="scope-tools-footer">
<span class="scope-tools-summary" data-testid="admin-scope-tools-summary">已选 {{ scopeToolsSummary.selected }}/{{ scopeToolsSummary.total }} 个工具<template v-if="scopeToolsSummary.detail"> {{ scopeToolsSummary.detail }}</template></span>
<span><el-button data-testid="admin-scope-tools-cancel" @click="scopeToolsDialog = false">取消</el-button><el-button type="primary" data-testid="admin-scope-tools-save" @click="saveScopeTools">保存配置</el-button></span>
</div>
</template>
</el-dialog>
<el-dialog :model-value="!!issuedTokens.length" title="Token 签发成功" width="600px" :close-on-click-modal="false" @close="issuedTokens = []">
+8 -7
View File
@@ -52,13 +52,14 @@ code { font-family: Menlo, Consolas, monospace; font-size: 12px; }
.audit-toolbar .el-input { width: 150px; }
.audit-toolbar .el-select { width: 140px; }
.audit-toolbar .el-date-editor { width: 150px; }
.tool-groups { display: block; max-height: 520px; overflow: auto; }
.tool-group { margin-bottom: 12px; border: 1px solid #ebeef5; border-radius: 4px; }
.tool-group h3 { margin: 0; padding: 9px 12px; font-size: 14px; background: #fafafa; border-bottom: 1px solid #ebeef5; }
.tool-group .el-checkbox { width: 100%; height: auto; margin: 0; padding: 9px 12px; align-items: flex-start; }
.tool-group .el-checkbox + .el-checkbox { border-top: 1px solid #f2f3f5; }
.tool-group .el-checkbox__label { display: flex; flex-direction: column; white-space: normal; }
.tool-group small { margin-top: 3px; color: #909399; }
.scope-tools-toolbar { display: flex; gap: 12px; align-items: center; }
.scope-tools-toolbar .el-input { max-width: 260px; }
.scope-tools-tabs { margin-top: 4px; }
.scope-tools-tabs .el-tabs__header { margin-bottom: 8px; }
.tab-badge { display: inline-block; margin-left: 6px; padding: 0 6px; font-size: 12px; line-height: 16px; border-radius: 8px; background: #f0f9eb; color: #67c23a; vertical-align: 2px; }
.scope-tools-footer { display: flex; align-items: center; justify-content: space-between; width: 100%; }
.scope-tools-summary { color: #606266; font-size: 13px; text-align: left; }
.el-table .row-selected { background: #ecf5ff; }
.token-secret { display: block; margin-top: 14px; padding: 14px; word-break: break-all; border-radius: 4px; background: #f4f4f5; font-size: 13px; }
.issued-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
.issued-row .token-secret { flex: 1; margin-top: 0; padding: 9px 12px; }
@@ -19,6 +19,7 @@ test('prototype interactions expose stable test ids', async () => {
'admin-table-clients',
'admin-issued-client-key',
'admin-table-tools',
'admin-tool-pager',
'admin-btn-new-scope',
'admin-table-scopes',
'admin-table-audit',
@@ -162,16 +162,24 @@ public class AdminConsoleController {
return tokenService.rotate(id, adminId(session));
}
/** pageSize=0 表示不分页返回全量,供作用域配置、审计筛选等需要完整工具列表的场景使用。 */
@GetMapping("/tools")
public List<ToolView> tools(@RequestParam(defaultValue = "") String category,
@RequestParam(defaultValue = "") String kind,
@RequestParam(defaultValue = "") String keyword) {
public ToolPageView tools(@RequestParam(defaultValue = "") String category,
@RequestParam(defaultValue = "") String kind,
@RequestParam(defaultValue = "") String keyword,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize) {
int pageNo = Math.max(page, 1);
int size = pageSize == 0 ? 0 : pageSize < 1 ? 20 : Math.min(pageSize, 100);
LambdaQueryWrapper<Tool> query = new LambdaQueryWrapper<Tool>().eq(Tool::getSoftDelete, 0);
if (!category.isBlank()) query.eq(Tool::getCategory, category);
if (!kind.isBlank()) query.eq(Tool::getKind, kind);
if (!keyword.isBlank()) query.and(w -> w.like(Tool::getName, keyword.trim())
.or().like(Tool::getDescription, keyword.trim()));
List<Tool> tools = toolMapper.selectList(query.orderByAsc(Tool::getCategory, Tool::getName));
long total = toolMapper.selectCount(query);
LambdaQueryWrapper<Tool> paged = query.orderByAsc(Tool::getCategory, Tool::getName);
if (size > 0) paged.last("LIMIT " + (long) (pageNo - 1) * size + "," + size);
List<Tool> tools = toolMapper.selectList(paged);
Map<Long, AgentScope> scopes = scopeMapper.selectList(new LambdaQueryWrapper<AgentScope>()
.eq(AgentScope::getSoftDelete, 0)).stream()
.collect(Collectors.toMap(AgentScope::getId, Function.identity()));
@@ -180,9 +188,10 @@ public class AdminConsoleController {
.filter(r -> scopes.containsKey(r.getScopeId()))
.collect(Collectors.groupingBy(ScopeTool::getToolId,
Collectors.mapping(r -> scopes.get(r.getScopeId()).getName(), Collectors.toList())));
return tools.stream().map(tool -> new ToolView(tool.getId(), tool.getName(), tool.getCategory(),
tool.getDescription(), tool.getKind(), tool.getStatus(), tool.getModifyTime(),
List<ToolView> records = tools.stream().map(tool -> new ToolView(tool.getId(), tool.getName(),
tool.getCategory(), tool.getDescription(), tool.getKind(), tool.getStatus(), tool.getModifyTime(),
scopeNames.getOrDefault(tool.getId(), List.of()))).toList();
return new ToolPageView(total, size > 0 ? pageNo : 1, size, records);
}
@PatchMapping("/tools/{id}/status")
@@ -397,6 +406,7 @@ public class AdminConsoleController {
public record StatusRequest(byte status) {}
public record ToolView(Long id, String name, String category, String description, String kind,
Byte status, Date modifyTime, List<String> scopeNames) {}
public record ToolPageView(long total, int page, int pageSize, List<ToolView> records) {}
public record ScopeRequest(String name, String code, String memo) {}
public record ScopeView(Long id, String name, String code, String memo, Byte status,
Date modifyTime, long toolCount) {}
@@ -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<>();
@@ -46,11 +46,15 @@ class AdminConsoleControllerTest {
new org.apache.ibatis.builder.MapperBuilderAssistant(
new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""),
me.iiv.agent.entity.m0.User.class);
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
new org.apache.ibatis.builder.MapperBuilderAssistant(
new com.baomidou.mybatisplus.core.MybatisConfiguration(), ""),
me.iiv.agent.entity.svc.Tool.class);
controller = new AdminConsoleController(userMapper, authorityMapper, agentMapper, tokenMapper,
scopeMapper, toolMapper, scopeToolMapper, auditMapper, tokenScopeMapper,
permissionService, tokenService);
when(userMapper.selectCount(any())).thenReturn(0L);
when(userMapper.selectList(any())).thenReturn(List.of());
org.mockito.Mockito.lenient().when(userMapper.selectCount(any())).thenReturn(0L);
org.mockito.Mockito.lenient().when(userMapper.selectList(any())).thenReturn(List.of());
}
@Test
@@ -78,4 +82,33 @@ class AdminConsoleControllerTest {
assertTrue(sql.contains("enterprise_type"), "用户列表必须过滤物业账号: " + sql);
assertTrue(sql.contains("IS NULL"), "历史 NULL 数据需保留: " + sql);
}
@Test
void toolsClampsPageSizeToHundred() {
when(toolMapper.selectCount(any())).thenReturn(30L);
when(toolMapper.selectList(any())).thenReturn(List.of());
when(scopeMapper.selectList(any())).thenReturn(List.of());
when(scopeToolMapper.selectList(any())).thenReturn(List.of());
AdminConsoleController.ToolPageView view = controller.tools("", "", "", 1, 500);
assertEquals(100, view.pageSize());
assertEquals(1, view.page());
assertEquals(30, view.total());
}
@Test
void toolsPageSizeZeroReturnsAllWithoutLimit() {
when(toolMapper.selectCount(any())).thenReturn(30L);
when(toolMapper.selectList(any())).thenReturn(List.of());
when(scopeMapper.selectList(any())).thenReturn(List.of());
when(scopeToolMapper.selectList(any())).thenReturn(List.of());
AdminConsoleController.ToolPageView view = controller.tools("", "", "", 3, 0);
assertEquals(0, view.pageSize());
assertEquals(1, view.page());
assertEquals(30, view.total());
org.mockito.ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<me.iiv.agent.entity.svc.Tool>>
captor = org.mockito.ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class);
org.mockito.Mockito.verify(toolMapper).selectList(captor.capture());
assertTrue(captor.getValue().getSqlSegment() == null || !captor.getValue().getSqlSegment().contains("LIMIT"),
"全量模式不应带 LIMIT: " + captor.getValue().getSqlSegment());
}
}
@@ -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;
}
}