feat(mcp): 新增人均用纸统计工具 (REQ-20260922-0004)
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
# 人均用纸统计 MCP 工具设计
|
||||
|
||||
需求:REQ-20260922-0004
|
||||
|
||||
工具:`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 |
|
||||
|
||||
`floorGender+daily=true` 不在老接口范围内,直接返回参数错误。
|
||||
|
||||
## 2. 数据与口径
|
||||
|
||||
- 数据源: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`,缺失或非法时拒绝查询。
|
||||
|
||||
## 3. 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| districtId | 是 | 正整数站点 ID |
|
||||
| startDate/endDate | 是 | yyyy-MM-dd,含首尾日期,endDate 不晚于今天 |
|
||||
| groupBy | 是 | gender / floor / floorGender |
|
||||
| daily | 否 | 默认 false;true 时增加日期维度 |
|
||||
| deviceType | 否 | 0 出纸机,1 擦手纸机 |
|
||||
| idStrList | 否 | 每项严格为 buildingId_floor_toiletId |
|
||||
|
||||
## 4. 面向 LLM 的返回结构
|
||||
|
||||
返回结构不随分组方式变化。维度和指标位于同一个行对象,无数据日期或分组不生成行。
|
||||
|
||||
```json
|
||||
{
|
||||
"districtId": 12610,
|
||||
"districtName": "望京SOHO",
|
||||
"dateRange": "2026-09-01 ~ 2026-09-21",
|
||||
"unit": "米/人次",
|
||||
"groupBy": "gender",
|
||||
"daily": false,
|
||||
"totalPerCapita": 1.32,
|
||||
"rows": [
|
||||
{
|
||||
"date": null,
|
||||
"floor": null,
|
||||
"gender": "男卫",
|
||||
"paperLengthMeters": 120.5,
|
||||
"useCount": 96,
|
||||
"perCapita": 1.26
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- 未参与分组的 `date/floor/gender` 保持为 null。
|
||||
- `daily=true` 时 `totalPerCapita` 为 null;非按日查询时为整个区间的总人均。
|
||||
- gender 映射:0男卫、1女卫、2母婴室、3无障碍、4第三卫、5其他,其他值显示未知。
|
||||
- 楼层按数据库字符串值的字典序输出。
|
||||
|
||||
## 5. 权限与作用域
|
||||
|
||||
- 查询前用 `PermissionService.applyDistrictRowFilter` 校验当前用户能否查看站点。
|
||||
- 站点不存在和无权访问统一返回“站点不存在或无权访问”。
|
||||
- 工具只绑定 `service_enabled=0` 的用户委托作用域,不向 sync、ops-query 等服务身份作用域开放。
|
||||
|
||||
## 6. 实现文件
|
||||
|
||||
- `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`
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
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.MonthlyStatistics;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper;
|
||||
import me.iiv.agent.mapper.m0.MonthlyStatisticsMapper;
|
||||
import me.iiv.agent.mcp.ToolObject;
|
||||
import me.iiv.agent.mcp.XqTool;
|
||||
import me.iiv.agent.permission.PermissionService;
|
||||
import me.iiv.agent.security.AuthContextHolder;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** 人均用纸统计:覆盖老 StatisticsReportController 的五种分组查询。 */
|
||||
@Component
|
||||
public class PaperPerCapitaStatisticsTools 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 Set<String> GROUPS = Set.of("gender", "floor", "floorGender");
|
||||
|
||||
final DistrictDistrictStrategyMapper districtMapper;
|
||||
final MonthlyStatisticsMapper monthlyStatisticsMapper;
|
||||
final PermissionService permissionService;
|
||||
final StringRedisTemplate redisTemplate;
|
||||
|
||||
public PaperPerCapitaStatisticsTools(DistrictDistrictStrategyMapper districtMapper,
|
||||
MonthlyStatisticsMapper monthlyStatisticsMapper,
|
||||
PermissionService permissionService,
|
||||
StringRedisTemplate redisTemplate) {
|
||||
this.districtMapper = districtMapper;
|
||||
this.monthlyStatisticsMapper = monthlyStatisticsMapper;
|
||||
this.permissionService = permissionService;
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
@XqTool(category = "district", kind = "read")
|
||||
@Tool(name = "district.paper.percapita.query", description = "查询站点人均用纸统计。"
|
||||
+ "用 groupBy=gender/floor/floorGender 选择性别、楼层或楼层与卫生间类型分组;"
|
||||
+ "daily=true 时再按日分组,仅支持 gender 和 floor。"
|
||||
+ "返回面向 LLM 的扁平 rows,每行含 date、floor、gender、paperLengthMeters、useCount、perCapita;"
|
||||
+ "没有数据的日期或分组不会补 0。paperLengthMeters 单位为米,perCapita 单位为米/人次。"
|
||||
+ "日期范围最大天数受 Redis Dictionary.StatisticsDays 限制。")
|
||||
public Map<String, Object> districtPaperPerCapitaQuery(
|
||||
@ToolParam(description = "站点 id,正整数") Long districtId,
|
||||
@ToolParam(description = "开始日期,yyyy-MM-dd,含当天") String startDate,
|
||||
@ToolParam(description = "结束日期,yyyy-MM-dd,含当天,不得晚于今天") String endDate,
|
||||
@ToolParam(description = "分组:gender、floor、floorGender") String groupBy,
|
||||
@ToolParam(required = false, description = "是否按日分组,默认 false;floorGender 不支持 true") Boolean daily,
|
||||
@ToolParam(required = false, description = "设备类型:0=出纸机、1=擦手纸机,缺省不过滤") Integer deviceType,
|
||||
@ToolParam(required = false, description = "位置列表,每项格式 buildingId_floor_toiletId;三列分别独立 IN 过滤")
|
||||
List<String> idStrList) {
|
||||
|
||||
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 (!StringUtils.hasText(groupBy) || !GROUPS.contains(groupBy)) {
|
||||
throw new IllegalArgumentException("groupBy 仅支持 gender、floor、floorGender");
|
||||
}
|
||||
boolean byDay = Boolean.TRUE.equals(daily);
|
||||
if (byDay && "floorGender".equals(groupBy)) {
|
||||
throw new IllegalArgumentException("floorGender 不支持 daily=true");
|
||||
}
|
||||
if (deviceType != null && deviceType != 0 && deviceType != 1) {
|
||||
throw new IllegalArgumentException("deviceType 仅支持 0、1");
|
||||
}
|
||||
PositionFilters filters = parsePositionFilters(idStrList);
|
||||
|
||||
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("站点不存在或无权访问");
|
||||
}
|
||||
|
||||
int maxDays = statisticsDays();
|
||||
int queryDays = (int) ChronoUnit.DAYS.between(start, end) + 1;
|
||||
if (queryDays > maxDays) {
|
||||
throw new IllegalArgumentException("查询时间段最大" + maxDays + "天");
|
||||
}
|
||||
|
||||
List<MonthlyStatistics> sourceRows = queryRows(districtId, start, end, deviceType, filters);
|
||||
Map<GroupKey, Aggregate> aggregates = aggregate(sourceRows, groupBy, byDay);
|
||||
List<Map<String, Object>> resultRows = toResultRows(aggregates);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("districtId", districtId);
|
||||
result.put("districtName", district.getDistrictName());
|
||||
result.put("dateRange", start + " ~ " + end);
|
||||
result.put("unit", "米/人次");
|
||||
result.put("groupBy", groupBy);
|
||||
result.put("daily", byDay);
|
||||
result.put("totalPerCapita", byDay ? null : totalPerCapita(aggregates));
|
||||
result.put("rows", resultRows);
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<MonthlyStatistics> queryRows(Long districtId, LocalDate start, LocalDate end,
|
||||
Integer deviceType, PositionFilters filters) {
|
||||
LambdaQueryWrapper<MonthlyStatistics> wrapper = new LambdaQueryWrapper<MonthlyStatistics>()
|
||||
.eq(MonthlyStatistics::getSoftDelete, 0)
|
||||
.eq(MonthlyStatistics::getDistrictId, districtId)
|
||||
.ge(MonthlyStatistics::getStatisticsTime,
|
||||
Date.from(start.atStartOfDay(SHANGHAI).toInstant()))
|
||||
.lt(MonthlyStatistics::getStatisticsTime,
|
||||
Date.from(end.plusDays(1).atStartOfDay(SHANGHAI).toInstant()));
|
||||
if (deviceType != null) {
|
||||
wrapper.eq(MonthlyStatistics::getDeviceType, deviceType);
|
||||
}
|
||||
if (!filters.buildingIds.isEmpty()) {
|
||||
wrapper.in(MonthlyStatistics::getBuildingId, filters.buildingIds);
|
||||
}
|
||||
if (!filters.floors.isEmpty()) {
|
||||
wrapper.in(MonthlyStatistics::getFloor, filters.floors);
|
||||
}
|
||||
if (!filters.toiletIds.isEmpty()) {
|
||||
wrapper.in(MonthlyStatistics::getToiletId, filters.toiletIds);
|
||||
}
|
||||
return monthlyStatisticsMapper.selectList(wrapper);
|
||||
}
|
||||
|
||||
private Map<GroupKey, Aggregate> aggregate(List<MonthlyStatistics> rows, String groupBy, boolean daily) {
|
||||
Comparator<GroupKey> comparator = Comparator
|
||||
.comparing((GroupKey key) -> key.date, Comparator.nullsFirst(Comparator.naturalOrder()))
|
||||
.thenComparing(key -> key.floor, Comparator.nullsFirst(Comparator.naturalOrder()))
|
||||
.thenComparing(key -> key.gender, Comparator.nullsFirst(Comparator.naturalOrder()));
|
||||
Map<GroupKey, Aggregate> result = new TreeMap<>(comparator);
|
||||
for (MonthlyStatistics row : rows) {
|
||||
String date = null;
|
||||
if (daily) {
|
||||
if (row.getStatisticsTime() == null) {
|
||||
continue;
|
||||
}
|
||||
date = row.getStatisticsTime().toInstant().atZone(SHANGHAI).toLocalDate().toString();
|
||||
}
|
||||
String floor = null;
|
||||
if ("floor".equals(groupBy) || "floorGender".equals(groupBy)) {
|
||||
if (!StringUtils.hasText(row.getFloor())) {
|
||||
continue;
|
||||
}
|
||||
floor = row.getFloor();
|
||||
}
|
||||
Integer gender = null;
|
||||
if ("gender".equals(groupBy) || "floorGender".equals(groupBy)) {
|
||||
if (row.getGender() == null) {
|
||||
continue;
|
||||
}
|
||||
gender = row.getGender();
|
||||
}
|
||||
GroupKey key = new GroupKey(date, floor, gender);
|
||||
Aggregate value = result.computeIfAbsent(key, ignored -> new Aggregate());
|
||||
value.paperLengthCm += row.getPaperLength() == null ? 0L : row.getPaperLength();
|
||||
value.useCount += row.getUseCount() == null ? 0L : row.getUseCount();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> toResultRows(Map<GroupKey, Aggregate> aggregates) {
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (Map.Entry<GroupKey, Aggregate> entry : aggregates.entrySet()) {
|
||||
GroupKey key = entry.getKey();
|
||||
Aggregate value = entry.getValue();
|
||||
double paperLengthMeters = value.paperLengthCm / 100.0;
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("date", key.date);
|
||||
row.put("floor", key.floor);
|
||||
row.put("gender", key.gender == null ? null : genderText(key.gender));
|
||||
row.put("paperLengthMeters", round2(paperLengthMeters));
|
||||
row.put("useCount", value.useCount);
|
||||
row.put("perCapita", value.useCount > 0
|
||||
? round2(paperLengthMeters / value.useCount)
|
||||
: round2(paperLengthMeters));
|
||||
rows.add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private Double totalPerCapita(Map<GroupKey, Aggregate> aggregates) {
|
||||
long paperLengthCm = aggregates.values().stream().mapToLong(value -> value.paperLengthCm).sum();
|
||||
long useCount = aggregates.values().stream().mapToLong(value -> value.useCount).sum();
|
||||
double paperLengthMeters = paperLengthCm / 100.0;
|
||||
return useCount > 0 ? round2(paperLengthMeters / useCount) : round2(paperLengthMeters);
|
||||
}
|
||||
|
||||
private int statisticsDays() {
|
||||
Object value = redisTemplate.opsForHash().get(DICTIONARY_KEY, DICT_STATISTICS_DAYS);
|
||||
if (value == null) {
|
||||
throw new IllegalStateException("Redis Dictionary 缺少 StatisticsDays 字段,拒绝查询");
|
||||
}
|
||||
try {
|
||||
int days = Integer.parseInt(value.toString().trim());
|
||||
if (days < 1) {
|
||||
throw new NumberFormatException("non-positive");
|
||||
}
|
||||
return days;
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalStateException("Redis Dictionary.StatisticsDays 必须为正整数");
|
||||
}
|
||||
}
|
||||
|
||||
private static PositionFilters parsePositionFilters(List<String> values) {
|
||||
PositionFilters filters = new PositionFilters();
|
||||
if (values == null || values.isEmpty()) {
|
||||
return filters;
|
||||
}
|
||||
for (String value : values) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
throw new IllegalArgumentException("idStrList 每项必须为 buildingId_floor_toiletId");
|
||||
}
|
||||
String[] parts = value.trim().split("_", -1);
|
||||
if (parts.length != 3 || !StringUtils.hasText(parts[1])) {
|
||||
throw new IllegalArgumentException("idStrList 每项必须为 buildingId_floor_toiletId: " + value);
|
||||
}
|
||||
filters.buildingIds.add(parsePositiveId(parts[0], "buildingId", value));
|
||||
filters.floors.add(parts[1].trim());
|
||||
filters.toiletIds.add(parsePositiveId(parts[2], "toiletId", value));
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
private static Long parsePositiveId(String value, String name, String source) {
|
||||
try {
|
||||
long id = Long.parseLong(value.trim());
|
||||
if (id < 1) {
|
||||
throw new NumberFormatException("non-positive");
|
||||
}
|
||||
return id;
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("idStrList 中 " + name + " 必须为正整数: " + source);
|
||||
}
|
||||
}
|
||||
|
||||
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 double round2(double value) {
|
||||
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).doubleValue();
|
||||
}
|
||||
|
||||
private static String genderText(Integer gender) {
|
||||
switch (gender) {
|
||||
case 0: return "男卫";
|
||||
case 1: return "女卫";
|
||||
case 2: return "母婴室";
|
||||
case 3: return "无障碍";
|
||||
case 4: return "第三卫";
|
||||
case 5: return "其他";
|
||||
default: return "未知";
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PositionFilters {
|
||||
final Set<Long> buildingIds = new LinkedHashSet<>();
|
||||
final Set<String> floors = new LinkedHashSet<>();
|
||||
final Set<Long> toiletIds = new LinkedHashSet<>();
|
||||
}
|
||||
|
||||
private static final class Aggregate {
|
||||
long paperLengthCm;
|
||||
long useCount;
|
||||
}
|
||||
|
||||
private record GroupKey(String date, String floor, Integer gender) {
|
||||
}
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
package me.iiv.agent.mcp.tool;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import me.iiv.agent.entity.m0.DistrictDistrictStrategy;
|
||||
import me.iiv.agent.entity.m0.MonthlyStatistics;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.mapper.m0.DistrictDistrictStrategyMapper;
|
||||
import me.iiv.agent.mapper.m0.MonthlyStatisticsMapper;
|
||||
import me.iiv.agent.permission.PermissionService;
|
||||
import me.iiv.agent.security.AuthContext;
|
||||
import me.iiv.agent.security.AuthContextHolder;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.redis.core.HashOperations;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PaperPerCapitaStatisticsToolsTest {
|
||||
|
||||
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;
|
||||
|
||||
PaperPerCapitaStatisticsTools tools;
|
||||
LocalDate today;
|
||||
String start;
|
||||
String end;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
DistrictDistrictStrategy.class);
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
MonthlyStatistics.class);
|
||||
tools = new PaperPerCapitaStatisticsTools(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() {
|
||||
assertThrows(IllegalArgumentException.class, () -> query(null, "gender", false, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> query(0L, "gender", false, null, null));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> tools.districtPaperPerCapitaQuery(9L, "2026/09/01", end,
|
||||
"gender", false, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> query(9L, null, false, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> query(9L, "building", false, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> query(9L, "floorGender", true, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> query(9L, "gender", false, 2, null));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> tools.districtPaperPerCapitaQuery(9L, end, start,
|
||||
"gender", false, null, null));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> tools.districtPaperPerCapitaQuery(9L, start, today.plusDays(1).toString(),
|
||||
"gender", false, null, null));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> query(9L, "gender", false, null, List.of("12_2F")));
|
||||
verifyNoInteractions(districtMapper, monthlyStatisticsMapper, redisTemplate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvisibleDistrict() {
|
||||
when(districtMapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
IllegalStateException exception = assertThrows(IllegalStateException.class,
|
||||
() -> query(9L, "gender", false, null, null));
|
||||
|
||||
assertEquals("站点不存在或无权访问", exception.getMessage());
|
||||
verify(permissionService).applyDistrictRowFilter(any(), any());
|
||||
verifyNoInteractions(monthlyStatisticsMapper, redisTemplate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRangeExceedingStatisticsDays() {
|
||||
mockDistrict();
|
||||
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
|
||||
when(hashOperations.get("Dictionary", "StatisticsDays")).thenReturn("2");
|
||||
|
||||
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
|
||||
() -> query(9L, "gender", false, null, null));
|
||||
|
||||
assertEquals("查询时间段最大2天", exception.getMessage());
|
||||
verifyNoInteractions(monthlyStatisticsMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregatesGenderForWholeRangeWithLlmFriendlyRows() {
|
||||
mockReady();
|
||||
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
|
||||
row(today.minusDays(2), "2F", 0, 100, 2),
|
||||
row(today.minusDays(1), "1F", 0, 150, 3),
|
||||
row(today.minusDays(1), "1F", 1, 50, 0)));
|
||||
|
||||
Map<String, Object> result = query(9L, "gender", false, null, null);
|
||||
|
||||
assertEquals(9L, result.get("districtId"));
|
||||
assertEquals("测试站点", result.get("districtName"));
|
||||
assertEquals(start + " ~ " + end, result.get("dateRange"));
|
||||
assertEquals("米/人次", result.get("unit"));
|
||||
assertEquals("gender", result.get("groupBy"));
|
||||
assertEquals(false, result.get("daily"));
|
||||
assertEquals(0.6, result.get("totalPerCapita"));
|
||||
|
||||
List<Map<String, Object>> rows = rows(result);
|
||||
assertEquals(2, rows.size());
|
||||
assertEquals(Map.of(
|
||||
"gender", "男卫",
|
||||
"paperLengthMeters", 2.5,
|
||||
"useCount", 5L,
|
||||
"perCapita", 0.5), withoutNulls(rows.get(0)));
|
||||
assertNull(rows.get(0).get("date"));
|
||||
assertNull(rows.get(0).get("floor"));
|
||||
assertEquals("女卫", rows.get(1).get("gender"));
|
||||
assertEquals(0.5, rows.get(1).get("perCapita"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregatesDailyByFloorWithoutAddingMissingDates() {
|
||||
mockReady();
|
||||
LocalDate onlyDay = today.minusDays(1);
|
||||
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
|
||||
row(onlyDay, "10F", 0, 100, 4),
|
||||
row(onlyDay, "2F", 1, 300, 6)));
|
||||
|
||||
Map<String, Object> result = query(9L, "floor", true, null, null);
|
||||
|
||||
assertNull(result.get("totalPerCapita"));
|
||||
List<Map<String, Object>> rows = rows(result);
|
||||
assertEquals(2, rows.size());
|
||||
assertEquals(onlyDay.toString(), rows.get(0).get("date"));
|
||||
assertEquals("10F", rows.get(0).get("floor"));
|
||||
assertNull(rows.get(0).get("gender"));
|
||||
assertEquals(0.25, rows.get(0).get("perCapita"));
|
||||
assertEquals("2F", rows.get(1).get("floor"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregatesDailyByGender() {
|
||||
mockReady();
|
||||
LocalDate day = today.minusDays(1);
|
||||
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
|
||||
row(day, "1F", 2, 240, 2)));
|
||||
|
||||
Map<String, Object> result = query(9L, "gender", true, null, null);
|
||||
|
||||
List<Map<String, Object>> rows = rows(result);
|
||||
assertEquals(1, rows.size());
|
||||
assertEquals(day.toString(), rows.get(0).get("date"));
|
||||
assertNull(rows.get(0).get("floor"));
|
||||
assertEquals("母婴室", rows.get(0).get("gender"));
|
||||
assertEquals(1.2, rows.get(0).get("perCapita"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregatesFloorForWholeRange() {
|
||||
mockReady();
|
||||
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
|
||||
row(today.minusDays(2), "3F", 0, 100, 2),
|
||||
row(today.minusDays(1), "3F", 1, 100, 2)));
|
||||
|
||||
Map<String, Object> result = query(9L, "floor", false, null, null);
|
||||
|
||||
List<Map<String, Object>> rows = rows(result);
|
||||
assertEquals(1, rows.size());
|
||||
assertNull(rows.get(0).get("date"));
|
||||
assertEquals("3F", rows.get(0).get("floor"));
|
||||
assertNull(rows.get(0).get("gender"));
|
||||
assertEquals(0.5, rows.get(0).get("perCapita"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregatesFloorAndGenderAndUsesReadableGenderNames() {
|
||||
mockReady();
|
||||
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
|
||||
row(today.minusDays(1), "1F", 3, 125, 1),
|
||||
row(today.minusDays(2), "1F", 3, 125, 1),
|
||||
row(today.minusDays(1), "1F", 4, 200, 2)));
|
||||
|
||||
Map<String, Object> result = query(9L, "floorGender", false, null, null);
|
||||
|
||||
List<Map<String, Object>> rows = rows(result);
|
||||
assertEquals(2, rows.size());
|
||||
assertEquals("无障碍", rows.get(0).get("gender"));
|
||||
assertEquals("第三卫", rows.get(1).get("gender"));
|
||||
assertEquals("1F", rows.get(0).get("floor"));
|
||||
assertEquals(1.25, rows.get(0).get("perCapita"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesIndependentPositionFiltersAndDeviceType() {
|
||||
mockReady();
|
||||
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
query(9L, "gender", false, 1, List.of("12_2F_31", "13_3F_32"));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<LambdaQueryWrapper<MonthlyStatistics>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(monthlyStatisticsMapper).selectList(captor.capture());
|
||||
String sql = captor.getValue().getSqlSegment();
|
||||
assertTrue(sql.contains("building_id IN"));
|
||||
assertTrue(sql.contains("floor IN"));
|
||||
assertTrue(sql.contains("toilet_id IN"));
|
||||
assertTrue(sql.contains("device_type"));
|
||||
}
|
||||
|
||||
private Map<String, Object> query(Long districtId, String groupBy, boolean daily,
|
||||
Integer deviceType, List<String> idStrList) {
|
||||
return tools.districtPaperPerCapitaQuery(districtId, start, end,
|
||||
groupBy, daily, deviceType, idStrList);
|
||||
}
|
||||
|
||||
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 MonthlyStatistics row(LocalDate date, String floor, Integer gender,
|
||||
int paperLengthCm, int useCount) {
|
||||
MonthlyStatistics row = new MonthlyStatistics();
|
||||
row.setStatisticsTime(Date.from(date.atStartOfDay(SHANGHAI).toInstant()));
|
||||
row.setFloor(floor);
|
||||
row.setGender(gender);
|
||||
row.setPaperLength(paperLengthCm);
|
||||
row.setUseCount(useCount);
|
||||
return row;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Map<String, Object>> rows(Map<String, Object> result) {
|
||||
return (List<Map<String, Object>>) result.get("rows");
|
||||
}
|
||||
|
||||
private static Map<String, Object> withoutNulls(Map<String, Object> row) {
|
||||
return row.entrySet().stream()
|
||||
.filter(entry -> entry.getValue() != null)
|
||||
.collect(java.util.stream.Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user