feat(wm-revenue): #55 客服知识库+公告板+KPI看板完整实现
- Entity: KbArticle(知识库文章), Announcement(公告), KpiDashboard(KPI看板VO)
- Mapper: KbArticleMapper, AnnouncementMapper (MyBatis-Plus)
- Service: KnowledgeBaseService(知识库CRUD+搜索+分类+点赞+热门),
AnnouncementService(公告发布/编辑/按类型筛选/按范围推送/撤回),
KpiService(KPI聚合计算:待处理量/时效/满意率/趋势/排行)
- Controller: CsSupportController (/api/revenue/cs/*)
- SQL DDL: V_cs_support.sql (cs_kb_article + cs_announcement 表+示例数据)
- Frontend: KnowledgeBaseView.vue(列表/卡片/Markdown编辑器),
AnnouncementView.vue(类型标签/状态切换/时间范围),
KpiDashboardView.vue(ECharts趋势图+饼图+排行)
- Unit Test: CsSupportServiceTest (知识库/公告/KPI三组测试)
- Router: 新增 /cs/knowledge, /cs/announcement, /cs/kpi 路由
This commit is contained in:
+151
@@ -0,0 +1,151 @@
|
||||
-- =====================================================
|
||||
-- GIS 地图展示模块 DDL
|
||||
-- 包含: 监测点位表、管网线段表、区域表
|
||||
-- 数据库: PostgreSQL
|
||||
-- =====================================================
|
||||
|
||||
-- 1. GIS 监测点位表
|
||||
CREATE TABLE IF NOT EXISTS prod_gis_point (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
point_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
point_name VARCHAR(200) NOT NULL,
|
||||
point_type VARCHAR(20) NOT NULL, -- flow/pressure/level/quality/valve
|
||||
area VARCHAR(100),
|
||||
lng DECIMAL(12, 8) NOT NULL, -- 经度
|
||||
lat DECIMAL(12, 8) NOT NULL, -- 纬度
|
||||
elevation DECIMAL(8, 2), -- 海拔高度(米)
|
||||
device_id BIGINT, -- 关联 prod_monitor_device.id
|
||||
address VARCHAR(500),
|
||||
status VARCHAR(20) DEFAULT 'online', -- online/offline/fault
|
||||
properties JSONB, -- 扩展属性(JSON)
|
||||
remark VARCHAR(500),
|
||||
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
COMMENT ON TABLE prod_gis_point IS 'GIS 监测点位表';
|
||||
COMMENT ON COLUMN prod_gis_point.point_type IS '点位类型: flow-流量/pressure-压力/level-液位/quality-水质/valve-阀门';
|
||||
COMMENT ON COLUMN prod_gis_point.lng IS '经度';
|
||||
COMMENT ON COLUMN prod_gis_point.lat IS '纬度';
|
||||
COMMENT ON COLUMN prod_gis_point.device_id IS '关联设备ID(prod_monitor_device.id)';
|
||||
COMMENT ON COLUMN prod_gis_point.properties IS '扩展属性(JSON,存储不同类型点位的特有属性)';
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_point_type ON prod_gis_point(point_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_point_area ON prod_gis_point(area);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_point_status ON prod_gis_point(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_point_device_id ON prod_gis_point(device_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_point_lng_lat ON prod_gis_point(lng, lat);
|
||||
|
||||
|
||||
-- 2. GIS 管网线段表
|
||||
CREATE TABLE IF NOT EXISTS prod_gis_pipeline (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
pipeline_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
pipeline_name VARCHAR(200),
|
||||
pipeline_type VARCHAR(30), -- supply/distribution/drainage/raw_water
|
||||
material VARCHAR(30), -- ductile_iron/pvc/pe/steel
|
||||
diameter DECIMAL(8, 2), -- 管径(mm)
|
||||
start_lng DECIMAL(12, 8) NOT NULL, -- 起点经度
|
||||
start_lat DECIMAL(12, 8) NOT NULL, -- 起点纬度
|
||||
end_lng DECIMAL(12, 8) NOT NULL, -- 终点经度
|
||||
end_lat DECIMAL(12, 8) NOT NULL, -- 终点纬度
|
||||
length DECIMAL(10, 2), -- 长度(米)
|
||||
start_node_id BIGINT, -- 起点节点ID(关联 prod_gis_point.id)
|
||||
end_node_id BIGINT, -- 终点节点ID(关联 prod_gis_point.id)
|
||||
area VARCHAR(100),
|
||||
burial_depth DECIMAL(6, 2), -- 埋深(米)
|
||||
build_year INTEGER,
|
||||
status VARCHAR(20) DEFAULT 'normal', -- normal/leakage/damaged/maintenance
|
||||
properties JSONB,
|
||||
remark VARCHAR(500),
|
||||
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
COMMENT ON TABLE prod_gis_pipeline IS 'GIS 管网线段表';
|
||||
COMMENT ON COLUMN prod_gis_pipeline.pipeline_type IS '管线类型: supply-供水/distribution-配水/drainage-排水/raw_water-原水';
|
||||
COMMENT ON COLUMN prod_gis_pipeline.material IS '材质: ductile_iron-球墨铸铁/pvc/pe/steel-钢管';
|
||||
COMMENT ON COLUMN prod_gis_pipeline.status IS '状态: normal-正常/leakage-渗漏/damaged-损坏/maintenance-维护中';
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_type ON prod_gis_pipeline(pipeline_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_area ON prod_gis_pipeline(area);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_status ON prod_gis_pipeline(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_start_node ON prod_gis_pipeline(start_node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_end_node ON prod_gis_pipeline(end_node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_start_coord ON prod_gis_pipeline(start_lng, start_lat);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_end_coord ON prod_gis_pipeline(end_lng, end_lat);
|
||||
|
||||
|
||||
-- 3. GIS 区域表
|
||||
CREATE TABLE IF NOT EXISTS prod_gis_area (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
area_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
area_name VARCHAR(200) NOT NULL,
|
||||
area_type VARCHAR(30), -- water_plant/supply_zone/dma/admin_district
|
||||
center_lng DECIMAL(12, 8),
|
||||
center_lat DECIMAL(12, 8),
|
||||
area_size DECIMAL(10, 4), -- 面积(平方公里)
|
||||
boundary TEXT, -- 边界 GeoJSON (Polygon/MultiPolygon)
|
||||
parent_id BIGINT, -- 上级区域ID
|
||||
device_count INTEGER DEFAULT 0,
|
||||
online_count INTEGER DEFAULT 0,
|
||||
alert_count INTEGER DEFAULT 0,
|
||||
population DECIMAL(10, 4), -- 供水人口(万人)
|
||||
status VARCHAR(20) DEFAULT 'active', -- active/inactive
|
||||
remark VARCHAR(500),
|
||||
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
COMMENT ON TABLE prod_gis_area IS 'GIS 区域表';
|
||||
COMMENT ON COLUMN prod_gis_area.area_type IS '区域类型: water_plant-水厂/supply_zone-供水片区/dma-独立计量区/admin_district-行政区';
|
||||
COMMENT ON COLUMN prod_gis_area.boundary IS '区域边界(GeoJSON 格式)';
|
||||
COMMENT ON COLUMN prod_gis_area.population IS '供水人口(万人)';
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_area_type ON prod_gis_area(area_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_area_status ON prod_gis_area(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_area_parent ON prod_gis_area(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_area_center ON prod_gis_area(center_lng, center_lat);
|
||||
|
||||
|
||||
-- =====================================================
|
||||
-- 初始数据 (示例)
|
||||
-- =====================================================
|
||||
|
||||
-- 示例区域
|
||||
INSERT INTO prod_gis_area (area_code, area_name, area_type, center_lng, center_lat, area_size, device_count, online_count, alert_count, population, status)
|
||||
VALUES
|
||||
('AREA-001', '一体化水厂', 'water_plant', 82.07100000, 44.84500000, 2.5, 15, 12, 1, 5.0, 'active'),
|
||||
('AREA-002', '管网一区', 'supply_zone', 82.08500000, 44.85500000, 8.0, 25, 20, 3, 12.0, 'active'),
|
||||
('AREA-003', '管网二区', 'supply_zone', 82.09500000, 44.86000000, 6.5, 18, 15, 2, 8.5, 'active'),
|
||||
('AREA-004', 'DMA-001', 'dma', 82.08000000, 44.85000000, 1.2, 8, 7, 0, 2.0, 'active'),
|
||||
('AREA-005', 'DMA-002', 'dma', 82.09000000, 44.85800000, 1.8, 10, 8, 1, 3.5, 'active')
|
||||
ON CONFLICT (area_code) DO NOTHING;
|
||||
|
||||
-- 示例监测点位
|
||||
INSERT INTO prod_gis_point (point_code, point_name, point_type, area, lng, lat, elevation, device_id, address, status)
|
||||
VALUES
|
||||
('GIS-FLOW-001', '一号泵站出口流量计', 'flow', '一体化水厂', 82.07123456, 44.84567890, 350.5, 1, '一号泵站出口', 'online'),
|
||||
('GIS-FLOW-002', '二号泵站流量计', 'flow', '一体化水厂', 82.07234567, 44.84678901, 348.2, 2, '二号泵站', 'online'),
|
||||
('GIS-PRES-001', '管网压力监测点A', 'pressure', '管网一区', 82.08567890, 44.85512345, 340.0, 3, '人民路与建设路交叉口', 'online'),
|
||||
('GIS-PRES-002', '管网压力监测点B', 'pressure', '管网一区', 82.08678901, 44.85623456, 338.5, 4, '中山路与解放路交叉口', 'offline'),
|
||||
('GIS-LEV-001', '清水池液位计', 'level', '一体化水厂', 82.07012345, 44.84456789, 355.0, NULL, '清水池', 'online'),
|
||||
('GIS-QUAL-001', '出厂水质监测仪', 'quality', '一体化水厂', 82.07345678, 44.84789012, 345.0, 5, '出厂水管', 'fault'),
|
||||
('GIS-VALV-001', '主干管阀门V01', 'valve', '管网一区', 82.08456789, 44.85401234, 342.0, NULL, '主干管起点', 'online'),
|
||||
('GIS-VALV-002', '主干管阀门V02', 'valve', '管网二区', 82.09512345, 44.86023456, 335.0, NULL, '主干管末端', 'online'),
|
||||
('GIS-FLOW-003', 'DMA-001入口流量计', 'flow', 'DMA-001', 82.08045678, 44.85067890, 341.5, NULL, 'DMA-001入口', 'online'),
|
||||
('GIS-PRES-003', '管网压力监测点C', 'pressure', '管网二区', 82.09623456, 44.86134567, 333.0, NULL, '建设路与和平路交叉口', 'online')
|
||||
ON CONFLICT (point_code) DO NOTHING;
|
||||
|
||||
-- 示例管线
|
||||
INSERT INTO prod_gis_pipeline (pipeline_code, pipeline_name, pipeline_type, material, diameter, start_lng, start_lat, end_lng, end_lat, length, start_node_id, end_node_id, area, burial_depth, build_year, status)
|
||||
VALUES
|
||||
('PIPE-001', '出厂水主干管', 'supply', 'ductile_iron', 600.00, 82.07123456, 44.84567890, 82.08456789, 44.85401234, 1500.00, 1, 7, '一体化水厂', 1.5, 2020, 'normal'),
|
||||
('PIPE-002', '管网一区主干管', 'distribution', 'ductile_iron', 400.00, 82.08456789, 44.85401234, 82.08678901, 44.85623456, 800.00, 7, 4, '管网一区', 1.2, 2020, 'normal'),
|
||||
('PIPE-003', '管网二区主干管', 'distribution', 'pvc', 300.00, 82.08678901, 44.85623456, 82.09512345, 44.86023456, 1200.00, 4, 8, '管网二区', 1.0, 2021, 'normal'),
|
||||
('PIPE-004', 'DMA-001入口管', 'distribution', 'pe', 200.00, 82.08456789, 44.85401234, 82.08045678, 44.85067890, 500.00, 7, 9, 'DMA-001', 0.8, 2022, 'normal'),
|
||||
('PIPE-005', '管网二区支管', 'distribution', 'pe', 150.00, 82.09512345, 44.86023456, 82.09623456, 44.86134567, 300.00, 8, 10, '管网二区', 0.8, 2022, 'maintenance')
|
||||
ON CONFLICT (pipeline_code) DO NOTHING;
|
||||
@@ -0,0 +1,109 @@
|
||||
-- ============================================================
|
||||
-- V4__quality_ledger.sql
|
||||
-- 水质检测台账模块 DDL
|
||||
-- 包含: 检测记录、水质标准、检测计划
|
||||
-- ============================================================
|
||||
|
||||
-- 1. 水质检测记录表
|
||||
CREATE TABLE IF NOT EXISTS prod_quality_test_record (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
test_type VARCHAR(20) NOT NULL DEFAULT 'routine', -- routine/special/complaint
|
||||
water_type VARCHAR(20) NOT NULL DEFAULT 'treated', -- raw/treated/network
|
||||
sampling_point VARCHAR(100), -- 采样点
|
||||
area VARCHAR(50), -- 所属区域
|
||||
test_date DATE NOT NULL, -- 检测日期
|
||||
test_time TIME, -- 检测时间
|
||||
tester VARCHAR(50), -- 检测人
|
||||
turbidity NUMERIC(10,2), -- 浊度 (NTU)
|
||||
ph NUMERIC(5,2), -- pH值
|
||||
residual_chlorine NUMERIC(6,3), -- 余氯 (mg/L)
|
||||
color NUMERIC(8,2), -- 色度 (度)
|
||||
odor NUMERIC(4,1), -- 嗅味 (级)
|
||||
ecoli NUMERIC(10,2), -- 大肠杆菌 (CFU/100mL)
|
||||
colony_count NUMERIC(10,2), -- 菌落总数 (CFU/mL)
|
||||
compliance_status VARCHAR(20) NOT NULL DEFAULT 'pending', -- qualified/unqualified/pending
|
||||
unqualified_items TEXT, -- 不合格项 (JSON)
|
||||
remark VARCHAR(500), -- 备注
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_record_type ON prod_quality_test_record(test_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_record_water_type ON prod_quality_test_record(water_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_record_area ON prod_quality_test_record(area);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_record_date ON prod_quality_test_record(test_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_record_compliance ON prod_quality_test_record(compliance_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_record_deleted ON prod_quality_test_record(deleted);
|
||||
|
||||
COMMENT ON TABLE prod_quality_test_record IS '水质检测记录表';
|
||||
COMMENT ON COLUMN prod_quality_test_record.test_type IS '检测类型: routine-常规/special-专项/complaint-投诉';
|
||||
COMMENT ON COLUMN prod_quality_test_record.water_type IS '水样类型: raw-原水/treated-出厂水/network-管网末梢水';
|
||||
COMMENT ON COLUMN prod_quality_test_record.compliance_status IS '合格状态: qualified-合格/unqualified-不合格/pending-待判定';
|
||||
|
||||
-- 2. 水质标准表 (GB5749-2022)
|
||||
CREATE TABLE IF NOT EXISTS prod_quality_standard (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
standard_name VARCHAR(100) NOT NULL,
|
||||
standard_code VARCHAR(50) NOT NULL DEFAULT 'GB5749-2022',
|
||||
param_name VARCHAR(50) NOT NULL, -- 参数编码
|
||||
param_label VARCHAR(50), -- 参数显示名
|
||||
param_unit VARCHAR(20), -- 单位
|
||||
min_value NUMERIC(12,4), -- 最小值 (NULL=无下限)
|
||||
max_value NUMERIC(12,4), -- 最大值 (NULL=无上限)
|
||||
water_type VARCHAR(20) NOT NULL DEFAULT 'all', -- 适用水样类型
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_standard_code ON prod_quality_standard(standard_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_standard_param ON prod_quality_standard(param_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_standard_deleted ON prod_quality_standard(deleted);
|
||||
|
||||
COMMENT ON TABLE prod_quality_standard IS '水质标准表 (基于GB5749-2022)';
|
||||
|
||||
-- 初始化 GB5749-2022 默认标准
|
||||
INSERT INTO prod_quality_standard (standard_name, standard_code, param_name, param_label, param_unit, min_value, max_value, water_type)
|
||||
VALUES
|
||||
('生活饮用水卫生标准', 'GB5749-2022', 'turbidity', '浊度', 'NTU', NULL, 1.0, 'treated'),
|
||||
('生活饮用水卫生标准', 'GB5749-2022', 'turbidity', '浊度', 'NTU', NULL, 3.0, 'network'),
|
||||
('生活饮用水卫生标准', 'GB5749-2022', 'ph', 'pH', '', 6.5, 8.5, 'all'),
|
||||
('生活饮用水卫生标准', 'GB5749-2022', 'residual_chlorine', '余氯', 'mg/L', 0.3, 2.0, 'treated'),
|
||||
('生活饮用水卫生标准', 'GB5749-2022', 'residual_chlorine', '余氯', 'mg/L', 0.05, 2.0, 'network'),
|
||||
('生活饮用水卫生标准', 'GB5749-2022', 'color', '色度', '度', NULL, 15.0, 'all'),
|
||||
('生活饮用水卫生标准', 'GB5749-2022', 'odor', '嗅味', '级', NULL, 2.0, 'all'),
|
||||
('生活饮用水卫生标准', 'GB5749-2022', 'ecoli', '大肠杆菌', 'CFU/100mL', NULL, 0.0, 'all'),
|
||||
('生活饮用水卫生标准', 'GB5749-2022', 'colony_count', '菌落总数', 'CFU/mL', NULL, 100.0, 'all')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 3. 水质检测计划表
|
||||
CREATE TABLE IF NOT EXISTS prod_quality_test_plan (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
plan_name VARCHAR(100) NOT NULL,
|
||||
test_type VARCHAR(20) NOT NULL DEFAULT 'routine', -- routine/special
|
||||
water_type VARCHAR(20) NOT NULL DEFAULT 'treated',
|
||||
sampling_point VARCHAR(100),
|
||||
area VARCHAR(50),
|
||||
frequency VARCHAR(20) NOT NULL DEFAULT 'daily', -- daily/weekly/monthly
|
||||
test_params VARCHAR(200), -- 检测参数 (逗号分隔)
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE, -- NULL=长期
|
||||
next_test_date DATE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active', -- active/paused/completed
|
||||
execution_count INTEGER NOT NULL DEFAULT 0,
|
||||
remark VARCHAR(500),
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_plan_status ON prod_quality_test_plan(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_plan_frequency ON prod_quality_test_plan(frequency);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_plan_next_date ON prod_quality_test_plan(next_test_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_plan_deleted ON prod_quality_test_plan(deleted);
|
||||
|
||||
COMMENT ON TABLE prod_quality_test_plan IS '水质检测计划表';
|
||||
COMMENT ON COLUMN prod_quality_test_plan.frequency IS '检测频率: daily-日检/weekly-周检/monthly-月检';
|
||||
COMMENT ON COLUMN prod_quality_test_plan.status IS '计划状态: active-启用/paused-暂停/completed-已完成';
|
||||
@@ -13,6 +13,9 @@ const routes = [
|
||||
{ path: 'system/dept', name: 'dept', component: () => import('@/views/system/dept/DeptList.vue') },
|
||||
{ path: 'dispatch-command', name: 'dispatchCommandList', component: () => import('@/views/dispatch-command/CommandList.vue') },
|
||||
{ path: 'dispatch-command/:id', name: 'dispatchCommandDetail', component: () => import('@/views/dispatch-command/CommandDetail.vue') },
|
||||
{ path: 'cs/knowledge', name: 'csKnowledge', component: () => import('@/views/cs/KnowledgeBaseView.vue') },
|
||||
{ path: 'cs/announcement', name: 'csAnnouncement', component: () => import('@/views/cs/AnnouncementView.vue') },
|
||||
{ path: 'cs/kpi', name: 'csKpi', component: () => import('@/views/cs/KpiDashboardView.vue') },
|
||||
]
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' }
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<div class="announcement-mgmt">
|
||||
<!-- 搜索区 -->
|
||||
<el-card shadow="never">
|
||||
<el-form :inline="true" :model="filterForm">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="filterForm.keyword" placeholder="标题/内容/范围" clearable @clear="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="filterForm.type" placeholder="全部" clearable @change="handleSearch">
|
||||
<el-option label="停水" value="water_outage" />
|
||||
<el-option label="水质" value="water_quality" />
|
||||
<el-option label="维修" value="maintenance" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filterForm.status" placeholder="全部" clearable @change="handleSearch">
|
||||
<el-option label="草稿" :value="0" />
|
||||
<el-option label="已发布" :value="1" />
|
||||
<el-option label="已撤回" :value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="12" style="margin-top: 12px">
|
||||
<el-col :span="6" v-for="stat in typeStats" :key="stat.type">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-value">{{ stat.count }}</div>
|
||||
<div class="stat-label">{{ typeLabel(stat.type) }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div style="margin-top: 16px">
|
||||
<el-button type="primary" @click="openCreateDialog"><el-icon><Plus /></el-icon> 发布公告</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<el-table :data="tableData" border style="margin-top: 12px" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="type" label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="typeTag(row.type)" size="small">{{ typeLabel(row.type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="priority" label="优先级" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="priorityTag(row.priority)" size="small">{{ priorityLabel(row.priority) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="affectedArea" label="影响范围" width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="publishTime" label="发布时间" width="170" />
|
||||
<el-table-column prop="publisherName" label="发布人" width="100" />
|
||||
<el-table-column label="操作" width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="viewDetail(row)">查看</el-button>
|
||||
<el-button link type="warning" v-if="row.status === 0" @click="openEditDialog(row)">编辑</el-button>
|
||||
<el-button link type="success" v-if="row.status === 0" @click="handlePublish(row)">发布</el-button>
|
||||
<el-button link type="warning" v-if="row.status === 1" @click="handleWithdraw(row)">撤回</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination style="margin-top: 16px; justify-content: flex-end"
|
||||
v-model:current-page="pagination.page" v-model:page-size="pagination.size"
|
||||
:total="pagination.total" :page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next" @change="fetchData" />
|
||||
|
||||
<!-- 编辑对话框 -->
|
||||
<el-dialog v-model="showEditor" :title="isEdit ? '编辑公告' : '发布公告'" width="700px" destroy-on-close>
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="标题" required>
|
||||
<el-input v-model="form.title" placeholder="公告标题" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="类型" required>
|
||||
<el-select v-model="form.type" style="width: 100%">
|
||||
<el-option label="停水" value="water_outage" />
|
||||
<el-option label="水质" value="water_quality" />
|
||||
<el-option label="维修" value="maintenance" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="优先级">
|
||||
<el-select v-model="form.priority" style="width: 100%">
|
||||
<el-option label="低" value="low" />
|
||||
<el-option label="中" value="medium" />
|
||||
<el-option label="高" value="high" />
|
||||
<el-option label="紧急" value="urgent" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="影响范围">
|
||||
<el-input v-model="form.affectedArea" placeholder="描述受影响的区域" />
|
||||
</el-form-item>
|
||||
<el-form-item label="区域编码">
|
||||
<el-input v-model="form.areaCode" placeholder="用于定向推送(选填)" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="计划开始">
|
||||
<el-date-picker v-model="form.plannedStart" type="datetime" style="width: 100%"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss" placeholder="开始时间" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="计划结束">
|
||||
<el-date-picker v-model="form.plannedEnd" type="datetime" style="width: 100%"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss" placeholder="结束时间" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="内容">
|
||||
<el-input v-model="form.content" type="textarea" :rows="6" placeholder="公告内容" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showEditor = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<el-dialog v-model="showDetail" :title="detailItem?.title" width="600px">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="类型">
|
||||
<el-tag :type="typeTag(detailItem?.type)">{{ typeLabel(detailItem?.type) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="优先级">
|
||||
<el-tag :type="priorityTag(detailItem?.priority)">{{ priorityLabel(detailItem?.priority) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ statusLabel(detailItem?.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="影响范围">{{ detailItem?.affectedArea }}</el-descriptions-item>
|
||||
<el-descriptions-item label="计划时间" :span="2">
|
||||
{{ detailItem?.plannedStart }} ~ {{ detailItem?.plannedEnd }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="发布人">{{ detailItem?.publisherName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发布时间">{{ detailItem?.publishTime }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-divider />
|
||||
<div class="detail-content">{{ detailItem?.content }}</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, Plus } from '@element-plus/icons-vue'
|
||||
import request from '@/api/request'
|
||||
|
||||
const API = '/api/revenue/cs/announcement'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const showEditor = ref(false)
|
||||
const showDetail = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
const tableData = ref<any[]>([])
|
||||
const typeStats = ref<any[]>([])
|
||||
const detailItem = ref<any>(null)
|
||||
|
||||
const filterForm = reactive({ keyword: '', type: '', status: undefined as number | undefined })
|
||||
const pagination = reactive({ page: 1, size: 10, total: 0 })
|
||||
|
||||
const form = reactive({
|
||||
title: '', content: '', type: 'water_outage', affectedArea: '', areaCode: '',
|
||||
plannedStart: '', plannedEnd: '', priority: 'medium', publisherName: '当前用户'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
fetchStats()
|
||||
})
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.get(`${API}/list`, {
|
||||
params: { page: pagination.page, size: pagination.size, ...filterForm }
|
||||
})
|
||||
tableData.value = res.data?.records || []
|
||||
pagination.total = res.data?.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const res = await request.get(`${API}/stats`)
|
||||
typeStats.value = res.data || []
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function handleSearch() { pagination.page = 1; fetchData() }
|
||||
function handleReset() {
|
||||
filterForm.keyword = ''; filterForm.type = ''; filterForm.status = undefined; handleSearch()
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
isEdit.value = false; editId.value = null
|
||||
Object.assign(form, { title: '', content: '', type: 'water_outage', affectedArea: '', areaCode: '',
|
||||
plannedStart: '', plannedEnd: '', priority: 'medium' })
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(row: any) {
|
||||
isEdit.value = true; editId.value = row.id
|
||||
Object.assign(form, row)
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.title) { ElMessage.warning('请输入标题'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
if (isEdit.value && editId.value) {
|
||||
await request.put(`${API}/${editId.value}`, form)
|
||||
} else {
|
||||
await request.post(API, form)
|
||||
}
|
||||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
showEditor.value = false
|
||||
fetchData(); fetchStats()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function viewDetail(row: any) { detailItem.value = row; showDetail.value = true }
|
||||
|
||||
async function handlePublish(row: any) {
|
||||
await ElMessageBox.confirm(`确定发布「${row.title}」?`, '确认发布')
|
||||
await request.post(`${API}/${row.id}/publish`)
|
||||
ElMessage.success('发布成功'); fetchData(); fetchStats()
|
||||
}
|
||||
|
||||
async function handleWithdraw(row: any) {
|
||||
await ElMessageBox.confirm(`确定撤回「${row.title}」?`, '确认撤回')
|
||||
await request.post(`${API}/${row.id}/withdraw`)
|
||||
ElMessage.success('撤回成功'); fetchData(); fetchStats()
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm(`确定删除「${row.title}」?`, '确认删除', { type: 'warning' })
|
||||
await request.delete(`${API}/${row.id}`)
|
||||
ElMessage.success('删除成功'); fetchData(); fetchStats()
|
||||
}
|
||||
|
||||
const typeMap: Record<string, string> = { water_outage: '停水', water_quality: '水质', maintenance: '维修', other: '其他' }
|
||||
const typeTagMap: Record<string, string> = { water_outage: 'danger', water_quality: 'warning', maintenance: '', other: 'info' }
|
||||
|
||||
function typeLabel(t: string) { return typeMap[t] || t }
|
||||
function typeTag(t: string) { return (typeTagMap[t] || 'info') as any }
|
||||
function priorityLabel(p: string) { return { low: '低', medium: '中', high: '高', urgent: '紧急' }[p] || p }
|
||||
function priorityTag(p: string) { return ({ low: 'info', medium: '', high: 'warning', urgent: 'danger' }[p] || 'info') as any }
|
||||
function statusLabel(s: number) { return ['草稿', '已发布', '已撤回'][s] || '未知' }
|
||||
function statusTag(s: number) { return ['info', 'success', 'warning'][s] as any || 'info' }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card { text-align: center; cursor: pointer; }
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: #409eff; }
|
||||
.stat-label { font-size: 13px; color: #666; margin-top: 4px; }
|
||||
.detail-content { white-space: pre-wrap; line-height: 1.8; }
|
||||
</style>
|
||||
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<div class="knowledge-base">
|
||||
<!-- 搜索过滤区 -->
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form :inline="true" :model="filterForm">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="filterForm.keyword" placeholder="搜索标题/内容/标签" clearable @clear="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="filterForm.category" placeholder="全部分类" clearable @change="handleSearch">
|
||||
<el-option v-for="cat in categories" :key="cat.category" :label="cat.category" :value="cat.category">
|
||||
<span>{{ cat.category }}</span>
|
||||
<span style="float: right; color: #999; font-size: 12px">{{ cat.count }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filterForm.status" placeholder="全部" clearable @change="handleSearch">
|
||||
<el-option label="草稿" :value="0" />
|
||||
<el-option label="已发布" :value="1" />
|
||||
<el-option label="已归档" :value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div style="margin-top: 16px; display: flex; justify-content: space-between; align-items: center">
|
||||
<el-button type="primary" @click="openCreateDialog"><el-icon><Plus /></el-icon> 新建文章</el-button>
|
||||
<el-radio-group v-model="viewMode" size="small">
|
||||
<el-radio-button label="list">列表</el-radio-button>
|
||||
<el-radio-button label="card">卡片</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 列表视图 -->
|
||||
<el-table v-if="viewMode === 'list'" :data="tableData" border style="margin-top: 12px" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="category" label="分类" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.category }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tags" label="标签" width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="viewCount" label="浏览" width="80" align="center" />
|
||||
<el-table-column prop="likeCount" label="点赞" width="80" align="center" />
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="authorName" label="作者" width="100" />
|
||||
<el-table-column prop="updatedAt" label="更新时间" width="170" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="viewDetail(row)">查看</el-button>
|
||||
<el-button link type="warning" @click="openEditDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 卡片视图 -->
|
||||
<el-row v-else :gutter="16" style="margin-top: 12px" v-loading="loading">
|
||||
<el-col :span="6" v-for="item in tableData" :key="item.id">
|
||||
<el-card shadow="hover" class="article-card" @click="viewDetail(item)">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">{{ item.title }}</span>
|
||||
<el-tag :type="statusTag(item.status)" size="small">{{ statusLabel(item.status) }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<p class="card-summary">{{ item.summary || '暂无摘要' }}</p>
|
||||
<div class="card-meta">
|
||||
<el-tag size="small">{{ item.category }}</el-tag>
|
||||
<span class="meta-count">👁 {{ item.viewCount || 0 }} · 👍 {{ item.likeCount || 0 }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination style="margin-top: 16px; justify-content: flex-end"
|
||||
v-model:current-page="pagination.page" v-model:page-size="pagination.size"
|
||||
:total="pagination.total" :page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next" @change="fetchData" />
|
||||
|
||||
<!-- 编辑对话框 -->
|
||||
<el-dialog v-model="showEditor" :title="isEdit ? '编辑文章' : '新建文章'" width="800px" destroy-on-close>
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="标题" required>
|
||||
<el-input v-model="form.title" placeholder="请输入文章标题" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="分类" required>
|
||||
<el-select v-model="form.category" placeholder="选择分类" style="width: 100%">
|
||||
<el-option label="FAQ" value="FAQ" />
|
||||
<el-option label="政策法规" value="政策法规" />
|
||||
<el-option label="操作指南" value="操作指南" />
|
||||
<el-option label="常见问题" value="常见问题" />
|
||||
<el-option label="通知公告" value="通知公告" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status" style="width: 100%">
|
||||
<el-option label="草稿" :value="0" />
|
||||
<el-option label="已发布" :value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="标签">
|
||||
<el-input v-model="form.tags" placeholder="多个标签用逗号分隔" />
|
||||
</el-form-item>
|
||||
<el-form-item label="摘要">
|
||||
<el-input v-model="form.summary" type="textarea" :rows="2" placeholder="文章摘要(选填)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容">
|
||||
<el-input v-model="form.content" type="textarea" :rows="12" placeholder="支持 Markdown 格式" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showEditor = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<el-dialog v-model="showDetail" :title="detailArticle?.title" width="700px">
|
||||
<div class="detail-meta">
|
||||
<el-tag>{{ detailArticle?.category }}</el-tag>
|
||||
<span>作者: {{ detailArticle?.authorName }}</span>
|
||||
<span>浏览: {{ detailArticle?.viewCount }}</span>
|
||||
<span>点赞: {{ detailArticle?.likeCount }}</span>
|
||||
</div>
|
||||
<el-divider />
|
||||
<div class="detail-content" v-html="renderMarkdown(detailArticle?.content || '')"></div>
|
||||
<template #footer>
|
||||
<el-button @click="handleLike" :icon="Star">点赞</el-button>
|
||||
<el-button type="primary" @click="showDetail = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, Plus, Star } from '@element-plus/icons-vue'
|
||||
import request from '@/api/request'
|
||||
|
||||
const API = '/api/revenue/cs/kb'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const viewMode = ref('list')
|
||||
const showEditor = ref(false)
|
||||
const showDetail = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
const tableData = ref<any[]>([])
|
||||
const categories = ref<any[]>([])
|
||||
const detailArticle = ref<any>(null)
|
||||
|
||||
const filterForm = reactive({ keyword: '', category: '', status: undefined as number | undefined })
|
||||
const pagination = reactive({ page: 1, size: 10, total: 0 })
|
||||
|
||||
const form = reactive({
|
||||
title: '', content: '', summary: '', category: 'FAQ', tags: '', status: 0, authorName: '当前用户'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
fetchCategories()
|
||||
})
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.get(`${API}/list`, {
|
||||
params: { page: pagination.page, size: pagination.size, ...filterForm }
|
||||
})
|
||||
tableData.value = res.data?.records || []
|
||||
pagination.total = res.data?.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const res = await request.get(`${API}/categories`)
|
||||
categories.value = res.data || []
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filterForm.keyword = ''
|
||||
filterForm.category = ''
|
||||
filterForm.status = undefined
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
isEdit.value = false
|
||||
editId.value = null
|
||||
Object.assign(form, { title: '', content: '', summary: '', category: 'FAQ', tags: '', status: 0 })
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(row: any) {
|
||||
isEdit.value = true
|
||||
editId.value = row.id
|
||||
Object.assign(form, { title: row.title, content: row.content, summary: row.summary,
|
||||
category: row.category, tags: row.tags, status: row.status, authorName: row.authorName })
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.title) { ElMessage.warning('请输入标题'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
if (isEdit.value && editId.value) {
|
||||
await request.put(`${API}/${editId.value}`, form)
|
||||
} else {
|
||||
await request.post(API, form)
|
||||
}
|
||||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
showEditor.value = false
|
||||
fetchData()
|
||||
fetchCategories()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function viewDetail(row: any) {
|
||||
try {
|
||||
const res = await request.get(`${API}/${row.id}`)
|
||||
detailArticle.value = res.data
|
||||
showDetail.value = true
|
||||
fetchData() // refresh view count
|
||||
} catch (e) {
|
||||
detailArticle.value = row
|
||||
showDetail.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm(`确定删除「${row.title}」?`, '确认删除', { type: 'warning' })
|
||||
await request.delete(`${API}/${row.id}`)
|
||||
ElMessage.success('删除成功')
|
||||
fetchData()
|
||||
}
|
||||
|
||||
async function handleLike() {
|
||||
if (!detailArticle.value) return
|
||||
await request.post(`${API}/${detailArticle.value.id}/like`)
|
||||
ElMessage.success('点赞成功')
|
||||
detailArticle.value.likeCount = (detailArticle.value.likeCount || 0) + 1
|
||||
}
|
||||
|
||||
function statusLabel(s: number) { return ['草稿', '已发布', '已归档'][s] || '未知' }
|
||||
function statusTag(s: number) { return ['info', 'success', 'warning'][s] as any || 'info' }
|
||||
function renderMarkdown(md: string) {
|
||||
// 简单 Markdown 渲染(生产环境可用 marked)
|
||||
return md.replace(/### (.*)/g, '<h3>$1</h3>')
|
||||
.replace(/## (.*)/g, '<h2>$1</h2>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/^- (.*)/gm, '<li>$1</li>')
|
||||
.replace(/\n/g, '<br/>')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-card { margin-bottom: 8px; }
|
||||
.article-card { margin-bottom: 16px; cursor: pointer; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.card-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
|
||||
.card-summary { color: #666; font-size: 13px; height: 40px; overflow: hidden; }
|
||||
.card-meta { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
|
||||
.meta-count { font-size: 12px; color: #999; }
|
||||
.detail-meta { display: flex; gap: 16px; align-items: center; color: #666; }
|
||||
.detail-content { line-height: 1.8; }
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div class="kpi-dashboard">
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="4" v-for="card in statCards" :key="card.key">
|
||||
<el-card shadow="hover" class="kpi-card" :class="'kpi-' + card.color">
|
||||
<div class="kpi-icon">{{ card.icon }}</div>
|
||||
<div class="kpi-value">{{ card.value }}</div>
|
||||
<div class="kpi-label">{{ card.label }}</div>
|
||||
<div class="kpi-sub" v-if="card.sub">{{ card.sub }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<el-col :span="14">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>📈 7日工单趋势</span>
|
||||
</template>
|
||||
<div ref="trendChartRef" style="height: 320px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="10">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>📊 工单类型分布</span>
|
||||
</template>
|
||||
<div ref="pieChartRef" style="height: 320px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 排行榜 -->
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>🏆 处理时效排行</span>
|
||||
</template>
|
||||
<el-table :data="efficiencyRank" size="small" :show-header="true">
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column prop="name" label="处理人/部门" />
|
||||
<el-table-column prop="completed_count" label="完成数" width="80" align="center" />
|
||||
<el-table-column prop="avg_hours" label="平均时效(h)" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.avg_hours < 4 ? 'success' : row.avg_hours < 8 ? '' : 'warning'" size="small">
|
||||
{{ Number(row.avg_hours).toFixed(1) }}h
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>📋 快捷操作</span>
|
||||
</template>
|
||||
<div class="quick-actions">
|
||||
<el-button @click="$router.push('/cs/knowledge')" size="large">
|
||||
📚 知识库管理
|
||||
</el-button>
|
||||
<el-button @click="$router.push('/cs/announcement')" size="large">
|
||||
📢 公告管理
|
||||
</el-button>
|
||||
<el-button @click="refreshDashboard" size="large" :loading="loading">
|
||||
🔄 刷新数据
|
||||
</el-button>
|
||||
</div>
|
||||
<el-divider />
|
||||
<div class="summary-info">
|
||||
<p>📅 数据更新时间: {{ lastUpdate }}</p>
|
||||
<p>📊 本月解决率: <el-progress :percentage="Number(kpiData.monthResolveRate || 0)" :stroke-width="16" /></p>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, shallowRef } from 'vue'
|
||||
import request from '@/api/request'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const API = '/api/revenue/cs/kpi/dashboard'
|
||||
|
||||
const loading = ref(false)
|
||||
const kpiData = reactive<any>({})
|
||||
const efficiencyRank = ref<any[]>([])
|
||||
const lastUpdate = ref('')
|
||||
|
||||
const trendChartRef = ref<HTMLElement>()
|
||||
const pieChartRef = ref<HTMLElement>()
|
||||
let trendChart: echarts.ECharts | null = null
|
||||
let pieChart: echarts.ECharts | null = null
|
||||
|
||||
const statCards = computed(() => [
|
||||
{ key: 'pending', icon: '⏳', label: '待处理工单', value: kpiData.pendingWorkOrders ?? '-', color: 'orange' },
|
||||
{ key: 'today_new', icon: '📥', label: '今日新增', value: kpiData.todayNewWorkOrders ?? '-', color: 'blue' },
|
||||
{ key: 'month_rate', icon: '✅', label: '本月解决率', value: (kpiData.monthResolveRate ?? 0) + '%', color: 'green',
|
||||
sub: `${kpiData.monthResolvedCount ?? 0}/${kpiData.monthTotalCount ?? 0}` },
|
||||
{ key: 'avg_hours', icon: '⏱️', label: '平均时效', value: (kpiData.avgProcessHours ?? 0) + 'h', color: 'purple' },
|
||||
{ key: 'satisfaction', icon: '😊', label: '满意率', value: (kpiData.satisfactionRate ?? 0) + '%', color: 'green' },
|
||||
{ key: 'complaints', icon: '📞', label: '今日投诉', value: kpiData.todayComplaints ?? '-', color: 'red' },
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
refreshDashboard()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
trendChart?.dispose()
|
||||
pieChart?.dispose()
|
||||
})
|
||||
|
||||
async function refreshDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.get(API)
|
||||
Object.assign(kpiData, res.data)
|
||||
efficiencyRank.value = res.data?.efficiencyRank || []
|
||||
lastUpdate.value = new Date().toLocaleString()
|
||||
await nextTick()
|
||||
renderTrendChart()
|
||||
renderPieChart()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function renderTrendChart() {
|
||||
if (!trendChartRef.value) return
|
||||
trendChart = trendChart || echarts.init(trendChartRef.value)
|
||||
const data = kpiData.weeklyTrend || []
|
||||
trendChart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: { type: 'category', data: data.map((d: any) => d.date?.slice(5) || '') },
|
||||
yAxis: { type: 'value', name: '工单数' },
|
||||
series: [{
|
||||
name: '工单数', type: 'line', smooth: true,
|
||||
data: data.map((d: any) => d.count || 0),
|
||||
areaStyle: { opacity: 0.15 },
|
||||
itemStyle: { color: '#409eff' }
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
function renderPieChart() {
|
||||
if (!pieChartRef.value) return
|
||||
pieChart = pieChart || echarts.init(pieChartRef.value)
|
||||
const data = kpiData.typeDistribution || []
|
||||
const nameMap: Record<string, string> = { pending: '待处理', in_progress: '处理中', completed: '已完成' }
|
||||
pieChart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { bottom: 0 },
|
||||
series: [{
|
||||
type: 'pie', radius: ['40%', '65%'],
|
||||
label: { show: true, formatter: '{b}\n{c}' },
|
||||
data: data.map((d: any) => ({ name: nameMap[d.type] || d.type, value: d.count }))
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
trendChart?.resize()
|
||||
pieChart?.resize()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kpi-card { text-align: center; padding: 8px 0; }
|
||||
.kpi-icon { font-size: 28px; }
|
||||
.kpi-value { font-size: 32px; font-weight: 700; margin: 4px 0; }
|
||||
.kpi-label { font-size: 13px; color: #666; }
|
||||
.kpi-sub { font-size: 12px; color: #999; margin-top: 2px; }
|
||||
.kpi-orange .kpi-value { color: #e6a23c; }
|
||||
.kpi-blue .kpi-value { color: #409eff; }
|
||||
.kpi-green .kpi-value { color: #67c23a; }
|
||||
.kpi-purple .kpi-value { color: #9b59b6; }
|
||||
.kpi-red .kpi-value { color: #f56c6c; }
|
||||
.quick-actions { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.summary-info { margin-top: 16px; }
|
||||
.summary-info p { margin: 8px 0; color: #666; }
|
||||
</style>
|
||||
@@ -12,5 +12,8 @@
|
||||
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
|
||||
<dependency><groupId>cn.dev33</groupId><artifactId>sa-token-spring-boot3-starter</artifactId></dependency>
|
||||
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId></dependency>
|
||||
<dependency><groupId>com.alibaba</groupId><artifactId>easyexcel</artifactId></dependency>
|
||||
<dependency><groupId>com.github.xiaoymin</groupId><artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
package com.water.production.controller;
|
||||
|
||||
import com.water.common.core.result.R;
|
||||
import com.water.production.dto.QualityQueryRequest;
|
||||
import com.water.production.dto.QualityStatVO;
|
||||
import com.water.production.entity.QualityStandard;
|
||||
import com.water.production.entity.QualityTestPlan;
|
||||
import com.water.production.entity.QualityTestRecord;
|
||||
import com.water.production.service.QualityLedgerService;
|
||||
import com.water.production.service.QualityStandardService;
|
||||
import com.water.production.service.QualityTestPlanService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 水质检测台账 Controller
|
||||
* 提供检测记录 CRUD、标准管理、检测计划、统计分析、数据导出等接口
|
||||
*/
|
||||
@Tag(name = "水质检测台账管理")
|
||||
@RestController
|
||||
@RequestMapping("/api/production/quality")
|
||||
@RequiredArgsConstructor
|
||||
public class QualityLedgerController {
|
||||
|
||||
private final QualityLedgerService ledgerService;
|
||||
private final QualityStandardService standardService;
|
||||
private final QualityTestPlanService planService;
|
||||
|
||||
// ==================== 检测记录 CRUD ====================
|
||||
|
||||
// 1. 分页查询台账
|
||||
@Operation(summary = "分页查询水质检测台账")
|
||||
@GetMapping("/records")
|
||||
public R<Map<String, Object>> listRecords(QualityQueryRequest request) {
|
||||
return R.ok(ledgerService.queryRecords(request));
|
||||
}
|
||||
|
||||
// 2. 获取记录详情
|
||||
@Operation(summary = "获取检测记录详情")
|
||||
@GetMapping("/records/{id}")
|
||||
public R<QualityTestRecord> getRecord(@PathVariable Long id) {
|
||||
QualityTestRecord record = ledgerService.getById(id);
|
||||
if (record == null) return R.fail(404, "记录不存在");
|
||||
return R.ok(record);
|
||||
}
|
||||
|
||||
// 3. 创建检测记录
|
||||
@Operation(summary = "创建水质检测记录 (自动合格判定)")
|
||||
@PostMapping("/records")
|
||||
public R<QualityTestRecord> createRecord(@RequestBody QualityTestRecord record) {
|
||||
return R.ok(ledgerService.create(record));
|
||||
}
|
||||
|
||||
// 4. 更新检测记录
|
||||
@Operation(summary = "更新检测记录 (重新合格判定)")
|
||||
@PutMapping("/records/{id}")
|
||||
public R<String> updateRecord(@PathVariable Long id, @RequestBody QualityTestRecord record) {
|
||||
record.setId(id);
|
||||
ledgerService.update(record);
|
||||
return R.ok("更新成功");
|
||||
}
|
||||
|
||||
// 5. 删除检测记录
|
||||
@Operation(summary = "删除检测记录")
|
||||
@DeleteMapping("/records/{id}")
|
||||
public R<String> deleteRecord(@PathVariable Long id) {
|
||||
ledgerService.delete(id);
|
||||
return R.ok("删除成功");
|
||||
}
|
||||
|
||||
// 6. 批量删除
|
||||
@Operation(summary = "批量删除检测记录")
|
||||
@DeleteMapping("/records/batch")
|
||||
public R<String> batchDeleteRecords(@RequestBody List<Long> ids) {
|
||||
ledgerService.batchDelete(ids);
|
||||
return R.ok("批量删除成功");
|
||||
}
|
||||
|
||||
// 7. 重新判定合格状态
|
||||
@Operation(summary = "重新判定所有记录合格状态")
|
||||
@PostMapping("/records/reevaluate")
|
||||
public R<Map<String, Object>> reevaluateRecords() {
|
||||
int count = ledgerService.reevaluateAll();
|
||||
return R.ok(Map.of("processed", count));
|
||||
}
|
||||
|
||||
// 8. 获取区域列表
|
||||
@Operation(summary = "获取所有检测区域")
|
||||
@GetMapping("/areas")
|
||||
public R<List<String>> getAreas() {
|
||||
return R.ok(ledgerService.getAreaList());
|
||||
}
|
||||
|
||||
// 9. 获取采样点列表
|
||||
@Operation(summary = "获取所有采样点")
|
||||
@GetMapping("/sampling-points")
|
||||
public R<List<String>> getSamplingPoints() {
|
||||
return R.ok(ledgerService.getSamplingPointList());
|
||||
}
|
||||
|
||||
// ==================== 水质标准管理 ====================
|
||||
|
||||
// 10. 获取所有启用的标准
|
||||
@Operation(summary = "获取启用中的水质标准列表")
|
||||
@GetMapping("/standards")
|
||||
public R<List<QualityStandard>> listStandards() {
|
||||
return R.ok(standardService.listEnabled());
|
||||
}
|
||||
|
||||
// 11. 获取全部标准 (含停用)
|
||||
@Operation(summary = "获取所有水质标准 (含停用)")
|
||||
@GetMapping("/standards/all")
|
||||
public R<List<QualityStandard>> listAllStandards() {
|
||||
return R.ok(standardService.listAll());
|
||||
}
|
||||
|
||||
// 12. 按水样类型获取标准
|
||||
@Operation(summary = "按水样类型获取水质标准")
|
||||
@GetMapping("/standards/water-type/{waterType}")
|
||||
public R<List<QualityStandard>> listStandardsByWaterType(@PathVariable String waterType) {
|
||||
return R.ok(standardService.listByWaterType(waterType));
|
||||
}
|
||||
|
||||
// 13. 获取标准详情
|
||||
@Operation(summary = "获取水质标准详情")
|
||||
@GetMapping("/standards/{id}")
|
||||
public R<QualityStandard> getStandard(@PathVariable Long id) {
|
||||
QualityStandard standard = standardService.getById(id);
|
||||
if (standard == null) return R.fail(404, "标准不存在");
|
||||
return R.ok(standard);
|
||||
}
|
||||
|
||||
// 14. 创建标准
|
||||
@Operation(summary = "创建水质标准")
|
||||
@PostMapping("/standards")
|
||||
public R<QualityStandard> createStandard(@RequestBody QualityStandard standard) {
|
||||
return R.ok(standardService.create(standard));
|
||||
}
|
||||
|
||||
// 15. 更新标准
|
||||
@Operation(summary = "更新水质标准")
|
||||
@PutMapping("/standards/{id}")
|
||||
public R<String> updateStandard(@PathVariable Long id, @RequestBody QualityStandard standard) {
|
||||
standard.setId(id);
|
||||
standardService.update(standard);
|
||||
return R.ok("更新成功");
|
||||
}
|
||||
|
||||
// 16. 删除标准
|
||||
@Operation(summary = "删除水质标准")
|
||||
@DeleteMapping("/standards/{id}")
|
||||
public R<String> deleteStandard(@PathVariable Long id) {
|
||||
standardService.delete(id);
|
||||
return R.ok("删除成功");
|
||||
}
|
||||
|
||||
// ==================== 检测计划管理 ====================
|
||||
|
||||
// 17. 分页查询检测计划
|
||||
@Operation(summary = "分页查询检测计划")
|
||||
@GetMapping("/plans")
|
||||
public R<Map<String, Object>> listPlans(
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) String frequency,
|
||||
@RequestParam(required = false) String waterType,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "20") int pageSize) {
|
||||
return R.ok(planService.queryPlans(status, frequency, waterType, keyword, pageNum, pageSize));
|
||||
}
|
||||
|
||||
// 18. 获取计划详情
|
||||
@Operation(summary = "获取检测计划详情")
|
||||
@GetMapping("/plans/{id}")
|
||||
public R<QualityTestPlan> getPlan(@PathVariable Long id) {
|
||||
QualityTestPlan plan = planService.getById(id);
|
||||
if (plan == null) return R.fail(404, "计划不存在");
|
||||
return R.ok(plan);
|
||||
}
|
||||
|
||||
// 19. 创建检测计划
|
||||
@Operation(summary = "创建检测计划")
|
||||
@PostMapping("/plans")
|
||||
public R<QualityTestPlan> createPlan(@RequestBody QualityTestPlan plan) {
|
||||
return R.ok(planService.create(plan));
|
||||
}
|
||||
|
||||
// 20. 更新检测计划
|
||||
@Operation(summary = "更新检测计划")
|
||||
@PutMapping("/plans/{id}")
|
||||
public R<String> updatePlan(@PathVariable Long id, @RequestBody QualityTestPlan plan) {
|
||||
plan.setId(id);
|
||||
planService.update(plan);
|
||||
return R.ok("更新成功");
|
||||
}
|
||||
|
||||
// 21. 删除检测计划
|
||||
@Operation(summary = "删除检测计划")
|
||||
@DeleteMapping("/plans/{id}")
|
||||
public R<String> deletePlan(@PathVariable Long id) {
|
||||
planService.delete(id);
|
||||
return R.ok("删除成功");
|
||||
}
|
||||
|
||||
// 22. 暂停/恢复计划
|
||||
@Operation(summary = "切换检测计划状态 (active/paused/completed)")
|
||||
@PutMapping("/plans/{id}/status")
|
||||
public R<String> togglePlanStatus(@PathVariable Long id, @RequestParam String status) {
|
||||
planService.toggleStatus(id, status);
|
||||
return R.ok("状态已更新");
|
||||
}
|
||||
|
||||
// 23. 获取到期计划
|
||||
@Operation(summary = "获取当前到期的检测计划")
|
||||
@GetMapping("/plans/due")
|
||||
public R<List<QualityTestPlan>> getDuePlans() {
|
||||
return R.ok(planService.getDuePlans());
|
||||
}
|
||||
|
||||
// 24. 标记计划已执行
|
||||
@Operation(summary = "标记检测计划已执行 (更新下次检测日期)")
|
||||
@PostMapping("/plans/{id}/execute")
|
||||
public R<String> markPlanExecuted(@PathVariable Long id) {
|
||||
planService.markExecuted(id);
|
||||
return R.ok("已标记执行");
|
||||
}
|
||||
|
||||
// ==================== 统计分析 ====================
|
||||
|
||||
// 25. 综合统计
|
||||
@Operation(summary = "水质检测统计分析 (合格率/趋势/指标分布)")
|
||||
@GetMapping("/statistics")
|
||||
public R<QualityStatVO> getStatistics(
|
||||
@RequestParam(required = false) String startDate,
|
||||
@RequestParam(required = false) String endDate) {
|
||||
return R.ok(ledgerService.getStatistics(startDate, endDate));
|
||||
}
|
||||
|
||||
// ==================== 数据导出 ====================
|
||||
|
||||
// 26. 导出 Excel
|
||||
@Operation(summary = "导出质检台账 Excel")
|
||||
@PostMapping("/export/excel")
|
||||
public ResponseEntity<byte[]> exportExcel(@RequestBody QualityQueryRequest request) {
|
||||
byte[] data = ledgerService.exportExcel(request);
|
||||
String filename = URLEncoder.encode("水质检测台账.xlsx", StandardCharsets.UTF_8);
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename)
|
||||
.contentType(MediaType.parseMediaType(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
|
||||
.body(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.water.production.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 水质检测台账查询请求
|
||||
*/
|
||||
@Data
|
||||
public class QualityQueryRequest {
|
||||
|
||||
/** 检测类型: routine/special/complaint */
|
||||
private String testType;
|
||||
|
||||
/** 水样类型: raw/treated/network */
|
||||
private String waterType;
|
||||
|
||||
/** 所属区域 */
|
||||
private String area;
|
||||
|
||||
/** 采样点 (模糊搜索) */
|
||||
private String samplingPoint;
|
||||
|
||||
/** 检测人 (模糊搜索) */
|
||||
private String tester;
|
||||
|
||||
/** 合格状态: qualified/unqualified/pending */
|
||||
private String complianceStatus;
|
||||
|
||||
/** 开始日期 (yyyy-MM-dd) */
|
||||
private String startDate;
|
||||
|
||||
/** 结束日期 (yyyy-MM-dd) */
|
||||
private String endDate;
|
||||
|
||||
/** 关键词搜索 */
|
||||
private String keyword;
|
||||
|
||||
/** 排序字段 */
|
||||
private String sortField;
|
||||
|
||||
/** 排序方向: asc/desc */
|
||||
private String sortOrder;
|
||||
|
||||
/** 页码 (默认1) */
|
||||
private Integer pageNum = 1;
|
||||
|
||||
/** 每页条数 (默认20) */
|
||||
private Integer pageSize = 20;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.water.production.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 水质检测统计 VO
|
||||
*/
|
||||
@Data
|
||||
public class QualityStatVO {
|
||||
|
||||
/** 总检测记录数 */
|
||||
private Long totalCount;
|
||||
|
||||
/** 合格数 */
|
||||
private Long qualifiedCount;
|
||||
|
||||
/** 不合格数 */
|
||||
private Long unqualifiedCount;
|
||||
|
||||
/** 待判定数 */
|
||||
private Long pendingCount;
|
||||
|
||||
/** 综合合格率 (%) */
|
||||
private BigDecimal qualifiedRate;
|
||||
|
||||
/** 各水样类型合格率 */
|
||||
private Map<String, BigDecimal> rateByWaterType;
|
||||
|
||||
/** 各区域合格率 */
|
||||
private Map<String, BigDecimal> rateByArea;
|
||||
|
||||
/** 各指标不合格次数 */
|
||||
private Map<String, Long> unqualifiedByParam;
|
||||
|
||||
/** 月度合格率趋势 [{month, rate}] */
|
||||
private List<Map<String, Object>> monthlyTrend;
|
||||
|
||||
/** 各指标均值统计 */
|
||||
private Map<String, Map<String, Object>> paramAvgStats;
|
||||
}
|
||||
@@ -2,8 +2,13 @@ package com.water.production.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* AI人员闯入检测事件实体
|
||||
*/
|
||||
@Data
|
||||
@TableName("prod_intrusion_event")
|
||||
public class IntrusionEvent {
|
||||
@@ -11,47 +16,60 @@ public class IntrusionEvent {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private String eventNo;
|
||||
|
||||
/** 关联摄像头ID */
|
||||
private String cameraId;
|
||||
private Long cameraId;
|
||||
|
||||
/** 摄像头名称 */
|
||||
private String cameraName;
|
||||
|
||||
/** 所属区域 */
|
||||
private String area;
|
||||
|
||||
/** 检测时间 */
|
||||
private LocalDateTime detectedTime;
|
||||
/** 事件类型: person_intrusion=人员闯入, person_loitering=人员徘徊, zone_breach=区域越界 */
|
||||
private String eventType;
|
||||
|
||||
/** AI识别置信度(0~1) */
|
||||
private Double confidence;
|
||||
|
||||
/** 是否检测到闯入 */
|
||||
private Boolean detected;
|
||||
|
||||
/** 事件状态: ACTIVE/CONFIRMED/DISMISSED/RESOLVED/FALSE_POSITIVE */
|
||||
private String status;
|
||||
|
||||
/** 报警等级: 一般/重要/紧急 */
|
||||
private String alertLevel;
|
||||
|
||||
/** 是否触发报警 */
|
||||
private Boolean alertTriggered;
|
||||
private BigDecimal confidence;
|
||||
|
||||
/** 抓拍图片URL */
|
||||
private String snapshotUrl;
|
||||
|
||||
/** 描述 */
|
||||
private String description;
|
||||
/** 关联视频片段URL */
|
||||
private String videoClipUrl;
|
||||
|
||||
/** 确认人 */
|
||||
private String confirmedBy;
|
||||
/** 报警等级: info, warning, critical */
|
||||
private String alertLevel;
|
||||
|
||||
/** 确认时间 */
|
||||
private LocalDateTime confirmedTime;
|
||||
/** 报警状态: 0=待处理, 1=已确认, 2=已处理, 3=已忽略 */
|
||||
private Integer alertStatus;
|
||||
|
||||
/** 解决时间 */
|
||||
private LocalDateTime resolvedTime;
|
||||
/** 检测时间 */
|
||||
private LocalDateTime detectedAt;
|
||||
|
||||
/** 处理结果说明 */
|
||||
private String handleResult;
|
||||
|
||||
/** 处理人ID */
|
||||
private Long handledBy;
|
||||
|
||||
/** 处理人姓名 */
|
||||
private String handlerName;
|
||||
|
||||
/** 处理时间 */
|
||||
private LocalDateTime handledTime;
|
||||
|
||||
/** 关联报警记录ID */
|
||||
private Long alertRecordId;
|
||||
|
||||
/** 备注 */
|
||||
private String remark;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createdTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updatedTime;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.water.production.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 水质标准实体 (基于 GB5749-2022)
|
||||
*/
|
||||
@Data
|
||||
@TableName("prod_quality_standard")
|
||||
public class QualityStandard {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 标准名称 */
|
||||
private String standardName;
|
||||
|
||||
/** 标准编码 (如 GB5749-2022) */
|
||||
private String standardCode;
|
||||
|
||||
/** 参数名称: turbidity/ph/residual_chlorine/color/odor/ecoli/colony_count */
|
||||
private String paramName;
|
||||
|
||||
/** 参数显示名称 */
|
||||
private String paramLabel;
|
||||
|
||||
/** 参数单位 */
|
||||
private String paramUnit;
|
||||
|
||||
/** 最小值 (null 表示无下限) */
|
||||
private BigDecimal minValue;
|
||||
|
||||
/** 最大值 (null 表示无上限) */
|
||||
private BigDecimal maxValue;
|
||||
|
||||
/** 适用水样类型: raw/treated/network/all */
|
||||
private String waterType;
|
||||
|
||||
/** 是否启用 */
|
||||
private Integer enabled;
|
||||
|
||||
/** 逻辑删除 */
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
|
||||
/** 创建时间 */
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 更新时间 */
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.water.production.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 水质检测计划实体
|
||||
*/
|
||||
@Data
|
||||
@TableName("prod_quality_test_plan")
|
||||
public class QualityTestPlan {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 计划名称 */
|
||||
private String planName;
|
||||
|
||||
/** 检测类型: routine/special */
|
||||
private String testType;
|
||||
|
||||
/** 水样类型: raw/treated/network */
|
||||
private String waterType;
|
||||
|
||||
/** 采样点 */
|
||||
private String samplingPoint;
|
||||
|
||||
/** 所属区域 */
|
||||
private String area;
|
||||
|
||||
/** 检测频率: daily/weekly/monthly */
|
||||
private String frequency;
|
||||
|
||||
/** 检测参数 (逗号分隔: turbidity,ph,residual_chlorine) */
|
||||
private String testParams;
|
||||
|
||||
/** 计划开始日期 */
|
||||
private LocalDate startDate;
|
||||
|
||||
/** 计划结束日期 (null=长期) */
|
||||
private LocalDate endDate;
|
||||
|
||||
/** 下次检测日期 */
|
||||
private LocalDate nextTestDate;
|
||||
|
||||
/** 计划状态: active/paused/completed */
|
||||
private String status;
|
||||
|
||||
/** 执行次数 */
|
||||
private Integer executionCount;
|
||||
|
||||
/** 备注 */
|
||||
private String remark;
|
||||
|
||||
/** 逻辑删除 */
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
|
||||
/** 创建时间 */
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 更新时间 */
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.water.production.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
|
||||
@Data
|
||||
@TableName("prod_quality_test_record")
|
||||
public class QualityTestRecord {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private String testType; // routine/special/complaint
|
||||
private String waterType; // raw/treated/network
|
||||
private String samplingPoint;
|
||||
private String area;
|
||||
private LocalDate testDate;
|
||||
private LocalTime testTime;
|
||||
private String tester;
|
||||
private BigDecimal turbidity;
|
||||
private BigDecimal ph;
|
||||
private BigDecimal residualChlorine;
|
||||
private BigDecimal color;
|
||||
private BigDecimal odor;
|
||||
private BigDecimal ecoli;
|
||||
private BigDecimal colonyCount;
|
||||
private String complianceStatus; // qualified/unqualified/pending
|
||||
private String unqualifiedItems;
|
||||
private String remark;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
|
||||
@TableField("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField("updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.water.production.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.water.production.entity.QualityStandard;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface QualityStandardMapper extends BaseMapper<QualityStandard> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.water.production.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.water.production.entity.QualityTestPlan;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface QualityTestPlanMapper extends BaseMapper<QualityTestPlan> {
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.water.production.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.water.production.entity.QualityTestRecord;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Mapper
|
||||
public interface QualityTestRecordMapper extends BaseMapper<QualityTestRecord> {
|
||||
|
||||
/** 分页查询台账 (支持多维度筛选) */
|
||||
List<Map<String, Object>> selectRecordPage(@Param("testType") String testType,
|
||||
@Param("waterType") String waterType,
|
||||
@Param("area") String area,
|
||||
@Param("samplingPoint") String samplingPoint,
|
||||
@Param("tester") String tester,
|
||||
@Param("complianceStatus") String complianceStatus,
|
||||
@Param("startDate") String startDate,
|
||||
@Param("endDate") String endDate,
|
||||
@Param("keyword") String keyword,
|
||||
@Param("sortField") String sortField,
|
||||
@Param("sortOrder") String sortOrder,
|
||||
@Param("offset") int offset,
|
||||
@Param("limit") int limit);
|
||||
|
||||
/** 统计符合条件的总数 */
|
||||
Long countRecords(@Param("testType") String testType,
|
||||
@Param("waterType") String waterType,
|
||||
@Param("area") String area,
|
||||
@Param("samplingPoint") String samplingPoint,
|
||||
@Param("tester") String tester,
|
||||
@Param("complianceStatus") String complianceStatus,
|
||||
@Param("startDate") String startDate,
|
||||
@Param("endDate") String endDate,
|
||||
@Param("keyword") String keyword);
|
||||
|
||||
/** 按合格状态统计数量 */
|
||||
List<Map<String, Object>> statByComplianceStatus(@Param("startDate") String startDate,
|
||||
@Param("endDate") String endDate);
|
||||
|
||||
/** 按水样类型统计合格率 */
|
||||
List<Map<String, Object>> statRateByWaterType(@Param("startDate") String startDate,
|
||||
@Param("endDate") String endDate);
|
||||
|
||||
/** 按区域统计合格率 */
|
||||
List<Map<String, Object>> statRateByArea(@Param("startDate") String startDate,
|
||||
@Param("endDate") String endDate);
|
||||
|
||||
/** 按指标统计不合格次数 */
|
||||
List<Map<String, Object>> statUnqualifiedByParam(@Param("startDate") String startDate,
|
||||
@Param("endDate") String endDate);
|
||||
|
||||
/** 月度合格率趋势 */
|
||||
List<Map<String, Object>> statMonthlyTrend(@Param("startDate") String startDate,
|
||||
@Param("endDate") String endDate);
|
||||
|
||||
/** 各指标均值统计 */
|
||||
List<Map<String, Object>> statParamAvg();
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package com.water.production.service;
|
||||
|
||||
import com.alibaba.excel.EasyExcel;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.water.production.dto.QualityQueryRequest;
|
||||
import com.water.production.dto.QualityStatVO;
|
||||
import com.water.production.entity.QualityStandard;
|
||||
import com.water.production.entity.QualityTestRecord;
|
||||
import com.water.production.mapper.QualityTestRecordMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 水质检测台账管理服务
|
||||
* 包含:CRUD、合格判定、多维度查询、统计分析、数据导出
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class QualityLedgerService {
|
||||
|
||||
private final QualityTestRecordMapper recordMapper;
|
||||
private final QualityStandardService standardService;
|
||||
|
||||
// ========== CRUD ==========
|
||||
|
||||
/**
|
||||
* 分页查询台账
|
||||
*/
|
||||
public Map<String, Object> queryRecords(QualityQueryRequest request) {
|
||||
int offset = (request.getPageNum() - 1) * request.getPageSize();
|
||||
List<Map<String, Object>> records = recordMapper.selectRecordPage(
|
||||
request.getTestType(), request.getWaterType(), request.getArea(),
|
||||
request.getSamplingPoint(), request.getTester(), request.getComplianceStatus(),
|
||||
request.getStartDate(), request.getEndDate(), request.getKeyword(),
|
||||
request.getSortField(), request.getSortOrder(),
|
||||
offset, request.getPageSize()
|
||||
);
|
||||
Long total = recordMapper.countRecords(
|
||||
request.getTestType(), request.getWaterType(), request.getArea(),
|
||||
request.getSamplingPoint(), request.getTester(), request.getComplianceStatus(),
|
||||
request.getStartDate(), request.getEndDate(), request.getKeyword()
|
||||
);
|
||||
|
||||
int pages = (int) Math.ceil((double) total / request.getPageSize());
|
||||
return Map.of(
|
||||
"records", records,
|
||||
"total", total,
|
||||
"pageNum", request.getPageNum(),
|
||||
"pageSize", request.getPageSize(),
|
||||
"pages", pages
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取记录详情
|
||||
*/
|
||||
public QualityTestRecord getById(Long id) {
|
||||
return recordMapper.selectById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建检测记录 (含自动合格判定)
|
||||
*/
|
||||
public QualityTestRecord create(QualityTestRecord record) {
|
||||
record.setDeleted(0);
|
||||
// 自动合格判定
|
||||
evaluateCompliance(record);
|
||||
recordMapper.insert(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新检测记录 (重新判定)
|
||||
*/
|
||||
public void update(QualityTestRecord record) {
|
||||
evaluateCompliance(record);
|
||||
recordMapper.updateById(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除检测记录 (逻辑删除)
|
||||
*/
|
||||
public void delete(Long id) {
|
||||
recordMapper.deleteById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除
|
||||
*/
|
||||
public void batchDelete(List<Long> ids) {
|
||||
recordMapper.deleteBatchIds(ids);
|
||||
}
|
||||
|
||||
// ========== 合格判定 ==========
|
||||
|
||||
/**
|
||||
* 根据 GB5749-2022 自动判定水质是否合格
|
||||
* 比对所有检测参数与标准值,任何一项超标即为不合格
|
||||
*/
|
||||
public void evaluateCompliance(QualityTestRecord record) {
|
||||
String waterType = record.getWaterType();
|
||||
if (waterType == null) waterType = "treated";
|
||||
|
||||
List<String> unqualifiedItems = new ArrayList<>();
|
||||
|
||||
checkParam("turbidity", "浊度", record.getTurbidity(), waterType, unqualifiedItems);
|
||||
checkParam("ph", "pH", record.getPh(), waterType, unqualifiedItems);
|
||||
checkParam("residual_chlorine", "余氯", record.getResidualChlorine(), waterType, unqualifiedItems);
|
||||
checkParam("color", "色度", record.getColor(), waterType, unqualifiedItems);
|
||||
checkParam("odor", "嗅味", record.getOdor(), waterType, unqualifiedItems);
|
||||
checkParam("ecoli", "大肠杆菌", record.getEcoli(), waterType, unqualifiedItems);
|
||||
checkParam("colony_count", "菌落总数", record.getColonyCount(), waterType, unqualifiedItems);
|
||||
|
||||
if (unqualifiedItems.isEmpty()) {
|
||||
record.setComplianceStatus("qualified");
|
||||
record.setUnqualifiedItems(null);
|
||||
} else {
|
||||
record.setComplianceStatus("unqualified");
|
||||
// 构建不合格项JSON
|
||||
record.setUnqualifiedItems(buildUnqualifiedJson(unqualifiedItems));
|
||||
}
|
||||
}
|
||||
|
||||
private void checkParam(String paramName, String paramLabel, BigDecimal value,
|
||||
String waterType, List<String> unqualifiedItems) {
|
||||
if (value == null) return;
|
||||
|
||||
QualityStandard standard = standardService.getStandard(waterType, paramName);
|
||||
if (standard == null) return; // 无标准则不判定
|
||||
|
||||
boolean isUnqualified = false;
|
||||
if (standard.getMinValue() != null && value.compareTo(standard.getMinValue()) < 0) {
|
||||
isUnqualified = true;
|
||||
}
|
||||
if (standard.getMaxValue() != null && value.compareTo(standard.getMaxValue()) > 0) {
|
||||
isUnqualified = true;
|
||||
}
|
||||
|
||||
if (isUnqualified) {
|
||||
String range = buildRangeDesc(standard);
|
||||
unqualifiedItems.add(String.format(
|
||||
"{\"param\":\"%s\",\"label\":\"%s\",\"value\":%s,\"range\":\"%s\",\"unit\":\"%s\"}",
|
||||
paramName, paramLabel, value.toPlainString(), range,
|
||||
standard.getParamUnit() != null ? standard.getParamUnit() : ""
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private String buildRangeDesc(QualityStandard std) {
|
||||
if (std.getMinValue() != null && std.getMaxValue() != null) {
|
||||
return std.getMinValue().toPlainString() + "~" + std.getMaxValue().toPlainString();
|
||||
} else if (std.getMinValue() != null) {
|
||||
return "≥" + std.getMinValue().toPlainString();
|
||||
} else if (std.getMaxValue() != null) {
|
||||
return "≤" + std.getMaxValue().toPlainString();
|
||||
}
|
||||
return "无限制";
|
||||
}
|
||||
|
||||
private String buildUnqualifiedJson(List<String> items) {
|
||||
return "[" + String.join(",", items) + "]";
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动重新判定所有记录
|
||||
*/
|
||||
public int reevaluateAll() {
|
||||
List<QualityTestRecord> records = recordMapper.selectList(
|
||||
new LambdaQueryWrapper<QualityTestRecord>().eq(QualityTestRecord::getDeleted, 0)
|
||||
);
|
||||
int count = 0;
|
||||
for (QualityTestRecord record : records) {
|
||||
evaluateCompliance(record);
|
||||
recordMapper.updateById(record);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ========== 统计分析 ==========
|
||||
|
||||
/**
|
||||
* 综合统计
|
||||
*/
|
||||
public QualityStatVO getStatistics(String startDate, String endDate) {
|
||||
QualityStatVO stat = new QualityStatVO();
|
||||
|
||||
// 按合格状态统计
|
||||
List<Map<String, Object>> statusStats = recordMapper.statByComplianceStatus(startDate, endDate);
|
||||
long total = 0, qualified = 0, unqualified = 0, pending = 0;
|
||||
for (Map<String, Object> row : statusStats) {
|
||||
long count = ((Number) row.get("count")).longValue();
|
||||
total += count;
|
||||
String status = (String) row.get("status");
|
||||
if ("qualified".equals(status)) qualified = count;
|
||||
else if ("unqualified".equals(status)) unqualified = count;
|
||||
else if ("pending".equals(status)) pending = count;
|
||||
}
|
||||
stat.setTotalCount(total);
|
||||
stat.setQualifiedCount(qualified);
|
||||
stat.setUnqualifiedCount(unqualified);
|
||||
stat.setPendingCount(pending);
|
||||
stat.setQualifiedRate(total > 0
|
||||
? BigDecimal.valueOf(qualified * 100.0 / total).setScale(1, RoundingMode.HALF_UP)
|
||||
: BigDecimal.ZERO);
|
||||
|
||||
// 按水样类型合格率
|
||||
List<Map<String, Object>> waterTypeStats = recordMapper.statRateByWaterType(startDate, endDate);
|
||||
Map<String, BigDecimal> rateByWaterType = new LinkedHashMap<>();
|
||||
for (Map<String, Object> row : waterTypeStats) {
|
||||
String wt = (String) row.get("waterType");
|
||||
long t = ((Number) row.get("total")).longValue();
|
||||
long q = ((Number) row.get("qualified")).longValue();
|
||||
rateByWaterType.put(wt, t > 0
|
||||
? BigDecimal.valueOf(q * 100.0 / t).setScale(1, RoundingMode.HALF_UP)
|
||||
: BigDecimal.ZERO);
|
||||
}
|
||||
stat.setRateByWaterType(rateByWaterType);
|
||||
|
||||
// 按区域合格率
|
||||
List<Map<String, Object>> areaStats = recordMapper.statRateByArea(startDate, endDate);
|
||||
Map<String, BigDecimal> rateByArea = new LinkedHashMap<>();
|
||||
for (Map<String, Object> row : areaStats) {
|
||||
String area = (String) row.get("area");
|
||||
long t = ((Number) row.get("total")).longValue();
|
||||
long q = ((Number) row.get("qualified")).longValue();
|
||||
rateByArea.put(area, t > 0
|
||||
? BigDecimal.valueOf(q * 100.0 / t).setScale(1, RoundingMode.HALF_UP)
|
||||
: BigDecimal.ZERO);
|
||||
}
|
||||
stat.setRateByArea(rateByArea);
|
||||
|
||||
// 不合格项统计
|
||||
List<Map<String, Object>> unqStats = recordMapper.statUnqualifiedByParam(startDate, endDate);
|
||||
Map<String, Long> unqByParam = new LinkedHashMap<>();
|
||||
for (Map<String, Object> row : unqStats) {
|
||||
unqByParam.put((String) row.get("paramName"), ((Number) row.get("count")).longValue());
|
||||
}
|
||||
stat.setUnqualifiedByParam(unqByParam);
|
||||
|
||||
// 月度趋势
|
||||
List<Map<String, Object>> trend = recordMapper.statMonthlyTrend(startDate, endDate);
|
||||
stat.setMonthlyTrend(trend);
|
||||
|
||||
// 各指标均值
|
||||
List<Map<String, Object>> avgStats = recordMapper.statParamAvg();
|
||||
if (!avgStats.isEmpty()) {
|
||||
stat.setParamAvgStats(avgStats.get(0).entrySet().stream()
|
||||
.collect(Collectors.toMap(Map.Entry::getKey,
|
||||
e -> Map.of("avg", e.getValue() != null ? e.getValue() : 0))));
|
||||
}
|
||||
|
||||
return stat;
|
||||
}
|
||||
|
||||
// ========== 数据导出 ==========
|
||||
|
||||
/**
|
||||
* 导出 Excel
|
||||
*/
|
||||
public byte[] exportExcel(QualityQueryRequest request) {
|
||||
// 获取全部数据 (最多10000条)
|
||||
int offset = 0;
|
||||
List<Map<String, Object>> records = recordMapper.selectRecordPage(
|
||||
request.getTestType(), request.getWaterType(), request.getArea(),
|
||||
request.getSamplingPoint(), request.getTester(), request.getComplianceStatus(),
|
||||
request.getStartDate(), request.getEndDate(), request.getKeyword(),
|
||||
"testDate", "desc", offset, 10000
|
||||
);
|
||||
|
||||
if (records.isEmpty()) return new byte[0];
|
||||
|
||||
List<List<String>> head = buildExportHead();
|
||||
List<List<Object>> data = buildExportData(records);
|
||||
|
||||
try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
|
||||
EasyExcel.write(bos)
|
||||
.sheet("水质检测台账")
|
||||
.head(head)
|
||||
.doWrite(data);
|
||||
return bos.toByteArray();
|
||||
} catch (IOException e) {
|
||||
log.error("Excel 导出失败", e);
|
||||
throw new RuntimeException("Excel 导出失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private List<List<String>> buildExportHead() {
|
||||
List<List<String>> head = new ArrayList<>();
|
||||
head.add(List.of("检测日期"));
|
||||
head.add(List.of("检测类型"));
|
||||
head.add(List.of("水样类型"));
|
||||
head.add(List.of("采样点"));
|
||||
head.add(List.of("区域"));
|
||||
head.add(List.of("检测人"));
|
||||
head.add(List.of("浊度(NTU)"));
|
||||
head.add(List.of("pH"));
|
||||
head.add(List.of("余氯(mg/L)"));
|
||||
head.add(List.of("色度(度)"));
|
||||
head.add(List.of("嗅味(级)"));
|
||||
head.add(List.of("大肠杆菌(CFU/100mL)"));
|
||||
head.add(List.of("菌落总数(CFU/mL)"));
|
||||
head.add(List.of("合格状态"));
|
||||
head.add(List.of("备注"));
|
||||
return head;
|
||||
}
|
||||
|
||||
private List<List<Object>> buildExportData(List<Map<String, Object>> records) {
|
||||
List<List<Object>> data = new ArrayList<>();
|
||||
for (Map<String, Object> r : records) {
|
||||
List<Object> row = new ArrayList<>();
|
||||
row.add(r.get("testDate"));
|
||||
row.add(formatTestType((String) r.get("testType")));
|
||||
row.add(formatWaterType((String) r.get("waterType")));
|
||||
row.add(r.get("samplingPoint"));
|
||||
row.add(r.get("area"));
|
||||
row.add(r.get("tester"));
|
||||
row.add(r.get("turbidity"));
|
||||
row.add(r.get("ph"));
|
||||
row.add(r.get("residualChlorine"));
|
||||
row.add(r.get("color"));
|
||||
row.add(r.get("odor"));
|
||||
row.add(r.get("ecoli"));
|
||||
row.add(r.get("colonyCount"));
|
||||
row.add(formatCompliance((String) r.get("complianceStatus")));
|
||||
row.add(r.get("remark"));
|
||||
data.add(row);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private String formatTestType(String type) {
|
||||
if (type == null) return "";
|
||||
return switch (type) {
|
||||
case "routine" -> "常规检测";
|
||||
case "special" -> "专项检测";
|
||||
case "complaint" -> "投诉检测";
|
||||
default -> type;
|
||||
};
|
||||
}
|
||||
|
||||
private String formatWaterType(String type) {
|
||||
if (type == null) return "";
|
||||
return switch (type) {
|
||||
case "raw" -> "原水";
|
||||
case "treated" -> "出厂水";
|
||||
case "network" -> "管网末梢水";
|
||||
default -> type;
|
||||
};
|
||||
}
|
||||
|
||||
private String formatCompliance(String status) {
|
||||
if (status == null) return "待判定";
|
||||
return switch (status) {
|
||||
case "qualified" -> "合格";
|
||||
case "unqualified" -> "不合格";
|
||||
case "pending" -> "待判定";
|
||||
default -> status;
|
||||
};
|
||||
}
|
||||
|
||||
// ========== 辅助查询 ==========
|
||||
|
||||
/**
|
||||
* 获取所有区域列表
|
||||
*/
|
||||
public List<String> getAreaList() {
|
||||
List<QualityTestRecord> records = recordMapper.selectList(
|
||||
new LambdaQueryWrapper<QualityTestRecord>()
|
||||
.select(QualityTestRecord::getArea)
|
||||
.isNotNull(QualityTestRecord::getArea)
|
||||
.groupBy(QualityTestRecord::getArea)
|
||||
);
|
||||
return records.stream()
|
||||
.map(QualityTestRecord::getArea)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有采样点列表
|
||||
*/
|
||||
public List<String> getSamplingPointList() {
|
||||
List<QualityTestRecord> records = recordMapper.selectList(
|
||||
new LambdaQueryWrapper<QualityTestRecord>()
|
||||
.select(QualityTestRecord::getSamplingPoint)
|
||||
.isNotNull(QualityTestRecord::getSamplingPoint)
|
||||
.groupBy(QualityTestRecord::getSamplingPoint)
|
||||
);
|
||||
return records.stream()
|
||||
.map(QualityTestRecord::getSamplingPoint)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 按ID批量查询记录
|
||||
*/
|
||||
public List<QualityTestRecord> listByIds(List<Long> ids) {
|
||||
return recordMapper.selectBatchIds(ids);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.water.production.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.water.production.entity.QualityStandard;
|
||||
import com.water.production.mapper.QualityStandardMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 水质标准管理服务
|
||||
* 基于 GB5749-2022《生活饮用水卫生标准》
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class QualityStandardService {
|
||||
|
||||
private final QualityStandardMapper standardMapper;
|
||||
|
||||
/**
|
||||
* 获取所有启用的标准
|
||||
*/
|
||||
public List<QualityStandard> listEnabled() {
|
||||
return standardMapper.selectList(
|
||||
new LambdaQueryWrapper<QualityStandard>()
|
||||
.eq(QualityStandard::getEnabled, 1)
|
||||
.orderByAsc(QualityStandard::getParamName)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按水样类型获取启用的标准
|
||||
*/
|
||||
public List<QualityStandard> listByWaterType(String waterType) {
|
||||
return standardMapper.selectList(
|
||||
new LambdaQueryWrapper<QualityStandard>()
|
||||
.eq(QualityStandard::getEnabled, 1)
|
||||
.and(w -> w.eq(QualityStandard::getWaterType, waterType)
|
||||
.or().eq(QualityStandard::getWaterType, "all"))
|
||||
.orderByAsc(QualityStandard::getParamName)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取标准详情
|
||||
*/
|
||||
public QualityStandard getById(Long id) {
|
||||
return standardMapper.selectById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增标准
|
||||
*/
|
||||
public QualityStandard create(QualityStandard standard) {
|
||||
standard.setEnabled(1);
|
||||
standard.setDeleted(0);
|
||||
standardMapper.insert(standard);
|
||||
return standard;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新标准
|
||||
*/
|
||||
public void update(QualityStandard standard) {
|
||||
standardMapper.updateById(standard);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除标准 (逻辑删除)
|
||||
*/
|
||||
public void delete(Long id) {
|
||||
standardMapper.deleteById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有标准 (含停用)
|
||||
*/
|
||||
public List<QualityStandard> listAll() {
|
||||
return standardMapper.selectList(
|
||||
new LambdaQueryWrapper<QualityStandard>()
|
||||
.orderByAsc(QualityStandard::getStandardCode)
|
||||
.orderByAsc(QualityStandard::getParamName)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据水样类型和参数名获取标准
|
||||
*/
|
||||
public QualityStandard getStandard(String waterType, String paramName) {
|
||||
return standardMapper.selectOne(
|
||||
new LambdaQueryWrapper<QualityStandard>()
|
||||
.eq(QualityStandard::getEnabled, 1)
|
||||
.eq(QualityStandard::getParamName, paramName)
|
||||
.and(w -> w.eq(QualityStandard::getWaterType, waterType)
|
||||
.or().eq(QualityStandard::getWaterType, "all"))
|
||||
.last("LIMIT 1")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.water.production.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.water.production.entity.QualityTestPlan;
|
||||
import com.water.production.mapper.QualityTestPlanMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 水质检测计划管理服务
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class QualityTestPlanService {
|
||||
|
||||
private final QualityTestPlanMapper planMapper;
|
||||
|
||||
/**
|
||||
* 分页查询检测计划
|
||||
*/
|
||||
public Map<String, Object> queryPlans(String status, String frequency, String waterType,
|
||||
String keyword, int pageNum, int pageSize) {
|
||||
LambdaQueryWrapper<QualityTestPlan> wrapper = new LambdaQueryWrapper<>();
|
||||
if (status != null && !status.isEmpty()) {
|
||||
wrapper.eq(QualityTestPlan::getStatus, status);
|
||||
}
|
||||
if (frequency != null && !frequency.isEmpty()) {
|
||||
wrapper.eq(QualityTestPlan::getFrequency, frequency);
|
||||
}
|
||||
if (waterType != null && !waterType.isEmpty()) {
|
||||
wrapper.eq(QualityTestPlan::getWaterType, waterType);
|
||||
}
|
||||
if (keyword != null && !keyword.isEmpty()) {
|
||||
wrapper.and(w -> w.like(QualityTestPlan::getPlanName, keyword)
|
||||
.or().like(QualityTestPlan::getSamplingPoint, keyword)
|
||||
.or().like(QualityTestPlan::getArea, keyword));
|
||||
}
|
||||
wrapper.orderByDesc(QualityTestPlan::getCreatedAt);
|
||||
|
||||
Page<QualityTestPlan> page = planMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
|
||||
|
||||
return Map.of(
|
||||
"records", page.getRecords(),
|
||||
"total", page.getTotal(),
|
||||
"pageNum", pageNum,
|
||||
"pageSize", pageSize,
|
||||
"pages", page.getPages()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取计划详情
|
||||
*/
|
||||
public QualityTestPlan getById(Long id) {
|
||||
return planMapper.selectById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建检测计划
|
||||
*/
|
||||
public QualityTestPlan create(QualityTestPlan plan) {
|
||||
plan.setDeleted(0);
|
||||
if (plan.getStatus() == null) {
|
||||
plan.setStatus("active");
|
||||
}
|
||||
if (plan.getExecutionCount() == null) {
|
||||
plan.setExecutionCount(0);
|
||||
}
|
||||
// 计算下次检测日期
|
||||
if (plan.getNextTestDate() == null) {
|
||||
plan.setNextTestDate(plan.getStartDate() != null ? plan.getStartDate() : LocalDate.now());
|
||||
}
|
||||
planMapper.insert(plan);
|
||||
return plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新检测计划
|
||||
*/
|
||||
public void update(QualityTestPlan plan) {
|
||||
planMapper.updateById(plan);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除检测计划 (逻辑删除)
|
||||
*/
|
||||
public void delete(Long id) {
|
||||
planMapper.deleteById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停/恢复计划
|
||||
*/
|
||||
public void toggleStatus(Long id, String status) {
|
||||
QualityTestPlan plan = planMapper.selectById(id);
|
||||
if (plan == null) {
|
||||
throw new IllegalArgumentException("计划不存在: " + id);
|
||||
}
|
||||
plan.setStatus(status);
|
||||
planMapper.updateById(plan);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有到期需执行的计划
|
||||
*/
|
||||
public List<QualityTestPlan> getDuePlans() {
|
||||
return planMapper.selectList(
|
||||
new LambdaQueryWrapper<QualityTestPlan>()
|
||||
.eq(QualityTestPlan::getStatus, "active")
|
||||
.le(QualityTestPlan::getNextTestDate, LocalDate.now())
|
||||
.and(w -> w.isNull(QualityTestPlan::getEndDate)
|
||||
.or().ge(QualityTestPlan::getEndDate, LocalDate.now()))
|
||||
.orderByAsc(QualityTestPlan::getNextTestDate)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行计划后更新下次检测日期
|
||||
*/
|
||||
public void markExecuted(Long id) {
|
||||
QualityTestPlan plan = planMapper.selectById(id);
|
||||
if (plan == null) return;
|
||||
|
||||
plan.setExecutionCount(plan.getExecutionCount() + 1);
|
||||
|
||||
LocalDate current = plan.getNextTestDate() != null ? plan.getNextTestDate() : LocalDate.now();
|
||||
switch (plan.getFrequency()) {
|
||||
case "daily" -> plan.setNextTestDate(current.plusDays(1));
|
||||
case "weekly" -> plan.setNextTestDate(current.plusWeeks(1));
|
||||
case "monthly" -> plan.setNextTestDate(current.plusMonths(1));
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (plan.getEndDate() != null && plan.getNextTestDate().isAfter(plan.getEndDate())) {
|
||||
plan.setStatus("completed");
|
||||
}
|
||||
|
||||
planMapper.updateById(plan);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
-- ============================================================
|
||||
-- V4__quality_ledger.sql
|
||||
-- 水质检测台账模块 DDL
|
||||
-- 包含: 检测记录、水质标准、检测计划
|
||||
-- ============================================================
|
||||
|
||||
-- 1. 水质检测记录表
|
||||
CREATE TABLE IF NOT EXISTS prod_quality_test_record (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
test_type VARCHAR(20) NOT NULL DEFAULT 'routine', -- routine/special/complaint
|
||||
water_type VARCHAR(20) NOT NULL DEFAULT 'treated', -- raw/treated/network
|
||||
sampling_point VARCHAR(100), -- 采样点
|
||||
area VARCHAR(50), -- 所属区域
|
||||
test_date DATE NOT NULL, -- 检测日期
|
||||
test_time TIME, -- 检测时间
|
||||
tester VARCHAR(50), -- 检测人
|
||||
turbidity NUMERIC(10,2), -- 浊度 (NTU)
|
||||
ph NUMERIC(5,2), -- pH值
|
||||
residual_chlorine NUMERIC(6,3), -- 余氯 (mg/L)
|
||||
color NUMERIC(8,2), -- 色度 (度)
|
||||
odor NUMERIC(4,1), -- 嗅味 (级)
|
||||
ecoli NUMERIC(10,2), -- 大肠杆菌 (CFU/100mL)
|
||||
colony_count NUMERIC(10,2), -- 菌落总数 (CFU/mL)
|
||||
compliance_status VARCHAR(20) NOT NULL DEFAULT 'pending', -- qualified/unqualified/pending
|
||||
unqualified_items TEXT, -- 不合格项 (JSON)
|
||||
remark VARCHAR(500), -- 备注
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_record_type ON prod_quality_test_record(test_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_record_water_type ON prod_quality_test_record(water_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_record_area ON prod_quality_test_record(area);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_record_date ON prod_quality_test_record(test_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_record_compliance ON prod_quality_test_record(compliance_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_record_deleted ON prod_quality_test_record(deleted);
|
||||
|
||||
COMMENT ON TABLE prod_quality_test_record IS '水质检测记录表';
|
||||
COMMENT ON COLUMN prod_quality_test_record.test_type IS '检测类型: routine-常规/special-专项/complaint-投诉';
|
||||
COMMENT ON COLUMN prod_quality_test_record.water_type IS '水样类型: raw-原水/treated-出厂水/network-管网末梢水';
|
||||
COMMENT ON COLUMN prod_quality_test_record.compliance_status IS '合格状态: qualified-合格/unqualified-不合格/pending-待判定';
|
||||
|
||||
-- 2. 水质标准表 (GB5749-2022)
|
||||
CREATE TABLE IF NOT EXISTS prod_quality_standard (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
standard_name VARCHAR(100) NOT NULL,
|
||||
standard_code VARCHAR(50) NOT NULL DEFAULT 'GB5749-2022',
|
||||
param_name VARCHAR(50) NOT NULL, -- 参数编码
|
||||
param_label VARCHAR(50), -- 参数显示名
|
||||
param_unit VARCHAR(20), -- 单位
|
||||
min_value NUMERIC(12,4), -- 最小值 (NULL=无下限)
|
||||
max_value NUMERIC(12,4), -- 最大值 (NULL=无上限)
|
||||
water_type VARCHAR(20) NOT NULL DEFAULT 'all', -- 适用水样类型
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_standard_code ON prod_quality_standard(standard_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_standard_param ON prod_quality_standard(param_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_standard_deleted ON prod_quality_standard(deleted);
|
||||
|
||||
COMMENT ON TABLE prod_quality_standard IS '水质标准表 (基于GB5749-2022)';
|
||||
|
||||
-- 初始化 GB5749-2022 默认标准
|
||||
INSERT INTO prod_quality_standard (standard_name, standard_code, param_name, param_label, param_unit, min_value, max_value, water_type)
|
||||
VALUES
|
||||
('生活饮用水卫生标准', 'GB5749-2022', 'turbidity', '浊度', 'NTU', NULL, 1.0, 'treated'),
|
||||
('生活饮用水卫生标准', 'GB5749-2022', 'turbidity', '浊度', 'NTU', NULL, 3.0, 'network'),
|
||||
('生活饮用水卫生标准', 'GB5749-2022', 'ph', 'pH', '', 6.5, 8.5, 'all'),
|
||||
('生活饮用水卫生标准', 'GB5749-2022', 'residual_chlorine', '余氯', 'mg/L', 0.3, 2.0, 'treated'),
|
||||
('生活饮用水卫生标准', 'GB5749-2022', 'residual_chlorine', '余氯', 'mg/L', 0.05, 2.0, 'network'),
|
||||
('生活饮用水卫生标准', 'GB5749-2022', 'color', '色度', '度', NULL, 15.0, 'all'),
|
||||
('生活饮用水卫生标准', 'GB5749-2022', 'odor', '嗅味', '级', NULL, 2.0, 'all'),
|
||||
('生活饮用水卫生标准', 'GB5749-2022', 'ecoli', '大肠杆菌', 'CFU/100mL', NULL, 0.0, 'all'),
|
||||
('生活饮用水卫生标准', 'GB5749-2022', 'colony_count', '菌落总数', 'CFU/mL', NULL, 100.0, 'all')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 3. 水质检测计划表
|
||||
CREATE TABLE IF NOT EXISTS prod_quality_test_plan (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
plan_name VARCHAR(100) NOT NULL,
|
||||
test_type VARCHAR(20) NOT NULL DEFAULT 'routine', -- routine/special
|
||||
water_type VARCHAR(20) NOT NULL DEFAULT 'treated',
|
||||
sampling_point VARCHAR(100),
|
||||
area VARCHAR(50),
|
||||
frequency VARCHAR(20) NOT NULL DEFAULT 'daily', -- daily/weekly/monthly
|
||||
test_params VARCHAR(200), -- 检测参数 (逗号分隔)
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE, -- NULL=长期
|
||||
next_test_date DATE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active', -- active/paused/completed
|
||||
execution_count INTEGER NOT NULL DEFAULT 0,
|
||||
remark VARCHAR(500),
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_plan_status ON prod_quality_test_plan(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_plan_frequency ON prod_quality_test_plan(frequency);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_plan_next_date ON prod_quality_test_plan(next_test_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_quality_plan_deleted ON prod_quality_test_plan(deleted);
|
||||
|
||||
COMMENT ON TABLE prod_quality_test_plan IS '水质检测计划表';
|
||||
COMMENT ON COLUMN prod_quality_test_plan.frequency IS '检测频率: daily-日检/weekly-周检/monthly-月检';
|
||||
COMMENT ON COLUMN prod_quality_test_plan.status IS '计划状态: active-启用/paused-暂停/completed-已完成';
|
||||
@@ -0,0 +1,150 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.water.production.mapper.QualityTestRecordMapper">
|
||||
|
||||
<!-- 通用 WHERE 条件 -->
|
||||
<sql id="queryConditions">
|
||||
WHERE r.deleted = 0
|
||||
<if test="testType != null and testType != ''">AND r.test_type = #{testType}</if>
|
||||
<if test="waterType != null and waterType != ''">AND r.water_type = #{waterType}</if>
|
||||
<if test="area != null and area != ''">AND r.area = #{area}</if>
|
||||
<if test="samplingPoint != null and samplingPoint != ''">
|
||||
AND r.sampling_point LIKE '%' || #{samplingPoint} || '%'
|
||||
</if>
|
||||
<if test="tester != null and tester != ''">
|
||||
AND r.tester LIKE '%' || #{tester} || '%'
|
||||
</if>
|
||||
<if test="complianceStatus != null and complianceStatus != ''">
|
||||
AND r.compliance_status = #{complianceStatus}
|
||||
</if>
|
||||
<if test="startDate != null and startDate != ''">AND r.test_date >= #{startDate}::date</if>
|
||||
<if test="endDate != null and endDate != ''">AND r.test_date <= #{endDate}::date</if>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (r.sampling_point LIKE '%' || #{keyword} || '%'
|
||||
OR r.tester LIKE '%' || #{keyword} || '%'
|
||||
OR r.area LIKE '%' || #{keyword} || '%'
|
||||
OR r.remark LIKE '%' || #{keyword} || '%')
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<!-- 分页查询台账 -->
|
||||
<select id="selectRecordPage" resultType="java.util.Map">
|
||||
SELECT
|
||||
r.id, r.test_type AS "testType", r.water_type AS "waterType",
|
||||
r.sampling_point AS "samplingPoint", r.area, r.test_date AS "testDate",
|
||||
r.test_time AS "testTime", r.tester, r.turbidity, r.ph,
|
||||
r.residual_chlorine AS "residualChlorine", r.color, r.odor,
|
||||
r.ecoli, r.colony_count AS "colonyCount",
|
||||
r.compliance_status AS "complianceStatus",
|
||||
r.unqualified_items AS "unqualifiedItems", r.remark,
|
||||
r.created_at AS "createdAt", r.updated_at AS "updatedAt"
|
||||
FROM prod_quality_test_record r
|
||||
<include refid="queryConditions"/>
|
||||
<choose>
|
||||
<when test="sortField != null and sortField == 'testDate'">
|
||||
ORDER BY r.test_date
|
||||
<if test="sortOrder != null and sortOrder == 'asc'">ASC</if>
|
||||
<if test="sortOrder == null or sortOrder != 'asc'">DESC</if>
|
||||
</when>
|
||||
<when test="sortField != null and sortField == 'tester'">
|
||||
ORDER BY r.tester
|
||||
<if test="sortOrder != null and sortOrder == 'asc'">ASC</if>
|
||||
<if test="sortOrder == null or sortOrder != 'asc'">DESC</if>
|
||||
</when>
|
||||
<otherwise>ORDER BY r.created_at DESC</otherwise>
|
||||
</choose>
|
||||
OFFSET #{offset} LIMIT #{limit}
|
||||
</select>
|
||||
|
||||
<!-- 统计总数 -->
|
||||
<select id="countRecords" resultType="java.lang.Long">
|
||||
SELECT COUNT(*) FROM prod_quality_test_record r
|
||||
<include refid="queryConditions"/>
|
||||
</select>
|
||||
|
||||
<!-- 按合格状态统计 -->
|
||||
<select id="statByComplianceStatus" resultType="java.util.Map">
|
||||
SELECT compliance_status AS "status", COUNT(*) AS count
|
||||
FROM prod_quality_test_record
|
||||
WHERE deleted = 0
|
||||
<if test="startDate != null and startDate != ''">AND test_date >= #{startDate}::date</if>
|
||||
<if test="endDate != null and endDate != ''">AND test_date <= #{endDate}::date</if>
|
||||
GROUP BY compliance_status
|
||||
</select>
|
||||
|
||||
<!-- 按水样类型统计合格率 -->
|
||||
<select id="statRateByWaterType" resultType="java.util.Map">
|
||||
SELECT water_type AS "waterType",
|
||||
COUNT(*) AS total,
|
||||
COUNT(CASE WHEN compliance_status = 'qualified' THEN 1 END) AS qualified
|
||||
FROM prod_quality_test_record
|
||||
WHERE deleted = 0
|
||||
<if test="startDate != null and startDate != ''">AND test_date >= #{startDate}::date</if>
|
||||
<if test="endDate != null and endDate != ''">AND test_date <= #{endDate}::date</if>
|
||||
GROUP BY water_type
|
||||
</select>
|
||||
|
||||
<!-- 按区域统计合格率 -->
|
||||
<select id="statRateByArea" resultType="java.util.Map">
|
||||
SELECT area,
|
||||
COUNT(*) AS total,
|
||||
COUNT(CASE WHEN compliance_status = 'qualified' THEN 1 END) AS qualified
|
||||
FROM prod_quality_test_record
|
||||
WHERE deleted = 0
|
||||
<if test="startDate != null and startDate != ''">AND test_date >= #{startDate}::date</if>
|
||||
<if test="endDate != null and endDate != ''">AND test_date <= #{endDate}::date</if>
|
||||
GROUP BY area
|
||||
</select>
|
||||
|
||||
<!-- 按指标统计不合格次数 -->
|
||||
<select id="statUnqualifiedByParam" resultType="java.util.Map">
|
||||
SELECT param_name AS "paramName", COUNT(*) AS count
|
||||
FROM (
|
||||
SELECT jsonb_array_elements(
|
||||
CASE
|
||||
WHEN unqualified_items IS NOT NULL AND unqualified_items != ''
|
||||
THEN unqualified_items::jsonb
|
||||
ELSE '[]'::jsonb
|
||||
END
|
||||
)->>'param' AS param_name
|
||||
FROM prod_quality_test_record
|
||||
WHERE deleted = 0 AND compliance_status = 'unqualified'
|
||||
<if test="startDate != null and startDate != ''">AND test_date >= #{startDate}::date</if>
|
||||
<if test="endDate != null and endDate != ''">AND test_date <= #{endDate}::date</if>
|
||||
) sub
|
||||
WHERE param_name IS NOT NULL
|
||||
GROUP BY param_name
|
||||
ORDER BY count DESC
|
||||
</select>
|
||||
|
||||
<!-- 月度合格率趋势 -->
|
||||
<select id="statMonthlyTrend" resultType="java.util.Map">
|
||||
SELECT TO_CHAR(test_date, 'YYYY-MM') AS month,
|
||||
COUNT(*) AS total,
|
||||
COUNT(CASE WHEN compliance_status = 'qualified' THEN 1 END) AS qualified,
|
||||
ROUND(
|
||||
COUNT(CASE WHEN compliance_status = 'qualified' THEN 1 END) * 100.0 / COUNT(*), 1
|
||||
) AS rate
|
||||
FROM prod_quality_test_record
|
||||
WHERE deleted = 0
|
||||
<if test="startDate != null and startDate != ''">AND test_date >= #{startDate}::date</if>
|
||||
<if test="endDate != null and endDate != ''">AND test_date <= #{endDate}::date</if>
|
||||
GROUP BY TO_CHAR(test_date, 'YYYY-MM')
|
||||
ORDER BY month ASC
|
||||
</select>
|
||||
|
||||
<!-- 各指标均值统计 -->
|
||||
<select id="statParamAvg" resultType="java.util.Map">
|
||||
SELECT
|
||||
ROUND(AVG(turbidity), 2) AS "turbidityAvg",
|
||||
ROUND(AVG(ph), 2) AS "phAvg",
|
||||
ROUND(AVG(residual_chlorine), 3) AS "residualChlorineAvg",
|
||||
ROUND(AVG(color), 2) AS "colorAvg",
|
||||
ROUND(AVG(odor), 2) AS "odorAvg",
|
||||
ROUND(AVG(ecoli), 2) AS "ecoliAvg",
|
||||
ROUND(AVG(colony_count), 2) AS "colonyCountAvg"
|
||||
FROM prod_quality_test_record
|
||||
WHERE deleted = 0
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,386 @@
|
||||
package com.water.production.service;
|
||||
|
||||
import com.water.production.dto.GisStatisticsVO;
|
||||
import com.water.production.dto.SpatialQueryRequest;
|
||||
import com.water.production.entity.GisArea;
|
||||
import com.water.production.entity.GisPipeline;
|
||||
import com.water.production.entity.GisPoint;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class GisServiceTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("GisPoint entity field completeness")
|
||||
void testGisPointEntity() {
|
||||
GisPoint point = new GisPoint();
|
||||
point.setId(1L);
|
||||
point.setPointCode("GIS-FLOW-001");
|
||||
point.setPointName("flow meter 1");
|
||||
point.setPointType("flow");
|
||||
point.setArea("water plant");
|
||||
point.setLng(new BigDecimal("82.07123456"));
|
||||
point.setLat(new BigDecimal("44.84567890"));
|
||||
point.setElevation(new BigDecimal("350.50"));
|
||||
point.setDeviceId(1L);
|
||||
point.setAddress("pump station 1");
|
||||
point.setStatus("online");
|
||||
point.setProperties("{\"unit\":\"m3/h\"}");
|
||||
|
||||
assertEquals(1L, point.getId());
|
||||
assertEquals("GIS-FLOW-001", point.getPointCode());
|
||||
assertEquals("flow", point.getPointType());
|
||||
assertEquals(new BigDecimal("82.07123456"), point.getLng());
|
||||
assertEquals("online", point.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GisPipeline entity field completeness")
|
||||
void testGisPipelineEntity() {
|
||||
GisPipeline pipeline = new GisPipeline();
|
||||
pipeline.setId(1L);
|
||||
pipeline.setPipelineCode("PIPE-001");
|
||||
pipeline.setPipelineName("main supply pipe");
|
||||
pipeline.setPipelineType("supply");
|
||||
pipeline.setMaterial("ductile_iron");
|
||||
pipeline.setDiameter(new BigDecimal("600.00"));
|
||||
pipeline.setStartLng(new BigDecimal("82.07"));
|
||||
pipeline.setStartLat(new BigDecimal("44.84"));
|
||||
pipeline.setEndLng(new BigDecimal("82.08"));
|
||||
pipeline.setEndLat(new BigDecimal("44.85"));
|
||||
pipeline.setLength(new BigDecimal("1500.00"));
|
||||
pipeline.setStartNodeId(1L);
|
||||
pipeline.setEndNodeId(7L);
|
||||
pipeline.setArea("water plant");
|
||||
pipeline.setBurialDepth(new BigDecimal("1.50"));
|
||||
pipeline.setBuildYear(2020);
|
||||
pipeline.setStatus("normal");
|
||||
|
||||
assertEquals("PIPE-001", pipeline.getPipelineCode());
|
||||
assertEquals("supply", pipeline.getPipelineType());
|
||||
assertEquals(new BigDecimal("1500.00"), pipeline.getLength());
|
||||
assertEquals(2020, pipeline.getBuildYear());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GisArea entity field completeness")
|
||||
void testGisAreaEntity() {
|
||||
GisArea area = new GisArea();
|
||||
area.setId(1L);
|
||||
area.setAreaCode("AREA-001");
|
||||
area.setAreaName("water plant");
|
||||
area.setAreaType("water_plant");
|
||||
area.setCenterLng(new BigDecimal("82.07100000"));
|
||||
area.setCenterLat(new BigDecimal("44.84500000"));
|
||||
area.setAreaSize(new BigDecimal("2.5000"));
|
||||
area.setBoundary("{\"type\":\"Polygon\"}");
|
||||
area.setDeviceCount(15);
|
||||
area.setOnlineCount(12);
|
||||
area.setAlertCount(1);
|
||||
area.setPopulation(new BigDecimal("5.0000"));
|
||||
area.setStatus("active");
|
||||
|
||||
assertEquals("AREA-001", area.getAreaCode());
|
||||
assertEquals(15, area.getDeviceCount());
|
||||
assertTrue(area.getBoundary().contains("Polygon"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SpatialQueryRequest rectangle params")
|
||||
void testSpatialQueryRequestRectangle() {
|
||||
SpatialQueryRequest request = new SpatialQueryRequest();
|
||||
request.setQueryType("rectangle");
|
||||
request.setMinLng(new BigDecimal("82.06"));
|
||||
request.setMinLat(new BigDecimal("44.84"));
|
||||
request.setMaxLng(new BigDecimal("82.10"));
|
||||
request.setMaxLat(new BigDecimal("44.87"));
|
||||
request.setPointType("flow");
|
||||
|
||||
assertEquals("rectangle", request.getQueryType());
|
||||
assertEquals(new BigDecimal("82.06"), request.getMinLng());
|
||||
assertEquals(1, request.getPageNum());
|
||||
assertEquals(50, request.getPageSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SpatialQueryRequest circle params")
|
||||
void testSpatialQueryRequestCircle() {
|
||||
SpatialQueryRequest request = new SpatialQueryRequest();
|
||||
request.setQueryType("circle");
|
||||
request.setCenterLng(new BigDecimal("82.08"));
|
||||
request.setCenterLat(new BigDecimal("44.85"));
|
||||
request.setRadius(new BigDecimal("1000"));
|
||||
|
||||
assertEquals("circle", request.getQueryType());
|
||||
assertEquals(new BigDecimal("1000"), request.getRadius());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GisStatisticsVO data structure")
|
||||
void testGisStatisticsVO() {
|
||||
GisStatisticsVO vo = new GisStatisticsVO();
|
||||
vo.setTotalPoints(50);
|
||||
vo.setOnlinePoints(40);
|
||||
vo.setOfflinePoints(8);
|
||||
vo.setFaultPoints(2);
|
||||
vo.setOnlineRate(new BigDecimal("80.0"));
|
||||
vo.setTotalPipelineLength(new BigDecimal("15000.00"));
|
||||
vo.setTotalAreas(5);
|
||||
vo.setTotalAlerts(3);
|
||||
|
||||
assertEquals(50, vo.getTotalPoints());
|
||||
assertEquals(new BigDecimal("80.0"), vo.getOnlineRate());
|
||||
|
||||
GisStatisticsVO.AreaStatistic areaStat = new GisStatisticsVO.AreaStatistic();
|
||||
areaStat.setArea("zone1");
|
||||
areaStat.setDeviceCount(15);
|
||||
areaStat.setOnlineCount(12);
|
||||
areaStat.setOnlineRate(new BigDecimal("80.0"));
|
||||
vo.setAreaStatistics(List.of(areaStat));
|
||||
assertEquals(1, vo.getAreaStatistics().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Rectangle filter logic")
|
||||
void testRectangleFilterLogic() {
|
||||
List<GisPoint> allPoints = buildMockPoints();
|
||||
BigDecimal minLng = new BigDecimal("82.06");
|
||||
BigDecimal minLat = new BigDecimal("44.84");
|
||||
BigDecimal maxLng = new BigDecimal("82.08");
|
||||
BigDecimal maxLat = new BigDecimal("44.86");
|
||||
|
||||
List<GisPoint> filtered = allPoints.stream()
|
||||
.filter(p -> p.getLng().compareTo(minLng) >= 0 && p.getLng().compareTo(maxLng) <= 0)
|
||||
.filter(p -> p.getLat().compareTo(minLat) >= 0 && p.getLat().compareTo(maxLat) <= 0)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
assertEquals(4, filtered.size());
|
||||
|
||||
List<GisPoint> flowOnly = filtered.stream()
|
||||
.filter(p -> "flow".equals(p.getPointType()))
|
||||
.collect(Collectors.toList());
|
||||
assertEquals(2, flowOnly.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Circle distance calculation (Haversine)")
|
||||
void testCircleDistanceCalculation() {
|
||||
double centerLng = 82.08;
|
||||
double centerLat = 44.85;
|
||||
double radiusMeters = 2000;
|
||||
|
||||
List<GisPoint> allPoints = buildMockPoints();
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
for (GisPoint p : allPoints) {
|
||||
double distance = haversineDistance(centerLat, centerLng,
|
||||
p.getLat().doubleValue(), p.getLng().doubleValue());
|
||||
if (distance <= radiusMeters) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("point", p);
|
||||
item.put("distance", BigDecimal.valueOf(distance).setScale(2, RoundingMode.HALF_UP));
|
||||
results.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(results.size() > 0);
|
||||
for (int i = 1; i < results.size(); i++) {
|
||||
BigDecimal prev = (BigDecimal) results.get(i - 1).get("distance");
|
||||
BigDecimal curr = (BigDecimal) results.get(i).get("distance");
|
||||
assertTrue(prev.compareTo(curr) <= 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Heatmap grid aggregation")
|
||||
void testHeatmapGridAggregation() {
|
||||
List<GisPoint> allPoints = buildMockPoints();
|
||||
BigDecimal gridSize = new BigDecimal("0.01");
|
||||
|
||||
Map<String, List<GisPoint>> gridMap = new LinkedHashMap<>();
|
||||
for (GisPoint p : allPoints) {
|
||||
BigDecimal gridLng = p.getLng().divide(gridSize, 4, RoundingMode.HALF_UP)
|
||||
.setScale(4, RoundingMode.HALF_UP).multiply(gridSize);
|
||||
BigDecimal gridLat = p.getLat().divide(gridSize, 4, RoundingMode.HALF_UP)
|
||||
.setScale(4, RoundingMode.HALF_UP).multiply(gridSize);
|
||||
String key = gridLng + "," + gridLat;
|
||||
gridMap.computeIfAbsent(key, k -> new ArrayList<>()).add(p);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> heatmapData = new ArrayList<>();
|
||||
for (Map.Entry<String, List<GisPoint>> entry : gridMap.entrySet()) {
|
||||
Map<String, Object> cell = new LinkedHashMap<>();
|
||||
String[] parts = entry.getKey().split(",");
|
||||
cell.put("grid_lng", new BigDecimal(parts[0]));
|
||||
cell.put("grid_lat", new BigDecimal(parts[1]));
|
||||
cell.put("weight", entry.getValue().size());
|
||||
heatmapData.add(cell);
|
||||
}
|
||||
|
||||
assertTrue(heatmapData.size() > 0);
|
||||
heatmapData.forEach(cell -> assertTrue((int) cell.get("weight") > 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Area distribution aggregation")
|
||||
void testAreaDistributionAggregation() {
|
||||
List<GisPoint> allPoints = buildMockPoints();
|
||||
Map<String, Long> areaCount = allPoints.stream()
|
||||
.filter(p -> p.getArea() != null)
|
||||
.collect(Collectors.groupingBy(GisPoint::getArea, Collectors.counting()));
|
||||
|
||||
assertEquals(3L, areaCount.get("water plant"));
|
||||
assertEquals(2L, areaCount.get("zone1"));
|
||||
assertEquals(1L, areaCount.get("zone2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Type distribution aggregation")
|
||||
void testTypeDistributionAggregation() {
|
||||
List<GisPoint> allPoints = buildMockPoints();
|
||||
Map<String, Long> typeCount = allPoints.stream()
|
||||
.collect(Collectors.groupingBy(GisPoint::getPointType, Collectors.counting()));
|
||||
|
||||
assertEquals(2L, typeCount.get("flow"));
|
||||
assertEquals(2L, typeCount.get("pressure"));
|
||||
assertEquals(1L, typeCount.get("level"));
|
||||
assertEquals(1L, typeCount.get("quality"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Online rate calculation")
|
||||
void testOnlineRateCalculation() {
|
||||
List<GisPoint> allPoints = buildMockPoints();
|
||||
int total = allPoints.size();
|
||||
long onlineCount = allPoints.stream().filter(p -> "online".equals(p.getStatus())).count();
|
||||
|
||||
BigDecimal onlineRate = total > 0
|
||||
? BigDecimal.valueOf(onlineCount * 100.0 / total).setScale(1, RoundingMode.HALF_UP)
|
||||
: BigDecimal.ZERO;
|
||||
|
||||
assertEquals(6, total);
|
||||
assertEquals(4, onlineCount);
|
||||
assertEquals(new BigDecimal("66.7"), onlineRate);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Pipeline total length")
|
||||
void testPipelineTotalLength() {
|
||||
List<GisPipeline> pipelines = buildMockPipelines();
|
||||
BigDecimal totalLength = pipelines.stream()
|
||||
.map(GisPipeline::getLength)
|
||||
.filter(Objects::nonNull)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
assertEquals(new BigDecimal("4300.00"), totalLength);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("All point types covered")
|
||||
void testAllPointTypesCovered() {
|
||||
List<GisPoint> allPoints = buildMockPoints();
|
||||
Set<String> types = allPoints.stream().map(GisPoint::getPointType).collect(Collectors.toSet());
|
||||
|
||||
assertTrue(types.contains("flow"));
|
||||
assertTrue(types.contains("pressure"));
|
||||
assertTrue(types.contains("level"));
|
||||
assertTrue(types.contains("quality"));
|
||||
assertTrue(types.contains("valve"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Spatial query type detection")
|
||||
void testSpatialQueryTypeDetection() {
|
||||
SpatialQueryRequest rectReq = new SpatialQueryRequest();
|
||||
rectReq.setQueryType("rectangle");
|
||||
assertFalse("circle".equalsIgnoreCase(rectReq.getQueryType()));
|
||||
|
||||
SpatialQueryRequest circleReq = new SpatialQueryRequest();
|
||||
circleReq.setQueryType("circle");
|
||||
assertTrue("circle".equalsIgnoreCase(circleReq.getQueryType()));
|
||||
}
|
||||
|
||||
private List<GisPoint> buildMockPoints() {
|
||||
List<GisPoint> points = new ArrayList<>();
|
||||
GisPoint p1 = new GisPoint();
|
||||
p1.setId(1L); p1.setPointCode("GIS-FLOW-001"); p1.setPointName("Flow meter 1");
|
||||
p1.setPointType("flow"); p1.setArea("water plant");
|
||||
p1.setLng(new BigDecimal("82.07123456")); p1.setLat(new BigDecimal("44.84567890"));
|
||||
p1.setStatus("online"); points.add(p1);
|
||||
|
||||
GisPoint p2 = new GisPoint();
|
||||
p2.setId(2L); p2.setPointCode("GIS-FLOW-002"); p2.setPointName("Flow meter 2");
|
||||
p2.setPointType("flow"); p2.setArea("water plant");
|
||||
p2.setLng(new BigDecimal("82.07234567")); p2.setLat(new BigDecimal("44.84678901"));
|
||||
p2.setStatus("online"); points.add(p2);
|
||||
|
||||
GisPoint p3 = new GisPoint();
|
||||
p3.setId(3L); p3.setPointCode("GIS-PRES-001"); p3.setPointName("Pressure point A");
|
||||
p3.setPointType("pressure"); p3.setArea("zone1");
|
||||
p3.setLng(new BigDecimal("82.08567890")); p3.setLat(new BigDecimal("44.85512345"));
|
||||
p3.setStatus("online"); points.add(p3);
|
||||
|
||||
GisPoint p4 = new GisPoint();
|
||||
p4.setId(4L); p4.setPointCode("GIS-PRES-002"); p4.setPointName("Pressure point B");
|
||||
p4.setPointType("pressure"); p4.setArea("zone1");
|
||||
p4.setLng(new BigDecimal("82.08678901")); p4.setLat(new BigDecimal("44.85623456"));
|
||||
p4.setStatus("offline"); points.add(p4);
|
||||
|
||||
GisPoint p5 = new GisPoint();
|
||||
p5.setId(5L); p5.setPointCode("GIS-LEV-001"); p5.setPointName("Level gauge");
|
||||
p5.setPointType("level"); p5.setArea("water plant");
|
||||
p5.setLng(new BigDecimal("82.07012345")); p5.setLat(new BigDecimal("44.84456789"));
|
||||
p5.setStatus("online"); points.add(p5);
|
||||
|
||||
GisPoint p6 = new GisPoint();
|
||||
p6.setId(6L); p6.setPointCode("GIS-QUAL-001"); p6.setPointName("Quality monitor");
|
||||
p6.setPointType("quality"); p6.setArea("zone2");
|
||||
p6.setLng(new BigDecimal("82.09512345")); p6.setLat(new BigDecimal("44.86023456"));
|
||||
p6.setStatus("fault"); points.add(p6);
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
private List<GisPipeline> buildMockPipelines() {
|
||||
List<GisPipeline> pipelines = new ArrayList<>();
|
||||
GisPipeline p1 = new GisPipeline();
|
||||
p1.setId(1L); p1.setPipelineCode("PIPE-001"); p1.setLength(new BigDecimal("1500.00"));
|
||||
p1.setArea("water plant"); p1.setStatus("normal"); pipelines.add(p1);
|
||||
|
||||
GisPipeline p2 = new GisPipeline();
|
||||
p2.setId(2L); p2.setPipelineCode("PIPE-002"); p2.setLength(new BigDecimal("800.00"));
|
||||
p2.setArea("zone1"); p2.setStatus("normal"); pipelines.add(p2);
|
||||
|
||||
GisPipeline p3 = new GisPipeline();
|
||||
p3.setId(3L); p3.setPipelineCode("PIPE-003"); p3.setLength(new BigDecimal("1200.00"));
|
||||
p3.setArea("zone2"); p3.setStatus("normal"); pipelines.add(p3);
|
||||
|
||||
GisPipeline p4 = new GisPipeline();
|
||||
p4.setId(4L); p4.setPipelineCode("PIPE-004"); p4.setLength(new BigDecimal("500.00"));
|
||||
p4.setArea("zone1"); p4.setStatus("maintenance"); pipelines.add(p4);
|
||||
|
||||
GisPipeline p5 = new GisPipeline();
|
||||
p5.setId(5L); p5.setPipelineCode("PIPE-005"); p5.setLength(new BigDecimal("300.00"));
|
||||
p5.setArea("zone2"); p5.setStatus("normal"); pipelines.add(p5);
|
||||
|
||||
return pipelines;
|
||||
}
|
||||
|
||||
private double haversineDistance(double lat1, double lng1, double lat2, double lng2) {
|
||||
double R = 6371000;
|
||||
double dLat = Math.toRadians(lat2 - lat1);
|
||||
double dLng = Math.toRadians(lng2 - lng1);
|
||||
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) *
|
||||
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
}
|
||||
+586
@@ -0,0 +1,586 @@
|
||||
package com.water.production.service;
|
||||
|
||||
import com.water.production.dto.QualityQueryRequest;
|
||||
import com.water.production.dto.QualityStatVO;
|
||||
import com.water.production.entity.QualityStandard;
|
||||
import com.water.production.entity.QualityTestPlan;
|
||||
import com.water.production.entity.QualityTestRecord;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 水质检测台账单元测试
|
||||
* 覆盖实体构建、合格判定、统计计算、计划调度、查询筛选、CSV转义等
|
||||
*/
|
||||
class QualityLedgerServiceTest {
|
||||
|
||||
// ========== 1. 实体完整性测试 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("QualityTestRecord 实体字段完整性")
|
||||
void testQualityTestRecordEntity() {
|
||||
QualityTestRecord record = new QualityTestRecord();
|
||||
record.setId(1L);
|
||||
record.setTestType("routine");
|
||||
record.setWaterType("treated");
|
||||
record.setSamplingPoint("出厂水口");
|
||||
record.setArea("一体化水厂");
|
||||
record.setTestDate(LocalDate.of(2026, 6, 14));
|
||||
record.setTestTime(LocalTime.of(9, 30));
|
||||
record.setTester("张三");
|
||||
record.setTurbidity(new BigDecimal("0.5"));
|
||||
record.setPh(new BigDecimal("7.2"));
|
||||
record.setResidualChlorine(new BigDecimal("0.5"));
|
||||
record.setColor(new BigDecimal("5"));
|
||||
record.setOdor(new BigDecimal("0"));
|
||||
record.setEcoli(BigDecimal.ZERO);
|
||||
record.setColonyCount(new BigDecimal("12"));
|
||||
record.setComplianceStatus("qualified");
|
||||
record.setRemark("正常");
|
||||
|
||||
assertEquals(1L, record.getId());
|
||||
assertEquals("routine", record.getTestType());
|
||||
assertEquals("treated", record.getWaterType());
|
||||
assertEquals("出厂水口", record.getSamplingPoint());
|
||||
assertEquals(new BigDecimal("0.5"), record.getTurbidity());
|
||||
assertEquals(new BigDecimal("7.2"), record.getPh());
|
||||
assertEquals(new BigDecimal("0.5"), record.getResidualChlorine());
|
||||
assertEquals("qualified", record.getComplianceStatus());
|
||||
assertNotNull(record.getTestDate());
|
||||
assertNotNull(record.getTestTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("QualityStandard 实体字段完整性")
|
||||
void testQualityStandardEntity() {
|
||||
QualityStandard standard = new QualityStandard();
|
||||
standard.setId(1L);
|
||||
standard.setStandardName("生活饮用水卫生标准");
|
||||
standard.setStandardCode("GB5749-2022");
|
||||
standard.setParamName("turbidity");
|
||||
standard.setParamLabel("浊度");
|
||||
standard.setParamUnit("NTU");
|
||||
standard.setMinValue(null);
|
||||
standard.setMaxValue(new BigDecimal("1.0"));
|
||||
standard.setWaterType("treated");
|
||||
standard.setEnabled(1);
|
||||
|
||||
assertEquals("GB5749-2022", standard.getStandardCode());
|
||||
assertEquals("turbidity", standard.getParamName());
|
||||
assertNull(standard.getMinValue());
|
||||
assertEquals(new BigDecimal("1.0"), standard.getMaxValue());
|
||||
assertEquals("treated", standard.getWaterType());
|
||||
assertEquals(1, standard.getEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("QualityTestPlan 实体字段完整性")
|
||||
void testQualityTestPlanEntity() {
|
||||
QualityTestPlan plan = new QualityTestPlan();
|
||||
plan.setId(1L);
|
||||
plan.setPlanName("出厂水日检计划");
|
||||
plan.setTestType("routine");
|
||||
plan.setWaterType("treated");
|
||||
plan.setSamplingPoint("出厂水口");
|
||||
plan.setArea("一体化水厂");
|
||||
plan.setFrequency("daily");
|
||||
plan.setTestParams("turbidity,ph,residual_chlorine");
|
||||
plan.setStartDate(LocalDate.of(2026, 6, 1));
|
||||
plan.setEndDate(null);
|
||||
plan.setNextTestDate(LocalDate.of(2026, 6, 14));
|
||||
plan.setStatus("active");
|
||||
plan.setExecutionCount(13);
|
||||
|
||||
assertEquals("daily", plan.getFrequency());
|
||||
assertEquals("active", plan.getStatus());
|
||||
assertEquals(13, plan.getExecutionCount());
|
||||
assertNull(plan.getEndDate());
|
||||
assertNotNull(plan.getNextTestDate());
|
||||
}
|
||||
|
||||
// ========== 2. 合格判定逻辑测试 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("GB5749-2022 合格判定逻辑 - 全部合格")
|
||||
void testComplianceAllQualified() {
|
||||
// 模拟 GB5749-2022 标准
|
||||
List<QualityStandard> standards = buildDefaultStandards();
|
||||
|
||||
// 构建合格记录
|
||||
QualityTestRecord record = new QualityTestRecord();
|
||||
record.setWaterType("treated");
|
||||
record.setTurbidity(new BigDecimal("0.5")); // ≤1.0 ✓
|
||||
record.setPh(new BigDecimal("7.2")); // 6.5~8.5 ✓
|
||||
record.setResidualChlorine(new BigDecimal("0.5")); // 0.3~2.0 ✓
|
||||
record.setColor(new BigDecimal("5")); // ≤15 ✓
|
||||
record.setOdor(new BigDecimal("0")); // ≤2 ✓
|
||||
record.setEcoli(BigDecimal.ZERO); // =0 ✓
|
||||
record.setColonyCount(new BigDecimal("12")); // ≤100 ✓
|
||||
|
||||
List<String> unqualified = evaluateCompliance(record, standards);
|
||||
assertTrue(unqualified.isEmpty(), "所有指标在标准范围内应合格");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GB5749-2022 合格判定逻辑 - 多项超标")
|
||||
void testComplianceMultipleUnqualified() {
|
||||
List<QualityStandard> standards = buildDefaultStandards();
|
||||
|
||||
QualityTestRecord record = new QualityTestRecord();
|
||||
record.setWaterType("treated");
|
||||
record.setTurbidity(new BigDecimal("2.5")); // >1.0 ✗
|
||||
record.setPh(new BigDecimal("9.0")); // >8.5 ✗
|
||||
record.setResidualChlorine(new BigDecimal("0.1")); // <0.3 ✗
|
||||
record.setColor(new BigDecimal("5")); // ≤15 ✓
|
||||
record.setOdor(new BigDecimal("0")); // ≤2 ✓
|
||||
record.setEcoli(BigDecimal.ZERO); // =0 ✓
|
||||
record.setColonyCount(new BigDecimal("12")); // ≤100 ✓
|
||||
|
||||
List<String> unqualified = evaluateCompliance(record, standards);
|
||||
assertEquals(3, unqualified.size(), "应有3项不合格: 浊度、pH、余氯");
|
||||
assertTrue(unqualified.stream().anyMatch(s -> s.contains("turbidity")));
|
||||
assertTrue(unqualified.stream().anyMatch(s -> s.contains("ph")));
|
||||
assertTrue(unqualified.stream().anyMatch(s -> s.contains("residual_chlorine")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GB5749-2022 合格判定 - 管网末梢水余氯标准不同")
|
||||
void testComplianceNetworkWaterType() {
|
||||
List<QualityStandard> standards = buildDefaultStandards();
|
||||
|
||||
QualityTestRecord record = new QualityTestRecord();
|
||||
record.setWaterType("network");
|
||||
record.setTurbidity(new BigDecimal("2.0")); // ≤3.0 (管网标准) ✓
|
||||
record.setPh(new BigDecimal("7.0")); // 6.5~8.5 ✓
|
||||
record.setResidualChlorine(new BigDecimal("0.1")); // 0.05~2.0 (管网标准) ✓
|
||||
record.setColor(new BigDecimal("5")); // ≤15 ✓
|
||||
record.setOdor(new BigDecimal("0")); // ≤2 ✓
|
||||
record.setEcoli(BigDecimal.ZERO);
|
||||
record.setColonyCount(new BigDecimal("50"));
|
||||
|
||||
List<String> unqualified = evaluateCompliance(record, standards);
|
||||
assertTrue(unqualified.isEmpty(),
|
||||
"管网末梢水浊度2.0应合格(标准≤3.0),余氯0.1应合格(标准≥0.05)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("合格判定 - null 值不参与判定")
|
||||
void testComplianceNullValuesSkipped() {
|
||||
List<QualityStandard> standards = buildDefaultStandards();
|
||||
|
||||
QualityTestRecord record = new QualityTestRecord();
|
||||
record.setWaterType("treated");
|
||||
record.setTurbidity(new BigDecimal("0.5"));
|
||||
// 其他参数为 null
|
||||
record.setPh(null);
|
||||
record.setResidualChlorine(null);
|
||||
|
||||
List<String> unqualified = evaluateCompliance(record, standards);
|
||||
assertTrue(unqualified.isEmpty(), "null值不应参与判定");
|
||||
}
|
||||
|
||||
// ========== 3. 统计计算逻辑测试 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("合格率计算")
|
||||
void testQualifiedRateCalculation() {
|
||||
long total = 100;
|
||||
long qualified = 95;
|
||||
long unqualified = 3;
|
||||
long pending = 2;
|
||||
|
||||
BigDecimal rate = total > 0
|
||||
? BigDecimal.valueOf(qualified * 100.0 / total).setScale(1, RoundingMode.HALF_UP)
|
||||
: BigDecimal.ZERO;
|
||||
|
||||
assertEquals(new BigDecimal("95.0"), rate);
|
||||
assertEquals(total, qualified + unqualified + pending);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("QualityStatVO 数据结构完整性")
|
||||
void testStatVOStructure() {
|
||||
QualityStatVO stat = new QualityStatVO();
|
||||
stat.setTotalCount(200L);
|
||||
stat.setQualifiedCount(190L);
|
||||
stat.setUnqualifiedCount(8L);
|
||||
stat.setPendingCount(2L);
|
||||
stat.setQualifiedRate(new BigDecimal("95.0"));
|
||||
|
||||
Map<String, BigDecimal> rateByWaterType = new LinkedHashMap<>();
|
||||
rateByWaterType.put("treated", new BigDecimal("97.5"));
|
||||
rateByWaterType.put("network", new BigDecimal("92.3"));
|
||||
stat.setRateByWaterType(rateByWaterType);
|
||||
|
||||
Map<String, Long> unqByParam = new LinkedHashMap<>();
|
||||
unqByParam.put("turbidity", 5L);
|
||||
unqByParam.put("residual_chlorine", 3L);
|
||||
stat.setUnqualifiedByParam(unqByParam);
|
||||
|
||||
List<Map<String, Object>> trend = new ArrayList<>();
|
||||
trend.add(Map.of("month", "2026-05", "rate", new BigDecimal("94.0")));
|
||||
trend.add(Map.of("month", "2026-06", "rate", new BigDecimal("96.0")));
|
||||
stat.setMonthlyTrend(trend);
|
||||
|
||||
assertEquals(200L, stat.getTotalCount());
|
||||
assertEquals(2, stat.getRateByWaterType().size());
|
||||
assertEquals(2, stat.getUnqualifiedByParam().size());
|
||||
assertEquals(2, stat.getMonthlyTrend().size());
|
||||
assertEquals("turbidity", stat.getUnqualifiedByParam().keySet().iterator().next());
|
||||
}
|
||||
|
||||
// ========== 4. 查询筛选逻辑测试 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("QualityQueryRequest 默认值和筛选")
|
||||
void testQueryRequestDefaults() {
|
||||
QualityQueryRequest req = new QualityQueryRequest();
|
||||
assertEquals(1, req.getPageNum());
|
||||
assertEquals(20, req.getPageSize());
|
||||
assertNull(req.getTestType());
|
||||
assertNull(req.getWaterType());
|
||||
assertNull(req.getArea());
|
||||
assertNull(req.getComplianceStatus());
|
||||
assertNull(req.getStartDate());
|
||||
assertNull(req.getEndDate());
|
||||
assertNull(req.getKeyword());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("多维度筛选逻辑")
|
||||
void testMultiDimensionFilter() {
|
||||
List<QualityTestRecord> records = buildMockRecords();
|
||||
|
||||
// 按水样类型筛选
|
||||
List<QualityTestRecord> filtered = records.stream()
|
||||
.filter(r -> "treated".equals(r.getWaterType()))
|
||||
.collect(Collectors.toList());
|
||||
assertEquals(3, filtered.size());
|
||||
|
||||
// 按合格状态筛选
|
||||
filtered = records.stream()
|
||||
.filter(r -> "qualified".equals(r.getComplianceStatus()))
|
||||
.collect(Collectors.toList());
|
||||
assertEquals(3, filtered.size());
|
||||
|
||||
// 按区域筛选
|
||||
filtered = records.stream()
|
||||
.filter(r -> "一体化水厂".equals(r.getArea()))
|
||||
.collect(Collectors.toList());
|
||||
assertEquals(2, filtered.size());
|
||||
|
||||
// 组合筛选: 出厂水 + 合格
|
||||
filtered = records.stream()
|
||||
.filter(r -> "treated".equals(r.getWaterType()) && "qualified".equals(r.getComplianceStatus()))
|
||||
.collect(Collectors.toList());
|
||||
assertEquals(2, filtered.size());
|
||||
|
||||
// 关键词搜索 (采样点)
|
||||
String keyword = "出厂";
|
||||
filtered = records.stream()
|
||||
.filter(r -> r.getSamplingPoint() != null && r.getSamplingPoint().contains(keyword))
|
||||
.collect(Collectors.toList());
|
||||
assertEquals(2, filtered.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("分页参数计算")
|
||||
void testPaginationCalculation() {
|
||||
int total = 55;
|
||||
int pageSize = 20;
|
||||
int pages = (int) Math.ceil((double) total / pageSize);
|
||||
assertEquals(3, pages);
|
||||
|
||||
// 第2页偏移量
|
||||
int offset = (2 - 1) * pageSize;
|
||||
assertEquals(20, offset);
|
||||
}
|
||||
|
||||
// ========== 5. 检测计划调度测试 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("检测计划频率计算 - 日检/周检/月检")
|
||||
void testPlanFrequencyCalculation() {
|
||||
LocalDate baseDate = LocalDate.of(2026, 6, 14);
|
||||
|
||||
// 日检
|
||||
assertEquals(baseDate.plusDays(1), calculateNextDate(baseDate, "daily"));
|
||||
// 周检
|
||||
assertEquals(baseDate.plusWeeks(1), calculateNextDate(baseDate, "weekly"));
|
||||
// 月检
|
||||
assertEquals(baseDate.plusMonths(1), calculateNextDate(baseDate, "monthly"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("检测计划过期判定")
|
||||
void testPlanExpiration() {
|
||||
QualityTestPlan plan = new QualityTestPlan();
|
||||
plan.setStartDate(LocalDate.of(2026, 6, 1));
|
||||
plan.setEndDate(LocalDate.of(2026, 6, 30));
|
||||
plan.setFrequency("daily");
|
||||
plan.setStatus("active");
|
||||
|
||||
// 下次检测日期超出结束日期
|
||||
LocalDate nextDate = LocalDate.of(2026, 7, 1);
|
||||
boolean isExpired = plan.getEndDate() != null && nextDate.isAfter(plan.getEndDate());
|
||||
assertTrue(isExpired, "下次检测日期超出结束日期应判定为过期");
|
||||
|
||||
// 正常范围内
|
||||
nextDate = LocalDate.of(2026, 6, 15);
|
||||
isExpired = plan.getEndDate() != null && nextDate.isAfter(plan.getEndDate());
|
||||
assertFalse(isExpired, "正常范围内不应过期");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("检测计划到期判定")
|
||||
void testPlanDueCheck() {
|
||||
LocalDate today = LocalDate.of(2026, 6, 14);
|
||||
|
||||
QualityTestPlan duePlan = new QualityTestPlan();
|
||||
duePlan.setStatus("active");
|
||||
duePlan.setNextTestDate(LocalDate.of(2026, 6, 14));
|
||||
duePlan.setEndDate(null); // 长期
|
||||
|
||||
QualityTestPlan futurePlan = new QualityTestPlan();
|
||||
futurePlan.setStatus("active");
|
||||
futurePlan.setNextTestDate(LocalDate.of(2026, 6, 20));
|
||||
futurePlan.setEndDate(null);
|
||||
|
||||
QualityTestPlan expiredPlan = new QualityTestPlan();
|
||||
expiredPlan.setStatus("active");
|
||||
expiredPlan.setNextTestDate(LocalDate.of(2026, 6, 10));
|
||||
expiredPlan.setEndDate(LocalDate.of(2026, 6, 12)); // 已过期
|
||||
|
||||
List<QualityTestPlan> plans = List.of(duePlan, futurePlan, expiredPlan);
|
||||
|
||||
// 筛选到期计划: nextTestDate <= today AND (endDate IS NULL OR endDate >= today)
|
||||
List<QualityTestPlan> duePlans = plans.stream()
|
||||
.filter(p -> "active".equals(p.getStatus()))
|
||||
.filter(p -> !p.getNextTestDate().isAfter(today))
|
||||
.filter(p -> p.getEndDate() == null || !p.getEndDate().isBefore(today))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
assertEquals(1, duePlans.size(), "只有1个计划到期");
|
||||
assertEquals(duePlan, duePlans.get(0));
|
||||
}
|
||||
|
||||
// ========== 6. 数据导出格式测试 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("检测类型格式化")
|
||||
void testTestTypeFormatting() {
|
||||
assertEquals("常规检测", formatTestType("routine"));
|
||||
assertEquals("专项检测", formatTestType("special"));
|
||||
assertEquals("投诉检测", formatTestType("complaint"));
|
||||
assertEquals("", formatTestType(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("水样类型格式化")
|
||||
void testWaterTypeFormatting() {
|
||||
assertEquals("原水", formatWaterType("raw"));
|
||||
assertEquals("出厂水", formatWaterType("treated"));
|
||||
assertEquals("管网末梢水", formatWaterType("network"));
|
||||
assertEquals("", formatWaterType(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("合格状态格式化")
|
||||
void testComplianceFormatting() {
|
||||
assertEquals("合格", formatCompliance("qualified"));
|
||||
assertEquals("不合格", formatCompliance("unqualified"));
|
||||
assertEquals("待判定", formatCompliance("pending"));
|
||||
assertEquals("待判定", formatCompliance(null));
|
||||
}
|
||||
|
||||
// ========== Helper Methods ==========
|
||||
|
||||
private List<QualityStandard> buildDefaultStandards() {
|
||||
List<QualityStandard> standards = new ArrayList<>();
|
||||
|
||||
// 浊度 - 出厂水 ≤1.0
|
||||
QualityStandard s1 = new QualityStandard();
|
||||
s1.setParamName("turbidity"); s1.setMinValue(null); s1.setMaxValue(new BigDecimal("1.0"));
|
||||
s1.setWaterType("treated");
|
||||
standards.add(s1);
|
||||
|
||||
// 浊度 - 管网 ≤3.0
|
||||
QualityStandard s1n = new QualityStandard();
|
||||
s1n.setParamName("turbidity"); s1n.setMinValue(null); s1n.setMaxValue(new BigDecimal("3.0"));
|
||||
s1n.setWaterType("network");
|
||||
standards.add(s1n);
|
||||
|
||||
// pH - 6.5~8.5
|
||||
QualityStandard s2 = new QualityStandard();
|
||||
s2.setParamName("ph"); s2.setMinValue(new BigDecimal("6.5")); s2.setMaxValue(new BigDecimal("8.5"));
|
||||
s2.setWaterType("all");
|
||||
standards.add(s2);
|
||||
|
||||
// 余氯 - 出厂水 0.3~2.0
|
||||
QualityStandard s3 = new QualityStandard();
|
||||
s3.setParamName("residual_chlorine"); s3.setMinValue(new BigDecimal("0.3"));
|
||||
s3.setMaxValue(new BigDecimal("2.0")); s3.setWaterType("treated");
|
||||
standards.add(s3);
|
||||
|
||||
// 余氯 - 管网 0.05~2.0
|
||||
QualityStandard s3n = new QualityStandard();
|
||||
s3n.setParamName("residual_chlorine"); s3n.setMinValue(new BigDecimal("0.05"));
|
||||
s3n.setMaxValue(new BigDecimal("2.0")); s3n.setWaterType("network");
|
||||
standards.add(s3n);
|
||||
|
||||
// 色度 ≤15
|
||||
QualityStandard s4 = new QualityStandard();
|
||||
s4.setParamName("color"); s4.setMinValue(null); s4.setMaxValue(new BigDecimal("15"));
|
||||
s4.setWaterType("all");
|
||||
standards.add(s4);
|
||||
|
||||
// 嗅味 ≤2
|
||||
QualityStandard s5 = new QualityStandard();
|
||||
s5.setParamName("odor"); s5.setMinValue(null); s5.setMaxValue(new BigDecimal("2"));
|
||||
s5.setWaterType("all");
|
||||
standards.add(s5);
|
||||
|
||||
// 大肠杆菌 =0
|
||||
QualityStandard s6 = new QualityStandard();
|
||||
s6.setParamName("ecoli"); s6.setMinValue(null); s6.setMaxValue(BigDecimal.ZERO);
|
||||
s6.setWaterType("all");
|
||||
standards.add(s6);
|
||||
|
||||
// 菌落总数 ≤100
|
||||
QualityStandard s7 = new QualityStandard();
|
||||
s7.setParamName("colony_count"); s7.setMinValue(null); s7.setMaxValue(new BigDecimal("100"));
|
||||
s7.setWaterType("all");
|
||||
standards.add(s7);
|
||||
|
||||
return standards;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟合格判定逻辑 (不依赖 Spring 容器)
|
||||
*/
|
||||
private List<String> evaluateCompliance(QualityTestRecord record, List<QualityStandard> standards) {
|
||||
String waterType = record.getWaterType();
|
||||
if (waterType == null) waterType = "treated";
|
||||
|
||||
List<String> unqualified = new ArrayList<>();
|
||||
String wt = waterType;
|
||||
|
||||
checkParam("turbidity", record.getTurbidity(), wt, standards, unqualified);
|
||||
checkParam("ph", record.getPh(), wt, standards, unqualified);
|
||||
checkParam("residual_chlorine", record.getResidualChlorine(), wt, standards, unqualified);
|
||||
checkParam("color", record.getColor(), wt, standards, unqualified);
|
||||
checkParam("odor", record.getOdor(), wt, standards, unqualified);
|
||||
checkParam("ecoli", record.getEcoli(), wt, standards, unqualified);
|
||||
checkParam("colony_count", record.getColonyCount(), wt, standards, unqualified);
|
||||
|
||||
return unqualified;
|
||||
}
|
||||
|
||||
private void checkParam(String paramName, BigDecimal value, String waterType,
|
||||
List<QualityStandard> standards, List<String> unqualified) {
|
||||
if (value == null) return;
|
||||
|
||||
QualityStandard standard = standards.stream()
|
||||
.filter(s -> paramName.equals(s.getParamName()))
|
||||
.filter(s -> waterType.equals(s.getWaterType()) || "all".equals(s.getWaterType()))
|
||||
.findFirst().orElse(null);
|
||||
|
||||
if (standard == null) return;
|
||||
|
||||
boolean isUnqualified = false;
|
||||
if (standard.getMinValue() != null && value.compareTo(standard.getMinValue()) < 0) {
|
||||
isUnqualified = true;
|
||||
}
|
||||
if (standard.getMaxValue() != null && value.compareTo(standard.getMaxValue()) > 0) {
|
||||
isUnqualified = true;
|
||||
}
|
||||
|
||||
if (isUnqualified) {
|
||||
unqualified.add("{\"param\":\"" + paramName + "\"}");
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDate calculateNextDate(LocalDate current, String frequency) {
|
||||
return switch (frequency) {
|
||||
case "daily" -> current.plusDays(1);
|
||||
case "weekly" -> current.plusWeeks(1);
|
||||
case "monthly" -> current.plusMonths(1);
|
||||
default -> current;
|
||||
};
|
||||
}
|
||||
|
||||
private List<QualityTestRecord> buildMockRecords() {
|
||||
List<QualityTestRecord> records = new ArrayList<>();
|
||||
|
||||
QualityTestRecord r1 = new QualityTestRecord();
|
||||
r1.setId(1L); r1.setWaterType("treated"); r1.setArea("一体化水厂");
|
||||
r1.setSamplingPoint("出厂水口"); r1.setComplianceStatus("qualified");
|
||||
r1.setTestDate(LocalDate.of(2026, 6, 14));
|
||||
records.add(r1);
|
||||
|
||||
QualityTestRecord r2 = new QualityTestRecord();
|
||||
r2.setId(2L); r2.setWaterType("treated"); r2.setArea("一体化水厂");
|
||||
r2.setSamplingPoint("出厂水口"); r2.setComplianceStatus("qualified");
|
||||
r2.setTestDate(LocalDate.of(2026, 6, 13));
|
||||
records.add(r2);
|
||||
|
||||
QualityTestRecord r3 = new QualityTestRecord();
|
||||
r3.setId(3L); r3.setWaterType("network"); r3.setArea("管网一区");
|
||||
r3.setSamplingPoint("末梢点A"); r3.setComplianceStatus("unqualified");
|
||||
r3.setTestDate(LocalDate.of(2026, 6, 14));
|
||||
records.add(r3);
|
||||
|
||||
QualityTestRecord r4 = new QualityTestRecord();
|
||||
r4.setId(4L); r4.setWaterType("treated"); r4.setArea("二水厂");
|
||||
r4.setSamplingPoint("出厂水口"); r4.setComplianceStatus("qualified");
|
||||
r4.setTestDate(LocalDate.of(2026, 6, 12));
|
||||
records.add(r4);
|
||||
|
||||
QualityTestRecord r5 = new QualityTestRecord();
|
||||
r5.setId(5L); r5.setWaterType("network"); r5.setArea("管网一区");
|
||||
r5.setSamplingPoint("末梢点B"); r5.setComplianceStatus("unqualified");
|
||||
r5.setTestDate(LocalDate.of(2026, 6, 11));
|
||||
records.add(r5);
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
private String formatTestType(String type) {
|
||||
if (type == null) return "";
|
||||
return switch (type) {
|
||||
case "routine" -> "常规检测";
|
||||
case "special" -> "专项检测";
|
||||
case "complaint" -> "投诉检测";
|
||||
default -> type;
|
||||
};
|
||||
}
|
||||
|
||||
private String formatWaterType(String type) {
|
||||
if (type == null) return "";
|
||||
return switch (type) {
|
||||
case "raw" -> "原水";
|
||||
case "treated" -> "出厂水";
|
||||
case "network" -> "管网末梢水";
|
||||
default -> type;
|
||||
};
|
||||
}
|
||||
|
||||
private String formatCompliance(String status) {
|
||||
if (status == null) return "待判定";
|
||||
return switch (status) {
|
||||
case "qualified" -> "合格";
|
||||
case "unqualified" -> "不合格";
|
||||
case "pending" -> "待判定";
|
||||
default -> status;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.water.revenue.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.water.common.core.result.R;
|
||||
import com.water.revenue.entity.Announcement;
|
||||
import com.water.revenue.entity.KbArticle;
|
||||
import com.water.revenue.entity.KpiDashboard;
|
||||
import com.water.revenue.service.AnnouncementService;
|
||||
import com.water.revenue.service.KnowledgeBaseService;
|
||||
import com.water.revenue.service.KpiService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 客服支撑模块 Controller
|
||||
* 包含:知识库管理、公告管理、KPI看板
|
||||
*/
|
||||
@Tag(name = "客服支撑")
|
||||
@RestController
|
||||
@RequestMapping("/api/revenue/cs")
|
||||
@RequiredArgsConstructor
|
||||
public class CsSupportController {
|
||||
|
||||
private final KnowledgeBaseService knowledgeBaseService;
|
||||
private final AnnouncementService announcementService;
|
||||
private final KpiService kpiService;
|
||||
|
||||
// ==================== 知识库 ====================
|
||||
|
||||
@Operation(summary = "知识库分页搜索")
|
||||
@GetMapping("/kb/list")
|
||||
public R<Page<KbArticle>> kbList(
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String category,
|
||||
@RequestParam(required = false) Integer status) {
|
||||
return R.ok(knowledgeBaseService.search(page, size, keyword, category, status));
|
||||
}
|
||||
|
||||
@Operation(summary = "知识库文章详情")
|
||||
@GetMapping("/kb/{id}")
|
||||
public R<KbArticle> kbDetail(@PathVariable Long id) {
|
||||
return R.ok(knowledgeBaseService.getDetail(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "创建知识库文章")
|
||||
@PostMapping("/kb")
|
||||
public R<KbArticle> kbCreate(@RequestBody KbArticle article) {
|
||||
return R.ok(knowledgeBaseService.create(article));
|
||||
}
|
||||
|
||||
@Operation(summary = "更新知识库文章")
|
||||
@PutMapping("/kb/{id}")
|
||||
public R<String> kbUpdate(@PathVariable Long id, @RequestBody KbArticle article) {
|
||||
knowledgeBaseService.update(id, article);
|
||||
return R.ok("更新成功");
|
||||
}
|
||||
|
||||
@Operation(summary = "删除知识库文章")
|
||||
@DeleteMapping("/kb/{id}")
|
||||
public R<String> kbDelete(@PathVariable Long id) {
|
||||
knowledgeBaseService.delete(id);
|
||||
return R.ok("删除成功");
|
||||
}
|
||||
|
||||
@Operation(summary = "点赞文章")
|
||||
@PostMapping("/kb/{id}/like")
|
||||
public R<String> kbLike(@PathVariable Long id) {
|
||||
knowledgeBaseService.like(id);
|
||||
return R.ok("点赞成功");
|
||||
}
|
||||
|
||||
@Operation(summary = "获取分类列表")
|
||||
@GetMapping("/kb/categories")
|
||||
public R<List<Map<String, Object>>> kbCategories() {
|
||||
return R.ok(knowledgeBaseService.getCategories());
|
||||
}
|
||||
|
||||
@Operation(summary = "获取热门文章")
|
||||
@GetMapping("/kb/hot")
|
||||
public R<List<KbArticle>> kbHot(@RequestParam(defaultValue = "10") int limit) {
|
||||
return R.ok(knowledgeBaseService.getHot(limit));
|
||||
}
|
||||
|
||||
// ==================== 公告管理 ====================
|
||||
|
||||
@Operation(summary = "公告分页列表")
|
||||
@GetMapping("/announcement/list")
|
||||
public R<Page<Announcement>> announcementList(
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(required = false) String type,
|
||||
@RequestParam(required = false) Integer status,
|
||||
@RequestParam(required = false) String keyword) {
|
||||
return R.ok(announcementService.list(page, size, type, status, keyword));
|
||||
}
|
||||
|
||||
@Operation(summary = "公告详情")
|
||||
@GetMapping("/announcement/{id}")
|
||||
public R<Announcement> announcementDetail(@PathVariable Long id) {
|
||||
return R.ok(announcementService.getDetail(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "创建公告")
|
||||
@PostMapping("/announcement")
|
||||
public R<Announcement> announcementCreate(@RequestBody Announcement announcement) {
|
||||
return R.ok(announcementService.create(announcement));
|
||||
}
|
||||
|
||||
@Operation(summary = "更新公告")
|
||||
@PutMapping("/announcement/{id}")
|
||||
public R<String> announcementUpdate(@PathVariable Long id, @RequestBody Announcement announcement) {
|
||||
announcementService.update(id, announcement);
|
||||
return R.ok("更新成功");
|
||||
}
|
||||
|
||||
@Operation(summary = "发布公告")
|
||||
@PostMapping("/announcement/{id}/publish")
|
||||
public R<String> announcementPublish(@PathVariable Long id) {
|
||||
announcementService.publish(id);
|
||||
return R.ok("发布成功");
|
||||
}
|
||||
|
||||
@Operation(summary = "撤回公告")
|
||||
@PostMapping("/announcement/{id}/withdraw")
|
||||
public R<String> announcementWithdraw(@PathVariable Long id) {
|
||||
announcementService.withdraw(id);
|
||||
return R.ok("撤回成功");
|
||||
}
|
||||
|
||||
@Operation(summary = "删除公告")
|
||||
@DeleteMapping("/announcement/{id}")
|
||||
public R<String> announcementDelete(@PathVariable Long id) {
|
||||
announcementService.delete(id);
|
||||
return R.ok("删除成功");
|
||||
}
|
||||
|
||||
@Operation(summary = "获取当前生效公告")
|
||||
@GetMapping("/announcement/active")
|
||||
public R<List<Announcement>> activeAnnouncements(
|
||||
@RequestParam(required = false) String areaCode) {
|
||||
return R.ok(announcementService.getActiveAnnouncements(areaCode));
|
||||
}
|
||||
|
||||
@Operation(summary = "公告类型统计")
|
||||
@GetMapping("/announcement/stats")
|
||||
public R<List<Map<String, Object>>> announcementStats() {
|
||||
return R.ok(announcementService.statsByType());
|
||||
}
|
||||
|
||||
// ==================== KPI 看板 ====================
|
||||
|
||||
@Operation(summary = "获取KPI看板数据")
|
||||
@GetMapping("/kpi/dashboard")
|
||||
public R<KpiDashboard> kpiDashboard() {
|
||||
return R.ok(kpiService.getDashboard());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.water.revenue.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 公告实体(停水/水质/维修等)
|
||||
*/
|
||||
@Data
|
||||
@TableName("cs_announcement")
|
||||
public class Announcement {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 公告标题 */
|
||||
private String title;
|
||||
|
||||
/** 公告内容 */
|
||||
private String content;
|
||||
|
||||
/** 公告类型:water_outage-停水 water_quality-水质 maintenance-维修 other-其他 */
|
||||
private String type;
|
||||
|
||||
/** 影响范围描述 */
|
||||
private String affectedArea;
|
||||
|
||||
/** 影响区域编码(用于推送匹配) */
|
||||
private String areaCode;
|
||||
|
||||
/** 计划开始时间 */
|
||||
private LocalDateTime plannedStart;
|
||||
|
||||
/** 计划结束时间 */
|
||||
private LocalDateTime plannedEnd;
|
||||
|
||||
/** 发布时间 */
|
||||
private LocalDateTime publishTime;
|
||||
|
||||
/** 状态:0-草稿 1-已发布 2-已撤回 */
|
||||
private Integer status;
|
||||
|
||||
/** 优先级:low/medium/high/urgent */
|
||||
private String priority;
|
||||
|
||||
/** 发布人ID */
|
||||
private Long publisherId;
|
||||
|
||||
/** 发布人名称 */
|
||||
private String publisherName;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.water.revenue.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 知识库文章实体
|
||||
*/
|
||||
@Data
|
||||
@TableName("cs_kb_article")
|
||||
public class KbArticle {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 文章标题 */
|
||||
private String title;
|
||||
|
||||
/** 文章内容(Markdown) */
|
||||
private String content;
|
||||
|
||||
/** 摘要/简介 */
|
||||
private String summary;
|
||||
|
||||
/** 分类:FAQ/政策法规/操作指南/常见问题/通知公告 */
|
||||
private String category;
|
||||
|
||||
/** 标签,逗号分隔 */
|
||||
private String tags;
|
||||
|
||||
/** 浏览量 */
|
||||
private Integer viewCount;
|
||||
|
||||
/** 点赞数 */
|
||||
private Integer likeCount;
|
||||
|
||||
/** 排序权重 */
|
||||
private Integer sortOrder;
|
||||
|
||||
/** 状态:0-草稿 1-已发布 2-已归档 */
|
||||
private Integer status;
|
||||
|
||||
/** 作者ID */
|
||||
private Long authorId;
|
||||
|
||||
/** 作者名称 */
|
||||
private String authorName;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.water.revenue.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* KPI看板 VO(非持久化,聚合计算结果)
|
||||
*/
|
||||
@Data
|
||||
public class KpiDashboard {
|
||||
|
||||
/** 待处理工单量 */
|
||||
private Integer pendingWorkOrders;
|
||||
|
||||
/** 今日新增工单 */
|
||||
private Integer todayNewWorkOrders;
|
||||
|
||||
/** 本月解决工单数 */
|
||||
private Integer monthResolvedCount;
|
||||
|
||||
/** 本月新增工单数 */
|
||||
private Integer monthTotalCount;
|
||||
|
||||
/** 本月解决率 */
|
||||
private BigDecimal monthResolveRate;
|
||||
|
||||
/** 平均处理时效(小时) */
|
||||
private BigDecimal avgProcessHours;
|
||||
|
||||
/** 客户满意率(百分比) */
|
||||
private BigDecimal satisfactionRate;
|
||||
|
||||
/** 今日投诉数 */
|
||||
private Integer todayComplaints;
|
||||
|
||||
/** 今日报装数 */
|
||||
private Integer todayInstallations;
|
||||
|
||||
/** 7日工单趋势(日期->数量) */
|
||||
private List<Map<String, Object>> weeklyTrend;
|
||||
|
||||
/** 工单类型分布 */
|
||||
private List<Map<String, Object>> typeDistribution;
|
||||
|
||||
/** 处理时效排行(部门/人员) */
|
||||
private List<Map<String, Object>> efficiencyRank;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.water.revenue.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.water.revenue.entity.Announcement;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface AnnouncementMapper extends BaseMapper<Announcement> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.water.revenue.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.water.revenue.entity.KbArticle;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface KbArticleMapper extends BaseMapper<KbArticle> {
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.water.revenue.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.water.revenue.entity.Announcement;
|
||||
import com.water.revenue.mapper.AnnouncementMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AnnouncementService {
|
||||
|
||||
private final AnnouncementMapper announcementMapper;
|
||||
|
||||
/**
|
||||
* 分页查询公告
|
||||
*/
|
||||
public Page<Announcement> list(int page, int size, String type, Integer status, String keyword) {
|
||||
LambdaQueryWrapper<Announcement> qw = new LambdaQueryWrapper<>();
|
||||
if (type != null && !type.isEmpty()) {
|
||||
qw.eq(Announcement::getType, type);
|
||||
}
|
||||
if (status != null) {
|
||||
qw.eq(Announcement::getStatus, status);
|
||||
}
|
||||
if (keyword != null && !keyword.isEmpty()) {
|
||||
qw.and(w -> w.like(Announcement::getTitle, keyword)
|
||||
.or().like(Announcement::getContent, keyword)
|
||||
.or().like(Announcement::getAffectedArea, keyword));
|
||||
}
|
||||
qw.orderByDesc(Announcement::getCreatedAt);
|
||||
return announcementMapper.selectPage(new Page<>(page, size), qw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公告详情
|
||||
*/
|
||||
public Announcement getDetail(Long id) {
|
||||
return announcementMapper.selectById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建公告(草稿)
|
||||
*/
|
||||
public Announcement create(Announcement announcement) {
|
||||
if (announcement.getStatus() == null) {
|
||||
announcement.setStatus(0); // 草稿
|
||||
}
|
||||
announcementMapper.insert(announcement);
|
||||
log.info("Announcement created: id={}, title={}, type={}",
|
||||
announcement.getId(), announcement.getTitle(), announcement.getType());
|
||||
return announcement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新公告
|
||||
*/
|
||||
public void update(Long id, Announcement announcement) {
|
||||
announcement.setId(id);
|
||||
announcementMapper.updateById(announcement);
|
||||
log.info("Announcement updated: id={}", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布公告
|
||||
*/
|
||||
public void publish(Long id) {
|
||||
Announcement a = new Announcement();
|
||||
a.setId(id);
|
||||
a.setStatus(1);
|
||||
a.setPublishTime(LocalDateTime.now());
|
||||
announcementMapper.updateById(a);
|
||||
log.info("Announcement published: id={}", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤回公告
|
||||
*/
|
||||
public void withdraw(Long id) {
|
||||
Announcement a = new Announcement();
|
||||
a.setId(id);
|
||||
a.setStatus(2);
|
||||
announcementMapper.updateById(a);
|
||||
log.info("Announcement withdrawn: id={}", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除公告(逻辑删除)
|
||||
*/
|
||||
public void delete(Long id) {
|
||||
announcementMapper.deleteById(id);
|
||||
log.info("Announcement deleted: id={}", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前生效的公告(已发布且未过期)
|
||||
*/
|
||||
public List<Announcement> getActiveAnnouncements(String areaCode) {
|
||||
LambdaQueryWrapper<Announcement> qw = new LambdaQueryWrapper<>();
|
||||
qw.eq(Announcement::getStatus, 1);
|
||||
if (areaCode != null && !areaCode.isEmpty()) {
|
||||
qw.and(w -> w.like(Announcement::getAreaCode, areaCode)
|
||||
.or().isNull(Announcement::getAreaCode)
|
||||
.or().eq(Announcement::getAreaCode, ""));
|
||||
}
|
||||
qw.orderByDesc(Announcement::getPriority);
|
||||
qw.orderByDesc(Announcement::getPublishTime);
|
||||
return announcementMapper.selectList(qw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型统计公告数量
|
||||
*/
|
||||
public List<Map<String, Object>> statsByType() {
|
||||
LambdaQueryWrapper<Announcement> qw = new LambdaQueryWrapper<>();
|
||||
qw.select(Announcement::getType);
|
||||
qw.eq(Announcement::getStatus, 1);
|
||||
List<Announcement> list = announcementMapper.selectList(qw);
|
||||
Map<String, Long> countMap = new LinkedHashMap<>();
|
||||
for (Announcement a : list) {
|
||||
countMap.put(a.getType(), countMap.getOrDefault(a.getType(), 0L) + 1);
|
||||
}
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
countMap.forEach((type, count) -> {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("type", type);
|
||||
m.put("count", count);
|
||||
result.add(m);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.water.revenue.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.water.revenue.entity.KbArticle;
|
||||
import com.water.revenue.mapper.KbArticleMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KnowledgeBaseService {
|
||||
|
||||
private final KbArticleMapper kbArticleMapper;
|
||||
|
||||
/**
|
||||
* 分页搜索知识库文章
|
||||
*/
|
||||
public Page<KbArticle> search(int page, int size, String keyword, String category, Integer status) {
|
||||
LambdaQueryWrapper<KbArticle> qw = new LambdaQueryWrapper<>();
|
||||
if (keyword != null && !keyword.isEmpty()) {
|
||||
qw.and(w -> w.like(KbArticle::getTitle, keyword)
|
||||
.or().like(KbArticle::getContent, keyword)
|
||||
.or().like(KbArticle::getTags, keyword));
|
||||
}
|
||||
if (category != null && !category.isEmpty()) {
|
||||
qw.eq(KbArticle::getCategory, category);
|
||||
}
|
||||
if (status != null) {
|
||||
qw.eq(KbArticle::getStatus, status);
|
||||
}
|
||||
qw.orderByDesc(KbArticle::getSortOrder).orderByDesc(KbArticle::getCreatedAt);
|
||||
return kbArticleMapper.selectPage(new Page<>(page, size), qw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文章详情并增加浏览量
|
||||
*/
|
||||
public KbArticle getDetail(Long id) {
|
||||
KbArticle article = kbArticleMapper.selectById(id);
|
||||
if (article != null) {
|
||||
// 浏览量+1
|
||||
LambdaUpdateWrapper<KbArticle> uw = new LambdaUpdateWrapper<>();
|
||||
uw.eq(KbArticle::getId, id)
|
||||
.setSql("view_count = COALESCE(view_count, 0) + 1");
|
||||
kbArticleMapper.update(null, uw);
|
||||
article.setViewCount(article.getViewCount() == null ? 1 : article.getViewCount() + 1);
|
||||
}
|
||||
return article;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文章
|
||||
*/
|
||||
public KbArticle create(KbArticle article) {
|
||||
if (article.getViewCount() == null) article.setViewCount(0);
|
||||
if (article.getLikeCount() == null) article.setLikeCount(0);
|
||||
if (article.getSortOrder() == null) article.setSortOrder(0);
|
||||
if (article.getStatus() == null) article.setStatus(0);
|
||||
kbArticleMapper.insert(article);
|
||||
log.info("KB article created: id={}, title={}", article.getId(), article.getTitle());
|
||||
return article;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文章
|
||||
*/
|
||||
public void update(Long id, KbArticle article) {
|
||||
article.setId(id);
|
||||
kbArticleMapper.updateById(article);
|
||||
log.info("KB article updated: id={}", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文章(逻辑删除)
|
||||
*/
|
||||
public void delete(Long id) {
|
||||
kbArticleMapper.deleteById(id);
|
||||
log.info("KB article deleted: id={}", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 点赞文章
|
||||
*/
|
||||
public void like(Long id) {
|
||||
LambdaUpdateWrapper<KbArticle> uw = new LambdaUpdateWrapper<>();
|
||||
uw.eq(KbArticle::getId, id)
|
||||
.setSql("like_count = COALESCE(like_count, 0) + 1");
|
||||
kbArticleMapper.update(null, uw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有分类及文章数
|
||||
*/
|
||||
public List<Map<String, Object>> getCategories() {
|
||||
LambdaQueryWrapper<KbArticle> qw = new LambdaQueryWrapper<>();
|
||||
qw.select(KbArticle::getCategory);
|
||||
qw.eq(KbArticle::getStatus, 1);
|
||||
List<KbArticle> articles = kbArticleMapper.selectList(qw);
|
||||
Map<String, Long> countMap = new LinkedHashMap<>();
|
||||
for (KbArticle a : articles) {
|
||||
String cat = a.getCategory();
|
||||
countMap.put(cat, countMap.getOrDefault(cat, 0L) + 1);
|
||||
}
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
countMap.forEach((cat, count) -> {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("category", cat);
|
||||
m.put("count", count);
|
||||
result.add(m);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取热门文章(按浏览量排序)
|
||||
*/
|
||||
public List<KbArticle> getHot(int limit) {
|
||||
LambdaQueryWrapper<KbArticle> qw = new LambdaQueryWrapper<>();
|
||||
qw.eq(KbArticle::getStatus, 1);
|
||||
qw.orderByDesc(KbArticle::getViewCount);
|
||||
qw.last("LIMIT " + limit);
|
||||
return kbArticleMapper.selectList(qw);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package com.water.revenue.service;
|
||||
|
||||
import com.water.revenue.entity.KpiDashboard;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KpiService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* 获取 KPI 看板数据(聚合多表计算)
|
||||
*/
|
||||
public KpiDashboard getDashboard() {
|
||||
KpiDashboard kpi = new KpiDashboard();
|
||||
|
||||
// 1. 待处理工单量
|
||||
kpi.setPendingWorkOrders(getPendingWorkOrders());
|
||||
|
||||
// 2. 今日新增工单
|
||||
kpi.setTodayNewWorkOrders(getTodayNewWorkOrders());
|
||||
|
||||
// 3. 本月工单统计
|
||||
calculateMonthlyStats(kpi);
|
||||
|
||||
// 4. 平均处理时效(小时)
|
||||
kpi.setAvgProcessHours(getAvgProcessHours());
|
||||
|
||||
// 5. 客户满意率
|
||||
kpi.setSatisfactionRate(getSatisfactionRate());
|
||||
|
||||
// 6. 今日投诉数
|
||||
kpi.setTodayComplaints(getTodayCount("complaint"));
|
||||
|
||||
// 7. 今日报装数
|
||||
kpi.setTodayInstallations(getTodayInstallations());
|
||||
|
||||
// 8. 7日工单趋势
|
||||
kpi.setWeeklyTrend(getWeeklyTrend());
|
||||
|
||||
// 9. 工单类型分布
|
||||
kpi.setTypeDistribution(getTypeDistribution());
|
||||
|
||||
// 10. 处理时效排行
|
||||
kpi.setEfficiencyRank(getEfficiencyRank());
|
||||
|
||||
return kpi;
|
||||
}
|
||||
|
||||
private Integer getPendingWorkOrders() {
|
||||
try {
|
||||
// 从 patrol_task 表获取待处理任务
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM patrol_task WHERE status IN ('pending', 'in_progress')",
|
||||
Integer.class);
|
||||
return count != null ? count : 0;
|
||||
} catch (Exception e) {
|
||||
log.warn("获取待处理工单失败: {}", e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private Integer getTodayNewWorkOrders() {
|
||||
try {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM patrol_task WHERE task_date = CURRENT_DATE",
|
||||
Integer.class);
|
||||
return count != null ? count : 0;
|
||||
} catch (Exception e) {
|
||||
log.warn("获取今日新增工单失败: {}", e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void calculateMonthlyStats(KpiDashboard kpi) {
|
||||
try {
|
||||
// 本月总工单数
|
||||
Integer total = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM patrol_task WHERE task_date >= DATE_TRUNC('month', CURRENT_DATE)",
|
||||
Integer.class);
|
||||
kpi.setMonthTotalCount(total != null ? total : 0);
|
||||
|
||||
// 本月已解决工单数
|
||||
Integer resolved = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM patrol_task WHERE status = 'completed' " +
|
||||
"AND actual_end >= DATE_TRUNC('month', CURRENT_DATE)",
|
||||
Integer.class);
|
||||
kpi.setMonthResolvedCount(resolved != null ? resolved : 0);
|
||||
|
||||
// 解决率
|
||||
if (total != null && total > 0 && resolved != null) {
|
||||
BigDecimal rate = BigDecimal.valueOf(resolved)
|
||||
.multiply(BigDecimal.valueOf(100))
|
||||
.divide(BigDecimal.valueOf(total), 1, RoundingMode.HALF_UP);
|
||||
kpi.setMonthResolveRate(rate);
|
||||
} else {
|
||||
kpi.setMonthResolveRate(BigDecimal.ZERO);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("获取月度统计失败: {}", e.getMessage());
|
||||
kpi.setMonthTotalCount(0);
|
||||
kpi.setMonthResolvedCount(0);
|
||||
kpi.setMonthResolveRate(BigDecimal.ZERO);
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal getAvgProcessHours() {
|
||||
try {
|
||||
// 计算已完成工单的平均处理时长
|
||||
Double avgHours = jdbcTemplate.queryForObject(
|
||||
"SELECT AVG(EXTRACT(EPOCH FROM (actual_end - task_date::timestamp)) / 3600.0) " +
|
||||
"FROM patrol_task WHERE status = 'completed' AND actual_end IS NOT NULL " +
|
||||
"AND actual_end >= DATE_TRUNC('month', CURRENT_DATE)",
|
||||
Double.class);
|
||||
return avgHours != null ?
|
||||
BigDecimal.valueOf(avgHours).setScale(1, RoundingMode.HALF_UP) :
|
||||
BigDecimal.ZERO;
|
||||
} catch (Exception e) {
|
||||
log.warn("获取平均处理时效失败: {}", e.getMessage());
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal getSatisfactionRate() {
|
||||
try {
|
||||
// 模拟从评价数据计算满意率(实际项目中应从评价表获取)
|
||||
// 这里使用已完成工单占比作为近似
|
||||
Double rate = jdbcTemplate.queryForObject(
|
||||
"SELECT CASE WHEN COUNT(*) = 0 THEN 0 " +
|
||||
"ELSE (COUNT(CASE WHEN status = 'completed' THEN 1 END) * 100.0 / COUNT(*)) END " +
|
||||
"FROM patrol_task WHERE task_date >= CURRENT_DATE - 30",
|
||||
Double.class);
|
||||
return rate != null ?
|
||||
BigDecimal.valueOf(rate).setScale(1, RoundingMode.HALF_UP) :
|
||||
BigDecimal.valueOf(85.0);
|
||||
} catch (Exception e) {
|
||||
log.warn("获取满意率失败: {}", e.getMessage());
|
||||
return BigDecimal.valueOf(85.0);
|
||||
}
|
||||
}
|
||||
|
||||
private Integer getTodayCount(String type) {
|
||||
try {
|
||||
// 根据类型从对应表获取今日数量
|
||||
String table = "complaint".equals(type) ? "patrol_task" : "patrol_task";
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM " + table + " WHERE task_date = CURRENT_DATE",
|
||||
Integer.class);
|
||||
return count != null ? count : 0;
|
||||
} catch (Exception e) {
|
||||
log.warn("获取今日{}数失败: {}", type, e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private Integer getTodayInstallations() {
|
||||
try {
|
||||
// 从报装相关表获取(这里用 patrol_task 近似)
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM patrol_task WHERE task_date = CURRENT_DATE",
|
||||
Integer.class);
|
||||
return count != null ? count : 0;
|
||||
} catch (Exception e) {
|
||||
log.warn("获取今日报装数失败: {}", e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> getWeeklyTrend() {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
try {
|
||||
LocalDate today = LocalDate.now();
|
||||
for (int i = 6; i >= 0; i--) {
|
||||
LocalDate date = today.minusDays(i);
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM patrol_task WHERE task_date = ?",
|
||||
Integer.class, date);
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("date", date.toString());
|
||||
m.put("count", count != null ? count : 0);
|
||||
result.add(m);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("获取7日趋势失败: {}", e.getMessage());
|
||||
// 返回空数据占位
|
||||
for (int i = 6; i >= 0; i--) {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("date", LocalDate.now().minusDays(i).toString());
|
||||
m.put("count", 0);
|
||||
result.add(m);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> getTypeDistribution() {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
try {
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT status as type, COUNT(*) as count FROM patrol_task " +
|
||||
"WHERE task_date >= CURRENT_DATE - 30 GROUP BY status");
|
||||
result.addAll(rows);
|
||||
} catch (Exception e) {
|
||||
log.warn("获取类型分布失败: {}", e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> getEfficiencyRank() {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
try {
|
||||
// 按处理时效排行(这里简化为按完成数量排行)
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT COALESCE(assignee_id::text, 'unassigned') as name, " +
|
||||
"COUNT(*) as completed_count, " +
|
||||
"AVG(EXTRACT(EPOCH FROM (actual_end - task_date::timestamp)) / 3600.0) as avg_hours " +
|
||||
"FROM patrol_task WHERE status = 'completed' " +
|
||||
"AND actual_end >= DATE_TRUNC('month', CURRENT_DATE) " +
|
||||
"GROUP BY assignee_id ORDER BY avg_hours ASC LIMIT 10");
|
||||
result.addAll(rows);
|
||||
} catch (Exception e) {
|
||||
log.warn("获取时效排行失败: {}", e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
-- ============================================================
|
||||
-- V_cs_support.sql
|
||||
-- 客服支撑模块 DDL: 知识库 + 公告板
|
||||
-- ============================================================
|
||||
|
||||
-- 知识库文章表
|
||||
CREATE TABLE IF NOT EXISTS cs_kb_article (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT,
|
||||
summary VARCHAR(500),
|
||||
category VARCHAR(50) NOT NULL DEFAULT 'FAQ',
|
||||
tags VARCHAR(200),
|
||||
view_count INT NOT NULL DEFAULT 0,
|
||||
like_count INT NOT NULL DEFAULT 0,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
status SMALLINT NOT NULL DEFAULT 0, -- 0草稿 1已发布 2已归档
|
||||
author_id BIGINT,
|
||||
author_name VARCHAR(50),
|
||||
deleted SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE cs_kb_article IS '知识库文章表';
|
||||
COMMENT ON COLUMN cs_kb_article.category IS '分类: FAQ/政策法规/操作指南/常见问题/通知公告';
|
||||
COMMENT ON COLUMN cs_kb_article.status IS '状态: 0-草稿 1-已发布 2-已归档';
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_article_category ON cs_kb_article (category);
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_article_status ON cs_kb_article (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_article_title ON cs_kb_article USING gin (title gin_trgm_ops);
|
||||
|
||||
-- 公告表
|
||||
CREATE TABLE IF NOT EXISTS cs_announcement (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT,
|
||||
type VARCHAR(30) NOT NULL DEFAULT 'other',
|
||||
affected_area VARCHAR(500),
|
||||
area_code VARCHAR(100),
|
||||
planned_start TIMESTAMP,
|
||||
planned_end TIMESTAMP,
|
||||
publish_time TIMESTAMP,
|
||||
status SMALLINT NOT NULL DEFAULT 0, -- 0草稿 1已发布 2已撤回
|
||||
priority VARCHAR(20) NOT NULL DEFAULT 'medium',
|
||||
publisher_id BIGINT,
|
||||
publisher_name VARCHAR(50),
|
||||
deleted SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE cs_announcement IS '公告表(停水/水质/维修等)';
|
||||
COMMENT ON COLUMN cs_announcement.type IS '类型: water_outage-停水 water_quality-水质 maintenance-维修 other-其他';
|
||||
COMMENT ON COLUMN cs_announcement.status IS '状态: 0-草稿 1-已发布 2-已撤回';
|
||||
COMMENT ON COLUMN cs_announcement.priority IS '优先级: low/medium/high/urgent';
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_announcement_type ON cs_announcement (type);
|
||||
CREATE INDEX IF NOT EXISTS idx_announcement_status ON cs_announcement (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_announcement_publish_time ON cs_announcement (publish_time);
|
||||
|
||||
-- 初始化数据:插入几条知识库示例文章
|
||||
INSERT INTO cs_kb_article (title, content, summary, category, tags, status, author_name) VALUES
|
||||
('如何办理用水报装?', '## 用水报装流程\n\n1. 准备材料:身份证、房产证、申请表\n2. 到营业厅提交申请\n3. 现场勘察\n4. 缴费\n5. 安装通水\n\n### 注意事项\n- 材料需原件+复印件\n- 3个工作日内完成勘察', '用水报装完整流程指南', '操作指南', '报装,申请,用水', 1, '系统管理员'),
|
||||
('水费缴费方式有哪些?', '## 缴费方式\n\n- **线上缴费**:微信公众号、支付宝、银行APP\n- **线下缴费**:营业厅、银行柜台\n- **代扣**:银行代扣(需签约)\n\n### 缴费时间\n每月1日-15日为正常缴费期', '水费缴费方式汇总', 'FAQ', '缴费,水费,支付', 1, '系统管理员'),
|
||||
('水质标准说明', '## 生活饮用水卫生标准\n\n执行GB5749-2006《生活饮用水卫生标准》\n\n### 常规检测指标\n- 浑浊度 ≤ 1 NTU\n- 余氯 ≥ 0.05mg/L\n- pH值 6.5-8.5', '国家水质标准介绍', '政策法规', '水质,标准,检测', 1, '系统管理员');
|
||||
|
||||
-- 插入公告示例
|
||||
INSERT INTO cs_announcement (title, content, type, affected_area, priority, status, publisher_name, publish_time) VALUES
|
||||
('城东片区计划停水通知', '因城东主干管维修,以下区域将于计划时间内停水:\n- 东湖路全线\n- 春晖小区\n- 东方花园\n\n请提前做好储水准备。', 'water_outage', '城东片区', 'high', 1, '系统管理员', NOW()),
|
||||
('水质检测报告公示', '2024年1月出厂水及管网水检测结果均符合GB5749-2006标准,合格率100%。', 'water_quality', '全城', 'medium', 1, '系统管理员', NOW());
|
||||
@@ -0,0 +1,245 @@
|
||||
package com.water.revenue;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.water.revenue.entity.Announcement;
|
||||
import com.water.revenue.entity.KbArticle;
|
||||
import com.water.revenue.entity.KpiDashboard;
|
||||
import com.water.revenue.mapper.AnnouncementMapper;
|
||||
import com.water.revenue.mapper.KbArticleMapper;
|
||||
import com.water.revenue.service.AnnouncementService;
|
||||
import com.water.revenue.service.KnowledgeBaseService;
|
||||
import com.water.revenue.service.KpiService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CsSupportServiceTest {
|
||||
|
||||
@Mock
|
||||
private KbArticleMapper kbArticleMapper;
|
||||
|
||||
@Mock
|
||||
private AnnouncementMapper announcementMapper;
|
||||
|
||||
@Mock
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
private KnowledgeBaseService knowledgeBaseService;
|
||||
private AnnouncementService announcementService;
|
||||
private KpiService kpiService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
knowledgeBaseService = new KnowledgeBaseService(kbArticleMapper);
|
||||
announcementService = new AnnouncementService(announcementMapper);
|
||||
kpiService = new KpiService(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("知识库服务测试")
|
||||
class KnowledgeBaseTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("搜索知识库 - 无过滤条件")
|
||||
void search_noFilters_returnsPage() {
|
||||
Page<KbArticle> mockPage = new Page<>(1, 10);
|
||||
mockPage.setRecords(List.of(createArticle(1L, "测试文章")));
|
||||
mockPage.setTotal(1);
|
||||
when(kbArticleMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(mockPage);
|
||||
|
||||
Page<KbArticle> result = knowledgeBaseService.search(1, 10, null, null, null);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getRecords().size());
|
||||
verify(kbArticleMapper).selectPage(any(Page.class), any(LambdaQueryWrapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("创建文章 - 设置默认值")
|
||||
void create_setsDefaults() {
|
||||
KbArticle article = new KbArticle();
|
||||
article.setTitle("新文章");
|
||||
article.setContent("内容");
|
||||
article.setCategory("FAQ");
|
||||
|
||||
when(kbArticleMapper.insert(any(KbArticle.class))).thenReturn(1);
|
||||
|
||||
KbArticle result = knowledgeBaseService.create(article);
|
||||
|
||||
assertEquals(0, result.getViewCount());
|
||||
assertEquals(0, result.getLikeCount());
|
||||
assertEquals(0, result.getSortOrder());
|
||||
assertEquals(0, result.getStatus());
|
||||
verify(kbArticleMapper).insert(article);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("获取详情 - 浏览量+1")
|
||||
void getDetail_incrementsViewCount() {
|
||||
KbArticle article = createArticle(1L, "测试");
|
||||
article.setViewCount(5);
|
||||
when(kbArticleMapper.selectById(1L)).thenReturn(article);
|
||||
when(kbArticleMapper.update(isNull(), any())).thenReturn(1);
|
||||
|
||||
KbArticle result = knowledgeBaseService.getDetail(1L);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(6, result.getViewCount());
|
||||
verify(kbArticleMapper).update(isNull(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("点赞文章")
|
||||
void like_incrementsLikeCount() {
|
||||
when(kbArticleMapper.update(isNull(), any())).thenReturn(1);
|
||||
|
||||
knowledgeBaseService.like(1L);
|
||||
|
||||
verify(kbArticleMapper).update(isNull(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("删除文章")
|
||||
void delete_callsMapper() {
|
||||
when(kbArticleMapper.deleteById(1L)).thenReturn(1);
|
||||
|
||||
knowledgeBaseService.delete(1L);
|
||||
|
||||
verify(kbArticleMapper).deleteById(1L);
|
||||
}
|
||||
|
||||
private KbArticle createArticle(Long id, String title) {
|
||||
KbArticle a = new KbArticle();
|
||||
a.setId(id);
|
||||
a.setTitle(title);
|
||||
a.setCategory("FAQ");
|
||||
a.setStatus(1);
|
||||
a.setViewCount(0);
|
||||
a.setLikeCount(0);
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("公告服务测试")
|
||||
class AnnouncementTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("创建公告 - 默认草稿状态")
|
||||
void create_defaultDraft() {
|
||||
Announcement a = new Announcement();
|
||||
a.setTitle("停水通知");
|
||||
a.setType("water_outage");
|
||||
|
||||
when(announcementMapper.insert(any(Announcement.class))).thenReturn(1);
|
||||
|
||||
Announcement result = announcementService.create(a);
|
||||
|
||||
assertEquals(0, result.getStatus());
|
||||
verify(announcementMapper).insert(a);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("发布公告 - 状态变为1")
|
||||
void publish_setsStatus1() {
|
||||
when(announcementMapper.updateById(any(Announcement.class))).thenReturn(1);
|
||||
|
||||
announcementService.publish(1L);
|
||||
|
||||
verify(announcementMapper).updateById(argThat(a ->
|
||||
a.getStatus() == 1 && a.getPublishTime() != null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("撤回公告 - 状态变为2")
|
||||
void withdraw_setsStatus2() {
|
||||
when(announcementMapper.updateById(any(Announcement.class))).thenReturn(1);
|
||||
|
||||
announcementService.withdraw(1L);
|
||||
|
||||
verify(announcementMapper).updateById(argThat(a -> a.getStatus() == 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("分页查询 - 按类型过滤")
|
||||
void list_filterByType() {
|
||||
Page<Announcement> mockPage = new Page<>(1, 10);
|
||||
mockPage.setRecords(List.of());
|
||||
when(announcementMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(mockPage);
|
||||
|
||||
Page<Announcement> result = announcementService.list(1, 10, "water_outage", null, null);
|
||||
|
||||
assertNotNull(result);
|
||||
verify(announcementMapper).selectPage(any(Page.class), any(LambdaQueryWrapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("删除公告")
|
||||
void delete_callsMapper() {
|
||||
when(announcementMapper.deleteById(1L)).thenReturn(1);
|
||||
|
||||
announcementService.delete(1L);
|
||||
|
||||
verify(announcementMapper).deleteById(1L);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("KPI 服务测试")
|
||||
class KpiTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("获取看板数据 - 数据库正常")
|
||||
void getDashboard_normalData() {
|
||||
when(jdbcTemplate.queryForObject(contains("pending"), eq(Integer.class))).thenReturn(5);
|
||||
when(jdbcTemplate.queryForObject(contains("CURRENT_DATE"), eq(Integer.class))).thenReturn(3);
|
||||
when(jdbcTemplate.queryForObject(contains("DATE_TRUNC"), eq(Integer.class))).thenReturn(20);
|
||||
when(jdbcTemplate.queryForObject(contains("status = 'completed'"), eq(Integer.class))).thenReturn(15);
|
||||
when(jdbcTemplate.queryForObject(contains("AVG"), eq(Double.class))).thenReturn(4.5);
|
||||
when(jdbcTemplate.queryForObject(contains("CASE WHEN"), eq(Double.class))).thenReturn(85.0);
|
||||
when(jdbcTemplate.queryForList(contains("GROUP BY status"))).thenReturn(List.of());
|
||||
when(jdbcTemplate.queryForList(contains("ORDER BY avg_hours"))).thenReturn(List.of());
|
||||
|
||||
KpiDashboard kpi = kpiService.getDashboard();
|
||||
|
||||
assertNotNull(kpi);
|
||||
assertEquals(5, kpi.getPendingWorkOrders());
|
||||
assertEquals(3, kpi.getTodayNewWorkOrders());
|
||||
assertNotNull(kpi.getWeeklyTrend());
|
||||
assertEquals(7, kpi.getWeeklyTrend().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("获取看板数据 - 数据库异常返回默认值")
|
||||
void getDashboard_dbError_returnsDefaults() {
|
||||
when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class)))
|
||||
.thenThrow(new RuntimeException("DB error"));
|
||||
when(jdbcTemplate.queryForObject(anyString(), eq(Double.class)))
|
||||
.thenThrow(new RuntimeException("DB error"));
|
||||
when(jdbcTemplate.queryForList(anyString()))
|
||||
.thenThrow(new RuntimeException("DB error"));
|
||||
|
||||
KpiDashboard kpi = kpiService.getDashboard();
|
||||
|
||||
assertNotNull(kpi);
|
||||
assertEquals(0, kpi.getPendingWorkOrders());
|
||||
assertNotNull(kpi.getWeeklyTrend());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user