diff --git a/src/objects/entry.object.ts b/src/objects/entry.object.ts index 84de41b..3209ab7 100644 --- a/src/objects/entry.object.ts +++ b/src/objects/entry.object.ts @@ -121,9 +121,31 @@ export const EntryLine = ObjectSchema.create({ nameField: 'indicator_name', fields: { + /** + * 归属两键(`sheet` / `plan_indicator`)的「新建可填、建完锁定」(#34)。 + * + * 这两个键决定一行明细挂在哪张填报单、对应哪条下达指标 —— 新建时必须能选(否则 + * 「新建」是死路),建成之后改任何一个都等于把这行悄悄挪走,连带 指标名称 / + * 计量单位 / 目标值 / 权重 四个冻结字段与已算出的得分全部对不上账。 + * + * `readonlyWhen: 'record.id != null'` —— 新记录没有 id,谓词判假,两个 lookup 正常 + * 可选;已存在的记录判真,编辑面渲染为只读文本。声明在对象层,所有展示面(记录 + * 编辑弹窗、内嵌网格、相关列表)一致生效;平台约定 `readonlyWhen` 不在创建路径上 + * 锁字段,所以 hook 与种子的建行路径不受影响。 + * + * 与视图层 `immutable` 的分工:`src/views/index.ts` 的表单里这两个字段标了 + * `immutable: true`(语义即「可建不可改」),但本版控制台只落实了它的前半句 —— + * 编辑弹窗里仍渲染带 ✕ 的选择器。**视图层 immutable 待平台修复** + * (objectstack-ai/objectstack#16171),**对象层 readonlyWhen 为应用侧兜底**; + * 平台修好后两者语义一致、可共存,不必回删。 + * + * 注意这是 UI/写入面的护栏而非权限闸门:数据层真护栏仍是 `src/security/index.ts` + * 的 `writeScope` 与 `src/hooks/entry-line.hook.ts` 的冻结守卫。 + */ sheet: Field.masterDetail('kpi_entry_sheet', { label: '所属填报单', required: true, + readonlyWhen: 'record.id != null', deleteBehavior: 'cascade', inlineEdit: 'grid', inlineTitle: '指标填报', @@ -147,8 +169,23 @@ export const EntryLine = ObjectSchema.create({ { name: 'remark' }, { name: 'is_adjusted' }, { name: 'adjust_type_applied' }, ], + /** + * 填报单详情「相关」页签里那张填报明细表的列(#34)。 + * + * 不声明时平台从子对象派生,派生出来的是 指标方向 / 计分方式 / 来源下达 / 指标 / + * 指标名称 / 计量单位 六列 —— 全是配置项,目标、权重、实际值、得分一个都没有,填报人 + * 打开「相关」页签看不到自己填了什么、得了多少分。这里按《设计方案》5.4 的指标网格 + * 口径显式列出。 + * + * 只能声明在关系上:相关列表的列取自 子对象 highlightFields → 本键 → 页面块的 + * `record:related_list.columns`,视图层没有这条链的入口(spec `field.zod.ts` 的 + * `relatedListColumns` 说明)。与上面的 `inlineColumns` 不同,这里只收字段名字符串, + * 标签、类型、格式一律从子对象字段定义取,列与字段因此不会各说各话。 + */ + relatedListColumns: ['indicator_name', 'unit', 'target_value', 'weight', 'actual_value', 'completion_rate', 'score_rate', 'final_score', 'remark'], }), - plan_indicator: Field.lookup('kpi_plan_indicator', { label: '来源下达', required: true }), + /** 建完锁定,理由与 `sheet` 上方那段注释同源(#34)。 */ + plan_indicator: Field.lookup('kpi_plan_indicator', { label: '来源下达', required: true, readonlyWhen: 'record.id != null' }), indicator: Field.lookup('kpi_indicator', { label: '指标', readonly: true }), indicator_name: Field.text({ label: '指标名称', readonly: true, searchable: true, maxLength: 200 }), unit: Field.text({ label: '计量单位', readonly: true, maxLength: 20 }), diff --git a/src/pages/index.ts b/src/pages/index.ts index e42cba8..0af53c0 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -1,5 +1,44 @@ import { definePage } from '@objectstack/spec/ui'; +/** + * 工作台 —— 填报人员的待办入口(#34)。 + * + * 设计目标是「一屏填数出分提交」:登录后直接落在本页,不再先跳「填报明细」再跳「填报单」。 + * 两个 `object-grid` 区块合起来构成一条不跳页的动线 —— + * + * 1. 「待填报的指标」= `kpi_entry_line` 的可编辑网格(`editable: true`),首列是「所属填报单」, + * 后面九列是填一行数当场要看的项;实际值与备注可改,其余列在字段定义上就是 `readonly`, + * 网格照此渲染。保存后 `entry-line.hook` 逐行算分,完成率 / 得分率 / 最终得分当场回填到 + * 同一张表。 + * 2. 「填报单」= `kpi_entry_sheet` 的只读网格,行操作挂 `kpi_sheet_submit`(「提交填报」), + * 填完即可在同一页提交。 + * + * **能看到哪些行**由数据层决定(对象 OWD `private` / `controlled_by_parent` + 方案发布时 + * 写入的动态共享规则 + 权限集 `readScope`)—— 视图筛选是展示范围,不是安全边界,这里不拿 + * `filter` 当权限用。 + * + * **屏幕上怎么分辨哪一行是哪一张单**是另一回事(评审 F3),三处按平台实际能到的程度处理: + * + * - 首列 `sheet`(所属填报单)是识别列,`sort` 也以它打头:跨方案 / 跨期间的同名指标 + * (两行「回款率」)靠它区分归属并排到一起,没有它,单击即改的网格里写错行不会有任何提示。 + * - 「只显示填报中的单的明细」**做不到**,不是没做:这是 `kpi_entry_sheet.status`,而查询 + * 引擎明确拒绝跨关系过滤 —— `$filter` 上写 `sheet.status` 返回 400 `INVALID_FIELD`: + * 「filters on 'sheet.status', which follows the relationship 'sheet' into another object + * — a filter reaches only columns of 'kpi_entry_line' itself … Denormalise the value onto + * 'kpi_entry_line' (a stored field, written when the source changes) and filter that.」 + * 本对象上没有任何字段编码填报单状态,而反规范化要新增字段 + 写入路径,超出本工作项范围。 + * 退化的替代条件(如「实际值为空」)会让保存后的行当场从网格里消失,连带毁掉「同页出分」, + * 比不过滤更糟,因此不做。已提交 / 已归档的行仍会出现在网格里,改动在保存时被 `sheet.hook` + * 的冻结闸门拦下并给出三段式提示 —— 拦得住,只是晚一步。 + * - 「对 org 范围岗位隐藏本区块」**做不到**:页面组件的 `visibleWhen` 只绑 `record` / + * `current_user` / `page.`(spec `ui/page.zod.ts`),没有岗位绑定。实测写 + * `'kpi_dept_reporter' in current_user.positions` 后,**连部门填报人员本人都看不到网格** + * (谓词求值为空 = 对所有人隐藏),已回退。 + * + * 区块标题按需求 §1.4 术语表用「填报单」「指标」的表内名词,不用「我的」——`kpi_home` 是 + * `isDefault: true` 的默认落地页,六个岗位共用;对管理员 / 人力审核这类 org 范围岗位, + * 网格里本来就不止「我的」行,标题不该替它们说话。 + */ export const HomePage = definePage({ name: 'kpi_home', label: '工作台', @@ -10,9 +49,33 @@ export const HomePage = definePage({ regions: [ { name: 'header', width: 'full', components: [{ type: 'page:header', properties: { title: 'KPI 考核管理', subtitle: '指标库 → 方案配置 → 指标下达 → 填报 → 并行核对 → 审核 → 计分 → 调整 → 汇总 → 归档' } }] }, { - name: 'main', width: 'large', + name: 'main', width: 'full', components: [ - { type: 'element:text', properties: { content: '部门填报人员:在「填报单」里按行录入实际值,系统即时出分;确认无误后点「提交填报」。分公司核对人员:在「分公司核对」里确认或提出争议。人力审核 / 分管领导:在「填报单」对应队列里审核通过或驳回(驳回必须填写原因)。审批通过后在「结果与归档」查看四个维度的汇总并归档。' } }, + { type: 'element:text', properties: { content: '部门填报人员:在下方「待填报的指标」直接填实际值,保存即出分,再到「填报单」点「提交填报」;分公司核对人员:在「分公司核对」确认或提出争议;人力审核 / 分管领导:在「填报单」对应队列里审核通过或驳回(驳回必须填写原因);审批通过后在「结果与归档」查看四个维度的汇总并归档。' } }, + { type: 'element:text', properties: { content: '待填报的指标', variant: 'subheading' } }, + { + type: 'object-grid', + properties: { + objectName: 'kpi_entry_line', + editable: true, + singleClickEdit: true, + columns: ['sheet', 'indicator_name', 'unit', 'target_value', 'weight', 'actual_value', 'completion_rate', 'score_rate', 'final_score', 'remark'], + sort: [{ field: 'sheet', order: 'asc' }, { field: 'indicator_name', order: 'asc' }], + pageSize: 25, + }, + }, + { type: 'element:divider', properties: {} }, + { type: 'element:text', properties: { content: '填报单', variant: 'subheading' } }, + { + type: 'object-grid', + properties: { + objectName: 'kpi_entry_sheet', + columns: ['name', 'subject', 'status', 'line_count', 'weight_total', 'indicator_score', 'bonus_total', 'total_score'], + rowActions: ['kpi_sheet_submit'], + sort: [{ field: 'updated_at', order: 'desc' }], + pageSize: 25, + }, + }, ], }, ], diff --git a/src/security/index.ts b/src/security/index.ts index 6ea543d..4205bd0 100644 --- a/src/security/index.ts +++ b/src/security/index.ts @@ -110,7 +110,15 @@ export const DeptReporterPermissionSet = definePermissionSet({ kpi_staff_assignment: readOwn, kpi_personal_item: { allowRead: true, readScope: 'org' }, // 填报单 OWD 为 private:本部门可见性由方案发布时写入的动态共享规则(services/sharing-service.ts)从 own 放宽到本单元 kpi_entry_sheet: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false, allowExport: true, readScope: 'own', writeScope: 'own' }, - kpi_entry_line: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowExport: true, readScope: 'own', writeScope: 'own' }, + // 填报明细只由方案发布生成:`allowCreate: false` 让「新建」在填报明细列表与填报单 + // 「相关」页签里都不再出现 —— 手工新建一行明细既没有冻结的目标值与权重,也不在任何 + // 指标下达之下,计分引擎算不出分,是一条注定作废的行。导入路径不受影响:导入填的是 + // 已生成行的实际值,走的是 `allowEdit`。 + // `allowDelete` 与它一起关:删除守卫(EntryLineDeleteGuardHook)只在填报单离开 + // 「填报中」之后才拦,留着删除权就等于允许填报人在填报期删掉发布生成的行 —— 新建已关, + // 删掉就没有任何重建路径,而提交闸门只数「实际值为空」的行,缺行的单能干净地提交, + // 权重合计与指标得分静默少掉一块。明细的增删只由方案发布与数据调整负责。 + kpi_entry_line: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false, allowExport: true, readScope: 'own', writeScope: 'own' }, kpi_check_task: { allowRead: true, readScope: 'own' }, // 审核记录:只见本部门填报单的留痕(共享规则按填报单放宽)。 kpi_review_record: readOwn, diff --git a/src/translations/zh-CN.objects.generated.ts b/src/translations/zh-CN.objects.generated.ts index 6373769..6ccb2d6 100644 --- a/src/translations/zh-CN.objects.generated.ts +++ b/src/translations/zh-CN.objects.generated.ts @@ -423,9 +423,6 @@ export const zhCNTranslations: TranslationData = { _sections: { entry: { label: "填报" - }, - score: { - label: "计分" } } }, diff --git a/src/views/index.ts b/src/views/index.ts index e5fe295..76eb144 100644 --- a/src/views/index.ts +++ b/src/views/index.ts @@ -140,15 +140,45 @@ export const EntrySheetViews = defineView({ }, }); -const lineCols = cols('sheet', 'indicator_name', 'unit', 'direction', 'scoring_method', 'target_value', 'weight', 'actual_value', 'completion_rate', 'score_rate', 'score', 'adjusted_score', 'is_adjusted', 'adjust_type_applied', 'final_score', 'remark'); +/** + * 填报明细列表列(#34):只留填报人当场要看的九项 + 「已调整」标记。 + * + * 移出列表的 所属填报单 / 指标方向 / 计分方式 / 调整类型 / 调整后得分 都是配置或留痕字段 —— + * 填一行数时用不上,却把「实际值」挤到第 8 列开外。它们仍在记录页与导出里,不是删掉。 + */ +const lineCols = cols('indicator_name', 'unit', 'target_value', 'weight', 'actual_value', 'completion_rate', 'score_rate', 'final_score', 'is_adjusted', 'remark'); export const EntryLineViews = defineView({ list: { label: '填报明细', type: 'grid', data: obj('kpi_entry_line'), columns: lineCols, inlineEdit: true, exportOptions: XLSX, sort: [{ field: 'indicator_name', order: 'asc' }] }, listViews: { unfilled: { label: '未填实际值', type: 'grid', data: obj('kpi_entry_line'), columns: lineCols, inlineEdit: true, filter: [{ field: 'actual_value', operator: 'is_null' }] }, }, + /** + * 编辑表单只留「实际值」「备注」两个可写字段(#34)。 + * + * 原来的「计分」分区把 完成率 / 得分率 / 指标得分 / 调整后得分 / 最终得分 一并摆进表单, + * 而本版本控制台的记录表单**提交表单上的全部字段**,不只提交改动过的那些:部门填报人员的 + * 字段权限把 `score` / `adjusted_score` 标成不可写,于是只改一格实际值也会被服务端按 + * 「写了不该写的字段」拒掉(平台侧 objectstack-ai/objectstack#15259)。把这些只读字段移出 + * 表单,提交体里就不再带它们 —— 得分照样在记录页与列表里看得到,那是读的地方。 + * + * 「所属填报单」「来源下达」标 `immutable` 而不是 `readonly`(评审 F1)。两者在对象上都是 + * `required: true`,而这是**唯一**一个 formView,新建与编辑共用:标 `readonly` 时控制台把 + * 两个必填 lookup 的「选择…」按钮一起 `disabled`,管理员与人力审核点「新建」只能得到 + * 「所属填报单不能为空、来源下达不能为空」,表单永远提交不了。`immutable`(spec + * `ui/view.zod.ts`:Editable on create, locked once the record exists)正是为这两种场景 + * 分开设的键,换上后管理员实测新建成功。 + * + * ⚠️ 实测边界,别按字面理解:本版控制台**只落实了 `immutable` 的前半句**。编辑弹窗里这两个 + * lookup 的「选择…」按钮仍是 enabled(`button.disabled === false`),没有「记录存在后锁死」 + * 的表现。也就是说,归属字段在编辑表单上并没有 UI 锁 —— 这与改动前主干上的行为一致 + * (主干这两个字段只有 `required`,同样可改),不是本次新开的口子;真正的护栏在数据层: + * `writeScope: 'own'` 让填报人只够得到自己那张单。另一条路(给「新建」单独一个 + * `formViews.create`)也实测过:控制台的「新建」仍然渲染 `formViews.form`,新建与编辑 + * 不按名字分流,因此这条路不成立。其余业务字段在对象定义上本就是 `readonly`,这里的标记 + * 只是让表单显式一致。 + */ formViews: { form: { type: 'simple', data: obj('kpi_entry_line'), sections: [ - { name: 'entry', label: '填报', columns: 2, fields: [{ field: 'sheet', required: true }, { field: 'plan_indicator', required: true }, { field: 'indicator_name' }, { field: 'unit' }, { field: 'target_value' }, { field: 'weight' }, { field: 'actual_value' }, { field: 'remark' }] }, - { name: 'score', label: '计分', columns: 2, fields: [{ field: 'direction' }, { field: 'scoring_method' }, { field: 'completion_rate' }, { field: 'score_rate' }, { field: 'score' }, { field: 'adjusted_score' }, { field: 'is_adjusted' }, { field: 'adjust_type_applied' }, { field: 'final_score' }, { field: 'last_adjustment' }, { field: 'calc_trace' }] }, + { name: 'entry', label: '填报', columns: 2, fields: [{ field: 'sheet', immutable: true }, { field: 'plan_indicator', immutable: true }, { field: 'indicator_name', readonly: true }, { field: 'unit', readonly: true }, { field: 'target_value', readonly: true }, { field: 'weight', readonly: true }, { field: 'actual_value' }, { field: 'remark' }] }, ] } }, });