diff --git "a/docs/04-\351\234\200\346\261\202\347\254\246\345\220\210\345\272\246\346\270\205\345\215\225.md" "b/docs/04-\351\234\200\346\261\202\347\254\246\345\220\210\345\272\246\346\270\205\345\215\225.md" index 2b1af9a..14845ec 100644 --- "a/docs/04-\351\234\200\346\261\202\347\254\246\345\220\210\345\272\246\346\270\205\345\215\225.md" +++ "b/docs/04-\351\234\200\346\261\202\347\254\246\345\220\210\345\272\246\346\270\205\345\215\225.md" @@ -1,54 +1,63 @@ # KPI 考核管理系统 需求符合度清单 -> os-project-dev-issue 步骤 4.5:逐条对照需求 V1.3,标 ✅ 完整实现 / ⚠️ 有偏差 / ❌ 未实现; -> 非 ✅ 条目只有三个出口(退回细化 / 平台受限挂起 / 升级裁决),没有「先简化实现」。 -> 证据列指向代码与自测记录(`docs/05-自测记录.md`,用例编号 T-xx)。 +> os-project-dev-issue 步骤 4.5:逐条对照**《设计方案》V1.0**(`docs/00-设计方案.md`,客户方 +> 2026-09-02 确认第 10 章全部 17 项 —— 第 1~12 项同意产品默认,第 13 / 14 / 15 项选 A), +> 标 ✅ 完整实现 / ⚠️ 有偏差 / ❌ 未实现;非 ✅ 条目只有三个出口(退回细化 / 平台受限挂起 / +> 升级裁决),没有「先简化实现」。需求条目沿用需求 V1.3 的编号,口径以设计方案为准。 +> 章节引用一律指向设计方案(如「设计 5.2」「第 10 章第 10 项」「表 10」),不再引用疑点编号。 +> 证据列指向代码与自测记录(`docs/05-自测记录.md`,用例编号 T-xx;每个编号都能在该文件 §3 总表查到)。 -## 一、核心业务流程(需求 §3) +## 一、核心业务流程(需求 §3 / 设计第 4 章) | # | 需求 | 态 | 实现落点 / 证据 | 偏差与出口 | |---|---|---|---|---| -| 1 | 配置方案:新建版本 → 选指标 → 分权重与目标 → 配考核关系与参与范围 | ✅ | `kpi_plan` + 主从 `kpi_plan_step / kpi_plan_subject / kpi_plan_indicator / kpi_staff_assignment / kpi_personal_item`,内联网格录入;T1、T2 | — | +| 1 | 配置方案:新建版本 → 选指标 → 分权重与目标 → 配考核关系与参与范围 | ✅ | `kpi_plan` + 主从 `kpi_plan_step / kpi_plan_subject / kpi_plan_indicator / kpi_staff_assignment / kpi_personal_item`,内联网格录入(设计 5.2);T1、T2 | — | | 2 | 发布前完整性检查 | ✅ | `PlanPublishHook`:流程节点合法、主体非空、每主体有指标、权重合计 100%、目标值齐、争议关闭,逐条列出问题;T3 | — | -| 3 | 发布:冻结参与范围、流程与口径,生成指标下达与到人分工,发起流程 | ✅ | 发布后子配置冻结(`PlanChildFreezeHook`,T7);为每个主体生成填报单与明细并冻结目标 / 权重 / 计分方式副本(T5、T6);到人分工随方案配置,发布后冻结 | 「发起流程」= 生成填报单进入「填报中」,无站内通知(需求未要求通知) | -| 4 | 填报:按填报视图录入或批量导入 → 自动保存 → 即时算分 → 提交冻结 | ✅ | 填报单表单内嵌明细网格 + 明细列表内联编辑;`EntryLineScoreHook` 保存即算分(T8、T9、T11);提交后明细冻结(T16);导入走平台 CSV 向导、xlsx 导出 | ⚠️ xlsx 直接导入为平台导入 API 能力,未做专用按钮(pre-intake R-低-02,列二期) | -| 5 | 核对与审核:分公司并行核对 → 人力审核 → 领导审批;驳回填原因、退回上一环节 | ✅ | 提交按分公司生成并行核对任务(T15),争议阻断推进(T18),全部确认自动推进(T19);驳回原因必填(T20),退回上一节点并重置核对(T21);人力 → 领导 → 已通过(T22、T23) | — | -| 6 | 计分:按既定口径自动算每个指标得分 | ✅ | `src/lib/scoring.ts` 四种方式 + 单元测试 14 条;计算说明落库 | 口径细节(逆向指标、封顶、到人合成)按推断实现,待疑点 Q-01~Q-05 拍板后只改数据 / 一处代码 | -| 7 | 汇总:部门 / 分公司 / 到人 / 分管领导四维,可钻取到指标得分明细 | ✅ | `kpi_result` 由 `results-service` 在通过 / 归档 / 调整落地时生成(T24、T27);加减分审批落定后由 `BonusAfterDecideHook` 立即重算(T48、T49);结果对象带来源填报单与计算明细 JSON;看板 2、矩阵报表可钻取 | — | -| 8 | 归档:审批通过后生成不可变快照,数据锁定 | ✅ | `kpi_snapshot`(SHA-256 校验和)+ `SnapshotImmutableHook`(T28~T30);归档后明细、加减分锁定(T31、T32) | — | +| 3 | 发布:冻结参与范围、流程与口径,生成指标下达与到人分工,发起流程 | ✅ | 发布后子配置冻结(`PlanChildFreezeHook`,T7);为每个主体生成填报单与明细并冻结目标 / 权重 / 计分方式副本(T5、T6);到人分工随方案配置,发布后冻结 | 「发起流程」= 生成填报单进入「填报中」;站内通知与到期提醒在设计表 10 列为二期候选 | +| 4 | 填报:按填报视图录入或批量导入 → 自动保存 → 即时算分 → 提交冻结 | ✅ | 填报单表单内嵌明细网格 + 明细列表内联编辑;`EntryLineScoreHook` 保存即算分(T8、T9、T11);提交后明细冻结(T16);导入走平台 CSV 向导、xlsx 导出 | xlsx **直接导入按钮**按设计表 10 列为二期候选(现走平台通用导入),不计为本期偏差 | +| 5 | 核对与审核:分公司并行核对 → 人力审核 → 领导审批;驳回填原因、退回上一环节 | ✅ | 提交按分公司生成并行核对任务(T15),争议阻断推进(T18),全部确认自动推进(T19);驳回原因必填(T20),退回上一节点并重置核对(T21,设计表 7 第 8 项);人力 → 领导 → 已通过(T22、T23) | — | +| 6 | 计分:按既定口径自动算每个指标得分 | ✅ | `src/lib/scoring.ts` 四种方式 + 单元测试 14 条;计算说明落库;逆向指标完成率 = 目标 ÷ 实际(第 10 章第 4 项)、个人得分 = 板块折算 + 承接项相加不封顶(第 5 项)、计分方式清单四种(第 12 项),`src/lib/aggregate.ts` 顶部注释已改引设计第 8 章表 6;T8、T9、T11、T37 | — 口径已由第 10 章第 4 / 5 / 12 项确认,无偏差 | +| 7 | 汇总:部门 / 分公司 / 到人 / 分管领导四维,可钻取到指标得分明细 | ✅ | `kpi_result` 由 `results-service` 在通过 / 归档 / 调整落地时生成(T24、T27);加减分审批落定后由 `BonusAfterDecideHook` 立即重算(T48、T49、T60);计算明细 JSON 逐指标带得分率、得分与「已调整」标记(T63);看板 2、矩阵报表可钻取 | — | +| 8 | 归档:审批通过后生成不可变快照,数据锁定 | ✅ | `kpi_snapshot`(SHA-256 校验和)+ `SnapshotImmutableHook`(T28~T30);归档后明细、加减分锁定(T31、T32);归档不可改、下一周期修正(第 10 章第 11 项) | — | -## 二、范围功能(需求 §1.3) +## 二、范围功能(需求 §1.3 / 设计第 5 章) | # | 需求 | 态 | 实现落点 / 证据 | 偏差与出口 | |---|---|---|---|---| -| 9 | 指标管理(定义、来源、计分方式、权重、版本可追溯) | ✅ | `kpi_indicator` + `kpi_indicator_step`;字段历史跟踪;版本号 + 替代版本;状态机 | — | -| 10 | 考核方案多版本并存、发布后冻结、复制上一版 | ✅ | `kpi_plan` 状态机;`PlanCloneHook` 复制节点 / 主体 / 下达 / 分工(T35) | 「多版本并存」按疑点 Q-09 的 B 理解(同周期草稿与已发布并存) | -| 11 | 流程可配置、随方案版本冻结 | ✅ | `kpi_plan_step` 数据化节点;两节点流程验证(T36) | 配置粒度按疑点 Q-08 的 B(可增删节点、改审核岗位);不同部门走不同流程未支持(Q-08 C) | -| 12 | 加减分 | ✅ | `kpi_bonus` 主从填报单,审批后计入最终得分(T25) | 封顶 / 保底未做(疑点 Q-04 待拍板) | -| 13 | 数据调整(源数据 / 计算结果) | ✅ | `kpi_adjustment`:记录调整前值、审批、批准即落地并重算(T26、T27) | — | -| 14 | 审计查询 | ✅ | `kpi_review_record` 只增不改(T33、T34)+ 平台审计日志 / 记录历史页签 | — | -| 15 | 组织与权限 | ✅ | 复用平台组织单元 / 用户;岗位 6、权限集 6;共享规则不再是元数据,由方案发布按参与主体动态写入(`src/services/sharing-service.ts`,T39) | 见 §三 数据范围偏差 | -| 16 | 数据接入与归档 | ✅ | 填报 / 导入 / 系统计算三类来源在指标上声明;归档快照 | 「系统计算」来源的自动取数需对接客户数据源,零外部集成(蓝图 §4) | - -## 三、角色与安全(需求 §2) +| 9 | 指标管理(定义、来源、计分方式、权重、版本可追溯) | ✅ | `kpi_indicator` + `kpi_indicator_step`(设计 5.1);字段历史跟踪;版本号 + 替代版本;状态机 | — | +| 10 | 考核方案多版本并存、发布后冻结、复制上一版 | ✅ | `kpi_plan` 状态机;`PlanCloneHook` 复制节点 / 主体 / 下达 / 分工(T35);同周期唯一生效版本见 #26 | — 「草稿与已发布并存、同周期只有一个生效版本」= 第 10 章第 10 项确认口径,已按此实现 | +| 11 | 流程可配置、随方案版本冻结 | ✅ | `kpi_plan_step` 数据化节点;两节点流程验证(T36) | — 粒度 = 可增删节点、可改审核岗位(第 10 章第 9 项);「不同部门走不同流程」在设计表 10 列为二期候选 | +| 12 | 加减分 | ✅ | `kpi_bonus` 主从填报单,审批后计入最终得分(T25、T60);**提出 = 人力审核、审批 = 人力负责人**(第 10 章第 6 项,本工作项落地:`src/hooks/bonus.hook.ts` 的 `requiredBonusPosition`;真实岗位账号取证 T65~T68,反例 T59) | — 不封顶不保底 = 第 10 章第 6 项确认口径;两个人力岗位账号都已能亲自操作(T65~T67),部门填报人员的新建权限已同步收窄(T58) | +| 13 | 数据调整(源数据 / 计算结果) | ✅ | `kpi_adjustment`:记录调整前值、审批、批准即落地并重算(T26、T27);落地标记见 #25(设计 5.7) | — | +| 14 | 审计查询 | ✅ | `kpi_review_record` 只增不改(T33、T34)+ 平台审计日志 / 记录历史页签(设计 5.9) | — | +| 15 | 组织与权限 | ✅ | 复用平台组织单元 / 用户;岗位 6、权限集 6;共享规则不再是元数据,由方案发布按参与主体动态写入(`src/services/sharing-service.ts`,T39) | 见 §三 #17、#18 | +| 16 | 数据接入与归档 | ✅ | 填报 / 导入 / 系统计算三类来源在指标上声明;归档快照 | 「系统计算」来源的自动取数需对接客户数据源,零外部集成;Excel 自由公式与跨表引用不进系统 = 第 16 项确认的范围边界 | +| 25 | 结果调整改某指标得分并标记「已调整」(设计 5.7、第 10 章第 14 项 = A) | ✅ | `kpi_entry_line.is_adjusted` / `adjust_type_applied`(只读,由 `AdjustmentHook` 以系统上下文写入,源数据与结果调整都标);列入填报明细列表视图与填报单内嵌网格;结果计算明细 JSON 每条指标带 `is_adjusted`;T62、T63 | — 网格列为**显式声明**(显式声明会整体替换平台派生的列集):19 列 = 子对象除主从字段外的全部字段,顺序沿用字段定义顺序、两个新列排最后,计算说明 / 最近调整 / 指标三列用 `defaultHidden` 折叠而非删除,派生列一列未少(评审 🟡-1,见 `docs/05` 问题记录 #4) | +| 26 | 同一周期只有一个生效版本(设计 5.2、第 10 章第 10 项) | ✅ | `PlanPublishHook` 发布前校验同 `period_type + year + period_no` 是否已有「已发布」方案,已关闭 / 已归档不阻断;拒绝文案点名生效版本;候选只按状态取、租户判定在纯函数里做(组织为空的种子 / 导入方案同样算生效版本,T71 + 消融对照);T61、T71 + 单元测试 4 条 | — | + +## 三、角色与安全(需求 §2 / 设计第 9 章) | # | 需求 | 态 | 实现落点 / 证据 | 偏差与出口 | |---|---|---|---|---| -| 17 | 六类角色职责 | ✅ | 岗位 `kpi_admin / kpi_hr_reviewer / kpi_hr_head / kpi_dept_reporter / kpi_branch_checker / kpi_exec_leader`,流程节点按岗位把关(hook 经 `sys_user_position` 查询) | 组织 owner / admin 自动等同系统管理员 | -| 18 | 数据范围:全部 / 分管 / 本部门 / 仅本人 | ✅ | 全部 = org、仅本人 = own 原生;本部门 / 本分公司 / 分管范围 = private 基线 + 方案发布时按参与主体、分管领导、到人分工写入的**动态共享规则**(`src/services/sharing-service.ts`),组织变化零元数据改动;到人结果行级安全只挡他人的到人维度(T41、T44、T46、T51)。不再依赖企业版 `hierarchy-security` | 四点实现说明:① 规则**按方案**建(条件带方案 id、规则名按方案加前缀),发布时对账停用本方案下已不该存在的规则 —— 换掉分管领导、撤主体、撤到人分工都当场失效,历史方案的授权原样保留(T54~T56);② 方案**已关闭 / 已归档**后,填报单 / 核对任务 / 调整由 `edit` 降为 `read`(留读、去写),结果本就只读(T57);③ 本应用的业务记录全部由 hook 以系统上下文创建,而平台对 `isSystem` 写入跳过记录共享物化(官方补偿手段是重新求值规则),因此每个业务触发点都重新声明一次相关规则;④ 演示种子的组织单元 `organization_id` 为空,`src/data/align-demo-units.ts` 在启动时把它们补成与 Setup 新建单元一致 —— 这是 objectstack-ai/objectstack#14547 的**临时夹具**修补,平台修复后删除(T52) | -| 19 | 越权访问拒绝、经办人以登录账号为准 | ✅ | 权限集对象级 + 字段级;经办人 / 审批人字段只读由 hook 盖章 | — | +| 17 | 六类角色职责 | ✅ | 岗位 `kpi_admin / kpi_hr_reviewer / kpi_hr_head / kpi_dept_reporter / kpi_branch_checker / kpi_exec_leader`,流程节点按岗位把关(hook 经 `sys_user_position` 查询);加减分登记 / 审批的岗位分离已落地(T59、T66);**人力岗位的记录级写范围**由方案发布写入的岗位共享规则(收件方类型 = `position`)授予 —— 人力审核登记加减分(T65)、人力负责人审批(T67)、人力审核在本节点驳回与审核通过(T69)均由真实岗位账号跑通,方案关闭后同样降为只读(T70) | — 第一轮曾因人力岗位无记录级写权限记为偏差,已按调度员 2026-09-02 的裁定(选项 A)修复并重测,详见 `docs/05-自测记录.md` 问题记录 #1 | +| 18 | 数据范围:全部 / 分管 / 本部门 / 仅本人 | ✅ | 全部 = org、仅本人 = own 原生;本部门 / 本分公司 / 分管范围 = private 基线 + 方案发布时按参与主体、**人力岗位**、分管领导、到人分工写入的**动态共享规则**(`src/services/sharing-service.ts`),组织变化零元数据改动;到人结果行级安全只挡他人的到人维度(T41、T44、T46、T51)。不再依赖企业版 `hierarchy-security`(设计第 17 项) | 四点实现说明:① 规则**按方案**建(条件带方案 id、规则名按方案加前缀),收件方三类:组织单元(`unit_and_subordinates`)、岗位(`position`,人力审核 / 人力负责人对本方案填报单与数据调整可编辑)、个人(`user`),发布时对账停用本方案下已不该存在的规则 —— 换掉分管领导、撤主体、撤到人分工都当场失效,历史方案的授权原样保留(T54~T56);② 方案**已关闭 / 已归档**后,填报单 / 核对任务 / 调整由 `edit` 降为 `read`(留读、去写),结果本就只读(T57);③ 本应用的业务记录全部由 hook 以系统上下文创建,而平台对 `isSystem` 写入跳过记录共享物化(官方补偿手段是重新求值规则),因此每个业务触发点都重新声明一次相关规则;④ 演示种子的组织单元 `organization_id` 为空,`src/data/align-demo-units.ts` 在启动时把它们补成与 Setup 新建单元一致 —— 这是 objectstack-ai/objectstack#14547 的**临时夹具**修补,平台修复后删除(T52) | +| 19 | 越权访问拒绝、经办人以登录账号为准 | ✅ | 权限集对象级 + 字段级;经办人 / 审批人字段只读由 hook 盖章;越权反例 T42、T58、T59、T64、T66、T68 | — | | 20 | 账号安全(密码策略、停用即失效、失败限制、白名单、敏感操作留痕) | ✅ | 平台账号体系(`sys_user` 失败计数 / 锁定 / 封禁 / 强制改密);审批、驳回、归档留痕 | 访问来源白名单归部署层(反向代理 / 网关)配置 | +| 27 | 分公司核对只能确认 / 提出争议,不能改值(第 10 章第 13 项 = A) | ✅ | 核对人员权限集对填报明细只有读 / 导出,改值在权限层即被拒(T64);确认与争议走 `kpi_check_task`(T45、T17);改值由填报部门经数据调整流程完成并留痕 | — | ## 四、非功能(需求 §4) | # | 需求 | 态 | 实现落点 / 证据 | 偏差与出口 | |---|---|---|---|---| -| 21 | 得分口径唯一、可重复计算、历史不漂移 | ✅ | 计分只在 `scoring.ts`;明细冻结指标口径副本;快照不可变 | — | -| 22 | 遵循 Excel 习惯,即时出分,校验提示,逐层钻取 | ⚠️ | 内联网格、即时算分、三段式提示、报表钻取 | 自由公式 / 跨 sheet 引用不进系统(范围边界,R-高-02);需业务方演示确认 | -| 23 | 性能:大数据量表格可正常浏览编辑 | ⚠️ | 填报单按主体拆分(每表数十行),列表分页 | 未做压测(升级项:实施期用真实数据量验证) | -| 24 | 可扩展:新增计分口径 / 考核关系 / 视图通过配置实现 | ✅ | 计分方式数据化(阶梯 / 区间 / 公式);考核关系与流程节点是方案数据;视图为元数据 | 新计分「方式」需加代码分支(口径参数不需要) | +| 21 | 得分口径唯一、可重复计算、历史不漂移 | ✅ | 计分只在 `scoring.ts`、汇总只在 `aggregate.ts`(口径依据设计第 8 章表 6);明细冻结指标口径副本;快照不可变 | — | +| 22 | 遵循 Excel 习惯,即时出分,校验提示,逐层钻取 | ✅ | 内联网格、即时算分、三段式提示、报表钻取 | 自由公式 / 跨 sheet 引用不进系统 —— 第 16 项已确认为范围边界,不再计为偏差 | +| 23 | 性能:大数据量表格可正常浏览编辑 | ⚠️ | 填报单按主体拆分(每表数十行),列表分页 | **出口:升级** —— 实施期用真实数据量验证,见设计表 10「试运行」阶段(一个考核周期真实跑一遍,值守与缺陷修复) | +| 24 | 可扩展:新增计分口径 / 考核关系 / 视图通过配置实现 | ✅ | 计分方式数据化(阶梯 / 区间 / 公式);考核关系与流程节点是方案数据;视图为元数据 | 新计分「方式」需加代码分支(口径参数不需要);清单四种 = 第 12 项确认 | ## 五、统计 -- ✅ 完整实现 21 条;⚠️ 有偏差 3 条(#4 xlsx 直导、#22 自由公式边界、#23 未压测);❌ 未实现 0 条。 -- ⚠️ 出口:#4 / #23 升级为二期事项交 PM 裁决;#22 退回需求确认(演示后拍板)。#18 已完整实现,但其中的演示夹具对齐是临时的,随 objectstack-ai/objectstack#14547 的平台修复删除。 +- 共 27 条:✅ 完整实现 **26** 条;⚠️ 有偏差 **1** 条(#23 未压测);❌ 未实现 **0** 条。 +- ⚠️ 出口: + - **#23 大数据量性能** → 升级:实施期真实数据量验证,见设计表 10「试运行」阶段。 +- 已闭环:#17 第一轮的偏差(人力岗位无记录级写权限)按调度员 2026-09-02 裁定的选项 A 修复,并以真实岗位账号重测通过(T65~T70),故本轮改 ✅。 +- 说明:#18 已完整实现,但其中的演示夹具对齐是临时的,随 objectstack-ai/objectstack#14547 的平台修复删除。 diff --git "a/docs/05-\350\207\252\346\265\213\350\256\260\345\275\225.md" "b/docs/05-\350\207\252\346\265\213\350\256\260\345\275\225.md" index eff6d76..543f01f 100644 --- "a/docs/05-\350\207\252\346\265\213\350\256\260\345\275\225.md" +++ "b/docs/05-\350\207\252\346\265\213\350\256\260\345\275\225.md" @@ -1,80 +1,463 @@ # KPI 考核管理系统 自测记录(开发侧) > 按 os-project-dev-test「最低限度档」执行:同会话自测,计划先落盘、逐条对照回填。 -> 本轮为**接口级真机验证**(REST 直调运行中的 dev 实例,不是浏览器 UI 截图),按 std-evidence -> 口径只能作为写操作落库的第二观察点,不替代 UI 功能证据;UI 走查与截图留待有浏览器环境的 -> 验收轮次(pm-test)。 +> **降级声明**:本轮为**接口级(REST)真机验证** —— 驱动脚本以真实账号登录运行中的 dev 实例逐条调用 +> 数据接口,不是浏览器 UI 走查,也没有截图。按 os-project-std-evidence,接口级证据只能作为写操作 +> 落库的第二观察点,不替代 UI 功能证据;UI 走查与截图留待有浏览器环境的验收轮次(pm-test)。 +> 本文件是挂在工作项 #10 上的测试报告的仓库内落盘版,两者同一份内容。 +> 本记录含四轮:第一轮落地已确认设计口径(T58~T64);第二轮按调度员 2026-09-02 的裁定补上人力岗位的 +> 记录级写范围(T65~T70),并以**真实岗位账号**重测第一轮遗留的阻塞问题;第三轮修 PR 评审提出的内嵌 +> 网格列集回归(问题记录 #4);第四轮修第三轮自己引入的租户过滤漏判(问题记录 #5,新增 T71)。 +> 全文数据取自**第四轮**真跑。 -**结论:✅ 通过** —— 静态门禁 3/3 绿(validate / typecheck / vitest 32 条),端到端流程用例 38/38 通过。 +## 第 1 页:结论 -## 1. 测试环境 +**结论:✅ 通过** + +共 73 条用例:通过 **73**。静态门禁 3/3 绿(validate / typecheck / vitest 68 条)。 +工作项 #10 的七条验收标准全部达成 —— 其中验收标准 1、2(以人力审核账号登记、以人力负责人账号批准) +在第一轮受数据范围所限无法演示,第二轮补上岗位共享规则后由真实岗位账号取证通过(T65、T66、T67)。 + +**关键观察**(接口级,替代截图) + +- T65 登记成功(人力审核账号):`share=true register=201`,加减分落库状态为「待审批」。 +- T66 拒绝原文:`{"error":"审批加减分失败:只有人力负责人岗位可以审批加减分。请转交人力负责人处理。","code":"VALIDATION_ERROR","declaredCode":"KPI_BONUS_APPROVE_POSITION","object":"kpi_bonus"}` +- T67 人力负责人批准后填报单最终得分 `before=103 after=105`,审批人 = 该账号本人。 +- T69 人力审核账号推进流程:`reject=200→draft approve=200→approved`。 +- T70 方案关闭后:岗位规则 4 条全部降为 `read`,该账号读填报单 200、写加减分 403。 +- T61 拒绝原文:`{"error":"发布失败:该考核周期已有生效版本「2026 年 11 月 月度考核」。请先关闭该版本,或改用复制出的新版本替换。","code":"VALIDATION_ERROR","declaredCode":"KPI_PLAN_PERIOD_CONFLICT","object":"kpi_plan"}` +- T63 计算明细原文:`{"plan_indicator":"...","indicator_name":"客户满意度","score_rate":90,"score":12.5,"is_adjusted":true,"adjust_type_applied":"result"}` + +## 2. 测试环境与账号 | 项 | 值 | |---|---| +| 被测版本 | commit `69a33c6f1bafc57254862d89b8b355235edc78ce`(分支 `issue-10-align-confirmed-design`,第四轮代码提交;第三轮 `6ba334f`、第二轮 `98cf887`、第一轮 `79437b2`)。核实方式(开测前三级核对):① `/proc/net/tcp` 中处于 LISTEN 的端口含 3110,且无 3100 / 3111;② 占用 3110 的进程 cwd = `/home/user/kpi-issue-10`;③ 该 worktree `git rev-parse HEAD` = 69a33c6,`git status` 干净,`src/hooks/plan.hook.ts` 的 blob 与 `HEAD:` 版本哈希一致(`dae1c9f8…`)。另核对构建产物:填报单内嵌网格 19 列(3 列默认隐藏)与子对象字段一一对应;⚠️ 打包器会剥离注释,**不能**用注释串做新鲜度探针,本轮改动的新鲜度由 T71 的行为(消融对照)证明 | | 平台 | @objectstack/* 17.2.0(npm 发布版),SqlDriver(better-sqlite3),单租户 | -| 启动 | `OS_PORT=3100 OS_DATABASE_URL=file:./.objectstack/dev-verify.db pnpm dev`(空库,自动加载 46 行演示种子) | -| 账号 | 管理员 admin@objectos.ai(组织 owner,等同考核系统管理员岗位) | -| 驱动脚本 | `scripts/e2e-flow.mjs`(REST;每条用例预期来自需求 §3 与蓝图,不从实现反推) | -| 未覆盖 | 各岗位账号的权限反例(用户不能种子,需 Setup 建号后再测);浏览器 UI;大数据量性能 | +| 环境 / 启动 | `OS_PORT=3110 OS_DATABASE_URL=file:./.objectstack/issue-10.db pnpm dev`(空库启动,自动加载 46 行演示种子),地址 `http://localhost:3110`。**改元数据 / 服务后必须先 `rm -rf dist` 再重启**,否则 `objectstack dev` 会沿用上次的构建产物(问题记录 #3) | +| 端 | REST 接口(PC 端服务侧)。UI 未走查 —— 见首段降级声明 | +| 测试账号 | 管理员 `admin@objectos.ai / admin123`(组织 owner,等同考核系统管理员岗位)。无权对照账号由脚本当场创建:以管理员经 REST `POST /auth/sign-up/email` 建号 → `sys_business_unit_member` 入单元 → `sys_user_position` 分配岗位,口令统一 `Passw0rd!23`;本轮建了部门填报人员(市场部)、分公司核对人员(华东)、分管领导(前任 / 新任)、**人力审核、人力负责人**六个账号,邮箱带时间戳后缀,每次运行重建 | +| 前置数据 | 仅演示种子(组织单元 8、指标库、2026 年 8 月月度考核方案及其下达);其余数据(9 / 10 / 11 / 12 月方案、账号、加减分、调整)由脚本按序创建,凭本表可从空库重建同等环境 | +| 驱动脚本 | `scripts/e2e-flow.mjs`(目标地址取环境变量 `KPI_BASE_URL`,本轮 `http://localhost:3110`);每条用例的预期只来自需求 §3、蓝图与《设计方案》V1.0,不从实现反推 | +| 测试日期 | 2026-09-02 | +| 未覆盖 | 浏览器 UI 走查与截图;大数据量性能 | -## 2. 静态门禁 +## 3. 测试计划与结果总表 -| 门禁 | 结果 | -|---|---| -| `pnpm validate`(41 条作者期规则、能力提供方、共享规则合法性) | ✓ 17 对象 / 17 视图 / 16 按钮 / 6 岗位 / 6 权限集 / 2 看板 / 3 报表 | -| `pnpm typecheck` | 0 error | -| `pnpm test`(vitest) | 32/32:计分引擎 14、状态机 12、汇总 4、CEL 求值 3 —— 含变异实验(公式求值失败、未命中阶梯、驳回回退、单节点流程) | - -## 3. 端到端流程用例与结果 - -| # | 用例 | 结果 | 实际观察(接口返回摘要) | -|---|---|---|---| -| T0 | 管理员登录 | ✅ 通过 | status 200 | -| T1 | 种子方案已加载 | ✅ 通过 | 2026 年 8 月 月度考核 | -| T2 | 指标下达 18 条且名称由 hook 填充 | ✅ 通过 | 18 rows, sample: 培训完成率 · 华东分公司 | -| T3 | 权重合计≠100 时发布被拒绝并列出问题 | ✅ 通过 | {"error":"发布失败,发布前完整性检查未通过:\n1. 指标下达:主体「市场部」的权重合计为 80%,必须等于 100%","code":"VALIDATION_ERROR","declaredCode":"KPI_PLAN_INCOMPLETE","object":"kpi_plan"} | -| T4 | 完整性通过后发布成功 | ✅ 通过 | {"object":"kpi_plan","id":"_qIsSOWufJdc3w7d","record":{"id":"_qIsSOWufJdc3w7d","created_at":"2026-09-02T10:44:32.065Z","updated_at":"2026-09-02T10:44:38.723Z"," | -| T5 | 发布生成 5 张填报单 | ✅ 通过 | 2026 年 8 月 月度考核 · 华南分公司[draft], 2026 年 8 月 月度考核 · 运营部[draft], 2026 年 8 月 月度考核 · 市场部[draft], 2026 年 8 月 月度考核 · 华东分公司[draft], 2026 年 8 月 月度考核 · 华北分公司[draft] | -| T6 | 生成 18 行明细并冻结目标/权重/计分方式副本 | ✅ 通过 | 18 rows, sample: 培训完成率 w=20 target=100 | -| T7 | 发布后修改指标下达被冻结拒绝 | ✅ 通过 | {"error":"修改失败:方案已发布,配置已冻结。如需调整指标、权重、目标或流程,请新建方案版本。","code":"VALIDATION_ERROR","declaredCode":"KPI_PLAN_FROZEN","object":"kpi_plan_indicator"} | -| T8 | 线性:实际 1320/目标 1200 → 完成率 110%,得分 55(权重 50) | ✅ 通过 | rate=110 score=55 trace=实际 1320 / 目标 1200(越高越好)→ 完成率 110%;权重 50%;线性:得分率 = 完成率限定在 [0%, 120%] = 110%;得分 = 50% × 110% = 55 | -| T9 | 区间插值:144/180=80% → 得分率 50%,得分 15(权重 30) | ✅ 通过 | rate=80 score_rate=50 score=15 | -| T10 | 有指标未填实际值时提交被拒绝 | ✅ 通过 | {"error":"提交失败:还有 1 个指标未填写实际值。请补齐后再提交。","code":"VALIDATION_ERROR","declaredCode":"KPI_SUBMIT_INCOMPLETE","object":"kpi_entry_sheet"} | -| T11 | 阶梯:87.3/90=97% → 命中「达标」得分率 90%,得分 18(权重 20) | ✅ 通过 | rate=97 score=18 trace=实际 87.3 / 目标 90(越高越好)→ 完成率 97%;权重 20%;阶梯:命中「达标」得分率 90%;得分 = 20% × 90% = 18 | -| T12 | 填报单汇总:指标得分合计 88、最终得分 88 | ✅ 通过 | indicator_score=88 total=88 weight_total=100 | -| T13 | 直接改状态被拒绝 | ✅ 通过 | {"error":"修改填报单状态失败:状态由流程推进,不能直接修改。请使用提交、审核通过、驳回或归档按钮。","code":"VALIDATION_ERROR","declaredCode":"KPI_SHEET_DIRECT_STATUS","object":"kpi_entry_sheet"} | -| T14 | 提交后进入分公司核对中 | ✅ 通过 | status=branch_checking {"object":"kpi_entry_sheet","id":"V9hE8ychLsn_rciG","record":{"id":"V9hE8ychLsn_rciG","created_at":"2026-09-02T10:44:38.477Z","updated_at | -| T15 | 按 3 家分公司生成并行核对任务 | ✅ 通过 | 2026 年 8 月 月度考核 · 市场部 · 华东分公司 核对 / 2026 年 8 月 月度考核 · 市场部 · 华南分公司 核对 / 2026 年 8 月 月度考核 · 市场部 · 华北分公司 核对 | -| T16 | 提交后修改明细被冻结拒绝 | ✅ 通过 | {"error":"修改填报明细失败:填报单已提交,数据已冻结。如需更正,请发起数据调整申请。","code":"VALIDATION_ERROR","declaredCode":"KPI_LINE_LOCKED","object":"kpi_entry_line"} | -| T17 | 提出争议但无意见被拒绝 | ✅ 通过 | {"error":"提出争议失败:核对意见不能为空。请填写争议内容后再提交。","code":"VALIDATION_FAILED","fields":[{"field":"_record","code":"rule_violation","message":"提出争议失败:核对意见不能为空。请填写争议内容后再提交。" | -| T18 | 有争议未解决时不能推进 | ✅ 通过 | {"error":"推进失败:还有 2 家分公司未核对、1 家分公司有争议未解决。请待全部确认后再推进。","code":"VALIDATION_ERROR","declaredCode":"KPI_CHECK_INCOMPLETE","object":"kpi_entry_sheet"} | -| T19 | 全部分公司确认后自动推进到人力审核中 | ✅ 通过 | status=hr_reviewing | -| T20 | 驳回原因为空被拒绝 | ✅ 通过 | {"error":"驳回失败:驳回原因不能为空。请填写原因后再驳回。","code":"VALIDATION_ERROR","declaredCode":"KPI_REJECT_REASON_REQUIRED","object":"kpi_entry_sheet"} | -| T21 | 驳回退回上一节点(分公司核对),核对任务重置,原因留痕 | ✅ 通过 | status=branch_checking reason=利润数据请复核 | -| T22 | 人力审核通过 → 领导审批中 | ✅ 通过 | status=leader_approving {"object":"kpi_entry_sheet","id":"V9hE8ychLsn_rciG","record":{"id":"V9hE8ychLsn_rciG","created_at":"2026-09-02T10:44:38.477Z","updated_a | -| T23 | 领导审批通过 → 已通过,盖章 | ✅ 通过 | status=approved | -| T24 | 通过后生成考核结果(部门维度) | ✅ 通过 | department:88#1 | -| T25 | 加减分批准后计入最终得分 91 | ✅ 通过 | bonus_total=3 total=91 {"object":"kpi_bonus","id":"UcyeR-gLZUIps58q","record":{"id":"UcyeR-gLZUIps58q","created_at":"2026-09-02T10:44:41.248Z","updated_at":"202 | -| T26 | 调整申请记录调整前值 144 与主体 | ✅ 通过 | {"object":"kpi_adjustment","id":"yactYJIh1TxwmPeS","record":{"id":"yactYJIh1TxwmPeS","created_at":"2026-09-02T10:44:41.399Z","updated_at":"2026-09-02T10:44:41.3 | -| T27 | 源数据调整落地并重算:利润 180 → 完成率 100%,得分 30;结果重新汇总 | ✅ 通过 | actual=180 score=30 total=106 results=106 | -| T28 | 归档生成不可变快照(含校验和、总分) | ✅ 通过 | snap=2026 年 8 月 月度考核 · 市场部 · 归档快照 checksum=81fefc45a6e9 total=106 | -| T29 | 修改快照被拒绝 | ✅ 通过 | {"error":"操作失败:归档快照由系统在归档时生成,不能手工新建、修改或删除。","code":"VALIDATION_ERROR","declaredCode":"KPI_SNAPSHOT_IMMUTABLE","object":"kpi_snapshot"} | -| T30 | 删除快照被拒绝 | ✅ 通过 | {"error":"操作失败:归档快照由系统在归档时生成,不能手工新建、修改或删除。","code":"VALIDATION_ERROR","declaredCode":"KPI_SNAPSHOT_IMMUTABLE","object":"kpi_snapshot"} | -| T31 | 归档后明细连备注也不可改(已冻结) | ✅ 通过 | {"error":"修改填报明细失败:填报单已提交,数据已冻结。如需更正,请发起数据调整申请。","code":"VALIDATION_ERROR","declaredCode":"KPI_LINE_LOCKED","object":"kpi_entry_line"} | -| T32 | 归档后不能再加减分 | ✅ 通过 | {"error":"修改加减分失败:填报单已归档,数据已锁定不可再改。","code":"VALIDATION_ERROR","declaredCode":"KPI_BONUS_ARCHIVED","object":"kpi_bonus"} | -| T33 | 审核记录完整留痕(生成/提交/争议/确认/驳回/通过/调整/归档) | ✅ 通过 | confirm,confirm,confirm,adjust,approve,dispute,confirm,confirm,approve,approve,submit,generate,reject,approve,archive,confirm | -| T34 | 审核记录不可篡改 | ✅ 通过 | {"error":"操作失败:审核记录是流程留痕,不能修改或删除。","code":"VALIDATION_ERROR","declaredCode":"KPI_REVIEW_IMMUTABLE","object":"kpi_review_record"} | -| T35 | 复制上一版本:流程节点 4、指标下达 18 已复制 | ✅ 通过 | steps=4 pis=18 {"object":"kpi_plan","id":"QUeboEmqi-kuF_lO","record":{"id":"QUeboEmqi-kuF_lO"," | -| T36 | 两节点流程(填报→人力审核):提交直达人力审核,通过即已通过 | ✅ 通过 | after submit=hr_reviewing final=approved | -| T37 | 逆向指标:目标 12 实际 10 → 完成率 120%,封顶 110% → 得分 55(权重 50) | ✅ 通过 | rate=120 score_rate=110 score=55 | - -## 4. 问题记录(本轮修复,均已复测通过) - -| # | 现象 | 原因 | 处理 | 严重程度 / 路由 | -|---|---|---|---|---| -| 1 | 提交填报单被拒:「状态流转不合法」 | 填报单对象的平台状态机只声明 draft → submitted,而 hook 按方案节点直接跳到 branch_checking | 状态机放开为流程内合法相邻状态,真正规则留在 hook | 阻塞 /【要改代码】 | -| 2 | 主写入被拒后核对任务、审核记录已落库 | before 阶段做了副作用 | 副作用全部移到 after 阶段(行已落库) | 严重 /【要改代码】 | -| 3 | 驳回后核对任务未重置 | 核对任务状态机不允许 confirmed → pending | 允许系统重置该边;非系统回退仍拒绝 | 严重 /【要改代码】 | -| 4 | 通过后无考核结果;加减分 / 调整随后报错 | 结果重算 `delete({ where })` 需 `multi: true`;抛错后「待执行动作」残留,汇总字段重算反复触发流程副作用 | 删除加 multi;after 阶段只响应本次写入携带的动作并先清空;汇总失败只记日志 | 阻塞 /【要改代码】 | -| 5 | 管理员被岗位校验拦下 | 平台明示 session.positions 不作授权;dev 管理员无 KPI 岗位 | 岗位经 `sys_user_position` 查询;组织 owner/admin 等同系统管理员 | 严重 /【要改代码】 | -| 6 | 权限集 `unit / unit_and_below` 被 validate 拒绝 | 企业版 `hierarchy-security` 能力,开源版不可用 | private 基线 + 按组织单元共享规则;分管范围开源版按 org;已记入解读报告 R-中-05 与蓝图 V0.2 | 一般 /【平台受限】 | +四类齐全:主流程 38、边界 3、异常 15、权限 17(合计 73)。 + +| 编号 | 标题 | 类型 | 端 | 角色 | 结果 | 证据 | +|---|---|---|---|---|---|---| +| T0 | 管理员登录 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T0 | +| T1 | 种子方案已加载 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T1 | +| T2 | 指标下达 18 条且名称由 hook 填充 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T2 | +| T3 | 权重合计≠100 时发布被拒绝并列出问题 | 异常 | REST | 管理员 | ✅ 通过 | §6 运行输出 T3 | +| T4 | 完整性通过后发布成功 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T4 | +| T5 | 发布生成 5 张填报单 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T5 | +| T6 | 生成 18 行明细并冻结目标/权重/计分方式副本 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T6 | +| T7 | 发布后修改指标下达被冻结拒绝 | 异常 | REST | 管理员 | ✅ 通过 | §6 运行输出 T7 | +| T8 | 线性:实际 1320/目标 1200 → 完成率 110%,得分 55(权重 50) | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T8 | +| T9 | 区间插值:144/180=80% → 得分率 50%,得分 15(权重 30) | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T9 | +| T10 | 有指标未填实际值时提交被拒绝 | 异常 | REST | 管理员 | ✅ 通过 | §6 运行输出 T10 | +| T11 | 阶梯:87.3/90=97% → 命中「达标」得分率 90%,得分 18(权重 20) | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T11 | +| T12 | 填报单汇总:指标得分合计 88、最终得分 88 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T12 | +| T13 | 直接改状态被拒绝 | 异常 | REST | 管理员 | ✅ 通过 | §6 运行输出 T13 | +| T14 | 提交后进入分公司核对中 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T14 | +| T15 | 按 3 家分公司生成并行核对任务 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T15 | +| T16 | 提交后修改明细被冻结拒绝 | 异常 | REST | 管理员 | ✅ 通过 | §6 运行输出 T16 | +| T17 | 提出争议但无意见被拒绝 | 异常 | REST | 管理员 | ✅ 通过 | §6 运行输出 T17 | +| T18 | 有争议未解决时不能推进 | 异常 | REST | 管理员 | ✅ 通过 | §6 运行输出 T18 | +| T19 | 全部分公司确认后自动推进到人力审核中 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T19 | +| T20 | 驳回原因为空被拒绝 | 异常 | REST | 管理员 | ✅ 通过 | §6 运行输出 T20 | +| T21 | 驳回退回上一节点(分公司核对),核对任务重置,原因留痕 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T21 | +| T22 | 人力审核通过 → 领导审批中 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T22 | +| T23 | 领导审批通过 → 已通过,盖章 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T23 | +| T24 | 通过后生成考核结果(部门维度) | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T24 | +| T25 | 加减分批准后计入最终得分 91 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T25 | +| T26 | 调整申请记录调整前值 144 与主体 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T26 | +| T27 | 源数据调整落地并重算:利润 180 → 完成率 100%,得分 30;结果重新汇总 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T27 | +| T28 | 归档生成不可变快照(含校验和、总分) | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T28 | +| T29 | 修改快照被拒绝 | 异常 | REST | 管理员 | ✅ 通过 | §6 运行输出 T29 | +| T30 | 删除快照被拒绝 | 异常 | REST | 管理员 | ✅ 通过 | §6 运行输出 T30 | +| T31 | 归档后明细连备注也不可改(已冻结) | 异常 | REST | 管理员 | ✅ 通过 | §6 运行输出 T31 | +| T32 | 归档后不能再加减分 | 异常 | REST | 管理员 | ✅ 通过 | §6 运行输出 T32 | +| T33 | 审核记录完整留痕(生成/提交/争议/确认/驳回/通过/调整/归档) | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T33 | +| T34 | 审核记录不可篡改 | 异常 | REST | 管理员 | ✅ 通过 | §6 运行输出 T34 | +| T35 | 复制上一版本:流程节点 4、指标下达 18 已复制 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T35 | +| T36 | 两节点流程(填报→人力审核):提交直达人力审核,通过即已通过 | 边界 | REST | 管理员 | ✅ 通过 | §6 运行输出 T36 | +| T37 | 逆向指标:目标 12 实际 10 → 完成率 120%,封顶 110% → 得分 55(权重 50) | 边界 | REST | 管理员 | ✅ 通过 | §6 运行输出 T37 | +| T38 | 发布第三版方案(市场部已配分管领导)生成 5 张填报单 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T38 | +| T39 | 发布按方案配置写入动态共享规则,条数与方案配置精确相符,元数据零改动 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T39 | +| T39b | 规则条件按方案隔离:每条规则的条件里都带本方案 id | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T39b | +| T40 | 规则求值把单元共享展开成逐人记录共享行(市场部填报单 → 用户 A) | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T40 | +| T41 | 部门填报人员只看到本部门(市场部)填报单,含本次新发布的那张 | 权限 | REST | 部门填报人员 | ✅ 通过 | §6 运行输出 T41 | +| T42 | 打开本部门以外(华东分公司)的填报单被拒绝 | 权限 | REST | 部门填报人员 | ✅ 通过 | §6 运行输出 T42 | +| T43 | 部门填报人员能填写实际值并提交本部门填报单 | 主流程 | REST | 部门填报人员 | ✅ 通过 | §6 运行输出 T43 | +| T44 | 分公司核对人员只看到本分公司(华东)的核对任务 | 权限 | REST | 分公司核对人员 | ✅ 通过 | §6 运行输出 T44 | +| T45 | 分公司核对人员能确认本分公司的核对任务 | 主流程 | REST | 分公司核对人员 | ✅ 通过 | §6 运行输出 T45 | +| T46 | 分管领导只看到分管主体(市场部)的填报单,看不到运营部 | 权限 | REST | 分管领导 | ✅ 通过 | §6 运行输出 T46 | +| T47 | 分管领导能在「领导审批中」执行审核通过 | 主流程 | REST | 分管领导 | ✅ 通过 | §6 运行输出 T47 | +| T48 | 已通过填报单批准加减分后,部门结果立即等于新的最终得分 | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T48 | +| T49 | 否决加减分后立即重算,被否决的分不计入部门结果 | 边界 | REST | 管理员 | ✅ 通过 | §6 运行输出 T49 | +| T50 | 不存在方案发布之外来源的 KPI 共享规则(静态规则文件已删除,也没有旧的按单元规则) | 权限 | REST | 管理员 | ✅ 通过 | §6 运行输出 T50 | +| T51 | 填报人员看到本部门结果与本人到人结果,看不到他人的到人结果 | 权限 | REST | 部门填报人员 | ✅ 通过 | §6 运行输出 T51 | +| T53 | 分管领导看到所分管主体(市场部)的部门结果,看不到非分管主体的结果 | 权限 | REST | 分管领导 | ✅ 通过 | §6 运行输出 T53 | +| T54 | 换人后新方案只为新任分管领导建规则,老领导没有新方案的规则 | 权限 | REST | 管理员 | ✅ 通过 | §6 运行输出 T54 | +| T55 | 换掉分管领导:老领导看不到新方案的填报单,历史方案的仍看得到 | 权限 | REST | 分管领导(前任) | ✅ 通过 | §6 运行输出 T55 | +| T56 | 新任分管领导看到新方案的市场部填报单 | 权限 | REST | 分管领导(新任) | ✅ 通过 | §6 运行输出 T56 | +| T57 | 方案关闭后填报单 / 核对任务 / 调整的共享降为只读(留读、去写) | 权限 | REST | 管理员 | ✅ 通过 | §6 运行输出 T57 | +| T52 | 演示种子组织单元在启动时被补上组织归属(与 Setup 里新建的单元一致) | 主流程 | REST | 系统(启动) | ✅ 通过 | §6 运行输出 T52 | +| T58 | 部门填报人员登记加减分被拒绝(权限集不再声明新建权限) **(本工作项新增)** | 权限 | REST | 部门填报人员 | ✅ 通过 | §6 运行输出 T58 | +| T59 | 非人力负责人岗位批准加减分被拒绝(第 10 章第 6 项:人力负责人审批) **(本工作项新增)** | 权限 | REST | 部门填报人员 | ✅ 通过 | §6 运行输出 T59 | +| T60 | 人力负责人岗位批准加减分成功,最终得分随之重算 **(本工作项新增)** | 主流程 | REST | 管理员(等同人力负责人岗位) | ✅ 通过 | §6 运行输出 T60 | +| T61 | 同周期已有生效版本时发布被拒并点名该版本;关闭后可发布(第 10 章第 10 项) **(本工作项新增)** | 异常 | REST | 管理员 | ✅ 通过 | §6 运行输出 T61 | +| T62 | 结果调整落地后该行标记「已调整」= 是、调整类型 = 计算结果,未调整行保持否 **(本工作项新增)** | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T62 | +| T63 | 考核结果的计算明细 JSON 逐指标带 is_adjusted,被调整的那条为 true **(本工作项新增)** | 主流程 | REST | 管理员 | ✅ 通过 | §6 运行输出 T63 | +| T64 | 分公司核对人员直接修改填报明细实际值被拒绝(第 10 章第 13 项:只能确认 / 提出争议) **(本工作项新增)** | 权限 | REST | 分公司核对人员 | ✅ 通过 | §6 运行输出 T64 | +| T65 | 人力审核账号登记加减分成功(岗位规则已授予记录级编辑权) **(本工作项第二轮新增)** | 主流程 | REST | 人力审核 | ✅ 通过 | §6 运行输出 T65 | +| T66 | 人力审核账号批准自己登记的加减分被拒绝(第 10 章第 6 项:人力负责人审批) **(本工作项第二轮新增)** | 权限 | REST | 人力审核 | ✅ 通过 | §6 运行输出 T66 | +| T67 | 人力负责人账号批准加减分成功,填报单最终得分随之重算 **(本工作项第二轮新增)** | 主流程 | REST | 人力负责人 | ✅ 通过 | §6 运行输出 T67 | +| T68 | 人力负责人账号登记加减分被拒绝(权限集不声明新建权限) **(本工作项第二轮新增)** | 权限 | REST | 人力负责人 | ✅ 通过 | §6 运行输出 T68 | +| T69 | 人力审核账号在人力审核节点驳回与审核通过均成功(真实岗位账号推进流程) **(本工作项第二轮新增)** | 主流程 | REST | 人力审核 | ✅ 通过 | §6 运行输出 T69 | +| T70 | 方案关闭后人力岗位对该方案填报单降为只读(留读、去写) **(本工作项第二轮新增)** | 权限 | REST | 人力审核 | ✅ 通过 | §6 运行输出 T70 | +| T71 | 组织为空的已发布方案仍阻断同周期发布(租户判定不靠查询过滤) **(本工作项第三轮新增)** | 异常 | REST | 管理员 | ✅ 通过 | §6 运行输出 T71 | + +## 4. 逐用例明细(本工作项新增 / 改动的用例) + +### T58:部门填报人员登记加减分被拒绝(权限集不再声明新建权限) + +- **前置条件**:2026 年 11 月方案已发布(市场部为参与主体),部门填报人员账号 A 已入市场部并持岗位 `kpi_dept_reporter`;等待该方案的市场部填报单共享到 A 后再操作。 +- **操作步骤**:以账号 A 登录 → `POST /api/v1/data/kpi_bonus`,body:`{ sheet: 11 月市场部填报单, title: '越权登记', bonus_type: 'add', points: 1, reason: '岗位反例' }`。 +- **预期结果**:① 请求被拒绝(4xx);② 记录拒绝发生在哪一层。 +- **实际观察**:① HTTP 403;② 拒绝层 = 权限集(`PERMISSION_DENIED`)—— 第二轮按裁定把 `kpi_dept_reporter_set` 的 `kpi_bonus.allowCreate` 改为 false,声明与执行一致后,拒绝比业务规则更早发生。业务规则本身的文案(校验规则 1)由单元测试 `requiredBonusPosition` 取证。 +- **结果**:✅ 通过 +- **截图**:无(接口级证据,见首段降级声明) + +### T59:非人力负责人岗位批准加减分被拒绝 + +- **前置条件**:同 T58;管理员先在同一张填报单上登记一条待审批加减分(事项「安全生产先进」,加分 2)。 +- **操作步骤**: + 1. 以账号 A(部门填报人员,对该填报单有记录级编辑权)登录 → `PATCH /api/v1/data/kpi_bonus/`,body:`{ status: 'approved' }`。 +- **预期结果**: + 1. 请求被拒绝(4xx); + 2. 提示:「审批加减分失败:只有人力负责人岗位可以审批加减分。请转交人力负责人处理。」 +- **实际观察**: + 1. HTTP 422; + 2. `{"error":"审批加减分失败:只有人力负责人岗位可以审批加减分。请转交人力负责人处理。","code":"VALIDATION_ERROR","declaredCode":"KPI_BONUS_APPROVE_POSITION","object":"kpi_bonus"}`。 +- **结果**:✅ 通过 +- **补充说明**:本用例用部门填报人员做反例,是因为人力审核账号连这一步都够不到(问题记录 #1)—— 它对填报单没有记录级写权限,会先被平台的 403 挡下,看不到业务规则的提示。用 A 做反例反而能证明「唯一被拒的原因是岗位判定」。 +- **截图**:无 + +### T60:人力负责人岗位批准加减分成功,最终得分随之重算 + +- **前置条件**:2026 年 10 月方案的市场部填报单已通过(状态 approved),批准前最终得分 106、加减分合计 3。管理员账号是组织 owner,岗位判定上等同人力负责人(与考核系统管理员同口径)。 +- **操作步骤**: + 1. `POST /api/v1/data/kpi_bonus`:事项「重点工作表彰」,加分 2; + 2. `PATCH /api/v1/data/kpi_bonus/`:`{ status: 'approved' }`; + 3. `GET /api/v1/data/kpi_entry_sheet/<市场部填报单>`。 +- **预期结果**: + 1. 批准成功(2xx),加减分状态为「已批准」且盖上审批人; + 2. 填报单加减分合计 3 → 5; + 3. 填报单最终得分 106 → 108。 +- **实际观察**: + 1. 批准返回 2xx,`status=approved`、`approved_by` 非空; + 2. `bonus_total=5`; + 3. `before=106 after=108`。 +- **结果**:✅ 通过 +- **截图**:无 + +### T61:同周期已有生效版本时发布被拒;关闭后可发布 + +- **前置条件**:2026 年 11 月月度考核已处于「已发布」。 +- **操作步骤**: + 1. 复制该版本新建方案「2026 年 11 月 月度考核(重复周期)」(period_type=month、year=2026、period_no=11); + 2. `PATCH /api/v1/data/kpi_plan/<新方案>`:`{ status: 'published' }`; + 3. 把原 11 月方案改为「已关闭」; + 4. 再次对新方案执行发布。 +- **预期结果**: + 1. 第 2 步被拒绝,提示点名已生效的方案名,并给出下一步(关闭该版本或用复制版替换); + 2. 第 4 步发布成功,新方案状态变为「已发布」。 +- **实际观察**: + 1. HTTP 422,`{"error":"发布失败:该考核周期已有生效版本「2026 年 11 月 月度考核」。请先关闭该版本,或改用复制出的新版本替换。","code":"VALIDATION_ERROR","declaredCode":"KPI_PLAN_PERIOD_CONFLICT","object":"kpi_plan"}`; + 2. 关闭后再发布返回 200,`status=published`。 +- **结果**:✅ 通过 +- **截图**:无 + +### T62:结果调整落地后标记「已调整」 + +- **前置条件**:2026 年 10 月方案的市场部填报单已通过,明细「客户满意度」已有得分。 +- **操作步骤**: + 1. `POST /api/v1/data/kpi_adjustment`:`{ line: <客户满意度明细>, adjust_type: 'result', new_value: 12.5, reason: '复核后按第 14 项调整该指标得分' }`; + 2. `PATCH` 该调整 → `submitted` → `approved`; + 3. `GET` 被调整的明细行与同张单里另一行未被调整的明细。 +- **预期结果**: + 1. 被调整行「已调整」= 是、「调整类型」= 计算结果; + 2. 该行调整后得分与最终得分 = 12.5; + 3. 未被调整的行「已调整」保持否、「调整类型」为空。 +- **实际观察**: + 1. `is_adjusted=true type=result`; + 2. `final=12.5`(`adjusted_score` 同为 12.5); + 3. 未调整行 `is_adjusted=false`、`adjust_type_applied` 为空。 +- **结果**:✅ 通过 +- **截图**:无 + +### T63:考核结果计算明细 JSON 逐指标带 is_adjusted + +- **前置条件**:T62 的调整已落地并触发结果重算。 +- **操作步骤**: + 1. `GET /api/v1/data/kpi_result?plan=<2026 年 10 月方案>&limit=100`,取维度 = 部门、单元 = 市场部的那一行,读 `breakdown`。 +- **预期结果**: + 1. `breakdown.lines` 逐指标列出,条数 = 该填报单的明细行数; + 2. 被调整的那条 `is_adjusted: true`、`adjust_type_applied: 'result'`; + 3. 未被调整的那条 `is_adjusted: false`。 +- **实际观察**: + 1. `lines=3`; + 2. `{"plan_indicator":"1GLVXvVPxDO9xY2G","indicator_name":"客户满意度","score_rate":90,"score":12.5,"is_adjusted":true,"adjust_type_applied":"result"}`; + 3. 未调整行 `is_adjusted:false`。 +- **结果**:✅ 通过 +- **截图**:无 + +### T64:分公司核对人员改填报明细实际值被拒绝 + +- **前置条件**:分公司核对人员账号 B 持岗位 `kpi_branch_checker`、属华东分公司;T45 已证明它能确认本分公司的核对任务。 +- **操作步骤**: + 1. 以账号 B 登录 → `PATCH /api/v1/data/kpi_entry_line/<客户满意度明细>`:`{ actual_value: 1 }`; + 2. 以管理员重新读该明细行。 +- **预期结果**: + 1. 请求被拒绝(403); + 2. 明细实际值未被改动。 +- **实际观察**: + 1. HTTP 403,`{"error":"You do not have permission to perform this action. Contact your administrator if you need access.","code":"PERMISSION_DENIED",...}`; + 2. 明细实际值与操作前一致。 +- **结果**:✅ 通过 +- **补充说明**:拒绝发生在权限层(核对人员的权限集对填报明细只有读 / 导出),不是业务 hook —— 「只能确认 / 提出争议、不能改值」由权限模型直接兑现。 +- **截图**:无 + +### T65:人力审核账号登记加减分成功 + +- **前置条件**:第二轮新建 2026 年 12 月方案(两节点流程:部门填报 → 人力审核),发布后由管理员填完市场部填报单并提交,填报单处于「人力审核中」;人力审核账号持岗位 `kpi_hr_reviewer`,不属于任何考核主体单元、也不是分管领导。 +- **操作步骤**: + 1. 轮询 `sys_record_share`,等方案发布写入的岗位共享规则求值出该账号对该填报单的 `edit` 行; + 2. 以人力审核账号登录 → `POST /api/v1/data/kpi_bonus`:`{ sheet: 12 月市场部填报单, title: '专项攻关表彰', bonus_type: 'add', points: 2, reason: '按第 6 项由人力审核登记' }`; + 3. 重读该加减分。 +- **预期结果**: + 1. 共享行出现(证明岗位收件方被展开成了该账号的记录共享); + 2. 登记成功(2xx); + 3. 加减分状态为「待审批」。 +- **实际观察**: + 1. `share=true`; + 2. HTTP 201; + 3. `status=draft`。 +- **结果**:✅ 通过 +- **截图**:无 + +### T66:人力审核账号批准自己登记的加减分被拒绝 + +- **前置条件**:T65 登记的那条加减分处于「待审批」,操作账号仍是登记它的人力审核账号(此时它对填报单已有记录级编辑权,所以拒绝只可能来自岗位判定)。 +- **操作步骤**:`PATCH /api/v1/data/kpi_bonus/`:`{ status: 'approved' }`。 +- **预期结果**:① 被拒绝(4xx);② 提示「审批加减分失败:只有人力负责人岗位可以审批加减分。请转交人力负责人处理。」 +- **实际观察**:① HTTP 422;② 文案一致,`declaredCode` = `KPI_BONUS_APPROVE_POSITION`。 +- **结果**:✅ 通过 —— 这条同时兑现了「人力审核不能审批自己登记的加减分」。 +- **截图**:无 + +### T67:人力负责人账号批准加减分成功,填报单最终得分随之重算 + +- **前置条件**:T65 的加减分待审批;批准前填报单最终得分 103、加减分合计 0。 +- **操作步骤**:以人力负责人账号(持 `kpi_hr_head`)登录 → `PATCH /api/v1/data/kpi_bonus/`:`{ status: 'approved' }` → 以管理员重读填报单与该加减分。 +- **预期结果**:① 批准成功;② 审批人 = 该人力负责人账号;③ 加减分合计 +2;④ 最终得分 103 → 105。 +- **实际观察**:① 2xx;② `approved_by` = 该账号 id;③ `bonus_total` = 2;④ `before=103 after=105`。 +- **结果**:✅ 通过 +- **截图**:无 + +### T68:人力负责人账号登记加减分被拒绝 + +- **操作步骤**:以人力负责人账号登录 → `POST /api/v1/data/kpi_bonus`(同一张填报单)。 +- **预期结果**:① 被拒绝;② 记录拒绝发生在哪一层。 +- **实际观察**:① HTTP 403;② 拒绝层 = 权限集(`kpi_hr_head_set` 不声明 `kpi_bonus` 的新建权限)—— 与「登记 = 人力审核」的确认口径一致,拒绝早于业务规则。 +- **结果**:✅ 通过 +- **截图**:无 + +### T69:人力审核账号在人力审核节点驳回与审核通过 + +- **前置条件**:12 月方案的市场部填报单处于「人力审核中」,该方案人力审核节点的审核岗位 = `kpi_hr_reviewer`。 +- **操作步骤**: + 1. 以人力审核账号登录 → `PATCH /api/v1/data/kpi_entry_sheet/`:`{ pending_action: 'reject', action_reason: '数据口径请复核' }`; + 2. 以管理员重新提交; + 3. 以人力审核账号 → `{ pending_action: 'approve' }`。 +- **预期结果**:① 驳回成功,填报单退回「填报中」并留下驳回原因;② 审核通过成功,两节点流程下直接变为「已通过」。 +- **实际观察**:① `reject=200 → status=draft`,`last_reject_reason=数据口径请复核`;② `approve=200 → status=approved`。 +- **结果**:✅ 通过 —— 人力审核节点第一次由真实岗位账号(而非管理员)推进。 +- **截图**:无 + +### T70:方案关闭后人力岗位对该方案填报单降为只读 + +- **前置条件**:12 月方案的填报单已通过;把该方案改为「已关闭」。 +- **操作步骤**: + 1. 管理员 `PATCH /api/v1/data/kpi_plan/`:`{ status: 'closed' }`,轮询该方案的岗位规则降为 `read`; + 2. 以人力审核账号 `GET` 该填报单; + 3. 以人力审核账号 `POST /api/v1/data/kpi_bonus`(同一张填报单)。 +- **预期结果**:① 该方案的 4 条岗位规则全部 `read`;② 读得到(留读);③ 写被拒(去写)。 +- **实际观察**:① `岗位规则 4 条全部 read=true`;② HTTP 200;③ HTTP 403。 +- **结果**:✅ 通过 +- **截图**:无 + +### T71:组织为空的已发布方案仍阻断同周期发布 + +- **前置条件**:演示种子写入的「2026 年 8 月 月度考核」在 T4 已发布,其 `organization_id` 为空(种子 / 导入写进来的行都是这样)。 +- **操作步骤**: + 1. 读该种子方案,确认状态「已发布」且组织为空; + 2. 复制它新建同周期方案(月度 / 2026 / 第 8 期); + 3. 对新方案执行发布。 +- **预期结果**: + 1. 发布被拒,提示点名种子方案; + 2. 新方案仍停在「草稿」。 +- **实际观察**: + 1. `seed_org=null seed_status=published`,发布返回 HTTP 422,`发布失败:该考核周期已有生效版本「2026 年 8 月 月度考核」。…`; + 2. 新方案 `status=draft`。 +- **结果**:✅ 通过 +- **反向验证(消融)**:把第三轮那条 `where.organization_id` 过滤加回去、清 `dist` 重建后重跑,本用例转红(`dup=200`,重复发布成功),而 T61(两条方案都有组织)保持绿 —— 与评审判断的「数据相关漏判」完全吻合。随后按 `git checkout HEAD --` 还原、再次清 `dist` 重建重跑,73/73 恢复全绿;还原后工作树与 `HEAD` 的 blob 哈希一致。 +- **截图**:无 + +## 5. 问题记录 + +| # | 用例 | 现象 | 复现步骤 | 原因分析 | 处理建议 | 截图 | 处置 | +|---|---|---|---|---|---|---|---| +| 1 | 第一轮:验收标准 1 / 2(当时无对应用例);第二轮:T65~T70 | 人力审核账号登记不了加减分、人力负责人账号批准不了加减分,连改填报单备注都被拒;两个账号都看得到填报单列表 | ① 管理员建号并 `POST sys_user_position {position:'kpi_hr_reviewer'}`;② 以该账号登录;③ `GET /data/kpi_entry_sheet` 能读到 25 张;④ `POST /data/kpi_bonus {sheet: 任一已发布方案的填报单, ...}` → 403;⑤ `PATCH /data/kpi_entry_sheet/<同一张> {remark:'x'}` → 403。**必现**(第一轮 3 次 3 次出现) | 填报单 OWD 为 private,记录级写权限只来自方案发布写入的动态共享规则(收件人 = 参与主体单元、核对分公司、分管领导、被下达员工),人力岗位不在其列;权限集里的 org 范围只放开了读。平台原文:`[Security] Access denied: insert on 'kpi_bonus' requires edit access to its master record (master 'kpi_entry_sheet' not editable by this user (row-level security))` | 【要改代码】按裁定选项 A:方案发布时为 `kpi_hr_reviewer` / `kpi_hr_head` 建按方案的共享规则(收件方类型 = `position`),授予本方案全部填报单与数据调整 `edit`;加减分作为填报单的主从子记录随之可写 | 无(接口级) | **已修复并重测**:修复提交 `98cf887`,重测所在提交 `98cf887`,证据 T65~T70(登记、审批、流程推进、关闭后只读)全部通过 | +| 2 | T58 / T68 | 部门填报人员的权限集仍声明了加减分的新建权限,而业务规则已只允许人力审核登记 —— 声明与执行不一致(按钮点了才报错) | 见 T58 | `kpi_dept_reporter_set` 的 `kpi_bonus.allowCreate` 为 true | 【要改代码】改为 `allowCreate: false`;`kpi_hr_head_set` 保持不可新建 | 无 | **已修复并重测**:修复提交 `98cf887`,重测所在提交 `98cf887`,证据 T58(拒绝层 = 权限集)、T68 | +| 3 | 第二轮首次运行 T39 / T65~T70 | 改完共享规则代码重启 dev 后,岗位共享规则一条都没写出来,T39 规则数仍是旧值 25、T65~T70 全红,而服务端日志里没有任何报错 | ① 改 `src/services/sharing-service.ts`;② 按 PID 停 dev、删数据库文件后重启;③ 跑 e2e → 新规则不存在;④ `grep _pos_ dist/*.mjs` = 0 命中,`dist/` 构建产物时间戳仍是上一轮的 | `objectstack dev` 沿用了 `dist/` 里上一轮的构建产物,新源码没有进入运行实例 —— 症状是「代码看着改了、运行的还是旧的」,且不报错 | 【环境问题】改元数据 / hook / 服务后,重启前先 `rm -rf dist`(项目 CLAUDE.md D3 的清理项);重启后用 `grep` 确认新产物含新代码再开测。**绕行**:同上,清理即可 | 无 | 已解决:清 `dist/` 后重启,同一份 e2e 脚本 72/72 通过 | +| 4 | 填报单内嵌网格(评审 🟡-1,无对应用例) | 填报单里的指标网格少了六列:来源下达(必填)、指标、指标方向、计分方式、计算说明、最近调整 —— 本工作项只要求加一列「已调整」,却把原有列挤掉了 | 打开任一填报单的「指标填报」内嵌网格,对照 `kpi_entry_line` 的字段清单逐列核对 | 给主从字段显式声明 `inlineColumns` 会**整体替换**平台从子对象派生的列集,新列表只列了 13 列 | 【要改代码】补回全部 19 列(= 子对象除主从字段外的全部字段),顺序沿用字段定义顺序,新列排最后;冗长的计算说明 / 最近调整 / 指标用平台原生 `defaultHidden` 折叠而不是删除。**绕行**:无需绕行(列表视图仍完整) | 无 | **已修复并重测**:修复与重测所在提交 `6ba334f`,构建产物核对为 19 列(3 列默认隐藏),`pnpm validate` 绿、e2e 72/72 | +| 5 | T71(本轮新增) | 第三轮为租户隔离加的 `where.organization_id` 等值条件会把**组织为空**的已发布方案挡在判定之外 —— 种子 / 导入写进来的方案行就是空组织,于是「同周期只有一个生效版本」对这类数据静默失效(第三轮之前没有这个问题) | ① 空库起 dev,种子方案(2026 年 8 月)在 T4 发布,其 `organization_id` 为空;② 复制它新建同周期方案并发布;③ 期望被拒,实际发布成功(HTTP 200)。**必现** | 等值条件不匹配 NULL,候选在进入 `findPublishedConflict` 之前就被查询滤掉,`sameTenant()` 里「缺组织时退回只按周期判定」的兜底永远轮不到它 | 【要改代码】候选只按 `status: 'published'` 取,租户判定全部交给纯函数 `sameTenant`;补纯函数用例(空组织候选)与 e2e T71。**绕行**:无需绕行(仅第三轮提交存在该缺陷,未发布) | 无 | **已修复并重测**:修复与重测所在提交 `69a33c6`,证据 T71 + 消融对照(加回过滤即转红,还原即恢复绿) | + +## 6. 运行输出(当次真跑原样) + +### 6.1 `pnpm verify`(validate + typecheck + vitest) + +```text + +> @objectstack-ai/kpi@0.1.0 verify /home/user/kpi-issue-10 +> pnpm validate && pnpm typecheck && pnpm test + + +> @objectstack-ai/kpi@0.1.0 validate /home/user/kpi-issue-10 +> objectstack validate + + +◆ Validate +──────────────────────────────────────── + → Loading configuration... + Config: /home/user/kpi-issue-10/objectstack.config.ts + Load time: 274ms + → Validating against ObjectStack Protocol... + → Running author-time rules (41)... + → Checking capability providers (#3366)... + → Checking package docs (ADR-0046)... + + ✓ Validation passed (655ms) + + KPI 考核管理 v0.1.0 + KPI assessment management: indicator library, plan versions, entry with instant scoring, parallel branch checks, configurable multi-step review, adjustments, four-dimension aggregation and immutable archives. + + Data: 17 Objects 184 Fields + UI: 1 Apps 17 Views 1 Pages 2 Dashboards 3 Reports 16 Actions + Logic: 0 Flows + Security: 6 Positions 6 Permissions + + +> @objectstack-ai/kpi@0.1.0 typecheck /home/user/kpi-issue-10 +> tsc --noEmit + + +> @objectstack-ai/kpi@0.1.0 test /home/user/kpi-issue-10 +> vitest run + + + RUN v4.1.11 /home/user/kpi-issue-10 + + + Test Files 6 passed (6) + Tests 68 passed (68) + Start at 18:33:30 + Duration 978ms (transform 806ms, setup 0ms, import 1.53s, tests 70ms, environment 1ms) + +``` + +单元测试 68 条分布:计分引擎 14、状态机 / 流程配置 11、数据范围规则 26(第二轮 +3:人力岗位规则生成、锚定与关闭后降级)、汇总 4、CEL 求值 3、已确认设计口径 10(第三轮 +1:同周期唯一性的租户隔离)。 + +### 6.2 `pnpm e2e`(`KPI_BASE_URL=http://localhost:3110 node scripts/e2e-flow.mjs`) + +```text +PASS T0 管理员登录 — status 200 +PASS T1 种子方案已加载 — 2026 年 8 月 月度考核 +PASS T2 指标下达 18 条且名称由 hook 填充 — 18 rows, sample: 成本费用率 · 运营部 +PASS T3 权重合计≠100 时发布被拒绝并列出问题 — {"error":"发布失败,发布前完整性检查未通过:\n1. 指标下达:主体「市场部」的权重合计为 60%,必须等于 100%","code":"VALIDATION_ERROR","declaredCode":"KPI_PLAN_INCOMPLETE","object":"kpi_plan"} +PASS T4 完整性通过后发布成功 — {"object":"kpi_plan","id":"I-z-8wRXLurbl6a6","record":{"id":"I-z-8wRXLurbl6a6","created_at":"2026-09-02T18:37:14.730Z","updated_at":"2026-09-02T18:37:40.432Z","organization_id":null,"created_by":null,"updated_by":"qXGjlT +PASS T5 发布生成 5 张填报单 — 2026 年 8 月 月度考核 · 运营部[draft], 2026 年 8 月 月度考核 · 华南分公司[draft], 2026 年 8 月 月度考核 · 华东分公司[draft], 2026 年 8 月 月度考核 · 市场部[draft], 2026 年 8 月 月度考核 · 华北分公司[draft] +PASS T6 生成 18 行明细并冻结目标/权重/计分方式副本 — 18 rows, sample: 客户满意度 w=20 target=90 +PASS T7 发布后修改指标下达被冻结拒绝 — {"error":"修改失败:方案已发布,配置已冻结。如需调整指标、权重、目标或流程,请新建方案版本。","code":"VALIDATION_ERROR","declaredCode":"KPI_PLAN_FROZEN","object":"kpi_plan_indicator"} +PASS T8 线性:实际 1320/目标 1200 → 完成率 110%,得分 55(权重 50) — rate=110 score=55 trace=实际 1320 / 目标 1200(越高越好)→ 完成率 110%;权重 50%;线性:得分率 = 完成率限定在 [0%, 120%] = 110%;得分 = 50% × 110% = 55 +PASS T9 区间插值:144/180=80% → 得分率 50%,得分 15(权重 30) — rate=80 score_rate=50 score=15 +PASS T10 有指标未填实际值时提交被拒绝 — {"error":"提交失败:还有 1 个指标未填写实际值。请补齐后再提交。","code":"VALIDATION_ERROR","declaredCode":"KPI_SUBMIT_INCOMPLETE","object":"kpi_entry_sheet"} +PASS T11 阶梯:87.3/90=97% → 命中「达标」得分率 90%,得分 18(权重 20) — rate=97 score=18 trace=实际 87.3 / 目标 90(越高越好)→ 完成率 97%;权重 20%;阶梯:命中「达标」得分率 90%;得分 = 20% × 90% = 18 +PASS T12 填报单汇总:指标得分合计 88、最终得分 88 — indicator_score=88 total=88 weight_total=100 +PASS T13 直接改状态被拒绝 — {"error":"修改填报单状态失败:状态由流程推进,不能直接修改。请使用提交、审核通过、驳回或归档按钮。","code":"VALIDATION_ERROR","declaredCode":"KPI_SHEET_DIRECT_STATUS","object":"kpi_entry_sheet"} +PASS T14 提交后进入分公司核对中 — status=branch_checking {"object":"kpi_entry_sheet","id":"u4KvzmsUHkHZLA0k","record":{"id":"u4KvzmsUHkHZLA0k","created_at":"2026-09-02T18:37:40.011Z","updated_at":"2026-09-02T18:37:41.284Z","organization_id":"org_mtkfufl3h3opu052","created_by": +PASS T15 按 3 家分公司生成并行核对任务 — 2026 年 8 月 月度考核 · 市场部 · 华东分公司 核对 | 2026 年 8 月 月度考核 · 市场部 · 华南分公司 核对 | 2026 年 8 月 月度考核 · 市场部 · 华北分公司 核对 +PASS T16 提交后修改明细被冻结拒绝 — {"error":"修改填报明细失败:填报单已提交,数据已冻结。如需更正,请发起数据调整申请。","code":"VALIDATION_ERROR","declaredCode":"KPI_LINE_LOCKED","object":"kpi_entry_line"} +PASS T17 提出争议但无意见被拒绝 — {"error":"提出争议失败:核对意见不能为空。请填写争议内容后再提交。","code":"VALIDATION_FAILED","fields":[{"field":"_record","code":"rule_violation","message":"提出争议失败:核对意见不能为空。请填写争议内容后再提交。"}],"object":"kpi_check_task"} +PASS T18 有争议未解决时不能推进 — {"error":"推进失败:还有 2 家分公司未核对、1 家分公司有争议未解决。请待全部确认后再推进。","code":"VALIDATION_ERROR","declaredCode":"KPI_CHECK_INCOMPLETE","object":"kpi_entry_sheet"} +PASS T19 全部分公司确认后自动推进到人力审核中 — status=hr_reviewing +PASS T20 驳回原因为空被拒绝 — {"error":"驳回失败:驳回原因不能为空。请填写原因后再驳回。","code":"VALIDATION_ERROR","declaredCode":"KPI_REJECT_REASON_REQUIRED","object":"kpi_entry_sheet"} +PASS T21 驳回退回上一节点(分公司核对),核对任务重置,原因留痕 — status=branch_checking reason=利润数据请复核 +PASS T22 人力审核通过 → 领导审批中 — status=leader_approving {"object":"kpi_entry_sheet","id":"u4KvzmsUHkHZLA0k","record":{"id":"u4KvzmsUHkHZLA0k","created_at":"2026-09-02T18:37:40.011Z","updated_at":"2026-09-02T18:37:42.621Z","organization_id":"org_mtkfufl3h3opu052","created_by": +PASS T23 领导审批通过 → 已通过,盖章 — status=approved +PASS T24 通过后生成考核结果(部门维度) — department:88#1 +PASS T25 加减分批准后计入最终得分 91 — bonus_total=3 total=91 {"object":"kpi_bonus","id":"xkcStxstEnj5aW-O","record":{"id":"xkcStxstEnj5aW-O","created_at":"2026-09-02T18:37:42.924Z","updated_at":"2026-09-02T18:37:42.982Z","organization_id":"org_mtkfufl3h3opu052","created_by":"qXGjl +PASS T26 调整申请记录调整前值 144 与主体 — {"object":"kpi_adjustment","id":"6wcDh21bAGgm8_6O","record":{"id":"6wcDh21bAGgm8_6O","created_at":"2026-09-02T18:37:43.132Z","updated_at":"2026-09-02T18:37:43.132Z","organization_id":"org_mtkfufl3h3opu052","created_by":" +PASS T27 源数据调整落地并重算:利润 180 → 完成率 100%,得分 30;结果重新汇总 — actual=180 score=30 total=106 results=106 +PASS T28 归档生成不可变快照(含校验和、总分) — snap=2026 年 8 月 月度考核 · 市场部 · 归档快照 checksum=6926cde940c4 total=106 +PASS T29 修改快照被拒绝 — {"error":"操作失败:归档快照由系统在归档时生成,不能手工新建、修改或删除。","code":"VALIDATION_ERROR","declaredCode":"KPI_SNAPSHOT_IMMUTABLE","object":"kpi_snapshot"} +PASS T30 删除快照被拒绝 — {"error":"操作失败:归档快照由系统在归档时生成,不能手工新建、修改或删除。","code":"VALIDATION_ERROR","declaredCode":"KPI_SNAPSHOT_IMMUTABLE","object":"kpi_snapshot"} +PASS T31 归档后明细连备注也不可改(已冻结) — {"error":"修改填报明细失败:填报单已提交,数据已冻结。如需更正,请发起数据调整申请。","code":"VALIDATION_ERROR","declaredCode":"KPI_LINE_LOCKED","object":"kpi_entry_line"} +PASS T32 归档后不能再加减分 — {"error":"修改加减分失败:填报单已归档,数据已锁定不可再改。","code":"VALIDATION_ERROR","declaredCode":"KPI_BONUS_ARCHIVED","object":"kpi_bonus"} +PASS T33 审核记录完整留痕(生成/提交/争议/确认/驳回/通过/调整/归档) — reject,confirm,confirm,adjust,submit,confirm,confirm,archive,approve,approve,approve,approve,confirm,dispute,confirm,generate +PASS T34 审核记录不可篡改 — {"error":"操作失败:审核记录是流程留痕,不能修改或删除。","code":"VALIDATION_ERROR","declaredCode":"KPI_REVIEW_IMMUTABLE","object":"kpi_review_record"} +PASS T35 复制上一版本:流程节点 4、指标下达 18 已复制 — steps=4 pis=18 {"object":"kpi_plan","id":"FUcyHYEqQkPKXTwE","record":{"id":"FUcyHYEqQkPKXTwE"," +PASS T36 两节点流程(填报→人力审核):提交直达人力审核,通过即已通过 — after submit=hr_reviewing final=approved +PASS T37 逆向指标:目标 12 实际 10 → 完成率 120%,封顶 110% → 得分 55(权重 50) — rate=120 score_rate=110 score=55 +PASS T38 发布第三版方案(市场部已配分管领导)生成 5 张填报单 — {"object":"kpi_plan","id":"vobGz2SXdJ7xzwAi","record":{"id":"vobGz2SXdJ7xzwAi"," sheets=5 +PASS T39 发布按方案配置写入动态共享规则,条数与方案配置精确相符,元数据零改动 — rules=29 expected=29 +PASS T39b 规则条件按方案隔离:每条规则的条件里都带本方案 id — 29 条 +PASS T40 规则求值把单元共享展开成逐人记录共享行(市场部填报单 → 用户 A) — shares=10 +PASS T41 部门填报人员只看到本部门(市场部)填报单,含本次新发布的那张 — 3 张:2026 年 9 月 月度考核 · 市场部 | 2026 年 10 月 月度考核 · 市场部 | 2026 年 8 月 月度考核 · 市场部 +PASS T42 打开本部门以外(华东分公司)的填报单被拒绝 — status=404 {"error":"Record aqb2KjB-ceDe7CFP not found in kpi_entry_sheet","code":"RECORD_NOT_FOUND","object":" +PASS T43 部门填报人员能填写实际值并提交本部门填报单 — status=branch_checking score=103 {"object":"kpi_entry_sheet","id":"Z3Tl5rlPXx_G8FVT","record":{"id":"Z3Tl5rlPXx_G8FVT","created_at":" +PASS T44 分公司核对人员只看到本分公司(华东)的核对任务 — 2 条:2026 年 8 月 月度考核 · 市场部 · 华东分公司 核对 | 2026 年 10 月 月度考核 · 市场部 · 华东分公司 核对 +PASS T45 分公司核对人员能确认本分公司的核对任务 — {"object":"kpi_check_task","id":"yaDbkaBS3t2xtGjd","record":{"id":"yaDbkaBS3t2xtGjd","created_at":"2026-09-02T18:37:48.0 +PASS T46 分管领导只看到分管主体(市场部)的填报单,看不到运营部 — 1 张:2026 年 10 月 月度考核 · 市场部 +PASS T47 分管领导能在「领导审批中」执行审核通过 — beforeLeader=leader_approving? status=approved {"object":"kpi_entry_sheet","id":"Z3Tl5rlPXx_G8FVT","record":{"id":"Z3Tl5rlPXx_G8FVT","created_at":"2026-09-02T18:37:46. +PASS T48 已通过填报单批准加减分后,部门结果立即等于新的最终得分 — before=103 after=106 sheet_total=106 +PASS T49 否决加减分后立即重算,被否决的分不计入部门结果 — after_reject=106 expected=106 sheet_total=106 {"object":"kpi_bonus","id":"nHyutqigPselMkGu","record":{"id":"nHyutqigPselMkGu", +PASS T50 不存在方案发布之外来源的 KPI 共享规则(静态规则文件已删除,也没有旧的按单元规则) — 77 条,示例 kpi_pvobgz2sxdj7x_qxpeji_result_bu_market:admin | kpi_piz8wrxlurbl6_17ug4yf_adjust_bu_south:admin +PASS T51 填报人员看到本部门结果与本人到人结果,看不到他人的到人结果 — dept=true own=true other=false | A 可见 3 条:person,department/bu_market,department/bu_market +PASS T53 分管领导看到所分管主体(市场部)的部门结果,看不到非分管主体的结果 — 2 条:leader,department/bu_market +PASS T54 换人后新方案只为新任分管领导建规则,老领导没有新方案的规则 — 新方案 27 条规则 +PASS T55 换掉分管领导:老领导看不到新方案的填报单,历史方案的仍看得到 — 老领导可见 1 张:2026 年 10 月 月度考核 · 市场部 +PASS T56 新任分管领导看到新方案的市场部填报单 — 新领导可见 1 张:2026 年 11 月 月度考核 · 市场部 +PASS T57 方案关闭后填报单 / 核对任务 / 调整的共享降为只读(留读、去写) — 29 条规则,其中可编辑 0 条 +PASS T52 演示种子组织单元在启动时被补上组织归属(与 Setup 里新建的单元一致) — bu_east:org_mtkfufl3h3opu052 bu_hq:org_mtkfufl3h3opu052 bu_hr:org_mtkfufl3h3opu052 bu_market:org_mtkfufl3h3opu052 bu_north:org_mtkfufl3h3opu052 bu_ops:org_mtkfufl3h3opu052 bu_south:org_mtkfufl3h3opu052 bu_west:org_mtkfufl3h3opu052 +PASS T58 部门填报人员登记加减分被拒绝(权限集不再声明新建权限) — status=403 拒绝层=权限集 {"error":"You do not have permission to perform this action. Contact your administrator if you need access.","code":"PERMISSION_DENIED","obj +PASS T59 非人力负责人岗位批准加减分被拒绝(第 10 章第 6 项:人力负责人审批) — status=422 {"error":"审批加减分失败:只有人力负责人岗位可以审批加减分。请转交人力负责人处理。","code":"VALIDATION_ERROR","declaredCode":"KPI_BONUS_APPROVE_POSITION","object":"kpi_bonus"} +PASS T60 人力负责人岗位批准加减分成功,最终得分随之重算 — before=106 after=108 bonus_total=5 +PASS T61 同周期已有生效版本时发布被拒并点名该版本;关闭后可发布(第 10 章第 10 项) — dup=422 {"error":"发布失败:该考核周期已有生效版本「2026 年 11 月 月度考核」。请先关闭该版本,或改用复制出的新版本替换。","code":"VALIDATION_ERROR","declaredCode":"KPI_PLAN_PERIOD_CONFLICT","object":"kpi_plan"} | after_close=200 status=published +PASS T62 结果调整落地后该行标记「已调整」= 是、调整类型 = 计算结果,未调整行保持否 — adjusted: is_adjusted=true type=result final=12.5 | untouched: is_adjusted=false {"object":"kpi_adjustment","id":"uS21LjGDIZ5PSmD9","record":{"id":"uS21LjGDIZ5PS +PASS T63 考核结果的计算明细 JSON 逐指标带 is_adjusted,被调整的那条为 true — lines=3 adjusted={"plan_indicator":"9XF-OUURYgyL99km","indicator_name":"客户满意度","score_rate":90,"score":12.5,"is_adjusted":true,"adjust_type_applied":"result"} +PASS T64 分公司核对人员直接修改填报明细实际值被拒绝(第 10 章第 13 项:只能确认 / 提出争议) — status=403 {"error":"You do not have permission to perform this action. Contact your administrator if you need access.","code":"PERMISSION_DENIED","obj +PASS T65 人力审核账号登记加减分成功(岗位规则已授予记录级编辑权) — share=true register=201 {"object":"kpi_bonus","id":"5t_S0omXRCdAOG71","record":{"id":"5t_S0omXRCdAOG71","created_a +PASS T66 人力审核账号批准自己登记的加减分被拒绝(第 10 章第 6 项:人力负责人审批) — status=422 {"error":"审批加减分失败:只有人力负责人岗位可以审批加减分。请转交人力负责人处理。","code":"VALIDATION_ERROR","declaredCode":"KPI_BONUS_APPROVE_POSITION","object":"kpi_bonus"} +PASS T67 人力负责人账号批准加减分成功,填报单最终得分随之重算 — approved_by=true before=103 after=105 +PASS T68 人力负责人账号登记加减分被拒绝(权限集不声明新建权限) — status=403 拒绝层=权限集 {"error":"You do not have permission to perform this action. Contact your administrator if you need access.","code":"PERMISSION_DENIED","obj +PASS T69 人力审核账号在人力审核节点驳回与审核通过均成功(真实岗位账号推进流程) — reject=200→draft approve=200→approved +PASS T70 方案关闭后人力岗位对该方案填报单降为只读(留读、去写) — 岗位规则 4 条全部 read=true read=200 write=403 +PASS T71 组织为空的已发布方案仍阻断同周期发布(租户判定不靠查询过滤) — seed_org=null seed_status=published dup=422 {"error":"发布失败:该考核周期已有生效版本「2026 年 8 月 月度考核」。请先关闭该版本,或改用复制出的新版本替换。","code":"VALIDATION_ERROR","declaredCode":"KPI_PLAN_PERIOD_CONFLICT","object":"kpi_p +{"passed":73,"failed":0} +``` diff --git a/scripts/e2e-flow.mjs b/scripts/e2e-flow.mjs index cec2aae..374b902 100644 --- a/scripts/e2e-flow.mjs +++ b/scripts/e2e-flow.mjs @@ -222,8 +222,9 @@ log('T38', '发布第三版方案(市场部已配分管领导)生成 5 张填报 const rulesOf = async (planId) => (await list('sys_sharing_rule', '?limit=500')).filter((x) => String(x.criteria_json ?? '').includes(planId) && x.active !== false); const kpiRules = await rulesOf(plan3.id); // 推导条数(不是下限):5 个参与主体 × 4 类(填报单 / 核对任务 / 调整 / 结果)= 20, -// 市场部配了分管领导 → +3(填报单、该主体结果、本人结果),2 条到人分工 → +2,合计 25。 -const EXPECTED_RULES = 5 * 4 + 3 + 2; +// 市场部配了分管领导 → +3(填报单、该主体结果、本人结果),2 条到人分工 → +2, +// 人力岗位 2 个 × 2 类(填报单 / 调整)→ +4,合计 29。 +const EXPECTED_RULES = 5 * 4 + 3 + 2 + 2 * 2; log('T39', '发布按方案配置写入动态共享规则,条数与方案配置精确相符,元数据零改动', kpiRules.length === EXPECTED_RULES && kpiRules.some((x) => x.object_name === 'kpi_entry_sheet' && x.recipient_type === 'unit_and_subordinates' && x.recipient_id === 'bu_market') && kpiRules.some((x) => x.recipient_type === 'user' && x.recipient_id === U.c.id && x.object_name === 'kpi_entry_sheet'), `rules=${kpiRules.length} expected=${EXPECTED_RULES}`); log('T39b', '规则条件按方案隔离:每条规则的条件里都带本方案 id', kpiRules.length > 0 && kpiRules.every((x) => { try { return JSON.parse(x.criteria_json).plan === plan3.id; } catch { return false; } }), `${kpiRules.length} 条`); const sheetShared = await waitUntil('市场部填报单共享给 A', async () => { @@ -365,6 +366,160 @@ const seededIds = ['bu_hq', 'bu_market', 'bu_ops', 'bu_hr', 'bu_east', 'bu_south const seeded = demoUnits.filter((u) => seededIds.includes(u.id)); log('T52', '演示种子组织单元在启动时被补上组织归属(与 Setup 里新建的单元一致)', seeded.length === seededIds.length && seeded.every((u) => !!u.organization_id), seeded.map((u) => `${u.id}:${u.organization_id ?? 'null'}`).join(' ')); + +// ── 13 已确认设计口径(《设计方案》V1.0 第 10 章第 6 / 10 / 13 / 14 项)──────────────── +// 岗位判定要用真实的非管理员账号验证:管理员(组织 owner)对所有岗位判定放行。 +// 这里用部门填报人员 A —— 它对 plan4 的市场部填报单有记录级编辑权(方案发布写入的共享 +// 规则),所以能走到 hook,岗位判定是它被拒的**唯一**原因。 +// 人力审核 / 人力负责人账号本身走不到这一步:填报单 OWD 为 private,共享规则只写给参与 +// 主体、分公司与分管领导,人力岗位没有记录级写权限(现象与最小复现见 docs/05 问题记录 #1)。 +await signIn(EMAIL.a); +await waitUntil('新方案的市场部填报单共享给 A', async () => (await list('kpi_entry_sheet', '?limit=100')).some((x) => x.id === market4.id)); +r = await post('kpi_bonus', { sheet: market4.id, title: '越权登记', bonus_type: 'add', points: 1, reason: '岗位反例' }); +asAdmin(); +// 声明与执行一致后,部门填报人员的权限集不再声明加减分的新建权限 —— 拒绝发生在权限层, +// 比业务规则更早;业务规则的文案由 T65 / T66 的真实人力岗位账号与单元测试取证。 +log('T58', '部门填报人员登记加减分被拒绝(权限集不再声明新建权限)', r.status >= 400, `status=${r.status} 拒绝层=${msg(r).includes('PERMISSION_DENIED') ? '权限集' : '业务规则'} ${msg(r).slice(0, 140)}`); + +// 审批岗位反例:同一个账号对已存在的加减分点「批准」,被岗位判定拒绝 +r = await post('kpi_bonus', { sheet: market4.id, title: '安全生产先进', bonus_type: 'add', points: 2, reason: '季度评优' }); +const bonus2 = one(r); +await signIn(EMAIL.a); +const selfApprove = await patch('kpi_bonus', bonus2.id, { status: 'approved' }); +asAdmin(); +log('T59', '非人力负责人岗位批准加减分被拒绝(第 10 章第 6 项:人力负责人审批)', selfApprove.status >= 400 && msg(selfApprove).includes('只有人力负责人岗位可以审批加减分'), `status=${selfApprove.status} ${msg(selfApprove)}`); + +// 人力负责人岗位(管理员等同该岗位)批准 → 成功,填报单最终得分随之重算 +const before60 = await get('kpi_entry_sheet', market3.id); +r = await post('kpi_bonus', { sheet: market3.id, title: '重点工作表彰', bonus_type: 'add', points: 2, reason: '按第 6 项由人力负责人审批' }); +const bonusHead = one(r); +r = await patch('kpi_bonus', bonusHead.id, { status: 'approved' }); +const after60 = await get('kpi_entry_sheet', market3.id); +const bonusRow = await get('kpi_bonus', bonusHead.id); +log('T60', '人力负责人岗位批准加减分成功,最终得分随之重算', r.status < 300 && bonusRow.status === 'approved' && !!bonusRow.approved_by && Number(after60.bonus_total) === Number(before60.bonus_total) + 2 && Number(after60.total_score) === Number(before60.total_score) + 2, `before=${before60.total_score} after=${after60.total_score} bonus_total=${after60.bonus_total}`); + +// 同一周期只有一个生效版本(第 10 章第 10 项):plan4(2026 年 11 月)已发布 +r = await post('kpi_plan', { name: '2026 年 11 月 月度考核(重复周期)', period_type: 'month', year: 2026, period_no: 11, period_start: '2026-11-01', period_end: '2026-11-30', based_on: plan.id }); +const planDup = one(r); +const dupPublish = await patch('kpi_plan', planDup.id, { status: 'published' }); +await patch('kpi_plan', plan4.id, { status: 'closed' }); +const afterClose = await patch('kpi_plan', planDup.id, { status: 'published' }); +const planDupRow = await get('kpi_plan', planDup.id); +log('T61', '同周期已有生效版本时发布被拒并点名该版本;关闭后可发布(第 10 章第 10 项)', dupPublish.status >= 400 && msg(dupPublish).includes('该考核周期已有生效版本「2026 年 11 月 月度考核」') && afterClose.status < 300 && planDupRow.status === 'published', `dup=${dupPublish.status} ${msg(dupPublish).slice(0, 160)} | after_close=${afterClose.status} status=${planDupRow.status}`); + +// 结果调整落地后标记「已调整」(第 10 章第 14 项 = A) +const m3lines = await list('kpi_entry_line', `?sheet=${market3.id}&limit=50`); +const adjLine = m3lines.find((l) => l.indicator_name === '客户满意度') ?? m3lines[0]; +const keptLine = m3lines.find((l) => l.id !== adjLine.id); +r = await post('kpi_adjustment', { line: adjLine.id, adjust_type: 'result', new_value: 12.5, reason: '复核后按第 14 项调整该指标得分' }); +const adj2 = one(r); +await patch('kpi_adjustment', adj2.id, { status: 'submitted' }); +r = await patch('kpi_adjustment', adj2.id, { status: 'approved' }); +const adjustedLine = await get('kpi_entry_line', adjLine.id); +const untouchedLine = await get('kpi_entry_line', keptLine.id); +const truthy = (v) => v === true || v === 1 || v === 'true'; +log('T62', '结果调整落地后该行标记「已调整」= 是、调整类型 = 计算结果,未调整行保持否', truthy(adjustedLine.is_adjusted) && adjustedLine.adjust_type_applied === 'result' && Number(adjustedLine.adjusted_score) === 12.5 && Number(adjustedLine.final_score) === 12.5 && !truthy(untouchedLine.is_adjusted) && !untouchedLine.adjust_type_applied, `adjusted: is_adjusted=${adjustedLine.is_adjusted} type=${adjustedLine.adjust_type_applied} final=${adjustedLine.final_score} | untouched: is_adjusted=${untouchedLine.is_adjusted} ${msg(r).slice(0, 80)}`); + +const resAfterAdjust = await list('kpi_result', `?plan=${plan3.id}&limit=100`); +const deptRow3 = resAfterAdjust.find((x) => x.dimension === 'department' && x.unit === 'bu_market'); +const parseJson = (v) => { if (!v) return null; if (typeof v === 'object') return v; try { return JSON.parse(v); } catch { return null; } }; +const bd = parseJson(deptRow3?.breakdown); +const bdLine = (bd?.lines ?? []).find((l) => String(l.plan_indicator) === String(adjLine.plan_indicator)); +const bdKept = (bd?.lines ?? []).find((l) => String(l.plan_indicator) === String(keptLine.plan_indicator)); +log('T63', '考核结果的计算明细 JSON 逐指标带 is_adjusted,被调整的那条为 true', !!bdLine && bdLine.is_adjusted === true && bdLine.adjust_type_applied === 'result' && !!bdKept && bdKept.is_adjusted === false, `lines=${(bd?.lines ?? []).length} adjusted=${JSON.stringify(bdLine ?? null)}`); + +// 分公司核对人员只能确认 / 提出争议,不能改值(第 10 章第 13 项 = A) +await signIn(EMAIL.b); +r = await patch('kpi_entry_line', adjLine.id, { actual_value: 1 }); +asAdmin(); +const stillLine = await get('kpi_entry_line', adjLine.id); +log('T64', '分公司核对人员直接修改填报明细实际值被拒绝(第 10 章第 13 项:只能确认 / 提出争议)', r.status >= 400 && Number(stillLine.actual_value) === Number(adjustedLine.actual_value), `status=${r.status} ${msg(r).slice(0, 140)}`); + + +// ── 14 人力岗位的记录级写范围(调度员 2026-09-02 裁定,选项 A)──────────────────────── +// 方案发布时为人力审核 / 人力负责人两个岗位建按方案的共享规则(收件方类型 = position), +// 于是这两个岗位对本方案的填报单与数据调整可编辑 —— 加减分是填报单的主从子记录,记录级 +// 判定看主记录,所以登记 / 审批加减分随之成立。以下全部用**真实岗位账号**取证,不用管理员。 +const EMAIL_HR = { reviewer: `hr.reviewer.${STAMP}@kpi.test`, head: `hr.head.${STAMP}@kpi.test` }; +for (const email of Object.values(EMAIL_HR)) await signUp('E2E HR', email); +asAdmin(); +const usersHr = await list('sys_user', '?limit=200'); +const HR = usersHr.find((u) => u.email === EMAIL_HR.reviewer); +const HEAD = usersHr.find((u) => u.email === EMAIL_HR.head); +await post('sys_user_position', { user_id: HR.id, position: 'kpi_hr_reviewer' }); +await post('sys_user_position', { user_id: HEAD.id, position: 'kpi_hr_head' }); + +// 新方案(两节点流程:部门填报 → 人力审核),让人力审核节点由人力审核账号亲自推进 +r = await post('kpi_plan', { name: '2026 年 12 月 月度考核', period_type: 'month', year: 2026, period_no: 12, period_start: '2026-12-01', period_end: '2026-12-31', based_on: plan.id }); +const plan5 = one(r); +const steps5 = await list('kpi_plan_step', `?plan=${plan5.id}`); +await call('DELETE', `/data/kpi_plan_step/${steps5.find((x) => x.step_type === 'branch_check').id}`); +await call('DELETE', `/data/kpi_plan_step/${steps5.find((x) => x.step_type === 'leader_approve').id}`); +await patch('kpi_plan', plan5.id, { status: 'published' }); +const sheets5 = await list('kpi_entry_sheet', `?plan=${plan5.id}&limit=100`); +const market5 = sheets5.find((x) => x.name.includes('市场部')); +const lines5 = await list('kpi_entry_line', `?sheet=${market5.id}&limit=50`); +for (const l of lines5) await patch('kpi_entry_line', l.id, { actual_value: l.indicator_name === '客户满意度' ? 87.3 : (l.indicator_name === '利润完成率' ? 180 : 1320) }); +await patch('kpi_entry_sheet', market5.id, { pending_action: 'submit' }); +const hrShared = await waitUntil('本方案填报单共享给人力审核岗位持有人', async () => { + const rowsNow = await list('sys_record_share', '?limit=1000'); + return rowsNow.some((x) => x.object_name === 'kpi_entry_sheet' && x.record_id === market5.id && x.recipient_id === HR.id && x.access_level === 'edit'); +}); + +// 人力审核账号:登记加减分成功 +await signIn(EMAIL_HR.reviewer); +r = await post('kpi_bonus', { sheet: market5.id, title: '专项攻关表彰', bonus_type: 'add', points: 2, reason: '按第 6 项由人力审核登记' }); +const hrBonus = one(r); +const registered = r.status < 300 && !!hrBonus?.id; +// 同一账号点批准 → 被岗位判定拒绝(职责分离) +const selfApprove2 = await patch('kpi_bonus', hrBonus?.id ?? 'none', { status: 'approved' }); +asAdmin(); +log('T65', '人力审核账号登记加减分成功(岗位规则已授予记录级编辑权)', hrShared && registered && (await get('kpi_bonus', hrBonus.id)).status === 'draft', `share=${hrShared} register=${r.status} ${msg(r).slice(0, 90)}`); +log('T66', '人力审核账号批准自己登记的加减分被拒绝(第 10 章第 6 项:人力负责人审批)', selfApprove2.status >= 400 && msg(selfApprove2).includes('只有人力负责人岗位可以审批加减分'), `status=${selfApprove2.status} ${msg(selfApprove2)}`); + +// 人力负责人账号:批准成功,填报单最终得分随之重算 +const before65 = await get('kpi_entry_sheet', market5.id); +await signIn(EMAIL_HR.head); +r = await patch('kpi_bonus', hrBonus.id, { status: 'approved' }); +const headRegister = await post('kpi_bonus', { sheet: market5.id, title: '人力负责人越权登记', bonus_type: 'add', points: 1, reason: '岗位反例' }); +asAdmin(); +const after65 = await get('kpi_entry_sheet', market5.id); +const hrBonusRow = await get('kpi_bonus', hrBonus.id); +log('T67', '人力负责人账号批准加减分成功,填报单最终得分随之重算', r.status < 300 && hrBonusRow.status === 'approved' && String(hrBonusRow.approved_by) === String(HEAD.id) && Number(after65.bonus_total) === Number(before65.bonus_total ?? 0) + 2 && Number(after65.total_score) === Number(before65.total_score) + 2, `approved_by=${hrBonusRow.approved_by === HEAD.id} before=${before65.total_score} after=${after65.total_score}`); +log('T68', '人力负责人账号登记加减分被拒绝(权限集不声明新建权限)', headRegister.status >= 400, `status=${headRegister.status} 拒绝层=${msg(headRegister).includes('PERMISSION_DENIED') ? '权限集' : '业务规则'} ${msg(headRegister).slice(0, 140)}`); + +// 人力审核账号推进人力审核节点:驳回 → 重新提交 → 审核通过 +await signIn(EMAIL_HR.reviewer); +const rejectRes = await patch('kpi_entry_sheet', market5.id, { pending_action: 'reject', action_reason: '数据口径请复核' }); +asAdmin(); +const afterReject5 = await get('kpi_entry_sheet', market5.id); +await patch('kpi_entry_sheet', market5.id, { pending_action: 'submit' }); +await signIn(EMAIL_HR.reviewer); +const approveRes = await patch('kpi_entry_sheet', market5.id, { pending_action: 'approve' }); +asAdmin(); +const afterApprove5 = await get('kpi_entry_sheet', market5.id); +log('T69', '人力审核账号在人力审核节点驳回与审核通过均成功(真实岗位账号推进流程)', rejectRes.status < 300 && afterReject5.status === 'draft' && afterReject5.last_reject_reason === '数据口径请复核' && approveRes.status < 300 && afterApprove5.status === 'approved', `reject=${rejectRes.status}→${afterReject5.status} approve=${approveRes.status}→${afterApprove5.status}`); + +// 方案关闭:人力岗位对该方案的填报单留读、去写 +await patch('kpi_plan', plan5.id, { status: 'closed' }); +await waitUntil('已关闭方案的人力岗位规则降为只读', async () => (await rulesOf(plan5.id)).filter((x) => x.recipient_type === 'position').every((x) => x.access_level === 'read'), 15000); +const hrRulesClosed = (await rulesOf(plan5.id)).filter((x) => x.recipient_type === 'position'); +await signIn(EMAIL_HR.reviewer); +const readAfterClose = await call('GET', `/data/kpi_entry_sheet/${market5.id}`); +const writeAfterClose = await post('kpi_bonus', { sheet: market5.id, title: '关闭后登记', bonus_type: 'add', points: 1, reason: '只读反例' }); +asAdmin(); +log('T70', '方案关闭后人力岗位对该方案填报单降为只读(留读、去写)', hrRulesClosed.length === 4 && hrRulesClosed.every((x) => x.access_level === 'read') && readAfterClose.status === 200 && writeAfterClose.status >= 400, `岗位规则 ${hrRulesClosed.length} 条全部 read=${hrRulesClosed.every((x) => x.access_level === 'read')} read=${readAfterClose.status} write=${writeAfterClose.status}`); + + +// 组织为空的已发布方案(种子 / 导入写进来的行)同样是生效版本 —— 候选不按组织过滤, +// 否则这类行会在判定之前被查询滤掉,同周期唯一性变成「看数据从哪来」的漏判。 +const seedPlan = await get('kpi_plan', plan.id); +r = await post('kpi_plan', { name: '2026 年 8 月 月度考核(重复周期)', period_type: 'month', year: 2026, period_no: 8, period_start: '2026-08-01', period_end: '2026-08-31', based_on: plan.id }); +const planDup8 = one(r); +const dup8 = await patch('kpi_plan', planDup8.id, { status: 'published' }); +const planDup8Row = await get('kpi_plan', planDup8.id); +log('T71', '组织为空的已发布方案仍阻断同周期发布(租户判定不靠查询过滤)', !seedPlan.organization_id && seedPlan.status === 'published' && dup8.status >= 400 && msg(dup8).includes('该考核周期已有生效版本「2026 年 8 月 月度考核」') && planDup8Row.status === 'draft', `seed_org=${seedPlan.organization_id ?? 'null'} seed_status=${seedPlan.status} dup=${dup8.status} ${msg(dup8).slice(0, 150)}`); + const summary = { passed: results.filter((x) => x.ok).length, failed: results.filter((x) => !x.ok).length }; console.log(JSON.stringify(summary)); import('node:fs').then((fs) => fs.writeFileSync(process.argv[2] ?? '/dev/null', JSON.stringify(results, null, 2))); diff --git a/src/hooks/adjustment.hook.ts b/src/hooks/adjustment.hook.ts index 179c16a..fc986c6 100644 --- a/src/hooks/adjustment.hook.ts +++ b/src/hooks/adjustment.hook.ts @@ -3,6 +3,27 @@ import { actorId, fail, findById, hasPosition, isSystem, merged, nowIso, recordI import { regenerateResults } from '../services/results-service.js'; import { provisionPlanSharing } from '../services/sharing-service.js'; +/** + * 调整落地时写给填报明细的补丁 —— 纯函数,「已调整」标记口径的唯一真值(单元测试点)。 + * + * 《设计方案》V1.0 第 10 章第 14 项(客户 2026-09-02 选 A):结果调整改某指标的得分并标记 + * 「已调整」。源数据调整同样标 —— 两种调整都让这一行的数字不再是原始填报值,列表与汇总 + * 明细都应当看得出来;`adjust_type_applied` 区分标的是哪一种。 + */ +export function adjustmentLinePatch( + adjustType: string | null | undefined, + newValue: number, + adjustmentId: string | null, +): Record { + const isResult = adjustType === 'result'; + return { + last_adjustment: adjustmentId, + is_adjusted: true, + adjust_type_applied: isResult ? 'result' : 'source', + ...(isResult ? { adjusted_score: newValue } : { actual_value: newValue }), + }; +} + /** 数据调整申请:插入时记录调整前值;审批时盖章;批准后落地到明细并重算。 */ export const AdjustmentHook: Hook = { name: 'kpi_adjustment_rules', @@ -52,10 +73,7 @@ export const AdjustmentHook: Hook = { const newValue = toNumber(row.new_value); if (newValue === null) fail('批准调整申请失败:调整后值不能为空。请填写调整后值。', 'KPI_ADJ_VALUE'); const id = recordId(ctx); - const patch: Record = { last_adjustment: id }; - if (row.adjust_type === 'result') patch.adjusted_score = newValue; - else patch.actual_value = newValue; - await api.object('kpi_entry_line').updateById(String(line.id), patch); + await api.object('kpi_entry_line').updateById(String(line.id), adjustmentLinePatch(row.adjust_type, newValue, id)); input.applied_at = nowIso(); await writeReview(api, { sheet: String(line.sheet), diff --git a/src/hooks/bonus.hook.ts b/src/hooks/bonus.hook.ts index 0765cd8..82213ff 100644 --- a/src/hooks/bonus.hook.ts +++ b/src/hooks/bonus.hook.ts @@ -3,7 +3,56 @@ import { actorId, fail, findById, hasPosition, isSystem, merged, nowIso, sys, to import { regenerateResults } from '../services/results-service.js'; import { provisionPlanSharing } from '../services/sharing-service.js'; -/** 加减分:计入分值带符号;审批盖章需人力岗位;归档后锁定。 */ +/** + * 加减分的岗位职责分离(《设计方案》V1.0 第 10 章第 6 项:加减分由**人力审核**提出、 + * **人力负责人**审批)。 + * + * 登记与审批要求的是**两个不同**的岗位,所以「人力审核不能审批自己登记的加减分」「人力 + * 负责人不能登记」这两条都由岗位判定本身兑现,不需要再比对经办人:只持有人力审核的账号 + * 走到审批一步就被拒,只持有人力负责人的账号走到登记一步就被拒。 + * + * 岗位判定沿用 `hasPosition` 的口径:`kpi_admin` 与组织 owner / admin 视同考核系统管理员 + * 放行(需求 §2 六类角色里管理员看全部、做全部),系统上下文放行。 + */ +export interface BonusPositionRule { + /** 该动作要求的岗位机器名。 */ + position: string; + /** 拒绝时的三段式提示(什么失败 + 原因 + 下一步)。 */ + message: string; + code: string; + /** 是否是审批动作(要盖审批人 / 审批时间)。 */ + decision: boolean; +} + +export const BONUS_REGISTER_RULE: BonusPositionRule = { + position: 'kpi_hr_reviewer', + message: '登记加减分失败:只有人力审核岗位可以登记加减分。请联系人力审核办理。', + code: 'KPI_BONUS_REGISTER_POSITION', + decision: false, +}; +export const BONUS_APPROVE_RULE: BonusPositionRule = { + position: 'kpi_hr_head', + message: '审批加减分失败:只有人力负责人岗位可以审批加减分。请转交人力负责人处理。', + code: 'KPI_BONUS_APPROVE_POSITION', + decision: true, +}; + +/** + * 本次写入需要哪个岗位 —— 纯函数,岗位分离口径的唯一真值(单元测试点)。 + * 登记 = 新建一条加减分;审批 = 把状态改成已批准 / 已否决;其余改动不受岗位约束。 + */ +export function requiredBonusPosition( + event: string, + input: Record, + prev: Record, +): BonusPositionRule | null { + if (event === 'beforeInsert') return BONUS_REGISTER_RULE; + if (event !== 'beforeUpdate') return null; + if (!('status' in input) || input.status === prev.status) return null; + return input.status === 'approved' || input.status === 'rejected' ? BONUS_APPROVE_RULE : null; +} + +/** 加减分:计入分值带符号;登记 / 审批按岗位分离把关;归档后锁定。 */ export const BonusHook: Hook = { name: 'kpi_bonus_rules', label: '加减分规则', @@ -23,10 +72,10 @@ export const BonusHook: Hook = { const points = toNumber(row.points) ?? 0; input.signed_points = row.bonus_type === 'deduct' ? -points : points; - if (ctx.event === 'beforeUpdate' && 'status' in input && input.status !== prev.status) { - if (input.status === 'approved' || input.status === 'rejected') { - const ok = (await hasPosition(ctx, 'kpi_hr_reviewer')) || (await hasPosition(ctx, 'kpi_hr_head')); - if (!ok) fail('审批加减分失败:该操作需要「人力审核」或「人力负责人」岗位。如需处理,请联系管理员分配岗位。', 'KPI_BONUS_POSITION'); + const rule = requiredBonusPosition(ctx.event, input, prev); + if (rule) { + if (!(await hasPosition(ctx, rule.position))) fail(rule.message, rule.code); + if (rule.decision) { input.approved_by = actorId(ctx); input.approved_at = nowIso(); } diff --git a/src/hooks/plan.hook.ts b/src/hooks/plan.hook.ts index 04cbe1c..dae1c9f 100644 --- a/src/hooks/plan.hook.ts +++ b/src/hooks/plan.hook.ts @@ -6,6 +6,68 @@ import { loadPlanSteps } from './sheet.hook.js'; const FROZEN_MESSAGE = '修改失败:方案已发布,配置已冻结。如需调整指标、权重、目标或流程,请新建方案版本。'; +/** + * 同一考核周期只有一个生效版本(《设计方案》V1.0 第 5.2 节、第 10 章第 10 项)。 + * + * 「同一周期」= `period_type` + `year` + `period_no` 三者相同;「生效版本」只指状态为 + * **已发布**的方案 —— 已关闭 / 已归档的是历史版本,不阻断新版本发布,草稿本来就还没生效 + * (草稿与已发布并存正是第 10 项确认的口径)。 + */ +export const PLAN_PERIOD_CONFLICT_CODE = 'KPI_PLAN_PERIOD_CONFLICT'; + +export interface PlanPeriodKey { + id?: string | null; + period_type?: unknown; + year?: unknown; + period_no?: unknown; + name?: unknown; + status?: unknown; + /** 租户隔离锚点(平台注入的组织归属)。 */ + organization_id?: unknown; +} + +/** 两条方案是否落在同一个考核周期(期数按数值比较,避免 "1" 与 1 判成不同周期)。 */ +export function samePeriod(a: PlanPeriodKey, b: PlanPeriodKey): boolean { + return ( + String(a.period_type ?? '') === String(b.period_type ?? '') && + toNumber(a.year) === toNumber(b.year) && + toNumber(a.period_no) === toNumber(b.period_no) + ); +} + +/** + * 两条方案是否属于同一租户。 + * + * 只在**两边都带**组织归属时才比较:带了就必须相等(多租户下别家的方案挡不住本家的发布); + * 有一边没有(单租户部署、或调用方没把该列读出来)就退回只按周期判定 —— 宁可多挡一次让人 + * 看见,也不因为缺一个字段而**静默**放行一个本该被拒的发布。 + */ +function sameTenant(a: PlanPeriodKey, b: PlanPeriodKey): boolean { + const x = a.organization_id ? String(a.organization_id) : ''; + const y = b.organization_id ? String(b.organization_id) : ''; + if (!x || !y) return true; + return x === y; +} + +/** + * 在候选方案里找出挡住本次发布的生效版本 —— 纯函数,唯一性口径的唯一真值(单元测试点)。 + * 候选由调用方按「已发布」+ 本租户筛出;这里再判一次状态与租户,保证口径不依赖调用方的 + * 查询条件(查询条件是性能优化,判定不能只靠它)。 + */ +export function findPublishedConflict(candidates: PlanPeriodKey[], self: PlanPeriodKey): PlanPeriodKey | null { + for (const c of candidates ?? []) { + if (String(c.status ?? '') !== 'published') continue; + if (self.id && String(c.id ?? '') === String(self.id)) continue; + if (!sameTenant(c, self)) continue; + if (samePeriod(c, self)) return c; + } + return null; +} + +export function planPeriodConflictMessage(name: string): string { + return `发布失败:该考核周期已有生效版本「${name}」。请先关闭该版本,或改用复制出的新版本替换。`; +} + async function assertPlanEditable(ctx: HookContext, planId: string | null | undefined): Promise { if (isSystem(ctx) || !planId) return; const plan = await findById(sys(ctx), 'kpi_plan', planId); @@ -112,6 +174,21 @@ export const PlanPublishHook: Hook = { // 平台校验拒绝时留下半截改动。 if (!(prev.status === 'draft' && input.status === 'published')) return; + // 同周期唯一生效版本:先于完整性检查判定 —— 这一条与方案内部配置无关,先说清楚 + // 「这个周期已经有生效版本了」比让作者先去补权重更省事。 + // + // 候选只按状态取,**不**把组织放进 `where`:等值条件不匹配 NULL,而种子 / 导入写进来的 + // 方案行组织就是空的 —— 那样的生效版本会在判定之前就被查询滤掉,同周期唯一性变成 + // 「看数据从哪来」的漏判。租户判定全部交给 `findPublishedConflict` 里的 `sameTenant`: + // 两边都有组织才比较,任一边缺失就退回只按周期判定(缺字段不得变成静默放行)。 + // 本方案自己的组织从**落库的行**上取,不从 ctx.previous 取 —— 那里未必带这一列。 + const period = merged>(ctx); + const self = await findById(api, 'kpi_plan', id); + const organizationId = self?.organization_id ? String(self.organization_id) : null; + const published = await api.object('kpi_plan').find({ where: { status: 'published' } }); + const conflict = findPublishedConflict(published as PlanPeriodKey[], { ...period, id, organization_id: organizationId }); + if (conflict) fail(planPeriodConflictMessage(String(conflict.name ?? '')), PLAN_PERIOD_CONFLICT_CODE); + const problems: string[] = []; const steps = await loadPlanSteps(api, id); problems.push(...validateSteps(steps).map((m) => `流程节点:${m}`)); diff --git a/src/lib/aggregate.ts b/src/lib/aggregate.ts index 6a4aaf2..71ff2f1 100644 --- a/src/lib/aggregate.ts +++ b/src/lib/aggregate.ts @@ -1,15 +1,29 @@ /** * 结果汇总引擎 —— 四维汇总口径的唯一真值(蓝图 C-6)。纯函数。 + * 口径依据《设计方案》V1.0 第 8 章表 6(客户 2026-09-02 确认),口径改动只改本文件。 * * - 部门 / 分公司:填报单最终得分(指标得分合计 + 已批准加减分),按主体类型分维度,组内排名; - * - 到人:个人得分 = Σ(所属板块填报单得分 × 分工权重%) × 个人系数 + Σ(承接项得分率% × 承接权重%) - * ——承接项得分率取该下达指标在主体填报单里的得分率(疑点 Q-03 未拍板,按推断口径实现, - * 口径改动只改本文件); - * - 分管领导:分管主体的最终得分按主体权重加权平均(权重全 0 时算术平均)(疑点 Q-05 推断口径)。 + * - 到人:个人得分 = Σ(所属板块得分 × 分工权重% × 个人系数)+ Σ(承接项得分率% × 承接权重%) + * ——承接项得分率取该下达指标在主体填报单里的得分率; + * - 分管领导:分管主体的最终得分按主体权重加权平均(权重全 0 时算术平均,表 7 第 7 项)。 + * + * 计算明细(breakdown)按指标逐条给出得分率、得分与「已调整」标记(第 10 章第 14 项), + * 让结果能钻取回是哪一行被调整过。 */ import { round2 } from './scoring.js'; +/** 填报明细的一行,进入结果的计算明细 JSON(带「已调整」标记)。 */ +export interface SheetLineSummary { + planIndicatorId: string; + indicatorName?: string | null; + scoreRate: number; + score: number; + isAdjusted: boolean; + /** 调整类型:source(源数据)/ result(计算结果);未调整为 null。 */ + adjustTypeApplied?: string | null; +} + export interface SheetSummary { id: string; subjectId: string; @@ -18,6 +32,8 @@ export interface SheetSummary { totalScore: number; /** 明细:plan_indicator id → 得分率(%) */ lineScoreRates: Record; + /** 逐指标明细;缺省时计算明细里不带指标行(得分不受影响)。 */ + lines?: SheetLineSummary[]; } export interface SubjectDef { @@ -79,7 +95,18 @@ export function aggregateResults( score: round2(s.totalScore), weightedScore: round2((s.totalScore * (subjectWeight.get(s.subjectId) ?? 0)) / 100), rank: 0, - breakdown: { total_score: s.totalScore, subject_weight: subjectWeight.get(s.subjectId) ?? 0 }, + breakdown: { + total_score: s.totalScore, + subject_weight: subjectWeight.get(s.subjectId) ?? 0, + lines: (s.lines ?? []).map((l) => ({ + plan_indicator: l.planIndicatorId, + indicator_name: l.indicatorName ?? null, + score_rate: l.scoreRate, + score: l.score, + is_adjusted: l.isAdjusted, + adjust_type_applied: l.adjustTypeApplied ?? null, + })), + }, })); const deptRows = rankRows(unitRows.filter((r) => r.dimension === 'department')); const branchRows = rankRows(unitRows.filter((r) => r.dimension === 'branch')); @@ -95,8 +122,16 @@ export function aggregateResults( for (const it of a.personalItems) { const rate = sheet?.lineScoreRates[it.planIndicatorId] ?? 0; const s = round2((rate * it.weight) / 100); + const src = sheet?.lines?.find((l) => l.planIndicatorId === it.planIndicatorId); fromItems += s; - items.push({ plan_indicator: it.planIndicatorId, score_rate: rate, weight: it.weight, score: s }); + items.push({ + plan_indicator: it.planIndicatorId, + score_rate: rate, + weight: it.weight, + score: s, + is_adjusted: src?.isAdjusted ?? false, + adjust_type_applied: src?.adjustTypeApplied ?? null, + }); } const entry = byEmployee.get(a.employeeId) ?? { name: a.employeeName ?? null, parts: [], score: 0 }; entry.parts.push({ unit: a.unitId, unit_score: unitScore, weight: a.weight, coefficient: a.coefficient, from_unit: fromUnit, items, from_items: round2(fromItems) }); diff --git a/src/objects/entry.object.ts b/src/objects/entry.object.ts index 4f201b1..6634e8d 100644 --- a/src/objects/entry.object.ts +++ b/src/objects/entry.object.ts @@ -127,6 +127,26 @@ export const EntryLine = ObjectSchema.create({ deleteBehavior: 'cascade', inlineEdit: 'grid', inlineTitle: '指标填报', + /** + * 内嵌网格的列**显式**声明 —— 只为让「已调整 / 调整类型」在填报单里也看得见。 + * + * 显式声明会**整体替换**平台从子对象派生的列集,所以这里必须列全:下面前 17 项 = + * 本次之前就有的全部字段,顺序与字段定义一致(= 原派生顺序),新增的两列排在最后。冗长但仍要留在网格里的三列(计算说明、最近调整、指标)用平台 + * 原生的 `defaultHidden` 折叠 —— 默认不占宽度,列选择器里仍能打开,而不是删掉。 + * + * 每项只写 `name`(身份列):不声明 `type` 时,标签、类型、选项、lookup 目标与联动 + * 规则都由子对象自己的字段定义补齐,列与字段因此不会各说各话。 + */ + inlineColumns: [ + { name: 'plan_indicator', required: true }, { name: 'indicator', defaultHidden: true }, + { name: 'indicator_name' }, { name: 'unit' }, { name: 'direction' }, { name: 'scoring_method' }, + { name: 'target_value' }, { name: 'weight' }, { name: 'actual_value' }, + { name: 'completion_rate' }, { name: 'score_rate' }, { name: 'score' }, + { name: 'adjusted_score' }, { name: 'final_score' }, + { name: 'calc_trace', defaultHidden: true }, { name: 'last_adjustment', defaultHidden: true }, + { name: 'remark' }, + { name: 'is_adjusted' }, { name: 'adjust_type_applied' }, + ], }), plan_indicator: Field.lookup('kpi_plan_indicator', { label: '来源下达', required: true }), indicator: Field.lookup('kpi_indicator', { label: '指标', readonly: true }), @@ -158,6 +178,18 @@ export const EntryLine = ObjectSchema.create({ score: Field.number({ label: '指标得分', readonly: true, scale: 2, min: 0, max: 1000 }), adjusted_score: Field.number({ label: '调整后得分', readonly: true, scale: 2, min: 0, max: 1000 }), final_score: Field.number({ label: '最终得分', readonly: true, scale: 2, min: 0, max: 1000 }), + // 「已调整」标记(《设计方案》V1.0 第 10 章第 14 项 = A:改某指标的得分并标记「已调整」)。 + // 两个字段都由数据调整批准落地时以系统上下文写入,页面上只读:源数据调整与结果调整都标, + // 「调整类型」说明标的是哪一种,列表与内嵌网格因此能一眼看出哪一行被调过。 + is_adjusted: Field.boolean({ label: '已调整', readonly: true, defaultValue: false }), + adjust_type_applied: Field.select({ + label: '调整类型', + readonly: true, + options: [ + { label: '源数据(实际值)', value: 'source' }, + { label: '计算结果(得分)', value: 'result' }, + ], + }), calc_trace: Field.textarea({ label: '计算说明', readonly: true }), last_adjustment: Field.lookup('kpi_adjustment', { label: '最近调整' }), remark: Field.text({ label: '备注', maxLength: 300 }), diff --git a/src/security/index.ts b/src/security/index.ts index 97786e9..ce5fa76 100644 --- a/src/security/index.ts +++ b/src/security/index.ts @@ -76,7 +76,9 @@ export const DeptReporterPermissionSet = definePermissionSet({ kpi_entry_line: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowExport: true, readScope: 'own', writeScope: 'own' }, kpi_check_task: { allowRead: true, readScope: 'own' }, kpi_review_record: { allowRead: true, readScope: 'org' }, - kpi_bonus: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false, readScope: 'own', writeScope: 'own' }, + // 登记加减分 = 人力审核岗位(《设计方案》V1.0 第 10 章第 6 项);声明与执行一致: + // 业务规则已只允许人力审核登记,这里就不再声明新建权限,按钮层面即不可用。 + kpi_bonus: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false, readScope: 'own', writeScope: 'own' }, kpi_adjustment: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false, readScope: 'own', writeScope: 'own' }, kpi_result: { allowRead: true, readScope: 'own' }, kpi_snapshot: { allowRead: true, readScope: 'org' }, ...PLATFORM_READ, diff --git a/src/services/results-service.ts b/src/services/results-service.ts index f3b1093..09806f6 100644 --- a/src/services/results-service.ts +++ b/src/services/results-service.ts @@ -1,4 +1,4 @@ -import { aggregateResults, type AssignmentDef, type SheetSummary, type SubjectDef } from '../lib/aggregate.js'; +import { aggregateResults, type AssignmentDef, type SheetLineSummary, type SheetSummary, type SubjectDef } from '../lib/aggregate.js'; import { nowIso, toNumber, type Api } from '../hooks/util.js'; /** @@ -31,8 +31,18 @@ export async function regenerateResults(api: Api, planId: string): Promise = {}; + const lineSummaries: SheetLineSummary[] = []; for (const l of lines) { - if (l.plan_indicator) lineScoreRates[String(l.plan_indicator)] = toNumber(l.score_rate) ?? 0; + if (!l.plan_indicator) continue; + lineScoreRates[String(l.plan_indicator)] = toNumber(l.score_rate) ?? 0; + lineSummaries.push({ + planIndicatorId: String(l.plan_indicator), + indicatorName: (l.indicator_name as string | undefined) ?? null, + scoreRate: toNumber(l.score_rate) ?? 0, + score: toNumber(l.final_score) ?? 0, + isAdjusted: l.is_adjusted === true, + adjustTypeApplied: (l.adjust_type_applied as string | undefined) ?? null, + }); } const indicatorScore = lines.reduce((sum: number, l: Record) => sum + (toNumber(l.final_score) ?? 0), 0); const bonuses = await api.object('kpi_bonus').find({ where: { sheet: s.id, status: 'approved' } }); @@ -44,6 +54,7 @@ export async function regenerateResults(api: Api, planId: string): Promise; - recipientType: 'unit_and_subordinates' | 'user'; + recipientType: 'unit_and_subordinates' | 'user' | 'position'; recipientId: string; accessLevel: 'read' | 'edit'; /** @@ -88,15 +88,43 @@ export function planRulePrefix(planId: string): string { /** 旧版本按单元(不带方案)建的规则前缀。它们正是不可回收的那批,发现即停用。 */ export const LEGACY_RULE_PREFIX = 'kpi_share_'; +/** 人力岗位:哪两个岗位拿本方案的写范围(详见 {@link planSharingIntents} 的说明)。 */ +const HR_POSITION_RULES: ReadonlyArray<{ position: string; label: string }> = [ + { position: 'kpi_hr_reviewer', label: '人力审核' }, + { position: 'kpi_hr_head', label: '人力负责人' }, +]; + +/** 人力岗位规则挂在哪些对象上(填报单 = 审核与加减分;数据调整 = 调整审批)。 */ +const HR_POSITION_OBJECTS: ReadonlyArray<{ key: string; object: string; label: string }> = [ + { key: 'sheet', object: 'kpi_entry_sheet', label: '填报单' }, + { key: 'adjust', object: 'kpi_adjustment', label: '数据调整' }, +]; + /** * 由方案配置推导出本方案需要的全部共享规则(纯函数,便于单元测试)。 * * - 考核主体单元:填报单 / 核对任务 / 数据调整可编辑,本单元结果只读; + * - 人力岗位(人力审核 / 人力负责人):本方案**全部**填报单与数据调整可编辑; * - 分管领导:所分管主体的填报单可编辑(领导审批节点要能改状态)、所分管主体的结果只读、 * 本人结果(分管领导维度)只读; * - 被考核员工:本人到人结果只读。 * * 方案已关闭 / 已归档时,可编辑的三类降为只读。 + * + * ## 人力岗位的记录级写范围(调度员 2026-09-02 裁定,选项 A) + * + * 人力审核要在填报单下**登记加减分**、在人力审核节点**审核通过 / 驳回**,人力负责人要 + * **审批加减分与数据调整** —— 这些都是对填报单及其子记录的写。填报单 OWD 是 private, + * 权限集里的 org 范围只放开读,写仍要过记录级判定,所以两个岗位**必须**拿到记录共享, + * 否则确认口径(第 10 章第 6 项)在真实岗位账号下根本执行不了。 + * + * 收件方直接用平台的 `position` 类型(ADR-0090 D3:岗位是扁平收件方,规则求值器 + * `expandRecipient` 把它展开成该岗位的全部持有人),不需要「持有该岗位的用户」这类等价 + * 表达。范围与其它三类规则同一套机制:条件带方案 id、规则名带方案前缀、发布时对账、 + * 方案已关闭 / 已归档后由 `edit` 降为 `read`。 + * + * 加减分(`kpi_bonus`)不需要自己的规则:它是填报单的主从子记录(`controlled_by_parent`), + * 记录级判定看的是主记录 —— 拿到填报单的 edit,插入 / 修改子记录就成立。 */ export function planSharingIntents( planId: string, @@ -206,6 +234,28 @@ export function planSharingIntents( }); } + // 人力岗位:本方案全部填报单与数据调整可编辑。规则要有组织归属才展开得出人,而组织归属 + // 取自锚定的组织单元行 —— 这里锚在第一个参与主体上(同一方案的主体在同一组织内);没有 + // 参与主体的方案发布不了,也就不需要这两类规则。 + const anchor = subjects.map((s) => String(s.subject ?? '')).find((u) => !!u) ?? ''; + if (anchor) { + for (const { position, label } of HR_POSITION_RULES) { + for (const { key, object, label: objectLabel } of HR_POSITION_OBJECTS) { + push({ + name: `${prefix}${key}_pos_${position}`, + label: `${objectLabel}共享给${label}岗位${suffix}`, + description: `人力岗位(${label})`, + object, + criteria: { plan: planId }, + recipientType: 'position', + recipientId: position, + accessLevel: writable, + anchorUnit: anchor, + }); + } + } + } + for (const a of assignments) { const employee = a.employee ? String(a.employee) : ''; if (!employee) continue; diff --git a/src/views/index.ts b/src/views/index.ts index b1a68d2..e5fe295 100644 --- a/src/views/index.ts +++ b/src/views/index.ts @@ -140,7 +140,7 @@ export const EntrySheetViews = defineView({ }, }); -const lineCols = cols('sheet', 'indicator_name', 'unit', 'direction', 'scoring_method', 'target_value', 'weight', 'actual_value', 'completion_rate', 'score_rate', 'score', 'adjusted_score', 'final_score', 'remark'); +const lineCols = cols('sheet', 'indicator_name', 'unit', 'direction', 'scoring_method', 'target_value', 'weight', 'actual_value', 'completion_rate', 'score_rate', 'score', 'adjusted_score', 'is_adjusted', 'adjust_type_applied', 'final_score', 'remark'); export const EntryLineViews = defineView({ list: { label: '填报明细', type: 'grid', data: obj('kpi_entry_line'), columns: lineCols, inlineEdit: true, exportOptions: XLSX, sort: [{ field: 'indicator_name', order: 'asc' }] }, listViews: { @@ -148,7 +148,7 @@ export const EntryLineViews = defineView({ }, formViews: { form: { type: 'simple', data: obj('kpi_entry_line'), sections: [ { name: 'entry', label: '填报', columns: 2, fields: [{ field: 'sheet', required: true }, { field: 'plan_indicator', required: true }, { field: 'indicator_name' }, { field: 'unit' }, { field: 'target_value' }, { field: 'weight' }, { field: 'actual_value' }, { field: 'remark' }] }, - { name: 'score', label: '计分', columns: 2, fields: [{ field: 'direction' }, { field: 'scoring_method' }, { field: 'completion_rate' }, { field: 'score_rate' }, { field: 'score' }, { field: 'adjusted_score' }, { field: 'final_score' }, { field: 'last_adjustment' }, { field: 'calc_trace' }] }, + { name: 'score', label: '计分', columns: 2, fields: [{ field: 'direction' }, { field: 'scoring_method' }, { field: 'completion_rate' }, { field: 'score_rate' }, { field: 'score' }, { field: 'adjusted_score' }, { field: 'is_adjusted' }, { field: 'adjust_type_applied' }, { field: 'final_score' }, { field: 'last_adjustment' }, { field: 'calc_trace' }] }, ] } }, }); diff --git a/test/design-alignment.test.ts b/test/design-alignment.test.ts new file mode 100644 index 0000000..bee5ea7 --- /dev/null +++ b/test/design-alignment.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { BONUS_APPROVE_RULE, BONUS_REGISTER_RULE, requiredBonusPosition } from '../src/hooks/bonus.hook.js'; +import { findPublishedConflict, planPeriodConflictMessage, samePeriod } from '../src/hooks/plan.hook.js'; +import { adjustmentLinePatch } from '../src/hooks/adjustment.hook.js'; +import { aggregateResults, type SheetSummary } from '../src/lib/aggregate.js'; + +/** + * 《设计方案》V1.0 已确认口径的落地校验(第 10 章第 6 / 10 / 14 项)。 + * 三条规则各自的唯一真值都是纯函数,这里直接对函数取证,不经运行期。 + */ + +describe('加减分岗位职责分离(第 10 章第 6 项:人力审核提出、人力负责人审批)', () => { + it('登记(新建)要求人力审核岗位', () => { + expect(requiredBonusPosition('beforeInsert', { title: '重大项目中标' }, {})).toBe(BONUS_REGISTER_RULE); + expect(BONUS_REGISTER_RULE.position).toBe('kpi_hr_reviewer'); + expect(BONUS_REGISTER_RULE.message).toContain('登记加减分失败'); + }); + + it('批准 / 驳回要求人力负责人岗位 —— 人力审核因此审批不了自己登记的加减分', () => { + expect(requiredBonusPosition('beforeUpdate', { status: 'approved' }, { status: 'draft' })).toBe(BONUS_APPROVE_RULE); + expect(requiredBonusPosition('beforeUpdate', { status: 'rejected' }, { status: 'draft' })).toBe(BONUS_APPROVE_RULE); + expect(BONUS_APPROVE_RULE.position).toBe('kpi_hr_head'); + expect(BONUS_APPROVE_RULE.decision).toBe(true); + }); + + it('不改状态的修改不受岗位约束(备注、依据等)', () => { + expect(requiredBonusPosition('beforeUpdate', { reason: '补充依据' }, { status: 'draft' })).toBeNull(); + expect(requiredBonusPosition('beforeUpdate', { status: 'draft' }, { status: 'draft' })).toBeNull(); + }); +}); + +describe('同周期唯一生效版本(第 10 章第 10 项 / 设计 5.2)', () => { + const draft = { id: 'p_new', period_type: 'month', year: 2026, period_no: 8, status: 'draft' }; + const live = { id: 'p_live', period_type: 'month', year: 2026, period_no: 8, status: 'published', name: '2026 年 8 月 月度考核' }; + + it('同一周期 = 周期类型 + 年度 + 期数三者相同(期数按数值比较)', () => { + expect(samePeriod(draft, live)).toBe(true); + expect(samePeriod(draft, { ...live, period_no: '8' })).toBe(true); + expect(samePeriod(draft, { ...live, period_no: 9 })).toBe(false); + expect(samePeriod(draft, { ...live, period_type: 'quarter' })).toBe(false); + expect(samePeriod(draft, { ...live, year: 2025 })).toBe(false); + }); + + it('同周期已有已发布方案时,发布被挡下并点名那个生效版本', () => { + const conflict = findPublishedConflict([live], draft); + expect(conflict?.id).toBe('p_live'); + const message = planPeriodConflictMessage(String(conflict?.name)); + expect(message).toBe('发布失败:该考核周期已有生效版本「2026 年 8 月 月度考核」。请先关闭该版本,或改用复制出的新版本替换。'); + }); + + it('不同组织的同周期方案互不冲突 —— 租户隔离(组织按等值比较)', () => { + const mine = { ...draft, organization_id: 'org_a' }; + expect(findPublishedConflict([{ ...live, organization_id: 'org_b' }], mine)).toBeNull(); + expect(findPublishedConflict([{ ...live, organization_id: 'org_a' }], mine)?.id).toBe('p_live'); + // 有一边读不出组织时退回只按周期判定 —— 缺字段不能变成静默放行 + expect(findPublishedConflict([{ ...live, organization_id: 'org_b' }], draft)?.id).toBe('p_live'); + // 种子 / 导入写进来的方案行组织为空,它依然是生效版本(候选不按组织过滤,判定才看得到它) + expect(findPublishedConflict([live], mine)?.id).toBe('p_live'); + expect(findPublishedConflict([{ ...live, organization_id: null }], mine)?.id).toBe('p_live'); + }); + + it('已关闭 / 已归档不是生效版本,不阻断发布;草稿同样不阻断;自己不挡自己', () => { + expect(findPublishedConflict([{ ...live, status: 'closed' }], draft)).toBeNull(); + expect(findPublishedConflict([{ ...live, status: 'archived' }], draft)).toBeNull(); + expect(findPublishedConflict([{ ...live, status: 'draft' }], draft)).toBeNull(); + expect(findPublishedConflict([{ ...live, id: 'p_new' }], draft)).toBeNull(); + }); +}); + +describe('调整落地标记「已调整」(第 10 章第 14 项 = A / 设计 5.7)', () => { + it('结果调整改得分并标记已调整,调整类型 = 计算结果', () => { + expect(adjustmentLinePatch('result', 26, 'adj_1')).toEqual({ + last_adjustment: 'adj_1', is_adjusted: true, adjust_type_applied: 'result', adjusted_score: 26, + }); + }); + + it('源数据调整改实际值并同样标记已调整,调整类型 = 源数据', () => { + expect(adjustmentLinePatch('source', 180, 'adj_2')).toEqual({ + last_adjustment: 'adj_2', is_adjusted: true, adjust_type_applied: 'source', actual_value: 180, + }); + }); + + it('计算明细 JSON 逐指标带 is_adjusted —— 部门维度与到人承接项都带', () => { + const sheets: SheetSummary[] = [ + { + id: 's1', subjectId: 'bu_market', subjectName: '市场部', subjectType: 'department', totalScore: 90, + lineScoreRates: { pi1: 100, pi2: 80 }, + lines: [ + { planIndicatorId: 'pi1', indicatorName: '利润完成率', scoreRate: 100, score: 30, isAdjusted: true, adjustTypeApplied: 'result' }, + { planIndicatorId: 'pi2', indicatorName: '客户满意度', scoreRate: 80, score: 16, isAdjusted: false, adjustTypeApplied: null }, + ], + }, + ]; + const rows = aggregateResults('2026-08 月度', sheets, [{ subjectId: 'bu_market', subjectWeight: 100 }], [ + { id: 'a1', employeeId: 'u1', employeeName: '张三', unitId: 'bu_market', weight: 100, coefficient: 1, personalItems: [{ planIndicatorId: 'pi1', weight: 20 }] }, + ]); + const dept = rows.find((r) => r.dimension === 'department')!; + expect(dept.breakdown.lines).toEqual([ + { plan_indicator: 'pi1', indicator_name: '利润完成率', score_rate: 100, score: 30, is_adjusted: true, adjust_type_applied: 'result' }, + { plan_indicator: 'pi2', indicator_name: '客户满意度', score_rate: 80, score: 16, is_adjusted: false, adjust_type_applied: null }, + ]); + const person = rows.find((r) => r.dimension === 'person')!; + const parts = person.breakdown.parts as Array>; + expect(parts[0].items[0]).toMatchObject({ plan_indicator: 'pi1', is_adjusted: true, adjust_type_applied: 'result' }); + // 得分不因标记而变:90×100%×1 + 100%×20% = 110 + expect(person.score).toBe(110); + }); +}); diff --git a/test/sharing.test.ts b/test/sharing.test.ts index 1e26006..5aea457 100644 --- a/test/sharing.test.ts +++ b/test/sharing.test.ts @@ -88,6 +88,33 @@ describe('按方案配置推导数据范围规则', () => { expect(intents.find((i) => i.recipientId === 'usr_XYZ-789')?.anchorUnit).toBe('bu_market'); }); + it('人力岗位:本方案全部填报单与数据调整可编辑,收件方是岗位本身', () => { + for (const position of ['kpi_hr_reviewer', 'kpi_hr_head']) { + expect(byName.get(`${P}sheet_pos_${position}`)).toMatchObject({ + object: 'kpi_entry_sheet', criteria: { plan: PLAN }, recipientType: 'position', recipientId: position, accessLevel: 'edit', + }); + expect(byName.get(`${P}adjust_pos_${position}`)).toMatchObject({ + object: 'kpi_adjustment', criteria: { plan: PLAN }, recipientType: 'position', recipientId: position, accessLevel: 'edit', + }); + } + // 条件只带方案(全方案范围),但绝不是 match-all —— 平台拒绝空条件的规则 + expect(Object.keys(byName.get(`${P}sheet_pos_kpi_hr_reviewer`)!.criteria)).toEqual(['plan']); + // 加减分是填报单的主从子记录,记录级判定看主记录,所以不另建规则 + expect(intents.some((i) => i.object === 'kpi_bonus')).toBe(false); + }); + + it('人力岗位规则锚在参与主体上 —— 没有主体就没有这两类规则', () => { + expect(byName.get(`${P}sheet_pos_kpi_hr_reviewer`)?.anchorUnit).toBe('bu_market'); + expect(planSharingIntents(PLAN, [], ASSIGNMENTS).some((i) => i.recipientType === 'position')).toBe(false); + }); + + it('方案关闭后人力岗位也只留读', () => { + const closed = planSharingIntents(PLAN, SUBJECTS, ASSIGNMENTS, 'closed'); + const hr = closed.filter((i) => i.recipientType === 'position'); + expect(hr).toHaveLength(4); + for (const i of hr) expect(i.accessLevel).toBe('read'); + }); + it('空配置不产生任何规则', () => { expect(planSharingIntents(PLAN, [], [])).toEqual([]); });