diff --git a/README.md b/README.md index c3a56a0..bced139 100644 --- a/README.md +++ b/README.md @@ -88,17 +88,17 @@ pnpm validate # 每次改元数据后跑;其余检查见 AGENTS.md §6 ## 当前状态 -2026-10-23:范围与路线重定(ADR-0008、ADR-0009)。核心收到 **27 个对象**,按版本交付: +2026-10-23:范围与路线重定(ADR-0008、ADR-0009)。核心收到 **27 个对象**(2026-10-24 审核问题项改为子对象后为 28 个),按版本交付: | 版本 | 内容 | 累计对象 | |---|---|---| | v0.1(已合入,PR #5) | 内部工作台:主数据、两级准入审批、资质到期扫描 | 7 | | v0.2(已合入,PR #13–#18) | 物料只读副本、银行账户、存量导入、信息变更单 | 10 | | v0.3(已合入,PR #29–#33、#41) | 合同与协议、供应商处置 | 13 | -| v0.4 | 交付记录导入、质量问题 / SCAR、供应商审核 | 16 | -| v0.5 | 绩效、风险事件、管理报表 = 内部版 MVP,试运行 | 20 | -| v0.6 | 供应商门户(依赖 objectstack#25233) | 21 | -| v0.7 | 寻源与封标、价格库;核心对象冻结 | 27 | +| v0.4 | 交付记录导入、质量问题 / SCAR、供应商审核 | 17 | +| v0.5 | 绩效、风险事件、管理报表 = 内部版 MVP,试运行 | 21 | +| v0.6 | 供应商门户(依赖 objectstack#25233) | 22 | +| v0.7 | 寻源与封标、价格库;核心对象冻结 | 28 | 已完成:核心设计、三个扩展包设计(保留)、ADR-0001~0009、参数文件首版、体系文件生成脚本 和 14 份模板、v0.1 内部工作台(PR #5)、v0.2 主数据补齐(PR #13–#18)、v0.3 合同与处置(PR #29–#33、#41)。文档目录见 [docs/README.md](./docs/README.md); diff --git a/docs/adr/0008-order-to-pay-stays-in-erp.md b/docs/adr/0008-order-to-pay-stays-in-erp.md index 9c682fa..1a6a391 100644 --- a/docs/adr/0008-order-to-pay-stays-in-erp.md +++ b/docs/adr/0008-order-to-pay-stays-in-erp.md @@ -67,3 +67,8 @@ 寻源与合同,可以最先做([ADR-0009](./0009-core-first-pack-criteria-and-customer-layer.md))。 - `srm_integration` 权限集、I1–I15 数据流、与主流 ERP 的字段对照保留在文档里作为 预留,立项时直接沿用。 + +## 补充(2026-10-24) + +v0.4 实现供应商审核时,审核问题项从 repeater 字段改为子对象 `srm_supplier_audit_finding`(平台的 repeater 不能声明子字段, +问题项也无法关联整改单),核心对象由 27 个变为 28 个,见 [02 §2](../design/02-domain-model.md#2-对象总览)。本记录的决定不变。 diff --git a/docs/adr/0009-core-first-pack-criteria-and-customer-layer.md b/docs/adr/0009-core-first-pack-criteria-and-customer-layer.md index c580ade..c95c577 100644 --- a/docs/adr/0009-core-first-pack-criteria-and-customer-layer.md +++ b/docs/adr/0009-core-first-pack-criteria-and-customer-layer.md @@ -65,3 +65,8 @@ - 实施手册(`docs/implementation/`,待建)要写明客户层的四种定制方式和边界。 - 源语言(中文源英文译,还是英文源中文译)在 v0.3 前单独决定(计划 D4),不影响 本记录。 + +## 补充(2026-10-24) + +v0.4 实现供应商审核时,审核问题项从 repeater 字段改为子对象 `srm_supplier_audit_finding`(平台的 repeater 不能声明子字段, +问题项也无法关联整改单),核心对象由 27 个变为 28 个,见 [02 §2](../design/02-domain-model.md#2-对象总览)。本记录的决定不变。 diff --git a/docs/design/02-domain-model.md b/docs/design/02-domain-model.md index c9af898..c41b99a 100644 --- a/docs/design/02-domain-model.md +++ b/docs/design/02-domain-model.md @@ -69,7 +69,7 @@ ## 2. 对象总览 -核心共 **27 个对象**([ADR-0008](../adr/0008-order-to-pay-stays-in-erp.md)),按版本交付([07 §1](./07-delivery-plan.md#1-分期路线图))。 +核心共 **28 个对象**([ADR-0008](../adr/0008-order-to-pay-stays-in-erp.md) 定为 27 个,2026-10-24 审核问题项从 repeater 改为子对象后为 28 个),按版本交付([07 §1](./07-delivery-plan.md#1-分期路线图))。 「来源」列:**SRM** 表示在 SRM 内产生;**导入** 表示从 ERP 导出表格按外部编号幂等导入、SRM 内只读; **门户** 表示门户上线(v0.6)后主要由供应商在门户里填写,在此之前由内部代录。 @@ -91,17 +91,18 @@ | 14 | `srm_delivery_record` | 交付记录(含检验结论) | 质量 | — | 导入 / 质检员录入 | v0.4 | | 15 | `srm_quality_issue` | 质量整改单(SCAR / 8D) | 质量 | — | SRM / 门户 | v0.4 | | 16 | `srm_supplier_audit` | 供应商审核 | 质量 | — | SRM | v0.4 | -| 17 | `srm_kpi` | 绩效指标 | 绩效 | — | SRM | v0.5 | -| 18 | `srm_performance_review` | 绩效考评单 | 绩效 | — | SRM | v0.5 | -| 19 | `srm_performance_score` | 评分明细 | 绩效 | 考评单 | SRM | v0.5 | -| 20 | `srm_risk_event` | 风险事件 | 风险 | — | SRM(手工登记) | v0.5 | -| 21 | `srm_supplier_invitation` | 门户邀请 | 门户 | — | SRM | v0.6 | -| 22 | `srm_sourcing_event` | 寻源单(询价 / 招标 / 竞价) | 寻源 | — | SRM | v0.7 | -| 23 | `srm_sourcing_item` | 寻源明细 | 寻源 | 寻源单 | SRM | v0.7 | -| 24 | `srm_sourcing_invitation` | 寻源邀请 | 寻源 | —(lookup 寻源单) | SRM | v0.7 | -| 25 | `srm_quotation` | 报价单 | 寻源 | — | 门户 | v0.7 | -| 26 | `srm_quotation_line` | 报价明细 | 寻源 | 报价单 | 门户 | v0.7 | -| 27 | `srm_price_record` | 价格记录(价格库) | 寻源 | — | SRM | v0.7 | +| 17 | `srm_supplier_audit_finding` | 审核问题项 | 质量 | 供应商审核 | SRM | v0.4 | +| 18 | `srm_kpi` | 绩效指标 | 绩效 | — | SRM | v0.5 | +| 19 | `srm_performance_review` | 绩效考评单 | 绩效 | — | SRM | v0.5 | +| 20 | `srm_performance_score` | 评分明细 | 绩效 | 考评单 | SRM | v0.5 | +| 21 | `srm_risk_event` | 风险事件 | 风险 | — | SRM(手工登记) | v0.5 | +| 22 | `srm_supplier_invitation` | 门户邀请 | 门户 | — | SRM | v0.6 | +| 23 | `srm_sourcing_event` | 寻源单(询价 / 招标 / 竞价) | 寻源 | — | SRM | v0.7 | +| 24 | `srm_sourcing_item` | 寻源明细 | 寻源 | 寻源单 | SRM | v0.7 | +| 25 | `srm_sourcing_invitation` | 寻源邀请 | 寻源 | —(lookup 寻源单) | SRM | v0.7 | +| 26 | `srm_quotation` | 报价单 | 寻源 | — | 门户 | v0.7 | +| 27 | `srm_quotation_line` | 报价明细 | 寻源 | 报价单 | 门户 | v0.7 | +| 28 | `srm_price_record` | 价格记录(价格库) | 寻源 | — | SRM | v0.7 | **从原设计移出核心的对象**(原 35 个中的 11 个,[ADR-0008](../adr/0008-order-to-pay-stays-in-erp.md)): @@ -141,7 +142,8 @@ erDiagram srm_material ||--o{ srm_delivery_record : "" srm_delivery_record ||--o| srm_quality_issue : "不合格生成" srm_supplier ||--o{ srm_supplier_audit : "审核" - srm_supplier_audit ||--o{ srm_quality_issue : "问题项转整改" + srm_supplier_audit ||--o{ srm_supplier_audit_finding : "问题项" + srm_supplier_audit_finding |o--o| srm_quality_issue : "转整改" srm_supplier ||--o{ srm_performance_review : "" srm_performance_review ||--o{ srm_performance_score : "评分" @@ -313,8 +315,9 @@ upsert 写入,结构不变。 | `duplicate_check` | select | `pass` / `duplicate` / `blacklisted` / `reapply`(命中已淘汰的供应商:不建新档,申请指向旧档,允许继续),受理 flow 写入 | | `risk_class` | select | 受理时从申请品类复制的最高风险等级,只读;决定是否需要现场审核 | | `material_class` | select | 受理时从申请品类复制的最高物资类别(`srm_material_class`),只读;决定准入审批是否会签质量经理(03 §12) | -| `audit_passed` | boolean | 默认 false;现场审核结果为通过或有条件通过时由 flow 置为 true。v0.4 之前没有审核对象,由 SQE 直接勾选并上传 `audit_report` | -| `audit_report` | file | 审核报告(v0.4 前的过渡字段,之后由 `srm_supplier_audit.report` 替代) | +| `audit_passed` | boolean | 只读,默认 false;准入审核完成时由 `srm_audit_admission_apply` 写入:结论为通过或有条件通过时为 true,否则为 false | +| `audit_result` | select(只读) | 准入审核结论 `pass` / `conditional` / `fail`,同一 flow 写入;有条件通过的申请批准后品类资格为 `trial`(WI-01 6.6.2) | +| `audit_report` | file | 不建:v0.4 前的过渡设计,审核报告在 `srm_supplier_audit.report` 上 | | `reviewer` | user | 初审人(品类经理) | | `sqe` | user | 负责审核的 SQE | | `supplier` | lookup → 供应商 | 品类经理把申请推进到「资料审核」时新建(或 `reapply` 时关联旧档)的供应商;`submitted` 阶段只有申请记录,不建主数据 | @@ -326,14 +329,34 @@ upsert 写入,结构不变。 > 可以读品类对象);不含现场审核分支和公司资料等表单字段;`approval_status` 记录审批节点的结论。 > 本表其余字段随 v0.4(供应商审核)和 v0.6(门户、邀请注册)补齐。 -**`srm_supplier_audit` 供应商审核**(OWD `private`;v0.4):`audit_no`、`supplier` -(lookup)、`application`(lookup,可空)、`audit_type`(`admission` 准入 / `annual` -年度 / `special` 专项)、`planned_date` / `audit_date`、`auditors`(user,多选)、 -`checklist`(repeater:检查项、评分、备注)、`checklist_score`(number)、`result` -(`pass` / `conditional` 有条件通过 / `fail`)、`findings`(repeater:问题项、严重度、 -整改要求、关联整改单)、`report`(file)、`sample_result`(`not_required` / `pass` / `fail`)、 -`status`(`planned` / `done` / `cancelled`)。问题项可经「转整改单」screen flow 生成 -`srm_quality_issue`(`source = audit`)。 +**`srm_supplier_audit` 供应商审核**(OWD 暂为 `public_read_write`:年度计划由系统生成、SQE 之间要互相接手, +目标是 `private` + 质量岗位改所有记录,等 objectstack#25470;只有质量岗位有 `allowEdit`,删除不开放;v0.4) + +| 字段 | 类型 | 说明 | +|---|---|---| +| `audit_no` | autonumber | `AUD{YYYY}{00000}` | +| `supplier` | lookup | 必填 | +| `audit_type` | select | `admission` 准入 / `annual` 年度 / `special` 专项 | +| `application` | lookup → 准入申请 | 准入审核必填,必须是本供应商、进行中的申请 | +| `plan_year` | number(只读) | 年度计划生成时写入,按「供应商 + 计划年度」防重 | +| `planned_date` / `audit_date` | date | 计划中时计划日期必填;完成时审核日期必填 | +| `auditors` | user(多选) | 审核员;年度计划默认品类的 SQE | +| `status` | select(stageField) | `planned` 计划中 / `done` 已完成 / `cancelled` 已取消;`planned → done / cancelled`,`cancelled → planned`,完成后不再变 | +| `cancel_reason` | textarea | 取消时必填(如不需现场复审的 B 类供应商、经批准以资料审核代替一次复审,WI-01 6.9) | +| `checklist_score` / `lowest_block_score` | number | 审核总分、最低审核块得分(0–100),完成时必填 | +| `result` | select | `pass` / `conditional` 有条件通过 / `fail`,完成时必填;按 `audit_score_thresholds` 校验,只拦「高判」:通过要求总分 ≥ pass 线、每块 ≥ floor 线、没有严重不符合项和否决项;有条件通过要求总分 ≥ floor 线、严重不符合项不超过 1 项、没有否决项(WI-01 6.6.1) | +| `sample_result` | select | `not_required` / `pass` / `fail` | +| `report` / `scoresheet` | file | 审核报告、审核评分表(QR-03) | +| `serious_finding_count` / `veto_finding_count` | summary | 严重不符合项数、否决项数,校验结论用 | + +检查表原设计为 repeater(检查项、评分、备注)。平台的 repeater 不能声明子字段(列名、类型、选项与翻译都没处写,objectstack#25681), +表单只能当无结构表格编辑,所以逐条评分留在评分表附件里,系统记总分和最低审核块得分;问题项改为子对象: + +**`srm_supplier_audit_finding` 审核问题项**(master_detail → 供应商审核,`controlled_by_parent`;v0.4):`title` 问题项(必填)、 +`description` 事实描述、`severity`(`serious` 严重不符合 / `general` 一般不符合 / `observation` 观察项)、`veto` 否决项(boolean)、 +`requirement` 整改要求、`quality_issue`(lookup → 整改单,只读)。不符合项经审核上的「转整改单」screen flow +`srm_audit_finding_to_scar` 各生成一张 `srm_quality_issue`(`source = audit`,`audit` 指向本审核;严重不符合 → `critical`, +一般不符合 → `major`)并回填 `quality_issue`,转出后问题项只读;观察项不转。 **`srm_supplier_change_request` 信息变更单**(OWD `private`;v0.2)。门户上线前由采购侧代供应商在 供应商档案下新建,新建即进入审批(03 §5)。 @@ -596,7 +619,7 @@ false,BR-13)、`received_quantity`(summary:`sum` 收货记录 `accepted_ | `issue_no` | autonumber | `SCAR{YYYY}{00000}` | | `supplier` / `material` | lookup | | | `delivery_record` | lookup | 来源交付记录(可空) | -| `audit` | lookup | 来源审核(可空);随供应商审核对象在 v0.4 Q3 加 | +| `audit` | lookup(只读) | 来源审核(可空);审核问题项「转整改单」时写入 | | `source` | select(共享选项列表 `srm_quality_issue_source`) | `iqc` 来料 / `production` 制程 / `customer` 客户投诉 / `audit` 审核发现 / `performance` 绩效改善 | | `severity` | select | 同检验 | | `problem_description` | richtext | D2 问题描述,必填 | @@ -611,7 +634,7 @@ false,BR-13)、`received_quantity`(summary:`sum` 收货记录 `accepted_ | `is_overdue` | boolean | 默认 false,逾期 flow 维护 | | `claim_amount` | currency | 索赔金额 | | `claim_status` | select | `none` / `proposed` / `supplier_accepted` / `disputed` / `settled`(财务已在 ERP 扣款,由应付会计从「供应商已接受」手工标记,选项对其他人不可选;SRM 不生成扣款);不是 `none` 时必须填索赔金额 | -| `steps` | repeater | 8D 各步骤的完成日期、责任人、附件(D1–D8),供门户整改回复页分段显示;随门户(v0.6)加,之前状态即 8D 进度 | +| `steps` | repeater | 8D 各步骤的完成日期、责任人、附件(D1–D8),供门户整改回复页分段显示;随门户(v0.6)加,之前状态即 8D 进度。repeater 要能声明子字段才可用(objectstack#25681) | 整改单上 8D 内容在关闭或撤销后只读。 @@ -764,7 +787,7 @@ ERP 集成前为空)、`reviewer`(user)、`appeal_reason`、`appeal_result | 报价单 | `QT{YYYY}{000000}` | QT2026001024 | 按年 | | 合同 | `CT{YYYY}{00000}` | CT202600088 | 按年 | | 整改单 | `SCAR{YYYY}{00000}` | SCAR202600007 | 按年 | -| 供应商审核 | `AUD{YYYY}{0000}` | AUD20260012 | 按年 | +| 供应商审核 | `AUD{YYYY}{00000}` | AUD202600012 | 按年 | | 考评单 | `PR{YYYY}{00000}` | PR202600210 | 按年 | | 送货单(预留) | `ASN{YYYYMMDD}{0000}` | ASN202610220015 | 按日 | diff --git a/docs/design/03-processes-and-automation.md b/docs/design/03-processes-and-automation.md index 4beab6a..0362ed5 100644 --- a/docs/design/03-processes-and-automation.md +++ b/docs/design/03-processes-and-automation.md @@ -86,7 +86,7 @@ v0.1 只有内部登记:品类经理在工作台新建准入申请(`source = internal`),下图中的公开 表单、开通门户、门户补资料三步跳过,`reviewer` 默认取创建人所在品类的品类经理。v0.6 起 受邀供应商在门户填申请(`source = invited`);公开表单(`self_registered`)待平台 -(01 FR-201),只收文字信息,附件在供应商注册账户、登录门户后上传。现场审核对象在 v0.4 才有,之前由 SQE 在申请上勾选 `audit_passed` 并上传报告。 +(01 FR-201),只收文字信息,附件在供应商注册账户、登录门户后上传。现场审核对象随 v0.4 加入,审核结论由 flow 写回申请。 内部登记时申请直接关联已登记的供应商(身份标识已由供应商上的唯一约束查重),所以 v0.3 的受理 flow 按所关联供应商的状态写 `duplicate_check`:`potential` → `pass`;`eliminated` → `reapply`; @@ -134,9 +134,9 @@ sequenceDiagram | 2 建档 | 品类经理把申请推进到 `doc_review`(人工确认后,防止灌水污染主数据) | flow `srm_application_accept`:`duplicate_check == 'pass'` 时新建 `potential` 供应商与主联系人并回填 `supplier`;`reapply` 时沿用旧档;供应商 `potential / eliminated → qualifying`;`submitted` 阶段只有申请记录,不建主数据 | | 3 开通门户 | 品类经理 / 管理员(v0.6) | 邀请主联系人加入组织并开通门户(见 [04 §3.3](./04-security-and-portal.md#33-门户账号的开通与回收));v0.1–v0.5 跳过 | | 4 补充资料 | 供应商 | 门户内补齐公司资料(门户可改的字段见 [04 §4.2](./04-security-and-portal.md#42-字段级权限fls))、银行账户、证书;品类经理可「退回补充」(申请→`returned`,通知联系人)。应付会计经「核验银行账户」、SQE 经「核验证书」两个 screen flow 完成核验 | -| 5 现场审核 | `risk_class == 'high'` | v0.4 起:SQE 建供应商审核记录(`audit_type = admission`),结果为通过或有条件通过时,flow 把申请的 `audit_passed` 置为 true;v0.4 前由 SQE 直接勾选并上传 `audit_report`。条件校验:`risk_class == 'high' && audit_passed == false` 时申请不得进入 `admission_review`(FR-204) | +| 5 现场审核 | `risk_class == 'high'` | SQE 建供应商审核记录(`audit_type = admission`,关联本次申请),审核后录入总分、最低审核块得分、结论与问题项(结论按 `audit_score_thresholds` 校验,02 §4.2)。审核完成或改了结论时,flow `srm_audit_admission_apply` 把结论写回申请:`audit_result` 记结论,通过或有条件通过时 `audit_passed = true`,否则为 false,并通知初审人;已批准或已驳回的申请不再写。校验规则 `audit_before_review`:`risk_class == 'high' && audit_passed != true` 时申请不得进入 `admission_review`(FR-204)。申请上的勾选框只由 flow 写,品类经理改不了 | | 6 准入评审 | 申请→`admission_review` | flow `srm_admission_approval`:第 1 级 `field` 审批人 = `reviewer`(品类经理);第 2 级 `position` = `srm_procurement_director`;`material_class == 'class_a'` 或 `risk_class == 'high'` 时第 2 级改为采购总监、`srm_quality_manager` 并行会签(`per_group`:两个岗位各一人通过,同岗位多人时任一人即可;任一人驳回即驳回)。校验规则拦截提交人 = 审批人(平台没有「不可自审」选项,objectstack#25446)。驳回 → 申请回 `doc_review` 并通知 | -| 7 批准后 | 审批通过 | 申请→`approved`,写入 `cycle_days`;供应商 `qualifying → qualified`;按申请品类建 `srm_supplier_category`(审核结论为有条件通过时为 `trial`,否则 `qualified`);通知供应商。ERP 建档与 `erp_vendor_code` 回写为预留集成点(06 §1) | +| 7 批准后 | 审批通过 | 申请→`approved`,写入 `cycle_days`;供应商 `qualifying → qualified`;按申请品类建 `srm_supplier_category`(申请的 `audit_result` 为有条件通过时为 `trial`,否则 `qualified`,WI-01 6.6.2);通知供应商。ERP 建档与 `erp_vendor_code` 回写为预留集成点(06 §1) | | 8 未通过 | 终止 | 申请→`rejected`;供应商 `qualifying → potential`(`reapply` 的旧档回 `eliminated`);v0.6 起回收门户账号 | ## 4. P3 寻源:询价 → 比价 → 定标(v0.7) @@ -325,7 +325,7 @@ stateDiagram-v2 | 步骤 | 触发 | 动作 | |---|---|---| -| 1 生成 | 交付记录写入(新建或修改,月度导入是 upsert),**变成** `inspection_result == 'fail'` 且 `severity in ['critical','major']`;或审核问题项经「转整改单」screen flow;客户投诉、制程等来源由 SQE 手工新建 | flow `srm_scar_create`:同一条交付记录已有整改单时不再开(重复导入不重复开单);新建整改单,`sqe` 取物料品类的 SQE,`containment_due = 创建时间 + scar_containment_hours`、`final_due = 创建日期 + scar_final_days`(参数见 `params/core.yaml`,由字段默认值算好);通知 SQE,品类没有 SQE 时通知质量经理指派(BR-15);供应商 `quality` 联系人的通知等 objectstack#25444 或 v0.6 门户。`minor` 不合格只计入绩效,不自动开单。v0.6 前供应商的回复由 SQE 代填 | +| 1 生成 | 交付记录写入(新建或修改,月度导入是 upsert),**变成** `inspection_result == 'fail'` 且 `severity in ['critical','major']`;或审核问题项经「转整改单」screen flow;客户投诉、制程等来源由 SQE 手工新建 | flow `srm_scar_create`:同一条交付记录已有整改单时不再开(重复导入不重复开单);新建整改单,`sqe` 取物料品类的 SQE,`containment_due = 创建时间 + scar_containment_hours`、`final_due = 创建日期 + scar_final_days`(参数见 `params/core.yaml`,由字段默认值算好);通知 SQE,品类没有 SQE 时通知质量经理指派(BR-15);供应商 `quality` 联系人的通知等 objectstack#25444 或 v0.6 门户。`minor` 不合格只计入绩效,不自动开单。审核问题项由 SQE 或质量经理在已完成的审核上点「转整改单」(`srm_audit_finding_to_scar`):还没转出的严重、一般不符合项各开一张(`source = audit`,严重 → `critical`、一般 → `major`,负责 SQE 为操作人),整改单回填到问题项,重复点击不重复开单;观察项不转。v0.6 前供应商的回复由 SQE 代填 | | 2 临时措施 | 供应商提交 D3(v0.6 前 SQE 代填 D3 后把状态改为「已提交临时措施」) | 状态→`containment_submitted`;D3 必填。SQE 认为临时措施不够时退回到 `open`,退回意见写入 `sqe_comment`(必填) | | 3 根因与措施 | 供应商提交 D4–D7(v0.6 前 SQE 代填后改状态) | 状态→`action_submitted`;flow `srm_scar_review` 发起 `approval` 节点:`field` 审批人 = `sqe`(没填时交给质量经理),审批期间锁定记录。SQE 用审批的「驳回」退回:状态回到 `containment_submitted`,审批意见写入 `sqe_comment`(FR-604),改好后再次提交重新审核。平台的「退回修改」窗口只能原样回到审批节点、中间改不了状态,所以退回用驳回表达;驳回时没写意见的不算数,提醒 SQE 后重新审核 | | 4 效果验证 | SQE 审核通过 | 状态→`verifying`;SQE 跟踪后续批次,确认有效后关闭(D8),或验证不通过退回 `action_submitted` 重新审核 | @@ -333,6 +333,21 @@ stateDiagram-v2 | 5a 复发重开 | SQE 把已关闭的整改单改回 `open` | flow `srm_scar_reopen`:期限按重开时间重算,逾期标志清掉,通知负责 SQE | | 6 索赔 | SQE 填写 `claim_amount`,`claim_status → proposed` | 供应商在门户接受或提出异议(v0.6 前线下确认后由 SQE 记录);接受后(`supplier_accepted`)由财务在 ERP 扣款,应付会计回来标记 `settled`。SRM 不生成扣款 | +**供应商审核与年度审核计划**(v0.4,FR-607;体系文件 WI-01):准入审核见 §3 第 5 步;年度审核与专项审核由 SQE +建审核记录,审核后录入结论与问题项,不符合项经「转整改单」进入上面的整改流程。年度计划由 +`srm_audit_plan_generate` 按供应商所供品类中最高的物资类别(`material_class`,P-D2)定复审频次, +频次取 `annual_audit_frequency`(示例:A 类 12 个月、B 类 24 个月、C 类不定期、不生成): + +| 步骤 | 动作 | +|---|---| +| 1 选供应商 | 先 A 类品类、再 B 类品类,逐个找状态为合格或试供的品类资格;供应商本身不是 `qualified`(冻结、淘汰等)的不排计划。同时供 A、B 两类的按 A 类频次 | +| 2 判到期 | 上次完成的审核(任何类型)日期 + 频次 ≤ 本年 12 月 31 日即本年到期;从没审核过的视为已到期 | +| 3 防重 | 供应商已有「计划中」的审核,或已有本年度(`plan_year`)生成的年度审核(不论完成还是取消)时跳过,重复运行不重复建(§2.3 R2) | +| 4 生成 | 新建 `audit_type = annual`、`status = planned` 的审核,计划日期 = 到期日(已过的取今天),审核员默认品类的 SQE;SQE 按日历视图(05 §2)排程 | +| 5 取消 | 不需要现场复审的(WI-01 6.9 的其他 B 类供应商、经批准以资料审核代替一次复审)由质量部门取消并写明原因,本年度不再生成 | + +每月 1 日跑一次,年初那次生成全年计划,之后各次补上漏掉的与年中新到期的。 + ## 9. P8 对账与结算(不做,留在 ERP) 对账、开票、付款回写的记录系统是 ERP,本流程不在 SRM 实现([ADR-0008](../adr/0008-order-to-pay-stays-in-erp.md))。 @@ -379,7 +394,8 @@ stateDiagram-v2 > v0.1 先实现其中的一部分:`srm_certificate_expire_sweep` 每日一次,把证书状态对齐到已过期 / 即将到期(30 天内)/ > 有效(有效期被延长时恢复);`srm_supplier_qualification_recalc` 在证书状态或「必备」标志变化后重算 > `qualification_alert`。到期提醒通知随 v0.1 收尾加入;`is_mandatory` 按品类必备资质自动写入随 v0.2;「经 SQE 核验才解除」 -> 随 v0.4 供应商审核加入;在此之前由内部人员维护证书与「必备」标志。 +> 随门户(v0.6)加入:门户上线前证书由 SQE 自己录入,录入即核验,要求再勾一次「已核验」没有意义;门户上线后证书由供应商上传, +> 改证书时 `verified` 复位,经 `srm_certificate_verify` 核验后才解除(2026-10-24 随 v0.4 Q3 核对后改期)。 ## 12. 审批矩阵 @@ -440,6 +456,8 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_scar_review` | record_change:状态→`action_submitted` | 整改单 | v0.4 | | `srm_scar_reopen` | record_change:状态 `closed`→`open` | 整改单 | v0.4 | | `srm_scar_overdue_sweep` | schedule:每小时(api 心跳等 objectstack#25675) | 整改单 | v0.4 | +| `srm_audit_admission_apply` | record_change:准入审核完成或改结论 | 供应商审核 → 准入申请 | v0.4 | +| `srm_audit_plan_generate` | schedule:每月 1 日 02:30(追赶式;年初那次生成全年计划) | 供应商审核 | v0.4 | | `srm_performance_generate` | schedule:每月 1 日(按类别周期) | 考评单 | v0.5 | | `srm_performance_publish` | record_change:状态→`reviewing` | 考评单 → 供应商 | v0.5 | | `srm_performance_apply` | record_change:状态→`published` | 考评单 → 整改单、处置单 | v0.5 | @@ -466,7 +484,7 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_contract_submit` 提交审批 | 合同(草稿) | 品类经理、采购主管 | v0.3 | | `srm_contract_activate` 确认生效 | 合同(签署中) | 采购员、采购主管、品类经理 | v0.3 | | `srm_change_submit` 提交变更申请 | 门户:公司资料(v0.6 前内部在供应商档案下直接新建变更单) | 门户管理员 | v0.6 | -| `srm_audit_finding_to_scar` 转整改单 | 供应商审核 | SQE | v0.4 | +| `srm_audit_finding_to_scar` 转整改单 | 供应商审核(已完成) | SQE、质量经理 | v0.4 | | `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 9e0b523..c6ed1e6 100644 --- a/docs/design/04-security-and-portal.md +++ b/docs/design/04-security-and-portal.md @@ -86,7 +86,7 @@ R 读 · C 建 · E 改 · D 删 · F 仅经 flow / screen flow 写入 · — | 联系人 | R | R | RCE | R | R | R | R;门户管理员 CE | | 银行账户 | — | — | R | — | — | R + F(核验) | R(本公司);门户管理员 C | | 资质证书 | R | R | RCE | R | R + F(核验) | R | RCE(本公司;状态与核验字段只读) | -| 准入申请 | — | — | RCE | — | RE | — | R(本公司) | +| 准入申请 | — | — | RCE(现场审核结论只读,经 F 写回) | — | R | — | R(本公司) | | 变更单 | — | — | RCE(本人发起的) | RCE(本人发起的;不含银行账户变更) | — | R | R(本公司)+ F(提交) | | 寻源单 / 明细 | R | RCE(状态只经 F) | R | R | R | — | — | | 寻源邀请 | R | RF | R | — | — | — | RE(本公司:接受 / 拒绝) | @@ -94,7 +94,7 @@ R 读 · C 建 · E 改 · D 删 · F 仅经 flow / screen flow 写入 · — | 价格记录 | R | RF | R | R | — | R | R(本公司) | | 合同 / 合同明细 | — | RC | RCE | RE(只能改签署件、签署日期;明细 R) | — | R | R(本公司,签署中及以后) | | 交付记录 | R | — | — | R | RCE(录入) | R | R(本公司) | -| 供应商审核 | R | — | R | — | RCE | — | — | +| 供应商审核(含问题项) | R | — | R | — | RCE(计划年度、问题项的整改单只读)+ F(转整改单) | — | — | | 整改单 | R | — | — | R | RCE(逾期标志只读) | RE(只改索赔状态:标记已扣款) | RE(本公司:回复) | | 考评单 / 评分明细 | R | — | — | R | R | — | R(本公司,已发布)+ F(申诉) | | 风险事件 | R | R | RCE | R | RCE | R | — | diff --git a/docs/design/06-integration-and-ai.md b/docs/design/06-integration-and-ai.md index 7d16d4c..eaa49d3 100644 --- a/docs/design/06-integration-and-ai.md +++ b/docs/design/06-integration-and-ai.md @@ -147,7 +147,7 @@ ObjectStack 的定位是**让 AI agent 安全执行真实业务**的后端:AI 平台的 AI 搭建助手适合**原型验证**:用一句话描述某个模块(例如「供应商准入」), 它会先给出 3–5 个对象的方案,确认后生成对象、视图和应用外壳,便于业务方尽早 -看到样子。但完整的 SRM 应以**代码仓里的应用包**为唯一事实来源:27 个对象、 +看到样子。但完整的 SRM 应以**代码仓里的应用包**为唯一事实来源:28 个对象、 二十多条 flow、精细的 RLS 需要版本管理、代码评审和回归测试。建议做法是用 AI 搭建 产出原型 → 业务确认 → 按本设计在应用包里正式实现。客户落地时的个性化同样由 AI 在 **客户层**生成(Studio 覆盖层、客户私有包),不改核心源码([ADR-0009](../adr/0009-core-first-pack-criteria-and-customer-layer.md))。 diff --git a/docs/design/07-delivery-plan.md b/docs/design/07-delivery-plan.md index 6da5017..67f1f47 100644 --- a/docs/design/07-delivery-plan.md +++ b/docs/design/07-delivery-plan.md @@ -8,7 +8,7 @@ ## 1. 分期路线图 -按版本交付,每个版本结束都有一个业务上能独立使用的闭环,对象数累计到核心 27 个。 +按版本交付,每个版本结束都有一个业务上能独立使用的闭环,对象数累计到核心 28 个(ADR-0008 定为 27 个,v0.4 审核问题项改为子对象后为 28 个)。 工期按 1–2 人加 AI 的开发方式估算,每个版本 2–3 周;两个门槛由田騏确认后才进入下一段。 | 版本 | 范围 | 新增对象(累计) | 本版闭环 | 前置条件 | @@ -16,10 +16,10 @@ | v0.1 内部工作台 | 供应商主数据、两级准入审批、证书到期提醒 | 7:品类、工厂、供应商、联系人、证书、供货品类资格、准入申请 | 供应商从登记到合格;证书到期前有提醒 | 已交付(PR #5) | | v0.2 主数据补齐 | 银行账户、信息变更申请、法人(业务单元)、物料与存量供应商导入 | +3 = 10:物料、银行账户、信息变更单 | 存量供应商导入后能复核;关键信息变更走审批 | 已交付(PR #13–#18) | | v0.3 合同与处置 | 合同与合同行、处置单(暂停 / 冻结 / 解除)、供应商自助重新申请 | +3 = 13:合同、合同行、处置单 | 合同到期前提醒;冻结后不能被邀请 | 已交付(PR #29–#33、#41) | -| v0.4 质量 | 交付记录导入、质量问题与整改单、供应商审核与年度审核计划 | +3 = 16:交付记录、质量问题、供应商审核 | 不合格交付能开整改单并闭环;OTD、合格率、PPM 出数 | 客户能按月导出交付数据 | -| v0.5 绩效与风险 | 指标库、按周期考评、风险事件与扫描、黑名单 | +4 = 20:绩效指标、考评单、考评明细、风险事件 | 第一次考评自动生成并发布;D 级进入处置 | 门槛 1:内部 MVP 验收(§2.1) | -| v0.6 供应商门户 | 邀请注册、门户补资料与证书、整改回复、通知 | +1 = 21:供应商邀请 | 供应商自己维护资料并回复整改 | objectstack#25233 修复;V1–V5 实测通过(§6) | -| v0.7 寻源 | 询价 / 竞价、封标、比价、定标审批、价格记录 | +6 = 27:寻源单、寻源明细、寻源邀请、报价单、报价明细、价格记录 | 封标询价到定标,价格记录生效 | 门槛 2:核心 27 个对象冻结(ADR-0009 第 5 条) | +| v0.4 质量 | 交付记录导入、质量问题与整改单、供应商审核与年度审核计划 | +4 = 17:交付记录、质量问题、供应商审核、审核问题项 | 不合格交付能开整改单并闭环;OTD、合格率、PPM 出数 | 客户能按月导出交付数据 | +| v0.5 绩效与风险 | 指标库、按周期考评、风险事件与扫描、黑名单 | +4 = 21:绩效指标、考评单、考评明细、风险事件 | 第一次考评自动生成并发布;D 级进入处置 | 门槛 1:内部 MVP 验收(§2.1) | +| v0.6 供应商门户 | 邀请注册、门户补资料与证书、整改回复、通知 | +1 = 22:供应商邀请 | 供应商自己维护资料并回复整改 | objectstack#25233 修复;V1–V5 实测通过(§6) | +| v0.7 寻源 | 询价 / 竞价、封标、比价、定标审批、价格记录 | +6 = 28:寻源单、寻源明细、寻源邀请、报价单、报价明细、价格记录 | 封标询价到定标,价格记录生效 | 门槛 2:核心 28 个对象冻结(ADR-0009 第 5 条) | | 之后(客户驱动) | ERP 连接器与门户镜像(订单确认、送货通知、付款进度)、电子签、工商数据、扩展包 | 镜像对象另计 | — | 客户明确型号或需求 | ```mermaid diff --git a/docs/design/README.md b/docs/design/README.md index e9efb5b..2656f48 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -14,7 +14,7 @@ ([ADR-0008](../adr/0008-order-to-pay-stays-in-erp.md))。 **形态**:一个用 TypeScript 元数据定义、编译成单一制品发布的应用包 `srm`(命名空间 -`srm`),核心 **27 个业务对象**,按 v0.1–v0.7 七个版本交付([07 §1](./07-delivery-plan.md#1-分期路线图)); +`srm`),核心 **28 个业务对象**,按 v0.1–v0.7 七个版本交付([07 §1](./07-delivery-plan.md#1-分期路线图)); 两个应用外壳——内部的「采购协同工作台」(v0.1 起)和面向外部的「供应商门户」(v0.6), 两者读写同一份数据,靠权限区分各自能看到什么。现阶段只做核心包,扩展包保留为设计(ADR-0009)。 diff --git a/docs/engineering/README.md b/docs/engineering/README.md index f31b6d7..dda9f99 100644 --- a/docs/engineering/README.md +++ b/docs/engineering/README.md @@ -25,7 +25,7 @@ - 每个工作项一个 PR,标题带版本和编号(如 `feat(core): v0.2 M2 bank account`);一个 PR 不跨版本。 - PR 描述列出改动的对象、flow、权限集和对应的设计文档章节;设计与实现不一致时同一 PR 改文档。 -- 改对象名、字段名、字段类型必须附 ADR;门槛 2(核心 27 个对象冻结)之后还要附迁移说明。 +- 改对象名、字段名、字段类型必须附 ADR;门槛 2(核心对象冻结)之后还要附迁移说明。 ## 3. CI 门禁(全部必须绿) diff --git a/docs/packs/README.md b/docs/packs/README.md index 9a08fe5..6af681f 100644 --- a/docs/packs/README.md +++ b/docs/packs/README.md @@ -10,7 +10,7 @@ hotsrm 面向不同行业的企业,但不为每个客户做一套系统。客 | 层 | 内容 | 谁会用 | 放在哪里 | |---|---|---|---| -| **通用核心** | 所有企业都需要的供应商管理功能与管理制度:供应商生命周期、合同、质量整改、绩效、风险、门户、寻源定价(27 个对象;订单到付款留在 ERP,[ADR-0008](../adr/0008-order-to-pay-stays-in-erp.md)) | 所有客户 | 设计在 `docs/design/`,源码在 `src/core/` | +| **通用核心** | 所有企业都需要的供应商管理功能与管理制度:供应商生命周期、合同、质量整改、绩效、风险、门户、寻源定价(28 个对象;订单到付款留在 ERP,[ADR-0008](../adr/0008-order-to-pay-stays-in-erp.md)) | 所有客户 | 设计在 `docs/design/`,源码在 `src/core/` | | **行业扩展包** | 某一类企业共有的做法,作为模块在构建时合进应用;**现阶段只有设计** | 同类客户 | 设计在 `docs/packs/`;源码目录(`src/bulk/`、`src/hazmat/`、`src/soe/`)在立项时再建 | | **客户层** | 某个客户的参数取值、组织结构、物资目录、审批岗位称谓、文件编号与样式、Studio 覆盖层(标签、必填、选项、新增字段、隐藏字段、视图、流程)、AI 生成的客户私有包 | 单个客户 | 客户的私有仓库与客户环境,**不进本仓库** | diff --git a/governance/templates/core/WI-01.md b/governance/templates/core/WI-01.md index 9e552f5..90af7c2 100644 --- a/governance/templates/core/WI-01.md +++ b/governance/templates/core/WI-01.md @@ -184,12 +184,14 @@ b) 年度复审、专项审核:按{{doc.GL-08.title}}处理。 | 类别 | 复审周期 | 说明 | |----------------|------------|------------------------------------| -| A 类物资供应商 | 每年 1 次 | {{org.terms.hazmat}}供应商不得免审符合 6.9.2 的可以用资料审核代替一次 | -| B 类中涉及危险化学品、特种设备或安全生产的供应商 | 每 2 年 1 次 | 绩效 A 级的可延长至 3 年 | +| A 类物资供应商 | 每 {{param.annual_audit_frequency.A}} 个月 1 次 | {{org.terms.hazmat}}供应商不得免审符合 6.9.2 的可以用资料审核代替一次 | +| B 类中涉及危险化学品、特种设备或安全生产的供应商 | 每 {{param.annual_audit_frequency.B}} 个月 1 次 | 绩效 A 级的可延长至 3 年 | | 其他 B 类、C 类供应商 | 不定期 | 发生质量问题或绩效 D 级时安排专项审核 | 6.9.2 上一年度绩效评价为 A 级、且无质量问题和安全环保事件的 A 类供应商,经{{org.dept.quality}}负责人批准,可以用资料审核代替一次现场复审,但不得连续两年代替。 +6.9.3 SRM 系统按 6.9.1 的周期为 A 类、B 类供应商生成年度审核计划;其他 B 类供应商和按 6.9.2 以资料审核代替的,由{{org.dept.quality}}在系统中取消该次计划并写明原因。 + # 7 信息化控制要求 | 控制要求 | 本指导书条款 | SRM 系统中的控制方式 | @@ -197,6 +199,9 @@ b) 年度复审、专项审核:按{{doc.GL-08.title}}处理。 | 审核结果记录 | 6.8.2 | 建立供应商审核记录,记录审核类型、日期、审核员、总分、结论、发现,上传报告 | | 高风险物资准入以现场审核为前置 | 6.8.2 | 审核结论为通过或有条件通过后,准入申请才能提交准入评审 | | 有条件通过准入为试供 | 6.6.2 | 准入批准时按审核结论建立「试供」的品类资格 | +| 审核结论符合分数线 | 6.6.1 | 录入的结论高于总分、审核块得分、严重不符合项和否决项对应的结论时,系统拒绝保存 | +| 不符合项转整改单 | 6.7.4 | 审核记录上的不符合项一键生成质量整改单,整改单与问题项互相关联 | +| 年度审核计划 | 6.9 | 系统每年按物资类别为到期的 A 类、B 类供应商生成计划中的年度审核;不需现场复审的由{{org.dept.quality}}取消并写明原因 | # 8 记录 diff --git a/params/core.yaml b/params/core.yaml index 4267889..db4282a 100644 --- a/params/core.yaml +++ b/params/core.yaml @@ -238,7 +238,15 @@ audit_score_thresholds: example: { pass: 85, floor: 70 } display: 通过线 {pass} 分;有条件通过和单个审核块的下限 {floor} 分 used_in: [WI-01 6.6.1] - system: 审核记录录入总分与结论(核心 02 srm_supplier_audit);结论由审核组按分数线判定 + system: 审核记录录入总分与结论(核心 02 srm_supplier_audit);结论由审核组按分数线判定,系统拦截高于分数线的结论 + +annual_audit_frequency: + label: 在册供应商现场复审频次(按物资类别) + unit: 个月 + example: { A: 12, B: 24 } + display: A 类每 {A} 个月 1 次,B 类每 {B} 个月 1 次,C 类不定期 + used_in: [WI-01 6.9.1] + system: 年度审核计划 flow srm_audit_plan_generate 按供应商所供最高物资类别(srm_category.material_class)生成到期的年度审核;C 类不生成(核心 03 §8) # ---- 政策层文件(ZC-01 管理目标、ZC-02 授权审批表、ZC-03 风险控制矩阵) ---- diff --git a/src/core/actions/audit.actions.ts b/src/core/actions/audit.actions.ts new file mode 100644 index 0000000..202770b --- /dev/null +++ b/src/core/actions/audit.actions.ts @@ -0,0 +1,15 @@ +import { P } from '@objectstack/spec'; +import { defineAction } from '@objectstack/spec/ui'; + +// 审核详情页的「转整改单」按钮(03 §13)。谁能操作、还有没有待转的问题项由 flow 自己检查。 +export const AuditFindingToScarAction = defineAction({ + name: 'srm_audit_finding_to_scar', + label: '转整改单', + objectName: 'srm_supplier_audit', + icon: 'clipboard-x', + type: 'flow', + target: 'srm_audit_finding_to_scar', + locations: ['record_header'], + visible: P`record.status == 'done'`, + refreshAfter: true, +}); diff --git a/src/core/actions/index.ts b/src/core/actions/index.ts index d6aa3ce..ce932d0 100644 --- a/src/core/actions/index.ts +++ b/src/core/actions/index.ts @@ -1,3 +1,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'; diff --git a/src/core/apps/workbench.app.ts b/src/core/apps/workbench.app.ts index aabb8a7..c263219 100644 --- a/src/core/apps/workbench.app.ts +++ b/src/core/apps/workbench.app.ts @@ -38,6 +38,7 @@ export const Workbench = App.create({ children: [ { id: 'nav_delivery_record', type: 'object', objectName: 'srm_delivery_record', label: '交付记录', icon: 'truck' }, { id: 'nav_quality_issue', type: 'object', objectName: 'srm_quality_issue', label: '整改单', icon: 'clipboard-x' }, + { id: 'nav_supplier_audit', type: 'object', objectName: 'srm_supplier_audit', label: '供应商审核', icon: 'clipboard-list' }, ], }, { diff --git a/src/core/flows/admission/approval.flow.ts b/src/core/flows/admission/approval.flow.ts index b9ee4c2..b8b45c7 100644 --- a/src/core/flows/admission/approval.flow.ts +++ b/src/core/flows/admission/approval.flow.ts @@ -5,10 +5,12 @@ import type * as Automation from '@objectstack/spec/automation'; // 第 1 级是申请上的初审人,第 2 级是采购总监岗位;A 类物资或高风险品类的申请第 2 级并行加质量经理会签 // (03 §12):采购总监、质量经理各一人通过才算通过(per_group,同岗位多人时任一人即可),任一人驳回即驳回。 // 任一级驳回,申请退回「资料审核」。 -// 批准后:申请→准入通过,供应商→合格,按申请品类建供货品类资格(v0.1 没有现场审核,一律合格)。 +// 批准后:申请→准入通过,供应商→合格,按申请品类建供货品类资格:现场审核结论为有条件通过时为「试供」, +// 否则为「合格」(03 §3 第 7 步,WI-01 6.6.2)。 const cel$ = (source: string) => ({ dialect: 'cel' as const, source }); // v0.4 之前受理的申请没有这两个字段,按不会签处理 +const grantStatus = cel`has(record.audit_result) && record.audit_result == 'conditional' ? 'trial' : 'qualified'`; const needsQuality = `(has(record.material_class) && record.material_class == 'class_a') || (has(record.risk_class) && record.risk_class == 'high')`; export const AdmissionApprovalFlow: Automation.Flow = { @@ -112,14 +114,14 @@ export const AdmissionApprovalFlow: Automation.Flow = { fields: { supplier: cel`record.supplier`, category: cel`categoryId`, - status: 'qualified', + status: grantStatus, approved_date: cel`today()`, }, }, }, ], }, - // 重新准入的供应商可能已有该品类的资格记录(唯一约束),改为恢复合格 + // 重新准入的供应商可能已有该品类的资格记录(唯一约束),改为恢复 catch: { nodes: [ { @@ -131,7 +133,7 @@ export const AdmissionApprovalFlow: Automation.Flow = { filter: { supplier: cel`record.supplier`, category: cel`categoryId` }, // 按条件更新要声明 multi;供应商 + 品类唯一,实际只有一条 multi: true, - fields: { status: 'qualified', approved_date: cel`today()` }, + fields: { status: grantStatus, approved_date: cel`today()` }, }, }, ], diff --git a/src/core/flows/audit/admission-apply.flow.ts b/src/core/flows/audit/admission-apply.flow.ts new file mode 100644 index 0000000..876ded9 --- /dev/null +++ b/src/core/flows/audit/admission-apply.flow.ts @@ -0,0 +1,103 @@ +import { P, cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// P1 步骤 5(03 §3,FR-204):准入审核完成(或完成后改了结论)时,把结论写回关联的准入申请:通过或 +// 有条件通过时 audit_passed = true,否则 false;有条件通过的申请批准后品类资格为「试供」(WI-01 6.6.2)。 +// 申请不在进行中(已批准、已驳回)时不写;同时通知申请的初审人(品类经理)。 +const finished = (r: string) => `${r}.audit_type == 'admission' && ${r}.status == 'done' && has(${r}.application) && ${r}.application != null`; + +export const AuditAdmissionApplyFlow: Automation.Flow = { + name: 'srm_audit_admission_apply', + label: '准入审核结论写回申请', + type: 'record_change', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: '准入审核完成', + config: { + objectName: 'srm_supplier_audit', + triggerType: 'record-after-write', + condition: { + dialect: 'cel', + source: + `${finished('record')} && (previous == null || !(${finished('previous')}) || previous.result != record.result` + + ` || previous.application != record.application)`, + }, + }, + }, + { + id: 'load_application', + type: 'get_record', + label: '读取准入申请', + config: { objectName: 'srm_supplier_application', filter: { id: cel`record.application` }, outputVariable: 'app' }, + }, + { id: 'open', type: 'decision', label: '申请进行中?' }, + { + id: 'write_back', + type: 'update_record', + label: '写回审核结论', + config: { + objectName: 'srm_supplier_application', + filter: { id: cel`app.id` }, + fields: { + audit_passed: cel`record.result == 'pass' || record.result == 'conditional'`, + audit_result: cel`record.result`, + }, + }, + }, + { + id: 'notify_guard', + type: 'try_catch', + label: '通知', + config: { + try: { + nodes: [ + { + id: 'notify_reviewer', + type: 'notify', + label: '通知初审人', + config: { + recipients: [cel`has(app.reviewer) && app.reviewer != null ? app.reviewer : null`], + title: '准入申请 {{ app.application_no }} 的现场审核已完成', + message: '审核 {{ record.audit_no }} 结论已写回准入申请。结论为通过或有条件通过时,申请可以进入准入评审;不通过时不能进入准入评审。', + severity: 'info', + sourceObject: 'srm_supplier_application', + sourceId: cel`app.id`, + }, + }, + ], + }, + // 申请没有初审人时不影响写回 + catch: { nodes: [{ id: 'skip_notify', type: 'assignment', label: '跳过通知' }] }, + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'load_application', type: 'default' }, + { id: 'e2', source: 'load_application', target: 'open', type: 'default' }, + { + id: 'e_open', + source: 'open', + target: 'write_back', + type: 'conditional', + condition: P`app != null && app.status != 'approved' && app.status != 'rejected'`, + label: '进行中', + }, + { + id: 'e_closed', + source: 'open', + target: 'end', + type: 'conditional', + condition: P`app == null || app.status == 'approved' || app.status == 'rejected'`, + label: '已结束', + }, + { id: 'e3', source: 'write_back', target: 'notify_guard', type: 'default' }, + { id: 'e4', source: 'notify_guard', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/audit/finding-to-scar.flow.ts b/src/core/flows/audit/finding-to-scar.flow.ts new file mode 100644 index 0000000..a259268 --- /dev/null +++ b/src/core/flows/audit/finding-to-scar.flow.ts @@ -0,0 +1,149 @@ +import { cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// 审核问题项转整改单(03 §8 第 1 步、§13,FR-607;WI-01 6.7.4):SQE 在已完成的审核记录上一次把还没转出的 +// 不符合项(严重、一般;观察项不转)各生成一张 source = audit 的整改单,并把整改单回填到问题项。 +// 已转出的问题项不再转,重复点击不重复开单。严重不符合 → 整改单「严重」,一般不符合 → 「主要」 +// (两者都要求供应商按 8D 回复,「次要」只计入绩效);负责 SQE 为操作人,期限按整改单的默认值计算。 +// 以系统身份运行:岗位检查写在 flow 里(objectstack#25481,同 srm_bank_verify)。 +const allowed = `current_user != null && ('srm_sqe' in current_user.positions || 'srm_quality_manager' in current_user.positions)`; +const isDone = `audit != null && audit.status == 'done'`; +const pendingOf = `findings.filter(f, f.severity != 'observation' && (!has(f.quality_issue) || f.quality_issue == null || isBlank(f.quality_issue)))`; +const hasPending = `${pendingOf}.size() > 0`; +const cond = (source: string) => ({ dialect: 'cel' as const, source }); +const text = (k: string) => `(has(finding.${k}) && finding.${k} != null && !isBlank(finding.${k}) ? finding.${k} : '')`; + +export const AuditFindingToScarFlow: Automation.Flow = { + name: 'srm_audit_finding_to_scar', + 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_audit', filter: { id: cel`recordId` }, outputVariable: 'audit' }, + }, + { + id: 'load_findings', + type: 'get_record', + label: '读取问题项', + config: { + objectName: 'srm_supplier_audit_finding', + filter: { audit: cel`audit != null ? audit.id : ''` }, + limit: 200, + outputVariable: 'findings', + }, + }, + { id: 'gate', type: 'decision', label: '可以转?' }, + { + id: 'refuse_position', + type: 'end', + label: '不是质量岗位', + config: { outcome: 'refused', message: '只有 SQE 或质量经理可以把审核问题项转为整改单' }, + }, + { + id: 'refuse_status', + type: 'end', + label: '审核未完成', + config: { outcome: 'refused', message: '审核完成后才能把问题项转为整改单' }, + }, + { + id: 'refuse_none', + type: 'end', + label: '没有待转的问题项', + config: { outcome: 'refused', message: '没有待转的不符合项:观察项不转整改单,已转出的不再重复转' }, + }, + { + id: 'confirm', + type: 'screen', + label: '确认转整改单', + config: { + title: '转整改单:{{ audit.audit_no }}', + description: '将为本次审核中还没转出的严重、一般不符合项各生成一张整改单,由你担任负责 SQE。观察项不转。', + waitForInput: true, + }, + }, + { + id: 'each_finding', + type: 'loop', + label: '逐条问题项', + config: { + collection: cond(pendingOf), + iteratorVariable: 'finding', + body: { + nodes: [ + { + id: 'guard_finding', + type: 'try_catch', + label: '单条问题项', + config: { + try: { + nodes: [ + { + id: 'create_issue', + type: 'create_record', + label: '新建整改单', + config: { + objectName: 'srm_quality_issue', + fields: { + supplier: cel`audit.supplier`, + audit: cel`audit.id`, + source: 'audit', + severity: cel`finding.severity == 'serious' ? 'critical' : 'major'`, + problem_description: cond( + `'审核 ' + audit.audit_no + ' 问题项:' + finding.title` + + ` + (${text('description')} != '' ? '\\n事实:' + ${text('description')} : '')` + + ` + (${text('requirement')} != '' ? '\\n整改要求:' + ${text('requirement')} : '')`, + ), + sqe: cel`current_user.id`, + }, + outputVariable: 'issue', + }, + }, + { + id: 'link_finding', + type: 'update_record', + label: '回填问题项', + config: { objectName: 'srm_supplier_audit_finding', filter: { id: cel`finding.id` }, fields: { quality_issue: cel`issue.id` } }, + }, + ], + edges: [{ id: 'e_link', source: 'create_issue', target: 'link_finding', type: 'default' }], + }, + // 单条失败不影响其他问题项;没转出的下次再点「转整改单」补上 + catch: { nodes: [{ id: 'skip_finding', type: 'assignment', label: '跳过该条' }] }, + }, + }, + ], + }, + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e0', source: 'start', target: 'load', type: 'default' }, + { id: 'e1', source: 'load', target: 'load_findings', type: 'default' }, + { id: 'e2', source: 'load_findings', target: 'gate', type: 'default' }, + { id: 'e3', source: 'gate', target: 'refuse_position', type: 'conditional', condition: cond(`!(${allowed})`), label: '不是质量岗位' }, + { id: 'e4', source: 'gate', target: 'refuse_status', type: 'conditional', condition: cond(`${allowed} && !(${isDone})`), label: '未完成' }, + { + id: 'e5', + source: 'gate', + target: 'refuse_none', + type: 'conditional', + condition: cond(`${allowed} && ${isDone} && !(${hasPending})`), + label: '没有待转', + }, + { id: 'e6', source: 'gate', target: 'confirm', type: 'conditional', condition: cond(`${allowed} && ${isDone} && ${hasPending}`), label: '可以' }, + { id: 'e7', source: 'confirm', target: 'each_finding', type: 'default' }, + { id: 'e8', source: 'each_finding', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/audit/plan-generate.flow.ts b/src/core/flows/audit/plan-generate.flow.ts new file mode 100644 index 0000000..56cb40b --- /dev/null +++ b/src/core/flows/audit/plan-generate.flow.ts @@ -0,0 +1,199 @@ +import type * as Automation from '@objectstack/spec/automation'; + +// 年度审核计划(03 §13,FR-607;WI-01 6.9):按供应商所供品类中最高的物资类别(srm_category.material_class, +// P-D2)定复审频次,为本年内到期的合格供应商各生成一条「计划中」的年度审核,计划日期 = 上次完成审核的日期 + +// 频次(已过期的取今天;从没审核过的取今天),审核员默认为品类的 SQE。日历视图按计划日期排程。 +// 频次取 params/core.yaml 的 annual_audit_frequency(示例:A 类 12 个月、B 类 24 个月、C 类不定期,不生成), +// 客户改参数时同步 FREQUENCY。 +// +// 每月 1 日跑一次,年初那次生成全年计划,之后各次补上漏掉的和年中新到期的(03 §2.3 R2):供应商已有「计划中」 +// 的审核、或已有本年度生成的年度审核(不论完成还是取消)时跳过,跑两遍不重复建。先处理 A 类品类、再处理 B 类, +// 同时供应 A、B 两类的供应商按 A 类频次生成。不需要现场复审的 B 类供应商由质量部门取消并写明原因(WI-01 6.9)。 +const FREQUENCY = { class_a: 12, class_b: 24 } as const; + +const year = `planYear`; +const yearEnd = `timestamp(string(today().getFullYear()) + '-12-31T00:00:00Z')`; + +const pass = (cls: keyof typeof FREQUENCY): Automation.FlowNode[] => { + const k = cls === 'class_a' ? 'A' : 'B'; + const months = FREQUENCY[cls]; + const cat = `cat${k}`; + const grant = `grant${k}`; + const prior = `prior${k}`; + // 最近一次完成的审核日期;没有时为 null + const last = `(${prior}.size() == 0 ? null : ${prior}.filter(a, ${prior}.all(b, string(b.audit_date) <= string(a.audit_date)))[0].audit_date)`; + const due = `addMonths(${last}, ${months})`; + const isDue = + `vendor${k} != null && vendor${k}.status == 'qualified' && planned${k} == null && thisYear${k} == null` + + ` && (${prior}.size() == 0 || ${due} <= ${yearEnd})`; + const plannedDate = `isoDate(${prior}.size() == 0 || ${due} < today() ? today() : ${due})`; + const c = (source: string) => ({ dialect: 'cel' as const, source }); + + return [ + { + id: `find_categories_${k}`, + type: 'get_record', + label: `读取 ${k} 类品类`, + config: { objectName: 'srm_category', filter: { material_class: cls }, limit: 500, outputVariable: `categories${k}` }, + }, + { + id: `each_category_${k}`, + type: 'loop', + label: `逐个 ${k} 类品类`, + config: { + collection: c(`categories${k}`), + iteratorVariable: cat, + body: { + nodes: [ + { + id: `guard_category_${k}`, + type: 'try_catch', + label: '单个品类', + config: { + try: { + nodes: [ + { + id: `find_grants_${k}`, + type: 'get_record', + label: '读取有效的品类资格', + config: { + objectName: 'srm_supplier_category', + filter: { category: c(`${cat}.id`), status: { $in: ['qualified', 'trial'] } }, + limit: 2000, + outputVariable: `grants${k}`, + }, + }, + { + id: `each_grant_${k}`, + type: 'loop', + label: '逐家供应商', + config: { + collection: c(`grants${k}`), + iteratorVariable: grant, + body: { + nodes: [ + { + id: `guard_${k}`, + type: 'try_catch', + label: '单家供应商', + config: { + try: { + nodes: [ + { + id: `load_vendor_${k}`, + type: 'get_record', + label: '读取供应商', + config: { objectName: 'srm_supplier', filter: { id: c(`${grant}.supplier`) }, outputVariable: `vendor${k}` }, + }, + { + id: `find_planned_${k}`, + type: 'get_record', + label: '已有计划中的审核?', + config: { + objectName: 'srm_supplier_audit', + filter: { supplier: c(`${grant}.supplier`), status: 'planned' }, + outputVariable: `planned${k}`, + }, + }, + { + id: `find_this_year_${k}`, + type: 'get_record', + label: '本年度已生成?', + config: { + objectName: 'srm_supplier_audit', + filter: { supplier: c(`${grant}.supplier`), audit_type: 'annual', plan_year: c(year) }, + outputVariable: `thisYear${k}`, + }, + }, + { + id: `find_prior_${k}`, + type: 'get_record', + label: '读取已完成的审核', + config: { + objectName: 'srm_supplier_audit', + filter: { supplier: c(`${grant}.supplier`), status: 'done' }, + limit: 200, + outputVariable: prior, + }, + }, + { id: `due_${k}`, type: 'decision', label: '本年到期?' }, + { + id: `create_plan_${k}`, + type: 'create_record', + label: '生成年度审核', + config: { + objectName: 'srm_supplier_audit', + fields: { + supplier: c(`${grant}.supplier`), + audit_type: 'annual', + plan_year: c(year), + planned_date: c(plannedDate), + status: 'planned', + auditors: c(`has(${cat}.sqe) && ${cat}.sqe != null ? [${cat}.sqe] : []`), + }, + }, + }, + { id: `skip_${k}`, type: 'assignment', label: '不到期或已安排' }, + { id: `next_${k}`, type: 'assignment', label: '下一家' }, + ], + edges: [ + { id: `e_${k}1`, source: `load_vendor_${k}`, target: `find_planned_${k}`, type: 'default' }, + { id: `e_${k}2`, source: `find_planned_${k}`, target: `find_this_year_${k}`, type: 'default' }, + { id: `e_${k}3`, source: `find_this_year_${k}`, target: `find_prior_${k}`, type: 'default' }, + { id: `e_${k}4`, source: `find_prior_${k}`, target: `due_${k}`, type: 'default' }, + { id: `e_${k}_due`, source: `due_${k}`, target: `create_plan_${k}`, type: 'conditional', condition: c(isDue), label: '到期' }, + { id: `e_${k}_skip`, source: `due_${k}`, target: `skip_${k}`, type: 'conditional', condition: c(`!(${isDue})`), label: '跳过' }, + { id: `e_${k}5`, source: `create_plan_${k}`, target: `next_${k}`, type: 'default' }, + { id: `e_${k}6`, source: `skip_${k}`, target: `next_${k}`, type: 'default' }, + ], + }, + // 单家失败不影响整次生成 + catch: { nodes: [{ id: `skip_error_${k}`, type: 'assignment', label: '跳过该供应商' }] }, + }, + }, + ], + }, + }, + }, + ], + edges: [{ id: `e_${k}_grants`, source: `find_grants_${k}`, target: `each_grant_${k}`, type: 'default' }], + }, + catch: { nodes: [{ id: `skip_category_${k}`, type: 'assignment', label: '跳过该品类' }] }, + }, + }, + ], + }, + }, + }, + ]; +}; + +export const AuditPlanGenerateFlow: Automation.Flow = { + name: 'srm_audit_plan_generate', + label: '生成年度审核计划', + type: 'schedule', + status: 'active', + runAs: 'system', + + nodes: [ + { id: 'start', type: 'start', label: '每月 1 日', config: { schedule: '30 2 1 * *' } }, + { + id: 'set_year', + type: 'assignment', + label: '本年度', + config: { assignments: { planYear: { dialect: 'cel', source: 'double(today().getFullYear())' } } }, + }, + ...pass('class_a'), + ...pass('class_b'), + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e0', source: 'start', target: 'set_year', type: 'default' }, + { id: 'e1', source: 'set_year', target: 'find_categories_A', type: 'default' }, + { id: 'e2', source: 'find_categories_A', target: 'each_category_A', type: 'default' }, + { id: 'e3', source: 'each_category_A', target: 'find_categories_B', type: 'default' }, + { id: 'e4', source: 'find_categories_B', target: 'each_category_B', type: 'default' }, + { id: 'e5', source: 'each_category_B', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/index.ts b/src/core/flows/index.ts index 16481f0..9bc75be 100644 --- a/src/core/flows/index.ts +++ b/src/core/flows/index.ts @@ -20,3 +20,6 @@ export { ScarCreateFlow } from './quality/scar-create.flow.js'; export { ScarReviewFlow } from './quality/scar-review.flow.js'; export { ScarReopenFlow } from './quality/scar-reopen.flow.js'; 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'; diff --git a/src/core/objects/index.ts b/src/core/objects/index.ts index b3af48b..a70ae47 100644 --- a/src/core/objects/index.ts +++ b/src/core/objects/index.ts @@ -13,3 +13,5 @@ export { Contract } from './contract.object.js'; export { ContractLine } from './contract-line.object.js'; 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'; diff --git a/src/core/objects/quality-issue.object.ts b/src/core/objects/quality-issue.object.ts index 7feafea..dcf5a50 100644 --- a/src/core/objects/quality-issue.object.ts +++ b/src/core/objects/quality-issue.object.ts @@ -42,6 +42,13 @@ export const QualityIssue = ObjectSchema.create({ }), material: Field.lookup('srm_material', { label: '物料', group: 'problem', displayField: 'erp_material_no', readonlyWhen: ended }), delivery_record: Field.lookup('srm_delivery_record', { label: '来源交付记录', group: 'problem', readonly: true }), + // 审核问题项经「转整改单」生成时写入(srm_audit_finding_to_scar,v0.4 Q3) + audit: Field.lookup('srm_supplier_audit', { + label: '来源审核', + group: 'problem', + readonly: true, + relatedListColumns: ['issue_no', 'severity', 'status', 'final_due', 'sqe'], + }), source: Field.select({ label: '来源', group: 'problem', required: true, defaultValue: 'iqc', picklist: 'srm_quality_issue_source', readonlyWhen: ended }), severity: Field.select({ label: '严重度', diff --git a/src/core/objects/supplier-application.object.ts b/src/core/objects/supplier-application.object.ts index 62c1c34..d932573 100644 --- a/src/core/objects/supplier-application.object.ts +++ b/src/core/objects/supplier-application.object.ts @@ -2,7 +2,8 @@ import { P } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; // 准入申请(02 §4.2、§5.2)。v0.1 由内部人员为已登记的潜在供应商发起:没有公开表单和门户, -// 所以申请直接关联供应商,申请品类用多值 lookup;现场审核分支随供应商审核对象加入(v0.2)。 +// 所以申请直接关联供应商,申请品类用多值 lookup;现场审核分支随供应商审核对象加入(v0.4): +// 高风险品类的申请要有通过或有条件通过的准入审核才能进入准入评审(FR-204)。 // v0.3 起淘汰供应商可以重新申请(沿用旧档),黑名单供应商的申请由受理 flow 自动驳回(FR-208、BR-02)。 export const SupplierApplication = ObjectSchema.create({ name: 'srm_supplier_application', @@ -68,6 +69,19 @@ export const SupplierApplication = ObjectSchema.create({ { label: '低', value: 'low' }, ], }), + // 准入审核完成时由 srm_audit_admission_apply 写入(03 §3 第 5 步),只读;审核报告在审核记录上 + audit_passed: Field.boolean({ label: '现场审核通过', group: 'review', defaultValue: false, readonly: true }), + // 有条件通过的申请批准后品类资格为「试供」(WI-01 6.6.2) + audit_result: Field.select({ + label: '现场审核结论', + group: 'review', + readonly: true, + options: [ + { label: '通过', value: 'pass' }, + { label: '有条件通过', value: 'conditional' }, + { label: '不通过', value: 'fail' }, + ], + }), reviewer: Field.user({ label: '初审人', group: 'review' }), // 审批节点写入的审批结论,只读 approval_status: Field.select({ @@ -116,6 +130,15 @@ export const SupplierApplication = ObjectSchema.create({ message: '进入准入评审前需要指定初审人', condition: P`record.status == 'admission_review' && (!has(record.reviewer) || record.reviewer == null || isBlank(record.reviewer))`, }, + { + // 高风险品类以现场审核为前置(FR-204,03 §3 第 5 步) + name: 'audit_before_review', + type: 'script', + severity: 'error', + events: ['update'], + message: '高风险品类的申请要有通过或有条件通过的现场审核,才能进入准入评审', + condition: P`record.status == 'admission_review' && has(previous.status) && previous.status != 'admission_review' && has(record.risk_class) && record.risk_class == 'high' && record.audit_passed != true`, + }, { name: 'reject_reason_required', type: 'script', diff --git a/src/core/objects/supplier-audit-finding.object.ts b/src/core/objects/supplier-audit-finding.object.ts new file mode 100644 index 0000000..dee1823 --- /dev/null +++ b/src/core/objects/supplier-audit-finding.object.ts @@ -0,0 +1,42 @@ +import { P } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +// 审核问题项(02 §4.2,WI-01 6.5、6.6.3、6.7):审核员在审核记录下逐条登记不符合项、观察项和否决项。 +// 严重不符合项、否决项的条数汇总到审核记录,用来校验结论(WI-01 6.6.1)。不符合项经审核记录上的 +// 「转整改单」(srm_audit_finding_to_scar)生成整改单并回填 quality_issue,转出后不再修改。 +const converted = P`has(record.quality_issue) && record.quality_issue != null && !isBlank(record.quality_issue)`; + +export const SupplierAuditFinding = ObjectSchema.create({ + name: 'srm_supplier_audit_finding', + label: '审核问题项', + pluralLabel: '审核问题项', + icon: 'list-checks', + sharingModel: 'controlled_by_parent', + nameField: 'title', + + fields: { + audit: Field.masterDetail('srm_supplier_audit', { + label: '供应商审核', + required: true, + deleteBehavior: 'cascade', + relatedListColumns: ['title', 'severity', 'veto', 'description', 'requirement', 'quality_issue'], + }), + title: Field.text({ label: '问题项', required: true, maxLength: 200, readonlyWhen: converted }), + description: Field.textarea({ label: '事实描述', maxLength: 2000, readonlyWhen: converted }), + severity: Field.select({ + label: '严重度', + required: true, + defaultValue: 'general', + readonlyWhen: converted, + options: [ + { label: '严重不符合', value: 'serious' }, + { label: '一般不符合', value: 'general' }, + { label: '观察项', value: 'observation' }, + ], + }), + // 否决项(WI-01 6.6.3):审核结论只能是「不通过」 + veto: Field.boolean({ label: '否决项', defaultValue: false, readonlyWhen: converted }), + requirement: Field.textarea({ label: '整改要求', maxLength: 2000, readonlyWhen: converted }), + quality_issue: Field.lookup('srm_quality_issue', { label: '整改单', readonly: true }), + }, +}); diff --git a/src/core/objects/supplier-audit.object.ts b/src/core/objects/supplier-audit.object.ts new file mode 100644 index 0000000..a55ed4f --- /dev/null +++ b/src/core/objects/supplier-audit.object.ts @@ -0,0 +1,192 @@ +import { P } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +// 供应商审核(02 §4.2,03 §3 第 5 步,FR-607、FR-204;体系文件 WI-01)。SQE 新建准入审核、专项审核, +// 年度审核由 srm_audit_plan_generate 按物资类别生成「计划中」记录;审核后录入日期、总分、最低审核块得分、 +// 结论,上传审核报告与评分表(QR-03),问题项在子表 srm_supplier_audit_finding 里登记,经「转整改单」 +// (srm_audit_finding_to_scar)生成整改单。准入审核完成时 srm_audit_admission_apply 把结论写回准入申请。 +// 结论分数线取 params/core.yaml 的 audit_score_thresholds(示例:通过 85 分,下限 70 分),客户改参数时同步这里。 +// +// 检查表按设计是 repeater,但平台的 repeater 不能声明子字段(列名、类型、选项、翻译都没处写),表单只能当 +// 无结构的表格编辑,问题项也无法回填整改单。所以逐条评分留在评分表附件里,系统记总分和最低审核块得分; +// 问题项改为子对象,可以单独查询、统计和关联整改单。 +const celOf = (source: string) => ({ dialect: 'cel' as const, source }); +const blank = (k: string) => `(!has(record.${k}) || record.${k} == null || isBlank(record.${k}))`; +const PASS = 85; +const FLOOR = 70; +const done = P`record.status == 'done'`; +// 分数没填按不达标处理;子表计数没有子记录时可能为空 +const below = (k: string, line: number) => `(!has(record.${k}) || record.${k} == null || record.${k} < ${line})`; +const countAbove = (k: string, n: number) => `(has(record.${k}) && record.${k} != null && record.${k} > ${n})`; + +export const SupplierAudit = ObjectSchema.create({ + name: 'srm_supplier_audit', + label: '供应商审核', + pluralLabel: '供应商审核', + icon: 'clipboard-list', + // 临时:目标是 private + 质量岗位「改所有记录」。年度计划由系统生成,SQE 之间要互相接手,平台的 + // modifyAllRecords 会连删除一起放开(objectstack#25470),先同整改单一样放宽 OWD;只有质量岗位有 allowEdit, + // 删除仍受 allowDelete 约束。审核记录不对供应商开放,门户上线后也不加 portal_position + sharingModel: 'public_read_write', + enable: { trackHistory: true }, + stageField: 'status', + nameField: 'audit_no', + + fieldGroups: [ + { key: 'plan', label: '审核计划', icon: 'calendar' }, + { key: 'result', label: '审核结论', icon: 'clipboard-check' }, + ], + + fields: { + audit_no: Field.autonumber({ label: '审核编号', group: 'plan', autonumberFormat: 'AUD{YYYY}{00000}' }), + supplier: Field.lookup('srm_supplier', { + label: '供应商', + group: 'plan', + required: true, + relatedListColumns: ['audit_no', 'audit_type', 'planned_date', 'audit_date', 'checklist_score', 'result', 'status'], + }), + audit_type: Field.select({ + label: '审核类型', + group: 'plan', + required: true, + defaultValue: 'admission', + options: [ + { label: '准入审核', value: 'admission' }, + { label: '年度审核', value: 'annual' }, + { label: '专项审核', value: 'special' }, + ], + }), + // 准入审核关联本次申请(03 §3 第 5 步);结论由 srm_audit_admission_apply 写回申请 + application: Field.lookup('srm_supplier_application', { + label: '准入申请', + group: 'plan', + requiredWhen: P`record.audit_type == 'admission'`, + relatedListColumns: ['audit_no', 'audit_date', 'checklist_score', 'result', 'sample_result', 'status'], + }), + // 年度计划生成时写入,重复运行按「供应商 + 计划年度」不重复生成(03 §2.3 R2) + plan_year: Field.number({ label: '计划年度', group: 'plan', readonly: true, scale: 0, useGrouping: false }), + planned_date: Field.date({ label: '计划日期', group: 'plan', requiredWhen: P`record.status == 'planned'` }), + auditors: Field.user({ label: '审核员', group: 'plan', multiple: true }), + status: Field.select({ + label: '状态', + group: 'plan', + defaultValue: 'planned', + options: [ + { label: '计划中', value: 'planned', kind: 'open' }, + { label: '已完成', value: 'done', kind: 'won' }, + { label: '已取消', value: 'cancelled', kind: 'lost' }, + ], + }), + // 取消原因:如 WI-01 6.9 不需要现场复审的 B 类供应商、经批准以资料审核代替一次复审 + cancel_reason: Field.textarea({ label: '取消原因', group: 'plan', requiredWhen: P`record.status == 'cancelled'` }), + + audit_date: Field.date({ label: '审核日期', group: 'result', requiredWhen: done }), + // 审核总分 = Σ(审核块得分 × 权重),满分 100(WI-01 6.3.3) + checklist_score: Field.number({ label: '审核总分', group: 'result', min: 0, max: 100, precision: 4, scale: 1, requiredWhen: done }), + lowest_block_score: Field.number({ label: '最低审核块得分', group: 'result', min: 0, max: 100, precision: 4, scale: 1, requiredWhen: done }), + result: Field.select({ + label: '审核结论', + group: 'result', + requiredWhen: done, + options: [ + { label: '通过', value: 'pass' }, + { label: '有条件通过', value: 'conditional' }, + { label: '不通过', value: 'fail' }, + ], + }), + sample_result: Field.select({ + label: '样品检验结论', + group: 'result', + defaultValue: 'not_required', + options: [ + { label: '不需要', value: 'not_required' }, + { label: '合格', value: 'pass' }, + { label: '不合格', value: 'fail' }, + ], + }), + report: Field.file({ label: '审核报告', group: 'result' }), + scoresheet: Field.file({ label: '审核评分表', group: 'result' }), + serious_finding_count: Field.summary({ + label: '严重不符合项数', + group: 'result', + summaryOperations: { object: 'srm_supplier_audit_finding', field: 'id', function: 'count', filter: { severity: 'serious' } }, + }), + veto_finding_count: Field.summary({ + label: '否决项数', + group: 'result', + summaryOperations: { object: 'srm_supplier_audit_finding', field: 'id', function: 'count', filter: { veto: true } }, + }), + }, + + validations: [ + { + name: 'audit_status_flow', + type: 'state_machine', + field: 'status', + message: '审核状态不允许这样变更', + initialStates: ['planned', 'done'], + transitions: { + planned: ['done', 'cancelled'], + cancelled: ['planned'], + done: [], + }, + }, + { + // 引用字段为空时不读关联记录(规则读不到会拒绝写入) + name: 'application_same_supplier_guard', + type: 'conditional', + message: '准入申请必须是本供应商的申请', + when: P`has(record.application) && record.application != null`, + then: { + name: 'application_same_supplier', + type: 'script', + severity: 'error', + message: '准入申请必须是本供应商的申请', + condition: P`has(record.application.supplier) && record.application.supplier != record.supplier`, + }, + }, + { + // 已结束的申请不再需要审核结论 + name: 'application_in_progress_guard', + type: 'conditional', + events: ['insert'], + message: '只能为进行中的准入申请安排准入审核', + when: P`has(record.application) && record.application != null`, + then: { + name: 'application_in_progress', + type: 'script', + severity: 'error', + message: '只能为进行中的准入申请安排准入审核', + condition: P`has(record.application.status) && (record.application.status == 'approved' || record.application.status == 'rejected')`, + }, + }, + { + // 结论按分数线判定(WI-01 6.6.1):系统只拦「高判」,审核组可以从严判定 + name: 'pass_meets_thresholds', + type: 'script', + severity: 'error', + message: `「通过」要求总分 ≥ ${PASS} 分、每个审核块 ≥ ${FLOOR} 分,且没有严重不符合项和否决项`, + condition: celOf( + `record.result == 'pass' && (${below('checklist_score', PASS)} || ${below('lowest_block_score', FLOOR)}` + + ` || ${countAbove('serious_finding_count', 0)} || ${countAbove('veto_finding_count', 0)})`, + ), + }, + { + name: 'conditional_meets_thresholds', + type: 'script', + severity: 'error', + message: `「有条件通过」要求总分 ≥ ${FLOOR} 分、严重不符合项不超过 1 项,且没有否决项`, + condition: celOf( + `record.result == 'conditional' && (${below('checklist_score', FLOOR)} || ${countAbove('serious_finding_count', 1)}` + + ` || ${countAbove('veto_finding_count', 0)})`, + ), + }, + { + name: 'cancel_reason_required', + type: 'script', + severity: 'error', + message: '取消审核时需要填写原因', + condition: celOf(`record.status == 'cancelled' && ${blank('cancel_reason')}`), + }, + ], +}); diff --git a/src/core/security/permission-sets.ts b/src/core/security/permission-sets.ts index dab5af0..13ff850 100644 --- a/src/core/security/permission-sets.ts +++ b/src/core/security/permission-sets.ts @@ -34,6 +34,9 @@ export const InternalBasePermissionSet = { srm_delivery_record: read, // 整改单内部只读(04 §2.3) srm_quality_issue: read, + // 供应商审核内部只读:品类经理推进准入时看审核结论(v0.4) + srm_supplier_audit: read, + srm_supplier_audit_finding: read, }, }; @@ -103,6 +106,9 @@ export const SupplierAdminPermissionSet = { 'srm_supplier_category.status_before_freeze': flowOwned, 'srm_supplier_category.freeze_scope': flowOwned, 'srm_supplier_category.freeze_disposition': flowOwned, + // 现场审核结论由 srm_audit_admission_apply 写回(FR-204) + 'srm_supplier_application.audit_passed': flowOwned, + 'srm_supplier_application.audit_result': flowOwned, ...importOwned, }, }; @@ -123,9 +129,15 @@ export const QualityPermissionSet = { srm_delivery_record: write, // 整改单:手工新建(客户投诉、制程等),v0.6 前代供应商填写 8D 回复、推进状态、登记索赔(03 §8) srm_quality_issue: write, + // 供应商审核:安排准入、专项审核,执行年度计划,录入结论与问题项(FR-607) + srm_supplier_audit: write, + srm_supplier_audit_finding: write, }, fields: { ...supplierFieldsReadOnly, + // 计划年度由 srm_audit_plan_generate 写入,问题项的整改单由「转整改单」回填 + 'srm_supplier_audit.plan_year': flowOwned, + 'srm_supplier_audit_finding.quality_issue': flowOwned, 'srm_supplier_certificate.status': flowOwned, 'srm_supplier_certificate.legacy_imported': flowOwned, // 逾期标志由 srm_scar_overdue_sweep 维护 @@ -161,6 +173,8 @@ export const ExecutivePermissionSet = { srm_supplier_disposition: read, srm_delivery_record: read, srm_quality_issue: read, + srm_supplier_audit: read, + srm_supplier_audit_finding: read, }, }; @@ -245,6 +259,8 @@ export const AuditorPermissionSet = { srm_supplier_disposition: read, srm_delivery_record: read, srm_quality_issue: read, + srm_supplier_audit: read, + srm_supplier_audit_finding: read, }, }; diff --git a/src/core/translations/en.ts b/src/core/translations/en.ts index ccd2d8b..403ee34 100644 --- a/src/core/translations/en.ts +++ b/src/core/translations/en.ts @@ -349,6 +349,7 @@ export const en: TranslationData = { application_status_flow: { message: 'This application status change is not allowed' }, supplier_admissible: { message: 'Only a Potential or Eliminated supplier can apply for admission' }, reviewer_before_review: { message: 'Assign a reviewer before the admission review' }, + audit_before_review: { message: 'A high-risk application needs an on-site audit that passed or conditionally passed before the admission review' }, reject_reason_required: { message: 'Enter a reason when rejecting' }, }, fields: { @@ -372,6 +373,8 @@ export const en: TranslationData = { }, material_class: { label: 'Material Class' }, risk_class: { label: 'Risk Class', options: { high: 'High', medium: 'Medium', low: 'Low' } }, + audit_passed: { label: 'On-site Audit Passed' }, + audit_result: { label: 'On-site Audit Result', options: { pass: 'Pass', conditional: 'Conditional Pass', fail: 'Fail' } }, reviewer: { label: 'Reviewer' }, approval_status: { label: 'Approval Result', options: { pending: 'Pending', approved: 'Approved', rejected: 'Rejected' } }, approved_date: { label: 'Approved Date' }, @@ -545,6 +548,7 @@ export const en: TranslationData = { supplier: { label: 'Supplier' }, material: { label: 'Material' }, delivery_record: { label: 'Source Delivery Record' }, + audit: { label: 'Source Audit' }, source: { label: 'Source' }, severity: { label: 'Severity', options: { critical: 'Critical', major: 'Major', minor: 'Minor' } }, problem_description: { label: 'Problem Description (D2)' }, @@ -583,6 +587,63 @@ export const en: TranslationData = { claims: { label: 'Claims' }, }, }, + srm_supplier_audit: { + label: 'Supplier Audit', + pluralLabel: 'Supplier Audits', + _sections: { plan: { label: 'Audit Plan' }, result: { label: 'Audit Result' } }, + _validations: { + audit_status_flow: { message: 'This audit status change is not allowed' }, + application_same_supplier: { message: 'The admission application must belong to this supplier' }, + application_in_progress: { message: 'An admission audit can only be scheduled for an application in progress' }, + pass_meets_thresholds: { + message: 'Pass requires a total score of at least 85, every audit block at least 70, and no serious nonconformity or veto finding', + }, + conditional_meets_thresholds: { + message: 'Conditional pass requires a total score of at least 70, at most one serious nonconformity, and no veto finding', + }, + cancel_reason_required: { message: 'Enter a reason when cancelling an audit' }, + }, + _actions: { srm_audit_finding_to_scar: { label: 'Raise SCARs' } }, + fields: { + audit_no: { label: 'Audit No.' }, + supplier: { label: 'Supplier' }, + audit_type: { label: 'Audit Type', options: { admission: 'Admission Audit', annual: 'Annual Audit', special: 'Special Audit' } }, + application: { label: 'Admission Application' }, + plan_year: { label: 'Plan Year' }, + planned_date: { label: 'Planned Date' }, + auditors: { label: 'Auditors' }, + status: { label: 'Status', options: { planned: 'Planned', done: 'Completed', cancelled: 'Cancelled' } }, + cancel_reason: { label: 'Cancellation Reason' }, + audit_date: { label: 'Audit Date' }, + checklist_score: { label: 'Total Score' }, + lowest_block_score: { label: 'Lowest Audit Block Score' }, + result: { label: 'Audit Result', options: { pass: 'Pass', conditional: 'Conditional Pass', fail: 'Fail' } }, + sample_result: { label: 'Sample Inspection Result', options: { not_required: 'Not Required', pass: 'Pass', fail: 'Fail' } }, + report: { label: 'Audit Report' }, + scoresheet: { label: 'Audit Scoresheet' }, + serious_finding_count: { label: 'Serious Nonconformities' }, + veto_finding_count: { label: 'Veto Findings' }, + }, + _views: { + default: { label: 'All Audits' }, + planned: { label: 'Planned' }, + calendar: { label: 'Audit Calendar' }, + admission: { label: 'Admission Audits' }, + }, + }, + srm_supplier_audit_finding: { + label: 'Audit Finding', + pluralLabel: 'Audit Findings', + fields: { + audit: { label: 'Supplier Audit' }, + title: { label: 'Finding' }, + description: { label: 'Objective Evidence' }, + severity: { label: 'Severity', options: { serious: 'Serious Nonconformity', general: 'Minor Nonconformity', observation: 'Observation' } }, + veto: { label: 'Veto Finding' }, + requirement: { label: 'Corrective Action Requirement' }, + quality_issue: { label: 'SCAR' }, + }, + }, }, picklists: { srm_country: { @@ -684,6 +745,21 @@ export const en: TranslationData = { refuse_supplier: { message: 'The supplier is no longer qualified, so the contract cannot take effect' }, }, }, + srm_audit_finding_to_scar: { + label: 'Raise SCARs from Audit Findings', + successMessage: 'SCARs raised for the nonconformities', + screens: { + confirm: { + title: 'Raise SCARs: {{ audit.audit_no }}', + description: 'A SCAR will be raised for each serious or minor nonconformity of this audit that has none yet, with you as the responsible SQE. Observations are not raised.', + }, + }, + refusals: { + refuse_position: { message: 'Only SQEs or quality managers can raise SCARs from audit findings' }, + refuse_status: { message: 'Findings can be raised as SCARs only after the audit is completed' }, + refuse_none: { message: 'No nonconformities left to raise: observations are not raised, and findings already raised are not raised again' }, + }, + }, }, apps: { srm_workbench: { @@ -701,6 +777,7 @@ export const en: TranslationData = { group_quality: { label: 'Quality' }, nav_delivery_record: { label: 'Delivery Records' }, nav_quality_issue: { label: 'Corrective Action Requests' }, + nav_supplier_audit: { label: 'Supplier Audits' }, group_analytics: { label: 'Analytics' }, nav_delivery_dashboard: { label: 'Delivery Dashboard' }, nav_quality_dashboard: { label: 'Quality Dashboard' }, diff --git a/src/core/views/index.ts b/src/core/views/index.ts index 74e8c83..7ff64e2 100644 --- a/src/core/views/index.ts +++ b/src/core/views/index.ts @@ -10,3 +10,4 @@ export { ContractViews } from './contract.view.js'; 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'; diff --git a/src/core/views/supplier-audit.view.ts b/src/core/views/supplier-audit.view.ts new file mode 100644 index 0000000..f9d4b9a --- /dev/null +++ b/src/core/views/supplier-audit.view.ts @@ -0,0 +1,52 @@ +import { defineView } from '@objectstack/spec'; + +const data = { provider: 'object' as const, object: 'srm_supplier_audit' }; +const columns = [ + { field: 'audit_no' }, + { field: 'supplier' }, + { field: 'audit_type' }, + { field: 'status' }, + { field: 'planned_date' }, + { field: 'audit_date' }, + { field: 'auditors' }, + { field: 'checklist_score' }, + { field: 'result' }, +]; + +// 05 §2:年度审核计划按计划日期排在日历上,SQE 与质量经理据此安排审核组(FR-607,WI-01 6.9) +export const SupplierAuditViews = defineView({ + list: { + label: '全部审核', + type: 'grid', + data, + columns, + sort: [{ field: 'audit_no', order: 'desc' }], + }, + + listViews: { + planned: { + label: '计划中', + type: 'grid', + data, + columns, + filter: [{ field: 'status', operator: 'equals', value: 'planned' }], + sort: [{ field: 'planned_date', order: 'asc' }], + }, + calendar: { + label: '审核日历', + type: 'calendar', + data, + columns: ['audit_no', 'supplier', 'audit_type', 'status'], + filter: [{ field: 'status', operator: 'equals', value: 'planned' }], + calendar: { startDateField: 'planned_date', titleField: 'supplier', colorField: 'audit_type' }, + }, + admission: { + label: '准入审核', + type: 'grid', + data, + columns: [...columns.filter((c) => c.field !== 'audit_type'), { field: 'application' }, { field: 'sample_result' }], + filter: [{ field: 'audit_type', operator: 'equals', value: 'admission' }], + sort: [{ field: 'audit_no', order: 'desc' }], + }, + }, +}); diff --git a/test/admission-countersign.test.ts b/test/admission-countersign.test.ts index b6bc5b4..34c61da 100644 --- a/test/admission-countersign.test.ts +++ b/test/admission-countersign.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import config from '../objectstack.config'; -import { Category, Supplier, SupplierApplication } from '../src/core/objects'; +import { Category, Supplier, SupplierApplication, SupplierAudit } from '../src/core/objects'; // v0.4 Q0:A 类物资或高风险品类的准入,第 2 级由采购总监、质量经理并行会签(03 §3 步骤 6、§12)。 // 受理时把申请品类中最高的物资类别与风险等级复制到申请。岗位都有人任命,所以单独起一个栈, @@ -49,8 +49,20 @@ describe('admission countersign by the quality manager', () => { const res = await stack.apiAs(as, 'POST', `/approvals/requests/${req!.id}/${verdict}`, { comment: verdict }); expect(res.status, await res.clone().text()).toBe(200); }; - // 推进到准入评审,品类经理通过第 1 级 + // 推进到准入评审,品类经理通过第 1 级。高风险品类以现场审核为前置(FR-204,见 supplier-audit.test.ts), + // 先由系统录一份通过的准入审核 const toSecondLevel = async (app: string) => { + const { supplier } = await row(SupplierApplication.name, app); + await system(SupplierAudit.name, { + supplier, + application: app, + audit_type: 'admission', + status: 'done', + audit_date: new Date().toISOString().slice(0, 10), + checklist_score: 90, + lowest_block_score: 80, + result: 'pass', + }); await stack.hooks.run(SupplierApplication.name, 'update', { id: app, status: 'admission_review' }, { as: manager.token }); await decide(manager.token, app, 'approve'); }; diff --git a/test/permissions.test.ts b/test/permissions.test.ts index b820b7f..62a5fb8 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -70,6 +70,8 @@ describe('internal permissions', () => { "srm_plant": "R", "srm_quality_issue": "RE", "srm_supplier": "R", + "srm_supplier_audit": "R", + "srm_supplier_audit_finding": "R", "srm_supplier_bank_account": "R", "srm_supplier_category": "R", "srm_supplier_certificate": "R", @@ -92,6 +94,8 @@ describe('internal permissions', () => { "srm_quality_issue": "R", "srm_supplier": "RCE", "srm_supplier_application": "RCE", + "srm_supplier_audit": "R", + "srm_supplier_audit_finding": "R", "srm_supplier_bank_account": "RCE", "srm_supplier_category": "RCE", "srm_supplier_certificate": "RCE", @@ -114,6 +118,8 @@ describe('internal permissions', () => { "srm_plant": "R", "srm_quality_issue": "R", "srm_supplier": "R", + "srm_supplier_audit": "R", + "srm_supplier_audit_finding": "R", "srm_supplier_bank_account": "R", "srm_supplier_category": "R", "srm_supplier_certificate": "R", @@ -135,6 +141,8 @@ describe('internal permissions', () => { "srm_quality_issue": "R", "srm_supplier": "R", "srm_supplier_application": "R", + "srm_supplier_audit": "R", + "srm_supplier_audit_finding": "R", "srm_supplier_bank_account": "R", "srm_supplier_category": "R", "srm_supplier_certificate": "R", @@ -157,6 +165,8 @@ describe('internal permissions', () => { "srm_quality_issue": "R", "srm_supplier": "R", "srm_supplier_application": "R", + "srm_supplier_audit": "R", + "srm_supplier_audit_finding": "R", "srm_supplier_bank_account": "R", "srm_supplier_category": "R", "srm_supplier_certificate": "R", @@ -181,6 +191,8 @@ describe('internal permissions', () => { "srm_plant": "R", "srm_quality_issue": "R", "srm_supplier": "R", + "srm_supplier_audit": "R", + "srm_supplier_audit_finding": "R", "srm_supplier_bank_account": "R", "srm_supplier_category": "R", "srm_supplier_certificate": "R", diff --git a/test/supplier-audit.test.ts b/test/supplier-audit.test.ts new file mode 100644 index 0000000..d9bb843 --- /dev/null +++ b/test/supplier-audit.test.ts @@ -0,0 +1,324 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import config from '../objectstack.config'; +import { + Category, + QualityIssue, + Supplier, + SupplierApplication, + SupplierAudit, + SupplierAuditFinding, + SupplierCategory, +} from '../src/core/objects'; + +// v0.4 Q3 供应商审核(02 §4.2,03 §3 第 5 步、§13,FR-204、FR-607,WI-01):高风险品类的准入以现场审核为前置, +// 有条件通过的准入批准后为试供;结论按分数线校验;审核问题项转整改单;年度审核计划按物资类别生成,跑两遍不重复。 +// 测试数据全部在这里生成(示例公司名、示例品类)。 +describe('supplier audit', () => { + let stack: VerifyStack; + let seq = 0; + + type Person = { token: string; id: string }; + const person = async (...positions: string[]): Promise => { + const token = await stack.signUp(`audit-${positions.join('-')}-${++seq}@example.com`); + const context = await stack.contextFor(token); + const org = context.tenantId ? { organization_id: String(context.tenantId) } : {}; + await stack.seed('sys_user_position', positions.map((position) => ({ user_id: String(context.userId), position, ...org }))); + return { token, id: String(context.userId) }; + }; + const system = async (object: string, doc: Record) => + (await stack.hooks.run(object, 'insert', doc, { system: true })) as { id: string }; + const row = async (object: string, id: string) => (await stack.rows(object, { id }))[0]!; + const insert = async (as: Person, object: string, doc: Record) => + (await stack.hooks.run(object, 'insert', doc, { as: as.token })) as { id: string }; + const update = (as: Person, object: string, doc: Record) => stack.hooks.run(object, 'update', doc, { as: as.token }); + + const day = (offset: number) => new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10); + const today = () => day(0); + const year = new Date().getUTCFullYear(); + + const category = async (doc: Record) => { + const n = ++seq; + return (await system(Category.name, { code: `AUD${n}`, name: `示例审核品类 ${n}`, ...doc })).id; + }; + const supplier = async (doc: Record = {}) => { + const n = ++seq; + return ( + await system(Supplier.name, { name: `示例审核测试有限公司 ${n}`, credit_code: `91000000MA0AUD${String(n).padStart(4, '0')}`, ...doc }) + ).id; + }; + // 合格的存量供应商,带若干品类资格 + const qualifiedSupplier = async (categories: string[], doc: Record = {}) => { + const id = await supplier({ legacy_imported: true, ...doc }); + for (const c of categories) await system(SupplierCategory.name, { supplier: id, category: c, status: 'qualified' }); + return id; + }; + const completed = { status: 'done', audit_date: today(), checklist_score: 90, lowest_block_score: 80, result: 'pass' }; + + 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); + }; + + // 从审核详情页按钮启动「转整改单」,停在确认屏后提交;拒绝时返回 end 节点的结果 + const toScar = async (as: Person, recordId: string) => { + const started = (await stack.actions.run(SupplierAudit.name, 'srm_audit_finding_to_scar', { as: as.token, recordId })) as { + status?: string; + runId?: string; + }; + if (started.status !== 'paused' || !started.runId) return started; + return stack.flows.resume({ flowName: 'srm_audit_finding_to_scar', runId: started.runId }, {}, { as: as.token }); + }; + + // 定时类 flow 默认不挂(objectstack#25442);这里打开,与部署时打开开关一致 + const scheduledWork = process.env.OS_AUTOMATION_SCHEDULED_WORK_ENABLED; + const generate = async () => { + const run = await stack.flows.run('srm_audit_plan_generate', {}, { system: true }); + expect(run.success, JSON.stringify(run)).toBe(true); + }; + const auditsOf = (supplierId: string) => stack.rows(SupplierAudit.name, { supplier: supplierId }); + + let sqe: Person; + let manager: Person; + let director: Person; + let qualityManager: Person; + + beforeAll(async () => { + process.env.OS_AUTOMATION_SCHEDULED_WORK_ENABLED = 'true'; + stack = await bootStack(config); + sqe = await person('srm_sqe'); + manager = await person('srm_category_manager'); + director = await person('srm_procurement_director'); + qualityManager = await person('srm_quality_manager'); + }); + + 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; + }); + + describe('admission', () => { + const apply = async (categories: string[]) => { + const vendor = await supplier(); + const app = await insert(manager, SupplierApplication.name, { supplier: vendor, applied_categories: categories, reviewer: manager.id }); + return { vendor, app: app.id }; + }; + const toReview = (app: string) => update(manager, SupplierApplication.name, { id: app, status: 'admission_review' }); + const admissionAudit = (vendor: string, app: string, doc: Record) => + insert(sqe, SupplierAudit.name, { supplier: vendor, application: app, audit_type: 'admission', planned_date: today(), ...doc }); + + it('keeps a high-risk application out of the admission review until an audit passes, then grants trial on a conditional pass', async () => { + const risky = await category({ risk_class: 'high', sqe: sqe.id }); + const { vendor, app } = await apply([risky]); + expect(await row(SupplierApplication.name, app)).toMatchObject({ status: 'doc_review', risk_class: 'high', audit_passed: false }); + await expect(toReview(app)).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }); + + // 计划中的审核不写回;完成且有条件通过后写回 + const audit = await admissionAudit(vendor, app, {}); + expect((await row(SupplierApplication.name, app)).audit_passed).toBe(false); + await insert(sqe, SupplierAuditFinding.name, { audit: audit.id, title: '检验规程未覆盖新品', severity: 'serious' }); + await update(sqe, SupplierAudit.name, { id: audit.id, status: 'done', audit_date: today(), checklist_score: 78, lowest_block_score: 72, result: 'conditional' }); + expect(await row(SupplierApplication.name, app)).toMatchObject({ audit_passed: true, audit_result: 'conditional' }); + + await toReview(app); + await decide(manager, app, 'approve'); + await decide(director, app, 'approve'); + await decide(qualityManager, app, 'approve'); + expect((await row(SupplierApplication.name, app)).status).toBe('approved'); + const [grant] = await stack.rows(SupplierCategory.name, { supplier: vendor }); + expect(grant).toMatchObject({ category: risky, status: 'trial' }); + }); + + it('blocks the admission review when the audit fails', async () => { + const { vendor, app } = await apply([await category({ risk_class: 'high' })]); + await admissionAudit(vendor, app, { ...completed, checklist_score: 60, lowest_block_score: 50, result: 'fail' }); + expect(await row(SupplierApplication.name, app)).toMatchObject({ audit_passed: false, audit_result: 'fail' }); + await expect(toReview(app)).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }); + }); + + it('does not require an audit for a medium-risk application and grants qualified after a pass', async () => { + const { vendor, app } = await apply([await category({ risk_class: 'medium' })]); + await toReview(app); + await decide(manager, app, 'approve'); + await decide(director, app, 'approve'); + const [grant] = await stack.rows(SupplierCategory.name, { supplier: vendor }); + expect(grant!.status).toBe('qualified'); + }); + + it('does not let the category manager tick the audit flag', async () => { + const { app } = await apply([await category({ risk_class: 'high' })]); + await update(manager, SupplierApplication.name, { id: app, audit_passed: true }).catch(() => undefined); + expect((await row(SupplierApplication.name, app)).audit_passed).toBe(false); + }); + + it('refuses an admission audit for another supplier\'s application', async () => { + const { app } = await apply([await category({})]); + const other = await supplier(); + await expect(admissionAudit(other, app, {})).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }); + await expect(insert(sqe, SupplierAudit.name, { supplier: other, audit_type: 'admission', planned_date: today() })).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + }); + }); + }); + + describe('result thresholds', () => { + const special = async () => + (await insert(sqe, SupplierAudit.name, { supplier: await supplier(), audit_type: 'special', planned_date: today() })).id; + const finish = (id: string, doc: Record) => update(sqe, SupplierAudit.name, { id, ...completed, ...doc }); + + it('refuses a pass below the score lines or with a serious nonconformity', async () => { + const low = await special(); + await expect(finish(low, { checklist_score: 84 })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }); + await expect(finish(low, { lowest_block_score: 69 })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }); + await finish(low, { checklist_score: 84, result: 'conditional' }); + expect((await row(SupplierAudit.name, low)).status).toBe('done'); + + const serious = await special(); + await insert(sqe, SupplierAuditFinding.name, { audit: serious, title: '无危险化学品管理制度', severity: 'serious' }); + await expect(finish(serious, {})).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }); + await finish(serious, { result: 'conditional' }); + }); + + it('allows only a fail with a veto finding or two serious nonconformities', async () => { + const veto = await special(); + await insert(sqe, SupplierAuditFinding.name, { audit: veto, title: '检验报告造假', severity: 'serious', veto: true }); + await expect(finish(veto, { result: 'conditional' })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }); + await finish(veto, { result: 'fail' }); + + const two = await special(); + await insert(sqe, SupplierAuditFinding.name, { audit: two, title: '仓储无温湿度记录', severity: 'serious' }); + await insert(sqe, SupplierAuditFinding.name, { audit: two, title: '无首件检验', severity: 'serious' }); + await expect(finish(two, { result: 'conditional' })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }); + }); + + it('needs a reason to cancel and keeps a completed audit completed', async () => { + const id = await special(); + await expect(update(sqe, SupplierAudit.name, { id, status: 'cancelled' })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }); + await update(sqe, SupplierAudit.name, { id, status: 'cancelled', cancel_reason: '供应商停产' }); + await update(sqe, SupplierAudit.name, { id, status: 'planned' }); + await finish(id, {}); + await expect(update(sqe, SupplierAudit.name, { id, status: 'planned' })).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }); + }); + }); + + describe('findings to SCAR', () => { + it('raises one SCAR per nonconformity, links it back, and never raises twice', async () => { + const vendor = await supplier(); + const audit = await insert(sqe, SupplierAudit.name, { supplier: vendor, audit_type: 'special', planned_date: today() }); + const serious = await insert(sqe, SupplierAuditFinding.name, { + audit: audit.id, + title: '来料未按规程抽样', + description: '抽查 3 批记录均未按 AQL 抽样', + severity: 'serious', + requirement: '15 日内提交整改计划', + }); + const general = await insert(sqe, SupplierAuditFinding.name, { audit: audit.id, title: '量具校准标签过期', severity: 'general' }); + const observation = await insert(sqe, SupplierAuditFinding.name, { audit: audit.id, title: '现场标识可改进', severity: 'observation' }); + + // 审核完成前不能转 + expect(await toScar(sqe, audit.id)).toMatchObject({ status: 'refused' }); + await update(sqe, SupplierAudit.name, { id: audit.id, ...completed, checklist_score: 80, result: 'conditional' }); + // 不是质量岗位不能转 + const refused = await toScar(manager, audit.id); + expect(refused, JSON.stringify(refused)).toMatchObject({ status: 'refused' }); + + const run = await toScar(sqe, audit.id); + expect(run, JSON.stringify(run)).toMatchObject({ success: true }); + const issues = await stack.rows(QualityIssue.name, { audit: audit.id }); + expect(issues).toHaveLength(2); + const bySeverity = Object.fromEntries(issues.map((i) => [i.severity, i])); + expect(bySeverity.critical).toMatchObject({ supplier: vendor, source: 'audit', status: 'open', sqe: sqe.id }); + expect(String(bySeverity.critical!.problem_description)).toContain('来料未按规程抽样'); + expect(String(bySeverity.critical!.problem_description)).toContain('AQL'); + expect(String(bySeverity.critical!.problem_description)).toContain('15 日内提交整改计划'); + expect(bySeverity.major).toBeTruthy(); + + expect((await row(SupplierAuditFinding.name, serious.id)).quality_issue).toBe(bySeverity.critical!.id); + expect((await row(SupplierAuditFinding.name, general.id)).quality_issue).toBe(bySeverity.major!.id); + expect((await row(SupplierAuditFinding.name, observation.id)).quality_issue ?? null).toBeNull(); + + // 再点一次:没有待转的问题项 + expect(await toScar(qualityManager, audit.id)).toMatchObject({ status: 'refused' }); + expect(await stack.rows(QualityIssue.name, { audit: audit.id })).toHaveLength(2); + // 转出的问题项不再修改 + await update(sqe, SupplierAuditFinding.name, { id: serious.id, title: '改过的标题' }).catch(() => undefined); + expect((await row(SupplierAuditFinding.name, serious.id)).title).toBe('来料未按规程抽样'); + }); + }); + + describe('annual plan', () => { + let classA: string; + let classB: string; + let classC: string; + + beforeAll(async () => { + classA = await category({ material_class: 'class_a', sqe: sqe.id }); + classB = await category({ material_class: 'class_b' }); + classC = await category({ material_class: 'class_c' }); + }); + + const pastAudit = (vendor: string, auditDate: string) => + system(SupplierAudit.name, { supplier: vendor, audit_type: 'annual', ...completed, audit_date: auditDate }); + + it('plans class A and B suppliers by their frequency, and a second run creates nothing new', async () => { + // A 类,从没审核过:今天到期 + const neverAudited = await qualifiedSupplier([classA]); + // A 类,上次审核在去年 12 月 31 日:今年 12 月 31 日到期 + const dueAtYearEnd = await qualifiedSupplier([classA]); + await pastAudit(dueAtYearEnd, `${year - 1}-12-31`); + // A 类,今年 1 月 1 日审核过:明年才到期 + const auditedThisYear = await qualifiedSupplier([classA]); + await pastAudit(auditedThisYear, `${year}-01-01`); + // B 类,两年前 1 月 1 日审核过:今年 1 月 1 日已到期,计划日期取今天 + const classBDue = await qualifiedSupplier([classB]); + await pastAudit(classBDue, `${year - 2}-01-01`); + // B 类,去年 1 月 1 日审核过:24 个月后才到期 + const classBNotDue = await qualifiedSupplier([classB]); + await pastAudit(classBNotDue, `${year - 1}-01-01`); + // 同时供 A、B 两类,去年 1 月 1 日审核过:按 A 类 12 个月,今年到期 + const both = await qualifiedSupplier([classB, classA]); + await pastAudit(both, `${year - 1}-01-01`); + // C 类不定期;冻结的供应商不排计划 + const classCOnly = await qualifiedSupplier([classC]); + const frozen = await qualifiedSupplier([classA]); + await stack.hooks.run(Supplier.name, 'update', { id: frozen, status: 'frozen' }, { system: true }); + + await generate(); + const planned = async (id: string) => (await auditsOf(id)).filter((a) => a.status === 'planned'); + + const [first] = await planned(neverAudited); + expect(first).toMatchObject({ audit_type: 'annual', plan_year: year, planned_date: today(), auditors: [sqe.id] }); + expect(String(first!.audit_no)).toMatch(/^AUD\d{4}\d{5}$/); + expect((await planned(dueAtYearEnd))[0]).toMatchObject({ planned_date: `${year}-12-31` }); + expect(await planned(auditedThisYear)).toHaveLength(0); + expect((await planned(classBDue))[0]).toMatchObject({ planned_date: today(), plan_year: year }); + expect(await planned(classBNotDue)).toHaveLength(0); + expect((await planned(both))[0]).toMatchObject({ planned_date: today() }); + expect(await planned(both)).toHaveLength(1); + expect(await planned(classCOnly)).toHaveLength(0); + expect(await planned(frozen)).toHaveLength(0); + + // 跑第二遍:不重复建 + const count = async () => + (await Promise.all([neverAudited, dueAtYearEnd, classBDue, both].map(auditsOf))).reduce((n, list) => n + list.length, 0); + const before = await count(); + await generate(); + expect(await count()).toBe(before); + }); + + it('does not plan again after the quality department cancels this year\'s audit', async () => { + const vendor = await qualifiedSupplier([classB]); + await generate(); + const [plan] = await auditsOf(vendor); + expect(plan).toMatchObject({ status: 'planned', plan_year: year }); + await update(qualityManager, SupplierAudit.name, { id: plan!.id, status: 'cancelled', cancel_reason: 'B 类非危化供应商,本年不安排现场复审(WI-01 6.9)' }); + await generate(); + expect(await auditsOf(vendor)).toHaveLength(1); + }); + }); +});