diff --git a/xiaoqu-agent-service/doc/paper-percapita-statistics-mcp-design.md b/xiaoqu-agent-service/doc/paper-percapita-statistics-mcp-design.md new file mode 100644 index 0000000..21ee4f8 --- /dev/null +++ b/xiaoqu-agent-service/doc/paper-percapita-statistics-mcp-design.md @@ -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` diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsTools.java new file mode 100644 index 0000000..c660932 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsTools.java @@ -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 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 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 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 districtWrapper = + new LambdaQueryWrapper() + .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 sourceRows = queryRows(districtId, start, end, deviceType, filters); + Map aggregates = aggregate(sourceRows, groupBy, byDay); + List> resultRows = toResultRows(aggregates); + + Map 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 queryRows(Long districtId, LocalDate start, LocalDate end, + Integer deviceType, PositionFilters filters) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .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 aggregate(List rows, String groupBy, boolean daily) { + Comparator 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 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> toResultRows(Map aggregates) { + List> rows = new ArrayList<>(); + for (Map.Entry entry : aggregates.entrySet()) { + GroupKey key = entry.getKey(); + Aggregate value = entry.getValue(); + double paperLengthMeters = value.paperLengthCm / 100.0; + Map 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 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 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 buildingIds = new LinkedHashSet<>(); + final Set floors = new LinkedHashSet<>(); + final Set toiletIds = new LinkedHashSet<>(); + } + + private static final class Aggregate { + long paperLengthCm; + long useCount; + } + + private record GroupKey(String date, String floor, Integer gender) { + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsToolsTest.java new file mode 100644 index 0000000..3e09bf9 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/PaperPerCapitaStatisticsToolsTest.java @@ -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 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 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> 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 result = query(9L, "floor", true, null, null); + + assertNull(result.get("totalPerCapita")); + List> 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 result = query(9L, "gender", true, null, null); + + List> 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 result = query(9L, "floor", false, null, null); + + List> 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 result = query(9L, "floorGender", false, null, null); + + List> 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> 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 query(Long districtId, String groupBy, boolean daily, + Integer deviceType, List 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> rows(Map result) { + return (List>) result.get("rows"); + } + + private static Map withoutNulls(Map row) { + return row.entrySet().stream() + .filter(entry -> entry.getValue() != null) + .collect(java.util.stream.Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } +}