diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index 4498ff5..7eb45a7 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -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 | diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md index a0c81c0..31d120a 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.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)。 + +--- + --- ## 附录:在线查看工具清单 diff --git a/xiaoqu-agent-service/doc/paper-trace-list-mcp-design.md b/xiaoqu-agent-service/doc/paper-trace-list-mcp-design.md new file mode 100644 index 0000000..22c45a2 --- /dev/null +++ b/xiaoqu-agent-service/doc/paper-trace-list-mcp-design.md @@ -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 发布。 diff --git a/xiaoqu-agent-service/sql/seed-dev.sql b/xiaoqu-agent-service/sql/seed-dev.sql index f723935..64b26ca 100644 --- a/xiaoqu-agent-service/sql/seed-dev.sql +++ b/xiaoqu-agent-service/sql/seed-dev.sql @@ -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; -- 校验 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/RfidInfo.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/RfidInfo.java new file mode 100644 index 0000000..f109502 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/RfidInfo.java @@ -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; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGateway.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGateway.java index 9155491..9fcbf0d 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGateway.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGateway.java @@ -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 records; + + public TraceSearchResult(long total, List records) { + this.total = total; + this.records = records; + } + + public long getTotal() { + return total; + } + + public List 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; + } + } } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java index 51ee598..c5169ad 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java @@ -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 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(); diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/RfidInfoMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/RfidInfoMapper.java new file mode 100644 index 0000000..9ef72da --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/RfidInfoMapper.java @@ -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 { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictPaperTraceTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictPaperTraceTools.java new file mode 100644 index 0000000..6a1258c --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictPaperTraceTools.java @@ -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 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 districtWrapper = new LambdaQueryWrapper() + .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 assignedRfids = assignedRfids(search.getRecords()); + List> records = new ArrayList<>(); + for (PaperUseLogEsGateway.TraceRecord record : search.getRecords()) { + records.add(recordRow(record, assignedRfids)); + } + + Map 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 assignedRfids(List records) { + Set rfids = new LinkedHashSet<>(); + for (PaperUseLogEsGateway.TraceRecord record : records) { + if (StringUtils.hasText(record.getRfid())) { + rfids.add(record.getRfid()); + } + } + Set assigned = new HashSet<>(); + if (rfids.isEmpty()) { + return assigned; + } + List rfidInfos = rfidInfoMapper.selectList(new LambdaQueryWrapper() + .eq(RfidInfo::getSoftDelete, 0) + .in(RfidInfo::getRfid, rfids) + .ge(RfidInfo::getAssignmentFlag, 1)); + for (RfidInfo rfidInfo : rfidInfos) { + assigned.add(rfidInfo.getRfid()); + } + return assigned; + } + + private Map recordRow(PaperUseLogEsGateway.TraceRecord record, Set assignedRfids) { + Map 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; + } + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/es/PaperUseLogEsGatewayImplTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/es/PaperUseLogEsGatewayImplTest.java index 184b60d..d875103 100644 --- a/xiaoqu-agent-service/src/test/java/me/iiv/agent/es/PaperUseLogEsGatewayImplTest.java +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/es/PaperUseLogEsGatewayImplTest.java @@ -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 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 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 查询换纸记录明细失败")); + } } diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DistrictPaperTraceToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DistrictPaperTraceToolsTest.java new file mode 100644 index 0000000..414232a --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/DistrictPaperTraceToolsTest.java @@ -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 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> recordsOf(Map result) { + return (List>) 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 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 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> records = recordsOf(result); + assertEquals(3, records.size()); + // 上纸行:stateText=上纸、genderText、isAssignment=true(rfid_info assignment_flag≥1) + Map 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 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 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 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 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 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 fromCaptor = ArgumentCaptor.forClass(Long.class); + ArgumentCaptor 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")); + } +}