入职必须一个事务写两处:员工表和账号表,要么都成,要么都不成。 调岗要「先写历史,再改主档」——反了的话,「改之前是什么」就被覆盖掉了。 工资不存公式,存「结果 + 当时的输入」——因为明年的标准会变。
这一节是本章唯一「功能最多」的一节, 教材 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 秒实验
- 把入职那个
dept_id = 99改成2,重跑,看员工表和账号表各多几行。 再故意让第三步(插账号)失败,确认员工那行也回滚了。 - 把工资公式里的 21.75 改成 21,重算一个人,看差多少钱。 这就是「口径不同」的实际后果。
- 想一想:如果两个人同时给同一个员工算工资,
UNIQUE (emp_id, ymonth)会怎么反应? 第二个人插入失败——所以「生成工资」按钮要能处理这个报错,不能直接崩。
常见误区
- 「入职先插员工,账号以后再补」。 补不上的那些人永远登不进来,而且没人知道有几个。 必须一个事务。
- 「调岗先改主档,历史回头再补」。 回头补的时候,旧值已经没了。 顺序不能反。
- 「工资存个公式,每月现算」。 标准一变,历史工资单全变样,没法对账。 存结果 + 存当时的输入。
自测三问(点开对答案)
1. 入职为什么必须一个事务? —— **因为「有员工没账号」和「有账号没员工」都是坏数据。** **前者登不进来,后者指向空气。** 2. 调岗为什么要「先写历史再改主档」? —— **反了的话旧值已被覆盖,历史表只能记到「改之后」。** **而且历史表要存名字不存 id,才能冻结当时的样子。** 3. 工资为什么存结果不存公式? —— **因为津贴标准、税率年年变。** **存公式的话,历史工资单重算会变样。** **存「结果 + 当时的输入」,任何一年都能还原。**下一篇 19.8 收尾: 八条冒烟断言、桌面与 Web 的分界表——然后下一章换 Java,看看哪些东西一点没变。
- ☐ (本篇完,下一篇 19.8 本章总结)