diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md index 6ad474f..eac355c 100644 --- a/docs/design/01-requirements.md +++ b/docs/design/01-requirements.md @@ -145,7 +145,7 @@ flowchart LR |---|---|---|---| | FR-200 | 作为品类经理,我要在门户上线前自己登记潜在供应商并发起准入 | 内部新建准入申请,来源记为「内部登记」;跳过门户补资料,初审人默认为创建人所在品类的品类经理 | P0(v0.1) | | FR-201 | 作为潜在供应商,我要不登录就能在线提交注册申请 | 公开注册表单可匿名提交,只收文字信息、不收附件(2026-10-24 决定:营业执照、证书等附件由供应商注册账户、登录门户后上传);提交后生成「已提交」的准入申请,申请人收到受理通知。多组织托管云上公开表单落入部署默认组织(objectstack ADR-0138 D4),待 07 §6 V1 实测与 objectstack#25447 后启用 | P2(待平台) | -| FR-202 | 作为品类经理,我要邀请指定供应商注册 | 填写公司名和联系人邮箱即可发出邀请;供应商以受邀成员身份进门户后填申请,来源记为「邀请」。v0.6 的默认注册方式 | P0(v0.6) | +| FR-202 | 作为品类经理,我要邀请指定供应商注册 | 先登记潜在供应商(全称与身份标识,唯一约束查重)和联系人,再建门户邀请,准入邀请填申请品类;组织管理员按邀请邮箱在成员页邀请为外部成员并开通门户;供应商登录门户后提交准入申请,品类由邀请带入,来源记为「邀请」。v0.6 的默认注册方式(2026-11-02 总监裁定:门户按联系人名册隔离,没有供应商档案就无法隔离,ADR-0010) | P0(v0.6) | | FR-203 | 作为采购初审人,我要在受理时自动看到重复和黑名单检查结果 | 身份标识命中已有供应商或黑名单时,申请页醒目提示并禁止进入下一步 | P0 | | FR-204 | 作为 SQE,我要按品类风险等级决定是否需要现场审核和样品验证 | 高风险品类的申请必须有通过(或有条件通过)的现场审核记录才能提交准入评审 | P0 | | FR-205 | 作为准入评审人,我要在一个审批里看到资料、审核报告和样品结论 | 审批页展示申请摘要、证书清单、审核得分;批准后供应商状态变为「合格」,对应品类资格变为「合格」或「试供」 | P0 | diff --git a/docs/design/02-domain-model.md b/docs/design/02-domain-model.md index 9f99ad2..b455ff1 100644 --- a/docs/design/02-domain-model.md +++ b/docs/design/02-domain-model.md @@ -305,7 +305,7 @@ upsert 写入,结构不变。 | 字段 | 类型 | 说明 | |---|---|---| | `application_no` | autonumber | `APP{YYYY}{00000}`,按年重置 | -| `source` | select | `internal` 内部登记(v0.1 默认)/ `invited` 邀请(v0.6)/ `self_registered` 自助注册(待平台) | +| `source` | select(只读) | `internal` 内部登记(默认)/ `invited` 邀请(v0.6,由「提交准入申请」写入)/ `self_registered` 自助注册(待平台,随公开表单加入) | | `company_name` | text | 必填 | | `country` | select | 必填,默认 `cn`,选项同供应商的 `srm_country` | | `credit_code` / `registration_no` | text | 规则同供应商:中国供应商填统一社会信用代码,境外供应商填当地注册号或税号 | @@ -325,7 +325,7 @@ upsert 写入,结构不变。 | `reviewer` | user | 初审人(品类经理) | | `review_escalated` | boolean | 只读,默认 false;初审人就是新建人或推进到准入评审的人时,`srm_admission_approval` 把品类经理一级上提给采购总监并写 true(03 §3 第 6 步) | | `sqe` | user | 负责审核的 SQE | -| `supplier` | lookup → 供应商 | 品类经理把申请推进到「资料审核」时新建(或 `reapply` 时关联旧档)的供应商;`submitted` 阶段只有申请记录,不建主数据 | +| `supplier` | lookup → 供应商 | 内部登记和邀请注册的申请新建时就关联已登记的供应商(邀请注册的供应商由品类经理在邀请前登记,04 §3.3);公开表单的申请由品类经理推进到「资料审核」时新建(或 `reapply` 时关联旧档)供应商,`submitted` 阶段只有申请记录,不建主数据 | | `reject_reason` | textarea | | | `supplement_note` | textarea(只读) | 补充说明:「退回补充」后供应商在门户经「补充完成,重新提交」(`srm_application_resubmit`)写入(v0.6) | | `cycle_days` | number | 从提交到批准的天数,批准时由 flow 写入(准入周期指标) | @@ -333,7 +333,8 @@ upsert 写入,结构不变。 > v0.1(内部代录,见 [07 §1](./07-delivery-plan.md#1-分期路线图))先实现一个子集:申请由内部人员为已登记的 > 潜在或淘汰供应商发起,直接关联 `supplier`;申请品类用多值 lookup `applied_categories`(不经匿名表单, > 可以读品类对象);不含现场审核分支和公司资料等表单字段;`approval_status` 记录审批节点的结论。 -> 本表其余字段随 v0.4(供应商审核)和 v0.6(门户、邀请注册)补齐。 +> v0.6 加 `source`:受邀供应商在门户经「提交准入申请」提交,申请同样预关联供应商、品类取自门户邀请,所以公司信息、 +> 联系人和附件仍在供应商档案、联系人和证书上,不在申请上。表中公司信息、执照、隐私同意等字段随公开表单(FR-201)加入。 **`srm_supplier_audit` 供应商审核**(OWD `private`,内部读靠权限集的 `viewAllRecords`:年度计划由系统生成、SQE 之间要互相接手, SQE、质量经理经共享规则 `srm_supplier_audit_edit_*` 改所有审核(可改不可删,objectstack#25470 的裁定写法);只有质量岗位有 `allowEdit`,删除不开放;v0.4) @@ -422,11 +423,25 @@ SQE、质量经理经共享规则 `srm_supplier_audit_edit_*` 改所有审核( 同一家供应商同时只能有一张审批中的处置单(读供应商的 `open_disposition_count`),免得两张单子审完后互相覆盖。 淘汰和黑名单清掉冻结记录,是为了重新准入后再冻结、解冻时,不会把没有重新准入的品类恢复。 -**`srm_supplier_invitation` 门户邀请**(OWD `private`;v0.6):`supplier`(lookup, -可空:存量供应商批量邀请时填)、`company_name`、`contact_name`、`contact_email`、 -`token`(text,只读,flow 生成)、`expires_at`、`status`(`sent` / `accepted` / `expired` / -`revoked`)、`application`(lookup,供应商接受邀请并提交申请后回填)、`invited_by`(user)。 -邀请注册是 v0.6 的默认注册方式(01 FR-202);组织邀请本身由组织管理员在成员页发(flow 发不了,07 §6 V3),本对象记录邀请的业务信息和进度(04 §3.3)。 +**`srm_supplier_invitation` 门户邀请**(OWD `private`;v0.6):品类经理、采购主管提出的门户开通需求(01 FR-202,04 §3.3)。 +组织邀请本身由组织管理员在成员页发(flow 发不了,07 §6 V3),门户访问只在「开通门户」写入联系人的 `portal_user` 后生效 +(ADR-0010),本对象记录邀请的业务信息和进度。门户不可见。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `invitation_no` | autonumber | `INV{YYYY}{00000}` | +| `supplier` | lookup → 供应商 | 必填。门户只能经名册看到数据,没有供应商档案就无法隔离:新公司先登记为潜在供应商(2026-11-02 总监裁定);黑名单供应商不得邀请 | +| `contact` | lookup → 联系人 | 必填,须属于该供应商、在职、尚未开通门户 | +| `contact_email` | email(只读) | 新建时由 `srm_invitation_send` 从联系人复制,组织管理员按它在成员页发邀请 | +| `categories` | lookup → 品类(多值) | 申请品类。潜在、淘汰的供应商是**准入邀请**,必填,开通后供应商按这些品类提交准入申请;准入中、合格、冻结的供应商只开通门户,不填 | +| `portal_admin` / `admin_authorization` | boolean / file | 授予门户管理员(默认 false);勾选时授权书必填(GL-10 6.3.1 c) | +| `notes` | textarea | | +| `status` | select(只读,stageField) | `sent` 已发送 → `accepted` 已开通;`sent` → `expired` 已过期(`srm_invitation_expire_sweep`);`sent` / `accepted` → `revoked` 已撤销(准入未通过、联系人停用、淘汰 / 黑名单) | +| `expires_on` | date(只读) | 开通期限,新建日 + 14 天(示例,`srm_invitation_send` 写入) | +| `portal_user` / `enabled_by` / `enabled_at` | user / user / datetime(只读) | 「开通门户」写入 | +| `application` | lookup → 准入申请(只读) | 供应商在门户提交准入申请后回填 | + +原设计的 `company_name` / `contact_name`(从供应商、联系人带出)、`token`(组织邀请自带链接)、`invited_by`(即创建人)不建。 ### 4.3 寻源(v0.7) diff --git a/docs/design/03-processes-and-automation.md b/docs/design/03-processes-and-automation.md index c4f882e..46d67dc 100644 --- a/docs/design/03-processes-and-automation.md +++ b/docs/design/03-processes-and-automation.md @@ -85,14 +85,20 @@ v0.1 只有内部登记:品类经理在工作台新建准入申请(`source = internal`),下图中的公开 表单、开通门户、门户补资料三步跳过,`reviewer` 默认取创建人所在品类的品类经理。v0.6 起 -受邀供应商在门户填申请(`source = invited`);公开表单(`self_registered`)待平台 +受邀供应商在门户提交申请(`source = invited`):品类经理先登记潜在供应商和联系人,再建门户邀请并填申请品类, +组织管理员开通门户后,供应商补齐资料,在门户的公司资料上点「提交准入申请」,品类由邀请带入(2026-11-02 总监裁定, +01 FR-202);公开表单(`self_registered`)待平台 (01 FR-201),只收文字信息,附件在供应商注册账户、登录门户后上传。现场审核对象随 v0.4 加入,审核结论由 flow 写回申请。 内部登记时申请直接关联已登记的供应商(身份标识已由供应商上的唯一约束查重),所以 v0.3 的受理 flow 按所关联供应商的状态写 `duplicate_check`:`potential` → `pass`;`eliminated` → `reapply`; `blacklisted` → 申请直接 `rejected` 并通知申请人,供应商状态不动;其他状态在新建申请时就被校验规则 -`supplier_admissible` 拦下,不会出现 `duplicate`。步骤 2「建档」与 `srm_application_accept` 随门户申请 -(v0.6)加入,之前受理时供应商直接进入 `qualifying`、申请进入 `doc_review`。重新准入批准后按本次申请 +`supplier_admissible` 拦下,不会出现 `duplicate`。受邀申请与内部登记一样预关联供应商,受理照旧(`eliminated` → +`reapply` 是重新准入 FR-208 的门户路径)。步骤 2「建档」与 `srm_application_accept` 不随 v0.6 加入,留给公开表单 +(FR-201);之前受理时供应商直接进入 `qualifying`、申请进入 `doc_review`。 + +受邀申请由系统身份建(门户不授准入申请的新建权),所以受理、退回补充、批准、未通过的「通知申请人」发给该供应商 +开通了门户的联系人(`portal_user`),不发给 `created_by`;随 v0.6 U6 供应商通知(#82)实现。重新准入批准后按本次申请 品类恢复或新建品类资格,没再申请的品类保持淘汰时的 `suspended`。 ```mermaid @@ -130,14 +136,14 @@ sequenceDiagram | 步骤 | 触发 | 自动化 / 人工动作 | |---|---|---| -| 1 受理 | 申请创建(v0.1 内部登记;v0.6 受邀供应商在门户提交) | 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) | 邀请主联系人加入组织并开通门户(见 [04 §3.3](./04-security-and-portal.md#33-门户账号的开通与回收));v0.1–v0.5 跳过 | +| 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 完成核验 | | 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 起回收门户账号 | +| 8 未通过 | 终止 | 申请→`rejected`;供应商 `qualifying → potential`(`reapply` 的旧档回 `eliminated`);v0.6 起 `srm_admission_rejection` 同时回收门户账号:清掉该供应商全部联系人的 `portal_user`、撤销门户邀请,有门户账号时通知组织管理员撤任命、移出组织(GL-10 6.6.1 c)。只有潜在、淘汰的供应商能申请(`supplier_admissible`),所以驳回总会让供应商退回,已合格供应商不受影响 | ## 4. P3 寻源:询价 → 比价 → 定标(v0.7) @@ -505,7 +511,7 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_contract_expiry_notice` / `srm_contract_expire_sweep` | time_relative:`end_date` −60 / −30 / −7 天 / schedule:每日 01:10(追赶式) | 合同 | v0.3 | | `srm_contract_renew` | 子 flow:到期扫描调用 | 合同 → 续签草稿、明细 | v0.3 | | `srm_contract_agreement_recalc` | record_change:质量协议 / 廉洁协议进入或离开 `active` | 合同 → 供应商 | v0.3 | -| `srm_disposition_approval` / `srm_disposition_apply` | record_change | 处置单(淘汰 / 黑名单时 → 联系人 `portal_user` 清空,v0.6) | v0.3 | +| `srm_disposition_approval` / `srm_disposition_apply` | record_change | 处置单(淘汰 / 黑名单时 → 联系人 `portal_user` 清空、门户邀请撤销、通知组织管理员,v0.6) | v0.3 | | `srm_scar_create` | record_change:交付记录不合格 | 交付记录 → 整改单 | v0.4 | | `srm_scar_review` | record_change:状态→`action_submitted` | 整改单 | v0.4 | | `srm_scar_reopen` | record_change:状态 `closed`→`open` | 整改单 | v0.4 | @@ -520,7 +526,10 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_performance_apply` | record_change:状态→`published` | 考评单 → 整改单、处置单 | v0.5 | | `srm_risk_event_apply` | record_change:风险事件新建或改为高风险 | 风险事件 → 处置单(草稿) | v0.5 | | `srm_blacklist_expiry_notice` | time_relative:供应商 `blacklist_until` −30 / 0 天 | 供应商(黑名单) | v0.5 | -| `srm_invitation_send` / `srm_invitation_expire_sweep` | record_change / schedule | 门户邀请 | v0.6 | +| `srm_invitation_send` / `srm_invitation_expire_sweep` | record_change:新建(补邀请邮箱、开通期限,通知组织管理员)/ schedule:每日 01:20(追赶式,过期仍「已发送」的置为已过期) | 门户邀请 | v0.6 | +| `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_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 | @@ -531,8 +540,8 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_esign_callback`、`srm_erp_push_retry`、`srm_external_risk_scan` | api / schedule | 合同、同步字段、风险事件 | 客户驱动主题 | | `srm_statement_generate`、`srm_invoice_post`、`srm_payment_callback` | — | — | 不做(ERP) | -用户操作入口的 screen flow(均以系统身份运行,flow 内做校验后再写入)。门户能发起的(`srm_appeal_submit`、`srm_claim_respond`) -读不到成员键,进门检查改为直接查名册:操作人必须是记录所属供应商的在职联系人(`portal_user` = 本人、`active == true`, +用户操作入口的 screen flow(除注明的以外均以系统身份运行,flow 内做校验后再写入)。门户能发起的(`srm_appeal_submit`、`srm_claim_respond`、 +`srm_application_submit`)读不到成员键,进门检查改为直接查名册:操作人必须是记录所属供应商的在职联系人(`portal_user` = 本人、`active == true`, 与成员键的名册条件相同,04 §3.1),否则拒绝;记录页按钮对门户不可见的记录本来就答「不存在」,这一检查挡的是直接触发 flow 的请求: | screen flow | 入口 | 使用者 | 版本 | @@ -542,6 +551,8 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_bank_verify` 核验银行账户 | 银行账户 | 应付会计 | v0.2 | | `srm_contract_submit` 提交审批 | 合同(草稿) | 品类经理、采购主管 | v0.3 | | `srm_contract_activate` 确认生效 | 合同(签署中) | 采购员、采购主管、品类经理 | v0.3 | +| `srm_portal_enable` 开通门户(**`runAs: 'user'`**:任命门户岗位要以组织管理员身份写,名册经子 flow `srm_portal_roster_write` 写) | 门户邀请(已发送) | 组织管理员(`owner` / `admin`,通常兼 SRM 系统管理员);品类经理、采购主管只建门户邀请 | v0.6 | +| `srm_application_submit` 提交准入申请 | 门户:公司资料(潜在 / 淘汰) | 受邀的门户用户 | v0.6 | | `srm_change_submit` 提交变更申请 | 门户:供应商档案(内部员工照旧在变更单列表直接新建) | 门户管理员(按门户名册检查) | v0.6 | | `srm_application_resubmit` 补充完成,重新提交 | 门户:准入申请(退回补充) | 申请所属供应商的门户用户 | v0.6 | | `srm_audit_finding_to_scar` 转整改单 | 供应商审核(已完成) | SQE、质量经理 | v0.4 | @@ -552,7 +563,6 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_appeal_submit` 申诉 | 考评单(已发布);v0.6 起门户:绩效 | 采购员、采购主管、品类经理代录(v0.5);门户管理员(v0.6) | v0.5 | | `srm_claim_respond` 确认索赔 | 门户:整改单(索赔已提出) | 门户用户 | v0.6 | | `srm_appeal_resolve` 处理申诉 | 考评单(申诉中) | 采购总监 | v0.5 | -| `srm_portal_enable` 开通门户 | 供应商联系人 | `srm_supplier_admin` | v0.6 | | `srm_invite_supplier` 邀请供应商 | 寻源单 | 寻源工程师 | v0.7 | | `srm_event_submit` 发布 | 寻源单 | 寻源工程师 | v0.7 | | `srm_deadline_extend` 延期截止 | 寻源单 | 寻源工程师 | v0.7 | diff --git a/docs/design/04-security-and-portal.md b/docs/design/04-security-and-portal.md index 58e3902..8b0e807 100644 --- a/docs/design/04-security-and-portal.md +++ b/docs/design/04-security-and-portal.md @@ -217,24 +217,36 @@ flowchart LR (`adminScope`)又只能在业务单元内任命,`external` 成员不进业务单元(07 §6 V2、V3 实测)。不需要新建任何岗位定义 (ADR-0010)。 -1. 在组织成员页按联系人邮箱发出邀请,角色选 `external`(flow 发不了邀请:平台规定邀请只经组织接口,07 §6 V3); -2. 运行「开通门户」screen flow(`srm_portal_enable`,v0.6 U3 实现;`runAs: 'user'`,以操作的组织管理员身份执行): - 选择联系人和已加入的用户 → 把用户任命到静态岗位 `srm_portal_user`,联系人 `contact_function` 含 `admin` 时再任命到 - `srm_portal_admin` → 在联系人上写 `portal_user`。任命是数据行,18.1.0 起 `runAs: 'system'` 的 flow 不得写 - (objectstack#25251),`runAs: 'user'` 由组织管理员启动时能写,其他人启动被拒(07 §6 V2);flow 进门先检查操作人 - 是组织管理员,不是就直接结束并说明。写入 `portal_user` 后下一个请求起该用户即能看到本公司数据;flow 还要检查同一 - 用户没有登记在别的联系人上(登记在两家时两家的数据都能看到); -3. 同一供应商以后再开通账号,同样走第 1、2 步。 +0. 品类经理或采购主管新建**门户邀请**(`srm_supplier_invitation`,02 §4.2):供应商与联系人必填——门户只能经名册看到数据, + 没有供应商档案就无法隔离,新公司先登记为潜在供应商和联系人(2026-11-02 总监裁定);潜在、淘汰的供应商是准入邀请, + 填申请品类;要授门户管理员的勾选并附授权书(GL-10 6.3.1 c)。`srm_invitation_send` 补邀请邮箱和开通期限(示例 14 天), + 通知组织管理员;到期未开通的由 `srm_invitation_expire_sweep` 置为已过期; +1. 组织管理员在组织成员页按邀请邮箱发出邀请,角色选 `external`(flow 发不了邀请:平台规定邀请只经组织接口,07 §6 V3); +2. 对方注册并接受后,组织管理员在门户邀请上运行「开通门户」screen flow(`srm_portal_enable`,`runAs: 'user'`,以操作的 + 组织管理员身份执行),选择已加入的用户: + - 进门先查操作人的组织角色(`sys_member.role` 为 `owner` / `admin`),不是就直接结束并说明;邀请须为「已发送」、 + 供应商不在黑名单、联系人在职且未开通; + - 所选用户须是 `external` 成员(内部员工不进门户名册),且没有登记在任何联系人上(一人一号,GL-10 6.3.3;登记在两家时 + 两家的数据都能看到,平台没有「非空值唯一」可以兜底); + - 把用户任命到静态岗位 `srm_portal_user`,邀请勾了门户管理员的再任命到 `srm_portal_admin`。任命是身份数据行, + `runAs: 'system'` 的 flow 不得写(objectstack#25251),`runAs: 'user'` 由组织管理员启动时能写,其他人启动被拒(07 §6 V2); + - 经子 flow `srm_portal_roster_write`(`runAs: 'system'`,autolaunched)在联系人上写 `portal_user`,邀请置为「已开通」。 + `portal_user` 字段级只读,`runAs: 'user'` 的写入会被数据层剔除(07 §6.3 PF19),所以名册由系统身份写;平台拒绝任何调用者 + 直接启动系统身份的 autolaunched flow,子 flow 只能经开通 flow 进入,写入前再查一遍一人一号。写入后下一个请求起该用户 + 只能看到本公司数据; +3. 准入邀请:供应商补齐资料、证书和银行账户后,在门户的公司资料上点「提交准入申请」(`srm_application_submit`,系统身份): + 供应商取自调用者自己的名册行(不取页面传入的记录),品类取自门户邀请,`source = invited`,申请回填到邀请(03 §3); +4. 同一供应商以后再开通账号,同样走第 0–2 步。 **回收**(下一个请求即生效,平台每个请求重读名册): | 场景 | 动作 | |---|---| -| 联系人离职 / 停用 | 联系人 `active = false`(名册过滤条件),下一个请求起看不到数据;组织管理员随后撤销门户岗位任命并移出组织 | +| 联系人离职 / 停用 | 联系人 `active = false`(名册过滤条件),下一个请求起看不到数据;`srm_portal_contact_revoke` 同时清掉 `portal_user`、撤销该联系人的门户邀请并通知组织管理员——日后把联系人改回在职也不会静默恢复门户访问,重新开通走门户邀请。组织管理员随后撤销门户岗位任命并移出组织 | | 供应商冻结 | 账号与门户功能保持不变:整改单仍要回复,资料仍要维护;冻结只阻止新的询价邀请(BR-03),新订单由 ERP 侧冻结标志拦截 | -| 供应商淘汰 / 黑名单 | `srm_disposition_apply` 执行时清掉该供应商全部联系人上的 `portal_user`,门户访问即刻失效,不靠人工停用联系人;岗位任命与组织成员身份由组织管理员随后移除(处置 flow 写不了任命,07 §6 V2)。重新准入通过后要重新开通门户 | -| 准入未通过 | 申请驳回、供应商退回 `potential`(重新准入的旧档回 `eliminated`)时清掉该供应商联系人上的 `portal_user`(03 §3 第 8 步,v0.6 U3);已合格供应商增加品类的申请被驳回不动门户。岗位任命与组织成员身份由组织管理员随后移除 | -| 季度复核 | 报表列出每家供应商的门户账号与在职联系人,差异由采购主管确认 | +| 供应商淘汰 / 黑名单 | `srm_disposition_apply` 执行时清掉该供应商全部联系人上的 `portal_user`、撤销门户邀请,门户访问即刻失效,不靠人工停用联系人;有门户账号时通知组织管理员,岗位任命与组织成员身份由组织管理员随后移除(处置 flow 写不了任命,07 §6 V2)。重新准入时重新邀请、开通 | +| 准入未通过 | 申请驳回、供应商退回 `potential`(重新准入的旧档回 `eliminated`)时 `srm_admission_rejection` 清掉该供应商全部联系人上的 `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 78e13e5..9586961 100644 --- a/docs/design/05-experience-and-analytics.md +++ b/docs/design/05-experience-and-analytics.md @@ -20,7 +20,8 @@ │ ├── 资质证书 30 天内到期 / 已过期 / 存量导入缺扫描件 │ ├── 变更单 (v0.2) │ ├── 处置单 (v0.3) -│ └── 门户邀请 (v0.6) +│ ├── 门户邀请 (v0.6)全部 / 待开通(组织管理员的待办)/ 已开通 +│ └── 门户账号 (v0.6)登记了门户账号的联系人,季度复核用 ├── 合同 (v0.3)合同 · 到期提醒 ├── 质量 (v0.4) │ ├── 交付记录 按月导入 / 不合格 @@ -170,7 +171,8 @@ | 新整改单、整改退回、整改重开、整改逾期 | 负责 SQE(新整改单没有 SQE 时发质量经理);逾期加质量经理(SQE 主管);v0.6 起加供应商 `quality` 联系人 | 邮件 + 站内 / 门户待办 | v0.4 | | 供应商在门户提交临时措施;供应商接受索赔 / 对索赔有异议 | 负责 SQE(提交临时措施时没有 SQE 发质量经理;SQE 本人代填的不发);接受索赔加应付会计 | 站内 | v0.6 | | 待你评分、评分截止前 3 天仍未评、退回重评、绩效结果发布;绩效申诉待处理、申诉处理结果 | 评分人;采购总监;申诉提交人(门户管理员或内部代录人;v0.6 U6 起加供应商 `admin` 联系人) | 站内 + 邮件 | v0.5 | -| 门户邀请、准入申请已受理 / 已驳回 / 已通过 | 受邀联系人、申请联系人 | 邮件 | v0.6 | +| 门户开通待办(新门户邀请);门户访问已收回(联系人停用、处置、准入未通过) | 组织管理员 | 站内 | v0.6(U3) | +| 门户邀请、准入申请已受理 / 已驳回 / 已通过 | 受邀联系人、申请联系人(受邀申请发给开通了门户的联系人 `portal_user`,不发 `created_by`) | 邮件 | v0.6(U6) | | 新的询价邀请、报价截止前 24 小时仍未提交、中标 / 未中标、待你审批的定标 | 受邀供应商 `sales` 联系人;审批人 | 邮件 + 门户待办 | v0.7 | | 新订单待确认、订单变更、交期风险、订单 24 / 48 小时未确认、付款完成 | 供应商 / 采购员 / 采购主管 | 邮件 / 站内 | ERP 集成后 | diff --git a/docs/design/07-delivery-plan.md b/docs/design/07-delivery-plan.md index 085a188..8e12dc2 100644 --- a/docs/design/07-delivery-plan.md +++ b/docs/design/07-delivery-plan.md @@ -274,6 +274,7 @@ test/ # 仓库根目录 | PF16 | objectstack#26133 导入 upsert 时同一文件里匹配键相同的两行都新建,之后再导报 `AMBIGUOUS_MATCH` | 证书等没有唯一约束的对象重复建档 | 清洗时同一张证书只留一行([导入说明 §6.2](../implementation/legacy-supplier-import.md#62-清洗清单)) | 2026-10-26 新提 | | 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) | 其他代码和文档里指向平台的临时做法,2026-10-26 核对结果: diff --git a/governance/templates/core/GL-10.md b/governance/templates/core/GL-10.md index acddbeb..21965f1 100644 --- a/governance/templates/core/GL-10.md +++ b/governance/templates/core/GL-10.md @@ -141,7 +141,7 @@ d) 完成门户登记后,该账号即可看到本公司的数据;{{org.dept. 6.6.1 门户账号按下列情形回收: -a) **联系人离职或岗位变动**:供应商应在人员离职或不再负责与公司业务往来后 1 个工作日内通知{{org.dept.procurement}};{{org.dept.procurement}}在 SRM 系统中停用该联系人,账号自停用起看不到任何供应商的数据;组织管理员随后撤销其门户岗位任命,并将其移出 SRM 系统; +a) **联系人离职或岗位变动**:供应商应在人员离职或不再负责与公司业务往来后 1 个工作日内通知{{org.dept.procurement}};{{org.dept.procurement}}在 SRM 系统中停用该联系人,账号自停用起看不到任何供应商的数据,SRM 系统同时清除该联系人的门户登记并通知组织管理员;组织管理员随后撤销其门户岗位任命,并将其移出 SRM 系统。联系人日后恢复在职的,门户账号不随之恢复,需要时按 6.3.2 重新开通; b) **供应商淘汰或列入黑名单**:处置执行时,SRM 系统清除该供应商全部联系人的门户登记,门户访问即时失效;组织管理员随后撤销这些账号的岗位任命并移出 SRM 系统(处置按{{doc.GL-08}}执行); @@ -209,9 +209,9 @@ c) 访问全程留痕,访问记录由{{org.dept.it}}保存,{{org.dept.oversi | 一个账号只登记在一家供应商 | 6.3.3 | 「开通门户」检查该账号是否已登记在其他联系人上,已登记的不予开通 | | 门户管理员按人授权 | 4.5、6.4.3 | 门户用户与门户管理员为两个固定岗位,门户管理员权限只授给被任命的个人 | | 门户可见范围与可办理事项 | 6.4.1、6.4.2、附录 A | 门户权限只放行本公司的数据;关键信息字段对门户只读;不授予门户的业务对象门户看不到 | -| 门户登记只由流程写入 | 4.3、6.4.5 | 联系人的门户登记字段只读,只由开通门户、处置执行、准入驳回等流程写入或清除;联系人在职状态对门户只读 | +| 门户登记只由流程写入 | 4.3、6.4.5 | 联系人的门户登记字段只读,只由开通门户、处置执行、准入驳回、联系人停用等流程写入或清除;联系人在职状态对门户只读 | | 内部权限不设默认授予 | 6.4.6 | SRM 内部权限集一律不设为默认,只经岗位任命授予 | -| 停用联系人即时回收 | 6.6.1 a) | 门户可见范围只认在职联系人上的门户登记,联系人停用后下一次访问即看不到数据 | +| 停用联系人即时回收 | 6.6.1 a) | 门户可见范围只认在职联系人上的门户登记,联系人停用后下一次访问即看不到数据;停用时系统同时清除其门户登记、撤销其门户邀请,并通知组织管理员 | | 淘汰、黑名单时清除门户登记 | 6.6.1 b) | 处置执行流程在淘汰、列入黑名单时清除该供应商全部联系人的门户登记 | | 准入未通过时回收 | 6.6.1 c) | 准入申请驳回、供应商退回潜在(或淘汰)时清除该供应商联系人的门户登记 | | 冻结不影响门户 | 6.6.2 | 冻结只改变供应商状态和供货资格,不改动门户登记 | @@ -260,7 +260,7 @@ SRM 系统上线后,上述记录以系统电子记录为主。 |----------------|----------------|----------------|--------------------------------| | 邀请 | {{org.position.category_manager}}、{{org.position.buyer_lead}} | 组织管理员 | 门户邀请为「已发送」;受邀人员以外部成员身份加入 | | 开通 | {{org.position.category_manager}}、{{org.position.buyer_lead}} | 组织管理员 | 任命门户用户岗位(门户管理员另任命门户管理员岗位),联系人完成门户登记 | -| 联系人离职或变动 | 供应商 | {{org.dept.procurement}}停用联系人;组织管理员撤销任命、移出成员 | 联系人停用即看不到数据 | +| 联系人离职或变动 | 供应商 | {{org.dept.procurement}}停用联系人;组织管理员撤销任命、移出成员 | 联系人停用即看不到数据,门户登记清除 | | 淘汰、列入黑名单 | 处置单 | 系统自动;组织管理员撤销任命、移出成员 | 全部联系人的门户登记清除 | | 准入未获批准 | 准入申请驳回 | 系统自动;组织管理员撤销任命、移出成员 | 供应商退回潜在(或淘汰)的,联系人的门户登记清除 | | 更换门户管理员 | 供应商(授权书) | 组织管理员 | 调整门户管理员岗位任命 | diff --git a/src/core/actions/index.ts b/src/core/actions/index.ts index dd7783e..c945bed 100644 --- a/src/core/actions/index.ts +++ b/src/core/actions/index.ts @@ -6,3 +6,4 @@ export { DispositionSubmitAction, DispositionCancelAction } from './disposition. export { ScoreSubmitAction, PerformanceVoidAction, AppealSubmitAction, AppealResolveAction, ScoreReassignAction } from './performance.actions.js'; export { ClaimRespondAction } from './quality-issue.actions.js'; export { ChangeSubmitAction, ApplicationResubmitAction, CertificateVerifyAction } from './portal.actions.js'; +export { PortalEnableAction, ApplicationSubmitAction } from './portal.actions.js'; diff --git a/src/core/actions/portal.actions.ts b/src/core/actions/portal.actions.ts index db66c21..69d3d59 100644 --- a/src/core/actions/portal.actions.ts +++ b/src/core/actions/portal.actions.ts @@ -44,3 +44,30 @@ export const CertificateVerifyAction = defineAction({ requiredPermissions: ['srm_verify_certificate'], refreshAfter: true, }); + +// 门户邀请上的「开通门户」(04 §3.3 第 2 步):组织管理员在成员页发出邀请、对方接受后运行。谁能开通由 flow 自己检查 +export const PortalEnableAction = defineAction({ + name: 'srm_portal_enable', + label: '开通门户', + objectName: 'srm_supplier_invitation', + icon: 'key-round', + type: 'flow', + target: 'srm_portal_enable', + locations: ['record_header', 'list_item'], + visible: P`record.status == 'sent'`, + refreshAfter: true, +}); + +// 门户公司资料上的「提交准入申请」(03 §3 第 1 步,FR-202):只对受邀的门户用户有效,flow 按调用者的名册行确定供应商 +export const ApplicationSubmitAction = defineAction({ + name: 'srm_application_submit', + label: '提交准入申请', + objectName: 'srm_supplier', + icon: 'send', + type: 'flow', + target: 'srm_application_submit', + locations: ['record_header'], + visible: P`record.status == 'potential' || record.status == 'eliminated'`, + requiredPermissions: ['srm_portal_access'], + refreshAfter: true, +}); diff --git a/src/core/apps/workbench.app.ts b/src/core/apps/workbench.app.ts index 2ed5c0a..91c2bce 100644 --- a/src/core/apps/workbench.app.ts +++ b/src/core/apps/workbench.app.ts @@ -80,6 +80,8 @@ export const Workbench = App.create({ { id: 'nav_bank_account', type: 'object', objectName: 'srm_supplier_bank_account', label: '银行账户', icon: 'landmark' }, { id: 'nav_change_request', type: 'object', objectName: 'srm_supplier_change_request', label: '变更单', icon: 'file-pen' }, { id: 'nav_disposition', type: 'object', objectName: 'srm_supplier_disposition', label: '处置单', icon: 'ban' }, + { id: 'nav_invitation', type: 'object', objectName: 'srm_supplier_invitation', label: '门户邀请', icon: 'mail-plus' }, + { id: 'nav_portal_accounts', type: 'object', objectName: 'srm_supplier_contact', viewName: 'portal_accounts', label: '门户账号', icon: 'key-round' }, ], }, { diff --git a/src/core/flows/admission/rejection.flow.ts b/src/core/flows/admission/rejection.flow.ts index b4a3c43..88f34cf 100644 --- a/src/core/flows/admission/rejection.flow.ts +++ b/src/core/flows/admission/rejection.flow.ts @@ -1,8 +1,12 @@ 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'; // P1 步骤 8(03 §3):申请未通过,供应商从「准入中」退回——普通申请回「潜在」, // 重新准入(reapply)的旧档回「淘汰」。黑名单申请在受理时就已驳回,供应商一直是黑名单,不动。 +// v0.6 起同时回收门户账号:清掉该供应商全部联系人上的 portal_user,门户邀请撤销,有门户账号时通知组织管理员撤销 +// 门户岗位任命并移出组织(04 §3.3)。再申请要重新邀请、重新开通。 export const AdmissionRejectionFlow: Automation.Flow = { name: 'srm_admission_rejection', label: '准入未通过', @@ -34,6 +38,28 @@ export const AdmissionRejectionFlow: Automation.Flow = { label: '供应商回到潜在', config: { objectName: 'srm_supplier', filter: { id: cel`record.supplier` }, fields: { status: 'potential' } }, }, + { + id: 'load_portal', + type: 'get_record', + label: '有门户账号?', + config: { objectName: 'srm_supplier_contact', filter: { supplier: cel`record.supplier`, portal_user: { $ne: null } }, outputVariable: 'portalContact' }, + }, + clearPortalUsers('revoke_portal', '回收门户账号', { supplier: cel`record.supplier` }), + revokeInvitations('revoke_invitations', { supplier: cel`record.supplier` }), + { id: 'had_portal', type: 'decision', label: '需要通知组织管理员?' }, + { + id: 'load_vendor', + type: 'get_record', + label: '读取供应商', + config: { objectName: 'srm_supplier', filter: { id: cel`record.supplier` }, fields: ['name'], outputVariable: 'vendor' }, + }, + loadOrgAdmins(), + notifyOrgAdmins('notify_admins', '通知组织管理员移除门户用户', { + title: '门户账号已回收:{{ vendor.name }}', + message: '准入申请 {{ record.application_no }} 未通过,{{ vendor.name }} 的门户访问已回收。请撤销该供应商门户用户的门户岗位任命,并把他们移出组织。', + sourceObject: 'srm_supplier_application', + sourceId: cel`record.id`, + }), { id: 'end', type: 'end', label: '结束' }, ], @@ -55,7 +81,15 @@ export const AdmissionRejectionFlow: Automation.Flow = { condition: P`!(has(record.duplicate_check) && record.duplicate_check == 'reapply')`, label: '普通申请', }, - { id: 'e2', source: 'supplier_eliminated', target: 'end', type: 'default' }, - { id: 'e3', source: 'supplier_potential', target: 'end', type: 'default' }, + { 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: '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: '有门户账号' }, + { id: 'e_none', source: 'had_portal', target: 'end', type: 'conditional', condition: P`portalContact == null`, label: '没有' }, + { id: 'e7', source: 'load_vendor', target: 'load_org_admins', type: 'default' }, + { id: 'e8', source: 'load_org_admins', target: 'notify_admins_guard', type: 'default' }, + { id: 'e9', source: 'notify_admins_guard', target: 'end', type: 'default' }, ], }; diff --git a/src/core/flows/disposition/disposition-apply.flow.ts b/src/core/flows/disposition/disposition-apply.flow.ts index 8ed70b1..199b9a5 100644 --- a/src/core/flows/disposition/disposition-apply.flow.ts +++ b/src/core/flows/disposition/disposition-apply.flow.ts @@ -1,5 +1,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'; // 执行供应商处置(02 §5.1,03 §5):审批全部通过后由 srm_disposition_approval 调用,以系统身份改供应商 // 状态和品类资格,处置单置为「已执行」。 @@ -60,19 +62,6 @@ const suspendAll = (id: string) => ({ }, }); -// 撤销门户访问(ADR-0010):成员键只认联系人上的 portal_user,清掉它就不再放行任何行;组织成员身份由管理员移除 -const revokePortal = (id: string) => ({ - id, - type: 'update_record' as const, - label: '撤销门户访问', - config: { - objectName: 'srm_supplier_contact', - filter: { supplier: cel`disp.supplier`, portal_user: { $ne: null } }, - multi: true, - fields: { portal_user: null }, - }, -}); - export const DispositionApplyFlow: Automation.Flow = { name: 'srm_disposition_apply', label: '执行供应商处置', @@ -115,7 +104,30 @@ export const DispositionApplyFlow: Automation.Flow = { blacklist_reason: cel`disp.reason`, }), suspendAll('suspend_all'), - revokePortal('revoke_portal'), + // 撤销门户访问(ADR-0010,04 §3.3):清掉该供应商全部联系人上的 portal_user,门户邀请一并撤销,通知组织管理员 + // 撤销门户岗位任命并把门户用户移出组织(系统身份写不了任命,objectstack#25251) + { + id: 'load_portal', + type: 'get_record', + label: '有门户账号?', + config: { objectName: 'srm_supplier_contact', filter: { supplier: cel`disp.supplier`, portal_user: { $ne: null } }, outputVariable: 'portalContact' }, + }, + clearPortalUsers('revoke_portal', '撤销门户访问', { supplier: cel`disp.supplier` }), + revokeInvitations('revoke_invitations', { supplier: cel`disp.supplier` }), + { id: 'had_portal', type: 'decision', label: '需要通知组织管理员?' }, + { + id: 'load_vendor', + type: 'get_record', + label: '读取供应商', + config: { objectName: 'srm_supplier', filter: { id: cel`disp.supplier` }, fields: ['name'], outputVariable: 'vendor' }, + }, + loadOrgAdmins(), + notifyOrgAdmins('notify_admins', '通知组织管理员移除门户用户', { + title: '门户访问已撤销:{{ vendor.name }}', + message: '处置单 {{ disp.disposition_no }} 已执行,{{ vendor.name }} 的门户访问已撤销。请撤销该供应商门户用户的门户岗位任命,并把他们移出组织。', + sourceObject: 'srm_supplier_disposition', + sourceId: cel`disp.id`, + }), setSupplier('remove_blacklist_supplier', '解除黑名单(转为淘汰)', 'eliminated'), { @@ -183,8 +195,15 @@ 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: 'revoke_portal', type: 'default' }, - { id: 'e_revoked', source: 'revoke_portal', target: 'done', type: 'default' }, + { id: 'e_suspended', source: 'suspend_all', 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' }, + { id: 'e_rv1', source: 'had_portal', target: 'load_vendor', type: 'conditional', condition: P`portalContact != null`, label: '有门户账号' }, + { id: 'e_rv5', source: 'had_portal', target: 'done', type: 'conditional', condition: P`portalContact == null`, label: '没有' }, + { id: 'e_rv2', source: 'load_vendor', target: 'load_org_admins', type: 'default' }, + { id: 'e_rv3', source: 'load_org_admins', target: 'notify_admins_guard', type: 'default' }, + { id: 'e_rv4', source: 'notify_admins_guard', target: 'done', type: 'default' }, { id: 'e_remove_blacklist', source: 'by_action', diff --git a/src/core/flows/index.ts b/src/core/flows/index.ts index 64ff887..a9e30c3 100644 --- a/src/core/flows/index.ts +++ b/src/core/flows/index.ts @@ -45,3 +45,8 @@ export { CertificateVerifyFlow } from './qualification/certificate-verify.flow.j export { ChangeSubmitFlow } from './change-request/change-submit.flow.js'; export { ApplicationResubmitFlow } from './admission/resubmit.flow.js'; export { BankPendingNoticeFlow } from './bank-account/bank-pending-notice.flow.js'; +export { InvitationSendFlow } from './portal/invitation-send.flow.js'; +export { InvitationExpireSweepFlow } from './portal/invitation-expire-sweep.flow.js'; +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'; diff --git a/src/core/flows/portal/application-submit.flow.ts b/src/core/flows/portal/application-submit.flow.ts new file mode 100644 index 0000000..9723107 --- /dev/null +++ b/src/core/flows/portal/application-submit.flow.ts @@ -0,0 +1,120 @@ +import { cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// 提交准入申请(03 §3 第 1 步,FR-202,v0.6):受邀供应商开通门户、补齐资料后,在门户的公司资料上提交准入申请。 +// 以系统身份运行:门户不授准入申请的新建权,也看不到品类表。系统身份读得到任何记录,而任何成员都能经 trigger 入口直接 +// 启动系统身份的 screen flow(objectstack#25481),所以隔离只能靠 flow 自己查名册:调用者必须是页面这家供应商的在职联系人 +// (与成员键 portal_supplier_ids 同一条件:portal_user = 调用者、active),否则直接结束;申请品类取门户邀请上品类经理填的品类, +// 来源记为「邀请」,申请回填到邀请。邀请要是发给调用者本人(联系人 = 调用者的名册行)、已开通、还没提交过申请的。之后受理照常由 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`; +const admissible = `vendor != null && (vendor.status == 'potential' || vendor.status == 'eliminated')`; + +export const ApplicationSubmitFlow: Automation.Flow = { + name: 'srm_application_submit', + label: '提交准入申请', + type: 'screen', + status: 'active', + runAs: 'system', + successMessage: '准入申请已提交,品类经理审核资料后会通知您', + + variables: [{ name: 'recordId', type: 'text', isInput: true, isOutput: false }], + + nodes: [ + { id: 'start', type: 'start', label: '开始' }, + { + id: 'load_membership', + type: 'get_record', + label: '调用者是这家供应商的门户用户?', + config: { + objectName: 'srm_supplier_contact', + filter: { portal_user: c(`current_user != null ? current_user.id : ''`), supplier: cel`recordId`, active: true }, + outputVariable: 'membership', + }, + }, + { + id: 'load_supplier', + type: 'get_record', + label: '读取本公司', + config: { objectName: 'srm_supplier', filter: { id: c(`membership != null ? membership.supplier : ''`) }, outputVariable: 'vendor' }, + }, + { + id: 'load_invitation', + type: 'get_record', + label: '读取待提交申请的邀请', + config: { + objectName: 'srm_supplier_invitation', + filter: { contact: c(`membership != null ? membership.id : ''`), status: 'accepted', application: null }, + outputVariable: 'invite', + }, + }, + { id: 'gate', type: 'decision', label: '可以提交?' }, + { + id: 'refuse_portal', + type: 'end', + label: '不是门户用户', + config: { outcome: 'refused', message: '准入申请由受邀供应商在门户提交;内部登记请在准入申请里新建' }, + }, + { + id: 'refuse_invitation', + type: 'end', + label: '没有待提交的邀请', + config: { outcome: 'refused', message: '没有待提交的准入邀请:申请已经提交,或邀请没有填申请品类' }, + }, + { + id: 'refuse_status', + type: 'end', + label: '供应商状态不可申请', + config: { outcome: 'refused', message: '贵公司当前状态不能提交准入申请,请联系对接的品类经理' }, + }, + { + id: 'confirm', + type: 'screen', + label: '确认提交', + config: { + title: '提交准入申请:{{ vendor.name }}', + description: '申请品类按邀请填写。提交前请先补齐公司资料、资质证书与银行账户;提交后由品类经理审核资料。', + waitForInput: true, + }, + }, + { + id: 'create', + type: 'create_record', + label: '新建准入申请', + config: { + objectName: 'srm_supplier_application', + fields: { supplier: cel`vendor.id`, applied_categories: cel`invite.categories`, source: 'invited' }, + outputVariable: 'app', + }, + }, + { + id: 'backfill', + type: 'update_record', + label: '回填邀请', + config: { objectName: 'srm_supplier_invitation', filter: { id: cel`invite.id` }, fields: { application: cel`app.id` } }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e0', source: 'start', target: 'load_membership', type: 'default' }, + { id: 'e1', source: 'load_membership', target: 'load_supplier', type: 'default' }, + { id: 'e2', source: 'load_supplier', target: 'load_invitation', type: 'default' }, + { id: 'e3', source: 'load_invitation', target: 'gate', type: 'default' }, + { id: 'e_portal', source: 'gate', target: 'refuse_portal', type: 'conditional', condition: c(`!(${portal})`), label: '不是门户用户' }, + { id: 'e_invite', source: 'gate', target: 'refuse_invitation', type: 'conditional', condition: c(`${portal} && !(${invited})`), label: '没有邀请' }, + { + id: 'e_status', + source: 'gate', + target: 'refuse_status', + type: 'conditional', + condition: c(`${portal} && ${invited} && !(${admissible})`), + label: '状态不可申请', + }, + { id: 'e_ok', source: 'gate', target: 'confirm', type: 'conditional', condition: c(`${portal} && ${invited} && ${admissible}`), label: '可以' }, + { id: 'e4', source: 'confirm', target: 'create', type: 'default' }, + { id: 'e5', source: 'create', target: 'backfill', type: 'default' }, + { id: 'e6', source: 'backfill', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/portal/contact-revoke.flow.ts b/src/core/flows/portal/contact-revoke.flow.ts new file mode 100644 index 0000000..4978d5a --- /dev/null +++ b/src/core/flows/portal/contact-revoke.flow.ts @@ -0,0 +1,53 @@ +import { P, cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; +import { loadOrgAdmins, notifyOrgAdmins } from './org-admins.js'; +import { clearPortalUsers, revokeInvitations } from './revoke.js'; + +// 联系人停用时收回门户账号(04 §3.3 回收):在职标志本身就是名册的过滤条件,停用即刻看不到数据;这里再清掉 portal_user, +// 免得日后有人把联系人改回在职时静默恢复门户访问——重新开通一律走门户邀请和「开通门户」。该联系人的门户邀请撤销, +// 并通知组织管理员撤销门户岗位任命、把该用户移出组织。 +export const PortalContactRevokeFlow: Automation.Flow = { + name: 'srm_portal_contact_revoke', + label: '联系人停用收回门户账号', + type: 'record_change', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: '开通了门户的联系人停用', + config: { + objectName: 'srm_supplier_contact', + triggerType: 'record-after-update', + condition: P`record.active == false && has(previous.active) && previous.active == true && has(record.portal_user) && record.portal_user != null`, + }, + }, + clearPortalUsers('clear', '清掉门户账号', { id: cel`record.id` }), + revokeInvitations('revoke_invitations', { contact: cel`record.id` }), + { + id: 'load_supplier', + type: 'get_record', + label: '读取供应商', + config: { objectName: 'srm_supplier', filter: { id: cel`record.supplier` }, fields: ['name'], outputVariable: 'vendor' }, + }, + loadOrgAdmins(), + notifyOrgAdmins('notify_admins', '通知组织管理员撤销任命', { + title: '门户账号已收回:{{ vendor.name }} {{ record.name }}', + message: '联系人 {{ record.name }}({{ record.email }})已停用,门户访问已收回。请撤销该用户的门户岗位任命,并把该用户移出组织。', + sourceObject: 'srm_supplier_contact', + sourceId: cel`record.id`, + }), + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'clear', type: 'default' }, + { id: 'e2', source: 'clear', target: 'revoke_invitations', type: 'default' }, + { id: 'e3', source: 'revoke_invitations', target: 'load_supplier', type: 'default' }, + { id: 'e4', source: 'load_supplier', target: 'load_org_admins', type: 'default' }, + { id: 'e5', source: 'load_org_admins', target: 'notify_admins_guard', type: 'default' }, + { id: 'e6', source: 'notify_admins_guard', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/portal/invitation-expire-sweep.flow.ts b/src/core/flows/portal/invitation-expire-sweep.flow.ts new file mode 100644 index 0000000..ce32cae --- /dev/null +++ b/src/core/flows/portal/invitation-expire-sweep.flow.ts @@ -0,0 +1,33 @@ +import { cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// 每日追赶式扫描(03 §13):过了开通期限仍「已发送」的门户邀请置为「已过期」。重复运行不重复处理(03 §2.3 R2)。 +// 组织邀请本身的有效期由平台管理;要继续开通的,品类经理新建一张门户邀请 +export const InvitationExpireSweepFlow: Automation.Flow = { + name: 'srm_invitation_expire_sweep', + label: '门户邀请过期扫描', + type: 'schedule', + status: 'active', + runAs: 'system', + + nodes: [ + { id: 'start', type: 'start', label: '每日 01:20', config: { schedule: '20 1 * * *' } }, + { + id: 'expire', + type: 'update_record', + label: '置为已过期', + config: { + objectName: 'srm_supplier_invitation', + filter: { status: 'sent', expires_on: { $lt: cel`isoDate(today())` } }, + multi: true, + fields: { status: 'expired' }, + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'expire', type: 'default' }, + { id: 'e2', source: 'expire', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/portal/invitation-send.flow.ts b/src/core/flows/portal/invitation-send.flow.ts new file mode 100644 index 0000000..7b77dea --- /dev/null +++ b/src/core/flows/portal/invitation-send.flow.ts @@ -0,0 +1,69 @@ +import { cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; +import { loadOrgAdmins, notifyOrgAdmins } from './org-admins.js'; + +// 门户邀请新建后(04 §3.3 第 1 步前):flow 发不了组织邀请(07 §6.2 V3),所以这里只把邀请变成组织管理员的待办—— +// 从联系人复制邀请邮箱、写开通期限,并通知组织管理员按邮箱在成员页邀请(角色 external),对方接受后运行「开通门户」。 +// 开通期限取 INVITATION_VALID_DAYS(示例 14 天),到期仍未开通的由 srm_invitation_expire_sweep 置为已过期。 +export const INVITATION_VALID_DAYS = 14; + +export const InvitationSendFlow: Automation.Flow = { + name: 'srm_invitation_send', + label: '门户邀请待开通', + type: 'record_change', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: '新建门户邀请', + config: { objectName: 'srm_supplier_invitation', triggerType: 'record-after-create' }, + }, + { + id: 'load_contact', + type: 'get_record', + label: '读取联系人', + config: { objectName: 'srm_supplier_contact', filter: { id: cel`record.contact` }, outputVariable: 'contact' }, + }, + { + id: 'load_supplier', + type: 'get_record', + label: '读取供应商', + config: { objectName: 'srm_supplier', filter: { id: cel`record.supplier` }, fields: ['name'], outputVariable: 'vendor' }, + }, + { + id: 'fill', + type: 'update_record', + label: '写入邀请邮箱与开通期限', + config: { + objectName: 'srm_supplier_invitation', + filter: { id: cel`record.id` }, + fields: { + contact_email: cel`contact.email`, + expires_on: { dialect: 'cel', source: `isoDate(addDays(today(), ${INVITATION_VALID_DAYS}))` }, + }, + }, + }, + loadOrgAdmins(), + notifyOrgAdmins('notify_admins', '通知组织管理员开通', { + title: '门户开通待办:{{ vendor.name }}', + message: + '门户邀请 {{ record.invitation_no }}:请在组织成员页按 {{ contact.email }} 发出邀请,角色选「external」;' + + `对方注册并接受邀请后,在这张门户邀请上点「开通门户」。${INVITATION_VALID_DAYS} 天内未开通的邀请自动过期。`, + sourceObject: 'srm_supplier_invitation', + sourceId: cel`record.id`, + }), + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'load_contact', type: 'default' }, + { id: 'e2', source: 'load_contact', target: 'load_supplier', type: 'default' }, + { id: 'e3', source: 'load_supplier', target: 'fill', type: 'default' }, + { id: 'e4', source: 'fill', target: 'load_org_admins', type: 'default' }, + { id: 'e5', source: 'load_org_admins', target: 'notify_admins_guard', type: 'default' }, + { id: 'e6', source: 'notify_admins_guard', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/portal/org-admins.ts b/src/core/flows/portal/org-admins.ts new file mode 100644 index 0000000..bbf2b9e --- /dev/null +++ b/src/core/flows/portal/org-admins.ts @@ -0,0 +1,37 @@ +import { cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// 组织管理员(组织角色 owner / admin):只有他们能发组织邀请、写门户岗位任命(07 §6.2 V2、V3), +// 所以门户开通需求和回收后的善后都通知他们(04 §3.3)。以系统身份运行的 flow 读成员表 +export const ORG_ADMIN_ROLES = ['owner', 'admin']; + +export const loadOrgAdmins = (id = 'load_org_admins'): Automation.FlowNode => ({ + id, + type: 'get_record', + label: '读取组织管理员', + config: { objectName: 'sys_member', filter: { role: { $in: ORG_ADMIN_ROLES } }, limit: 50, outputVariable: 'orgAdmins' }, +}); + +// 通知失败不影响业务写入:包在 try_catch 里 +export const notifyOrgAdmins = ( + id: string, + label: string, + config: { title: string; message: string; sourceObject: string; sourceId: ReturnType }, +): Automation.FlowNode => ({ + id: `${id}_guard`, + type: 'try_catch', + label, + config: { + try: { + nodes: [ + { + id, + type: 'notify', + label, + config: { recipients: cel`orgAdmins.map(m, m.user_id)`, severity: 'warning', ...config }, + }, + ], + }, + catch: { nodes: [{ id: `${id}_skip`, type: 'assignment', label: '跳过通知' }] }, + }, +} as Automation.FlowNode); diff --git a/src/core/flows/portal/portal-enable.flow.ts b/src/core/flows/portal/portal-enable.flow.ts new file mode 100644 index 0000000..153abc3 --- /dev/null +++ b/src/core/flows/portal/portal-enable.flow.ts @@ -0,0 +1,326 @@ +import { P, cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; +import { ORG_ADMIN_ROLES } from './org-admins.js'; + +// 开通门户(04 §3.3 第 2 步,ADR-0010):组织管理员已在成员页按邀请邮箱发出邀请(角色 external),对方注册并接受后, +// 在门户邀请上运行本 flow,选择已加入的用户: +// 1. 把用户任命到静态岗位 srm_portal_user,邀请上勾了「授予门户管理员」(附授权书,GL-10 6.3.1 c)时再任命到 srm_portal_admin; +// 2. 经子 flow srm_portal_roster_write 在联系人上写 portal_user,邀请置为「已开通」。写入后下一个请求起该用户只能看到本公司数据。 +// +// 身份为什么分两段(07 §6.2 V2,U3 实测 2026-11-02): +// - 任命是身份数据行,runAs: 'system' 的 flow 不得写(objectstack#25251);runAs: 'user' 只有组织管理员启动时能写, +// 其他人启动被平台拒绝。所以本 flow 是 runAs: 'user'; +// - portal_user 是字段级只读(任何人都不能在界面或 REST 上写),runAs: 'user' 的写入被数据层剔除(节点结果只给警告、 +// 写入照样算成功,平台既定行为,07 §6.3 PF19);所以由 runAs: 'system' 的 autolaunched 子 flow 写。平台对任何调用者(含平台管理员)都拒绝直接启动系统身份的 +// autolaunched flow,子 flow 只能经本 flow 进入; +// - 本 flow 进门先查操作人的组织角色,不是 owner / admin 就结束:子 flow 以系统身份写名册,这道检查是名册写入前的 +// 最后一道门(第一次开通时还有平台对任命的检查;已持有门户岗位的用户再开通到另一位联系人上时不再任命)。 +// 选的用户必须是 external 成员(内部员工不进门户名册),且没有登记在任何联系人上:登记在两家时两家的数据都能看到, +// 平台没有「非空值唯一」的约束可以兜底(ADR-0010 后果)。 +const PORTAL_USER = 'srm_portal_user'; +const PORTAL_ADMIN = 'srm_portal_admin'; +const c = (source: string) => ({ dialect: 'cel' as const, source }); + +const isAdmin = `operator != null`; +const open = `invite != null && invite.status == 'sent'`; +const ready = `contact != null && contact.active == true && !(has(contact.portal_user) && contact.portal_user != null)`; +const admissible = `vendor != null && vendor.status != 'blacklisted'`; +const isPortalAdmin = `invite.portal_admin == true`; + +const assign = (id: string, label: string, position: string): Automation.FlowNode => ({ + id, + type: 'create_record', + label, + config: { + objectName: 'sys_user_position', + fields: { user_id: cel`portal_account`, position, organization_id: cel`member.organization_id` }, + }, +}); + +export const PortalEnableFlow: Automation.Flow = { + name: 'srm_portal_enable', + label: '开通门户', + type: 'screen', + status: 'active', + runAs: 'user', + successMessage: '门户已开通:该用户下一次请求起可以看到本公司的数据', + + variables: [{ name: 'recordId', type: 'text', isInput: true, isOutput: false }], + + nodes: [ + { id: 'start', type: 'start', label: '开始' }, + { + id: 'load_operator', + type: 'get_record', + label: '操作人是组织管理员?', + config: { + objectName: 'sys_member', + filter: { user_id: c(`current_user != null ? current_user.id : ''`), role: { $in: ORG_ADMIN_ROLES } }, + outputVariable: 'operator', + }, + }, + { id: 'gate_admin', type: 'decision', label: '组织管理员?' }, + { + id: 'refuse_admin', + type: 'end', + label: '不是组织管理员', + config: { outcome: 'refused', message: '门户由组织管理员开通;品类经理、采购主管请新建门户邀请,由组织管理员处理' }, + }, + { + id: 'load', + type: 'get_record', + label: '读取门户邀请', + config: { objectName: 'srm_supplier_invitation', filter: { id: cel`recordId` }, outputVariable: 'invite' }, + }, + { + id: 'load_contact', + type: 'get_record', + label: '读取联系人', + config: { objectName: 'srm_supplier_contact', filter: { id: c(`invite != null ? invite.contact : ''`) }, outputVariable: 'contact' }, + }, + { + id: 'load_supplier', + type: 'get_record', + label: '读取供应商', + config: { objectName: 'srm_supplier', filter: { id: c(`invite != null ? invite.supplier : ''`) }, fields: ['name', 'status'], outputVariable: 'vendor' }, + }, + { id: 'gate', type: 'decision', label: '可以开通?' }, + { + id: 'refuse_status', + type: 'end', + label: '不是待开通', + config: { outcome: 'refused', message: '只有「已发送」的门户邀请可以开通;已过期或已撤销的请品类经理重新邀请' }, + }, + { + id: 'refuse_blacklisted', + type: 'end', + label: '黑名单供应商', + config: { outcome: 'refused', message: '黑名单供应商不能开通门户' }, + }, + { + id: 'refuse_contact', + type: 'end', + label: '联系人不可开通', + config: { outcome: 'refused', message: '联系人已停用或已开通门户账号' }, + }, + { + id: 'pick', + type: 'screen', + label: '选择门户账号', + config: { + title: '开通门户:{{ vendor.name }}', + description: + '联系人:{{ contact.name }} 邀请邮箱:{{ invite.contact_email }}\n' + + '请先在组织成员页按这个邮箱发出邀请(角色选 external),对方注册并接受邀请后,在这里选择该用户。' + + '开通后该用户只能看到这家供应商的数据。', + fields: [{ name: 'portal_account', label: '门户账号', type: 'lookup', reference: 'sys_user', required: true }], + }, + }, + { + id: 'load_member', + type: 'get_record', + label: '读取所选用户的组织成员身份', + config: { + objectName: 'sys_member', + filter: { user_id: cel`portal_account`, organization_id: cel`operator.organization_id` }, + outputVariable: 'member', + }, + }, + { + id: 'load_taken', + type: 'get_record', + label: '所选用户已登记在联系人上?', + config: { objectName: 'srm_supplier_contact', filter: { portal_user: cel`portal_account` }, outputVariable: 'taken' }, + }, + { id: 'check', type: 'decision', label: '所选用户合适?' }, + { + id: 'refuse_member', + type: 'end', + label: '还不是组织成员', + config: { outcome: 'refused', message: '该用户还没有加入组织:请先在成员页按邀请邮箱发出邀请(角色 external),对方接受后再开通' }, + }, + { + id: 'refuse_role', + type: 'end', + label: '不是外部成员', + config: { outcome: 'refused', message: '门户账号必须以 external(外部成员)角色加入组织;内部员工不能开通为门户账号' }, + }, + { + id: 'refuse_taken', + type: 'end', + label: '已登记在别的联系人上', + config: { outcome: 'refused', message: '该用户已经是另一位联系人的门户账号;一个门户账号只能对应一位联系人' }, + }, + { + id: 'load_held_user', + type: 'get_record', + label: '已任命门户用户岗位?', + config: { objectName: 'sys_user_position', filter: { user_id: cel`portal_account`, position: PORTAL_USER }, outputVariable: 'heldUser' }, + }, + { + id: 'load_held_admin', + type: 'get_record', + label: '已任命门户管理员岗位?', + config: { objectName: 'sys_user_position', filter: { user_id: cel`portal_account`, position: PORTAL_ADMIN }, outputVariable: 'heldAdmin' }, + }, + { id: 'need_user', type: 'decision', label: '任命门户用户?' }, + assign('assign_user', '任命到门户用户岗位', PORTAL_USER), + { id: 'need_admin', type: 'decision', label: '任命门户管理员?' }, + assign('assign_admin', '任命到门户管理员岗位', PORTAL_ADMIN), + { + id: 'roster', + type: 'subflow', + label: '登记门户账号', + config: { + flowName: 'srm_portal_roster_write', + input: { + invitationId: cel`invite.id`, + contactId: cel`contact.id`, + userId: cel`portal_account`, + operatorId: cel`current_user.id`, + }, + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e0', source: 'start', target: 'load_operator', type: 'default' }, + { id: 'e1', source: 'load_operator', target: 'gate_admin', type: 'default' }, + { id: 'e_not_admin', source: 'gate_admin', target: 'refuse_admin', type: 'conditional', condition: c(`!(${isAdmin})`), label: '不是' }, + { id: 'e_admin', source: 'gate_admin', target: 'load', type: 'conditional', condition: c(isAdmin), label: '是' }, + { id: 'e2', source: 'load', target: 'load_contact', type: 'default' }, + { id: 'e3', source: 'load_contact', target: 'load_supplier', type: 'default' }, + { id: 'e4', source: 'load_supplier', target: 'gate', type: 'default' }, + { id: 'e_status', source: 'gate', target: 'refuse_status', type: 'conditional', condition: c(`!(${open})`), label: '不是待开通' }, + { + id: 'e_blacklisted', + source: 'gate', + target: 'refuse_blacklisted', + type: 'conditional', + condition: c(`${open} && !(${admissible})`), + label: '黑名单', + }, + { + id: 'e_contact', + source: 'gate', + target: 'refuse_contact', + type: 'conditional', + condition: c(`${open} && ${admissible} && !(${ready})`), + label: '联系人不可开通', + }, + { id: 'e_ok', source: 'gate', target: 'pick', type: 'conditional', condition: c(`${open} && ${admissible} && ${ready}`), label: '可以' }, + { id: 'e5', source: 'pick', target: 'load_member', type: 'default' }, + { id: 'e6', source: 'load_member', target: 'load_taken', type: 'default' }, + { id: 'e7', source: 'load_taken', target: 'check', type: 'default' }, + { id: 'e_no_member', source: 'check', target: 'refuse_member', type: 'conditional', condition: P`member == null`, label: '不是成员' }, + { id: 'e_role', source: 'check', target: 'refuse_role', type: 'conditional', condition: P`member != null && member.role != 'external'`, label: '不是外部成员' }, + { + id: 'e_taken', + source: 'check', + target: 'refuse_taken', + type: 'conditional', + condition: P`member != null && member.role == 'external' && taken != null`, + label: '已登记', + }, + { + id: 'e_fit', + source: 'check', + target: 'load_held_user', + type: 'conditional', + condition: P`member != null && member.role == 'external' && taken == null`, + label: '合适', + }, + { id: 'e8', source: 'load_held_user', target: 'load_held_admin', type: 'default' }, + { id: 'e9', source: 'load_held_admin', target: 'need_user', type: 'default' }, + { id: 'e_assign_user', source: 'need_user', target: 'assign_user', type: 'conditional', condition: P`heldUser == null`, label: '未任命' }, + { id: 'e_has_user', source: 'need_user', target: 'need_admin', type: 'conditional', condition: P`heldUser != null`, label: '已任命' }, + { id: 'e10', source: 'assign_user', target: 'need_admin', type: 'default' }, + { + id: 'e_assign_admin', + source: 'need_admin', + target: 'assign_admin', + type: 'conditional', + condition: c(`${isPortalAdmin} && heldAdmin == null`), + label: '主账号,未任命', + }, + { + id: 'e_no_admin', + source: 'need_admin', + target: 'roster', + type: 'conditional', + condition: c(`!(${isPortalAdmin}) || heldAdmin != null`), + label: '不需要', + }, + { id: 'e11', source: 'assign_admin', target: 'roster', type: 'default' }, + { id: 'e12', source: 'roster', target: 'end', type: 'default' }, + ], +}; + +// 名册写入(ADR-0010「授权就是写名册」):只由 srm_portal_enable 调用。portal_user 字段级只读,只有系统身份写得进去; +// 平台拒绝任何调用者直接启动系统身份的 autolaunched flow。写入前自己再核一遍「一人一号」(该用户没有登记在任何联系人上), +// 写入条件也限定联系人在职、尚未登记门户账号、邀请仍是已发送,免得并发的两次开通互相覆盖 +export const PortalRosterWriteFlow: Automation.Flow = { + name: 'srm_portal_roster_write', + label: '登记门户账号', + type: 'autolaunched', + status: 'active', + runAs: 'system', + + variables: [ + { name: 'invitationId', type: 'text', isInput: true, isOutput: false }, + { name: 'contactId', type: 'text', isInput: true, isOutput: false }, + { name: 'userId', type: 'text', isInput: true, isOutput: false }, + { name: 'operatorId', type: 'text', isInput: true, isOutput: false }, + ], + + nodes: [ + { id: 'start', type: 'start', label: '开始' }, + { + id: 'load_taken', + type: 'get_record', + label: '该用户已登记在联系人上?', + config: { objectName: 'srm_supplier_contact', filter: { portal_user: cel`userId` }, outputVariable: 'taken' }, + }, + { id: 'check', type: 'decision', label: '一人一号?' }, + { + id: 'refuse_taken', + type: 'end', + label: '已登记在别的联系人上', + config: { outcome: 'refused', message: '该用户已经是另一位联系人的门户账号;一个门户账号只能对应一位联系人' }, + }, + { + id: 'write_contact', + type: 'update_record', + label: '联系人写入门户账号', + config: { + objectName: 'srm_supplier_contact', + filter: { id: cel`contactId`, active: true, portal_user: null }, + // 带条件的写(比对后写入)要走 multi 路径,平台才按全部条件匹配,至多写这一行 + multi: true, + fields: { portal_user: cel`userId` }, + }, + }, + { + id: 'write_invitation', + type: 'update_record', + label: '邀请置为已开通', + config: { + objectName: 'srm_supplier_invitation', + filter: { id: cel`invitationId`, status: 'sent' }, + multi: true, + fields: { status: 'accepted', portal_user: cel`userId`, enabled_by: cel`operatorId`, enabled_at: cel`isoDatetime(now())` }, + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e0', source: 'start', target: 'load_taken', type: 'default' }, + { id: 'e1', source: 'load_taken', target: 'check', type: 'default' }, + { id: 'e_taken', source: 'check', target: 'refuse_taken', type: 'conditional', condition: P`taken != null`, label: '已登记' }, + { id: 'e_free', source: 'check', target: 'write_contact', type: 'conditional', condition: P`taken == null`, label: '未登记' }, + { id: 'e2', source: 'write_contact', target: 'write_invitation', type: 'default' }, + { id: 'e3', source: 'write_invitation', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/portal/revoke.ts b/src/core/flows/portal/revoke.ts new file mode 100644 index 0000000..06be81a --- /dev/null +++ b/src/core/flows/portal/revoke.ts @@ -0,0 +1,30 @@ +import type * as Automation from '@objectstack/spec/automation'; + +type Expr = unknown; + +// 回收门户访问的公共节点(04 §3.3 回收,ADR-0010)。成员键只认在职联系人上的 portal_user,清掉它下一个请求起就不再 +// 放行任何行;岗位任命与组织成员身份由组织管理员随后移除(系统身份的 flow 写不了任命,objectstack#25251)。 +export const clearPortalUsers = (id: string, label: string, filter: Record): Automation.FlowNode => ({ + id, + type: 'update_record', + label, + config: { + objectName: 'srm_supplier_contact', + filter: { ...filter, portal_user: { $ne: null } }, + multi: true, + fields: { portal_user: null }, + }, +}); + +// 待开通和已开通的门户邀请一并撤销:待开通的不能再开通,已开通的在列表上显示门户已收回 +export const revokeInvitations = (id: string, filter: Record): Automation.FlowNode => ({ + id, + type: 'update_record', + label: '撤销门户邀请', + config: { + objectName: 'srm_supplier_invitation', + filter: { ...filter, status: { $in: ['sent', 'accepted'] } }, + multi: true, + fields: { status: 'revoked' }, + }, +}); diff --git a/src/core/objects/index.ts b/src/core/objects/index.ts index edfe30f..9439ab6 100644 --- a/src/core/objects/index.ts +++ b/src/core/objects/index.ts @@ -8,6 +8,7 @@ export { SupplierApplication } from './supplier-application.object.js'; export { SupplierBankAccount } from './supplier-bank-account.object.js'; export { SupplierChangeRequest } from './supplier-change-request.object.js'; export { SupplierDisposition } from './supplier-disposition.object.js'; +export { SupplierInvitation } from './supplier-invitation.object.js'; export { Material } from './material.object.js'; export { Contract } from './contract.object.js'; export { ContractLine } from './contract-line.object.js'; diff --git a/src/core/objects/supplier-application.object.ts b/src/core/objects/supplier-application.object.ts index 6b1046e..d0d39ae 100644 --- a/src/core/objects/supplier-application.object.ts +++ b/src/core/objects/supplier-application.object.ts @@ -31,6 +31,17 @@ export const SupplierApplication = ObjectSchema.create({ multiple: true, required: true, }, + // 来源:内部登记(品类经理在工作台新建)或邀请(v0.6,受邀供应商在门户经「提交准入申请」提交,由该 flow 写入) + source: Field.select({ + label: '来源', + group: 'application', + readonly: true, + defaultValue: 'internal', + options: [ + { label: '内部登记', value: 'internal' }, + { label: '邀请', value: 'invited' }, + ], + }), status: Field.select({ label: '状态', group: 'review', diff --git a/src/core/objects/supplier-invitation.object.ts b/src/core/objects/supplier-invitation.object.ts new file mode 100644 index 0000000..8d2d082 --- /dev/null +++ b/src/core/objects/supplier-invitation.object.ts @@ -0,0 +1,136 @@ +import { P } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +// 门户邀请(02 §4.2,04 §3.3,v0.6):品类经理、采购主管提出的门户开通需求,记录邀请的业务信息和进度。 +// 组织邀请本身由组织管理员在成员页发(flow 发不了,07 §6.2 V3),门户访问由组织管理员运行「开通门户」写入联系人的 +// portal_user 后才生效(ADR-0010)。所以邀请必须指向已登记的供应商和联系人:门户用户只能经名册看到数据,没有供应商 +// 档案就无法隔离。邀请按供应商状态分两类:潜在、淘汰的供应商是准入邀请(新公司先登记为潜在供应商,唯一约束查重), +// 必填申请品类,开通后在门户提交准入申请;准入中、合格、冻结的供应商只开通门户,不填品类;黑名单不得邀请。 +// 状态、过期日、开通结果只由 flow 写:srm_invitation_send 补邮箱与过期日并通知组织管理员,srm_portal_enable 开通, +// srm_invitation_expire_sweep 过期,处置执行、准入未通过、联系人停用时撤销。门户不可见(04 §3.2)。 +// 建好后供应商和联系人不再改:邀请邮箱、开通期限按新建时的联系人写入,要换人另建一张邀请 +const created = P`has(record.id) && record.id != null`; + +export const SupplierInvitation = ObjectSchema.create({ + name: 'srm_supplier_invitation', + label: '门户邀请', + pluralLabel: '门户邀请', + icon: 'mail-plus', + sharingModel: 'private', + enable: { trackHistory: true }, + stageField: 'status', + nameField: 'invitation_no', + + fieldGroups: [ + { key: 'invitation', label: '邀请信息', icon: 'mail-plus' }, + { key: 'progress', label: '开通进度', icon: 'key-round' }, + ], + + fields: { + invitation_no: Field.autonumber({ label: '邀请编号', group: 'invitation', autonumberFormat: 'INV{YYYY}{00000}' }), + supplier: Field.lookup('srm_supplier', { label: '供应商', group: 'invitation', required: true, readonlyWhen: created }), + contact: Field.lookup('srm_supplier_contact', { label: '联系人', group: 'invitation', required: true, readonlyWhen: created }), + // 新建时由 srm_invitation_send 从联系人复制:组织管理员按它在成员页发邀请 + contact_email: Field.email({ label: '邀请邮箱', group: 'invitation', readonly: true }), + // 准入邀请填申请品类:潜在或淘汰的供应商开通门户后,按这些品类在门户提交准入申请(source = invited) + categories: { + type: 'lookup', + label: '申请品类', + group: 'invitation', + reference: 'srm_category', + multiple: true, + }, + // 门户管理员按人授权(GL-10 6.3.1 c、6.4.3):要供应商盖章的授权书,开通时再任命到 srm_portal_admin + portal_admin: Field.boolean({ label: '授予门户管理员', group: 'invitation', defaultValue: false }), + admin_authorization: Field.file({ + label: '门户管理员授权书', + group: 'invitation', + requiredWhen: P`record.portal_admin == true`, + visibleWhen: P`record.portal_admin == true`, + }), + notes: Field.textarea({ label: '说明', group: 'invitation' }), + status: Field.select({ + label: '状态', + group: 'progress', + readonly: true, + defaultValue: 'sent', + options: [ + // 已发给组织管理员,等对方接受组织邀请后开通 + { label: '已发送', value: 'sent', kind: 'open' }, + { label: '已开通', value: 'accepted', kind: 'won' }, + { label: '已过期', value: 'expired', kind: 'lost' }, + { label: '已撤销', value: 'revoked', kind: 'lost' }, + ], + }), + expires_on: Field.date({ label: '开通期限', group: 'progress', readonly: true }), + portal_user: Field.user({ label: '门户账号', group: 'progress', readonly: true }), + enabled_by: Field.user({ label: '开通人', group: 'progress', readonly: true }), + enabled_at: Field.datetime({ label: '开通时间', group: 'progress', readonly: true }), + // 供应商在门户提交准入申请后由 srm_application_submit 回填 + application: Field.lookup('srm_supplier_application', { label: '准入申请', group: 'progress', readonly: true }), + }, + + validations: [ + { + name: 'invitation_status_flow', + type: 'state_machine', + field: 'status', + message: '门户邀请状态不允许这样变更', + initialStates: ['sent'], + transitions: { + sent: ['accepted', 'expired', 'revoked'], + accepted: ['revoked'], + }, + }, + { + name: 'invitation_contact_of_supplier', + type: 'script', + severity: 'error', + message: '联系人必须属于这家供应商', + condition: P`has(record.contact.supplier) && record.contact.supplier != record.supplier`, + }, + { + name: 'invitation_contact_active', + type: 'script', + severity: 'error', + events: ['insert'], + message: '只能邀请在职的联系人', + condition: P`has(record.contact.active) && record.contact.active != true`, + }, + { + name: 'invitation_contact_not_enabled', + type: 'script', + severity: 'error', + events: ['insert'], + message: '这位联系人已开通门户账号', + condition: P`has(record.contact.portal_user) && record.contact.portal_user != null`, + }, + { + // 黑名单供应商不得开通门户(BR-02);淘汰的供应商可以为重新准入邀请(FR-208) + name: 'invitation_supplier_not_blacklisted', + type: 'script', + severity: 'error', + events: ['insert'], + message: '黑名单供应商不能开通门户', + condition: P`has(record.supplier.status) && record.supplier.status == 'blacklisted'`, + }, + { + // 准入邀请(潜在、淘汰的供应商)必填申请品类:供应商开通后按这些品类提交准入申请 + name: 'invitation_categories_required', + type: 'script', + severity: 'error', + events: ['insert'], + message: '「潜在」或「淘汰」状态的供应商是准入邀请,请填写申请品类', + condition: P`has(record.supplier.status) && (record.supplier.status == 'potential' || record.supplier.status == 'eliminated') && !(has(record.categories) && record.categories != null && size(record.categories) > 0)`, + }, + { + // 申请品类只用于准入:已在准入中或已合格的供应商只开通门户,不再提交申请 + name: 'invitation_categories_for_admission', + type: 'script', + severity: 'error', + events: ['insert'], + message: '只有「潜在」或「淘汰」状态的供应商邀请时填申请品类', + condition: P`has(record.categories) && record.categories != null && size(record.categories) > 0 && has(record.supplier.status) && record.supplier.status != 'potential' && record.supplier.status != 'eliminated'`, + }, + ], +}); diff --git a/src/core/security/permission-sets.ts b/src/core/security/permission-sets.ts index 0e84cc2..bc6a696 100644 --- a/src/core/security/permission-sets.ts +++ b/src/core/security/permission-sets.ts @@ -75,6 +75,8 @@ export const BuyerPermissionSet = { // 其余字段 FLS 只读 srm_contract: { ...read, allowEdit: true }, srm_contract_line: read, + // 提出门户开通需求(04 §2.3、§3.3):新建门户邀请,状态与开通结果只由 flow 写 + srm_supplier_invitation: { ...write, allowEdit: false }, }, fields: { ...contractFieldsReadOnly, @@ -110,6 +112,8 @@ export const SupplierAdminPermissionSet = { srm_supplier_disposition: write, // 登记与处理风险事件(04 §2.3,v0.5);来源、相关证书、处置单只由 flow 写入(对象上的 readonly) srm_risk_event: write, + // 提出门户开通需求(04 §3.3):开通本身由组织管理员运行「开通门户」;状态与开通结果只由 flow 写(对象上的 readonly) + srm_supplier_invitation: write, }, // 银行账号完整可读(BR-20,04 §4.2) systemPermissions: ['srm_view_bank_account_no'], @@ -193,6 +197,7 @@ export const ExecutivePermissionSet = { srm_contract: read, srm_contract_line: read, srm_supplier_disposition: read, + srm_supplier_invitation: read, srm_delivery_record: read, srm_quality_issue: read, srm_supplier_audit: read, @@ -264,6 +269,8 @@ export const AdminPermissionSet = { // 删除错误生成的考评单(评分明细随之删除)后可补跑生成;删系统生成的记录要 modifyAllRecords,同交付记录 srm_performance_review: { ...read, allowDelete: true, modifyAllRecords: true }, srm_performance_score: { ...read, allowDelete: true }, + // 组织管理员通常兼任 SRM 系统管理员:「开通门户」以操作人身份读门户邀请(04 §3.3) + srm_supplier_invitation: read, }, fields: { 'srm_supplier.status': flowOwned, @@ -303,6 +310,7 @@ export const AuditorPermissionSet = { srm_contract: read, srm_contract_line: read, srm_supplier_disposition: read, + srm_supplier_invitation: read, srm_delivery_record: read, srm_quality_issue: read, srm_supplier_audit: read, diff --git a/src/core/translations/en.ts b/src/core/translations/en.ts index cd8640c..3096688 100644 --- a/src/core/translations/en.ts +++ b/src/core/translations/en.ts @@ -146,6 +146,7 @@ export const en: TranslationData = { confirmText: "Confirm that this supplier's record, contacts and certificates have been checked?", successMessage: 'Review confirmed', }, + srm_application_submit: { label: 'Submit Admission Application' }, }, }, srm_supplier_contact: { @@ -169,6 +170,10 @@ export const en: TranslationData = { active: { label: 'Active' }, portal_user: { label: 'Portal Account' }, }, + _views: { + default: { label: 'All Contacts' }, + portal_accounts: { label: 'Portal Accounts' }, + }, }, srm_supplier_certificate: { label: 'Certificate', @@ -312,6 +317,42 @@ export const en: TranslationData = { freeze_disposition: { label: 'Freeze Disposition' }, }, }, + srm_supplier_invitation: { + label: 'Portal Invitation', + pluralLabel: 'Portal Invitations', + _sections: { invitation: { label: 'Invitation' }, progress: { label: 'Portal Opening' } }, + _validations: { + invitation_status_flow: { message: 'This invitation status change is not allowed' }, + invitation_contact_of_supplier: { message: 'The contact must belong to this supplier' }, + invitation_contact_active: { message: 'Only an active contact can be invited' }, + invitation_contact_not_enabled: { message: 'This contact already has a portal account' }, + invitation_supplier_not_blacklisted: { message: 'A blacklisted supplier cannot be given portal access' }, + invitation_categories_required: { message: 'A Potential or Eliminated supplier is invited for admission: enter the applied categories' }, + invitation_categories_for_admission: { message: 'Applied categories are entered only when inviting a Potential or Eliminated supplier' }, + }, + _actions: { srm_portal_enable: { label: 'Open Portal' } }, + _views: { + default: { label: 'All Portal Invitations' }, + sent: { label: 'To Open' }, + accepted: { label: 'Opened' }, + }, + fields: { + invitation_no: { label: 'Invitation No.' }, + supplier: { label: 'Supplier' }, + contact: { label: 'Contact' }, + contact_email: { label: 'Invitation Email' }, + categories: { label: 'Applied Categories' }, + portal_admin: { label: 'Grant Portal Admin' }, + admin_authorization: { label: 'Portal Admin Authorization Letter' }, + notes: { label: 'Notes' }, + status: { label: 'Status', options: { sent: 'Sent', accepted: 'Opened', expired: 'Expired', revoked: 'Revoked' } }, + expires_on: { label: 'Open By' }, + portal_user: { label: 'Portal Account' }, + enabled_by: { label: 'Opened By' }, + enabled_at: { label: 'Opened At' }, + application: { label: 'Admission Application' }, + }, + }, srm_supplier_disposition: { label: 'Supplier Disposition', pluralLabel: 'Supplier Dispositions', @@ -387,6 +428,7 @@ export const en: TranslationData = { application_no: { label: 'Application No.' }, supplier: { label: 'Supplier' }, applied_categories: { label: 'Applied Categories' }, + source: { label: 'Source', options: { internal: 'Internal Registration', invited: 'Invitation' } }, status: { label: 'Status', options: { @@ -1051,6 +1093,49 @@ export const en: TranslationData = { refuse_none: { message: 'No nonconformities left to raise: observations are not raised, and findings already raised are not raised again' }, }, }, + srm_portal_enable: { + label: 'Open Portal', + successMessage: 'Portal opened: from the next request this user sees this supplier\'s data', + screens: { + pick: { + title: 'Open portal: {{ vendor.name }}', + description: + 'Contact: {{ contact.name }} Invitation email: {{ invite.contact_email }}\n' + + 'First invite this email on the organization members page (role external). After the person signs up and accepts, pick the user here. ' + + 'Once opened, the user sees only this supplier\'s data.', + fields: { portal_account: { label: 'Portal Account' } }, + }, + }, + refusals: { + refuse_admin: { message: 'The portal is opened by an organization administrator; category managers and buyer leads create a portal invitation for the administrator' }, + refuse_status: { message: 'Only a Sent invitation can be opened; ask the category manager to invite again if it expired or was revoked' }, + refuse_blacklisted: { message: 'A blacklisted supplier cannot be given portal access' }, + refuse_contact: { message: 'The contact is inactive or already has a portal account' }, + refuse_member: { message: 'This user has not joined the organization: invite the invitation email on the members page (role external) and open the portal after it is accepted' }, + refuse_role: { message: 'A portal account must join the organization as an external member; employees cannot be portal accounts' }, + refuse_taken: { message: 'This user is already the portal account of another contact; one portal account belongs to one contact' }, + }, + }, + srm_portal_roster_write: { + refusals: { + refuse_taken: { message: 'This user is already the portal account of another contact; one portal account belongs to one contact' }, + }, + }, + srm_application_submit: { + label: 'Submit Admission Application', + successMessage: 'Admission application submitted; the category manager will let you know after reviewing your documents', + screens: { + confirm: { + title: 'Submit admission application: {{ vendor.name }}', + description: 'The applied categories come from the invitation. Complete your company profile, certificates and bank account first; the category manager reviews them after you submit.', + }, + }, + refusals: { + refuse_portal: { message: 'Admission applications are submitted by invited suppliers in the portal; for internal registration create an admission application' }, + refuse_invitation: { message: 'No admission invitation is waiting: the application was already submitted, or the invitation has no applied categories' }, + refuse_status: { message: 'Your company cannot apply for admission in its current status; please contact your category manager' }, + }, + }, srm_disposition_submit: { label: 'Submit Disposition for Approval', successMessage: 'Disposition submitted for approval', @@ -1270,6 +1355,8 @@ export const en: TranslationData = { nav_bank_account: { label: 'Bank Accounts' }, nav_change_request: { label: 'Change Requests' }, nav_disposition: { label: 'Dispositions' }, + nav_invitation: { label: 'Portal Invitations' }, + nav_portal_accounts: { label: 'Portal Accounts' }, group_contract: { label: 'Contracts' }, nav_contract: { label: 'Contracts' }, group_quality: { label: 'Quality' }, diff --git a/src/core/views/index.ts b/src/core/views/index.ts index dbc7daf..9fcb9a9 100644 --- a/src/core/views/index.ts +++ b/src/core/views/index.ts @@ -5,6 +5,8 @@ export { SupplierCertificateViews } from './supplier-certificate.view.js'; export { SupplierApplicationViews } from './supplier-application.view.js'; export { SupplierBankAccountViews } from './supplier-bank-account.view.js'; export { SupplierChangeRequestViews } from './supplier-change-request.view.js'; +export { SupplierInvitationViews } from './supplier-invitation.view.js'; +export { SupplierContactViews } from './supplier-contact.view.js'; export { MaterialViews } from './material.view.js'; export { ContractViews } from './contract.view.js'; export { SupplierDispositionViews } from './supplier-disposition.view.js'; diff --git a/src/core/views/supplier-contact.view.ts b/src/core/views/supplier-contact.view.ts new file mode 100644 index 0000000..4a00194 --- /dev/null +++ b/src/core/views/supplier-contact.view.ts @@ -0,0 +1,26 @@ +import { defineView } from '@objectstack/spec'; + +const data = { provider: 'object' as const, object: 'srm_supplier_contact' }; + +// 门户账号季度复核(04 §3.3 回收):列出已登记门户账号的联系人,采购主管对照组织成员页的外部成员确认差异 +// (停用、离职、处置收回的联系人上已清掉 portal_user,不在此列) +export const SupplierContactViews = defineView({ + list: { + label: '全部联系人', + type: 'grid', + data, + columns: [{ field: 'supplier' }, { field: 'name' }, { field: 'contact_function' }, { field: 'email' }, { field: 'active' }], + sort: [{ field: 'supplier', order: 'asc' }], + }, + + listViews: { + portal_accounts: { + label: '门户账号', + type: 'grid', + data, + columns: [{ field: 'supplier' }, { field: 'name' }, { field: 'contact_function' }, { field: 'email' }, { field: 'portal_user' }, { field: 'active' }], + sort: [{ field: 'supplier', order: 'asc' }], + filter: [{ field: 'portal_user', operator: 'is_not_null' }], + }, + }, +}); diff --git a/src/core/views/supplier-invitation.view.ts b/src/core/views/supplier-invitation.view.ts new file mode 100644 index 0000000..1bce5ea --- /dev/null +++ b/src/core/views/supplier-invitation.view.ts @@ -0,0 +1,42 @@ +import { defineView } from '@objectstack/spec'; + +const data = { provider: 'object' as const, object: 'srm_supplier_invitation' }; +const columns = [ + { field: 'invitation_no' }, + { field: 'supplier' }, + { field: 'contact' }, + { field: 'contact_email' }, + { field: 'status' }, + { field: 'expires_on' }, + { field: 'created_by' }, + { field: 'enabled_at' }, +]; + +export const SupplierInvitationViews = defineView({ + list: { + label: '全部门户邀请', + type: 'grid', + data, + columns, + sort: [{ field: 'invitation_no', order: 'desc' }], + }, + + listViews: { + // 组织管理员的待办:按邀请邮箱在成员页发邀请,对方接受后点「开通门户」(04 §3.3) + sent: { + label: '待开通', + type: 'grid', + data, + columns, + sort: [{ field: 'expires_on', order: 'asc' }], + filter: [{ field: 'status', operator: 'equals', value: 'sent' }], + }, + accepted: { + label: '已开通', + type: 'grid', + data, + columns, + filter: [{ field: 'status', operator: 'equals', value: 'accepted' }], + }, + }, +}); diff --git a/test/permissions.test.ts b/test/permissions.test.ts index 2c1e133..dd46fd1 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -126,6 +126,7 @@ describe('internal permissions', () => { "srm_supplier_change_request": "RCE", "srm_supplier_contact": "RCED", "srm_supplier_disposition": "RCE", + "srm_supplier_invitation": "RCE", } `); }); @@ -181,6 +182,7 @@ describe('internal permissions', () => { "srm_supplier_change_request": "R", "srm_supplier_contact": "R", "srm_supplier_disposition": "R", + "srm_supplier_invitation": "R", } `); }); @@ -209,6 +211,7 @@ describe('internal permissions', () => { "srm_supplier_change_request": "R", "srm_supplier_contact": "R", "srm_supplier_disposition": "R", + "srm_supplier_invitation": "R", } `); }); @@ -238,6 +241,7 @@ describe('internal permissions', () => { "srm_supplier_change_request": "RCE", "srm_supplier_contact": "R", "srm_supplier_disposition": "R", + "srm_supplier_invitation": "RC", } `); }); diff --git a/test/portal-invitation.test.ts b/test/portal-invitation.test.ts new file mode 100644 index 0000000..73dceea --- /dev/null +++ b/test/portal-invitation.test.ts @@ -0,0 +1,363 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { cel, composeStacks, defineStack } from '@objectstack/spec'; +import { stacks } from '../objectstack.composition'; + +// v0.6 U3(#79):门户邀请 → 组织邀请(external)→ 注册并接受 → 开通门户 → 提交准入申请 → 只看到本公司; +// 回收(准入未通过、联系人停用、淘汰)后看不到;邀请过期。设计见 04 §3.3、03 §3,制度见 GL-10 6.2–6.6。 +// 门户用户沿用 U1(test/portal-platform.test.ts)的真实路径:成员策略 invite-only,组织管理员在成员页按邮箱邀请为 +// external,对方注册、接受邀请后重新登录。 +process.env.OS_AUTH_MEMBERSHIP_POLICY = 'invite-only'; + +const PASSWORD = 'Portal!Invite123'; +type Row = Record; + +// 平台行为探针(07 §6.3 PF19):runAs: 'user' 的 flow 写字段级只读的 portal_user,数据层剔除该字段、节点只给警告, +// 运行照样成功。所以「开通门户」把名册写入交给系统身份的子 flow;平台行为一变本探针就会失败,届时复核 07 §6.3 +const PROBE = defineStack({ + manifest: { id: 'app.objectstack.hotsrm.invitation-probe', namespace: 'srm', version: '0.0.0', type: 'module', name: 'invitation-probe' }, + flows: [ + { + name: 'srm_probe_user_writes_roster', + label: 'srm_probe_user_writes_roster', + type: 'screen', + status: 'active', + runAs: 'user', + variables: ['contactId', 'userId'].map((name) => ({ name, type: 'text', isInput: true, isOutput: false })), + nodes: [ + { id: 'start', type: 'start', label: 'start' }, + { + id: 'write', + type: 'update_record', + label: 'write', + config: { objectName: 'srm_supplier_contact', filter: { id: cel`contactId` }, fields: { portal_user: cel`userId` } }, + }, + { id: 'end', type: 'end', label: 'end' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'write', type: 'default' }, + { id: 'e2', source: 'write', target: 'end', type: 'default' }, + ], + }, + ], +} as Parameters[0]); + +describe('portal invitation and opening (v0.6 U3)', () => { + let stack: VerifyStack; + let ql: any; + let orgId = ''; + let seq = 0; + const token: Record = {}; + const uid: Record = {}; + let category = ''; + + const SYS = { context: { isSystem: true } }; + const row = async (object: string, id: string) => (await stack.rows(object, { id }))[0] as Row; + const system = async (object: string, doc: Row) => (await stack.hooks.run(object, 'insert', doc, { system: true })) as Row; + const signUp = async (who: string) => { + const email = `${who.toLowerCase()}@invitation.test`; + const signed = await stack.signUp(email, PASSWORD, who); + uid[who] = String((await stack.contextFor(signed)).userId); + return { email, signed }; + }; + // 组织管理员在成员页按邮箱发邀请 → 对方注册 → 接受 → 重新登录拿到带组织的会话(04 §3.3 第 1 步) + const join = async (who: string, role: 'external' | 'member' | 'admin') => { + const email = `${who.toLowerCase()}@invitation.test`; + const sent = await stack.apiAs(token.owner, 'POST', '/auth/organization/invite-member', { email, role, organizationId: orgId }); + expect(sent.status, await sent.clone().text()).toBe(200); + const { id: invitationId } = (await sent.json()) as { id: string }; + const { signed } = await signUp(who); + const accepted = await stack.apiAs(signed, 'POST', '/auth/organization/accept-invitation', { invitationId }); + expect(accepted.status, await accepted.clone().text()).toBe(200); + token[who] = await stack.signIn(email, PASSWORD); + return token[who]; + }; + const hold = (who: string, position: string) => stack.seed('sys_user_position', [{ user_id: uid[who], position, organization_id: orgId }]); + const positionsOf = async (who: string) => + (await ql.find('sys_user_position', { where: { user_id: uid[who] }, ...SYS })).map((p: Row) => String(p.position)).sort(); + + const attachment = async () => { + const name = `invitation-${++seq}.pdf`; + const [file] = await stack.seed('sys_file', [{ key: `attachments/${name}`, name, mime_type: 'application/pdf', size: 1, scope: 'attachments', status: 'committed' }]); + return String(file!.id); + }; + // 品类经理先登记潜在供应商与联系人(总监裁定 2026-11-02:没有供应商档案就无法隔离) + const supplierWithContact = async (label: string, extra: Row = {}) => { + const n = ++seq; + // 存量导入的合格供应商只有系统管理员的导入能建,这里以系统身份建;潜在供应商由品类经理登记 + const doc = { name: `示例门户邀请${label}有限公司 ${n}`, credit_code: `91000000MA0INV${String(n).padStart(4, '0')}`, ...extra }; + const supplier = extra.legacy_imported + ? await stack.hooks.run('srm_supplier', 'insert', doc, { system: true }) + : await stack.hooks.run('srm_supplier', 'insert', doc, { as: token.manager }); + const contact = await stack.hooks.run( + 'srm_supplier_contact', + 'insert', + { supplier: supplier.id, name: `联系人 ${n}`, email: `contact-${n}@supplier.test` }, + { as: token.manager }, + ); + return { supplier: String(supplier.id), contact: String(contact.id) }; + }; + const invite = async (target: { supplier: string; contact: string }, extra: Row = {}) => + (await stack.hooks.run('srm_supplier_invitation', 'insert', { supplier: target.supplier, contact: target.contact, ...extra }, { as: token.manager })) as Row; + + // 从门户邀请上的按钮启动「开通门户」,在选账号的屏上提交 + const open = async (as: string, invitationId: string, account: string) => { + const started = (await stack.actions.run('srm_supplier_invitation', 'srm_portal_enable', { as, recordId: invitationId })) as Row; + if (started.status !== 'paused') return started; + return (await stack.flows.resume({ flowName: 'srm_portal_enable', runId: started.runId }, { portal_account: account }, { as })) as Row; + }; + const submitApplication = async (as: string, supplierId: string) => { + const started = (await stack.actions.run('srm_supplier', 'srm_application_submit', { as, recordId: supplierId })) as Row; + if (started.status !== 'paused') return started; + return (await stack.flows.resume({ flowName: 'srm_application_submit', runId: started.runId }, {}, { as })) as Row; + }; + const refused = (run: Row, why: RegExp) => { + expect(run, JSON.stringify(run)).toMatchObject({ status: 'refused' }); + expect(String(run.refusalMessage), JSON.stringify(run)).toMatch(why); + }; + const visibleSuppliers = async (who: string) => { + const res = await stack.apiAs(token[who], 'GET', '/data/srm_supplier'); + const body = (await res.json().catch(() => ({}))) as { records?: Row[] }; + return { status: res.status, ids: (body.records ?? []).map((r) => String(r.id)) }; + }; + + let X: { supplier: string; contact: string }; + let invitationX: Row; + + beforeAll(async () => { + stack = await bootStack(composeStacks([...stacks, PROBE], { manifest: 'preserve' })); + ql = await stack.kernel.getServiceAsync('objectql'); + token.owner = await stack.signIn(); + const owner = await stack.contextFor(token.owner); + orgId = String(owner.tenantId); + uid.owner = String(owner.userId); + + // 组织管理员兼 SRM 系统管理员(04 §3.3);品类经理;不是管理员的普通员工 + await join('orgAdmin', 'admin'); + await hold('orgAdmin', 'srm_admin'); + await join('manager', 'member'); + await hold('manager', 'srm_category_manager'); + await join('staff', 'member'); + await hold('staff', 'srm_buyer'); + category = String((await system('srm_category', { code: 'INV1', name: '示例门户邀请品类' })).id); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + describe('a category manager raises the request', () => { + it('creates an admission invitation for a potential supplier; the flow fills the email and the deadline and tells the org admins', async () => { + X = await supplierWithContact('甲'); + invitationX = await invite(X, { categories: [category] }); + const saved = await row('srm_supplier_invitation', String(invitationX.id)); + expect(saved).toMatchObject({ status: 'sent', contact_email: expect.stringMatching(/@supplier\.test$/) }); + expect(String(saved.expires_on) > new Date().toISOString().slice(0, 10)).toBe(true); + await expect + .poll(async () => (await stack.rows('sys_notification', { source_id: String(invitationX.id) })).length) + .toBe(1); + }); + + it('needs the applied categories for a potential supplier, and refuses them for a qualified one', async () => { + const P = await supplierWithContact('乙'); + await expect(invite(P)).rejects.toThrow(/申请品类|applied categories/i); + const Q = await supplierWithContact('丙', { legacy_imported: true }); + await expect(invite(Q, { categories: [category] })).rejects.toThrow(/申请品类|applied categories/i); + // 只开通门户的邀请(已合格供应商)不填品类 + expect((await invite(Q)).status).toBe('sent'); + }); + + it('refuses a blacklisted supplier, a contact of another supplier, and a portal admin grant without the authorization letter', async () => { + const B = await supplierWithContact('丁', { legacy_imported: true }); + await stack.hooks.run('srm_supplier', 'update', { id: B.supplier, status: 'blacklisted' }, { system: true }); + await expect(invite(B)).rejects.toThrow(/黑名单|blacklisted/); + const C = await supplierWithContact('戊', { legacy_imported: true }); + await expect(invite({ supplier: C.supplier, contact: X.contact })).rejects.toThrow(/联系人必须属于|must belong/); + await expect(invite(C, { portal_admin: true })).rejects.toThrow(); + expect((await invite(C, { portal_admin: true, admin_authorization: await attachment() })).portal_admin).toBe(true); + }); + + it('does not let the category manager set the status or the portal account, or move the invitation to another contact', async () => { + const other = await supplierWithContact('乙二'); + await stack.hooks.run('srm_supplier_invitation', 'update', { id: invitationX.id, status: 'accepted', portal_user: uid.manager }, { as: token.manager }).catch(() => null); + await stack.hooks.run('srm_supplier_invitation', 'update', { id: invitationX.id, supplier: other.supplier, contact: other.contact }, { as: token.manager }).catch(() => null); + expect(await row('srm_supplier_invitation', String(invitationX.id))).toMatchObject({ supplier: X.supplier, contact: X.contact }); + expect(await row('srm_supplier_invitation', String(invitationX.id))).toMatchObject({ status: 'sent' }); + expect((await row('srm_supplier_invitation', String(invitationX.id))).portal_user ?? null).toBeNull(); + }); + }); + + describe('the organization admin opens the portal', () => { + it('refuses a category manager and an employee who is not an organization admin', async () => { + await join('supX', 'external'); + for (const who of ['manager', 'staff']) { + refused(await open(token[who], String(invitationX.id), uid.supX), /organization administrator/); + } + expect((await row('srm_supplier_contact', X.contact)).portal_user ?? null).toBeNull(); + }); + + it('refuses a user who has not joined, and an internal member', async () => { + await signUp('notJoined'); + refused(await open(token.orgAdmin, String(invitationX.id), uid.notJoined), /has not joined/); + refused(await open(token.orgAdmin, String(invitationX.id), uid.staff), /external member/); + expect(await positionsOf('staff')).toEqual(['srm_buyer']); + }); + + it('assigns the static portal position, writes the roster and marks the invitation opened', async () => { + expect(await visibleSuppliers('supX')).toMatchObject({ ids: [] }); + const run = await open(token.orgAdmin, String(invitationX.id), uid.supX); + expect(run.success, JSON.stringify(run)).toBe(true); + expect(run.status, JSON.stringify(run)).not.toBe('refused'); + expect(await positionsOf('supX')).toEqual(['srm_portal_user']); + expect((await row('srm_supplier_contact', X.contact)).portal_user).toBe(uid.supX); + expect(await row('srm_supplier_invitation', String(invitationX.id))).toMatchObject({ + status: 'accepted', + portal_user: uid.supX, + enabled_by: uid.orgAdmin, + }); + // 下一个请求起只看到本公司 + expect(await visibleSuppliers('supX')).toEqual({ status: 200, ids: [X.supplier] }); + }); + + it('opens a portal admin only when the invitation grants it', async () => { + const A = await supplierWithContact('己', { legacy_imported: true }); + const inv = await invite(A, { portal_admin: true, admin_authorization: await attachment() }); + await join('supA', 'external'); + expect((await open(token.orgAdmin, String(inv.id), uid.supA)).success).toBe(true); + expect(await positionsOf('supA')).toEqual(['srm_portal_admin', 'srm_portal_user']); + expect(await visibleSuppliers('supA')).toEqual({ status: 200, ids: [A.supplier] }); + }); + + it('keeps one account on one contact: refuses to register an opened user on another supplier', async () => { + const Y = await supplierWithContact('庚', { legacy_imported: true }); + const inv = await invite(Y); + refused(await open(token.orgAdmin, String(inv.id), uid.supX), /another contact/); + expect((await row('srm_supplier_contact', Y.contact)).portal_user ?? null).toBeNull(); + expect(await visibleSuppliers('supX')).toEqual({ status: 200, ids: [X.supplier] }); + // 已开通的邀请不能再开通 + refused(await open(token.orgAdmin, String(invitationX.id), uid.supX), /Only a Sent invitation/); + }); + + it('writes the roster only through the opening flow: the system sub-flow cannot be started directly', async () => { + const Z = await supplierWithContact('辛', { legacy_imported: true }); + for (const who of ['owner', 'orgAdmin', 'manager']) { + await expect( + stack.flows.run('srm_portal_roster_write', { contactId: Z.contact, userId: uid.staff, invitationId: '', operatorId: uid[who] }, { as: token[who] }), + who, + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + } + expect((await row('srm_supplier_contact', Z.contact)).portal_user ?? null).toBeNull(); + }); + + // PF19:平台行为变了这里会失败,届时复核「开通门户」的子 flow 写法(07 §6.3) + it('PLATFORM PROBE (PF19): a runAs user flow writing the read-only portal_user succeeds without writing it', async () => { + const W = await supplierWithContact('壬', { legacy_imported: true }); + const run = await stack.flows.run('srm_probe_user_writes_roster', { contactId: W.contact, userId: uid.staff }, { as: token.owner }); + expect(run.success).toBe(true); + expect((await row('srm_supplier_contact', W.contact)).portal_user ?? null).toBeNull(); + }); + }); + + describe('the invited supplier applies in the portal', () => { + // 按钮只给有门户访问能力的人;直接触发时 flow 只认调用者自己的名册行 + it('refuses an employee: the supplier comes from the caller’s own roster row', async () => { + await expect(submitApplication(token.manager, X.supplier)).rejects.toThrow(/srm_portal_access/); + refused((await stack.flows.run('srm_application_submit', { recordId: X.supplier }, { as: token.manager })) as Row, /submitted by invited suppliers/); + }); + + // 直接触发 flow(不经记录页按钮)也一样:系统身份的 flow 只认调用者在该供应商上的名册行 + it('refuses a portal user of another supplier, from the record button and from a direct trigger', async () => { + // 按钮入口:别家的供应商对门户不可见,平台答「不存在」或 flow 拒绝 + const button = (await submitApplication(token.supA, X.supplier).catch((e) => ({ status: 'error', error: e }))) as Row; + expect(['error', 'refused'], JSON.stringify(button)).toContain(button.status); + refused((await stack.flows.run('srm_application_submit', { recordId: X.supplier }, { as: token.supA })) as Row, /submitted by invited suppliers/); + expect(await stack.rows('srm_supplier_application', { supplier: X.supplier })).toHaveLength(0); + expect((await row('srm_supplier_invitation', String(invitationX.id))).application ?? null).toBeNull(); + }); + + it('submits the application with the invitation’s categories, source invited, and links it back to the invitation', async () => { + const run = await submitApplication(token.supX, X.supplier); + expect(run.success, JSON.stringify(run)).toBe(true); + const inv = await row('srm_supplier_invitation', String(invitationX.id)); + expect(inv.application).toBeTruthy(); + const app = await row('srm_supplier_application', String(inv.application)); + expect(app).toMatchObject({ supplier: X.supplier, source: 'invited', duplicate_check: 'pass' }); + expect(app.applied_categories).toEqual([category]); + // 受理照常:供应商进入准入中,门户看得到自己的申请 + expect((await row('srm_supplier', X.supplier)).status).toBe('qualifying'); + const res = await stack.apiAs(token.supX, 'GET', '/data/srm_supplier_application'); + expect(((await res.json()) as { records: Row[] }).records.map((r) => String(r.id))).toEqual([String(inv.application)]); + }); + + it('submits once per invitation', async () => { + refused(await submitApplication(token.supX, X.supplier), /No admission invitation is waiting/); + expect(await stack.rows('srm_supplier_application', { supplier: X.supplier })).toHaveLength(1); + }); + + it('does not let the portal see invitations', async () => { + const res = await stack.apiAs(token.supX, 'GET', '/data/srm_supplier_invitation'); + const ids = res.status === 200 ? ((await res.json()) as { records: Row[] }).records : []; + expect(ids).toEqual([]); + }); + }); + + describe('revocation', () => { + it('revokes the portal when the admission is rejected and the supplier returns to potential (03 §3 step 8)', async () => { + const [app] = await stack.rows('srm_supplier_application', { supplier: X.supplier }); + await stack.hooks.run('srm_supplier_application', 'update', { id: app!.id, status: 'rejected', reject_reason: '示例未通过' }, { system: true }); + expect((await row('srm_supplier', X.supplier)).status).toBe('potential'); + expect((await row('srm_supplier_contact', X.contact)).portal_user ?? null).toBeNull(); + expect((await row('srm_supplier_invitation', String(invitationX.id))).status).toBe('revoked'); + expect(await visibleSuppliers('supX')).toMatchObject({ ids: [] }); + await expect.poll(async () => (await stack.rows('sys_notification', { source_id: String(app!.id) })).length).toBeGreaterThan(0); + }); + + it('clears the roster when a contact is deactivated, so reactivating it does not bring the portal back', async () => { + const D = await supplierWithContact('癸', { legacy_imported: true }); + const inv = await invite(D); + await join('supD', 'external'); + expect((await open(token.orgAdmin, String(inv.id), uid.supD)).success).toBe(true); + expect(await visibleSuppliers('supD')).toEqual({ status: 200, ids: [D.supplier] }); + await stack.hooks.run('srm_supplier_contact', 'update', { id: D.contact, active: false }, { as: token.manager }); + expect(await visibleSuppliers('supD')).toMatchObject({ ids: [] }); + expect((await row('srm_supplier_contact', D.contact)).portal_user ?? null).toBeNull(); + expect((await row('srm_supplier_invitation', String(inv.id))).status).toBe('revoked'); + await stack.hooks.run('srm_supplier_contact', 'update', { id: D.contact, active: true }, { as: token.manager }); + expect(await visibleSuppliers('supD')).toMatchObject({ ids: [] }); + }); + + it('revokes open invitations and the roster when a supplier is eliminated', async () => { + const E = await supplierWithContact('子', { legacy_imported: true }); + const opened = await invite(E); + await join('supE', 'external'); + expect((await open(token.orgAdmin, String(opened.id), uid.supE)).success).toBe(true); + const second = await stack.hooks.run('srm_supplier_contact', 'insert', { supplier: E.supplier, name: '第二联系人', email: 'second@supplier.test' }, { as: token.manager }); + const waiting = await invite({ supplier: E.supplier, contact: String(second.id) }); + const [disposition] = await stack.seed('srm_supplier_disposition', [ + { supplier: E.supplier, action: 'eliminate', scope: 'all', reason: '示例淘汰', status: 'in_review' }, + ]); + const run = await stack.flows.run('srm_disposition_apply', { recordId: String(disposition!.id) }, { system: true }); + expect(run.success, JSON.stringify(run)).toBe(true); + expect(await visibleSuppliers('supE')).toMatchObject({ ids: [] }); + expect((await row('srm_supplier_invitation', String(opened.id))).status).toBe('revoked'); + expect((await row('srm_supplier_invitation', String(waiting.id))).status).toBe('revoked'); + await expect.poll(async () => (await stack.rows('sys_notification', { source_id: String(disposition!.id) })).length).toBeGreaterThan(0); + }); + }); + + describe('expiry', () => { + it('expires a sent invitation past its deadline, once, and leaves opened ones alone', async () => { + const F = await supplierWithContact('丑', { legacy_imported: true }); + const late = await invite(F); + await stack.hooks.run('srm_supplier_invitation', 'update', { id: late.id, expires_on: '2026-01-01' }, { system: true }); + const fresh = await invite({ supplier: F.supplier, contact: String((await stack.hooks.run('srm_supplier_contact', 'insert', { supplier: F.supplier, name: '另一位', email: 'another@supplier.test' }, { as: token.manager })).id) }); + for (let i = 0; i < 2; i++) { + const run = await stack.flows.run('srm_invitation_expire_sweep', {}, { system: true }); + expect(run.success, JSON.stringify(run)).toBe(true); + } + expect((await row('srm_supplier_invitation', String(late.id))).status).toBe('expired'); + expect((await row('srm_supplier_invitation', String(fresh.id))).status).toBe('sent'); + refused(await open(token.orgAdmin, String(late.id), uid.staff), /Only a Sent invitation/); + }); + }); +}); diff --git a/test/portal-isolation.test.ts b/test/portal-isolation.test.ts index aca9e95..5609a57 100644 --- a/test/portal-isolation.test.ts +++ b/test/portal-isolation.test.ts @@ -29,6 +29,7 @@ const NOT_FOR_PORTAL: Record = { srm_supplier_audit_finding: '跟随审核', srm_risk_event: '内部风险评估', srm_supplier_disposition: '内部处置决定,结果体现在供应商状态上', + srm_supplier_invitation: '内部的门户开通需求,门户经名册而不是邀请看到数据', }; // controlled_by_parent 子对象的主记录字段 @@ -131,7 +132,7 @@ describe('portal isolation metadata (ADR-0010)', () => { it('makes every portal-facing system flow check the contact roster like the membership key', () => { const key = SupplierContact.membershipKeys!.portal_supplier_ids as { userField: string; valueField: string; filter: string }; expect(key.filter).toBe('active == true'); - for (const name of ['srm_claim_respond', 'srm_appeal_submit', 'srm_change_submit', 'srm_application_resubmit']) { + for (const name of ['srm_claim_respond', 'srm_appeal_submit', 'srm_change_submit', 'srm_application_resubmit', 'srm_application_submit']) { const flow = (config.flows ?? []).find((f) => f.name === name)!; expect(flow.runAs, name).toBe('system'); const roster = flow.nodes.find((n) => n.id === 'load_membership') as { config: { objectName: string; filter: Row } } | undefined; @@ -457,9 +458,11 @@ describe('portal isolation (04 §3.7)', () => { await stack.hooks.run('srm_performance_review', 'update', { id: own, published_date: today }, { system: true }); await stack.hooks.run('srm_supplier_contact', 'update', { id: aAdmin.contact, active: false }, { system: true }); expect((await run(aAdmin, 'srm_performance_review', 'srm_appeal_submit', own, { appeal_text: '停用后申诉' }))).toEqual(REFUSED); + // 停用同时清掉了 portal_user(srm_portal_contact_revoke),恢复在职后要重新登记名册(实际经「开通门户」,这里直接以系统身份写) await stack.hooks.run('srm_supplier_contact', 'update', { id: aAdmin.contact, active: true }, { system: true }); + await stack.hooks.run('srm_supplier_contact', 'update', { id: aAdmin.contact, portal_user: aAdmin.id }, { system: true }); expect((await stack.rows('srm_performance_review', { id: own }))[0]!.status).toBe('published'); - // 对照:恢复在职后,本公司的考评单经按钮能申诉,说明上面的拒绝来自隔离检查而不是入口本身不通 + // 对照:恢复在职并重新登记后,本公司的考评单经按钮能申诉,说明上面的拒绝来自隔离检查而不是入口本身不通 expect((await run(aAdmin, 'srm_performance_review', 'srm_appeal_submit', own, { appeal_text: '示例申诉' })).status[0]).toBe('completed'); expect((await stack.rows('srm_performance_review', { id: own }))[0]!).toMatchObject({ status: 'appealed', appeal_recorded_by: aAdmin.id }); }); @@ -587,7 +590,11 @@ describe('portal isolation (04 §3.7)', () => { expect(idsOf(await list(c.token, 'srm_supplier'))).toEqual([B.supplier]); await stack.hooks.run('srm_supplier_contact', 'update', { id: c.contact, active: false }, { system: true }); await expectNothing(c.token); + // 停用时 srm_portal_contact_revoke 同时清掉 portal_user:改回在职也不恢复门户访问,重新开通要走「开通门户」(04 §3.3) + expect((await stack.rows('srm_supplier_contact', { id: c.contact }))[0]!.portal_user ?? null).toBeNull(); await stack.hooks.run('srm_supplier_contact', 'update', { id: c.contact, active: true }, { system: true }); + await expectNothing(c.token); + await stack.hooks.run('srm_supplier_contact', 'update', { id: c.contact, portal_user: c.id }, { system: true }); expect(idsOf(await list(c.token, 'srm_supplier'))).toEqual([B.supplier]); await stack.hooks.updateWhere('sys_user_position', { user_id: c.id, position: 'srm_portal_user' }, { active: false }, { system: true }).catch(() => 0); const [assignment] = await stack.rows('sys_user_position', { user_id: c.id, position: 'srm_portal_user' });