Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions scripts/e2e-flow.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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 () => {
Expand Down
8 changes: 5 additions & 3 deletions scripts/software-flow.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
13 changes: 10 additions & 3 deletions scripts/software-people.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
};

Expand Down Expand Up @@ -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)]));
Expand Down
32 changes: 32 additions & 0 deletions src/hooks/plan.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
if (isSystem(ctx) || !planId) return;
const plan = await findById(sys(ctx), 'kpi_plan', planId);
Expand Down Expand Up @@ -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, any>) => String(s.subject)));
for (const key of bySubject.keys()) {
if (!subjectIds.has(key)) problems.push(`指标下达:存在下达给未参与主体(${key})的指标,请先把该主体加入参与主体或删除下达`);
Expand Down
38 changes: 37 additions & 1 deletion test/design-alignment.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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」');
});
});
Loading