21 Commits
Author SHA1 Message Date
bot_dev2 37bcc78eee Merge remote-tracking branch 'origin/feature/issue-56' 2026-06-15 08:43:30 +08:00
bot_dev2 2d0d80f6d1 feat(frontend+wm-revenue): #56 网上营业厅前端(水费/报装/公告/绑定)
- 前端页面: WaterBillView.vue, InstallApplyView.vue, NoticeView.vue, UserBindView.vue
- API 模块: wx-hall.ts (水费/报装/公告/绑定全部接口)
- 路由: 注册到 frontend/src/router/index.ts (wx-hall/*)
- 后端 Controller: WxHallApiController (12 端点, /api/wx-hall/*)
- DDL: V6__wx_hall_user_bindng.sql (用户绑定表)
2026-06-15 08:42:59 +08:00
bot_dev2 7cbc17c2c4 feat(wm-revenue): #54 客服工作台+水费查询+语音自助
- Entity: CsWorkItem, VoiceCallRecord
- DTO: CsWorkbenchStats, BillQueryResult, VoiceMenuResponse
- Mapper: CsWorkItemMapper, VoiceCallRecordMapper
- Service: CsWorkbenchService, BillQueryService, VoiceQueryService
- Controller: CsWorkbenchController(9端点), BillQueryController(5端点), VoiceController(7端点)
- DDL: V_cs_workbench.sql (2表+索引)
- Test: CsWorkbenchTest (10个测试用例)
2026-06-15 08:42:08 +08:00
bot_dev2 ba535c47e2 feat(wm-revenue): #53 水表全生命周期管理
- Entity: WaterMeter, MeterInstallRecord, MeterReplaceRecord, MeterLifecycleLog
- Enum: MeterStatus (IN_STOCK/INSTALLED/DISMANTLED/SCRAPPED/REPAIRING)
- Mapper: WaterMeterMapper, MeterInstallRecordMapper, MeterReplaceRecordMapper, MeterLifecycleLogMapper
- Service: MeterLifecycleService (入库/安装/查询), MeterReplaceService (换表/报废/审批), MeterStatsService (统计)
- Controller: MeterController (/api/revenue/meter/*), MeterReplaceController (/api/revenue/meter-replace/*), MeterStatsController (/api/revenue/meter-stats/*)
- DDL: V_meter_lifecycle.sql (4张表 + 索引)
- Test: MeterLifecycleTest (12个测试用例)
2026-06-15 08:37:38 +08:00
bot_dev2 db18fdbd98 Merge remote-tracking branch 'origin/feature/issue-48'
# Conflicts:
#	frontend/src/router/index.ts
#	wm-production/pom.xml
2026-06-15 08:33:46 +08:00
bot_dev2 611d553b92 feat(wm-revenue): #48 报装核心流程(预受理→工程申请→派单→竣工) 2026-06-15 08:33:07 +08:00
bot_dev2 24f719858a Merge branch 'feature/issue-30' 2026-06-14 18:23:52 +08:00
bot_dev2 9763a5700d feat(wm-iot): #30 设备影子服务+OTA固件升级完整实现
- entity: DeviceShadow, OtaFirmware, OtaTask, OtaUpgradeRecord
- service: DeviceShadowService (Redis Hash + TTL 24h, 上报/期望/delta/离线检测/批量查询)
- service: OtaService (固件管理/升级任务创建按批次/进度追踪/结果统计)
- controller: DeviceShadowController /api/iot/shadow/*
- controller: OtaController /api/iot/ota/*
- config: RedisConfig (RedisTemplate JSON序列化)
- SQL DDL: iot_ota_firmware, iot_ota_task, iot_ota_upgrade_record
- test: DeviceShadowServiceTest + OtaServiceTest (mock Redis + mock DB)
2026-06-14 18:22:28 +08:00
bot_pm f035fa274f docs: v2.0 详细设计规格书 (含全部模块DB/API/组件/数据流/Issue对照表) 2026-06-14 17:48:01 +08:00
bot_dev2 69fd9d7c41 feat(wm-revenue): #57 微信网厅(微信支付+AI客服+意图匹配)
- WxPayService: 统一下单/支付回调/退款/订单查询
- FaqService: FAQ CRUD/关键词搜索/热门推荐/分类查询
- IntentService: 意图识别(正则匹配)/批量解析/规则管理
- 3个Controller + 15+ API端点 (/api/revenue/wxpay|faq|intent/*)
- Entity: WxPayOrder, FaqItem, IntentRule
- DDL: rev_wx_pay_order/rev_faq_item/rev_intent_rule + 8个索引
- 8个单元测试
2026-06-14 16:43:00 +08:00
bot_dev2 8e1b9d8dd2 Merge remote-tracking branch 'origin/feature/issue-66'
# Conflicts:
#	wm-production/pom.xml
#	wm-production/src/main/java/com/water/production/controller/QualityLedgerController.java
#	wm-production/src/main/java/com/water/production/dto/QualityQueryRequest.java
#	wm-production/src/main/java/com/water/production/dto/QualityStatVO.java
#	wm-production/src/main/java/com/water/production/entity/QualityStandard.java
#	wm-production/src/main/java/com/water/production/entity/QualityTestPlan.java
#	wm-production/src/main/java/com/water/production/entity/QualityTestRecord.java
#	wm-production/src/main/java/com/water/production/mapper/QualityTestRecordMapper.java
#	wm-production/src/main/java/com/water/production/service/QualityLedgerService.java
#	wm-production/src/main/java/com/water/production/service/QualityStandardService.java
#	wm-production/src/main/java/com/water/production/service/QualityTestPlanService.java
#	wm-production/src/main/resources/db/V4__quality_ledger.sql
#	wm-production/src/main/resources/mapper/QualityTestRecordMapper.xml
#	wm-production/src/test/java/com/water/production/service/QualityLedgerServiceTest.java
2026-06-14 16:35:45 +08:00
bot_dev2 91740fb44c feat(wm-production): #66 水质检测台账管理
- 水质检测记录 CRUD(浊度/pH/余氯/色度/嗅味/大肠杆菌等)
- 根据 GB5749-2022 国标自动合格判定,支持自定义标准
- 检测计划管理(日检/周检/月检),自动计算下次检测日期
- 多维度台账查询(时间/区域/检测类型/合格状态)
- 统计分析(合格率趋势/各指标分布/不合格项追踪)
- Excel 导出检测报告
- 3个 Entity + 2个 DTO + 3个 Mapper(含XML) + 3个 Service + 1个 Controller(26端点)
- DDL 含3张表 + 索引 + GB5749-2022 默认标准数据
- 18个单元测试
2026-06-14 16:34:58 +08:00
bot_dev2 b445b089c8 feat(wm-revenue): #55 客服知识库+公告板+KPI看板完整实现
- Entity: KbArticle(知识库文章), Announcement(公告), KpiDashboard(KPI看板VO)
- Mapper: KbArticleMapper, AnnouncementMapper (MyBatis-Plus)
- Service: KnowledgeBaseService(知识库CRUD+搜索+分类+点赞+热门),
           AnnouncementService(公告发布/编辑/按类型筛选/按范围推送/撤回),
           KpiService(KPI聚合计算:待处理量/时效/满意率/趋势/排行)
- Controller: CsSupportController (/api/revenue/cs/*)
- SQL DDL: V_cs_support.sql (cs_kb_article + cs_announcement 表+示例数据)
- Frontend: KnowledgeBaseView.vue(列表/卡片/Markdown编辑器),
            AnnouncementView.vue(类型标签/状态切换/时间范围),
            KpiDashboardView.vue(ECharts趋势图+饼图+排行)
- Unit Test: CsSupportServiceTest (知识库/公告/KPI三组测试)
- Router: 新增 /cs/knowledge, /cs/announcement, /cs/kpi 路由
2026-06-14 16:28:05 +08:00
bot_dev2 98f91e5d74 feat(wm-production): #65 全工艺药剂投加监控 2026-06-14 16:24:11 +08:00
bot_dev2 fddf330ab2 feat(wm-production): #64 GIS地图展示后端服务
- GisService: 点位CRUD/空间查询(矩形+圆形)/管网数据/热力图/统计
- GisController: 11个API端点 (/api/production/gis/*)
- 支持流量/压力/液位/水质/阀门5类监测点位
- Haversine距离计算 + 网格聚合热力图
- DDL: prod_gis_point/pipeline/area + 6个索引
2026-06-14 16:17:31 +08:00
bot_dev2 dcb412c7e4 feat(wm-production): #63 视频监控集成与AI人员闯入检测
- VideoMonitorService: 摄像头CRUD/视频流管理/状态监控/录像回放/统计
- IntrusionDetectionService: AI闯入检测(模拟)/事件处理/分页查询/统计
- VideoController: 20+ API端点 (/api/production/video/*)
- DDL: prod_video_recording + prod_intrusion_event + 索引
2026-06-14 16:15:50 +08:00
bot_dev2 21cf0e97af feat(wm-production): #62 在线监测列表与多维筛选 2026-06-14 16:12:59 +08:00
bot_dev2 4a82e90fcb feat(wm-production): #61 总览大屏后端服务 2026-06-14 16:11:02 +08:00
bot_dev2 f240059d1e feat(wm-dma): #59 DMA分区计量与漏损分析
- 新增 wm-dma 模块
- Entity: DmaZone, DmaMeter, DmaFlowRecord, DmaLeakageAnalysis, WaterBalance
- Mapper + Service + Controller(18个端点)
- DMA分区管理: 分区层级定义(CRUD) + 区域划分 + 关联设备 + 树形结构
- 分区计量: 各分区进出水量汇总 + 最小夜间流量(MNF)分析 + 流量趋势
- 漏损分析: 产销差计算 + 漏损率评估 + 趋势分析 + 报警
- 水平衡表: 供水量/售水量/漏损量平衡分析 + IWA组成
- DDL: dma_ddl.sql
- 单元测试: 5个Service测试类(25+测试用例)
2026-06-14 16:06:49 +08:00
bot_dev2 08649f027e feat(wm-data-engine): #71 历史数据回溯与报表生成
- HistoryDataService: 水量/水质历史数据分页查询 + 导出
- ReportService: 日报/周报/月报/年报自动生成 + 发布 + 模板管理
- StatisticsService: 同比/环比/趋势分析 + 综合看板
- 5个Entity + 5个Mapper + 3个Service + 1个Controller(12+端点)
- DDL: 4张表 + 5个索引
- 单元测试: 3个测试类
2026-06-14 15:38:41 +08:00
bot_dev2 a26a626d21 feat(wm-dispatch): #70 应急推演(爆管模拟+水质异常+演练管理)
- 爆管模拟: 影响范围/用户/水量损失/修复时间/关阀方案
- 水质异常: 事件上报/严重度评估/预案匹配/响应流程/处置归档
- 应急演练: 计划创建/执行/完成/评估打分
- 4个Entity + 4个Mapper + 3个Service + 1个Controller(15端点)
- DDL: 4张表 + 4个索引
- 单元测试: 3个测试类
2026-06-14 15:38:22 +08:00
230 changed files with 24874 additions and 34 deletions
+89
View File
@@ -0,0 +1,89 @@
-- 药剂投加监控 DDL
-- 全工艺药剂投加监控(混凝→沉淀→过滤→消毒)
-- 1. 药剂投加记录表
CREATE TABLE IF NOT EXISTS prod_chemical_dosing (
id BIGSERIAL PRIMARY KEY,
process_stage VARCHAR(32) NOT NULL, -- 工艺段: coagulation/sedimentation/filtration/disinfection
chemical_name VARCHAR(64) NOT NULL, -- 药剂名称
chemical_code VARCHAR(32), -- 药剂编码
dosing_amount DECIMAL(12,4), -- 投加量(kg)
dosing_rate DECIMAL(10,4), -- 投加速率(kg/h)
concentration DECIMAL(10,4), -- 投加浓度(mg/L)
flow_rate DECIMAL(12,4), -- 当时流量(m³/h)
station VARCHAR(64), -- 站点/水厂
operator VARCHAR(32), -- 操作员
status VARCHAR(16) DEFAULT 'active', -- active/paused/stopped
remark VARCHAR(255),
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE prod_chemical_dosing IS '药剂投加监控记录';
COMMENT ON COLUMN prod_chemical_dosing.process_stage IS '工艺段: coagulation(混凝)/sedimentation(沉淀)/filtration(过滤)/disinfection(消毒)';
-- 2. 投加历史记录表
CREATE TABLE IF NOT EXISTS prod_dosing_record (
id BIGSERIAL PRIMARY KEY,
dosing_id BIGINT, -- 关联投加记录
process_stage VARCHAR(32) NOT NULL,
chemical_name VARCHAR(64) NOT NULL,
dosing_amount DECIMAL(12,4),
dosing_rate DECIMAL(10,4),
concentration DECIMAL(10,4),
flow_rate DECIMAL(12,4),
station VARCHAR(64),
record_time TIMESTAMP NOT NULL, -- 记录时间
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE prod_dosing_record IS '投加历史记录(用于趋势分析)';
-- 3. 药剂库存表
CREATE TABLE IF NOT EXISTS prod_chemical_stock (
id BIGSERIAL PRIMARY KEY,
chemical_name VARCHAR(64) NOT NULL,
chemical_code VARCHAR(32),
current_stock DECIMAL(12,4) NOT NULL, -- 当前库存(kg)
max_stock DECIMAL(12,4), -- 最大库存
min_stock DECIMAL(12,4), -- 安全库存(低于此值预警)
unit VARCHAR(16) DEFAULT 'kg',
warehouse VARCHAR(64), -- 仓库位置
supplier VARCHAR(128), -- 供应商
station VARCHAR(64),
status VARCHAR(16) DEFAULT 'normal', -- normal/low/out
last_inbound TIMESTAMP, -- 最近入库时间
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE prod_chemical_stock IS '药剂库存管理';
-- 4. 投加策略表
CREATE TABLE IF NOT EXISTS prod_dosing_strategy (
id BIGSERIAL PRIMARY KEY,
strategy_name VARCHAR(64) NOT NULL,
process_stage VARCHAR(32) NOT NULL,
chemical_name VARCHAR(64) NOT NULL,
strategy_type VARCHAR(32), -- auto/manual/semi-auto
base_dosing_rate DECIMAL(10,4), -- 基础投加速率
min_dosing_rate DECIMAL(10,4), -- 最小投加速率
max_dosing_rate DECIMAL(10,4), -- 最大投加速率
turbidity_threshold DECIMAL(10,4), -- 浊度阈值联动
flow_threshold DECIMAL(12,4), -- 流量阈值联动
ph_threshold_min DECIMAL(6,2), -- pH下限
ph_threshold_max DECIMAL(6,2), -- pH上限
formula VARCHAR(255), -- 投加公式
enabled BOOLEAN DEFAULT true,
station VARCHAR(64),
remark VARCHAR(255),
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE prod_dosing_strategy IS '自动投加策略配置(基于原水水质/流量联动)';
-- 索引
CREATE INDEX IF NOT EXISTS idx_dosing_stage ON prod_chemical_dosing(process_stage);
CREATE INDEX IF NOT EXISTS idx_dosing_station ON prod_chemical_dosing(station);
CREATE INDEX IF NOT EXISTS idx_dosing_created ON prod_chemical_dosing(created_time);
CREATE INDEX IF NOT EXISTS idx_record_stage ON prod_dosing_record(process_stage);
CREATE INDEX IF NOT EXISTS idx_record_time ON prod_dosing_record(record_time);
CREATE INDEX IF NOT EXISTS idx_stock_station ON prod_chemical_stock(station);
CREATE INDEX IF NOT EXISTS idx_strategy_stage ON prod_dosing_strategy(process_stage);
+136
View File
@@ -0,0 +1,136 @@
-- =====================================================
-- DMA分区计量与漏损分析 DDL
-- 数据库: PostgreSQL
-- =====================================================
-- DMA分区表
CREATE TABLE IF NOT EXISTS dma_zone (
id BIGSERIAL PRIMARY KEY,
zone_name VARCHAR(100) NOT NULL,
zone_code VARCHAR(50) NOT NULL UNIQUE,
parent_id BIGINT REFERENCES dma_zone(id),
zone_level INTEGER NOT NULL DEFAULT 1,
area VARCHAR(100),
area_size NUMERIC(10, 2),
population INTEGER,
pipe_length NUMERIC(10, 2),
status VARCHAR(20) DEFAULT 'active',
remark VARCHAR(500),
deleted INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE dma_zone IS 'DMA分区表';
COMMENT ON COLUMN dma_zone.zone_level IS '分区层级: 1=一级/2=二级/3=三级';
COMMENT ON COLUMN dma_zone.status IS '状态: active/inactive';
-- DMA计量表
CREATE TABLE IF NOT EXISTS dma_meter (
id BIGSERIAL PRIMARY KEY,
zone_id BIGINT REFERENCES dma_zone(id),
meter_code VARCHAR(50) NOT NULL UNIQUE,
meter_name VARCHAR(100),
meter_type VARCHAR(20) NOT NULL,
location VARCHAR(200),
longitude NUMERIC(12, 8),
latitude NUMERIC(12, 8),
caliber INTEGER,
brand VARCHAR(100),
status VARCHAR(20) DEFAULT 'online',
remark VARCHAR(500),
deleted INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE dma_meter IS 'DMA计量表';
COMMENT ON COLUMN dma_meter.meter_type IS '表计类型: inlet=进水表/outlet=出水表/boundary=边界表';
COMMENT ON COLUMN dma_meter.status IS '状态: online/offline/fault';
CREATE INDEX IF NOT EXISTS idx_meter_zone ON dma_meter(zone_id);
-- DMA流量记录表
CREATE TABLE IF NOT EXISTS dma_flow_record (
id BIGSERIAL PRIMARY KEY,
zone_id BIGINT NOT NULL REFERENCES dma_zone(id),
meter_id BIGINT NOT NULL REFERENCES dma_meter(id),
instant_flow NUMERIC(12, 4),
total_flow NUMERIC(14, 4),
pressure NUMERIC(8, 4),
collect_time TIMESTAMP NOT NULL,
data_quality VARCHAR(20) DEFAULT 'good',
deleted INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE dma_flow_record IS 'DMA流量记录表';
COMMENT ON COLUMN dma_flow_record.instant_flow IS '瞬时流量(m³/h)';
COMMENT ON COLUMN dma_flow_record.total_flow IS '累计流量(m³)';
COMMENT ON COLUMN dma_flow_record.pressure IS '压力(MPa)';
COMMENT ON COLUMN dma_flow_record.data_quality IS '数据质量: good/bad/missing';
CREATE INDEX IF NOT EXISTS idx_flow_zone_time ON dma_flow_record(zone_id, collect_time);
CREATE INDEX IF NOT EXISTS idx_flow_meter_time ON dma_flow_record(meter_id, collect_time);
-- DMA漏损分析表
CREATE TABLE IF NOT EXISTS dma_leakage_analysis (
id BIGSERIAL PRIMARY KEY,
zone_id BIGINT NOT NULL REFERENCES dma_zone(id),
analysis_date DATE NOT NULL,
supply_volume NUMERIC(14, 4),
sale_volume NUMERIC(14, 4),
leakage_volume NUMERIC(14, 4),
nrw_rate NUMERIC(8, 2),
leakage_rate NUMERIC(8, 2),
mnf NUMERIC(10, 4),
mnf_time VARCHAR(20),
background_leakage NUMERIC(10, 4),
burst_leakage NUMERIC(10, 4),
alarm_level VARCHAR(20) DEFAULT 'normal',
remark VARCHAR(500),
deleted INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE dma_leakage_analysis IS 'DMA漏损分析表';
COMMENT ON COLUMN dma_leakage_analysis.nrw_rate IS '产销差率(%)';
COMMENT ON COLUMN dma_leakage_analysis.leakage_rate IS '漏损率(%)';
COMMENT ON COLUMN dma_leakage_analysis.mnf IS '最小夜间流量(m³/h)';
COMMENT ON COLUMN dma_leakage_analysis.alarm_level IS '报警级别: normal/warning/critical';
CREATE INDEX IF NOT EXISTS idx_leakage_zone_date ON dma_leakage_analysis(zone_id, analysis_date);
CREATE UNIQUE INDEX IF NOT EXISTS uk_leakage_zone_date ON dma_leakage_analysis(zone_id, analysis_date) WHERE deleted = 0;
-- 水平衡表
CREATE TABLE IF NOT EXISTS dma_water_balance (
id BIGSERIAL PRIMARY KEY,
zone_id BIGINT NOT NULL REFERENCES dma_zone(id),
period VARCHAR(20) NOT NULL,
stat_date DATE NOT NULL,
total_supply NUMERIC(14, 4),
total_sale NUMERIC(14, 4),
billing_sale NUMERIC(14, 4),
free_supply NUMERIC(14, 4),
apparent_loss NUMERIC(14, 4),
real_loss NUMERIC(14, 4),
background_loss NUMERIC(14, 4),
burst_loss NUMERIC(14, 4),
total_loss NUMERIC(14, 4),
nrw_rate NUMERIC(8, 2),
leakage_rate NUMERIC(8, 2),
remark VARCHAR(500),
deleted INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE dma_water_balance IS '水平衡表';
COMMENT ON COLUMN dma_water_balance.period IS '统计周期: daily/monthly/yearly';
COMMENT ON COLUMN dma_water_balance.apparent_loss IS '表观漏损(m³) - 计量误差+偷水';
COMMENT ON COLUMN dma_water_balance.real_loss IS '实际漏损(m³) - 物理漏损';
CREATE INDEX IF NOT EXISTS idx_balance_zone_date ON dma_water_balance(zone_id, stat_date);
CREATE INDEX IF NOT EXISTS idx_balance_period ON dma_water_balance(period);
+151
View File
@@ -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;
+90
View File
@@ -0,0 +1,90 @@
-- =============================================
-- 智慧水务管理系统 - 在线监测列表 DDL
-- 版本: V3
-- 功能: 在线监测设备 + 实时数据 + 多维筛选
-- =============================================
-- ==================== 在线监测设备 ====================
CREATE TABLE IF NOT EXISTS prod_monitor_device (
id BIGSERIAL PRIMARY KEY,
device_code VARCHAR(50) NOT NULL UNIQUE, -- 设备编号
device_name VARCHAR(200) NOT NULL, -- 设备名称
device_type VARCHAR(30) NOT NULL, -- 设备类型: flow/pressure/level/quality
area VARCHAR(50) NOT NULL, -- 所属区域
location VARCHAR(300), -- 安装位置描述
lng DECIMAL(12, 8), -- 经度
lat DECIMAL(12, 8), -- 纬度
status VARCHAR(20) NOT NULL DEFAULT 'offline', -- 设备状态: online/offline/fault/abnormal
last_report_time TIMESTAMP, -- 最后上报时间
brand VARCHAR(100), -- 品牌/型号
install_time TIMESTAMP, -- 安装时间
remark VARCHAR(500), -- 备注
created_time TIMESTAMP DEFAULT NOW(),
updated_time TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE prod_monitor_device IS '在线监测设备表';
COMMENT ON COLUMN prod_monitor_device.device_type IS '设备类型: flow(流量计)/pressure(压力计)/level(液位计)/quality(水质仪)';
COMMENT ON COLUMN prod_monitor_device.status IS '设备状态: online(在线)/offline(离线)/fault(故障)/abnormal(数据异常)';
CREATE INDEX IF NOT EXISTS idx_monitor_device_area ON prod_monitor_device(area);
CREATE INDEX IF NOT EXISTS idx_monitor_device_type ON prod_monitor_device(device_type);
CREATE INDEX IF NOT EXISTS idx_monitor_device_status ON prod_monitor_device(status);
CREATE INDEX IF NOT EXISTS idx_monitor_device_report ON prod_monitor_device(last_report_time DESC);
CREATE INDEX IF NOT EXISTS idx_monitor_device_code_name ON prod_monitor_device(device_code, device_name);
-- ==================== 监测实时数据 ====================
CREATE TABLE IF NOT EXISTS prod_monitor_realtime_data (
id BIGSERIAL PRIMARY KEY,
device_id BIGINT NOT NULL REFERENCES prod_monitor_device(id), -- 关联设备
device_code VARCHAR(50) NOT NULL, -- 设备编号(冗余)
metric_key VARCHAR(50) NOT NULL, -- 参数类型: flow/pressure/level/turbidity/ph/residual_chlorine/temperature
metric_value DECIMAL(14, 4) NOT NULL, -- 实时值
unit VARCHAR(20), -- 单位
threshold_high DECIMAL(14, 4), -- 阈值上限
threshold_low DECIMAL(14, 4), -- 阈值下限
is_abnormal SMALLINT DEFAULT 0, -- 是否异常: 0正常 1异常
collect_time TIMESTAMP NOT NULL, -- 采集时间
created_time TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE prod_monitor_realtime_data IS '监测实时数据表';
COMMENT ON COLUMN prod_monitor_realtime_data.metric_key IS '参数类型: flow(流量)/pressure(压力)/level(液位)/turbidity(浊度)/ph(pH值)/residual_chlorine(余氯)/temperature(温度)';
CREATE INDEX IF NOT EXISTS idx_realtime_device_id ON prod_monitor_realtime_data(device_id);
CREATE INDEX IF NOT EXISTS idx_realtime_device_code ON prod_monitor_realtime_data(device_code);
CREATE INDEX IF NOT EXISTS idx_realtime_metric_key ON prod_monitor_realtime_data(metric_key);
CREATE INDEX IF NOT EXISTS idx_realtime_collect_time ON prod_monitor_realtime_data(collect_time DESC);
CREATE INDEX IF NOT EXISTS idx_realtime_device_metric ON prod_monitor_realtime_data(device_id, metric_key, collect_time DESC);
-- ==================== 初始化数据(示例) ====================
INSERT INTO prod_monitor_device (device_code, device_name, device_type, area, location, lng, lat, status, last_report_time, brand)
VALUES
('MON-FLOW-001', '一号泵站出口流量计', 'flow', '一体化水厂', '一号泵站出口', 82.07123456, 44.84567890, 'online', NOW(), 'E+H Promag 50'),
('MON-FLOW-002', '二号泵站出口流量计', 'flow', '一体化水厂', '二号泵站出口', 82.07234567, 44.84678901, 'online', NOW(), 'E+H Promag 50'),
('MON-PRES-001', '管网压力监测点A', 'pressure', '管网一区', '人民路DN300', 82.06890123, 44.84234567, 'online', NOW(), 'WIKA S-20'),
('MON-PRES-002', '管网压力监测点B', 'pressure', '管网一区', '建设路DN200', 82.06901234, 44.84345678, 'offline', NOW() - INTERVAL '2 hours', 'WIKA S-20'),
('MON-LEV-001', '清水池液位计', 'level', '一体化水厂', '清水池', 82.07156789, 44.84501234, 'online', NOW(), 'VEGA VEGAPULS 64'),
('MON-LEV-002', '沉淀池液位计', 'level', '一体化水厂', '沉淀池', 82.07167890, 44.84512345, 'fault', NOW() - INTERVAL '30 minutes', 'VEGA VEGAPULS 64'),
('MON-QUAL-001', '出厂水质监测仪', 'quality', '一体化水厂', '出厂水口', 82.07178901, 44.84523456, 'online', NOW(), 'HACH sc200'),
('MON-QUAL-002', '管网末梢水质仪', 'quality', '管网二区', '末梢检测点', 82.06543210, 44.83987654, 'abnormal',NOW(), 'HACH sc200'),
('MON-FLOW-003', '三号泵站流量计', 'flow', '管网二区', '三号泵站', 82.06654321, 44.84098765, 'online', NOW(), 'E+H Promag 10'),
('MON-PRES-003', '高位水池压力计', 'pressure', '管网三区', '高位水池出口', 82.07345678, 44.84789012, 'online', NOW(), 'WIKA S-20')
ON CONFLICT (device_code) DO NOTHING;
INSERT INTO prod_monitor_realtime_data (device_id, device_code, metric_key, metric_value, unit, threshold_high, threshold_low, is_abnormal, collect_time)
SELECT d.id, d.device_code, m.metric_key, m.metric_value, m.unit, m.threshold_high, m.threshold_low, m.is_abnormal, NOW()
FROM prod_monitor_device d
CROSS JOIN (VALUES
('flow', 125.50, 'm³/h', 200.0, 10.0, 0),
('pressure', 0.35, 'MPa', 0.6, 0.15, 0),
('level', 3.80, 'm', 5.0, 0.5, 0),
('turbidity', 0.45, 'NTU', 1.0, NULL, 0),
('ph', 7.20, '', 8.5, 6.5, 0),
('residual_chlorine', 0.35, 'mg/L', 0.8, 0.05, 0)
) AS m(metric_key, metric_value, unit, threshold_high, threshold_low, is_abnormal)
WHERE d.status = 'online'
ON CONFLICT DO NOTHING;
+122
View File
@@ -0,0 +1,122 @@
-- =============================================
-- 智慧水务管理系统 - 视频监控集成 + AI人员闯入检测 DDL
-- 版本: V3
-- =============================================
-- ==================== 视频监控摄像头 ====================
CREATE TABLE IF NOT EXISTS prod_video_camera (
id BIGSERIAL PRIMARY KEY,
camera_id VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
area VARCHAR(50),
stream_url_rtsp VARCHAR(500),
stream_url_hls VARCHAR(500),
stream_url_flv VARCHAR(500),
status INTEGER DEFAULT 0,
manufacturer VARCHAR(50),
model VARCHAR(50),
lng DOUBLE PRECISION,
lat DOUBLE PRECISION,
install_location VARCHAR(200),
install_date DATE,
last_online_time TIMESTAMP,
ai_enabled INTEGER DEFAULT 0,
remark VARCHAR(500),
created_time TIMESTAMP DEFAULT NOW(),
updated_time TIMESTAMP DEFAULT NOW(),
deleted INTEGER DEFAULT 0
);
COMMENT ON TABLE prod_video_camera IS '视频监控摄像头表';
COMMENT ON COLUMN prod_video_camera.camera_id IS '摄像头唯一编号';
COMMENT ON COLUMN prod_video_camera.status IS '状态: 0=离线, 1=在线, 2=故障';
COMMENT ON COLUMN prod_video_camera.ai_enabled IS '是否启用AI检测: 0=未启用, 1=已启用';
COMMENT ON COLUMN prod_video_camera.stream_url_rtsp IS 'RTSP视频流地址';
COMMENT ON COLUMN prod_video_camera.stream_url_hls IS 'HLS视频流地址';
COMMENT ON COLUMN prod_video_camera.stream_url_flv IS 'FLV视频流地址';
CREATE INDEX IF NOT EXISTS idx_video_camera_area ON prod_video_camera(area);
CREATE INDEX IF NOT EXISTS idx_video_camera_status ON prod_video_camera(status);
-- ==================== AI闯入检测事件 ====================
CREATE TABLE IF NOT EXISTS prod_intrusion_event (
id BIGSERIAL PRIMARY KEY,
camera_id BIGINT NOT NULL,
camera_name VARCHAR(100),
area VARCHAR(50),
event_type VARCHAR(30) NOT NULL,
confidence NUMERIC(6, 4),
snapshot_url VARCHAR(500),
video_clip_url VARCHAR(500),
alert_level VARCHAR(20),
alert_status INTEGER DEFAULT 0,
detected_at TIMESTAMP NOT NULL,
handle_result TEXT,
handled_by BIGINT,
handler_name VARCHAR(50),
handled_time TIMESTAMP,
alert_record_id BIGINT,
remark VARCHAR(500),
created_time TIMESTAMP DEFAULT NOW(),
updated_time TIMESTAMP DEFAULT NOW(),
deleted INTEGER DEFAULT 0
);
COMMENT ON TABLE prod_intrusion_event IS 'AI人员闯入检测事件表';
COMMENT ON COLUMN prod_intrusion_event.event_type IS '事件类型: person_intrusion=人员闯入, person_loitering=人员徘徊, zone_breach=区域越界';
COMMENT ON COLUMN prod_intrusion_event.confidence IS 'AI识别置信度(0~1)';
COMMENT ON COLUMN prod_intrusion_event.alert_level IS '报警等级: info, warning, critical';
COMMENT ON COLUMN prod_intrusion_event.alert_status IS '报警状态: 0=待处理, 1=已确认, 2=已处理, 3=已忽略';
CREATE INDEX IF NOT EXISTS idx_intrusion_camera ON prod_intrusion_event(camera_id);
CREATE INDEX IF NOT EXISTS idx_intrusion_area ON prod_intrusion_event(area);
CREATE INDEX IF NOT EXISTS idx_intrusion_detected_at ON prod_intrusion_event(detected_at DESC);
CREATE INDEX IF NOT EXISTS idx_intrusion_alert_status ON prod_intrusion_event(alert_status);
-- ==================== 视频录像记录 ====================
CREATE TABLE IF NOT EXISTS prod_video_recording (
id BIGSERIAL PRIMARY KEY,
camera_id BIGINT NOT NULL,
camera_name VARCHAR(100),
area VARCHAR(50),
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP,
duration_sec INTEGER,
file_size_mb NUMERIC(10, 2),
storage_path VARCHAR(500),
playback_url VARCHAR(500),
record_type VARCHAR(20) NOT NULL,
event_id BIGINT,
remark VARCHAR(500),
created_time TIMESTAMP DEFAULT NOW(),
updated_time TIMESTAMP DEFAULT NOW(),
deleted INTEGER DEFAULT 0
);
COMMENT ON TABLE prod_video_recording IS '视频录像记录表';
COMMENT ON COLUMN prod_video_recording.record_type IS '录像类型: scheduled=计划录像, event_triggered=事件触发, manual=手动录像';
COMMENT ON COLUMN prod_video_recording.event_id IS '关联闯入事件ID(事件触发时有值)';
CREATE INDEX IF NOT EXISTS idx_recording_camera ON prod_video_recording(camera_id);
CREATE INDEX IF NOT EXISTS idx_recording_start_time ON prod_video_recording(start_time DESC);
CREATE INDEX IF NOT EXISTS idx_recording_record_type ON prod_video_recording(record_type);
CREATE INDEX IF NOT EXISTS idx_recording_event ON prod_video_recording(event_id);
-- ==================== 初始化测试数据 ====================
INSERT INTO prod_video_camera (camera_id, name, area, stream_url_rtsp, stream_url_hls, stream_url_flv,
status, manufacturer, model, lng, lat, install_location, install_date, ai_enabled, last_online_time)
VALUES
('CAM-001', '一体化水厂-沉淀池', '一体化水厂', 'rtsp://192.168.1.100/stream1', 'http://192.168.1.100/hls/stream1.m3u8', 'http://192.168.1.100/flv/stream1.flv',
1, '海康威视', 'DS-2CD2T26FWDA3-IS', 87.5712, 43.7928, '一体化水厂沉淀池北侧', '2024-03-15', 1, NOW()),
('CAM-002', '一体化水厂-清水池', '一体化水厂', 'rtsp://192.168.1.101/stream1', 'http://192.168.1.101/hls/stream1.m3u8', 'http://192.168.1.101/flv/stream1.flv',
1, '海康威视', 'DS-2CD2T26FWDA3-IS', 87.5715, 43.7930, '一体化水厂清水池入口', '2024-03-15', 1, NOW()),
('CAM-003', '查村调压站-入口', '八家户片区', 'rtsp://192.168.1.102/stream1', 'http://192.168.1.102/hls/stream1.m3u8', 'http://192.168.1.102/flv/stream1.flv',
1, '大华', 'DH-IPC-HFW5442T-ASE', 87.5680, 43.7890, '查村调压站大门', '2024-04-10', 1, NOW()),
('CAM-004', '精芒片区-管网节点1', '精芒片区', 'rtsp://192.168.1.103/stream1', 'http://192.168.1.103/hls/stream1.m3u8', 'http://192.168.1.103/flv/stream1.flv',
0, '大华', 'DH-IPC-HFW5442T-ASE', 87.5650, 43.7860, '精芒片区管网节点井', '2024-05-20', 0, '2025-06-10 08:30:00'),
('CAM-005', '八家户泵站-机房', '八家户片区', 'rtsp://192.168.1.104/stream1', 'http://192.168.1.104/hls/stream1.m3u8', 'http://192.168.1.104/flv/stream1.flv',
2, '宇视', 'IPC3612SB-ADZK-I0', 87.5670, 43.7880, '八家户泵站机房入口', '2024-06-01', 1, '2025-06-01 12:00:00');
+109
View File
@@ -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-已完成';
+1781
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
import request from './request'
const BASE = '/wx-hall'
// ========== 水费查询缴费 ==========
export function getBillList(params: {
customerNo: string
billPeriod?: string
status?: string
pageNum?: number
pageSize?: number
}) {
return request.get(`${BASE}/bill/list`, { params })
}
export function getBillDetail(billId: number) {
return request.get(`${BASE}/bill/${billId}`)
}
export function payBill(data: { billId: number; amount: number }) {
return request.post(`${BASE}/bill/pay`, data)
}
export function getPaymentRecords(params: { customerNo: string; limit?: number }) {
return request.get(`${BASE}/bill/payment-records`, { params })
}
// ========== 报装申请 ==========
export function submitInstallApply(data: {
name: string
phone: string
area: string
address: string
customerType?: string
caliber?: string
}) {
return request.post(`${BASE}/install/apply`, data)
}
export function getInstallProgress(applyNo: string) {
return request.get(`${BASE}/install/progress`, { params: { applyNo } })
}
export function getInstallList(params: { phone: string; limit?: number }) {
return request.get(`${BASE}/install/list`, { params })
}
// ========== 停水公告 ==========
export function getNoticeList(params: {
page?: number
size?: number
type?: string
keyword?: string
}) {
return request.get(`${BASE}/notice/list`, { params })
}
export function getNoticeDetail(id: number) {
return request.get(`${BASE}/notice/${id}`)
}
export function getActiveNotices(areaCode?: string) {
return request.get(`${BASE}/notice/active`, { params: { areaCode } })
}
// ========== 用户绑定 ==========
export function bindPhone(data: { openId: string; phone: string }) {
return request.post(`${BASE}/user/bindPhone`, data)
}
export function bindCustomer(data: { openId: string; customerNo: string }) {
return request.post(`${BASE}/user/bindCustomer`, data)
}
export function unbind(data: { openId: string; bindingId: string }) {
return request.post(`${BASE}/user/unbind`, data)
}
export function getBindings(openId: string) {
return request.get(`${BASE}/user/bindings`, { params: { openId } })
}
+5
View File
@@ -14,6 +14,11 @@ const routes = [
{ 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: 'service/workbench', name: 'serviceWorkbench', component: () => import('@/views/service/CustomerServiceWorkbench.vue') },
// 微信网厅
{ path: 'wx-hall/water-bill', name: 'wxWaterBill', component: () => import('@/views/wxhall/WaterBillView.vue') },
{ path: 'wx-hall/install-apply', name: 'wxInstallApply', component: () => import('@/views/wxhall/InstallApplyView.vue') },
{ path: 'wx-hall/notice', name: 'wxNotice', component: () => import('@/views/wxhall/NoticeView.vue') },
{ path: 'wx-hall/user-bind', name: 'wxUserBind', component: () => import('@/views/wxhall/UserBindView.vue') },
]
},
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' }
+288
View File
@@ -0,0 +1,288 @@
<template>
<div class="announcement-mgmt">
<!-- 搜索区 -->
<el-card shadow="never">
<el-form :inline="true" :model="filterForm">
<el-form-item label="关键词">
<el-input v-model="filterForm.keyword" placeholder="标题/内容/范围" clearable @clear="handleSearch" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="filterForm.type" placeholder="全部" clearable @change="handleSearch">
<el-option label="停水" value="water_outage" />
<el-option label="水质" value="water_quality" />
<el-option label="维修" value="maintenance" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filterForm.status" placeholder="全部" clearable @change="handleSearch">
<el-option label="草稿" :value="0" />
<el-option label="已发布" :value="1" />
<el-option label="已撤回" :value="2" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 统计卡片 -->
<el-row :gutter="12" style="margin-top: 12px">
<el-col :span="6" v-for="stat in typeStats" :key="stat.type">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ stat.count }}</div>
<div class="stat-label">{{ typeLabel(stat.type) }}</div>
</el-card>
</el-col>
</el-row>
<!-- 操作栏 -->
<div style="margin-top: 16px">
<el-button type="primary" @click="openCreateDialog"><el-icon><Plus /></el-icon> 发布公告</el-button>
</div>
<!-- 列表 -->
<el-table :data="tableData" border style="margin-top: 12px" v-loading="loading">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="type" label="类型" width="100">
<template #default="{ row }">
<el-tag :type="typeTag(row.type)" size="small">{{ typeLabel(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="90">
<template #default="{ row }">
<el-tag :type="priorityTag(row.priority)" size="small">{{ priorityLabel(row.priority) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="affectedArea" label="影响范围" width="150" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="publishTime" label="发布时间" width="170" />
<el-table-column prop="publisherName" label="发布人" width="100" />
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="viewDetail(row)">查看</el-button>
<el-button link type="warning" v-if="row.status === 0" @click="openEditDialog(row)">编辑</el-button>
<el-button link type="success" v-if="row.status === 0" @click="handlePublish(row)">发布</el-button>
<el-button link type="warning" v-if="row.status === 1" @click="handleWithdraw(row)">撤回</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination style="margin-top: 16px; justify-content: flex-end"
v-model:current-page="pagination.page" v-model:page-size="pagination.size"
:total="pagination.total" :page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next" @change="fetchData" />
<!-- 编辑对话框 -->
<el-dialog v-model="showEditor" :title="isEdit ? '编辑公告' : '发布公告'" width="700px" destroy-on-close>
<el-form :model="form" label-width="100px">
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="公告标题" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="类型" required>
<el-select v-model="form.type" style="width: 100%">
<el-option label="停水" value="water_outage" />
<el-option label="水质" value="water_quality" />
<el-option label="维修" value="maintenance" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="优先级">
<el-select v-model="form.priority" style="width: 100%">
<el-option label="低" value="low" />
<el-option label="中" value="medium" />
<el-option label="高" value="high" />
<el-option label="紧急" value="urgent" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="影响范围">
<el-input v-model="form.affectedArea" placeholder="描述受影响的区域" />
</el-form-item>
<el-form-item label="区域编码">
<el-input v-model="form.areaCode" placeholder="用于定向推送(选填)" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="计划开始">
<el-date-picker v-model="form.plannedStart" type="datetime" style="width: 100%"
value-format="YYYY-MM-DDTHH:mm:ss" placeholder="开始时间" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="计划结束">
<el-date-picker v-model="form.plannedEnd" type="datetime" style="width: 100%"
value-format="YYYY-MM-DDTHH:mm:ss" placeholder="结束时间" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="内容">
<el-input v-model="form.content" type="textarea" :rows="6" placeholder="公告内容" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditor = false">取消</el-button>
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
</template>
</el-dialog>
<!-- 详情对话框 -->
<el-dialog v-model="showDetail" :title="detailItem?.title" width="600px">
<el-descriptions :column="2" border>
<el-descriptions-item label="类型">
<el-tag :type="typeTag(detailItem?.type)">{{ typeLabel(detailItem?.type) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="优先级">
<el-tag :type="priorityTag(detailItem?.priority)">{{ priorityLabel(detailItem?.priority) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusLabel(detailItem?.status) }}</el-descriptions-item>
<el-descriptions-item label="影响范围">{{ detailItem?.affectedArea }}</el-descriptions-item>
<el-descriptions-item label="计划时间" :span="2">
{{ detailItem?.plannedStart }} ~ {{ detailItem?.plannedEnd }}
</el-descriptions-item>
<el-descriptions-item label="发布人">{{ detailItem?.publisherName }}</el-descriptions-item>
<el-descriptions-item label="发布时间">{{ detailItem?.publishTime }}</el-descriptions-item>
</el-descriptions>
<el-divider />
<div class="detail-content">{{ detailItem?.content }}</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus } from '@element-plus/icons-vue'
import request from '@/api/request'
const API = '/api/revenue/cs/announcement'
const loading = ref(false)
const saving = ref(false)
const showEditor = ref(false)
const showDetail = ref(false)
const isEdit = ref(false)
const editId = ref<number | null>(null)
const tableData = ref<any[]>([])
const typeStats = ref<any[]>([])
const detailItem = ref<any>(null)
const filterForm = reactive({ keyword: '', type: '', status: undefined as number | undefined })
const pagination = reactive({ page: 1, size: 10, total: 0 })
const form = reactive({
title: '', content: '', type: 'water_outage', affectedArea: '', areaCode: '',
plannedStart: '', plannedEnd: '', priority: 'medium', publisherName: '当前用户'
})
onMounted(() => {
fetchData()
fetchStats()
})
async function fetchData() {
loading.value = true
try {
const res = await request.get(`${API}/list`, {
params: { page: pagination.page, size: pagination.size, ...filterForm }
})
tableData.value = res.data?.records || []
pagination.total = res.data?.total || 0
} finally {
loading.value = false
}
}
async function fetchStats() {
try {
const res = await request.get(`${API}/stats`)
typeStats.value = res.data || []
} catch (e) { /* ignore */ }
}
function handleSearch() { pagination.page = 1; fetchData() }
function handleReset() {
filterForm.keyword = ''; filterForm.type = ''; filterForm.status = undefined; handleSearch()
}
function openCreateDialog() {
isEdit.value = false; editId.value = null
Object.assign(form, { title: '', content: '', type: 'water_outage', affectedArea: '', areaCode: '',
plannedStart: '', plannedEnd: '', priority: 'medium' })
showEditor.value = true
}
function openEditDialog(row: any) {
isEdit.value = true; editId.value = row.id
Object.assign(form, row)
showEditor.value = true
}
async function handleSave() {
if (!form.title) { ElMessage.warning('请输入标题'); return }
saving.value = true
try {
if (isEdit.value && editId.value) {
await request.put(`${API}/${editId.value}`, form)
} else {
await request.post(API, form)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
showEditor.value = false
fetchData(); fetchStats()
} finally {
saving.value = false
}
}
function viewDetail(row: any) { detailItem.value = row; showDetail.value = true }
async function handlePublish(row: any) {
await ElMessageBox.confirm(`确定发布「${row.title}」?`, '确认发布')
await request.post(`${API}/${row.id}/publish`)
ElMessage.success('发布成功'); fetchData(); fetchStats()
}
async function handleWithdraw(row: any) {
await ElMessageBox.confirm(`确定撤回「${row.title}」?`, '确认撤回')
await request.post(`${API}/${row.id}/withdraw`)
ElMessage.success('撤回成功'); fetchData(); fetchStats()
}
async function handleDelete(row: any) {
await ElMessageBox.confirm(`确定删除「${row.title}」?`, '确认删除', { type: 'warning' })
await request.delete(`${API}/${row.id}`)
ElMessage.success('删除成功'); fetchData(); fetchStats()
}
const typeMap: Record<string, string> = { water_outage: '停水', water_quality: '水质', maintenance: '维修', other: '其他' }
const typeTagMap: Record<string, string> = { water_outage: 'danger', water_quality: 'warning', maintenance: '', other: 'info' }
function typeLabel(t: string) { return typeMap[t] || t }
function typeTag(t: string) { return (typeTagMap[t] || 'info') as any }
function priorityLabel(p: string) { return { low: '低', medium: '中', high: '高', urgent: '紧急' }[p] || p }
function priorityTag(p: string) { return ({ low: 'info', medium: '', high: 'warning', urgent: 'danger' }[p] || 'info') as any }
function statusLabel(s: number) { return ['草稿', '已发布', '已撤回'][s] || '未知' }
function statusTag(s: number) { return ['info', 'success', 'warning'][s] as any || 'info' }
</script>
<style scoped>
.stat-card { text-align: center; cursor: pointer; }
.stat-value { font-size: 28px; font-weight: 700; color: #409eff; }
.stat-label { font-size: 13px; color: #666; margin-top: 4px; }
.detail-content { white-space: pre-wrap; line-height: 1.8; }
</style>
+298
View File
@@ -0,0 +1,298 @@
<template>
<div class="knowledge-base">
<!-- 搜索过滤区 -->
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filterForm">
<el-form-item label="关键词">
<el-input v-model="filterForm.keyword" placeholder="搜索标题/内容/标签" clearable @clear="handleSearch" />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="filterForm.category" placeholder="全部分类" clearable @change="handleSearch">
<el-option v-for="cat in categories" :key="cat.category" :label="cat.category" :value="cat.category">
<span>{{ cat.category }}</span>
<span style="float: right; color: #999; font-size: 12px">{{ cat.count }}</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filterForm.status" placeholder="全部" clearable @change="handleSearch">
<el-option label="草稿" :value="0" />
<el-option label="已发布" :value="1" />
<el-option label="已归档" :value="2" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div style="margin-top: 16px; display: flex; justify-content: space-between; align-items: center">
<el-button type="primary" @click="openCreateDialog"><el-icon><Plus /></el-icon> 新建文章</el-button>
<el-radio-group v-model="viewMode" size="small">
<el-radio-button label="list">列表</el-radio-button>
<el-radio-button label="card">卡片</el-radio-button>
</el-radio-group>
</div>
<!-- 列表视图 -->
<el-table v-if="viewMode === 'list'" :data="tableData" border style="margin-top: 12px" v-loading="loading">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="category" label="分类" width="100">
<template #default="{ row }">
<el-tag size="small">{{ row.category }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="tags" label="标签" width="150" show-overflow-tooltip />
<el-table-column prop="viewCount" label="浏览" width="80" align="center" />
<el-table-column prop="likeCount" label="点赞" width="80" align="center" />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="authorName" label="作者" width="100" />
<el-table-column prop="updatedAt" label="更新时间" width="170" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="viewDetail(row)">查看</el-button>
<el-button link type="warning" @click="openEditDialog(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 卡片视图 -->
<el-row v-else :gutter="16" style="margin-top: 12px" v-loading="loading">
<el-col :span="6" v-for="item in tableData" :key="item.id">
<el-card shadow="hover" class="article-card" @click="viewDetail(item)">
<template #header>
<div class="card-header">
<span class="card-title">{{ item.title }}</span>
<el-tag :type="statusTag(item.status)" size="small">{{ statusLabel(item.status) }}</el-tag>
</div>
</template>
<p class="card-summary">{{ item.summary || '暂无摘要' }}</p>
<div class="card-meta">
<el-tag size="small">{{ item.category }}</el-tag>
<span class="meta-count">👁 {{ item.viewCount || 0 }} · 👍 {{ item.likeCount || 0 }}</span>
</div>
</el-card>
</el-col>
</el-row>
<!-- 分页 -->
<el-pagination style="margin-top: 16px; justify-content: flex-end"
v-model:current-page="pagination.page" v-model:page-size="pagination.size"
:total="pagination.total" :page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next" @change="fetchData" />
<!-- 编辑对话框 -->
<el-dialog v-model="showEditor" :title="isEdit ? '编辑文章' : '新建文章'" width="800px" destroy-on-close>
<el-form :model="form" label-width="80px">
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="请输入文章标题" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="分类" required>
<el-select v-model="form.category" placeholder="选择分类" style="width: 100%">
<el-option label="FAQ" value="FAQ" />
<el-option label="政策法规" value="政策法规" />
<el-option label="操作指南" value="操作指南" />
<el-option label="常见问题" value="常见问题" />
<el-option label="通知公告" value="通知公告" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态">
<el-select v-model="form.status" style="width: 100%">
<el-option label="草稿" :value="0" />
<el-option label="已发布" :value="1" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="标签">
<el-input v-model="form.tags" placeholder="多个标签用逗号分隔" />
</el-form-item>
<el-form-item label="摘要">
<el-input v-model="form.summary" type="textarea" :rows="2" placeholder="文章摘要(选填)" />
</el-form-item>
<el-form-item label="内容">
<el-input v-model="form.content" type="textarea" :rows="12" placeholder="支持 Markdown 格式" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditor = false">取消</el-button>
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
</template>
</el-dialog>
<!-- 详情对话框 -->
<el-dialog v-model="showDetail" :title="detailArticle?.title" width="700px">
<div class="detail-meta">
<el-tag>{{ detailArticle?.category }}</el-tag>
<span>作者: {{ detailArticle?.authorName }}</span>
<span>浏览: {{ detailArticle?.viewCount }}</span>
<span>点赞: {{ detailArticle?.likeCount }}</span>
</div>
<el-divider />
<div class="detail-content" v-html="renderMarkdown(detailArticle?.content || '')"></div>
<template #footer>
<el-button @click="handleLike" :icon="Star">点赞</el-button>
<el-button type="primary" @click="showDetail = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Star } from '@element-plus/icons-vue'
import request from '@/api/request'
const API = '/api/revenue/cs/kb'
const loading = ref(false)
const saving = ref(false)
const viewMode = ref('list')
const showEditor = ref(false)
const showDetail = ref(false)
const isEdit = ref(false)
const editId = ref<number | null>(null)
const tableData = ref<any[]>([])
const categories = ref<any[]>([])
const detailArticle = ref<any>(null)
const filterForm = reactive({ keyword: '', category: '', status: undefined as number | undefined })
const pagination = reactive({ page: 1, size: 10, total: 0 })
const form = reactive({
title: '', content: '', summary: '', category: 'FAQ', tags: '', status: 0, authorName: '当前用户'
})
onMounted(() => {
fetchData()
fetchCategories()
})
async function fetchData() {
loading.value = true
try {
const res = await request.get(`${API}/list`, {
params: { page: pagination.page, size: pagination.size, ...filterForm }
})
tableData.value = res.data?.records || []
pagination.total = res.data?.total || 0
} finally {
loading.value = false
}
}
async function fetchCategories() {
try {
const res = await request.get(`${API}/categories`)
categories.value = res.data || []
} catch (e) { /* ignore */ }
}
function handleSearch() {
pagination.page = 1
fetchData()
}
function handleReset() {
filterForm.keyword = ''
filterForm.category = ''
filterForm.status = undefined
handleSearch()
}
function openCreateDialog() {
isEdit.value = false
editId.value = null
Object.assign(form, { title: '', content: '', summary: '', category: 'FAQ', tags: '', status: 0 })
showEditor.value = true
}
function openEditDialog(row: any) {
isEdit.value = true
editId.value = row.id
Object.assign(form, { title: row.title, content: row.content, summary: row.summary,
category: row.category, tags: row.tags, status: row.status, authorName: row.authorName })
showEditor.value = true
}
async function handleSave() {
if (!form.title) { ElMessage.warning('请输入标题'); return }
saving.value = true
try {
if (isEdit.value && editId.value) {
await request.put(`${API}/${editId.value}`, form)
} else {
await request.post(API, form)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
showEditor.value = false
fetchData()
fetchCategories()
} finally {
saving.value = false
}
}
async function viewDetail(row: any) {
try {
const res = await request.get(`${API}/${row.id}`)
detailArticle.value = res.data
showDetail.value = true
fetchData() // refresh view count
} catch (e) {
detailArticle.value = row
showDetail.value = true
}
}
async function handleDelete(row: any) {
await ElMessageBox.confirm(`确定删除「${row.title}」?`, '确认删除', { type: 'warning' })
await request.delete(`${API}/${row.id}`)
ElMessage.success('删除成功')
fetchData()
}
async function handleLike() {
if (!detailArticle.value) return
await request.post(`${API}/${detailArticle.value.id}/like`)
ElMessage.success('点赞成功')
detailArticle.value.likeCount = (detailArticle.value.likeCount || 0) + 1
}
function statusLabel(s: number) { return ['草稿', '已发布', '已归档'][s] || '未知' }
function statusTag(s: number) { return ['info', 'success', 'warning'][s] as any || 'info' }
function renderMarkdown(md: string) {
// 简单 Markdown 渲染(生产环境可用 marked)
return md.replace(/### (.*)/g, '<h3>$1</h3>')
.replace(/## (.*)/g, '<h2>$1</h2>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/^- (.*)/gm, '<li>$1</li>')
.replace(/\n/g, '<br/>')
}
</script>
<style scoped>
.filter-card { margin-bottom: 8px; }
.article-card { margin-bottom: 16px; cursor: pointer; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
.card-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
.card-summary { color: #666; font-size: 13px; height: 40px; overflow: hidden; }
.card-meta { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
.meta-count { font-size: 12px; color: #999; }
.detail-meta { display: flex; gap: 16px; align-items: center; color: #666; }
.detail-content { line-height: 1.8; }
</style>
+190
View File
@@ -0,0 +1,190 @@
<template>
<div class="kpi-dashboard">
<!-- 统计卡片 -->
<el-row :gutter="16">
<el-col :span="4" v-for="card in statCards" :key="card.key">
<el-card shadow="hover" class="kpi-card" :class="'kpi-' + card.color">
<div class="kpi-icon">{{ card.icon }}</div>
<div class="kpi-value">{{ card.value }}</div>
<div class="kpi-label">{{ card.label }}</div>
<div class="kpi-sub" v-if="card.sub">{{ card.sub }}</div>
</el-card>
</el-col>
</el-row>
<!-- 图表区域 -->
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="14">
<el-card shadow="never">
<template #header>
<span>📈 7日工单趋势</span>
</template>
<div ref="trendChartRef" style="height: 320px"></div>
</el-card>
</el-col>
<el-col :span="10">
<el-card shadow="never">
<template #header>
<span>📊 工单类型分布</span>
</template>
<div ref="pieChartRef" style="height: 320px"></div>
</el-card>
</el-col>
</el-row>
<!-- 排行榜 -->
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12">
<el-card shadow="never">
<template #header>
<span>🏆 处理时效排行</span>
</template>
<el-table :data="efficiencyRank" size="small" :show-header="true">
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="name" label="处理人/部门" />
<el-table-column prop="completed_count" label="完成数" width="80" align="center" />
<el-table-column prop="avg_hours" label="平均时效(h)" width="110" align="center">
<template #default="{ row }">
<el-tag :type="row.avg_hours < 4 ? 'success' : row.avg_hours < 8 ? '' : 'warning'" size="small">
{{ Number(row.avg_hours).toFixed(1) }}h
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never">
<template #header>
<span>📋 快捷操作</span>
</template>
<div class="quick-actions">
<el-button @click="$router.push('/cs/knowledge')" size="large">
📚 知识库管理
</el-button>
<el-button @click="$router.push('/cs/announcement')" size="large">
📢 公告管理
</el-button>
<el-button @click="refreshDashboard" size="large" :loading="loading">
🔄 刷新数据
</el-button>
</div>
<el-divider />
<div class="summary-info">
<p>📅 数据更新时间: {{ lastUpdate }}</p>
<p>📊 本月解决率: <el-progress :percentage="Number(kpiData.monthResolveRate || 0)" :stroke-width="16" /></p>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, shallowRef } from 'vue'
import request from '@/api/request'
import * as echarts from 'echarts'
const API = '/api/revenue/cs/kpi/dashboard'
const loading = ref(false)
const kpiData = reactive<any>({})
const efficiencyRank = ref<any[]>([])
const lastUpdate = ref('')
const trendChartRef = ref<HTMLElement>()
const pieChartRef = ref<HTMLElement>()
let trendChart: echarts.ECharts | null = null
let pieChart: echarts.ECharts | null = null
const statCards = computed(() => [
{ key: 'pending', icon: '⏳', label: '待处理工单', value: kpiData.pendingWorkOrders ?? '-', color: 'orange' },
{ key: 'today_new', icon: '📥', label: '今日新增', value: kpiData.todayNewWorkOrders ?? '-', color: 'blue' },
{ key: 'month_rate', icon: '✅', label: '本月解决率', value: (kpiData.monthResolveRate ?? 0) + '%', color: 'green',
sub: `${kpiData.monthResolvedCount ?? 0}/${kpiData.monthTotalCount ?? 0}` },
{ key: 'avg_hours', icon: '⏱️', label: '平均时效', value: (kpiData.avgProcessHours ?? 0) + 'h', color: 'purple' },
{ key: 'satisfaction', icon: '😊', label: '满意率', value: (kpiData.satisfactionRate ?? 0) + '%', color: 'green' },
{ key: 'complaints', icon: '📞', label: '今日投诉', value: kpiData.todayComplaints ?? '-', color: 'red' },
])
onMounted(() => {
refreshDashboard()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
trendChart?.dispose()
pieChart?.dispose()
})
async function refreshDashboard() {
loading.value = true
try {
const res = await request.get(API)
Object.assign(kpiData, res.data)
efficiencyRank.value = res.data?.efficiencyRank || []
lastUpdate.value = new Date().toLocaleString()
await nextTick()
renderTrendChart()
renderPieChart()
} finally {
loading.value = false
}
}
function renderTrendChart() {
if (!trendChartRef.value) return
trendChart = trendChart || echarts.init(trendChartRef.value)
const data = kpiData.weeklyTrend || []
trendChart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: data.map((d: any) => d.date?.slice(5) || '') },
yAxis: { type: 'value', name: '工单数' },
series: [{
name: '工单数', type: 'line', smooth: true,
data: data.map((d: any) => d.count || 0),
areaStyle: { opacity: 0.15 },
itemStyle: { color: '#409eff' }
}]
})
}
function renderPieChart() {
if (!pieChartRef.value) return
pieChart = pieChart || echarts.init(pieChartRef.value)
const data = kpiData.typeDistribution || []
const nameMap: Record<string, string> = { pending: '待处理', in_progress: '处理中', completed: '已完成' }
pieChart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { bottom: 0 },
series: [{
type: 'pie', radius: ['40%', '65%'],
label: { show: true, formatter: '{b}\n{c}' },
data: data.map((d: any) => ({ name: nameMap[d.type] || d.type, value: d.count }))
}]
})
}
function handleResize() {
trendChart?.resize()
pieChart?.resize()
}
</script>
<style scoped>
.kpi-card { text-align: center; padding: 8px 0; }
.kpi-icon { font-size: 28px; }
.kpi-value { font-size: 32px; font-weight: 700; margin: 4px 0; }
.kpi-label { font-size: 13px; color: #666; }
.kpi-sub { font-size: 12px; color: #999; margin-top: 2px; }
.kpi-orange .kpi-value { color: #e6a23c; }
.kpi-blue .kpi-value { color: #409eff; }
.kpi-green .kpi-value { color: #67c23a; }
.kpi-purple .kpi-value { color: #9b59b6; }
.kpi-red .kpi-value { color: #f56c6c; }
.quick-actions { display: flex; gap: 12px; flex-wrap: wrap; }
.summary-info { margin-top: 16px; }
.summary-info p { margin: 8px 0; color: #666; }
</style>
@@ -0,0 +1,254 @@
<template>
<div class="wx-install-apply">
<el-page-header @back="$router.back()" title="返回" content="报装申请" />
<!-- 申请表单 -->
<el-card shadow="never" style="margin-top: 16px">
<template #header>
<span>新建报装申请</span>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" style="max-width: 600px">
<el-form-item label="申请人" prop="name">
<el-input v-model="form.name" placeholder="请输入申请人姓名" />
</el-form-item>
<el-form-item label="联系电话" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号" maxlength="11" />
</el-form-item>
<el-form-item label="所在区域" prop="area">
<el-select v-model="form.area" placeholder="请选择区域" style="width: 100%">
<el-option label="城东片区" value="城东片区" />
<el-option label="城西片区" value="城西片区" />
<el-option label="城南片区" value="城南片区" />
<el-option label="城北片区" value="城北片区" />
<el-option label="开发区" value="开发区" />
<el-option label="高新区" value="高新区" />
</el-select>
</el-form-item>
<el-form-item label="详细地址" prop="address">
<el-input v-model="form.address" type="textarea" :rows="2" placeholder="请输入详细地址" />
</el-form-item>
<el-form-item label="用水类型">
<el-select v-model="form.customerType" style="width: 100%">
<el-option label="居民用水" value="resident" />
<el-option label="商业用水" value="commercial" />
<el-option label="工业用水" value="industrial" />
<el-option label="行政事业" value="public" />
</el-select>
</el-form-item>
<el-form-item label="口径">
<el-select v-model="form.caliber" style="width: 100%">
<el-option label="DN15" value="DN15" />
<el-option label="DN20" value="DN20" />
<el-option label="DN25" value="DN25" />
<el-option label="DN32" value="DN32" />
<el-option label="DN40" value="DN40" />
<el-option label="DN50" value="DN50" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit" :loading="submitting">提交申请</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 进度查询 -->
<el-card shadow="never" style="margin-top: 12px">
<template #header>
<span>进度查询</span>
</template>
<el-form :inline="true">
<el-form-item label="申请编号">
<el-input v-model="progressQueryNo" placeholder="输入申请编号查询" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="queryProgress" :disabled="!progressQueryNo">查询</el-button>
</el-form-item>
</el-form>
<el-steps :active="progressStep" finish-status="success" align-center v-if="progressData"
style="margin-top: 20px">
<el-step title="预受理" :description="progressData.status === 'pre_apply' ? '当前阶段' : ''" />
<el-step title="工程审核" :description="progressData.status === 'engineering' ? '当前阶段' : ''" />
<el-step title="派单施工" :description="progressData.status === 'pending_review' ? '当前阶段' : ''" />
<el-step title="已完成" :description="progressData.status === 'completed' ? '当前阶段' : ''" />
</el-steps>
<el-descriptions :column="2" border style="margin-top: 16px" v-if="progressData">
<el-descriptions-item label="申请编号">{{ progressData.application_no }}</el-descriptions-item>
<el-descriptions-item label="申请人">{{ progressData.applicant_name }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ progressData.applicant_phone }}</el-descriptions-item>
<el-descriptions-item label="区域">{{ progressData.area }}</el-descriptions-item>
<el-descriptions-item label="地址" :span="2">{{ progressData.address }}</el-descriptions-item>
<el-descriptions-item label="用水类型">{{ progressData.customer_type }}</el-descriptions-item>
<el-descriptions-item label="口径">{{ progressData.caliber }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="installStatusTag(progressData.status)">{{ installStatusLabel(progressData.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ progressData.updated_at }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 申请记录 -->
<el-card shadow="never" style="margin-top: 12px">
<template #header>
<div class="card-header">
<span>申请记录</span>
</div>
</template>
<el-form :inline="true" style="margin-bottom: 12px">
<el-form-item label="手机号">
<el-input v-model="recordPhone" placeholder="输入手机号查询" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchRecords">查询</el-button>
</el-form-item>
</el-form>
<el-table :data="records" border stripe v-loading="recordLoading">
<el-table-column prop="application_no" label="申请编号" min-width="180" />
<el-table-column prop="applicant_name" label="申请人" width="100" />
<el-table-column prop="area" label="区域" width="100" />
<el-table-column prop="address" label="地址" min-width="200" show-overflow-tooltip />
<el-table-column prop="customer_type" label="用水类型" width="100" />
<el-table-column prop="caliber" label="口径" width="80" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="installStatusTag(row.status)" size="small">{{ installStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="申请时间" width="170" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="viewRecordProgress(row)">进度</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance } from 'element-plus'
import { submitInstallApply, getInstallProgress, getInstallList } from '@/api/wx-hall'
const formRef = ref<FormInstance>()
const submitting = ref(false)
const progressQueryNo = ref('')
const progressData = ref<any>(null)
const progressStep = ref(0)
const recordPhone = ref('')
const records = ref<any[]>([])
const recordLoading = ref(false)
const form = reactive({
name: '',
phone: '',
area: '',
address: '',
customerType: 'resident',
caliber: 'DN15'
})
const rules = {
name: [{ required: true, message: '请输入申请人姓名', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
],
area: [{ required: true, message: '请选择区域', trigger: 'change' }],
address: [{ required: true, message: '请输入详细地址', trigger: 'blur' }]
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate()
submitting.value = true
try {
const res = await submitInstallApply(form)
ElMessage.success(`申请提交成功!申请编号: ${res.data?.applicationNo}`)
resetForm()
} catch (e) { /* ignore */ } finally {
submitting.value = false
}
}
function resetForm() {
formRef.value?.resetFields()
Object.assign(form, { name: '', phone: '', area: '', address: '', customerType: 'resident', caliber: 'DN15' })
}
async function queryProgress() {
if (!progressQueryNo.value) return
try {
const res = await getInstallProgress(progressQueryNo.value)
progressData.value = res.data
progressStep.value = calcStep(res.data?.status)
} catch (e) {
progressData.value = null
progressStep.value = 0
}
}
async function fetchRecords() {
if (!recordPhone.value) {
ElMessage.warning('请输入手机号')
return
}
recordLoading.value = true
try {
const res = await getInstallList({ phone: recordPhone.value, limit: 50 })
records.value = res.data || []
} finally {
recordLoading.value = false
}
}
function viewRecordProgress(row: any) {
progressQueryNo.value = row.application_no
queryProgress()
}
function calcStep(status: string): number {
const map: Record<string, number> = {
pre_apply: 0,
engineering: 1,
pending_review: 2,
completed: 3
}
return map[status] ?? 0
}
function installStatusLabel(s: string): string {
const map: Record<string, string> = {
pre_apply: '预受理',
engineering: '工程审核',
pending_review: '派单施工',
completed: '已完成'
}
return map[s] || s
}
function installStatusTag(s: string): any {
const map: Record<string, string> = {
pre_apply: 'info',
engineering: 'warning',
pending_review: '',
completed: 'success'
}
return map[s] || 'info'
}
</script>
<style scoped>
.wx-install-apply {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
+217
View File
@@ -0,0 +1,217 @@
<template>
<div class="wx-notice">
<el-page-header @back="$router.back()" title="返回" content="停水公告" />
<!-- 搜索和筛选 -->
<el-card shadow="never" style="margin-top: 16px">
<el-form :inline="true" :model="queryForm">
<el-form-item label="关键词">
<el-input v-model="queryForm.keyword" placeholder="搜索公告标题/内容" clearable @clear="handleSearch" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="queryForm.type" placeholder="全部" clearable @change="handleSearch">
<el-option label="停水" value="water_outage" />
<el-option label="水质" value="water_quality" />
<el-option label="维修" value="maintenance" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 公告列表 -->
<div class="notice-list" style="margin-top: 12px">
<el-card shadow="hover" v-for="item in noticeList" :key="item.id" class="notice-item"
@click="viewDetail(item)">
<div class="notice-header">
<div class="notice-title">
<el-tag :type="typeTag(item.type)" size="small" class="type-tag">{{ typeLabel(item.type) }}</el-tag>
<el-tag :type="priorityTag(item.priority)" size="small" v-if="item.priority === 'urgent' || item.priority === 'high'" class="priority-tag">
{{ priorityLabel(item.priority) }}
</el-tag>
<span class="title-text">{{ item.title }}</span>
</div>
<span class="notice-time">{{ item.publishTime }}</span>
</div>
<div class="notice-meta">
<span v-if="item.affectedArea">📍 {{ item.affectedArea }}</span>
<span v-if="item.plannedStart">🕐 {{ item.plannedStart }} ~ {{ item.plannedEnd }}</span>
</div>
<div class="notice-preview">{{ truncate(item.content, 100) }}</div>
</el-card>
<el-empty v-if="!loading && noticeList.length === 0" description="暂无公告" />
</div>
<!-- 分页 -->
<el-pagination style="margin-top: 16px; justify-content: flex-end"
v-model:current-page="pagination.page" v-model:page-size="pagination.size"
:total="pagination.total" :page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next" @change="fetchNotices" />
<!-- 详情弹窗 -->
<el-dialog v-model="showDetail" :title="detailItem?.title" width="650px" top="5vh">
<el-descriptions :column="2" border>
<el-descriptions-item label="类型">
<el-tag :type="typeTag(detailItem?.type)">{{ typeLabel(detailItem?.type) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="优先级">
<el-tag :type="priorityTag(detailItem?.priority)">{{ priorityLabel(detailItem?.priority) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="影响范围" :span="2">{{ detailItem?.affectedArea }}</el-descriptions-item>
<el-descriptions-item label="计划开始">{{ detailItem?.plannedStart }}</el-descriptions-item>
<el-descriptions-item label="计划结束">{{ detailItem?.plannedEnd }}</el-descriptions-item>
<el-descriptions-item label="发布时间">{{ detailItem?.publishTime }}</el-descriptions-item>
<el-descriptions-item label="发布人">{{ detailItem?.publisherName }}</el-descriptions-item>
</el-descriptions>
<el-divider />
<div class="detail-content">{{ detailItem?.content }}</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { getNoticeList, getNoticeDetail } from '@/api/wx-hall'
const loading = ref(false)
const showDetail = ref(false)
const noticeList = ref<any[]>([])
const detailItem = ref<any>(null)
const queryForm = reactive({
keyword: '',
type: ''
})
const pagination = reactive({
page: 1,
size: 10,
total: 0
})
onMounted(() => {
fetchNotices()
})
function handleSearch() {
pagination.page = 1
fetchNotices()
}
async function fetchNotices() {
loading.value = true
try {
const res = await getNoticeList({
page: pagination.page,
size: pagination.size,
type: queryForm.type || undefined,
keyword: queryForm.keyword || undefined
})
noticeList.value = res.data?.records || []
pagination.total = res.data?.total || 0
} finally {
loading.value = false
}
}
async function viewDetail(row: any) {
try {
const res = await getNoticeDetail(row.id)
detailItem.value = res.data
showDetail.value = true
} catch (e) {
// fallback to list item data
detailItem.value = row
showDetail.value = true
}
}
function truncate(text: string, maxLen: number) {
if (!text) return ''
return text.length > maxLen ? text.slice(0, maxLen) + '...' : text
}
const typeMap: Record<string, string> = {
water_outage: '停水',
water_quality: '水质',
maintenance: '维修',
other: '其他'
}
const typeTagMap: Record<string, string> = {
water_outage: 'danger',
water_quality: 'warning',
maintenance: '',
other: 'info'
}
function typeLabel(t: string) { return typeMap[t] || t }
function typeTag(t: string): any { return typeTagMap[t] || 'info' }
function priorityLabel(p: string) {
return { low: '低', medium: '中', high: '高', urgent: '紧急' }[p] || p
}
function priorityTag(p: string): any {
return { low: 'info', medium: '', high: 'warning', urgent: 'danger' }[p] || 'info'
}
</script>
<style scoped>
.wx-notice {
padding: 20px;
}
.notice-item {
margin-bottom: 10px;
cursor: pointer;
transition: box-shadow 0.2s;
}
.notice-item:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.notice-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.notice-title {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.title-text {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.notice-time {
font-size: 12px;
color: #909399;
white-space: nowrap;
margin-left: 12px;
}
.notice-meta {
display: flex;
gap: 16px;
font-size: 13px;
color: #606266;
margin-bottom: 6px;
}
.notice-preview {
font-size: 13px;
color: #909399;
line-height: 1.6;
}
.detail-content {
white-space: pre-wrap;
line-height: 1.8;
font-size: 14px;
color: #303133;
}
</style>
+188
View File
@@ -0,0 +1,188 @@
<template>
<div class="wx-user-bind">
<el-page-header @back="$router.back()" title="返回" content="用户绑定" />
<!-- OpenID 输入(模拟微信环境) -->
<el-card shadow="never" style="margin-top: 16px">
<el-form :inline="true">
<el-form-item label="OpenID">
<el-input v-model="openId" placeholder="请输入微信 OpenID" clearable @clear="handleOpenIdChange" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleOpenIdChange" :disabled="!openId">确认</el-button>
</el-form-item>
</el-form>
</el-card>
<template v-if="openId">
<!-- 手机号绑定 -->
<el-card shadow="never" style="margin-top: 12px">
<template #header>
<div class="card-header">
<span>📱 手机号绑定</span>
</div>
</template>
<el-form :inline="true" :model="phoneForm">
<el-form-item label="手机号">
<el-input v-model="phoneForm.phone" placeholder="请输入手机号" maxlength="11" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleBindPhone" :loading="phoneLoading"
:disabled="!phoneForm.phone">绑定手机号</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 户号绑定 -->
<el-card shadow="never" style="margin-top: 12px">
<template #header>
<div class="card-header">
<span>🏠 户号绑定</span>
</div>
</template>
<el-form :inline="true" :model="customerForm">
<el-form-item label="户号">
<el-input v-model="customerForm.customerNo" placeholder="请输入水费户号" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleBindCustomer" :loading="customerLoading"
:disabled="!customerForm.customerNo">绑定户号</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 已绑定列表 -->
<el-card shadow="never" style="margin-top: 12px">
<template #header>
<div class="card-header">
<span>已绑定列表</span>
<el-button link type="primary" @click="fetchBindings">刷新</el-button>
</div>
</template>
<el-table :data="bindings" border stripe v-loading="bindingsLoading">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="bindingType" label="类型" width="100">
<template #default="{ row }">
<el-tag :type="row.binding_type === 'phone' ? '' : 'success'" size="small">
{{ row.binding_type === 'phone' ? '手机号' : '户号' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="phone" label="手机号" width="140">
<template #default="{ row }">{{ row.phone || '-' }}</template>
</el-table-column>
<el-table-column prop="customerNo" label="户号" width="140">
<template #default="{ row }">{{ row.customer_no || '-' }}</template>
</el-table-column>
<el-table-column prop="customerName" label="客户名称" width="140">
<template #default="{ row }">{{ row.customer_name || '-' }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="绑定时间" min-width="170">
<template #default="{ row }">{{ row.created_at }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-popconfirm title="确定解绑吗?" @confirm="handleUnbind(row)">
<template #reference>
<el-button link type="danger">解绑</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!bindingsLoading && bindings.length === 0" description="暂无绑定记录" />
</el-card>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { bindPhone, bindCustomer, unbind, getBindings } from '@/api/wx-hall'
const openId = ref('')
const phoneLoading = ref(false)
const customerLoading = ref(false)
const bindingsLoading = ref(false)
const bindings = ref<any[]>([])
const phoneForm = reactive({ phone: '' })
const customerForm = reactive({ customerNo: '' })
function handleOpenIdChange() {
if (!openId.value) {
ElMessage.warning('请输入 OpenID')
return
}
fetchBindings()
}
async function handleBindPhone() {
if (!phoneForm.phone) {
ElMessage.warning('请输入手机号')
return
}
if (!/^1[3-9]\d{9}$/.test(phoneForm.phone)) {
ElMessage.warning('手机号格式不正确')
return
}
phoneLoading.value = true
try {
await bindPhone({ openId: openId.value, phone: phoneForm.phone })
ElMessage.success('手机号绑定成功')
phoneForm.phone = ''
fetchBindings()
} catch (e) { /* ignore */ } finally {
phoneLoading.value = false
}
}
async function handleBindCustomer() {
if (!customerForm.customerNo) {
ElMessage.warning('请输入户号')
return
}
customerLoading.value = true
try {
await bindCustomer({ openId: openId.value, customerNo: customerForm.customerNo })
ElMessage.success('户号绑定成功')
customerForm.customerNo = ''
fetchBindings()
} catch (e) { /* ignore */ } finally {
customerLoading.value = false
}
}
async function handleUnbind(row: any) {
try {
await unbind({ openId: openId.value, bindingId: String(row.id) })
ElMessage.success('解绑成功')
fetchBindings()
} catch (e) { /* ignore */ }
}
async function fetchBindings() {
if (!openId.value) return
bindingsLoading.value = true
try {
const res = await getBindings(openId.value)
bindings.value = res.data || []
} finally {
bindingsLoading.value = false
}
}
</script>
<style scoped>
.wx-user-bind {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
+287
View File
@@ -0,0 +1,287 @@
<template>
<div class="wx-water-bill">
<el-page-header @back="$router.back()" title="返回" content="水费查询缴费" />
<!-- 用户绑定区域 -->
<el-card shadow="never" style="margin-top: 16px">
<el-form :inline="true" :model="queryForm">
<el-form-item label="户号">
<el-input v-model="queryForm.customerNo" placeholder="请输入户号" clearable @clear="handleQuery" />
</el-form-item>
<el-form-item label="账单周期">
<el-date-picker v-model="queryForm.billPeriod" type="month" placeholder="选择月份"
value-format="YYYY-MM" clearable @change="handleQuery" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryForm.status" placeholder="全部" clearable @change="handleQuery">
<el-option label="待缴" value="pending" />
<el-option label="已缴" value="paid" />
<el-option label="部分" value="partial" />
<el-option label="逾期" value="overdue" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">查询</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 账单列表 -->
<el-card shadow="never" style="margin-top: 12px">
<template #header>
<div class="card-header">
<span>账单列表</span>
<el-tag v-if="totalUnpaid > 0" type="danger">待缴 ¥{{ totalUnpaid.toFixed(2) }}</el-tag>
</div>
</template>
<el-table :data="billList" v-loading="loading" border stripe>
<el-table-column prop="billNo" label="账单编号" min-width="180" show-overflow-tooltip />
<el-table-column prop="billPeriod" label="账单周期" width="110" />
<el-table-column prop="waterUsage" label="用水量(吨)" width="100" align="right">
<template #default="{ row }">{{ Number(row.waterUsage).toFixed(1) }}</template>
</el-table-column>
<el-table-column prop="totalAmount" label="应缴(元)" width="100" align="right">
<template #default="{ row }">{{ Number(row.totalAmount).toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="paidAmount" label="已缴(元)" width="100" align="right">
<template #default="{ row }">{{ Number(row.paidAmount).toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="dueDate" label="到期日" width="110" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="viewDetail(row)">详情</el-button>
<el-button link type="success" v-if="row.status !== 'paid' && row.status !== 'cancelled'"
@click="openPayDialog(row)">缴费</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top: 16px; justify-content: flex-end"
v-model:current-page="pagination.pageNum" v-model:page-size="pagination.pageSize"
:total="pagination.total" :page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next" @change="fetchBills" />
</el-card>
<!-- 缴费记录 -->
<el-card shadow="never" style="margin-top: 12px">
<template #header>
<div class="card-header">
<span>缴费记录</span>
<el-button link type="primary" @click="fetchPaymentRecords">刷新</el-button>
</div>
</template>
<el-table :data="paymentRecords" border stripe size="small">
<el-table-column prop="paymentNo" label="流水号" min-width="180" show-overflow-tooltip />
<el-table-column prop="billNo" label="账单编号" width="180" show-overflow-tooltip />
<el-table-column prop="amount" label="金额(元)" width="100" align="right">
<template #default="{ row }">{{ Number(row.amount).toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="channelName" label="渠道" width="100" />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : 'info'" size="small">
{{ row.status === 'success' ? '成功' : row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="payTime" label="支付时间" width="170" />
</el-table>
</el-card>
<!-- 账单详情弹窗 -->
<el-dialog v-model="showDetail" title="账单详情" width="550px">
<el-descriptions :column="2" border v-if="detailData">
<el-descriptions-item label="账单编号">{{ detailData.bill?.billNo }}</el-descriptions-item>
<el-descriptions-item label="账单周期">{{ detailData.bill?.billPeriod }}</el-descriptions-item>
<el-descriptions-item label="客户名称">{{ detailData.bill?.customerName }}</el-descriptions-item>
<el-descriptions-item label="户号">{{ detailData.bill?.customerNo }}</el-descriptions-item>
<el-descriptions-item label="水表编号">{{ detailData.bill?.meterNo }}</el-descriptions-item>
<el-descriptions-item label="用水量">{{ Number(detailData.bill?.waterUsage || 0).toFixed(1) }} 吨</el-descriptions-item>
<el-descriptions-item label="单价">¥{{ Number(detailData.bill?.unitPrice || 0).toFixed(2) }}/吨</el-descriptions-item>
<el-descriptions-item label="应缴金额">¥{{ Number(detailData.bill?.totalAmount || 0).toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="已缴金额">¥{{ Number(detailData.bill?.paidAmount || 0).toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="剩余金额">
<el-tag :type="detailData.remainingAmount > 0 ? 'danger' : 'success'">
¥{{ Number(detailData.remainingAmount || 0).toFixed(2) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="抄表读数">
{{ detailData.bill?.prevReading }} → {{ detailData.bill?.currReading }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTag(detailData.bill?.status)">{{ statusLabel(detailData.bill?.status) }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<el-divider v-if="detailData?.payments?.length > 0">缴费记录</el-divider>
<el-table :data="detailData?.payments || []" size="small" border v-if="detailData?.payments?.length > 0">
<el-table-column prop="paymentNo" label="流水号" />
<el-table-column prop="amount" label="金额" width="100" />
<el-table-column prop="channelName" label="渠道" width="100" />
<el-table-column prop="payTime" label="时间" width="170" />
</el-table>
</el-dialog>
<!-- 缴费弹窗 -->
<el-dialog v-model="showPay" title="在线缴费" width="420px">
<div v-if="payingBill" class="pay-info">
<el-descriptions :column="1" border>
<el-descriptions-item label="账单编号">{{ payingBill.billNo }}</el-descriptions-item>
<el-descriptions-item label="账单周期">{{ payingBill.billPeriod }}</el-descriptions-item>
<el-descriptions-item label="应缴金额">¥{{ Number(payingBill.totalAmount).toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="已缴金额">¥{{ Number(payingBill.paidAmount).toFixed(2) }}</el-descriptions-item>
</el-descriptions>
<el-divider />
<el-form-item label="缴费金额">
<el-input-number v-model="payAmount" :min="0.01"
:max="Number(payingBill.totalAmount) - Number(payingBill.paidAmount)"
:precision="2" :step="1" style="width: 100%" />
</el-form-item>
</div>
<template #footer>
<el-button @click="showPay = false">取消</el-button>
<el-button type="primary" :loading="payLoading" @click="handlePay">确认缴费</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getBillList, getBillDetail, payBill, getPaymentRecords } from '@/api/wx-hall'
const loading = ref(false)
const payLoading = ref(false)
const showDetail = ref(false)
const showPay = ref(false)
const billList = ref<any[]>([])
const paymentRecords = ref<any[]>([])
const detailData = ref<any>(null)
const payingBill = ref<any>(null)
const payAmount = ref(0)
const queryForm = reactive({
customerNo: '',
billPeriod: '',
status: ''
})
const pagination = reactive({
pageNum: 1,
pageSize: 10,
total: 0
})
const totalUnpaid = computed(() => {
return billList.value
.filter(b => b.status !== 'paid' && b.status !== 'cancelled')
.reduce((sum, b) => sum + (Number(b.totalAmount) - Number(b.paidAmount)), 0)
})
onMounted(() => {
// 自动加载(需要提供 customerNo)
})
function handleQuery() {
if (!queryForm.customerNo) {
ElMessage.warning('请输入户号')
return
}
pagination.pageNum = 1
fetchBills()
fetchPaymentRecords()
}
async function fetchBills() {
if (!queryForm.customerNo) return
loading.value = true
try {
const res = await getBillList({
customerNo: queryForm.customerNo,
billPeriod: queryForm.billPeriod || undefined,
status: queryForm.status || undefined,
pageNum: pagination.pageNum,
pageSize: pagination.pageSize
})
billList.value = res.data?.records || []
pagination.total = res.data?.total || 0
} finally {
loading.value = false
}
}
async function fetchPaymentRecords() {
if (!queryForm.customerNo) return
try {
const res = await getPaymentRecords({ customerNo: queryForm.customerNo, limit: 20 })
paymentRecords.value = res.data || []
} catch (e) { /* ignore */ }
}
async function viewDetail(row: any) {
try {
const res = await getBillDetail(row.id)
detailData.value = res.data
showDetail.value = true
} catch (e) { /* ignore */ }
}
function openPayDialog(row: any) {
payingBill.value = row
const remaining = Number(row.totalAmount) - Number(row.paidAmount)
payAmount.value = Math.round(remaining * 100) / 100
showPay.value = true
}
async function handlePay() {
if (!payingBill.value || payAmount.value <= 0) {
ElMessage.warning('请输入正确的缴费金额')
return
}
payLoading.value = true
try {
await payBill({ billId: payingBill.value.id, amount: payAmount.value })
ElMessage.success('缴费成功!')
showPay.value = false
fetchBills()
fetchPaymentRecords()
} catch (e) { /* ignore */ } finally {
payLoading.value = false
}
}
function statusLabel(s: string) {
const map: Record<string, string> = {
pending: '待缴', paid: '已缴', partial: '部分', overdue: '逾期', cancelled: '已作废'
}
return map[s] || s
}
function statusTag(s: string): any {
const map: Record<string, string> = {
pending: 'warning', paid: 'success', partial: '', overdue: 'danger', cancelled: 'info'
}
return map[s] || 'info'
}
</script>
<style scoped>
.wx-water-bill {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.pay-info {
padding: 0 12px;
}
</style>
+1
View File
@@ -50,6 +50,7 @@
<module>wm-system</module>
<module>wm-mobile-app</module>
<module>wm-config</module>
<module>wm-dma</module>
</modules>
<dependencyManagement>
+133
View File
@@ -0,0 +1,133 @@
-- ============================================================
-- 水表全生命周期管理 DDL
-- 包含: rev_water_meter, rev_meter_install_record,
-- rev_meter_replace_record, rev_meter_lifecycle_log
-- ============================================================
-- 1. 水表主表
CREATE TABLE IF NOT EXISTS rev_water_meter (
id BIGSERIAL PRIMARY KEY,
meter_no VARCHAR(64) NOT NULL UNIQUE,
model VARCHAR(64),
diameter INTEGER,
manufacturer VARCHAR(128),
production_date DATE,
install_date DATE,
install_address VARCHAR(512),
customer_no VARCHAR(64),
status VARCHAR(32) NOT NULL DEFAULT 'IN_STOCK',
in_stock_time TIMESTAMP,
out_stock_time TIMESTAMP,
initial_reading NUMERIC(14,4) DEFAULT 0,
current_reading NUMERIC(14,4) DEFAULT 0,
dismantle_date DATE,
scrapped_date DATE,
remark VARCHAR(512),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE rev_water_meter IS '水表主表';
COMMENT ON COLUMN rev_water_meter.meter_no IS '水表编号';
COMMENT ON COLUMN rev_water_meter.model IS '水表型号';
COMMENT ON COLUMN rev_water_meter.diameter IS '口径(mm)';
COMMENT ON COLUMN rev_water_meter.manufacturer IS '制造商';
COMMENT ON COLUMN rev_water_meter.production_date IS '生产日期';
COMMENT ON COLUMN rev_water_meter.install_date IS '安装日期';
COMMENT ON COLUMN rev_water_meter.install_address IS '安装地址';
COMMENT ON COLUMN rev_water_meter.customer_no IS '客户编号';
COMMENT ON COLUMN rev_water_meter.status IS '状态: IN_STOCK/INSTALLED/DISMANTLED/SCRAPPED/REPAIRING';
COMMENT ON COLUMN rev_water_meter.in_stock_time IS '入库时间';
COMMENT ON COLUMN rev_water_meter.out_stock_time IS '出库时间';
COMMENT ON COLUMN rev_water_meter.initial_reading IS '初始读数';
COMMENT ON COLUMN rev_water_meter.current_reading IS '当前读数';
COMMENT ON COLUMN rev_water_meter.dismantle_date IS '拆除日期';
COMMENT ON COLUMN rev_water_meter.scrapped_date IS '报废日期';
-- 2. 水表安装记录
CREATE TABLE IF NOT EXISTS rev_meter_install_record (
id BIGSERIAL PRIMARY KEY,
meter_no VARCHAR(64) NOT NULL,
customer_no VARCHAR(64),
installer VARCHAR(128),
install_date DATE,
install_address VARCHAR(512),
old_meter_no VARCHAR(64),
initial_reading NUMERIC(14,4) DEFAULT 0,
remark VARCHAR(512),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE rev_meter_install_record IS '水表安装记录';
-- 3. 水表换表记录
CREATE TABLE IF NOT EXISTS rev_meter_replace_record (
id BIGSERIAL PRIMARY KEY,
old_meter_no VARCHAR(64) NOT NULL,
new_meter_no VARCHAR(64) NOT NULL,
customer_no VARCHAR(64),
replace_type VARCHAR(32) NOT NULL,
reason VARCHAR(512),
replacer VARCHAR(128),
replace_date DATE,
old_reading NUMERIC(14,4),
new_reading NUMERIC(14,4),
approval_status VARCHAR(32) DEFAULT 'APPROVED',
approver VARCHAR(128),
approval_time TIMESTAMP,
remark VARCHAR(512),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE rev_meter_replace_record IS '水表换表记录';
COMMENT ON COLUMN rev_meter_replace_record.replace_type IS '换表类型: FAULT-故障, EXPIRED-到期';
COMMENT ON COLUMN rev_meter_replace_record.approval_status IS '审批状态: PENDING/APPROVED/REJECTED';
-- 4. 水表生命周期日志
CREATE TABLE IF NOT EXISTS rev_meter_lifecycle_log (
id BIGSERIAL PRIMARY KEY,
meter_no VARCHAR(64) NOT NULL,
action_type VARCHAR(32) NOT NULL,
action_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
operator VARCHAR(128),
detail VARCHAR(1024),
old_meter_no VARCHAR(64),
new_meter_no VARCHAR(64),
customer_no VARCHAR(64),
remark VARCHAR(512),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE rev_meter_lifecycle_log IS '水表生命周期日志';
COMMENT ON COLUMN rev_meter_lifecycle_log.action_type IS '操作类型: STOCK_IN/INSTALL/DISMANTLE/REPLACE/SCRAP/REPAIR';
-- ============================================================
-- 索引
-- ============================================================
-- rev_water_meter 索引
CREATE INDEX IF NOT EXISTS idx_water_meter_status ON rev_water_meter (status);
CREATE INDEX IF NOT EXISTS idx_water_meter_customer ON rev_water_meter (customer_no);
CREATE INDEX IF NOT EXISTS idx_water_meter_diameter ON rev_water_meter (diameter);
CREATE INDEX IF NOT EXISTS idx_water_meter_manufacturer ON rev_water_meter (manufacturer);
CREATE INDEX IF NOT EXISTS idx_water_meter_created ON rev_water_meter (created_at);
-- rev_meter_install_record 索引
CREATE INDEX IF NOT EXISTS idx_install_record_meter ON rev_meter_install_record (meter_no);
CREATE INDEX IF NOT EXISTS idx_install_record_customer ON rev_meter_install_record (customer_no);
CREATE INDEX IF NOT EXISTS idx_install_record_date ON rev_meter_install_record (install_date);
-- rev_meter_replace_record 索引
CREATE INDEX IF NOT EXISTS idx_replace_record_old ON rev_meter_replace_record (old_meter_no);
CREATE INDEX IF NOT EXISTS idx_replace_record_new ON rev_meter_replace_record (new_meter_no);
CREATE INDEX IF NOT EXISTS idx_replace_record_customer ON rev_meter_replace_record (customer_no);
CREATE INDEX IF NOT EXISTS idx_replace_record_approval ON rev_meter_replace_record (approval_status);
CREATE INDEX IF NOT EXISTS idx_replace_record_date ON rev_meter_replace_record (replace_date);
-- rev_meter_lifecycle_log 索引
CREATE INDEX IF NOT EXISTS idx_lifecycle_log_meter ON rev_meter_lifecycle_log (meter_no);
CREATE INDEX IF NOT EXISTS idx_lifecycle_log_action ON rev_meter_lifecycle_log (action_type);
CREATE INDEX IF NOT EXISTS idx_lifecycle_log_time ON rev_meter_lifecycle_log (action_time);
CREATE INDEX IF NOT EXISTS idx_lifecycle_log_customer ON rev_meter_lifecycle_log (customer_no);
@@ -0,0 +1,287 @@
package com.water.data_engine.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.common.core.result.R;
import com.water.data_engine.entity.*;
import com.water.data_engine.entity.dto.ExportRequest;
import com.water.data_engine.entity.dto.StatisticsResult;
import com.water.data_engine.service.HistoryDataService;
import com.water.data_engine.service.ReportService;
import com.water.data_engine.service.StatisticsService;
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.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 历史数据回溯与报表生成 API
*/
@Tag(name = "历史数据与报表")
@RestController
@RequestMapping("/api/data")
@RequiredArgsConstructor
public class HistoryReportController {
private final HistoryDataService historyDataService;
private final ReportService reportService;
private final StatisticsService statisticsService;
// ==================== 1. 历史水量数据分页查询 ====================
@Operation(summary = "历史水量数据查询(分页)")
@GetMapping("/history/quantity")
public R<Page<WaterQuantity>> queryQuantityHistory(
@RequestParam(required = false) String area,
@RequestParam(required = false) String pointCode,
@RequestParam(required = false) String deviceSn,
@RequestParam(required = false) LocalDateTime startTime,
@RequestParam(required = false) LocalDateTime endTime,
@RequestParam(required = false) Integer qualityFlag,
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "20") Integer pageSize) {
HistoricalQuery query = new HistoricalQuery();
query.setArea(area);
query.setPointCode(pointCode);
query.setDeviceSn(deviceSn);
query.setStartTime(startTime);
query.setEndTime(endTime);
query.setQualityFlag(qualityFlag);
query.setPageNum(pageNum);
query.setPageSize(pageSize);
return R.ok(historyDataService.queryQuantityHistory(query));
}
// ==================== 2. 历史水质数据分页查询 ====================
@Operation(summary = "历史水质数据查询(分页)")
@GetMapping("/history/quality")
public R<Page<WaterQuality>> queryQualityHistory(
@RequestParam(required = false) String area,
@RequestParam(required = false) String pointCode,
@RequestParam(required = false) String deviceSn,
@RequestParam(required = false) LocalDateTime startTime,
@RequestParam(required = false) LocalDateTime endTime,
@RequestParam(required = false) Integer qualityFlag,
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "20") Integer pageSize) {
HistoricalQuery query = new HistoricalQuery();
query.setArea(area);
query.setPointCode(pointCode);
query.setDeviceSn(deviceSn);
query.setStartTime(startTime);
query.setEndTime(endTime);
query.setQualityFlag(qualityFlag);
query.setPageNum(pageNum);
query.setPageSize(pageSize);
return R.ok(historyDataService.queryQualityHistory(query));
}
// ==================== 3. 水量区域聚合统计 ====================
@Operation(summary = "水量区域聚合统计")
@GetMapping("/aggregate/quantity/area")
public R<List<Map<String, Object>>> aggregateQuantityByArea(
@RequestParam LocalDateTime startTime,
@RequestParam LocalDateTime endTime,
@RequestParam(required = false) String area) {
return R.ok(historyDataService.aggregateQuantityByArea(startTime, endTime, area));
}
// ==================== 4. 水质区域聚合统计 ====================
@Operation(summary = "水质区域聚合统计")
@GetMapping("/aggregate/quality/area")
public R<List<Map<String, Object>>> aggregateQualityByArea(
@RequestParam LocalDateTime startTime,
@RequestParam LocalDateTime endTime,
@RequestParam(required = false) String area) {
return R.ok(historyDataService.aggregateQualityByArea(startTime, endTime, area));
}
// ==================== 5. 数据导出 ====================
@Operation(summary = "历史数据导出")
@PostMapping("/export")
public R<Map<String, Object>> exportData(@RequestBody ExportRequest request) {
return R.ok(historyDataService.queryForExport(request));
}
// ==================== 6. 生成报表 ====================
@Operation(summary = "自动生成报表")
@PostMapping("/report/generate")
public R<DataReport> generateReport(
@RequestParam String reportType,
@RequestParam String dataType,
@RequestParam(required = false) String area,
@RequestParam(required = false) LocalDate periodStart,
@RequestParam(required = false) LocalDate periodEnd) {
if (periodStart != null && periodEnd != null) {
return R.ok(reportService.generateReport(reportType, dataType, area, periodStart, periodEnd));
}
return R.ok(reportService.generateReport(reportType, dataType, area));
}
// ==================== 7. 报表列表(分页) ====================
@Operation(summary = "报表列表(分页)")
@GetMapping("/report/list")
public R<Page<DataReport>> listReports(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) String reportType,
@RequestParam(required = false) String dataType) {
return R.ok(reportService.listReports(pageNum, pageSize, reportType, dataType));
}
// ==================== 8. 报表详情 ====================
@Operation(summary = "报表详情")
@GetMapping("/report/{id}")
public R<DataReport> getReportDetail(@PathVariable Long id) {
return R.ok(reportService.getReportDetail(id));
}
// ==================== 9. 最近报表 ====================
@Operation(summary = "查询最近生成的报表")
@GetMapping("/report/recent")
public R<List<DataReport>> recentReports(
@RequestParam(required = false) String reportType,
@RequestParam(required = false) String dataType,
@RequestParam(defaultValue = "10") int limit) {
return R.ok(reportService.findRecentReports(reportType, dataType, limit));
}
// ==================== 10. 删除报表 ====================
@Operation(summary = "删除报表")
@DeleteMapping("/report/{id}")
public R<Void> deleteReport(@PathVariable Long id) {
reportService.deleteReport(id);
return R.ok();
}
// ==================== 11. 模板列表 ====================
@Operation(summary = "报表模板列表")
@GetMapping("/template/list")
public R<List<ReportTemplate>> listTemplates(
@RequestParam(required = false) String reportType,
@RequestParam(required = false) String dataType) {
return R.ok(reportService.listTemplates(reportType, dataType));
}
// ==================== 12. 模板详情 ====================
@Operation(summary = "报表模板详情")
@GetMapping("/template/{id}")
public R<ReportTemplate> getTemplate(@PathVariable Long id) {
return R.ok(reportService.getTemplate(id));
}
// ==================== 13. 创建模板 ====================
@Operation(summary = "创建报表模板")
@PostMapping("/template")
public R<ReportTemplate> createTemplate(@RequestBody ReportTemplate template) {
return R.ok(reportService.createTemplate(template));
}
// ==================== 14. 更新模板 ====================
@Operation(summary = "更新报表模板")
@PutMapping("/template/{id}")
public R<ReportTemplate> updateTemplate(@PathVariable Long id, @RequestBody ReportTemplate template) {
template.setId(id);
return R.ok(reportService.updateTemplate(template));
}
// ==================== 15. 同比分析 ====================
@Operation(summary = "同比分析(水量/水质)")
@GetMapping("/statistics/yoy")
public R<StatisticsResult> yearOverYear(
@RequestParam String dataType,
@RequestParam(required = false) LocalDate date,
@RequestParam(required = false) String area) {
LocalDate targetDate = date != null ? date : LocalDate.now();
if ("quantity".equals(dataType)) {
return R.ok(statisticsService.quantityYearOverYear(targetDate, area));
} else {
return R.ok(statisticsService.qualityYearOverYear(targetDate, area));
}
}
// ==================== 16. 环比分析 ====================
@Operation(summary = "环比分析(水量/水质)")
@GetMapping("/statistics/mom")
public R<StatisticsResult> monthOverMonth(
@RequestParam String dataType,
@RequestParam(required = false) LocalDate date,
@RequestParam(required = false) String area) {
LocalDate targetDate = date != null ? date : LocalDate.now();
if ("quantity".equals(dataType)) {
return R.ok(statisticsService.quantityMonthOverMonth(targetDate, area));
} else {
return R.ok(statisticsService.qualityMonthOverMonth(targetDate, area));
}
}
// ==================== 17. 趋势分析 ====================
@Operation(summary = "趋势分析(日级)")
@GetMapping("/statistics/trend")
public R<StatisticsResult> trend(
@RequestParam String dataType,
@RequestParam LocalDate startDate,
@RequestParam LocalDate endDate,
@RequestParam(required = false) String area,
@RequestParam(required = false) String pointCode) {
if ("quantity".equals(dataType)) {
return R.ok(statisticsService.quantityTrend(startDate, endDate, area, pointCode));
} else {
return R.ok(statisticsService.qualityTrend(startDate, endDate, area, pointCode));
}
}
// ==================== 18. 月度趋势(年度) ====================
@Operation(summary = "月度趋势(年度报表)")
@GetMapping("/statistics/monthly-trend")
public R<StatisticsResult> monthlyTrend(
@RequestParam String dataType,
@RequestParam int year,
@RequestParam(required = false) String area) {
if ("quantity".equals(dataType)) {
return R.ok(statisticsService.quantityMonthlyTrend(year, area));
} else {
return R.ok(statisticsService.qualityMonthlyTrend(year, area));
}
}
// ==================== 19. 仪表板概览 ====================
@Operation(summary = "数据统计仪表板概览")
@GetMapping("/statistics/dashboard")
public R<Map<String, Object>> dashboard(
@RequestParam(required = false) LocalDate date,
@RequestParam(required = false) String area) {
LocalDate targetDate = date != null ? date : LocalDate.now();
return R.ok(statisticsService.dashboardOverview(targetDate, area));
}
// ==================== 20. 报表类型统计 ====================
@Operation(summary = "报表类型统计")
@GetMapping("/report/statistics")
public R<List<Map<String, Object>>> reportStatistics() {
return R.ok(reportService.countReportsByType());
}
}
@@ -0,0 +1,29 @@
package com.water.data_engine.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDate;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("de_data_report")
public class DataReport extends BaseEntity {
private String reportName;
private String reportCode;
private Long templateId;
private String reportType;
private String dataType;
private String area;
private LocalDate periodStart;
private LocalDate periodEnd;
@TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
private Object content;
@TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
private Object summary;
private String filePath;
private String status;
private String generatedBy;
}
@@ -0,0 +1,19 @@
package com.water.data_engine.entity;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class HistoricalQuery {
private String dataType;
private String area;
private String pointCode;
private String deviceSn;
private LocalDateTime startTime;
private LocalDateTime endTime;
private Integer qualityFlag;
private Integer pageNum = 1;
private Integer pageSize = 20;
private String orderBy;
private String orderDirection = "desc";
}
@@ -0,0 +1,21 @@
package com.water.data_engine.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("de_report_template")
public class ReportTemplate extends BaseEntity {
private String templateName;
private String templateCode;
private String reportType;
private String dataType;
private String description;
@TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
private Object config;
private String cronExpr;
private Integer enabled;
}
@@ -0,0 +1,31 @@
package com.water.data_engine.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("de_water_quality")
public class WaterQuality extends BaseEntity {
private String monitorPoint;
private String pointCode;
private String area;
private String deviceSn;
private BigDecimal ph;
private BigDecimal turbidity;
private BigDecimal residualChlorine;
private BigDecimal dissolvedOxygen;
private BigDecimal conductivity;
private BigDecimal temperature;
private BigDecimal cod;
private BigDecimal ammoniaNitrogen;
private Integer isQualified;
private LocalDateTime collectTime;
private String dataType;
private Integer quality;
}
@@ -0,0 +1,27 @@
package com.water.data_engine.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("de_water_quantity")
public class WaterQuantity extends BaseEntity {
private String monitorPoint;
private String pointCode;
private String area;
private String deviceSn;
private BigDecimal flowRate;
private BigDecimal totalFlow;
private BigDecimal pressure;
private BigDecimal waterLevel;
private BigDecimal velocity;
private LocalDateTime collectTime;
private String dataType;
private Integer quality;
}
@@ -0,0 +1,19 @@
package com.water.data_engine.entity.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
@Data
public class StatisticsResult {
private String type;
private String area;
private String dataType;
private BigDecimal currentValue;
private BigDecimal compareValue;
private BigDecimal changeAmount;
private BigDecimal changeRate;
private List<Map<String, Object>> trendData;
private Map<String, Object> extra;
}
@@ -0,0 +1,5 @@
package com.water.data_engine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.data_engine.entity.DataReport;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface DataReportMapper extends BaseMapper<DataReport> {}
@@ -0,0 +1,5 @@
package com.water.data_engine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.data_engine.entity.HistoricalQuery;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface HistoricalQueryMapper extends BaseMapper<HistoricalQuery> {}
@@ -0,0 +1,5 @@
package com.water.data_engine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.data_engine.entity.ReportTemplate;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface ReportTemplateMapper extends BaseMapper<ReportTemplate> {}
@@ -0,0 +1,5 @@
package com.water.data_engine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.data_engine.entity.WaterQuality;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface WaterQualityMapper extends BaseMapper<WaterQuality> {}
@@ -0,0 +1,5 @@
package com.water.data_engine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.data_engine.entity.WaterQuantity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface WaterQuantityMapper extends BaseMapper<WaterQuantity> {}
@@ -0,0 +1,108 @@
package com.water.data_engine.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.data_engine.entity.HistoricalQuery;
import com.water.data_engine.entity.WaterQuantity;
import com.water.data_engine.entity.WaterQuality;
import com.water.data_engine.mapper.WaterQuantityMapper;
import com.water.data_engine.mapper.WaterQualityMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.*;
@Service
@RequiredArgsConstructor
public class HistoryDataService {
private final WaterQuantityMapper quantityMapper;
private final WaterQualityMapper qualityMapper;
/**
* 历史水量数据分页查询
*/
public Page<WaterQuantity> queryQuantityHistory(HistoricalQuery query) {
Page<WaterQuantity> page = new Page<>(query.getPageNum(), query.getPageSize());
LambdaQueryWrapper<WaterQuantity> wrapper = new LambdaQueryWrapper<>();
if (query.getArea() != null && !query.getArea().isBlank()) {
wrapper.eq(WaterQuantity::getArea, query.getArea());
}
if (query.getPointCode() != null && !query.getPointCode().isBlank()) {
wrapper.eq(WaterQuantity::getPointCode, query.getPointCode());
}
if (query.getStartTime() != null) {
wrapper.ge(WaterQuantity::getRecordTime, query.getStartTime());
}
if (query.getEndTime() != null) {
wrapper.le(WaterQuantity::getRecordTime, query.getEndTime());
}
wrapper.orderByDesc(WaterQuantity::getRecordTime);
return quantityMapper.selectPage(page, wrapper);
}
/**
* 历史水质数据分页查询
*/
public Page<WaterQuality> queryQualityHistory(HistoricalQuery query) {
Page<WaterQuality> page = new Page<>(query.getPageNum(), query.getPageSize());
LambdaQueryWrapper<WaterQuality> wrapper = new LambdaQueryWrapper<>();
if (query.getArea() != null && !query.getArea().isBlank()) {
wrapper.eq(WaterQuality::getArea, query.getArea());
}
if (query.getPointCode() != null && !query.getPointCode().isBlank()) {
wrapper.eq(WaterQuality::getPointCode, query.getPointCode());
}
if (query.getStartTime() != null) {
wrapper.ge(WaterQuality::getRecordTime, query.getStartTime());
}
if (query.getEndTime() != null) {
wrapper.le(WaterQuality::getRecordTime, query.getEndTime());
}
wrapper.orderByDesc(WaterQuality::getRecordTime);
return qualityMapper.selectPage(page, wrapper);
}
/**
* 导出历史数据
*/
public List<Map<String, Object>> exportHistory(String dataType, String area,
LocalDateTime start, LocalDateTime end) {
List<Map<String, Object>> result = new ArrayList<>();
if ("quantity".equals(dataType)) {
LambdaQueryWrapper<WaterQuantity> wrapper = new LambdaQueryWrapper<>();
if (area != null) wrapper.eq(WaterQuantity::getArea, area);
if (start != null) wrapper.ge(WaterQuantity::getRecordTime, start);
if (end != null) wrapper.le(WaterQuantity::getRecordTime, end);
List<WaterQuantity> records = quantityMapper.selectList(wrapper);
for (WaterQuantity r : records) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("区域", r.getArea());
row.put("监测点", r.getPointCode());
row.put("时间", r.getRecordTime());
row.put("水量(m³)", r.getQuantity());
row.put("单位", r.getUnit());
result.add(row);
}
} else if ("quality".equals(dataType)) {
LambdaQueryWrapper<WaterQuality> wrapper = new LambdaQueryWrapper<>();
if (area != null) wrapper.eq(WaterQuality::getArea, area);
if (start != null) wrapper.ge(WaterQuality::getRecordTime, start);
if (end != null) wrapper.le(WaterQuality::getRecordTime, end);
List<WaterQuality> records = qualityMapper.selectList(wrapper);
for (WaterQuality r : records) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("区域", r.getArea());
row.put("监测点", r.getPointCode());
row.put("时间", r.getRecordTime());
row.put("浊度(NTU)", r.getTurbidity());
row.put("pH", r.getPh());
row.put("余氯(mg/L)", r.getResidualChlorine());
row.put("结果", r.getResult());
result.add(row);
}
}
return result;
}
}
@@ -0,0 +1,111 @@
package com.water.data_engine.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.data_engine.entity.DataReport;
import com.water.data_engine.entity.ReportTemplate;
import com.water.data_engine.mapper.DataReportMapper;
import com.water.data_engine.mapper.ReportTemplateMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
@Service
@RequiredArgsConstructor
public class ReportService {
private final DataReportMapper reportMapper;
private final ReportTemplateMapper templateMapper;
/**
* 自动生成报表
*/
public DataReport generateReport(String reportType, String period) {
DataReport report = new DataReport();
report.setReportNo("RPT-" + System.currentTimeMillis());
report.setReportType(reportType); // daily/weekly/monthly/yearly
report.setPeriod(period);
report.setTitle(reportType + " 报表 " + period);
// Generate content based on type
Map<String, Object> content = new LinkedHashMap<>();
content.put("generatedAt", LocalDateTime.now());
content.put("period", period);
switch (reportType) {
case "daily" -> {
content.put("totalSupply", 12500.0 + Math.random() * 2000);
content.put("totalConsumption", 11000.0 + Math.random() * 1500);
content.put("alertCount", (int)(Math.random() * 10));
content.put("waterQualityRate", 98.5 + Math.random() * 1.5);
}
case "weekly" -> {
content.put("avgDailySupply", 12000.0 + Math.random() * 1000);
content.put("peakDay", "周三");
content.put("totalAlerts", (int)(Math.random() * 50));
content.put("avgQualityRate", 98.0 + Math.random() * 2.0);
}
case "monthly" -> {
content.put("totalSupply", 380000.0 + Math.random() * 50000);
content.put("totalConsumption", 350000.0 + Math.random() * 40000);
content.put("leakageRate", 8.0 + Math.random() * 4);
content.put("complaints", (int)(Math.random() * 30));
}
case "yearly" -> {
content.put("totalSupply", 4500000.0 + Math.random() * 500000);
content.put("yoyGrowth", -5.0 + Math.random() * 15);
content.put("infrastructureInvestment", 2500000.0);
content.put("serviceCoverage", 95.0 + Math.random() * 5);
}
}
report.setContent(content.toString());
report.setStatus("GENERATED");
report.setCreatedTime(LocalDateTime.now());
reportMapper.insert(report);
return report;
}
/**
* 获取报表列表
*/
public List<DataReport> listReports(String reportType, String status) {
LambdaQueryWrapper<DataReport> wrapper = new LambdaQueryWrapper<>();
if (reportType != null && !reportType.isBlank()) wrapper.eq(DataReport::getReportType, reportType);
if (status != null && !status.isBlank()) wrapper.eq(DataReport::getStatus, status);
return reportMapper.selectList(wrapper.orderByDesc(DataReport::getCreatedTime));
}
/**
* 获取报表详情
*/
public DataReport getReport(Long id) {
return reportMapper.selectById(id);
}
/**
* 发布报表
*/
public void publishReport(Long id) {
DataReport report = reportMapper.selectById(id);
if (report == null) throw new RuntimeException("报表不存在");
report.setStatus("PUBLISHED");
report.setPublishedTime(LocalDateTime.now());
reportMapper.updateById(report);
}
/**
* 模板管理
*/
public List<ReportTemplate> listTemplates() {
return templateMapper.selectList(null);
}
public ReportTemplate createTemplate(ReportTemplate template) {
template.setCreatedTime(LocalDateTime.now());
templateMapper.insert(template);
return template;
}
}
@@ -0,0 +1,106 @@
package com.water.data_engine.service;
import com.water.data_engine.entity.dto.StatisticsResult;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.*;
@Service
@RequiredArgsConstructor
public class StatisticsService {
/**
* 同比分析
*/
public StatisticsResult yearOverYear(String metric, String period) {
StatisticsResult result = new StatisticsResult();
result.setMetric(metric);
result.setPeriod(period);
result.setType("YOY");
// Simulated data
double current = 1000 + Math.random() * 5000;
double previous = 1000 + Math.random() * 5000;
result.setCurrentValue(current);
result.setPreviousValue(previous);
result.setChangeRate(previous > 0 ? (current - previous) / previous * 100 : 0);
result.setTrend(current > previous ? "上升" : "下降");
return result;
}
/**
* 环比分析
*/
public StatisticsResult monthOverMonth(String metric, String period) {
StatisticsResult result = new StatisticsResult();
result.setMetric(metric);
result.setPeriod(period);
result.setType("MOM");
double current = 500 + Math.random() * 2000;
double previous = 500 + Math.random() * 2000;
result.setCurrentValue(current);
result.setPreviousValue(previous);
result.setChangeRate(previous > 0 ? (current - previous) / previous * 100 : 0);
result.setTrend(current > previous ? "上升" : "下降");
return result;
}
/**
* 趋势分析
*/
public Map<String, Object> trendAnalysis(String metric, String area, String startPeriod, String endPeriod) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("metric", metric);
result.put("area", area);
result.put("startPeriod", startPeriod);
result.put("endPeriod", endPeriod);
// Generate trend data points
List<Map<String, Object>> dataPoints = new ArrayList<>();
double base = 1000 + Math.random() * 2000;
for (int i = 0; i < 12; i++) {
Map<String, Object> point = new LinkedHashMap<>();
point.put("period", "2025-" + String.format("%02d", i + 1));
point.put("value", base + Math.random() * 500 - 250);
dataPoints.add(point);
}
result.put("dataPoints", dataPoints);
// Summary
double avg = dataPoints.stream().mapToDouble(p -> (Double) p.get("value")).average().orElse(0);
double max = dataPoints.stream().mapToDouble(p -> (Double) p.get("value")).max().orElse(0);
double min = dataPoints.stream().mapToDouble(p -> (Double) p.get("value")).min().orElse(0);
result.put("average", avg);
result.put("max", max);
result.put("min", min);
result.put("overallTrend", dataPoints.get(dataPoints.size() - 1).get("value")
.compareTo(dataPoints.get(0).get("value")) > 0 ? "上升" : "下降");
return result;
}
/**
* 综合看板
*/
public Map<String, Object> dashboard() {
Map<String, Object> dashboard = new LinkedHashMap<>();
dashboard.put("todaySupply", 12500 + Math.random() * 2000);
dashboard.put("todayAlerts", (int)(Math.random() * 10));
dashboard.put("deviceOnlineRate", 0.92 + Math.random() * 0.08);
dashboard.put("waterQualityRate", 97 + Math.random() * 3);
dashboard.put("activeWorkOrders", (int)(Math.random() * 20));
dashboard.put("monthlyConsumption", 350000 + Math.random() * 50000);
// Top alerts
List<Map<String, Object>> topAlerts = new ArrayList<>();
topAlerts.add(Map.of("area", "A区主管", "type", "压力异常", "level", "重要"));
topAlerts.add(Map.of("area", "B区支管", "type", "流量偏低", "level", "一般"));
dashboard.put("topAlerts", topAlerts);
return dashboard;
}
}
@@ -0,0 +1,56 @@
-- History Data & Report DDL
CREATE TABLE IF NOT EXISTS de_water_quantity (
id BIGSERIAL PRIMARY KEY,
area VARCHAR(100),
point_code VARCHAR(50),
device_sn VARCHAR(50),
quantity DOUBLE PRECISION,
unit VARCHAR(20),
quality_flag INT DEFAULT 0,
record_time TIMESTAMP,
created_time TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS de_water_quality (
id BIGSERIAL PRIMARY KEY,
area VARCHAR(100),
point_code VARCHAR(50),
device_sn VARCHAR(50),
turbidity DOUBLE PRECISION,
ph DOUBLE PRECISION,
residual_chlorine DOUBLE PRECISION,
color DOUBLE PRECISION,
odor DOUBLE PRECISION,
result VARCHAR(20),
quality_flag INT DEFAULT 0,
record_time TIMESTAMP,
created_time TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS de_data_report (
id BIGSERIAL PRIMARY KEY,
report_no VARCHAR(50) UNIQUE,
report_type VARCHAR(20),
period VARCHAR(50),
title VARCHAR(200),
content TEXT,
status VARCHAR(20) DEFAULT 'GENERATED',
published_time TIMESTAMP,
created_time TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS de_report_template (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200),
report_type VARCHAR(20),
template_content TEXT,
description TEXT,
status INT DEFAULT 1,
created_time TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wq_area_time ON de_water_quantity(area, record_time);
CREATE INDEX IF NOT EXISTS idx_wq_point ON de_water_quantity(point_code);
CREATE INDEX IF NOT EXISTS idx_qual_area_time ON de_water_quality(area, record_time);
CREATE INDEX IF NOT EXISTS idx_rpt_type ON de_data_report(report_type);
CREATE INDEX IF NOT EXISTS idx_rpt_status ON de_data_report(status);
@@ -0,0 +1,185 @@
package com.water.data_engine.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.data_engine.entity.*;
import com.water.data_engine.entity.dto.ExportRequest;
import com.water.data_engine.mapper.WaterQuantityMapper;
import com.water.data_engine.mapper.WaterQualityMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 历史数据服务测试
*/
@ExtendWith(MockitoExtension.class)
class HistoryDataServiceTest {
@Mock
private WaterQuantityMapper waterQuantityMapper;
@Mock
private WaterQualityMapper waterQualityMapper;
private HistoryDataService historyDataService;
@BeforeEach
void setUp() {
historyDataService = new HistoryDataService(waterQuantityMapper, waterQualityMapper);
}
@Test
@DisplayName("分页查询水量历史数据-按区域和时间范围")
void testQueryQuantityHistory() {
// Given
HistoricalQuery query = new HistoricalQuery();
query.setArea("城东");
query.setStartTime(LocalDateTime.of(2026, 1, 1, 0, 0));
query.setEndTime(LocalDateTime.of(2026, 1, 31, 23, 59));
query.setPageNum(1);
query.setPageSize(10);
WaterQuantity wq = new WaterQuantity();
wq.setId(1L);
wq.setMonitorPoint("城东水厂出口");
wq.setArea("城东");
wq.setFlowRate(new BigDecimal("120.5"));
wq.setPressure(new BigDecimal("0.35"));
wq.setCollectTime(LocalDateTime.of(2026, 1, 15, 10, 0));
Page<WaterQuantity> mockPage = new Page<>(1, 10);
mockPage.setRecords(List.of(wq));
mockPage.setTotal(1);
when(waterQuantityMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
.thenReturn(mockPage);
// When
Page<WaterQuantity> result = historyDataService.queryQuantityHistory(query);
// Then
assertNotNull(result);
assertEquals(1, result.getRecords().size());
assertEquals("城东", result.getRecords().get(0).getArea());
verify(waterQuantityMapper).selectPage(any(Page.class), any(LambdaQueryWrapper.class));
}
@Test
@DisplayName("分页查询水质历史数据-按监测点")
void testQueryQualityHistory() {
// Given
HistoricalQuery query = new HistoricalQuery();
query.setPointCode("WQ001");
query.setStartTime(LocalDateTime.of(2026, 1, 1, 0, 0));
query.setEndTime(LocalDateTime.of(2026, 1, 31, 23, 59));
query.setPageNum(1);
query.setPageSize(20);
WaterQuality wq = new WaterQuality();
wq.setId(1L);
wq.setMonitorPoint("水厂出口");
wq.setPointCode("WQ001");
wq.setPh(new BigDecimal("7.2"));
wq.setTurbidity(new BigDecimal("0.5"));
wq.setIsQualified(1);
wq.setCollectTime(LocalDateTime.of(2026, 1, 10, 8, 0));
Page<WaterQuality> mockPage = new Page<>(1, 20);
mockPage.setRecords(List.of(wq));
mockPage.setTotal(1);
when(waterQualityMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
.thenReturn(mockPage);
// When
Page<WaterQuality> result = historyDataService.queryQualityHistory(query);
// Then
assertNotNull(result);
assertEquals(1, result.getRecords().size());
assertEquals("WQ001", result.getRecords().get(0).getPointCode());
}
@Test
@DisplayName("水量区域聚合统计")
void testAggregateQuantityByArea() {
LocalDateTime start = LocalDateTime.of(2026, 1, 1, 0, 0);
LocalDateTime end = LocalDateTime.of(2026, 1, 31, 23, 59);
Map<String, Object> stat = new LinkedHashMap<>();
stat.put("area", "城东");
stat.put("avg_flow_rate", new BigDecimal("120.5"));
stat.put("record_count", 1000L);
when(waterQuantityMapper.aggregateByArea(eq(start), eq(end), isNull()))
.thenReturn(List.of(stat));
List<Map<String, Object>> result = historyDataService.aggregateQuantityByArea(start, end, null);
assertNotNull(result);
assertEquals(1, result.size());
assertEquals("城东", result.get(0).get("area"));
}
@Test
@DisplayName("导出水量数据-生成导出结构")
void testQueryForExport_Quantity() {
ExportRequest request = new ExportRequest();
request.setDataType("quantity");
request.setArea("城东");
request.setStartTime(LocalDateTime.of(2026, 1, 1, 0, 0));
request.setEndTime(LocalDateTime.of(2026, 1, 31, 23, 59));
request.setFormat("excel");
WaterQuantity wq = new WaterQuantity();
wq.setMonitorPoint("城东水厂");
wq.setArea("城东");
wq.setFlowRate(new BigDecimal("120.5"));
when(waterQuantityMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(List.of(wq));
Map<String, Object> result = historyDataService.queryForExport(request);
assertNotNull(result);
assertEquals("quantity", result.get("dataType"));
assertEquals("城东", result.get("area"));
assertNotNull(result.get("headers"));
assertNotNull(result.get("data"));
}
@Test
@DisplayName("导出水质数据-生成导出结构")
void testQueryForExport_Quality() {
ExportRequest request = new ExportRequest();
request.setDataType("quality");
request.setStartTime(LocalDateTime.of(2026, 1, 1, 0, 0));
request.setEndTime(LocalDateTime.of(2026, 1, 31, 23, 59));
WaterQuality wq = new WaterQuality();
wq.setMonitorPoint("水厂出口");
wq.setPh(new BigDecimal("7.2"));
wq.setIsQualified(1);
when(waterQualityMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(List.of(wq));
Map<String, Object> result = historyDataService.queryForExport(request);
assertNotNull(result);
assertEquals("quality", result.get("dataType"));
assertEquals(1, ((List<?>) result.get("data")).size());
}
}
@@ -0,0 +1,158 @@
package com.water.data_engine.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.data_engine.entity.*;
import com.water.data_engine.mapper.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 报表服务测试
*/
@ExtendWith(MockitoExtension.class)
class ReportServiceTest {
@Mock
private DataReportMapper dataReportMapper;
@Mock
private ReportTemplateMapper reportTemplateMapper;
@Mock
private WaterQuantityMapper waterQuantityMapper;
@Mock
private WaterQualityMapper waterQualityMapper;
private ReportService reportService;
@BeforeEach
void setUp() {
reportService = new ReportService(dataReportMapper, reportTemplateMapper,
waterQuantityMapper, waterQualityMapper);
}
@Test
@DisplayName("生成日报-水量")
void testGenerateDailyReport_Quantity() {
// Given
ReportTemplate template = new ReportTemplate();
template.setId(1L);
template.setTemplateCode("TPL-QTY-DAY");
template.setReportType("daily");
template.setDataType("quantity");
when(reportTemplateMapper.findByType("daily", "quantity")).thenReturn(List.of(template));
when(waterQuantityMapper.aggregateByArea(any(), any(), any())).thenReturn(List.of());
when(waterQuantityMapper.aggregateDaily(any(), any(), any(), any())).thenReturn(List.of());
when(dataReportMapper.insert(any(DataReport.class))).thenReturn(1);
// When
DataReport report = reportService.generateReport("daily", "quantity", null);
// Then
assertNotNull(report);
assertEquals("daily", report.getReportType());
assertEquals("quantity", report.getDataType());
assertEquals("generated", report.getStatus());
assertNotNull(report.getReportCode());
assertTrue(report.getReportCode().startsWith("RPT-"));
verify(dataReportMapper).insert(any(DataReport.class));
}
@Test
@DisplayName("生成月报-水质(指定时间段)")
void testGenerateMonthlyReport_Quality_WithPeriod() {
when(reportTemplateMapper.findByType("monthly", "quality")).thenReturn(List.of());
when(waterQualityMapper.aggregateByArea(any(), any(), any())).thenReturn(List.of());
when(waterQualityMapper.aggregateDaily(any(), any(), any(), any())).thenReturn(List.of());
when(dataReportMapper.insert(any(DataReport.class))).thenReturn(1);
LocalDate start = LocalDate.of(2026, 1, 1);
LocalDate end = LocalDate.of(2026, 1, 31);
DataReport report = reportService.generateReport("monthly", "quality", "城东", start, end);
assertNotNull(report);
assertEquals("monthly", report.getReportType());
assertEquals("quality", report.getDataType());
assertEquals("城东", report.getArea());
assertEquals(start, report.getPeriodStart());
assertEquals(end, report.getPeriodEnd());
}
@Test
@DisplayName("生成报表-不支持的类型抛异常")
void testGenerateReport_InvalidType() {
assertThrows(IllegalArgumentException.class, () ->
reportService.generateReport("invalid", "quantity", null)
);
}
@Test
@DisplayName("查询报表列表-分页")
void testListReports() {
Page<DataReport> mockPage = new Page<>(1, 10);
mockPage.setRecords(List.of());
mockPage.setTotal(0);
when(dataReportMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
.thenReturn(mockPage);
Page<DataReport> result = reportService.listReports(1, 10, "daily", null);
assertNotNull(result);
verify(dataReportMapper).selectPage(any(Page.class), any(LambdaQueryWrapper.class));
}
@Test
@DisplayName("模板CRUD操作")
void testTemplateOperations() {
// Create
ReportTemplate template = new ReportTemplate();
template.setTemplateName("测试模板");
template.setTemplateCode("TPL-TEST");
template.setReportType("daily");
template.setDataType("quantity");
when(reportTemplateMapper.insert(any(ReportTemplate.class))).thenReturn(1);
ReportTemplate created = reportService.createTemplate(template);
assertEquals("测试模板", created.getTemplateName());
// Get
when(reportTemplateMapper.selectById(1L)).thenReturn(template);
ReportTemplate found = reportService.getTemplate(1L);
assertNotNull(found);
// Not found
when(reportTemplateMapper.selectById(999L)).thenReturn(null);
assertThrows(RuntimeException.class, () -> reportService.getTemplate(999L));
}
@Test
@DisplayName("查询最近报表和统计")
void testRecentAndStatistics() {
when(dataReportMapper.findRecent(any(), any(), eq(5))).thenReturn(List.of());
when(dataReportMapper.countByType()).thenReturn(List.of());
List<DataReport> recent = reportService.findRecentReports(null, null, 5);
List<Map<String, Object>> stats = reportService.countReportsByType();
assertNotNull(recent);
assertNotNull(stats);
verify(dataReportMapper).findRecent(any(), any(), eq(5));
verify(dataReportMapper).countByType();
}
}
@@ -0,0 +1,180 @@
package com.water.data_engine.service;
import com.water.data_engine.entity.dto.StatisticsResult;
import com.water.data_engine.mapper.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 统计分析服务测试
*/
@ExtendWith(MockitoExtension.class)
class StatisticsServiceTest {
@Mock
private WaterQuantityMapper waterQuantityMapper;
@Mock
private WaterQualityMapper waterQualityMapper;
@Mock
private StatQuantityDailyMapper statQuantityDailyMapper;
@Mock
private StatQualityDailyMapper statQualityDailyMapper;
private StatisticsService statisticsService;
@BeforeEach
void setUp() {
statisticsService = new StatisticsService(
waterQuantityMapper, waterQualityMapper,
statQuantityDailyMapper, statQualityDailyMapper
);
}
@Test
@DisplayName("水量同比分析-有数据")
void testQuantityYearOverYear() {
// Given
LocalDate date = LocalDate.of(2026, 6, 14);
Map<String, Object> currentData = new LinkedHashMap<>();
currentData.put("area", "城东");
currentData.put("sum_total_flow", 15000.0);
Map<String, Object> compareData = new LinkedHashMap<>();
compareData.put("area", "城东");
compareData.put("sum_total_flow", 12000.0);
when(statQuantityDailyMapper.sumByArea(any(), any(), isNull()))
.thenReturn(List.of(currentData))
.thenReturn(List.of(compareData));
// When
StatisticsResult result = statisticsService.quantityYearOverYear(date, null);
// Then
assertNotNull(result);
assertEquals("yoy", result.getType());
assertEquals("quantity", result.getDataType());
assertEquals(0, new BigDecimal("15000").compareTo(result.getCurrentValue()));
assertEquals(0, new BigDecimal("12000").compareTo(result.getCompareValue()));
assertTrue(result.getChangeRate().compareTo(BigDecimal.ZERO) > 0);
}
@Test
@DisplayName("水量环比分析-对比期为零")
void testQuantityMonthOverMonth_ZeroCompare() {
LocalDate date = LocalDate.of(2026, 3, 15);
Map<String, Object> currentData = new LinkedHashMap<>();
currentData.put("area", "城南");
currentData.put("sum_total_flow", 5000.0);
when(statQuantityDailyMapper.sumByArea(any(), any(), isNull()))
.thenReturn(List.of(currentData))
.thenReturn(List.of());
StatisticsResult result = statisticsService.quantityMonthOverMonth(date, null);
assertNotNull(result);
assertEquals("mom", result.getType());
assertEquals(0, new BigDecimal("100").compareTo(result.getChangeRate()));
}
@Test
@DisplayName("水量日趋势分析")
void testQuantityTrend() {
LocalDate start = LocalDate.of(2026, 1, 1);
LocalDate end = LocalDate.of(2026, 1, 7);
Map<String, Object> day1 = new LinkedHashMap<>();
day1.put("stat_date", "2026-01-01");
day1.put("avg_flow_rate", new BigDecimal("120.5"));
day1.put("daily_flow", new BigDecimal("2892"));
day1.put("avg_pressure", new BigDecimal("0.35"));
Map<String, Object> day2 = new LinkedHashMap<>();
day2.put("stat_date", "2026-01-02");
day2.put("avg_flow_rate", new BigDecimal("118.3"));
day2.put("daily_flow", new BigDecimal("2839"));
day2.put("avg_pressure", new BigDecimal("0.34"));
when(waterQuantityMapper.aggregateDaily(any(), any(), any(), any()))
.thenReturn(List.of(day1, day2));
StatisticsResult result = statisticsService.quantityTrend(start, end, null, null);
assertNotNull(result);
assertEquals("trend", result.getType());
assertEquals("quantity", result.getDataType());
assertEquals(2, result.getTrendData().size());
assertEquals("2026-01-01", result.getTrendData().get(0).get("date"));
}
@Test
@DisplayName("水质日趋势分析")
void testQualityTrend() {
LocalDate start = LocalDate.of(2026, 1, 1);
LocalDate end = LocalDate.of(2026, 1, 3);
Map<String, Object> day1 = new LinkedHashMap<>();
day1.put("stat_date", "2026-01-01");
day1.put("avg_turbidity", new BigDecimal("0.5"));
day1.put("qualified_rate", new BigDecimal("98.5"));
when(waterQualityMapper.aggregateDaily(any(), any(), any(), any()))
.thenReturn(List.of(day1));
StatisticsResult result = statisticsService.qualityTrend(start, end, "城东", null);
assertNotNull(result);
assertEquals("trend", result.getType());
assertEquals("quality", result.getDataType());
assertEquals(1, result.getTrendData().size());
}
@Test
@DisplayName("仪表板概览-综合统计")
void testDashboardOverview() {
LocalDate date = LocalDate.of(2026, 6, 14);
when(waterQuantityMapper.aggregateByArea(any(), any(), any())).thenReturn(List.of());
when(waterQualityMapper.aggregateByArea(any(), any(), any())).thenReturn(List.of());
Map<String, Object> result = statisticsService.dashboardOverview(date, null);
assertNotNull(result);
assertEquals(date.toString(), result.get("date"));
assertTrue(result.containsKey("quantityStats"));
assertTrue(result.containsKey("qualityStats"));
}
@Test
@DisplayName("水质同比分析-无数据返回零")
void testQualityYearOverYear_Empty() {
when(statQualityDailyMapper.sumByArea(any(), any(), any()))
.thenReturn(List.of())
.thenReturn(List.of());
StatisticsResult result = statisticsService.qualityYearOverYear(LocalDate.now(), null);
assertNotNull(result);
assertEquals("yoy", result.getType());
assertEquals(0, BigDecimal.ZERO.compareTo(result.getCurrentValue()));
assertEquals(0, BigDecimal.ZERO.compareTo(result.getChangeRate()));
}
}
@@ -0,0 +1,109 @@
package com.water.dispatch.controller;
import com.water.common.core.result.R;
import com.water.dispatch.entity.*;
import com.water.dispatch.entity.dto.*;
import com.water.dispatch.service.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@Tag(name = "应急推演")
@RestController
@RequestMapping("/api/dispatch/emergency")
@RequiredArgsConstructor
public class EmergencyController {
private final PipeBurstService pipeBurstService;
private final WaterQualityIncidentService waterQualityService;
private final EmergencyDrillService drillService;
// === 爆管模拟 ===
@PostMapping("/pipe-burst/simulate")
public R<PipeBurstSimulation> simulatePipeBurst(@RequestBody PipeBurstRequest request) {
return R.ok(pipeBurstService.simulate(request));
}
@GetMapping("/pipe-burst/{id}/impact")
public R<Map<String, Object>> getImpactAnalysis(@PathVariable Long id) {
return R.ok(pipeBurstService.getImpactAnalysis(id));
}
@GetMapping("/pipe-burst/{id}/valve-plan")
public R<Map<String, Object>> getValvePlan(@PathVariable Long id) {
return R.ok(pipeBurstService.getValveShutdownPlan(id));
}
@GetMapping("/pipe-burst/list")
public R<List<PipeBurstSimulation>> listSimulations(@RequestParam(required = false) String status) {
return R.ok(pipeBurstService.listSimulations(status));
}
// === 水质异常处置 ===
@PostMapping("/water-quality/report")
public R<WaterQualityIncident> reportIncident(@RequestBody WaterQualityRequest request) {
return R.ok(waterQualityService.reportIncident(request));
}
@GetMapping("/water-quality/{type}/plan")
public R<Map<String, Object>> matchPlan(@PathVariable String type) {
return R.ok(waterQualityService.matchPlan(type));
}
@PostMapping("/water-quality/{id}/start-response")
public R<Map<String, Object>> startResponse(@PathVariable Long id) {
return R.ok(waterQualityService.startResponse(id));
}
@PostMapping("/water-quality/{id}/progress")
public R<Map<String, Object>> updateProgress(@PathVariable Long id,
@RequestParam String progress,
@RequestParam String operator) {
return R.ok(waterQualityService.updateProgress(id, progress, operator));
}
@PostMapping("/water-quality/{id}/resolve")
public R<WaterQualityIncident> resolve(@PathVariable Long id, @RequestParam String resolution) {
return R.ok(waterQualityService.resolveIncident(id, resolution));
}
@GetMapping("/water-quality/list")
public R<List<WaterQualityIncident>> listIncidents(
@RequestParam(required = false) String status,
@RequestParam(required = false) String pollutantType) {
return R.ok(waterQualityService.listIncidents(status, pollutantType));
}
@GetMapping("/water-quality/{id}/detail")
public R<Map<String, Object>> getIncidentDetail(@PathVariable Long id) {
return R.ok(waterQualityService.getIncidentDetail(id));
}
// === 应急演练 ===
@PostMapping("/drill")
public R<EmergencyDrill> createDrill(@RequestBody DrillCreateRequest request) {
return R.ok(drillService.createDrill(request));
}
@PostMapping("/drill/{id}/start")
public R<EmergencyDrill> startDrill(@PathVariable Long id) {
return R.ok(drillService.startDrill(id));
}
@PostMapping("/drill/{id}/complete")
public R<EmergencyDrill> completeDrill(@PathVariable Long id) {
return R.ok(drillService.completeDrill(id));
}
@PostMapping("/drill/{id}/evaluate")
public R<DrillEvaluation> evaluateDrill(@PathVariable Long id, @RequestBody DrillEvaluationRequest request) {
return R.ok(drillService.evaluateDrill(id, request));
}
@GetMapping("/drill/list")
public R<List<EmergencyDrill>> listDrills(@RequestParam(required = false) String status) {
return R.ok(drillService.listDrills(status));
}
}
@@ -0,0 +1,36 @@
package com.water.dispatch.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("disp_drill_evaluation")
public class DrillEvaluation {
@TableId(type = IdType.AUTO)
private Long id;
private String evaluationNo;
private Long drillId;
private String drillNo;
private Long evaluatorId;
private String evaluatorName;
private Integer responseScore;
private Integer handlingScore;
private Integer coordinationScore;
private Integer resourceScore;
private Integer reportingScore;
private Integer overallScore;
private String grade;
private String evaluationDetails;
private String strengths;
private String weaknesses;
private String recommendations;
private String followUpActions;
private String status;
private LocalDateTime evaluatedAt;
@TableLogic
private Integer deleted;
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}
@@ -0,0 +1,44 @@
package com.water.dispatch.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("disp_emergency_drill")
public class EmergencyDrill {
@TableId(type = IdType.AUTO)
private Long id;
private String drillNo;
private String name;
private String drillType;
private String scenario;
private String objectives;
private String planContent;
private String participatingDepts;
private Integer participantCount;
private Long organizerId;
private String organizerName;
private String location;
private LocalDate plannedDate;
private LocalDateTime plannedStartTime;
private LocalDateTime plannedEndTime;
private LocalDateTime actualStartTime;
private LocalDateTime actualEndTime;
private String status;
private String executionLog;
private String summary;
private String issuesFound;
private String improvements;
private Long relatedPlanId;
private String relatedPlanName;
private Long creatorId;
private String creatorName;
private String remark;
@TableLogic
private Integer deleted;
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}
@@ -0,0 +1,40 @@
package com.water.dispatch.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("disp_pipe_burst_simulation")
public class PipeBurstSimulation {
@TableId(type = IdType.AUTO)
private Long id;
private String simulationNo;
private Long pipeId;
private String pipeNo;
private Double longitude;
private Double latitude;
private String location;
private Double pipeDiameter;
private String pipeMaterial;
private Double pipePressure;
private Double impactRadius;
private Double impactArea;
private Integer affectedUsers;
private String affectedRegion;
private Double leakageRate;
private Double estimatedRepairHours;
private String valveShutdownPlan;
private Integer valveAffectedUsers;
private String status;
private String simulationParams;
private String simulationResult;
private Long creatorId;
private String creatorName;
private String remark;
@TableLogic
private Integer deleted;
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}
@@ -0,0 +1,46 @@
package com.water.dispatch.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("disp_water_quality_incident")
public class WaterQualityIncident {
@TableId(type = IdType.AUTO)
private Long id;
private String incidentNo;
private String title;
private String description;
private String sourceType;
private Long monitorPointId;
private String monitorPointName;
private String abnormalIndicator;
private Double detectedValue;
private Double standardValue;
private Double exceedMultiple;
private String severityLevel;
private String status;
private Long matchedPlanId;
private String matchedPlanName;
private String handlingPlan;
private String handlingMeasures;
private Integer handlingProgress;
private Long handlerId;
private String handlerName;
private LocalDateTime detectedTime;
private LocalDateTime confirmedTime;
private LocalDateTime handlingStartTime;
private LocalDateTime resolvedTime;
private String affectedArea;
private Integer affectedPopulation;
private String warningMessage;
private Long creatorId;
private String creatorName;
private String remark;
@TableLogic
private Integer deleted;
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}
@@ -0,0 +1,26 @@
package com.water.dispatch.entity.dto;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
public class DrillCreateRequest {
private String name;
private String drillType;
private String scenario;
private String objectives;
private String planContent;
private String participatingDepts;
private Integer participantCount;
private Long organizerId;
private String organizerName;
private String location;
private LocalDate plannedDate;
private LocalDateTime plannedStartTime;
private LocalDateTime plannedEndTime;
private Long relatedPlanId;
private String relatedPlanName;
private Long creatorId;
private String creatorName;
}
@@ -0,0 +1,20 @@
package com.water.dispatch.entity.dto;
import lombok.Data;
@Data
public class DrillEvaluationRequest {
private Long drillId;
private Long evaluatorId;
private String evaluatorName;
private Integer responseScore;
private Integer handlingScore;
private Integer coordinationScore;
private Integer resourceScore;
private Integer reportingScore;
private String evaluationDetails;
private String strengths;
private String weaknesses;
private String recommendations;
private String followUpActions;
}
@@ -0,0 +1,18 @@
package com.water.dispatch.entity.dto;
import lombok.Data;
@Data
public class PipeBurstRequest {
private Long pipeId;
private String pipeNo;
private Double longitude;
private Double latitude;
private String location;
private Double pipeDiameter;
private String pipeMaterial;
private Double pipePressure;
private Long creatorId;
private String creatorName;
private String remark;
}
@@ -0,0 +1,21 @@
package com.water.dispatch.entity.dto;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class WaterQualityRequest {
private String title;
private String description;
private String sourceType;
private Long monitorPointId;
private String monitorPointName;
private String abnormalIndicator;
private Double detectedValue;
private Double standardValue;
private LocalDateTime detectedTime;
private String affectedArea;
private Integer affectedPopulation;
private Long creatorId;
private String creatorName;
}
@@ -0,0 +1,8 @@
package com.water.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.dispatch.entity.DrillEvaluation;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface DrillEvaluationMapper extends BaseMapper<DrillEvaluation> {}
@@ -0,0 +1,8 @@
package com.water.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.dispatch.entity.EmergencyDrill;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface EmergencyDrillMapper extends BaseMapper<EmergencyDrill> {}
@@ -0,0 +1,8 @@
package com.water.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.dispatch.entity.PipeBurstSimulation;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PipeBurstSimulationMapper extends BaseMapper<PipeBurstSimulation> {}
@@ -0,0 +1,8 @@
package com.water.dispatch.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.dispatch.entity.WaterQualityIncident;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface WaterQualityIncidentMapper extends BaseMapper<WaterQualityIncident> {}
@@ -0,0 +1,302 @@
package com.water.dispatch.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.common.core.exception.BusinessException;
import com.water.dispatch.entity.DrillEvaluation;
import com.water.dispatch.entity.EmergencyDrill;
import com.water.dispatch.entity.dto.DrillCreateRequest;
import com.water.dispatch.entity.dto.DrillEvaluationRequest;
import com.water.dispatch.mapper.DrillEvaluationMapper;
import com.water.dispatch.mapper.EmergencyDrillMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.*;
/**
* 应急演练服务 - 演练计划/执行/评估全流程管理
*/
@Service
@RequiredArgsConstructor
public class EmergencyDrillService {
private final EmergencyDrillMapper drillMapper;
private final DrillEvaluationMapper evaluationMapper;
// ============ 演练计划 ============
/**
* 创建演练计划
*/
public EmergencyDrill createDrill(DrillCreateRequest request) {
if (request.getName() == null || request.getName().isBlank()) {
throw new BusinessException("演练名称不能为空");
}
EmergencyDrill drill = new EmergencyDrill();
drill.setDrillNo("DRILL-" + System.currentTimeMillis());
drill.setName(request.getName());
drill.setDrillType(request.getDrillType() != null ? request.getDrillType() : "OTHER");
drill.setScenario(request.getScenario());
drill.setObjectives(request.getObjectives());
drill.setPlanContent(request.getPlanContent());
drill.setParticipatingDepts(request.getParticipatingDepts());
drill.setParticipantCount(request.getParticipantCount());
drill.setOrganizerId(request.getOrganizerId());
drill.setOrganizerName(request.getOrganizerName());
drill.setLocation(request.getLocation());
drill.setPlannedDate(request.getPlannedDate());
drill.setPlannedStartTime(request.getPlannedStartTime());
drill.setPlannedEndTime(request.getPlannedEndTime());
drill.setRelatedPlanId(request.getRelatedPlanId());
drill.setRelatedPlanName(request.getRelatedPlanName());
drill.setCreatorId(request.getCreatorId());
drill.setCreatorName(request.getCreatorName());
drill.setStatus("PLANNED");
drillMapper.insert(drill);
return drill;
}
/**
* 查询演练详情
*/
public EmergencyDrill getDrillById(Long id) {
EmergencyDrill drill = drillMapper.selectById(id);
if (drill == null) throw new BusinessException("演练记录不存在");
return drill;
}
/**
* 查询演练列表
*/
public List<EmergencyDrill> listDrills(String status, String drillType) {
return drillMapper.selectList(
new LambdaQueryWrapper<EmergencyDrill>()
.eq(status != null, EmergencyDrill::getStatus, status)
.eq(drillType != null, EmergencyDrill::getDrillType, drillType)
.orderByDesc(EmergencyDrill::getCreatedAt));
}
// ============ 演练执行 ============
/**
* 启动演练
*/
public EmergencyDrill startDrill(Long id) {
EmergencyDrill drill = getDrillById(id);
if (!"PLANNED".equals(drill.getStatus())) {
throw new BusinessException("仅已计划状态的演练可启动");
}
drill.setStatus("IN_PROGRESS");
drill.setActualStartTime(LocalDateTime.now());
// 初始化执行日志
List<Map<String, Object>> logs = new ArrayList<>();
addExecutionLog(logs, "演练启动", "演练正式开始", LocalDateTime.now());
drill.setExecutionLog(logs.toString());
drillMapper.updateById(drill);
return drill;
}
/**
* 记录演练执行过程
*/
public EmergencyDrill logExecution(Long id, String stage, String content, String operator) {
EmergencyDrill drill = getDrillById(id);
if (!"IN_PROGRESS".equals(drill.getStatus())) {
throw new BusinessException("演练未在进行中");
}
List<Map<String, Object>> logs = parseExecutionLog(drill.getExecutionLog());
addExecutionLog(logs, stage, content + " (操作人: " + operator + ")", LocalDateTime.now());
drill.setExecutionLog(logs.toString());
drillMapper.updateById(drill);
return drill;
}
/**
* 完成演练
*/
public EmergencyDrill completeDrill(Long id, String summary, String issuesFound, String improvements) {
EmergencyDrill drill = getDrillById(id);
if (!"IN_PROGRESS".equals(drill.getStatus())) {
throw new BusinessException("演练未在进行中,无法完成");
}
drill.setStatus("COMPLETED");
drill.setActualEndTime(LocalDateTime.now());
drill.setSummary(summary);
drill.setIssuesFound(issuesFound);
drill.setImprovements(improvements);
// 追加完成日志
List<Map<String, Object>> logs = parseExecutionLog(drill.getExecutionLog());
addExecutionLog(logs, "演练结束", "演练完成,进入评估阶段", LocalDateTime.now());
drill.setExecutionLog(logs.toString());
drillMapper.updateById(drill);
return drill;
}
/**
* 取消演练
*/
public EmergencyDrill cancelDrill(Long id, String reason) {
EmergencyDrill drill = getDrillById(id);
if ("COMPLETED".equals(drill.getStatus()) || "EVALUATED".equals(drill.getStatus())) {
throw new BusinessException("已完成/已评估的演练不可取消");
}
drill.setStatus("CANCELLED");
drill.setRemark(reason);
drillMapper.updateById(drill);
return drill;
}
// ============ 演练评估 ============
/**
* 创建演练评估
*/
public DrillEvaluation evaluate(DrillEvaluationRequest request) {
if (request.getDrillId() == null) {
throw new BusinessException("演练ID不能为空");
}
EmergencyDrill drill = getDrillById(request.getDrillId());
if (!"COMPLETED".equals(drill.getStatus()) && !"EVALUATED".equals(drill.getStatus())) {
throw new BusinessException("仅已完成状态的演练可评估");
}
DrillEvaluation eval = new DrillEvaluation();
eval.setEvaluationNo("EVAL-" + System.currentTimeMillis());
eval.setDrillId(request.getDrillId());
eval.setDrillNo(drill.getDrillNo());
eval.setEvaluatorId(request.getEvaluatorId());
eval.setEvaluatorName(request.getEvaluatorName());
eval.setResponseScore(request.getResponseScore());
eval.setHandlingScore(request.getHandlingScore());
eval.setCoordinationScore(request.getCoordinationScore());
eval.setResourceScore(request.getResourceScore());
eval.setReportingScore(request.getReportingScore());
eval.setEvaluationDetails(request.getEvaluationDetails());
eval.setStrengths(request.getStrengths());
eval.setWeaknesses(request.getWeaknesses());
eval.setRecommendations(request.getRecommendations());
eval.setFollowUpActions(request.getFollowUpActions());
eval.setEvaluatedAt(LocalDateTime.now());
eval.setStatus("SUBMITTED");
// 计算综合评分(五维度加权平均)
int overallScore = calculateOverallScore(
request.getResponseScore(),
request.getHandlingScore(),
request.getCoordinationScore(),
request.getResourceScore(),
request.getReportingScore());
eval.setOverallScore(overallScore);
// 评估等级
eval.setGrade(calculateGrade(overallScore));
evaluationMapper.insert(eval);
// 更新演练状态为已评估
drill.setStatus("EVALUATED");
drillMapper.updateById(drill);
return eval;
}
/**
* 查询演练的评估列表
*/
public List<DrillEvaluation> getEvaluations(Long drillId) {
return evaluationMapper.selectList(
new LambdaQueryWrapper<DrillEvaluation>()
.eq(DrillEvaluation::getDrillId, drillId)
.orderByDesc(DrillEvaluation::getCreatedAt));
}
/**
* 查询评估详情
*/
public DrillEvaluation getEvaluationById(Long id) {
DrillEvaluation eval = evaluationMapper.selectById(id);
if (eval == null) throw new BusinessException("评估记录不存在");
return eval;
}
/**
* 获取演练统计
*/
public Map<String, Object> getDrillStatistics() {
Map<String, Object> stats = new LinkedHashMap<>();
long totalDrills = drillMapper.selectCount(null);
long completedDrills = drillMapper.selectCount(
new LambdaQueryWrapper<EmergencyDrill>().eq(EmergencyDrill::getStatus, "COMPLETED"));
long evaluatedDrills = drillMapper.selectCount(
new LambdaQueryWrapper<EmergencyDrill>().eq(EmergencyDrill::getStatus, "EVALUATED"));
long plannedDrills = drillMapper.selectCount(
new LambdaQueryWrapper<EmergencyDrill>().eq(EmergencyDrill::getStatus, "PLANNED"));
stats.put("totalDrills", totalDrills);
stats.put("completedDrills", completedDrills);
stats.put("evaluatedDrills", evaluatedDrills);
stats.put("plannedDrills", plannedDrills);
// 平均评分
List<DrillEvaluation> allEvals = evaluationMapper.selectList(null);
if (!allEvals.isEmpty()) {
double avgScore = allEvals.stream()
.filter(e -> e.getOverallScore() != null)
.mapToInt(DrillEvaluation::getOverallScore)
.average()
.orElse(0.0);
stats.put("averageScore", Math.round(avgScore * 10.0) / 10.0);
} else {
stats.put("averageScore", 0.0);
}
return stats;
}
// ============ 私有方法 ============
private void addExecutionLog(List<Map<String, Object>> logs, String stage, String content, LocalDateTime time) {
Map<String, Object> log = new LinkedHashMap<>();
log.put("stage", stage);
log.put("content", content);
log.put("time", time);
logs.add(log);
}
private List<Map<String, Object>> parseExecutionLog(String executionLog) {
if (executionLog == null || executionLog.isBlank()) {
return new ArrayList<>();
}
// 简单解析:返回新list,通过addExecutionLog追加
return new ArrayList<>();
}
private int calculateOverallScore(Integer response, Integer handling, Integer coordination,
Integer resource, Integer reporting) {
int r = response != null ? response : 0;
int h = handling != null ? handling : 0;
int c = coordination != null ? coordination : 0;
int re = resource != null ? resource : 0;
int rp = reporting != null ? reporting : 0;
// 加权: 响应25% + 处置30% + 协调20% + 资源15% + 信息10%
return (int) Math.round(r * 0.25 + h * 0.30 + c * 0.20 + re * 0.15 + rp * 0.10);
}
private String calculateGrade(int score) {
if (score >= 90) return "EXCELLENT";
if (score >= 75) return "GOOD";
if (score >= 60) return "PASS";
return "FAIL";
}
}
@@ -0,0 +1,97 @@
package com.water.dispatch.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.dispatch.entity.PipeBurstSimulation;
import com.water.dispatch.entity.dto.PipeBurstRequest;
import com.water.dispatch.mapper.PipeBurstSimulationMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.*;
@Service
@RequiredArgsConstructor
public class PipeBurstService {
private final PipeBurstSimulationMapper simulationMapper;
public PipeBurstSimulation simulate(PipeBurstRequest request) {
PipeBurstSimulation sim = new PipeBurstSimulation();
sim.setSimulationNo("PB-" + System.currentTimeMillis());
sim.setLocation(request.getLocation());
sim.setLng(request.getLng());
sim.setLat(request.getLat());
sim.setPipeDiameter(request.getPipeDiameter());
sim.setPipeMaterial(request.getPipeMaterial());
// Simulate impact based on diameter
double diameter = request.getPipeDiameter() != null ? request.getPipeDiameter() : 200.0;
int affectedRadius = (int)(diameter * 2.5); // meters
int affectedUsers = (int)(diameter * 0.6);
double waterLoss = diameter * 0.15; // m³/h
int repairHours = (int)(diameter / 50.0) + 2;
sim.setAffectedRadius(affectedRadius);
sim.setAffectedUsers(affectedUsers);
sim.setEstimatedWaterLoss(waterLoss);
sim.setEstimatedRepairHours(repairHours);
sim.setPressureDrop(diameter * 0.02);
// Generate affected valves
List<String> valves = new ArrayList<>();
valves.add("V-" + (int)(Math.random() * 1000));
valves.add("V-" + (int)(Math.random() * 1000));
sim.setAffectedValves(String.join(",", valves));
sim.setStatus("COMPLETED");
sim.setCreatedTime(LocalDateTime.now());
simulationMapper.insert(sim);
return sim;
}
public Map<String, Object> getImpactAnalysis(Long id) {
PipeBurstSimulation sim = simulationMapper.selectById(id);
if (sim == null) throw new RuntimeException("模拟记录不存在");
Map<String, Object> analysis = new LinkedHashMap<>();
analysis.put("simulation", sim);
analysis.put("affectedArea", sim.getAffectedRadius() * sim.getAffectedRadius() * Math.PI / 10000 + " 公顷");
analysis.put("estimatedCost", sim.getAffectedUsers() * 150.0 + " 元");
analysis.put("priority", sim.getPipeDiameter() > 300 ? "紧急" : sim.getPipeDiameter() > 150 ? "重要" : "一般");
// Suggested isolation plan
Map<String, Object> plan = new LinkedHashMap<>();
plan.put("closeValves", sim.getAffectedValves() != null ? sim.getAffectedValves().split(",") : new String[]{});
plan.put("notifyUsers", sim.getAffectedUsers());
plan.put("dispatchTeam", sim.getPipeDiameter() > 300 ? "应急抢修一队" : "常规维修组");
plan.put("estimatedArrival", sim.getPipeDiameter() > 300 ? "30分钟" : "60分钟");
analysis.put("isolationPlan", plan);
return analysis;
}
public List<PipeBurstSimulation> listSimulations(String status) {
LambdaQueryWrapper<PipeBurstSimulation> wrapper = new LambdaQueryWrapper<>();
if (status != null && !status.isBlank()) {
wrapper.eq(PipeBurstSimulation::getStatus, status);
}
return simulationMapper.selectList(wrapper.orderByDesc(PipeBurstSimulation::getCreatedTime));
}
public Map<String, Object> getValveShutdownPlan(Long simulationId) {
PipeBurstSimulation sim = simulationMapper.selectById(simulationId);
if (sim == null) throw new RuntimeException("模拟记录不存在");
Map<String, Object> plan = new LinkedHashMap<>();
plan.put("simulationNo", sim.getSimulationNo());
plan.put("burstLocation", sim.getLocation());
plan.put("valvesToClose", sim.getAffectedValves() != null ?
Arrays.asList(sim.getAffectedValves().split(",")) : Collections.emptyList());
plan.put("alternativeSupply", "附近消防栓临时供水");
plan.put("estimatedShutdownTime", "15分钟");
plan.put("estimatedRestoreTime", sim.getEstimatedRepairHours() + 2 + "小时");
return plan;
}
}
@@ -0,0 +1,133 @@
package com.water.dispatch.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.dispatch.entity.WaterQualityIncident;
import com.water.dispatch.entity.dto.WaterQualityRequest;
import com.water.dispatch.mapper.WaterQualityIncidentMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.*;
@Service
@RequiredArgsConstructor
public class WaterQualityIncidentService {
private final WaterQualityIncidentMapper incidentMapper;
// Predefined response plans
private static final Map<String, Map<String, Object>> RESPONSE_PLANS = Map.of(
"TURBIDITY", Map.of("planName", "浊度异常处置预案", "steps", List.of(
"1. 立即停止取水", "2. 启动备用水源", "3. 加密水质检测频次(每30分钟)",
"4. 排查上游污染源", "5. 调整水厂处理工艺", "6. 水质恢复后逐步恢复供水"
)),
"CHLORINE", Map.of("planName", "余氯异常处置预案", "steps", List.of(
"1. 检查加氯设备", "2. 调整加氯量", "3. 末梢水采样检测",
"4. 必要时冲洗管网", "5. 通知用户注意事项"
)),
"PH", Map.of("planName", "pH异常处置预案", "steps", List.of(
"1. 停止供水", "2. 排查酸碱污染源", "3. 中和处理",
"4. 管网冲洗", "5. 连续监测直至达标"
)),
"HEAVY_METAL", Map.of("planName", "重金属超标处置预案", "steps", List.of(
"1. 立即停止供水并上报", "2. 启动应急供水", "3. 排查工业污染源",
"4. 联系环保部门", "5. 管网彻底冲洗消毒", "6. 连续72小时监测"
))
);
public WaterQualityIncident reportIncident(WaterQualityRequest request) {
WaterQualityIncident incident = new WaterQualityIncident();
incident.setIncidentNo("WQI-" + System.currentTimeMillis());
incident.setLocation(request.getLocation());
incident.setPollutantType(request.getPollutantType());
incident.setDetectedValue(request.getDetectedValue());
incident.setStandardValue(request.getStandardValue());
incident.setSeverity(calculateSeverity(request.getPollutantType(),
request.getDetectedValue(), request.getStandardValue()));
incident.setStatus("DETECTED");
incident.setDetectedTime(LocalDateTime.now());
incident.setCreatedTime(LocalDateTime.now());
// Auto-match response plan
Map<String, Object> plan = matchPlan(request.getPollutantType());
if (plan != null) {
incident.setMatchedPlan((String) plan.get("planName"));
}
incidentMapper.insert(incident);
return incident;
}
public Map<String, Object> matchPlan(String pollutantType) {
return RESPONSE_PLANS.getOrDefault(pollutantType,
Map.of("planName", "通用水质异常处置预案", "steps", List.of(
"1. 采样复检", "2. 分析异常原因", "3. 采取对应措施", "4. 持续监测"
)));
}
public Map<String, Object> startResponse(Long incidentId) {
WaterQualityIncident incident = incidentMapper.selectById(incidentId);
if (incident == null) throw new RuntimeException("水质事件不存在");
incident.setStatus("RESPONDING");
incident.setResponseStartTime(LocalDateTime.now());
incidentMapper.updateById(incident);
Map<String, Object> result = new LinkedHashMap<>();
result.put("incident", incident);
result.put("plan", matchPlan(incident.getPollutantType()));
result.put("responseTeam", "水质应急小组");
return result;
}
public Map<String, Object> updateProgress(Long incidentId, String progress, String operator) {
WaterQualityIncident incident = incidentMapper.selectById(incidentId);
if (incident == null) throw new RuntimeException("水质事件不存在");
String currentLog = incident.getResponseLog() != null ? incident.getResponseLog() : "";
incident.setResponseLog(currentLog + "[" + LocalDateTime.now() + "] " + operator + ": " + progress + "\n");
incidentMapper.updateById(incident);
return Map.of("incidentId", incidentId, "status", incident.getStatus(), "logUpdated", true);
}
public WaterQualityIncident resolveIncident(Long incidentId, String resolution) {
WaterQualityIncident incident = incidentMapper.selectById(incidentId);
if (incident == null) throw new RuntimeException("水质事件不存在");
incident.setStatus("RESOLVED");
incident.setResolvedTime(LocalDateTime.now());
incident.setResolution(resolution);
incidentMapper.updateById(incident);
return incident;
}
public List<WaterQualityIncident> listIncidents(String status, String pollutantType) {
LambdaQueryWrapper<WaterQualityIncident> wrapper = new LambdaQueryWrapper<>();
if (status != null && !status.isBlank()) wrapper.eq(WaterQualityIncident::getStatus, status);
if (pollutantType != null && !pollutantType.isBlank()) wrapper.eq(WaterQualityIncident::getPollutantType, pollutantType);
return incidentMapper.selectList(wrapper.orderByDesc(WaterQualityIncident::getCreatedTime));
}
public Map<String, Object> getIncidentDetail(Long id) {
WaterQualityIncident incident = incidentMapper.selectById(id);
if (incident == null) throw new RuntimeException("水质事件不存在");
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("incident", incident);
detail.put("plan", matchPlan(incident.getPollutantType()));
detail.put("exceedRate", incident.getStandardValue() > 0 ?
String.format("%.1f%%", (incident.getDetectedValue() / incident.getStandardValue() - 1) * 100) : "N/A");
return detail;
}
private String calculateSeverity(String type, Double detected, Double standard) {
if (standard == null || standard <= 0) return "MEDIUM";
double ratio = detected / standard;
if (ratio > 3.0 || "HEAVY_METAL".equals(type)) return "CRITICAL";
if (ratio > 2.0) return "HIGH";
if (ratio > 1.0) return "MEDIUM";
return "LOW";
}
}
@@ -0,0 +1,67 @@
-- Emergency Drill & Response DDL
CREATE TABLE IF NOT EXISTS disp_pipe_burst_simulation (
id BIGSERIAL PRIMARY KEY,
simulation_no VARCHAR(50) UNIQUE,
location VARCHAR(200),
lng DOUBLE PRECISION,
lat DOUBLE PRECISION,
pipe_diameter DOUBLE PRECISION,
pipe_material VARCHAR(50),
affected_radius INT,
affected_users INT,
estimated_water_loss DOUBLE PRECISION,
estimated_repair_hours INT,
pressure_drop DOUBLE PRECISION,
affected_valves TEXT,
status VARCHAR(20) DEFAULT 'COMPLETED',
created_time TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS disp_water_quality_incident (
id BIGSERIAL PRIMARY KEY,
incident_no VARCHAR(50) UNIQUE,
location VARCHAR(200),
pollutant_type VARCHAR(50),
detected_value DOUBLE PRECISION,
standard_value DOUBLE PRECISION,
severity VARCHAR(20),
status VARCHAR(20) DEFAULT 'DETECTED',
matched_plan VARCHAR(200),
detected_time TIMESTAMP,
response_start_time TIMESTAMP,
resolved_time TIMESTAMP,
response_log TEXT,
resolution TEXT,
created_time TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS disp_emergency_drill (
id BIGSERIAL PRIMARY KEY,
drill_no VARCHAR(50) UNIQUE,
name VARCHAR(200),
drill_type VARCHAR(30),
description TEXT,
status VARCHAR(20) DEFAULT 'PLANNED',
planned_time TIMESTAMP,
started_time TIMESTAMP,
completed_time TIMESTAMP,
created_time TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS disp_drill_evaluation (
id BIGSERIAL PRIMARY KEY,
drill_id BIGINT REFERENCES disp_emergency_drill(id),
score INT,
response_time_score INT,
coordination_score INT,
overall_rating VARCHAR(20),
findings TEXT,
recommendations TEXT,
evaluator VARCHAR(50),
created_time TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pbs_status ON disp_pipe_burst_simulation(status);
CREATE INDEX IF NOT EXISTS idx_wqi_status ON disp_water_quality_incident(status);
CREATE INDEX IF NOT EXISTS idx_wqi_type ON disp_water_quality_incident(pollutant_type);
CREATE INDEX IF NOT EXISTS idx_ed_status ON disp_emergency_drill(status);
@@ -0,0 +1,253 @@
package com.water.dispatch.service;
import com.water.common.core.exception.BusinessException;
import com.water.dispatch.entity.DrillEvaluation;
import com.water.dispatch.entity.EmergencyDrill;
import com.water.dispatch.entity.dto.DrillCreateRequest;
import com.water.dispatch.entity.dto.DrillEvaluationRequest;
import com.water.dispatch.mapper.DrillEvaluationMapper;
import com.water.dispatch.mapper.EmergencyDrillMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class EmergencyDrillServiceTest {
@Mock
private EmergencyDrillMapper drillMapper;
@Mock
private DrillEvaluationMapper evaluationMapper;
@InjectMocks
private EmergencyDrillService emergencyDrillService;
@Test
void testCreateDrill() {
when(drillMapper.insert(any())).thenAnswer(invocation -> {
EmergencyDrill drill = invocation.getArgument(0);
drill.setId(1L);
return 1;
});
DrillCreateRequest request = new DrillCreateRequest();
request.setName("2024年度爆管抢修演练");
request.setDrillType("PIPE_BURST");
request.setScenario("模拟DN600主干管爆裂");
request.setObjectives("检验应急响应速度和关阀操作");
request.setOrganizerId(1L);
request.setOrganizerName("张三");
request.setLocation("城南水厂");
request.setPlannedDate(LocalDate.of(2024, 6, 15));
request.setParticipantCount(50);
EmergencyDrill result = emergencyDrillService.createDrill(request);
assertNotNull(result.getDrillNo());
assertTrue(result.getDrillNo().startsWith("DRILL-"));
assertEquals("PLANNED", result.getStatus());
assertEquals("PIPE_BURST", result.getDrillType());
assertEquals("2024年度爆管抢修演练", result.getName());
verify(drillMapper).insert(any());
}
@Test
void testCreateDrillMissingName() {
DrillCreateRequest request = new DrillCreateRequest();
assertThrows(BusinessException.class, () -> emergencyDrillService.createDrill(request));
}
@Test
void testStartDrill() {
EmergencyDrill drill = buildDrill(1L, "PLANNED");
when(drillMapper.selectById(1L)).thenReturn(drill);
when(drillMapper.updateById(any())).thenReturn(1);
EmergencyDrill result = emergencyDrillService.startDrill(1L);
assertEquals("IN_PROGRESS", result.getStatus());
assertNotNull(result.getActualStartTime());
assertNotNull(result.getExecutionLog());
}
@Test
void testStartDrillWrongStatus() {
EmergencyDrill drill = buildDrill(1L, "IN_PROGRESS");
when(drillMapper.selectById(1L)).thenReturn(drill);
assertThrows(BusinessException.class, () -> emergencyDrillService.startDrill(1L));
}
@Test
void testLogExecution() {
EmergencyDrill drill = buildDrill(1L, "IN_PROGRESS");
drill.setExecutionLog("[]");
when(drillMapper.selectById(1L)).thenReturn(drill);
when(drillMapper.updateById(any())).thenReturn(1);
EmergencyDrill result = emergencyDrillService.logExecution(1L, "关阀操作", "完成上游阀门关闭", "李四");
assertEquals("IN_PROGRESS", result.getStatus());
assertNotNull(result.getExecutionLog());
}
@Test
void testLogExecutionWrongStatus() {
EmergencyDrill drill = buildDrill(1L, "PLANNED");
when(drillMapper.selectById(1L)).thenReturn(drill);
assertThrows(BusinessException.class,
() -> emergencyDrillService.logExecution(1L, "stage", "content", "operator"));
}
@Test
void testCompleteDrill() {
EmergencyDrill drill = buildDrill(1L, "IN_PROGRESS");
drill.setExecutionLog("[]");
when(drillMapper.selectById(1L)).thenReturn(drill);
when(drillMapper.updateById(any())).thenReturn(1);
EmergencyDrill result = emergencyDrillService.completeDrill(1L,
"演练顺利完成", "发现通信设备不足", "建议增配对讲机");
assertEquals("COMPLETED", result.getStatus());
assertNotNull(result.getActualEndTime());
assertEquals("演练顺利完成", result.getSummary());
assertEquals("发现通信设备不足", result.getIssuesFound());
}
@Test
void testCompleteDrillWrongStatus() {
EmergencyDrill drill = buildDrill(1L, "PLANNED");
when(drillMapper.selectById(1L)).thenReturn(drill);
assertThrows(BusinessException.class,
() -> emergencyDrillService.completeDrill(1L, "summary", "issues", "improvements"));
}
@Test
void testCancelDrill() {
EmergencyDrill drill = buildDrill(1L, "PLANNED");
when(drillMapper.selectById(1L)).thenReturn(drill);
when(drillMapper.updateById(any())).thenReturn(1);
EmergencyDrill result = emergencyDrillService.cancelDrill(1L, "天气原因取消");
assertEquals("CANCELLED", result.getStatus());
}
@Test
void testCancelCompletedDrill() {
EmergencyDrill drill = buildDrill(1L, "COMPLETED");
when(drillMapper.selectById(1L)).thenReturn(drill);
assertThrows(BusinessException.class, () -> emergencyDrillService.cancelDrill(1L, "reason"));
}
@Test
void testEvaluateDrill() {
EmergencyDrill drill = buildDrill(1L, "COMPLETED");
when(drillMapper.selectById(1L)).thenReturn(drill);
when(drillMapper.updateById(any())).thenReturn(1);
when(evaluationMapper.insert(any())).thenAnswer(invocation -> {
DrillEvaluation eval = invocation.getArgument(0);
eval.setId(1L);
return 1;
});
DrillEvaluationRequest request = new DrillEvaluationRequest();
request.setDrillId(1L);
request.setEvaluatorId(1L);
request.setEvaluatorName("王五");
request.setResponseScore(85);
request.setHandlingScore(90);
request.setCoordinationScore(80);
request.setResourceScore(75);
request.setReportingScore(88);
request.setStrengths("响应迅速");
request.setWeaknesses("资源调配待加强");
request.setRecommendations("增加备品备件储备");
DrillEvaluation result = emergencyDrillService.evaluate(request);
assertNotNull(result.getEvaluationNo());
assertTrue(result.getEvaluationNo().startsWith("EVAL-"));
assertNotNull(result.getOverallScore());
assertTrue(result.getOverallScore() > 0);
assertNotNull(result.getGrade());
assertEquals("SUBMITTED", result.getStatus());
// 验证综合评分计算
// 85*0.25 + 90*0.30 + 80*0.20 + 75*0.15 + 88*0.10 = 21.25+27+16+11.25+8.8 = 84.3 ≈ 84
assertEquals(84, result.getOverallScore());
assertEquals("GOOD", result.getGrade());
verify(drillMapper).updateById(any()); // 更新演练状态为 EVALUATED
}
@Test
void testEvaluateWrongStatus() {
EmergencyDrill drill = buildDrill(1L, "PLANNED");
when(drillMapper.selectById(1L)).thenReturn(drill);
DrillEvaluationRequest request = new DrillEvaluationRequest();
request.setDrillId(1L);
assertThrows(BusinessException.class, () -> emergencyDrillService.evaluate(request));
}
@Test
void testGetDrillStatistics() {
when(drillMapper.selectCount(any())).thenReturn(10L, 5L, 3L, 2L);
when(evaluationMapper.selectList(any())).thenReturn(Collections.emptyList());
Map<String, Object> stats = emergencyDrillService.getDrillStatistics();
assertNotNull(stats);
assertEquals(10L, stats.get("totalDrills"));
assertEquals(5L, stats.get("completedDrills"));
assertEquals(3L, stats.get("evaluatedDrills"));
assertEquals(2L, stats.get("plannedDrills"));
}
@Test
void testGetDrillStatisticsWithEvaluations() {
when(drillMapper.selectCount(any())).thenReturn(5L, 3L, 2L, 0L);
DrillEvaluation eval1 = new DrillEvaluation();
eval1.setOverallScore(85);
DrillEvaluation eval2 = new DrillEvaluation();
eval2.setOverallScore(75);
when(evaluationMapper.selectList(any())).thenReturn(List.of(eval1, eval2));
Map<String, Object> stats = emergencyDrillService.getDrillStatistics();
assertEquals(80.0, stats.get("averageScore"));
}
private EmergencyDrill buildDrill(Long id, String status) {
EmergencyDrill drill = new EmergencyDrill();
drill.setId(id);
drill.setDrillNo("DRILL-TEST-001");
drill.setName("测试演练");
drill.setDrillType("PIPE_BURST");
drill.setScenario("测试场景");
drill.setStatus(status);
drill.setOrganizerId(1L);
drill.setOrganizerName("张三");
return drill;
}
}
@@ -0,0 +1,164 @@
package com.water.dispatch.service;
import com.water.common.core.exception.BusinessException;
import com.water.dispatch.entity.PipeBurstSimulation;
import com.water.dispatch.entity.dto.PipeBurstRequest;
import com.water.dispatch.mapper.PipeBurstSimulationMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class PipeBurstServiceTest {
@Mock
private PipeBurstSimulationMapper simulationMapper;
@InjectMocks
private PipeBurstService pipeBurstService;
@Test
void testSimulatePipeBurst() {
when(simulationMapper.insert(any())).thenAnswer(invocation -> {
PipeBurstSimulation sim = invocation.getArgument(0);
sim.setId(1L);
return 1;
});
when(simulationMapper.updateById(any())).thenReturn(1);
PipeBurstRequest request = new PipeBurstRequest();
request.setLongitude(116.404);
request.setLatitude(39.915);
request.setLocation("北京市朝阳区建国路100号");
request.setPipeDiameter(400.0);
request.setPipeMaterial("球墨铸铁");
request.setPipePressure(0.35);
request.setCreatorId(1L);
request.setCreatorName("张三");
PipeBurstSimulation result = pipeBurstService.simulate(request);
assertNotNull(result.getSimulationNo());
assertTrue(result.getSimulationNo().startsWith("PBS-"));
assertEquals("COMPLETED", result.getStatus());
assertNotNull(result.getImpactRadius());
assertTrue(result.getImpactRadius() > 0);
assertNotNull(result.getAffectedUsers());
assertTrue(result.getAffectedUsers() > 0);
assertNotNull(result.getEstimatedRepairHours());
assertTrue(result.getEstimatedRepairHours() > 0);
assertNotNull(result.getValveShutdownPlan());
assertNotNull(result.getLeakageRate());
verify(simulationMapper).insert(any());
verify(simulationMapper, atLeastOnce()).updateById(any());
}
@Test
void testSimulateMissingCoordinates() {
PipeBurstRequest request = new PipeBurstRequest();
request.setLocation("测试位置");
assertThrows(BusinessException.class, () -> pipeBurstService.simulate(request));
}
@Test
void testSimulateDefaultValues() {
when(simulationMapper.insert(any())).thenReturn(1);
when(simulationMapper.updateById(any())).thenReturn(1);
PipeBurstRequest request = new PipeBurstRequest();
request.setLongitude(120.0);
request.setLatitude(30.0);
// 不设置pipeDiameter和pipePressure,使用默认值
PipeBurstSimulation result = pipeBurstService.simulate(request);
assertNotNull(result);
assertEquals("COMPLETED", result.getStatus());
assertNotNull(result.getImpactRadius());
}
@Test
void testGetImpactAnalysis() {
PipeBurstSimulation sim = buildSimulation(1L, "COMPLETED");
when(simulationMapper.selectById(1L)).thenReturn(sim);
Map<String, Object> analysis = pipeBurstService.getImpactAnalysis(1L);
assertNotNull(analysis);
assertEquals("PBS-TEST-001", analysis.get("simulationNo"));
assertTrue(analysis.containsKey("impactRadius"));
assertTrue(analysis.containsKey("affectedUsers"));
assertTrue(analysis.containsKey("valveShutdownPlan"));
}
@Test
void testGetValvePlan() {
PipeBurstSimulation sim = buildSimulation(1L, "COMPLETED");
when(simulationMapper.selectById(1L)).thenReturn(sim);
Map<String, Object> plan = pipeBurstService.getValvePlan(1L);
assertNotNull(plan);
assertEquals("PBS-TEST-001", plan.get("pipeNo"));
assertTrue(plan.containsKey("valveShutdownPlan"));
assertTrue(plan.containsKey("recommendation"));
}
@Test
void testArchiveSimulation() {
PipeBurstSimulation sim = buildSimulation(1L, "COMPLETED");
when(simulationMapper.selectById(1L)).thenReturn(sim);
when(simulationMapper.updateById(any())).thenReturn(1);
PipeBurstSimulation result = pipeBurstService.archive(1L);
assertEquals("ARCHIVED", result.getStatus());
verify(simulationMapper).updateById(any());
}
@Test
void testArchiveNonCompletedSimulation() {
PipeBurstSimulation sim = buildSimulation(1L, "RUNNING");
when(simulationMapper.selectById(1L)).thenReturn(sim);
assertThrows(BusinessException.class, () -> pipeBurstService.archive(1L));
}
@Test
void testGetByIdNotFound() {
when(simulationMapper.selectById(999L)).thenReturn(null);
assertThrows(BusinessException.class, () -> pipeBurstService.getById(999L));
}
private PipeBurstSimulation buildSimulation(Long id, String status) {
PipeBurstSimulation sim = new PipeBurstSimulation();
sim.setId(id);
sim.setSimulationNo("PBS-TEST-001");
sim.setPipeNo("PIPE-001");
sim.setLongitude(116.404);
sim.setLatitude(39.915);
sim.setLocation("测试位置");
sim.setPipeDiameter(300.0);
sim.setPipePressure(0.3);
sim.setImpactRadius(150.0);
sim.setImpactArea(70685.83);
sim.setAffectedUsers(354);
sim.setAffectedRegion("测试区域");
sim.setLeakageRate(100.0);
sim.setEstimatedRepairHours(6.0);
sim.setValveShutdownPlan("[{valveId: V-001}]");
sim.setValveAffectedUsers(637);
sim.setStatus(status);
return sim;
}
}
@@ -0,0 +1,186 @@
package com.water.dispatch.service;
import com.water.common.core.exception.BusinessException;
import com.water.dispatch.entity.WaterQualityIncident;
import com.water.dispatch.entity.dto.WaterQualityRequest;
import com.water.dispatch.mapper.EmergencyPlanMapper;
import com.water.dispatch.mapper.WaterQualityIncidentMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class WaterQualityServiceTest {
@Mock
private WaterQualityIncidentMapper incidentMapper;
@Mock
private EmergencyPlanMapper planMapper;
@InjectMocks
private WaterQualityService waterQualityService;
@Test
void testReportIncident() {
when(incidentMapper.insert(any())).thenAnswer(invocation -> {
WaterQualityIncident inc = invocation.getArgument(0);
inc.setId(1L);
return 1;
});
when(incidentMapper.updateById(any())).thenReturn(1);
when(planMapper.selectList(any())).thenReturn(Collections.emptyList());
WaterQualityRequest request = new WaterQualityRequest();
request.setTitle("末梢水浊度超标");
request.setDescription("某小区末梢水浊度异常");
request.setSourceType("END");
request.setAbnormalIndicator("TURBIDITY");
request.setDetectedValue(3.5);
request.setStandardValue(1.0);
request.setDetectedTime(LocalDateTime.now());
request.setCreatorId(1L);
request.setCreatorName("张三");
WaterQualityIncident result = waterQualityService.report(request);
assertNotNull(result.getIncidentNo());
assertTrue(result.getIncidentNo().startsWith("WQI-"));
assertEquals("DETECTED", result.getStatus());
assertEquals(3.5, result.getExceedMultiple());
assertNotNull(result.getSeverityLevel());
assertNotNull(result.getWarningMessage());
verify(incidentMapper).insert(any());
}
@Test
void testReportMissingIndicator() {
WaterQualityRequest request = new WaterQualityRequest();
request.setTitle("测试");
assertThrows(BusinessException.class, () -> waterQualityService.report(request));
}
@Test
void testConfirmIncident() {
WaterQualityIncident incident = buildIncident(1L, "DETECTED");
when(incidentMapper.selectById(1L)).thenReturn(incident);
when(incidentMapper.updateById(any())).thenReturn(1);
WaterQualityIncident result = waterQualityService.confirm(1L);
assertEquals("CONFIRMED", result.getStatus());
assertNotNull(result.getConfirmedTime());
}
@Test
void testConfirmWrongStatus() {
WaterQualityIncident incident = buildIncident(1L, "HANDLING");
when(incidentMapper.selectById(1L)).thenReturn(incident);
assertThrows(BusinessException.class, () -> waterQualityService.confirm(1L));
}
@Test
void testStartHandling() {
WaterQualityIncident incident = buildIncident(1L, "CONFIRMED");
when(incidentMapper.selectById(1L)).thenReturn(incident);
when(incidentMapper.updateById(any())).thenReturn(1);
when(planMapper.selectList(any())).thenReturn(Collections.emptyList());
WaterQualityIncident result = waterQualityService.startHandling(1L, 2L, "李四");
assertEquals("HANDLING", result.getStatus());
assertEquals(2L, result.getHandlerId());
assertEquals("李四", result.getHandlerName());
assertNotNull(result.getHandlingStartTime());
assertNotNull(result.getHandlingMeasures());
assertEquals(10, result.getHandlingProgress());
}
@Test
void testUpdateProgress() {
WaterQualityIncident incident = buildIncident(1L, "HANDLING");
when(incidentMapper.selectById(1L)).thenReturn(incident);
when(incidentMapper.updateById(any())).thenReturn(1);
WaterQualityIncident result = waterQualityService.updateProgress(1L, 50, "管网冲洗完成");
assertEquals(50, result.getHandlingProgress());
assertEquals("HANDLING", result.getStatus());
}
@Test
void testUpdateProgressToComplete() {
WaterQualityIncident incident = buildIncident(1L, "HANDLING");
when(incidentMapper.selectById(1L)).thenReturn(incident);
when(incidentMapper.updateById(any())).thenReturn(1);
WaterQualityIncident result = waterQualityService.updateProgress(1L, 100, "全部完成");
assertEquals(100, result.getHandlingProgress());
assertEquals("RESOLVED", result.getStatus());
assertNotNull(result.getResolvedTime());
}
@Test
void testResolveIncident() {
WaterQualityIncident incident = buildIncident(1L, "HANDLING");
when(incidentMapper.selectById(1L)).thenReturn(incident);
when(incidentMapper.updateById(any())).thenReturn(1);
WaterQualityIncident result = waterQualityService.resolve(1L, "水质恢复正常");
assertEquals("RESOLVED", result.getStatus());
assertNotNull(result.getResolvedTime());
}
@Test
void testResolveAlreadyResolved() {
WaterQualityIncident incident = buildIncident(1L, "RESOLVED");
when(incidentMapper.selectById(1L)).thenReturn(incident);
assertThrows(BusinessException.class, () -> waterQualityService.resolve(1L, "test"));
}
@Test
void testGetHandlingTimeline() {
WaterQualityIncident incident = buildIncident(1L, "HANDLING");
incident.setDetectedTime(LocalDateTime.of(2024, 1, 1, 8, 0));
incident.setConfirmedTime(LocalDateTime.of(2024, 1, 1, 9, 0));
incident.setHandlingStartTime(LocalDateTime.of(2024, 1, 1, 10, 0));
when(incidentMapper.selectById(1L)).thenReturn(incident);
Map<String, Object> timeline = waterQualityService.getHandlingTimeline(1L);
assertNotNull(timeline);
assertEquals("WQI-TEST-001", timeline.get("incidentNo"));
assertEquals("HANDLING", timeline.get("status"));
assertNotNull(timeline.get("timeline"));
}
private WaterQualityIncident buildIncident(Long id, String status) {
WaterQualityIncident incident = new WaterQualityIncident();
incident.setId(id);
incident.setIncidentNo("WQI-TEST-001");
incident.setTitle("浊度超标");
incident.setAbnormalIndicator("TURBIDITY");
incident.setDetectedValue(3.5);
incident.setStandardValue(1.0);
incident.setExceedMultiple(3.5);
incident.setSeverityLevel("LEVEL_2");
incident.setStatus(status);
incident.setHandlingProgress(0);
return incident;
}
}
+80
View File
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.water</groupId>
<artifactId>wm-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>wm-dma</artifactId>
<name>wm-dma</name>
<description>DMA分区计量与漏损分析模块</description>
<dependencies>
<!-- 公共模块 -->
<dependency>
<groupId>com.water</groupId>
<artifactId>wm-common</artifactId>
</dependency>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Nacos 服务发现 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!-- PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<!-- Hutool -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
</dependency>
<!-- Knife4j OpenAPI3 -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,15 @@
package com.water.dma;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication(scanBasePackages = "com.water")
@MapperScan("com.water.dma.mapper")
@EnableScheduling
public class DmaApplication {
public static void main(String[] args) {
SpringApplication.run(DmaApplication.class, args);
}
}
@@ -0,0 +1,33 @@
package com.water.dma.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.LocalDateTime;
@Configuration
public class MyBatisPlusConfig implements MetaObjectHandler {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL));
return interceptor;
}
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createdAt", LocalDateTime::now, LocalDateTime.class);
this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime::now, LocalDateTime.class);
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime::now, LocalDateTime.class);
}
}
@@ -0,0 +1,79 @@
package com.water.dma.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.common.core.result.R;
import com.water.dma.entity.DmaFlowRecord;
import com.water.dma.service.DmaFlowService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* DMA流量计量控制器
*/
@Tag(name = "DMA流量计量")
@RestController
@RequestMapping("/api/dma/flow")
@RequiredArgsConstructor
public class DmaFlowController {
private final DmaFlowService flowService;
@Operation(summary = "分页查询流量记录")
@GetMapping("/page")
public R<Page<DmaFlowRecord>> page(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) Long zoneId,
@RequestParam(required = false) Long meterId,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime) {
return R.ok(flowService.page(pageNum, pageSize, zoneId, meterId, startTime, endTime));
}
@Operation(summary = "创建流量记录")
@PostMapping
public R<DmaFlowRecord> create(@RequestBody DmaFlowRecord record) {
return R.ok(flowService.create(record));
}
@Operation(summary = "批量创建流量记录")
@PostMapping("/batch")
public R<String> batchCreate(@RequestBody List<DmaFlowRecord> records) {
flowService.batchCreate(records);
return R.ok("批量创建成功");
}
@Operation(summary = "获取分区进出水量汇总")
@GetMapping("/summary/{zoneId}")
public R<Map<String, Object>> getZoneFlowSummary(
@PathVariable Long zoneId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime) {
return R.ok(flowService.getZoneFlowSummary(zoneId, startTime, endTime));
}
@Operation(summary = "最小夜间流量(MNF)分析")
@GetMapping("/mnf/{zoneId}")
public R<Map<String, Object>> getMNFAnalysis(
@PathVariable Long zoneId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
return R.ok(flowService.getMNFAnalysis(zoneId, date));
}
@Operation(summary = "获取流量趋势")
@GetMapping("/trend/{zoneId}")
public R<List<Map<String, Object>>> getFlowTrend(
@PathVariable Long zoneId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime) {
return R.ok(flowService.getFlowTrend(zoneId, startTime, endTime));
}
}
@@ -0,0 +1,70 @@
package com.water.dma.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.common.core.result.R;
import com.water.dma.entity.DmaLeakageAnalysis;
import com.water.dma.service.DmaLeakageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
/**
* DMA漏损分析控制器
*/
@Tag(name = "DMA漏损分析")
@RestController
@RequestMapping("/api/dma/leakage")
@RequiredArgsConstructor
public class DmaLeakageController {
private final DmaLeakageService leakageService;
@Operation(summary = "分页查询漏损分析")
@GetMapping("/page")
public R<Page<DmaLeakageAnalysis>> page(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) Long zoneId,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
return R.ok(leakageService.page(pageNum, pageSize, zoneId, startDate, endDate));
}
@Operation(summary = "执行漏损分析")
@PostMapping("/analyze")
public R<DmaLeakageAnalysis> analyze(
@RequestParam Long zoneId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
@RequestParam BigDecimal supplyVolume,
@RequestParam BigDecimal saleVolume) {
return R.ok(leakageService.analyze(zoneId, date, supplyVolume, saleVolume));
}
@Operation(summary = "获取漏损趋势")
@GetMapping("/trend/{zoneId}")
public R<List<Map<String, Object>>> getTrend(
@PathVariable Long zoneId,
@RequestParam(defaultValue = "30") int days) {
return R.ok(leakageService.getTrend(zoneId, days));
}
@Operation(summary = "获取报警列表")
@GetMapping("/alarms")
public R<List<DmaLeakageAnalysis>> getAlarms(
@RequestParam(required = false) String alarmLevel) {
return R.ok(leakageService.getAlarms(alarmLevel));
}
@Operation(summary = "获取分区漏损汇总")
@GetMapping("/summary/{zoneId}")
public R<Map<String, Object>> getZoneSummary(@PathVariable Long zoneId) {
return R.ok(leakageService.getZoneSummary(zoneId));
}
}
@@ -0,0 +1,74 @@
package com.water.dma.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.common.core.result.R;
import com.water.dma.entity.DmaMeter;
import com.water.dma.service.DmaMeterService;
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;
/**
* DMA计量表管理控制器
*/
@Tag(name = "DMA计量表管理")
@RestController
@RequestMapping("/api/dma/meter")
@RequiredArgsConstructor
public class DmaMeterController {
private final DmaMeterService meterService;
@Operation(summary = "分页查询计量表")
@GetMapping("/page")
public R<Page<DmaMeter>> page(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) Long zoneId,
@RequestParam(required = false) String meterType) {
return R.ok(meterService.page(pageNum, pageSize, zoneId, meterType));
}
@Operation(summary = "获取计量表详情")
@GetMapping("/{id}")
public R<DmaMeter> getById(@PathVariable Long id) {
return R.ok(meterService.getById(id));
}
@Operation(summary = "创建计量表")
@PostMapping
public R<DmaMeter> create(@RequestBody DmaMeter meter) {
return R.ok(meterService.create(meter));
}
@Operation(summary = "更新计量表")
@PutMapping("/{id}")
public R<String> update(@PathVariable Long id, @RequestBody DmaMeter meter) {
meter.setId(id);
meterService.update(meter);
return R.ok("更新成功");
}
@Operation(summary = "删除计量表")
@DeleteMapping("/{id}")
public R<String> delete(@PathVariable Long id) {
meterService.delete(id);
return R.ok("删除成功");
}
@Operation(summary = "获取分区下的计量表")
@GetMapping("/zone/{zoneId}")
public R<List<DmaMeter>> listByZoneId(@PathVariable Long zoneId) {
return R.ok(meterService.listByZoneId(zoneId));
}
@Operation(summary = "绑定计量表到分区")
@PostMapping("/{meterId}/bind/{zoneId}")
public R<String> bindToZone(@PathVariable Long meterId, @PathVariable Long zoneId) {
meterService.bindToZone(meterId, zoneId);
return R.ok("绑定成功");
}
}
@@ -0,0 +1,73 @@
package com.water.dma.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.common.core.result.R;
import com.water.dma.entity.DmaZone;
import com.water.dma.service.DmaZoneService;
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;
/**
* DMA分区管理控制器
*/
@Tag(name = "DMA分区管理")
@RestController
@RequestMapping("/api/dma/zone")
@RequiredArgsConstructor
public class DmaZoneController {
private final DmaZoneService zoneService;
@Operation(summary = "分页查询分区")
@GetMapping("/page")
public R<Page<DmaZone>> page(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) String zoneName) {
return R.ok(zoneService.page(pageNum, pageSize, zoneName));
}
@Operation(summary = "获取分区详情")
@GetMapping("/{id}")
public R<DmaZone> getById(@PathVariable Long id) {
return R.ok(zoneService.getById(id));
}
@Operation(summary = "创建分区")
@PostMapping
public R<DmaZone> create(@RequestBody DmaZone zone) {
return R.ok(zoneService.create(zone));
}
@Operation(summary = "更新分区")
@PutMapping("/{id}")
public R<String> update(@PathVariable Long id, @RequestBody DmaZone zone) {
zone.setId(id);
zoneService.update(zone);
return R.ok("更新成功");
}
@Operation(summary = "删除分区")
@DeleteMapping("/{id}")
public R<String> delete(@PathVariable Long id) {
zoneService.delete(id);
return R.ok("删除成功");
}
@Operation(summary = "获取分区树")
@GetMapping("/tree")
public R<List<Map<String, Object>>> getZoneTree() {
return R.ok(zoneService.getZoneTree());
}
@Operation(summary = "获取所有分区")
@GetMapping("/list")
public R<List<DmaZone>> listAll() {
return R.ok(zoneService.listAll());
}
}
@@ -0,0 +1,75 @@
package com.water.dma.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.common.core.result.R;
import com.water.dma.entity.WaterBalance;
import com.water.dma.service.WaterBalanceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.Map;
/**
* 水平衡分析控制器
*/
@Tag(name = "水平衡分析")
@RestController
@RequestMapping("/api/dma/balance")
@RequiredArgsConstructor
public class WaterBalanceController {
private final WaterBalanceService balanceService;
@Operation(summary = "分页查询水平衡")
@GetMapping("/page")
public R<Page<WaterBalance>> page(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) Long zoneId,
@RequestParam(required = false) String period,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
return R.ok(balanceService.page(pageNum, pageSize, zoneId, period, startDate, endDate));
}
@Operation(summary = "获取水平衡详情")
@GetMapping("/{id}")
public R<WaterBalance> getById(@PathVariable Long id) {
return R.ok(balanceService.getById(id));
}
@Operation(summary = "创建水平衡记录")
@PostMapping
public R<WaterBalance> create(@RequestBody WaterBalance balance) {
return R.ok(balanceService.create(balance));
}
@Operation(summary = "更新水平衡记录")
@PutMapping("/{id}")
public R<String> update(@PathVariable Long id, @RequestBody WaterBalance balance) {
balance.setId(id);
balanceService.update(balance);
return R.ok("更新成功");
}
@Operation(summary = "删除水平衡记录")
@DeleteMapping("/{id}")
public R<String> delete(@PathVariable Long id) {
balanceService.delete(id);
return R.ok("删除成功");
}
@Operation(summary = "生成水平衡分析报告")
@GetMapping("/report/{zoneId}")
public R<Map<String, Object>> generateReport(
@PathVariable Long zoneId,
@RequestParam(defaultValue = "monthly") String period,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
return R.ok(balanceService.generateReport(zoneId, period, startDate, endDate));
}
}
@@ -0,0 +1,39 @@
package com.water.dma.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* DMA流量记录实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("dma_flow_record")
public class DmaFlowRecord extends BaseEntity {
/** 所属分区ID */
private Long zoneId;
/** 表计ID */
private Long meterId;
/** 瞬时流量(m³/h) */
private BigDecimal instantFlow;
/** 累计流量(m³) */
private BigDecimal totalFlow;
/** 压力(MPa) */
private BigDecimal pressure;
/** 采集时间 */
private LocalDateTime collectTime;
/** 数据质量: good/bad/missing */
private String dataQuality;
}
@@ -0,0 +1,57 @@
package com.water.dma.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* DMA漏损分析实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("dma_leakage_analysis")
public class DmaLeakageAnalysis extends BaseEntity {
/** 所属分区ID */
private Long zoneId;
/** 分析日期 */
private LocalDate analysisDate;
/** 供水量(m³) */
private BigDecimal supplyVolume;
/** 售水量(m³) */
private BigDecimal saleVolume;
/** 漏损量(m³) */
private BigDecimal leakageVolume;
/** 产销差率(%) */
private BigDecimal nrwRate;
/** 漏损率(%) */
private BigDecimal leakageRate;
/** 最小夜间流量(m³/h) */
private BigDecimal mnf;
/** MNF发生时间 */
private String mnfTime;
/** 背景漏损(m³/h) */
private BigDecimal backgroundLeakage;
/** 爆管漏损(m³/h) */
private BigDecimal burstLeakage;
/** 报警级别: normal/warning/critical */
private String alarmLevel;
/** 备注 */
private String remark;
}
@@ -0,0 +1,50 @@
package com.water.dma.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
/**
* DMA分区计量表实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("dma_meter")
public class DmaMeter extends BaseEntity {
/** 所属分区ID */
private Long zoneId;
/** 表计编号 */
private String meterCode;
/** 表计名称 */
private String meterName;
/** 表计类型: inlet=进水表/outlet=出水表/boundary=边界表 */
private String meterType;
/** 安装位置 */
private String location;
/** 经度 */
private BigDecimal longitude;
/** 纬度 */
private BigDecimal latitude;
/** 口径(mm) */
private Integer caliber;
/** 品牌/型号 */
private String brand;
/** 状态: online/offline/fault */
private String status;
/** 备注 */
private String remark;
}
@@ -0,0 +1,47 @@
package com.water.dma.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
/**
* DMA分区实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("dma_zone")
public class DmaZone extends BaseEntity {
/** 分区名称 */
private String zoneName;
/** 分区编码(唯一) */
private String zoneCode;
/** 父级分区ID(顶级为null) */
private Long parentId;
/** 分区层级: 1=一级/2=二级/3=三级 */
private Integer zoneLevel;
/** 所属区域 */
private String area;
/** 分区面积(km²) */
private BigDecimal areaSize;
/** 服务人口数 */
private Integer population;
/** 管网长度(km) */
private BigDecimal pipeLength;
/** 状态: active/inactive */
private String status;
/** 备注 */
private String remark;
}
@@ -0,0 +1,63 @@
package com.water.dma.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 水平衡表实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("dma_water_balance")
public class WaterBalance extends BaseEntity {
/** 所属分区ID */
private Long zoneId;
/** 统计周期: daily/monthly/yearly */
private String period;
/** 统计日期 */
private LocalDate statDate;
/** 总供水量(m³) */
private BigDecimal totalSupply;
/** 总售水量(m³) */
private BigDecimal totalSale;
/** 计费售水量(m³) */
private BigDecimal billingSale;
/** 免费供水量(m³) */
private BigDecimal freeSupply;
/** 表观漏损(m³) - 计量误差+偷水 */
private BigDecimal apparentLoss;
/** 实际漏损(m³) - 物理漏损 */
private BigDecimal realLoss;
/** 背景漏损(m³) */
private BigDecimal backgroundLoss;
/** 爆管漏损(m³) */
private BigDecimal burstLoss;
/** 总漏损量(m³) */
private BigDecimal totalLoss;
/** 产销差率(%) */
private BigDecimal nrwRate;
/** 漏损率(%) */
private BigDecimal leakageRate;
/** 备注 */
private String remark;
}
@@ -0,0 +1,28 @@
package com.water.dma.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.dma.entity.DmaFlowRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* DMA流量记录Mapper
*/
@Mapper
public interface DmaFlowRecordMapper extends BaseMapper<DmaFlowRecord> {
@Select("SELECT meter_id, SUM(instant_flow) as total_flow FROM dma_flow_record " +
"WHERE zone_id = #{zoneId} AND collect_time BETWEEN #{startTime} AND #{endTime} " +
"GROUP BY meter_id")
List<Map<String, Object>> sumFlowByMeter(Long zoneId, LocalDateTime startTime, LocalDateTime endTime);
@Select("SELECT MIN(instant_flow) as mnf FROM dma_flow_record " +
"WHERE zone_id = #{zoneId} AND collect_time::time BETWEEN '02:00:00' AND '04:00:00' " +
"AND collect_time::date = #{date}")
BigDecimal getMNF(Long zoneId, java.time.LocalDate date);
}
@@ -0,0 +1,12 @@
package com.water.dma.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.dma.entity.DmaLeakageAnalysis;
import org.apache.ibatis.annotations.Mapper;
/**
* DMA漏损分析Mapper
*/
@Mapper
public interface DmaLeakageAnalysisMapper extends BaseMapper<DmaLeakageAnalysis> {
}
@@ -0,0 +1,12 @@
package com.water.dma.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.dma.entity.DmaMeter;
import org.apache.ibatis.annotations.Mapper;
/**
* DMA计量表Mapper
*/
@Mapper
public interface DmaMeterMapper extends BaseMapper<DmaMeter> {
}
@@ -0,0 +1,12 @@
package com.water.dma.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.dma.entity.DmaZone;
import org.apache.ibatis.annotations.Mapper;
/**
* DMA分区Mapper
*/
@Mapper
public interface DmaZoneMapper extends BaseMapper<DmaZone> {
}
@@ -0,0 +1,12 @@
package com.water.dma.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.dma.entity.WaterBalance;
import org.apache.ibatis.annotations.Mapper;
/**
* 水平衡表Mapper
*/
@Mapper
public interface WaterBalanceMapper extends BaseMapper<WaterBalance> {
}
@@ -0,0 +1,137 @@
package com.water.dma.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.dma.entity.DmaFlowRecord;
import com.water.dma.mapper.DmaFlowRecordMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
/**
* DMA流量记录服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DmaFlowService {
private final DmaFlowRecordMapper flowRecordMapper;
/**
* 分页查询流量记录
*/
public Page<DmaFlowRecord> page(int pageNum, int pageSize, Long zoneId, Long meterId,
LocalDateTime startTime, LocalDateTime endTime) {
LambdaQueryWrapper<DmaFlowRecord> wrapper = new LambdaQueryWrapper<>();
if (zoneId != null) {
wrapper.eq(DmaFlowRecord::getZoneId, zoneId);
}
if (meterId != null) {
wrapper.eq(DmaFlowRecord::getMeterId, meterId);
}
if (startTime != null) {
wrapper.ge(DmaFlowRecord::getCollectTime, startTime);
}
if (endTime != null) {
wrapper.le(DmaFlowRecord::getCollectTime, endTime);
}
wrapper.orderByDesc(DmaFlowRecord::getCollectTime);
return flowRecordMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
}
/**
* 批量创建流量记录
*/
public void batchCreate(List<DmaFlowRecord> records) {
for (DmaFlowRecord record : records) {
if (record.getDataQuality() == null) {
record.setDataQuality("good");
}
flowRecordMapper.insert(record);
}
}
/**
* 创建单条流量记录
*/
public DmaFlowRecord create(DmaFlowRecord record) {
if (record.getDataQuality() == null) {
record.setDataQuality("good");
}
flowRecordMapper.insert(record);
return record;
}
/**
* 获取分区进出水量汇总
*/
public Map<String, Object> getZoneFlowSummary(Long zoneId, LocalDateTime startTime, LocalDateTime endTime) {
List<Map<String, Object>> flowSums = flowRecordMapper.sumFlowByMeter(zoneId, startTime, endTime);
Map<String, Object> result = new LinkedHashMap<>();
BigDecimal totalInflow = BigDecimal.ZERO;
BigDecimal totalOutflow = BigDecimal.ZERO;
for (Map<String, Object> row : flowSums) {
Object flowObj = row.get("total_flow");
BigDecimal flow = flowObj != null ? new BigDecimal(flowObj.toString()) : BigDecimal.ZERO;
totalInflow = totalInflow.add(flow);
}
result.put("zoneId", zoneId);
result.put("startTime", startTime);
result.put("endTime", endTime);
result.put("totalInflow", totalInflow);
result.put("totalOutflow", totalOutflow);
result.put("netFlow", totalInflow.subtract(totalOutflow));
result.put("meterCount", flowSums.size());
return result;
}
/**
* 最小夜间流量(MNF)分析
*/
public Map<String, Object> getMNFAnalysis(Long zoneId, LocalDate date) {
BigDecimal mnf = flowRecordMapper.getMNF(zoneId, date);
Map<String, Object> result = new LinkedHashMap<>();
result.put("zoneId", zoneId);
result.put("date", date);
result.put("mnf", mnf != null ? mnf : BigDecimal.ZERO);
result.put("mnfTime", "02:00-04:00");
result.put("analysisResult", mnf != null && mnf.compareTo(new BigDecimal("5")) > 0 ? "异常" : "正常");
return result;
}
/**
* 获取流量趋势
*/
public List<Map<String, Object>> getFlowTrend(Long zoneId, LocalDateTime startTime, LocalDateTime endTime) {
LambdaQueryWrapper<DmaFlowRecord> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DmaFlowRecord::getZoneId, zoneId);
wrapper.ge(DmaFlowRecord::getCollectTime, startTime);
wrapper.le(DmaFlowRecord::getCollectTime, endTime);
wrapper.orderByAsc(DmaFlowRecord::getCollectTime);
List<DmaFlowRecord> records = flowRecordMapper.selectList(wrapper);
List<Map<String, Object>> trend = new ArrayList<>();
for (DmaFlowRecord record : records) {
Map<String, Object> point = new LinkedHashMap<>();
point.put("time", record.getCollectTime());
point.put("instantFlow", record.getInstantFlow());
point.put("totalFlow", record.getTotalFlow());
point.put("pressure", record.getPressure());
trend.add(point);
}
return trend;
}
}
@@ -0,0 +1,179 @@
package com.water.dma.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.dma.entity.DmaLeakageAnalysis;
import com.water.dma.entity.DmaZone;
import com.water.dma.mapper.DmaLeakageAnalysisMapper;
import com.water.dma.mapper.DmaZoneMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.*;
/**
* DMA漏损分析服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DmaLeakageService {
private final DmaLeakageAnalysisMapper leakageMapper;
private final DmaZoneMapper zoneMapper;
/**
* 分页查询漏损分析
*/
public Page<DmaLeakageAnalysis> page(int pageNum, int pageSize, Long zoneId,
LocalDate startDate, LocalDate endDate) {
LambdaQueryWrapper<DmaLeakageAnalysis> wrapper = new LambdaQueryWrapper<>();
if (zoneId != null) {
wrapper.eq(DmaLeakageAnalysis::getZoneId, zoneId);
}
if (startDate != null) {
wrapper.ge(DmaLeakageAnalysis::getAnalysisDate, startDate);
}
if (endDate != null) {
wrapper.le(DmaLeakageAnalysis::getAnalysisDate, endDate);
}
wrapper.orderByDesc(DmaLeakageAnalysis::getAnalysisDate);
return leakageMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
}
/**
* 执行漏损分析
*/
public DmaLeakageAnalysis analyze(Long zoneId, LocalDate date, BigDecimal supplyVolume, BigDecimal saleVolume) {
DmaLeakageAnalysis analysis = new DmaLeakageAnalysis();
analysis.setZoneId(zoneId);
analysis.setAnalysisDate(date);
analysis.setSupplyVolume(supplyVolume);
analysis.setSaleVolume(saleVolume);
// 计算漏损量
BigDecimal leakageVolume = supplyVolume.subtract(saleVolume);
analysis.setLeakageVolume(leakageVolume);
// 计算产销差率
if (supplyVolume.compareTo(BigDecimal.ZERO) > 0) {
BigDecimal nrwRate = leakageVolume.multiply(new BigDecimal("100"))
.divide(supplyVolume, 2, RoundingMode.HALF_UP);
analysis.setNrwRate(nrwRate);
analysis.setLeakageRate(nrwRate);
} else {
analysis.setNrwRate(BigDecimal.ZERO);
analysis.setLeakageRate(BigDecimal.ZERO);
}
// 设置报警级别
String alarmLevel = determineAlarmLevel(analysis.getNrwRate());
analysis.setAlarmLevel(alarmLevel);
leakageMapper.insert(analysis);
return analysis;
}
/**
* 确定报警级别
*/
private String determineAlarmLevel(BigDecimal nrwRate) {
if (nrwRate == null) return "normal";
if (nrwRate.compareTo(new BigDecimal("20")) > 0) {
return "critical";
} else if (nrwRate.compareTo(new BigDecimal("12")) > 0) {
return "warning";
}
return "normal";
}
/**
* 获取漏损趋势分析
*/
public List<Map<String, Object>> getTrend(Long zoneId, int days) {
LocalDate endDate = LocalDate.now();
LocalDate startDate = endDate.minusDays(days);
LambdaQueryWrapper<DmaLeakageAnalysis> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DmaLeakageAnalysis::getZoneId, zoneId);
wrapper.ge(DmaLeakageAnalysis::getAnalysisDate, startDate);
wrapper.le(DmaLeakageAnalysis::getAnalysisDate, endDate);
wrapper.orderByAsc(DmaLeakageAnalysis::getAnalysisDate);
List<DmaLeakageAnalysis> analyses = leakageMapper.selectList(wrapper);
List<Map<String, Object>> trend = new ArrayList<>();
for (DmaLeakageAnalysis a : analyses) {
Map<String, Object> point = new LinkedHashMap<>();
point.put("date", a.getAnalysisDate());
point.put("supplyVolume", a.getSupplyVolume());
point.put("saleVolume", a.getSaleVolume());
point.put("leakageVolume", a.getLeakageVolume());
point.put("nrwRate", a.getNrwRate());
point.put("alarmLevel", a.getAlarmLevel());
trend.add(point);
}
return trend;
}
/**
* 获取报警列表
*/
public List<DmaLeakageAnalysis> getAlarms(String alarmLevel) {
LambdaQueryWrapper<DmaLeakageAnalysis> wrapper = new LambdaQueryWrapper<>();
if (alarmLevel != null && !alarmLevel.isEmpty()) {
wrapper.eq(DmaLeakageAnalysis::getAlarmLevel, alarmLevel);
} else {
wrapper.in(DmaLeakageAnalysis::getAlarmLevel, "warning", "critical");
}
wrapper.orderByDesc(DmaLeakageAnalysis::getAnalysisDate);
return leakageMapper.selectList(wrapper);
}
/**
* 获取分区漏损汇总
*/
public Map<String, Object> getZoneSummary(Long zoneId) {
LambdaQueryWrapper<DmaLeakageAnalysis> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DmaLeakageAnalysis::getZoneId, zoneId);
wrapper.orderByDesc(DmaLeakageAnalysis::getAnalysisDate);
wrapper.last("LIMIT 30");
List<DmaLeakageAnalysis> recent = leakageMapper.selectList(wrapper);
Map<String, Object> summary = new LinkedHashMap<>();
if (recent.isEmpty()) {
summary.put("zoneId", zoneId);
summary.put("avgNrwRate", BigDecimal.ZERO);
summary.put("totalLeakage", BigDecimal.ZERO);
summary.put("alarmCount", 0);
return summary;
}
BigDecimal totalNrw = BigDecimal.ZERO;
BigDecimal totalLeakage = BigDecimal.ZERO;
int alarmCount = 0;
for (DmaLeakageAnalysis a : recent) {
if (a.getNrwRate() != null) totalNrw = totalNrw.add(a.getNrwRate());
if (a.getLeakageVolume() != null) totalLeakage = totalLeakage.add(a.getLeakageVolume());
if (!"normal".equals(a.getAlarmLevel())) alarmCount++;
}
BigDecimal avgNrw = totalNrw.divide(new BigDecimal(recent.size()), 2, RoundingMode.HALF_UP);
DmaZone zone = zoneMapper.selectById(zoneId);
summary.put("zoneId", zoneId);
summary.put("zoneName", zone != null ? zone.getZoneName() : "");
summary.put("dataDays", recent.size());
summary.put("avgNrwRate", avgNrw);
summary.put("totalLeakage", totalLeakage);
summary.put("alarmCount", alarmCount);
return summary;
}
}
@@ -0,0 +1,99 @@
package com.water.dma.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.dma.entity.DmaMeter;
import com.water.dma.mapper.DmaMeterMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* DMA计量表管理服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DmaMeterService {
private final DmaMeterMapper meterMapper;
/**
* 分页查询计量表
*/
public Page<DmaMeter> page(int pageNum, int pageSize, Long zoneId, String meterType) {
LambdaQueryWrapper<DmaMeter> wrapper = new LambdaQueryWrapper<>();
if (zoneId != null) {
wrapper.eq(DmaMeter::getZoneId, zoneId);
}
if (meterType != null && !meterType.isEmpty()) {
wrapper.eq(DmaMeter::getMeterType, meterType);
}
wrapper.orderByAsc(DmaMeter::getMeterCode);
return meterMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
}
/**
* 获取计量表详情
*/
public DmaMeter getById(Long id) {
return meterMapper.selectById(id);
}
/**
* 创建计量表
*/
public DmaMeter create(DmaMeter meter) {
if (meter.getStatus() == null) {
meter.setStatus("online");
}
meterMapper.insert(meter);
return meter;
}
/**
* 更新计量表
*/
public void update(DmaMeter meter) {
meterMapper.updateById(meter);
}
/**
* 删除计量表
*/
public void delete(Long id) {
meterMapper.deleteById(id);
}
/**
* 获取分区下的所有计量表
*/
public List<DmaMeter> listByZoneId(Long zoneId) {
LambdaQueryWrapper<DmaMeter> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DmaMeter::getZoneId, zoneId);
return meterMapper.selectList(wrapper);
}
/**
* 统计分区表计数量
*/
public Long countByZoneId(Long zoneId) {
LambdaQueryWrapper<DmaMeter> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DmaMeter::getZoneId, zoneId);
return meterMapper.selectCount(wrapper);
}
/**
* 绑定计量表到分区
*/
public void bindToZone(Long meterId, Long zoneId) {
DmaMeter meter = meterMapper.selectById(meterId);
if (meter == null) {
throw new RuntimeException("计量表不存在");
}
meter.setZoneId(zoneId);
meterMapper.updateById(meter);
}
}
@@ -0,0 +1,122 @@
package com.water.dma.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.dma.entity.DmaZone;
import com.water.dma.mapper.DmaZoneMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.*;
/**
* DMA分区管理服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DmaZoneService {
private final DmaZoneMapper zoneMapper;
/**
* 分页查询分区
*/
public Page<DmaZone> page(int pageNum, int pageSize, String zoneName) {
LambdaQueryWrapper<DmaZone> wrapper = new LambdaQueryWrapper<>();
if (zoneName != null && !zoneName.isEmpty()) {
wrapper.like(DmaZone::getZoneName, zoneName);
}
wrapper.orderByAsc(DmaZone::getZoneLevel, DmaZone::getZoneCode);
return zoneMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
}
/**
* 获取分区详情
*/
public DmaZone getById(Long id) {
return zoneMapper.selectById(id);
}
/**
* 创建分区
*/
public DmaZone create(DmaZone zone) {
if (zone.getStatus() == null) {
zone.setStatus("active");
}
zoneMapper.insert(zone);
return zone;
}
/**
* 更新分区
*/
public void update(DmaZone zone) {
zoneMapper.updateById(zone);
}
/**
* 删除分区
*/
public void delete(Long id) {
// 检查是否有子分区
LambdaQueryWrapper<DmaZone> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DmaZone::getParentId, id);
Long count = zoneMapper.selectCount(wrapper);
if (count > 0) {
throw new RuntimeException("该分区存在子分区,无法删除");
}
zoneMapper.deleteById(id);
}
/**
* 获取分区树形结构
*/
public List<Map<String, Object>> getZoneTree() {
LambdaQueryWrapper<DmaZone> wrapper = new LambdaQueryWrapper<>();
wrapper.orderByAsc(DmaZone::getZoneLevel, DmaZone::getZoneCode);
List<DmaZone> allZones = zoneMapper.selectList(wrapper);
Map<Long, Map<String, Object>> zoneMap = new LinkedHashMap<>();
List<Map<String, Object>> roots = new ArrayList<>();
for (DmaZone zone : allZones) {
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", zone.getId());
node.put("zoneName", zone.getZoneName());
node.put("zoneCode", zone.getZoneCode());
node.put("zoneLevel", zone.getZoneLevel());
node.put("parentId", zone.getParentId());
node.put("area", zone.getArea());
node.put("status", zone.getStatus());
node.put("children", new ArrayList<>());
zoneMap.put(zone.getId(), node);
}
for (Map.Entry<Long, Map<String, Object>> entry : zoneMap.entrySet()) {
Map<String, Object> node = entry.getValue();
Long parentId = (Long) node.get("parentId");
if (parentId == null) {
roots.add(node);
} else {
Map<String, Object> parent = zoneMap.get(parentId);
if (parent != null) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> children = (List<Map<String, Object>>) parent.get("children");
children.add(node);
}
}
}
return roots;
}
/**
* 获取所有分区列表
*/
public List<DmaZone> listAll() {
return zoneMapper.selectList(new LambdaQueryWrapper<>());
}
}
@@ -0,0 +1,168 @@
package com.water.dma.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.dma.entity.WaterBalance;
import com.water.dma.mapper.WaterBalanceMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.*;
/**
* 水平衡分析服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WaterBalanceService {
private final WaterBalanceMapper balanceMapper;
/**
* 分页查询水平衡数据
*/
public Page<WaterBalance> page(int pageNum, int pageSize, Long zoneId, String period,
LocalDate startDate, LocalDate endDate) {
LambdaQueryWrapper<WaterBalance> wrapper = new LambdaQueryWrapper<>();
if (zoneId != null) {
wrapper.eq(WaterBalance::getZoneId, zoneId);
}
if (period != null && !period.isEmpty()) {
wrapper.eq(WaterBalance::getPeriod, period);
}
if (startDate != null) {
wrapper.ge(WaterBalance::getStatDate, startDate);
}
if (endDate != null) {
wrapper.le(WaterBalance::getStatDate, endDate);
}
wrapper.orderByDesc(WaterBalance::getStatDate);
return balanceMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
}
/**
* 创建水平衡记录
*/
public WaterBalance create(WaterBalance balance) {
// 自动计算总漏损
if (balance.getTotalLoss() == null && balance.getTotalSupply() != null && balance.getTotalSale() != null) {
balance.setTotalLoss(balance.getTotalSupply().subtract(balance.getTotalSale()));
}
// 计算产销差率
calculateRates(balance);
balanceMapper.insert(balance);
return balance;
}
/**
* 更新水平衡记录
*/
public void update(WaterBalance balance) {
calculateRates(balance);
balanceMapper.updateById(balance);
}
/**
* 删除水平衡记录
*/
public void delete(Long id) {
balanceMapper.deleteById(id);
}
/**
* 计算产销差率和漏损率
*/
private void calculateRates(WaterBalance balance) {
if (balance.getTotalSupply() != null && balance.getTotalSupply().compareTo(BigDecimal.ZERO) > 0) {
BigDecimal totalLoss = balance.getTotalLoss() != null ? balance.getTotalLoss() : BigDecimal.ZERO;
BigDecimal rate = totalLoss.multiply(new BigDecimal("100"))
.divide(balance.getTotalSupply(), 2, RoundingMode.HALF_UP);
balance.setNrwRate(rate);
balance.setLeakageRate(rate);
}
}
/**
* 生成水平衡分析报告
*/
public Map<String, Object> generateReport(Long zoneId, String period, LocalDate startDate, LocalDate endDate) {
LambdaQueryWrapper<WaterBalance> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(WaterBalance::getZoneId, zoneId);
wrapper.eq(WaterBalance::getPeriod, period);
wrapper.ge(WaterBalance::getStatDate, startDate);
wrapper.le(WaterBalance::getStatDate, endDate);
List<WaterBalance> records = balanceMapper.selectList(wrapper);
Map<String, Object> report = new LinkedHashMap<>();
report.put("zoneId", zoneId);
report.put("period", period);
report.put("startDate", startDate);
report.put("endDate", endDate);
if (records.isEmpty()) {
report.put("totalSupply", BigDecimal.ZERO);
report.put("totalSale", BigDecimal.ZERO);
report.put("totalLoss", BigDecimal.ZERO);
report.put("avgNrwRate", BigDecimal.ZERO);
report.put("recordCount", 0);
return report;
}
BigDecimal totalSupply = BigDecimal.ZERO;
BigDecimal totalSale = BigDecimal.ZERO;
BigDecimal totalLoss = BigDecimal.ZERO;
BigDecimal totalApparentLoss = BigDecimal.ZERO;
BigDecimal totalRealLoss = BigDecimal.ZERO;
for (WaterBalance r : records) {
if (r.getTotalSupply() != null) totalSupply = totalSupply.add(r.getTotalSupply());
if (r.getTotalSale() != null) totalSale = totalSale.add(r.getTotalSale());
if (r.getTotalLoss() != null) totalLoss = totalLoss.add(r.getTotalLoss());
if (r.getApparentLoss() != null) totalApparentLoss = totalApparentLoss.add(r.getApparentLoss());
if (r.getRealLoss() != null) totalRealLoss = totalRealLoss.add(r.getRealLoss());
}
BigDecimal avgNrwRate = BigDecimal.ZERO;
if (totalSupply.compareTo(BigDecimal.ZERO) > 0) {
avgNrwRate = totalLoss.multiply(new BigDecimal("100"))
.divide(totalSupply, 2, RoundingMode.HALF_UP);
}
report.put("totalSupply", totalSupply);
report.put("totalSale", totalSale);
report.put("totalLoss", totalLoss);
report.put("apparentLoss", totalApparentLoss);
report.put("realLoss", totalRealLoss);
report.put("avgNrwRate", avgNrwRate);
report.put("recordCount", records.size());
// IWA水平衡组成
Map<String, Object> iwa = new LinkedHashMap<>();
iwa.put("billingSale", records.stream()
.map(WaterBalance::getBillingSale)
.filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add));
iwa.put("freeSupply", records.stream()
.map(WaterBalance::getFreeSupply)
.filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add));
iwa.put("apparentLoss", totalApparentLoss);
iwa.put("realLoss", totalRealLoss);
report.put("iwaComponents", iwa);
return report;
}
/**
* 获取水平衡详情
*/
public WaterBalance getById(Long id) {
return balanceMapper.selectById(id);
}
}
+29
View File
@@ -0,0 +1,29 @@
server:
port: 8090
spring:
application:
name: wm-dma
datasource:
url: jdbc:postgresql://localhost:5432/water_management
username: water
password: water123
driver-class-name: org.postgresql.Driver
cloud:
nacos:
discovery:
server-addr: localhost:8848
mybatis-plus:
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
logging:
level:
com.water.dma: DEBUG
@@ -0,0 +1,130 @@
package com.water.dma.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.dma.entity.DmaFlowRecord;
import com.water.dma.mapper.DmaFlowRecordMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* DMA流量服务测试
*/
@ExtendWith(MockitoExtension.class)
class DmaFlowServiceTest {
@Mock
private DmaFlowRecordMapper flowRecordMapper;
private DmaFlowService flowService;
@BeforeEach
void setUp() {
flowService = new DmaFlowService(flowRecordMapper);
}
@Test
@DisplayName("创建流量记录")
void testCreate() {
DmaFlowRecord record = new DmaFlowRecord();
record.setZoneId(1L);
record.setMeterId(1L);
record.setInstantFlow(new BigDecimal("12.5"));
record.setTotalFlow(new BigDecimal("1000"));
record.setCollectTime(LocalDateTime.now());
when(flowRecordMapper.insert(any(DmaFlowRecord.class))).thenReturn(1);
DmaFlowRecord created = flowService.create(record);
assertNotNull(created);
assertEquals("good", created.getDataQuality());
verify(flowRecordMapper).insert(any(DmaFlowRecord.class));
}
@Test
@DisplayName("批量创建流量记录")
void testBatchCreate() {
DmaFlowRecord r1 = new DmaFlowRecord();
r1.setZoneId(1L);
r1.setMeterId(1L);
r1.setInstantFlow(new BigDecimal("10"));
r1.setCollectTime(LocalDateTime.now());
DmaFlowRecord r2 = new DmaFlowRecord();
r2.setZoneId(1L);
r2.setMeterId(2L);
r2.setInstantFlow(new BigDecimal("15"));
r2.setCollectTime(LocalDateTime.now());
when(flowRecordMapper.insert(any(DmaFlowRecord.class))).thenReturn(1);
flowService.batchCreate(Arrays.asList(r1, r2));
verify(flowRecordMapper, times(2)).insert(any(DmaFlowRecord.class));
}
@Test
@DisplayName("获取分区进出水量汇总")
void testGetZoneFlowSummary() {
Map<String, Object> row = new HashMap<>();
row.put("meter_id", 1L);
row.put("total_flow", new BigDecimal("100"));
when(flowRecordMapper.sumFlowByMeter(eq(1L), any(), any())).thenReturn(List.of(row));
Map<String, Object> summary = flowService.getZoneFlowSummary(1L,
LocalDateTime.now().minusHours(24), LocalDateTime.now());
assertNotNull(summary);
assertEquals(1L, summary.get("zoneId"));
assertEquals(1, summary.get("meterCount"));
}
@Test
@DisplayName("MNF分析-正常")
void testGetMNFAnalysis() {
when(flowRecordMapper.getMNF(eq(1L), any())).thenReturn(new BigDecimal("3.5"));
Map<String, Object> result = flowService.getMNFAnalysis(1L, LocalDate.now());
assertNotNull(result);
assertEquals(new BigDecimal("3.5"), result.get("mnf"));
assertEquals("正常", result.get("analysisResult"));
}
@Test
@DisplayName("MNF分析-无数据")
void testGetMNFAnalysisNoData() {
when(flowRecordMapper.getMNF(eq(1L), any())).thenReturn(null);
Map<String, Object> result = flowService.getMNFAnalysis(1L, LocalDate.now());
assertEquals(BigDecimal.ZERO, result.get("mnf"));
}
@Test
@DisplayName("获取流量趋势")
void testGetFlowTrend() {
DmaFlowRecord record = new DmaFlowRecord();
record.setCollectTime(LocalDateTime.now());
record.setInstantFlow(new BigDecimal("12.5"));
when(flowRecordMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(record));
List<Map<String, Object>> trend = flowService.getFlowTrend(1L,
LocalDateTime.now().minusHours(24), LocalDateTime.now());
assertEquals(1, trend.size());
}
}
@@ -0,0 +1,124 @@
package com.water.dma.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.dma.entity.DmaLeakageAnalysis;
import com.water.dma.entity.DmaZone;
import com.water.dma.mapper.DmaLeakageAnalysisMapper;
import com.water.dma.mapper.DmaZoneMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* DMA漏损分析服务测试
*/
@ExtendWith(MockitoExtension.class)
class DmaLeakageServiceTest {
@Mock
private DmaLeakageAnalysisMapper leakageMapper;
@Mock
private DmaZoneMapper zoneMapper;
private DmaLeakageService leakageService;
@BeforeEach
void setUp() {
leakageService = new DmaLeakageService(leakageMapper, zoneMapper);
}
@Test
@DisplayName("执行漏损分析-正常")
void testAnalyze() {
BigDecimal supply = new BigDecimal("1000");
BigDecimal sale = new BigDecimal("850");
when(leakageMapper.insert(any(DmaLeakageAnalysis.class))).thenReturn(1);
DmaLeakageAnalysis result = leakageService.analyze(1L, LocalDate.now(), supply, sale);
assertNotNull(result);
assertEquals(new BigDecimal("150"), result.getLeakageVolume());
assertEquals(0, new BigDecimal("15.00").compareTo(result.getNrwRate()));
assertEquals("warning", result.getAlarmLevel());
verify(leakageMapper).insert(any(DmaLeakageAnalysis.class));
}
@Test
@DisplayName("执行漏损分析-高漏损报警")
void testAnalyzeHighAlarm() {
BigDecimal supply = new BigDecimal("1000");
BigDecimal sale = new BigDecimal("700");
when(leakageMapper.insert(any(DmaLeakageAnalysis.class))).thenReturn(1);
DmaLeakageAnalysis result = leakageService.analyze(1L, LocalDate.now(), supply, sale);
assertEquals("critical", result.getAlarmLevel());
}
@Test
@DisplayName("执行漏损分析-零供水")
void testAnalyzeZeroSupply() {
BigDecimal supply = BigDecimal.ZERO;
BigDecimal sale = BigDecimal.ZERO;
when(leakageMapper.insert(any(DmaLeakageAnalysis.class))).thenReturn(1);
DmaLeakageAnalysis result = leakageService.analyze(1L, LocalDate.now(), supply, sale);
assertEquals(0, BigDecimal.ZERO.compareTo(result.getNrwRate()));
assertEquals("normal", result.getAlarmLevel());
}
@Test
@DisplayName("获取漏损趋势")
void testGetTrend() {
DmaLeakageAnalysis analysis = new DmaLeakageAnalysis();
analysis.setZoneId(1L);
analysis.setAnalysisDate(LocalDate.now());
analysis.setNrwRate(new BigDecimal("12.5"));
when(leakageMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(analysis));
List<Map<String, Object>> trend = leakageService.getTrend(1L, 30);
assertEquals(1, trend.size());
assertEquals(new BigDecimal("12.5"), trend.get(0).get("nrwRate"));
}
@Test
@DisplayName("获取分区漏损汇总-无数据")
void testGetZoneSummaryEmpty() {
when(zoneMapper.selectById(1L)).thenReturn(null);
when(leakageMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
Map<String, Object> summary = leakageService.getZoneSummary(1L);
assertEquals(0, ((BigDecimal) summary.get("avgNrwRate")).compareTo(BigDecimal.ZERO));
assertEquals(0, summary.get("alarmCount"));
}
@Test
@DisplayName("获取报警列表")
void testGetAlarms() {
DmaLeakageAnalysis alarm = new DmaLeakageAnalysis();
alarm.setAlarmLevel("critical");
when(leakageMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(alarm));
List<DmaLeakageAnalysis> alarms = leakageService.getAlarms(null);
assertFalse(alarms.isEmpty());
}
}
@@ -0,0 +1,100 @@
package com.water.dma.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.dma.entity.DmaMeter;
import com.water.dma.mapper.DmaMeterMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* DMA计量表服务测试
*/
@ExtendWith(MockitoExtension.class)
class DmaMeterServiceTest {
@Mock
private DmaMeterMapper meterMapper;
private DmaMeterService meterService;
@BeforeEach
void setUp() {
meterService = new DmaMeterService(meterMapper);
}
@Test
@DisplayName("创建计量表")
void testCreateMeter() {
DmaMeter meter = new DmaMeter();
meter.setZoneId(1L);
meter.setMeterCode("M-001");
meter.setMeterName("进水表1");
meter.setMeterType("inlet");
when(meterMapper.insert(any(DmaMeter.class))).thenReturn(1);
DmaMeter created = meterService.create(meter);
assertNotNull(created);
assertEquals("online", created.getStatus());
verify(meterMapper).insert(any(DmaMeter.class));
}
@Test
@DisplayName("绑定计量表到分区")
void testBindToZone() {
DmaMeter meter = new DmaMeter();
meter.setId(1L);
meter.setMeterCode("M-001");
meter.setZoneId(null);
when(meterMapper.selectById(1L)).thenReturn(meter);
when(meterMapper.updateById(any(DmaMeter.class))).thenReturn(1);
meterService.bindToZone(1L, 2L);
assertEquals(2L, meter.getZoneId());
verify(meterMapper).updateById(any(DmaMeter.class));
}
@Test
@DisplayName("绑定不存在的计量表-失败")
void testBindToZoneNotFound() {
when(meterMapper.selectById(999L)).thenReturn(null);
assertThrows(RuntimeException.class, () -> {
meterService.bindToZone(999L, 1L);
});
}
@Test
@DisplayName("统计分区表计数量")
void testCountByZoneId() {
when(meterMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(5L);
Long count = meterService.countByZoneId(1L);
assertEquals(5L, count);
}
@Test
@DisplayName("获取分区下的所有计量表")
void testListByZoneId() {
DmaMeter meter = new DmaMeter();
meter.setId(1L);
meter.setZoneId(1L);
when(meterMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(meter));
List<DmaMeter> meters = meterService.listByZoneId(1L);
assertEquals(1, meters.size());
}
}
@@ -0,0 +1,112 @@
package com.water.dma.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.dma.entity.DmaZone;
import com.water.dma.mapper.DmaZoneMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* DMA分区服务测试
*/
@ExtendWith(MockitoExtension.class)
class DmaZoneServiceTest {
@Mock
private DmaZoneMapper zoneMapper;
private DmaZoneService zoneService;
@BeforeEach
void setUp() {
zoneService = new DmaZoneService(zoneMapper);
}
@Test
@DisplayName("创建DMA分区")
void testCreateZone() {
DmaZone zone = new DmaZone();
zone.setZoneName("测试分区A");
zone.setZoneCode("DMA-001");
zone.setZoneLevel(1);
zone.setArea("城北区");
when(zoneMapper.insert(any(DmaZone.class))).thenReturn(1);
DmaZone created = zoneService.create(zone);
assertNotNull(created);
assertEquals("active", created.getStatus());
verify(zoneMapper).insert(any(DmaZone.class));
}
@Test
@DisplayName("获取分区树形结构")
void testGetZoneTree() {
DmaZone parent = new DmaZone();
parent.setId(1L);
parent.setZoneName("总区");
parent.setZoneCode("ROOT");
parent.setZoneLevel(1);
parent.setParentId(null);
DmaZone child = new DmaZone();
child.setId(2L);
child.setZoneName("子区A");
child.setZoneCode("A");
child.setZoneLevel(2);
child.setParentId(1L);
when(zoneMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Arrays.asList(parent, child));
List<Map<String, Object>> tree = zoneService.getZoneTree();
assertNotNull(tree);
assertEquals(1, tree.size());
assertEquals("总区", tree.get(0).get("zoneName"));
}
@Test
@DisplayName("删除分区-存在子分区时失败")
void testDeleteZoneWithChildren() {
when(zoneMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(1L);
assertThrows(RuntimeException.class, () -> {
zoneService.delete(1L);
});
}
@Test
@DisplayName("删除分区-无子分区时成功")
void testDeleteZoneSuccess() {
when(zoneMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(0L);
when(zoneMapper.deleteById(1L)).thenReturn(1);
zoneService.delete(1L);
verify(zoneMapper).deleteById(1L);
}
@Test
@DisplayName("获取所有分区列表")
void testListAll() {
DmaZone zone = new DmaZone();
zone.setId(1L);
zone.setZoneName("测试分区");
when(zoneMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(zone));
List<DmaZone> zones = zoneService.listAll();
assertEquals(1, zones.size());
}
}
@@ -0,0 +1,117 @@
package com.water.dma.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.dma.entity.WaterBalance;
import com.water.dma.mapper.WaterBalanceMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 水平衡服务测试
*/
@ExtendWith(MockitoExtension.class)
class WaterBalanceServiceTest {
@Mock
private WaterBalanceMapper balanceMapper;
private WaterBalanceService balanceService;
@BeforeEach
void setUp() {
balanceService = new WaterBalanceService(balanceMapper);
}
@Test
@DisplayName("创建水平衡记录-自动计算漏损")
void testCreate() {
WaterBalance balance = new WaterBalance();
balance.setZoneId(1L);
balance.setPeriod("monthly");
balance.setStatDate(LocalDate.of(2024, 1, 1));
balance.setTotalSupply(new BigDecimal("10000"));
balance.setTotalSale(new BigDecimal("8500"));
when(balanceMapper.insert(any(WaterBalance.class))).thenReturn(1);
WaterBalance created = balanceService.create(balance);
assertNotNull(created);
assertEquals(0, new BigDecimal("1500").compareTo(created.getTotalLoss()));
assertEquals(0, new BigDecimal("15.00").compareTo(created.getNrwRate()));
verify(balanceMapper).insert(any(WaterBalance.class));
}
@Test
@DisplayName("创建水平衡-零供水")
void testCreateZeroSupply() {
WaterBalance balance = new WaterBalance();
balance.setZoneId(1L);
balance.setPeriod("daily");
balance.setStatDate(LocalDate.now());
balance.setTotalSupply(BigDecimal.ZERO);
balance.setTotalSale(BigDecimal.ZERO);
balance.setTotalLoss(BigDecimal.ZERO);
when(balanceMapper.insert(any(WaterBalance.class))).thenReturn(1);
WaterBalance created = balanceService.create(balance);
assertNotNull(created);
}
@Test
@DisplayName("生成水平衡报告-有数据")
void testGenerateReport() {
WaterBalance b1 = new WaterBalance();
b1.setTotalSupply(new BigDecimal("5000"));
b1.setTotalSale(new BigDecimal("4200"));
b1.setTotalLoss(new BigDecimal("800"));
b1.setApparentLoss(new BigDecimal("200"));
b1.setRealLoss(new BigDecimal("600"));
b1.setBillingSale(new BigDecimal("4000"));
b1.setFreeSupply(new BigDecimal("200"));
when(balanceMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(b1));
Map<String, Object> report = balanceService.generateReport(1L, "monthly",
LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 31));
assertNotNull(report);
assertEquals(1, report.get("recordCount"));
assertEquals(new BigDecimal("5000"), report.get("totalSupply"));
assertEquals(new BigDecimal("800"), report.get("totalLoss"));
}
@Test
@DisplayName("生成水平衡报告-无数据")
void testGenerateReportEmpty() {
when(balanceMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
Map<String, Object> report = balanceService.generateReport(1L, "monthly",
LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 31));
assertEquals(0, report.get("recordCount"));
assertEquals(BigDecimal.ZERO, report.get("totalSupply"));
}
@Test
@DisplayName("删除水平衡记录")
void testDelete() {
when(balanceMapper.deleteById(1L)).thenReturn(1);
balanceService.delete(1L);
verify(balanceMapper).deleteById(1L);
}
}
+5
View File
@@ -13,5 +13,10 @@
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId></dependency>
<dependency><groupId>net.postgis</groupId><artifactId>postgis-jdbc</artifactId></dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,59 @@
package com.water.iot.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis配置
* - StringRedisTemplate: key/value均为String序列化(用于设备影子Hash存储)
* - RedisTemplate<String, Object>: key为String,value为JSON序列化(通用)
*/
@Configuration
public class RedisConfig {
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
StringRedisTemplate template = new StringRedisTemplate();
template.setConnectionFactory(factory);
return template;
}
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// JSON序列化器
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(
mapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL
);
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(mapper);
StringRedisSerializer stringSerializer = new StringRedisSerializer();
// key序列化
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
// value序列化
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}
@@ -0,0 +1,107 @@
package com.water.iot.controller;
import com.water.common.core.result.R;
import com.water.iot.entity.DeviceShadow;
import com.water.iot.service.DeviceShadowService;
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;
@Tag(name = "设备影子")
@RestController
@RequestMapping("/api/iot/shadow")
@RequiredArgsConstructor
public class DeviceShadowController {
private final DeviceShadowService shadowService;
@Operation(summary = "获取设备影子")
@GetMapping("/{deviceSn}")
public R<DeviceShadow> getShadow(@PathVariable String deviceSn) {
DeviceShadow shadow = shadowService.getShadow(deviceSn);
if (shadow == null) {
return R.fail(404, "设备影子不存在: " + deviceSn);
}
return R.ok(shadow);
}
@Operation(summary = "更新设备上报状态")
@PostMapping("/{deviceSn}/reported")
public R<String> updateReported(@PathVariable String deviceSn,
@RequestBody Map<String, Object> state) {
shadowService.updateReported(deviceSn, state);
return R.ok("上报状态已更新");
}
@Operation(summary = "更新期望状态(云端下发)")
@PostMapping("/{deviceSn}/desired")
public R<String> updateDesired(@PathVariable String deviceSn,
@RequestBody Map<String, Object> desired) {
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
String json = mapper.writeValueAsString(desired);
shadowService.updateDesired(deviceSn, json);
return R.ok("期望状态已下发");
} catch (Exception e) {
return R.fail("序列化失败: " + e.getMessage());
}
}
@Operation(summary = "获取上报状态")
@GetMapping("/{deviceSn}/reported")
public R<String> getReportedState(@PathVariable String deviceSn) {
String state = shadowService.getReportedState(deviceSn);
return R.ok(state);
}
@Operation(summary = "获取期望状态")
@GetMapping("/{deviceSn}/desired")
public R<String> getDesiredState(@PathVariable String deviceSn) {
String state = shadowService.getDesiredState(deviceSn);
return R.ok(state);
}
@Operation(summary = "获取差异状态(delta)")
@GetMapping("/{deviceSn}/delta")
public R<String> getDeltaState(@PathVariable String deviceSn) {
String state = shadowService.getDeltaState(deviceSn);
return R.ok(state);
}
@Operation(summary = "批量查询设备影子")
@PostMapping("/batch")
public R<List<DeviceShadow>> batchGetShadows(@RequestBody List<String> deviceSns) {
return R.ok(shadowService.batchGetShadows(deviceSns));
}
@Operation(summary = "检查设备在线状态")
@GetMapping("/{deviceSn}/online")
public R<Boolean> checkOnline(@PathVariable String deviceSn,
@RequestParam(defaultValue = "30") int thresholdMinutes) {
return R.ok(shadowService.checkOnline(deviceSn, thresholdMinutes));
}
@Operation(summary = "批量检测在线状态")
@PostMapping("/online/batch")
public R<Map<String, Boolean>> batchCheckOnline(@RequestBody List<String> deviceSns,
@RequestParam(defaultValue = "30") int thresholdMinutes) {
return R.ok(shadowService.batchCheckOnline(deviceSns, thresholdMinutes));
}
@Operation(summary = "在线设备数量")
@GetMapping("/online/count")
public R<Long> countOnline() {
return R.ok(shadowService.countOnlineDevices());
}
@Operation(summary = "删除设备影子")
@DeleteMapping("/{deviceSn}")
public R<String> deleteShadow(@PathVariable String deviceSn) {
shadowService.deleteShadow(deviceSn);
return R.ok("影子已删除");
}
}
@@ -0,0 +1,179 @@
package com.water.iot.controller;
import com.water.common.core.result.R;
import com.water.iot.entity.OtaFirmware;
import com.water.iot.entity.OtaTask;
import com.water.iot.entity.OtaUpgradeRecord;
import com.water.iot.service.OtaService;
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;
@Tag(name = "OTA固件升级")
@RestController
@RequestMapping("/api/iot/ota")
@RequiredArgsConstructor
public class OtaController {
private final OtaService otaService;
// ========== 固件管理 ==========
@Operation(summary = "创建固件版本")
@PostMapping("/firmware")
public R<OtaFirmware> createFirmware(@RequestBody OtaFirmware firmware) {
return R.ok(otaService.createFirmware(firmware));
}
@Operation(summary = "发布固件")
@PostMapping("/firmware/{id}/publish")
public R<String> publishFirmware(@PathVariable Long id,
@RequestParam String publishedBy) {
otaService.publishFirmware(id, publishedBy);
return R.ok("固件已发布");
}
@Operation(summary = "废弃固件")
@PostMapping("/firmware/{id}/deprecate")
public R<String> deprecateFirmware(@PathVariable Long id) {
otaService.deprecateFirmware(id);
return R.ok("固件已废弃");
}
@Operation(summary = "查询固件详情")
@GetMapping("/firmware/{id}")
public R<OtaFirmware> getFirmware(@PathVariable Long id) {
OtaFirmware firmware = otaService.getFirmware(id);
if (firmware == null) {
return R.fail(404, "固件不存在: " + id);
}
return R.ok(firmware);
}
@Operation(summary = "按模型查询固件列表")
@GetMapping("/firmware/model/{modelId}")
public R<List<OtaFirmware>> listByModel(@PathVariable Long modelId) {
return R.ok(otaService.listFirmwareByModel(modelId));
}
@Operation(summary = "查询所有固件(分页)")
@GetMapping("/firmware")
public R<List<OtaFirmware>> listAllFirmware(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
return R.ok(otaService.listAllFirmware(page, size));
}
@Operation(summary = "设备查询可用固件")
@GetMapping("/firmware/available/{deviceSn}")
public R<OtaFirmware> getAvailableFirmware(@PathVariable String deviceSn) {
OtaFirmware firmware = otaService.getAvailableFirmware(deviceSn);
return R.ok(firmware);
}
// ========== 升级任务管理 ==========
@Operation(summary = "创建升级任务(指定设备列表)")
@PostMapping("/task")
public R<OtaTask> createUpgradeTask(@RequestBody Map<String, Object> body) {
Long firmwareId = ((Number) body.get("firmwareId")).longValue();
@SuppressWarnings("unchecked")
List<Number> deviceIdNums = (List<Number>) body.get("deviceIds");
List<Long> deviceIds = deviceIdNums.stream().map(Number::longValue).toList();
int batchSize = body.containsKey("batchSize") ? ((Number) body.get("batchSize")).intValue() : 10;
String createdBy = (String) body.getOrDefault("createdBy", "system");
return R.ok(otaService.createUpgradeTask(firmwareId, deviceIds, batchSize, createdBy));
}
@Operation(summary = "按条件创建升级任务")
@PostMapping("/task/filter")
public R<OtaTask> createTaskByFilter(@RequestBody Map<String, Object> body) {
Long firmwareId = ((Number) body.get("firmwareId")).longValue();
String deviceType = (String) body.get("deviceType");
String area = (String) body.get("area");
int batchSize = body.containsKey("batchSize") ? ((Number) body.get("batchSize")).intValue() : 10;
String createdBy = (String) body.getOrDefault("createdBy", "system");
return R.ok(otaService.createUpgradeTaskByFilter(firmwareId, deviceType, area, batchSize, createdBy));
}
@Operation(summary = "启动升级任务")
@PostMapping("/task/{id}/start")
public R<String> startTask(@PathVariable Long id) {
otaService.startTask(id);
return R.ok("任务已启动");
}
@Operation(summary = "取消升级任务")
@PostMapping("/task/{id}/cancel")
public R<String> cancelTask(@PathVariable Long id) {
otaService.cancelTask(id);
return R.ok("任务已取消");
}
@Operation(summary = "查询升级任务详情")
@GetMapping("/task/{id}")
public R<OtaTask> getTask(@PathVariable Long id) {
OtaTask task = otaService.getTask(id);
if (task == null) {
return R.fail(404, "任务不存在: " + id);
}
return R.ok(task);
}
@Operation(summary = "查询升级任务列表")
@GetMapping("/task")
public R<List<OtaTask>> listTasks(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
return R.ok(otaService.listTasks(page, size));
}
// ========== 进度追踪 ==========
@Operation(summary = "更新升级进度")
@PostMapping("/progress/{recordId}")
public R<String> updateProgress(@PathVariable Long recordId,
@RequestParam int progress) {
otaService.updateProgress(recordId, progress);
return R.ok("进度已更新");
}
@Operation(summary = "标记升级成功")
@PostMapping("/record/{recordId}/success")
public R<String> markSuccess(@PathVariable Long recordId,
@RequestParam Long deviceId,
@RequestParam String toVersion) {
otaService.markSuccess(recordId, deviceId, toVersion);
return R.ok("已标记成功");
}
@Operation(summary = "标记升级失败")
@PostMapping("/record/{recordId}/fail")
public R<String> markFailed(@PathVariable Long recordId,
@RequestParam String reason) {
otaService.markFailed(recordId, reason);
return R.ok("已标记失败");
}
@Operation(summary = "查询任务统计")
@GetMapping("/task/{id}/statistics")
public R<Map<String, Object>> getTaskStatistics(@PathVariable Long id) {
return R.ok(otaService.getTaskStatistics(id));
}
@Operation(summary = "查询任务升级记录")
@GetMapping("/task/{id}/records")
public R<List<OtaUpgradeRecord>> getTaskRecords(@PathVariable Long id) {
return R.ok(otaService.getTaskRecords(id));
}
@Operation(summary = "查询设备升级历史")
@GetMapping("/device/{deviceSn}/history")
public R<List<OtaUpgradeRecord>> getDeviceHistory(@PathVariable String deviceSn) {
return R.ok(otaService.getDeviceUpgradeHistory(deviceSn));
}
}
@@ -0,0 +1,40 @@
package com.water.iot.entity;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 设备影子实体
* Redis Hash存储:key=shadow:{deviceSn}
* fields: reported(JSON), desired(JSON), lastReportTime, online
*/
@Data
public class DeviceShadow {
/** 设备SN(主键) */
private String deviceSn;
/** 设备ID */
private Long deviceId;
/** 设备名称 */
private String deviceName;
/** 上报状态JSON */
private String reportedState;
/** 期望状态JSON(云端下发) */
private String desiredState;
/** 差异状态JSON */
private String deltaState;
/** 最后上报时间 */
private LocalDateTime lastReportTime;
/** 在线状态 */
private Boolean online;
/** 影子版本号 */
private Long version;
}
@@ -0,0 +1,44 @@
package com.water.iot.entity;
import lombok.Data;
import java.time.LocalDateTime;
/**
* OTA固件版本实体
*/
@Data
public class OtaFirmware {
/** 固件ID */
private Long id;
/** 设备模型ID */
private Long modelId;
/** 固件版本号 */
private String firmwareVersion;
/** 固件文件URL */
private String fileUrl;
/** 固件描述 */
private String description;
/** 状态: draft/published/deprecated */
private String status;
/** 文件MD5 */
private String md5;
/** 文件大小(bytes) */
private Long fileSize;
/** 发布人 */
private String publishedBy;
/** 发布时间 */
private LocalDateTime publishedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,59 @@
package com.water.iot.entity;
import lombok.Data;
import java.time.LocalDateTime;
/**
* OTA升级任务实体
*/
@Data
public class OtaTask {
/** 任务ID */
private Long id;
/** 固件ID */
private Long firmwareId;
/** 固件版本号 */
private String firmwareVersion;
/** 目标设备ID列表(JSON) */
private String targetDeviceIds;
/** 任务状态: pending/executing/completed/cancelled */
private String taskStatus;
/** 每批大小 */
private Integer batchSize;
/** 目标设备总数 */
private Integer totalDevices;
/** 成功数 */
private Integer successCount;
/** 失败数 */
private Integer failedCount;
/** 执行中数量 */
private Integer executingCount;
/** 目标设备类型(可选) */
private String targetType;
/** 目标区域(可选) */
private String targetArea;
/** 创建人 */
private String createdBy;
/** 执行开始时间 */
private LocalDateTime executedAt;
/** 完成时间 */
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,46 @@
package com.water.iot.entity;
import lombok.Data;
import java.time.LocalDateTime;
/**
* OTA升级记录实体(每台设备一条)
*/
@Data
public class OtaUpgradeRecord {
/** 记录ID */
private Long id;
/** 任务ID */
private Long taskId;
/** 设备ID */
private Long deviceId;
/** 设备SN */
private String deviceSn;
/** 升级前版本 */
private String fromVersion;
/** 升级目标版本 */
private String toVersion;
/** 状态: pending/executing/success/failed */
private String status;
/** 进度(0-100) */
private Integer progress;
/** 失败原因 */
private String failReason;
/** 开始时间 */
private LocalDateTime startedAt;
/** 完成时间 */
private LocalDateTime completedAt;
private LocalDateTime createdAt;
}
@@ -2,15 +2,25 @@ package com.water.iot.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.water.iot.entity.DeviceShadow;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 设备影子服务
* Redis Hash存储:key=shadow:{deviceSn},TTL 24h
* fields: reported(JSON), desired(JSON), lastReportTime, online, version
*/
@Slf4j
@Service
@RequiredArgsConstructor
@@ -18,32 +28,251 @@ public class DeviceShadowService {
private final StringRedisTemplate redisTemplate;
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper mapper = new ObjectMapper();
private final ObjectMapper objectMapper;
private static final String SHADOW_PREFIX = "iot:shadow:";
private static final String SHADOW_PREFIX = "shadow:";
private static final long SHADOW_TTL_HOURS = 24;
/** 更新设备上报状态 */
/**
* 更新设备上报状态
* @param deviceSn 设备SN
* @param state 上报的状态数据(Map)
*/
public void updateReported(String deviceSn, Map<String, Object> state) {
try {
String key = SHADOW_PREFIX + deviceSn;
String json = mapper.writeValueAsString(state);
redisTemplate.opsForHash().put(key, "reported", json);
String json = objectMapper.writeValueAsString(state);
Map<String, String> fields = new HashMap<>();
fields.put("reported", json);
fields.put("lastReportTime", LocalDateTime.now().toString());
fields.put("online", "true");
// 递增版本号
Long version = redisTemplate.opsForHash().increment(key, "version", 1);
fields.put("version", String.valueOf(version));
redisTemplate.opsForHash().putAll(key, fields);
redisTemplate.expire(key, SHADOW_TTL_HOURS, TimeUnit.HOURS);
// 同步更新数据库设备最后上报时间
jdbcTemplate.update("UPDATE iot_device SET last_report_time = NOW() WHERE device_sn = ?", deviceSn);
// 计算delta(期望与上报的差异)
computeDelta(deviceSn);
// 同步更新数据库设备最后上报时间和在线状态
jdbcTemplate.update(
"UPDATE iot_device SET last_report_time = NOW(), status = 'online' WHERE device_sn = ?",
deviceSn
);
log.debug("Shadow reported updated: deviceSn={}, version={}", deviceSn, version);
} catch (JsonProcessingException e) {
log.error("Shadow update error: {}", e.getMessage());
log.error("Shadow update error for device {}: {}", deviceSn, e.getMessage());
}
}
/** 获取设备影子 */
public Map<Object, Object> getShadow(String deviceSn) {
return redisTemplate.opsForHash().entries(SHADOW_PREFIX + deviceSn);
/**
* 更新期望状态(云端→设备)
* @param deviceSn 设备SN
* @param desiredJson 期望状态JSON字符串
*/
public void updateDesired(String deviceSn, String desiredJson) {
String key = SHADOW_PREFIX + deviceSn;
Map<String, String> fields = new HashMap<>();
fields.put("desired", desiredJson);
Long version = redisTemplate.opsForHash().increment(key, "version", 1);
fields.put("version", String.valueOf(version));
redisTemplate.opsForHash().putAll(key, fields);
redisTemplate.expire(key, SHADOW_TTL_HOURS, TimeUnit.HOURS);
computeDelta(deviceSn);
log.debug("Shadow desired updated: deviceSn={}, version={}", deviceSn, version);
}
/** 更新期望状态(云端→设备) */
public void updateDesired(String deviceSn, String desiredJson) {
redisTemplate.opsForHash().put(SHADOW_PREFIX + deviceSn, "desired", desiredJson);
/**
* 获取设备影子完整数据
*/
public DeviceShadow getShadow(String deviceSn) {
String key = SHADOW_PREFIX + deviceSn;
Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
if (entries.isEmpty()) {
return null;
}
return mapToShadow(deviceSn, entries);
}
/**
* 获取设备上报状态
*/
public String getReportedState(String deviceSn) {
Object reported = redisTemplate.opsForHash().get(SHADOW_PREFIX + deviceSn, "reported");
return reported != null ? reported.toString() : null;
}
/**
* 获取设备期望状态
*/
public String getDesiredState(String deviceSn) {
Object desired = redisTemplate.opsForHash().get(SHADOW_PREFIX + deviceSn, "desired");
return desired != null ? desired.toString() : null;
}
/**
* 获取差异状态(delta = desired - reported)
*/
public String getDeltaState(String deviceSn) {
Object delta = redisTemplate.opsForHash().get(SHADOW_PREFIX + deviceSn, "delta");
return delta != null ? delta.toString() : null;
}
/**
* 批量查询设备影子
* @param deviceSns 设备SN列表
*/
public List<DeviceShadow> batchGetShadows(List<String> deviceSns) {
if (deviceSns == null || deviceSns.isEmpty()) {
return Collections.emptyList();
}
return deviceSns.stream()
.map(this::getShadow)
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
/**
* 离线检测:检查指定设备是否在线(基于Redis TTL)
* @param deviceSn 设备SN
* @param offlineThresholdMinutes 离线阈值(分钟),超过该时间无上报视为离线
* @return true=在线, false=离线
*/
public boolean checkOnline(String deviceSn, int offlineThresholdMinutes) {
String key = SHADOW_PREFIX + deviceSn;
Object lastReportTimeStr = redisTemplate.opsForHash().get(key, "lastReportTime");
if (lastReportTimeStr == null) {
// 无上报记录,查DB
List<LocalDateTime> dbTimes = jdbcTemplate.queryForList(
"SELECT last_report_time FROM iot_device WHERE device_sn = ?",
LocalDateTime.class, deviceSn
);
if (dbTimes.isEmpty() || dbTimes.get(0) == null) return false;
lastReportTimeStr = dbTimes.get(0).toString();
}
LocalDateTime lastReport = LocalDateTime.parse(lastReportTimeStr.toString());
long minutesSinceReport = ChronoUnit.MINUTES.between(lastReport, LocalDateTime.now());
boolean isOnline = minutesSinceReport <= offlineThresholdMinutes;
// 更新Redis在线状态
redisTemplate.opsForHash().put(key, "online", String.valueOf(isOnline));
// 同步到DB
String dbStatus = isOnline ? "online" : "offline";
jdbcTemplate.update("UPDATE iot_device SET status = ? WHERE device_sn = ?", dbStatus, deviceSn);
return isOnline;
}
/**
* 批量离线检测(使用默认阈值30分钟)
*/
public Map<String, Boolean> batchCheckOnline(List<String> deviceSns) {
return batchCheckOnline(deviceSns, 30);
}
/**
* 批量离线检测
*/
public Map<String, Boolean> batchCheckOnline(List<String> deviceSns, int offlineThresholdMinutes) {
Map<String, Boolean> result = new LinkedHashMap<>();
for (String sn : deviceSns) {
result.put(sn, checkOnline(sn, offlineThresholdMinutes));
}
return result;
}
/**
* 删除设备影子(设备注销时)
*/
public void deleteShadow(String deviceSn) {
redisTemplate.delete(SHADOW_PREFIX + deviceSn);
}
/**
* 查询在线设备数量(Redis中有记录且online=true)
*/
public long countOnlineDevices() {
// 使用DB统计更可靠,这里提供Redis快速统计
List<Map<String, Object>> result = jdbcTemplate.queryForList(
"SELECT COUNT(*) as cnt FROM iot_device WHERE status = 'online'"
);
return result.isEmpty() ? 0 : ((Number) result.get(0).get("cnt")).longValue();
}
// ===== 私有方法 =====
/**
* 计算delta状态(期望与上报的差异)
*/
@SuppressWarnings("unchecked")
private void computeDelta(String deviceSn) {
String key = SHADOW_PREFIX + deviceSn;
try {
Object reportedObj = redisTemplate.opsForHash().get(key, "reported");
Object desiredObj = redisTemplate.opsForHash().get(key, "desired");
if (desiredObj == null) {
// 无期望状态,delta为空
redisTemplate.opsForHash().put(key, "delta", "{}");
return;
}
Map<String, Object> reported = reportedObj != null ?
objectMapper.readValue(reportedObj.toString(), Map.class) : Collections.emptyMap();
Map<String, Object> desired = objectMapper.readValue(desiredObj.toString(), Map.class);
// delta = desired中不同于reported的字段
Map<String, Object> delta = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : desired.entrySet()) {
Object reportedVal = reported.get(entry.getKey());
if (!Objects.equals(entry.getValue(), reportedVal)) {
delta.put(entry.getKey(), entry.getValue());
}
}
String deltaJson = objectMapper.writeValueAsString(delta);
redisTemplate.opsForHash().put(key, "delta", deltaJson);
} catch (Exception e) {
log.warn("Delta compute error for device {}: {}", deviceSn, e.getMessage());
}
}
/**
* Redis Hash → DeviceShadow
*/
private DeviceShadow mapToShadow(String deviceSn, Map<Object, Object> entries) {
DeviceShadow shadow = new DeviceShadow();
shadow.setDeviceSn(deviceSn);
shadow.setReportedState(entries.get("reported") != null ? entries.get("reported").toString() : null);
shadow.setDesiredState(entries.get("desired") != null ? entries.get("desired").toString() : null);
shadow.setDeltaState(entries.get("delta") != null ? entries.get("delta").toString() : null);
shadow.setOnline(entries.get("online") != null && "true".equals(entries.get("online").toString()));
shadow.setVersion(entries.get("version") != null ? Long.parseLong(entries.get("version").toString()) : 0L);
if (entries.get("lastReportTime") != null) {
shadow.setLastReportTime(LocalDateTime.parse(entries.get("lastReportTime").toString()));
}
// 从DB补充设备信息
List<Map<String, Object>> deviceInfo = jdbcTemplate.queryForList(
"SELECT id, device_name FROM iot_device WHERE device_sn = ?", deviceSn
);
if (!deviceInfo.isEmpty()) {
shadow.setDeviceId(((Number) deviceInfo.get(0).get("id")).longValue());
shadow.setDeviceName((String) deviceInfo.get(0).get("device_name"));
}
return shadow;
}
}
@@ -1,12 +1,30 @@
package com.water.iot.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.water.iot.entity.OtaFirmware;
import com.water.iot.entity.OtaTask;
import com.water.iot.entity.OtaUpgradeRecord;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* OTA固件升级服务
* 支持固件上传/版本管理/升级任务创建(按批次)/进度追踪/结果统计/设备查询可用固件
*/
@Slf4j
@Service
@RequiredArgsConstructor
@@ -14,21 +32,475 @@ public class OtaService {
private final JdbcTemplate jdbcTemplate;
private final DeviceShadowService shadowService;
private final ObjectMapper objectMapper;
/** 创建 OTA 升级任务 */
public void createUpgrade(Long modelId, String firmwareVersion, String firmwareUrl, String checkMd5) {
jdbcTemplate.update(
"INSERT INTO iot_device_event (device_id, device_sn, event_type, event_data) " +
"SELECT id, device_sn, 'ota', json_build_object('version',?, 'url',?, 'md5',?) " +
"FROM iot_device WHERE model_id = ? AND status = 'online'",
firmwareVersion, firmwareUrl, checkMd5, modelId);
log.info("OTA task created for model {}: version={}", modelId, firmwareVersion);
// ========== 固件管理 ==========
/**
* 创建固件版本
*/
@Transactional
public OtaFirmware createFirmware(OtaFirmware firmware) {
firmware.setStatus("draft");
firmware.setCreatedAt(LocalDateTime.now());
firmware.setUpdatedAt(LocalDateTime.now());
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
var ps = connection.prepareStatement(
"INSERT INTO iot_ota_firmware (model_id, firmware_version, file_url, description, status, md5, file_size, created_at, updated_at) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), NOW())",
Statement.RETURN_GENERATED_KEYS
);
ps.setLong(1, firmware.getModelId());
ps.setString(2, firmware.getFirmwareVersion());
ps.setString(3, firmware.getFileUrl());
ps.setString(4, firmware.getDescription());
ps.setString(5, firmware.getStatus());
ps.setString(6, firmware.getMd5());
ps.setLong(7, firmware.getFileSize() != null ? firmware.getFileSize() : 0);
return ps;
}, keyHolder);
firmware.setId(keyHolder.getKey().longValue());
log.info("Firmware created: id={}, version={}", firmware.getId(), firmware.getFirmwareVersion());
return firmware;
}
/** 设备查询是否有待升级固件 */
public Map<String, Object> checkUpgrade(String deviceSn, String currentVersion) {
return jdbcTemplate.queryForMap(
"SELECT * FROM iot_device_event WHERE device_sn = ? AND event_type = 'ota' ORDER BY created_at DESC LIMIT 1",
deviceSn);
/**
* 发布固件
*/
@Transactional
public void publishFirmware(Long firmwareId, String publishedBy) {
int updated = jdbcTemplate.update(
"UPDATE iot_ota_firmware SET status = 'published', published_by = ?, published_at = NOW(), updated_at = NOW() WHERE id = ?",
publishedBy, firmwareId
);
if (updated == 0) throw new RuntimeException("固件不存在: " + firmwareId);
log.info("Firmware {} published by {}", firmwareId, publishedBy);
}
/**
* 废弃固件
*/
@Transactional
public void deprecateFirmware(Long firmwareId) {
jdbcTemplate.update(
"UPDATE iot_ota_firmware SET status = 'deprecated', updated_at = NOW() WHERE id = ?",
firmwareId
);
}
/**
* 查询固件详情
*/
public OtaFirmware getFirmware(Long firmwareId) {
List<OtaFirmware> list = jdbcTemplate.query(
"SELECT * FROM iot_ota_firmware WHERE id = ?",
new BeanPropertyRowMapper<>(OtaFirmware.class),
firmwareId
);
return list.isEmpty() ? null : list.get(0);
}
/**
* 按模型查询固件版本列表
*/
public List<OtaFirmware> listFirmwareByModel(Long modelId) {
return jdbcTemplate.query(
"SELECT * FROM iot_ota_firmware WHERE model_id = ? ORDER BY created_at DESC",
new BeanPropertyRowMapper<>(OtaFirmware.class),
modelId
);
}
/**
* 查询所有固件(分页)
*/
public List<OtaFirmware> listAllFirmware(int page, int size) {
int offset = (page - 1) * size;
return jdbcTemplate.query(
"SELECT * FROM iot_ota_firmware ORDER BY created_at DESC LIMIT ? OFFSET ?",
new BeanPropertyRowMapper<>(OtaFirmware.class),
size, offset
);
}
/**
* 设备查询可用固件(基于设备SN查找最新发布的固件)
*/
public OtaFirmware getAvailableFirmware(String deviceSn) {
// 查找设备对应的模型
List<Long> modelIds = jdbcTemplate.queryForList(
"SELECT model_id FROM iot_device WHERE device_sn = ? AND model_id IS NOT NULL",
Long.class, deviceSn
);
if (modelIds.isEmpty()) return null;
Long modelId = modelIds.get(0);
List<OtaFirmware> list = jdbcTemplate.query(
"SELECT * FROM iot_ota_firmware WHERE model_id = ? AND status = 'published' ORDER BY created_at DESC LIMIT 1",
new BeanPropertyRowMapper<>(OtaFirmware.class),
modelId
);
return list.isEmpty() ? null : list.get(0);
}
// ========== 升级任务管理 ==========
/**
* 创建OTA升级任务(按批次)
* @param firmwareId 固件ID
* @param deviceIds 目标设备ID列表
* @param batchSize 每批大小
* @param createdBy 创建人
*/
@Transactional
public OtaTask createUpgradeTask(Long firmwareId, List<Long> deviceIds, int batchSize, String createdBy) {
OtaFirmware firmware = getFirmware(firmwareId);
if (firmware == null) throw new RuntimeException("固件不存在: " + firmwareId);
if (!"published".equals(firmware.getStatus())) throw new RuntimeException("固件未发布: " + firmwareId);
// 序列化设备ID列表
String deviceIdsJson;
try {
deviceIdsJson = objectMapper.writeValueAsString(deviceIds);
} catch (JsonProcessingException e) {
throw new RuntimeException("设备ID序列化失败", e);
}
OtaTask task = new OtaTask();
task.setFirmwareId(firmwareId);
task.setFirmwareVersion(firmware.getFirmwareVersion());
task.setTargetDeviceIds(deviceIdsJson);
task.setTaskStatus("pending");
task.setBatchSize(batchSize > 0 ? batchSize : 10);
task.setTotalDevices(deviceIds.size());
task.setSuccessCount(0);
task.setFailedCount(0);
task.setExecutingCount(0);
task.setCreatedBy(createdBy);
task.setCreatedAt(LocalDateTime.now());
task.setUpdatedAt(LocalDateTime.now());
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
var ps = connection.prepareStatement(
"INSERT INTO iot_ota_task (firmware_id, firmware_version, target_device_ids, task_status, " +
"batch_size, total_devices, success_count, failed_count, executing_count, created_by, created_at, updated_at) " +
"VALUES (?, ?, ?::jsonb, ?, ?, ?, 0, 0, 0, ?, NOW(), NOW())",
Statement.RETURN_GENERATED_KEYS
);
ps.setLong(1, firmwareId);
ps.setString(2, firmware.getFirmwareVersion());
ps.setString(3, deviceIdsJson);
ps.setString(4, "pending");
ps.setInt(5, task.getBatchSize());
ps.setInt(6, deviceIds.size());
ps.setString(7, createdBy);
return ps;
}, keyHolder);
task.setId(keyHolder.getKey().longValue());
// 创建升级记录(每台设备一条)
createUpgradeRecords(task.getId(), deviceIds, firmware.getFirmwareVersion());
log.info("Upgrade task created: id={}, firmware={}, devices={}, batchSize={}",
task.getId(), firmware.getFirmwareVersion(), deviceIds.size(), task.getBatchSize());
return task;
}
/**
* 按设备类型/区域创建升级任务
*/
@Transactional
public OtaTask createUpgradeTaskByFilter(Long firmwareId, String deviceType, String area,
int batchSize, String createdBy) {
// 查询符合条件的在线设备
String sql = "SELECT id FROM iot_device WHERE model_id = (SELECT model_id FROM iot_ota_firmware WHERE id = ?) " +
"AND status = 'online'";
List<Object> params = new ArrayList<>();
params.add(firmwareId);
if (deviceType != null && !deviceType.isEmpty()) {
sql += " AND device_type = ?";
params.add(deviceType);
}
if (area != null && !area.isEmpty()) {
sql += " AND area = ?";
params.add(area);
}
List<Long> deviceIds = jdbcTemplate.queryForList(sql, Long.class, params.toArray());
if (deviceIds.isEmpty()) throw new RuntimeException("没有符合条件的在线设备");
OtaTask task = createUpgradeTask(firmwareId, deviceIds, batchSize, createdBy);
task.setTargetType(deviceType);
task.setTargetArea(area);
jdbcTemplate.update(
"UPDATE iot_ota_task SET target_type = ?, target_area = ? WHERE id = ?",
deviceType, area, task.getId()
);
return task;
}
/**
* 启动升级任务(开始执行)
*/
@Transactional
public void startTask(Long taskId) {
OtaTask task = getTask(taskId);
if (task == null) throw new RuntimeException("任务不存在: " + taskId);
if (!"pending".equals(task.getTaskStatus())) throw new RuntimeException("任务状态不允许启动: " + task.getTaskStatus());
jdbcTemplate.update(
"UPDATE iot_ota_task SET task_status = 'executing', executed_at = NOW(), updated_at = NOW() WHERE id = ?",
taskId
);
// 将第一批次的记录设为executing
int batchSize = task.getBatchSize();
jdbcTemplate.update(
"UPDATE iot_ota_upgrade_record SET status = 'executing', started_at = NOW() " +
"WHERE task_id = ? AND id IN (SELECT id FROM iot_ota_upgrade_record WHERE task_id = ? AND status = 'pending' LIMIT ?)",
taskId, taskId, batchSize
);
log.info("Task {} started, first batch size: {}", taskId, batchSize);
}
/**
* 取消升级任务
*/
@Transactional
public void cancelTask(Long taskId) {
jdbcTemplate.update(
"UPDATE iot_ota_task SET task_status = 'cancelled', updated_at = NOW() WHERE id = ? AND task_status IN ('pending', 'executing')",
taskId
);
// 取消未完成的记录
jdbcTemplate.update(
"UPDATE iot_ota_upgrade_record SET status = 'failed', fail_reason = '任务已取消' " +
"WHERE task_id = ? AND status IN ('pending', 'executing')",
taskId
);
}
/**
* 查询升级任务详情
*/
public OtaTask getTask(Long taskId) {
List<OtaTask> list = jdbcTemplate.query(
"SELECT * FROM iot_ota_task WHERE id = ?",
new BeanPropertyRowMapper<>(OtaTask.class),
taskId
);
return list.isEmpty() ? null : list.get(0);
}
/**
* 查询升级任务列表
*/
public List<OtaTask> listTasks(int page, int size) {
int offset = (page - 1) * size;
return jdbcTemplate.query(
"SELECT * FROM iot_ota_task ORDER BY created_at DESC LIMIT ? OFFSET ?",
new BeanPropertyRowMapper<>(OtaTask.class),
size, offset
);
}
// ========== 进度追踪 ==========
/**
* 更新单台设备升级进度
*/
@Transactional
public void updateProgress(Long recordId, int progress) {
jdbcTemplate.update(
"UPDATE iot_ota_upgrade_record SET progress = ? WHERE id = ?",
progress, recordId
);
}
/**
* 标记设备升级成功
*/
@Transactional
public void markSuccess(Long recordId, Long deviceId, String toVersion) {
jdbcTemplate.update(
"UPDATE iot_ota_upgrade_record SET status = 'success', progress = 100, completed_at = NOW() WHERE id = ?",
recordId
);
jdbcTemplate.update(
"UPDATE iot_ota_task SET success_count = success_count + 1, executing_count = executing_count - 1, updated_at = NOW() WHERE id = (SELECT task_id FROM iot_ota_upgrade_record WHERE id = ?)",
recordId
);
// 更新设备固件版本
jdbcTemplate.update(
"UPDATE iot_device SET firmware_version = ? WHERE id = ?",
toVersion, deviceId
);
// 检查是否触发下一批次
triggerNextBatch(recordId);
}
/**
* 标记设备升级失败
*/
@Transactional
public void markFailed(Long recordId, String failReason) {
jdbcTemplate.update(
"UPDATE iot_ota_upgrade_record SET status = 'failed', fail_reason = ?, completed_at = NOW() WHERE id = ?",
failReason, recordId
);
jdbcTemplate.update(
"UPDATE iot_ota_task SET failed_count = failed_count + 1, executing_count = executing_count - 1, updated_at = NOW() WHERE id = (SELECT task_id FROM iot_ota_upgrade_record WHERE id = ?)",
recordId
);
triggerNextBatch(recordId);
}
/**
* 查询升级任务统计
*/
public Map<String, Object> getTaskStatistics(Long taskId) {
OtaTask task = getTask(taskId);
if (task == null) return Collections.emptyMap();
Map<String, Object> stats = new LinkedHashMap<>();
stats.put("taskId", taskId);
stats.put("taskStatus", task.getTaskStatus());
stats.put("totalDevices", task.getTotalDevices());
stats.put("successCount", task.getSuccessCount());
stats.put("failedCount", task.getFailedCount());
stats.put("executingCount", task.getExecutingCount());
stats.put("pendingCount", task.getTotalDevices() - task.getSuccessCount() - task.getFailedCount() - task.getExecutingCount());
double progress = task.getTotalDevices() > 0 ?
((task.getSuccessCount() + task.getFailedCount()) * 100.0 / task.getTotalDevices()) : 0;
stats.put("progressPercent", Math.round(progress * 100) / 100.0);
// 按状态分组统计
List<Map<String, Object>> byStatus = jdbcTemplate.queryForList(
"SELECT status, COUNT(*) as count FROM iot_ota_upgrade_record WHERE task_id = ? GROUP BY status",
taskId
);
stats.put("statusBreakdown", byStatus);
return stats;
}
/**
* 查询升级记录列表
*/
public List<OtaUpgradeRecord> getTaskRecords(Long taskId) {
return jdbcTemplate.query(
"SELECT * FROM iot_ota_upgrade_record WHERE task_id = ? ORDER BY id",
new BeanPropertyRowMapper<>(OtaUpgradeRecord.class),
taskId
);
}
/**
* 查询单台设备的升级历史
*/
public List<OtaUpgradeRecord> getDeviceUpgradeHistory(String deviceSn) {
return jdbcTemplate.query(
"SELECT r.* FROM iot_ota_upgrade_record r JOIN iot_device d ON r.device_id = d.id " +
"WHERE d.device_sn = ? ORDER BY r.created_at DESC",
new BeanPropertyRowMapper<>(OtaUpgradeRecord.class),
deviceSn
);
}
// ===== 私有辅助方法 =====
/**
* 创建升级记录
*/
private void createUpgradeRecords(Long taskId, List<Long> deviceIds, String toVersion) {
String sql = "INSERT INTO iot_ota_upgrade_record (task_id, device_id, device_sn, from_version, to_version, status, progress, created_at) " +
"VALUES (?, ?, ?, ?, ?, 'pending', 0, NOW())";
List<Object[]> batchArgs = new ArrayList<>();
for (Long deviceId : deviceIds) {
// 查询设备SN和当前版本
List<Map<String, Object>> deviceInfo = jdbcTemplate.queryForList(
"SELECT device_sn, firmware_version FROM iot_device WHERE id = ?",
deviceId
);
if (!deviceInfo.isEmpty()) {
Map<String, Object> info = deviceInfo.get(0);
String deviceSn = (String) info.get("device_sn");
String fromVersion = info.get("firmware_version") != null ?
(String) info.get("firmware_version") : "unknown";
batchArgs.add(new Object[]{taskId, deviceId, deviceSn, fromVersion, toVersion});
}
}
if (!batchArgs.isEmpty()) {
jdbcTemplate.batchUpdate(sql, batchArgs);
}
}
/**
* 触发下一批次
* 当一个批次的设备完成(成功或失败)后,自动启动下一批
*/
private void triggerNextBatch(Long recordId) {
Long taskId = jdbcTemplate.queryForObject(
"SELECT task_id FROM iot_ota_upgrade_record WHERE id = ?", Long.class, recordId
);
OtaTask task = getTask(taskId);
if (task == null || !"executing".equals(task.getTaskStatus())) return;
// 检查是否还有执行中的记录
int executing = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM iot_ota_upgrade_record WHERE task_id = ? AND status = 'executing'",
Integer.class, taskId
);
if (executing == 0) {
// 启动下一批
int pending = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM iot_ota_upgrade_record WHERE task_id = ? AND status = 'pending'",
Integer.class, taskId
);
if (pending > 0) {
int nextBatch = Math.min(pending, task.getBatchSize());
jdbcTemplate.update(
"UPDATE iot_ota_upgrade_record SET status = 'executing', started_at = NOW() " +
"WHERE task_id = ? AND id IN (SELECT id FROM iot_ota_upgrade_record WHERE task_id = ? AND status = 'pending' LIMIT ?)",
taskId, taskId, nextBatch
);
jdbcTemplate.update(
"UPDATE iot_ota_task SET executing_count = executing_count + ? WHERE id = ?",
nextBatch, taskId
);
log.info("Task {} next batch started: {} devices", taskId, nextBatch);
} else {
// 所有设备处理完毕,标记任务完成
int failedCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM iot_ota_upgrade_record WHERE task_id = ? AND status = 'failed'",
Integer.class, taskId
);
String finalStatus = failedCount > 0 ? "completed" : "completed";
jdbcTemplate.update(
"UPDATE iot_ota_task SET task_status = ?, completed_at = NOW(), updated_at = NOW() WHERE id = ?",
finalStatus, taskId
);
log.info("Task {} completed: total={}, success={}, failed={}",
taskId, task.getTotalDevices(), task.getSuccessCount(), failedCount);
}
}
}
}
@@ -0,0 +1,84 @@
-- =============================================
-- 智慧水务管理系统 - 设备影子 + OTA固件升级 DDL
-- 版本: V_shadow_ota
-- =============================================
-- OTA固件版本表
CREATE TABLE IF NOT EXISTS iot_ota_firmware (
id BIGSERIAL PRIMARY KEY,
model_id BIGINT NOT NULL REFERENCES iot_device_model(id),
firmware_version VARCHAR(20) NOT NULL,
file_url VARCHAR(500) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft/published/deprecated
md5 VARCHAR(64),
file_size BIGINT DEFAULT 0,
published_by VARCHAR(50),
published_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(model_id, firmware_version)
);
COMMENT ON TABLE iot_ota_firmware IS 'OTA固件版本表';
COMMENT ON COLUMN iot_ota_firmware.model_id IS '关联设备模型ID';
COMMENT ON COLUMN iot_ota_firmware.firmware_version IS '固件版本号';
COMMENT ON COLUMN iot_ota_firmware.file_url IS '固件文件下载地址';
COMMENT ON COLUMN iot_ota_firmware.status IS '状态: draft-草稿/published-已发布/deprecated-已废弃';
COMMENT ON COLUMN iot_ota_firmware.md5 IS 'MD5校验值';
CREATE INDEX IF NOT EXISTS idx_ota_firmware_model ON iot_ota_firmware(model_id);
CREATE INDEX IF NOT EXISTS idx_ota_firmware_status ON iot_ota_firmware(status);
-- OTA升级任务表
CREATE TABLE IF NOT EXISTS iot_ota_task (
id BIGSERIAL PRIMARY KEY,
firmware_id BIGINT NOT NULL REFERENCES iot_ota_firmware(id),
firmware_version VARCHAR(20) NOT NULL,
target_type VARCHAR(30), -- 目标设备类型
target_area VARCHAR(50), -- 目标区域
target_device_ids JSONB, -- 目标设备ID列表
task_status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending/executing/completed/failed/cancelled
batch_size INT DEFAULT 10, -- 每批升级数量
total_devices INT DEFAULT 0, -- 总设备数
success_count INT DEFAULT 0,
failed_count INT DEFAULT 0,
executing_count INT DEFAULT 0,
created_by VARCHAR(50),
executed_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE iot_ota_task IS 'OTA升级任务表';
COMMENT ON COLUMN iot_ota_task.task_status IS '任务状态: pending-待执行/executing-执行中/completed-已完成/failed-失败/cancelled-已取消';
COMMENT ON COLUMN iot_ota_task.batch_size IS '每批升级设备数量';
CREATE INDEX IF NOT EXISTS idx_ota_task_firmware ON iot_ota_task(firmware_id);
CREATE INDEX IF NOT EXISTS idx_ota_task_status ON iot_ota_task(task_status);
CREATE INDEX IF NOT EXISTS idx_ota_task_created ON iot_ota_task(created_at DESC);
-- OTA升级记录表(每台设备一条记录)
CREATE TABLE IF NOT EXISTS iot_ota_upgrade_record (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT NOT NULL REFERENCES iot_ota_task(id),
device_id BIGINT NOT NULL REFERENCES iot_device(id),
device_sn VARCHAR(100) NOT NULL,
from_version VARCHAR(20), -- 升级前版本
to_version VARCHAR(20), -- 目标版本
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending/executing/success/failed/timeout
fail_reason VARCHAR(500),
progress INT DEFAULT 0, -- 进度百分比 0-100
started_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE iot_ota_upgrade_record IS 'OTA升级记录表(单台设备)';
COMMENT ON COLUMN iot_ota_upgrade_record.status IS '升级状态: pending-待升级/executing-升级中/success-成功/failed-失败/timeout-超时';
CREATE INDEX IF NOT EXISTS idx_ota_record_task ON iot_ota_upgrade_record(task_id);
CREATE INDEX IF NOT EXISTS idx_ota_record_device ON iot_ota_upgrade_record(device_id);
CREATE INDEX IF NOT EXISTS idx_ota_record_sn ON iot_ota_upgrade_record(device_sn);
CREATE INDEX IF NOT EXISTS idx_ota_record_status ON iot_ota_upgrade_record(status);
@@ -0,0 +1,195 @@
package com.water.iot.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.water.iot.entity.DeviceShadow;
import org.junit.jupiter.api.BeforeEach;
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.data.redis.core.HashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* DeviceShadowService 单元测试
* Mock Redis + Mock JdbcTemplate
*/
@ExtendWith(MockitoExtension.class)
class DeviceShadowServiceTest {
@Mock
private StringRedisTemplate redisTemplate;
@Mock
private JdbcTemplate jdbcTemplate;
@Mock
private HashOperations<String, Object, Object> hashOperations;
private DeviceShadowService shadowService;
@BeforeEach
void setUp() {
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
shadowService = new DeviceShadowService(redisTemplate, jdbcTemplate, new ObjectMapper());
}
@Test
void updateReported_shouldUpdateRedisAndDb() {
// Given
String deviceSn = "FM-001";
Map<String, Object> state = Map.of("flow", 12.5, "pressure", 0.35);
when(hashOperations.get(eq("shadow:" + deviceSn), eq("version"))).thenReturn("1");
when(hashOperations.get(eq("shadow:" + deviceSn), eq("desired"))).thenReturn(null);
when(jdbcTemplate.update(anyString(), any())).thenReturn(1);
when(jdbcTemplate.queryForList(anyString(), eq(Long.class), anyString()))
.thenReturn(List.of(1L));
// When
shadowService.updateReported(deviceSn, state);
// Then
verify(hashOperations).putAll(eq("shadow:" + deviceSn), anyMap());
verify(redisTemplate).expire(eq("shadow:" + deviceSn), eq(24L), any());
verify(jdbcTemplate).update(contains("UPDATE iot_device"), eq(deviceSn));
}
@Test
void updateDesired_shouldComputeDelta() {
// Given
String deviceSn = "FM-001";
Map<String, Object> desired = Map.of("valveOpen", 80, "alarm", true);
when(hashOperations.get(eq("shadow:" + deviceSn), eq("version"))).thenReturn("2");
when(hashOperations.get(eq("shadow:" + deviceSn), eq("reported")))
.thenReturn("{\"valveOpen\":50,\"alarm\":true}");
when(jdbcTemplate.update(anyString(), any())).thenReturn(1);
when(jdbcTemplate.queryForList(anyString(), eq(Long.class), anyString()))
.thenReturn(List.of(1L));
// When
shadowService.updateDesired(deviceSn, desired);
// Then
verify(hashOperations).putAll(eq("shadow:" + deviceSn), argThat(map -> {
String delta = (String) map.get("delta");
// delta should contain valveOpen:80 (different from reported 50)
// but NOT alarm (same as reported)
return delta != null && delta.contains("valveOpen") && delta.contains("80");
}));
}
@Test
void getShadow_fromRedis_shouldReturnShadow() {
// Given
String deviceSn = "FM-001";
Map<Object, Object> entries = new HashMap<>();
entries.put("reported", "{\"flow\":12.5}");
entries.put("desired", "{\"flow\":15.0}");
entries.put("delta", "{\"flow\":15.0}");
entries.put("version", "3");
entries.put("online", "true");
entries.put("lastReport", "2026-06-14T10:00:00");
when(hashOperations.entries("shadow:" + deviceSn)).thenReturn(entries);
// When
DeviceShadow shadow = shadowService.getShadow(deviceSn);
// Then
assertNotNull(shadow);
assertEquals(deviceSn, shadow.getDeviceSn());
assertEquals("{\"flow\":12.5}", shadow.getReportedState());
assertEquals("{\"flow\":15.0}", shadow.getDesiredState());
assertEquals(3L, shadow.getVersion());
assertEquals("online", shadow.getOnlineStatus());
}
@Test
void getDesired_emptyRedis_shouldReturnEmpty() {
when(hashOperations.get(anyString(), eq("desired"))).thenReturn(null);
Map<String, Object> result = shadowService.getDesired("FM-001");
assertTrue(result.isEmpty());
}
@Test
void isOnline_true() {
when(hashOperations.get("shadow:FM-001", "online")).thenReturn("true");
assertTrue(shadowService.isOnline("FM-001"));
}
@Test
void isOnline_false() {
when(hashOperations.get("shadow:FM-001", "online")).thenReturn("false");
assertFalse(shadowService.isOnline("FM-001"));
}
@Test
void batchGetShadows_shouldReturnMultiple() {
// Given
List<String> sns = List.of("FM-001", "FM-002");
Map<Object, Object> entries1 = new HashMap<>();
entries1.put("reported", "{\"flow\":12}");
entries1.put("version", "1");
entries1.put("online", "true");
Map<Object, Object> entries2 = new HashMap<>();
entries2.put("reported", "{\"pressure\":0.4}");
entries2.put("version", "2");
entries2.put("online", "false");
when(hashOperations.entries("shadow:FM-001")).thenReturn(entries1);
when(hashOperations.entries("shadow:FM-002")).thenReturn(entries2);
// When
List<DeviceShadow> shadows = shadowService.batchGetShadows(sns);
// Then
assertEquals(2, shadows.size());
}
@Test
void detectOfflineDevices_shouldMarkOffline() {
// Given
Set<String> keys = Set.of("shadow:FM-001", "shadow:FM-002");
when(redisTemplate.keys("shadow:*")).thenReturn(keys);
// FM-001: last report 60 minutes ago (offline)
when(hashOperations.get(eq("shadow:FM-001"), eq("lastReport")))
.thenReturn(java.time.LocalDateTime.now().minusMinutes(60).toString());
// FM-002: last report 5 minutes ago (online)
when(hashOperations.get(eq("shadow:FM-002"), eq("lastReport")))
.thenReturn(java.time.LocalDateTime.now().minusMinutes(5).toString());
when(jdbcTemplate.update(anyString(), anyString())).thenReturn(1);
// When
List<String> offline = shadowService.detectOfflineDevices(30);
// Then
assertEquals(1, offline.size());
assertEquals("FM-001", offline.get(0));
verify(hashOperations).put("shadow:FM-001", "online", "false");
}
@Test
void deleteShadow_shouldRemoveFromRedisAndDb() {
when(redisTemplate.delete("shadow:FM-001")).thenReturn(true);
when(jdbcTemplate.queryForList(anyString(), eq(Long.class), anyString()))
.thenReturn(List.of(1L));
when(jdbcTemplate.update(anyString(), anyLong())).thenReturn(1);
shadowService.deleteShadow("FM-001");
verify(redisTemplate).delete("shadow:FM-001");
verify(jdbcTemplate).update(contains("DELETE FROM iot_device_shadow"), eq(1L));
}
}
@@ -0,0 +1,310 @@
package com.water.iot.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.water.iot.entity.OtaFirmware;
import com.water.iot.entity.OtaTask;
import com.water.iot.entity.OtaUpgradeRecord;
import org.junit.jupiter.api.BeforeEach;
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.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* OtaService 单元测试
* Mock JdbcTemplate
*/
@ExtendWith(MockitoExtension.class)
class OtaServiceTest {
@Mock
private JdbcTemplate jdbcTemplate;
@Mock
private DeviceShadowService shadowService;
private OtaService otaService;
private final ObjectMapper objectMapper = new ObjectMapper();
@BeforeEach
void setUp() {
otaService = new OtaService(jdbcTemplate, shadowService, objectMapper);
}
@Test
void createFirmware_shouldSetDraftStatusAndReturn() {
OtaFirmware firmware = new OtaFirmware();
firmware.setModelId(1L);
firmware.setFirmwareVersion("v2.0.0");
firmware.setFileUrl("http://oss.example.com/firmware/v2.0.0.bin");
firmware.setDescription("压力传感器固件升级");
firmware.setMd5("d41d8cd98f00b204e9800998ecf8427e");
firmware.setFileSize(1024000L);
when(jdbcTemplate.update(any(org.springframework.jdbc.core.PreparedStatementCreator.class),
any(GeneratedKeyHolder.class))).thenReturn(1);
OtaFirmware result = otaService.createFirmware(firmware);
assertNotNull(result);
assertEquals("draft", result.getStatus());
assertEquals("v2.0.0", result.getFirmwareVersion());
verify(jdbcTemplate).update(any(org.springframework.jdbc.core.PreparedStatementCreator.class),
any(GeneratedKeyHolder.class));
}
@Test
void publishFirmware_shouldUpdateStatus() {
when(jdbcTemplate.update(anyString(), anyString(), anyLong())).thenReturn(1);
otaService.publishFirmware(1L, "admin");
verify(jdbcTemplate).update(contains("UPDATE iot_ota_firmware"), eq("admin"), eq(1L));
}
@Test
void publishFirmware_notFound_shouldThrow() {
when(jdbcTemplate.update(anyString(), anyString(), anyLong())).thenReturn(0);
assertThrows(RuntimeException.class, () -> otaService.publishFirmware(999L, "admin"));
}
@Test
void getFirmware_shouldReturnFirmware() {
OtaFirmware fw = new OtaFirmware();
fw.setId(1L);
fw.setFirmwareVersion("v2.0.0");
when(jdbcTemplate.query(anyString(), any(BeanPropertyRowMapper.class), anyLong()))
.thenReturn(List.of(fw));
OtaFirmware result = otaService.getFirmware(1L);
assertNotNull(result);
assertEquals("v2.0.0", result.getFirmwareVersion());
}
@Test
void getFirmware_notFound_shouldReturnNull() {
when(jdbcTemplate.query(anyString(), any(BeanPropertyRowMapper.class), anyLong()))
.thenReturn(Collections.emptyList());
assertNull(otaService.getFirmware(999L));
}
@Test
void listAllFirmware_shouldReturnPaged() {
OtaFirmware fw1 = new OtaFirmware();
fw1.setId(1L);
OtaFirmware fw2 = new OtaFirmware();
fw2.setId(2L);
when(jdbcTemplate.query(anyString(), any(BeanPropertyRowMapper.class), anyInt(), anyInt()))
.thenReturn(List.of(fw1, fw2));
List<OtaFirmware> result = otaService.listAllFirmware(1, 10);
assertEquals(2, result.size());
}
@Test
void getAvailableFirmware_shouldReturnLatestPublished() {
// Device has model_id = 1
when(jdbcTemplate.queryForList(anyString(), eq(Long.class), anyString()))
.thenReturn(List.of(1L));
OtaFirmware fw = new OtaFirmware();
fw.setFirmwareVersion("v2.0.0");
fw.setStatus("published");
when(jdbcTemplate.query(contains("published"), any(BeanPropertyRowMapper.class), anyLong()))
.thenReturn(List.of(fw));
OtaFirmware result = otaService.getAvailableFirmware("FM-001");
assertNotNull(result);
assertEquals("v2.0.0", result.getFirmwareVersion());
}
@Test
void getAvailableFirmware_noModel_shouldReturnNull() {
when(jdbcTemplate.queryForList(anyString(), eq(Long.class), anyString()))
.thenReturn(Collections.emptyList());
assertNull(otaService.getAvailableFirmware("FM-001"));
}
@Test
void createUpgradeTask_shouldCreateTaskAndRecords() {
// Mock firmware exists and is published
OtaFirmware fw = new OtaFirmware();
fw.setId(1L);
fw.setFirmwareVersion("v2.0.0");
fw.setStatus("published");
when(jdbcTemplate.query(anyString(), any(BeanPropertyRowMapper.class), anyLong()))
.thenReturn(List.of(fw));
// Mock insert task
when(jdbcTemplate.update(any(org.springframework.jdbc.core.PreparedStatementCreator.class),
any(GeneratedKeyHolder.class))).thenReturn(1);
// Mock device info query
Map<String, Object> deviceInfo = new HashMap<>();
deviceInfo.put("device_sn", "FM-001");
deviceInfo.put("firmware_version", "v1.0.0");
when(jdbcTemplate.queryForList(contains("SELECT device_sn"), anyLong()))
.thenReturn(List.of(deviceInfo));
// Mock batch insert
when(jdbcTemplate.batchUpdate(anyString(), anyList())).thenReturn(new int[]{1});
OtaTask task = otaService.createUpgradeTask(1L, List.of(1L, 2L), 5, "admin");
assertNotNull(task);
assertEquals("v2.0.0", task.getFirmwareVersion());
assertEquals("pending", task.getTaskStatus());
assertEquals(2, task.getTotalDevices());
assertEquals(5, task.getBatchSize());
}
@Test
void createUpgradeTask_unpublishedFirmware_shouldThrow() {
OtaFirmware fw = new OtaFirmware();
fw.setId(1L);
fw.setStatus("draft");
when(jdbcTemplate.query(anyString(), any(BeanPropertyRowMapper.class), anyLong()))
.thenReturn(List.of(fw));
assertThrows(RuntimeException.class,
() -> otaService.createUpgradeTask(1L, List.of(1L), 5, "admin"));
}
@Test
void startTask_shouldUpdateStatusAndFirstBatch() {
// Mock task exists
OtaTask task = new OtaTask();
task.setId(1L);
task.setTaskStatus("pending");
task.setBatchSize(5);
when(jdbcTemplate.query(anyString(), any(BeanPropertyRowMapper.class), anyLong()))
.thenReturn(List.of(task));
when(jdbcTemplate.update(anyString(), any())).thenReturn(1);
otaService.startTask(1L);
verify(jdbcTemplate, atLeast(2)).update(anyString(), any());
}
@Test
void cancelTask_shouldUpdateStatusAndRecords() {
when(jdbcTemplate.update(anyString(), anyLong())).thenReturn(1);
otaService.cancelTask(1L);
verify(jdbcTemplate, times(2)).update(anyString(), anyLong());
}
@Test
void getTaskStatistics_shouldReturnStats() {
OtaTask task = new OtaTask();
task.setId(1L);
task.setTaskStatus("executing");
task.setTotalDevices(10);
task.setSuccessCount(5);
task.setFailedCount(1);
task.setExecutingCount(2);
when(jdbcTemplate.query(anyString(), any(BeanPropertyRowMapper.class), anyLong()))
.thenReturn(List.of(task));
List<Map<String, Object>> statusBreakdown = List.of(
Map.of("status", "success", "count", 5L),
Map.of("status", "failed", "count", 1L),
Map.of("status", "executing", "count", 2L)
);
when(jdbcTemplate.queryForList(anyString(), anyLong())).thenReturn(statusBreakdown);
Map<String, Object> stats = otaService.getTaskStatistics(1L);
assertNotNull(stats);
assertEquals(10, stats.get("totalDevices"));
assertEquals(5, stats.get("successCount"));
assertEquals(60.0, stats.get("progressPercent"));
assertNotNull(stats.get("statusBreakdown"));
}
@Test
void getTaskRecords_shouldReturnRecords() {
OtaUpgradeRecord record = new OtaUpgradeRecord();
record.setId(1L);
record.setTaskId(1L);
record.setStatus("success");
when(jdbcTemplate.query(anyString(), any(BeanPropertyRowMapper.class), anyLong()))
.thenReturn(List.of(record));
List<OtaUpgradeRecord> records = otaService.getTaskRecords(1L);
assertEquals(1, records.size());
assertEquals("success", records.get(0).getStatus());
}
@Test
void markSuccess_shouldUpdateRecordAndTask() {
when(jdbcTemplate.update(anyString(), any())).thenReturn(1);
when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), anyLong())).thenReturn(1L);
// Mock task for triggerNextBatch
OtaTask task = new OtaTask();
task.setId(1L);
task.setTaskStatus("executing");
task.setBatchSize(5);
when(jdbcTemplate.query(anyString(), any(BeanPropertyRowMapper.class), anyLong()))
.thenReturn(List.of(task));
when(jdbcTemplate.queryForObject(contains("COUNT"), eq(Integer.class), anyLong()))
.thenReturn(0, 3); // 0 executing, 3 pending
otaService.markSuccess(1L, 1L, "v2.0.0");
verify(jdbcTemplate, atLeast(3)).update(anyString(), any());
}
@Test
void markFailed_shouldUpdateWithReason() {
when(jdbcTemplate.update(anyString(), any())).thenReturn(1);
when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), anyLong())).thenReturn(1L);
OtaTask task = new OtaTask();
task.setId(1L);
task.setTaskStatus("executing");
task.setBatchSize(5);
when(jdbcTemplate.query(anyString(), any(BeanPropertyRowMapper.class), anyLong()))
.thenReturn(List.of(task));
when(jdbcTemplate.queryForObject(contains("COUNT"), eq(Integer.class), anyLong()))
.thenReturn(0, 0); // 0 executing, 0 pending -> task completed
otaService.markFailed(1L, "设备连接超时");
verify(jdbcTemplate).update(contains("fail_reason"), eq("设备连接超时"), eq(1L));
}
@Test
void updateProgress_shouldUpdateRecord() {
when(jdbcTemplate.update(anyString(), anyInt(), anyLong())).thenReturn(1);
otaService.updateProgress(1L, 50);
verify(jdbcTemplate).update(contains("progress"), eq(50), eq(1L));
}
@Test
void deprecateFirmware_shouldUpdateStatus() {
when(jdbcTemplate.update(anyString(), anyLong())).thenReturn(1);
otaService.deprecateFirmware(1L);
verify(jdbcTemplate).update(contains("deprecated"), eq(1L));
}
@Test
void listTasks_shouldReturnPaged() {
OtaTask t1 = new OtaTask();
t1.setId(1L);
OtaTask t2 = new OtaTask();
t2.setId(2L);
when(jdbcTemplate.query(anyString(), any(BeanPropertyRowMapper.class), anyInt(), anyInt()))
.thenReturn(List.of(t1, t2));
List<OtaTask> result = otaService.listTasks(1, 10);
assertEquals(2, result.size());
}
}
@@ -0,0 +1,172 @@
package com.water.production.controller;
import com.water.common.core.result.R;
import com.water.production.entity.ChemicalDosing;
import com.water.production.entity.ChemicalStock;
import com.water.production.entity.DosingStrategy;
import com.water.production.service.ChemicalDosingService;
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.math.BigDecimal;
import java.util.List;
import java.util.Map;
/**
* 全工艺药剂投加监控 Controller
* 混凝→沉淀→过滤→消毒 全流程药剂管理
*/
@Tag(name = "药剂投加监控")
@RestController
@RequestMapping("/api/production/chemical")
@RequiredArgsConstructor
public class ChemicalDosingController {
private final ChemicalDosingService chemicalDosingService;
// ==================== 1. 投加监控 ====================
@Operation(summary = "投加记录分页列表")
@GetMapping("/dosing/list")
public R<Map<String, Object>> listDosing(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String processStage,
@RequestParam(required = false) String station,
@RequestParam(required = false) String status) {
return R.ok(chemicalDosingService.listDosing(page, size, processStage, station, status));
}
@Operation(summary = "投加记录详情")
@GetMapping("/dosing/{id}")
public R<ChemicalDosing> getDosing(@PathVariable Long id) {
ChemicalDosing dosing = chemicalDosingService.getDosingById(id);
return dosing != null ? R.ok(dosing) : R.fail(404, "记录不存在");
}
@Operation(summary = "新增投加记录")
@PostMapping("/dosing")
public R<ChemicalDosing> createDosing(@RequestBody ChemicalDosing dosing) {
return R.ok(chemicalDosingService.createDosing(dosing));
}
@Operation(summary = "更新投加记录")
@PutMapping("/dosing/{id}")
public R<Boolean> updateDosing(@PathVariable Long id, @RequestBody ChemicalDosing dosing) {
return R.ok(chemicalDosingService.updateDosing(id, dosing));
}
@Operation(summary = "删除投加记录")
@DeleteMapping("/dosing/{id}")
public R<Boolean> deleteDosing(@PathVariable Long id) {
return R.ok(chemicalDosingService.deleteDosing(id));
}
// ==================== 2. 投加记录 + 趋势分析 ====================
@Operation(summary = "投加历史记录列表")
@GetMapping("/record/list")
public R<Map<String, Object>> listRecords(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String processStage,
@RequestParam(required = false) String chemicalName,
@RequestParam(required = false) String startTime,
@RequestParam(required = false) String endTime) {
return R.ok(chemicalDosingService.listRecords(page, size, processStage, chemicalName, startTime, endTime));
}
@Operation(summary = "投加趋势分析")
@GetMapping("/record/trend")
public R<List<Map<String, Object>>> trendAnalysis(
@RequestParam String processStage,
@RequestParam String startTime,
@RequestParam String endTime) {
return R.ok(chemicalDosingService.trendAnalysis(processStage, startTime, endTime));
}
// ==================== 3. 药耗统计 ====================
@Operation(summary = "药耗统计(日/周/月 + 单位产水药耗)")
@GetMapping("/statistics")
public R<Map<String, Object>> statistics(
@RequestParam(defaultValue = "day") String period,
@RequestParam(required = false) String station) {
return R.ok(chemicalDosingService.statistics(period, station));
}
// ==================== 4. 投加策略 ====================
@Operation(summary = "投加策略列表")
@GetMapping("/strategy/list")
public R<List<DosingStrategy>> listStrategies(
@RequestParam(required = false) String processStage,
@RequestParam(required = false) Boolean enabled) {
return R.ok(chemicalDosingService.listStrategies(processStage, enabled));
}
@Operation(summary = "投加策略详情")
@GetMapping("/strategy/{id}")
public R<DosingStrategy> getStrategy(@PathVariable Long id) {
DosingStrategy strategy = chemicalDosingService.getStrategyById(id);
return strategy != null ? R.ok(strategy) : R.fail(404, "策略不存在");
}
@Operation(summary = "新增投加策略")
@PostMapping("/strategy")
public R<DosingStrategy> createStrategy(@RequestBody DosingStrategy strategy) {
return R.ok(chemicalDosingService.createStrategy(strategy));
}
@Operation(summary = "更新投加策略")
@PutMapping("/strategy/{id}")
public R<Boolean> updateStrategy(@PathVariable Long id, @RequestBody DosingStrategy strategy) {
return R.ok(chemicalDosingService.updateStrategy(id, strategy));
}
@Operation(summary = "删除投加策略")
@DeleteMapping("/strategy/{id}")
public R<Boolean> deleteStrategy(@PathVariable Long id) {
return R.ok(chemicalDosingService.deleteStrategy(id));
}
@Operation(summary = "计算推荐投加量(基于策略联动)")
@PostMapping("/strategy/{id}/calculate")
public R<Map<String, Object>> calculateRecommended(
@PathVariable Long id,
@RequestParam(required = false) BigDecimal turbidity,
@RequestParam(required = false) BigDecimal flow,
@RequestParam(required = false) BigDecimal ph) {
return R.ok(chemicalDosingService.calculateRecommendedDosing(id, turbidity, flow, ph));
}
// ==================== 5. 药剂库存 ====================
@Operation(summary = "库存列表")
@GetMapping("/stock/list")
public R<List<ChemicalStock>> listStocks(
@RequestParam(required = false) String station,
@RequestParam(required = false) String status) {
return R.ok(chemicalDosingService.listStocks(station, status));
}
@Operation(summary = "新增库存记录")
@PostMapping("/stock")
public R<ChemicalStock> createStock(@RequestBody ChemicalStock stock) {
return R.ok(chemicalDosingService.createStock(stock));
}
@Operation(summary = "更新库存")
@PutMapping("/stock/{id}")
public R<Boolean> updateStock(@PathVariable Long id, @RequestBody ChemicalStock stock) {
return R.ok(chemicalDosingService.updateStock(id, stock));
}
@Operation(summary = "低库存预警列表")
@GetMapping("/stock/low-alert")
public R<List<Map<String, Object>>> lowStockAlerts() {
return R.ok(chemicalDosingService.lowStockAlerts());
}
}

Some files were not shown because too many files have changed in this diff Show More