简介:围绕员工管理系统展开的功能点估算(FPA)案例文档,面向正在学习软件规模度量、准备软件工程课程实践或项目管理工作的读者,用完整估算过程演示如何把需求拆解为可量化的功能点。内容以员工信息、部门信息与工资表三部分数据为主线,先识别ILF与EIF(合计19个功能点),再逐项统计EI、EO、EQ——添加、修改、删除员工均按高复杂度计,查询员工与统计年薪分别归入EQ与EO,并说明DET与FTR的取舍原则,如标签控件内容不计入DET、报表标题与页码不作为查询字段。随后给出通用系统特性评分表,由特性得分推出调整因子0.84,最终得到48.72个功能点,并附复杂度矩阵与功能点对照表,便于对照练习。整份资料为单个docx文档,压缩包约102KB,已有232人学习。
1. 功能点估算案例复盘:一张用例图怎么算出 48.72 个 FP
需求评审开到一半,项目经理把报价单推过来:“这个员工管理系统,甲方要按功能点计价,你给个数。”屏幕上只有一张用例图和几张字段表,员工基本信息、部门信息,工资数据还得从财务系统读。没有一行代码,却要先算出一个带两位小数的数字。这就是功能点估算最典型的落地场景:把用户能看见的功能件拆成 ILF、EIF、EI、EO、EQ 五类,逐个数 DET、RET、FTR,再乘一个调整因子。后面我会用 C# 把这套表格做成能重算、能对比基线的控制台工具,避免每次报价都从头数一遍。
这个案例的结果是 58 个未调整功能点,调整因子 0.84,最终 48.72 个 FP。数字本身不复杂,复杂的是每一步的口径判断——同一个“添加员工”,有人算出 16 个 DET,有人算出 18 个,差出来的两个就足以让报价差几千块。它适合两类人:要做工作量报价、又不想拍脑袋的 C# 开发;以及需要核对乙方估算表、想知道水分挤在哪一行的项目负责人。
2. 五类功能件的边界:先分清谁维护、谁改变、谁计算
功能点估算翻车最多的不是算术,是分类。五类功能件里,ILF 和 EIF 是“数据存在哪儿”,EI、EO、EQ 是“人怎么动这份数据”。分类定错,后面的 DET、FTR 数得再准也是白搭。这一章先把员工管理系统里的每个功能件钉到具体类别上,再用 C# 建一个能直接跑的清单模型。
2.1 ILF 与 EIF:判据是维护权,不是数据放在哪张表
ILF(内部逻辑文件)和 EIF(外部接口文件)的唯一区分标准,是这个数据的增删改由谁负责。本系统能维护的,就是 ILF;只能读、由别的系统维护的,就是 EIF。员工管理系统里,员工信息和部门信息由本系统录入和维护,是两个 ILF;工资表的金额和单位由财务系统产生,本系统只引用,所以是 EIF。这个判断不依赖数据库是不是同一个库、是不是同一个连接串,只依赖业务上的维护边界。
真正容易出错的是“一个 ILF 到底包含几张表”。案例里员工信息这一项,物理上看至少有四块:员工基本信息、教育情况、工作经历、家属信息。如果按数据库表数,很容易数成 4 个 ILF,功能点立刻翻几倍。正确口径是看用户视角:这四块数据围绕同一个业务对象(员工),在同一个维护界面里一起录入、一起保存,逻辑上就是一个 ILF,四块数据对应 4 个 RET(记录类型)。RET 数的是这个逻辑文件内部的子分组个数,不是表个数,也不是关联查询个数。
提示:判断主从表要不要合并,问一句“用户是不是在同一个表单里一次性提交”。是,就合并成一个 ILF;如果子表有独立的维护入口、独立的新增删除权限,才考虑拆开。
部门信息只有一个 RET,两个 DET(部门 ID、部门名称),按矩阵落在低复杂度,权重 7。员工信息有 4 个 RET、18 个 DET,按矩阵同样落在低复杂度,权重还是 7。这里就出现第一个反直觉的地方:RET 从 1 涨到 4,复杂度并没有升级,因为 DET 还在 1~19 这一档。很多人看到“四张子表”就下意识往中等复杂度上调,属于凭感觉抬价。工资表作为 EIF,2 个 RET、4 个 DET,低复杂度权重 5。三项加起来 7+7+5=19,这就是案例里 ILF/EIF 合计 19 的来源。
2.2 EI、EO、EQ:按用户意图切,不按接口方法切
事务类功能件(EI、EO、EQ)的划分,最好别对着 Controller 里的方法数。一个“保存员工”的方法内部可能写了三张表的 insert,对用户来说仍然是点了一次保存,算一个 EI。反过来,一个查询页面如果带导出、带统计汇总,可能同时承载 EQ 和 EO 两种意图,需要拆开计。
案例里的划分是清楚的。添加、修改、删除员工信息,改变系统内部数据状态,都是 EI;添加、修改、删除部门信息,同样是 EI。查询员工信息、查询部门信息,只是把已存的数据原样取出来展示,不带任何计算,是 EQ。统计员工年薪这一项,要用工资数据按年份做汇总计算,输出的是派生数据,所以是 EO。EO 和 EQ 的分界线就一条:输出结果里有没有经过公式、汇总或计算。有,就是 EO;纯粹的条件过滤加字段展示,就是 EQ。
有一个常被忽略的细节:删除操作用户只输入一个员工 ID,但 FTR 仍然引用了员工、部门、工资表三个文件(删除时要校验部门归属、要联动清理工资引用),所以 FTR=3、DET=1,落在中等复杂度,权重 4。如果只按“用户界面上就一个 ID 输入框”去判断,很容易把它当成低复杂度算 3 分,一行就差 1 个 FP。
2.3 用 C# 把功能件清单建模成可枚举的数据
手写 Excel 估算表最大的问题是改一处要重算一片。用 C# 把每个功能件抽成一条记录,DET、RET、FTR 作为字段存进去,后面换矩阵、换权重、做版本对比都只是换个循环。下面这份清单就是案例里全部 13 个功能件的代码化表达。
public enum FpKind { ILF, EIF, EI, EO, EQ } // Ret 只对 ILF/EIF 有意义,Ftr 只对 EI/EO/EQ 有意义,用不到的填 0 public sealed record FpItem(string Name, FpKind Kind, int Det, int Ret, int Ftr); public static class FpCatalog { public static List<FpItem> EmployeeSystem() => new() { // 数据功能件 new("员工信息", FpKind.ILF, Det: 18, Ret: 4, Ftr: 0), new("部门信息", FpKind.ILF, Det: 2, Ret: 1, Ftr: 0), new("工资表", FpKind.EIF, Det: 4, Ret: 2, Ftr: 0), // 事务功能件:EI new("添加员工信息", FpKind.EI, Det: 18, Ret: 0, Ftr: 3), new("修改员工信息", FpKind.EI, Det: 18, Ret: 0, Ftr: 3), new("删除员工信息", FpKind.EI, Det: 1, Ret: 0, Ftr: 3), new("添加部门信息", FpKind.EI, Det: 1, Ret: 0, Ftr: 1), new("修改部门信息", FpKind.EI, Det: 1, Ret: 0, Ftr: 1), new("删除部门信息", FpKind.EI, Det: 1, Ret: 0, Ftr: 1), // 事务功能件:EQ new("查询员工信息", FpKind.EQ, Det: 20, Ret: 0, Ftr: 3), new("查询部门信息", FpKind.EQ, Det: 2, Ret: 0, Ftr: 1), // 事务功能件:EO new("统计员工年薪", FpKind.EO, Det: 5, Ret: 0, Ftr: 2), }; }这段代码的关键不在语法,在字段命名和填值规则。Ret对 EI/EO/EQ 一律填 0,Ftr对 ILF/EIF 一律填 0,是为了让非法组合在数据结构层面就写不出来;读取时按Kind分发到不同的计算分支,避免出现“给 EI 算 RET”这种口径混乱。Det填的是最终口径的数字,不是原始字段个数,比如添加员工填 18 而不是 20,原因在第 3 章展开。把清单写成方法而不是静态字段,是为了后面做基线对比时可以传参生成不同版本。
有了这份清单,分类是否合理可以直接用代码做约束校验。比如加一条规则:EIF 的Ret必须大于 0,EI 的Ftr必须大于 0 且Ret必须为 0,任何一条不满足就抛异常。这种看似多余的断言,在多人协作填表时能省掉大量返工。
public static void Validate(IEnumerable<FpItem> items) { foreach (var it in items) { bool isData = it.Kind is FpKind.ILF or FpKind.EIF; if (isData && it.Ret <= 0) throw new ArgumentException($"{it.Name}: 数据功能件必须填 RET"); if (isData && it.Ftr != 0) throw new ArgumentException($"{it.Name}: 数据功能件不应填 FTR"); if (!isData && (it.Ret != 0 || it.Ftr <= 0)) throw new ArgumentException($"{it.Name}: 事务功能件必须填 FTR 且 RET 为 0"); } }Validate里的三个判断对应三种最常见的填表错误:把 EIF 当成事务件只填 FTR、给 ILF 顺手填了个 FTR、EI 忘了填引用了几个文件。跑一遍校验再算分值,比事后拿计算器核对省事得多。到这一步,功能件清单已经从一张静态表格变成了有约束的数据结构,后面接复杂度矩阵和调整因子都是在这个结构上加方法。
3. DET、RET、FTR 的实操口径:把 19、25、9、5 逐个对回去
上一章把功能件分了类,这一章解决“数字怎么数”。功能点估算里最耗时间的不是算乘法,是数 DET。同一个添加员工界面,数出 16 和 18 都有人能自圆其说,差别在于有没有把标签控件、重复字段、自动带出的值算进去。这一章把案例里的每个关键数字拆开对一遍,再落到 C# 的复杂度矩阵实现上。
3.1 DET 的加减法:标签、主外键、自动计算字段
DET 的定义是“用户可识别的、非重复的数据项”。三个限定词各有各的坑。“用户可识别”意味着界面上那些纯说明性的静态文字不算,比如“员工 ID”这种标签控件,它展示的是系统生成的编号,用户并不输入,所以不计入添加员工的 DET。“非重复”意味着同一个数据项在界面上出现两次只算一个,部门信息在员工表单里既有部门 ID 又有部门名称,这两个在部门 ILF 里已经算过,在员工这一侧属于引用,不重复计入。“用户可识别”还意味着数据库里存了但用户看不见的审计字段,比如创建时间、最后修改人,如果没有在界面上呈现,就不算 DET。
案例里添加员工的 DET 是 18,来源可以拆成两段。员工信息本体贡献 16 个:员工名称、性别、生日、婚否、所属部门、受教育时间、学校名称、所学专业、工作时间、工作单位、工作部门、工作职务、家属姓名、关系、家属年龄、家属工作单位。员工 ID 是标签、部门相关字段与部门 ILF 重复,这两处被扣掉。剩下 2 个来自工资表:金额和单位。工资表里的员工 ID 和员工姓名因为是引用已有员工的标识,不重复计,所以只加 2。16+2=18,这个数就定住了。
删除员工只算 1 个 DET,因为用户能提供的只有一个员工 ID,其余字段都是系统内部拿着这个 ID 去定位和校验的,不构成用户输入。这一条经常被质疑“删除明明动了三张表”,但 DET 数的是输入项,不是受影响的数据量,受影响的范围体现在 FTR 上。
注意:EI 的 DET 只统计用户在界面上直接输入的信息。通过页面自动计算、下拉带出、复制填充得到的数据一律不计。这条如果放松,几乎每个表单的 DET 都会虚高 30% 以上。
EO 和 EQ 的 DET 口径又不一样。查询员工信息算 20 个,比添加还多,因为查询结果要把员工、部门、工资三块展示字段都铺出来,用户能看到的每一个输出字段都算。但报表的标题、页码、打印时间、分页控件这类框架性内容不算 DET,它们不承载业务信息。统计员工年薪只算 5 个:员工 ID、员工名称、年份、年薪、单位,因为它的输出是一张窄表,字段本来就少。
3.2 RET 与 FTR:主从表合并,引用按文件计
RET 只在 ILF/EIF 里出现,含义是这个逻辑文件内部的记录类型子集个数。员工信息之所以是 4 个 RET,是因为它内部可以分成员工基本信息、教育情况、工作经历、家属信息四组,每组有自己的字段集合,但都挂在同一个员工主键下,由同一个维护入口管理。部门信息只有一组字段,RET=1。工资表由财务系统提供,内部按员工和发放批次分成两组,RET=2。
这里有个判断顺序问题:先确定逻辑文件的边界,再数 RET。如果先数表再合并,很容易把“员工基本信息表”和“员工教育表”当成两个独立文件,各自算一个 RET,最后合起来还是 4,看起来没差,但 ILF 个数会从 1 变成 4,总分直接跑偏。所以 RET 的计数必须建立在 ILF 边界已经确定的前提下。
FTR 是事务功能件引用的 ILF/EIF 个数,注意是“文件个数”,不是“表个数”,也不是“SQL 里 join 了几张表”。添加员工引用员工、部门、工资表三个文件,FTR=3;删除员工同样要碰这三个文件,FTR 也是 3,虽然它只读不写工资表。查询员工信息要展示三块数据,FTR=3;查询部门信息只碰部门一个文件,FTR=1;统计员工年薪要读员工和工资,FTR=2。
一个高频误算:把一次三表 join 当成 FTR=3,但如果这三张表其实都属于员工信息这一个 ILF 的三个 RET,那 FTR 应该是 1。判断依据回到上一章:这三个 RET 是不是同一个逻辑文件。是,就只算一个 FTR。案例里添加员工之所以是 3,是因为员工、部门、工资表分别属于三个不同的逻辑文件,而不是因为写了三张表的语句。
3.3 C# 实现复杂度矩阵:五张表压成两个 switch
复杂度矩阵是功能点估算里最像“查表”的部分,也是最适合代码化的部分。ILF/EIF 看 DET 和 RET 两个维度,EI 看 DET 和 FTR,EO/EQ 看 DET 和 FTR 但分档不同。三套矩阵的边界值一定要照着标准抄,不要凭记忆改。
| DET 区间 | RET=1 | RET=2~5 | RET≥6 |
|---|---|---|---|
| 1~19 | 低 | 低 | 中 |
| 20~50 | 低 | 中 | 高 |
| ≥51 | 中 | 高 | 高 |
| DET 区间 | FTR=0~1 | FTR=2 | FTR>2 |
|---|---|---|---|
| 1~4 | 低 | 低 | 中 |
| 5~15 | 低 | 中 | 高 |
| ≥16 | 中 | 高 | 高 |
EO 和 EQ 共用一套矩阵,只是权重不同。DET 分档是 1~5、6~19、≥20,FTR 分档是 0~1、2~3、≥4。
| DET 区间 | FTR=0~1 | FTR=2~3 | FTR≥4 |
|---|---|---|---|
| 1~5 | 低 | 低 | 中 |
| 6~19 | 低 | 中 | 高 |
| ≥20 | 中 | 高 | 高 |
把这三张表写成 C# 的模式匹配,代码比表格还短,而且边界值不会抄错。
public enum Complexity { Low, Medium, High } public static class Matrix { // ILF / EIF:DET × RET public static Complexity Data(int det, int ret) => (det, ret) switch { (<= 19, 1) => Complexity.Low, (<= 19, <=5) => Complexity.Low, (<= 19, _) => Complexity.Medium, (<= 50, 1) => Complexity.Low, (<= 50, <=5) => Complexity.Medium, (<= 50, _) => Complexity.High, (_, 1) => Complexity.Medium, (_, <=5) => Complexity.High, _ => Complexity.High }; // EI:DET × FTR public static Complexity Ei(int det, int ftr) => (det, ftr) switch { (<= 4, <=1) => Complexity.Low, (<= 4, 2) => Complexity.Low, (<= 4, _) => Complexity.Medium, (<= 15, <=1) => Complexity.Low, (<= 15, 2) => Complexity.Medium, (<= 15, _) => Complexity.High, (_, <=1) => Complexity.Medium, (_, 2) => Complexity.High, _ => Complexity.High }; // EO / EQ:DET × FTR,DET 分档与 EI 不同 public static Complexity EoEq(int det, int ftr) => (det, ftr) switch { (<= 5, <=1) => Complexity.Low, (<= 5, <=3) => Complexity.Low, (<= 5, _) => Complexity.Medium, (<= 19, <=1) => Complexity.Low, (<= 19, <=3) => Complexity.Medium, (<= 19, _) => Complexity.High, (_, <=1) => Complexity.Medium, (_, <=3) => Complexity.High, _ => Complexity.High }; }模式匹配的顺序有讲究:更窄的条件必须写在前面。(<= 19, 1)如果放到(<= 19, <=5)后面就永远匹配不到,而 C# 编译器不会给这种逻辑错误报警,只会静默返回错误结果。所以写完矩阵必须用已知样本回归,本文后面会给出断言。
权重查表同样代码化,五类功能件在三种复杂度下的分值固定,写成二维结构最直白。
| 类型 | 低 | 中 | 高 |
|---|---|---|---|
| EI | 3 | 4 | 6 |
| EO | 4 | 5 | 7 |
| EQ | 3 | 4 | 6 |
| ILF | 7 | 10 | 15 |
| EIF | 5 | 7 | 10 |
public static int Weight(FpKind kind, Complexity c) => (kind, c) switch { (FpKind.EI, Complexity.Low) => 3, (FpKind.EI, Complexity.Medium) => 4, (FpKind.EI, Complexity.High) => 6, (FpKind.EO, Complexity.Low) => 4, (FpKind.EO, Complexity.Medium) => 5, (FpKind.EO, Complexity.High) => 7, (FpKind.EQ, Complexity.Low) => 3, (FpKind.EQ, Complexity.Medium) => 4, (FpKind.EQ, Complexity.High) => 6, (FpKind.ILF, Complexity.Low) => 7, (FpKind.ILF, Complexity.Medium) => 10, (FpKind.ILF, Complexity.High) => 15, (FpKind.EIF, Complexity.Low) => 5, (FpKind.EIF, Complexity.Medium) => 7, (FpKind.EIF, Complexity.High) => 10, _ => throw new ArgumentOutOfRangeException(nameof(kind)) }; // 单条功能件算分:数据件走 Data 矩阵,事务件按 EI 或 EO/EQ 分流 public static int Unadjusted(FpItem it) => it.Kind switch { FpKind.ILF or FpKind.EIF => Weight(it.Kind, Matrix.Data(it.Det, it.Ret)), FpKind.EI => Weight(it.Kind, Matrix.Ei(it.Det, it.Ftr)), _ => Weight(it.Kind, Matrix.EoEq(it.Det, it.Ftr)) };Unadjusted是整个工具的核心,它把“分类 → 查矩阵 → 查权重”三步合成一行。拿案例数据跑一遍,按类型分组求和应该得到 ILF/EIF=19、EI=25、EQ=9、EO=5,总计 58。这里可以顺手校验几个边界:添加员工 DET=18、FTR=3 落在 EI 的高档,取 6 分;查询部门 DET=2、FTR=1 落在 EO/EQ 的低档,取 3 分;统计年薪 DET=5、FTR=2 落在低档,取 4 分。如果哪一项对不上,先回去查是不是矩阵分档抄错了一位。
4. 通用系统特性打分:19 分是怎么变成 0.84 的
到这一步,58 个未调整功能点已经是死数了。但真正报给甲方的是 48.72,中间差着一个调整因子。这 14 项通用系统特性(GSC)看起来像填空题,实际是整套估算里主观性最强的部分,也是甲乙双方最容易扯皮的地方。这一章把打分口径、边界和代码实现讲清楚。
4.1 十四项特性与 0~5 分的判断口径
每项特性按影响程度打 0~5 分:0 表示无影响,1 表示轻微,2 表示较小,3 表示平均,4 表示显著,5 表示强烈。案例里的打分是数据通讯 3、分布式数据处理 2、性能 0、高强度配置 0、交易速度 0、在线数据输入 5、最终用户效率 2、在线更新 3、负责的处理 0、可复用性 3、易安装性 0、易操作性 0、多场地 0、支持变更 1,合计 19 分。
几个分值值得单独说。在线数据输入给 5,是因为员工信息的录入横跨 4 个 RET,还要联动校验部门归属、引用工资数据,录入路径长且校验多,这不是“随便给个高分”,而是这一项确实构成了系统复杂度的大头。数据通讯给 3,是因为工资数据要从财务系统取,涉及跨系统交互但接口数量有限。可复用性给 3,是因为部门维护这类模块在同类系统里能复用,但员工信息的字段结构绑定业务,复用面有限。性能、交易速度、多场地全给 0,是因为这是一个内部管理类系统,并发和部署规模都不构成压力,如实打 0 比“每项都给 3”可信得多。
提示:打分时最容易出现的偏差是全部落在 2~3 分。一项全是中间值的估算表,调整因子会稳定在 1.0 附近,等于白做。先把明确无影响的项打 0,再给有实质影响的项打 4~5,剩下的才是平均值。
4.2 调整因子的边界:为什么它只能在 0.65 到 1.35 之间动
调整因子(VAF)的公式是 0.65 加上 0.01 乘以总分。总分 0 时因子 0.65,总分 70(14 项全 5 分)时因子 1.35。这个区间的含义是:整套通用特性的影响幅度,最多把估算拉高 35% 或压低 35%,不可能出现翻倍。所以如果有人拿着一张调整因子 1.3 的表告诉你“系统特别复杂所以要多收 30%”,你要看的不是因子本身,而是那 14 项里哪几项打了 5 分、理由站不站得住。
案例总分 19,VAF = 0.65 + 0.19 = 0.84,意思是这个系统的通用特性整体低于平均水平,最终功能点从 58 下调到 48.72。下调的原因主要来自性能、配置、交易速度、易用性这些维度确实没有额外要求。这个结果对乙方不利,但它是按口径算出来的,比事后拍一个“系统复杂度系数 1.2”要经得起追问。
需要说明的是,48.72 这个两位小数是乘法的产物,不代表估算精度到了百分位。功能点估算本身的误差带通常在 ±10% 以上,报 48.72 更多是为了让公式可追溯,方便对方按同一个因子重算。真拿去报价,通常会取整成 49 或者按区间报。
4.3 用 decimal 算调整因子,别用 double
调整因子这一步涉及小数乘法,用double会出现经典的浮点误差:0.65 + 0.01 × 19 在双精度下不一定精确等于 0.84,再乘 58 可能打出 48.719999999999999 这样的结果。金额和人力估算都不该带这种尾巴,直接用decimal。
public sealed record Gsc(string Name, int Score); public static decimal Vaf(IReadOnlyList<Gsc> gsc) { if (gsc.Count != 14) throw new ArgumentException($"GSC 必须 14 项,当前 {gsc.Count} 项"); if (gsc.Any(x => x.Score is < 0 or > 5)) throw new ArgumentException("GSC 单项分值必须在 0~5 之间"); return 0.65m + 0.01m * gsc.Sum(x => x.Score); } // 案例数据 var gsc = new List<Gsc> { new("数据通讯", 3), new("分布式数据处理", 2), new("性能", 0), new("高强度配置", 0), new("交易速度", 0), new("在线数据输入", 5), new("最终用户效率", 2), new("在线更新", 3), new("负责的处理", 0), new("可复用性", 3), new("易安装性", 0), new("易操作性", 0), new("多场地", 0), new("支持变更", 1) }; decimal vaf = Vaf(gsc); // 0.84 decimal afp = 58m * vaf; // 48.72 Console.WriteLine($"{vaf:F2} / {afp:F2}");Vaf里放了两个前置校验:项数必须恰好 14,单项必须在 0~5。这两个校验看着琐碎,但能挡住最常见的两类错误——复制上一份表时多带了一项,或者手滑打成 15 分。decimal的m后缀不能漏,否则字面量会被推导成double,编译期就可能报类型不匹配。计算顺序上先算 VAF 再乘 UFP,两步分开存变量,方便在报表里分别展示“调整前”和“调整后”两个数,甲方核对时也能一眼看到因子是怎么来的。
把 UFP 和 VAF 接起来,整个估算链路就闭合了:功能件清单 → 分类 → 复杂度矩阵 → 权重求和 → GSC 打分 → VAF → AFP。每一环都有代码兜底,改任何一个 DET 都能立刻看到最终数字的变化,这比在 Excel 里追公式引用要安心得多。
5. 五个真实口径分歧:功能点估算最常翻车的地方
功能点估算的争议很少出在公式上,几乎全部集中在“这一项到底算不算”。下面五条是实际核对估算表时反复出现的分歧,每条按现象、原因、解决写,覆盖数据功能件和事务功能件两个层面。
5.1 数据功能件的三个口径坑
坑一:把主从表拆成多个 ILF。现象是估算表里“员工基本信息”“员工教育”“员工工作经历”“员工家属”各占一行,四个 ILF,功能点直接多出 21 分。原因在于按数据库表数数,忽略了这些表共享同一个业务主键、同一个维护入口。解决办法是先问“用户是不是在同一个界面里一次性提交”,是就合并成一个 ILF,四个子分组记为 4 个 RET,最终按 4 RET、18 DET 落在低复杂度,权重 7,和拆开算差了整整三倍。
坑二:把外部系统数据当成本系统 ILF。现象是工资表被记成 ILF,权重按 7 算而不是 5,单这一项就差 2 分,还会连带把引用它的 EI 的 FTR 判断搞乱。原因是工资数据在本系统里有缓存表或者视图,看起来像本地数据。解决是回到维护权判断:金额和单位由财务系统产生,本系统只读不写,就是 EIF。哪怕本系统数据库里真有一张同步过来的工资表,只要业务上不负责维护,仍然算 EIF。
坑三:RET 按字段分组数,越数越多。现象是员工信息被数出 6 个甚至 8 个 RET,复杂度从低跳到中。原因是把“字段类别”当成了 RET,比如把“联系方式”单独拆一组。RET 的含义是逻辑上的记录类型子集,判断依据是这组数据是否有独立的生命周期和独立的主键关系。解决是只保留案例里那四组,其他的并入员工基本信息这一组。
5.2 事务功能件的三个口径坑
坑四:EI 的 DET 把自动带出的字段算进去。现象是添加员工的 DET 数到 20 甚至 22,比标准口径多两三个,EI 复杂度从高档的 6 分变成……这里不会变,因为 18 和 20 都在“≥16”这一档,但换一个规模小一点的表单,16 和 18 就会跨档,3 分和 6 分差一倍。原因是把部门名称、员工 ID 这类系统带出或生成的字段当成了用户输入。解决是在数 DET 前先标注每个字段的来源:用户键入、系统生成、引用其他文件,只有第一类计入 EI 的 DET。
坑五:FTR 按 SQL join 的表数算。现象是查询员工信息的 FTR 被数成 5 或 6,因为 SQL 里 join 了员工、教育、工作经历、家属、部门多张表,复杂度从高变成……同样是高档不变。但在中等规模的事务上,FTR 从 2 变 3 就会跨档。原因是混淆了物理表和逻辑文件。解决是先按第 2 章的口径把逻辑文件定下来,再数这个事务碰了几个逻辑文件。案例里查询员工信息碰员工、部门、工资三个逻辑文件,FTR=3,不是 5。
坑六:EO 和 EQ 混着算。现象是“统计员工年薪”被记成 EQ,权重从 4 降到 3,看起来只差 1 分,但口径错了会连带影响 DET 的计数方式,因为 EO 和 EQ 对派生字段的处理不同。原因是判断标准停留在“它是个查询按钮”。解决是看输出:结果里有没有经过公式、汇总、跨记录计算。年薪是 12 个月工资求和,属于计算输出,就是 EO。
5.3 因子与结论层面的两个坑
坑七:GSC 十四项全打 3 分。现象是调整因子稳定落在 1.07,估算表看起来“很中性”。原因是打分人不想承担判断责任,全填平均值最安全。解决是强制从 0 开始:先列出明确无影响的项打 0,再列出确实构成难点的项打 4~5,剩下才是 2~3。案例里性能、配置、交易速度全打 0,在线数据输入打 5,最终因子 0.84,反而比全 3 分的 1.07 更接近真实。
坑八:把 48.72 当成精确值直接乘单价。现象是报价单上出现“48.72 × 单价”算出带角分的结果,甲方质疑精度来源。原因是没区分“计算精度”和“估算精度”。解决是在对外文档里保留两位小数用于追溯,实际报价按区间取整,比如 45~52 个 FP,并注明这是按某版需求清单和某次 GSC 打分得出的基线,需求变更后需要重算。
6. 把估算做成 C# 基线工具:改一个字段就知道 AFP 变了多少
手工估算表最难受的场景不是第一次算,是需求改了一轮之后重算。加了两个字段、删了一个查询、部门维护挪到另一个系统,你得从头把 13 行重新过一遍,还不敢保证没漏。我后来的做法是把功能件清单序列化成 JSON,每次估算存一版基线,改完跑一次 diff,直接看哪些行的分值变了、AFP 差多少。
做法很小:FpItem加System.Text.Json的序列化,每版存成baseline-v1.json、baseline-v2.json,然后按Name做左连接对比。
using System.Text.Json; static Dictionary<string, FpItem> Load(string path) => JsonSerializer.Deserialize<List<FpItem>>(File.ReadAllText(path))! .ToDictionary(x => x.Name); var oldMap = Load("baseline-v1.json"); var newMap = Load("baseline-v2.json"); foreach (var name in oldMap.Keys.Union(newMap.Keys)) { oldMap.TryGetValue(name, out var o); newMap.TryGetValue(name, out var n); int oldFp = o is null ? 0 : FpCatalog.Unadjusted(o); int newFp = n is null ? 0 : FpCatalog.Unadjusted(n); if (oldFp != newFp) Console.WriteLine($"{name}: {oldFp} -> {newFp} ({(newFp - oldFp):+0;-0})"); }Union保证新增和删除的行都能被扫到,TryGetValue配合out var让缺失的一侧取 null,再用 0 兜底,这样新增项显示为0 -> 6,删除项显示为3 -> 0,一眼能看出是加需求还是砍需求。这个对比表拿去和甲方对变更,比口头说“这次改动大概多了 10 个功能点”有说服力得多。
再配三条断言把案例数字固定下来,改代码时不怕手滑改错矩阵。Assert用最简单的if + throw就行,不必引第三方库:
void Check(string label, int actual, int expected) { if (actual != expected) throw new Exception($"{label}: {actual} != {expected}"); } var items = FpCatalog.EmployeeSystem(); Check("ILF+EIF", items.Where(x => x.Kind is FpKind.ILF or FpKind.EIF).Sum(FpCatalog.Unadjusted), 19); Check("EI", items.Where(x => x.Kind == FpKind.EI).Sum(FpCatalog.Unadjusted), 25); Check("EQ", items.Where(x => x.Kind == FpKind.EQ).Sum(FpCatalog.Unadjusted), 9); Check("EO", items.Where(x => x.Kind == FpKind.EO).Sum(FpCatalog.Unadjusted), 5); Check("AFP", (int)(58m * 0.84m * 100), 4872); // 48.72 放大 100 倍避免精度比较这五条断言的价值在于它能挡住“重构把 switch 顺序写反”这类隐蔽错误。模式匹配写错顺序不会报编译错,只会让某个边界值悄悄变了档,算出来的 AFP 差几个点,人眼很难发现。有了断言,dotnet run一遍就知道矩阵还是不是原来那个。
最后一个习惯:每次估算都把 GSC 的 14 项打分和功能件清单一起存档,哪怕这次只改了一个字段。血泪经验是前一年做过一个类似的系统,当时只存了最终的 48.72,没存中间的分项,半年后甲方问“为什么这次同样的模块多了 6 个功能点”,翻遍邮件也找不回当时的 DET 口径,只能重新数一遍。从那以后我每次出估算表,都强制把 JSON 基线、GSC 打分表和版本号一起放进同一个目录,改一次存一版。希望帮到你。
本文还有配套的精品资源,点击获取