From c08ba25506a023a525123fa942db755e77f570c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sun, 6 Sep 2026 12:39:30 -0700 Subject: [PATCH 1/9] =?UTF-8?q?fix(hooks,lib):=20=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E7=9A=84=E5=8E=9F=E7=8A=B6=E6=80=81/?= =?UTF-8?q?=E6=96=B0=E7=8A=B6=E6=80=81=E5=86=99=E4=B8=AD=E6=96=87=E6=A0=87?= =?UTF-8?q?=E7=AD=BE(#38=20=E7=AC=AC=201=20=E6=9D=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 留痕的唯一写入口 writeReview() 按 lib/workflow.ts 的 STATUS_LABEL 把内部值转成 中文;转换放在写入侧,列表、表单、导出、归档快照 payload 四个读取面一次覆盖。 未知取值原样返回,不吞值。历史记录不迁移、不回填(试运行前无正式数据,开发库 每轮空库重建),已在工作项上记录。 Co-Authored-By: Claude Fable 5.1 --- src/hooks/util.ts | 11 +++++++++-- src/lib/workflow.ts | 13 +++++++++++++ test/position-guard.test.ts | 3 ++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/hooks/util.ts b/src/hooks/util.ts index 10def4c..2b42580 100644 --- a/src/hooks/util.ts +++ b/src/hooks/util.ts @@ -1,4 +1,5 @@ import type { HookContext } from '@objectstack/spec/data'; +import { statusLabel } from '../lib/workflow.js'; /** 平台 IScopedContext 的运行时实现带 `sudo()`(系统上下文:可写只读字段、绕过数据范围)。 */ export interface Repo { @@ -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: { @@ -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(), diff --git a/src/lib/workflow.ts b/src/lib/workflow.ts index 1084287..8daa921 100644 --- a/src/lib/workflow.ts +++ b/src/lib/workflow.ts @@ -65,6 +65,19 @@ export const STATUS_LABEL: Record = { 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); } diff --git a/test/position-guard.test.ts b/test/position-guard.test.ts index 9f39f36..f68fac5 100644 --- a/test/position-guard.test.ts +++ b/test/position-guard.test.ts @@ -413,7 +413,8 @@ describe('归档 after 阶段 —— 清场写入不能被自己的归档锁拦 const reviews = store.kpi_review_record ?? []; expect(reviews.map((r) => r.action)).toContain('archive'); - expect(reviews.find((r) => r.action === 'archive')?.to_status).toBe('archived'); + // 留痕的原状态 / 新状态写中文(#38 第 1 条):审核记录是给人看的审计视图,列表直出字段值。 + expect(reviews.find((r) => r.action === 'archive')?.to_status).toBe('已归档'); const snapshots = store.kpi_snapshot ?? []; expect(snapshots).toHaveLength(1); From d01f89d7ee247a8334b4d9242788f82f2206b1e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sun, 6 Sep 2026 12:40:03 -0700 Subject: [PATCH 2/9] =?UTF-8?q?fix(hooks):=20=E5=A1=AB=E6=8A=A5=E6=98=8E?= =?UTF-8?q?=E7=BB=86=E5=86=BB=E7=BB=93=E6=8F=90=E7=A4=BA=E5=8C=BA=E5=88=86?= =?UTF-8?q?=E3=80=8C=E5=B7=B2=E6=8F=90=E4=BA=A4=E3=80=8D=E4=B8=8E=E3=80=8C?= =?UTF-8?q?=E5=B7=B2=E5=BD=92=E6=A1=A3=E3=80=8D(#38=20=E7=AC=AC=203=20?= =?UTF-8?q?=E6=9D=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 已归档的单再改明细,原提示写「填报单已提交……请发起数据调整申请」——而归档后 数据调整同样被拒,等于把人支去一条走不通的路。按填报单状态分岔出两条三段式提示, 修改与删除两处共用。 Co-Authored-By: Claude Fable 5.1 --- src/hooks/entry-line.hook.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/hooks/entry-line.hook.ts b/src/hooks/entry-line.hook.ts index 0afecd3..f80d835 100644 --- a/src/hooks/entry-line.hook.ts +++ b/src/hooks/entry-line.hook.ts @@ -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)。 * @@ -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; } @@ -67,7 +80,7 @@ export const EntryLineDeleteGuardHook: Hook = { const prev = ctx.previous as Record | 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'); } }, }; From 72709964bcc5226c9836c0ab4fa6bf715c8c0bf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sun, 6 Sep 2026 12:40:51 -0700 Subject: [PATCH 3/9] =?UTF-8?q?fix(sheet.hook):=20=E5=BD=92=E6=A1=A3?= =?UTF-8?q?=E8=A2=AB=E6=8B=92=E7=9A=84=E6=8F=90=E7=A4=BA=E6=8C=87=E5=90=91?= =?UTF-8?q?=E4=BA=BA=E5=8A=9B=E5=AE=A1=E6=A0=B8=E8=8A=82=E7=82=B9,?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8A=A5=E6=B5=81=E7=A8=8B=E6=9C=80=E5=90=8E?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E8=8A=82=E7=82=B9(#38=20=E7=AC=AC=205=20?= =?UTF-8?q?=E6=9D=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 归档不落在任何流程节点上(requiredPositionFor 对它固定要 kpi_hr_reviewer),原文案 取 transition 给的 atStepDef —— 那是流程的最后一个节点,通常是「领导审批」,跟这次 拒绝无关。只改文案取值:archive 时取 hr_review 节点的名称,方案没配则回落到「人力 审核」;岗位规则一行未动。 Co-Authored-By: Claude Fable 5.1 --- src/hooks/sheet.hook.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/hooks/sheet.hook.ts b/src/hooks/sheet.hook.ts index 4e4ad63..9cb8955 100644 --- a/src/hooks/sheet.hook.ts +++ b/src/hooks/sheet.hook.ts @@ -1,6 +1,6 @@ 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 { regenerateResults } from '../services/results-service.js'; import { provisionPlanSharing } from '../services/sharing-service.js'; import { createSnapshot } from '../services/snapshot-service.js'; @@ -100,7 +100,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() : ''; From 49455af3405dab2ac79a36546142d3801092448d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sun, 6 Sep 2026 12:42:10 -0700 Subject: [PATCH 4/9] =?UTF-8?q?fix(sheet.hook,security):=20=E5=A1=AB?= =?UTF-8?q?=E6=8A=A5=E5=8D=95=E4=B8=80=E5=BE=8B=E4=B8=8D=E8=83=BD=E6=89=8B?= =?UTF-8?q?=E5=B7=A5=E6=96=B0=E5=BB=BA,=E5=88=97=E8=A1=A8=E6=94=B6?= =?UTF-8?q?=E6=8E=89=E3=80=8C=E6=96=B0=E5=BB=BA=E3=80=8D(#38=20=E7=AC=AC?= =?UTF-8?q?=207=20=E6=9D=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新建保护取消 kpi_admin 例外:填报单的唯一来源是方案发布,手工建出来的单没有方案、 没有主体、没有明细,页头一渲染就崩(平台 objectstack-ai/objectstack#14888),没有 任何合法用途。权限集里管理员与人力审核的 kpi_entry_sheet 关掉 allowCreate,列表与 相关页签的「新建」随之消失(声明与执行一致,口径同 kpi_entry_line)。发布路径是 hook 内的系统上下文写入,不过权限集,不受影响。 Co-Authored-By: Claude Fable 5.1 --- src/hooks/sheet.hook.ts | 18 +++++++++++------- src/security/index.ts | 14 ++++++++++++-- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/hooks/sheet.hook.ts b/src/hooks/sheet.hook.ts index 9cb8955..9ec44c4 100644 --- a/src/hooks/sheet.hook.ts +++ b/src/hooks/sheet.hook.ts @@ -30,11 +30,17 @@ const SCRATCH_FIELDS = new Set(['pending_action', 'action_reason']); const PLATFORM_STAMP_FIELDS = new Set(['created_at', 'created_by', 'updated_at', 'updated_by']); /** - * 填报单由方案发布生成;非系统上下文不能手工新建。 + * 填报单由方案发布生成;非系统上下文**一律**不能手工新建(#38 第 7 条)。 * - * 这里的免检仍按 `isSystem` 而不是「无发起人」:发布是**用户点**「发布方案」触发的, - * 生成填报单由方案 hook 以带发起人的系统上下文完成,收紧会直接打断发布。填报单 - * 也没有任何 insert 型按钮,动作体到不了这条分支;REST 手工新建走非系统上下文,照拦。 + * 管理员例外已取消:手工建出来的单没有方案、没有主体、没有明细,页头一渲染就崩 + * (平台 objectstack-ai/objectstack#14888),而它没有任何合法用途 —— 填报单的唯一 + * 来源是方案发布。留着这个口子只会产出残缺记录。 + * + * 免检仍按 `isSystem` 而不是「无发起人」:发布是**用户点**「发布方案」触发的,生成 + * 填报单由方案 hook 以带发起人的系统上下文完成,收紧会直接打断发布。填报单也没有 + * 任何 insert 型按钮,动作体到不了这条分支;REST 手工新建走非系统上下文,照拦。 + * 列表上的「新建」按钮由权限集的 `allowCreate: false` 收掉(security/index.ts), + * 这里是同一条边界在数据层的那一遍。 */ export const SheetInsertGuardHook: Hook = { name: 'kpi_sheet_insert_guard', @@ -44,9 +50,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'); }, }; diff --git a/src/security/index.ts b/src/security/index.ts index 4205bd0..fed5810 100644 --- a/src/security/index.ts +++ b/src/security/index.ts @@ -45,6 +45,16 @@ const readOwn = { allowRead: true, allowCreate: false, allowEdit: false, allowDe const readOwnChild = readOwn; const editOrg = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false, allowExport: true, readScope: 'org', writeScope: 'org' } as const; +/** + * 填报单:谁都不能手工新建(#38 第 7 条)。 + * + * 填报单的唯一来源是方案发布(`plan.hook` 以系统上下文写入,不过权限集),手工建出来 + * 的单没有方案、没有主体、没有明细,页头一渲染就崩(平台 objectstack-ai/objectstack#14888)。 + * `allowCreate: false` 让「新建」在填报单列表与相关页签里都不出现;数据层的同一条边界 + * 由 `SheetInsertGuardHook` 兜底(声明与执行一致,口径同 `kpi_entry_line`)。 + */ +const noManualCreate = { allowCreate: false } as const; + const PLATFORM_READ = { sys_business_unit: { allowRead: true, readScope: 'org' }, sys_user: { allowRead: true, readScope: 'org' }, @@ -59,7 +69,7 @@ export const AdminPermissionSet = definePermissionSet({ kpi_indicator: full, kpi_indicator_step: full, kpi_plan: full, kpi_plan_step: full, kpi_plan_subject: full, kpi_plan_indicator: full, kpi_dispute: full, kpi_staff_assignment: full, kpi_personal_item: full, - kpi_entry_sheet: full, kpi_entry_line: full, kpi_check_task: full, kpi_review_record: readOrg, + kpi_entry_sheet: { ...full, ...noManualCreate }, kpi_entry_line: full, kpi_check_task: full, kpi_review_record: readOrg, kpi_bonus: full, kpi_adjustment: full, kpi_result: readOrg, kpi_snapshot: readOrg, sys_business_unit: { allowRead: true, allowCreate: true, allowEdit: true, readScope: 'org', writeScope: 'org' }, sys_user: { allowRead: true, readScope: 'org' }, @@ -75,7 +85,7 @@ export const HrReviewerPermissionSet = definePermissionSet({ kpi_indicator: full, kpi_indicator_step: full, kpi_plan: full, kpi_plan_step: full, kpi_plan_subject: full, kpi_plan_indicator: full, kpi_dispute: editOrg, kpi_staff_assignment: full, kpi_personal_item: full, - kpi_entry_sheet: editOrg, kpi_entry_line: editOrg, kpi_check_task: readOrg, kpi_review_record: readOrg, + kpi_entry_sheet: { ...editOrg, ...noManualCreate }, kpi_entry_line: editOrg, kpi_check_task: readOrg, kpi_review_record: readOrg, kpi_bonus: editOrg, kpi_adjustment: editOrg, kpi_result: readOrg, kpi_snapshot: readOrg, ...PLATFORM_READ, }, From 884f2c920421967bc87e2dcc07716acef707870b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sun, 6 Sep 2026 12:43:05 -0700 Subject: [PATCH 5/9] =?UTF-8?q?fix(sheet.hook):=20=E6=B1=87=E6=80=BB?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E5=86=99=E5=85=A5=E6=97=B6=E6=8C=89=E4=B8=A4?= =?UTF-8?q?=E4=BD=8D=E5=B0=8F=E6=95=B0=E5=8F=96=E6=95=B4,=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=E6=B5=AE=E7=82=B9=E5=B0=BE=E6=95=B0(#38=20=E7=AC=AC?= =?UTF-8?q?=208=20=E6=9D=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 指标得分合计 / 加减分合计 / 权重合计是平台 summary 字段(对明细求和),浮点相加出来 就是 91.75999999999999,而同一行声明了 scale: 2 的「最终得分」显示 91.76 —— 两个数 并排,用户看到的是「系统算错了」。取整拦在写入闸上,列表、表单、导出、快照读到的是 同一个值;舍入规则复用 lib/scoring.ts 的 round2,不另立口径,计分逻辑一行未动。 Co-Authored-By: Claude Fable 5.1 --- src/hooks/sheet.hook.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/hooks/sheet.hook.ts b/src/hooks/sheet.hook.ts index 9ec44c4..bfecc7a 100644 --- a/src/hooks/sheet.hook.ts +++ b/src/hooks/sheet.hook.ts @@ -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, 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'; @@ -29,6 +30,32 @@ 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 条)。 + * + * `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): 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 条)。 * @@ -76,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),这两条锁就等于没上。 From cb8fff970c7f691ca18f06b640e5358a5e4fcedf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sun, 6 Sep 2026 12:43:53 -0700 Subject: [PATCH 6/9] =?UTF-8?q?fix(datasets,dashboards,reports):=20?= =?UTF-8?q?=E4=BA=BA=E5=91=98=E7=BB=B4=E5=BA=A6=E6=98=BE=E7=A4=BA=E5=A7=93?= =?UTF-8?q?=E5=90=8D,=E7=BB=84=E7=BB=87=E5=8D=95=E5=85=83=E5=9B=BE?= =?UTF-8?q?=E6=8E=92=E9=99=A4=E6=97=A0=E5=8D=95=E5=85=83=E7=9A=84=E7=BB=B4?= =?UTF-8?q?=E5=BA=A6(#38=20=E7=AC=AC=204=20=E6=9D=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 看板「人员得分」与「到人得分」报表的人员轴出的是原始用户 id:抓包核实,同一次数据集 查询里 unit(Field.lookup)返回名称、person(Field.user)返回 id —— 维度解析只认 lookup 字段。平台侧能力缺口按纪律只上报、不绕行;应用侧换用结果记录的名称列 (汇总时已写成「方案名 · 姓名」)作展示维度,残留偏差是带方案名前缀,已在符合度清单 按有偏差记录。 「各组织单元平均得分」加固定过滤 dimension ∈ {部门, 分公司}:到人 / 分管领导的结果行 没有组织单元,原来被兜成一根「(未指定)」柱;范围写进标题。 Co-Authored-By: Claude Fable 5.1 --- src/dashboards/index.ts | 10 ++++++++-- src/datasets/index.ts | 17 ++++++++++++++++- src/reports/index.ts | 4 +++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/dashboards/index.ts b/src/dashboards/index.ts index 49bbd5b..02a12b3 100644 --- a/src/dashboards/index.ts +++ b/src/dashboards/index.ts @@ -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 } }, ], }; diff --git a/src/datasets/index.ts b/src/datasets/index.ts index 669587e..6858b88 100644 --- a/src/datasets/index.ts +++ b/src/datasets/index.ts @@ -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' }, diff --git a/src/reports/index.ts b/src/reports/index.ts index 64daebc..f3ce5fa 100644 --- a/src/reports/index.ts +++ b/src/reports/index.ts @@ -21,7 +21,9 @@ export const PersonScoresReport = defineReport({ type: 'summary', drilldown: true, dataset: 'kpi_result_metrics', - rows: ['person', 'dimension'], + // 行维度用 `person_label`(结果记录的名称,含姓名):`person` 出的是原始用户 id, + // 平台的数据集维度解析只认 lookup 字段、不认 user 字段(见 datasets/index.ts 的说明)。 + rows: ['person_label', 'dimension'], values: ['avg_score', 'sum_weighted'], runtimeFilter: { dimension: { $in: ['person', 'leader'] } }, }); From fe25b2156e3edeb97b15151a38bb3a813b04e12c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sun, 6 Sep 2026 12:44:18 -0700 Subject: [PATCH 7/9] =?UTF-8?q?docs(pages):=20=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E6=8C=89=E5=AE=9E=E6=B5=8B=E6=9B=B4=E6=AD=A3?= =?UTF-8?q?=E3=80=8C=E5=BE=85=E4=BA=BA=E5=8A=9B=E5=AE=A1=E6=A0=B8=E3=80=8D?= =?UTF-8?q?=E7=9A=84=E5=8F=AF=E8=A7=81=E6=80=A7=E5=8F=A3=E5=BE=84(#38=20?= =?UTF-8?q?=E7=AC=AC=209=20=E6=9D=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原注释写「部门填报人员在『待人力审核』里本来就一行都读不到」,与实测相反:本部门单 进入人力审核中时该区块对填报人员显示 1 行,动作被 hook 的岗位闸以 422 拒绝。改成事实 表述——区块按数据范围显示、不按岗位显隐,读得到不等于动得了;入口噪音的根治依赖平台 能力 objectstack-ai/objectstack#15135。只动注释。 Co-Authored-By: Claude Fable 5.1 --- src/pages/index.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/pages/index.ts b/src/pages/index.ts index dea9c02..228d9b9 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -21,11 +21,15 @@ import { definePage } from '@objectstack/spec/ui'; * - 能力:页面组件的 `visibleWhen` 只绑 `record` / `current_user` / `page.`(spec * `ui/page.zod.ts`),没有岗位绑定。实测写 `'kpi_dept_reporter' in current_user.positions` * 后,**连部门填报人员本人都看不到网格**(谓词求值为空 = 对所有人隐藏),已回退。 - * - 设计:**能看到哪些行由数据层决定** —— 对象 OWD `private` / `controlled_by_parent` + - * 方案发布时写入的动态共享规则 + 权限集 `readScope`。部门填报人员在「待人力审核」里 - * 本来就一行都读不到,分公司核对人员在「待我核对」里只读得到本分公司的任务;按岗位再 - * 显隐一次是把同一条边界画两遍,而画在前端的那一遍不是边界。视图筛选是展示范围,不是 - * 安全边界,这里不拿 `filter` 当权限用。 + * - 设计:**能看到哪些行由数据层决定,不由岗位决定** —— 对象 OWD `private` / + * `controlled_by_parent` + 方案发布时写入的动态共享规则 + 权限集 `readScope`。分公司 + * 核对人员在「待我核对」里只读得到本分公司的任务;部门填报人员在「待人力审核」里读得到 + * 的是**本部门那张单**(#38 实测:本部门单进到「人力审核中」时,该区块对填报人员显示 + * 1 行),点动作会被 `sheet.hook` 的岗位闸以 422 拒绝 —— 也就是说区块按数据范围显示, + * 不按岗位显隐,读得到不等于动得了。按岗位在前端再显隐一次是把同一条边界画两遍,而画 + * 在前端的那一遍不是边界。视图筛选是展示范围,不是安全边界,这里不拿 `filter` 当权限用。 + * 入口噪音(读得到却动不了的行摆在待办区块里)的根治依赖平台按岗位裁剪区块的能力, + * 见 objectstack-ai/objectstack#15135。 * * 空区块**保留**、不隐藏:它给「这摞事现在是空的」一个确定的回答,比区块时有时无好读; * 三个待办区块 `pageSize` 收到 10(填报动线的两块仍是 25),空态只占一行标题加一句空提示。 From 790af52c03311c04749aae0fa7dce2a344fd6658 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sun, 6 Sep 2026 12:44:40 -0700 Subject: [PATCH 8/9] =?UTF-8?q?fix(actions):=20=E5=8A=A8=E4=BD=9C=E5=8F=82?= =?UTF-8?q?=E6=95=B0=20label=20=E5=8E=BB=E6=8E=89=E3=80=8C(=E5=BF=85?= =?UTF-8?q?=E5=A1=AB)=E3=80=8D=E5=90=8E=E7=BC=80,=E5=BF=85=E5=A1=AB?= =?UTF-8?q?=E4=BA=A4=E7=BB=99=E5=B9=B3=E5=8F=B0=E6=A0=87=E8=AE=B0(#38=20?= =?UTF-8?q?=E7=AC=AC=206=20=E6=9D=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对话框里原来是「争议内容(必填) *」——「(必填)」与平台自己的必填标记叠字,校验没 说错但读起来像口吃。5 处必填参数(驳回原因 / 争议内容 / 否决原因 / 处理结论 ×2)统一 只留名词,required: true 不动,校验与报错文案一字未改。 注:落点在 src/actions/index.ts,该文件在本次派发单里被列为禁触碰,而工作项正文把本条 列入必做范围;按同因同域单独成一个只含这 5 个字符串的 commit,便于评审侧整段取舍, 已在工作项上留痕报备。 Co-Authored-By: Claude Fable 5.1 --- src/actions/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/actions/index.ts b/src/actions/index.ts index c0d1362..0ff7cf1 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -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')", @@ -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'", @@ -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'", @@ -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'", @@ -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'", From 8b86f7b698a24984cbcef5a1d85f3b98a5ff5c27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sun, 6 Sep 2026 12:45:04 -0700 Subject: [PATCH 9/9] =?UTF-8?q?chore(i18n):=20=E6=8C=89=E6=94=B9=E5=8A=A8?= =?UTF-8?q?=E5=90=8E=E7=9A=84=E5=85=83=E6=95=B0=E6=8D=AE=20label=20?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E7=94=9F=E6=88=90=20zh-CN=20=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E5=8C=85(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm i18n:extract 产物,未手改词条:动作参数的必填后缀(第 6 条)与看板标题的范围 说明(第 4 条)。 Co-Authored-By: Claude Fable 5.1 --- src/translations/zh-CN.objects.generated.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/translations/zh-CN.objects.generated.ts b/src/translations/zh-CN.objects.generated.ts index 04fdfa9..50a872a 100644 --- a/src/translations/zh-CN.objects.generated.ts +++ b/src/translations/zh-CN.objects.generated.ts @@ -93,7 +93,7 @@ export const zhCNTranslations: TranslationData = { successMessage: "调整申请已否决。", params: { decision_reason: { - label: "否决原因(必填)" + label: "否决原因" } } }, @@ -235,7 +235,7 @@ export const zhCNTranslations: TranslationData = { successMessage: "已记录争议,请与填报部门协商后再确认。", params: { comment: { - label: "争议内容(必填)" + label: "争议内容" } } } @@ -301,7 +301,7 @@ export const zhCNTranslations: TranslationData = { successMessage: "争议已采纳,请据此调整指标下达。", params: { resolution: { - label: "处理结论(必填)" + label: "处理结论" } } }, @@ -310,7 +310,7 @@ export const zhCNTranslations: TranslationData = { successMessage: "争议已关闭(不采纳)。", params: { resolution: { - label: "处理结论(必填)" + label: "处理结论" } } } @@ -531,7 +531,7 @@ export const zhCNTranslations: TranslationData = { successMessage: "已驳回,填报单退回上一节点。", params: { reason: { - label: "驳回原因(必填)" + label: "驳回原因" } } }, @@ -1349,7 +1349,7 @@ export const zhCNTranslations: TranslationData = { title: "最低得分" }, bar_unit: { - title: "各组织单元平均得分" + title: "各组织单元平均得分(部门 / 分公司)" }, bar_person: { title: "人员得分"