diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index 25a41a8..862242b 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -100,6 +100,9 @@ xiaoqu-agent-service/ | `hardware.electricity.message.list.query` | hardware | read | 设备电量/信号/温度等消息记录分页查询(ES electricitymessage_yyyyMM 月索引,只走本地集群,无权限过滤,对齐老 /batteryAnalyse/batteryAnalyseList)。入参 snCode(必填)/startTime/endTime(必填且须同一天,跨天拒绝)/messageType(0-5,不传=全部类型)/page/rows(默认 1/500,上限 1000);出参 snCode/index/total/page/rows/records(createTime/messageType/messageTypeText/value),createTime ASC。详细设计见 doc/electricity-message-mcp-tool-design.md | | `agent.user.list.query` | agent | read | 代理角色用户分页查询(m0 user 表,强制 enterprise_type=1 + soft_delete=0,对齐老 /user/listByPage 代理用户子集)。入参 agentId/name/mobile/no/authorityId/deptId/workStatus/page/rows;出参含 agentName/authorityName/deptList(dept_user 岗位明细)/districtName,mobile 原样不脱敏,不含 idcard/password/openid 与位置字段。行权限:总部看全部,代理商按 visibleAgentIds(自己+直属下级+代管)收缩,agentId 越权拒绝;只绑 prod scope 1。详细设计见 doc/agent-user-list-mcp-tool-design.md | | `hardware.cleanlog.list.query` | hardware | read | 清机记录分页查询(m0 hardware_clean_log + hardware_clean_pic,对齐老 /hardwareCleanLog/listByPage)。入参 districtId/propertyId/agentId/userId/snCode(模糊)/type/userRoleType/startTime/endTime(按 modify_time)/minCleanRfidCount/maxCleanRfidCount/page/rows;硬过滤 soft_delete=0 且 status=1,固定 id DESC;传 agentId 仍叠加行级 ACL(修老接口绕过 bug);名称 Redis multiGet + user 按页批量富化(修 N+1);type=0 用行内图片,type≠0 按页批量查图片表拼 OSS URL。详细设计见 doc/hardware-cleanlog-mcp-design.md | +| `battery.inspection.list.query` | battery | read | 电池检查记录分页查询(m2 battery_inspection,对齐老 /batteryInspection/pageList)。入参 snCode(模糊)/agentId/propertyId/districtId/checkUserId/checkUserName(模糊)/checkType(0小保养 1大保养)/checkRecord14/productionDateStart/End/checkTimeStart/End;硬过滤 soft_delete=0,固定 id DESC;行权限 resolveRowScope 含直属下级落 apply_agent_id。详细设计见 doc/battery-inspection-repair-mcp-design.md | +| `repair.recording.list.query` | repair | read | 故障汇总分页查询(m0 equipment_repair_recording,对齐老 /equipmentRepair/recordingByPage)。入参 id/snCode/problem(模糊)/agentId(收窄语义,仍叠加行权限)/propertyId/districtId/state/userId/sourceType/deviceType/createTimeStart/End;硬过滤 soft_delete=0+effective_state=1,固定 id DESC;富化按页批量(修 N+1)。详细设计见 doc/battery-inspection-repair-mcp-design.md | +| `repair.handle.list.query` | repair | read | 现场维修记录分页查询(m0 equipment_repair_handle,对齐老 /equipmentRepair/repairRecordHandleList)。入参 id/equipmentRepairId/snCode/agentId/propertyId/districtId/state/userId/assembly/assemblyFault/chipCode(模糊)/accessoryId;硬过滤 soft_delete=0+effective_state=1,固定 id DESC;组件/零部件名按页查 fault_info 字典,chipCode 落 chip_code(修老接口拼不存在列的 bug)。详细设计见 doc/battery-inspection-repair-mcp-design.md | 分页统一规范:page 从 1 开始,rows 默认 20、上限 100,超出截断为 100 并在返回 `notice` 字段提示。 diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md index e21b3e6..80fbf99 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -1154,6 +1154,51 @@ 三个工具详细设计见 [warehouse-details-stock-mcp-design](warehouse-details-stock-mcp-design.md)。 +## 49. battery.inspection.list.query + +电池检查记录分页查询(m2 `battery_inspection`,纯 MySQL),对应老 `/batteryInspection/pageList`。硬过滤 `soft_delete=0`,固定 `id DESC`。 + +- 分类:battery | 类型:读 +- 入参(全部可选):`agentId`/`propertyId`/`districtId`/`checkUserId`(精确); + `snCode`/`checkUserName`(模糊);`checkType`(0小保养 1大保养)/`checkRecord14`(0否 1是); + `productionDateStart/End`(生产日期,纯日期);`checkTimeStart/End`(检查时间,纯日期自动扩展当天边界); + `page`/`rows` 默认 1/20 上限 100。 +- record 全字段,checkRecord1-14 为检查项(0未勾选 1勾选;3=电池内阻、4=测量电压为原始读数), + 依次 1无法充电 2电量异常 3电池内阻 4测量电压 5温度异常 6气味异常 7异物异响 8明显损伤 + 9鼓包变形 10明显变色 11氧化腐蚀 12异物粘连 13测量内阻是否处理触点 14是否返回; + agent/property/district/applyAgent 名称为行内冗余字段;附 checkTypeText。 +- 行级权限:复用 `resolveRowScope` 口径(**含直属下级**,方案 B 已拍板,与老接口 getByIsAgentNew 不含下级的口径有差异),落 `apply_agent_id`;传 agentId 也仍叠加行过滤。 + +## 50. repair.recording.list.query + +故障汇总(报修/故障记录)分页查询(m0 `equipment_repair_recording`),对应老 `/equipmentRepair/recordingByPage`。硬过滤 `soft_delete=0 + effective_state=1`,固定 `id DESC`。 + +- 分类:repair | 类型:读 +- 入参(全部可选):`id`/`propertyId`/`districtId`/`userId`(申报人,精确); + `snCode`/`problem`(模糊);`agentId` 为收窄语义(申请代理或所属代理命中其一,仍叠加行权限); + `state`(0待解决 1已解决 2延迟解决)/`sourceType`(0运维换机 1客户换机 2维护维修)/ + `deviceType`(0厕纸机 1擦手纸机);`createTimeStart/End`(纯日期含边界);`page`/`rows` 默认 1/20 上限 100。 +- record 全字段 + agentName/propertyName/districtName/userName 富化(Redis multiGet/按页批量,修老接口逐条 N+1) + + stateText/sourceTypeText/deviceTypeText;图片不在列表内(老系统为独立接口)。 +- 行级权限:resolveRowScope 口径(含直属下级)按 propertyFlag=true 三字段 OR 落 + (apply_agent_id OR property_id 代管物业 OR agent_id)。 + +## 51. repair.handle.list.query + +现场维修记录(维修处理明细)分页查询(m0 `equipment_repair_handle`),对应老 `/equipmentRepair/repairRecordHandleList`。硬过滤 `soft_delete=0 + effective_state=1`,固定 `id DESC`。 + +- 分类:repair | 类型:读 +- 入参(全部可选):`id`/`equipmentRepairId`(故障记录 id)/`propertyId`/`districtId`/`userId`(维修人)/ + `accessoryId`(备件 id,精确);`snCode`/`assembly`(组件编号)/`assemblyFault`(零部件编号)/ + `chipCode`(备件芯片编号,落 `chip_code`——修老接口 chip 参数拼不存在列的 bug)(模糊); + `agentId` 收窄语义同 recording;`state` 同 recording;`page`/`rows` 默认 1/20 上限 100。 +- record 全字段 + agentName/propertyName/districtName/userName(维修人)富化 + + assemblyName/assemblyFaultName(fault_info 字典按页批量,type 0/1 区分组件/零部件) + + accessoryName(备件不存在为 null,修老接口 NPE 风险)+ stateText/deviceTypeText。 +- 行级权限:与 repair.recording.list.query 同口径。 + +三个工具详细设计见 [battery-inspection-repair-mcp-design](battery-inspection-repair-mcp-design.md)。 + --- ## 附录:在线查看工具清单 diff --git a/xiaoqu-agent-service/doc/battery-inspection-repair-mcp-design.md b/xiaoqu-agent-service/doc/battery-inspection-repair-mcp-design.md new file mode 100644 index 0000000..b672ad3 --- /dev/null +++ b/xiaoqu-agent-service/doc/battery-inspection-repair-mcp-design.md @@ -0,0 +1,102 @@ +# 电池检查 / 故障汇总 / 现场维修记录 MCP 工具设计 + +- 需求:REQ-20261106-0020(id 6422) +- 状态:**已评审通过(2026-11-06 王恒拍板:方案 B)**,开发中 +- 日期:2026-11-06 + +## 1. 背景 + +将老系统三个分页列表接口 MCP 化,供外部 Agent 查询: + +| 老接口 | 业务含义 | 数据源 | 新工具 | +|---|---|---|---| +| `/batteryInspection/pageList` | 电池检查记录 | m2 `battery_inspection` | `battery.inspection.list.query` | +| `/equipmentRepair/recordingByPage` | 故障汇总管理(报修/故障记录) | m0 `equipment_repair_recording` | `repair.recording.list.query` | +| `/equipmentRepair/repairRecordHandleList` | 现场维修记录(维修处理明细) | m0 `equipment_repair_handle` | `repair.handle.list.query` | + +三个接口在 agent-service 均无对应工具(已有 `battery.fault.list.query` 是电池故障日志,与电池检查不同域),不重复。 + +## 2. 老接口口径(复刻基准) + +### 2.1 batteryInspection/pageList +`xiaoqu-franchisee/.../BatteryInspectionController.java:62` +- 硬过滤:`soft_delete=0`;默认 `id desc`;物理分页 +- 入参:snCode(like)、agentId/propertyId/districtId(eq)、productionDate 区间(不拼时间)、checkTime 区间(拼 00:00:00/23:59:59)、checkUserId(eq)、checkUserName(like)、checkRecord14、checkType(0 小保养/1 大保养) +- 行权限:`WrapperUtils.getByIsAgentNew` propertyFlag=false 分支:代理商 → `apply_agent_id IN (自己+代管)`;销售非负责人 → `property_id IN (自建物业 ∪ 代管代理名下物业)`;总部不过滤 +- 返回:bean 全字段,名称(agent/property/district/applyAgent)行内冗余落库,无富化无 N+1 + +### 2.2 equipmentRepair/recordingByPage +`EquipmentRepairController.java:117` +- 硬过滤:`soft_delete=0` + `effective_state=1`;默认 `id desc` +- 入参:id、agentId(**OR 语义**:`apply_agent_id=? OR agent_id=?`)、propertyId、state、districtId、userId、createTime 区间(不拼时间)、snCode(like)、sourceType、problem(like) +- 行权限:`getByIsAgentNew` propertyFlag=true:代理商 `AND (apply_agent_id IN ids OR property_id IN 代管物业 OR agent_id IN ids)`;销售非负责人按物业集合 +- 富化:逐条 Redis 查 agent/property(老拼写 `name:Properyt:`)/district 名 + 逐条 userService.getById → MCP 改批量 +- 图片不在列表(独立接口 problemPicList 查 `equipment_repair_pic`) + +### 2.3 equipmentRepair/repairRecordHandleList +`EquipmentRepairController.java:183` +- 硬过滤:`soft_delete=0` + `effective_state=1`;默认 `id desc` +- 入参:在 recording 基础上多 assembly(like)、assemblyFault(like)、accessoryId(eq)、equipmentRepairId(eq)、chipCode +- 行权限:同 recording +- 富化最重:组件/零件名查 `fault_info` 字典、备件名查 `accessory`、维修人查 `user`——老代码逐条查且 accessory byId 无 null 判断有 NPE 风险 → MCP 全改按页 IN 批量 + +## 3. MCP 工具设计 + +### 3.1 共性约定 +- 1 个工具类 `BatteryRepairTools`(或按域拆 `BatteryInspectionTools` + `EquipmentRepairTools`,倾向后者:battery 归 battery 域、repair 归 repair 域,与现有 battery.fault 并列) +- 纯 MySQL 无 ES;物理分页 `page/rows`(rows 上限 100,超出截断 + notice) +- 固定 `id DESC` 排序,不搬老 sidx/sord 动态排序(注入面) +- 枚举配中文文案;报错 `ToolCallException` 透出 +- 不搬 webId(老接口仅校验不裁剪) +- 实体/Mapper 复制到 `me.iiv.agent.entity/mapper` 本地包(m2: BatteryInspection;m0: EquipmentRepairRecording、EquipmentRepairHandle、FaultInfo、Accessory),全列拷贝 + +### 3.2 battery.inspection.list.query +- 入参:snCode、agentId、propertyId、districtId、checkType、checkUserId、checkUserName、productionDateStart/End、checkTimeStart/End、checkRecord14、page、rows +- 返回 rows[]:id、snCode、checkType(+Text)、checkRecord1-14(含文案:1 无法充电/2 电量异常/3 电池内阻/4 测量电压/5 温度异常/6 气味异常/7 异物异响/8 明显损伤/9 鼓包变形/10 明显变色/11 氧化腐蚀/12 异物粘连/13 测内阻是否处理触点/14 是否返回)、productionDate、checkTime、checkUserId/Name、applyAgentId/Name、agentId/Name、propertyId/Name、districtId/Name、memo、createTime +- 行权限:直接复用现有 `resolveRowScope` 口径(**含直属下级**,方案 B 已拍板),落 `apply_agent_id` 字段 + +### 3.3 repair.recording.list.query +- 入参:id、snCode、agentId(保留 apply_agent_id OR agent_id 收窄语义)、propertyId、districtId、state、userId、sourceType、deviceType、problem、createTimeStart/End、page、rows +- 返回 rows[]:id、repairNo、snCode、deviceType(+Text)、state(+Text:0 待解决/1 已解决/2 延迟解决)、sourceType(+Text)、problem、paperCount、faultCount、recordTime、userId/userName(申报人)、位置字段组(buildingId/floor/toiletId/positionId/installAddress)、agentId/Name、applyAgentId/Name、propertyId/Name、districtId/Name、maintenanceAgentId/Name、maintenancePropertyId/Name、handUserId/Name、workNo、createTime +- 富化批量:Redis multiGet(`name:Agent:`/`name:Properyt:` 保留老拼写/`name:District:`)+ user 按页 IN +- 行权限:复用 `resolveRowScope` 口径(含直属下级),按 propertyFlag=true 三字段 OR 落(apply_agent_id OR property_id 代管物业 OR agent_id) +- 图片:本期不内嵌(老系统也是独立接口);如需要后续加 pic.list 工具 + +### 3.4 repair.handle.list.query +- 入参:id、equipmentRepairId、snCode、agentId(同 OR 语义)、propertyId、districtId、state、userId、assembly、assemblyFault、accessoryId、chipCode、page、rows +- 返回 rows[]:id、recordrNo(列名拼写如此)、equipmentRepairId、snCode、deviceType(+Text)、state(+Text)、assembly/assemblyName、assemblyFault/assemblyFaultName、accessoryId/accessoryName、chipCode/oldChipCode、addTime、userId/userName(维修人)、memo、位置字段组、agentId/Name、applyAgentId/Name、propertyId/Name、districtId/Name、maintenanceAgentId/Name、maintenancePropertyId/Name、workNo、createTime +- 富化:fault_info 按页收集 assembly/assemblyFault 号批量查(带 type 区分 0 组件/1 零部件)、accessory 与 user 按页 IN、Redis 名称 multiGet +- 行权限:同 recording(共用 repair 三字段 OR 行过滤方法) + +## 4. 与老系统的差异(修正记录) + +| # | 老系统行为 | MCP 行为 | 理由 | +|---|---|---|---| +| 1 | sidx/sord 动态排序拼列名 | 固定 id desc | 注入口径收敛 | +| 2 | repair 富化逐条 Redis/MySQL(N+1,accessory 有 NPE 风险) | 按页批量 | 性能+稳定性 | +| 3 | HandleRecordingPageInput 的 `chip` 参数拼不存在的 `chip` 列(报错风险) | 参数名 chipCode,落 `chip_code` 列 | 修 bug | +| 4 | webId 必填校验(不裁剪) | 不搬 | MCP 无页面概念 | +| 5 | 检查时间区间自动拼 00:00:00/23:59:59 | 保留该语义并在参数描述写清 | 体验一致 | + +## 5. 权限决策点(**已拍板:方案 B**,2026-11-06 王恒) + +**行权限口径**:老接口用 `getByIsAgentNew`,代理可见范围 = 自己 + warehouse_agent_auth 代管,**不含直属下级代理**;而 agent-service 现有全部 apply*RowFilter(resolveRowScope)都含直属下级。 + +- ~~方案 A:新增两个变体 1:1 复刻老口径~~ +- **方案 B(采纳):直接复用 resolveRowScope 口径(含直属下级),少两个方法;与老系统可见范围差异=代理额外可见直属下级数据,已确认接受** + +## 6. 改动清单 + +1. 实体+Mapper 本地拷贝:m2 BatteryInspection;m0 EquipmentRepairRecording、EquipmentRepairHandle、FaultInfo、Accessory(注意 recordr_no 列名拼写) +2. `PermissionService`:**不新增变体**,battery 用现有 resolveRowScope 口径落 apply_agent_id;repair 两个工具共用三字段 OR 口径(resolveRowScope ids + 代管物业集合) +3. 工具类:`BatteryInspectionTools`(1 工具)、`EquipmentRepairTools`(2 工具) +4. 单测:枚举/校验/权限变体/批量富化 +5. seed-dev.sql scope 绑定;上线后 prod 绑 scope 1/2/3/5(在线查询工具惯例,battery/repair 冒烟走 ops-query=scope 5) +6. `doc/agent-mcp-tools-reference.md` 登记 3 个工具 +7. 装配层零改动(启动自动注册) + +## 7. 验收口径 + +- 构建全绿(现有 496 + 新增用例) +- dev 冒烟:三工具列表分页、过滤、行权限(代理账号只见自己范围) +- 生产冒烟:ops-query token 三工具各 1 次真实调用 + 无权收窄验证 diff --git a/xiaoqu-agent-service/sql/seed-dev.sql b/xiaoqu-agent-service/sql/seed-dev.sql index 27ae13e..d7bd610 100644 --- a/xiaoqu-agent-service/sql/seed-dev.sql +++ b/xiaoqu-agent-service/sql/seed-dev.sql @@ -56,7 +56,7 @@ WHERE NOT EXISTS (SELECT 1 FROM agent_scope WHERE code = 'ops-query'); INSERT INTO scope_tool (scope_id, tool_id, create_id) SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t -WHERE s.code = 'ops-query' AND t.name IN ('sys.ping', 'integration.context.get', 'agent.count', 'agent.list.query', 'agent.detail.query', 'property.list.query', 'property.detail.query', 'district.list.query', 'district.detail.get', 'district.paper.statistics.query', 'district.paper.position.detail.query', 'hardware.list.query', 'hardware.detail.query', 'hardware.cleanlog.list.query', 'contract.list.query', 'contract.detail.query', 'mall.order.list.query', 'mall.order.detail.query', 'mall.order.logistics.query', 'install.order.list.query', 'install.order.detail.query', 'delivery.order.list.query', 'delivery.order.detail.query', 'pickup.order.list.query', 'pickup.order.detail.query', 'relocation.order.list.query', 'relocation.order.detail.query', 'withdraw.order.list.query', 'withdraw.order.detail.query', 'warehouse.in.list.query', 'warehouse.in.detail.query', 'warehouse.out.list.query', 'warehouse.out.detail.query', 'warehouse.stock.list.query', 'warehouse.out.details.list.query', 'warehouse.in.details.list.query', 'warehouse.hardstock.list.query') +WHERE s.code = 'ops-query' AND t.name IN ('sys.ping', 'integration.context.get', 'agent.count', 'agent.list.query', 'agent.detail.query', 'property.list.query', 'property.detail.query', 'district.list.query', 'district.detail.get', 'district.paper.statistics.query', 'district.paper.position.detail.query', 'hardware.list.query', 'hardware.detail.query', 'hardware.cleanlog.list.query', 'contract.list.query', 'contract.detail.query', 'mall.order.list.query', 'mall.order.detail.query', 'mall.order.logistics.query', 'install.order.list.query', 'install.order.detail.query', 'delivery.order.list.query', 'delivery.order.detail.query', 'pickup.order.list.query', 'pickup.order.detail.query', 'relocation.order.list.query', 'relocation.order.detail.query', 'withdraw.order.list.query', 'withdraw.order.detail.query', 'warehouse.in.list.query', 'warehouse.in.detail.query', 'warehouse.out.list.query', 'warehouse.out.detail.query', 'warehouse.stock.list.query', 'warehouse.out.details.list.query', 'warehouse.in.details.list.query', 'warehouse.hardstock.list.query', 'battery.inspection.list.query', 'repair.recording.list.query', 'repair.handle.list.query') AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id); -- 人均用纸统计仅开放给用户委托 Token:绑定全部非服务身份作用域,不进入 sync/ops-query。 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/Accessory.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/Accessory.java new file mode 100644 index 0000000..591d378 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/Accessory.java @@ -0,0 +1,45 @@ +package me.iiv.agent.entity.m0; + +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; + +/** 备件(m0.accessory),逐字段复制自老系统 beans/b0/Accessory(老实体未标 @TableName,按约定落 accessory 表)。 */ +@Getter +@Setter +@ToString +@TableName("accessory") +public class Accessory 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 stockNo; + /** 生成编号 */ + private String productNo; + /** 显示状态 */ + private Integer showStatus; + /** 物料id */ + private Long materielId; + /** 设计寿命 */ + private Integer lifeCount; + /** 备件名称 */ + private String name; + /** 是否有编码 */ + private Integer codeStatus; + /** 排序 */ + private Integer orderBy; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/EquipmentRepairHandle.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/EquipmentRepairHandle.java new file mode 100644 index 0000000..c7a5c5d --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/EquipmentRepairHandle.java @@ -0,0 +1,75 @@ +package me.iiv.agent.entity.m0; + +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; + +/** 设备处理(现场维修记录,m0.equipment_repair_handle),逐字段复制自老系统 beans/b0/EquipmentRepairHandle。 */ +@Getter +@Setter +@ToString +@TableName("equipment_repair_handle") +public class EquipmentRepairHandle 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; + /** 维修任务id(关联 equipment_repair_recording.id) */ + private Long equipmentRepairId; + /** 维修人 */ + private Long userId; + /** 添加时间 */ + private Date addTime; + /** 记录描述 */ + private String memo; + /** 维修状态 0:待解决;1:已解决;2:延迟解决 */ + private Integer state; + /** 记录编号(列名拼写即 recordr_no,勿改) */ + private String recordrNo; + /** 有效状态(老接口恒过滤 effective_state=1) */ + private Integer effectiveState; + /** 设备所属代理ID */ + private Long agentId; + private Long propertyId; + private Long districtId; + private Long buildingId; + private String floor; + private Long toiletId; + private Long positionId; + private String installAddress; + private String snCode; + /** 0:厕纸机;1:擦手纸机 */ + private Integer deviceType; + /** 组件编号(fault_info.assembly_no,type=0 组件) */ + private String assembly; + /** 零部件编号(fault_info.assembly_no,type=1 零部件) */ + private String assemblyFault; + /** 备件ID */ + private Long accessoryId; + /** 备件芯片编号 */ + private String chipCode; + /** 旧芯片编号 */ + private String oldChipCode; + /** 工单编号 */ + private String workNo; + private Long maintenanceAgentId; + private String maintenanceAgentName; + private Long maintenancePropertyId; + private String maintenancePropertyName; + /** 记录人代理ID */ + private Long applyAgentId; + /** 记录人代理 */ + private String applyAgentName; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/EquipmentRepairRecording.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/EquipmentRepairRecording.java new file mode 100644 index 0000000..91dcbf1 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/EquipmentRepairRecording.java @@ -0,0 +1,74 @@ +package me.iiv.agent.entity.m0; + +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; + +/** 设备维修(故障汇总,m0.equipment_repair_recording),逐字段复制自老系统 beans/b0/EquipmentRepairRecording。 */ +@Getter +@Setter +@ToString +@TableName("equipment_repair_recording") +public class EquipmentRepairRecording 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; + /** 设备所属代理ID */ + private Long agentId; + private Long propertyId; + private Long districtId; + private Long buildingId; + private String floor; + private Long toiletId; + private Long positionId; + private String installAddress; + /** 申报人 */ + private Long userId; + /** 最近修改时间 */ + private Date recordTime; + private String snCode; + /** 问题描述 */ + private String problem; + /** 维修状态 0:待解决;1:已解决;2:延迟解决 */ + private Integer state; + /** 维修编号 */ + private String repairNo; + /** 有效状态(老接口恒过滤 effective_state=1) */ + private Integer effectiveState; + /** 0:厕纸机;1:擦手纸机 */ + private Integer deviceType; + /** 出纸次数 */ + private Long paperCount; + /** 故障点计数 */ + private Integer faultCount; + /** 来源类型 0:运维换机;1:客户换机;2:维护维修 */ + private Integer sourceType; + /** 工单编号 */ + private String workNo; + private Long maintenanceAgentId; + private String maintenanceAgentName; + private Long maintenancePropertyId; + private String maintenancePropertyName; + /** 维修人 */ + private Long handUserId; + /** 维修人名称 */ + private String handUserName; + /** 记录人代理ID */ + private Long applyAgentId; + /** 记录人代理 */ + private String applyAgentName; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/FaultInfo.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/FaultInfo.java new file mode 100644 index 0000000..2eb5e8a --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/FaultInfo.java @@ -0,0 +1,43 @@ +package me.iiv.agent.entity.m0; + +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; + +/** 故障点字典(m0.fault_info),逐字段复制自老系统 beans/b0/FaultInfo。 */ +@Getter +@Setter +@ToString +@TableName("fault_info") +public class FaultInfo 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; + /** 数据状态,0 正常,99 删除 */ + private Integer softDelete; + /** 组件编号 */ + private String assemblyNo; + /** 组件名称 */ + private String assemblyName; + /** 排序 */ + private Integer orderBy; + /** 上级编号 */ + private String parentNo; + /** 故障类型 0:组件;1:零部件 */ + private Integer type; + private String memo; + /** 当前零部件编号 */ + private String currendNo; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/BatteryInspection.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/BatteryInspection.java new file mode 100644 index 0000000..ace7d44 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/BatteryInspection.java @@ -0,0 +1,83 @@ +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.time.LocalDate; +import java.util.Date; + +/** 电池检测(m2.battery_inspection),逐字段复制自老系统 publicbeanandmap beans/b2/BatteryInspection。 */ +@Getter +@Setter +@ToString +@TableName("battery_inspection") +public class BatteryInspection 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; + /** 无法充电(0:-;1:√) */ + private Integer checkRecord1; + /** 电量异常(0:-;1:√) */ + private Integer checkRecord2; + /** 电池内阻 */ + private String checkRecord3; + /** 测量电压 */ + private String checkRecord4; + /** 温度异常(0:-;1:√) */ + private Integer checkRecord5; + /** 气味异常(0:-;1:√) */ + private Integer checkRecord6; + /** 异物异响(0:-;1:√) */ + private Integer checkRecord7; + /** 明显损伤(0:-;1:√) */ + private Integer checkRecord8; + /** 鼓包变形(0:-;1:√) */ + private Integer checkRecord9; + /** 明显变色(0:-;1:√) */ + private Integer checkRecord10; + /** 氧化腐蚀(0:-;1:√) */ + private Integer checkRecord11; + /** 异物粘连(0:-;1:√) */ + private Integer checkRecord12; + /** 测量内阻是否处理触点(0:-;1:√) */ + private Integer checkRecord13; + /** 是否返回(0:-;1:√) */ + private Integer checkRecord14; + /** 电池编号 */ + private String snCode; + /** 申请代理(行内冗余名称落库,老接口无富化) */ + private Long applyAgentId; + private String applyAgentName; + private Long agentId; + private Long propertyId; + private Long districtId; + /** 公司名称(行内冗余) */ + private String agentName; + /** 物业名称(行内冗余) */ + private String propertyName; + /** 项目名称(行内冗余) */ + private String districtName; + /** 生产日期 */ + private LocalDate productionDate; + /** 检查时间 */ + private Date checkTime; + private String memo; + /** 检查人ID */ + private Long checkUserId; + /** 检查人 */ + private String checkUserName; + /** 保养类型(0:小保养;1:大保养) */ + private Integer checkType; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/AccessoryMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/AccessoryMapper.java new file mode 100644 index 0000000..9f8d700 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/AccessoryMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m0; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m0.Accessory; + +public interface AccessoryMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/EquipmentRepairHandleMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/EquipmentRepairHandleMapper.java new file mode 100644 index 0000000..4564bb3 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/EquipmentRepairHandleMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m0; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m0.EquipmentRepairHandle; + +public interface EquipmentRepairHandleMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/EquipmentRepairRecordingMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/EquipmentRepairRecordingMapper.java new file mode 100644 index 0000000..50abe46 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/EquipmentRepairRecordingMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m0; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m0.EquipmentRepairRecording; + +public interface EquipmentRepairRecordingMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/FaultInfoMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/FaultInfoMapper.java new file mode 100644 index 0000000..daa80b6 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/FaultInfoMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m0; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m0.FaultInfo; + +public interface FaultInfoMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/BatteryInspectionMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/BatteryInspectionMapper.java new file mode 100644 index 0000000..876d335 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/BatteryInspectionMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m2; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m2.BatteryInspection; + +public interface BatteryInspectionMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/BatteryInspectionTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/BatteryInspectionTools.java new file mode 100644 index 0000000..757f149 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/BatteryInspectionTools.java @@ -0,0 +1,251 @@ +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.BatteryInspection; +import me.iiv.agent.mapper.m2.BatteryInspectionMapper; +import me.iiv.agent.mcp.McpErrorCode; +import me.iiv.agent.mcp.ToolCallException; +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.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 电池检查记录查询工具,仿老系统 BatteryInspectionController /pageList + * (xiaoqu-franchisee controller/batteryInspection/BatteryInspectionController.java:62)。 + * 名称(agent/property/district/applyAgent)行内冗余落库,无富化; + * 固定 id DESC(不搬老 sidx/sord 动态排序);checkTime 区间按老接口拼整天 00:00:00/23:59:59。 + */ +@Component +public class BatteryInspectionTools implements ToolObject { + + private static final int DEFAULT_ROWS = 20; + private static final int MAX_ROWS = 100; + private static final DateTimeFormatter DATE_FORMAT = + DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT); + + /** checkRecord1-14 检查项文案(老 BatteryInspection 实体注释口径) */ + private static final String[] CHECK_RECORD_LABELS = { + "无法充电", "电量异常", "电池内阻", "测量电压", "温度异常", "气味异常", "异物异响", + "明显损伤", "鼓包变形", "明显变色", "氧化腐蚀", "异物粘连", "测量内阻是否处理触点", "是否返回"}; + + final BatteryInspectionMapper mapper; + final PermissionService permissionService; + + public BatteryInspectionTools(BatteryInspectionMapper mapper, PermissionService permissionService) { + this.mapper = mapper; + this.permissionService = permissionService; + } + + /** + * 分页查询电池检查记录。硬过滤 soft_delete=0,固定 id DESC, + * selectCount + LIMIT 物理分页(项目未注册 MP 分页拦截器,禁用 selectPage)。 + */ + @XqTool(category = "battery", kind = "read") + @Tool(name = "battery.inspection.list.query", description = "电池检查记录分页查询,对应老系统 /batteryInspection/pageList。" + + "硬过滤 soft_delete=0,固定按 id 倒序。支持 snCode(模糊)、agentId/propertyId/districtId/" + + "checkUserId(精确)、checkUserName(模糊)、checkType(0=小保养 1=大保养)、" + + "checkRecord14(是否返回:0=否 1=是)筛选;productionDateStart/End 按生产日期过滤(纯日期);" + + "checkTimeStart/End 按检查时间过滤,纯日期自动扩展为当天 00:00:00 至 23:59:59。" + + "传了 agentId 也仍叠加当前身份的行级数据权限,无权数据返回空。" + + "page 默认 1,rows 默认 20、上限 100(超出截断为 100 并在 notice 提示)。" + + "每行返回全字段:checkRecord1-14 为检查项(0=未勾选 1=勾选,3=电池内阻与 4=测量电压为原始读数)," + + "依次为 1无法充电 2电量异常 3电池内阻 4测量电压 5温度异常 6气味异常 7异物异响 8明显损伤 " + + "9鼓包变形 10明显变色 11氧化腐蚀 12异物粘连 13测量内阻是否处理触点 14是否返回;" + + "agent/property/district/applyAgent 名称为行内冗余字段。") + public Map batteryInspectionListQuery( + @ToolParam(required = false, description = "电池编号,模糊匹配") String snCode, + @ToolParam(required = false, description = "代理 id,正整数,精确匹配") Long agentId, + @ToolParam(required = false, description = "物业 id,正整数,精确匹配") Long propertyId, + @ToolParam(required = false, description = "站点 id,正整数,精确匹配") Long districtId, + @ToolParam(required = false, description = "保养类型:0=小保养 1=大保养") Integer checkType, + @ToolParam(required = false, description = "检查人 id,正整数,精确匹配") Long checkUserId, + @ToolParam(required = false, description = "检查人姓名,模糊匹配") String checkUserName, + @ToolParam(required = false, description = "生产日期起始,yyyy-MM-dd,含当天") String productionDateStart, + @ToolParam(required = false, description = "生产日期结束,yyyy-MM-dd,含当天") String productionDateEnd, + @ToolParam(required = false, description = "检查时间起始,yyyy-MM-dd,自动扩展为当天 00:00:00") String checkTimeStart, + @ToolParam(required = false, description = "检查时间结束,yyyy-MM-dd,自动扩展为当天 23:59:59") String checkTimeEnd, + @ToolParam(required = false, description = "是否返回:0=否 1=是") Integer checkRecord14, + @ToolParam(required = false, description = "页码,从 1 开始,默认 1") Integer page, + @ToolParam(required = false, description = "每页条数,默认 20,上限 100(超出截断为 100)") Integer rows) { + validatePositive(agentId, "agentId"); + validatePositive(propertyId, "propertyId"); + validatePositive(districtId, "districtId"); + validatePositive(checkUserId, "checkUserId"); + validateEnum(checkType, "checkType", 0, 1); + validateEnum(checkRecord14, "checkRecord14", 0, 1); + LocalDate productionStart = parseDate(productionDateStart, "productionDateStart"); + LocalDate productionEnd = parseDate(productionDateEnd, "productionDateEnd"); + if (productionStart != null && productionEnd != null && productionStart.isAfter(productionEnd)) { + throw invalid("productionDateStart 不能晚于 productionDateEnd"); + } + LocalDate checkStart = parseDate(checkTimeStart, "checkTimeStart"); + LocalDate checkEnd = parseDate(checkTimeEnd, "checkTimeEnd"); + if (checkStart != null && checkEnd != null && checkStart.isAfter(checkEnd)) { + throw invalid("checkTimeStart 不能晚于 checkTimeEnd"); + } + + User user = AuthContextHolder.get().getUser(); + 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(BatteryInspection::getSoftDelete, 0) + .eq(agentId != null, BatteryInspection::getAgentId, agentId) + .eq(propertyId != null, BatteryInspection::getPropertyId, propertyId) + .eq(districtId != null, BatteryInspection::getDistrictId, districtId) + .eq(checkType != null, BatteryInspection::getCheckType, checkType) + .eq(checkUserId != null, BatteryInspection::getCheckUserId, checkUserId) + .eq(checkRecord14 != null, BatteryInspection::getCheckRecord14, checkRecord14); + if (StringUtils.hasText(snCode)) { + wrapper.like(BatteryInspection::getSnCode, snCode.trim()); + } + if (StringUtils.hasText(checkUserName)) { + wrapper.like(BatteryInspection::getCheckUserName, checkUserName.trim()); + } + if (productionStart != null) { + wrapper.ge(BatteryInspection::getProductionDate, productionStart); + } + if (productionEnd != null) { + wrapper.le(BatteryInspection::getProductionDate, productionEnd); + } + // 老接口语义:检查时间纯日期拼整天(:67-74) + if (checkStart != null) { + wrapper.ge(BatteryInspection::getCheckTime, checkStart.format(DATE_FORMAT) + " 00:00:00"); + } + if (checkEnd != null) { + wrapper.le(BatteryInspection::getCheckTime, checkEnd.format(DATE_FORMAT) + " 23:59:59"); + } + permissionService.applyBatteryInspectionRowFilter(wrapper, user); + + Long total = mapper.selectCount(wrapper); + wrapper.orderByDesc(BatteryInspection::getId) + .last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize); + List> records = mapper.selectList(wrapper).stream() + .map(this::toRecord).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 toRecord(BatteryInspection bean) { + Map record = new LinkedHashMap<>(); + record.put("id", bean.getId()); + record.put("snCode", bean.getSnCode()); + record.put("checkType", bean.getCheckType()); + record.put("checkTypeText", checkTypeText(bean.getCheckType())); + putCheckRecord(record, 1, bean.getCheckRecord1()); + putCheckRecord(record, 2, bean.getCheckRecord2()); + record.put("checkRecord3", bean.getCheckRecord3()); + record.put("checkRecord4", bean.getCheckRecord4()); + putCheckRecord(record, 5, bean.getCheckRecord5()); + putCheckRecord(record, 6, bean.getCheckRecord6()); + putCheckRecord(record, 7, bean.getCheckRecord7()); + putCheckRecord(record, 8, bean.getCheckRecord8()); + putCheckRecord(record, 9, bean.getCheckRecord9()); + putCheckRecord(record, 10, bean.getCheckRecord10()); + putCheckRecord(record, 11, bean.getCheckRecord11()); + putCheckRecord(record, 12, bean.getCheckRecord12()); + putCheckRecord(record, 13, bean.getCheckRecord13()); + putCheckRecord(record, 14, bean.getCheckRecord14()); + record.put("productionDate", bean.getProductionDate() == null + ? null : bean.getProductionDate().format(DATE_FORMAT)); + record.put("checkTime", format(bean.getCheckTime())); + record.put("checkUserId", bean.getCheckUserId()); + record.put("checkUserName", bean.getCheckUserName()); + record.put("applyAgentId", bean.getApplyAgentId()); + record.put("applyAgentName", bean.getApplyAgentName()); + record.put("agentId", bean.getAgentId()); + record.put("agentName", bean.getAgentName()); + record.put("propertyId", bean.getPropertyId()); + record.put("propertyName", bean.getPropertyName()); + record.put("districtId", bean.getDistrictId()); + record.put("districtName", bean.getDistrictName()); + record.put("memo", bean.getMemo()); + record.put("createTime", format(bean.getCreateTime())); + return record; + } + + /** 0/1 检查项:勾选(1)时透出该检查项文案,未勾选/null 时为 null */ + private static void putCheckRecord(Map record, int index, Integer value) { + record.put("checkRecord" + index, value); + record.put("checkRecord" + index + "Text", + value != null && value == 1 ? CHECK_RECORD_LABELS[index - 1] : null); + } + + private static String checkTypeText(Integer value) { + if (value == null) { + return null; + } + return switch (value) { + case 0 -> "小保养"; + case 1 -> "大保养"; + default -> "未知(" + value + ")"; + }; + } + + private static LocalDate parseDate(String value, String name) { + if (!StringUtils.hasText(value)) { + return null; + } + try { + return LocalDate.parse(value.trim(), DATE_FORMAT); + } catch (DateTimeParseException e) { + throw invalid(name + " 格式非法,要求 yyyy-MM-dd: " + value); + } + } + + private static String format(Date value) { + return value == null ? null : new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(value); + } + + private static void validatePositive(Long value, String name) { + if (value != null && value < 1) { + throw invalid(name + " 必须为正整数"); + } + } + + private static void validateEnum(Integer value, String name, int... allowed) { + if (value == null) { + return; + } + for (int candidate : allowed) { + if (value == candidate) { + return; + } + } + String options = Arrays.stream(allowed).mapToObj(String::valueOf) + .collect(Collectors.joining("、")); + throw invalid(name + " 仅支持 " + options); + } + + /** 入参类错误透出具体文案给调用方,便于 LLM 自愈 */ + private static ToolCallException invalid(String message) { + return new ToolCallException(McpErrorCode.INVALID_ARGUMENT, message); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/EquipmentRepairTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/EquipmentRepairTools.java new file mode 100644 index 0000000..292720e --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/EquipmentRepairTools.java @@ -0,0 +1,542 @@ +package me.iiv.agent.mcp.tool; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import me.iiv.agent.entity.m0.Accessory; +import me.iiv.agent.entity.m0.EquipmentRepairHandle; +import me.iiv.agent.entity.m0.EquipmentRepairRecording; +import me.iiv.agent.entity.m0.FaultInfo; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.mapper.m0.AccessoryMapper; +import me.iiv.agent.mapper.m0.EquipmentRepairHandleMapper; +import me.iiv.agent.mapper.m0.EquipmentRepairRecordingMapper; +import me.iiv.agent.mapper.m0.FaultInfoMapper; +import me.iiv.agent.mapper.m0.UserMapper; +import me.iiv.agent.mcp.McpErrorCode; +import me.iiv.agent.mcp.ToolCallException; +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.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.text.SimpleDateFormat; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 故障汇总 / 现场维修记录查询工具,仿老系统 EquipmentRepairController + * /recordingByPage(:117)与 /repairRecordHandleList(:183)。 + * 修正老接口坑(设计文档 doc/battery-inspection-repair-mcp-design.md §4): + * 富化改按页批量(Redis multiGet + 按页 IN,老接口逐条 N+1 且 accessory byId 无 null 判断有 NPE 风险); + * handle 的 chip 参数落不存在的 chip 列 → 参数名 chipCode、落 chip_code 列; + * 不搬 webId/sidx/sord;图片不内嵌(老系统也是独立接口)。 + */ +@Component +public class EquipmentRepairTools implements ToolObject { + + private static final int DEFAULT_ROWS = 20; + private static final int MAX_ROWS = 100; + + /** 老接口 Redis 字典 key(Properyt 为老拼写) */ + private static final String NAME_AGENT_PREFIX = "name:Agent:"; + private static final String NAME_PROPERTY_PREFIX = "name:Properyt:"; + private static final String NAME_DISTRICT_PREFIX = "name:District:"; + + private static final DateTimeFormatter DATE_FORMAT = + DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT); + + /** fault_info.type:0=组件 1=零部件 */ + private static final int FAULT_TYPE_ASSEMBLY = 0; + private static final int FAULT_TYPE_ASSEMBLY_FAULT = 1; + + final EquipmentRepairRecordingMapper recordingMapper; + final EquipmentRepairHandleMapper handleMapper; + final FaultInfoMapper faultInfoMapper; + final AccessoryMapper accessoryMapper; + final UserMapper userMapper; + final PermissionService permissionService; + final StringRedisTemplate redisTemplate; + + public EquipmentRepairTools(EquipmentRepairRecordingMapper recordingMapper, + EquipmentRepairHandleMapper handleMapper, + FaultInfoMapper faultInfoMapper, + AccessoryMapper accessoryMapper, + UserMapper userMapper, + PermissionService permissionService, + StringRedisTemplate redisTemplate) { + this.recordingMapper = recordingMapper; + this.handleMapper = handleMapper; + this.faultInfoMapper = faultInfoMapper; + this.accessoryMapper = accessoryMapper; + this.userMapper = userMapper; + this.permissionService = permissionService; + this.redisTemplate = redisTemplate; + } + + /** + * 故障汇总分页查询。硬过滤 soft_delete=0 + effective_state=1,固定 id DESC, + * selectCount + LIMIT 物理分页。名称/申报人富化按页批量,无 N+1。 + */ + @XqTool(category = "repair", kind = "read") + @Tool(name = "repair.recording.list.query", description = "故障汇总(报修/故障记录)分页查询,对应老系统" + + " /equipmentRepair/recordingByPage。仅返回有效记录(soft_delete=0 且 effective_state=1)," + + "固定按 id 倒序。支持 id/snCode(模糊)/problem(模糊)/propertyId/districtId/state" + + "(0=待解决 1=已解决 2=延迟解决)/userId(申报人)/sourceType(0=运维换机 1=客户换机 2=维护维修)" + + "/deviceType(0=厕纸机 1=擦手纸机)筛选;agentId 为收窄语义" + + "(申请代理或所属代理命中其一);createTimeStart/End 按记录创建时间过滤(纯日期,含边界日期值本身)。" + + "传了筛选条件也仍叠加当前身份的行级数据权限,无权数据返回空。" + + "page 默认 1,rows 默认 20、上限 100(超出截断为 100 并在 notice 提示)。" + + "每行返回记录全字段 + agentName/propertyName/districtName/userName 富化(Redis/按页批量)" + + "+ stateText/sourceTypeText/deviceTypeText 中文文案;图片不在列表内(老系统为独立接口)。") + public Map repairRecordingListQuery( + @ToolParam(required = false, description = "故障记录 id,正整数,精确匹配") Long id, + @ToolParam(required = false, description = "设备 SN,模糊匹配") String snCode, + @ToolParam(required = false, description = "代理 id,正整数;收窄为申请代理或所属代理命中其一," + + "且仍叠加行级数据权限") Long agentId, + @ToolParam(required = false, description = "物业 id,正整数,精确匹配") Long propertyId, + @ToolParam(required = false, description = "站点 id,正整数,精确匹配") Long districtId, + @ToolParam(required = false, description = "维修状态:0=待解决 1=已解决 2=延迟解决") Integer state, + @ToolParam(required = false, description = "申报人 id,正整数,精确匹配") Long userId, + @ToolParam(required = false, description = "来源类型:0=运维换机 1=客户换机 2=维护维修") Integer sourceType, + @ToolParam(required = false, description = "设备类型:0=厕纸机 1=擦手纸机") Integer deviceType, + @ToolParam(required = false, description = "问题描述,模糊匹配") String problem, + @ToolParam(required = false, description = "创建时间起始,yyyy-MM-dd,含边界值") String createTimeStart, + @ToolParam(required = false, description = "创建时间结束,yyyy-MM-dd,含边界值") String createTimeEnd, + @ToolParam(required = false, description = "页码,从 1 开始,默认 1") Integer page, + @ToolParam(required = false, description = "每页条数,默认 20,上限 100(超出截断为 100)") Integer rows) { + validatePositive(id, "id"); + validatePositive(agentId, "agentId"); + validatePositive(propertyId, "propertyId"); + validatePositive(districtId, "districtId"); + validatePositive(userId, "userId"); + validateEnum(state, "state", 0, 1, 2); + validateEnum(sourceType, "sourceType", 0, 1, 2); + validateEnum(deviceType, "deviceType", 0, 1); + LocalDate createStart = parseDate(createTimeStart, "createTimeStart"); + LocalDate createEnd = parseDate(createTimeEnd, "createTimeEnd"); + if (createStart != null && createEnd != null && createStart.isAfter(createEnd)) { + throw invalid("createTimeStart 不能晚于 createTimeEnd"); + } + + User user = AuthContextHolder.get().getUser(); + 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(EquipmentRepairRecording::getSoftDelete, 0) + .eq(EquipmentRepairRecording::getEffectiveState, 1) + .eq(id != null, EquipmentRepairRecording::getId, id) + .eq(propertyId != null, EquipmentRepairRecording::getPropertyId, propertyId) + .eq(districtId != null, EquipmentRepairRecording::getDistrictId, districtId) + .eq(state != null, EquipmentRepairRecording::getState, state) + .eq(userId != null, EquipmentRepairRecording::getUserId, userId) + .eq(sourceType != null, EquipmentRepairRecording::getSourceType, sourceType) + .eq(deviceType != null, EquipmentRepairRecording::getDeviceType, deviceType); + if (StringUtils.hasText(snCode)) { + wrapper.like(EquipmentRepairRecording::getSnCode, snCode.trim()); + } + if (StringUtils.hasText(problem)) { + wrapper.like(EquipmentRepairRecording::getProblem, problem.trim()); + } + // 老接口 agentId 收窄语义(:126-129):申请代理或所属代理命中其一 + if (agentId != null) { + wrapper.and(w -> w.eq(EquipmentRepairRecording::getApplyAgentId, agentId) + .or().eq(EquipmentRepairRecording::getAgentId, agentId)); + } + // 老接口语义:createTime 区间不拼时间(beanToWrapper 原样落值) + if (createStart != null) { + wrapper.ge(EquipmentRepairRecording::getCreateTime, createStart.format(DATE_FORMAT)); + } + if (createEnd != null) { + wrapper.le(EquipmentRepairRecording::getCreateTime, createEnd.format(DATE_FORMAT)); + } + permissionService.applyRepairRecordingRowFilter(wrapper, user); + + Long total = recordingMapper.selectCount(wrapper); + wrapper.orderByDesc(EquipmentRepairRecording::getId) + .last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize); + List list = recordingMapper.selectList(wrapper); + + NameEnrichment enrichment = enrichNames(list, EquipmentRepairRecording::getAgentId, + EquipmentRepairRecording::getPropertyId, EquipmentRepairRecording::getDistrictId, + EquipmentRepairRecording::getUserId); + List> records = new ArrayList<>(); + for (EquipmentRepairRecording row : list) { + records.add(toRecordingRecord(row, enrichment)); + } + return pageResult(total, pageNo, pageSize, truncated, records); + } + + /** + * 现场维修记录分页查询。硬过滤 soft_delete=0 + effective_state=1,固定 id DESC。 + * 组件/零部件名按页批量查 fault_info(type 0/1 区分)、备件与维修人按页 IN 批量,无 N+1。 + */ + @XqTool(category = "repair", kind = "read") + @Tool(name = "repair.handle.list.query", description = "现场维修记录(维修处理明细)分页查询,对应老系统" + + " /equipmentRepair/repairRecordHandleList。仅返回有效记录(soft_delete=0 且 effective_state=1)," + + "固定按 id 倒序。支持 id/equipmentRepairId(故障记录 id)/propertyId/districtId/state" + + "(0=待解决 1=已解决 2=延迟解决)/userId(维修人)/accessoryId 精确匹配," + + "snCode/assembly(组件编号)/assemblyFault(零部件编号)/chipCode(备件芯片编号)模糊匹配;" + + "agentId 为收窄语义(申请代理或所属代理命中其一),且仍叠加行级数据权限。" + + "page 默认 1,rows 默认 20、上限 100(超出截断为 100 并在 notice 提示)。" + + "每行返回记录全字段 + agentName/propertyName/districtName/userName(维修人)富化" + + "+ assemblyName/assemblyFaultName(fault_info 字典,组件/零部件区分)" + + "+ accessoryName(备件名,备件不存在时为 null)+ stateText/deviceTypeText 中文文案。") + public Map repairHandleListQuery( + @ToolParam(required = false, description = "维修处理记录 id,正整数,精确匹配") Long id, + @ToolParam(required = false, description = "故障记录 id(equipment_repair_recording.id)," + + "正整数,精确匹配") Long equipmentRepairId, + @ToolParam(required = false, description = "设备 SN,模糊匹配") String snCode, + @ToolParam(required = false, description = "代理 id,正整数;收窄为申请代理或所属代理命中其一," + + "且仍叠加行级数据权限") Long agentId, + @ToolParam(required = false, description = "物业 id,正整数,精确匹配") Long propertyId, + @ToolParam(required = false, description = "站点 id,正整数,精确匹配") Long districtId, + @ToolParam(required = false, description = "维修状态:0=待解决 1=已解决 2=延迟解决") Integer state, + @ToolParam(required = false, description = "维修人 id,正整数,精确匹配") Long userId, + @ToolParam(required = false, description = "组件编号,模糊匹配") String assembly, + @ToolParam(required = false, description = "零部件编号,模糊匹配") String assemblyFault, + @ToolParam(required = false, description = "备件 id,正整数,精确匹配") Long accessoryId, + @ToolParam(required = false, description = "备件芯片编号,模糊匹配(落 chip_code 列)") String chipCode, + @ToolParam(required = false, description = "页码,从 1 开始,默认 1") Integer page, + @ToolParam(required = false, description = "每页条数,默认 20,上限 100(超出截断为 100)") Integer rows) { + validatePositive(id, "id"); + validatePositive(equipmentRepairId, "equipmentRepairId"); + validatePositive(agentId, "agentId"); + validatePositive(propertyId, "propertyId"); + validatePositive(districtId, "districtId"); + validatePositive(userId, "userId"); + validatePositive(accessoryId, "accessoryId"); + validateEnum(state, "state", 0, 1, 2); + + User user = AuthContextHolder.get().getUser(); + 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(EquipmentRepairHandle::getSoftDelete, 0) + .eq(EquipmentRepairHandle::getEffectiveState, 1) + .eq(id != null, EquipmentRepairHandle::getId, id) + .eq(equipmentRepairId != null, EquipmentRepairHandle::getEquipmentRepairId, equipmentRepairId) + .eq(propertyId != null, EquipmentRepairHandle::getPropertyId, propertyId) + .eq(districtId != null, EquipmentRepairHandle::getDistrictId, districtId) + .eq(state != null, EquipmentRepairHandle::getState, state) + .eq(userId != null, EquipmentRepairHandle::getUserId, userId) + .eq(accessoryId != null, EquipmentRepairHandle::getAccessoryId, accessoryId); + if (StringUtils.hasText(snCode)) { + wrapper.like(EquipmentRepairHandle::getSnCode, snCode.trim()); + } + if (StringUtils.hasText(assembly)) { + wrapper.like(EquipmentRepairHandle::getAssembly, assembly.trim()); + } + if (StringUtils.hasText(assemblyFault)) { + wrapper.like(EquipmentRepairHandle::getAssemblyFault, assemblyFault.trim()); + } + if (StringUtils.hasText(chipCode)) { + wrapper.like(EquipmentRepairHandle::getChipCode, chipCode.trim()); + } + if (agentId != null) { + wrapper.and(w -> w.eq(EquipmentRepairHandle::getApplyAgentId, agentId) + .or().eq(EquipmentRepairHandle::getAgentId, agentId)); + } + permissionService.applyRepairHandleRowFilter(wrapper, user); + + Long total = handleMapper.selectCount(wrapper); + wrapper.orderByDesc(EquipmentRepairHandle::getId) + .last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize); + List list = handleMapper.selectList(wrapper); + + NameEnrichment enrichment = enrichNames(list, EquipmentRepairHandle::getAgentId, + EquipmentRepairHandle::getPropertyId, EquipmentRepairHandle::getDistrictId, + EquipmentRepairHandle::getUserId); + FaultNames faultNames = enrichFaultNames(list); + Map accessoryNames = enrichAccessoryNames(list); + List> records = new ArrayList<>(); + for (EquipmentRepairHandle row : list) { + records.add(toHandleRecord(row, enrichment, faultNames, accessoryNames)); + } + return pageResult(total, pageNo, pageSize, truncated, records); + } + + /** 批量富化上下文:Redis 名称 multiGet + 本页 user 批量 IN(修老接口逐条 N+1)。 */ + private NameEnrichment enrichNames(List list, Function agentIdGetter, + Function propertyIdGetter, + Function districtIdGetter, + Function userIdGetter) { + NameEnrichment enrichment = new NameEnrichment(); + if (list.isEmpty()) { + return enrichment; + } + enrichment.agentNames = redisNames(list, agentIdGetter, NAME_AGENT_PREFIX); + enrichment.propertyNames = redisNames(list, propertyIdGetter, NAME_PROPERTY_PREFIX); + enrichment.districtNames = redisNames(list, districtIdGetter, NAME_DISTRICT_PREFIX); + List userIds = list.stream().map(userIdGetter) + .filter(Objects::nonNull).distinct().collect(Collectors.toList()); + if (!userIds.isEmpty()) { + enrichment.userNames = userMapper.selectBatchIds(userIds).stream() + .collect(Collectors.toMap(User::getId, User::getName, (a, b) -> a)); + } + return enrichment; + } + + private Map redisNames(List list, Function idGetter, String prefix) { + List ids = list.stream().map(idGetter) + .filter(Objects::nonNull).distinct().collect(Collectors.toList()); + if (ids.isEmpty()) { + return Map.of(); + } + List keys = ids.stream().map(id -> prefix + id).collect(Collectors.toList()); + List values = redisTemplate.opsForValue().multiGet(keys); + Map names = new LinkedHashMap<>(); + for (int i = 0; i < ids.size(); i++) { + names.put(ids.get(i), values == null || i >= values.size() ? null : values.get(i)); + } + return names; + } + + /** + * 组件/零部件名:按页收集 assembly/assemblyFault 号,带 type 区分批量查 fault_info + * (老接口全表加载字典且不分 type 的修正)。assembly 落 type=0 组件,assemblyFault 落 type=1 零部件。 + */ + private FaultNames enrichFaultNames(List list) { + FaultNames names = new FaultNames(); + names.assemblyNames = faultNames(list, EquipmentRepairHandle::getAssembly, FAULT_TYPE_ASSEMBLY); + names.assemblyFaultNames = faultNames(list, EquipmentRepairHandle::getAssemblyFault, + FAULT_TYPE_ASSEMBLY_FAULT); + return names; + } + + private Map faultNames(List list, + Function noGetter, int type) { + List nos = list.stream().map(noGetter) + .filter(StringUtils::hasText).map(String::trim).distinct().collect(Collectors.toList()); + if (nos.isEmpty()) { + return Map.of(); + } + return faultInfoMapper.selectList(new LambdaQueryWrapper() + .eq(FaultInfo::getSoftDelete, 0) + .eq(FaultInfo::getType, type) + .in(FaultInfo::getAssemblyNo, nos)) + .stream().collect(Collectors.toMap(FaultInfo::getAssemblyNo, + FaultInfo::getAssemblyName, (a, b) -> a)); + } + + /** 备件名按页 IN 批量查(修老接口 byId 无 null 判断的 NPE 风险);查不到的 id 名称为 null。 */ + private Map enrichAccessoryNames(List list) { + List ids = list.stream().map(EquipmentRepairHandle::getAccessoryId) + .filter(Objects::nonNull).filter(id -> id > 0).distinct().collect(Collectors.toList()); + if (ids.isEmpty()) { + return Map.of(); + } + return accessoryMapper.selectBatchIds(ids).stream() + .filter(Objects::nonNull) + .collect(Collectors.toMap(Accessory::getId, Accessory::getName, (a, b) -> a)); + } + + private Map toRecordingRecord(EquipmentRepairRecording row, NameEnrichment enrichment) { + Map record = new LinkedHashMap<>(); + record.put("id", row.getId()); + record.put("repairNo", row.getRepairNo()); + record.put("snCode", row.getSnCode()); + record.put("deviceType", row.getDeviceType()); + record.put("deviceTypeText", deviceTypeText(row.getDeviceType())); + record.put("state", row.getState()); + record.put("stateText", stateText(row.getState())); + record.put("sourceType", row.getSourceType()); + record.put("sourceTypeText", sourceTypeText(row.getSourceType())); + record.put("problem", row.getProblem()); + record.put("paperCount", row.getPaperCount()); + record.put("faultCount", row.getFaultCount()); + record.put("recordTime", format(row.getRecordTime())); + record.put("userId", row.getUserId()); + record.put("userName", mapGet(enrichment.userNames, row.getUserId())); + record.put("buildingId", row.getBuildingId()); + record.put("floor", row.getFloor()); + record.put("toiletId", row.getToiletId()); + record.put("positionId", row.getPositionId()); + record.put("installAddress", row.getInstallAddress()); + record.put("agentId", row.getAgentId()); + record.put("agentName", mapGet(enrichment.agentNames, row.getAgentId())); + record.put("applyAgentId", row.getApplyAgentId()); + record.put("applyAgentName", row.getApplyAgentName()); + record.put("propertyId", row.getPropertyId()); + record.put("propertyName", mapGet(enrichment.propertyNames, row.getPropertyId())); + record.put("districtId", row.getDistrictId()); + record.put("districtName", mapGet(enrichment.districtNames, row.getDistrictId())); + record.put("maintenanceAgentId", row.getMaintenanceAgentId()); + record.put("maintenanceAgentName", row.getMaintenanceAgentName()); + record.put("maintenancePropertyId", row.getMaintenancePropertyId()); + record.put("maintenancePropertyName", row.getMaintenancePropertyName()); + record.put("handUserId", row.getHandUserId()); + record.put("handUserName", row.getHandUserName()); + record.put("workNo", row.getWorkNo()); + record.put("createTime", format(row.getCreateTime())); + return record; + } + + private Map toHandleRecord(EquipmentRepairHandle row, NameEnrichment enrichment, + FaultNames faultNames, Map accessoryNames) { + Map record = new LinkedHashMap<>(); + record.put("id", row.getId()); + record.put("recordrNo", row.getRecordrNo()); + record.put("equipmentRepairId", row.getEquipmentRepairId()); + record.put("snCode", row.getSnCode()); + record.put("deviceType", row.getDeviceType()); + record.put("deviceTypeText", deviceTypeText(row.getDeviceType())); + record.put("state", row.getState()); + record.put("stateText", stateText(row.getState())); + record.put("assembly", row.getAssembly()); + record.put("assemblyName", faultNames.assemblyNames.get(row.getAssembly())); + record.put("assemblyFault", row.getAssemblyFault()); + record.put("assemblyFaultName", faultNames.assemblyFaultNames.get(row.getAssemblyFault())); + record.put("accessoryId", row.getAccessoryId()); + record.put("accessoryName", mapGet(accessoryNames, row.getAccessoryId())); + record.put("chipCode", row.getChipCode()); + record.put("oldChipCode", row.getOldChipCode()); + record.put("addTime", format(row.getAddTime())); + record.put("userId", row.getUserId()); + record.put("userName", mapGet(enrichment.userNames, row.getUserId())); + record.put("memo", row.getMemo()); + record.put("buildingId", row.getBuildingId()); + record.put("floor", row.getFloor()); + record.put("toiletId", row.getToiletId()); + record.put("positionId", row.getPositionId()); + record.put("installAddress", row.getInstallAddress()); + record.put("agentId", row.getAgentId()); + record.put("agentName", mapGet(enrichment.agentNames, row.getAgentId())); + record.put("applyAgentId", row.getApplyAgentId()); + record.put("applyAgentName", row.getApplyAgentName()); + record.put("propertyId", row.getPropertyId()); + record.put("propertyName", mapGet(enrichment.propertyNames, row.getPropertyId())); + record.put("districtId", row.getDistrictId()); + record.put("districtName", mapGet(enrichment.districtNames, row.getDistrictId())); + record.put("maintenanceAgentId", row.getMaintenanceAgentId()); + record.put("maintenanceAgentName", row.getMaintenanceAgentName()); + record.put("maintenancePropertyId", row.getMaintenancePropertyId()); + record.put("maintenancePropertyName", row.getMaintenancePropertyName()); + record.put("workNo", row.getWorkNo()); + record.put("createTime", format(row.getCreateTime())); + return record; + } + + private static Map pageResult(Long total, int pageNo, int pageSize, + boolean truncated, List> records) { + 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 static final class NameEnrichment { + Map agentNames = Map.of(); + Map propertyNames = Map.of(); + Map districtNames = Map.of(); + Map userNames = Map.of(); + } + + private static final class FaultNames { + Map assemblyNames = Map.of(); + Map assemblyFaultNames = Map.of(); + } + + private static V mapGet(Map map, K key) { + return key == null ? null : map.get(key); + } + + private static String format(Date value) { + return value == null ? null : new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(value); + } + + private static String stateText(Integer value) { + if (value == null) { + return null; + } + return switch (value) { + case 0 -> "待解决"; + case 1 -> "已解决"; + case 2 -> "延迟解决"; + default -> "未知(" + value + ")"; + }; + } + + private static String sourceTypeText(Integer value) { + if (value == null) { + return null; + } + return switch (value) { + case 0 -> "运维换机"; + case 1 -> "客户换机"; + case 2 -> "维护维修"; + default -> "未知(" + value + ")"; + }; + } + + private static String deviceTypeText(Integer value) { + if (value == null) { + return null; + } + return switch (value) { + case 0 -> "厕纸机"; + case 1 -> "擦手纸机"; + default -> "未知(" + value + ")"; + }; + } + + private static LocalDate parseDate(String value, String name) { + if (!StringUtils.hasText(value)) { + return null; + } + try { + return LocalDate.parse(value.trim(), DATE_FORMAT); + } catch (DateTimeParseException e) { + throw invalid(name + " 格式非法,要求 yyyy-MM-dd: " + value); + } + } + + private static void validatePositive(Long value, String name) { + if (value != null && value < 1) { + throw invalid(name + " 必须为正整数"); + } + } + + private static void validateEnum(Integer value, String name, int... allowed) { + if (value == null) { + return; + } + for (int candidate : allowed) { + if (value == candidate) { + return; + } + } + String options = Arrays.stream(allowed).mapToObj(String::valueOf) + .collect(Collectors.joining("、")); + throw invalid(name + " 仅支持 " + options); + } + + /** 入参类错误透出具体文案给调用方,便于 LLM 自愈 */ + private static ToolCallException invalid(String message) { + return new ToolCallException(McpErrorCode.INVALID_ARGUMENT, message); + } +} 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 2388eee..f49e4da 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 @@ -1,12 +1,15 @@ package me.iiv.agent.permission; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.support.SFunction; import me.iiv.agent.entity.m0.Agent; import me.iiv.agent.entity.m0.AgentManageDistrictAuth; import me.iiv.agent.entity.m0.Authority; import me.iiv.agent.entity.m0.BatteryFaultLog; import me.iiv.agent.entity.m0.DeviceLifeCycle; import me.iiv.agent.entity.m0.DistrictDistrictStrategy; +import me.iiv.agent.entity.m0.EquipmentRepairHandle; +import me.iiv.agent.entity.m0.EquipmentRepairRecording; import me.iiv.agent.entity.m0.HardwareCleanLog; import me.iiv.agent.entity.m0.HardwareView; import me.iiv.agent.entity.m0.HardSockLife; @@ -17,6 +20,7 @@ import me.iiv.agent.entity.m1.MallGoodsOrder; import me.iiv.agent.entity.m1.SalerRepayment; import me.iiv.agent.entity.m1.SalerRepaymentCustomer; import me.iiv.agent.entity.m2.ContractNew; +import me.iiv.agent.entity.m2.BatteryInspection; import me.iiv.agent.entity.m2.DeliveryOrder; import me.iiv.agent.entity.m2.InstallOrder; import me.iiv.agent.entity.m2.PickupOrder; @@ -1097,4 +1101,59 @@ public class PermissionService { } wrapper.in(DeviceLifeCycle::getAgentId, scope.ids); } + + /** + * 电池检查(m2 battery_inspection)行权限(REQ-20261106-0020,方案 B 拍板复用 resolveRowScope 含直属下级 + * 口径,对齐老 /batteryInspection/pageList 的 getByIsAgentNew propertyFlag=false 变体): + * 总部/服务号全量;销售非负责人按 property_id 集合;其余代理按 apply_agent_id IN ids + * (不加 managedPropertyIds OR——老 propertyFlag=false 分支本无物业 OR 段)。 + */ + public void applyBatteryInspectionRowFilter(LambdaQueryWrapper wrapper, User user) { + RowScope scope = resolveRowScope(user); + if (scope.headquarters) { + return; + } + if (scope.salesPropertyIds != null) { + wrapper.in(BatteryInspection::getPropertyId, scope.salesPropertyIds); + return; + } + wrapper.in(BatteryInspection::getApplyAgentId, scope.ids); + } + + /** 故障汇总(m0 equipment_repair_recording)行权限,见 applyEquipmentRepairScope。 */ + public void applyRepairRecordingRowFilter(LambdaQueryWrapper wrapper, User user) { + applyEquipmentRepairScope(wrapper, user, EquipmentRepairRecording::getApplyAgentId, + EquipmentRepairRecording::getAgentId, EquipmentRepairRecording::getPropertyId); + } + + /** 现场维修记录(m0 equipment_repair_handle)行权限,与故障汇总同口径。 */ + public void applyRepairHandleRowFilter(LambdaQueryWrapper wrapper, User user) { + applyEquipmentRepairScope(wrapper, user, EquipmentRepairHandle::getApplyAgentId, + EquipmentRepairHandle::getAgentId, EquipmentRepairHandle::getPropertyId); + } + + /** + * 维修域共用行权限(REQ-20261106-0020,对齐老 getByIsAgentNew propertyFlag=true 变体的有效口径): + * 总部/服务号全量;销售非负责人按 property_id 集合;其余代理 + * AND (apply_agent_id IN ids OR agent_id IN ids OR property_id IN 代管物业集合[非空时])。 + */ + private void applyEquipmentRepairScope(LambdaQueryWrapper wrapper, User user, + SFunction applyAgentIdColumn, + SFunction agentIdColumn, + SFunction propertyIdColumn) { + RowScope scope = resolveRowScope(user); + if (scope.headquarters) { + return; + } + if (scope.salesPropertyIds != null) { + wrapper.in(propertyIdColumn, scope.salesPropertyIds); + return; + } + wrapper.and(w -> { + w.in(applyAgentIdColumn, scope.ids).or().in(agentIdColumn, scope.ids); + if (!scope.managedPropertyIds.isEmpty()) { + w.or().in(propertyIdColumn, scope.managedPropertyIds); + } + }); + } } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/BatteryInspectionToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/BatteryInspectionToolsTest.java new file mode 100644 index 0000000..a7b229e --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/BatteryInspectionToolsTest.java @@ -0,0 +1,206 @@ +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.BatteryInspection; +import me.iiv.agent.mapper.m2.BatteryInspectionMapper; +import me.iiv.agent.mcp.McpErrorCode; +import me.iiv.agent.mcp.ToolCallException; +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.time.Instant; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +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.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BatteryInspectionToolsTest { + + @Mock + BatteryInspectionMapper mapper; + @Mock + PermissionService permissionService; + + BatteryInspectionTools tools; + + @BeforeEach + void setUp() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), + BatteryInspection.class); + tools = new BatteryInspectionTools(mapper, permissionService); + User user = new User(); + user.setId(7L); + AuthContextHolder.set(new AuthContext(null, user)); + } + + @AfterEach + void tearDown() { + AuthContextHolder.clear(); + } + + @Test + @SuppressWarnings("unchecked") + void listBuildsFiltersAppliesAclAndTruncatesRows() { + BatteryInspection bean = new BatteryInspection(); + bean.setId(9L); + bean.setSnCode("BAT001"); + bean.setCheckType(1); + bean.setCheckRecord1(1); + bean.setCheckRecord2(0); + bean.setCheckRecord3("120mΩ"); + bean.setCheckRecord4("12.4V"); + bean.setCheckRecord14(1); + bean.setProductionDate(LocalDate.of(2026, 1, 15)); + bean.setCheckTime(Date.from(Instant.parse("2026-10-20T02:00:00Z"))); + bean.setCheckUserId(8L); + bean.setCheckUserName("张三"); + bean.setApplyAgentId(12L); + bean.setApplyAgentName("申请代理"); + bean.setAgentId(12L); + bean.setAgentName("测试代理"); + bean.setPropertyId(11L); + bean.setPropertyName("测试物业"); + bean.setDistrictId(5L); + bean.setDistrictName("测试站点"); + bean.setMemo("例行检查"); + bean.setCreateTime(Date.from(Instant.parse("2026-10-20T03:00:00Z"))); + when(mapper.selectCount(any())).thenReturn(1L); + when(mapper.selectList(any())).thenReturn(List.of(bean)); + + Map result = tools.batteryInspectionListQuery( + "BAT", 12L, 11L, 5L, 1, 8L, "张", + "2026-01-01", "2026-12-31", "2026-10-01", "2026-10-31", 1, 1, 500); + + assertEquals(1, result.get("page")); + assertEquals(100, result.get("rows")); + assertEquals("rows 超过上限 100,已截断为 100", result.get("notice")); + + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(mapper).selectList(captor.capture()); + String sql = captor.getValue().getSqlSegment(); + assertTrue(sql.contains("soft_delete =")); + assertTrue(sql.contains("agent_id =")); + assertTrue(sql.contains("property_id =")); + assertTrue(sql.contains("district_id =")); + assertTrue(sql.contains("check_type =")); + assertTrue(sql.contains("check_user_id =")); + assertTrue(sql.contains("check_user_name LIKE")); + assertTrue(sql.contains("check_record14 =")); + assertTrue(sql.contains("sn_code LIKE")); + assertTrue(sql.contains("production_date >=")); + assertTrue(sql.contains("production_date <=")); + assertTrue(sql.contains("check_time >=")); + assertTrue(sql.contains("check_time <=")); + assertTrue(sql.contains("ORDER BY id DESC")); + // 检查时间区间按老接口拼整天(BatteryInspectionController.java:67-74) + assertTrue(captor.getValue().getParamNameValuePairs().values() + .contains("2026-10-01 00:00:00")); + assertTrue(captor.getValue().getParamNameValuePairs().values() + .contains("2026-10-31 23:59:59")); + // 生产日期不拼时间 + assertTrue(captor.getValue().getParamNameValuePairs().values() + .contains(LocalDate.of(2026, 1, 1))); + verify(permissionService).applyBatteryInspectionRowFilter(any(), any()); + + Map record = ((List>) result.get("records")).get(0); + assertEquals(9L, record.get("id")); + assertEquals("大保养", record.get("checkTypeText")); + assertEquals("无法充电", record.get("checkRecord1Text")); + assertEquals(0, record.get("checkRecord2")); + assertNull(record.get("checkRecord2Text")); + assertEquals("120mΩ", record.get("checkRecord3")); + assertEquals("12.4V", record.get("checkRecord4")); + assertEquals("是否返回", record.get("checkRecord14Text")); + assertEquals("2026-01-15", record.get("productionDate")); + assertEquals("测试代理", record.get("agentName")); + assertEquals("申请代理", record.get("applyAgentName")); + assertEquals("2026-10-20 10:00:00", record.get("checkTime")); + } + + @Test + void rejectsInvalidParamsBeforeQuerying() { + // 非正 id + assertThrows(ToolCallException.class, () -> tools.batteryInspectionListQuery( + null, 0L, null, null, null, null, null, null, null, null, null, null, 1, 20)); + // 枚举越界 + assertThrows(ToolCallException.class, () -> tools.batteryInspectionListQuery( + null, null, null, null, 2, null, null, null, null, null, null, null, 1, 20)); + assertThrows(ToolCallException.class, () -> tools.batteryInspectionListQuery( + null, null, null, null, null, null, null, null, null, null, null, 2, 1, 20)); + // 日期格式非法 + assertThrows(ToolCallException.class, () -> tools.batteryInspectionListQuery( + null, null, null, null, null, null, null, "2026/01/01", null, null, null, null, 1, 20)); + assertThrows(ToolCallException.class, () -> tools.batteryInspectionListQuery( + null, null, null, null, null, null, null, null, null, "2026-10-01 00:00:00", null, null, 1, 20)); + // 区间倒置 + assertThrows(ToolCallException.class, () -> tools.batteryInspectionListQuery( + null, null, null, null, null, null, null, "2026-12-31", "2026-01-01", null, null, null, 1, 20)); + assertThrows(ToolCallException.class, () -> tools.batteryInspectionListQuery( + null, null, null, null, null, null, null, null, null, "2026-10-31", "2026-10-01", null, 1, 20)); + + ToolCallException exception = assertThrows(ToolCallException.class, + () -> tools.batteryInspectionListQuery( + null, 0L, null, null, null, null, null, null, null, null, null, null, 1, 20)); + assertEquals(McpErrorCode.INVALID_ARGUMENT, exception.getCode()); + assertTrue(exception.getRawMessage().contains("agentId")); + verifyNoInteractions(mapper, permissionService); + } + + @Test + @SuppressWarnings("unchecked") + void emptyPageReturnsEmptyRecords() { + when(mapper.selectCount(any())).thenReturn(0L); + when(mapper.selectList(any())).thenReturn(List.of()); + + Map result = tools.batteryInspectionListQuery( + null, null, null, null, null, null, null, null, null, null, null, null, null, null); + + assertEquals(0L, result.get("total")); + assertEquals(1, result.get("page")); + assertEquals(20, result.get("rows")); + assertEquals(List.of(), result.get("records")); + assertNull(result.get("notice")); + verify(permissionService).applyBatteryInspectionRowFilter(any(), any()); + } + + @Test + void springAiPublishesReviewedSchema() { + var callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider.builder() + .toolObjects(tools).build().getToolCallbacks(); + Map schemas = Arrays.stream(callbacks).collect(Collectors.toMap( + callback -> callback.getToolDefinition().name(), + callback -> callback.getToolDefinition().inputSchema())); + + assertEquals(1, schemas.size()); + assertTrue(schemas.containsKey("battery.inspection.list.query")); + String schema = schemas.get("battery.inspection.list.query"); + assertTrue(schema.contains("productionDateStart")); + assertTrue(schema.contains("checkTimeStart")); + assertTrue(schema.contains("checkRecord14")); + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/EquipmentRepairToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/EquipmentRepairToolsTest.java new file mode 100644 index 0000000..f36a969 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/EquipmentRepairToolsTest.java @@ -0,0 +1,402 @@ +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.Accessory; +import me.iiv.agent.entity.m0.EquipmentRepairHandle; +import me.iiv.agent.entity.m0.EquipmentRepairRecording; +import me.iiv.agent.entity.m0.FaultInfo; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.mapper.m0.AccessoryMapper; +import me.iiv.agent.mapper.m0.EquipmentRepairHandleMapper; +import me.iiv.agent.mapper.m0.EquipmentRepairRecordingMapper; +import me.iiv.agent.mapper.m0.FaultInfoMapper; +import me.iiv.agent.mapper.m0.UserMapper; +import me.iiv.agent.mcp.McpErrorCode; +import me.iiv.agent.mcp.ToolCallException; +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 org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +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.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class EquipmentRepairToolsTest { + + @Mock + EquipmentRepairRecordingMapper recordingMapper; + @Mock + EquipmentRepairHandleMapper handleMapper; + @Mock + FaultInfoMapper faultInfoMapper; + @Mock + AccessoryMapper accessoryMapper; + @Mock + UserMapper userMapper; + @Mock + PermissionService permissionService; + @Mock + StringRedisTemplate redisTemplate; + + EquipmentRepairTools tools; + + @BeforeEach + void setUp() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, EquipmentRepairRecording.class); + TableInfoHelper.initTableInfo(assistant, EquipmentRepairHandle.class); + TableInfoHelper.initTableInfo(assistant, FaultInfo.class); + tools = new EquipmentRepairTools(recordingMapper, handleMapper, faultInfoMapper, + accessoryMapper, userMapper, permissionService, redisTemplate); + User user = new User(); + user.setId(7L); + AuthContextHolder.set(new AuthContext(null, user)); + } + + @AfterEach + void tearDown() { + AuthContextHolder.clear(); + } + + @SuppressWarnings("unchecked") + private void stubRedisNames() { + ValueOperations valueOperations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.multiGet(anyList())).thenAnswer(invocation -> { + List keys = invocation.getArgument(0); + return keys.stream().map(key -> switch (key) { + case "name:Agent:12" -> "测试代理"; + case "name:Properyt:11" -> "测试物业"; + case "name:District:5" -> "测试站点"; + default -> null; + }).collect(Collectors.toList()); + }); + } + + @Test + @SuppressWarnings("unchecked") + void recordingBuildsFiltersAppliesAclAndEnrichesBatch() { + EquipmentRepairRecording row = new EquipmentRepairRecording(); + row.setId(9L); + row.setRepairNo("WX001"); + row.setSnCode("D2AB001"); + row.setDeviceType(1); + row.setState(1); + row.setSourceType(0); + row.setProblem("不出纸"); + row.setPaperCount(128L); + row.setFaultCount(2); + row.setRecordTime(Date.from(Instant.parse("2026-10-20T02:00:00Z"))); + row.setUserId(8L); + row.setBuildingId(30L); + row.setFloor("3F"); + row.setToiletId(40L); + row.setPositionId(50L); + row.setInstallAddress("3F 男卫"); + row.setAgentId(12L); + row.setApplyAgentId(12L); + row.setApplyAgentName("申请代理"); + row.setPropertyId(11L); + row.setDistrictId(5L); + row.setMaintenanceAgentId(21L); + row.setMaintenanceAgentName("运维代理"); + row.setMaintenancePropertyId(22L); + row.setMaintenancePropertyName("运维物业"); + row.setHandUserId(23L); + row.setHandUserName("维修员"); + row.setWorkNo("WO001"); + row.setCreateTime(Date.from(Instant.parse("2026-10-19T02:00:00Z"))); + when(recordingMapper.selectCount(any())).thenReturn(1L); + when(recordingMapper.selectList(any())).thenReturn(List.of(row)); + + stubRedisNames(); + User reporter = new User(); + reporter.setId(8L); + reporter.setName("申报员"); + when(userMapper.selectBatchIds(any())).thenReturn(List.of(reporter)); + + Map result = tools.repairRecordingListQuery( + 9L, "D2AB", 12L, 11L, 5L, 1, 8L, 0, 1, "不出纸", + "2026-10-01", "2026-10-31", 1, 500); + + assertEquals(1, result.get("page")); + assertEquals(100, result.get("rows")); + assertEquals("rows 超过上限 100,已截断为 100", result.get("notice")); + + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(recordingMapper).selectList(captor.capture()); + String sql = captor.getValue().getSqlSegment(); + assertTrue(sql.contains("soft_delete =")); + assertTrue(sql.contains("effective_state =")); + assertTrue(sql.contains("id =")); + assertTrue(sql.contains("sn_code LIKE")); + assertTrue(sql.contains("problem LIKE")); + assertTrue(sql.contains("property_id =")); + assertTrue(sql.contains("district_id =")); + assertTrue(sql.contains("state =")); + assertTrue(sql.contains("user_id =")); + assertTrue(sql.contains("source_type =")); + assertTrue(sql.contains("device_type =")); + // agentId 收窄语义:apply_agent_id OR agent_id(老 :126-129) + assertTrue(sql.contains("apply_agent_id =")); + assertTrue(sql.contains("agent_id =")); + assertTrue(sql.contains("OR")); + // createTime 区间不拼时间(老 beanToWrapper 原样落值) + assertTrue(sql.contains("create_time >=")); + assertTrue(sql.contains("create_time <=")); + assertTrue(captor.getValue().getParamNameValuePairs().values().contains("2026-10-01")); + assertTrue(captor.getValue().getParamNameValuePairs().values().contains("2026-10-31")); + assertTrue(sql.contains("ORDER BY id DESC")); + verify(permissionService).applyRepairRecordingRowFilter(any(), any()); + + Map record = ((List>) result.get("records")).get(0); + assertEquals("WX001", record.get("repairNo")); + assertEquals("擦手纸机", record.get("deviceTypeText")); + assertEquals("已解决", record.get("stateText")); + assertEquals("运维换机", record.get("sourceTypeText")); + assertEquals(128L, record.get("paperCount")); + assertEquals("测试代理", record.get("agentName")); + assertEquals("测试物业", record.get("propertyName")); + assertEquals("测试站点", record.get("districtName")); + assertEquals("申报员", record.get("userName")); + assertEquals("维修员", record.get("handUserName")); + assertEquals("2026-10-20 10:00:00", record.get("recordTime")); + } + + @Test + void recordingRejectsInvalidParamsBeforeQuerying() { + // 非正 id + assertThrows(ToolCallException.class, () -> tools.repairRecordingListQuery( + 0L, null, null, null, null, null, null, null, null, null, null, null, 1, 20)); + // 枚举越界 + assertThrows(ToolCallException.class, () -> tools.repairRecordingListQuery( + null, null, null, null, null, 3, null, null, null, null, null, null, 1, 20)); + assertThrows(ToolCallException.class, () -> tools.repairRecordingListQuery( + null, null, null, null, null, null, null, 3, null, null, null, null, 1, 20)); + assertThrows(ToolCallException.class, () -> tools.repairRecordingListQuery( + null, null, null, null, null, null, null, null, 2, null, null, null, 1, 20)); + // 日期格式非法 / 区间倒置 + assertThrows(ToolCallException.class, () -> tools.repairRecordingListQuery( + null, null, null, null, null, null, null, null, null, null, + "2026-10-01 00:00:00", null, 1, 20)); + assertThrows(ToolCallException.class, () -> tools.repairRecordingListQuery( + null, null, null, null, null, null, null, null, null, null, + "2026-10-31", "2026-10-01", 1, 20)); + + ToolCallException exception = assertThrows(ToolCallException.class, + () -> tools.repairRecordingListQuery( + 0L, null, null, null, null, null, null, null, null, null, null, null, 1, 20)); + assertEquals(McpErrorCode.INVALID_ARGUMENT, exception.getCode()); + assertTrue(exception.getRawMessage().contains("id")); + verifyNoInteractions(recordingMapper, userMapper, redisTemplate); + } + + @Test + @SuppressWarnings("unchecked") + void recordingEmptyPageSkipsEnrichment() { + when(recordingMapper.selectCount(any())).thenReturn(0L); + when(recordingMapper.selectList(any())).thenReturn(List.of()); + + Map result = tools.repairRecordingListQuery( + null, null, null, null, null, null, null, null, null, null, null, null, null, null); + + assertEquals(0L, result.get("total")); + assertEquals(1, result.get("page")); + assertEquals(20, result.get("rows")); + assertEquals(List.of(), result.get("records")); + assertNull(result.get("notice")); + verify(permissionService).applyRepairRecordingRowFilter(any(), any()); + verifyNoInteractions(userMapper, redisTemplate); + } + + @Test + @SuppressWarnings("unchecked") + void handleBuildsFiltersAndEnrichesFaultAccessoryBatch() { + EquipmentRepairHandle row = new EquipmentRepairHandle(); + row.setId(9L); + row.setRecordrNo("CL001"); + row.setEquipmentRepairId(3L); + row.setSnCode("D2AB001"); + row.setDeviceType(0); + row.setState(2); + row.setAssembly("A01"); + row.setAssemblyFault("F02"); + row.setAccessoryId(5L); + row.setChipCode("CHIP001"); + row.setOldChipCode("CHIP000"); + row.setAddTime(Date.from(Instant.parse("2026-10-21T02:00:00Z"))); + row.setUserId(8L); + row.setMemo("现场更换"); + row.setAgentId(12L); + row.setApplyAgentId(12L); + row.setApplyAgentName("申请代理"); + row.setPropertyId(11L); + row.setDistrictId(5L); + row.setMaintenanceAgentId(21L); + row.setMaintenanceAgentName("运维代理"); + row.setWorkNo("WO001"); + // 第二行:无组件/备件,验证 null 路径(修老接口 accessory byId NPE 风险) + EquipmentRepairHandle sparse = new EquipmentRepairHandle(); + sparse.setId(10L); + sparse.setAccessoryId(99L); + when(handleMapper.selectCount(any())).thenReturn(2L); + when(handleMapper.selectList(any())).thenReturn(List.of(row, sparse)); + + stubRedisNames(); + User repairman = new User(); + repairman.setId(8L); + repairman.setName("维修员"); + when(userMapper.selectBatchIds(any())).thenReturn(List.of(repairman)); + when(faultInfoMapper.selectList(any())).thenAnswer(invocation -> { + LambdaQueryWrapper wrapper = invocation.getArgument(0); + // 参数映射在 getSqlSegment() 生成时才填充;soft_delete=0 恒在,按 type=1 区分零部件 + wrapper.getSqlSegment(); + FaultInfo info = new FaultInfo(); + if (wrapper.getParamNameValuePairs().values().contains(1)) { + info.setAssemblyNo("F02"); + info.setAssemblyName("齿轮"); + } else { + info.setAssemblyNo("A01"); + info.setAssemblyName("纸轴组件"); + } + return List.of(info); + }); + // accessoryId=99 查不到(老接口此处 NPE,MCP 透出 null) + Accessory accessory = new Accessory(); + accessory.setId(5L); + accessory.setName("纸轴"); + when(accessoryMapper.selectBatchIds(any())).thenReturn(List.of(accessory)); + + Map result = tools.repairHandleListQuery( + 9L, 3L, "D2AB", 12L, 11L, 5L, 2, 8L, "A0", "F0", 5L, "CHIP", 1, 20); + + assertEquals(2L, result.get("total")); + + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(handleMapper).selectList(captor.capture()); + String sql = captor.getValue().getSqlSegment(); + assertTrue(sql.contains("soft_delete =")); + assertTrue(sql.contains("effective_state =")); + assertTrue(sql.contains("equipment_repair_id =")); + assertTrue(sql.contains("assembly LIKE")); + assertTrue(sql.contains("assembly_fault LIKE")); + assertTrue(sql.contains("accessory_id =")); + // chip 参数修老 bug:落 chip_code 列(老 HandleRecordingPageInput.chip 拼不存在的 chip 列) + assertTrue(sql.contains("chip_code LIKE")); + assertTrue(sql.contains("apply_agent_id =")); + assertTrue(sql.contains("agent_id =")); + assertTrue(sql.contains("ORDER BY id DESC")); + verify(permissionService).applyRepairHandleRowFilter(any(), any()); + // fault_info 按页批量查两次:type=0 组件 / type=1 零部件 + ArgumentCaptor> faultCaptor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(faultInfoMapper, org.mockito.Mockito.times(2)).selectList(faultCaptor.capture()); + for (LambdaQueryWrapper wrapper : faultCaptor.getAllValues()) { + String faultSql = wrapper.getSqlSegment(); + assertTrue(faultSql.contains("soft_delete =")); + assertTrue(faultSql.contains("type =")); + assertTrue(faultSql.contains("assembly_no IN")); + } + + Map record = ((List>) result.get("records")).get(0); + assertEquals("CL001", record.get("recordrNo")); + assertEquals("厕纸机", record.get("deviceTypeText")); + assertEquals("延迟解决", record.get("stateText")); + assertEquals("纸轴组件", record.get("assemblyName")); + assertEquals("齿轮", record.get("assemblyFaultName")); + assertEquals("纸轴", record.get("accessoryName")); + assertEquals("CHIP001", record.get("chipCode")); + assertEquals("维修员", record.get("userName")); + assertEquals("测试代理", record.get("agentName")); + assertEquals("2026-10-21 10:00:00", record.get("addTime")); + + Map sparseRecord = ((List>) result.get("records")).get(1); + assertNull(sparseRecord.get("assemblyName")); + assertNull(sparseRecord.get("assemblyFaultName")); + assertNull(sparseRecord.get("accessoryName")); + assertNull(sparseRecord.get("userName")); + assertNull(sparseRecord.get("agentName")); + } + + @Test + void handleRejectsInvalidParamsBeforeQuerying() { + // 非正 id + assertThrows(ToolCallException.class, () -> tools.repairHandleListQuery( + 0L, null, null, null, null, null, null, null, null, null, null, null, 1, 20)); + assertThrows(ToolCallException.class, () -> tools.repairHandleListQuery( + null, 0L, null, null, null, null, null, null, null, null, null, null, 1, 20)); + assertThrows(ToolCallException.class, () -> tools.repairHandleListQuery( + null, null, null, null, null, null, null, null, null, null, 0L, null, 1, 20)); + // 枚举越界 + assertThrows(ToolCallException.class, () -> tools.repairHandleListQuery( + null, null, null, null, null, null, 3, null, null, null, null, null, 1, 20)); + + ToolCallException exception = assertThrows(ToolCallException.class, + () -> tools.repairHandleListQuery( + null, null, null, null, null, null, 3, null, null, null, null, null, 1, 20)); + assertEquals(McpErrorCode.INVALID_ARGUMENT, exception.getCode()); + assertTrue(exception.getRawMessage().contains("state")); + verifyNoInteractions(handleMapper, faultInfoMapper, accessoryMapper, userMapper, redisTemplate); + } + + @Test + @SuppressWarnings("unchecked") + void handleEmptyPageSkipsEnrichment() { + when(handleMapper.selectCount(any())).thenReturn(0L); + when(handleMapper.selectList(any())).thenReturn(List.of()); + + Map result = tools.repairHandleListQuery( + null, null, null, null, null, null, null, null, null, null, null, null, null, null); + + assertEquals(0L, result.get("total")); + assertEquals(List.of(), result.get("records")); + assertNull(result.get("notice")); + verify(permissionService).applyRepairHandleRowFilter(any(), any()); + verifyNoInteractions(faultInfoMapper, accessoryMapper, userMapper, redisTemplate); + } + + @Test + void springAiPublishesReviewedSchema() { + var callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider.builder() + .toolObjects(tools).build().getToolCallbacks(); + Map schemas = Arrays.stream(callbacks).collect(Collectors.toMap( + callback -> callback.getToolDefinition().name(), + callback -> callback.getToolDefinition().inputSchema())); + + assertEquals(2, schemas.size()); + assertTrue(schemas.containsKey("repair.recording.list.query")); + assertTrue(schemas.containsKey("repair.handle.list.query")); + assertTrue(schemas.get("repair.recording.list.query").contains("sourceType")); + assertTrue(schemas.get("repair.handle.list.query").contains("chipCode")); + assertTrue(schemas.get("repair.handle.list.query").contains("equipmentRepairId")); + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceBatteryRepairRowFilterTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceBatteryRepairRowFilterTest.java new file mode 100644 index 0000000..1f9ad0b --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceBatteryRepairRowFilterTest.java @@ -0,0 +1,242 @@ +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.Agent; +import me.iiv.agent.entity.m0.AgentManageDistrictAuth; +import me.iiv.agent.entity.m0.Authority; +import me.iiv.agent.entity.m0.EquipmentRepairHandle; +import me.iiv.agent.entity.m0.EquipmentRepairRecording; +import me.iiv.agent.entity.m0.Properyt; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.m2.BatteryInspection; +import me.iiv.agent.entity.m2.WarehouseAgentAuth; +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.HashOperations; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +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; + +/** + * PermissionService 电池检查/维修域行权限直接单测(REQ-20261106-0020 方案 B): + * battery 落 apply_agent_id 单字段;repair 两表共用 apply_agent_id OR agent_id OR 代管物业 三字段口径。 + */ +@ExtendWith(MockitoExtension.class) +class PermissionServiceBatteryRepairRowFilterTest { + + @Mock + AuthorityMapper authorityMapper; + @Mock + AgentMapper agentMapper; + @Mock + WarehouseAgentAuthMapper warehouseAgentAuthMapper; + @Mock + AgentManageDistrictAuthMapper agentManageDistrictAuthMapper; + @Mock + ProperytMapper properytMapper; + @Mock + StringRedisTemplate redisTemplate; + @Mock + HashOperations hashOperations; + + PermissionService permissionService; + + @BeforeEach + void setUp() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, BatteryInspection.class); + TableInfoHelper.initTableInfo(assistant, EquipmentRepairRecording.class); + TableInfoHelper.initTableInfo(assistant, EquipmentRepairHandle.class); + TableInfoHelper.initTableInfo(assistant, Properyt.class); + permissionService = new PermissionService(authorityMapper, agentMapper, + warehouseAgentAuthMapper, agentManageDistrictAuthMapper, properytMapper, redisTemplate); + } + + @Test + void headquartersBatteryInspectionAddsNoBoundary() { + when(authorityMapper.selectById(1L)).thenReturn(authority(0)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyBatteryInspectionRowFilter(wrapper, user(null)); + + assertTrue(wrapper.getSqlSegment().isBlank()); + } + + @Test + void agentBatteryInspectionNarrowsToApplyAgentOnly() { + when(authorityMapper.selectById(1L)).thenReturn(authority(1)); + Agent subordinate = new Agent(); + subordinate.setId(2L); + when(agentMapper.selectList(any())).thenReturn(List.of(subordinate)); + WarehouseAgentAuth managedAuth = new WarehouseAgentAuth(); + managedAuth.setManagerAgentId(3L); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of(managedAuth)); + // 代管物业存在也不拼 property_id OR 段(老 propertyFlag=false 口径) + AgentManageDistrictAuth districtAuth = new AgentManageDistrictAuth(); + districtAuth.setPropertyId(44L); + when(agentManageDistrictAuthMapper.selectList(any())).thenReturn(List.of(districtAuth)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyBatteryInspectionRowFilter(wrapper, user(1L)); + + String sql = wrapper.getSqlSegment(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(sql.contains("apply_agent_id IN")); + assertFalse(sql.contains("property_id")); + assertFalse(sql.contains(" OR ")); + assertTrue(values.contains(1L)); + assertTrue(values.contains(2L)); + assertTrue(values.contains(3L)); + } + + @Test + void salesNonLeaderBatteryInspectionUsesPropertyIds() { + User user = user(1L); + user.setDeptId(5L); + user.setPostId(7L); + when(authorityMapper.selectById(1L)).thenReturn(authority(1)); + when(agentMapper.selectList(any())).thenReturn(List.of()); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of()); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(hashOperations.get("Dictionary", "authDeptId")).thenReturn(",5,"); + when(hashOperations.get("Dictionary", "authDeptPostId")).thenReturn("8,"); + Properyt property = new Properyt(); + property.setId(55L); + when(properytMapper.selectList(any())).thenReturn(List.of(property)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyBatteryInspectionRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + assertTrue(sql.contains("property_id IN")); + assertFalse(sql.contains("apply_agent_id")); + assertTrue(wrapper.getParamNameValuePairs().values().contains(55L)); + verifyNoInteractions(agentManageDistrictAuthMapper); + } + + @Test + void agentRepairRecordingUsesThreeFieldOrScope() { + when(authorityMapper.selectById(1L)).thenReturn(authority(1)); + Agent subordinate = new Agent(); + subordinate.setId(2L); + when(agentMapper.selectList(any())).thenReturn(List.of(subordinate)); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of()); + AgentManageDistrictAuth districtAuth = new AgentManageDistrictAuth(); + districtAuth.setPropertyId(44L); + when(agentManageDistrictAuthMapper.selectList(any())).thenReturn(List.of(districtAuth)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyRepairRecordingRowFilter(wrapper, user(1L)); + + String sql = wrapper.getSqlSegment(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(sql.contains("apply_agent_id IN")); + assertTrue(sql.contains("agent_id IN")); + assertTrue(sql.contains("property_id IN")); + assertTrue(sql.contains("OR")); + assertTrue(values.contains(1L)); + assertTrue(values.contains(2L)); + assertTrue(values.contains(44L)); + } + + @Test + void agentRepairRecordingWithoutManagedPropertyDropsPropertyBranch() { + when(authorityMapper.selectById(1L)).thenReturn(authority(1)); + when(agentMapper.selectList(any())).thenReturn(List.of()); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of()); + when(agentManageDistrictAuthMapper.selectList(any())).thenReturn(List.of()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyRepairRecordingRowFilter(wrapper, user(1L)); + + String sql = wrapper.getSqlSegment(); + assertTrue(sql.contains("apply_agent_id IN")); + assertTrue(sql.contains("agent_id IN")); + assertFalse(sql.contains("property_id")); + } + + @Test + void salesNonLeaderRepairRecordingUsesPropertyIds() { + User user = user(1L); + user.setDeptId(5L); + user.setPostId(7L); + when(authorityMapper.selectById(1L)).thenReturn(authority(1)); + when(agentMapper.selectList(any())).thenReturn(List.of()); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of()); + when(redisTemplate.opsForHash()).thenReturn(hashOperations); + when(hashOperations.get("Dictionary", "authDeptId")).thenReturn(",5,"); + when(hashOperations.get("Dictionary", "authDeptPostId")).thenReturn("8,"); + when(properytMapper.selectList(any())).thenReturn(List.of()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyRepairRecordingRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + assertTrue(sql.contains("property_id IN")); + assertFalse(sql.contains("apply_agent_id")); + assertTrue(wrapper.getParamNameValuePairs().values().contains(0L)); + } + + @Test + void agentRepairHandleSharesRecordingScope() { + when(authorityMapper.selectById(1L)).thenReturn(authority(1)); + when(agentMapper.selectList(any())).thenReturn(List.of()); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of()); + AgentManageDistrictAuth districtAuth = new AgentManageDistrictAuth(); + districtAuth.setPropertyId(44L); + when(agentManageDistrictAuthMapper.selectList(any())).thenReturn(List.of(districtAuth)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyRepairHandleRowFilter(wrapper, user(1L)); + + String sql = wrapper.getSqlSegment(); + assertTrue(sql.contains("apply_agent_id IN")); + assertTrue(sql.contains("agent_id IN")); + assertTrue(sql.contains("property_id IN")); + assertTrue(sql.contains("OR")); + } + + @Test + void headquartersRepairRecordingAddsNoBoundary() { + when(authorityMapper.selectById(1L)).thenReturn(authority(0)); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + permissionService.applyRepairRecordingRowFilter(wrapper, user(null)); + + assertTrue(wrapper.getSqlSegment().isBlank()); + } + + private static Authority authority(int isAgent) { + Authority authority = new Authority(); + authority.setId(1L); + authority.setIsagent(isAgent); + authority.setSoftDelete(0); + return authority; + } + + private static User user(Long agentId) { + User user = new User(); + user.setId(7L); + user.setAuthorityId(1L); + user.setAgentId(agentId); + return user; + } +}