人事系统的三条主线是「组织、人员、薪酬」——它们不是并列的,是一层压一层。 制度文件里那些「不许」「必须」「只能」,一句句都是数据库约束。 需求分析真正的产出不是功能清单,是两张表:业务动作对到哪张表、制度原话对到哪条约束。
第 18 章做的是酒店:一间房、一位客、一张单,一天几十次高频小事务。 这一章做企业人事:人少、变动慢,但每一行数据都要能翻三年账。 教材 19.1 分三小节:业务背景、功能需求、权限需求。 「权限需求」这一节看着最不起眼,其实是这类系统最容易翻车的地方—— 工资表能不能互相看,决定这个系统上线第一天会不会吵架。 沙箱没有 C#、没有 WinForms、没有 SQL Server,做法照旧: 数据库层用 SQLite 真跑,界面层用「菜单树 + 表格输出」复刻数据流, C# 原码照贴标不执行——复刻的是「数据怎么流」,不是「界面怎么画」。
一、三条主线,其实是三层
| 主线 | 管什么 | 变了会怎样 |
|---|---|---|
| 组织 | 部门、岗位、职级 | 牵动所有人的人事归属 |
| 人员 | 员工档案、调岗、离职 | 牵动考勤和工资 |
| 薪酬 | 考勤汇总、工资核算 | 只影响当期,不动历史 |
这三条不是并列关系,是「组织决定人员挂在哪,人员决定薪酬算给谁」。
所以建模顺序也必须是这个顺序——先有部门表,才能有员工表的 dept_id。
反过来先建工资表,就会发现它谁都不认识。
新手最容易犯的错就是「从最想做的功能开始建表」——
想先做工资计算,于是先建 tb_salary,然后发现连人是谁都填不进去。
二、业务动作对到表:需求分析的硬产出
| 业务动作 | 落到数据上 |
|---|---|
| 新人入职,填一张表 | tb_employee 插一行 |
| 部门调整、换岗 | 改 dept_id/post_id,另留一条历史 |
| 每天记考勤 | tb_attend 每天每人一行 |
| 月底算工资 | tb_salary 每人每月一行 |
| 发账号、收账号 | tb_user 增删改 |
这五条几乎覆盖了人事系统的全部。 剩下的(花名册、合同到期提醒、 离职面谈)都是这五条的组合或变体。 这张表最省力的用法:把「业务动作」一列先写满,再逐个问「它动到哪张表」。 能落到表上的是需求;落不下去的,是流程或者制度,不该写进系统。 举个落不下去的例子: 「部门之间要互相配合」。 这句话没有对应的表,它是一句管理口号——硬要做成「协作关系表」,最后一定没人填。
三、制度条款就是约束:一条一条对过去
| 制度原话 | 落到哪 |
|---|---|
| 每人一个工号,不许重 | eno UNIQUE |
| 性别只有男和女 | CHECK (sex IN ('男','女')) |
| 入职日期不能空 | hire NOT NULL |
| 岗位职级 1 到 10 级 | CHECK (level BETWEEN 1 AND 10) |
| 一人一天只能一条考勤 | UNIQUE (emp_id, adate) |
| 有在职员工的部门不能删 | 外键默认拦(RESTRICT) |
| 离职不删人,改状态 | state 字段 + 不写 DELETE |
这张表比功能清单有用。 功能清单告诉你「做什么」,这张表告诉你「什么错不能犯」。
而错是不能犯的,功能是可以后加的。
最后一条要单独说:「离职不删人」不是技术选择,是业务选择。
删了行,这个人去年的工资单、考勤记录就全对不上了。
所以 state 这个字段从第一天就得有——后加的话,历史数据已经删没了,补不回来。
四、权限需求:三种人三种视野
| 角色 | 能看 | 不能看 |
|---|---|---|
| 管理员 | 全部 | — |
| 人事 | 员工、考勤、工资 | 系统用户与角色 |
| 普通 | 只看自己的考勤和工资 | 别人的一切 |
「普通」这一行是最容易被砍掉的,也是最容易出事的。
如果所有人的工资表都能点开,这个系统上线第一天就会吵架。
这不是危言耸听——工资是最敏感的一张表,敏感程度超过身份证号。
权限要做成「角色」而不是「人」。
八个员工逐个配权限,加一个人就得再配一遍;配三个角色,加人只是选一个角色。
还有一个坑: 「普通」只能看自己的,那「自己」怎么判断?
答案在 tb_user.emp_id 上——账号必须能对上员工,对不上就只能靠姓名拼音硬猜,一定出错。
五、建出来看看:七张表各自几行
| 表 | 一行代表 | 这一版的初始行数 |
|---|---|---|
tb_dept |
一个部门 | 7(三层树) |
tb_post |
一个岗位 | 6(带职级与薪酬档) |
tb_employee |
一个人 | 8 |
tb_transfer |
一次调动 | 0 |
tb_attend |
某人某天 | 0 |
tb_salary |
某人某月 | 0 |
tb_user |
一个能登进来的人 | 0 |
比第 18 章多了两类表: 一类是历史表(调岗、考勤),一类是按月存结果的表(工资)。
这不是拍脑袋加的——是被「要能翻旧账」这个需求逼出来的。
「一行代表」这一列值得盯着看三遍。 tb_attend 一行是「某人某天」,
所以主键该是 (emp_id, adate) 联合唯一——
不是「一条考勤记录」,因为那样说不出哪两条算重复。
六、动手 Demo:从业务动作抠出七张表
下面这段代码只做一件事:把 19.1 那两张表落成一个真的库,然后数一数每张表几行。 重点看第三节那四处「试撞」——制度原话变成约束之后,真的能拦住错数据。
import sqlite3
DDL = u'''
CREATE TABLE tb_dept (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dname TEXT NOT NULL UNIQUE,
parent INTEGER REFERENCES tb_dept(id), -- 自引用,成树
mgr TEXT
);
CREATE TABLE tb_employee (
id INTEGER PRIMARY KEY AUTOINCREMENT,
eno TEXT NOT NULL UNIQUE,
ename TEXT NOT NULL,
sex TEXT NOT NULL CHECK (sex IN ('男', '女')),
dept_id INTEGER NOT NULL REFERENCES tb_dept(id),
post_id INTEGER NOT NULL REFERENCES tb_post(id),
hire TEXT NOT NULL,
state TEXT NOT NULL DEFAULT '在职'
CHECK (state IN ('在职', '离职'))
);
'''
c = sqlite3.connect(':memory:', isolation_level=None)
c.execute('PRAGMA foreign_keys = ON') # SQLite 默认是关的
c.executescript(DDL)
for t in ('tb_dept', 'tb_post', 'tb_employee',
'tb_transfer', 'tb_attend', 'tb_salary', 'tb_user'):
n = c.execute('SELECT COUNT(*) FROM ' + t).fetchone()[0]
print(t, n)
实跑输出:
--------------------------------------------------------------------
需求分析:制度文件里全是规则
--------------------------------------------------------------------
① 业务背景:人事部一天在干什么
--------------------------------------------------------------------
| 业务动作 | 落到数据上是什么 |
|---|---|
| 新人入职,填一张表 | tb_employee 插一行 |
| 部门调整,换岗 | 改 dept_id/post_id,另留一条历史 |
| 每天记考勤 | tb_attend 每天每人一行 |
| 月底算工资 | tb_salary 每人每月一行 |
| 发账号、收账号 | tb_user 增删改 |
这五条几乎覆盖了人事系统的全部。剩下的(花名册、合同到期提醒、离职面谈)都是这五条的组合或变体。
需求分析最省力的做法:把「业务动作」一列写满,再问每个动作动到哪张表。
能落到表上的是需求,落不下去的是流程或制度,不该写进系统。
② 制度条款其实都是约束
--------------------------------------------------------------------
| 制度原话 | 落到哪 |
|---|---|
| 每人一个工号,不许重 | eno UNIQUE |
| 性别只有男和女 | CHECK (sex IN (...)) |
| 入职日期不能空 | hire NOT NULL |
| 岗位职级 1 到 10 级 | CHECK (level BETWEEN 1 AND 10) |
| 一个人一天只能有一条考勤 | UNIQUE (emp_id, adate) |
| 有在职员工的部门不能删 | 外键默认拦(RESTRICT) |
| 离职不删人,改状态 | state 字段 + 不写 DELETE |
「制度原话 → 落到哪」这张表是需求分析的核心产出。
它比功能清单有用——功能清单告诉你做什么,这张表告诉你什么错不能犯。
最后一条要单独说: 「离职不删人」不是技术选择,是业务选择。
删了行,这个人去年的工资单、考勤记录就全对不上了。
所以 state 这个字段是必须的,而且从第一天就得有。
③ 权限需求:三种人三种视野
--------------------------------------------------------------------
| 角色 | 能看 | 不能看 |
|---|---|---|
| 管理员 | 全部 | — |
| 人事 | 员工、考勤、工资 | 系统用户与角色 |
| 普通 | 只看自己的考勤和工资 | 别人的一切 |
「普通」这一行是最容易被砍掉的,也是最容易出事的。
如果所有人的工资表都能点开,这个系统上线第一天就会吵架。
权限做成「角色」而不是「人」——八个员工逐个配权限,加一个人就得再配一遍;
配三个角色,加人只是选角色。
④ 建出来看看:七张表几行数据
--------------------------------------------------------------------
| 表 | 行数 | 说明 |
|---|---:|---|
| tb_dept | 7 | 部门树,含父子 |
| tb_post | 6 | 岗位与薪酬档 |
| tb_employee | 8 | 在职员工 |
| tb_transfer | 0 | 调岗历史,还没有 |
| tb_attend | 0 | 考勤,还没有 |
| tb_salary | 0 | 工资,还没到月底 |
| tb_user | 0 | 账号,等 19.5 建 |
比第 18 章多了两张「历史表」(调岗、考勤),还有一张「按月存结果」的表(工资)。
这不是拍脑袋加的——是被「要能翻旧账」这个需求逼出来的。
三张 0 行的表是故意的。 调岗、考勤、工资都还没发生——建库阶段就要能接受空表,
别为了好看硬塞假数据进业务表。
样例数据的作用是「验证设计」:八个员工、六个岗位、七层部门试一遍,
立刻能看出「一个人能不能兼两个岗」这类漏洞——光看表结构看不出来。
「一个人兼两个岗」这一版是做不到的(post_id 只有一个),这是刻意的简化。
真要做,得加一张 tb_emp_post 关联表——但那会让工资计算复杂一倍,先把主干跑通。
30 秒实验
- 把
sex的CHECK临时去掉,插一条'未知',看能不能进库。 再把它加回来——体会一下「约束是改一次就永久生效」这件事。 - 试插一个
dept_id = 99的员工。如果没报错,说明PRAGMA foreign_keys没打开。 这是 SQLite 最常见的坑,SQL Server 里不存在。 - 想一想:如果业务加一句「一个人可以兼多个岗」,要改几张表、几个模块? 一张关联表 + 工资计算模块——这就是「需求变更成本」,需求阶段就该估出来。
常见误区
- 「需求分析就是列功能清单」。 清单只能告诉你要做什么,不能告诉你什么错不能犯。 真正值钱的是「制度原话 → 约束」那张表。
- 「先建最想做的功能需要的表」。 建表要按依赖顺序:组织 → 人员 → 薪酬。 倒着建,建到一半就会发现外键指不到人。
- 「权限是界面上隐藏几个按钮」。 桌面程序里界面拦不住任何人——用户拿 SSMS 直连数据库,菜单一行都不管用。 真正的权限在数据库账号的 GRANT 上。
自测三问(点开对答案)
1. 人事系统的三条主线是哪三条,为什么有先后? —— **组织、人员、薪酬。** **组织决定人挂在哪,人员决定薪酬算给谁**—— **建表顺序必须照这个来,否则外键指不到。** 2. 「离职不删人」为什么要从第一天就做成字段? —— **因为删了行,历史工资单和考勤记录就全对不上了。** **`state` 字段后加时,历史数据已经没了,补不回来。** 3. 权限为什么要做成角色而不是人? —— **配一次管一批,加人只是选角色。** **逐个配的话,人员一流动就得重配,迟早漏。**下一篇 19.2,把上面那五个业务动作画成一棵功能树: 画完你会发现,「组织管理」必须单独当一级节点,不能塞进「员工管理」里。
- ☐ (本篇完,下一篇 19.2 系统设计)