From e7fdff239a1d85f85d7ae623ccaa6edcc5247b35 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 16:05:42 +0000 Subject: [PATCH] feat(core): v0.5 R1 risk events and the blacklist register - srm_risk_event (risk_type picklist, severity, source system/manual, certificate link, status open/mitigating/closed with a required conclusion on close); internal read, CM/SQE create and edit - qualification recalc opens a system qualification event when a mandatory certificate expires (once per certificate while open) and closes those events when the alert clears - srm_risk_event_apply: a high-severity event drafts a freeze disposition (or joins the open draft) and notifies the category managers - dispositions gain draft / cancelled, submitted_by, and the submit / cancel screen flows; approval starts on create or on draft submission with the submitter as initiator - blacklist register fields on the supplier, the register view and srm_blacklist_expiry_notice (30 days before and on the ban end date) - supplier dashboard with lifecycle, alert, blacklist and open-risk cards - docs 02/03/04/05 and GL-08 control table synced; tests added Refs #54 Claude-Session: https://claude.ai/code/session_012bYgpNwJq1cQtSUv9r8uuy Co-authored-by: Claude --- docs/design/02-domain-model.md | 36 ++- docs/design/03-processes-and-automation.md | 20 +- docs/design/04-security-and-portal.md | 2 + docs/design/05-experience-and-analytics.md | 4 +- governance/templates/core/GL-08.md | 4 + src/core/actions/disposition.actions.ts | 27 ++ src/core/actions/index.ts | 1 + src/core/analytics/index.ts | 2 + src/core/analytics/risk.dataset.ts | 44 +++ src/core/analytics/supplier.dashboard.ts | 67 ++++ src/core/apps/workbench.app.ts | 10 + .../disposition/disposition-apply.flow.ts | 14 +- .../disposition/disposition-approval.flow.ts | 20 +- .../disposition/disposition-cancel.flow.ts | 75 +++++ .../disposition/disposition-submit.flow.ts | 76 +++++ src/core/flows/index.ts | 4 + .../qualification-recalc.flow.ts | 66 +++- .../risk/blacklist-expiry-notice.flow.ts | 87 ++++++ src/core/flows/risk/risk-event-apply.flow.ts | 149 +++++++++ src/core/index.ts | 15 +- src/core/objects/index.ts | 1 + src/core/objects/risk-event.object.ts | 112 +++++++ .../objects/supplier-disposition.object.ts | 56 +++- src/core/objects/supplier.object.ts | 18 ++ src/core/picklists/index.ts | 1 + src/core/picklists/risk-type.picklist.ts | 17 ++ src/core/security/permission-sets.ts | 8 + src/core/translations/en.ts | 136 ++++++++- src/core/views/index.ts | 1 + src/core/views/risk-event.view.ts | 59 ++++ src/core/views/supplier-disposition.view.ts | 9 + src/core/views/supplier.view.ts | 25 ++ test/permissions.test.ts | 6 + test/risk-event.test.ts | 286 ++++++++++++++++++ 34 files changed, 1419 insertions(+), 39 deletions(-) create mode 100644 src/core/actions/disposition.actions.ts create mode 100644 src/core/analytics/risk.dataset.ts create mode 100644 src/core/analytics/supplier.dashboard.ts create mode 100644 src/core/flows/disposition/disposition-cancel.flow.ts create mode 100644 src/core/flows/disposition/disposition-submit.flow.ts create mode 100644 src/core/flows/risk/blacklist-expiry-notice.flow.ts create mode 100644 src/core/flows/risk/risk-event-apply.flow.ts create mode 100644 src/core/objects/risk-event.object.ts create mode 100644 src/core/picklists/risk-type.picklist.ts create mode 100644 src/core/views/risk-event.view.ts create mode 100644 test/risk-event.test.ts diff --git a/docs/design/02-domain-model.md b/docs/design/02-domain-model.md index a463577..b51f7a5 100644 --- a/docs/design/02-domain-model.md +++ b/docs/design/02-domain-model.md @@ -212,6 +212,9 @@ erDiagram | `expired_certificate_count` | summary | `count` 资质证书,条件 `status == 'expired'` | | `default_bank_account_count` | summary | `count` 银行账户,条件 `is_default == true`;「每家至多一个默认账户」的校验读它(v0.2) | | `open_disposition_count` | summary | `count` 处置单,条件 `status == 'in_review'`;「同一家供应商同时只能有一张审批中的处置单」的校验读它(v0.3) | +| `blacklisted_on` / `blacklist_until` | date(只读) | 黑名单名录(v0.5):拉入黑名单的处置单执行时写入拉黑日期和禁入期满日(拉黑日期 + `blacklist_years` 年)。期满前 30 天和当天由 `srm_blacklist_expiry_notice` 提醒,只提醒,解除仍走「解除黑名单」处置单;解除后保留为最近一次拉黑的记录 | +| `blacklist_disposition` / `blacklist_reason` | lookup → 处置单 / textarea(只读) | 拉黑的处置单与处置原因,与上两项同时写入(v0.5) | +| `open_risk_event_count` | summary | `count` 风险事件,条件 `status in ['open', 'mitigating']`(v0.5) | 关键信息锁定(v0.2):`name`、`country`、`credit_code`、`registration_no`、`legal_representative`、 `registered_capital`、`address` 在 `potential` / `qualifying` 期间可以直接改;准入通过后(合格、冻结、 @@ -387,12 +390,18 @@ upsert 写入,结构不变。 `disposition_no`(自动编号 `DSP{YYYY}{00000}`)、`supplier`、`action` (`freeze` / `unfreeze` / `eliminate` / `blacklist` / `remove_blacklist`)、 `scope`(`all` 全部品类 / `category` 指定品类,只有冻结和解冻可以指定品类)、`categories`(多值 lookup 品类, -指定品类时必填)、`reason`(必填)、`effective_date`(默认当天)、`status`(`in_review` 审批中 / `applied` 已执行 / -`rejected` 已驳回)、`approval_status`(审批节点写入)、只读的 `category_approver`(发起人不是品类经理时才有 +指定品类时必填)、`reason`(必填)、`effective_date`(默认当天)、`status`(`draft` 草稿 / `in_review` 审批中 / +`applied` 已执行 / `rejected` 已驳回 / `cancelled` 已作废;草稿与作废随 v0.5)、`submitted_by`(user,只读:草稿的提交人,v0.5)、 +`approval_status`(审批节点写入)、只读的 `category_approver`(发起人不是品类经理时才有 这一级,[03 §5](./03-processes-and-automation.md#5-p2-信息变更与供应商处置))/ `director_approver` / `gm_approver` / `compliance_approver`、`reject_reason`、`applied_at`。 -新建即进入审批,供应商、动作、范围和品类只在新建时填写,原因和生效日期在被退回修改期间可改。审批通过即执行, +新建即进入审批,供应商、动作、范围和品类只在新建时填写,原因和生效日期在被退回修改期间可改。 +**草稿**(v0.5)只由 flow 生成(高风险事件,[03 §11](./03-processes-and-automation.md#11-p10-资质与风险监控资质-v01风险事件-v05); +绩效结果应用,03 §10),原因由 flow 写好;品类经理或采购主管经「提交审批」进入审批(提交人写入 `submitted_by` 并成为记录所有者, +审批以它为发起人),或经「作废」放弃(作废理由写入 `reject_reason`)。状态机:`draft` → `in_review` / `cancelled`, +`in_review` → `applied` / `rejected`;`initialStates` 为 `in_review`、`draft`。提交时再校验一次动作与供应商当前状态、 +是否已有审批中的处置单(草稿期间供应商状态可能已变)。审批通过即执行, `effective_date` 记录业务生效日,供采购员在 ERP 同步冻结标志时参照。新建时校验动作与供应商当前状态 ([§5.1](#51-供应商-srm_supplierstatus)): @@ -677,6 +686,26 @@ ERP 集成前为空)、`reviewer`(user)、`appeal_reason`、`appeal_result `description`、`status`(`open` / `mitigating` / `closed`)、`mitigation_plan`、`owner_note`、 `disposition`(lookup,`high` 级事件生成的处置单草稿)。 +实现(v0.5):OWD 暂用 `public_read_write`(系统生成的事件要能由品类经理、SQE 互相接手,`modifyAllRecords` 会连删除一起 +放开,[objectstack#25470](https://github.com/objectstack-ai/objectstack/issues/25470)),开启字段历史,门户不可见。 + +| 字段 | 类型 | 说明 / 约束 | +|---|---|---| +| `event_no` | autonumber | `RSK{YYYY}{00000}` | +| `supplier` | lookup → 供应商 | 必填,新建后只读;供应商详情页的风险事件列表 | +| `risk_type` | select | 共享选项列表 `srm_risk_type`,必填 | +| `severity` | select | `high` / `medium` / `low`,默认 `medium`;`high` 触发 `srm_risk_event_apply` | +| `source` | select(只读) | 手工登记为 `manual`(默认值);`system` 只由 flow 写入;`external_api` 预留 | +| `certificate` | lookup → 资质证书(只读) | 资质类系统事件指回过期的必备证书;同一证书有未关闭的事件时不重复建 | +| `description` | textarea | 必填 | +| `status` | select(stageField) | `open` 未处理(open)→ `mitigating` 处理中(open)/ `closed` 已关闭(won);`mitigating` → `open` / `closed`;`closed` → `open`(关错或风险复现时重新打开) | +| `mitigation_plan` | textarea | 应对措施 | +| `owner_note` | textarea | 处理结论,`closed` 时必填(`requiredWhen`);资质恢复自动关闭时由 flow 写入 | +| `disposition` | lookup → 处置单(只读) | 高风险事件生成或并入的冻结处置单草稿 | +| `external_ref` | text(只读) | 外部服务的事件编号,外部拉取上线后按它去重 | + +关闭后类型、严重度、描述、应对措施只读。 + ## 5. 状态机 每个状态字段都是所在对象的 `stageField`。表中「kind」列即选项的 `kind`;「允许 @@ -790,6 +819,7 @@ ERP 集成前为空)、`reviewer`(user)、`appeal_reason`、`appeal_result | 整改单 | `SCAR{YYYY}{00000}` | SCAR202600007 | 按年 | | 供应商审核 | `AUD{YYYY}{00000}` | AUD202600012 | 按年 | | 考评单 | `PR{YYYY}{00000}` | PR202600210 | 按年 | +| 风险事件 | `RSK{YYYY}{00000}` | RSK202600009 | 按年 | | 送货单(预留) | `ASN{YYYYMMDD}{0000}` | ASN202610220015 | 按日 | 物料、交付记录、工厂沿用 ERP 编号,不在 SRM 内另行编号。 diff --git a/docs/design/03-processes-and-automation.md b/docs/design/03-processes-and-automation.md index fb4f247..fabd216 100644 --- a/docs/design/03-processes-and-automation.md +++ b/docs/design/03-processes-and-automation.md @@ -236,6 +236,13 @@ ERP 侧的银行账户由财务按 SRM 的审批记录维护(ERP 集成为预 - 执行以系统身份改供应商状态和品类资格(结果见 [02 §4.2](./02-domain-model.md#42-生命周期) 处置单), 处置单置为「已执行」,通知发起人并提醒在 ERP 同步冻结标志;驳回不改供应商,记下驳回意见。 - 通知供应商与门户账号回收随门户(v0.6);冻结后不能新建合同由合同的校验规则负责(v0.3 C1)。 +- 处置单草稿(v0.5):高风险事件(§11)和绩效结果应用(§10 第 6 步)由 flow 生成草稿,不直接发起审批。品类经理或 + 采购主管在草稿上点「提交审批」(screen flow `srm_disposition_submit`),审批 flow 在草稿变为审批中时发起,发起人 + 是提交人(`submitted_by`;手工新建的处置单是新建人),品类经理一级的判断与 BR-11 比对都按发起人;不需要处置时点 + 「作废」(`srm_disposition_cancel`,写明理由)。提交时再校验一次动作与供应商当前状态、是否已有审批中的处置单。 +- 黑名单名录(v0.5):拉入黑名单执行时把拉黑日期、禁入期满日(拉黑日期 + `blacklist_years` 年)、处置单和原因写到 + 供应商上,「黑名单名录」视图列出全部黑名单供应商。`srm_blacklist_expiry_notice` 在期满前 30 天和当天通知拉黑处置单 + 的发起人和供应商各品类的品类经理;期满不自动解除,解除仍发起「解除黑名单」处置单,按 §12 审批。 ## 6. P4 合同审批、签署与到期(v0.3) @@ -391,8 +398,15 @@ stateDiagram-v2 | 供应商修改证书 | 证书的编号、有效期或附件被门户修改 | flow 把 `verified` 重置为 false,通知 SQE 重新核验 | | 资质恢复 | 证书或品类资格写入后的记录触发(`srm_supplier_qualification_recalc`) | 先按该供应商全部品类的 `mandatory_cert_types` 重算每张证书的 `is_mandatory`;再重新计算:每一种必备资质类型都有 `status == 'valid'` 且 `verified == true` 的证书时,`qualification_alert = false`(v0.5 起关闭对应风险事件)。只上传新证书而未经 SQE 核验,不会解除异常 | | 风险事件处理 | 手工登记或系统生成(v0.5) | `high` 级事件通知品类经理并生成处置单草稿(冻结);关闭时填写处理结论 | +| 资质风险事件 | `srm_supplier_qualification_recalc`(证书状态或「必备」标志变化后) | 这张证书是必备证书且变为已过期时,新建 `qualification` 类、`source = system`、中风险的风险事件,`certificate` 指回证书;这张证书已有未关闭的事件时不再建。资质异常解除时关闭该供应商未关闭的系统资质事件,写处理结论;手工登记的事件不动 | | 外部风险 | `schedule`:每周一 04:00(客户驱动主题) | flow 按批 `loop` 合格供应商,`http` 节点调用企业信息服务;新出现的经营异常、失信、重大诉讼、股权变更按 `external_ref` 去重后建风险事件(FR-903)。有客户要求时立项 | +> 风险事件处理(v0.5,`srm_risk_event_apply`):事件新建为 `high` 或改为 `high`、还没有处置单时触发。供应商为合格且没有 +> 审批中的处置单时生成冻结(全部品类)草稿,原因写事件编号与描述;已有冻结草稿时不再建,事件指向那张草稿(多个高风险 +> 事件合并到一张处置单);供应商不是合格状态或已有审批中的处置单时只通知。通知供应商各品类的品类经理,品类都没有品类 +> 经理时通知全部品类经理。草稿由品类经理核实后提交或作废(§5)。风险事件由品类经理、SQE 登记和处理,内部其余岗位只读 +> (04 §2.3)。 + > v0.1 先实现其中的一部分:`srm_certificate_expire_sweep` 每日一次,把证书状态对齐到已过期 / 即将到期(30 天内)/ > 有效(有效期被延长时恢复);`srm_supplier_qualification_recalc` 在证书状态或「必备」标志变化后重算 > `qualification_alert`。到期提醒通知随 v0.1 收尾加入;`is_mandatory` 按品类必备资质自动写入随 v0.2;「经 SQE 核验才解除」 @@ -446,7 +460,7 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_admission_approval` | record_change:状态→`admission_review` | 准入申请 | v0.1 | | `srm_certificate_expiry_notice` | time_relative:`valid_to` −30 / −15 / −7 天 | 资质证书 | v0.1 | | `srm_certificate_expire_sweep` | schedule:每日 01:00(追赶式) | 资质证书 | v0.1 | -| `srm_supplier_qualification_recalc` | record_change:证书、品类资格写入 | 资质证书 → 供应商 | v0.1 | +| `srm_supplier_qualification_recalc` | record_change:证书、品类资格写入 | 资质证书 → 供应商(v0.5 起 → 风险事件) | v0.1 | | `srm_change_approval` / `srm_change_apply` | record_change:变更单新建 / 子 flow | 变更单 → 供应商、银行账户、联系人 | v0.2 | | `srm_contract_approval` | record_change:状态→`in_review` | 合同 | v0.3 | | `srm_contract_recalled` | record_change:审批中的合同审批结论→`recalled` | 合同 | v0.3 | @@ -463,7 +477,8 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_performance_generate` | schedule:每月 1 日(按类别周期) | 考评单 | 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 | +| `srm_risk_event_apply` | record_change:风险事件新建或改为高风险 | 风险事件 → 处置单(草稿) | v0.5 | +| `srm_blacklist_expiry_notice` | time_relative:供应商 `blacklist_until` −30 / 0 天 | 供应商(黑名单) | v0.5 | | `srm_invitation_send` / `srm_invitation_expire_sweep` | record_change / schedule | 门户邀请 | v0.6 | | `srm_portal_position_backfill` | record_change:供应商 `portal_position` 写入 | 供应商 → 带 `portal_position` 的单据 | v0.6 | | `srm_single_source_approval` | record_change:状态→`approval_pending` | 寻源单 | v0.7 | @@ -487,6 +502,7 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_contract_activate` 确认生效 | 合同(签署中) | 采购员、采购主管、品类经理 | v0.3 | | `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_portal_enable` 开通门户 | 供应商联系人 | `srm_supplier_admin` | v0.6 | diff --git a/docs/design/04-security-and-portal.md b/docs/design/04-security-and-portal.md index c6ed1e6..2abcc3c 100644 --- a/docs/design/04-security-and-portal.md +++ b/docs/design/04-security-and-portal.md @@ -104,6 +104,8 @@ R 读 · C 建 · E 改 · D 删 · F 仅经 flow / screen flow 写入 · — 表中未列的权限集:`srm_executive`、`srm_auditor` 对全部对象只读(报价同样受封标约束); `srm_legal` 对合同与明细读改(不新建);`srm_iqc` 对检验增改;绩效评分只经「提交评分」screen flow。 +风险事件的 OWD 暂为 `public_read_write`(系统生成的事件要由品类经理、SQE 互相接手,objectstack#25470),来源、相关证书、 +处置单三个字段只经 F 写入(v0.5)。 **`controlled_by_parent` 的写入规则**:子记录(联系人、证书、银行账户、合同明细、报价 明细)的修改要求对父记录有编辑权。所以门户对供应商、报价单都授予编辑权,再用字段级 diff --git a/docs/design/05-experience-and-analytics.md b/docs/design/05-experience-and-analytics.md index 4869437..0e6dd46 100644 --- a/docs/design/05-experience-and-analytics.md +++ b/docs/design/05-experience-and-analytics.md @@ -27,7 +27,7 @@ │ └── 供应商审核 计划 / 问题项 ├── 绩效与风险 (v0.5) │ ├── 考评单 本期 / 待评分 -│ └── 风险事件 +│ └── 风险事件 风险看板(按严重度)/ 未关闭 / 按类型;黑名单名录在供应商列表视图 ├── 寻源 (v0.7) │ ├── 寻源单 我的 / 报价中 / 待开标 / 评标中 / 定标审批中 │ ├── 报价 (开标后可见) @@ -132,7 +132,7 @@ |---|---|---| | 采购总览 | 采购总监、总经理 | 本年支出(按品类)、Top 10 供应商支出、寻源节约额、供应商等级分布、资质异常数 | | 寻源 | 品类经理、寻源工程师 | 进行中寻源单(按状态)、本月定标金额、平均有效报价家数、单一来源占比 | -| 供应商 | 品类经理、SQE | 供应商按生命周期分布、30 天内到期证书数、准入申请积压、风险事件(按严重度) | +| 供应商 | 品类经理、SQE | 供应商按生命周期分布、30 天内到期证书数、准入申请积压、风险事件(按严重度)——v0.5 先上生命周期分布、资质异常数、黑名单数和未关闭风险事件(按严重度、按类型、高风险数),到期证书与准入积压后续补上 | | 交付 | 采购员、品类经理 | 按月准时 / 不准时交付行数(并列柱)、上月按供应商的 OTD(交付记录计数之比);考评单上线后加按供应商的 OTD 趋势;待确认订单数、交期风险行数在 ERP 集成后加 | | 质量 | SQE、质量经理 | 不合格交付行、批次合格率、PPM、按供应商的 PPM 排名、不合格交付(按供应商、物料)——默认上月,可改区间(v0.4 Q1);未关闭整改单(按状态)、逾期整改单(v0.4 Q2,看当前积压、不受日期区间过滤);审核问题项关闭率随审核上线 | | 门户首页 | 供应商 | 待办计数卡片、最新等级与总分、近 4 期得分趋势 | diff --git a/governance/templates/core/GL-08.md b/governance/templates/core/GL-08.md index 094af44..4b2781f 100644 --- a/governance/templates/core/GL-08.md +++ b/governance/templates/core/GL-08.md @@ -125,6 +125,8 @@ e) 解除黑名单,适用于黑名单内的供应商。 6.2.5 处置原因和生效日期在处置单被退回修改期间可以修改;供应商、处置动作、范围和物资类别提交后不得修改,需要改变的,驳回后重新发起。 +6.2.6 供应商出现高风险事件或绩效评价结果触发处置时,SRM 系统自动生成处置单草稿。{{org.position.category_manager}}(或{{org.position.buyer_lead}})应核实后提交审批,提交人即为发起人;核实后认为不需要处置的,作废草稿并写明理由。 + ## 6.3 审批 6.3.1 处置按附录 A 的权限审批:冻结、解冻由{{org.position.category_manager}}发起,{{org.position.procurement_director}}审批;处置单由{{org.position.buyer_lead}}发起的,先经{{org.position.category_manager}}审批,再按本款审批;淘汰、解除黑名单再经{{org.position.general_manager}}审批;列入黑名单经{{org.position.general_manager}}审批后,由{{org.position.procurement_compliance}}会签。 @@ -196,6 +198,7 @@ f) 解除黑名单:供应商状态转为「淘汰」,不恢复任何供货 | 控制要求 | 本办法条款 | SRM 系统中的控制方式 | |------------------------|------------|--------------------------------| | 处置经处置单办理 | 6.2.1 | 供应商状态和供货资格只能由处置单的执行流程修改;新建处置单即提交审批 | +| 系统生成的处置单草稿 | 6.2.6 | 高风险事件、绩效结果生成的草稿不自动审批;提交后以提交人为发起人进入审批,提交时再校验动作与供应商状态;作废须写明理由 | | 处置动作与供应商状态相符 | 6.2.2、6.2.3 | 新建处置单时校验动作与供应商当前状态;只有冻结、解冻可以指定物资类别,指定时必须选择物资类别 | | 同一供应商只有一张审批中的处置单 | 6.2.4 | 供应商已有审批中的处置单时,不能再新建 | | 按处置动作分级审批、黑名单会签 | 6.3.1 | 处置审批流程:发起人不是{{org.position.category_manager}}的,先由{{org.position.category_manager}}审批;{{org.position.procurement_director}}审批;淘汰、列入和解除黑名单加{{org.position.general_manager}};列入黑名单再经{{org.position.procurement_compliance}}会签 | @@ -206,6 +209,7 @@ f) 解除黑名单:供应商状态转为「淘汰」,不恢复任何供货 | 淘汰只能重新准入 | 6.5.2、6.6.3 | 供应商状态机不允许从「淘汰」直接回到「合格」,只能经新的准入申请转为「准入中」 | | 黑名单不得准入 | 6.5.3 | 注册受理时按统一社会信用代码(境外供应商按注册号)比对,命中黑名单自动不予受理 | | 解除黑名单单独审批 | 6.6.2 | 解除黑名单是单独的处置动作,按最高一级审批,执行后供应商转为「淘汰」 | +| 黑名单名录与期满提醒 | 6.5.3、6.6.2 | 列入黑名单执行时记录拉黑日期、禁入期满日({{param.blacklist_years}} 年)、处置单和原因,黑名单名录列出全部黑名单供应商;期满前 30 天和当天提醒发起人和{{org.position.category_manager}},不自动解除 | # 8 记录 diff --git a/src/core/actions/disposition.actions.ts b/src/core/actions/disposition.actions.ts new file mode 100644 index 0000000..d3adc37 --- /dev/null +++ b/src/core/actions/disposition.actions.ts @@ -0,0 +1,27 @@ +import { P } from '@objectstack/spec'; +import { defineAction } from '@objectstack/spec/ui'; + +// 处置单草稿的「提交审批」「作废」按钮(03 §5,v0.5)。谁能操作由 flow 自己检查。 +export const DispositionSubmitAction = defineAction({ + name: 'srm_disposition_submit', + label: '提交审批', + objectName: 'srm_supplier_disposition', + icon: 'send', + type: 'flow', + target: 'srm_disposition_submit', + locations: ['record_header'], + visible: P`record.status == 'draft'`, + refreshAfter: true, +}); + +export const DispositionCancelAction = defineAction({ + name: 'srm_disposition_cancel', + label: '作废', + objectName: 'srm_supplier_disposition', + icon: 'x-circle', + type: 'flow', + target: 'srm_disposition_cancel', + locations: ['record_header'], + visible: P`record.status == 'draft'`, + refreshAfter: true, +}); diff --git a/src/core/actions/index.ts b/src/core/actions/index.ts index ce932d0..223d233 100644 --- a/src/core/actions/index.ts +++ b/src/core/actions/index.ts @@ -2,3 +2,4 @@ export { BankVerifyAction } from './bank-account.action.js'; 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'; diff --git a/src/core/analytics/index.ts b/src/core/analytics/index.ts index abb128d..803b8b6 100644 --- a/src/core/analytics/index.ts +++ b/src/core/analytics/index.ts @@ -2,3 +2,5 @@ export { DeliveryMetrics } from './delivery.dataset.js'; export { DeliveryDashboard, QualityDashboard } from './delivery.dashboard.js'; export { DeliveryScorecardReport } from './delivery.report.js'; export { QualityIssueMetrics } from './quality-issue.dataset.js'; +export { RiskEventMetrics, SupplierMetrics } from './risk.dataset.js'; +export { SupplierDashboard } from './supplier.dashboard.js'; diff --git a/src/core/analytics/risk.dataset.ts b/src/core/analytics/risk.dataset.ts new file mode 100644 index 0000000..b210131 --- /dev/null +++ b/src/core/analytics/risk.dataset.ts @@ -0,0 +1,44 @@ +import { defineDataset } from '@objectstack/spec/ui'; + +// 风险事件统计(05 §4.2 供应商看板):未关闭风险事件按严重度、类型分布 +export const RiskEventMetrics = defineDataset({ + name: 'srm_risk_event_metrics', + label: '风险事件统计', + description: '风险事件按严重度、类型、来源、供应商统计,含未关闭数(风险事件,05 §4.2)', + object: 'srm_risk_event', + dimensions: [ + { name: 'severity', label: '严重度', field: 'severity', type: 'string' }, + { name: 'risk_type', label: '风险类型', field: 'risk_type', type: 'string' }, + { name: 'source', label: '来源', field: 'source', type: 'string' }, + { name: 'status', label: '状态', field: 'status', type: 'string' }, + { name: 'supplier', label: '供应商', field: 'supplier', type: 'lookup' }, + { name: 'created_month', label: '登记月份', field: 'created_at', type: 'date', dateGranularity: 'month' }, + ], + measures: [ + { name: 'event_count', label: '风险事件数', aggregate: 'count' }, + { name: 'unclosed_count', label: '未关闭风险事件', aggregate: 'count', filter: { status: { $in: ['open', 'mitigating'] } } }, + { + name: 'unclosed_high_count', + label: '未关闭高风险事件', + aggregate: 'count', + filter: { severity: 'high', status: { $in: ['open', 'mitigating'] } }, + }, + ], +}); + +// 供应商统计(05 §4.2 供应商看板):按生命周期分布、资质异常数、黑名单数 +export const SupplierMetrics = defineDataset({ + name: 'srm_supplier_metrics', + label: '供应商统计', + description: '供应商按生命周期状态、类型统计,含资质异常数与黑名单数(供应商,05 §4.2)', + object: 'srm_supplier', + dimensions: [ + { name: 'status', label: '状态', field: 'status', type: 'string' }, + { name: 'supplier_type', label: '供应商类型', field: 'supplier_type', type: 'string' }, + ], + measures: [ + { name: 'supplier_count', label: '供应商数', aggregate: 'count' }, + { name: 'alert_count', label: '资质异常供应商', aggregate: 'count', filter: { qualification_alert: true } }, + { name: 'blacklisted_count', label: '黑名单供应商', aggregate: 'count', filter: { status: 'blacklisted' } }, + ], +}); diff --git a/src/core/analytics/supplier.dashboard.ts b/src/core/analytics/supplier.dashboard.ts new file mode 100644 index 0000000..1789c4c --- /dev/null +++ b/src/core/analytics/supplier.dashboard.ts @@ -0,0 +1,67 @@ +import type { Dashboard } from '@objectstack/spec/ui'; + +// 05 §4.2 供应商看板(v0.5 风险事件随附):供应商按生命周期分布、资质异常与黑名单数、未关闭风险事件按严重度与类型。 +// 看的是当前状况,不设日期区间。30 天内到期证书数、准入申请积压两张卡片后续补上。 +export const SupplierDashboard: Dashboard = { + name: 'srm_supplier_dashboard', + label: '供应商', + description: '供应商按生命周期分布、资质异常与黑名单数、未关闭风险事件按严重度与类型', + columns: 12, + widgets: [ + { + id: 'suppliers_by_status', + type: 'column', + title: '供应商按生命周期分布', + dataset: 'srm_supplier_metrics', + dimensions: ['status'], + values: ['supplier_count'], + layout: { x: 0, y: 0, w: 8, h: 4 }, + }, + { + id: 'qualification_alerts', + type: 'metric', + title: '资质异常供应商', + dataset: 'srm_supplier_metrics', + values: ['alert_count'], + layout: { x: 8, y: 0, w: 4, h: 2 }, + colorVariant: 'danger', + }, + { + id: 'blacklisted_suppliers', + type: 'metric', + title: '黑名单供应商', + dataset: 'srm_supplier_metrics', + values: ['blacklisted_count'], + layout: { x: 8, y: 2, w: 4, h: 2 }, + }, + { + id: 'unclosed_risks_by_severity', + type: 'column', + title: '未关闭风险事件(按严重度)', + dataset: 'srm_risk_event_metrics', + dimensions: ['severity'], + values: ['unclosed_count'], + filter: { status: { $in: ['open', 'mitigating'] } }, + layout: { x: 0, y: 4, w: 6, h: 4 }, + }, + { + id: 'unclosed_risks_by_type', + type: 'horizontal-bar', + title: '未关闭风险事件(按类型)', + dataset: 'srm_risk_event_metrics', + dimensions: ['risk_type'], + values: ['unclosed_count'], + filter: { status: { $in: ['open', 'mitigating'] } }, + layout: { x: 6, y: 4, w: 6, h: 4 }, + }, + { + id: 'unclosed_high_risks', + type: 'metric', + title: '未关闭高风险事件', + dataset: 'srm_risk_event_metrics', + values: ['unclosed_high_count'], + layout: { x: 0, y: 8, w: 4, h: 2 }, + colorVariant: 'danger', + }, + ], +}; diff --git a/src/core/apps/workbench.app.ts b/src/core/apps/workbench.app.ts index c263219..ac5015b 100644 --- a/src/core/apps/workbench.app.ts +++ b/src/core/apps/workbench.app.ts @@ -41,6 +41,15 @@ export const Workbench = App.create({ { id: 'nav_supplier_audit', type: 'object', objectName: 'srm_supplier_audit', label: '供应商审核', icon: 'clipboard-list' }, ], }, + { + id: 'group_performance_risk', + type: 'group', + label: '绩效与风险', + icon: 'gauge', + children: [ + { id: 'nav_risk_event', type: 'object', objectName: 'srm_risk_event', label: '风险事件', icon: 'siren' }, + ], + }, { id: 'group_analytics', type: 'group', @@ -49,6 +58,7 @@ export const Workbench = App.create({ children: [ { id: 'nav_delivery_dashboard', type: 'dashboard', dashboardName: 'srm_delivery_dashboard', label: '交付看板', icon: 'truck' }, { id: 'nav_quality_dashboard', type: 'dashboard', dashboardName: 'srm_quality_dashboard', label: '质量看板', icon: 'shield-check' }, + { id: 'nav_supplier_dashboard', type: 'dashboard', dashboardName: 'srm_supplier_dashboard', label: '供应商看板', icon: 'factory' }, { id: 'nav_delivery_scorecard', type: 'report', reportName: 'srm_delivery_scorecard', label: '供应商交付与质量', icon: 'table' }, ], }, diff --git a/src/core/flows/disposition/disposition-apply.flow.ts b/src/core/flows/disposition/disposition-apply.flow.ts index d39613b..1db2235 100644 --- a/src/core/flows/disposition/disposition-apply.flow.ts +++ b/src/core/flows/disposition/disposition-apply.flow.ts @@ -8,12 +8,15 @@ import type * as Automation from '@objectstack/spec/automation'; // 「指定品类」冻结的),原本就暂停的资格不动;全部品类时供应商→合格; // - 淘汰 / 拉入黑名单:供应商→淘汰 / 黑名单,品类资格全部暂停并清掉冻结记录,重新准入时按申请品类恢复; // - 解除黑名单:供应商→淘汰,之后只能重新走准入(FR-208)。 +// 拉入黑名单同时写供应商的黑名单名录字段(拉黑日期、禁入期满日、处置单、原因,v0.5)。禁入期限取 +// params/core.yaml 的 blacklist_years(示例 5 年),客户改参数时同步 BLACKLIST_YEARS。 +const BLACKLIST_YEARS = 5; -const setSupplier = (id: string, label: string, status: string) => ({ +const setSupplier = (id: string, label: string, status: string, extra: Record = {}) => ({ id, type: 'update_record' as const, label, - config: { objectName: 'srm_supplier', filter: { id: cel`disp.supplier` }, fields: { status } }, + config: { objectName: 'srm_supplier', filter: { id: cel`disp.supplier` }, fields: { status, ...extra } }, }); // 指定品类时只动所选品类的资格 @@ -91,7 +94,12 @@ export const DispositionApplyFlow: Automation.Flow = { // 淘汰 / 黑名单 / 解除黑名单 setSupplier('eliminate_supplier', '供应商淘汰', 'eliminated'), - setSupplier('blacklist_supplier', '供应商拉入黑名单', 'blacklisted'), + setSupplier('blacklist_supplier', '供应商拉入黑名单', 'blacklisted', { + blacklisted_on: cel`isoDate(today())`, + blacklist_until: { dialect: 'cel', source: `isoDate(addMonths(today(), ${BLACKLIST_YEARS * 12}))` }, + blacklist_disposition: cel`disp.id`, + blacklist_reason: cel`disp.reason`, + }), suspendAll('suspend_all'), setSupplier('remove_blacklist_supplier', '解除黑名单(转为淘汰)', 'eliminated'), diff --git a/src/core/flows/disposition/disposition-approval.flow.ts b/src/core/flows/disposition/disposition-approval.flow.ts index 7308859..18b0bb0 100644 --- a/src/core/flows/disposition/disposition-approval.flow.ts +++ b/src/core/flows/disposition/disposition-approval.flow.ts @@ -1,7 +1,8 @@ import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; -// 供应商处置审批(03 §5、§12,FR-208,BR-11):持 srm_supplier_admin 的岗位新建处置单即发起审批。 +// 供应商处置审批(03 §5、§12,FR-208,BR-11):持 srm_supplier_admin 的岗位新建处置单即发起审批; +// flow 生成的处置单草稿在「提交审批」(srm_disposition_submit)后发起,发起人是提交人(submitted_by)。 // 第 1 级品类经理:发起人是品类经理时发起即是这一级;不是(如采购主管)时先由品类经理审批。 // 冻结 / 解冻:再经采购总监;淘汰 / 黑名单 / 解除黑名单:再加总经理;拉入黑名单最后由内控会签。 // 第一个审批节点可以通过、驳回或退回修改;后面几级只能通过或驳回(见下)。全部通过后经 srm_disposition_apply @@ -12,6 +13,9 @@ import type * as Automation from '@objectstack/spec/automation'; // 撞人时这次通过不算数,通知审批人后重新开一轮同级审批(临时做法,平台支持后改为节点配置)。 const cel$ = (source: string) => ({ dialect: 'cel' as const, source }); +// 发起人:草稿的提交人;手工新建的处置单没有 submitted_by,是新建人 +const initiator = `(has(record.submitted_by) && record.submitted_by != null ? record.submitted_by : record.created_by)`; + const approval = (label: string, position: string, maxRevisions: number) => ({ type: 'approval' as const, label, @@ -69,7 +73,7 @@ const recordApprover = (id: string, label: string, field: string, actor: string, // 审批人撞人:发起人或前面各级的审批人(没经过的级别变量是空串,不会撞上) const collides = (actor: string, earlier: string[]) => - [`${actor}.actor_id == record.created_by`, ...earlier.map((v) => `${actor}.actor_id == ${v}`)].join(' || '); + [`${actor}.actor_id == ${initiator}`, ...earlier.map((v) => `${actor}.actor_id == ${v}`)].join(' || '); const sodEdges = (prefix: string, decision: string, actor: string, earlier: string[], selfNode: string, okNode: string, review: string) => [ { id: `e_${prefix}_self`, source: decision, target: selfNode, type: 'conditional' as const, condition: cel$(collides(actor, earlier)), label: '撞人' }, { id: `e_${prefix}_ok`, source: decision, target: okNode, type: 'conditional' as const, condition: cel$(`!(${collides(actor, earlier)})`), label: '不同的人' }, @@ -90,11 +94,11 @@ export const DispositionApprovalFlow: Automation.Flow = { { id: 'start', type: 'start', - label: '新建处置单', + label: '新建处置单或提交草稿', config: { objectName: 'srm_supplier_disposition', - triggerType: 'record-after-create', - condition: P`record.status == 'in_review'`, + triggerType: 'record-after-write', + condition: P`record.status == 'in_review' && (previous == null || (has(previous.status) && previous.status == 'draft'))`, }, }, { id: 'init', type: 'assignment', label: '初始化审批人变量', config: { assignments: { categoryActor: '', directorActor: '', gmActor: '' } } }, @@ -104,7 +108,7 @@ export const DispositionApprovalFlow: Automation.Flow = { label: '发起人是品类经理?', config: { objectName: 'sys_user_position', - filter: { user_id: cel`record.created_by`, position: 'srm_category_manager' }, + filter: { user_id: cel$(initiator), position: 'srm_category_manager' }, outputVariable: 'initiatorCm', }, }, @@ -199,7 +203,7 @@ export const DispositionApprovalFlow: Automation.Flow = { type: 'notify', label: '通知发起人:已执行', config: { - recipients: cel`record.created_by`, + recipients: cel$(initiator), title: '处置单 {{ record.disposition_no }} 已审批通过并执行', message: '供应商状态和品类资格已按处置单更新。请通知采购员在 ERP 同步供应商的冻结标志。', sourceObject: 'srm_supplier_disposition', @@ -231,7 +235,7 @@ export const DispositionApprovalFlow: Automation.Flow = { type: 'notify', label: '通知发起人:已驳回', config: { - recipients: cel`record.created_by`, + recipients: cel$(initiator), title: '处置单 {{ record.disposition_no }} 已驳回', message: '供应商状态未作修改,驳回原因见处置单。', severity: 'warning', diff --git a/src/core/flows/disposition/disposition-cancel.flow.ts b/src/core/flows/disposition/disposition-cancel.flow.ts new file mode 100644 index 0000000..b4e1129 --- /dev/null +++ b/src/core/flows/disposition/disposition-cancel.flow.ts @@ -0,0 +1,75 @@ +import { cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// 作废处置单草稿(03 §5,v0.5):品类经理或采购主管核实后认为不必处置时作废草稿,写明理由。只作废草稿, +// 审批中的处置单走审批(驳回)。以系统身份运行,进门检查同 srm_disposition_submit。 +// 作废理由记在驳回原因字段(reject_reason),处置单上只有这一个「为什么没执行」的字段。 +const allowed = `current_user != null && ('srm_category_manager' in current_user.positions || 'srm_buyer_lead' in current_user.positions)`; +const isDraft = `disp != null && disp.status == 'draft'`; +const cel$ = (source: string) => ({ dialect: 'cel' as const, source }); + +export const DispositionCancelFlow: Automation.Flow = { + name: 'srm_disposition_cancel', + 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_supplier_disposition', filter: { id: cel`recordId` }, outputVariable: 'disp' }, + }, + { 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: '作废处置单草稿:{{ disp.disposition_no }}', + description: '作废后这张草稿不再提交审批,供应商状态不变。', + fields: [{ name: 'cancel_reason', label: '作废理由', type: 'textarea', required: true }], + waitForInput: true, + }, + }, + { + id: 'cancel', + type: 'update_record', + label: '置为已作废', + config: { + objectName: 'srm_supplier_disposition', + filter: { id: cel`disp.id` }, + fields: { status: 'cancelled', reject_reason: cel`cancel_reason` }, + }, + }, + { 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: cel$(`!(${allowed})`), label: '没有权限' }, + { id: 'e3', source: 'gate', target: 'refuse_status', type: 'conditional', condition: cel$(`${allowed} && !(${isDraft})`), label: '不是草稿' }, + { id: 'e4', source: 'gate', target: 'confirm', type: 'conditional', condition: cel$(`${allowed} && ${isDraft}`), label: '可以' }, + { id: 'e5', source: 'confirm', target: 'cancel', type: 'default' }, + { id: 'e6', source: 'cancel', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/disposition/disposition-submit.flow.ts b/src/core/flows/disposition/disposition-submit.flow.ts new file mode 100644 index 0000000..e9729af --- /dev/null +++ b/src/core/flows/disposition/disposition-submit.flow.ts @@ -0,0 +1,76 @@ +import { cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// 提交处置单草稿(03 §5,v0.5):flow 生成的草稿(高风险事件、绩效结果应用)由品类经理或采购主管核对后 +// 提交,草稿 → 审批中,srm_disposition_approval 随即以提交人为发起人开始审批。以系统身份运行,因为状态对所有人 +// 只读;进门的检查在 flow 里做(临时做法同 srm_contract_submit:平台还不能在 flow 上声明谁可以启动,objectstack#25481)。 +// 提交人记入 submitted_by 并接管记录所有权(处置单 OWD 为 private,退回修改时提交人要能改原因)。 +// 供应商状态是否还允许这个动作、是否已有审批中的处置单,由处置单上的 *_on_submit 校验规则把关。 +const allowed = `current_user != null && ('srm_category_manager' in current_user.positions || 'srm_buyer_lead' in current_user.positions)`; +const isDraft = `disp != null && disp.status == 'draft'`; +const cel$ = (source: string) => ({ dialect: 'cel' as const, source }); + +export const DispositionSubmitFlow: Automation.Flow = { + name: 'srm_disposition_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_supplier_disposition', filter: { id: cel`recordId` }, outputVariable: 'disp' }, + }, + { 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: '提交处置单:{{ disp.disposition_no }}', + description: '处置原因:{{ disp.reason }}\n提交后进入审批,你是这张处置单的发起人。原因不合适时请作废草稿、手工新建处置单。', + waitForInput: true, + }, + }, + { + id: 'submit', + type: 'update_record', + label: '置为审批中', + config: { + objectName: 'srm_supplier_disposition', + filter: { id: cel`disp.id` }, + fields: { status: 'in_review', submitted_by: cel`current_user.id`, owner_id: cel`current_user.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: cel$(`!(${allowed})`), label: '没有权限' }, + { id: 'e3', source: 'gate', target: 'refuse_status', type: 'conditional', condition: cel$(`${allowed} && !(${isDraft})`), label: '不是草稿' }, + { id: 'e4', source: 'gate', target: 'confirm', type: 'conditional', condition: cel$(`${allowed} && ${isDraft}`), label: '可以' }, + { id: 'e5', source: 'confirm', target: 'submit', type: 'default' }, + { id: 'e6', source: 'submit', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/index.ts b/src/core/flows/index.ts index 9bc75be..7ab6991 100644 --- a/src/core/flows/index.ts +++ b/src/core/flows/index.ts @@ -23,3 +23,7 @@ export { ScarOverdueSweepFlow } from './quality/scar-overdue-sweep.flow.js'; export { AuditAdmissionApplyFlow } from './audit/admission-apply.flow.js'; export { AuditFindingToScarFlow } from './audit/finding-to-scar.flow.js'; export { AuditPlanGenerateFlow } from './audit/plan-generate.flow.js'; +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'; diff --git a/src/core/flows/qualification/qualification-recalc.flow.ts b/src/core/flows/qualification/qualification-recalc.flow.ts index 6432c29..7fb78f9 100644 --- a/src/core/flows/qualification/qualification-recalc.flow.ts +++ b/src/core/flows/qualification/qualification-recalc.flow.ts @@ -1,9 +1,16 @@ import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; +import { CertTypePicklist } from '../../picklists/cert-type.picklist.js'; // P10 资质异常与恢复(03 §11,FR-902):证书状态或「必备」标志变化后重新计算供应商的 // qualification_alert——还有任何一张必备证书已过期就为 true,否则为 false。 -// v0.1 由内部人员维护证书,暂不要求「经 SQE 核验」才解除;门户版本(v0.2)加上 verified 条件。 +// v0.1 由内部人员维护证书,暂不要求「经 SQE 核验」才解除;门户版本(v0.6)加上 verified 条件。 +// v0.5 起联动风险事件(03 §11):这张证书是必备证书且刚变为已过期时,新建一条系统来源的「资质」类风险事件 +// (这张证书已有未关闭的事件时不再建);资质异常解除时,关闭该供应商未关闭的系统资质事件并写处理结论。 +const expiredMandatory = `record.is_mandatory == true && record.status == 'expired'`; +// 事件描述里写资质类型的中文名;扩展包追加的类型不在表里,写选项值 +const certTypeLabels = `{${CertTypePicklist.options.map((o) => `'${o.value}': '${o.label}'`).join(', ')}}`; +const certTypeLabel = `(record.cert_type in ${certTypeLabels} ? ${certTypeLabels}[record.cert_type] : record.cert_type)`; export const QualificationRecalcFlow: Automation.Flow = { name: 'srm_supplier_qualification_recalc', label: '资质异常重算', @@ -40,12 +47,58 @@ export const QualificationRecalcFlow: Automation.Flow = { label: '标记资质异常', config: { objectName: 'srm_supplier', filter: { id: cel`record.supplier` }, fields: { qualification_alert: true } }, }, + { id: 'this_expired', type: 'decision', label: '这张必备证书刚过期?' }, + { + id: 'find_open_event', + type: 'get_record', + label: '这张证书已有未关闭的风险事件?', + config: { + objectName: 'srm_risk_event', + filter: { certificate: cel`record.id`, status: { $ne: 'closed' } }, + outputVariable: 'openEvent', + }, + }, + { id: 'has_event', type: 'decision', label: '已有事件?' }, + { + id: 'create_event', + type: 'create_record', + label: '新建资质风险事件', + config: { + objectName: 'srm_risk_event', + fields: { + supplier: cel`record.supplier`, + risk_type: 'qualification', + severity: 'medium', + source: 'system', + certificate: cel`record.id`, + description: { + dialect: 'cel', + source: + `'必备资质证书' + (has(record.cert_no) && record.cert_no != null && !isBlank(record.cert_no) ? ' ' + record.cert_no : '')` + + ` + '(' + ${certTypeLabel} + ')已过期' + (has(record.valid_to) && record.valid_to != null ? ',有效期至 ' + string(record.valid_to) : '')` + + ` + '。补齐有效证书后资质异常解除,本事件自动关闭。'`, + }, + }, + outputVariable: 'riskEvent', + }, + }, { id: 'clear_alert', type: 'update_record', label: '解除资质异常', config: { objectName: 'srm_supplier', filter: { id: cel`record.supplier` }, fields: { qualification_alert: false } }, }, + { + id: 'close_events', + type: 'update_record', + label: '关闭系统生成的资质风险事件', + config: { + objectName: 'srm_risk_event', + filter: { supplier: cel`record.supplier`, risk_type: 'qualification', source: 'system', status: { $ne: 'closed' } }, + multi: true, + fields: { status: 'closed', owner_note: '必备资质已恢复,资质异常解除,系统自动关闭。' }, + }, + }, { id: 'end', type: 'end', label: '结束' }, ], @@ -54,7 +107,14 @@ export const QualificationRecalcFlow: Automation.Flow = { { id: 'e2', source: 'find_expired_mandatory', target: 'check', type: 'default' }, { id: 'e3', source: 'check', target: 'raise_alert', type: 'conditional', condition: P`expiredMandatory != null`, label: '有' }, { id: 'e4', source: 'check', target: 'clear_alert', type: 'conditional', condition: P`expiredMandatory == null`, label: '没有' }, - { id: 'e5', source: 'raise_alert', target: 'end', type: 'default' }, - { id: 'e6', source: 'clear_alert', target: 'end', type: 'default' }, + { id: 'e5', source: 'raise_alert', target: 'this_expired', type: 'default' }, + { id: 'e5a', source: 'this_expired', target: 'find_open_event', type: 'conditional', condition: { dialect: 'cel', source: expiredMandatory }, label: '是' }, + { id: 'e5b', source: 'this_expired', target: 'end', type: 'conditional', condition: { dialect: 'cel', source: `!(${expiredMandatory})` }, label: '不是' }, + { id: 'e5c', source: 'find_open_event', target: 'has_event', type: 'default' }, + { id: 'e5d', source: 'has_event', target: 'end', type: 'conditional', condition: P`openEvent != null`, label: '已有' }, + { id: 'e5e', source: 'has_event', target: 'create_event', type: 'conditional', condition: P`openEvent == null`, label: '没有' }, + { id: 'e5f', source: 'create_event', target: 'end', type: 'default' }, + { id: 'e6', source: 'clear_alert', target: 'close_events', type: 'default' }, + { id: 'e7', source: 'close_events', target: 'end', type: 'default' }, ], }; diff --git a/src/core/flows/risk/blacklist-expiry-notice.flow.ts b/src/core/flows/risk/blacklist-expiry-notice.flow.ts new file mode 100644 index 0000000..83bc433 --- /dev/null +++ b/src/core/flows/risk/blacklist-expiry-notice.flow.ts @@ -0,0 +1,87 @@ +import { cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// 黑名单禁入期满提醒(02 §4.1,03 §5,FR-208):黑名单供应商的禁入期满日(拉黑日期 + params/core.yaml 的 +// blacklist_years)前 30 天和当天各提醒一次,通知拉黑处置单的发起人和供应商各品类的品类经理。只提醒: +// 期满不自动解除,是否解除由品类经理评估后发起「解除黑名单」处置单,按 03 §12 审批。 +// 平台按天扫描、每家供应商每个提醒日只发一次(time_relative 触发)。 +const cel$ = (source: string) => ({ dialect: 'cel' as const, source }); + +export const BlacklistExpiryNoticeFlow: Automation.Flow = { + name: 'srm_blacklist_expiry_notice', + label: '黑名单禁入期满提醒', + type: 'schedule', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: '禁入期满前 30 天和当天', + config: { + timeRelative: { object: 'srm_supplier', dateField: 'blacklist_until', offsetDays: [30, 0], filter: { status: 'blacklisted' } }, + }, + }, + { + id: 'load_disposition', + type: 'get_record', + label: '读取拉黑处置单', + config: { + objectName: 'srm_supplier_disposition', + filter: { id: cel`has(record.blacklist_disposition) && record.blacklist_disposition != null ? record.blacklist_disposition : ''` }, + outputVariable: 'disp', + }, + }, + { + id: 'load_qualifications', + type: 'get_record', + label: '读取品类资格', + config: { objectName: 'srm_supplier_category', filter: { supplier: cel`record.id` }, limit: 200, outputVariable: 'qualifications' }, + }, + { + id: 'load_categories', + type: 'get_record', + label: '读取品类', + config: { objectName: 'srm_category', filter: { id: { $in: cel`qualifications.map(q, q.category)` } }, limit: 200, outputVariable: 'categories' }, + }, + { + id: 'notify_guard', + type: 'try_catch', + label: '通知', + config: { + try: { + nodes: [ + { + id: 'notify', + type: 'notify', + label: '通知发起人和品类经理', + config: { + recipients: [ + cel$(`disp != null ? (has(disp.submitted_by) && disp.submitted_by != null ? disp.submitted_by : disp.created_by) : null`), + cel`categories.filter(c, has(c.category_manager) && c.category_manager != null).map(c, c.category_manager)`, + ], + title: '黑名单供应商 {{ record.name }} 的禁入期将于 {{ record.blacklist_until }} 届满', + message: '{{ record.name }} 于 {{ record.blacklisted_on }} 拉入黑名单,禁入期至 {{ record.blacklist_until }}。期满不会自动解除;如需解除,请评估后发起「解除黑名单」处置单审批。', + severity: 'info', + sourceObject: 'srm_supplier', + sourceId: cel`record.id`, + }, + }, + ], + }, + // 没有可通知的人(处置单发起人已不在、品类没有品类经理)时跳过 + catch: { nodes: [{ id: 'skip_notify', type: 'assignment', label: '跳过通知' }] }, + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'load_disposition', type: 'default' }, + { id: 'e2', source: 'load_disposition', target: 'load_qualifications', type: 'default' }, + { id: 'e3', source: 'load_qualifications', target: 'load_categories', type: 'default' }, + { id: 'e4', source: 'load_categories', target: 'notify_guard', type: 'default' }, + { id: 'e5', source: 'notify_guard', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/risk/risk-event-apply.flow.ts b/src/core/flows/risk/risk-event-apply.flow.ts new file mode 100644 index 0000000..49c20cb --- /dev/null +++ b/src/core/flows/risk/risk-event-apply.flow.ts @@ -0,0 +1,149 @@ +import { P, cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// 高风险事件处理(03 §11,FR-903):风险事件新建为高风险或改为高风险时,为合格供应商生成冻结(全部品类)处置单 +// 草稿并回填到事件的 disposition,通知供应商各品类的品类经理核实后提交审批或作废(没有品类经理时通知全部品类经理)。 +// 不直接发起审批:风险是否成立、要不要冻结由品类经理判断,草稿只是把处置准备好。 +// - 这家供应商已有冻结草稿:不再建,事件指向那张草稿(多个高风险事件合并到一张处置单); +// - 已有审批中的处置单,或供应商不是合格状态(已冻结、淘汰、黑名单、准入中):不生成草稿,只通知。 +const becameHigh = `record.severity == 'high' && record.status != 'closed' && (previous == null || !has(previous.severity) || previous.severity != 'high') && (!has(record.disposition) || record.disposition == null)`; +const canDraft = `vendor != null && vendor.status == 'qualified' && (!has(vendor.open_disposition_count) || vendor.open_disposition_count == 0)`; +const cel$ = (source: string) => ({ dialect: 'cel' as const, source }); +// 供应商各品类的品类经理;一个都没有时通知全部品类经理 +const managers = cel$( + `size(categories.filter(c, has(c.category_manager) && c.category_manager != null)) > 0` + + ` ? categories.filter(c, has(c.category_manager) && c.category_manager != null).map(c, c.category_manager)` + + ` : allManagers.map(p, p.user_id)`, +); + +const notify = (id: string, label: string, title: string, message: string) => ({ + id: `${id}_guard`, + type: 'try_catch' as const, + label, + config: { + try: { + nodes: [ + { + id, + type: 'notify' as const, + label, + config: { recipients: managers, title, message, severity: 'warning' as const, sourceObject: 'srm_risk_event', sourceId: cel`record.id` }, + }, + ], + }, + // 没有可通知的人时不影响草稿 + catch: { nodes: [{ id: `${id}_skip`, type: 'assignment' as const, label: '跳过通知' }] }, + }, +}); + +export const RiskEventApplyFlow: Automation.Flow = { + name: 'srm_risk_event_apply', + label: '高风险事件生成处置单草稿', + type: 'record_change', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: '风险事件为高风险', + config: { objectName: 'srm_risk_event', triggerType: 'record-after-write', condition: cel$(becameHigh) }, + }, + { + id: 'load_supplier', + type: 'get_record', + label: '读取供应商', + config: { objectName: 'srm_supplier', filter: { id: cel`record.supplier` }, outputVariable: 'vendor' }, + }, + { + id: 'load_qualifications', + type: 'get_record', + label: '读取品类资格', + config: { objectName: 'srm_supplier_category', filter: { supplier: cel`record.supplier` }, limit: 200, outputVariable: 'qualifications' }, + }, + { + id: 'load_categories', + type: 'get_record', + label: '读取品类', + config: { objectName: 'srm_category', filter: { id: { $in: cel`qualifications.map(q, q.category)` } }, limit: 200, outputVariable: 'categories' }, + }, + { + id: 'load_all_managers', + type: 'get_record', + label: '读取全部品类经理', + config: { objectName: 'sys_user_position', filter: { position: 'srm_category_manager' }, limit: 50, outputVariable: 'allManagers' }, + }, + { id: 'can_draft', type: 'decision', label: '可以生成冻结草稿?' }, + { + id: 'find_draft', + type: 'get_record', + label: '已有冻结草稿?', + config: { + objectName: 'srm_supplier_disposition', + filter: { supplier: cel`record.supplier`, status: 'draft', action: 'freeze' }, + outputVariable: 'existingDraft', + }, + }, + { id: 'has_draft', type: 'decision', label: '已有草稿?' }, + { + id: 'create_draft', + type: 'create_record', + label: '生成冻结处置单草稿', + config: { + objectName: 'srm_supplier_disposition', + fields: { + supplier: cel`record.supplier`, + action: 'freeze', + scope: 'all', + status: 'draft', + reason: cel`'高风险事件 ' + record.event_no + ':' + record.description`, + }, + outputVariable: 'draft', + }, + }, + { + id: 'link_new', + type: 'update_record', + label: '事件回填处置单', + config: { objectName: 'srm_risk_event', filter: { id: cel`record.id` }, fields: { disposition: cel`draft.id` } }, + }, + { + id: 'link_existing', + type: 'update_record', + label: '事件指向已有草稿', + config: { objectName: 'srm_risk_event', filter: { id: cel`record.id` }, fields: { disposition: cel`existingDraft.id` } }, + }, + notify( + 'notify_draft', + '通知品类经理:已生成冻结草稿', + '高风险事件 {{ record.event_no }}:已生成冻结处置单草稿', + '供应商出现高风险事件,系统已生成冻结(全部品类)处置单草稿。请核实风险后在处置单上「提交审批」,不需要冻结时「作废」。', + ), + notify( + 'notify_only', + '通知品类经理:未生成草稿', + '高风险事件 {{ record.event_no }}:请评估处置', + '供应商出现高风险事件。供应商不是合格状态或已有审批中的处置单,系统没有生成冻结草稿,请评估是否需要其他处置。', + ), + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'load_supplier', type: 'default' }, + { id: 'e2', source: 'load_supplier', target: 'load_qualifications', type: 'default' }, + { id: 'e3', source: 'load_qualifications', target: 'load_categories', type: 'default' }, + { id: 'e4', source: 'load_categories', target: 'load_all_managers', type: 'default' }, + { id: 'e5', source: 'load_all_managers', target: 'can_draft', type: 'default' }, + { id: 'e_yes', source: 'can_draft', target: 'find_draft', type: 'conditional', condition: cel$(canDraft), label: '合格且没有审批中的处置单' }, + { id: 'e_no', source: 'can_draft', target: 'notify_only_guard', type: 'conditional', condition: cel$(`!(${canDraft})`), label: '不能生成' }, + { id: 'e6', source: 'find_draft', target: 'has_draft', type: 'default' }, + { id: 'e_new', source: 'has_draft', target: 'create_draft', type: 'conditional', condition: P`existingDraft == null`, label: '没有' }, + { id: 'e_old', source: 'has_draft', target: 'link_existing', type: 'conditional', condition: P`existingDraft != null`, label: '已有' }, + { id: 'e7', source: 'create_draft', target: 'link_new', type: 'default' }, + { id: 'e8', source: 'link_new', target: 'notify_draft_guard', type: 'default' }, + { id: 'e9', source: 'link_existing', target: 'notify_draft_guard', type: 'default' }, + { id: 'e10', source: 'notify_draft_guard', target: 'end', type: 'default' }, + { id: 'e11', source: 'notify_only_guard', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/index.ts b/src/core/index.ts index c2270e8..e957f15 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -5,7 +5,16 @@ import * as views from './views/index.js'; import * as flows from './flows/index.js'; import * as apps from './apps/index.js'; import * as actions from './actions/index.js'; -import { DeliveryMetrics, QualityIssueMetrics, DeliveryDashboard, QualityDashboard, DeliveryScorecardReport } from './analytics/index.js'; +import { + DeliveryMetrics, + QualityIssueMetrics, + RiskEventMetrics, + SupplierMetrics, + DeliveryDashboard, + QualityDashboard, + SupplierDashboard, + DeliveryScorecardReport, +} from './analytics/index.js'; import * as translations from './translations/index.js'; import { SrmSeedData } from './data/index.js'; import { SrmPermissionSets } from './security/permission-sets.js'; @@ -36,8 +45,8 @@ export default defineStack({ flows: Object.values(flows), actions: Object.values(actions), apps: Object.values(apps), - datasets: [DeliveryMetrics, QualityIssueMetrics], - dashboards: [DeliveryDashboard, QualityDashboard], + datasets: [DeliveryMetrics, QualityIssueMetrics, RiskEventMetrics, SupplierMetrics], + dashboards: [DeliveryDashboard, QualityDashboard, SupplierDashboard], reports: [DeliveryScorecardReport], capabilities: SrmCapabilities, permissions: SrmPermissionSets, diff --git a/src/core/objects/index.ts b/src/core/objects/index.ts index a70ae47..f95cb57 100644 --- a/src/core/objects/index.ts +++ b/src/core/objects/index.ts @@ -15,3 +15,4 @@ export { DeliveryRecord } from './delivery-record.object.js'; export { QualityIssue } from './quality-issue.object.js'; export { SupplierAudit } from './supplier-audit.object.js'; export { SupplierAuditFinding } from './supplier-audit-finding.object.js'; +export { RiskEvent } from './risk-event.object.js'; diff --git a/src/core/objects/risk-event.object.ts b/src/core/objects/risk-event.object.ts new file mode 100644 index 0000000..87a3b16 --- /dev/null +++ b/src/core/objects/risk-event.object.ts @@ -0,0 +1,112 @@ +import { P } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +// 风险事件(02 §4.9,03 §11,FR-903):品类经理、SQE 手工登记工商、司法、经营等风险;必备证书过期时由 +// srm_supplier_qualification_recalc 以系统身份新建「资质」类事件(同一证书有未关闭的事件时不再建),资质恢复后 +// 由同一 flow 自动关闭。高风险事件由 srm_risk_event_apply 通知品类经理并生成冻结处置单草稿,回填 disposition。 +// 来源现阶段只有 system / manual:外部企业信息服务的自动拉取是客户驱动主题,external_ref 为它预留(按外部编号去重)。 +const closed = P`record.status == 'closed'`; + +export const RiskEvent = ObjectSchema.create({ + name: 'srm_risk_event', + label: '风险事件', + pluralLabel: '风险事件', + icon: 'siren', + // 临时:目标是 public_read + 风险岗位「改所有记录」。系统生成的事件属于系统,品类经理与 SQE 之间要互相接手, + // 平台的 modifyAllRecords 会连删除一起放开(objectstack#25470),只能先放宽 OWD;能改哪些字段由 allowEdit + // 与 FLS 决定,删除仍受 allowDelete 约束。门户不可见(04 §3.2) + sharingModel: 'public_read_write', + enable: { trackHistory: true }, + stageField: 'status', + nameField: 'event_no', + + fieldGroups: [ + { key: 'event', label: '风险事件', icon: 'siren' }, + { key: 'handling', label: '处理', icon: 'shield-check' }, + { key: 'external', label: '外部系统', icon: 'link' }, + ], + + fields: { + event_no: Field.autonumber({ label: '事件编号', group: 'event', autonumberFormat: 'RSK{YYYY}{00000}' }), + supplier: Field.lookup('srm_supplier', { + label: '供应商', + group: 'event', + required: true, + readonlyWhen: P`has(record.id) && record.id != null`, + relatedListColumns: ['event_no', 'risk_type', 'severity', 'source', 'status', 'disposition', 'created_at'], + }), + risk_type: Field.select({ label: '风险类型', group: 'event', required: true, picklist: 'srm_risk_type', readonlyWhen: closed }), + severity: Field.select({ + label: '严重度', + group: 'event', + required: true, + defaultValue: 'medium', + readonlyWhen: closed, + options: [ + { label: '高', value: 'high' }, + { label: '中', value: 'medium' }, + { label: '低', value: 'low' }, + ], + }), + // 手工登记一律为 manual;system 只由 flow 写入,external_api 留给外部企业信息服务(客户驱动主题) + source: Field.select({ + label: '来源', + group: 'event', + defaultValue: 'manual', + readonly: true, + options: [ + { label: '系统生成', value: 'system' }, + { label: '外部服务', value: 'external_api' }, + { label: '手工登记', value: 'manual' }, + ], + }), + // 资质类事件指回触发它的必备证书:同一证书有未关闭的事件时不重复建 + certificate: Field.lookup('srm_supplier_certificate', { + label: '相关证书', + group: 'event', + readonly: true, + displayField: 'cert_no', + relatedListColumns: ['event_no', 'severity', 'status', 'created_at'], + }), + description: Field.textarea({ label: '事件描述', group: 'event', required: true, maxLength: 4000, readonlyWhen: closed }), + + status: Field.select({ + label: '状态', + group: 'handling', + defaultValue: 'open', + options: [ + { label: '未处理', value: 'open', kind: 'open' }, + { label: '处理中', value: 'mitigating', kind: 'open' }, + { label: '已关闭', value: 'closed', kind: 'won' }, + ], + }), + mitigation_plan: Field.textarea({ label: '应对措施', group: 'handling', maxLength: 4000, readonlyWhen: closed }), + // 关闭时必填(FR-903「处理结论」,requiredWhen 在服务端同样生效);资质恢复自动关闭时由 flow 写入 + owner_note: Field.textarea({ label: '处理结论', group: 'handling', maxLength: 4000, requiredWhen: closed }), + // 高风险事件生成的冻结处置单草稿(srm_risk_event_apply 回填) + disposition: Field.lookup('srm_supplier_disposition', { + label: '处置单', + group: 'handling', + readonly: true, + relatedListColumns: ['event_no', 'risk_type', 'severity', 'status'], + }), + + external_ref: Field.text({ label: '外部事件编号', group: 'external', readonly: true, maxLength: 100 }), + }, + + validations: [ + { + name: 'risk_event_status_flow', + type: 'state_machine', + field: 'status', + message: '风险事件状态不允许这样变更', + initialStates: ['open'], + transitions: { + open: ['mitigating', 'closed'], + mitigating: ['open', 'closed'], + // 关错了或风险复现时重新打开 + closed: ['open'], + }, + }, + ], +}); diff --git a/src/core/objects/supplier-disposition.object.ts b/src/core/objects/supplier-disposition.object.ts index 545a832..993ff13 100644 --- a/src/core/objects/supplier-disposition.object.ts +++ b/src/core/objects/supplier-disposition.object.ts @@ -5,12 +5,28 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; // (srm_disposition_approval),通过后由 srm_disposition_apply 以系统身份改供应商状态和品类资格。 // 动作与供应商当前状态的合法组合在新建时校验,与供应商状态机(02 §5.1)一致;同一家供应商同时 // 只能有一张审批中的处置单,免得两张单子审完后互相覆盖。 +// 草稿(v0.5):高风险事件(srm_risk_event_apply)和绩效结果应用由 flow 生成处置单草稿,品类经理或采购主管 +// 核对后经「提交审批」(srm_disposition_submit)进入审批,或经「作废」(srm_disposition_cancel)放弃。 +// 提交时同一套状态校验再跑一遍(供应商状态可能在草稿期间变了)。提交人记入 submitted_by 并接管记录所有权, +// 审批 flow 以它为发起人(手工新建的处置单没有 submitted_by,发起人是新建人)。 // 供应商、动作、范围和品类只在新建时填写。服务端新建时不看 readonlyWhen,新建表单却会按默认值 // (状态「审批中」)把字段锁住,所以先判断记录已保存(有 id)。 // 临时绕法(objectstack#25584),平台修复后去掉 id 判断 const created = P`has(record.id) && record.id != null`; -// 原因与生效日期只在新建时和被退回修改期间可改;审批中平台锁定记录,审结后只读 +// 原因与生效日期只在新建时和被退回修改期间可改;审批中平台锁定记录,审结后只读。 +// 草稿的原因由生成它的 flow 写好(如风险事件编号与描述),要换原因就作废草稿、手工新建 const proposal = P`has(record.id) && record.id != null && record.approval_status != 'returned'`; +// 动作与供应商状态的组合在新建和草稿提交时各校验一次;「只能有一张审批中」在新建(非草稿)和提交时校验 +const allowedActions = + `has(record.supplier.status) && !(` + + `(record.action == 'freeze' && record.supplier.status == 'qualified')` + + ` || (record.action == 'unfreeze' && record.scope == 'all' && record.supplier.status == 'frozen')` + + ` || (record.action == 'unfreeze' && record.scope == 'category' && record.supplier.status == 'qualified')` + + ` || (record.action == 'eliminate' && (record.supplier.status == 'potential' || record.supplier.status == 'qualified' || record.supplier.status == 'frozen'))` + + ` || (record.action == 'blacklist' && (record.supplier.status == 'qualified' || record.supplier.status == 'frozen' || record.supplier.status == 'eliminated'))` + + ` || (record.action == 'remove_blacklist' && record.supplier.status == 'blacklisted'))`; +const hasOpen = `has(record.supplier.open_disposition_count) && record.supplier.open_disposition_count > 0`; +const submitting = `has(previous.status) && previous.status == 'draft' && record.status == 'in_review'`; export const SupplierDisposition = ObjectSchema.create({ name: 'srm_supplier_disposition', @@ -75,11 +91,15 @@ export const SupplierDisposition = ObjectSchema.create({ defaultValue: 'in_review', readonly: true, options: [ + { label: '草稿', value: 'draft', kind: 'open' }, { label: '审批中', value: 'in_review', kind: 'open' }, { label: '已执行', value: 'applied', kind: 'won' }, { label: '已驳回', value: 'rejected', kind: 'lost' }, + { label: '已作废', value: 'cancelled', kind: 'lost' }, ], }), + // 草稿的提交人(srm_disposition_submit 写入);手工新建的处置单为空,发起人即新建人 + submitted_by: Field.user({ label: '提交人', group: 'review', readonly: true }), // 审批节点写入的审批结论;returned = 退回修改 approval_status: Field.select({ label: '审批结论', @@ -108,11 +128,13 @@ export const SupplierDisposition = ObjectSchema.create({ type: 'state_machine', field: 'status', message: '处置单状态不允许这样变更', - initialStates: ['in_review'], + initialStates: ['in_review', 'draft'], transitions: { + draft: ['in_review', 'cancelled'], in_review: ['applied', 'rejected'], applied: [], rejected: [], + cancelled: [], }, }, { @@ -139,17 +161,15 @@ export const SupplierDisposition = ObjectSchema.create({ severity: 'error', events: ['insert'], message: '供应商当前状态不能执行这个处置动作', - condition: { - dialect: 'cel', - source: - `has(record.supplier.status) && !(` - + `(record.action == 'freeze' && record.supplier.status == 'qualified')` - + ` || (record.action == 'unfreeze' && record.scope == 'all' && record.supplier.status == 'frozen')` - + ` || (record.action == 'unfreeze' && record.scope == 'category' && record.supplier.status == 'qualified')` - + ` || (record.action == 'eliminate' && (record.supplier.status == 'potential' || record.supplier.status == 'qualified' || record.supplier.status == 'frozen'))` - + ` || (record.action == 'blacklist' && (record.supplier.status == 'qualified' || record.supplier.status == 'frozen' || record.supplier.status == 'eliminated'))` - + ` || (record.action == 'remove_blacklist' && record.supplier.status == 'blacklisted'))`, - }, + condition: { dialect: 'cel', source: allowedActions }, + }, + { + name: 'disposition_action_allowed_on_submit', + type: 'script', + severity: 'error', + events: ['update'], + message: '供应商当前状态不能执行这个处置动作', + condition: { dialect: 'cel', source: `${submitting} && ${allowedActions}` }, }, { name: 'one_open_disposition', @@ -157,7 +177,15 @@ export const SupplierDisposition = ObjectSchema.create({ severity: 'error', events: ['insert'], message: '这家供应商已有审批中的处置单,审结后再发起', - condition: P`has(record.supplier.open_disposition_count) && record.supplier.open_disposition_count > 0`, + condition: { dialect: 'cel', source: `record.status != 'draft' && ${hasOpen}` }, + }, + { + name: 'one_open_disposition_on_submit', + type: 'script', + severity: 'error', + events: ['update'], + message: '这家供应商已有审批中的处置单,审结后再发起', + condition: { dialect: 'cel', source: `${submitting} && ${hasOpen}` }, }, ], }); diff --git a/src/core/objects/supplier.object.ts b/src/core/objects/supplier.object.ts index abd7b5b..467281c 100644 --- a/src/core/objects/supplier.object.ts +++ b/src/core/objects/supplier.object.ts @@ -148,6 +148,24 @@ export const Supplier = ObjectSchema.create({ filter: { status: 'in_review' }, }, }), + // 黑名单名录(02 §4.1,FR-208):拉入黑名单的处置单执行时由 srm_disposition_apply 写入,只读。 + // 禁入期满日 = 拉黑日期 + blacklist_years 年,到期前 30 天和当天提醒(srm_blacklist_expiry_notice), + // 只提醒,解除仍走「解除黑名单」处置单审批。解除后保留为最近一次拉黑的记录 + blacklisted_on: Field.date({ label: '拉黑日期', group: 'qualification', readonly: true }), + blacklist_until: Field.date({ label: '禁入期满日', group: 'qualification', readonly: true }), + blacklist_disposition: Field.lookup('srm_supplier_disposition', { label: '拉黑处置单', group: 'qualification', readonly: true }), + blacklist_reason: Field.textarea({ label: '拉黑原因', group: 'qualification', readonly: true }), + // 未关闭的风险事件(未处理、处理中),供供应商列表与名录参考(v0.5) + open_risk_event_count: Field.summary({ + label: '未关闭风险事件数', + group: 'qualification', + summaryOperations: { + object: 'srm_risk_event', + field: 'id', + function: 'count', + filter: { status: { $in: ['open', 'mitigating'] } }, + }, + }), // 银行账户「每家至多一个默认账户」的校验读这个计数(v0.2) default_bank_account_count: Field.summary({ label: '默认银行账户数', diff --git a/src/core/picklists/index.ts b/src/core/picklists/index.ts index a147956..d639769 100644 --- a/src/core/picklists/index.ts +++ b/src/core/picklists/index.ts @@ -3,3 +3,4 @@ export { CertTypePicklist } from './cert-type.picklist.js'; export { ContractTypePicklist } from './contract-type.picklist.js'; export { MaterialClassPicklist } from './material-class.picklist.js'; export { QualityIssueSourcePicklist } from './quality-issue-source.picklist.js'; +export { RiskTypePicklist } from './risk-type.picklist.js'; diff --git a/src/core/picklists/risk-type.picklist.ts b/src/core/picklists/risk-type.picklist.ts new file mode 100644 index 0000000..8b93492 --- /dev/null +++ b/src/core/picklists/risk-type.picklist.ts @@ -0,0 +1,17 @@ +import { definePicklist } from '@objectstack/spec/data'; + +// 风险类型(02 §4.9):扩展包可能追加类型(02 §1 第 13 条),所以做成共享选项列表 +export const RiskTypePicklist = definePicklist({ + name: 'srm_risk_type', + label: '风险类型', + options: [ + { label: '资质', value: 'qualification' }, + { label: '司法诉讼', value: 'legal' }, + { label: '经营异常', value: 'operation' }, + { label: '财务', value: 'financial' }, + { label: '交付', value: 'delivery' }, + { label: '质量', value: 'quality' }, + { label: '环境社会治理', value: 'esg' }, + { label: '制裁名单', value: 'sanction' }, + ], +}); diff --git a/src/core/security/permission-sets.ts b/src/core/security/permission-sets.ts index 13ff850..027cd53 100644 --- a/src/core/security/permission-sets.ts +++ b/src/core/security/permission-sets.ts @@ -37,6 +37,8 @@ export const InternalBasePermissionSet = { // 供应商审核内部只读:品类经理推进准入时看审核结论(v0.4) srm_supplier_audit: read, srm_supplier_audit_finding: read, + // 风险事件内部只读(04 §2.3),品类经理与 SQE 登记和处理(v0.5) + srm_risk_event: read, }, }; @@ -93,6 +95,8 @@ export const SupplierAdminPermissionSet = { srm_contract_line: write, // 发起冻结 / 解冻 / 淘汰 / 黑名单;新建即进入审批,改只限被退回修改期间(对象上的 readonlyWhen,v0.3) srm_supplier_disposition: write, + // 登记与处理风险事件(04 §2.3,v0.5);来源、相关证书、处置单只由 flow 写入(对象上的 readonly) + srm_risk_event: write, }, // 银行账号完整可读(BR-20,04 §4.2) systemPermissions: ['srm_view_bank_account_no'], @@ -132,6 +136,8 @@ export const QualityPermissionSet = { // 供应商审核:安排准入、专项审核,执行年度计划,录入结论与问题项(FR-607) srm_supplier_audit: write, srm_supplier_audit_finding: write, + // 登记与处理风险事件(04 §2.3,v0.5):资质、质量类风险由 SQE 跟进 + srm_risk_event: write, }, fields: { ...supplierFieldsReadOnly, @@ -175,6 +181,7 @@ export const ExecutivePermissionSet = { srm_quality_issue: read, srm_supplier_audit: read, srm_supplier_audit_finding: read, + srm_risk_event: read, }, }; @@ -261,6 +268,7 @@ export const AuditorPermissionSet = { srm_quality_issue: read, srm_supplier_audit: read, srm_supplier_audit_finding: read, + srm_risk_event: read, }, }; diff --git a/src/core/translations/en.ts b/src/core/translations/en.ts index 47ab7cb..db9f199 100644 --- a/src/core/translations/en.ts +++ b/src/core/translations/en.ts @@ -118,11 +118,18 @@ export const en: TranslationData = { primary_contact_count: { label: 'Primary Contacts' }, expired_certificate_count: { label: 'Expired Certificates' }, default_bank_account_count: { label: 'Default Bank Accounts' }, + blacklisted_on: { label: 'Blacklisted On' }, + blacklist_until: { label: 'Blacklist Ban Ends' }, + blacklist_disposition: { label: 'Blacklist Disposition' }, + blacklist_reason: { label: 'Blacklist Reason' }, + open_risk_event_count: { label: 'Open Risk Events' }, }, _views: { default: { label: 'All Suppliers' }, qualification_alert: { label: 'Qualification Alerts' }, legacy_review: { label: 'Legacy Imports to Review' }, + blacklist: { label: 'Blacklist Register' }, + open_risk: { label: 'With Open Risks' }, primary_contact_conflict: { label: 'Duplicate Primary Contacts' }, }, _actions: { @@ -306,6 +313,12 @@ export const en: TranslationData = { disposition_categories_required: { message: 'Select at least one category' }, disposition_action_allowed: { message: 'This action is not allowed for the supplier\'s current status' }, one_open_disposition: { message: 'This supplier already has a disposition in review; wait until it is decided' }, + disposition_action_allowed_on_submit: { message: 'This action is not allowed for the supplier\'s current status' }, + one_open_disposition_on_submit: { message: 'This supplier already has a disposition in review; wait until it is decided' }, + }, + _actions: { + srm_disposition_submit: { label: 'Submit for Approval' }, + srm_disposition_cancel: { label: 'Cancel Draft' }, }, fields: { disposition_no: { label: 'Disposition No.' }, @@ -324,7 +337,11 @@ export const en: TranslationData = { categories: { label: 'Categories' }, reason: { label: 'Reason' }, effective_date: { label: 'Effective Date' }, - status: { label: 'Status', options: { in_review: 'In Review', applied: 'Applied', rejected: 'Rejected' } }, + status: { + label: 'Status', + options: { draft: 'Draft', in_review: 'In Review', applied: 'Applied', rejected: 'Rejected', cancelled: 'Cancelled' }, + }, + submitted_by: { label: 'Submitted By' }, approval_status: { label: 'Approval Result', options: { pending: 'Pending', approved: 'Approved', rejected: 'Rejected', returned: 'Returned for Revision', recalled: 'Recalled' }, @@ -338,6 +355,7 @@ export const en: TranslationData = { }, _views: { default: { label: 'All Dispositions' }, + draft: { label: 'Drafts to Submit' }, in_review: { label: 'In Review' }, applied: { label: 'Applied' }, }, @@ -645,6 +663,34 @@ export const en: TranslationData = { quality_issue: { label: 'SCAR' }, }, }, + srm_risk_event: { + label: 'Risk Event', + pluralLabel: 'Risk Events', + _sections: { event: { label: 'Risk Event' }, handling: { label: 'Handling' }, external: { label: 'External Systems' } }, + _validations: { + risk_event_status_flow: { message: 'This risk event status change is not allowed' }, + }, + fields: { + event_no: { label: 'Risk Event No.' }, + supplier: { label: 'Supplier' }, + risk_type: { label: 'Risk Type' }, + severity: { label: 'Severity', options: { high: 'High', medium: 'Medium', low: 'Low' } }, + source: { label: 'Source', options: { system: 'System', external_api: 'External Service', manual: 'Manual Entry' } }, + certificate: { label: 'Related Certificate' }, + description: { label: 'Description' }, + status: { label: 'Status', options: { open: 'Open', mitigating: 'Mitigating', closed: 'Closed' } }, + mitigation_plan: { label: 'Mitigation Plan' }, + owner_note: { label: 'Conclusion' }, + disposition: { label: 'Disposition' }, + external_ref: { label: 'External Event No.' }, + }, + _views: { + default: { label: 'All Risk Events' }, + board: { label: 'Risk Board' }, + unclosed: { label: 'Open Risk Events' }, + by_type: { label: 'By Type' }, + }, + }, }, picklists: { srm_country: { @@ -684,6 +730,19 @@ export const en: TranslationData = { label: 'Material Class', options: { class_a: 'Class A (Strategic)', class_b: 'Class B (Important)', class_c: 'Class C (General)' }, }, + srm_risk_type: { + label: 'Risk Type', + options: { + qualification: 'Qualification', + legal: 'Litigation', + operation: 'Business Irregularity', + financial: 'Financial', + delivery: 'Delivery', + quality: 'Quality', + esg: 'ESG', + sanction: 'Sanctions List', + }, + }, srm_contract_type: { label: 'Contract Type', options: { @@ -761,6 +820,36 @@ export const en: TranslationData = { refuse_none: { message: 'No nonconformities left to raise: observations are not raised, and findings already raised are not raised again' }, }, }, + srm_disposition_submit: { + label: 'Submit Disposition for Approval', + successMessage: 'Disposition submitted for approval', + screens: { + confirm: { + title: 'Submit disposition: {{ disp.disposition_no }}', + description: + 'Reason: {{ disp.reason }}\nOnce submitted, the disposition goes to approval with you as its initiator. If the reason does not fit, cancel the draft and raise a disposition by hand.', + }, + }, + refusals: { + refuse_position: { message: 'Only category managers or buyer leads can submit dispositions' }, + refuse_status: { message: 'Only draft dispositions need submitting; a disposition raised by hand is already in review' }, + }, + }, + srm_disposition_cancel: { + label: 'Cancel Disposition Draft', + successMessage: 'Disposition draft cancelled', + screens: { + confirm: { + title: 'Cancel disposition draft: {{ disp.disposition_no }}', + description: 'The draft will not be submitted for approval and the supplier status stays as it is.', + fields: { cancel_reason: { label: 'Cancellation Reason' } }, + }, + }, + refusals: { + refuse_position: { message: 'Only category managers or buyer leads can cancel disposition drafts' }, + refuse_status: { message: 'Only drafts can be cancelled; a disposition in review is rejected by its approvers' }, + }, + }, }, apps: { srm_workbench: { @@ -779,9 +868,12 @@ export const en: TranslationData = { nav_delivery_record: { label: 'Delivery Records' }, nav_quality_issue: { label: 'Corrective Action Requests' }, nav_supplier_audit: { label: 'Supplier Audits' }, + group_performance_risk: { label: 'Performance and Risk' }, + nav_risk_event: { label: 'Risk Events' }, group_analytics: { label: 'Analytics' }, nav_delivery_dashboard: { label: 'Delivery Dashboard' }, nav_quality_dashboard: { label: 'Quality Dashboard' }, + nav_supplier_dashboard: { label: 'Supplier Dashboard' }, nav_delivery_scorecard: { label: 'Supplier Delivery and Quality' }, group_master_data: { label: 'Master Data' }, nav_category: { label: 'Purchasing Categories' }, @@ -812,8 +904,50 @@ export const en: TranslationData = { overdue_issues: { title: 'Overdue Corrective Action Requests' }, }, }, + srm_supplier_dashboard: { + label: 'Suppliers', + description: 'Suppliers by lifecycle status, qualification alerts and blacklisted suppliers, open risk events by severity and type', + widgets: { + suppliers_by_status: { title: 'Suppliers by Lifecycle Status' }, + qualification_alerts: { title: 'Suppliers with Qualification Alerts' }, + blacklisted_suppliers: { title: 'Blacklisted Suppliers' }, + unclosed_risks_by_severity: { title: 'Open Risk Events by Severity' }, + unclosed_risks_by_type: { title: 'Open Risk Events by Type' }, + unclosed_high_risks: { title: 'Open High-severity Risk Events' }, + }, + }, }, datasets: { + srm_risk_event_metrics: { + label: 'Risk Event Statistics', + description: 'Risk events by severity, type, source and supplier, with the open count (risk events, 05 §4.2)', + dimensions: { + severity: { label: 'Severity' }, + risk_type: { label: 'Risk Type' }, + source: { label: 'Source' }, + status: { label: 'Status' }, + supplier: { label: 'Supplier' }, + created_month: { label: 'Month Registered' }, + }, + measures: { + event_count: { label: 'Risk Events' }, + unclosed_count: { label: 'Open Risk Events' }, + unclosed_high_count: { label: 'Open High-severity Risk Events' }, + }, + }, + srm_supplier_metrics: { + label: 'Supplier Statistics', + description: 'Suppliers by lifecycle status and type, with qualification alerts and blacklisted suppliers (suppliers, 05 §4.2)', + dimensions: { + status: { label: 'Status' }, + supplier_type: { label: 'Supplier Type' }, + }, + measures: { + supplier_count: { label: 'Suppliers' }, + alert_count: { label: 'Suppliers with Qualification Alerts' }, + blacklisted_count: { label: 'Blacklisted Suppliers' }, + }, + }, srm_quality_issue_metrics: { label: 'Corrective Action Request Statistics', description: 'Corrective action requests by status, supplier and severity, with the overdue count (05 §4.2)', diff --git a/src/core/views/index.ts b/src/core/views/index.ts index 7ff64e2..e569bf1 100644 --- a/src/core/views/index.ts +++ b/src/core/views/index.ts @@ -11,3 +11,4 @@ export { SupplierDispositionViews } from './supplier-disposition.view.js'; export { DeliveryRecordViews } from './delivery-record.view.js'; export { QualityIssueViews } from './quality-issue.view.js'; export { SupplierAuditViews } from './supplier-audit.view.js'; +export { RiskEventViews } from './risk-event.view.js'; diff --git a/src/core/views/risk-event.view.ts b/src/core/views/risk-event.view.ts new file mode 100644 index 0000000..30cdb9d --- /dev/null +++ b/src/core/views/risk-event.view.ts @@ -0,0 +1,59 @@ +import { defineView } from '@objectstack/spec'; + +const data = { provider: 'object' as const, object: 'srm_risk_event' }; +const columns = [ + { field: 'event_no' }, + { field: 'supplier' }, + { field: 'risk_type' }, + { field: 'severity' }, + { field: 'source' }, + { field: 'status' }, + { field: 'disposition' }, + { field: 'created_at' }, +]; +const unclosed = { field: 'status', operator: 'in' as const, value: ['open', 'mitigating'] }; + +// 05 §1、§4.2:未关闭风险事件按严重度分列给品类经理、SQE 跟进;按类型看分布。供应商详情页的风险事件列表 +// 来自事件的 supplier 关联(relatedListColumns) +export const RiskEventViews = defineView({ + list: { + label: '全部风险事件', + type: 'grid', + data, + columns, + sort: [{ field: 'event_no', order: 'desc' }], + }, + + listViews: { + board: { + label: '风险看板', + type: 'kanban', + data, + columns: ['event_no', 'supplier', 'risk_type', 'status', 'disposition'], + filter: [unclosed], + kanban: { + groupByField: 'severity', + columns: ['event_no', 'supplier', 'risk_type', 'status', 'disposition'], + }, + }, + unclosed: { + label: '未关闭风险事件', + type: 'grid', + data, + columns, + filter: [unclosed], + sort: [{ field: 'created_at', order: 'asc' }], + }, + by_type: { + label: '按类型', + type: 'kanban', + data, + columns: ['event_no', 'supplier', 'severity', 'status'], + filter: [unclosed], + kanban: { + groupByField: 'risk_type', + columns: ['event_no', 'supplier', 'severity', 'status'], + }, + }, + }, +}); diff --git a/src/core/views/supplier-disposition.view.ts b/src/core/views/supplier-disposition.view.ts index 271c6b2..58885bc 100644 --- a/src/core/views/supplier-disposition.view.ts +++ b/src/core/views/supplier-disposition.view.ts @@ -10,6 +10,7 @@ const columns = [ { field: 'approval_status' }, { field: 'effective_date' }, { field: 'created_by' }, + { field: 'submitted_by' }, { field: 'applied_at' }, ]; @@ -23,6 +24,14 @@ export const SupplierDispositionViews = defineView({ }, listViews: { + // flow 生成的草稿(高风险事件、绩效结果应用,v0.5):品类经理核实后提交审批或作废 + draft: { + label: '待提交草稿', + type: 'grid', + data, + columns, + filter: [{ field: 'status', operator: 'equals', value: 'draft' }], + }, in_review: { label: '审批中', type: 'grid', diff --git a/src/core/views/supplier.view.ts b/src/core/views/supplier.view.ts index 46cc941..c44d63d 100644 --- a/src/core/views/supplier.view.ts +++ b/src/core/views/supplier.view.ts @@ -46,6 +46,31 @@ export const SupplierViews = defineView({ ], sort: [{ field: 'supplier_no', order: 'asc' }], }, + // 黑名单名录(FR-208,v0.5):拉黑日期、原因、处置单与禁入期满日;期满前提醒,解除仍走处置单审批 + blacklist: { + label: '黑名单名录', + type: 'grid', + data, + columns: [ + { field: 'supplier_no' }, + { field: 'name' }, + { field: 'country' }, + { field: 'blacklisted_on' }, + { field: 'blacklist_until' }, + { field: 'blacklist_reason' }, + { field: 'blacklist_disposition' }, + ], + filter: [{ field: 'status', operator: 'equals', value: 'blacklisted' }], + sort: [{ field: 'blacklisted_on', order: 'desc' }], + }, + // 有未关闭风险事件的供应商(v0.5) + open_risk: { + label: '有未关闭风险', + type: 'grid', + data, + columns: [{ field: 'supplier_no' }, { field: 'name' }, { field: 'status' }, { field: 'open_risk_event_count' }, { field: 'qualification_alert' }], + filter: [{ field: 'open_risk_event_count', operator: 'greater_than', value: 0 }], + }, // 同一批导入里的两位主联系人校验规则拦不住(objectstack#25477),在这里人工处理 primary_contact_conflict: { label: '主联系人重复', diff --git a/test/permissions.test.ts b/test/permissions.test.ts index 62a5fb8..4963594 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -69,6 +69,7 @@ describe('internal permissions', () => { "srm_material": "R", "srm_plant": "R", "srm_quality_issue": "RE", + "srm_risk_event": "R", "srm_supplier": "R", "srm_supplier_audit": "R", "srm_supplier_audit_finding": "R", @@ -92,6 +93,7 @@ describe('internal permissions', () => { "srm_material": "RE", "srm_plant": "R", "srm_quality_issue": "R", + "srm_risk_event": "RCE", "srm_supplier": "RCE", "srm_supplier_application": "RCE", "srm_supplier_audit": "R", @@ -117,6 +119,7 @@ describe('internal permissions', () => { "srm_material": "R", "srm_plant": "R", "srm_quality_issue": "R", + "srm_risk_event": "R", "srm_supplier": "R", "srm_supplier_audit": "R", "srm_supplier_audit_finding": "R", @@ -139,6 +142,7 @@ describe('internal permissions', () => { "srm_material": "R", "srm_plant": "R", "srm_quality_issue": "R", + "srm_risk_event": "R", "srm_supplier": "R", "srm_supplier_application": "R", "srm_supplier_audit": "R", @@ -163,6 +167,7 @@ describe('internal permissions', () => { "srm_material": "R", "srm_plant": "R", "srm_quality_issue": "R", + "srm_risk_event": "R", "srm_supplier": "R", "srm_supplier_application": "R", "srm_supplier_audit": "R", @@ -190,6 +195,7 @@ describe('internal permissions', () => { "srm_material": "R", "srm_plant": "R", "srm_quality_issue": "R", + "srm_risk_event": "R", "srm_supplier": "R", "srm_supplier_audit": "R", "srm_supplier_audit_finding": "R", diff --git a/test/risk-event.test.ts b/test/risk-event.test.ts new file mode 100644 index 0000000..df6192a --- /dev/null +++ b/test/risk-event.test.ts @@ -0,0 +1,286 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import config from '../objectstack.config'; +import { Category, RiskEvent, Supplier, SupplierCategory, SupplierCertificate, SupplierDisposition } from '../src/core/objects'; + +// v0.5 R1 风险事件与黑名单名录(02 §4.9,03 §5、§11,FR-208、FR-902、FR-903):手工登记、必备证书过期生成资质事件 +// 与恢复后关闭、高风险事件生成冻结处置单草稿(提交审批 / 作废)、黑名单名录字段与禁入期满提醒。 +// 测试数据全部在这里生成(示例公司名、示例品类),不依赖外部样本。 +describe('risk events and the blacklist register', () => { + let stack: VerifyStack; + let seq = 0; + + type Person = { token: string; id: string }; + 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(`rsk-${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 row = async (object: string, id: string) => (await stack.rows(object, { id }))[0]!; + const system = (object: string, doc: Record) => + stack.hooks.run(object, 'insert', doc, { system: true }) as Promise<{ id: string }>; + const inbox = async (userId: string, recordId: string) => + (await stack.rows('sys_inbox_message', { user_id: userId })).filter((m) => String(m.action_url).endsWith(`/${recordId}`)); + + // 合格供应商(存量导入建档即为合格),一个合格的品类资格,品类经理为 manager + const qualifiedSupplier = async () => { + const n = ++seq; + const supplier = await system(Supplier.name, { + name: `示例风险测试有限公司 ${n}`, + credit_code: `91000000MA0RSK${String(n).padStart(4, '0')}`, + legacy_imported: true, + }); + const category = await system(Category.name, { code: `RSK${n}`, name: `示例风险品类 ${n}`, category_manager: manager.id }); + await system(SupplierCategory.name, { supplier: supplier.id, category: category.id, status: 'qualified' }); + return supplier.id; + }; + const register = (as: Person, doc: Record) => + stack.hooks.run(RiskEvent.name, 'insert', { risk_type: 'legal', description: '示例:供应商涉及合同纠纷诉讼', ...doc }, { as: as.token }) as Promise<{ + id: string; + }>; + const events = (filter: Record) => stack.rows(RiskEvent.name, filter); + const dispositions = (supplier: string) => stack.rows(SupplierDisposition.name, { supplier }); + + const sweep = async () => { + const run = await stack.flows.run('srm_certificate_expire_sweep', {}, { system: true }); + expect(run.success, JSON.stringify(run)).toBe(true); + }; + const certificate = async (supplier: string, validTo: string, isMandatory: boolean) => { + const name = `rsk-cert-${++seq}.pdf`; + const [file] = await stack.seed('sys_file', [ + { key: `attachments/${name}`, name, mime_type: 'application/pdf', size: 1, scope: 'attachments', status: 'committed' }, + ]); + return ( + await stack.hooks.run( + SupplierCertificate.name, + 'insert', + { + supplier, + cert_type: 'iso9001', + cert_no: `示例证书 ${seq}`, + valid_from: day(-400), + valid_to: validTo, + is_mandatory: isMandatory, + attachment: String(file!.id), + }, + { as: sqe.token }, + ) + ).id as string; + }; + + // 启动 screen flow;停在确认页时用 input 提交,拒绝时返回 end 节点的结果 + const runScreen = async (as: Person, flow: string, recordId: string, input: Record = {}) => { + const started = (await stack.actions.run(SupplierDisposition.name, flow, { as: as.token, recordId })) as { runId?: string; status?: string }; + if (started.status !== 'paused' || !started.runId) return started; + return stack.flows.resume({ flowName: flow, runId: started.runId }, input, { as: as.token }); + }; + const pending = async (recordId: string) => + (await stack.rows('sys_approval_request', { record_id: recordId })).find((r) => r.status === 'pending'); + const decide = async (as: Person, recordId: string, verdict: 'approve' | 'reject') => { + 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: verdict }); + expect(res.status, await res.clone().text()).toBe(200); + }; + + let manager: Person; + let lead: Person; + let sqe: Person; + let buyer: Person; + let director: Person; + let gm: Person; + let compliance: Person; + + // 定时类 flow 默认不挂(objectstack#25442);这里打开,禁入期满提醒才能由平台的扫描任务触发 + const scheduledWork = process.env.OS_AUTOMATION_SCHEDULED_WORK_ENABLED; + + beforeAll(async () => { + process.env.OS_AUTOMATION_SCHEDULED_WORK_ENABLED = 'true'; + stack = await bootStack(config); + manager = await person('srm_category_manager'); + lead = await person('srm_buyer_lead'); + sqe = await person('srm_sqe'); + buyer = await person('srm_buyer'); + director = await person('srm_procurement_director'); + gm = await person('srm_general_manager'); + compliance = await person('srm_procurement_compliance'); + }); + + 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('registers a risk event by hand and closes it only with a conclusion', async () => { + const supplier = await qualifiedSupplier(); + // 来源只由 flow 写:手工登记写了 system 也记为手工 + const event = await register(manager, { supplier, severity: 'medium', source: 'system' }); + expect(await row(RiskEvent.name, event.id)).toMatchObject({ status: 'open', source: 'manual', severity: 'medium' }); + expect(String((await row(RiskEvent.name, event.id)).event_no)).toMatch(/^RSK\d{9}$/); + + await stack.hooks.run(RiskEvent.name, 'update', { id: event.id, status: 'mitigating', mitigation_plan: '示例:要求供应商提交诉讼进展说明' }, { as: sqe.token }); + await expect(stack.hooks.run(RiskEvent.name, 'update', { id: event.id, status: 'closed' }, { as: sqe.token })).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + }); + await stack.hooks.run(RiskEvent.name, 'update', { id: event.id, status: 'closed', owner_note: '示例:诉讼已和解,不影响供货' }, { as: sqe.token }); + expect((await row(RiskEvent.name, event.id)).status).toBe('closed'); + // 中风险不生成处置单草稿 + expect(await dispositions(supplier)).toHaveLength(0); + }); + + it('does not let a buyer register risk events', async () => { + const supplier = await qualifiedSupplier(); + await expect(register(buyer, { supplier })).rejects.toBeTruthy(); + expect(await events({ supplier })).toHaveLength(0); + }); + + it('opens a qualification event when a mandatory certificate expires and closes it once restored', async () => { + const supplier = await qualifiedSupplier(); + const cert = await certificate(supplier, day(-1), true); + await sweep(); + expect((await row(Supplier.name, supplier)).qualification_alert).toBe(true); + const opened = await events({ supplier }); + expect(opened).toHaveLength(1); + expect(opened[0]).toMatchObject({ risk_type: 'qualification', source: 'system', severity: 'medium', status: 'open', certificate: cert }); + expect(String(opened[0]!.description)).toContain('ISO 9001'); + + // 再扫一次、改证书的其他字段都不重复建 + await sweep(); + await stack.hooks.run(SupplierCertificate.name, 'update', { id: cert, issuer: '示例认证机构' }, { as: sqe.token }); + expect(await events({ supplier })).toHaveLength(1); + + // 延期后次日扫描:资质异常解除,事件自动关闭并写处理结论 + await stack.hooks.run(SupplierCertificate.name, 'update', { id: cert, valid_to: day(365) }, { as: sqe.token }); + await sweep(); + expect((await row(Supplier.name, supplier)).qualification_alert).toBe(false); + const closed = await row(RiskEvent.name, opened[0]!.id as string); + expect(closed.status).toBe('closed'); + expect(String(closed.owner_note)).toContain('自动关闭'); + }); + + it('opens no event for an optional certificate and keeps hand-registered events open on recovery', async () => { + const supplier = await qualifiedSupplier(); + await certificate(supplier, day(-1), false); + await sweep(); + expect(await events({ supplier })).toHaveLength(0); + + const manual = await register(sqe, { supplier, risk_type: 'qualification', description: '示例:供应商生产许可证正在换证' }); + const cert = await certificate(supplier, day(-1), true); + await sweep(); + await stack.hooks.run(SupplierCertificate.name, 'update', { id: cert, valid_to: day(365) }, { as: sqe.token }); + await sweep(); + expect((await row(RiskEvent.name, manual.id)).status).toBe('open'); + }); + + it('drafts a freeze disposition for a high-severity event, and the category manager submits it', async () => { + const supplier = await qualifiedSupplier(); + const event = await register(manager, { supplier, severity: 'high', risk_type: 'sanction', description: '示例:供应商列入制裁名单' }); + const [draft] = await dispositions(supplier); + expect(draft).toMatchObject({ status: 'draft', action: 'freeze', scope: 'all' }); + expect(String(draft!.reason)).toContain('示例:供应商列入制裁名单'); + expect((await row(RiskEvent.name, event.id)).disposition).toBe(draft!.id); + // 草稿不进审批,供应商不变;通知品类经理 + expect(await pending(draft!.id as string)).toBeUndefined(); + expect((await row(Supplier.name, supplier)).status).toBe('qualified'); + await expect.poll(async () => (await inbox(manager.id, event.id)).length).toBe(1); + + // 第二个高风险事件并到同一张草稿 + const second = await register(sqe, { supplier, severity: 'high', risk_type: 'operation', description: '示例:经营异常名录' }); + expect(await dispositions(supplier)).toHaveLength(1); + expect((await row(RiskEvent.name, second.id)).disposition).toBe(draft!.id); + + // 采购员不能提交;品类经理提交后以他为发起人审批,通过后冻结 + expect(await runScreen(buyer, 'srm_disposition_submit', draft!.id as string)).toMatchObject({ status: 'refused' }); + await runScreen(manager, 'srm_disposition_submit', draft!.id as string); + expect(await row(SupplierDisposition.name, draft!.id as string)).toMatchObject({ status: 'in_review', submitted_by: manager.id }); + await decide(director, draft!.id as string, 'approve'); + expect(await row(SupplierDisposition.name, draft!.id as string)).toMatchObject({ status: 'applied', director_approver: director.id }); + expect((await row(Supplier.name, supplier)).status).toBe('frozen'); + }); + + it('routes a draft submitted by a buyer lead through the category manager level first', async () => { + const supplier = await qualifiedSupplier(); + await register(sqe, { supplier, severity: 'high', description: '示例:重大质量事故' }); + const [draft] = await dispositions(supplier); + await runScreen(lead, 'srm_disposition_submit', draft!.id as string); + await decide(manager, draft!.id as string, 'approve'); + expect(await row(SupplierDisposition.name, draft!.id as string)).toMatchObject({ status: 'in_review', category_approver: manager.id }); + await decide(director, draft!.id as string, 'approve'); + expect((await row(Supplier.name, supplier)).status).toBe('frozen'); + }); + + it('lets the category manager cancel a draft, and drafts again when an event is raised to high', async () => { + const supplier = await qualifiedSupplier(); + const event = await register(manager, { supplier, severity: 'high' }); + const [draft] = await dispositions(supplier); + await runScreen(manager, 'srm_disposition_cancel', draft!.id as string, { cancel_reason: '示例:核实后风险不成立' }); + expect(await row(SupplierDisposition.name, draft!.id as string)).toMatchObject({ status: 'cancelled', reject_reason: '示例:核实后风险不成立' }); + expect((await row(Supplier.name, supplier)).status).toBe('qualified'); + // 作废的草稿不能再提交 + expect(await runScreen(manager, 'srm_disposition_submit', draft!.id as string)).toMatchObject({ status: 'refused' }); + + // 中风险改为高风险时生成草稿;已回填处置单的事件不再生成 + const medium = await register(manager, { supplier, severity: 'medium' }); + expect((await dispositions(supplier)).filter((d) => d.status === 'draft')).toHaveLength(0); + await stack.hooks.run(RiskEvent.name, 'update', { id: medium.id, severity: 'high' }, { as: manager.token }); + expect((await dispositions(supplier)).filter((d) => d.status === 'draft')).toHaveLength(1); + expect((await row(RiskEvent.name, event.id)).disposition).toBe(draft!.id); + }); + + it('only notifies for a high-severity event on a supplier that cannot be frozen', async () => { + const supplier = await qualifiedSupplier(); + await stack.hooks.run(Supplier.name, 'update', { id: supplier, status: 'frozen' }, { system: true }); + const event = await register(manager, { supplier, severity: 'high' }); + expect(await dispositions(supplier)).toHaveLength(0); + expect((await row(RiskEvent.name, event.id)).disposition ?? null).toBeNull(); + await expect.poll(async () => (await inbox(manager.id, event.id)).length).toBe(1); + }); + + it('refuses to submit a draft once the supplier can no longer be frozen', async () => { + const supplier = await qualifiedSupplier(); + await register(manager, { supplier, severity: 'high' }); + const [draft] = await dispositions(supplier); + // 草稿期间另一张处置单已审批中 + const eliminate = (await stack.hooks.run( + SupplierDisposition.name, + 'insert', + { supplier, action: 'eliminate', reason: '示例:连续两期 D 级' }, + { as: manager.token }, + )) as { id: string }; + await expect(runScreen(manager, 'srm_disposition_submit', draft!.id as string)).rejects.toBeTruthy(); + expect((await row(SupplierDisposition.name, draft!.id as string)).status).toBe('draft'); + expect((await row(SupplierDisposition.name, eliminate.id)).status).toBe('in_review'); + }); + + it('records the blacklist register and reminds before the ban ends', async () => { + const supplier = await qualifiedSupplier(); + const blacklist = (await stack.hooks.run( + SupplierDisposition.name, + 'insert', + { supplier, action: 'blacklist', reason: '示例:提供虚假资质文件' }, + { as: manager.token }, + )) as { id: string }; + await decide(director, blacklist.id, 'approve'); + await decide(gm, blacklist.id, 'approve'); + await decide(compliance, blacklist.id, 'approve'); + const listed = await row(Supplier.name, supplier); + expect(listed).toMatchObject({ + status: 'blacklisted', + blacklisted_on: day(0), + blacklist_disposition: blacklist.id, + blacklist_reason: '示例:提供虚假资质文件', + }); + expect(String(listed.blacklist_until)).toBe(`${Number(day(0).slice(0, 4)) + 5}${day(0).slice(4)}`); + + // 禁入期满前 30 天提醒发起人和品类经理(只提醒,不自动解除) + await stack.hooks.run(Supplier.name, 'update', { id: supplier, blacklist_until: day(30) }, { system: true }); + const job = stack.kernel.getService<{ trigger(name: string): Promise }>('job'); + await job.trigger('flow-time-relative:srm_blacklist_expiry_notice'); + await expect.poll(async () => (await inbox(manager.id, supplier)).length).toBeGreaterThan(0); + expect((await row(Supplier.name, supplier)).status).toBe('blacklisted'); + }); +});