一张票的诞生:电气作业票规则与系统设计深度指南
从制度、领域到工程三层,讲清「两票三制」背后的防事故逻辑,并把它完整翻译成不会说谎的系统代码。
写在前面
网上讲两票三制的文章大多停在名词解释,本文会深入的去对这个规则以及对应的系统设计来给相关的从业人员(如果你是搞制造业、电气相关的开发工作的)讲述清楚这个票的设计,主要从下面三个层面展开:
-
制度层:解释每条规则背后防的是什么事故、制度设计用了什么通用的风险控制说明;
-
领域层:规程条款如何逐条转换成数据模型、状态空间和约束断言;
-
工程层:并发、幂等、证据链、防篡改、测试策略这些细节。
制度篇:从事故到规则
为什么必须有一张票
大多数检修作业的风险是可见、可控的:机械停转、上锁挂牌、能量释放完毕,作业面就是安全的。但是电能不一样:
-
能量不可见:肉眼无法分辨设备带电与否,看起来停了并不等于真的停了,一方面是停机不等于断电,另一方面则是断电后也可能存在残压或反送电的情况。
-
不能把电锁起来:电源侧随时可能来电,检修中也可能因误合闸、反送电而突然带电。
-
没有给误操作的容错空间:带负荷(等同于人为引弧短路)拉隔离开关、误入带电间隔,事故在毫秒级发生,没有纠正机会,一旦误操作,则基本等同于发生事故。
-
连锁反应:误操作可能引发系统波动、影响电网,不只是个人伤亡问题,更可能升级成公共安全事件。
电力行业历史上反复出现三类典型恶性事故模式:误入带电间隔(走错位置)、带负荷拉隔离开关(顺序错误)、突然来电伤人(隔离不完整)。对这三类事故的反制,分别落在【双重名称核对】【操作票顺序约束】【停电-验电-接地三件套】上,在后面我们会看到,几乎每条规则都能对应一类事故。
制度的总对策方向是,任何改变设备能量状态、或使人员接近带电体的作业,必须走先授权、再隔离、后作业、终恢复的固定路径,且每一步留下可核查的痕迹。承载这个路径的凭证,就是工作票和操作票。
标准层级与七十年沿革
在继续深入前,我们先厘清标准层级,因为很多人分不清 GB、DL、DL/T 和企业标准的关系:
-
GB:强制性国家标准,底线要求,必须执行;GB/T 为推荐性;
-
DL / DL/T:电力行业标准(DL/T 为推荐性),专业细节通常比国标更细、更新更快;
-
Q/:企业标准,在不低于上位标准的前提下细化本单位执行口径。
两票规则的主线沿革:
timeline title 电气安全工作规程七十年沿革 1950年代 : 燃料工业部《电业安全工作规程》 : 我国电业规程体系起点 1991年 : DL 408-91 发布 : 电力行业标准,两票三制体系定型 2012年 : GB 26860-2011 实施 : GB 26859-2011 实施 : 上升为国家标准 2024年 : DL/T 408-2023 实施 : 细化带电作业、认可电子两票版本 时间节点 意义 《电业安全工作规程》(燃料工业部) 1950 年代初 我国电业安全规程体系起点 DL 408-91 1991 年发布 电力行业标准,“两票三制”体系定型 GB 26860-2011 2012-06-01 实施 发电厂和变电站电气部分上升为国家标准 [2] GB 26859-2011 2012-06-01 实施 电力线路部分的对应国家标准 [4] DL/T 408-2023 2024-06-28 实施 替代 DL 408-91,细化带电作业、认可电子两票等 [3] 2023 版有几个值得我们工程师注意的变化:标准明确适应采用信息技术手段办理工作票,电子票与纸质票同等效力、可追溯防篡改 [3];对带电作业、故障紧急抢修等场景的票据与监护要求做了细化 [3]。前者直接给了电子两票系统合规身份,后者则是扩充了状态机的分支。
授权、隔离、留痕
两票 = 工作票 + 操作票。三制 = 交接班制度、巡回检查制度、设备定期试验轮换制度 [1]。
名词背后是三个通用的风险控制原语,这也是它们能跨行业迁移(医疗、航空、化工都有类似制度)的原因:
-
授权(Authorization):作业前必须由具备资格的第三方确认“这件事该做、这个人能做”,这对应着工作票的签发与许可;
-
隔离(Isolation):把危险能量与作业面物理隔开,并防止意外恢复,这是对应停电、验电、装设接地线、悬挂标示牌和装设遮栏,以及操作票对倒闸顺序的约束;
-
留痕(Accountability):每一步有签名、有时间、有编号、可回溯,对应票面签名栏、连续编号、保存期限,也是事后调查与改进的唯一依据。
三制则覆盖没有作业的日常:交接班保证责任连续不断档;巡回检查让缺陷可以早一些暴露;定期试验轮换则是防止备用设备备而不用、用时失灵的情况。国家能源局监管部门的专项整治方案把一书两票三制并列为刚性执行要求 [1],等于是给这套制度加了监管背书。
四类票与判断逻辑
现行规程体系内,电气作业相关票据主要有四类:
| 票种 | 典型场景 | 关键特征 |
|---|---|---|
| 第一种工作票 | 高压设备上工作,需全部或部分停电,或需做安全措施 | 必须先完成停电、验电、装设接地线等;涉及调度与运行方式改变 [2] |
| 第二种工作票 | 带电作业;不需要停电的低压设备上工作 | 不停电,管控重心是安全距离、个人防护、绝缘工器具 [2] |
| 带电作业票 | 带电作业的专项管控 | DL/T 408-2023 对带电作业票据与监护要求细化 [3] |
| 事故应急抢修单 | 事故紧急抢修 | 可不填工作票,但必须履行许可手续与安全措施,事后补记录 [2][3] |
我们在判断用哪种票的时候,核心变量不是电压高低,而是“是否停电、是否做安全措施”。把判断逻辑写成伪代码如下(这也是我在系统里票种推荐功能的原型,但是实际情况中票种仍以企业现行规程、培训和调度许可为准):
function suggestTicketType(job: JobDescription): TicketType {
if (job.isEmergencyRepair) return 'REPAIR_ORDER'; // 事故抢修
if (job.needPowerOff || job.needSafetyMeasures) return 'FIRST'; // 需停电/做措施
if (job.isLiveWork) return 'LIVE_WORK'; // 带电作业
return 'SECOND'; // 其余不停电工作
}同一个低压设备,需要停电做措施时要按规程办理相应工作票;高压试验等带电性质的工作按带电作业类票据管理。但是,最终我们一定要按照企业规则、培训的情况来做,在现场拿不准时,一定要问工作许可人,不要自己猜,这是生产大忌,我后来担心这种情况存在,所以将这句话做成了表单里的实时提示,提醒作业人员不要瞎猜。
工作票的完整生命周期
我们可以把工作票流程简单的画成“填写→签发→许可→执行→终结”这样的一条线。但是真实流程是一个带异常路径的状态空间,并且异常路径恰恰是系统设计最容易漏的部分:
stateDiagram-v2
direction TB
[*] --> 草稿
草稿 --> 已签发 : 签发人审查通过
草稿 --> 草稿 : 退回修改
草稿 --> 已作废 : 填写错误 作废留痕
已签发 --> 已许可 : 许可人确认措施布置完毕
已签发 --> 已作废 : 检修计划变更
已许可 --> 执行中 : 双方确认后许可开工
执行中 --> 已间断 : 每日收工交回工作票
已间断 --> 执行中 : 复查措施完好后复工
执行中 --> 待延期 : 预计超期 提前2h申请
待延期 --> 执行中 : 许可人批准 延期仅一次
待延期 --> 执行中 : 拒绝则按期终结
执行中 --> 负责人变更 : 负责人临时离开 变更签认
负责人变更 --> 执行中 : 完成变更交接
执行中 --> 已终结 : 撤人 拆措施 双方签字
已终结 --> 已归档 : 按编号归档保存
已作废 --> 已归档 : 作废票同样归档
已归档 --> [*]
标准围绕这个状态空间的硬规则逐条给出“条款 + 防范的是什么事故”:
| 规程条款 | 条文要点 | 防范事故 |
|---|---|---|
| 一式两份 | 一份在工作地点由负责人收执,一份保存在许可人处至终结 [2] | 双方随时可对质,防止单方涂改、防止口头票 |
| 一人一票 | 一个工作负责人同一时间只能持一张有效票 | 防止负责人多头指挥、监护缺位 |
| 一个电气连接部分一票 | 同一停电范围的同一连接部分只发一张票 [2] | 防止两张票互相看不见,交叉作业误送电 |
| 有效期 | 以批准的检修期为限 [2] | 防止无限期票,计划失控 |
| 延期 | 提前 2 小时向许可人申请,只能办一次,再延重新办票 [5] | 给许可人留出重新评估运行方式的反应时间 |
| 间断与复工 | 收工交回票,复工前重新检查安全措施 [2] | 防止夜间标识被挪动、接地线被误拆而不自知 |
| 工作终结 | 人员全部撤离、接地线全部拆除、双方签名后方可恢复送电 [2] | 防止人还在、电已送,恢复送电前的最后防线 |
| 作废 | 错误票据作废留档、编号不复用 | 防止补票、抽票等档案造假空间 |
分权设计
工作票流转中最关键的角色设计是三种人:工作票签发人、工作负责人(监护人)、工作许可人。
| 角色 | 所在层级 | 核心职责 | 资格要点 |
|---|---|---|---|
| 工作票签发人 | 计划层 | 审查工作必要性、安全措施正确完备、所派人员适当 | 熟悉人员技术水平与设备情况、熟悉规程,经批准公布 [2][6] |
| 工作许可人 | 运行层 | 审查措施是否符合现场条件、布置安全措施、许可开工、终结验收 | 运行值班人员中的合格者,经批准公布 [2][6] |
| 工作负责人 | 现场层 | 正确安全地组织工作、交待措施、全程监护、始终在现场 | 有经验、熟悉设备,经批准公布 [2][6] |
这里你可能觉得【经批准公布】读起来有点奇怪,怀疑我是不是写错了,但是这个就是这样写的,“经批准公布”是电力安全规程里的公文式缩写,日常说话我们自然是不会这么讲的,你可以理解为,经单位批准并正式公布的工作票签发人/负责人/许可人。就是说我们要求持证上岗。
这个设计的精髓是利用信息不对称做交叉校验:签发人站在计划视角回答“该不该干、谁来干”;许可人站在运行视角回答“当前运行方式下能不能干、措施够不够”;负责人站在现场视角回答“怎么把活安全干完”。三个视角各自掌握的信息不完全重合,任何一个人的疏漏大概率被另外两人拦截,这就是分权的价值,这要比“找一个最专业的人把关”可靠得多。
但配套的约束同样重要:
-
不得兼任:一张票上签发人与工作负责人不得为同一人(自审自批等于没审);
-
资质名单制:三种人须经培训、考试合格,由单位发文公布,不在名单不得担任 [6];
-
名单动态性:人员离职、考核不合格即从名单剔除,这一点是系统权限模块的硬需求,而不是 nice to have。
操作票:工程性的对抗人为失误
倒闸操作(拉合开关、刀闸等改变设备状态的操作)节奏快、重复性高,是人为失误高发区。规程对操作票的要求,本质是一套对抗人的可靠性极限的交互工程:
| 规则 | 工程原理 |
|---|---|
| 一个操作任务一张票 [2] | 缩小作业单元,避免长序列操作的疲劳与遗漏 |
| 双重名称(设备名称 + 编号)[2] | 用两个独立标识交叉定位,防止走错间隔,因为单名称认错的概率远高于双标识同时认错 |
| 监护复诵制(唱票→复诵→核对→执行)[2] | 闭环沟通(closed-loop communication):指令必须被复述确认后才执行,航空、医疗采用同款机制,强制对齐我说的和你听到的 |
| 逐项打勾、不得跳项漏项添项 [2] | 把长流程切成原子步骤,每步留下不可逆的执行痕迹,防止凭记忆操作 |
| 操作中不得更换监护人与操作人 | 保持责任连续,双人互验关系不断裂 |
| 连续编号、保存一年 [2] | 完整留痕,支撑事后调查与审计 |
从安全科学看,这对应瑞士奶酪模型:人为失误就像光线,任何单层防御都有孔洞,多重独立防御层叠在一起,光线同时穿过所有孔洞的概率被指数级压低。双重名称、复诵、监护、打勾,就是四层奶酪。
规程同样也规定了几类可不填操作票的情形(如事故应急处理、拉合断路器的单一操作等),但不填票不等于不核对、不监护,安全要求一点不少 [2][3],我们在做系统时这类场景要单独留免票登记通道,而不是不留任何记录。
从条款到约束
我在本文中用活票来表示处于未终结、未作废状态,仍需占用资源约束的工作票。
把前三节的规则压缩成一张条款 → 系统断言 → 数据落点的映射表。这张简表是我在设计整个系统的需求基线,同样也是验收依据:
| 规程条款 | 系统断言(校验规则) | 数据落点 |
|---|---|---|
| 三种人持证 [6] | 签名者必须在有效期资质名单内 | qualification 表 + 签名前置校验 |
| 同票不得兼任签发与负责 | issuer_id != leader_id | 票面约束 + 表单联动剔除 |
| 一人一票 | 负责人不存在未终结活票 | 活票唯一索引 |
| 一个连接部分一票 | 同一 location_id 不存在活票 | 活票唯一索引 |
| 措施逐项确认后方可许可 [2] | measures 全部 confirmed_at 非空才允许 permit | 状态机 guard |
| 延期提前 2h、仅一次 [5] | now <= plan_end - 2h && extensions.length < 1 | 状态机 guard + 字段约束 |
| 复工先复查措施 [2] | resume 前必须有复查确认记录 | 状态机 guard |
| 终结双确认 [2] | 撤人 + 拆措施两个独立确认后才能 terminate | 状态机 guard |
| 编号连续、留档一年 [2] | 编号服务端生成、连续、作废不复用;归档策略 ≥12 个月 | 编号服务 + 归档任务 |
| 一切签名可追溯 [3] | 每个签名含角色、服务端时间、票状态快照、内容哈希 | sign_record + 哈希链 |
这张简表就是规程即需求的证明,表中的核心校验规则都能从规程条款直接映射为系统断言和数据落点,不需要额外发明业务规则。至于边界条件、票种差异、异常路径和并发处理,属于详细规格需要显式化的工程决策,不在本简表范围内。
系统篇:领域驱动的完整设计
需求、非需求与系统边界
从纸质痛点出发归纳功能需求,同时明确非目标(Non-goals),这一点远比功能列表更重要:
功能需求,对应纸质四痛点:
-
输入约束(防字迹涂改)
-
流程在线(防跑腿)
-
数据结构化(防翻档案数票)
-
模板内置(防口传徒)。
非功能需求:
-
记录不可抵赖(签名+时间+快照)
-
防篡改(哈希链)
-
现场弱网可用
-
签名瞬间不丢失(幂等重试安全)。
明确的非目标:系统不能证明物理世界照做了。电子签名能证明某人按了确认,证明不了接地线真的挂了。所以必须搭配物理校验手段,去设备扫码核对、关键措施拍照留证、到位监督 [9]。在设计系统前认清了这条边界,才不会做出有电子票就高枕无忧的危险系统。
行业的实践同样如此,白鹤滩电厂的两票智能管控系统用 9 大模块覆盖办票、审批与风险预控 [7],配电网领域的研究把两票与设备台账、运行数据做多源交互 [8],2023 版规程则从标准层面认可了电子两票 [3]。
领域模型与存储设计
建模的原则就是一句话:票面即模型,纸质票的栏目是几十年现场打磨出来的信息结构,就不要再重新发明了,只需要做结构化。
以下 DDL 为 PostgreSQL 示意,仅用于说明约束思路和落点,并非生产中可执行的脚本。
在实际落地的时候还需要结合企业现行规程、数据库选型和完整表结构来设计。
这是核心的实体关系:
person(人) ──1:N── qualification(资质) # 三种人名单
equipment(设备) ──N:1── location(电气连接部分)
work_ticket(工作票) ──1:N── safety_measure(安全措施)
work_ticket ──1:N── sign_record(签名) # 证据链
work_ticket ──1:N── audit_log(审计) # 哈希链
operator_ticket(操作票) ──1:N── op_step(操作步骤) # 有序游标关键表结构(这里给的是 PostgreSQL 语法,每条约束标注对应规程条款):
-- 三种人资质名单(对应"考试—发文—公布"机制 [6])
CREATE TABLE qualification (
id BIGSERIAL PRIMARY KEY,
person_id BIGINT NOT NULL REFERENCES person(id),
role VARCHAR(16) NOT NULL CHECK (role IN ('ISSUER','LEADER','PERMITTOR')),
valid_from DATE NOT NULL,
valid_until DATE NOT NULL,
doc_no VARCHAR(128) NOT NULL, -- 公布文号,追溯发文
CHECK (valid_until > valid_from)
);
-- 工作票主表
CREATE TABLE work_ticket (
id BIGSERIAL PRIMARY KEY,
ticket_no VARCHAR(32) NOT NULL UNIQUE, -- 连续编号,服务端生成
type SMALLINT NOT NULL, -- 1第一种 2第二种 3带电作业 4抢修单
state VARCHAR(16) NOT NULL,
version INT NOT NULL DEFAULT 0, -- 乐观锁:并发编辑冲突提示
location_id BIGINT NOT NULL,
leader_id BIGINT NOT NULL REFERENCES person(id),
issuer_id BIGINT NOT NULL REFERENCES person(id),
permitor_id BIGINT,
content TEXT NOT NULL,
plan_start TIMESTAMPTZ NOT NULL,
plan_end TIMESTAMPTZ NOT NULL,
extension_count SMALLINT NOT NULL DEFAULT 0 CHECK (extension_count <= 1), -- 延期仅一次 [5]
active BOOLEAN NOT NULL DEFAULT TRUE, -- 冗余"活票"标记
CHECK (plan_end > plan_start),
CHECK (issuer_id <> leader_id) -- 同票不得兼任
);
-- "一个电气连接部分同时只有一张活票" [2]
CREATE UNIQUE INDEX uq_active_ticket_location ON work_ticket (location_id) WHERE active;
-- "一人一票" [2]
CREATE UNIQUE INDEX uq_active_ticket_leader ON work_ticket (leader_id) WHERE active;
-- 说明:部分索引为 PostgreSQL 语法;MySQL 可用生成列或冗余列等价实现,细节这里省略。这两条唯一索引是全表最重要的两行,应用层先查后写的校验在并发下并不可靠:两个签发人可能同时给同一负责人签发,两次查询都返回“无活票”。所以关键约束必须沉到存储层,让数据库做最后防线(这个坑我踩过一次,后端在设计的时候比较粗糙,这一部分没有想到过,在测试的时候发现了问题)。
-- 安全措施(第一种票五类:停电/验电/接地/标示牌/遮栏 [2])
CREATE TABLE safety_measure (
id BIGSERIAL PRIMARY KEY,
ticket_id BIGINT NOT NULL REFERENCES work_ticket(id),
seq INT NOT NULL,
category SMALLINT NOT NULL CHECK (category BETWEEN 1 AND 5),
equipment_id BIGINT NOT NULL REFERENCES equipment(id), -- 措施挂在具体设备上
content TEXT NOT NULL,
confirmed_by BIGINT REFERENCES person(id),
confirmed_at TIMESTAMPTZ, -- 非空 = 已逐项确认
UNIQUE (ticket_id, seq)
);
-- 签名记录(证据链核心)
CREATE TABLE sign_record (
id BIGSERIAL PRIMARY KEY,
ticket_id BIGINT NOT NULL REFERENCES work_ticket(id),
action VARCHAR(32) NOT NULL, -- 签名对应的动作
role VARCHAR(16) NOT NULL,
signer_id BIGINT NOT NULL REFERENCES person(id),
signature TEXT NOT NULL, -- 手写笔迹 Base64
signed_at TIMESTAMPTZ NOT NULL, -- 服务端时间,绝不取客户端
state_at VARCHAR(16) NOT NULL, -- 签名时的票状态快照
content_hash CHAR(64) NOT NULL -- 票面关键字段 SHA-256
);
-- 审计日志(append-only 哈希链,见 2.6)
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL,
actor_id BIGINT NOT NULL,
action VARCHAR(32) NOT NULL,
ticket_id BIGINT NOT NULL,
detail JSONB NOT NULL,
prev_hash CHAR(64), -- 前一条 self_hash
self_hash CHAR(64) NOT NULL
);
-- 连续编号服务 [2]:作废票同样消耗编号,保证连续
CREATE TABLE ticket_sequence (
prefix VARCHAR(16) PRIMARY KEY, -- 如 'WT-2026'
next_no BIGINT NOT NULL
);
-- 发号(与建票同事务):
-- UPDATE ticket_sequence SET next_no = next_no + 1
-- WHERE prefix = $1 RETURNING next_no - 1;状态机
状态图是给人看,迁移表是给机器执行的。
这是完整迁移定义(含异常路径),它同时是前端按钮渲染、后端接口校验、测试用例生成的唯一来源:
| 动作 | from | to | 角色 | guards(前置断言) | 副作用 |
|---|---|---|---|---|---|
| saveDraft | DRAFT | DRAFT | LEADER | JSON Schema 校验通过 | version+1 |
| issue | DRAFT | ISSUED | ISSUER | 签发人持证;issuer≠leader;措施非空 | 签名 |
| rejectIssue | DRAFT | DRAFT | ISSUER | — | 退回意见入审计 |
| permit | ISSUED | PERMITTED | PERMITTOR | 许可人持证;措施全部 confirmed_at | 签名 |
| start | PERMITTED | EXECUTING | LEADER | 双方开工确认 | 签名+时间 |
| suspend | EXECUTING | SUSPENDED | LEADER | — | 审计 |
| resume | SUSPENDED | EXECUTING | LEADER | 措施复查记录存在 [2] | 复查记录 |
| applyExtension | EXECUTING | PENDING_EXT | LEADER | now ≤ plan_end−2h;extension_count<1 [5] | 延期申请 |
| approveExtension | PENDING_EXT | EXECUTING | PERMITTOR | — | extension_count+1 |
| rejectExtension | PENDING_EXT | EXECUTING | PERMITTOR | — | 审计(按期终结) |
| changeLeader | EXECUTING | EXECUTING | PERMITTOR | 新负责人持证且无活票;双方签认 | 变更记录 |
| terminate | EXECUTING / SUSPENDED | TERMINATED | LEADER | 撤人+拆措施双确认 [2] | 签名;active=false |
| void | 终结前全部 | VOIDED | ISSUER | 作废原因必填;签发人+负责人双签 | active=false;留档 |
TypeScript 实现:
为控制篇幅,实现部分只展开核心迁移;
rejectIssue、changeLeader、rejectExtension等省略,结构与上表一致。
export type Role = 'ISSUER' | 'LEADER' | 'PERMITTOR';
export type State = 'DRAFT' | 'ISSUED' | 'PERMITTED' | 'EXECUTING'
| 'SUSPENDED' | 'PENDING_EXT' | 'TERMINATED' | 'VOIDED';
export interface Transition {
action: string;
from: State[];
to: State;
roles: Role[];
guards?: Array<(t: Ticket, ctx: Ctx) => boolean>;
effect?: (t: Ticket, ctx: Ctx) => void; // 副作用:签名、计数、快照
}
export const transitions: Transition[] = [
{ action: 'issue', from: ['DRAFT'], to: 'ISSUED', roles: ['ISSUER'],
guards: [issuerQualified,
(t) => t.issuerId !== t.leaderId,
(t) => t.measures.length > 0],
effect: (t, ctx) => ctx.sign('issue') },
{ action: 'permit', from: ['ISSUED'], to: 'PERMITTED', roles: ['PERMITTOR'],
guards: [permittorQualified,
(t) => t.measures.every(m => m.confirmedAt != null)],
effect: (t, ctx) => ctx.sign('permit') },
{ action: 'start', from: ['PERMITTED'], to: 'EXECUTING', roles: ['LEADER'] },
{ action: 'suspend', from: ['EXECUTING'], to: 'SUSPENDED', roles: ['LEADER'] },
{ action: 'resume', from: ['SUSPENDED'], to: 'EXECUTING', roles: ['LEADER'],
guards: [(t) => t.measureRecheckLog != null] },
{ action: 'applyExtension', from: ['EXECUTING'], to: 'PENDING_EXT',
roles: ['LEADER'],
guards: [(t, ctx) => ctx.serverNow <= t.planEnd - 2 * 3600_000, // 提前2h [5]
(t) => t.extensionCount < 1] }, // 仅一次 [5]
{ action: 'approveExtension', from: ['PENDING_EXT'], to: 'EXECUTING',
roles: ['PERMITTOR'],
effect: (t) => { t.extensionCount += 1; } },
{ action: 'terminate', from: ['EXECUTING', 'SUSPENDED'], to: 'TERMINATED',
roles: ['LEADER'],
guards: [(t) => t.crewWithdrawn && t.groundsRemoved], // 双确认 [2]
effect: (t, ctx) => { ctx.sign('terminate'); t.active = false; } },
{ action: 'void',
from: ['DRAFT','ISSUED','PERMITTED','EXECUTING','SUSPENDED','PENDING_EXT'],
to: 'VOIDED', roles: ['ISSUER'],
guards: [(_t, ctx) => ctx.reason.trim().length > 0,
(_t, ctx) => ctx.signatures.includes('ISSUER') && ctx.signatures.includes('LEADER')],
effect: (t, ctx) => { ctx.sign('void'); t.active = false; } },
];
// 当前用户可执行的动作:按钮渲染的唯一依据(服务端计算后下发)
export function allowedActions(t: Ticket, user: User, ctx: Ctx): string[] {
return transitions
.filter(tr => tr.from.includes(t.state))
.filter(tr => tr.roles.includes(user.role))
.filter(tr => (tr.guards ?? []).every(g => g(t, ctx)))
.map(tr => tr.action);
}服务端 dispatch 的完整纪律(幂等 + 锁 + 校验 + 副作用 + 留痕):
export async function dispatch(cmd: DispatchCommand) {
return db.transaction(async tx => {
// 1. 幂等:弱网重试安全,重放直接返回上次结果(尤其保护 extensionCount)
const hit = await tx.idempotency.find(cmd.clientRequestId);
if (hit) return hit.result;
// 2. 行锁读票(低并发单体场景足够;version 另用于前端编辑冲突提示)
const ticket = await tx.ticket.lockForUpdate(cmd.ticketId);
// 3. 用同一份 transitions 做二次校验(不信任前端任何判断)
const tr = findTransition(cmd.action);
assert(tr.from.includes(ticket.state), 'ERR_STATE');
assert(tr.roles.includes(cmd.user.role), 'ERR_ROLE');
assertQualified(cmd.user, ticket); // 资质名单 [6]
for (const g of tr.guards ?? []) assert(g(ticket, ctx), 'ERR_GUARD');
// 4. 副作用 + 留痕(哈希链追加,见 2.6)
tr.effect?.(ticket, ctx);
await tx.ticket.update(ticket); // version = version + 1
await appendAudit(tx, ticket, cmd);
// 5. 返回新状态 + 服务端计算的 allowedActions
const result = { state: ticket.state, version: ticket.version,
allowed: allowedActions(ticket, cmd.user, ctx) };
await tx.idempotency.save(cmd.clientRequestId, result);
return result;
});
}幂等键在存储层需有唯一索引,防止并发重试绕过应用层查询。
这里包含的四个关键工程决策,各自都对应一类事故:
-
allowedActions由服务端计算下发,前端只渲染。早期版本前端自行判断,灰度期间新旧前端对“能否延期”判断不一致,出现“按钮能点、接口挂掉”的幽灵按钮。规则只有一个真相源。 -
幂等键
clientRequestId:现场弱网重试频繁,如果没有幂等,一次重试可能导致重复签名、重复扣减延期次数。 -
行锁/乐观锁:办票并发量不高,行锁足够,更高并发或分布式场景需改用乐观锁 + 唯一索引兜底;
version的真正用途是前端草稿并发编辑的冲突提示。 -
并发约束沉到唯一索引:见上一节所说的,应用层校验挡不住同时提交,但数据库挡得住。
权限模型
只用 RBAC(角色绑定菜单/接口)做两票系统出漏洞是必然的,因为有角色不等于此刻有资格。我们用三层漏斗进行完整的判定:
| 层级 | 判定对象 | 失败含义 |
|---|---|---|
| 第 1 层 角色(RBAC 粗筛) | user.role ∈ transition.roles | 无权限 |
| 第 2 层 资质(有效期名单) | qualification 表中该角色记录 valid_from ≤ server_date ≤ valid_until [6] | 无资格 |
| 第 3 层 约束(业务不变量) | 一人一票 / 一个连接部分一票 / 同票不兼任 / 延期窗口 | 不满足条件 |
三层的失败语义不同,前端提示也应区分。其中有两个要点:
-
名单同步流程:安监部门每年“培训→考试→发文”产生新名单 [6],系统提供按
doc_no(发文文号)批量导入并版本化生效;导入即旧名单失效、新名单生效,中间不留 gap,如离职、考核不合格的人当天就签不了字; -
候选名单前置:三种人候选不是选完再校验,而是资质名单直接驱动选择器数据源,不在名单的人根本搜不到,把错误在输入阶段消灭(当然,这种并不能完全的避免,毕竟用户操作到提交也有时间差,所以提交校验是必须的)。
同一人可持有多个角色资质,但同票内不得兼任签发人、负责人、许可人中的任意两个。
注意:具体规则以企业现行规程和安监名单为准。
表单模板与双端校验
表单是这个系统里前端工作量最密集、交互最重的部分。主要有以下设计:
-
票面分区:按纸质票的视觉分区组织表单(基本信息 / 人员 / 计划时间 / 安全措施 / 签名区),老用户零学习成本迁移,同时可以兼顾车间员工的心智成本;
-
措施模板引擎:
measure_template(ticket_type, voltage_level, equipment_type)三元组索引出模板 JSON,第一种票固定预置停电、验电、接地、标示牌、遮栏五类骨架 [2]。将模板版本化,票创建时锁定模板版本,后续模板修订只影响新建票,不影响任何已存在票的状态; -
JSON Schema 双端共享:同一份 Schema 前端实时校验、后端提交时校验,且错误消息直接引用规程条款原文,如延期时间距计划截止不足 2 小时(依据 Q/CDT 21402001 延期条款 [5]),这比参数校验失败能让现场人员配合得多;
-
台账联动:工作地点、设备一律从台账选择,选中自动带出名称 + 编号双重名称 [2];人员选择器联动持证状态,过期标红禁选。手写设备名的输入框,尽量不要有。
签名和证据链
电子票的公信力 = 签名可信 + 时间可信 + 内容不可篡改。《电子签名法》对可靠电子签名的要求可以工程化为三件事 [10]:
| 法律要件 | 工程实现 |
|---|---|
| 签名制作数据专属于签名人 | 签名动作绑定登录态 + 操作密码二次验证(关键节点手写笔迹板) |
| 签署时由签名人控制 | 签名前展示票面关键字段摘要,确认后才落 sign_record |
| 签名后对内容的任何改动可被发现 | content_hash(票面关键字段 SHA-256)+ 审计哈希链 |
《电子签名法》第十三条的四个条件中,第 3、4 条分别针对“签名本身的改动”和“数据电文内容的改动”,在工程上两者都需要覆盖,本文合并表述了。
哈希链审计是防篡改的核心,每条审计记录的 self_hash = SHA256(prev_hash ‖ canonical(detail)),首条记录的 prev_hash 填固定常量(如全零),作为整条链的锚点,确保任何环境下第一条哈希都可复现。任何对历史记录的增删改都会让后续所有哈希不匹配,校验脚本(离线导出即可验证)能在分钟级确认整条链的完整性。
在哈希链之外,还有三条辅助规则:
-
一切时间取服务端时钟,客户端时间只做展示;
-
sign_record记录票状态快照(state_at),用于回答签字时票处于什么状态这个在事故调查中最常被追问的问题; -
归档 PDF 附哈希清单,纸质档案与电子记录可互相印证 [3]。
移动端、离线与物理校验
现场办票发生在手机上,交互设计的原型直接来自规程:
-
扫码核对:设备二维码编码台账 id + 校验位,扫出来自动比对票面双重名称,这样可以把核对名称编号[2] 变成扫一下的操作,更不易出错;
-
逐项确认交互:安全措施与操作步骤只能按顺序逐项确认,不可跳步,复刻监护复诵的节奏 [2];关键措施确认时强制拍照留证,即拍即传不留本地相册;
-
到位监督:蓝牙 Beacon / GPS 电子围栏校验操作人确实在设备附近 [9],从而弥补系统证明不了人到场的短板;
-
离线策略:如果采用了离线方案,那么 PWA + IndexedDB 只缓存草稿。此外要求活票的一切状态迁移禁止离线执行,离线执行等于绕过服务端校验与留痕,这条红线不能破,提交时可以用
clientRequestId幂等去重; -
纸质应急预案:系统故障时按纸质票流程办理,恢复后补录并打补录标记,保证台账完整。
打印、归档与集成
-
打印:HTML→PDF,像素级复刻档案室历史版式,A4 分页、页脚带连续编号条码,归档时回扫条码即可电子对账;
-
集成:
-
设备台账是双重名称(设备名称 + 设备编号)的唯一来源,禁止重复维护;
-
调度停电单与工作票关联,形成停电—作业—恢复闭环 [8];
-
门禁系统联动授权作业区域;
-
视频监控可选联动(关键步骤留存影像)[8][9];
-
-
数据口径:月度报表自动生成“办票量、有效票/作废票、按票种分布”,替代翻档案数票的操作。
测试策略
这个系统的测试策略非常具有领域驱动的特点:
-
迁移穷举测试(transition coverage):
状态 × 动作全组合逐一断言,要么按迁移表到达目标状态,要么以预期错误码拒绝。状态机没有未定义行为,任何组合都必须有确定结果; -
规程回归测试:每条规程条款至少一个用例,测试名里带条款号(如
[延期][Q/CDT-2017] 提前1.5h申请延期应被拒绝)。规程改版时,按条款号检索用例即可评估影响面,这是规程即需求在测试层的延伸; -
异常路径用例:作废后重建、并发双开(断言第二个提交吃唯一索引冲突)、幂等重试(断言不双签不双扣)、离线草稿同步冲突;
-
证据链用例:篡改任意一条审计记录,断言校验脚本触发报警。
量化运营
系统跑起来之后,数据反哺制度才可以实现价值的闭环:
| 指标 | 含义 | 指向的改进 | 数据来源 |
|---|---|---|---|
| 办票周期 | 从创建到许可的时长分布 | 签发/许可环节是否卡人 | work_ticket.created_at → sign_record.signed_at(permit) |
| 退回率及原因分布 | 草稿被签发人退回的比例 | 表单哪个栏目设计有问题 | rejectIssue 审计记录 + 退回原因 |
| 作废率 | 作废票 / 总票 | 计划质量、模板易用性 | work_ticket.state = 'VOIDED' |
| 延期率 | 发生延期的票占比 | 检修工期估算口径 | extension_count > 0 |
| 措施确认耗时 | 措施布置实际耗时 | 停电操作流程瓶颈 | safety_measure.confirmed_at 最早/最晚之差 |
一个运营心得:这些指标用于改进流程,不用于个人绩效排名。一旦量化变成考核,现场的第一反应是想办法把数据做好看而不是把安全做好,制度设计如此,系统运营同样如此。
实战篇:问题实录
系统稳定运行多年后,我们再回看开发过程,就会发现最值钱的不是代码,是在开发、运行过程中发现的问题。
问题一:把工作票当成一张表单来做
刚开始我是按 CRUD 做的,字段校验很细,但是没有判断“表单项在这个阶段允不允许填”。结果就出现了未签发的票被提前填上措施确认时间。但是这个实际在开发的时候写表单的时候就可以发现,因为这个操作明显是不符合交互逻辑的。
我修改的时候,是将所有写操作收敛到 dispatch,让编辑权限由状态机决定。简单来说就是先判断状态,然后判断字段信息。
问题二:权限收敛只做到了菜单
初版 RBAC 只关注“能不能进签发页面”,能进入就能渲染。签发人打开别人执行中的票,界面上照样可以出现许可操作入口。但是两票权限是“角色 × 状态 × 动作”三维的,配置菜单级只覆盖了其中一维。
这其实是一种妥协,有时候为了工期,我们会分步骤进行实现,我当时预估车间人员使用的时候,菜单级的校验基本上就可以 cover 了,毕竟这个是工作用的工具,他们也不存在时间去研究这套工具的机制,按照培训操作一般不会有问题。
事实证明,这样的操作确实是可以的,PC+内网访问,基本不会有什么问题,但是后来要搞移动端,开放公网的时候,这种就存在风险了,我们也就老老实实完善了两票权限。
问题三:时间戳用了客户端时间
早期的一些和时间相关的表单项是直接从前端取了 new Date(),还是那句话,一般没有问题,但是有一次我们发现有一台电脑的本机时间快了,导致许可时间晚于终结时间,服务端检查到之后自动作废了(有一个时间上的安全拦截机制),说实话,这其实也就是件小事,改一下数据库就可以解决了,但是如果我们做这种改动,如果这张票出了问题,我们在审计的时候是说不清楚的。
所以不建议,类似时间的内容,我们还是统一的用服务端时间,因为关键节点的时间是证据,而不仅仅是 UI。而证据,是必须来自可信源的。
问题四:打印版式没有对齐
打印版式是有公司规范要求的,我们在界面上可以做一些优化,但是最终打印成票据的时候,因为是要存档的,这种票据有一定的格式要求,可能会很丑,但是没关系,归档的需求要高于任何UI要求,不要自由发挥。
问题五:并发双开击穿了应用层校验
在一开始的设计中,我们采用的是查有没有活票→再插入这样的应用层校验,乍一看可以放心,但是在这个检查到插入的过程中,如果有一个新的建票请求,那么就可能会击穿这个校验。一般典型场景是多个人给一个人派单,正常情况是这个人只能一次接收一个任务(一人一票),但是因为这个校验没有兜住,导致这个人接收到了两个任务。
这个问题和并发量没有什么关系,只要这个时间窗口(检查-插入)存在,就一定是会遇到的,属于是经典的 TOCTOU问题。
解决方案是多样的,但是直接在存储层增加唯一索引,可以从根本防范这个问题。
问题六:前端校验出现版本滞后
前后端分离项目(前提),一开始,延期判断只有前端有,后来后台终于加上了这套校验,但是和前端的校验有一些不一致(有口径变化),前端升级的过程中(灰度或者全量),只要有人还在用旧版本,这个提交的按钮点击就有一定可能性报错。因为前端的页面更新再怎么也不会是即时的。
其实一开始就应该将这套规则收敛到后台判断,接口返回对应的操作列表,这样可以实现校验的及时更新,但是事已至此,我们能做的就是解决,很简单暴力,发布完成后,通知车间刷新当前的页面。不过更优雅的其实是在新版本中,使用新接口,我们的接口都带有版本标记(v1/v2),我们需要允许一段时间的并存,当确定影响变的微小的时候,就可以放心移除旧版接口了。
结语
读到这里,相信我的读者们会对两票三制有一个很完善的理解了。
在电票系统中,规程就是最好的需求文档,领域知识则决定系统的上限。前端工程师的价值不只是把交互做好,而是要把一个行业几十年的制度积淀,完整地翻译成不会说谎的代码,这件事本身就足够有价值了。
给作业人员一句话:你们每一次很麻烦的核对、复诵、签名,背后都是一类被反复验证过的事故场景;
再说我们工程师:一定要先读懂制度,再写代码,顺序搞反了,我们工作肯定是会返工。
参考资料
-
国家能源局综合司:《四川电力安全生产专项整治三年行动实施方案》(“一书两票三制”刚性执行要求)— http://www.nea.gov.cn/2020-07/10/c_139203070.htm
-
GB 26860-2011《电力安全工作规程 发电厂和变电站电气部分》(PDF)— https://sbc.shu.edu.cn/DLAQGZGC.pdf
-
DL/T 408-2023《电力安全工作规程 发电厂和变电站电气部分》(PDF,2024-06-28 实施)— https://img.antpedia.com/standard/files/pdfs_ora/20260725/hb3/dl/DL_T%20408-2023.pdf
-
GB 26859-2011《电力安全工作规程 电力线路部分》(PDF)— http://www.he.sgcc.com.cn/html/files/2024-07/14/20240714203746228427932.pdf
-
Q/CDT 21402001-2017《工作票、操作票使用与管理标准》(延期 2 小时及一次为限等条款)— https://m.book118.com/try_down/455003330021012002.pdf
-
《山东热电公司工作票和操作票管理实施细则》(三种人培训、考试与名单公布流程)— https://m.book118.com/try_down/466034220132011001.pdf
-
三峡集团:《白鹤滩电厂两票智能管控系统上线运行》报道 — https://www.ctg.com.cn/sxjt/xwzx55/zhxw23/2026011210254393420/index.html
-
《考虑多源数据交互的配电网数字化“两票”研究与应用》,《电气应用》 — http://www.dqyy1982.com.cn/cn/article/id/fa31e5ed-7588-48f2-8c89-1ab48400ef05
-
赛摩博晟:智能两票系统(移动办票、二维码核对、蓝牙到位监督)— https://www.bsh-tech.com/html/productCenter-X-ZNYD.html
-
《中华人民共和国电子签名法》(可靠电子签名要件,可在国家法律法规数据库查阅)— https://flk.npc.gov.cn/