Compare commits

...
Author SHA1 Message Date
my_local_kimi_dev2 b245ad757b slark WIP snapshot (fence 1791784479207) 2026-10-12 13:54:39 +08:00
11 changed files with 1400 additions and 53 deletions
@@ -0,0 +1 @@
{"version":2,"projectId":"layout-b2fffc41-14aa-4de3-b78c-14b3615b3ef2","paneId":"p1","bindingId":"binding-2f85e81e-7984-46ce-ad28-310187aa6722","window":"窗口1","path":"xiaoqu-ops-report-2026-09.html","kind":"html"}
@@ -1,47 +1,51 @@
# 人均用纸统计 MCP 工具设计
需求:REQ-20260922-0004
来源:老系统 `StatisticsReportController`(xiaoqu-franchisee)5 个接口的 MCP 化。
工具:`district.paper.percapita.query`
## 1. 覆盖范围
一个 MCP 工具通过 `groupBy` 与 `daily` 覆盖老 `StatisticsReportController` 的五个接口:
| 老接口 | groupBy | daily |
| 老接口 | 分组维度 | 图表形态 |
|---|---|---|
| perPaperLengthStatistics | gender | false |
| perPaperLengthStatisticsDetail | gender | true |
| perPaperLengthStatisticsByFloor | floor | false |
| perPaperLengthStatisticsDetailByFloor | floor | true |
| perPaperLengthStatisticsByToilet | floorGender | false |
| perPaperLengthStatistics | gender | 柱图 |
| perPaperLengthStatisticsDetail | 日期 × gender | 折线 |
| perPaperLengthStatisticsByFloor | floor | 柱图 |
| perPaperLengthStatisticsDetailByFloor | 日期 × floor | 折线 |
| perPaperLengthStatisticsByToilet | floor × gender | 分组柱图(名为 ByToilet,实际维度是楼层×卫生间类型) |
`floorGender+daily=true` 不在老接口范围内,直接返回参数错误。
## 1. 数据源
## 2. 数据与口径
- **纯 MySQL**,m0 库 `monthly_statistics` 日汇总表(Mapper 已存在:`mapper/m0/MonthlyStatisticsMapper.java`,实体 `entity/m0/MonthlyStatistics.java`)。**不查 ES**——ES 只在老系统离线写入管道里(XXL-Job 从 paperuselog 聚合落表)。
- 聚合口径(与老系统一致):`sum(use_count)`、`sum(paper_length)/100`(表内 paper_length 单位为厘米,/100 转米),人均 = paper_length(米) / use_count,保留 2 位小数;use_count=0 时人均取 paperLength 原值(沿用老逻辑)。
- 过滤条件:`soft_delete=0`、`statistics_time` 起止(含当天)、`district_id` 必填、可选 `device_type`、可选位置筛选 `idStrList`(`buildingId_floor_toiletId` 三段各自独立 IN)。
- 天数上限:Redis `Dictionary` hash 的 `StatisticsDays`,超限报错(与 district.paper.statistics.query 一致)。
- 数据源:m0 `monthly_statistics` 日汇总表,不查 ES。
- 固定过滤:`soft_delete=0`、`district_id`、日期左闭右开区间。
- 可选过滤:`device_type`;`idStrList` 中 building、floor、toilet 分别独立 IN。
- `paperLengthMeters=Σpaper_length/100`,表内 `paper_length` 单位为厘米。
- `perCapita=paperLengthMeters/Σuse_count`,四舍五入保留两位小数。
- 当 useCount=0 时,perCapita 取 paperLengthMeters,延续老系统语义。
- 日期上限读取 Redis `Dictionary.StatisticsDays`,缺失或非法时拒绝查询。
## 2. 权限(与老系统的关键差异)
## 3. 参数
老系统 5 个接口**无任何行级权限**(UserUtils 全被注释,agentId/propertyId 入参被删)。MCP 版必须补上:
- 沿用 `district.paper.statistics.query` 的模式:先按 `permissionService.applyDistrictRowFilter` 校验 districtId 对当前用户可见,不可见抛 `IllegalStateException("站点不存在或无权访问")`。
- 只发用户委托 Token(user_delegated),服务 Token 不给这几个 query 工具的 scope(遵守 AK/Scope 约束,见 [[xiaoqu-mcp-architecture]])。
## 3. 工具形态:推荐合并为 1 个工具
老系统 5 个接口本质是 5 种分组组合,合并成一个工具对 agent 更友好(少注册、少 scope 绑定、schema 自解释):
```
district.paper.percapita.query (@XqTool(category="district", kind="read"))
```
参数:
| 参数 | 必填 | 说明 |
|---|---|---|
| districtId | 是 | 正整数站点 ID |
| startDate/endDate | 是 | yyyy-MM-dd,含首尾日期,endDate 不晚于今天 |
| groupBy | 是 | gender / floor / floorGender |
| daily | 否 | 默认 false;true 时增加日期维度 |
| deviceType | 否 | 0 出纸机,1 擦手纸机 |
| idStrList | 否 | 每项严格为 buildingId_floor_toiletId |
| districtId | 是 | 站点 id |
| startDate / endDate | 是 | yyyy-MM-dd,含当天;跨度 ≤ StatisticsDays |
| groupBy | 是 | `gender` / `floor` / `floorGender`(楼层×卫生间类型,即老 ByToilet) |
| daily | 否,默认 false | true=按日折线(仅 gender/floor 支持;floorGender+daily 直接参数报错) |
| deviceType | 否 | 0=出纸机 1=擦手纸机 |
| idStrList | 否 | 位置筛选,`buildingId_floor_toiletId` 格式 |
## 4. 面向 LLM 的返回结构
映射:`gender`+daily=false→①,+daily=true→②,`floor`+false→③,+true→④,`floorGender`→⑤。
返回结构不随分组方式变化。维度和指标位于同一个行对象,无数据日期或分组不生成行。
返回 `Map<String, Object>`(项目统一风格,无 DTO 类)。**面向 LLM 消费设计**(王恒 2026-09-22 确认:后续所有 MCP 工具设计都以返给 LLM 展示为准)——一种结构走天下,不随 groupBy/daily 变形态:
```json
{
@@ -53,33 +57,37 @@
"daily": false,
"totalPerCapita": 1.32,
"rows": [
{
"date": null,
"floor": null,
"gender": "男卫",
"paperLengthMeters": 120.5,
"useCount": 96,
"perCapita": 1.26
}
{"date": "2026-09-01", "floor": null, "gender": "男卫", "paperLengthMeters": 120.5, "useCount": 96, "perCapita": 1.25}
]
}
```
- 未参与分组的 `date/floor/gender` 保持为 null。
- `daily=true` 时 `totalPerCapita` 为 null;非按日查询时为整个区间的总人均。
- gender 映射:0男卫、1女卫、2母婴室、3无障碍、4第三卫、5其他,其他值显示未知。
- 楼层按数据库字符串值的字典序输出。
- `rows` 是扁平自描述行数组:每行把维度值(date/floor/gender)和指标(paperLengthMeters/useCount/perCapita)放同一对象,LLM 不需要维护平行数组下标对齐。
- 维度字段恒在、不参与的维度为 null:`daily=false` 时 `date=null`;`groupBy=gender` 时 `floor=null`;`groupBy=floor` 时 `gender=null`;`floorGender` 两者都有值。
- 透出 `paperLengthMeters`、`useCount` 原始值,LLM 可自行二次计算,不必从人均反推。
- 无数据的日期/分组**不出行**(LLM 语义:没有就是没数据,不会误读成 0)——放弃老系统折线"补完整日期横轴 + 填 0.0"的做法,画折线是消费方的事。
- `totalPerCapita` 仅在 daily=false 时给出(区间总人均);daily=true 时为 null,由消费方按需聚合。
- gender 文案映射沿用老系统:0男卫/1女卫/2母婴室/3无障碍/4第三卫/5其他。
- 数值统一 Double、保留 2 位小数;use_count=0 时人均取 paperLength 原值(沿用老逻辑)。
## 5. 权限与作用域
备选方案:1:1 拆 5 个工具(`district.paper.percapita.{gender,genderDaily,floor,floorDaily,floorGender}.query`)。更贴近老接口但注册/绑定成本 ×5,不推荐。
- 查询前用 `PermissionService.applyDistrictRowFilter` 校验当前用户能否查看站点。
- 站点不存在和无权访问统一返回“站点不存在或无权访问”。
- 工具只绑定 `service_enabled=0` 的用户委托作用域,不向 sync、ops-query 等服务身份作用域开放。
废弃方案:ECharts 平行数组结构(nameList/valueList、dateList/series、xnameList/groupList/groups)——给前端画图顺,但 LLM 消费有下标错位、补 0.0 混淆语义、多形态理解成本三个问题,已被王恒否决(2026-09-22)。
## 6. 实现文件
## 4. 改动文件清单
- `src/main/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsTools.java`
- `src/test/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsToolsTest.java`
- `sql/seed-dev.sql`
- `doc/agent-mcp-tools-reference.md`
- `README.md`
1. 新建 `src/main/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsTools.java`(@Component implements ToolObject,骨架照抄 DistrictPaperStatisticsTools.java:73-87)
2. 实体/Mapper:**无需新增**(monthly_statistics 已覆盖)
3. 权限:**无需新增**(复用 applyDistrictRowFilter,PermissionService.java:798)
4. ES:**不涉及**
5. 单测 `src/test/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsToolsTest.java`(Mockito + AuthContextHolder.set(),参照 DistrictPaperStatisticsToolsTest)
6. `sql/seed-dev.sql` 加幂等 scope_tool 绑定;生产绑 scope 1/2/3/5(与 district.paper.statistics.query 同组)
7. 登记 `doc/agent-mcp-tools-reference.md`
8. 装配层零改动(McpToolConfig 自动收集 ToolObject bean,ToolSyncService 自动同步 tool 表)
## 5. 已知坑 / 注意点
- 老代码 `totalPaperLength` 用 Integer 存 SQL 已 /100 的值再除,精度有损失;MCP 版统一用 Double 计算,不与老系统逐分核对。
- `setWrapperByPosition` 的三段独立 IN 语义要保持(不是联合匹配),否则结果集不同。
- daily 不再补完整日期横轴(老系统补 0.0 是给前端画图用;MCP 面向 LLM,无数据日期不出行)。
- floor 是 String 列,排序按字典序(老系统行为),文档里注明。
@@ -0,0 +1,32 @@
package me.iiv.agent.entity.m0;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.io.Serializable;
import java.util.Date;
/** RFID(m0.rfid_info,极简字段:换纸明细的 isAssignment 判定用)。 */
@Getter
@Setter
@ToString
@TableName("rfid_info")
public class RfidInfo implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private Long createId;
private Date createTime;
private Long modifyId;
private Date modifyTime;
private Integer softDelete;
private String rfid;
/** ≥1 表示已生成(老 getRfidTraceList 口径:assignment_flag >= 1) */
private Integer assignmentFlag;
}
@@ -1,5 +1,7 @@
package me.iiv.agent.es;
import java.util.List;
/**
* paperuselog(换纸记录)ES 查询网关。集群选择(云/本地)与索引选择(热/冷)
* 由调用方通过参数传入,本接口只做单次查询,便于单测 mock。
@@ -22,4 +24,112 @@ public interface PaperUseLogEsGateway {
*/
long countState2(String index, boolean localCluster, Long districtId, Integer gender,
Long fromMillis, Long toMillis, Integer deviceType, String positionMode);
/**
* 换纸记录分页明细:term districtId、createTime 闭区间(epoch 毫秒)、
* snCode/rfid 走 .keyword 精确匹配(text 主字段禁止 term/match 分词)、
* state(2=上纸/4=撤纸)与 deviceType 可选过滤,按 createTime ASC 排序,from+size 分页。
*
* @param index paperuselog / paperuselog_all
* @param localCluster true=本地集群,false=云集群
* @param snCode 可选,keyword 精确匹配
* @param rfid 可选,keyword 精确匹配
* @param state 可选,2=上纸、4=撤纸,null=查所有
* @param deviceType 可选,0=出纸机、1=擦手纸机,null=不过滤
* @param from from+size 分页起始偏移
* @param size 每页条数
* @return 命中总数(track_total_hits)与当页记录
*/
TraceSearchResult searchTraces(String index, boolean localCluster, Long districtId,
Long fromMillis, Long toMillis,
String snCode, String rfid, Integer state, Integer deviceType,
int from, int size);
/** 换纸明细分页查询结果。 */
class TraceSearchResult {
private final long total;
private final List<TraceRecord> records;
public TraceSearchResult(long total, List<TraceRecord> records) {
this.total = total;
this.records = records;
}
public long getTotal() {
return total;
}
public List<TraceRecord> getRecords() {
return records;
}
}
/** 单条换纸记录(_source 文本值已归一为数值、createTime 已归一为 ISO 文本)。 */
class TraceRecord {
private final String createTime;
private final Integer state;
private final String rfid;
private final String snCode;
private final Integer lengthCm;
private final Integer paperlengthCm;
private final Long positionsId;
private final Long toiletId;
private final Integer gender;
private final Integer deviceType;
public TraceRecord(String createTime, Integer state, String rfid, String snCode,
Integer lengthCm, Integer paperlengthCm,
Long positionsId, Long toiletId, Integer gender, Integer deviceType) {
this.createTime = createTime;
this.state = state;
this.rfid = rfid;
this.snCode = snCode;
this.lengthCm = lengthCm;
this.paperlengthCm = paperlengthCm;
this.positionsId = positionsId;
this.toiletId = toiletId;
this.gender = gender;
this.deviceType = deviceType;
}
public String getCreateTime() {
return createTime;
}
public Integer getState() {
return state;
}
public String getRfid() {
return rfid;
}
public String getSnCode() {
return snCode;
}
public Integer getLengthCm() {
return lengthCm;
}
public Integer getPaperlengthCm() {
return paperlengthCm;
}
public Long getPositionsId() {
return positionsId;
}
public Long getToiletId() {
return toiletId;
}
public Integer getGender() {
return gender;
}
public Integer getDeviceType() {
return deviceType;
}
}
}
@@ -12,6 +12,8 @@ import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* paperuselog ES 查询实现。
@@ -89,6 +91,118 @@ public class PaperUseLogEsGatewayImpl implements PaperUseLogEsGateway {
}
}
@Override
public TraceSearchResult searchTraces(String index, boolean localCluster, Long districtId,
Long fromMillis, Long toMillis,
String snCode, String rfid, Integer state, Integer deviceType,
int from, int size) {
ArrayNode filters = mapper.createArrayNode();
filters.addObject().putObject("term").putObject("districtId").put("value", districtId);
// createTime 为 date 类型,range 必须发 epoch 毫秒(同 distinctRfidCount)
ObjectNode range = filters.addObject().putObject("range").putObject("createTime");
range.put("gte", fromMillis).put("lte", toMillis);
if (StringUtils.hasText(snCode)) {
// rfid/snCode 主字段是 text(有 ignore_above=256 的 keyword 子字段),
// 精确匹配必须走 .keyword term;matchQuery 会被 standard analyzer 分词误配
filters.addObject().putObject("term").putObject("snCode.keyword").put("value", snCode);
}
if (StringUtils.hasText(rfid)) {
filters.addObject().putObject("term").putObject("rfid.keyword").put("value", rfid);
}
if (state != null) {
filters.addObject().putObject("term").putObject("state").put("value", state);
}
if (deviceType != null) {
// 与 baseFilters 同口径:老代码 deviceType 字符串参数走 match(数字文本分词后等价精确)
filters.addObject().putObject("match").putObject("deviceType")
.put("query", String.valueOf(deviceType));
}
try {
ObjectNode body = mapper.createObjectNode();
body.put("from", from).put("size", size);
body.put("track_total_hits", true);
ObjectNode bool = body.putObject("query").putObject("bool");
bool.set("filter", filters);
body.putArray("sort").addObject().putObject("createTime").put("order", "asc");
Request req = new Request("POST", "/" + index + "/_search");
req.setJsonEntity(mapper.writeValueAsString(body));
Response resp = client(localCluster).performRequest(req);
JsonNode root = mapper.readTree(resp.getEntity().getContent());
List<TraceRecord> records = new ArrayList<>();
for (JsonNode hit : root.at("/hits/hits")) {
JsonNode source = hit.get("_source");
if (source == null) {
continue;
}
records.add(new TraceRecord(
isoText(source.get("createTime")),
integerValue(source.get("state")),
textValue(source.get("rfid")),
textValue(source.get("snCode")),
integerValue(source.get("length")),
integerValue(source.get("paperlength")),
longValue(source.get("positionsId")),
longValue(source.get("toiletId")),
integerValue(source.get("gender")),
integerValue(source.get("deviceType"))));
}
return new TraceSearchResult(root.at("/hits/total/value").asLong(), records);
} catch (IOException | RuntimeException e) {
throw new IllegalStateException("ES 查询换纸记录明细失败: " + e.getMessage(), e);
}
}
/** createTime 在 _source 里是 ISO 文本(2026-09-21T10:00:00.000+08:00)或 epoch 毫秒数,统一归一为 ISO 文本 */
private static String isoText(JsonNode value) {
if (value == null || value.isNull()) {
return null;
}
if (value.isNumber()) {
return java.time.Instant.ofEpochMilli(value.longValue()).toString();
}
return value.isTextual() ? value.asText() : null;
}
private static String textValue(JsonNode value) {
return value == null || value.isNull() ? null : value.asText();
}
private static Long longValue(JsonNode value) {
if (value == null || value.isNull()) {
return null;
}
if (value.isNumber()) {
return value.longValue();
}
if (value.isTextual()) {
try {
return Long.valueOf(value.asText().trim());
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
/** _source 里 length/paperlength/state 等数值可能是文本("150"),统一解析;非数值返回 null */
private static Integer integerValue(JsonNode value) {
if (value == null || value.isNull()) {
return null;
}
if (value.isNumber()) {
return value.intValue();
}
if (value.isTextual()) {
try {
return Integer.valueOf(value.asText().trim());
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
private ArrayNode baseFilters(Long districtId, Integer gender, Long fromMillis, Long toMillis,
Integer deviceType, String positionMode) {
ArrayNode filters = mapper.createArrayNode();
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m0;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m0.RfidInfo;
public interface RfidInfoMapper extends BaseMapper<RfidInfo> {
}
@@ -0,0 +1,253 @@
package me.iiv.agent.mcp.tool;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import me.iiv.agent.entity.m0.DistrictDistrictStrategy;
import me.iiv.agent.entity.m0.RfidInfo;
import me.iiv.agent.entity.m0.User;
import me.iiv.agent.es.PaperUseLogEsGateway;
import me.iiv.agent.es.PaperUseLogEsPlanner;
import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper;
import me.iiv.agent.mapper.m0.RfidInfoMapper;
import me.iiv.agent.mcp.ToolObject;
import me.iiv.agent.mcp.XqTool;
import me.iiv.agent.permission.PermissionService;
import me.iiv.agent.security.AuthContextHolder;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 客户换纸记录分页查询(老 MonthlyStatisticsController.getRfidTraceList 移植):
* ES 索引 paperuselog(热)/ paperuselog_all(冷,startDate 距今 ≥90 天),
* 按用户拍板**不复刻**老系统的"轨迹展开"逻辑(scroll 全量拉取 + Java 侧隐式字段改写),
* 直接出分页明细;v1 不返回满卷标记(newPaperFlag),只返回原始 lengthCm。
* 站点可见性校验、LocalEsDate 集群分界与冷热索引路由复用 district.paper.statistics.query 同款口径。
* 设计文档:doc/paper-trace-list-mcp-design.md
*/
@Component
public class DistrictPaperTraceTools implements ToolObject {
private static final String DICTIONARY_KEY = "Dictionary";
private static final String DICT_LOCAL_ES_DATE = "LocalEsDate";
/** 日期区间硬限 31 天(一个月,2026-09-24 拍板) */
private static final int MAX_QUERY_DAYS = 31;
private static final int DEFAULT_ROWS = 20;
private static final int MAX_ROWS = 100;
private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
final DistrictDistrictStrategyMapper districtMapper;
final RfidInfoMapper rfidInfoMapper;
final PermissionService permissionService;
final PaperUseLogEsGateway paperUseLogEsGateway;
final StringRedisTemplate redisTemplate;
public DistrictPaperTraceTools(DistrictDistrictStrategyMapper districtMapper,
RfidInfoMapper rfidInfoMapper,
PermissionService permissionService,
PaperUseLogEsGateway paperUseLogEsGateway,
StringRedisTemplate redisTemplate) {
this.districtMapper = districtMapper;
this.rfidInfoMapper = rfidInfoMapper;
this.permissionService = permissionService;
this.paperUseLogEsGateway = paperUseLogEsGateway;
this.redisTemplate = redisTemplate;
}
@XqTool(category = "district", kind = "read")
@Tool(name = "district.paper.trace.list.query", description = "客户换纸记录分页查询(ES paperuselog 索引,"
+ "复刻老 /MonthlyStatistics/getRfidTraceList 的数据查询语义但出分页明细,不复刻轨迹展开逻辑)。"
+ "入参 districtId/startDate/endDate 必填;snCode/rfid 可选,keyword 精确匹配;state 可选"
+ "(2=上纸、4=撤纸,不传=查所有);deviceType 可选(0=出纸机、1=擦手纸机)。"
+ "返回 districtId/districtName/index/total/page/rows/records,record 含 createTime、state+stateText、"
+ "rfid、snCode、lengthCm(原始长度,单位厘米)、paperlengthCm、positionsId(0=厕所级)、toiletId、"
+ "gender+genderText、deviceType、isAssignment(该 rfid 在 m0 rfid_info 中 assignment_flag≥1 为 true);"
+ "按 createTime ASC 排序。日期区间最长 31 天,跨越 Redis LocalEsDate 分界会被拒绝。")
public Map<String, Object> districtPaperTraceListQuery(
@ToolParam(description = "站点 id,正整数") Long districtId,
@ToolParam(description = "开始日期,yyyy-MM-dd,含当天") String startDate,
@ToolParam(description = "结束日期,yyyy-MM-dd,含当天,不得晚于今天") String endDate,
@ToolParam(required = false, description = "设备 SN,keyword 精确匹配") String snCode,
@ToolParam(required = false, description = "rfid,keyword 精确匹配") String rfid,
@ToolParam(required = false, description = "换纸状态:2=上纸、4=撤纸,不传=查所有") Integer state,
@ToolParam(required = false, description = "设备类型:0=出纸机、1=擦手纸机,缺省不过滤") Integer deviceType,
@ToolParam(required = false, description = "页码,从 1 开始,默认 1") Integer page,
@ToolParam(required = false, description = "每页条数,默认 20,上限 100,超出截断为 100") Integer rows) {
if (districtId == null) {
throw new IllegalArgumentException("districtId 必填");
}
if (districtId < 1) {
throw new IllegalArgumentException("districtId 必须为正整数");
}
LocalDate start = parseDay(startDate, "startDate");
LocalDate end = parseDay(endDate, "endDate");
LocalDate today = LocalDate.now(SHANGHAI);
if (start.isAfter(end)) {
throw new IllegalArgumentException("startDate 不能晚于 endDate");
}
if (end.isAfter(today)) {
throw new IllegalArgumentException("endDate 不得晚于今天");
}
if (state != null && state != 2 && state != 4) {
throw new IllegalArgumentException("state 仅支持 2(上纸)、4(撤纸)");
}
if (deviceType != null && deviceType != 0 && deviceType != 1) {
throw new IllegalArgumentException("deviceType 仅支持 0、1");
}
int queryDays = (int) ChronoUnit.DAYS.between(start, end) + 1;
if (queryDays > MAX_QUERY_DAYS) {
throw new IllegalArgumentException(
"查询时间段最长 " + MAX_QUERY_DAYS + " 天(一个月),请修改日期范围");
}
User user = AuthContextHolder.get().getUser();
LambdaQueryWrapper<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("站点不存在或无权访问");
}
// 与 district.paper.statistics.query 同款:LocalEsDate 集群分界,字典缺失 fail fast(老系统 NPE 被吞)
LocalDate localEsDate = parseDay(dictionaryValue(DICT_LOCAL_ES_DATE), DICT_LOCAL_ES_DATE);
PaperUseLogEsPlanner.checkNotCrossingBoundary(start, end, localEsDate);
String index = PaperUseLogEsPlanner.resolveIndex(start, today);
boolean localCluster = PaperUseLogEsPlanner.useLocalCluster(localEsDate, end);
long fromMillis = start.atStartOfDay(SHANGHAI).toInstant().toEpochMilli();
// 与 statistics 工具同款老口径:endDate=今天 → 上界取今天 00:00(当天数据未跑完),否则次日 00:00-1ms
LocalDate endExclusive = end.equals(today) ? end : end.plusDays(1);
long toMillis = endExclusive.atStartOfDay(SHANGHAI).toInstant().toEpochMilli() - 1;
int pageNo = page == null || page < 1 ? 1 : page;
boolean truncatedRows = rows != null && rows > MAX_ROWS;
int pageSize = rows == null || rows < 1 ? DEFAULT_ROWS : Math.min(rows, MAX_ROWS);
int from = (pageNo - 1) * pageSize;
PaperUseLogEsGateway.TraceSearchResult search = paperUseLogEsGateway.searchTraces(
index, localCluster, districtId, fromMillis, toMillis,
StringUtils.hasText(snCode) ? snCode.trim() : null,
StringUtils.hasText(rfid) ? rfid.trim() : null,
state, deviceType, from, pageSize);
Set<String> assignedRfids = assignedRfids(search.getRecords());
List<Map<String, Object>> records = new ArrayList<>();
for (PaperUseLogEsGateway.TraceRecord record : search.getRecords()) {
records.add(recordRow(record, assignedRfids));
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("districtId", districtId);
result.put("districtName", district.getDistrictName());
result.put("index", index);
result.put("total", search.getTotal());
result.put("page", pageNo);
result.put("rows", pageSize);
if (truncatedRows) {
result.put("notice", "rows 超过上限 " + MAX_ROWS + ",已截断为 " + MAX_ROWS);
}
result.put("records", records);
return result;
}
/** 老 getRfidTraceList :735-741 口径:m0 rfid_info soft_delete=0、rfid IN (页内 rfid)、assignment_flag≥1 */
private Set<String> assignedRfids(List<PaperUseLogEsGateway.TraceRecord> records) {
Set<String> rfids = new LinkedHashSet<>();
for (PaperUseLogEsGateway.TraceRecord record : records) {
if (StringUtils.hasText(record.getRfid())) {
rfids.add(record.getRfid());
}
}
Set<String> assigned = new HashSet<>();
if (rfids.isEmpty()) {
return assigned;
}
List<RfidInfo> rfidInfos = rfidInfoMapper.selectList(new LambdaQueryWrapper<RfidInfo>()
.eq(RfidInfo::getSoftDelete, 0)
.in(RfidInfo::getRfid, rfids)
.ge(RfidInfo::getAssignmentFlag, 1));
for (RfidInfo rfidInfo : rfidInfos) {
assigned.add(rfidInfo.getRfid());
}
return assigned;
}
private Map<String, Object> recordRow(PaperUseLogEsGateway.TraceRecord record, Set<String> assignedRfids) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("createTime", record.getCreateTime());
row.put("state", record.getState());
row.put("stateText", stateText(record.getState()));
row.put("rfid", record.getRfid());
row.put("snCode", record.getSnCode());
row.put("lengthCm", record.getLengthCm());
row.put("paperlengthCm", record.getPaperlengthCm());
row.put("positionsId", record.getPositionsId());
row.put("toiletId", record.getToiletId());
row.put("gender", record.getGender());
row.put("genderText", genderText(record.getGender()));
row.put("deviceType", record.getDeviceType());
row.put("isAssignment", StringUtils.hasText(record.getRfid()) && assignedRfids.contains(record.getRfid()));
return row;
}
private String dictionaryValue(String field) {
Object value = redisTemplate.opsForHash().get(DICTIONARY_KEY, field);
if (value == null) {
throw new IllegalStateException("Redis Dictionary 缺少 " + field + " 字段,拒绝查询");
}
return value.toString();
}
private static LocalDate parseDay(String value, String name) {
if (!StringUtils.hasText(value)) {
throw new IllegalArgumentException(name + " 必填,格式 yyyy-MM-dd");
}
try {
return LocalDate.parse(value.trim());
} catch (java.time.format.DateTimeParseException e) {
throw new IllegalArgumentException(name + " 格式非法,要求 yyyy-MM-dd: " + value);
}
}
private static String stateText(Integer state) {
if (state == null) {
return null;
}
switch (state) {
case 2: return "上纸";
case 4: return "撤纸";
default: return null;
}
}
/** gender 透传索引原值;0男/1女/2母婴/3残卫/4第三/5其他,未知编码 genderText 为 null */
private static String genderText(Integer gender) {
if (gender == null) {
return null;
}
switch (gender) {
case 0: return "男";
case 1: return "女";
case 2: return "母婴";
case 3: return "残卫";
case 4: return "第三";
case 5: return "其他";
default: return null;
}
}
}
@@ -18,6 +18,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -135,4 +136,103 @@ class PaperUseLogEsGatewayImplTest {
1789392000000L, 1789478399000L, null, null));
assertTrue(exception.getMessage().startsWith("ES 查询换纸 rfid 去重失败"));
}
@Test
void searchTracesBuildsKeywordTermsAndSortAsc() throws Exception {
Response response = esResponse("{\"hits\":{\"total\":{\"value\":0},\"hits\":[]}}");
when(localRestClient.performRequest(any(Request.class))).thenReturn(response);
gateway.searchTraces("paperuselog", true, 2578L,
1789392000000L, 1789478399000L,
"SN-001", "RF-ABC", 2, 1, 40, 20);
ArgumentCaptor<Request> captor = ArgumentCaptor.forClass(Request.class);
verify(localRestClient).performRequest(captor.capture());
Request captured = captor.getValue();
assertEquals("POST", captured.getMethod());
assertEquals("/paperuselog/_search", captured.getEndpoint());
JsonNode body = mapper.readTree(EntityUtils.toString(captured.getEntity()));
JsonNode filter = body.at("/query/bool/filter");
// rfid/snCode 是 text 类型,精确匹配必须走 .keyword term(禁止 matchQuery 分词误配)
assertTrue(filter.toString().contains("\"snCode.keyword\":{\"value\":\"SN-001\"}"));
assertTrue(filter.toString().contains("\"rfid.keyword\":{\"value\":\"RF-ABC\"}"));
assertTrue(filter.toString().contains("\"state\":{\"value\":2}"));
assertTrue(filter.toString().contains("\"districtId\":{\"value\":2578}"));
assertTrue(filter.toString().contains("\"deviceType\":{\"query\":\"1\"}"));
// createTime 为 date 类型,range 必须发 epoch 毫秒
assertEquals(1789392000000L, filter.findValue("gte").asLong());
assertEquals(1789478399000L, filter.findValue("lte").asLong());
assertEquals(40, body.get("from").asInt());
assertEquals(20, body.get("size").asInt());
assertTrue(body.get("track_total_hits").asBoolean());
assertEquals("asc", body.at("/sort/0/createTime/order").asText());
}
@Test
void searchTracesOmitsOptionalFilters() throws Exception {
Response response = esResponse("{\"hits\":{\"total\":{\"value\":0},\"hits\":[]}}");
when(cloudRestClient.performRequest(any(Request.class))).thenReturn(response);
gateway.searchTraces("paperuselog_all", false, 2578L,
1789392000000L, 1789478399000L,
null, null, null, null, 0, 20);
verify(cloudRestClient).performRequest(any(Request.class));
ArgumentCaptor<Request> captor = ArgumentCaptor.forClass(Request.class);
verify(cloudRestClient).performRequest(captor.capture());
JsonNode filter = mapper.readTree(EntityUtils.toString(captor.getValue().getEntity()))
.at("/query/bool/filter");
// 可选过滤全不传:只留 districtId + createTime range,不带 keyword/state/deviceType 条件
assertEquals(2, filter.size());
assertTrue(filter.toString().contains("districtId"));
assertTrue(filter.toString().contains("createTime"));
assertTrue(!filter.toString().contains("keyword"));
assertTrue(!filter.toString().contains("state"));
}
@Test
void searchTracesNormalizesTextSourceValues() throws Exception {
// _source 里 length/paperlength/state 是文本、createTime 是 ISO 文本(生产索引实际形态)
String response = "{\"hits\":{\"total\":{\"value\":2},\"hits\":["
+ "{\"_source\":{\"createTime\":\"2026-09-21T10:00:00.000+08:00\",\"state\":\"2\","
+ "\"rfid\":\"RF-1\",\"snCode\":\"SN-1\",\"length\":\"150\",\"paperlength\":\"18000\","
+ "\"positionsId\":\"11\",\"toiletId\":\"3\",\"gender\":\"0\",\"deviceType\":\"0\"}},"
+ "{\"_source\":{\"createTime\":1789392000000,\"state\":4,"
+ "\"rfid\":null,\"snCode\":\"SN-2\",\"length\":80,\"paperlength\":9000,"
+ "\"positionsId\":0,\"toiletId\":7,\"gender\":1,\"deviceType\":1}}"
+ "]}}";
Response hitsResponse = esResponse(response);
when(localRestClient.performRequest(any(Request.class))).thenReturn(hitsResponse);
PaperUseLogEsGateway.TraceSearchResult result = gateway.searchTraces("paperuselog", true, 2578L,
1789392000000L, 1789478399000L, null, null, null, null, 0, 20);
assertEquals(2L, result.getTotal());
PaperUseLogEsGateway.TraceRecord first = result.getRecords().get(0);
assertEquals("2026-09-21T10:00:00.000+08:00", first.getCreateTime());
assertEquals(2, first.getState());
assertEquals("RF-1", first.getRfid());
assertEquals(150, first.getLengthCm());
assertEquals(18000, first.getPaperlengthCm());
assertEquals(11L, first.getPositionsId());
assertEquals(3L, first.getToiletId());
assertEquals(0, first.getGender());
PaperUseLogEsGateway.TraceRecord second = result.getRecords().get(1);
// epoch 毫秒形态归一为 Instant ISO 文本
assertEquals("2026-09-14T13:20:00Z", second.getCreateTime());
assertEquals(4, second.getState());
assertNull(second.getRfid());
assertEquals(0L, second.getPositionsId());
}
@Test
void searchTracesWrapsEsFailureInIllegalState() throws Exception {
when(localRestClient.performRequest(any(Request.class)))
.thenThrow(new IOException("connection reset"));
IllegalStateException exception = assertThrows(IllegalStateException.class,
() -> gateway.searchTraces("paperuselog", true, 2578L,
1789392000000L, 1789478399000L, null, null, null, null, 0, 20));
assertTrue(exception.getMessage().startsWith("ES 查询换纸记录明细失败"));
}
}
@@ -0,0 +1,314 @@
package me.iiv.agent.mcp.tool;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import me.iiv.agent.entity.m0.DistrictDistrictStrategy;
import me.iiv.agent.entity.m0.RfidInfo;
import me.iiv.agent.entity.m0.User;
import me.iiv.agent.es.PaperUseLogEsGateway;
import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper;
import me.iiv.agent.mapper.m0.RfidInfoMapper;
import me.iiv.agent.permission.PermissionService;
import me.iiv.agent.security.AuthContext;
import me.iiv.agent.security.AuthContextHolder;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DistrictPaperTraceToolsTest {
private static final ZoneId SH = ZoneId.of("Asia/Shanghai");
@Mock
DistrictDistrictStrategyMapper districtMapper;
@Mock
RfidInfoMapper rfidInfoMapper;
@Mock
PermissionService permissionService;
@Mock
PaperUseLogEsGateway paperUseLogEsGateway;
@Mock
StringRedisTemplate redisTemplate;
@Mock
HashOperations<String, Object, Object> hashOperations;
DistrictPaperTraceTools tools;
LocalDate today;
String start;
String end;
@BeforeEach
void setUp() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
DistrictDistrictStrategy.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
RfidInfo.class);
tools = new DistrictPaperTraceTools(districtMapper, rfidInfoMapper, permissionService,
paperUseLogEsGateway, redisTemplate);
User user = new User();
user.setId(7L);
AuthContextHolder.set(new AuthContext(null, user));
today = LocalDate.now(SH);
start = today.minusDays(6).toString();
end = today.toString();
}
@AfterEach
void tearDown() {
AuthContextHolder.clear();
}
private void mockDistrictAndDictionary() {
DistrictDistrictStrategy district = new DistrictDistrictStrategy();
district.setId(9L);
district.setDistrictName("测试站点");
when(districtMapper.selectOne(any())).thenReturn(district);
lenient().when(redisTemplate.opsForHash()).thenReturn(hashOperations);
// LocalEsDate 在区间之外 → 不跨界;距今久远 → 云集群
lenient().when(hashOperations.get("Dictionary", "LocalEsDate")).thenReturn("2000-01-01");
}
private static PaperUseLogEsGateway.TraceRecord trace(String createTime, Integer state, String rfid,
String snCode, Integer lengthCm, Integer paperlengthCm,
Long positionsId, Long toiletId, Integer gender,
Integer deviceType) {
return new PaperUseLogEsGateway.TraceRecord(createTime, state, rfid, snCode, lengthCm, paperlengthCm,
positionsId, toiletId, gender, deviceType);
}
private static RfidInfo assignedRfid(String rfid) {
RfidInfo rfidInfo = new RfidInfo();
rfidInfo.setId(1L);
rfidInfo.setSoftDelete(0);
rfidInfo.setRfid(rfid);
rfidInfo.setAssignmentFlag(1);
return rfidInfo;
}
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> recordsOf(Map<String, Object> result) {
return (List<Map<String, Object>>) result.get("records");
}
@Test
void rejectsInvalidArgumentsBeforeAnyQuery() {
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperTraceListQuery(
null, start, end, null, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperTraceListQuery(
0L, start, end, null, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperTraceListQuery(
9L, "2026/09/01", end, null, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperTraceListQuery(
9L, end, start, null, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperTraceListQuery(
9L, start, today.plusDays(1).toString(), null, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperTraceListQuery(
9L, start, end, null, null, 3, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperTraceListQuery(
9L, start, end, null, null, null, 2, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperTraceListQuery(
9L, today.minusDays(40).toString(), end, null, null, null, null, null, null));
verifyNoInteractions(districtMapper, rfidInfoMapper, paperUseLogEsGateway, redisTemplate);
}
@Test
void rejectsInvisibleDistrict() {
when(districtMapper.selectOne(any())).thenReturn(null);
IllegalStateException exception = assertThrows(IllegalStateException.class,
() -> tools.districtPaperTraceListQuery(9L, start, end, null, null, null, null, null, null));
assertEquals("站点不存在或无权访问", exception.getMessage());
verify(permissionService).applyDistrictRowFilter(any(), any());
verifyNoInteractions(paperUseLogEsGateway, redisTemplate);
}
@Test
void failsFastWhenLocalEsDateDictionaryMissing() {
DistrictDistrictStrategy district = new DistrictDistrictStrategy();
district.setId(9L);
when(districtMapper.selectOne(any())).thenReturn(district);
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
when(hashOperations.get("Dictionary", "LocalEsDate")).thenReturn(null);
assertThrows(IllegalStateException.class,
() -> tools.districtPaperTraceListQuery(9L, start, end, null, null, null, null, null, null));
verifyNoInteractions(paperUseLogEsGateway);
}
@Test
void rejectsRangeCrossingLocalEsDateBoundary() {
mockDistrictAndDictionary();
LocalDate boundary = today.minusDays(3);
when(hashOperations.get("Dictionary", "LocalEsDate")).thenReturn(boundary.toString());
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> tools.districtPaperTraceListQuery(9L, start, end, null, null, null, null, null, null));
assertTrue(exception.getMessage().contains("本地化数据分界日"));
verifyNoInteractions(paperUseLogEsGateway);
}
@Test
void routesColdIndexAndMapsRecords() {
mockDistrictAndDictionary();
String coldStart = today.minusDays(100).toString();
String coldEnd = today.minusDays(95).toString();
List<PaperUseLogEsGateway.TraceRecord> traces = List.of(
trace("2026-07-01T10:00:00.000+08:00", 2, "RF-1", "SN-1", 150, 18000, 11L, 3L, 0, 0),
trace("2026-07-01T11:00:00.000+08:00", 4, "RF-2", "SN-1", 80, 9000, 0L, 3L, 0, 0),
trace("2026-07-01T12:00:00.000+08:00", 2, null, "SN-2", 200, 18000, 5L, 4L, 99, 1));
when(paperUseLogEsGateway.searchTraces(eq("paperuselog_all"), anyBoolean(), eq(9L),
anyLong(), anyLong(), isNull(), isNull(), isNull(), isNull(), anyInt(), anyInt()))
.thenReturn(new PaperUseLogEsGateway.TraceSearchResult(42L, traces));
when(rfidInfoMapper.selectList(any())).thenReturn(List.of(assignedRfid("RF-1")));
Map<String, Object> result = tools.districtPaperTraceListQuery(
9L, coldStart, coldEnd, null, null, null, null, null, null);
assertEquals(9L, result.get("districtId"));
assertEquals("测试站点", result.get("districtName"));
assertEquals("paperuselog_all", result.get("index"));
assertEquals(42L, result.get("total"));
assertEquals(1, result.get("page"));
assertEquals(20, result.get("rows"));
assertNull(result.get("notice"));
List<Map<String, Object>> records = recordsOf(result);
assertEquals(3, records.size());
// 上纸行:stateText=上纸、genderText、isAssignment=true(rfid_info assignment_flag≥1)
Map<String, Object> first = records.get(0);
assertEquals("2026-07-01T10:00:00.000+08:00", first.get("createTime"));
assertEquals(2, first.get("state"));
assertEquals("上纸", first.get("stateText"));
assertEquals("RF-1", first.get("rfid"));
assertEquals(150, first.get("lengthCm"));
assertEquals(18000, first.get("paperlengthCm"));
assertEquals(11L, first.get("positionsId"));
assertEquals(3L, first.get("toiletId"));
assertEquals(0, first.get("gender"));
assertEquals("男", first.get("genderText"));
assertEquals(0, first.get("deviceType"));
assertEquals(Boolean.TRUE, first.get("isAssignment"));
// 撤纸行:positionsId=0 为厕所级;RF-2 不在 rfid_info 命中列表 → false
Map<String, Object> second = records.get(1);
assertEquals(4, second.get("state"));
assertEquals("撤纸", second.get("stateText"));
assertEquals(0L, second.get("positionsId"));
assertEquals(Boolean.FALSE, second.get("isAssignment"));
// rfid 为空 → isAssignment=false;未知 gender 编码 genderText=null
Map<String, Object> third = records.get(2);
assertNull(third.get("rfid"));
assertEquals(Boolean.FALSE, third.get("isAssignment"));
assertEquals(99, third.get("gender"));
assertNull(third.get("genderText"));
}
@Test
void truncatesRowsAbove100WithNotice() {
mockDistrictAndDictionary();
when(paperUseLogEsGateway.searchTraces(any(), anyBoolean(), anyLong(), anyLong(), anyLong(),
isNull(), isNull(), isNull(), isNull(), anyInt(), eq(100)))
.thenReturn(new PaperUseLogEsGateway.TraceSearchResult(0L, List.of()));
Map<String, Object> result = tools.districtPaperTraceListQuery(
9L, start, end, null, null, null, null, null, 500);
assertEquals(100, result.get("rows"));
assertEquals("rows 超过上限 100,已截断为 100", result.get("notice"));
}
@Test
void passesThroughOptionalFiltersAndPagination() {
mockDistrictAndDictionary();
when(paperUseLogEsGateway.searchTraces(eq("paperuselog"), eq(false), eq(9L),
anyLong(), anyLong(), eq("SN-1"), eq("RF-1"), eq(4), eq(1), eq(40), eq(20)))
.thenReturn(new PaperUseLogEsGateway.TraceSearchResult(100L, List.of()));
Map<String, Object> result = tools.districtPaperTraceListQuery(
9L, start, end, " SN-1 ", " RF-1 ", 4, 1, 3, 20);
assertEquals("paperuselog", result.get("index"));
assertEquals(100L, result.get("total"));
assertEquals(3, result.get("page"));
assertEquals(20, result.get("rows"));
assertNull(result.get("notice"));
assertTrue(recordsOf(result).isEmpty());
}
@Test
void emptyRecordsSkipRfidInfoQuery() {
mockDistrictAndDictionary();
when(paperUseLogEsGateway.searchTraces(any(), anyBoolean(), anyLong(), anyLong(), anyLong(),
isNull(), isNull(), isNull(), isNull(), anyInt(), anyInt()))
.thenReturn(new PaperUseLogEsGateway.TraceSearchResult(0L, List.of()));
Map<String, Object> result = tools.districtPaperTraceListQuery(
9L, start, end, null, null, null, null, null, null);
assertTrue(recordsOf(result).isEmpty());
verifyNoInteractions(rfidInfoMapper);
}
@Test
void queryEndBeforeTodayUsesInclusiveEndOfDay() {
mockDistrictAndDictionary();
when(paperUseLogEsGateway.searchTraces(any(), anyBoolean(), anyLong(), anyLong(), anyLong(),
isNull(), isNull(), isNull(), isNull(), anyInt(), anyInt()))
.thenReturn(new PaperUseLogEsGateway.TraceSearchResult(0L, List.of()));
String endDay = today.minusDays(1).toString();
tools.districtPaperTraceListQuery(9L, start, endDay, null, null, null, null, null, null);
ArgumentCaptor<Long> fromCaptor = ArgumentCaptor.forClass(Long.class);
ArgumentCaptor<Long> toCaptor = ArgumentCaptor.forClass(Long.class);
verify(paperUseLogEsGateway).searchTraces(any(), anyBoolean(), anyLong(),
fromCaptor.capture(), toCaptor.capture(),
isNull(), isNull(), isNull(), isNull(), anyInt(), anyInt());
long expectedFrom = today.minusDays(6).atStartOfDay(SH).toInstant().toEpochMilli();
long expectedTo = today.atStartOfDay(SH).toInstant().toEpochMilli() - 1;
assertEquals(expectedFrom, fromCaptor.getValue());
assertEquals(expectedTo, toCaptor.getValue());
}
@Test
void springAiPublishesToolSchema() {
org.springframework.ai.tool.ToolCallback[] callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider
.builder().toolObjects(tools).build().getToolCallbacks();
assertEquals(1, callbacks.length);
assertEquals("district.paper.trace.list.query",
callbacks[0].getToolDefinition().name());
String schema = callbacks[0].getToolDefinition().inputSchema();
assertTrue(schema.contains("districtId"));
assertTrue(schema.contains("startDate"));
assertTrue(schema.contains("snCode"));
assertTrue(schema.contains("rfid"));
assertTrue(schema.contains("state"));
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 792 KiB

+408
View File
@@ -0,0 +1,408 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>小趣运营报告预览</title>
<link rel="stylesheet" href="/api/worktable/template/dshell.css" />
<style>
.rpt { display: flex; flex-direction: column; gap: 12px; padding: 16px 18px; }
.rpt-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; flex-wrap: wrap; }
.rpt-meta { text-align: right; }
.rpt-cols { display: grid; grid-template-columns: 1.55fr 1fr; gap: 12px; align-items: start; }
@media (max-width: 900px) { .rpt-cols { grid-template-columns: 1fr; } }
.rpt-sec { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.rpt-kpi .dshell-statValue { font-size: 21px; line-height: 1.25; }
.rpt-kpi .dshell-statLabel { letter-spacing: .2px; }
.rpt-down { color: #f85149 !important; }
.rpt-num { font-variant-numeric: tabular-nums; }
.rpt-chart { display: block; width: 100%; height: auto; margin-top: 2px; }
.rpt-legend { display: flex; gap: 14px; flex-wrap: wrap; }
.rpt-legend span { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--dsw-alias-label-secondary, #9aa4b2); }
.rpt-legend i { width: 12px; height: 3px; border-radius: 2px; display: inline-block; }
.rpt-bar { height: 6px; border-radius: 3px; background: rgba(255,255,255,.07); overflow: hidden; min-width: 64px; }
.rpt-bar > i { display: block; height: 100%; border-radius: 3px; }
.rpt-rank { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; margin-right: 7px; border-radius: 4px; background: rgba(79,142,247,.16); color: #4f8ef7; font-size: 10.5px; font-weight: 600; }
.rpt-tool { display: block; font-size: 10.5px; color: var(--dsw-alias-label-tertiary, #6b7280); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.rpt-err { margin-bottom: 9px; }
.rpt-errHead { display: flex; justify-content: space-between; gap: 10px; font-size: 11.5px; color: var(--dsw-alias-label-secondary, #9aa4b2); margin-bottom: 4px; }
.rpt-errHead code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; color: var(--dsw-alias-label-primary, #e6e8eb); }
.rpt-note { font-size: 11px; color: var(--dsw-alias-label-tertiary, #6b7280); line-height: 1.75; }
.rpt-sum { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px 14px; }
</style>
</head>
<body>
<div class="dshell rpt">
<!-- 标题区 -->
<div class="rpt-head">
<div>
<h1 class="dshell-title" style="font-size:19px">小趣运营报告 · 2026 年 9 月</h1>
<p class="dshell-sub">统计周期 2026-09-01 ~ 2026-09-30 · 数据源:小趣MCP(m0/m1/m2 库 + ES paperuselog)</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
<span class="dshell-badge dshell-badgeDone">跑批完成</span>
<span class="dshell-badge">只读工具 32 个</span>
<span class="dshell-badge dshell-badgeWait">演示数据</span>
</div>
</div>
<div class="rpt-meta dshell-muted">
生成时间 2026-10-10 16:12<br />
schema_version v1 · 行级权限:总部视角<br />
生成方式:district.paper.statistics.query 等
</div>
</div>
<!-- KPI -->
<div class="dshell-grid rpt-kpi">
<div class="dshell-stat">
<div class="dshell-statLabel">用纸长度</div>
<div class="dshell-statValue">22.57 万米</div>
<div class="dshell-statDelta">+8.3% 环比</div>
</div>
<div class="dshell-stat">
<div class="dshell-statLabel">使用人次</div>
<div class="dshell-statValue">68.42 万</div>
<div class="dshell-statDelta">+5.1% 环比</div>
</div>
<div class="dshell-stat">
<div class="dshell-statLabel">换纸卷数</div>
<div class="dshell-statValue">2,468 卷</div>
<div class="dshell-statDelta rpt-down">-2.4% 环比</div>
</div>
<div class="dshell-stat">
<div class="dshell-statLabel">台日合计</div>
<div class="dshell-statValue">15,208</div>
<div class="dshell-statDelta">+3.6% 环比</div>
</div>
<div class="dshell-stat">
<div class="dshell-statLabel">单次用纸</div>
<div class="dshell-statValue">0.330 米</div>
<div class="dshell-statDelta rpt-down">-1.2% 环比</div>
</div>
<div class="dshell-stat">
<div class="dshell-statLabel">在用站点 / 设备</div>
<div class="dshell-statValue">386 / 1,126</div>
<div class="dshell-statDelta">+4 个站点 环比</div>
</div>
</div>
<!-- 趋势 -->
<div class="dshell-card">
<div class="rpt-sec">
<h2 class="dshell-sub" style="font-size:13px;color:var(--dsw-alias-label-primary,#e6e8eb)">每日用纸长度走势(单位:米)</h2>
<div class="rpt-legend">
<span><i style="background:#3fb950"></i>9 月日均 7,524</span>
<span><i style="background:#d29922"></i>周末峰值</span>
<span><i style="background:#6b7280"></i>8 月日均 6,947</span>
</div>
</div>
<svg class="rpt-chart" viewBox="0 0 780 224" role="img" aria-label="每日用纸长度走势">
<defs>
<linearGradient id="g1" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3fb950" stop-opacity=".34" />
<stop offset="100%" stop-color="#3fb950" stop-opacity="0" />
</linearGradient>
</defs>
<line x1="54" y1="196.0" x2="766" y2="196.0" stroke="rgba(255,255,255,.07)" stroke-width="1"/>
<text x="46" y="199.5" fill="#6b7280" font-size="10" text-anchor="end">0</text>
<line x1="54" y1="136.0" x2="766" y2="136.0" stroke="rgba(255,255,255,.07)" stroke-width="1"/>
<text x="46" y="139.5" fill="#6b7280" font-size="10" text-anchor="end">4k</text>
<line x1="54" y1="76.0" x2="766" y2="76.0" stroke="rgba(255,255,255,.07)" stroke-width="1"/>
<text x="46" y="79.5" fill="#6b7280" font-size="10" text-anchor="end">8k</text>
<line x1="54" y1="16.0" x2="766" y2="16.0" stroke="rgba(255,255,255,.07)" stroke-width="1"/>
<text x="46" y="19.5" fill="#6b7280" font-size="10" text-anchor="end">12k</text>
<line x1="54" y1="91.8" x2="766" y2="91.8" stroke="#6b7280" stroke-width="1" stroke-dasharray="4 4" />
<path d="M54.0,99.4 L78.6,96.6 L103.1,93.5 L127.7,90.4 L152.2,75.6 L176.8,62.6 L201.3,98.3 L225.9,93.9 L250.4,89.2 L275.0,93.8 L299.5,89.8 L324.1,75.8 L348.6,63.7 L373.2,92.7 L397.7,89.0 L422.3,95.3 L446.8,89.2 L471.4,88.9 L495.9,63.8 L520.5,66.5 L545.0,92.4 L569.6,91.6 L594.1,85.1 L618.7,78.7 L643.2,81.1 L667.8,51.2 L692.3,57.3 L716.9,85.2 L741.4,87.7 L766.0,76.0 L766.0,196.0 L54.0,196.0 Z" fill="url(#g1)" />
<path d="M54.0,99.4 L78.6,96.6 L103.1,93.5 L127.7,90.4 L152.2,75.6 L176.8,62.6 L201.3,98.3 L225.9,93.9 L250.4,89.2 L275.0,93.8 L299.5,89.8 L324.1,75.8 L348.6,63.7 L373.2,92.7 L397.7,89.0 L422.3,95.3 L446.8,89.2 L471.4,88.9 L495.9,63.8 L520.5,66.5 L545.0,92.4 L569.6,91.6 L594.1,85.1 L618.7,78.7 L643.2,81.1 L667.8,51.2 L692.3,57.3 L716.9,85.2 L741.4,87.7 L766.0,76.0" fill="none" stroke="#3fb950" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round" />
<circle cx="152.2" cy="75.6" r="2.6" fill="#0b0e14" stroke="#d29922" stroke-width="1.6"/>
<circle cx="176.8" cy="62.6" r="2.6" fill="#0b0e14" stroke="#d29922" stroke-width="1.6"/>
<circle cx="324.1" cy="75.8" r="2.6" fill="#0b0e14" stroke="#d29922" stroke-width="1.6"/>
<circle cx="348.6" cy="63.7" r="2.6" fill="#0b0e14" stroke="#d29922" stroke-width="1.6"/>
<circle cx="495.9" cy="63.8" r="2.6" fill="#0b0e14" stroke="#d29922" stroke-width="1.6"/>
<circle cx="520.5" cy="66.5" r="2.6" fill="#0b0e14" stroke="#d29922" stroke-width="1.6"/>
<circle cx="667.8" cy="51.2" r="2.6" fill="#0b0e14" stroke="#d29922" stroke-width="1.6"/>
<circle cx="692.3" cy="57.3" r="2.6" fill="#0b0e14" stroke="#d29922" stroke-width="1.6"/>
<text x="54.0" y="216" fill="#6b7280" font-size="10" text-anchor="middle">9/1</text>
<text x="176.8" y="216" fill="#6b7280" font-size="10" text-anchor="middle">9/6</text>
<text x="348.6" y="216" fill="#6b7280" font-size="10" text-anchor="middle">9/13</text>
<text x="520.5" y="216" fill="#6b7280" font-size="10" text-anchor="middle">9/20</text>
<text x="692.3" y="216" fill="#6b7280" font-size="10" text-anchor="middle">9/27</text>
<text x="766.0" y="216" fill="#6b7280" font-size="10" text-anchor="middle">9/30</text>
<text x="54" y="12" fill="#6b7280" font-size="10">日均 7,524 · 峰值 9/26 9,655 · 谷值 9/1 6,440</text>
</svg>
</div>
<!-- 两栏 -->
<div class="rpt-cols">
<div>
<div class="dshell-card">
<div class="rpt-sec">
<h2 class="dshell-sub" style="font-size:13px;color:var(--dsw-alias-label-primary,#e6e8eb)">性别维度用纸结构</h2>
<span class="dshell-muted">gender 为 null 的行按口径跳过</span>
</div>
<table class="dshell-table">
<thead>
<tr><th>性别</th><th>用纸长度(米)</th><th>使用人次</th><th>单次(米)</th><th>环比</th><th>占比</th></tr>
</thead>
<tbody>
<tr>
<td>男</td>
<td class="rpt-num">129,563</td>
<td class="rpt-num">364,063</td>
<td class="rpt-num">0.356</td>
<td class="rpt-num" style="color:#3fb950">+9.4%</td>
<td><div class="rpt-bar"><i style="width:57.4%;background:#3fb950"></i></div></td>
</tr>
<tr>
<td>女</td>
<td class="rpt-num">96,157</td>
<td class="rpt-num">320,137</td>
<td class="rpt-num">0.300</td>
<td class="rpt-num" style="color:#3fb950">+6.6%</td>
<td><div class="rpt-bar"><i style="width:42.6%;background:#3fb950"></i></div></td>
</tr>
</tbody>
</table>
</div>
<div class="dshell-card" style="margin-top:12px">
<div class="rpt-sec">
<h2 class="dshell-sub" style="font-size:13px;color:var(--dsw-alias-label-primary,#e6e8eb)">站点用纸 TOP 5</h2>
<span class="dshell-muted">TOP5 合计 78,275 米 · 占全网 34.7%</span>
</div>
<table class="dshell-table">
<thead>
<tr><th>站点</th><th>城市</th><th>用纸长度</th><th>使用人次</th><th>台日</th><th>换纸卷</th><th>环比</th><th>相对量级</th></tr>
</thead>
<tbody>
<tr>
<td><span class="rpt-rank">1</span>环球港购物中心站</td>
<td class="dshell-muted">上海</td>
<td class="rpt-num">21,480</td>
<td class="rpt-num">61,240</td>
<td class="rpt-num">1,206</td>
<td class="rpt-num">214</td>
<td class="rpt-num" style="color:#3fb950">+12.4%</td>
<td><div class="rpt-bar"><i style="width:100.0%;background:#4f8ef7"></i></div></td>
</tr>
<tr>
<td><span class="rpt-rank">2</span>中央车站枢纽站</td>
<td class="dshell-muted">上海</td>
<td class="rpt-num">18,930</td>
<td class="rpt-num">57,860</td>
<td class="rpt-num">1,140</td>
<td class="rpt-num">241</td>
<td class="rpt-num" style="color:#3fb950">+6.1%</td>
<td><div class="rpt-bar"><i style="width:88.1%;background:#4f8ef7"></i></div></td>
</tr>
<tr>
<td><span class="rpt-rank">3</span>滨江公园站</td>
<td class="dshell-muted">杭州</td>
<td class="rpt-num">15,260</td>
<td class="rpt-num">48,930</td>
<td class="rpt-num">1,082</td>
<td class="rpt-num">132</td>
<td class="rpt-num" style="color:#3fb950">+21.7%</td>
<td><div class="rpt-bar"><i style="width:71.0%;background:#4f8ef7"></i></div></td>
</tr>
<tr>
<td><span class="rpt-rank">4</span>市民中心广场站</td>
<td class="dshell-muted">苏州</td>
<td class="rpt-num">12,845</td>
<td class="rpt-num">39,420</td>
<td class="rpt-num">1,015</td>
<td class="rpt-num">118</td>
<td class="rpt-num" style="color:#f85149">-3.2%</td>
<td><div class="rpt-bar"><i style="width:59.8%;background:#4f8ef7"></i></div></td>
</tr>
<tr>
<td><span class="rpt-rank">5</span>星河湾社区站</td>
<td class="dshell-muted">南京</td>
<td class="rpt-num">9,760</td>
<td class="rpt-num">28,150</td>
<td class="rpt-num">962</td>
<td class="rpt-num">96</td>
<td class="rpt-num" style="color:#3fb950">+1.8%</td>
<td><div class="rpt-bar"><i style="width:45.4%;background:#4f8ef7"></i></div></td>
</tr>
</tbody>
</table>
</div>
<div class="dshell-card" style="margin-top:12px">
<div class="rpt-sec">
<h2 class="dshell-sub" style="font-size:13px;color:var(--dsw-alias-label-primary,#e6e8eb)">分周汇总</h2>
<span class="dshell-muted">环比口径:日均(消除各周天数差异)</span>
</div>
<table class="dshell-table">
<thead>
<tr><th>周次</th><th>用纸长度(米)</th><th>用纸日均</th><th>使用人次</th><th>台日</th><th>日均环比</th><th>量级</th></tr>
</thead>
<tbody>
<tr>
<td>第 1 周 · 9/1–9/6</td>
<td class="rpt-num">43,856</td>
<td class="rpt-num">7,309</td>
<td class="rpt-num">132,935</td>
<td class="rpt-num">2,961</td>
<td class="rpt-num" style="color:inherit">—</td>
<td><div class="rpt-bar"><i style="width:92.0%;background:#3fb950"></i></div></td>
</tr>
<tr>
<td>第 2 周 · 9/7–9/13</td>
<td class="rpt-num">51,174</td>
<td class="rpt-num">7,311</td>
<td class="rpt-num">155,116</td>
<td class="rpt-num">3,451</td>
<td class="rpt-num" style="color:#3fb950">+0.0%</td>
<td><div class="rpt-bar"><i style="width:92.0%;background:#3fb950"></i></div></td>
</tr>
<tr>
<td>第 3 周 · 9/14–9/20</td>
<td class="rpt-num">52,444</td>
<td class="rpt-num">7,492</td>
<td class="rpt-num">158,970</td>
<td class="rpt-num">3,535</td>
<td class="rpt-num" style="color:#3fb950">+2.5%</td>
<td><div class="rpt-bar"><i style="width:94.3%;background:#3fb950"></i></div></td>
</tr>
<tr>
<td>第 4 周 · 9/21–9/27</td>
<td class="rpt-num">55,640</td>
<td class="rpt-num">7,949</td>
<td class="rpt-num">168,655</td>
<td class="rpt-num">3,739</td>
<td class="rpt-num" style="color:#3fb950">+6.1%</td>
<td><div class="rpt-bar"><i style="width:100.0%;background:#3fb950"></i></div></td>
</tr>
<tr>
<td>第 5 周 · 9/28–9/30</td>
<td class="rpt-num">22,606</td>
<td class="rpt-num">7,535</td>
<td class="rpt-num">68,525</td>
<td class="rpt-num">1,522</td>
<td class="rpt-num" style="color:#f85149">-5.2%</td>
<td><div class="rpt-bar"><i style="width:94.8%;background:#3fb950"></i></div></td>
</tr>
</tbody>
</table>
</div>
</div>
<div>
<div class="dshell-card">
<div class="rpt-sec">
<h2 class="dshell-sub" style="font-size:13px;color:var(--dsw-alias-label-primary,#e6e8eb)">工单与订单待办</h2>
<span class="dshell-muted">共 71 单</span>
</div>
<div class="dshell-list">
<div class="dshell-listItem">
<span class="dshell-listItemTitle">出库待办<span class="rpt-tool">warehouse.todo.list.query</span></span>
<span style="display:flex;align-items:center;gap:10px">
<b class="rpt-num" style="font-size:13px">12</b>
<span class="dshell-badge dshell-badgeWait">待处理</span>
</span>
</div>
<div class="dshell-listItem">
<span class="dshell-listItemTitle">安装单待派<span class="rpt-tool">install.order.list.query</span></span>
<span style="display:flex;align-items:center;gap:10px">
<b class="rpt-num" style="font-size:13px">7</b>
<span class="dshell-badge dshell-badgeWait">待派单</span>
</span>
</div>
<div class="dshell-listItem">
<span class="dshell-listItemTitle">取件单超时<span class="rpt-tool">pickup.order.list.query</span></span>
<span style="display:flex;align-items:center;gap:10px">
<b class="rpt-num" style="font-size:13px">3</b>
<span class="dshell-badge dshell-badgeWait">需跟进</span>
</span>
</div>
<div class="dshell-listItem">
<span class="dshell-listItemTitle">设备报障未闭环<span class="rpt-tool">battery.fault.list.query</span></span>
<span style="display:flex;align-items:center;gap:10px">
<b class="rpt-num" style="font-size:13px">5</b>
<span class="dshell-badge dshell-badgeWait">处理中</span>
</span>
</div>
<div class="dshell-listItem">
<span class="dshell-listItemTitle">入库单已完结<span class="rpt-tool">warehouse.in.list.query</span></span>
<span style="display:flex;align-items:center;gap:10px">
<b class="rpt-num" style="font-size:13px">26</b>
<span class="dshell-badge dshell-badgeDone">已完成</span>
</span>
</div>
<div class="dshell-listItem">
<span class="dshell-listItemTitle">撤机单已完结<span class="rpt-tool">withdraw.order.list.query</span></span>
<span style="display:flex;align-items:center;gap:10px">
<b class="rpt-num" style="font-size:13px">18</b>
<span class="dshell-badge dshell-badgeDone">已完成</span>
</span>
</div>
</div>
</div>
<div class="dshell-card" style="margin-top:12px">
<div class="rpt-sec">
<h2 class="dshell-sub" style="font-size:13px;color:var(--dsw-alias-label-primary,#e6e8eb)">MCP 服务运行时</h2>
<span class="dshell-badge dshell-badgeDone">健康</span>
</div>
<div class="rpt-sum">
<div><div class="dshell-statLabel">工具调用</div><div class="rpt-num" style="font-size:14px">18,642</div></div>
<div><div class="dshell-statLabel">错误率</div><div class="rpt-num" style="font-size:14px">0.42%</div></div>
<div><div class="dshell-statLabel">P95 耗时</div><div class="rpt-num" style="font-size:14px">320 ms</div></div>
<div><div class="dshell-statLabel">P99 耗时</div><div class="rpt-num" style="font-size:14px">780 ms</div></div>
</div>
<div class="dshell-divider"></div>
<div class="dshell-muted" style="margin-bottom:8px">错误码分布(共 74 次)</div>
<div class="rpt-err">
<div class="rpt-errHead"><code>PERMISSION_DENIED</code><span>34 次 · 46%</span></div>
<div class="rpt-bar"><i style="width:100.0%;background:#d29922"></i></div>
</div>
<div class="rpt-err">
<div class="rpt-errHead"><code>NOT_FOUND_OR_FORBIDDEN</code><span>21 次 · 28%</span></div>
<div class="rpt-bar"><i style="width:61.8%;background:#d29922"></i></div>
</div>
<div class="rpt-err">
<div class="rpt-errHead"><code>UPSTREAM_UNAVAILABLE</code><span>14 次 · 19%</span></div>
<div class="rpt-bar"><i style="width:41.2%;background:#d29922"></i></div>
</div>
<div class="rpt-err">
<div class="rpt-errHead"><code>DATA_INTEGRITY_ERROR</code><span>5 次 · 7%</span></div>
<div class="rpt-bar"><i style="width:14.7%;background:#d29922"></i></div>
</div>
<div class="dshell-divider"></div>
<div class="dshell-muted" style="margin-bottom:8px">客户端来源占比</div>
<div class="rpt-err">
<div class="rpt-errHead"><span>mcp-kimi</span><span>61%</span></div>
<div class="rpt-bar"><i style="width:100.0%;background:#4f8ef7"></i></div>
</div>
<div class="rpt-err">
<div class="rpt-errHead"><span>mcp-claude</span><span>27%</span></div>
<div class="rpt-bar"><i style="width:44.3%;background:#4f8ef7"></i></div>
</div>
<div class="rpt-err">
<div class="rpt-errHead"><span>cli</span><span>8%</span></div>
<div class="rpt-bar"><i style="width:13.1%;background:#4f8ef7"></i></div>
</div>
<div class="rpt-err">
<div class="rpt-errHead"><span>unknown</span><span>4%</span></div>
<div class="rpt-bar"><i style="width:6.6%;background:#4f8ef7"></i></div>
</div>
</div>
</div>
</div>
<!-- 口径 -->
<div class="dshell-card">
<div class="dshell-muted" style="margin-bottom:6px">口径与说明</div>
<div class="rpt-note">
1. 用纸长度 = Σpaper_length / 100(米,3 位小数);使用人次仅累计 position_id&gt;0 且 runl_mode=0 的厕位模式行;台日合计为按日去重厕位(p_)与厕墙(t_)数的累加;单次用纸 = 用纸长度 / 使用人次。<br />
2. 换纸卷数 rfidCount 取自 ES:统计区间距今 &lt;90 天走热索引 paperuselog,否则冷索引 paperuselog_all;站点 district_strategy.rfid_use_flag=1 时按 state∈{2 上纸, 4 撤纸} 的 rfid 去重计数(去重上限 10000),否则取 state=2 记录数。<br />
3. 上述指标由小趣MCP 只读工具产出(district.paper.statistics.query、warehouse.todo.list.query、install.order.list.query、pickup.order.list.query、battery.fault.list.query 等),调用审计落 audit_log,Token 与手机号均已脱敏。<br />
4. <b>本页为版式预览稿,数值为演示数据</b>;接入真实库后按同一模板替换即可,字段名与工具返回保持一致。
</div>
</div>
</div>
</body>
</html>