From a2bf1b92af950d292e9fc43e7a4196863a489d40 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:37:37 +0000 Subject: [PATCH 1/2] fix(portal): close gaps found in the v0.6 portal exit test - Let category managers and buyer leads handle portal-submitted applications (edit sharing rule), require a return reason when sending an application back, notify the inviting manager on portal submission and the returning manager on resubmission - Hide internal navigation groups and internal-only actions from portal users behind a new srm_internal_access capability - Tell the supplier quality contact when a claim is raised on a SCAR (number only, no amount) - setup:local works under the invite-only membership policy and makes srmadmin an org admin; local demo doc covers the portal walkthrough - Design docs 02/03/04/05/07 updated; V6 records the local exit test Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017ihL2fedQbjJWWSwaVb31Q --- docs/design/02-domain-model.md | 1 + docs/design/03-processes-and-automation.md | 6 +-- docs/design/04-security-and-portal.md | 2 +- docs/design/05-experience-and-analytics.md | 8 +++- docs/design/07-delivery-plan.md | 2 +- docs/implementation/local-demo.md | 19 ++++++--- scripts/setup-local.mjs | 9 +++++ src/core/actions/bank-account.action.ts | 2 + src/core/actions/contract.actions.ts | 2 + src/core/actions/performance.actions.ts | 12 ++++-- src/core/actions/portal.actions.ts | 1 + src/core/actions/supplier.actions.ts | 1 + src/core/apps/workbench.app.ts | 11 +++++- src/core/flows/admission/resubmit.flow.ts | 9 +++-- .../flows/portal/application-submit.flow.ts | 30 +++++++++++++- .../quality/scar-supplier-notice.flow.ts | 11 +++++- src/core/notifications/index.ts | 13 +++++++ .../objects/supplier-application.object.ts | 10 +++++ src/core/security/capabilities.ts | 11 +++++- src/core/security/permission-sets.ts | 4 ++ src/core/security/sharing-rules.ts | 9 +++++ src/core/translations/en.ts | 2 + test/permissions.test.ts | 19 +++++++++ test/portal-invitation.test.ts | 39 ++++++++++++++++++- test/portal-notifications.test.ts | 13 +++++-- test/portal-self-service.test.ts | 3 +- 26 files changed, 219 insertions(+), 30 deletions(-) diff --git a/docs/design/02-domain-model.md b/docs/design/02-domain-model.md index 8655bb7..59a3a64 100644 --- a/docs/design/02-domain-model.md +++ b/docs/design/02-domain-model.md @@ -328,6 +328,7 @@ upsert 写入,结构不变。 | `sqe` | user | 负责审核的 SQE | | `supplier` | lookup → 供应商 | 内部登记和邀请注册的申请新建时就关联已登记的供应商(邀请注册的供应商由品类经理在邀请前登记,04 §3.3);公开表单的申请由品类经理推进到「资料审核」时新建(或 `reapply` 时关联旧档)供应商,`submitted` 阶段只有申请记录,不建主数据 | | `reject_reason` | textarea | | +| `return_reason` | textarea | 退回意见:品类经理「退回补充」时必填(校验规则 `return_reason_required`),供应商在门户的申请上看得到要补什么(v0.6 出口验收) | | `supplement_note` | textarea(只读) | 补充说明:「退回补充」后供应商在门户经「补充完成,重新提交」(`srm_application_resubmit`)写入(v0.6) | | `cycle_days` | number | 从提交到批准的天数,批准时由 flow 写入(准入周期指标) | diff --git a/docs/design/03-processes-and-automation.md b/docs/design/03-processes-and-automation.md index 26d842d..bd881cb 100644 --- a/docs/design/03-processes-and-automation.md +++ b/docs/design/03-processes-and-automation.md @@ -141,7 +141,7 @@ sequenceDiagram | 1 受理 | 申请创建(v0.1 内部登记;v0.6 受邀供应商在门户经 screen flow `srm_application_submit` 提交:供应商取自调用者自己的名册行,品类取自门户邀请,申请回填到邀请) | flow `srm_application_intake`(record_change,after-create):按身份标识查供应商(`country == 'cn'` 用 `credit_code`,其他国家 / 地区用 `country` + `registration_no`,BR-01);命中 `blacklisted` → 申请置 `rejected`、`duplicate_check = blacklisted`,通知申请人;命中 `eliminated` → `reapply`,申请的 `supplier` 指向旧档并允许继续(淘汰后重新准入的唯一路径,FR-208);命中 `qualified` / `qualifying` / `frozen` / `potential` → `duplicate`,通知品类经理人工判断;未命中 → `pass`。同一 flow 按申请品类找到品类经理和 SQE,写入 `reviewer` / `sqe`,把这些品类中最高的 `risk_class` 和 `material_class` 复制到申请 | | 2 建档(公开表单,待平台) | 品类经理把申请推进到 `doc_review`(人工确认后,防止灌水污染主数据);内部登记与邀请注册的申请已预关联供应商,没有这一步 | flow `srm_application_accept`:`duplicate_check == 'pass'` 时新建 `potential` 供应商与主联系人并回填 `supplier`;`reapply` 时沿用旧档;供应商 `potential / eliminated → qualifying`;`submitted` 阶段只有申请记录,不建主数据 | | 3 开通门户 | 品类经理、采购主管提出(门户邀请);组织管理员操作(v0.6) | 组织管理员按邀请邮箱在成员页邀请为 `external`,对方接受后运行「开通门户」(见 [04 §3.3](./04-security-and-portal.md#33-门户账号的开通与回收));准入邀请在开通后由供应商提交申请,所以这一步在步骤 1 之前;v0.1–v0.5 跳过 | -| 4 补充资料 | 供应商 | 门户内补齐公司资料(门户可改的字段见 [04 §4.2](./04-security-and-portal.md#42-字段级权限fls))、银行账户、证书;品类经理可「退回补充」(申请→`returned`,通知申请联系人,v0.6 U6)。供应商补齐后在申请上点「补充完成,重新提交」(screen flow `srm_application_resubmit`,v0.6):写补充说明 `supplement_note`,申请回到 `doc_review`,通知初审人;只有申请所属供应商的门户用户能提交(flow 按门户名册检查)。应付会计经「核验银行账户」、SQE 经「核验证书」两个 screen flow 完成核验 | +| 4 补充资料 | 供应商 | 门户内补齐公司资料(门户可改的字段见 [04 §4.2](./04-security-and-portal.md#42-字段级权限fls))、银行账户、证书;品类经理可「退回补充」(申请→`returned`,必须写退回意见 `return_reason`,通知申请联系人,v0.6 U6)。受邀申请由门户用户提交、属主是门户账号,品类经理和采购主管经共享规则「处理准入申请」(`srm_supplier_application` 的编辑规则,关联了供应商的申请)才能受理、退回;`srm_application_submit` 提交后通知发出邀请的人(门户邀请的 `created_by`)。供应商补齐后在申请上点「补充完成,重新提交」(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);通知申请联系人(v0.6 U6)。ERP 建档与 `erp_vendor_code` 回写为预留集成点(06 §1) | @@ -357,7 +357,7 @@ stateDiagram-v2 | 4 效果验证 | SQE 审核通过 | 状态→`verifying`;SQE 跟踪后续批次,确认有效后关闭(D8),或验证不通过退回 `action_submitted` 重新审核 | | 5 逾期升级 | 追赶式扫描 `srm_scar_overdue_sweep`(每小时;外部心跳见 §2.3 R3) | 超过 `containment_due` 仍为 `open`、或超过 `final_due` 仍未到 `verifying`:`is_overdue = true`,通知负责 SQE 与质量经理(SQE 主管,FR-605);标志保留到关闭,供「SCAR 按期关闭率」统计;`srm_scar_supplier_notice` 同时提醒供应商 `quality` 联系人(v0.6 U6) | | 5a 复发重开 | SQE 把已关闭的整改单改回 `open` | flow `srm_scar_reopen`:期限按重开时间重算,逾期标志清掉,通知负责 SQE;`srm_scar_supplier_notice` 把重算后的期限通知供应商 `quality` 联系人(v0.6 U6)。SQE 退回 D4–D7(`action_submitted → containment_submitted`)时也通知 | -| 6 索赔 | SQE 填写 `claim_amount`,`claim_status → proposed` | 供应商在门户的整改单上点「确认索赔」(screen flow `srm_claim_respond`)接受或提出异议,异议必须写意见(`claim_response`);没开通门户的线下确认后由 SQE 记录。接受(`supplier_accepted`)通知负责 SQE 与应付会计,由财务在 ERP 扣款,应付会计回来标记 `settled`;异议(`disputed`)通知负责 SQE 协商,调整金额后可重新提出。SRM 不生成扣款 | +| 6 索赔 | SQE 填写 `claim_amount`,`claim_status → proposed` | `srm_scar_supplier_notice` 通知供应商 `quality` 联系人有索赔待确认(只写单号,金额在门户的整改单上看,v0.6 出口验收补);供应商在门户的整改单上点「确认索赔」(screen flow `srm_claim_respond`)接受或提出异议,异议必须写意见(`claim_response`);没开通门户的线下确认后由 SQE 记录。接受(`supplier_accepted`)通知负责 SQE 与应付会计,由财务在 ERP 扣款,应付会计回来标记 `settled`;异议(`disputed`)通知负责 SQE 协商,调整金额后可重新提出。SRM 不生成扣款 | **供应商审核与年度审核计划**(v0.4,FR-607;体系文件 WI-01):准入审核见 §3 第 5 步;年度审核与专项审核由 SQE 建审核记录,审核后录入结论与问题项,不符合项经「转整改单」进入上面的整改流程。年度计划由 @@ -539,7 +539,7 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_admission_rejection` | record_change:申请→`rejected` | 准入申请 → 供应商(v0.6 起 → 联系人 `portal_user` 清空、门户邀请撤销) | v0.1 | | `srm_portal_opened_notice` | record_change:门户邀请 `sent`→`accepted` | 门户邀请 → 受邀联系人 | v0.6(U6) | | `srm_application_supplier_notice` | record_change:有申请联系人的申请受理(`submitted`→`doc_review`)、退回补充、通过 | 准入申请 → 申请联系人 | v0.6(U6) | -| `srm_scar_supplier_notice` | record_change:整改单新建、D4–D7 退回、重开、标记逾期 | 整改单 → 供应商 `quality` 联系人 | v0.6(U6) | +| `srm_scar_supplier_notice` | record_change:整改单新建、D4–D7 退回、重开、标记逾期、提出索赔(`claim_status` → `proposed`,只写单号,不写金额) | 整改单 → 供应商 `quality` 联系人 | v0.6(U6) | | `srm_review_supplier_notice` | record_change:考评单 `reviewing`→`published`、`appealed`→`published` | 考评单 → 供应商 `admin` 联系人 | v0.6(U6) | | `srm_bank_supplier_notice` | record_change:银行账户 `pending_verify`→`active` | 银行账户 → 门户管理员 | v0.6(U6) | | `srm_change_supplier_notice` | record_change:变更单生效、驳回、退回修改 | 变更单 → 门户管理员 | v0.6(U6) | diff --git a/docs/design/04-security-and-portal.md b/docs/design/04-security-and-portal.md index db0804f..81bb4cd 100644 --- a/docs/design/04-security-and-portal.md +++ b/docs/design/04-security-and-portal.md @@ -176,7 +176,7 @@ flowchart LR |---|---|---|---| | 供应商 | 本公司 | 只能改资料字段(§4.2);名称、地址、银行账户等关键信息走变更单 | RLS 在供应商自身的 `id` 上;授予编辑权是为了让门户能维护证书等子记录;内部评估类字段(关联方、优选、风险事件数等)门户不可见 | | 联系人、证书、银行账户、品类资格 | 跟随供应商 | 证书可增可改(U4):门户改了类型、编号、发证机构、有效期或扫描件时 `verified` 复位、通知 SQE,经「核验证书」才能解除资质异常(03 §11);联系人仅门户管理员可增可改;银行账户仅门户管理员可增,新建即「待核验」并通知应付会计,已有账户的修改走变更单 | `controlled_by_parent`;联系人的 `portal_user`、`active`,证书的 `status`、`is_mandatory`、`verified`、`verified_by`,银行账户的状态与核验人门户只读 | -| 准入申请 | 本公司 | 只经「补充完成,重新提交」screen flow(`returned` 状态,门户用户,U4):写补充说明,回到资料审核;申请本身的品类、初审人由内部维护 | `private`:读共享规则 + RLS;flow 以系统身份写,进门按门户名册检查启动人属于申请的供应商 | +| 准入申请 | 本公司 | 只经「补充完成,重新提交」screen flow(`returned` 状态,门户用户,U4):写补充说明,回到资料审核;申请本身的品类、初审人由内部维护 | `private`:读共享规则 + RLS;flow 以系统身份写,进门按门户名册检查启动人属于申请的供应商。门户提交的申请属主是门户账号,内部由编辑共享规则「处理准入申请」放行品类经理、采购主管受理和退回;退回意见 `return_reason` 门户可见 | | 变更单 | 本公司 | 新建只经「提交变更申请」screen flow(门户管理员,U4);被退回修改的单由门户管理员改完重新提交(写策略:本公司且 `approval_status == 'returned'`) | `private`:读共享规则 + RLS;改只限自己发起(拥有)的单——`external` 成员对 `private` 对象只能改自己的行,写策略再限定本公司、被退回 | | 寻源邀请 | 本公司 | 可改 `status`(接受 / 拒绝)与拒绝原因 | 邀请上有寻源单的快照,门户不读寻源单本身 | | 报价单 | 本公司 | `status in ['draft','submitted'] && bid_opened == false` 的本公司报价 | 写策略 `using` 限定可改哪些行,`check` 限定改完仍属本公司;上一轮的 `superseded` 报价不可改 | diff --git a/docs/design/05-experience-and-analytics.md b/docs/design/05-experience-and-analytics.md index ff44b58..7d211bb 100644 --- a/docs/design/05-experience-and-analytics.md +++ b/docs/design/05-experience-and-analytics.md @@ -7,7 +7,10 @@ 同一份数据,两个外壳:内部员工用「采购协同工作台」,供应商用「供应商门户」。平台规定一个应用包只定义一个应用 (objectstack ADR-0019 D3),所以门户实现为应用 `srm_workbench` 里的「供应商门户」导航组,只对持门户能力 `srm_portal_access`(门户权限集授予)的用户显示(v0.6);门户用户即便点开内部菜单,RLS 仍然只放行他本公司的数据 -(导航可见性是体验,RLS 才是边界)。 +(导航可见性是体验,RLS 才是边界)。反过来,内部导航组(供应商、合同、质量、绩效与风险、分析、基础数据)和内部经办动作 +(确认复核、提交 / 生效合同、开通门户、核验银行账户、提交评分、作废 / 处理申诉、改派评分人)要求内部能力 +`srm_internal_access`(内部基础权限集和审计权限集授予),门户用户看不到;门户也要用的动作(如「提交申诉」)不设能力, +由 flow 自己按名册检查(v0.6 出口验收补)。 ### 1.1 采购协同工作台 `srm_workbench` @@ -177,13 +180,14 @@ | 证书即将到期(前 30 / 15 / 7 天)/ 已过期 | SQE(品类都没有 SQE 时质量经理);v0.6 U6 起加供应商主账号与质量联系人 | 邮件 + 站内 | v0.6(U6,v0.1 只对齐状态未提醒) | | 银行账户变更待复核、变更已生效 | 应付会计 / 采购员 | 站内 | v0.2 | | 合同待审批、合同即将到期 | 审批人、合同所有者、品类经理 | 站内 + 邮件 | v0.3 | -| 新整改单、整改退回、整改重开、整改逾期 | 负责 SQE(新整改单没有 SQE 时发质量经理);逾期加质量经理(SQE 主管);v0.6 U6 起加供应商 `quality` 联系人(任何来源的新整改单都发) | 邮件 + 站内 | v0.4(供应商侧 v0.6 U6) | +| 新整改单、整改退回、整改重开、整改逾期;提出索赔 | 负责 SQE(新整改单没有 SQE 时发质量经理);逾期加质量经理(SQE 主管);v0.6 U6 起加供应商 `quality` 联系人(任何来源的新整改单都发;提出索赔只发供应商,不写金额) | 邮件 + 站内 | v0.4(供应商侧 v0.6 U6) | | 供应商在门户提交临时措施;供应商接受索赔 / 对索赔有异议 | 负责 SQE(提交临时措施时没有 SQE 发质量经理;SQE 本人代填的不发);接受索赔加应付会计 | 站内 | v0.6 | | 待你评分、评分截止前 3 天仍未评、退回重评、绩效结果发布;绩效申诉待处理、申诉处理结果 | 评分人;采购总监;内部代录的申诉人;v0.6 U6 起绩效发布和申诉结果加供应商 `admin` 联系人(门户提交的申诉人只收这一条) | 站内 + 邮件 | v0.5(供应商侧 v0.6 U6) | | 门户开通待办(新门户邀请);门户访问已收回(联系人停用、处置、准入未通过) | 组织管理员 | 站内 | v0.6(U3) | | 银行账户核验生效;信息变更单已生效 / 已驳回 / 退回修改 | 供应商门户管理员(内部发起的变更单另发发起人) | 邮件 + 站内 | v0.6(U6) | | 供货资格冻结 / 解冻、淘汰、列入黑名单 | 供应商 `admin` 联系人(只写范围,不写处置原因) | 邮件 + 站内 | v0.6(U6) | | 门户已开通(门户邀请)、准入申请已受理 / 退回补充 / 已通过 / 未通过 | 受邀联系人;申请联系人(申请上的 `contact`,不发 `created_by`;内部登记的申请没有,不发) | 邮件 + 站内 | v0.6(U6) | +| 供应商在门户提交了准入申请;补充完成、重新提交 | 发出门户邀请的人;初审人(没有时发退回申请的人) | 站内 | v0.6 | | 新的询价邀请、报价截止前 24 小时仍未提交、中标 / 未中标、待你审批的定标 | 受邀供应商 `sales` 联系人;审批人 | 邮件 + 门户待办 | v0.7 | | 新订单待确认、订单变更、交期风险、订单 24 / 48 小时未确认、付款完成 | 供应商 / 采购员 / 采购主管 | 邮件 / 站内 | ERP 集成后 | diff --git a/docs/design/07-delivery-plan.md b/docs/design/07-delivery-plan.md index 4696b25..e38f673 100644 --- a/docs/design/07-delivery-plan.md +++ b/docs/design/07-delivery-plan.md @@ -243,7 +243,7 @@ test/ # 仓库根目录 | V3 | flow 发起组织邀请 | 门户开通自动化 | 管理员在成员页邀请 | ❌ 2026-11-02 不通过:`runAs: 'system'` 拒绝(objectstack#25251);`runAs: 'user'` 连组织管理员也被拒,邀请只能经组织接口发(平台裁定,ADR-0092)。邀请接口只认组织管理员,品类经理调用返回 403。按「不满足时」一列:管理员在成员页按 `external` 邀请(04 §3.3 第 1 步);登记 PF17 | | V4 | 门户用户对 `controlled_by_parent` 子记录的写入 | 证书、整改回复 | 父记录授编辑权 + 字段级只读 | ✅ 2026-11-02 通过:父记录授编辑 + RLS 限本公司后,门户用户能给本公司增改证书(含员工录的证书,同公司另一位门户用户也能改),不能给别家建、不能把证书挪到别家、不能改 `status` / `verified`、不能删;父记录只授读时子记录增改都被拒,所以父记录必须授编辑。父记录上设为只读的字段(名称、状态等)一经提交整单拒绝(403),不是静默丢弃,门户表单不要提交只读字段。门户管理员加银行账户还要 `srm_view_bank_account_no` 能力(BR-20)。U2 用正式元数据复测通过(门户在本公司名下新建证书、门户管理员新建联系人放行,在别家名下新建、改挂到别家一律拒绝,`test/portal-isolation.test.ts`) | | V5 | 环境内其他应用的 `isDefault` 权限集 | R2 | 逐个审查 | ✅ 2026-11-02 通过:另一个应用的 `isDefault` 权限集作用到每位员工,不作用到任何 `external` 成员;`external` 也不持 `member_default`,只有 `external_default` 和岗位、直接授予的权限集 | -| V6 | 托管环境的成员键隔离(**v0.6 出口条件**) | 门户隔离(ADR-0010) | 改回方案 A(ADR-0003) | ⏳ 进程内实测通过(2026-11-02,U2);在托管环境用两个 `external` 门户用户复测 04 §3.7 的交叉读、空名册、回收,并覆盖「键为空」与「运行时不认识这个键」两种情形,期望都是拒绝、零行;同时经 AI 助手复核交叉读。由出口测试执行一次 | +| V6 | 托管环境的成员键隔离(**v0.6 出口条件**) | 门户隔离(ADR-0010) | 改回方案 A(ADR-0003) | ⏳ 进程内实测通过(2026-11-02,U2);本地出口验收通过(2026-11-02:两个 `external` 门户用户经 REST、浏览器、本地 MCP 通道交叉读都零行或「不存在」,联系人停用、拉入黑名单后同一会话刷新即零行);托管环境复测与控制台 AI 助手复核还没做(出口验收时没有托管环境)。在托管环境用两个 `external` 门户用户复测 04 §3.7 的交叉读、空名册、回收,并覆盖「键为空」与「运行时不认识这个键」两种情形,期望都是拒绝、零行;同时经 AI 助手复核交叉读。由出口测试执行一次 | 复现:V1–V5 见 `test/portal-platform.test.ts`(18.1.0,进程内启动整个应用,门户用户走真实的邀请 → 注册 → 接受邀请)。其中的 门户岗位、门户权限集和供应商的 `portal_position` 是测平台行为用的夹具(方案 A 的形状),不是应用元数据;正式元数据(ADR-0010)的 diff --git a/docs/implementation/local-demo.md b/docs/implementation/local-demo.md index 1771c2d..98ce46e 100644 --- a/docs/implementation/local-demo.md +++ b/docs/implementation/local-demo.md @@ -15,13 +15,14 @@ git clone https://github.com/objectstack-ai/hotsrm.git cd hotsrm pnpm install -OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true pnpm dev +OS_AUTH_MEMBERSHIP_POLICY=invite-only OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true pnpm dev ``` -Windows PowerShell:`$env:OS_AUTOMATION_SCHEDULED_WORK_ENABLED="true"; pnpm dev` +Windows PowerShell:`$env:OS_AUTH_MEMBERSHIP_POLICY="invite-only"; $env:OS_AUTOMATION_SCHEDULED_WORK_ENABLED="true"; pnpm dev` 看到 `Server is ready` 后保持这个窗口。数据库是 `.objectstack/data/objectstack.db`(sqlite),重启后数据还在。 开关 `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` 让证书到期扫描、整改逾期提醒等定时 flow 运行,不开也能用,只是定时任务不执行。 +`OS_AUTH_MEMBERSHIP_POLICY=invite-only` 是门户(v0.6)的前提:注册不自动入组织,供应商只能凭邀请进来([04 §3.3](../design/04-security-and-portal.md#33-门户账号的开通与回收))。不演示门户可以不设。 ## 3. 建账号、导入样例数据 @@ -35,7 +36,7 @@ Windows PowerShell:`$env:HOTSRM_DEMO_PASSWORD="<自定密码>"; pnpm setup:loc 脚本 [`scripts/setup-local.mjs`](../../scripts/setup-local.mjs) 用平台开发管理员(`objectstack dev` 在空库上种的账号,启动日志里会打印)调用接口: -1. 每个岗位建一个账号 `<账号>@hotsrm.local`,见下表; +1. 每个岗位建一个账号 `<账号>@hotsrm.local`,见下表,并加入组织(`invite-only` 下管理员建的账号不会自动入组织);`srmadmin` 同时设为组织管理员,负责门户开通; 2. 给三个演示品类(电子元器件、包装材料、工业品与备件)指派品类经理、SQE、采购员、评分人,审批和自动整改单按品类找人; 3. 按 [样例导入表格](../../data/samples/README.md) 的顺序和匹配字段导入物料、存量供应商、联系人、证书、合同与明细、2026-09 交付记录(导入后自动开出 2 张整改单);物料按物料号前缀关联到演示品类。 @@ -64,13 +65,21 @@ Windows PowerShell:`$env:HOTSRM_DEMO_PASSWORD="<自定密码>"; pnpm setup:loc 平台开发管理员只用于 Setup,没有 SRM 岗位,看不到 SRM 菜单,验收不要用它。 -## 5. 常用操作 +## 5. 演示供应商门户(v0.6) + +1. 品类经理在「门户邀请」新建邀请(选已登记的供应商和联系人,门户管理员要附授权书),组织管理员 `srmadmin` 收到待办; +2. `srmadmin` 打开组织成员页 http://localhost:3000/_console/organizations/default/members ,按邀请邮箱邀请为 `external`; +3. 本地不发邮件:在组织的邀请页(`/_console/organizations/default/invitations`)查到邀请 ID,在无痕窗口打开 + `http://localhost:3000/_console/register?redirect=%2Faccept-invitation%2F<邀请ID>`,用该邮箱注册并接受邀请(平台邀请 48 小时内有效); +4. `srmadmin` 回到门户邀请记录点「开通门户」,选刚接受邀请的用户;之后该用户登录只看到「供应商门户」导航组和本公司数据。 + +## 6. 常用操作 - 同时用两个岗位:一个普通窗口、一个无痕窗口各登一个账号 - 从头再来:停掉 `pnpm dev`,删除 `.objectstack` 目录,重新启动后再运行一次 `pnpm setup:local` - 更新代码:`git pull && pnpm install`,已有数据保留 -## 6. 已知平台问题 +## 7. 已知平台问题 | 现象 | 处理 | 跟踪 | |---|---|---| diff --git a/scripts/setup-local.mjs b/scripts/setup-local.mjs index 88198f8..987b4e8 100644 --- a/scripts/setup-local.mjs +++ b/scripts/setup-local.mjs @@ -63,10 +63,19 @@ for (const [key, position, name] of USERS) { if (created.status >= 300 && !JSON.stringify(created.json).includes('ALREADY_EXISTS')) must(created, `新建账号 ${email}`); const [user] = rows(must(await api(admin, 'GET', `/data/sys_user?email=${encodeURIComponent(email)}`), `查找 ${email}`)); ids[key] = user.id; + // 成员策略为 invite-only(开门户的环境必须如此,04 §3.3)时管理员建的账号不会自动入组织,先加为普通成员 + const member = rows(must(await api(admin, 'GET', `/data/sys_member?user_id=${user.id}&organization_id=${org}`), '查成员')); + if (!member.length) must(await api(admin, 'POST', '/auth/organization/add-member', { userId: user.id, organizationId: org, role: 'member' }), `加入组织 ${email}`); const has = rows(must(await api(admin, 'GET', `/data/sys_user_position?user_id=${user.id}&position=${position}`), '查岗位')); if (!has.length) must(await api(admin, 'POST', '/data/sys_user_position', { user_id: user.id, position, organization_id: org }), `指派岗位 ${position}`); console.log(`✓ ${email.padEnd(26)} ${name}`); } +// SRM 系统管理员兼任组织管理员(04 §3.3):在成员页邀请门户用户、运行「开通门户」 +const [srmadminMember] = rows(must(await api(admin, 'GET', `/data/sys_member?user_id=${ids.srmadmin}&organization_id=${org}`), '查成员')); +if (srmadminMember.role !== 'admin' && srmadminMember.role !== 'owner') { + must(await api(admin, 'POST', '/auth/organization/update-member-role', { memberId: srmadminMember.id, organizationId: org, role: 'admin' }), '设为组织管理员'); +} +console.log('✓ srmadmin@hotsrm.local 兼任组织管理员'); const tokens = {}; for (const key of ['srmadmin', 'cm', 'sqe']) tokens[key] = await signIn(`${key}@hotsrm.local`, PASSWORD); diff --git a/src/core/actions/bank-account.action.ts b/src/core/actions/bank-account.action.ts index 577e545..2ca44b3 100644 --- a/src/core/actions/bank-account.action.ts +++ b/src/core/actions/bank-account.action.ts @@ -11,5 +11,7 @@ export const BankVerifyAction = defineAction({ target: 'srm_bank_verify', locations: ['record_header', 'list_item'], visible: P`record.status == 'pending_verify'`, + // 门户管理员能新建银行账户,但核验只属于内部(应付会计):按钮不显示给门户用户 + requiredPermissions: ['srm_internal_access'], refreshAfter: true, }); diff --git a/src/core/actions/contract.actions.ts b/src/core/actions/contract.actions.ts index d02d177..0a8d8cb 100644 --- a/src/core/actions/contract.actions.ts +++ b/src/core/actions/contract.actions.ts @@ -11,6 +11,7 @@ export const ContractSubmitAction = defineAction({ target: 'srm_contract_submit', locations: ['record_header'], visible: P`record.status == 'draft'`, + requiredPermissions: ['srm_internal_access'], refreshAfter: true, }); @@ -23,5 +24,6 @@ export const ContractActivateAction = defineAction({ target: 'srm_contract_activate', locations: ['record_header'], visible: P`record.status == 'signing'`, + requiredPermissions: ['srm_internal_access'], refreshAfter: true, }); diff --git a/src/core/actions/performance.actions.ts b/src/core/actions/performance.actions.ts index 8ed3046..45d6287 100644 --- a/src/core/actions/performance.actions.ts +++ b/src/core/actions/performance.actions.ts @@ -2,7 +2,9 @@ import { P } from '@objectstack/spec'; import { defineAction } from '@objectstack/spec/ui'; // 考评单上的按钮(03 §10 第 3、5、7 步)。谁能操作、能不能操作由 flow 自己检查。 -const action = (name: string, label: string, icon: string, status: string) => +// 内部按钮只显示给持 srm_internal_access 的内部员工;「申诉」门户管理员和代录的采购都要用,不限能力(flow 自己检查) +const INTERNAL = ['srm_internal_access']; +const action = (name: string, label: string, icon: string, status: string, requiredPermissions?: string[]) => defineAction({ name, label, @@ -12,13 +14,14 @@ const action = (name: string, label: string, icon: string, status: string) => target: name, locations: ['record_header'], visible: P`record.status == ${status}`, + ...(requiredPermissions ? { requiredPermissions } : {}), refreshAfter: true, }); -export const ScoreSubmitAction = action('srm_score_submit', '提交评分', 'pencil', 'scoring'); -export const PerformanceVoidAction = action('srm_performance_void', '作废', 'x-circle', 'scoring'); +export const ScoreSubmitAction = action('srm_score_submit', '提交评分', 'pencil', 'scoring', INTERNAL); +export const PerformanceVoidAction = action('srm_performance_void', '作废', 'x-circle', 'scoring', INTERNAL); export const AppealSubmitAction = action('srm_appeal_submit', '申诉', 'message-square', 'published'); -export const AppealResolveAction = action('srm_appeal_resolve', '处理申诉', 'gavel', 'appealed'); +export const AppealResolveAction = action('srm_appeal_resolve', '处理申诉', 'gavel', 'appealed', INTERNAL); // 评分项上的按钮(考评单的评分明细列表每行,及评分项详情页):回避改派评分人,自动算出得分的项不显示 export const ScoreReassignAction = defineAction({ @@ -30,5 +33,6 @@ export const ScoreReassignAction = defineAction({ target: 'srm_score_reassign', locations: ['record_related', 'record_header'], visible: P`record.scorer != null || record.scored == false || record.defaulted == true`, + requiredPermissions: ['srm_internal_access'], refreshAfter: true, }); diff --git a/src/core/actions/portal.actions.ts b/src/core/actions/portal.actions.ts index 69d3d59..07da2dc 100644 --- a/src/core/actions/portal.actions.ts +++ b/src/core/actions/portal.actions.ts @@ -55,6 +55,7 @@ export const PortalEnableAction = defineAction({ target: 'srm_portal_enable', locations: ['record_header', 'list_item'], visible: P`record.status == 'sent'`, + requiredPermissions: ['srm_internal_access'], refreshAfter: true, }); diff --git a/src/core/actions/supplier.actions.ts b/src/core/actions/supplier.actions.ts index 25a3150..bb591dc 100644 --- a/src/core/actions/supplier.actions.ts +++ b/src/core/actions/supplier.actions.ts @@ -11,6 +11,7 @@ export const ConfirmLegacyReviewAction = defineAction({ patch: { legacy_reviewed: true }, locations: ['record_header', 'list_item'], visible: P`record.legacy_imported == true && record.legacy_reviewed != true`, + requiredPermissions: ['srm_internal_access'], confirmText: '确认已核对该供应商的档案、联系人和证书?', successMessage: '已确认复核', refreshAfter: true, diff --git a/src/core/apps/workbench.app.ts b/src/core/apps/workbench.app.ts index 91c2bce..e12985a 100644 --- a/src/core/apps/workbench.app.ts +++ b/src/core/apps/workbench.app.ts @@ -2,8 +2,9 @@ import { App } from '@objectstack/spec/ui'; // SRM 应用:内部工作台与供应商门户(05 §1)。导航按分组组织,扩展包往分组里挂菜单(ADR-0002)。 // 平台规定一个应用包只有一个应用(objectstack ADR-0019 D3),所以门户不是第二个应用,而是本应用里只对持门户能力 -// srm_portal_access 的用户显示的一组导航(门户权限集授予)。这只是体验:门户用户能看到哪些行由门户 RLS 决定 -// (ADR-0010),点进内部菜单也只看到本公司的数据。 +// srm_portal_access 的用户显示的一组导航(门户权限集授予);内部分组反过来只对持 srm_internal_access 的内部员工显示 +// (v0.6 出口验收:门户用户原先也看到「处置单」「门户邀请」等内部菜单,点进去是「无权访问」)。这只是体验:门户用户 +// 能看到哪些行由门户 RLS 决定(ADR-0010),直接打开内部对象的地址也只看到本公司的数据。 export const Workbench = App.create({ name: 'srm_workbench', label: 'SRM 工作台', @@ -70,6 +71,7 @@ export const Workbench = App.create({ { id: 'group_supplier', type: 'group', + requiredPermissions: ['srm_internal_access'], label: '供应商', icon: 'factory', expanded: true, @@ -87,6 +89,7 @@ export const Workbench = App.create({ { id: 'group_contract', type: 'group', + requiredPermissions: ['srm_internal_access'], label: '合同', icon: 'file-signature', children: [ @@ -96,6 +99,7 @@ export const Workbench = App.create({ { id: 'group_quality', type: 'group', + requiredPermissions: ['srm_internal_access'], label: '质量', icon: 'shield-check', children: [ @@ -107,6 +111,7 @@ export const Workbench = App.create({ { id: 'group_performance', type: 'group', + requiredPermissions: ['srm_internal_access'], label: '绩效与风险', icon: 'gauge', children: [ @@ -119,6 +124,7 @@ export const Workbench = App.create({ { id: 'group_analytics', type: 'group', + requiredPermissions: ['srm_internal_access'], label: '分析', icon: 'chart-column', children: [ @@ -131,6 +137,7 @@ export const Workbench = App.create({ { id: 'group_master_data', type: 'group', + requiredPermissions: ['srm_internal_access'], label: '基础数据', icon: 'database', children: [ diff --git a/src/core/flows/admission/resubmit.flow.ts b/src/core/flows/admission/resubmit.flow.ts index aa982cc..2430180 100644 --- a/src/core/flows/admission/resubmit.flow.ts +++ b/src/core/flows/admission/resubmit.flow.ts @@ -3,7 +3,7 @@ import type * as Automation from '@objectstack/spec/automation'; // 门户「补充完成,重新提交」(03 §3 第 4 步,04 §3.2,v0.6):品类经理把准入申请「退回补充」后,供应商在门户补齐资料、 // 证书和银行账户(这些都在供应商档案下,门户可以直接维护),再在申请上写明补了什么、重新提交:申请回到「资料审核」, -// 通知初审人。申请本身的品类、初审人等由内部维护,门户不直接改。 +// 通知初审人(还没指定时通知退回申请的人)。申请本身的品类、初审人等由内部维护,门户不直接改。 // // 以系统身份运行:门户对准入申请只有读共享(ADR-0010 两把锁)。flow 进门判断启动人持门户岗位、并且是这张申请所属供应商 // 在职联系人上登记的门户账号(与成员键同一份名册),伪造别家申请的 recordId 时名册对不上,直接结束(直接触发不检查记录可读,objectstack#29050;谁可以启动见 objectstack#25481)。 @@ -83,10 +83,13 @@ export const ApplicationResubmitFlow: Automation.Flow = { { id: 'notify_reviewer', type: 'notify', - label: '通知初审人(没有初审人时通知申请的新建人)', + // 没有初审人时通知退回它的人:申请停在「退回补充」,最后一次改它的就是退回的品类经理。门户提交的申请 + // 新建人是门户用户本人(系统身份代建),不能退回到新建人 + label: '通知初审人(没有初审人时通知退回申请的人)', config: { recipients: c( - `has(application.reviewer) && application.reviewer != null ? [application.reviewer] : [application.created_by]`, + `has(application.reviewer) && application.reviewer != null ? [application.reviewer]` + + ` : has(application.updated_by) && application.updated_by != null ? [application.updated_by] : [application.created_by]`, ), title: '准入申请 {{ application.application_no }} 已补充,待资料审核', message: '供应商在门户补充了资料并重新提交,补充说明:{{ note }}', diff --git a/src/core/flows/portal/application-submit.flow.ts b/src/core/flows/portal/application-submit.flow.ts index fca2f4c..3b6b65a 100644 --- a/src/core/flows/portal/application-submit.flow.ts +++ b/src/core/flows/portal/application-submit.flow.ts @@ -94,6 +94,33 @@ export const ApplicationSubmitFlow: Automation.Flow = { label: '回填邀请', config: { objectName: 'srm_supplier_invitation', filter: { id: cel`invite.id` }, fields: { application: cel`app.id` } }, }, + // 申请由系统身份代建、属于提交的门户用户,内部没有人因此收到待办:告诉提出这份邀请的品类经理(或采购主管)来受理 + { + id: 'notify_guard', + type: 'try_catch', + label: '通知邀请人', + config: { + try: { + nodes: [ + { + id: 'notify_requester', + type: 'notify', + label: '通知邀请人受理', + config: { + recipients: cel`[invite.created_by]`, + title: '{{ vendor.name }} 在门户提交了准入申请', + message: '受邀供应商已在门户提交准入申请,请审核资料:资料不全的「退回补充」并写明退回意见,齐全的指定初审人后提交准入评审。', + severity: 'info', + sourceObject: 'srm_supplier_application', + sourceId: cel`app.id`, + }, + }, + ], + }, + // 邀请人已离职等找不到人时不影响提交 + catch: { nodes: [{ id: 'skip_notify', type: 'assignment', label: '跳过通知' }] }, + }, + }, { id: 'end', type: 'end', label: '结束' }, ], @@ -115,6 +142,7 @@ export const ApplicationSubmitFlow: Automation.Flow = { { 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' }, + { id: 'e6', source: 'backfill', target: 'notify_guard', type: 'default' }, + { id: 'e7', source: 'notify_guard', target: 'end', type: 'default' }, ], }; diff --git a/src/core/flows/quality/scar-supplier-notice.flow.ts b/src/core/flows/quality/scar-supplier-notice.flow.ts index 2a3703a..eae3119 100644 --- a/src/core/flows/quality/scar-supplier-notice.flow.ts +++ b/src/core/flows/quality/scar-supplier-notice.flow.ts @@ -3,7 +3,7 @@ import { byFunction, loadRoster, loadVendor, notifySupplier, pathOf, vendorName // 整改单通知供应商(05 §5「新整改单、整改退回、整改重开、整改逾期」加供应商 quality 联系人,v0.6 U6): // 不论整改单从哪里来(来料不合格自动开单、审核问题项转单、SQE 手工新建),新建、SQE 退回根因与措施、关闭后重新打开、 -// 被逾期扫描标记逾期,都发给该供应商名册上的质量联系人(没有质量联系人时发主账号联系人)。内部 SQE / 质量经理的通知 +// SQE 提出索赔(v0.6 出口验收补上)、被逾期扫描标记逾期,都发给该供应商名册上的质量联系人(没有质量联系人时发主账号联系人)。内部 SQE / 质量经理的通知 // 仍在 srm_scar_create、srm_scar_review、srm_scar_reopen、srm_scar_overdue_sweep 里,不变。 const SOURCE = 'srm_quality_issue'; const events: Array<[key: string, condition: string, template: string, label: string]> = [ @@ -15,6 +15,13 @@ const events: Array<[key: string, condition: string, template: string, label: st '整改退回', ], ['reopened', `previous != null && record.status == 'open' && has(previous.status) && previous.status == 'closed'`, 'srm_scar_reopened', '整改重开'], + // SQE 提出索赔(03 §8 第 6 步):供应商要在门户「确认索赔」,不通知就只能等他自己点开整改单。金额不写进通知(R5) + [ + 'claim', + `previous != null && has(record.claim_status) && record.claim_status == 'proposed' && !(has(previous.claim_status) && previous.claim_status == 'proposed')`, + 'srm_scar_claim_proposed', + '提出索赔', + ], [ 'overdue', `previous != null && record.is_overdue == true && !(has(previous.is_overdue) && previous.is_overdue == true)`, @@ -34,7 +41,7 @@ export const ScarSupplierNoticeFlow: Automation.Flow = { { id: 'start', type: 'start', - label: '整改单新建、退回、重开或逾期', + label: '整改单新建、退回、重开、提出索赔或逾期', config: { objectName: SOURCE, triggerType: 'record-after-write', diff --git a/src/core/notifications/index.ts b/src/core/notifications/index.ts index 2d6e84d..5bf69ff 100644 --- a/src/core/notifications/index.ts +++ b/src/core/notifications/index.ts @@ -150,6 +150,19 @@ const TEMPLATES: Template[] = [ body: 'Corrective action request {{ no }} of {{ supplier }} is past its reply deadline (containment {{ containment_due | datetime }}, root cause and actions {{ final_due | date }}). Please reply in the {{ appName }} supplier portal as soon as possible.', }, }, + { + name: 'srm_scar_claim_proposed', + label: '整改单提出索赔', + variables: ['no', 'supplier'], + zh: { + subject: '整改单 {{ no }} 提出了索赔,请确认', + body: '我方 SQE 已就 {{ supplier }} 的整改单 {{ no }} 提出索赔。请登录 {{ appName }} 供应商门户,在整改单上点「确认索赔」接受或提出异议。', + }, + en: { + subject: 'A claim was raised on corrective action request {{ no }}', + body: 'Our SQE has raised a claim on corrective action request {{ no }} of {{ supplier }}. Sign in to the {{ appName }} supplier portal and click "Respond to Claim" on the request to accept it or dispute it.', + }, + }, { name: 'srm_review_published', label: '绩效结果已发布', diff --git a/src/core/objects/supplier-application.object.ts b/src/core/objects/supplier-application.object.ts index a314920..00001c4 100644 --- a/src/core/objects/supplier-application.object.ts +++ b/src/core/objects/supplier-application.object.ts @@ -112,6 +112,8 @@ export const SupplierApplication = ObjectSchema.create({ }), approved_date: Field.date({ label: '批准日期', group: 'review', readonly: true }), reject_reason: Field.textarea({ label: '未通过原因', group: 'review' }), + // 「退回补充」时品类经理写给供应商的退回意见:要补什么(v0.6)。门户可读,退回通知请供应商到门户查看 + return_reason: Field.textarea({ label: '退回意见', group: 'review' }), // 「退回补充」后供应商在门户重新提交时写的补充说明(srm_application_resubmit,v0.6),只读 supplement_note: Field.textarea({ label: '补充说明', group: 'review', readonly: true }), }, @@ -157,6 +159,14 @@ export const SupplierApplication = ObjectSchema.create({ message: '高风险品类的申请要有通过或有条件通过的现场审核,才能进入准入评审', condition: P`record.status == 'admission_review' && has(previous.status) && previous.status != 'admission_review' && has(record.risk_class) && record.risk_class == 'high' && record.audit_passed != true`, }, + { + // 退回补充要写明补什么:供应商看不到内部的讨论区,只能在申请上看到退回意见(v0.6) + name: 'return_reason_required', + type: 'script', + severity: 'error', + message: '退回补充时需要填写退回意见', + condition: P`record.status == 'returned' && (!has(record.return_reason) || record.return_reason == null || isBlank(record.return_reason))`, + }, { name: 'reject_reason_required', type: 'script', diff --git a/src/core/security/capabilities.ts b/src/core/security/capabilities.ts index a6cb1d6..a559aa1 100644 --- a/src/core/security/capabilities.ts +++ b/src/core/security/capabilities.ts @@ -31,4 +31,13 @@ export const VerifyCertificate = defineCapability({ scope: 'org', }); -export const SrmCapabilities = [ViewBankAccountNo, PortalAccess, PortalManage, VerifyCertificate]; +// 内部工作台(v0.6):内部菜单和内部专用按钮只显示给内部员工,门户用户看不到(05 §1)。只是体验:门户用户点进内部对象, +// 能看到的行仍由门户 RLS 决定,权限集没授的对象答「无权访问」。平台管理员不持应用能力,同样看不到内部菜单,验收用 SRM 岗位账号 +export const InternalAccess = defineCapability({ + name: 'srm_internal_access', + label: '使用内部工作台', + description: '看到 SRM 工作台的内部菜单与内部按钮(如银行账户「核验」);内部岗位都持有,门户用户不持有', + scope: 'org', +}); + +export const SrmCapabilities = [ViewBankAccountNo, PortalAccess, PortalManage, VerifyCertificate, InternalAccess]; diff --git a/src/core/security/permission-sets.ts b/src/core/security/permission-sets.ts index bc6a696..14527f6 100644 --- a/src/core/security/permission-sets.ts +++ b/src/core/security/permission-sets.ts @@ -47,6 +47,8 @@ export const InternalBasePermissionSet = { srm_performance_review: read, srm_performance_score: read, }, + // 内部工作台的导航(体验,不是数据边界):门户用户不持这项能力,看不到内部菜单 + systemPermissions: ['srm_internal_access'], }; const contractFieldsReadOnly = Object.fromEntries(Object.keys(Contract.fields).map((field) => [`srm_contract.${field}`, readOnly])); @@ -320,6 +322,8 @@ export const AuditorPermissionSet = { srm_performance_review: read, srm_performance_score: read, }, + // 内控 / 监标只挂这一个权限集,内部菜单也从这里授予 + systemPermissions: ['srm_internal_access'], }; export const SrmPermissionSets = [ diff --git a/src/core/security/sharing-rules.ts b/src/core/security/sharing-rules.ts index 3986eea..b095975 100644 --- a/src/core/security/sharing-rules.ts +++ b/src/core/security/sharing-rules.ts @@ -66,6 +66,14 @@ export const ContractEditRules = editRules('srm_contract', P`record.title != ''` 'srm_legal_counsel', ]); +// 准入申请(02 §4.2,03 §3):OWD private,读靠权限集的 viewAllRecords。门户提交的申请由系统身份 flow 代建,属于提交的 +// 门户用户(v0.6);品类经理、采购主管要能受理、退回补充、指定初审人,也要能接手同事登记的申请。门户写不了申请(门户 +// 权限集不授编辑),这里只放给持 srm_supplier_admin 的两个岗位。供应商必填 +export const ApplicationEditRules = editRules('srm_supplier_application', P`record.supplier != ''`, '处理准入申请', [ + 'srm_category_manager', + 'srm_buyer_lead', +]); + // 整改单(02 §4.6,03 §8):自动开的单属于系统,SQE 之间要互相接手;应付会计只改索赔状态(FLS)。来源必填 export const QualityIssueEditRules = editRules('srm_quality_issue', P`record.source != ''`, '处理整改单', [ 'srm_sqe', @@ -119,6 +127,7 @@ export const SrmSharingRules = [ ...SupplierEditRules, ...MaterialEditRules, ...ContractEditRules, + ...ApplicationEditRules, ...QualityIssueEditRules, ...SupplierAuditEditRules, ...RiskEventEditRules, diff --git a/src/core/translations/en.ts b/src/core/translations/en.ts index c052a97..bd6dd99 100644 --- a/src/core/translations/en.ts +++ b/src/core/translations/en.ts @@ -422,6 +422,7 @@ export const en: TranslationData = { supplier_admissible: { message: 'Only a Potential or Eliminated supplier can apply for admission' }, reviewer_before_review: { message: 'Assign a reviewer before the admission review' }, audit_before_review: { message: 'A high-risk application needs an on-site audit that passed or conditionally passed before the admission review' }, + return_reason_required: { message: 'Enter what the supplier needs to supplement when returning the application' }, reject_reason_required: { message: 'Enter a reason when rejecting' }, }, fields: { @@ -454,6 +455,7 @@ export const en: TranslationData = { approval_status: { label: 'Approval Result', options: { pending: 'Pending', approved: 'Approved', rejected: 'Rejected' } }, approved_date: { label: 'Approved Date' }, reject_reason: { label: 'Rejection Reason' }, + return_reason: { label: 'Return Reason' }, supplement_note: { label: 'Supplement Note' }, }, _actions: { srm_application_resubmit: { label: 'Supplemented, Resubmit' } }, diff --git a/test/permissions.test.ts b/test/permissions.test.ts index dd46fd1..73a5ad2 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -3,6 +3,7 @@ import { bootStack, type VerifyStack } from '@objectstack/verify'; import config from '../objectstack.config'; import * as objects from '../src/core/objects'; import { Supplier, SupplierApplication, SupplierBankAccount } from '../src/core/objects'; +import { Workbench } from '../src/core/apps/workbench.app'; import { SrmPermissionSets } from '../src/core/security/permission-sets'; import { SrmPositions } from '../src/core/security/positions'; import { SrmSharingRules } from '../src/core/security/sharing-rules'; @@ -68,6 +69,24 @@ describe('internal permissions', () => { expect(SrmPermissionSets.filter((set) => 'isDefault' in set)).toEqual([]); }); + // 内部菜单和内部经办动作只给内部岗位(05 §1):门户岗位不持 srm_internal_access,内部岗位都持有 + it('keeps the internal workbench capability on internal positions only', () => { + const holds = (position: (typeof SrmPositions)[number]) => + position.permissionSets.some((name) => + ((SrmPermissionSets.find((set) => set.name === name) as { systemPermissions?: string[] }).systemPermissions ?? []).includes('srm_internal_access'), + ); + const portal = SrmPositions.filter((p) => p.name.startsWith('srm_portal_')); + expect(portal.length).toBeGreaterThan(0); + expect(portal.filter(holds).map((p) => p.name)).toEqual([]); + expect(SrmPositions.filter((p) => !p.name.startsWith('srm_portal_') && !holds(p)).map((p) => p.name)).toEqual([]); + }); + + it('shows every navigation group to either internal users or portal users, never both', () => { + const groups = (Workbench.navigation ?? []) as { id: string; type?: string; requiredPermissions?: string[] }[]; + const ungated = groups.filter((g) => g.type === 'group' && !g.requiredPermissions?.some((p) => ['srm_internal_access', 'srm_portal_access'].includes(p))); + expect(ungated.map((g) => g.id)).toEqual([]); + }); + it('mounts only permission sets that exist', () => { const names = new Set(SrmPermissionSets.map((set) => set.name)); for (const position of SrmPositions) { diff --git a/test/portal-invitation.test.ts b/test/portal-invitation.test.ts index 73dceea..4b2c422 100644 --- a/test/portal-invitation.test.ts +++ b/test/portal-invitation.test.ts @@ -289,6 +289,42 @@ describe('portal invitation and opening (v0.6 U3)', () => { expect(((await res.json()) as { records: Row[] }).records.map((r) => String(r.id))).toEqual([String(inv.application)]); }); + it('tells the inviting category manager, who handles the application the portal user owns and returns it only with a reason', async () => { + const appId = String((await row('srm_supplier_invitation', String(invitationX.id))).application); + // 申请由系统身份代建、属于提交的门户用户;邀请人收到受理待办 + await expect + .poll(async () => { + for (const note of await stack.rows('sys_notification', { source_id: appId })) { + const receipts = await stack.rows('sys_notification_receipt', { notification_id: note.id }); + if (receipts.some((r) => String(r.user_id ?? r.recipient_id ?? '') === uid.manager)) return true; + } + return false; + }) + .toBe(true); + // 品类经理不是申请的拥有人,仍能退回补充(共享规则),但要写退回意见 + await expect( + stack.hooks.run('srm_supplier_application', 'update', { id: appId, status: 'returned' }, { as: token.manager }), + ).rejects.toThrow(/supplement|退回意见/); + await stack.hooks.run('srm_supplier_application', 'update', { id: appId, status: 'returned', return_reason: '请补传 ISO 9001 证书' }, { as: token.manager }); + // 门户看得到退回意见 + const res = await stack.apiAs(token.supX, 'GET', `/data/srm_supplier_application/${appId}`); + expect(res.status).toBe(200); + expect(JSON.stringify(await res.json())).toContain('请补传 ISO 9001 证书'); + // 门户补充后重新提交:回到资料审核(后面的用例从这里接着走),通知退回它的品类经理,不通知门户用户自己 + const before = (await stack.rows('sys_notification', { source_id: appId })).map((n) => String(n.id)); + const started = (await stack.actions.run('srm_supplier_application', 'srm_application_resubmit', { as: token.supX, recordId: appId })) as Row; + const run = (await stack.flows.resume({ flowName: 'srm_application_resubmit', runId: started.runId }, { note: '已补传' }, { as: token.supX })) as Row; + expect(run.success, JSON.stringify(run)).toBe(true); + expect((await row('srm_supplier_application', appId)).status).toBe('doc_review'); + await expect + .poll(async () => { + const fresh = (await stack.rows('sys_notification', { source_id: appId })).filter((n) => !before.includes(String(n.id))); + const receipts = (await Promise.all(fresh.map((n) => stack.rows('sys_notification_receipt', { notification_id: n.id })))).flat(); + return receipts.map((r) => String(r.user_id ?? r.recipient_id ?? '')); + }) + .toEqual([uid.manager]); + }); + 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); @@ -304,12 +340,13 @@ describe('portal invitation and opening (v0.6 U3)', () => { 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 }); + const before = (await stack.rows('sys_notification', { source_id: String(app!.id) })).length; 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); + await expect.poll(async () => (await stack.rows('sys_notification', { source_id: String(app!.id) })).length).toBeGreaterThan(before); }); it('clears the roster when a contact is deactivated, so reactivating it does not bring the portal back', async () => { diff --git a/test/portal-notifications.test.ts b/test/portal-notifications.test.ts index f1aaf2d..46327ce 100644 --- a/test/portal-notifications.test.ts +++ b/test/portal-notifications.test.ts @@ -210,7 +210,7 @@ describe('supplier notifications (05 §5, v0.6 U6)', () => { expect((await row('srm_quality_issue', id)).status).toBe('open'); }, 30_000); - it('tells the quality contact when the SQE returns D4–D7, when the SCAR reopens and when it goes overdue', async () => { + it('tells the quality contact when the SQE returns D4–D7, when the SCAR reopens, when a claim is raised and when it goes overdue', async () => { const returned = await seedScar(a, { status: 'action_submitted', containment_action: '示例:在库品全检隔离', @@ -242,7 +242,14 @@ describe('supplier notifications (05 §5, v0.6 U6)', () => { expect(run.success, JSON.stringify(run)).toBe(true); const [overdue] = await inbox(aQuality.id, late); expect(overdue!.title).toContain('已逾期'); - for (const id of [returned, reopened, late]) expect(await supplierRecipients(id)).toEqual([aQuality.id]); + // SQE 提出索赔:通知里没有金额(R5),请供应商到门户确认 + const claimed = await seedScar(a, { status: 'verifying', containment_action: '示例:在库品全检隔离', root_cause: '示例:模具磨损', corrective_action: '示例:更换模具' }); + await update('srm_quality_issue', claimed, { claim_amount: 3600, claim_status: 'proposed' }); + const [claim] = await inbox(aQuality.id, claimed); + expect(claim!.title).toContain('提出了索赔'); + expect(claim!.body_md).not.toContain('3600'); + expect(claim!.body_md).not.toContain('3,600'); + for (const id of [returned, reopened, late, claimed]) expect(await supplierRecipients(id)).toEqual([aQuality.id]); }, 30_000); it('warns the SQE and the supplier’s admin and quality contacts before and when a certificate expires', async () => { @@ -284,7 +291,7 @@ describe('supplier notifications (05 §5, v0.6 U6)', () => { const app = await row('srm_supplier_application', id); expect(accepted!.title).toBe(`准入申请 ${app.application_no} 已受理`); - await update('srm_supplier_application', id, { status: 'returned' }); + await update('srm_supplier_application', id, { status: 'returned', return_reason: '示例:请补传 ISO 9001 证书' }); expect((await inbox(applicant.id, id, 2)).map((m) => m.title)).toContain(`准入申请 ${app.application_no} 需要补充资料`); // 供应商重新提交回到资料审核:不再发「已受理」 await update('srm_supplier_application', id, { status: 'doc_review' }); diff --git a/test/portal-self-service.test.ts b/test/portal-self-service.test.ts index 2cff44d..71629b4 100644 --- a/test/portal-self-service.test.ts +++ b/test/portal-self-service.test.ts @@ -302,7 +302,8 @@ describe('portal self-service (v0.6 U4)', () => { const supplier = String((await sys('srm_supplier', { name: `示例待补充有限公司 ${n}`, credit_code: `91000000MA0SSA${String(n).padStart(4, '0')}` })).id); const application = await stack.hooks.run('srm_supplier_application', 'insert', { supplier, applied_categories: [category] }, { as: manager.token }); const id = String(application.id); - for (const status of ['doc_review', 'returned']) await stack.hooks.run('srm_supplier_application', 'update', { id, status }, { as: manager.token }); + await stack.hooks.run('srm_supplier_application', 'update', { id, status: 'doc_review' }, { as: manager.token }); + await stack.hooks.run('srm_supplier_application', 'update', { id, status: 'returned', return_reason: '请补传 ISO 9001 证书' }, { as: manager.token }); const p = await portalUser(supplier); const other = await portalUser((await qualifiedSupplier()).id); From 79200e706de6f92304455ade4c41d8b35e943d9c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:43:16 +0000 Subject: [PATCH 2/2] docs(delivery): register PF21 lookup names the portal cannot read Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017ihL2fedQbjJWWSwaVb31Q --- docs/design/07-delivery-plan.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/design/07-delivery-plan.md b/docs/design/07-delivery-plan.md index e38f673..d86e798 100644 --- a/docs/design/07-delivery-plan.md +++ b/docs/design/07-delivery-plan.md @@ -276,6 +276,7 @@ test/ # 仓库根目录 | PF18 | objectstack#29050 直接触发 flow(REST trigger)不按启动人的权限读 `recordId`,按钮入口(#16370)会答「不存在」 | 门户能启动的系统身份 screen flow(确认索赔、申诉、提交变更申请、准入补充后重新提交) | 每条 flow 进门自己查名册(`portal_user` = 本人、`active == true`,与成员键同条件),元数据测试守住(04 §3.7) | 2026-11-02 新提 | | PF19 | 平台既定行为(objectstack#3407 起):数据层剔除对只读字段的写入,flow 节点只在步骤结果里给警告(`droppedFields`),写入照样算成功;`runAs: 'user'` 的 flow 因此写不进字段级只读的 `portal_user` | 门户开通(名册写入) | 「开通门户」以 `runAs: 'user'` 任命门户岗位,名册由系统身份的子 flow `srm_portal_roster_write` 写;平台拒绝直接启动系统身份的 autolaunched flow,子 flow 只经开通 flow 进入(04 §3.3);`test/portal-invitation.test.ts` 的探针盯着平台行为 | 平台既定行为,长期做法(2026-11-02 U3 实测,总监裁定不另提 issue) | | PF20 | objectstack#29095 邮件模板的渲染上下文只有 `appName`,没有部署地址(`OS_BASE_URL`),flow 发出的邮件拼不出能点开的完整链接 | 供应商侧通知邮件(05 §5) | 邮件正文写「登录门户查看」和单号,站内信带单据链接;模板数据给出相对路径 `path`,客户可在模板覆盖层写上自己的门户域名 | 2026-11-02 U6 新提 | +| PF21 | objectstack#29171(功能缺口,待维护者裁定)引用的目标记录对当前用户不可读时,lookup 只显示「受限记录」(18.1.0 显示原始 ID,objectui#13335 已改),不显示名称 | 门户页面上指向品类、物料、内部员工的关联字段(准入申请的品类、整改单的物料和负责 SQE) | 引用对象上加 flow 写入的只读文本快照字段(`category_names`、`material_name`、`sqe_name` 等),门户只读快照(hotsrm#95,裁定线程 2026-11-02 选 B);平台做了以后快照字段保留不删 | 2026-11-02 出口验收新提 | 其他代码和文档里指向平台的临时做法,2026-10-26 核对结果: