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
39 changes: 38 additions & 1 deletion src/objects/entry.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '指标填报',
Expand All @@ -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 }),
Expand Down
67 changes: 65 additions & 2 deletions src/pages/index.ts
Original file line number Diff line number Diff line change
@@ -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.<var>`(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: '工作台',
Expand All @@ -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,
},
},
],
},
],
Expand Down
10 changes: 9 additions & 1 deletion src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 0 additions & 3 deletions src/translations/zh-CN.objects.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,9 +423,6 @@ export const zhCNTranslations: TranslationData = {
_sections: {
entry: {
label: "填报"
},
score: {
label: "计分"
}
}
},
Expand Down
36 changes: 33 additions & 3 deletions src/views/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }] },
] } },
});

Expand Down
Loading