From 92019ec8b4398367ddf6f372df63c5abe6a1cc78 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 16:29:32 +0000 Subject: [PATCH] feat(core): v0.5 P2 performance review generation and auto scoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - srm_performance_review (PR{YYYY}{00000}, supplier + period unique, state machine per 02 §5.7, grade values grade_a..grade_d) and srm_performance_score (master_detail, weight snapshot) - srm_performance_generate: schedule days 1-7 03:00, catch-up and idempotent; cycle by the supplier's highest material class (perf_cycle, CYCLE checked against the parameter by test); active = qualified or frozen; refuses and notifies the procurement director when active weights are not 100%; rebuilds a review a failed run left in calculating; asOf input for back-runs - auto scoring: OTD, lot acceptance rate and PPM from the period's delivery records (same basis as srm_delivery_metrics), linear KPI scores from full/zero values; no data -> item unscored and review flagged data_incomplete (07 R7) - views all / awaiting scores / data incomplete; nav; internal read-only, srm_admin deletes - sums by loop accumulation (07 §6.3 PF15, data point on objectstack#14500); 200 suppliers x 30 lines ~8.6 s (07 R9, opt-in load test) - design docs 02 / 03 / 05 / 07 and params synced; tests Closes #51 Claude-Session: https://claude.ai/code/session_01A56prixkxJr4M3yuAoM4qi Co-authored-by: Claude --- docs/design/02-domain-model.md | 44 +- docs/design/03-processes-and-automation.md | 6 +- docs/design/05-experience-and-analytics.md | 6 +- docs/design/07-delivery-plan.md | 3 +- params/core.yaml | 2 +- src/core/apps/workbench.app.ts | 1 + src/core/flows/index.ts | 1 + src/core/flows/performance/generate.flow.ts | 432 ++++++++++++++++++ src/core/objects/index.ts | 2 + src/core/objects/performance-review.object.ts | 121 +++++ src/core/objects/performance-score.object.ts | 28 ++ src/core/security/permission-sets.ts | 15 + src/core/translations/en.ts | 68 +++ src/core/views/index.ts | 1 + src/core/views/performance-review.view.ts | 48 ++ test/performance-generate-load.test.ts | 70 +++ test/performance-review.test.ts | 357 +++++++++++++++ test/permissions.test.ts | 14 + 18 files changed, 1195 insertions(+), 24 deletions(-) create mode 100644 src/core/flows/performance/generate.flow.ts create mode 100644 src/core/objects/performance-review.object.ts create mode 100644 src/core/objects/performance-score.object.ts create mode 100644 src/core/views/performance-review.view.ts create mode 100644 test/performance-generate-load.test.ts create mode 100644 test/performance-review.test.ts diff --git a/docs/design/02-domain-model.md b/docs/design/02-domain-model.md index d712cbd..39ce04e 100644 --- a/docs/design/02-domain-model.md +++ b/docs/design/02-domain-model.md @@ -190,7 +190,7 @@ erDiagram | `registration_no` | text | 境外供应商在注册地的登记号或税号(如增值税号、雇主识别号);`country != 'cn'` 时必填并显示。对象声明组合唯一索引 `country` + `registration_no`(BR-01) | | `supplier_type` | select | `manufacturer` 生产商 / `distributor` 代理分销 / `service` 服务商 / `logistics` 物流商 | | `status` | select(stageField) | 见 §5.1 生命周期状态机 | -| `grade` | select | `a` / `b` / `c` / `d`,最近一期绩效发布时写入 | +| `grade` | select | `grade_a` / `grade_b` / `grade_c` / `grade_d`(同考评单),最近一期绩效发布时写入 | | `latest_score` | number | 最近一期绩效总分(存储字段,供看板使用) | | `qualification_alert` | boolean | 默认 false;任一必备资质类型没有「有效且已核验」的证书时由 flow 置为 true(BR-04),补齐并经 SQE 核验后才恢复为 false | | `risk_level` | select | `low` / `medium` / `high` | @@ -667,19 +667,21 @@ false,BR-13)、`received_quantity`(summary:`sum` 收货记录 `accepted_ 集成前不启用。按物资类别分方案的接口预留为可选多选字段 `material_classes`(选项列表 `srm_material_class`,空 = 全部 类别),有客户需要时再加,v0.5 不做(2026-10-24 裁定)。 -**`srm_performance_review` 绩效考评单**:`review_no`、`supplier`、`cycle`(`monthly` / -`quarterly` / `yearly`,按供应商所供最高物资类别取参数 `perf_cycle`)、`period`(text, -如 `2026Q3`、`2026-10`)、`period_start` / `period_end`(date)、`quality_score` / -`cost_score` / `delivery_score` / `service_score` / `hse_score` / `total_score`(number,存储字段, -由算分 flow 写入,供看板使用)、`grade`(`a` / `b` / `c` / `d`)、`status`(见 §5.7)、 -`otd_rate` / `lot_pass_rate`(percent)、`ppm`(number)、`ack_on_time_rate`(percent, -ERP 集成前为空)、`reviewer`(user)、`appeal_reason`、`appeal_result`。自动指标由 -交付记录按 `period` 汇总。 - -**`srm_performance_score` 评分明细**(master_detail → 考评单):`kpi`(lookup)、 -`raw_value`(number,自动指标的原始值)、`score`(number,0–100)、`weight` -(percent,快照)、`scorer`(user,人工指标的评分人)、`comment`、`scored` -(boolean,默认 false)。 +**`srm_performance_review` 绩效考评单**(OWD `public_read`,内部只读:由 flow 生成和推进,评分只经「提交评分」 +screen flow;删除只给 `srm_admin`,删掉后可补跑生成;开启字段历史;`supplier` + `period` 唯一):`review_no` +(`PR{YYYY}{00000}`)、`supplier`、`cycle`(`monthly` / `quarterly` / `yearly`,按供应商所供最高物资类别取参数 +`perf_cycle`)、`period`(text,月度 `2026-10`、季度 `2026Q3`、年度 `2026`)、`period_start` / `period_end`(date)、 +`quality_score` / `cost_score` / `delivery_score` / `service_score` / `hse_score` / `total_score`(number,存储字段, +由汇总分级写入,供看板使用)、`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`。 +自动指标由交付记录按 `period` 汇总,比率字段存小数(0.985 即 98.5%);flow 写的字段一律只读。 + +**`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` 写明)。 ### 4.8 结算(不做,留在 ERP) @@ -809,9 +811,17 @@ ERP 集成前为空)、`reviewer`(user)、`appeal_reason`、`appeal_result ### 5.7 绩效考评单 `srm_performance_review.status` -`calculating` ★ 自动算分(open)→ `scoring` 人工评分(open)→ `reviewing` 审定中 -(open)→ `published` 已发布(won)→ `appealed` 申诉中(open)→ `published`; -作废为 `void`(lost)。 +| 值 | 标签 | kind | 允许跳转到 | +|---|---|---|---| +| `calculating` ★ | 自动算分 | open | `scoring`、`void` | +| `scoring` | 人工评分 | open | `reviewing`、`void` | +| `reviewing` | 审定中 | open | `published`、`void` | +| `appealed` | 申诉中 | open | `published` | +| `published` | 已发布 | won | `appealed` | +| `void` | 已作废 | lost | — | + +`calculating` 只存在于一次生成运行之内:生成 flow 建单、建评分明细、算完自动指标后在同一次运行里转 `scoring`; +中途失败留下的 `calculating` 考评单在下次运行时删掉重建(03 §10 第 1 步)。 ### 5.8 对账单 diff --git a/docs/design/03-processes-and-automation.md b/docs/design/03-processes-and-automation.md index 879a7b7..b67f88a 100644 --- a/docs/design/03-processes-and-automation.md +++ b/docs/design/03-processes-and-automation.md @@ -381,8 +381,8 @@ stateDiagram-v2 | 步骤 | 触发 | 动作 | |---|---|---| -| 1 生成 | `schedule`:每月 1 日 03:00(追赶式) | 按供应商所供最高物资类别取周期参数 `perf_cycle`(示例 A 类每月、B 类每季、C 类每年);本次运行到期的供应商各建一张上期考评单(`calculating`,`cycle` / `period` 写明),按指标库中启用的指标建评分明细(FR-702);启用指标的权重合计不是 100% 时本次不生成并通知采购总监;已有同期考评单的跳过 | -| 2 自动算分 | 同一次运行 | 按 [05 §4](./05-experience-and-analytics.md#4-仪表盘与指标口径) 的口径从交付记录计算 OTD、批次合格率、PPM,写入原始值与得分;订单响应及时率在 ERP 集成前在指标库中不启用(不建评分明细),启用时由采购总监调整其余权重。得分按指标的满分值、零分值线性计算(02 §4.7)。由 flow 的查询节点按供应商和 `period` 汇总交付记录上的 `on_time`、`inspection_result`、`defect_quantity` | +| 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) | @@ -474,7 +474,7 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_scar_overdue_sweep` | schedule:每小时(api 心跳等 objectstack#25675) | 整改单 | v0.4 | | `srm_audit_admission_apply` | record_change:准入审核完成或改结论 | 供应商审核 → 准入申请 | v0.4 | | `srm_audit_plan_generate` | schedule:每月 1 日 02:30(追赶式;年初那次生成全年计划) | 供应商审核 | v0.4 | -| `srm_performance_generate` | schedule:每月 1 日(按类别周期) | 考评单 | v0.5 | +| `srm_performance_generate` | schedule:每月 1–7 日 03:00(追赶式,按类别周期) | 考评单、评分明细 | v0.5 | | `srm_performance_publish` | record_change:状态→`reviewing` | 考评单 → 供应商 | v0.5 | | `srm_performance_apply` | record_change:状态→`published` | 考评单 → 整改单、处置单 | v0.5 | | `srm_risk_event_apply` | record_change:风险事件新建或改为高风险 | 风险事件 → 处置单(草稿) | v0.5 | diff --git a/docs/design/05-experience-and-analytics.md b/docs/design/05-experience-and-analytics.md index e9a5ef9..050521c 100644 --- a/docs/design/05-experience-and-analytics.md +++ b/docs/design/05-experience-and-analytics.md @@ -27,7 +27,7 @@ │ └── 供应商审核 计划 / 问题项 ├── 绩效与风险 (v0.5) │ ├── 绩效指标 全部 / 启用指标(权重合计)/ 已停用 -│ ├── 考评单 本期 / 待评分 +│ ├── 考评单 全部 / 待评分 / 数据不全(本期考评随 v0.5 P3) │ └── 风险事件 风险看板(按严重度)/ 未关闭 / 按类型;黑名单名录在供应商列表视图 ├── 寻源 (v0.7) │ ├── 寻源单 我的 / 报价中 / 待开标 / 评标中 / 定标审批中 @@ -80,7 +80,9 @@ | 整改单 | 索赔 | 列表 | `claim_status != 'none'` | SQE、应付会计 | | 供应商审核 | 审核计划 | 日历 | 日期字段 `planned_date` | SQE | | 绩效指标 | 启用指标 | 列表 | `active == true`,按 `dimension` 分组,权重列合计(应为 100%)与各维度小计 | 采购总监 | -| 考评单 | 本期考评 | 列表 | 当前期间,按 `grade` 分组 | 采购总监 | +| 考评单 | 待评分 | 列表 | `status == 'scoring'`,按期间倒序 | 采购总监 | +| 考评单 | 数据不全 | 列表 | `data_incomplete == true`:本期交付记录没导入或不全(07 R7) | 采购总监、SQE | +| 考评单 | 本期考评 | 列表 | 当前期间,按 `grade` 分组(随 v0.5 P3 汇总分级) | 采购总监 | | 采购订单、订单行 | 待供应商确认、交期风险、交货排程 | 列表 / 甘特 | 预留(ERP 集成后的门户镜像) | 采购员、计划员 | 门户侧的视图是上述视图的子集,由 RLS 自动限定到本公司,**不需要**为门户单独写过滤 diff --git a/docs/design/07-delivery-plan.md b/docs/design/07-delivery-plan.md index b9927fc..273f375 100644 --- a/docs/design/07-delivery-plan.md +++ b/docs/design/07-delivery-plan.md @@ -146,7 +146,7 @@ test/ # 仓库根目录 | R6 | 存量供应商数据质量差(重复、缺信用代码) | 高 | 中 | v0.2 导入前专项清洗;导入时按信用代码(境外按国家 / 地区 + 注册号)匹配,可重复执行;标记「存量导入」进「存量待复核」视图([导入说明](../implementation/legacy-supplier-import.md)) | | R7 | ERP 集成推迟后,交付数据靠每月导入,客户不按时导出则绩效断档 | 中 | 中 | 导入模板与操作清单写进实施手册;考评 flow 发现本期无交付记录时标记「数据不全」而不是给零分 | | R8 | 每版对象与 flow 较多,回归成本上升 | 中 | 中 | 审批矩阵、导入幂等、关键 flow 的自动化测试进 CI(docs/engineering §3) | -| R9 | 考评在 flow 里批量处理,供应商多时运行时间长 | 低 | 中 | 按法人或品类错峰拆分;规模继续增长时评估服务端函数 | +| R9 | 考评在 flow 里批量处理,供应商多时运行时间长 | 低 | 中 | 按法人或品类错峰拆分;规模继续增长时评估服务端函数。2026-10-24 实测:200 家供应商 × 30 行交付记录一次生成约 8.6 秒、重跑约 0.6 秒(03 §10),暂不拆分;测试 `test/performance-generate-load.test.ts`(`SRM_LOAD_TEST=1`) | | R10 | 审批人与发起人为同一人(职责分离失效) | 中 | 中 | 岗位分离 + `expression` 审批人改派(04 §6);平台开关见 objectstack#25446 | | R11 | 客户落地时要求改核心字段,覆盖层与核心升级冲突(平台目前不检测,objectstack ADR-0005) | 中 | 中 | 客户层规则(ADR-0009 第 4 条);核心升级前跑「核心 + 客户层」组合构建 | @@ -242,6 +242,7 @@ test/ # 仓库根目录 | PF12 | objectstack#25493 导入接受 `YYYY.MM.DD` / `YYYYMMDD`、选项别名与大小写、每行报全部错误 | 存量台账导入 | 导入前清洗 | | PF13 | objectstack#24817 多级审批的后续节点以原发起人为提交人(主干已修,18.0.0 未带上) | 信息变更单第 2 级退回修改 | 第 2 级只能通过或驳回(03 §5) | | PF14 | objectstack#25683 i18n 检查不把字段的 `inlineHelpText` 当作默认语言词条 | 带帮助文字的字段(绩效指标的满分值、零分值) | zh-CN 翻译包只放这两条帮助文字,引用源码同一份常量;修复后删除 | +| PF15 | objectstack#14500 flow 没有汇总能力:CEL 不能对列表求和,`get_record` 只返回行(已关的「汇总 / 分组节点」卡,hotsrm 的用法作为数据点记在评论里) | 考评生成的 Σ不良数、Σ实收数、启用指标权重合计 | `loop` + `assignment` 逐行累加;200 家 × 30 行约 8.6 秒,性能可接受 | --- diff --git a/params/core.yaml b/params/core.yaml index edbf644..976caf9 100644 --- a/params/core.yaml +++ b/params/core.yaml @@ -53,7 +53,7 @@ perf_cycle: example: { A: 每月, B: 每季度, C: 每年 } display: A 类{A},B 类{B},C 类{C} used_in: [QP-01 6.7.1, QP-01 附录 A] - system: 考评生成 flow 按供应商所供最高物资类别(srm_category.material_class)取周期(核心 03 §10,v0.5) + system: 考评生成 flow srm_performance_generate 按供应商所供最高物资类别(srm_category.material_class)取周期,flow 的 CYCLE 与本参数由测试核对一致(核心 03 §10,v0.5) perf_kpi_weights: label: 绩效指标权重(按指标编码) diff --git a/src/core/apps/workbench.app.ts b/src/core/apps/workbench.app.ts index fdd951d..8554488 100644 --- a/src/core/apps/workbench.app.ts +++ b/src/core/apps/workbench.app.ts @@ -48,6 +48,7 @@ export const Workbench = App.create({ icon: 'gauge', 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_risk_event', type: 'object', objectName: 'srm_risk_event', label: '风险事件', icon: 'siren' }, ], }, diff --git a/src/core/flows/index.ts b/src/core/flows/index.ts index 7ab6991..27798a1 100644 --- a/src/core/flows/index.ts +++ b/src/core/flows/index.ts @@ -27,3 +27,4 @@ export { DispositionSubmitFlow } from './disposition/disposition-submit.flow.js' 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'; diff --git a/src/core/flows/performance/generate.flow.ts b/src/core/flows/performance/generate.flow.ts new file mode 100644 index 0000000..084bac1 --- /dev/null +++ b/src/core/flows/performance/generate.flow.ts @@ -0,0 +1,432 @@ +import type * as Automation from '@objectstack/spec/automation'; + +// P9 步骤 1–2(03 §10,FR-702):生成考评单并自动算分。 +// +// 生成:活跃供应商(合格或冻结——冻结期间仍在交付在途订单,连续 D 级要能累计,03 §10 第 6 步)按所供品类中最高的 +// 物资类别(srm_category.material_class,品类资格不论状态)取周期:参数 perf_cycle(示例 A 类每月、B 类每季、C 类每年), +// 客户改参数时同步 CYCLE。每期的第一个月为「到期」,生成上一期的考评单(calculating),按指标库中启用的指标各建一行评分 +// 明细并快照权重。启用指标的权重合计不是 100% 时本次不生成,通知采购总监。没有品类资格的供应商不生成。 +// +// 自动算分:按供应商与 period 汇总交付记录,口径同数据集 srm_delivery_metrics(05 §4.1):OTD = 准时行 ÷ 交付行; +// 批次合格率 = 合格行 ÷ 已检行(让步接收计不合格);PPM = Σ不良数 ÷ Σ实收数 × 1,000,000。写入考评单的比率字段, +// 再按每条自动指标的满分值、零分值线性算分(02 §4.7),得分规则全在指标库里,这里不写死数字。本期没有交付记录、 +// 某项指标没有数据(如没有已检批次)时该项不给分、考评单标记「数据不全」(07 R7)。算完转人工评分(scoring)。 +// +// 追赶式(03 §2.3 R2):每月 1–7 日每天 03:00 运行,同一供应商同一期间已有考评单的跳过,1 日漏跑后几天补上,跑两遍不 +// 重复建(考评单上 supplier + period 唯一索引兜底)。补跑更早的月份时传入运行日 asOf(如 2026-10-01)。 +// 单家失败只跳过该家,不中断整次生成。 +// +// 临时:CEL 没有对列表求和,flow 也没有汇总节点,Σ不良数、Σ实收数与权重合计只能逐行累加(07 §6.3 PF15, +// 数据点记在 objectstack#14500);平台给出汇总能力后改掉。 +export const CYCLE = { class_a: 'monthly', class_b: 'quarterly', class_c: 'yearly' } as const; + +type Cel = { dialect: 'cel'; source: string }; +const c = (source: string): Cel => ({ dialect: 'cel', source }); +const val = (k: string) => `(has(${k}) && ${k} != null)`; +const round = (x: string, digits: number) => `(double(round((${x}) * ${10 ** digits}.0)) / ${10 ** digits}.0)`; + +// 供应商的类别:A 优先于 B 优先于 C +const classOf = (['class_a', 'class_b', 'class_c'] as const).reduceRight( + (rest, cls) => `cats.exists(x, ${val('x.material_class')} && x.material_class == '${cls}') ? '${cls}' : ${rest}`, + `''`, +); +const cycleOf = (Object.entries(CYCLE) as Array<[string, string]>).reduceRight( + (rest, [cls, cycle]) => `supplierClass == '${cls}' ? '${cycle}' : ${rest}`, + `''`, +); +// 每期的第一个月到期(月份从 0 起) +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 span = (n: number) => `[${Array.from({ length: n }, (_, i) => i).join(', ')}]`; + +// 某条指标在本期的原始值(与满分值、零分值同一单位);人工指标与没有数据源的自动指标为 null +const rawOf = (k: string) => + `(${k}.method == 'auto' && ${val(`${k}.auto_metric`)} ? (` + + `${k}.auto_metric == 'otd_rate' ? otdRaw : ${k}.auto_metric == 'lot_pass_rate' ? lotRaw : ${k}.auto_metric == 'ppm' ? ppmRaw : null) : null)`; +const scoreOf = (k: string, raw: string) => + `(${raw} == null ? null : ${round( + `max(0.0, min(100.0, 100.0 * (${raw} - double(${k}.zero_score_value)) / (double(${k}.full_score_value) - double(${k}.zero_score_value))))`, + 2, + )})`; + +const supplierBody: Automation.FlowNode[] = [ + { + id: 'find_grants', + type: 'get_record', + label: '读取品类资格', + config: { objectName: 'srm_supplier_category', filter: { supplier: c('vendor.id') }, fields: ['category'], limit: 500, outputVariable: 'grants' }, + }, + { id: 'has_grants', type: 'decision', label: '有品类资格?' }, + { + id: 'find_categories', + type: 'get_record', + label: '读取所供品类', + config: { + objectName: 'srm_category', + filter: { id: { $in: c('grants.map(g, g.category)') } }, + fields: ['material_class'], + limit: 500, + outputVariable: 'cats', + }, + }, + { + id: 'classify', + type: 'assignment', + label: '定周期与期间', + config: { + assignments: { + supplierClass: c(classOf), + cycle: c(cycleOf), + periodStart: c(`cycle == 'monthly' ? startMonthly : cycle == 'quarterly' ? startQuarterly : startYearly`), + period: c( + `cycle == 'monthly' ? isoDate(periodStart).substring(0, 7)` + + ` : cycle == 'quarterly' ? string(periodStart.getFullYear()) + 'Q' + string(periodStart.getMonth() / 3 + 1)` + + ` : string(periodStart.getFullYear())`, + ), + // 本期包含的月份,交付记录的 period 是实到日期的年月 + months: c( + `(cycle == 'monthly' ? ${span(1)} : cycle == 'quarterly' ? ${span(3)} : ${span(12)}).map(i, isoDate(addMonths(periodStart, i)).substring(0, 7))`, + ), + }, + }, + }, + { + id: 'find_existing', + type: 'get_record', + label: '本期已有考评单?', + config: { objectName: 'srm_performance_review', filter: { supplier: c('vendor.id'), period: c('period') }, fields: ['id', 'status'], outputVariable: 'existing' }, + }, + { id: 'is_due', type: 'decision', label: '本期到期且未生成?' }, + // 上次运行中途失败留下的「自动算分」考评单(正常运行在同一次里转人工评分):删掉(评分明细随之删除)后重建 + { + id: 'delete_stale', + type: 'delete_record', + label: '删除未算完的考评单', + config: { objectName: 'srm_performance_review', filter: { id: c('existing.id') } }, + }, + { + id: 'create_review', + type: 'create_record', + label: '生成考评单', + config: { + objectName: 'srm_performance_review', + fields: { + supplier: c('vendor.id'), + cycle: c('cycle'), + period: c('period'), + period_start: c('isoDate(periodStart)'), + period_end: c('periodEnd'), + status: 'calculating', + }, + outputVariable: 'review', + }, + }, + { + id: 'find_deliveries', + type: 'get_record', + label: '读取本期交付记录', + config: { + objectName: 'srm_delivery_record', + filter: { supplier: c('vendor.id'), period: { $in: c('months') } }, + fields: ['on_time', 'inspection_result', 'defect_quantity', 'received_quantity'], + limit: 50000, + outputVariable: 'deliveries', + }, + }, + { + id: 'reset_sums', + type: 'assignment', + label: '累计清零', + config: { assignments: { defectSum: c('0.0'), receivedSum: c('0.0'), lineFailed: c('false') } }, + }, + { + id: 'sum_deliveries', + type: 'loop', + label: '累计不良数与实收数', + config: { + collection: c('deliveries'), + iteratorVariable: 'line', + body: { + nodes: [ + { + id: 'add_line', + type: 'assignment', + label: '累加', + config: { + assignments: { + defectSum: c(`defectSum + (${val('line.defect_quantity')} ? double(line.defect_quantity) : 0.0)`), + receivedSum: c(`receivedSum + (${val('line.received_quantity')} ? double(line.received_quantity) : 0.0)`), + }, + }, + }, + ], + }, + }, + }, + { + id: 'metrics', + type: 'assignment', + label: '算 OTD、批次合格率、PPM', + config: { + assignments: { + lineCount: c('double(deliveries.size())'), + onTimeCount: c(`double(deliveries.filter(d, ${val('d.on_time')} && d.on_time == true).size())`), + inspectedCount: c(`double(deliveries.filter(d, ${val('d.inspection_result')} && d.inspection_result in ['pass', 'fail', 'concession']).size())`), + passedCount: c(`double(deliveries.filter(d, ${val('d.inspection_result')} && d.inspection_result == 'pass').size())`), + // 百分点(98.5 即 98.5%)与 PPM,保留两位小数;评分明细的原始值与指标阈值同一单位 + otdRaw: c(`lineCount > 0.0 ? ${round('100.0 * onTimeCount / lineCount', 2)} : null`), + lotRaw: c(`inspectedCount > 0.0 ? ${round('100.0 * passedCount / inspectedCount', 2)} : null`), + ppmRaw: c(`receivedSum > 0.0 ? ${round('defectSum * 1000000.0 / receivedSum', 2)} : null`), + incomplete: c(`kpis.exists(k, k.method == 'auto' && ${rawOf('k')} == null)`), + }, + }, + }, + { + id: 'create_scores', + type: 'loop', + label: '按启用指标建评分明细', + config: { + collection: c('kpis'), + iteratorVariable: 'kpi', + body: { + nodes: [ + { + id: 'guard_score', + type: 'try_catch', + label: '单条评分明细', + config: { + try: { + nodes: [ + { + id: 'line_raw', + type: 'assignment', + label: '本项原始值', + config: { assignments: { raw: c(rawOf('kpi')) } }, + }, + { + id: 'create_score', + type: 'create_record', + label: '建评分明细', + config: { + objectName: 'srm_performance_score', + fields: { + review: c('review.id'), + kpi: c('kpi.id'), + weight: c('kpi.weight'), + raw_value: c('raw'), + score: c(scoreOf('kpi', 'raw')), + scored: c(`kpi.method == 'auto' && raw != null`), + comment: c(`kpi.method == 'auto' && raw == null ? '本期没有可用的交付数据,自动指标未算分' : null`), + }, + }, + }, + ], + 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') } } }] }, + }, + }, + ], + }, + }, + }, + { id: 'lines_ok', type: 'decision', label: '评分明细齐全?' }, + { + id: 'to_scoring', + type: 'update_record', + label: '写入自动指标,转人工评分', + config: { + objectName: 'srm_performance_review', + filter: { id: c('review.id') }, + fields: { + // 比率字段存小数(0.985 即 98.5%) + otd_rate: c(`otdRaw == null ? null : ${round('otdRaw / 100.0', 4)}`), + lot_pass_rate: c(`lotRaw == null ? null : ${round('lotRaw / 100.0', 4)}`), + ppm: c('ppmRaw == null ? null : double(round(ppmRaw))'), + delivery_count: c('lineCount'), + data_incomplete: c('incomplete'), + status: 'scoring', + }, + }, + }, + { id: 'leave_calculating', type: 'assignment', label: '留在自动算分,下次重建' }, + { id: 'skip_vendor', type: 'assignment', label: '不到期、已生成或没有品类' }, + { id: 'next_vendor', type: 'assignment', label: '下一家' }, +]; + +const supplierEdges: Automation.FlowEdge[] = [ + { id: 'v1', source: 'find_grants', target: 'has_grants', type: 'default' }, + { id: 'v_grants', source: 'has_grants', target: 'find_categories', type: 'conditional', condition: c('grants.size() > 0'), label: '有' }, + { id: 'v_no_grants', source: 'has_grants', target: 'skip_vendor', type: 'conditional', condition: c('grants.size() == 0'), label: '没有' }, + { id: 'v2', source: 'find_categories', target: 'classify', type: 'default' }, + { id: 'v3', source: 'classify', target: 'find_existing', type: 'default' }, + { id: 'v4', source: 'find_existing', target: 'is_due', type: 'default' }, + { id: 'v_due', source: 'is_due', target: 'create_review', type: 'conditional', condition: c(`${isDue} && existing == null`), label: '生成' }, + { id: 'v_stale', source: 'is_due', target: 'delete_stale', type: 'conditional', condition: c(`${isDue} && ${stale}`), label: '重建' }, + { + id: 'v_skip', + source: 'is_due', + target: 'skip_vendor', + type: 'conditional', + condition: c(`!(${isDue}) || (existing != null && !(${stale}))`), + label: '跳过', + }, + { id: 'v_restart', source: 'delete_stale', target: 'create_review', type: 'default' }, + { id: 'v5', source: 'create_review', target: 'find_deliveries', type: 'default' }, + { id: 'v6', source: 'find_deliveries', target: 'reset_sums', type: 'default' }, + { id: 'v7', source: 'reset_sums', target: 'sum_deliveries', type: 'default' }, + { id: 'v8', source: 'sum_deliveries', target: 'metrics', type: 'default' }, + { id: 'v9', source: 'metrics', target: 'create_scores', type: 'default' }, + { 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: 'v12', source: 'skip_vendor', target: 'next_vendor', type: 'default' }, + { id: 'v13', source: 'leave_calculating', target: 'next_vendor', type: 'default' }, +]; + +export const PerformanceGenerateFlow: Automation.Flow = { + name: 'srm_performance_generate', + label: '生成绩效考评单', + type: 'schedule', + status: 'active', + runAs: 'system', + // 运行日:定时运行时为空(取今天);补跑某个月时传入该月的日期 + variables: [{ name: 'asOf', type: 'text', isInput: true, isOutput: false, defaultValue: '' }], + + nodes: [ + { id: 'start', type: 'start', label: '每月 1–7 日', config: { schedule: '0 3 1-7 * *' } }, + { + id: 'set_dates', + type: 'assignment', + label: '运行日与各周期的上一期', + config: { + assignments: { + runDay: c(`asOf != '' ? date(asOf) : today()`), + thisMonth: c(`date(isoDate(runDay).substring(0, 7) + '-01')`), + runMonth: c('runDay.getMonth()'), + periodEnd: c('isoDate(addDays(thisMonth, -1))'), + startMonthly: c('addMonths(thisMonth, -1)'), + startQuarterly: c('addMonths(thisMonth, -3)'), + startYearly: c('addMonths(thisMonth, -12)'), + }, + }, + }, + { + id: 'load_kpis', + type: 'get_record', + label: '读取启用的指标', + config: { objectName: 'srm_kpi', filter: { active: true }, limit: 200, outputVariable: 'kpis' }, + }, + { id: 'reset_weight', type: 'assignment', label: '权重合计清零', config: { assignments: { totalWeight: c('0.0') } } }, + { + id: 'sum_weights', + type: 'loop', + label: '合计启用指标权重', + config: { + collection: c('kpis'), + iteratorVariable: 'k', + body: { + nodes: [ + { + id: 'add_weight', + type: 'assignment', + label: '累加', + config: { assignments: { totalWeight: c(`totalWeight + (${val('k.weight')} ? double(k.weight) : 0.0)`) } }, + }, + ], + }, + }, + }, + { id: 'weights_ok', type: 'decision', label: '权重合计 100%?' }, + { + id: 'load_directors', + type: 'get_record', + label: '读取采购总监', + config: { objectName: 'sys_user_position', filter: { position: 'srm_procurement_director' }, limit: 50, outputVariable: 'directors' }, + }, + { + id: 'weight_pct', + type: 'assignment', + label: '权重合计(%)', + config: { assignments: { weightPct: c(`string(${round('totalWeight * 100.0', 2)})`) } }, + }, + { + id: 'notify_weights', + type: 'notify', + label: '通知采购总监', + config: { + recipients: [c('directors.map(p, p.user_id)')], + title: '本期绩效考评单未生成:启用指标权重合计不是 100%', + message: '指标库中启用指标的权重合计为 {{ weightPct }}%,应为 100%。请在「绩效指标 / 启用指标」调整权重后,由系统管理员补跑考评生成。', + severity: 'warning', + }, + }, + { + id: 'load_vendors', + type: 'get_record', + label: '读取活跃供应商', + config: { + objectName: 'srm_supplier', + filter: { status: { $in: ['qualified', 'frozen'] } }, + fields: ['id'], + limit: 20000, + outputVariable: 'vendors', + }, + }, + { + id: 'each_vendor', + type: 'loop', + label: '逐家供应商', + config: { + collection: c('vendors'), + iteratorVariable: 'vendor', + body: { + nodes: [ + { + id: 'guard_vendor', + type: 'try_catch', + label: '单家供应商', + config: { + try: { nodes: supplierBody, edges: supplierEdges }, + catch: { nodes: [{ id: 'skip_error', type: 'assignment', label: '跳过该供应商' }] }, + }, + }, + ], + }, + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'set_dates', type: 'default' }, + { id: 'e2', source: 'set_dates', target: 'load_kpis', type: 'default' }, + { id: 'e3', source: 'load_kpis', target: 'reset_weight', type: 'default' }, + { id: 'e4', source: 'reset_weight', target: 'sum_weights', type: 'default' }, + { id: 'e5', source: 'sum_weights', target: 'weights_ok', type: 'default' }, + { + id: 'e_ok', + source: 'weights_ok', + target: 'load_vendors', + type: 'conditional', + condition: c('kpis.size() > 0 && abs(totalWeight - 1.0) < 0.0001'), + label: '是', + }, + { + id: 'e_bad', + source: 'weights_ok', + target: 'load_directors', + type: 'conditional', + condition: c('!(kpis.size() > 0 && abs(totalWeight - 1.0) < 0.0001)'), + label: '否', + }, + { id: 'e6', source: 'load_directors', target: 'weight_pct', type: 'default' }, + { id: 'e7', source: 'weight_pct', target: 'notify_weights', type: 'default' }, + { id: 'e8', source: 'notify_weights', target: 'end', type: 'default' }, + { id: 'e9', source: 'load_vendors', target: 'each_vendor', type: 'default' }, + { id: 'e10', source: 'each_vendor', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/objects/index.ts b/src/core/objects/index.ts index 16f8c3e..edfe30f 100644 --- a/src/core/objects/index.ts +++ b/src/core/objects/index.ts @@ -17,3 +17,5 @@ export { Kpi } from './kpi.object.js'; export { SupplierAudit } from './supplier-audit.object.js'; export { SupplierAuditFinding } from './supplier-audit-finding.object.js'; export { RiskEvent } from './risk-event.object.js'; +export { PerformanceReview } from './performance-review.object.js'; +export { PerformanceScore } from './performance-score.object.js'; diff --git a/src/core/objects/performance-review.object.ts b/src/core/objects/performance-review.object.ts new file mode 100644 index 0000000..2322992 --- /dev/null +++ b/src/core/objects/performance-review.object.ts @@ -0,0 +1,121 @@ +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 加入, +// 这些步骤要写的字段(各维度得分、总分、等级、审定人、申诉)在这里先建好。 +// 同一供应商同一期间只有一张考评单(唯一索引兜底,flow 先查后建,重复运行不重复建)。 +// 门户上线(v0.6)时加 portal_position,供应商只看本公司已发布的考评单(04 §3)。 +export const PerformanceReview = ObjectSchema.create({ + name: 'srm_performance_review', + label: '绩效考评单', + pluralLabel: '绩效考评单', + icon: 'award', + // 内部只读(04 §2.3):考评单只由 flow 生成和推进,评分只经「提交评分」screen flow(v0.5 P3) + sharingModel: 'public_read', + enable: { trackHistory: true }, + stageField: 'status', + nameField: 'review_no', + + fieldGroups: [ + { key: 'basic', label: '基本信息', icon: 'award' }, + { key: 'metrics', label: '自动指标', icon: 'calculator' }, + { key: 'scores', label: '得分与等级', icon: 'star' }, + { key: 'appeal', label: '申诉', icon: 'message-square' }, + ], + + fields: { + review_no: Field.autonumber({ label: '考评单号', group: 'basic', autonumberFormat: 'PR{YYYY}{00000}' }), + supplier: Field.lookup('srm_supplier', { + label: '供应商', + group: 'basic', + required: true, + readonly: true, + relatedListColumns: ['review_no', 'period', 'cycle', 'total_score', 'grade', 'status', 'data_incomplete'], + }), + // 按供应商所供最高物资类别取参数 perf_cycle(03 §10 第 1 步) + cycle: Field.select({ + label: '考评周期', + group: 'basic', + required: true, + readonly: true, + options: [ + { label: '月度', value: 'monthly' }, + { label: '季度', value: 'quarterly' }, + { label: '年度', value: 'yearly' }, + ], + }), + // 月度 2026-10、季度 2026Q3、年度 2026 + period: Field.text({ label: '考评期间', group: 'basic', required: true, readonly: true, maxLength: 10 }), + period_start: Field.date({ label: '期间开始', group: 'basic', readonly: true }), + period_end: Field.date({ label: '期间结束', group: 'basic', readonly: true }), + status: Field.select({ + label: '状态', + group: 'basic', + required: true, + defaultValue: 'calculating', + options: [ + { label: '自动算分', value: 'calculating', kind: 'open' }, + { label: '人工评分', value: 'scoring', kind: 'open' }, + { label: '审定中', value: 'reviewing', kind: 'open' }, + { label: '申诉中', value: 'appealed', kind: 'open' }, + { label: '已发布', value: 'published', kind: 'won' }, + { label: '已作废', value: 'void', kind: 'lost' }, + ], + }), + // 本期没有交付记录或某项自动指标没有数据(07 R7):该项不给零分,留给人工评分环节处理 + data_incomplete: Field.boolean({ label: '数据不全', group: 'basic', defaultValue: false, readonly: true }), + reviewer: Field.user({ label: '审定人', group: 'basic' }), + + // 交付记录按期汇总(05 §4.1,口径同数据集 srm_delivery_metrics);没有数据的项为空 + otd_rate: Field.percent({ label: '交付准时率', group: 'metrics', readonly: true, scale: 2 }), + lot_pass_rate: Field.percent({ label: '批次合格率', group: 'metrics', readonly: true, scale: 2 }), + ppm: Field.number({ label: 'PPM', group: 'metrics', readonly: true, scale: 0 }), + // ERP 集成前为空 + ack_on_time_rate: Field.percent({ label: '订单响应及时率', group: 'metrics', readonly: true, scale: 2 }), + delivery_count: Field.number({ label: '交付行数', group: 'metrics', readonly: true, scale: 0 }), + + // 各维度得分与总分由汇总分级写入(03 §10 第 4 步,v0.5 P3),存储字段供看板使用 + quality_score: Field.number({ label: '质量得分', group: 'scores', readonly: true, scale: 2 }), + cost_score: Field.number({ label: '成本得分', group: 'scores', readonly: true, scale: 2 }), + delivery_score: Field.number({ label: '交付得分', group: 'scores', readonly: true, scale: 2 }), + service_score: Field.number({ label: '服务得分', group: 'scores', readonly: true, scale: 2 }), + hse_score: Field.number({ label: '安全环保得分', group: 'scores', readonly: true, scale: 2 }), + total_score: Field.number({ label: '总分', group: 'scores', readonly: true, scale: 2 }), + // 选项值至少两个字符,所以是 grade_a 而不是 a + grade: Field.select({ + label: '等级', + group: 'scores', + readonly: true, + options: [ + { label: 'A', value: 'grade_a' }, + { label: 'B', value: 'grade_b' }, + { label: 'C', value: 'grade_c' }, + { label: 'D', value: 'grade_d' }, + ], + }), + + appeal_reason: Field.textarea({ label: '申诉理由', group: 'appeal', maxLength: 2000 }), + appeal_result: Field.textarea({ label: '申诉处理结果', group: 'appeal', maxLength: 2000 }), + }, + + indexes: [{ name: 'srm_performance_review_period', fields: ['supplier', 'period'], unique: 'organization' }], + + validations: [ + { + name: 'review_status_flow', + type: 'state_machine', + field: 'status', + message: '考评单状态不允许这样变更', + initialStates: ['calculating'], + transitions: { + calculating: ['scoring', 'void'], + scoring: ['reviewing', 'void'], + reviewing: ['published', 'void'], + published: ['appealed'], + appealed: ['published'], + void: [], + }, + }, + ], +}); diff --git a/src/core/objects/performance-score.object.ts b/src/core/objects/performance-score.object.ts new file mode 100644 index 0000000..6ceca1e --- /dev/null +++ b/src/core/objects/performance-score.object.ts @@ -0,0 +1,28 @@ +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +// 评分明细(02 §4.7,FR-702):考评生成时按指标库中启用的指标各建一行,权重从指标快照过来,之后改指标库不影响 +// 已生成的考评单。自动指标由生成 flow 写入原始值与得分;人工指标由评分人经「提交评分」screen flow 打分(v0.5 P3)。 +// 原始值与指标的满分值、零分值同一单位:比率按百分点(98 即 98%),PPM 按 PPM。 +export const PerformanceScore = ObjectSchema.create({ + name: 'srm_performance_score', + label: '评分明细', + pluralLabel: '评分明细', + icon: 'list-checks', + sharingModel: 'controlled_by_parent', + + fields: { + review: Field.masterDetail('srm_performance_review', { + label: '考评单', + required: true, + deleteBehavior: 'cascade', + relatedListColumns: ['kpi', 'weight', 'raw_value', 'score', 'scored', 'scorer', 'comment'], + }), + kpi: Field.lookup('srm_kpi', { label: '指标', required: true, readonly: true }), + weight: Field.percent({ label: '权重', readonly: true, min: 0, max: 1 }), + raw_value: Field.number({ label: '原始值', readonly: true, scale: 2 }), + score: Field.number({ label: '得分', min: 0, max: 100, scale: 2 }), + scorer: Field.user({ label: '评分人' }), + comment: Field.textarea({ label: '评分说明', maxLength: 2000 }), + scored: Field.boolean({ label: '已评分', defaultValue: false }), + }, +}); diff --git a/src/core/security/permission-sets.ts b/src/core/security/permission-sets.ts index bb6941b..ba0e6a1 100644 --- a/src/core/security/permission-sets.ts +++ b/src/core/security/permission-sets.ts @@ -1,6 +1,7 @@ import { Supplier } from '../objects/supplier.object.js'; import { Contract } from '../objects/contract.object.js'; import { QualityIssue } from '../objects/quality-issue.object.js'; +import { PerformanceReview } from '../objects/performance-review.object.js'; // 内部权限集(04 §2.2)的 v0.1 子集。一律不设 isDefault(AGENTS.md 红线 2)。 // 状态类字段(供应商状态、资质异常、证书状态)只由 flow 写入,权限集里一律只读。 @@ -41,6 +42,9 @@ export const InternalBasePermissionSet = { srm_risk_event: read, // 绩效指标库内部只读(v0.5):评分人按评分规则说明打分 srm_kpi: read, + // 考评单与评分明细内部只读(04 §2.3):由 flow 生成和推进,评分只经「提交评分」screen flow(v0.5 P3) + srm_performance_review: read, + srm_performance_score: read, }, }; @@ -185,6 +189,8 @@ export const ExecutivePermissionSet = { srm_supplier_audit_finding: read, srm_risk_event: read, srm_kpi: read, + srm_performance_review: read, + srm_performance_score: read, }, }; @@ -225,6 +231,9 @@ export const FinancePermissionSet = { }, }; +// 管理员能删考评单要 modifyAllRecords,它连修改一起放开(objectstack#25470):考评单字段对管理员一律只读 +const reviewFieldsReadOnly = Object.fromEntries(Object.keys(PerformanceReview.fields).map((field) => [`srm_performance_review.${field}`, readOnly])); + // 系统管理员:维护品类与工厂等配置,执行物料导入和存量供应商、联系人、证书导入(04 §2.2) export const AdminPermissionSet = { name: 'srm_admin', @@ -241,12 +250,16 @@ export const AdminPermissionSet = { srm_delivery_record: { ...read, allowDelete: true, modifyAllRecords: true }, // 指标库的配置与清理(04 §2.2);删除自己没建的指标要 modifyAllRecords,同上 srm_kpi: { ...write, allowDelete: true, modifyAllRecords: true }, + // 删除错误生成的考评单(评分明细随之删除)后可补跑生成;删系统生成的记录要 modifyAllRecords,同交付记录 + srm_performance_review: { ...read, allowDelete: true, modifyAllRecords: true }, + srm_performance_score: { ...read, allowDelete: true }, }, fields: { 'srm_supplier.status': flowOwned, 'srm_supplier.qualification_alert': flowOwned, ...agreementFlags, 'srm_supplier_certificate.status': flowOwned, + ...reviewFieldsReadOnly, }, }; @@ -285,6 +298,8 @@ export const AuditorPermissionSet = { srm_supplier_audit_finding: read, srm_risk_event: read, srm_kpi: read, + srm_performance_review: read, + srm_performance_score: read, }, }; diff --git a/src/core/translations/en.ts b/src/core/translations/en.ts index 6ea6291..57c91e4 100644 --- a/src/core/translations/en.ts +++ b/src/core/translations/en.ts @@ -730,6 +730,73 @@ export const en: TranslationData = { by_type: { label: 'By Type' }, }, }, + srm_performance_review: { + label: 'Performance Review', + pluralLabel: 'Performance Reviews', + _sections: { + basic: { label: 'Basic Information' }, + metrics: { label: 'Automatic Metrics' }, + scores: { label: 'Scores and Grade' }, + appeal: { label: 'Appeal' }, + }, + _validations: { + review_status_flow: { message: 'This review status change is not allowed' }, + }, + fields: { + review_no: { label: 'Review No.' }, + supplier: { label: 'Supplier' }, + cycle: { label: 'Review Cycle', options: { monthly: 'Monthly', quarterly: 'Quarterly', yearly: 'Yearly' } }, + period: { label: 'Review Period' }, + period_start: { label: 'Period Start' }, + period_end: { label: 'Period End' }, + status: { + label: 'Status', + options: { + calculating: 'Auto Scoring', + scoring: 'Manual Scoring', + reviewing: 'Under Review', + appealed: 'Under Appeal', + published: 'Published', + void: 'Void', + }, + }, + data_incomplete: { label: 'Data Incomplete' }, + reviewer: { label: 'Reviewed By' }, + otd_rate: { label: 'On-time Delivery Rate' }, + lot_pass_rate: { label: 'Lot Acceptance Rate' }, + ppm: { label: 'PPM' }, + ack_on_time_rate: { label: 'Order Acknowledgement On-time Rate' }, + delivery_count: { label: 'Delivery Lines' }, + quality_score: { label: 'Quality Score' }, + cost_score: { label: 'Cost Score' }, + delivery_score: { label: 'Delivery Score' }, + service_score: { label: 'Service Score' }, + 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' } }, + appeal_reason: { label: 'Appeal Reason' }, + appeal_result: { label: 'Appeal Outcome' }, + }, + _views: { + default: { label: 'All Reviews' }, + scoring: { label: 'Awaiting Scores' }, + incomplete: { label: 'Data Incomplete' }, + }, + }, + srm_performance_score: { + label: 'Review Score Line', + pluralLabel: 'Review Score Lines', + fields: { + review: { label: 'Performance Review' }, + kpi: { label: 'KPI' }, + weight: { label: 'Weight' }, + raw_value: { label: 'Raw Value' }, + score: { label: 'Score' }, + scorer: { label: 'Scorer' }, + comment: { label: 'Scoring Note' }, + scored: { label: 'Scored' }, + }, + }, }, picklists: { srm_country: { @@ -913,6 +980,7 @@ export const en: TranslationData = { nav_supplier_audit: { label: 'Supplier Audits' }, group_performance: { label: 'Performance and Risk' }, nav_kpi: { label: 'Performance KPIs' }, + nav_performance_review: { label: 'Performance Reviews' }, 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 8387e02..9e7b25c 100644 --- a/src/core/views/index.ts +++ b/src/core/views/index.ts @@ -13,3 +13,4 @@ export { QualityIssueViews } from './quality-issue.view.js'; 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'; diff --git a/src/core/views/performance-review.view.ts b/src/core/views/performance-review.view.ts new file mode 100644 index 0000000..36db57e --- /dev/null +++ b/src/core/views/performance-review.view.ts @@ -0,0 +1,48 @@ +import { defineView } from '@objectstack/spec'; + +const data = { provider: 'object' as const, object: 'srm_performance_review' }; +const columns = [ + { field: 'review_no' }, + { field: 'supplier' }, + { field: 'cycle' }, + { field: 'period' }, + { field: 'status' }, + { field: 'delivery_count' }, + { field: 'otd_rate' }, + { field: 'lot_pass_rate' }, + { field: 'ppm' }, + { field: 'data_incomplete' }, + { field: 'total_score' }, + { field: 'grade' }, +]; +const sort = [{ field: 'period', order: 'desc' as const }, { field: 'review_no', order: 'desc' as const }]; + +// 05 §2:考评单按期间倒序;「待评分」给采购总监跟进人工评分,「数据不全」提示本期交付数据没导入或不全(07 R7) +export const PerformanceReviewViews = defineView({ + list: { + label: '全部考评单', + type: 'grid', + data, + columns, + sort, + }, + + listViews: { + scoring: { + label: '待评分', + type: 'grid', + data, + columns: columns.filter((c) => c.field !== 'status' && c.field !== 'total_score' && c.field !== 'grade'), + filter: [{ field: 'status', operator: 'equals', value: 'scoring' }], + sort, + }, + incomplete: { + label: '数据不全', + type: 'grid', + data, + columns: columns.filter((c) => c.field !== 'data_incomplete'), + filter: [{ field: 'data_incomplete', operator: 'equals', value: true }], + sort, + }, + }, +}); diff --git a/test/performance-generate-load.test.ts b/test/performance-generate-load.test.ts new file mode 100644 index 0000000..d140bc3 --- /dev/null +++ b/test/performance-generate-load.test.ts @@ -0,0 +1,70 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import config from '../objectstack.config'; +import { Category, DeliveryRecord, Material, PerformanceReview, PerformanceScore, Supplier, SupplierCategory } from '../src/core/objects'; + +// 07 R9:考评生成在一个 flow 里逐家处理,先量 200 家供应商(每家 1–2 个品类、本月 6 行交付记录)一次生成的耗时。 +// 结果记在 03 §10;数据量大、耗时长,默认不跑:SRM_LOAD_TEST=1 pnpm vitest run test/performance-generate-load.test.ts +const SUPPLIERS = Number(process.env.SRM_LOAD_SUPPLIERS ?? 200); +const LINES = Number(process.env.SRM_LOAD_LINES ?? 6); + +describe.skipIf(!process.env.SRM_LOAD_TEST)('performance review generation load', () => { + let stack: VerifyStack; + const scheduledWork = process.env.OS_AUTOMATION_SCHEDULED_WORK_ENABLED; + const system = async (object: string, doc: Record) => + (await stack.hooks.run(object, 'insert', doc, { system: true })) as { id: string }; + + beforeAll(async () => { + process.env.OS_AUTOMATION_SCHEDULED_WORK_ENABLED = 'true'; + stack = await bootStack(config); + const material = (await system(Material.name, { erp_material_no: 'LOAD-0001', material_code: 'LOAD-0001', name: '示例负载物料' })).id; + const a = (await system(Category.name, { code: 'LOAD_A', name: '示例负载 A 类品类', material_class: 'class_a' })).id; + const c = (await system(Category.name, { code: 'LOAD_C', name: '示例负载 C 类品类', material_class: 'class_c' })).id; + for (let n = 0; n < SUPPLIERS; n++) { + const { id } = await system(Supplier.name, { + name: `示例负载测试有限公司 ${n}`, + credit_code: `91000000MA0LD${String(n).padStart(5, '0')}`, + legacy_imported: true, + }); + await system(SupplierCategory.name, { supplier: id, category: a, status: 'qualified' }); + if (n % 2 === 0) await system(SupplierCategory.name, { supplier: id, category: c, status: 'qualified' }); + for (let i = 0; i < LINES; i++) { + await system(DeliveryRecord.name, { + erp_receipt_no: `LD${n}-${i}`, + erp_order_no: '4500070001', + erp_line_no: '10', + supplier: id, + material, + required_date: '2034-03-10', + receipt_date: i === 0 ? '2034-03-12' : `2034-03-${String(1 + (i % 28)).padStart(2, '0')}`, + received_quantity: 500, + inspection_result: 'pass', + defect_quantity: i === 1 ? 1 : undefined, + }); + } + } + }, 1_800_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(`generates and scores ${SUPPLIERS} monthly reviews in one run`, async () => { + const started = Date.now(); + const run = await stack.flows.run('srm_performance_generate', { asOf: '2034-04-01' }, { system: true }); + const first = Date.now() - started; + expect(run.success, JSON.stringify(run).slice(0, 2000)).toBe(true); + const reviews = await stack.rows(PerformanceReview.name, { period: '2034-03' }); + expect(reviews).toHaveLength(SUPPLIERS); + expect(reviews.every((r) => r.status === 'scoring' && r.delivery_count === LINES)).toBe(true); + expect((await stack.rows(PerformanceScore.name, {})).length).toBe(SUPPLIERS * 7); + + const again = Date.now(); + await stack.flows.run('srm_performance_generate', { asOf: '2034-04-02' }, { system: true }); + const rerun = Date.now() - again; + expect(await stack.rows(PerformanceReview.name, { period: '2034-03' })).toHaveLength(SUPPLIERS); + console.log(`[load] ${SUPPLIERS} suppliers × ${LINES} lines: first run ${first} ms, rerun ${rerun} ms`); + }, 1_800_000); +}); diff --git a/test/performance-review.test.ts b/test/performance-review.test.ts new file mode 100644 index 0000000..9ce0f78 --- /dev/null +++ b/test/performance-review.test.ts @@ -0,0 +1,357 @@ +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 { CYCLE } from '../src/core/flows/performance/generate.flow'; + +// v0.5 P2 考评单生成与自动算分(02 §4.7、§5.7,03 §10 第 1–2 步,FR-702,07 R7、R9):按供应商所供最高物资类别 +// 定周期,每期第一个月生成上期考评单并按启用指标建评分明细;OTD、批次合格率、PPM 从交付记录汇总,口径同 +// srm_delivery_metrics;没有交付记录时标记数据不全而不给零分;跑两遍不重复建;权重合计不是 100% 时不生成。 +// 运行日用 asOf 指定,测试数据全部在这里生成(示例公司名、示例品类),期间取 2030 年起,不与样例表格的 2026-09 混在一起。 +type Row = Record; + +describe('performance review generation', () => { + let stack: VerifyStack; + let seq = 0; + + const person = async (position: string) => { + const token = await stack.signUp(`perf-${position}-${++seq}@example.com`); + const context = await stack.contextFor(token); + const org = context.tenantId ? { organization_id: String(context.tenantId) } : {}; + await stack.seed('sys_user_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 }); + + // 定时类 flow 默认不挂(objectstack#25442);这里打开,与部署时打开开关一致 + 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 reviewsOf = async (supplier: string) => + (await stack.rows(PerformanceReview.name, { supplier })).sort((a, b) => String(a.period).localeCompare(String(b.period))); + const linesOf = async (review: string) => { + const lines = await stack.rows(PerformanceScore.name, { review }); + return Object.fromEntries(lines.map((line) => [codes.get(String(line.kpi))!, line])); + }; + + let codes: Map; + let material: string; + const category = async (materialClass: string) => { + const n = ++seq; + return (await system(Category.name, { code: `PERF${n}`, name: `示例绩效品类 ${n}`, material_class: materialClass })).id; + }; + // 存量导入的合格供应商,带若干品类资格 + const vendor = async (categories: string[], status = 'qualified') => { + const n = ++seq; + const { id } = await system(Supplier.name, { + name: `示例绩效测试有限公司 ${n}`, + credit_code: `91000000MA0PRF${String(n).padStart(4, '0')}`, + legacy_imported: true, + }); + for (const c of categories) await system(SupplierCategory.name, { supplier: id, category: c, status: 'qualified' }); + if (status !== 'qualified') await systemUpdate(Supplier.name, { id, status }); + return id; + }; + const deliver = (supplier: string, doc: Row) => + system(DeliveryRecord.name, { + erp_receipt_no: `PRF${String(++seq).padStart(7, '0')}`, + erp_order_no: '4500090001', + erp_line_no: '10', + supplier, + material, + required_date: doc.receipt_date, + received_quantity: 1000, + inspection_result: 'pass', + ...doc, + }); + + let classA: string; + let classB: string; + let classC: string; + + 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: 'PRF-0001', material_code: 'PRF-0001', name: '示例绩效物料' })).id; + classA = await category('class_a'); + classB = await category('class_b'); + classC = await category('class_c'); + }, 60_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 cycle per material class from the parameter', () => { + const yaml = readFileSync(new URL('../params/core.yaml', import.meta.url), 'utf8'); + const block = yaml.split(/\n(?=\S)/).find((b) => b.startsWith('perf_cycle:'))!; + const example = Object.fromEntries( + /example:\s*\{([^}]*)\}/.exec(block)![1]!.split(',').map((pair) => pair.split(':').map((s) => s.trim()) as [string, string]), + ); + const words = { monthly: '每月', quarterly: '每季度', yearly: '每年' } as const; + expect({ A: words[CYCLE.class_a], B: words[CYCLE.class_b], C: words[CYCLE.class_c] }).toEqual(example); + }); + + describe('cycles', () => { + let monthly: string; + let quarterly: string; + let yearly: string; + let frozen: string; + let potential: string; + let noGrants: string; + + beforeAll(async () => { + monthly = await vendor([classA, classC]); + quarterly = await vendor([classB, classC]); + yearly = await vendor([classC]); + frozen = await vendor([classA], 'frozen'); + potential = (await system(Supplier.name, { name: `示例潜在供应商 ${++seq}`, credit_code: `91000000MA0PRF${String(++seq).padStart(4, '0')}` })).id; + await system(SupplierCategory.name, { supplier: potential, category: classA, status: 'trial' }); + noGrants = await vendor([]); + await generate('2031-01-03'); + }, 60_000); + + it('generates last period\'s review for every class whose period starts this month, then moves it to scoring', async () => { + expect(await reviewsOf(monthly)).toMatchObject([ + { cycle: 'monthly', period: '2030-12', period_start: '2030-12-01', period_end: '2030-12-31', status: 'scoring' }, + ]); + expect(await reviewsOf(quarterly)).toMatchObject([ + { cycle: 'quarterly', period: '2030Q4', period_start: '2030-10-01', period_end: '2030-12-31', status: 'scoring' }, + ]); + expect(await reviewsOf(yearly)).toMatchObject([ + { cycle: 'yearly', period: '2030', period_start: '2030-01-01', period_end: '2030-12-31', status: 'scoring' }, + ]); + expect((await reviewsOf(monthly))[0]!.review_no).toMatch(/^PR\d{9}$/); + }); + + it('includes frozen suppliers and skips inactive ones and those without a category', async () => { + expect(await reviewsOf(frozen)).toMatchObject([{ period: '2030-12' }]); + expect(await reviewsOf(potential)).toHaveLength(0); + expect(await reviewsOf(noGrants)).toHaveLength(0); + }); + + it('builds one score line per active KPI with the weight snapshot', async () => { + const kpis = await stack.rows(Kpi.name, { active: true }); + const lines = await linesOf(String((await reviewsOf(quarterly))[0]!.id)); + expect(Object.keys(lines).sort()).toEqual(kpis.map((k) => String(k.code)).sort()); + for (const k of kpis) expect(lines[String(k.code)]!.weight).toBeCloseTo(Number(k.weight), 6); + expect(lines.ACK_ON_TIME).toBeUndefined(); + }); + + it('creates nothing new when run again, and only monthly reviews outside the first month of a quarter or year', async () => { + await generate('2031-01-05'); + for (const id of [monthly, quarterly, yearly, frozen]) expect(await reviewsOf(id)).toHaveLength(1); + + await generate('2031-02-02'); + expect((await reviewsOf(monthly)).map((r) => r.period)).toEqual(['2030-12', '2031-01']); + expect(await reviewsOf(quarterly)).toHaveLength(1); + expect(await reviewsOf(yearly)).toHaveLength(1); + }); + }); + + describe('automatic scoring', () => { + let supplier: string; + let quarterly: string; + let idle: string; + + beforeAll(async () => { + supplier = await vendor([classA]); + // 2032-06:10 行,9 行准时;9 行合格、1 行不合格;实收 10,000,不良 3 + for (let i = 0; i < 10; i++) { + await deliver(supplier, { + receipt_date: '2032-06-10', + required_date: i === 0 ? '2032-06-05' : '2032-06-12', + inspection_result: i === 1 ? 'fail' : 'pass', + severity: i === 1 ? 'minor' : undefined, + defect_quantity: i === 1 ? 2 : i === 2 ? 1 : undefined, + }); + } + // 上月的不计入 + await deliver(supplier, { receipt_date: '2032-05-31', required_date: '2032-05-01', inspection_result: 'fail', severity: 'major', defect_quantity: 500 }); + + // 季度:7 月、9 月各一行,8 月一行让步接收;未检行不进合格率分母 + quarterly = await vendor([classB]); + await deliver(quarterly, { receipt_date: '2032-07-03' }); + await deliver(quarterly, { receipt_date: '2032-08-15', inspection_result: 'concession', defect_quantity: 10 }); + await deliver(quarterly, { receipt_date: '2032-09-30', inspection_result: 'not_inspected' }); + + idle = await vendor([classA]); + await generate('2032-07-01'); + }, 60_000); + + it('writes OTD, lot acceptance rate and PPM from the period\'s delivery records', async () => { + const [review] = await reviewsOf(supplier); + expect(review).toMatchObject({ period: '2032-06', delivery_count: 10, data_incomplete: false, status: 'scoring' }); + expect(review!.otd_rate).toBeCloseTo(0.9, 6); + expect(review!.lot_pass_rate).toBeCloseTo(0.9, 6); + expect(review!.ppm).toBe(300); + expect(review!.ack_on_time_rate ?? null).toBeNull(); + }); + + it('scores automatic KPIs linearly between their zero and full values and leaves manual ones to scorers', async () => { + const lines = await linesOf(String((await reviewsOf(supplier))[0]!.id)); + // OTD 90%:满分 98、零分 78 → 60;批次合格率 90%:满分 99、零分 89 → 10;PPM 300:满分 100、零分 5000 → 95.92 + expect(lines.OTD).toMatchObject({ raw_value: 90, score: 60, scored: true }); + expect(lines.LOT_PASS).toMatchObject({ raw_value: 90, score: 10, scored: true }); + expect(lines.PPM).toMatchObject({ raw_value: 300, scored: true }); + expect(lines.PPM!.score).toBeCloseTo(95.92, 2); + for (const code of ['PLAN_FULFIL', 'PRICE', 'SERVICE', 'SAFETY_ENV']) { + expect(lines[code]).toMatchObject({ scored: false }); + expect(lines[code]!.score ?? null).toBeNull(); + } + }); + + it('sums a quarter over its three months', async () => { + const [review] = await reviewsOf(quarterly); + expect(review).toMatchObject({ period: '2032Q2' }); + // 2032-07-01 生成的是二季度;三季度的交付等 10 月生成 + expect(review!.delivery_count).toBe(0); + await generate('2032-10-04'); + const q3 = (await reviewsOf(quarterly)).find((r) => r.period === '2032Q3')!; + expect(q3).toMatchObject({ delivery_count: 3, data_incomplete: false }); + expect(q3.otd_rate).toBeCloseTo(1, 6); + expect(q3.lot_pass_rate).toBeCloseTo(0.5, 6); + expect(q3.ppm).toBeCloseTo(3333, 0); + }); + + it('marks a period without delivery records as incomplete instead of scoring zero', async () => { + const [review] = await reviewsOf(idle); + expect(review).toMatchObject({ period: '2032-06', delivery_count: 0, data_incomplete: true, status: 'scoring' }); + expect(review!.otd_rate ?? null).toBeNull(); + const lines = await linesOf(String(review!.id)); + for (const code of ['OTD', 'LOT_PASS', 'PPM']) { + expect(lines[code]).toMatchObject({ scored: false }); + expect(lines[code]!.score ?? null).toBeNull(); + expect(String(lines[code]!.comment)).toContain('交付'); + } + }); + }); + + // 门槛 1 第 6 步前半:考评单上的三项自动指标与导入的交付记录一致(同数据集 srm_delivery_metrics 的口径) + describe('imported sample', () => { + type Described = { name: string; fields: Record }; + const sample = (file: string) => readFileSync(new URL(`../data/samples/${file}`, import.meta.url), 'utf8'); + const importAs = async (token: string, object: Described, csv: string, matchFields: string[], skipBlankMatchKey = false) => { + const mapping = Object.fromEntries(Object.entries(object.fields).map(([name, field]) => [String(field.label), name])); + const res = await stack.apiAs(token, 'POST', `/data/${object.name}/import`, { format: 'csv', csv, mapping, writeMode: 'upsert', matchFields, skipBlankMatchKey }); + const body = (await res.json()) as { errors: number; data?: { errors: number } }; + expect(res.status, JSON.stringify(body)).toBe(200); + expect((body.data ?? body).errors, JSON.stringify(body)).toBe(0); + }; + let sqe: string; + + beforeAll(async () => { + const admin = (await person('srm_admin')).token; + sqe = (await person('srm_sqe')).token; + await importAs(admin, Material, sample('srm_material.csv'), ['erp_material_no']); + await importAs(admin, Supplier, sample('srm_supplier.csv'), ['credit_code'], true); + await importAs(admin, Supplier, sample('srm_supplier.csv'), ['country', 'registration_no'], true); + await importAs(sqe, DeliveryRecord, sample('srm_delivery_record.csv'), ['erp_receipt_no', 'erp_order_no', 'erp_line_no']); + // 样例供应商都按 A 类(月度)考评 + for (const row of await stack.rows(Supplier.name, { status: 'qualified' })) { + if ((await stack.rows(SupplierCategory.name, { supplier: row.id })).length === 0) { + await system(SupplierCategory.name, { supplier: row.id, category: classA, status: 'qualified' }); + } + } + await generate('2026-10-02'); + }, 120_000); + + it('matches the delivery metrics dataset for every supplier delivering in the period', async () => { + const res = await stack.apiAs(sqe, 'POST', '/analytics/dataset/query', { + datasetName: 'srm_delivery_metrics', + selection: { dimensions: ['period', 'supplier'], measures: ['line_count', 'otd_rate', 'lot_pass_rate', 'ppm'] }, + }); + const body = (await res.json()) as { rows: Array> }; + expect(res.status, JSON.stringify(body)).toBe(200); + const names = new Map((await stack.rows(Supplier.name, {})).map((r) => [String(r.name), String(r.id)])); + + let compared = 0; + for (const metric of body.rows.filter((r) => r.period === '2026-09')) { + const supplier = names.get(String(metric.supplier)) ?? String(metric.supplier); + const review = (await reviewsOf(supplier)).find((r) => r.period === '2026-09'); + if ((await stack.rows(Supplier.name, { id: supplier }))[0]!.status !== 'qualified') { + expect(review, String(metric.supplier)).toBeUndefined(); + continue; + } + expect(review, String(metric.supplier)).toBeTruthy(); + expect(review!.delivery_count).toBe(metric.line_count); + expect(Number(review!.otd_rate)).toBeCloseTo(Number(metric.otd_rate), 4); + if (metric.lot_pass_rate == null) { + expect(review!.lot_pass_rate ?? null).toBeNull(); + expect(review!.data_incomplete).toBe(true); + } else { + expect(Number(review!.lot_pass_rate)).toBeCloseTo(Number(metric.lot_pass_rate), 4); + } + expect(Number(review!.ppm)).toBeCloseTo(Number(metric.ppm ?? 0), 0); + compared++; + } + expect(compared).toBeGreaterThanOrEqual(6); + }); + }); + + describe('guards', () => { + it('generates nothing and tells the procurement director when active weights do not add up to 100%', async () => { + const director = await person('srm_procurement_director'); + const supplier = await vendor([classA]); + const [service] = await stack.rows(Kpi.name, { code: 'SERVICE' }); + await systemUpdate(Kpi.name, { id: service!.id, active: false }); + try { + await generate('2033-03-02'); + expect(await reviewsOf(supplier)).toHaveLength(0); + const notice = async () => + (await stack.rows('sys_notification', {})).find((n) => String((n.payload as Row | undefined)?.title).includes('权重合计')); + await expect.poll(notice).toBeTruthy(); + expect(String(((await notice())!.payload as Row).body)).toContain('90%'); + await expect.poll(async () => JSON.stringify(await stack.rows('sys_notification_receipt', { notification_id: (await notice())!.id }))).toContain(director.id); + } finally { + await systemUpdate(Kpi.name, { id: service!.id, active: true }); + } + await generate('2033-03-02'); + expect(await reviewsOf(supplier)).toMatchObject([{ period: '2033-02', status: 'scoring' }]); + }); + + it('rebuilds a review a failed run left in auto scoring', async () => { + const supplier = await vendor([classA]); + const stale = await system(PerformanceReview.name, { supplier, cycle: 'monthly', period: '2033-04', status: 'calculating' }); + await generate('2033-05-01'); + const [review] = await reviewsOf(supplier); + expect(review).toMatchObject({ period: '2033-04', status: 'scoring' }); + expect(review!.id).not.toBe(stale.id); + expect(await stack.rows(PerformanceReview.name, { id: stale.id })).toHaveLength(0); + expect(Object.keys(await linesOf(String(review!.id)))).toHaveLength(7); + }); + + it('refuses a second review for the same supplier and period', async () => { + const supplier = await vendor([classA]); + await system(PerformanceReview.name, { supplier, cycle: 'monthly', period: '2033-06', status: 'calculating' }); + await expect(system(PerformanceReview.name, { supplier, cycle: 'monthly', period: '2033-06', status: 'calculating' })).rejects.toBeTruthy(); + }); + + it('keeps reviews read-only for internal positions; only the SRM admin deletes', async () => { + const supplier = await vendor([classA]); + await generate('2033-08-01'); + const [review] = await reviewsOf(supplier); + for (const position of ['srm_procurement_director', 'srm_buyer', 'srm_sqe', 'srm_category_manager', 'srm_general_manager']) { + const { token } = await person(position); + expect(await stack.rows(PerformanceReview.name, { id: review!.id }, { as: token })).toHaveLength(1); + expect((await stack.rows(PerformanceScore.name, { review: review!.id }, { as: token })).length).toBe(7); + await expect(stack.hooks.run(PerformanceReview.name, 'update', { id: review!.id, total_score: 99 }, { as: token })).rejects.toBeTruthy(); + await expect( + stack.hooks.run(PerformanceReview.name, 'insert', { supplier, cycle: 'monthly', period: '2033-09' }, { as: token }), + ).rejects.toBeTruthy(); + } + const admin = await person('srm_admin'); + await expect(stack.hooks.run(PerformanceReview.name, 'update', { id: review!.id, total_score: 99 }, { as: admin.token })).rejects.toBeTruthy(); + await stack.hooks.run(PerformanceReview.name, 'delete', { id: review!.id }, { as: admin.token }); + expect(await stack.rows(PerformanceReview.name, { id: review!.id })).toHaveLength(0); + expect(await stack.rows(PerformanceScore.name, { review: review!.id })).toHaveLength(0); + }); + }); +}); diff --git a/test/permissions.test.ts b/test/permissions.test.ts index ebe151d..54ae8df 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -68,6 +68,8 @@ describe('internal permissions', () => { "srm_delivery_record": "R", "srm_kpi": "R", "srm_material": "R", + "srm_performance_review": "R", + "srm_performance_score": "R", "srm_plant": "R", "srm_quality_issue": "RE", "srm_risk_event": "R", @@ -93,6 +95,8 @@ describe('internal permissions', () => { "srm_delivery_record": "R", "srm_kpi": "R", "srm_material": "RE", + "srm_performance_review": "R", + "srm_performance_score": "R", "srm_plant": "R", "srm_quality_issue": "R", "srm_risk_event": "RCE", @@ -120,6 +124,8 @@ describe('internal permissions', () => { "srm_delivery_record": "R", "srm_kpi": "R", "srm_material": "R", + "srm_performance_review": "R", + "srm_performance_score": "R", "srm_plant": "R", "srm_quality_issue": "R", "srm_risk_event": "R", @@ -144,6 +150,8 @@ describe('internal permissions', () => { "srm_delivery_record": "R", "srm_kpi": "R", "srm_material": "R", + "srm_performance_review": "R", + "srm_performance_score": "R", "srm_plant": "R", "srm_quality_issue": "R", "srm_risk_event": "R", @@ -170,6 +178,8 @@ describe('internal permissions', () => { "srm_delivery_record": "R", "srm_kpi": "R", "srm_material": "R", + "srm_performance_review": "R", + "srm_performance_score": "R", "srm_plant": "R", "srm_quality_issue": "R", "srm_risk_event": "R", @@ -197,6 +207,8 @@ describe('internal permissions', () => { "srm_delivery_record": "R", "srm_kpi": "RCE", "srm_material": "RE", + "srm_performance_review": "R", + "srm_performance_score": "R", "srm_plant": "R", "srm_quality_issue": "R", "srm_risk_event": "R", @@ -225,6 +237,8 @@ describe('internal permissions', () => { "srm_delivery_record": "RCE", "srm_kpi": "R", "srm_material": "R", + "srm_performance_review": "R", + "srm_performance_score": "R", "srm_plant": "R", "srm_quality_issue": "R", "srm_risk_event": "R",