Przeglądaj źródła

feat(revenue): 远传集抄模块 - 批量抄表+大表监控DN80+任务管理+数据质量分析

- DDL: mr_task, mr_meter_reading, mr_large_meter_monitor, mr_data_quality
- Entity: MeterReading, LargeMeterMonitor, MeterReadingTask, MeterDataQuality
- Mapper: 4个Mapper + 自定义SQL(异常分布/日摘要/活跃报警/质量趋势)
- Service: 批量抄表(异常检测)+大表监控(流量报警)+任务管理+质量评分
- Controller: 22个API端点(抄表/大表/质量/任务)
- Test: 16个单元测试(异常检测/评分计算/报警阈值/实体逻辑)

Closes #58
bot_dev2 5 dni temu
rodzic
commit
21e17f6831

+ 109
- 0
db/postgresql/V3__meter_reading.sql Wyświetl plik

@@ -0,0 +1,109 @@
1
+-- =============================================
2
+-- 远传集抄模块 DDL
3
+-- 批量抄表 + 大表监控(DN80+) + 任务管理 + 数据质量
4
+-- =============================================
5
+
6
+-- 抄表任务
7
+CREATE TABLE IF NOT EXISTS mr_task (
8
+    id              BIGSERIAL PRIMARY KEY,
9
+    task_no         VARCHAR(30) UNIQUE NOT NULL,
10
+    task_name       VARCHAR(200) NOT NULL,
11
+    task_type       VARCHAR(20) NOT NULL,           -- batch_read / large_monitor / manual_check
12
+    area            VARCHAR(50),
13
+    meter_count     INT DEFAULT 0,
14
+    success_count   INT DEFAULT 0,
15
+    fail_count      INT DEFAULT 0,
16
+    abnormal_count  INT DEFAULT 0,
17
+    progress        SMALLINT DEFAULT 0,             -- 0-100
18
+    status          VARCHAR(20) DEFAULT 'pending',  -- pending / running / completed / failed / cancelled
19
+    scheduled_time  TIMESTAMP,
20
+    started_at      TIMESTAMP,
21
+    completed_at    TIMESTAMP,
22
+    assigned_to     VARCHAR(50),
23
+    remark          VARCHAR(500),
24
+    deleted         SMALLINT DEFAULT 0,
25
+    created_at      TIMESTAMP DEFAULT NOW(),
26
+    updated_at      TIMESTAMP DEFAULT NOW()
27
+);
28
+COMMENT ON TABLE mr_task IS '抄表任务表';
29
+CREATE INDEX IF NOT EXISTS idx_mr_task_status ON mr_task(status);
30
+
31
+-- 远传抄表记录
32
+CREATE TABLE IF NOT EXISTS mr_meter_reading (
33
+    id              BIGSERIAL PRIMARY KEY,
34
+    task_id         BIGINT REFERENCES mr_task(id),
35
+    meter_id        BIGINT NOT NULL,
36
+    meter_no        VARCHAR(50) NOT NULL,
37
+    device_sn       VARCHAR(50),
38
+    prev_reading    DECIMAL(12,2),
39
+    curr_reading    DECIMAL(12,2),
40
+    consumption     DECIMAL(12,2),
41
+    reading_time    TIMESTAMP NOT NULL,
42
+    reading_period  VARCHAR(10),                    -- 2026-06
43
+    signal_strength SMALLINT,                       -- 信号强度 0-100
44
+    battery_level   SMALLINT,                       -- 电池电量 0-100
45
+    read_status     VARCHAR(20) DEFAULT 'success',  -- success / failed / timeout / abnormal
46
+    abnormal_type   VARCHAR(50),                    -- reverse_flow / over_range / stuck / low_battery / signal_lost
47
+    abnormal_desc   VARCHAR(500),
48
+    verified        SMALLINT DEFAULT 0,
49
+    verified_by     BIGINT,
50
+    verified_at     TIMESTAMP,
51
+    deleted         SMALLINT DEFAULT 0,
52
+    created_at      TIMESTAMP DEFAULT NOW(),
53
+    updated_at      TIMESTAMP DEFAULT NOW()
54
+);
55
+COMMENT ON TABLE mr_meter_reading IS '远传抄表记录表';
56
+CREATE INDEX IF NOT EXISTS idx_mr_reading_task ON mr_meter_reading(task_id);
57
+CREATE INDEX IF NOT EXISTS idx_mr_reading_meter ON mr_meter_reading(meter_id);
58
+CREATE INDEX IF NOT EXISTS idx_mr_reading_period ON mr_meter_reading(reading_period);
59
+
60
+-- 大表监控记录 (DN80+)
61
+CREATE TABLE IF NOT EXISTS mr_large_meter_monitor (
62
+    id              BIGSERIAL PRIMARY KEY,
63
+    meter_id        BIGINT NOT NULL,
64
+    meter_no        VARCHAR(50) NOT NULL,
65
+    caliber         VARCHAR(10) NOT NULL,           -- DN80/DN100/DN150/DN200/DN300/DN400
66
+    customer_name   VARCHAR(100),
67
+    area            VARCHAR(50),
68
+    instant_flow    DECIMAL(12,4),                  -- 瞬时流量 m³/h
69
+    cumulative_flow DECIMAL(12,2),                  -- 累计流量 m³
70
+    pressure        DECIMAL(8,4),                   -- 压力 MPa
71
+    flow_direction  SMALLINT DEFAULT 1,             -- 1:正向 -1:反向
72
+    temperature     DECIMAL(6,2),                   -- 水温 ℃
73
+    monitor_time    TIMESTAMP NOT NULL,
74
+    status          VARCHAR(20) DEFAULT 'normal',   -- normal / warning / alarm / offline
75
+    alarm_type      VARCHAR(50),                    -- over_flow / reverse_flow / pressure_low / pressure_high / offline
76
+    alarm_desc      VARCHAR(500),
77
+    deleted         SMALLINT DEFAULT 0,
78
+    created_at      TIMESTAMP DEFAULT NOW(),
79
+    updated_at      TIMESTAMP DEFAULT NOW()
80
+);
81
+COMMENT ON TABLE mr_large_meter_monitor IS '大表监控记录表(DN80+)';
82
+CREATE INDEX IF NOT EXISTS idx_mr_large_meter ON mr_large_meter_monitor(meter_id);
83
+CREATE INDEX IF NOT EXISTS idx_mr_large_time ON mr_large_meter_monitor(monitor_time);
84
+CREATE INDEX IF NOT EXISTS idx_mr_large_status ON mr_large_meter_monitor(status);
85
+
86
+-- 数据质量分析
87
+CREATE TABLE IF NOT EXISTS mr_data_quality (
88
+    id              BIGSERIAL PRIMARY KEY,
89
+    analysis_period VARCHAR(10) NOT NULL,           -- 2026-06
90
+    area            VARCHAR(50),
91
+    total_meters    INT DEFAULT 0,
92
+    read_success    INT DEFAULT 0,
93
+    read_failed     INT DEFAULT 0,
94
+    read_timeout    INT DEFAULT 0,
95
+    abnormal_count  INT DEFAULT 0,
96
+    read_rate       DECIMAL(6,2),                   -- 抄表率 %
97
+    success_rate    DECIMAL(6,2),                   -- 成功率 %
98
+    abnormal_rate   DECIMAL(6,2),                   -- 异常率 %
99
+    avg_signal      DECIMAL(6,2),                   -- 平均信号强度
100
+    avg_battery     DECIMAL(6,2),                   -- 平均电池电量
101
+    quality_score   DECIMAL(6,2),                   -- 数据质量评分 0-100
102
+    quality_level   VARCHAR(10),                    -- excellent / good / fair / poor
103
+    detail          JSONB,                          -- 详细分析数据
104
+    deleted         SMALLINT DEFAULT 0,
105
+    created_at      TIMESTAMP DEFAULT NOW(),
106
+    updated_at      TIMESTAMP DEFAULT NOW()
107
+);
108
+COMMENT ON TABLE mr_data_quality IS '抄表数据质量分析表';
109
+CREATE INDEX IF NOT EXISTS idx_mr_quality_period ON mr_data_quality(analysis_period);

+ 204
- 0
wm-revenue/src/main/java/com/water/revenue/controller/MeterReadingController.java Wyświetl plik

@@ -0,0 +1,204 @@
1
+package com.water.revenue.controller;
2
+
3
+import com.baomidou.mybatisplus.core.metadata.IPage;
4
+import com.water.common.core.result.R;
5
+import com.water.revenue.entity.*;
6
+import com.water.revenue.service.*;
7
+import io.swagger.v3.oas.annotations.Operation;
8
+import io.swagger.v3.oas.annotations.tags.Tag;
9
+import lombok.RequiredArgsConstructor;
10
+import org.springframework.web.bind.annotation.*;
11
+
12
+import java.time.LocalDateTime;
13
+import java.util.*;
14
+
15
+@Tag(name = "远传集抄")
16
+@RestController
17
+@RequestMapping("/meter-reading")
18
+@RequiredArgsConstructor
19
+public class MeterReadingController {
20
+
21
+    private final MeterReadingService meterReadingService;
22
+    private final MeterReadingTaskService taskService;
23
+    private final LargeMeterMonitorService largeMeterService;
24
+    private final MeterDataQualityService qualityService;
25
+
26
+    // ==================== 抄表任务管理 ====================
27
+
28
+    @Operation(summary = "创建抄表任务")
29
+    @PostMapping("/task")
30
+    public R<MeterReadingTask> createTask(@RequestBody Map<String, String> req) {
31
+        LocalDateTime scheduledTime = req.containsKey("scheduledTime")
32
+            ? LocalDateTime.parse(req.get("scheduledTime")) : null;
33
+        MeterReadingTask task = taskService.createTask(
34
+            req.get("taskName"), req.get("taskType"), req.get("area"),
35
+            scheduledTime, req.get("assignedTo"), req.get("remark"));
36
+        return R.ok(task);
37
+    }
38
+
39
+    @Operation(summary = "查询任务详情")
40
+    @GetMapping("/task/{id}")
41
+    public R<MeterReadingTask> getTask(@PathVariable Long id) {
42
+        return R.ok(taskService.getById(id));
43
+    }
44
+
45
+    @Operation(summary = "分页查询任务列表")
46
+    @GetMapping("/task/list")
47
+    public R<IPage<MeterReadingTask>> listTasks(
48
+            @RequestParam(defaultValue = "1") int current,
49
+            @RequestParam(defaultValue = "20") int size,
50
+            @RequestParam(required = false) String status,
51
+            @RequestParam(required = false) String taskType) {
52
+        return R.ok(taskService.page(current, size, status, taskType));
53
+    }
54
+
55
+    @Operation(summary = "取消任务")
56
+    @PutMapping("/task/{id}/cancel")
57
+    public R<String> cancelTask(@PathVariable Long id) {
58
+        taskService.cancelTask(id);
59
+        return R.ok("任务已取消");
60
+    }
61
+
62
+    @Operation(summary = "任务统计概览")
63
+    @GetMapping("/task/overview")
64
+    public R<Map<String, Object>> taskOverview() {
65
+        return R.ok(taskService.taskOverview());
66
+    }
67
+
68
+    // ==================== 批量抄表 ====================
69
+
70
+    @Operation(summary = "执行批量抄表")
71
+    @PostMapping("/batch-read")
72
+    public R<Map<String, Object>> batchRead(@RequestBody Map<String, Object> req) {
73
+        Long taskId = Long.parseLong(String.valueOf(req.get("taskId")));
74
+        String area = (String) req.get("area");
75
+        return R.ok(meterReadingService.batchRead(taskId, area));
76
+    }
77
+
78
+    @Operation(summary = "批量导入抄表数据")
79
+    @PostMapping("/batch-import")
80
+    @SuppressWarnings("unchecked")
81
+    public R<Map<String, Object>> batchImport(@RequestBody Map<String, Object> req) {
82
+        Long taskId = Long.parseLong(String.valueOf(req.get("taskId")));
83
+        List<Map<String, Object>> records = (List<Map<String, Object>>) req.get("records");
84
+        return R.ok(meterReadingService.batchImport(taskId, records));
85
+    }
86
+
87
+    @Operation(summary = "查询抄表记录(分页)")
88
+    @GetMapping("/reading/list")
89
+    public R<IPage<MeterReading>> listReadings(
90
+            @RequestParam(defaultValue = "1") int current,
91
+            @RequestParam(defaultValue = "20") int size,
92
+            @RequestParam(required = false) Long taskId,
93
+            @RequestParam(required = false) String readStatus,
94
+            @RequestParam(required = false) String period) {
95
+        return R.ok(meterReadingService.page(current, size, taskId, readStatus, period));
96
+    }
97
+
98
+    @Operation(summary = "查询抄表记录详情")
99
+    @GetMapping("/reading/{id}")
100
+    public R<MeterReading> getReading(@PathVariable Long id) {
101
+        return R.ok(meterReadingService.getById(id));
102
+    }
103
+
104
+    @Operation(summary = "审核抄表记录")
105
+    @PutMapping("/reading/{id}/verify")
106
+    public R<String> verifyReading(@PathVariable Long id, @RequestParam Long operatorId) {
107
+        meterReadingService.verify(id, operatorId);
108
+        return R.ok("审核通过");
109
+    }
110
+
111
+    @Operation(summary = "抄表异常分布统计")
112
+    @GetMapping("/reading/abnormal-distribution")
113
+    public R<List<Map<String, Object>>> abnormalDistribution(@RequestParam Long taskId) {
114
+        return R.ok(meterReadingService.abnormalDistribution(taskId));
115
+    }
116
+
117
+    @Operation(summary = "抄表趋势(按周期)")
118
+    @GetMapping("/reading/trend")
119
+    public R<List<Map<String, Object>>> readingTrend(
120
+            @RequestParam(defaultValue = "12") int limit) {
121
+        return R.ok(meterReadingService.periodTrend(limit));
122
+    }
123
+
124
+    // ==================== 大表监控 (DN80+) ====================
125
+
126
+    @Operation(summary = "大表实时监控数据采集")
127
+    @PostMapping("/large-meter/collect")
128
+    public R<Map<String, Object>> collectLargeMeter(@RequestBody Map<String, Object> req) {
129
+        Long meterId = Long.parseLong(String.valueOf(req.get("meterId")));
130
+        return R.ok(largeMeterService.collectLargeMeterData(meterId));
131
+    }
132
+
133
+    @Operation(summary = "大表监控列表")
134
+    @GetMapping("/large-meter/list")
135
+    public R<List<Map<String, Object>>> largeMeterList() {
136
+        return R.ok(largeMeterService.largeMeterList());
137
+    }
138
+
139
+    @Operation(summary = "大表24小时摘要")
140
+    @GetMapping("/large-meter/{meterId}/daily-summary")
141
+    public R<Map<String, Object>> dailySummary(@PathVariable Long meterId) {
142
+        return R.ok(largeMeterService.dailySummary(meterId));
143
+    }
144
+
145
+    @Operation(summary = "大表监控记录分页")
146
+    @GetMapping("/large-meter/monitor/list")
147
+    public R<IPage<LargeMeterMonitor>> pageMonitor(
148
+            @RequestParam(defaultValue = "1") int current,
149
+            @RequestParam(defaultValue = "20") int size,
150
+            @RequestParam(required = false) Long meterId,
151
+            @RequestParam(required = false) String status) {
152
+        return R.ok(largeMeterService.pageMonitor(current, size, meterId, status));
153
+    }
154
+
155
+    @Operation(summary = "活跃报警列表")
156
+    @GetMapping("/large-meter/alarms")
157
+    public R<List<Map<String, Object>>> activeAlarms(
158
+            @RequestParam(defaultValue = "50") int limit) {
159
+        return R.ok(largeMeterService.activeAlarms(limit));
160
+    }
161
+
162
+    @Operation(summary = "大表状态分布")
163
+    @GetMapping("/large-meter/status-distribution")
164
+    public R<List<Map<String, Object>>> statusDistribution() {
165
+        return R.ok(largeMeterService.statusDistribution());
166
+    }
167
+
168
+    @Operation(summary = "大表用水趋势(近7天)")
169
+    @GetMapping("/large-meter/{meterId}/flow-trend")
170
+    public R<List<Map<String, Object>>> flowTrend(@PathVariable Long meterId) {
171
+        return R.ok(largeMeterService.flowTrend(meterId));
172
+    }
173
+
174
+    // ==================== 数据质量分析 ====================
175
+
176
+    @Operation(summary = "生成数据质量报告")
177
+    @PostMapping("/quality/generate")
178
+    public R<MeterDataQuality> generateQualityReport(@RequestBody Map<String, String> req) {
179
+        return R.ok(qualityService.generateReport(req.get("period"), req.get("area")));
180
+    }
181
+
182
+    @Operation(summary = "查询质量报告列表")
183
+    @GetMapping("/quality/list")
184
+    public R<IPage<MeterDataQuality>> listQualityReports(
185
+            @RequestParam(defaultValue = "1") int current,
186
+            @RequestParam(defaultValue = "20") int size,
187
+            @RequestParam(required = false) String period,
188
+            @RequestParam(required = false) String qualityLevel) {
189
+        return R.ok(qualityService.page(current, size, period, qualityLevel));
190
+    }
191
+
192
+    @Operation(summary = "质量报告详情")
193
+    @GetMapping("/quality/{id}")
194
+    public R<MeterDataQuality> getQualityReport(@PathVariable Long id) {
195
+        return R.ok(qualityService.getById(id));
196
+    }
197
+
198
+    @Operation(summary = "数据质量趋势")
199
+    @GetMapping("/quality/trend")
200
+    public R<List<Map<String, Object>>> qualityTrend(
201
+            @RequestParam(defaultValue = "12") int limit) {
202
+        return R.ok(qualityService.qualityTrend(limit));
203
+    }
204
+}

+ 30
- 0
wm-revenue/src/main/java/com/water/revenue/entity/LargeMeterMonitor.java Wyświetl plik

@@ -0,0 +1,30 @@
1
+package com.water.revenue.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.TableName;
4
+import com.water.common.core.entity.BaseEntity;
5
+import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
8
+import java.math.BigDecimal;
9
+import java.time.LocalDateTime;
10
+
11
+@Data
12
+@EqualsAndHashCode(callSuper = true)
13
+@TableName("mr_large_meter_monitor")
14
+public class LargeMeterMonitor extends BaseEntity {
15
+
16
+    private Long meterId;
17
+    private String meterNo;
18
+    private String caliber;         // DN80/DN100/DN150/DN200/DN300/DN400
19
+    private String customerName;
20
+    private String area;
21
+    private BigDecimal instantFlow;     // 瞬时流量 m³/h
22
+    private BigDecimal cumulativeFlow;  // 累计流量 m³
23
+    private BigDecimal pressure;        // 压力 MPa
24
+    private Integer flowDirection;      // 1:正向 -1:反向
25
+    private BigDecimal temperature;     // 水温 ℃
26
+    private LocalDateTime monitorTime;
27
+    private String status;          // normal / warning / alarm / offline
28
+    private String alarmType;       // over_flow / reverse_flow / pressure_low / pressure_high / offline
29
+    private String alarmDesc;
30
+}

+ 30
- 0
wm-revenue/src/main/java/com/water/revenue/entity/MeterDataQuality.java Wyświetl plik

@@ -0,0 +1,30 @@
1
+package com.water.revenue.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.TableName;
4
+import com.water.common.core.entity.BaseEntity;
5
+import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
8
+import java.math.BigDecimal;
9
+
10
+@Data
11
+@EqualsAndHashCode(callSuper = true)
12
+@TableName("mr_data_quality")
13
+public class MeterDataQuality extends BaseEntity {
14
+
15
+    private String analysisPeriod;  // 2026-06
16
+    private String area;
17
+    private Integer totalMeters;
18
+    private Integer readSuccess;
19
+    private Integer readFailed;
20
+    private Integer readTimeout;
21
+    private Integer abnormalCount;
22
+    private BigDecimal readRate;        // 抄表率 %
23
+    private BigDecimal successRate;     // 成功率 %
24
+    private BigDecimal abnormalRate;    // 异常率 %
25
+    private BigDecimal avgSignal;       // 平均信号强度
26
+    private BigDecimal avgBattery;      // 平均电池电量
27
+    private BigDecimal qualityScore;    // 数据质量评分 0-100
28
+    private String qualityLevel;        // excellent / good / fair / poor
29
+    private String detail;              // JSONB 详细分析数据
30
+}

+ 33
- 0
wm-revenue/src/main/java/com/water/revenue/entity/MeterReading.java Wyświetl plik

@@ -0,0 +1,33 @@
1
+package com.water.revenue.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.TableName;
4
+import com.water.common.core.entity.BaseEntity;
5
+import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
8
+import java.math.BigDecimal;
9
+import java.time.LocalDateTime;
10
+
11
+@Data
12
+@EqualsAndHashCode(callSuper = true)
13
+@TableName("mr_meter_reading")
14
+public class MeterReading extends BaseEntity {
15
+
16
+    private Long taskId;
17
+    private Long meterId;
18
+    private String meterNo;
19
+    private String deviceSn;
20
+    private BigDecimal prevReading;
21
+    private BigDecimal currReading;
22
+    private BigDecimal consumption;
23
+    private LocalDateTime readingTime;
24
+    private String readingPeriod;
25
+    private Integer signalStrength;
26
+    private Integer batteryLevel;
27
+    private String readStatus;      // success / failed / timeout / abnormal
28
+    private String abnormalType;    // reverse_flow / over_range / stuck / low_battery / signal_lost
29
+    private String abnormalDesc;
30
+    private Integer verified;
31
+    private Long verifiedBy;
32
+    private LocalDateTime verifiedAt;
33
+}

+ 30
- 0
wm-revenue/src/main/java/com/water/revenue/entity/MeterReadingTask.java Wyświetl plik

@@ -0,0 +1,30 @@
1
+package com.water.revenue.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.TableName;
4
+import com.water.common.core.entity.BaseEntity;
5
+import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
8
+import java.time.LocalDateTime;
9
+
10
+@Data
11
+@EqualsAndHashCode(callSuper = true)
12
+@TableName("mr_task")
13
+public class MeterReadingTask extends BaseEntity {
14
+
15
+    private String taskNo;
16
+    private String taskName;
17
+    private String taskType;        // batch_read / large_monitor / manual_check
18
+    private String area;
19
+    private Integer meterCount;
20
+    private Integer successCount;
21
+    private Integer failCount;
22
+    private Integer abnormalCount;
23
+    private Integer progress;       // 0-100
24
+    private String status;          // pending / running / completed / failed / cancelled
25
+    private LocalDateTime scheduledTime;
26
+    private LocalDateTime startedAt;
27
+    private LocalDateTime completedAt;
28
+    private String assignedTo;
29
+    private String remark;
30
+}

+ 35
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/LargeMeterMonitorMapper.java Wyświetl plik

@@ -0,0 +1,35 @@
1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.LargeMeterMonitor;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+import java.util.Map;
11
+
12
+@Mapper
13
+public interface LargeMeterMonitorMapper extends BaseMapper<LargeMeterMonitor> {
14
+
15
+    @Select("SELECT meter_id, meter_no, caliber, area, " +
16
+            "AVG(instant_flow) as avg_flow, MAX(instant_flow) as max_flow, " +
17
+            "MIN(pressure) as min_pressure, MAX(pressure) as max_pressure " +
18
+            "FROM mr_large_meter_monitor " +
19
+            "WHERE meter_id = #{meterId} AND deleted = 0 " +
20
+            "AND monitor_time >= NOW() - INTERVAL '24 hours' " +
21
+            "GROUP BY meter_id, meter_no, caliber, area")
22
+    Map<String, Object> dailySummary(@Param("meterId") Long meterId);
23
+
24
+    @Select("SELECT status, COUNT(*) as cnt FROM mr_large_meter_monitor " +
25
+            "WHERE deleted = 0 AND monitor_time >= NOW() - INTERVAL '1 hour' " +
26
+            "GROUP BY status")
27
+    List<Map<String, Object>> currentStatusDistribution();
28
+
29
+    @Select("SELECT meter_id, meter_no, caliber, instant_flow, cumulative_flow, " +
30
+            "pressure, status, alarm_type, alarm_desc, monitor_time " +
31
+            "FROM mr_large_meter_monitor " +
32
+            "WHERE deleted = 0 AND status IN ('warning', 'alarm') " +
33
+            "ORDER BY monitor_time DESC LIMIT #{limit}")
34
+    List<Map<String, Object>> activeAlarms(@Param("limit") int limit);
35
+}

+ 21
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/MeterDataQualityMapper.java Wyświetl plik

@@ -0,0 +1,21 @@
1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.MeterDataQuality;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+import java.util.Map;
11
+
12
+@Mapper
13
+public interface MeterDataQualityMapper extends BaseMapper<MeterDataQuality> {
14
+
15
+    @Select("SELECT analysis_period, " +
16
+            "AVG(read_rate) as avg_read_rate, AVG(success_rate) as avg_success_rate, " +
17
+            "AVG(abnormal_rate) as avg_abnormal_rate, AVG(quality_score) as avg_quality_score " +
18
+            "FROM mr_data_quality WHERE deleted = 0 " +
19
+            "GROUP BY analysis_period ORDER BY analysis_period DESC LIMIT #{limit}")
20
+    List<Map<String, Object>> qualityTrend(@Param("limit") int limit);
21
+}

+ 30
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/MeterReadingMapper.java Wyświetl plik

@@ -0,0 +1,30 @@
1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.MeterReading;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+import java.util.Map;
11
+
12
+@Mapper
13
+public interface MeterReadingMapper extends BaseMapper<MeterReading> {
14
+
15
+    @Select("SELECT read_status, COUNT(*) as cnt FROM mr_meter_reading " +
16
+            "WHERE task_id = #{taskId} AND deleted = 0 GROUP BY read_status")
17
+    List<Map<String, Object>> countByStatus(@Param("taskId") Long taskId);
18
+
19
+    @Select("SELECT abnormal_type, COUNT(*) as cnt FROM mr_meter_reading " +
20
+            "WHERE task_id = #{taskId} AND deleted = 0 AND abnormal_type IS NOT NULL " +
21
+            "GROUP BY abnormal_type ORDER BY cnt DESC")
22
+    List<Map<String, Object>> abnormalDistribution(@Param("taskId") Long taskId);
23
+
24
+    @Select("SELECT reading_period, COUNT(*) as total, " +
25
+            "SUM(CASE WHEN read_status = 'success' THEN 1 ELSE 0 END) as success_cnt, " +
26
+            "SUM(CASE WHEN read_status = 'abnormal' THEN 1 ELSE 0 END) as abnormal_cnt " +
27
+            "FROM mr_meter_reading WHERE deleted = 0 " +
28
+            "GROUP BY reading_period ORDER BY reading_period DESC LIMIT #{limit}")
29
+    List<Map<String, Object>> periodTrend(@Param("limit") int limit);
30
+}

+ 9
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/MeterReadingTaskMapper.java Wyświetl plik

@@ -0,0 +1,9 @@
1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.MeterReadingTask;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface MeterReadingTaskMapper extends BaseMapper<MeterReadingTask> {
9
+}

+ 172
- 0
wm-revenue/src/main/java/com/water/revenue/service/LargeMeterMonitorService.java Wyświetl plik

@@ -0,0 +1,172 @@
1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.core.metadata.IPage;
5
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
6
+import com.water.revenue.entity.LargeMeterMonitor;
7
+import com.water.revenue.mapper.LargeMeterMonitorMapper;
8
+import lombok.RequiredArgsConstructor;
9
+import lombok.extern.slf4j.Slf4j;
10
+import org.springframework.jdbc.core.JdbcTemplate;
11
+import org.springframework.stereotype.Service;
12
+
13
+import java.math.BigDecimal;
14
+import java.math.RoundingMode;
15
+import java.time.LocalDateTime;
16
+import java.util.*;
17
+
18
+@Slf4j
19
+@Service
20
+@RequiredArgsConstructor
21
+public class LargeMeterMonitorService {
22
+
23
+    private final LargeMeterMonitorMapper monitorMapper;
24
+    private final JdbcTemplate jdbcTemplate;
25
+
26
+    /** 实时采集大表数据(DN80+) */
27
+    public Map<String, Object> collectLargeMeterData(Long meterId) {
28
+        // 查询大表基础信息
29
+        Map<String, Object> meterInfo = jdbcTemplate.queryForMap(
30
+            "SELECT rm.id, rm.meter_no, rm.caliber, rm.current_reading, " +
31
+            "c.customer_name, c.area, i.device_sn " +
32
+            "FROM rev_meter rm " +
33
+            "JOIN rev_customer c ON rm.customer_id = c.id " +
34
+            "LEFT JOIN iot_device i ON rm.device_id = i.id " +
35
+            "WHERE rm.id = ? AND rm.caliber IN ('DN80','DN100','DN150','DN200','DN300','DN400')",
36
+            meterId);
37
+
38
+        Random rng = new Random();
39
+        LargeMeterMonitor monitor = new LargeMeterMonitor();
40
+        monitor.setMeterId(meterId);
41
+        monitor.setMeterNo((String) meterInfo.get("meter_no"));
42
+        monitor.setCaliber((String) meterInfo.get("caliber"));
43
+        monitor.setCustomerName((String) meterInfo.get("customer_name"));
44
+        monitor.setArea((String) meterInfo.get("area"));
45
+
46
+        // 模拟大表实时数据
47
+        BigDecimal instantFlow = BigDecimal.valueOf(5 + rng.nextDouble() * 50)
48
+            .setScale(4, RoundingMode.HALF_UP);
49
+        BigDecimal prevReading = meterInfo.get("current_reading") != null
50
+            ? ((Number) meterInfo.get("current_reading")).decimalValue() : BigDecimal.ZERO;
51
+        BigDecimal cumulativeFlow = prevReading.add(
52
+            BigDecimal.valueOf(rng.nextDouble() * 2).setScale(2, RoundingMode.HALF_UP));
53
+        BigDecimal pressure = BigDecimal.valueOf(0.2 + rng.nextDouble() * 0.4)
54
+            .setScale(4, RoundingMode.HALF_UP);
55
+        BigDecimal temperature = BigDecimal.valueOf(15 + rng.nextDouble() * 15)
56
+            .setScale(2, RoundingMode.HALF_UP);
57
+
58
+        monitor.setInstantFlow(instantFlow);
59
+        monitor.setCumulativeFlow(cumulativeFlow);
60
+        monitor.setPressure(pressure);
61
+        monitor.setFlowDirection(1);
62
+        monitor.setTemperature(temperature);
63
+        monitor.setMonitorTime(LocalDateTime.now());
64
+
65
+        // 报警检测
66
+        String alarmType = detectLargeMeterAlarm(instantFlow, pressure);
67
+        if (alarmType != null) {
68
+            monitor.setStatus("alarm");
69
+            monitor.setAlarmType(alarmType);
70
+            monitor.setAlarmDesc(getAlarmDesc(alarmType, instantFlow, pressure));
71
+        } else {
72
+            monitor.setStatus("normal");
73
+        }
74
+
75
+        monitorMapper.insert(monitor);
76
+
77
+        // 更新水表读数
78
+        jdbcTemplate.update("UPDATE rev_meter SET current_reading = ? WHERE id = ?",
79
+            cumulativeFlow, meterId);
80
+
81
+        Map<String, Object> result = new HashMap<>(meterInfo);
82
+        result.put("instantFlow", instantFlow);
83
+        result.put("cumulativeFlow", cumulativeFlow);
84
+        result.put("pressure", pressure);
85
+        result.put("temperature", temperature);
86
+        result.put("status", monitor.getStatus());
87
+        result.put("alarmType", monitor.getAlarmType());
88
+        result.put("alarmDesc", monitor.getAlarmDesc());
89
+        return result;
90
+    }
91
+
92
+    /** 大表报警检测 */
93
+    private String detectLargeMeterAlarm(BigDecimal flow, BigDecimal pressure) {
94
+        if (flow.compareTo(BigDecimal.valueOf(100)) > 0) return "over_flow";
95
+        if (pressure.compareTo(BigDecimal.valueOf(0.15)) < 0) return "pressure_low";
96
+        if (pressure.compareTo(BigDecimal.valueOf(0.6)) > 0) return "pressure_high";
97
+        return null;
98
+    }
99
+
100
+    private String getAlarmDesc(String alarmType, BigDecimal flow, BigDecimal pressure) {
101
+        return switch (alarmType) {
102
+            case "over_flow" -> "瞬时流量 " + flow + " m³/h 超过阈值 100 m³/h";
103
+            case "pressure_low" -> "管网压力 " + pressure + " MPa 低于阈值 0.15 MPa";
104
+            case "pressure_high" -> "管网压力 " + pressure + " MPa 高于阈值 0.6 MPa";
105
+            default -> "未知报警";
106
+        };
107
+    }
108
+
109
+    /** 获取大表监控列表(最新状态) */
110
+    public List<Map<String, Object>> largeMeterList() {
111
+        return jdbcTemplate.queryForList(
112
+            "SELECT rm.id, rm.meter_no, rm.caliber, rm.current_reading, rm.install_address, " +
113
+            "c.customer_name, c.area, " +
114
+            "(SELECT status FROM mr_large_meter_monitor WHERE meter_id = rm.id AND deleted = 0 " +
115
+            " ORDER BY monitor_time DESC LIMIT 1) as monitor_status, " +
116
+            "(SELECT instant_flow FROM mr_large_meter_monitor WHERE meter_id = rm.id AND deleted = 0 " +
117
+            " ORDER BY monitor_time DESC LIMIT 1) as latest_flow, " +
118
+            "(SELECT pressure FROM mr_large_meter_monitor WHERE meter_id = rm.id AND deleted = 0 " +
119
+            " ORDER BY monitor_time DESC LIMIT 1) as latest_pressure, " +
120
+            "(SELECT monitor_time FROM mr_large_meter_monitor WHERE meter_id = rm.id AND deleted = 0 " +
121
+            " ORDER BY monitor_time DESC LIMIT 1) as latest_monitor_time " +
122
+            "FROM rev_meter rm " +
123
+            "JOIN rev_customer c ON rm.customer_id = c.id " +
124
+            "WHERE rm.caliber IN ('DN80','DN100','DN150','DN200','DN300','DN400') " +
125
+            "AND rm.status = 'active' " +
126
+            "ORDER BY rm.caliber DESC");
127
+    }
128
+
129
+    /** 大表24小时趋势 */
130
+    public Map<String, Object> dailySummary(Long meterId) {
131
+        Map<String, Object> summary = monitorMapper.dailySummary(meterId);
132
+        if (summary == null) {
133
+            summary = new HashMap<>();
134
+            summary.put("meterId", meterId);
135
+            summary.put("message", "暂无24小时监控数据");
136
+        }
137
+        return summary;
138
+    }
139
+
140
+    /** 大表监控分页查询 */
141
+    public IPage<LargeMeterMonitor> pageMonitor(int current, int size, Long meterId, String status) {
142
+        LambdaQueryWrapper<LargeMeterMonitor> wrapper = new LambdaQueryWrapper<>();
143
+        if (meterId != null) wrapper.eq(LargeMeterMonitor::getMeterId, meterId);
144
+        if (status != null && !status.isEmpty()) wrapper.eq(LargeMeterMonitor::getStatus, status);
145
+        wrapper.orderByDesc(LargeMeterMonitor::getMonitorTime);
146
+        return monitorMapper.selectPage(new Page<>(current, size), wrapper);
147
+    }
148
+
149
+    /** 活跃报警列表 */
150
+    public List<Map<String, Object>> activeAlarms(int limit) {
151
+        return monitorMapper.activeAlarms(limit);
152
+    }
153
+
154
+    /** 当前状态分布 */
155
+    public List<Map<String, Object>> statusDistribution() {
156
+        return monitorMapper.currentStatusDistribution();
157
+    }
158
+
159
+    /** 用水趋势分析(近7天) */
160
+    public List<Map<String, Object>> flowTrend(Long meterId) {
161
+        return jdbcTemplate.queryForList(
162
+            "SELECT DATE(monitor_time) as date, " +
163
+            "AVG(instant_flow) as avg_flow, MAX(instant_flow) as max_flow, " +
164
+            "MIN(instant_flow) as min_flow, " +
165
+            "MAX(cumulative_flow) - MIN(cumulative_flow) as daily_consumption " +
166
+            "FROM mr_large_meter_monitor " +
167
+            "WHERE meter_id = ? AND deleted = 0 " +
168
+            "AND monitor_time >= NOW() - INTERVAL '7 days' " +
169
+            "GROUP BY DATE(monitor_time) ORDER BY date",
170
+            meterId);
171
+    }
172
+}

+ 123
- 0
wm-revenue/src/main/java/com/water/revenue/service/MeterDataQualityService.java Wyświetl plik

@@ -0,0 +1,123 @@
1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.core.metadata.IPage;
5
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
6
+import com.water.revenue.entity.MeterDataQuality;
7
+import com.water.revenue.mapper.MeterDataQualityMapper;
8
+import lombok.RequiredArgsConstructor;
9
+import lombok.extern.slf4j.Slf4j;
10
+import org.springframework.jdbc.core.JdbcTemplate;
11
+import org.springframework.stereotype.Service;
12
+
13
+import java.math.BigDecimal;
14
+import java.math.RoundingMode;
15
+import java.time.YearMonth;
16
+import java.time.format.DateTimeFormatter;
17
+import java.util.*;
18
+
19
+@Slf4j
20
+@Service
21
+@RequiredArgsConstructor
22
+public class MeterDataQualityService {
23
+
24
+    private final MeterDataQualityMapper qualityMapper;
25
+    private final JdbcTemplate jdbcTemplate;
26
+
27
+    /** 生成数据质量分析报告 */
28
+    public MeterDataQuality generateReport(String period, String area) {
29
+        if (period == null || period.isEmpty()) {
30
+            period = YearMonth.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
31
+        }
32
+
33
+        String areaClause = (area != null && !area.isEmpty())
34
+            ? " AND c.area = '" + area.replace("'", "''") + "'" : "";
35
+
36
+        // 统计数据
37
+        Map<String, Object> stats = jdbcTemplate.queryForMap(
38
+            "SELECT COUNT(*) as total, " +
39
+            "SUM(CASE WHEN mr.read_status = 'success' THEN 1 ELSE 0 END) as success_cnt, " +
40
+            "SUM(CASE WHEN mr.read_status = 'failed' THEN 1 ELSE 0 END) as failed_cnt, " +
41
+            "SUM(CASE WHEN mr.read_status = 'timeout' THEN 1 ELSE 0 END) as timeout_cnt, " +
42
+            "SUM(CASE WHEN mr.read_status = 'abnormal' THEN 1 ELSE 0 END) as abnormal_cnt, " +
43
+            "AVG(mr.signal_strength) as avg_signal, " +
44
+            "AVG(mr.battery_level) as avg_battery " +
45
+            "FROM mr_meter_reading mr " +
46
+            "LEFT JOIN rev_meter rm ON mr.meter_id = rm.id " +
47
+            "LEFT JOIN rev_customer c ON rm.customer_id = c.id " +
48
+            "WHERE mr.reading_period = ? AND mr.deleted = 0" + areaClause,
49
+            period);
50
+
51
+        int total = ((Number) stats.get("total")).intValue();
52
+        int success = ((Number) stats.get("success_cnt")).intValue();
53
+        int failed = ((Number) stats.get("failed_cnt")).intValue();
54
+        int timeout = stats.get("timeout_cnt") != null ? ((Number) stats.get("timeout_cnt")).intValue() : 0;
55
+        int abnormal = ((Number) stats.get("abnormal_cnt")).intValue();
56
+
57
+        MeterDataQuality quality = new MeterDataQuality();
58
+        quality.setAnalysisPeriod(period);
59
+        quality.setArea(area);
60
+        quality.setTotalMeters(total);
61
+        quality.setReadSuccess(success);
62
+        quality.setReadFailed(failed);
63
+        quality.setReadTimeout(timeout);
64
+        quality.setAbnormalCount(abnormal);
65
+
66
+        // 计算指标
67
+        if (total > 0) {
68
+            quality.setReadRate(BigDecimal.valueOf((success + abnormal) * 100.0 / total)
69
+                .setScale(2, RoundingMode.HALF_UP));
70
+            quality.setSuccessRate(BigDecimal.valueOf(success * 100.0 / total)
71
+                .setScale(2, RoundingMode.HALF_UP));
72
+            quality.setAbnormalRate(BigDecimal.valueOf(abnormal * 100.0 / total)
73
+                .setScale(2, RoundingMode.HALF_UP));
74
+        } else {
75
+            quality.setReadRate(BigDecimal.ZERO);
76
+            quality.setSuccessRate(BigDecimal.ZERO);
77
+            quality.setAbnormalRate(BigDecimal.ZERO);
78
+        }
79
+
80
+        quality.setAvgSignal(stats.get("avg_signal") != null
81
+            ? ((Number) stats.get("avg_signal")).decimalValue().setScale(2, RoundingMode.HALF_UP)
82
+            : BigDecimal.ZERO);
83
+        quality.setAvgBattery(stats.get("avg_battery") != null
84
+            ? ((Number) stats.get("avg_battery")).decimalValue().setScale(2, RoundingMode.HALF_UP)
85
+            : BigDecimal.ZERO);
86
+
87
+        // 综合评分 = 成功率 * 0.4 + 抄表率 * 0.3 + (100 - 异常率) * 0.3
88
+        BigDecimal score = quality.getSuccessRate().multiply(BigDecimal.valueOf(0.4))
89
+            .add(quality.getReadRate().multiply(BigDecimal.valueOf(0.3)))
90
+            .add(BigDecimal.valueOf(100).subtract(quality.getAbnormalRate()).multiply(BigDecimal.valueOf(0.3)));
91
+        quality.setQualityScore(score.setScale(2, RoundingMode.HALF_UP));
92
+
93
+        // 等级评定
94
+        if (score.compareTo(BigDecimal.valueOf(90)) >= 0) quality.setQualityLevel("excellent");
95
+        else if (score.compareTo(BigDecimal.valueOf(75)) >= 0) quality.setQualityLevel("good");
96
+        else if (score.compareTo(BigDecimal.valueOf(60)) >= 0) quality.setQualityLevel("fair");
97
+        else quality.setQualityLevel("poor");
98
+
99
+        qualityMapper.insert(quality);
100
+        log.info("Generated quality report: period={} area={} score={} level={}",
101
+            period, area, quality.getQualityScore(), quality.getQualityLevel());
102
+        return quality;
103
+    }
104
+
105
+    /** 查询质量报告列表 */
106
+    public IPage<MeterDataQuality> page(int current, int size, String period, String qualityLevel) {
107
+        LambdaQueryWrapper<MeterDataQuality> wrapper = new LambdaQueryWrapper<>();
108
+        if (period != null && !period.isEmpty()) wrapper.eq(MeterDataQuality::getAnalysisPeriod, period);
109
+        if (qualityLevel != null && !qualityLevel.isEmpty()) wrapper.eq(MeterDataQuality::getQualityLevel, qualityLevel);
110
+        wrapper.orderByDesc(MeterDataQuality::getCreatedAt);
111
+        return qualityMapper.selectPage(new Page<>(current, size), wrapper);
112
+    }
113
+
114
+    /** 质量趋势 */
115
+    public List<Map<String, Object>> qualityTrend(int limit) {
116
+        return qualityMapper.qualityTrend(limit);
117
+    }
118
+
119
+    /** 获取报告详情 */
120
+    public MeterDataQuality getById(Long id) {
121
+        return qualityMapper.selectById(id);
122
+    }
123
+}

+ 228
- 0
wm-revenue/src/main/java/com/water/revenue/service/MeterReadingService.java Wyświetl plik

@@ -0,0 +1,228 @@
1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.core.metadata.IPage;
5
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
6
+import com.water.revenue.entity.MeterReading;
7
+import com.water.revenue.mapper.MeterReadingMapper;
8
+import lombok.RequiredArgsConstructor;
9
+import lombok.extern.slf4j.Slf4j;
10
+import org.springframework.jdbc.core.JdbcTemplate;
11
+import org.springframework.stereotype.Service;
12
+import org.springframework.transaction.annotation.Transactional;
13
+
14
+import java.math.BigDecimal;
15
+import java.math.RoundingMode;
16
+import java.time.LocalDateTime;
17
+import java.time.YearMonth;
18
+import java.time.format.DateTimeFormatter;
19
+import java.util.*;
20
+
21
+@Slf4j
22
+@Service
23
+@RequiredArgsConstructor
24
+public class MeterReadingService {
25
+
26
+    private final MeterReadingMapper meterReadingMapper;
27
+    private final MeterReadingTaskService taskService;
28
+    private final JdbcTemplate jdbcTemplate;
29
+
30
+    /** 批量抄表 - 远传数据自动采集 */
31
+    @Transactional
32
+    public Map<String, Object> batchRead(Long taskId, String area) {
33
+        taskService.updateStatus(taskId, "running");
34
+
35
+        // 查询区域内活跃的远传水表
36
+        List<Map<String, Object>> meters = jdbcTemplate.queryForList(
37
+            "SELECT rm.id, rm.meter_no, rm.current_reading, rm.caliber, " +
38
+            "i.device_sn, c.customer_name, c.area " +
39
+            "FROM rev_meter rm " +
40
+            "LEFT JOIN iot_device i ON rm.device_id = i.id " +
41
+            "JOIN rev_customer c ON rm.customer_id = c.id " +
42
+            "WHERE rm.status = 'active' AND i.device_sn IS NOT NULL" +
43
+            (area != null && !area.isEmpty() ? " AND c.area = ?" : ""),
44
+            area != null && !area.isEmpty() ? new Object[]{area} : new Object[]{});
45
+
46
+        String period = YearMonth.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
47
+        int success = 0, failed = 0, abnormal = 0;
48
+        Random rng = new Random();
49
+
50
+        for (Map<String, Object> m : meters) {
51
+            MeterReading reading = new MeterReading();
52
+            reading.setTaskId(taskId);
53
+            reading.setMeterId(((Number) m.get("id")).longValue());
54
+            reading.setMeterNo((String) m.get("meter_no"));
55
+            reading.setDeviceSn((String) m.get("device_sn"));
56
+            reading.setReadingTime(LocalDateTime.now());
57
+            reading.setReadingPeriod(period);
58
+
59
+            try {
60
+                BigDecimal prev = m.get("current_reading") != null
61
+                    ? ((Number) m.get("current_reading")).decimalValue()
62
+                    : BigDecimal.ZERO;
63
+
64
+                // 模拟远传采集:随机增量 0-50 m³
65
+                double increment = rng.nextDouble() * 50;
66
+                BigDecimal curr = prev.add(BigDecimal.valueOf(increment).setScale(2, RoundingMode.HALF_UP));
67
+                BigDecimal consumption = curr.subtract(prev);
68
+
69
+                // 信号强度和电池电量模拟
70
+                int signal = 60 + rng.nextInt(41);  // 60-100
71
+                int battery = 30 + rng.nextInt(71); // 30-100
72
+
73
+                reading.setPrevReading(prev);
74
+                reading.setCurrReading(curr);
75
+                reading.setConsumption(consumption);
76
+                reading.setSignalStrength(signal);
77
+                reading.setBatteryLevel(battery);
78
+
79
+                // 异常检测
80
+                String abnormalType = detectAbnormal(consumption, signal, battery, prev);
81
+                if (abnormalType != null) {
82
+                    reading.setReadStatus("abnormal");
83
+                    reading.setAbnormalType(abnormalType);
84
+                    reading.setAbnormalDesc(getAbnormalDesc(abnormalType));
85
+                    abnormal++;
86
+                } else {
87
+                    reading.setReadStatus("success");
88
+                    success++;
89
+                }
90
+
91
+                meterReadingMapper.insert(reading);
92
+
93
+                // 更新水表当前读数
94
+                jdbcTemplate.update("UPDATE rev_meter SET current_reading = ? WHERE id = ?",
95
+                    curr, m.get("id"));
96
+
97
+                // 同步写入 rev_reading
98
+                jdbcTemplate.update(
99
+                    "INSERT INTO rev_reading (meter_id, reading_date, reading_period, prev_reading, " +
100
+                    "curr_reading, consumption, read_type, abnormal_flag) " +
101
+                    "VALUES (?, CURRENT_DATE, ?, ?, ?, ?, 'remote', ?)",
102
+                    m.get("id"), period, prev, curr, consumption,
103
+                    abnormalType != null ? 1 : 0);
104
+
105
+            } catch (Exception e) {
106
+                reading.setReadStatus("failed");
107
+                reading.setAbnormalDesc("采集失败: " + e.getMessage());
108
+                meterReadingMapper.insert(reading);
109
+                failed++;
110
+                log.warn("Read failed for meter {}: {}", m.get("meter_no"), e.getMessage());
111
+            }
112
+        }
113
+
114
+        taskService.updateProgress(taskId, meters.size(), success, failed, abnormal);
115
+        taskService.updateStatus(taskId, "completed");
116
+
117
+        log.info("Batch read completed: area={} total={} success={} failed={} abnormal={}",
118
+            area, meters.size(), success, failed, abnormal);
119
+
120
+        Map<String, Object> result = new HashMap<>();
121
+        result.put("taskId", taskId);
122
+        result.put("area", area);
123
+        result.put("total", meters.size());
124
+        result.put("success", success);
125
+        result.put("failed", failed);
126
+        result.put("abnormal", abnormal);
127
+        result.put("period", period);
128
+        result.put("successRate", meters.isEmpty() ? 0 :
129
+            BigDecimal.valueOf(success * 100.0 / meters.size()).setScale(2, RoundingMode.HALF_UP));
130
+        return result;
131
+    }
132
+
133
+    /** 异常检测 */
134
+    private String detectAbnormal(BigDecimal consumption, int signal, int battery, BigDecimal prev) {
135
+        if (battery < 20) return "low_battery";
136
+        if (signal < 30) return "signal_lost";
137
+        if (consumption.compareTo(BigDecimal.ZERO) < 0) return "reverse_flow";
138
+        if (consumption.compareTo(prev.multiply(BigDecimal.valueOf(3))) > 0 && prev.compareTo(BigDecimal.ZERO) > 0)
139
+            return "over_range";
140
+        if (consumption.compareTo(BigDecimal.ZERO) == 0 && prev.compareTo(BigDecimal.ZERO) > 0)
141
+            return "stuck";
142
+        return null;
143
+    }
144
+
145
+    private String getAbnormalDesc(String abnormalType) {
146
+        return switch (abnormalType) {
147
+            case "reverse_flow" -> "反向流量,可能管道接反";
148
+            case "over_range" -> "用水量超出正常范围3倍以上";
149
+            case "stuck" -> "水表读数无变化,疑似卡表";
150
+            case "low_battery" -> "设备电池电量过低";
151
+            case "signal_lost" -> "信号强度过低,数据可能不准确";
152
+            default -> "未知异常";
153
+        };
154
+    }
155
+
156
+    /** 批量导入抄表数据 */
157
+    @Transactional
158
+    public Map<String, Object> batchImport(Long taskId, List<Map<String, Object>> records) {
159
+        int success = 0, failed = 0;
160
+        String period = YearMonth.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
161
+
162
+        for (Map<String, Object> rec : records) {
163
+            try {
164
+                MeterReading reading = new MeterReading();
165
+                reading.setTaskId(taskId);
166
+                reading.setMeterNo((String) rec.get("meterNo"));
167
+                reading.setMeterId(((Number) rec.get("meterId")).longValue());
168
+                reading.setDeviceSn((String) rec.get("deviceSn"));
169
+
170
+                BigDecimal prev = new BigDecimal(String.valueOf(rec.get("prevReading")));
171
+                BigDecimal curr = new BigDecimal(String.valueOf(rec.get("currReading")));
172
+                reading.setPrevReading(prev);
173
+                reading.setCurrReading(curr);
174
+                reading.setConsumption(curr.subtract(prev));
175
+                reading.setReadingTime(LocalDateTime.now());
176
+                reading.setReadingPeriod(period);
177
+                reading.setReadStatus("success");
178
+
179
+                meterReadingMapper.insert(reading);
180
+                success++;
181
+            } catch (Exception e) {
182
+                failed++;
183
+                log.warn("Import failed: {}", e.getMessage());
184
+            }
185
+        }
186
+
187
+        Map<String, Object> result = new HashMap<>();
188
+        result.put("total", records.size());
189
+        result.put("success", success);
190
+        result.put("failed", failed);
191
+        return result;
192
+    }
193
+
194
+    /** 查询抄表记录(分页) */
195
+    public IPage<MeterReading> page(int current, int size, Long taskId, String readStatus, String period) {
196
+        LambdaQueryWrapper<MeterReading> wrapper = new LambdaQueryWrapper<>();
197
+        if (taskId != null) wrapper.eq(MeterReading::getTaskId, taskId);
198
+        if (readStatus != null && !readStatus.isEmpty()) wrapper.eq(MeterReading::getReadStatus, readStatus);
199
+        if (period != null && !period.isEmpty()) wrapper.eq(MeterReading::getReadingPeriod, period);
200
+        wrapper.orderByDesc(MeterReading::getReadingTime);
201
+        return meterReadingMapper.selectPage(new Page<>(current, size), wrapper);
202
+    }
203
+
204
+    /** 获取单条抄表记录 */
205
+    public MeterReading getById(Long id) {
206
+        return meterReadingMapper.selectById(id);
207
+    }
208
+
209
+    /** 抄表异常分布统计 */
210
+    public List<Map<String, Object>> abnormalDistribution(Long taskId) {
211
+        return meterReadingMapper.abnormalDistribution(taskId);
212
+    }
213
+
214
+    /** 抄表趋势 */
215
+    public List<Map<String, Object>> periodTrend(int limit) {
216
+        return meterReadingMapper.periodTrend(limit);
217
+    }
218
+
219
+    /** 审核抄表记录 */
220
+    public void verify(Long readingId, Long operatorId) {
221
+        MeterReading reading = meterReadingMapper.selectById(readingId);
222
+        if (reading == null) throw new RuntimeException("抄表记录不存在: " + readingId);
223
+        reading.setVerified(1);
224
+        reading.setVerifiedBy(operatorId);
225
+        reading.setVerifiedAt(LocalDateTime.now());
226
+        meterReadingMapper.updateById(reading);
227
+    }
228
+}

+ 117
- 0
wm-revenue/src/main/java/com/water/revenue/service/MeterReadingTaskService.java Wyświetl plik

@@ -0,0 +1,117 @@
1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.core.metadata.IPage;
5
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
6
+import com.water.revenue.entity.MeterReadingTask;
7
+import com.water.revenue.mapper.MeterReadingTaskMapper;
8
+import lombok.RequiredArgsConstructor;
9
+import lombok.extern.slf4j.Slf4j;
10
+import org.springframework.stereotype.Service;
11
+
12
+import java.time.LocalDateTime;
13
+import java.time.format.DateTimeFormatter;
14
+import java.util.HashMap;
15
+import java.util.Map;
16
+import java.util.UUID;
17
+
18
+@Slf4j
19
+@Service
20
+@RequiredArgsConstructor
21
+public class MeterReadingTaskService {
22
+
23
+    private final MeterReadingTaskMapper taskMapper;
24
+
25
+    /** 创建抄表任务 */
26
+    public MeterReadingTask createTask(String taskName, String taskType, String area,
27
+                                       LocalDateTime scheduledTime, String assignedTo, String remark) {
28
+        MeterReadingTask task = new MeterReadingTask();
29
+        task.setTaskNo("MRT-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
30
+                + "-" + UUID.randomUUID().toString().substring(0, 6).toUpperCase());
31
+        task.setTaskName(taskName);
32
+        task.setTaskType(taskType);
33
+        task.setArea(area);
34
+        task.setMeterCount(0);
35
+        task.setSuccessCount(0);
36
+        task.setFailCount(0);
37
+        task.setAbnormalCount(0);
38
+        task.setProgress(0);
39
+        task.setStatus("pending");
40
+        task.setScheduledTime(scheduledTime);
41
+        task.setAssignedTo(assignedTo);
42
+        task.setRemark(remark);
43
+        taskMapper.insert(task);
44
+        log.info("Created meter reading task: {}", task.getTaskNo());
45
+        return task;
46
+    }
47
+
48
+    /** 查询任务详情 */
49
+    public MeterReadingTask getById(Long id) {
50
+        return taskMapper.selectById(id);
51
+    }
52
+
53
+    /** 分页查询任务 */
54
+    public IPage<MeterReadingTask> page(int current, int size, String status, String taskType) {
55
+        LambdaQueryWrapper<MeterReadingTask> wrapper = new LambdaQueryWrapper<>();
56
+        if (status != null && !status.isEmpty()) {
57
+            wrapper.eq(MeterReadingTask::getStatus, status);
58
+        }
59
+        if (taskType != null && !taskType.isEmpty()) {
60
+            wrapper.eq(MeterReadingTask::getTaskType, taskType);
61
+        }
62
+        wrapper.orderByDesc(MeterReadingTask::getCreatedAt);
63
+        return taskMapper.selectPage(new Page<>(current, size), wrapper);
64
+    }
65
+
66
+    /** 更新任务状态 */
67
+    public void updateStatus(Long taskId, String status) {
68
+        MeterReadingTask task = taskMapper.selectById(taskId);
69
+        if (task == null) throw new RuntimeException("任务不存在: " + taskId);
70
+        task.setStatus(status);
71
+        if ("running".equals(status)) {
72
+            task.setStartedAt(LocalDateTime.now());
73
+        } else if ("completed".equals(status) || "failed".equals(status)) {
74
+            task.setCompletedAt(LocalDateTime.now());
75
+        }
76
+        taskMapper.updateById(task);
77
+    }
78
+
79
+    /** 更新任务进度 */
80
+    public void updateProgress(Long taskId, int meterCount, int successCount, int failCount, int abnormalCount) {
81
+        MeterReadingTask task = taskMapper.selectById(taskId);
82
+        if (task == null) throw new RuntimeException("任务不存在: " + taskId);
83
+        task.setMeterCount(meterCount);
84
+        task.setSuccessCount(successCount);
85
+        task.setFailCount(failCount);
86
+        task.setAbnormalCount(abnormalCount);
87
+        task.setProgress(meterCount > 0 ? (successCount + failCount + abnormalCount) * 100 / meterCount : 0);
88
+        taskMapper.updateById(task);
89
+    }
90
+
91
+    /** 取消任务 */
92
+    public void cancelTask(Long taskId) {
93
+        updateStatus(taskId, "cancelled");
94
+    }
95
+
96
+    /** 任务统计概览 */
97
+    public Map<String, Object> taskOverview() {
98
+        Map<String, Object> overview = new HashMap<>();
99
+        LambdaQueryWrapper<MeterReadingTask> wrapper = new LambdaQueryWrapper<>();
100
+        wrapper.eq(MeterReadingTask::getStatus, "pending");
101
+        overview.put("pendingCount", taskMapper.selectCount(wrapper));
102
+
103
+        wrapper = new LambdaQueryWrapper<>();
104
+        wrapper.eq(MeterReadingTask::getStatus, "running");
105
+        overview.put("runningCount", taskMapper.selectCount(wrapper));
106
+
107
+        wrapper = new LambdaQueryWrapper<>();
108
+        wrapper.eq(MeterReadingTask::getStatus, "completed");
109
+        overview.put("completedCount", taskMapper.selectCount(wrapper));
110
+
111
+        wrapper = new LambdaQueryWrapper<>();
112
+        wrapper.ge(MeterReadingTask::getCreatedAt, LocalDateTime.now().minusDays(7));
113
+        overview.put("weekTotal", taskMapper.selectCount(wrapper));
114
+
115
+        return overview;
116
+    }
117
+}

+ 336
- 0
wm-revenue/src/test/java/com/water/revenue/service/MeterReadingServiceTest.java Wyświetl plik

@@ -0,0 +1,336 @@
1
+package com.water.revenue.service;
2
+
3
+import com.water.revenue.entity.*;
4
+import org.junit.jupiter.api.DisplayName;
5
+import org.junit.jupiter.api.Test;
6
+
7
+import java.math.BigDecimal;
8
+import java.math.RoundingMode;
9
+import java.time.LocalDateTime;
10
+import java.time.YearMonth;
11
+import java.time.format.DateTimeFormatter;
12
+import java.util.*;
13
+
14
+import static org.junit.jupiter.api.Assertions.*;
15
+
16
+/**
17
+ * 远传集抄模块单元测试
18
+ * 测试实体逻辑、异常检测规则、数据质量评分计算、大表报警阈值等纯逻辑
19
+ */
20
+class MeterReadingServiceTest {
21
+
22
+    // ==================== 实体字段与默认值测试 ====================
23
+
24
+    @Test
25
+    @DisplayName("测试MeterReadingTask实体 - 默认值和字段完整性")
26
+    void testMeterReadingTask_defaults() {
27
+        MeterReadingTask task = new MeterReadingTask();
28
+        task.setTaskNo("MRT-20260614120000-ABC123");
29
+        task.setTaskName("城南片区6月批量抄表");
30
+        task.setTaskType("batch_read");
31
+        task.setArea("城南");
32
+        task.setMeterCount(0);
33
+        task.setSuccessCount(0);
34
+        task.setFailCount(0);
35
+        task.setAbnormalCount(0);
36
+        task.setProgress(0);
37
+        task.setStatus("pending");
38
+
39
+        assertEquals("MRT-20260614120000-ABC123", task.getTaskNo());
40
+        assertEquals("batch_read", task.getTaskType());
41
+        assertEquals(0, task.getProgress());
42
+        assertEquals("pending", task.getStatus());
43
+    }
44
+
45
+    @Test
46
+    @DisplayName("测试任务编号格式 - MRT-前缀+时间戳+随机串")
47
+    void testTaskNoFormat() {
48
+        String taskNo = "MRT-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
49
+                + "-" + UUID.randomUUID().toString().substring(0, 6).toUpperCase();
50
+
51
+        assertTrue(taskNo.startsWith("MRT-"), "任务编号应以MRT-开头");
52
+        assertTrue(taskNo.length() > 20, "任务编号长度应大于20");
53
+        // 验证中间部分是数字(时间戳)
54
+        String[] parts = taskNo.split("-");
55
+        assertEquals(3, parts.length, "任务编号应包含3段");
56
+        assertTrue(parts[1].matches("\\d{14}"), "第二段应为14位时间戳");
57
+    }
58
+
59
+    // ==================== 异常检测逻辑测试 ====================
60
+
61
+    @Test
62
+    @DisplayName("测试异常检测 - 反向流量")
63
+    void testAbnormalDetection_reverseFlow() {
64
+        BigDecimal consumption = BigDecimal.valueOf(-5.0);
65
+        BigDecimal prev = BigDecimal.valueOf(100);
66
+        int signal = 80;
67
+        int battery = 90;
68
+
69
+        String result = detectAbnormal(consumption, signal, battery, prev);
70
+        assertEquals("reverse_flow", result, "负用水量应检测为反向流量");
71
+    }
72
+
73
+    @Test
74
+    @DisplayName("测试异常检测 - 低电量")
75
+    void testAbnormalDetection_lowBattery() {
76
+        BigDecimal consumption = BigDecimal.valueOf(10);
77
+        BigDecimal prev = BigDecimal.valueOf(100);
78
+        int signal = 80;
79
+        int battery = 15;
80
+
81
+        String result = detectAbnormal(consumption, signal, battery, prev);
82
+        assertEquals("low_battery", result, "电池<20%应报低电量");
83
+    }
84
+
85
+    @Test
86
+    @DisplayName("测试异常检测 - 信号弱")
87
+    void testAbnormalDetection_signalLost() {
88
+        BigDecimal consumption = BigDecimal.valueOf(10);
89
+        BigDecimal prev = BigDecimal.valueOf(100);
90
+        int signal = 20;
91
+        int battery = 90;
92
+
93
+        String result = detectAbnormal(consumption, signal, battery, prev);
94
+        assertEquals("signal_lost", result, "信号<30应报信号丢失");
95
+    }
96
+
97
+    @Test
98
+    @DisplayName("测试异常检测 - 超量程(用水量突增3倍以上)")
99
+    void testAbnormalDetection_overRange() {
100
+        BigDecimal prev = BigDecimal.valueOf(100);
101
+        BigDecimal consumption = prev.multiply(BigDecimal.valueOf(4)); // 4倍
102
+        int signal = 80;
103
+        int battery = 90;
104
+
105
+        String result = detectAbnormal(consumption, signal, battery, prev);
106
+        assertEquals("over_range", result, "用水量超3倍应报超量程");
107
+    }
108
+
109
+    @Test
110
+    @DisplayName("测试异常检测 - 卡表(读数为零但之前有读数)")
111
+    void testAbnormalDetection_stuck() {
112
+        BigDecimal consumption = BigDecimal.ZERO;
113
+        BigDecimal prev = BigDecimal.valueOf(100);
114
+        int signal = 80;
115
+        int battery = 90;
116
+
117
+        String result = detectAbnormal(consumption, signal, battery, prev);
118
+        assertEquals("stuck", result, "零用水量但有历史读数应报卡表");
119
+    }
120
+
121
+    @Test
122
+    @DisplayName("测试异常检测 - 正常情况无异常")
123
+    void testAbnormalDetection_normal() {
124
+        BigDecimal consumption = BigDecimal.valueOf(15);
125
+        BigDecimal prev = BigDecimal.valueOf(100);
126
+        int signal = 85;
127
+        int battery = 75;
128
+
129
+        String result = detectAbnormal(consumption, signal, battery, prev);
130
+        assertNull(result, "正常数据不应触发异常");
131
+    }
132
+
133
+    /** 从MeterReadingService复制的异常检测逻辑(纯函数,可直接测试) */
134
+    private String detectAbnormal(BigDecimal consumption, int signal, int battery, BigDecimal prev) {
135
+        if (battery < 20) return "low_battery";
136
+        if (signal < 30) return "signal_lost";
137
+        if (consumption.compareTo(BigDecimal.ZERO) < 0) return "reverse_flow";
138
+        if (consumption.compareTo(prev.multiply(BigDecimal.valueOf(3))) > 0 && prev.compareTo(BigDecimal.ZERO) > 0)
139
+            return "over_range";
140
+        if (consumption.compareTo(BigDecimal.ZERO) == 0 && prev.compareTo(BigDecimal.ZERO) > 0)
141
+            return "stuck";
142
+        return null;
143
+    }
144
+
145
+    // ==================== 异常描述完整性测试 ====================
146
+
147
+    @Test
148
+    @DisplayName("测试异常类型描述覆盖所有类型")
149
+    void testAbnormalDescCompleteness() {
150
+        String[] types = {"reverse_flow", "over_range", "stuck", "low_battery", "signal_lost"};
151
+        for (String type : types) {
152
+            String desc = getAbnormalDesc(type);
153
+            assertNotNull(desc, "异常类型 " + type + " 应有描述");
154
+            assertFalse(desc.isEmpty(), "异常类型 " + type + " 描述不应为空");
155
+            assertNotEquals("未知异常", desc, "已知类型不应返回'未知异常'");
156
+        }
157
+    }
158
+
159
+    @Test
160
+    @DisplayName("测试未知异常类型返回默认描述")
161
+    void testAbnormalDesc_unknown() {
162
+        assertEquals("未知异常", getAbnormalDesc("some_new_type"));
163
+    }
164
+
165
+    private String getAbnormalDesc(String abnormalType) {
166
+        return switch (abnormalType) {
167
+            case "reverse_flow" -> "反向流量,可能管道接反";
168
+            case "over_range" -> "用水量超出正常范围3倍以上";
169
+            case "stuck" -> "水表读数无变化,疑似卡表";
170
+            case "low_battery" -> "设备电池电量过低";
171
+            case "signal_lost" -> "信号强度过低,数据可能不准确";
172
+            default -> "未知异常";
173
+        };
174
+    }
175
+
176
+    // ==================== 数据质量评分计算测试 ====================
177
+
178
+    @Test
179
+    @DisplayName("测试质量评分计算 - 全量成功应为满分")
180
+    void testQualityScore_perfect() {
181
+        BigDecimal successRate = BigDecimal.valueOf(100);
182
+        BigDecimal readRate = BigDecimal.valueOf(100);
183
+        BigDecimal abnormalRate = BigDecimal.ZERO;
184
+
185
+        BigDecimal score = calculateScore(successRate, readRate, abnormalRate);
186
+        // 100*0.4 + 100*0.3 + (100-0)*0.3 = 40+30+30 = 100
187
+        assertEquals(0, score.compareTo(BigDecimal.valueOf(100)), "全量成功应为100分");
188
+    }
189
+
190
+    @Test
191
+    @DisplayName("测试质量评分计算 - 部分异常")
192
+    void testQualityScore_partialAbnormal() {
193
+        BigDecimal successRate = BigDecimal.valueOf(80);
194
+        BigDecimal readRate = BigDecimal.valueOf(95);
195
+        BigDecimal abnormalRate = BigDecimal.valueOf(5);
196
+
197
+        BigDecimal score = calculateScore(successRate, readRate, abnormalRate);
198
+        // 80*0.4 + 95*0.3 + 95*0.3 = 32+28.5+28.5 = 89
199
+        assertTrue(score.compareTo(BigDecimal.valueOf(85)) > 0, "评分应>85");
200
+        assertTrue(score.compareTo(BigDecimal.valueOf(92)) < 0, "评分应<92");
201
+    }
202
+
203
+    @Test
204
+    @DisplayName("测试质量等级划分 - excellent")
205
+    void testQualityLevel_excellent() {
206
+        assertEquals("excellent", getQualityLevel(BigDecimal.valueOf(95)));
207
+        assertEquals("excellent", getQualityLevel(BigDecimal.valueOf(90)));
208
+    }
209
+
210
+    @Test
211
+    @DisplayName("测试质量等级划分 - good/fair/poor")
212
+    void testQualityLevel_otherLevels() {
213
+        assertEquals("good", getQualityLevel(BigDecimal.valueOf(80)));
214
+        assertEquals("good", getQualityLevel(BigDecimal.valueOf(75)));
215
+        assertEquals("fair", getQualityLevel(BigDecimal.valueOf(65)));
216
+        assertEquals("fair", getQualityLevel(BigDecimal.valueOf(60)));
217
+        assertEquals("poor", getQualityLevel(BigDecimal.valueOf(50)));
218
+        assertEquals("poor", getQualityLevel(BigDecimal.valueOf(0)));
219
+    }
220
+
221
+    private BigDecimal calculateScore(BigDecimal successRate, BigDecimal readRate, BigDecimal abnormalRate) {
222
+        return successRate.multiply(BigDecimal.valueOf(0.4))
223
+            .add(readRate.multiply(BigDecimal.valueOf(0.3)))
224
+            .add(BigDecimal.valueOf(100).subtract(abnormalRate).multiply(BigDecimal.valueOf(0.3)))
225
+            .setScale(2, RoundingMode.HALF_UP);
226
+    }
227
+
228
+    private String getQualityLevel(BigDecimal score) {
229
+        if (score.compareTo(BigDecimal.valueOf(90)) >= 0) return "excellent";
230
+        if (score.compareTo(BigDecimal.valueOf(75)) >= 0) return "good";
231
+        if (score.compareTo(BigDecimal.valueOf(60)) >= 0) return "fair";
232
+        return "poor";
233
+    }
234
+
235
+    // ==================== 大表报警阈值测试 ====================
236
+
237
+    @Test
238
+    @DisplayName("测试大表报警 - 超流量")
239
+    void testLargeMeterAlarm_overFlow() {
240
+        BigDecimal flow = BigDecimal.valueOf(120);
241
+        BigDecimal pressure = BigDecimal.valueOf(0.35);
242
+        assertEquals("over_flow", detectLargeMeterAlarm(flow, pressure));
243
+    }
244
+
245
+    @Test
246
+    @DisplayName("测试大表报警 - 低压")
247
+    void testLargeMeterAlarm_pressureLow() {
248
+        BigDecimal flow = BigDecimal.valueOf(30);
249
+        BigDecimal pressure = BigDecimal.valueOf(0.10);
250
+        assertEquals("pressure_low", detectLargeMeterAlarm(flow, pressure));
251
+    }
252
+
253
+    @Test
254
+    @DisplayName("测试大表报警 - 高压")
255
+    void testLargeMeterAlarm_pressureHigh() {
256
+        BigDecimal flow = BigDecimal.valueOf(30);
257
+        BigDecimal pressure = BigDecimal.valueOf(0.7);
258
+        assertEquals("pressure_high", detectLargeMeterAlarm(flow, pressure));
259
+    }
260
+
261
+    @Test
262
+    @DisplayName("测试大表报警 - 正常无报警")
263
+    void testLargeMeterAlarm_normal() {
264
+        BigDecimal flow = BigDecimal.valueOf(25);
265
+        BigDecimal pressure = BigDecimal.valueOf(0.35);
266
+        assertNull(detectLargeMeterAlarm(flow, pressure));
267
+    }
268
+
269
+    private String detectLargeMeterAlarm(BigDecimal flow, BigDecimal pressure) {
270
+        if (flow.compareTo(BigDecimal.valueOf(100)) > 0) return "over_flow";
271
+        if (pressure.compareTo(BigDecimal.valueOf(0.15)) < 0) return "pressure_low";
272
+        if (pressure.compareTo(BigDecimal.valueOf(0.6)) > 0) return "pressure_high";
273
+        return null;
274
+    }
275
+
276
+    // ==================== 大表口径验证 ====================
277
+
278
+    @Test
279
+    @DisplayName("测试大表口径范围 - DN80至DN400")
280
+    void testLargeMeterCalibers() {
281
+        List<String> validCalibers = List.of("DN80", "DN100", "DN150", "DN200", "DN300", "DN400");
282
+        List<String> invalidCalibers = List.of("DN15", "DN20", "DN25", "DN40", "DN50", "DN65");
283
+
284
+        for (String c : validCalibers) {
285
+            assertTrue(isLargeMeter(c), c + " 应判定为大表");
286
+        }
287
+        for (String c : invalidCalibers) {
288
+            assertFalse(isLargeMeter(c), c + " 不应判定为大表");
289
+        }
290
+    }
291
+
292
+    private boolean isLargeMeter(String caliber) {
293
+        return Set.of("DN80", "DN100", "DN150", "DN200", "DN300", "DN400").contains(caliber);
294
+    }
295
+
296
+    // ==================== 抄表周期格式测试 ====================
297
+
298
+    @Test
299
+    @DisplayName("测试抄表周期格式 - yyyy-MM")
300
+    void testReadingPeriodFormat() {
301
+        String period = YearMonth.of(2026, 6).format(DateTimeFormatter.ofPattern("yyyy-MM"));
302
+        assertEquals("2026-06", period);
303
+
304
+        String period2 = YearMonth.of(2026, 12).format(DateTimeFormatter.ofPattern("yyyy-MM"));
305
+        assertEquals("2026-12", period2);
306
+    }
307
+
308
+    // ==================== MeterReading实体测试 ====================
309
+
310
+    @Test
311
+    @DisplayName("测试MeterReading实体 - 用水量计算")
312
+    void testMeterReading_consumption() {
313
+        MeterReading reading = new MeterReading();
314
+        reading.setPrevReading(BigDecimal.valueOf(100.50));
315
+        reading.setCurrReading(BigDecimal.valueOf(135.75));
316
+        reading.setConsumption(reading.getCurrReading().subtract(reading.getPrevReading()));
317
+
318
+        assertEquals(BigDecimal.valueOf(35.25), reading.getConsumption());
319
+    }
320
+
321
+    @Test
322
+    @DisplayName("测试MeterReading实体 - 审核状态")
323
+    void testMeterReading_verify() {
324
+        MeterReading reading = new MeterReading();
325
+        reading.setVerified(0);
326
+        assertEquals(0, reading.getVerified());
327
+
328
+        reading.setVerified(1);
329
+        reading.setVerifiedBy(1001L);
330
+        reading.setVerifiedAt(LocalDateTime.of(2026, 6, 14, 15, 0));
331
+
332
+        assertEquals(1, reading.getVerified());
333
+        assertEquals(1001L, reading.getVerifiedBy());
334
+        assertNotNull(reading.getVerifiedAt());
335
+    }
336
+}