Browse Source

feat(wm-production): #66 水质检测台账完整实现

- 新增检测点位管理 (QualityTestPoint entity/mapper/service/controller)
- 新增 QualityTestController 统一入口 (/api/production/quality-test)
- 新增 QualityExportService (Excel+PDF 报表导出)
- 新增检测点位 Mapper XML (按类型/区域统计 + 各点位合格率)
- 新增 DDL: V_quality_test.sql (检测点位表 + 预置10条示例数据)
- 前端3页面: TestRecordList.vue/TestPointView.vue/TestStatsView.vue
- 前端 API: qualityTest.ts
- 路由: quality-test/records, points, stats
- 单元测试: QualityTestPointServiceTest + QualityStandardServiceTest
- 完善 db/quality_ledger_ddl.sql 追加检测点位表DDL
- GB5749-2022 合格判定 + 完整 CRUD + 筛选/统计/导出
bot_dev2 5 days ago
parent
commit
46d67667b4

+ 136
- 0
db/quality_ledger_ddl.sql View File

@@ -0,0 +1,136 @@
1
+-- ============================================================
2
+-- V4__quality_ledger.sql
3
+-- 水质检测台账模块 DDL
4
+-- 包含: 检测记录、水质标准、检测计划
5
+-- ============================================================
6
+
7
+-- 1. 水质检测记录表
8
+CREATE TABLE IF NOT EXISTS prod_quality_test_record (
9
+    id                  BIGSERIAL PRIMARY KEY,
10
+    test_type           VARCHAR(20)     NOT NULL DEFAULT 'routine',   -- routine/special/complaint
11
+    water_type          VARCHAR(20)     NOT NULL DEFAULT 'treated',   -- raw/treated/network
12
+    sampling_point      VARCHAR(100),                                  -- 采样点
13
+    area                VARCHAR(50),                                   -- 所属区域
14
+    test_date           DATE            NOT NULL,                      -- 检测日期
15
+    test_time           TIME,                                          -- 检测时间
16
+    tester              VARCHAR(50),                                   -- 检测人
17
+    turbidity           NUMERIC(10,2),                                 -- 浊度 (NTU)
18
+    ph                  NUMERIC(5,2),                                  -- pH值
19
+    residual_chlorine   NUMERIC(6,3),                                  -- 余氯 (mg/L)
20
+    color               NUMERIC(8,2),                                  -- 色度 (度)
21
+    odor                NUMERIC(4,1),                                  -- 嗅味 (级)
22
+    ecoli               NUMERIC(10,2),                                 -- 大肠杆菌 (CFU/100mL)
23
+    colony_count        NUMERIC(10,2),                                 -- 菌落总数 (CFU/mL)
24
+    compliance_status   VARCHAR(20)     NOT NULL DEFAULT 'pending',    -- qualified/unqualified/pending
25
+    unqualified_items   TEXT,                                          -- 不合格项 (JSON)
26
+    remark              VARCHAR(500),                                  -- 备注
27
+    deleted             INTEGER         NOT NULL DEFAULT 0,
28
+    created_at          TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
29
+    updated_at          TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP
30
+);
31
+
32
+CREATE INDEX IF NOT EXISTS idx_quality_record_type ON prod_quality_test_record(test_type);
33
+CREATE INDEX IF NOT EXISTS idx_quality_record_water_type ON prod_quality_test_record(water_type);
34
+CREATE INDEX IF NOT EXISTS idx_quality_record_area ON prod_quality_test_record(area);
35
+CREATE INDEX IF NOT EXISTS idx_quality_record_date ON prod_quality_test_record(test_date);
36
+CREATE INDEX IF NOT EXISTS idx_quality_record_compliance ON prod_quality_test_record(compliance_status);
37
+CREATE INDEX IF NOT EXISTS idx_quality_record_deleted ON prod_quality_test_record(deleted);
38
+
39
+COMMENT ON TABLE prod_quality_test_record IS '水质检测记录表';
40
+COMMENT ON COLUMN prod_quality_test_record.test_type IS '检测类型: routine-常规/special-专项/complaint-投诉';
41
+COMMENT ON COLUMN prod_quality_test_record.water_type IS '水样类型: raw-原水/treated-出厂水/network-管网末梢水';
42
+COMMENT ON COLUMN prod_quality_test_record.compliance_status IS '合格状态: qualified-合格/unqualified-不合格/pending-待判定';
43
+
44
+-- 2. 水质标准表 (GB5749-2022)
45
+CREATE TABLE IF NOT EXISTS prod_quality_standard (
46
+    id              BIGSERIAL PRIMARY KEY,
47
+    standard_name   VARCHAR(100)    NOT NULL,
48
+    standard_code   VARCHAR(50)     NOT NULL DEFAULT 'GB5749-2022',
49
+    param_name      VARCHAR(50)     NOT NULL,                          -- 参数编码
50
+    param_label     VARCHAR(50),                                       -- 参数显示名
51
+    param_unit      VARCHAR(20),                                       -- 单位
52
+    min_value       NUMERIC(12,4),                                     -- 最小值 (NULL=无下限)
53
+    max_value       NUMERIC(12,4),                                     -- 最大值 (NULL=无上限)
54
+    water_type      VARCHAR(20)     NOT NULL DEFAULT 'all',            -- 适用水样类型
55
+    enabled         INTEGER         NOT NULL DEFAULT 1,
56
+    deleted         INTEGER         NOT NULL DEFAULT 0,
57
+    created_at      TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
58
+    updated_at      TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP
59
+);
60
+
61
+CREATE INDEX IF NOT EXISTS idx_quality_standard_code ON prod_quality_standard(standard_code);
62
+CREATE INDEX IF NOT EXISTS idx_quality_standard_param ON prod_quality_standard(param_name);
63
+CREATE INDEX IF NOT EXISTS idx_quality_standard_deleted ON prod_quality_standard(deleted);
64
+
65
+COMMENT ON TABLE prod_quality_standard IS '水质标准表 (基于GB5749-2022)';
66
+
67
+-- 初始化 GB5749-2022 默认标准
68
+INSERT INTO prod_quality_standard (standard_name, standard_code, param_name, param_label, param_unit, min_value, max_value, water_type)
69
+VALUES
70
+    ('生活饮用水卫生标准', 'GB5749-2022', 'turbidity', '浊度', 'NTU', NULL, 1.0, 'treated'),
71
+    ('生活饮用水卫生标准', 'GB5749-2022', 'turbidity', '浊度', 'NTU', NULL, 3.0, 'network'),
72
+    ('生活饮用水卫生标准', 'GB5749-2022', 'ph', 'pH', '', 6.5, 8.5, 'all'),
73
+    ('生活饮用水卫生标准', 'GB5749-2022', 'residual_chlorine', '余氯', 'mg/L', 0.3, 2.0, 'treated'),
74
+    ('生活饮用水卫生标准', 'GB5749-2022', 'residual_chlorine', '余氯', 'mg/L', 0.05, 2.0, 'network'),
75
+    ('生活饮用水卫生标准', 'GB5749-2022', 'color', '色度', '度', NULL, 15.0, 'all'),
76
+    ('生活饮用水卫生标准', 'GB5749-2022', 'odor', '嗅味', '级', NULL, 2.0, 'all'),
77
+    ('生活饮用水卫生标准', 'GB5749-2022', 'ecoli', '大肠杆菌', 'CFU/100mL', NULL, 0.0, 'all'),
78
+    ('生活饮用水卫生标准', 'GB5749-2022', 'colony_count', '菌落总数', 'CFU/mL', NULL, 100.0, 'all')
79
+ON CONFLICT DO NOTHING;
80
+
81
+-- 3. 水质检测计划表
82
+CREATE TABLE IF NOT EXISTS prod_quality_test_plan (
83
+    id              BIGSERIAL PRIMARY KEY,
84
+    plan_name       VARCHAR(100)    NOT NULL,
85
+    test_type       VARCHAR(20)     NOT NULL DEFAULT 'routine',        -- routine/special
86
+    water_type      VARCHAR(20)     NOT NULL DEFAULT 'treated',
87
+    sampling_point  VARCHAR(100),
88
+    area            VARCHAR(50),
89
+    frequency       VARCHAR(20)     NOT NULL DEFAULT 'daily',          -- daily/weekly/monthly
90
+    test_params     VARCHAR(200),                                      -- 检测参数 (逗号分隔)
91
+    start_date      DATE            NOT NULL,
92
+    end_date        DATE,                                              -- NULL=长期
93
+    next_test_date  DATE,
94
+    status          VARCHAR(20)     NOT NULL DEFAULT 'active',         -- active/paused/completed
95
+    execution_count INTEGER         NOT NULL DEFAULT 0,
96
+    remark          VARCHAR(500),
97
+    deleted         INTEGER         NOT NULL DEFAULT 0,
98
+    created_at      TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
99
+    updated_at      TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP
100
+);
101
+
102
+CREATE INDEX IF NOT EXISTS idx_quality_plan_status ON prod_quality_test_plan(status);
103
+CREATE INDEX IF NOT EXISTS idx_quality_plan_frequency ON prod_quality_test_plan(frequency);
104
+CREATE INDEX IF NOT EXISTS idx_quality_plan_next_date ON prod_quality_test_plan(next_test_date);
105
+CREATE INDEX IF NOT EXISTS idx_quality_plan_deleted ON prod_quality_test_plan(deleted);
106
+
107
+COMMENT ON TABLE prod_quality_test_plan IS '水质检测计划表';
108
+COMMENT ON COLUMN prod_quality_test_plan.frequency IS '检测频率: daily-日检/weekly-周检/monthly-月检';
109
+COMMENT ON COLUMN prod_quality_test_plan.status IS '计划状态: active-启用/paused-暂停/completed-已完成';
110
+
111
+-- 4. 水质检测点位表
112
+CREATE TABLE IF NOT EXISTS prod_quality_test_point (
113
+    id                  BIGSERIAL PRIMARY KEY,
114
+    point_name          VARCHAR(200)    NOT NULL,
115
+    point_type          VARCHAR(50)     NOT NULL DEFAULT 'factory',
116
+    location            VARCHAR(500),
117
+    longitude           NUMERIC(10,7),
118
+    latitude            NUMERIC(10,7),
119
+    sampling_frequency  VARCHAR(20)     NOT NULL DEFAULT 'daily',
120
+    area                VARCHAR(200),
121
+    water_plant         VARCHAR(200),
122
+    status              VARCHAR(20)     NOT NULL DEFAULT 'active',
123
+    remark              TEXT,
124
+    deleted             INTEGER         NOT NULL DEFAULT 0,
125
+    created_at          TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
126
+    updated_at          TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP
127
+);
128
+
129
+CREATE INDEX IF NOT EXISTS idx_quality_point_type ON prod_quality_test_point(point_type);
130
+CREATE INDEX IF NOT EXISTS idx_quality_point_area ON prod_quality_test_point(area);
131
+CREATE INDEX IF NOT EXISTS idx_quality_point_status ON prod_quality_test_point(status);
132
+CREATE INDEX IF NOT EXISTS idx_quality_point_deleted ON prod_quality_test_point(deleted);
133
+
134
+COMMENT ON TABLE prod_quality_test_point IS '水质检测点位表';
135
+COMMENT ON COLUMN prod_quality_test_point.point_type IS '点位类型: waterSource-水源/factory-出厂/endpoint-末梢';
136
+COMMENT ON COLUMN prod_quality_test_point.sampling_frequency IS '采样频率: daily/weekly/monthly/quarterly';

+ 187
- 0
frontend/src/api/qualityTest.ts View File

@@ -0,0 +1,187 @@
1
+import request from './request'
2
+
3
+const BASE = '/production/quality-test'
4
+
5
+// ==================== 检测记录 ====================
6
+
7
+export function queryRecords(params: {
8
+  pageNum?: number; pageSize?: number; testType?: string; waterType?: string;
9
+  area?: string; samplingPoint?: string; tester?: string; complianceStatus?: string;
10
+  startDate?: string; endDate?: string; keyword?: string; sortField?: string; sortOrder?: string
11
+}) {
12
+  return request.get(`${BASE}/records`, { params })
13
+}
14
+
15
+export function getRecord(id: number) {
16
+  return request.get(`${BASE}/records/${id}`)
17
+}
18
+
19
+export function createRecord(data: any) {
20
+  return request.post(`${BASE}/records`, data)
21
+}
22
+
23
+export function updateRecord(id: number, data: any) {
24
+  return request.put(`${BASE}/records/${id}`, data)
25
+}
26
+
27
+export function deleteRecord(id: number) {
28
+  return request.delete(`${BASE}/records/${id}`)
29
+}
30
+
31
+export function batchDeleteRecords(ids: number[]) {
32
+  return request.delete(`${BASE}/records/batch`, { data: ids })
33
+}
34
+
35
+export function reevaluateAll() {
36
+  return request.post(`${BASE}/records/reevaluate`)
37
+}
38
+
39
+export function getRecordAreas() {
40
+  return request.get(`${BASE}/records/areas`)
41
+}
42
+
43
+export function getSamplingPoints() {
44
+  return request.get(`${BASE}/records/sampling-points`)
45
+}
46
+
47
+// ==================== 检测点位 ====================
48
+
49
+export function queryPoints(params: {
50
+  pageNum?: number; pageSize?: number; pointType?: string;
51
+  area?: string; status?: string; keyword?: string
52
+}) {
53
+  return request.get(`${BASE}/points`, { params })
54
+}
55
+
56
+export function getAllPoints() {
57
+  return request.get(`${BASE}/points/all`)
58
+}
59
+
60
+export function getPoint(id: number) {
61
+  return request.get(`${BASE}/points/${id}`)
62
+}
63
+
64
+export function createPoint(data: any) {
65
+  return request.post(`${BASE}/points`, data)
66
+}
67
+
68
+export function updatePoint(id: number, data: any) {
69
+  return request.put(`${BASE}/points/${id}`, data)
70
+}
71
+
72
+export function deletePoint(id: number) {
73
+  return request.delete(`${BASE}/points/${id}`)
74
+}
75
+
76
+export function batchDeletePoints(ids: number[]) {
77
+  return request.delete(`${BASE}/points/batch`, { data: ids })
78
+}
79
+
80
+export function togglePointStatus(id: number) {
81
+  return request.put(`${BASE}/points/${id}/toggle`)
82
+}
83
+
84
+export function pointStatByType() {
85
+  return request.get(`${BASE}/points/stat/type`)
86
+}
87
+
88
+export function pointStatByArea() {
89
+  return request.get(`${BASE}/points/stat/area`)
90
+}
91
+
92
+export function pointComplianceRate(startDate?: string, endDate?: string) {
93
+  return request.get(`${BASE}/points/stat/compliance`, { params: { startDate, endDate } })
94
+}
95
+
96
+export function getPointAreas() {
97
+  return request.get(`${BASE}/points/areas`)
98
+}
99
+
100
+// ==================== 水质标准 ====================
101
+
102
+export function listStandards() {
103
+  return request.get(`${BASE}/standards`)
104
+}
105
+
106
+export function listAllStandards() {
107
+  return request.get(`${BASE}/standards/all`)
108
+}
109
+
110
+export function getStandardsByWaterType(waterType: string) {
111
+  return request.get(`${BASE}/standards/water-type/${waterType}`)
112
+}
113
+
114
+export function getStandard(id: number) {
115
+  return request.get(`${BASE}/standards/${id}`)
116
+}
117
+
118
+export function createStandard(data: any) {
119
+  return request.post(`${BASE}/standards`, data)
120
+}
121
+
122
+export function updateStandard(id: number, data: any) {
123
+  return request.put(`${BASE}/standards/${id}`, data)
124
+}
125
+
126
+export function deleteStandard(id: number) {
127
+  return request.delete(`${BASE}/standards/${id}`)
128
+}
129
+
130
+// ==================== 检测计划 ====================
131
+
132
+export function queryPlans(params: {
133
+  pageNum?: number; pageSize?: number; testType?: string; waterType?: string;
134
+  area?: string; status?: string; keyword?: string
135
+}) {
136
+  return request.get(`${BASE}/plans`, { params })
137
+}
138
+
139
+export function getPlan(id: number) {
140
+  return request.get(`${BASE}/plans/${id}`)
141
+}
142
+
143
+export function createPlan(data: any) {
144
+  return request.post(`${BASE}/plans`, data)
145
+}
146
+
147
+export function updatePlan(id: number, data: any) {
148
+  return request.put(`${BASE}/plans/${id}`, data)
149
+}
150
+
151
+export function deletePlan(id: number) {
152
+  return request.delete(`${BASE}/plans/${id}`)
153
+}
154
+
155
+export function togglePlanStatus(id: number) {
156
+  return request.put(`${BASE}/plans/${id}/status`)
157
+}
158
+
159
+export function getDuePlans() {
160
+  return request.get(`${BASE}/plans/due`)
161
+}
162
+
163
+export function markPlanExecuted(id: number) {
164
+  return request.post(`${BASE}/plans/${id}/execute`)
165
+}
166
+
167
+// ==================== 统计 ====================
168
+
169
+export function getStatistics(startDate?: string, endDate?: string) {
170
+  return request.get(`${BASE}/statistics`, { params: { startDate, endDate } })
171
+}
172
+
173
+// ==================== 导出 ====================
174
+
175
+export function exportExcel(params: any) {
176
+  return request.post(`${BASE}/export/excel`, null, {
177
+    params,
178
+    responseType: 'blob'
179
+  })
180
+}
181
+
182
+export function getExportPdfUrl(startDate?: string, endDate?: string) {
183
+  const params = new URLSearchParams()
184
+  if (startDate) params.set('startDate', startDate)
185
+  if (endDate) params.set('endDate', endDate)
186
+  return `/api${BASE}/export/pdf?${params.toString()}`
187
+}

+ 6
- 0
frontend/src/router/index.ts View File

@@ -13,6 +13,12 @@ const routes = [
13 13
       { path: 'system/dept', name: 'dept', component: () => import('@/views/system/dept/DeptList.vue') },
14 14
       { path: 'dispatch-command', name: 'dispatchCommandList', component: () => import('@/views/dispatch-command/CommandList.vue') },
15 15
       { path: 'dispatch-command/:id', name: 'dispatchCommandDetail', component: () => import('@/views/dispatch-command/CommandDetail.vue') },
16
+      { path: 'cs/knowledge', name: 'csKnowledge', component: () => import('@/views/cs/KnowledgeBaseView.vue') },
17
+      { path: 'cs/announcement', name: 'csAnnouncement', component: () => import('@/views/cs/AnnouncementView.vue') },
18
+      { path: 'cs/kpi', name: 'csKpi', component: () => import('@/views/cs/KpiDashboardView.vue') },
19
+      { path: 'quality-test/records', name: 'qualityTestRecords', component: () => import('@/views/quality-test/TestRecordList.vue') },
20
+      { path: 'quality-test/points', name: 'qualityTestPoints', component: () => import('@/views/quality-test/TestPointView.vue') },
21
+      { path: 'quality-test/stats', name: 'qualityTestStats', component: () => import('@/views/quality-test/TestStatsView.vue') },
16 22
     ]
17 23
   },
18 24
   { path: '/:pathMatch(.*)*', redirect: '/dashboard' }

+ 295
- 0
frontend/src/views/quality-test/TestPointView.vue View File

@@ -0,0 +1,295 @@
1
+<template>
2
+  <div class="test-point-view">
3
+    <el-card shadow="never" class="filter-card">
4
+      <el-form :inline="true" :model="filterForm">
5
+        <el-form-item label="点位类型">
6
+          <el-select v-model="filterForm.pointType" placeholder="全部" clearable @change="handleSearch">
7
+            <el-option label="水源" value="waterSource" />
8
+            <el-option label="出厂" value="factory" />
9
+            <el-option label="末梢" value="endpoint" />
10
+          </el-select>
11
+        </el-form-item>
12
+        <el-form-item label="区域">
13
+          <el-select v-model="filterForm.area" placeholder="全部" clearable @change="handleSearch">
14
+            <el-option v-for="a in areaList" :key="a" :label="a" :value="a" />
15
+          </el-select>
16
+        </el-form-item>
17
+        <el-form-item label="状态">
18
+          <el-select v-model="filterForm.status" placeholder="全部" clearable @change="handleSearch">
19
+            <el-option label="启用" value="active" />
20
+            <el-option label="停用" value="inactive" />
21
+          </el-select>
22
+        </el-form-item>
23
+        <el-form-item label="关键词">
24
+          <el-input v-model="filterForm.keyword" placeholder="点位名称/位置/水厂" clearable @clear="handleSearch" />
25
+        </el-form-item>
26
+        <el-form-item>
27
+          <el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
28
+          <el-button @click="handleReset">重置</el-button>
29
+        </el-form-item>
30
+      </el-form>
31
+    </el-card>
32
+
33
+    <el-row :gutter="12" style="margin-top: 12px">
34
+      <el-col :span="6" v-for="stat in typeStats" :key="stat.pointType">
35
+        <el-card shadow="hover" class="stat-card" @click="filterByType(stat.pointType)">
36
+          <div class="stat-value">{{ stat.count }}</div>
37
+          <div class="stat-label">{{ pointTypeLabel(stat.pointType) }} (启用 {{ stat.activeCount }})</div>
38
+        </el-card>
39
+      </el-col>
40
+    </el-row>
41
+
42
+    <div style="margin-top: 16px; display: flex; justify-content: space-between">
43
+      <el-button type="primary" @click="showCreateDialog = true"><el-icon><Plus /></el-icon> 新建点位</el-button>
44
+      <el-button @click="handleBatchDelete" :disabled="!selectedIds.length" type="danger" plain>批量删除</el-button>
45
+    </div>
46
+
47
+    <el-table :data="tableData" border style="margin-top: 10px"
48
+      @selection-change="handleSelectionChange" v-loading="loading">
49
+      <el-table-column type="selection" width="50" />
50
+      <el-table-column prop="point_name" label="点位名称" min-width="160" show-overflow-tooltip />
51
+      <el-table-column prop="point_type" label="类型" width="100">
52
+        <template #default="{ row }">
53
+          <el-tag :type="pointTypeTag(row.point_type)" size="small">{{ pointTypeLabel(row.point_type) }}</el-tag>
54
+        </template>
55
+      </el-table-column>
56
+      <el-table-column prop="location" label="位置" min-width="180" show-overflow-tooltip />
57
+      <el-table-column prop="area" label="区域" width="90" />
58
+      <el-table-column prop="water_plant" label="关联水厂" width="120" />
59
+      <el-table-column prop="sampling_frequency" label="采样频率" width="100">
60
+        <template #default="{ row }">
61
+          <el-tag type="info" size="small">{{ freqLabel(row.sampling_frequency) }}</el-tag>
62
+        </template>
63
+      </el-table-column>
64
+      <el-table-column prop="status" label="状态" width="80">
65
+        <template #default="{ row }">
66
+          <el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
67
+            {{ row.status === 'active' ? '启用' : '停用' }}
68
+          </el-tag>
69
+        </template>
70
+      </el-table-column>
71
+      <el-table-column label="操作" width="180" fixed="right">
72
+        <template #default="{ row }">
73
+          <el-button link type="primary" @click="showEdit(row)">编辑</el-button>
74
+          <el-button link :type="row.status === 'active' ? 'warning' : 'success'" @click="handleToggle(row)">
75
+            {{ row.status === 'active' ? '停用' : '启用' }}
76
+          </el-button>
77
+          <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
78
+        </template>
79
+      </el-table-column>
80
+    </el-table>
81
+
82
+    <el-pagination style="margin-top: 16px; justify-content: flex-end"
83
+      v-model:current-page="pagination.page" v-model:page-size="pagination.size"
84
+      :total="pagination.total" :page-sizes="[10, 20, 50]"
85
+      layout="total, sizes, prev, pager, next" @change="fetchData" />
86
+
87
+    <el-dialog v-model="showCreateDialog" :title="editingPoint ? '编辑检测点位' : '新建检测点位'" width="600px" destroy-on-close>
88
+      <el-form :model="pointForm" label-width="110px" :rules="rules" ref="formRef">
89
+        <el-form-item label="点位名称" prop="pointName">
90
+          <el-input v-model="pointForm.pointName" placeholder="请输入点位名称" />
91
+        </el-form-item>
92
+        <el-row :gutter="16">
93
+          <el-col :span="12">
94
+            <el-form-item label="点位类型" prop="pointType">
95
+              <el-select v-model="pointForm.pointType" placeholder="请选择" style="width: 100%">
96
+                <el-option label="水源" value="waterSource" />
97
+                <el-option label="出厂" value="factory" />
98
+                <el-option label="末梢" value="endpoint" />
99
+              </el-select>
100
+            </el-form-item>
101
+          </el-col>
102
+          <el-col :span="12">
103
+            <el-form-item label="采样频率" prop="samplingFrequency">
104
+              <el-select v-model="pointForm.samplingFrequency" placeholder="请选择" style="width: 100%">
105
+                <el-option label="日检" value="daily" />
106
+                <el-option label="周检" value="weekly" />
107
+                <el-option label="月检" value="monthly" />
108
+                <el-option label="季检" value="quarterly" />
109
+              </el-select>
110
+            </el-form-item>
111
+          </el-col>
112
+        </el-row>
113
+        <el-form-item label="位置描述">
114
+          <el-input v-model="pointForm.location" placeholder="位置描述" />
115
+        </el-form-item>
116
+        <el-row :gutter="16">
117
+          <el-col :span="12">
118
+            <el-form-item label="所属区域">
119
+              <el-input v-model="pointForm.area" placeholder="区域" />
120
+            </el-form-item>
121
+          </el-col>
122
+          <el-col :span="12">
123
+            <el-form-item label="关联水厂">
124
+              <el-input v-model="pointForm.waterPlant" placeholder="水厂名称" />
125
+            </el-form-item>
126
+          </el-col>
127
+        </el-row>
128
+        <el-row :gutter="16">
129
+          <el-col :span="12">
130
+            <el-form-item label="经度">
131
+              <el-input-number v-model="pointForm.longitude" :precision="7" :step="0.001" controls-position="right" style="width: 100%" />
132
+            </el-form-item>
133
+          </el-col>
134
+          <el-col :span="12">
135
+            <el-form-item label="纬度">
136
+              <el-input-number v-model="pointForm.latitude" :precision="7" :step="0.001" controls-position="right" style="width: 100%" />
137
+            </el-form-item>
138
+          </el-col>
139
+        </el-row>
140
+        <el-form-item label="备注">
141
+          <el-input v-model="pointForm.remark" type="textarea" :rows="2" placeholder="备注" />
142
+        </el-form-item>
143
+      </el-form>
144
+      <template #footer>
145
+        <el-button @click="showCreateDialog = false">取消</el-button>
146
+        <el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
147
+      </template>
148
+    </el-dialog>
149
+  </div>
150
+</template>
151
+
152
+<script setup lang="ts">
153
+import { ref, reactive, onMounted } from 'vue'
154
+import { ElMessage, ElMessageBox } from 'element-plus'
155
+import { Search, Plus } from '@element-plus/icons-vue'
156
+import {
157
+  queryPoints, createPoint, updatePoint, deletePoint, batchDeletePoints,
158
+  togglePointStatus, pointStatByType, getPointAreas
159
+} from '@/api/qualityTest'
160
+
161
+const loading = ref(false)
162
+const submitLoading = ref(false)
163
+const tableData = ref<any[]>([])
164
+const selectedIds = ref<number[]>([])
165
+const showCreateDialog = ref(false)
166
+const editingPoint = ref<any>(null)
167
+const typeStats = ref<any[]>([])
168
+const areaList = ref<string[]>([])
169
+const formRef = ref()
170
+
171
+const filterForm = reactive({ pointType: '', area: '', status: '', keyword: '' })
172
+const pagination = reactive({ page: 1, size: 10, total: 0 })
173
+
174
+const defaultForm = () => ({
175
+  pointName: '', pointType: 'factory', location: '', area: '',
176
+  waterPlant: '', samplingFrequency: 'daily',
177
+  longitude: undefined, latitude: undefined, remark: ''
178
+})
179
+const pointForm = reactive(defaultForm())
180
+
181
+const rules = {
182
+  pointName: [{ required: true, message: '请输入点位名称', trigger: 'blur' }],
183
+  pointType: [{ required: true, message: '请选择点位类型', trigger: 'change' }],
184
+  samplingFrequency: [{ required: true, message: '请选择采样频率', trigger: 'change' }]
185
+}
186
+
187
+const pointTypeMap: Record<string, { label: string; type: string }> = {
188
+  waterSource: { label: '水源', type: 'warning' },
189
+  factory: { label: '出厂', type: 'primary' },
190
+  endpoint: { label: '末梢', type: 'success' }
191
+}
192
+const pointTypeLabel = (v: string) => pointTypeMap[v]?.label || v
193
+const pointTypeTag = (v: string) => (pointTypeMap[v]?.type || 'info') as any
194
+
195
+const freqMap: Record<string, string> = { daily: '日检', weekly: '周检', monthly: '月检', quarterly: '季检' }
196
+const freqLabel = (v: string) => freqMap[v] || v
197
+
198
+const fetchData = async () => {
199
+  loading.value = true
200
+  try {
201
+    const res: any = await queryPoints({
202
+      pageNum: pagination.page, pageSize: pagination.size,
203
+      pointType: filterForm.pointType || undefined,
204
+      area: filterForm.area || undefined,
205
+      status: filterForm.status || undefined,
206
+      keyword: filterForm.keyword || undefined
207
+    })
208
+    tableData.value = res.data?.records || []
209
+    pagination.total = res.data?.total || 0
210
+  } catch (e) { console.error(e) }
211
+  finally { loading.value = false }
212
+}
213
+
214
+const loadStats = async () => {
215
+  try {
216
+    const res: any = await pointStatByType()
217
+    typeStats.value = res.data || []
218
+  } catch (e) { console.error(e) }
219
+}
220
+
221
+const loadAreas = async () => {
222
+  try {
223
+    const res: any = await getPointAreas()
224
+    areaList.value = res.data || []
225
+  } catch (e) { console.error(e) }
226
+}
227
+
228
+const handleSearch = () => { pagination.page = 1; fetchData() }
229
+const handleReset = () => {
230
+  Object.assign(filterForm, { pointType: '', area: '', status: '', keyword: '' })
231
+  handleSearch()
232
+}
233
+const filterByType = (type: string) => { filterForm.pointType = type; handleSearch() }
234
+
235
+const handleSubmit = async () => {
236
+  await formRef.value?.validate()
237
+  submitLoading.value = true
238
+  try {
239
+    if (editingPoint.value) {
240
+      await updatePoint(editingPoint.value.id, pointForm)
241
+      ElMessage.success('更新成功')
242
+    } else {
243
+      await createPoint(pointForm)
244
+      ElMessage.success('创建成功')
245
+    }
246
+    showCreateDialog.value = false
247
+    editingPoint.value = null
248
+    Object.assign(pointForm, defaultForm())
249
+    fetchData(); loadStats(); loadAreas()
250
+  } catch (e) { console.error(e) }
251
+  finally { submitLoading.value = false }
252
+}
253
+
254
+const showEdit = (row: any) => {
255
+  editingPoint.value = row
256
+  Object.assign(pointForm, {
257
+    pointName: row.point_name, pointType: row.point_type,
258
+    location: row.location, area: row.area,
259
+    waterPlant: row.water_plant, samplingFrequency: row.sampling_frequency,
260
+    longitude: row.longitude, latitude: row.latitude, remark: row.remark
261
+  })
262
+  showCreateDialog.value = true
263
+}
264
+
265
+const handleDelete = async (row: any) => {
266
+  await ElMessageBox.confirm('确认删除该检测点位?', '提示', { type: 'warning' })
267
+  await deletePoint(row.id)
268
+  ElMessage.success('删除成功')
269
+  fetchData(); loadStats(); loadAreas()
270
+}
271
+
272
+const handleBatchDelete = async () => {
273
+  await ElMessageBox.confirm(`确认删除选中的 ${selectedIds.value.length} 个点位?`, '提示', { type: 'warning' })
274
+  await batchDeletePoints(selectedIds.value)
275
+  ElMessage.success('批量删除成功')
276
+  fetchData(); loadStats(); loadAreas()
277
+}
278
+
279
+const handleToggle = async (row: any) => {
280
+  await togglePointStatus(row.id)
281
+  ElMessage.success(`已${row.status === 'active' ? '停用' : '启用'}该点位`)
282
+  fetchData(); loadStats()
283
+}
284
+
285
+const handleSelectionChange = (rows: any[]) => { selectedIds.value = rows.map(r => r.id) }
286
+
287
+onMounted(() => { fetchData(); loadStats(); loadAreas() })
288
+</script>
289
+
290
+<style scoped>
291
+.filter-card { margin-bottom: 8px; }
292
+.stat-card { cursor: pointer; text-align: center; }
293
+.stat-value { font-size: 28px; font-weight: bold; color: #409eff; }
294
+.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
295
+</style>

+ 420
- 0
frontend/src/views/quality-test/TestRecordList.vue View File

@@ -0,0 +1,420 @@
1
+<template>
2
+  <div class="test-record-list">
3
+    <el-card shadow="never" class="filter-card">
4
+      <el-form :inline="true" :model="filterForm">
5
+        <el-form-item label="水质类型">
6
+          <el-select v-model="filterForm.waterType" placeholder="全部" clearable @change="handleSearch">
7
+            <el-option label="原水" value="rawWater" />
8
+            <el-option label="出厂水" value="factoryWater" />
9
+            <el-option label="管网水" value="pipeNetworkWater" />
10
+            <el-option label="末梢水" value="endUserWater" />
11
+          </el-select>
12
+        </el-form-item>
13
+        <el-form-item label="检测类型">
14
+          <el-select v-model="filterForm.testType" placeholder="全部" clearable @change="handleSearch">
15
+            <el-option label="常规" value="routine" />
16
+            <el-option label="应急" value="emergency" />
17
+            <el-option label="专项" value="special" />
18
+          </el-select>
19
+        </el-form-item>
20
+        <el-form-item label="合格状态">
21
+          <el-select v-model="filterForm.complianceStatus" placeholder="全部" clearable @change="handleSearch">
22
+            <el-option label="合格" value="qualified" />
23
+            <el-option label="不合格" value="unqualified" />
24
+            <el-option label="待判定" value="pending" />
25
+          </el-select>
26
+        </el-form-item>
27
+        <el-form-item label="采样点">
28
+          <el-input v-model="filterForm.samplingPoint" placeholder="采样点" clearable @clear="handleSearch" />
29
+        </el-form-item>
30
+        <el-form-item label="时间范围">
31
+          <el-date-picker v-model="dateRange" type="daterange" range-separator="至"
32
+            start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD"
33
+            @change="handleSearch" />
34
+        </el-form-item>
35
+        <el-form-item label="关键词">
36
+          <el-input v-model="filterForm.keyword" placeholder="采样点/检测人/备注" clearable @clear="handleSearch" />
37
+        </el-form-item>
38
+        <el-form-item>
39
+          <el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
40
+          <el-button @click="handleReset">重置</el-button>
41
+        </el-form-item>
42
+      </el-form>
43
+    </el-card>
44
+
45
+    <div style="margin-top: 16px; display: flex; justify-content: space-between; align-items: center">
46
+      <div>
47
+        <el-button type="primary" @click="showCreateDialog = true"><el-icon><Plus /></el-icon> 新建记录</el-button>
48
+        <el-button @click="handleBatchDelete" :disabled="!selectedIds.length" type="danger" plain>批量删除</el-button>
49
+        <el-button @click="handleReevaluate" type="warning" plain>重新判定</el-button>
50
+      </div>
51
+      <div>
52
+        <el-button @click="handleExportExcel" type="success" plain><el-icon><Download /></el-icon> 导出 Excel</el-button>
53
+        <el-button @click="handleExportPdf" type="danger" plain><el-icon><Document /></el-icon> 导出 PDF</el-button>
54
+      </div>
55
+    </div>
56
+
57
+    <el-table :data="tableData" border style="margin-top: 10px"
58
+      @selection-change="handleSelectionChange" v-loading="loading">
59
+      <el-table-column type="selection" width="50" />
60
+      <el-table-column prop="test_type" label="检测类型" width="100">
61
+        <template #default="{ row }">
62
+          <el-tag :type="testTypeTag(row.test_type)" size="small">{{ testTypeLabel(row.test_type) }}</el-tag>
63
+        </template>
64
+      </el-table-column>
65
+      <el-table-column prop="water_type" label="水质类型" width="110">
66
+        <template #default="{ row }">
67
+          <el-tag type="info" size="small">{{ waterTypeLabel(row.water_type) }}</el-tag>
68
+        </template>
69
+      </el-table-column>
70
+      <el-table-column prop="sampling_point" label="采样点" min-width="140" show-overflow-tooltip />
71
+      <el-table-column prop="area" label="区域" width="90" />
72
+      <el-table-column prop="test_date" label="检测日期" width="110" />
73
+      <el-table-column prop="tester" label="检测人" width="90" />
74
+      <el-table-column prop="turbidity" label="浊度(NTU)" width="100" align="right" />
75
+      <el-table-column prop="ph" label="pH" width="70" align="right" />
76
+      <el-table-column prop="residual_chlorine" label="余氯" width="80" align="right" />
77
+      <el-table-column prop="compliance_status" label="合格状态" width="100">
78
+        <template #default="{ row }">
79
+          <el-tag :type="complianceTag(row.compliance_status)" size="small">
80
+            {{ complianceLabel(row.compliance_status) }}
81
+          </el-tag>
82
+        </template>
83
+      </el-table-column>
84
+      <el-table-column prop="unqualified_items" label="不合格项" width="160" show-overflow-tooltip>
85
+        <template #default="{ row }">
86
+          <span v-if="row.unqualified_items" class="unqualified-text">{{ formatUnqualified(row.unqualified_items) }}</span>
87
+          <span v-else class="text-muted">-</span>
88
+        </template>
89
+      </el-table-column>
90
+      <el-table-column label="操作" width="160" fixed="right">
91
+        <template #default="{ row }">
92
+          <el-button link type="primary" @click="showDetail(row)">详情</el-button>
93
+          <el-button link type="warning" @click="showEdit(row)">编辑</el-button>
94
+          <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
95
+        </template>
96
+      </el-table-column>
97
+    </el-table>
98
+
99
+    <el-pagination style="margin-top: 16px; justify-content: flex-end"
100
+      v-model:current-page="pagination.page" v-model:page-size="pagination.size"
101
+      :total="pagination.total" :page-sizes="[10, 20, 50]"
102
+      layout="total, sizes, prev, pager, next" @change="fetchData" />
103
+
104
+    <el-dialog v-model="showCreateDialog" :title="editingRecord ? '编辑检测记录' : '新建检测记录'" width="700px" destroy-on-close>
105
+      <el-form :model="recordForm" label-width="110px" :rules="rules" ref="formRef">
106
+        <el-row :gutter="16">
107
+          <el-col :span="12">
108
+            <el-form-item label="水质类型" prop="waterType">
109
+              <el-select v-model="recordForm.waterType" placeholder="请选择">
110
+                <el-option label="原水" value="rawWater" />
111
+                <el-option label="出厂水" value="factoryWater" />
112
+                <el-option label="管网水" value="pipeNetworkWater" />
113
+                <el-option label="末梢水" value="endUserWater" />
114
+              </el-select>
115
+            </el-form-item>
116
+          </el-col>
117
+          <el-col :span="12">
118
+            <el-form-item label="检测类型" prop="testType">
119
+              <el-select v-model="recordForm.testType" placeholder="请选择">
120
+                <el-option label="常规" value="routine" />
121
+                <el-option label="应急" value="emergency" />
122
+                <el-option label="专项" value="special" />
123
+              </el-select>
124
+            </el-form-item>
125
+          </el-col>
126
+        </el-row>
127
+        <el-row :gutter="16">
128
+          <el-col :span="12">
129
+            <el-form-item label="采样点" prop="samplingPoint">
130
+              <el-input v-model="recordForm.samplingPoint" placeholder="采样点名称" />
131
+            </el-form-item>
132
+          </el-col>
133
+          <el-col :span="12">
134
+            <el-form-item label="所属区域" prop="area">
135
+              <el-input v-model="recordForm.area" placeholder="区域" />
136
+            </el-form-item>
137
+          </el-col>
138
+        </el-row>
139
+        <el-row :gutter="16">
140
+          <el-col :span="12">
141
+            <el-form-item label="检测日期" prop="testDate">
142
+              <el-date-picker v-model="recordForm.testDate" type="date" value-format="YYYY-MM-DD"
143
+                placeholder="选择日期" style="width: 100%" />
144
+            </el-form-item>
145
+          </el-col>
146
+          <el-col :span="12">
147
+            <el-form-item label="检测人" prop="tester">
148
+              <el-input v-model="recordForm.tester" placeholder="检测人" />
149
+            </el-form-item>
150
+          </el-col>
151
+        </el-row>
152
+        <el-divider>检测指标</el-divider>
153
+        <el-row :gutter="16">
154
+          <el-col :span="8">
155
+            <el-form-item label="浊度(NTU)">
156
+              <el-input-number v-model="recordForm.turbidity" :precision="4" :min="0" :max="9999" controls-position="right" />
157
+            </el-form-item>
158
+          </el-col>
159
+          <el-col :span="8">
160
+            <el-form-item label="pH值">
161
+              <el-input-number v-model="recordForm.ph" :precision="2" :min="0" :max="14" :step="0.1" controls-position="right" />
162
+            </el-form-item>
163
+          </el-col>
164
+          <el-col :span="8">
165
+            <el-form-item label="余氯(mg/L)">
166
+              <el-input-number v-model="recordForm.residualChlorine" :precision="4" :min="0" :max="99" controls-position="right" />
167
+            </el-form-item>
168
+          </el-col>
169
+        </el-row>
170
+        <el-row :gutter="16">
171
+          <el-col :span="8">
172
+            <el-form-item label="色度(度)">
173
+              <el-input-number v-model="recordForm.color" :precision="2" :min="0" :max="9999" controls-position="right" />
174
+            </el-form-item>
175
+          </el-col>
176
+          <el-col :span="8">
177
+            <el-form-item label="嗅味(级)">
178
+              <el-input v-model="recordForm.odor" placeholder="0-3" />
179
+            </el-form-item>
180
+          </el-col>
181
+          <el-col :span="8">
182
+            <el-form-item label="大肠杆菌">
183
+              <el-input-number v-model="recordForm.ecoli" :precision="2" :min="0" :max="999999" controls-position="right" />
184
+            </el-form-item>
185
+          </el-col>
186
+        </el-row>
187
+        <el-row :gutter="16">
188
+          <el-col :span="8">
189
+            <el-form-item label="菌落总数">
190
+              <el-input-number v-model="recordForm.colonyCount" :precision="2" :min="0" :max="999999" controls-position="right" />
191
+            </el-form-item>
192
+          </el-col>
193
+        </el-row>
194
+        <el-form-item label="备注">
195
+          <el-input v-model="recordForm.remark" type="textarea" :rows="2" placeholder="备注信息" />
196
+        </el-form-item>
197
+      </el-form>
198
+      <template #footer>
199
+        <el-button @click="showCreateDialog = false">取消</el-button>
200
+        <el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
201
+      </template>
202
+    </el-dialog>
203
+
204
+    <el-dialog v-model="showDetailDialog" title="检测记录详情" width="600px">
205
+      <el-descriptions :column="2" border v-if="detailRecord">
206
+        <el-descriptions-item label="检测类型">{{ testTypeLabel(detailRecord.test_type) }}</el-descriptions-item>
207
+        <el-descriptions-item label="水质类型">{{ waterTypeLabel(detailRecord.water_type) }}</el-descriptions-item>
208
+        <el-descriptions-item label="采样点">{{ detailRecord.sampling_point }}</el-descriptions-item>
209
+        <el-descriptions-item label="区域">{{ detailRecord.area }}</el-descriptions-item>
210
+        <el-descriptions-item label="检测日期">{{ detailRecord.test_date }}</el-descriptions-item>
211
+        <el-descriptions-item label="检测时间">{{ detailRecord.test_time }}</el-descriptions-item>
212
+        <el-descriptions-item label="检测人">{{ detailRecord.tester }}</el-descriptions-item>
213
+        <el-descriptions-item label="合格状态">
214
+          <el-tag :type="complianceTag(detailRecord.compliance_status)">
215
+            {{ complianceLabel(detailRecord.compliance_status) }}
216
+          </el-tag>
217
+        </el-descriptions-item>
218
+        <el-descriptions-item label="浊度(NTU)">{{ detailRecord.turbidity ?? '-' }}</el-descriptions-item>
219
+        <el-descriptions-item label="pH">{{ detailRecord.ph ?? '-' }}</el-descriptions-item>
220
+        <el-descriptions-item label="余氯(mg/L)">{{ detailRecord.residual_chlorine ?? '-' }}</el-descriptions-item>
221
+        <el-descriptions-item label="色度(度)">{{ detailRecord.color ?? '-' }}</el-descriptions-item>
222
+        <el-descriptions-item label="嗅味(级)">{{ detailRecord.odor ?? '-' }}</el-descriptions-item>
223
+        <el-descriptions-item label="大肠杆菌">{{ detailRecord.ecoli ?? '-' }}</el-descriptions-item>
224
+        <el-descriptions-item label="菌落总数">{{ detailRecord.colony_count ?? '-' }}</el-descriptions-item>
225
+        <el-descriptions-item label="不合格项" :span="2">
226
+          <span v-if="detailRecord.unqualified_items" class="unqualified-text">
227
+            {{ formatUnqualified(detailRecord.unqualified_items) }}
228
+          </span>
229
+          <span v-else>-</span>
230
+        </el-descriptions-item>
231
+        <el-descriptions-item label="备注" :span="2">{{ detailRecord.remark || '-' }}</el-descriptions-item>
232
+        <el-descriptions-item label="创建时间">{{ detailRecord.created_at }}</el-descriptions-item>
233
+        <el-descriptions-item label="更新时间">{{ detailRecord.updated_at }}</el-descriptions-item>
234
+      </el-descriptions>
235
+    </el-dialog>
236
+  </div>
237
+</template>
238
+
239
+<script setup lang="ts">
240
+import { ref, reactive, onMounted } from 'vue'
241
+import { ElMessage, ElMessageBox } from 'element-plus'
242
+import { Search, Plus, Download, Document } from '@element-plus/icons-vue'
243
+import {
244
+  queryRecords, createRecord, updateRecord, deleteRecord, batchDeleteRecords,
245
+  reevaluateAll, exportExcel, getExportPdfUrl
246
+} from '@/api/qualityTest'
247
+
248
+const loading = ref(false)
249
+const submitLoading = ref(false)
250
+const tableData = ref<any[]>([])
251
+const selectedIds = ref<number[]>([])
252
+const showCreateDialog = ref(false)
253
+const showDetailDialog = ref(false)
254
+const detailRecord = ref<any>(null)
255
+const editingRecord = ref<any>(null)
256
+const dateRange = ref<[string, string] | null>(null)
257
+const formRef = ref()
258
+
259
+const filterForm = reactive({
260
+  waterType: '', testType: '', complianceStatus: '',
261
+  samplingPoint: '', keyword: ''
262
+})
263
+
264
+const pagination = reactive({ page: 1, size: 10, total: 0 })
265
+
266
+const defaultForm = () => ({
267
+  waterType: '', testType: 'routine', samplingPoint: '', area: '',
268
+  testDate: new Date().toISOString().slice(0, 10), tester: '',
269
+  turbidity: undefined, ph: undefined, residualChlorine: undefined,
270
+  color: undefined, odor: '', ecoli: undefined, colonyCount: undefined, remark: ''
271
+})
272
+
273
+const recordForm = reactive(defaultForm())
274
+
275
+const rules = {
276
+  waterType: [{ required: true, message: '请选择水质类型', trigger: 'change' }],
277
+  testType: [{ required: true, message: '请选择检测类型', trigger: 'change' }],
278
+  samplingPoint: [{ required: true, message: '请输入采样点', trigger: 'blur' }],
279
+  testDate: [{ required: true, message: '请选择检测日期', trigger: 'change' }]
280
+}
281
+
282
+const testTypeMap: Record<string, { label: string; type: string }> = {
283
+  routine: { label: '常规', type: '' },
284
+  emergency: { label: '应急', type: 'danger' },
285
+  special: { label: '专项', type: 'warning' }
286
+}
287
+const testTypeLabel = (v: string) => testTypeMap[v]?.label || v
288
+const testTypeTag = (v: string) => (testTypeMap[v]?.type || '') as any
289
+
290
+const waterTypeMap: Record<string, string> = {
291
+  rawWater: '原水', factoryWater: '出厂水',
292
+  pipeNetworkWater: '管网水', endUserWater: '末梢水'
293
+}
294
+const waterTypeLabel = (v: string) => waterTypeMap[v] || v
295
+
296
+const complianceMap: Record<string, { label: string; type: string }> = {
297
+  qualified: { label: '合格', type: 'success' },
298
+  unqualified: { label: '不合格', type: 'danger' },
299
+  pending: { label: '待判定', type: 'warning' }
300
+}
301
+const complianceLabel = (v: string) => complianceMap[v]?.label || v
302
+const complianceTag = (v: string) => (complianceMap[v]?.type || 'info') as any
303
+
304
+const formatUnqualified = (items: string) => {
305
+  try { return JSON.parse(items).join(', ') } catch { return items }
306
+}
307
+
308
+const fetchData = async () => {
309
+  loading.value = true
310
+  try {
311
+    const res: any = await queryRecords({
312
+      pageNum: pagination.page, pageSize: pagination.size,
313
+      waterType: filterForm.waterType || undefined,
314
+      testType: filterForm.testType || undefined,
315
+      complianceStatus: filterForm.complianceStatus || undefined,
316
+      samplingPoint: filterForm.samplingPoint || undefined,
317
+      keyword: filterForm.keyword || undefined,
318
+      startDate: dateRange.value?.[0], endDate: dateRange.value?.[1]
319
+    })
320
+    tableData.value = res.data?.records || []
321
+    pagination.total = res.data?.total || 0
322
+  } catch (e) { console.error(e) }
323
+  finally { loading.value = false }
324
+}
325
+
326
+const handleSearch = () => { pagination.page = 1; fetchData() }
327
+const handleReset = () => {
328
+  Object.assign(filterForm, { waterType: '', testType: '', complianceStatus: '', samplingPoint: '', keyword: '' })
329
+  dateRange.value = null
330
+  handleSearch()
331
+}
332
+
333
+const handleSubmit = async () => {
334
+  await formRef.value?.validate()
335
+  submitLoading.value = true
336
+  try {
337
+    if (editingRecord.value) {
338
+      await updateRecord(editingRecord.value.id, recordForm)
339
+      ElMessage.success('更新成功')
340
+    } else {
341
+      await createRecord(recordForm)
342
+      ElMessage.success('创建成功(已自动合格判定)')
343
+    }
344
+    showCreateDialog.value = false
345
+    editingRecord.value = null
346
+    Object.assign(recordForm, defaultForm())
347
+    fetchData()
348
+  } catch (e) { console.error(e) }
349
+  finally { submitLoading.value = false }
350
+}
351
+
352
+const showEdit = (row: any) => {
353
+  editingRecord.value = row
354
+  Object.assign(recordForm, {
355
+    waterType: row.water_type, testType: row.test_type,
356
+    samplingPoint: row.sampling_point, area: row.area,
357
+    testDate: row.test_date, tester: row.tester,
358
+    turbidity: row.turbidity, ph: row.ph,
359
+    residualChlorine: row.residual_chlorine, color: row.color,
360
+    odor: row.odor, ecoli: row.ecoli,
361
+    colonyCount: row.colony_count, remark: row.remark
362
+  })
363
+  showCreateDialog.value = true
364
+}
365
+
366
+const showDetail = (row: any) => { detailRecord.value = row; showDetailDialog.value = true }
367
+
368
+const handleDelete = async (row: any) => {
369
+  await ElMessageBox.confirm('确认删除该检测记录?', '提示', { type: 'warning' })
370
+  await deleteRecord(row.id)
371
+  ElMessage.success('删除成功')
372
+  fetchData()
373
+}
374
+
375
+const handleBatchDelete = async () => {
376
+  await ElMessageBox.confirm(`确认删除选中的 ${selectedIds.value.length} 条记录?`, '提示', { type: 'warning' })
377
+  await batchDeleteRecords(selectedIds.value)
378
+  ElMessage.success('批量删除成功')
379
+  fetchData()
380
+}
381
+
382
+const handleReevaluate = async () => {
383
+  await ElMessageBox.confirm('将对所有记录重新执行合格判定,确认?', '重新判定', { type: 'warning' })
384
+  const res: any = await reevaluateAll()
385
+  ElMessage.success(`重新判定完成,${res.data?.updatedCount || 0} 条记录状态变更`)
386
+  fetchData()
387
+}
388
+
389
+const handleSelectionChange = (rows: any[]) => { selectedIds.value = rows.map(r => r.id) }
390
+
391
+const handleExportExcel = async () => {
392
+  try {
393
+    const res: any = await exportExcel({
394
+      waterType: filterForm.waterType || undefined,
395
+      testType: filterForm.testType || undefined,
396
+      complianceStatus: filterForm.complianceStatus || undefined,
397
+      startDate: dateRange.value?.[0], endDate: dateRange.value?.[1],
398
+      keyword: filterForm.keyword || undefined
399
+    })
400
+    const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
401
+    const url = URL.createObjectURL(blob)
402
+    const a = document.createElement('a')
403
+    a.href = url; a.download = '水质检测报告.xlsx'; a.click()
404
+    URL.revokeObjectURL(url)
405
+  } catch (e) { console.error(e) }
406
+}
407
+
408
+const handleExportPdf = () => {
409
+  const url = getExportPdfUrl(dateRange.value?.[0], dateRange.value?.[1])
410
+  window.open(url, '_blank')
411
+}
412
+
413
+onMounted(fetchData)
414
+</script>
415
+
416
+<style scoped>
417
+.filter-card { margin-bottom: 8px; }
418
+.unqualified-text { color: #f56c6c; font-size: 12px; }
419
+.text-muted { color: #c0c4cc; }
420
+</style>

+ 213
- 0
frontend/src/views/quality-test/TestStatsView.vue View File

@@ -0,0 +1,213 @@
1
+<template>
2
+  <div class="test-stats-view">
3
+    <el-card shadow="never" class="filter-card">
4
+      <el-form :inline="true" :model="filterForm">
5
+        <el-form-item label="统计时间">
6
+          <el-date-picker v-model="dateRange" type="daterange" range-separator="至"
7
+            start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD"
8
+            @change="loadStats" />
9
+        </el-form-item>
10
+        <el-form-item>
11
+          <el-button type="primary" @click="loadStats"><el-icon><Refresh /></el-icon> 刷新</el-button>
12
+          <el-button @click="handleExportPdf" type="danger" plain><el-icon><Document /></el-icon> 导出报表</el-button>
13
+        </el-form-item>
14
+      </el-form>
15
+    </el-card>
16
+
17
+    <el-row :gutter="16" style="margin-top: 16px">
18
+      <el-col :span="5">
19
+        <el-card shadow="hover" class="stat-card stat-total">
20
+          <div class="stat-value">{{ stats.totalRecords ?? 0 }}</div>
21
+          <div class="stat-label">总检测记录</div>
22
+        </el-card>
23
+      </el-col>
24
+      <el-col :span="5">
25
+        <el-card shadow="hover" class="stat-card stat-qualified">
26
+          <div class="stat-value">{{ stats.qualifiedCount ?? 0 }}</div>
27
+          <div class="stat-label">合格记录</div>
28
+        </el-card>
29
+      </el-col>
30
+      <el-col :span="5">
31
+        <el-card shadow="hover" class="stat-card stat-unqualified">
32
+          <div class="stat-value">{{ stats.unqualifiedCount ?? 0 }}</div>
33
+          <div class="stat-label">不合格记录</div>
34
+        </el-card>
35
+      </el-col>
36
+      <el-col :span="5">
37
+        <el-card shadow="hover" class="stat-card stat-pending">
38
+          <div class="stat-value">{{ stats.pendingCount ?? 0 }}</div>
39
+          <div class="stat-label">待判定</div>
40
+        </el-card>
41
+      </el-col>
42
+      <el-col :span="4">
43
+        <el-card shadow="hover" class="stat-card stat-rate">
44
+          <div class="stat-value">{{ (stats.qualifiedRate ?? 0).toFixed(1) }}%</div>
45
+          <div class="stat-label">合格率</div>
46
+        </el-card>
47
+      </el-col>
48
+    </el-row>
49
+
50
+    <el-row :gutter="16" style="margin-top: 16px">
51
+      <el-col :span="8">
52
+        <el-card shadow="never">
53
+          <template #header><span>平均浊度</span></template>
54
+          <div class="avg-value">{{ stats.avgTurbidity?.toFixed(4) ?? '-' }} <span class="unit">NTU</span></div>
55
+        </el-card>
56
+      </el-col>
57
+      <el-col :span="8">
58
+        <el-card shadow="never">
59
+          <template #header><span>平均 pH 值</span></template>
60
+          <div class="avg-value">{{ stats.avgPh?.toFixed(2) ?? '-' }}</div>
61
+        </el-card>
62
+      </el-col>
63
+      <el-col :span="8">
64
+        <el-card shadow="never">
65
+          <template #header><span>平均余氯</span></template>
66
+          <div class="avg-value">{{ stats.avgResidualChlorine?.toFixed(4) ?? '-' }} <span class="unit">mg/L</span></div>
67
+        </el-card>
68
+      </el-col>
69
+    </el-row>
70
+
71
+    <el-card shadow="never" style="margin-top: 16px">
72
+      <template #header><span>月度合格率趋势</span></template>
73
+      <el-table :data="stats.trendByDate || []" border>
74
+        <el-table-column prop="month" label="月份" width="120" />
75
+        <el-table-column prop="total" label="总检测数" width="120" align="right" />
76
+        <el-table-column prop="qualified" label="合格数" width="120" align="right" />
77
+        <el-table-column label="合格率" width="120" align="right">
78
+          <template #default="{ row }">
79
+            <span :style="{ color: row.rate >= 95 ? '#67c23a' : row.rate >= 90 ? '#e6a23c' : '#f56c6c' }">
80
+              {{ Number(row.rate).toFixed(1) }}%
81
+            </span>
82
+          </template>
83
+        </el-table-column>
84
+        <el-table-column label="趋势">
85
+          <template #default="{ row }">
86
+            <el-progress :percentage="Number(row.rate)" :color="row.rate >= 95 ? '#67c23a' : row.rate >= 90 ? '#e6a23c' : '#f56c6c'"
87
+              :show-text="false" :stroke-width="12" />
88
+          </template>
89
+        </el-table-column>
90
+      </el-table>
91
+    </el-card>
92
+
93
+    <el-row :gutter="16" style="margin-top: 16px">
94
+      <el-col :span="12">
95
+        <el-card shadow="never">
96
+          <template #header><span>各区域合格率对比</span></template>
97
+          <el-table :data="stats.byArea || []" border size="small">
98
+            <el-table-column prop="area" label="区域" width="100" />
99
+            <el-table-column prop="total" label="总数" width="80" align="right" />
100
+            <el-table-column prop="qualified" label="合格" width="80" align="right" />
101
+            <el-table-column label="合格率" align="right">
102
+              <template #default="{ row }">
103
+                <span :style="{ color: row.rate >= 95 ? '#67c23a' : '#f56c6c' }">
104
+                  {{ Number(row.rate).toFixed(1) }}%
105
+                </span>
106
+              </template>
107
+            </el-table-column>
108
+          </el-table>
109
+        </el-card>
110
+      </el-col>
111
+      <el-col :span="12">
112
+        <el-card shadow="never">
113
+          <template #header><span>各水质类型合格率对比</span></template>
114
+          <el-table :data="stats.byWaterType || []" border size="small">
115
+            <el-table-column prop="waterType" label="水质类型" width="130">
116
+              <template #default="{ row }">{{ waterTypeLabel(row.waterType) }}</template>
117
+            </el-table-column>
118
+            <el-table-column prop="total" label="总数" width="80" align="right" />
119
+            <el-table-column prop="qualified" label="合格" width="80" align="right" />
120
+            <el-table-column label="合格率" align="right">
121
+              <template #default="{ row }">
122
+                <span :style="{ color: row.rate >= 95 ? '#67c23a' : '#f56c6c' }">
123
+                  {{ Number(row.rate).toFixed(1) }}%
124
+                </span>
125
+              </template>
126
+            </el-table-column>
127
+          </el-table>
128
+        </el-card>
129
+      </el-col>
130
+    </el-row>
131
+
132
+    <el-card shadow="never" style="margin-top: 16px">
133
+      <template #header><span>各检测点位合格率对比</span></template>
134
+      <el-table :data="pointCompliance || []" border size="small">
135
+        <el-table-column prop="pointName" label="检测点位" min-width="180" show-overflow-tooltip />
136
+        <el-table-column prop="total" label="总数" width="80" align="right" />
137
+        <el-table-column prop="qualified" label="合格" width="80" align="right" />
138
+        <el-table-column label="合格率" width="120" align="right">
139
+          <template #default="{ row }">
140
+            <el-tag :type="row.rate >= 95 ? 'success' : row.rate >= 90 ? 'warning' : 'danger'" size="small">
141
+              {{ Number(row.rate).toFixed(1) }}%
142
+            </el-tag>
143
+          </template>
144
+        </el-table-column>
145
+        <el-table-column label="进度" min-width="200">
146
+          <template #default="{ row }">
147
+            <el-progress :percentage="Number(row.rate)"
148
+              :color="row.rate >= 95 ? '#67c23a' : row.rate >= 90 ? '#e6a23c' : '#f56c6c'"
149
+              :show-text="false" :stroke-width="10" />
150
+          </template>
151
+        </el-table-column>
152
+      </el-table>
153
+    </el-card>
154
+
155
+    <el-card shadow="never" style="margin-top: 16px">
156
+      <template #header><span>不合格项目统计 TOP</span></template>
157
+      <el-table :data="stats.unqualifiedItems || []" border size="small" v-if="stats.unqualifiedItems?.length">
158
+        <el-table-column prop="param" label="不合格项" min-width="160" />
159
+        <el-table-column prop="count" label="出现次数" width="120" align="right" />
160
+      </el-table>
161
+      <el-empty v-else description="暂无不合格项" :image-size="60" />
162
+    </el-card>
163
+  </div>
164
+</template>
165
+
166
+<script setup lang="ts">
167
+import { ref, reactive, onMounted } from 'vue'
168
+import { Refresh, Document } from '@element-plus/icons-vue'
169
+import { getStatistics, pointComplianceRate, getExportPdfUrl } from '@/api/qualityTest'
170
+
171
+const dateRange = ref<[string, string] | null>(null)
172
+const stats = reactive<any>({})
173
+const pointCompliance = ref<any[]>([])
174
+
175
+const waterTypeMap: Record<string, string> = {
176
+  rawWater: '原水', factoryWater: '出厂水',
177
+  pipeNetworkWater: '管网水', endUserWater: '末梢水'
178
+}
179
+const waterTypeLabel = (v: string) => waterTypeMap[v] || v
180
+
181
+const loadStats = async () => {
182
+  try {
183
+    const res: any = await getStatistics(dateRange.value?.[0], dateRange.value?.[1])
184
+    Object.assign(stats, res.data || {})
185
+  } catch (e) { console.error(e) }
186
+
187
+  try {
188
+    const res: any = await pointComplianceRate(dateRange.value?.[0], dateRange.value?.[1])
189
+    pointCompliance.value = res.data || []
190
+  } catch (e) { console.error(e) }
191
+}
192
+
193
+const handleExportPdf = () => {
194
+  const url = getExportPdfUrl(dateRange.value?.[0], dateRange.value?.[1])
195
+  window.open(url, '_blank')
196
+}
197
+
198
+onMounted(loadStats)
199
+</script>
200
+
201
+<style scoped>
202
+.filter-card { margin-bottom: 8px; }
203
+.stat-card { text-align: center; }
204
+.stat-value { font-size: 32px; font-weight: bold; }
205
+.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
206
+.stat-total .stat-value { color: #409eff; }
207
+.stat-qualified .stat-value { color: #67c23a; }
208
+.stat-unqualified .stat-value { color: #f56c6c; }
209
+.stat-pending .stat-value { color: #e6a23c; }
210
+.stat-rate .stat-value { color: #67c23a; font-size: 28px; }
211
+.avg-value { font-size: 24px; font-weight: bold; color: #303133; text-align: center; }
212
+.avg-value .unit { font-size: 13px; color: #909399; font-weight: normal; }
213
+</style>

+ 314
- 0
wm-production/src/main/java/com/water/production/controller/QualityTestController.java View File

@@ -0,0 +1,314 @@
1
+package com.water.production.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.common.core.result.R;
5
+import com.water.production.dto.QualityQueryRequest;
6
+import com.water.production.dto.QualityStatVO;
7
+import com.water.production.entity.QualityStandard;
8
+import com.water.production.entity.QualityTestPlan;
9
+import com.water.production.entity.QualityTestPoint;
10
+import com.water.production.entity.QualityTestRecord;
11
+import com.water.production.service.QualityExportService;
12
+import com.water.production.service.QualityLedgerService;
13
+import com.water.production.service.QualityStandardService;
14
+import com.water.production.service.QualityTestPlanService;
15
+import com.water.production.service.QualityTestPointService;
16
+import io.swagger.v3.oas.annotations.Operation;
17
+import io.swagger.v3.oas.annotations.tags.Tag;
18
+import jakarta.servlet.http.HttpServletResponse;
19
+import lombok.RequiredArgsConstructor;
20
+import org.springframework.web.bind.annotation.*;
21
+
22
+import java.io.IOException;
23
+import java.util.List;
24
+import java.util.Map;
25
+
26
+/**
27
+ * 水质检测台账统一入口
28
+ * 整合检测记录 CRUD、检测点位管理、水质标准、检测计划、统计与导出
29
+ */
30
+@Tag(name = "水质检测台账")
31
+@RestController
32
+@RequestMapping("/api/production/quality-test")
33
+@RequiredArgsConstructor
34
+public class QualityTestController {
35
+
36
+    private final QualityLedgerService ledgerService;
37
+    private final QualityStandardService standardService;
38
+    private final QualityTestPlanService planService;
39
+    private final QualityTestPointService pointService;
40
+    private final QualityExportService exportService;
41
+
42
+    // ==================== 检测记录 CRUD ====================
43
+
44
+    @Operation(summary = "查询检测记录(分页)")
45
+    @GetMapping("/records")
46
+    public R<Map<String, Object>> queryRecords(QualityQueryRequest request) {
47
+        return R.ok(ledgerService.queryRecords(request));
48
+    }
49
+
50
+    @Operation(summary = "获取检测记录详情")
51
+    @GetMapping("/records/{id}")
52
+    public R<QualityTestRecord> getRecord(@PathVariable Long id) {
53
+        return R.ok(ledgerService.getById(id));
54
+    }
55
+
56
+    @Operation(summary = "创建检测记录(自动合格判定)")
57
+    @PostMapping("/records")
58
+    public R<QualityTestRecord> createRecord(@RequestBody QualityTestRecord record) {
59
+        return R.ok(ledgerService.create(record));
60
+    }
61
+
62
+    @Operation(summary = "更新检测记录")
63
+    @PutMapping("/records/{id}")
64
+    public R<QualityTestRecord> updateRecord(@PathVariable Long id, @RequestBody QualityTestRecord record) {
65
+        return R.ok(ledgerService.update(id, record));
66
+    }
67
+
68
+    @Operation(summary = "删除检测记录")
69
+    @DeleteMapping("/records/{id}")
70
+    public R<Void> deleteRecord(@PathVariable Long id) {
71
+        ledgerService.delete(id);
72
+        return R.ok();
73
+    }
74
+
75
+    @Operation(summary = "批量删除检测记录")
76
+    @DeleteMapping("/records/batch")
77
+    public R<Void> batchDeleteRecords(@RequestBody List<Long> ids) {
78
+        ledgerService.batchDelete(ids);
79
+        return R.ok();
80
+    }
81
+
82
+    @Operation(summary = "重新判定所有记录")
83
+    @PostMapping("/records/reevaluate")
84
+    public R<Map<String, Object>> reevaluateAll() {
85
+        int count = ledgerService.reevaluateAll();
86
+        return R.ok(Map.of("updatedCount", count));
87
+    }
88
+
89
+    @Operation(summary = "获取区域列表")
90
+    @GetMapping("/records/areas")
91
+    public R<List<String>> getRecordAreas() {
92
+        return R.ok(ledgerService.getAreaList());
93
+    }
94
+
95
+    @Operation(summary = "获取采样点列表")
96
+    @GetMapping("/records/sampling-points")
97
+    public R<List<String>> getSamplingPoints() {
98
+        return R.ok(ledgerService.getSamplingPointList());
99
+    }
100
+
101
+    // ==================== 检测点位管理 ====================
102
+
103
+    @Operation(summary = "查询检测点位(分页)")
104
+    @GetMapping("/points")
105
+    public R<Map<String, Object>> queryPoints(
106
+            @RequestParam(defaultValue = "1") int pageNum,
107
+            @RequestParam(defaultValue = "10") int pageSize,
108
+            @RequestParam(required = false) String pointType,
109
+            @RequestParam(required = false) String area,
110
+            @RequestParam(required = false) String status,
111
+            @RequestParam(required = false) String keyword) {
112
+        return R.ok(pointService.queryPoints(pageNum, pageSize, pointType, area, status, keyword));
113
+    }
114
+
115
+    @Operation(summary = "获取所有活跃点位")
116
+    @GetMapping("/points/all")
117
+    public R<List<QualityTestPoint>> listAllPoints() {
118
+        return R.ok(pointService.listAll());
119
+    }
120
+
121
+    @Operation(summary = "获取点位详情")
122
+    @GetMapping("/points/{id}")
123
+    public R<QualityTestPoint> getPoint(@PathVariable Long id) {
124
+        return R.ok(pointService.getById(id));
125
+    }
126
+
127
+    @Operation(summary = "创建检测点位")
128
+    @PostMapping("/points")
129
+    public R<QualityTestPoint> createPoint(@RequestBody QualityTestPoint point) {
130
+        return R.ok(pointService.create(point));
131
+    }
132
+
133
+    @Operation(summary = "更新检测点位")
134
+    @PutMapping("/points/{id}")
135
+    public R<QualityTestPoint> updatePoint(@PathVariable Long id, @RequestBody QualityTestPoint point) {
136
+        return R.ok(pointService.update(id, point));
137
+    }
138
+
139
+    @Operation(summary = "删除检测点位")
140
+    @DeleteMapping("/points/{id}")
141
+    public R<Void> deletePoint(@PathVariable Long id) {
142
+        pointService.delete(id);
143
+        return R.ok();
144
+    }
145
+
146
+    @Operation(summary = "批量删除检测点位")
147
+    @DeleteMapping("/points/batch")
148
+    public R<Void> batchDeletePoints(@RequestBody List<Long> ids) {
149
+        pointService.batchDelete(ids);
150
+        return R.ok();
151
+    }
152
+
153
+    @Operation(summary = "切换点位状态")
154
+    @PutMapping("/points/{id}/toggle")
155
+    public R<QualityTestPoint> togglePointStatus(@PathVariable Long id) {
156
+        return R.ok(pointService.toggleStatus(id));
157
+    }
158
+
159
+    @Operation(summary = "按类型分组统计")
160
+    @GetMapping("/points/stat/type")
161
+    public R<List<Map<String, Object>>> pointStatByType() {
162
+        return R.ok(pointService.statByPointType());
163
+    }
164
+
165
+    @Operation(summary = "按区域分组统计")
166
+    @GetMapping("/points/stat/area")
167
+    public R<List<Map<String, Object>>> pointStatByArea() {
168
+        return R.ok(pointService.statByArea());
169
+    }
170
+
171
+    @Operation(summary = "各点位合格率")
172
+    @GetMapping("/points/stat/compliance")
173
+    public R<List<Map<String, Object>>> pointComplianceRate(
174
+            @RequestParam(required = false) String startDate,
175
+            @RequestParam(required = false) String endDate) {
176
+        return R.ok(pointService.complianceRateByPoint(startDate, endDate));
177
+    }
178
+
179
+    @Operation(summary = "获取点位区域列表")
180
+    @GetMapping("/points/areas")
181
+    public R<List<String>> getPointAreas() {
182
+        return R.ok(pointService.getAreaList());
183
+    }
184
+
185
+    // ==================== 水质标准 ====================
186
+
187
+    @Operation(summary = "获取启用的标准列表")
188
+    @GetMapping("/standards")
189
+    public R<List<QualityStandard>> listStandards() {
190
+        return R.ok(standardService.listEnabled());
191
+    }
192
+
193
+    @Operation(summary = "获取全部标准")
194
+    @GetMapping("/standards/all")
195
+    public R<List<QualityStandard>> listAllStandards() {
196
+        return R.ok(standardService.listAll());
197
+    }
198
+
199
+    @Operation(summary = "按水质类型获取标准")
200
+    @GetMapping("/standards/water-type/{waterType}")
201
+    public R<List<QualityStandard>> listStandardsByWaterType(@PathVariable String waterType) {
202
+        return R.ok(standardService.listByWaterType(waterType));
203
+    }
204
+
205
+    @Operation(summary = "获取标准详情")
206
+    @GetMapping("/standards/{id}")
207
+    public R<QualityStandard> getStandard(@PathVariable Long id) {
208
+        return R.ok(standardService.getById(id));
209
+    }
210
+
211
+    @Operation(summary = "创建标准")
212
+    @PostMapping("/standards")
213
+    public R<QualityStandard> createStandard(@RequestBody QualityStandard standard) {
214
+        return R.ok(standardService.create(standard));
215
+    }
216
+
217
+    @Operation(summary = "更新标准")
218
+    @PutMapping("/standards/{id}")
219
+    public R<QualityStandard> updateStandard(@PathVariable Long id, @RequestBody QualityStandard standard) {
220
+        return R.ok(standardService.update(id, standard));
221
+    }
222
+
223
+    @Operation(summary = "删除标准")
224
+    @DeleteMapping("/standards/{id}")
225
+    public R<Void> deleteStandard(@PathVariable Long id) {
226
+        standardService.delete(id);
227
+        return R.ok();
228
+    }
229
+
230
+    // ==================== 检测计划 ====================
231
+
232
+    @Operation(summary = "查询检测计划(分页)")
233
+    @GetMapping("/plans")
234
+    public R<Page<QualityTestPlan>> queryPlans(
235
+            @RequestParam(defaultValue = "1") int pageNum,
236
+            @RequestParam(defaultValue = "10") int pageSize,
237
+            @RequestParam(required = false) String testType,
238
+            @RequestParam(required = false) String waterType,
239
+            @RequestParam(required = false) String area,
240
+            @RequestParam(required = false) String status,
241
+            @RequestParam(required = false) String keyword) {
242
+        return R.ok(planService.queryPlans(pageNum, pageSize, testType, waterType, area, status, keyword));
243
+    }
244
+
245
+    @Operation(summary = "获取计划详情")
246
+    @GetMapping("/plans/{id}")
247
+    public R<QualityTestPlan> getPlan(@PathVariable Long id) {
248
+        return R.ok(planService.getById(id));
249
+    }
250
+
251
+    @Operation(summary = "创建计划")
252
+    @PostMapping("/plans")
253
+    public R<QualityTestPlan> createPlan(@RequestBody QualityTestPlan plan) {
254
+        return R.ok(planService.create(plan));
255
+    }
256
+
257
+    @Operation(summary = "更新计划")
258
+    @PutMapping("/plans/{id}")
259
+    public R<QualityTestPlan> updatePlan(@PathVariable Long id, @RequestBody QualityTestPlan plan) {
260
+        return R.ok(planService.update(id, plan));
261
+    }
262
+
263
+    @Operation(summary = "删除计划")
264
+    @DeleteMapping("/plans/{id}")
265
+    public R<Void> deletePlan(@PathVariable Long id) {
266
+        planService.delete(id);
267
+        return R.ok();
268
+    }
269
+
270
+    @Operation(summary = "切换计划状态")
271
+    @PutMapping("/plans/{id}/status")
272
+    public R<QualityTestPlan> togglePlanStatus(@PathVariable Long id) {
273
+        return R.ok(planService.toggleStatus(id));
274
+    }
275
+
276
+    @Operation(summary = "获取到期计划")
277
+    @GetMapping("/plans/due")
278
+    public R<List<QualityTestPlan>> getDuePlans() {
279
+        return R.ok(planService.getDuePlans());
280
+    }
281
+
282
+    @Operation(summary = "标记计划已执行")
283
+    @PostMapping("/plans/{id}/execute")
284
+    public R<QualityTestPlan> markPlanExecuted(@PathVariable Long id) {
285
+        return R.ok(planService.markExecuted(id));
286
+    }
287
+
288
+    // ==================== 统计 ====================
289
+
290
+    @Operation(summary = "获取统计数据")
291
+    @GetMapping("/statistics")
292
+    public R<QualityStatVO> getStatistics(
293
+            @RequestParam(required = false) String startDate,
294
+            @RequestParam(required = false) String endDate) {
295
+        return R.ok(ledgerService.getStatistics(startDate, endDate));
296
+    }
297
+
298
+    // ==================== 导出 ====================
299
+
300
+    @Operation(summary = "导出 Excel 报表")
301
+    @PostMapping("/export/excel")
302
+    public void exportExcel(QualityQueryRequest request, HttpServletResponse response) throws IOException {
303
+        exportService.exportExcel(request, response);
304
+    }
305
+
306
+    @Operation(summary = "导出 PDF 统计报表")
307
+    @GetMapping("/export/pdf")
308
+    public void exportPdf(
309
+            @RequestParam(required = false) String startDate,
310
+            @RequestParam(required = false) String endDate,
311
+            HttpServletResponse response) throws IOException {
312
+        exportService.exportPdfReport(startDate, endDate, response);
313
+    }
314
+}

+ 56
- 0
wm-production/src/main/java/com/water/production/entity/QualityTestPoint.java View File

@@ -0,0 +1,56 @@
1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.time.LocalDateTime;
7
+
8
+/**
9
+ * 水质检测点位
10
+ */
11
+@Data
12
+@TableName("prod_quality_test_point")
13
+public class QualityTestPoint {
14
+
15
+    @TableId(type = IdType.AUTO)
16
+    private Long id;
17
+
18
+    /** 点位名称 */
19
+    private String pointName;
20
+
21
+    /** 点位类型: waterSource-水源/factory-出厂/endpoint-末梢 */
22
+    private String pointType;
23
+
24
+    /** 位置描述 */
25
+    private String location;
26
+
27
+    /** 经度 */
28
+    private Double longitude;
29
+
30
+    /** 纬度 */
31
+    private Double latitude;
32
+
33
+    /** 采样频率: daily/weekly/monthly/quarterly */
34
+    private String samplingFrequency;
35
+
36
+    /** 所属区域 */
37
+    private String area;
38
+
39
+    /** 关联水厂 */
40
+    private String waterPlant;
41
+
42
+    /** 状态: active/inactive */
43
+    private String status;
44
+
45
+    /** 备注 */
46
+    private String remark;
47
+
48
+    @TableLogic
49
+    private Integer deleted;
50
+
51
+    @TableField(fill = FieldFill.INSERT)
52
+    private LocalDateTime createdAt;
53
+
54
+    @TableField(fill = FieldFill.INSERT_UPDATE)
55
+    private LocalDateTime updatedAt;
56
+}

+ 31
- 0
wm-production/src/main/java/com/water/production/mapper/QualityTestPointMapper.java View File

@@ -0,0 +1,31 @@
1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.QualityTestPoint;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+
8
+import java.util.List;
9
+import java.util.Map;
10
+
11
+@Mapper
12
+public interface QualityTestPointMapper extends BaseMapper<QualityTestPoint> {
13
+
14
+    /**
15
+     * 按类型分组统计
16
+     */
17
+    List<Map<String, Object>> statByPointType();
18
+
19
+    /**
20
+     * 按区域分组统计
21
+     */
22
+    List<Map<String, Object>> statByArea();
23
+
24
+    /**
25
+     * 查询各点位的合格率
26
+     */
27
+    List<Map<String, Object>> complianceRateByPoint(
28
+        @Param("startDate") String startDate,
29
+        @Param("endDate") String endDate
30
+    );
31
+}

+ 215
- 0
wm-production/src/main/java/com/water/production/service/QualityExportService.java View File

@@ -0,0 +1,215 @@
1
+package com.water.production.service;
2
+
3
+import com.alibaba.excel.EasyExcel;
4
+import com.alibaba.excel.write.style.column.LongestMatchColumnWidthStyleStrategy;
5
+import com.water.production.dto.QualityQueryRequest;
6
+import com.water.production.dto.QualityStatVO;
7
+import jakarta.servlet.http.HttpServletResponse;
8
+import lombok.RequiredArgsConstructor;
9
+import lombok.extern.slf4j.Slf4j;
10
+import org.springframework.stereotype.Service;
11
+
12
+import java.io.IOException;
13
+import java.net.URLEncoder;
14
+import java.nio.charset.StandardCharsets;
15
+import java.time.LocalDate;
16
+import java.time.format.DateTimeFormatter;
17
+import java.util.List;
18
+import java.util.*;
19
+import java.util.stream.Collectors;
20
+
21
+@Slf4j
22
+@Service
23
+@RequiredArgsConstructor
24
+public class QualityExportService {
25
+
26
+    private final QualityLedgerService ledgerService;
27
+
28
+    /**
29
+     * 导出 Excel 报表(增强版,含自动列宽)
30
+     */
31
+    @SuppressWarnings("unchecked")
32
+    public void exportExcel(QualityQueryRequest request, HttpServletResponse response) throws IOException {
33
+        request.setPageNum(1);
34
+        request.setPageSize(100000);
35
+        Map<String, Object> result = ledgerService.queryRecords(request);
36
+        List<Map<String, Object>> records = (List<Map<String, Object>>) result.get("records");
37
+
38
+        List<List<String>> excelData = records.stream().map(r -> {
39
+            List<String> row = new ArrayList<>();
40
+            row.add(String.valueOf(r.getOrDefault("testType", "")));
41
+            row.add(String.valueOf(r.getOrDefault("waterType", "")));
42
+            row.add(String.valueOf(r.getOrDefault("samplingPoint", "")));
43
+            row.add(String.valueOf(r.getOrDefault("area", "")));
44
+            row.add(String.valueOf(r.getOrDefault("testDate", "")));
45
+            row.add(String.valueOf(r.getOrDefault("tester", "")));
46
+            row.add(String.valueOf(r.getOrDefault("turbidity", "")));
47
+            row.add(String.valueOf(r.getOrDefault("ph", "")));
48
+            row.add(String.valueOf(r.getOrDefault("residualChlorine", "")));
49
+            row.add(String.valueOf(r.getOrDefault("color", "")));
50
+            row.add(String.valueOf(r.getOrDefault("odor", "")));
51
+            row.add(String.valueOf(r.getOrDefault("ecoli", "")));
52
+            row.add(String.valueOf(r.getOrDefault("colonyCount", "")));
53
+            row.add(complianceLabel(String.valueOf(r.getOrDefault("complianceStatus", ""))));
54
+            row.add(String.valueOf(r.getOrDefault("unqualifiedItems", "")));
55
+            row.add(String.valueOf(r.getOrDefault("remark", "")));
56
+            return row;
57
+        }).collect(Collectors.toList());
58
+
59
+        List<List<String>> head = List.of(
60
+            List.of("检测类型"), List.of("水质类型"), List.of("采样点"), List.of("区域"),
61
+            List.of("检测日期"), List.of("检测人"), List.of("浊度(NTU)"), List.of("pH"),
62
+            List.of("余氯(mg/L)"), List.of("色度(度)"), List.of("嗅味(级)"), List.of("大肠杆菌(CFU/100mL)"),
63
+            List.of("菌落总数(CFU/mL)"), List.of("合格状态"), List.of("不合格项"), List.of("备注")
64
+        );
65
+
66
+        String filename = "水质检测报告_" + LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE) + ".xlsx";
67
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
68
+        response.setHeader("Content-Disposition",
69
+                "attachment;filename=" + URLEncoder.encode(filename, StandardCharsets.UTF_8));
70
+
71
+        EasyExcel.write(response.getOutputStream())
72
+                .head(head)
73
+                .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
74
+                .sheet("检测记录")
75
+                .doWrite(excelData.stream().map(row -> (List<Object>) (List<?>) row).collect(Collectors.toList()));
76
+
77
+        log.info("导出质检Excel: {} 条记录", records.size());
78
+    }
79
+
80
+    /**
81
+     * 导出 PDF 统计报表
82
+     */
83
+    public void exportPdfReport(String startDate, String endDate, HttpServletResponse response) throws IOException {
84
+        QualityStatVO stats = ledgerService.getStatistics(startDate, endDate);
85
+
86
+        String filename = "水质统计报表_" + LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE) + ".pdf";
87
+        response.setContentType("application/pdf");
88
+        response.setHeader("Content-Disposition",
89
+                "attachment;filename=" + URLEncoder.encode(filename, StandardCharsets.UTF_8));
90
+
91
+        StringBuilder sb = new StringBuilder();
92
+        sb.append("==========================================================\n");
93
+        sb.append("              Water Quality Test Report\n");
94
+        sb.append("==========================================================\n");
95
+        sb.append("Report Date: ").append(LocalDate.now()).append("\n");
96
+        if (startDate != null || endDate != null) {
97
+            sb.append("Period: ").append(startDate != null ? startDate : "start")
98
+              .append(" to ").append(endDate != null ? endDate : "now").append("\n");
99
+        }
100
+        sb.append("\n--- Summary ---\n");
101
+        sb.append(String.format("Total Records:    %d\n", stats.getTotalRecords()));
102
+        sb.append(String.format("Qualified:        %d\n", stats.getQualifiedCount()));
103
+        sb.append(String.format("Unqualified:      %d\n", stats.getUnqualifiedCount()));
104
+        sb.append(String.format("Pending:          %d\n", stats.getPendingCount()));
105
+        sb.append(String.format("Qualified Rate:   %.2f%%\n", stats.getQualifiedRate()));
106
+
107
+        if (stats.getAvgTurbidity() != null) {
108
+            sb.append(String.format("\nAvg Turbidity:    %.4f NTU\n", stats.getAvgTurbidity()));
109
+        }
110
+        if (stats.getAvgPh() != null) {
111
+            sb.append(String.format("Avg pH:           %.4f\n", stats.getAvgPh()));
112
+        }
113
+        if (stats.getAvgResidualChlorine() != null) {
114
+            sb.append(String.format("Avg Chlorine:     %.4f mg/L\n", stats.getAvgResidualChlorine()));
115
+        }
116
+
117
+        if (stats.getByWaterType() != null && !stats.getByWaterType().isEmpty()) {
118
+            sb.append("\n--- By Water Type ---\n");
119
+            for (Map<String, Object> row : stats.getByWaterType()) {
120
+                sb.append(String.format("  %s: total=%d, qualified=%d, rate=%.2f%%\n",
121
+                    row.get("waterType"), row.get("total"), row.get("qualified"), row.get("rate")));
122
+            }
123
+        }
124
+
125
+        if (stats.getByArea() != null && !stats.getByArea().isEmpty()) {
126
+            sb.append("\n--- By Area ---\n");
127
+            for (Map<String, Object> row : stats.getByArea()) {
128
+                sb.append(String.format("  %s: total=%d, qualified=%d, rate=%.2f%%\n",
129
+                    row.get("area"), row.get("total"), row.get("qualified"), row.get("rate")));
130
+            }
131
+        }
132
+
133
+        if (stats.getTrendByDate() != null && !stats.getTrendByDate().isEmpty()) {
134
+            sb.append("\n--- Monthly Trend ---\n");
135
+            for (Map<String, Object> row : stats.getTrendByDate()) {
136
+                sb.append(String.format("  %s: total=%d, qualified=%d, rate=%.2f%%\n",
137
+                    row.get("month"), row.get("total"), row.get("qualified"), row.get("rate")));
138
+            }
139
+        }
140
+
141
+        if (stats.getUnqualifiedItems() != null && !stats.getUnqualifiedItems().isEmpty()) {
142
+            sb.append("\n--- Unqualified Items ---\n");
143
+            for (Map<String, Object> row : stats.getUnqualifiedItems()) {
144
+                sb.append(String.format("  %s: %d times\n", row.get("param"), row.get("count")));
145
+            }
146
+        }
147
+
148
+        sb.append("\n==========================================================\n");
149
+        sb.append("  Auto-generated | Standard: GB5749-2022\n");
150
+        sb.append("==========================================================\n");
151
+
152
+        byte[] pdfBytes = createSimplePdf(sb.toString());
153
+        response.getOutputStream().write(pdfBytes);
154
+        response.getOutputStream().flush();
155
+
156
+        log.info("导出 PDF 统计报表");
157
+    }
158
+
159
+    /**
160
+     * 创建简单 PDF(基于最低限度的 PDF 1.4 规范)
161
+     */
162
+    private byte[] createSimplePdf(String text) {
163
+        String safeText = text.replaceAll("[^\\x20-\\x7E\\n]", "");
164
+
165
+        StringBuilder pdf = new StringBuilder();
166
+        pdf.append("%PDF-1.4\n");
167
+
168
+        pdf.append("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
169
+        pdf.append("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
170
+        pdf.append("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] ");
171
+        pdf.append("/Contents 5 0 R /Resources << /Font << /F1 4 0 R >> >> >>\nendobj\n");
172
+        pdf.append("4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>\nendobj\n");
173
+
174
+        String[] lines = safeText.split("\n");
175
+        StringBuilder stream = new StringBuilder();
176
+        stream.append("BT\n/F1 9 Tf\n");
177
+        int y = 800;
178
+        for (String line : lines) {
179
+            if (y < 50) break;
180
+            String escaped = line.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)");
181
+            stream.append(String.format("1 0 0 1 50 %d Tm\n(%s) Tj\n", y, escaped));
182
+            y -= 14;
183
+        }
184
+        stream.append("ET\n");
185
+
186
+        String streamStr = stream.toString();
187
+        pdf.append("5 0 obj\n<< /Length ").append(streamStr.length()).append(" >>\nstream\n");
188
+        pdf.append(streamStr);
189
+        pdf.append("endstream\nendobj\n");
190
+
191
+        String body = pdf.toString();
192
+        int xrefPos = body.length();
193
+        pdf.append("xref\n0 6\n");
194
+        pdf.append("0000000000 65535 f \n");
195
+        pdf.append(String.format("%010d 00000 n \n", body.indexOf("1 0 obj")));
196
+        pdf.append(String.format("%010d 00000 n \n", body.indexOf("2 0 obj")));
197
+        pdf.append(String.format("%010d 00000 n \n", body.indexOf("3 0 obj")));
198
+        pdf.append(String.format("%010d 00000 n \n", body.indexOf("4 0 obj")));
199
+        pdf.append(String.format("%010d 00000 n \n", body.indexOf("5 0 obj")));
200
+
201
+        pdf.append("trailer\n<< /Size 6 /Root 1 0 R >>\n");
202
+        pdf.append("startxref\n").append(xrefPos).append("\n%%EOF\n");
203
+
204
+        return pdf.toString().getBytes(StandardCharsets.US_ASCII);
205
+    }
206
+
207
+    private String complianceLabel(String status) {
208
+        return switch (status) {
209
+            case "qualified" -> "合格";
210
+            case "unqualified" -> "不合格";
211
+            case "pending" -> "待判定";
212
+            default -> status;
213
+        };
214
+    }
215
+}

+ 169
- 0
wm-production/src/main/java/com/water/production/service/QualityTestPointService.java View File

@@ -0,0 +1,169 @@
1
+package com.water.production.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.common.core.exception.BusinessException;
5
+import com.water.production.entity.QualityTestPoint;
6
+import com.water.production.mapper.QualityTestPointMapper;
7
+import lombok.RequiredArgsConstructor;
8
+import lombok.extern.slf4j.Slf4j;
9
+import org.springframework.stereotype.Service;
10
+import org.springframework.transaction.annotation.Transactional;
11
+
12
+import java.util.List;
13
+import java.util.Map;
14
+
15
+@Slf4j
16
+@Service
17
+@RequiredArgsConstructor
18
+public class QualityTestPointService {
19
+
20
+    private final QualityTestPointMapper pointMapper;
21
+
22
+    /**
23
+     * 分页查询检测点位
24
+     */
25
+    public Map<String, Object> queryPoints(int pageNum, int pageSize, String pointType, String area,
26
+                                            String status, String keyword) {
27
+        LambdaQueryWrapper<QualityTestPoint> wrapper = new LambdaQueryWrapper<>();
28
+        if (pointType != null && !pointType.isBlank()) wrapper.eq(QualityTestPoint::getPointType, pointType);
29
+        if (area != null && !area.isBlank()) wrapper.eq(QualityTestPoint::getArea, area);
30
+        if (status != null && !status.isBlank()) wrapper.eq(QualityTestPoint::getStatus, status);
31
+        if (keyword != null && !keyword.isBlank()) {
32
+            wrapper.and(w -> w.like(QualityTestPoint::getPointName, keyword)
33
+                              .or().like(QualityTestPoint::getLocation, keyword)
34
+                              .or().like(QualityTestPoint::getWaterPlant, keyword));
35
+        }
36
+        wrapper.orderByDesc(QualityTestPoint::getCreatedAt);
37
+
38
+        long total = pointMapper.selectCount(wrapper);
39
+        int offset = (pageNum - 1) * pageSize;
40
+        wrapper.last("OFFSET " + offset + " LIMIT " + pageSize);
41
+        List<QualityTestPoint> records = pointMapper.selectList(wrapper);
42
+
43
+        long totalPages = (total + pageSize - 1) / pageSize;
44
+        return Map.of(
45
+            "records", records,
46
+            "total", total,
47
+            "pageNum", pageNum,
48
+            "pageSize", pageSize,
49
+            "totalPages", totalPages
50
+        );
51
+    }
52
+
53
+    /**
54
+     * 获取所有活跃点位列表(不分页)
55
+     */
56
+    public List<QualityTestPoint> listAll() {
57
+        LambdaQueryWrapper<QualityTestPoint> wrapper = new LambdaQueryWrapper<>();
58
+        wrapper.eq(QualityTestPoint::getStatus, "active")
59
+               .orderByAsc(QualityTestPoint::getPointType, QualityTestPoint::getPointName);
60
+        return pointMapper.selectList(wrapper);
61
+    }
62
+
63
+    /**
64
+     * 获取点位详情
65
+     */
66
+    public QualityTestPoint getById(Long id) {
67
+        QualityTestPoint point = pointMapper.selectById(id);
68
+        if (point == null) {
69
+            throw new BusinessException("检测点位不存在");
70
+        }
71
+        return point;
72
+    }
73
+
74
+    /**
75
+     * 创建检测点位
76
+     */
77
+    @Transactional
78
+    public QualityTestPoint create(QualityTestPoint point) {
79
+        if (point.getStatus() == null) point.setStatus("active");
80
+        pointMapper.insert(point);
81
+        log.info("创建检测点位: {} ({})", point.getPointName(), point.getPointType());
82
+        return point;
83
+    }
84
+
85
+    /**
86
+     * 更新检测点位
87
+     */
88
+    @Transactional
89
+    public QualityTestPoint update(Long id, QualityTestPoint point) {
90
+        QualityTestPoint existing = getById(id);
91
+        point.setId(existing.getId());
92
+        pointMapper.updateById(point);
93
+        log.info("更新检测点位: {}", id);
94
+        return pointMapper.selectById(id);
95
+    }
96
+
97
+    /**
98
+     * 删除检测点位
99
+     */
100
+    @Transactional
101
+    public void delete(Long id) {
102
+        getById(id);
103
+        pointMapper.deleteById(id);
104
+        log.info("删除检测点位: {}", id);
105
+    }
106
+
107
+    /**
108
+     * 批量删除
109
+     */
110
+    @Transactional
111
+    public void batchDelete(List<Long> ids) {
112
+        if (ids == null || ids.isEmpty()) return;
113
+        pointMapper.deleteBatchIds(ids);
114
+        log.info("批量删除检测点位: {} 条", ids.size());
115
+    }
116
+
117
+    /**
118
+     * 切换点位状态
119
+     */
120
+    @Transactional
121
+    public QualityTestPoint toggleStatus(Long id) {
122
+        QualityTestPoint point = getById(id);
123
+        if ("active".equals(point.getStatus())) {
124
+            point.setStatus("inactive");
125
+        } else {
126
+            point.setStatus("active");
127
+        }
128
+        pointMapper.updateById(point);
129
+        log.info("切换检测点位状态: {} -> {}", id, point.getStatus());
130
+        return point;
131
+    }
132
+
133
+    /**
134
+     * 按类型分组统计
135
+     */
136
+    public List<Map<String, Object>> statByPointType() {
137
+        return pointMapper.statByPointType();
138
+    }
139
+
140
+    /**
141
+     * 按区域分组统计
142
+     */
143
+    public List<Map<String, Object>> statByArea() {
144
+        return pointMapper.statByArea();
145
+    }
146
+
147
+    /**
148
+     * 各点位合格率
149
+     */
150
+    public List<Map<String, Object>> complianceRateByPoint(String startDate, String endDate) {
151
+        return pointMapper.complianceRateByPoint(startDate, endDate);
152
+    }
153
+
154
+    /**
155
+     * 获取区域列表
156
+     */
157
+    public List<String> getAreaList() {
158
+        LambdaQueryWrapper<QualityTestPoint> wrapper = new LambdaQueryWrapper<>();
159
+        wrapper.select(QualityTestPoint::getArea)
160
+               .isNotNull(QualityTestPoint::getArea)
161
+               .ne(QualityTestPoint::getArea, "")
162
+               .groupBy(QualityTestPoint::getArea);
163
+        return pointMapper.selectList(wrapper).stream()
164
+                .map(QualityTestPoint::getArea)
165
+                .distinct()
166
+                .sorted()
167
+                .toList();
168
+    }
169
+}

+ 47
- 0
wm-production/src/main/resources/db/V_quality_test.sql View File

@@ -0,0 +1,47 @@
1
+-- ============================================================
2
+-- V_quality_test.sql
3
+-- 水质检测台账 - 检测点位管理表
4
+-- ============================================================
5
+
6
+-- 检测点位表
7
+CREATE TABLE IF NOT EXISTS prod_quality_test_point (
8
+    id                  BIGSERIAL PRIMARY KEY,
9
+    point_name          VARCHAR(200)    NOT NULL,
10
+    point_type          VARCHAR(50)     NOT NULL DEFAULT 'factory',
11
+    location            VARCHAR(500),
12
+    longitude           NUMERIC(10,7),
13
+    latitude            NUMERIC(10,7),
14
+    sampling_frequency  VARCHAR(20)     NOT NULL DEFAULT 'daily',
15
+    area                VARCHAR(200),
16
+    water_plant         VARCHAR(200),
17
+    status              VARCHAR(20)     NOT NULL DEFAULT 'active',
18
+    remark              TEXT,
19
+    deleted             INTEGER         NOT NULL DEFAULT 0,
20
+    created_at          TIMESTAMP       NOT NULL DEFAULT NOW(),
21
+    updated_at          TIMESTAMP       NOT NULL DEFAULT NOW()
22
+);
23
+
24
+CREATE INDEX IF NOT EXISTS idx_quality_point_type ON prod_quality_test_point(point_type);
25
+CREATE INDEX IF NOT EXISTS idx_quality_point_area ON prod_quality_test_point(area);
26
+CREATE INDEX IF NOT EXISTS idx_quality_point_status ON prod_quality_test_point(status);
27
+CREATE INDEX IF NOT EXISTS idx_quality_point_deleted ON prod_quality_test_point(deleted);
28
+
29
+COMMENT ON TABLE prod_quality_test_point IS '水质检测点位表';
30
+COMMENT ON COLUMN prod_quality_test_point.point_type IS '点位类型: waterSource-水源/factory-出厂/endpoint-末梢';
31
+COMMENT ON COLUMN prod_quality_test_point.sampling_frequency IS '采样频率: daily-日检/weekly-周检/monthly-月检/quarterly-季检';
32
+COMMENT ON COLUMN prod_quality_test_point.status IS '状态: active-启用/inactive-停用';
33
+
34
+-- 预置示例检测点位
35
+INSERT INTO prod_quality_test_point (point_name, point_type, location, sampling_frequency, area, water_plant, status)
36
+VALUES
37
+    ('长江取水口', 'waterSource', '长江主河道取水口', 'daily', '城南', '第一水厂', 'active'),
38
+    ('第一水厂出厂口', 'factory', '第一水厂清水池出口', 'daily', '城南', '第一水厂', 'active'),
39
+    ('城南末梢-学府路', 'endpoint', '学府路168号', 'weekly', '城南', '第一水厂', 'active'),
40
+    ('城南末梢-科技园', 'endpoint', '科技园区A栋', 'weekly', '城南', '第一水厂', 'active'),
41
+    ('城北取水口', 'waterSource', '北江支流取水口', 'daily', '城北', '第二水厂', 'active'),
42
+    ('第二水厂出厂口', 'factory', '第二水厂出厂管', 'daily', '城北', '第二水厂', 'active'),
43
+    ('城北末梢-住宅区', 'endpoint', '北苑小区3号楼', 'weekly', '城北', '第二水厂', 'active'),
44
+    ('城北末梢-医院', 'endpoint', '市第一人民医院', 'daily', '城北', '第二水厂', 'active'),
45
+    ('工业园区取水口', 'waterSource', '工业区专用取水口', 'weekly', '城东', '第三水厂', 'active'),
46
+    ('第三水厂出厂口', 'factory', '第三水厂出厂管', 'daily', '城东', '第三水厂', 'active')
47
+ON CONFLICT DO NOTHING;

+ 41
- 0
wm-production/src/main/resources/mapper/QualityTestPointMapper.xml View File

@@ -0,0 +1,41 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
3
+<mapper namespace="com.water.production.mapper.QualityTestPointMapper">
4
+
5
+    <select id="statByPointType" resultType="java.util.Map">
6
+        SELECT
7
+            point_type AS pointType,
8
+            COUNT(*) AS count,
9
+            COUNT(CASE WHEN status = 'active' THEN 1 END) AS activeCount
10
+        FROM prod_quality_test_point
11
+        WHERE deleted = 0
12
+        GROUP BY point_type
13
+        ORDER BY count DESC
14
+    </select>
15
+
16
+    <select id="statByArea" resultType="java.util.Map">
17
+        SELECT
18
+            area,
19
+            COUNT(*) AS count,
20
+            COUNT(CASE WHEN status = 'active' THEN 1 END) AS activeCount
21
+        FROM prod_quality_test_point
22
+        WHERE deleted = 0 AND area IS NOT NULL AND area != ''
23
+        GROUP BY area
24
+        ORDER BY count DESC
25
+    </select>
26
+
27
+    <select id="complianceRateByPoint" resultType="java.util.Map">
28
+        SELECT
29
+            r.sampling_point AS pointName,
30
+            COUNT(*) AS total,
31
+            COUNT(CASE WHEN r.compliance_status = 'qualified' THEN 1 END) AS qualified,
32
+            ROUND(COUNT(CASE WHEN r.compliance_status = 'qualified' THEN 1 END) * 100.0 / NULLIF(COUNT(*), 0), 2) AS rate
33
+        FROM prod_quality_test_record r
34
+        WHERE r.deleted = 0
35
+        <if test="startDate != null and startDate != ''">AND r.test_date &gt;= #{startDate}::date</if>
36
+        <if test="endDate != null and endDate != ''">AND r.test_date &lt;= #{endDate}::date</if>
37
+        GROUP BY r.sampling_point
38
+        ORDER BY rate DESC
39
+    </select>
40
+
41
+</mapper>

+ 125
- 0
wm-production/src/test/java/com/water/production/service/QualityStandardServiceTest.java View File

@@ -0,0 +1,125 @@
1
+package com.water.production.service;
2
+
3
+import com.water.production.entity.QualityStandard;
4
+import com.water.production.mapper.QualityStandardMapper;
5
+import org.junit.jupiter.api.DisplayName;
6
+import org.junit.jupiter.api.Test;
7
+import org.junit.jupiter.api.extension.ExtendWith;
8
+import org.mockito.InjectMocks;
9
+import org.mockito.Mock;
10
+import org.mockito.junit.jupiter.MockitoExtension;
11
+
12
+import static org.junit.jupiter.api.Assertions.*;
13
+import static org.mockito.Mockito.*;
14
+
15
+/**
16
+ * QualityStandardService 水质标准单元测试
17
+ * 验证 GB5749-2022 标准参数正确性
18
+ */
19
+@ExtendWith(MockitoExtension.class)
20
+class QualityStandardServiceTest {
21
+
22
+    @Mock
23
+    private QualityStandardMapper standardMapper;
24
+
25
+    @InjectMocks
26
+    private QualityStandardService standardService;
27
+
28
+    @Test
29
+    @DisplayName("出厂水浊度限值 <=1 NTU")
30
+    void factoryWaterTurbidityLimit() {
31
+        QualityStandard s = new QualityStandard();
32
+        s.setWaterType("factoryWater"); s.setParamName("turbidity");
33
+        s.setMinValue(null); s.setMaxValue(1.0); s.setEnabled(true);
34
+        when(standardMapper.selectOne(any())).thenReturn(s);
35
+
36
+        QualityStandard result = standardService.getStandard("factoryWater", "turbidity");
37
+        assertNull(result.getMinValue());
38
+        assertEquals(1.0, result.getMaxValue());
39
+    }
40
+
41
+    @Test
42
+    @DisplayName("pH 限值 6.5~8.5")
43
+    void phRangeAllTypes() {
44
+        QualityStandard s = new QualityStandard();
45
+        s.setWaterType("all"); s.setParamName("ph");
46
+        s.setMinValue(6.5); s.setMaxValue(8.5); s.setEnabled(true);
47
+        when(standardMapper.selectOne(any())).thenReturn(s);
48
+
49
+        QualityStandard result = standardService.getStandard("all", "ph");
50
+        assertEquals(6.5, result.getMinValue());
51
+        assertEquals(8.5, result.getMaxValue());
52
+    }
53
+
54
+    @Test
55
+    @DisplayName("出厂水余氯 0.3~4.0 mg/L")
56
+    void factoryWaterChlorineRange() {
57
+        QualityStandard s = new QualityStandard();
58
+        s.setWaterType("factoryWater"); s.setParamName("residualChlorine");
59
+        s.setMinValue(0.3); s.setMaxValue(4.0); s.setEnabled(true);
60
+        when(standardMapper.selectOne(any())).thenReturn(s);
61
+
62
+        QualityStandard result = standardService.getStandard("factoryWater", "residualChlorine");
63
+        assertEquals(0.3, result.getMinValue());
64
+        assertEquals(4.0, result.getMaxValue());
65
+    }
66
+
67
+    @Test
68
+    @DisplayName("管网水余氯 0.05~4.0 mg/L")
69
+    void pipeNetworkChlorineRange() {
70
+        QualityStandard s = new QualityStandard();
71
+        s.setWaterType("pipeNetworkWater"); s.setParamName("residualChlorine");
72
+        s.setMinValue(0.05); s.setMaxValue(4.0); s.setEnabled(true);
73
+        when(standardMapper.selectOne(any())).thenReturn(s);
74
+
75
+        QualityStandard result = standardService.getStandard("pipeNetworkWater", "residualChlorine");
76
+        assertEquals(0.05, result.getMinValue());
77
+        assertEquals(4.0, result.getMaxValue());
78
+    }
79
+
80
+    @Test
81
+    @DisplayName("大肠杆菌不得检出 (max=0)")
82
+    void ecoliMustBeZero() {
83
+        QualityStandard s = new QualityStandard();
84
+        s.setWaterType("factoryWater"); s.setParamName("ecoli");
85
+        s.setMinValue(null); s.setMaxValue(0.0); s.setEnabled(true);
86
+        when(standardMapper.selectOne(any())).thenReturn(s);
87
+
88
+        QualityStandard result = standardService.getStandard("factoryWater", "ecoli");
89
+        assertEquals(0.0, result.getMaxValue());
90
+    }
91
+
92
+    @Test
93
+    @DisplayName("色度限值 <=15 度")
94
+    void colorLimit() {
95
+        QualityStandard s = new QualityStandard();
96
+        s.setWaterType("all"); s.setParamName("color");
97
+        s.setMinValue(null); s.setMaxValue(15.0); s.setEnabled(true);
98
+        when(standardMapper.selectOne(any())).thenReturn(s);
99
+
100
+        QualityStandard result = standardService.getStandard("all", "color");
101
+        assertEquals(15.0, result.getMaxValue());
102
+    }
103
+
104
+    @Test
105
+    @DisplayName("菌落总数 <=100 CFU/mL")
106
+    void colonyCountLimit() {
107
+        QualityStandard s = new QualityStandard();
108
+        s.setWaterType("factoryWater"); s.setParamName("colonyCount");
109
+        s.setMinValue(null); s.setMaxValue(100.0); s.setEnabled(true);
110
+        when(standardMapper.selectOne(any())).thenReturn(s);
111
+
112
+        QualityStandard result = standardService.getStandard("factoryWater", "colonyCount");
113
+        assertEquals(100.0, result.getMaxValue());
114
+    }
115
+
116
+    @Test
117
+    @DisplayName("标准编号 GB5749-2022")
118
+    void standardCode() {
119
+        QualityStandard s = new QualityStandard();
120
+        s.setStandardCode("GB5749-2022");
121
+        s.setStandardName("生活饮用水卫生标准");
122
+        assertEquals("GB5749-2022", s.getStandardCode());
123
+        assertEquals("生活饮用水卫生标准", s.getStandardName());
124
+    }
125
+}

+ 114
- 0
wm-production/src/test/java/com/water/production/service/QualityTestPointServiceTest.java View File

@@ -0,0 +1,114 @@
1
+package com.water.production.service;
2
+
3
+import com.water.production.entity.QualityTestPoint;
4
+import com.water.production.mapper.QualityTestPointMapper;
5
+import org.junit.jupiter.api.DisplayName;
6
+import org.junit.jupiter.api.Test;
7
+import org.junit.jupiter.api.extension.ExtendWith;
8
+import org.mockito.InjectMocks;
9
+import org.mockito.Mock;
10
+import org.mockito.junit.jupiter.MockitoExtension;
11
+
12
+import static org.junit.jupiter.api.Assertions.*;
13
+import static org.mockito.Mockito.*;
14
+
15
+/**
16
+ * QualityTestPointService 检测点位管理单元测试
17
+ */
18
+@ExtendWith(MockitoExtension.class)
19
+class QualityTestPointServiceTest {
20
+
21
+    @Mock
22
+    private QualityTestPointMapper pointMapper;
23
+
24
+    @InjectMocks
25
+    private QualityTestPointService pointService;
26
+
27
+    @Test
28
+    @DisplayName("创建点位 - 默认状态为 active")
29
+    void createPointDefaultStatus() {
30
+        QualityTestPoint point = new QualityTestPoint();
31
+        point.setPointName("测试点位");
32
+        point.setPointType("factory");
33
+        point.setSamplingFrequency("daily");
34
+
35
+        when(pointMapper.insert(any(QualityTestPoint.class))).thenReturn(1);
36
+
37
+        QualityTestPoint result = pointService.create(point);
38
+
39
+        assertEquals("active", result.getStatus());
40
+        verify(pointMapper, times(1)).insert(point);
41
+    }
42
+
43
+    @Test
44
+    @DisplayName("创建各类点位 - waterSource/factory/endpoint")
45
+    void createPointAllTypes() {
46
+        when(pointMapper.insert(any())).thenReturn(1);
47
+
48
+        QualityTestPoint ws = new QualityTestPoint();
49
+        ws.setPointName("取水口"); ws.setPointType("waterSource");
50
+        assertEquals("waterSource", pointService.create(ws).getPointType());
51
+
52
+        QualityTestPoint ft = new QualityTestPoint();
53
+        ft.setPointName("出厂口"); ft.setPointType("factory");
54
+        assertEquals("factory", pointService.create(ft).getPointType());
55
+
56
+        QualityTestPoint ep = new QualityTestPoint();
57
+        ep.setPointName("末梢点"); ep.setPointType("endpoint");
58
+        assertEquals("endpoint", pointService.create(ep).getPointType());
59
+    }
60
+
61
+    @Test
62
+    @DisplayName("切换状态 - active -> inactive")
63
+    void toggleActiveToInactive() {
64
+        QualityTestPoint point = new QualityTestPoint();
65
+        point.setId(1L); point.setStatus("active");
66
+
67
+        when(pointMapper.selectById(1L)).thenReturn(point);
68
+        when(pointMapper.updateById(any())).thenReturn(1);
69
+
70
+        QualityTestPoint result = pointService.toggleStatus(1L);
71
+        assertEquals("inactive", result.getStatus());
72
+    }
73
+
74
+    @Test
75
+    @DisplayName("切换状态 - inactive -> active")
76
+    void toggleInactiveToActive() {
77
+        QualityTestPoint point = new QualityTestPoint();
78
+        point.setId(2L); point.setStatus("inactive");
79
+
80
+        when(pointMapper.selectById(2L)).thenReturn(point);
81
+        when(pointMapper.updateById(any())).thenReturn(1);
82
+
83
+        QualityTestPoint result = pointService.toggleStatus(2L);
84
+        assertEquals("active", result.getStatus());
85
+    }
86
+
87
+    @Test
88
+    @DisplayName("点位实体字段完整性")
89
+    void pointEntityFields() {
90
+        QualityTestPoint p = new QualityTestPoint();
91
+        p.setId(1L);
92
+        p.setPointName("长江取水口");
93
+        p.setPointType("waterSource");
94
+        p.setLocation("长江主河道");
95
+        p.setLongitude(118.7654321);
96
+        p.setLatitude(32.1234567);
97
+        p.setSamplingFrequency("daily");
98
+        p.setArea("城南");
99
+        p.setWaterPlant("第一水厂");
100
+        p.setStatus("active");
101
+        p.setRemark("主取水口");
102
+
103
+        assertEquals("长江取水口", p.getPointName());
104
+        assertEquals("waterSource", p.getPointType());
105
+        assertEquals("长江主河道", p.getLocation());
106
+        assertEquals(118.7654321, p.getLongitude());
107
+        assertEquals(32.1234567, p.getLatitude());
108
+        assertEquals("daily", p.getSamplingFrequency());
109
+        assertEquals("城南", p.getArea());
110
+        assertEquals("第一水厂", p.getWaterPlant());
111
+        assertEquals("active", p.getStatus());
112
+        assertEquals("主取水口", p.getRemark());
113
+    }
114
+}