Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b245ad757b |
+1
@@ -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> {
|
||||
}
|
||||
+253
@@ -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 查询换纸记录明细失败"));
|
||||
}
|
||||
}
|
||||
|
||||
+314
@@ -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 |
@@ -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>0 且 runl_mode=0 的厕位模式行;台日合计为按日去重厕位(p_)与厕墙(t_)数的累加;单次用纸 = 用纸长度 / 使用人次。<br />
|
||||
2. 换纸卷数 rfidCount 取自 ES:统计区间距今 <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>
|
||||
Reference in New Issue
Block a user