Преглед изворни кода

feat(wm-production): #66 水质检测台账管理

- 水质检测记录 CRUD(浊度/pH/余氯/色度/嗅味/大肠杆菌等)
- 根据 GB5749-2022 国标自动合格判定,支持自定义标准
- 检测计划管理(日检/周检/月检),自动计算下次检测日期
- 多维度台账查询(时间/区域/检测类型/合格状态)
- 统计分析(合格率趋势/各指标分布/不合格项追踪)
- Excel 导出检测报告
- 3个 Entity + 2个 DTO + 3个 Mapper(含XML) + 3个 Service + 1个 Controller(26端点)
- DDL 含3张表 + 索引 + GB5749-2022 默认标准数据
- 18个单元测试
bot_dev2 пре 5 дана
родитељ
комит
91740fb44c

+ 2
- 0
wm-production/pom.xml Прегледај датотеку

@@ -12,5 +12,7 @@
12 12
         <dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
13 13
         <dependency><groupId>cn.dev33</groupId><artifactId>sa-token-spring-boot3-starter</artifactId></dependency>
14 14
         <dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId></dependency>
15
+        <dependency><groupId>com.alibaba</groupId><artifactId>easyexcel</artifactId></dependency>
16
+        <dependency><groupId>com.github.xiaoymin</groupId><artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId></dependency>
15 17
     </dependencies>
16 18
 </project>

+ 214
- 0
wm-production/src/main/java/com/water/production/controller/QualityLedgerController.java Прегледај датотеку

@@ -0,0 +1,214 @@
1
+package com.water.production.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.common.core.result.R;
5
+import com.water.production.dto.QualityQueryRequest;
6
+import com.water.production.dto.QualityStatVO;
7
+import com.water.production.entity.QualityStandard;
8
+import com.water.production.entity.QualityTestPlan;
9
+import com.water.production.entity.QualityTestRecord;
10
+import com.water.production.service.QualityLedgerService;
11
+import com.water.production.service.QualityStandardService;
12
+import com.water.production.service.QualityTestPlanService;
13
+import io.swagger.v3.oas.annotations.Operation;
14
+import io.swagger.v3.oas.annotations.tags.Tag;
15
+import jakarta.servlet.http.HttpServletResponse;
16
+import lombok.RequiredArgsConstructor;
17
+import org.springframework.web.bind.annotation.*;
18
+
19
+import java.io.IOException;
20
+import java.util.List;
21
+import java.util.Map;
22
+
23
+@Tag(name = "水质检测台账管理")
24
+@RestController
25
+@RequestMapping("/api/production/quality")
26
+@RequiredArgsConstructor
27
+public class QualityLedgerController {
28
+
29
+    private final QualityLedgerService ledgerService;
30
+    private final QualityStandardService standardService;
31
+    private final QualityTestPlanService planService;
32
+
33
+    // ==================== 记录 CRUD ====================
34
+
35
+    @Operation(summary = "查询检测记录(分页)")
36
+    @GetMapping("/records")
37
+    public R<Map<String, Object>> queryRecords(QualityQueryRequest request) {
38
+        return R.ok(ledgerService.queryRecords(request));
39
+    }
40
+
41
+    @Operation(summary = "获取检测记录详情")
42
+    @GetMapping("/records/{id}")
43
+    public R<QualityTestRecord> getRecord(@PathVariable Long id) {
44
+        return R.ok(ledgerService.getById(id));
45
+    }
46
+
47
+    @Operation(summary = "创建检测记录")
48
+    @PostMapping("/records")
49
+    public R<QualityTestRecord> createRecord(@RequestBody QualityTestRecord record) {
50
+        return R.ok(ledgerService.create(record));
51
+    }
52
+
53
+    @Operation(summary = "更新检测记录")
54
+    @PutMapping("/records/{id}")
55
+    public R<QualityTestRecord> updateRecord(@PathVariable Long id, @RequestBody QualityTestRecord record) {
56
+        return R.ok(ledgerService.update(id, record));
57
+    }
58
+
59
+    @Operation(summary = "删除检测记录")
60
+    @DeleteMapping("/records/{id}")
61
+    public R<Void> deleteRecord(@PathVariable Long id) {
62
+        ledgerService.delete(id);
63
+        return R.ok();
64
+    }
65
+
66
+    @Operation(summary = "批量删除检测记录")
67
+    @DeleteMapping("/records/batch")
68
+    public R<Void> batchDeleteRecords(@RequestBody List<Long> ids) {
69
+        ledgerService.batchDelete(ids);
70
+        return R.ok();
71
+    }
72
+
73
+    @Operation(summary = "重新判定所有记录")
74
+    @PostMapping("/records/reevaluate")
75
+    public R<Map<String, Object>> reevaluateAll() {
76
+        int count = ledgerService.reevaluateAll();
77
+        return R.ok(Map.of("updatedCount", count));
78
+    }
79
+
80
+    // ==================== 辅助 ====================
81
+
82
+    @Operation(summary = "获取区域列表")
83
+    @GetMapping("/areas")
84
+    public R<List<String>> getAreas() {
85
+        return R.ok(ledgerService.getAreaList());
86
+    }
87
+
88
+    @Operation(summary = "获取采样点列表")
89
+    @GetMapping("/sampling-points")
90
+    public R<List<String>> getSamplingPoints() {
91
+        return R.ok(ledgerService.getSamplingPointList());
92
+    }
93
+
94
+    // ==================== 标准 ====================
95
+
96
+    @Operation(summary = "获取启用的标准列表")
97
+    @GetMapping("/standards")
98
+    public R<List<QualityStandard>> listStandards() {
99
+        return R.ok(standardService.listEnabled());
100
+    }
101
+
102
+    @Operation(summary = "获取全部标准")
103
+    @GetMapping("/standards/all")
104
+    public R<List<QualityStandard>> listAllStandards() {
105
+        return R.ok(standardService.listAll());
106
+    }
107
+
108
+    @Operation(summary = "按水质类型获取标准")
109
+    @GetMapping("/standards/water-type/{waterType}")
110
+    public R<List<QualityStandard>> listStandardsByWaterType(@PathVariable String waterType) {
111
+        return R.ok(standardService.listByWaterType(waterType));
112
+    }
113
+
114
+    @Operation(summary = "获取标准详情")
115
+    @GetMapping("/standards/{id}")
116
+    public R<QualityStandard> getStandard(@PathVariable Long id) {
117
+        return R.ok(standardService.getById(id));
118
+    }
119
+
120
+    @Operation(summary = "创建标准")
121
+    @PostMapping("/standards")
122
+    public R<QualityStandard> createStandard(@RequestBody QualityStandard standard) {
123
+        return R.ok(standardService.create(standard));
124
+    }
125
+
126
+    @Operation(summary = "更新标准")
127
+    @PutMapping("/standards/{id}")
128
+    public R<QualityStandard> updateStandard(@PathVariable Long id, @RequestBody QualityStandard standard) {
129
+        return R.ok(standardService.update(id, standard));
130
+    }
131
+
132
+    @Operation(summary = "删除标准")
133
+    @DeleteMapping("/standards/{id}")
134
+    public R<Void> deleteStandard(@PathVariable Long id) {
135
+        standardService.delete(id);
136
+        return R.ok();
137
+    }
138
+
139
+    // ==================== 计划 ====================
140
+
141
+    @Operation(summary = "查询检测计划(分页)")
142
+    @GetMapping("/plans")
143
+    public R<Page<QualityTestPlan>> queryPlans(
144
+            @RequestParam(defaultValue = "1") int pageNum,
145
+            @RequestParam(defaultValue = "10") int pageSize,
146
+            @RequestParam(required = false) String testType,
147
+            @RequestParam(required = false) String waterType,
148
+            @RequestParam(required = false) String area,
149
+            @RequestParam(required = false) String status,
150
+            @RequestParam(required = false) String keyword) {
151
+        return R.ok(planService.queryPlans(pageNum, pageSize, testType, waterType, area, status, keyword));
152
+    }
153
+
154
+    @Operation(summary = "获取计划详情")
155
+    @GetMapping("/plans/{id}")
156
+    public R<QualityTestPlan> getPlan(@PathVariable Long id) {
157
+        return R.ok(planService.getById(id));
158
+    }
159
+
160
+    @Operation(summary = "创建计划")
161
+    @PostMapping("/plans")
162
+    public R<QualityTestPlan> createPlan(@RequestBody QualityTestPlan plan) {
163
+        return R.ok(planService.create(plan));
164
+    }
165
+
166
+    @Operation(summary = "更新计划")
167
+    @PutMapping("/plans/{id}")
168
+    public R<QualityTestPlan> updatePlan(@PathVariable Long id, @RequestBody QualityTestPlan plan) {
169
+        return R.ok(planService.update(id, plan));
170
+    }
171
+
172
+    @Operation(summary = "删除计划")
173
+    @DeleteMapping("/plans/{id}")
174
+    public R<Void> deletePlan(@PathVariable Long id) {
175
+        planService.delete(id);
176
+        return R.ok();
177
+    }
178
+
179
+    @Operation(summary = "切换计划状态")
180
+    @PutMapping("/plans/{id}/status")
181
+    public R<QualityTestPlan> togglePlanStatus(@PathVariable Long id) {
182
+        return R.ok(planService.toggleStatus(id));
183
+    }
184
+
185
+    @Operation(summary = "获取到期计划")
186
+    @GetMapping("/plans/due")
187
+    public R<List<QualityTestPlan>> getDuePlans() {
188
+        return R.ok(planService.getDuePlans());
189
+    }
190
+
191
+    @Operation(summary = "标记计划已执行")
192
+    @PostMapping("/plans/{id}/execute")
193
+    public R<QualityTestPlan> markPlanExecuted(@PathVariable Long id) {
194
+        return R.ok(planService.markExecuted(id));
195
+    }
196
+
197
+    // ==================== 统计 ====================
198
+
199
+    @Operation(summary = "获取统计数据")
200
+    @GetMapping("/statistics")
201
+    public R<QualityStatVO> getStatistics(
202
+            @RequestParam(required = false) String startDate,
203
+            @RequestParam(required = false) String endDate) {
204
+        return R.ok(ledgerService.getStatistics(startDate, endDate));
205
+    }
206
+
207
+    // ==================== 导出 ====================
208
+
209
+    @Operation(summary = "导出Excel")
210
+    @PostMapping("/export/excel")
211
+    public void exportExcel(QualityQueryRequest request, HttpServletResponse response) throws IOException {
212
+        ledgerService.exportExcel(request, response);
213
+    }
214
+}

+ 49
- 0
wm-production/src/main/java/com/water/production/dto/QualityQueryRequest.java Прегледај датотеку

@@ -0,0 +1,49 @@
1
+package com.water.production.dto;
2
+
3
+import lombok.Data;
4
+
5
+/**
6
+ * 水质检测查询请求
7
+ */
8
+@Data
9
+public class QualityQueryRequest {
10
+
11
+    /** 检测类型 */
12
+    private String testType;
13
+
14
+    /** 水质类型 */
15
+    private String waterType;
16
+
17
+    /** 区域 */
18
+    private String area;
19
+
20
+    /** 采样点 */
21
+    private String samplingPoint;
22
+
23
+    /** 检测人 */
24
+    private String tester;
25
+
26
+    /** 合格状态 */
27
+    private String complianceStatus;
28
+
29
+    /** 开始日期 */
30
+    private String startDate;
31
+
32
+    /** 结束日期 */
33
+    private String endDate;
34
+
35
+    /** 关键字 */
36
+    private String keyword;
37
+
38
+    /** 排序字段 */
39
+    private String sortField;
40
+
41
+    /** 排序方向 */
42
+    private String sortOrder;
43
+
44
+    /** 页码 */
45
+    private Integer pageNum = 1;
46
+
47
+    /** 每页大小 */
48
+    private Integer pageSize = 10;
49
+}

+ 52
- 0
wm-production/src/main/java/com/water/production/dto/QualityStatVO.java Прегледај датотеку

@@ -0,0 +1,52 @@
1
+package com.water.production.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.util.List;
6
+import java.util.Map;
7
+
8
+/**
9
+ * 水质检测统计VO
10
+ */
11
+@Data
12
+public class QualityStatVO {
13
+
14
+    /** 总记录数 */
15
+    private Long totalRecords;
16
+
17
+    /** 合格数 */
18
+    private Long qualifiedCount;
19
+
20
+    /** 不合格数 */
21
+    private Long unqualifiedCount;
22
+
23
+    /** 待判定数 */
24
+    private Long pendingCount;
25
+
26
+    /** 合格率 */
27
+    private Double qualifiedRate;
28
+
29
+    /** 按水质类型统计 */
30
+    private List<Map<String, Object>> byWaterType;
31
+
32
+    /** 按区域统计 */
33
+    private List<Map<String, Object>> byArea;
34
+
35
+    /** 按检测类型统计 */
36
+    private List<Map<String, Object>> byTestType;
37
+
38
+    /** 按日期趋势 */
39
+    private List<Map<String, Object>> trendByDate;
40
+
41
+    /** 不合格项目统计 */
42
+    private List<Map<String, Object>> unqualifiedItems;
43
+
44
+    /** 平均浊度 */
45
+    private Double avgTurbidity;
46
+
47
+    /** 平均pH */
48
+    private Double avgPh;
49
+
50
+    /** 平均余氯 */
51
+    private Double avgResidualChlorine;
52
+}

+ 53
- 0
wm-production/src/main/java/com/water/production/entity/QualityStandard.java Прегледај датотеку

@@ -0,0 +1,53 @@
1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.time.LocalDateTime;
7
+
8
+/**
9
+ * 水质标准
10
+ */
11
+@Data
12
+@TableName("prod_quality_standard")
13
+public class QualityStandard {
14
+
15
+    @TableId(type = IdType.AUTO)
16
+    private Long id;
17
+
18
+    /** 标准名称 */
19
+    private String standardName;
20
+
21
+    /** 标准编号 */
22
+    private String standardCode;
23
+
24
+    /** 参数名 */
25
+    private String paramName;
26
+
27
+    /** 参数标签 */
28
+    private String paramLabel;
29
+
30
+    /** 参数单位 */
31
+    private String paramUnit;
32
+
33
+    /** 最小值 */
34
+    private Double minValue;
35
+
36
+    /** 最大值 */
37
+    private Double maxValue;
38
+
39
+    /** 水质类型 */
40
+    private String waterType;
41
+
42
+    /** 是否启用 */
43
+    private Boolean enabled;
44
+
45
+    @TableLogic
46
+    private Integer deleted;
47
+
48
+    @TableField(fill = FieldFill.INSERT)
49
+    private LocalDateTime createdAt;
50
+
51
+    @TableField(fill = FieldFill.INSERT_UPDATE)
52
+    private LocalDateTime updatedAt;
53
+}

+ 66
- 0
wm-production/src/main/java/com/water/production/entity/QualityTestPlan.java Прегледај датотеку

@@ -0,0 +1,66 @@
1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.time.LocalDate;
7
+import java.time.LocalDateTime;
8
+
9
+/**
10
+ * 水质检测计划
11
+ */
12
+@Data
13
+@TableName("prod_quality_test_plan")
14
+public class QualityTestPlan {
15
+
16
+    @TableId(type = IdType.AUTO)
17
+    private Long id;
18
+
19
+    /** 计划名称 */
20
+    private String planName;
21
+
22
+    /** 检测类型 */
23
+    private String testType;
24
+
25
+    /** 水质类型 */
26
+    private String waterType;
27
+
28
+    /** 采样点 */
29
+    private String samplingPoint;
30
+
31
+    /** 区域 */
32
+    private String area;
33
+
34
+    /** 频率: daily/weekly/monthly */
35
+    private String frequency;
36
+
37
+    /** 检测参数 (JSON数组) */
38
+    private String testParams;
39
+
40
+    /** 开始日期 */
41
+    private LocalDate startDate;
42
+
43
+    /** 结束日期 */
44
+    private LocalDate endDate;
45
+
46
+    /** 下次检测日期 */
47
+    private LocalDate nextTestDate;
48
+
49
+    /** 状态: active/paused/expired */
50
+    private String status;
51
+
52
+    /** 执行次数 */
53
+    private Integer executionCount;
54
+
55
+    /** 备注 */
56
+    private String remark;
57
+
58
+    @TableLogic
59
+    private Integer deleted;
60
+
61
+    @TableField(fill = FieldFill.INSERT)
62
+    private LocalDateTime createdAt;
63
+
64
+    @TableField(fill = FieldFill.INSERT_UPDATE)
65
+    private LocalDateTime updatedAt;
66
+}

+ 79
- 0
wm-production/src/main/java/com/water/production/entity/QualityTestRecord.java Прегледај датотеку

@@ -0,0 +1,79 @@
1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.time.LocalDate;
7
+import java.time.LocalDateTime;
8
+import java.time.LocalTime;
9
+
10
+/**
11
+ * 水质检测记录
12
+ */
13
+@Data
14
+@TableName("prod_quality_test_record")
15
+public class QualityTestRecord {
16
+
17
+    @TableId(type = IdType.AUTO)
18
+    private Long id;
19
+
20
+    /** 检测类型: routine/emergency/special */
21
+    private String testType;
22
+
23
+    /** 水质类型: rawWater/factoryWater/pipeNetworkWater/endUserWater */
24
+    private String waterType;
25
+
26
+    /** 采样点 */
27
+    private String samplingPoint;
28
+
29
+    /** 所属区域 */
30
+    private String area;
31
+
32
+    /** 检测日期 */
33
+    private LocalDate testDate;
34
+
35
+    /** 检测时间 */
36
+    private LocalTime testTime;
37
+
38
+    /** 检测人 */
39
+    private String tester;
40
+
41
+    /** 浊度 (NTU) */
42
+    private Double turbidity;
43
+
44
+    /** pH值 */
45
+    private Double ph;
46
+
47
+    /** 余氯 (mg/L) */
48
+    private Double residualChlorine;
49
+
50
+    /** 色度 (度) */
51
+    private Double color;
52
+
53
+    /** 嗅味 */
54
+    private String odor;
55
+
56
+    /** 大肠杆菌 (CFU/100mL) */
57
+    private Double ecoli;
58
+
59
+    /** 菌落总数 (CFU/mL) */
60
+    private Double colonyCount;
61
+
62
+    /** 合格状态: qualified/unqualified/pending */
63
+    private String complianceStatus;
64
+
65
+    /** 不合格项目 (JSON) */
66
+    private String unqualifiedItems;
67
+
68
+    /** 备注 */
69
+    private String remark;
70
+
71
+    @TableLogic
72
+    private Integer deleted;
73
+
74
+    @TableField(fill = FieldFill.INSERT)
75
+    private LocalDateTime createdAt;
76
+
77
+    @TableField(fill = FieldFill.INSERT_UPDATE)
78
+    private LocalDateTime updatedAt;
79
+}

+ 9
- 0
wm-production/src/main/java/com/water/production/mapper/QualityStandardMapper.java Прегледај датотеку

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

+ 9
- 0
wm-production/src/main/java/com/water/production/mapper/QualityTestPlanMapper.java Прегледај датотеку

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

+ 68
- 0
wm-production/src/main/java/com/water/production/mapper/QualityTestRecordMapper.java Прегледај датотеку

@@ -0,0 +1,68 @@
1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.QualityTestRecord;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+
8
+import java.util.List;
9
+import java.util.Map;
10
+
11
+@Mapper
12
+public interface QualityTestRecordMapper extends BaseMapper<QualityTestRecord> {
13
+
14
+    List<Map<String, Object>> selectRecordPage(
15
+        @Param("testType") String testType,
16
+        @Param("waterType") String waterType,
17
+        @Param("area") String area,
18
+        @Param("samplingPoint") String samplingPoint,
19
+        @Param("tester") String tester,
20
+        @Param("complianceStatus") String complianceStatus,
21
+        @Param("startDate") String startDate,
22
+        @Param("endDate") String endDate,
23
+        @Param("keyword") String keyword,
24
+        @Param("sortField") String sortField,
25
+        @Param("sortOrder") String sortOrder,
26
+        @Param("offset") int offset,
27
+        @Param("limit") int limit
28
+    );
29
+
30
+    Long countRecords(
31
+        @Param("testType") String testType,
32
+        @Param("waterType") String waterType,
33
+        @Param("area") String area,
34
+        @Param("samplingPoint") String samplingPoint,
35
+        @Param("tester") String tester,
36
+        @Param("complianceStatus") String complianceStatus,
37
+        @Param("startDate") String startDate,
38
+        @Param("endDate") String endDate,
39
+        @Param("keyword") String keyword
40
+    );
41
+
42
+    List<Map<String, Object>> statByComplianceStatus(
43
+        @Param("startDate") String startDate,
44
+        @Param("endDate") String endDate
45
+    );
46
+
47
+    List<Map<String, Object>> statRateByWaterType(
48
+        @Param("startDate") String startDate,
49
+        @Param("endDate") String endDate
50
+    );
51
+
52
+    List<Map<String, Object>> statRateByArea(
53
+        @Param("startDate") String startDate,
54
+        @Param("endDate") String endDate
55
+    );
56
+
57
+    List<Map<String, Object>> statUnqualifiedByParam(
58
+        @Param("startDate") String startDate,
59
+        @Param("endDate") String endDate
60
+    );
61
+
62
+    List<Map<String, Object>> statMonthlyTrend(
63
+        @Param("startDate") String startDate,
64
+        @Param("endDate") String endDate
65
+    );
66
+
67
+    Map<String, Object> statParamAvg();
68
+}

+ 311
- 0
wm-production/src/main/java/com/water/production/service/QualityLedgerService.java Прегледај датотеку

@@ -0,0 +1,311 @@
1
+package com.water.production.service;
2
+
3
+import com.alibaba.excel.EasyExcel;
4
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
5
+import com.water.common.core.exception.BusinessException;
6
+import com.water.production.dto.QualityQueryRequest;
7
+import com.water.production.dto.QualityStatVO;
8
+import com.water.production.entity.QualityStandard;
9
+import com.water.production.entity.QualityTestRecord;
10
+import com.water.production.mapper.QualityTestRecordMapper;
11
+import jakarta.servlet.http.HttpServletResponse;
12
+import lombok.RequiredArgsConstructor;
13
+import lombok.extern.slf4j.Slf4j;
14
+import org.springframework.stereotype.Service;
15
+import org.springframework.transaction.annotation.Transactional;
16
+
17
+import java.io.IOException;
18
+import java.net.URLEncoder;
19
+import java.nio.charset.StandardCharsets;
20
+import java.time.LocalDate;
21
+import java.util.*;
22
+import java.util.stream.Collectors;
23
+
24
+@Slf4j
25
+@Service
26
+@RequiredArgsConstructor
27
+public class QualityLedgerService {
28
+
29
+    private final QualityTestRecordMapper recordMapper;
30
+    private final QualityStandardService standardService;
31
+
32
+    /**
33
+     * 分页查询检测记录
34
+     */
35
+    public Map<String, Object> queryRecords(QualityQueryRequest request) {
36
+        int offset = (request.getPageNum() - 1) * request.getPageSize();
37
+        List<Map<String, Object>> records = recordMapper.selectRecordPage(
38
+                request.getTestType(), request.getWaterType(), request.getArea(),
39
+                request.getSamplingPoint(), request.getTester(), request.getComplianceStatus(),
40
+                request.getStartDate(), request.getEndDate(), request.getKeyword(),
41
+                request.getSortField(), request.getSortOrder(), offset, request.getPageSize()
42
+        );
43
+        Long total = recordMapper.countRecords(
44
+                request.getTestType(), request.getWaterType(), request.getArea(),
45
+                request.getSamplingPoint(), request.getTester(), request.getComplianceStatus(),
46
+                request.getStartDate(), request.getEndDate(), request.getKeyword()
47
+        );
48
+
49
+        Map<String, Object> result = new LinkedHashMap<>();
50
+        result.put("records", records);
51
+        result.put("total", total);
52
+        result.put("pageNum", request.getPageNum());
53
+        result.put("pageSize", request.getPageSize());
54
+        result.put("totalPages", (total + request.getPageSize() - 1) / request.getPageSize());
55
+        return result;
56
+    }
57
+
58
+    /**
59
+     * 获取记录详情
60
+     */
61
+    public QualityTestRecord getById(Long id) {
62
+        QualityTestRecord record = recordMapper.selectById(id);
63
+        if (record == null) {
64
+            throw new BusinessException("检测记录不存在");
65
+        }
66
+        return record;
67
+    }
68
+
69
+    /**
70
+     * 创建检测记录(自动合格判定)
71
+     */
72
+    @Transactional
73
+    public QualityTestRecord create(QualityTestRecord record) {
74
+        if (record.getTestDate() == null) record.setTestDate(LocalDate.now());
75
+        evaluateCompliance(record);
76
+        recordMapper.insert(record);
77
+        log.info("创建水质检测记录: {}/{}", record.getWaterType(), record.getSamplingPoint());
78
+        return record;
79
+    }
80
+
81
+    /**
82
+     * 更新检测记录
83
+     */
84
+    @Transactional
85
+    public QualityTestRecord update(Long id, QualityTestRecord record) {
86
+        QualityTestRecord existing = getById(id);
87
+        record.setId(existing.getId());
88
+        evaluateCompliance(record);
89
+        recordMapper.updateById(record);
90
+        log.info("更新水质检测记录: {}", id);
91
+        return recordMapper.selectById(id);
92
+    }
93
+
94
+    /**
95
+     * 删除检测记录
96
+     */
97
+    @Transactional
98
+    public void delete(Long id) {
99
+        getById(id);
100
+        recordMapper.deleteById(id);
101
+        log.info("删除水质检测记录: {}", id);
102
+    }
103
+
104
+    /**
105
+     * 批量删除
106
+     */
107
+    @Transactional
108
+    public void batchDelete(List<Long> ids) {
109
+        if (ids == null || ids.isEmpty()) return;
110
+        recordMapper.deleteBatchIds(ids);
111
+        log.info("批量删除水质检测记录: {} 条", ids.size());
112
+    }
113
+
114
+    /**
115
+     * 对记录执行合格判定
116
+     */
117
+    public void evaluateCompliance(QualityTestRecord record) {
118
+        if (record.getWaterType() == null) {
119
+            record.setComplianceStatus("pending");
120
+            return;
121
+        }
122
+
123
+        List<String> unqualified = new ArrayList<>();
124
+
125
+        checkParam(record.getWaterType(), "turbidity", record.getTurbidity(), "浊度", unqualified);
126
+        checkParam(record.getWaterType(), "ph", record.getPh(), "pH", unqualified);
127
+        checkParam(record.getWaterType(), "residualChlorine", record.getResidualChlorine(), "余氯", unqualified);
128
+        checkParam(record.getWaterType(), "color", record.getColor(), "色度", unqualified);
129
+        checkParam(record.getWaterType(), "ecoli", record.getEcoli(), "大肠杆菌", unqualified);
130
+        checkParam(record.getWaterType(), "colonyCount", record.getColonyCount(), "菌落总数", unqualified);
131
+
132
+        if (unqualified.isEmpty()) {
133
+            record.setComplianceStatus("qualified");
134
+            record.setUnqualifiedItems(null);
135
+        } else {
136
+            record.setComplianceStatus("unqualified");
137
+            record.setUnqualifiedItems("[\"" + String.join("\",\"", unqualified) + "\"]");
138
+        }
139
+    }
140
+
141
+    private void checkParam(String waterType, String paramName, Double value, String label,
142
+                             List<String> unqualified) {
143
+        if (value == null) return;
144
+        QualityStandard standard = standardService.getStandard(waterType, paramName);
145
+        if (standard == null) return;
146
+        if (standard.getMinValue() != null && value < standard.getMinValue()) {
147
+            unqualified.add(label + "(偏低)");
148
+        }
149
+        if (standard.getMaxValue() != null && value > standard.getMaxValue()) {
150
+            unqualified.add(label + "(偏高)");
151
+        }
152
+    }
153
+
154
+    /**
155
+     * 重新判定所有记录
156
+     */
157
+    @Transactional
158
+    public int reevaluateAll() {
159
+        LambdaQueryWrapper<QualityTestRecord> wrapper = new LambdaQueryWrapper<>();
160
+        wrapper.in(QualityTestRecord::getComplianceStatus, "pending", "qualified", "unqualified");
161
+        List<QualityTestRecord> records = recordMapper.selectList(wrapper);
162
+        int count = 0;
163
+        for (QualityTestRecord record : records) {
164
+            String oldStatus = record.getComplianceStatus();
165
+            evaluateCompliance(record);
166
+            if (!oldStatus.equals(record.getComplianceStatus())) {
167
+                recordMapper.updateById(record);
168
+                count++;
169
+            }
170
+        }
171
+        log.info("重新判定完成,共 {} 条记录状态变更", count);
172
+        return count;
173
+    }
174
+
175
+    /**
176
+     * 获取统计数据
177
+     */
178
+    public QualityStatVO getStatistics(String startDate, String endDate) {
179
+        QualityStatVO vo = new QualityStatVO();
180
+
181
+        // 按合格状态统计
182
+        List<Map<String, Object>> statusStats = recordMapper.statByComplianceStatus(startDate, endDate);
183
+        long total = 0, qualified = 0, unqualified = 0, pending = 0;
184
+        for (Map<String, Object> row : statusStats) {
185
+            String status = String.valueOf(row.get("status"));
186
+            long cnt = ((Number) row.get("count")).longValue();
187
+            total += cnt;
188
+            switch (status) {
189
+                case "qualified" -> qualified = cnt;
190
+                case "unqualified" -> unqualified = cnt;
191
+                case "pending" -> pending = cnt;
192
+            }
193
+        }
194
+        vo.setTotalRecords(total);
195
+        vo.setQualifiedCount(qualified);
196
+        vo.setUnqualifiedCount(unqualified);
197
+        vo.setPendingCount(pending);
198
+        vo.setQualifiedRate(total > 0 ? Math.round(qualified * 10000.0 / total) / 100.0 : 0.0);
199
+
200
+        // 按水质类型
201
+        vo.setByWaterType(recordMapper.statRateByWaterType(startDate, endDate));
202
+
203
+        // 按区域
204
+        vo.setByArea(recordMapper.statRateByArea(startDate, endDate));
205
+
206
+        // 按检测类型
207
+        vo.setByTestType(recordMapper.statByComplianceStatus(startDate, endDate));
208
+
209
+        // 月度趋势
210
+        vo.setTrendByDate(recordMapper.statMonthlyTrend(startDate, endDate));
211
+
212
+        // 不合格项
213
+        vo.setUnqualifiedItems(recordMapper.statUnqualifiedByParam(startDate, endDate));
214
+
215
+        // 参数均值
216
+        Map<String, Object> avgMap = recordMapper.statParamAvg();
217
+        if (avgMap != null) {
218
+            vo.setAvgTurbidity(toDouble(avgMap.get("avgTurbidity")));
219
+            vo.setAvgPh(toDouble(avgMap.get("avgPh")));
220
+            vo.setAvgResidualChlorine(toDouble(avgMap.get("avgResidualChlorine")));
221
+        }
222
+
223
+        return vo;
224
+    }
225
+
226
+    private Double toDouble(Object val) {
227
+        if (val == null) return null;
228
+        if (val instanceof Number) return ((Number) val).doubleValue();
229
+        return null;
230
+    }
231
+
232
+    /**
233
+     * 导出Excel
234
+     */
235
+    public void exportExcel(QualityQueryRequest request, HttpServletResponse response) throws IOException {
236
+        request.setPageNum(1);
237
+        request.setPageSize(100000);
238
+        Map<String, Object> result = queryRecords(request);
239
+        @SuppressWarnings("unchecked")
240
+        List<Map<String, Object>> records = (List<Map<String, Object>>) result.get("records");
241
+
242
+        List<List<String>> excelData = records.stream().map(r -> {
243
+            List<String> row = new ArrayList<>();
244
+            row.add(String.valueOf(r.getOrDefault("testType", "")));
245
+            row.add(String.valueOf(r.getOrDefault("waterType", "")));
246
+            row.add(String.valueOf(r.getOrDefault("samplingPoint", "")));
247
+            row.add(String.valueOf(r.getOrDefault("area", "")));
248
+            row.add(String.valueOf(r.getOrDefault("testDate", "")));
249
+            row.add(String.valueOf(r.getOrDefault("tester", "")));
250
+            row.add(String.valueOf(r.getOrDefault("turbidity", "")));
251
+            row.add(String.valueOf(r.getOrDefault("ph", "")));
252
+            row.add(String.valueOf(r.getOrDefault("residualChlorine", "")));
253
+            row.add(String.valueOf(r.getOrDefault("color", "")));
254
+            row.add(String.valueOf(r.getOrDefault("odor", "")));
255
+            row.add(String.valueOf(r.getOrDefault("ecoli", "")));
256
+            row.add(String.valueOf(r.getOrDefault("colonyCount", "")));
257
+            row.add(String.valueOf(r.getOrDefault("complianceStatus", "")));
258
+            row.add(String.valueOf(r.getOrDefault("unqualifiedItems", "")));
259
+            row.add(String.valueOf(r.getOrDefault("remark", "")));
260
+            return row;
261
+        }).collect(Collectors.toList());
262
+
263
+        List<List<String>> head = List.of(
264
+                List.of("检测类型"), List.of("水质类型"), List.of("采样点"), List.of("区域"),
265
+                List.of("检测日期"), List.of("检测人"), List.of("浊度"), List.of("pH"),
266
+                List.of("余氯"), List.of("色度"), List.of("嗅味"), List.of("大肠杆菌"),
267
+                List.of("菌落总数"), List.of("合格状态"), List.of("不合格项"), List.of("备注")
268
+        );
269
+
270
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
271
+        response.setHeader("Content-Disposition",
272
+                "attachment;filename=" + URLEncoder.encode("水质检测报告.xlsx", StandardCharsets.UTF_8));
273
+
274
+        EasyExcel.write(response.getOutputStream())
275
+                .head(head)
276
+                .sheet("检测记录")
277
+                .doWrite(excelData.stream().map(row -> (List<Object>) (List<?>) row).collect(Collectors.toList()));
278
+    }
279
+
280
+    /**
281
+     * 获取区域列表
282
+     */
283
+    public List<String> getAreaList() {
284
+        LambdaQueryWrapper<QualityTestRecord> wrapper = new LambdaQueryWrapper<>();
285
+        wrapper.select(QualityTestRecord::getArea)
286
+               .isNotNull(QualityTestRecord::getArea)
287
+               .ne(QualityTestRecord::getArea, "")
288
+               .groupBy(QualityTestRecord::getArea);
289
+        return recordMapper.selectList(wrapper).stream()
290
+                .map(QualityTestRecord::getArea)
291
+                .distinct()
292
+                .sorted()
293
+                .collect(Collectors.toList());
294
+    }
295
+
296
+    /**
297
+     * 获取采样点列表
298
+     */
299
+    public List<String> getSamplingPointList() {
300
+        LambdaQueryWrapper<QualityTestRecord> wrapper = new LambdaQueryWrapper<>();
301
+        wrapper.select(QualityTestRecord::getSamplingPoint)
302
+               .isNotNull(QualityTestRecord::getSamplingPoint)
303
+               .ne(QualityTestRecord::getSamplingPoint, "")
304
+               .groupBy(QualityTestRecord::getSamplingPoint);
305
+        return recordMapper.selectList(wrapper).stream()
306
+                .map(QualityTestRecord::getSamplingPoint)
307
+                .distinct()
308
+                .sorted()
309
+                .collect(Collectors.toList());
310
+    }
311
+}

+ 105
- 0
wm-production/src/main/java/com/water/production/service/QualityStandardService.java Прегледај датотеку

@@ -0,0 +1,105 @@
1
+package com.water.production.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.common.core.exception.BusinessException;
5
+import com.water.production.entity.QualityStandard;
6
+import com.water.production.mapper.QualityStandardMapper;
7
+import lombok.RequiredArgsConstructor;
8
+import lombok.extern.slf4j.Slf4j;
9
+import org.springframework.stereotype.Service;
10
+import org.springframework.transaction.annotation.Transactional;
11
+
12
+import java.util.List;
13
+
14
+@Slf4j
15
+@Service
16
+@RequiredArgsConstructor
17
+public class QualityStandardService {
18
+
19
+    private final QualityStandardMapper standardMapper;
20
+
21
+    /**
22
+     * 获取所有启用的标准
23
+     */
24
+    public List<QualityStandard> listEnabled() {
25
+        LambdaQueryWrapper<QualityStandard> wrapper = new LambdaQueryWrapper<>();
26
+        wrapper.eq(QualityStandard::getEnabled, true)
27
+               .orderByAsc(QualityStandard::getWaterType, QualityStandard::getParamName);
28
+        return standardMapper.selectList(wrapper);
29
+    }
30
+
31
+    /**
32
+     * 按水质类型获取标准
33
+     */
34
+    public List<QualityStandard> listByWaterType(String waterType) {
35
+        LambdaQueryWrapper<QualityStandard> wrapper = new LambdaQueryWrapper<>();
36
+        wrapper.eq(QualityStandard::getEnabled, true)
37
+               .eq(QualityStandard::getWaterType, waterType)
38
+               .orderByAsc(QualityStandard::getParamName);
39
+        return standardMapper.selectList(wrapper);
40
+    }
41
+
42
+    /**
43
+     * 获取全部标准(含禁用)
44
+     */
45
+    public List<QualityStandard> listAll() {
46
+        LambdaQueryWrapper<QualityStandard> wrapper = new LambdaQueryWrapper<>();
47
+        wrapper.orderByAsc(QualityStandard::getWaterType, QualityStandard::getParamName);
48
+        return standardMapper.selectList(wrapper);
49
+    }
50
+
51
+    /**
52
+     * 获取标准详情
53
+     */
54
+    public QualityStandard getById(Long id) {
55
+        QualityStandard standard = standardMapper.selectById(id);
56
+        if (standard == null) {
57
+            throw new BusinessException("标准不存在");
58
+        }
59
+        return standard;
60
+    }
61
+
62
+    /**
63
+     * 获取指定水质类型和参数名的标准
64
+     */
65
+    public QualityStandard getStandard(String waterType, String paramName) {
66
+        LambdaQueryWrapper<QualityStandard> wrapper = new LambdaQueryWrapper<>();
67
+        wrapper.eq(QualityStandard::getWaterType, waterType)
68
+               .eq(QualityStandard::getParamName, paramName)
69
+               .eq(QualityStandard::getEnabled, true)
70
+               .last("LIMIT 1");
71
+        return standardMapper.selectOne(wrapper);
72
+    }
73
+
74
+    /**
75
+     * 创建标准
76
+     */
77
+    @Transactional
78
+    public QualityStandard create(QualityStandard standard) {
79
+        standardMapper.insert(standard);
80
+        log.info("创建水质标准: {} - {}", standard.getStandardName(), standard.getParamLabel());
81
+        return standard;
82
+    }
83
+
84
+    /**
85
+     * 更新标准
86
+     */
87
+    @Transactional
88
+    public QualityStandard update(Long id, QualityStandard standard) {
89
+        QualityStandard existing = getById(id);
90
+        standard.setId(existing.getId());
91
+        standardMapper.updateById(standard);
92
+        log.info("更新水质标准: {}", id);
93
+        return standardMapper.selectById(id);
94
+    }
95
+
96
+    /**
97
+     * 删除标准
98
+     */
99
+    @Transactional
100
+    public void delete(Long id) {
101
+        getById(id);
102
+        standardMapper.deleteById(id);
103
+        log.info("删除水质标准: {}", id);
104
+    }
105
+}

+ 144
- 0
wm-production/src/main/java/com/water/production/service/QualityTestPlanService.java Прегледај датотеку

@@ -0,0 +1,144 @@
1
+package com.water.production.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.common.core.exception.BusinessException;
6
+import com.water.production.entity.QualityTestPlan;
7
+import com.water.production.mapper.QualityTestPlanMapper;
8
+import lombok.RequiredArgsConstructor;
9
+import lombok.extern.slf4j.Slf4j;
10
+import org.springframework.stereotype.Service;
11
+import org.springframework.transaction.annotation.Transactional;
12
+
13
+import java.time.LocalDate;
14
+import java.util.List;
15
+
16
+@Slf4j
17
+@Service
18
+@RequiredArgsConstructor
19
+public class QualityTestPlanService {
20
+
21
+    private final QualityTestPlanMapper planMapper;
22
+
23
+    /**
24
+     * 分页查询计划
25
+     */
26
+    public Page<QualityTestPlan> queryPlans(int pageNum, int pageSize, String testType, String waterType,
27
+                                             String area, String status, String keyword) {
28
+        Page<QualityTestPlan> page = new Page<>(pageNum, pageSize);
29
+        LambdaQueryWrapper<QualityTestPlan> wrapper = new LambdaQueryWrapper<>();
30
+        if (testType != null && !testType.isBlank()) wrapper.eq(QualityTestPlan::getTestType, testType);
31
+        if (waterType != null && !waterType.isBlank()) wrapper.eq(QualityTestPlan::getWaterType, waterType);
32
+        if (area != null && !area.isBlank()) wrapper.eq(QualityTestPlan::getArea, area);
33
+        if (status != null && !status.isBlank()) wrapper.eq(QualityTestPlan::getStatus, status);
34
+        if (keyword != null && !keyword.isBlank()) {
35
+            wrapper.and(w -> w.like(QualityTestPlan::getPlanName, keyword)
36
+                              .or().like(QualityTestPlan::getSamplingPoint, keyword));
37
+        }
38
+        wrapper.orderByDesc(QualityTestPlan::getCreatedAt);
39
+        return planMapper.selectPage(page, wrapper);
40
+    }
41
+
42
+    /**
43
+     * 获取计划详情
44
+     */
45
+    public QualityTestPlan getById(Long id) {
46
+        QualityTestPlan plan = planMapper.selectById(id);
47
+        if (plan == null) {
48
+            throw new BusinessException("检测计划不存在");
49
+        }
50
+        return plan;
51
+    }
52
+
53
+    /**
54
+     * 创建计划
55
+     */
56
+    @Transactional
57
+    public QualityTestPlan create(QualityTestPlan plan) {
58
+        if (plan.getStatus() == null) plan.setStatus("active");
59
+        if (plan.getExecutionCount() == null) plan.setExecutionCount(0);
60
+        if (plan.getNextTestDate() == null) plan.setNextTestDate(plan.getStartDate());
61
+        planMapper.insert(plan);
62
+        log.info("创建检测计划: {}", plan.getPlanName());
63
+        return plan;
64
+    }
65
+
66
+    /**
67
+     * 更新计划
68
+     */
69
+    @Transactional
70
+    public QualityTestPlan update(Long id, QualityTestPlan plan) {
71
+        QualityTestPlan existing = getById(id);
72
+        plan.setId(existing.getId());
73
+        planMapper.updateById(plan);
74
+        log.info("更新检测计划: {}", id);
75
+        return planMapper.selectById(id);
76
+    }
77
+
78
+    /**
79
+     * 删除计划
80
+     */
81
+    @Transactional
82
+    public void delete(Long id) {
83
+        getById(id);
84
+        planMapper.deleteById(id);
85
+        log.info("删除检测计划: {}", id);
86
+    }
87
+
88
+    /**
89
+     * 切换计划状态
90
+     */
91
+    @Transactional
92
+    public QualityTestPlan toggleStatus(Long id) {
93
+        QualityTestPlan plan = getById(id);
94
+        if ("active".equals(plan.getStatus())) {
95
+            plan.setStatus("paused");
96
+        } else if ("paused".equals(plan.getStatus())) {
97
+            plan.setStatus("active");
98
+        }
99
+        planMapper.updateById(plan);
100
+        log.info("切换检测计划状态: {} -> {}", id, plan.getStatus());
101
+        return plan;
102
+    }
103
+
104
+    /**
105
+     * 获取到期计划
106
+     */
107
+    public List<QualityTestPlan> getDuePlans() {
108
+        LambdaQueryWrapper<QualityTestPlan> wrapper = new LambdaQueryWrapper<>();
109
+        wrapper.eq(QualityTestPlan::getStatus, "active")
110
+               .le(QualityTestPlan::getNextTestDate, LocalDate.now())
111
+               .orderByAsc(QualityTestPlan::getNextTestDate);
112
+        return planMapper.selectList(wrapper);
113
+    }
114
+
115
+    /**
116
+     * 标记已执行,计算下次检测日期
117
+     */
118
+    @Transactional
119
+    public QualityTestPlan markExecuted(Long id) {
120
+        QualityTestPlan plan = getById(id);
121
+        plan.setExecutionCount(plan.getExecutionCount() + 1);
122
+
123
+        // 计算下次检测日期
124
+        LocalDate nextDate = plan.getNextTestDate();
125
+        if (nextDate == null) nextDate = LocalDate.now();
126
+
127
+        switch (plan.getFrequency() != null ? plan.getFrequency() : "daily") {
128
+            case "daily" -> nextDate = nextDate.plusDays(1);
129
+            case "weekly" -> nextDate = nextDate.plusWeeks(1);
130
+            case "monthly" -> nextDate = nextDate.plusMonths(1);
131
+            default -> nextDate = nextDate.plusDays(1);
132
+        }
133
+        plan.setNextTestDate(nextDate);
134
+
135
+        // 如果下次检测超过结束日期,自动过期
136
+        if (plan.getEndDate() != null && nextDate.isAfter(plan.getEndDate())) {
137
+            plan.setStatus("expired");
138
+        }
139
+
140
+        planMapper.updateById(plan);
141
+        log.info("标记检测计划已执行: {}, 下次检测: {}", id, nextDate);
142
+        return plan;
143
+    }
144
+}

+ 134
- 0
wm-production/src/main/resources/db/V4__quality_ledger.sql Прегледај датотеку

@@ -0,0 +1,134 @@
1
+-- ============================================================
2
+-- V4: 水质检测台账 (GB5749-2022)
3
+-- ============================================================
4
+
5
+-- 1. 水质检测记录表
6
+CREATE TABLE IF NOT EXISTS prod_quality_test_record (
7
+    id              BIGSERIAL PRIMARY KEY,
8
+    test_type       VARCHAR(50)     NOT NULL DEFAULT 'routine',
9
+    water_type      VARCHAR(50)     NOT NULL,
10
+    sampling_point  VARCHAR(200),
11
+    area            VARCHAR(200),
12
+    test_date       DATE            NOT NULL DEFAULT CURRENT_DATE,
13
+    test_time       TIME,
14
+    tester          VARCHAR(100),
15
+    turbidity       NUMERIC(10,4),
16
+    ph              NUMERIC(6,3),
17
+    residual_chlorine NUMERIC(10,4),
18
+    color           NUMERIC(10,2),
19
+    odor            VARCHAR(100),
20
+    ecoli           NUMERIC(12,2),
21
+    colony_count    NUMERIC(12,2),
22
+    compliance_status VARCHAR(20)   DEFAULT 'pending',
23
+    unqualified_items TEXT,
24
+    remark          TEXT,
25
+    deleted         INTEGER         NOT NULL DEFAULT 0,
26
+    created_at      TIMESTAMP       NOT NULL DEFAULT NOW(),
27
+    updated_at      TIMESTAMP       NOT NULL DEFAULT NOW()
28
+);
29
+
30
+CREATE INDEX IF NOT EXISTS idx_quality_record_test_type ON prod_quality_test_record(test_type);
31
+CREATE INDEX IF NOT EXISTS idx_quality_record_water_type ON prod_quality_test_record(water_type);
32
+CREATE INDEX IF NOT EXISTS idx_quality_record_test_date ON prod_quality_test_record(test_date);
33
+CREATE INDEX IF NOT EXISTS idx_quality_record_area ON prod_quality_test_record(area);
34
+CREATE INDEX IF NOT EXISTS idx_quality_record_compliance ON prod_quality_test_record(compliance_status);
35
+CREATE INDEX IF NOT EXISTS idx_quality_record_deleted ON prod_quality_test_record(deleted);
36
+
37
+COMMENT ON TABLE prod_quality_test_record IS '水质检测记录表';
38
+COMMENT ON COLUMN prod_quality_test_record.test_type IS '检测类型: routine/emergency/special';
39
+COMMENT ON COLUMN prod_quality_test_record.water_type IS '水质类型: rawWater/factoryWater/pipeNetworkWater/endUserWater';
40
+COMMENT ON COLUMN prod_quality_test_record.compliance_status IS '合格状态: qualified/unqualified/pending';
41
+
42
+-- 2. 水质标准表
43
+CREATE TABLE IF NOT EXISTS prod_quality_standard (
44
+    id              BIGSERIAL PRIMARY KEY,
45
+    standard_name   VARCHAR(200)    NOT NULL,
46
+    standard_code   VARCHAR(100),
47
+    param_name      VARCHAR(100)    NOT NULL,
48
+    param_label     VARCHAR(100)    NOT NULL,
49
+    param_unit      VARCHAR(50),
50
+    min_value       NUMERIC(12,4),
51
+    max_value       NUMERIC(12,4),
52
+    water_type      VARCHAR(50)     NOT NULL,
53
+    enabled         BOOLEAN         NOT NULL DEFAULT TRUE,
54
+    deleted         INTEGER         NOT NULL DEFAULT 0,
55
+    created_at      TIMESTAMP       NOT NULL DEFAULT NOW(),
56
+    updated_at      TIMESTAMP       NOT NULL DEFAULT NOW()
57
+);
58
+
59
+CREATE INDEX IF NOT EXISTS idx_quality_standard_water_type ON prod_quality_standard(water_type);
60
+CREATE INDEX IF NOT EXISTS idx_quality_standard_param ON prod_quality_standard(param_name);
61
+CREATE INDEX IF NOT EXISTS idx_quality_standard_enabled ON prod_quality_standard(enabled);
62
+CREATE INDEX IF NOT EXISTS idx_quality_standard_deleted ON prod_quality_standard(deleted);
63
+
64
+COMMENT ON TABLE prod_quality_standard IS '水质标准表';
65
+COMMENT ON COLUMN prod_quality_standard.water_type IS '水质类型: rawWater/factoryWater/pipeNetworkWater/endUserWater';
66
+
67
+-- 3. 水质检测计划表
68
+CREATE TABLE IF NOT EXISTS prod_quality_test_plan (
69
+    id              BIGSERIAL PRIMARY KEY,
70
+    plan_name       VARCHAR(200)    NOT NULL,
71
+    test_type       VARCHAR(50)     NOT NULL DEFAULT 'routine',
72
+    water_type      VARCHAR(50)     NOT NULL,
73
+    sampling_point  VARCHAR(200),
74
+    area            VARCHAR(200),
75
+    frequency       VARCHAR(20)     NOT NULL DEFAULT 'daily',
76
+    test_params     TEXT,
77
+    start_date      DATE            NOT NULL,
78
+    end_date        DATE,
79
+    next_test_date  DATE,
80
+    status          VARCHAR(20)     NOT NULL DEFAULT 'active',
81
+    execution_count INTEGER         NOT NULL DEFAULT 0,
82
+    remark          TEXT,
83
+    deleted         INTEGER         NOT NULL DEFAULT 0,
84
+    created_at      TIMESTAMP       NOT NULL DEFAULT NOW(),
85
+    updated_at      TIMESTAMP       NOT NULL DEFAULT NOW()
86
+);
87
+
88
+CREATE INDEX IF NOT EXISTS idx_quality_plan_water_type ON prod_quality_test_plan(water_type);
89
+CREATE INDEX IF NOT EXISTS idx_quality_plan_status ON prod_quality_test_plan(status);
90
+CREATE INDEX IF NOT EXISTS idx_quality_plan_next_date ON prod_quality_test_plan(next_test_date);
91
+CREATE INDEX IF NOT EXISTS idx_quality_plan_deleted ON prod_quality_test_plan(deleted);
92
+
93
+COMMENT ON TABLE prod_quality_test_plan IS '水质检测计划表';
94
+COMMENT ON COLUMN prod_quality_test_plan.frequency IS '检测频率: daily/weekly/monthly';
95
+COMMENT ON COLUMN prod_quality_test_plan.status IS '状态: active/paused/expired';
96
+
97
+-- ============================================================
98
+-- GB5749-2022 默认标准数据
99
+-- ============================================================
100
+
101
+-- 原水 (rawWater) 标准
102
+INSERT INTO prod_quality_standard (standard_name, standard_code, param_name, param_label, param_unit, min_value, max_value, water_type, enabled) VALUES
103
+('GB5749-2022', 'GB5749-2022', 'turbidity', '浊度', 'NTU', NULL, 4.0, 'rawWater', TRUE),
104
+('GB5749-2022', 'GB5749-2022', 'ph', 'pH值', '', 6.5, 8.5, 'rawWater', TRUE),
105
+('GB5749-2022', 'GB5749-2022', 'color', '色度', '度', NULL, 15.0, 'rawWater', TRUE),
106
+('GB5749-2022', 'GB5749-2022', 'ecoli', '大肠杆菌', 'CFU/100mL', NULL, 10000.0, 'rawWater', TRUE),
107
+('GB5749-2022', 'GB5749-2022', 'colonyCount', '菌落总数', 'CFU/mL', NULL, 500.0, 'rawWater', TRUE);
108
+
109
+-- 出厂水 (factoryWater) 标准
110
+INSERT INTO prod_quality_standard (standard_name, standard_code, param_name, param_label, param_unit, min_value, max_value, water_type, enabled) VALUES
111
+('GB5749-2022', 'GB5749-2022', 'turbidity', '浊度', 'NTU', NULL, 1.0, 'factoryWater', TRUE),
112
+('GB5749-2022', 'GB5749-2022', 'ph', 'pH值', '', 6.5, 8.5, 'factoryWater', TRUE),
113
+('GB5749-2022', 'GB5749-2022', 'residualChlorine', '余氯', 'mg/L', 0.3, 4.0, 'factoryWater', TRUE),
114
+('GB5749-2022', 'GB5749-2022', 'color', '色度', '度', NULL, 15.0, 'factoryWater', TRUE),
115
+('GB5749-2022', 'GB5749-2022', 'ecoli', '大肠杆菌', 'CFU/100mL', NULL, 0.0, 'factoryWater', TRUE),
116
+('GB5749-2022', 'GB5749-2022', 'colonyCount', '菌落总数', 'CFU/mL', NULL, 100.0, 'factoryWater', TRUE);
117
+
118
+-- 管网水 (pipeNetworkWater) 标准
119
+INSERT INTO prod_quality_standard (standard_name, standard_code, param_name, param_label, param_unit, min_value, max_value, water_type, enabled) VALUES
120
+('GB5749-2022', 'GB5749-2022', 'turbidity', '浊度', 'NTU', NULL, 1.0, 'pipeNetworkWater', TRUE),
121
+('GB5749-2022', 'GB5749-2022', 'ph', 'pH值', '', 6.5, 8.5, 'pipeNetworkWater', TRUE),
122
+('GB5749-2022', 'GB5749-2022', 'residualChlorine', '余氯', 'mg/L', 0.05, 4.0, 'pipeNetworkWater', TRUE),
123
+('GB5749-2022', 'GB5749-2022', 'color', '色度', '度', NULL, 15.0, 'pipeNetworkWater', TRUE),
124
+('GB5749-2022', 'GB5749-2022', 'ecoli', '大肠杆菌', 'CFU/100mL', NULL, 0.0, 'pipeNetworkWater', TRUE),
125
+('GB5749-2022', 'GB5749-2022', 'colonyCount', '菌落总数', 'CFU/mL', NULL, 100.0, 'pipeNetworkWater', TRUE);
126
+
127
+-- 末梢水 (endUserWater) 标准
128
+INSERT INTO prod_quality_standard (standard_name, standard_code, param_name, param_label, param_unit, min_value, max_value, water_type, enabled) VALUES
129
+('GB5749-2022', 'GB5749-2022', 'turbidity', '浊度', 'NTU', NULL, 1.0, 'endUserWater', TRUE),
130
+('GB5749-2022', 'GB5749-2022', 'ph', 'pH值', '', 6.5, 8.5, 'endUserWater', TRUE),
131
+('GB5749-2022', 'GB5749-2022', 'residualChlorine', '余氯', 'mg/L', 0.05, 4.0, 'endUserWater', TRUE),
132
+('GB5749-2022', 'GB5749-2022', 'color', '色度', '度', NULL, 15.0, 'endUserWater', TRUE),
133
+('GB5749-2022', 'GB5749-2022', 'ecoli', '大肠杆菌', 'CFU/100mL', NULL, 0.0, 'endUserWater', TRUE),
134
+('GB5749-2022', 'GB5749-2022', 'colonyCount', '菌落总数', 'CFU/mL', NULL, 100.0, 'endUserWater', TRUE);

+ 122
- 0
wm-production/src/main/resources/mapper/QualityTestRecordMapper.xml Прегледај датотеку

@@ -0,0 +1,122 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
3
+<mapper namespace="com.water.production.mapper.QualityTestRecordMapper">
4
+
5
+    <select id="selectRecordPage" resultType="java.util.Map">
6
+        SELECT
7
+            id, test_type, water_type, sampling_point, area, test_date, test_time,
8
+            tester, turbidity, ph, residual_chlorine, color, odor, ecoli, colony_count,
9
+            compliance_status, unqualified_items, remark, created_at, updated_at
10
+        FROM prod_quality_test_record
11
+        WHERE deleted = 0
12
+        <if test="testType != null and testType != ''">AND test_type = #{testType}</if>
13
+        <if test="waterType != null and waterType != ''">AND water_type = #{waterType}</if>
14
+        <if test="area != null and area != ''">AND area = #{area}</if>
15
+        <if test="samplingPoint != null and samplingPoint != ''">AND sampling_point = #{samplingPoint}</if>
16
+        <if test="tester != null and tester != ''">AND tester LIKE '%' || #{tester} || '%'</if>
17
+        <if test="complianceStatus != null and complianceStatus != ''">AND compliance_status = #{complianceStatus}</if>
18
+        <if test="startDate != null and startDate != ''">AND test_date &gt;= #{startDate}::date</if>
19
+        <if test="endDate != null and endDate != ''">AND test_date &lt;= #{endDate}::date</if>
20
+        <if test="keyword != null and keyword != ''">
21
+            AND (sampling_point LIKE '%' || #{keyword} || '%' OR tester LIKE '%' || #{keyword} || '%' OR remark LIKE '%' || #{keyword} || '%')
22
+        </if>
23
+        <choose>
24
+            <when test="sortField != null and sortField != '' and sortOrder != null and sortOrder == 'asc'">
25
+                ORDER BY ${sortField} ASC
26
+            </when>
27
+            <when test="sortField != null and sortField != '' and sortOrder != null and sortOrder == 'desc'">
28
+                ORDER BY ${sortField} DESC
29
+            </when>
30
+            <otherwise>ORDER BY created_at DESC</otherwise>
31
+        </choose>
32
+        OFFSET #{offset} LIMIT #{limit}
33
+    </select>
34
+
35
+    <select id="countRecords" resultType="java.lang.Long">
36
+        SELECT COUNT(*)
37
+        FROM prod_quality_test_record
38
+        WHERE deleted = 0
39
+        <if test="testType != null and testType != ''">AND test_type = #{testType}</if>
40
+        <if test="waterType != null and waterType != ''">AND water_type = #{waterType}</if>
41
+        <if test="area != null and area != ''">AND area = #{area}</if>
42
+        <if test="samplingPoint != null and samplingPoint != ''">AND sampling_point = #{samplingPoint}</if>
43
+        <if test="tester != null and tester != ''">AND tester LIKE '%' || #{tester} || '%'</if>
44
+        <if test="complianceStatus != null and complianceStatus != ''">AND compliance_status = #{complianceStatus}</if>
45
+        <if test="startDate != null and startDate != ''">AND test_date &gt;= #{startDate}::date</if>
46
+        <if test="endDate != null and endDate != ''">AND test_date &lt;= #{endDate}::date</if>
47
+        <if test="keyword != null and keyword != ''">
48
+            AND (sampling_point LIKE '%' || #{keyword} || '%' OR tester LIKE '%' || #{keyword} || '%' OR remark LIKE '%' || #{keyword} || '%')
49
+        </if>
50
+    </select>
51
+
52
+    <select id="statByComplianceStatus" resultType="java.util.Map">
53
+        SELECT compliance_status AS status, COUNT(*) AS count
54
+        FROM prod_quality_test_record
55
+        WHERE deleted = 0
56
+        <if test="startDate != null and startDate != ''">AND test_date &gt;= #{startDate}::date</if>
57
+        <if test="endDate != null and endDate != ''">AND test_date &lt;= #{endDate}::date</if>
58
+        GROUP BY compliance_status
59
+    </select>
60
+
61
+    <select id="statRateByWaterType" resultType="java.util.Map">
62
+        SELECT
63
+            water_type AS waterType,
64
+            COUNT(*) AS total,
65
+            COUNT(CASE WHEN compliance_status = 'qualified' THEN 1 END) AS qualified,
66
+            ROUND(COUNT(CASE WHEN compliance_status = 'qualified' THEN 1 END) * 100.0 / NULLIF(COUNT(*), 0), 2) AS rate
67
+        FROM prod_quality_test_record
68
+        WHERE deleted = 0
69
+        <if test="startDate != null and startDate != ''">AND test_date &gt;= #{startDate}::date</if>
70
+        <if test="endDate != null and endDate != ''">AND test_date &lt;= #{endDate}::date</if>
71
+        GROUP BY water_type
72
+    </select>
73
+
74
+    <select id="statRateByArea" resultType="java.util.Map">
75
+        SELECT
76
+            area,
77
+            COUNT(*) AS total,
78
+            COUNT(CASE WHEN compliance_status = 'qualified' THEN 1 END) AS qualified,
79
+            ROUND(COUNT(CASE WHEN compliance_status = 'qualified' THEN 1 END) * 100.0 / NULLIF(COUNT(*), 0), 2) AS rate
80
+        FROM prod_quality_test_record
81
+        WHERE deleted = 0
82
+        <if test="startDate != null and startDate != ''">AND test_date &gt;= #{startDate}::date</if>
83
+        <if test="endDate != null and endDate != ''">AND test_date &lt;= #{endDate}::date</if>
84
+        GROUP BY area
85
+    </select>
86
+
87
+    <select id="statUnqualifiedByParam" resultType="java.util.Map">
88
+        SELECT
89
+            jsonb_array_elements_text(unqualified_items::jsonb) AS param,
90
+            COUNT(*) AS count
91
+        FROM prod_quality_test_record
92
+        WHERE deleted = 0 AND compliance_status = 'unqualified' AND unqualified_items IS NOT NULL
93
+        <if test="startDate != null and startDate != ''">AND test_date &gt;= #{startDate}::date</if>
94
+        <if test="endDate != null and endDate != ''">AND test_date &lt;= #{endDate}::date</if>
95
+        GROUP BY param
96
+        ORDER BY count DESC
97
+    </select>
98
+
99
+    <select id="statMonthlyTrend" resultType="java.util.Map">
100
+        SELECT
101
+            TO_CHAR(test_date, 'YYYY-MM') AS month,
102
+            COUNT(*) AS total,
103
+            COUNT(CASE WHEN compliance_status = 'qualified' THEN 1 END) AS qualified,
104
+            ROUND(COUNT(CASE WHEN compliance_status = 'qualified' THEN 1 END) * 100.0 / NULLIF(COUNT(*), 0), 2) AS rate
105
+        FROM prod_quality_test_record
106
+        WHERE deleted = 0
107
+        <if test="startDate != null and startDate != ''">AND test_date &gt;= #{startDate}::date</if>
108
+        <if test="endDate != null and endDate != ''">AND test_date &lt;= #{endDate}::date</if>
109
+        GROUP BY month
110
+        ORDER BY month
111
+    </select>
112
+
113
+    <select id="statParamAvg" resultType="java.util.Map">
114
+        SELECT
115
+            ROUND(AVG(turbidity)::numeric, 4) AS avgTurbidity,
116
+            ROUND(AVG(ph)::numeric, 4) AS avgPh,
117
+            ROUND(AVG(residual_chlorine)::numeric, 4) AS avgResidualChlorine
118
+        FROM prod_quality_test_record
119
+        WHERE deleted = 0 AND compliance_status IS NOT NULL
120
+    </select>
121
+
122
+</mapper>

+ 399
- 0
wm-production/src/test/java/com/water/production/service/QualityLedgerServiceTest.java Прегледај датотеку

@@ -0,0 +1,399 @@
1
+package com.water.production.service;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.production.dto.QualityQueryRequest;
5
+import com.water.production.dto.QualityStatVO;
6
+import com.water.production.entity.QualityStandard;
7
+import com.water.production.entity.QualityTestPlan;
8
+import com.water.production.entity.QualityTestRecord;
9
+import com.water.production.mapper.QualityStandardMapper;
10
+import com.water.production.mapper.QualityTestPlanMapper;
11
+import com.water.production.mapper.QualityTestRecordMapper;
12
+import org.junit.jupiter.api.BeforeEach;
13
+import org.junit.jupiter.api.DisplayName;
14
+import org.junit.jupiter.api.Test;
15
+import org.junit.jupiter.api.extension.ExtendWith;
16
+import org.mockito.InjectMocks;
17
+import org.mockito.Mock;
18
+import org.mockito.junit.jupiter.MockitoExtension;
19
+
20
+import java.time.LocalDate;
21
+import java.time.LocalTime;
22
+import java.util.*;
23
+
24
+import static org.junit.jupiter.api.Assertions.*;
25
+import static org.mockito.ArgumentMatchers.*;
26
+import static org.mockito.Mockito.*;
27
+
28
+@ExtendWith(MockitoExtension.class)
29
+class QualityLedgerServiceTest {
30
+
31
+    @Mock
32
+    private QualityTestRecordMapper recordMapper;
33
+
34
+    @Mock
35
+    private QualityStandardService standardService;
36
+
37
+    @Mock
38
+    private QualityTestPlanMapper planMapper;
39
+
40
+    @Mock
41
+    private QualityStandardMapper standardMapper;
42
+
43
+    @InjectMocks
44
+    private QualityLedgerService ledgerService;
45
+
46
+    private QualityTestPlanService planService;
47
+
48
+    private QualityTestRecord sampleRecord;
49
+
50
+    @BeforeEach
51
+    void setUp() {
52
+        planService = new QualityTestPlanService(planMapper);
53
+
54
+        sampleRecord = new QualityTestRecord();
55
+        sampleRecord.setId(1L);
56
+        sampleRecord.setTestType("routine");
57
+        sampleRecord.setWaterType("factoryWater");
58
+        sampleRecord.setSamplingPoint("出厂水采样点A");
59
+        sampleRecord.setArea("城区A");
60
+        sampleRecord.setTestDate(LocalDate.now());
61
+        sampleRecord.setTestTime(LocalTime.of(10, 0));
62
+        sampleRecord.setTester("张三");
63
+        sampleRecord.setTurbidity(0.5);
64
+        sampleRecord.setPh(7.2);
65
+        sampleRecord.setResidualChlorine(0.8);
66
+        sampleRecord.setColor(5.0);
67
+        sampleRecord.setOdor("无");
68
+        sampleRecord.setEcoli(0.0);
69
+        sampleRecord.setColonyCount(20.0);
70
+    }
71
+
72
+    // ==================== 记录 CRUD 测试 ====================
73
+
74
+    @Test
75
+    @DisplayName("1. 创建检测记录-自动判定合格")
76
+    void testCreateRecord_Qualified() {
77
+        when(standardService.getStandard("factoryWater", "turbidity"))
78
+                .thenReturn(buildStandard("turbidity", null, 1.0));
79
+        when(standardService.getStandard("factoryWater", "ph"))
80
+                .thenReturn(buildStandard("ph", 6.5, 8.5));
81
+        when(standardService.getStandard("factoryWater", "residualChlorine"))
82
+                .thenReturn(buildStandard("residualChlorine", 0.3, 4.0));
83
+        when(standardService.getStandard("factoryWater", "color"))
84
+                .thenReturn(buildStandard("color", null, 15.0));
85
+        when(standardService.getStandard("factoryWater", "ecoli"))
86
+                .thenReturn(buildStandard("ecoli", null, 0.0));
87
+        when(standardService.getStandard("factoryWater", "colonyCount"))
88
+                .thenReturn(buildStandard("colonyCount", null, 100.0));
89
+        when(recordMapper.insert(any())).thenReturn(1);
90
+
91
+        QualityTestRecord result = ledgerService.create(sampleRecord);
92
+
93
+        assertEquals("qualified", result.getComplianceStatus());
94
+        assertNull(result.getUnqualifiedItems());
95
+        verify(recordMapper).insert(sampleRecord);
96
+    }
97
+
98
+    @Test
99
+    @DisplayName("2. 创建检测记录-自动判定不合格")
100
+    void testCreateRecord_Unqualified() {
101
+        sampleRecord.setTurbidity(2.5); // 超标
102
+        sampleRecord.setEcoli(10.0); // 超标
103
+
104
+        when(standardService.getStandard("factoryWater", "turbidity"))
105
+                .thenReturn(buildStandard("turbidity", null, 1.0));
106
+        when(standardService.getStandard("factoryWater", "ph"))
107
+                .thenReturn(buildStandard("ph", 6.5, 8.5));
108
+        when(standardService.getStandard("factoryWater", "residualChlorine"))
109
+                .thenReturn(buildStandard("residualChlorine", 0.3, 4.0));
110
+        when(standardService.getStandard("factoryWater", "color"))
111
+                .thenReturn(buildStandard("color", null, 15.0));
112
+        when(standardService.getStandard("factoryWater", "ecoli"))
113
+                .thenReturn(buildStandard("ecoli", null, 0.0));
114
+        when(standardService.getStandard("factoryWater", "colonyCount"))
115
+                .thenReturn(buildStandard("colonyCount", null, 100.0));
116
+        when(recordMapper.insert(any())).thenReturn(1);
117
+
118
+        QualityTestRecord result = ledgerService.create(sampleRecord);
119
+
120
+        assertEquals("unqualified", result.getComplianceStatus());
121
+        assertNotNull(result.getUnqualifiedItems());
122
+        assertTrue(result.getUnqualifiedItems().contains("浊度"));
123
+        assertTrue(result.getUnqualifiedItems().contains("大肠杆菌"));
124
+    }
125
+
126
+    @Test
127
+    @DisplayName("3. 创建检测记录-waterType为空时pending")
128
+    void testCreateRecord_PendingWhenNoWaterType() {
129
+        sampleRecord.setWaterType(null);
130
+        when(recordMapper.insert(any())).thenReturn(1);
131
+
132
+        QualityTestRecord result = ledgerService.create(sampleRecord);
133
+
134
+        assertEquals("pending", result.getComplianceStatus());
135
+    }
136
+
137
+    @Test
138
+    @DisplayName("4. 查询检测记录分页")
139
+    void testQueryRecords() {
140
+        QualityQueryRequest request = new QualityQueryRequest();
141
+        request.setPageNum(1);
142
+        request.setPageSize(10);
143
+
144
+        List<Map<String, Object>> mockRecords = List.of(
145
+                Map.of("id", 1L, "testType", "routine"),
146
+                Map.of("id", 2L, "testType", "emergency")
147
+        );
148
+        when(recordMapper.selectRecordPage(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), eq(0), eq(10)))
149
+                .thenReturn(mockRecords);
150
+        when(recordMapper.countRecords(any(), any(), any(), any(), any(), any(), any(), any(), any()))
151
+                .thenReturn(2L);
152
+
153
+        Map<String, Object> result = ledgerService.queryRecords(request);
154
+
155
+        assertNotNull(result);
156
+        assertEquals(2L, result.get("total"));
157
+        assertEquals(1, result.get("pageNum"));
158
+        assertEquals(1L, result.get("totalPages"));
159
+    }
160
+
161
+    @Test
162
+    @DisplayName("5. 获取记录详情")
163
+    void testGetById() {
164
+        when(recordMapper.selectById(1L)).thenReturn(sampleRecord);
165
+
166
+        QualityTestRecord result = ledgerService.getById(1L);
167
+
168
+        assertNotNull(result);
169
+        assertEquals(1L, result.getId());
170
+        assertEquals("routine", result.getTestType());
171
+    }
172
+
173
+    @Test
174
+    @DisplayName("6. 获取不存在记录抛异常")
175
+    void testGetById_NotFound() {
176
+        when(recordMapper.selectById(999L)).thenReturn(null);
177
+
178
+        assertThrows(Exception.class, () -> ledgerService.getById(999L));
179
+    }
180
+
181
+    @Test
182
+    @DisplayName("7. 更新检测记录")
183
+    void testUpdateRecord() {
184
+        when(recordMapper.selectById(1L)).thenReturn(sampleRecord);
185
+        when(standardService.getStandard(anyString(), anyString())).thenReturn(null);
186
+        when(recordMapper.updateById(any())).thenReturn(1);
187
+        when(recordMapper.selectById(1L)).thenReturn(sampleRecord);
188
+
189
+        sampleRecord.setRemark("updated");
190
+        QualityTestRecord result = ledgerService.update(1L, sampleRecord);
191
+
192
+        assertNotNull(result);
193
+        verify(recordMapper).updateById(any());
194
+    }
195
+
196
+    @Test
197
+    @DisplayName("8. 删除检测记录")
198
+    void testDeleteRecord() {
199
+        when(recordMapper.selectById(1L)).thenReturn(sampleRecord);
200
+        when(recordMapper.deleteById(1L)).thenReturn(1);
201
+
202
+        ledgerService.delete(1L);
203
+
204
+        verify(recordMapper).deleteById(1L);
205
+    }
206
+
207
+    @Test
208
+    @DisplayName("9. 批量删除记录")
209
+    void testBatchDelete() {
210
+        List<Long> ids = List.of(1L, 2L, 3L);
211
+        when(recordMapper.deleteBatchIds(ids)).thenReturn(3);
212
+
213
+        ledgerService.batchDelete(ids);
214
+
215
+        verify(recordMapper).deleteBatchIds(ids);
216
+    }
217
+
218
+    // ==================== 合格判定测试 ====================
219
+
220
+    @Test
221
+    @DisplayName("10. 合格判定-pH偏低")
222
+    void testEvaluateCompliance_PhLow() {
223
+        sampleRecord.setPh(5.0);
224
+        when(standardService.getStandard("factoryWater", "turbidity")).thenReturn(buildStandard("turbidity", null, 1.0));
225
+        when(standardService.getStandard("factoryWater", "ph")).thenReturn(buildStandard("ph", 6.5, 8.5));
226
+        when(standardService.getStandard("factoryWater", "residualChlorine")).thenReturn(buildStandard("residualChlorine", 0.3, 4.0));
227
+        when(standardService.getStandard("factoryWater", "color")).thenReturn(buildStandard("color", null, 15.0));
228
+        when(standardService.getStandard("factoryWater", "ecoli")).thenReturn(buildStandard("ecoli", null, 0.0));
229
+        when(standardService.getStandard("factoryWater", "colonyCount")).thenReturn(buildStandard("colonyCount", null, 100.0));
230
+
231
+        ledgerService.evaluateCompliance(sampleRecord);
232
+
233
+        assertEquals("unqualified", sampleRecord.getComplianceStatus());
234
+        assertTrue(sampleRecord.getUnqualifiedItems().contains("pH"));
235
+        assertTrue(sampleRecord.getUnqualifiedItems().contains("偏低"));
236
+    }
237
+
238
+    @Test
239
+    @DisplayName("11. 合格判定-余氯偏高")
240
+    void testEvaluateCompliance_ChlorineHigh() {
241
+        sampleRecord.setResidualChlorine(5.0);
242
+        when(standardService.getStandard("factoryWater", "turbidity")).thenReturn(buildStandard("turbidity", null, 1.0));
243
+        when(standardService.getStandard("factoryWater", "ph")).thenReturn(buildStandard("ph", 6.5, 8.5));
244
+        when(standardService.getStandard("factoryWater", "residualChlorine")).thenReturn(buildStandard("residualChlorine", 0.3, 4.0));
245
+        when(standardService.getStandard("factoryWater", "color")).thenReturn(buildStandard("color", null, 15.0));
246
+        when(standardService.getStandard("factoryWater", "ecoli")).thenReturn(buildStandard("ecoli", null, 0.0));
247
+        when(standardService.getStandard("factoryWater", "colonyCount")).thenReturn(buildStandard("colonyCount", null, 100.0));
248
+
249
+        ledgerService.evaluateCompliance(sampleRecord);
250
+
251
+        assertEquals("unqualified", sampleRecord.getComplianceStatus());
252
+        assertTrue(sampleRecord.getUnqualifiedItems().contains("余氯"));
253
+        assertTrue(sampleRecord.getUnqualifiedItems().contains("偏高"));
254
+    }
255
+
256
+    @Test
257
+    @DisplayName("12. 合格判定-无标准时不判定")
258
+    void testEvaluateCompliance_NoStandard() {
259
+        sampleRecord.setWaterType("unknownType");
260
+        when(standardService.getStandard(eq("unknownType"), anyString())).thenReturn(null);
261
+
262
+        ledgerService.evaluateCompliance(sampleRecord);
263
+
264
+        assertEquals("qualified", sampleRecord.getComplianceStatus());
265
+    }
266
+
267
+    // ==================== 统计测试 ====================
268
+
269
+    @Test
270
+    @DisplayName("13. 获取统计数据")
271
+    void testGetStatistics() {
272
+        List<Map<String, Object>> statusStats = List.of(
273
+                Map.of("status", "qualified", "count", 80L),
274
+                Map.of("status", "unqualified", "count", 15L),
275
+                Map.of("status", "pending", "count", 5L)
276
+        );
277
+        when(recordMapper.statByComplianceStatus(any(), any())).thenReturn(statusStats);
278
+        when(recordMapper.statRateByWaterType(any(), any())).thenReturn(Collections.emptyList());
279
+        when(recordMapper.statRateByArea(any(), any())).thenReturn(Collections.emptyList());
280
+        when(recordMapper.statMonthlyTrend(any(), any())).thenReturn(Collections.emptyList());
281
+        when(recordMapper.statUnqualifiedByParam(any(), any())).thenReturn(Collections.emptyList());
282
+        when(recordMapper.statParamAvg()).thenReturn(Map.of(
283
+                "avgTurbidity", 0.45, "avgPh", 7.1, "avgResidualChlorine", 0.6
284
+        ));
285
+
286
+        QualityStatVO vo = ledgerService.getStatistics(null, null);
287
+
288
+        assertNotNull(vo);
289
+        assertEquals(100L, vo.getTotalRecords());
290
+        assertEquals(80L, vo.getQualifiedCount());
291
+        assertEquals(15L, vo.getUnqualifiedCount());
292
+        assertEquals(5L, vo.getPendingCount());
293
+        assertEquals(80.0, vo.getQualifiedRate());
294
+    }
295
+
296
+    @Test
297
+    @DisplayName("14. 统计-空数据")
298
+    void testGetStatistics_Empty() {
299
+        when(recordMapper.statByComplianceStatus(any(), any())).thenReturn(Collections.emptyList());
300
+        when(recordMapper.statRateByWaterType(any(), any())).thenReturn(Collections.emptyList());
301
+        when(recordMapper.statRateByArea(any(), any())).thenReturn(Collections.emptyList());
302
+        when(recordMapper.statMonthlyTrend(any(), any())).thenReturn(Collections.emptyList());
303
+        when(recordMapper.statUnqualifiedByParam(any(), any())).thenReturn(Collections.emptyList());
304
+        when(recordMapper.statParamAvg()).thenReturn(null);
305
+
306
+        QualityStatVO vo = ledgerService.getStatistics(null, null);
307
+
308
+        assertEquals(0L, vo.getTotalRecords());
309
+        assertEquals(0.0, vo.getQualifiedRate());
310
+    }
311
+
312
+    // ==================== 辅助方法测试 ====================
313
+
314
+    @Test
315
+    @DisplayName("15. 获取区域列表")
316
+    void testGetAreaList() {
317
+        QualityTestRecord r1 = new QualityTestRecord();
318
+        r1.setArea("城区A");
319
+        QualityTestRecord r2 = new QualityTestRecord();
320
+        r2.setArea("城区B");
321
+        when(recordMapper.selectList(any())).thenReturn(List.of(r1, r2));
322
+
323
+        List<String> areas = ledgerService.getAreaList();
324
+
325
+        assertNotNull(areas);
326
+        assertEquals(2, areas.size());
327
+        assertTrue(areas.contains("城区A"));
328
+        assertTrue(areas.contains("城区B"));
329
+    }
330
+
331
+    @Test
332
+    @DisplayName("16. 获取采样点列表")
333
+    void testGetSamplingPointList() {
334
+        QualityTestRecord r1 = new QualityTestRecord();
335
+        r1.setSamplingPoint("采样点A");
336
+        QualityTestRecord r2 = new QualityTestRecord();
337
+        r2.setSamplingPoint("采样点B");
338
+        when(recordMapper.selectList(any())).thenReturn(List.of(r1, r2));
339
+
340
+        List<String> points = ledgerService.getSamplingPointList();
341
+
342
+        assertNotNull(points);
343
+        assertEquals(2, points.size());
344
+    }
345
+
346
+    // ==================== 计划测试 ====================
347
+
348
+    @Test
349
+    @DisplayName("17. 创建检测计划")
350
+    void testCreatePlan() {
351
+        QualityTestPlan plan = buildPlan();
352
+        when(planMapper.insert(any())).thenReturn(1);
353
+
354
+        QualityTestPlan result = planService.create(plan);
355
+
356
+        assertEquals("active", result.getStatus());
357
+        assertEquals(0, result.getExecutionCount());
358
+        verify(planMapper).insert(plan);
359
+    }
360
+
361
+    @Test
362
+    @DisplayName("18. 标记计划已执行-日检+1天")
363
+    void testMarkPlanExecuted() {
364
+        QualityTestPlan plan = buildPlan();
365
+        plan.setId(1L);
366
+        plan.setNextTestDate(LocalDate.of(2026, 6, 14));
367
+        plan.setExecutionCount(5);
368
+        when(planMapper.selectById(1L)).thenReturn(plan);
369
+        when(planMapper.updateById(any())).thenReturn(1);
370
+
371
+        QualityTestPlan result = planService.markExecuted(1L);
372
+
373
+        assertEquals(6, result.getExecutionCount());
374
+        assertEquals(LocalDate.of(2026, 6, 15), result.getNextTestDate());
375
+    }
376
+
377
+    // ==================== 辅助方法 ====================
378
+
379
+    private QualityStandard buildStandard(String paramName, Double min, Double max) {
380
+        QualityStandard s = new QualityStandard();
381
+        s.setParamName(paramName);
382
+        s.setMinValue(min);
383
+        s.setMaxValue(max);
384
+        s.setEnabled(true);
385
+        return s;
386
+    }
387
+
388
+    private QualityTestPlan buildPlan() {
389
+        QualityTestPlan plan = new QualityTestPlan();
390
+        plan.setPlanName("日检计划");
391
+        plan.setTestType("routine");
392
+        plan.setWaterType("factoryWater");
393
+        plan.setSamplingPoint("出厂水采样点A");
394
+        plan.setArea("城区A");
395
+        plan.setFrequency("daily");
396
+        plan.setStartDate(LocalDate.now());
397
+        return plan;
398
+    }
399
+}