From 79437b2bfa0611237c4bd2e1c17e1eb3354f8bf5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 17:31:12 +0000 Subject: [PATCH 1/8] feat(kpi): align code with confirmed design V1.0 (bonus segregation, one live plan per period, adjusted marker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed design V1.0 chapter 10 items 6 / 10 / 14 (customer sign-off 2026-09-02) were not yet enforced in code: - Bonus role segregation (item 6): registering a bonus now requires the HR reviewer position and approving/rejecting requires the HR head position, so an HR reviewer cannot approve what they registered and an HR head cannot register. The decision is a pure function (requiredBonusPosition) — the single source of truth for the segregation rule. - One live version per assessment period (item 10): publishing is refused when another plan with the same period type + year + period number is already published; closed and archived plans do not block. Period comparison and the refusal message live in pure functions (samePeriod / findPublishedConflict). - "Adjusted" marker (item 14 = A): kpi_entry_line gains is_adjusted and adjust_type_applied, written by the adjustment hook when an approved adjustment lands (both source and result adjustments). The marker shows in the entry-line list view and in the sheet's inline grid, and every indicator row of the result breakdown JSON now carries is_adjusted. The aggregate engine header now cites design V1.0 chapter 8 table 6 instead of the pre-sign-off "undecided / inferred" wording; the algorithm is unchanged. Unit tests cover all three rules; e2e adds T58-T64: two bonus position counter-examples, duplicate publish in the same period (plus success after closing the live version), the adjusted marker on the line and in the result breakdown, and a branch checker's direct value edit being refused. Part of #10 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb --- scripts/e2e-flow.mjs | 67 +++++++++++++++++++++++ src/hooks/adjustment.hook.ts | 26 +++++++-- src/hooks/bonus.hook.ts | 59 ++++++++++++++++++-- src/hooks/plan.hook.ts | 51 +++++++++++++++++ src/lib/aggregate.ts | 47 ++++++++++++++-- src/objects/entry.object.ts | 20 +++++++ src/services/results-service.ts | 15 ++++- src/views/index.ts | 4 +- test/design-alignment.test.ts | 97 +++++++++++++++++++++++++++++++++ 9 files changed, 367 insertions(+), 19 deletions(-) create mode 100644 test/design-alignment.test.ts diff --git a/scripts/e2e-flow.mjs b/scripts/e2e-flow.mjs index cec2aae..6fceaef 100644 --- a/scripts/e2e-flow.mjs +++ b/scripts/e2e-flow.mjs @@ -365,6 +365,73 @@ 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(); +log('T58', '非人力审核岗位登记加减分被拒绝(第 10 章第 6 项:人力审核提出)', r.status >= 400 && msg(r).includes('只有人力审核岗位可以登记加减分'), `status=${r.status} ${msg(r)}`); + +// 审批岗位反例:同一个账号对已存在的加减分点「批准」,被岗位判定拒绝 +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)}`); + 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..c7a75d7 100644 --- a/src/hooks/plan.hook.ts +++ b/src/hooks/plan.hook.ts @@ -6,6 +6,50 @@ 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; +} + +/** 两条方案是否落在同一个考核周期(期数按数值比较,避免 "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) + ); +} + +/** + * 在候选方案里找出挡住本次发布的生效版本 —— 纯函数,唯一性口径的唯一真值(单元测试点)。 + * 候选由调用方按「已发布」筛出;这里再判一次状态,保证口径不依赖调用方的查询条件。 + */ +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 (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 +156,13 @@ export const PlanPublishHook: Hook = { // 平台校验拒绝时留下半截改动。 if (!(prev.status === 'draft' && input.status === 'published')) return; + // 同周期唯一生效版本:先于完整性检查判定 —— 这一条与方案内部配置无关,先说清楚 + // 「这个周期已经有生效版本了」比让作者先去补权重更省事。 + const period = merged>(ctx); + const published = await api.object('kpi_plan').find({ where: { status: 'published' } }); + const conflict = findPublishedConflict(published as PlanPeriodKey[], { ...period, id }); + 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..081b5d3 100644 --- a/src/objects/entry.object.ts +++ b/src/objects/entry.object.ts @@ -127,6 +127,14 @@ export const EntryLine = ObjectSchema.create({ deleteBehavior: 'cascade', inlineEdit: 'grid', inlineTitle: '指标填报', + // 内嵌网格的列显式声明(身份列,类型 / 标签由子对象字段定义补齐),这样「已调整」 + // 与「调整类型」在填报单里也看得见,而不只在明细列表视图里。 + inlineColumns: [ + { name: 'indicator_name' }, { name: 'unit' }, { name: 'target_value' }, { name: 'weight' }, + { name: 'actual_value' }, { name: 'completion_rate' }, { name: 'score_rate' }, { name: 'score' }, + { name: 'adjusted_score' }, { name: 'is_adjusted' }, { name: 'adjust_type_applied' }, + { name: 'final_score' }, { name: 'remark' }, + ], }), plan_indicator: Field.lookup('kpi_plan_indicator', { label: '来源下达', required: true }), indicator: Field.lookup('kpi_indicator', { label: '指标', readonly: true }), @@ -158,6 +166,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/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 { + 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('已关闭 / 已归档不是生效版本,不阻断发布;草稿同样不阻断;自己不挡自己', () => { + 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); + }); +}); From 92721a6d18e977a916ff14b045a5c3d908077286 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 17:43:39 +0000 Subject: [PATCH 2/8] docs: refresh compliance checklist and self-test record for design V1.0 docs/04 now compares against the confirmed design V1.0 (chapter references instead of open-question numbers), records the three newly enforced items as their own rows (adjusted marker, one live version per period, branch checker cannot edit values), drops every "pending decision" note that the 2026-09-02 sign-off settled, and recounts the statistics line so it matches the status column. One new deviation is recorded honestly: the HR reviewer / HR head positions have read scope only, so bonus registration and approval are reachable by an administrator alone until their record-level write scope is decided. docs/05 is rewritten on the dev-test report template: verdict page with the case count, environment table carrying the tested commit SHA and how the port / process / revision were cross-checked, a seven-column plan-and-result table covering all 66 e2e cases, per-case detail for the seven added cases, a problem record with reproduction steps and a workaround, and the verbatim output of this run's `pnpm verify` and `pnpm e2e`. Part of #10 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb --- ...10\345\272\246\346\270\205\345\215\225.md" | 71 +-- ...52\346\265\213\350\256\260\345\275\225.md" | 424 +++++++++++++++--- 2 files changed, 395 insertions(+), 100 deletions(-) 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..1129f32 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`;反例 T58、T59) | — 不封顶不保底 = 第 10 章第 6 项确认口径。人力岗位账号当前够不到写路径,记在 #17 | +| 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 | — | +| 26 | 同一周期只有一个生效版本(设计 5.2、第 10 章第 10 项) | ✅ | `PlanPublishHook` 发布前校验同 `period_type + year + period_no` 是否已有「已发布」方案,已关闭 / 已归档不阻断;拒绝文案点名生效版本;T61 + 单元测试 3 条 | — | + +## 三、角色与安全(需求 §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` 查询);加减分登记 / 审批的岗位分离已落地(T58、T59) | **偏差**:人力审核、人力负责人两个岗位当前只有读范围 —— 填报单 OWD 为 private,记录级写权限只由方案发布的共享规则授予给参与主体单元、核对分公司、分管领导,人力岗位不在其中,因此加减分登记 / 审批与人力审核节点的推进实际只有管理员(组织 owner / admin)执行得了。**出口:升级裁决** —— 人力岗位的记录级写范围要先拍板(全部方案 / 仅本方案、写到哪些对象),再由数据范围工作项补规则;现象、最小复现与绕行见 `docs/05-自测记录.md` 问题记录 #1 | +| 18 | 数据范围:全部 / 分管 / 本部门 / 仅本人 | ✅ | 全部 = org、仅本人 = own 原生;本部门 / 本分公司 / 分管范围 = private 基线 + 方案发布时按参与主体、分管领导、到人分工写入的**动态共享规则**(`src/services/sharing-service.ts`),组织变化零元数据改动;到人结果行级安全只挡他人的到人维度(T41、T44、T46、T51)。不再依赖企业版 `hierarchy-security`(设计第 17 项) | 四点实现说明:① 规则**按方案**建(条件带方案 id、规则名按方案加前缀),发布时对账停用本方案下已不该存在的规则 —— 换掉分管领导、撤主体、撤到人分工都当场失效,历史方案的授权原样保留(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 | — | | 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 条:✅ 完整实现 **25** 条;⚠️ 有偏差 **2** 条(#17 人力岗位写范围、#23 未压测);❌ 未实现 **0** 条。 +- ⚠️ 出口: + - **#17 人力岗位的记录级写权限** → 升级裁决:见 `docs/05-自测记录.md` 问题记录 #1(含最小复现与绕行:由考核系统管理员代办),由 PM 分诊后另立工作项; + - **#23 大数据量性能** → 升级:实施期真实数据量验证,见设计表 10「试运行」阶段。 +- 说明:#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..70d62c3 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,366 @@ # 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 上的测试报告的仓库内落盘版,两者同一份内容。 -**结论:✅ 通过** —— 静态门禁 3/3 绿(validate / typecheck / vitest 32 条),端到端流程用例 38/38 通过。 +## 第 1 页:结论 -## 1. 测试环境 +**结论:❌ 存在问题** + +共 66 条用例:通过 **66**。静态门禁 3/3 绿(validate / typecheck / vitest 64 条)。 +本工作项新增用例 7 条(T58~T64)全部通过;但工作项 #10 的验收标准 1、2 要求「以人力审核账号登记加减分」 +「以人力负责人账号批准」,这两步在当前数据范围设计下**任何人力岗位账号都执行不了**(问题记录 #1), +因此结论按模板取「存在问题」。 + +**问题清单** + +| 用例 | 问题现象(预期 vs 实际) | 严重程度 | 问题原因(初步定位) | 处理建议 | 截图 | +|---|---|---|---|---|---| +| **验收标准 1 / 2**(无对应用例:该路径不可达) | 预期:持「人力审核」岗位的账号能在填报单下登记加减分、持「人力负责人」岗位的账号能批准;实际:两个账号都被平台以 403 挡在业务规则之前 —— 登记报「requires edit access to its master record」,连改填报单备注也报「You do not have access to this record」 | 阻塞 | 填报单默认共享为 private,记录级写权限只由方案发布写入的共享规则授予(参与主体单元、分公司、分管领导),人力岗位不在其中;人力岗位的权限集虽是 org 范围,读得到、写不到 | 【要改代码】需先拍板人力岗位的记录级写范围(见「需拍板事项」),再由数据范围工作项补规则;本工作项的岗位判定本身已验证(T58、T59、T60) | 无(接口级证据,见问题记录 #1) | + +**关键观察**(接口级,替代截图) + +- T58 拒绝原文:`{"error":"登记加减分失败:只有人力审核岗位可以登记加减分。请联系人力审核办理。","code":"VALIDATION_ERROR","declaredCode":"KPI_BONUS_REGISTER_POSITION","object":"kpi_bonus"}` +- T61 拒绝原文:`{"error":"发布失败:该考核周期已有生效版本「2026 年 11 月 月度考核」。请先关闭该版本,或改用复制出的新版本替换。","code":"VALIDATION_ERROR","declaredCode":"KPI_PLAN_PERIOD_CONFLICT","object":"kpi_plan"}` +- T63 计算明细原文:`{"plan_indicator":"1GLVXvVPxDO9xY2G","indicator_name":"客户满意度","score_rate":90,"score":12.5,"is_adjusted":true,"adjust_type_applied":"result"}` + +## 2. 测试环境与账号 | 项 | 值 | |---|---| +| 被测版本 | commit `79437b2bfa0611237c4bd2e1c17e1eb3354f8bf5`(分支 `issue-10-align-confirmed-design`,本 PR 的代码提交)。核实方式(开测前三级核对):① `/proc/net/tcp` 中处于 LISTEN 的端口含 3110,且无 3100 / 3111;② 占用 3110 的进程 pid 3441,`/proc/3441/cwd` = `/home/user/kpi-issue-10`;③ 该 worktree `git rev-parse HEAD` = 79437b2,`git status` 干净。本文件与 `docs/04` 的文档提交叠在该提交之上,不改任何被测代码 | | 平台 | @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` | +| 端 | 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 月方案、账号、加减分、调整)由脚本按序创建,凭本表可从空库重建同等环境 | +| 驱动脚本 | `scripts/e2e-flow.mjs`(目标地址取环境变量 `KPI_BASE_URL`,本轮 `http://localhost:3110`);每条用例的预期只来自需求 §3、蓝图与《设计方案》V1.0,不从实现反推 | +| 测试日期 | 2026-09-02 | +| 未覆盖 | 浏览器 UI 走查与截图;大数据量性能;人力岗位账号的写路径(问题记录 #1) | -## 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 | 一般 /【平台受限】 | +四类齐全:主流程 35、边界 3、异常 14、权限 14(合计 66)。 + +| 编号 | 标题 | 类型 | 端 | 角色 | 结果 | 证据 | +|---|---|---|---|---|---|---| +| 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 | 非人力审核岗位登记加减分被拒绝(第 10 章第 6 项:人力审核提出) **(本工作项新增)** | 权限 | 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 | + +## 4. 逐用例明细(本工作项新增 / 改动的用例) + +### T58:非人力审核岗位登记加减分被拒绝 + +- **前置条件**:T54 已发布 2026 年 11 月方案(市场部为参与主体),部门填报人员账号 A 已入市场部并持岗位 `kpi_dept_reporter`;等待该方案的市场部填报单共享到 A 后再操作。 +- **操作步骤**: + 1. 以账号 A 登录 → `POST /api/v1/data/kpi_bonus`,body:`{ sheet: <11 月市场部填报单>, title: '越权登记', bonus_type: 'add', points: 1, reason: '岗位反例' }`。 +- **预期结果**: + 1. 请求被拒绝(4xx); + 2. 提示为三段式且点名岗位:「登记加减分失败:只有人力审核岗位可以登记加减分。请联系人力审核办理。」 +- **实际观察**: + 1. HTTP 422; + 2. `{"error":"登记加减分失败:只有人力审核岗位可以登记加减分。请联系人力审核办理。","code":"VALIDATION_ERROR","declaredCode":"KPI_BONUS_REGISTER_POSITION","object":"kpi_bonus"}`。 +- **结果**:✅ 通过 +- **截图**:无(接口级证据,见首段降级声明) + +### 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 —— 「只能确认 / 提出争议、不能改值」由权限模型直接兑现。 +- **截图**:无 + +## 5. 问题记录 + +| # | 用例 | 现象 | 复现步骤 | 原因分析 | 处理建议 | 截图 | 处置 | +|---|---|---|---|---|---|---|---| +| 1 | 验收标准 1 / 2(无对应用例) | 人力审核账号登记不了加减分、人力负责人账号批准不了加减分,连打开填报单改个备注都被拒;两个账号都看得到填报单列表 | ① 管理员建号 `hr.repro@kpi.test` 并 `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,记录级写权限只来自方案发布时写入的动态共享规则,收件人是参与主体单元、核对分公司、分管领导与被下达员工;人力岗位不在收件人之列。人力岗位权限集里的 `readScope/writeScope: '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))` | 【要改代码】先拍板人力岗位的记录级写范围(全部方案的填报单?还是只在本方案、且只到加减分 / 调整 / 审核动作?),再在方案发布的共享规则里补一类收件人。**绕行**:由考核系统管理员(或组织 owner)代为登记 / 审批 —— 岗位判定对管理员放行,业务不至于卡死 | 无(接口级) | 待细化退回:已写入本 PR 的「需拍板事项」,由 PM 分诊后另立工作项 | +| 2 | T58 | 部门填报人员的权限集仍声明了加减分的新建权限,但业务规则已只允许人力审核登记 —— 声明与执行不一致(表现为「按钮点了才报错」) | 见 T58 | 权限集 `kpi_dept_reporter_set` 的 `kpi_bonus.allowCreate` 仍为 true;本工作项只改业务规则,未动权限集(范围纪律) | 【要改代码】随问题 #1 的拍板一起收窄该权限声明,让「不可用」在按钮层就体现出来 | 无 | 待定(遗留问题,已列入 PR 与返回报告) | + +## 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: 273ms + → Validating against ObjectStack Protocol... + → Running author-time rules (41)... + → Checking capability providers (#3366)... + → Checking package docs (ADR-0046)... + + ✓ Validation passed (654ms) + + 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 64 passed (64) + Start at 17:38:16 + Duration 818ms (transform 416ms, setup 0ms, import 1.09s, tests 64ms, environment 1ms) + +``` + +单元测试 64 条分布:计分引擎 14、状态机 / 流程配置 11、数据范围规则 23、汇总 4、CEL 求值 3、 +已确认设计口径(本工作项新增)9。 + +### 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. 指标下达:主体「市场部」的权重合计为 90%,必须等于 100%","code":"VALIDATION_ERROR","declaredCode":"KPI_PLAN_INCOMPLETE","object":"kpi_plan"} +PASS T4 完整性通过后发布成功 — {"object":"kpi_plan","id":"-4bGt1dhCJcgb_jw","record":{"id":"-4bGt1dhCJcgb_jw","created_at":"2026-09-02T17:36:29.496Z","updated_at":"2026-09-02T17:37:24.582Z","organization_id":null,"created_by":null,"updated_by":"2BtTAu +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":"Jox2yHWhSbAkEIM9","record":{"id":"Jox2yHWhSbAkEIM9","created_at":"2026-09-02T17:37:24.245Z","updated_at":"2026-09-02T17:37:25.493Z","organization_id":"org_mtkdoaw9o1j1w7ya","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":"Jox2yHWhSbAkEIM9","record":{"id":"Jox2yHWhSbAkEIM9","created_at":"2026-09-02T17:37:24.245Z","updated_at":"2026-09-02T17:37:26.810Z","organization_id":"org_mtkdoaw9o1j1w7ya","created_by": +PASS T23 领导审批通过 → 已通过,盖章 — status=approved +PASS T24 通过后生成考核结果(部门维度) — department:88#1 +PASS T25 加减分批准后计入最终得分 91 — bonus_total=3 total=91 {"object":"kpi_bonus","id":"dVXbt5uRc4XKnzxl","record":{"id":"dVXbt5uRc4XKnzxl","created_at":"2026-09-02T17:37:27.098Z","updated_at":"2026-09-02T17:37:27.154Z","organization_id":"org_mtkdoaw9o1j1w7ya","created_by":"2BtTA +PASS T26 调整申请记录调整前值 144 与主体 — {"object":"kpi_adjustment","id":"TC1q9fQxzGzaxdaF","record":{"id":"TC1q9fQxzGzaxdaF","created_at":"2026-09-02T17:37:27.297Z","updated_at":"2026-09-02T17:37:27.297Z","organization_id":"org_mtkdoaw9o1j1w7ya","created_by":" +PASS T27 源数据调整落地并重算:利润 180 → 完成率 100%,得分 30;结果重新汇总 — actual=180 score=30 total=106 results=106 +PASS T28 归档生成不可变快照(含校验和、总分) — snap=2026 年 8 月 月度考核 · 市场部 · 归档快照 checksum=4466063bad69 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 审核记录完整留痕(生成/提交/争议/确认/驳回/通过/调整/归档) — dispute,confirm,archive,approve,approve,submit,confirm,confirm,confirm,confirm,generate,approve,confirm,reject,adjust,approve +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":"vKW8OYUvMvHk3qWp","record":{"id":"vKW8OYUvMvHk3qWp"," +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":"LT09r7J6-0FGaTB_","record":{"id":"LT09r7J6-0FGaTB_"," sheets=5 +PASS T39 发布按方案配置写入动态共享规则,条数与方案配置精确相符,元数据零改动 — rules=25 expected=25 +PASS T39b 规则条件按方案隔离:每条规则的条件里都带本方案 id — 25 条 +PASS T40 规则求值把单元共享展开成逐人记录共享行(市场部填报单 → 用户 A) — shares=10 +PASS T41 部门填报人员只看到本部门(市场部)填报单,含本次新发布的那张 — 3 张:2026 年 8 月 月度考核 · 市场部 | 2026 年 10 月 月度考核 · 市场部 | 2026 年 9 月 月度考核 · 市场部 +PASS T42 打开本部门以外(华东分公司)的填报单被拒绝 — status=404 {"error":"Record QavXsmDQ5WQdd-kk not found in kpi_entry_sheet","code":"RECORD_NOT_FOUND","object":" +PASS T43 部门填报人员能填写实际值并提交本部门填报单 — status=branch_checking score=103 {"object":"kpi_entry_sheet","id":"SwwGtMGTRUFcrA13","record":{"id":"SwwGtMGTRUFcrA13","created_at":" +PASS T44 分公司核对人员只看到本分公司(华东)的核对任务 — 2 条:2026 年 10 月 月度考核 · 市场部 · 华东分公司 核对 | 2026 年 8 月 月度考核 · 市场部 · 华东分公司 核对 +PASS T45 分公司核对人员能确认本分公司的核对任务 — {"object":"kpi_check_task","id":"80au58dCOwBJcTHA","record":{"id":"80au58dCOwBJcTHA","created_at":"2026-09-02T17:37:32.3 +PASS T46 分管领导只看到分管主体(市场部)的填报单,看不到运营部 — 1 张:2026 年 10 月 月度考核 · 市场部 +PASS T47 分管领导能在「领导审批中」执行审核通过 — beforeLeader=leader_approving? status=approved {"object":"kpi_entry_sheet","id":"SwwGtMGTRUFcrA13","record":{"id":"SwwGtMGTRUFcrA13","created_at":"2026-09-02T17:37:31. +PASS T48 已通过填报单批准加减分后,部门结果立即等于新的最终得分 — before=103 after=106 sheet_total=106 +PASS T49 否决加减分后立即重算,被否决的分不计入部门结果 — after_reject=106 expected=106 sheet_total=106 {"object":"kpi_bonus","id":"yhMJ8utDVQMU5D6p","record":{"id":"yhMJ8utDVQMU5D6p", +PASS T50 不存在方案发布之外来源的 KPI 共享规则(静态规则文件已删除,也没有旧的按单元规则) — 65 条,示例 kpi_p4bgt1dhcjcgb_5wyu7x_sheet_bu_north:admin | kpi_pvkw8oyuvmvhk_1728dr3_sheet_bu_ops:admin +PASS T51 填报人员看到本部门结果与本人到人结果,看不到他人的到人结果 — dept=true own=true other=false | A 可见 3 条:department/bu_market,department/bu_market,person +PASS T53 分管领导看到所分管主体(市场部)的部门结果,看不到非分管主体的结果 — 2 条:leader,department/bu_market +PASS T54 换人后新方案只为新任分管领导建规则,老领导没有新方案的规则 — 新方案 23 条规则 +PASS T55 换掉分管领导:老领导看不到新方案的填报单,历史方案的仍看得到 — 老领导可见 1 张:2026 年 10 月 月度考核 · 市场部 +PASS T56 新任分管领导看到新方案的市场部填报单 — 新领导可见 1 张:2026 年 11 月 月度考核 · 市场部 +PASS T57 方案关闭后填报单 / 核对任务 / 调整的共享降为只读(留读、去写) — 25 条规则,其中可编辑 0 条 +PASS T52 演示种子组织单元在启动时被补上组织归属(与 Setup 里新建的单元一致) — bu_east:org_mtkdoaw9o1j1w7ya bu_hq:org_mtkdoaw9o1j1w7ya bu_hr:org_mtkdoaw9o1j1w7ya bu_market:org_mtkdoaw9o1j1w7ya bu_north:org_mtkdoaw9o1j1w7ya bu_ops:org_mtkdoaw9o1j1w7ya bu_south:org_mtkdoaw9o1j1w7ya bu_west:org_mtkdoaw9o1j1w7ya +PASS T58 非人力审核岗位登记加减分被拒绝(第 10 章第 6 项:人力审核提出) — status=422 {"error":"登记加减分失败:只有人力审核岗位可以登记加减分。请联系人力审核办理。","code":"VALIDATION_ERROR","declaredCode":"KPI_BONUS_REGISTER_POSITION","object":"kpi_bonus"} +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":"oj_yJBYKgUrj8adI","record":{"id":"oj_yJBYKgUrj8 +PASS T63 考核结果的计算明细 JSON 逐指标带 is_adjusted,被调整的那条为 true — lines=3 adjusted={"plan_indicator":"1GLVXvVPxDO9xY2G","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 +{"passed":66,"failed":0} +``` From 98cf887450737d4375dd6eacfacae140c3afb1d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 17:55:30 +0000 Subject: [PATCH 3/8] feat(kpi): grant the HR positions record-level write scope on their own plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruling of 2026-09-02, option A. Plan publish now writes two more classes of per-plan sharing rule, using the platform's `position` recipient type (ADR-0090 D3 — the rule evaluator expands a position into its holders), so the HR reviewer and HR head positions get edit on every entry sheet and every adjustment of that plan. Bonuses need no rule of their own: they are master-detail children of the entry sheet, and the record-level check reads the master. The new rules ride the existing machinery unchanged — criteria carry the plan id, names carry the plan prefix, publish reconciles them, and a closed or archived plan downgrades them from edit to read with everything else. Declaration now matches enforcement: the department reporter permission set no longer declares create on kpi_bonus, since only the HR reviewer may register one. Rule count per plan grows by four, so the e2e expectation is derived again from the plan's configuration rather than adjusted by hand. Part of #10 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb --- scripts/e2e-flow.mjs | 84 +++++++++++++++++++++++++++++++-- src/security/index.ts | 4 +- src/services/sharing-service.ts | 52 +++++++++++++++++++- test/sharing.test.ts | 27 +++++++++++ 4 files changed, 162 insertions(+), 5 deletions(-) diff --git a/scripts/e2e-flow.mjs b/scripts/e2e-flow.mjs index 6fceaef..30898ce 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 () => { @@ -376,7 +377,9 @@ 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(); -log('T58', '非人力审核岗位登记加减分被拒绝(第 10 章第 6 项:人力审核提出)', r.status >= 400 && msg(r).includes('只有人力审核岗位可以登记加减分'), `status=${r.status} ${msg(r)}`); +// 声明与执行一致后,部门填报人员的权限集不再声明加减分的新建权限 —— 拒绝发生在权限层, +// 比业务规则更早;业务规则的文案由 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: '季度评优' }); @@ -432,6 +435,81 @@ 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 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/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/sharing-service.ts b/src/services/sharing-service.ts index 97a151f..261ab51 100644 --- a/src/services/sharing-service.ts +++ b/src/services/sharing-service.ts @@ -37,7 +37,7 @@ export interface SharingIntent { object: string; /** 记录筛选条件(平台 FilterCondition 的 JSON 形态),恒含方案 id。 */ criteria: Record; - recipientType: 'unit_and_subordinates' | 'user'; + recipientType: 'unit_and_subordinates' | 'user' | 'position'; recipientId: string; accessLevel: 'read' | 'edit'; /** @@ -92,12 +92,40 @@ export const LEGACY_RULE_PREFIX = 'kpi_share_'; * 由方案配置推导出本方案需要的全部共享规则(纯函数,便于单元测试)。 * * - 考核主体单元:填报单 / 核对任务 / 数据调整可编辑,本单元结果只读; + * - 人力岗位(人力审核 / 人力负责人):本方案**全部**填报单与数据调整可编辑; * - 分管领导:所分管主体的填报单可编辑(领导审批节点要能改状态)、所分管主体的结果只读、 * 本人结果(分管领导维度)只读; * - 被考核员工:本人到人结果只读。 * * 方案已关闭 / 已归档时,可编辑的三类降为只读。 */ +/** + * 人力岗位的记录级写范围(调度员 2026-09-02 裁定,选项 A)。 + * + * 人力审核要在填报单下**登记加减分**、在人力审核节点**审核通过 / 驳回**,人力负责人要 + * **审批加减分与数据调整** —— 这些都是对填报单及其子记录的写。填报单 OWD 是 private, + * 权限集里的 org 范围只放开读,写仍要过记录级判定,所以两个岗位**必须**拿到记录共享, + * 否则确认口径(第 10 章第 6 项)在真实岗位账号下根本执行不了。 + * + * 收件方直接用平台的 `position` 类型(ADR-0090 D3:岗位是扁平收件方,规则求值器 + * `expandRecipient` 把它展开成该岗位的全部持有人),不需要「持有该岗位的用户」这类等价 + * 表达。范围与其它三类规则同一套机制:条件带方案 id、规则名带方案前缀、发布时对账、 + * 方案已关闭 / 已归档后由 `edit` 降为 `read`。 + * + * 加减分(`kpi_bonus`)不需要自己的规则:它是填报单的主从子记录(`controlled_by_parent`), + * 记录级判定看的是主记录 —— 拿到填报单的 edit,插入 / 修改子记录就成立。 + */ +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: '数据调整' }, +]; + export function planSharingIntents( planId: string, subjects: SubjectRow[], @@ -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/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([]); }); From a92c9d9f3d9251269934246f92b1dfd6f6691888 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:02:06 +0000 Subject: [PATCH 4/8] =?UTF-8?q?docs:=20second=20round=20=E2=80=94=20HR=20p?= =?UTF-8?q?ositions=20verified=20with=20their=20own=20accounts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/04 turns row 17 back to fully implemented: the deviation recorded in the first round (HR positions had read scope only) is fixed and retested with real position accounts, and the statistics line is recounted against the status column (26 fully implemented, 1 deviation left: no load test yet). docs/05 is refreshed from the second run: verdict is now a pass over 72 cases, the environment table carries the retested commit and the rebuild caveat, the plan-and-result table covers the six added cases, each of them has its own detail section, and the problem record closes items 1 and 2 as fixed-and- retested while adding the stale-build finding that cost the first attempt of this round. Part of #10 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb --- ...10\345\272\246\346\270\205\345\215\225.md" | 12 +- ...52\346\265\213\350\256\260\345\275\225.md" | 212 ++++++++++++------ 2 files changed, 149 insertions(+), 75 deletions(-) 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 1129f32..a83b411 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" @@ -27,7 +27,7 @@ | 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`;反例 T58、T59) | — 不封顶不保底 = 第 10 章第 6 项确认口径。人力岗位账号当前够不到写路径,记在 #17 | +| 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 | @@ -39,9 +39,9 @@ | # | 需求 | 态 | 实现落点 / 证据 | 偏差与出口 | |---|---|---|---|---| -| 17 | 六类角色职责 | ⚠️ | 岗位 `kpi_admin / kpi_hr_reviewer / kpi_hr_head / kpi_dept_reporter / kpi_branch_checker / kpi_exec_leader`,流程节点按岗位把关(hook 经 `sys_user_position` 查询);加减分登记 / 审批的岗位分离已落地(T58、T59) | **偏差**:人力审核、人力负责人两个岗位当前只有读范围 —— 填报单 OWD 为 private,记录级写权限只由方案发布的共享规则授予给参与主体单元、核对分公司、分管领导,人力岗位不在其中,因此加减分登记 / 审批与人力审核节点的推进实际只有管理员(组织 owner / admin)执行得了。**出口:升级裁决** —— 人力岗位的记录级写范围要先拍板(全部方案 / 仅本方案、写到哪些对象),再由数据范围工作项补规则;现象、最小复现与绕行见 `docs/05-自测记录.md` 问题记录 #1 | -| 18 | 数据范围:全部 / 分管 / 本部门 / 仅本人 | ✅ | 全部 = org、仅本人 = own 原生;本部门 / 本分公司 / 分管范围 = private 基线 + 方案发布时按参与主体、分管领导、到人分工写入的**动态共享规则**(`src/services/sharing-service.ts`),组织变化零元数据改动;到人结果行级安全只挡他人的到人维度(T41、T44、T46、T51)。不再依赖企业版 `hierarchy-security`(设计第 17 项) | 四点实现说明:① 规则**按方案**建(条件带方案 id、规则名按方案加前缀),发布时对账停用本方案下已不该存在的规则 —— 换掉分管领导、撤主体、撤到人分工都当场失效,历史方案的授权原样保留(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 | — | +| 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);改值由填报部门经数据调整流程完成并留痕 | — | @@ -56,8 +56,8 @@ ## 五、统计 -- 共 27 条:✅ 完整实现 **25** 条;⚠️ 有偏差 **2** 条(#17 人力岗位写范围、#23 未压测);❌ 未实现 **0** 条。 +- 共 27 条:✅ 完整实现 **26** 条;⚠️ 有偏差 **1** 条(#23 未压测);❌ 未实现 **0** 条。 - ⚠️ 出口: - - **#17 人力岗位的记录级写权限** → 升级裁决:见 `docs/05-自测记录.md` 问题记录 #1(含最小复现与绕行:由考核系统管理员代办),由 PM 分诊后另立工作项; - **#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 70d62c3..0e0512b 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" @@ -5,45 +5,44 @@ > 数据接口,不是浏览器 UI 走查,也没有截图。按 os-project-std-evidence,接口级证据只能作为写操作 > 落库的第二观察点,不替代 UI 功能证据;UI 走查与截图留待有浏览器环境的验收轮次(pm-test)。 > 本文件是挂在工作项 #10 上的测试报告的仓库内落盘版,两者同一份内容。 +> 本记录含两轮:第一轮落地已确认设计口径(T58~T64);第二轮按调度员 2026-09-02 的裁定补上人力岗位的 +> 记录级写范围(T65~T70),并以**真实岗位账号**重测第一轮遗留的阻塞问题。全文数据取自第二轮真跑。 ## 第 1 页:结论 -**结论:❌ 存在问题** +**结论:✅ 通过** -共 66 条用例:通过 **66**。静态门禁 3/3 绿(validate / typecheck / vitest 64 条)。 -本工作项新增用例 7 条(T58~T64)全部通过;但工作项 #10 的验收标准 1、2 要求「以人力审核账号登记加减分」 -「以人力负责人账号批准」,这两步在当前数据范围设计下**任何人力岗位账号都执行不了**(问题记录 #1), -因此结论按模板取「存在问题」。 - -**问题清单** - -| 用例 | 问题现象(预期 vs 实际) | 严重程度 | 问题原因(初步定位) | 处理建议 | 截图 | -|---|---|---|---|---|---| -| **验收标准 1 / 2**(无对应用例:该路径不可达) | 预期:持「人力审核」岗位的账号能在填报单下登记加减分、持「人力负责人」岗位的账号能批准;实际:两个账号都被平台以 403 挡在业务规则之前 —— 登记报「requires edit access to its master record」,连改填报单备注也报「You do not have access to this record」 | 阻塞 | 填报单默认共享为 private,记录级写权限只由方案发布写入的共享规则授予(参与主体单元、分公司、分管领导),人力岗位不在其中;人力岗位的权限集虽是 org 范围,读得到、写不到 | 【要改代码】需先拍板人力岗位的记录级写范围(见「需拍板事项」),再由数据范围工作项补规则;本工作项的岗位判定本身已验证(T58、T59、T60) | 无(接口级证据,见问题记录 #1) | +共 72 条用例:通过 **72**。静态门禁 3/3 绿(validate / typecheck / vitest 67 条)。 +工作项 #10 的七条验收标准全部达成 —— 其中验收标准 1、2(以人力审核账号登记、以人力负责人账号批准) +在第一轮受数据范围所限无法演示,第二轮补上岗位共享规则后由真实岗位账号取证通过(T65、T66、T67)。 **关键观察**(接口级,替代截图) -- T58 拒绝原文:`{"error":"登记加减分失败:只有人力审核岗位可以登记加减分。请联系人力审核办理。","code":"VALIDATION_ERROR","declaredCode":"KPI_BONUS_REGISTER_POSITION","object":"kpi_bonus"}` +- 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":"1GLVXvVPxDO9xY2G","indicator_name":"客户满意度","score_rate":90,"score":12.5,"is_adjusted":true,"adjust_type_applied":"result"}` +- T63 计算明细原文:`{"plan_indicator":"...","indicator_name":"客户满意度","score_rate":90,"score":12.5,"is_adjusted":true,"adjust_type_applied":"result"}` ## 2. 测试环境与账号 | 项 | 值 | |---|---| -| 被测版本 | commit `79437b2bfa0611237c4bd2e1c17e1eb3354f8bf5`(分支 `issue-10-align-confirmed-design`,本 PR 的代码提交)。核实方式(开测前三级核对):① `/proc/net/tcp` 中处于 LISTEN 的端口含 3110,且无 3100 / 3111;② 占用 3110 的进程 pid 3441,`/proc/3441/cwd` = `/home/user/kpi-issue-10`;③ 该 worktree `git rev-parse HEAD` = 79437b2,`git status` 干净。本文件与 `docs/04` 的文档提交叠在该提交之上,不改任何被测代码 | +| 被测版本 | commit `98cf887450737d4375dd6eacfacae140c3afb1d4`(分支 `issue-10-align-confirmed-design`,第二轮代码提交;第一轮代码提交 `79437b2`)。核实方式(开测前三级核对):① `/proc/net/tcp` 中处于 LISTEN 的端口含 3110,且无 3100 / 3111;② 占用 3110 的进程 pid 5428,`/proc/5428/cwd` = `/home/user/kpi-issue-10`;③ 该 worktree `git rev-parse HEAD` = 98cf887,`git status` 干净。文档提交叠在其上,不改任何被测代码 | | 平台 | @objectstack/* 17.2.0(npm 发布版),SqlDriver(better-sqlite3),单租户 | -| 环境 / 启动 | `OS_PORT=3110 OS_DATABASE_URL=file:./.objectstack/issue-10.db pnpm dev`(空库启动,自动加载 46 行演示种子),地址 `http://localhost:3110` | +| 环境 / 启动 | `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 月方案、账号、加减分、调整)由脚本按序创建,凭本表可从空库重建同等环境 | +| 测试账号 | 管理员 `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 走查与截图;大数据量性能;人力岗位账号的写路径(问题记录 #1) | +| 未覆盖 | 浏览器 UI 走查与截图;大数据量性能 | ## 3. 测试计划与结果总表 -四类齐全:主流程 35、边界 3、异常 14、权限 14(合计 66)。 +四类齐全:主流程 38、边界 3、异常 14、权限 17(合计 72)。 | 编号 | 标题 | 类型 | 端 | 角色 | 结果 | 证据 | |---|---|---|---|---|---|---| @@ -106,27 +105,28 @@ | T56 | 新任分管领导看到新方案的市场部填报单 | 权限 | REST | 分管领导(新任) | ✅ 通过 | §6 运行输出 T56 | | T57 | 方案关闭后填报单 / 核对任务 / 调整的共享降为只读(留读、去写) | 权限 | REST | 管理员 | ✅ 通过 | §6 运行输出 T57 | | T52 | 演示种子组织单元在启动时被补上组织归属(与 Setup 里新建的单元一致) | 主流程 | REST | 系统(启动) | ✅ 通过 | §6 运行输出 T52 | -| T58 | 非人力审核岗位登记加减分被拒绝(第 10 章第 6 项:人力审核提出) **(本工作项新增)** | 权限 | REST | 部门填报人员 | ✅ 通过 | §6 运行输出 T58 | +| 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 | ## 4. 逐用例明细(本工作项新增 / 改动的用例) -### T58:非人力审核岗位登记加减分被拒绝 +### T58:部门填报人员登记加减分被拒绝(权限集不再声明新建权限) -- **前置条件**:T54 已发布 2026 年 11 月方案(市场部为参与主体),部门填报人员账号 A 已入市场部并持岗位 `kpi_dept_reporter`;等待该方案的市场部填报单共享到 A 后再操作。 -- **操作步骤**: - 1. 以账号 A 登录 → `POST /api/v1/data/kpi_bonus`,body:`{ sheet: <11 月市场部填报单>, title: '越权登记', bonus_type: 'add', points: 1, reason: '岗位反例' }`。 -- **预期结果**: - 1. 请求被拒绝(4xx); - 2. 提示为三段式且点名岗位:「登记加减分失败:只有人力审核岗位可以登记加减分。请联系人力审核办理。」 -- **实际观察**: - 1. HTTP 422; - 2. `{"error":"登记加减分失败:只有人力审核岗位可以登记加减分。请联系人力审核办理。","code":"VALIDATION_ERROR","declaredCode":"KPI_BONUS_REGISTER_POSITION","object":"kpi_bonus"}`。 +- **前置条件**: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` 取证。 - **结果**:✅ 通过 - **截图**:无(接口级证据,见首段降级声明) @@ -230,12 +230,81 @@ - **补充说明**:拒绝发生在权限层(核对人员的权限集对填报明细只有读 / 导出),不是业务 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。 +- **结果**:✅ 通过 +- **截图**:无 + ## 5. 问题记录 | # | 用例 | 现象 | 复现步骤 | 原因分析 | 处理建议 | 截图 | 处置 | |---|---|---|---|---|---|---|---| -| 1 | 验收标准 1 / 2(无对应用例) | 人力审核账号登记不了加减分、人力负责人账号批准不了加减分,连打开填报单改个备注都被拒;两个账号都看得到填报单列表 | ① 管理员建号 `hr.repro@kpi.test` 并 `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,记录级写权限只来自方案发布时写入的动态共享规则,收件人是参与主体单元、核对分公司、分管领导与被下达员工;人力岗位不在收件人之列。人力岗位权限集里的 `readScope/writeScope: '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))` | 【要改代码】先拍板人力岗位的记录级写范围(全部方案的填报单?还是只在本方案、且只到加减分 / 调整 / 审核动作?),再在方案发布的共享规则里补一类收件人。**绕行**:由考核系统管理员(或组织 owner)代为登记 / 审批 —— 岗位判定对管理员放行,业务不至于卡死 | 无(接口级) | 待细化退回:已写入本 PR 的「需拍板事项」,由 PM 分诊后另立工作项 | -| 2 | T58 | 部门填报人员的权限集仍声明了加减分的新建权限,但业务规则已只允许人力审核登记 —— 声明与执行不一致(表现为「按钮点了才报错」) | 见 T58 | 权限集 `kpi_dept_reporter_set` 的 `kpi_bonus.allowCreate` 仍为 true;本工作项只改业务规则,未动权限集(范围纪律) | 【要改代码】随问题 #1 的拍板一起收窄该权限声明,让「不可用」在按钮层就体现出来 | 无 | 待定(遗留问题,已列入 PR 与返回报告) | +| 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 通过 | ## 6. 运行输出(当次真跑原样) @@ -255,13 +324,13 @@ ──────────────────────────────────────── → Loading configuration... Config: /home/user/kpi-issue-10/objectstack.config.ts - Load time: 273ms + Load time: 268ms → Validating against ObjectStack Protocol... → Running author-time rules (41)... → Checking capability providers (#3366)... → Checking package docs (ADR-0046)... - ✓ Validation passed (654ms) + ✓ Validation passed (597ms) 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. @@ -284,24 +353,23 @@ Test Files 6 passed (6) - Tests 64 passed (64) - Start at 17:38:16 - Duration 818ms (transform 416ms, setup 0ms, import 1.09s, tests 64ms, environment 1ms) + Tests 67 passed (67) + Start at 17:58:51 + Duration 1.08s (transform 806ms, setup 0ms, import 1.65s, tests 79ms, environment 1ms) ``` -单元测试 64 条分布:计分引擎 14、状态机 / 流程配置 11、数据范围规则 23、汇总 4、CEL 求值 3、 -已确认设计口径(本工作项新增)9。 +单元测试 67 条分布:计分引擎 14、状态机 / 流程配置 11、数据范围规则 26(第二轮 +3:人力岗位规则生成、锚定与关闭后降级)、汇总 4、CEL 求值 3、已确认设计口径 9。 ### 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. 指标下达:主体「市场部」的权重合计为 90%,必须等于 100%","code":"VALIDATION_ERROR","declaredCode":"KPI_PLAN_INCOMPLETE","object":"kpi_plan"} -PASS T4 完整性通过后发布成功 — {"object":"kpi_plan","id":"-4bGt1dhCJcgb_jw","record":{"id":"-4bGt1dhCJcgb_jw","created_at":"2026-09-02T17:36:29.496Z","updated_at":"2026-09-02T17:37:24.582Z","organization_id":null,"created_by":null,"updated_by":"2BtTAu -PASS T5 发布生成 5 张填报单 — 2026 年 8 月 月度考核 · 华东分公司[draft], 2026 年 8 月 月度考核 · 市场部[draft], 2026 年 8 月 月度考核 · 华北分公司[draft], 2026 年 8 月 月度考核 · 华南分公司[draft], 2026 年 8 月 月度考核 · 运营部[draft] +PASS T2 指标下达 18 条且名称由 hook 填充 — 18 rows, sample: 客户满意度 · 运营部 +PASS T3 权重合计≠100 时发布被拒绝并列出问题 — {"error":"发布失败,发布前完整性检查未通过:\n1. 指标下达:主体「市场部」的权重合计为 80%,必须等于 100%","code":"VALIDATION_ERROR","declaredCode":"KPI_PLAN_INCOMPLETE","object":"kpi_plan"} +PASS T4 完整性通过后发布成功 — {"object":"kpi_plan","id":"33-m-HTqZ7IFvZH9","record":{"id":"33-m-HTqZ7IFvZH9","created_at":"2026-09-02T17:57:42.813Z","updated_at":"2026-09-02T17:58:16.231Z","organization_id":null,"created_by":null,"updated_by":"ucZamz +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 @@ -310,7 +378,7 @@ PASS T10 有指标未填实际值时提交被拒绝 — {"error":"提交失败: 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":"Jox2yHWhSbAkEIM9","record":{"id":"Jox2yHWhSbAkEIM9","created_at":"2026-09-02T17:37:24.245Z","updated_at":"2026-09-02T17:37:25.493Z","organization_id":"org_mtkdoaw9o1j1w7ya","created_by": +PASS T14 提交后进入分公司核对中 — status=branch_checking {"object":"kpi_entry_sheet","id":"04nrbRxc7JrLwfwR","record":{"id":"04nrbRxc7JrLwfwR","created_at":"2026-09-02T17:58:15.830Z","updated_at":"2026-09-02T17:58:17.069Z","organization_id":"org_mtkeflg0b0ihz6pj","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"} @@ -318,49 +386,55 @@ PASS T18 有争议未解决时不能推进 — {"error":"推进失败:还有 2 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":"Jox2yHWhSbAkEIM9","record":{"id":"Jox2yHWhSbAkEIM9","created_at":"2026-09-02T17:37:24.245Z","updated_at":"2026-09-02T17:37:26.810Z","organization_id":"org_mtkdoaw9o1j1w7ya","created_by": +PASS T22 人力审核通过 → 领导审批中 — status=leader_approving {"object":"kpi_entry_sheet","id":"04nrbRxc7JrLwfwR","record":{"id":"04nrbRxc7JrLwfwR","created_at":"2026-09-02T17:58:15.830Z","updated_at":"2026-09-02T17:58:18.471Z","organization_id":"org_mtkeflg0b0ihz6pj","created_by": PASS T23 领导审批通过 → 已通过,盖章 — status=approved PASS T24 通过后生成考核结果(部门维度) — department:88#1 -PASS T25 加减分批准后计入最终得分 91 — bonus_total=3 total=91 {"object":"kpi_bonus","id":"dVXbt5uRc4XKnzxl","record":{"id":"dVXbt5uRc4XKnzxl","created_at":"2026-09-02T17:37:27.098Z","updated_at":"2026-09-02T17:37:27.154Z","organization_id":"org_mtkdoaw9o1j1w7ya","created_by":"2BtTA -PASS T26 调整申请记录调整前值 144 与主体 — {"object":"kpi_adjustment","id":"TC1q9fQxzGzaxdaF","record":{"id":"TC1q9fQxzGzaxdaF","created_at":"2026-09-02T17:37:27.297Z","updated_at":"2026-09-02T17:37:27.297Z","organization_id":"org_mtkdoaw9o1j1w7ya","created_by":" +PASS T25 加减分批准后计入最终得分 91 — bonus_total=3 total=91 {"object":"kpi_bonus","id":"lWd8Z75DYlqV2q-2","record":{"id":"lWd8Z75DYlqV2q-2","created_at":"2026-09-02T17:58:18.784Z","updated_at":"2026-09-02T17:58:18.864Z","organization_id":"org_mtkeflg0b0ihz6pj","created_by":"ucZam +PASS T26 调整申请记录调整前值 144 与主体 — {"object":"kpi_adjustment","id":"D8TUj-mX_sq71IJY","record":{"id":"D8TUj-mX_sq71IJY","created_at":"2026-09-02T17:58:19.035Z","updated_at":"2026-09-02T17:58:19.035Z","organization_id":"org_mtkeflg0b0ihz6pj","created_by":" PASS T27 源数据调整落地并重算:利润 180 → 完成率 100%,得分 30;结果重新汇总 — actual=180 score=30 total=106 results=106 -PASS T28 归档生成不可变快照(含校验和、总分) — snap=2026 年 8 月 月度考核 · 市场部 · 归档快照 checksum=4466063bad69 total=106 +PASS T28 归档生成不可变快照(含校验和、总分) — snap=2026 年 8 月 月度考核 · 市场部 · 归档快照 checksum=f38c736683de 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 审核记录完整留痕(生成/提交/争议/确认/驳回/通过/调整/归档) — dispute,confirm,archive,approve,approve,submit,confirm,confirm,confirm,confirm,generate,approve,confirm,reject,adjust,approve +PASS T33 审核记录完整留痕(生成/提交/争议/确认/驳回/通过/调整/归档) — approve,approve,archive,reject,submit,confirm,confirm,confirm,generate,approve,confirm,approve,dispute,adjust,confirm,confirm 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":"vKW8OYUvMvHk3qWp","record":{"id":"vKW8OYUvMvHk3qWp"," +PASS T35 复制上一版本:流程节点 4、指标下达 18 已复制 — steps=4 pis=18 {"object":"kpi_plan","id":"5eTiRCO1oIsrm73g","record":{"id":"5eTiRCO1oIsrm73g"," 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":"LT09r7J6-0FGaTB_","record":{"id":"LT09r7J6-0FGaTB_"," sheets=5 -PASS T39 发布按方案配置写入动态共享规则,条数与方案配置精确相符,元数据零改动 — rules=25 expected=25 -PASS T39b 规则条件按方案隔离:每条规则的条件里都带本方案 id — 25 条 +PASS T38 发布第三版方案(市场部已配分管领导)生成 5 张填报单 — {"object":"kpi_plan","id":"wqgPOEL5-qCrTvvr","record":{"id":"wqgPOEL5-qCrTvvr"," sheets=5 +PASS T39 发布按方案配置写入动态共享规则,条数与方案配置精确相符,元数据零改动 — rules=29 expected=29 +PASS T39b 规则条件按方案隔离:每条规则的条件里都带本方案 id — 29 条 PASS T40 规则求值把单元共享展开成逐人记录共享行(市场部填报单 → 用户 A) — shares=10 -PASS T41 部门填报人员只看到本部门(市场部)填报单,含本次新发布的那张 — 3 张:2026 年 8 月 月度考核 · 市场部 | 2026 年 10 月 月度考核 · 市场部 | 2026 年 9 月 月度考核 · 市场部 -PASS T42 打开本部门以外(华东分公司)的填报单被拒绝 — status=404 {"error":"Record QavXsmDQ5WQdd-kk not found in kpi_entry_sheet","code":"RECORD_NOT_FOUND","object":" -PASS T43 部门填报人员能填写实际值并提交本部门填报单 — status=branch_checking score=103 {"object":"kpi_entry_sheet","id":"SwwGtMGTRUFcrA13","record":{"id":"SwwGtMGTRUFcrA13","created_at":" +PASS T41 部门填报人员只看到本部门(市场部)填报单,含本次新发布的那张 — 3 张:2026 年 8 月 月度考核 · 市场部 | 2026 年 9 月 月度考核 · 市场部 | 2026 年 10 月 月度考核 · 市场部 +PASS T42 打开本部门以外(华东分公司)的填报单被拒绝 — status=404 {"error":"Record d_aCxqGOfcWJ07Pj not found in kpi_entry_sheet","code":"RECORD_NOT_FOUND","object":" +PASS T43 部门填报人员能填写实际值并提交本部门填报单 — status=branch_checking score=103 {"object":"kpi_entry_sheet","id":"d0HgE21JjX53TWCD","record":{"id":"d0HgE21JjX53TWCD","created_at":" PASS T44 分公司核对人员只看到本分公司(华东)的核对任务 — 2 条:2026 年 10 月 月度考核 · 市场部 · 华东分公司 核对 | 2026 年 8 月 月度考核 · 市场部 · 华东分公司 核对 -PASS T45 分公司核对人员能确认本分公司的核对任务 — {"object":"kpi_check_task","id":"80au58dCOwBJcTHA","record":{"id":"80au58dCOwBJcTHA","created_at":"2026-09-02T17:37:32.3 +PASS T45 分公司核对人员能确认本分公司的核对任务 — {"object":"kpi_check_task","id":"_yTMCkdUEG0ULARS","record":{"id":"_yTMCkdUEG0ULARS","created_at":"2026-09-02T17:58:24.5 PASS T46 分管领导只看到分管主体(市场部)的填报单,看不到运营部 — 1 张:2026 年 10 月 月度考核 · 市场部 -PASS T47 分管领导能在「领导审批中」执行审核通过 — beforeLeader=leader_approving? status=approved {"object":"kpi_entry_sheet","id":"SwwGtMGTRUFcrA13","record":{"id":"SwwGtMGTRUFcrA13","created_at":"2026-09-02T17:37:31. +PASS T47 分管领导能在「领导审批中」执行审核通过 — beforeLeader=leader_approving? status=approved {"object":"kpi_entry_sheet","id":"d0HgE21JjX53TWCD","record":{"id":"d0HgE21JjX53TWCD","created_at":"2026-09-02T17:58:23. PASS T48 已通过填报单批准加减分后,部门结果立即等于新的最终得分 — before=103 after=106 sheet_total=106 -PASS T49 否决加减分后立即重算,被否决的分不计入部门结果 — after_reject=106 expected=106 sheet_total=106 {"object":"kpi_bonus","id":"yhMJ8utDVQMU5D6p","record":{"id":"yhMJ8utDVQMU5D6p", -PASS T50 不存在方案发布之外来源的 KPI 共享规则(静态规则文件已删除,也没有旧的按单元规则) — 65 条,示例 kpi_p4bgt1dhcjcgb_5wyu7x_sheet_bu_north:admin | kpi_pvkw8oyuvmvhk_1728dr3_sheet_bu_ops:admin -PASS T51 填报人员看到本部门结果与本人到人结果,看不到他人的到人结果 — dept=true own=true other=false | A 可见 3 条:department/bu_market,department/bu_market,person -PASS T53 分管领导看到所分管主体(市场部)的部门结果,看不到非分管主体的结果 — 2 条:leader,department/bu_market -PASS T54 换人后新方案只为新任分管领导建规则,老领导没有新方案的规则 — 新方案 23 条规则 +PASS T49 否决加减分后立即重算,被否决的分不计入部门结果 — after_reject=106 expected=106 sheet_total=106 {"object":"kpi_bonus","id":"LftmnZkQbQd-cPtS","record":{"id":"LftmnZkQbQd-cPtS", +PASS T50 不存在方案发布之外来源的 KPI 共享规则(静态规则文件已删除,也没有旧的按单元规则) — 77 条,示例 kpi_p5etirco1oisr_qrsqcu_check_bu_south:admin | kpi_pwqgpoel5qcrt_ziy10s_check_bu_north:admin +PASS T51 填报人员看到本部门结果与本人到人结果,看不到他人的到人结果 — dept=true own=true other=false | A 可见 3 条:department/bu_market,person,department/bu_market +PASS T53 分管领导看到所分管主体(市场部)的部门结果,看不到非分管主体的结果 — 2 条:department/bu_market,leader +PASS T54 换人后新方案只为新任分管领导建规则,老领导没有新方案的规则 — 新方案 27 条规则 PASS T55 换掉分管领导:老领导看不到新方案的填报单,历史方案的仍看得到 — 老领导可见 1 张:2026 年 10 月 月度考核 · 市场部 PASS T56 新任分管领导看到新方案的市场部填报单 — 新领导可见 1 张:2026 年 11 月 月度考核 · 市场部 -PASS T57 方案关闭后填报单 / 核对任务 / 调整的共享降为只读(留读、去写) — 25 条规则,其中可编辑 0 条 -PASS T52 演示种子组织单元在启动时被补上组织归属(与 Setup 里新建的单元一致) — bu_east:org_mtkdoaw9o1j1w7ya bu_hq:org_mtkdoaw9o1j1w7ya bu_hr:org_mtkdoaw9o1j1w7ya bu_market:org_mtkdoaw9o1j1w7ya bu_north:org_mtkdoaw9o1j1w7ya bu_ops:org_mtkdoaw9o1j1w7ya bu_south:org_mtkdoaw9o1j1w7ya bu_west:org_mtkdoaw9o1j1w7ya -PASS T58 非人力审核岗位登记加减分被拒绝(第 10 章第 6 项:人力审核提出) — status=422 {"error":"登记加减分失败:只有人力审核岗位可以登记加减分。请联系人力审核办理。","code":"VALIDATION_ERROR","declaredCode":"KPI_BONUS_REGISTER_POSITION","object":"kpi_bonus"} +PASS T57 方案关闭后填报单 / 核对任务 / 调整的共享降为只读(留读、去写) — 29 条规则,其中可编辑 0 条 +PASS T52 演示种子组织单元在启动时被补上组织归属(与 Setup 里新建的单元一致) — bu_east:org_mtkeflg0b0ihz6pj bu_hq:org_mtkeflg0b0ihz6pj bu_hr:org_mtkeflg0b0ihz6pj bu_market:org_mtkeflg0b0ihz6pj bu_north:org_mtkeflg0b0ihz6pj bu_ops:org_mtkeflg0b0ihz6pj bu_south:org_mtkeflg0b0ihz6pj bu_west:org_mtkeflg0b0ihz6pj +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":"oj_yJBYKgUrj8adI","record":{"id":"oj_yJBYKgUrj8 -PASS T63 考核结果的计算明细 JSON 逐指标带 is_adjusted,被调整的那条为 true — lines=3 adjusted={"plan_indicator":"1GLVXvVPxDO9xY2G","indicator_name":"客户满意度","score_rate":90,"score":12.5,"is_adjusted":true,"adjust_type_applied":"result"} +PASS T62 结果调整落地后该行标记「已调整」= 是、调整类型 = 计算结果,未调整行保持否 — adjusted: is_adjusted=true type=result final=12.5 | untouched: is_adjusted=false {"object":"kpi_adjustment","id":"8SI44-Cqkv-aT9cf","record":{"id":"8SI44-Cqkv-aT +PASS T63 考核结果的计算明细 JSON 逐指标带 is_adjusted,被调整的那条为 true — lines=3 adjusted={"plan_indicator":"ug7kqU0igfh8dmOF","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 -{"passed":66,"failed":0} +PASS T65 人力审核账号登记加减分成功(岗位规则已授予记录级编辑权) — share=true register=201 {"object":"kpi_bonus","id":"YxzisR6zuAlDTzAD","record":{"id":"YxzisR6zuAlDTzAD","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 +{"passed":72,"failed":0} ``` From 6ba334fab01a02da5ce60cb6e556ae718fa1eea8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:21:55 +0000 Subject: [PATCH 5/8] fix(kpi): keep the whole derived column set in the entry-sheet inline grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2. An explicit `inlineColumns` REPLACES the column set the platform derives from the child object, so declaring one to surface the "adjusted" marker silently dropped six columns from the entry grid — the required source assignment among them. All nineteen columns of `kpi_entry_line` (every field except the master-detail one) are declared again, in the field-definition order the derivation used, with the two new columns last. The three verbose ones — the calculation trace, the last adjustment and the indicator reference — are collapsed with the platform's own `defaultHidden` rather than dropped, so the column picker still reaches them. Also from the review, both small: - The same-period publish check queried plans through the system context with no tenant filter, so a published plan of another organization could block a publish. The organization is now read off the persisted plan row and applied as an equality condition, matching how the sharing service anchors its rules; the pure decision compares tenants too, and falls back to the period-only verdict when either side carries no organization — a missing column must not turn into a silent pass. - The HR-position rationale moved into the JSDoc of `planSharingIntents` instead of sitting between that JSDoc and the signature. Part of #10 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb --- src/hooks/plan.hook.ts | 32 +++++++++++++++++++++++++++++--- src/objects/entry.object.ts | 25 +++++++++++++++++++------ src/services/sharing-service.ts | 28 ++++++++++++++-------------- test/design-alignment.test.ts | 9 +++++++++ 4 files changed, 71 insertions(+), 23 deletions(-) diff --git a/src/hooks/plan.hook.ts b/src/hooks/plan.hook.ts index c7a75d7..c4abbae 100644 --- a/src/hooks/plan.hook.ts +++ b/src/hooks/plan.hook.ts @@ -22,6 +22,8 @@ export interface PlanPeriodKey { period_no?: unknown; name?: unknown; status?: unknown; + /** 租户隔离锚点(平台注入的组织归属)。 */ + organization_id?: unknown; } /** 两条方案是否落在同一个考核周期(期数按数值比较,避免 "1" 与 1 判成不同周期)。 */ @@ -33,14 +35,30 @@ export function samePeriod(a: PlanPeriodKey, b: PlanPeriodKey): boolean { ); } +/** + * 两条方案是否属于同一租户。 + * + * 只在**两边都带**组织归属时才比较:带了就必须相等(多租户下别家的方案挡不住本家的发布); + * 有一边没有(单租户部署、或调用方没把该列读出来)就退回只按周期判定 —— 宁可多挡一次让人 + * 看见,也不因为缺一个字段而**静默**放行一个本该被拒的发布。 + */ +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; @@ -158,9 +176,17 @@ export const PlanPublishHook: Hook = { // 同周期唯一生效版本:先于完整性检查判定 —— 这一条与方案内部配置无关,先说清楚 // 「这个周期已经有生效版本了」比让作者先去补权重更省事。 + // + // 查询走系统上下文(要看到本租户里当前用户未必可见的方案),所以租户隔离得自己带上: + // 组织归属从**落库的方案行**上取,不从 ctx.previous 取 —— 那里未必带这一列。与 + // services/sharing-service.ts 的做法一致:组织是等值条件,不做宽松匹配。 const period = merged>(ctx); - const published = await api.object('kpi_plan').find({ where: { status: 'published' } }); - const conflict = findPublishedConflict(published as PlanPeriodKey[], { ...period, id }); + const self = await findById(api, 'kpi_plan', id); + const organizationId = self?.organization_id ? String(self.organization_id) : null; + const where: Record = { status: 'published' }; + if (organizationId) where.organization_id = organizationId; + const published = await api.object('kpi_plan').find({ where }); + 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[] = []; diff --git a/src/objects/entry.object.ts b/src/objects/entry.object.ts index 081b5d3..950abc6 100644 --- a/src/objects/entry.object.ts +++ b/src/objects/entry.object.ts @@ -127,13 +127,26 @@ export const EntryLine = ObjectSchema.create({ deleteBehavior: 'cascade', inlineEdit: 'grid', inlineTitle: '指标填报', - // 内嵌网格的列显式声明(身份列,类型 / 标签由子对象字段定义补齐),这样「已调整」 - // 与「调整类型」在填报单里也看得见,而不只在明细列表视图里。 + /** + * 内嵌网格的列**显式**声明 —— 只为让「已调整 / 调整类型」在填报单里也看得见。 + * + * 显式声明会**整体替换**平台从子对象派生的列集,所以这里必须列全:下面前 17 项就是 + * 本对象除主从字段(`sheet`)之外的全部字段,顺序与字段定义一致(= 原派生顺序), + * 新增的两列排在最后。冗长但仍要留在网格里的三列(计算说明、最近调整、指标)用平台 + * 原生的 `defaultHidden` 折叠 —— 默认不占宽度,列选择器里仍能打开,而不是删掉。 + * + * 每项只写 `name`(身份列):不声明 `type` 时,标签、类型、选项、lookup 目标与联动 + * 规则都由子对象自己的字段定义补齐,列与字段因此不会各说各话。 + */ inlineColumns: [ - { name: 'indicator_name' }, { name: 'unit' }, { name: 'target_value' }, { name: 'weight' }, - { name: 'actual_value' }, { name: 'completion_rate' }, { name: 'score_rate' }, { name: 'score' }, - { name: 'adjusted_score' }, { name: 'is_adjusted' }, { name: 'adjust_type_applied' }, - { name: 'final_score' }, { name: 'remark' }, + { 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 }), diff --git a/src/services/sharing-service.ts b/src/services/sharing-service.ts index 261ab51..30b58d5 100644 --- a/src/services/sharing-service.ts +++ b/src/services/sharing-service.ts @@ -88,6 +88,18 @@ 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: '数据调整' }, +]; + /** * 由方案配置推导出本方案需要的全部共享规则(纯函数,便于单元测试)。 * @@ -98,9 +110,8 @@ export const LEGACY_RULE_PREFIX = 'kpi_share_'; * - 被考核员工:本人到人结果只读。 * * 方案已关闭 / 已归档时,可编辑的三类降为只读。 - */ -/** - * 人力岗位的记录级写范围(调度员 2026-09-02 裁定,选项 A)。 + * + * ## 人力岗位的记录级写范围(调度员 2026-09-02 裁定,选项 A) * * 人力审核要在填报单下**登记加减分**、在人力审核节点**审核通过 / 驳回**,人力负责人要 * **审批加减分与数据调整** —— 这些都是对填报单及其子记录的写。填报单 OWD 是 private, @@ -115,17 +126,6 @@ export const LEGACY_RULE_PREFIX = 'kpi_share_'; * 加减分(`kpi_bonus`)不需要自己的规则:它是填报单的主从子记录(`controlled_by_parent`), * 记录级判定看的是主记录 —— 拿到填报单的 edit,插入 / 修改子记录就成立。 */ -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: '数据调整' }, -]; - export function planSharingIntents( planId: string, subjects: SubjectRow[], diff --git a/test/design-alignment.test.ts b/test/design-alignment.test.ts index d677ac8..5cfe29d 100644 --- a/test/design-alignment.test.ts +++ b/test/design-alignment.test.ts @@ -48,6 +48,15 @@ describe('同周期唯一生效版本(第 10 章第 10 项 / 设计 5.2)', () => 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'); + }); + it('已关闭 / 已归档不是生效版本,不阻断发布;草稿同样不阻断;自己不挡自己', () => { expect(findPublishedConflict([{ ...live, status: 'closed' }], draft)).toBeNull(); expect(findPublishedConflict([{ ...live, status: 'archived' }], draft)).toBeNull(); From 783fa804ec5cd1fad6f52d46e72498166051c492 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:24:10 +0000 Subject: [PATCH 6/8] docs: record the inline-grid column regression and its fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/05 gains problem record 4 (the entry grid lost six derived columns when the explicit column list was introduced), closed as fixed and retested on 6ba334f with the built metadata checked column by column, and is refreshed from this round's run: 68 unit tests, the retested commit and its cross-checks, and the verbatim verify / e2e output. docs/04 row 25 now states that the grid columns are declared explicitly and that no derived column was lost — the three verbose ones are collapsed, not dropped. Part of #10 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb --- ...10\345\272\246\346\270\205\345\215\225.md" | 2 +- ...52\346\265\213\350\256\260\345\275\225.md" | 74 ++++++++++--------- 2 files changed, 39 insertions(+), 37 deletions(-) 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 a83b411..52ed011 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" @@ -32,7 +32,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 | — | +| 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` 是否已有「已发布」方案,已关闭 / 已归档不阻断;拒绝文案点名生效版本;T61 + 单元测试 3 条 | — | ## 三、角色与安全(需求 §2 / 设计第 9 章) 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 0e0512b..9d4eb15 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" @@ -5,14 +5,15 @@ > 数据接口,不是浏览器 UI 走查,也没有截图。按 os-project-std-evidence,接口级证据只能作为写操作 > 落库的第二观察点,不替代 UI 功能证据;UI 走查与截图留待有浏览器环境的验收轮次(pm-test)。 > 本文件是挂在工作项 #10 上的测试报告的仓库内落盘版,两者同一份内容。 -> 本记录含两轮:第一轮落地已确认设计口径(T58~T64);第二轮按调度员 2026-09-02 的裁定补上人力岗位的 -> 记录级写范围(T65~T70),并以**真实岗位账号**重测第一轮遗留的阻塞问题。全文数据取自第二轮真跑。 +> 本记录含三轮:第一轮落地已确认设计口径(T58~T64);第二轮按调度员 2026-09-02 的裁定补上人力岗位的 +> 记录级写范围(T65~T70),并以**真实岗位账号**重测第一轮遗留的阻塞问题;第三轮修 PR 评审提出的内嵌 +> 网格列集回归与两处小改(问题记录 #4)。全文数据取自**第三轮**真跑,用例集与第二轮相同。 ## 第 1 页:结论 **结论:✅ 通过** -共 72 条用例:通过 **72**。静态门禁 3/3 绿(validate / typecheck / vitest 67 条)。 +共 72 条用例:通过 **72**。静态门禁 3/3 绿(validate / typecheck / vitest 68 条)。 工作项 #10 的七条验收标准全部达成 —— 其中验收标准 1、2(以人力审核账号登记、以人力负责人账号批准) 在第一轮受数据范围所限无法演示,第二轮补上岗位共享规则后由真实岗位账号取证通过(T65、T66、T67)。 @@ -30,7 +31,7 @@ | 项 | 值 | |---|---| -| 被测版本 | commit `98cf887450737d4375dd6eacfacae140c3afb1d4`(分支 `issue-10-align-confirmed-design`,第二轮代码提交;第一轮代码提交 `79437b2`)。核实方式(开测前三级核对):① `/proc/net/tcp` 中处于 LISTEN 的端口含 3110,且无 3100 / 3111;② 占用 3110 的进程 pid 5428,`/proc/5428/cwd` = `/home/user/kpi-issue-10`;③ 该 worktree `git rev-parse HEAD` = 98cf887,`git status` 干净。文档提交叠在其上,不改任何被测代码 | +| 被测版本 | commit `6ba334fab01a02da5ce60cb6e556ae718fa1eea8`(分支 `issue-10-align-confirmed-design`,第三轮代码提交;第二轮 `98cf887`、第一轮 `79437b2`)。核实方式(开测前三级核对):① `/proc/net/tcp` 中处于 LISTEN 的端口含 3110,且无 3100 / 3111;② 占用 3110 的进程 pid 7077,`/proc/7077/cwd` = `/home/user/kpi-issue-10`;③ 该 worktree `git rev-parse HEAD` = 6ba334f,`git status` 干净。另核对构建产物 `dist/objectstack.json` 里填报单内嵌网格为 19 列(3 列默认隐藏),与子对象字段一一对应。文档提交叠在其上,不改任何被测代码 | | 平台 | @objectstack/* 17.2.0(npm 发布版),SqlDriver(better-sqlite3),单租户 | | 环境 / 启动 | `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 未走查 —— 见首段降级声明 | @@ -305,6 +306,7 @@ | 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 | ## 6. 运行输出(当次真跑原样) @@ -324,13 +326,13 @@ ──────────────────────────────────────── → Loading configuration... Config: /home/user/kpi-issue-10/objectstack.config.ts - Load time: 268ms + Load time: 289ms → Validating against ObjectStack Protocol... → Running author-time rules (41)... → Checking capability providers (#3366)... → Checking package docs (ADR-0046)... - ✓ Validation passed (597ms) + ✓ Validation passed (636ms) 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. @@ -353,24 +355,24 @@ Test Files 6 passed (6) - Tests 67 passed (67) - Start at 17:58:51 - Duration 1.08s (transform 806ms, setup 0ms, import 1.65s, tests 79ms, environment 1ms) + Tests 68 passed (68) + Start at 18:21:41 + Duration 973ms (transform 640ms, setup 0ms, import 1.41s, tests 70ms, environment 1ms) ``` -单元测试 67 条分布:计分引擎 14、状态机 / 流程配置 11、数据范围规则 26(第二轮 +3:人力岗位规则生成、锚定与关闭后降级)、汇总 4、CEL 求值 3、已确认设计口径 9。 +单元测试 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. 指标下达:主体「市场部」的权重合计为 80%,必须等于 100%","code":"VALIDATION_ERROR","declaredCode":"KPI_PLAN_INCOMPLETE","object":"kpi_plan"} -PASS T4 完整性通过后发布成功 — {"object":"kpi_plan","id":"33-m-HTqZ7IFvZH9","record":{"id":"33-m-HTqZ7IFvZH9","created_at":"2026-09-02T17:57:42.813Z","updated_at":"2026-09-02T17:58:16.231Z","organization_id":null,"created_by":null,"updated_by":"ucZamz -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 T2 指标下达 18 条且名称由 hook 填充 — 18 rows, sample: 安全事故次数 · 华南分公司 +PASS T3 权重合计≠100 时发布被拒绝并列出问题 — {"error":"发布失败,发布前完整性检查未通过:\n1. 指标下达:主体「市场部」的权重合计为 90%,必须等于 100%","code":"VALIDATION_ERROR","declaredCode":"KPI_PLAN_INCOMPLETE","object":"kpi_plan"} +PASS T4 完整性通过后发布成功 — {"object":"kpi_plan","id":"aZTcxf98549vwAJv","record":{"id":"aZTcxf98549vwAJv","created_at":"2026-09-02T18:22:12.855Z","updated_at":"2026-09-02T18:22:52.585Z","organization_id":null,"created_by":null,"updated_by":"JFY7Lj +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=30 target=180 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 @@ -378,7 +380,7 @@ PASS T10 有指标未填实际值时提交被拒绝 — {"error":"提交失败: 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":"04nrbRxc7JrLwfwR","record":{"id":"04nrbRxc7JrLwfwR","created_at":"2026-09-02T17:58:15.830Z","updated_at":"2026-09-02T17:58:17.069Z","organization_id":"org_mtkeflg0b0ihz6pj","created_by": +PASS T14 提交后进入分公司核对中 — status=branch_checking {"object":"kpi_entry_sheet","id":"GKOblLG3n-YEeILo","record":{"id":"GKOblLG3n-YEeILo","created_at":"2026-09-02T18:22:52.175Z","updated_at":"2026-09-02T18:22:53.434Z","organization_id":"org_mtkfb3nn8vu1b1na","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"} @@ -386,51 +388,51 @@ PASS T18 有争议未解决时不能推进 — {"error":"推进失败:还有 2 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":"04nrbRxc7JrLwfwR","record":{"id":"04nrbRxc7JrLwfwR","created_at":"2026-09-02T17:58:15.830Z","updated_at":"2026-09-02T17:58:18.471Z","organization_id":"org_mtkeflg0b0ihz6pj","created_by": +PASS T22 人力审核通过 → 领导审批中 — status=leader_approving {"object":"kpi_entry_sheet","id":"GKOblLG3n-YEeILo","record":{"id":"GKOblLG3n-YEeILo","created_at":"2026-09-02T18:22:52.175Z","updated_at":"2026-09-02T18:22:54.791Z","organization_id":"org_mtkfb3nn8vu1b1na","created_by": PASS T23 领导审批通过 → 已通过,盖章 — status=approved PASS T24 通过后生成考核结果(部门维度) — department:88#1 -PASS T25 加减分批准后计入最终得分 91 — bonus_total=3 total=91 {"object":"kpi_bonus","id":"lWd8Z75DYlqV2q-2","record":{"id":"lWd8Z75DYlqV2q-2","created_at":"2026-09-02T17:58:18.784Z","updated_at":"2026-09-02T17:58:18.864Z","organization_id":"org_mtkeflg0b0ihz6pj","created_by":"ucZam -PASS T26 调整申请记录调整前值 144 与主体 — {"object":"kpi_adjustment","id":"D8TUj-mX_sq71IJY","record":{"id":"D8TUj-mX_sq71IJY","created_at":"2026-09-02T17:58:19.035Z","updated_at":"2026-09-02T17:58:19.035Z","organization_id":"org_mtkeflg0b0ihz6pj","created_by":" +PASS T25 加减分批准后计入最终得分 91 — bonus_total=3 total=91 {"object":"kpi_bonus","id":"xftrptmWzHPIau9I","record":{"id":"xftrptmWzHPIau9I","created_at":"2026-09-02T18:22:55.125Z","updated_at":"2026-09-02T18:22:55.186Z","organization_id":"org_mtkfb3nn8vu1b1na","created_by":"JFY7L +PASS T26 调整申请记录调整前值 144 与主体 — {"object":"kpi_adjustment","id":"6eTMAsHeUaXlnb0X","record":{"id":"6eTMAsHeUaXlnb0X","created_at":"2026-09-02T18:22:55.345Z","updated_at":"2026-09-02T18:22:55.345Z","organization_id":"org_mtkfb3nn8vu1b1na","created_by":" PASS T27 源数据调整落地并重算:利润 180 → 完成率 100%,得分 30;结果重新汇总 — actual=180 score=30 total=106 results=106 -PASS T28 归档生成不可变快照(含校验和、总分) — snap=2026 年 8 月 月度考核 · 市场部 · 归档快照 checksum=f38c736683de total=106 +PASS T28 归档生成不可变快照(含校验和、总分) — snap=2026 年 8 月 月度考核 · 市场部 · 归档快照 checksum=3ccd418e6b71 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 审核记录完整留痕(生成/提交/争议/确认/驳回/通过/调整/归档) — approve,approve,archive,reject,submit,confirm,confirm,confirm,generate,approve,confirm,approve,dispute,adjust,confirm,confirm +PASS T33 审核记录完整留痕(生成/提交/争议/确认/驳回/通过/调整/归档) — approve,archive,approve,approve,approve,confirm,confirm,confirm,reject,confirm,confirm,adjust,generate,dispute,submit,confirm 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":"5eTiRCO1oIsrm73g","record":{"id":"5eTiRCO1oIsrm73g"," +PASS T35 复制上一版本:流程节点 4、指标下达 18 已复制 — steps=4 pis=18 {"object":"kpi_plan","id":"HNgh5D_4FYTJxnoM","record":{"id":"HNgh5D_4FYTJxnoM"," 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":"wqgPOEL5-qCrTvvr","record":{"id":"wqgPOEL5-qCrTvvr"," sheets=5 +PASS T38 发布第三版方案(市场部已配分管领导)生成 5 张填报单 — {"object":"kpi_plan","id":"5IQk96_eM8NClnV9","record":{"id":"5IQk96_eM8NClnV9"," sheets=5 PASS T39 发布按方案配置写入动态共享规则,条数与方案配置精确相符,元数据零改动 — rules=29 expected=29 PASS T39b 规则条件按方案隔离:每条规则的条件里都带本方案 id — 29 条 PASS T40 规则求值把单元共享展开成逐人记录共享行(市场部填报单 → 用户 A) — shares=10 -PASS T41 部门填报人员只看到本部门(市场部)填报单,含本次新发布的那张 — 3 张:2026 年 8 月 月度考核 · 市场部 | 2026 年 9 月 月度考核 · 市场部 | 2026 年 10 月 月度考核 · 市场部 -PASS T42 打开本部门以外(华东分公司)的填报单被拒绝 — status=404 {"error":"Record d_aCxqGOfcWJ07Pj not found in kpi_entry_sheet","code":"RECORD_NOT_FOUND","object":" -PASS T43 部门填报人员能填写实际值并提交本部门填报单 — status=branch_checking score=103 {"object":"kpi_entry_sheet","id":"d0HgE21JjX53TWCD","record":{"id":"d0HgE21JjX53TWCD","created_at":" +PASS T41 部门填报人员只看到本部门(市场部)填报单,含本次新发布的那张 — 3 张:2026 年 9 月 月度考核 · 市场部 | 2026 年 8 月 月度考核 · 市场部 | 2026 年 10 月 月度考核 · 市场部 +PASS T42 打开本部门以外(华东分公司)的填报单被拒绝 — status=404 {"error":"Record 4EGsk-cbA_rgqdL8 not found in kpi_entry_sheet","code":"RECORD_NOT_FOUND","object":" +PASS T43 部门填报人员能填写实际值并提交本部门填报单 — status=branch_checking score=103 {"object":"kpi_entry_sheet","id":"gZ8s-mdZA5l9X8Vl","record":{"id":"gZ8s-mdZA5l9X8Vl","created_at":" PASS T44 分公司核对人员只看到本分公司(华东)的核对任务 — 2 条:2026 年 10 月 月度考核 · 市场部 · 华东分公司 核对 | 2026 年 8 月 月度考核 · 市场部 · 华东分公司 核对 -PASS T45 分公司核对人员能确认本分公司的核对任务 — {"object":"kpi_check_task","id":"_yTMCkdUEG0ULARS","record":{"id":"_yTMCkdUEG0ULARS","created_at":"2026-09-02T17:58:24.5 +PASS T45 分公司核对人员能确认本分公司的核对任务 — {"object":"kpi_check_task","id":"btmjrY070vyK2k8n","record":{"id":"btmjrY070vyK2k8n","created_at":"2026-09-02T18:23:00.3 PASS T46 分管领导只看到分管主体(市场部)的填报单,看不到运营部 — 1 张:2026 年 10 月 月度考核 · 市场部 -PASS T47 分管领导能在「领导审批中」执行审核通过 — beforeLeader=leader_approving? status=approved {"object":"kpi_entry_sheet","id":"d0HgE21JjX53TWCD","record":{"id":"d0HgE21JjX53TWCD","created_at":"2026-09-02T17:58:23. +PASS T47 分管领导能在「领导审批中」执行审核通过 — beforeLeader=leader_approving? status=approved {"object":"kpi_entry_sheet","id":"gZ8s-mdZA5l9X8Vl","record":{"id":"gZ8s-mdZA5l9X8Vl","created_at":"2026-09-02T18:22:59. PASS T48 已通过填报单批准加减分后,部门结果立即等于新的最终得分 — before=103 after=106 sheet_total=106 -PASS T49 否决加减分后立即重算,被否决的分不计入部门结果 — after_reject=106 expected=106 sheet_total=106 {"object":"kpi_bonus","id":"LftmnZkQbQd-cPtS","record":{"id":"LftmnZkQbQd-cPtS", -PASS T50 不存在方案发布之外来源的 KPI 共享规则(静态规则文件已删除,也没有旧的按单元规则) — 77 条,示例 kpi_p5etirco1oisr_qrsqcu_check_bu_south:admin | kpi_pwqgpoel5qcrt_ziy10s_check_bu_north:admin -PASS T51 填报人员看到本部门结果与本人到人结果,看不到他人的到人结果 — dept=true own=true other=false | A 可见 3 条:department/bu_market,person,department/bu_market -PASS T53 分管领导看到所分管主体(市场部)的部门结果,看不到非分管主体的结果 — 2 条:department/bu_market,leader +PASS T49 否决加减分后立即重算,被否决的分不计入部门结果 — after_reject=106 expected=106 sheet_total=106 {"object":"kpi_bonus","id":"c6D_oWNVhaGZnmr7","record":{"id":"c6D_oWNVhaGZnmr7", +PASS T50 不存在方案发布之外来源的 KPI 共享规则(静态规则文件已删除,也没有旧的按单元规则) — 77 条,示例 kpi_p5iqk96em8ncl_nzv5xf_sheet_pos_kpi_hr_head:admin | kpi_p5iqk96em8ncl_nzv5xf_adjust_bu_market: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_mtkeflg0b0ihz6pj bu_hq:org_mtkeflg0b0ihz6pj bu_hr:org_mtkeflg0b0ihz6pj bu_market:org_mtkeflg0b0ihz6pj bu_north:org_mtkeflg0b0ihz6pj bu_ops:org_mtkeflg0b0ihz6pj bu_south:org_mtkeflg0b0ihz6pj bu_west:org_mtkeflg0b0ihz6pj +PASS T52 演示种子组织单元在启动时被补上组织归属(与 Setup 里新建的单元一致) — bu_east:org_mtkfb3nn8vu1b1na bu_hq:org_mtkfb3nn8vu1b1na bu_hr:org_mtkfb3nn8vu1b1na bu_market:org_mtkfb3nn8vu1b1na bu_north:org_mtkfb3nn8vu1b1na bu_ops:org_mtkfb3nn8vu1b1na bu_south:org_mtkfb3nn8vu1b1na bu_west:org_mtkfb3nn8vu1b1na 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":"8SI44-Cqkv-aT9cf","record":{"id":"8SI44-Cqkv-aT -PASS T63 考核结果的计算明细 JSON 逐指标带 is_adjusted,被调整的那条为 true — lines=3 adjusted={"plan_indicator":"ug7kqU0igfh8dmOF","indicator_name":"客户满意度","score_rate":90,"score":12.5,"is_adjusted":true,"adjust_type_applied":"result"} +PASS T62 结果调整落地后该行标记「已调整」= 是、调整类型 = 计算结果,未调整行保持否 — adjusted: is_adjusted=true type=result final=12.5 | untouched: is_adjusted=false {"object":"kpi_adjustment","id":"OKkpQRor2vxbj4v2","record":{"id":"OKkpQRor2vxbj +PASS T63 考核结果的计算明细 JSON 逐指标带 is_adjusted,被调整的那条为 true — lines=3 adjusted={"plan_indicator":"tsiip5UPv0oKlYLh","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":"YxzisR6zuAlDTzAD","record":{"id":"YxzisR6zuAlDTzAD","created_a +PASS T65 人力审核账号登记加减分成功(岗位规则已授予记录级编辑权) — share=true register=201 {"object":"kpi_bonus","id":"BkYfQ7Oq4ufIplfo","record":{"id":"BkYfQ7Oq4ufIplfo","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 From 69a33c6f1bafc57254862d89b8b355235edc78ce Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:33:48 +0000 Subject: [PATCH 7/8] fix(kpi): let a published plan with no organization still block its period MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 3. The tenant guard added last round narrowed the candidate query with an `organization_id` equality condition. Equality does not match NULL, so a published plan whose organization is empty — which is exactly what the seeded and imported rows look like — was filtered out before the decision ran, and the "fall back to the period-only verdict when either side has no organization" branch could never see it. Same-period uniqueness became data-dependent. Candidates are now taken by status alone and the tenant decision stays entirely inside the pure function, where it is already covered: two plans that both carry an organization must match, and a missing organization on either side falls back to the period comparison. A unit case pins the empty-organization candidate, and an e2e case publishes a second plan into the seeded plan's period — the seeded plan carries no organization and still blocks it. Also from the review: the inline-grid comment now says the first seventeen columns are the fields that existed before this change, rather than describing them as the object's full field set. Part of #10 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb --- scripts/e2e-flow.mjs | 10 ++++++++++ src/hooks/plan.hook.ts | 12 ++++++------ src/objects/entry.object.ts | 5 ++--- test/design-alignment.test.ts | 2 ++ 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/scripts/e2e-flow.mjs b/scripts/e2e-flow.mjs index 30898ce..374b902 100644 --- a/scripts/e2e-flow.mjs +++ b/scripts/e2e-flow.mjs @@ -510,6 +510,16 @@ const writeAfterClose = await post('kpi_bonus', { sheet: market5.id, title: '关 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/plan.hook.ts b/src/hooks/plan.hook.ts index c4abbae..dae1c9f 100644 --- a/src/hooks/plan.hook.ts +++ b/src/hooks/plan.hook.ts @@ -177,15 +177,15 @@ export const PlanPublishHook: Hook = { // 同周期唯一生效版本:先于完整性检查判定 —— 这一条与方案内部配置无关,先说清楚 // 「这个周期已经有生效版本了」比让作者先去补权重更省事。 // - // 查询走系统上下文(要看到本租户里当前用户未必可见的方案),所以租户隔离得自己带上: - // 组织归属从**落库的方案行**上取,不从 ctx.previous 取 —— 那里未必带这一列。与 - // services/sharing-service.ts 的做法一致:组织是等值条件,不做宽松匹配。 + // 候选只按状态取,**不**把组织放进 `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 where: Record = { status: 'published' }; - if (organizationId) where.organization_id = organizationId; - const published = await api.object('kpi_plan').find({ where }); + 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); diff --git a/src/objects/entry.object.ts b/src/objects/entry.object.ts index 950abc6..6634e8d 100644 --- a/src/objects/entry.object.ts +++ b/src/objects/entry.object.ts @@ -130,9 +130,8 @@ export const EntryLine = ObjectSchema.create({ /** * 内嵌网格的列**显式**声明 —— 只为让「已调整 / 调整类型」在填报单里也看得见。 * - * 显式声明会**整体替换**平台从子对象派生的列集,所以这里必须列全:下面前 17 项就是 - * 本对象除主从字段(`sheet`)之外的全部字段,顺序与字段定义一致(= 原派生顺序), - * 新增的两列排在最后。冗长但仍要留在网格里的三列(计算说明、最近调整、指标)用平台 + * 显式声明会**整体替换**平台从子对象派生的列集,所以这里必须列全:下面前 17 项 = + * 本次之前就有的全部字段,顺序与字段定义一致(= 原派生顺序),新增的两列排在最后。冗长但仍要留在网格里的三列(计算说明、最近调整、指标)用平台 * 原生的 `defaultHidden` 折叠 —— 默认不占宽度,列选择器里仍能打开,而不是删掉。 * * 每项只写 `name`(身份列):不声明 `type` 时,标签、类型、选项、lookup 目标与联动 diff --git a/test/design-alignment.test.ts b/test/design-alignment.test.ts index 5cfe29d..bee5ea7 100644 --- a/test/design-alignment.test.ts +++ b/test/design-alignment.test.ts @@ -54,7 +54,9 @@ describe('同周期唯一生效版本(第 10 章第 10 项 / 设计 5.2)', () => 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('已关闭 / 已归档不是生效版本,不阻断发布;草稿同样不阻断;自己不挡自己', () => { From 02521855fb5f5a39df66c5a1a8aa080ff202bf7c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:39:23 +0000 Subject: [PATCH 8/8] docs: record the tenant-filter blind spot and its ablation evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/05 gains problem record 5 — the organization equality condition added last round hid published plans that carry no organization, which is exactly how the seeded and imported rows look — closed as fixed and retested on 69a33c6. The new case T71 has its own detail section, including the reverse verification: putting the filter back turns T71 red (the duplicate publish succeeds) while T61 stays green, and restoring turns the whole suite green again, with the restored file's blob hash matching HEAD. The environment table also warns that the bundler strips comments, so a comment string cannot serve as a staleness probe. docs/04 row 26 now states that candidates are taken by status alone and the tenant decision lives in the pure function, with T71 as evidence. Part of #10 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb --- ...10\345\272\246\346\270\205\345\215\225.md" | 2 +- ...52\346\265\213\350\256\260\345\275\225.md" | 91 ++++++++++++------- 2 files changed, 57 insertions(+), 36 deletions(-) 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 52ed011..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" @@ -33,7 +33,7 @@ | 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` 是否已有「已发布」方案,已关闭 / 已归档不阻断;拒绝文案点名生效版本;T61 + 单元测试 3 条 | — | +| 26 | 同一周期只有一个生效版本(设计 5.2、第 10 章第 10 项) | ✅ | `PlanPublishHook` 发布前校验同 `period_type + year + period_no` 是否已有「已发布」方案,已关闭 / 已归档不阻断;拒绝文案点名生效版本;候选只按状态取、租户判定在纯函数里做(组织为空的种子 / 导入方案同样算生效版本,T71 + 消融对照);T61、T71 + 单元测试 4 条 | — | ## 三、角色与安全(需求 §2 / 设计第 9 章) 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 9d4eb15..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" @@ -5,15 +5,16 @@ > 数据接口,不是浏览器 UI 走查,也没有截图。按 os-project-std-evidence,接口级证据只能作为写操作 > 落库的第二观察点,不替代 UI 功能证据;UI 走查与截图留待有浏览器环境的验收轮次(pm-test)。 > 本文件是挂在工作项 #10 上的测试报告的仓库内落盘版,两者同一份内容。 -> 本记录含三轮:第一轮落地已确认设计口径(T58~T64);第二轮按调度员 2026-09-02 的裁定补上人力岗位的 +> 本记录含四轮:第一轮落地已确认设计口径(T58~T64);第二轮按调度员 2026-09-02 的裁定补上人力岗位的 > 记录级写范围(T65~T70),并以**真实岗位账号**重测第一轮遗留的阻塞问题;第三轮修 PR 评审提出的内嵌 -> 网格列集回归与两处小改(问题记录 #4)。全文数据取自**第三轮**真跑,用例集与第二轮相同。 +> 网格列集回归(问题记录 #4);第四轮修第三轮自己引入的租户过滤漏判(问题记录 #5,新增 T71)。 +> 全文数据取自**第四轮**真跑。 ## 第 1 页:结论 **结论:✅ 通过** -共 72 条用例:通过 **72**。静态门禁 3/3 绿(validate / typecheck / vitest 68 条)。 +共 73 条用例:通过 **73**。静态门禁 3/3 绿(validate / typecheck / vitest 68 条)。 工作项 #10 的七条验收标准全部达成 —— 其中验收标准 1、2(以人力审核账号登记、以人力负责人账号批准) 在第一轮受数据范围所限无法演示,第二轮补上岗位共享规则后由真实岗位账号取证通过(T65、T66、T67)。 @@ -31,7 +32,7 @@ | 项 | 值 | |---|---| -| 被测版本 | commit `6ba334fab01a02da5ce60cb6e556ae718fa1eea8`(分支 `issue-10-align-confirmed-design`,第三轮代码提交;第二轮 `98cf887`、第一轮 `79437b2`)。核实方式(开测前三级核对):① `/proc/net/tcp` 中处于 LISTEN 的端口含 3110,且无 3100 / 3111;② 占用 3110 的进程 pid 7077,`/proc/7077/cwd` = `/home/user/kpi-issue-10`;③ 该 worktree `git rev-parse HEAD` = 6ba334f,`git status` 干净。另核对构建产物 `dist/objectstack.json` 里填报单内嵌网格为 19 列(3 列默认隐藏),与子对象字段一一对应。文档提交叠在其上,不改任何被测代码 | +| 被测版本 | 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=3110 OS_DATABASE_URL=file:./.objectstack/issue-10.db pnpm dev`(空库启动,自动加载 46 行演示种子),地址 `http://localhost:3110`。**改元数据 / 服务后必须先 `rm -rf dist` 再重启**,否则 `objectstack dev` 会沿用上次的构建产物(问题记录 #3) | | 端 | REST 接口(PC 端服务侧)。UI 未走查 —— 见首段降级声明 | @@ -43,7 +44,7 @@ ## 3. 测试计划与结果总表 -四类齐全:主流程 38、边界 3、异常 14、权限 17(合计 72)。 +四类齐全:主流程 38、边界 3、异常 15、权限 17(合计 73)。 | 编号 | 标题 | 类型 | 端 | 角色 | 结果 | 证据 | |---|---|---|---|---|---|---| @@ -119,6 +120,7 @@ | T68 | 人力负责人账号登记加减分被拒绝(权限集不声明新建权限) **(本工作项第二轮新增)** | 权限 | REST | 人力负责人 | ✅ 通过 | §6 运行输出 T68 | | T69 | 人力审核账号在人力审核节点驳回与审核通过均成功(真实岗位账号推进流程) **(本工作项第二轮新增)** | 主流程 | REST | 人力审核 | ✅ 通过 | §6 运行输出 T69 | | T70 | 方案关闭后人力岗位对该方案填报单降为只读(留读、去写) **(本工作项第二轮新增)** | 权限 | REST | 人力审核 | ✅ 通过 | §6 运行输出 T70 | +| T71 | 组织为空的已发布方案仍阻断同周期发布(租户判定不靠查询过滤) **(本工作项第三轮新增)** | 异常 | REST | 管理员 | ✅ 通过 | §6 运行输出 T71 | ## 4. 逐用例明细(本工作项新增 / 改动的用例) @@ -299,6 +301,23 @@ - **结果**:✅ 通过 - **截图**:无 +### 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. 问题记录 | # | 用例 | 现象 | 复现步骤 | 原因分析 | 处理建议 | 截图 | 处置 | @@ -307,6 +326,7 @@ | 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. 运行输出(当次真跑原样) @@ -326,13 +346,13 @@ ──────────────────────────────────────── → Loading configuration... Config: /home/user/kpi-issue-10/objectstack.config.ts - Load time: 289ms + Load time: 274ms → Validating against ObjectStack Protocol... → Running author-time rules (41)... → Checking capability providers (#3366)... → Checking package docs (ADR-0046)... - ✓ Validation passed (636ms) + ✓ 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. @@ -356,8 +376,8 @@ Test Files 6 passed (6) Tests 68 passed (68) - Start at 18:21:41 - Duration 973ms (transform 640ms, setup 0ms, import 1.41s, tests 70ms, environment 1ms) + Start at 18:33:30 + Duration 978ms (transform 806ms, setup 0ms, import 1.53s, tests 70ms, environment 1ms) ``` @@ -368,11 +388,11 @@ ```text PASS T0 管理员登录 — status 200 PASS T1 种子方案已加载 — 2026 年 8 月 月度考核 -PASS T2 指标下达 18 条且名称由 hook 填充 — 18 rows, sample: 安全事故次数 · 华南分公司 -PASS T3 权重合计≠100 时发布被拒绝并列出问题 — {"error":"发布失败,发布前完整性检查未通过:\n1. 指标下达:主体「市场部」的权重合计为 90%,必须等于 100%","code":"VALIDATION_ERROR","declaredCode":"KPI_PLAN_INCOMPLETE","object":"kpi_plan"} -PASS T4 完整性通过后发布成功 — {"object":"kpi_plan","id":"aZTcxf98549vwAJv","record":{"id":"aZTcxf98549vwAJv","created_at":"2026-09-02T18:22:12.855Z","updated_at":"2026-09-02T18:22:52.585Z","organization_id":null,"created_by":null,"updated_by":"JFY7Lj -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=30 target=180 +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 @@ -380,7 +400,7 @@ PASS T10 有指标未填实际值时提交被拒绝 — {"error":"提交失败: 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":"GKOblLG3n-YEeILo","record":{"id":"GKOblLG3n-YEeILo","created_at":"2026-09-02T18:22:52.175Z","updated_at":"2026-09-02T18:22:53.434Z","organization_id":"org_mtkfb3nn8vu1b1na","created_by": +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"} @@ -388,55 +408,56 @@ PASS T18 有争议未解决时不能推进 — {"error":"推进失败:还有 2 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":"GKOblLG3n-YEeILo","record":{"id":"GKOblLG3n-YEeILo","created_at":"2026-09-02T18:22:52.175Z","updated_at":"2026-09-02T18:22:54.791Z","organization_id":"org_mtkfb3nn8vu1b1na","created_by": +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":"xftrptmWzHPIau9I","record":{"id":"xftrptmWzHPIau9I","created_at":"2026-09-02T18:22:55.125Z","updated_at":"2026-09-02T18:22:55.186Z","organization_id":"org_mtkfb3nn8vu1b1na","created_by":"JFY7L -PASS T26 调整申请记录调整前值 144 与主体 — {"object":"kpi_adjustment","id":"6eTMAsHeUaXlnb0X","record":{"id":"6eTMAsHeUaXlnb0X","created_at":"2026-09-02T18:22:55.345Z","updated_at":"2026-09-02T18:22:55.345Z","organization_id":"org_mtkfb3nn8vu1b1na","created_by":" +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=3ccd418e6b71 total=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 审核记录完整留痕(生成/提交/争议/确认/驳回/通过/调整/归档) — approve,archive,approve,approve,approve,confirm,confirm,confirm,reject,confirm,confirm,adjust,generate,dispute,submit,confirm +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":"HNgh5D_4FYTJxnoM","record":{"id":"HNgh5D_4FYTJxnoM"," +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":"5IQk96_eM8NClnV9","record":{"id":"5IQk96_eM8NClnV9"," sheets=5 +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 年 8 月 月度考核 · 市场部 | 2026 年 10 月 月度考核 · 市场部 -PASS T42 打开本部门以外(华东分公司)的填报单被拒绝 — status=404 {"error":"Record 4EGsk-cbA_rgqdL8 not found in kpi_entry_sheet","code":"RECORD_NOT_FOUND","object":" -PASS T43 部门填报人员能填写实际值并提交本部门填报单 — status=branch_checking score=103 {"object":"kpi_entry_sheet","id":"gZ8s-mdZA5l9X8Vl","record":{"id":"gZ8s-mdZA5l9X8Vl","created_at":" -PASS T44 分公司核对人员只看到本分公司(华东)的核对任务 — 2 条:2026 年 10 月 月度考核 · 市场部 · 华东分公司 核对 | 2026 年 8 月 月度考核 · 市场部 · 华东分公司 核对 -PASS T45 分公司核对人员能确认本分公司的核对任务 — {"object":"kpi_check_task","id":"btmjrY070vyK2k8n","record":{"id":"btmjrY070vyK2k8n","created_at":"2026-09-02T18:23:00.3 +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":"gZ8s-mdZA5l9X8Vl","record":{"id":"gZ8s-mdZA5l9X8Vl","created_at":"2026-09-02T18:22:59. +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":"c6D_oWNVhaGZnmr7","record":{"id":"c6D_oWNVhaGZnmr7", -PASS T50 不存在方案发布之外来源的 KPI 共享规则(静态规则文件已删除,也没有旧的按单元规则) — 77 条,示例 kpi_p5iqk96em8ncl_nzv5xf_sheet_pos_kpi_hr_head:admin | kpi_p5iqk96em8ncl_nzv5xf_adjust_bu_market:admin +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_mtkfb3nn8vu1b1na bu_hq:org_mtkfb3nn8vu1b1na bu_hr:org_mtkfb3nn8vu1b1na bu_market:org_mtkfb3nn8vu1b1na bu_north:org_mtkfb3nn8vu1b1na bu_ops:org_mtkfb3nn8vu1b1na bu_south:org_mtkfb3nn8vu1b1na bu_west:org_mtkfb3nn8vu1b1na +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":"OKkpQRor2vxbj4v2","record":{"id":"OKkpQRor2vxbj -PASS T63 考核结果的计算明细 JSON 逐指标带 is_adjusted,被调整的那条为 true — lines=3 adjusted={"plan_indicator":"tsiip5UPv0oKlYLh","indicator_name":"客户满意度","score_rate":90,"score":12.5,"is_adjusted":true,"adjust_type_applied":"result"} +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":"BkYfQ7Oq4ufIplfo","record":{"id":"BkYfQ7Oq4ufIplfo","created_a +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 -{"passed":72,"failed":0} +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} ```