简介:这是一份面向电力行业信息化开发者与C++/MFC学习者的设备管理系统参考实现,围绕设备资产、运行状态监测、维护检修计划、工单流转、备件库存及报表分析等业务展开,可用于理解电力设备全生命周期管理如何落到软件模块与界面交互中。压缩包共65个文件、约1.47MB,以19个.h头文件和16个.cpp实现文件为主体,另含8个bmp位图、7个ico图标、rc资源脚本、dsw/dsp工程配置及db数据库文件,覆盖界面资源、工程组织与数据存储等部分。已有226人学习,适合需要参考系统架构、模块划分、数据库表设计与MFC工程搭建方式的读者,也可借此梳理设备台账、检修工单、库存预警和统计分析等功能的数据流与实现思路。
1. 从一台变压器的台账说起:设备管理系统的边界在哪
去年帮一个小团队看他们自建的设备管理台账,Excel 一共 14 个 sheet,主表里一台主变同时存在三个名字:铭牌名、调度名、班组口头叫法,巡检记录按口头叫法填,缺陷单按调度名填,月底对账时三个表对不上,谁也不敢删。这不是个例,设备管理这件事最容易翻车的地方从来不是界面好不好看,而是台账编码、状态和单据之间的引用关系一断,后面所有统计都成了玄学。
这份参考实现针对的就是这类场景:面向变电、配电侧的主设备(变压器、开关柜、GIS、避雷器、电缆段等),把台账、巡检排期、缺陷工单、预防性试验、备件消耗串成一条链,落到一套可跑的关系模型和几张核心表上。它不适合想直接买商业 EAM 的人,适合手里有一堆历史 Excel、想自己搭一套轻量系统、或者拿它当课程设计和二次开发底座的从业者。下面按"模型怎么定、单据怎么流转、试验怎么算、坑在哪"四段拆。
2. 数据模型先立住:设备编码、台账三张表与状态机约束
设备管理系统里 80% 的返工来自建模阶段偷懒。很多人上来就建一张大宽表,把变压器油色谱、开关柜局放、避雷器泄漏电流全塞进同一张表,结果每加一类设备就加十几列,三年后表宽到没人敢碰。参考实现的做法是主表只放全局可检索字段,参数走扩展表,状态变更走独立的状态机表,三者用 asset_code 串起来。
2.1 编码规则先于表结构定:KKS 风格的资产编码
编码不是主键,主键用自增或雪花 ID 都行,asset_code 是给人看的、也是跨系统对接时的业务主键。常见做法是照 KKS 或厂内惯用规则拼:站码-间隔码-设备类型码-序号,比如S01-BAY02-TR-001。三条硬约束必须在建表前定死,否则后面全是历史包袱:
- 长度和字符集:建议 48 位以内,只允许大写字母、数字和短横线,禁止中文和空格。中文编码一旦进入对外接口,编码转换能让人排查一整天。
- 一旦生成不再变更:设备搬迁、改名、换间隔,asset_code 都不动,只改 parent_id 和名称字段。这条是血泪经验,编码一改,历史工单、试验报告、备件消耗全部失去关联。
- 退役不删除:退役设备保留记录,run_status 置 4,禁止物理删除。台账删除是这类系统里最典型的后悔药缺失场景。
2.2 主表与参数扩展表:JSONB 还是 EAV
下面的建表脚本是这套模型的核心,可以直接跑在 PostgreSQL 14 以上版本:
-- 设备台账主表:只放全局检索和跨模块引用的字段 CREATE TABLE em_equipment ( id BIGSERIAL PRIMARY KEY, asset_code VARCHAR(48) NOT NULL UNIQUE, -- 业务主键,全生命周期不变 parent_id BIGINT REFERENCES em_equipment(id), node_level SMALLINT NOT NULL, -- 1厂站 2间隔 3设备 4部件 equipment_type VARCHAR(32) NOT NULL, -- transformer/switchgear/gis/arrester/cable name VARCHAR(128) NOT NULL, station_id BIGINT NOT NULL, voltage_level SMALLINT, -- 单位 kV:10/35/110/220 commission_date DATE, -- 投运日期,预试周期靠它推进 run_status SMALLINT NOT NULL DEFAULT 1, -- 1运行 2备用 3检修 4退役 spec_json JSONB NOT NULL DEFAULT '{}'::jsonb, -- 铭牌与差异化参数 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_em_parent ON em_equipment(parent_id); CREATE INDEX idx_em_station ON em_equipment(station_id, equipment_type); CREATE INDEX idx_em_spec_gin ON em_equipment USING GIN (spec_json);逻辑说明:parent_id 自引用把"厂站—间隔—设备—部件"做成树,node_level 冗余存层级,避免递归查询算深度;spec_json 承载铭牌参数,比如变压器存{"rated_capacity": 50000, "cooling": "ONAN", "oltc": "有载"},开关柜存额定电流和开断电流。参数说明上,voltage_level 用整数存 kV 而不是字符串"110kV",是为了排序和分档计算,这一点在算预试周期时非常关键。
为什么不选 EAV(属性—值对)三表?EAV 的好处是任意扩展,坏处是查一次"所有 110kV 且容量大于 40MVA 的变压器"要连三次表再自连接,写起来痛苦、执行计划也难看。JSONB 加 GIN 索引的折中更适合台账这种"大部分字段只读、少数字段要检索"的场景。实测中超过 5 万条设备、参数键在 200 个以内,JSONB 的查询延迟依然在几十毫秒级。
2.3 状态机单独建表:把非法迁移挡在数据库层
运行、备用、检修、退役四个状态之间不是随便跳的。检修必须关联一张已审批的工作票,退役必须关联报废单,从退役直接跳回运行更是绝对不能允许。把这些规则写进业务代码里迟早会被绕过,参考实现的做法是建一张迁移白名单表:
CREATE TABLE em_status_transition ( id BIGSERIAL PRIMARY KEY, from_status SMALLINT NOT NULL, to_status SMALLINT NOT NULL, require_role VARCHAR(32) NOT NULL, -- 允许操作的角色:operator/engineer/manager require_ref VARCHAR(32), -- 必须存在的关联单据:work_order/scrap_order UNIQUE (from_status, to_status) ); -- 初始化白名单:只登记合法迁移 INSERT INTO em_status_transition (from_status, to_status, require_role, require_ref) VALUES (1, 3, 'engineer', 'work_order'), -- 运行 -> 检修 (3, 1, 'engineer', 'work_order'), -- 检修 -> 运行 (1, 2, 'operator', NULL), -- 运行 -> 备用 (2, 1, 'operator', NULL), -- 备用 -> 运行 (1, 4, 'manager', 'scrap_order'), -- 运行 -> 退役 (2, 4, 'manager', 'scrap_order'); -- 备用 -> 退役 -- 注意没有登记 (4,1):退役不可逆,需要走新建资产流程应用层迁移前先查这张表,查不到就直接拒绝,再把校验结果记进审计日志。这一步的收益在半年后显现:某次有人写了个批量脚本想把 200 台设备状态直接刷成运行,因为白名单里没有 (4,1),脚本在第一批就报错退出,避免了一次数据事故。参数上 require_ref 留空表示无前置单据,require_role 与系统的角色表对齐即可,不必做成完整 RBAC。
3. 巡检排期与缺陷工单:把到期计算和流转状态写死
台账定好之后,第二个高频需求是"什么时候该巡检"和"发现了缺陷谁来处理"。这两件事的本质都是日期计算加状态流转,实现上如果偷懒用"每天扫全表算是否到期",几万条设备加十几条周期规则,数据库压力会非常直观。
3.1 巡检周期规则怎么落:按设备类型和电压等级分档
周期不建议硬编码在代码里,落一张规则表,按设备类型、电压等级、是否重要设备三个维度匹配,命中优先级从高到低:
| 设备类型 | 电压等级 | 巡检周期 | 说明 |
|---|---|---|---|
| transformer | 110 及以上 | 7 天 | 重要主变按周巡 |
| transformer | 35 及以下 | 30 天 | 常规月巡 |
| switchgear | 10 | 30 天 | 含局放检测项 |
| arrester | 全等级 | 90 天 | 雷雨季前追加一次 |
| cable | 10 | 90 天 | 含红外测温 |
规则表字段建议是(equipment_type, voltage_min, voltage_max, cycle_days, priority, effective_from)。effective_from 很重要——规程调整后不能把历史巡检记录的下次到期日全部重算,只对生效日之后的计划生效。
3.2 到期计算:不要在查询时算,落一张计划表
常见做法是每天凌晨跑一次批处理,把未来 30 天内到期的计划物化成em_inspection_plan表,字段包括equipment_id, plan_date, cycle_days, source_rule_id, status。查询端只查这张表,响应从秒级降到毫秒级。核心的日期推进用 Python 写比 SQL 清晰:
from datetime import date, timedelta from dateutil.relativedelta import relativedelta def next_due(last_done: date, cycle: dict) -> date: """按周期规则推进下次到期日。 cycle 示例:{"unit": "month", "value": 3, "align": "month_end"} """ unit, value = cycle["unit"], cycle["value"] if unit == "day": base = last_done + timedelta(days=value) elif unit == "month": base = last_done + relativedelta(months=value) elif unit == "year": base = last_done + relativedelta(years=value) else: raise ValueError(f"unsupported cycle unit: {unit}") # 月末对齐:1月31日 + 1个月 应落到 2月28/29日,而不是 3月3日 if cycle.get("align") == "month_end": base = (base.replace(day=1) + relativedelta(months=1)) - timedelta(days=1) return base逻辑说明:这里刻意不用timedelta(days=30)代替一个月,因为 2 月和 31 天月份会把计划越推越偏,一年下来能漂出五六天。relativedelta 会处理月末截断,但截断方向是"往后收",所以额外加了 month_end 对齐分支。参数说明上,cycle 的 unit 只用 day / month / year 三种,value 为正整数,align 目前只支持 month_end,后续要加"季度末"再扩展。
3.3 缺陷单到工单:用乐观锁挡住重复派单
巡检发现异常生成缺陷单,缺陷单审批后派生成工单。这块最容易出的问题是两个人同时点"派单",生成两张工单指向同一缺陷。参考实现用版本号乐观锁:
-- 派单:只有 version 匹配且状态为待派单时才成功 UPDATE em_defect SET status = 'dispatched', work_order_id = $1, version = version + 1, updated_at = now() WHERE id = $2 AND status = 'pending_dispatch' AND version = $3; -- 影响行数为 0 说明被别人抢先,应用层直接提示"该缺陷已被派单"配套的工单状态流转是created -> accepted -> processing -> finished -> verified,验收环节必须由发起人之外的角色完成,防止自己派单自己验收。表上除了 status 还要存accepted_at / finished_at / verified_at三个时间戳,后面算平均处理时长(MTTR)全靠它们,缺一个就没法做设备可靠性统计。这里有个细节:状态字段用字符串还是小整数?小整数省空间但可读性差,凌晨排查问题时对着status=4猜含义很痛苦,建议在库里存小整数、在应用层做枚举映射,同时写一张字典表供报表直接 join。
4. 预防性试验与采集接入:周期分档、点表映射和告警去抖
巡检管的是"看",预防性试验管的是"测"。这部分是电力设备管理区别于通用资产管理的核心,也是最多人做不完整的地方——要么只记个试验日期,要么把试验数据当文本塞进备注字段,后面想做趋势分析完全没法用。
4.1 试验周期表:按电压等级和投运年份分档
预试周期常见的分档逻辑是:投运前三年加密,之后按电压等级放宽。落库时建议建em_test_cycle表,字段(equipment_type, voltage_min, voltage_max, years_since_commission_min, years_since_commission_max, cycle_months)。举例:110kV 以上变压器投运三年内 12 个月一测,三年后 36 个月;35kV 开关柜统一 36 个月。这里不要写死具体数字,各单位的规程有差异,参考实现只提供分档框架和计算函数,具体阈值由使用方填。
试验记录表em_test_record至少要有这些列:equipment_id, test_type, test_date, items_json, conclusion, tester, report_no。items_json 里存结构化结果,比如绝缘电阻{"insulation_resistance_mohm": 1200, "absorb_ratio": 1.6},这样一年后可以写 SQL 直接查"吸收比低于 1.3 且最近一次试验在 12 个月内的设备"。
计算下次试验日期时,基准取"最近一次试验日期"和"投运日期"中较晚的那个,而不是简单按投运日期加周期。这个细节不做,超期设备列表会长期虚高。
4.2 采集侧接入:点表映射单独配置
如果系统要接在线监测,常见做法是用 Modbus TCP 轮询或通过规约网关转成 MQTT。采集代码本身不难,难的是点表管理——同一型号设备在不同站点的寄存器地址往往不同。参考实现把点表抽成配置:
# point_map.py —— 采集点表,与设备台账通过 asset_code 关联 POINT_MAP = { "S01-BAY02-TR-001": [ # (寄存器地址, 功能码, 数据类型, 缩放系数, 业务字段) (1000, 3, "int16", 0.1, "oil_temp"), # 油温,单位 ℃ (1002, 3, "uint16", 0.01, "dc_bus_voltage"),# 直流母线电压,单位 kV (1010, 3, "int32", 1.0, "active_power"), # 有功功率,单位 kW,占两寄存器 ], }from pymodbus.client import ModbusTcpClient def poll_once(asset_code: str, host: str, port: int = 502) -> dict: """轮询一台设备,返回 {业务字段: 物理值};失败抛异常由上层记录。""" client = ModbusTcpClient(host, port=port, timeout=3) result = {} try: if not client.connect(): raise ConnectionError(f"{asset_code} connect failed") for addr, fc, dtype, scale, field in POINT_MAP[asset_code]: count = 2 if dtype == "int32" else 1 # int32 占两个寄存器 rr = client.read_holding_registers(addr, count=count, slave=1) if rr.isError(): continue # 单点失败不影响整台设备 raw = rr.registers[0] if count == 1 else (rr.registers[0] << 16) | rr.registers[1] if dtype == "int16" and raw > 32767: # 有符号数还原 raw -= 65536 result[field] = raw * scale finally: client.close() return result逻辑说明:read_holding_registers 的 slave 参数按现场从站地址改,count 由数据类型决定,int32 必须读两个寄存器并按大端拼接。单点读取失败用 continue 跳过而不是整体抛错,因为现场常有某个寄存器偶发无响应,整台设备中断会让曲线出现大量空洞。参数说明上,timeout 设 3 秒是经验值,低于 1 秒在串口转网口的链路上误报率很高;scale 是缩放系数,量程 0.1 的温感必须乘回去,忘了乘会导致告警永远不触发。
4.3 越限告警去抖:连续三次才入库
采集值直接和阈值比较然后入库告警,是噪音的主要来源。负荷波动、通信抖动都会让瞬时值穿越阈值。参考实现用滑动窗口去抖:同一测点连续 3 个采样周期越限才生成告警事件,任一周期回落到阈值内则计数清零。实现上用一个 Redis 的计数器键alarm:cnt:{asset_code}:{field},带 10 分钟过期,比在数据库里维护状态轻得多。告警事件的表结构要留first_seen_at和confirmed_at,前者是第一次越限时间,后者是确认时间,两者之差就是抖动时长,用来评估阈值设置是否合理。
5. 上线后最容易翻车的五处:台账、时区、并发、删除与导入的排查清单
这套东西在测试环境跑通不难,难的是接完真实数据之后。下面五条都是我这边实际遇到过的,按出现频率排。
现象一:编码改版后历史单据全部挂空。某次为了统一编码规则,把 asset_code 从 12 位改成 16 位,改完发现半年前的工单查询全部返回空。原因很直接:工单表存的是编码字符串,不是外键。解决办法是工单、试验记录、备件消耗表一律用 equipment_id 做外键,asset_code 只在展示层 join 出来。如果历史数据已经这样了,补救方式是建一张old_code -> new_code映射表做一次性回填,回填脚本必须在事务里跑并先做备份。
现象二:巡检计划整体偏移一天。现象是计划日期和实际日期差一天,且只在部分月份出现。原因是数据库存timestamptz,应用侧用本地时区取 date,夏令时或跨时区部署时日期截断位置不同。解决方式是所有"日期"语义的字段统一用date类型,所有"时刻"语义的用timestamptz,应用层禁止用字符串拼日期,取当前日期一律走数据库CURRENT_DATE。
现象三:同一缺陷生成两张工单。现象是缺陷单上挂着两个 work_order_id,班组重复出工。原因是两个用户几乎同时点派单,业务层先查后改,中间有窗口。解决就是 3.3 里的乐观锁,UPDATE ... WHERE version = ?,影响行数为 0 时返回明确提示。再补一条数据库层的唯一部分索引:CREATE UNIQUE INDEX uk_defect_open ON em_work_order(defect_id) WHERE status <> 'cancelled';,双保险。
现象四:删除设备后工单列表报错。现象是台账里删了一台退役设备,历史工单页面直接 500。原因是外键没设 ON DELETE 行为,或者干脆没建外键,代码里按 id 查设备拿到空指针。解决:台账表禁止物理删除,统一走 run_status=4;外键写ON DELETE RESTRICT,让数据库在有人尝试删除时直接拒绝。这条规则要在开发规范里写死,不能靠自觉。
现象五:Excel 导入把全角空格带进了编码。现象是导入后编码看起来一样但关联不上,肉眼比对十分钟找不出问题。原因是从 Excel 复制时带了不可见字符和全角空格。解决:导入前置一道清洗,asset_code.strip().upper()之后用正则^[A-Z0-9-]{6,48}$校验,不通过的行进错误报告而不是静默跳过。导入这种活儿最好做成"先预检、出报告、人确认、再落库"两步,一次性硬导的代价通常是一次数据回滚。
6. 进阶一步:用台账和缺陷历史算设备健康度评分
台账和工单数据攒够半年之后,可以做一件很实用的事:给每台设备算一个健康度评分,把巡检、缺陷、试验超期三类信号加权,输出 0–100 的分值,用于排检修优先级。参考实现里这个分值不参与自动决策,只作为列表排序和看板颜色,避免评分不准时误导现场。
评分公式我用的是减法模型,比加权求和更容易解释:
-- 健康度 = 100 - 缺陷扣分 - 试验超期扣分 - 家族缺陷扣分,下限 0 WITH defect_score AS ( SELECT e.id, SUM(CASE d.severity WHEN 'critical' THEN 15 WHEN 'major' THEN 8 ELSE 2 END) AS penalty FROM em_equipment e LEFT JOIN em_defect d ON d.equipment_id = e.id AND d.created_at > now() - interval '365 days' AND d.status <> 'closed_invalid' -- 误报缺陷不计分 GROUP BY e.id ), test_score AS ( SELECT e.id, CASE WHEN MAX(t.test_date) IS NULL THEN 20 -- 从未试验 WHEN MAX(t.test_date) < now() - interval '18 months' THEN 12 ELSE 0 END AS penalty FROM em_equipment e LEFT JOIN em_test_record t ON t.equipment_id = e.id GROUP BY e.id ) SELECT e.asset_code, e.name, GREATEST(0, 100 - COALESCE(ds.penalty, 0) - COALESCE(ts.penalty, 0)) AS health_score FROM em_equipment e LEFT JOIN defect_score ds ON ds.id = e.id LEFT JOIN test_score ts ON ts.id = e.id WHERE e.run_status <> 4 ORDER BY health_score ASC LIMIT 50;扣分权重的取法有个简单原则:严重缺陷一次扣掉的分,应该大于等于两次一般缺陷,因为它对停电计划的影响本来就不止两倍。试验超期扣 12 分是我拍的经验值,理由是超期本身不代表设备一定有问题,但会显著降低数据的可信度。误报缺陷一定要排除,否则一次传感器故障能让一台好设备在列表里挂一年。
验证这套评分是否靠谱,别去看分数分布好不好看,去看历史回测:取过去 12 个月里实际发生过临修或非计划停运的设备,看它们在发生前 30 天的健康度排名是否落在前 20%。我第一版权重拍得太轻,回测命中率只有三成,把严重缺陷的扣分从 10 提到 15、并加入"同一部件 90 天内重复缺陷额外扣 5 分"之后,命中率才到六成左右。回测脚本不复杂,把历史工单按时间切片重算一遍就行,但一定要在评分上线前跑。
从那以后我每次给这类系统加任何评分或排名逻辑,都强制先跑一遍历史回测,命中率不到五成的一律只做展示、不做排序。评分这种东西看着无害,一旦被用来生成检修计划,错了就是实实在在的出工成本。希望帮到你。
本文还有配套的精品资源,点击获取