新增 district.paper.statistics.query 客户用纸统计工具(含 ES 7.17 客户端引入) #23
@@ -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。
|
||||
@@ -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> {
|
||||
}
|
||||
+319
@@ -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));
|
||||
}
|
||||
}
|
||||
+398
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user