feat(mcp): 新增设备日均用纸统计工具 (REQ-20260922-0007)
This commit is contained in:
@@ -92,6 +92,8 @@ xiaoqu-agent-service/
|
||||
| `pickup.order.list.query` | workorder | read | 取货工单分页查询;申请/取货/完成日期独立过滤,物流按页批量富化 |
|
||||
| `pickup.order.detail.query` | workorder | read | 取货工单详情;返回物流、取货物品、SN 调整、附件、流程及关联单据 |
|
||||
| `district.paper.statistics.query` | hardware | read | 客户用纸统计(复刻老 /MonthlyStatistics/getMonthlyStatisticsMap)。入参 districtId/startDate/endDate/positionMode/deviceType/buildingId/floor/toiletId;出参 toiletGenders、summaryByGender(paperLengthM 米/useCount/perUseLengthM/deviceDayCount/rfidCount)、daily 按日明细。DB 聚合 m0 monthly_statistics;rfidCount 查 ES paperuselog(90 天热/冷索引,LocalEsDate 云/本地集群分界,rfid_use_flag 分口径)。详细设计见 doc/district-paper-statistics-design.md |
|
||||
| `district.paper.percapita.query` | district | read | 人均用纸统计。groupBy=gender/floor/floorGender 与 daily 组合覆盖老系统 5 个接口;返回面向 LLM 的扁平 rows(维度、用纸米数、使用人次、人均值同一行),无数据不补零。数据源为 m0 monthly_statistics,不查 ES;仅绑定用户委托作用域。详细设计见 doc/paper-percapita-statistics-mcp-design.md |
|
||||
| `district.paper.device_average.query` | hardware | read | 设备日均用纸统计。view=gender/daily_gender/floor/daily_floor/floor_gender 覆盖老系统 5 个硬件统计接口;数据库按视图聚合,固定以 COUNT(*) 为设备日统计记录数分母,返回统一 rows 和 summary。用户委托作用域与 ops-query 服务身份均可调用。 |
|
||||
|
||||
分页统一规范:page 从 1 开始,rows 默认 20、上限 100,超出截断为 100 并在返回 `notice` 字段提示。
|
||||
|
||||
|
||||
@@ -782,6 +782,71 @@
|
||||
|
||||
---
|
||||
|
||||
## 31. district.paper.percapita.query
|
||||
|
||||
查询站点人均用纸统计,覆盖老 `StatisticsReportController` 的五个接口。
|
||||
|
||||
- 分类:district | 类型:读
|
||||
- 数据来源:m0 `monthly_statistics` 日汇总表,固定 `soft_delete=0`、指定站点及日期范围;不查 ES。
|
||||
- 入参:`districtId`、`startDate`、`endDate`、`groupBy`(gender/floor/floorGender),以及可选
|
||||
`daily`、`deviceType`、`idStrList`。`floorGender+daily=true` 会报参数错误。
|
||||
- `idStrList` 每项严格使用 `buildingId_floor_toiletId` 格式;building、floor、toilet 三列分别独立 IN 过滤,
|
||||
保留老系统语义。
|
||||
- 口径:`paperLengthMeters=Σpaper_length/100`,`perCapita=paperLengthMeters/Σuse_count`,保留两位小数;
|
||||
useCount=0 时 perCapita 取 paperLengthMeters。
|
||||
- 返回固定为扁平 `rows`:每行包含 `date/floor/gender/paperLengthMeters/useCount/perCapita`,
|
||||
未参与的维度为 null;无数据日期或分组不补零。`daily=false` 另返回区间 `totalPerCapita`。
|
||||
- gender 文案:0男卫、1女卫、2母婴室、3无障碍、4第三卫、5其他。
|
||||
- 行级权限:先用 `applyDistrictRowFilter` 校验站点可见性;不可见统一返回“站点不存在或无权访问”。
|
||||
- 日期不得晚于今天,查询天数受 Redis `Dictionary.StatisticsDays` 限制。
|
||||
- scope:仅绑定 `service_enabled=0` 的用户委托作用域,不向服务身份作用域开放。
|
||||
|
||||
详细设计见 `paper-percapita-statistics-mcp-design.md`。
|
||||
|
||||
---
|
||||
|
||||
## 32. district.paper.device_average.query
|
||||
|
||||
查询站点设备日均用纸统计,覆盖老 `StatisticsReportController` 的五个硬件统计接口。
|
||||
|
||||
- 分类:hardware | 类型:读
|
||||
- 数据来源:m0 `monthly_statistics`,固定 `soft_delete=0`、指定站点和日期范围,在数据库中完成聚合;不查 ES。
|
||||
- 入参:`districtId`、`startDate`、`endDate`、`view`,以及可选的 `deviceType`、`buildingIds`、
|
||||
`floors`、`toiletIds`。位置列表内部使用 IN,三个位置维度之间使用 AND。
|
||||
- `view` 固定为:`gender`(卫生间类型)、`daily_gender`(日期×卫生间类型)、`floor`(楼层)、
|
||||
`daily_floor`(日期×楼层)、`floor_gender`(楼层×卫生间类型)。
|
||||
- 口径:`paperLengthMeters=SUM(COALESCE(paper_length,0))/100`,
|
||||
`recordCount=COUNT(*)`,`averagePaperMeters=paperLengthMeters/recordCount`,最终按 HALF_UP 保留两位小数。
|
||||
`use_count` 不参与该指标计算。
|
||||
- 返回 `summary` 和统一的 `rows`。每行包含
|
||||
`date/floor/genderCode/gender/paperLengthMeters/recordCount/averagePaperMeters`,未参与当前 view 的维度为 null;
|
||||
`daily_*` 不补缺失日期。无数据时 `rows=[]`,summary 平均值为 null,并在 notice 说明。
|
||||
- gender 文案:0男卫、1女卫、2母婴室、3无障碍、4第三卫、5其他;null 为未配置,其他整数显示 `未知(code)`。
|
||||
- 行级权限:先用 `applyDistrictRowFilter` 校验站点可见性;不可见统一返回 `NOT_FOUND_OR_FORBIDDEN`。
|
||||
tenant_service 仅可通过 `ops-query` 调用,按总部视角查询。
|
||||
- 日期不得晚于上海时区当天,查询自然日数受 Redis `Dictionary.StatisticsDays` 限制。
|
||||
|
||||
示例响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"districtId": 12610,
|
||||
"districtName": "望京SOHO",
|
||||
"dateRange": {"startDate": "2026-09-01", "endDate": "2026-09-21", "days": 21},
|
||||
"view": "gender",
|
||||
"unit": "米/设备日统计记录",
|
||||
"summary": {"paperLengthMeters": 250.00, "recordCount": 200, "averagePaperMeters": 1.25},
|
||||
"total": 1,
|
||||
"rows": [
|
||||
{"date": null, "floor": null, "genderCode": 0, "gender": "男卫",
|
||||
"paperLengthMeters": 250.00, "recordCount": 200, "averagePaperMeters": 1.25}
|
||||
],
|
||||
"notice": "统计分母为 monthly_statistics 记录数 COUNT(*),不是使用人次 SUM(use_count)"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录:在线查看工具清单
|
||||
|
||||
```bash
|
||||
|
||||
@@ -59,6 +59,21 @@ SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t
|
||||
WHERE s.code = 'ops-query' AND t.name IN ('sys.ping', 'integration.context.get', 'agent.count', 'agent.list.query', 'agent.detail.query', 'property.list.query', 'property.detail.query', 'district.list.query', 'district.detail.get', 'district.paper.statistics.query', 'hardware.list.query', 'hardware.detail.query', 'contract.list.query', 'contract.detail.query', 'mall.order.list.query', 'mall.order.detail.query', 'mall.order.logistics.query', 'install.order.list.query', 'install.order.detail.query', 'delivery.order.list.query', 'delivery.order.detail.query', 'pickup.order.list.query', 'pickup.order.detail.query', 'relocation.order.list.query', 'relocation.order.detail.query', 'withdraw.order.list.query', 'withdraw.order.detail.query', 'warehouse.in.list.query', 'warehouse.in.detail.query', 'warehouse.out.list.query', 'warehouse.out.detail.query', 'warehouse.stock.list.query')
|
||||
AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id);
|
||||
|
||||
-- 人均用纸统计仅开放给用户委托 Token:绑定全部非服务身份作用域,不进入 sync/ops-query。
|
||||
INSERT INTO scope_tool (scope_id, tool_id, create_id)
|
||||
SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t
|
||||
WHERE s.soft_delete = 0 AND s.status = 1 AND s.service_enabled = 0
|
||||
AND t.name = 'district.paper.percapita.query'
|
||||
AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id);
|
||||
|
||||
-- 设备日均用纸统计:用户委托作用域可调用;tenant_service 通过 ops-query 按总部视角查询;不进入 sync。
|
||||
INSERT INTO scope_tool (scope_id, tool_id, create_id)
|
||||
SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t
|
||||
WHERE s.soft_delete = 0 AND s.status = 1
|
||||
AND (s.service_enabled = 0 OR s.code = 'ops-query')
|
||||
AND t.name = 'district.paper.device_average.query'
|
||||
AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- 校验
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package me.iiv.agent.entity.m0;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** monthly_statistics 设备日均用纸数据库聚合结果。 */
|
||||
@Getter
|
||||
@Setter
|
||||
public class DeviceAverageAggregateRow {
|
||||
|
||||
private LocalDate statisticsDate;
|
||||
private String floor;
|
||||
private Integer genderCode;
|
||||
private BigDecimal paperLengthCm;
|
||||
private Long recordCount;
|
||||
}
|
||||
+17
@@ -1,7 +1,24 @@
|
||||
package me.iiv.agent.mapper.m0;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import me.iiv.agent.entity.m0.DeviceAverageAggregateRow;
|
||||
import me.iiv.agent.entity.m0.MonthlyStatistics;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.SelectProvider;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public interface MonthlyStatisticsMapper extends BaseMapper<MonthlyStatistics> {
|
||||
|
||||
@SelectProvider(type = MonthlyStatisticsSqlProvider.class, method = "selectDeviceAverage")
|
||||
List<DeviceAverageAggregateRow> selectDeviceAverage(
|
||||
@Param("view") MonthlyStatisticsSqlProvider.View view,
|
||||
@Param("districtId") Long districtId,
|
||||
@Param("startAt") LocalDateTime startAt,
|
||||
@Param("endExclusive") LocalDateTime endExclusive,
|
||||
@Param("deviceType") Integer deviceType,
|
||||
@Param("buildingIds") List<Long> buildingIds,
|
||||
@Param("floors") List<String> floors,
|
||||
@Param("toiletIds") List<Long> toiletIds);
|
||||
}
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package me.iiv.agent.mapper.m0;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** monthly_statistics 受控聚合 SQL,只允许设备日均统计定义的五种分组。 */
|
||||
public class MonthlyStatisticsSqlProvider {
|
||||
|
||||
public String selectDeviceAverage(Map<String, Object> params) {
|
||||
View view = (View) params.get("view");
|
||||
if (view == null) {
|
||||
throw new IllegalArgumentException("view is required");
|
||||
}
|
||||
|
||||
StringBuilder sql = new StringBuilder("<script>SELECT ")
|
||||
.append(view.selectDimensions())
|
||||
.append(", SUM(COALESCE(paper_length, 0)) AS paper_length_cm")
|
||||
.append(", COUNT(*) AS record_count")
|
||||
.append(" FROM monthly_statistics")
|
||||
.append(" WHERE soft_delete = 0")
|
||||
.append(" AND district_id = #{districtId}")
|
||||
.append(" AND statistics_time >= #{startAt}")
|
||||
.append(" AND statistics_time < #{endExclusive}");
|
||||
|
||||
if (params.get("deviceType") != null) {
|
||||
sql.append(" AND device_type = #{deviceType}");
|
||||
}
|
||||
appendIn(sql, params, "buildingIds", "building_id", "buildingId");
|
||||
appendIn(sql, params, "floors", "floor", "floorValue");
|
||||
appendIn(sql, params, "toiletIds", "toilet_id", "toiletId");
|
||||
sql.append(" GROUP BY ").append(view.groupBy())
|
||||
.append(" ORDER BY ").append(view.orderBy())
|
||||
.append("</script>");
|
||||
return sql.toString();
|
||||
}
|
||||
|
||||
private static void appendIn(StringBuilder sql, Map<String, Object> params,
|
||||
String parameter, String column, String item) {
|
||||
Object value = params.get(parameter);
|
||||
if (!(value instanceof List<?>) || ((List<?>) value).isEmpty()) {
|
||||
return;
|
||||
}
|
||||
sql.append(" AND ").append(column).append(" IN ")
|
||||
.append("<foreach collection='").append(parameter)
|
||||
.append("' item='").append(item)
|
||||
.append("' open='(' separator=',' close=')'>#{")
|
||||
.append(item).append("}</foreach>");
|
||||
}
|
||||
|
||||
public enum View {
|
||||
GENDER("gender", "NULL AS statistics_date, NULL AS floor, gender AS gender_code",
|
||||
"gender", "gender"),
|
||||
DAILY_GENDER("daily_gender",
|
||||
"DATE(statistics_time) AS statistics_date, NULL AS floor, gender AS gender_code",
|
||||
"DATE(statistics_time), gender", "DATE(statistics_time), gender"),
|
||||
FLOOR("floor", "NULL AS statistics_date, floor, NULL AS gender_code",
|
||||
"floor", "floor"),
|
||||
DAILY_FLOOR("daily_floor",
|
||||
"DATE(statistics_time) AS statistics_date, floor, NULL AS gender_code",
|
||||
"DATE(statistics_time), floor", "DATE(statistics_time), floor"),
|
||||
FLOOR_GENDER("floor_gender", "NULL AS statistics_date, floor, gender AS gender_code",
|
||||
"floor, gender", "floor, gender");
|
||||
|
||||
private final String apiValue;
|
||||
private final String selectDimensions;
|
||||
private final String groupBy;
|
||||
private final String orderBy;
|
||||
|
||||
View(String apiValue, String selectDimensions, String groupBy, String orderBy) {
|
||||
this.apiValue = apiValue;
|
||||
this.selectDimensions = selectDimensions;
|
||||
this.groupBy = groupBy;
|
||||
this.orderBy = orderBy;
|
||||
}
|
||||
|
||||
public String apiValue() {
|
||||
return apiValue;
|
||||
}
|
||||
|
||||
public String selectDimensions() {
|
||||
return selectDimensions;
|
||||
}
|
||||
|
||||
public String groupBy() {
|
||||
return groupBy;
|
||||
}
|
||||
|
||||
public String orderBy() {
|
||||
return orderBy;
|
||||
}
|
||||
|
||||
public boolean includesDate() {
|
||||
return this == DAILY_GENDER || this == DAILY_FLOOR;
|
||||
}
|
||||
|
||||
public boolean includesFloor() {
|
||||
return this == FLOOR || this == DAILY_FLOOR || this == FLOOR_GENDER;
|
||||
}
|
||||
|
||||
public boolean includesGender() {
|
||||
return this == GENDER || this == DAILY_GENDER || this == FLOOR_GENDER;
|
||||
}
|
||||
|
||||
public static View fromApiValue(String value) {
|
||||
for (View view : values()) {
|
||||
if (view.apiValue.equals(value)) {
|
||||
return view;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
package me.iiv.agent.mcp.tool;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import me.iiv.agent.entity.m0.DeviceAverageAggregateRow;
|
||||
import me.iiv.agent.entity.m0.DistrictDistrictStrategy;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.entity.svc.AgentApiKey;
|
||||
import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper;
|
||||
import me.iiv.agent.mapper.m0.MonthlyStatisticsMapper;
|
||||
import me.iiv.agent.mapper.m0.MonthlyStatisticsSqlProvider;
|
||||
import me.iiv.agent.mcp.McpErrorCode;
|
||||
import me.iiv.agent.mcp.ToolCallException;
|
||||
import me.iiv.agent.mcp.ToolObject;
|
||||
import me.iiv.agent.mcp.XqTool;
|
||||
import me.iiv.agent.permission.PermissionService;
|
||||
import me.iiv.agent.security.AuthContext;
|
||||
import me.iiv.agent.security.AuthContextHolder;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** 设备日均用纸统计:覆盖老 StatisticsReportController 的五种硬件统计视图。 */
|
||||
@Component
|
||||
public class HardwareStatisticsTools implements ToolObject {
|
||||
|
||||
private static final String DICTIONARY_KEY = "Dictionary";
|
||||
private static final String DICT_STATISTICS_DAYS = "StatisticsDays";
|
||||
private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
|
||||
private static final BigDecimal CENTIMETERS_PER_METER = BigDecimal.valueOf(100);
|
||||
private static final String COUNT_NOTICE =
|
||||
"统计分母为 monthly_statistics 记录数 COUNT(*),不是使用人次 SUM(use_count)";
|
||||
|
||||
final DistrictDistrictStrategyMapper districtMapper;
|
||||
final MonthlyStatisticsMapper monthlyStatisticsMapper;
|
||||
final PermissionService permissionService;
|
||||
final StringRedisTemplate redisTemplate;
|
||||
|
||||
public HardwareStatisticsTools(DistrictDistrictStrategyMapper districtMapper,
|
||||
MonthlyStatisticsMapper monthlyStatisticsMapper,
|
||||
PermissionService permissionService,
|
||||
StringRedisTemplate redisTemplate) {
|
||||
this.districtMapper = districtMapper;
|
||||
this.monthlyStatisticsMapper = monthlyStatisticsMapper;
|
||||
this.permissionService = permissionService;
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
@XqTool(category = "hardware", kind = "read")
|
||||
@Tool(name = "district.paper.device_average.query", description = "查询站点设备日均用纸量。"
|
||||
+ "view 仅支持 gender、daily_gender、floor、daily_floor、floor_gender;"
|
||||
+ "返回统一 rows,每行包含日期、楼层、卫生间类型、用纸米数、统计记录数和设备日均值。"
|
||||
+ "分母固定为 monthly_statistics 的 COUNT(*),不是 SUM(use_count)。"
|
||||
+ "日期范围最大天数受 Redis Dictionary.StatisticsDays 限制。")
|
||||
public Map<String, Object> districtPaperDeviceAverageQuery(
|
||||
@ToolParam(description = "站点 id,正整数") Long districtId,
|
||||
@ToolParam(description = "开始日期,yyyy-MM-dd,含当天") String startDate,
|
||||
@ToolParam(description = "结束日期,yyyy-MM-dd,含当天,不得晚于今天") String endDate,
|
||||
@ToolParam(description = "视图:gender、daily_gender、floor、daily_floor、floor_gender") String view,
|
||||
@ToolParam(required = false, description = "设备类型:0=出纸机、1=擦手纸机,缺省不过滤") Integer deviceType,
|
||||
@ToolParam(required = false, description = "楼 id 列表;列表内 IN,与其他位置维度 AND")
|
||||
List<Long> buildingIds,
|
||||
@ToolParam(required = false, description = "楼层列表;列表内 IN,与其他位置维度 AND")
|
||||
List<String> floors,
|
||||
@ToolParam(required = false, description = "卫生间 id 列表;列表内 IN,与其他位置维度 AND")
|
||||
List<Long> toiletIds) {
|
||||
|
||||
if (districtId == null || districtId < 1) {
|
||||
throw invalid("districtId 必填且必须为正整数");
|
||||
}
|
||||
LocalDate start = parseDay(startDate, "startDate");
|
||||
LocalDate end = parseDay(endDate, "endDate");
|
||||
if (start.isAfter(end)) {
|
||||
throw invalid("startDate 不能晚于 endDate");
|
||||
}
|
||||
if (end.isAfter(LocalDate.now(SHANGHAI))) {
|
||||
throw invalid("endDate 不得晚于今天");
|
||||
}
|
||||
MonthlyStatisticsSqlProvider.View selectedView = MonthlyStatisticsSqlProvider.View.fromApiValue(view);
|
||||
if (selectedView == null) {
|
||||
throw invalid("view 仅支持 gender、daily_gender、floor、daily_floor、floor_gender");
|
||||
}
|
||||
if (deviceType != null && deviceType != 0 && deviceType != 1) {
|
||||
throw invalid("deviceType 仅支持 0、1");
|
||||
}
|
||||
List<Long> normalizedBuildingIds = positiveIds(buildingIds, "buildingIds");
|
||||
List<String> normalizedFloors = floorValues(floors);
|
||||
List<Long> normalizedToiletIds = positiveIds(toiletIds, "toiletIds");
|
||||
|
||||
AuthContext context = AuthContextHolder.get();
|
||||
if (context == null || (context.getUser() == null && !isServicePrincipal(context))) {
|
||||
throw new ToolCallException(McpErrorCode.UNAUTHENTICATED, "缺少有效身份上下文");
|
||||
}
|
||||
User user = context.getUser();
|
||||
DistrictDistrictStrategy district = visibleDistrict(districtId, user);
|
||||
|
||||
int maxDays = statisticsDays();
|
||||
int queryDays = (int) ChronoUnit.DAYS.between(start, end) + 1;
|
||||
if (queryDays > maxDays) {
|
||||
throw invalid("查询时间段最大" + maxDays + "天");
|
||||
}
|
||||
|
||||
List<DeviceAverageAggregateRow> aggregates;
|
||||
try {
|
||||
aggregates = monthlyStatisticsMapper.selectDeviceAverage(
|
||||
selectedView,
|
||||
districtId,
|
||||
start.atStartOfDay(),
|
||||
end.plusDays(1).atStartOfDay(),
|
||||
deviceType,
|
||||
normalizedBuildingIds,
|
||||
normalizedFloors,
|
||||
normalizedToiletIds);
|
||||
} catch (RuntimeException exception) {
|
||||
throw new ToolCallException(McpErrorCode.UPSTREAM_UNAVAILABLE, "设备用纸统计数据暂不可用");
|
||||
}
|
||||
|
||||
return response(district, start, end, queryDays, selectedView,
|
||||
aggregates == null ? List.of() : aggregates);
|
||||
}
|
||||
|
||||
private DistrictDistrictStrategy visibleDistrict(Long districtId, User user) {
|
||||
try {
|
||||
LambdaQueryWrapper<DistrictDistrictStrategy> wrapper =
|
||||
new LambdaQueryWrapper<DistrictDistrictStrategy>()
|
||||
.eq(DistrictDistrictStrategy::getSoftDelete, 0)
|
||||
.eq(DistrictDistrictStrategy::getId, districtId);
|
||||
permissionService.applyDistrictRowFilter(wrapper, user);
|
||||
DistrictDistrictStrategy district = districtMapper.selectOne(wrapper);
|
||||
if (district == null) {
|
||||
throw new ToolCallException(McpErrorCode.NOT_FOUND_OR_FORBIDDEN,
|
||||
"站点不存在或无权访问");
|
||||
}
|
||||
return district;
|
||||
} catch (ToolCallException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw new ToolCallException(McpErrorCode.UPSTREAM_UNAVAILABLE, "站点权限数据暂不可用");
|
||||
}
|
||||
}
|
||||
|
||||
private int statisticsDays() {
|
||||
Object value;
|
||||
try {
|
||||
value = redisTemplate.opsForHash().get(DICTIONARY_KEY, DICT_STATISTICS_DAYS);
|
||||
} catch (RuntimeException exception) {
|
||||
throw new ToolCallException(McpErrorCode.UPSTREAM_UNAVAILABLE, "统计日期配置暂不可用");
|
||||
}
|
||||
if (value == null) {
|
||||
throw new ToolCallException(McpErrorCode.UPSTREAM_UNAVAILABLE,
|
||||
"Redis Dictionary 缺少 StatisticsDays 字段");
|
||||
}
|
||||
try {
|
||||
int days = Integer.parseInt(value.toString().trim());
|
||||
if (days < 1) {
|
||||
throw new NumberFormatException("non-positive");
|
||||
}
|
||||
return days;
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new ToolCallException(McpErrorCode.UPSTREAM_UNAVAILABLE,
|
||||
"Redis Dictionary.StatisticsDays 必须为正整数");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> response(DistrictDistrictStrategy district,
|
||||
LocalDate start,
|
||||
LocalDate end,
|
||||
int queryDays,
|
||||
MonthlyStatisticsSqlProvider.View view,
|
||||
List<DeviceAverageAggregateRow> aggregates) {
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
BigDecimal totalPaperLengthCm = BigDecimal.ZERO;
|
||||
long totalRecordCount = 0L;
|
||||
for (DeviceAverageAggregateRow aggregate : aggregates) {
|
||||
validateAggregate(aggregate);
|
||||
totalPaperLengthCm = totalPaperLengthCm.add(aggregate.getPaperLengthCm());
|
||||
totalRecordCount += aggregate.getRecordCount();
|
||||
rows.add(resultRow(aggregate, view));
|
||||
}
|
||||
|
||||
Map<String, Object> dateRange = new LinkedHashMap<>();
|
||||
dateRange.put("startDate", start.toString());
|
||||
dateRange.put("endDate", end.toString());
|
||||
dateRange.put("days", queryDays);
|
||||
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("paperLengthMeters", meters(totalPaperLengthCm));
|
||||
summary.put("recordCount", totalRecordCount);
|
||||
summary.put("averagePaperMeters", totalRecordCount == 0
|
||||
? null : average(totalPaperLengthCm, totalRecordCount));
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("districtId", district.getId());
|
||||
result.put("districtName", district.getDistrictName());
|
||||
result.put("dateRange", dateRange);
|
||||
result.put("view", view.apiValue());
|
||||
result.put("unit", "米/设备日统计记录");
|
||||
result.put("summary", summary);
|
||||
result.put("total", rows.size());
|
||||
result.put("rows", rows);
|
||||
result.put("notice", rows.isEmpty() ? COUNT_NOTICE + ";当前条件无数据" : COUNT_NOTICE);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Map<String, Object> resultRow(DeviceAverageAggregateRow aggregate,
|
||||
MonthlyStatisticsSqlProvider.View view) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("date", view.includesDate() && aggregate.getStatisticsDate() != null
|
||||
? aggregate.getStatisticsDate().toString() : null);
|
||||
row.put("floor", view.includesFloor() ? aggregate.getFloor() : null);
|
||||
row.put("genderCode", view.includesGender() ? aggregate.getGenderCode() : null);
|
||||
row.put("gender", view.includesGender() ? genderText(aggregate.getGenderCode()) : null);
|
||||
row.put("paperLengthMeters", meters(aggregate.getPaperLengthCm()));
|
||||
row.put("recordCount", aggregate.getRecordCount());
|
||||
row.put("averagePaperMeters", average(aggregate.getPaperLengthCm(), aggregate.getRecordCount()));
|
||||
return row;
|
||||
}
|
||||
|
||||
private static void validateAggregate(DeviceAverageAggregateRow aggregate) {
|
||||
if (aggregate == null || aggregate.getPaperLengthCm() == null
|
||||
|| aggregate.getPaperLengthCm().signum() < 0
|
||||
|| aggregate.getRecordCount() == null || aggregate.getRecordCount() < 1) {
|
||||
throw new ToolCallException(McpErrorCode.DATA_INTEGRITY_ERROR, "设备用纸聚合结果异常");
|
||||
}
|
||||
}
|
||||
|
||||
private static BigDecimal meters(BigDecimal centimeters) {
|
||||
return centimeters.divide(CENTIMETERS_PER_METER, 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private static BigDecimal average(BigDecimal centimeters, long recordCount) {
|
||||
return centimeters.divide(CENTIMETERS_PER_METER.multiply(BigDecimal.valueOf(recordCount)),
|
||||
2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private static List<Long> positiveIds(List<Long> values, String name) {
|
||||
if (values == null || values.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
LinkedHashSet<Long> normalized = new LinkedHashSet<>();
|
||||
for (Long value : values) {
|
||||
if (value == null || value < 1) {
|
||||
throw invalid(name + " 每项必须为正整数");
|
||||
}
|
||||
normalized.add(value);
|
||||
}
|
||||
return new ArrayList<>(normalized);
|
||||
}
|
||||
|
||||
private static List<String> floorValues(List<String> values) {
|
||||
if (values == null || values.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
LinkedHashSet<String> normalized = new LinkedHashSet<>();
|
||||
for (String value : values) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
throw invalid("floors 每项不得为空");
|
||||
}
|
||||
normalized.add(value.trim());
|
||||
}
|
||||
return new ArrayList<>(normalized);
|
||||
}
|
||||
|
||||
private static LocalDate parseDay(String value, String name) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
throw invalid(name + " 必填,格式 yyyy-MM-dd");
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(value.trim());
|
||||
} catch (DateTimeParseException exception) {
|
||||
throw invalid(name + " 格式非法,要求 yyyy-MM-dd: " + value);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isServicePrincipal(AuthContext context) {
|
||||
return context.getToken() != null
|
||||
&& AgentApiKey.PRINCIPAL_TYPE_SERVICE.equals(context.getToken().getPrincipalType());
|
||||
}
|
||||
|
||||
private static ToolCallException invalid(String message) {
|
||||
return new ToolCallException(McpErrorCode.INVALID_ARGUMENT, message);
|
||||
}
|
||||
|
||||
private static String genderText(Integer gender) {
|
||||
if (gender == null) {
|
||||
return "未配置";
|
||||
}
|
||||
return switch (gender) {
|
||||
case 0 -> "男卫";
|
||||
case 1 -> "女卫";
|
||||
case 2 -> "母婴室";
|
||||
case 3 -> "无障碍";
|
||||
case 4 -> "第三卫";
|
||||
case 5 -> "其他";
|
||||
default -> "未知(" + gender + ")";
|
||||
};
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package me.iiv.agent.mapper.m0;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import me.iiv.agent.entity.m0.DeviceAverageAggregateRow;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.apache.ibatis.session.SqlSessionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Connection;
|
||||
import java.sql.Statement;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
class MonthlyStatisticsMapperIntegrationTest {
|
||||
|
||||
private SqlSessionFactory sqlSessionFactory;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
JdbcDataSource dataSource = new JdbcDataSource();
|
||||
dataSource.setURL("jdbc:h2:mem:device_average;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1");
|
||||
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
|
||||
statement.execute("DROP TABLE IF EXISTS monthly_statistics");
|
||||
statement.execute("CREATE TABLE monthly_statistics ("
|
||||
+ "id BIGINT AUTO_INCREMENT PRIMARY KEY, statistics_time TIMESTAMP, soft_delete INT,"
|
||||
+ "district_id BIGINT, building_id BIGINT, floor VARCHAR(20), toilet_id BIGINT,"
|
||||
+ "gender INT, paper_length INT, use_count INT, device_type INT)");
|
||||
statement.execute("INSERT INTO monthly_statistics"
|
||||
+ "(statistics_time,soft_delete,district_id,building_id,floor,toilet_id,gender,paper_length,use_count,device_type) VALUES"
|
||||
+ "('2026-09-20 00:00:00',0,9,12,'2F',31,0,100,99,0),"
|
||||
+ "('2026-09-20 00:00:00',0,9,12,'2F',32,0,150,99,0),"
|
||||
+ "('2026-09-21 00:00:00',0,9,13,'3F',33,1,50,99,1),"
|
||||
+ "('2026-09-21 00:00:00',1,9,13,'3F',34,1,900,99,1)");
|
||||
}
|
||||
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
configuration.setMapUnderscoreToCamelCase(true);
|
||||
configuration.addMapper(MonthlyStatisticsMapper.class);
|
||||
MybatisSqlSessionFactoryBean factoryBean = new MybatisSqlSessionFactoryBean();
|
||||
factoryBean.setDataSource(dataSource);
|
||||
factoryBean.setConfiguration(configuration);
|
||||
sqlSessionFactory = factoryBean.getObject();
|
||||
}
|
||||
|
||||
@Test
|
||||
void executesAllFiveGroupingsWithCountStar() {
|
||||
try (SqlSession session = sqlSessionFactory.openSession()) {
|
||||
MonthlyStatisticsMapper mapper = session.getMapper(MonthlyStatisticsMapper.class);
|
||||
|
||||
List<DeviceAverageAggregateRow> gender = query(mapper, MonthlyStatisticsSqlProvider.View.GENDER);
|
||||
assertEquals(2, gender.size());
|
||||
assertEquals(2L, gender.get(0).getRecordCount());
|
||||
assertBigDecimal("250", gender.get(0).getPaperLengthCm());
|
||||
|
||||
List<DeviceAverageAggregateRow> dailyGender =
|
||||
query(mapper, MonthlyStatisticsSqlProvider.View.DAILY_GENDER);
|
||||
assertEquals(LocalDate.of(2026, 9, 20), dailyGender.get(0).getStatisticsDate());
|
||||
assertEquals(0, dailyGender.get(0).getGenderCode());
|
||||
|
||||
List<DeviceAverageAggregateRow> floor = query(mapper, MonthlyStatisticsSqlProvider.View.FLOOR);
|
||||
assertEquals("2F", floor.get(0).getFloor());
|
||||
assertNull(floor.get(0).getGenderCode());
|
||||
|
||||
List<DeviceAverageAggregateRow> dailyFloor =
|
||||
query(mapper, MonthlyStatisticsSqlProvider.View.DAILY_FLOOR);
|
||||
assertEquals(LocalDate.of(2026, 9, 20), dailyFloor.get(0).getStatisticsDate());
|
||||
assertEquals("2F", dailyFloor.get(0).getFloor());
|
||||
|
||||
List<DeviceAverageAggregateRow> floorGender =
|
||||
query(mapper, MonthlyStatisticsSqlProvider.View.FLOOR_GENDER);
|
||||
assertEquals("2F", floorGender.get(0).getFloor());
|
||||
assertEquals(0, floorGender.get(0).getGenderCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindsDeviceAndIndependentPositionFilters() {
|
||||
try (SqlSession session = sqlSessionFactory.openSession()) {
|
||||
MonthlyStatisticsMapper mapper = session.getMapper(MonthlyStatisticsMapper.class);
|
||||
List<DeviceAverageAggregateRow> rows = mapper.selectDeviceAverage(
|
||||
MonthlyStatisticsSqlProvider.View.GENDER,
|
||||
9L,
|
||||
atStart(LocalDate.of(2026, 9, 20)),
|
||||
atStart(LocalDate.of(2026, 9, 22)),
|
||||
0,
|
||||
List.of(12L),
|
||||
List.of("2F"),
|
||||
List.of(31L));
|
||||
|
||||
assertEquals(1, rows.size());
|
||||
assertEquals(1L, rows.get(0).getRecordCount());
|
||||
assertBigDecimal("100", rows.get(0).getPaperLengthCm());
|
||||
}
|
||||
}
|
||||
|
||||
private static List<DeviceAverageAggregateRow> query(MonthlyStatisticsMapper mapper,
|
||||
MonthlyStatisticsSqlProvider.View view) {
|
||||
return mapper.selectDeviceAverage(
|
||||
view,
|
||||
9L,
|
||||
atStart(LocalDate.of(2026, 9, 20)),
|
||||
atStart(LocalDate.of(2026, 9, 22)),
|
||||
null,
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of());
|
||||
}
|
||||
|
||||
private static LocalDateTime atStart(LocalDate day) {
|
||||
return day.atStartOfDay();
|
||||
}
|
||||
|
||||
private static void assertBigDecimal(String expected, BigDecimal actual) {
|
||||
assertEquals(0, new BigDecimal(expected).compareTo(actual));
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package me.iiv.agent.mapper.m0;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MonthlyStatisticsSqlProviderTest {
|
||||
|
||||
private final MonthlyStatisticsSqlProvider provider = new MonthlyStatisticsSqlProvider();
|
||||
|
||||
@Test
|
||||
void generatesWhitelistedGroupingsAndBoundFilters() {
|
||||
for (MonthlyStatisticsSqlProvider.View view : MonthlyStatisticsSqlProvider.View.values()) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("view", view);
|
||||
params.put("deviceType", 1);
|
||||
params.put("buildingIds", List.of(12L));
|
||||
params.put("floors", List.of("2F"));
|
||||
params.put("toiletIds", List.of(31L));
|
||||
|
||||
String sql = provider.selectDeviceAverage(params);
|
||||
|
||||
assertTrue(sql.contains("SUM(COALESCE(paper_length, 0)) AS paper_length_cm"));
|
||||
assertTrue(sql.contains("COUNT(*) AS record_count"));
|
||||
assertTrue(sql.contains("#{districtId}"));
|
||||
assertTrue(sql.contains("#{deviceType}"));
|
||||
assertTrue(sql.contains("collection='buildingIds'"));
|
||||
assertTrue(sql.contains("collection='floors'"));
|
||||
assertTrue(sql.contains("collection='toiletIds'"));
|
||||
assertFalse(sql.contains("2F"));
|
||||
assertTrue(sql.contains("GROUP BY " + expectedGroupBy(view)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void omitsOptionalFiltersWhenListsAreEmpty() {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("view", MonthlyStatisticsSqlProvider.View.DAILY_GENDER);
|
||||
params.put("deviceType", null);
|
||||
params.put("buildingIds", List.of());
|
||||
params.put("floors", List.of());
|
||||
params.put("toiletIds", List.of());
|
||||
|
||||
String sql = provider.selectDeviceAverage(params);
|
||||
|
||||
assertTrue(sql.contains("DATE(statistics_time) AS statistics_date"));
|
||||
assertTrue(sql.contains("GROUP BY DATE(statistics_time), gender"));
|
||||
assertFalse(sql.contains("device_type ="));
|
||||
assertFalse(sql.contains("building_id IN"));
|
||||
assertFalse(sql.contains("floor IN"));
|
||||
assertFalse(sql.contains("toilet_id IN"));
|
||||
}
|
||||
|
||||
private static String expectedGroupBy(MonthlyStatisticsSqlProvider.View view) {
|
||||
return switch (view) {
|
||||
case GENDER -> "gender";
|
||||
case DAILY_GENDER -> "DATE(statistics_time), gender";
|
||||
case FLOOR -> "floor";
|
||||
case DAILY_FLOOR -> "DATE(statistics_time), floor";
|
||||
case FLOOR_GENDER -> "floor, gender";
|
||||
};
|
||||
}
|
||||
}
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
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.DeviceAverageAggregateRow;
|
||||
import me.iiv.agent.entity.m0.DistrictDistrictStrategy;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.entity.svc.AgentApiKey;
|
||||
import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper;
|
||||
import me.iiv.agent.mapper.m0.MonthlyStatisticsMapper;
|
||||
import me.iiv.agent.mapper.m0.MonthlyStatisticsSqlProvider;
|
||||
import me.iiv.agent.mcp.McpErrorCode;
|
||||
import me.iiv.agent.mcp.ToolCallException;
|
||||
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.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class HardwareStatisticsToolsTest {
|
||||
|
||||
private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
@Mock
|
||||
DistrictDistrictStrategyMapper districtMapper;
|
||||
@Mock
|
||||
MonthlyStatisticsMapper monthlyStatisticsMapper;
|
||||
@Mock
|
||||
PermissionService permissionService;
|
||||
@Mock
|
||||
StringRedisTemplate redisTemplate;
|
||||
@Mock
|
||||
HashOperations<String, Object, Object> hashOperations;
|
||||
|
||||
HardwareStatisticsTools tools;
|
||||
LocalDate today;
|
||||
String start;
|
||||
String end;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
DistrictDistrictStrategy.class);
|
||||
tools = new HardwareStatisticsTools(districtMapper, monthlyStatisticsMapper,
|
||||
permissionService, redisTemplate);
|
||||
User user = new User();
|
||||
user.setId(7L);
|
||||
AuthContextHolder.set(new AuthContext(null, user));
|
||||
today = LocalDate.now(SHANGHAI);
|
||||
start = today.minusDays(2).toString();
|
||||
end = today.toString();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
AuthContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidArgumentsBeforeQuerying() {
|
||||
assertCode(McpErrorCode.INVALID_ARGUMENT,
|
||||
() -> query(null, "gender", null, null, null, null));
|
||||
assertCode(McpErrorCode.INVALID_ARGUMENT,
|
||||
() -> query(0L, "gender", null, null, null, null));
|
||||
assertCode(McpErrorCode.INVALID_ARGUMENT,
|
||||
() -> tools.districtPaperDeviceAverageQuery(9L, "2026/09/01", end,
|
||||
"gender", null, null, null, null));
|
||||
assertCode(McpErrorCode.INVALID_ARGUMENT,
|
||||
() -> query(9L, "unsupported", null, null, null, null));
|
||||
assertCode(McpErrorCode.INVALID_ARGUMENT,
|
||||
() -> query(9L, "gender", 2, null, null, null));
|
||||
assertCode(McpErrorCode.INVALID_ARGUMENT,
|
||||
() -> tools.districtPaperDeviceAverageQuery(9L, end, start,
|
||||
"gender", null, null, null, null));
|
||||
assertCode(McpErrorCode.INVALID_ARGUMENT,
|
||||
() -> query(9L, "gender", null, List.of(0L), null, null));
|
||||
assertCode(McpErrorCode.INVALID_ARGUMENT,
|
||||
() -> query(9L, "gender", null, null, List.of(" "), null));
|
||||
assertCode(McpErrorCode.INVALID_ARGUMENT,
|
||||
() -> query(9L, "gender", null, null, null, List.of(-1L)));
|
||||
verifyNoInteractions(districtMapper, monthlyStatisticsMapper, redisTemplate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingIdentityAndInvisibleDistrict() {
|
||||
AuthContextHolder.clear();
|
||||
assertCode(McpErrorCode.UNAUTHENTICATED,
|
||||
() -> query(9L, "gender", null, null, null, null));
|
||||
verifyNoInteractions(districtMapper, monthlyStatisticsMapper, redisTemplate);
|
||||
|
||||
User user = new User();
|
||||
user.setId(7L);
|
||||
AuthContextHolder.set(new AuthContext(null, user));
|
||||
when(districtMapper.selectOne(any())).thenReturn(null);
|
||||
ToolCallException error = assertCode(McpErrorCode.NOT_FOUND_OR_FORBIDDEN,
|
||||
() -> query(9L, "gender", null, null, null, null));
|
||||
assertEquals("站点不存在或无权访问", error.getRawMessage());
|
||||
verify(permissionService).applyDistrictRowFilter(any(), eq(user));
|
||||
verifyNoInteractions(monthlyStatisticsMapper, redisTemplate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRangeExceedingStatisticsDays() {
|
||||
mockDistrict();
|
||||
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
|
||||
when(hashOperations.get("Dictionary", "StatisticsDays")).thenReturn("2");
|
||||
|
||||
ToolCallException error = assertCode(McpErrorCode.INVALID_ARGUMENT,
|
||||
() -> query(9L, "gender", null, null, null, null));
|
||||
|
||||
assertEquals("查询时间段最大2天", error.getRawMessage());
|
||||
verifyNoInteractions(monthlyStatisticsMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesCountStarForGenderViewAndBuildsSummary() {
|
||||
mockReady();
|
||||
when(monthlyStatisticsMapper.selectDeviceAverage(
|
||||
eq(MonthlyStatisticsSqlProvider.View.GENDER), eq(9L), any(), any(),
|
||||
eq(null), anyList(), anyList(), anyList())).thenReturn(List.of(
|
||||
aggregate(null, null, 0, "250", 2L),
|
||||
aggregate(null, null, 1, "50", 1L)));
|
||||
|
||||
Map<String, Object> result = query(9L, "gender", null, null, null, null);
|
||||
|
||||
assertEquals("米/设备日统计记录", result.get("unit"));
|
||||
assertEquals("gender", result.get("view"));
|
||||
assertEquals(2, result.get("total"));
|
||||
Map<String, Object> summary = summary(result);
|
||||
assertEquals(new BigDecimal("3.00"), summary.get("paperLengthMeters"));
|
||||
assertEquals(3L, summary.get("recordCount"));
|
||||
assertEquals(new BigDecimal("1.00"), summary.get("averagePaperMeters"));
|
||||
|
||||
List<Map<String, Object>> rows = rows(result);
|
||||
assertEquals("男卫", rows.get(0).get("gender"));
|
||||
assertEquals(0, rows.get(0).get("genderCode"));
|
||||
assertEquals(2L, rows.get(0).get("recordCount"));
|
||||
assertEquals(new BigDecimal("1.25"), rows.get(0).get("averagePaperMeters"));
|
||||
assertTrue(result.get("notice").toString().contains("COUNT(*)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsAllFiveViewsToDatabaseAggregation() {
|
||||
mockReady();
|
||||
LocalDate day = today.minusDays(1);
|
||||
when(monthlyStatisticsMapper.selectDeviceAverage(any(), eq(9L), any(), any(),
|
||||
eq(null), anyList(), anyList(), anyList()))
|
||||
.thenAnswer(invocation -> {
|
||||
MonthlyStatisticsSqlProvider.View view = invocation.getArgument(0);
|
||||
return switch (view) {
|
||||
case GENDER -> List.of(aggregate(null, null, 0, "100", 1L));
|
||||
case DAILY_GENDER -> List.of(aggregate(day, null, 1, "100", 1L));
|
||||
case FLOOR -> List.of(aggregate(null, "2F", null, "100", 1L));
|
||||
case DAILY_FLOOR -> List.of(aggregate(day, "2F", null, "100", 1L));
|
||||
case FLOOR_GENDER -> List.of(aggregate(null, "2F", 3, "100", 1L));
|
||||
};
|
||||
});
|
||||
|
||||
assertDimensions(query(9L, "gender", null, null, null, null), null, null, "男卫");
|
||||
assertDimensions(query(9L, "daily_gender", null, null, null, null), day.toString(), null, "女卫");
|
||||
assertDimensions(query(9L, "floor", null, null, null, null), null, "2F", null);
|
||||
assertDimensions(query(9L, "daily_floor", null, null, null, null), day.toString(), "2F", null);
|
||||
assertDimensions(query(9L, "floor_gender", null, null, null, null), null, "2F", "无障碍");
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizesFiltersAndPassesThemAsBoundParameters() {
|
||||
mockReady();
|
||||
when(monthlyStatisticsMapper.selectDeviceAverage(any(), eq(9L), any(), any(),
|
||||
eq(1), anyList(), anyList(), anyList())).thenReturn(List.of());
|
||||
|
||||
query(9L, "gender", 1,
|
||||
List.of(12L, 12L, 13L), List.of(" 2F ", "2F", "3F"), List.of(31L, 32L, 31L));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<Long>> buildingCaptor = ArgumentCaptor.forClass(List.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<String>> floorCaptor = ArgumentCaptor.forClass(List.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<Long>> toiletCaptor = ArgumentCaptor.forClass(List.class);
|
||||
verify(monthlyStatisticsMapper).selectDeviceAverage(
|
||||
eq(MonthlyStatisticsSqlProvider.View.GENDER), eq(9L), any(), any(), eq(1),
|
||||
buildingCaptor.capture(), floorCaptor.capture(), toiletCaptor.capture());
|
||||
assertEquals(List.of(12L, 13L), buildingCaptor.getValue());
|
||||
assertEquals(List.of("2F", "3F"), floorCaptor.getValue());
|
||||
assertEquals(List.of(31L, 32L), toiletCaptor.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsExplicitEmptySummaryAndUnknownGender() {
|
||||
mockReady();
|
||||
when(monthlyStatisticsMapper.selectDeviceAverage(any(), eq(9L), any(), any(),
|
||||
eq(null), anyList(), anyList(), anyList())).thenReturn(List.of());
|
||||
|
||||
Map<String, Object> empty = query(9L, "gender", null, null, null, null);
|
||||
assertEquals(new BigDecimal("0.00"), summary(empty).get("paperLengthMeters"));
|
||||
assertEquals(0L, summary(empty).get("recordCount"));
|
||||
assertNull(summary(empty).get("averagePaperMeters"));
|
||||
assertTrue(empty.get("notice").toString().contains("无数据"));
|
||||
|
||||
when(monthlyStatisticsMapper.selectDeviceAverage(any(), eq(9L), any(), any(),
|
||||
eq(null), anyList(), anyList(), anyList()))
|
||||
.thenReturn(List.of(aggregate(null, null, 99, "0", 1L)));
|
||||
Map<String, Object> unknown = query(9L, "gender", null, null, null, null);
|
||||
assertEquals("未知(99)", rows(unknown).get(0).get("gender"));
|
||||
assertEquals(new BigDecimal("0.00"), rows(unknown).get(0).get("averagePaperMeters"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowsServicePrincipalToUseHeadquartersDistrictView() {
|
||||
AgentApiKey serviceToken = new AgentApiKey();
|
||||
serviceToken.setPrincipalType(AgentApiKey.PRINCIPAL_TYPE_SERVICE);
|
||||
AuthContextHolder.set(new AuthContext(serviceToken, null));
|
||||
mockReady();
|
||||
when(monthlyStatisticsMapper.selectDeviceAverage(any(), eq(9L), any(), any(),
|
||||
eq(null), anyList(), anyList(), anyList())).thenReturn(List.of());
|
||||
|
||||
Map<String, Object> result = query(9L, "gender", null, null, null, null);
|
||||
|
||||
assertEquals(9L, result.get("districtId"));
|
||||
verify(permissionService).applyDistrictRowFilter(any(), eq(null));
|
||||
}
|
||||
|
||||
private Map<String, Object> query(Long districtId, String view, Integer deviceType,
|
||||
List<Long> buildingIds, List<String> floors, List<Long> toiletIds) {
|
||||
return tools.districtPaperDeviceAverageQuery(districtId, start, end, view,
|
||||
deviceType, buildingIds, floors, toiletIds);
|
||||
}
|
||||
|
||||
private void mockDistrict() {
|
||||
DistrictDistrictStrategy district = new DistrictDistrictStrategy();
|
||||
district.setId(9L);
|
||||
district.setDistrictName("测试站点");
|
||||
when(districtMapper.selectOne(any())).thenReturn(district);
|
||||
}
|
||||
|
||||
private void mockReady() {
|
||||
mockDistrict();
|
||||
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
|
||||
when(hashOperations.get("Dictionary", "StatisticsDays")).thenReturn("31");
|
||||
}
|
||||
|
||||
private static DeviceAverageAggregateRow aggregate(LocalDate date, String floor, Integer gender,
|
||||
String paperLengthCm, Long recordCount) {
|
||||
DeviceAverageAggregateRow row = new DeviceAverageAggregateRow();
|
||||
row.setStatisticsDate(date);
|
||||
row.setFloor(floor);
|
||||
row.setGenderCode(gender);
|
||||
row.setPaperLengthCm(new BigDecimal(paperLengthCm));
|
||||
row.setRecordCount(recordCount);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static void assertDimensions(Map<String, Object> result, String date, String floor, String gender) {
|
||||
Map<String, Object> row = rows(result).get(0);
|
||||
assertEquals(date, row.get("date"));
|
||||
assertEquals(floor, row.get("floor"));
|
||||
assertEquals(gender, row.get("gender"));
|
||||
}
|
||||
|
||||
private static ToolCallException assertCode(McpErrorCode code, Runnable invocation) {
|
||||
ToolCallException error = assertThrows(ToolCallException.class, invocation::run);
|
||||
assertEquals(code, error.getCode());
|
||||
return error;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Map<String, Object>> rows(Map<String, Object> result) {
|
||||
return (List<Map<String, Object>>) result.get("rows");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> summary(Map<String, Object> result) {
|
||||
return (Map<String, Object>) result.get("summary");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user