feat(district): 新增 district.paper.position.detail.query 客户用纸详情工具 (REQ-20261012-0001) #28

Merged
wangheng merged 1 commits from feat/REQ-20261012-0001 into main 2026-10-12 11:55:03 +08:00
10 changed files with 971 additions and 1 deletions
+1
View File
@@ -94,6 +94,7 @@ xiaoqu-agent-service/
| `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 服务身份均可调用。 |
| `district.paper.position.detail.query` | district | read | 客户用纸详情(复刻老 /MonthlyStatistics/getMonthlyStatisticsPerPositionMap)。入参 districtId/startDate/endDate/positionMode/deviceType/buildingId/floor/toiletId;按 位置(厕位/厕墙)× 日期 聚合 m0 monthly_statistics(纯 MySQL,不查 ES),返回扁平 rows(date/locationType/positionId/toiletId/locationName/gender/genderText/snCodes/paperLengthMeters/useCount/perUseLengthMeters),无数据不补 0;位置命名直查 m0 positions/toilet,查不到给"未知";修复老接口多行合并整数除法截断 bug,厕墙人均为 null。详细设计见 doc/paper-position-detail-mcp-design.md |
| `battery.fault.list.query` | hardware | read | 电池故障分页查询。按站点/物业/代理商/SN/设备类型/故障类型/恢复状态/故障日期筛选;沿用设备行级权限,按故障时间和 ID 倒序。详细设计见 doc/battery-fault-mcp-tool-design.md |
| `hardware.electricity.message.list.query` | hardware | read | 设备电量/信号/温度等消息记录分页查询(ES electricitymessage_yyyyMM 月索引,只走本地集群,无权限过滤,对齐老 /batteryAnalyse/batteryAnalyseList)。入参 snCode(必填)/startTime/endTime(必填且须同一天,跨天拒绝)/messageType(0-5,不传=全部类型)/page/rows(默认 1/500,上限 1000);出参 snCode/index/total/page/rows/records(createTime/messageType/messageTypeText/value),createTime ASC。详细设计见 doc/electricity-message-mcp-tool-design.md |
@@ -819,6 +819,33 @@
---
## 33. district.paper.position.detail.query
客户用纸详情,复刻老系统 `POST /MonthlyStatistics/getMonthlyStatisticsPerPositionMap`
(MonthlyStatisticsController :404-512),按 位置(厕位/厕墙)× 日期 输出用纸明细。
- 分类:district | 类型:读
- 数据来源:m0 `monthly_statistics`(XXL-Job 预聚合日汇总),**纯 MySQL 不查 ES**;
`statistics_time` 左闭右开 [startDate 00:00:00, endDate+1 00:00:00),可选 `buildingId/floor/toiletId/deviceType` 精确过滤,
`positionMode`(position=仅厕位 `position_id>0` / wall=仅厕墙)对齐 statistics 工具语义,不沿用老 detail 接口 positionId 0/1 的混乱口径。
- 入参:`districtId`(必填正整数)、`startDate`/`endDate`(必填 yyyy-MM-dd,startDate≤endDate≤今天);
日期区间硬限 31 天(一个月,2026-09-24 拍板),且受 Redis `Dictionary.StatisticsDays` 限制,字典缺失 fail fast。
- 返回扁平 `rows[]`:`date/locationType(position|wall)/positionId/toiletId/locationName/gender/genderText/`
`snCodes/paperLengthMeters/useCount/perUseLengthMeters`;排序 date 升序、locationType、positionId/toiletId 升序。
- 指标口径:`paperLengthMeters`=Σpaper_length/100(米,2 位小数,**全程 Double**——修老 :452 合并分支整数除法
逐次截断 bug);`useCount`=Σuse_count **仅厕位行累计**(厕墙恒 0);`perUseLengthMeters`=paperLengthMeters/useCount
(useCount=0 为 null,厕墙不适用而非老系统的硬编码 0.0);`snCodes`=组内 distinct sn_code 去重排序。
- 维度字段恒在、不适用为 null:厕墙行 `positionId=null`;`gender` 透传库内原值,
genderText 映射 0男卫/1女卫/2母婴/3残卫/4第三/5其他,99 及未知编码为 null。
- 位置命名:直查 m0 `positions.memo`(回退 name)/ `toilet.no`(回退 name)批量富化(新增极简实体+Mapper,
不读 Redis 实体缓存),查不到给"未知";无数据的 日期×位置 不出行、不补 0。
- 行级权限:站点可见性沿用 `district.paper.statistics.query` 模式(id + soft_delete=0 + applyDistrictRowFilter),
查不到统一抛 `站点不存在或无权访问`(老 detail 接口无行级权限,MCP 版补齐)。
详细设计见 `paper-position-detail-mcp-design.md`。
---
## 33. district.paper.percapita.query
查询站点人均用纸统计,覆盖老 `StatisticsReportController` 的五个接口。
@@ -0,0 +1,147 @@
# district.paper.position.detail.query 设计文档(客户用纸详情 MCP 工具)
> 状态:设计中(待评审)
> 老接口:`POST /MonthlyStatistics/getMonthlyStatisticsPerPositionMap`(xiaoqu-franchisee
> `MonthlyStatisticsController.java:404-512`,辅助方法 `putMonthStatistToDay` :863-883)
> 姊妹工具:`district.paper.statistics.query`(客户用纸统计,已实现,复刻同 Controller 的
> getMonthlyStatisticsMap;设计见 district-paper-statistics-design.md)
## 1. 定位
按站点查询**位置级(厕位/厕墙)× 日期**的用纸明细:每个位置每天用掉多少纸、多少人次、
人均多少、涉及哪些设备 SN。面向 LLM 消费,返回扁平行数组,不搬老系统的嵌套 Map 结构。
## 2. 老系统口径复刻
- 表:m0 库 `monthly_statistics`(每站点每厕位/厕每天一行,XXL-Job 预聚合;实体
`entity/m0/MonthlyStatistics.java` 已存在)。**纯 MySQL,不查 ES**——老接口的 ES 分支
(`monthlyStatisticsESFlag` 开关走 monthly_statistics 索引)只是同一份预聚合数据的另一存储,
MCP 统一走 DB(与 district.paper.percapita.query 一致)。
- 过滤:`soft_delete=0`、`district_id` 精确必填、`statistics_time ∈ [startDate, endDate]`
(实现为左闭右开 `[start, end+1)`);可选 `building_id/floor/toilet_id/device_type` 精确匹配。
- 天数上限:Redis db5 `Dictionary` hash 的 `StatisticsDays`,超限抛 `查询时间段最大{N}天`
(老 :412-423)。本工具不查 paperuselog,**无 LocalEsDate 冷热集群/跨界判断**。
- 分组键:位置 = `position_id > 0` ? 厕位 `p-{positionId}` : 厕墙 `t-{toiletId}`(老 :437-442)。
- 指标(每 位置×日期):
- `paperLengthMeters` = Σpaper_length / 100(表内厘米→米,2 位小数)。
⚠️ 修老 bug::452 合并分支 `paperLength / 100` 是**整数除法**,同位置同日多行时每次合并
都截断到整米;MCP 版全程 Double 计算(:879 单行情景是 /100.0,无此问题)。
- `useCount` = Σuse_count,**仅厕位行累计**(厕墙行表内恒 0,老 :454-456)。
- `perUseLengthMeters` = paperLengthMeters / useCount(2 位小数);useCount=0 时为 **null**
(老系统厕墙硬编码 0.0,:876——0 和"不适用"语义不同,按 LLM 输出铁律改 null)。
- `snCodes` = distinct sn_code(老 :457-473 snmap)。
- 位置命名:老系统读 Redis 实体缓存 `entity:Positions:{id}`.memo / `entity:Toilet:{id}`.no,
缓存冷时显示"未知"(:486-497);MCP 改**直查 m0 positions/toilet 表**(新增极简实体+Mapper),
查不到同样给"未知"。DB 比缓存更稳,且本服务无该缓存的写入端。
## 3. 工具契约
```
district.paper.position.detail.query @XqTool(category="district", kind="read")
```
| 参数 | 必填 | 说明 |
|---|---|---|
| districtId | 是 | 站点 id,正整数 |
| startDate / endDate | 是 | yyyy-MM-dd,含当天;endDate 不得晚于今天;跨度 ≤ StatisticsDays |
| positionMode | 否 | `position`=仅厕位、`wall`=仅厕墙,缺省不过滤(对齐 statistics 工具,**不沿用**老 input.positionId 0/1 的混乱语义——老 detail 接口 positionId=1 经 beanToWrapper 变 `eq position_id=1`,与统计接口的 `gt 0` 不一致) |
| deviceType | 否 | 0=出纸机、1=擦手纸机 |
| buildingId / floor / toiletId | 否 | 精确匹配 |
返回 `Map<String, Object>`(统一风格,无 DTO 类),**一种结构走天下**:
```json
{
"districtId": 12610,
"districtName": "望京SOHO",
"dateRange": {"start": "2026-09-01", "end": "2026-09-07"},
"unit": "米/人次",
"rows": [
{
"date": "2026-09-01",
"locationType": "position",
"positionId": 3456,
"toiletId": 123,
"locationName": "3层男卫1号位",
"gender": 0,
"genderText": "男卫",
"snCodes": ["SN001", "SN002"],
"paperLengthMeters": 45.6,
"useCount": 38,
"perUseLengthMeters": 1.2
},
{
"date": "2026-09-01",
"locationType": "wall",
"positionId": null,
"toiletId": 124,
"locationName": "3层女卫",
"gender": 1,
"genderText": "女卫",
"snCodes": ["SN003"],
"paperLengthMeters": 12.3,
"useCount": 0,
"perUseLengthMeters": null
}
]
}
```
遵守 MCP 输出铁律(王恒 2026-09-22 确认):
- 扁平 `rows[]`,每行维度值+指标同对象自描述;不搬老系统
`Map<位置拼接串, Map<日期, 指标>>` 嵌套结构,也不把 `memo(p-123)_sn1,sn2` 塞进 key。
- 无数据的 日期×位置 **不出行**,不补 0.0(老系统只给前端已发生数据的 key,本就无补 0,
此处语义保持一致)。
- 透出原始度量(paperLengthMeters、useCount、snCodes),perUseLengthMeters 只是派生。
- 维度字段恒在、不适用为 null:厕墙行 `positionId=null`;`gender` 为库内原值
(0男/1女/2母婴/3残卫/4第三/5其他,99=无厕所,其他编码 genderText 为 null)。
- 排序确定:date 升序,再 locationType、positionId/toiletId 升序(老系统按拼接 key 字典序
倒序是给前端展示用,LLM 消费改为稳定正序)。
## 4. 权限
- 沿用 district.paper.statistics.query 模式:`district_district_strategy` eq id + soft_delete=0
套 `PermissionService.applyDistrictRowFilter`,查不到抛 `站点不存在或无权访问`
(老 detail 接口**无行级权限**,MCP 版补上,与 percapita 设计 §2 一致)。
- scope 绑定:用户委托 + ops-query(生产惯例绑 scope 1/2/3/5),sync 不绑。
## 5. 与老系统的差异点(有意为之)
| 点 | 老系统 | 本工具 | 理由 |
|---|---|---|---|
| 返回结构 | 嵌套 Map,key 拼 `memo(p-123)_sn1,sn2` | 扁平 rows[] 结构化字段 | LLM 消费,免 key 解析 |
| 多行合并用纸量 | 整数除法逐次截断(:452) | 全程 Double,末次 /100 | 修精度 bug |
| 厕墙人均 | 硬编码 0.0 | null | 区分"0"与"不适用" |
| 位置命名 | Redis 实体缓存,冷缓存=未知 | DB 直查 positions/toilet | 本服务无缓存写入端,DB 更稳 |
| 位置过滤 | input.positionId 0/1,detail 接口语义有 bug | positionMode 枚举 | 与 statistics 工具统一 |
| ES 分支 | monthlyStatisticsESFlag 走 ES 索引 | 纯 MySQL | 同一预聚合数据,DB 即可 |
## 6. 改动文件清单
1. 新建 `entity/m0/Positions.java`、`entity/m0/Toilet.java`(极简字段子集:Positions 取
id/districtId/memo/name/softDelete;Toilet 取 id/districtId/no/name/gender/softDelete)
+ `mapper/m0/PositionsMapper.java`、`ToiletMapper.java`(空接口继承 BaseMapper)。
2. 新建 `mcp/tool/DistrictPaperPositionDetailTools.java`
(@Component implements ToolObject,骨架照抄 DistrictPaperStatisticsTools.java:43-87;
构造器注入 MonthlyStatisticsMapper/PositionsMapper/ToiletMapper/DistrictDistrictStrategyMapper/
PermissionService/StringRedisTemplate)。
3. 单测 `src/test/java/me/iiv/agent/mcp/tool/DistrictPaperPositionDetailToolsTest.java`
(Mockito + AuthContextHolder.set(),参照 DistrictPaperStatisticsToolsTest)。
4. `sql/seed-dev.sql` 加幂等 scope_tool 绑定(白名单制,须手工把工具名加进对应 scope 的
IN 列表再跑 seed);生产上线后绑 scope 1/2/3/5。
5. 登记 `doc/agent-mcp-tools-reference.md`。
6. 装配层零改动(McpToolConfig 自动收集 ToolObject bean,ToolSyncService 自动同步 tool 表)。
## 7. 单测要点
- 参数校验:districtId 必填/正整数、日期格式、start≤end、endDate≤今天、positionMode 枚举、
deviceType 0/1。
- StatisticsDays 超限拒绝;字典缺失 fail fast。
- 站点不可见 → `站点不存在或无权访问`。
- 分组键:position_id>0 → position 行、=0 → wall 行;同位置同日多行合并(Double 精度,
覆盖老整数除法 bug 的回归用例)。
- useCount 仅厕位行累计;厕墙行 perUseLengthMeters=null。
- snCodes 去重;locationName 从 positions.memo/toilet.no 富化,查不到给"未知"。
- gender 透传与 genderText 映射(含 99/未知编码 → null)。
- 无数据不出行(空结果 rows=[])。
+1 -1
View File
@@ -56,7 +56,7 @@ WHERE NOT EXISTS (SELECT 1 FROM agent_scope WHERE code = '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.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')
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', 'district.paper.position.detail.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。
@@ -0,0 +1,38 @@
package me.iiv.agent.entity.m0;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.io.Serializable;
/**
* positions 厕位(二级场景,m0 库)。极简字段子集,仅用于位置命名富化;
* 字段照搬老项目 xiaoqu-publicbeanandmap beans/b0/Positions.java。
*/
@Getter
@Setter
@ToString
@TableName("positions")
public class Positions implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 所属区域/站点 */
private Long districtId;
/** 厕位编号 */
private String memo;
/** 场景名称 */
private String name;
/** 数据状态 */
private Integer softDelete;
}
@@ -0,0 +1,41 @@
package me.iiv.agent.entity.m0;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.io.Serializable;
/**
* toilet 厕所(一级场景,m0 库)。极简字段子集,仅用于厕墙命名富化与性别透传;
* 字段照搬老项目 xiaoqu-publicbeanandmap beans/b0/Toilet.java。
*/
@Getter
@Setter
@ToString
@TableName("toilet")
public class Toilet implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 区域id */
private Long districtId;
/** 厕所编号 */
private String no;
/** 场景名称 */
private String name;
/** 0:男厕;1:女厕;2:母婴室;3:残障卫生间;4:第三卫生间;5:其他 */
private Integer gender;
/** 数据状态 */
private Integer softDelete;
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m0;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m0.Positions;
public interface PositionsMapper extends BaseMapper<Positions> {
}
@@ -0,0 +1,7 @@
package me.iiv.agent.mapper.m0;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import me.iiv.agent.entity.m0.Toilet;
public interface ToiletMapper extends BaseMapper<Toilet> {
}
@@ -0,0 +1,360 @@
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.Positions;
import me.iiv.agent.entity.m0.Toilet;
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.mapper.m0.PositionsMapper;
import me.iiv.agent.mapper.m0.ToiletMapper;
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.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
/**
* 客户用纸详情(老 MonthlyStatisticsController.getMonthlyStatisticsPerPositionMap 复刻):
* 纯 DB 聚合 m0 monthly_statistics,按 位置(厕位/厕墙)× 日期 分组,
* 输出扁平行(用纸长度米/人次/人均米/涉及 SN)。位置命名直查 m0 positions/toilet 表。
* 设计文档:doc/paper-position-detail-mcp-design.md
*/
@Component
public class DistrictPaperPositionDetailTools implements ToolObject {
private static final String DICTIONARY_KEY = "Dictionary";
private static final String DICT_STATISTICS_DAYS = "StatisticsDays";
/** 日期区间硬限一个月(2026-09-24 用户拍板),与 Redis StatisticsDays 取更严 */
private static final int MAX_QUERY_DAYS = 31;
private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
private static final String UNKNOWN_NAME = "未知";
final DistrictDistrictStrategyMapper districtMapper;
final MonthlyStatisticsMapper monthlyStatisticsMapper;
final PositionsMapper positionsMapper;
final ToiletMapper toiletMapper;
final PermissionService permissionService;
final StringRedisTemplate redisTemplate;
public DistrictPaperPositionDetailTools(DistrictDistrictStrategyMapper districtMapper,
MonthlyStatisticsMapper monthlyStatisticsMapper,
PositionsMapper positionsMapper,
ToiletMapper toiletMapper,
PermissionService permissionService,
StringRedisTemplate redisTemplate) {
this.districtMapper = districtMapper;
this.monthlyStatisticsMapper = monthlyStatisticsMapper;
this.positionsMapper = positionsMapper;
this.toiletMapper = toiletMapper;
this.permissionService = permissionService;
this.redisTemplate = redisTemplate;
}
@XqTool(category = "district", kind = "read")
@Tool(name = "district.paper.position.detail.query", description = "客户用纸详情:按站点和日期范围查询"
+ "每个位置(厕位/厕墙)每天的用纸明细——用纸长度(米)、使用人次、人均用纸(米/人次)与涉及设备 SN。"
+ "数据来自 m0 monthly_statistics 日汇总表,纯 MySQL 查询。返回扁平 rows,"
+ "每行含 date、locationType(position=厕位/wall=厕墙)、positionId、toiletId、locationName、"
+ "gender、genderText、snCodes、paperLengthMeters、useCount、perUseLengthMeters;"
+ "没有数据的日期与位置不出行、不补 0;厕墙行 useCount 恒 0、perUseLengthMeters 为 null。"
+ "日期区间最长 31 天(一个月),且受 Redis Dictionary.StatisticsDays 限制。")
public Map<String, Object> districtPaperPositionDetailQuery(
@ToolParam(description = "站点 id,正整数") Long districtId,
@ToolParam(description = "开始日期,yyyy-MM-dd,含当天") String startDate,
@ToolParam(description = "结束日期,yyyy-MM-dd,含当天,不得晚于今天") String endDate,
@ToolParam(required = false, description = "位置过滤:position=仅厕位、wall=仅厕墙,缺省不过滤") String positionMode,
@ToolParam(required = false, description = "设备类型:0=出纸机、1=擦手纸机,缺省不过滤") Integer deviceType,
@ToolParam(required = false, description = "楼 id,正整数,精确匹配") Long buildingId,
@ToolParam(required = false, description = "楼层,精确匹配") String floor,
@ToolParam(required = false, description = "厕所 id,正整数,精确匹配") Long toiletId) {
if (districtId == null) {
throw new IllegalArgumentException("districtId 必填");
}
validatePositive(districtId, "districtId");
validatePositive(buildingId, "buildingId");
validatePositive(toiletId, "toiletId");
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(positionMode) && !"position".equals(positionMode) && !"wall".equals(positionMode)) {
throw new IllegalArgumentException("positionMode 仅支持 position、wall");
}
if (deviceType != null && deviceType != 0 && deviceType != 1) {
throw new IllegalArgumentException("deviceType 仅支持 0、1");
}
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 queryDays = (int) ChronoUnit.DAYS.between(start, end) + 1;
if (queryDays > MAX_QUERY_DAYS) {
throw new IllegalArgumentException(
"查询时间段最长 " + MAX_QUERY_DAYS + " 天(一个月),请修改日期范围");
}
int maxDays = statisticsDays();
if (queryDays > maxDays) {
throw new IllegalArgumentException("查询时间段最大" + maxDays + "天");
}
List<MonthlyStatistics> sourceRows = queryRows(districtId, start, end,
positionMode, deviceType, buildingId, floor, toiletId);
Map<GroupKey, Aggregate> aggregates = aggregate(sourceRows);
List<Map<String, Object>> rows = toResultRows(aggregates);
Map<String, Object> dateRange = new LinkedHashMap<>();
dateRange.put("start", start.toString());
dateRange.put("end", end.toString());
Map<String, Object> result = new LinkedHashMap<>();
result.put("districtId", districtId);
result.put("districtName", district.getDistrictName());
result.put("dateRange", dateRange);
result.put("unit", "米/人次");
result.put("rows", rows);
return result;
}
private List<MonthlyStatistics> queryRows(Long districtId, LocalDate start, LocalDate end,
String positionMode, Integer deviceType,
Long buildingId, String floor, Long toiletId) {
LambdaQueryWrapper<MonthlyStatistics> wrapper = new LambdaQueryWrapper<MonthlyStatistics>()
.eq(MonthlyStatistics::getSoftDelete, 0)
.eq(MonthlyStatistics::getDistrictId, districtId)
.ge(MonthlyStatistics::getStatisticsTime,
Date.from(start.atStartOfDay(SHANGHAI).toInstant()))
// [start 00:00:00, end 23:59:59],用左闭右开避免秒精度丢行
.lt(MonthlyStatistics::getStatisticsTime,
Date.from(end.plusDays(1).atStartOfDay(SHANGHAI).toInstant()));
if ("position".equals(positionMode)) {
wrapper.gt(MonthlyStatistics::getPositionId, 0);
} else if ("wall".equals(positionMode)) {
wrapper.and(w -> w.isNull(MonthlyStatistics::getPositionId)
.or().le(MonthlyStatistics::getPositionId, 0));
}
if (deviceType != null) {
wrapper.eq(MonthlyStatistics::getDeviceType, deviceType);
}
if (buildingId != null) {
wrapper.eq(MonthlyStatistics::getBuildingId, buildingId);
}
if (StringUtils.hasText(floor)) {
wrapper.eq(MonthlyStatistics::getFloor, floor);
}
if (toiletId != null) {
wrapper.eq(MonthlyStatistics::getToiletId, toiletId);
}
return monthlyStatisticsMapper.selectList(wrapper);
}
/** 老 :435-475 分组:位置(position_id>0 厕位 / 否则厕墙)× 日期;用纸全程 Double(修老 :452 整数除法截断) */
private Map<GroupKey, Aggregate> aggregate(List<MonthlyStatistics> sourceRows) {
Comparator<GroupKey> comparator = Comparator
.comparing((GroupKey key) -> key.date)
.thenComparing(key -> key.locationType)
.thenComparing(key -> key.locationId, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(key -> key.toiletId, Comparator.nullsLast(Comparator.naturalOrder()));
Map<GroupKey, Aggregate> result = new TreeMap<>(comparator);
for (MonthlyStatistics row : sourceRows) {
if (row.getStatisticsTime() == null) {
continue;
}
String date = row.getStatisticsTime().toInstant().atZone(SHANGHAI).toLocalDate().toString();
boolean positionRow = row.getPositionId() != null && row.getPositionId() > 0;
GroupKey key = positionRow
? new GroupKey(date, "position", row.getPositionId(), row.getToiletId())
: new GroupKey(date, "wall", null, row.getToiletId());
Aggregate value = result.computeIfAbsent(key, ignored -> new Aggregate());
value.paperLengthCm += row.getPaperLength() == null ? 0L : row.getPaperLength();
// useCount 仅厕位行累计,厕墙行表内恒 0(老 :454-456)
if (positionRow && row.getUseCount() != null) {
value.useCount += row.getUseCount();
}
if (StringUtils.hasText(row.getSnCode())) {
value.snCodes.add(row.getSnCode());
}
if (value.gender == null) {
value.gender = row.getGender();
}
}
return result;
}
private List<Map<String, Object>> toResultRows(Map<GroupKey, Aggregate> aggregates) {
Map<Long, String> positionNames = loadPositionNames(aggregates);
Map<Long, String> toiletNames = loadToiletNames(aggregates);
List<Map<String, Object>> rows = new ArrayList<>();
for (Map.Entry<GroupKey, Aggregate> entry : aggregates.entrySet()) {
GroupKey key = entry.getKey();
Aggregate value = entry.getValue();
boolean positionRow = "position".equals(key.locationType);
double paperLengthMeters = value.paperLengthCm / 100.0;
Map<String, Object> row = new LinkedHashMap<>();
row.put("date", key.date);
row.put("locationType", key.locationType);
row.put("positionId", positionRow ? key.locationId : null);
row.put("toiletId", key.toiletId);
row.put("locationName", positionRow
? positionNames.getOrDefault(key.locationId, UNKNOWN_NAME)
: toiletNames.getOrDefault(key.toiletId, UNKNOWN_NAME));
row.put("gender", value.gender);
row.put("genderText", genderText(value.gender));
row.put("snCodes", new ArrayList<>(value.snCodes));
row.put("paperLengthMeters", round2(paperLengthMeters));
row.put("useCount", value.useCount);
// 厕墙人均不适用(useCount 恒 0 时为 null,区分"0"与"不适用")
row.put("perUseLengthMeters", value.useCount > 0
? round2(paperLengthMeters / value.useCount) : null);
rows.add(row);
}
return rows;
}
private Map<Long, String> loadPositionNames(Map<GroupKey, Aggregate> aggregates) {
Set<Long> ids = new LinkedHashSet<>();
for (GroupKey key : aggregates.keySet()) {
if ("position".equals(key.locationType) && key.locationId != null) {
ids.add(key.locationId);
}
}
Map<Long, String> names = new HashMap<>();
if (ids.isEmpty()) {
return names;
}
List<Positions> positions = positionsMapper.selectList(new LambdaQueryWrapper<Positions>()
.eq(Positions::getSoftDelete, 0)
.in(Positions::getId, ids));
for (Positions position : positions) {
names.put(position.getId(), firstText(position.getMemo(), position.getName()));
}
return names;
}
private Map<Long, String> loadToiletNames(Map<GroupKey, Aggregate> aggregates) {
Set<Long> ids = new LinkedHashSet<>();
for (GroupKey key : aggregates.keySet()) {
if ("wall".equals(key.locationType) && key.toiletId != null) {
ids.add(key.toiletId);
}
}
Map<Long, String> names = new HashMap<>();
if (ids.isEmpty()) {
return names;
}
List<Toilet> toilets = toiletMapper.selectList(new LambdaQueryWrapper<Toilet>()
.eq(Toilet::getSoftDelete, 0)
.in(Toilet::getId, ids));
for (Toilet toilet : toilets) {
names.put(toilet.getId(), firstText(toilet.getNo(), toilet.getName()));
}
return names;
}
private static String firstText(String primary, String fallback) {
if (StringUtils.hasText(primary)) {
return primary;
}
return StringUtils.hasText(fallback) ? fallback : UNKNOWN_NAME;
}
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 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 void validatePositive(Long value, String name) {
if (value != null && value < 1) {
throw new IllegalArgumentException(name + " 必须为正整数");
}
}
private static double round2(double value) {
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).doubleValue();
}
/** gender 透传库内原值;0男卫/1女卫/2母婴/3残卫/4第三/5其他,99 及未知编码 genderText 为 null */
private static String genderText(Integer gender) {
if (gender == null) {
return null;
}
switch (gender) {
case 0: return "男卫";
case 1: return "女卫";
case 2: return "母婴";
case 3: return "残卫";
case 4: return "第三";
case 5: return "其他";
default: return null;
}
}
private record GroupKey(String date, String locationType, Long locationId, Long toiletId) {
}
private static final class Aggregate {
long paperLengthCm;
long useCount;
Integer gender;
final Set<String> snCodes = new TreeSet<>();
}
}
@@ -0,0 +1,342 @@
package me.iiv.agent.mcp.tool;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import me.iiv.agent.entity.m0.DistrictDistrictStrategy;
import me.iiv.agent.entity.m0.MonthlyStatistics;
import me.iiv.agent.entity.m0.Positions;
import me.iiv.agent.entity.m0.Toilet;
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.mapper.m0.PositionsMapper;
import me.iiv.agent.mapper.m0.ToiletMapper;
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.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DistrictPaperPositionDetailToolsTest {
private static final ZoneId SH = ZoneId.of("Asia/Shanghai");
@Mock
DistrictDistrictStrategyMapper districtMapper;
@Mock
MonthlyStatisticsMapper monthlyStatisticsMapper;
@Mock
PositionsMapper positionsMapper;
@Mock
ToiletMapper toiletMapper;
@Mock
PermissionService permissionService;
@Mock
StringRedisTemplate redisTemplate;
@Mock
HashOperations<String, Object, Object> hashOperations;
DistrictPaperPositionDetailTools 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);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
Positions.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
Toilet.class);
tools = new DistrictPaperPositionDetailTools(districtMapper, monthlyStatisticsMapper,
positionsMapper, toiletMapper, permissionService, redisTemplate);
User user = new User();
user.setId(7L);
AuthContextHolder.set(new AuthContext(null, user));
today = LocalDate.now();
start = today.minusDays(6).toString();
end = today.toString();
}
@AfterEach
void tearDown() {
AuthContextHolder.clear();
}
private void mockDistrictAndDictionary() {
DistrictDistrictStrategy district = new DistrictDistrictStrategy();
district.setId(9L);
district.setDistrictName("测试站点");
when(districtMapper.selectOne(any())).thenReturn(district);
lenient().when(redisTemplate.opsForHash()).thenReturn(hashOperations);
lenient().when(hashOperations.get("Dictionary", "StatisticsDays")).thenReturn("31");
}
private static MonthlyStatistics row(LocalDate day, Long positionId, Long toiletId,
Integer gender, String snCode, int paperLengthCm, int useCount) {
MonthlyStatistics row = new MonthlyStatistics();
row.setId(1L);
row.setSoftDelete(0);
row.setDistrictId(9L);
row.setStatisticsTime(Date.from(day.atStartOfDay(SH).toInstant()));
row.setGender(gender);
row.setPositionId(positionId);
row.setToiletId(toiletId);
row.setSnCode(snCode);
row.setPaperLength(paperLengthCm);
row.setUseCount(useCount);
row.setRunlMode(0);
return row;
}
private static Positions position(long id, String memo, String name) {
Positions position = new Positions();
position.setId(id);
position.setDistrictId(9L);
position.setMemo(memo);
position.setName(name);
position.setSoftDelete(0);
return position;
}
private static Toilet toilet(long id, String no, String name) {
Toilet toilet = new Toilet();
toilet.setId(id);
toilet.setDistrictId(9L);
toilet.setNo(no);
toilet.setName(name);
toilet.setSoftDelete(0);
return toilet;
}
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> rowsOf(Map<String, Object> result) {
return (List<Map<String, Object>>) result.get("rows");
}
@Test
void rejectsInvalidArgumentsBeforeAnyQuery() {
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery(
null, start, end, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery(
0L, start, end, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery(
9L, "2026/09/01", end, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery(
9L, start, end, "bad", null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery(
9L, start, end, null, 2, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery(
9L, end, start, null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery(
9L, start, today.plusDays(1).toString(), null, null, null, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery(
9L, start, end, null, null, -1L, null, null));
assertThrows(IllegalArgumentException.class, () -> tools.districtPaperPositionDetailQuery(
9L, start, end, null, null, null, null, 0L));
verifyNoInteractions(districtMapper, monthlyStatisticsMapper, positionsMapper, toiletMapper,
redisTemplate);
}
@Test
void rejectsInvisibleDistrict() {
when(districtMapper.selectOne(any())).thenReturn(null);
IllegalStateException exception = assertThrows(IllegalStateException.class,
() -> tools.districtPaperPositionDetailQuery(9L, start, end, null, null, null, null, null));
assertEquals("站点不存在或无权访问", exception.getMessage());
verify(permissionService).applyDistrictRowFilter(any(), any());
verifyNoInteractions(monthlyStatisticsMapper, positionsMapper, toiletMapper);
}
@Test
void failsFastWhenDictionaryMissing() {
DistrictDistrictStrategy district = new DistrictDistrictStrategy();
district.setId(9L);
when(districtMapper.selectOne(any())).thenReturn(district);
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
when(hashOperations.get("Dictionary", "StatisticsDays")).thenReturn(null);
assertThrows(IllegalStateException.class,
() -> tools.districtPaperPositionDetailQuery(9L, start, end, null, null, null, null, null));
verifyNoInteractions(monthlyStatisticsMapper, positionsMapper, toiletMapper);
}
@Test
void rejectsRangeExceedingHardLimitOf31Days() {
mockDistrictAndDictionary();
String wideStart = today.minusDays(40).toString();
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> tools.districtPaperPositionDetailQuery(9L, wideStart, end, null, null, null, null, null));
assertTrue(exception.getMessage().contains("最长 31 天"));
verifyNoInteractions(monthlyStatisticsMapper, positionsMapper, toiletMapper);
}
@Test
void rejectsRangeExceedingStatisticsDays() {
mockDistrictAndDictionary();
when(hashOperations.get("Dictionary", "StatisticsDays")).thenReturn("5");
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> tools.districtPaperPositionDetailQuery(9L, start, end, null, null, null, null, null));
assertEquals("查询时间段最大5天", exception.getMessage());
verifyNoInteractions(monthlyStatisticsMapper, positionsMapper, toiletMapper);
}
@Test
void emptyResultProducesNoRowsWithoutEnrichmentQueries() {
mockDistrictAndDictionary();
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of());
Map<String, Object> result = tools.districtPaperPositionDetailQuery(
9L, start, end, null, null, null, null, null);
assertEquals(9L, result.get("districtId"));
assertEquals("测试站点", result.get("districtName"));
assertEquals("米/人次", result.get("unit"));
assertTrue(rowsOf(result).isEmpty());
verifyNoInteractions(positionsMapper, toiletMapper);
}
@Test
void mergesSamePositionSameDayRowsWithoutIntegerDivisionTruncation() {
mockDistrictAndDictionary();
LocalDate day = today.minusDays(1);
// 老 :452 合并分支 paperLength/100 是整数除法:150+150 会被截成 1+1=2 米,本工具必须给 3.0
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
row(day, 11L, 3L, 0, "SN001", 150, 2),
row(day, 11L, 3L, 0, "SN002", 150, 1),
row(day, 11L, 3L, 0, "SN001", 50, 1)));
when(positionsMapper.selectList(any())).thenReturn(List.of(position(11L, "1号位", "男卫场景")));
Map<String, Object> result = tools.districtPaperPositionDetailQuery(
9L, start, end, null, null, null, null, null);
List<Map<String, Object>> rows = rowsOf(result);
assertEquals(1, rows.size());
Map<String, Object> row = rows.get(0);
assertEquals(day.toString(), row.get("date"));
assertEquals("position", row.get("locationType"));
assertEquals(11L, row.get("positionId"));
assertEquals(3L, row.get("toiletId"));
assertEquals("1号位", row.get("locationName"));
assertEquals(0, row.get("gender"));
assertEquals("男卫", row.get("genderText"));
assertEquals(List.of("SN001", "SN002"), row.get("snCodes"));
assertEquals(3.5, row.get("paperLengthMeters"));
assertEquals(4L, row.get("useCount"));
assertEquals(0.88, row.get("perUseLengthMeters"));
verifyNoInteractions(toiletMapper);
}
@Test
void wallRowHasNullPerUseAndUnknownCodeGenderTextIsNull() {
mockDistrictAndDictionary();
LocalDate day = today.minusDays(1);
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
row(day, 0L, 7L, 99, "SN003", 80, 0)));
when(toiletMapper.selectList(any())).thenReturn(List.of(toilet(7L, "T-07", "女卫")));
Map<String, Object> result = tools.districtPaperPositionDetailQuery(
9L, start, end, null, null, null, null, null);
List<Map<String, Object>> rows = rowsOf(result);
assertEquals(1, rows.size());
Map<String, Object> row = rows.get(0);
assertEquals("wall", row.get("locationType"));
assertNull(row.get("positionId"));
assertEquals(7L, row.get("toiletId"));
assertEquals("T-07", row.get("locationName"));
assertEquals(99, row.get("gender"));
assertNull(row.get("genderText"));
assertEquals(0.8, row.get("paperLengthMeters"));
assertEquals(0L, row.get("useCount"));
assertNull(row.get("perUseLengthMeters"));
verifyNoInteractions(positionsMapper);
}
@Test
void missingPositionOrToiletFallsBackToUnknownName() {
mockDistrictAndDictionary();
LocalDate day = today.minusDays(1);
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of(
row(day, 11L, 3L, 0, "SN001", 100, 1),
row(day, 12L, 3L, 0, "SN002", 100, 1),
row(day, 0L, 8L, 1, "SN003", 100, 0)));
// 11 查得到 memo;12 查不到 → 未知;厕墙 8 的 no 为空回退 name
when(positionsMapper.selectList(any())).thenReturn(List.of(position(11L, "1号位", "男卫场景")));
when(toiletMapper.selectList(any())).thenReturn(List.of(toilet(8L, " ", "三层女卫")));
Map<String, Object> result = tools.districtPaperPositionDetailQuery(
9L, start, end, null, null, null, null, null);
List<Map<String, Object>> rows = rowsOf(result);
assertEquals(3, rows.size());
assertEquals("1号位", rows.get(0).get("locationName"));
assertEquals("未知", rows.get(1).get("locationName"));
assertEquals("三层女卫", rows.get(2).get("locationName"));
// 排序:date 升序,再 locationType(position < wall),再 positionId/toiletId 升序
assertEquals(11L, rows.get(0).get("positionId"));
assertEquals(12L, rows.get(1).get("positionId"));
assertEquals("wall", rows.get(2).get("locationType"));
}
@Test
void positionModeNarrowsDbQuery() {
mockDistrictAndDictionary();
when(monthlyStatisticsMapper.selectList(any())).thenReturn(List.of());
tools.districtPaperPositionDetailQuery(9L, start, end, "position", null, null, null, null);
tools.districtPaperPositionDetailQuery(9L, start, end, "wall", null, null, null, null);
ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<MonthlyStatistics>>
captor = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper.class);
verify(monthlyStatisticsMapper, org.mockito.Mockito.times(2)).selectList(captor.capture());
String positionSql = captor.getAllValues().get(0).getSqlSegment();
String wallSql = captor.getAllValues().get(1).getSqlSegment();
assertTrue(positionSql.contains("position_id >"));
assertTrue(wallSql.contains("position_id IS NULL") && wallSql.contains("position_id <="));
}
@Test
void springAiPublishesToolSchema() {
org.springframework.ai.tool.ToolCallback[] callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider
.builder().toolObjects(tools).build().getToolCallbacks();
assertEquals(1, callbacks.length);
assertEquals("district.paper.position.detail.query",
callbacks[0].getToolDefinition().name());
String schema = callbacks[0].getToolDefinition().inputSchema();
assertTrue(schema.contains("districtId"));
assertTrue(schema.contains("startDate"));
assertTrue(schema.contains("positionMode"));
}
}