Merge branch 'feat/paper-trace-list-mcp-tools': 客户换纸记录 MCP 工具 (REQ-20261012-0002)

This commit is contained in:
my_local_kimi_dev2
2026-10-12 14:18:38 +08:00
12 changed files with 1097 additions and 53 deletions
+1
View File
@@ -95,6 +95,7 @@ xiaoqu-agent-service/
| `district.paper.percapita.query` | district | read | 人均用纸统计。groupBy=gender/floor/floorGender 与 daily 组合覆盖老系统 5 个接口;返回面向 LLM 的扁平 rows(维度、用纸米数、使用人次、人均值同一行),无数据不补零。数据源为 m0 monthly_statistics,不查 ES;仅绑定用户委托作用域。详细设计见 doc/paper-percapita-statistics-mcp-design.md |
| `district.paper.device_average.query` | hardware | read | 设备日均用纸统计。view=gender/daily_gender/floor/daily_floor/floor_gender 覆盖老系统 5 个硬件统计接口;数据库按视图聚合,固定以 COUNT(*) 为设备日统计记录数分母,返回统一 rows 和 summary。用户委托作用域与 ops-query 服务身份均可调用。 |
| `district.paper.position.detail.query` | district | read | 客户用纸详情(复刻老 /MonthlyStatistics/getMonthlyStatisticsPerPositionMap)。入参 districtId/startDate/endDate/positionMode/deviceType/buildingId/floor/toiletId;按 位置(厕位/厕墙)× 日期 聚合 m0 monthly_statistics(纯 MySQL,不查 ES),返回扁平 rows(date/locationType/positionId/toiletId/locationName/gender/genderText/snCodes/paperLengthMeters/useCount/perUseLengthMeters),无数据不补 0;位置命名直查 m0 positions/toilet,查不到给"未知";修复老接口多行合并整数除法截断 bug,厕墙人均为 null。详细设计见 doc/paper-position-detail-mcp-design.md |
| `district.paper.trace.list.query` | district | read | 客户换纸记录分页查询(复刻老 /MonthlyStatistics/getRfidTraceList 数据语义,出分页明细不复刻轨迹展开)。入参 districtId/startDate/endDate 必填(≤31 天),snCode/rfid 可选 .keyword 精确匹配,state 可选(2上纸/4撤纸,不传查所有),deviceType 可选,page/rows 默认 1/20 上限 100。返回 records(createTime/state+stateText/rfid/snCode/lengthCm 厘米/paperlengthCm/positionsId/toiletId/gender+genderText/deviceType/isAssignment),createTime ASC。ES paperuselog 热/冷索引 + LocalEsDate 云/本地集群路由(复用 statistics 同款);v1 不返回满卷标记。详细设计见 doc/paper-trace-list-mcp-design.md |
| `battery.fault.list.query` | hardware | read | 电池故障分页查询。按站点/物业/代理商/SN/设备类型/故障类型/恢复状态/故障日期筛选;沿用设备行级权限,按故障时间和 ID 倒序。详细设计见 doc/battery-fault-mcp-tool-design.md |
| `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 |
@@ -977,6 +977,26 @@
---
## 38. district.paper.trace.list.query
客户换纸记录分页查询(ES paperuselog 热索引 / paperuselog_all 冷索引),复刻老
`/MonthlyStatistics/getRfidTraceList` 的数据查询语义但**出分页明细**(不复刻轨迹展开)。
- 分类:district | 类型:读
- 入参:`districtId`、`startDate`、`endDate` 必填(yyyy-MM-dd 闭区间,endDate 不得晚于今天,区间硬限 31 天);
可选 `snCode`/`rfid`(**.keyword 精确匹配**)、`state`(2=上纸/4=撤纸,不传=查所有)、`deviceType`(0/1)、
`page`/`rows`(默认 1/20,上限 100 截断 + notice)。
- 返回 `districtId/districtName/index/total/page/rows/records`,record 含 createTime、state+stateText、rfid、
snCode、**lengthCm**(原始长度,单位厘米)、paperlengthCm、positionsId(0=厕所级)、toiletId、gender+genderText、
deviceType、isAssignment(m0 rfid_info assignment_flag≥1);按 createTime ASC 排序。
- 行级权限:`applyDistrictRowFilter` 校验站点可见性,不可见返回「站点不存在或无权访问」(老系统此接口无鉴权,属历史遗漏,MCP 收紧)。
- LocalEsDate 集群分界与冷热索引路由复用 district.paper.statistics.query 同款;跨界拒绝、字典缺失 fail fast。
- v1 不返回满卷标记(newPaperFlag),由 agent 按原始 lengthCm 自行判断。
详细设计见 [paper-trace-list-mcp-design](paper-trace-list-mcp-design.md)。
---
---
## 附录:在线查看工具清单
@@ -1,47 +1,51 @@
# 人均用纸统计 MCP 工具设计
需求:REQ-20260922-0004
来源:老系统 `StatisticsReportController`(xiaoqu-franchisee)5 个接口的 MCP 化。
工具:`district.paper.percapita.query`
## 1. 覆盖范围
一个 MCP 工具通过 `groupBy` 与 `daily` 覆盖老 `StatisticsReportController` 的五个接口:
| 老接口 | groupBy | daily |
| 老接口 | 分组维度 | 图表形态 |
|---|---|---|
| perPaperLengthStatistics | gender | false |
| perPaperLengthStatisticsDetail | gender | true |
| perPaperLengthStatisticsByFloor | floor | false |
| perPaperLengthStatisticsDetailByFloor | floor | true |
| perPaperLengthStatisticsByToilet | floorGender | false |
| perPaperLengthStatistics | gender | 柱图 |
| perPaperLengthStatisticsDetail | 日期 × gender | 折线 |
| perPaperLengthStatisticsByFloor | floor | 柱图 |
| perPaperLengthStatisticsDetailByFloor | 日期 × floor | 折线 |
| perPaperLengthStatisticsByToilet | floor × gender | 分组柱图(名为 ByToilet,实际维度是楼层×卫生间类型) |
`floorGender+daily=true` 不在老接口范围内,直接返回参数错误。
## 1. 数据源
## 2. 数据与口径
- **纯 MySQL**,m0 库 `monthly_statistics` 日汇总表(Mapper 已存在:`mapper/m0/MonthlyStatisticsMapper.java`,实体 `entity/m0/MonthlyStatistics.java`)。**不查 ES**——ES 只在老系统离线写入管道里(XXL-Job 从 paperuselog 聚合落表)。
- 聚合口径(与老系统一致):`sum(use_count)`、`sum(paper_length)/100`(表内 paper_length 单位为厘米,/100 转米),人均 = paper_length(米) / use_count,保留 2 位小数;use_count=0 时人均取 paperLength 原值(沿用老逻辑)。
- 过滤条件:`soft_delete=0`、`statistics_time` 起止(含当天)、`district_id` 必填、可选 `device_type`、可选位置筛选 `idStrList`(`buildingId_floor_toiletId` 三段各自独立 IN)。
- 天数上限:Redis `Dictionary` hash 的 `StatisticsDays`,超限报错(与 district.paper.statistics.query 一致)。
- 数据源:m0 `monthly_statistics` 日汇总表,不查 ES。
- 固定过滤:`soft_delete=0`、`district_id`、日期左闭右开区间。
- 可选过滤:`device_type`;`idStrList` 中 building、floor、toilet 分别独立 IN。
- `paperLengthMeters=Σpaper_length/100`,表内 `paper_length` 单位为厘米。
- `perCapita=paperLengthMeters/Σuse_count`,四舍五入保留两位小数。
- 当 useCount=0 时,perCapita 取 paperLengthMeters,延续老系统语义。
- 日期上限读取 Redis `Dictionary.StatisticsDays`,缺失或非法时拒绝查询。
## 2. 权限(与老系统的关键差异)
## 3. 参数
老系统 5 个接口**无任何行级权限**(UserUtils 全被注释,agentId/propertyId 入参被删)。MCP 版必须补上:
- 沿用 `district.paper.statistics.query` 的模式:先按 `permissionService.applyDistrictRowFilter` 校验 districtId 对当前用户可见,不可见抛 `IllegalStateException("站点不存在或无权访问")`。
- 只发用户委托 Token(user_delegated),服务 Token 不给这几个 query 工具的 scope(遵守 AK/Scope 约束,见 [[xiaoqu-mcp-architecture]])。
## 3. 工具形态:推荐合并为 1 个工具
老系统 5 个接口本质是 5 种分组组合,合并成一个工具对 agent 更友好(少注册、少 scope 绑定、schema 自解释):
```
district.paper.percapita.query (@XqTool(category="district", kind="read"))
```
参数:
| 参数 | 必填 | 说明 |
|---|---|---|
| districtId | 是 | 正整数站点 ID |
| startDate/endDate | 是 | yyyy-MM-dd,含首尾日期,endDate 不晚于今天 |
| groupBy | 是 | gender / floor / floorGender |
| daily | 否 | 默认 false;true 时增加日期维度 |
| deviceType | 否 | 0 出纸机,1 擦手纸机 |
| idStrList | 否 | 每项严格为 buildingId_floor_toiletId |
| districtId | 是 | 站点 id |
| startDate / endDate | 是 | yyyy-MM-dd,含当天;跨度 ≤ StatisticsDays |
| groupBy | 是 | `gender` / `floor` / `floorGender`(楼层×卫生间类型,即老 ByToilet) |
| daily | 否,默认 false | true=按日折线(仅 gender/floor 支持;floorGender+daily 直接参数报错) |
| deviceType | 否 | 0=出纸机 1=擦手纸机 |
| idStrList | 否 | 位置筛选,`buildingId_floor_toiletId` 格式 |
## 4. 面向 LLM 的返回结构
映射:`gender`+daily=false→①,+daily=true→②,`floor`+false→③,+true→④,`floorGender`→⑤。
返回结构不随分组方式变化。维度和指标位于同一个行对象,无数据日期或分组不生成行。
返回 `Map<String, Object>`(项目统一风格,无 DTO 类)。**面向 LLM 消费设计**(王恒 2026-09-22 确认:后续所有 MCP 工具设计都以返给 LLM 展示为准)——一种结构走天下,不随 groupBy/daily 变形态:
```json
{
@@ -53,33 +57,37 @@
"daily": false,
"totalPerCapita": 1.32,
"rows": [
{
"date": null,
"floor": null,
"gender": "男卫",
"paperLengthMeters": 120.5,
"useCount": 96,
"perCapita": 1.26
}
{"date": "2026-09-01", "floor": null, "gender": "男卫", "paperLengthMeters": 120.5, "useCount": 96, "perCapita": 1.25}
]
}
```
- 未参与分组的 `date/floor/gender` 保持为 null。
- `daily=true` 时 `totalPerCapita` 为 null;非按日查询时为整个区间的总人均。
- gender 映射:0男卫、1女卫、2母婴室、3无障碍、4第三卫、5其他,其他值显示未知。
- 楼层按数据库字符串值的字典序输出。
- `rows` 是扁平自描述行数组:每行把维度值(date/floor/gender)和指标(paperLengthMeters/useCount/perCapita)放同一对象,LLM 不需要维护平行数组下标对齐。
- 维度字段恒在、不参与的维度为 null:`daily=false` 时 `date=null`;`groupBy=gender` 时 `floor=null`;`groupBy=floor` 时 `gender=null`;`floorGender` 两者都有值。
- 透出 `paperLengthMeters`、`useCount` 原始值,LLM 可自行二次计算,不必从人均反推。
- 无数据的日期/分组**不出行**(LLM 语义:没有就是没数据,不会误读成 0)——放弃老系统折线"补完整日期横轴 + 填 0.0"的做法,画折线是消费方的事。
- `totalPerCapita` 仅在 daily=false 时给出(区间总人均);daily=true 时为 null,由消费方按需聚合。
- gender 文案映射沿用老系统:0男卫/1女卫/2母婴室/3无障碍/4第三卫/5其他。
- 数值统一 Double、保留 2 位小数;use_count=0 时人均取 paperLength 原值(沿用老逻辑)。
## 5. 权限与作用域
备选方案:1:1 拆 5 个工具(`district.paper.percapita.{gender,genderDaily,floor,floorDaily,floorGender}.query`)。更贴近老接口但注册/绑定成本 ×5,不推荐。
- 查询前用 `PermissionService.applyDistrictRowFilter` 校验当前用户能否查看站点。
- 站点不存在和无权访问统一返回“站点不存在或无权访问”。
- 工具只绑定 `service_enabled=0` 的用户委托作用域,不向 sync、ops-query 等服务身份作用域开放。
废弃方案:ECharts 平行数组结构(nameList/valueList、dateList/series、xnameList/groupList/groups)——给前端画图顺,但 LLM 消费有下标错位、补 0.0 混淆语义、多形态理解成本三个问题,已被王恒否决(2026-09-22)。
## 6. 实现文件
## 4. 改动文件清单
- `src/main/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsTools.java`
- `src/test/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsToolsTest.java`
- `sql/seed-dev.sql`
- `doc/agent-mcp-tools-reference.md`
- `README.md`
1. 新建 `src/main/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsTools.java`(@Component implements ToolObject,骨架照抄 DistrictPaperStatisticsTools.java:73-87)
2. 实体/Mapper:**无需新增**(monthly_statistics 已覆盖)
3. 权限:**无需新增**(复用 applyDistrictRowFilter,PermissionService.java:798)
4. ES:**不涉及**
5. 单测 `src/test/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsToolsTest.java`(Mockito + AuthContextHolder.set(),参照 DistrictPaperStatisticsToolsTest)
6. `sql/seed-dev.sql` 加幂等 scope_tool 绑定;生产绑 scope 1/2/3/5(与 district.paper.statistics.query 同组)
7. 登记 `doc/agent-mcp-tools-reference.md`
8. 装配层零改动(McpToolConfig 自动收集 ToolObject bean,ToolSyncService 自动同步 tool 表)
## 5. 已知坑 / 注意点
- 老代码 `totalPaperLength` 用 Integer 存 SQL 已 /100 的值再除,精度有损失;MCP 版统一用 Double 计算,不与老系统逐分核对。
- `setWrapperByPosition` 的三段独立 IN 语义要保持(不是联合匹配),否则结果集不同。
- daily 不再补完整日期横轴(老系统补 0.0 是给前端画图用;MCP 面向 LLM,无数据日期不出行)。
- floor 是 String 列,排序按字典序(老系统行为),文档里注明。
@@ -0,0 +1,76 @@
# 客户换纸记录 MCP 工具设计
需求:REQ-20261012-0002(id 5975)
工具:`district.paper.trace.list.query`(@XqTool(category="district", kind="read"))
## 背景
老系统 `MonthlyStatisticsController.getRfidTraceList`(/MonthlyStatistics/getRfidTraceList,客户换纸记录)
移植为 MCP 工具。数据源 ES `paperuselog`(热)/ `paperuselog_all`(冷,距今 ≥90 天),集群(云/本地)与
冷热索引路由复用 `PaperUseLogEsPlanner` 同款口径。
## 老接口行为与坑(xiaoqu-franchisee MonthlyStatisticsController.java:517-860)
1. **轨迹展开**:撤纸(state=4)和上纸(state=2)两轮 ES scroll 全量拉取,Java 侧按
`P_{positionsId}_{snCode}_{rfid}` 复合 key 合并成"纸卷轨迹";`modifyTime` 被改写成下一卷的上纸时间、
`createId` 复用携带撤纸剩余纸长——输出契约全是隐式字段复用。
2. **matchQuery 分词匹配**:snCode/rfid 走 matchQuery,不精确(同模块 getRfidTraceListBySncode 用 .keyword)。
3. **入参 state 被强制覆盖为 2**,调用方传值无效。
4. **scroll 深分页上限 ~10 万条截停静默丢数据**。
5. **Redis SpecialSncode 命中的设备整组跳过**(运维屏蔽逻辑)。
6. **ES 异常返回空壳 success**,前端分不清"无数据"和"查询失败"。
7. **完全无权限校验**(同 Controller 其他接口都有取人,属历史遗漏)。
8. 长度单位全厘米(满卷默认 18000cm=180m);时间校验异常被吞,传空时间直接 500。
## 决策表(2026-10-12 王恒拍板)
| # | 决策点 | 结论 |
|---|---|---|
| 1 | 轨迹 vs 明细 | **出分页明细,不复刻轨迹展开**。scroll 全量 + 隐式字段改写分页不可做、契约隐式、静默丢数据;agent 拿到按 rfid 分组的明细(createTime 升序)可自行重构"每卷纸用了多久" |
| 2 | 时间上限 | **硬上限 31 天**(startDate/endDate 必填,yyyy-MM-dd 闭区间日粒度;不依赖 Redis StatisticsDays) |
| 3 | 满卷标记 newPaperFlag | **v1 不返回**,只返回原始 lengthCm 由 agent 判断(老系统需查 paper_info.length 按站点纸规格判定;v1.1 再评估) |
## 参数契约
| 参数 | 必填 | 说明 |
|---|---|---|
| districtId | 是 | 站点 id,正整数;先查 district_strategy 校验可见性 |
| startDate / endDate | 是 | yyyy-MM-dd,闭区间,endDate 不得晚于今天;区间 ≤31 天 |
| snCode / rfid | 否 | **keyword 精确匹配**(修老系统 match 分词坑) |
| state | 否 | 2=上纸 / 4=撤纸,**不传=查所有**(修老系统强制覆盖坑) |
| deviceType | 否 | 0=出纸机 / 1=擦手纸机 |
| page / rows | 否 | 默认 1/20,rows 上限 100 截断 + notice(标准约定) |
## 返回契约
`districtId / districtName / index / total / page / rows / [notice] / records`,按 createTime ASC 排序。
record 含 `createTime`、`state`+`stateText`(上纸/撤纸)、`rfid`、`snCode`、**`lengthCm`**(原始长度,字段名带单位,厘米)、
`paperlengthCm`、`positionsId`(0=厕所级)、`toiletId`、`gender`+`genderText`、`deviceType`、
`isAssignment`(m0 rfid_info 该 rfid assignment_flag≥1 为 true)。
## 与老系统的差异
- 权限:老系统裸奔 → MCP 复用 `district.paper.statistics.query` 同款 `applyDistrictRowFilter`,
不可见抛"站点不存在或无权访问",不泄露存在性。
- 明确不搬:SpecialSncode 屏蔽(运维专用)、scroll 深分页(改标准 from+size)、空壳 success
(MCP 异常直接抛 IllegalStateException)、同 Controller 的无鉴权运维 GET 接口(testsetMonthlyStatics*/
update*/reset* 绝不暴露)。
- endDate 上界口径与 statistics 工具同款:endDate=今天 → 取今天 00:00-1ms(当天数据未跑完),否则次日 00:00-1ms。
- ES 查询一律低阶 RestHighLevelClient(高阶 elasticsearch-java 被生产 ES 7.7.1 双重拒绝,见 electricitymessage 坑沉淀);
createTime range 发 epoch 毫秒;_source 文本值归一。
## 实现文件
- `src/main/java/me/iiv/agent/es/PaperUseLogEsGateway.java` + Impl:新增 `searchTraces` 明细查询
(term districtId、snCode.keyword/rfid.keyword term、state/deviceType 可选、from+size、track_total_hits)
- `src/main/java/me/iiv/agent/entity/m0/RfidInfo.java` + `mapper/m0/RfidInfoMapper.java`:极简实体(isAssignment 判定)
- `src/main/java/me/iiv/agent/mcp/tool/DistrictPaperTraceTools.java`
- `src/test/java/me/iiv/agent/es/PaperUseLogEsGatewayImplTest.java`(新增 searchTraces 用例)
- `src/test/java/me/iiv/agent/mcp/tool/DistrictPaperTraceToolsTest.java`
- `sql/seed-dev.sql` 幂等 scope_tool 绑定;生产绑 scope 1(test)/2(operation)/3(xiaoqu-ai)/5(ops-query)
## 测试要点
参数校验(在任一查询前全部拦截)/ 权限(不可见站点、applyDistrictRowFilter 必调)/ 字典缺失 fail fast /
LocalEsDate 跨界拒绝 / 冷热索引与集群路由 / 枚举映射(stateText、genderText 未知为 null)/ rows 截断 notice /
endDate 上界毫秒口径 / 空记录跳过 rfid_info 查询 / Spring AI schema 发布。
+9
View File
@@ -74,6 +74,15 @@ WHERE s.soft_delete = 0 AND s.status = 1
AND t.name = 'district.paper.device_average.query'
AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id);
-- 客户换纸记录(REQ-20261012-0002):口径同 district.paper.statistics.query——
-- 用户委托作用域 + ops-query 服务身份(总部视角),不进入 sync。
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.soft_delete = 0 AND s.status = 1
AND (s.service_enabled = 0 OR s.code = 'ops-query')
AND t.name = 'district.paper.trace.list.query'
AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id);
COMMIT;
-- 校验
@@ -0,0 +1,32 @@
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;
/** RFID(m0.rfid_info,极简字段:换纸明细的 isAssignment 判定用)。 */
@Getter
@Setter
@ToString
@TableName("rfid_info")
public class RfidInfo 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 rfid;
/** ≥1 表示已生成(老 getRfidTraceList 口径:assignment_flag >= 1) */
private Integer assignmentFlag;
}
@@ -1,5 +1,7 @@
package me.iiv.agent.es;
import java.util.List;
/**
* paperuselog(换纸记录)ES 查询网关。集群选择(云/本地)与索引选择(热/冷)
* 由调用方通过参数传入,本接口只做单次查询,便于单测 mock。
@@ -22,4 +24,112 @@ public interface PaperUseLogEsGateway {
*/
long countState2(String index, boolean localCluster, Long districtId, Integer gender,
Long fromMillis, Long toMillis, Integer deviceType, String positionMode);
/**
* 换纸记录分页明细:term districtId、createTime 闭区间(epoch 毫秒)、
* snCode/rfid 走 .keyword 精确匹配(text 主字段禁止 term/match 分词)、
* state(2=上纸/4=撤纸)与 deviceType 可选过滤,按 createTime ASC 排序,from+size 分页。
*
* @param index paperuselog / paperuselog_all
* @param localCluster true=本地集群,false=云集群
* @param snCode 可选,keyword 精确匹配
* @param rfid 可选,keyword 精确匹配
* @param state 可选,2=上纸、4=撤纸,null=查所有
* @param deviceType 可选,0=出纸机、1=擦手纸机,null=不过滤
* @param from from+size 分页起始偏移
* @param size 每页条数
* @return 命中总数(track_total_hits)与当页记录
*/
TraceSearchResult searchTraces(String index, boolean localCluster, Long districtId,
Long fromMillis, Long toMillis,
String snCode, String rfid, Integer state, Integer deviceType,
int from, int size);
/** 换纸明细分页查询结果。 */
class TraceSearchResult {
private final long total;
private final List<TraceRecord> records;
public TraceSearchResult(long total, List<TraceRecord> records) {
this.total = total;
this.records = records;
}
public long getTotal() {
return total;
}
public List<TraceRecord> getRecords() {
return records;
}
}
/** 单条换纸记录(_source 文本值已归一为数值、createTime 已归一为 ISO 文本)。 */
class TraceRecord {
private final String createTime;
private final Integer state;
private final String rfid;
private final String snCode;
private final Integer lengthCm;
private final Integer paperlengthCm;
private final Long positionsId;
private final Long toiletId;
private final Integer gender;
private final Integer deviceType;
public TraceRecord(String createTime, Integer state, String rfid, String snCode,
Integer lengthCm, Integer paperlengthCm,
Long positionsId, Long toiletId, Integer gender, Integer deviceType) {
this.createTime = createTime;
this.state = state;
this.rfid = rfid;
this.snCode = snCode;
this.lengthCm = lengthCm;
this.paperlengthCm = paperlengthCm;
this.positionsId = positionsId;
this.toiletId = toiletId;
this.gender = gender;
this.deviceType = deviceType;
}
public String getCreateTime() {
return createTime;
}
public Integer getState() {
return state;
}
public String getRfid() {
return rfid;
}
public String getSnCode() {
return snCode;
}
public Integer getLengthCm() {
return lengthCm;
}
public Integer getPaperlengthCm() {
return paperlengthCm;
}
public Long getPositionsId() {
return positionsId;
}
public Long getToiletId() {
return toiletId;
}
public Integer getGender() {
return gender;
}
public Integer getDeviceType() {
return deviceType;
}
}
}
@@ -12,6 +12,8 @@ import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* paperuselog ES 查询实现。
@@ -89,6 +91,118 @@ public class PaperUseLogEsGatewayImpl implements PaperUseLogEsGateway {
}
}
@Override
public TraceSearchResult searchTraces(String index, boolean localCluster, Long districtId,
Long fromMillis, Long toMillis,
String snCode, String rfid, Integer state, Integer deviceType,
int from, int size) {
ArrayNode filters = mapper.createArrayNode();
filters.addObject().putObject("term").putObject("districtId").put("value", districtId);
// createTime 为 date 类型,range 必须发 epoch 毫秒(同 distinctRfidCount)
ObjectNode range = filters.addObject().putObject("range").putObject("createTime");
range.put("gte", fromMillis).put("lte", toMillis);
if (StringUtils.hasText(snCode)) {
// rfid/snCode 主字段是 text(有 ignore_above=256 的 keyword 子字段),
// 精确匹配必须走 .keyword term;matchQuery 会被 standard analyzer 分词误配
filters.addObject().putObject("term").putObject("snCode.keyword").put("value", snCode);
}
if (StringUtils.hasText(rfid)) {
filters.addObject().putObject("term").putObject("rfid.keyword").put("value", rfid);
}
if (state != null) {
filters.addObject().putObject("term").putObject("state").put("value", state);
}
if (deviceType != null) {
// 与 baseFilters 同口径:老代码 deviceType 字符串参数走 match(数字文本分词后等价精确)
filters.addObject().putObject("match").putObject("deviceType")
.put("query", String.valueOf(deviceType));
}
try {
ObjectNode body = mapper.createObjectNode();
body.put("from", from).put("size", size);
body.put("track_total_hits", true);
ObjectNode bool = body.putObject("query").putObject("bool");
bool.set("filter", filters);
body.putArray("sort").addObject().putObject("createTime").put("order", "asc");
Request req = new Request("POST", "/" + index + "/_search");
req.setJsonEntity(mapper.writeValueAsString(body));
Response resp = client(localCluster).performRequest(req);
JsonNode root = mapper.readTree(resp.getEntity().getContent());
List<TraceRecord> records = new ArrayList<>();
for (JsonNode hit : root.at("/hits/hits")) {
JsonNode source = hit.get("_source");
if (source == null) {
continue;
}
records.add(new TraceRecord(
isoText(source.get("createTime")),
integerValue(source.get("state")),
textValue(source.get("rfid")),
textValue(source.get("snCode")),
integerValue(source.get("length")),
integerValue(source.get("paperlength")),
longValue(source.get("positionsId")),
longValue(source.get("toiletId")),
integerValue(source.get("gender")),
integerValue(source.get("deviceType"))));
}
return new TraceSearchResult(root.at("/hits/total/value").asLong(), records);
} catch (IOException | RuntimeException e) {
throw new IllegalStateException("ES 查询换纸记录明细失败: " + e.getMessage(), e);
}
}
/** createTime 在 _source 里是 ISO 文本(2026-09-21T10:00:00.000+08:00)或 epoch 毫秒数,统一归一为 ISO 文本 */
private static String isoText(JsonNode value) {
if (value == null || value.isNull()) {
return null;
}
if (value.isNumber()) {
return java.time.Instant.ofEpochMilli(value.longValue()).toString();
}
return value.isTextual() ? value.asText() : null;
}
private static String textValue(JsonNode value) {
return value == null || value.isNull() ? null : value.asText();
}
private static Long longValue(JsonNode value) {
if (value == null || value.isNull()) {
return null;
}
if (value.isNumber()) {
return value.longValue();
}
if (value.isTextual()) {
try {
return Long.valueOf(value.asText().trim());
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
/** _source 里 length/paperlength/state 等数值可能是文本("150"),统一解析;非数值返回 null */
private static Integer integerValue(JsonNode value) {
if (value == null || value.isNull()) {
return null;
}
if (value.isNumber()) {
return value.intValue();
}
if (value.isTextual()) {
try {
return Integer.valueOf(value.asText().trim());
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
private ArrayNode baseFilters(Long districtId, Integer gender, Long fromMillis, Long toMillis,
Integer deviceType, String positionMode) {
ArrayNode filters = mapper.createArrayNode();
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m0;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m0.RfidInfo;
public interface RfidInfoMapper extends BaseMapper<RfidInfo> {
}
@@ -0,0 +1,253 @@
package me.iiv.agent.mcp.tool;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import me.iiv.agent.entity.m0.DistrictDistrictStrategy;
import me.iiv.agent.entity.m0.RfidInfo;
import me.iiv.agent.entity.m0.User;
import me.iiv.agent.es.PaperUseLogEsGateway;
import me.iiv.agent.es.PaperUseLogEsPlanner;
import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper;
import me.iiv.agent.mapper.m0.RfidInfoMapper;
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.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 客户换纸记录分页查询(老 MonthlyStatisticsController.getRfidTraceList 移植):
* ES 索引 paperuselog(热)/ paperuselog_all(冷,startDate 距今 ≥90 天),
* 按用户拍板**不复刻**老系统的"轨迹展开"逻辑(scroll 全量拉取 + Java 侧隐式字段改写),
* 直接出分页明细;v1 不返回满卷标记(newPaperFlag),只返回原始 lengthCm。
* 站点可见性校验、LocalEsDate 集群分界与冷热索引路由复用 district.paper.statistics.query 同款口径。
* 设计文档:doc/paper-trace-list-mcp-design.md
*/
@Component
public class DistrictPaperTraceTools implements ToolObject {
private static final String DICTIONARY_KEY = "Dictionary";
private static final String DICT_LOCAL_ES_DATE = "LocalEsDate";
/** 日期区间硬限 31 天(一个月,2026-09-24 拍板) */
private static final int MAX_QUERY_DAYS = 31;
private static final int DEFAULT_ROWS = 20;
private static final int MAX_ROWS = 100;
private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
final DistrictDistrictStrategyMapper districtMapper;
final RfidInfoMapper rfidInfoMapper;
final PermissionService permissionService;
final PaperUseLogEsGateway paperUseLogEsGateway;
final StringRedisTemplate redisTemplate;
public DistrictPaperTraceTools(DistrictDistrictStrategyMapper districtMapper,
RfidInfoMapper rfidInfoMapper,
PermissionService permissionService,
PaperUseLogEsGateway paperUseLogEsGateway,
StringRedisTemplate redisTemplate) {
this.districtMapper = districtMapper;
this.rfidInfoMapper = rfidInfoMapper;
this.permissionService = permissionService;
this.paperUseLogEsGateway = paperUseLogEsGateway;
this.redisTemplate = redisTemplate;
}
@XqTool(category = "district", kind = "read")
@Tool(name = "district.paper.trace.list.query", description = "客户换纸记录分页查询(ES paperuselog 索引,"
+ "复刻老 /MonthlyStatistics/getRfidTraceList 的数据查询语义但出分页明细,不复刻轨迹展开逻辑)。"
+ "入参 districtId/startDate/endDate 必填;snCode/rfid 可选,keyword 精确匹配;state 可选"
+ "(2=上纸、4=撤纸,不传=查所有);deviceType 可选(0=出纸机、1=擦手纸机)。"
+ "返回 districtId/districtName/index/total/page/rows/records,record 含 createTime、state+stateText、"
+ "rfid、snCode、lengthCm(原始长度,单位厘米)、paperlengthCm、positionsId(0=厕所级)、toiletId、"
+ "gender+genderText、deviceType、isAssignment(该 rfid 在 m0 rfid_info 中 assignment_flag≥1 为 true);"
+ "按 createTime ASC 排序。日期区间最长 31 天,跨越 Redis LocalEsDate 分界会被拒绝。")
public Map<String, Object> districtPaperTraceListQuery(
@ToolParam(description = "站点 id,正整数") Long districtId,
@ToolParam(description = "开始日期,yyyy-MM-dd,含当天") String startDate,
@ToolParam(description = "结束日期,yyyy-MM-dd,含当天,不得晚于今天") String endDate,
@ToolParam(required = false, description = "设备 SN,keyword 精确匹配") String snCode,
@ToolParam(required = false, description = "rfid,keyword 精确匹配") String rfid,
@ToolParam(required = false, description = "换纸状态:2=上纸、4=撤纸,不传=查所有") Integer state,
@ToolParam(required = false, description = "设备类型:0=出纸机、1=擦手纸机,缺省不过滤") Integer deviceType,
@ToolParam(required = false, description = "页码,从 1 开始,默认 1") Integer page,
@ToolParam(required = false, description = "每页条数,默认 20,上限 100,超出截断为 100") Integer rows) {
if (districtId == null) {
throw new IllegalArgumentException("districtId 必填");
}
if (districtId < 1) {
throw new IllegalArgumentException("districtId 必须为正整数");
}
LocalDate start = parseDay(startDate, "startDate");
LocalDate end = parseDay(endDate, "endDate");
LocalDate today = LocalDate.now(SHANGHAI);
if (start.isAfter(end)) {
throw new IllegalArgumentException("startDate 不能晚于 endDate");
}
if (end.isAfter(today)) {
throw new IllegalArgumentException("endDate 不得晚于今天");
}
if (state != null && state != 2 && state != 4) {
throw new IllegalArgumentException("state 仅支持 2(上纸)、4(撤纸)");
}
if (deviceType != null && deviceType != 0 && deviceType != 1) {
throw new IllegalArgumentException("deviceType 仅支持 0、1");
}
int queryDays = (int) ChronoUnit.DAYS.between(start, end) + 1;
if (queryDays > MAX_QUERY_DAYS) {
throw new IllegalArgumentException(
"查询时间段最长 " + MAX_QUERY_DAYS + " 天(一个月),请修改日期范围");
}
User user = AuthContextHolder.get().getUser();
LambdaQueryWrapper<DistrictDistrictStrategy> districtWrapper = new LambdaQueryWrapper<DistrictDistrictStrategy>()
.eq(DistrictDistrictStrategy::getSoftDelete, 0)
.eq(DistrictDistrictStrategy::getId, districtId);
permissionService.applyDistrictRowFilter(districtWrapper, user);
DistrictDistrictStrategy district = districtMapper.selectOne(districtWrapper);
if (district == null) {
throw new IllegalStateException("站点不存在或无权访问");
}
// 与 district.paper.statistics.query 同款:LocalEsDate 集群分界,字典缺失 fail fast(老系统 NPE 被吞)
LocalDate localEsDate = parseDay(dictionaryValue(DICT_LOCAL_ES_DATE), DICT_LOCAL_ES_DATE);
PaperUseLogEsPlanner.checkNotCrossingBoundary(start, end, localEsDate);
String index = PaperUseLogEsPlanner.resolveIndex(start, today);
boolean localCluster = PaperUseLogEsPlanner.useLocalCluster(localEsDate, end);
long fromMillis = start.atStartOfDay(SHANGHAI).toInstant().toEpochMilli();
// 与 statistics 工具同款老口径:endDate=今天 → 上界取今天 00:00(当天数据未跑完),否则次日 00:00-1ms
LocalDate endExclusive = end.equals(today) ? end : end.plusDays(1);
long toMillis = endExclusive.atStartOfDay(SHANGHAI).toInstant().toEpochMilli() - 1;
int pageNo = page == null || page < 1 ? 1 : page;
boolean truncatedRows = rows != null && rows > MAX_ROWS;
int pageSize = rows == null || rows < 1 ? DEFAULT_ROWS : Math.min(rows, MAX_ROWS);
int from = (pageNo - 1) * pageSize;
PaperUseLogEsGateway.TraceSearchResult search = paperUseLogEsGateway.searchTraces(
index, localCluster, districtId, fromMillis, toMillis,
StringUtils.hasText(snCode) ? snCode.trim() : null,
StringUtils.hasText(rfid) ? rfid.trim() : null,
state, deviceType, from, pageSize);
Set<String> assignedRfids = assignedRfids(search.getRecords());
List<Map<String, Object>> records = new ArrayList<>();
for (PaperUseLogEsGateway.TraceRecord record : search.getRecords()) {
records.add(recordRow(record, assignedRfids));
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("districtId", districtId);
result.put("districtName", district.getDistrictName());
result.put("index", index);
result.put("total", search.getTotal());
result.put("page", pageNo);
result.put("rows", pageSize);
if (truncatedRows) {
result.put("notice", "rows 超过上限 " + MAX_ROWS + ",已截断为 " + MAX_ROWS);
}
result.put("records", records);
return result;
}
/** 老 getRfidTraceList :735-741 口径:m0 rfid_info soft_delete=0、rfid IN (页内 rfid)、assignment_flag≥1 */
private Set<String> assignedRfids(List<PaperUseLogEsGateway.TraceRecord> records) {
Set<String> rfids = new LinkedHashSet<>();
for (PaperUseLogEsGateway.TraceRecord record : records) {
if (StringUtils.hasText(record.getRfid())) {
rfids.add(record.getRfid());
}
}
Set<String> assigned = new HashSet<>();
if (rfids.isEmpty()) {
return assigned;
}
List<RfidInfo> rfidInfos = rfidInfoMapper.selectList(new LambdaQueryWrapper<RfidInfo>()
.eq(RfidInfo::getSoftDelete, 0)
.in(RfidInfo::getRfid, rfids)
.ge(RfidInfo::getAssignmentFlag, 1));
for (RfidInfo rfidInfo : rfidInfos) {
assigned.add(rfidInfo.getRfid());
}
return assigned;
}
private Map<String, Object> recordRow(PaperUseLogEsGateway.TraceRecord record, Set<String> assignedRfids) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("createTime", record.getCreateTime());
row.put("state", record.getState());
row.put("stateText", stateText(record.getState()));
row.put("rfid", record.getRfid());
row.put("snCode", record.getSnCode());
row.put("lengthCm", record.getLengthCm());
row.put("paperlengthCm", record.getPaperlengthCm());
row.put("positionsId", record.getPositionsId());
row.put("toiletId", record.getToiletId());
row.put("gender", record.getGender());
row.put("genderText", genderText(record.getGender()));
row.put("deviceType", record.getDeviceType());
row.put("isAssignment", StringUtils.hasText(record.getRfid()) && assignedRfids.contains(record.getRfid()));
return row;
}
private String dictionaryValue(String field) {
Object value = redisTemplate.opsForHash().get(DICTIONARY_KEY, field);
if (value == null) {
throw new IllegalStateException("Redis Dictionary 缺少 " + field + " 字段,拒绝查询");
}
return value.toString();
}
private static LocalDate parseDay(String value, String name) {
if (!StringUtils.hasText(value)) {
throw new IllegalArgumentException(name + " 必填,格式 yyyy-MM-dd");
}
try {
return LocalDate.parse(value.trim());
} catch (java.time.format.DateTimeParseException e) {
throw new IllegalArgumentException(name + " 格式非法,要求 yyyy-MM-dd: " + value);
}
}
private static String stateText(Integer state) {
if (state == null) {
return null;
}
switch (state) {
case 2: return "上纸";
case 4: return "撤纸";
default: return null;
}
}
/** gender 透传索引原值;0男/1女/2母婴/3残卫/4第三/5其他,未知编码 genderText 为 null */
private static String genderText(Integer gender) {
if (gender == null) {
return null;
}
switch (gender) {
case 0: return "男";
case 1: return "女";
case 2: return "母婴";
case 3: return "残卫";
case 4: return "第三";
case 5: return "其他";
default: return null;
}
}
}
@@ -18,6 +18,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
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;
@@ -135,4 +136,103 @@ class PaperUseLogEsGatewayImplTest {
1789392000000L, 1789478399000L, null, null));
assertTrue(exception.getMessage().startsWith("ES 查询换纸 rfid 去重失败"));
}
@Test
void searchTracesBuildsKeywordTermsAndSortAsc() throws Exception {
Response response = esResponse("{\"hits\":{\"total\":{\"value\":0},\"hits\":[]}}");
when(localRestClient.performRequest(any(Request.class))).thenReturn(response);
gateway.searchTraces("paperuselog", true, 2578L,
1789392000000L, 1789478399000L,
"SN-001", "RF-ABC", 2, 1, 40, 20);
ArgumentCaptor<Request> captor = ArgumentCaptor.forClass(Request.class);
verify(localRestClient).performRequest(captor.capture());
Request captured = captor.getValue();
assertEquals("POST", captured.getMethod());
assertEquals("/paperuselog/_search", captured.getEndpoint());
JsonNode body = mapper.readTree(EntityUtils.toString(captured.getEntity()));
JsonNode filter = body.at("/query/bool/filter");
// rfid/snCode 是 text 类型,精确匹配必须走 .keyword term(禁止 matchQuery 分词误配)
assertTrue(filter.toString().contains("\"snCode.keyword\":{\"value\":\"SN-001\"}"));
assertTrue(filter.toString().contains("\"rfid.keyword\":{\"value\":\"RF-ABC\"}"));
assertTrue(filter.toString().contains("\"state\":{\"value\":2}"));
assertTrue(filter.toString().contains("\"districtId\":{\"value\":2578}"));
assertTrue(filter.toString().contains("\"deviceType\":{\"query\":\"1\"}"));
// createTime 为 date 类型,range 必须发 epoch 毫秒
assertEquals(1789392000000L, filter.findValue("gte").asLong());
assertEquals(1789478399000L, filter.findValue("lte").asLong());
assertEquals(40, body.get("from").asInt());
assertEquals(20, body.get("size").asInt());
assertTrue(body.get("track_total_hits").asBoolean());
assertEquals("asc", body.at("/sort/0/createTime/order").asText());
}
@Test
void searchTracesOmitsOptionalFilters() throws Exception {
Response response = esResponse("{\"hits\":{\"total\":{\"value\":0},\"hits\":[]}}");
when(cloudRestClient.performRequest(any(Request.class))).thenReturn(response);
gateway.searchTraces("paperuselog_all", false, 2578L,
1789392000000L, 1789478399000L,
null, null, null, null, 0, 20);
verify(cloudRestClient).performRequest(any(Request.class));
ArgumentCaptor<Request> captor = ArgumentCaptor.forClass(Request.class);
verify(cloudRestClient).performRequest(captor.capture());
JsonNode filter = mapper.readTree(EntityUtils.toString(captor.getValue().getEntity()))
.at("/query/bool/filter");
// 可选过滤全不传:只留 districtId + createTime range,不带 keyword/state/deviceType 条件
assertEquals(2, filter.size());
assertTrue(filter.toString().contains("districtId"));
assertTrue(filter.toString().contains("createTime"));
assertTrue(!filter.toString().contains("keyword"));
assertTrue(!filter.toString().contains("state"));
}
@Test
void searchTracesNormalizesTextSourceValues() throws Exception {
// _source 里 length/paperlength/state 是文本、createTime 是 ISO 文本(生产索引实际形态)
String response = "{\"hits\":{\"total\":{\"value\":2},\"hits\":["
+ "{\"_source\":{\"createTime\":\"2026-09-21T10:00:00.000+08:00\",\"state\":\"2\","
+ "\"rfid\":\"RF-1\",\"snCode\":\"SN-1\",\"length\":\"150\",\"paperlength\":\"18000\","
+ "\"positionsId\":\"11\",\"toiletId\":\"3\",\"gender\":\"0\",\"deviceType\":\"0\"}},"
+ "{\"_source\":{\"createTime\":1789392000000,\"state\":4,"
+ "\"rfid\":null,\"snCode\":\"SN-2\",\"length\":80,\"paperlength\":9000,"
+ "\"positionsId\":0,\"toiletId\":7,\"gender\":1,\"deviceType\":1}}"
+ "]}}";
Response hitsResponse = esResponse(response);
when(localRestClient.performRequest(any(Request.class))).thenReturn(hitsResponse);
PaperUseLogEsGateway.TraceSearchResult result = gateway.searchTraces("paperuselog", true, 2578L,
1789392000000L, 1789478399000L, null, null, null, null, 0, 20);
assertEquals(2L, result.getTotal());
PaperUseLogEsGateway.TraceRecord first = result.getRecords().get(0);
assertEquals("2026-09-21T10:00:00.000+08:00", first.getCreateTime());
assertEquals(2, first.getState());
assertEquals("RF-1", first.getRfid());
assertEquals(150, first.getLengthCm());
assertEquals(18000, first.getPaperlengthCm());
assertEquals(11L, first.getPositionsId());
assertEquals(3L, first.getToiletId());
assertEquals(0, first.getGender());
PaperUseLogEsGateway.TraceRecord second = result.getRecords().get(1);
// epoch 毫秒形态归一为 Instant ISO 文本
assertEquals("2026-09-14T13:20:00Z", second.getCreateTime());
assertEquals(4, second.getState());
assertNull(second.getRfid());
assertEquals(0L, second.getPositionsId());
}
@Test
void searchTracesWrapsEsFailureInIllegalState() throws Exception {
when(localRestClient.performRequest(any(Request.class)))
.thenThrow(new IOException("connection reset"));
IllegalStateException exception = assertThrows(IllegalStateException.class,
() -> gateway.searchTraces("paperuselog", true, 2578L,
1789392000000L, 1789478399000L, null, null, null, null, 0, 20));
assertTrue(exception.getMessage().startsWith("ES 查询换纸记录明细失败"));
}
}
@@ -0,0 +1,314 @@
package me.iiv.agent.mcp.tool;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import me.iiv.agent.entity.m0.DistrictDistrictStrategy;
import me.iiv.agent.entity.m0.RfidInfo;
import me.iiv.agent.entity.m0.User;
import me.iiv.agent.es.PaperUseLogEsGateway;
import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper;
import me.iiv.agent.mapper.m0.RfidInfoMapper;
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.HashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
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.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DistrictPaperTraceToolsTest {
private static final ZoneId SH = ZoneId.of("Asia/Shanghai");
@Mock
DistrictDistrictStrategyMapper districtMapper;
@Mock
RfidInfoMapper rfidInfoMapper;
@Mock
PermissionService permissionService;
@Mock
PaperUseLogEsGateway paperUseLogEsGateway;
@Mock
StringRedisTemplate redisTemplate;
@Mock
HashOperations<String, Object, Object> hashOperations;
DistrictPaperTraceTools tools;
LocalDate today;
String start;
String end;
@BeforeEach
void setUp() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
DistrictDistrictStrategy.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
RfidInfo.class);
tools = new DistrictPaperTraceTools(districtMapper, rfidInfoMapper, permissionService,
paperUseLogEsGateway, redisTemplate);
User user = new User();
user.setId(7L);
AuthContextHolder.set(new AuthContext(null, user));
today = LocalDate.now(SH);
start = today.minusDays(6).toString();
end = today.toString();
}
@AfterEach
void tearDown() {
AuthContextHolder.clear();
}
private void mockDistrictAndDictionary() {
DistrictDistrictStrategy district = new DistrictDistrictStrategy();
district.setId(9L);
district.setDistrictName("测试站点");
when(districtMapper.selectOne(any())).thenReturn(district);
lenient().when(redisTemplate.opsForHash()).thenReturn(hashOperations);
// LocalEsDate 在区间之外 → 不跨界;距今久远 → 云集群
lenient().when(hashOperations.get("Dictionary", "LocalEsDate")).thenReturn("2000-01-01");
}
private static PaperUseLogEsGateway.TraceRecord trace(String createTime, Integer state, String rfid,
String snCode, Integer lengthCm, Integer paperlengthCm,
Long positionsId, Long toiletId, Integer gender,
Integer deviceType) {
return new PaperUseLogEsGateway.TraceRecord(createTime, state, rfid, snCode, lengthCm, paperlengthCm,
positionsId, toiletId, gender, deviceType);
}
private static RfidInfo assignedRfid(String rfid) {
RfidInfo rfidInfo = new RfidInfo();
rfidInfo.setId(1L);
rfidInfo.setSoftDelete(0);
rfidInfo.setRfid(rfid);
rfidInfo.setAssignmentFlag(1);
return rfidInfo;
}
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> recordsOf(Map<String, Object> result) {
return (List<Map<String, Object>>) result.get("records");
}
@Test
void rejectsInvalidArgumentsBeforeAnyQuery() {
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperTraceListQuery(
null, start, end, null, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperTraceListQuery(
0L, start, end, null, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperTraceListQuery(
9L, "2026/09/01", end, null, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperTraceListQuery(
9L, end, start, null, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperTraceListQuery(
9L, start, today.plusDays(1).toString(), null, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperTraceListQuery(
9L, start, end, null, null, 3, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperTraceListQuery(
9L, start, end, null, null, null, 2, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperTraceListQuery(
9L, today.minusDays(40).toString(), end, null, null, null, null, null, null));
verifyNoInteractions(districtMapper, rfidInfoMapper, paperUseLogEsGateway, redisTemplate);
}
@Test
void rejectsInvisibleDistrict() {
when(districtMapper.selectOne(any())).thenReturn(null);
IllegalStateException exception = assertThrows(IllegalStateException.class,
() -> tools.districtPaperTraceListQuery(9L, start, end, null, null, null, null, null, null));
assertEquals("站点不存在或无权访问", exception.getMessage());
verify(permissionService).applyDistrictRowFilter(any(), any());
verifyNoInteractions(paperUseLogEsGateway, redisTemplate);
}
@Test
void failsFastWhenLocalEsDateDictionaryMissing() {
DistrictDistrictStrategy district = new DistrictDistrictStrategy();
district.setId(9L);
when(districtMapper.selectOne(any())).thenReturn(district);
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
when(hashOperations.get("Dictionary", "LocalEsDate")).thenReturn(null);
assertThrows(IllegalStateException.class,
() -> tools.districtPaperTraceListQuery(9L, start, end, null, null, null, null, null, null));
verifyNoInteractions(paperUseLogEsGateway);
}
@Test
void rejectsRangeCrossingLocalEsDateBoundary() {
mockDistrictAndDictionary();
LocalDate boundary = today.minusDays(3);
when(hashOperations.get("Dictionary", "LocalEsDate")).thenReturn(boundary.toString());
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> tools.districtPaperTraceListQuery(9L, start, end, null, null, null, null, null, null));
assertTrue(exception.getMessage().contains("本地化数据分界日"));
verifyNoInteractions(paperUseLogEsGateway);
}
@Test
void routesColdIndexAndMapsRecords() {
mockDistrictAndDictionary();
String coldStart = today.minusDays(100).toString();
String coldEnd = today.minusDays(95).toString();
List<PaperUseLogEsGateway.TraceRecord> traces = List.of(
trace("2026-07-01T10:00:00.000+08:00", 2, "RF-1", "SN-1", 150, 18000, 11L, 3L, 0, 0),
trace("2026-07-01T11:00:00.000+08:00", 4, "RF-2", "SN-1", 80, 9000, 0L, 3L, 0, 0),
trace("2026-07-01T12:00:00.000+08:00", 2, null, "SN-2", 200, 18000, 5L, 4L, 99, 1));
when(paperUseLogEsGateway.searchTraces(eq("paperuselog_all"), anyBoolean(), eq(9L),
anyLong(), anyLong(), isNull(), isNull(), isNull(), isNull(), anyInt(), anyInt()))
.thenReturn(new PaperUseLogEsGateway.TraceSearchResult(42L, traces));
when(rfidInfoMapper.selectList(any())).thenReturn(List.of(assignedRfid("RF-1")));
Map<String, Object> result = tools.districtPaperTraceListQuery(
9L, coldStart, coldEnd, null, null, null, null, null, null);
assertEquals(9L, result.get("districtId"));
assertEquals("测试站点", result.get("districtName"));
assertEquals("paperuselog_all", result.get("index"));
assertEquals(42L, result.get("total"));
assertEquals(1, result.get("page"));
assertEquals(20, result.get("rows"));
assertNull(result.get("notice"));
List<Map<String, Object>> records = recordsOf(result);
assertEquals(3, records.size());
// 上纸行:stateText=上纸、genderText、isAssignment=true(rfid_info assignment_flag≥1)
Map<String, Object> first = records.get(0);
assertEquals("2026-07-01T10:00:00.000+08:00", first.get("createTime"));
assertEquals(2, first.get("state"));
assertEquals("上纸", first.get("stateText"));
assertEquals("RF-1", first.get("rfid"));
assertEquals(150, first.get("lengthCm"));
assertEquals(18000, first.get("paperlengthCm"));
assertEquals(11L, first.get("positionsId"));
assertEquals(3L, first.get("toiletId"));
assertEquals(0, first.get("gender"));
assertEquals("男", first.get("genderText"));
assertEquals(0, first.get("deviceType"));
assertEquals(Boolean.TRUE, first.get("isAssignment"));
// 撤纸行:positionsId=0 为厕所级;RF-2 不在 rfid_info 命中列表 → false
Map<String, Object> second = records.get(1);
assertEquals(4, second.get("state"));
assertEquals("撤纸", second.get("stateText"));
assertEquals(0L, second.get("positionsId"));
assertEquals(Boolean.FALSE, second.get("isAssignment"));
// rfid 为空 → isAssignment=false;未知 gender 编码 genderText=null
Map<String, Object> third = records.get(2);
assertNull(third.get("rfid"));
assertEquals(Boolean.FALSE, third.get("isAssignment"));
assertEquals(99, third.get("gender"));
assertNull(third.get("genderText"));
}
@Test
void truncatesRowsAbove100WithNotice() {
mockDistrictAndDictionary();
when(paperUseLogEsGateway.searchTraces(any(), anyBoolean(), anyLong(), anyLong(), anyLong(),
isNull(), isNull(), isNull(), isNull(), anyInt(), eq(100)))
.thenReturn(new PaperUseLogEsGateway.TraceSearchResult(0L, List.of()));
Map<String, Object> result = tools.districtPaperTraceListQuery(
9L, start, end, null, null, null, null, null, 500);
assertEquals(100, result.get("rows"));
assertEquals("rows 超过上限 100,已截断为 100", result.get("notice"));
}
@Test
void passesThroughOptionalFiltersAndPagination() {
mockDistrictAndDictionary();
when(paperUseLogEsGateway.searchTraces(eq("paperuselog"), eq(false), eq(9L),
anyLong(), anyLong(), eq("SN-1"), eq("RF-1"), eq(4), eq(1), eq(40), eq(20)))
.thenReturn(new PaperUseLogEsGateway.TraceSearchResult(100L, List.of()));
Map<String, Object> result = tools.districtPaperTraceListQuery(
9L, start, end, " SN-1 ", " RF-1 ", 4, 1, 3, 20);
assertEquals("paperuselog", result.get("index"));
assertEquals(100L, result.get("total"));
assertEquals(3, result.get("page"));
assertEquals(20, result.get("rows"));
assertNull(result.get("notice"));
assertTrue(recordsOf(result).isEmpty());
}
@Test
void emptyRecordsSkipRfidInfoQuery() {
mockDistrictAndDictionary();
when(paperUseLogEsGateway.searchTraces(any(), anyBoolean(), anyLong(), anyLong(), anyLong(),
isNull(), isNull(), isNull(), isNull(), anyInt(), anyInt()))
.thenReturn(new PaperUseLogEsGateway.TraceSearchResult(0L, List.of()));
Map<String, Object> result = tools.districtPaperTraceListQuery(
9L, start, end, null, null, null, null, null, null);
assertTrue(recordsOf(result).isEmpty());
verifyNoInteractions(rfidInfoMapper);
}
@Test
void queryEndBeforeTodayUsesInclusiveEndOfDay() {
mockDistrictAndDictionary();
when(paperUseLogEsGateway.searchTraces(any(), anyBoolean(), anyLong(), anyLong(), anyLong(),
isNull(), isNull(), isNull(), isNull(), anyInt(), anyInt()))
.thenReturn(new PaperUseLogEsGateway.TraceSearchResult(0L, List.of()));
String endDay = today.minusDays(1).toString();
tools.districtPaperTraceListQuery(9L, start, endDay, null, null, null, null, null, null);
ArgumentCaptor<Long> fromCaptor = ArgumentCaptor.forClass(Long.class);
ArgumentCaptor<Long> toCaptor = ArgumentCaptor.forClass(Long.class);
verify(paperUseLogEsGateway).searchTraces(any(), anyBoolean(), anyLong(),
fromCaptor.capture(), toCaptor.capture(),
isNull(), isNull(), isNull(), isNull(), anyInt(), anyInt());
long expectedFrom = today.minusDays(6).atStartOfDay(SH).toInstant().toEpochMilli();
long expectedTo = today.atStartOfDay(SH).toInstant().toEpochMilli() - 1;
assertEquals(expectedFrom, fromCaptor.getValue());
assertEquals(expectedTo, toCaptor.getValue());
}
@Test
void springAiPublishesToolSchema() {
org.springframework.ai.tool.ToolCallback[] callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider
.builder().toolObjects(tools).build().getToolCallbacks();
assertEquals(1, callbacks.length);
assertEquals("district.paper.trace.list.query",
callbacks[0].getToolDefinition().name());
String schema = callbacks[0].getToolDefinition().inputSchema();
assertTrue(schema.contains("districtId"));
assertTrue(schema.contains("startDate"));
assertTrue(schema.contains("snCode"));
assertTrue(schema.contains("rfid"));
assertTrue(schema.contains("state"));
}
}