diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md
index b9e117b..e19244e 100644
--- a/xiaoqu-agent-service/README.md
+++ b/xiaoqu-agent-service/README.md
@@ -91,6 +91,7 @@ xiaoqu-agent-service/
| `install.order.detail.query` | workorder | read | 装机工单详情;返回明细、作业人员、地址、商城订单、流程、配发 SN、出入库关联及凭证 |
| `pickup.order.list.query` | workorder | read | 取货工单分页查询;申请/取货/完成日期独立过滤,物流按页批量富化 |
| `pickup.order.detail.query` | workorder | read | 取货工单详情;返回物流、取货物品、SN 调整、附件、流程及关联单据 |
+| `district.paper.statistics.query` | hardware | read | 客户用纸统计(复刻老 /MonthlyStatistics/getMonthlyStatisticsMap)。入参 districtId/startDate/endDate/positionMode/deviceType/buildingId/floor/toiletId;出参 toiletGenders、summaryByGender(paperLengthM 米/useCount/perUseLengthM/deviceDayCount/rfidCount)、daily 按日明细。DB 聚合 m0 monthly_statistics;rfidCount 查 ES paperuselog(90 天热/冷索引,LocalEsDate 云/本地集群分界,rfid_use_flag 分口径)。详细设计见 doc/district-paper-statistics-design.md |
分页统一规范:page 从 1 开始,rows 默认 20、上限 100,超出截断为 100 并在返回 `notice` 字段提示。
diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md
index b0a9cb9..b131a2c 100644
--- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md
+++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md
@@ -744,6 +744,34 @@
---
+## 30. district.paper.statistics.query
+
+客户用纸统计,1:1 复刻老系统 `POST /MonthlyStatistics/getMonthlyStatisticsMap`(MonthlyStatisticsController),
+分主体统计(DB)与换纸卷数(ES)两部分。
+
+- 分类:hardware | 类型:读
+- 主体数据来源:m0 `monthly_statistics`(XXL-Job 预聚合的站点日汇总,每站点每厕位/厕每天一行),
+ `statistics_time` 范围 [startDate 00:00:00, endDate 23:59:59],可选 `buildingId/floor/toiletId/deviceType` 精确过滤。
+- 入参:`districtId`(必填正整数)、`startDate`/`endDate`(必填 yyyy-MM-dd,startDate≤endDate≤今天)、
+ `positionMode`(position=仅厕位/wall=仅厕墙,只作用于 ES 换纸卷数口径)、`deviceType`(0/1)、`buildingId`、`floor`、`toiletId`。
+- 聚合维度 gender(0男1女2母婴3残卫4第三5其他99未知):
+ `paperLengthM`=Σpaper_length/100(米,3 位小数)、`useCount`=Σuse_count(仅厕位模式 runl_mode=0 的厕位行累计,厕墙行恒 0)、
+ `deviceDayCount`=按日 distinct 厕位/厕数之和(老系统 perPositionsCount,输出改名)、
+ `perUseLengthM`=paperLengthM/useCount(useCount=0 为 null);另输出按日明细(date/gender/paperLengthM/useCount/deviceDayCount)。
+- 换纸卷数 `rfidCount`(ES):读 `district_strategy.rfid_use_flag`(行 id=站点 id)——
+ =1 时 state∈{2上纸,4撤纸} 合并后 rfid 去重数(terms 聚合 size 10000 统计 bucket 数,超出部分不计);
+ ≠1 或记录缺失时取 state=2 精确 count(不含撤纸、不去重)。仅对 DB 结果中出现过的 gender 逐组查询。
+- ES 口径:索引按 startDate 距今 90 天分冷热(`paperuselog` / `paperuselog_all`,老 isHotTime 硬编码);
+ 集群按 Redis `Dictionary.LocalEsDate`(yyyy-MM-dd)分界——分界日 > endDate 走本地集群,
+ startDate < 分界日 < endDate 拒绝查询;`Dictionary.StatisticsDays` 限制查询天数上限;字典缺失 fail fast 抛 `IllegalStateException`。
+ endDate=今天时 ES 上界取今天 00:00(当天数据未跑完),否则 23:59:59(含边界)。
+- 行级权限:站点可见性沿用 `district.detail.get` 模式(id + soft_delete=0 + applyDistrictRowFilter),
+ 查不到统一抛 `站点不存在或无权访问`。
+- 与老系统的差异(详见 `district-paper-statistics-design.md`):ES 异常不静默为 0 而是抛错;
+ rfid 去重用 terms 聚合替代 scroll 拉全量;district_strategy 缺失/空 rfid_use_flag 按"未使用 rfid"口径而非 NPE。
+
+---
+
## 附录:在线查看工具清单
```bash
diff --git a/xiaoqu-agent-service/doc/district-paper-statistics-design.md b/xiaoqu-agent-service/doc/district-paper-statistics-design.md
new file mode 100644
index 0000000..771ff8d
--- /dev/null
+++ b/xiaoqu-agent-service/doc/district-paper-statistics-design.md
@@ -0,0 +1,161 @@
+# district.paper.statistics.query 设计文档(客户用纸统计 MCP 工具)
+
+> 状态:已实现(dev2/paper-statistics-mcp)
+> 工具:`district.paper.statistics.query`(@XqTool category=hardware, kind=read)
+
+## 1. 背景
+
+管理端/Agent 需要按站点查询客户用纸统计:各性别分组的用纸长度、使用人次、台日合计、换纸卷数及按日明细。
+老系统接口为 `POST /MonthlyStatistics/getMonthlyStatisticsMap`(xiaoqu-franchisee
+`MonthlyStatisticsController.java:106-355`)。本工具 1:1 复刻其统计口径,作为 MCP 只读工具暴露。
+
+## 2. 老系统口径复刻
+
+### 2.1 主体统计(DB 部分)
+
+- 表:m0 库 `monthly_statistics`(每站点每厕位/厕每天一行,XXL-Job 预聚合;实体照老
+ `beans/b0/MonthlyStatistics.java`)。
+- 过滤:`soft_delete=0`、`district_id` 精确、`statistics_time ∈ [startDate 00:00:00, endDate 23:59:59]`
+ (实现为左闭右开 `[start, end+1)` 避免秒精度丢行);可选 `building_id/floor/toilet_id/device_type` 精确匹配。
+- 聚合维度 `gender`(gender 为 null 的行跳过,老 :178-180):
+ - `paperLengthM` = Σpaper_length / 100(paper_length 单位厘米,输出米,3 位小数);
+ - `useCount` = Σuse_count(**仅 position_id>0 且 runl_mode=0 的厕位模式行累计**,厕墙行恒 0;
+ 这是老 :200-203、:220-222 分支的意图——老 :188 的日分组已存在分支漏判 runlMode,按意图统一);
+ - `deviceDayCount` = 按日 distinct 厕位/厕数之和(老 :190-192、:249:positionSet 存
+ `p_{positionId}`(厕位行)与 `t_{toiletId}`(厕墙行),每天取 set size 再累加)。
+ 老出参字段名 perPositionsCount 注释有误,本工具改名 deviceDayCount);
+ - `perUseLengthM` = paperLengthM / useCount(useCount=0 时为 null)。
+- 按日明细:date(yyyy-MM-dd)+ 上述指标按 (date, gender) 输出。
+- `toiletGenders`:老系统查 Toilet 表取 gender 集合(老 :147-155);本工具直接取 DB 结果中 distinct gender
+ (本服务未引入 Toilet 实体,且换纸卷数本来就只对 DB 结果中出现过的 gender 查询,两者口径天然一致)。
+
+### 2.2 换纸卷数 rfidCount(ES 部分,本次引入 ES 客户端)
+
+- 索引:`paperuselog`(热)/ `paperuselog_all`(冷)。startDate 距今 <90 天走热索引,
+ 否则冷索引(老 `DateToConverter.isHotTime`,:324-336,90 天硬编码)。
+- 集群:Redis db5 `Dictionary` hash 的 `LocalEsDate`(yyyy-MM-dd,云/本地 ES 数据分界日):
+ - `localEsDate > endDate` → 本地集群;
+ - `startDate < localEsDate < endDate`(与老代码同为严格小于比较)→ 拒绝:
+ `查询范围跨越本地化数据分界日 {localEsDate}`;
+ - 其他 → 云集群。
+- 天数上限:Redis `Dictionary.StatisticsDays`(Integer);查询天数 = endDate-startDate+1,
+ 超限抛 `查询时间段最大{N}天`。
+- 时间边界(epoch 毫秒,Asia/Shanghai):start = startDate 00:00:00;
+ endDate=今天 → end = 今天 00:00:00(排除当天,当天数据未跑完,老 :260-268),
+ 否则 end = endDate 23:59:59(含边界;实现为 `(endDate+1)00:00:00 - 1ms`)。
+- 查询条件(对齐老 ElasticsearchPublic 语义:数值 term、字符串 match,BaseSearchSupportImpl2 :688-716):
+ - term `districtId`、term `gender`(每组 gender 各查一次,与老 :272 一致);
+ - range `createTime` [gte, lte] 含边界;
+ - `deviceType` 非空时 match `deviceType`(老代码该参数为字符串走 matchQuery);
+ - `positionMode`:position → range `positionsId > 0`;wall → term `positionsId = 0`;不传不加。
+- 口径分支:读 m0 库 `district_strategy`(行 id=站点 id,与老 `districtStrategyService.getById` 一致)
+ 的 `rfid_use_flag`:
+ - =1(使用 rfid):state ∈ {2上纸, 4撤纸} 合并后 rfid 去重数。实现用 terms 聚合
+ (field `rfid`, size 10000)统计 bucket 数,**不做 scroll 拉全量**;
+ ⚠️ 去重上限 10000,单站点单 gender 查询区间内不同 rfid 超过 10000 时超出部分不计
+ (老系统 scroll 全量无此上限,这是本实现的有意取舍:省内存、降耗时,正常站点量级远低于此)。
+ - ≠1 或记录不存在 / rfid_use_flag 为 null:state=2 记录数(精确 count,不含撤纸、不去重)。
+
+## 3. 与本仓库既有约定的关系
+
+- 工具类 `mcp/tool/DistrictPaperStatisticsTools.java`:@Component + ToolObject,构造器注入;
+ `@XqTool(category="hardware", kind="read")` + `@Tool` + `@ToolParam`;返回 LinkedHashMap 白名单拼装;
+ 参数错误抛 IllegalArgumentException,权限/字典/ES 错误抛 IllegalStateException。
+- 站点可见性:district_district_strategy eq id + soft_delete=0 后套
+ `PermissionService.applyDistrictRowFilter`,查不到抛 `站点不存在或无权访问`(同 district.detail.get 模式)。
+- 字典读取:`redisTemplate.opsForHash().get("Dictionary", field)`,与 PermissionService 同一 redis(db5);
+ 缺失 fail fast。
+
+## 4. ES 客户端接入(本次新增)
+
+- 依赖:`co.elastic.clients:elasticsearch-java:7.17.29`(pom.xml **显式钉版本**——Spring Boot 3.5.16 BOM
+ 管理的是 elasticsearch 8.x,老集群为 7.x 必须用 7.17 客户端)。传递引入
+ `org.elasticsearch.client:elasticsearch-rest-client`(低阶)与 jakarta json。
+- 配置:`app.es.cloud.{hosts,port,username,password}` / `app.es.local.{...}`(application.yml 公共默认值,
+ application-dev.yml 明文 dev 值:cloud=192.168.1.152:9200、local=127.0.0.1:9200 无账号;
+ application-prod.yml 全部 `${SVC_ES_CLOUD_*}` / `${SVC_ES_LOCAL_*}` 环境变量占位,hosts 支持逗号多节点)。
+- `config/EsClientConfig.java`:低阶 `org.elasticsearch.client.RestClient`(HttpHost 多节点、
+ BasicCredentialsProvider、connect 1000ms / socket 60000ms)注册为 bean(destroyMethod=close),
+ 上套 `ElasticsearchClient`(JacksonJsonpMapper,classpath 已有 jackson)两个 bean:
+ `esCloudClient` / `esLocalClient`。
+- `es/PaperUseLogEsGateway` 接口(便于单测 mock)+ `PaperUseLogEsGatewayImpl`:
+ `distinctRfidCount(...)` / `countState2(...)`,签名收 `index, localCluster, districtId, gender,
+ fromMillis, toMillis, deviceType, positionMode`;集群与索引选择不在实现内。
+- `es/PaperUseLogEsPlanner`:索引/集群/跨界判断纯函数(收 LocalDate),独立单测。
+
+## 5. 与老系统的差异点(有意为之)
+
+| 点 | 老系统 | 本工具 | 理由 |
+|---|---|---|---|
+| ES 异常 | try/catch 打印堆栈,计数静默为 0(:299-301、:320-322) | 抛 IllegalStateException | 静默为 0 会产出"换纸 0 卷"的误导性统计;fail fast 让调用方显式感知 |
+| rfid 去重实现 | count+分页拉全量文档,内存 Set 去重 | terms 聚合 size 10000 数 bucket | 不拉全量、低内存低耗时;上限 10000 对正常站点足够,超限部分不计(见 §2.2) |
+| district_strategy 不存在 / rfid_use_flag=null | NPE(:324-325) | 按"未使用 rfid"口径走 state=2 count | 该表与站点 1:1,缺失视为未启用 rfid;避免 500 |
+| Dictionary 字典缺失 | NPE 被外层 catch 吞掉,继续执行 | 抛 IllegalStateException | fail fast,避免权限/口径配置异常导致错误数据范围 |
+| use_count 聚合 | 厕位/厕墙/runl_mode 多分支,:188 已存在日分组分支漏判 runlMode | 仅 position_id>0 且 runl_mode=0 行累计 | 统一为老代码新分组分支的意图口径(厕位模式使用人次),消除老代码分支不一致的 bug |
+| DB 时间过滤 | between 含两端(秒精度) | 左闭右开 [start, end+1) | 等价且避免 23:59:59 之后毫秒级数据丢行 |
+
+## 6. 行级权限说明
+
+站点维度:`applyDistrictRowFilter`(总部全量;代理=自己+直属下级+代管授权;销售非负责人按创建范围),
+查不到与无权限统一抛 `站点不存在或无权访问`,防止枚举越权站点。
+ES 侧不带用户维度过滤:换纸记录跟随站点口径,站点可见即数据可见(与老系统一致)。
+
+## 7. 配置说明
+
+| 配置 | dev | prod |
+|---|---|---|
+| app.es.cloud.hosts / port | 192.168.1.152 / 9200 | `${SVC_ES_CLOUD_HOSTS}` / `${SVC_ES_CLOUD_PORT:9200}` |
+| app.es.cloud.username / password | 空(无认证) | `${SVC_ES_CLOUD_USERNAME:}` / `${SVC_ES_CLOUD_PASSWORD:}` |
+| app.es.local.hosts / port | 127.0.0.1 / 9200 | `${SVC_ES_LOCAL_HOSTS}` / `${SVC_ES_LOCAL_PORT:9200}` |
+| app.es.local.username / password | 空 | `${SVC_ES_LOCAL_USERNAME:}` / `${SVC_ES_LOCAL_PASSWORD:}` |
+
+username 为空则不设置认证(BasicCredentialsProvider 不挂载)。
+
+## 8. 出参示例
+
+```json
+{
+ "districtId": 10086,
+ "districtName": "示例站点",
+ "dateRange": {"start": "2026-09-01", "end": "2026-09-07"},
+ "toiletGenders": [
+ {"gender": 0, "genderText": "男"},
+ {"gender": 1, "genderText": "女"}
+ ],
+ "summaryByGender": [
+ {
+ "gender": 0,
+ "genderText": "男",
+ "paperLengthM": 1234.567,
+ "useCount": 890,
+ "perUseLengthM": 1.387,
+ "deviceDayCount": 42,
+ "rfidCount": 15
+ },
+ {
+ "gender": 1,
+ "genderText": "女",
+ "paperLengthM": 980.5,
+ "useCount": 0,
+ "perUseLengthM": null,
+ "deviceDayCount": 35,
+ "rfidCount": 11
+ }
+ ],
+ "daily": [
+ {"date": "2026-09-01", "gender": 0, "paperLengthM": 176.4, "useCount": 120, "deviceDayCount": 6}
+ ]
+}
+```
+
+所有长度字段单位均为米(字段名带 M 后缀);`useCount=0` 时 `perUseLengthM=null`。
+
+## 9. 单测
+
+- `DistrictPaperStatisticsToolsTest`(12 个):参数校验(日期格式/大小关系/今天上限/positionMode/deviceType)、
+ 站点不可见拒绝、字典缺失 fail fast、StatisticsDays 超限、LocalEsDate 跨界拒绝、
+ cm→m 换算与 gender/按日聚合与台日口径、rfid_use_flag=1/=1/记录缺失三口径分支、
+ 冷热索引选择、ES 结束时间边界(今天 00:00 vs 过去日 23:59:59.999)、positionMode/deviceType 透传。
+- `PaperUseLogEsPlannerTest`(3 个):90 天冷热索引边界、localEsDate 与 endDate 相等走云集群、
+ 跨界拒绝的严格小于口径。
+- ES gateway 在工具测试中全部 mock,不依赖真实 ES。
diff --git a/xiaoqu-agent-service/pom.xml b/xiaoqu-agent-service/pom.xml
index 3aed19a..e3f2bfc 100644
--- a/xiaoqu-agent-service/pom.xml
+++ b/xiaoqu-agent-service/pom.xml
@@ -79,6 +79,14 @@
spring-boot-starter-data-redis
+
+
+ co.elastic.clients
+ elasticsearch-java
+ 7.17.29
+
+
org.projectlombok
lombok
diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/EsClientConfig.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/EsClientConfig.java
new file mode 100644
index 0000000..23b53c6
--- /dev/null
+++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/EsClientConfig.java
@@ -0,0 +1,77 @@
+package me.iiv.agent.config;
+
+import co.elastic.clients.elasticsearch.ElasticsearchClient;
+import co.elastic.clients.json.jackson.JacksonJsonpMapper;
+import co.elastic.clients.transport.rest_client.RestClientTransport;
+import org.apache.http.HttpHost;
+import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.impl.client.BasicCredentialsProvider;
+import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
+import org.elasticsearch.client.RestClient;
+import org.elasticsearch.client.RestClientBuilder;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.util.StringUtils;
+
+import java.util.Arrays;
+
+/**
+ * 双集群 ES 客户端:cloud=云集群、local=本地化集群(老系统 Dictionary.LocalEsDate 分界)。
+ * 低阶 RestClient 也注册为 bean(destroyMethod=close 随容器关闭);
+ * 高阶 ElasticsearchClient 包装同一 RestClient,关闭 client 即关闭底层连接。
+ */
+@Configuration
+public class EsClientConfig {
+
+ @Bean(name = "esCloudRestClient", destroyMethod = "close")
+ public RestClient esCloudRestClient(@Value("${app.es.cloud.hosts}") String hosts,
+ @Value("${app.es.cloud.port:9200}") int port,
+ @Value("${app.es.cloud.username:}") String username,
+ @Value("${app.es.cloud.password:}") String password) {
+ return buildRestClient("cloud", hosts, port, username, password);
+ }
+
+ @Bean(name = "esCloudClient")
+ public ElasticsearchClient esCloudClient(@Qualifier("esCloudRestClient") RestClient restClient) {
+ return new ElasticsearchClient(new RestClientTransport(restClient, new JacksonJsonpMapper()));
+ }
+
+ @Bean(name = "esLocalRestClient", destroyMethod = "close")
+ public RestClient esLocalRestClient(@Value("${app.es.local.hosts}") String hosts,
+ @Value("${app.es.local.port:9200}") int port,
+ @Value("${app.es.local.username:}") String username,
+ @Value("${app.es.local.password:}") String password) {
+ return buildRestClient("local", hosts, port, username, password);
+ }
+
+ @Bean(name = "esLocalClient")
+ public ElasticsearchClient esLocalClient(@Qualifier("esLocalRestClient") RestClient restClient) {
+ return new ElasticsearchClient(new RestClientTransport(restClient, new JacksonJsonpMapper()));
+ }
+
+ private static RestClient buildRestClient(String cluster, String hosts, int port,
+ String username, String password) {
+ HttpHost[] httpHosts = Arrays.stream(hosts.split(","))
+ .map(String::trim)
+ .filter(StringUtils::hasText)
+ .map(host -> new HttpHost(host, port))
+ .toArray(HttpHost[]::new);
+ if (httpHosts.length == 0) {
+ throw new IllegalStateException("app.es." + cluster + ".hosts 未配置有效节点");
+ }
+ RestClientBuilder builder = RestClient.builder(httpHosts)
+ .setRequestConfigCallback(config ->
+ config.setConnectTimeout(1000).setSocketTimeout(60000));
+ if (StringUtils.hasText(username)) {
+ BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
+ credentialsProvider.setCredentials(AuthScope.ANY,
+ new UsernamePasswordCredentials(username, password));
+ builder.setHttpClientConfigCallback((HttpAsyncClientBuilder httpClientBuilder) ->
+ httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider));
+ }
+ return builder.build();
+ }
+}
diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/DistrictStrategy.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/DistrictStrategy.java
new file mode 100644
index 0000000..35d3081
--- /dev/null
+++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/DistrictStrategy.java
@@ -0,0 +1,36 @@
+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;
+
+/**
+ * district_strategy 站点策略(m0 库 xiaoqu_comples_d;老系统 beans/b0 走 dataSource0,
+ * 行 id=站点 id,与 district_district_strategy 一一对应)。
+ * 本服务只取 rfid_use_flag 判断换纸卷数口径。
+ */
+@Getter
+@Setter
+@ToString
+@TableName("district_strategy")
+public class DistrictStrategy implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @TableId(value = "id", type = IdType.AUTO)
+ private Long id;
+
+ /** 站点id */
+ private Long districtId;
+
+ /** rfid使用标记(0:未使用;1:使用) */
+ private Integer rfidUseFlag;
+
+ /** 数据状态 */
+ private Integer softDelete;
+}
diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/MonthlyStatistics.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/MonthlyStatistics.java
new file mode 100644
index 0000000..5d188ac
--- /dev/null
+++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/MonthlyStatistics.java
@@ -0,0 +1,81 @@
+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;
+
+/**
+ * monthly_statistics 站点用纸日统计(m0 库,每站点每厕位/厕每天一行,XXL-Job 预聚合)。
+ * 字段照搬老项目 xiaoqu-publicbeanandmap beans/b0/MonthlyStatistics.java。
+ */
+@Getter
+@Setter
+@ToString
+@TableName("monthly_statistics")
+public class MonthlyStatistics implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @TableId(value = "id", type = IdType.AUTO)
+ private Long id;
+
+ /** 统计时间(当天 0 点) */
+ private Date statisticsTime;
+
+ /** 数据状态 */
+ private Integer softDelete;
+
+ /** 代理商 */
+ private Long agentId;
+
+ /** 所属物业 */
+ private Long propertyId;
+
+ /** 所属区域/站点 */
+ private Long districtId;
+
+ /** 楼id */
+ private Long buildingId;
+
+ /** 楼层 */
+ private String floor;
+
+ /** 厕所id */
+ private Long toiletId;
+
+ /** 位置id(>0=厕位,=0=厕墙) */
+ private Long positionId;
+
+ /** 位置信息 */
+ private String position;
+
+ /** 性别(0男1女2母婴3残卫4第三5其他,无厕所时99) */
+ private Integer gender;
+
+ /** sn码 */
+ private String snCode;
+
+ /** 用纸长度,单位厘米 */
+ private Integer paperLength;
+
+ /** 有效使用人数(仅厕位模式有值,厕墙行恒 0) */
+ private Integer useCount;
+
+ /** 总人次 */
+ private Integer totalCount;
+
+ /** 出纸人次 */
+ private Integer paperCount;
+
+ /** 运行模式(0厕位模式) */
+ private Integer runlMode;
+
+ /** 设备类型(0出纸机1擦手纸机) */
+ private Integer deviceType;
+}
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
new file mode 100644
index 0000000..9155491
--- /dev/null
+++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGateway.java
@@ -0,0 +1,25 @@
+package me.iiv.agent.es;
+
+/**
+ * paperuselog(换纸记录)ES 查询网关。集群选择(云/本地)与索引选择(热/冷)
+ * 由调用方通过参数传入,本接口只做单次查询,便于单测 mock。
+ */
+public interface PaperUseLogEsGateway {
+
+ /**
+ * rfid 去重数:state ∈ {2上纸, 4撤纸} 合并后 rfid 去重(terms 聚合统计 bucket 数,
+ * size 上限 10000,超出部分不计——见设计文档)。
+ *
+ * @param index paperuselog / paperuselog_all
+ * @param localCluster true=本地集群,false=云集群
+ */
+ long distinctRfidCount(String index, boolean localCluster, Long districtId, Integer gender,
+ Long fromMillis, Long toMillis, Integer deviceType, String positionMode);
+
+ /**
+ * state=2(上纸)记录数(精确 count,不含 state=4、不去重)。
+ * 参数含义同 {@link #distinctRfidCount}。
+ */
+ long countState2(String index, boolean localCluster, Long districtId, Integer gender,
+ Long fromMillis, Long toMillis, Integer deviceType, String positionMode);
+}
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
new file mode 100644
index 0000000..32c4edc
--- /dev/null
+++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsGatewayImpl.java
@@ -0,0 +1,97 @@
+package me.iiv.agent.es;
+
+import co.elastic.clients.elasticsearch.ElasticsearchClient;
+import co.elastic.clients.elasticsearch._types.FieldValue;
+import co.elastic.clients.elasticsearch._types.query_dsl.Query;
+import co.elastic.clients.json.JsonData;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * paperuselog ES 查询实现(elasticsearch-java 7.17 强类型 API)。
+ * 查询口径对齐老 MonthlyStatisticsController.getMonthlyStatisticsMap:
+ * term districtId/gender、range createTime [gte,lte](含边界)、deviceType 用 match(老代码字符串参数走 match),
+ * positionMode 厕位=positionsId>0、厕墙=positionsId=0。
+ * ES 异常不静默,抛 IllegalStateException(与老系统静默为 0 不同,见设计文档)。
+ */
+@Component
+public class PaperUseLogEsGatewayImpl implements PaperUseLogEsGateway {
+
+ /** rfid terms 聚合去重上限,超出部分不计入 */
+ private static final int RFID_TERMS_SIZE = 10000;
+
+ private final ElasticsearchClient cloudClient;
+ private final ElasticsearchClient localClient;
+
+ public PaperUseLogEsGatewayImpl(@Qualifier("esCloudClient") ElasticsearchClient cloudClient,
+ @Qualifier("esLocalClient") ElasticsearchClient localClient) {
+ this.cloudClient = cloudClient;
+ this.localClient = localClient;
+ }
+
+ @Override
+ public long distinctRfidCount(String index, boolean localCluster, Long districtId, Integer gender,
+ Long fromMillis, Long toMillis, Integer deviceType, String positionMode) {
+ List filters = baseFilters(districtId, gender, fromMillis, toMillis, deviceType, positionMode);
+ // 上纸(2)+撤纸(4) 合并后 rfid 去重(老 :302-342)
+ filters.add(Query.of(q -> q.terms(t -> t.field("state")
+ .terms(tt -> tt.value(List.of(FieldValue.of(2), FieldValue.of(4)))))));
+ try {
+ return client(localCluster).search(s -> s
+ .index(index)
+ .size(0)
+ .trackTotalHits(t -> t.enabled(true))
+ .query(q -> q.bool(b -> b.filter(filters)))
+ .aggregations("rfids", a -> a.terms(t -> t.field("rfid").size(RFID_TERMS_SIZE))),
+ Void.class)
+ .aggregations().get("rfids").sterms().buckets().array().size();
+ } catch (IOException | RuntimeException e) {
+ throw new IllegalStateException("ES 查询换纸 rfid 去重失败: " + e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public long countState2(String index, boolean localCluster, Long districtId, Integer gender,
+ Long fromMillis, Long toMillis, Integer deviceType, String positionMode) {
+ List filters = baseFilters(districtId, gender, fromMillis, toMillis, deviceType, positionMode);
+ filters.add(Query.of(q -> q.term(t -> t.field("state").value(FieldValue.of(2)))));
+ try {
+ return client(localCluster).count(c -> c.index(index)
+ .query(q -> q.bool(b -> b.filter(filters)))).count();
+ } catch (IOException | RuntimeException e) {
+ throw new IllegalStateException("ES 查询换纸上纸记录数失败: " + e.getMessage(), e);
+ }
+ }
+
+ private List baseFilters(Long districtId, Integer gender, Long fromMillis, Long toMillis,
+ Integer deviceType, String positionMode) {
+ List filters = new ArrayList<>();
+ filters.add(Query.of(q -> q.term(t -> t.field("districtId").value(FieldValue.of(districtId)))));
+ filters.add(Query.of(q -> q.term(t -> t.field("gender").value(FieldValue.of(gender)))));
+ // createTime 为 epoch 毫秒;含边界(老 rangeQuery from/to 均 inclusive)
+ filters.add(Query.of(q -> q.range(r -> r.field("createTime")
+ .gte(JsonData.of(fromMillis)).lte(JsonData.of(toMillis)))));
+ if (deviceType != null) {
+ // 老代码 deviceType 为字符串参数走 matchQuery(BaseSearchSupportImpl2 type=0 非数值分支)
+ filters.add(Query.of(q -> q.match(m -> m.field("deviceType")
+ .query(FieldValue.of(String.valueOf(deviceType))))));
+ }
+ if (StringUtils.hasText(positionMode)) {
+ if ("position".equals(positionMode)) {
+ filters.add(Query.of(q -> q.range(r -> r.field("positionsId").gt(JsonData.of(0)))));
+ } else {
+ filters.add(Query.of(q -> q.term(t -> t.field("positionsId").value(FieldValue.of(0)))));
+ }
+ }
+ return filters;
+ }
+
+ private ElasticsearchClient client(boolean localCluster) {
+ return localCluster ? localClient : cloudClient;
+ }
+}
diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsPlanner.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsPlanner.java
new file mode 100644
index 0000000..e3a5725
--- /dev/null
+++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/es/PaperUseLogEsPlanner.java
@@ -0,0 +1,36 @@
+package me.iiv.agent.es;
+
+import java.time.LocalDate;
+
+/**
+ * paperuselog 查询的索引/集群选择纯函数(老 DateToConverter.isHotTime / MonthlyStatisticsController
+ * 的 LocalEsDate 判断),抽成静态方法便于单测。常量与比较口径 1:1 对齐老系统。
+ */
+public final class PaperUseLogEsPlanner {
+
+ public static final String HOT_INDEX = "paperuselog";
+ public static final String COLD_INDEX = "paperuselog_all";
+
+ /** 老 isHotTime 硬编码 90 天:startDate 距今 <90 天走热索引,否则冷索引 */
+ private static final int HOT_DAYS = 90;
+
+ private PaperUseLogEsPlanner() {
+ }
+
+ /** startDate >= 今天-90天 → 热索引 paperuselog,否则 paperuselog_all */
+ public static String resolveIndex(LocalDate startDate, LocalDate today) {
+ return startDate.isBefore(today.minusDays(HOT_DAYS)) ? COLD_INDEX : HOT_INDEX;
+ }
+
+ /** localEsDate > endDate → 整个区间在本地集群 */
+ public static boolean useLocalCluster(LocalDate localEsDate, LocalDate endDate) {
+ return localEsDate.isAfter(endDate);
+ }
+
+ /** startDate < localEsDate < endDate(与老代码同口径的严格小于)→ 跨界拒绝 */
+ public static void checkNotCrossingBoundary(LocalDate startDate, LocalDate endDate, LocalDate localEsDate) {
+ if (localEsDate.isBefore(endDate) && localEsDate.isAfter(startDate)) {
+ throw new IllegalArgumentException("查询范围跨越本地化数据分界日 " + localEsDate);
+ }
+ }
+}
diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/DistrictStrategyMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/DistrictStrategyMapper.java
new file mode 100644
index 0000000..99923c4
--- /dev/null
+++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/DistrictStrategyMapper.java
@@ -0,0 +1,7 @@
+package me.iiv.agent.mapper.m0;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import me.iiv.agent.entity.m0.DistrictStrategy;
+
+public interface DistrictStrategyMapper extends BaseMapper {
+}
diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/MonthlyStatisticsMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/MonthlyStatisticsMapper.java
new file mode 100644
index 0000000..0d8173d
--- /dev/null
+++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/MonthlyStatisticsMapper.java
@@ -0,0 +1,7 @@
+package me.iiv.agent.mapper.m0;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import me.iiv.agent.entity.m0.MonthlyStatistics;
+
+public interface MonthlyStatisticsMapper extends BaseMapper {
+}
diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictPaperStatisticsTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictPaperStatisticsTools.java
new file mode 100644
index 0000000..aea5e1a
--- /dev/null
+++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/DistrictPaperStatisticsTools.java
@@ -0,0 +1,319 @@
+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.DistrictStrategy;
+import me.iiv.agent.entity.m0.MonthlyStatistics;
+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.DistrictStrategyMapper;
+import me.iiv.agent.mapper.m0.MonthlyStatisticsMapper;
+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.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+
+/**
+ * 客户用纸统计(老 MonthlyStatisticsController.getMonthlyStatisticsMap 复刻):
+ * DB 部分聚合 m0 monthly_statistics(用纸长度/人次/台日),ES 部分(paperuselog)
+ * 按站点策略 rfid_use_flag 分口径统计换纸卷数。
+ */
+@Component
+public class DistrictPaperStatisticsTools implements ToolObject {
+
+ private static final String DICTIONARY_KEY = "Dictionary";
+ private static final String DICT_LOCAL_ES_DATE = "LocalEsDate";
+ private static final String DICT_STATISTICS_DAYS = "StatisticsDays";
+ private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
+ private static final DateTimeFormatter DAY_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+ final DistrictDistrictStrategyMapper districtMapper;
+ final MonthlyStatisticsMapper monthlyStatisticsMapper;
+ final DistrictStrategyMapper districtStrategyMapper;
+ final PermissionService permissionService;
+ final StringRedisTemplate redisTemplate;
+ final PaperUseLogEsGateway paperUseLogEsGateway;
+
+ public DistrictPaperStatisticsTools(DistrictDistrictStrategyMapper districtMapper,
+ MonthlyStatisticsMapper monthlyStatisticsMapper,
+ DistrictStrategyMapper districtStrategyMapper,
+ PermissionService permissionService,
+ StringRedisTemplate redisTemplate,
+ PaperUseLogEsGateway paperUseLogEsGateway) {
+ this.districtMapper = districtMapper;
+ this.monthlyStatisticsMapper = monthlyStatisticsMapper;
+ this.districtStrategyMapper = districtStrategyMapper;
+ this.permissionService = permissionService;
+ this.redisTemplate = redisTemplate;
+ this.paperUseLogEsGateway = paperUseLogEsGateway;
+ }
+
+ @XqTool(category = "hardware", kind = "read")
+ @Tool(name = "district.paper.statistics.query", description = "客户用纸统计:按站点和日期范围统计各性别分组的用纸长度(米)、"
+ + "使用人次、台日合计与换纸卷数,并给出按日明细。主体数据来自 m0 monthly_statistics 日汇总表;"
+ + "换纸卷数来自 ES paperuselog(热索引近 90 天,更早走冷索引 paperuselog_all;"
+ + "按 Redis LocalEsDate 分界选择云/本地集群,跨界查询会被拒绝)。"
+ + "dateRange 最大天数受 Redis StatisticsDays 限制。所有长度字段单位均为米。")
+ public Map districtPaperStatisticsQuery(
+ @ToolParam(description = "站点 id,正整数") Long districtId,
+ @ToolParam(description = "开始日期,yyyy-MM-dd,含当天") String startDate,
+ @ToolParam(description = "结束日期,yyyy-MM-dd,含当天,不得晚于今天") String endDate,
+ @ToolParam(required = false, description = "厕位/厕墙过滤:position=仅厕位、wall=仅厕墙,缺省不过滤") String positionMode,
+ @ToolParam(required = false, description = "设备类型:0=出纸机、1=擦手纸机,缺省不过滤") Integer deviceType,
+ @ToolParam(required = false, description = "楼 id,正整数,精确匹配") Long buildingId,
+ @ToolParam(required = false, description = "楼层,精确匹配") String floor,
+ @ToolParam(required = false, description = "厕所 id,正整数,精确匹配") Long toiletId) {
+
+ if (districtId == null) {
+ throw new IllegalArgumentException("districtId 必填");
+ }
+ validatePositive(districtId, "districtId");
+ validatePositive(buildingId, "buildingId");
+ validatePositive(toiletId, "toiletId");
+ LocalDate start = parseDay(startDate, "startDate");
+ LocalDate end = parseDay(endDate, "endDate");
+ LocalDate today = LocalDate.now();
+ if (start.isAfter(end)) {
+ throw new IllegalArgumentException("startDate 不能晚于 endDate");
+ }
+ if (end.isAfter(today)) {
+ throw new IllegalArgumentException("endDate 不得晚于今天");
+ }
+ if (StringUtils.hasText(positionMode) && !"position".equals(positionMode) && !"wall".equals(positionMode)) {
+ throw new IllegalArgumentException("positionMode 仅支持 position、wall");
+ }
+ if (deviceType != null && deviceType != 0 && deviceType != 1) {
+ throw new IllegalArgumentException("deviceType 仅支持 0、1");
+ }
+
+ 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("站点不存在或无权访问");
+ }
+
+ // 老 :121-143:LocalEsDate 集群分界 + StatisticsDays 天数上限;字典缺失 fail fast(老系统 NPE 被吞)
+ LocalDate localEsDate = parseDay(dictionaryValue(DICT_LOCAL_ES_DATE), DICT_LOCAL_ES_DATE);
+ int maxDays = Integer.parseInt(dictionaryValue(DICT_STATISTICS_DAYS).trim());
+ int queryDays = (int) ChronoUnit.DAYS.between(start, end) + 1;
+ if (queryDays > maxDays) {
+ throw new IllegalArgumentException("查询时间段最大" + maxDays + "天");
+ }
+ PaperUseLogEsPlanner.checkNotCrossingBoundary(start, end, localEsDate);
+ String index = PaperUseLogEsPlanner.resolveIndex(start, today);
+ boolean localCluster = PaperUseLogEsPlanner.useLocalCluster(localEsDate, end);
+
+ List rows = queryMonthlyStatistics(districtId, start, end,
+ buildingId, floor, toiletId, deviceType);
+ Map byGender = aggregate(rows);
+
+ DistrictStrategy strategy = districtStrategyMapper.selectOne(new LambdaQueryWrapper()
+ .eq(DistrictStrategy::getSoftDelete, 0)
+ .eq(DistrictStrategy::getId, districtId));
+ // 老 :324-325 对不存在/空 rfidUseFlag 会 NPE;这里按"未使用 rfid"口径走 state=2 count
+ boolean rfidUsed = strategy != null && strategy.getRfidUseFlag() != null
+ && strategy.getRfidUseFlag() == 1;
+
+ long fromMillis = start.atStartOfDay(SHANGHAI).toInstant().toEpochMilli();
+ // 老 :260-268:endDate=今天 → 上界取今天 00:00(当天数据未跑完),否则 23:59:59(含边界)
+ LocalDate endExclusive = end.equals(today) ? end : end.plusDays(1);
+ long toMillis = endExclusive.atStartOfDay(SHANGHAI).toInstant().toEpochMilli() - 1;
+
+ List