feat(mcp): 新增人均用纸统计工具 (REQ-20260922-0004)

This commit is contained in:
qiudl
2026-09-22 11:29:08 +02:00
parent c47668f6b9
commit 35d7ad73ed
3 changed files with 684 additions and 0 deletions
@@ -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`
@@ -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) {
}
}
@@ -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));
}
}