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个单元测试
This commit is contained in:
2026-06-14 16:34:58 +08:00
parent 98f91e5d74
commit 91740fb44c
16 changed files with 1816 additions and 0 deletions
+2
View File
@@ -12,5 +12,7 @@
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
<dependency><groupId>cn.dev33</groupId><artifactId>sa-token-spring-boot3-starter</artifactId></dependency>
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId></dependency>
<dependency><groupId>com.alibaba</groupId><artifactId>easyexcel</artifactId></dependency>
<dependency><groupId>com.github.xiaoymin</groupId><artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId></dependency>
</dependencies>
</project>
@@ -0,0 +1,214 @@
package com.water.production.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.common.core.result.R;
import com.water.production.dto.QualityQueryRequest;
import com.water.production.dto.QualityStatVO;
import com.water.production.entity.QualityStandard;
import com.water.production.entity.QualityTestPlan;
import com.water.production.entity.QualityTestRecord;
import com.water.production.service.QualityLedgerService;
import com.water.production.service.QualityStandardService;
import com.water.production.service.QualityTestPlanService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
import java.util.Map;
@Tag(name = "水质检测台账管理")
@RestController
@RequestMapping("/api/production/quality")
@RequiredArgsConstructor
public class QualityLedgerController {
private final QualityLedgerService ledgerService;
private final QualityStandardService standardService;
private final QualityTestPlanService planService;
// ==================== 记录 CRUD ====================
@Operation(summary = "查询检测记录(分页)")
@GetMapping("/records")
public R<Map<String, Object>> queryRecords(QualityQueryRequest request) {
return R.ok(ledgerService.queryRecords(request));
}
@Operation(summary = "获取检测记录详情")
@GetMapping("/records/{id}")
public R<QualityTestRecord> getRecord(@PathVariable Long id) {
return R.ok(ledgerService.getById(id));
}
@Operation(summary = "创建检测记录")
@PostMapping("/records")
public R<QualityTestRecord> createRecord(@RequestBody QualityTestRecord record) {
return R.ok(ledgerService.create(record));
}
@Operation(summary = "更新检测记录")
@PutMapping("/records/{id}")
public R<QualityTestRecord> updateRecord(@PathVariable Long id, @RequestBody QualityTestRecord record) {
return R.ok(ledgerService.update(id, record));
}
@Operation(summary = "删除检测记录")
@DeleteMapping("/records/{id}")
public R<Void> deleteRecord(@PathVariable Long id) {
ledgerService.delete(id);
return R.ok();
}
@Operation(summary = "批量删除检测记录")
@DeleteMapping("/records/batch")
public R<Void> batchDeleteRecords(@RequestBody List<Long> ids) {
ledgerService.batchDelete(ids);
return R.ok();
}
@Operation(summary = "重新判定所有记录")
@PostMapping("/records/reevaluate")
public R<Map<String, Object>> reevaluateAll() {
int count = ledgerService.reevaluateAll();
return R.ok(Map.of("updatedCount", count));
}
// ==================== 辅助 ====================
@Operation(summary = "获取区域列表")
@GetMapping("/areas")
public R<List<String>> getAreas() {
return R.ok(ledgerService.getAreaList());
}
@Operation(summary = "获取采样点列表")
@GetMapping("/sampling-points")
public R<List<String>> getSamplingPoints() {
return R.ok(ledgerService.getSamplingPointList());
}
// ==================== 标准 ====================
@Operation(summary = "获取启用的标准列表")
@GetMapping("/standards")
public R<List<QualityStandard>> listStandards() {
return R.ok(standardService.listEnabled());
}
@Operation(summary = "获取全部标准")
@GetMapping("/standards/all")
public R<List<QualityStandard>> listAllStandards() {
return R.ok(standardService.listAll());
}
@Operation(summary = "按水质类型获取标准")
@GetMapping("/standards/water-type/{waterType}")
public R<List<QualityStandard>> listStandardsByWaterType(@PathVariable String waterType) {
return R.ok(standardService.listByWaterType(waterType));
}
@Operation(summary = "获取标准详情")
@GetMapping("/standards/{id}")
public R<QualityStandard> getStandard(@PathVariable Long id) {
return R.ok(standardService.getById(id));
}
@Operation(summary = "创建标准")
@PostMapping("/standards")
public R<QualityStandard> createStandard(@RequestBody QualityStandard standard) {
return R.ok(standardService.create(standard));
}
@Operation(summary = "更新标准")
@PutMapping("/standards/{id}")
public R<QualityStandard> updateStandard(@PathVariable Long id, @RequestBody QualityStandard standard) {
return R.ok(standardService.update(id, standard));
}
@Operation(summary = "删除标准")
@DeleteMapping("/standards/{id}")
public R<Void> deleteStandard(@PathVariable Long id) {
standardService.delete(id);
return R.ok();
}
// ==================== 计划 ====================
@Operation(summary = "查询检测计划(分页)")
@GetMapping("/plans")
public R<Page<QualityTestPlan>> queryPlans(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) String testType,
@RequestParam(required = false) String waterType,
@RequestParam(required = false) String area,
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword) {
return R.ok(planService.queryPlans(pageNum, pageSize, testType, waterType, area, status, keyword));
}
@Operation(summary = "获取计划详情")
@GetMapping("/plans/{id}")
public R<QualityTestPlan> getPlan(@PathVariable Long id) {
return R.ok(planService.getById(id));
}
@Operation(summary = "创建计划")
@PostMapping("/plans")
public R<QualityTestPlan> createPlan(@RequestBody QualityTestPlan plan) {
return R.ok(planService.create(plan));
}
@Operation(summary = "更新计划")
@PutMapping("/plans/{id}")
public R<QualityTestPlan> updatePlan(@PathVariable Long id, @RequestBody QualityTestPlan plan) {
return R.ok(planService.update(id, plan));
}
@Operation(summary = "删除计划")
@DeleteMapping("/plans/{id}")
public R<Void> deletePlan(@PathVariable Long id) {
planService.delete(id);
return R.ok();
}
@Operation(summary = "切换计划状态")
@PutMapping("/plans/{id}/status")
public R<QualityTestPlan> togglePlanStatus(@PathVariable Long id) {
return R.ok(planService.toggleStatus(id));
}
@Operation(summary = "获取到期计划")
@GetMapping("/plans/due")
public R<List<QualityTestPlan>> getDuePlans() {
return R.ok(planService.getDuePlans());
}
@Operation(summary = "标记计划已执行")
@PostMapping("/plans/{id}/execute")
public R<QualityTestPlan> markPlanExecuted(@PathVariable Long id) {
return R.ok(planService.markExecuted(id));
}
// ==================== 统计 ====================
@Operation(summary = "获取统计数据")
@GetMapping("/statistics")
public R<QualityStatVO> getStatistics(
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate) {
return R.ok(ledgerService.getStatistics(startDate, endDate));
}
// ==================== 导出 ====================
@Operation(summary = "导出Excel")
@PostMapping("/export/excel")
public void exportExcel(QualityQueryRequest request, HttpServletResponse response) throws IOException {
ledgerService.exportExcel(request, response);
}
}
@@ -0,0 +1,49 @@
package com.water.production.dto;
import lombok.Data;
/**
* 水质检测查询请求
*/
@Data
public class QualityQueryRequest {
/** 检测类型 */
private String testType;
/** 水质类型 */
private String waterType;
/** 区域 */
private String area;
/** 采样点 */
private String samplingPoint;
/** 检测人 */
private String tester;
/** 合格状态 */
private String complianceStatus;
/** 开始日期 */
private String startDate;
/** 结束日期 */
private String endDate;
/** 关键字 */
private String keyword;
/** 排序字段 */
private String sortField;
/** 排序方向 */
private String sortOrder;
/** 页码 */
private Integer pageNum = 1;
/** 每页大小 */
private Integer pageSize = 10;
}
@@ -0,0 +1,52 @@
package com.water.production.dto;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 水质检测统计VO
*/
@Data
public class QualityStatVO {
/** 总记录数 */
private Long totalRecords;
/** 合格数 */
private Long qualifiedCount;
/** 不合格数 */
private Long unqualifiedCount;
/** 待判定数 */
private Long pendingCount;
/** 合格率 */
private Double qualifiedRate;
/** 按水质类型统计 */
private List<Map<String, Object>> byWaterType;
/** 按区域统计 */
private List<Map<String, Object>> byArea;
/** 按检测类型统计 */
private List<Map<String, Object>> byTestType;
/** 按日期趋势 */
private List<Map<String, Object>> trendByDate;
/** 不合格项目统计 */
private List<Map<String, Object>> unqualifiedItems;
/** 平均浊度 */
private Double avgTurbidity;
/** 平均pH */
private Double avgPh;
/** 平均余氯 */
private Double avgResidualChlorine;
}
@@ -0,0 +1,53 @@
package com.water.production.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 水质标准
*/
@Data
@TableName("prod_quality_standard")
public class QualityStandard {
@TableId(type = IdType.AUTO)
private Long id;
/** 标准名称 */
private String standardName;
/** 标准编号 */
private String standardCode;
/** 参数名 */
private String paramName;
/** 参数标签 */
private String paramLabel;
/** 参数单位 */
private String paramUnit;
/** 最小值 */
private Double minValue;
/** 最大值 */
private Double maxValue;
/** 水质类型 */
private String waterType;
/** 是否启用 */
private Boolean enabled;
@TableLogic
private Integer deleted;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}
@@ -0,0 +1,66 @@
package com.water.production.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 水质检测计划
*/
@Data
@TableName("prod_quality_test_plan")
public class QualityTestPlan {
@TableId(type = IdType.AUTO)
private Long id;
/** 计划名称 */
private String planName;
/** 检测类型 */
private String testType;
/** 水质类型 */
private String waterType;
/** 采样点 */
private String samplingPoint;
/** 区域 */
private String area;
/** 频率: daily/weekly/monthly */
private String frequency;
/** 检测参数 (JSON数组) */
private String testParams;
/** 开始日期 */
private LocalDate startDate;
/** 结束日期 */
private LocalDate endDate;
/** 下次检测日期 */
private LocalDate nextTestDate;
/** 状态: active/paused/expired */
private String status;
/** 执行次数 */
private Integer executionCount;
/** 备注 */
private String remark;
@TableLogic
private Integer deleted;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}
@@ -0,0 +1,79 @@
package com.water.production.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* 水质检测记录
*/
@Data
@TableName("prod_quality_test_record")
public class QualityTestRecord {
@TableId(type = IdType.AUTO)
private Long id;
/** 检测类型: routine/emergency/special */
private String testType;
/** 水质类型: rawWater/factoryWater/pipeNetworkWater/endUserWater */
private String waterType;
/** 采样点 */
private String samplingPoint;
/** 所属区域 */
private String area;
/** 检测日期 */
private LocalDate testDate;
/** 检测时间 */
private LocalTime testTime;
/** 检测人 */
private String tester;
/** 浊度 (NTU) */
private Double turbidity;
/** pH值 */
private Double ph;
/** 余氯 (mg/L) */
private Double residualChlorine;
/** 色度 (度) */
private Double color;
/** 嗅味 */
private String odor;
/** 大肠杆菌 (CFU/100mL) */
private Double ecoli;
/** 菌落总数 (CFU/mL) */
private Double colonyCount;
/** 合格状态: qualified/unqualified/pending */
private String complianceStatus;
/** 不合格项目 (JSON) */
private String unqualifiedItems;
/** 备注 */
private String remark;
@TableLogic
private Integer deleted;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}
@@ -0,0 +1,9 @@
package com.water.production.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.production.entity.QualityStandard;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface QualityStandardMapper extends BaseMapper<QualityStandard> {
}
@@ -0,0 +1,9 @@
package com.water.production.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.production.entity.QualityTestPlan;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface QualityTestPlanMapper extends BaseMapper<QualityTestPlan> {
}
@@ -0,0 +1,68 @@
package com.water.production.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.production.entity.QualityTestRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface QualityTestRecordMapper extends BaseMapper<QualityTestRecord> {
List<Map<String, Object>> selectRecordPage(
@Param("testType") String testType,
@Param("waterType") String waterType,
@Param("area") String area,
@Param("samplingPoint") String samplingPoint,
@Param("tester") String tester,
@Param("complianceStatus") String complianceStatus,
@Param("startDate") String startDate,
@Param("endDate") String endDate,
@Param("keyword") String keyword,
@Param("sortField") String sortField,
@Param("sortOrder") String sortOrder,
@Param("offset") int offset,
@Param("limit") int limit
);
Long countRecords(
@Param("testType") String testType,
@Param("waterType") String waterType,
@Param("area") String area,
@Param("samplingPoint") String samplingPoint,
@Param("tester") String tester,
@Param("complianceStatus") String complianceStatus,
@Param("startDate") String startDate,
@Param("endDate") String endDate,
@Param("keyword") String keyword
);
List<Map<String, Object>> statByComplianceStatus(
@Param("startDate") String startDate,
@Param("endDate") String endDate
);
List<Map<String, Object>> statRateByWaterType(
@Param("startDate") String startDate,
@Param("endDate") String endDate
);
List<Map<String, Object>> statRateByArea(
@Param("startDate") String startDate,
@Param("endDate") String endDate
);
List<Map<String, Object>> statUnqualifiedByParam(
@Param("startDate") String startDate,
@Param("endDate") String endDate
);
List<Map<String, Object>> statMonthlyTrend(
@Param("startDate") String startDate,
@Param("endDate") String endDate
);
Map<String, Object> statParamAvg();
}
@@ -0,0 +1,311 @@
package com.water.production.service;
import com.alibaba.excel.EasyExcel;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.common.core.exception.BusinessException;
import com.water.production.dto.QualityQueryRequest;
import com.water.production.dto.QualityStatVO;
import com.water.production.entity.QualityStandard;
import com.water.production.entity.QualityTestRecord;
import com.water.production.mapper.QualityTestRecordMapper;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class QualityLedgerService {
private final QualityTestRecordMapper recordMapper;
private final QualityStandardService standardService;
/**
* 分页查询检测记录
*/
public Map<String, Object> queryRecords(QualityQueryRequest request) {
int offset = (request.getPageNum() - 1) * request.getPageSize();
List<Map<String, Object>> records = recordMapper.selectRecordPage(
request.getTestType(), request.getWaterType(), request.getArea(),
request.getSamplingPoint(), request.getTester(), request.getComplianceStatus(),
request.getStartDate(), request.getEndDate(), request.getKeyword(),
request.getSortField(), request.getSortOrder(), offset, request.getPageSize()
);
Long total = recordMapper.countRecords(
request.getTestType(), request.getWaterType(), request.getArea(),
request.getSamplingPoint(), request.getTester(), request.getComplianceStatus(),
request.getStartDate(), request.getEndDate(), request.getKeyword()
);
Map<String, Object> result = new LinkedHashMap<>();
result.put("records", records);
result.put("total", total);
result.put("pageNum", request.getPageNum());
result.put("pageSize", request.getPageSize());
result.put("totalPages", (total + request.getPageSize() - 1) / request.getPageSize());
return result;
}
/**
* 获取记录详情
*/
public QualityTestRecord getById(Long id) {
QualityTestRecord record = recordMapper.selectById(id);
if (record == null) {
throw new BusinessException("检测记录不存在");
}
return record;
}
/**
* 创建检测记录(自动合格判定)
*/
@Transactional
public QualityTestRecord create(QualityTestRecord record) {
if (record.getTestDate() == null) record.setTestDate(LocalDate.now());
evaluateCompliance(record);
recordMapper.insert(record);
log.info("创建水质检测记录: {}/{}", record.getWaterType(), record.getSamplingPoint());
return record;
}
/**
* 更新检测记录
*/
@Transactional
public QualityTestRecord update(Long id, QualityTestRecord record) {
QualityTestRecord existing = getById(id);
record.setId(existing.getId());
evaluateCompliance(record);
recordMapper.updateById(record);
log.info("更新水质检测记录: {}", id);
return recordMapper.selectById(id);
}
/**
* 删除检测记录
*/
@Transactional
public void delete(Long id) {
getById(id);
recordMapper.deleteById(id);
log.info("删除水质检测记录: {}", id);
}
/**
* 批量删除
*/
@Transactional
public void batchDelete(List<Long> ids) {
if (ids == null || ids.isEmpty()) return;
recordMapper.deleteBatchIds(ids);
log.info("批量删除水质检测记录: {} 条", ids.size());
}
/**
* 对记录执行合格判定
*/
public void evaluateCompliance(QualityTestRecord record) {
if (record.getWaterType() == null) {
record.setComplianceStatus("pending");
return;
}
List<String> unqualified = new ArrayList<>();
checkParam(record.getWaterType(), "turbidity", record.getTurbidity(), "浊度", unqualified);
checkParam(record.getWaterType(), "ph", record.getPh(), "pH", unqualified);
checkParam(record.getWaterType(), "residualChlorine", record.getResidualChlorine(), "余氯", unqualified);
checkParam(record.getWaterType(), "color", record.getColor(), "色度", unqualified);
checkParam(record.getWaterType(), "ecoli", record.getEcoli(), "大肠杆菌", unqualified);
checkParam(record.getWaterType(), "colonyCount", record.getColonyCount(), "菌落总数", unqualified);
if (unqualified.isEmpty()) {
record.setComplianceStatus("qualified");
record.setUnqualifiedItems(null);
} else {
record.setComplianceStatus("unqualified");
record.setUnqualifiedItems("[\"" + String.join("\",\"", unqualified) + "\"]");
}
}
private void checkParam(String waterType, String paramName, Double value, String label,
List<String> unqualified) {
if (value == null) return;
QualityStandard standard = standardService.getStandard(waterType, paramName);
if (standard == null) return;
if (standard.getMinValue() != null && value < standard.getMinValue()) {
unqualified.add(label + "(偏低)");
}
if (standard.getMaxValue() != null && value > standard.getMaxValue()) {
unqualified.add(label + "(偏高)");
}
}
/**
* 重新判定所有记录
*/
@Transactional
public int reevaluateAll() {
LambdaQueryWrapper<QualityTestRecord> wrapper = new LambdaQueryWrapper<>();
wrapper.in(QualityTestRecord::getComplianceStatus, "pending", "qualified", "unqualified");
List<QualityTestRecord> records = recordMapper.selectList(wrapper);
int count = 0;
for (QualityTestRecord record : records) {
String oldStatus = record.getComplianceStatus();
evaluateCompliance(record);
if (!oldStatus.equals(record.getComplianceStatus())) {
recordMapper.updateById(record);
count++;
}
}
log.info("重新判定完成,共 {} 条记录状态变更", count);
return count;
}
/**
* 获取统计数据
*/
public QualityStatVO getStatistics(String startDate, String endDate) {
QualityStatVO vo = new QualityStatVO();
// 按合格状态统计
List<Map<String, Object>> statusStats = recordMapper.statByComplianceStatus(startDate, endDate);
long total = 0, qualified = 0, unqualified = 0, pending = 0;
for (Map<String, Object> row : statusStats) {
String status = String.valueOf(row.get("status"));
long cnt = ((Number) row.get("count")).longValue();
total += cnt;
switch (status) {
case "qualified" -> qualified = cnt;
case "unqualified" -> unqualified = cnt;
case "pending" -> pending = cnt;
}
}
vo.setTotalRecords(total);
vo.setQualifiedCount(qualified);
vo.setUnqualifiedCount(unqualified);
vo.setPendingCount(pending);
vo.setQualifiedRate(total > 0 ? Math.round(qualified * 10000.0 / total) / 100.0 : 0.0);
// 按水质类型
vo.setByWaterType(recordMapper.statRateByWaterType(startDate, endDate));
// 按区域
vo.setByArea(recordMapper.statRateByArea(startDate, endDate));
// 按检测类型
vo.setByTestType(recordMapper.statByComplianceStatus(startDate, endDate));
// 月度趋势
vo.setTrendByDate(recordMapper.statMonthlyTrend(startDate, endDate));
// 不合格项
vo.setUnqualifiedItems(recordMapper.statUnqualifiedByParam(startDate, endDate));
// 参数均值
Map<String, Object> avgMap = recordMapper.statParamAvg();
if (avgMap != null) {
vo.setAvgTurbidity(toDouble(avgMap.get("avgTurbidity")));
vo.setAvgPh(toDouble(avgMap.get("avgPh")));
vo.setAvgResidualChlorine(toDouble(avgMap.get("avgResidualChlorine")));
}
return vo;
}
private Double toDouble(Object val) {
if (val == null) return null;
if (val instanceof Number) return ((Number) val).doubleValue();
return null;
}
/**
* 导出Excel
*/
public void exportExcel(QualityQueryRequest request, HttpServletResponse response) throws IOException {
request.setPageNum(1);
request.setPageSize(100000);
Map<String, Object> result = queryRecords(request);
@SuppressWarnings("unchecked")
List<Map<String, Object>> records = (List<Map<String, Object>>) result.get("records");
List<List<String>> excelData = records.stream().map(r -> {
List<String> row = new ArrayList<>();
row.add(String.valueOf(r.getOrDefault("testType", "")));
row.add(String.valueOf(r.getOrDefault("waterType", "")));
row.add(String.valueOf(r.getOrDefault("samplingPoint", "")));
row.add(String.valueOf(r.getOrDefault("area", "")));
row.add(String.valueOf(r.getOrDefault("testDate", "")));
row.add(String.valueOf(r.getOrDefault("tester", "")));
row.add(String.valueOf(r.getOrDefault("turbidity", "")));
row.add(String.valueOf(r.getOrDefault("ph", "")));
row.add(String.valueOf(r.getOrDefault("residualChlorine", "")));
row.add(String.valueOf(r.getOrDefault("color", "")));
row.add(String.valueOf(r.getOrDefault("odor", "")));
row.add(String.valueOf(r.getOrDefault("ecoli", "")));
row.add(String.valueOf(r.getOrDefault("colonyCount", "")));
row.add(String.valueOf(r.getOrDefault("complianceStatus", "")));
row.add(String.valueOf(r.getOrDefault("unqualifiedItems", "")));
row.add(String.valueOf(r.getOrDefault("remark", "")));
return row;
}).collect(Collectors.toList());
List<List<String>> head = List.of(
List.of("检测类型"), List.of("水质类型"), List.of("采样点"), List.of("区域"),
List.of("检测日期"), List.of("检测人"), List.of("浊度"), List.of("pH"),
List.of("余氯"), List.of("色度"), List.of("嗅味"), List.of("大肠杆菌"),
List.of("菌落总数"), List.of("合格状态"), List.of("不合格项"), List.of("备注")
);
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition",
"attachment;filename=" + URLEncoder.encode("水质检测报告.xlsx", StandardCharsets.UTF_8));
EasyExcel.write(response.getOutputStream())
.head(head)
.sheet("检测记录")
.doWrite(excelData.stream().map(row -> (List<Object>) (List<?>) row).collect(Collectors.toList()));
}
/**
* 获取区域列表
*/
public List<String> getAreaList() {
LambdaQueryWrapper<QualityTestRecord> wrapper = new LambdaQueryWrapper<>();
wrapper.select(QualityTestRecord::getArea)
.isNotNull(QualityTestRecord::getArea)
.ne(QualityTestRecord::getArea, "")
.groupBy(QualityTestRecord::getArea);
return recordMapper.selectList(wrapper).stream()
.map(QualityTestRecord::getArea)
.distinct()
.sorted()
.collect(Collectors.toList());
}
/**
* 获取采样点列表
*/
public List<String> getSamplingPointList() {
LambdaQueryWrapper<QualityTestRecord> wrapper = new LambdaQueryWrapper<>();
wrapper.select(QualityTestRecord::getSamplingPoint)
.isNotNull(QualityTestRecord::getSamplingPoint)
.ne(QualityTestRecord::getSamplingPoint, "")
.groupBy(QualityTestRecord::getSamplingPoint);
return recordMapper.selectList(wrapper).stream()
.map(QualityTestRecord::getSamplingPoint)
.distinct()
.sorted()
.collect(Collectors.toList());
}
}
@@ -0,0 +1,105 @@
package com.water.production.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.common.core.exception.BusinessException;
import com.water.production.entity.QualityStandard;
import com.water.production.mapper.QualityStandardMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class QualityStandardService {
private final QualityStandardMapper standardMapper;
/**
* 获取所有启用的标准
*/
public List<QualityStandard> listEnabled() {
LambdaQueryWrapper<QualityStandard> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(QualityStandard::getEnabled, true)
.orderByAsc(QualityStandard::getWaterType, QualityStandard::getParamName);
return standardMapper.selectList(wrapper);
}
/**
* 按水质类型获取标准
*/
public List<QualityStandard> listByWaterType(String waterType) {
LambdaQueryWrapper<QualityStandard> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(QualityStandard::getEnabled, true)
.eq(QualityStandard::getWaterType, waterType)
.orderByAsc(QualityStandard::getParamName);
return standardMapper.selectList(wrapper);
}
/**
* 获取全部标准(含禁用)
*/
public List<QualityStandard> listAll() {
LambdaQueryWrapper<QualityStandard> wrapper = new LambdaQueryWrapper<>();
wrapper.orderByAsc(QualityStandard::getWaterType, QualityStandard::getParamName);
return standardMapper.selectList(wrapper);
}
/**
* 获取标准详情
*/
public QualityStandard getById(Long id) {
QualityStandard standard = standardMapper.selectById(id);
if (standard == null) {
throw new BusinessException("标准不存在");
}
return standard;
}
/**
* 获取指定水质类型和参数名的标准
*/
public QualityStandard getStandard(String waterType, String paramName) {
LambdaQueryWrapper<QualityStandard> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(QualityStandard::getWaterType, waterType)
.eq(QualityStandard::getParamName, paramName)
.eq(QualityStandard::getEnabled, true)
.last("LIMIT 1");
return standardMapper.selectOne(wrapper);
}
/**
* 创建标准
*/
@Transactional
public QualityStandard create(QualityStandard standard) {
standardMapper.insert(standard);
log.info("创建水质标准: {} - {}", standard.getStandardName(), standard.getParamLabel());
return standard;
}
/**
* 更新标准
*/
@Transactional
public QualityStandard update(Long id, QualityStandard standard) {
QualityStandard existing = getById(id);
standard.setId(existing.getId());
standardMapper.updateById(standard);
log.info("更新水质标准: {}", id);
return standardMapper.selectById(id);
}
/**
* 删除标准
*/
@Transactional
public void delete(Long id) {
getById(id);
standardMapper.deleteById(id);
log.info("删除水质标准: {}", id);
}
}
@@ -0,0 +1,144 @@
package com.water.production.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.common.core.exception.BusinessException;
import com.water.production.entity.QualityTestPlan;
import com.water.production.mapper.QualityTestPlanMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class QualityTestPlanService {
private final QualityTestPlanMapper planMapper;
/**
* 分页查询计划
*/
public Page<QualityTestPlan> queryPlans(int pageNum, int pageSize, String testType, String waterType,
String area, String status, String keyword) {
Page<QualityTestPlan> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<QualityTestPlan> wrapper = new LambdaQueryWrapper<>();
if (testType != null && !testType.isBlank()) wrapper.eq(QualityTestPlan::getTestType, testType);
if (waterType != null && !waterType.isBlank()) wrapper.eq(QualityTestPlan::getWaterType, waterType);
if (area != null && !area.isBlank()) wrapper.eq(QualityTestPlan::getArea, area);
if (status != null && !status.isBlank()) wrapper.eq(QualityTestPlan::getStatus, status);
if (keyword != null && !keyword.isBlank()) {
wrapper.and(w -> w.like(QualityTestPlan::getPlanName, keyword)
.or().like(QualityTestPlan::getSamplingPoint, keyword));
}
wrapper.orderByDesc(QualityTestPlan::getCreatedAt);
return planMapper.selectPage(page, wrapper);
}
/**
* 获取计划详情
*/
public QualityTestPlan getById(Long id) {
QualityTestPlan plan = planMapper.selectById(id);
if (plan == null) {
throw new BusinessException("检测计划不存在");
}
return plan;
}
/**
* 创建计划
*/
@Transactional
public QualityTestPlan create(QualityTestPlan plan) {
if (plan.getStatus() == null) plan.setStatus("active");
if (plan.getExecutionCount() == null) plan.setExecutionCount(0);
if (plan.getNextTestDate() == null) plan.setNextTestDate(plan.getStartDate());
planMapper.insert(plan);
log.info("创建检测计划: {}", plan.getPlanName());
return plan;
}
/**
* 更新计划
*/
@Transactional
public QualityTestPlan update(Long id, QualityTestPlan plan) {
QualityTestPlan existing = getById(id);
plan.setId(existing.getId());
planMapper.updateById(plan);
log.info("更新检测计划: {}", id);
return planMapper.selectById(id);
}
/**
* 删除计划
*/
@Transactional
public void delete(Long id) {
getById(id);
planMapper.deleteById(id);
log.info("删除检测计划: {}", id);
}
/**
* 切换计划状态
*/
@Transactional
public QualityTestPlan toggleStatus(Long id) {
QualityTestPlan plan = getById(id);
if ("active".equals(plan.getStatus())) {
plan.setStatus("paused");
} else if ("paused".equals(plan.getStatus())) {
plan.setStatus("active");
}
planMapper.updateById(plan);
log.info("切换检测计划状态: {} -> {}", id, plan.getStatus());
return plan;
}
/**
* 获取到期计划
*/
public List<QualityTestPlan> getDuePlans() {
LambdaQueryWrapper<QualityTestPlan> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(QualityTestPlan::getStatus, "active")
.le(QualityTestPlan::getNextTestDate, LocalDate.now())
.orderByAsc(QualityTestPlan::getNextTestDate);
return planMapper.selectList(wrapper);
}
/**
* 标记已执行,计算下次检测日期
*/
@Transactional
public QualityTestPlan markExecuted(Long id) {
QualityTestPlan plan = getById(id);
plan.setExecutionCount(plan.getExecutionCount() + 1);
// 计算下次检测日期
LocalDate nextDate = plan.getNextTestDate();
if (nextDate == null) nextDate = LocalDate.now();
switch (plan.getFrequency() != null ? plan.getFrequency() : "daily") {
case "daily" -> nextDate = nextDate.plusDays(1);
case "weekly" -> nextDate = nextDate.plusWeeks(1);
case "monthly" -> nextDate = nextDate.plusMonths(1);
default -> nextDate = nextDate.plusDays(1);
}
plan.setNextTestDate(nextDate);
// 如果下次检测超过结束日期,自动过期
if (plan.getEndDate() != null && nextDate.isAfter(plan.getEndDate())) {
plan.setStatus("expired");
}
planMapper.updateById(plan);
log.info("标记检测计划已执行: {}, 下次检测: {}", id, nextDate);
return plan;
}
}
@@ -0,0 +1,134 @@
-- ============================================================
-- V4: 水质检测台账 (GB5749-2022)
-- ============================================================
-- 1. 水质检测记录表
CREATE TABLE IF NOT EXISTS prod_quality_test_record (
id BIGSERIAL PRIMARY KEY,
test_type VARCHAR(50) NOT NULL DEFAULT 'routine',
water_type VARCHAR(50) NOT NULL,
sampling_point VARCHAR(200),
area VARCHAR(200),
test_date DATE NOT NULL DEFAULT CURRENT_DATE,
test_time TIME,
tester VARCHAR(100),
turbidity NUMERIC(10,4),
ph NUMERIC(6,3),
residual_chlorine NUMERIC(10,4),
color NUMERIC(10,2),
odor VARCHAR(100),
ecoli NUMERIC(12,2),
colony_count NUMERIC(12,2),
compliance_status VARCHAR(20) DEFAULT 'pending',
unqualified_items TEXT,
remark TEXT,
deleted INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_quality_record_test_type ON prod_quality_test_record(test_type);
CREATE INDEX IF NOT EXISTS idx_quality_record_water_type ON prod_quality_test_record(water_type);
CREATE INDEX IF NOT EXISTS idx_quality_record_test_date ON prod_quality_test_record(test_date);
CREATE INDEX IF NOT EXISTS idx_quality_record_area ON prod_quality_test_record(area);
CREATE INDEX IF NOT EXISTS idx_quality_record_compliance ON prod_quality_test_record(compliance_status);
CREATE INDEX IF NOT EXISTS idx_quality_record_deleted ON prod_quality_test_record(deleted);
COMMENT ON TABLE prod_quality_test_record IS '水质检测记录表';
COMMENT ON COLUMN prod_quality_test_record.test_type IS '检测类型: routine/emergency/special';
COMMENT ON COLUMN prod_quality_test_record.water_type IS '水质类型: rawWater/factoryWater/pipeNetworkWater/endUserWater';
COMMENT ON COLUMN prod_quality_test_record.compliance_status IS '合格状态: qualified/unqualified/pending';
-- 2. 水质标准表
CREATE TABLE IF NOT EXISTS prod_quality_standard (
id BIGSERIAL PRIMARY KEY,
standard_name VARCHAR(200) NOT NULL,
standard_code VARCHAR(100),
param_name VARCHAR(100) NOT NULL,
param_label VARCHAR(100) NOT NULL,
param_unit VARCHAR(50),
min_value NUMERIC(12,4),
max_value NUMERIC(12,4),
water_type VARCHAR(50) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
deleted INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_quality_standard_water_type ON prod_quality_standard(water_type);
CREATE INDEX IF NOT EXISTS idx_quality_standard_param ON prod_quality_standard(param_name);
CREATE INDEX IF NOT EXISTS idx_quality_standard_enabled ON prod_quality_standard(enabled);
CREATE INDEX IF NOT EXISTS idx_quality_standard_deleted ON prod_quality_standard(deleted);
COMMENT ON TABLE prod_quality_standard IS '水质标准表';
COMMENT ON COLUMN prod_quality_standard.water_type IS '水质类型: rawWater/factoryWater/pipeNetworkWater/endUserWater';
-- 3. 水质检测计划表
CREATE TABLE IF NOT EXISTS prod_quality_test_plan (
id BIGSERIAL PRIMARY KEY,
plan_name VARCHAR(200) NOT NULL,
test_type VARCHAR(50) NOT NULL DEFAULT 'routine',
water_type VARCHAR(50) NOT NULL,
sampling_point VARCHAR(200),
area VARCHAR(200),
frequency VARCHAR(20) NOT NULL DEFAULT 'daily',
test_params TEXT,
start_date DATE NOT NULL,
end_date DATE,
next_test_date DATE,
status VARCHAR(20) NOT NULL DEFAULT 'active',
execution_count INTEGER NOT NULL DEFAULT 0,
remark TEXT,
deleted INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_quality_plan_water_type ON prod_quality_test_plan(water_type);
CREATE INDEX IF NOT EXISTS idx_quality_plan_status ON prod_quality_test_plan(status);
CREATE INDEX IF NOT EXISTS idx_quality_plan_next_date ON prod_quality_test_plan(next_test_date);
CREATE INDEX IF NOT EXISTS idx_quality_plan_deleted ON prod_quality_test_plan(deleted);
COMMENT ON TABLE prod_quality_test_plan IS '水质检测计划表';
COMMENT ON COLUMN prod_quality_test_plan.frequency IS '检测频率: daily/weekly/monthly';
COMMENT ON COLUMN prod_quality_test_plan.status IS '状态: active/paused/expired';
-- ============================================================
-- GB5749-2022 默认标准数据
-- ============================================================
-- 原水 (rawWater) 标准
INSERT INTO prod_quality_standard (standard_name, standard_code, param_name, param_label, param_unit, min_value, max_value, water_type, enabled) VALUES
('GB5749-2022', 'GB5749-2022', 'turbidity', '浊度', 'NTU', NULL, 4.0, 'rawWater', TRUE),
('GB5749-2022', 'GB5749-2022', 'ph', 'pH值', '', 6.5, 8.5, 'rawWater', TRUE),
('GB5749-2022', 'GB5749-2022', 'color', '色度', '度', NULL, 15.0, 'rawWater', TRUE),
('GB5749-2022', 'GB5749-2022', 'ecoli', '大肠杆菌', 'CFU/100mL', NULL, 10000.0, 'rawWater', TRUE),
('GB5749-2022', 'GB5749-2022', 'colonyCount', '菌落总数', 'CFU/mL', NULL, 500.0, 'rawWater', TRUE);
-- 出厂水 (factoryWater) 标准
INSERT INTO prod_quality_standard (standard_name, standard_code, param_name, param_label, param_unit, min_value, max_value, water_type, enabled) VALUES
('GB5749-2022', 'GB5749-2022', 'turbidity', '浊度', 'NTU', NULL, 1.0, 'factoryWater', TRUE),
('GB5749-2022', 'GB5749-2022', 'ph', 'pH值', '', 6.5, 8.5, 'factoryWater', TRUE),
('GB5749-2022', 'GB5749-2022', 'residualChlorine', '余氯', 'mg/L', 0.3, 4.0, 'factoryWater', TRUE),
('GB5749-2022', 'GB5749-2022', 'color', '色度', '度', NULL, 15.0, 'factoryWater', TRUE),
('GB5749-2022', 'GB5749-2022', 'ecoli', '大肠杆菌', 'CFU/100mL', NULL, 0.0, 'factoryWater', TRUE),
('GB5749-2022', 'GB5749-2022', 'colonyCount', '菌落总数', 'CFU/mL', NULL, 100.0, 'factoryWater', TRUE);
-- 管网水 (pipeNetworkWater) 标准
INSERT INTO prod_quality_standard (standard_name, standard_code, param_name, param_label, param_unit, min_value, max_value, water_type, enabled) VALUES
('GB5749-2022', 'GB5749-2022', 'turbidity', '浊度', 'NTU', NULL, 1.0, 'pipeNetworkWater', TRUE),
('GB5749-2022', 'GB5749-2022', 'ph', 'pH值', '', 6.5, 8.5, 'pipeNetworkWater', TRUE),
('GB5749-2022', 'GB5749-2022', 'residualChlorine', '余氯', 'mg/L', 0.05, 4.0, 'pipeNetworkWater', TRUE),
('GB5749-2022', 'GB5749-2022', 'color', '色度', '度', NULL, 15.0, 'pipeNetworkWater', TRUE),
('GB5749-2022', 'GB5749-2022', 'ecoli', '大肠杆菌', 'CFU/100mL', NULL, 0.0, 'pipeNetworkWater', TRUE),
('GB5749-2022', 'GB5749-2022', 'colonyCount', '菌落总数', 'CFU/mL', NULL, 100.0, 'pipeNetworkWater', TRUE);
-- 末梢水 (endUserWater) 标准
INSERT INTO prod_quality_standard (standard_name, standard_code, param_name, param_label, param_unit, min_value, max_value, water_type, enabled) VALUES
('GB5749-2022', 'GB5749-2022', 'turbidity', '浊度', 'NTU', NULL, 1.0, 'endUserWater', TRUE),
('GB5749-2022', 'GB5749-2022', 'ph', 'pH值', '', 6.5, 8.5, 'endUserWater', TRUE),
('GB5749-2022', 'GB5749-2022', 'residualChlorine', '余氯', 'mg/L', 0.05, 4.0, 'endUserWater', TRUE),
('GB5749-2022', 'GB5749-2022', 'color', '色度', '度', NULL, 15.0, 'endUserWater', TRUE),
('GB5749-2022', 'GB5749-2022', 'ecoli', '大肠杆菌', 'CFU/100mL', NULL, 0.0, 'endUserWater', TRUE),
('GB5749-2022', 'GB5749-2022', 'colonyCount', '菌落总数', 'CFU/mL', NULL, 100.0, 'endUserWater', TRUE);
@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.water.production.mapper.QualityTestRecordMapper">
<select id="selectRecordPage" resultType="java.util.Map">
SELECT
id, test_type, water_type, sampling_point, area, test_date, test_time,
tester, turbidity, ph, residual_chlorine, color, odor, ecoli, colony_count,
compliance_status, unqualified_items, remark, created_at, updated_at
FROM prod_quality_test_record
WHERE deleted = 0
<if test="testType != null and testType != ''">AND test_type = #{testType}</if>
<if test="waterType != null and waterType != ''">AND water_type = #{waterType}</if>
<if test="area != null and area != ''">AND area = #{area}</if>
<if test="samplingPoint != null and samplingPoint != ''">AND sampling_point = #{samplingPoint}</if>
<if test="tester != null and tester != ''">AND tester LIKE '%' || #{tester} || '%'</if>
<if test="complianceStatus != null and complianceStatus != ''">AND compliance_status = #{complianceStatus}</if>
<if test="startDate != null and startDate != ''">AND test_date &gt;= #{startDate}::date</if>
<if test="endDate != null and endDate != ''">AND test_date &lt;= #{endDate}::date</if>
<if test="keyword != null and keyword != ''">
AND (sampling_point LIKE '%' || #{keyword} || '%' OR tester LIKE '%' || #{keyword} || '%' OR remark LIKE '%' || #{keyword} || '%')
</if>
<choose>
<when test="sortField != null and sortField != '' and sortOrder != null and sortOrder == 'asc'">
ORDER BY ${sortField} ASC
</when>
<when test="sortField != null and sortField != '' and sortOrder != null and sortOrder == 'desc'">
ORDER BY ${sortField} DESC
</when>
<otherwise>ORDER BY created_at DESC</otherwise>
</choose>
OFFSET #{offset} LIMIT #{limit}
</select>
<select id="countRecords" resultType="java.lang.Long">
SELECT COUNT(*)
FROM prod_quality_test_record
WHERE deleted = 0
<if test="testType != null and testType != ''">AND test_type = #{testType}</if>
<if test="waterType != null and waterType != ''">AND water_type = #{waterType}</if>
<if test="area != null and area != ''">AND area = #{area}</if>
<if test="samplingPoint != null and samplingPoint != ''">AND sampling_point = #{samplingPoint}</if>
<if test="tester != null and tester != ''">AND tester LIKE '%' || #{tester} || '%'</if>
<if test="complianceStatus != null and complianceStatus != ''">AND compliance_status = #{complianceStatus}</if>
<if test="startDate != null and startDate != ''">AND test_date &gt;= #{startDate}::date</if>
<if test="endDate != null and endDate != ''">AND test_date &lt;= #{endDate}::date</if>
<if test="keyword != null and keyword != ''">
AND (sampling_point LIKE '%' || #{keyword} || '%' OR tester LIKE '%' || #{keyword} || '%' OR remark LIKE '%' || #{keyword} || '%')
</if>
</select>
<select id="statByComplianceStatus" resultType="java.util.Map">
SELECT compliance_status AS status, COUNT(*) AS count
FROM prod_quality_test_record
WHERE deleted = 0
<if test="startDate != null and startDate != ''">AND test_date &gt;= #{startDate}::date</if>
<if test="endDate != null and endDate != ''">AND test_date &lt;= #{endDate}::date</if>
GROUP BY compliance_status
</select>
<select id="statRateByWaterType" resultType="java.util.Map">
SELECT
water_type AS waterType,
COUNT(*) AS total,
COUNT(CASE WHEN compliance_status = 'qualified' THEN 1 END) AS qualified,
ROUND(COUNT(CASE WHEN compliance_status = 'qualified' THEN 1 END) * 100.0 / NULLIF(COUNT(*), 0), 2) AS rate
FROM prod_quality_test_record
WHERE deleted = 0
<if test="startDate != null and startDate != ''">AND test_date &gt;= #{startDate}::date</if>
<if test="endDate != null and endDate != ''">AND test_date &lt;= #{endDate}::date</if>
GROUP BY water_type
</select>
<select id="statRateByArea" resultType="java.util.Map">
SELECT
area,
COUNT(*) AS total,
COUNT(CASE WHEN compliance_status = 'qualified' THEN 1 END) AS qualified,
ROUND(COUNT(CASE WHEN compliance_status = 'qualified' THEN 1 END) * 100.0 / NULLIF(COUNT(*), 0), 2) AS rate
FROM prod_quality_test_record
WHERE deleted = 0
<if test="startDate != null and startDate != ''">AND test_date &gt;= #{startDate}::date</if>
<if test="endDate != null and endDate != ''">AND test_date &lt;= #{endDate}::date</if>
GROUP BY area
</select>
<select id="statUnqualifiedByParam" resultType="java.util.Map">
SELECT
jsonb_array_elements_text(unqualified_items::jsonb) AS param,
COUNT(*) AS count
FROM prod_quality_test_record
WHERE deleted = 0 AND compliance_status = 'unqualified' AND unqualified_items IS NOT NULL
<if test="startDate != null and startDate != ''">AND test_date &gt;= #{startDate}::date</if>
<if test="endDate != null and endDate != ''">AND test_date &lt;= #{endDate}::date</if>
GROUP BY param
ORDER BY count DESC
</select>
<select id="statMonthlyTrend" resultType="java.util.Map">
SELECT
TO_CHAR(test_date, 'YYYY-MM') AS month,
COUNT(*) AS total,
COUNT(CASE WHEN compliance_status = 'qualified' THEN 1 END) AS qualified,
ROUND(COUNT(CASE WHEN compliance_status = 'qualified' THEN 1 END) * 100.0 / NULLIF(COUNT(*), 0), 2) AS rate
FROM prod_quality_test_record
WHERE deleted = 0
<if test="startDate != null and startDate != ''">AND test_date &gt;= #{startDate}::date</if>
<if test="endDate != null and endDate != ''">AND test_date &lt;= #{endDate}::date</if>
GROUP BY month
ORDER BY month
</select>
<select id="statParamAvg" resultType="java.util.Map">
SELECT
ROUND(AVG(turbidity)::numeric, 4) AS avgTurbidity,
ROUND(AVG(ph)::numeric, 4) AS avgPh,
ROUND(AVG(residual_chlorine)::numeric, 4) AS avgResidualChlorine
FROM prod_quality_test_record
WHERE deleted = 0 AND compliance_status IS NOT NULL
</select>
</mapper>
@@ -0,0 +1,399 @@
package com.water.production.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.production.dto.QualityQueryRequest;
import com.water.production.dto.QualityStatVO;
import com.water.production.entity.QualityStandard;
import com.water.production.entity.QualityTestPlan;
import com.water.production.entity.QualityTestRecord;
import com.water.production.mapper.QualityStandardMapper;
import com.water.production.mapper.QualityTestPlanMapper;
import com.water.production.mapper.QualityTestRecordMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class QualityLedgerServiceTest {
@Mock
private QualityTestRecordMapper recordMapper;
@Mock
private QualityStandardService standardService;
@Mock
private QualityTestPlanMapper planMapper;
@Mock
private QualityStandardMapper standardMapper;
@InjectMocks
private QualityLedgerService ledgerService;
private QualityTestPlanService planService;
private QualityTestRecord sampleRecord;
@BeforeEach
void setUp() {
planService = new QualityTestPlanService(planMapper);
sampleRecord = new QualityTestRecord();
sampleRecord.setId(1L);
sampleRecord.setTestType("routine");
sampleRecord.setWaterType("factoryWater");
sampleRecord.setSamplingPoint("出厂水采样点A");
sampleRecord.setArea("城区A");
sampleRecord.setTestDate(LocalDate.now());
sampleRecord.setTestTime(LocalTime.of(10, 0));
sampleRecord.setTester("张三");
sampleRecord.setTurbidity(0.5);
sampleRecord.setPh(7.2);
sampleRecord.setResidualChlorine(0.8);
sampleRecord.setColor(5.0);
sampleRecord.setOdor("无");
sampleRecord.setEcoli(0.0);
sampleRecord.setColonyCount(20.0);
}
// ==================== 记录 CRUD 测试 ====================
@Test
@DisplayName("1. 创建检测记录-自动判定合格")
void testCreateRecord_Qualified() {
when(standardService.getStandard("factoryWater", "turbidity"))
.thenReturn(buildStandard("turbidity", null, 1.0));
when(standardService.getStandard("factoryWater", "ph"))
.thenReturn(buildStandard("ph", 6.5, 8.5));
when(standardService.getStandard("factoryWater", "residualChlorine"))
.thenReturn(buildStandard("residualChlorine", 0.3, 4.0));
when(standardService.getStandard("factoryWater", "color"))
.thenReturn(buildStandard("color", null, 15.0));
when(standardService.getStandard("factoryWater", "ecoli"))
.thenReturn(buildStandard("ecoli", null, 0.0));
when(standardService.getStandard("factoryWater", "colonyCount"))
.thenReturn(buildStandard("colonyCount", null, 100.0));
when(recordMapper.insert(any())).thenReturn(1);
QualityTestRecord result = ledgerService.create(sampleRecord);
assertEquals("qualified", result.getComplianceStatus());
assertNull(result.getUnqualifiedItems());
verify(recordMapper).insert(sampleRecord);
}
@Test
@DisplayName("2. 创建检测记录-自动判定不合格")
void testCreateRecord_Unqualified() {
sampleRecord.setTurbidity(2.5); // 超标
sampleRecord.setEcoli(10.0); // 超标
when(standardService.getStandard("factoryWater", "turbidity"))
.thenReturn(buildStandard("turbidity", null, 1.0));
when(standardService.getStandard("factoryWater", "ph"))
.thenReturn(buildStandard("ph", 6.5, 8.5));
when(standardService.getStandard("factoryWater", "residualChlorine"))
.thenReturn(buildStandard("residualChlorine", 0.3, 4.0));
when(standardService.getStandard("factoryWater", "color"))
.thenReturn(buildStandard("color", null, 15.0));
when(standardService.getStandard("factoryWater", "ecoli"))
.thenReturn(buildStandard("ecoli", null, 0.0));
when(standardService.getStandard("factoryWater", "colonyCount"))
.thenReturn(buildStandard("colonyCount", null, 100.0));
when(recordMapper.insert(any())).thenReturn(1);
QualityTestRecord result = ledgerService.create(sampleRecord);
assertEquals("unqualified", result.getComplianceStatus());
assertNotNull(result.getUnqualifiedItems());
assertTrue(result.getUnqualifiedItems().contains("浊度"));
assertTrue(result.getUnqualifiedItems().contains("大肠杆菌"));
}
@Test
@DisplayName("3. 创建检测记录-waterType为空时pending")
void testCreateRecord_PendingWhenNoWaterType() {
sampleRecord.setWaterType(null);
when(recordMapper.insert(any())).thenReturn(1);
QualityTestRecord result = ledgerService.create(sampleRecord);
assertEquals("pending", result.getComplianceStatus());
}
@Test
@DisplayName("4. 查询检测记录分页")
void testQueryRecords() {
QualityQueryRequest request = new QualityQueryRequest();
request.setPageNum(1);
request.setPageSize(10);
List<Map<String, Object>> mockRecords = List.of(
Map.of("id", 1L, "testType", "routine"),
Map.of("id", 2L, "testType", "emergency")
);
when(recordMapper.selectRecordPage(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), eq(0), eq(10)))
.thenReturn(mockRecords);
when(recordMapper.countRecords(any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(2L);
Map<String, Object> result = ledgerService.queryRecords(request);
assertNotNull(result);
assertEquals(2L, result.get("total"));
assertEquals(1, result.get("pageNum"));
assertEquals(1L, result.get("totalPages"));
}
@Test
@DisplayName("5. 获取记录详情")
void testGetById() {
when(recordMapper.selectById(1L)).thenReturn(sampleRecord);
QualityTestRecord result = ledgerService.getById(1L);
assertNotNull(result);
assertEquals(1L, result.getId());
assertEquals("routine", result.getTestType());
}
@Test
@DisplayName("6. 获取不存在记录抛异常")
void testGetById_NotFound() {
when(recordMapper.selectById(999L)).thenReturn(null);
assertThrows(Exception.class, () -> ledgerService.getById(999L));
}
@Test
@DisplayName("7. 更新检测记录")
void testUpdateRecord() {
when(recordMapper.selectById(1L)).thenReturn(sampleRecord);
when(standardService.getStandard(anyString(), anyString())).thenReturn(null);
when(recordMapper.updateById(any())).thenReturn(1);
when(recordMapper.selectById(1L)).thenReturn(sampleRecord);
sampleRecord.setRemark("updated");
QualityTestRecord result = ledgerService.update(1L, sampleRecord);
assertNotNull(result);
verify(recordMapper).updateById(any());
}
@Test
@DisplayName("8. 删除检测记录")
void testDeleteRecord() {
when(recordMapper.selectById(1L)).thenReturn(sampleRecord);
when(recordMapper.deleteById(1L)).thenReturn(1);
ledgerService.delete(1L);
verify(recordMapper).deleteById(1L);
}
@Test
@DisplayName("9. 批量删除记录")
void testBatchDelete() {
List<Long> ids = List.of(1L, 2L, 3L);
when(recordMapper.deleteBatchIds(ids)).thenReturn(3);
ledgerService.batchDelete(ids);
verify(recordMapper).deleteBatchIds(ids);
}
// ==================== 合格判定测试 ====================
@Test
@DisplayName("10. 合格判定-pH偏低")
void testEvaluateCompliance_PhLow() {
sampleRecord.setPh(5.0);
when(standardService.getStandard("factoryWater", "turbidity")).thenReturn(buildStandard("turbidity", null, 1.0));
when(standardService.getStandard("factoryWater", "ph")).thenReturn(buildStandard("ph", 6.5, 8.5));
when(standardService.getStandard("factoryWater", "residualChlorine")).thenReturn(buildStandard("residualChlorine", 0.3, 4.0));
when(standardService.getStandard("factoryWater", "color")).thenReturn(buildStandard("color", null, 15.0));
when(standardService.getStandard("factoryWater", "ecoli")).thenReturn(buildStandard("ecoli", null, 0.0));
when(standardService.getStandard("factoryWater", "colonyCount")).thenReturn(buildStandard("colonyCount", null, 100.0));
ledgerService.evaluateCompliance(sampleRecord);
assertEquals("unqualified", sampleRecord.getComplianceStatus());
assertTrue(sampleRecord.getUnqualifiedItems().contains("pH"));
assertTrue(sampleRecord.getUnqualifiedItems().contains("偏低"));
}
@Test
@DisplayName("11. 合格判定-余氯偏高")
void testEvaluateCompliance_ChlorineHigh() {
sampleRecord.setResidualChlorine(5.0);
when(standardService.getStandard("factoryWater", "turbidity")).thenReturn(buildStandard("turbidity", null, 1.0));
when(standardService.getStandard("factoryWater", "ph")).thenReturn(buildStandard("ph", 6.5, 8.5));
when(standardService.getStandard("factoryWater", "residualChlorine")).thenReturn(buildStandard("residualChlorine", 0.3, 4.0));
when(standardService.getStandard("factoryWater", "color")).thenReturn(buildStandard("color", null, 15.0));
when(standardService.getStandard("factoryWater", "ecoli")).thenReturn(buildStandard("ecoli", null, 0.0));
when(standardService.getStandard("factoryWater", "colonyCount")).thenReturn(buildStandard("colonyCount", null, 100.0));
ledgerService.evaluateCompliance(sampleRecord);
assertEquals("unqualified", sampleRecord.getComplianceStatus());
assertTrue(sampleRecord.getUnqualifiedItems().contains("余氯"));
assertTrue(sampleRecord.getUnqualifiedItems().contains("偏高"));
}
@Test
@DisplayName("12. 合格判定-无标准时不判定")
void testEvaluateCompliance_NoStandard() {
sampleRecord.setWaterType("unknownType");
when(standardService.getStandard(eq("unknownType"), anyString())).thenReturn(null);
ledgerService.evaluateCompliance(sampleRecord);
assertEquals("qualified", sampleRecord.getComplianceStatus());
}
// ==================== 统计测试 ====================
@Test
@DisplayName("13. 获取统计数据")
void testGetStatistics() {
List<Map<String, Object>> statusStats = List.of(
Map.of("status", "qualified", "count", 80L),
Map.of("status", "unqualified", "count", 15L),
Map.of("status", "pending", "count", 5L)
);
when(recordMapper.statByComplianceStatus(any(), any())).thenReturn(statusStats);
when(recordMapper.statRateByWaterType(any(), any())).thenReturn(Collections.emptyList());
when(recordMapper.statRateByArea(any(), any())).thenReturn(Collections.emptyList());
when(recordMapper.statMonthlyTrend(any(), any())).thenReturn(Collections.emptyList());
when(recordMapper.statUnqualifiedByParam(any(), any())).thenReturn(Collections.emptyList());
when(recordMapper.statParamAvg()).thenReturn(Map.of(
"avgTurbidity", 0.45, "avgPh", 7.1, "avgResidualChlorine", 0.6
));
QualityStatVO vo = ledgerService.getStatistics(null, null);
assertNotNull(vo);
assertEquals(100L, vo.getTotalRecords());
assertEquals(80L, vo.getQualifiedCount());
assertEquals(15L, vo.getUnqualifiedCount());
assertEquals(5L, vo.getPendingCount());
assertEquals(80.0, vo.getQualifiedRate());
}
@Test
@DisplayName("14. 统计-空数据")
void testGetStatistics_Empty() {
when(recordMapper.statByComplianceStatus(any(), any())).thenReturn(Collections.emptyList());
when(recordMapper.statRateByWaterType(any(), any())).thenReturn(Collections.emptyList());
when(recordMapper.statRateByArea(any(), any())).thenReturn(Collections.emptyList());
when(recordMapper.statMonthlyTrend(any(), any())).thenReturn(Collections.emptyList());
when(recordMapper.statUnqualifiedByParam(any(), any())).thenReturn(Collections.emptyList());
when(recordMapper.statParamAvg()).thenReturn(null);
QualityStatVO vo = ledgerService.getStatistics(null, null);
assertEquals(0L, vo.getTotalRecords());
assertEquals(0.0, vo.getQualifiedRate());
}
// ==================== 辅助方法测试 ====================
@Test
@DisplayName("15. 获取区域列表")
void testGetAreaList() {
QualityTestRecord r1 = new QualityTestRecord();
r1.setArea("城区A");
QualityTestRecord r2 = new QualityTestRecord();
r2.setArea("城区B");
when(recordMapper.selectList(any())).thenReturn(List.of(r1, r2));
List<String> areas = ledgerService.getAreaList();
assertNotNull(areas);
assertEquals(2, areas.size());
assertTrue(areas.contains("城区A"));
assertTrue(areas.contains("城区B"));
}
@Test
@DisplayName("16. 获取采样点列表")
void testGetSamplingPointList() {
QualityTestRecord r1 = new QualityTestRecord();
r1.setSamplingPoint("采样点A");
QualityTestRecord r2 = new QualityTestRecord();
r2.setSamplingPoint("采样点B");
when(recordMapper.selectList(any())).thenReturn(List.of(r1, r2));
List<String> points = ledgerService.getSamplingPointList();
assertNotNull(points);
assertEquals(2, points.size());
}
// ==================== 计划测试 ====================
@Test
@DisplayName("17. 创建检测计划")
void testCreatePlan() {
QualityTestPlan plan = buildPlan();
when(planMapper.insert(any())).thenReturn(1);
QualityTestPlan result = planService.create(plan);
assertEquals("active", result.getStatus());
assertEquals(0, result.getExecutionCount());
verify(planMapper).insert(plan);
}
@Test
@DisplayName("18. 标记计划已执行-日检+1天")
void testMarkPlanExecuted() {
QualityTestPlan plan = buildPlan();
plan.setId(1L);
plan.setNextTestDate(LocalDate.of(2026, 6, 14));
plan.setExecutionCount(5);
when(planMapper.selectById(1L)).thenReturn(plan);
when(planMapper.updateById(any())).thenReturn(1);
QualityTestPlan result = planService.markExecuted(1L);
assertEquals(6, result.getExecutionCount());
assertEquals(LocalDate.of(2026, 6, 15), result.getNextTestDate());
}
// ==================== 辅助方法 ====================
private QualityStandard buildStandard(String paramName, Double min, Double max) {
QualityStandard s = new QualityStandard();
s.setParamName(paramName);
s.setMinValue(min);
s.setMaxValue(max);
s.setEnabled(true);
return s;
}
private QualityTestPlan buildPlan() {
QualityTestPlan plan = new QualityTestPlan();
plan.setPlanName("日检计划");
plan.setTestType("routine");
plan.setWaterType("factoryWater");
plan.setSamplingPoint("出厂水采样点A");
plan.setArea("城区A");
plan.setFrequency("daily");
plan.setStartDate(LocalDate.now());
return plan;
}
}