新增 district.paper.statistics.query 客户用纸统计工具(含 ES 7.17 客户端引入) #23

Merged
wangheng merged 2 commits from dev2/paper-statistics-mcp into main 2026-09-20 18:54:30 +08:00
18 changed files with 1365 additions and 0 deletions
+1
View File
@@ -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` 字段提示。
@@ -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
@@ -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。
+8
View File
@@ -79,6 +79,14 @@
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Elasticsearch 客户端:老集群为 7.x,必须显式钉 7.17(Boot BOM 管理的是 8.x)。
elasticsearch-java 传递引入 elasticsearch-rest-client(低阶 RestClient)与 jakarta json -->
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>7.17.29</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
@@ -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();
}
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
@@ -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<Query> 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<Query> 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<Query> baseFilters(Long districtId, Integer gender, Long fromMillis, Long toMillis,
Integer deviceType, String positionMode) {
List<Query> 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;
}
}
@@ -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);
}
}
}
@@ -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<DistrictStrategy> {
}
@@ -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<MonthlyStatistics> {
}
@@ -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<String, Object> 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<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("站点不存在或无权访问");
}
// 老 :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<MonthlyStatistics> rows = queryMonthlyStatistics(districtId, start, end,
buildingId, floor, toiletId, deviceType);
Map<Integer, GenderAgg> byGender = aggregate(rows);
DistrictStrategy strategy = districtStrategyMapper.selectOne(new LambdaQueryWrapper<DistrictStrategy>()
.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<Map<String, Object>> summaryByGender = new ArrayList<>();
List<Map<String, Object>> daily = new ArrayList<>();
List<Map<String, Object>> toiletGenders = new ArrayList<>();
for (Map.Entry<Integer, GenderAgg> entry : new TreeMap<>(byGender).entrySet()) {
Integer gender = entry.getKey();
GenderAgg agg = entry.getValue();
long rfidCount = rfidUsed
? paperUseLogEsGateway.distinctRfidCount(index, localCluster, districtId, gender,
fromMillis, toMillis, deviceType, positionMode)
: paperUseLogEsGateway.countState2(index, localCluster, districtId, gender,
fromMillis, toMillis, deviceType, positionMode);
Map<String, Object> item = new LinkedHashMap<>();
item.put("gender", gender);
item.put("genderText", genderText(gender));
item.put("paperLengthM", round3(agg.paperCm / 100.0));
item.put("useCount", agg.useCount);
item.put("perUseLengthM", agg.useCount > 0 ? round3(agg.paperCm / 100.0 / agg.useCount) : null);
item.put("deviceDayCount", agg.deviceDayCount());
item.put("rfidCount", rfidCount);
summaryByGender.add(item);
for (Map.Entry<String, DailyAgg> dayEntry : agg.daily.entrySet()) {
DailyAgg dayAgg = dayEntry.getValue();
Map<String, Object> dayItem = new LinkedHashMap<>();
dayItem.put("date", dayEntry.getKey());
dayItem.put("gender", gender);
dayItem.put("paperLengthM", round3(dayAgg.paperCm / 100.0));
dayItem.put("useCount", dayAgg.useCount);
dayItem.put("deviceDayCount", (long) dayAgg.positionKeys.size());
daily.add(dayItem);
}
Map<String, Object> genderItem = new LinkedHashMap<>();
genderItem.put("gender", gender);
genderItem.put("genderText", genderText(gender));
toiletGenders.add(genderItem);
}
Map<String, Object> dateRange = new LinkedHashMap<>();
dateRange.put("start", start.toString());
dateRange.put("end", end.toString());
Map<String, Object> result = new LinkedHashMap<>();
result.put("districtId", districtId);
result.put("districtName", district.getDistrictName());
result.put("dateRange", dateRange);
result.put("toiletGenders", toiletGenders);
result.put("summaryByGender", summaryByGender);
result.put("daily", daily);
return result;
}
private List<MonthlyStatistics> queryMonthlyStatistics(Long districtId, LocalDate start, LocalDate end,
Long buildingId, String floor, Long toiletId,
Integer deviceType) {
LambdaQueryWrapper<MonthlyStatistics> wrapper = new LambdaQueryWrapper<MonthlyStatistics>()
.eq(MonthlyStatistics::getSoftDelete, 0)
.eq(MonthlyStatistics::getDistrictId, districtId)
.ge(MonthlyStatistics::getStatisticsTime, Date.from(start.atStartOfDay(SHANGHAI).toInstant()))
// [start 00:00:00, end 23:59:59],用左闭右开避免秒精度丢行
.lt(MonthlyStatistics::getStatisticsTime, Date.from(end.plusDays(1).atStartOfDay(SHANGHAI).toInstant()));
if (buildingId != null) {
wrapper.eq(MonthlyStatistics::getBuildingId, buildingId);
}
if (StringUtils.hasText(floor)) {
wrapper.eq(MonthlyStatistics::getFloor, floor);
}
if (toiletId != null) {
wrapper.eq(MonthlyStatistics::getToiletId, toiletId);
}
if (deviceType != null) {
wrapper.eq(MonthlyStatistics::getDeviceType, deviceType);
}
return monthlyStatisticsMapper.selectList(wrapper);
}
/** 老 :176-249 聚合:gender 分组 + 按日分组;台日=每天 distinct 厕位/厕数之和(positionSet) */
private Map<Integer, GenderAgg> aggregate(List<MonthlyStatistics> rows) {
Map<Integer, GenderAgg> byGender = new LinkedHashMap<>();
for (MonthlyStatistics row : rows) {
if (row.getGender() == null) {
continue;
}
GenderAgg agg = byGender.computeIfAbsent(row.getGender(), gender -> new GenderAgg());
int paperLength = row.getPaperLength() == null ? 0 : row.getPaperLength();
int useCount = row.getUseCount() == null ? 0 : row.getUseCount();
agg.paperCm += paperLength;
boolean positionRow = row.getPositionId() != null && row.getPositionId() > 0;
// useCount 仅厕位模式(runl_mode=0)的厕位行累计,厕墙恒 0(老 :200-203/220-222 意图;
// 老代码日分组已存在分支漏判 runlMode,此处按意图统一,见设计文档差异点)
boolean countUse = positionRow && row.getRunlMode() != null && row.getRunlMode() == 0;
if (countUse) {
agg.useCount += useCount;
}
String day = DAY_FORMAT.format(row.getStatisticsTime().toInstant().atZone(SHANGHAI));
DailyAgg dailyAgg = agg.daily.computeIfAbsent(day, key -> new DailyAgg());
dailyAgg.paperCm += paperLength;
if (countUse) {
dailyAgg.useCount += useCount;
}
// 厕位行按 positionId、厕墙行按 toiletId 计台(老 "p_"/"t_" positionSet)
if (positionRow) {
dailyAgg.positionKeys.add("p_" + row.getPositionId());
} else {
dailyAgg.positionKeys.add("t_" + row.getToiletId());
}
}
return byGender;
}
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 void validatePositive(Long value, String name) {
if (value != null && value < 1) {
throw new IllegalArgumentException(name + " 必须为正整数");
}
}
private static double round3(double value) {
return BigDecimal.valueOf(value).setScale(3, RoundingMode.HALF_UP).doubleValue();
}
private static String genderText(Integer gender) {
if (gender == null) {
return "未知";
}
switch (gender) {
case 0: return "男";
case 1: return "女";
case 2: return "母婴";
case 3: return "残卫";
case 4: return "第三";
case 5: return "其他";
case 99: return "未知";
default: return "未知";
}
}
private static final class GenderAgg {
long paperCm;
long useCount;
final Map<String, DailyAgg> daily = new TreeMap<>();
long deviceDayCount() {
return daily.values().stream().mapToLong(day -> day.positionKeys.size()).sum();
}
}
private static final class DailyAgg {
long paperCm;
long useCount;
final Set<String> positionKeys = new LinkedHashSet<>();
}
}
@@ -36,6 +36,14 @@ app:
jdbc-url: jdbc:mysql://192.168.1.181:3306/xiaoqu_agent_svc_d?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: kaixinjiuhao
# ES 双集群(老 dev 值):cloud=云集群、local=本地化集群;无账号
es:
cloud:
hosts: 192.168.1.152
port: 9200
local:
hosts: 127.0.0.1
port: 9200
spring:
data:
@@ -27,6 +27,18 @@ app:
jdbc-url: ${SVC_DB_SVC_URL}
username: ${SVC_DB_SVC_USER}
password: ${SVC_DB_SVC_PASSWORD}
# ES 双集群:cloud=云集群、local=本地化集群(hosts 支持逗号多节点)
es:
cloud:
hosts: ${SVC_ES_CLOUD_HOSTS}
port: ${SVC_ES_CLOUD_PORT:9200}
username: ${SVC_ES_CLOUD_USERNAME:}
password: ${SVC_ES_CLOUD_PASSWORD:}
local:
hosts: ${SVC_ES_LOCAL_HOSTS}
port: ${SVC_ES_LOCAL_PORT:9200}
username: ${SVC_ES_LOCAL_USERNAME:}
password: ${SVC_ES_LOCAL_PASSWORD:}
spring:
data:
@@ -32,6 +32,19 @@ app:
rate-limit:
query-per-minute: ${QUERY_RATE_LIMIT_PER_MINUTE:600}
sync-per-minute: ${SYNC_RATE_LIMIT_PER_MINUTE:120}
# 双集群 ES(paperuselog 换纸记录):cloud=云集群、local=本地化集群;
# hosts 支持逗号多节点,username 为空 = 不设认证;各环境在 application-{env}.yml 覆盖
es:
cloud:
hosts: ${SVC_ES_CLOUD_HOSTS:}
port: ${SVC_ES_CLOUD_PORT:9200}
username: ${SVC_ES_CLOUD_USERNAME:}
password: ${SVC_ES_CLOUD_PASSWORD:}
local:
hosts: ${SVC_ES_LOCAL_HOSTS:}
port: ${SVC_ES_LOCAL_PORT:9200}
username: ${SVC_ES_LOCAL_USERNAME:}
password: ${SVC_ES_LOCAL_PASSWORD:}
spring:
application:
@@ -0,0 +1,51 @@
package me.iiv.agent.es;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PaperUseLogEsPlannerTest {
private static final LocalDate TODAY = LocalDate.of(2026, 9, 20);
@Test
void hotIndexWhenStartWithin90Days() {
assertEquals("paperuselog", PaperUseLogEsPlanner.resolveIndex(TODAY.minusDays(90), TODAY));
assertEquals("paperuselog", PaperUseLogEsPlanner.resolveIndex(TODAY.minusDays(6), TODAY));
assertEquals("paperuselog_all", PaperUseLogEsPlanner.resolveIndex(TODAY.minusDays(91), TODAY));
assertEquals("paperuselog_all", PaperUseLogEsPlanner.resolveIndex(TODAY.minusDays(365), TODAY));
}
@Test
void localClusterOnlyWhenLocalEsDateAfterEndDate() {
assertTrue(PaperUseLogEsPlanner.useLocalCluster(LocalDate.of(2026, 9, 21), TODAY));
// 与老代码一致:localEsDate == endDate 走云集群
assertFalse(PaperUseLogEsPlanner.useLocalCluster(TODAY, TODAY));
assertFalse(PaperUseLogEsPlanner.useLocalCluster(TODAY.minusDays(30), TODAY));
}
@Test
void crossingBoundaryRejectedOnlyWhenStrictlyInside() {
LocalDate start = TODAY.minusDays(6);
LocalDate boundary = TODAY.minusDays(3);
LocalDate end = TODAY;
// start < localEsDate < end → 拒绝
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> PaperUseLogEsPlanner.checkNotCrossingBoundary(start, end, boundary));
assertEquals("查询范围跨越本地化数据分界日 " + boundary, exception.getMessage());
// localEsDate == startDate:老口径不拒绝(localEsDate > startDay 为 false)
PaperUseLogEsPlanner.checkNotCrossingBoundary(boundary, end, boundary);
// localEsDate == endDate:不拒绝且走云集群
PaperUseLogEsPlanner.checkNotCrossingBoundary(start, boundary, boundary);
// 整段在分界日之前/之后
PaperUseLogEsPlanner.checkNotCrossingBoundary(start, end, TODAY.plusDays(1));
PaperUseLogEsPlanner.checkNotCrossingBoundary(start, end, start.minusDays(1));
}
}
@@ -0,0 +1,398 @@
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.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.mapper.m0.DistrictDistrictStrategyMapper;
import me.iiv.agent.mapper.m0.DistrictStrategyMapper;
import me.iiv.agent.mapper.m0.MonthlyStatisticsMapper;
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.Date;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DistrictPaperStatisticsToolsTest {
private static final ZoneId SH = ZoneId.of("Asia/Shanghai");
@Mock
DistrictDistrictStrategyMapper districtMapper;
@Mock
MonthlyStatisticsMapper monthlyStatisticsMapper;
@Mock
DistrictStrategyMapper districtStrategyMapper;
@Mock
PermissionService permissionService;
@Mock
StringRedisTemplate redisTemplate;
@Mock
HashOperations<String, Object, Object> hashOperations;
@Mock
PaperUseLogEsGateway esGateway;
DistrictPaperStatisticsTools tools;
LocalDate today;
String start;
String end;
@BeforeEach
void setUp() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
DistrictDistrictStrategy.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
MonthlyStatistics.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
DistrictStrategy.class);
tools = new DistrictPaperStatisticsTools(districtMapper, monthlyStatisticsMapper,
districtStrategyMapper, permissionService, redisTemplate, esGateway);
User user = new User();
user.setId(7L);
AuthContextHolder.set(new AuthContext(null, user));
today = LocalDate.now();
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);
when(hashOperations.get("Dictionary", "LocalEsDate")).thenReturn("2020-01-01");
when(hashOperations.get("Dictionary", "StatisticsDays")).thenReturn("31");
}
private static MonthlyStatistics row(LocalDate day, Long positionId, Long toiletId,
Integer gender, int paperLengthCm, int useCount) {
MonthlyStatistics row = new MonthlyStatistics();
row.setId(1L);
row.setSoftDelete(0);
row.setDistrictId(9L);
row.setStatisticsTime(Date.from(day.atStartOfDay(SH).toInstant()));
row.setGender(gender);
row.setPositionId(positionId);
row.setToiletId(toiletId);
row.setPaperLength(paperLengthCm);
row.setUseCount(useCount);
row.setRunlMode(0);
return row;
}
@Test
void rejectsInvalidArgumentsBeforeAnyQuery() {
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery(
null, start, end, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery(
0L, start, end, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery(
9L, "2026/09/01", end, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery(
9L, start, end, "bad", null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery(
9L, start, end, null, 2, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery(
9L, end, start, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperStatisticsQuery(
9L, start, today.plusDays(1).toString(), null, null, null, null, null));
verifyNoInteractions(districtMapper, monthlyStatisticsMapper, redisTemplate, esGateway);
}
@Test
void rejectsInvisibleDistrict() {
when(districtMapper.selectOne(any())).thenReturn(null);
IllegalStateException exception = assertThrows(IllegalStateException.class,
() -> tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null));
assertEquals("站点不存在或无权访问", exception.getMessage());
verify(permissionService).applyDistrictRowFilter(any(), any());
verifyNoInteractions(monthlyStatisticsMapper, esGateway);
}
@Test
void failsFastWhenDictionaryMissing() {
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.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null));
verifyNoInteractions(monthlyStatisticsMapper, esGateway);
}
@Test
void rejectsRangeExceedingStatisticsDays() {
mockDistrictAndDictionary();
when(hashOperations.get("Dictionary", "StatisticsDays")).thenReturn("5");
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null));
assertEquals("查询时间段最大5天", exception.getMessage());
verifyNoInteractions(monthlyStatisticsMapper, esGateway);
}
@Test
void rejectsRangeCrossingLocalEsDateBoundary() {
mockDistrictAndDictionary();
when(hashOperations.get("Dictionary", "LocalEsDate")).thenReturn(today.minusDays(3).toString());
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null));
assertTrue(exception.getMessage().startsWith("查询范围跨越本地化数据分界日"));
verifyNoInteractions(monthlyStatisticsMapper, esGateway);
}
@Test
void aggregatesByGenderAndDayWithMeterConversion() {
mockDistrictAndDictionary();
LocalDate day1 = today.minusDays(2);
LocalDate day2 = today.minusDays(1);
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
row(day1, 11L, 3L, 0, 100, 2),
row(day1, 12L, 3L, 0, 50, 1),
row(day2, 0L, 7L, 0, 50, 0),
row(day1, 21L, 5L, 1, 200, 4)));
DistrictStrategy strategy = new DistrictStrategy();
strategy.setId(9L);
strategy.setRfidUseFlag(1);
when(districtStrategyMapper.selectOne(any())).thenReturn(strategy);
when(esGateway.distinctRfidCount(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(),
any(), any())).thenReturn(8L);
Map<String, Object> result = tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null);
assertEquals(9L, result.get("districtId"));
assertEquals("测试站点", result.get("districtName"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> summary = (List<Map<String, Object>>) result.get("summaryByGender");
assertEquals(2, summary.size());
Map<String, Object> male = summary.get(0);
assertEquals(0, male.get("gender"));
assertEquals("男", male.get("genderText"));
assertEquals(2.0, male.get("paperLengthM"));
assertEquals(3L, male.get("useCount"));
assertEquals(0.667, male.get("perUseLengthM"));
// 台日=每天 distinct 厕位/厕数之和:day1 两个厕位 + day2 一个厕墙 = 3
assertEquals(3L, male.get("deviceDayCount"));
assertEquals(8L, male.get("rfidCount"));
Map<String, Object> female = summary.get(1);
assertEquals(1, female.get("gender"));
assertEquals(0.5, female.get("perUseLengthM"));
assertEquals(1L, female.get("deviceDayCount"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> daily = (List<Map<String, Object>>) result.get("daily");
assertEquals(3, daily.size());
Map<String, Object> day1Item = daily.get(0);
assertEquals(day1.toString(), day1Item.get("date"));
assertEquals(1.5, day1Item.get("paperLengthM"));
assertEquals(3L, day1Item.get("useCount"));
assertEquals(2L, day1Item.get("deviceDayCount"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> genders = (List<Map<String, Object>>) result.get("toiletGenders");
assertEquals(List.of(0, 1), genders.stream().map(g -> g.get("gender")).toList());
verify(esGateway).distinctRfidCount(eq("paperuselog"), eq(false), eq(9L), eq(0),
anyLong(), anyLong(), any(), any());
verify(esGateway).distinctRfidCount(eq("paperuselog"), eq(false), eq(9L), eq(1),
anyLong(), anyLong(), any(), any());
verify(esGateway, never()).countState2(anyString(), anyBoolean(), anyLong(), anyInt(),
anyLong(), anyLong(), any(), any());
}
@Test
void nonRfidStrategyUsesState2Count() {
mockDistrictAndDictionary();
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
row(today.minusDays(1), 11L, 3L, 0, 100, 2)));
DistrictStrategy strategy = new DistrictStrategy();
strategy.setId(9L);
strategy.setRfidUseFlag(0);
when(districtStrategyMapper.selectOne(any())).thenReturn(strategy);
when(esGateway.countState2(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(),
any(), any())).thenReturn(5L);
Map<String, Object> result = tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null);
@SuppressWarnings("unchecked")
List<Map<String, Object>> summary = (List<Map<String, Object>>) result.get("summaryByGender");
assertEquals(5L, summary.get(0).get("rfidCount"));
verify(esGateway).countState2(eq("paperuselog"), eq(false), eq(9L), eq(0),
anyLong(), anyLong(), any(), any());
verify(esGateway, never()).distinctRfidCount(anyString(), anyBoolean(), anyLong(), anyInt(),
anyLong(), anyLong(), any(), any());
}
@Test
void missingStrategyFallsBackToState2Count() {
mockDistrictAndDictionary();
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
row(today.minusDays(1), 11L, 3L, 0, 100, 2)));
when(districtStrategyMapper.selectOne(any())).thenReturn(null);
when(esGateway.countState2(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(),
any(), any())).thenReturn(0L);
tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null);
verify(esGateway).countState2(anyString(), anyBoolean(), anyLong(), anyInt(),
anyLong(), anyLong(), any(), any());
verify(esGateway, never()).distinctRfidCount(anyString(), anyBoolean(), anyLong(), anyInt(),
anyLong(), anyLong(), any(), any());
}
@Test
void useCountOnlyAccumulatesForPositionModeRows() {
mockDistrictAndDictionary();
MonthlyStatistics wallModeRow = row(today.minusDays(1), 11L, 3L, 0, 100, 5);
wallModeRow.setRunlMode(1);
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
wallModeRow,
row(today.minusDays(1), 11L, 3L, 0, 100, 2)));
when(districtStrategyMapper.selectOne(any())).thenReturn(null);
when(esGateway.countState2(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(),
any(), any())).thenReturn(0L);
Map<String, Object> result = tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null);
@SuppressWarnings("unchecked")
List<Map<String, Object>> summary = (List<Map<String, Object>>) result.get("summaryByGender");
// runl_mode=1 的厕位行 useCount 不计入,仅厕位模式(runl_mode=0)累计
assertEquals(2L, summary.get(0).get("useCount"));
assertEquals(2.0, summary.get(0).get("paperLengthM"));
}
@Test
void useCountZeroYieldsNullPerUseLength() {
mockDistrictAndDictionary();
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
row(today.minusDays(1), 0L, 7L, 99, 80, 0)));
when(districtStrategyMapper.selectOne(any())).thenReturn(null);
when(esGateway.countState2(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(),
any(), any())).thenReturn(0L);
Map<String, Object> result = tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null);
@SuppressWarnings("unchecked")
List<Map<String, Object>> summary = (List<Map<String, Object>>) result.get("summaryByGender");
assertEquals(0.8, summary.get(0).get("paperLengthM"));
assertNull(summary.get(0).get("perUseLengthM"));
assertEquals("未知", summary.get(0).get("genderText"));
}
@Test
void coldIndexUsedWhenStartOlderThan90Days() {
mockDistrictAndDictionary();
when(hashOperations.get("Dictionary", "StatisticsDays")).thenReturn("365");
String coldStart = today.minusDays(120).toString();
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
row(today.minusDays(100), 11L, 3L, 0, 100, 2)));
when(districtStrategyMapper.selectOne(any())).thenReturn(null);
when(esGateway.countState2(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(),
any(), any())).thenReturn(0L);
tools.districtPaperStatisticsQuery(9L, coldStart, end, null, null, null, null, null);
verify(esGateway).countState2(eq("paperuselog_all"), eq(false), anyLong(), anyInt(),
anyLong(), anyLong(), any(), any());
}
@Test
void endBoundaryExcludesTodayButKeepsFullPastDays() {
mockDistrictAndDictionary();
LocalDate pastEnd = today.minusDays(1);
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
row(pastEnd, 11L, 3L, 0, 100, 2)));
when(districtStrategyMapper.selectOne(any())).thenReturn(null);
when(esGateway.countState2(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(),
any(), any())).thenReturn(0L);
// endDate=今天 → 上界取今天 00:00(毫秒-1)
tools.districtPaperStatisticsQuery(9L, start, end, null, null, null, null, null);
// endDate=过去某天 → 上界取当天 23:59:59.999
tools.districtPaperStatisticsQuery(9L, start, pastEnd.toString(), null, null, null, null, null);
ArgumentCaptor<Long> toCaptor = ArgumentCaptor.forClass(Long.class);
verify(esGateway, org.mockito.Mockito.times(2)).countState2(anyString(), anyBoolean(), anyLong(), anyInt(),
anyLong(), toCaptor.capture(), any(), any());
assertEquals(List.of(
today.atStartOfDay(SH).toInstant().toEpochMilli() - 1,
pastEnd.plusDays(1).atStartOfDay(SH).toInstant().toEpochMilli() - 1), toCaptor.getAllValues());
}
@Test
void springAiPublishesToolSchema() throws Exception {
org.springframework.ai.tool.ToolCallback[] callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider
.builder().toolObjects(tools).build().getToolCallbacks();
assertEquals(1, callbacks.length);
assertEquals("district.paper.statistics.query",
callbacks[0].getToolDefinition().name());
String schema = callbacks[0].getToolDefinition().inputSchema();
assertTrue(schema.contains("districtId"));
assertTrue(schema.contains("startDate"));
assertTrue(new com.fasterxml.jackson.databind.ObjectMapper().readTree(schema)
.path("required").toString().contains("\"districtId\""));
}
@Test
void passesPositionModeAndDeviceTypeThroughToEs() {
mockDistrictAndDictionary();
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
row(today.minusDays(1), 11L, 3L, 0, 100, 2)));
DistrictStrategy strategy = new DistrictStrategy();
strategy.setRfidUseFlag(1);
when(districtStrategyMapper.selectOne(any())).thenReturn(strategy);
when(esGateway.distinctRfidCount(anyString(), anyBoolean(), anyLong(), anyInt(), anyLong(), anyLong(),
any(), any())).thenReturn(1L);
tools.districtPaperStatisticsQuery(9L, start, end, "position", 1, null, null, null);
verify(esGateway).distinctRfidCount(eq("paperuselog"), eq(false), eq(9L), eq(0),
anyLong(), anyLong(), eq(1), eq("position"));
}
}