From c28bcefb1c801ed238a5b8374efaae899cd5784e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Thu, 3 Sep 2026 22:38:54 -0700 Subject: [PATCH] =?UTF-8?q?feat(plan):=20=E5=8F=91=E5=B8=83=E5=89=8D?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=80=A7=E6=A3=80=E6=9F=A5=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E3=80=8C=E6=AF=8F=E4=B8=AA=E5=8F=82=E4=B8=8E=E4=B8=BB=E4=BD=93?= =?UTF-8?q?=E9=A1=BB=E9=85=8D=E5=88=86=E7=AE=A1=E9=A2=86=E5=AF=BC=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 流程配了「领导审批」节点、却有主体没配分管领导时,方案照样能发布:填报单走到该 节点后不会出现在任何人的队列里,流程死在这一步(#29,来源 UI 实测)。 - 新增纯函数 missingLeaderProblems(steps, subjects):流程含「领导审批」节点时, 逐条列出未配分管领导的主体名称;没有该节点(节点可配置、可删)时不检查; - PlanPublishHook 在既有 problems 收集处调用,与现有检查同一编号列表、同一格式; - 单测三用例 + 主体名为空的兜底用例; - 演示脚本按需求补配置(不放松检查):软件档案给财务部、人力行政部补上分管领导, 默认档案的端到端脚本在发布前为 5 个参与主体配置分管领导;两处分管范围相关的 断言改为按方案实配推导,不再写死。 Co-Authored-By: Claude Fable 5.1 --- scripts/e2e-flow.mjs | 19 +++++++++++++++--- scripts/software-flow.mjs | 8 +++++--- scripts/software-people.mjs | 13 +++++++++--- src/hooks/plan.hook.ts | 32 +++++++++++++++++++++++++++++ test/design-alignment.test.ts | 38 ++++++++++++++++++++++++++++++++++- 5 files changed, 100 insertions(+), 10 deletions(-) diff --git a/scripts/e2e-flow.mjs b/scripts/e2e-flow.mjs index 374b902..b5d4406 100644 --- a/scripts/e2e-flow.mjs +++ b/scripts/e2e-flow.mjs @@ -32,6 +32,15 @@ const plan = plans[0]; log('T1', '种子方案已加载', !!plan, plan?.name); const pis = await list('kpi_plan_indicator', `?plan=${plan.id}&limit=100`); log('T2', '指标下达 18 条且名称由 hook 填充', pis.length === 18 && pis.every((p) => p.name && p.name.includes(' · ')), `${pis.length} rows, sample: ${pis[0]?.name}`); +// 发布前完整性检查要求「流程含领导审批节点时,每个参与主体都要配分管领导」——演示种子只建 +// 组织树与方案配置,分管领导是用户查找字段、种子里给不了值,所以由脚本按需求补配置(补配置, +// 不放松检查)。这里先用管理员账号占住 5 个主体的分管领导,后面的方案版本再换成专用的领导账号。 +const adminUser = (await list('sys_user', '?limit=200')).find((u) => u.email === 'admin@objectos.ai'); +const subs1 = await list('kpi_plan_subject', `?plan=${plan.id}&limit=50`); +for (const s of subs1) await patch('kpi_plan_subject', s.id, { leader: adminUser.id }); +log('T2b', '发布前为每个参与主体配置分管领导(种子不带,脚本按需求补)', subs1.length === 5 && !!adminUser, + `${subs1.length} 个主体 → ${adminUser?.name ?? adminUser?.email}`); + // 2 publish with weight problem: temporarily break a weight const marketPis = pis.filter((p) => p.name.endsWith('市场部')); const ORIG = { '营业收入完成率': 50, '利润完成率': 30, '客户满意度': 20 }; @@ -222,9 +231,13 @@ 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, -// 人力岗位 2 个 × 2 类(填报单 / 调整)→ +4,合计 29。 -const EXPECTED_RULES = 5 * 4 + 3 + 2 + 2 * 2; +// 每个配了分管领导的主体 → +2(该主体填报单、该主体结果),每位分管领导 → +1(本人结果), +// 2 条到人分工 → +2,人力岗位 2 个 × 2 类(填报单 / 调整)→ +4。 +// 分管领导按方案实配推导:全部主体都要配分管领导(发布前完整性检查),市场部配的是 C, +// 其余主体沿用复制自上一版的管理员账号。 +const subs3After = await list('kpi_plan_subject', `?plan=${plan3.id}&limit=50`); +const leaderSubs3 = subs3After.filter((x) => x.leader); +const EXPECTED_RULES = 5 * 4 + leaderSubs3.length * 2 + new Set(leaderSubs3.map((x) => String(x.leader))).size + 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 () => { diff --git a/scripts/software-flow.mjs b/scripts/software-flow.mjs index 3522727..43d4b90 100644 --- a/scripts/software-flow.mjs +++ b/scripts/software-flow.mjs @@ -468,11 +468,13 @@ log('T17c', '分公司核对人员只见本分公司的核对任务', eastTasks. await signIn(ACCOUNT.leaderTech); await waitUntil('技术线分管主体填报单共享给分管领导', async () => (await list('kpi_entry_sheet', '?limit=100')).length > 0); const techSheets = await list('kpi_entry_sheet', '?limit=100'); -const techScope = new Set(['bu_sw_rd', 'bu_sw_pd', 'bu_sw_qa']); +// 分管范围以方案里实际配置的分管领导为准(每个参与主体都要配分管领导,范围会随档案调整)。 +const techLeaderId = String(userByEmail.get(ACCOUNT.leaderTech)?.id ?? ''); +const techScope = new Set(subjects.filter((x) => String(x.leader ?? '') === techLeaderId).map((x) => String(x.subject))); const leaderCross = await call('GET', `/data/kpi_entry_sheet/${salesSheet.id}`); log('T17d', '分管领导只见分管主体的填报单,打开非分管主体的填报单被拒绝', - techSheets.length > 0 && techSheets.every((s) => techScope.has(String(s.subject))) && (leaderCross.status === 403 || leaderCross.status === 404), - `可见 ${techSheets.length} 张:${techSheets.map((s) => s.name.split(' · ')[1]).join('、')};越权读取 status=${leaderCross.status}`); + techScope.size > 0 && techSheets.length === techScope.size && techSheets.every((s) => techScope.has(String(s.subject))) && (leaderCross.status === 403 || leaderCross.status === 404), + `分管 ${techScope.size} 个主体,可见 ${techSheets.length} 张:${techSheets.map((s) => s.name.split(' · ')[1]).join('、')};越权读取 status=${leaderCross.status}`); asAdmin(); const summary = { passed: results.filter((x) => x.ok).length, failed: results.filter((x) => !x.ok).length }; diff --git a/scripts/software-people.mjs b/scripts/software-people.mjs index 0c14547..0c0c796 100644 --- a/scripts/software-people.mjs +++ b/scripts/software-people.mjs @@ -46,9 +46,15 @@ const POSITION_LABEL = { kpi_exec_leader: '分管领导', }; -/** 分管领导的分管范围(两位领导范围不重叠)。 */ +/** + * 分管领导的分管范围(两位领导范围不重叠,且必须覆盖方案的全部 11 个参与主体)。 + * + * 发布前完整性检查要求「流程含领导审批节点时,每个参与主体都要配分管领导」,否则填报单 + * 会停在领导审批节点无人可审;演示档案只设两位分管领导,财务部与人力行政部这两个职能 + * 后台主体并入技术线领导的分管范围,不额外新增账号。 + */ const LEADER_SCOPE = { - 'leader.tech@kpi.demo': ['bu_sw_rd', 'bu_sw_pd', 'bu_sw_qa'], + 'leader.tech@kpi.demo': ['bu_sw_rd', 'bu_sw_pd', 'bu_sw_qa', 'bu_sw_fin', 'bu_sw_hr'], 'leader.gtm@kpi.demo': ['bu_sw_sales', 'bu_sw_mkt', 'bu_sw_cs', 'bu_sw_east', 'bu_sw_south', 'bu_sw_north'], }; @@ -172,7 +178,8 @@ for (const [email, units] of Object.entries(LEADER_SCOPE)) { const subjectsAfter = await list('kpi_plan_subject', `?plan=${plan.id}&limit=100`); const leaderIds = new Set(subjectsAfter.map((s) => String(s.leader ?? '')).filter(Boolean)); const covered = subjectsAfter.filter((s) => s.leader).length; -step('分管领导已设置', leaderIds.size >= 2 && covered === 9, `${leaderIds.size} 位领导,覆盖 ${covered} 个主体(技术线 3 + 市场线 6),分管范围不重叠`); +step('分管领导已设置', leaderIds.size >= 2 && covered === subjectsAfter.length, + `${leaderIds.size} 位领导,覆盖 ${covered}/${subjectsAfter.length} 个主体(分管范围不重叠;发布前完整性检查要求主体全覆盖,缺一个都发布不了)`); // ── 账号清单(供测试报告引用)──────────────────────────────────────────── const unitName = new Map((await list('sys_business_unit', '?limit=200')).map((u) => [String(u.id), String(u.name)])); diff --git a/src/hooks/plan.hook.ts b/src/hooks/plan.hook.ts index dae1c9f..1b2485b 100644 --- a/src/hooks/plan.hook.ts +++ b/src/hooks/plan.hook.ts @@ -68,6 +68,35 @@ export function planPeriodConflictMessage(name: string): string { return `发布失败:该考核周期已有生效版本「${name}」。请先关闭该版本,或改用复制出的新版本替换。`; } +/** 参与主体行里与本检查相关的三列(名称、主体、分管领导)。 */ +export interface PlanSubjectLeaderRow { + name?: unknown; + subject?: unknown; + leader?: unknown; +} + +/** + * 「每个参与主体都有人能审批」—— 发布前完整性检查的一条(《设计方案》表 2 第 1 步)。 + * + * 流程配了「领导审批」节点、却有主体没配分管领导时,方案照样能发布:填报单走到该节点后 + * 不会出现在任何人的队列里,流程死在这一步。所以检查条件挂在节点上 —— 流程里没有「领导 + * 审批」节点(节点可配置、可删)时不做此检查,免得把不走领导审批的方案也一并挡住。 + * + * 纯函数:口径的唯一真值(单元测试点),hook 层只负责取数。 + */ +export function missingLeaderProblems(steps: PlanStepDef[], subjects: PlanSubjectLeaderRow[]): string[] { + if (!(steps ?? []).some((s) => s?.step_type === 'leader_approve')) return []; + const problems: string[] = []; + for (const s of subjects ?? []) { + if (String(s?.leader ?? '').trim()) continue; + const label = String(s?.name ?? '').trim() || String(s?.subject ?? ''); + problems.push( + `分管领导:主体「${label}」未配置分管领导,发布后其填报单走到「领导审批」节点将无人可审;请在方案的「参与主体与考核关系」里为该主体指定分管领导。`, + ); + } + return problems; +} + async function assertPlanEditable(ctx: HookContext, planId: string | null | undefined): Promise { if (isSystem(ctx) || !planId) return; const plan = await findById(sys(ctx), 'kpi_plan', planId); @@ -217,6 +246,9 @@ export const PlanPublishHook: Hook = { } } } + // 每个参与主体都要有人能审批:流程含「领导审批」节点时,主体必须配分管领导(#29)。 + problems.push(...missingLeaderProblems(steps, subjects as PlanSubjectLeaderRow[])); + const subjectIds = new Set(subjects.map((s: Record) => String(s.subject))); for (const key of bySubject.keys()) { if (!subjectIds.has(key)) problems.push(`指标下达:存在下达给未参与主体(${key})的指标,请先把该主体加入参与主体或删除下达`); diff --git a/test/design-alignment.test.ts b/test/design-alignment.test.ts index bee5ea7..dc9bb02 100644 --- a/test/design-alignment.test.ts +++ b/test/design-alignment.test.ts @@ -1,6 +1,7 @@ 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 { findPublishedConflict, missingLeaderProblems, planPeriodConflictMessage, samePeriod } from '../src/hooks/plan.hook.js'; +import { DEFAULT_STEPS, type PlanStepDef } from '../src/lib/workflow.js'; import { adjustmentLinePatch } from '../src/hooks/adjustment.hook.js'; import { aggregateResults, type SheetSummary } from '../src/lib/aggregate.js'; @@ -106,3 +107,38 @@ describe('调整落地标记「已调整」(第 10 章第 14 项 = A / 设计 5. expect(person.score).toBe(110); }); }); + +describe('发布前完整性检查:每个参与主体须配分管领导(设计 表 2 第 1 步)', () => { + const subjects = [ + { name: '销售部', subject: 'bu_sales', leader: 'u_leader_1' }, + { name: '财务部', subject: 'bu_fin', leader: null }, + { name: '人力行政部', subject: 'bu_hr', leader: '' }, + ]; + const noLeaderStep: PlanStepDef[] = DEFAULT_STEPS.filter((s) => s.step_type !== 'leader_approve'); + + it('有主体缺分管领导 → 逐条列出主体名称,拦下发布', () => { + const problems = missingLeaderProblems(DEFAULT_STEPS, subjects); + expect(problems).toHaveLength(2); + expect(problems[0]).toContain('财务部'); + expect(problems[1]).toContain('人力行政部'); + expect(problems.join('\n')).not.toContain('销售部'); + // 三段式:哪里出的问题 + 为什么不能发 + 怎么办 + expect(problems[0]).toContain('未配置分管领导'); + expect(problems[0]).toContain('领导审批'); + expect(problems[0]).toContain('请在方案的「参与主体与考核关系」里为该主体指定分管领导'); + }); + + it('全部主体都配了分管领导 → 放行', () => { + expect(missingLeaderProblems(DEFAULT_STEPS, subjects.map((s) => ({ ...s, leader: 'u_leader_1' })))).toEqual([]); + }); + + it('流程里没有「领导审批」节点 → 不做此检查', () => { + expect(noLeaderStep.some((s) => s.step_type === 'leader_approve')).toBe(false); + expect(missingLeaderProblems(noLeaderStep, subjects)).toEqual([]); + }); + + it('主体名称为空时退回主体标识,不会出现空的「主体「」」', () => { + const problems = missingLeaderProblems(DEFAULT_STEPS, [{ name: '', subject: 'bu_x', leader: null }]); + expect(problems[0]).toContain('主体「bu_x」'); + }); +});