diff --git a/docs/design/02-domain-model.md b/docs/design/02-domain-model.md index 738e7be..8c3d26d 100644 --- a/docs/design/02-domain-model.md +++ b/docs/design/02-domain-model.md @@ -635,17 +635,18 @@ SQE、质量经理、应付会计经共享规则 `srm_quality_issue_edit_*` 改 | `source` | select(共享选项列表 `srm_quality_issue_source`) | `iqc` 来料 / `production` 制程 / `customer` 客户投诉 / `audit` 审核发现 / `performance` 绩效改善 | | `severity` | select | 同检验 | | `problem_description` | richtext | D2 问题描述,必填 | -| `status` | select(stageField) | 见 §5.6 | +| `status` | select(stageField) | 见 §5.6;门户用户只能走供应商的两步(待响应 → 已提交临时措施 → 已提交根因与措施),其余状态保持不变,由选项的 `visibleWhen` 按操作人在服务端限定(v0.6 U5) | | `sqe` | user | 负责 SQE | | `containment_due` / `final_due` | datetime / date | 只读,创建时按参数算好:临时措施 `scar_containment_hours`(示例 72 小时),最终回复 `scar_final_days`(示例 30 天);问题复发重新打开时按重开时间重算 | -| `containment_action` | richtext | D3,供应商填写(v0.6 前由 SQE 代填);提交临时措施后必填 | +| `containment_action` | richtext | D3,供应商在门户填写(没开通门户的由 SQE 代填);提交临时措施后必填 | | `root_cause` | richtext | D4;提交根因与措施后必填 | | `corrective_action` / `preventive_action` | richtext | D5–D6 / D7;纠正措施提交后必填 | | `evidence` | file(多个) | 整改证据 | | `sqe_comment` | textarea | 审核意见,退回时必填 | | `is_overdue` | boolean | 默认 false,逾期 flow 维护 | | `claim_amount` | currency | 索赔金额 | -| `claim_status` | select | `none` / `proposed` / `supplier_accepted` / `disputed` / `settled`(财务已在 ERP 扣款,由应付会计从「供应商已接受」手工标记,选项对其他人不可选;SRM 不生成扣款);不是 `none` 时必须填索赔金额 | +| `claim_status` | select | `none` / `proposed` / `supplier_accepted` / `disputed` / `settled`(财务已在 ERP 扣款,由应付会计从「供应商已接受」手工标记,选项对其他人不可选;SRM 不生成扣款);不是 `none` 时必须填索赔金额;供应商在门户经「确认索赔」接受或提出异议(v0.6 U5) | +| `claim_response` | textarea | 供应商索赔意见,提出异议时必填;「确认索赔」写入,SQE 代录线下确认时可补记(v0.6 U5) | 整改单上 8D 内容在关闭或撤销后只读。 @@ -653,7 +654,7 @@ SQE、质量经理、应付会计经共享规则 `srm_quality_issue_edit_*` 改 (D2 `problem_description`、D3 `containment_action`、D4 `root_cause`、D5–D7 `corrective_action` / `preventive_action`、 `evidence`、`sqe_comment`),`status` 即 8D 进度,QP-04 的控制点都由这些字段覆盖。平台已裁定对象上的 repeater 不声明 子字段、有类型的列表要用子对象(objectstack#25681),核心不为此增加对象(对象数按 07 §1 不变)。页面需要时间点的, -v0.6 U5 可以加两个由现有状态流转 flow 写入的日期时间字段(临时措施提交时间、根因与措施提交时间)。客户要求逐步记录 +可以加两个由状态流转 flow 写入的日期时间字段(临时措施提交时间、根因与措施提交时间);v0.6 U5 没有加,提交时间看字段历史(整改单开启了 `trackHistory`)。客户要求逐步记录 责任人和日期的(如汽车行业),放客户层,或在门槛 2 之前以新增字段的方式加。 ### 4.7 绩效(v0.5) @@ -688,9 +689,11 @@ ERP 集成前为空)、`delivery_count`(number,本期交付行数)、`da 某项启用的自动指标没有数据时为 true,该项不给零分,07 R7)、`score_due`(date,评分截止日,转人工评分时为当天 + `perf_score_days`)、`approval_status`(审批结论,审定发布的审批节点写入)、`reviewer`(user,审定人)、`review_comment` (审定意见:通过或退回重评时采购总监的意见)、`published_date`(date)、`appeal_reason`、`appeal_date`(date,申诉登记日)、 -`appeal_recorded_by`(user,代录人)、`appeal_result`、`appeal_outcome`(`maintained` 维持原结果 / `adjusted` 调整总分)、 +`appeal_recorded_by`(user,申诉提交人:门户管理员或内部代录人)、`appeal_result`、`appeal_outcome`(`maintained` 维持原结果 / `adjusted` 调整总分)、 `appeal_handled_by`(user,处理申诉的采购总监)、`void_reason`(v0.5 P3 加入后面这些字段)。 自动指标由交付记录按 `period` 汇总,比率字段存小数(0.985 即 98.5%);flow 写的字段一律只读。 +列表搜索只扫 `review_no`、`period`、`cycle`、`status`、`grade`(`searchableFields`),评分明细只扫 `comment`:默认会扫全部 +文本字段,而审定意见、回避原因门户读不到(04 §4.2),扫到读不到的字段平台会拒绝整个搜索。 维度得分是该维度各评分明细按权重的加权平均(0–100,没有该维度的指标时为空),总分是全部明细的 `Σ(score × weight)` (启用指标权重合计 100%),都保留两位小数,等级按两位小数的总分判断。 diff --git a/docs/design/03-processes-and-automation.md b/docs/design/03-processes-and-automation.md index 024addc..de42334 100644 --- a/docs/design/03-processes-and-automation.md +++ b/docs/design/03-processes-and-automation.md @@ -336,13 +336,13 @@ 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` 不合格只计入绩效,不自动开单。审核问题项由 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 后重新审核 | +| 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` 联系人的通知随 v0.6 U6(#82)。`minor` 不合格只计入绩效,不自动开单。审核问题项由 SQE 或质量经理在已完成的审核上点「转整改单」(`srm_audit_finding_to_scar`):还没转出的严重、一般不符合项各开一张(`source = audit`,严重 → `critical`、一般 → `major`,负责 SQE 为操作人),整改单回填到问题项,重复点击不重复开单;观察项不转。供应商在门户回复(v0.6 U5),没开通门户的由 SQE 代填 | +| 2 临时措施 | 供应商在门户填 D3 后把状态改为「已提交临时措施」(没开通门户的由 SQE 代填) | 状态→`containment_submitted`;D3 必填。提交人不是负责 SQE 本人时 `srm_scar_containment_notice` 通知 SQE(没有 SQE 时通知质量经理)。SQE 认为临时措施不够时退回到 `open`,退回意见写入 `sqe_comment`(必填)。门户只能改本公司处于 `open`、`containment_submitted` 的整改单,只能改 D3–D7 与证据,状态只能往前走这两步(04 §3.2);SQE 审核 D3 期间供应商可以接着填 D4–D7 | +| 3 根因与措施 | 供应商在门户提交 D4–D7(没开通门户的由 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` 重新审核 | | 5 逾期升级 | 追赶式扫描 `srm_scar_overdue_sweep`(每小时;外部心跳见 §2.3 R3) | 超过 `containment_due` 仍为 `open`、或超过 `final_due` 仍未到 `verifying`:`is_overdue = true`,通知负责 SQE 与质量经理(SQE 主管,FR-605);标志保留到关闭,供「SCAR 按期关闭率」统计;提醒供应商等 objectstack#25444 或 v0.6 | | 5a 复发重开 | SQE 把已关闭的整改单改回 `open` | flow `srm_scar_reopen`:期限按重开时间重算,逾期标志清掉,通知负责 SQE | -| 6 索赔 | SQE 填写 `claim_amount`,`claim_status → proposed` | 供应商在门户接受或提出异议(v0.6 前线下确认后由 SQE 记录);接受后(`supplier_accepted`)由财务在 ERP 扣款,应付会计回来标记 `settled`。SRM 不生成扣款 | +| 6 索赔 | SQE 填写 `claim_amount`,`claim_status → proposed` | 供应商在门户的整改单上点「确认索赔」(screen flow `srm_claim_respond`)接受或提出异议,异议必须写意见(`claim_response`);没开通门户的线下确认后由 SQE 记录。接受(`supplier_accepted`)通知负责 SQE 与应付会计,由财务在 ERP 扣款,应付会计回来标记 `settled`;异议(`disputed`)通知负责 SQE 协商,调整金额后可重新提出。SRM 不生成扣款 | **供应商审核与年度审核计划**(v0.4,FR-607;体系文件 WI-01):准入审核见 §3 第 5 步;年度审核与专项审核由 SQE 建审核记录,审核后录入结论与问题项,不符合项经「转整改单」进入上面的整改流程。年度计划由 @@ -389,7 +389,7 @@ stateDiagram-v2 | 4 汇总分级 | 状态→`reviewing`(`srm_performance_publish`) | `total_score = Σ(score × weight)`,各维度得分为维度内按权重的加权平均;等级线取参数 `perf_grade_min`(示例 A ≥ 90、B ≥ 80、C ≥ 70,其余 D),写入考评单 | | 5 审定发布 | 同一 flow 的审批:采购总监(`position = srm_procurement_director`) | 通过后→`published`,写审定人、发布日期;回写供应商 `grade`、`latest_score`(同一供应商已有更晚期间的已发布考评单时不回写),通知评分人;供应商可在门户查看结果与扣分项(FR-704,v0.6)。采购总监驳回即「退回重评」:考评单回到 `scoring`,审定意见写入 `review_comment`,截止日重新起算,通知评分人改分。职责分离(同 BR-11):审定人不能是本考评单的评分人,撞人时这次通过不算数,通知后重新开一轮审定 | | 6 结果应用 | 发布后的记录触发(`srm_performance_apply`,审定中 → 已发布) | C 级:生成改善要求(整改单,`source = performance`);D 级:生成处置单草稿(冻结);连续 `perf_d_exit_periods` 期 D 级:生成淘汰处置单草稿交品类经理评审(BR-18);A 级:在名录中标记优选,供寻源邀请参考。参数 `perf_auto_apply`(示例开启)关闭时只通知品类经理(07 D9),细则见表后说明 | -| 7 申诉 | 供应商发布后 `perf_appeal_days`(示例 7)天内 | v0.6 前由采购员、采购主管或品类经理在考评单上代录(screen flow `srm_appeal_submit`,门户上线后供应商自己提交):只能申诉一次,写申诉理由 → `appealed`,通知采购总监。采购总监经「处理申诉」(`srm_appeal_resolve`)写处理结果,选择维持原结果或调整总分(等级按新总分重算并回写供应商)→ 回到 `published`,处理人和结果留在考评单上(FR-706)。处理人同样不能是本考评单的评分人。结果应用(第 6 步)只在审定发布时触发;申诉改变等级时,flow 通知品类经理,按原等级生成的草稿由品类经理作废或另行处理 | +| 7 申诉 | 供应商发布后 `perf_appeal_days`(示例 7)天内 | 供应商门户管理员在门户的考评单上提交(screen flow `srm_appeal_submit`,v0.6 U5);没开通门户的由采购员、采购主管或品类经理代录:只能申诉一次,写申诉理由 → `appealed`,通知采购总监。采购总监经「处理申诉」(`srm_appeal_resolve`)写处理结果,选择维持原结果或调整总分(等级按新总分重算并回写供应商)→ 回到 `published`,处理人和结果留在考评单上(FR-706)。处理人同样不能是本考评单的评分人。结果应用(第 6 步)只在审定发布时触发;申诉改变等级时,flow 通知品类经理,按原等级生成的草稿由品类经理作废或另行处理 | > 结果应用(v0.5,`srm_performance_apply`):只在审定发布(`reviewing` → `published`)时触发,申诉处理回到已发布不触发。 > - C 级:开一张整改单,`source = performance`、严重度 `minor`、`performance_review` 指回考评单,问题描述写总分与各维度得分;负责 @@ -505,6 +505,7 @@ 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_scar_containment_notice` | record_change:状态 `open`→`containment_submitted`,提交人不是负责 SQE | 整改单 → SQE | v0.6 | | `srm_audit_admission_apply` | record_change:准入审核完成或改结论 | 供应商审核 → 准入申请 | v0.4 | | `srm_audit_plan_generate` | schedule:每月 1 日 02:30(追赶式;年初那次生成全年计划) | 供应商审核 | v0.4 | | `srm_performance_generate` | schedule:每月 `perf_generate_day`(示例 6 日)起连续 7 天 03:00(追赶式,按类别周期) | 考评单、评分明细 | v0.5 | @@ -525,7 +526,9 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_esign_callback`、`srm_erp_push_retry`、`srm_external_risk_scan` | api / schedule | 合同、同步字段、风险事件 | 客户驱动主题 | | `srm_statement_generate`、`srm_invoice_post`、`srm_payment_callback` | — | — | 不做(ERP) | -用户操作入口的 screen flow(均以系统身份运行,flow 内做校验后再写入): +用户操作入口的 screen flow(均以系统身份运行,flow 内做校验后再写入)。门户能发起的(`srm_appeal_submit`、`srm_claim_respond`) +读不到成员键,进门检查改为直接查名册:操作人必须是记录所属供应商的在职联系人(`portal_user` = 本人、`active == true`, +与成员键的名册条件相同,04 §3.1),否则拒绝;记录页按钮对门户不可见的记录本来就答「不存在」,这一检查挡的是直接触发 flow 的请求: | screen flow | 入口 | 使用者 | 版本 | |---|---|---|---| @@ -541,6 +544,7 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_score_reassign` 改派评分人 | 评分项(考评单人工评分中) | 采购总监 | v0.5 | | `srm_performance_void` 作废 | 考评单(人工评分) | 采购总监 | v0.5 | | `srm_appeal_submit` 申诉 | 考评单(已发布);v0.6 起门户:绩效 | 采购员、采购主管、品类经理代录(v0.5);门户管理员(v0.6) | v0.5 | +| `srm_claim_respond` 确认索赔 | 门户:整改单(索赔已提出) | 门户用户 | v0.6 | | `srm_appeal_resolve` 处理申诉 | 考评单(申诉中) | 采购总监 | v0.5 | | `srm_portal_enable` 开通门户 | 供应商联系人 | `srm_supplier_admin` | v0.6 | | `srm_invite_supplier` 邀请供应商 | 寻源单 | 寻源工程师 | v0.7 | diff --git a/docs/design/04-security-and-portal.md b/docs/design/04-security-and-portal.md index 6ab829b..8a69b6a 100644 --- a/docs/design/04-security-and-portal.md +++ b/docs/design/04-security-and-portal.md @@ -184,8 +184,8 @@ flowchart LR | 价格记录 | 本公司 | — | | | 合同 / 合同明细 | 本公司且 `status in ['signing','active','expired','terminated']` | — | 草稿与审批中的合同不可见;`private`:读共享规则只共享这几种状态,RLS 再限定本公司 | | 交付记录 | 本公司 | — | 供应商看到的是用于算分的交付结果 | -| 整改单 | 本公司 | 供应商回复字段 | `sqe_comment`、`claim_amount` 只读 | -| 考评单 / 评分明细 | 本公司且 `status in ['published','appealed']` | 只经「申诉」screen flow(U5) | 未发布的考评不可见;申诉只能对已发布的考评提起,申诉期间照常可见 | +| 整改单 | 本公司 | 状态为 `open`、`containment_submitted` 的本公司整改单:只能改 D3–D7 与证据,状态只能走 待响应 → 已提交临时措施 → 已提交根因与措施;索赔只经「确认索赔」screen flow(U5) | 写策略 `using` 限定轮到供应商回复的行,`check` 限定改完仍属本公司、停在这三个状态;状态怎么走由 `status` 选项的 `visibleWhen` 按操作人限定(服务端执行);其余字段(含 `sqe_comment`、`claim_amount`、`claim_status`)FLS 只读 | +| 考评单 / 评分明细 | 本公司且 `status in ['published','appealed']` | 只经「申诉」screen flow,门户管理员(U5) | 未发布的考评不可见;申诉只能对已发布的考评提起,申诉期间照常可见;评分人、回避改派、审定人与审定意见、申诉处理人门户不可见(§4.2) | | 采购订单 / 订单行、送货单 / 明细 | 预留(ERP 集成后) | 订单行的答复字段;送货单只经 screen flow | 订单头授予编辑权但字段全部 FLS 只读 | | 寻源单、寻源明细、审核、风险事件、处置单、门户邀请 | 不可见 | — | 门户权限集不授予这些对象;处置结果体现在供应商状态上 | @@ -341,6 +341,8 @@ export const PortalSupplier = definePermissionSet({ | 交叉读 | 准备供应商 A、B 各一个门户用户;对 §3.2 每个对象,用 A 的身份按 B 的记录 id 读取 | 全部返回「不存在」,列表与搜索中不出现 | | 交叉写 | 用 A 的身份修改 B 的记录、把自己的单据改挂到 B 名下、在 B 名下新建联系人或证书 | 全部拒绝 | | 冒用名册 | 用门户身份(含门户管理员)或任何内部岗位写联系人的 `portal_user`、改联系人归属或在职标志 | 拒绝或写入被丢弃,授权不变;只有 flow 能写 | +| 越过回复范围 | 用 A 的身份改本公司整改单的非回复字段(SQE 意见、索赔、问题描述、逾期标志)、把状态改成效果验证 / 关闭 / 撤销或退回、改轮到 SQE 的整改单 | 拒绝或写入被丢弃,值不变(v0.6 U5) | +| 越权 screen flow | 用 A 的身份对 B 的整改单「确认索赔」、对 B 的考评单「申诉」,分别经记录页按钮和直接触发 flow;联系人停用后对本公司考评单申诉 | 按钮答「不存在」,直接触发被 flow 的名册检查拒绝,记录不变;恢复在职后本公司的申诉照常(v0.6 U5) | | 开标后改价 | 开标后用中标供应商的身份修改本公司报价明细的单价或中标字段 | 拒绝(v0.7 寻源上线时加入) | | 自解资质异常 | 用门户身份把本公司过期证书的 `status` 改为有效,或修改有效期;新建证书时自带「已核验」 | 全部拒绝(v0.6 U2 门户只能新建证书;U4 放开修改时,改有效期重置 `verified`,资质异常不解除) | | 空名册 | 持门户岗位但不在任何在职联系人上的用户;联系人停用的用户;在名册上但没有门户岗位的用户;没开通门户的供应商的单据 | 每个对象零行 | @@ -393,6 +395,8 @@ export const PortalSupplier = definePermissionSet({ | `srm_po_line.unit_price` / `quantity` / `required_date`(预留) | 内部、本公司门户 | 仅 ERP 集成 | 订单以 ERP 为准 | | `srm_quality_issue.claim_amount` | 内部、本公司门户 | SQE | | | `srm_performance_review.*_score` | 内部、本公司门户(已发布) | 算分 flow、评分人 | | +| `srm_performance_review` 的 `reviewer`、`review_comment`、`appeal_handled_by`;`srm_performance_score` 的 `scorer`、`reassigned_from`、`reassigned_by`、`reassign_reason`、`reassigned_date` | 内部 | flow | 内部评价过程:评分人与回避信息不对被评供应商公开(GL-05)。两个对象声明了 `searchableFields`,门户的列表搜索不扫这些字段(02 §4.7) | +| `srm_quality_issue` 的 D3–D7、`evidence`、`status` | 内部、本公司门户 | SQE;本公司门户(轮到供应商回复时,§3.2) | 门户在整改单上能改的只有这几项 | | `srm_supplier_contact.portal_user` | 内部、本公司门户 | 仅 flow(字段级只读) | 门户名册:写入即授出这家供应商的门户数据(ADR-0010) | | `srm_supplier_contact.active` | 内部、本公司门户 | 内部(`srm_supplier_admin`);门户只读 | 名册过滤条件,门户不能恢复被停用的账号 | | `srm_supplier` 的 `name`、`country`、`credit_code`、`registration_no`、`address`、`legal_representative`、`registered_capital`、`status`、`grade`、`qualification_alert`、`erp_vendor_code` | 内部、本公司门户 | 内部(关键信息变更走变更单) | 门户只读;门户可直接改的只有 `short_name`、`website`、`supplier_type`、`taxpayer_type`、`established_date` 等资料字段 | diff --git a/docs/design/05-experience-and-analytics.md b/docs/design/05-experience-and-analytics.md index 702ca85..8ac8a79 100644 --- a/docs/design/05-experience-and-analytics.md +++ b/docs/design/05-experience-and-analytics.md @@ -167,7 +167,8 @@ | 银行账户变更待复核、变更已生效 | 应付会计 / 采购员 | 站内 | v0.2 | | 合同待审批、合同即将到期 | 审批人、合同所有者、品类经理 | 站内 + 邮件 | v0.3 | | 新整改单、整改退回、整改重开、整改逾期 | 负责 SQE(新整改单没有 SQE 时发质量经理);逾期加质量经理(SQE 主管);v0.6 起加供应商 `quality` 联系人 | 邮件 + 站内 / 门户待办 | v0.4 | -| 待你评分、评分截止前 3 天仍未评、退回重评、绩效结果发布;绩效申诉待处理、申诉处理结果 | 评分人;采购总监;申诉代录人(v0.6 起加供应商 `admin` 联系人) | 站内 + 邮件 | v0.5 | +| 供应商在门户提交临时措施;供应商接受索赔 / 对索赔有异议 | 负责 SQE(提交临时措施时没有 SQE 发质量经理;SQE 本人代填的不发);接受索赔加应付会计 | 站内 | v0.6 | +| 待你评分、评分截止前 3 天仍未评、退回重评、绩效结果发布;绩效申诉待处理、申诉处理结果 | 评分人;采购总监;申诉提交人(门户管理员或内部代录人;v0.6 U6 起加供应商 `admin` 联系人) | 站内 + 邮件 | v0.5 | | 门户邀请、准入申请已受理 / 已驳回 / 已通过 | 受邀联系人、申请联系人 | 邮件 | v0.6 | | 新的询价邀请、报价截止前 24 小时仍未提交、中标 / 未中标、待你审批的定标 | 受邀供应商 `sales` 联系人;审批人 | 邮件 + 门户待办 | v0.7 | | 新订单待确认、订单变更、交期风险、订单 24 / 48 小时未确认、付款完成 | 供应商 / 采购员 / 采购主管 | 邮件 / 站内 | ERP 集成后 | diff --git a/docs/design/07-delivery-plan.md b/docs/design/07-delivery-plan.md index cc971c4..a55f561 100644 --- a/docs/design/07-delivery-plan.md +++ b/docs/design/07-delivery-plan.md @@ -135,6 +135,7 @@ test/ # 仓库根目录 ├── approval-matrix.test.ts # 03 §12 每一行(v0.1 起) ├── portal-platform.test.ts # 门户平台实测 V1–V5(§6.2,v0.6 U1) ├── portal-isolation.test.ts # 04 §3.7 的交叉越权用例(v0.6) +├── portal-response.test.ts # 门户整改回复、索赔确认、绩效查看与申诉(v0.6 U5) ├── sealed-bid.test.ts # 封标与截止锁价(v0.7) └── flows/*.test.ts # 每条 flow 的正向与异常路径 ``` diff --git a/governance/templates/core/GL-06.md b/governance/templates/core/GL-06.md index 1c2c789..77e5508 100644 --- a/governance/templates/core/GL-06.md +++ b/governance/templates/core/GL-06.md @@ -143,7 +143,7 @@ title: 供应商绩效评价管理办法 ## 6.7 申诉 -6.7.1 供应商对考评结果有异议的,可以在结果发布之日起 {{param.perf_appeal_days}} 日内书面申诉一次,写明理由并提供证明材料。供应商门户上线前,由采购员、{{org.position.buyer_lead}}或{{org.position.category_manager}}在 SRM 系统中代为登记,申诉书原件作为附件上传;门户上线后,供应商在门户中提交。 +6.7.1 供应商对考评结果有异议的,可以在结果发布之日起 {{param.perf_appeal_days}} 日内书面申诉一次,写明理由并提供证明材料。供应商门户上线前,由采购员、{{org.position.buyer_lead}}或{{org.position.category_manager}}在 SRM 系统中代为登记,申诉书原件作为附件上传;门户上线后,由供应商的门户管理员在门户中提交。 6.7.2 申诉由{{org.position.procurement_director}}处理,处理人不得是本考评单的评分人。处理人会同相关评分人和部门复核,结论为维持原结果或调整总分;调整总分的,SRM 系统按调整后的总分重新确定等级,并更新供应商档案。处理结果写入考评单,由{{org.dept.procurement}}告知供应商。 @@ -182,7 +182,7 @@ title: 供应商绩效评价管理办法 | 连续 D 级判定 | 6.6.7 | 按同一评价周期下相邻期间的已发布考评单判定,缺期或作废即中断 | | 不重复生成草稿 | 6.6.8 | 供应商已有审批中的处置单或同一动作的草稿、或状态不适用时,不生成草稿,只通知{{org.position.category_manager}} | | 处置草稿须核实提交 | 6.6.6、6.6.7 | 草稿不自动审批;提交后按{{doc.GL-08}}审批,作废须写明理由 | -| 申诉一次、限期 | 6.7.1 | 只有已发布的考评单能申诉,每张一次,超过发布后 {{param.perf_appeal_days}} 日的不能登记 | +| 申诉一次、限期 | 6.7.1 | 只有已发布的考评单能申诉,每张一次,超过发布后 {{param.perf_appeal_days}} 日的不能登记;门户中只有本公司的门户管理员能对本公司的考评单提交申诉 | | 申诉处理人不是评分人 | 6.7.2 | 只有{{org.position.procurement_director}}能处理,评分人不能处理本考评单的申诉;调整总分时重新定级并更新供应商 | # 8 记录 diff --git a/governance/templates/core/GL-07.md b/governance/templates/core/GL-07.md index 6272dc7..eae1d19 100644 --- a/governance/templates/core/GL-07.md +++ b/governance/templates/core/GL-07.md @@ -155,6 +155,7 @@ c) 逾期超过最终回复期限一倍仍未完成,或措施经反复退回 | 严重、主要不合格自动开单,次要只计绩效 | 6.1.1、附录 A | 交付记录变为严重或主要不合格时自动开立整改单并指派品类{{org.position.sqe}},同一交付记录不重复开单;次要不合格不开单 | | 期限系统计算、不可修改 | 6.2.1 | 开立时按 {{param.scar_containment_hours}} 小时和 {{param.scar_final_days}} 日计算期限,期限字段只读 | | 回复内容完整 | 6.3.1 | 提交临时措施后 D3 必填;提交根因与措施后 D4 与 D5–D6 必填 | +| 供应商只回复轮到自己的整改单 | 6.3、6.8.2 | 供应商门户上线后,供应商只能修改本公司处于「待供应商响应」「已提交临时措施」的整改单的回复内容和证据,状态只能推进到「已提交临时措施」「已提交根因与措施」,不能自行进入效果验证、关闭、撤销或退回;问题描述、审核意见、期限和索赔只读,索赔只能在门户中确认接受或提出异议 | | 措施审核与锁定 | 6.4.3 | 提交根因与措施后由负责{{org.position.sqe}}(没有时为{{org.position.quality_manager}})审核,审核期间整改单锁定 | | 退回必须写意见 | 6.4.2 | 退回到上一步时审核意见必填;审核驳回未写意见的不生效,提醒{{org.position.sqe}}重新审核 | | 逾期标记与通知 | 6.6.1 | 定时检查超期整改单,标记逾期并通知{{org.position.sqe}}和{{org.position.quality_manager}},标记保留至关闭 | diff --git a/src/core/actions/index.ts b/src/core/actions/index.ts index 486e009..f8dfc7c 100644 --- a/src/core/actions/index.ts +++ b/src/core/actions/index.ts @@ -4,3 +4,4 @@ export { ContractSubmitAction, ContractActivateAction } from './contract.actions export { AuditFindingToScarAction } from './audit.actions.js'; export { DispositionSubmitAction, DispositionCancelAction } from './disposition.actions.js'; export { ScoreSubmitAction, PerformanceVoidAction, AppealSubmitAction, AppealResolveAction, ScoreReassignAction } from './performance.actions.js'; +export { ClaimRespondAction } from './quality-issue.actions.js'; diff --git a/src/core/actions/quality-issue.actions.ts b/src/core/actions/quality-issue.actions.ts new file mode 100644 index 0000000..032c560 --- /dev/null +++ b/src/core/actions/quality-issue.actions.ts @@ -0,0 +1,15 @@ +import { P } from '@objectstack/spec'; +import { defineAction } from '@objectstack/spec/ui'; + +// 整改单上的「确认索赔」(03 §8 第 6 步):供应商在门户接受索赔或提出异议。谁能操作由 flow 自己检查(本公司门户用户)。 +export const ClaimRespondAction = defineAction({ + name: 'srm_claim_respond', + label: '确认索赔', + objectName: 'srm_quality_issue', + icon: 'receipt', + type: 'flow', + target: 'srm_claim_respond', + locations: ['record_header'], + visible: P`record.claim_status == 'proposed'`, + refreshAfter: true, +}); diff --git a/src/core/flows/index.ts b/src/core/flows/index.ts index 7f27f65..5f4a785 100644 --- a/src/core/flows/index.ts +++ b/src/core/flows/index.ts @@ -21,6 +21,8 @@ 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 { ScarContainmentNoticeFlow } from './quality/scar-containment-notice.flow.js'; +export { ClaimRespondFlow } from './quality/claim-respond.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/flows/performance/appeal-resolve.flow.ts b/src/core/flows/performance/appeal-resolve.flow.ts index 6648d65..cfecda3 100644 --- a/src/core/flows/performance/appeal-resolve.flow.ts +++ b/src/core/flows/performance/appeal-resolve.flow.ts @@ -4,7 +4,7 @@ import { gradeOf } from './params.js'; // 处理绩效申诉(03 §10 第 7 步,FR-706):采购总监在申诉中的考评单上写处理结果,选择维持原结果或调整总分;调整时 // 等级按新总分取 perf_grade_min 重算(params.ts 的 GRADE_MIN),各评分明细与维度得分不变,处理结果里写明调整依据。 -// 处理后回到已发布,处理人、结论和结果留在考评单上;是该供应商最近一期时同步回写供应商等级、总分与优选标志;通知申诉代录人。 +// 处理后回到已发布,处理人、结论和结果留在考评单上;是该供应商最近一期时同步回写供应商等级、总分与优选标志;通知申诉提交人(门户管理员或内部代录人)。 // 结果应用(srm_performance_apply)只在审定发布时触发,这里不重新应用:等级变了时通知供应商各品类的品类经理,核对按原等级 // 开的整改单和生成的处置单草稿(来源考评单为本单),不需要的由品类经理作废,新等级需要处置的另行发起(03 §10 第 7 步)。 // 处理人不能是本考评单的评分人(同审定发布的职责分离)。以系统身份运行,进门检查在 flow 里做。 @@ -142,7 +142,7 @@ export const AppealResolveFlow: Automation.Flow = { { id: 'notify', type: 'notify', - label: '通知申诉代录人', + label: '通知申诉提交人', config: { recipients: cel`has(review.appeal_recorded_by) && review.appeal_recorded_by != null ? review.appeal_recorded_by : null`, title: '考评单 {{ review.review_no }} 的申诉已处理', diff --git a/src/core/flows/performance/appeal-submit.flow.ts b/src/core/flows/performance/appeal-submit.flow.ts index afa8065..b8c4741 100644 --- a/src/core/flows/performance/appeal-submit.flow.ts +++ b/src/core/flows/performance/appeal-submit.flow.ts @@ -3,13 +3,17 @@ import type * as Automation from '@objectstack/spec/automation'; import { APPEAL_DAYS } from './params.js'; // 绩效申诉(03 §10 第 7 步,FR-706):已发布的考评单在发布后 perf_appeal_days(params.ts 的 APPEAL_DAYS,示例 7 天)内 -// 可以申诉一次,写申诉理由 → 申诉中,通知采购总监处理(srm_appeal_resolve)。v0.6 门户上线前供应商的申诉由采购员、 -// 采购主管或品类经理代录,代录人记在考评单上;门户上线后供应商门户管理员经同一个 flow 提交。 +// 可以申诉一次,写申诉理由 → 申诉中,通知采购总监处理(srm_appeal_resolve)。供应商门户管理员在门户自己提交(v0.6 U5); +// 没开通门户的供应商仍由采购员、采购主管或品类经理代录。提交人(门户管理员或代录人)记在考评单上。 // 以系统身份运行,因为考评单对所有人只读;进门检查在 flow 里做(同 srm_disposition_submit,objectstack#25481)。 +// 门户提交时进门检查代替 RLS 做隔离:操作人必须持门户管理员岗位,并且是这张考评单所属供应商的在职联系人(与成员键 +// portal_supplier_ids 的名册条件相同,02 §4.1、04 §3.1)。 const c = (source: string) => ({ dialect: 'cel' as const, source }); -const allowed = - `current_user != null && ('srm_buyer' in current_user.positions || 'srm_buyer_lead' in current_user.positions` +const internal = + `('srm_buyer' in current_user.positions || 'srm_buyer_lead' in current_user.positions` + ` || 'srm_category_manager' in current_user.positions)`; +const portalAdmin = `('srm_portal_admin' in current_user.positions && membership != null)`; +const allowed = `current_user != null && (${internal} || ${portalAdmin})`; const published = `review != null && review.status == 'published'`; const once = `!(has(review.appeal_date) && review.appeal_date != null)`; const inWindow = `has(review.published_date) && review.published_date != null && isoDate(today()) <= isoDate(addDays(date(review.published_date), ${APPEAL_DAYS}))`; @@ -32,12 +36,27 @@ export const AppealSubmitFlow: Automation.Flow = { label: '读取考评单', config: { objectName: 'srm_performance_review', filter: { id: cel`recordId` }, outputVariable: 'review' }, }, + { + id: 'load_membership', + type: 'get_record', + label: '操作人是本公司在职联系人?', + config: { + objectName: 'srm_supplier_contact', + filter: { + supplier: cel`review != null ? review.supplier : ''`, + portal_user: cel`current_user != null ? current_user.id : ''`, + active: true, + }, + fields: ['id'], + outputVariable: 'membership', + }, + }, { id: 'gate', type: 'decision', label: '可以申诉?' }, { id: 'refuse_position', type: 'end', - label: '没有代录权限', - config: { outcome: 'refused', message: '供应商的绩效申诉由采购员、采购主管或品类经理代录' }, + label: '没有申诉权限', + config: { outcome: 'refused', message: '绩效申诉由本公司门户管理员提交,没开通门户的由采购员、采购主管或品类经理代录' }, }, { id: 'refuse_status', @@ -89,16 +108,29 @@ export const AppealSubmitFlow: Automation.Flow = { config: { objectName: 'sys_user_position', filter: { position: 'srm_procurement_director' }, limit: 50, outputVariable: 'directors' }, }, { - id: 'notify', - type: 'notify', + id: 'notify_guard', + type: 'try_catch', label: '通知采购总监', config: { - recipients: [cel`directors.map(p, p.user_id)`], - title: '考评单 {{ review.review_no }} 有申诉待处理', - message: '供应商对期间 {{ review.period }} 的考评结果提出申诉,理由见考评单。请在考评单上点「处理申诉」。', - severity: 'warning', - sourceObject: 'srm_performance_review', - sourceId: cel`review.id`, + try: { + nodes: [ + { + id: 'notify', + type: 'notify', + label: '通知采购总监', + config: { + recipients: [cel`directors.map(p, p.user_id)`], + title: '考评单 {{ review.review_no }} 有申诉待处理', + message: '供应商对期间 {{ review.period }} 的考评结果提出申诉,理由见考评单。请在考评单上点「处理申诉」。', + severity: 'warning', + sourceObject: 'srm_performance_review', + sourceId: cel`review.id`, + }, + }, + ], + }, + // 没有采购总监时不影响申诉登记(申诉已写入考评单,「申诉中」视图可见) + catch: { nodes: [{ id: 'skip_notify', type: 'assignment', label: '跳过通知' }] }, }, }, { id: 'end', type: 'end', label: '结束' }, @@ -106,7 +138,8 @@ export const AppealSubmitFlow: Automation.Flow = { edges: [ { id: 'e0', source: 'start', target: 'load', type: 'default' }, - { id: 'e1', source: 'load', target: 'gate', type: 'default' }, + { id: 'e1', source: 'load', target: 'load_membership', type: 'default' }, + { id: 'e1m', source: 'load_membership', target: 'gate', type: 'default' }, { id: 'e2', source: 'gate', target: 'refuse_position', type: 'conditional', condition: c(`!(${allowed})`), label: '没有权限' }, { id: 'e3', source: 'gate', target: 'refuse_status', type: 'conditional', condition: c(`${allowed} && !(${published})`), label: '不是已发布' }, { id: 'e4', source: 'gate', target: 'refuse_once', type: 'conditional', condition: c(`${allowed} && ${published} && !(${once})`), label: '已申诉过' }, @@ -121,7 +154,7 @@ export const AppealSubmitFlow: Automation.Flow = { { id: 'e6', source: 'gate', target: 'confirm', type: 'conditional', condition: c(`${allowed} && ${published} && ${once} && ${inWindow}`), label: '可以' }, { id: 'e7', source: 'confirm', target: 'appeal', type: 'default' }, { id: 'e8', source: 'appeal', target: 'load_directors', type: 'default' }, - { id: 'e9', source: 'load_directors', target: 'notify', type: 'default' }, - { id: 'e10', source: 'notify', target: 'end', type: 'default' }, + { id: 'e9', source: 'load_directors', target: 'notify_guard', type: 'default' }, + { id: 'e10', source: 'notify_guard', target: 'end', type: 'default' }, ], }; diff --git a/src/core/flows/quality/claim-respond.flow.ts b/src/core/flows/quality/claim-respond.flow.ts new file mode 100644 index 0000000..23b1a28 --- /dev/null +++ b/src/core/flows/quality/claim-respond.flow.ts @@ -0,0 +1,154 @@ +import { cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// 供应商确认索赔(03 §8 第 6 步,FR-606,v0.6 U5):SQE 提出索赔(claim_status = proposed)后,供应商在门户的整改单上点 +// 「确认索赔」,选择接受或提出异议并写意见(异议必填)。接受 → supplier_accepted,通知负责 SQE 与应付会计(财务据此在 ERP +// 扣款,回来标记「已扣款」);异议 → disputed,通知负责 SQE 协商,SQE 调整后可重新提出。没开通门户的供应商仍由 SQE 线下 +// 确认后直接改索赔状态。 +// 以系统身份运行:索赔状态对门户字段级只读,门户只能经这个 flow 改。进门检查代替 RLS 做隔离:操作人必须持门户岗位, +// 并且是这张整改单所属供应商的在职联系人(与成员键 portal_supplier_ids 的名册条件相同,02 §4.1、04 §3.1)。 +const c = (source: string) => ({ dialect: 'cel' as const, source }); +const member = `current_user != null && 'srm_portal_user' in current_user.positions && issue != null && membership != null`; +const proposed = `has(issue.claim_status) && issue.claim_status == 'proposed'`; + +const notify = (id: string, label: string, recipients: string, title: string, message: string): Automation.FlowNode => ({ + id, + type: 'try_catch', + label, + config: { + try: { + nodes: [ + { + id: `${id}_send`, + type: 'notify', + label, + config: { recipients: c(recipients), title, message, sourceObject: 'srm_quality_issue', sourceId: cel`issue.id` }, + }, + ], + }, + // 没有可通知的人(整改单没有 SQE、没有应付会计)时不影响确认 + catch: { nodes: [{ id: `${id}_skip`, type: 'assignment', label: '跳过通知' }] }, + }, +}); + +export const ClaimRespondFlow: Automation.Flow = { + name: 'srm_claim_respond', + 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_quality_issue', filter: { id: cel`recordId` }, outputVariable: 'issue' }, + }, + { + id: 'load_membership', + type: 'get_record', + label: '操作人是本公司在职联系人?', + config: { + objectName: 'srm_supplier_contact', + filter: { + supplier: cel`issue != null ? issue.supplier : ''`, + portal_user: cel`current_user != null ? current_user.id : ''`, + active: true, + }, + fields: ['id'], + outputVariable: 'membership', + }, + }, + { id: 'gate', type: 'decision', label: '可以确认?' }, + { + id: 'refuse_member', + type: 'end', + label: '不是本公司门户用户', + config: { outcome: 'refused', message: '索赔由供应商在门户确认;线下确认的由 SQE 直接在整改单上记录索赔状态' }, + }, + { + id: 'refuse_status', + type: 'end', + label: '不是已提出', + config: { outcome: 'refused', message: '只有已提出、还没确认的索赔可以接受或提出异议' }, + }, + { + id: 'confirm', + type: 'screen', + label: '确认索赔', + config: { + title: '确认索赔:{{ issue.issue_no }}', + description: '索赔金额 {{ issue.claim_amount }}。接受后由采购方财务在结算时扣款;有异议请写明理由,SQE 会与你联系。', + fields: [ + { + name: 'decision', + label: '确认结果', + type: 'select', + required: true, + options: [ + { label: '接受索赔', value: 'accept' }, + { label: '提出异议', value: 'dispute' }, + ], + }, + { name: 'response_text', label: '意见', type: 'textarea', required: true, visibleWhen: "decision == 'dispute'" }, + ], + }, + }, + { + id: 'respond', + type: 'update_record', + label: '写入确认结果', + config: { + objectName: 'srm_quality_issue', + filter: { id: cel`issue.id` }, + fields: { + claim_status: c(`decision == 'accept' ? 'supplier_accepted' : 'disputed'`), + claim_response: c(`has(vars.response_text) && vars.response_text != null ? vars.response_text : ''`), + }, + }, + }, + { id: 'accepted', type: 'decision', label: '接受了?' }, + { + id: 'load_accountants', + type: 'get_record', + label: '读取应付会计', + config: { objectName: 'sys_user_position', filter: { position: 'srm_ap_accountant' }, limit: 50, outputVariable: 'accountants' }, + }, + notify( + 'notify_accepted', + '通知 SQE 与应付会计:供应商已接受索赔', + `(has(issue.sqe) && issue.sqe != null ? [issue.sqe] : []) + accountants.map(p, p.user_id)`, + '整改单 {{ issue.issue_no }} 的索赔供应商已接受', + '供应商已接受索赔(金额 {{ issue.claim_amount }})。请财务在 ERP 结算时扣款,扣款后由应付会计在整改单上标记「已扣款」。', + ), + notify( + 'notify_disputed', + '通知 SQE:供应商对索赔有异议', + `has(issue.sqe) && issue.sqe != null ? issue.sqe : null`, + '整改单 {{ issue.issue_no }} 的索赔供应商有异议', + '供应商对索赔(金额 {{ issue.claim_amount }})提出异议,意见见整改单。请与供应商协商,调整后可重新提出索赔。', + ), + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e0', source: 'start', target: 'load', type: 'default' }, + { id: 'e1', source: 'load', target: 'load_membership', type: 'default' }, + { id: 'e2', source: 'load_membership', target: 'gate', type: 'default' }, + { id: 'e3', source: 'gate', target: 'refuse_member', type: 'conditional', condition: c(`!(${member})`), label: '不是本公司' }, + { id: 'e4', source: 'gate', target: 'refuse_status', type: 'conditional', condition: c(`${member} && !(${proposed})`), label: '不是已提出' }, + { id: 'e5', source: 'gate', target: 'confirm', type: 'conditional', condition: c(`${member} && ${proposed}`), label: '可以' }, + { id: 'e6', source: 'confirm', target: 'respond', type: 'default' }, + { id: 'e7', source: 'respond', target: 'accepted', type: 'default' }, + { id: 'e8', source: 'accepted', target: 'load_accountants', type: 'conditional', condition: c(`decision == 'accept'`), label: '接受' }, + { id: 'e9', source: 'accepted', target: 'notify_disputed', type: 'conditional', condition: c(`decision != 'accept'`), label: '异议' }, + { id: 'e10', source: 'load_accountants', target: 'notify_accepted', type: 'default' }, + { id: 'e11', source: 'notify_accepted', target: 'end', type: 'default' }, + { id: 'e12', source: 'notify_disputed', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/quality/scar-containment-notice.flow.ts b/src/core/flows/quality/scar-containment-notice.flow.ts new file mode 100644 index 0000000..b27de8a --- /dev/null +++ b/src/core/flows/quality/scar-containment-notice.flow.ts @@ -0,0 +1,64 @@ +import { P, cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// 供应商在门户提交临时措施(D3)时通知负责 SQE 查看(03 §8 第 2 步,v0.6 U5);整改单没有 SQE 时通知质量经理指派。 +// 提交人(这次更新的 updated_by)就是负责 SQE 本人时不发:没开通门户的供应商由 SQE 代填。以系统身份运行的 flow 里 +// current_user 为空,所以按记录上的 updated_by 判断。D4–D7 的提交由 srm_scar_review 发起审核,审批本身通知审核人。 +export const ScarContainmentNoticeFlow: Automation.Flow = { + name: 'srm_scar_containment_notice', + label: '供应商已提交临时措施', + type: 'record_change', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: '门户提交临时措施', + config: { + objectName: 'srm_quality_issue', + triggerType: 'record-after-update', + condition: P`record.status == 'containment_submitted' && has(previous.status) && previous.status == 'open' && !(has(record.sqe) && record.sqe != null && has(record.updated_by) && record.updated_by == record.sqe)`, + }, + }, + { + id: 'load_quality_managers', + type: 'get_record', + label: '读取质量经理', + config: { objectName: 'sys_user_position', filter: { position: 'srm_quality_manager' }, limit: 50, outputVariable: 'qualityManagers' }, + }, + { + id: 'notify_guard', + type: 'try_catch', + label: '通知', + config: { + try: { + nodes: [ + { + id: 'notify_sqe', + type: 'notify', + label: '通知 SQE(没有 SQE 时通知质量经理)', + config: { + recipients: cel`has(record.sqe) && record.sqe != null ? [record.sqe] : qualityManagers.map(p, p.user_id)`, + title: '整改单 {{ record.issue_no }}:供应商已提交临时措施', + message: '供应商已在门户提交临时措施(D3),请查看。措施不够时把状态改回「待供应商响应」并写明审核意见。', + sourceObject: 'srm_quality_issue', + sourceId: cel`record.id`, + }, + }, + ], + }, + // 没有可通知的人时不影响提交 + catch: { nodes: [{ id: 'skip_notify', type: 'assignment', label: '跳过通知' }] }, + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'load_quality_managers', type: 'default' }, + { id: 'e2', source: 'load_quality_managers', target: 'notify_guard', type: 'default' }, + { id: 'e3', source: 'notify_guard', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/objects/performance-review.object.ts b/src/core/objects/performance-review.object.ts index 9959621..87c15e1 100644 --- a/src/core/objects/performance-review.object.ts +++ b/src/core/objects/performance-review.object.ts @@ -4,7 +4,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; // 指标建评分明细并快照权重,从交付记录算出 OTD、批次合格率、PPM 与对应得分后转人工评分(scoring)。本期没有交付记录、 // 某项自动指标没有数据时标记「数据不全」,该项不给分(07 R7)。v0.5 P3:评分人经「提交评分」打分,截止后缺评取默认分 // (srm_score_overdue_sweep)→ 审定中:srm_performance_publish 汇总分级并交采购总监审定,通过即发布并回写供应商等级, -// 驳回即退回重评 → 发布后可代录申诉一次(srm_appeal_submit),采购总监处理(srm_appeal_resolve)后回到已发布。 +// 驳回即退回重评 → 发布后可申诉一次(srm_appeal_submit:门户管理员提交,或内部代录),采购总监处理(srm_appeal_resolve)后回到已发布。 // 全部字段由 flow 写入,对用户只读。 // 同一供应商同一期间只有一张考评单(唯一索引兜底,flow 先查后建,重复运行不重复建)。 // 门户只看本公司已发布(含申诉中)的考评单,按 supplier 读成员键隔离(04 §3.2,ADR-0010)。 @@ -18,6 +18,8 @@ export const PerformanceReview = ObjectSchema.create({ enable: { trackHistory: true }, stageField: 'status', nameField: 'review_no', + // 列表搜索只扫这几个门户也能读的字段:默认会扫全部文本字段,门户读不到的审定意见会让门户的搜索整体被拒(04 §4.2) + searchableFields: ['review_no', 'period', 'cycle', 'status', 'grade'], fieldGroups: [ { key: 'basic', label: '基本信息', icon: 'award' }, @@ -116,10 +118,10 @@ export const PerformanceReview = ObjectSchema.create({ review_comment: Field.textarea({ label: '审定意见', group: 'scores', readonly: true, maxLength: 2000 }), published_date: Field.date({ label: '发布日期', group: 'scores', readonly: true }), - // 申诉(03 §10 第 7 步,FR-706):发布后 perf_appeal_days 内一次;v0.6 前由采购代录 + // 申诉(03 §10 第 7 步,FR-706):发布后 perf_appeal_days 内一次;供应商门户管理员提交,没开通门户的由采购代录 appeal_reason: Field.textarea({ label: '申诉理由', group: 'appeal', readonly: true, maxLength: 2000 }), appeal_date: Field.date({ label: '申诉日期', group: 'appeal', readonly: true }), - appeal_recorded_by: Field.user({ label: '申诉代录人', group: 'appeal', readonly: true }), + appeal_recorded_by: Field.user({ label: '申诉提交人', group: 'appeal', readonly: true }), appeal_result: Field.textarea({ label: '申诉处理结果', group: 'appeal', readonly: true, maxLength: 2000 }), appeal_outcome: Field.select({ label: '申诉结论', diff --git a/src/core/objects/performance-score.object.ts b/src/core/objects/performance-score.object.ts index a5060a3..9e1820a 100644 --- a/src/core/objects/performance-score.object.ts +++ b/src/core/objects/performance-score.object.ts @@ -11,6 +11,8 @@ export const PerformanceScore = ObjectSchema.create({ pluralLabel: '评分明细', icon: 'list-checks', sharingModel: 'controlled_by_parent', + // 列表搜索只扫评分说明:回避原因门户读不到,默认扫全部文本字段会让门户的搜索整体被拒(04 §4.2) + searchableFields: ['comment'], fields: { review: Field.masterDetail('srm_performance_review', { diff --git a/src/core/objects/quality-issue.object.ts b/src/core/objects/quality-issue.object.ts index a66a518..1dd7489 100644 --- a/src/core/objects/quality-issue.object.ts +++ b/src/core/objects/quality-issue.object.ts @@ -2,7 +2,7 @@ import { P, cel } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; // 质量整改单 SCAR / 8D(02 §4.6、§5.6,03 §8,FR-602–606)。主要 / 严重不合格的交付记录由 srm_scar_create 自动开单; -// 客户投诉、制程等来源由 SQE 手工新建。v0.6 门户上线前供应商的回复(D3、D4–D7)由 SQE 代填:填好 D3 把状态改为 +// 客户投诉、制程等来源由 SQE 手工新建。供应商在门户回复(v0.6 U5;没开通门户的供应商仍由 SQE 代填):填好 D3 把状态改为 // 「已提交临时措施」,填好 D4–D7 改为「已提交根因与措施」,后者触发 srm_scar_review 由负责 SQE 审核; // 审核通过进入「效果验证」,SQE 跟踪后续批次确认有效后关闭(D8)。复发时重新打开(srm_scar_reopen 重算期限), // 误判撤销。逾期由 srm_scar_overdue_sweep 置标志并通知 SQE 与质量经理(FR-605)。 @@ -10,6 +10,11 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; const celOf = (source: string) => ({ dialect: 'cel' as const, source }); const blank = (k: string) => `(!has(record.${k}) || record.${k} == null || isBlank(record.${k}))`; const ended = P`record.status == 'closed' || record.status == 'cancelled'`; +// 门户用户只能做供应商的两步:待响应 → 已提交临时措施 → 已提交根因与措施,其余状态只能保持不变(选项门禁在服务端 +// 按操作人判断,04 §3.2)。哪些行能改、改完仍属本公司由门户权限集的 RLS 决定 +const portal = `(current_user != null && 'srm_portal_user' in current_user.positions)`; +const from = (...states: string[]) => + celOf(`!${portal} || (previous != null && has(previous.status) && previous.status in [${states.map((v) => `'${v}'`).join(', ')}])`); export const QualityIssue = ObjectSchema.create({ name: 'srm_quality_issue', @@ -97,12 +102,12 @@ export const QualityIssue = ObjectSchema.create({ group: 'review', defaultValue: 'open', options: [ - { label: '待供应商响应', value: 'open', kind: 'open' }, - { label: '已提交临时措施', value: 'containment_submitted', kind: 'open' }, - { label: '已提交根因与措施', value: 'action_submitted', kind: 'open' }, - { label: '效果验证', value: 'verifying', kind: 'open' }, - { label: '已关闭', value: 'closed', kind: 'won' }, - { label: '已撤销', value: 'cancelled', kind: 'lost' }, + { label: '待供应商响应', value: 'open', kind: 'open', visibleWhen: from('open') }, + { label: '已提交临时措施', value: 'containment_submitted', kind: 'open', visibleWhen: from('open', 'containment_submitted') }, + { label: '已提交根因与措施', value: 'action_submitted', kind: 'open', visibleWhen: from('containment_submitted', 'action_submitted') }, + { label: '效果验证', value: 'verifying', kind: 'open', visibleWhen: from('verifying') }, + { label: '已关闭', value: 'closed', kind: 'won', visibleWhen: from('closed') }, + { label: '已撤销', value: 'cancelled', kind: 'lost', visibleWhen: from('cancelled') }, ], }), sqe: Field.user({ label: '负责 SQE', group: 'review', readonlyWhen: ended }), @@ -113,7 +118,8 @@ export const QualityIssue = ObjectSchema.create({ sqe_comment: Field.textarea({ label: 'SQE 审核意见', group: 'review', maxLength: 2000 }), is_overdue: Field.boolean({ label: '逾期', group: 'review', defaultValue: false, readonly: true }), - // 索赔只记结论,供财务在 ERP 扣款时参考,SRM 不生成扣款(ADR-0008)。「已扣款」只由应付会计标记 + // 索赔只记结论,供财务在 ERP 扣款时参考,SRM 不生成扣款(ADR-0008)。供应商在门户经「确认索赔」(srm_claim_respond) + // 接受或提出异议,没开通门户的由 SQE 线下确认后记录。「已扣款」只由应付会计标记 claim_amount: Field.currency({ label: '索赔金额', group: 'claim', min: 0 }), claim_status: Field.select({ label: '索赔状态', @@ -131,6 +137,8 @@ export const QualityIssue = ObjectSchema.create({ }, ], }), + // 供应商确认索赔时写的意见,提出异议时必填(srm_claim_respond 写入;SQE 代录线下确认时可补记) + claim_response: Field.textarea({ label: '供应商索赔意见', group: 'claim', maxLength: 2000 }), }, validations: [ diff --git a/src/core/security/portal-permission-sets.ts b/src/core/security/portal-permission-sets.ts index b3f45bd..3541b52 100644 --- a/src/core/security/portal-permission-sets.ts +++ b/src/core/security/portal-permission-sets.ts @@ -1,5 +1,6 @@ import { Supplier } from '../objects/supplier.object.js'; import { SupplierBankAccount } from '../objects/supplier-bank-account.object.js'; +import { QualityIssue } from '../objects/quality-issue.object.js'; // 门户权限集(04 §3.2、§3.4,ADR-0010)。门户用户按组织角色 external 邀请,任命到静态岗位 srm_portal_user // (门户管理员再加 srm_portal_admin);能看哪家供应商只由成员键 current_user.portal_supplier_ids 决定, @@ -17,6 +18,10 @@ const OWN = 'supplier in current_user.portal_supplier_ids'; export const PortalContractStatuses = ['signing', 'active', 'expired', 'terminated']; // 门户能看到的考评单状态:发布后才可见;申诉只能对已发布的考评提起,申诉期间照常可见(04 §3.2) export const PortalReviewStatuses = ['published', 'appealed']; +// 整改单:供应商能改的是轮到自己回复的单(待响应、已提交临时措施——SQE 看 D3 期间可以接着填 D4–D7);改完只能 +// 停在供应商的两步上。状态怎么走由整改单 status 选项的门禁按操作人限定(只能 待响应 → 已提交临时措施 → 已提交根因与措施) +export const PortalScarEditableStatuses = ['open', 'containment_submitted']; +const PortalScarResultStatuses = ['open', 'containment_submitted', 'action_submitted']; const list = (values: string[]) => `[${values.map((v) => `'${v}'`).join(', ')}]`; const none = { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false, viewAllRecords: false, modifyAllRecords: false }; @@ -42,6 +47,28 @@ const supplierFields = Object.fromEntries( .map((field) => [`srm_supplier.${field}`, supplierHidden.has(field) ? hidden : readOnly]), ); +// 整改单:门户只能填供应商回复(D3–D7、证据)并推进状态;问题描述、SQE 意见、期限、索赔等一律只读(04 §3.2、§4.2)。 +// 索赔的接受 / 异议只经「确认索赔」screen flow(srm_claim_respond) +const scarEditable = new Set(['containment_action', 'root_cause', 'corrective_action', 'preventive_action', 'evidence', 'status']); +const scarFields = Object.fromEntries( + Object.keys(QualityIssue.fields) + .filter((field) => !scarEditable.has(field)) + .map((field) => [`srm_quality_issue.${field}`, readOnly]), +); + +// 绩效:门户看总分、维度得分、等级、评分明细的原始值、得分与说明(扣分项,FR-704);评分人、回避改派、审定人与审定意见 +// 是内部评价过程,门户不可见(GL-05 回避要求,04 §4.2) +const performanceHidden = [ + 'srm_performance_review.reviewer', + 'srm_performance_review.review_comment', + 'srm_performance_review.appeal_handled_by', + 'srm_performance_score.scorer', + 'srm_performance_score.reassigned_from', + 'srm_performance_score.reassigned_by', + 'srm_performance_score.reassign_reason', + 'srm_performance_score.reassigned_date', +]; + // 证书:门户上传新证书;状态、强制标志、核验结论只由 flow 与「核验证书」写入(防止自解资质异常,04 §3.7) const certificateFlowOwned = ['status', 'is_mandatory', 'verified', 'legacy_imported']; const certificateFields = Object.fromEntries(certificateFlowOwned.map((field) => [`srm_supplier_certificate.${field}`, readOnly])); @@ -61,7 +88,8 @@ export const PortalSupplierPermissionSet = { srm_contract: none, srm_contract_line: none, srm_delivery_record: none, - srm_quality_issue: none, + // 授予编辑权是为了供应商回复;能改哪些行由 srm_portal_quality_issue_write 限定,其余字段 FLS 只读 + srm_quality_issue: { ...none, allowEdit: true }, srm_performance_review: none, srm_performance_score: none, }, @@ -71,8 +99,8 @@ export const PortalSupplierPermissionSet = { // 联系人:在职标志也是成员键的过滤条件,门户不能自己恢复一个被停用的账号 'srm_supplier_contact.active': readOnly, 'srm_supplier_contact.mobile': readOnly, - 'srm_quality_issue.sqe_comment': readOnly, - 'srm_quality_issue.claim_amount': readOnly, + ...scarFields, + ...Object.fromEntries(performanceHidden.map((field) => [field, hidden])), }, rowLevelSecurity: [ { name: 'srm_portal_supplier_read', object: 'srm_supplier', operation: 'select', using: 'id in current_user.portal_supplier_ids' }, @@ -93,6 +121,13 @@ export const PortalSupplierPermissionSet = { }, { name: 'srm_portal_delivery_read', object: 'srm_delivery_record', operation: 'select', using: OWN }, { name: 'srm_portal_quality_issue_read', object: 'srm_quality_issue', operation: 'select', using: OWN }, + { + name: 'srm_portal_quality_issue_write', + object: 'srm_quality_issue', + operation: 'update', + using: `${OWN} && status in ${list(PortalScarEditableStatuses)}`, + check: `${OWN} && status in ${list(PortalScarResultStatuses)}`, + }, { name: 'srm_portal_review_read', object: 'srm_performance_review', diff --git a/src/core/translations/en.ts b/src/core/translations/en.ts index e057d43..f08dc30 100644 --- a/src/core/translations/en.ts +++ b/src/core/translations/en.ts @@ -651,7 +651,9 @@ export const en: TranslationData = { label: 'Claim Status', options: { none: 'No Claim', proposed: 'Proposed', supplier_accepted: 'Accepted by Supplier', disputed: 'Disputed', settled: 'Settled' }, }, + claim_response: { label: 'Supplier Claim Response' }, }, + _actions: { srm_claim_respond: { label: 'Respond to Claim' } }, _views: { default: { label: 'All Corrective Action Requests' }, board: { label: 'Corrective Action Board' }, @@ -799,7 +801,7 @@ export const en: TranslationData = { published_date: { label: 'Published On' }, appeal_reason: { label: 'Appeal Reason' }, appeal_date: { label: 'Appeal Date' }, - appeal_recorded_by: { label: 'Appeal Recorded By' }, + appeal_recorded_by: { label: 'Appeal Submitted By' }, appeal_result: { label: 'Appeal Outcome' }, appeal_outcome: { label: 'Appeal Decision', options: { maintained: 'Result Upheld', adjusted: 'Total Score Adjusted' } }, appeal_handled_by: { label: 'Appeal Handled By' }, @@ -954,6 +956,25 @@ export const en: TranslationData = { director_review: { label: 'Procurement Director Sign-off' }, }, }, + srm_scar_containment_notice: { label: 'Supplier Submitted Containment' }, + srm_claim_respond: { + label: 'Respond to Claim', + successMessage: 'Claim response submitted', + screens: { + confirm: { + title: 'Respond to claim: {{ issue.issue_no }}', + description: 'Claim amount {{ issue.claim_amount }}. Once accepted, the buyer\'s finance team deducts it at settlement; if you disagree, give your reasons and the SQE will contact you.', + fields: { + decision: { label: 'Response', options: { accept: 'Accept the Claim', dispute: 'Dispute the Claim' } }, + response_text: { label: 'Comment' }, + }, + }, + }, + refusals: { + refuse_member: { message: 'Claims are confirmed by the supplier on the portal; an offline confirmation is recorded by the SQE on the SCAR' }, + refuse_status: { message: 'Only a proposed claim that has not been answered can be accepted or disputed' }, + }, + }, srm_scar_review: { label: 'Corrective Action Review', approvals: { @@ -1118,7 +1139,7 @@ export const en: TranslationData = { }, }, refusals: { - refuse_position: { message: 'Supplier appeals are recorded by buyers, buyer leads or category managers' }, + refuse_position: { message: 'Appeals are submitted by the supplier\'s portal administrator, or recorded by buyers, buyer leads or category managers for suppliers without portal access' }, refuse_status: { message: 'Only published reviews can be appealed' }, refuse_once: { message: 'A review can be appealed only once, and this one has been appealed' }, refuse_window: { message: `The appeal period has passed: a result can be appealed within ${APPEAL_DAYS} days of publication` }, diff --git a/test/performance-publish.test.ts b/test/performance-publish.test.ts index e46d1cb..c5a2d7c 100644 --- a/test/performance-publish.test.ts +++ b/test/performance-publish.test.ts @@ -356,7 +356,7 @@ describe('performance scoring, grading, publishing and appeals', () => { expect(await row(Supplier.name, v.appeal!)).toMatchObject({ grade: 'grade_c', latest_score: 72.5 }); // 评分人之外的岗位不能代录 - expect(await refusal(sqe, 'srm_appeal_submit', id)).toContain('Supplier appeals are recorded by'); + expect(await refusal(sqe, 'srm_appeal_submit', id)).toContain('portal administrator, or recorded by buyers'); const { titles } = await runScreen(lead, 'srm_appeal_submit', id, () => ({ appeal_text: '示例申诉:本期两批让步接收已由客户确认合格' })); expect(titles).toHaveLength(1); expect(await review(id)).toMatchObject({ diff --git a/test/portal-isolation.test.ts b/test/portal-isolation.test.ts index 3368968..96e27f4 100644 --- a/test/portal-isolation.test.ts +++ b/test/portal-isolation.test.ts @@ -119,6 +119,31 @@ describe('portal isolation metadata (ADR-0010)', () => { } }); + // 门户能改的整改单字段只有供应商回复与状态(v0.6 U5);索赔、SQE 意见、期限等一律只读 + it('lets the portal edit only the SCAR response fields', () => { + const supplierSet = SrmPortalPermissionSets.find((s) => s.name === 'srm_portal_supplier') as unknown as { fields: Record }; + const issue = objectNamed('srm_quality_issue'); + const editable = Object.keys(issue.fields).filter((field) => supplierSet.fields[`srm_quality_issue.${field}`]?.editable !== false); + expect(editable.sort()).toEqual(['containment_action', 'corrective_action', 'evidence', 'preventive_action', 'root_cause', 'status']); + }); + + // 以系统身份运行、门户能发起的 screen flow 读不到成员键,只能自己查名册:条件必须与成员键的名册条件一致 + it('makes every portal-facing system flow check the contact roster like the membership key', () => { + const key = SupplierContact.membershipKeys!.portal_supplier_ids as { userField: string; valueField: string; filter: string }; + expect(key.filter).toBe('active == true'); + for (const name of ['srm_claim_respond', 'srm_appeal_submit']) { + const flow = (config.flows ?? []).find((f) => f.name === name)!; + expect(flow.runAs, name).toBe('system'); + const roster = flow.nodes.find((n) => n.id === 'load_membership') as { config: { objectName: string; filter: Row } } | undefined; + expect(roster?.config.objectName, name).toBe('srm_supplier_contact'); + expect(Object.keys(roster!.config.filter).sort(), name).toEqual(['active', key.userField, key.valueField].sort()); + expect(roster!.config.filter.active, name).toBe(true); + // 每条走到界面或写入的路径都经过 membership 判断 + const gate = flow.edges.filter((e) => e.source === 'gate'); + for (const edge of gate) expect(JSON.stringify(edge.condition), `${name} ${edge.id}`).toContain('membership'); + } + }); + it('keeps portal and internal positions apart', () => { const portalSetNames = new Set(portalSets.map((s) => s.name)); for (const position of SrmPositions) { @@ -355,6 +380,90 @@ describe('portal isolation (04 §3.7)', () => { expect(rows[0]!.website ?? null).toBeNull(); }); + // 整改单回复(v0.6 U5):只能改本公司、轮到供应商回复的单,只能改回复字段,状态只能走供应商的两步 + it('limits SCAR replies to the own supplier, the supplier\'s turn and the response fields', async () => { + // 种子写入直接给定状态(不触发审核流程);本公司新建的单记入 A 的可见行 + const issue = async (supplier: string, doc: Row = {}) => { + const id = String((await seed('srm_quality_issue', { supplier, severity: 'major', problem_description: '示例门户回复隔离', ...doc })).id); + (supplier === A.supplier ? A : B).visible.get('srm_quality_issue')!.push(id); + return id; + }; + const write = (id: string, doc: Row) => stack.hooks.run('srm_quality_issue', 'update', { id, ...doc }, { as: a.token }); + const stored = async (id: string) => (await stack.rows('srm_quality_issue', { id }))[0]!; + // 别家的单:改不了,也提交不了 + const foreign = await issue(B.supplier); + await expect(write(foreign, { containment_action: '越权回复' })).rejects.toBeTruthy(); + await expect(write(foreign, { status: 'containment_submitted' })).rejects.toBeTruthy(); + expect(await stored(foreign)).toMatchObject({ status: 'open' }); + expect((await stored(foreign)).containment_action ?? null).toBeNull(); + + const own = await issue(A.supplier); + // 不是回复字段:拒绝或被丢弃,值不变 + for (const doc of [{ sqe_comment: '自填意见' }, { claim_amount: 1 }, { claim_status: 'supplier_accepted' }, { problem_description: '改问题' }, { is_overdue: true }, { supplier: B.supplier }]) { + await write(own, doc).catch(() => undefined); + const after = await stored(own); + for (const [field, value] of Object.entries(doc)) expect(after[field], field).not.toEqual(value); + } + // 状态不能跳过审核、不能撤销或关闭 + for (const status of ['action_submitted', 'verifying', 'closed', 'cancelled']) { + await expect(write(own, { containment_action: '示例临时措施', status }), status).rejects.toBeTruthy(); + } + await write(own, { containment_action: '示例临时措施', status: 'containment_submitted' }); + // 提交后不能自己退回 + await expect(write(own, { status: 'open' })).rejects.toBeTruthy(); + expect((await stored(own)).status).toBe('containment_submitted'); + // 轮到 SQE(效果验证、已关闭)时整张单都改不了 + for (const status of ['action_submitted', 'verifying', 'closed', 'cancelled']) { + const locked = await issue(A.supplier, { containment_action: '示例', root_cause: '示例', corrective_action: '示例', status }); + await expect(write(locked, { preventive_action: '改' }), status).rejects.toBeTruthy(); + expect((await stored(locked)).preventive_action ?? null, status).toBeNull(); + } + }); + + // 系统身份运行的 screen flow 自己做隔离检查:别家的整改单、考评单一律拒绝,记录不变 + it('refuses the claim and appeal flows on another supplier\'s records', async () => { + // 两个入口都试:记录页按钮(action)和直接触发 flow(REST trigger,不经按钮所在页面的读取)。flow 以系统身份 + // 读记录,所以隔离只能靠 flow 自己的名册检查 + const run = async (who: Portal, object: string, flow: string, recordId: string, input: Row) => { + const finish = async (start: Promise) => { + let res = (await start.catch((e) => ({ status: 'error', error: e }))) as Row; + for (let i = 0; i < 3 && res.status === 'paused' && res.runId; i++) { + res = (await stack.flows.resume({ flowName: flow, runId: res.runId }, input, { as: who.token }).catch((e) => ({ status: 'error', error: e }))) as Row; + } + return String(res.status ?? (res.success === true ? 'completed' : 'failed')); + }; + return { + // 按钮入口:记录对门户不可见,平台答「不存在」;直接触发:flow 的名册检查拒绝 + status: [ + await finish(stack.actions.run(object, flow, { as: who.token, recordId })), + await finish(stack.flows.run(flow, { recordId }, { as: who.token })), + ], + }; + }; + const REFUSED = { status: ['error', 'refused'] }; + const foreign = String((await sys('srm_quality_issue', { supplier: B.supplier, severity: 'major', problem_description: '示例索赔隔离', claim_amount: 100, claim_status: 'proposed' })).id); + B.visible.get('srm_quality_issue')!.push(foreign); + expect(await run(a, 'srm_quality_issue', 'srm_claim_respond', foreign, { decision: 'accept' })).toEqual(REFUSED); + expect((await stack.rows('srm_quality_issue', { id: foreign }))[0]!.claim_status).toBe('proposed'); + + const today = new Date().toISOString().slice(0, 10); + const review = B.visible.get('srm_performance_review')![0]!; + await stack.hooks.run('srm_performance_review', 'update', { id: review, published_date: today }, { system: true }); + expect((await run(aAdmin, 'srm_performance_review', 'srm_appeal_submit', review, { appeal_text: '越权申诉' }))).toEqual(REFUSED); + expect((await stack.rows('srm_performance_review', { id: review }))[0]!).toMatchObject({ status: 'published' }); + expect((await stack.rows('srm_performance_review', { id: review }))[0]!.appeal_reason ?? null).toBeNull(); + // 联系人停用后,同一个门户管理员对本公司的考评单也申诉不了 + const own = A.visible.get('srm_performance_review')![0]!; + await stack.hooks.run('srm_performance_review', 'update', { id: own, published_date: today }, { system: true }); + await stack.hooks.run('srm_supplier_contact', 'update', { id: aAdmin.contact, active: false }, { system: true }); + expect((await run(aAdmin, 'srm_performance_review', 'srm_appeal_submit', own, { appeal_text: '停用后申诉' }))).toEqual(REFUSED); + await stack.hooks.run('srm_supplier_contact', 'update', { id: aAdmin.contact, active: true }, { system: true }); + expect((await stack.rows('srm_performance_review', { id: own }))[0]!.status).toBe('published'); + // 对照:恢复在职后,本公司的考评单经按钮能申诉,说明上面的拒绝来自隔离检查而不是入口本身不通 + expect((await run(aAdmin, 'srm_performance_review', 'srm_appeal_submit', own, { appeal_text: '示例申诉' })).status[0]).toBe('completed'); + expect((await stack.rows('srm_performance_review', { id: own }))[0]!).toMatchObject({ status: 'appealed', appeal_recorded_by: aAdmin.id }); + }); + // 名册就是授权:任何门户身份、任何内部岗位都写不了 portal_user,也改不了联系人归属和在职标志 it('lets nobody but a flow grant portal access through the contact roster', async () => { await expect( diff --git a/test/portal-response.test.ts b/test/portal-response.test.ts new file mode 100644 index 0000000..2d383d1 --- /dev/null +++ b/test/portal-response.test.ts @@ -0,0 +1,227 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import config from '../objectstack.config'; +import { Category, Material, PerformanceReview, PerformanceScore, QualityIssue, Supplier, SupplierContact } from '../src/core/objects'; + +// v0.6 U5(#81):供应商在门户回复整改单、确认索赔、查看已发布绩效并申诉(01 FR-603、606、704、706;03 §8 第 2–6 步、 +// §10 第 5、7 步;04 §3.2)。门户用户按组织角色 external 邀请、任命静态岗位、登记在联系人 portal_user 上(同 PR #87 / #88 +// 的准备步骤)。隔离(别家的单、不该改的字段和状态)在 test/portal-isolation.test.ts。测试数据全部在这里生成。 +type Row = Record; +type Person = { token: string; id: string }; +type FlowResult = { status?: string; runId?: string; screen?: { title?: string }; refusalMessage?: string }; + +describe('portal SCAR reply, claim and performance appeal', () => { + let stack: VerifyStack; + let seq = 0; + let org: Record = {}; + + const sys = async (object: string, doc: Row) => (await stack.hooks.run(object, 'insert', doc, { system: true })) as Row; + const row = async (object: string, id: string) => (await stack.rows(object, { id }))[0]!; + const assign = (userId: string, position: string) => stack.seed('sys_user_position', [{ user_id: userId, position, ...org }]); + const person = async (position: string): Promise => { + const token = await stack.signUp(`portal-reply-${position}-${++seq}@example.com`); + const id = String((await stack.contextFor(token)).userId); + await assign(id, position); + return { token, id }; + }; + const portalUser = async (supplier: string, positions: string[], contactFunction: string[]): Promise => { + const token = await stack.signUp(`portal-reply-${++seq}@example.com`); + const id = String((await stack.contextFor(token)).userId); + expect(await stack.hooks.updateWhere('sys_member', { user_id: id }, { role: 'external' }, { system: true })).toBe(1); + for (const position of positions) await assign(id, position); + await sys(SupplierContact.name, { supplier, name: `示例门户联系人 ${seq}`, email: `portal-reply-${seq}@example.com`, contact_function: contactFunction, portal_user: id }); + return { token, id }; + }; + const reply = (as: Person, id: string, doc: Row) => stack.hooks.run(QualityIssue.name, 'update', { id, ...doc }, { as: as.token }); + const file = async () => { + const name = `portal-evidence-${++seq}.pdf`; + const [created] = await stack.seed('sys_file', [{ key: `attachments/${name}`, name, mime_type: 'application/pdf', size: 1, scope: 'attachments', status: 'committed' }]); + return String(created?.id); + }; + // 站内信经消息服务异步投递:等到收齐 count 条(或超时)再读 + const inbox = async (userId: string, recordId: string, count = 1) => { + const read = async () => (await stack.rows('sys_inbox_message', { user_id: userId })).filter((m) => String(m.action_url).endsWith(`/${recordId}`)); + for (let i = 0; i < 40 && (await read()).length < count; i++) await new Promise((r) => setTimeout(r, 50)); + return read(); + }; + 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', comment: string) => { + const req = await pending(recordId); + expect(req, 'no pending approval request').toBeTruthy(); + const res = await stack.apiAs(as.token, 'POST', `/approvals/requests/${req!.id}/${verdict}`, { comment }); + expect(res.status, await res.clone().text()).toBe(200); + }; + const runScreen = async (as: Person, object: string, flow: string, recordId: string, input: Row = {}) => { + let res = (await stack.actions.run(object, flow, { as: as.token, recordId })) as FlowResult; + let screens = 0; + while (res.status === 'paused' && res.runId) { + expect(++screens, 'too many screens').toBeLessThan(5); + res = (await stack.flows.resume({ flowName: flow, runId: res.runId }, input, { as: as.token })) as FlowResult; + } + return { res, screens }; + }; + const refusal = async (as: Person, object: string, flow: string, recordId: string) => { + const { res, screens } = await runScreen(as, object, flow, recordId); + expect(screens).toBe(0); + expect(res.status).toBe('refused'); + return String(res.refusalMessage ?? ''); + }; + + let sqe: Person; + let accountant: Person; + let director: Person; + let supplier: string; + let material: string; + let x: Person; + let xAdmin: Person; + const scar = async (doc: Row = {}) => + String( + ( + await sys(QualityIssue.name, { + supplier, + material, + severity: 'major', + problem_description: '示例:来料尺寸超差', + sqe: sqe.id, + ...doc, + }) + ).id, + ); + + beforeAll(async () => { + stack = await bootStack(config); + const ctx = await stack.contextFor(await stack.signIn()); + org = ctx.tenantId ? { organization_id: String(ctx.tenantId) } : {}; + sqe = await person('srm_sqe'); + accountant = await person('srm_ap_accountant'); + director = await person('srm_procurement_director'); + const category = await sys(Category.name, { code: `PRP-${++seq}`, name: '示例品类(门户回复)', sqe: sqe.id }); + material = String((await sys(Material.name, { erp_material_no: `EX-PRP-${++seq}`, material_code: `M-PRP-${seq}`, name: '示例冲压件', category: category.id })).id); + supplier = String((await sys(Supplier.name, { name: '示例门户回复有限公司', credit_code: '91000000MA0PRP0001', legacy_imported: true })).id); + x = await portalUser(supplier, ['srm_portal_user'], ['quality']); + xAdmin = await portalUser(supplier, ['srm_portal_user', 'srm_portal_admin'], ['admin']); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + // 07 §2.2 第 1 步:X 在门户回复一张整改单并经 SQE 审核(退回一次、改好再通过) + it('lets the supplier reply D3 and D4–D7 on the portal and the SQE review it', async () => { + const id = await scar(); + const [seen] = await stack.rows(QualityIssue.name, { id }, { as: x.token }); + expect(seen).toMatchObject({ status: 'open', problem_description: '示例:来料尺寸超差' }); + + // D3:可以先存草稿,再提交 + await reply(x, id, { containment_action: '示例:在库品全检隔离,在途批次加严检验' }); + expect((await row(QualityIssue.name, id)).status).toBe('open'); + await reply(x, id, { status: 'containment_submitted' }); + expect(await row(QualityIssue.name, id)).toMatchObject({ status: 'containment_submitted', containment_action: '示例:在库品全检隔离,在途批次加严检验' }); + expect(await inbox(sqe.id, id)).toHaveLength(1); + + // D4–D7 与证据,提交后由负责 SQE 审核(srm_scar_review),审核期间锁定 + const evidence = await file(); + await reply(x, id, { + root_cause: '示例:模具磨损未按计划更换', + corrective_action: '示例:更换模具并复测首件', + preventive_action: '示例:模具寿命纳入点检表', + evidence: [evidence], + }); + await reply(x, id, { status: 'action_submitted' }); + expect((await row(QualityIssue.name, id)).status).toBe('action_submitted'); + expect(String((await pending(id))?.pending_approvers ?? '')).toContain(sqe.id); + await expect(reply(x, id, { preventive_action: '审核中改' })).rejects.toBeTruthy(); + + // SQE 退回:回到已提交临时措施,意见供应商可见 + await decide(sqe, id, 'reject', '示例:根因分析需附模具点检记录'); + const [returned] = await stack.rows(QualityIssue.name, { id }, { as: x.token }); + expect(returned).toMatchObject({ status: 'containment_submitted', sqe_comment: '示例:根因分析需附模具点检记录' }); + + await reply(x, id, { root_cause: '示例:模具磨损未按计划更换,点检记录见附件', evidence: [await file()] }); + await reply(x, id, { status: 'action_submitted' }); + await decide(sqe, id, 'approve', '示例:同意'); + expect((await row(QualityIssue.name, id)).status).toBe('verifying'); + + // 轮到 SQE 验证效果:供应商不能再改,也不能自己关闭 + await expect(reply(x, id, { corrective_action: '验证中改' })).rejects.toBeTruthy(); + await expect(reply(x, id, { status: 'closed' })).rejects.toBeTruthy(); + await stack.hooks.run(QualityIssue.name, 'update', { id, status: 'closed' }, { as: sqe.token }); + expect((await row(QualityIssue.name, id)).status).toBe('closed'); + }); + + it('lets an SQE still record the reply for a supplier without portal access, without notifying itself', async () => { + const id = await scar(); + await stack.hooks.run(QualityIssue.name, 'update', { id, containment_action: '示例:SQE 代填', status: 'containment_submitted' }, { as: sqe.token }); + expect((await row(QualityIssue.name, id)).status).toBe('containment_submitted'); + expect(await inbox(sqe.id, id)).toHaveLength(0); + }); + + // 03 §8 第 6 步:供应商接受或提出异议;接受后应付会计据此扣款并标记已扣款 + it('lets the supplier accept or dispute a proposed claim', async () => { + const id = await scar(); + expect(await refusal(x, QualityIssue.name, 'srm_claim_respond', id)).toContain('Only a proposed claim'); + await stack.hooks.run(QualityIssue.name, 'update', { id, claim_amount: 5000, claim_status: 'proposed' }, { as: sqe.token }); + + // 内部岗位不经这个 flow:线下确认的由 SQE 直接改索赔状态 + expect(await refusal(sqe, QualityIssue.name, 'srm_claim_respond', id)).toContain('confirmed by the supplier on the portal'); + // 门户不能直接改索赔状态 + await reply(x, id, { claim_status: 'supplier_accepted' }).catch(() => undefined); + expect((await row(QualityIssue.name, id)).claim_status).toBe('proposed'); + + await runScreen(x, QualityIssue.name, 'srm_claim_respond', id, { decision: 'dispute', response_text: '示例:不良数与我方出货检验记录不符' }); + expect(await row(QualityIssue.name, id)).toMatchObject({ claim_status: 'disputed', claim_response: '示例:不良数与我方出货检验记录不符' }); + expect(await inbox(sqe.id, id)).toHaveLength(1); + expect(await refusal(x, QualityIssue.name, 'srm_claim_respond', id)).toContain('Only a proposed claim'); + + // 协商后 SQE 调整金额重新提出,供应商接受 + await stack.hooks.run(QualityIssue.name, 'update', { id, claim_amount: 3000, claim_status: 'proposed' }, { as: sqe.token }); + await runScreen(x, QualityIssue.name, 'srm_claim_respond', id, { decision: 'accept' }); + expect((await row(QualityIssue.name, id)).claim_status).toBe('supplier_accepted'); + expect(await inbox(sqe.id, id, 2)).toHaveLength(2); + expect(await inbox(accountant.id, id)).toHaveLength(1); + await stack.hooks.run(QualityIssue.name, 'update', { id, claim_status: 'settled' }, { as: accountant.token }); + expect((await row(QualityIssue.name, id)).claim_status).toBe('settled'); + }); + + // FR-704 / FR-706:已发布绩效与扣分项门户可见,门户管理员申诉一次 + it('shows the published review with its score lines and lets the portal admin appeal once', async () => { + const today = new Date().toISOString().slice(0, 10); + const [review] = await stack.seed(PerformanceReview.name, [ + { supplier, cycle: 'monthly', period: '2026-09', period_start: '2026-09-01', period_end: '2026-09-30', status: 'published', published_date: today, total_score: 76.5, grade: 'grade_c', quality_score: 70, reviewer: director.id, review_comment: '示例:内部审定意见' }, + ]); + const id = String(review!.id); + const kpi = String((await stack.rows('srm_kpi'))[0]!.id); + await stack.seed(PerformanceScore.name, [{ review: id, kpi, raw_value: 92, score: 60, scored: true, scorer: sqe.id, comment: '示例:两批让步接收扣分' }]); + + const [seen] = await stack.rows(PerformanceReview.name, { id }, { as: x.token }); + expect(seen).toMatchObject({ total_score: 76.5, grade: 'grade_c', quality_score: 70 }); + expect(seen!.review_comment ?? null).toBeNull(); + expect(seen!.reviewer ?? null).toBeNull(); + const [line] = await stack.rows(PerformanceScore.name, { review: id }, { as: x.token }); + expect(line).toMatchObject({ raw_value: 92, score: 60, comment: '示例:两批让步接收扣分' }); + expect(line!.scorer ?? null).toBeNull(); + // 隐藏字段不影响门户的列表搜索(objectstack#25672 只涉及选项字段) + const search = await stack.apiAs(x.token, 'GET', `/data/${PerformanceReview.name}?search=2026-09`); + expect(search.status, await search.clone().text()).toBe(200); + + // 普通门户用户不能申诉,门户管理员可以 + expect(await refusal(x, PerformanceReview.name, 'srm_appeal_submit', id)).toContain('portal administrator'); + await runScreen(xAdmin, PerformanceReview.name, 'srm_appeal_submit', id, { appeal_text: '示例申诉:两批让步接收已由客户确认合格' }); + expect(await row(PerformanceReview.name, id)).toMatchObject({ + status: 'appealed', + appeal_reason: '示例申诉:两批让步接收已由客户确认合格', + appeal_date: today, + appeal_recorded_by: xAdmin.id, + }); + expect(await inbox(director.id, id)).toHaveLength(1); + // 申诉期间照常可见 + expect(await stack.rows(PerformanceReview.name, { id }, { as: x.token })).toHaveLength(1); + + // 采购总监处理后回到已发布,处理结果门户可见;只能申诉一次 + await runScreen(director, PerformanceReview.name, 'srm_appeal_resolve', id, { outcome: 'maintained', result_text: '示例:维持原结果' }); + const [resolved] = await stack.rows(PerformanceReview.name, { id }, { as: x.token }); + expect(resolved).toMatchObject({ status: 'published', appeal_outcome: 'maintained', appeal_result: '示例:维持原结果' }); + expect(await inbox(xAdmin.id, id)).toHaveLength(1); + expect(await refusal(xAdmin, PerformanceReview.name, 'srm_appeal_submit', id)).toContain('appealed only once'); + }); +});