diff --git a/docs/design/02-domain-model.md b/docs/design/02-domain-model.md index 8c3d26d..9f99ad2 100644 --- a/docs/design/02-domain-model.md +++ b/docs/design/02-domain-model.md @@ -263,7 +263,8 @@ flow 拒绝新建人核验自己建的账户(03 §13)。 | `valid_from` / `valid_to` | date | `valid_to >= valid_from`(跨字段校验) | | `attachment` | file | 扫描件;`requiredWhen: record.legacy_imported != true`——表格导入带不了文件,存量证书复核时补传 | | `status` | select | `valid` 有效 / `expiring` 即将到期 / `expired` 已过期,由 flow 按 `valid_to` 维护,门户只读 | -| `verified` | boolean | 默认 false;SQE 经「核验证书」screen flow 置为 true;供应商修改编号、有效期或附件后由 flow 重置为 false,门户只读 | +| `verified` | boolean(只读) | 默认 false,只由 flow 写入(v0.6):内部人员录入或修改证书即视为核验(门户上线前证书一直由内部录入);门户新建证书或改了类型、编号、发证机构、有效期、扫描件时复位为 false 并通知 SQE(`srm_certificate_change_check`);SQE、质量经理经「核验证书」screen flow(`srm_certificate_verify`)置为 true。资质异常只认已核验的证书(03 §11) | +| `verified_by` | user(只读) | 核验人:内部录入时为录入人,经「核验证书」时为 SQE / 质量经理;门户改动复位时清空(v0.6) | | `legacy_imported` | boolean | 默认 false;存量导入的证书为 true,同供应商的同名字段(只在新建时可置为 true,只有 `srm_admin` 可写)(v0.2) | **`srm_category` 采购品类**(OWD `public_read`):`code`、`name`、`parent`(`tree`, @@ -326,6 +327,7 @@ upsert 写入,结构不变。 | `sqe` | user | 负责审核的 SQE | | `supplier` | lookup → 供应商 | 品类经理把申请推进到「资料审核」时新建(或 `reapply` 时关联旧档)的供应商;`submitted` 阶段只有申请记录,不建主数据 | | `reject_reason` | textarea | | +| `supplement_note` | textarea(只读) | 补充说明:「退回补充」后供应商在门户经「补充完成,重新提交」(`srm_application_resubmit`)写入(v0.6) | | `cycle_days` | number | 从提交到批准的天数,批准时由 flow 写入(准入周期指标) | > v0.1(内部代录,见 [07 §1](./07-delivery-plan.md#1-分期路线图))先实现一个子集:申请由内部人员为已登记的 diff --git a/docs/design/03-processes-and-automation.md b/docs/design/03-processes-and-automation.md index de42334..c4f882e 100644 --- a/docs/design/03-processes-and-automation.md +++ b/docs/design/03-processes-and-automation.md @@ -133,7 +133,7 @@ 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 跳过 | -| 4 补充资料 | 供应商 | 门户内补齐公司资料(门户可改的字段见 [04 §4.2](./04-security-and-portal.md#42-字段级权限fls))、银行账户、证书;品类经理可「退回补充」(申请→`returned`,通知联系人)。应付会计经「核验银行账户」、SQE 经「核验证书」两个 screen flow 完成核验 | +| 4 补充资料 | 供应商 | 门户内补齐公司资料(门户可改的字段见 [04 §4.2](./04-security-and-portal.md#42-字段级权限fls))、银行账户、证书;品类经理可「退回补充」(申请→`returned`,通知联系人随 v0.6 U6)。供应商补齐后在申请上点「补充完成,重新提交」(screen flow `srm_application_resubmit`,v0.6):写补充说明 `supplement_note`,申请回到 `doc_review`,通知初审人;只有申请所属供应商的门户用户能提交(flow 按门户名册检查)。应付会计经「核验银行账户」、SQE 经「核验证书」两个 screen flow 完成核验 | | 5 现场审核 | `risk_class == 'high'` | SQE 建供应商审核记录(`audit_type = admission`,关联本次申请),审核后录入总分、最低审核块得分、结论与问题项(结论按 `audit_score_thresholds` 校验,02 §4.2)。审核完成或改了结论时,flow `srm_audit_admission_apply` 把结论写回申请:`audit_result` 记结论,通过或有条件通过时 `audit_passed = true`,否则为 false,并通知初审人;已批准或已驳回的申请不再写。校验规则 `audit_before_review`:`risk_class == 'high' && audit_passed != true` 时申请不得进入 `admission_review`(FR-204)。申请上的勾选框只由 flow 写,品类经理改不了 | | 6 准入评审 | 申请→`admission_review` | flow `srm_admission_approval`:第 1 级 `field` 审批人 = `reviewer`(品类经理);第 2 级 `position` = `srm_procurement_director`;`material_class == 'class_a'` 或 `risk_class == 'high'` 时第 2 级改为采购总监、`srm_quality_manager` 并行会签(`per_group`:两个岗位各一人通过,同岗位多人时任一人即可;任一人驳回即驳回)。初审人就是申请的新建人或把申请推进到准入评审的人时,品类经理一级上提给采购总监、与第 2 级合并(标准申请由采购总监审批,A 类或高风险由采购总监 + 质量经理会签),申请上 `review_escalated` 记为 true;不要求另一位品类经理,免得只有一位品类经理的品类卡住(平台没有「不可自审」选项,objectstack#25446;同合同、处置单的做法)。驳回 → 申请回 `doc_review` 并通知 | | 7 批准后 | 审批通过 | 申请→`approved`,写入 `cycle_days`;供应商 `qualifying → qualified`;按申请品类建 `srm_supplier_category`(申请的 `audit_result` 为有条件通过时为 `trial`,否则 `qualified`,WI-01 6.6.2);通知供应商。ERP 建档与 `erp_vendor_code` 回写为预留集成点(06 §1) | @@ -191,14 +191,16 @@ flowchart TD ## 5. P2 信息变更与供应商处置 -**新增银行账户**(v0.2,BR-20):采购侧(`srm_supplier_admin`)在供应商档案下新建银行账户, -记录为 `pending_verify` 并锁定 → 应付会计在账户上点「核验」,经 screen flow `srm_bank_verify` +**新增银行账户**(v0.2,BR-20):采购侧(`srm_supplier_admin`)在供应商档案下新建银行账户(v0.6 起本公司门户管理员也可以在门户新建), +记录为 `pending_verify` 并锁定,`srm_bank_pending_notice` 通知全部应付会计(v0.6,通知不含账号)→ 应付会计在账户上点「核验」,经 screen flow `srm_bank_verify` 按 `params/core.yaml` 的 `bank_change_verification` 核实后确认 → 账户置为 `active`,写入 `verified_by`。flow 拒绝三种情况:启动人不是应付会计、账户不是待核验、启动人就是新建人 (双人原则)。已有账户的修改不直接编辑,走下面的信息变更单。 **信息变更**(v0.2,FR-207):门户上线前由采购侧代供应商在供应商档案下新建变更单(v0.6 起供应商在门户经 -「提交变更申请」screen flow 提交),填写变更后的值 → 审批 flow `srm_change_approval` 新建即触发,先把当前档案 +「提交变更申请」screen flow `srm_change_submit` 提交:本公司门户管理员在供应商档案上选变更类型、填写新值,flow 以系统身份代建变更单, +发起人记为门户管理员本人;flow 进门检查启动人持 `srm_portal_admin` 并且是这家供应商在职联系人上登记的门户账号, +银行账户、联系人的归属再由变更单的校验规则把关),填写变更后的值 → 审批 flow `srm_change_approval` 新建即触发,先把当前档案 快照到 `before_value` → 第 1 级 `position = srm_buyer_lead`(采购确认);`change_type == 'bank_account'` 时 增加第 2 级 `position = srm_ap_accountant`(财务复核,核实方式按 `params/core.yaml` 的 `bank_change_verification`) → 通过后子 flow `srm_change_apply` 以系统身份写回:基本信息、地址写回供应商(同时写 `last_change_request`, @@ -207,7 +209,8 @@ flowchart TD 变更单为 `applied`,通知发起人。 - **驳回**:不改主记录,审批意见写入 `reject_reason`,通知发起人。 -- **退回修改**:两级都可以退回(`revise` → `approval_revise`,每级至多 3 次),记录解锁,发起人改完重新提交, +- **退回修改**:两级都可以退回(`revise` → `approval_revise`,每级至多 3 次),记录解锁,发起人改完重新提交(门户提交的变更单由 + 门户管理员在门户改:门户权限集只放行本公司、被退回的单,变更单是 `private`,`external` 成员本来只能改自己发起的), 从第 1 级重新审批(第 2 级退回时账户信息改过,采购要再确认);第 1 级通过时清掉上一轮的财务审批人。 18.0.0 时第 2 级不能退回(平台把第 2 个审批节点的提交人记成第 1 级审批人,objectstack#24817),18.1.0 修复后加回(2026-10-26)。 - **职责分离(BR-11)**:发起人不能审批本人的变更单,两级审批人不能是同一人。平台没有「提交人不得审批」开关 @@ -427,8 +430,8 @@ stateDiagram-v2 |---|---|---| | 到期提醒 | `time_relative`:证书 `valid_to`,提前 30 / 15 / 7 天 | 证书 `status → expiring`;通知 SQE 与供应商联系人(FR-901) | | 到期生效 | 追赶式扫描:`valid_to < today` 且不是 `expired`(定时 flow 在云上默认关闭,部署时打开,objectstack#25442) | 证书→`expired`;`is_mandatory` 的证书过期时供应商 `qualification_alert = true`(FR-902);v0.5 起同时新建 `qualification` 类风险事件 | -| 供应商修改证书 | 证书的编号、有效期或附件被门户修改 | flow 把 `verified` 重置为 false,通知 SQE 重新核验 | -| 资质恢复 | 证书或品类资格写入后的记录触发(`srm_supplier_qualification_recalc`) | 先按该供应商全部品类的 `mandatory_cert_types` 标记每张证书的 `is_mandatory`(`srm_certificate_mandatory_mark`:新建证书或改了类型时看这张证书;`srm_grant_mandatory_mark`:新增品类资格时看该供应商的全部证书。只置为必备、不取消,存量导入和手工标记的保留);再重新计算:每一种必备资质类型都有 `status == 'valid'` 且 `verified == true` 的证书时,`qualification_alert = false`(v0.5 起关闭对应风险事件)。只上传新证书而未经 SQE 核验,不会解除异常 | +| 证书改动核验(v0.6) | 证书新建,或类型、编号、发证机构、有效期、扫描件被修改(`srm_certificate_change_check`) | 改动人持门户岗位:`verified` 复位为 false、清空核验人,通知该供应商各品类的 SQE(品类都没有 SQE 时通知质量经理)经「核验证书」(`srm_certificate_verify`,SQE / 质量经理;已核验、已过期的不核验)核对原件;改动人是内部人员或系统:录入即核验,`verified = true`、核验人记为改动人 | +| 资质恢复 | 证书或品类资格写入后的记录触发(`srm_supplier_qualification_recalc`) | 先按该供应商全部品类的 `mandatory_cert_types` 标记每张证书的 `is_mandatory`(`srm_certificate_mandatory_mark`:新建证书或改了类型时看这张证书;`srm_grant_mandatory_mark`:新增品类资格时看该供应商的全部证书。只置为必备、不取消,存量导入和手工标记的保留);再重新计算(证书状态、「必备」标志或 `verified` 变化后):同类型里有未过期(有效或即将到期)且已核验的证书,称这一类「顶得住」。有一张过期的必备证书而同类型顶不住时标记 `qualification_alert = true`;每一张过期或未核验的必备证书同类型都顶得住(即每一种必备资质类型都有未过期且已核验的证书)时解除(v0.5 起关闭对应风险事件);两样都不满足时保持原值——门户改了一张有效证书只复位核验,不新标异常,已有的异常要等 SQE 核验后才解除。只上传新证书或把过期证书的有效期改长而未经 SQE 核验,不会解除异常(v0.6) | | 风险事件处理 | 手工登记或系统生成(v0.5) | `high` 级事件通知品类经理并生成处置单草稿(冻结);关闭时填写处理结论 | | 资质风险事件 | `srm_supplier_qualification_recalc`(证书状态或「必备」标志变化后) | 这张证书是必备证书且变为已过期时,新建 `qualification` 类、`source = system`、中风险的风险事件,`certificate` 指回证书;这张证书已有未关闭的事件时不再建。资质异常解除时关闭该供应商未关闭的系统资质事件,写处理结论;手工登记的事件不动 | | 外部风险 | `schedule`:每周一 04:00(客户驱动主题) | flow 按批 `loop` 合格供应商,`http` 节点调用企业信息服务;新出现的经营异常、失信、重大诉讼、股权变更按 `external_ref` 去重后建风险事件(FR-903)。有客户要求时立项 | @@ -492,7 +495,9 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_admission_approval` | record_change:状态→`admission_review` | 准入申请 | v0.1 | | `srm_certificate_expiry_notice` | time_relative:`valid_to` −30 / −15 / −7 天 | 资质证书 | v0.1 | | `srm_certificate_expire_sweep` | schedule:每日 01:00(追赶式) | 资质证书 | v0.1 | -| `srm_supplier_qualification_recalc` | record_change:证书、品类资格写入 | 资质证书 → 供应商(v0.5 起 → 风险事件) | v0.1 | +| `srm_supplier_qualification_recalc` | record_change:证书状态、「必备」标志、`verified` 变化 | 资质证书 → 供应商(v0.5 起 → 风险事件;v0.6 起只认已核验的证书) | v0.1 | +| `srm_certificate_change_check` | record_change:证书新建或改了内容 | 资质证书(门户改动复位核验并通知 SQE,内部录入即核验) | v0.6 | +| `srm_bank_pending_notice` | record_change:银行账户以「待核验」新建 | 银行账户 → 应付会计通知 | v0.6 | | `srm_certificate_mandatory_mark` / `srm_grant_mandatory_mark` | record_change:证书新建或改类型 / 品类资格新增 | 品类 → 资质证书(必备资质标记) | v0.5(门槛 1 补) | | `srm_change_approval` / `srm_change_apply` | record_change:变更单新建 / 子 flow | 变更单 → 供应商、银行账户、联系人 | v0.2 | | `srm_contract_approval` | record_change:状态→`in_review` | 合同 | v0.3 | @@ -532,12 +537,13 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | screen flow | 入口 | 使用者 | 版本 | |---|---|---|---| -| `srm_certificate_verify` 核验证书 | 资质证书 | SQE | v0.1 | +| `srm_certificate_verify` 核验证书 | 资质证书(未核验、未过期) | SQE、质量经理 | v0.6(门户上线前内部录入即核验) | | `srm_primary_contact_set` 设为主联系人 | 供应商联系人 | `srm_supplier_admin` | v0.1 | | `srm_bank_verify` 核验银行账户 | 银行账户 | 应付会计 | v0.2 | | `srm_contract_submit` 提交审批 | 合同(草稿) | 品类经理、采购主管 | v0.3 | | `srm_contract_activate` 确认生效 | 合同(签署中) | 采购员、采购主管、品类经理 | v0.3 | -| `srm_change_submit` 提交变更申请 | 门户:公司资料(v0.6 前内部在供应商档案下直接新建变更单) | 门户管理员 | v0.6 | +| `srm_change_submit` 提交变更申请 | 门户:供应商档案(内部员工照旧在变更单列表直接新建) | 门户管理员(按门户名册检查) | v0.6 | +| `srm_application_resubmit` 补充完成,重新提交 | 门户:准入申请(退回补充) | 申请所属供应商的门户用户 | v0.6 | | `srm_audit_finding_to_scar` 转整改单 | 供应商审核(已完成) | SQE、质量经理 | v0.4 | | `srm_disposition_submit` 提交审批 / `srm_disposition_cancel` 作废 | 处置单(草稿) | 品类经理、采购主管 | v0.5 | | `srm_score_submit` 提交评分 | 考评单(人工评分) | 评分人 | v0.5 | diff --git a/docs/design/04-security-and-portal.md b/docs/design/04-security-and-portal.md index 7d7ee52..58e3902 100644 --- a/docs/design/04-security-and-portal.md +++ b/docs/design/04-security-and-portal.md @@ -175,9 +175,9 @@ flowchart LR | 对象 | 门户读 | 门户写 | 说明 | |---|---|---|---| | 供应商 | 本公司 | 只能改资料字段(§4.2);名称、地址、银行账户等关键信息走变更单 | RLS 在供应商自身的 `id` 上;授予编辑权是为了让门户能维护证书等子记录;内部评估类字段(关联方、优选、风险事件数等)门户不可见 | -| 联系人、证书、银行账户、品类资格 | 跟随供应商 | 证书可增(v0.6 U2;修改随 U4 的「核验证书」一起放开);联系人和银行账户仅门户管理员可增,门户管理员可改联系人手机 | `controlled_by_parent`;联系人的 `portal_user`、`active` 门户只读 | -| 准入申请 | 本公司 | 「退回补充」状态时可改(U4) | `private`:读共享规则 + RLS | -| 变更单 | 本公司 | 只经「提交变更申请」screen flow(门户管理员,U4) | `private`:读共享规则 + RLS | +| 联系人、证书、银行账户、品类资格 | 跟随供应商 | 证书可增可改(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(门户管理员,U4);被退回修改的单由门户管理员改完重新提交(写策略:本公司且 `approval_status == 'returned'`) | `private`:读共享规则 + RLS;改只限自己发起(拥有)的单——`external` 成员对 `private` 对象只能改自己的行,写策略再限定本公司、被退回 | | 寻源邀请 | 本公司 | 可改 `status`(接受 / 拒绝)与拒绝原因 | 邀请上有寻源单的快照,门户不读寻源单本身 | | 报价单 | 本公司 | `status in ['draft','submitted'] && bid_opened == false` 的本公司报价 | 写策略 `using` 限定可改哪些行,`check` 限定改完仍属本公司;上一轮的 `superseded` 报价不可改 | | 报价明细 | 跟随报价单 | 跟随报价单;只能改单价、交期、税率、起订量、备注 | 截止后由校验规则锁定(BR-06);金额、中标字段、快照字段只读 | @@ -345,14 +345,16 @@ export const PortalSupplier = definePermissionSet({ | 越过回复范围 | 用 A 的身份改本公司整改单的非回复字段(SQE 意见、索赔、问题描述、逾期标志)、把状态改成效果验证 / 关闭 / 撤销或退回、改轮到 SQE 的整改单 | 拒绝或写入被丢弃,值不变(v0.6 U5) | | 越权 screen flow | 用 A 的身份对 B 的整改单「确认索赔」、对 B 的考评单「申诉」,分别经记录页按钮和直接触发 flow;联系人停用后对本公司考评单申诉 | 按钮答「不存在」,直接触发被 flow 的名册检查拒绝,记录不变;恢复在职后本公司的申诉照常(v0.6 U5) | | 开标后改价 | 开标后用中标供应商的身份修改本公司报价明细的单价或中标字段 | 拒绝(v0.7 寻源上线时加入) | -| 自解资质异常 | 用门户身份把本公司过期证书的 `status` 改为有效,或修改有效期;新建证书时自带「已核验」 | 全部拒绝(v0.6 U2 门户只能新建证书;U4 放开修改时,改有效期重置 `verified`,资质异常不解除) | +| 自解资质异常 | 用门户身份把本公司过期证书的 `status` 改为有效、新建证书时自带「已核验」、写 `verified`;修改有效期或类型 | 改状态、自带核验被拒绝或写入被丢弃;修改内容放行但 `verified` 复位,资质异常在 SQE 核验前不解除(U4) | +| 自助写路径 | 用 A 的身份改 B 的证书、联系人、变更单,在 B 名下建银行账户;伪造 `recordId` 启动「提交变更申请」「补充完成,重新提交」;门户身份启动「核验证书」 | 全部拒绝,B 的记录不变(U4) | | 空名册 | 持门户岗位但不在任何在职联系人上的用户;联系人停用的用户;在名册上但没有门户岗位的用户;没开通门户的供应商的单据 | 每个对象零行 | | 回收 | 停用 A 用户的联系人、撤销其门户岗位任命、执行 A 的淘汰处置后立即访问 | 看不到任何供应商数据 | | 用户目录 | 用 A 的身份在界面和 REST API 上查询用户列表 | 看不到 B 的门户账号,也看不到内部员工的完整名单(§3.6) | | 多通道 | 以上用例分别经门户界面、REST API、导出、AI 助手执行 | 结果一致 | | 元数据守卫 | 检查门户权限集与共享规则 | 门户能读的每个对象都有读成员键的 RLS(或是子对象);只授门户岗位的共享规则只有读权限且对象有这条 RLS;门户权限集不设 `isDefault`、不授删除 / 全量读写 / 导出;每个带供应商引用的对象要么授给门户,要么写明不授的理由 | -自动化:`test/portal-isolation.test.ts` 覆盖除「开标后改价」以外的全部用例,在 CI 里跑,不得删除或跳过(AGENTS.md +自动化:`test/portal-isolation.test.ts` 覆盖除「开标后改价」以外的全部用例(门户自助的完整场景另见 +`test/portal-self-service.test.ts`),在 CI 里跑,不得删除或跳过(AGENTS.md 红线 2);门户界面走的就是 REST API,导出经 REST 验证被拒;AI 助手以提问者身份查询、走同一套 RLS,v0.6 出口测试时 在托管环境人工复核一次(07 §6.2 V6)。 @@ -386,7 +388,7 @@ export const PortalSupplier = definePermissionSet({ | 字段 | 谁可读 | 谁可改 | 原因 | |---|---|---|---| | `srm_supplier_bank_account.account_no` | 完整值:应付会计(`srm_finance`)、`srm_supplier_admin`、本公司门户管理员(v0.6);其他内部岗位读到只留后 4 位的脱敏值。实现为字段 `maskingRule: 'bank_account'` + `requiredPermissions: ['srm_view_bank_account_no']`,能力由权限集 `systemPermissions` 授予;没有任何权限集给银行账户 `allowExport` | 新建时(`srm_supplier_admin`;v0.6 起门户管理员)、经变更单 flow;`pending_verify` 期间与有效后都只读 | 资金安全(BR-20) | -| `srm_supplier_contact.mobile` | 内部员工、本公司门户用户 | 本公司门户管理员 | 个人信息最小化 | +| `srm_supplier_contact.mobile` | 内部员工、本公司门户用户 | 本公司门户管理员(v0.6 起门户管理员可增改本公司联系人) | 个人信息最小化 | | `srm_sourcing_event.budget_amount` | 寻源、采购总监、内控 | 寻源 | 门户不可见(门户本就不读寻源单) | | `srm_sourcing_item.target_price` / `last_price` | 同上 | 寻源 | 同上 | | `srm_quotation.technical_score` / `commercial_score` / `total_score` / `rank` | 内部评标相关角色(开标后) | 寻源、评标 flow | 门户不可见,防止供应商据此调价 | @@ -401,7 +403,7 @@ export const PortalSupplier = definePermissionSet({ | `srm_supplier_contact.portal_user` | 内部、本公司门户 | 仅 flow(字段级只读) | 门户名册:写入即授出这家供应商的门户数据(ADR-0010) | | `srm_supplier_contact.active` | 内部、本公司门户 | 内部(`srm_supplier_admin`);门户只读 | 名册过滤条件,门户不能恢复被停用的账号 | | `srm_supplier` 的 `name`、`country`、`credit_code`、`registration_no`、`address`、`legal_representative`、`registered_capital`、`status`、`grade`、`qualification_alert`、`erp_vendor_code` | 内部、本公司门户 | 内部(关键信息变更走变更单) | 门户只读;门户可直接改的只有 `short_name`、`website`、`supplier_type`、`taxpayer_type`、`established_date` 等资料字段 | -| `srm_supplier_certificate.status` / `is_mandatory` / `verified` | 内部、本公司门户 | 仅 flow 与「核验证书」screen flow | 防止供应商自行解除资质异常 | +| `srm_supplier_certificate.status` / `is_mandatory` / `verified` / `verified_by` | 内部、本公司门户 | 仅 flow 与「核验证书」screen flow(`verified` 为对象级只读,内部也不能直接改) | 防止供应商自行解除资质异常 | | `srm_quotation_line` 的 `amount`、`is_awarded`、`award_quantity`、`award_ratio` 及快照字段 | 所有可读报价的人 | 仅 flow | 防止供应商改金额或自报中标 | | `srm_purchase_order` 全部字段(预留) | 内部、本公司门户 | 内部与集成 | 门户对订单头有编辑权只为写订单行,字段全部只读 | diff --git a/docs/design/05-experience-and-analytics.md b/docs/design/05-experience-and-analytics.md index 8ac8a79..78e13e5 100644 --- a/docs/design/05-experience-and-analytics.md +++ b/docs/design/05-experience-and-analytics.md @@ -2,11 +2,12 @@ > 所属:[SRM 设计总览](./README.md) · 上一篇:[04 权限与门户](./04-security-and-portal.md) · 下一篇:[06 集成与 AI](./06-integration-and-ai.md) -## 1. 两个应用外壳 +## 1. 两个外壳,一个应用 -同一份数据,两个应用:内部员工用「采购协同工作台」,供应商用「供应商门户」。门户 -应用只对持有门户权限集的用户可见;即便有人拿到内部应用的地址,RLS 仍然只放行他 -本公司的数据(应用可见性是体验,RLS 才是边界)。 +同一份数据,两个外壳:内部员工用「采购协同工作台」,供应商用「供应商门户」。平台规定一个应用包只定义一个应用 +(objectstack ADR-0019 D3),所以门户实现为应用 `srm_workbench` 里的「供应商门户」导航组,只对持门户能力 +`srm_portal_access`(门户权限集授予)的用户显示(v0.6);门户用户即便点开内部菜单,RLS 仍然只放行他本公司的数据 +(导航可见性是体验,RLS 才是边界)。 ### 1.1 采购协同工作台 `srm_workbench` @@ -39,12 +40,12 @@ └── 设置 品类 · 物料 · 工厂 ``` -### 1.2 供应商门户 `srm_portal`(v0.6) +### 1.2 供应商门户(v0.6,`srm_workbench` 的「供应商门户」导航组) ``` 供应商门户 -├── 首页 我的待办(见 01 §5.10)· 最新绩效 · 即将到期的证书 -├── 公司资料 本公司档案 · 联系人 · 资质证书 · 银行账户 · 变更申请 · 准入申请进度 +├── 首页 我的待办(见 01 §5.10):待我处理的整改单 · 即将到期的证书 · 准入申请进度 · 最新绩效 +├── 公司资料 本公司档案(「提交变更申请」)· 联系人 · 资质证书 · 银行账户 · 变更申请 · 准入申请(「补充完成,重新提交」) ├── 质量 整改单 · 交付结果 ├── 绩效 历次考评结果 ├── 询价与报价 (v0.7)收到的邀请 · 我的报价(草稿 / 已提交 / 历史) diff --git a/docs/design/07-delivery-plan.md b/docs/design/07-delivery-plan.md index a55f561..085a188 100644 --- a/docs/design/07-delivery-plan.md +++ b/docs/design/07-delivery-plan.md @@ -121,7 +121,7 @@ src/core/ │ ├── positions.ts # 内部岗位 │ ├── permission-sets.ts # 内部权限集 │ └── portal.ts # 门户权限集与 RLS(见 04 §3.4) -├── apps/ # srm_workbench、srm_portal 两个应用外壳(导航按分组) +├── apps/ # srm_workbench 一个应用(ADR-0019 D3),内部工作台与「供应商门户」导航组(导航按分组) ├── views/ # 列表、看板、日历、甘特 ├── reports/ # 比价矩阵等(v0.7) ├── dashboards/ @@ -135,6 +135,7 @@ test/ # 仓库根目录 ├── approval-matrix.test.ts # 03 §12 每一行(v0.1 起) ├── portal-platform.test.ts # 门户平台实测 V1–V5(§6.2,v0.6 U1) ├── portal-isolation.test.ts # 04 §3.7 的交叉越权用例(v0.6) +├── portal-self-service.test.ts # 门户自助维护:补证书与核验、变更申请、退回补充(v0.6 U4) ├── portal-response.test.ts # 门户整改回复、索赔确认、绩效查看与申诉(v0.6 U5) ├── sealed-bid.test.ts # 封标与截止锁价(v0.7) └── flows/*.test.ts # 每条 flow 的正向与异常路径 @@ -272,6 +273,7 @@ test/ # 仓库根目录 | PF15 | objectstack#14500 flow 没有汇总能力:CEL 不能对列表求和,`get_record` 只返回行(已关的「汇总 / 分组节点」卡,hotsrm 的用法作为数据点记在评论里) | 考评生成的 Σ不良数、Σ实收数、启用指标权重合计 | `loop` + `assignment` 逐行累加;200 家 × 30 行约 8.6 秒,性能可接受 | 平台不纳入计划 | | 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 新提 | 其他代码和文档里指向平台的临时做法,2026-10-26 核对结果: diff --git a/docs/design/README.md b/docs/design/README.md index 09b5d79..bf92975 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -15,8 +15,8 @@ **形态**:一个用 TypeScript 元数据定义、编译成单一制品发布的应用包 `srm`(命名空间 `srm`),核心 **28 个业务对象**,按 v0.1–v0.7 七个版本交付([07 §1](./07-delivery-plan.md#1-分期路线图)); -两个应用外壳——内部的「采购协同工作台」(v0.1 起)和面向外部的「供应商门户」(v0.6), -两者读写同一份数据,靠权限区分各自能看到什么。现阶段只做核心包,扩展包保留为设计(ADR-0009)。 +两个外壳——内部的「采购协同工作台」(v0.1 起)和面向外部的「供应商门户」(v0.6;平台规定一个应用包只有一个应用, +门户是同一应用里只对门户用户显示的导航组,05 §1),两者读写同一份数据,靠权限区分各自能看到什么。现阶段只做核心包,扩展包保留为设计(ADR-0009)。 **六个关键设计决定**: @@ -53,7 +53,7 @@ | [02-domain-model.md](./02-domain-model.md) | 领域模型、对象与字段、关系图、状态机、编码规则 | 产品、开发 | | [03-processes-and-automation.md](./03-processes-and-automation.md) | 10 条端到端流程、审批矩阵、自动化清单、定时任务可靠性设计 | 业务方、开发 | | [04-security-and-portal.md](./04-security-and-portal.md) | 角色与权限矩阵、组织级默认可见性(OWD)、供应商门户隔离、封标、字段级权限、职责分离 | 安全、实施、开发 | -| [05-experience-and-analytics.md](./05-experience-and-analytics.md) | 两个应用的导航、视图、表单、仪表盘与指标口径、通知 | 产品、设计 | +| [05-experience-and-analytics.md](./05-experience-and-analytics.md) | 两个外壳(工作台与门户)的导航、视图、表单、仪表盘与指标口径、通知 | 产品、设计 | | [06-integration-and-ai.md](./06-integration-and-ai.md) | 预留的 ERP 集成点与字段映射(现阶段不实现)、导入方式、外部服务、AI 助手用例与治理 | 开发、架构 | | [07-delivery-plan.md](./07-delivery-plan.md) | 版本路线图与门槛、验收、应用包结构、风险、待决策事项、平台依赖与验证结果 | 项目经理、全体 | | [../packs/](../packs/README.md) | 行业扩展包(设计保留,未排期):大宗物料包、危化品包、国企采购合规包 | 产品、实施 | diff --git a/src/core/actions/index.ts b/src/core/actions/index.ts index f8dfc7c..dd7783e 100644 --- a/src/core/actions/index.ts +++ b/src/core/actions/index.ts @@ -5,3 +5,4 @@ export { AuditFindingToScarAction } from './audit.actions.js'; export { DispositionSubmitAction, DispositionCancelAction } from './disposition.actions.js'; export { ScoreSubmitAction, PerformanceVoidAction, AppealSubmitAction, AppealResolveAction, ScoreReassignAction } from './performance.actions.js'; export { ClaimRespondAction } from './quality-issue.actions.js'; +export { ChangeSubmitAction, ApplicationResubmitAction, CertificateVerifyAction } from './portal.actions.js'; diff --git a/src/core/actions/portal.actions.ts b/src/core/actions/portal.actions.ts new file mode 100644 index 0000000..db66c21 --- /dev/null +++ b/src/core/actions/portal.actions.ts @@ -0,0 +1,46 @@ +import { P } from '@objectstack/spec'; +import { defineAction } from '@objectstack/spec/ui'; + +// v0.6 门户自助维护的按钮(03 §3、§5、§11)。requiredPermissions 只决定按钮显示给谁,谁能操作仍由 flow 自己检查 +// (门户按钮查门户名册,核验按钮查岗位)。 + +// 供应商档案上的「提交变更申请」:本公司门户管理员提交关键信息变更(srm_change_submit) +export const ChangeSubmitAction = defineAction({ + name: 'srm_change_submit', + label: '提交变更申请', + objectName: 'srm_supplier', + icon: 'file-pen', + type: 'flow', + target: 'srm_change_submit', + locations: ['record_header'], + requiredPermissions: ['srm_portal_manage'], + refreshAfter: true, +}); + +// 准入申请上的「补充完成,重新提交」:退回补充后由供应商门户提交(srm_application_resubmit) +export const ApplicationResubmitAction = defineAction({ + name: 'srm_application_resubmit', + label: '补充完成,重新提交', + objectName: 'srm_supplier_application', + icon: 'send', + type: 'flow', + target: 'srm_application_resubmit', + locations: ['record_header'], + visible: P`record.status == 'returned'`, + requiredPermissions: ['srm_portal_access'], + refreshAfter: true, +}); + +// 证书上的「核验证书」:SQE、质量经理核对门户上传或修改的证书(srm_certificate_verify) +export const CertificateVerifyAction = defineAction({ + name: 'srm_certificate_verify', + label: '核验证书', + objectName: 'srm_supplier_certificate', + icon: 'shield-check', + type: 'flow', + target: 'srm_certificate_verify', + locations: ['record_header', 'list_item'], + visible: P`record.verified != true && record.status != 'expired'`, + requiredPermissions: ['srm_verify_certificate'], + refreshAfter: true, +}); diff --git a/src/core/apps/workbench.app.ts b/src/core/apps/workbench.app.ts index 9725731..2ed5c0a 100644 --- a/src/core/apps/workbench.app.ts +++ b/src/core/apps/workbench.app.ts @@ -1,11 +1,72 @@ import { App } from '@objectstack/spec/ui'; -// 内部工作台。导航按分组组织,扩展包往分组里挂菜单(ADR-0002)。 +// SRM 应用:内部工作台与供应商门户(05 §1)。导航按分组组织,扩展包往分组里挂菜单(ADR-0002)。 +// 平台规定一个应用包只有一个应用(objectstack ADR-0019 D3),所以门户不是第二个应用,而是本应用里只对持门户能力 +// srm_portal_access 的用户显示的一组导航(门户权限集授予)。这只是体验:门户用户能看到哪些行由门户 RLS 决定 +// (ADR-0010),点进内部菜单也只看到本公司的数据。 export const Workbench = App.create({ name: 'srm_workbench', label: 'SRM 工作台', icon: 'handshake', navigation: [ + // 供应商门户(05 §1.2,v0.6):首页是一组待办列表(01 §5.10),点进去就是要处理的那条记录;询价与报价随 v0.7 加入 + { + id: 'group_portal', + type: 'group', + label: '供应商门户', + icon: 'store', + expanded: true, + requiredPermissions: ['srm_portal_access'], + children: [ + { + id: 'portal_home', + type: 'group', + label: '首页', + icon: 'house', + expanded: true, + children: [ + { id: 'portal_nav_todo_issues', type: 'object', objectName: 'srm_quality_issue', viewName: 'portal_todo', label: '待我处理的整改单', icon: 'clipboard-x' }, + { id: 'portal_nav_todo_certificates', type: 'object', objectName: 'srm_supplier_certificate', viewName: 'expiring', label: '即将到期的证书', icon: 'badge-alert' }, + { id: 'portal_nav_todo_applications', type: 'object', objectName: 'srm_supplier_application', viewName: 'in_progress', label: '准入申请进度', icon: 'clipboard-check' }, + { id: 'portal_nav_latest_reviews', type: 'object', objectName: 'srm_performance_review', label: '最新绩效', icon: 'award' }, + ], + }, + { + id: 'portal_company', + type: 'group', + label: '公司资料', + icon: 'building', + expanded: true, + children: [ + { id: 'portal_nav_profile', type: 'object', objectName: 'srm_supplier', label: '本公司档案', icon: 'factory' }, + { id: 'portal_nav_contacts', type: 'object', objectName: 'srm_supplier_contact', label: '联系人', icon: 'contact' }, + { id: 'portal_nav_certificates', type: 'object', objectName: 'srm_supplier_certificate', label: '资质证书', icon: 'badge-check' }, + { id: 'portal_nav_bank_accounts', type: 'object', objectName: 'srm_supplier_bank_account', label: '银行账户', icon: 'landmark' }, + { id: 'portal_nav_change_requests', type: 'object', objectName: 'srm_supplier_change_request', label: '变更申请', icon: 'file-pen' }, + { id: 'portal_nav_applications', type: 'object', objectName: 'srm_supplier_application', label: '准入申请', icon: 'clipboard-check' }, + ], + }, + { + id: 'portal_quality', + type: 'group', + label: '质量', + icon: 'shield-check', + children: [ + { id: 'portal_nav_quality_issues', type: 'object', objectName: 'srm_quality_issue', label: '整改单', icon: 'clipboard-x' }, + { id: 'portal_nav_delivery_records', type: 'object', objectName: 'srm_delivery_record', label: '交付结果', icon: 'truck' }, + ], + }, + { + id: 'portal_performance', + type: 'group', + label: '绩效', + icon: 'gauge', + children: [ + { id: 'portal_nav_reviews', type: 'object', objectName: 'srm_performance_review', label: '历次考评结果', icon: 'award' }, + ], + }, + ], + }, { id: 'group_supplier', type: 'group', diff --git a/src/core/flows/admission/resubmit.flow.ts b/src/core/flows/admission/resubmit.flow.ts new file mode 100644 index 0000000..aa982cc --- /dev/null +++ b/src/core/flows/admission/resubmit.flow.ts @@ -0,0 +1,117 @@ +import { cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// 门户「补充完成,重新提交」(03 §3 第 4 步,04 §3.2,v0.6):品类经理把准入申请「退回补充」后,供应商在门户补齐资料、 +// 证书和银行账户(这些都在供应商档案下,门户可以直接维护),再在申请上写明补了什么、重新提交:申请回到「资料审核」, +// 通知初审人。申请本身的品类、初审人等由内部维护,门户不直接改。 +// +// 以系统身份运行:门户对准入申请只有读共享(ADR-0010 两把锁)。flow 进门判断启动人持门户岗位、并且是这张申请所属供应商 +// 在职联系人上登记的门户账号(与成员键同一份名册),伪造别家申请的 recordId 时名册对不上,直接结束(直接触发不检查记录可读,objectstack#29050;谁可以启动见 objectstack#25481)。 +const uid = `current_user != null ? current_user.id : ''`; +const allowed = `current_user != null && 'srm_portal_user' in current_user.positions && application != null && membership != null`; +const returned = `application.status == 'returned'`; +const c = (source: string) => ({ dialect: 'cel' as const, source }); + +export const ApplicationResubmitFlow: Automation.Flow = { + name: 'srm_application_resubmit', + label: '补充完成,重新提交', + type: 'screen', + status: 'active', + runAs: 'system', + successMessage: '已重新提交,等待资料审核', + + variables: [{ name: 'recordId', type: 'text', isInput: true, isOutput: false }], + + nodes: [ + { id: 'start', type: 'start', label: '开始' }, + { + id: 'load', + type: 'get_record', + label: '读取准入申请', + config: { objectName: 'srm_supplier_application', filter: { id: cel`recordId` }, outputVariable: 'application' }, + }, + { + id: 'load_membership', + type: 'get_record', + label: '启动人是这家供应商的门户账号?', + config: { + objectName: 'srm_supplier_contact', + filter: { supplier: c(`application != null ? application.supplier : ''`), portal_user: c(uid), active: true }, + outputVariable: 'membership', + }, + }, + { id: 'gate', type: 'decision', label: '可以重新提交?' }, + { + id: 'refuse_position', + type: 'end', + label: '不是本公司门户用户', + config: { outcome: 'refused', message: '只有申请所属供应商的门户用户可以重新提交准入申请' }, + }, + { + id: 'refuse_status', + type: 'end', + label: '不是退回补充', + config: { outcome: 'refused', message: '只有「退回补充」的准入申请需要重新提交' }, + }, + { + id: 'confirm', + type: 'screen', + label: '说明补充内容', + config: { + title: '重新提交准入申请:{{ application.application_no }}', + description: '请先在「公司资料」里补齐资料、证书和银行账户,再写明补充了哪些内容,提交后回到资料审核。', + fields: [{ name: 'note', label: '补充说明', type: 'textarea', required: true }], + }, + }, + { + id: 'resubmit', + type: 'update_record', + label: '回到资料审核', + config: { + objectName: 'srm_supplier_application', + filter: { id: cel`application.id` }, + fields: { status: 'doc_review', supplement_note: cel`note` }, + }, + }, + { + id: 'notify_guard', + type: 'try_catch', + label: '通知', + config: { + try: { + nodes: [ + { + id: 'notify_reviewer', + type: 'notify', + label: '通知初审人(没有初审人时通知申请的新建人)', + config: { + recipients: c( + `has(application.reviewer) && application.reviewer != null ? [application.reviewer] : [application.created_by]`, + ), + title: '准入申请 {{ application.application_no }} 已补充,待资料审核', + message: '供应商在门户补充了资料并重新提交,补充说明:{{ note }}', + severity: 'info', + sourceObject: 'srm_supplier_application', + sourceId: cel`application.id`, + }, + }, + ], + }, + catch: { nodes: [{ id: 'skip_notify', type: 'assignment', label: '跳过通知' }] }, + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e0', source: 'start', target: 'load', type: 'default' }, + { id: 'e1', source: 'load', target: 'load_membership', type: 'default' }, + { id: 'e2', source: 'load_membership', target: 'gate', type: 'default' }, + { id: 'e3', source: 'gate', target: 'refuse_position', type: 'conditional', condition: c(`!(${allowed})`), label: '不可以' }, + { id: 'e4', source: 'gate', target: 'refuse_status', type: 'conditional', condition: c(`${allowed} && !(${returned})`), label: '不是退回补充' }, + { id: 'e5', source: 'gate', target: 'confirm', type: 'conditional', condition: c(`${allowed} && ${returned}`), label: '可以' }, + { id: 'e6', source: 'confirm', target: 'resubmit', type: 'default' }, + { id: 'e7', source: 'resubmit', target: 'notify_guard', type: 'default' }, + { id: 'e8', source: 'notify_guard', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/bank-account/bank-pending-notice.flow.ts b/src/core/flows/bank-account/bank-pending-notice.flow.ts new file mode 100644 index 0000000..18a971b --- /dev/null +++ b/src/core/flows/bank-account/bank-pending-notice.flow.ts @@ -0,0 +1,72 @@ +import { P, cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// 新建银行账户待核验通知(03 §5,v0.6):v0.6 起门户管理员也能在门户新建银行账户,应付会计不再总是事先知道, +// 所以账户以「待核验」新建时通知全部应付会计,到账户上点「核验」(srm_bank_verify,双人原则照旧)。 +// 通知里不写账号:通知没有脱敏规则(BR-20)。 +export const BankPendingNoticeFlow: Automation.Flow = { + name: 'srm_bank_pending_notice', + label: '银行账户待核验通知', + type: 'record_change', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: '新建待核验的银行账户', + config: { + objectName: 'srm_supplier_bank_account', + triggerType: 'record-after-create', + condition: P`record.status == 'pending_verify'`, + }, + }, + { + id: 'load_supplier', + type: 'get_record', + label: '读取供应商', + config: { objectName: 'srm_supplier', filter: { id: cel`record.supplier` }, outputVariable: 'supplier' }, + }, + { + id: 'load_accountants', + type: 'get_record', + label: '读取应付会计', + config: { objectName: 'sys_user_position', filter: { position: 'srm_ap_accountant' }, limit: 50, outputVariable: 'accountants' }, + }, + { + id: 'notify_guard', + type: 'try_catch', + label: '通知', + config: { + try: { + nodes: [ + { + id: 'notify_accountants', + type: 'notify', + label: '通知应付会计核验', + config: { + recipients: cel`accountants.map(p, p.user_id)`, + title: '{{ supplier.name }} 新建了银行账户,待核验', + message: '开户行:{{ record.bank_name }} {{ record.branch_name }}。请按银行账户核实方式确认账户属实后,在账户上点「核验」;新建人不能核验自己建的账户。', + severity: 'info', + sourceObject: 'srm_supplier_bank_account', + sourceId: cel`record.id`, + }, + }, + ], + }, + // 环境里还没有任命应付会计时不影响新建 + catch: { nodes: [{ id: 'skip_notify', type: 'assignment', label: '跳过通知' }] }, + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'load_supplier', type: 'default' }, + { id: 'e2', source: 'load_supplier', target: 'load_accountants', type: 'default' }, + { id: 'e3', source: 'load_accountants', target: 'notify_guard', type: 'default' }, + { id: 'e4', source: 'notify_guard', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/change-request/change-submit.flow.ts b/src/core/flows/change-request/change-submit.flow.ts new file mode 100644 index 0000000..7367416 --- /dev/null +++ b/src/core/flows/change-request/change-submit.flow.ts @@ -0,0 +1,212 @@ +import { cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// 门户「提交变更申请」(03 §5,04 §3.2,v0.6):本公司门户管理员在供应商档案上提交关键信息变更(基本信息、地址、 +// 银行账户、联系人),flow 代为新建变更单;新建即触发 srm_change_approval,审批与写回沿用 v0.2,发起人(created_by)是 +// 门户管理员本人。内部员工照旧在变更单列表直接新建。 +// +// 以系统身份运行:门户对变更单只有读共享(ADR-0010 两把锁),新建要靠 flow。所以 flow 进门自己判断「是不是这家供应商的 +// 门户管理员」——持 srm_portal_admin 岗位,并且是这家供应商在职联系人上登记的门户账号(与成员键同一份名册)。供应商取自 +// 启动记录,但不信任它:伪造别家的 recordId 时名册对不上,直接结束。银行账户、联系人的归属由变更单上的校验规则再把一道关。 +// 临时做法:直接触发 flow(REST trigger)不像按钮那样先按启动人的权限读一遍 recordId(objectstack#29050),系统身份的 flow +// 会读到别家的记录,所以名册检查留在 flow 里,平台补上后再评估。谁可以启动另见 objectstack#25481。 +const uid = `current_user != null ? current_user.id : ''`; +const allowed = `current_user != null && 'srm_portal_admin' in current_user.positions && supplier != null && membership != null`; +const c = (source: string) => ({ dialect: 'cel' as const, source }); +// 屏幕上没填的字段不会绑定成变量,只能用 has(vars.x) 判断(spec FlowVariableSchema 的说明) +const opt = (name: string) => c(`has(vars.${name}) && vars.${name} != null && vars.${name} != '' ? vars.${name} : null`); +const typeIs = (t: string) => `change_type == '${t}'`; + +const common = { supplier: cel`supplier.id`, reason: cel`reason`, status: 'in_review' }; + +export const ChangeSubmitFlow: Automation.Flow = { + name: 'srm_change_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_supplier', + type: 'get_record', + label: '读取供应商', + config: { objectName: 'srm_supplier', filter: { id: cel`recordId` }, outputVariable: 'supplier' }, + }, + { + id: 'load_membership', + type: 'get_record', + label: '启动人是这家供应商的门户账号?', + config: { + objectName: 'srm_supplier_contact', + filter: { supplier: cel`recordId`, portal_user: c(uid), active: true }, + outputVariable: 'membership', + }, + }, + { id: 'gate', type: 'decision', label: '可以提交?' }, + { + id: 'refuse_position', + type: 'end', + label: '不是本公司门户管理员', + config: { outcome: 'refused', message: '只有本公司的门户管理员可以在门户提交变更申请' }, + }, + { + id: 'fill', + type: 'screen', + label: '填写变更', + config: { + title: '提交变更申请:{{ supplier.name }}', + description: '只填要改的项。提交后由采购审批,银行账户变更还要财务复核;审批通过后系统写回档案。', + fields: [ + { + name: 'change_type', + label: '变更类型', + type: 'select', + required: true, + options: [ + { label: '基本信息', value: 'basic' }, + { label: '注册地址', value: 'address' }, + { label: '银行账户', value: 'bank_account' }, + { label: '联系人', value: 'contact' }, + ], + }, + { name: 'reason', label: '变更原因', type: 'textarea', required: true }, + // 基本信息:至少填一项(变更单的校验规则 basic_change_not_empty) + { name: 'new_name', label: '新公司全称', type: 'text', visibleWhen: typeIs('basic') }, + { name: 'new_credit_code', label: '新统一社会信用代码', type: 'text', visibleWhen: typeIs('basic') }, + { name: 'new_registration_no', label: '新注册号 / 税号', type: 'text', visibleWhen: typeIs('basic') }, + { name: 'new_legal_representative', label: '新法定代表人', type: 'text', visibleWhen: typeIs('basic') }, + { name: 'new_registered_capital', label: '新注册资本', type: 'number', min: 0, visibleWhen: typeIs('basic') }, + // 注册地址:国家 / 地区沿用档案(改国家是另一家主体) + { name: 'address_street', label: '新地址(街道门牌)', type: 'text', required: true, visibleWhen: typeIs('address') }, + { name: 'address_city', label: '城市', type: 'text', required: true, visibleWhen: typeIs('address') }, + { name: 'address_state', label: '省 / 州', type: 'text', visibleWhen: typeIs('address') }, + { name: 'address_postal_code', label: '邮政编码', type: 'text', visibleWhen: typeIs('address') }, + // 银行账户:用一个新账户替换一个有效账户;户名须与公司全称一致(中国供应商) + { name: 'bank_account', label: '要替换的银行账户', type: 'lookup', reference: 'srm_supplier_bank_account', required: true, visibleWhen: typeIs('bank_account') }, + { name: 'new_bank_name', label: '新开户行', type: 'text', required: true, visibleWhen: typeIs('bank_account') }, + { name: 'new_branch_name', label: '新支行', type: 'text', visibleWhen: typeIs('bank_account') }, + { name: 'new_account_name', label: '新账户名称', type: 'text', required: true, defaultValue: cel`supplier.name`, visibleWhen: typeIs('bank_account') }, + { name: 'new_account_no', label: '新银行账号', type: 'text', required: true, visibleWhen: typeIs('bank_account') }, + { name: 'new_swift_code', label: '新 SWIFT 代码', type: 'text', visibleWhen: typeIs('bank_account') }, + { + name: 'new_currency', + label: '新账户币种', + type: 'select', + defaultValue: 'cny', + visibleWhen: typeIs('bank_account'), + options: [ + { label: '人民币', value: 'cny' }, + { label: '美元', value: 'usd' }, + { label: '欧元', value: 'eur' }, + { label: '日元', value: 'jpy' }, + { label: '港币', value: 'hkd' }, + ], + }, + // 联系人:改姓名、手机、邮箱 + { name: 'contact', label: '联系人', type: 'lookup', reference: 'srm_supplier_contact', required: true, visibleWhen: typeIs('contact') }, + { name: 'new_contact_name', label: '新姓名', type: 'text', visibleWhen: typeIs('contact') }, + { name: 'new_mobile', label: '新手机', type: 'text', visibleWhen: typeIs('contact') }, + { name: 'new_email', label: '新邮箱', type: 'text', visibleWhen: typeIs('contact') }, + ], + }, + }, + { id: 'by_type', type: 'decision', label: '按变更类型建单' }, + { + id: 'create_basic', + type: 'create_record', + label: '新建基本信息变更单', + config: { + objectName: 'srm_supplier_change_request', + fields: { + ...common, + change_type: 'basic', + new_name: opt('new_name'), + new_credit_code: opt('new_credit_code'), + new_registration_no: opt('new_registration_no'), + new_legal_representative: opt('new_legal_representative'), + new_registered_capital: c(`has(vars.new_registered_capital) && vars.new_registered_capital != null ? vars.new_registered_capital : null`), + }, + outputVariable: 'change', + }, + }, + { + id: 'create_address', + type: 'create_record', + label: '新建地址变更单', + config: { + objectName: 'srm_supplier_change_request', + fields: { + ...common, + change_type: 'address', + new_address: c( + `{'street': address_street, 'city': address_city` + + `, 'state': has(vars.address_state) && vars.address_state != null ? vars.address_state : ''` + + `, 'postalCode': has(vars.address_postal_code) && vars.address_postal_code != null ? vars.address_postal_code : ''` + + `, 'country': has(supplier.address) && supplier.address != null && has(supplier.address.country) ? supplier.address.country : 'CN'}`, + ), + }, + outputVariable: 'change', + }, + }, + { + id: 'create_bank', + type: 'create_record', + label: '新建银行账户变更单', + config: { + objectName: 'srm_supplier_change_request', + fields: { + ...common, + change_type: 'bank_account', + bank_account: cel`bank_account`, + new_bank_name: cel`new_bank_name`, + new_branch_name: opt('new_branch_name'), + new_account_name: cel`new_account_name`, + new_account_no: cel`new_account_no`, + new_swift_code: opt('new_swift_code'), + new_currency: c(`has(vars.new_currency) && vars.new_currency != null ? vars.new_currency : 'cny'`), + }, + outputVariable: 'change', + }, + }, + { + id: 'create_contact', + type: 'create_record', + label: '新建联系人变更单', + config: { + objectName: 'srm_supplier_change_request', + fields: { + ...common, + change_type: 'contact', + contact: cel`contact`, + new_contact_name: opt('new_contact_name'), + new_mobile: opt('new_mobile'), + new_email: opt('new_email'), + }, + outputVariable: 'change', + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e0', source: 'start', target: 'load_supplier', type: 'default' }, + { id: 'e1', source: 'load_supplier', target: 'load_membership', type: 'default' }, + { id: 'e2', source: 'load_membership', target: 'gate', type: 'default' }, + { id: 'e3', source: 'gate', target: 'refuse_position', type: 'conditional', condition: c(`!(${allowed})`), label: '不可以' }, + { id: 'e4', source: 'gate', target: 'fill', type: 'conditional', condition: c(allowed), label: '可以' }, + { id: 'e5', source: 'fill', target: 'by_type', type: 'default' }, + { id: 'e6', source: 'by_type', target: 'create_basic', type: 'conditional', condition: c(typeIs('basic')), label: '基本信息' }, + { id: 'e7', source: 'by_type', target: 'create_address', type: 'conditional', condition: c(typeIs('address')), label: '地址' }, + { id: 'e8', source: 'by_type', target: 'create_bank', type: 'conditional', condition: c(typeIs('bank_account')), label: '银行账户' }, + { id: 'e9', source: 'by_type', target: 'create_contact', type: 'conditional', condition: c(typeIs('contact')), label: '联系人' }, + { id: 'e10', source: 'create_basic', target: 'end', type: 'default' }, + { id: 'e11', source: 'create_address', target: 'end', type: 'default' }, + { id: 'e12', source: 'create_bank', target: 'end', type: 'default' }, + { id: 'e13', source: 'create_contact', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/index.ts b/src/core/flows/index.ts index 5f4a785..64ff887 100644 --- a/src/core/flows/index.ts +++ b/src/core/flows/index.ts @@ -40,3 +40,8 @@ export { AppealSubmitFlow } from './performance/appeal-submit.flow.js'; export { AppealResolveFlow } from './performance/appeal-resolve.flow.js'; export { PerformanceVoidFlow } from './performance/void.flow.js'; export { PerformanceApplyFlow } from './performance/apply.flow.js'; +export { CertificateChangeCheckFlow } from './qualification/certificate-change-check.flow.js'; +export { CertificateVerifyFlow } from './qualification/certificate-verify.flow.js'; +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'; diff --git a/src/core/flows/qualification/certificate-change-check.flow.ts b/src/core/flows/qualification/certificate-change-check.flow.ts new file mode 100644 index 0000000..f3ffed3 --- /dev/null +++ b/src/core/flows/qualification/certificate-change-check.flow.ts @@ -0,0 +1,143 @@ +import { P, cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// P10 证书改动核验(03 §11,v0.6):证书的内容(类型、编号、发证机构、有效期、扫描件)新建或被修改后,按改动人区分: +// - 门户用户(持 srm_portal_user 岗位)改的:「已核验」复位为 false,通知 SQE 经「核验证书」(srm_certificate_verify) +// 核对原件。没核验的证书不能解除资质异常(srm_supplier_qualification_recalc),供应商自己上传或改有效期都解不了; +// - 内部人员或系统写入的:录入即核验(门户上线前证书一直由内部录入,03 §11 注),核验人记为改动人。 +// 类型也在监视之列:把一张已核验的 ISO 14001 改成 ISO 9001 同样要重新核验,否则能顶替过期的同类证书。 +// 判断改动人读的是 updated_by 的岗位任命,不依赖触发上下文;门户岗位与内部岗位互不兼任(test/portal-isolation.test.ts)。 +const watched = ['cert_type', 'cert_no', 'issuer', 'valid_from', 'valid_to', 'attachment']; +const changed = watched.map((f) => `record.${f} != previous.${f}`).join(' || '); +const writer = `has(record.updated_by) && record.updated_by != null ? record.updated_by : (has(record.created_by) && record.created_by != null ? record.created_by : '')`; + +export const CertificateChangeCheckFlow: Automation.Flow = { + name: 'srm_certificate_change_check', + label: '证书改动核验', + type: 'record_change', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: '新建证书或改了证书内容', + config: { + objectName: 'srm_supplier_certificate', + triggerType: 'record-after-write', + condition: { dialect: 'cel', source: `previous == null || ${changed}` }, + }, + }, + { + id: 'find_portal_writer', + type: 'get_record', + label: '改动人是门户用户?', + config: { + objectName: 'sys_user_position', + filter: { user_id: { dialect: 'cel', source: writer }, position: 'srm_portal_user' }, + outputVariable: 'portalWriter', + }, + }, + { id: 'by_portal', type: 'decision', label: '门户改的?' }, + { + id: 'mark_verified', + type: 'update_record', + label: '内部录入即核验', + config: { + objectName: 'srm_supplier_certificate', + filter: { id: cel`record.id` }, + fields: { verified: true, verified_by: { dialect: 'cel', source: `${writer} == '' ? null : ${writer}` } }, + }, + }, + { + id: 'reset_verified', + type: 'update_record', + label: '复位「已核验」', + config: { + objectName: 'srm_supplier_certificate', + filter: { id: cel`record.id` }, + fields: { verified: false, verified_by: null }, + }, + }, + { + id: 'load_supplier', + type: 'get_record', + label: '读取供应商', + config: { objectName: 'srm_supplier', filter: { id: cel`record.supplier` }, outputVariable: 'supplier' }, + }, + { + id: 'load_grants', + type: 'get_record', + label: '读取品类资格', + config: { objectName: 'srm_supplier_category', filter: { supplier: cel`record.supplier` }, fields: ['category'], limit: 500, outputVariable: 'grants' }, + }, + { + id: 'load_categories', + type: 'get_record', + label: '读取品类的 SQE', + config: { + objectName: 'srm_category', + filter: { id: { $in: cel`grants.map(g, g.category)` } }, + fields: ['sqe'], + limit: 500, + outputVariable: 'categories', + }, + }, + { + id: 'load_quality_managers', + type: 'get_record', + label: '读取质量经理', + config: { objectName: 'sys_user_position', filter: { position: 'srm_quality_manager' }, limit: 50, outputVariable: 'qualityManagers' }, + }, + { + id: 'notify_guard', + type: 'try_catch', + label: '通知', + config: { + try: { + nodes: [ + { + id: 'notify_sqe', + type: 'notify', + label: '通知 SQE 核验(品类没有 SQE 时通知质量经理)', + config: { + recipients: { + dialect: 'cel', + source: + `categories.exists(c, has(c.sqe) && c.sqe != null)` + + ` ? categories.filter(c, has(c.sqe) && c.sqe != null).map(c, c.sqe)` + + ` : qualityManagers.map(p, p.user_id)`, + }, + title: '{{ supplier.name }} 在门户提交了证书,待核验', + message: + '供应商在门户新建或修改了资质证书(编号 {{ record.cert_no }},有效期至 {{ record.valid_to }})。' + + '请核对原件后在证书上点「核验证书」;核验前这张证书不能解除资质异常。', + severity: 'info', + sourceObject: 'srm_supplier_certificate', + sourceId: cel`record.id`, + }, + }, + ], + }, + // 没有可通知的人(品类没有 SQE、也没有质量经理)时不影响复位 + catch: { nodes: [{ id: 'skip_notify', type: 'assignment', label: '跳过通知' }] }, + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e1', source: 'start', target: 'find_portal_writer', type: 'default' }, + { id: 'e2', source: 'find_portal_writer', target: 'by_portal', type: 'default' }, + { id: 'e3', source: 'by_portal', target: 'mark_verified', type: 'conditional', condition: P`portalWriter == null`, label: '内部' }, + { id: 'e4', source: 'by_portal', target: 'reset_verified', type: 'conditional', condition: P`portalWriter != null`, label: '门户' }, + { id: 'e5', source: 'mark_verified', target: 'end', type: 'default' }, + { id: 'e6', source: 'reset_verified', target: 'load_supplier', type: 'default' }, + { id: 'e7', source: 'load_supplier', target: 'load_grants', type: 'default' }, + { id: 'e8', source: 'load_grants', target: 'load_categories', type: 'default' }, + { id: 'e9', source: 'load_categories', target: 'load_quality_managers', type: 'default' }, + { id: 'e10', source: 'load_quality_managers', target: 'notify_guard', type: 'default' }, + { id: 'e11', source: 'notify_guard', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/qualification/certificate-verify.flow.ts b/src/core/flows/qualification/certificate-verify.flow.ts new file mode 100644 index 0000000..fe32391 --- /dev/null +++ b/src/core/flows/qualification/certificate-verify.flow.ts @@ -0,0 +1,91 @@ +import { cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// 核验证书(03 §3 第 4 步、§11,v0.6):供应商在门户上传或修改的证书「已核验」为 false,SQE 或质量经理核对原件后在这里确认, +// 「已核验」置为 true 并记下核验人;资质异常重算(srm_supplier_qualification_recalc)随之判断能否解除。 +// 只核验未核验、未过期的证书:过期证书核验了也不能顶替,应由供应商上传新证书。 +// 以系统身份运行,因为「已核验」对所有人只读;岗位检查写在 flow 里(同 srm_bank_verify,objectstack#25481)。 +const allowed = `current_user != null && ('srm_sqe' in current_user.positions || 'srm_quality_manager' in current_user.positions)`; +const pending = `certificate != null && certificate.verified != true`; +const notExpired = `certificate.status != 'expired'`; + +export const CertificateVerifyFlow: Automation.Flow = { + name: 'srm_certificate_verify', + label: '核验证书', + type: 'screen', + status: 'active', + runAs: 'system', + successMessage: '证书已核验', + + variables: [{ name: 'recordId', type: 'text', isInput: true, isOutput: false }], + + nodes: [ + { id: 'start', type: 'start', label: '开始' }, + { + id: 'load', + type: 'get_record', + label: '读取证书', + config: { objectName: 'srm_supplier_certificate', filter: { id: cel`recordId` }, outputVariable: 'certificate' }, + }, + { id: 'gate', type: 'decision', label: '可以核验?' }, + { + id: 'refuse_position', + type: 'end', + label: '不是 SQE', + config: { outcome: 'refused', message: '只有 SQE 或质量经理可以核验证书' }, + }, + { + id: 'refuse_status', + type: 'end', + label: '已核验', + config: { outcome: 'refused', message: '这张证书已经核验过' }, + }, + { + id: 'refuse_expired', + type: 'end', + label: '已过期', + config: { outcome: 'refused', message: '证书已过期,核验后也不能解除资质异常;请供应商上传新证书' }, + }, + { + id: 'confirm', + type: 'screen', + label: '核对证书', + config: { + title: '核验证书:{{ certificate.cert_no }}', + description: + '发证机构:{{ certificate.issuer }}\n有效期:{{ certificate.valid_from }} 至 {{ certificate.valid_to }}\n' + + '请打开扫描件,与原件或发证机构的公开查询结果核对编号、持证单位和有效期,一致后确认核验。', + waitForInput: true, + }, + }, + { + id: 'verify', + type: 'update_record', + label: '置为已核验', + config: { + objectName: 'srm_supplier_certificate', + filter: { id: cel`certificate.id` }, + fields: { verified: true, verified_by: cel`current_user.id` }, + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e0', source: 'start', target: 'load', type: 'default' }, + { id: 'e1', source: 'load', target: 'gate', type: 'default' }, + { id: 'e2', source: 'gate', target: 'refuse_position', type: 'conditional', condition: { dialect: 'cel', source: `!(${allowed})` }, label: '不是 SQE' }, + { id: 'e3', source: 'gate', target: 'refuse_status', type: 'conditional', condition: { dialect: 'cel', source: `${allowed} && !(${pending})` }, label: '已核验' }, + { + id: 'e4', + source: 'gate', + target: 'refuse_expired', + type: 'conditional', + condition: { dialect: 'cel', source: `${allowed} && ${pending} && !(${notExpired})` }, + label: '已过期', + }, + { id: 'e5', source: 'gate', target: 'confirm', type: 'conditional', condition: { dialect: 'cel', source: `${allowed} && ${pending} && ${notExpired}` }, label: '可以' }, + { id: 'e6', source: 'confirm', target: 'verify', type: 'default' }, + { id: 'e7', source: 'verify', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/qualification/qualification-recalc.flow.ts b/src/core/flows/qualification/qualification-recalc.flow.ts index 7fb78f9..ec6804f 100644 --- a/src/core/flows/qualification/qualification-recalc.flow.ts +++ b/src/core/flows/qualification/qualification-recalc.flow.ts @@ -2,12 +2,19 @@ import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; import { CertTypePicklist } from '../../picklists/cert-type.picklist.js'; -// P10 资质异常与恢复(03 §11,FR-902):证书状态或「必备」标志变化后重新计算供应商的 -// qualification_alert——还有任何一张必备证书已过期就为 true,否则为 false。 -// v0.1 由内部人员维护证书,暂不要求「经 SQE 核验」才解除;门户版本(v0.6)加上 verified 条件。 +// P10 资质异常与恢复(03 §11,FR-902):证书状态、「必备」标志或「已核验」变化后重新计算供应商的 qualification_alert。 +// 一张证书「顶得住」它的类型 = 同类型里有一张未过期(有效或即将到期)且已核验的证书。 +// - 标记:有一张过期的必备证书,而同类型没有顶得住的证书; +// - 解除:每一张过期或未核验的必备证书,同类型都有顶得住的证书(即每一种必备资质类型都有未过期且已核验的证书); +// - 两样都不满足时保持原值:门户改了一张有效证书(复位为未核验)不会因此新标资质异常,但已有的异常要等 SQE 核验后才解除, +// 门户上传新证书、把过期证书的有效期改长都解不了(v0.6,「经 SQE 核验才解除」)。 +// 内部录入或修改的证书由 srm_certificate_change_check 直接记为已核验,所以内部续期、补录照旧即时生效。 // v0.5 起联动风险事件(03 §11):这张证书是必备证书且刚变为已过期时,新建一条系统来源的「资质」类风险事件 // (这张证书已有未关闭的事件时不再建);资质异常解除时,关闭该供应商未关闭的系统资质事件并写处理结论。 const expiredMandatory = `record.is_mandatory == true && record.status == 'expired'`; +const covered = (c: string) => `certs.exists(d, d.cert_type == ${c}.cert_type && d.status != 'expired' && d.verified == true)`; +const raise = `certs.exists(c, c.is_mandatory == true && c.status == 'expired' && !${covered('c')})`; +const clear = `!certs.exists(c, c.is_mandatory == true && (c.status == 'expired' || c.verified != true) && !${covered('c')})`; // 事件描述里写资质类型的中文名;扩展包追加的类型不在表里,写选项值 const certTypeLabels = `{${CertTypePicklist.options.map((o) => `'${o.value}': '${o.label}'`).join(', ')}}`; const certTypeLabel = `(record.cert_type in ${certTypeLabels} ? ${certTypeLabels}[record.cert_type] : record.cert_type)`; @@ -27,20 +34,23 @@ export const QualificationRecalcFlow: Automation.Flow = { objectName: 'srm_supplier_certificate', triggerType: 'record-after-update', condition: P`(has(record.status) && has(previous.status) && record.status != previous.status) - || (has(record.is_mandatory) && has(previous.is_mandatory) && record.is_mandatory != previous.is_mandatory)`, + || (has(record.is_mandatory) && has(previous.is_mandatory) && record.is_mandatory != previous.is_mandatory) + || (has(record.verified) && has(previous.verified) && record.verified != previous.verified)`, }, }, { - id: 'find_expired_mandatory', + id: 'load_certs', type: 'get_record', - label: '找一张已过期的必备证书', + label: '读取该供应商的全部证书', config: { objectName: 'srm_supplier_certificate', - filter: { supplier: cel`record.supplier`, is_mandatory: true, status: 'expired' }, - outputVariable: 'expiredMandatory', + filter: { supplier: cel`record.supplier` }, + fields: ['cert_type', 'is_mandatory', 'status', 'verified'], + limit: 500, + outputVariable: 'certs', }, }, - { id: 'check', type: 'decision', label: '还有过期的必备证书?' }, + { id: 'check', type: 'decision', label: '标记、解除还是保持?' }, { id: 'raise_alert', type: 'update_record', @@ -103,10 +113,11 @@ export const QualificationRecalcFlow: Automation.Flow = { ], edges: [ - { id: 'e1', source: 'start', target: 'find_expired_mandatory', type: 'default' }, - { id: 'e2', source: 'find_expired_mandatory', target: 'check', type: 'default' }, - { id: 'e3', source: 'check', target: 'raise_alert', type: 'conditional', condition: P`expiredMandatory != null`, label: '有' }, - { id: 'e4', source: 'check', target: 'clear_alert', type: 'conditional', condition: P`expiredMandatory == null`, label: '没有' }, + { id: 'e1', source: 'start', target: 'load_certs', type: 'default' }, + { id: 'e2', source: 'load_certs', target: 'check', type: 'default' }, + { id: 'e3', source: 'check', target: 'raise_alert', type: 'conditional', condition: { dialect: 'cel', source: raise }, label: '标记' }, + { id: 'e4', source: 'check', target: 'clear_alert', type: 'conditional', condition: { dialect: 'cel', source: clear }, label: '解除' }, + { id: 'e4a', source: 'check', target: 'end', type: 'conditional', condition: { dialect: 'cel', source: `!(${raise}) && !(${clear})` }, label: '保持' }, { id: 'e5', source: 'raise_alert', target: 'this_expired', type: 'default' }, { id: 'e5a', source: 'this_expired', target: 'find_open_event', type: 'conditional', condition: { dialect: 'cel', source: expiredMandatory }, label: '是' }, { id: 'e5b', source: 'this_expired', target: 'end', type: 'conditional', condition: { dialect: 'cel', source: `!(${expiredMandatory})` }, label: '不是' }, diff --git a/src/core/objects/supplier-application.object.ts b/src/core/objects/supplier-application.object.ts index f47f34f..6b1046e 100644 --- a/src/core/objects/supplier-application.object.ts +++ b/src/core/objects/supplier-application.object.ts @@ -98,6 +98,8 @@ export const SupplierApplication = ObjectSchema.create({ }), approved_date: Field.date({ label: '批准日期', group: 'review', readonly: true }), reject_reason: Field.textarea({ label: '未通过原因', group: 'review' }), + // 「退回补充」后供应商在门户重新提交时写的补充说明(srm_application_resubmit,v0.6),只读 + supplement_note: Field.textarea({ label: '补充说明', group: 'review', readonly: true }), }, validations: [ diff --git a/src/core/objects/supplier-certificate.object.ts b/src/core/objects/supplier-certificate.object.ts index 6bcd720..c9f99a0 100644 --- a/src/core/objects/supplier-certificate.object.ts +++ b/src/core/objects/supplier-certificate.object.ts @@ -2,6 +2,8 @@ import { P } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; // 资质证书(02 §4.1)。status 与 verified 由 flow 维护(资质扫描、核验证书)。 +// v0.6 起门户可以上传和修改证书:门户新建或改了证书内容时 verified 复位、通知 SQE,经「核验证书」才算数; +// 内部人员录入或修改即视为核验(srm_certificate_change_check,03 §11)。 export const SupplierCertificate = ObjectSchema.create({ name: 'srm_supplier_certificate', label: '资质证书', @@ -45,7 +47,9 @@ export const SupplierCertificate = ObjectSchema.create({ { label: '已过期', value: 'expired' }, ], }), - verified: Field.boolean({ label: '已核验', group: 'review', defaultValue: false }), + // 只由 flow 写入:内部录入即核验、门户改动复位、SQE 经「核验证书」确认(03 §11) + verified: Field.boolean({ label: '已核验', group: 'review', defaultValue: false, readonly: true }), + verified_by: Field.user({ label: '核验人', group: 'review', readonly: true }), legacy_imported: Field.boolean({ label: '存量导入', group: 'review', diff --git a/src/core/security/capabilities.ts b/src/core/security/capabilities.ts index b5b9a05..a6cb1d6 100644 --- a/src/core/security/capabilities.ts +++ b/src/core/security/capabilities.ts @@ -1,6 +1,6 @@ import { defineCapability } from '@objectstack/spec'; -// 应用自己的授权能力(平台 ADR-0066 D1)。字段用 requiredPermissions 引用,权限集用 systemPermissions 授予。 +// 应用自己的授权能力(平台 ADR-0066 D1)。字段、应用、按钮用 requiredPermissions 引用,权限集用 systemPermissions 授予。 export const ViewBankAccountNo = defineCapability({ name: 'srm_view_bank_account_no', label: '查看完整银行账号', @@ -8,4 +8,27 @@ export const ViewBankAccountNo = defineCapability({ scope: 'org', }); -export const SrmCapabilities = [ViewBankAccountNo]; +// 门户(v0.6):只决定看不看得到门户应用和门户上的按钮,是体验;数据边界仍是门户 RLS(05 §1) +export const PortalAccess = defineCapability({ + name: 'srm_portal_access', + label: '使用供应商门户', + description: '看到供应商门户应用和门户用户的按钮(如准入申请「补充完成,重新提交」);能看哪些数据仍由门户行级规则决定', + scope: 'org', +}); + +export const PortalManage = defineCapability({ + name: 'srm_portal_manage', + label: '门户管理员操作', + description: '看到门户管理员的按钮(如「提交变更申请」);谁能提交仍由 flow 按门户名册检查', + scope: 'org', +}); + +// 核验证书(v0.6):只决定按钮显示给谁,谁能核验仍由 srm_certificate_verify 检查岗位 +export const VerifyCertificate = defineCapability({ + name: 'srm_verify_certificate', + label: '核验证书', + description: '看到证书上的「核验证书」按钮(SQE、质量经理)', + scope: 'org', +}); + +export const SrmCapabilities = [ViewBankAccountNo, PortalAccess, PortalManage, VerifyCertificate]; diff --git a/src/core/security/permission-sets.ts b/src/core/security/permission-sets.ts index efbcf40..0e84cc2 100644 --- a/src/core/security/permission-sets.ts +++ b/src/core/security/permission-sets.ts @@ -153,6 +153,8 @@ export const QualityPermissionSet = { // 登记与处理风险事件(04 §2.3,v0.5):资质、质量类风险由 SQE 跟进 srm_risk_event: write, }, + // 证书上的「核验证书」按钮(v0.6);谁能核验由 srm_certificate_verify 检查岗位 + systemPermissions: ['srm_verify_certificate'], fields: { ...supplierFieldsReadOnly, // 计划年度由 srm_audit_plan_generate 写入,问题项的整改单由「转整改单」回填 diff --git a/src/core/security/portal-permission-sets.ts b/src/core/security/portal-permission-sets.ts index 3541b52..1ef9b5b 100644 --- a/src/core/security/portal-permission-sets.ts +++ b/src/core/security/portal-permission-sets.ts @@ -69,8 +69,9 @@ const performanceHidden = [ 'srm_performance_score.reassigned_date', ]; -// 证书:门户上传新证书;状态、强制标志、核验结论只由 flow 与「核验证书」写入(防止自解资质异常,04 §3.7) -const certificateFlowOwned = ['status', 'is_mandatory', 'verified', 'legacy_imported']; +// 证书:门户上传和修改证书(v0.6 U4);状态、强制标志、核验结论只由 flow 与「核验证书」写入(防止自解资质异常,04 §3.7)。 +// 门户改了证书内容,srm_certificate_change_check 把「已核验」复位并通知 SQE,核验前不能解除资质异常(03 §11) +const certificateFlowOwned = ['status', 'is_mandatory', 'verified', 'verified_by', 'legacy_imported']; const certificateFields = Object.fromEntries(certificateFlowOwned.map((field) => [`srm_supplier_certificate.${field}`, readOnly])); export const PortalSupplierPermissionSet = { @@ -80,7 +81,7 @@ export const PortalSupplierPermissionSet = { // 授予编辑权是为了资料字段和证书(controlled_by_parent 要求主记录可编辑);其余字段 FLS 只读 srm_supplier: { ...none, allowEdit: true }, srm_supplier_contact: none, - srm_supplier_certificate: { ...none, allowCreate: true }, + srm_supplier_certificate: { ...none, allowCreate: true, allowEdit: true }, srm_supplier_category: none, srm_supplier_bank_account: none, srm_supplier_application: none, @@ -93,6 +94,8 @@ export const PortalSupplierPermissionSet = { srm_performance_review: none, srm_performance_score: none, }, + // 门户应用和门户按钮的显示(体验,不是数据边界) + systemPermissions: ['srm_portal_access'], fields: { ...supplierFields, ...certificateFields, @@ -137,8 +140,9 @@ export const PortalSupplierPermissionSet = { ], }; -// 门户管理员(按人授予,04 §3.3):在本公司下新增联系人与银行账户,看完整银行账号(04 §4.2)。 -// 与 srm_portal_supplier 同时持有;本身不授任何行,行范围仍由上面的 RLS 和主记录决定 +// 门户管理员(按人授予,04 §3.3):在本公司下新增、修改联系人,新增银行账户,看完整银行账号(04 §4.2); +// 关键信息只经「提交变更申请」(srm_change_submit)提交,被退回修改的本人变更单可以改完重新提交(v0.6 U4)。 +// 与 srm_portal_supplier 同时持有;本身不授读,行范围仍由门户 RLS 和主记录决定 const bankFlowOwned = Object.fromEntries( Object.entries(SupplierBankAccount.fields) .filter(([, field]) => (field as { readonly?: boolean }).readonly) @@ -151,8 +155,20 @@ export const PortalAdminPermissionSet = { objects: { srm_supplier_contact: { ...none, allowCreate: true, allowEdit: true }, srm_supplier_bank_account: { ...none, allowCreate: true }, + // 只为退回修改:变更单是 private,external 成员只能改自己发起(拥有)的行;下面的写策略再限定到本公司、被退回的单。 + // 变更内容字段的 readonlyWhen 让它们只在退回期间可改,供应商、类型、状态与审批字段对谁都只读(对象定义) + srm_supplier_change_request: { ...none, allowEdit: true }, }, - systemPermissions: ['srm_view_bank_account_no'], + systemPermissions: ['srm_view_bank_account_no', 'srm_portal_manage'], + rowLevelSecurity: [ + { + name: 'srm_portal_change_request_revise', + object: 'srm_supplier_change_request', + operation: 'update' as const, + using: `${OWN} && approval_status == 'returned'`, + check: OWN, + }, + ], fields: { 'srm_supplier_contact.mobile': { readable: true, editable: true }, 'srm_supplier_contact.active': readOnly, diff --git a/src/core/translations/en.ts b/src/core/translations/en.ts index f08dc30..cd8640c 100644 --- a/src/core/translations/en.ts +++ b/src/core/translations/en.ts @@ -140,6 +140,7 @@ export const en: TranslationData = { primary_contact_conflict: { label: 'Duplicate Primary Contacts' }, }, _actions: { + srm_change_submit: { label: 'Submit Change Request' }, srm_confirm_legacy_review: { label: 'Confirm Review', confirmText: "Confirm that this supplier's record, contacts and certificates have been checked?", @@ -185,8 +186,10 @@ export const en: TranslationData = { attachment: { label: 'Scan' }, status: { label: 'Status', options: { valid: 'Valid', expiring: 'Expiring', expired: 'Expired' } }, verified: { label: 'Verified' }, + verified_by: { label: 'Verified By' }, legacy_imported: { label: 'Legacy Import' }, }, + _actions: { srm_certificate_verify: { label: 'Verify Certificate' } }, _views: { default: { label: 'All Certificates' }, expiring: { label: 'Expiring and Expired' }, @@ -408,7 +411,9 @@ 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' }, + supplement_note: { label: 'Supplement Note' }, }, + _actions: { srm_application_resubmit: { label: 'Supplemented, Resubmit' } }, _views: { default: { label: 'All Applications' }, in_progress: { label: 'In Progress' }, @@ -658,6 +663,7 @@ export const en: TranslationData = { default: { label: 'All Corrective Action Requests' }, board: { label: 'Corrective Action Board' }, unfinished: { label: 'Open Corrective Action Requests' }, + portal_todo: { label: 'Corrective Actions Awaiting Me' }, overdue: { label: 'Overdue Corrective Action Requests' }, claims: { label: 'Claims' }, }, @@ -1165,11 +1171,98 @@ export const en: TranslationData = { refuse_scorer: { message: 'You scored this review; ask another procurement director to handle the appeal' }, }, }, + srm_certificate_verify: { + label: 'Verify Certificate', + successMessage: 'Certificate verified', + screens: { + confirm: { + title: 'Verify certificate: {{ certificate.cert_no }}', + description: + 'Issuer: {{ certificate.issuer }}\nValid: {{ certificate.valid_from }} to {{ certificate.valid_to }}\n' + + 'Open the scan and check the number, holder and validity against the original or the issuer\'s public register, then verify.', + }, + }, + refusals: { + refuse_position: { message: 'Only SQEs or quality managers can verify certificates' }, + refuse_status: { message: 'This certificate has already been verified' }, + refuse_expired: { message: 'The certificate has expired and cannot clear a qualification alert even if verified; ask the supplier to upload a new one' }, + }, + }, + srm_change_submit: { + label: 'Submit Change Request', + successMessage: 'Change request submitted for purchasing approval', + screens: { + fill: { + title: 'Submit change request: {{ supplier.name }}', + description: 'Fill in only what changes. Purchasing approves the request, and a bank account change is also reviewed by finance; the profile is updated once approved.', + fields: { + change_type: { label: 'Change Type', options: { basic: 'Basic Information', address: 'Registered Address', bank_account: 'Bank Account', contact: 'Contact' } }, + reason: { label: 'Reason for Change' }, + new_name: { label: 'New Legal Name' }, + new_credit_code: { label: 'New Unified Social Credit Code' }, + new_registration_no: { label: 'New Registration / Tax No.' }, + new_legal_representative: { label: 'New Legal Representative' }, + new_registered_capital: { label: 'New Registered Capital' }, + address_street: { label: 'New Address (Street)' }, + address_city: { label: 'City' }, + address_state: { label: 'Province / State' }, + address_postal_code: { label: 'Postal Code' }, + bank_account: { label: 'Bank Account to Replace' }, + new_bank_name: { label: 'New Bank' }, + new_branch_name: { label: 'New Branch' }, + new_account_name: { label: 'New Account Name' }, + new_account_no: { label: 'New Account No.' }, + new_swift_code: { label: 'New SWIFT Code' }, + new_currency: { label: 'New Account Currency', options: { cny: 'CNY', usd: 'USD', eur: 'EUR', jpy: 'JPY', hkd: 'HKD' } }, + contact: { label: 'Contact' }, + new_contact_name: { label: 'New Name' }, + new_mobile: { label: 'New Mobile' }, + new_email: { label: 'New Email' }, + }, + }, + }, + refusals: { + refuse_position: { message: 'Only this company\'s portal administrators can submit change requests in the portal' }, + }, + }, + srm_application_resubmit: { + label: 'Supplemented, Resubmit', + successMessage: 'Resubmitted for document review', + screens: { + confirm: { + title: 'Resubmit admission application: {{ application.application_no }}', + description: 'First complete the profile, certificates and bank accounts under Company Profile, then describe what you added; the application returns to document review.', + fields: { note: { label: 'Supplement Note' } }, + }, + }, + refusals: { + refuse_position: { message: 'Only portal users of the applying supplier can resubmit the application' }, + refuse_status: { message: 'Only applications returned for supplement need resubmitting' }, + }, + }, }, apps: { srm_workbench: { label: 'SRM Workbench', navigation: { + group_portal: { label: 'Supplier Portal' }, + portal_home: { label: 'Home' }, + portal_nav_todo_issues: { label: 'Corrective Actions Awaiting Me' }, + portal_nav_todo_certificates: { label: 'Expiring Certificates' }, + portal_nav_todo_applications: { label: 'Admission Progress' }, + portal_nav_latest_reviews: { label: 'Latest Performance' }, + portal_company: { label: 'Company Profile' }, + portal_nav_profile: { label: 'Our Company' }, + portal_nav_contacts: { label: 'Contacts' }, + portal_nav_certificates: { label: 'Certificates' }, + portal_nav_bank_accounts: { label: 'Bank Accounts' }, + portal_nav_change_requests: { label: 'Change Requests' }, + portal_nav_applications: { label: 'Admission Applications' }, + portal_quality: { label: 'Quality' }, + portal_nav_quality_issues: { label: 'Corrective Action Requests' }, + portal_nav_delivery_records: { label: 'Delivery Results' }, + portal_performance: { label: 'Performance' }, + portal_nav_reviews: { label: 'Performance Reviews' }, group_supplier: { label: 'Suppliers' }, nav_supplier: { label: 'Suppliers' }, nav_application: { label: 'Admission Applications' }, diff --git a/src/core/views/quality-issue.view.ts b/src/core/views/quality-issue.view.ts index 12e11ab..e141175 100644 --- a/src/core/views/quality-issue.view.ts +++ b/src/core/views/quality-issue.view.ts @@ -46,6 +46,15 @@ export const QualityIssueViews = defineView({ filter: [unfinished], sort: [{ field: 'final_due', order: 'asc' }], }, + // 门户首页待办(01 §5.10,v0.6):等供应商提交临时措施,或临时措施后(含被 SQE 退回后)等供应商提交根因与措施 + portal_todo: { + label: '待我处理的整改单', + type: 'grid', + data, + columns: columns.filter((c) => c.field !== 'supplier' && c.field !== 'claim_status'), + filter: [{ field: 'status', operator: 'in', value: ['open', 'containment_submitted'] }], + sort: [{ field: 'containment_due', order: 'asc' }], + }, overdue: { label: '逾期整改单', type: 'grid', diff --git a/test/portal-isolation.test.ts b/test/portal-isolation.test.ts index 96e27f4..aca9e95 100644 --- a/test/portal-isolation.test.ts +++ b/test/portal-isolation.test.ts @@ -131,7 +131,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']) { + for (const name of ['srm_claim_respond', 'srm_appeal_submit', 'srm_change_submit', 'srm_application_resubmit']) { 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; @@ -511,10 +511,13 @@ describe('portal isolation (04 §3.7)', () => { expect(idsOf(await list(b.token, 'srm_supplier_certificate'))).not.toContain(String(created.id)); }); - // 自解资质异常:门户上传的新证书不能自带「已核验」或状态,已有证书门户改不了 + // 自解资质异常:门户上传的新证书不能自带「已核验」或状态;改已有证书的内容会复位「已核验」(v0.6 U4,03 §11), + // 状态门户改不了。资质异常要经 SQE 核验才解除,完整场景见 portal-self-service.test.ts it('does not let a portal user clear a qualification alert', async () => { const certificate = A.visible.get('srm_supplier_certificate')![0]!; - await expect(stack.hooks.run('srm_supplier_certificate', 'update', { id: certificate, valid_to: '2040-01-01' }, { as: a.token })).rejects.toBeTruthy(); + expect((await stack.rows('srm_supplier_certificate', { id: certificate }))[0]!.verified).toBe(true); + await stack.hooks.run('srm_supplier_certificate', 'update', { id: certificate, valid_to: '2040-01-01' }, { as: a.token }); + expect((await stack.rows('srm_supplier_certificate', { id: certificate }))[0]!.verified).toBe(false); await expect(stack.hooks.run('srm_supplier_certificate', 'update', { id: certificate, status: 'valid' }, { as: a.token })).rejects.toBeTruthy(); await expect( stack.hooks.run( @@ -526,6 +529,36 @@ describe('portal isolation (04 §3.7)', () => { ).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); }); + // v0.6 U4 的写路径(04 §3.7):改别家的证书、联系人、变更单,在别家名下建银行账户,经 screen flow 给别家提交变更或 + // 重新提交别家的准入申请,一律拒绝;别家的记录一个字段都不变 + it('refuses the self-service write paths across suppliers', async () => { + const bCert = B.visible.get('srm_supplier_certificate')![0]!; + await expect(stack.hooks.run('srm_supplier_certificate', 'update', { id: bCert, valid_to: '2041-01-01' }, { as: a.token })).rejects.toBeTruthy(); + // 把自己的证书改挂到别家名下 + const own = A.visible.get('srm_supplier_certificate')![0]!; + await expect(stack.hooks.run('srm_supplier_certificate', 'update', { id: own, supplier: B.supplier }, { as: a.token })).rejects.toBeTruthy(); + const bContact = B.visible.get('srm_supplier_contact')![0]!; + await expect(stack.hooks.run('srm_supplier_contact', 'update', { id: bContact, mobile: '13000000000' }, { as: aAdmin.token })).rejects.toBeTruthy(); + await expect( + stack.hooks.run('srm_supplier_bank_account', 'insert', { supplier: B.supplier, bank_name: '示例银行', account_name: B.name, account_no: '6222000011112222' }, { as: aAdmin.token }), + ).rejects.toBeTruthy(); + const bChange = B.visible.get('srm_supplier_change_request')![0]!; + await expect(stack.hooks.run('srm_supplier_change_request', 'update', { id: bChange, reason: '冒名' }, { as: aAdmin.token })).rejects.toBeTruthy(); + // 系统身份的 screen flow:门户管理员伪造别家的供应商、门户用户伪造别家的准入申请,名册对不上即结束 + const submit = (await stack.flows.run('srm_change_submit', { recordId: B.supplier }, { as: aAdmin.token })) as { status?: string }; + expect(submit.status).toBe('refused'); + const resubmit = (await stack.flows.run('srm_application_resubmit', { recordId: B.visible.get('srm_supplier_application')![0]! }, { as: a.token })) as { status?: string }; + expect(resubmit.status).toBe('refused'); + // 核验证书只给 SQE / 质量经理 + const verify = (await stack.flows.run('srm_certificate_verify', { recordId: own }, { as: aAdmin.token })) as { status?: string }; + expect(verify.status).toBe('refused'); + expect((await stack.rows('srm_supplier_certificate', { id: bCert }))[0]!.valid_to).not.toBe('2041-01-01'); + expect((await stack.rows('srm_supplier_contact', { id: bContact }))[0]!.mobile ?? null).toBeNull(); + expect((await stack.rows('srm_supplier_change_request', { id: bChange }))[0]!.reason).toBe('示例变更'); + expect(await stack.rows('srm_supplier_change_request', { supplier: B.supplier })).toHaveLength(1); + expect((await stack.rows('srm_supplier_bank_account', { supplier: B.supplier })).map((r) => String(r.id))).toEqual(B.visible.get('srm_supplier_bank_account')); + }); + it('shows the full bank account number to the portal admin only', async () => { const id = A.visible.get('srm_supplier_bank_account')![0]!; const [full] = await stack.rows('srm_supplier_bank_account', { id }, { as: aAdmin.token }); diff --git a/test/portal-platform.test.ts b/test/portal-platform.test.ts index c3abf98..9803171 100644 --- a/test/portal-platform.test.ts +++ b/test/portal-platform.test.ts @@ -420,6 +420,8 @@ describe('portal platform checks (v0.6 U1, V1–V5)', () => { }); it('refuses the certificate’s flow-owned fields, a move to another supplier, a delete, and another supplier’s certificate', async () => { + // 「已核验」由 srm_certificate_change_check 维护(v0.6 U4):探针岗位不是门户岗位,按内部录入处理;这里只断言拒绝的写入没改动它 + const before = (await stack.rows(SupplierCertificate.name, { id: certificate.a }))[0]!.verified; await expect( stack.hooks.run(SupplierCertificate.name, 'update', { id: certificate.a, status: 'valid', verified: true }, asA()), ).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); @@ -432,7 +434,7 @@ describe('portal platform checks (v0.6 U1, V1–V5)', () => { await expect( stack.hooks.run(SupplierCertificate.name, 'update', { id: certificate.b, valid_to: '2030-01-01' }, asA()), ).rejects.toMatchObject({ code: 'RECORD_NOT_FOUND' }); - expect((await stack.rows(SupplierCertificate.name, { id: certificate.a }))[0]).toMatchObject({ supplier: supplier.a, verified: false }); + expect((await stack.rows(SupplierCertificate.name, { id: certificate.a }))[0]).toMatchObject({ supplier: supplier.a, verified: before }); }); it('needs edit on the parent: with read-only access to its supplier it can neither add nor edit a certificate', async () => { diff --git a/test/portal-self-service.test.ts b/test/portal-self-service.test.ts new file mode 100644 index 0000000..2cff44d --- /dev/null +++ b/test/portal-self-service.test.ts @@ -0,0 +1,349 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import config from '../objectstack.config'; + +// v0.6 U4 门户自助维护(03 §3 第 4 步、§5、§11,04 §3.2、§4.2):门户补证书 → SQE 核验后资质异常解除;门户管理员经 +// 「提交变更申请」提交关键信息变更 → 审批写回;被退回的变更单门户改完重新提交;准入申请退回补充后门户重新提交; +// 门户管理员新建银行账户 → 应付会计核验。门户用户的准备沿用 portal-isolation.test.ts(external + 门户岗位 + 名册)。 +// 测试数据全部在这里生成(示例公司名、示例银行),不依赖外部样本。 + +type Row = Record; +type Person = { token: string; id: string }; + +describe('portal self-service (v0.6 U4)', () => { + let stack: VerifyStack; + let admin: string; + let seq = 0; + let org: Record = {}; + + const day = (offset: number) => new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10); + const sys = async (object: string, doc: Row) => (await stack.hooks.run(object, 'insert', doc, { system: true })) as Row; + const row = async (object: string, id: string) => (await stack.rows(object, { id }))[0]!; + const scan = async () => { + const name = `self-service-scan-${++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); + }; + const assign = (userId: string, position: string) => stack.seed('sys_user_position', [{ user_id: userId, position, ...org }]); + const person = async (...positions: string[]): Promise => { + const token = await stack.signUp(`ss-${positions.join('-')}-${++seq}@example.com`); + const id = String((await stack.contextFor(token)).userId); + for (const position of positions) await assign(id, position); + return { token, id }; + }; + // 门户用户:注册 → external → 门户岗位 → 名册(联系人上的 portal_user 由系统写入,等同「开通门户」flow) + const portalUser = async (supplier: string, admin = false): Promise => { + const token = await stack.signUp(`ss-portal-${++seq}@example.com`); + const id = String((await stack.contextFor(token)).userId); + expect(await stack.hooks.updateWhere('sys_member', { user_id: id }, { role: 'external' }, { system: true })).toBe(1); + await assign(id, 'srm_portal_user'); + if (admin) await assign(id, 'srm_portal_admin'); + const contact = await sys('srm_supplier_contact', { + supplier, + name: `门户联系人 ${seq}`, + email: `ss-portal-${seq}@example.com`, + portal_user: id, + ...(admin ? { contact_function: ['admin'] } : {}), + }); + return { token, id, contact: String(contact.id) }; + }; + + // screen flow:从按钮启动,停在屏幕上时提交 input;refused / failed 原样返回 + const runScreen = async (as: string, object: string, flow: string, recordId: string, input: Row = {}) => { + const started = (await stack.actions.run(object, flow, { as, recordId })) as { runId?: string; status?: string }; + if (started.status !== 'paused' || !started.runId) return started as Row; + return (await stack.flows.resume({ flowName: flow, runId: started.runId }, input, { as })) as Row; + }; + // 绕过按钮直接启动 flow(任何成员都能经 trigger 入口启动系统身份的 screen flow,objectstack#25481) + const startDirect = async (as: string, flow: string, recordId: string, input: Row = {}) => { + const started = (await stack.flows.run(flow, { recordId }, { as })) as { runId?: string; status?: string }; + if (started.status !== 'paused' || !started.runId) return started as Row; + return (await stack.flows.resume({ flowName: flow, runId: started.runId }, input, { as })) as Row; + }; + const sweep = async () => { + const run = await stack.flows.run('srm_certificate_expire_sweep', {}, { system: true }); + expect(run.success, JSON.stringify(run)).toBe(true); + }; + const pendingApproval = async (recordId: string) => + (await stack.rows('sys_approval_request', { record_id: recordId })).find((r) => r.status === 'pending'); + const decide = async (as: string, recordId: string, verdict: 'approve' | 'reject' | 'revise', comment: string = verdict) => { + const req = await pendingApproval(recordId); + expect(req, 'no pending approval request').toBeTruthy(); + const res = await stack.apiAs(as, 'POST', `/approvals/requests/${req!.id}/${verdict}`, { comment }); + expect(res.status, await res.clone().text()).toBe(200); + return req!; + }; + // 通知:sys_notification 一条 + 每位收件人一条 sys_notification_receipt + const notified = async (sourceId: string, userId: string) => { + const notes = await stack.rows('sys_notification', { source_id: sourceId }); + for (const note of notes) { + const receipts = await stack.rows('sys_notification_receipt', { notification_id: note.id }); + if (receipts.some((r) => String(r.user_id ?? r.recipient_id ?? '') === userId)) return true; + } + return false; + }; + + let category: string; + let sqe: Person; + let lead: Person; + let accountant: Person; + let manager: Person; + + // 合格供应商:营业执照与 ISO 9001 为品类的必备资质;ISO 9001 已过期 → 资质异常 + const qualifiedSupplier = async () => { + const n = ++seq; + const name = `示例门户自助有限公司 ${n}`; + const supplier = String( + (await sys('srm_supplier', { + name, + credit_code: `91000000MA0SSV${String(n).padStart(4, '0')}`, + legal_representative: '张示例', + address: { street: '示例路 1 号', city: '苏州', country: 'CN' }, + legacy_imported: true, + })).id, + ); + await sys('srm_supplier_category', { supplier, category, status: 'qualified' }); + const license = await sys('srm_supplier_certificate', { supplier, cert_type: 'business_license', cert_no: `BL-${n}`, valid_to: day(3650), attachment: await scan() }); + const iso = await sys('srm_supplier_certificate', { supplier, cert_type: 'iso9001', cert_no: `ISO-${n}`, valid_from: day(-1100), valid_to: day(-1), attachment: await scan() }); + const account = await sys('srm_supplier_bank_account', { + supplier, + bank_name: '示例银行', + branch_name: '示例支行', + account_name: name, + account_no: `62220000${String(n).padStart(8, '0')}`, + status: 'active', + is_default: true, + }); + await sweep(); + return { id: supplier, name, license: String(license.id), iso: String(iso.id), account: String(account.id) }; + }; + + beforeAll(async () => { + stack = await bootStack(config); + admin = await stack.signIn(); + const ctx = await stack.contextFor(admin); + org = ctx.tenantId ? { organization_id: String(ctx.tenantId) } : {}; + sqe = await person('srm_sqe'); + lead = await person('srm_buyer_lead'); + accountant = await person('srm_ap_accountant'); + manager = await person('srm_category_manager'); + category = String( + (await sys('srm_category', { + code: `SSV${++seq}`, + name: `示例门户自助品类 ${seq}`, + mandatory_cert_types: ['business_license', 'iso9001'], + sqe: sqe.id, + category_manager: manager.id, + })).id, + ); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + // 07 §2.2 第 1 步:X 在门户补齐证书,SQE 核验后资质异常解除 + it('clears the qualification alert only after the SQE verifies the certificate the portal uploaded', async () => { + const x = await qualifiedSupplier(); + expect((await row('srm_supplier', x.id)).qualification_alert).toBe(true); + expect((await row('srm_supplier_certificate', x.iso)).verified).toBe(true); + const p = await portalUser(x.id); + + const uploaded = await stack.hooks.run( + 'srm_supplier_certificate', + 'insert', + { supplier: x.id, cert_type: 'iso9001', cert_no: `ISO-NEW-${seq}`, issuer: '示例认证机构', valid_from: day(-1), valid_to: day(1095), attachment: await scan() }, + { as: p.token }, + ); + expect(await row('srm_supplier_certificate', String(uploaded.id))).toMatchObject({ verified: false, is_mandatory: true }); + expect((await row('srm_supplier', x.id)).qualification_alert).toBe(true); + // 品类的 SQE 收到核验通知 + await expect.poll(() => notified(String(uploaded.id), sqe.id)).toBe(true); + + // 门户用户自己核验不了,过期证书不能核验 + expect((await startDirect(p.token, 'srm_certificate_verify', String(uploaded.id))).status).toBe('refused'); + expect((await runScreen(sqe.token, 'srm_supplier_certificate', 'srm_certificate_verify', x.iso)).status).toBe('refused'); + + const verified = await runScreen(sqe.token, 'srm_supplier_certificate', 'srm_certificate_verify', String(uploaded.id)); + expect(verified.success, JSON.stringify(verified)).toBe(true); + expect(await row('srm_supplier_certificate', String(uploaded.id))).toMatchObject({ verified: true, verified_by: sqe.id }); + expect((await row('srm_supplier', x.id)).qualification_alert).toBe(false); + // 已核验的证书不再核验 + expect((await runScreen(sqe.token, 'srm_supplier_certificate', 'srm_certificate_verify', String(uploaded.id))).status).toBe('refused'); + }); + + // 自解资质异常(04 §3.7):门户把过期证书的有效期改长,扫描后证书变为有效,但没有核验就不解除 + it('does not let a portal renewal of an expired certificate clear the alert before verification', async () => { + const x = await qualifiedSupplier(); + const p = await portalUser(x.id); + const renewed = await stack.hooks.run('srm_supplier_certificate', 'update', { id: x.iso, valid_to: day(700) }, { as: p.token }); + expect(renewed.valid_to).toBe(day(700)); + expect((await row('srm_supplier_certificate', x.iso)).verified).toBe(false); + await sweep(); + expect((await row('srm_supplier_certificate', x.iso)).status).toBe('valid'); + expect((await row('srm_supplier', x.id)).qualification_alert).toBe(true); + // 状态、核验结论门户改不了 + await expect(stack.hooks.run('srm_supplier_certificate', 'update', { id: x.iso, status: 'valid' }, { as: p.token })).rejects.toBeTruthy(); + await stack.hooks.run('srm_supplier_certificate', 'update', { id: x.iso, verified: true }, { as: p.token }).catch(() => undefined); + expect((await row('srm_supplier_certificate', x.iso)).verified).toBe(false); + + expect((await runScreen(sqe.token, 'srm_supplier_certificate', 'srm_certificate_verify', x.iso)).success).toBe(true); + expect((await row('srm_supplier', x.id)).qualification_alert).toBe(false); + }); + + // 门户改一张有效证书的内容(含类型)只复位核验,不新标资质异常;不能拿改了类型的证书顶替过期的同类证书 + it('resets verification when the portal edits a valid certificate, without raising a new alert', async () => { + const x = await qualifiedSupplier(); + const p = await portalUser(x.id); + const iso14 = await sys('srm_supplier_certificate', { supplier: x.id, cert_type: 'iso14001', cert_no: `E-${seq}`, valid_to: day(800), attachment: await scan() }); + expect((await row('srm_supplier_certificate', String(iso14.id))).verified).toBe(true); + await stack.hooks.run('srm_supplier_certificate', 'update', { id: String(iso14.id), cert_type: 'iso9001' }, { as: p.token }); + expect((await row('srm_supplier_certificate', String(iso14.id))).verified).toBe(false); + expect((await row('srm_supplier', x.id)).qualification_alert).toBe(true); + + // 另一家资质正常的供应商:门户改有效的营业执照只复位核验,资质异常不变 + const y = await qualifiedSupplier(); + await stack.hooks.run('srm_supplier_certificate', 'update', { id: y.iso, valid_to: day(500) }, { system: true }); + await sweep(); + expect((await row('srm_supplier', y.id)).qualification_alert).toBe(false); + const q = await portalUser(y.id); + await stack.hooks.run('srm_supplier_certificate', 'update', { id: y.license, issuer: '示例市场监管局' }, { as: q.token }); + expect((await row('srm_supplier_certificate', y.license)).verified).toBe(false); + expect((await row('srm_supplier', y.id)).qualification_alert).toBe(false); + }); + + // 门户提交的变更单经审批写回(issue #80 出口条件) + it('writes back a basic change the portal admin submitted, after purchasing approval', async () => { + const x = await qualifiedSupplier(); + const pa = await portalUser(x.id, true); + const run = await runScreen(pa.token, 'srm_supplier', 'srm_change_submit', x.id, { + change_type: 'basic', + reason: '工商变更(示例)', + new_legal_representative: '王示例', + }); + expect(run.success, JSON.stringify(run)).toBe(true); + const [change] = await stack.rows('srm_supplier_change_request', { supplier: x.id }); + expect(change).toMatchObject({ change_type: 'basic', status: 'in_review', approval_status: 'pending', created_by: pa.id, new_legal_representative: '王示例' }); + // 门户能看到自己提交的变更单,别的门户用户(同一家)也能看到 + expect((await stack.rows('srm_supplier_change_request', { id: change!.id }, { as: pa.token })).length).toBe(1); + + await decide(lead.token, String(change!.id), 'approve'); + expect(await row('srm_supplier_change_request', String(change!.id))).toMatchObject({ status: 'applied', purchase_approver: lead.id }); + expect((await row('srm_supplier', x.id)).legal_representative).toBe('王示例'); + }); + + it('routes a portal bank account change through finance review and swaps the accounts', async () => { + const x = await qualifiedSupplier(); + const pa = await portalUser(x.id, true); + const run = await runScreen(pa.token, 'srm_supplier', 'srm_change_submit', x.id, { + change_type: 'bank_account', + reason: '更换收款账户(示例)', + bank_account: x.account, + new_bank_name: '示例银行', + new_branch_name: '示例新支行', + new_account_name: x.name, + new_account_no: '6222999900001111', + }); + expect(run.success, JSON.stringify(run)).toBe(true); + const [change] = await stack.rows('srm_supplier_change_request', { supplier: x.id }); + await decide(lead.token, String(change!.id), 'approve'); + await decide(accountant.token, String(change!.id), 'approve'); + const done = await row('srm_supplier_change_request', String(change!.id)); + expect(done).toMatchObject({ status: 'applied', finance_approver: accountant.id }); + expect((await row('srm_supplier_bank_account', x.account)).status).toBe('inactive'); + expect(await row('srm_supplier_bank_account', String(done.new_bank_account))).toMatchObject({ status: 'active', account_no: '6222999900001111' }); + }); + + it('lets the portal admin revise a returned change and resubmit it', async () => { + const x = await qualifiedSupplier(); + const pa = await portalUser(x.id, true); + await runScreen(pa.token, 'srm_supplier', 'srm_change_submit', x.id, { change_type: 'basic', reason: '示例', new_legal_representative: '赵示例' }); + const [change] = await stack.rows('srm_supplier_change_request', { supplier: x.id }); + const id = String(change!.id); + // 审批中门户改不了 + await stack.hooks.run('srm_supplier_change_request', 'update', { id, new_legal_representative: '孙示例' }, { as: pa.token }).catch(() => undefined); + expect((await row('srm_supplier_change_request', id)).new_legal_representative).toBe('赵示例'); + + const first = await decide(lead.token, id, 'revise', '姓名有误'); + expect((await row('srm_supplier_change_request', id)).approval_status).toBe('returned'); + await stack.hooks.run('srm_supplier_change_request', 'update', { id, new_legal_representative: '钱示例' }, { as: pa.token }); + const res = await stack.apiAs(pa.token, 'POST', `/approvals/requests/${first.id}/resubmit`, {}); + expect(res.status, await res.clone().text()).toBe(200); + await decide(lead.token, id, 'approve'); + expect((await row('srm_supplier', x.id)).legal_representative).toBe('钱示例'); + }); + + it('refuses the change submission to a plain portal user, an internal user and a forged supplier', async () => { + const x = await qualifiedSupplier(); + const y = await qualifiedSupplier(); + const plain = await portalUser(x.id); + const pa = await portalUser(x.id, true); + const input = { change_type: 'basic', reason: '冒名', new_name: '示例冒名有限公司' }; + expect((await startDirect(plain.token, 'srm_change_submit', x.id, input)).status).toBe('refused'); + expect((await startDirect(manager.token, 'srm_change_submit', x.id, input)).status).toBe('refused'); + // 门户管理员伪造别家的供应商:名册对不上 + expect((await startDirect(pa.token, 'srm_change_submit', y.id, input)).status).toBe('refused'); + expect(await stack.rows('srm_supplier_change_request', { supplier: y.id })).toEqual([]); + // 选别家的银行账户:变更单的校验规则拒绝 + const forged = await runScreen(pa.token, 'srm_supplier', 'srm_change_submit', x.id, { + change_type: 'bank_account', + reason: '冒名', + bank_account: y.account, + new_bank_name: '示例银行', + new_account_name: x.name, + new_account_no: '6222999900002222', + }).catch((e) => ({ success: false, error: String(e) })); + expect(forged.success).not.toBe(true); + expect(await stack.rows('srm_supplier_change_request', { supplier: x.id })).toEqual([]); + }); + + it('lets the portal resubmit an application returned for supplement', async () => { + const n = ++seq; + 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 }); + const p = await portalUser(supplier); + const other = await portalUser((await qualifiedSupplier()).id); + + expect((await startDirect(other.token, 'srm_application_resubmit', id, { note: '冒名' })).status).toBe('refused'); + expect((await startDirect(manager.token, 'srm_application_resubmit', id, { note: '内部' })).status).toBe('refused'); + const run = await runScreen(p.token, 'srm_supplier_application', 'srm_application_resubmit', id, { note: '已补传 ISO 9001 证书' }); + expect(run.success, JSON.stringify(run)).toBe(true); + expect(await row('srm_supplier_application', id)).toMatchObject({ status: 'doc_review', supplement_note: '已补传 ISO 9001 证书' }); + // 不是退回补充的申请不能再提交 + expect((await runScreen(p.token, 'srm_supplier_application', 'srm_application_resubmit', id, { note: '再来' })).status).toBe('refused'); + // 门户不能直接改申请 + await expect(stack.hooks.run('srm_supplier_application', 'update', { id, status: 'admission_review' }, { as: p.token })).rejects.toBeTruthy(); + }); + + it('lets the portal admin add a bank account that the AP accountant then verifies', async () => { + const x = await qualifiedSupplier(); + const pa = await portalUser(x.id, true); + const created = await stack.hooks.run( + 'srm_supplier_bank_account', + 'insert', + { supplier: x.id, bank_name: '示例银行', branch_name: '示例二支行', account_name: x.name, account_no: '6222888800003333' }, + { as: pa.token }, + ); + const id = String(created.id); + expect(await row('srm_supplier_bank_account', id)).toMatchObject({ status: 'pending_verify', created_by: pa.id }); + await expect.poll(() => notified(id, accountant.id)).toBe(true); + // 门户自己置不了有效 + await stack.hooks.run('srm_supplier_bank_account', 'update', { id, status: 'active' }, { as: pa.token }).catch(() => undefined); + expect((await row('srm_supplier_bank_account', id)).status).toBe('pending_verify'); + const verified = await runScreen(accountant.token, 'srm_supplier_bank_account', 'srm_bank_verify', id); + expect(verified.success, JSON.stringify(verified)).toBe(true); + expect(await row('srm_supplier_bank_account', id)).toMatchObject({ status: 'active', verified_by: accountant.id }); + }); + + it('lets the portal admin maintain contacts but not a plain portal user', async () => { + const x = await qualifiedSupplier(); + const pa = await portalUser(x.id, true); + const plain = await portalUser(x.id); + const added = await stack.hooks.run('srm_supplier_contact', 'insert', { supplier: x.id, name: '新联系人', email: `new-${seq}@example.com`, mobile: '13900000000' }, { as: pa.token }); + const updated = await stack.hooks.run('srm_supplier_contact', 'update', { id: String(added.id), mobile: '13900000001' }, { as: pa.token }); + expect(updated.mobile).toBe('13900000001'); + await expect(stack.hooks.run('srm_supplier_contact', 'update', { id: String(added.id), mobile: '13900000002' }, { as: plain.token })).rejects.toBeTruthy(); + }); +}); diff --git a/test/qualification.test.ts b/test/qualification.test.ts index c0c5656..51d573d 100644 --- a/test/qualification.test.ts +++ b/test/qualification.test.ts @@ -80,16 +80,28 @@ describe('qualification monitoring', () => { expect((await read(Supplier.name, supplier)).qualification_alert).toBe(false); }); - it('keeps the alert while another mandatory certificate is still expired', async () => { + // 资质异常按资质类型判断(03 §11):另一种必备资质还过期着,续了这一种也不解除 + it('keeps the alert while a mandatory certificate of another type is still expired', async () => { const supplier = await newSupplier(); const first = await newCertificate(supplier, day(-1), true); - await newCertificate(supplier, day(-2), true); + await newCertificate(supplier, day(-2), true, 'business_license'); await sweep(); await stack.hooks.run(SupplierCertificate.name, 'update', { id: first, valid_to: day(365) }, { as: admin }); await sweep(); expect((await read(Supplier.name, supplier)).qualification_alert).toBe(true); }); + // 同类型补了一张新证书(内部录入即核验)就顶得住过期的旧证书,不必删除旧证书 + it('clears the alert once a verified certificate of the same type replaces the expired one', async () => { + const supplier = await newSupplier(); + await newCertificate(supplier, day(-1), true); + await sweep(); + expect((await read(Supplier.name, supplier)).qualification_alert).toBe(true); + const renewed = await newCertificate(supplier, day(365), true); + expect(await read(SupplierCertificate.name, renewed)).toMatchObject({ verified: true }); + expect((await read(Supplier.name, supplier)).qualification_alert).toBe(false); + }); + it('changes nothing on a second run', async () => { const supplier = await newSupplier(); const cert = await newCertificate(supplier, day(-1), true);