Compare commits
21
Commits
e762d548b0
...
37bcc78eee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37bcc78eee | ||
|
|
2d0d80f6d1 | ||
|
|
7cbc17c2c4 | ||
|
|
ba535c47e2 | ||
|
|
db18fdbd98 | ||
|
|
611d553b92 | ||
|
|
24f719858a | ||
|
|
9763a5700d | ||
|
|
f035fa274f | ||
|
|
69fd9d7c41 | ||
|
|
8e1b9d8dd2 | ||
|
|
91740fb44c | ||
|
|
b445b089c8 | ||
|
|
98f91e5d74 | ||
|
|
fddf330ab2 | ||
|
|
dcb412c7e4 | ||
|
|
21cf0e97af | ||
|
|
4a82e90fcb | ||
|
|
f240059d1e | ||
|
|
08649f027e | ||
|
|
a26a626d21 |
@@ -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
@@ -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
@@ -0,0 +1,151 @@
|
||||
-- =====================================================
|
||||
-- GIS 地图展示模块 DDL
|
||||
-- 包含: 监测点位表、管网线段表、区域表
|
||||
-- 数据库: PostgreSQL
|
||||
-- =====================================================
|
||||
|
||||
-- 1. GIS 监测点位表
|
||||
CREATE TABLE IF NOT EXISTS prod_gis_point (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
point_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
point_name VARCHAR(200) NOT NULL,
|
||||
point_type VARCHAR(20) NOT NULL, -- flow/pressure/level/quality/valve
|
||||
area VARCHAR(100),
|
||||
lng DECIMAL(12, 8) NOT NULL, -- 经度
|
||||
lat DECIMAL(12, 8) NOT NULL, -- 纬度
|
||||
elevation DECIMAL(8, 2), -- 海拔高度(米)
|
||||
device_id BIGINT, -- 关联 prod_monitor_device.id
|
||||
address VARCHAR(500),
|
||||
status VARCHAR(20) DEFAULT 'online', -- online/offline/fault
|
||||
properties JSONB, -- 扩展属性(JSON)
|
||||
remark VARCHAR(500),
|
||||
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
COMMENT ON TABLE prod_gis_point IS 'GIS 监测点位表';
|
||||
COMMENT ON COLUMN prod_gis_point.point_type IS '点位类型: flow-流量/pressure-压力/level-液位/quality-水质/valve-阀门';
|
||||
COMMENT ON COLUMN prod_gis_point.lng IS '经度';
|
||||
COMMENT ON COLUMN prod_gis_point.lat IS '纬度';
|
||||
COMMENT ON COLUMN prod_gis_point.device_id IS '关联设备ID(prod_monitor_device.id)';
|
||||
COMMENT ON COLUMN prod_gis_point.properties IS '扩展属性(JSON,存储不同类型点位的特有属性)';
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_point_type ON prod_gis_point(point_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_point_area ON prod_gis_point(area);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_point_status ON prod_gis_point(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_point_device_id ON prod_gis_point(device_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_point_lng_lat ON prod_gis_point(lng, lat);
|
||||
|
||||
|
||||
-- 2. GIS 管网线段表
|
||||
CREATE TABLE IF NOT EXISTS prod_gis_pipeline (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
pipeline_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
pipeline_name VARCHAR(200),
|
||||
pipeline_type VARCHAR(30), -- supply/distribution/drainage/raw_water
|
||||
material VARCHAR(30), -- ductile_iron/pvc/pe/steel
|
||||
diameter DECIMAL(8, 2), -- 管径(mm)
|
||||
start_lng DECIMAL(12, 8) NOT NULL, -- 起点经度
|
||||
start_lat DECIMAL(12, 8) NOT NULL, -- 起点纬度
|
||||
end_lng DECIMAL(12, 8) NOT NULL, -- 终点经度
|
||||
end_lat DECIMAL(12, 8) NOT NULL, -- 终点纬度
|
||||
length DECIMAL(10, 2), -- 长度(米)
|
||||
start_node_id BIGINT, -- 起点节点ID(关联 prod_gis_point.id)
|
||||
end_node_id BIGINT, -- 终点节点ID(关联 prod_gis_point.id)
|
||||
area VARCHAR(100),
|
||||
burial_depth DECIMAL(6, 2), -- 埋深(米)
|
||||
build_year INTEGER,
|
||||
status VARCHAR(20) DEFAULT 'normal', -- normal/leakage/damaged/maintenance
|
||||
properties JSONB,
|
||||
remark VARCHAR(500),
|
||||
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
COMMENT ON TABLE prod_gis_pipeline IS 'GIS 管网线段表';
|
||||
COMMENT ON COLUMN prod_gis_pipeline.pipeline_type IS '管线类型: supply-供水/distribution-配水/drainage-排水/raw_water-原水';
|
||||
COMMENT ON COLUMN prod_gis_pipeline.material IS '材质: ductile_iron-球墨铸铁/pvc/pe/steel-钢管';
|
||||
COMMENT ON COLUMN prod_gis_pipeline.status IS '状态: normal-正常/leakage-渗漏/damaged-损坏/maintenance-维护中';
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_type ON prod_gis_pipeline(pipeline_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_area ON prod_gis_pipeline(area);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_status ON prod_gis_pipeline(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_start_node ON prod_gis_pipeline(start_node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_end_node ON prod_gis_pipeline(end_node_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_start_coord ON prod_gis_pipeline(start_lng, start_lat);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_end_coord ON prod_gis_pipeline(end_lng, end_lat);
|
||||
|
||||
|
||||
-- 3. GIS 区域表
|
||||
CREATE TABLE IF NOT EXISTS prod_gis_area (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
area_code VARCHAR(50) NOT NULL UNIQUE,
|
||||
area_name VARCHAR(200) NOT NULL,
|
||||
area_type VARCHAR(30), -- water_plant/supply_zone/dma/admin_district
|
||||
center_lng DECIMAL(12, 8),
|
||||
center_lat DECIMAL(12, 8),
|
||||
area_size DECIMAL(10, 4), -- 面积(平方公里)
|
||||
boundary TEXT, -- 边界 GeoJSON (Polygon/MultiPolygon)
|
||||
parent_id BIGINT, -- 上级区域ID
|
||||
device_count INTEGER DEFAULT 0,
|
||||
online_count INTEGER DEFAULT 0,
|
||||
alert_count INTEGER DEFAULT 0,
|
||||
population DECIMAL(10, 4), -- 供水人口(万人)
|
||||
status VARCHAR(20) DEFAULT 'active', -- active/inactive
|
||||
remark VARCHAR(500),
|
||||
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
COMMENT ON TABLE prod_gis_area IS 'GIS 区域表';
|
||||
COMMENT ON COLUMN prod_gis_area.area_type IS '区域类型: water_plant-水厂/supply_zone-供水片区/dma-独立计量区/admin_district-行政区';
|
||||
COMMENT ON COLUMN prod_gis_area.boundary IS '区域边界(GeoJSON 格式)';
|
||||
COMMENT ON COLUMN prod_gis_area.population IS '供水人口(万人)';
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_area_type ON prod_gis_area(area_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_area_status ON prod_gis_area(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_area_parent ON prod_gis_area(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_gis_area_center ON prod_gis_area(center_lng, center_lat);
|
||||
|
||||
|
||||
-- =====================================================
|
||||
-- 初始数据 (示例)
|
||||
-- =====================================================
|
||||
|
||||
-- 示例区域
|
||||
INSERT INTO prod_gis_area (area_code, area_name, area_type, center_lng, center_lat, area_size, device_count, online_count, alert_count, population, status)
|
||||
VALUES
|
||||
('AREA-001', '一体化水厂', 'water_plant', 82.07100000, 44.84500000, 2.5, 15, 12, 1, 5.0, 'active'),
|
||||
('AREA-002', '管网一区', 'supply_zone', 82.08500000, 44.85500000, 8.0, 25, 20, 3, 12.0, 'active'),
|
||||
('AREA-003', '管网二区', 'supply_zone', 82.09500000, 44.86000000, 6.5, 18, 15, 2, 8.5, 'active'),
|
||||
('AREA-004', 'DMA-001', 'dma', 82.08000000, 44.85000000, 1.2, 8, 7, 0, 2.0, 'active'),
|
||||
('AREA-005', 'DMA-002', 'dma', 82.09000000, 44.85800000, 1.8, 10, 8, 1, 3.5, 'active')
|
||||
ON CONFLICT (area_code) DO NOTHING;
|
||||
|
||||
-- 示例监测点位
|
||||
INSERT INTO prod_gis_point (point_code, point_name, point_type, area, lng, lat, elevation, device_id, address, status)
|
||||
VALUES
|
||||
('GIS-FLOW-001', '一号泵站出口流量计', 'flow', '一体化水厂', 82.07123456, 44.84567890, 350.5, 1, '一号泵站出口', 'online'),
|
||||
('GIS-FLOW-002', '二号泵站流量计', 'flow', '一体化水厂', 82.07234567, 44.84678901, 348.2, 2, '二号泵站', 'online'),
|
||||
('GIS-PRES-001', '管网压力监测点A', 'pressure', '管网一区', 82.08567890, 44.85512345, 340.0, 3, '人民路与建设路交叉口', 'online'),
|
||||
('GIS-PRES-002', '管网压力监测点B', 'pressure', '管网一区', 82.08678901, 44.85623456, 338.5, 4, '中山路与解放路交叉口', 'offline'),
|
||||
('GIS-LEV-001', '清水池液位计', 'level', '一体化水厂', 82.07012345, 44.84456789, 355.0, NULL, '清水池', 'online'),
|
||||
('GIS-QUAL-001', '出厂水质监测仪', 'quality', '一体化水厂', 82.07345678, 44.84789012, 345.0, 5, '出厂水管', 'fault'),
|
||||
('GIS-VALV-001', '主干管阀门V01', 'valve', '管网一区', 82.08456789, 44.85401234, 342.0, NULL, '主干管起点', 'online'),
|
||||
('GIS-VALV-002', '主干管阀门V02', 'valve', '管网二区', 82.09512345, 44.86023456, 335.0, NULL, '主干管末端', 'online'),
|
||||
('GIS-FLOW-003', 'DMA-001入口流量计', 'flow', 'DMA-001', 82.08045678, 44.85067890, 341.5, NULL, 'DMA-001入口', 'online'),
|
||||
('GIS-PRES-003', '管网压力监测点C', 'pressure', '管网二区', 82.09623456, 44.86134567, 333.0, NULL, '建设路与和平路交叉口', 'online')
|
||||
ON CONFLICT (point_code) DO NOTHING;
|
||||
|
||||
-- 示例管线
|
||||
INSERT INTO prod_gis_pipeline (pipeline_code, pipeline_name, pipeline_type, material, diameter, start_lng, start_lat, end_lng, end_lat, length, start_node_id, end_node_id, area, burial_depth, build_year, status)
|
||||
VALUES
|
||||
('PIPE-001', '出厂水主干管', 'supply', 'ductile_iron', 600.00, 82.07123456, 44.84567890, 82.08456789, 44.85401234, 1500.00, 1, 7, '一体化水厂', 1.5, 2020, 'normal'),
|
||||
('PIPE-002', '管网一区主干管', 'distribution', 'ductile_iron', 400.00, 82.08456789, 44.85401234, 82.08678901, 44.85623456, 800.00, 7, 4, '管网一区', 1.2, 2020, 'normal'),
|
||||
('PIPE-003', '管网二区主干管', 'distribution', 'pvc', 300.00, 82.08678901, 44.85623456, 82.09512345, 44.86023456, 1200.00, 4, 8, '管网二区', 1.0, 2021, 'normal'),
|
||||
('PIPE-004', 'DMA-001入口管', 'distribution', 'pe', 200.00, 82.08456789, 44.85401234, 82.08045678, 44.85067890, 500.00, 7, 9, 'DMA-001', 0.8, 2022, 'normal'),
|
||||
('PIPE-005', '管网二区支管', 'distribution', 'pe', 150.00, 82.09512345, 44.86023456, 82.09623456, 44.86134567, 300.00, 8, 10, '管网二区', 0.8, 2022, 'maintenance')
|
||||
ON CONFLICT (pipeline_code) DO NOTHING;
|
||||
@@ -0,0 +1,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;
|
||||
@@ -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');
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 } })
|
||||
}
|
||||
@@ -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' }
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<div class="announcement-mgmt">
|
||||
<!-- 搜索区 -->
|
||||
<el-card shadow="never">
|
||||
<el-form :inline="true" :model="filterForm">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="filterForm.keyword" placeholder="标题/内容/范围" clearable @clear="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="filterForm.type" placeholder="全部" clearable @change="handleSearch">
|
||||
<el-option label="停水" value="water_outage" />
|
||||
<el-option label="水质" value="water_quality" />
|
||||
<el-option label="维修" value="maintenance" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filterForm.status" placeholder="全部" clearable @change="handleSearch">
|
||||
<el-option label="草稿" :value="0" />
|
||||
<el-option label="已发布" :value="1" />
|
||||
<el-option label="已撤回" :value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="12" style="margin-top: 12px">
|
||||
<el-col :span="6" v-for="stat in typeStats" :key="stat.type">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-value">{{ stat.count }}</div>
|
||||
<div class="stat-label">{{ typeLabel(stat.type) }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div style="margin-top: 16px">
|
||||
<el-button type="primary" @click="openCreateDialog"><el-icon><Plus /></el-icon> 发布公告</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<el-table :data="tableData" border style="margin-top: 12px" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="type" label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="typeTag(row.type)" size="small">{{ typeLabel(row.type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="priority" label="优先级" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="priorityTag(row.priority)" size="small">{{ priorityLabel(row.priority) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="affectedArea" label="影响范围" width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="publishTime" label="发布时间" width="170" />
|
||||
<el-table-column prop="publisherName" label="发布人" width="100" />
|
||||
<el-table-column label="操作" width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="viewDetail(row)">查看</el-button>
|
||||
<el-button link type="warning" v-if="row.status === 0" @click="openEditDialog(row)">编辑</el-button>
|
||||
<el-button link type="success" v-if="row.status === 0" @click="handlePublish(row)">发布</el-button>
|
||||
<el-button link type="warning" v-if="row.status === 1" @click="handleWithdraw(row)">撤回</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination style="margin-top: 16px; justify-content: flex-end"
|
||||
v-model:current-page="pagination.page" v-model:page-size="pagination.size"
|
||||
:total="pagination.total" :page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next" @change="fetchData" />
|
||||
|
||||
<!-- 编辑对话框 -->
|
||||
<el-dialog v-model="showEditor" :title="isEdit ? '编辑公告' : '发布公告'" width="700px" destroy-on-close>
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="标题" required>
|
||||
<el-input v-model="form.title" placeholder="公告标题" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="类型" required>
|
||||
<el-select v-model="form.type" style="width: 100%">
|
||||
<el-option label="停水" value="water_outage" />
|
||||
<el-option label="水质" value="water_quality" />
|
||||
<el-option label="维修" value="maintenance" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="优先级">
|
||||
<el-select v-model="form.priority" style="width: 100%">
|
||||
<el-option label="低" value="low" />
|
||||
<el-option label="中" value="medium" />
|
||||
<el-option label="高" value="high" />
|
||||
<el-option label="紧急" value="urgent" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="影响范围">
|
||||
<el-input v-model="form.affectedArea" placeholder="描述受影响的区域" />
|
||||
</el-form-item>
|
||||
<el-form-item label="区域编码">
|
||||
<el-input v-model="form.areaCode" placeholder="用于定向推送(选填)" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="计划开始">
|
||||
<el-date-picker v-model="form.plannedStart" type="datetime" style="width: 100%"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss" placeholder="开始时间" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="计划结束">
|
||||
<el-date-picker v-model="form.plannedEnd" type="datetime" style="width: 100%"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss" placeholder="结束时间" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="内容">
|
||||
<el-input v-model="form.content" type="textarea" :rows="6" placeholder="公告内容" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showEditor = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<el-dialog v-model="showDetail" :title="detailItem?.title" width="600px">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="类型">
|
||||
<el-tag :type="typeTag(detailItem?.type)">{{ typeLabel(detailItem?.type) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="优先级">
|
||||
<el-tag :type="priorityTag(detailItem?.priority)">{{ priorityLabel(detailItem?.priority) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ statusLabel(detailItem?.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="影响范围">{{ detailItem?.affectedArea }}</el-descriptions-item>
|
||||
<el-descriptions-item label="计划时间" :span="2">
|
||||
{{ detailItem?.plannedStart }} ~ {{ detailItem?.plannedEnd }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="发布人">{{ detailItem?.publisherName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发布时间">{{ detailItem?.publishTime }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-divider />
|
||||
<div class="detail-content">{{ detailItem?.content }}</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, Plus } from '@element-plus/icons-vue'
|
||||
import request from '@/api/request'
|
||||
|
||||
const API = '/api/revenue/cs/announcement'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const showEditor = ref(false)
|
||||
const showDetail = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
const tableData = ref<any[]>([])
|
||||
const typeStats = ref<any[]>([])
|
||||
const detailItem = ref<any>(null)
|
||||
|
||||
const filterForm = reactive({ keyword: '', type: '', status: undefined as number | undefined })
|
||||
const pagination = reactive({ page: 1, size: 10, total: 0 })
|
||||
|
||||
const form = reactive({
|
||||
title: '', content: '', type: 'water_outage', affectedArea: '', areaCode: '',
|
||||
plannedStart: '', plannedEnd: '', priority: 'medium', publisherName: '当前用户'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
fetchStats()
|
||||
})
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.get(`${API}/list`, {
|
||||
params: { page: pagination.page, size: pagination.size, ...filterForm }
|
||||
})
|
||||
tableData.value = res.data?.records || []
|
||||
pagination.total = res.data?.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const res = await request.get(`${API}/stats`)
|
||||
typeStats.value = res.data || []
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function handleSearch() { pagination.page = 1; fetchData() }
|
||||
function handleReset() {
|
||||
filterForm.keyword = ''; filterForm.type = ''; filterForm.status = undefined; handleSearch()
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
isEdit.value = false; editId.value = null
|
||||
Object.assign(form, { title: '', content: '', type: 'water_outage', affectedArea: '', areaCode: '',
|
||||
plannedStart: '', plannedEnd: '', priority: 'medium' })
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(row: any) {
|
||||
isEdit.value = true; editId.value = row.id
|
||||
Object.assign(form, row)
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.title) { ElMessage.warning('请输入标题'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
if (isEdit.value && editId.value) {
|
||||
await request.put(`${API}/${editId.value}`, form)
|
||||
} else {
|
||||
await request.post(API, form)
|
||||
}
|
||||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
showEditor.value = false
|
||||
fetchData(); fetchStats()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function viewDetail(row: any) { detailItem.value = row; showDetail.value = true }
|
||||
|
||||
async function handlePublish(row: any) {
|
||||
await ElMessageBox.confirm(`确定发布「${row.title}」?`, '确认发布')
|
||||
await request.post(`${API}/${row.id}/publish`)
|
||||
ElMessage.success('发布成功'); fetchData(); fetchStats()
|
||||
}
|
||||
|
||||
async function handleWithdraw(row: any) {
|
||||
await ElMessageBox.confirm(`确定撤回「${row.title}」?`, '确认撤回')
|
||||
await request.post(`${API}/${row.id}/withdraw`)
|
||||
ElMessage.success('撤回成功'); fetchData(); fetchStats()
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm(`确定删除「${row.title}」?`, '确认删除', { type: 'warning' })
|
||||
await request.delete(`${API}/${row.id}`)
|
||||
ElMessage.success('删除成功'); fetchData(); fetchStats()
|
||||
}
|
||||
|
||||
const typeMap: Record<string, string> = { water_outage: '停水', water_quality: '水质', maintenance: '维修', other: '其他' }
|
||||
const typeTagMap: Record<string, string> = { water_outage: 'danger', water_quality: 'warning', maintenance: '', other: 'info' }
|
||||
|
||||
function typeLabel(t: string) { return typeMap[t] || t }
|
||||
function typeTag(t: string) { return (typeTagMap[t] || 'info') as any }
|
||||
function priorityLabel(p: string) { return { low: '低', medium: '中', high: '高', urgent: '紧急' }[p] || p }
|
||||
function priorityTag(p: string) { return ({ low: 'info', medium: '', high: 'warning', urgent: 'danger' }[p] || 'info') as any }
|
||||
function statusLabel(s: number) { return ['草稿', '已发布', '已撤回'][s] || '未知' }
|
||||
function statusTag(s: number) { return ['info', 'success', 'warning'][s] as any || 'info' }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card { text-align: center; cursor: pointer; }
|
||||
.stat-value { font-size: 28px; font-weight: 700; color: #409eff; }
|
||||
.stat-label { font-size: 13px; color: #666; margin-top: 4px; }
|
||||
.detail-content { white-space: pre-wrap; line-height: 1.8; }
|
||||
</style>
|
||||
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<div class="knowledge-base">
|
||||
<!-- 搜索过滤区 -->
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form :inline="true" :model="filterForm">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="filterForm.keyword" placeholder="搜索标题/内容/标签" clearable @clear="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="filterForm.category" placeholder="全部分类" clearable @change="handleSearch">
|
||||
<el-option v-for="cat in categories" :key="cat.category" :label="cat.category" :value="cat.category">
|
||||
<span>{{ cat.category }}</span>
|
||||
<span style="float: right; color: #999; font-size: 12px">{{ cat.count }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filterForm.status" placeholder="全部" clearable @change="handleSearch">
|
||||
<el-option label="草稿" :value="0" />
|
||||
<el-option label="已发布" :value="1" />
|
||||
<el-option label="已归档" :value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div style="margin-top: 16px; display: flex; justify-content: space-between; align-items: center">
|
||||
<el-button type="primary" @click="openCreateDialog"><el-icon><Plus /></el-icon> 新建文章</el-button>
|
||||
<el-radio-group v-model="viewMode" size="small">
|
||||
<el-radio-button label="list">列表</el-radio-button>
|
||||
<el-radio-button label="card">卡片</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 列表视图 -->
|
||||
<el-table v-if="viewMode === 'list'" :data="tableData" border style="margin-top: 12px" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="category" label="分类" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.category }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tags" label="标签" width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="viewCount" label="浏览" width="80" align="center" />
|
||||
<el-table-column prop="likeCount" label="点赞" width="80" align="center" />
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="authorName" label="作者" width="100" />
|
||||
<el-table-column prop="updatedAt" label="更新时间" width="170" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="viewDetail(row)">查看</el-button>
|
||||
<el-button link type="warning" @click="openEditDialog(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 卡片视图 -->
|
||||
<el-row v-else :gutter="16" style="margin-top: 12px" v-loading="loading">
|
||||
<el-col :span="6" v-for="item in tableData" :key="item.id">
|
||||
<el-card shadow="hover" class="article-card" @click="viewDetail(item)">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">{{ item.title }}</span>
|
||||
<el-tag :type="statusTag(item.status)" size="small">{{ statusLabel(item.status) }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<p class="card-summary">{{ item.summary || '暂无摘要' }}</p>
|
||||
<div class="card-meta">
|
||||
<el-tag size="small">{{ item.category }}</el-tag>
|
||||
<span class="meta-count">👁 {{ item.viewCount || 0 }} · 👍 {{ item.likeCount || 0 }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination style="margin-top: 16px; justify-content: flex-end"
|
||||
v-model:current-page="pagination.page" v-model:page-size="pagination.size"
|
||||
:total="pagination.total" :page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next" @change="fetchData" />
|
||||
|
||||
<!-- 编辑对话框 -->
|
||||
<el-dialog v-model="showEditor" :title="isEdit ? '编辑文章' : '新建文章'" width="800px" destroy-on-close>
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="标题" required>
|
||||
<el-input v-model="form.title" placeholder="请输入文章标题" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="分类" required>
|
||||
<el-select v-model="form.category" placeholder="选择分类" style="width: 100%">
|
||||
<el-option label="FAQ" value="FAQ" />
|
||||
<el-option label="政策法规" value="政策法规" />
|
||||
<el-option label="操作指南" value="操作指南" />
|
||||
<el-option label="常见问题" value="常见问题" />
|
||||
<el-option label="通知公告" value="通知公告" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status" style="width: 100%">
|
||||
<el-option label="草稿" :value="0" />
|
||||
<el-option label="已发布" :value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="标签">
|
||||
<el-input v-model="form.tags" placeholder="多个标签用逗号分隔" />
|
||||
</el-form-item>
|
||||
<el-form-item label="摘要">
|
||||
<el-input v-model="form.summary" type="textarea" :rows="2" placeholder="文章摘要(选填)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容">
|
||||
<el-input v-model="form.content" type="textarea" :rows="12" placeholder="支持 Markdown 格式" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showEditor = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<el-dialog v-model="showDetail" :title="detailArticle?.title" width="700px">
|
||||
<div class="detail-meta">
|
||||
<el-tag>{{ detailArticle?.category }}</el-tag>
|
||||
<span>作者: {{ detailArticle?.authorName }}</span>
|
||||
<span>浏览: {{ detailArticle?.viewCount }}</span>
|
||||
<span>点赞: {{ detailArticle?.likeCount }}</span>
|
||||
</div>
|
||||
<el-divider />
|
||||
<div class="detail-content" v-html="renderMarkdown(detailArticle?.content || '')"></div>
|
||||
<template #footer>
|
||||
<el-button @click="handleLike" :icon="Star">点赞</el-button>
|
||||
<el-button type="primary" @click="showDetail = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, Plus, Star } from '@element-plus/icons-vue'
|
||||
import request from '@/api/request'
|
||||
|
||||
const API = '/api/revenue/cs/kb'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const viewMode = ref('list')
|
||||
const showEditor = ref(false)
|
||||
const showDetail = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
const tableData = ref<any[]>([])
|
||||
const categories = ref<any[]>([])
|
||||
const detailArticle = ref<any>(null)
|
||||
|
||||
const filterForm = reactive({ keyword: '', category: '', status: undefined as number | undefined })
|
||||
const pagination = reactive({ page: 1, size: 10, total: 0 })
|
||||
|
||||
const form = reactive({
|
||||
title: '', content: '', summary: '', category: 'FAQ', tags: '', status: 0, authorName: '当前用户'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
fetchCategories()
|
||||
})
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.get(`${API}/list`, {
|
||||
params: { page: pagination.page, size: pagination.size, ...filterForm }
|
||||
})
|
||||
tableData.value = res.data?.records || []
|
||||
pagination.total = res.data?.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const res = await request.get(`${API}/categories`)
|
||||
categories.value = res.data || []
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filterForm.keyword = ''
|
||||
filterForm.category = ''
|
||||
filterForm.status = undefined
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
isEdit.value = false
|
||||
editId.value = null
|
||||
Object.assign(form, { title: '', content: '', summary: '', category: 'FAQ', tags: '', status: 0 })
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(row: any) {
|
||||
isEdit.value = true
|
||||
editId.value = row.id
|
||||
Object.assign(form, { title: row.title, content: row.content, summary: row.summary,
|
||||
category: row.category, tags: row.tags, status: row.status, authorName: row.authorName })
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.title) { ElMessage.warning('请输入标题'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
if (isEdit.value && editId.value) {
|
||||
await request.put(`${API}/${editId.value}`, form)
|
||||
} else {
|
||||
await request.post(API, form)
|
||||
}
|
||||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
showEditor.value = false
|
||||
fetchData()
|
||||
fetchCategories()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function viewDetail(row: any) {
|
||||
try {
|
||||
const res = await request.get(`${API}/${row.id}`)
|
||||
detailArticle.value = res.data
|
||||
showDetail.value = true
|
||||
fetchData() // refresh view count
|
||||
} catch (e) {
|
||||
detailArticle.value = row
|
||||
showDetail.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm(`确定删除「${row.title}」?`, '确认删除', { type: 'warning' })
|
||||
await request.delete(`${API}/${row.id}`)
|
||||
ElMessage.success('删除成功')
|
||||
fetchData()
|
||||
}
|
||||
|
||||
async function handleLike() {
|
||||
if (!detailArticle.value) return
|
||||
await request.post(`${API}/${detailArticle.value.id}/like`)
|
||||
ElMessage.success('点赞成功')
|
||||
detailArticle.value.likeCount = (detailArticle.value.likeCount || 0) + 1
|
||||
}
|
||||
|
||||
function statusLabel(s: number) { return ['草稿', '已发布', '已归档'][s] || '未知' }
|
||||
function statusTag(s: number) { return ['info', 'success', 'warning'][s] as any || 'info' }
|
||||
function renderMarkdown(md: string) {
|
||||
// 简单 Markdown 渲染(生产环境可用 marked)
|
||||
return md.replace(/### (.*)/g, '<h3>$1</h3>')
|
||||
.replace(/## (.*)/g, '<h2>$1</h2>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/^- (.*)/gm, '<li>$1</li>')
|
||||
.replace(/\n/g, '<br/>')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-card { margin-bottom: 8px; }
|
||||
.article-card { margin-bottom: 16px; cursor: pointer; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.card-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
|
||||
.card-summary { color: #666; font-size: 13px; height: 40px; overflow: hidden; }
|
||||
.card-meta { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
|
||||
.meta-count { font-size: 12px; color: #999; }
|
||||
.detail-meta { display: flex; gap: 16px; align-items: center; color: #666; }
|
||||
.detail-content { line-height: 1.8; }
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div class="kpi-dashboard">
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="4" v-for="card in statCards" :key="card.key">
|
||||
<el-card shadow="hover" class="kpi-card" :class="'kpi-' + card.color">
|
||||
<div class="kpi-icon">{{ card.icon }}</div>
|
||||
<div class="kpi-value">{{ card.value }}</div>
|
||||
<div class="kpi-label">{{ card.label }}</div>
|
||||
<div class="kpi-sub" v-if="card.sub">{{ card.sub }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<el-col :span="14">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>📈 7日工单趋势</span>
|
||||
</template>
|
||||
<div ref="trendChartRef" style="height: 320px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="10">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>📊 工单类型分布</span>
|
||||
</template>
|
||||
<div ref="pieChartRef" style="height: 320px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 排行榜 -->
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>🏆 处理时效排行</span>
|
||||
</template>
|
||||
<el-table :data="efficiencyRank" size="small" :show-header="true">
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column prop="name" label="处理人/部门" />
|
||||
<el-table-column prop="completed_count" label="完成数" width="80" align="center" />
|
||||
<el-table-column prop="avg_hours" label="平均时效(h)" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.avg_hours < 4 ? 'success' : row.avg_hours < 8 ? '' : 'warning'" size="small">
|
||||
{{ Number(row.avg_hours).toFixed(1) }}h
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>📋 快捷操作</span>
|
||||
</template>
|
||||
<div class="quick-actions">
|
||||
<el-button @click="$router.push('/cs/knowledge')" size="large">
|
||||
📚 知识库管理
|
||||
</el-button>
|
||||
<el-button @click="$router.push('/cs/announcement')" size="large">
|
||||
📢 公告管理
|
||||
</el-button>
|
||||
<el-button @click="refreshDashboard" size="large" :loading="loading">
|
||||
🔄 刷新数据
|
||||
</el-button>
|
||||
</div>
|
||||
<el-divider />
|
||||
<div class="summary-info">
|
||||
<p>📅 数据更新时间: {{ lastUpdate }}</p>
|
||||
<p>📊 本月解决率: <el-progress :percentage="Number(kpiData.monthResolveRate || 0)" :stroke-width="16" /></p>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, shallowRef } from 'vue'
|
||||
import request from '@/api/request'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const API = '/api/revenue/cs/kpi/dashboard'
|
||||
|
||||
const loading = ref(false)
|
||||
const kpiData = reactive<any>({})
|
||||
const efficiencyRank = ref<any[]>([])
|
||||
const lastUpdate = ref('')
|
||||
|
||||
const trendChartRef = ref<HTMLElement>()
|
||||
const pieChartRef = ref<HTMLElement>()
|
||||
let trendChart: echarts.ECharts | null = null
|
||||
let pieChart: echarts.ECharts | null = null
|
||||
|
||||
const statCards = computed(() => [
|
||||
{ key: 'pending', icon: '⏳', label: '待处理工单', value: kpiData.pendingWorkOrders ?? '-', color: 'orange' },
|
||||
{ key: 'today_new', icon: '📥', label: '今日新增', value: kpiData.todayNewWorkOrders ?? '-', color: 'blue' },
|
||||
{ key: 'month_rate', icon: '✅', label: '本月解决率', value: (kpiData.monthResolveRate ?? 0) + '%', color: 'green',
|
||||
sub: `${kpiData.monthResolvedCount ?? 0}/${kpiData.monthTotalCount ?? 0}` },
|
||||
{ key: 'avg_hours', icon: '⏱️', label: '平均时效', value: (kpiData.avgProcessHours ?? 0) + 'h', color: 'purple' },
|
||||
{ key: 'satisfaction', icon: '😊', label: '满意率', value: (kpiData.satisfactionRate ?? 0) + '%', color: 'green' },
|
||||
{ key: 'complaints', icon: '📞', label: '今日投诉', value: kpiData.todayComplaints ?? '-', color: 'red' },
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
refreshDashboard()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
trendChart?.dispose()
|
||||
pieChart?.dispose()
|
||||
})
|
||||
|
||||
async function refreshDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.get(API)
|
||||
Object.assign(kpiData, res.data)
|
||||
efficiencyRank.value = res.data?.efficiencyRank || []
|
||||
lastUpdate.value = new Date().toLocaleString()
|
||||
await nextTick()
|
||||
renderTrendChart()
|
||||
renderPieChart()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function renderTrendChart() {
|
||||
if (!trendChartRef.value) return
|
||||
trendChart = trendChart || echarts.init(trendChartRef.value)
|
||||
const data = kpiData.weeklyTrend || []
|
||||
trendChart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: { type: 'category', data: data.map((d: any) => d.date?.slice(5) || '') },
|
||||
yAxis: { type: 'value', name: '工单数' },
|
||||
series: [{
|
||||
name: '工单数', type: 'line', smooth: true,
|
||||
data: data.map((d: any) => d.count || 0),
|
||||
areaStyle: { opacity: 0.15 },
|
||||
itemStyle: { color: '#409eff' }
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
function renderPieChart() {
|
||||
if (!pieChartRef.value) return
|
||||
pieChart = pieChart || echarts.init(pieChartRef.value)
|
||||
const data = kpiData.typeDistribution || []
|
||||
const nameMap: Record<string, string> = { pending: '待处理', in_progress: '处理中', completed: '已完成' }
|
||||
pieChart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: { bottom: 0 },
|
||||
series: [{
|
||||
type: 'pie', radius: ['40%', '65%'],
|
||||
label: { show: true, formatter: '{b}\n{c}' },
|
||||
data: data.map((d: any) => ({ name: nameMap[d.type] || d.type, value: d.count }))
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
trendChart?.resize()
|
||||
pieChart?.resize()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kpi-card { text-align: center; padding: 8px 0; }
|
||||
.kpi-icon { font-size: 28px; }
|
||||
.kpi-value { font-size: 32px; font-weight: 700; margin: 4px 0; }
|
||||
.kpi-label { font-size: 13px; color: #666; }
|
||||
.kpi-sub { font-size: 12px; color: #999; margin-top: 2px; }
|
||||
.kpi-orange .kpi-value { color: #e6a23c; }
|
||||
.kpi-blue .kpi-value { color: #409eff; }
|
||||
.kpi-green .kpi-value { color: #67c23a; }
|
||||
.kpi-purple .kpi-value { color: #9b59b6; }
|
||||
.kpi-red .kpi-value { color: #f56c6c; }
|
||||
.quick-actions { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.summary-info { margin-top: 16px; }
|
||||
.summary-info p { margin: 8px 0; color: #666; }
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -50,6 +50,7 @@
|
||||
<module>wm-system</module>
|
||||
<module>wm-mobile-app</module>
|
||||
<module>wm-config</module>
|
||||
<module>wm-dma</module>
|
||||
</modules>
|
||||
|
||||
<dependencyManagement>
|
||||
|
||||
@@ -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);
|
||||
+287
@@ -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);
|
||||
+185
@@ -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();
|
||||
}
|
||||
}
|
||||
+180
@@ -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;
|
||||
}
|
||||
}
|
||||
+133
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+172
@@ -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
Reference in New Issue
Block a user