diff --git a/docs/design/02-domain-model.md b/docs/design/02-domain-model.md index b455ff1..8655bb7 100644 --- a/docs/design/02-domain-model.md +++ b/docs/design/02-domain-model.md @@ -306,6 +306,7 @@ upsert 写入,结构不变。 |---|---|---| | `application_no` | autonumber | `APP{YYYY}{00000}`,按年重置 | | `source` | select(只读) | `internal` 内部登记(默认)/ `invited` 邀请(v0.6,由「提交准入申请」写入)/ `self_registered` 自助注册(待平台,随公开表单加入) | +| `contact` | lookup → `srm_supplier_contact`(只读) | 申请联系人(v0.6 U6):门户提交时由「提交准入申请」写入提交人在名册上的联系人,受理、退回补充、通过、未通过的通知发给它在名册上的门户账号;内部登记的申请为空 | | `company_name` | text | 必填 | | `country` | select | 必填,默认 `cn`,选项同供应商的 `srm_country` | | `credit_code` / `registration_no` | text | 规则同供应商:中国供应商填统一社会信用代码,境外供应商填当地注册号或税号 | diff --git a/docs/design/03-processes-and-automation.md b/docs/design/03-processes-and-automation.md index 46d67dc..26d842d 100644 --- a/docs/design/03-processes-and-automation.md +++ b/docs/design/03-processes-and-automation.md @@ -97,8 +97,10 @@ flow 按所关联供应商的状态写 `duplicate_check`:`potential` → `pass `reapply` 是重新准入 FR-208 的门户路径)。步骤 2「建档」与 `srm_application_accept` 不随 v0.6 加入,留给公开表单 (FR-201);之前受理时供应商直接进入 `qualifying`、申请进入 `doc_review`。 -受邀申请由系统身份建(门户不授准入申请的新建权),所以受理、退回补充、批准、未通过的「通知申请人」发给该供应商 -开通了门户的联系人(`portal_user`),不发给 `created_by`;随 v0.6 U6 供应商通知(#82)实现。重新准入批准后按本次申请 +受邀申请由系统身份建(门户不授准入申请的新建权),所以受理、退回补充、批准、未通过的「通知申请人」发给申请联系人: +`srm_application_submit` 把提交人在名册上的联系人写进申请的 `contact`,通知发给它在名册上的门户账号,不发给 `created_by` +(v0.6 U6)。受理、退回补充、批准由 `srm_application_supplier_notice` 发(从「退回补充」回到资料审核是供应商自己重新提交的, +不发);未通过由 `srm_admission_rejection` 在回收门户之前发。内部登记的申请没有申请联系人,不发供应商。重新准入批准后按本次申请 品类恢复或新建品类资格,没再申请的品类保持淘汰时的 `suspended`。 ```mermaid @@ -139,11 +141,11 @@ sequenceDiagram | 1 受理 | 申请创建(v0.1 内部登记;v0.6 受邀供应商在门户经 screen flow `srm_application_submit` 提交:供应商取自调用者自己的名册行,品类取自门户邀请,申请回填到邀请) | flow `srm_application_intake`(record_change,after-create):按身份标识查供应商(`country == 'cn'` 用 `credit_code`,其他国家 / 地区用 `country` + `registration_no`,BR-01);命中 `blacklisted` → 申请置 `rejected`、`duplicate_check = blacklisted`,通知申请人;命中 `eliminated` → `reapply`,申请的 `supplier` 指向旧档并允许继续(淘汰后重新准入的唯一路径,FR-208);命中 `qualified` / `qualifying` / `frozen` / `potential` → `duplicate`,通知品类经理人工判断;未命中 → `pass`。同一 flow 按申请品类找到品类经理和 SQE,写入 `reviewer` / `sqe`,把这些品类中最高的 `risk_class` 和 `material_class` 复制到申请 | | 2 建档(公开表单,待平台) | 品类经理把申请推进到 `doc_review`(人工确认后,防止灌水污染主数据);内部登记与邀请注册的申请已预关联供应商,没有这一步 | flow `srm_application_accept`:`duplicate_check == 'pass'` 时新建 `potential` 供应商与主联系人并回填 `supplier`;`reapply` 时沿用旧档;供应商 `potential / eliminated → qualifying`;`submitted` 阶段只有申请记录,不建主数据 | | 3 开通门户 | 品类经理、采购主管提出(门户邀请);组织管理员操作(v0.6) | 组织管理员按邀请邮箱在成员页邀请为 `external`,对方接受后运行「开通门户」(见 [04 §3.3](./04-security-and-portal.md#33-门户账号的开通与回收));准入邀请在开通后由供应商提交申请,所以这一步在步骤 1 之前;v0.1–v0.5 跳过 | -| 4 补充资料 | 供应商 | 门户内补齐公司资料(门户可改的字段见 [04 §4.2](./04-security-and-portal.md#42-字段级权限fls))、银行账户、证书;品类经理可「退回补充」(申请→`returned`,通知联系人随 v0.6 U6)。供应商补齐后在申请上点「补充完成,重新提交」(screen flow `srm_application_resubmit`,v0.6):写补充说明 `supplement_note`,申请回到 `doc_review`,通知初审人;只有申请所属供应商的门户用户能提交(flow 按门户名册检查)。应付会计经「核验银行账户」、SQE 经「核验证书」两个 screen flow 完成核验 | +| 4 补充资料 | 供应商 | 门户内补齐公司资料(门户可改的字段见 [04 §4.2](./04-security-and-portal.md#42-字段级权限fls))、银行账户、证书;品类经理可「退回补充」(申请→`returned`,通知申请联系人,v0.6 U6)。供应商补齐后在申请上点「补充完成,重新提交」(screen flow `srm_application_resubmit`,v0.6):写补充说明 `supplement_note`,申请回到 `doc_review`,通知初审人;只有申请所属供应商的门户用户能提交(flow 按门户名册检查)。应付会计经「核验银行账户」、SQE 经「核验证书」两个 screen flow 完成核验 | | 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`:两个岗位各一人通过,同岗位多人时任一人即可;任一人驳回即驳回)。初审人就是申请的新建人或把申请推进到准入评审的人时,品类经理一级上提给采购总监、与第 2 级合并(标准申请由采购总监审批,A 类或高风险由采购总监 + 质量经理会签),申请上 `review_escalated` 记为 true;不要求另一位品类经理,免得只有一位品类经理的品类卡住(平台没有「不可自审」选项,objectstack#25446;同合同、处置单的做法)。驳回 → 申请回 `doc_review` 并通知 | -| 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 起 `srm_admission_rejection` 同时回收门户账号:清掉该供应商全部联系人的 `portal_user`、撤销门户邀请,有门户账号时通知组织管理员撤任命、移出组织(GL-10 6.6.1 c)。只有潜在、淘汰的供应商能申请(`supplier_admissible`),所以驳回总会让供应商退回,已合格供应商不受影响 | +| 7 批准后 | 审批通过 | 申请→`approved`,写入 `cycle_days`;供应商 `qualifying → qualified`;按申请品类建 `srm_supplier_category`(申请的 `audit_result` 为有条件通过时为 `trial`,否则 `qualified`,WI-01 6.6.2);通知申请联系人(v0.6 U6)。ERP 建档与 `erp_vendor_code` 回写为预留集成点(06 §1) | +| 8 未通过 | 终止 | 申请→`rejected`;供应商 `qualifying → potential`(`reapply` 的旧档回 `eliminated`);v0.6 起 `srm_admission_rejection` 先通知申请联系人「未通过」(U6),再回收门户账号:清掉该供应商全部联系人的 `portal_user`、撤销门户邀请,有门户账号时通知组织管理员撤任命、移出组织(GL-10 6.6.1 c)。只有潜在、淘汰的供应商能申请(`supplier_admissible`),所以驳回总会让供应商退回,已合格供应商不受影响 | ## 4. P3 寻源:询价 → 比价 → 定标(v0.7) @@ -212,7 +214,9 @@ flowchart TD → 通过后子 flow `srm_change_apply` 以系统身份写回:基本信息、地址写回供应商(同时写 `last_change_request`, 见 02 §4.1 关键信息锁定),联系人写回联系人;银行账户变更停用原账户(是默认账户的同时取消默认)、按新值建一个 `active` 账户,核验人记为财务复核人,原账户是默认的新账户接着做默认 → 写回后的档案快照到 `after_value`, -变更单为 `applied`,通知发起人。 +变更单为 `applied`,通知发起人。v0.6 U6 起生效、驳回、退回修改都由 `srm_change_supplier_notice` 通知该供应商的门户管理员; +发起人本人是门户用户时 `srm_change_approval` 不再发内部措辞的通知。银行账户由待核验变为有效(核验或经变更单生效)时 +`srm_bank_supplier_notice` 通知门户管理员(只写开户行,不写账号)。 - **驳回**:不改主记录,审批意见写入 `reject_reason`,通知发起人。 - **退回修改**:两级都可以退回(`revise` → `approval_revise`,每级至多 3 次),记录解锁,发起人改完重新提交(门户提交的变更单由 @@ -246,7 +250,9 @@ ERP 侧的银行账户由财务按 SRM 的审批记录维护(ERP 集成为预 处置单置为「已执行」,通知发起人并提醒在 ERP 同步冻结标志;驳回不改供应商,记下驳回意见。 - 门户账号回收(v0.6,ADR-0010):淘汰、拉入黑名单执行时清掉该供应商全部联系人上的 `portal_user`,门户访问在下一个 请求就失效,不靠人工停用联系人;冻结不动门户([04 §3.3](./04-security-and-portal.md#33-门户账号的开通与回收))。 - 通知供应商随 v0.6 U6;冻结后不能新建合同由合同的校验规则负责(v0.3 C1)。 + 通知供应商(v0.6 U6):冻结、解冻执行后 `srm_disposition_supplier_notice` 通知该供应商的主账号(`admin`)联系人, + 只写冻结范围,不写处置原因,链接到公司资料(处置单不对门户开放);淘汰、拉入黑名单在回收门户之前由 + `srm_disposition_apply` 通知;解除黑名单时门户早已回收,不发。冻结后不能新建合同由合同的校验规则负责(v0.3 C1)。 - 处置单草稿(v0.5):高风险事件(§11)和绩效结果应用(§10 第 6 步)由 flow 生成草稿,不直接发起审批。品类经理或 采购主管在草稿上点「提交审批」(screen flow `srm_disposition_submit`),审批 flow 在草稿变为审批中时发起,发起人 是提交人(`submitted_by`;手工新建的处置单是新建人),品类经理一级的判断与 BR-11 比对都按发起人;不需要处置时点 @@ -345,12 +351,12 @@ 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` 联系人的通知随 v0.6 U6(#82)。`minor` 不合格只计入绩效,不自动开单。审核问题项由 SQE 或质量经理在已完成的审核上点「转整改单」(`srm_audit_finding_to_scar`):还没转出的严重、一般不符合项各开一张(`source = audit`,严重 → `critical`、一般 → `major`,负责 SQE 为操作人),整改单回填到问题项,重复点击不重复开单;观察项不转。供应商在门户回复(v0.6 U5),没开通门户的由 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)。任何来源的新整改单都由 `srm_scar_supplier_notice` 通知供应商的 `quality` 联系人(没有质量门户联系人时发主账号联系人,v0.6 U6)。`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 | +| 5 逾期升级 | 追赶式扫描 `srm_scar_overdue_sweep`(每小时;外部心跳见 §2.3 R3) | 超过 `containment_due` 仍为 `open`、或超过 `final_due` 仍未到 `verifying`:`is_overdue = true`,通知负责 SQE 与质量经理(SQE 主管,FR-605);标志保留到关闭,供「SCAR 按期关闭率」统计;`srm_scar_supplier_notice` 同时提醒供应商 `quality` 联系人(v0.6 U6) | +| 5a 复发重开 | SQE 把已关闭的整改单改回 `open` | flow `srm_scar_reopen`:期限按重开时间重算,逾期标志清掉,通知负责 SQE;`srm_scar_supplier_notice` 把重算后的期限通知供应商 `quality` 联系人(v0.6 U6)。SQE 退回 D4–D7(`action_submitted → containment_submitted`)时也通知 | | 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 @@ -396,9 +402,9 @@ stateDiagram-v2 | 2 自动算分 | 同一次运行 | 按 [05 §4](./05-experience-and-analytics.md#4-仪表盘与指标口径) 的口径(同数据集 `srm_delivery_metrics`)从交付记录计算 OTD、批次合格率、PPM:flow 的查询节点按供应商和本期包含的月份(交付记录的 `period`)取交付记录,汇总 `on_time`、`inspection_result`、`defect_quantity`、`received_quantity`,写入考评单的比率字段与交付行数;每条自动指标的评分明细写入原始值与得分,得分按指标的满分值、零分值线性计算(02 §4.7),数字全在指标库里,flow 不写死。本期没有交付记录、某项指标没有数据(如没有已检批次)时该项不给分(`scored = false`,`comment` 写明),考评单标记「数据不全」,不给零分(07 R7),由人工评分环节处理。订单响应及时率在 ERP 集成前在指标库中不启用(不建评分明细),启用时由采购总监调整其余权重。算完转 `scoring`。不良数与实收数逐行累加:CEL 没有对列表求和、flow 没有汇总节点(07 §6.3 PF15)。性能(07 R9,2026-10-24 进程内 sqlite 实测):200 家供应商、每家 6 行交付记录一次生成约 7.4 秒,每家 30 行约 8.6 秒,重跑(全部跳过)约 0.6 秒 | | 3 人工评分 | 生成 flow 转 `scoring` 时 | 评分人在生成评分明细时写入:人工指标和没有数据的自动指标按指标的 `scorer_type` 从供应商的品类上取(品类经理 `category_manager`、采购员 `buyer`、SQE `sqe`、需求部门评分人 `evaluator`);供应商有多个品类时取物资类别最高的、设了这个人的品类,都没设时退回品类经理,仍没有则不指派(截止后按缺评处理)。转 `scoring` 时写评分截止日 `score_due`(当天 + `perf_score_days`,示例 10 天)并通知各评分人(「待你评分」)。评分人在考评单上点「提交评分」(screen flow `srm_score_submit`):flow 只列出指派给本人的评分项,逐项显示指标、评分规则和原始值,填 0–100 分和说明,已评的可以在截止前改;全部评分项都已评分时考评单转 `reviewing`。`srm_score_due_notice` 在截止前 3 天提醒还有未评项的评分人;扫描任务 `srm_score_overdue_sweep`(每日,追赶式)把已过截止日的考评单上未评项取参数 `perf_missing_score`(示例 60 分,客户可改)、标记「未评」(`defaulted`),转 `reviewing`。评分人需要回避的(GL-05 6.3、GL-06 6.4.5),采购总监在评分项上点「改派评分人」(screen flow `srm_score_reassign`,考评单在 `scoring` 时),见表后说明 | | 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):审定人不能是本考评单的评分人,撞人时这次通过不算数,通知后重新开一轮审定 | +| 5 审定发布 | 同一 flow 的审批:采购总监(`position = srm_procurement_director`) | 通过后→`published`,写审定人、发布日期;回写供应商 `grade`、`latest_score`(同一供应商已有更晚期间的已发布考评单时不回写),通知评分人;供应商可在门户查看结果与扣分项(FR-704,v0.6),`srm_review_supplier_notice` 通知供应商主账号(`admin`)联系人(只写单号和期间,v0.6 U6)。采购总监驳回即「退回重评」:考评单回到 `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)天内 | 供应商门户管理员在门户的考评单上提交(screen flow `srm_appeal_submit`,v0.6 U5);没开通门户的由采购员、采购主管或品类经理代录:只能申诉一次,写申诉理由 → `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);通知内部代录的申诉人,供应商主账号联系人由 `srm_review_supplier_notice` 通知(门户提交的申诉人只收这一条,v0.6 U6)。处理人同样不能是本考评单的评分人。结果应用(第 6 步)只在审定发布时触发;申诉改变等级时,flow 通知品类经理,按原等级生成的草稿由品类经理作废或另行处理 | > 结果应用(v0.5,`srm_performance_apply`):只在审定发布(`reviewing` → `published`)时触发,申诉处理回到已发布不触发。 > - C 级:开一张整改单,`source = performance`、严重度 `minor`、`performance_review` 指回考评单,问题描述写总分与各维度得分;负责 @@ -434,8 +440,8 @@ stateDiagram-v2 | 场景 | 触发 | 动作 | |---|---|---| -| 到期提醒 | `time_relative`:证书 `valid_to`,提前 30 / 15 / 7 天 | 证书 `status → expiring`;通知 SQE 与供应商联系人(FR-901) | -| 到期生效 | 追赶式扫描:`valid_to < today` 且不是 `expired`(定时 flow 在云上默认关闭,部署时打开,objectstack#25442) | 证书→`expired`;`is_mandatory` 的证书过期时供应商 `qualification_alert = true`(FR-902);v0.5 起同时新建 `qualification` 类风险事件 | +| 到期提醒 | `time_relative`:证书 `valid_to`,提前 30 / 15 / 7 天(`srm_certificate_expiry_notice`,v0.6 U6 实现;状态由到期扫描对齐) | 通知 SQE(该供应商各品类的 SQE,都没有时质量经理)与供应商的主账号、质量联系人(FR-901);平台按天扫描,每张证书每个提醒日只发一次 | +| 到期生效 | 追赶式扫描:`valid_to < today` 且不是 `expired`(定时 flow 在云上默认关闭,部署时打开,objectstack#25442) | 证书→`expired`;`is_mandatory` 的证书过期时供应商 `qualification_alert = true`(FR-902);v0.5 起同时新建 `qualification` 类风险事件 ;证书变为已过期时 `srm_certificate_expired_notice` 通知 SQE 与供应商的主账号、质量联系人(v0.6 U6) | | 证书改动核验(v0.6) | 证书新建,或类型、编号、发证机构、有效期、扫描件被修改(`srm_certificate_change_check`) | 改动人持门户岗位:`verified` 复位为 false、清空核验人,通知该供应商各品类的 SQE(品类都没有 SQE 时通知质量经理)经「核验证书」(`srm_certificate_verify`,SQE / 质量经理;已核验、已过期的不核验)核对原件;改动人是内部人员或系统:录入即核验,`verified = true`、核验人记为改动人 | | 资质恢复 | 证书或品类资格写入后的记录触发(`srm_supplier_qualification_recalc`) | 先按该供应商全部品类的 `mandatory_cert_types` 标记每张证书的 `is_mandatory`(`srm_certificate_mandatory_mark`:新建证书或改了类型时看这张证书;`srm_grant_mandatory_mark`:新增品类资格时看该供应商的全部证书。只置为必备、不取消,存量导入和手工标记的保留);再重新计算(证书状态、「必备」标志或 `verified` 变化后):同类型里有未过期(有效或即将到期)且已核验的证书,称这一类「顶得住」。有一张过期的必备证书而同类型顶不住时标记 `qualification_alert = true`;每一张过期或未核验的必备证书同类型都顶得住(即每一种必备资质类型都有未过期且已核验的证书)时解除(v0.5 起关闭对应风险事件);两样都不满足时保持原值——门户改了一张有效证书只复位核验,不新标异常,已有的异常要等 SQE 核验后才解除。只上传新证书或把过期证书的有效期改长而未经 SQE 核验,不会解除异常(v0.6) | | 风险事件处理 | 手工登记或系统生成(v0.5) | `high` 级事件通知品类经理并生成处置单草稿(冻结);关闭时填写处理结论 | @@ -499,7 +505,8 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_application_intake` | record_change:新建 | 准入申请 | v0.1 | | `srm_application_accept` | record_change:状态→`doc_review` | 准入申请 → 供应商、联系人 | v0.1 | | `srm_admission_approval` | record_change:状态→`admission_review` | 准入申请 | v0.1 | -| `srm_certificate_expiry_notice` | time_relative:`valid_to` −30 / −15 / −7 天 | 资质证书 | v0.1 | +| `srm_certificate_expiry_notice` | time_relative:`valid_to` −30 / −15 / −7 天(未过期的证书) | 资质证书 → SQE、供应商联系人 | v0.6(U6,原排 v0.1) | +| `srm_certificate_expired_notice` | record_change:证书→`expired` | 资质证书 → SQE、供应商联系人 | v0.6(U6) | | `srm_certificate_expire_sweep` | schedule:每日 01:00(追赶式) | 资质证书 | v0.1 | | `srm_supplier_qualification_recalc` | record_change:证书状态、「必备」标志、`verified` 变化 | 资质证书 → 供应商(v0.5 起 → 风险事件;v0.6 起只认已核验的证书) | v0.1 | | `srm_certificate_change_check` | record_change:证书新建或改了内容 | 资质证书(门户改动复位核验并通知 SQE,内部录入即核验) | v0.6 | @@ -530,6 +537,13 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_portal_roster_write` | 子 flow:只由「开通门户」调用(系统身份写联系人 `portal_user`,平台拒绝直接启动) | 门户邀请 → 联系人 | v0.6 | | `srm_portal_contact_revoke` | record_change:开通了门户的联系人停用 | 联系人(清 `portal_user`)、门户邀请(撤销),通知组织管理员 | v0.6 | | `srm_admission_rejection` | record_change:申请→`rejected` | 准入申请 → 供应商(v0.6 起 → 联系人 `portal_user` 清空、门户邀请撤销) | v0.1 | +| `srm_portal_opened_notice` | record_change:门户邀请 `sent`→`accepted` | 门户邀请 → 受邀联系人 | v0.6(U6) | +| `srm_application_supplier_notice` | record_change:有申请联系人的申请受理(`submitted`→`doc_review`)、退回补充、通过 | 准入申请 → 申请联系人 | v0.6(U6) | +| `srm_scar_supplier_notice` | record_change:整改单新建、D4–D7 退回、重开、标记逾期 | 整改单 → 供应商 `quality` 联系人 | v0.6(U6) | +| `srm_review_supplier_notice` | record_change:考评单 `reviewing`→`published`、`appealed`→`published` | 考评单 → 供应商 `admin` 联系人 | v0.6(U6) | +| `srm_bank_supplier_notice` | record_change:银行账户 `pending_verify`→`active` | 银行账户 → 门户管理员 | v0.6(U6) | +| `srm_change_supplier_notice` | record_change:变更单生效、驳回、退回修改 | 变更单 → 门户管理员 | v0.6(U6) | +| `srm_disposition_supplier_notice` | record_change:冻结 / 解冻处置单已执行 | 处置单 → 供应商 `admin` 联系人 | v0.6(U6) | | `srm_single_source_approval` | record_change:状态→`approval_pending` | 寻源单 | v0.7 | | `srm_event_publish` | record_change:状态→`published` | 寻源单 | v0.7 | | `srm_bid_close_sweep` | schedule:每 10 分钟 + api 心跳 | 寻源单、报价单 | v0.7 | diff --git a/docs/design/04-security-and-portal.md b/docs/design/04-security-and-portal.md index 8b0e807..db0804f 100644 --- a/docs/design/04-security-and-portal.md +++ b/docs/design/04-security-and-portal.md @@ -233,7 +233,7 @@ flowchart LR - 经子 flow `srm_portal_roster_write`(`runAs: 'system'`,autolaunched)在联系人上写 `portal_user`,邀请置为「已开通」。 `portal_user` 字段级只读,`runAs: 'user'` 的写入会被数据层剔除(07 §6.3 PF19),所以名册由系统身份写;平台拒绝任何调用者 直接启动系统身份的 autolaunched flow,子 flow 只能经开通 flow 进入,写入前再查一遍一人一号。写入后下一个请求起该用户 - 只能看到本公司数据; + 只能看到本公司数据;邀请置为「已开通」后 `srm_portal_opened_notice` 给受邀联系人发「门户已开通」(05 §5); 3. 准入邀请:供应商补齐资料、证书和银行账户后,在门户的公司资料上点「提交准入申请」(`srm_application_submit`,系统身份): 供应商取自调用者自己的名册行(不取页面传入的记录),品类取自门户邀请,`source = invited`,申请回填到邀请(03 §3); 4. 同一供应商以后再开通账号,同样走第 0–2 步。 @@ -243,9 +243,9 @@ flowchart LR | 场景 | 动作 | |---|---| | 联系人离职 / 停用 | 联系人 `active = false`(名册过滤条件),下一个请求起看不到数据;`srm_portal_contact_revoke` 同时清掉 `portal_user`、撤销该联系人的门户邀请并通知组织管理员——日后把联系人改回在职也不会静默恢复门户访问,重新开通走门户邀请。组织管理员随后撤销门户岗位任命并移出组织 | -| 供应商冻结 | 账号与门户功能保持不变:整改单仍要回复,资料仍要维护;冻结只阻止新的询价邀请(BR-03),新订单由 ERP 侧冻结标志拦截 | -| 供应商淘汰 / 黑名单 | `srm_disposition_apply` 执行时清掉该供应商全部联系人上的 `portal_user`、撤销门户邀请,门户访问即刻失效,不靠人工停用联系人;有门户账号时通知组织管理员,岗位任命与组织成员身份由组织管理员随后移除(处置 flow 写不了任命,07 §6 V2)。重新准入时重新邀请、开通 | -| 准入未通过 | 申请驳回、供应商退回 `potential`(重新准入的旧档回 `eliminated`)时 `srm_admission_rejection` 清掉该供应商全部联系人上的 `portal_user`、撤销门户邀请,有门户账号时通知组织管理员(03 §3 第 8 步);已合格供应商增加品类的申请被驳回不动门户(只有潜在、淘汰的供应商能申请,`supplier_admissible`)。岗位任命与组织成员身份由组织管理员随后移除 | +| 供应商冻结 | 账号与门户功能保持不变,冻结与解冻都通知主账号联系人(05 §5):整改单仍要回复,资料仍要维护;冻结只阻止新的询价邀请(BR-03),新订单由 ERP 侧冻结标志拦截 | +| 供应商淘汰 / 黑名单 | `srm_disposition_apply` 执行时清掉该供应商全部联系人上的 `portal_user`、撤销门户邀请,门户访问即刻失效,不靠人工停用联系人;清名册之前先给主账号联系人发淘汰 / 列入黑名单通知(05 §5,清掉后名册上就没人可发了);有门户账号时通知组织管理员,岗位任命与组织成员身份由组织管理员随后移除(处置 flow 写不了任命,07 §6 V2)。重新准入时重新邀请、开通 | +| 准入未通过 | 申请驳回、供应商退回 `potential`(重新准入的旧档回 `eliminated`)时 `srm_admission_rejection` 先通知申请联系人「未通过」(05 §5),再清掉该供应商全部联系人上的 `portal_user`、撤销门户邀请,有门户账号时通知组织管理员(03 §3 第 8 步);已合格供应商增加品类的申请被驳回不动门户(只有潜在、淘汰的供应商能申请,`supplier_admissible`)。岗位任命与组织成员身份由组织管理员随后移除 | | 季度复核 | 工作台「供应商 › 门户账号」视图(联系人的 `portal_accounts` 视图)按供应商列出登记了门户账号的联系人;停用、处置、驳回收回的已清掉 `portal_user`,不在此列。组织管理员对照组织成员页的外部成员,差异由采购主管确认(GL-10 6.6.3) | ### 3.4 RLS 示例 diff --git a/docs/design/05-experience-and-analytics.md b/docs/design/05-experience-and-analytics.md index 9586961..ff44b58 100644 --- a/docs/design/05-experience-and-analytics.md +++ b/docs/design/05-experience-and-analytics.md @@ -160,25 +160,43 @@ ## 5. 通知 平台的 `notify` 节点只能发给组织内用户(objectstack#25444):v0.6 前供应商侧的通知发给 -内部经办人(品类经理 / SQE),由其转告;v0.6 起发给供应商的门户用户。 +内部经办人(品类经理 / SQE),由其转告;v0.6 起门户用户是组织成员(组织角色 external),供应商侧的通知直接发给 +他们(U6)。收件人规则: + +- **只取本条记录所属供应商的名册**(ADR-0010 红线 2):在职、登记了门户账号(`portal_user`)的联系人,与成员键 + 同一条件;供应商取触发记录自己的 `supplier`。按职能挑人:标「质量」的发 `quality` 联系人,该供应商没有质量门户 + 联系人时发主账号(`admin`)联系人;标「门户管理员」的发名册上任命了 `srm_portal_admin` 的门户账号;准入申请发 + 申请联系人(门户提交时写入申请的 `contact`)。 +- 没开通门户的供应商名册为空,不发;内部经办人的通知照旧,由其转告(v0.5 行为不变)。内部通知本来发给门户用户 + 本人的(门户提交的变更单的发起人、门户提交的申诉人),改由供应商侧通知代替,不重复发。 +- 门户回收(准入未通过、淘汰、拉入黑名单)要清空名册,相应的「未通过」「已淘汰」「已列入禁入名单」在回收之前发。 | 事件 | 接收人 | 通道 | 版本 | |---|---|---|---| | 待你审核的准入申请 | 审批人 | 站内 + 邮件 | v0.1 | -| 证书即将到期 / 已过期 | SQE(v0.6 起加供应商联系人) | 邮件 + 站内 | v0.1 | +| 证书即将到期(前 30 / 15 / 7 天)/ 已过期 | SQE(品类都没有 SQE 时质量经理);v0.6 U6 起加供应商主账号与质量联系人 | 邮件 + 站内 | v0.6(U6,v0.1 只对齐状态未提醒) | | 银行账户变更待复核、变更已生效 | 应付会计 / 采购员 | 站内 | v0.2 | | 合同待审批、合同即将到期 | 审批人、合同所有者、品类经理 | 站内 + 邮件 | v0.3 | -| 新整改单、整改退回、整改重开、整改逾期 | 负责 SQE(新整改单没有 SQE 时发质量经理);逾期加质量经理(SQE 主管);v0.6 起加供应商 `quality` 联系人 | 邮件 + 站内 / 门户待办 | v0.4 | +| 新整改单、整改退回、整改重开、整改逾期 | 负责 SQE(新整改单没有 SQE 时发质量经理);逾期加质量经理(SQE 主管);v0.6 U6 起加供应商 `quality` 联系人(任何来源的新整改单都发) | 邮件 + 站内 | v0.4(供应商侧 v0.6 U6) | | 供应商在门户提交临时措施;供应商接受索赔 / 对索赔有异议 | 负责 SQE(提交临时措施时没有 SQE 发质量经理;SQE 本人代填的不发);接受索赔加应付会计 | 站内 | v0.6 | -| 待你评分、评分截止前 3 天仍未评、退回重评、绩效结果发布;绩效申诉待处理、申诉处理结果 | 评分人;采购总监;申诉提交人(门户管理员或内部代录人;v0.6 U6 起加供应商 `admin` 联系人) | 站内 + 邮件 | v0.5 | +| 待你评分、评分截止前 3 天仍未评、退回重评、绩效结果发布;绩效申诉待处理、申诉处理结果 | 评分人;采购总监;内部代录的申诉人;v0.6 U6 起绩效发布和申诉结果加供应商 `admin` 联系人(门户提交的申诉人只收这一条) | 站内 + 邮件 | v0.5(供应商侧 v0.6 U6) | | 门户开通待办(新门户邀请);门户访问已收回(联系人停用、处置、准入未通过) | 组织管理员 | 站内 | v0.6(U3) | -| 门户邀请、准入申请已受理 / 已驳回 / 已通过 | 受邀联系人、申请联系人(受邀申请发给开通了门户的联系人 `portal_user`,不发 `created_by`) | 邮件 | v0.6(U6) | +| 银行账户核验生效;信息变更单已生效 / 已驳回 / 退回修改 | 供应商门户管理员(内部发起的变更单另发发起人) | 邮件 + 站内 | v0.6(U6) | +| 供货资格冻结 / 解冻、淘汰、列入黑名单 | 供应商 `admin` 联系人(只写范围,不写处置原因) | 邮件 + 站内 | v0.6(U6) | +| 门户已开通(门户邀请)、准入申请已受理 / 退回补充 / 已通过 / 未通过 | 受邀联系人;申请联系人(申请上的 `contact`,不发 `created_by`;内部登记的申请没有,不发) | 邮件 + 站内 | v0.6(U6) | | 新的询价邀请、报价截止前 24 小时仍未提交、中标 / 未中标、待你审批的定标 | 受邀供应商 `sales` 联系人;审批人 | 邮件 + 门户待办 | v0.7 | | 新订单待确认、订单变更、交期风险、订单 24 / 48 小时未确认、付款完成 | 供应商 / 采购员 / 采购主管 | 邮件 / 站内 | ERP 集成后 | 通知模板统一放在应用包里,按中文 / 英文两套维护;邮件只给出摘要和门户链接,**不在 邮件正文里放报价金额、银行账号等敏感内容**。 +供应商侧通知的模板在 `src/core/notifications/`(平台 `emailTemplates`,每个模板写 `zh-CN`、`en-US` 两份):notify 节点 +按模板名引用,平台按收件人自己的语言(`sys_user.locale`,未设时取部署默认语言 zh-CN;选英文的落到 en-US)渲染, +同一份内容既发邮件也进站内信,站内信点开跳到单据。正文只写单号、供应商名称、事件和期限,不写问题描述、金额、账号、 +分数、等级和处置原因,详情登录门户查看。邮件里的完整链接要拼部署地址,平台模板上下文里没有(07 §6.3 PF20), +模板数据里给出相对路径 `path`,客户可以在模板覆盖层里写上自己的门户域名;时间按服务器时区显示。 +内部经办人的通知仍是 flow 里写的中文标题和正文。 + ## 6. 多语言与移动端 - **多语言**:对象、字段、选项、视图标签和通知模板按中文维护;英文翻译覆盖全部元数据, diff --git a/docs/design/07-delivery-plan.md b/docs/design/07-delivery-plan.md index 8e12dc2..4696b25 100644 --- a/docs/design/07-delivery-plan.md +++ b/docs/design/07-delivery-plan.md @@ -227,7 +227,7 @@ test/ # 仓库根目录 | 仪表盘 | ✅ ⚠️ | 各类看板 | 求和 / 平均只能落在存储数值字段;多值字段不能做维度 | 2026-10-23 确认 | | 平台导入(表格) | ✅ ⚠️ | 物料、存量供应商、交付记录 | 按外部编号匹配,可重复执行;只在新建时写入的列(如供应商初始状态)用字段默认值和 `readonlyWhen` 表达(PF8);一次导入只能一组匹配字段,境内 / 境外供应商分两遍(PF9);同一批里的跨行规则拦不住(PF10) | 2026-10-23 样例表格在 CI 跑两遍通过;模拟「脏」台账试导结论见[导入说明 §6](../implementation/legacy-supplier-import.md#6-试导结果与清洗清单)(PF11、PF12);2026-10-24 交付记录月度表格两遍通过,「脏」表格试导见[交付记录导入说明 §5](../implementation/delivery-import.md#5-试导结果与清洗清单) | | 批量 API `upsert`、`http` 节点、webhook | ✅ ⚠️ | ERP 集成(预留) | 业务字段记同步状态 + 追赶式重推(06 §1.1) | ERP 立项时验证 | -| 通知(`notify`) | ⚠️ | 全部通知 | 只能发给组织内用户,外部联系人的邮件待平台(PF3,主干已修、18.1.0 未带上) | 2026-10-23 | +| 通知(`notify`) | ✅ ⚠️ | 全部通知 | 只能发给组织内用户;门户用户是组织成员(external),供应商侧通知经门户账号发站内信和邮件,用中英文邮件模板按收件人语言渲染(v0.6 U6);没开通门户的联系人仍由经办人转告(PF3);邮件里拼不出带部署地址的完整链接(PF20) | 2026-10-23;2026-11-02 U6 实测 | | 字段历史、审批历史 | ✅ | 审计 | — | 2026-10-23 确认 | | AI 治理(工具调用审计、回答级权限) | ✅(企业版) | AI 用例的审计 | 需企业版许可 | — | | 应用包代码(钩子、插件、函数) | ⚠️ | 不依赖 | 平台支持 QuickJS 沙箱内的事务型钩子(ADR-0034/0102/0119),可被 `OS_DISABLE_AUTHORED_HOOKS` 关闭;本设计按 ADR-0001 不依赖 | 2026-10-23 更正(原文「托管云不执行代码」有误) | @@ -258,7 +258,7 @@ test/ # 仓库根目录 |---|---|---|---|---| | PF1 | objectstack#25570(原 #25442)定时 flow 的延迟与漏跑保证 | 提醒、考评、扫描 | 追赶式扫描 | 平台不纳入计划;追赶式扫描为长期做法 | | PF2 | objectstack#25234(原 #25443)运行时创建岗位 → 平台改以声明式成员键回应 | 门户开通 | 管理员手工建岗位 | 18.1.0 已发布 `ObjectSchema.membershipKeys`;U2 实测通过,2026-11-02 改用(ADR-0010) | -| PF3 | objectstack#25571(原 #25444)通知组织外联系人 | 供应商邮件通知 | 门户内通知 + 人工邮件 | 主干已修,18.1.0 未带上 | +| PF3 | objectstack#25571(原 #25444)通知组织外联系人 | 没开通门户的供应商联系人的邮件通知 | 开通门户的联系人是组织成员,直接发(v0.6 U6);没开通的由内部经办人转告 | 主干已修,18.1.0 未带上;门户上线后影响只剩没开通门户的联系人 | | PF4 | objectstack#25572(原 #25445)公开表单附件 | 不再依赖(2026-10-24 决定公开表单不收附件) | 附件在供应商注册账户、登录后上传 | 主干已修;决定不变 | | PF5 | objectstack#25573(原 #25446)禁止自审开关 | 职责分离 | `expression` 改派;信息变更单每级通过后读审批记录比对,撞人时重开同级审批(03 §5) | 主干已修,18.1.0 未带上 | | PF6 | objectstack#25233 门户用户可列出全组织用户 | 门户隔离 | v0.6 阻塞;见 D8 | 18.1.0 已修:组织角色 `external`(04 §3.3);V1 已通过(2026-11-02) | @@ -275,6 +275,7 @@ test/ # 仓库根目录 | PF17 | objectstack#25251 自动化不得以系统身份写岗位任命、权限集授予、邀请和成员(裁定 B);邀请只经组织接口(V2、V3) | 门户开通、回收 | 组织管理员在成员页邀请、移出组织;任命与门户管理员授权由 `runAs: 'user'` 的「开通门户」flow 在组织管理员操作时写;处置 flow 不撤任命,改为通知组织管理员(04 §3.3) | 平台裁定,长期做法(2026-11-02 实测登记,不另提 issue) | | PF18 | objectstack#29050 直接触发 flow(REST trigger)不按启动人的权限读 `recordId`,按钮入口(#16370)会答「不存在」 | 门户能启动的系统身份 screen flow(确认索赔、申诉、提交变更申请、准入补充后重新提交) | 每条 flow 进门自己查名册(`portal_user` = 本人、`active == true`,与成员键同条件),元数据测试守住(04 §3.7) | 2026-11-02 新提 | | PF19 | 平台既定行为(objectstack#3407 起):数据层剔除对只读字段的写入,flow 节点只在步骤结果里给警告(`droppedFields`),写入照样算成功;`runAs: 'user'` 的 flow 因此写不进字段级只读的 `portal_user` | 门户开通(名册写入) | 「开通门户」以 `runAs: 'user'` 任命门户岗位,名册由系统身份的子 flow `srm_portal_roster_write` 写;平台拒绝直接启动系统身份的 autolaunched flow,子 flow 只经开通 flow 进入(04 §3.3);`test/portal-invitation.test.ts` 的探针盯着平台行为 | 平台既定行为,长期做法(2026-11-02 U3 实测,总监裁定不另提 issue) | +| PF20 | objectstack#29095 邮件模板的渲染上下文只有 `appName`,没有部署地址(`OS_BASE_URL`),flow 发出的邮件拼不出能点开的完整链接 | 供应商侧通知邮件(05 §5) | 邮件正文写「登录门户查看」和单号,站内信带单据链接;模板数据给出相对路径 `path`,客户可在模板覆盖层写上自己的门户域名 | 2026-11-02 U6 新提 | 其他代码和文档里指向平台的临时做法,2026-10-26 核对结果: diff --git a/src/core/flows/admission/application-supplier-notice.flow.ts b/src/core/flows/admission/application-supplier-notice.flow.ts new file mode 100644 index 0000000..5f75969 --- /dev/null +++ b/src/core/flows/admission/application-supplier-notice.flow.ts @@ -0,0 +1,63 @@ +import type * as Automation from '@objectstack/spec/automation'; +import { loadRoster, loadVendor, notifySupplier, pathOf, rosterContact, vendorName } from '../portal/supplier-notice.js'; + +// 准入申请进展通知供应商(05 §5「准入申请已受理 / 已通过」与 03 §3 第 4 步「退回补充」,v0.6 U6)。「未通过」在 +// srm_admission_rejection 回收门户之前发(回收后名册上就没有申请联系人了)。 +// 收件人是申请联系人(门户提交时由 srm_application_submit 写入的名册行)在名册上的门户账号,不发 created_by; +// 联系人停用或门户被收回后名册上没有这一行,就不再发。内部登记的申请没有申请联系人,不发供应商,内部通知不变。 +// 受理 = 资料审核首次开始(从「已提交」进入);从「退回补充」回到资料审核是供应商自己重新提交的,不再通知。 +const SOURCE = 'srm_supplier_application'; +const changedTo = (status: string) => `record.status == '${status}' && has(previous.status) && previous.status != '${status}'`; +const accepted = `record.status == 'doc_review' && has(previous.status) && previous.status == 'submitted'`; +const events: Array<[key: string, condition: string, template: string, label: string]> = [ + ['accepted', accepted, 'srm_application_accepted', '已受理'], + ['returned', changedTo('returned'), 'srm_application_returned', '退回补充'], + ['approved', changedTo('approved'), 'srm_application_approved', '已通过'], +]; + +export const ApplicationSupplierNoticeFlow: Automation.Flow = { + name: 'srm_application_supplier_notice', + label: '准入申请进展通知供应商', + type: 'record_change', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: '门户提交的申请受理、退回补充或通过', + config: { + objectName: SOURCE, + triggerType: 'record-after-update', + condition: { + dialect: 'cel', + source: `has(record.contact) && record.contact != null && (${events.map(([, cond]) => `(${cond})`).join(' || ')})`, + }, + }, + }, + loadRoster('record.supplier'), + loadVendor('record.supplier'), + { id: 'by_event', type: 'decision', label: '哪一种进展?' }, + ...events.map(([key, , template, label]) => + notifySupplier(`notify_${key}`, `通知申请联系人:${label}`, { + recipients: rosterContact('record.contact'), + template, + data: { no: 'record.application_no', supplier: vendorName, path: pathOf(SOURCE, 'record.id') }, + sourceObject: SOURCE, + sourceId: 'record.id', + }), + ), + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'load_roster', type: 'default' }, + { id: 'e3', source: 'load_roster', target: 'load_vendor', type: 'default' }, + { id: 'e4', source: 'load_vendor', target: 'by_event', type: 'default' }, + ...events.flatMap(([key, condition, , label]) => [ + { id: `e_${key}`, source: 'by_event', target: `notify_${key}_guard`, type: 'conditional' as const, condition: { dialect: 'cel' as const, source: condition }, label }, + { id: `e_${key}_end`, source: `notify_${key}_guard`, target: 'end', type: 'default' as const }, + ]), + ], +}; diff --git a/src/core/flows/admission/rejection.flow.ts b/src/core/flows/admission/rejection.flow.ts index 88f34cf..cab0ba4 100644 --- a/src/core/flows/admission/rejection.flow.ts +++ b/src/core/flows/admission/rejection.flow.ts @@ -2,11 +2,13 @@ import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; import { loadOrgAdmins, notifyOrgAdmins } from '../portal/org-admins.js'; import { clearPortalUsers, revokeInvitations } from '../portal/revoke.js'; +import { loadRoster, notifySupplier, rosterContact } from '../portal/supplier-notice.js'; // P1 步骤 8(03 §3):申请未通过,供应商从「准入中」退回——普通申请回「潜在」, // 重新准入(reapply)的旧档回「淘汰」。黑名单申请在受理时就已驳回,供应商一直是黑名单,不动。 // v0.6 起同时回收门户账号:清掉该供应商全部联系人上的 portal_user,门户邀请撤销,有门户账号时通知组织管理员撤销 // 门户岗位任命并移出组织(04 §3.3)。再申请要重新邀请、重新开通。 +// 回收前先通知申请联系人「准入申请未通过」(v0.6 U6):回收后名册上就没有他了,所以这一条不放在 srm_application_supplier_notice 里。 export const AdmissionRejectionFlow: Automation.Flow = { name: 'srm_admission_rejection', label: '准入未通过', @@ -44,6 +46,20 @@ export const AdmissionRejectionFlow: Automation.Flow = { label: '有门户账号?', config: { objectName: 'srm_supplier_contact', filter: { supplier: cel`record.supplier`, portal_user: { $ne: null } }, outputVariable: 'portalContact' }, }, + { + id: 'load_vendor_name', + type: 'get_record', + label: '读取供应商名称', + config: { objectName: 'srm_supplier', filter: { id: cel`record.supplier` }, fields: ['name'], outputVariable: 'vendor' }, + }, + loadRoster('record.supplier'), + notifySupplier('notify_applicant', '通知申请联系人:未通过', { + recipients: `has(record.contact) && record.contact != null ? ${rosterContact('record.contact')} : []`, + template: 'srm_application_rejected', + data: { no: 'record.application_no', supplier: `vendor != null ? vendor.name : '-'`, path: `'/srm_supplier_application/' + record.id` }, + sourceObject: 'srm_supplier_application', + sourceId: 'record.id', + }), clearPortalUsers('revoke_portal', '回收门户账号', { supplier: cel`record.supplier` }), revokeInvitations('revoke_invitations', { supplier: cel`record.supplier` }), { id: 'had_portal', type: 'decision', label: '需要通知组织管理员?' }, @@ -83,7 +99,10 @@ export const AdmissionRejectionFlow: Automation.Flow = { }, { id: 'e2', source: 'supplier_eliminated', target: 'load_portal', type: 'default' }, { id: 'e3', source: 'supplier_potential', target: 'load_portal', type: 'default' }, - { id: 'e4', source: 'load_portal', target: 'revoke_portal', type: 'default' }, + { id: 'e4', source: 'load_portal', target: 'load_vendor_name', type: 'default' }, + { id: 'e4a', source: 'load_vendor_name', target: 'load_roster', type: 'default' }, + { id: 'e4b', source: 'load_roster', target: 'notify_applicant_guard', type: 'default' }, + { id: 'e4c', source: 'notify_applicant_guard', target: 'revoke_portal', type: 'default' }, { id: 'e5', source: 'revoke_portal', target: 'revoke_invitations', type: 'default' }, { id: 'e6', source: 'revoke_invitations', target: 'had_portal', type: 'default' }, { id: 'e_had', source: 'had_portal', target: 'load_vendor', type: 'conditional', condition: P`portalContact != null`, label: '有门户账号' }, diff --git a/src/core/flows/bank-account/bank-supplier-notice.flow.ts b/src/core/flows/bank-account/bank-supplier-notice.flow.ts new file mode 100644 index 0000000..ae64075 --- /dev/null +++ b/src/core/flows/bank-account/bank-supplier-notice.flow.ts @@ -0,0 +1,47 @@ +import type * as Automation from '@objectstack/spec/automation'; +import { loadPortalAdmins, loadRoster, loadVendor, notifySupplier, pathOf, portalAdmins, vendorName } from '../portal/supplier-notice.js'; + +// 银行账户核验结果通知供应商(05 §5「银行账户与变更单结果」,v0.6 U6):待核验的账户由应付会计核验为有效 +// (srm_bank_verify),或经银行账户变更单生效时,发给该供应商名册上的门户管理员(银行账户由门户管理员维护,04 §3.2)。 +// 通知里只写开户行,不写账号(05 §5)。 +const SOURCE = 'srm_supplier_bank_account'; + +export const BankSupplierNoticeFlow: Automation.Flow = { + name: 'srm_bank_supplier_notice', + label: '银行账户核验通知供应商', + type: 'record_change', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: '账户由待核验变为有效', + config: { + objectName: SOURCE, + triggerType: 'record-after-update', + condition: { dialect: 'cel', source: `record.status == 'active' && has(previous.status) && previous.status == 'pending_verify'` }, + }, + }, + loadRoster('record.supplier'), + loadPortalAdmins(), + loadVendor('record.supplier'), + notifySupplier('notify_admins', '通知供应商门户管理员:账户已核验', { + recipients: portalAdmins, + template: 'srm_bank_account_verified', + data: { supplier: vendorName, bank: 'record.bank_name', path: pathOf(SOURCE, 'record.id') }, + sourceObject: SOURCE, + sourceId: 'record.id', + }), + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'load_roster', type: 'default' }, + { id: 'e2', source: 'load_roster', target: 'load_portal_admins', type: 'default' }, + { id: 'e3', source: 'load_portal_admins', target: 'load_vendor', type: 'default' }, + { id: 'e4', source: 'load_vendor', target: 'notify_admins_guard', type: 'default' }, + { id: 'e5', source: 'notify_admins_guard', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/change-request/change-approval.flow.ts b/src/core/flows/change-request/change-approval.flow.ts index c2d381f..0b53b9f 100644 --- a/src/core/flows/change-request/change-approval.flow.ts +++ b/src/core/flows/change-request/change-approval.flow.ts @@ -14,6 +14,24 @@ const snapshotOf = (v: string, keys: string[]) => ({ source: `{${keys.map((k) => `'${k}': has(${v}.${k}) ? ${v}.${k} : null`).join(', ')}}`, }); +// 发起人是门户用户(门户管理员经 srm_change_submit 提交)时,结果由 srm_change_supplier_notice 以中英文模板发给供应商的 +// 门户管理员,这里不再发内部措辞的通知(v0.6 U6) +const portalCreator = (key: string): Automation.FlowNode[] => [ + { + id: `${key}_creator`, + type: 'get_record', + label: '发起人是门户用户?', + config: { objectName: 'sys_user_position', filter: { user_id: cel`record.created_by`, position: 'srm_portal_user' }, outputVariable: `${key}PortalCreator` }, + }, + { id: `${key}_internal`, type: 'decision', label: '内部发起?' }, +]; +const portalCreatorEdges = (key: string, from: string, notify: string): Automation.FlowEdge[] => [ + { id: `e_${key}_creator`, source: from, target: `${key}_creator`, type: 'default' }, + { id: `e_${key}_check`, source: `${key}_creator`, target: `${key}_internal`, type: 'default' }, + { id: `e_${key}_internal`, source: `${key}_internal`, target: notify, type: 'conditional', condition: { dialect: 'cel', source: `${key}PortalCreator == null` }, label: '内部' }, + { id: `e_${key}_portal`, source: `${key}_internal`, target: 'end', type: 'conditional', condition: { dialect: 'cel', source: `${key}PortalCreator != null` }, label: '门户' }, +]; + const approval = (label: string, position: string) => ({ type: 'approval' as const, label, @@ -180,6 +198,7 @@ export const ChangeApprovalFlow: Automation.Flow = { label: '写回档案', config: { flowName: 'srm_change_apply', input: { recordId: cel`record.id` } }, }, + ...portalCreator('applied'), { id: 'notify_applied', type: 'notify', @@ -209,6 +228,7 @@ export const ChangeApprovalFlow: Automation.Flow = { }, }, }, + ...portalCreator('rejected'), { id: 'notify_rejected', type: 'notify', @@ -323,12 +343,12 @@ export const ChangeApprovalFlow: Automation.Flow = { { id: 'e_f_again', source: 'finance_self', target: 'finance_review', type: 'back', label: '重新复核' }, { id: 'e_f_next', source: 'record_finance', target: 'apply', type: 'default' }, - { id: 'e_applied', source: 'apply', target: 'notify_applied', type: 'default' }, + ...portalCreatorEdges('applied', 'apply', 'notify_applied'), { id: 'e_applied_end', source: 'notify_applied', target: 'end', type: 'default' }, { id: 'e_p_rejected', source: 'purchase_reject_action', target: 'mark_rejected', type: 'default' }, { id: 'e_f_rejected', source: 'finance_reject_action', target: 'mark_rejected', type: 'default' }, - { id: 'e_rejected', source: 'mark_rejected', target: 'notify_rejected', type: 'default' }, + ...portalCreatorEdges('rejected', 'mark_rejected', 'notify_rejected'), { id: 'e_rejected_end', source: 'notify_rejected', target: 'end', type: 'default' }, { id: 'e_resubmit', source: 'revise_window', target: 'purchase_review', type: 'back', label: 'resubmit' }, diff --git a/src/core/flows/change-request/change-supplier-notice.flow.ts b/src/core/flows/change-request/change-supplier-notice.flow.ts new file mode 100644 index 0000000..0fb0653 --- /dev/null +++ b/src/core/flows/change-request/change-supplier-notice.flow.ts @@ -0,0 +1,63 @@ +import type * as Automation from '@objectstack/spec/automation'; +import { loadPortalAdmins, loadRoster, loadVendor, notifySupplier, pathOf, portalAdmins, vendorName } from '../portal/supplier-notice.js'; + +// 信息变更单结果通知供应商(05 §5「银行账户与变更单结果」,v0.6 U6):变更单生效、驳回或退回修改时,发给该供应商名册上的 +// 门户管理员(关键信息由门户管理员经「提交变更申请」提交,退回的由门户管理员改完重新提交,04 §3.2)。 +// 内部发起人的通知仍在 srm_change_approval 里;发起人本人是门户用户时那边不再发,只收这里的中英文通知。 +const SOURCE = 'srm_supplier_change_request'; +const events: Array<[key: string, condition: string, template: string, label: string]> = [ + ['applied', `record.status == 'applied' && has(previous.status) && previous.status != 'applied'`, 'srm_change_applied', '已生效'], + ['rejected', `record.status == 'rejected' && has(previous.status) && previous.status != 'rejected'`, 'srm_change_rejected', '已驳回'], + [ + 'returned', + `record.approval_status == 'returned' && !(has(previous.approval_status) && previous.approval_status == 'returned')`, + 'srm_change_returned', + '退回修改', + ], +]; + +export const ChangeSupplierNoticeFlow: Automation.Flow = { + name: 'srm_change_supplier_notice', + label: '变更单结果通知供应商', + type: 'record_change', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: '变更单生效、驳回或退回修改', + config: { + objectName: SOURCE, + triggerType: 'record-after-update', + condition: { dialect: 'cel', source: events.map(([, cond]) => `(${cond})`).join(' || ') }, + }, + }, + loadRoster('record.supplier'), + loadPortalAdmins(), + loadVendor('record.supplier'), + { id: 'by_event', type: 'decision', label: '哪一种结果?' }, + ...events.map(([key, , template, label]) => + notifySupplier(`notify_${key}`, `通知供应商门户管理员:${label}`, { + recipients: portalAdmins, + template, + data: { no: 'record.change_no', supplier: vendorName, path: pathOf(SOURCE, 'record.id') }, + sourceObject: SOURCE, + sourceId: 'record.id', + }), + ), + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'load_roster', type: 'default' }, + { id: 'e2', source: 'load_roster', target: 'load_portal_admins', type: 'default' }, + { id: 'e3', source: 'load_portal_admins', target: 'load_vendor', type: 'default' }, + { id: 'e4', source: 'load_vendor', target: 'by_event', type: 'default' }, + ...events.flatMap(([key, condition, , label]) => [ + { id: `e_${key}`, source: 'by_event', target: `notify_${key}_guard`, type: 'conditional' as const, condition: { dialect: 'cel' as const, source: condition }, label }, + { id: `e_${key}_end`, source: `notify_${key}_guard`, target: 'end', type: 'default' as const }, + ]), + ], +}; diff --git a/src/core/flows/disposition/disposition-apply.flow.ts b/src/core/flows/disposition/disposition-apply.flow.ts index 199b9a5..6755e59 100644 --- a/src/core/flows/disposition/disposition-apply.flow.ts +++ b/src/core/flows/disposition/disposition-apply.flow.ts @@ -2,6 +2,7 @@ import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; import { loadOrgAdmins, notifyOrgAdmins } from '../portal/org-admins.js'; import { clearPortalUsers, revokeInvitations } from '../portal/revoke.js'; +import { byFunction, loadRoster, notifySupplier } from '../portal/supplier-notice.js'; // 执行供应商处置(02 §5.1,03 §5):审批全部通过后由 srm_disposition_approval 调用,以系统身份改供应商 // 状态和品类资格,处置单置为「已执行」。 @@ -10,6 +11,7 @@ import { clearPortalUsers, revokeInvitations } from '../portal/revoke.js'; // 「指定品类」冻结的),原本就暂停的资格不动;全部品类时供应商→合格; // - 淘汰 / 拉入黑名单:供应商→淘汰 / 黑名单,品类资格全部暂停并清掉冻结记录,重新准入时按申请品类恢复; // 同时清掉该供应商联系人上的门户账号,门户访问在下一个请求就失效(ADR-0010,04 §3.3);重新准入后要再开通门户; +// 回收前先通知供应商主账号联系人(v0.6 U6,回收后名册就空了;冻结、解冻的通知在 srm_disposition_supplier_notice); // - 解除黑名单:供应商→淘汰,之后只能重新走准入(FR-208)。 // 拉入黑名单同时写供应商的黑名单名录字段(拉黑日期、禁入期满日、处置单、原因,v0.5)。禁入期限取 // params/core.yaml 的 blacklist_years(示例 5 年),客户改参数时同步 BLACKLIST_YEARS。 @@ -104,6 +106,23 @@ export const DispositionApplyFlow: Automation.Flow = { blacklist_reason: cel`disp.reason`, }), suspendAll('suspend_all'), + loadRoster('disp.supplier'), + { + id: 'load_notice_vendor', + type: 'get_record', + label: '读取供应商名称', + config: { objectName: 'srm_supplier', filter: { id: cel`disp.supplier` }, fields: ['name'], outputVariable: 'vendor' }, + }, + { id: 'by_exit', type: 'decision', label: '淘汰还是黑名单?' }, + ...(['eliminate', 'blacklist'] as const).map((action) => + notifySupplier(`notify_${action}`, `通知供应商主账号:${action === 'eliminate' ? '已淘汰' : '已拉入黑名单'}`, { + recipients: byFunction('admin'), + template: action === 'eliminate' ? 'srm_supplier_eliminated' : 'srm_supplier_blacklisted', + data: { supplier: `vendor != null ? vendor.name : '-'`, path: `'/srm_supplier/' + disp.supplier` }, + sourceObject: 'srm_supplier', + sourceId: 'disp.supplier', + }), + ), // 撤销门户访问(ADR-0010,04 §3.3):清掉该供应商全部联系人上的 portal_user,门户邀请一并撤销,通知组织管理员 // 撤销门户岗位任命并把门户用户移出组织(系统身份写不了任命,objectstack#25251) { @@ -195,7 +214,13 @@ export const DispositionApplyFlow: Automation.Flow = { { id: 'e_el1', source: 'eliminate_supplier', target: 'suspend_all', type: 'default' }, { id: 'e_blacklist', source: 'by_action', target: 'blacklist_supplier', type: 'conditional', condition: P`disp.action == 'blacklist'`, label: '拉入黑名单' }, { id: 'e_bl1', source: 'blacklist_supplier', target: 'suspend_all', type: 'default' }, - { id: 'e_suspended', source: 'suspend_all', target: 'load_portal', type: 'default' }, + { id: 'e_suspended', source: 'suspend_all', target: 'load_roster', type: 'default' }, + { id: 'e_nt1', source: 'load_roster', target: 'load_notice_vendor', type: 'default' }, + { id: 'e_nt2', source: 'load_notice_vendor', target: 'by_exit', type: 'default' }, + { id: 'e_nt3', source: 'by_exit', target: 'notify_eliminate_guard', type: 'conditional', condition: P`disp.action == 'eliminate'`, label: '淘汰' }, + { id: 'e_nt4', source: 'by_exit', target: 'notify_blacklist_guard', type: 'conditional', condition: P`disp.action == 'blacklist'`, label: '拉入黑名单' }, + { id: 'e_nt5', source: 'notify_eliminate_guard', target: 'load_portal', type: 'default' }, + { id: 'e_nt6', source: 'notify_blacklist_guard', target: 'load_portal', type: 'default' }, { id: 'e_portal', source: 'load_portal', target: 'revoke_portal', type: 'default' }, { id: 'e_revoked', source: 'revoke_portal', target: 'revoke_invitations', type: 'default' }, { id: 'e_rv0', source: 'revoke_invitations', target: 'had_portal', type: 'default' }, diff --git a/src/core/flows/disposition/disposition-supplier-notice.flow.ts b/src/core/flows/disposition/disposition-supplier-notice.flow.ts new file mode 100644 index 0000000..49802e3 --- /dev/null +++ b/src/core/flows/disposition/disposition-supplier-notice.flow.ts @@ -0,0 +1,50 @@ +import type * as Automation from '@objectstack/spec/automation'; +import { byFunction, loadRoster, loadVendor, notifySupplier, pathOf, vendorName } from '../portal/supplier-notice.js'; + +// 冻结 / 解冻结果通知供应商(03 §5「通知供应商」,v0.6 U6):处置单执行后发给该供应商名册上的主账号(admin)联系人。 +// 处置单本身不对门户开放(04 §3.2),通知链接到公司资料,只写冻结范围,不写处置原因。淘汰、拉入黑名单在 +// srm_disposition_apply 回收门户之前发(回收后名册就空了);解除黑名单时门户早已回收,不发。 +const SOURCE = 'srm_supplier_disposition'; +const applied = (action: string) => `record.status == 'applied' && has(previous.status) && previous.status != 'applied' && record.action == '${action}'`; +// 范围两种语言都看得懂,不随收件人语言变 +const scope = `has(record.scope) && record.scope == 'category' ? '部分品类 / some categories' : '全部品类 / all categories'`; + +export const DispositionSupplierNoticeFlow: Automation.Flow = { + name: 'srm_disposition_supplier_notice', + label: '冻结与解冻通知供应商', + type: 'record_change', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: '冻结或解冻处置单已执行', + config: { objectName: SOURCE, triggerType: 'record-after-update', condition: { dialect: 'cel', source: `(${applied('freeze')}) || (${applied('unfreeze')})` } }, + }, + loadRoster('record.supplier'), + loadVendor('record.supplier'), + { id: 'by_action', type: 'decision', label: '冻结还是解冻?' }, + ...(['freeze', 'unfreeze'] as const).map((action) => + notifySupplier(`notify_${action}`, `通知供应商主账号:${action === 'freeze' ? '已冻结' : '已解冻'}`, { + recipients: byFunction('admin'), + template: action === 'freeze' ? 'srm_supplier_frozen' : 'srm_supplier_unfrozen', + data: { supplier: vendorName, scope, path: pathOf('srm_supplier', 'record.supplier') }, + sourceObject: 'srm_supplier', + sourceId: 'record.supplier', + }), + ), + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'load_roster', type: 'default' }, + { id: 'e2', source: 'load_roster', target: 'load_vendor', type: 'default' }, + { id: 'e3', source: 'load_vendor', target: 'by_action', type: 'default' }, + { id: 'e_freeze', source: 'by_action', target: 'notify_freeze_guard', type: 'conditional', condition: { dialect: 'cel', source: `record.action == 'freeze'` }, label: '冻结' }, + { id: 'e_unfreeze', source: 'by_action', target: 'notify_unfreeze_guard', type: 'conditional', condition: { dialect: 'cel', source: `record.action == 'unfreeze'` }, label: '解冻' }, + { id: 'e4', source: 'notify_freeze_guard', target: 'end', type: 'default' }, + { id: 'e5', source: 'notify_unfreeze_guard', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/index.ts b/src/core/flows/index.ts index a9e30c3..fa611f1 100644 --- a/src/core/flows/index.ts +++ b/src/core/flows/index.ts @@ -50,3 +50,12 @@ export { InvitationExpireSweepFlow } from './portal/invitation-expire-sweep.flow export { PortalEnableFlow, PortalRosterWriteFlow } from './portal/portal-enable.flow.js'; export { ApplicationSubmitFlow } from './portal/application-submit.flow.js'; export { PortalContactRevokeFlow } from './portal/contact-revoke.flow.js'; +export { PortalOpenedNoticeFlow } from './portal/portal-opened-notice.flow.js'; +export { ApplicationSupplierNoticeFlow } from './admission/application-supplier-notice.flow.js'; +export { ScarSupplierNoticeFlow } from './quality/scar-supplier-notice.flow.js'; +export { CertificateExpiryNoticeFlow } from './qualification/certificate-expiry-notice.flow.js'; +export { CertificateExpiredNoticeFlow } from './qualification/certificate-expired-notice.flow.js'; +export { ReviewSupplierNoticeFlow } from './performance/review-supplier-notice.flow.js'; +export { BankSupplierNoticeFlow } from './bank-account/bank-supplier-notice.flow.js'; +export { ChangeSupplierNoticeFlow } from './change-request/change-supplier-notice.flow.js'; +export { DispositionSupplierNoticeFlow } from './disposition/disposition-supplier-notice.flow.js'; diff --git a/src/core/flows/performance/appeal-resolve.flow.ts b/src/core/flows/performance/appeal-resolve.flow.ts index cfecda3..a9cafb3 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_review_supplier_notice 通知供应商)。 // 结果应用(srm_performance_apply)只在审定发布时触发,这里不重新应用:等级变了时通知供应商各品类的品类经理,核对按原等级 // 开的整改单和生成的处置单草稿(来源考评单为本单),不需要的由品类经理作废,新等级需要处置的另行发起(03 §10 第 7 步)。 // 处理人不能是本考评单的评分人(同审定发布的职责分离)。以系统身份运行,进门检查在 flow 里做。 @@ -139,6 +139,19 @@ export const AppealResolveFlow: Automation.Flow = { fields: { grade: cel`gradeValue`, latest_score: cel`totalScore`, preferred: c(`gradeValue == 'grade_a'`) }, }, }, + // 申诉提交人是门户用户(门户管理员在门户提交)时,结果由 srm_review_supplier_notice 以中英文模板发给供应商主账号联系人, + // 这里不再发「请把结果告知供应商」(v0.6 U6) + { + id: 'load_recorder', + type: 'get_record', + label: '申诉提交人是门户用户?', + config: { + objectName: 'sys_user_position', + filter: { user_id: c(`has(review.appeal_recorded_by) && review.appeal_recorded_by != null ? review.appeal_recorded_by : ''`), position: 'srm_portal_user' }, + outputVariable: 'portalRecorder', + }, + }, + { id: 'recorder_internal', type: 'decision', label: '内部代录?' }, { id: 'notify', type: 'notify', @@ -214,8 +227,25 @@ export const AppealResolveFlow: Automation.Flow = { { id: 'e10', source: 'close', target: 'load_later', type: 'default' }, { id: 'e11', source: 'load_later', target: 'is_latest', type: 'default' }, { id: 'e_latest', source: 'is_latest', target: 'write_supplier', type: 'conditional', condition: c('later == null'), label: '是' }, - { id: 'e_older', source: 'is_latest', target: 'notify', type: 'conditional', condition: c('later != null'), label: '已有更晚的' }, - { id: 'e12', source: 'write_supplier', target: 'notify', type: 'default' }, + { id: 'e_older', source: 'is_latest', target: 'load_recorder', type: 'conditional', condition: c('later != null'), label: '已有更晚的' }, + { id: 'e12', source: 'write_supplier', target: 'load_recorder', type: 'default' }, + { id: 'e12a', source: 'load_recorder', target: 'recorder_internal', type: 'default' }, + { + id: 'e12b', + source: 'recorder_internal', + target: 'notify', + type: 'conditional', + condition: c('portalRecorder == null && has(review.appeal_recorded_by) && review.appeal_recorded_by != null'), + label: '内部代录', + }, + { + id: 'e12c', + source: 'recorder_internal', + target: 'is_regraded', + type: 'conditional', + condition: c('!(portalRecorder == null && has(review.appeal_recorded_by) && review.appeal_recorded_by != null)'), + label: '门户提交', + }, { id: 'e13', source: 'notify', target: 'is_regraded', type: 'default' }, { id: 'e_same', source: 'is_regraded', target: 'end', type: 'conditional', condition: c('!changed'), label: '没变' }, { id: 'e_regraded', source: 'is_regraded', target: 'load_qualifications', type: 'conditional', condition: c('changed'), label: '变了' }, diff --git a/src/core/flows/performance/review-supplier-notice.flow.ts b/src/core/flows/performance/review-supplier-notice.flow.ts new file mode 100644 index 0000000..fb94bd5 --- /dev/null +++ b/src/core/flows/performance/review-supplier-notice.flow.ts @@ -0,0 +1,55 @@ +import type * as Automation from '@objectstack/spec/automation'; +import { byFunction, loadRoster, loadVendor, notifySupplier, pathOf, vendorName } from '../portal/supplier-notice.js'; +import { APPEAL_DAYS } from './params.js'; + +// 绩效结果通知供应商(05 §5「绩效结果发布;申诉处理结果」加供应商 admin 联系人,v0.6 U6):考评单经采购总监审定发布、 +// 申诉处理完回到已发布时,发给该供应商名册上的主账号(admin)联系人。门户只看得到已发布、申诉中的考评单(04 §3.2), +// 通知里也只写单号和期间,不写分数和等级。内部评分人、申诉代录人的通知仍在 srm_performance_publish、srm_appeal_resolve 里。 +const SOURCE = 'srm_performance_review'; +const published = `record.status == 'published' && has(previous.status) && previous.status == 'reviewing'`; +const resolved = `record.status == 'published' && has(previous.status) && previous.status == 'appealed'`; + +export const ReviewSupplierNoticeFlow: Automation.Flow = { + name: 'srm_review_supplier_notice', + label: '绩效结果通知供应商', + type: 'record_change', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: '考评单发布或申诉处理完', + config: { objectName: SOURCE, triggerType: 'record-after-update', condition: { dialect: 'cel', source: `(${published}) || (${resolved})` } }, + }, + loadRoster('record.supplier'), + loadVendor('record.supplier'), + { id: 'by_event', type: 'decision', label: '发布还是申诉结果?' }, + notifySupplier('notify_published', '通知供应商主账号:绩效已发布', { + recipients: byFunction('admin'), + template: 'srm_review_published', + data: { no: 'record.review_no', supplier: vendorName, period: 'record.period', appeal_days: `'${APPEAL_DAYS}'`, path: pathOf(SOURCE, 'record.id') }, + sourceObject: SOURCE, + sourceId: 'record.id', + }), + notifySupplier('notify_resolved', '通知供应商主账号:申诉已处理', { + recipients: byFunction('admin'), + template: 'srm_appeal_resolved', + data: { no: 'record.review_no', supplier: vendorName, period: 'record.period', path: pathOf(SOURCE, 'record.id') }, + sourceObject: SOURCE, + sourceId: 'record.id', + }), + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'load_roster', type: 'default' }, + { id: 'e2', source: 'load_roster', target: 'load_vendor', type: 'default' }, + { id: 'e3', source: 'load_vendor', target: 'by_event', type: 'default' }, + { id: 'e_published', source: 'by_event', target: 'notify_published_guard', type: 'conditional', condition: { dialect: 'cel', source: published }, label: '发布' }, + { id: 'e_resolved', source: 'by_event', target: 'notify_resolved_guard', type: 'conditional', condition: { dialect: 'cel', source: resolved }, label: '申诉结果' }, + { id: 'e4', source: 'notify_published_guard', target: 'end', type: 'default' }, + { id: 'e5', source: 'notify_resolved_guard', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/portal/application-submit.flow.ts b/src/core/flows/portal/application-submit.flow.ts index 9723107..fca2f4c 100644 --- a/src/core/flows/portal/application-submit.flow.ts +++ b/src/core/flows/portal/application-submit.flow.ts @@ -5,7 +5,7 @@ import type * as Automation from '@objectstack/spec/automation'; // 以系统身份运行:门户不授准入申请的新建权,也看不到品类表。系统身份读得到任何记录,而任何成员都能经 trigger 入口直接 // 启动系统身份的 screen flow(objectstack#25481),所以隔离只能靠 flow 自己查名册:调用者必须是页面这家供应商的在职联系人 // (与成员键 portal_supplier_ids 同一条件:portal_user = 调用者、active),否则直接结束;申请品类取门户邀请上品类经理填的品类, -// 来源记为「邀请」,申请回填到邀请。邀请要是发给调用者本人(联系人 = 调用者的名册行)、已开通、还没提交过申请的。之后受理照常由 srm_application_intake 处理(黑名单驳回、淘汰重新准入)。 +// 来源记为「邀请」,申请联系人记为调用者的名册行(进展通知发给他,v0.6 U6),申请回填到邀请。邀请要是发给调用者本人(联系人 = 调用者的名册行)、已开通、还没提交过申请的。之后受理照常由 srm_application_intake 处理(黑名单驳回、淘汰重新准入)。 const c = (source: string) => ({ dialect: 'cel' as const, source }); const portal = `current_user != null && membership != null`; const invited = `invite != null && has(invite.categories) && invite.categories != null && size(invite.categories) > 0`; @@ -84,7 +84,7 @@ export const ApplicationSubmitFlow: Automation.Flow = { label: '新建准入申请', config: { objectName: 'srm_supplier_application', - fields: { supplier: cel`vendor.id`, applied_categories: cel`invite.categories`, source: 'invited' }, + fields: { supplier: cel`vendor.id`, applied_categories: cel`invite.categories`, source: 'invited', contact: cel`membership.id` }, outputVariable: 'app', }, }, diff --git a/src/core/flows/portal/portal-opened-notice.flow.ts b/src/core/flows/portal/portal-opened-notice.flow.ts new file mode 100644 index 0000000..f637d2a --- /dev/null +++ b/src/core/flows/portal/portal-opened-notice.flow.ts @@ -0,0 +1,44 @@ +import { P } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; +import { loadRoster, loadVendor, notifySupplier, pathOf, rosterContact, vendorName } from './supplier-notice.js'; + +// 门户邀请(05 §5「门户邀请」,v0.6 U6):邀请本身是组织管理员在成员页发出的平台组织邀请(04 §3.3,flow 发不了); +// 「开通门户」把联系人登记进名册、邀请置为已开通之后,这里告诉受邀联系人门户已经能用、下一步做什么。 +// 收件人只取名册上这张邀请的联系人(名册条件同成员键:在职、portal_user 有值)。 +export const PortalOpenedNoticeFlow: Automation.Flow = { + name: 'srm_portal_opened_notice', + label: '门户开通通知供应商', + type: 'record_change', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: '门户邀请已开通', + config: { + objectName: 'srm_supplier_invitation', + triggerType: 'record-after-update', + condition: P`record.status == 'accepted' && has(previous.status) && previous.status == 'sent'`, + }, + }, + loadRoster('record.supplier'), + loadVendor('record.supplier'), + notifySupplier('notify_contact', '通知受邀联系人:门户已开通', { + recipients: rosterContact('record.contact'), + template: 'srm_portal_opened', + data: { supplier: vendorName, path: pathOf('srm_supplier', 'record.supplier') }, + sourceObject: 'srm_supplier', + sourceId: 'record.supplier', + }), + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'load_roster', type: 'default' }, + { id: 'e2', source: 'load_roster', target: 'load_vendor', type: 'default' }, + { id: 'e3', source: 'load_vendor', target: 'notify_contact_guard', type: 'default' }, + { id: 'e4', source: 'notify_contact_guard', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/portal/supplier-notice.ts b/src/core/flows/portal/supplier-notice.ts new file mode 100644 index 0000000..9d2db6d --- /dev/null +++ b/src/core/flows/portal/supplier-notice.ts @@ -0,0 +1,109 @@ +import type * as Automation from '@objectstack/spec/automation'; + +// 供应商侧通知(05 §5,v0.6 U6)的公共节点。门户用户是组织成员(组织角色 external),notify 能直接发给他们;收件人一律 +// 从「这条记录所属供应商」的联系人名册里取(ADR-0010 红线 2): +// - 名册条件与成员键 portal_supplier_ids 相同(在职、portal_user 有值),供应商取触发记录自己的 supplier,不接受外部传入; +// - 通知内容走邮件模板(src/core/notifications),模板数据只取触发记录和它所属供应商自己的字段; +// - 邮件和站内信同一份模板,按收件人语言出中文或英文;正文只写单号、事件和「登录门户查看」,不放金额、账号等敏感内容(05 §5、R5)。 +// 内部经办人的通知不经这里,保持原样(没开通门户的供应商由经办人转告,v0.5 行为不变)。 +const c = (source: string) => ({ dialect: 'cel' as const, source }); + +export const ROSTER = 'roster'; + +// 读取供应商的门户名册:supplier 是一段求值为供应商 id 的 CEL(触发记录上的 supplier) +export const loadRoster = (supplier: string, id = 'load_roster'): Automation.FlowNode => ({ + id, + type: 'get_record', + label: '读取供应商的门户名册', + config: { + objectName: 'srm_supplier_contact', + filter: { supplier: c(supplier), active: true, portal_user: { $ne: null } }, + fields: ['id', 'supplier', 'portal_user', 'contact_function'], + limit: 200, + outputVariable: ROSTER, + }, +}); + +const hasFunction = (fn: string) => `has(r.contact_function) && r.contact_function != null && '${fn}' in r.contact_function`; + +// 名册上某职能联系人的门户账号;没有这个职能的门户联系人时发给主账号(admin)联系人 +export const byFunction = (fn: string) => + fn === 'admin' + ? `${ROSTER}.filter(r, ${hasFunction('admin')}).map(r, r.portal_user)` + : `${ROSTER}.exists(r, ${hasFunction(fn)}) ? ${ROSTER}.filter(r, ${hasFunction(fn)}).map(r, r.portal_user)` + + ` : ${ROSTER}.filter(r, ${hasFunction('admin')}).map(r, r.portal_user)`; + +// 名册上任一所列职能的联系人的门户账号(每人一次) +export const byAnyFunction = (fns: string[]) => `${ROSTER}.filter(r, ${fns.map((fn) => `(${hasFunction(fn)})`).join(' || ')}).map(r, r.portal_user)`; + +// 名册上的某一位联系人(如受邀联系人)的门户账号 +export const rosterContact = (contact: string) => `${ROSTER}.filter(r, r.id == ${contact}).map(r, r.portal_user)`; + +// 门户管理员:名册上的门户账号中任命了 srm_portal_admin 的(银行账户、变更单由门户管理员维护,04 §3.2) +export const PORTAL_ADMINS = 'portalAdmins'; +export const loadPortalAdmins = (id = 'load_portal_admins'): Automation.FlowNode => ({ + id, + type: 'get_record', + label: '读取名册上的门户管理员', + config: { + objectName: 'sys_user_position', + filter: { position: 'srm_portal_admin', user_id: { $in: c(`${ROSTER}.map(r, r.portal_user)`) } }, + fields: ['user_id'], + limit: 200, + outputVariable: PORTAL_ADMINS, + }, +}); +export const portalAdmins = `${PORTAL_ADMINS}.map(p, p.user_id)`; + +// 这个用户是名册上的门户账号吗(内部经办人的通知据此跳过门户用户,避免同一件事收到两条) +export const onRoster = (user: string) => `${ROSTER}.exists(r, r.portal_user == ${user})`; + +// 发给供应商:收件人、模板名、模板数据(每项一段 CEL)、链接到的记录。没有可发的人或投递出错不影响业务写入:包在 try_catch 里 +export const notifySupplier = ( + id: string, + label: string, + config: { recipients: string; template: string; data: Record; sourceObject: string; sourceId: string }, +): Automation.FlowNode => + ({ + id: `${id}_guard`, + type: 'try_catch', + label, + config: { + try: { + nodes: [ + { + id, + type: 'notify', + label, + config: { + recipients: c(config.recipients), + template: config.template, + templateData: Object.fromEntries(Object.entries(config.data).map(([k, v]) => [k, c(v)])), + channels: ['inbox', 'email'], + topic: 'srm_supplier', + sourceObject: config.sourceObject, + sourceId: c(config.sourceId), + }, + }, + ], + }, + catch: { nodes: [{ id: `${id}_skip`, type: 'assignment', label: '跳过通知' }] }, + }, + }) as Automation.FlowNode; + +// 模板里常用的「可能为空的文本字段」 +export const text = (path: string, fallback = '-') => { + const [obj, field] = [path.slice(0, path.lastIndexOf('.')), path.slice(path.lastIndexOf('.') + 1)]; + return `has(${obj}.${field}) && ${path} != null ? string(${path}) : '${fallback}'`; +}; + +// 读取触发记录所属供应商的名称(模板里的「贵司」) +export const VENDOR = 'vendor'; +export const loadVendor = (supplier: string, id = 'load_vendor'): Automation.FlowNode => ({ + id, + type: 'get_record', + label: '读取供应商名称', + config: { objectName: 'srm_supplier', filter: { id: c(supplier) }, fields: ['id', 'name'], outputVariable: VENDOR }, +}); +export const vendorName = `${VENDOR} != null ? ${VENDOR}.name : '-'`; +export const pathOf = (object: string, id: string) => `'/${object}/' + ${id}`; diff --git a/src/core/flows/qualification/certificate-expired-notice.flow.ts b/src/core/flows/qualification/certificate-expired-notice.flow.ts new file mode 100644 index 0000000..7520d2d --- /dev/null +++ b/src/core/flows/qualification/certificate-expired-notice.flow.ts @@ -0,0 +1,34 @@ +import type * as Automation from '@objectstack/spec/automation'; +import { certificateNoticeEdges, certificateNoticeNodes } from './certificate-notice.js'; + +// 证书过期通知(05 §5「证书已过期」,03 §11):srm_certificate_expire_sweep 把证书置为「已过期」时(或有人手工置为已过期), +// 通知 SQE 与供应商联系人。资质异常标志和风险事件由 srm_supplier_qualification_recalc 处理,这里只通知。 +export const CertificateExpiredNoticeFlow: Automation.Flow = { + name: 'srm_certificate_expired_notice', + label: '证书过期通知', + type: 'record_change', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: '证书变为已过期', + config: { + objectName: 'srm_supplier_certificate', + triggerType: 'record-after-update', + condition: { dialect: 'cel', source: `record.status == 'expired' && has(previous.status) && previous.status != 'expired'` }, + }, + }, + ...certificateNoticeNodes({ + template: 'srm_certificate_expired', + title: '{{ vendor.name }} 的资质证书已过期', + message: '证书编号 {{ record.cert_no }},有效期至 {{ record.valid_to }},已过期。必备资质过期时供应商标记资质异常;请督促供应商在门户上传新证书,收到后核验。', + severity: 'warning', + }), + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: certificateNoticeEdges('start'), +}; diff --git a/src/core/flows/qualification/certificate-expiry-notice.flow.ts b/src/core/flows/qualification/certificate-expiry-notice.flow.ts new file mode 100644 index 0000000..4e86879 --- /dev/null +++ b/src/core/flows/qualification/certificate-expiry-notice.flow.ts @@ -0,0 +1,38 @@ +import type * as Automation from '@objectstack/spec/automation'; +import { certificateNoticeEdges, certificateNoticeNodes } from './certificate-notice.js'; + +// P10 到期提醒(03 §11,FR-901):未过期的证书在有效期至前 30 / 15 / 7 天各提醒一次,通知 SQE 与供应商联系人 +// (v0.6 U6 起供应商侧经门户通知)。平台按天扫描、每张证书每个提醒日只发一次(time_relative 触发)。 +// 状态由 srm_certificate_expire_sweep 对齐,这里只提醒。 +export const CertificateExpiryNoticeFlow: Automation.Flow = { + name: 'srm_certificate_expiry_notice', + label: '证书到期提醒', + type: 'schedule', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: '有效期至前 30 / 15 / 7 天', + config: { + timeRelative: { + object: 'srm_supplier_certificate', + dateField: 'valid_to', + offsetDays: [30, 15, 7], + filter: { status: { $in: ['valid', 'expiring'] } }, + }, + }, + }, + ...certificateNoticeNodes({ + template: 'srm_certificate_expiring', + title: '{{ vendor.name }} 的资质证书将于 {{ record.valid_to }} 到期', + message: '证书编号 {{ record.cert_no }},有效期至 {{ record.valid_to }}。请督促供应商换证并在门户上传新证书,收到后核验。', + severity: 'info', + }), + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: certificateNoticeEdges('start'), +}; diff --git a/src/core/flows/qualification/certificate-notice.ts b/src/core/flows/qualification/certificate-notice.ts new file mode 100644 index 0000000..eed9b18 --- /dev/null +++ b/src/core/flows/qualification/certificate-notice.ts @@ -0,0 +1,76 @@ +import type * as Automation from '@objectstack/spec/automation'; +import { byAnyFunction, loadRoster, loadVendor, notifySupplier, pathOf, vendorName } from '../portal/supplier-notice.js'; + +// 证书到期提醒和过期通知共用的节点(05 §5「证书即将到期 / 已过期」,03 §11):内部发给该供应商各品类的 SQE(品类都没有 +// SQE 时发质量经理,与 srm_certificate_change_check 同一取法),供应商侧发名册上的主账号与质量联系人。 +const c = (source: string) => ({ dialect: 'cel' as const, source }); +const SOURCE = 'srm_supplier_certificate'; + +export const certificateNoticeNodes = (opts: { template: string; title: string; message: string; severity: 'info' | 'warning' }): Automation.FlowNode[] => [ + { + id: 'load_grants', + type: 'get_record', + label: '读取品类资格', + config: { objectName: 'srm_supplier_category', filter: { supplier: c('record.supplier') }, fields: ['category'], limit: 500, outputVariable: 'grants' }, + }, + { + id: 'load_categories', + type: 'get_record', + label: '读取品类的 SQE', + config: { objectName: 'srm_category', filter: { id: { $in: c('grants.map(g, g.category)') } }, fields: ['sqe'], limit: 500, outputVariable: 'categories' }, + }, + { + id: 'load_quality_managers', + type: 'get_record', + label: '读取质量经理', + config: { objectName: 'sys_user_position', filter: { position: 'srm_quality_manager' }, limit: 50, outputVariable: 'qualityManagers' }, + }, + loadRoster('record.supplier'), + loadVendor('record.supplier'), + { + id: 'notify_sqe_guard', + type: 'try_catch', + label: '通知 SQE', + config: { + try: { + nodes: [ + { + id: 'notify_sqe', + type: 'notify', + label: '通知 SQE(品类没有 SQE 时通知质量经理)', + config: { + recipients: c( + `categories.exists(x, has(x.sqe) && x.sqe != null)` + + ` ? categories.filter(x, has(x.sqe) && x.sqe != null).map(x, x.sqe)` + + ` : qualityManagers.map(p, p.user_id)`, + ), + title: opts.title, + message: opts.message, + severity: opts.severity, + sourceObject: SOURCE, + sourceId: c('record.id'), + }, + }, + ], + }, + catch: { nodes: [{ id: 'notify_sqe_skip', type: 'assignment', label: '跳过通知' }] }, + }, + } as Automation.FlowNode, + notifySupplier('notify_supplier', '通知供应商主账号与质量联系人', { + recipients: byAnyFunction(['admin', 'quality']), + template: opts.template, + data: { + supplier: vendorName, + cert_no: `has(record.cert_no) && record.cert_no != null && record.cert_no != '' ? record.cert_no : '-'`, + valid_to: `has(record.valid_to) && record.valid_to != null ? string(record.valid_to) : '-'`, + path: pathOf(SOURCE, 'record.id'), + }, + sourceObject: SOURCE, + sourceId: 'record.id', + }), +]; + +export const certificateNoticeEdges = (first: string): Automation.FlowEdge[] => { + const chain = [first, 'load_grants', 'load_categories', 'load_quality_managers', 'load_roster', 'load_vendor', 'notify_sqe_guard', 'notify_supplier_guard', 'end']; + return chain.slice(1).map((target, i) => ({ id: `e${i + 1}`, source: chain[i]!, target, type: 'default' as const })); +}; diff --git a/src/core/flows/quality/scar-create.flow.ts b/src/core/flows/quality/scar-create.flow.ts index 06a35c7..a701e7d 100644 --- a/src/core/flows/quality/scar-create.flow.ts +++ b/src/core/flows/quality/scar-create.flow.ts @@ -5,7 +5,7 @@ import type * as Automation from '@objectstack/spec/automation'; // 新建和修改都看(月度导入是 upsert,第二遍导入可能把「合格」改成「不合格」),只在「变成」主要 / 严重不合格时开单; // 同一条交付记录已有整改单时不再开,重复导入不重复开单(03 §2.3 R2)。次要不合格只计入绩效,不开单。 // 负责 SQE 取物料品类的 SQE;品类没有 SQE 时通知质量经理指派。期限由整改单字段的默认值按参数算好。 -// 供应商 quality 联系人的通知等 objectstack#25444 或 v0.6 门户上线后再加。 +// 供应商 quality 联系人的通知在 srm_scar_supplier_notice(v0.6 U6)。 const qualifying = (r: string) => `${r}.inspection_result == 'fail' && (${r}.severity == 'critical' || ${r}.severity == 'major')`; export const ScarCreateFlow: Automation.Flow = { diff --git a/src/core/flows/quality/scar-overdue-sweep.flow.ts b/src/core/flows/quality/scar-overdue-sweep.flow.ts index f687847..1df9428 100644 --- a/src/core/flows/quality/scar-overdue-sweep.flow.ts +++ b/src/core/flows/quality/scar-overdue-sweep.flow.ts @@ -53,7 +53,7 @@ const eachIssue = (key: string, collection: ReturnType, title: strin // 重复运行不重复通知(03 §2.3 R2);标志保留到关闭,供「SCAR 按期关闭率」统计(05 §4.1)。 // 每小时跑一次。外部心跳(03 §2.3 R3)暂缺:api 触发的 flow 只能把签名密钥写死在元数据里,公开仓库不能这么做 // (objectstack#25675),平台支持部署时注入密钥后再加 srm_scar_overdue_heartbeat。 -// 供应商的提醒等 objectstack#25444 或 v0.6 门户上线后再加。 +// 供应商侧的逾期通知在 srm_scar_supplier_notice(is_overdue 置位时,v0.6 U6)。 export const ScarOverdueSweepFlow: Automation.Flow = { name: 'srm_scar_overdue_sweep', label: '整改逾期扫描', diff --git a/src/core/flows/quality/scar-supplier-notice.flow.ts b/src/core/flows/quality/scar-supplier-notice.flow.ts new file mode 100644 index 0000000..2a3703a --- /dev/null +++ b/src/core/flows/quality/scar-supplier-notice.flow.ts @@ -0,0 +1,82 @@ +import type * as Automation from '@objectstack/spec/automation'; +import { byFunction, loadRoster, loadVendor, notifySupplier, pathOf, vendorName } from '../portal/supplier-notice.js'; + +// 整改单通知供应商(05 §5「新整改单、整改退回、整改重开、整改逾期」加供应商 quality 联系人,v0.6 U6): +// 不论整改单从哪里来(来料不合格自动开单、审核问题项转单、SQE 手工新建),新建、SQE 退回根因与措施、关闭后重新打开、 +// 被逾期扫描标记逾期,都发给该供应商名册上的质量联系人(没有质量联系人时发主账号联系人)。内部 SQE / 质量经理的通知 +// 仍在 srm_scar_create、srm_scar_review、srm_scar_reopen、srm_scar_overdue_sweep 里,不变。 +const SOURCE = 'srm_quality_issue'; +const events: Array<[key: string, condition: string, template: string, label: string]> = [ + ['new', `previous == null`, 'srm_scar_new', '新整改单'], + [ + 'returned', + `previous != null && record.status == 'containment_submitted' && has(previous.status) && previous.status == 'action_submitted'`, + 'srm_scar_returned', + '整改退回', + ], + ['reopened', `previous != null && record.status == 'open' && has(previous.status) && previous.status == 'closed'`, 'srm_scar_reopened', '整改重开'], + [ + 'overdue', + `previous != null && record.is_overdue == true && !(has(previous.is_overdue) && previous.is_overdue == true)`, + 'srm_scar_overdue', + '整改逾期', + ], +]; + +export const ScarSupplierNoticeFlow: Automation.Flow = { + name: 'srm_scar_supplier_notice', + label: '整改单通知供应商', + type: 'record_change', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: '整改单新建、退回、重开或逾期', + config: { + objectName: SOURCE, + triggerType: 'record-after-write', + condition: { dialect: 'cel', source: events.map(([, cond]) => `(${cond})`).join(' || ') }, + }, + }, + // 重开、退回时整改单上的期限由别的 flow 刚刚重算或写过:从库里重读一遍,模板拿到的是最新期限 + { + id: 'load_issue', + type: 'get_record', + label: '读取整改单当前期限', + config: { objectName: SOURCE, filter: { id: { dialect: 'cel', source: 'record.id' } }, fields: ['id', 'issue_no', 'containment_due', 'final_due'], outputVariable: 'issue' }, + }, + loadRoster('record.supplier'), + loadVendor('record.supplier'), + { id: 'by_event', type: 'decision', label: '哪一种事件?' }, + ...events.map(([key, , template, label]) => + notifySupplier(`notify_${key}`, `通知供应商质量联系人:${label}`, { + recipients: byFunction('quality'), + template, + data: { + no: 'issue.issue_no', + supplier: vendorName, + containment_due: `has(issue.containment_due) && issue.containment_due != null ? string(issue.containment_due) : '-'`, + final_due: `has(issue.final_due) && issue.final_due != null ? string(issue.final_due) : '-'`, + path: pathOf(SOURCE, 'record.id'), + }, + sourceObject: SOURCE, + sourceId: 'record.id', + }), + ), + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'load_issue', type: 'default' }, + { id: 'e2', source: 'load_issue', target: 'load_roster', type: 'default' }, + { id: 'e3', source: 'load_roster', target: 'load_vendor', type: 'default' }, + { id: 'e4', source: 'load_vendor', target: 'by_event', type: 'default' }, + ...events.flatMap(([key, condition, , label]) => [ + { id: `e_${key}`, source: 'by_event', target: `notify_${key}_guard`, type: 'conditional' as const, condition: { dialect: 'cel' as const, source: condition }, label }, + { id: `e_${key}_end`, source: `notify_${key}_guard`, target: 'end', type: 'default' as const }, + ]), + ], +}; diff --git a/src/core/index.ts b/src/core/index.ts index 1a11e20..8d96802 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -16,6 +16,7 @@ import { DeliveryScorecardReport, } from './analytics/index.js'; import * as translations from './translations/index.js'; +import { SrmEmailTemplates } from './notifications/index.js'; import { SrmSeedData } from './data/index.js'; import { SrmPermissionSets } from './security/permission-sets.js'; import { SrmPositions } from './security/positions.js'; @@ -53,6 +54,8 @@ export default defineStack({ permissions: SrmPermissionSets, positions: SrmPositions, sharingRules: SrmSharingRules, + // 供应商侧通知的中英文模板(05 §5,v0.6 U6):notify 节点按模板名引用,邮件和站内信按收件人语言渲染 + emailTemplates: SrmEmailTemplates, i18n: { defaultLocale: 'zh-CN', diff --git a/src/core/notifications/index.ts b/src/core/notifications/index.ts new file mode 100644 index 0000000..2d6e84d --- /dev/null +++ b/src/core/notifications/index.ts @@ -0,0 +1,293 @@ +// 供应商侧通知模板(05 §5,v0.6 U6)。平台按收件人的语言(sys_user.locale,未设时取部署默认语言 zh-CN)在 +// (name, locale) 上精确查找,查不到时退到 en-US,所以每个模板都写 zh-CN 和 en-US 两份;英文用户选 en 时落到 en-US。 +// 同一份模板既发邮件也进站内信(站内信点开即跳到单据)。正文只写单号、事件和「登录门户查看」: +// 不放金额、银行账号、扣分明细等内容(05 §5、07 R5)。邮件里的完整链接需要部署地址,模板上下文里没有, +// 只给出 {{ path }}(如 /srm_quality_issue/),客户可以在模板覆盖层里写上自己的门户域名拼成链接。 +type Copy = { subject: string; body: string }; +type Template = { name: string; label: string; variables: string[]; zh: Copy; en: Copy }; + +const TEMPLATES: Template[] = [ + { + name: 'srm_portal_opened', + label: '门户已开通', + variables: ['supplier'], + zh: { + subject: '{{ supplier }}:供应商门户账号已开通', + body: '您好,{{ supplier }} 的供应商门户账号已开通。登录 {{ appName }} 后可以维护公司资料、资质证书和银行账户;收到准入邀请的,请补齐资料后在公司资料上点「提交准入申请」。', + }, + en: { + subject: '{{ supplier }}: your supplier portal account is open', + body: 'Hello, the supplier portal account of {{ supplier }} is now open. Sign in to {{ appName }} to maintain your company profile, certificates and bank accounts; if you were invited to qualify, complete your profile and click "Submit Admission Application" on your company profile.', + }, + }, + { + name: 'srm_application_accepted', + label: '准入申请已受理', + variables: ['no', 'supplier'], + zh: { + subject: '准入申请 {{ no }} 已受理', + body: '{{ supplier }} 的准入申请 {{ no }} 已受理,品类经理正在审核资料。审核中如需补充,我们会再通知您。详情请登录 {{ appName }} 供应商门户查看。', + }, + en: { + subject: 'Admission application {{ no }} received', + body: 'The admission application {{ no }} of {{ supplier }} has been received and the category manager is reviewing your documents. We will let you know if anything needs supplementing. Sign in to the {{ appName }} supplier portal for details.', + }, + }, + { + name: 'srm_application_returned', + label: '准入申请退回补充', + variables: ['no', 'supplier'], + zh: { + subject: '准入申请 {{ no }} 需要补充资料', + body: '{{ supplier }} 的准入申请 {{ no }} 已退回补充。请登录 {{ appName }} 供应商门户查看退回意见,补齐后在申请上点「补充完成,重新提交」。', + }, + en: { + subject: 'Admission application {{ no }} needs more information', + body: 'The admission application {{ no }} of {{ supplier }} has been returned for more information. Sign in to the {{ appName }} supplier portal to see what is needed, then click "Supplemented, Resubmit" on the application.', + }, + }, + { + name: 'srm_application_approved', + label: '准入申请已通过', + variables: ['no', 'supplier'], + zh: { + subject: '准入申请 {{ no }} 已通过', + body: '{{ supplier }} 的准入申请 {{ no }} 已审批通过,贵司已成为合格供应商。详情请登录 {{ appName }} 供应商门户查看。', + }, + en: { + subject: 'Admission application {{ no }} approved', + body: 'The admission application {{ no }} of {{ supplier }} has been approved and your company is now a qualified supplier. Sign in to the {{ appName }} supplier portal for details.', + }, + }, + { + name: 'srm_application_rejected', + label: '准入申请未通过', + variables: ['no', 'supplier'], + zh: { + subject: '准入申请 {{ no }} 未通过', + body: '{{ supplier }} 的准入申请 {{ no }} 未通过。原因请登录 {{ appName }} 供应商门户查看,或联系对接的品类经理。', + }, + en: { + subject: 'Admission application {{ no }} not approved', + body: 'The admission application {{ no }} of {{ supplier }} was not approved. Sign in to the {{ appName }} supplier portal for the reason, or contact your category manager.', + }, + }, + { + name: 'srm_certificate_expiring', + label: '资质证书即将到期', + variables: ['supplier', 'cert_no', 'valid_to'], + zh: { + subject: '{{ supplier }}:资质证书将于 {{ valid_to | date }} 到期', + body: '{{ supplier }} 的一份资质证书(编号 {{ cert_no }})将于 {{ valid_to | date }} 到期。请及时换证,并登录 {{ appName }} 供应商门户上传新证书,SQE 核验后生效。', + }, + en: { + subject: '{{ supplier }}: a certificate expires on {{ valid_to | date }}', + body: 'A certificate of {{ supplier }} (No. {{ cert_no }}) expires on {{ valid_to | date }}. Please renew it and upload the new certificate in the {{ appName }} supplier portal; it takes effect once our SQE has verified it.', + }, + }, + { + name: 'srm_certificate_expired', + label: '资质证书已过期', + variables: ['supplier', 'cert_no', 'valid_to'], + zh: { + subject: '{{ supplier }}:资质证书已过期', + body: '{{ supplier }} 的一份资质证书(编号 {{ cert_no }})已于 {{ valid_to | date }} 过期。必备资质过期会影响供货资格,请尽快登录 {{ appName }} 供应商门户上传新证书。', + }, + en: { + subject: '{{ supplier }}: a certificate has expired', + body: 'A certificate of {{ supplier }} (No. {{ cert_no }}) expired on {{ valid_to | date }}. An expired mandatory certificate affects your qualification to supply; please upload the new certificate in the {{ appName }} supplier portal as soon as possible.', + }, + }, + { + name: 'srm_scar_new', + label: '新整改单', + variables: ['no', 'supplier', 'containment_due', 'final_due'], + zh: { + subject: '新整改单 {{ no }}:请提交临时措施', + body: '{{ supplier }} 有一张新的整改单 {{ no }}。请在 {{ containment_due | datetime }} 前提交临时措施(D3),在 {{ final_due | date }} 前提交根因与永久措施(D4–D7)。问题描述和回复入口请登录 {{ appName }} 供应商门户查看。', + }, + en: { + subject: 'New corrective action request {{ no }}: containment needed', + body: '{{ supplier }} has a new corrective action request {{ no }}. Please submit containment (D3) by {{ containment_due | datetime }} and root cause with permanent actions (D4–D7) by {{ final_due | date }}. Sign in to the {{ appName }} supplier portal to see the problem and reply.', + }, + }, + { + name: 'srm_scar_returned', + label: '整改措施被退回', + variables: ['no', 'supplier', 'final_due'], + zh: { + subject: '整改单 {{ no }} 的根因与措施被退回', + body: '{{ supplier }} 提交的整改单 {{ no }} 根因与措施(D4–D7)已被 SQE 退回。请登录 {{ appName }} 供应商门户查看退回意见,修改后在 {{ final_due | date }} 前重新提交。', + }, + en: { + subject: 'Root cause and actions returned on {{ no }}', + body: 'Our SQE has returned the root cause and actions (D4–D7) that {{ supplier }} submitted on corrective action request {{ no }}. Sign in to the {{ appName }} supplier portal to see the comments, and resubmit by {{ final_due | date }}.', + }, + }, + { + name: 'srm_scar_reopened', + label: '整改单重新打开', + variables: ['no', 'supplier', 'containment_due', 'final_due'], + zh: { + subject: '整改单 {{ no }} 因问题复发重新打开', + body: '{{ supplier }} 的整改单 {{ no }} 因问题复发重新打开。请在 {{ containment_due | datetime }} 前重新提交临时措施(D3),在 {{ final_due | date }} 前提交根因与永久措施(D4–D7)。详情请登录 {{ appName }} 供应商门户查看。', + }, + en: { + subject: 'Corrective action request {{ no }} reopened', + body: 'Corrective action request {{ no }} of {{ supplier }} has been reopened because the problem recurred. Please resubmit containment (D3) by {{ containment_due | datetime }} and root cause with permanent actions (D4–D7) by {{ final_due | date }}. Sign in to the {{ appName }} supplier portal for details.', + }, + }, + { + name: 'srm_scar_overdue', + label: '整改单逾期', + variables: ['no', 'supplier', 'containment_due', 'final_due'], + zh: { + subject: '整改单 {{ no }} 已逾期', + body: '{{ supplier }} 的整改单 {{ no }} 已超过回复期限(临时措施 {{ containment_due | datetime }},根因与措施 {{ final_due | date }})。请尽快登录 {{ appName }} 供应商门户回复。', + }, + en: { + subject: 'Corrective action request {{ no }} is overdue', + body: 'Corrective action request {{ no }} of {{ supplier }} is past its reply deadline (containment {{ containment_due | datetime }}, root cause and actions {{ final_due | date }}). Please reply in the {{ appName }} supplier portal as soon as possible.', + }, + }, + { + name: 'srm_review_published', + label: '绩效结果已发布', + variables: ['no', 'supplier', 'period', 'appeal_days'], + zh: { + subject: '{{ period }} 绩效考评结果已发布', + body: '{{ supplier }} {{ period }} 的绩效考评结果(考评单 {{ no }})已发布。请登录 {{ appName }} 供应商门户查看;对结果有异议的,可在发布后 {{ appeal_days }} 天内在门户提交一次申诉。', + }, + en: { + subject: 'Performance results for {{ period }} published', + body: 'The performance review {{ no }} of {{ supplier }} for {{ period }} has been published. Sign in to the {{ appName }} supplier portal to see it; if you disagree, you may appeal once in the portal within {{ appeal_days }} days of publication.', + }, + }, + { + name: 'srm_appeal_resolved', + label: '绩效申诉已处理', + variables: ['no', 'supplier', 'period'], + zh: { + subject: '考评单 {{ no }} 的申诉已处理', + body: '{{ supplier }} 对 {{ period }} 考评单 {{ no }} 的申诉已处理。处理结果请登录 {{ appName }} 供应商门户查看。', + }, + en: { + subject: 'Appeal on performance review {{ no }} resolved', + body: 'The appeal of {{ supplier }} on performance review {{ no }} ({{ period }}) has been resolved. Sign in to the {{ appName }} supplier portal to see the outcome.', + }, + }, + { + name: 'srm_bank_account_verified', + label: '银行账户已核验', + variables: ['supplier', 'bank'], + zh: { + subject: '{{ supplier }}:银行账户已核验生效', + body: '{{ supplier }} 在 {{ bank }} 的银行账户已由我方财务核验,状态为有效。账户详情请登录 {{ appName }} 供应商门户查看。', + }, + en: { + subject: '{{ supplier }}: bank account verified', + body: 'The bank account of {{ supplier }} at {{ bank }} has been verified by our finance team and is now active. Sign in to the {{ appName }} supplier portal for the account details.', + }, + }, + { + name: 'srm_change_applied', + label: '变更单已生效', + variables: ['no', 'supplier'], + zh: { + subject: '变更单 {{ no }} 已审批通过并生效', + body: '{{ supplier }} 的信息变更单 {{ no }} 已审批通过,变更内容已写入供应商档案。详情请登录 {{ appName }} 供应商门户查看。', + }, + en: { + subject: 'Change request {{ no }} approved and applied', + body: 'The information change request {{ no }} of {{ supplier }} has been approved and applied to your supplier record. Sign in to the {{ appName }} supplier portal for details.', + }, + }, + { + name: 'srm_change_rejected', + label: '变更单已驳回', + variables: ['no', 'supplier'], + zh: { + subject: '变更单 {{ no }} 已驳回', + body: '{{ supplier }} 的信息变更单 {{ no }} 未获批准,供应商档案未作修改。驳回原因请登录 {{ appName }} 供应商门户查看。', + }, + en: { + subject: 'Change request {{ no }} rejected', + body: 'The information change request {{ no }} of {{ supplier }} was not approved and your supplier record is unchanged. Sign in to the {{ appName }} supplier portal for the reason.', + }, + }, + { + name: 'srm_change_returned', + label: '变更单退回修改', + variables: ['no', 'supplier'], + zh: { + subject: '变更单 {{ no }} 退回修改', + body: '{{ supplier }} 的信息变更单 {{ no }} 已退回修改。请登录 {{ appName }} 供应商门户查看审批意见,修改后重新提交。', + }, + en: { + subject: 'Change request {{ no }} returned for revision', + body: 'The information change request {{ no }} of {{ supplier }} has been returned for revision. Sign in to the {{ appName }} supplier portal to see the comments, then revise and resubmit it.', + }, + }, + { + name: 'srm_supplier_frozen', + label: '供应商已冻结', + variables: ['supplier', 'scope'], + zh: { + subject: '{{ supplier }}:供货资格已冻结', + body: '{{ supplier }} 的供货资格已冻结(范围:{{ scope }})。冻结期间不能参与新的合作,在途业务照常;原因和后续安排请联系对接的品类经理。供货品类的状态可登录 {{ appName }} 供应商门户查看。', + }, + en: { + subject: '{{ supplier }}: qualification to supply frozen', + body: 'The qualification of {{ supplier }} to supply has been frozen (scope: {{ scope }}). While frozen you cannot take on new business; business in progress continues. Please contact your category manager for the reason and next steps. You can see the status of your supply categories in the {{ appName }} supplier portal.', + }, + }, + { + name: 'srm_supplier_unfrozen', + label: '供应商已解冻', + variables: ['supplier', 'scope'], + zh: { + subject: '{{ supplier }}:供货资格已恢复', + body: '{{ supplier }} 被冻结的供货资格已恢复(范围:{{ scope }})。供货品类的状态可登录 {{ appName }} 供应商门户查看。', + }, + en: { + subject: '{{ supplier }}: qualification to supply restored', + body: 'The frozen qualification of {{ supplier }} to supply has been restored (scope: {{ scope }}). You can see the status of your supply categories in the {{ appName }} supplier portal.', + }, + }, + { + name: 'srm_supplier_eliminated', + label: '供应商已淘汰', + variables: ['supplier'], + zh: { + subject: '{{ supplier }}:合格供应商资格已终止', + body: '{{ supplier }} 的合格供应商资格已终止(淘汰),供应商门户账号随之停用。如需再次合作,须重新申请准入;具体事宜请联系对接的品类经理。', + }, + en: { + subject: '{{ supplier }}: qualified supplier status ended', + body: 'The qualified supplier status of {{ supplier }} has ended (eliminated) and your supplier portal accounts are closed. Working together again requires a new admission application; please contact your category manager for details.', + }, + }, + { + name: 'srm_supplier_blacklisted', + label: '供应商已列入禁入名单', + variables: ['supplier'], + zh: { + subject: '{{ supplier }}:合格供应商资格已终止', + body: '{{ supplier }} 的合格供应商资格已终止,并在禁入期内不得重新申请准入,供应商门户账号随之停用。具体事宜请联系对接的品类经理。', + }, + en: { + subject: '{{ supplier }}: qualified supplier status ended', + body: 'The qualified supplier status of {{ supplier }} has ended, and no new admission application will be accepted during the exclusion period. Your supplier portal accounts are closed. Please contact your category manager for details.', + }, + }, +]; + +export const SUPPLIER_TEMPLATE_NAMES = TEMPLATES.map((t) => t.name); + +const html = (body: string) => `

${body}

`; +const variablesOf = (t: Template) => [...t.variables, 'path'].map((name) => ({ name, type: 'string' as const, required: false })); + +export const SrmEmailTemplates = TEMPLATES.flatMap((t) => [ + { name: t.name, label: t.label, locale: 'zh-CN', subject: t.zh.subject, bodyHtml: html(t.zh.body), bodyText: t.zh.body, variables: variablesOf(t) }, + { name: t.name, label: t.label, locale: 'en-US', subject: t.en.subject, bodyHtml: html(t.en.body), bodyText: t.en.body, variables: variablesOf(t) }, +]); diff --git a/src/core/objects/supplier-application.object.ts b/src/core/objects/supplier-application.object.ts index d0d39ae..a314920 100644 --- a/src/core/objects/supplier-application.object.ts +++ b/src/core/objects/supplier-application.object.ts @@ -42,6 +42,9 @@ export const SupplierApplication = ObjectSchema.create({ { label: '邀请', value: 'invited' }, ], }), + // 申请联系人(v0.6 U6):门户提交时由「提交准入申请」flow 写入提交人在名册上的联系人,申请进展通知发给他的门户账号 + // (srm_application_supplier_notice)。内部登记的申请为空;只由 flow 写入,任何岗位都不能改 + contact: Field.lookup('srm_supplier_contact', { label: '申请联系人', group: 'application', readonly: true }), status: Field.select({ label: '状态', group: 'review', diff --git a/src/core/translations/en.ts b/src/core/translations/en.ts index 3096688..c052a97 100644 --- a/src/core/translations/en.ts +++ b/src/core/translations/en.ts @@ -429,6 +429,7 @@ export const en: TranslationData = { supplier: { label: 'Supplier' }, applied_categories: { label: 'Applied Categories' }, source: { label: 'Source', options: { internal: 'Internal Registration', invited: 'Invitation' } }, + contact: { label: 'Applicant Contact' }, status: { label: 'Status', options: { diff --git a/test/portal-notifications.test.ts b/test/portal-notifications.test.ts new file mode 100644 index 0000000..f1aaf2d --- /dev/null +++ b/test/portal-notifications.test.ts @@ -0,0 +1,417 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import config from '../objectstack.config'; +import { SrmEmailTemplates, SUPPLIER_TEMPLATE_NAMES } from '../src/core/notifications'; + +// v0.6 U6(#82):供应商侧通知(05 §5)。门户用户是组织成员(external),notify 直接发给他们;收件人只取「记录所属供应商」 +// 名册上的门户账号(ADR-0010 红线 2),按职能(quality / admin)或门户管理员挑人;没开通门户的供应商不发,内部通知不变。 +// 内容走中英文邮件模板,按收件人语言渲染;站内信与邮件同一份。测试数据全部在这里生成。 +type Row = Record; +type Person = { token: string; id: string }; +type Flow = { name: string; nodes: Row[]; edges: Row[] }; + +const flows = (config.flows ?? []) as unknown as Flow[]; +// 展开 try_catch / loop 里嵌套的节点 +const allNodes = (nodes: Row[]): Row[] => + nodes.flatMap((n) => [n, ...allNodes([...(n.config?.try?.nodes ?? []), ...(n.config?.catch?.nodes ?? []), ...(n.config?.body?.nodes ?? [])])]); +const supplierNotices = flows.flatMap((flow) => + allNodes(flow.nodes) + .filter((n) => n.type === 'notify' && typeof n.config?.template === 'string') + .map((node) => ({ flow, node })), +); + +describe('supplier notification metadata (05 §5)', () => { + it('ships every supplier template in Chinese and English with the same placeholders', () => { + const holes = (s: string) => [...s.matchAll(/{{\s*([\w.]+)/g)].map((m) => m[1]).sort(); + for (const name of SUPPLIER_TEMPLATE_NAMES) { + const members = SrmEmailTemplates.filter((t) => t.name === name); + expect(members.map((t) => t.locale).sort(), name).toEqual(['en-US', 'zh-CN']); + const [zh, en] = [members.find((t) => t.locale === 'zh-CN')!, members.find((t) => t.locale === 'en-US')!]; + expect(holes(en.subject + en.bodyText), name).toEqual(holes(zh.subject + zh.bodyText)); + // 不把敏感内容放进邮件:没有金额、账号、分数、等级一类的占位符 + expect(holes(zh.subject + zh.bodyText).filter((h) => /amount|account|score|grade|price/.test(h!)), name).toEqual([]); + } + }); + + it('feeds every placeholder of each referenced template', () => { + const used = new Set(supplierNotices.map(({ node }) => node.config.template)); + expect([...used].sort()).toEqual([...SUPPLIER_TEMPLATE_NAMES].sort()); + for (const { flow, node } of supplierNotices) { + const template = SrmEmailTemplates.find((t) => t.name === node.config.template && t.locale === 'zh-CN')!; + const holes = [...(template.subject + template.bodyText).matchAll(/{{\s*([\w.]+)/g)].map((m) => m[1]); + for (const hole of holes.filter((h) => h !== 'appName')) expect(Object.keys(node.config.templateData), `${flow.name} ${node.id} ${hole}`).toContain(hole); + expect(node.config.channels, `${flow.name} ${node.id}`).toEqual(['inbox', 'email']); + } + }); + + // 隔离(ADR-0010):收件人只能从本条记录所属供应商的名册里来,名册条件与成员键一致 + it('takes supplier recipients only from the roster of the record’s own supplier', () => { + for (const { flow, node } of supplierNotices) { + const roster = allNodes(flow.nodes).find((n) => n.id === 'load_roster'); + expect(roster, flow.name).toBeTruthy(); + expect(roster!.config.objectName).toBe('srm_supplier_contact'); + expect(roster!.config.filter.active, flow.name).toBe(true); + expect(roster!.config.filter.portal_user, flow.name).toEqual({ $ne: null }); + // 名册的供应商就是触发记录(处置执行子 flow 里是按 id 读出的处置单 disp)自己的供应商 + expect(['record.supplier', 'disp.supplier'], flow.name).toContain(roster!.config.filter.supplier.source); + const recipients = String(node.config.recipients.source); + expect(/roster|portalAdmins/.test(recipients), `${flow.name} ${node.id}`).toBe(true); + // 门户管理员也只在名册的门户账号里找 + if (recipients.includes('portalAdmins')) { + const admins = allNodes(flow.nodes).find((n) => n.id === 'load_portal_admins')!; + expect(admins.config.filter.user_id.$in.source).toBe('roster.map(r, r.portal_user)'); + } + // 记录触发、定时或系统身份的子 flow:门户用户不能经 trigger 入口直接启动(只有 screen flow 才要在 flow 里查调用者的名册) + expect(['record_change', 'schedule', 'autolaunched'], flow.name).toContain((config.flows ?? []).find((f) => f.name === flow.name)!.type); + } + }); +}); + +describe('supplier notifications (05 §5, v0.6 U6)', () => { + let stack: VerifyStack; + let seq = 0; + let org: Record = {}; + const day = (offset: number) => new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10); + const sys = async (object: string, doc: Row) => (await stack.hooks.run(object, 'insert', doc, { system: true })) as Row; + const update = (object: string, id: string, doc: Row) => stack.hooks.run(object, 'update', { id, ...doc }, { system: true }); + 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(`notice-${position}-${++seq}@example.com`); + const id = String((await stack.contextFor(token)).userId); + await assign(id, position); + return { token, id }; + }; + type Portal = Person & { contact: string }; + const portalUser = async (supplier: string, contactFunction: string[], opts: { admin?: boolean; locale?: string; active?: boolean } = {}): Promise => { + const token = await stack.signUp(`notice-portal-${++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); + if (opts.locale) await stack.hooks.run('sys_user', 'update', { id, locale: opts.locale }, { system: true }); + await assign(id, 'srm_portal_user'); + if (opts.admin) await assign(id, 'srm_portal_admin'); + const contact = await sys('srm_supplier_contact', { + supplier, + name: `示例门户联系人 ${seq}`, + email: `notice-portal-${seq}@example.com`, + contact_function: contactFunction, + portal_user: id, + active: opts.active ?? true, + }); + return { token, id, contact: String(contact.id) }; + }; + // 存量导入的供应商建档即合格;潜在供应商正常新建 + const vendor = async (name: string, status: 'qualified' | 'potential' = 'qualified') => { + const doc = { name, credit_code: `91000000MA0NT${String(++seq).padStart(4, '0')}0` }; + return String((await sys('srm_supplier', status === 'qualified' ? { ...doc, legacy_imported: true } : doc)).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 < 60 && (await read()).length < count; i++) await new Promise((r) => setTimeout(r, 50)); + return read(); + }; + // 一条记录的全部供应商侧通知(topic srm_supplier)的收件人 + const supplierRecipients = async (recordId: string) => { + const notes = (await stack.rows('sys_notification', { source_id: recordId })).filter((n) => n.topic === 'srm_supplier'); + const receipts = await Promise.all(notes.map((n) => stack.rows('sys_notification_receipt', { notification_id: n.id }))); + return receipts.flat().map((r) => String(r.user_id ?? r.recipient_id)).sort(); + }; + 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' | 'revise', comment: string = verdict) => { + const req = await pending(recordId); + expect(req, 'no pending approval request').toBeTruthy(); + const res = await stack.apiAs(as.token, 'POST', `/approvals/requests/${req!.id}/${verdict}`, { comment }); + expect(res.status, await res.clone().text()).toBe(200); + }; + const 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 Row; + while (res.status === 'paused' && res.runId) res = (await stack.flows.resume({ flowName: flow, runId: res.runId }, input, { as: as.token })) as Row; + return res; + }; + + let sqe: Person; + let lead: Person; + let accountant: Person; + let director: Person; + let buyer: Person; + let category: string; + let material: string; + // A:质量、主账号(门户管理员、英文)、商务各一位,另有一位已停用的质量联系人;B:只有主账号;C:没开通门户 + let a: string; + let b: string; + let c: string; + let aQuality: Portal; + let aAdmin: Portal; + let aSales: Portal; + let aInactive: Portal; + let bAdmin: Portal; + const aPortal = () => [aQuality.id, aAdmin.id, aSales.id, aInactive.id]; + + // 定时类 flow 默认不挂(objectstack#25442);这里打开,证书到期提醒才能由平台的扫描任务触发 + const scheduledWork = process.env.OS_AUTOMATION_SCHEDULED_WORK_ENABLED; + + beforeAll(async () => { + process.env.OS_AUTOMATION_SCHEDULED_WORK_ENABLED = 'true'; + stack = await bootStack(config); + const ctx = await stack.contextFor(await stack.signIn()); + org = ctx.tenantId ? { organization_id: String(ctx.tenantId) } : {}; + sqe = await person('srm_sqe'); + lead = await person('srm_buyer_lead'); + accountant = await person('srm_ap_accountant'); + director = await person('srm_procurement_director'); + buyer = await person('srm_buyer_lead'); + category = String((await sys('srm_category', { code: `NTC-${++seq}`, name: '示例品类(供应商通知)', sqe: sqe.id })).id); + material = String((await sys('srm_material', { erp_material_no: `EX-NTC-${++seq}`, material_code: `M-NTC-${seq}`, name: '示例冲压件', category })).id); + a = await vendor('示例通知甲有限公司'); + b = await vendor('示例通知乙有限公司'); + c = await vendor('示例通知丙有限公司'); + await stack.seed('srm_supplier_category', [{ supplier: a, category, status: 'qualified', ...org }]); + aQuality = await portalUser(a, ['quality']); + aAdmin = await portalUser(a, ['admin'], { admin: true, locale: 'en' }); + aSales = await portalUser(a, ['sales']); + aInactive = await portalUser(a, ['quality'], { active: false }); + bAdmin = await portalUser(b, ['admin'], { admin: true }); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + if (scheduledWork === undefined) delete process.env.OS_AUTOMATION_SCHEDULED_WORK_ENABLED; + else process.env.OS_AUTOMATION_SCHEDULED_WORK_ENABLED = scheduledWork; + }); + + const scar = async (supplier: string, doc: Row = {}) => + String((await sys('srm_quality_issue', { supplier, material, severity: 'major', problem_description: '示例:来料尺寸超差', sqe: sqe.id, ...doc })).id); + const seedScar = async (supplier: string, doc: Row) => + String((await stack.seed('srm_quality_issue', [{ supplier, material, severity: 'major', problem_description: '示例:来料尺寸超差', sqe: sqe.id, ...org, ...doc }]))[0]!.id); + + it('tells the supplier’s quality contact about a new SCAR, in the recipient’s language, and nobody at another supplier', async () => { + const id = await scar(a); + const [note] = await inbox(aQuality.id, id); + const issue = await row('srm_quality_issue', id); + expect(note!.title).toBe(`新整改单 ${issue.issue_no}:请提交临时措施`); + expect(note!.body_md).toContain('示例通知甲有限公司'); + expect(note!.body_md).not.toContain('来料尺寸超差'); + expect(await supplierRecipients(id)).toEqual([aQuality.id]); + + // B 没有质量联系人:发给主账号;A 的人一个都收不到 + const other = await scar(b); + const [english] = await inbox(bAdmin.id, other); + expect(english!.title).toContain('新整改单'); + expect(await supplierRecipients(other)).toEqual([bAdmin.id]); + for (const user of aPortal()) expect(await inbox(user, other, 0)).toEqual([]); + }, 30_000); + + it('sends nothing to the supplier side when the supplier has no portal account', async () => { + const id = await scar(c); + await new Promise((r) => setTimeout(r, 300)); + expect(await supplierRecipients(id)).toEqual([]); + expect((await row('srm_quality_issue', id)).status).toBe('open'); + }, 30_000); + + it('tells the quality contact when the SQE returns D4–D7, when the SCAR reopens and when it goes overdue', async () => { + const returned = await seedScar(a, { + status: 'action_submitted', + containment_action: '示例:在库品全检隔离', + root_cause: '示例:模具磨损', + corrective_action: '示例:更换模具并加首件检验', + }); + await update('srm_quality_issue', returned, { status: 'containment_submitted', sqe_comment: '示例:根因分析不充分' }); + const [back] = await inbox(aQuality.id, returned); + expect(back!.title).toContain('根因与措施被退回'); + expect(back!.body_md).not.toContain('根因分析不充分'); + + const reopened = await seedScar(a, { + status: 'closed', + containment_due: '2026-01-01T00:00:00.000Z', + final_due: '2026-01-31', + containment_action: '示例:在库品全检隔离', + root_cause: '示例:模具磨损', + corrective_action: '示例:更换模具并加首件检验', + }); + await update('srm_quality_issue', reopened, { status: 'open' }); + const [again] = await inbox(aQuality.id, reopened); + expect(again!.title).toContain('重新打开'); + // 期限是重开时重算过的,不是关闭前的旧期限 + expect(again!.body_md).not.toContain('2026/1/31'); + expect(again!.body_md).toContain(String((await row('srm_quality_issue', reopened)).final_due).replace(/-0?/g, '/')); + + const late = await seedScar(a, { status: 'open', is_overdue: false, containment_due: new Date(Date.now() - 3_600_000).toISOString() }); + const run = await stack.flows.run('srm_scar_overdue_sweep', {}, { system: true }); + expect(run.success, JSON.stringify(run)).toBe(true); + const [overdue] = await inbox(aQuality.id, late); + expect(overdue!.title).toContain('已逾期'); + for (const id of [returned, reopened, late]) expect(await supplierRecipients(id)).toEqual([aQuality.id]); + }, 30_000); + + it('warns the SQE and the supplier’s admin and quality contacts before and when a certificate expires', async () => { + const expiring = String((await sys('srm_supplier_certificate', { supplier: a, cert_type: 'iso9001', cert_no: 'NTC-ISO-1', valid_from: day(-700), valid_to: day(30), legacy_imported: true })).id); + const notDue = String((await sys('srm_supplier_certificate', { supplier: a, cert_type: 'iso14001', cert_no: 'NTC-ISO-2', valid_from: day(-700), valid_to: day(31), legacy_imported: true })).id); + const job = stack.kernel.getService<{ trigger(name: string): Promise }>('job'); + await job.trigger('flow-time-relative:srm_certificate_expiry_notice'); + const [zh] = await inbox(aQuality.id, expiring); + expect(zh!.title).toBe(`示例通知甲有限公司:资质证书将于 ${zh!.title.match(/\d.*\d/)![0]} 到期`); + expect(zh!.body_md).toContain('NTC-ISO-1'); + // 门户管理员选了英文 + const [en] = await inbox(aAdmin.id, expiring); + expect(en!.title).toContain('a certificate expires on'); + expect(await inbox(sqe.id, expiring)).toHaveLength(1); + expect(await supplierRecipients(expiring)).toEqual([aQuality.id, aAdmin.id].sort()); + expect(await stack.rows('sys_notification', { source_id: notDue })).toHaveLength(0); + // 同一提醒日再扫一次不重复发 + await job.trigger('flow-time-relative:srm_certificate_expiry_notice'); + expect(await stack.rows('sys_notification', { source_id: expiring })).toHaveLength(2); + + const expired = String((await sys('srm_supplier_certificate', { supplier: a, cert_type: 'iso45001', cert_no: 'NTC-ISO-3', valid_from: day(-700), valid_to: day(-1), legacy_imported: true })).id); + const sweep = await stack.flows.run('srm_certificate_expire_sweep', {}, { system: true }); + expect(sweep.success, JSON.stringify(sweep)).toBe(true); + expect((await row('srm_supplier_certificate', expired)).status).toBe('expired'); + const [gone] = await inbox(aQuality.id, expired); + expect(gone!.title).toBe('示例通知甲有限公司:资质证书已过期'); + expect(await inbox(sqe.id, expired)).toHaveLength(1); + expect(await supplierRecipients(expired)).toEqual([aQuality.id, aAdmin.id].sort()); + }, 30_000); + + it('keeps the applicant contact informed of a portal-submitted application', async () => { + const p = await vendor('示例通知丁有限公司', 'potential'); + const applicant = await portalUser(p, ['sales']); + const colleague = await portalUser(p, ['admin']); + const id = String((await sys('srm_supplier_application', { supplier: p, applied_categories: [category], contact: applicant.contact })).id); + // contact 是只读字段:系统身份写得进去(门户里由「提交准入申请」写) + expect((await row('srm_supplier_application', id)).contact).toBe(applicant.contact); + const [accepted] = await inbox(applicant.id, id); + const app = await row('srm_supplier_application', id); + expect(accepted!.title).toBe(`准入申请 ${app.application_no} 已受理`); + + await update('srm_supplier_application', id, { status: 'returned' }); + expect((await inbox(applicant.id, id, 2)).map((m) => m.title)).toContain(`准入申请 ${app.application_no} 需要补充资料`); + // 供应商重新提交回到资料审核:不再发「已受理」 + await update('srm_supplier_application', id, { status: 'doc_review' }); + await update('srm_supplier_application', id, { status: 'rejected', reject_reason: '示例:资料不全' }); + const titles = (await inbox(applicant.id, id, 3)).map((m) => m.title); + expect(titles).toContain(`准入申请 ${app.application_no} 未通过`); + expect(titles).toHaveLength(3); + // 只发申请联系人;未通过后门户已回收 + expect(await supplierRecipients(id)).toEqual([applicant.id, applicant.id, applicant.id]); + expect(await inbox(colleague.id, id, 0)).toEqual([]); + expect((await row('srm_supplier_contact', applicant.contact)).portal_user ?? null).toBeNull(); + + // 内部登记的申请没有申请联系人:不发供应商 + const q = await vendor('示例通知戊有限公司', 'potential'); + await portalUser(q, ['admin']); + const internal = String((await sys('srm_supplier_application', { supplier: q, applied_categories: [category] })).id); + expect((await row('srm_supplier_application', internal)).status).toBe('doc_review'); + await new Promise((r) => setTimeout(r, 300)); + expect(await supplierRecipients(internal)).toEqual([]); + }, 30_000); + + it('tells the applicant contact when the application is approved', async () => { + const p = await vendor('示例通知己有限公司', 'potential'); + const applicant = await portalUser(p, ['admin']); + const [seeded] = await stack.seed('srm_supplier_application', [ + { supplier: p, applied_categories: [category], contact: applicant.contact, source: 'invited', status: 'admission_review', reviewer: director.id, ...org }, + ]); + const id = String(seeded!.id); + await update('srm_supplier', p, { status: 'qualifying' }); + await update('srm_supplier_application', id, { status: 'approved' }); + const [approved] = await inbox(applicant.id, id); + expect(approved!.title).toContain('已通过'); + }, 30_000); + + it('tells the admin contact when performance is published and when the appeal is resolved, without the internal relay', async () => { + const today = day(0); + const [review] = await stack.seed('srm_performance_review', [ + { supplier: a, cycle: 'monthly', period: '2026-08', period_start: '2026-08-01', period_end: '2026-08-31', status: 'reviewing', total_score: 95, grade: 'grade_a', ...org }, + ]); + const id = String(review!.id); + await update('srm_performance_review', id, { status: 'published', published_date: today }); + const [published] = await inbox(aAdmin.id, id); + expect(published!.title).toBe('Performance results for 2026-08 published'); + expect(published!.body_md).not.toContain('95'); + expect(await supplierRecipients(id)).toEqual([aAdmin.id]); + + // 门户管理员申诉,采购总监处理:门户管理员只收到中英文模板的那一条,不收「请把结果告知供应商」 + await runScreen(aAdmin, 'srm_performance_review', 'srm_appeal_submit', id, { appeal_text: '示例申诉' }); + expect((await row('srm_performance_review', id)).status).toBe('appealed'); + await runScreen(director, 'srm_performance_review', 'srm_appeal_resolve', id, { outcome: 'maintained', result_text: '示例:维持原结果' }); + expect((await row('srm_performance_review', id)).status).toBe('published'); + const notes = await inbox(aAdmin.id, id, 2); + expect(notes.map((m) => m.title).sort()).toEqual([`Appeal on performance review ${(await row('srm_performance_review', id)).review_no} resolved`, 'Performance results for 2026-08 published'].sort()); + await new Promise((r) => setTimeout(r, 300)); + expect(await inbox(aAdmin.id, id, 3)).toHaveLength(2); + for (const user of [aQuality.id, aSales.id, bAdmin.id]) expect(await inbox(user, id, 0)).toEqual([]); + }, 30_000); + + it('tells the portal administrators about a verified bank account and change request results', async () => { + const account = String((await sys('srm_supplier_bank_account', { supplier: a, bank_name: '示例银行', branch_name: '示例支行', account_name: '示例通知甲有限公司', account_no: '6222000011112222', currency: 'cny' })).id); + expect((await row('srm_supplier_bank_account', account)).status).toBe('pending_verify'); + const verified = await runScreen(accountant, 'srm_supplier_bank_account', 'srm_bank_verify', account); + expect((await row('srm_supplier_bank_account', account)).status, JSON.stringify(verified)).toBe('active'); + const [bank] = await inbox(aAdmin.id, account); + expect(bank!.title).toBe('示例通知甲有限公司: bank account verified'); + expect(bank!.body_md).not.toContain('2222'); + expect(await supplierRecipients(account)).toEqual([aAdmin.id]); + + // 门户管理员提交的变更单:退回修改、生效都只收模板通知,不收内部措辞的那条 + const run = await runScreen(aAdmin, 'srm_supplier', 'srm_change_submit', a, { change_type: 'basic', reason: '示例', new_legal_representative: '王示例' }); + expect(run.success, JSON.stringify(run)).toBe(true); + const [change] = await stack.rows('srm_supplier_change_request', { supplier: a }); + const cid = String(change!.id); + await decide(lead, cid, 'revise', '示例:姓名有误'); + expect((await inbox(aAdmin.id, cid)).map((m) => m.title)).toEqual([`Change request ${change!.change_no} returned for revision`]); + const req = (await stack.rows('sys_approval_request', { record_id: cid })).find((r) => r.status !== 'approved'); + const res = await stack.apiAs(aAdmin.token, 'POST', `/approvals/requests/${req!.id}/resubmit`, {}); + expect(res.status, await res.clone().text()).toBe(200); + await decide(lead, cid, 'approve'); + expect((await row('srm_supplier_change_request', cid)).status).toBe('applied'); + const titles = (await inbox(aAdmin.id, cid, 2)).map((m) => m.title); + expect(titles).toContain(`Change request ${change!.change_no} approved and applied`); + await new Promise((r) => setTimeout(r, 300)); + expect(await inbox(aAdmin.id, cid, 3)).toHaveLength(2); + expect(await inbox(aQuality.id, cid, 0)).toEqual([]); + + // 内部发起的变更单被驳回:发起人照旧收到内部通知,供应商门户管理员收到模板通知 + const internal = String((await stack.hooks.run('srm_supplier_change_request', 'insert', { supplier: a, change_type: 'basic', reason: '示例', new_legal_representative: '李示例' }, { as: buyer.token }) as Row).id); + await decide(lead, internal, 'reject', '示例:无依据'); + expect((await row('srm_supplier_change_request', internal)).status).toBe('rejected'); + expect((await inbox(buyer.id, internal)).map((m) => m.title)).toEqual([`变更单 ${(await row('srm_supplier_change_request', internal)).change_no} 已驳回`]); + expect((await inbox(aAdmin.id, internal)).map((m) => m.title)[0]).toContain('rejected'); + }, 30_000); + + it('tells the admin contact about a freeze, an unfreeze and an elimination, before the portal is revoked', async () => { + const p = await vendor('示例通知辛有限公司'); + const admin = await portalUser(p, ['admin'], { locale: 'en' }); + const quality = await portalUser(p, ['quality']); + const disposition = async (action: string) => { + const [d] = await stack.seed('srm_supplier_disposition', [{ supplier: p, action, scope: 'all', reason: '示例:交付持续不达标', status: 'in_review', ...org }]); + const run = await stack.flows.run('srm_disposition_apply', { recordId: String(d!.id) }, { system: true }); + expect(run.success, JSON.stringify(run)).toBe(true); + }; + await disposition('freeze'); + const [frozen] = await inbox(admin.id, p); + expect(frozen!.title).toBe('示例通知辛有限公司: qualification to supply frozen'); + expect(frozen!.body_md).toContain('all categories'); + expect(frozen!.body_md).not.toContain('交付持续不达标'); + await disposition('unfreeze'); + expect((await inbox(admin.id, p, 2)).map((m) => m.title)).toContain('示例通知辛有限公司: qualification to supply restored'); + await disposition('eliminate'); + expect((await inbox(admin.id, p, 3)).map((m) => m.title)).toContain('示例通知辛有限公司: qualified supplier status ended'); + expect((await row('srm_supplier_contact', admin.contact)).portal_user ?? null).toBeNull(); + expect(await inbox(quality.id, p, 0)).toEqual([]); + }, 30_000); + + it('tells the invited contact that the portal is open, and only that contact', async () => { + const p = await vendor('示例通知庚有限公司', 'potential'); + const token = await stack.signUp(`notice-open-${++seq}@example.com`); + const user = String((await stack.contextFor(token)).userId); + const contact = String((await sys('srm_supplier_contact', { supplier: p, name: '示例受邀联系人', email: `notice-open-${seq}@example.com`, contact_function: ['admin'] })).id); + const other = await portalUser(p, ['admin']); + const invitation = String((await sys('srm_supplier_invitation', { supplier: p, contact, categories: [category] })).id); + await update('srm_supplier_contact', contact, { portal_user: user }); + await update('srm_supplier_invitation', invitation, { status: 'accepted', portal_user: user }); + const [opened] = await inbox(user, p); + expect(opened!.title).toBe('示例通知庚有限公司:供应商门户账号已开通'); + expect(await inbox(other.id, p, 0)).toEqual([]); + }, 30_000); +});