From b445b089c821e47dc96d0982911cf2e4c4a1b43c Mon Sep 17 00:00:00 2001 From: bot_dev2 Date: Sun, 14 Jun 2026 16:28:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(wm-revenue):=20#55=20=E5=AE=A2=E6=9C=8D?= =?UTF-8?q?=E7=9F=A5=E8=AF=86=E5=BA=93+=E5=85=AC=E5=91=8A=E6=9D=BF+KPI?= =?UTF-8?q?=E7=9C=8B=E6=9D=BF=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 路由 --- db/gis_ddl.sql | 151 +++++ db/quality_ledger_ddl.sql | 109 ++++ frontend/src/router/index.ts | 3 + frontend/src/views/cs/AnnouncementView.vue | 288 +++++++++ frontend/src/views/cs/KnowledgeBaseView.vue | 298 +++++++++ frontend/src/views/cs/KpiDashboardView.vue | 190 ++++++ wm-production/pom.xml | 3 + .../controller/QualityLedgerController.java | 263 ++++++++ .../production/dto/QualityQueryRequest.java | 49 ++ .../water/production/dto/QualityStatVO.java | 44 ++ .../production/entity/IntrusionEvent.java | 70 ++- .../production/entity/QualityStandard.java | 57 ++ .../production/entity/QualityTestPlan.java | 69 +++ .../production/entity/QualityTestRecord.java | 43 ++ .../mapper/QualityStandardMapper.java | 9 + .../mapper/QualityTestPlanMapper.java | 9 + .../mapper/QualityTestRecordMapper.java | 62 ++ .../service/QualityLedgerService.java | 415 +++++++++++++ .../service/QualityStandardService.java | 104 ++++ .../service/QualityTestPlanService.java | 147 +++++ .../main/resources/db/V4__quality_ledger.sql | 109 ++++ .../mapper/QualityTestRecordMapper.xml | 150 +++++ .../production/service/GisServiceTest.java | 386 ++++++++++++ .../service/QualityLedgerServiceTest.java | 586 ++++++++++++++++++ .../controller/CsSupportController.java | 164 +++++ .../water/revenue/entity/Announcement.java | 59 ++ .../com/water/revenue/entity/KbArticle.java | 57 ++ .../water/revenue/entity/KpiDashboard.java | 49 ++ .../revenue/mapper/AnnouncementMapper.java | 9 + .../water/revenue/mapper/KbArticleMapper.java | 9 + .../revenue/service/AnnouncementService.java | 138 +++++ .../revenue/service/KnowledgeBaseService.java | 130 ++++ .../com/water/revenue/service/KpiService.java | 237 +++++++ .../src/main/resources/db/V_cs_support.sql | 73 +++ .../water/revenue/CsSupportServiceTest.java | 245 ++++++++ 35 files changed, 4758 insertions(+), 26 deletions(-) create mode 100644 db/gis_ddl.sql create mode 100644 db/quality_ledger_ddl.sql create mode 100644 frontend/src/views/cs/AnnouncementView.vue create mode 100644 frontend/src/views/cs/KnowledgeBaseView.vue create mode 100644 frontend/src/views/cs/KpiDashboardView.vue create mode 100644 wm-production/src/main/java/com/water/production/controller/QualityLedgerController.java create mode 100644 wm-production/src/main/java/com/water/production/dto/QualityQueryRequest.java create mode 100644 wm-production/src/main/java/com/water/production/dto/QualityStatVO.java create mode 100644 wm-production/src/main/java/com/water/production/entity/QualityStandard.java create mode 100644 wm-production/src/main/java/com/water/production/entity/QualityTestPlan.java create mode 100644 wm-production/src/main/java/com/water/production/entity/QualityTestRecord.java create mode 100644 wm-production/src/main/java/com/water/production/mapper/QualityStandardMapper.java create mode 100644 wm-production/src/main/java/com/water/production/mapper/QualityTestPlanMapper.java create mode 100644 wm-production/src/main/java/com/water/production/mapper/QualityTestRecordMapper.java create mode 100644 wm-production/src/main/java/com/water/production/service/QualityLedgerService.java create mode 100644 wm-production/src/main/java/com/water/production/service/QualityStandardService.java create mode 100644 wm-production/src/main/java/com/water/production/service/QualityTestPlanService.java create mode 100644 wm-production/src/main/resources/db/V4__quality_ledger.sql create mode 100644 wm-production/src/main/resources/mapper/QualityTestRecordMapper.xml create mode 100644 wm-production/src/test/java/com/water/production/service/GisServiceTest.java create mode 100644 wm-production/src/test/java/com/water/production/service/QualityLedgerServiceTest.java create mode 100644 wm-revenue/src/main/java/com/water/revenue/controller/CsSupportController.java create mode 100644 wm-revenue/src/main/java/com/water/revenue/entity/Announcement.java create mode 100644 wm-revenue/src/main/java/com/water/revenue/entity/KbArticle.java create mode 100644 wm-revenue/src/main/java/com/water/revenue/entity/KpiDashboard.java create mode 100644 wm-revenue/src/main/java/com/water/revenue/mapper/AnnouncementMapper.java create mode 100644 wm-revenue/src/main/java/com/water/revenue/mapper/KbArticleMapper.java create mode 100644 wm-revenue/src/main/java/com/water/revenue/service/AnnouncementService.java create mode 100644 wm-revenue/src/main/java/com/water/revenue/service/KnowledgeBaseService.java create mode 100644 wm-revenue/src/main/java/com/water/revenue/service/KpiService.java create mode 100644 wm-revenue/src/main/resources/db/V_cs_support.sql create mode 100644 wm-revenue/src/test/java/com/water/revenue/CsSupportServiceTest.java diff --git a/db/gis_ddl.sql b/db/gis_ddl.sql new file mode 100644 index 00000000..9ce699c9 --- /dev/null +++ b/db/gis_ddl.sql @@ -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; diff --git a/db/quality_ledger_ddl.sql b/db/quality_ledger_ddl.sql new file mode 100644 index 00000000..8bc1b9c8 --- /dev/null +++ b/db/quality_ledger_ddl.sql @@ -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-已完成'; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 8c76219f..21180165 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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' } diff --git a/frontend/src/views/cs/AnnouncementView.vue b/frontend/src/views/cs/AnnouncementView.vue new file mode 100644 index 00000000..e19a1534 --- /dev/null +++ b/frontend/src/views/cs/AnnouncementView.vue @@ -0,0 +1,288 @@ + + + + + diff --git a/frontend/src/views/cs/KnowledgeBaseView.vue b/frontend/src/views/cs/KnowledgeBaseView.vue new file mode 100644 index 00000000..bf0d5ff0 --- /dev/null +++ b/frontend/src/views/cs/KnowledgeBaseView.vue @@ -0,0 +1,298 @@ + + + + + diff --git a/frontend/src/views/cs/KpiDashboardView.vue b/frontend/src/views/cs/KpiDashboardView.vue new file mode 100644 index 00000000..9b0c17a3 --- /dev/null +++ b/frontend/src/views/cs/KpiDashboardView.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/wm-production/pom.xml b/wm-production/pom.xml index 7c7b09d1..d1a8893a 100644 --- a/wm-production/pom.xml +++ b/wm-production/pom.xml @@ -12,5 +12,8 @@ com.baomidoumybatis-plus-spring-boot3-starter cn.dev33sa-token-spring-boot3-starter org.postgresqlpostgresql + com.alibabaeasyexcel + com.github.xiaoyminknife4j-openapi3-jakarta-spring-boot-starter + org.springframework.bootspring-boot-starter-testtest \ No newline at end of file diff --git a/wm-production/src/main/java/com/water/production/controller/QualityLedgerController.java b/wm-production/src/main/java/com/water/production/controller/QualityLedgerController.java new file mode 100644 index 00000000..1b69115e --- /dev/null +++ b/wm-production/src/main/java/com/water/production/controller/QualityLedgerController.java @@ -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> listRecords(QualityQueryRequest request) { + return R.ok(ledgerService.queryRecords(request)); + } + + // 2. 获取记录详情 + @Operation(summary = "获取检测记录详情") + @GetMapping("/records/{id}") + public R 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 createRecord(@RequestBody QualityTestRecord record) { + return R.ok(ledgerService.create(record)); + } + + // 4. 更新检测记录 + @Operation(summary = "更新检测记录 (重新合格判定)") + @PutMapping("/records/{id}") + public R updateRecord(@PathVariable Long id, @RequestBody QualityTestRecord record) { + record.setId(id); + ledgerService.update(record); + return R.ok("更新成功"); + } + + // 5. 删除检测记录 + @Operation(summary = "删除检测记录") + @DeleteMapping("/records/{id}") + public R deleteRecord(@PathVariable Long id) { + ledgerService.delete(id); + return R.ok("删除成功"); + } + + // 6. 批量删除 + @Operation(summary = "批量删除检测记录") + @DeleteMapping("/records/batch") + public R batchDeleteRecords(@RequestBody List ids) { + ledgerService.batchDelete(ids); + return R.ok("批量删除成功"); + } + + // 7. 重新判定合格状态 + @Operation(summary = "重新判定所有记录合格状态") + @PostMapping("/records/reevaluate") + public R> reevaluateRecords() { + int count = ledgerService.reevaluateAll(); + return R.ok(Map.of("processed", count)); + } + + // 8. 获取区域列表 + @Operation(summary = "获取所有检测区域") + @GetMapping("/areas") + public R> getAreas() { + return R.ok(ledgerService.getAreaList()); + } + + // 9. 获取采样点列表 + @Operation(summary = "获取所有采样点") + @GetMapping("/sampling-points") + public R> getSamplingPoints() { + return R.ok(ledgerService.getSamplingPointList()); + } + + // ==================== 水质标准管理 ==================== + + // 10. 获取所有启用的标准 + @Operation(summary = "获取启用中的水质标准列表") + @GetMapping("/standards") + public R> listStandards() { + return R.ok(standardService.listEnabled()); + } + + // 11. 获取全部标准 (含停用) + @Operation(summary = "获取所有水质标准 (含停用)") + @GetMapping("/standards/all") + public R> listAllStandards() { + return R.ok(standardService.listAll()); + } + + // 12. 按水样类型获取标准 + @Operation(summary = "按水样类型获取水质标准") + @GetMapping("/standards/water-type/{waterType}") + public R> listStandardsByWaterType(@PathVariable String waterType) { + return R.ok(standardService.listByWaterType(waterType)); + } + + // 13. 获取标准详情 + @Operation(summary = "获取水质标准详情") + @GetMapping("/standards/{id}") + public R 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 createStandard(@RequestBody QualityStandard standard) { + return R.ok(standardService.create(standard)); + } + + // 15. 更新标准 + @Operation(summary = "更新水质标准") + @PutMapping("/standards/{id}") + public R updateStandard(@PathVariable Long id, @RequestBody QualityStandard standard) { + standard.setId(id); + standardService.update(standard); + return R.ok("更新成功"); + } + + // 16. 删除标准 + @Operation(summary = "删除水质标准") + @DeleteMapping("/standards/{id}") + public R deleteStandard(@PathVariable Long id) { + standardService.delete(id); + return R.ok("删除成功"); + } + + // ==================== 检测计划管理 ==================== + + // 17. 分页查询检测计划 + @Operation(summary = "分页查询检测计划") + @GetMapping("/plans") + public R> 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 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 createPlan(@RequestBody QualityTestPlan plan) { + return R.ok(planService.create(plan)); + } + + // 20. 更新检测计划 + @Operation(summary = "更新检测计划") + @PutMapping("/plans/{id}") + public R updatePlan(@PathVariable Long id, @RequestBody QualityTestPlan plan) { + plan.setId(id); + planService.update(plan); + return R.ok("更新成功"); + } + + // 21. 删除检测计划 + @Operation(summary = "删除检测计划") + @DeleteMapping("/plans/{id}") + public R deletePlan(@PathVariable Long id) { + planService.delete(id); + return R.ok("删除成功"); + } + + // 22. 暂停/恢复计划 + @Operation(summary = "切换检测计划状态 (active/paused/completed)") + @PutMapping("/plans/{id}/status") + public R togglePlanStatus(@PathVariable Long id, @RequestParam String status) { + planService.toggleStatus(id, status); + return R.ok("状态已更新"); + } + + // 23. 获取到期计划 + @Operation(summary = "获取当前到期的检测计划") + @GetMapping("/plans/due") + public R> getDuePlans() { + return R.ok(planService.getDuePlans()); + } + + // 24. 标记计划已执行 + @Operation(summary = "标记检测计划已执行 (更新下次检测日期)") + @PostMapping("/plans/{id}/execute") + public R markPlanExecuted(@PathVariable Long id) { + planService.markExecuted(id); + return R.ok("已标记执行"); + } + + // ==================== 统计分析 ==================== + + // 25. 综合统计 + @Operation(summary = "水质检测统计分析 (合格率/趋势/指标分布)") + @GetMapping("/statistics") + public R 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 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); + } +} diff --git a/wm-production/src/main/java/com/water/production/dto/QualityQueryRequest.java b/wm-production/src/main/java/com/water/production/dto/QualityQueryRequest.java new file mode 100644 index 00000000..ddb46aae --- /dev/null +++ b/wm-production/src/main/java/com/water/production/dto/QualityQueryRequest.java @@ -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; +} diff --git a/wm-production/src/main/java/com/water/production/dto/QualityStatVO.java b/wm-production/src/main/java/com/water/production/dto/QualityStatVO.java new file mode 100644 index 00000000..c0c3c3d9 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/dto/QualityStatVO.java @@ -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 rateByWaterType; + + /** 各区域合格率 */ + private Map rateByArea; + + /** 各指标不合格次数 */ + private Map unqualifiedByParam; + + /** 月度合格率趋势 [{month, rate}] */ + private List> monthlyTrend; + + /** 各指标均值统计 */ + private Map> paramAvgStats; +} diff --git a/wm-production/src/main/java/com/water/production/entity/IntrusionEvent.java b/wm-production/src/main/java/com/water/production/entity/IntrusionEvent.java index be8fe5c8..304b053b 100644 --- a/wm-production/src/main/java/com/water/production/entity/IntrusionEvent.java +++ b/wm-production/src/main/java/com/water/production/entity/IntrusionEvent.java @@ -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; } diff --git a/wm-production/src/main/java/com/water/production/entity/QualityStandard.java b/wm-production/src/main/java/com/water/production/entity/QualityStandard.java new file mode 100644 index 00000000..0cface9f --- /dev/null +++ b/wm-production/src/main/java/com/water/production/entity/QualityStandard.java @@ -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; +} diff --git a/wm-production/src/main/java/com/water/production/entity/QualityTestPlan.java b/wm-production/src/main/java/com/water/production/entity/QualityTestPlan.java new file mode 100644 index 00000000..07b52e04 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/entity/QualityTestPlan.java @@ -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; +} diff --git a/wm-production/src/main/java/com/water/production/entity/QualityTestRecord.java b/wm-production/src/main/java/com/water/production/entity/QualityTestRecord.java new file mode 100644 index 00000000..33357004 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/entity/QualityTestRecord.java @@ -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; +} diff --git a/wm-production/src/main/java/com/water/production/mapper/QualityStandardMapper.java b/wm-production/src/main/java/com/water/production/mapper/QualityStandardMapper.java new file mode 100644 index 00000000..82c301ab --- /dev/null +++ b/wm-production/src/main/java/com/water/production/mapper/QualityStandardMapper.java @@ -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 { +} diff --git a/wm-production/src/main/java/com/water/production/mapper/QualityTestPlanMapper.java b/wm-production/src/main/java/com/water/production/mapper/QualityTestPlanMapper.java new file mode 100644 index 00000000..d513cb25 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/mapper/QualityTestPlanMapper.java @@ -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 { +} diff --git a/wm-production/src/main/java/com/water/production/mapper/QualityTestRecordMapper.java b/wm-production/src/main/java/com/water/production/mapper/QualityTestRecordMapper.java new file mode 100644 index 00000000..c371d286 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/mapper/QualityTestRecordMapper.java @@ -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 { + + /** 分页查询台账 (支持多维度筛选) */ + List> 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> statByComplianceStatus(@Param("startDate") String startDate, + @Param("endDate") String endDate); + + /** 按水样类型统计合格率 */ + List> statRateByWaterType(@Param("startDate") String startDate, + @Param("endDate") String endDate); + + /** 按区域统计合格率 */ + List> statRateByArea(@Param("startDate") String startDate, + @Param("endDate") String endDate); + + /** 按指标统计不合格次数 */ + List> statUnqualifiedByParam(@Param("startDate") String startDate, + @Param("endDate") String endDate); + + /** 月度合格率趋势 */ + List> statMonthlyTrend(@Param("startDate") String startDate, + @Param("endDate") String endDate); + + /** 各指标均值统计 */ + List> statParamAvg(); +} diff --git a/wm-production/src/main/java/com/water/production/service/QualityLedgerService.java b/wm-production/src/main/java/com/water/production/service/QualityLedgerService.java new file mode 100644 index 00000000..24b44eef --- /dev/null +++ b/wm-production/src/main/java/com/water/production/service/QualityLedgerService.java @@ -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 queryRecords(QualityQueryRequest request) { + int offset = (request.getPageNum() - 1) * request.getPageSize(); + List> 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 ids) { + recordMapper.deleteBatchIds(ids); + } + + // ========== 合格判定 ========== + + /** + * 根据 GB5749-2022 自动判定水质是否合格 + * 比对所有检测参数与标准值,任何一项超标即为不合格 + */ + public void evaluateCompliance(QualityTestRecord record) { + String waterType = record.getWaterType(); + if (waterType == null) waterType = "treated"; + + List 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 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 items) { + return "[" + String.join(",", items) + "]"; + } + + /** + * 手动重新判定所有记录 + */ + public int reevaluateAll() { + List records = recordMapper.selectList( + new LambdaQueryWrapper().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> statusStats = recordMapper.statByComplianceStatus(startDate, endDate); + long total = 0, qualified = 0, unqualified = 0, pending = 0; + for (Map 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> waterTypeStats = recordMapper.statRateByWaterType(startDate, endDate); + Map rateByWaterType = new LinkedHashMap<>(); + for (Map 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> areaStats = recordMapper.statRateByArea(startDate, endDate); + Map rateByArea = new LinkedHashMap<>(); + for (Map 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> unqStats = recordMapper.statUnqualifiedByParam(startDate, endDate); + Map unqByParam = new LinkedHashMap<>(); + for (Map row : unqStats) { + unqByParam.put((String) row.get("paramName"), ((Number) row.get("count")).longValue()); + } + stat.setUnqualifiedByParam(unqByParam); + + // 月度趋势 + List> trend = recordMapper.statMonthlyTrend(startDate, endDate); + stat.setMonthlyTrend(trend); + + // 各指标均值 + List> 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> 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> head = buildExportHead(); + List> 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> buildExportHead() { + List> 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> buildExportData(List> records) { + List> data = new ArrayList<>(); + for (Map r : records) { + List 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 getAreaList() { + List records = recordMapper.selectList( + new LambdaQueryWrapper() + .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 getSamplingPointList() { + List records = recordMapper.selectList( + new LambdaQueryWrapper() + .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 listByIds(List ids) { + return recordMapper.selectBatchIds(ids); + } +} diff --git a/wm-production/src/main/java/com/water/production/service/QualityStandardService.java b/wm-production/src/main/java/com/water/production/service/QualityStandardService.java new file mode 100644 index 00000000..d0521dec --- /dev/null +++ b/wm-production/src/main/java/com/water/production/service/QualityStandardService.java @@ -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 listEnabled() { + return standardMapper.selectList( + new LambdaQueryWrapper() + .eq(QualityStandard::getEnabled, 1) + .orderByAsc(QualityStandard::getParamName) + ); + } + + /** + * 按水样类型获取启用的标准 + */ + public List listByWaterType(String waterType) { + return standardMapper.selectList( + new LambdaQueryWrapper() + .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 listAll() { + return standardMapper.selectList( + new LambdaQueryWrapper() + .orderByAsc(QualityStandard::getStandardCode) + .orderByAsc(QualityStandard::getParamName) + ); + } + + /** + * 根据水样类型和参数名获取标准 + */ + public QualityStandard getStandard(String waterType, String paramName) { + return standardMapper.selectOne( + new LambdaQueryWrapper() + .eq(QualityStandard::getEnabled, 1) + .eq(QualityStandard::getParamName, paramName) + .and(w -> w.eq(QualityStandard::getWaterType, waterType) + .or().eq(QualityStandard::getWaterType, "all")) + .last("LIMIT 1") + ); + } +} diff --git a/wm-production/src/main/java/com/water/production/service/QualityTestPlanService.java b/wm-production/src/main/java/com/water/production/service/QualityTestPlanService.java new file mode 100644 index 00000000..462e85a7 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/service/QualityTestPlanService.java @@ -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 queryPlans(String status, String frequency, String waterType, + String keyword, int pageNum, int pageSize) { + LambdaQueryWrapper 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 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 getDuePlans() { + return planMapper.selectList( + new LambdaQueryWrapper() + .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); + } +} diff --git a/wm-production/src/main/resources/db/V4__quality_ledger.sql b/wm-production/src/main/resources/db/V4__quality_ledger.sql new file mode 100644 index 00000000..8bc1b9c8 --- /dev/null +++ b/wm-production/src/main/resources/db/V4__quality_ledger.sql @@ -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-已完成'; diff --git a/wm-production/src/main/resources/mapper/QualityTestRecordMapper.xml b/wm-production/src/main/resources/mapper/QualityTestRecordMapper.xml new file mode 100644 index 00000000..323be17a --- /dev/null +++ b/wm-production/src/main/resources/mapper/QualityTestRecordMapper.xml @@ -0,0 +1,150 @@ + + + + + + + WHERE r.deleted = 0 + AND r.test_type = #{testType} + AND r.water_type = #{waterType} + AND r.area = #{area} + + AND r.sampling_point LIKE '%' || #{samplingPoint} || '%' + + + AND r.tester LIKE '%' || #{tester} || '%' + + + AND r.compliance_status = #{complianceStatus} + + AND r.test_date >= #{startDate}::date + AND r.test_date <= #{endDate}::date + + AND (r.sampling_point LIKE '%' || #{keyword} || '%' + OR r.tester LIKE '%' || #{keyword} || '%' + OR r.area LIKE '%' || #{keyword} || '%' + OR r.remark LIKE '%' || #{keyword} || '%') + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wm-production/src/test/java/com/water/production/service/GisServiceTest.java b/wm-production/src/test/java/com/water/production/service/GisServiceTest.java new file mode 100644 index 00000000..117a8538 --- /dev/null +++ b/wm-production/src/test/java/com/water/production/service/GisServiceTest.java @@ -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 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 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 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 allPoints = buildMockPoints(); + List> results = new ArrayList<>(); + for (GisPoint p : allPoints) { + double distance = haversineDistance(centerLat, centerLng, + p.getLat().doubleValue(), p.getLng().doubleValue()); + if (distance <= radiusMeters) { + Map 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 allPoints = buildMockPoints(); + BigDecimal gridSize = new BigDecimal("0.01"); + + Map> 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> heatmapData = new ArrayList<>(); + for (Map.Entry> entry : gridMap.entrySet()) { + Map 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 allPoints = buildMockPoints(); + Map 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 allPoints = buildMockPoints(); + Map 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 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 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 allPoints = buildMockPoints(); + Set 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 buildMockPoints() { + List 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 buildMockPipelines() { + List 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; + } +} diff --git a/wm-production/src/test/java/com/water/production/service/QualityLedgerServiceTest.java b/wm-production/src/test/java/com/water/production/service/QualityLedgerServiceTest.java new file mode 100644 index 00000000..6ddb4d75 --- /dev/null +++ b/wm-production/src/test/java/com/water/production/service/QualityLedgerServiceTest.java @@ -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 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 unqualified = evaluateCompliance(record, standards); + assertTrue(unqualified.isEmpty(), "所有指标在标准范围内应合格"); + } + + @Test + @DisplayName("GB5749-2022 合格判定逻辑 - 多项超标") + void testComplianceMultipleUnqualified() { + List 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 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 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 unqualified = evaluateCompliance(record, standards); + assertTrue(unqualified.isEmpty(), + "管网末梢水浊度2.0应合格(标准≤3.0),余氯0.1应合格(标准≥0.05)"); + } + + @Test + @DisplayName("合格判定 - null 值不参与判定") + void testComplianceNullValuesSkipped() { + List standards = buildDefaultStandards(); + + QualityTestRecord record = new QualityTestRecord(); + record.setWaterType("treated"); + record.setTurbidity(new BigDecimal("0.5")); + // 其他参数为 null + record.setPh(null); + record.setResidualChlorine(null); + + List 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 rateByWaterType = new LinkedHashMap<>(); + rateByWaterType.put("treated", new BigDecimal("97.5")); + rateByWaterType.put("network", new BigDecimal("92.3")); + stat.setRateByWaterType(rateByWaterType); + + Map unqByParam = new LinkedHashMap<>(); + unqByParam.put("turbidity", 5L); + unqByParam.put("residual_chlorine", 3L); + stat.setUnqualifiedByParam(unqByParam); + + List> 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 records = buildMockRecords(); + + // 按水样类型筛选 + List 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 plans = List.of(duePlan, futurePlan, expiredPlan); + + // 筛选到期计划: nextTestDate <= today AND (endDate IS NULL OR endDate >= today) + List 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 buildDefaultStandards() { + List 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 evaluateCompliance(QualityTestRecord record, List standards) { + String waterType = record.getWaterType(); + if (waterType == null) waterType = "treated"; + + List 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 standards, List 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 buildMockRecords() { + List 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; + }; + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/controller/CsSupportController.java b/wm-revenue/src/main/java/com/water/revenue/controller/CsSupportController.java new file mode 100644 index 00000000..ad055775 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/controller/CsSupportController.java @@ -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> 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 kbDetail(@PathVariable Long id) { + return R.ok(knowledgeBaseService.getDetail(id)); + } + + @Operation(summary = "创建知识库文章") + @PostMapping("/kb") + public R kbCreate(@RequestBody KbArticle article) { + return R.ok(knowledgeBaseService.create(article)); + } + + @Operation(summary = "更新知识库文章") + @PutMapping("/kb/{id}") + public R kbUpdate(@PathVariable Long id, @RequestBody KbArticle article) { + knowledgeBaseService.update(id, article); + return R.ok("更新成功"); + } + + @Operation(summary = "删除知识库文章") + @DeleteMapping("/kb/{id}") + public R kbDelete(@PathVariable Long id) { + knowledgeBaseService.delete(id); + return R.ok("删除成功"); + } + + @Operation(summary = "点赞文章") + @PostMapping("/kb/{id}/like") + public R kbLike(@PathVariable Long id) { + knowledgeBaseService.like(id); + return R.ok("点赞成功"); + } + + @Operation(summary = "获取分类列表") + @GetMapping("/kb/categories") + public R>> kbCategories() { + return R.ok(knowledgeBaseService.getCategories()); + } + + @Operation(summary = "获取热门文章") + @GetMapping("/kb/hot") + public R> kbHot(@RequestParam(defaultValue = "10") int limit) { + return R.ok(knowledgeBaseService.getHot(limit)); + } + + // ==================== 公告管理 ==================== + + @Operation(summary = "公告分页列表") + @GetMapping("/announcement/list") + public R> 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 announcementDetail(@PathVariable Long id) { + return R.ok(announcementService.getDetail(id)); + } + + @Operation(summary = "创建公告") + @PostMapping("/announcement") + public R announcementCreate(@RequestBody Announcement announcement) { + return R.ok(announcementService.create(announcement)); + } + + @Operation(summary = "更新公告") + @PutMapping("/announcement/{id}") + public R announcementUpdate(@PathVariable Long id, @RequestBody Announcement announcement) { + announcementService.update(id, announcement); + return R.ok("更新成功"); + } + + @Operation(summary = "发布公告") + @PostMapping("/announcement/{id}/publish") + public R announcementPublish(@PathVariable Long id) { + announcementService.publish(id); + return R.ok("发布成功"); + } + + @Operation(summary = "撤回公告") + @PostMapping("/announcement/{id}/withdraw") + public R announcementWithdraw(@PathVariable Long id) { + announcementService.withdraw(id); + return R.ok("撤回成功"); + } + + @Operation(summary = "删除公告") + @DeleteMapping("/announcement/{id}") + public R announcementDelete(@PathVariable Long id) { + announcementService.delete(id); + return R.ok("删除成功"); + } + + @Operation(summary = "获取当前生效公告") + @GetMapping("/announcement/active") + public R> activeAnnouncements( + @RequestParam(required = false) String areaCode) { + return R.ok(announcementService.getActiveAnnouncements(areaCode)); + } + + @Operation(summary = "公告类型统计") + @GetMapping("/announcement/stats") + public R>> announcementStats() { + return R.ok(announcementService.statsByType()); + } + + // ==================== KPI 看板 ==================== + + @Operation(summary = "获取KPI看板数据") + @GetMapping("/kpi/dashboard") + public R kpiDashboard() { + return R.ok(kpiService.getDashboard()); + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/entity/Announcement.java b/wm-revenue/src/main/java/com/water/revenue/entity/Announcement.java new file mode 100644 index 00000000..3c0ce76d --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/entity/Announcement.java @@ -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; +} diff --git a/wm-revenue/src/main/java/com/water/revenue/entity/KbArticle.java b/wm-revenue/src/main/java/com/water/revenue/entity/KbArticle.java new file mode 100644 index 00000000..93ed7a2f --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/entity/KbArticle.java @@ -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; +} diff --git a/wm-revenue/src/main/java/com/water/revenue/entity/KpiDashboard.java b/wm-revenue/src/main/java/com/water/revenue/entity/KpiDashboard.java new file mode 100644 index 00000000..7b9d1f63 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/entity/KpiDashboard.java @@ -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> weeklyTrend; + + /** 工单类型分布 */ + private List> typeDistribution; + + /** 处理时效排行(部门/人员) */ + private List> efficiencyRank; +} diff --git a/wm-revenue/src/main/java/com/water/revenue/mapper/AnnouncementMapper.java b/wm-revenue/src/main/java/com/water/revenue/mapper/AnnouncementMapper.java new file mode 100644 index 00000000..7f400ec3 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/mapper/AnnouncementMapper.java @@ -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 { +} diff --git a/wm-revenue/src/main/java/com/water/revenue/mapper/KbArticleMapper.java b/wm-revenue/src/main/java/com/water/revenue/mapper/KbArticleMapper.java new file mode 100644 index 00000000..709f78e0 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/mapper/KbArticleMapper.java @@ -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 { +} diff --git a/wm-revenue/src/main/java/com/water/revenue/service/AnnouncementService.java b/wm-revenue/src/main/java/com/water/revenue/service/AnnouncementService.java new file mode 100644 index 00000000..9bb6d98c --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/service/AnnouncementService.java @@ -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 list(int page, int size, String type, Integer status, String keyword) { + LambdaQueryWrapper 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 getActiveAnnouncements(String areaCode) { + LambdaQueryWrapper 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> statsByType() { + LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); + qw.select(Announcement::getType); + qw.eq(Announcement::getStatus, 1); + List list = announcementMapper.selectList(qw); + Map countMap = new LinkedHashMap<>(); + for (Announcement a : list) { + countMap.put(a.getType(), countMap.getOrDefault(a.getType(), 0L) + 1); + } + List> result = new ArrayList<>(); + countMap.forEach((type, count) -> { + Map m = new HashMap<>(); + m.put("type", type); + m.put("count", count); + result.add(m); + }); + return result; + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/service/KnowledgeBaseService.java b/wm-revenue/src/main/java/com/water/revenue/service/KnowledgeBaseService.java new file mode 100644 index 00000000..0dd43433 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/service/KnowledgeBaseService.java @@ -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 search(int page, int size, String keyword, String category, Integer status) { + LambdaQueryWrapper 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 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 uw = new LambdaUpdateWrapper<>(); + uw.eq(KbArticle::getId, id) + .setSql("like_count = COALESCE(like_count, 0) + 1"); + kbArticleMapper.update(null, uw); + } + + /** + * 获取所有分类及文章数 + */ + public List> getCategories() { + LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); + qw.select(KbArticle::getCategory); + qw.eq(KbArticle::getStatus, 1); + List articles = kbArticleMapper.selectList(qw); + Map countMap = new LinkedHashMap<>(); + for (KbArticle a : articles) { + String cat = a.getCategory(); + countMap.put(cat, countMap.getOrDefault(cat, 0L) + 1); + } + List> result = new ArrayList<>(); + countMap.forEach((cat, count) -> { + Map m = new HashMap<>(); + m.put("category", cat); + m.put("count", count); + result.add(m); + }); + return result; + } + + /** + * 获取热门文章(按浏览量排序) + */ + public List getHot(int limit) { + LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); + qw.eq(KbArticle::getStatus, 1); + qw.orderByDesc(KbArticle::getViewCount); + qw.last("LIMIT " + limit); + return kbArticleMapper.selectList(qw); + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/service/KpiService.java b/wm-revenue/src/main/java/com/water/revenue/service/KpiService.java new file mode 100644 index 00000000..56a64a32 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/service/KpiService.java @@ -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> getWeeklyTrend() { + List> 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 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 m = new HashMap<>(); + m.put("date", LocalDate.now().minusDays(i).toString()); + m.put("count", 0); + result.add(m); + } + } + return result; + } + + private List> getTypeDistribution() { + List> result = new ArrayList<>(); + try { + List> 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> getEfficiencyRank() { + List> result = new ArrayList<>(); + try { + // 按处理时效排行(这里简化为按完成数量排行) + List> 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; + } +} diff --git a/wm-revenue/src/main/resources/db/V_cs_support.sql b/wm-revenue/src/main/resources/db/V_cs_support.sql new file mode 100644 index 00000000..f1a1e06b --- /dev/null +++ b/wm-revenue/src/main/resources/db/V_cs_support.sql @@ -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()); diff --git a/wm-revenue/src/test/java/com/water/revenue/CsSupportServiceTest.java b/wm-revenue/src/test/java/com/water/revenue/CsSupportServiceTest.java new file mode 100644 index 00000000..f08b9635 --- /dev/null +++ b/wm-revenue/src/test/java/com/water/revenue/CsSupportServiceTest.java @@ -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 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 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 mockPage = new Page<>(1, 10); + mockPage.setRecords(List.of()); + when(announcementMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class))) + .thenReturn(mockPage); + + Page 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()); + } + } +}