Skip to content
Merged
71 changes: 40 additions & 31 deletions docs/04-需求符合度清单.md

Large diffs are not rendered by default.

521 changes: 452 additions & 69 deletions docs/05-自测记录.md

Large diffs are not rendered by default.

159 changes: 157 additions & 2 deletions scripts/e2e-flow.mjs

Large diffs are not rendered by default.

26 changes: 22 additions & 4 deletions src/hooks/adjustment.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
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',
Expand Down Expand Up @@ -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<string, unknown> = { 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),
Expand Down
59 changes: 54 additions & 5 deletions src/hooks/bonus.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
prev: Record<string, unknown>,
): 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: '加减分规则',
Expand All @@ -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();
}
Expand Down
77 changes: 77 additions & 0 deletions src/hooks/plan.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,68 @@ import { loadPlanSteps } from './sheet.hook.js';

const FROZEN_MESSAGE = '修改失败:方案已发布,配置已冻结。如需调整指标、权重、目标或流程,请新建方案版本。';

/**
* 同一考核周期只有一个生效版本(《设计方案》V1.0 第 5.2 节、第 10 章第 10 项)。
*
* 「同一周期」= `period_type` + `year` + `period_no` 三者相同;「生效版本」只指状态为
* **已发布**的方案 —— 已关闭 / 已归档的是历史版本,不阻断新版本发布,草稿本来就还没生效
* (草稿与已发布并存正是第 10 项确认的口径)。
*/
export const PLAN_PERIOD_CONFLICT_CODE = 'KPI_PLAN_PERIOD_CONFLICT';

export interface PlanPeriodKey {
id?: string | null;
period_type?: unknown;
year?: unknown;
period_no?: unknown;
name?: unknown;
status?: unknown;
/** 租户隔离锚点(平台注入的组织归属)。 */
organization_id?: unknown;
}

/** 两条方案是否落在同一个考核周期(期数按数值比较,避免 "1" 与 1 判成不同周期)。 */
export function samePeriod(a: PlanPeriodKey, b: PlanPeriodKey): boolean {
return (
String(a.period_type ?? '') === String(b.period_type ?? '') &&
toNumber(a.year) === toNumber(b.year) &&
toNumber(a.period_no) === toNumber(b.period_no)
);
}

/**
* 两条方案是否属于同一租户。
*
* 只在**两边都带**组织归属时才比较:带了就必须相等(多租户下别家的方案挡不住本家的发布);
* 有一边没有(单租户部署、或调用方没把该列读出来)就退回只按周期判定 —— 宁可多挡一次让人
* 看见,也不因为缺一个字段而**静默**放行一个本该被拒的发布。
*/
function sameTenant(a: PlanPeriodKey, b: PlanPeriodKey): boolean {
const x = a.organization_id ? String(a.organization_id) : '';
const y = b.organization_id ? String(b.organization_id) : '';
if (!x || !y) return true;
return x === y;
}

/**
* 在候选方案里找出挡住本次发布的生效版本 —— 纯函数,唯一性口径的唯一真值(单元测试点)。
* 候选由调用方按「已发布」+ 本租户筛出;这里再判一次状态与租户,保证口径不依赖调用方的
* 查询条件(查询条件是性能优化,判定不能只靠它)。
*/
export function findPublishedConflict(candidates: PlanPeriodKey[], self: PlanPeriodKey): PlanPeriodKey | null {
for (const c of candidates ?? []) {
if (String(c.status ?? '') !== 'published') continue;
if (self.id && String(c.id ?? '') === String(self.id)) continue;
if (!sameTenant(c, self)) continue;
if (samePeriod(c, self)) return c;
}
return null;
}

export function planPeriodConflictMessage(name: string): string {
return `发布失败:该考核周期已有生效版本「${name}」。请先关闭该版本,或改用复制出的新版本替换。`;
}

async function assertPlanEditable(ctx: HookContext, planId: string | null | undefined): Promise<void> {
if (isSystem(ctx) || !planId) return;
const plan = await findById(sys(ctx), 'kpi_plan', planId);
Expand Down Expand Up @@ -112,6 +174,21 @@ export const PlanPublishHook: Hook = {
// 平台校验拒绝时留下半截改动。
if (!(prev.status === 'draft' && input.status === 'published')) return;

// 同周期唯一生效版本:先于完整性检查判定 —— 这一条与方案内部配置无关,先说清楚
// 「这个周期已经有生效版本了」比让作者先去补权重更省事。
//
// 候选只按状态取,**不**把组织放进 `where`:等值条件不匹配 NULL,而种子 / 导入写进来的
// 方案行组织就是空的 —— 那样的生效版本会在判定之前就被查询滤掉,同周期唯一性变成
// 「看数据从哪来」的漏判。租户判定全部交给 `findPublishedConflict` 里的 `sameTenant`:
// 两边都有组织才比较,任一边缺失就退回只按周期判定(缺字段不得变成静默放行)。
// 本方案自己的组织从**落库的行**上取,不从 ctx.previous 取 —— 那里未必带这一列。
const period = merged<Record<string, any>>(ctx);
const self = await findById(api, 'kpi_plan', id);
const organizationId = self?.organization_id ? String(self.organization_id) : null;
const published = await api.object('kpi_plan').find({ where: { status: 'published' } });
const conflict = findPublishedConflict(published as PlanPeriodKey[], { ...period, id, organization_id: organizationId });
if (conflict) fail(planPeriodConflictMessage(String(conflict.name ?? '')), PLAN_PERIOD_CONFLICT_CODE);

const problems: string[] = [];
const steps = await loadPlanSteps(api, id);
problems.push(...validateSteps(steps).map((m) => `流程节点:${m}`));
Expand Down
47 changes: 41 additions & 6 deletions src/lib/aggregate.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -18,6 +32,8 @@ export interface SheetSummary {
totalScore: number;
/** 明细:plan_indicator id → 得分率(%) */
lineScoreRates: Record<string, number>;
/** 逐指标明细;缺省时计算明细里不带指标行(得分不受影响)。 */
lines?: SheetLineSummary[];
}

export interface SubjectDef {
Expand Down Expand Up @@ -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'));
Expand All @@ -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) });
Expand Down
32 changes: 32 additions & 0 deletions src/objects/entry.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,26 @@ export const EntryLine = ObjectSchema.create({
deleteBehavior: 'cascade',
inlineEdit: 'grid',
inlineTitle: '指标填报',
/**
* 内嵌网格的列**显式**声明 —— 只为让「已调整 / 调整类型」在填报单里也看得见。
*
* 显式声明会**整体替换**平台从子对象派生的列集,所以这里必须列全:下面前 17 项 =
* 本次之前就有的全部字段,顺序与字段定义一致(= 原派生顺序),新增的两列排在最后。冗长但仍要留在网格里的三列(计算说明、最近调整、指标)用平台
* 原生的 `defaultHidden` 折叠 —— 默认不占宽度,列选择器里仍能打开,而不是删掉。
*
* 每项只写 `name`(身份列):不声明 `type` 时,标签、类型、选项、lookup 目标与联动
* 规则都由子对象自己的字段定义补齐,列与字段因此不会各说各话。
*/
inlineColumns: [
{ name: 'plan_indicator', required: true }, { name: 'indicator', defaultHidden: true },
{ name: 'indicator_name' }, { name: 'unit' }, { name: 'direction' }, { name: 'scoring_method' },
{ name: 'target_value' }, { name: 'weight' }, { name: 'actual_value' },
{ name: 'completion_rate' }, { name: 'score_rate' }, { name: 'score' },
{ name: 'adjusted_score' }, { name: 'final_score' },
{ name: 'calc_trace', defaultHidden: true }, { name: 'last_adjustment', defaultHidden: true },
{ name: 'remark' },
{ name: 'is_adjusted' }, { name: 'adjust_type_applied' },
],
}),
plan_indicator: Field.lookup('kpi_plan_indicator', { label: '来源下达', required: true }),
indicator: Field.lookup('kpi_indicator', { label: '指标', readonly: true }),
Expand Down Expand Up @@ -158,6 +178,18 @@ export const EntryLine = ObjectSchema.create({
score: Field.number({ label: '指标得分', readonly: true, scale: 2, min: 0, max: 1000 }),
adjusted_score: Field.number({ label: '调整后得分', readonly: true, scale: 2, min: 0, max: 1000 }),
final_score: Field.number({ label: '最终得分', readonly: true, scale: 2, min: 0, max: 1000 }),
// 「已调整」标记(《设计方案》V1.0 第 10 章第 14 项 = A:改某指标的得分并标记「已调整」)。
// 两个字段都由数据调整批准落地时以系统上下文写入,页面上只读:源数据调整与结果调整都标,
// 「调整类型」说明标的是哪一种,列表与内嵌网格因此能一眼看出哪一行被调过。
is_adjusted: Field.boolean({ label: '已调整', readonly: true, defaultValue: false }),
adjust_type_applied: Field.select({
label: '调整类型',
readonly: true,
options: [
{ label: '源数据(实际值)', value: 'source' },
{ label: '计算结果(得分)', value: 'result' },
],
}),
calc_trace: Field.textarea({ label: '计算说明', readonly: true }),
last_adjustment: Field.lookup('kpi_adjustment', { label: '最近调整' }),
remark: Field.text({ label: '备注', maxLength: 300 }),
Expand Down
4 changes: 3 additions & 1 deletion src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading