Files
xiaoqu_mcp_service/xiaoqu-agent-service/doc/district-paper-statistics-design.md
T

162 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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。