From e665f9c688d3f645719d68a64606af7beea506c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 16:53:13 +0000 Subject: [PATCH] feat(core): v0.5 P3 manual scoring, grading, director publish and appeals - Scorers are assigned when score lines are generated: manual KPIs and automatic KPIs without data take a scorer by the new srm_kpi.scorer_type from the supplier's categories (new srm_category.buyer / evaluator), falling back to the category manager; scorers are notified and the review gets a scoring due date (perf_score_days). - srm_score_submit screen flow: a scorer sees and fills only the lines assigned to them, one screen per line, prefilled for revisions; the review moves to reviewing once every line is scored. - srm_score_due_notice reminds open scorers 3 days before the due date; srm_score_overdue_sweep gives missing lines perf_missing_score, marks them defaulted and moves the review on. - srm_performance_publish totals and grades the review (perf_grade_min), then the procurement director approves (published, supplier grade and latest_score written unless a later period is already published) or rejects (back to scoring with the comment). A director who scored the review cannot approve it (BR-11 style recheck). - srm_appeal_submit (recorded by buyers before the portal, once, within perf_appeal_days) and srm_appeal_resolve (director: uphold or adjust the total, regrade, sync the supplier); srm_performance_void for reviews in manual scoring. New position srm_evaluator, views and nav entry. - Design docs 02 / 03 / 05 and params updated first. Closes #52 Claude-Session: https://claude.ai/code/session_01WFrb9tuVXniHJeMavJzpES Co-authored-by: Claude --- docs/design/02-domain-model.md | 24 +- docs/design/03-processes-and-automation.md | 22 +- docs/design/05-experience-and-analytics.md | 9 +- params/core.yaml | 18 +- src/core/actions/index.ts | 1 + src/core/actions/performance.actions.ts | 21 + src/core/apps/workbench.app.ts | 1 + src/core/data/index.ts | 19 +- src/core/flows/index.ts | 7 + .../flows/performance/appeal-resolve.flow.ts | 160 +++++++ .../flows/performance/appeal-submit.flow.ts | 127 ++++++ src/core/flows/performance/generate.flow.ts | 67 ++- src/core/flows/performance/params.ts | 17 + src/core/flows/performance/publish.flow.ts | 278 ++++++++++++ .../performance/score-due-notice.flow.ts | 85 ++++ .../performance/score-overdue-sweep.flow.ts | 83 ++++ .../flows/performance/score-submit.flow.ts | 173 ++++++++ src/core/flows/performance/void.flow.ts | 70 +++ src/core/objects/category.object.ts | 3 + src/core/objects/kpi.object.ts | 12 + src/core/objects/performance-review.object.ts | 49 ++- src/core/objects/performance-score.object.ts | 7 +- src/core/objects/supplier.object.ts | 13 + src/core/security/positions.ts | 2 + src/core/translations/en.ts | 107 ++++- src/core/views/index.ts | 1 + src/core/views/performance-review.view.ts | 28 +- src/core/views/performance-score.view.ts | 40 ++ test/performance-publish.test.ts | 410 ++++++++++++++++++ 29 files changed, 1811 insertions(+), 43 deletions(-) create mode 100644 src/core/actions/performance.actions.ts create mode 100644 src/core/flows/performance/appeal-resolve.flow.ts create mode 100644 src/core/flows/performance/appeal-submit.flow.ts create mode 100644 src/core/flows/performance/params.ts create mode 100644 src/core/flows/performance/publish.flow.ts create mode 100644 src/core/flows/performance/score-due-notice.flow.ts create mode 100644 src/core/flows/performance/score-overdue-sweep.flow.ts create mode 100644 src/core/flows/performance/score-submit.flow.ts create mode 100644 src/core/flows/performance/void.flow.ts create mode 100644 src/core/views/performance-score.view.ts create mode 100644 test/performance-publish.test.ts diff --git a/docs/design/02-domain-model.md b/docs/design/02-domain-model.md index 39ce04e..16230ee 100644 --- a/docs/design/02-domain-model.md +++ b/docs/design/02-domain-model.md @@ -266,7 +266,8 @@ flow 拒绝新建人核验自己建的账户(03 §13)。 | `legacy_imported` | boolean | 默认 false;存量导入的证书为 true,同供应商的同名字段(只在新建时可置为 true,只有 `srm_admin` 可写)(v0.2) | **`srm_category` 采购品类**(OWD `public_read`):`code`、`name`、`parent`(`tree`, -上级品类)、`category_manager`(user)、`sqe`(user)、`is_direct`(boolean,直接 +上级品类)、`category_manager`(user)、`sqe`(user)、`buyer`(user,采购员)、`evaluator`(user,需求部门 +评分人,持 `srm_evaluator` 岗位;`buyer` 与 `evaluator` 随 v0.5 绩效评分加入,见 §4.7 `scorer_type`)、`is_direct`(boolean,直接 物料)、`material_class`(共享选项列表 `srm_material_class`:`class_a` 战略类 / `class_b` 重要类 / `class_c` 一般类(选项值至少 2 个字符,所以不用单字母),与体系文件的物资 A / B / C 分类同源,决定准入审批是否会签质量经理、 审核频次和绩效周期)、`risk_class`(`high` / `medium` / `low`,决定准入是否需要现场 @@ -659,7 +660,9 @@ false,BR-13)、`received_quantity`(summary:`sum` 收货记录 `accepted_ `weight`(percent)、`active`(默认 true)、`scoring_rule`(textarea,评分规则说明,例如「OTD ≥ 98% 得满分, 每低 1% 扣 5 分」)、`full_score_value` / `zero_score_value`(number,自动指标必填:原始值达到满分值得 100 分、 达到零分值得 0 分,之间线性计分、越界取 0 或 100;满分值大于零分值时越高越好,反之越低越好〔PPM〕;比率按百分点填, -两者不能相同)。启用的指标权重必须大于 0。 +两者不能相同)、`scorer_type`(评分人:`category_manager` 品类经理〔默认〕/ `buyer` 采购员 / `sqe` SQE / +`evaluator` 需求部门评分人;人工指标和没有数据的自动指标按它从供应商的品类上取评分人,03 §10 第 3 步)。 +启用的指标权重必须大于 0。 现阶段一套全局方案:**启用指标的权重合计应为 100%**。跨记录合计写不成单条校验规则(改权重往往要连改几条), 所以由视图「启用指标」检查——按维度分组,权重列的合计即总权重、分组表头是各维度小计;考评生成 flow 再守一次 @@ -675,13 +678,20 @@ screen flow;删除只给 `srm_admin`,删掉后可补跑生成;开启字段 由汇总分级写入,供看板使用)、`grade`(`grade_a` / `grade_b` / `grade_c` / `grade_d`;平台的选项值至少两个字符)、 `status`(见 §5.7)、`otd_rate` / `lot_pass_rate`(percent)、`ppm`(number)、`ack_on_time_rate`(percent, ERP 集成前为空)、`delivery_count`(number,本期交付行数)、`data_incomplete`(boolean,默认 false:本期没有交付记录或 -某项启用的自动指标没有数据时为 true,该项不给零分,07 R7)、`reviewer`(user)、`appeal_reason`、`appeal_result`。 +某项启用的自动指标没有数据时为 true,该项不给零分,07 R7)、`score_due`(date,评分截止日,转人工评分时为当天 + +`perf_score_days`)、`approval_status`(审批结论,审定发布的审批节点写入)、`reviewer`(user,审定人)、`review_comment` +(审定意见:通过或退回重评时采购总监的意见)、`published_date`(date)、`appeal_reason`、`appeal_date`(date,申诉登记日)、 +`appeal_recorded_by`(user,代录人)、`appeal_result`、`appeal_outcome`(`maintained` 维持原结果 / `adjusted` 调整总分)、 +`appeal_handled_by`(user,处理申诉的采购总监)、`void_reason`(v0.5 P3 加入后面这些字段)。 自动指标由交付记录按 `period` 汇总,比率字段存小数(0.985 即 98.5%);flow 写的字段一律只读。 +维度得分是该维度各评分明细按权重的加权平均(0–100,没有该维度的指标时为空),总分是全部明细的 `Σ(score × weight)` +(启用指标权重合计 100%),都保留两位小数,等级按两位小数的总分判断。 **`srm_performance_score` 评分明细**(master_detail → 考评单,`controlled_by_parent`):`kpi`(lookup)、 `raw_value`(number,自动指标的原始值,与指标的满分值、零分值同一单位:比率按百分点,PPM 按 PPM)、`score`(number,0–100)、 `weight`(percent,生成时从指标快照)、`scorer`(user,人工指标的评分人)、`comment`、`scored`(boolean,默认 false; -自动指标算出得分时为 true,没有数据时为 false 并在 `comment` 写明)。 +自动指标算出得分时为 true,没有数据时为 false 并在 `comment` 写明)、`defaulted`(boolean,默认 false,「未评」:评分截止时 +还没有评分,由扫描任务按参数 `perf_missing_score` 取默认分,v0.5 P3)。 ### 4.8 结算(不做,留在 ERP) @@ -815,13 +825,15 @@ ERP 集成前为空)、`delivery_count`(number,本期交付行数)、`da |---|---|---|---| | `calculating` ★ | 自动算分 | open | `scoring`、`void` | | `scoring` | 人工评分 | open | `reviewing`、`void` | -| `reviewing` | 审定中 | open | `published`、`void` | +| `reviewing` | 审定中 | open | `published`、`scoring`(采购总监退回重评)、`void` | | `appealed` | 申诉中 | open | `published` | | `published` | 已发布 | won | `appealed` | | `void` | 已作废 | lost | — | `calculating` 只存在于一次生成运行之内:生成 flow 建单、建评分明细、算完自动指标后在同一次运行里转 `scoring`; -中途失败留下的 `calculating` 考评单在下次运行时删掉重建(03 §10 第 1 步)。 +中途失败留下的 `calculating` 考评单在下次运行时删掉重建(03 §10 第 1 步)。作废只在人工评分阶段,由采购总监经「作废」 +screen flow 写明理由(例如供应商本期实际没有供货);作废的考评单仍占着 `supplier` + `period`,同期要重新考评时由系统管理员 +删除后补跑生成。 ### 5.8 对账单 diff --git a/docs/design/03-processes-and-automation.md b/docs/design/03-processes-and-automation.md index b67f88a..a8267a7 100644 --- a/docs/design/03-processes-and-automation.md +++ b/docs/design/03-processes-and-automation.md @@ -383,11 +383,15 @@ stateDiagram-v2 |---|---|---| | 1 生成 | `schedule`:每月 1–7 日每天 03:00(追赶式) | 活跃供应商(合格或冻结:冻结期间仍在交付在途订单,连续 D 级要能累计)按所供品类(不论品类资格状态)中最高的物资类别取周期参数 `perf_cycle`(示例 A 类每月、B 类每季、C 类每年),没有品类资格的不生成;每期的第一个月「到期」,建上一期的考评单(`calculating`,`cycle` / `period` / 起止日期写明),按指标库中启用的指标建评分明细并快照权重(FR-702)。启用指标的权重合计不是 100% 时本次不生成并通知采购总监。同一供应商同一期间已有考评单的跳过(`supplier` + `period` 唯一),1 日漏跑的后几天补上,跑两遍不重复建;上次中途失败留下的 `calculating` 考评单删掉重建。补跑更早的月份时传入运行日 `asOf` | | 2 自动算分 | 同一次运行 | 按 [05 §4](./05-experience-and-analytics.md#4-仪表盘与指标口径) 的口径(同数据集 `srm_delivery_metrics`)从交付记录计算 OTD、批次合格率、PPM:flow 的查询节点按供应商和本期包含的月份(交付记录的 `period`)取交付记录,汇总 `on_time`、`inspection_result`、`defect_quantity`、`received_quantity`,写入考评单的比率字段与交付行数;每条自动指标的评分明细写入原始值与得分,得分按指标的满分值、零分值线性计算(02 §4.7),数字全在指标库里,flow 不写死。本期没有交付记录、某项指标没有数据(如没有已检批次)时该项不给分(`scored = false`,`comment` 写明),考评单标记「数据不全」,不给零分(07 R7),由人工评分环节处理。订单响应及时率在 ERP 集成前在指标库中不启用(不建评分明细),启用时由采购总监调整其余权重。算完转 `scoring`。不良数与实收数逐行累加:CEL 没有对列表求和、flow 没有汇总节点(07 §6.3 PF15)。性能(07 R9,2026-10-24 进程内 sqlite 实测):200 家供应商、每家 6 行交付记录一次生成约 7.4 秒,每家 30 行约 8.6 秒,重跑(全部跳过)约 0.6 秒 | -| 3 人工评分 | 状态→`scoring` | 按评分明细的 `scorer`(品类经理、SQE、需求部门评分人)通知,评分人经「提交评分」screen flow 只看到并填写分配给自己的评分项;截止 10 天,扫描任务提醒未评项;截止后未评项按「取 60 分并标记未评」处理(可配置) | -| 4 汇总分级 | 全部评分完成或截止 | `total_score = Σ(score × weight)`;等级线取参数 `perf_grade_min`(示例 A ≥ 90、B ≥ 80、C ≥ 70,其余 D);状态→`reviewing` | -| 5 审定发布 | 审批:采购总监(`position = srm_procurement_director`) | 通过后→`published`;回写供应商 `grade`、`latest_score`;供应商可在门户查看结果与扣分项(FR-704) | +| 3 人工评分 | 生成 flow 转 `scoring` 时 | 评分人在生成评分明细时写入:人工指标和没有数据的自动指标按指标的 `scorer_type` 从供应商的品类上取(品类经理 `category_manager`、采购员 `buyer`、SQE `sqe`、需求部门评分人 `evaluator`);供应商有多个品类时取物资类别最高的、设了这个人的品类,都没设时退回品类经理,仍没有则不指派(截止后按缺评处理)。转 `scoring` 时写评分截止日 `score_due`(当天 + `perf_score_days`,示例 10 天)并通知各评分人(「待你评分」)。评分人在考评单上点「提交评分」(screen flow `srm_score_submit`):flow 只列出指派给本人的评分项,逐项显示指标、评分规则和原始值,填 0–100 分和说明,已评的可以在截止前改;全部评分项都已评分时考评单转 `reviewing`。`srm_score_due_notice` 在截止前 3 天提醒还有未评项的评分人;扫描任务 `srm_score_overdue_sweep`(每日,追赶式)把已过截止日的考评单上未评项取参数 `perf_missing_score`(示例 60 分,客户可改)、标记「未评」(`defaulted`),转 `reviewing` | +| 4 汇总分级 | 状态→`reviewing`(`srm_performance_publish`) | `total_score = Σ(score × weight)`,各维度得分为维度内按权重的加权平均;等级线取参数 `perf_grade_min`(示例 A ≥ 90、B ≥ 80、C ≥ 70,其余 D),写入考评单 | +| 5 审定发布 | 同一 flow 的审批:采购总监(`position = srm_procurement_director`) | 通过后→`published`,写审定人、发布日期;回写供应商 `grade`、`latest_score`(同一供应商已有更晚期间的已发布考评单时不回写),通知评分人;供应商可在门户查看结果与扣分项(FR-704,v0.6)。采购总监驳回即「退回重评」:考评单回到 `scoring`,审定意见写入 `review_comment`,截止日重新起算,通知评分人改分。职责分离(同 BR-11):审定人不能是本考评单的评分人,撞人时这次通过不算数,通知后重新开一轮审定 | | 6 结果应用 | 发布后的记录触发 | C 级:生成改善要求(整改单,`source = performance`);D 级:生成处置单草稿(冻结);连续 `perf_d_exit_periods` 期 D 级:生成淘汰处置单草稿交品类经理评审(BR-18);A 级:在名录中标记优选,供寻源邀请参考 | -| 7 申诉 | 供应商发布后 7 天内 | 状态→`appealed`,采购总监处理后回到 `published`,结果留痕(FR-706) | +| 7 申诉 | 供应商发布后 `perf_appeal_days`(示例 7)天内 | v0.6 前由采购员、采购主管或品类经理在考评单上代录(screen flow `srm_appeal_submit`,门户上线后供应商自己提交):只能申诉一次,写申诉理由 → `appealed`,通知采购总监。采购总监经「处理申诉」(`srm_appeal_resolve`)写处理结果,选择维持原结果或调整总分(等级按新总分重算并回写供应商)→ 回到 `published`,处理人和结果留在考评单上(FR-706)。处理人同样不能是本考评单的评分人。结果应用(第 6 步)只在审定发布时触发;申诉改变等级时,按原等级生成的草稿由品类经理作废或另行处理 | + +> 评分人的回避(评分人、复核人与被评价供应商有关联关系的)系统判断不了,是制度要求(GL-05);系统能做到的是评分只能填 +> 指派给本人的评分项、评分人不能审定或处理本考评单的申诉。人工评分环节的界面过滤之外,评分写入只经 screen flow(考评单与 +> 评分明细对所有内部岗位只读),所以绕过界面也改不了别人的评分项。 ## 11. P10 资质与风险监控(资质 v0.1,风险事件 v0.5) @@ -475,7 +479,9 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_audit_admission_apply` | record_change:准入审核完成或改结论 | 供应商审核 → 准入申请 | v0.4 | | `srm_audit_plan_generate` | schedule:每月 1 日 02:30(追赶式;年初那次生成全年计划) | 供应商审核 | v0.4 | | `srm_performance_generate` | schedule:每月 1–7 日 03:00(追赶式,按类别周期) | 考评单、评分明细 | v0.5 | -| `srm_performance_publish` | record_change:状态→`reviewing` | 考评单 → 供应商 | v0.5 | +| `srm_performance_publish` | record_change:状态→`reviewing`(汇总分级 + 采购总监审定) | 考评单 → 供应商 | v0.5 | +| `srm_score_due_notice` | time_relative:考评单 `score_due` −3 天 | 考评单 → 评分人 | v0.5 | +| `srm_score_overdue_sweep` | schedule:每日 04:00(追赶式) | 考评单、评分明细 | v0.5 | | `srm_performance_apply` | record_change:状态→`published` | 考评单 → 整改单、处置单 | v0.5 | | `srm_risk_event_apply` | record_change:风险事件新建或改为高风险 | 风险事件 → 处置单(草稿) | v0.5 | | `srm_blacklist_expiry_notice` | time_relative:供应商 `blacklist_until` −30 / 0 天 | 供应商(黑名单) | v0.5 | @@ -503,8 +509,10 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_change_submit` 提交变更申请 | 门户:公司资料(v0.6 前内部在供应商档案下直接新建变更单) | 门户管理员 | v0.6 | | `srm_audit_finding_to_scar` 转整改单 | 供应商审核(已完成) | SQE、质量经理 | v0.4 | | `srm_disposition_submit` 提交审批 / `srm_disposition_cancel` 作废 | 处置单(草稿) | 品类经理、采购主管 | v0.5 | -| `srm_score_submit` 提交评分 | 考评单 | 评分人 | v0.5 | -| `srm_appeal_submit` 申诉 | 门户:绩效 | 门户管理员 | v0.6 | +| `srm_score_submit` 提交评分 | 考评单(人工评分) | 评分人 | v0.5 | +| `srm_performance_void` 作废 | 考评单(人工评分) | 采购总监 | v0.5 | +| `srm_appeal_submit` 申诉 | 考评单(已发布);v0.6 起门户:绩效 | 采购员、采购主管、品类经理代录(v0.5);门户管理员(v0.6) | v0.5 | +| `srm_appeal_resolve` 处理申诉 | 考评单(申诉中) | 采购总监 | v0.5 | | `srm_portal_enable` 开通门户 | 供应商联系人 | `srm_supplier_admin` | v0.6 | | `srm_invite_supplier` 邀请供应商 | 寻源单 | 寻源工程师 | v0.7 | | `srm_event_submit` 发布 | 寻源单 | 寻源工程师 | v0.7 | diff --git a/docs/design/05-experience-and-analytics.md b/docs/design/05-experience-and-analytics.md index 050521c..1060760 100644 --- a/docs/design/05-experience-and-analytics.md +++ b/docs/design/05-experience-and-analytics.md @@ -27,7 +27,8 @@ │ └── 供应商审核 计划 / 问题项 ├── 绩效与风险 (v0.5) │ ├── 绩效指标 全部 / 启用指标(权重合计)/ 已停用 -│ ├── 考评单 全部 / 待评分 / 数据不全(本期考评随 v0.5 P3) +│ ├── 考评单 全部 / 待评分 / 数据不全 / 审定中 / 申诉中 / 已发布(按等级) +│ ├── 待我评分 评分明细:指派给本人、还没评分的评分项 │ └── 风险事件 风险看板(按严重度)/ 未关闭 / 按类型;黑名单名录在供应商列表视图 ├── 寻源 (v0.7) │ ├── 寻源单 我的 / 报价中 / 待开标 / 评标中 / 定标审批中 @@ -82,7 +83,9 @@ | 绩效指标 | 启用指标 | 列表 | `active == true`,按 `dimension` 分组,权重列合计(应为 100%)与各维度小计 | 采购总监 | | 考评单 | 待评分 | 列表 | `status == 'scoring'`,按期间倒序 | 采购总监 | | 考评单 | 数据不全 | 列表 | `data_incomplete == true`:本期交付记录没导入或不全(07 R7) | 采购总监、SQE | -| 考评单 | 本期考评 | 列表 | 当前期间,按 `grade` 分组(随 v0.5 P3 汇总分级) | 采购总监 | +| 考评单 | 审定中 / 申诉中 | 列表 | `status == 'reviewing'` / `status == 'appealed'` | 采购总监 | +| 考评单 | 已发布 | 列表 | `status == 'published'`,按 `grade` 分组、期间倒序(列表视图不能按「当前期间」动态过滤,用期间倒序代替) | 采购总监、品类经理 | +| 评分明细 | 待我评分 | 列表 | `scorer` 是本人且 `scored == false`,从这里点进考评单「提交评分」 | 评分人 | | 采购订单、订单行 | 待供应商确认、交期风险、交货排程 | 列表 / 甘特 | 预留(ERP 集成后的门户镜像) | 采购员、计划员 | 门户侧的视图是上述视图的子集,由 RLS 自动限定到本公司,**不需要**为门户单独写过滤 @@ -163,7 +166,7 @@ | 银行账户变更待复核、变更已生效 | 应付会计 / 采购员 | 站内 | v0.2 | | 合同待审批、合同即将到期 | 审批人、合同所有者、品类经理 | 站内 + 邮件 | v0.3 | | 新整改单、整改退回、整改重开、整改逾期 | 负责 SQE(新整改单没有 SQE 时发质量经理);逾期加质量经理(SQE 主管);v0.6 起加供应商 `quality` 联系人 | 邮件 + 站内 / 门户待办 | v0.4 | -| 待你评分、绩效结果发布 | 评分人;采购总监(v0.6 起加供应商 `admin` 联系人) | 站内 + 邮件 | v0.5 | +| 待你评分、评分截止前 3 天仍未评、退回重评、绩效结果发布;绩效申诉待处理、申诉处理结果 | 评分人;采购总监;申诉代录人(v0.6 起加供应商 `admin` 联系人) | 站内 + 邮件 | v0.5 | | 门户邀请、准入申请已受理 / 已驳回 / 已通过 | 受邀联系人、申请联系人 | 邮件 | v0.6 | | 新的询价邀请、报价截止前 24 小时仍未提交、中标 / 未中标、待你审批的定标 | 受邀供应商 `sales` 联系人;审批人 | 邮件 + 门户待办 | v0.7 | | 新订单待确认、订单变更、交期风险、订单 24 / 48 小时未确认、付款完成 | 供应商 / 采购员 / 采购主管 | 邮件 / 站内 | ERP 集成后 | diff --git a/params/core.yaml b/params/core.yaml index 976caf9..71f9ecd 100644 --- a/params/core.yaml +++ b/params/core.yaml @@ -70,14 +70,28 @@ perf_grade_min: example: { A: 90, B: 80, C: 70 } display: A 级 ≥ {A},B 级 ≥ {B},C 级 ≥ {C} used_in: [QP-01 6.7.3] - system: 考评单汇总分级规则(核心 03 §10 第 4 步) + system: 考评单汇总分级 srm_performance_publish 与申诉调整总分 srm_appeal_resolve,flow 的 GRADE_MIN 与本参数由测试核对一致(核心 03 §10 第 4 步) + +perf_score_days: + label: 绩效人工评分期限 + unit: 日历日(转人工评分起) + example: 10 + used_in: [] + system: 考评单转人工评分时写入评分截止日 score_due,截止前 3 天提醒未评项,截止后由 srm_score_overdue_sweep 按缺评处理;flow 的 SCORE_DAYS 与本参数由测试核对一致(核心 03 §10 第 3 步) + +perf_missing_score: + label: 绩效评分缺评默认分 + unit: 分 + example: 60 + used_in: [] + system: 评分截止后未评的评分项取本分值并标记「未评」(srm_score_overdue_sweep);flow 的 MISSING_SCORE 与本参数由测试核对一致(核心 03 §10 第 3 步,FR-703) perf_appeal_days: label: 绩效结果申诉期限 unit: 日历日(发布后) example: 7 used_in: [QP-01 6.7.5] - system: 申诉 screen flow srm_appeal_submit 的时限校验(核心 03 §10 第 7 步) + system: 申诉 screen flow srm_appeal_submit 的时限校验,flow 的 APPEAL_DAYS 与本参数由测试核对一致(核心 03 §10 第 7 步) perf_d_exit_periods: label: 启动淘汰的连续 D 级期数 diff --git a/src/core/actions/index.ts b/src/core/actions/index.ts index 223d233..a25e87d 100644 --- a/src/core/actions/index.ts +++ b/src/core/actions/index.ts @@ -3,3 +3,4 @@ export { ConfirmLegacyReviewAction } from './supplier.actions.js'; export { ContractSubmitAction, ContractActivateAction } from './contract.actions.js'; export { AuditFindingToScarAction } from './audit.actions.js'; export { DispositionSubmitAction, DispositionCancelAction } from './disposition.actions.js'; +export { ScoreSubmitAction, PerformanceVoidAction, AppealSubmitAction, AppealResolveAction } from './performance.actions.js'; diff --git a/src/core/actions/performance.actions.ts b/src/core/actions/performance.actions.ts new file mode 100644 index 0000000..6de5e41 --- /dev/null +++ b/src/core/actions/performance.actions.ts @@ -0,0 +1,21 @@ +import { P } from '@objectstack/spec'; +import { defineAction } from '@objectstack/spec/ui'; + +// 考评单上的按钮(03 §10 第 3、5、7 步)。谁能操作、能不能操作由 flow 自己检查。 +const action = (name: string, label: string, icon: string, status: string) => + defineAction({ + name, + label, + objectName: 'srm_performance_review', + icon, + type: 'flow', + target: name, + locations: ['record_header'], + visible: P`record.status == ${status}`, + refreshAfter: true, + }); + +export const ScoreSubmitAction = action('srm_score_submit', '提交评分', 'pencil', 'scoring'); +export const PerformanceVoidAction = action('srm_performance_void', '作废', 'x-circle', 'scoring'); +export const AppealSubmitAction = action('srm_appeal_submit', '申诉', 'message-square', 'published'); +export const AppealResolveAction = action('srm_appeal_resolve', '处理申诉', 'gavel', 'appealed'); diff --git a/src/core/apps/workbench.app.ts b/src/core/apps/workbench.app.ts index 8554488..9725731 100644 --- a/src/core/apps/workbench.app.ts +++ b/src/core/apps/workbench.app.ts @@ -49,6 +49,7 @@ export const Workbench = App.create({ children: [ { id: 'nav_kpi', type: 'object', objectName: 'srm_kpi', label: '绩效指标', icon: 'gauge' }, { id: 'nav_performance_review', type: 'object', objectName: 'srm_performance_review', label: '考评单', icon: 'award' }, + { id: 'nav_my_scores', type: 'object', objectName: 'srm_performance_score', viewName: 'mine', label: '待我评分', icon: 'pencil' }, { id: 'nav_risk_event', type: 'object', objectName: 'srm_risk_event', label: '风险事件', icon: 'siren' }, ], }, diff --git a/src/core/data/index.ts b/src/core/data/index.ts index e6ee9b6..421ffab 100644 --- a/src/core/data/index.ts +++ b/src/core/data/index.ts @@ -38,19 +38,20 @@ const suppliers = defineSeed(Supplier, { }); // 示例绩效指标库(02 §4.7,QP-01 附录 D):权重取 params/core.yaml 的 perf_kpi_weights(示例值,客户改参数时同步这里), -// 启用指标的权重合计 100%。自动指标口径见 05 §4.1;订单响应及时率要 ERP 订单确认数据,集成前不启用,启用时由采购总监调整其余权重 +// 启用指标的权重合计 100%。自动指标口径见 05 §4.1;订单响应及时率要 ERP 订单确认数据,集成前不启用,启用时由采购总监调整其余权重。 +// 评分人(scorer_type)按评分规则说明里的岗位;自动指标的评分人只在本期没有数据时用上(03 §10 第 3 步) const kpis = defineSeed(Kpi, { mode: 'ignore', externalId: 'code', records: [ - { code: 'LOT_PASS', name: '批次合格率', dimension: 'quality', method: 'auto', auto_metric: 'lot_pass_rate', weight: 0.2, full_score_value: 99, zero_score_value: 89, scoring_rule: '示例:检验合格批次 ÷ 已检验批次(让步接收计为不合格);≥ 99% 得满分,每低 1% 扣 10 分' }, - { code: 'PPM', name: '来料不良 PPM', dimension: 'quality', method: 'auto', auto_metric: 'ppm', weight: 0.1, full_score_value: 100, zero_score_value: 5000, scoring_rule: '示例:不良数 ÷ 实收数 × 1,000,000;≤ 100 得满分,≥ 5000 得 0 分,之间按比例' }, - { code: 'OTD', name: '交付准时率', dimension: 'delivery', method: 'auto', auto_metric: 'otd_rate', weight: 0.15, full_score_value: 98, zero_score_value: 78, scoring_rule: '示例:准时交付行 ÷ 交付行(无承诺交期时按需求日期);≥ 98% 得满分,每低 1% 扣 5 分' }, - { code: 'PLAN_FULFIL', name: '计划完成率', dimension: 'delivery', method: 'manual', weight: 0.1, scoring_rule: '示例:采购员按实际供货量 ÷ 月度计划量评分,100% 得满分' }, - { code: 'ACK_ON_TIME', name: '订单响应及时率', dimension: 'delivery', method: 'auto', auto_metric: 'ack_on_time_rate', weight: 0.1, active: false, full_score_value: 95, zero_score_value: 75, scoring_rule: '示例:下发后 24 小时内确认的订单 ÷ 下发订单;ERP 集成后启用' }, - { code: 'PRICE', name: '价格竞争力', dimension: 'cost', method: 'manual', weight: 0.2, scoring_rule: '示例:品类经理对比市场参考价或同类供应商价格评分' }, - { code: 'SERVICE', name: '响应与配合', dimension: 'service', method: 'manual', weight: 0.1, scoring_rule: '示例:问题处理、资料提交、对账配合的及时性,由采购员评分' }, - { code: 'SAFETY_ENV', name: '安全环保合规', dimension: 'hse', method: 'manual', weight: 0.15, scoring_rule: '示例:送货与现场作业安全、环保合规,按发生的事件扣分,由 SQE 评分' }, + { code: 'LOT_PASS', name: '批次合格率', dimension: 'quality', method: 'auto', auto_metric: 'lot_pass_rate', scorer_type: 'sqe', weight: 0.2, full_score_value: 99, zero_score_value: 89, scoring_rule: '示例:检验合格批次 ÷ 已检验批次(让步接收计为不合格);≥ 99% 得满分,每低 1% 扣 10 分' }, + { code: 'PPM', name: '来料不良 PPM', dimension: 'quality', method: 'auto', auto_metric: 'ppm', scorer_type: 'sqe', weight: 0.1, full_score_value: 100, zero_score_value: 5000, scoring_rule: '示例:不良数 ÷ 实收数 × 1,000,000;≤ 100 得满分,≥ 5000 得 0 分,之间按比例' }, + { code: 'OTD', name: '交付准时率', dimension: 'delivery', method: 'auto', auto_metric: 'otd_rate', scorer_type: 'buyer', weight: 0.15, full_score_value: 98, zero_score_value: 78, scoring_rule: '示例:准时交付行 ÷ 交付行(无承诺交期时按需求日期);≥ 98% 得满分,每低 1% 扣 5 分' }, + { code: 'PLAN_FULFIL', name: '计划完成率', dimension: 'delivery', method: 'manual', scorer_type: 'buyer', weight: 0.1, scoring_rule: '示例:采购员按实际供货量 ÷ 月度计划量评分,100% 得满分' }, + { code: 'ACK_ON_TIME', name: '订单响应及时率', dimension: 'delivery', method: 'auto', auto_metric: 'ack_on_time_rate', scorer_type: 'buyer', weight: 0.1, active: false, full_score_value: 95, zero_score_value: 75, scoring_rule: '示例:下发后 24 小时内确认的订单 ÷ 下发订单;ERP 集成后启用' }, + { code: 'PRICE', name: '价格竞争力', dimension: 'cost', method: 'manual', scorer_type: 'category_manager', weight: 0.2, scoring_rule: '示例:品类经理对比市场参考价或同类供应商价格评分' }, + { code: 'SERVICE', name: '响应与配合', dimension: 'service', method: 'manual', scorer_type: 'buyer', weight: 0.1, scoring_rule: '示例:问题处理、资料提交、对账配合的及时性,由采购员评分' }, + { code: 'SAFETY_ENV', name: '安全环保合规', dimension: 'hse', method: 'manual', scorer_type: 'sqe', weight: 0.15, scoring_rule: '示例:送货与现场作业安全、环保合规,按发生的事件扣分,由 SQE 评分' }, ], }); diff --git a/src/core/flows/index.ts b/src/core/flows/index.ts index 27798a1..1e9b761 100644 --- a/src/core/flows/index.ts +++ b/src/core/flows/index.ts @@ -28,3 +28,10 @@ export { DispositionCancelFlow } from './disposition/disposition-cancel.flow.js' export { RiskEventApplyFlow } from './risk/risk-event-apply.flow.js'; export { BlacklistExpiryNoticeFlow } from './risk/blacklist-expiry-notice.flow.js'; export { PerformanceGenerateFlow } from './performance/generate.flow.js'; +export { ScoreSubmitFlow } from './performance/score-submit.flow.js'; +export { PerformancePublishFlow } from './performance/publish.flow.js'; +export { ScoreDueNoticeFlow } from './performance/score-due-notice.flow.js'; +export { ScoreOverdueSweepFlow } from './performance/score-overdue-sweep.flow.js'; +export { AppealSubmitFlow } from './performance/appeal-submit.flow.js'; +export { AppealResolveFlow } from './performance/appeal-resolve.flow.js'; +export { PerformanceVoidFlow } from './performance/void.flow.js'; diff --git a/src/core/flows/performance/appeal-resolve.flow.ts b/src/core/flows/performance/appeal-resolve.flow.ts new file mode 100644 index 0000000..b55476e --- /dev/null +++ b/src/core/flows/performance/appeal-resolve.flow.ts @@ -0,0 +1,160 @@ +import { cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; +import { gradeOf } from './params.js'; + +// 处理绩效申诉(03 §10 第 7 步,FR-706):采购总监在申诉中的考评单上写处理结果,选择维持原结果或调整总分;调整时 +// 等级按新总分取 perf_grade_min 重算(params.ts 的 GRADE_MIN),各评分明细与维度得分不变,处理结果里写明调整依据。 +// 处理后回到已发布,处理人、结论和结果留在考评单上;是该供应商最近一期时同步回写供应商等级与总分;通知申诉代录人。 +// 处理人不能是本考评单的评分人(同审定发布的职责分离)。以系统身份运行,进门检查在 flow 里做。 +const c = (source: string) => ({ dialect: 'cel' as const, source }); +const round2 = (x: string) => `(double(round((${x}) * 100.0)) / 100.0)`; +const director = `current_user != null && 'srm_procurement_director' in current_user.positions`; +const appealed = `review != null && review.status == 'appealed'`; + +export const AppealResolveFlow: Automation.Flow = { + name: 'srm_appeal_resolve', + label: '处理绩效申诉', + type: 'screen', + status: 'active', + runAs: 'system', + successMessage: '申诉已处理,考评单回到已发布', + + variables: [{ name: 'recordId', type: 'text', isInput: true, isOutput: false }], + + nodes: [ + { id: 'start', type: 'start', label: '开始' }, + { + id: 'load', + type: 'get_record', + label: '读取考评单', + config: { objectName: 'srm_performance_review', filter: { id: cel`recordId` }, outputVariable: 'review' }, + }, + { id: 'gate', type: 'decision', label: '可以处理?' }, + { + id: 'refuse_position', + type: 'end', + label: '不是采购总监', + config: { outcome: 'refused', message: '绩效申诉由采购总监处理' }, + }, + { + id: 'refuse_status', + type: 'end', + label: '不在申诉中', + config: { outcome: 'refused', message: '只有申诉中的考评单需要处理申诉' }, + }, + { + id: 'load_own', + type: 'get_record', + label: '本人是这张考评单的评分人?', + config: { objectName: 'srm_performance_score', filter: { review: cel`review.id`, scorer: cel`current_user.id` }, fields: ['id'], outputVariable: 'own' }, + }, + { id: 'sod', type: 'decision', label: '评分人不能处理申诉' }, + { + id: 'refuse_scorer', + type: 'end', + label: '评分人不能处理本考评单的申诉', + config: { outcome: 'refused', message: '你是这张考评单的评分人,申诉请由另一位采购总监处理' }, + }, + { + id: 'resolve', + type: 'screen', + label: '处理申诉', + config: { + title: '处理申诉:{{ review.review_no }}', + description: '期间 {{ review.period }},原总分 {{ review.total_score }}。\n申诉理由:{{ review.appeal_reason }}', + fields: [ + { + name: 'outcome', + label: '处理结论', + type: 'select', + required: true, + options: [ + { label: '维持原结果', value: 'maintained' }, + { label: '调整总分', value: 'adjusted' }, + ], + }, + { name: 'adjusted_score', label: '调整后总分(0–100)', type: 'number', required: true, min: 0, max: 100, visibleWhen: "outcome == 'adjusted'" }, + { name: 'result_text', label: '处理结果', type: 'textarea', required: true }, + ], + }, + }, + { + id: 'regrade', + type: 'assignment', + label: '总分与等级', + config: { + assignments: { + totalScore: c(`outcome == 'adjusted' ? ${round2('double(adjusted_score)')} : double(review.total_score)`), + gradeValue: c(gradeOf('totalScore')), + }, + }, + }, + { + id: 'close', + type: 'update_record', + label: '回到已发布', + config: { + objectName: 'srm_performance_review', + filter: { id: cel`review.id` }, + fields: { + status: 'published', + appeal_outcome: cel`outcome`, + appeal_result: cel`result_text`, + appeal_handled_by: cel`current_user.id`, + total_score: cel`totalScore`, + grade: cel`gradeValue`, + }, + }, + }, + { + id: 'load_later', + type: 'get_record', + label: '有更晚期间的已发布考评单?', + config: { + objectName: 'srm_performance_review', + filter: { supplier: cel`review.supplier`, status: { $in: ['published', 'appealed'] }, period_end: { $gt: cel`review.period_end` } }, + fields: ['id'], + outputVariable: 'later', + }, + }, + { id: 'is_latest', type: 'decision', label: '是最近一期?' }, + { + id: 'write_supplier', + type: 'update_record', + label: '回写供应商等级与总分', + config: { objectName: 'srm_supplier', filter: { id: cel`review.supplier` }, fields: { grade: cel`gradeValue`, latest_score: cel`totalScore` } }, + }, + { + id: 'notify', + type: 'notify', + label: '通知申诉代录人', + config: { + recipients: cel`has(review.appeal_recorded_by) && review.appeal_recorded_by != null ? review.appeal_recorded_by : null`, + title: '考评单 {{ review.review_no }} 的申诉已处理', + message: '采购总监已处理申诉,处理结果见考评单。请把结果告知供应商。', + sourceObject: 'srm_performance_review', + sourceId: cel`review.id`, + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e0', source: 'start', target: 'load', type: 'default' }, + { id: 'e1', source: 'load', target: 'gate', type: 'default' }, + { id: 'e2', source: 'gate', target: 'refuse_position', type: 'conditional', condition: c(`!(${director})`), label: '不是采购总监' }, + { id: 'e3', source: 'gate', target: 'refuse_status', type: 'conditional', condition: c(`${director} && !(${appealed})`), label: '不在申诉中' }, + { id: 'e4', source: 'gate', target: 'load_own', type: 'conditional', condition: c(`${director} && ${appealed}`), label: '可以' }, + { id: 'e5', source: 'load_own', target: 'sod', type: 'default' }, + { id: 'e6', source: 'sod', target: 'refuse_scorer', type: 'conditional', condition: c('own != null'), label: '是评分人' }, + { id: 'e7', source: 'sod', target: 'resolve', type: 'conditional', condition: c('own == null'), label: '不是' }, + { id: 'e8', source: 'resolve', target: 'regrade', type: 'default' }, + { id: 'e9', source: 'regrade', target: 'close', type: 'default' }, + { id: 'e10', source: 'close', target: 'load_later', type: 'default' }, + { id: 'e11', source: 'load_later', target: 'is_latest', type: 'default' }, + { id: 'e_latest', source: 'is_latest', target: 'write_supplier', type: 'conditional', condition: c('later == null'), label: '是' }, + { id: 'e_older', source: 'is_latest', target: 'notify', type: 'conditional', condition: c('later != null'), label: '已有更晚的' }, + { id: 'e12', source: 'write_supplier', target: 'notify', type: 'default' }, + { id: 'e13', source: 'notify', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/performance/appeal-submit.flow.ts b/src/core/flows/performance/appeal-submit.flow.ts new file mode 100644 index 0000000..afa8065 --- /dev/null +++ b/src/core/flows/performance/appeal-submit.flow.ts @@ -0,0 +1,127 @@ +import { cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; +import { APPEAL_DAYS } from './params.js'; + +// 绩效申诉(03 §10 第 7 步,FR-706):已发布的考评单在发布后 perf_appeal_days(params.ts 的 APPEAL_DAYS,示例 7 天)内 +// 可以申诉一次,写申诉理由 → 申诉中,通知采购总监处理(srm_appeal_resolve)。v0.6 门户上线前供应商的申诉由采购员、 +// 采购主管或品类经理代录,代录人记在考评单上;门户上线后供应商门户管理员经同一个 flow 提交。 +// 以系统身份运行,因为考评单对所有人只读;进门检查在 flow 里做(同 srm_disposition_submit,objectstack#25481)。 +const c = (source: string) => ({ dialect: 'cel' as const, source }); +const allowed = + `current_user != null && ('srm_buyer' in current_user.positions || 'srm_buyer_lead' in current_user.positions` + + ` || 'srm_category_manager' in current_user.positions)`; +const published = `review != null && review.status == 'published'`; +const once = `!(has(review.appeal_date) && review.appeal_date != null)`; +const inWindow = `has(review.published_date) && review.published_date != null && isoDate(today()) <= isoDate(addDays(date(review.published_date), ${APPEAL_DAYS}))`; + +export const AppealSubmitFlow: Automation.Flow = { + name: 'srm_appeal_submit', + label: '绩效申诉', + type: 'screen', + status: 'active', + runAs: 'system', + successMessage: '申诉已登记,等待采购总监处理', + + variables: [{ name: 'recordId', type: 'text', isInput: true, isOutput: false }], + + nodes: [ + { id: 'start', type: 'start', label: '开始' }, + { + id: 'load', + type: 'get_record', + label: '读取考评单', + config: { objectName: 'srm_performance_review', filter: { id: cel`recordId` }, outputVariable: 'review' }, + }, + { id: 'gate', type: 'decision', label: '可以申诉?' }, + { + id: 'refuse_position', + type: 'end', + label: '没有代录权限', + config: { outcome: 'refused', message: '供应商的绩效申诉由采购员、采购主管或品类经理代录' }, + }, + { + id: 'refuse_status', + type: 'end', + label: '不是已发布', + config: { outcome: 'refused', message: '只有已发布的考评单可以申诉' }, + }, + { + id: 'refuse_once', + type: 'end', + label: '已申诉过', + config: { outcome: 'refused', message: '每张考评单只能申诉一次,这张已经申诉过' }, + }, + { + id: 'refuse_window', + type: 'end', + label: '超过申诉期限', + config: { outcome: 'refused', message: `已超过申诉期限:考评结果发布后 ${APPEAL_DAYS} 日内可以申诉` }, + }, + { + id: 'confirm', + type: 'screen', + label: '登记申诉', + config: { + title: '绩效申诉:{{ review.review_no }}', + description: '期间 {{ review.period }},总分 {{ review.total_score }}。每张考评单只能申诉一次,提交后由采购总监处理。', + fields: [{ name: 'appeal_text', label: '申诉理由', type: 'textarea', required: true }], + }, + }, + { + id: 'appeal', + type: 'update_record', + label: '置为申诉中', + config: { + objectName: 'srm_performance_review', + filter: { id: cel`review.id` }, + fields: { + status: 'appealed', + appeal_reason: cel`appeal_text`, + appeal_date: cel`isoDate(today())`, + appeal_recorded_by: cel`current_user.id`, + }, + }, + }, + { + id: 'load_directors', + type: 'get_record', + label: '读取采购总监', + config: { objectName: 'sys_user_position', filter: { position: 'srm_procurement_director' }, limit: 50, outputVariable: 'directors' }, + }, + { + id: 'notify', + type: 'notify', + label: '通知采购总监', + config: { + recipients: [cel`directors.map(p, p.user_id)`], + title: '考评单 {{ review.review_no }} 有申诉待处理', + message: '供应商对期间 {{ review.period }} 的考评结果提出申诉,理由见考评单。请在考评单上点「处理申诉」。', + severity: 'warning', + sourceObject: 'srm_performance_review', + sourceId: cel`review.id`, + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e0', source: 'start', target: 'load', type: 'default' }, + { id: 'e1', source: 'load', target: 'gate', type: 'default' }, + { id: 'e2', source: 'gate', target: 'refuse_position', type: 'conditional', condition: c(`!(${allowed})`), label: '没有权限' }, + { id: 'e3', source: 'gate', target: 'refuse_status', type: 'conditional', condition: c(`${allowed} && !(${published})`), label: '不是已发布' }, + { id: 'e4', source: 'gate', target: 'refuse_once', type: 'conditional', condition: c(`${allowed} && ${published} && !(${once})`), label: '已申诉过' }, + { + id: 'e5', + source: 'gate', + target: 'refuse_window', + type: 'conditional', + condition: c(`${allowed} && ${published} && ${once} && !(${inWindow})`), + label: '超过期限', + }, + { id: 'e6', source: 'gate', target: 'confirm', type: 'conditional', condition: c(`${allowed} && ${published} && ${once} && ${inWindow}`), label: '可以' }, + { id: 'e7', source: 'confirm', target: 'appeal', type: 'default' }, + { id: 'e8', source: 'appeal', target: 'load_directors', type: 'default' }, + { id: 'e9', source: 'load_directors', target: 'notify', type: 'default' }, + { id: 'e10', source: 'notify', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/performance/generate.flow.ts b/src/core/flows/performance/generate.flow.ts index 084bac1..5147950 100644 --- a/src/core/flows/performance/generate.flow.ts +++ b/src/core/flows/performance/generate.flow.ts @@ -1,4 +1,5 @@ import type * as Automation from '@objectstack/spec/automation'; +import { SCORE_DAYS } from './params.js'; // P9 步骤 1–2(03 §10,FR-702):生成考评单并自动算分。 // @@ -12,6 +13,10 @@ import type * as Automation from '@objectstack/spec/automation'; // 再按每条自动指标的满分值、零分值线性算分(02 §4.7),得分规则全在指标库里,这里不写死数字。本期没有交付记录、 // 某项指标没有数据(如没有已检批次)时该项不给分、考评单标记「数据不全」(07 R7)。算完转人工评分(scoring)。 // +// 指派评分人(03 §10 第 3 步,v0.5 P3):人工指标和没有数据的自动指标按指标的 scorer_type 从供应商的品类上取评分人—— +// 物资类别最高、设了这个人的品类优先,都没设时退回品类经理,仍没有则不指派(截止后按缺评处理)。转人工评分时写评分截止日 +// (当天 + perf_score_days)并通知各评分人。 +// // 追赶式(03 §2.3 R2):每月 1–7 日每天 03:00 运行,同一供应商同一期间已有考评单的跳过,1 日漏跑后几天补上,跑两遍不 // 重复建(考评单上 supplier + period 唯一索引兜底)。补跑更早的月份时传入运行日 asOf(如 2026-10-01)。 // 单家失败只跳过该家,不中断整次生成。 @@ -38,6 +43,17 @@ const cycleOf = (Object.entries(CYCLE) as Array<[string, string]>).reduceRight( const due = `(cycle == 'monthly' || (cycle == 'quarterly' && runMonth % 3 == 0) || (cycle == 'yearly' && runMonth == 0))`; const isDue = `(cycle != '' && ${due})`; const stale = `(existing != null && existing.status == 'calculating')`; +// 供应商品类上的某个人:物资类别最高、设了这个人的品类优先 +const personOf = (field: string) => + (['class_a', 'class_b', 'class_c'] as const).reduceRight( + (rest, cls) => `cats.exists(x, x.material_class == '${cls}' && ${val(`x.${field}`)}) ? cats.filter(x, x.material_class == '${cls}' && ${val(`x.${field}`)})[0].${field} : ${rest}`, + `cats.exists(x, ${val(`x.${field}`)}) ? cats.filter(x, ${val(`x.${field}`)})[0].${field} : null`, + ); +// 某条指标的评分人:按 scorer_type 取,没设时是品类经理 +const scorerOf = (k: string) => + `(${val(`${k}.scorer_type`)} && ${k}.scorer_type == 'buyer' ? buyerUser` + + ` : ${val(`${k}.scorer_type`)} && ${k}.scorer_type == 'sqe' ? sqeUser` + + ` : ${val(`${k}.scorer_type`)} && ${k}.scorer_type == 'evaluator' ? evaluatorUser : cmUser)`; const span = (n: number) => `[${Array.from({ length: n }, (_, i) => i).join(', ')}]`; // 某条指标在本期的原始值(与满分值、零分值同一单位);人工指标与没有数据源的自动指标为 null @@ -65,7 +81,7 @@ const supplierBody: Automation.FlowNode[] = [ config: { objectName: 'srm_category', filter: { id: { $in: c('grants.map(g, g.category)') } }, - fields: ['material_class'], + fields: ['material_class', 'category_manager', 'buyer', 'sqe', 'evaluator'], limit: 500, outputVariable: 'cats', }, @@ -84,6 +100,14 @@ const supplierBody: Automation.FlowNode[] = [ + ` : cycle == 'quarterly' ? string(periodStart.getFullYear()) + 'Q' + string(periodStart.getMonth() / 3 + 1)` + ` : string(periodStart.getFullYear())`, ), + // 评分人:采购员、SQE、需求部门评分人没设时退回品类经理 + cmUser: c(personOf('category_manager')), + buyerFound: c(personOf('buyer')), + sqeFound: c(personOf('sqe')), + evaluatorFound: c(personOf('evaluator')), + buyerUser: c('buyerFound != null ? buyerFound : cmUser'), + sqeUser: c('sqeFound != null ? sqeFound : cmUser'), + evaluatorUser: c('evaluatorFound != null ? evaluatorFound : cmUser'), // 本期包含的月份,交付记录的 period 是实到日期的年月 months: c( `(cycle == 'monthly' ? ${span(1)} : cycle == 'quarterly' ? ${span(3)} : ${span(12)}).map(i, isoDate(addMonths(periodStart, i)).substring(0, 7))`, @@ -138,7 +162,7 @@ const supplierBody: Automation.FlowNode[] = [ id: 'reset_sums', type: 'assignment', label: '累计清零', - config: { assignments: { defectSum: c('0.0'), receivedSum: c('0.0'), lineFailed: c('false') } }, + config: { assignments: { defectSum: c('0.0'), receivedSum: c('0.0'), lineFailed: c('false'), scorers: c('[]') } }, }, { id: 'sum_deliveries', @@ -202,7 +226,13 @@ const supplierBody: Automation.FlowNode[] = [ id: 'line_raw', type: 'assignment', label: '本项原始值', - config: { assignments: { raw: c(rawOf('kpi')) } }, + config: { + assignments: { + raw: c(rawOf('kpi')), + // 算出得分的自动指标不需要评分人 + lineScorer: c(`kpi.method == 'auto' && raw != null ? null : ${scorerOf('kpi')}`), + }, + }, }, { id: 'create_score', @@ -218,11 +248,21 @@ const supplierBody: Automation.FlowNode[] = [ score: c(scoreOf('kpi', 'raw')), scored: c(`kpi.method == 'auto' && raw != null`), comment: c(`kpi.method == 'auto' && raw == null ? '本期没有可用的交付数据,自动指标未算分' : null`), + scorer: c('lineScorer'), }, }, }, + { + id: 'collect_scorer', + type: 'assignment', + label: '记下评分人(去重)', + config: { assignments: { scorers: c('lineScorer != null && !(lineScorer in scorers) ? scorers + [lineScorer] : scorers') } }, + }, + ], + edges: [ + { id: 'e_score', source: 'line_raw', target: 'create_score', type: 'default' }, + { id: 'e_collect', source: 'create_score', target: 'collect_scorer', type: 'default' }, ], - edges: [{ id: 'e_score', source: 'line_raw', target: 'create_score', type: 'default' }], }, // 缺一行的考评单不能往下走:留在「自动算分」,下次运行删掉重建 catch: { nodes: [{ id: 'score_failed', type: 'assignment', label: '记下失败', config: { assignments: { lineFailed: c('true') } } }] }, @@ -247,10 +287,24 @@ const supplierBody: Automation.FlowNode[] = [ ppm: c('ppmRaw == null ? null : double(round(ppmRaw))'), delivery_count: c('lineCount'), data_incomplete: c('incomplete'), + score_due: c(`isoDate(addDays(today(), ${SCORE_DAYS}))`), status: 'scoring', }, }, }, + { id: 'has_scorers', type: 'decision', label: '有评分人?' }, + { + id: 'notify_scorers', + type: 'notify', + label: '通知评分人', + config: { + recipients: c('scorers'), + title: '待你评分:{{ review.review_no }}', + message: '考评单 {{ review.review_no }}(期间 {{ period }})有指派给你的评分项,请在考评单上点「提交评分」,评分截止日见考评单。', + sourceObject: 'srm_performance_review', + sourceId: c('review.id'), + }, + }, { id: 'leave_calculating', type: 'assignment', label: '留在自动算分,下次重建' }, { id: 'skip_vendor', type: 'assignment', label: '不到期、已生成或没有品类' }, { id: 'next_vendor', type: 'assignment', label: '下一家' }, @@ -282,7 +336,10 @@ const supplierEdges: Automation.FlowEdge[] = [ { id: 'v10', source: 'create_scores', target: 'lines_ok', type: 'default' }, { id: 'v_lines', source: 'lines_ok', target: 'to_scoring', type: 'conditional', condition: c('!lineFailed'), label: '齐全' }, { id: 'v_partial', source: 'lines_ok', target: 'leave_calculating', type: 'conditional', condition: c('lineFailed'), label: '有失败' }, - { id: 'v11', source: 'to_scoring', target: 'next_vendor', type: 'default' }, + { id: 'v11', source: 'to_scoring', target: 'has_scorers', type: 'default' }, + { id: 'v_notify', source: 'has_scorers', target: 'notify_scorers', type: 'conditional', condition: c('scorers.size() > 0'), label: '有' }, + { id: 'v_no_scorers', source: 'has_scorers', target: 'next_vendor', type: 'conditional', condition: c('scorers.size() == 0'), label: '没有' }, + { id: 'v_notified', source: 'notify_scorers', target: 'next_vendor', type: 'default' }, { id: 'v12', source: 'skip_vendor', target: 'next_vendor', type: 'default' }, { id: 'v13', source: 'leave_calculating', target: 'next_vendor', type: 'default' }, ]; diff --git a/src/core/flows/performance/params.ts b/src/core/flows/performance/params.ts new file mode 100644 index 0000000..a5b353f --- /dev/null +++ b/src/core/flows/performance/params.ts @@ -0,0 +1,17 @@ +// 绩效评分、分级与申诉用到的参数(params/core.yaml,示例值)。flow 里不能读参数文件,客户改参数时同步这里; +// 测试逐项核对与 params/core.yaml 一致(同 generate.flow.ts 的 CYCLE)。 + +// perf_grade_min:等级下限分,A ≥ 90、B ≥ 80、C ≥ 70,其余 D(03 §10 第 4 步) +export const GRADE_MIN = { A: 90, B: 80, C: 70 } as const; +// perf_score_days:转人工评分起的评分期限(日历日) +export const SCORE_DAYS = 10; +// perf_missing_score:截止后未评项的默认分(FR-703) +export const MISSING_SCORE = 60; +// perf_appeal_days:发布后可以申诉的天数(FR-706) +export const APPEAL_DAYS = 7; +// 截止前几天提醒还有未评项的评分人 +export const SCORE_REMINDER_DAYS = 3; + +// 按总分定等级的 CEL(总分已保留两位小数) +export const gradeOf = (total: string) => + `(${total} >= ${GRADE_MIN.A}.0 ? 'grade_a' : ${total} >= ${GRADE_MIN.B}.0 ? 'grade_b' : ${total} >= ${GRADE_MIN.C}.0 ? 'grade_c' : 'grade_d')`; diff --git a/src/core/flows/performance/publish.flow.ts b/src/core/flows/performance/publish.flow.ts new file mode 100644 index 0000000..dc8e336 --- /dev/null +++ b/src/core/flows/performance/publish.flow.ts @@ -0,0 +1,278 @@ +import { P, cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; +import { SCORE_DAYS, gradeOf } from './params.js'; + +// P9 步骤 4–5(03 §10,FR-704):考评单转审定中(全部评分完成或评分截止)时汇总分级,交采购总监审定发布。 +// +// 汇总:total_score = Σ(score × weight)(启用指标权重合计 100%);各维度得分为维度内按权重的加权平均(0–100,没有该维度 +// 的指标时为空);都保留两位小数,等级按两位小数的总分取参数 perf_grade_min(params.ts 的 GRADE_MIN)。 +// 审定:position = srm_procurement_director(03 §12)。通过 → 已发布,写审定人、意见、发布日期,回写供应商 grade、 +// latest_score(同一供应商已有更晚期间的已发布考评单时不回写,补跑旧期间不会覆盖最新结果),通知评分人。 +// 驳回即「退回重评」:回到人工评分,审定意见写入 review_comment,截止日重新起算,通知评分人改分。只有一级审批, +// 所以不用平台的「退回修改」(考评单没有发起人可以重新提交)。 +// +// 职责分离(同 BR-11):审定人不能是本考评单的评分人。平台还没有「提交人不得审批」开关(objectstack#25446), +// 所以通过后由 flow 读审批记录比对,撞人时这次通过不算数,通知后重新开一轮审定(同 srm_disposition_approval)。 +// +// 临时:CEL 没有对列表求和,加权合计只能逐行累加(07 §6.3 PF15,数据点记在 objectstack#14500)。 +const c = (source: string) => ({ dialect: 'cel' as const, source }); +const val = (k: string) => `(has(${k}) && ${k} != null)`; +const round2 = (x: string) => `(double(round((${x}) * 100.0)) / 100.0)`; +const DIMENSIONS = ['quality', 'cost', 'delivery', 'service', 'hse'] as const; + +// 维度内加权平均的累加器:W 为权重和,S 为得分 × 权重之和 +const accumulators = Object.fromEntries(DIMENSIONS.flatMap((d) => [[`${d}W`, c('0.0')], [`${d}S`, c('0.0')]])); +const addLine = Object.fromEntries( + DIMENSIONS.flatMap((d) => [ + [`${d}W`, c(`dim == '${d}' ? ${d}W + w : ${d}W`)], + [`${d}S`, c(`dim == '${d}' ? ${d}S + sc * w : ${d}S`)], + ]), +); +const dimensionScores = Object.fromEntries(DIMENSIONS.map((d) => [`${d}_score`, c(`${d}W > 0.0 ? ${round2(`${d}S / ${d}W`)} : null`)])); + +export const PerformancePublishFlow: Automation.Flow = { + name: 'srm_performance_publish', + label: '绩效汇总分级与审定发布', + type: 'record_change', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: '考评单转审定中', + config: { + objectName: 'srm_performance_review', + triggerType: 'record-after-write', + condition: P`record.status == 'reviewing' && previous != null && has(previous.status) && previous.status != 'reviewing'`, + }, + }, + { + id: 'load_lines', + type: 'get_record', + label: '读取评分明细', + config: { + objectName: 'srm_performance_score', + filter: { review: cel`record.id` }, + fields: ['kpi', 'weight', 'score', 'scorer'], + limit: 500, + outputVariable: 'lines', + }, + }, + { + id: 'load_kpis', + type: 'get_record', + label: '读取指标维度', + config: { + objectName: 'srm_kpi', + filter: { id: { $in: cel`lines.map(l, l.kpi)` } }, + fields: ['id', 'dimension'], + limit: 500, + outputVariable: 'kpis', + }, + }, + { id: 'reset', type: 'assignment', label: '累计清零', config: { assignments: { total: c('0.0'), scorers: c('[]'), ...accumulators } } }, + { + id: 'sum_lines', + type: 'loop', + label: '累加加权得分', + config: { + collection: cel`lines`, + iteratorVariable: 'line', + body: { + nodes: [ + { + id: 'add_line', + type: 'assignment', + label: '累加', + config: { + assignments: { + dim: c(`kpis.exists(k, k.id == line.kpi) ? kpis.filter(k, k.id == line.kpi)[0].dimension : ''`), + sc: c(`${val('line.score')} ? double(line.score) : 0.0`), + w: c(`${val('line.weight')} ? double(line.weight) : 0.0`), + total: c('total + sc * w'), + ...addLine, + scorers: c(`${val('line.scorer')} && !(line.scorer in scorers) ? scorers + [line.scorer] : scorers`), + }, + }, + }, + ], + }, + }, + }, + { + id: 'grade', + type: 'assignment', + label: '总分与等级', + config: { assignments: { totalScore: c(round2('total')), gradeValue: c(gradeOf('totalScore')) } }, + }, + { + id: 'write_scores', + type: 'update_record', + label: '写入各维度得分、总分与等级', + config: { + objectName: 'srm_performance_review', + filter: { id: cel`record.id` }, + fields: { ...dimensionScores, total_score: cel`totalScore`, grade: cel`gradeValue` }, + }, + }, + + // 采购总监审定 + { + id: 'director_review', + type: 'approval', + label: '采购总监审定', + config: { + approvers: [{ type: 'position', value: 'srm_procurement_director' }], + // 岗位没人时交给组织管理员,避免考评单卡在锁定状态 + onEmptyApprovers: 'admin_rescue', + behavior: 'first_response', + lockRecord: true, + approvalStatusField: 'approval_status', + maxRevisions: 0, + }, + }, + { + id: 'approve_action', + type: 'get_record', + label: '读取审定记录', + config: { objectName: 'sys_approval_action', filter: { request_id: cel`director_review.requestId`, action: 'approve' }, outputVariable: 'approveAction' }, + }, + { id: 'sod', type: 'decision', label: '审定人是本考评单的评分人?' }, + { + id: 'self', + type: 'notify', + label: '评分人不能审定本考评单', + config: { + recipients: cel`approveAction.actor_id`, + title: '考评单 {{ record.review_no }} 需由另一位采购总监审定', + message: '你是这张考评单的评分人,你的审定不计入,已重新提交给采购总监。', + severity: 'warning', + sourceObject: 'srm_performance_review', + sourceId: cel`record.id`, + }, + }, + { + id: 'publish', + type: 'update_record', + label: '发布', + config: { + objectName: 'srm_performance_review', + filter: { id: cel`record.id` }, + fields: { + status: 'published', + reviewer: cel`approveAction.actor_id`, + review_comment: c(`${val('approveAction.comment')} && approveAction.comment != '' ? approveAction.comment : null`), + published_date: cel`isoDate(today())`, + }, + }, + }, + { + id: 'load_later', + type: 'get_record', + label: '有更晚期间的已发布考评单?', + config: { + objectName: 'srm_performance_review', + filter: { supplier: cel`record.supplier`, status: { $in: ['published', 'appealed'] }, period_end: { $gt: cel`record.period_end` } }, + fields: ['id'], + outputVariable: 'later', + }, + }, + { id: 'is_latest', type: 'decision', label: '是最近一期?' }, + { + id: 'write_supplier', + type: 'update_record', + label: '回写供应商等级与总分', + config: { objectName: 'srm_supplier', filter: { id: cel`record.supplier` }, fields: { grade: cel`gradeValue`, latest_score: cel`totalScore` } }, + }, + { id: 'has_scorers', type: 'decision', label: '有评分人?' }, + { + id: 'notify_published', + type: 'notify', + label: '通知评分人:已发布', + config: { + recipients: cel`scorers`, + title: '考评单 {{ record.review_no }} 已发布', + message: '期间 {{ record.period }} 的考评结果已由采购总监审定发布:总分 {{ totalScore }}。', + sourceObject: 'srm_performance_review', + sourceId: cel`record.id`, + }, + }, + + // 驳回:退回重评 + { + id: 'reject_action', + type: 'get_record', + label: '读取退回意见', + config: { objectName: 'sys_approval_action', filter: { request_id: cel`director_review.requestId`, action: 'reject' }, outputVariable: 'rejectAction' }, + }, + { + id: 'back_to_scoring', + type: 'update_record', + label: '退回重评', + config: { + objectName: 'srm_performance_review', + filter: { id: cel`record.id` }, + fields: { + status: 'scoring', + review_comment: c(`rejectAction != null && ${val('rejectAction.comment')} ? rejectAction.comment : null`), + score_due: c(`isoDate(addDays(today(), ${SCORE_DAYS}))`), + }, + }, + }, + { id: 'has_scorers_back', type: 'decision', label: '有评分人?' }, + { + id: 'notify_back', + type: 'notify', + label: '通知评分人:退回重评', + config: { + recipients: cel`scorers`, + title: '考评单 {{ record.review_no }} 退回重评', + message: '采购总监退回了这张考评单,意见见考评单的「审定意见」。请在评分截止日前点「提交评分」修改你的评分项。', + severity: 'warning', + sourceObject: 'srm_performance_review', + sourceId: cel`record.id`, + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'load_lines', type: 'default' }, + { id: 'e2', source: 'load_lines', target: 'load_kpis', type: 'default' }, + { id: 'e3', source: 'load_kpis', target: 'reset', type: 'default' }, + { id: 'e4', source: 'reset', target: 'sum_lines', type: 'default' }, + { id: 'e5', source: 'sum_lines', target: 'grade', type: 'default' }, + { id: 'e6', source: 'grade', target: 'write_scores', type: 'default' }, + { id: 'e7', source: 'write_scores', target: 'director_review', type: 'default' }, + + { id: 'e_approve', source: 'director_review', target: 'approve_action', type: 'default', label: 'approve' }, + { id: 'e_reject', source: 'director_review', target: 'reject_action', type: 'default', label: 'reject' }, + { id: 'e8', source: 'approve_action', target: 'sod', type: 'default' }, + { + id: 'e_self', + source: 'sod', + target: 'self', + type: 'conditional', + condition: c('approveAction.actor_id in scorers'), + label: '审定人是评分人', + }, + { id: 'e_ok', source: 'sod', target: 'publish', type: 'conditional', condition: c('!(approveAction.actor_id in scorers)'), label: '不是' }, + { id: 'e_again', source: 'self', target: 'director_review', type: 'back', label: '重新审定' }, + { id: 'e9', source: 'publish', target: 'load_later', type: 'default' }, + { id: 'e10', source: 'load_later', target: 'is_latest', type: 'default' }, + { id: 'e_latest', source: 'is_latest', target: 'write_supplier', type: 'conditional', condition: c('later == null'), label: '是' }, + { id: 'e_older', source: 'is_latest', target: 'has_scorers', type: 'conditional', condition: c('later != null'), label: '已有更晚的' }, + { id: 'e11', source: 'write_supplier', target: 'has_scorers', type: 'default' }, + { id: 'e_notify', source: 'has_scorers', target: 'notify_published', type: 'conditional', condition: c('scorers.size() > 0'), label: '有' }, + { id: 'e_quiet', source: 'has_scorers', target: 'end', type: 'conditional', condition: c('scorers.size() == 0'), label: '没有' }, + { id: 'e12', source: 'notify_published', target: 'end', type: 'default' }, + + { id: 'e13', source: 'reject_action', target: 'back_to_scoring', type: 'default' }, + { id: 'e14', source: 'back_to_scoring', target: 'has_scorers_back', type: 'default' }, + { id: 'e_notify_back', source: 'has_scorers_back', target: 'notify_back', type: 'conditional', condition: c('scorers.size() > 0'), label: '有' }, + { id: 'e_quiet_back', source: 'has_scorers_back', target: 'end', type: 'conditional', condition: c('scorers.size() == 0'), label: '没有' }, + { id: 'e15', source: 'notify_back', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/performance/score-due-notice.flow.ts b/src/core/flows/performance/score-due-notice.flow.ts new file mode 100644 index 0000000..683a8c3 --- /dev/null +++ b/src/core/flows/performance/score-due-notice.flow.ts @@ -0,0 +1,85 @@ +import { cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; +import { SCORE_REMINDER_DAYS } from './params.js'; + +// 评分截止提醒(03 §10 第 3 步):人工评分中的考评单在评分截止日前 3 天(params.ts 的 SCORE_REMINDER_DAYS), +// 提醒还有未评项的评分人。平台按天扫描、每张考评单只发一次(time_relative 触发);截止后的缺评由 +// srm_score_overdue_sweep 处理。 +const c = (source: string) => ({ dialect: 'cel' as const, source }); +const val = (k: string) => `(has(${k}) && ${k} != null)`; + +export const ScoreDueNoticeFlow: Automation.Flow = { + name: 'srm_score_due_notice', + label: '绩效评分截止提醒', + type: 'schedule', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: `截止前 ${SCORE_REMINDER_DAYS} 天`, + config: { + timeRelative: { object: 'srm_performance_review', dateField: 'score_due', offsetDays: [SCORE_REMINDER_DAYS], filter: { status: 'scoring' } }, + }, + }, + { + id: 'load_pending', + type: 'get_record', + label: '读取未评项', + config: { + objectName: 'srm_performance_score', + filter: { review: cel`record.id`, scored: false }, + fields: ['scorer'], + limit: 500, + outputVariable: 'pending', + }, + }, + { id: 'reset', type: 'assignment', label: '评分人清单', config: { assignments: { scorers: c('[]') } } }, + { + id: 'collect', + type: 'loop', + label: '去重评分人', + config: { + collection: cel`pending`, + iteratorVariable: 'line', + body: { + nodes: [ + { + id: 'add_scorer', + type: 'assignment', + label: '记下评分人', + config: { assignments: { scorers: c(`${val('line.scorer')} && !(line.scorer in scorers) ? scorers + [line.scorer] : scorers`) } }, + }, + ], + }, + }, + }, + { id: 'has_scorers', type: 'decision', label: '有未评的评分人?' }, + { + id: 'notify', + type: 'notify', + label: '提醒评分人', + config: { + recipients: cel`scorers`, + title: '考评单 {{ record.review_no }} 将于 {{ record.score_due }} 截止评分', + message: '你还有未评的评分项。截止后未评的项按默认分处理并标记「未评」,请在考评单上点「提交评分」。', + severity: 'warning', + sourceObject: 'srm_performance_review', + sourceId: cel`record.id`, + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'load_pending', type: 'default' }, + { id: 'e2', source: 'load_pending', target: 'reset', type: 'default' }, + { id: 'e3', source: 'reset', target: 'collect', type: 'default' }, + { id: 'e4', source: 'collect', target: 'has_scorers', type: 'default' }, + { id: 'e_notify', source: 'has_scorers', target: 'notify', type: 'conditional', condition: c('scorers.size() > 0'), label: '有' }, + { id: 'e_none', source: 'has_scorers', target: 'end', type: 'conditional', condition: c('scorers.size() == 0'), label: '没有' }, + { id: 'e5', source: 'notify', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/performance/score-overdue-sweep.flow.ts b/src/core/flows/performance/score-overdue-sweep.flow.ts new file mode 100644 index 0000000..0f999e2 --- /dev/null +++ b/src/core/flows/performance/score-overdue-sweep.flow.ts @@ -0,0 +1,83 @@ +import { cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; +import { MISSING_SCORE } from './params.js'; + +// 评分截止(03 §10 第 3 步,FR-703):人工评分中、已过评分截止日的考评单,未评的评分项取参数 perf_missing_score +// (params.ts 的 MISSING_SCORE,示例 60 分)并标记「未评」,考评单转审定中(srm_performance_publish 汇总分级)。 +// 追赶式(03 §2.3 R2):处理所有已过截止日还在人工评分的考评单,处理后离开人工评分,重复运行不重复处理; +// 漏跑一天,下一次补上。单张失败只跳过该张。 +const c = (source: string) => ({ dialect: 'cel' as const, source }); + +export const ScoreOverdueSweepFlow: Automation.Flow = { + name: 'srm_score_overdue_sweep', + label: '绩效评分截止扫描', + type: 'schedule', + status: 'active', + runAs: 'system', + + nodes: [ + { id: 'start', type: 'start', label: '每日 04:00', config: { schedule: '0 4 * * *' } }, + { + id: 'load_due', + type: 'get_record', + label: '读取已过截止日的考评单', + config: { + objectName: 'srm_performance_review', + filter: { status: 'scoring', score_due: { $lt: cel`isoDate(today())` } }, + fields: ['id'], + limit: 20000, + outputVariable: 'due', + }, + }, + { + id: 'each_review', + type: 'loop', + label: '逐张考评单', + config: { + collection: cel`due`, + iteratorVariable: 'review', + body: { + nodes: [ + { + id: 'guard_review', + type: 'try_catch', + label: '单张考评单', + config: { + try: { + nodes: [ + { + id: 'default_missing', + type: 'update_record', + label: '未评项取默认分', + config: { + objectName: 'srm_performance_score', + filter: { review: cel`review.id`, scored: false }, + fields: { score: c(`${MISSING_SCORE}.0`), scored: true, defaulted: true }, + multi: true, + }, + }, + { + id: 'to_reviewing', + type: 'update_record', + label: '转审定中', + config: { objectName: 'srm_performance_review', filter: { id: cel`review.id` }, fields: { status: 'reviewing' } }, + }, + ], + edges: [{ id: 'e_review', source: 'default_missing', target: 'to_reviewing', type: 'default' }], + }, + catch: { nodes: [{ id: 'skip_error', type: 'assignment', label: '跳过该考评单' }] }, + }, + }, + ], + }, + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'load_due', type: 'default' }, + { id: 'e2', source: 'load_due', target: 'each_review', type: 'default' }, + { id: 'e3', source: 'each_review', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/performance/score-submit.flow.ts b/src/core/flows/performance/score-submit.flow.ts new file mode 100644 index 0000000..0a15b6c --- /dev/null +++ b/src/core/flows/performance/score-submit.flow.ts @@ -0,0 +1,173 @@ +import { cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// 提交评分(03 §10 第 3 步,FR-703):评分人在人工评分中的考评单上点「提交评分」,flow 只列出指派给本人的评分项, +// 逐项显示指标、评分规则、原始值和权重,填 0–100 分与说明;已评的项会带出原来的分数,截止前可以改。 +// 填完最后一项后,考评单上全部评分项都已评分时转审定中(srm_performance_publish 汇总分级并交采购总监审定)。 +// 以系统身份运行:考评单与评分明细对所有内部岗位只读,评分只经这里写入,所以绕过界面也改不了别人的评分项。 +// 逐项循环用顶层的回边(screen 不能放在 loop 节点里暂停)。 +const cel$ = (source: string) => ({ dialect: 'cel' as const, source }); +const val = (k: string) => `(has(${k}) && ${k} != null)`; +const open = `current_user != null && review != null && review.status == 'scoring'`; + +export const ScoreSubmitFlow: Automation.Flow = { + name: 'srm_score_submit', + label: '提交评分', + type: 'screen', + status: 'active', + runAs: 'system', + successMessage: '评分已提交', + + variables: [{ name: 'recordId', type: 'text', isInput: true, isOutput: false }], + + nodes: [ + { id: 'start', type: 'start', label: '开始' }, + { + id: 'load', + type: 'get_record', + label: '读取考评单', + config: { objectName: 'srm_performance_review', filter: { id: cel`recordId` }, outputVariable: 'review' }, + }, + { id: 'gate', type: 'decision', label: '人工评分中?' }, + { + id: 'refuse_status', + type: 'end', + label: '不在人工评分', + config: { outcome: 'refused', message: '只有人工评分中的考评单可以提交评分' }, + }, + { + id: 'load_mine', + type: 'get_record', + label: '读取指派给本人的评分项', + config: { + objectName: 'srm_performance_score', + filter: { review: cel`review.id`, scorer: cel`current_user.id` }, + limit: 200, + outputVariable: 'mine', + }, + }, + { id: 'has_mine', type: 'decision', label: '有指派给本人的评分项?' }, + { + id: 'refuse_none', + type: 'end', + label: '没有指派给本人的评分项', + config: { outcome: 'refused', message: '这张考评单没有指派给你的评分项' }, + }, + { + id: 'load_supplier', + type: 'get_record', + label: '读取供应商', + config: { objectName: 'srm_supplier', filter: { id: cel`review.supplier` }, fields: ['name'], outputVariable: 'vendor' }, + }, + { + id: 'load_kpis', + type: 'get_record', + label: '读取指标', + config: { + objectName: 'srm_kpi', + filter: { id: { $in: cel`mine.map(l, l.kpi)` } }, + fields: ['id', 'name', 'scoring_rule'], + limit: 200, + outputVariable: 'kpis', + }, + }, + { id: 'init', type: 'assignment', label: '从第一项开始', config: { assignments: { i: cel`0`, count: cel`mine.size()` } } }, + { + id: 'pick', + type: 'assignment', + label: '取下一项', + config: { + assignments: { + line: cel`mine[i]`, + n: cel`i + 1`, + kpiName: cel$(`kpis.exists(k, k.id == line.kpi) ? kpis.filter(k, k.id == line.kpi)[0].name : ''`), + ruleText: cel$( + `kpis.exists(k, k.id == line.kpi && ${val('k.scoring_rule')}) ? kpis.filter(k, k.id == line.kpi)[0].scoring_rule : '—'`, + ), + rawText: cel$(`${val('line.raw_value')} ? string(line.raw_value) : '—'`), + weightText: cel$(`${val('line.weight')} ? string(double(round(double(line.weight) * 10000.0)) / 100.0) : '—'`), + noteText: cel$(`${val('line.comment')} ? line.comment : ''`), + }, + }, + }, + { + id: 'score_line', + type: 'screen', + label: '评分', + config: { + title: '评分:{{ kpiName }}({{ n }} / {{ count }})', + description: + '考评单:{{ review.review_no }} 供应商:{{ vendor.name }} 期间:{{ review.period }}\n' + + '评分规则:{{ ruleText }}\n原始值:{{ rawText }} 权重:{{ weightText }}%\n{{ noteText }}', + fields: [ + { + name: 'score', + label: '得分(0–100)', + type: 'number', + required: true, + min: 0, + max: 100, + defaultValue: cel$(`${val('line.score')} ? line.score : null`), + }, + { name: 'score_note', label: '评分说明', type: 'textarea', defaultValue: cel$(`${val('line.comment')} ? line.comment : ''`) }, + ], + }, + }, + { + id: 'save', + type: 'update_record', + label: '写入评分', + config: { + objectName: 'srm_performance_score', + filter: { id: cel`line.id` }, + fields: { + score: cel`double(round(double(score) * 100.0)) / 100.0`, + comment: cel$(`${val('vars.score_note')} && vars.score_note != '' ? vars.score_note : null`), + scored: true, + defaulted: false, + }, + }, + }, + { id: 'advance', type: 'assignment', label: '下一项', config: { assignments: { i: cel`i + 1` } } }, + { id: 'more', type: 'decision', label: '还有指派给本人的评分项?' }, + { id: 'next_line', type: 'assignment', label: '回到下一项' }, + { + id: 'load_rest', + type: 'get_record', + label: '还有未评的评分项?', + config: { objectName: 'srm_performance_score', filter: { review: cel`review.id`, scored: false }, fields: ['id'], outputVariable: 'rest' }, + }, + { id: 'all_scored', type: 'decision', label: '全部评分项都已评分?' }, + { + id: 'to_reviewing', + type: 'update_record', + label: '转审定中', + config: { objectName: 'srm_performance_review', filter: { id: cel`review.id` }, fields: { status: 'reviewing' } }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e0', source: 'start', target: 'load', type: 'default' }, + { id: 'e1', source: 'load', target: 'gate', type: 'default' }, + { id: 'e2', source: 'gate', target: 'refuse_status', type: 'conditional', condition: cel$(`!(${open})`), label: '不在人工评分' }, + { id: 'e3', source: 'gate', target: 'load_mine', type: 'conditional', condition: cel$(open), label: '人工评分中' }, + { id: 'e4', source: 'load_mine', target: 'has_mine', type: 'default' }, + { id: 'e5', source: 'has_mine', target: 'refuse_none', type: 'conditional', condition: cel$('mine.size() == 0'), label: '没有' }, + { id: 'e6', source: 'has_mine', target: 'load_supplier', type: 'conditional', condition: cel$('mine.size() > 0'), label: '有' }, + { id: 'e7', source: 'load_supplier', target: 'load_kpis', type: 'default' }, + { id: 'e8', source: 'load_kpis', target: 'init', type: 'default' }, + { id: 'e9', source: 'init', target: 'pick', type: 'default' }, + { id: 'e10', source: 'pick', target: 'score_line', type: 'default' }, + { id: 'e11', source: 'score_line', target: 'save', type: 'default' }, + { id: 'e12', source: 'save', target: 'advance', type: 'default' }, + { id: 'e13', source: 'advance', target: 'more', type: 'default' }, + { id: 'e_next', source: 'more', target: 'next_line', type: 'conditional', condition: cel$('i < count'), label: '还有' }, + { id: 'e_back', source: 'next_line', target: 'pick', type: 'back', label: '下一项' }, + { id: 'e_done', source: 'more', target: 'load_rest', type: 'conditional', condition: cel$('i >= count'), label: '本人的都评完了' }, + { id: 'e14', source: 'load_rest', target: 'all_scored', type: 'default' }, + { id: 'e_all', source: 'all_scored', target: 'to_reviewing', type: 'conditional', condition: cel$('rest == null'), label: '全部已评' }, + { id: 'e_partial', source: 'all_scored', target: 'end', type: 'conditional', condition: cel$('rest != null'), label: '还有别人的未评项' }, + { id: 'e15', source: 'to_reviewing', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/performance/void.flow.ts b/src/core/flows/performance/void.flow.ts new file mode 100644 index 0000000..d38bcd7 --- /dev/null +++ b/src/core/flows/performance/void.flow.ts @@ -0,0 +1,70 @@ +import { cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// 作废考评单(02 §5.7):人工评分中的考评单由采购总监写明理由后作废,例如供应商本期实际没有供货。作废的考评单仍占着 +// supplier + period,同期要重新考评时由系统管理员删除后补跑生成。审定中的考评单先由采购总监退回重评再作废。 +// 以系统身份运行,进门检查在 flow 里做。 +const c = (source: string) => ({ dialect: 'cel' as const, source }); +const director = `current_user != null && 'srm_procurement_director' in current_user.positions`; +const scoring = `review != null && review.status == 'scoring'`; + +export const PerformanceVoidFlow: Automation.Flow = { + name: 'srm_performance_void', + label: '作废考评单', + type: 'screen', + status: 'active', + runAs: 'system', + successMessage: '考评单已作废', + + variables: [{ name: 'recordId', type: 'text', isInput: true, isOutput: false }], + + nodes: [ + { id: 'start', type: 'start', label: '开始' }, + { + id: 'load', + type: 'get_record', + label: '读取考评单', + config: { objectName: 'srm_performance_review', filter: { id: cel`recordId` }, outputVariable: 'review' }, + }, + { id: 'gate', type: 'decision', label: '可以作废?' }, + { + id: 'refuse_position', + type: 'end', + label: '不是采购总监', + config: { outcome: 'refused', message: '考评单由采购总监作废' }, + }, + { + id: 'refuse_status', + type: 'end', + label: '不在人工评分', + config: { outcome: 'refused', message: '只有人工评分中的考评单可以作废;审定中的先退回重评' }, + }, + { + id: 'confirm', + type: 'screen', + label: '确认作废', + config: { + title: '作废考评单:{{ review.review_no }}', + description: '作废后这张考评单不再评分和发布;同期要重新考评时,请系统管理员删除后补跑生成。', + fields: [{ name: 'reason_text', label: '作废理由', type: 'textarea', required: true }], + }, + }, + { + id: 'void', + type: 'update_record', + label: '置为已作废', + config: { objectName: 'srm_performance_review', filter: { id: cel`review.id` }, fields: { status: 'void', void_reason: cel`reason_text` } }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e0', source: 'start', target: 'load', type: 'default' }, + { id: 'e1', source: 'load', target: 'gate', type: 'default' }, + { id: 'e2', source: 'gate', target: 'refuse_position', type: 'conditional', condition: c(`!(${director})`), label: '不是采购总监' }, + { id: 'e3', source: 'gate', target: 'refuse_status', type: 'conditional', condition: c(`${director} && !(${scoring})`), label: '不在人工评分' }, + { id: 'e4', source: 'gate', target: 'confirm', type: 'conditional', condition: c(`${director} && ${scoring}`), label: '可以' }, + { id: 'e5', source: 'confirm', target: 'void', type: 'default' }, + { id: 'e6', source: 'void', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/objects/category.object.ts b/src/core/objects/category.object.ts index 7fcbf94..a1fa5a7 100644 --- a/src/core/objects/category.object.ts +++ b/src/core/objects/category.object.ts @@ -19,6 +19,9 @@ export const Category = ObjectSchema.create({ parent: { type: 'tree', label: '上级品类', group: 'basic', reference: 'srm_category' }, category_manager: Field.user({ label: '品类经理', group: 'basic' }), sqe: Field.user({ label: 'SQE', group: 'basic' }), + // 绩效评分人(v0.5,02 §4.7 scorer_type):考评生成时按指标的评分人类型从供应商的品类上取 + buyer: Field.user({ label: '采购员', group: 'basic' }), + evaluator: Field.user({ label: '需求部门评分人', group: 'basic' }), is_direct: Field.boolean({ label: '直接物料', group: 'basic', defaultValue: true }), // 物资 A / B / C 分类(P-D2):A 类准入审批加质量经理会签(03 §12) material_class: Field.select({ label: '物资类别', group: 'admission', required: true, defaultValue: 'class_c', picklist: 'srm_material_class' }), diff --git a/src/core/objects/kpi.object.ts b/src/core/objects/kpi.object.ts index ec6a387..efe2a1b 100644 --- a/src/core/objects/kpi.object.ts +++ b/src/core/objects/kpi.object.ts @@ -59,6 +59,18 @@ export const Kpi = ObjectSchema.create({ ], }), weight: Field.percent({ label: '权重', group: 'basic', required: true, min: 0, max: 1 }), + // 人工指标和没有数据的自动指标由谁评分(03 §10 第 3 步):考评生成时按它从供应商的品类上取评分人 + scorer_type: Field.select({ + label: '评分人', + group: 'basic', + defaultValue: 'category_manager', + options: [ + { label: '品类经理', value: 'category_manager' }, + { label: '采购员', value: 'buyer' }, + { label: 'SQE', value: 'sqe' }, + { label: '需求部门评分人', value: 'evaluator' }, + ], + }), active: Field.boolean({ label: '启用', group: 'basic', defaultValue: true }), scoring_rule: Field.textarea({ label: '评分规则说明', group: 'scoring', maxLength: 2000 }), // 自动指标线性算分:原始值达到满分值得 100 分,达到零分值得 0 分,之间按比例,越界取 0 或 100。 diff --git a/src/core/objects/performance-review.object.ts b/src/core/objects/performance-review.object.ts index 2322992..ca2bf32 100644 --- a/src/core/objects/performance-review.object.ts +++ b/src/core/objects/performance-review.object.ts @@ -2,8 +2,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; // 绩效考评单(02 §4.7、§5.7,03 §10,FR-702):srm_performance_generate 每期为活跃供应商生成(calculating),按启用 // 指标建评分明细并快照权重,从交付记录算出 OTD、批次合格率、PPM 与对应得分后转人工评分(scoring)。本期没有交付记录、 -// 某项自动指标没有数据时标记「数据不全」,该项不给分(07 R7)。人工评分、汇总分级、审定发布、申诉随 v0.5 P3 加入, -// 这些步骤要写的字段(各维度得分、总分、等级、审定人、申诉)在这里先建好。 +// 某项自动指标没有数据时标记「数据不全」,该项不给分(07 R7)。v0.5 P3:评分人经「提交评分」打分,截止后缺评取默认分 +// (srm_score_overdue_sweep)→ 审定中:srm_performance_publish 汇总分级并交采购总监审定,通过即发布并回写供应商等级, +// 驳回即退回重评 → 发布后可代录申诉一次(srm_appeal_submit),采购总监处理(srm_appeal_resolve)后回到已发布。 +// 全部字段由 flow 写入,对用户只读。 // 同一供应商同一期间只有一张考评单(唯一索引兜底,flow 先查后建,重复运行不重复建)。 // 门户上线(v0.6)时加 portal_position,供应商只看本公司已发布的考评单(04 §3)。 export const PerformanceReview = ObjectSchema.create({ @@ -11,7 +13,7 @@ export const PerformanceReview = ObjectSchema.create({ label: '绩效考评单', pluralLabel: '绩效考评单', icon: 'award', - // 内部只读(04 §2.3):考评单只由 flow 生成和推进,评分只经「提交评分」screen flow(v0.5 P3) + // 内部只读(04 §2.3):考评单只由 flow 生成和推进,评分只经「提交评分」screen flow sharingModel: 'public_read', enable: { trackHistory: true }, stageField: 'status', @@ -65,7 +67,9 @@ export const PerformanceReview = ObjectSchema.create({ }), // 本期没有交付记录或某项自动指标没有数据(07 R7):该项不给零分,留给人工评分环节处理 data_incomplete: Field.boolean({ label: '数据不全', group: 'basic', defaultValue: false, readonly: true }), - reviewer: Field.user({ label: '审定人', group: 'basic' }), + // 评分截止日:转人工评分时为当天 + perf_score_days;退回重评时重新起算(03 §10 第 3、5 步) + score_due: Field.date({ label: '评分截止日', group: 'basic', readonly: true }), + void_reason: Field.textarea({ label: '作废理由', group: 'basic', readonly: true, maxLength: 2000 }), // 交付记录按期汇总(05 §4.1,口径同数据集 srm_delivery_metrics);没有数据的项为空 otd_rate: Field.percent({ label: '交付准时率', group: 'metrics', readonly: true, scale: 2 }), @@ -95,8 +99,38 @@ export const PerformanceReview = ObjectSchema.create({ ], }), - appeal_reason: Field.textarea({ label: '申诉理由', group: 'appeal', maxLength: 2000 }), - appeal_result: Field.textarea({ label: '申诉处理结果', group: 'appeal', maxLength: 2000 }), + // 审定发布(03 §10 第 5 步):审批节点写审批结论,flow 写审定人、意见和发布日期 + approval_status: Field.select({ + label: '审批结论', + group: 'scores', + readonly: true, + options: [ + { label: '审批中', value: 'pending' }, + { label: '已批准', value: 'approved' }, + { label: '已驳回', value: 'rejected' }, + { label: '退回修改', value: 'returned' }, + { label: '已撤回', value: 'recalled' }, + ], + }), + reviewer: Field.user({ label: '审定人', group: 'scores', readonly: true }), + review_comment: Field.textarea({ label: '审定意见', group: 'scores', readonly: true, maxLength: 2000 }), + published_date: Field.date({ label: '发布日期', group: 'scores', readonly: true }), + + // 申诉(03 §10 第 7 步,FR-706):发布后 perf_appeal_days 内一次;v0.6 前由采购代录 + appeal_reason: Field.textarea({ label: '申诉理由', group: 'appeal', readonly: true, maxLength: 2000 }), + appeal_date: Field.date({ label: '申诉日期', group: 'appeal', readonly: true }), + appeal_recorded_by: Field.user({ label: '申诉代录人', group: 'appeal', readonly: true }), + appeal_result: Field.textarea({ label: '申诉处理结果', group: 'appeal', readonly: true, maxLength: 2000 }), + appeal_outcome: Field.select({ + label: '申诉结论', + group: 'appeal', + readonly: true, + options: [ + { label: '维持原结果', value: 'maintained' }, + { label: '调整总分', value: 'adjusted' }, + ], + }), + appeal_handled_by: Field.user({ label: '申诉处理人', group: 'appeal', readonly: true }), }, indexes: [{ name: 'srm_performance_review_period', fields: ['supplier', 'period'], unique: 'organization' }], @@ -111,7 +145,8 @@ export const PerformanceReview = ObjectSchema.create({ transitions: { calculating: ['scoring', 'void'], scoring: ['reviewing', 'void'], - reviewing: ['published', 'void'], + // 采购总监驳回即退回重评(03 §10 第 5 步) + reviewing: ['published', 'scoring', 'void'], published: ['appealed'], appealed: ['published'], void: [], diff --git a/src/core/objects/performance-score.object.ts b/src/core/objects/performance-score.object.ts index 6ceca1e..16bdc97 100644 --- a/src/core/objects/performance-score.object.ts +++ b/src/core/objects/performance-score.object.ts @@ -1,7 +1,8 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; // 评分明细(02 §4.7,FR-702):考评生成时按指标库中启用的指标各建一行,权重从指标快照过来,之后改指标库不影响 -// 已生成的考评单。自动指标由生成 flow 写入原始值与得分;人工指标由评分人经「提交评分」screen flow 打分(v0.5 P3)。 +// 已生成的考评单。自动指标由生成 flow 写入原始值与得分;人工指标和没有数据的自动指标由生成时指派的评分人经「提交评分」 +// screen flow 打分(v0.5 P3),截止后未评的取默认分并标记「未评」。 // 原始值与指标的满分值、零分值同一单位:比率按百分点(98 即 98%),PPM 按 PPM。 export const PerformanceScore = ObjectSchema.create({ name: 'srm_performance_score', @@ -15,7 +16,7 @@ export const PerformanceScore = ObjectSchema.create({ label: '考评单', required: true, deleteBehavior: 'cascade', - relatedListColumns: ['kpi', 'weight', 'raw_value', 'score', 'scored', 'scorer', 'comment'], + relatedListColumns: ['kpi', 'weight', 'raw_value', 'score', 'scored', 'defaulted', 'scorer', 'comment'], }), kpi: Field.lookup('srm_kpi', { label: '指标', required: true, readonly: true }), weight: Field.percent({ label: '权重', readonly: true, min: 0, max: 1 }), @@ -24,5 +25,7 @@ export const PerformanceScore = ObjectSchema.create({ scorer: Field.user({ label: '评分人' }), comment: Field.textarea({ label: '评分说明', maxLength: 2000 }), scored: Field.boolean({ label: '已评分', defaultValue: false }), + // 「未评」:评分截止时还没有评分,由 srm_score_overdue_sweep 取参数 perf_missing_score 的默认分(FR-703) + defaulted: Field.boolean({ label: '未评(默认分)', defaultValue: false }), }, }); diff --git a/src/core/objects/supplier.object.ts b/src/core/objects/supplier.object.ts index 467281c..fd76c2f 100644 --- a/src/core/objects/supplier.object.ts +++ b/src/core/objects/supplier.object.ts @@ -82,6 +82,19 @@ export const Supplier = ObjectSchema.create({ { label: '高', value: 'high' }, ], }), + // 最近一期已发布的绩效结果(03 §10 第 5 步),由 srm_performance_publish / srm_appeal_resolve 写入;选项同考评单 + grade: Field.select({ + label: '绩效等级', + group: 'qualification', + readonly: true, + options: [ + { label: 'A', value: 'grade_a' }, + { label: 'B', value: 'grade_b' }, + { label: 'C', value: 'grade_c' }, + { label: 'D', value: 'grade_d' }, + ], + }), + latest_score: Field.number({ label: '最近绩效总分', group: 'qualification', readonly: true, scale: 2 }), registered_capital: Field.currency({ label: '注册资本', group: 'registration' }), established_date: Field.date({ label: '成立日期', group: 'registration' }), legal_representative: Field.text({ label: '法定代表人', group: 'registration', maxLength: 50 }), diff --git a/src/core/security/positions.ts b/src/core/security/positions.ts index 9e32548..6e72247 100644 --- a/src/core/security/positions.ts +++ b/src/core/security/positions.ts @@ -13,6 +13,8 @@ export const SrmPositions = [ { name: 'srm_iqc_inspector', label: 'IQC 检验员', permissionSets: ['srm_internal_base', 'srm_iqc'] }, { name: 'srm_ap_accountant', label: '应付会计', permissionSets: ['srm_internal_base', 'srm_finance'] }, { name: 'srm_legal_counsel', label: '法务', permissionSets: ['srm_internal_base', 'srm_legal'] }, + // 需求部门评分人(v0.5):只读,绩效评分只经「提交评分」screen flow;指派到品类的 evaluator 字段上(03 §10 第 3 步) + { name: 'srm_evaluator', label: '评分人', permissionSets: ['srm_internal_base'] }, { name: 'srm_general_manager', label: '总经理', permissionSets: ['srm_internal_base', 'srm_executive'] }, // 内控 / 监标:只挂 srm_auditor(已含全部 SRM 对象只读);淘汰、黑名单的会签审批由 flow 授予 { name: 'srm_procurement_compliance', label: '内控 / 监标', permissionSets: ['srm_auditor'] }, diff --git a/src/core/translations/en.ts b/src/core/translations/en.ts index 57c91e4..aee4a1e 100644 --- a/src/core/translations/en.ts +++ b/src/core/translations/en.ts @@ -1,4 +1,5 @@ import type { TranslationData } from '@objectstack/spec/system'; +import { APPEAL_DAYS } from '../flows/performance/params.js'; // 源码里的标签是中文;英文词条覆盖全部对象、字段、选项、视图与导航(ADR-0007)。 export const en: TranslationData = { @@ -13,6 +14,8 @@ export const en: TranslationData = { parent: { label: 'Parent Category' }, category_manager: { label: 'Category Manager' }, sqe: { label: 'SQE' }, + buyer: { label: 'Buyer' }, + evaluator: { label: 'Requesting-department Scorer' }, is_direct: { label: 'Direct Material' }, material_class: { label: 'Material Class' }, risk_class: { @@ -97,6 +100,8 @@ export const en: TranslationData = { }, qualification_alert: { label: 'Qualification Alert' }, risk_level: { label: 'Risk Level', options: { low: 'Low', medium: 'Medium', high: 'High' } }, + grade: { label: 'Performance Grade', options: { grade_a: 'A', grade_b: 'B', grade_c: 'C', grade_d: 'D' } }, + latest_score: { label: 'Latest Performance Score' }, registered_capital: { label: 'Registered Capital' }, established_date: { label: 'Established Date' }, legal_representative: { label: 'Legal Representative' }, @@ -568,6 +573,10 @@ export const en: TranslationData = { options: { otd_rate: 'On-time Delivery (OTD)', lot_pass_rate: 'Lot Acceptance Rate', ppm: 'PPM', ack_on_time_rate: 'Order Acknowledgement On-time Rate' }, }, weight: { label: 'Weight' }, + scorer_type: { + label: 'Scorer', + options: { category_manager: 'Category Manager', buyer: 'Buyer', sqe: 'SQE', evaluator: 'Requesting-department Scorer' }, + }, active: { label: 'Active' }, scoring_rule: { label: 'Scoring Rule Description' }, full_score_value: { @@ -761,7 +770,8 @@ export const en: TranslationData = { }, }, data_incomplete: { label: 'Data Incomplete' }, - reviewer: { label: 'Reviewed By' }, + score_due: { label: 'Scoring Due Date' }, + void_reason: { label: 'Void Reason' }, otd_rate: { label: 'On-time Delivery Rate' }, lot_pass_rate: { label: 'Lot Acceptance Rate' }, ppm: { label: 'PPM' }, @@ -774,12 +784,32 @@ export const en: TranslationData = { hse_score: { label: 'HSE Score' }, total_score: { label: 'Total Score' }, grade: { label: 'Grade', options: { grade_a: 'A', grade_b: 'B', grade_c: 'C', grade_d: 'D' } }, + approval_status: { + label: 'Approval Result', + options: { pending: 'Pending', approved: 'Approved', rejected: 'Rejected', returned: 'Returned for Revision', recalled: 'Recalled' }, + }, + reviewer: { label: 'Reviewed By' }, + review_comment: { label: 'Review Comment' }, + published_date: { label: 'Published On' }, appeal_reason: { label: 'Appeal Reason' }, + appeal_date: { label: 'Appeal Date' }, + appeal_recorded_by: { label: 'Appeal Recorded By' }, appeal_result: { label: 'Appeal Outcome' }, + appeal_outcome: { label: 'Appeal Decision', options: { maintained: 'Result Upheld', adjusted: 'Total Score Adjusted' } }, + appeal_handled_by: { label: 'Appeal Handled By' }, + }, + _actions: { + srm_score_submit: { label: 'Submit Scores' }, + srm_performance_void: { label: 'Void' }, + srm_appeal_submit: { label: 'Appeal' }, + srm_appeal_resolve: { label: 'Resolve Appeal' }, }, _views: { default: { label: 'All Reviews' }, scoring: { label: 'Awaiting Scores' }, + reviewing: { label: 'Under Review' }, + appealed: { label: 'Under Appeal' }, + published: { label: 'Published' }, incomplete: { label: 'Data Incomplete' }, }, }, @@ -795,6 +825,11 @@ export const en: TranslationData = { scorer: { label: 'Scorer' }, comment: { label: 'Scoring Note' }, scored: { label: 'Scored' }, + defaulted: { label: 'Not Scored (Default Score)' }, + }, + _views: { + default: { label: 'All Score Lines' }, + mine: { label: 'Awaiting My Scores' }, }, }, }, @@ -960,6 +995,75 @@ export const en: TranslationData = { refuse_status: { message: 'Only drafts can be cancelled; a disposition in review is rejected by its approvers' }, }, }, + srm_score_submit: { + label: 'Submit Scores', + successMessage: 'Scores submitted', + screens: { + score_line: { + title: 'Score: {{ kpiName }} ({{ n }} / {{ count }})', + description: + 'Review: {{ review.review_no }} Supplier: {{ vendor.name }} Period: {{ review.period }}\n' + + 'Scoring rule: {{ ruleText }}\nRaw value: {{ rawText }} Weight: {{ weightText }}%\n{{ noteText }}', + fields: { score: { label: 'Score (0–100)' }, score_note: { label: 'Scoring Note' } }, + }, + }, + refusals: { + refuse_status: { message: 'Scores can be submitted only while the review is in manual scoring' }, + refuse_none: { message: 'No score lines of this review are assigned to you' }, + }, + }, + srm_performance_void: { + label: 'Void Performance Review', + successMessage: 'Performance review voided', + screens: { + confirm: { + title: 'Void performance review: {{ review.review_no }}', + description: 'A voided review is no longer scored or published. To review the same period again, ask the SRM administrator to delete it and rerun generation.', + fields: { reason_text: { label: 'Void Reason' } }, + }, + }, + refusals: { + refuse_position: { message: 'Only the procurement director can void performance reviews' }, + refuse_status: { message: 'Only reviews in manual scoring can be voided; send a review under review back for rescoring first' }, + }, + }, + srm_appeal_submit: { + label: 'Performance Appeal', + successMessage: 'Appeal recorded; the procurement director will handle it', + screens: { + confirm: { + title: 'Performance appeal: {{ review.review_no }}', + description: 'Period {{ review.period }}, total score {{ review.total_score }}. A review can be appealed only once; the procurement director handles the appeal.', + fields: { appeal_text: { label: 'Appeal Reason' } }, + }, + }, + refusals: { + refuse_position: { message: 'Supplier appeals are recorded by buyers, buyer leads or category managers' }, + refuse_status: { message: 'Only published reviews can be appealed' }, + refuse_once: { message: 'A review can be appealed only once, and this one has been appealed' }, + refuse_window: { message: `The appeal period has passed: a result can be appealed within ${APPEAL_DAYS} days of publication` }, + }, + }, + srm_appeal_resolve: { + label: 'Resolve Performance Appeal', + successMessage: 'Appeal resolved; the review is published again', + screens: { + resolve: { + title: 'Resolve appeal: {{ review.review_no }}', + description: 'Period {{ review.period }}, original total score {{ review.total_score }}.\nAppeal reason: {{ review.appeal_reason }}', + fields: { + outcome: { label: 'Decision', options: { maintained: 'Uphold the Result', adjusted: 'Adjust the Total Score' } }, + adjusted_score: { label: 'Adjusted Total Score (0–100)' }, + result_text: { label: 'Outcome' }, + }, + }, + }, + refusals: { + refuse_position: { message: 'Performance appeals are handled by the procurement director' }, + refuse_status: { message: 'Only reviews under appeal need resolving' }, + refuse_scorer: { message: 'You scored this review; ask another procurement director to handle the appeal' }, + }, + }, }, apps: { srm_workbench: { @@ -981,6 +1085,7 @@ export const en: TranslationData = { group_performance: { label: 'Performance and Risk' }, nav_kpi: { label: 'Performance KPIs' }, nav_performance_review: { label: 'Performance Reviews' }, + nav_my_scores: { label: 'Awaiting My Scores' }, nav_risk_event: { label: 'Risk Events' }, group_analytics: { label: 'Analytics' }, nav_delivery_dashboard: { label: 'Delivery Dashboard' }, diff --git a/src/core/views/index.ts b/src/core/views/index.ts index 9e7b25c..dbc7daf 100644 --- a/src/core/views/index.ts +++ b/src/core/views/index.ts @@ -14,3 +14,4 @@ export { SupplierAuditViews } from './supplier-audit.view.js'; export { RiskEventViews } from './risk-event.view.js'; export { KpiViews } from './kpi.view.js'; export { PerformanceReviewViews } from './performance-review.view.js'; +export { PerformanceScoreViews } from './performance-score.view.js'; diff --git a/src/core/views/performance-review.view.ts b/src/core/views/performance-review.view.ts index 36db57e..fdf878e 100644 --- a/src/core/views/performance-review.view.ts +++ b/src/core/views/performance-review.view.ts @@ -17,7 +17,8 @@ const columns = [ ]; const sort = [{ field: 'period', order: 'desc' as const }, { field: 'review_no', order: 'desc' as const }]; -// 05 §2:考评单按期间倒序;「待评分」给采购总监跟进人工评分,「数据不全」提示本期交付数据没导入或不全(07 R7) +// 05 §2:考评单按期间倒序;「待评分」给采购总监跟进人工评分,「数据不全」提示本期交付数据没导入或不全(07 R7); +// 「审定中」「申诉中」是采购总监的待办;「已发布」按等级分组(列表视图不能按当前期间动态过滤,用期间倒序代替) export const PerformanceReviewViews = defineView({ list: { label: '全部考评单', @@ -36,6 +37,31 @@ export const PerformanceReviewViews = defineView({ filter: [{ field: 'status', operator: 'equals', value: 'scoring' }], sort, }, + reviewing: { + label: '审定中', + type: 'grid', + data, + columns: columns.filter((c) => c.field !== 'status'), + filter: [{ field: 'status', operator: 'equals', value: 'reviewing' }], + sort, + }, + appealed: { + label: '申诉中', + type: 'grid', + data, + columns: [...columns.filter((c) => c.field !== 'status'), { field: 'appeal_date' }], + filter: [{ field: 'status', operator: 'equals', value: 'appealed' }], + sort, + }, + published: { + label: '已发布', + type: 'grid', + data, + columns: [...columns.filter((c) => c.field !== 'status'), { field: 'published_date' }], + filter: [{ field: 'status', operator: 'equals', value: 'published' }], + grouping: { fields: [{ field: 'grade', order: 'asc' }] }, + sort, + }, incomplete: { label: '数据不全', type: 'grid', diff --git a/src/core/views/performance-score.view.ts b/src/core/views/performance-score.view.ts new file mode 100644 index 0000000..6491374 --- /dev/null +++ b/src/core/views/performance-score.view.ts @@ -0,0 +1,40 @@ +import { defineView } from '@objectstack/spec'; + +const data = { provider: 'object' as const, object: 'srm_performance_score' }; +const columns = [ + { field: 'review' }, + { field: 'kpi' }, + { field: 'weight' }, + { field: 'raw_value' }, + { field: 'score' }, + { field: 'scored' }, + { field: 'defaulted' }, + { field: 'scorer' }, + { field: 'comment' }, +]; +const sort = [{ field: 'review', order: 'desc' as const }]; + +// 05 §2:「待我评分」列出指派给本人、还没评分的评分项,从这里点进考评单「提交评分」(03 §10 第 3 步) +export const PerformanceScoreViews = defineView({ + list: { + label: '全部评分项', + type: 'grid', + data, + columns, + sort, + }, + + listViews: { + mine: { + label: '待我评分', + type: 'grid', + data, + columns: columns.filter((c) => c.field !== 'scorer' && c.field !== 'scored' && c.field !== 'defaulted'), + filter: [ + { field: 'scorer', operator: 'equals', value: '{current_user_id}' }, + { field: 'scored', operator: 'equals', value: false }, + ], + sort, + }, + }, +}); diff --git a/test/performance-publish.test.ts b/test/performance-publish.test.ts new file mode 100644 index 0000000..69e1d38 --- /dev/null +++ b/test/performance-publish.test.ts @@ -0,0 +1,410 @@ +import { readFileSync } from 'node:fs'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import config from '../objectstack.config'; +import { Category, DeliveryRecord, Kpi, Material, PerformanceReview, PerformanceScore, Supplier, SupplierCategory } from '../src/core/objects'; +import { APPEAL_DAYS, GRADE_MIN, MISSING_SCORE, SCORE_DAYS } from '../src/core/flows/performance/params'; + +// v0.5 P3 人工评分、汇总分级、审定发布与申诉(02 §4.7、§5.7,03 §10 第 3–5、7 步,FR-703、704、706): +// 生成时按指标的评分人类型从品类上指派评分人;评分人经「提交评分」只看到并填写自己的评分项;截止后缺评取默认分并标记未评; +// 全部评分完成后汇总分级、交采购总监审定,通过即发布并回写供应商等级,驳回即退回重评;发布后可代录申诉一次,采购总监处理。 +// 测试数据全部在这里生成(示例公司名、示例品类),期间取 2031 年起,不与其他测试混在一起。 +type Row = Record; +type Person = { token: string; id: string }; +type FlowResult = { status?: string; runId?: string; screen?: { title?: string }; success?: boolean; outcome?: string; message?: string }; + +describe('performance scoring, grading, publishing and appeals', () => { + let stack: VerifyStack; + let seq = 0; + + const day = (offset: number) => new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10); + const person = async (...positions: string[]): Promise => { + const token = await stack.signUp(`pub-${positions.join('-')}-${++seq}@example.com`); + const context = await stack.contextFor(token); + const org = context.tenantId ? { organization_id: String(context.tenantId) } : {}; + await stack.seed('sys_user_position', positions.map((position) => ({ user_id: String(context.userId), position, ...org }))); + return { token, id: String(context.userId) }; + }; + const system = async (object: string, doc: Row) => (await stack.hooks.run(object, 'insert', doc, { system: true })) as { id: string }; + const systemUpdate = (object: string, doc: Row) => stack.hooks.run(object, 'update', doc, { system: true }); + const row = async (object: string, id: string) => (await stack.rows(object, { id }))[0]!; + + const scheduledWork = process.env.OS_AUTOMATION_SCHEDULED_WORK_ENABLED; + const generate = async (asOf: string) => { + const run = await stack.flows.run('srm_performance_generate', { asOf }, { system: true }); + expect(run.success, JSON.stringify(run)).toBe(true); + }; + const sweep = async () => { + const run = await stack.flows.run('srm_score_overdue_sweep', {}, { system: true }); + expect(run.success, JSON.stringify(run)).toBe(true); + }; + const reviewOf = async (supplier: string, period = '2031-01') => (await stack.rows(PerformanceReview.name, { supplier, period }))[0]!; + const review = (id: string) => row(PerformanceReview.name, id); + let codes: Map; + const linesOf = async (reviewId: string) => + Object.fromEntries((await stack.rows(PerformanceScore.name, { review: reviewId })).map((l) => [codes.get(String(l.kpi))!, l])); + + // 启动 screen flow;每停在一屏用 input(屏幕标题) 提交,直到结束;返回结束结果与经过的屏幕标题 + const runScreen = async (as: Person, flow: string, recordId: string, input: (title: string) => Row = () => ({})) => { + let res = (await stack.actions.run(PerformanceReview.name, flow, { as: as.token, recordId })) as FlowResult; + const titles: string[] = []; + while (res.status === 'paused' && res.runId) { + const title = String(res.screen?.title ?? ''); + titles.push(title); + expect(titles.length, 'too many screens').toBeLessThan(20); + res = (await stack.flows.resume({ flowName: flow, runId: res.runId }, input(title), { as: as.token })) as FlowResult; + } + return { res, titles }; + }; + // 拒绝时的提示(测试用户的界面语言是英文,取英文词条) + const refusal = async (as: Person, flow: string, recordId: string) => { + const { res, titles } = await runScreen(as, flow, recordId); + expect(titles).toHaveLength(0); + expect(res.status).toBe('refused'); + return String((res as { refusalMessage?: string }).refusalMessage ?? ''); + }; + // 按指标名称打分:名称 → 分数,没列的打 fallback + const scoreAs = (as: Person, reviewId: string, byName: Record, fallback = 75) => + runScreen(as, 'srm_score_submit', reviewId, (title) => { + const name = Object.keys(byName).find((n) => title.includes(n)); + return { score: name ? byName[name] : fallback, score_note: `示例评分说明:${title}` }; + }); + + const pending = async (recordId: string) => + (await stack.rows('sys_approval_request', { record_id: recordId })).find((r) => r.status === 'pending'); + const pendingApprovers = async (recordId: string) => String((await pending(recordId))?.pending_approvers ?? ''); + const decide = async (as: Person, recordId: string, verdict: 'approve' | 'reject', comment: string = verdict) => { + const req = await pending(recordId); + expect(req, 'no pending approval request').toBeTruthy(); + const res = await stack.apiAs(as.token, 'POST', `/approvals/requests/${req!.id}/${verdict}`, { comment }); + expect(res.status, await res.clone().text()).toBe(200); + }; + const inbox = async (userId: string, recordId: string) => + (await stack.rows('sys_inbox_message', { user_id: userId })).filter((m) => String(m.action_url).endsWith(`/${recordId}`)); + + let cm: Person; + let buyer: Person; + let sqe: Person; + let director: Person; + let otherDirector: Person; + let scoringDirector: Person; + let lead: Person; + let material: string; + + const category = async (people: Row) => { + const n = ++seq; + return (await system(Category.name, { code: `PUB${n}`, name: `示例发布品类 ${n}`, material_class: 'class_a', ...people })).id; + }; + const vendor = async (categoryId: string) => { + const n = ++seq; + const { id } = await system(Supplier.name, { + name: `示例绩效发布有限公司 ${n}`, + credit_code: `91000000MA0PUB${String(n).padStart(4, '0')}`, + legacy_imported: true, + }); + await system(SupplierCategory.name, { supplier: id, category: categoryId, status: 'qualified' }); + return id; + }; + // 本期 10 行交付记录,全部准时、合格、无不良:三项自动指标都是满分 + const deliverAll = async (supplier: string, month: string) => { + for (let i = 0; i < 10; i++) { + await system(DeliveryRecord.name, { + erp_receipt_no: `PUB${String(++seq).padStart(7, '0')}`, + erp_order_no: '4500091001', + erp_line_no: '10', + supplier, + material, + required_date: `${month}-10`, + receipt_date: `${month}-10`, + received_quantity: 1000, + inspection_result: 'pass', + }); + } + }; + + const v: Record = {}; + + beforeAll(async () => { + process.env.OS_AUTOMATION_SCHEDULED_WORK_ENABLED = 'true'; + stack = await bootStack(config); + codes = new Map((await stack.rows(Kpi.name, {})).map((k) => [String(k.id), String(k.code)])); + material = (await system(Material.name, { erp_material_no: 'PUB-0001', material_code: 'PUB-0001', name: '示例发布物料' })).id; + cm = await person('srm_category_manager'); + buyer = await person('srm_buyer'); + sqe = await person('srm_sqe'); + director = await person('srm_procurement_director'); + otherDirector = await person('srm_procurement_director'); + scoringDirector = await person('srm_procurement_director'); + lead = await person('srm_buyer_lead'); + + const full = await category({ category_manager: cm.id, buyer: buyer.id, sqe: sqe.id }); + const noBuyer = await category({ category_manager: cm.id, sqe: sqe.id }); + // 采购总监兼这个品类的品类经理:价格竞争力由他评分,他不能审定这张考评单 + const directorScored = await category({ category_manager: scoringDirector.id, buyer: buyer.id, sqe: sqe.id }); + + for (const name of ['main', 'reject', 'appeal', 'appealLate', 'void', 'older'] as const) { + v[name] = await vendor(full); + await deliverAll(v[name], '2031-01'); + } + v.noBuyer = await vendor(noBuyer); + await deliverAll(v.noBuyer, '2031-01'); + v.sod = await vendor(directorScored); + await deliverAll(v.sod, '2031-01'); + // 没有交付记录:自动指标没有数据,交给评分人 + for (const name of ['empty', 'grade90', 'grade8999', 'grade70', 'grade6999'] as const) v[name] = await vendor(full); + + await generate('2031-02-02'); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + if (scheduledWork === undefined) delete process.env.OS_AUTOMATION_SCHEDULED_WORK_ENABLED; + else process.env.OS_AUTOMATION_SCHEDULED_WORK_ENABLED = scheduledWork; + }); + + it('takes the grade lines, scoring period, missing score and appeal period from the parameters', () => { + const yaml = readFileSync(new URL('../params/core.yaml', import.meta.url), 'utf8'); + const example = (key: string) => /example:\s*(.+)/.exec(yaml.split(/\n(?=\S)/).find((b) => b.startsWith(`${key}:`))!)![1]!.trim(); + expect(example('perf_grade_min')).toBe(`{ A: ${GRADE_MIN.A}, B: ${GRADE_MIN.B}, C: ${GRADE_MIN.C} }`); + expect(Number(example('perf_score_days'))).toBe(SCORE_DAYS); + expect(Number(example('perf_missing_score'))).toBe(MISSING_SCORE); + expect(Number(example('perf_appeal_days'))).toBe(APPEAL_DAYS); + }); + + it('assigns each manual or data-less line a scorer by the KPI scorer type and notifies the scorers', async () => { + const r = await reviewOf(v.main!); + expect(r).toMatchObject({ status: 'scoring', score_due: day(SCORE_DAYS), data_incomplete: false }); + const lines = await linesOf(String(r.id)); + expect(lines.PLAN_FULFIL!.scorer).toBe(buyer.id); + expect(lines.SERVICE!.scorer).toBe(buyer.id); + expect(lines.PRICE!.scorer).toBe(cm.id); + expect(lines.SAFETY_ENV!.scorer).toBe(sqe.id); + // 算出得分的自动指标不需要评分人 + for (const code of ['LOT_PASS', 'PPM', 'OTD']) expect(lines[code]).toMatchObject({ scored: true, score: 100 }); + for (const code of ['LOT_PASS', 'PPM', 'OTD']) expect(lines[code]!.scorer ?? null).toBeNull(); + for (const who of [buyer, cm, sqe]) await expect.poll(async () => (await inbox(who.id, String(r.id))).length).toBe(1); + }); + + it('falls back to the category manager when the category has no person of that type', async () => { + const lines = await linesOf(String((await reviewOf(v.noBuyer!)).id)); + expect(lines.PLAN_FULFIL!.scorer).toBe(cm.id); + expect(lines.SERVICE!.scorer).toBe(cm.id); + expect(lines.SAFETY_ENV!.scorer).toBe(sqe.id); + }); + + it('gives automatic KPIs without data to a scorer instead of a zero', async () => { + const r = await reviewOf(v.empty!); + expect(r).toMatchObject({ status: 'scoring', data_incomplete: true, delivery_count: 0 }); + const lines = await linesOf(String(r.id)); + expect(lines.LOT_PASS).toMatchObject({ scored: false, scorer: sqe.id }); + expect(lines.PPM).toMatchObject({ scored: false, scorer: sqe.id }); + expect(lines.OTD).toMatchObject({ scored: false, scorer: buyer.id }); + expect(lines.OTD!.score ?? null).toBeNull(); + }); + + it('shows a scorer only the lines assigned to them and refuses someone with none', async () => { + const r = await reviewOf(v.main!); + const id = String(r.id); + const { res, titles } = await scoreAs(buyer, id, { 计划完成率: 80, 响应与配合: 80 }); + expect(titles).toHaveLength(2); + expect(titles.some((t) => t.includes('计划完成率'))).toBe(true); + expect(titles.some((t) => t.includes('响应与配合'))).toBe(true); + expect(res.status).not.toBe('failed'); + const lines = await linesOf(id); + expect(lines.PLAN_FULFIL).toMatchObject({ score: 80, scored: true }); + expect(String(lines.PLAN_FULFIL!.comment)).toContain('示例评分说明'); + // 别人的评分项不动 + expect(lines.PRICE!.scored).toBe(false); + expect((await review(id)).status).toBe('scoring'); + + // 没有指派给本人的评分项 + const outsider = await person('srm_buyer'); + const refused = await runScreen(outsider, 'srm_score_submit', id); + expect(refused.titles).toHaveLength(0); + expect(refused.res).toMatchObject({ status: 'refused', refusalMessage: expect.stringContaining('No score lines of this review are assigned to you') }); + + // 评分明细对评分人也是只读:绕过界面直接改不了 + await expect( + stack.hooks.run(PerformanceScore.name, 'update', { id: String(lines.PRICE!.id), score: 100, scored: true }, { as: buyer.token }), + ).rejects.toThrow(); + expect((await linesOf(id)).PRICE!.scored).toBe(false); + }); + + it('grades the review once all lines are scored and publishes it on the director\'s approval', async () => { + const id = String((await reviewOf(v.main!)).id); + await scoreAs(cm, id, { 价格竞争力: 85 }); + expect((await review(id)).status).toBe('scoring'); + await scoreAs(sqe, id, { 安全环保合规: 90 }); + + const graded = await review(id); + // 20 + 10 + 15(自动满分)+ 8 + 8 + 17 + 13.5 + expect(graded).toMatchObject({ status: 'reviewing', total_score: 91.5, grade: 'grade_a', approval_status: 'pending' }); + expect(graded).toMatchObject({ quality_score: 100, delivery_score: 92, cost_score: 85, service_score: 80, hse_score: 90 }); + expect(await pendingApprovers(id)).toContain(director.id); + + await decide(director, id, 'approve', '同意发布'); + expect(await review(id)).toMatchObject({ status: 'published', reviewer: director.id, review_comment: '同意发布', published_date: day(0) }); + expect(await row(Supplier.name, v.main!)).toMatchObject({ grade: 'grade_a', latest_score: 91.5 }); + await expect.poll(async () => (await inbox(buyer.id, id)).length).toBe(2); + }); + + it('grades by the parameter lines at the exact boundaries', async () => { + const cases: Array<[string, number, string]> = [ + ['grade90', 90, 'grade_a'], + ['grade8999', 89.99, 'grade_b'], + ['grade70', 70, 'grade_c'], + ['grade6999', 69.99, 'grade_d'], + ]; + for (const [name, value, grade] of cases) { + const id = String((await reviewOf(v[name]!)).id); + for (const who of [buyer, cm]) await scoreAs(who, id, {}, value); + // 评分说明可以不填 + await runScreen(sqe, 'srm_score_submit', id, () => ({ score: value })); + expect(await review(id), name).toMatchObject({ status: 'reviewing', total_score: value, grade }); + expect((await linesOf(id)).SAFETY_ENV!.comment ?? null).toBeNull(); + } + }); + + it('defaults missing scores after the due date, marks them not scored and moves on to review', async () => { + const id = String((await reviewOf(v.empty!)).id); + await scoreAs(cm, id, { 价格竞争力: 80 }); + // 截止日没到时扫描不处理 + await sweep(); + expect((await review(id)).status).toBe('scoring'); + + await systemUpdate(PerformanceReview.name, { id, score_due: day(-1) }); + await sweep(); + const lines = await linesOf(id); + expect(lines.PRICE).toMatchObject({ score: 80, scored: true, defaulted: false }); + for (const code of ['LOT_PASS', 'PPM', 'OTD', 'PLAN_FULFIL', 'SERVICE', 'SAFETY_ENV']) { + expect(lines[code], code).toMatchObject({ score: MISSING_SCORE, scored: true, defaulted: true }); + } + // 0.2 × 80 + 0.8 × 60 + expect(await review(id)).toMatchObject({ status: 'reviewing', total_score: 64, grade: 'grade_d' }); + // 再扫一次不重复处理 + await sweep(); + expect((await review(id)).status).toBe('reviewing'); + }); + + it('reminds scorers with open lines three days before the due date', async () => { + const id = String((await reviewOf(v.void!)).id); + await systemUpdate(PerformanceReview.name, { id, score_due: day(3) }); + const before = (await inbox(cm.id, id)).length; + const job = stack.kernel.getService<{ trigger(name: string): Promise }>('job'); + await job.trigger('flow-time-relative:srm_score_due_notice'); + await expect.poll(async () => (await inbox(cm.id, id)).length).toBe(before + 1); + }); + + it('does not count the approval of a director who scored the review', async () => { + const id = String((await reviewOf(v.sod!)).id); + for (const who of [buyer, scoringDirector, sqe]) await scoreAs(who, id, {}, 85); + expect((await review(id)).status).toBe('reviewing'); + await decide(scoringDirector, id, 'approve'); + expect((await review(id)).status).toBe('reviewing'); + await expect + .poll(async () => (await stack.rows('sys_inbox_message', { user_id: scoringDirector.id })).some((m) => String(m.title).includes('另一位采购总监'))) + .toBe(true); + expect(await pendingApprovers(id)).toContain(otherDirector.id); + await decide(otherDirector, id, 'approve'); + expect(await review(id)).toMatchObject({ status: 'published', reviewer: otherDirector.id }); + }); + + it('sends a review back for rescoring when the director rejects it', async () => { + const id = String((await reviewOf(v.reject!)).id); + for (const who of [buyer, cm, sqe]) await scoreAs(who, id, {}, 75); + await systemUpdate(PerformanceReview.name, { id, score_due: day(-2) }); + // 自动指标满分 45 + 0.55 × 75 + expect((await review(id)).total_score).toBe(86.25); + await decide(director, id, 'reject', '价格竞争力偏高,请品类经理复核'); + expect(await review(id)).toMatchObject({ status: 'scoring', review_comment: '价格竞争力偏高,请品类经理复核', score_due: day(SCORE_DAYS) }); + await expect.poll(async () => (await inbox(cm.id, id)).length).toBe(2); + + // 品类经理改分:屏幕带出原来的分数,改完考评单再次转审定中,重新汇总 + const started = (await stack.actions.run(PerformanceReview.name, 'srm_score_submit', { as: cm.token, recordId: id })) as FlowResult & { + screen?: { fields?: Array<{ name: string; defaultValue?: unknown }> }; + }; + expect(started.screen?.fields?.find((f) => f.name === 'score')?.defaultValue).toBe(75); + await stack.flows.resume({ flowName: 'srm_score_submit', runId: started.runId! }, { score: 10 }, { as: cm.token }); + const titles = [started.screen?.title]; + expect(titles).toHaveLength(1); + expect((await linesOf(id)).PRICE).toMatchObject({ score: 10, scored: true }); + const again = await review(id); + expect(again).toMatchObject({ status: 'reviewing', total_score: 73.25, grade: 'grade_c', approval_status: 'pending' }); + await decide(director, id, 'approve'); + expect((await review(id)).status).toBe('published'); + }); + + it('does not overwrite the supplier grade with an older period', async () => { + await generate('2031-03-02'); + const newer = String((await reviewOf(v.older!, '2031-02')).id); + const older = String((await reviewOf(v.older!, '2031-01')).id); + for (const who of [buyer, cm, sqe]) await scoreAs(who, newer, {}, 50); + await decide(director, newer, 'approve'); + expect(await row(Supplier.name, v.older!)).toMatchObject({ grade: 'grade_d' }); + for (const who of [buyer, cm, sqe]) await scoreAs(who, older, {}, 95); + await decide(director, older, 'approve'); + expect((await review(older)).status).toBe('published'); + expect(await row(Supplier.name, v.older!)).toMatchObject({ grade: 'grade_d' }); + }); + + it('records one appeal within the appeal period and lets a director who did not score it adjust the result', async () => { + const id = String((await reviewOf(v.appeal!)).id); + // 自动指标满分 45 + 0.55 × 50 + for (const who of [buyer, cm, sqe]) await scoreAs(who, id, {}, 50); + await decide(director, id, 'approve'); + expect(await row(Supplier.name, v.appeal!)).toMatchObject({ grade: 'grade_c', latest_score: 72.5 }); + + // 评分人之外的岗位不能代录 + expect(await refusal(sqe, 'srm_appeal_submit', id)).toContain('Supplier appeals are recorded by'); + const { titles } = await runScreen(lead, 'srm_appeal_submit', id, () => ({ appeal_text: '示例申诉:本期两批让步接收已由客户确认合格' })); + expect(titles).toHaveLength(1); + expect(await review(id)).toMatchObject({ + status: 'appealed', + appeal_reason: '示例申诉:本期两批让步接收已由客户确认合格', + appeal_date: day(0), + appeal_recorded_by: lead.id, + }); + await expect.poll(async () => (await inbox(director.id, id)).length).toBeGreaterThan(0); + + // 处理人不能是评分人;只有采购总监处理 + await systemUpdate(PerformanceScore.name, { id: String((await linesOf(id)).PRICE!.id), scorer: scoringDirector.id }); + expect(await refusal(scoringDirector, 'srm_appeal_resolve', id)).toContain('You scored this review'); + expect(await refusal(cm, 'srm_appeal_resolve', id)).toContain('handled by the procurement director'); + + await runScreen(director, 'srm_appeal_resolve', id, () => ({ outcome: 'adjusted', adjusted_score: 80.5, result_text: '示例:复核后计入两批合格' })); + expect(await review(id)).toMatchObject({ + status: 'published', + appeal_outcome: 'adjusted', + appeal_result: '示例:复核后计入两批合格', + appeal_handled_by: director.id, + total_score: 80.5, + grade: 'grade_b', + }); + expect(await row(Supplier.name, v.appeal!)).toMatchObject({ grade: 'grade_b', latest_score: 80.5 }); + await expect.poll(async () => (await inbox(lead.id, id)).length).toBe(1); + + // 只能申诉一次 + expect(await refusal(lead, 'srm_appeal_submit', id)).toContain('appealed only once'); + }); + + it('keeps the result when the director upholds it, and refuses an appeal after the appeal period', async () => { + const id = String((await reviewOf(v.appealLate!)).id); + for (const who of [buyer, cm, sqe]) await scoreAs(who, id, {}, 88); + await decide(director, id, 'approve'); + await systemUpdate(PerformanceReview.name, { id, published_date: day(-APPEAL_DAYS - 1) }); + expect(await refusal(lead, 'srm_appeal_submit', id)).toContain(`within ${APPEAL_DAYS} days`); + + await systemUpdate(PerformanceReview.name, { id, published_date: day(-APPEAL_DAYS) }); + await runScreen(buyer, 'srm_appeal_submit', id, () => ({ appeal_text: '示例申诉' })); + await runScreen(director, 'srm_appeal_resolve', id, () => ({ outcome: 'maintained', result_text: '示例:维持原结果' })); + expect(await review(id)).toMatchObject({ status: 'published', appeal_outcome: 'maintained', total_score: 93.4, grade: 'grade_a' }); + }); + + it('lets only the procurement director void a review in manual scoring', async () => { + const id = String((await reviewOf(v.void!)).id); + expect(await refusal(cm, 'srm_performance_void', id)).toContain('Only the procurement director can void'); + await runScreen(director, 'srm_performance_void', id, () => ({ reason_text: '示例:本期实际没有供货' })); + expect(await review(id)).toMatchObject({ status: 'void', void_reason: '示例:本期实际没有供货' }); + expect(await refusal(buyer, 'srm_score_submit', id)).toContain('only while the review is in manual scoring'); + }); +});