From 2de8ae209dd43e2347969b88499b0d5481d47558 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:21:19 +0000 Subject: [PATCH] feat(core): notify supplier portal users directly (v0.6 U6) Supplier-side notices now go to the supplier's own portal users instead of only to the internal handler. Recipients are read from the roster of the triggering record's supplier (active contacts with portal_user, the same condition as the membership key), so a notice can never reach another supplier (ADR-0010, red line 2). Suppliers without a portal get nothing new; the internal handler path is unchanged. - 21 email templates (zh-CN / en-US) rendered per recipient locale; bodies carry the record number and event only, no amounts, accounts or scores. - New record-change / schedule flows: SCAR new / returned / reopened / overdue, certificate expiring (30/15/7 days) and expired, application accepted / returned / approved, performance published and appeal result, bank account verified, change request applied / rejected / returned, freeze / unfreeze, portal opened. - Rejection, elimination and blacklisting notify before the roster is cleared. Internal notices that used to reach the portal user themselves (portal-submitted change requests and appeals) are skipped. - srm_supplier_application.contact records the applicant contact. - Docs 02/03/04/05/07; PF20 = objectstack#29095 (no base URL in the email template context). Closes #82 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fexWhTMqS1KFBnTPG --- docs/design/02-domain-model.md | 1 + docs/design/03-processes-and-automation.md | 44 +- docs/design/04-security-and-portal.md | 8 +- docs/design/05-experience-and-analytics.md | 28 +- docs/design/07-delivery-plan.md | 5 +- .../application-supplier-notice.flow.ts | 63 +++ src/core/flows/admission/rejection.flow.ts | 21 +- .../bank-account/bank-supplier-notice.flow.ts | 47 ++ .../change-request/change-approval.flow.ts | 24 +- .../change-supplier-notice.flow.ts | 63 +++ .../disposition/disposition-apply.flow.ts | 27 +- .../disposition-supplier-notice.flow.ts | 50 +++ src/core/flows/index.ts | 9 + .../flows/performance/appeal-resolve.flow.ts | 36 +- .../review-supplier-notice.flow.ts | 55 +++ .../flows/portal/application-submit.flow.ts | 4 +- .../flows/portal/portal-opened-notice.flow.ts | 44 ++ src/core/flows/portal/supplier-notice.ts | 109 +++++ .../certificate-expired-notice.flow.ts | 34 ++ .../certificate-expiry-notice.flow.ts | 38 ++ .../flows/qualification/certificate-notice.ts | 76 ++++ src/core/flows/quality/scar-create.flow.ts | 2 +- .../flows/quality/scar-overdue-sweep.flow.ts | 2 +- .../quality/scar-supplier-notice.flow.ts | 82 ++++ src/core/index.ts | 3 + src/core/notifications/index.ts | 293 ++++++++++++ .../objects/supplier-application.object.ts | 3 + src/core/translations/en.ts | 1 + test/portal-notifications.test.ts | 417 ++++++++++++++++++ 29 files changed, 1552 insertions(+), 37 deletions(-) create mode 100644 src/core/flows/admission/application-supplier-notice.flow.ts create mode 100644 src/core/flows/bank-account/bank-supplier-notice.flow.ts create mode 100644 src/core/flows/change-request/change-supplier-notice.flow.ts create mode 100644 src/core/flows/disposition/disposition-supplier-notice.flow.ts create mode 100644 src/core/flows/performance/review-supplier-notice.flow.ts create mode 100644 src/core/flows/portal/portal-opened-notice.flow.ts create mode 100644 src/core/flows/portal/supplier-notice.ts create mode 100644 src/core/flows/qualification/certificate-expired-notice.flow.ts create mode 100644 src/core/flows/qualification/certificate-expiry-notice.flow.ts create mode 100644 src/core/flows/qualification/certificate-notice.ts create mode 100644 src/core/flows/quality/scar-supplier-notice.flow.ts create mode 100644 src/core/notifications/index.ts create mode 100644 test/portal-notifications.test.ts 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); +});