ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

电力收费系统数据库设计:从表结构到阶梯电价计费存储过程实战

电力收费系统数据库设计:从表结构到阶梯电价计费存储过程实战 简介这份数据库课程设计文档面向高校计算机相关专业学生围绕「某电力公司收费管理信息系统」这一典型课题提供从需求分析到数据库落地的完整设计思路适合正在准备课程设计、需要参考建模范例与实现路径的学习者。资源包内共1个doc文件约261KB以Word文档形式呈现便于直接阅读、批注与二次编辑。文档系统梳理了客户、用电类型、员工、用电信息、费用管理、收费登记等核心表结构给出E-R模型与一对多关系设计并说明视图、触发器、存储过程的实现要点如收费时自动更新应收与实收费用、按月份统计未交费用户等。内容还覆盖系统概要设计、数据流程图、功能模块图及程序流程图能帮助读者理解收费标志自动修改、结余金额联动等业务逻辑。目前已有241人学习可作为课程设计报告撰写与数据库综合应用的实用参考。1. 电力收费系统到底在收什么从一笔电费看懂整套数据库设计很多人第一次拿到「电力公司收费系统」这个课设题目脑子里蹦出来的就是一张用户表加一张电费表两张表一关联扣个余额就完事。真按这个思路交上去答辩时老师问一句「阶梯电价怎么算、违约金从哪天起算、换表当月的电量怎么拆」基本就当场卡壳。这个系统的本质不是记账而是把「用电行为」翻译成「应收金额」再翻译成「实收流水」的一条完整链路中间任何一环的规则没落到表结构里后面全是补丁。它适合两类人一类是数据库课设想拿高分、需要把范式、事务、触发器、存储过程都用上的同学另一类是想练手一个「规则密集、状态流转多」的业务系统的开发者。收费系统看着土但它把计费规则、账期、欠费、滞纳金、票据这些真实业务约束全塞进来了比图书管理、学生成绩这类题目更能体现数据库设计功力。下面我按自己带课设时最常用的一套方案从表结构一路讲到计费存储过程和排错能直接照着复现。2. 先把表结构立住七张核心表与三个容易设计错的字段2.1 从业务动作倒推实体而不是从字段倒推表设计表之前先别急着写 CREATE TABLE先把系统里发生的动作列出来用户开户、装表、抄表、算费、出账、缴费、开票、欠费催收。每个动作对应一到两个实体实体之间再定关系。我一般会先画一张动作-实体对照确认没有遗漏再动手。核心实体有七个用户customer、电表meter、电价方案price_plan、抄表记录meter_reading、账单bill、缴费流水payment、票据invoice。用户和电表是一对多因为一个用户可能有多个计量点电表和抄表记录是一对多账单挂在用户和抄表记录上缴费流水挂在账单上允许一笔账单分多次缴票据挂在缴费流水上。这里第一个容易翻车的点别把电表信息直接塞进用户表。课设里图省事把表号、倍率写进用户表结果一个用户两个计量点就没法表达后期改表结构比重新设计还痛苦。2.2 建表 SQL 与字段含义下面这套 DDL 是我常用的最小可用版本MySQL 8.0 直接能跑。注意金额统一用 DECIMAL别用 FLOAT电费算到分浮点误差累积起来对不上账是要出事的。-- 用户表一个用户可有多个计量点 CREATE TABLE customer ( customer_id BIGINT PRIMARY KEY AUTO_INCREMENT, cust_no VARCHAR(20) NOT NULL UNIQUE COMMENT 户号对外唯一标识, cust_name VARCHAR(50) NOT NULL, addr VARCHAR(200), phone VARCHAR(20), status TINYINT DEFAULT 1 COMMENT 1正常 0销户, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 电价方案阶梯电价按方案档位存别写死在代码里 CREATE TABLE price_plan ( plan_id BIGINT PRIMARY KEY AUTO_INCREMENT, plan_name VARCHAR(50) NOT NULL, plan_type TINYINT NOT NULL COMMENT 1居民阶梯 2商业 3工业, effective_date DATE NOT NULL COMMENT 生效日期支持调价, status TINYINT DEFAULT 1 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 阶梯档位一个方案多条按档位序号排 CREATE TABLE price_tier ( tier_id BIGINT PRIMARY KEY AUTO_INCREMENT, plan_id BIGINT NOT NULL, tier_no INT NOT NULL COMMENT 档位序号从1开始, upper_kwh DECIMAL(12,2) COMMENT 本档上限电量NULL表示无上限, unit_price DECIMAL(10,4) NOT NULL COMMENT 单价元/度, FOREIGN KEY (plan_id) REFERENCES price_plan(plan_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 电表倍率、初始读数必须存换表靠它 CREATE TABLE meter ( meter_id BIGINT PRIMARY KEY AUTO_INCREMENT, meter_no VARCHAR(30) NOT NULL UNIQUE, customer_id BIGINT NOT NULL, multiplier DECIMAL(6,2) DEFAULT 1 COMMENT 互感器倍率, install_date DATE, status TINYINT DEFAULT 1 COMMENT 1在用 0拆表, FOREIGN KEY (customer_id) REFERENCES customer(customer_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 抄表记录本期读数、上期读数、抄表方式 CREATE TABLE meter_reading ( reading_id BIGINT PRIMARY KEY AUTO_INCREMENT, meter_id BIGINT NOT NULL, read_date DATE NOT NULL, prev_reading DECIMAL(12,2) NOT NULL, curr_reading DECIMAL(12,2) NOT NULL, read_type TINYINT DEFAULT 1 COMMENT 1正常 2换表 3估抄, operator VARCHAR(30), FOREIGN KEY (meter_id) REFERENCES meter(meter_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 账单金额、账期、状态是核心 CREATE TABLE bill ( bill_id BIGINT PRIMARY KEY AUTO_INCREMENT, bill_no VARCHAR(30) NOT NULL UNIQUE, customer_id BIGINT NOT NULL, reading_id BIGINT NOT NULL, period_start DATE NOT NULL, period_end DATE NOT NULL, kwh DECIMAL(12,2) NOT NULL COMMENT 计费电量, amount DECIMAL(12,2) NOT NULL COMMENT 应收金额, paid_amount DECIMAL(12,2) DEFAULT 0 COMMENT 已收金额, status TINYINT DEFAULT 0 COMMENT 0未缴 1部分 2已缴 3作废, due_date DATE NOT NULL COMMENT 缴费截止日违约金起算依据, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (customer_id) REFERENCES customer(customer_id), FOREIGN KEY (reading_id) REFERENCES meter_reading(reading_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 缴费流水一笔账单可多次缴 CREATE TABLE payment ( pay_id BIGINT PRIMARY KEY AUTO_INCREMENT, bill_id BIGINT NOT NULL, pay_amount DECIMAL(12,2) NOT NULL, pay_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_channel TINYINT COMMENT 1柜台 2线上 3代扣, FOREIGN KEY (bill_id) REFERENCES bill(bill_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字段说明里几个关键点multiplier是互感器倍率工业用户抄到的读数要乘倍率才是真实电量漏了这个字段算出来的电费能差几十倍due_date单独存而不是靠账期推算因为不同用户缴费期限可能不同违约金起算全靠它paid_amount冗余在账单表里是为了避免每次查余额都去 SUM 缴费流水代价是要在缴费时同步更新这个一致性靠事务保证。2.3 三个最容易设计错的字段第一个是电量字段的单位和精度。抄表读数是表计读数计费电量是读数差乘倍率两者语义不同别共用一个字段。我见过把curr_reading - prev_reading直接当电量的换表当月直接算成负数。第二个是账单状态用枚举还是状态机。课设里用 TINYINT 存状态没问题但状态流转必须写清楚未缴→部分→已缴作废只能从未缴进入。别允许从已缴回退到未缴那是对账的灾难。第三个是时间字段用 DATE 还是 DATETIME。账期、截止日用 DATE缴费、创建时间用 DATETIME。混用会导致「当天缴费算不算逾期」这种边界问题答辩时被追问很难圆。3. 阶梯电价怎么算一个存储过程把计费规则讲透3.1 阶梯计费的本质是分段累加不是套公式居民阶梯电价最常见的规则是第一档 0-180 度按 0.5 元第二档 181-400 度按 0.55 元第三档 400 度以上按 0.8 元。注意这里的档位是累进的不是「超过 180 就全部按 0.55」。也就是说用了 300 度前 180 度按 0.5剩下 120 度按 0.55总价是 90 66 156 元。很多人第一版写成 300 × 0.55直接算错。把规则落到price_tier表后计费逻辑就是按档位序号从小到大遍历每档取「本档上限 - 上档上限」作为本档可容纳电量和剩余电量取小乘单价累加直到电量分配完。3.2 计费存储过程与逐行解释下面这个存储过程输入抄表记录 ID输出计费电量和金额并把账单插进去。用存储过程而不是应用层算是因为计费规则要能被多个入口复用批量出账、单笔补算放数据库里改规则只改一处。DELIMITER $$ CREATE PROCEDURE calc_bill(IN p_reading_id BIGINT, OUT p_bill_id BIGINT) BEGIN DECLARE v_meter_id BIGINT; DECLARE v_customer_id BIGINT; DECLARE v_multiplier DECIMAL(6,2); DECLARE v_kwh DECIMAL(12,2); DECLARE v_plan_id BIGINT; DECLARE v_remain DECIMAL(12,2); DECLARE v_amount DECIMAL(12,2) DEFAULT 0; DECLARE v_prev_upper DECIMAL(12,2) DEFAULT 0; DECLARE v_tier_upper DECIMAL(12,2); DECLARE v_price DECIMAL(10,4); DECLARE v_take DECIMAL(12,2); DECLARE done INT DEFAULT 0; -- 游标遍历该用户当前生效方案的档位 DECLARE cur CURSOR FOR SELECT t.upper_kwh, t.unit_price FROM price_tier t JOIN price_plan p ON t.plan_id p.plan_id WHERE p.plan_id v_plan_id AND p.status 1 ORDER BY t.tier_no; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done 1; -- 1. 取抄表对应的表、用户、倍率 SELECT r.meter_id, m.customer_id, m.multiplier INTO v_meter_id, v_customer_id, v_multiplier FROM meter_reading r JOIN meter m ON r.meter_id m.meter_id WHERE r.reading_id p_reading_id; -- 2. 算计费电量 (本期-上期) * 倍率 SELECT (curr_reading - prev_reading) * v_multiplier INTO v_kwh FROM meter_reading WHERE reading_id p_reading_id; -- 3. 取该用户当前生效的电价方案简化取最新一条 SELECT plan_id INTO v_plan_id FROM price_plan WHERE status 1 ORDER BY effective_date DESC LIMIT 1; SET v_remain v_kwh; -- 4. 逐档累加 OPEN cur; read_loop: LOOP FETCH cur INTO v_tier_upper, v_price; IF done 1 OR v_remain 0 THEN LEAVE read_loop; END IF; -- 本档可容纳电量有上限则上限-上档上限无上限则剩余全吃 IF v_tier_upper IS NULL THEN SET v_take v_remain; ELSE SET v_take LEAST(v_remain, v_tier_upper - v_prev_upper); SET v_prev_upper v_tier_upper; END IF; SET v_amount v_amount v_take * v_price; SET v_remain v_remain - v_take; END LOOP; CLOSE cur; -- 5. 插入账单金额保留两位 INSERT INTO bill(bill_no, customer_id, reading_id, period_start, period_end, kwh, amount, due_date) VALUES (CONCAT(B, DATE_FORMAT(NOW(),%Y%m%d), LPAD(p_reading_id,6,0)), v_customer_id, p_reading_id, CURDATE(), CURDATE(), v_kwh, ROUND(v_amount,2), DATE_ADD(CURDATE(), INTERVAL 30 DAY)); SET p_bill_id LAST_INSERT_ID(); END$$ DELIMITER ;逻辑说明游标按档位序号升序取v_prev_upper记录上一档的上限本档可容纳电量就是upper_kwh - v_prev_upper。用LEAST(v_remain, ...)保证不会超分配。最后一档upper_kwh为 NULL 时直接吃掉剩余电量。金额最后ROUND到分避免出现 156.00000001 这种脏数据。参数说明p_reading_id是抄表记录主键调用前必须保证该记录已存在且本期读数大于上期p_bill_id是输出参数返回新生成的账单 ID。due_date这里简化成出账后 30 天真实系统会按用户类型区分。调用方式CALL calc_bill(1001, new_bill_id); SELECT new_bill_id;3.3 换表、估抄、退补这些特殊场景怎么处理换表是课设里最容易被忽略的场景。换表当月旧表拆走时的读数要作为旧表的最终读数新表起始读数通常不为零。正确做法是生成两条抄表记录旧表一条read_type2新表一条read_type2计费电量是两段之和。如果只按一条记录算要么漏算要么算成负数。估抄是抄表员没到现场按历史均值先出账下月多退少补。实现上给read_type3账单照出但要在账单表加一个is_estimated标记下月实际抄表后生成一条调整账单。退补金额可能为负所以amount字段允许负数别加 CHECK 约束限制成正数。4. 缴费、违约金与对账事务边界和三个必调参数4.1 缴费必须是一个事务别拆成两步缴费动作包含三件事插一条 payment 流水、更新 bill 的 paid_amount、如果缴清则把 status 改成已缴。这三步必须在一个事务里否则插了流水没更新账单对账时就会出现「钱收了账没平」的黑匣子。START TRANSACTION; -- 1. 插流水 INSERT INTO payment(bill_id, pay_amount, pay_channel) VALUES (1001, 200.00, 1); -- 2. 累加已收 UPDATE bill SET paid_amount paid_amount 200.00 WHERE bill_id 1001; -- 3. 判断是否缴清更新状态 UPDATE bill SET status CASE WHEN paid_amount amount THEN 2 WHEN paid_amount 0 THEN 1 ELSE 0 END WHERE bill_id 1001; COMMIT;注意第三步的 CASE 依赖第二步更新后的值MySQL 在同一事务里能读到自己的修改所以顺序不能反。如果先改状态再累加金额状态判断用的还是旧值会出错。4.2 违约金按天算起算日和费率是两个必调参数违约金规则一般是「超过 due_date 后每天按欠费金额的万分之几收取」。两个参数必须可配起算日due_date 次日还是当日和日费率。我一般把费率放配置表别写死在 SQL 里调价时不用改代码。-- 计算某账单截至今天的违约金 SELECT b.bill_no, (b.amount - b.paid_amount) AS owe, DATEDIFF(CURDATE(), b.due_date) AS overdue_days, ROUND((b.amount - b.paid_amount) * 0.0005 * GREATEST(DATEDIFF(CURDATE(), b.due_date), 0), 2) AS penalty FROM bill b WHERE b.status IN (0,1) AND b.due_date CURDATE();GREATEST(..., 0)是关键未逾期的账单不能算出负违约金。日费率 0.0005 对应万分之五这个值要按实际规则调别照抄。4.3 对账查询找出账实不符的账单对账就是核对bill.paid_amount和该账单所有 payment 之和是否一致。不一致说明有事务没提交成功或者被手工改过数据。SELECT b.bill_id, b.bill_no, b.paid_amount, IFNULL(SUM(p.pay_amount), 0) AS real_paid, b.paid_amount - IFNULL(SUM(p.pay_amount), 0) AS diff FROM bill b LEFT JOIN payment p ON b.bill_id p.bill_id GROUP BY b.bill_id, b.bill_no, b.paid_amount HAVING diff 0;这条查询应该长期返回空集。如果课设答辩时老师让你现场跑能跑出空集就是加分项。LEFT JOIN保证没缴过费的账单也参与比对IFNULL把 NULL 转成 0。5. 避坑与排查课设答辩前一定要过的五道坎5.1 抄表读数倒挂计费电量为负现象某条账单的 kwh 是负数金额也是负的。原因通常是换表或估抄时上期读数填错或者新表起始读数比旧表终止读数小被当成同一条记录算差。解决在插入抄表记录前加校验curr_reading prev_reading换表场景拆成两条记录分别算别硬塞进一条。5.2 缴费后账单状态没变余额对不上现象流水插进去了但账单还是未缴。原因多半是缴费的三步没放同一事务或者第二步 UPDATE 的 WHERE 条件写错比如用了 bill_no 但传的是 bill_id。解决把三步包进事务UPDATE 后立刻 SELECT 验证paid_amount是否变化对账查询跑一遍确认 diff 为 0。5.3 阶梯电价算出来比实际高一大截现象300 度电算出 240 元而不是 156 元。原因是没有分段累加直接用了最高档单价乘总电量。解决确认游标按 tier_no 升序v_prev_upper正确累加用一条已知答案的用例验证——180 度应该是 90 元181 度应该是 90.55 元卡在档位边界上测。5.4 违约金把未逾期账单也算进去了现象所有未缴账单都显示有违约金。原因是查询条件漏了due_date CURDATE()或者 DATEDIFF 没套 GREATEST。解决先筛due_date CURDATE()再对天数取GREATEST(..., 0)两个条件缺一不可。5.5 金额字段用 FLOAT对账差几分钱现象单笔看不出问题几百笔汇总后总额差几毛。原因是 FLOAT 二进制存储有精度损失。解决所有金额字段改 DECIMAL(12,2)计算过程用 DECIMAL最后 ROUND 到分。已经建表的用ALTER TABLE ... MODIFY改类型改完重新跑一遍对账。6. 把课设做成能讲清楚的作品验证脚本与一个收尾习惯答辩时最怕的不是功能少而是说不清数据怎么流转。我的习惯是准备一个验证脚本从开户到出账到缴费跑一遍每步打印关键表的状态老师问哪一步都能当场演示。下面这个脚本用存储过程串起全流程跑完能直接看到账单从 0 到缴清的状态变化。-- 验证脚本一条龙跑通开户→抄表→出账→缴费 -- 1. 开户 INSERT INTO customer(cust_no, cust_name, addr, phone) VALUES (C0001, 测试用户, 某小区1栋101, 13800000000); SET cid LAST_INSERT_ID(); -- 2. 装表倍率1 INSERT INTO meter(meter_no, customer_id, multiplier, install_date) VALUES (M0001, cid, 1, CURDATE()); SET mid LAST_INSERT_ID(); -- 3. 抄表上期0本期300 INSERT INTO meter_reading(meter_id, read_date, prev_reading, curr_reading, read_type) VALUES (mid, CURDATE(), 0, 300, 1); SET rid LAST_INSERT_ID(); -- 4. 出账 CALL calc_bill(rid, bid); SELECT bill_no, kwh, amount, status FROM bill WHERE bill_id bid; -- 预期kwh300, amount156.00, status0 -- 5. 缴费200 START TRANSACTION; INSERT INTO payment(bill_id, pay_amount, pay_channel) VALUES (bid, 200, 1); UPDATE bill SET paid_amount paid_amount 200 WHERE bill_id bid; UPDATE bill SET status CASE WHEN paid_amount amount THEN 2 WHEN paid_amount 0 THEN 1 ELSE 0 END WHERE bill_id bid; COMMIT; SELECT bill_no, amount, paid_amount, status FROM bill WHERE bill_id bid; -- 预期paid_amount200, status2已缴清因为200156 -- 6. 对账应返回空 SELECT b.bill_id, b.paid_amount, IFNULL(SUM(p.pay_amount),0) AS real_paid FROM bill b LEFT JOIN payment p ON b.bill_id p.bill_id WHERE b.bill_id bid GROUP BY b.bill_id, b.paid_amount HAVING b.paid_amount IFNULL(SUM(p.pay_amount),0);跑完这个脚本如果第 4 步金额是 156.00、第 5 步状态是 2、第 6 步返回空说明计费、缴费、对账三条链路都通了。任何一步不对按第 5 章的排查顺序往回找。进阶一点的做法是把calc_bill改成支持批量出账传入账期游标遍历该账期内所有未出账的抄表记录逐条调用。这样月底出账一条 SQL 搞定也更能体现存储过程的价值。批量出账要注意加事务中途失败要整体回滚别出一半留一半。我自己踩过最深的一个坑是早期图快把电价规则写死在应用代码里后来调价改了三个地方还漏了一处导致一批账单算错只能手工冲正。从那以后凡是「会变的规则」一律进配置表代码只读不写死。做课设也一样规则进表、逻辑进存储过程、验证脚本常备这三条守住答辩怎么问都不慌。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进