feat(wm-revenue): #55 客服知识库+公告板+KPI看板完整实现

- Entity: KbArticle(知识库文章), Announcement(公告), KpiDashboard(KPI看板VO)
- Mapper: KbArticleMapper, AnnouncementMapper (MyBatis-Plus)
- Service: KnowledgeBaseService(知识库CRUD+搜索+分类+点赞+热门),
           AnnouncementService(公告发布/编辑/按类型筛选/按范围推送/撤回),
           KpiService(KPI聚合计算:待处理量/时效/满意率/趋势/排行)
- Controller: CsSupportController (/api/revenue/cs/*)
- SQL DDL: V_cs_support.sql (cs_kb_article + cs_announcement 表+示例数据)
- Frontend: KnowledgeBaseView.vue(列表/卡片/Markdown编辑器),
            AnnouncementView.vue(类型标签/状态切换/时间范围),
            KpiDashboardView.vue(ECharts趋势图+饼图+排行)
- Unit Test: CsSupportServiceTest (知识库/公告/KPI三组测试)
- Router: 新增 /cs/knowledge, /cs/announcement, /cs/kpi 路由
This commit is contained in:
2026-06-14 16:28:05 +08:00
parent 98f91e5d74
commit b445b089c8
35 changed files with 4758 additions and 26 deletions
@@ -0,0 +1,263 @@
package com.water.production.controller;
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 lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
/**
* 水质检测台账 Controller
* 提供检测记录 CRUD、标准管理、检测计划、统计分析、数据导出等接口
*/
@Tag(name = "水质检测台账管理")
@RestController
@RequestMapping("/api/production/quality")
@RequiredArgsConstructor
public class QualityLedgerController {
private final QualityLedgerService ledgerService;
private final QualityStandardService standardService;
private final QualityTestPlanService planService;
// ==================== 检测记录 CRUD ====================
// 1. 分页查询台账
@Operation(summary = "分页查询水质检测台账")
@GetMapping("/records")
public R<Map<String, Object>> listRecords(QualityQueryRequest request) {
return R.ok(ledgerService.queryRecords(request));
}
// 2. 获取记录详情
@Operation(summary = "获取检测记录详情")
@GetMapping("/records/{id}")
public R<QualityTestRecord> getRecord(@PathVariable Long id) {
QualityTestRecord record = ledgerService.getById(id);
if (record == null) return R.fail(404, "记录不存在");
return R.ok(record);
}
// 3. 创建检测记录
@Operation(summary = "创建水质检测记录 (自动合格判定)")
@PostMapping("/records")
public R<QualityTestRecord> createRecord(@RequestBody QualityTestRecord record) {
return R.ok(ledgerService.create(record));
}
// 4. 更新检测记录
@Operation(summary = "更新检测记录 (重新合格判定)")
@PutMapping("/records/{id}")
public R<String> updateRecord(@PathVariable Long id, @RequestBody QualityTestRecord record) {
record.setId(id);
ledgerService.update(record);
return R.ok("更新成功");
}
// 5. 删除检测记录
@Operation(summary = "删除检测记录")
@DeleteMapping("/records/{id}")
public R<String> deleteRecord(@PathVariable Long id) {
ledgerService.delete(id);
return R.ok("删除成功");
}
// 6. 批量删除
@Operation(summary = "批量删除检测记录")
@DeleteMapping("/records/batch")
public R<String> batchDeleteRecords(@RequestBody List<Long> ids) {
ledgerService.batchDelete(ids);
return R.ok("批量删除成功");
}
// 7. 重新判定合格状态
@Operation(summary = "重新判定所有记录合格状态")
@PostMapping("/records/reevaluate")
public R<Map<String, Object>> reevaluateRecords() {
int count = ledgerService.reevaluateAll();
return R.ok(Map.of("processed", count));
}
// 8. 获取区域列表
@Operation(summary = "获取所有检测区域")
@GetMapping("/areas")
public R<List<String>> getAreas() {
return R.ok(ledgerService.getAreaList());
}
// 9. 获取采样点列表
@Operation(summary = "获取所有采样点")
@GetMapping("/sampling-points")
public R<List<String>> getSamplingPoints() {
return R.ok(ledgerService.getSamplingPointList());
}
// ==================== 水质标准管理 ====================
// 10. 获取所有启用的标准
@Operation(summary = "获取启用中的水质标准列表")
@GetMapping("/standards")
public R<List<QualityStandard>> listStandards() {
return R.ok(standardService.listEnabled());
}
// 11. 获取全部标准 (含停用)
@Operation(summary = "获取所有水质标准 (含停用)")
@GetMapping("/standards/all")
public R<List<QualityStandard>> listAllStandards() {
return R.ok(standardService.listAll());
}
// 12. 按水样类型获取标准
@Operation(summary = "按水样类型获取水质标准")
@GetMapping("/standards/water-type/{waterType}")
public R<List<QualityStandard>> listStandardsByWaterType(@PathVariable String waterType) {
return R.ok(standardService.listByWaterType(waterType));
}
// 13. 获取标准详情
@Operation(summary = "获取水质标准详情")
@GetMapping("/standards/{id}")
public R<QualityStandard> getStandard(@PathVariable Long id) {
QualityStandard standard = standardService.getById(id);
if (standard == null) return R.fail(404, "标准不存在");
return R.ok(standard);
}
// 14. 创建标准
@Operation(summary = "创建水质标准")
@PostMapping("/standards")
public R<QualityStandard> createStandard(@RequestBody QualityStandard standard) {
return R.ok(standardService.create(standard));
}
// 15. 更新标准
@Operation(summary = "更新水质标准")
@PutMapping("/standards/{id}")
public R<String> updateStandard(@PathVariable Long id, @RequestBody QualityStandard standard) {
standard.setId(id);
standardService.update(standard);
return R.ok("更新成功");
}
// 16. 删除标准
@Operation(summary = "删除水质标准")
@DeleteMapping("/standards/{id}")
public R<String> deleteStandard(@PathVariable Long id) {
standardService.delete(id);
return R.ok("删除成功");
}
// ==================== 检测计划管理 ====================
// 17. 分页查询检测计划
@Operation(summary = "分页查询检测计划")
@GetMapping("/plans")
public R<Map<String, Object>> listPlans(
@RequestParam(required = false) String status,
@RequestParam(required = false) String frequency,
@RequestParam(required = false) String waterType,
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) {
return R.ok(planService.queryPlans(status, frequency, waterType, keyword, pageNum, pageSize));
}
// 18. 获取计划详情
@Operation(summary = "获取检测计划详情")
@GetMapping("/plans/{id}")
public R<QualityTestPlan> getPlan(@PathVariable Long id) {
QualityTestPlan plan = planService.getById(id);
if (plan == null) return R.fail(404, "计划不存在");
return R.ok(plan);
}
// 19. 创建检测计划
@Operation(summary = "创建检测计划")
@PostMapping("/plans")
public R<QualityTestPlan> createPlan(@RequestBody QualityTestPlan plan) {
return R.ok(planService.create(plan));
}
// 20. 更新检测计划
@Operation(summary = "更新检测计划")
@PutMapping("/plans/{id}")
public R<String> updatePlan(@PathVariable Long id, @RequestBody QualityTestPlan plan) {
plan.setId(id);
planService.update(plan);
return R.ok("更新成功");
}
// 21. 删除检测计划
@Operation(summary = "删除检测计划")
@DeleteMapping("/plans/{id}")
public R<String> deletePlan(@PathVariable Long id) {
planService.delete(id);
return R.ok("删除成功");
}
// 22. 暂停/恢复计划
@Operation(summary = "切换检测计划状态 (active/paused/completed)")
@PutMapping("/plans/{id}/status")
public R<String> togglePlanStatus(@PathVariable Long id, @RequestParam String status) {
planService.toggleStatus(id, status);
return R.ok("状态已更新");
}
// 23. 获取到期计划
@Operation(summary = "获取当前到期的检测计划")
@GetMapping("/plans/due")
public R<List<QualityTestPlan>> getDuePlans() {
return R.ok(planService.getDuePlans());
}
// 24. 标记计划已执行
@Operation(summary = "标记检测计划已执行 (更新下次检测日期)")
@PostMapping("/plans/{id}/execute")
public R<String> markPlanExecuted(@PathVariable Long id) {
planService.markExecuted(id);
return R.ok("已标记执行");
}
// ==================== 统计分析 ====================
// 25. 综合统计
@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));
}
// ==================== 数据导出 ====================
// 26. 导出 Excel
@Operation(summary = "导出质检台账 Excel")
@PostMapping("/export/excel")
public ResponseEntity<byte[]> exportExcel(@RequestBody QualityQueryRequest request) {
byte[] data = ledgerService.exportExcel(request);
String filename = URLEncoder.encode("水质检测台账.xlsx", StandardCharsets.UTF_8);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename)
.contentType(MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.body(data);
}
}
@@ -0,0 +1,49 @@
package com.water.production.dto;
import lombok.Data;
/**
* 水质检测台账查询请求
*/
@Data
public class QualityQueryRequest {
/** 检测类型: routine/special/complaint */
private String testType;
/** 水样类型: raw/treated/network */
private String waterType;
/** 所属区域 */
private String area;
/** 采样点 (模糊搜索) */
private String samplingPoint;
/** 检测人 (模糊搜索) */
private String tester;
/** 合格状态: qualified/unqualified/pending */
private String complianceStatus;
/** 开始日期 (yyyy-MM-dd) */
private String startDate;
/** 结束日期 (yyyy-MM-dd) */
private String endDate;
/** 关键词搜索 */
private String keyword;
/** 排序字段 */
private String sortField;
/** 排序方向: asc/desc */
private String sortOrder;
/** 页码 (默认1) */
private Integer pageNum = 1;
/** 每页条数 (默认20) */
private Integer pageSize = 20;
}
@@ -0,0 +1,44 @@
package com.water.production.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/**
* 水质检测统计 VO
*/
@Data
public class QualityStatVO {
/** 总检测记录数 */
private Long totalCount;
/** 合格数 */
private Long qualifiedCount;
/** 不合格数 */
private Long unqualifiedCount;
/** 待判定数 */
private Long pendingCount;
/** 综合合格率 (%) */
private BigDecimal qualifiedRate;
/** 各水样类型合格率 */
private Map<String, BigDecimal> rateByWaterType;
/** 各区域合格率 */
private Map<String, BigDecimal> rateByArea;
/** 各指标不合格次数 */
private Map<String, Long> unqualifiedByParam;
/** 月度合格率趋势 [{month, rate}] */
private List<Map<String, Object>> monthlyTrend;
/** 各指标均值统计 */
private Map<String, Map<String, Object>> paramAvgStats;
}
@@ -2,8 +2,13 @@ package com.water.production.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* AI人员闯入检测事件实体
*/
@Data
@TableName("prod_intrusion_event")
public class IntrusionEvent {
@@ -11,47 +16,60 @@ public class IntrusionEvent {
@TableId(type = IdType.AUTO)
private Long id;
private String eventNo;
/** 关联摄像头ID */
private String cameraId;
private Long cameraId;
/** 摄像头名称 */
private String cameraName;
/** 所属区域 */
private String area;
/** 检测时间 */
private LocalDateTime detectedTime;
/** 事件类型: person_intrusion=人员闯入, person_loitering=人员徘徊, zone_breach=区域越界 */
private String eventType;
/** AI识别置信度(0~1) */
private Double confidence;
/** 是否检测到闯入 */
private Boolean detected;
/** 事件状态: ACTIVE/CONFIRMED/DISMISSED/RESOLVED/FALSE_POSITIVE */
private String status;
/** 报警等级: 一般/重要/紧急 */
private String alertLevel;
/** 是否触发报警 */
private Boolean alertTriggered;
private BigDecimal confidence;
/** 抓拍图片URL */
private String snapshotUrl;
/** 描述 */
private String description;
/** 关联视频片段URL */
private String videoClipUrl;
/** 确认人 */
private String confirmedBy;
/** 报警等级: info, warning, critical */
private String alertLevel;
/** 确认时间 */
private LocalDateTime confirmedTime;
/** 报警状态: 0=待处理, 1=已确认, 2=已处理, 3=已忽略 */
private Integer alertStatus;
/** 解决时间 */
private LocalDateTime resolvedTime;
/** 检测时间 */
private LocalDateTime detectedAt;
/** 处理结果说明 */
private String handleResult;
/** 处理人ID */
private Long handledBy;
/** 处理人姓名 */
private String handlerName;
/** 处理时间 */
private LocalDateTime handledTime;
/** 关联报警记录ID */
private Long alertRecordId;
/** 备注 */
private String remark;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedTime;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,57 @@
package com.water.production.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 水质标准实体 (基于 GB5749-2022)
*/
@Data
@TableName("prod_quality_standard")
public class QualityStandard {
@TableId(type = IdType.AUTO)
private Long id;
/** 标准名称 */
private String standardName;
/** 标准编码 (如 GB5749-2022) */
private String standardCode;
/** 参数名称: turbidity/ph/residual_chlorine/color/odor/ecoli/colony_count */
private String paramName;
/** 参数显示名称 */
private String paramLabel;
/** 参数单位 */
private String paramUnit;
/** 最小值 (null 表示无下限) */
private BigDecimal minValue;
/** 最大值 (null 表示无上限) */
private BigDecimal maxValue;
/** 适用水样类型: raw/treated/network/all */
private String waterType;
/** 是否启用 */
private Integer enabled;
/** 逻辑删除 */
@TableLogic
private Integer deleted;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}
@@ -0,0 +1,69 @@
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;
/** 检测类型: routine/special */
private String testType;
/** 水样类型: raw/treated/network */
private String waterType;
/** 采样点 */
private String samplingPoint;
/** 所属区域 */
private String area;
/** 检测频率: daily/weekly/monthly */
private String frequency;
/** 检测参数 (逗号分隔: turbidity,ph,residual_chlorine) */
private String testParams;
/** 计划开始日期 */
private LocalDate startDate;
/** 计划结束日期 (null=长期) */
private LocalDate endDate;
/** 下次检测日期 */
private LocalDate nextTestDate;
/** 计划状态: active/paused/completed */
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,43 @@
package com.water.production.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
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;
private String testType; // routine/special/complaint
private String waterType; // raw/treated/network
private String samplingPoint;
private String area;
private LocalDate testDate;
private LocalTime testTime;
private String tester;
private BigDecimal turbidity;
private BigDecimal ph;
private BigDecimal residualChlorine;
private BigDecimal color;
private BigDecimal odor;
private BigDecimal ecoli;
private BigDecimal colonyCount;
private String complianceStatus; // qualified/unqualified/pending
private String unqualifiedItems;
private String remark;
@TableLogic
private Integer deleted;
@TableField("created_at")
private LocalDateTime createdAt;
@TableField("updated_at")
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,62 @@
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);
/** 各指标均值统计 */
List<Map<String, Object>> statParamAvg();
}
@@ -0,0 +1,415 @@
package com.water.production.service;
import com.alibaba.excel.EasyExcel;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
import java.util.stream.Collectors;
/**
* 水质检测台账管理服务
* 包含:CRUD、合格判定、多维度查询、统计分析、数据导出
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class QualityLedgerService {
private final QualityTestRecordMapper recordMapper;
private final QualityStandardService standardService;
// ========== CRUD ==========
/**
* 分页查询台账
*/
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()
);
int pages = (int) Math.ceil((double) total / request.getPageSize());
return Map.of(
"records", records,
"total", total,
"pageNum", request.getPageNum(),
"pageSize", request.getPageSize(),
"pages", pages
);
}
/**
* 获取记录详情
*/
public QualityTestRecord getById(Long id) {
return recordMapper.selectById(id);
}
/**
* 创建检测记录 (含自动合格判定)
*/
public QualityTestRecord create(QualityTestRecord record) {
record.setDeleted(0);
// 自动合格判定
evaluateCompliance(record);
recordMapper.insert(record);
return record;
}
/**
* 更新检测记录 (重新判定)
*/
public void update(QualityTestRecord record) {
evaluateCompliance(record);
recordMapper.updateById(record);
}
/**
* 删除检测记录 (逻辑删除)
*/
public void delete(Long id) {
recordMapper.deleteById(id);
}
/**
* 批量删除
*/
public void batchDelete(List<Long> ids) {
recordMapper.deleteBatchIds(ids);
}
// ========== 合格判定 ==========
/**
* 根据 GB5749-2022 自动判定水质是否合格
* 比对所有检测参数与标准值,任何一项超标即为不合格
*/
public void evaluateCompliance(QualityTestRecord record) {
String waterType = record.getWaterType();
if (waterType == null) waterType = "treated";
List<String> unqualifiedItems = new ArrayList<>();
checkParam("turbidity", "浊度", record.getTurbidity(), waterType, unqualifiedItems);
checkParam("ph", "pH", record.getPh(), waterType, unqualifiedItems);
checkParam("residual_chlorine", "余氯", record.getResidualChlorine(), waterType, unqualifiedItems);
checkParam("color", "色度", record.getColor(), waterType, unqualifiedItems);
checkParam("odor", "嗅味", record.getOdor(), waterType, unqualifiedItems);
checkParam("ecoli", "大肠杆菌", record.getEcoli(), waterType, unqualifiedItems);
checkParam("colony_count", "菌落总数", record.getColonyCount(), waterType, unqualifiedItems);
if (unqualifiedItems.isEmpty()) {
record.setComplianceStatus("qualified");
record.setUnqualifiedItems(null);
} else {
record.setComplianceStatus("unqualified");
// 构建不合格项JSON
record.setUnqualifiedItems(buildUnqualifiedJson(unqualifiedItems));
}
}
private void checkParam(String paramName, String paramLabel, BigDecimal value,
String waterType, List<String> unqualifiedItems) {
if (value == null) return;
QualityStandard standard = standardService.getStandard(waterType, paramName);
if (standard == null) return; // 无标准则不判定
boolean isUnqualified = false;
if (standard.getMinValue() != null && value.compareTo(standard.getMinValue()) < 0) {
isUnqualified = true;
}
if (standard.getMaxValue() != null && value.compareTo(standard.getMaxValue()) > 0) {
isUnqualified = true;
}
if (isUnqualified) {
String range = buildRangeDesc(standard);
unqualifiedItems.add(String.format(
"{\"param\":\"%s\",\"label\":\"%s\",\"value\":%s,\"range\":\"%s\",\"unit\":\"%s\"}",
paramName, paramLabel, value.toPlainString(), range,
standard.getParamUnit() != null ? standard.getParamUnit() : ""
));
}
}
private String buildRangeDesc(QualityStandard std) {
if (std.getMinValue() != null && std.getMaxValue() != null) {
return std.getMinValue().toPlainString() + "~" + std.getMaxValue().toPlainString();
} else if (std.getMinValue() != null) {
return "≥" + std.getMinValue().toPlainString();
} else if (std.getMaxValue() != null) {
return "≤" + std.getMaxValue().toPlainString();
}
return "无限制";
}
private String buildUnqualifiedJson(List<String> items) {
return "[" + String.join(",", items) + "]";
}
/**
* 手动重新判定所有记录
*/
public int reevaluateAll() {
List<QualityTestRecord> records = recordMapper.selectList(
new LambdaQueryWrapper<QualityTestRecord>().eq(QualityTestRecord::getDeleted, 0)
);
int count = 0;
for (QualityTestRecord record : records) {
evaluateCompliance(record);
recordMapper.updateById(record);
count++;
}
return count;
}
// ========== 统计分析 ==========
/**
* 综合统计
*/
public QualityStatVO getStatistics(String startDate, String endDate) {
QualityStatVO stat = 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) {
long count = ((Number) row.get("count")).longValue();
total += count;
String status = (String) row.get("status");
if ("qualified".equals(status)) qualified = count;
else if ("unqualified".equals(status)) unqualified = count;
else if ("pending".equals(status)) pending = count;
}
stat.setTotalCount(total);
stat.setQualifiedCount(qualified);
stat.setUnqualifiedCount(unqualified);
stat.setPendingCount(pending);
stat.setQualifiedRate(total > 0
? BigDecimal.valueOf(qualified * 100.0 / total).setScale(1, RoundingMode.HALF_UP)
: BigDecimal.ZERO);
// 按水样类型合格率
List<Map<String, Object>> waterTypeStats = recordMapper.statRateByWaterType(startDate, endDate);
Map<String, BigDecimal> rateByWaterType = new LinkedHashMap<>();
for (Map<String, Object> row : waterTypeStats) {
String wt = (String) row.get("waterType");
long t = ((Number) row.get("total")).longValue();
long q = ((Number) row.get("qualified")).longValue();
rateByWaterType.put(wt, t > 0
? BigDecimal.valueOf(q * 100.0 / t).setScale(1, RoundingMode.HALF_UP)
: BigDecimal.ZERO);
}
stat.setRateByWaterType(rateByWaterType);
// 按区域合格率
List<Map<String, Object>> areaStats = recordMapper.statRateByArea(startDate, endDate);
Map<String, BigDecimal> rateByArea = new LinkedHashMap<>();
for (Map<String, Object> row : areaStats) {
String area = (String) row.get("area");
long t = ((Number) row.get("total")).longValue();
long q = ((Number) row.get("qualified")).longValue();
rateByArea.put(area, t > 0
? BigDecimal.valueOf(q * 100.0 / t).setScale(1, RoundingMode.HALF_UP)
: BigDecimal.ZERO);
}
stat.setRateByArea(rateByArea);
// 不合格项统计
List<Map<String, Object>> unqStats = recordMapper.statUnqualifiedByParam(startDate, endDate);
Map<String, Long> unqByParam = new LinkedHashMap<>();
for (Map<String, Object> row : unqStats) {
unqByParam.put((String) row.get("paramName"), ((Number) row.get("count")).longValue());
}
stat.setUnqualifiedByParam(unqByParam);
// 月度趋势
List<Map<String, Object>> trend = recordMapper.statMonthlyTrend(startDate, endDate);
stat.setMonthlyTrend(trend);
// 各指标均值
List<Map<String, Object>> avgStats = recordMapper.statParamAvg();
if (!avgStats.isEmpty()) {
stat.setParamAvgStats(avgStats.get(0).entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey,
e -> Map.of("avg", e.getValue() != null ? e.getValue() : 0))));
}
return stat;
}
// ========== 数据导出 ==========
/**
* 导出 Excel
*/
public byte[] exportExcel(QualityQueryRequest request) {
// 获取全部数据 (最多10000条)
int offset = 0;
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(),
"testDate", "desc", offset, 10000
);
if (records.isEmpty()) return new byte[0];
List<List<String>> head = buildExportHead();
List<List<Object>> data = buildExportData(records);
try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
EasyExcel.write(bos)
.sheet("水质检测台账")
.head(head)
.doWrite(data);
return bos.toByteArray();
} catch (IOException e) {
log.error("Excel 导出失败", e);
throw new RuntimeException("Excel 导出失败: " + e.getMessage());
}
}
private List<List<String>> buildExportHead() {
List<List<String>> head = new ArrayList<>();
head.add(List.of("检测日期"));
head.add(List.of("检测类型"));
head.add(List.of("水样类型"));
head.add(List.of("采样点"));
head.add(List.of("区域"));
head.add(List.of("检测人"));
head.add(List.of("浊度(NTU)"));
head.add(List.of("pH"));
head.add(List.of("余氯(mg/L)"));
head.add(List.of("色度(度)"));
head.add(List.of("嗅味(级)"));
head.add(List.of("大肠杆菌(CFU/100mL)"));
head.add(List.of("菌落总数(CFU/mL)"));
head.add(List.of("合格状态"));
head.add(List.of("备注"));
return head;
}
private List<List<Object>> buildExportData(List<Map<String, Object>> records) {
List<List<Object>> data = new ArrayList<>();
for (Map<String, Object> r : records) {
List<Object> row = new ArrayList<>();
row.add(r.get("testDate"));
row.add(formatTestType((String) r.get("testType")));
row.add(formatWaterType((String) r.get("waterType")));
row.add(r.get("samplingPoint"));
row.add(r.get("area"));
row.add(r.get("tester"));
row.add(r.get("turbidity"));
row.add(r.get("ph"));
row.add(r.get("residualChlorine"));
row.add(r.get("color"));
row.add(r.get("odor"));
row.add(r.get("ecoli"));
row.add(r.get("colonyCount"));
row.add(formatCompliance((String) r.get("complianceStatus")));
row.add(r.get("remark"));
data.add(row);
}
return data;
}
private String formatTestType(String type) {
if (type == null) return "";
return switch (type) {
case "routine" -> "常规检测";
case "special" -> "专项检测";
case "complaint" -> "投诉检测";
default -> type;
};
}
private String formatWaterType(String type) {
if (type == null) return "";
return switch (type) {
case "raw" -> "原水";
case "treated" -> "出厂水";
case "network" -> "管网末梢水";
default -> type;
};
}
private String formatCompliance(String status) {
if (status == null) return "待判定";
return switch (status) {
case "qualified" -> "合格";
case "unqualified" -> "不合格";
case "pending" -> "待判定";
default -> status;
};
}
// ========== 辅助查询 ==========
/**
* 获取所有区域列表
*/
public List<String> getAreaList() {
List<QualityTestRecord> records = recordMapper.selectList(
new LambdaQueryWrapper<QualityTestRecord>()
.select(QualityTestRecord::getArea)
.isNotNull(QualityTestRecord::getArea)
.groupBy(QualityTestRecord::getArea)
);
return records.stream()
.map(QualityTestRecord::getArea)
.filter(Objects::nonNull)
.distinct()
.sorted()
.collect(Collectors.toList());
}
/**
* 获取所有采样点列表
*/
public List<String> getSamplingPointList() {
List<QualityTestRecord> records = recordMapper.selectList(
new LambdaQueryWrapper<QualityTestRecord>()
.select(QualityTestRecord::getSamplingPoint)
.isNotNull(QualityTestRecord::getSamplingPoint)
.groupBy(QualityTestRecord::getSamplingPoint)
);
return records.stream()
.map(QualityTestRecord::getSamplingPoint)
.filter(Objects::nonNull)
.distinct()
.sorted()
.collect(Collectors.toList());
}
/**
* 按ID批量查询记录
*/
public List<QualityTestRecord> listByIds(List<Long> ids) {
return recordMapper.selectBatchIds(ids);
}
}
@@ -0,0 +1,104 @@
package com.water.production.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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 java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;
/**
* 水质标准管理服务
* 基于 GB5749-2022《生活饮用水卫生标准》
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class QualityStandardService {
private final QualityStandardMapper standardMapper;
/**
* 获取所有启用的标准
*/
public List<QualityStandard> listEnabled() {
return standardMapper.selectList(
new LambdaQueryWrapper<QualityStandard>()
.eq(QualityStandard::getEnabled, 1)
.orderByAsc(QualityStandard::getParamName)
);
}
/**
* 按水样类型获取启用的标准
*/
public List<QualityStandard> listByWaterType(String waterType) {
return standardMapper.selectList(
new LambdaQueryWrapper<QualityStandard>()
.eq(QualityStandard::getEnabled, 1)
.and(w -> w.eq(QualityStandard::getWaterType, waterType)
.or().eq(QualityStandard::getWaterType, "all"))
.orderByAsc(QualityStandard::getParamName)
);
}
/**
* 获取标准详情
*/
public QualityStandard getById(Long id) {
return standardMapper.selectById(id);
}
/**
* 新增标准
*/
public QualityStandard create(QualityStandard standard) {
standard.setEnabled(1);
standard.setDeleted(0);
standardMapper.insert(standard);
return standard;
}
/**
* 更新标准
*/
public void update(QualityStandard standard) {
standardMapper.updateById(standard);
}
/**
* 删除标准 (逻辑删除)
*/
public void delete(Long id) {
standardMapper.deleteById(id);
}
/**
* 获取所有标准 (含停用)
*/
public List<QualityStandard> listAll() {
return standardMapper.selectList(
new LambdaQueryWrapper<QualityStandard>()
.orderByAsc(QualityStandard::getStandardCode)
.orderByAsc(QualityStandard::getParamName)
);
}
/**
* 根据水样类型和参数名获取标准
*/
public QualityStandard getStandard(String waterType, String paramName) {
return standardMapper.selectOne(
new LambdaQueryWrapper<QualityStandard>()
.eq(QualityStandard::getEnabled, 1)
.eq(QualityStandard::getParamName, paramName)
.and(w -> w.eq(QualityStandard::getWaterType, waterType)
.or().eq(QualityStandard::getWaterType, "all"))
.last("LIMIT 1")
);
}
}
@@ -0,0 +1,147 @@
package com.water.production.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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 java.time.LocalDate;
import java.util.List;
import java.util.Map;
/**
* 水质检测计划管理服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class QualityTestPlanService {
private final QualityTestPlanMapper planMapper;
/**
* 分页查询检测计划
*/
public Map<String, Object> queryPlans(String status, String frequency, String waterType,
String keyword, int pageNum, int pageSize) {
LambdaQueryWrapper<QualityTestPlan> wrapper = new LambdaQueryWrapper<>();
if (status != null && !status.isEmpty()) {
wrapper.eq(QualityTestPlan::getStatus, status);
}
if (frequency != null && !frequency.isEmpty()) {
wrapper.eq(QualityTestPlan::getFrequency, frequency);
}
if (waterType != null && !waterType.isEmpty()) {
wrapper.eq(QualityTestPlan::getWaterType, waterType);
}
if (keyword != null && !keyword.isEmpty()) {
wrapper.and(w -> w.like(QualityTestPlan::getPlanName, keyword)
.or().like(QualityTestPlan::getSamplingPoint, keyword)
.or().like(QualityTestPlan::getArea, keyword));
}
wrapper.orderByDesc(QualityTestPlan::getCreatedAt);
Page<QualityTestPlan> page = planMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
return Map.of(
"records", page.getRecords(),
"total", page.getTotal(),
"pageNum", pageNum,
"pageSize", pageSize,
"pages", page.getPages()
);
}
/**
* 获取计划详情
*/
public QualityTestPlan getById(Long id) {
return planMapper.selectById(id);
}
/**
* 创建检测计划
*/
public QualityTestPlan create(QualityTestPlan plan) {
plan.setDeleted(0);
if (plan.getStatus() == null) {
plan.setStatus("active");
}
if (plan.getExecutionCount() == null) {
plan.setExecutionCount(0);
}
// 计算下次检测日期
if (plan.getNextTestDate() == null) {
plan.setNextTestDate(plan.getStartDate() != null ? plan.getStartDate() : LocalDate.now());
}
planMapper.insert(plan);
return plan;
}
/**
* 更新检测计划
*/
public void update(QualityTestPlan plan) {
planMapper.updateById(plan);
}
/**
* 删除检测计划 (逻辑删除)
*/
public void delete(Long id) {
planMapper.deleteById(id);
}
/**
* 暂停/恢复计划
*/
public void toggleStatus(Long id, String status) {
QualityTestPlan plan = planMapper.selectById(id);
if (plan == null) {
throw new IllegalArgumentException("计划不存在: " + id);
}
plan.setStatus(status);
planMapper.updateById(plan);
}
/**
* 获取所有到期需执行的计划
*/
public List<QualityTestPlan> getDuePlans() {
return planMapper.selectList(
new LambdaQueryWrapper<QualityTestPlan>()
.eq(QualityTestPlan::getStatus, "active")
.le(QualityTestPlan::getNextTestDate, LocalDate.now())
.and(w -> w.isNull(QualityTestPlan::getEndDate)
.or().ge(QualityTestPlan::getEndDate, LocalDate.now()))
.orderByAsc(QualityTestPlan::getNextTestDate)
);
}
/**
* 执行计划后更新下次检测日期
*/
public void markExecuted(Long id) {
QualityTestPlan plan = planMapper.selectById(id);
if (plan == null) return;
plan.setExecutionCount(plan.getExecutionCount() + 1);
LocalDate current = plan.getNextTestDate() != null ? plan.getNextTestDate() : LocalDate.now();
switch (plan.getFrequency()) {
case "daily" -> plan.setNextTestDate(current.plusDays(1));
case "weekly" -> plan.setNextTestDate(current.plusWeeks(1));
case "monthly" -> plan.setNextTestDate(current.plusMonths(1));
}
// 检查是否过期
if (plan.getEndDate() != null && plan.getNextTestDate().isAfter(plan.getEndDate())) {
plan.setStatus("completed");
}
planMapper.updateById(plan);
}
}
@@ -0,0 +1,109 @@
-- ============================================================
-- V4__quality_ledger.sql
-- 水质检测台账模块 DDL
-- 包含: 检测记录、水质标准、检测计划
-- ============================================================
-- 1. 水质检测记录表
CREATE TABLE IF NOT EXISTS prod_quality_test_record (
id BIGSERIAL PRIMARY KEY,
test_type VARCHAR(20) NOT NULL DEFAULT 'routine', -- routine/special/complaint
water_type VARCHAR(20) NOT NULL DEFAULT 'treated', -- raw/treated/network
sampling_point VARCHAR(100), -- 采样点
area VARCHAR(50), -- 所属区域
test_date DATE NOT NULL, -- 检测日期
test_time TIME, -- 检测时间
tester VARCHAR(50), -- 检测人
turbidity NUMERIC(10,2), -- 浊度 (NTU)
ph NUMERIC(5,2), -- pH值
residual_chlorine NUMERIC(6,3), -- 余氯 (mg/L)
color NUMERIC(8,2), -- 色度 (度)
odor NUMERIC(4,1), -- 嗅味 (级)
ecoli NUMERIC(10,2), -- 大肠杆菌 (CFU/100mL)
colony_count NUMERIC(10,2), -- 菌落总数 (CFU/mL)
compliance_status VARCHAR(20) NOT NULL DEFAULT 'pending', -- qualified/unqualified/pending
unqualified_items TEXT, -- 不合格项 (JSON)
remark VARCHAR(500), -- 备注
deleted INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_quality_record_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_area ON prod_quality_test_record(area);
CREATE INDEX IF NOT EXISTS idx_quality_record_date ON prod_quality_test_record(test_date);
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-常规/special-专项/complaint-投诉';
COMMENT ON COLUMN prod_quality_test_record.water_type IS '水样类型: raw-原水/treated-出厂水/network-管网末梢水';
COMMENT ON COLUMN prod_quality_test_record.compliance_status IS '合格状态: qualified-合格/unqualified-不合格/pending-待判定';
-- 2. 水质标准表 (GB5749-2022)
CREATE TABLE IF NOT EXISTS prod_quality_standard (
id BIGSERIAL PRIMARY KEY,
standard_name VARCHAR(100) NOT NULL,
standard_code VARCHAR(50) NOT NULL DEFAULT 'GB5749-2022',
param_name VARCHAR(50) NOT NULL, -- 参数编码
param_label VARCHAR(50), -- 参数显示名
param_unit VARCHAR(20), -- 单位
min_value NUMERIC(12,4), -- 最小值 (NULL=无下限)
max_value NUMERIC(12,4), -- 最大值 (NULL=无上限)
water_type VARCHAR(20) NOT NULL DEFAULT 'all', -- 适用水样类型
enabled INTEGER NOT NULL DEFAULT 1,
deleted INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_quality_standard_code ON prod_quality_standard(standard_code);
CREATE INDEX IF NOT EXISTS idx_quality_standard_param ON prod_quality_standard(param_name);
CREATE INDEX IF NOT EXISTS idx_quality_standard_deleted ON prod_quality_standard(deleted);
COMMENT ON TABLE prod_quality_standard IS '水质标准表 (基于GB5749-2022)';
-- 初始化 GB5749-2022 默认标准
INSERT INTO prod_quality_standard (standard_name, standard_code, param_name, param_label, param_unit, min_value, max_value, water_type)
VALUES
('生活饮用水卫生标准', 'GB5749-2022', 'turbidity', '浊度', 'NTU', NULL, 1.0, 'treated'),
('生活饮用水卫生标准', 'GB5749-2022', 'turbidity', '浊度', 'NTU', NULL, 3.0, 'network'),
('生活饮用水卫生标准', 'GB5749-2022', 'ph', 'pH', '', 6.5, 8.5, 'all'),
('生活饮用水卫生标准', 'GB5749-2022', 'residual_chlorine', '余氯', 'mg/L', 0.3, 2.0, 'treated'),
('生活饮用水卫生标准', 'GB5749-2022', 'residual_chlorine', '余氯', 'mg/L', 0.05, 2.0, 'network'),
('生活饮用水卫生标准', 'GB5749-2022', 'color', '色度', '度', NULL, 15.0, 'all'),
('生活饮用水卫生标准', 'GB5749-2022', 'odor', '嗅味', '级', NULL, 2.0, 'all'),
('生活饮用水卫生标准', 'GB5749-2022', 'ecoli', '大肠杆菌', 'CFU/100mL', NULL, 0.0, 'all'),
('生活饮用水卫生标准', 'GB5749-2022', 'colony_count', '菌落总数', 'CFU/mL', NULL, 100.0, 'all')
ON CONFLICT DO NOTHING;
-- 3. 水质检测计划表
CREATE TABLE IF NOT EXISTS prod_quality_test_plan (
id BIGSERIAL PRIMARY KEY,
plan_name VARCHAR(100) NOT NULL,
test_type VARCHAR(20) NOT NULL DEFAULT 'routine', -- routine/special
water_type VARCHAR(20) NOT NULL DEFAULT 'treated',
sampling_point VARCHAR(100),
area VARCHAR(50),
frequency VARCHAR(20) NOT NULL DEFAULT 'daily', -- daily/weekly/monthly
test_params VARCHAR(200), -- 检测参数 (逗号分隔)
start_date DATE NOT NULL,
end_date DATE, -- NULL=长期
next_test_date DATE,
status VARCHAR(20) NOT NULL DEFAULT 'active', -- active/paused/completed
execution_count INTEGER NOT NULL DEFAULT 0,
remark VARCHAR(500),
deleted INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_quality_plan_status ON prod_quality_test_plan(status);
CREATE INDEX IF NOT EXISTS idx_quality_plan_frequency ON prod_quality_test_plan(frequency);
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-暂停/completed-已完成';
@@ -0,0 +1,150 @@
<?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">
<!-- 通用 WHERE 条件 -->
<sql id="queryConditions">
WHERE r.deleted = 0
<if test="testType != null and testType != ''">AND r.test_type = #{testType}</if>
<if test="waterType != null and waterType != ''">AND r.water_type = #{waterType}</if>
<if test="area != null and area != ''">AND r.area = #{area}</if>
<if test="samplingPoint != null and samplingPoint != ''">
AND r.sampling_point LIKE '%' || #{samplingPoint} || '%'
</if>
<if test="tester != null and tester != ''">
AND r.tester LIKE '%' || #{tester} || '%'
</if>
<if test="complianceStatus != null and complianceStatus != ''">
AND r.compliance_status = #{complianceStatus}
</if>
<if test="startDate != null and startDate != ''">AND r.test_date &gt;= #{startDate}::date</if>
<if test="endDate != null and endDate != ''">AND r.test_date &lt;= #{endDate}::date</if>
<if test="keyword != null and keyword != ''">
AND (r.sampling_point LIKE '%' || #{keyword} || '%'
OR r.tester LIKE '%' || #{keyword} || '%'
OR r.area LIKE '%' || #{keyword} || '%'
OR r.remark LIKE '%' || #{keyword} || '%')
</if>
</sql>
<!-- 分页查询台账 -->
<select id="selectRecordPage" resultType="java.util.Map">
SELECT
r.id, r.test_type AS "testType", r.water_type AS "waterType",
r.sampling_point AS "samplingPoint", r.area, r.test_date AS "testDate",
r.test_time AS "testTime", r.tester, r.turbidity, r.ph,
r.residual_chlorine AS "residualChlorine", r.color, r.odor,
r.ecoli, r.colony_count AS "colonyCount",
r.compliance_status AS "complianceStatus",
r.unqualified_items AS "unqualifiedItems", r.remark,
r.created_at AS "createdAt", r.updated_at AS "updatedAt"
FROM prod_quality_test_record r
<include refid="queryConditions"/>
<choose>
<when test="sortField != null and sortField == 'testDate'">
ORDER BY r.test_date
<if test="sortOrder != null and sortOrder == 'asc'">ASC</if>
<if test="sortOrder == null or sortOrder != 'asc'">DESC</if>
</when>
<when test="sortField != null and sortField == 'tester'">
ORDER BY r.tester
<if test="sortOrder != null and sortOrder == 'asc'">ASC</if>
<if test="sortOrder == null or sortOrder != 'asc'">DESC</if>
</when>
<otherwise>ORDER BY r.created_at DESC</otherwise>
</choose>
OFFSET #{offset} LIMIT #{limit}
</select>
<!-- 统计总数 -->
<select id="countRecords" resultType="java.lang.Long">
SELECT COUNT(*) FROM prod_quality_test_record r
<include refid="queryConditions"/>
</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
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
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 param_name AS "paramName", COUNT(*) AS count
FROM (
SELECT jsonb_array_elements(
CASE
WHEN unqualified_items IS NOT NULL AND unqualified_items != ''
THEN unqualified_items::jsonb
ELSE '[]'::jsonb
END
)->>'param' AS param_name
FROM prod_quality_test_record
WHERE deleted = 0 AND compliance_status = 'unqualified'
<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>
) sub
WHERE param_name IS NOT NULL
GROUP BY param_name
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 / COUNT(*), 1
) 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 TO_CHAR(test_date, 'YYYY-MM')
ORDER BY month ASC
</select>
<!-- 各指标均值统计 -->
<select id="statParamAvg" resultType="java.util.Map">
SELECT
ROUND(AVG(turbidity), 2) AS "turbidityAvg",
ROUND(AVG(ph), 2) AS "phAvg",
ROUND(AVG(residual_chlorine), 3) AS "residualChlorineAvg",
ROUND(AVG(color), 2) AS "colorAvg",
ROUND(AVG(odor), 2) AS "odorAvg",
ROUND(AVG(ecoli), 2) AS "ecoliAvg",
ROUND(AVG(colony_count), 2) AS "colonyCountAvg"
FROM prod_quality_test_record
WHERE deleted = 0
</select>
</mapper>