🪟 第19章 C#与SQL Server实现企业人事管理系统
📄 一句话概括

入职必须一个事务写两处:员工表和账号表,要么都成,要么都不成。 调岗要「先写历史,再改主档」——反了的话,「改之前是什么」就被覆盖掉了。 工资不存公式,存「结果 + 当时的输入」——因为明年的标准会变。

📝 本章和前面几章的关系

这一节是本章唯一「功能最多」的一节, 教材 19.7 分四小节:员工信息管理、部门与岗位设置、考勤与工资、业务模块的公共骨架。 四个模块看着不一样,骨架其实一样: 列表 → 选中 → 编辑 → 校验 → 保存 → 刷新。 「公共骨架」那一小节就是把这套流程抽象出来,后面章节会反复用到。 工资那一小节要特别注意口径: 教材给的公式里用了「月平均计薪天数 21.75」, 这个数字是法定口径((365−104)/12),不是随便写的。 换口径算出来的数会差几百块,一定要在文档里写清楚用的是哪个。

一、入职:一个事务写两处

步 写哪儿 失败了怎么办
1 tb_employee 插一行 整个回滚
2 取回新员工 id 整个回滚
3 tb_user 插一行(带盐和哈希) 整个回滚
4 提交 —

为什么必须一个事务? 因为「有员工没账号」和「有账号没员工」都是坏数据。 前一种人登不进来,后一种账号指向空气。 第 2 步「取回新 id」很容易漏。 漏了就只能写死 id,或者再查一次—— 写死必错,再查一次在并发下可能查到别人。 SQL Server 用 SCOPE_IDENTITY(),SQLite 用 last_insert_rowid()。 注意这里和第 18 章的区别: 酒店开房是「改房态 + 插住单」,是两个已有的记录;入职是「插员工 + 插账号」,是两个新记录。 事务的边界一样,但取 id 这一步是新问题。

二、调岗:先写历史,再改主档

顺序 历史表里能记下什么
先查旧值 → 写历史 → 改主档 改之前和改之后,两个都有
先改主档 → 再写历史 只剩「改之后」,旧值已经没了

这个顺序不能反。 反了的话,历史表里就只能记「改完之后是什么」,「改之前是什么」已经被覆盖掉了。 还有个更隐蔽的细节:历史表该存「名字」还是「id」? 存名字。 因为部门改名以后,历史记录里还该是当时的名字—— 存 id 的话,翻三年前的调岗记录,会看到今天的部门名,那不是历史。 这条经验可以概括成一句: 历史表要冻结「当时的样子」,不存指针。 同理,工资表里存的是「当时的基本工资」,不是「岗位 id」。

三、考勤:批量写,别一条一条来

写法 180 行的耗时 原因
逐条 INSERT 各自提交 几秒 每条一次刷盘
一个事务批量插 不到 30 毫秒 只在提交时刷一次盘

这不是「优化」,是「能不能用」的区别。 人事一天导入 200 人的考勤,逐条提交要等十几秒—— 用户会以为程序卡死了。 批量写还有个配套问题:重复怎么办? 同一人同一天不能有两条, 所以要么 INSERT OR IGNORE(SQLite),要么 IF NOT EXISTS ... INSERT(SQL Server), 要么干脆先删后插(DELETE 当月 + 批量 INSERT)。 「先删后插」是最省事的,但风险最大——中途失败就整月考勤没了。 所以它必须包在事务里,而且删的范围要精确到「这个人这个月」。

四、工资:算出来的,还是存下来的

口径 值 说明
月平均计薪天数 21.75 (365 − 104) ÷ 12,法定口径
日薪 基本工资 ÷ 21.75 用于扣缺勤
加班费 加班小时 × 40 这一版简化成固定单价
迟到扣款 迟到次数 × 50 这一版简化成固定金额

工资表存的是「结果」,不是「公式」。 因为明年的津贴标准会变—— 存公式的话,去年的工资单明年重算会变样,那就没法对账了。 正确做法是「存结果 + 存当时的输入」:基本工资、津贴、加班小时、扣款, 四个都存进去,再把总额存进去。 这样任何一年都能还原出「当时怎么算的」。 21.75 这个数要单独说: 它是 (365 − 104) ÷ 12,104 是一年的双休日。 有些公司用 21.75,有些用当月实际工作日——口径不同,同样缺勤一天扣的钱可能差几十块。 所以文档里必须写清楚用的是哪个。

五、四个模块的公共骨架

阶段 每个模块都要做的事
列表 SELECT ... FROM v_xxx,带筛选条件
选中 把当前行的 id 记下来(不是整行数据)
编辑 打开编辑区,字段跟表列一一对应
校验 必填、格式、业务规则(工号唯一、日期不倒挂)
保存 一个事务,失败全回滚
刷新 重新查列表,让用户看到结果

「选中时记 id,不记整行数据」这一条很重要。 记整行数据的话,别人改了这行,你保存时会把旧值覆盖回去—— 这就是「丢失更新」,第 15 章讲过的经典问题。 记 id、保存时重查、必要时比对版本号,才是正经做法。 最省事的版本号是加一列 rowver(每次更新 +1), 保存时带上 WHERE id=? AND rowver=?,影响行数为 0 就说明被人改过。 这套骨架抽象出来,四个模块能省一半代码。 但别抽象得太早—— 先老老实实写两个模块,第三個再抽,否则会抽出一个谁都不合适的基类。

六、动手 Demo:入职、调岗、考勤、算工资

这一段把四个业务动作各跑一遍: 入职(含一次失败回滚)、调岗(含历史)、考勤批量导入、按公式算一个人月工资。 重点看第一节那次失败——部门 id 写错,整个入职回滚,员工表一行都没多。

# ① 入职:一个事务写两处
try:
    c.execute('BEGIN')
    c.execute('INSERT INTO tb_employee (eno, ename, sex, dept_id, post_id, hire)'
              ' VALUES (?,?,?,?,?,?)',
              ('E009', '许文涛', '男', 99, 4, '2026-10-15'))
    c.execute('COMMIT')
except Exception as ex:
    c.execute('ROLLBACK')
    print('回滚:', ex)

# ② 调岗:先写历史,再改主档
c.execute('BEGIN')
c.execute('''INSERT INTO tb_transfer
             (emp_id, old_dept, new_dept, old_post, new_post, tdate, reason)
             SELECT e.id, d.dname, '研发一组', p.pname, '主管', '2026-10-20', '项目需要'
               FROM tb_employee e
               JOIN tb_dept d ON d.id = e.dept_id
               JOIN tb_post p ON p.id = e.post_id
              WHERE e.eno = 'E007' ''')
c.execute("UPDATE tb_employee SET dept_id=6, post_id=3 WHERE eno='E007'")
c.execute('COMMIT')

# ③ 考勤批量写:一个事务 180 行
c.execute('BEGIN')
c.executemany('INSERT OR IGNORE INTO tb_attend'
              ' (emp_id, adate, status) VALUES (?,?,?)', recs)
c.execute('COMMIT')

# ④ 工资:日薪 = 基本 / 21.75,缺勤扣日薪,迟到扣 50
for r in c.execute(SALARY_SQL):
    total = r[2] + r[3] + r[6] * 40 - r[4] * (r[2] / 21.75) - r[5] * 50
    print(r[0], r[1], '%.2f' % total)

实跑输出:

--------------------------------------------------------------------
业务模块:入职、调岗、考勤、工资
--------------------------------------------------------------------


  ① 入职:一个事务写两处
  --------------------------------------------------------------------

  入职 E009(部门 99 不存在)→ 回滚:FOREIGN KEY constraint failed
  员工数:8 → 8

  入职 E009(部门 6)→ 员工表 + 账号表各一行,一起提交
  员工数:9,账号数:1
  为什么必须一个事务? 因为「有员工没账号」和「有账号没员工」都是坏数据。
  前一种人登不进来,后一种账号指向空气。
  注意:这里先插员工拿到的 id 是 9,
  但代码里不该写死 9——真实代码要用 SCOPE_IDENTITY() 或 last_insert_rowid() 取回来。

  ② 调岗:改一个字段 + 留一条历史
  --------------------------------------------------------------------

  1> SELECT e.eno, e.ename, d.dname, p.pname
     FROM tb_employee e JOIN tb_dept d ON d.id=e.dept_id JOIN tb_post p ON p.id=e.post_id
     WHERE e.eno='E007'
  eno   ename  dname     pname
  -----------------------------
  E007  吴鹏   研发二组  工程师
  (1 行受影响)


  1> SELECT old_dept, new_dept, old_post, new_post, tdate FROM tb_transfer
  old_dept  new_dept  old_post  new_post  tdate
  --------------------------------------------------
  研发二组  研发一组  工程师    主管      2026-10-20
  (1 行受影响)

  1> SELECT eno, ename, dept, post FROM v_emp WHERE eno='E007'
  eno   ename  dept      post
  ---------------------------
  E007  吴鹏   研发一组  主管
  (1 行受影响)
  「先写历史,再改主档」这个顺序不能反。
  反了的话,历史表里就只能记「改完之后是什么」,「改之前是什么」已经被覆盖掉了。
  这也是为什么历史表要存「名字」而不是「id」——部门改名以后,历史记录里还该是当时的名字,
  而不是跟着变。

  ③ 考勤:批量写,别一条一条来
  --------------------------------------------------------------------

  1> SELECT status, COUNT(*) AS 人次 FROM tb_attend GROUP BY status ORDER BY 人次 DESC
  status  人次
  ------------
  正常    173
  迟到    3
  请假    2
  缺勤    2
  (4 行受影响)
  9 个人 × 20 天 = 180 行,一个事务写进去不到 30 毫秒;逐条提交要几秒钟。
  这就是第 15 章那个「178 倍」的真实用处。
  INSERT OR IGNORE 在 SQL Server 里没有直接对应写法,
  常见做法是 IF NOT EXISTS ... INSERT 或者 MERGE。目的是同一人同一天不重复写。

  ④ 工资:算出来的,还是存下来的?
  --------------------------------------------------------------------

  日薪 = 基本工资 ÷ 21.75(月平均计薪天数)
  应发 = 基本 + 岗位津贴 + 加班小时 × 40
  扣款 = 缺勤天数 × 日薪 + 迟到次数 × 50(请假不扣)
  实发 = 应发 − 扣款


| 工号 | 姓名 | 基本 | 津贴 | 缺勤 | 迟到 | 加班h | 实发 |
|---|---|---:|---:|---:|---:|---:|---:|
| E001 | 陈国强 | 15000.00 | 8000.00 | 0 | 0 | 4 | 23160.00 |
| E002 | 李慧 | 11000.00 | 5000.00 | 0 | 0 | 0 | 16000.00 |
| E003 | 王志远 | 11000.00 | 5000.00 | 0 | 0 | 2 | 16080.00 |
| E004 | 赵敏 | 4500.00 | 800.00 | 0 | 1 | 0 | 5250.00 |
| E005 | 孙浩 | 8000.00 | 3000.00 | 1 | 0 | 3 | 10752.18 |
| E006 | 周雅静 | 6000.00 | 1500.00 | 1 | 2 | 6 | 7364.14 |
| E007 | 吴鹏 | 8000.00 | 3000.00 | 0 | 0 | 0 | 11000.00 |
| E008 | 郑晓丹 | 4500.00 | 800.00 | 0 | 0 | 8 | 5620.00 |
| E009 | 许文涛 | 6000.00 | 1500.00 | 0 | 0 | 5 | 7700.00 |

  工资不存「公式」,存「结果 + 当时的输入」。
  因为明年税率、津贴标准都会变——存公式的话,去年的工资单明年重算会变样。
  注意上表里没有「加班」这一列数据源,用的是一个固定数组——真实系统里加班是单独一张表,
  这里为了聚焦公式省掉了,正文里说清楚。

第一节那次回滚值得盯三秒: 员工数「8 → 8」,一行都没多。 如果没包事务,员工表会多一行 dept_id=99 的脏数据—— 而这条脏数据在做「按部门统计人数」时会凭空多出一个人。 第二节调岗的历史表里存的是「研发二组→研发一组」这样的名字,不是 id。 这就是「历史冻结当时的样子」——将来部门改名,这条记录不会跟着变。 第四节那张工资表里没有「加班」的数据源,用的是固定数组。 真实系统里加班是单独一张表(tb_overtime), 这里为了聚焦公式省掉了——省掉的部分在正文里说明了,不算含糊过去。

30 秒实验

  1. 把入职那个 dept_id = 99 改成 2,重跑,看员工表和账号表各多几行。 再故意让第三步(插账号)失败,确认员工那行也回滚了。
  2. 把工资公式里的 21.75 改成 21,重算一个人,看差多少钱。 这就是「口径不同」的实际后果。
  3. 想一想:如果两个人同时给同一个员工算工资,UNIQUE (emp_id, ymonth) 会怎么反应? 第二个人插入失败——所以「生成工资」按钮要能处理这个报错,不能直接崩。

常见误区

  1. 「入职先插员工,账号以后再补」。 补不上的那些人永远登不进来,而且没人知道有几个。 必须一个事务。
  2. 「调岗先改主档,历史回头再补」。 回头补的时候,旧值已经没了。 顺序不能反。
  3. 「工资存个公式,每月现算」。 标准一变,历史工资单全变样,没法对账。 存结果 + 存当时的输入。
自测三问(点开对答案) 1. 入职为什么必须一个事务? —— **因为「有员工没账号」和「有账号没员工」都是坏数据。** **前者登不进来,后者指向空气。** 2. 调岗为什么要「先写历史再改主档」? —— **反了的话旧值已被覆盖,历史表只能记到「改之后」。** **而且历史表要存名字不存 id,才能冻结当时的样子。** 3. 工资为什么存结果不存公式? —— **因为津贴标准、税率年年变。** **存公式的话,历史工资单重算会变样。** **存「结果 + 当时的输入」,任何一年都能还原。**

下一篇 19.8 收尾: 八条冒烟断言、桌面与 Web 的分界表——然后下一章换 Java,看看哪些东西一点没变。

相关笔记