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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const SheetRejectAction = defineAction({
objectName: 'kpi_entry_sheet',
type: 'script',
variant: 'danger',
params: [{ name: 'reason', type: 'textarea', label: '驳回原因(必填)', required: true }],
params: [{ name: 'reason', type: 'textarea', label: '驳回原因', required: true }],
body: { ...WRITE, source: rec('kpi_entry_sheet') + "if (!input.reason || !String(input.reason).trim()) throw new Error('驳回失败:驳回原因不能为空。请填写原因后再驳回。');" + "await repo.update({ id: id, pending_action: 'reject', action_reason: input.reason }); return { ok: true };" },
successMessage: '已驳回,填报单退回上一节点。',
visible: "has(record.status) && (record.status == 'branch_checking' || record.status == 'hr_reviewing' || record.status == 'leader_approving' || record.status == 'submitted')",
Expand Down Expand Up @@ -91,7 +91,7 @@ export const CheckDisputeAction = defineAction({
objectName: 'kpi_check_task',
type: 'script',
variant: 'danger',
params: [{ name: 'comment', type: 'textarea', label: '争议内容(必填)', required: true }],
params: [{ name: 'comment', type: 'textarea', label: '争议内容', required: true }],
body: { ...WRITE, source: rec('kpi_check_task') + "if (!input.comment || !String(input.comment).trim()) throw new Error('提出争议失败:核对意见不能为空。请填写争议内容后再提交。');" + "await repo.update({ id: id, status: 'disputed', comment: input.comment }); return { ok: true };" },
successMessage: '已记录争议,请与填报部门协商后再确认。',
visible: "has(record.status) && record.status == 'pending'",
Expand Down Expand Up @@ -205,7 +205,7 @@ export const AdjustmentRejectAction = defineAction({
objectName: 'kpi_adjustment',
type: 'script',
variant: 'danger',
params: [{ name: 'decision_reason', type: 'textarea', label: '否决原因(必填)', required: true }],
params: [{ name: 'decision_reason', type: 'textarea', label: '否决原因', required: true }],
body: { ...WRITE, source: rec('kpi_adjustment') + "if (!input.decision_reason || !String(input.decision_reason).trim()) throw new Error('否决调整申请失败:审批意见不能为空。请填写否决原因后再提交。');" + "await repo.update({ id: id, status: 'rejected', decision_reason: input.decision_reason }); return { ok: true };" },
successMessage: '调整申请已否决。',
visible: "has(record.status) && record.status == 'submitted'",
Expand All @@ -220,7 +220,7 @@ export const DisputeAcceptAction = defineAction({
icon: 'check-circle',
objectName: 'kpi_dispute',
type: 'script',
params: [{ name: 'resolution', type: 'textarea', label: '处理结论(必填)', required: true }],
params: [{ name: 'resolution', type: 'textarea', label: '处理结论', required: true }],
body: { ...WRITE, source: rec('kpi_dispute') + "if (!input.resolution || !String(input.resolution).trim()) throw new Error('处理争议失败:处理结论不能为空。请填写处理结论后再提交。');" + "await repo.update({ id: id, status: 'accepted', resolution: input.resolution }); return { ok: true };" },
successMessage: '争议已采纳,请据此调整指标下达。',
visible: "has(record.status) && record.status == 'open'",
Expand All @@ -235,7 +235,7 @@ export const DisputeRejectAction = defineAction({
objectName: 'kpi_dispute',
type: 'script',
variant: 'danger',
params: [{ name: 'resolution', type: 'textarea', label: '处理结论(必填)', required: true }],
params: [{ name: 'resolution', type: 'textarea', label: '处理结论', required: true }],
body: { ...WRITE, source: rec('kpi_dispute') + "if (!input.resolution || !String(input.resolution).trim()) throw new Error('处理争议失败:处理结论不能为空。请填写处理结论后再提交。');" + "await repo.update({ id: id, status: 'rejected', resolution: input.resolution }); return { ok: true };" },
successMessage: '争议已关闭(不采纳)。',
visible: "has(record.status) && record.status == 'open'",
Expand Down
10 changes: 8 additions & 2 deletions src/dashboards/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,14 @@ export const ResultsDashboard: Dashboard = {
{ id: 'm_avg', type: 'metric', title: '平均得分', dataset: 'kpi_result_metrics', values: ['avg_score'], colorVariant: 'success', layout: { x: 3, y: 0, w: 3, h: 2 } },
{ id: 'm_max', type: 'metric', title: '最高得分', dataset: 'kpi_result_metrics', values: ['max_score'], colorVariant: 'warning', layout: { x: 6, y: 0, w: 3, h: 2 } },
{ id: 'm_min', type: 'metric', title: '最低得分', dataset: 'kpi_result_metrics', values: ['min_score'], colorVariant: 'danger', layout: { x: 9, y: 0, w: 3, h: 2 } },
{ id: 'bar_unit', type: 'bar', title: '各组织单元平均得分', dataset: 'kpi_result_metrics', dimensions: ['unit'], values: ['avg_score'], chartConfig: axis('bar', 'unit', 'avg_score'), layout: { x: 0, y: 2, w: 6, h: 5 } },
{ id: 'bar_person', type: 'horizontal-bar', title: '人员得分', dataset: 'kpi_result_metrics', dimensions: ['person'], values: ['avg_score'], filter: { dimension: { $in: ['person', 'leader'] } }, filterBindings: { dimension: false }, chartConfig: axis('horizontal-bar', 'person', 'avg_score'), layout: { x: 6, y: 2, w: 6, h: 5 } },
// 只算部门与分公司两个维度(#38 第 4 条):到人 / 分管领导的结果行没有组织单元,
// 不过滤就会在轴上多出一根把它们全兜进去的「(未指定)」柱。固定过滤 + 关掉与全局
// 「汇总维度」筛选器的绑定,写法与下面的「人员得分」一致;范围写进标题,免得看图的人
// 以为全局筛选没生效。
{ id: 'bar_unit', type: 'bar', title: '各组织单元平均得分(部门 / 分公司)', dataset: 'kpi_result_metrics', dimensions: ['unit'], values: ['avg_score'], filter: { dimension: { $in: ['department', 'branch'] } }, filterBindings: { dimension: false }, chartConfig: axis('bar', 'unit', 'avg_score'), layout: { x: 0, y: 2, w: 6, h: 5 } },
// 轴用 `person_label`(结果记录的名称,含姓名)而不是 `person`:后者出的是原始用户 id
// ——平台的数据集维度解析只认 lookup 字段,不认 user 字段(见 datasets/index.ts 的说明)。
{ id: 'bar_person', type: 'horizontal-bar', title: '人员得分', dataset: 'kpi_result_metrics', dimensions: ['person_label'], values: ['avg_score'], filter: { dimension: { $in: ['person', 'leader'] } }, filterBindings: { dimension: false }, chartConfig: axis('horizontal-bar', 'person_label', 'avg_score'), layout: { x: 6, y: 2, w: 6, h: 5 } },
{ id: 'tbl_dim', type: 'table', title: '按维度汇总', dataset: 'kpi_result_metrics', dimensions: ['dimension'], values: ['result_count', 'avg_score', 'max_score', 'min_score'], filterBindings: { dimension: false }, layout: { x: 0, y: 7, w: 12, h: 4 } },
],
};
Expand Down
17 changes: 16 additions & 1 deletion src/datasets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,22 @@ export const ResultDataset = defineDataset({
{ name: 'dimension', label: '汇总维度', field: 'dimension', type: 'string' },
{ name: 'plan', label: '考核方案', field: 'plan', type: 'lookup' },
{ name: 'unit', label: '组织单元', field: 'unit', type: 'lookup' },
{ name: 'person', label: '人员', field: 'person', type: 'lookup' },
{ name: 'person', label: '人员(账号)', field: 'person', type: 'lookup' },
/**
* 人员的展示维度(#38 第 4 条)。
*
* `person` 维度在图表与报表里出的是**原始用户 id**,不是姓名。抓包核实过服务端行为
* (`POST /api/v1/analytics/dataset/query`):`unit` 维度返回「财务部」「华东分公司」,
* 同一次查询里 `person` 维度返回 `7zLYIwpX82If4BvtWGdx2FJeP1S3yyrE`。差别在字段类型 ——
* `Field.lookup` 出 `type: 'lookup'`,`Field.user` 出 `type: 'user'`(同样带
* `reference: 'sys_user'`),数据集的维度解析只认前者。这是平台侧的能力缺口,按项目
* 纪律只上报、不在应用侧绕平台。
*
* 应用侧能做的是换一个**本身就存着姓名**的列:`kpi_result.name` 在汇总时已写成
* 「方案名 · 姓名」。残留偏差是轴标签带方案名前缀,不是纯姓名 —— 平台补上 user 维度
* 解析后,这两处应换回 `person`。
*/
{ name: 'person_label', label: '人员', field: 'name', type: 'string' },
],
measures: [
{ name: 'result_count', label: '结果数', aggregate: 'count' },
Expand Down
17 changes: 15 additions & 2 deletions src/hooks/entry-line.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ import { scoreLine } from '../services/scoring-service.js';

const EDITABLE_AFTER_SUBMIT = new Set(['remark']);

/**
* 冻结提示按填报单状态分岔(#38 第 3 条)。
*
* 「已提交」和「已归档」是两种不同的锁,出口也不同:提交后还能走数据调整申请更正,
* 归档后连数据调整都被拒(adjustment.hook 的归档闸),再让提示指向数据调整就是把人
* 支去一条走不通的路。三段式不变:什么失败、为什么、怎么办。
*/
function lockedMessage(action: '修改' | '删除', sheetStatus: unknown): string {
return sheetStatus === 'archived'
? `${action}填报明细失败:填报单已归档,数据已锁定不可再改。归档数据不能更正,如有疑问请联系人力审核。`
: `${action}填报明细失败:填报单已提交,数据已冻结。如需更正,请发起数据调整申请。`;
}

/**
* 填报明细 —— 即时算分 + 提交后锁定(蓝图 B-M4-02 / B-M4-03)。
*
Expand All @@ -29,7 +42,7 @@ export const EntryLineScoreHook: Hook = {
if (sheet && sheet.status !== 'draft') {
const touched = Object.keys(input).filter((k) => k !== 'id' && !EDITABLE_AFTER_SUBMIT.has(k) && input[k] !== (ctx.previous as any)?.[k]);
if (touched.length > 0) {
fail('修改填报明细失败:填报单已提交,数据已冻结。如需更正,请发起数据调整申请。', 'KPI_LINE_LOCKED');
fail(lockedMessage('修改', sheet.status), 'KPI_LINE_LOCKED');
}
return;
}
Expand Down Expand Up @@ -67,7 +80,7 @@ export const EntryLineDeleteGuardHook: Hook = {
const prev = ctx.previous as Record<string, any> | undefined;
const sheet = await findById(sys(ctx), 'kpi_entry_sheet', prev?.sheet);
if (sheet && sheet.status !== 'draft') {
fail('删除填报明细失败:填报单已提交,数据已冻结。如需更正,请发起数据调整申请。', 'KPI_LINE_LOCKED');
fail(lockedMessage('删除', sheet.status), 'KPI_LINE_LOCKED');
}
},
};
60 changes: 51 additions & 9 deletions src/hooks/sheet.hook.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Hook, HookContext } from '@objectstack/spec/data';
import { actorId, fail, findById, hasPosition, isSystem, isSystemWrite, merged, nowIso, recordId, sys, sysNoActor, writeReview } from './util.js';
import { requiredPositionFor, STATUS_LABEL, transition, type PlanStepDef, type SheetAction, type SheetStatus } from '../lib/workflow.js';
import { requiredPositionFor, STATUS_LABEL, STEP_LABEL, transition, type PlanStepDef, type SheetAction, type SheetStatus } from '../lib/workflow.js';
import { round2 } from '../lib/scoring.js';
import { regenerateResults } from '../services/results-service.js';
import { provisionPlanSharing } from '../services/sharing-service.js';
import { createSnapshot } from '../services/snapshot-service.js';
Expand Down Expand Up @@ -30,11 +31,43 @@ const SCRATCH_FIELDS = new Set(['pending_action', 'action_reason']);
const PLATFORM_STAMP_FIELDS = new Set(['created_at', 'created_by', 'updated_at', 'updated_by']);

/**
* 填报单由方案发布生成;非系统上下文不能手工新建
* 汇总字段的小数位 —— 写入时按两位取整(#38 第 8 条)
*
* 这里的免检仍按 `isSystem` 而不是「无发起人」:发布是**用户点**「发布方案」触发的,
* 生成填报单由方案 hook 以带发起人的系统上下文完成,收紧会直接打断发布。填报单
* 也没有任何 insert 型按钮,动作体到不了这条分支;REST 手工新建走非系统上下文,照拦。
* `indicator_score` / `bonus_total` / `weight_total` 是平台 summary 字段(对明细求和),
* 求和是浮点相加,一串两位小数加出来就是 `91.75999999999999`;同一行的「最终得分」是
* 声明了 `scale: 2` 的 formula 字段,显示得干干净净 —— 两个数并排,前者比后者多十几位,
* 用户看到的是「系统算错了」。
*
* 取整放在写入闸上:汇总重算经本 hook 落库,拦在这里,列表、表单、导出、快照读的都是
* 同一个已取整的值。**计分口径不动** —— 舍入规则仍是 `lib/scoring.ts` 的 `round2`,这里
* 只是调用它,不另立一套。(更彻底的做法是给对象上的 summary 字段补 `scale`,顺带补齐
* 数字四件套,但那要动字段定义,超出本单范围,已在工作项上记录。)
*/
const SUMMARY_SCALE_2 = ['indicator_score', 'bonus_total', 'weight_total'] as const;

function roundSummaryFields(input: Record<string, any>): void {
for (const field of SUMMARY_SCALE_2) {
if (!(field in input)) continue;
const raw = input[field];
if (raw === null || raw === undefined || raw === '') continue;
const n = typeof raw === 'number' ? raw : Number(raw);
if (!Number.isFinite(n)) continue;
input[field] = n < 0 ? -round2(-n) : round2(n);
}
}

/**
* 填报单由方案发布生成;非系统上下文**一律**不能手工新建(#38 第 7 条)。
*
* 管理员例外已取消:手工建出来的单没有方案、没有主体、没有明细,页头一渲染就崩
* (平台 objectstack-ai/objectstack#14888),而它没有任何合法用途 —— 填报单的唯一
* 来源是方案发布。留着这个口子只会产出残缺记录。
*
* 免检仍按 `isSystem` 而不是「无发起人」:发布是**用户点**「发布方案」触发的,生成
* 填报单由方案 hook 以带发起人的系统上下文完成,收紧会直接打断发布。填报单也没有
* 任何 insert 型按钮,动作体到不了这条分支;REST 手工新建走非系统上下文,照拦。
* 列表上的「新建」按钮由权限集的 `allowCreate: false` 收掉(security/index.ts),
* 这里是同一条边界在数据层的那一遍。
*/
export const SheetInsertGuardHook: Hook = {
name: 'kpi_sheet_insert_guard',
Expand All @@ -44,9 +77,7 @@ export const SheetInsertGuardHook: Hook = {
priority: 100,
handler: async (ctx: HookContext) => {
if (isSystem(ctx)) return;
if (!(await hasPosition(ctx, 'kpi_admin'))) {
fail('新建填报单失败:填报单由考核方案发布时自动生成,不能手工新建。请在考核方案中发布方案。', 'KPI_SHEET_MANUAL_INSERT');
}
fail('新建填报单失败:填报单由考核方案发布时自动生成,不能手工新建。请在考核方案中发布方案。', 'KPI_SHEET_MANUAL_INSERT');
},
};

Expand All @@ -72,6 +103,10 @@ export const SheetTransitionHook: Hook = {
const id = recordId(ctx);
const action = input.pending_action as SheetAction | null | undefined;

// 汇总字段先取整再往下走:归档锁按「input 与 prev 是否不同」判改动,取整后与库里
// 已经是两位小数的值相等,不会被当成用户在改数据。
roundSummaryFields(input);

if (!action) {
// 免检只给纯系统写入(无发起人)。按钮的动作体带着发起人以「受信任」身份写入,
// 只看 isSystem 会把它当系统写入放行(objectstack#2849),这两条锁就等于没上。
Expand Down Expand Up @@ -100,7 +135,14 @@ export const SheetTransitionHook: Hook = {
// (hasPosition 的免检只留给无发起人的纯系统写入)。
const required = requiredPositionFor(steps, fromStatus, action);
if (!(await hasPosition(ctx, required))) {
fail(`操作失败:当前节点「${result.atStepDef?.label ?? STATUS_LABEL[fromStatus]}」需要由对应岗位处理,你没有该岗位。如需处理,请联系管理员分配岗位。`, 'KPI_SHEET_POSITION');
// 文案取「实际要求的那个节点」,不是 transition 给的 atStepDef(#38 第 5 条)。
// 归档不落在任何流程节点上 —— `requiredPositionFor` 对它固定要人力审核岗位,而
// `atStepDef` 是流程的最后一个节点(通常是「领导审批」)。照 atStepDef 写,提示就会
// 把人指到一个跟这次拒绝无关的节点上。规则不动,只改文案取值。
const gateStep = action === 'archive' ? steps.find((s) => s.step_type === 'hr_review') ?? null : result.atStepDef;
const gateLabel = gateStep?.label ?? (gateStep ? STEP_LABEL[gateStep.step_type] : null)
?? (action === 'archive' ? STEP_LABEL.hr_review : STATUS_LABEL[fromStatus]);
fail(`操作失败:当前节点「${gateLabel}」需要由对应岗位处理,你没有该岗位。如需处理,请联系管理员分配岗位。`, 'KPI_SHEET_POSITION');
}

const reason = typeof input.action_reason === 'string' ? input.action_reason.trim() : '';
Expand Down
11 changes: 9 additions & 2 deletions src/hooks/util.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { HookContext } from '@objectstack/spec/data';
import { statusLabel } from '../lib/workflow.js';

/** 平台 IScopedContext 的运行时实现带 `sudo()`(系统上下文:可写只读字段、绕过数据范围)。 */
export interface Repo {
Expand Down Expand Up @@ -143,6 +144,12 @@ export async function nameOf(api: Api, object: string, id: string | null | undef
return (row?.name as string | undefined) ?? fallback;
}

/**
* 写一条审核记录 —— 全系统留痕的唯一入口(流程推进、核对、调整落地、方案发布都经这里)。
*
* 原状态 / 新状态按 {@link statusLabel} 写中文:这两个字段是文本字段、列表直出,写内部值
* 就是把 `draft` / `branch_checking` 摆给用户看。转换放在这一处,四个调用点一次覆盖。
*/
export async function writeReview(
api: Api,
data: {
Expand All @@ -159,8 +166,8 @@ export async function writeReview(
sheet: data.sheet,
action: data.action,
step_label: data.step_label ?? null,
from_status: data.from_status ?? null,
to_status: data.to_status ?? null,
from_status: statusLabel(data.from_status),
to_status: statusLabel(data.to_status),
actor: data.actor ?? null,
reason: data.reason ?? null,
acted_at: nowIso(),
Expand Down
13 changes: 13 additions & 0 deletions src/lib/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ export const STATUS_LABEL: Record<SheetStatus, string> = {
archived: '已归档',
};

/**
* 状态的中文标签 —— 给**留痕写入**用(审核记录的原状态 / 新状态)。
*
* 审核记录是给人看的审计视图,列表直出字段值,存内部值就等于把 `draft` /
* `branch_checking` 摆到用户面前。转换放在写入侧而不是展示侧:留痕对象一次写入、多处
* 读取(列表、表单、导出、归档快照 payload),写入侧转一次,四个读取面全都对。
* 未知取值原样返回,不吞值。
*/
export function statusLabel(status: string | null | undefined): string | null {
if (status === null || status === undefined || status === '') return null;
return STATUS_LABEL[status as SheetStatus] ?? status;
}

export function sortSteps(steps: PlanStepDef[]): PlanStepDef[] {
return [...steps].sort((a, b) => a.seq - b.seq);
}
Expand Down
Loading
Loading