diff --git a/README.md b/README.md index d888ffb..6141f86 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ pnpm validate # 每次改元数据后跑;其余检查见 AGENTS.md §6 | 版本 | 内容 | 累计对象 | |---|---|---| | v0.1(已合入,PR #5) | 内部工作台:主数据、两级准入审批、资质到期扫描 | 7 | -| v0.2 | 物料只读副本、银行账户、存量导入、信息变更单 | 10 | +| v0.2(已合入,PR #13–#18) | 物料只读副本、银行账户、存量导入、信息变更单 | 10 | | v0.3 | 合同与协议、供应商处置 | 13 | | v0.4 | 交付记录导入、质量问题 / SCAR、供应商审核 | 16 | | v0.5 | 绩效、风险事件、管理报表 = 内部版 MVP,试运行 | 20 | @@ -101,7 +101,7 @@ pnpm validate # 每次改元数据后跑;其余检查见 AGENTS.md §6 | v0.7 | 寻源与封标、价格库;核心对象冻结 | 27 | 已完成:核心设计、三个扩展包设计(保留)、ADR-0001~0009、参数文件首版、体系文件生成脚本 -和 12 份模板、v0.1 内部工作台(PR #5)。文档目录见 [docs/README.md](./docs/README.md); +和 12 份模板、v0.1 内部工作台(PR #5)、v0.2 主数据补齐(PR #13–#18)。文档目录见 [docs/README.md](./docs/README.md); 参与开发前先读 [AGENTS.md](./AGENTS.md)。 -下一步:v0.2 主数据补齐——物料只读副本、银行账户、存量导入、信息变更单([07 §1](./docs/design/07-delivery-plan.md#1-分期路线图))。 +下一步:v0.3 合同与处置——合同与合同行、供应商处置单([07 §1](./docs/design/07-delivery-plan.md#1-分期路线图))。 diff --git a/docs/design/02-domain-model.md b/docs/design/02-domain-model.md index 1b215b4..6d4cf00 100644 --- a/docs/design/02-domain-model.md +++ b/docs/design/02-domain-model.md @@ -201,6 +201,7 @@ erDiagram | `is_related_party` | boolean | 默认 false;存在关联关系申报时为 true(FR-904) | | `quality_agreement_signed` / `integrity_agreement_signed` | boolean | 默认 false;协议生效时由合同 flow 置为 true | | `erp_vendor_code` | text | ERP 供应商编码,唯一;预留,ERP 集成前空着或由采购员手工填写 | +| `last_change_request` | lookup → 信息变更单(只读) | 最近一次写回本档案的变更单,由 `srm_change_apply` 写入(v0.2) | | `portal_position` | text | 门户岗位标识,如 `srm_vendor_sup0000128`;开通门户时写入,只读。校验规则:非空时必须以 `srm_vendor_` 开头(没开通门户的供应商为空)。v0.6 加 | | `legacy_imported` | boolean | 默认 false;存量导入的供应商为 true(v0.2)。只能在新建时置为 true:`readonlyWhen: previous.legacy_imported != true`,已建档的供应商重复导入时该列被忽略;只有 `srm_admin` 可写 | | `legacy_reviewed` | boolean | 默认 false;品类经理核对后经「确认复核」按钮置为 true。`legacy_imported && !legacy_reviewed` 的供应商列在「存量待复核」视图(v0.2) | @@ -209,6 +210,12 @@ erDiagram | `expired_certificate_count` | summary | `count` 资质证书,条件 `status == 'expired'` | | `default_bank_account_count` | summary | `count` 银行账户,条件 `is_default == true`;「每家至多一个默认账户」的校验读它(v0.2) | +关键信息锁定(v0.2):`name`、`country`、`credit_code`、`registration_no`、`legal_representative`、 +`registered_capital`、`address` 在 `potential` / `qualifying` 期间可以直接改;准入通过后(合格、冻结、 +淘汰、黑名单)只能经信息变更单修改。实现为校验规则 `key_info_via_change_request`:这些字段有变化而 +`last_change_request` 没变时拒绝写入;`last_change_request` 只读,只有写回 flow 能带着它一起写。 +不用 `readonlyWhen`,因为它连系统身份的写入也挡,写回 flow 就写不进去。 + **`srm_supplier_contact` 供应商联系人**(master_detail → 供应商;OWD `controlled_by_parent`) | 字段 | 类型 | 说明 | @@ -231,7 +238,7 @@ erDiagram | `currency` | select | `cny` / `usd` / `eur` / `jpy` / `hkd`,默认 `cny` | | `status` | select(stageField,只读) | `pending_verify` 待核验(新建默认)/ `active` 有效 / `inactive` 停用;只由 flow 写入,状态机 `pending_verify → active ⇄ inactive` | | `verified_by` | user(只读) | 核验人(应付会计),由「核验银行账户」screen flow 写入 | -| `is_default` | boolean | 默认 false;只有 `active` 的账户可设为默认(校验规则);每家至多一个默认账户(校验规则读供应商的 `default_bank_account_count`) | +| `is_default` | boolean | 默认 false;只有 `active` 的账户可设为默认(校验规则,新建与修改都拦;不用 `readonlyWhen`,变更单停用原账户时要同时取消默认);每家至多一个默认账户(校验规则读供应商的 `default_bank_account_count`) | | `erp_bank_id` | text(只读) | 预留,ERP 集成前空着 | 锁定:开户行、支行、户名、账号、SWIFT、币种在任何状态下都 `readonlyWhen` 只读(新建时可填)—— @@ -324,11 +331,30 @@ upsert 写入,结构不变。 `status`(`planned` / `done` / `cancelled`)。问题项可经「转整改单」screen flow 生成 `srm_quality_issue`(`source = audit`)。 -**`srm_supplier_change_request` 信息变更单**(v0.2):`supplier`、`change_type` -(`basic` 基本信息 / `bank_account` 银行账户 / `address` 地址 / `contact` 联系人)、 -`before_value` / `after_value`(json,变更前后快照)、`reason`、`status`、 -`purchase_approver` / `finance_approver`(user)、`applied_at`(审批通过后写回档案 -的时间)。门户上线前由采购员代供应商发起。 +**`srm_supplier_change_request` 信息变更单**(OWD `private`;v0.2)。门户上线前由采购侧代供应商在 +供应商档案下新建,新建即进入审批(03 §5)。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `change_no` | autonumber | `CHG{YYYY}{00000}` | +| `supplier` | lookup → 供应商 | 必填;新建后只读 | +| `change_type` | select | `basic` 基本信息 / `bank_account` 银行账户 / `address` 地址 / `contact` 联系人;新建后只读 | +| `reason` | textarea | 必填 | +| `new_name` / `new_credit_code` / `new_registration_no` / `new_legal_representative` / `new_registered_capital` | text / currency | 基本信息的新值,至少填一项,留空的保持原值 | +| `new_address` | address | 地址变更必填 | +| `bank_account` | lookup → 银行账户 | 被替换的原账户:必须属于本供应商、状态为 `active`;新建后只读 | +| `new_bank_name` / `new_branch_name` / `new_account_name` / `new_account_no` / `new_swift_code` / `new_currency` | text / select | 新账户。户名规则同银行账户;`new_account_no` 同样脱敏并要求 `srm_view_bank_account_no`,所以银行账户变更只能由持该能力的岗位(品类经理、采购主管)发起 | +| `contact` | lookup → 联系人 | 联系人变更必填,必须属于本供应商;新值 `new_contact_name` / `new_mobile` / `new_email` | +| `status` | select(stageField,只读) | `in_review` 审批中 / `applied` 已生效 / `rejected` 已驳回;只由 flow 写入 | +| `approval_status` | select(只读) | 审批节点镜像:`pending` / `approved` / `rejected` / `returned` 退回修改 / `recalled` | +| `purchase_approver` / `finance_approver` | user(只读) | 两级审批的实际审批人,flow 从审批记录写入 | +| `reject_reason` | textarea(只读) | 驳回时的审批意见 | +| `before_value` / `after_value` | json(只读) | 新建时快照的当前档案 / 写回后的档案;银行账号不进快照(BR-20) | +| `applied_at` | datetime(只读) | 写回时间 | +| `new_bank_account` | lookup → 银行账户(只读) | 银行账户变更写回时新建的账户 | + +新值用分类型字段而不是直接编辑 json:表单能按类型显示、校验,退回后发起人能直接改。变更内容只在新建时和 +`approval_status == 'returned'` 期间可改(`readonlyWhen`),审批中平台锁定记录。 **`srm_supplier_disposition` 供应商处置单**(v0.3):`supplier`、`action` (`freeze` / `unfreeze` / `eliminate` / `blacklist` / `remove_blacklist`)、 diff --git a/docs/design/03-processes-and-automation.md b/docs/design/03-processes-and-automation.md index fe55607..cd9c9dd 100644 --- a/docs/design/03-processes-and-automation.md +++ b/docs/design/03-processes-and-automation.md @@ -190,13 +190,24 @@ flowchart TD `verified_by`。flow 拒绝三种情况:启动人不是应付会计、账户不是待核验、启动人就是新建人 (双人原则)。已有账户的修改不直接编辑,走下面的信息变更单。 -**信息变更**(v0.2,FR-207):供应商在门户经「提交变更申请」screen flow 提交变更单(v0.6 前由采购员代供应商发起)→ -`before_value` 自动快照当前档案 → 审批 flow `srm_change_approval`:第 1 级 -`position = srm_buyer_lead`(采购确认);`change_type == 'bank_account'` 时增加第 2 级 -`position = srm_ap_accountant`(财务复核), -两级审批人由不同岗位担任(BR-11)→ 通过后 flow 把 `after_value` 写回供应商或银行 -账户,新银行账户为 `active`,旧账户为 `inactive`。驳回时通知供应商并写明原因。ERP 侧的 -银行账户由财务按 SRM 的审批记录维护(ERP 集成为预留集成点)。 +**信息变更**(v0.2,FR-207):门户上线前由采购侧代供应商在供应商档案下新建变更单(v0.6 起供应商在门户经 +「提交变更申请」screen flow 提交),填写变更后的值 → 审批 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`, +见 02 §4.1 关键信息锁定),联系人写回联系人;银行账户变更停用原账户(是默认账户的同时取消默认)、按新值建一个 +`active` 账户,核验人记为财务复核人,原账户是默认的新账户接着做默认 → 写回后的档案快照到 `after_value`, +变更单为 `applied`,通知发起人。 + +- **驳回**:不改主记录,审批意见写入 `reject_reason`,通知发起人。 +- **退回修改**:第 1 级可以退回(`revise` → `approval_revise`,至多 3 次),记录解锁,发起人改完重新提交, + 从第 1 级重新审批。**临时做法**:第 2 级只能通过或驳回、不能退回——平台 18.0.0 把第 2 个审批节点的提交人记成 + 第 1 级审批人,退回后发起人无法重新提交(objectstack#24817 已在主干修复、未发版);发版后加回退回。 +- **职责分离(BR-11)**:发起人不能审批本人的变更单,两级审批人不能是同一人。平台没有「提交人不得审批」开关 + (objectstack#25446),岗位又可能兼任,所以每级通过后 flow 读审批记录比对,撞人时这次通过不算数,通知审批人 + 并重新开一轮同级审批。审批人在节点开启时按岗位解析:只有一位采购主管且他就是发起人时,要再任命一位。 + +ERP 侧的银行账户由财务按 SRM 的审批记录维护(ERP 集成为预留集成点)。 **供应商处置**(v0.3,FR-208):品类经理发起处置单 → 审批:冻结 / 解冻为品类经理 → 采购总监;淘汰与黑名单再加 `position = srm_general_manager`;解除黑名单单独走同一条 @@ -377,7 +388,7 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `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.1 | -| `srm_change_approval` / `srm_change_apply` | record_change | 变更单、银行账户 | v0.2 | +| `srm_change_approval` / `srm_change_apply` | record_change:变更单新建 / 子 flow | 变更单 → 供应商、银行账户、联系人 | v0.2 | | `srm_contract_approval` | record_change:状态→`in_review` | 合同 | v0.3 | | `srm_contract_expiry_notice` / `srm_contract_expire_sweep` | time_relative / schedule | 合同 | v0.3 | | `srm_disposition_approval` / `srm_disposition_apply` | record_change | 处置单 | v0.3 | @@ -408,7 +419,7 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。 | `srm_certificate_verify` 核验证书 | 资质证书 | SQE | v0.1 | | `srm_primary_contact_set` 设为主联系人 | 供应商联系人 | `srm_supplier_admin` | v0.1 | | `srm_bank_verify` 核验银行账户 | 银行账户 | 应付会计 | v0.2 | -| `srm_change_submit` 提交变更申请 | 供应商档案(v0.6 起门户:公司资料) | 采购员;v0.6 起门户管理员 | v0.2 | +| `srm_change_submit` 提交变更申请 | 门户:公司资料(v0.6 前内部在供应商档案下直接新建变更单) | 门户管理员 | v0.6 | | `srm_audit_finding_to_scar` 转整改单 | 供应商审核 | SQE | v0.4 | | `srm_score_submit` 提交评分 | 考评单 | 评分人 | v0.5 | | `srm_appeal_submit` 申诉 | 门户:绩效 | 门户管理员 | v0.6 | diff --git a/docs/design/04-security-and-portal.md b/docs/design/04-security-and-portal.md index c5180e8..0ae4a21 100644 --- a/docs/design/04-security-and-portal.md +++ b/docs/design/04-security-and-portal.md @@ -54,7 +54,7 @@ SRM 同时服务内部员工和互相竞争的供应商,权限设计的首要 | `srm_internal_base` | 内部员工的基础:读品类、物料、工厂、供应商主数据(不含银行账号)、价格库;**不是** `isDefault` | | `srm_sourcing` | 寻源单、寻源明细的增改(状态只经 screen flow 推进);邀请、技术评分、定标只经 screen flow;读报价(受封标 RLS 约束);新建合同 | | `srm_supplier_admin` | 供应商档案、联系人、证书、准入申请、审核、供货品类资格、处置单、合同的增改;开通门户 | -| `srm_buyer` | 供应商档案、合同、交付记录读;物料只改 `category` 关联(其余字段 FLS 只读,不新建、不删除);代供应商发起变更单(v0.6 前);ERP 集成后加订单、送货单的内部字段 | +| `srm_buyer` | 供应商档案、合同、交付记录读;物料只改 `category` 关联(其余字段 FLS 只读,不新建、不删除);代供应商发起变更单(v0.6 前;银行账户变更要填完整账号,由持完整账号能力的品类经理或采购主管发起);ERP 集成后加订单、送货单的内部字段 | | `srm_quality` | 审核、交付记录(录入)、整改单的增改;证书只读,核验经 screen flow | | `srm_iqc` | 交付记录的录入(没有质量系统的客户) | | `srm_finance` | 银行账号可读,核验经 screen flow;银行账户变更的第 2 级审批;整改单索赔结论标记 | @@ -85,7 +85,7 @@ R 读 · C 建 · E 改 · D 删 · F 仅经 flow / screen flow 写入 · — | 银行账户 | — | — | R | — | — | R + F(核验) | R(本公司);门户管理员 C | | 资质证书 | R | R | RCE | R | R + F(核验) | R | RCE(本公司;状态与核验字段只读) | | 准入申请 | — | — | RCE | — | RE | — | R(本公司) | -| 变更单 | — | — | R | R | — | R | R(本公司)+ F(提交) | +| 变更单 | — | — | RCE(本人发起的) | RCE(本人发起的;不含银行账户变更) | — | R | R(本公司)+ F(提交) | | 寻源单 / 明细 | R | RCE(状态只经 F) | R | R | R | — | — | | 寻源邀请 | R | RF | R | — | — | — | RE(本公司:接受 / 拒绝) | | 报价单 / 明细 | — | R(开标后)+ F(评分、定标) | — | — | — | — | RE(本公司,截止前且未开标) | diff --git a/docs/design/07-delivery-plan.md b/docs/design/07-delivery-plan.md index 3bbca2c..f1d0ceb 100644 --- a/docs/design/07-delivery-plan.md +++ b/docs/design/07-delivery-plan.md @@ -14,7 +14,7 @@ | 版本 | 范围 | 新增对象(累计) | 本版闭环 | 前置条件 | |---|---|---|---|---| | v0.1 内部工作台 | 供应商主数据、两级准入审批、证书到期提醒 | 7:品类、工厂、供应商、联系人、证书、供货品类资格、准入申请 | 供应商从登记到合格;证书到期前有提醒 | 已交付(PR #5) | -| v0.2 主数据补齐 | 银行账户、信息变更申请、法人(业务单元)、物料与存量供应商导入 | +3 = 10:物料、银行账户、信息变更单 | 存量供应商导入后能复核;关键信息变更走审批 | — | +| v0.2 主数据补齐 | 银行账户、信息变更申请、法人(业务单元)、物料与存量供应商导入 | +3 = 10:物料、银行账户、信息变更单 | 存量供应商导入后能复核;关键信息变更走审批 | 已交付(PR #13–#18) | | v0.3 合同与处置 | 合同与合同行、处置单(暂停 / 冻结 / 解除)、供应商自助重新申请 | +3 = 13:合同、合同行、处置单 | 合同到期前提醒;冻结后不能被邀请 | — | | v0.4 质量 | 交付记录导入、质量问题与整改单、供应商审核与年度审核计划 | +3 = 16:交付记录、质量问题、供应商审核 | 不合格交付能开整改单并闭环;OTD、合格率、PPM 出数 | 客户能按月导出交付数据 | | v0.5 绩效与风险 | 指标库、按周期考评、风险事件与扫描、黑名单 | +4 = 20:绩效指标、考评单、考评明细、风险事件 | 第一次考评自动生成并发布;D 级进入处置 | 门槛 1:内部 MVP 验收(§2.1) | @@ -232,7 +232,7 @@ test/ # 仓库根目录 | PF2 | objectstack#25443 运行时创建岗位 | 门户开通 | 管理员手工建岗位 | | PF3 | objectstack#25444 通知组织外联系人 | 供应商邮件通知 | 门户内通知 + 人工邮件 | | PF4 | objectstack#25445 公开表单附件 | 供应商自助注册 | 内部登记 + 邀请注册 | -| PF5 | objectstack#25446 禁止自审开关 | 职责分离 | `expression` 改派 | +| PF5 | objectstack#25446 禁止自审开关 | 职责分离 | `expression` 改派;信息变更单每级通过后读审批记录比对,撞人时重开同级审批(03 §5) | | PF6 | objectstack#25233 门户用户可列出全组织用户 | 门户隔离 | v0.6 阻塞;见 D8 | | PF7 | objectstack#25447 ADR-0138 D5(多组织环境下公开表单绑定到指定组织) | 多法人客户的自助注册 | 邀请注册;多法人按 ADR-0006 单组织时不受影响 | | PF8 | objectstack#25475 导入时「只在新建时写入」的列 | 存量导入不回退状态 | `status` 默认值按 `legacy_imported` 计算、`legacy_imported` 用 `readonlyWhen` 锁定(02 §4.1) | @@ -240,8 +240,9 @@ test/ # 仓库根目录 | PF10 | objectstack#25477 条件唯一(每家至多一位主联系人)在批量写入中生效 | 联系人导入 | 汇总字段 + 校验规则守卫单条写入;「主联系人重复」视图兜底同批写入 | | PF11 | objectstack#25491 导入映射的 `transform`(转大写 / 小写)被忽略 | 信用代码、邮箱归一 | 导入前清洗([导入说明 §6.2](../implementation/legacy-supplier-import.md#62-清洗清单)) | | PF12 | objectstack#25493 导入接受 `YYYY.MM.DD` / `YYYYMMDD`、选项别名与大小写、每行报全部错误 | 存量台账导入 | 导入前清洗 | +| PF13 | objectstack#24817 多级审批的后续节点以原发起人为提交人(主干已修,18.0.0 未带上) | 信息变更单第 2 级退回修改 | 第 2 级只能通过或驳回(03 §5) | --- -*本设计的下一步:v0.2 主数据补齐 → … → v0.5 门槛 1 验收 → v0.6 门户 → v0.7 寻源与门槛 2。 +*本设计的下一步:v0.3 合同与处置 → … → v0.5 门槛 1 验收 → v0.6 门户 → v0.7 寻源与门槛 2。 每个版本出口时更新 §1 的路线表。* diff --git a/docs/implementation/legacy-supplier-import.md b/docs/implementation/legacy-supplier-import.md index 3bc5d22..846842b 100644 --- a/docs/implementation/legacy-supplier-import.md +++ b/docs/implementation/legacy-supplier-import.md @@ -54,7 +54,10 @@ - 供应商状态、资质异常、证书状态、「存量已复核」都不在表格里,导入不会改动它们, 导入后冻结、淘汰或复核过的供应商保持原样; - 已经通过准入建档的供应商出现在存量表里时,只更新资料字段,不会被标记为存量导入 - (试运行报告里会列出被忽略的「存量导入」列)。 + (试运行报告里会列出被忽略的「存量导入」列); +- 存量导入的供应商建档即为合格,此后全称、信用代码、注册号、法定代表人、注册资本、注册地址只能经 + 信息变更单修改([02 §4.1](../design/02-domain-model.md#41-供应商主数据))。重复导入时这些列与系统里的值不同, + 该行报错、不更新;值相同的行照常通过。 ## 4. 导入之后:复核 diff --git a/src/core/apps/workbench.app.ts b/src/core/apps/workbench.app.ts index b7cced5..f4d028e 100644 --- a/src/core/apps/workbench.app.ts +++ b/src/core/apps/workbench.app.ts @@ -17,6 +17,7 @@ export const Workbench = App.create({ { id: 'nav_application', type: 'object', objectName: 'srm_supplier_application', label: '准入申请', icon: 'clipboard-check' }, { id: 'nav_certificate', type: 'object', objectName: 'srm_supplier_certificate', label: '资质证书', icon: 'badge-check' }, { id: 'nav_bank_account', type: 'object', objectName: 'srm_supplier_bank_account', label: '银行账户', icon: 'landmark' }, + { id: 'nav_change_request', type: 'object', objectName: 'srm_supplier_change_request', label: '变更单', icon: 'file-pen' }, ], }, { diff --git a/src/core/flows/change-request/change-apply.flow.ts b/src/core/flows/change-request/change-apply.flow.ts new file mode 100644 index 0000000..3eab8cd --- /dev/null +++ b/src/core/flows/change-request/change-apply.flow.ts @@ -0,0 +1,221 @@ +import { P, cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// 写回信息变更(03 §5):审批全部通过后由 srm_change_approval 调用,以系统身份把变更单上的新值写回 +// 供应商、银行账户或联系人,再把写回后的档案快照到 after_value,变更单置为「已生效」。 +// 留空的新值保持原值。银行账户变更:原账户停用(是默认账户的同时取消默认),按新值建一个已核验的 +// 有效账户、核验人记为财务复核人,原账户是默认账户的新账户接着做默认(每家至多一个默认账户)。 +const cel$ = (source: string) => ({ dialect: 'cel' as const, source }); +const current = (v: string, k: string) => `(has(${v}.${k}) ? ${v}.${k} : null)`; +const filled = (k: string) => `has(req.${k}) && req.${k} != null`; +// 新值填了就用新值,否则保持原值 +const text = (newKey: string, v: string, k: string) => + cel$(`${filled(newKey)} && !isBlank(req.${newKey}) ? req.${newKey} : ${current(v, k)}`); +const value = (newKey: string, v: string, k: string) => cel$(`${filled(newKey)} ? req.${newKey} : ${current(v, k)}`); +const snapshotOf = (v: string, keys: string[]) => cel$(`{${keys.map((k) => `'${k}': ${current(v, k)}`).join(', ')}}`); + +const supplierKeys = ['name', 'credit_code', 'registration_no', 'legal_representative', 'registered_capital', 'address']; + +export const ChangeApplyFlow: Automation.Flow = { + name: 'srm_change_apply', + label: '写回信息变更', + type: 'autolaunched', + status: 'active', + runAs: 'system', + + 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_change_request', filter: { id: cel`recordId` }, outputVariable: 'req' }, + }, + { id: 'by_type', type: 'decision', label: '按变更类型写回' }, + + // 基本信息 / 地址 → 供应商 + { + id: 'load_supplier', + type: 'get_record', + label: '读取供应商', + config: { objectName: 'srm_supplier', filter: { id: cel`req.supplier` }, outputVariable: 'supplier' }, + }, + { + id: 'write_basic', + type: 'update_record', + label: '写回基本信息', + config: { + objectName: 'srm_supplier', + filter: { id: cel`req.supplier` }, + fields: { + name: text('new_name', 'supplier', 'name'), + credit_code: text('new_credit_code', 'supplier', 'credit_code'), + registration_no: text('new_registration_no', 'supplier', 'registration_no'), + legal_representative: text('new_legal_representative', 'supplier', 'legal_representative'), + registered_capital: value('new_registered_capital', 'supplier', 'registered_capital'), + last_change_request: cel`req.id`, + }, + }, + }, + { + id: 'write_address', + type: 'update_record', + label: '写回注册地址', + config: { objectName: 'srm_supplier', filter: { id: cel`req.supplier` }, fields: { address: cel`req.new_address`, last_change_request: cel`req.id` } }, + }, + { + id: 'reload_supplier', + type: 'get_record', + label: '读取写回后的供应商', + config: { objectName: 'srm_supplier', filter: { id: cel`req.supplier` }, outputVariable: 'after' }, + }, + { + id: 'done_supplier', + type: 'update_record', + label: '变更单已生效', + config: { + objectName: 'srm_supplier_change_request', + filter: { id: cel`req.id` }, + fields: { status: 'applied', applied_at: cel`isoDatetime(now())`, after_value: snapshotOf('after', supplierKeys) }, + }, + }, + + // 银行账户:停用原账户,建新的有效账户 + { + id: 'load_account', + type: 'get_record', + label: '读取原银行账户', + config: { objectName: 'srm_supplier_bank_account', filter: { id: cel`req.bank_account` }, outputVariable: 'old' }, + }, + { + id: 'retire_account', + type: 'update_record', + label: '停用原账户', + config: { + objectName: 'srm_supplier_bank_account', + filter: { id: cel`old.id` }, + fields: { status: 'inactive', is_default: false }, + }, + }, + { + id: 'create_account', + type: 'create_record', + label: '建新的有效账户', + config: { + objectName: 'srm_supplier_bank_account', + fields: { + supplier: cel`req.supplier`, + bank_name: cel`req.new_bank_name`, + branch_name: cel$(current('req', 'new_branch_name')), + account_name: cel`req.new_account_name`, + account_no: cel`req.new_account_no`, + swift_code: cel$(current('req', 'new_swift_code')), + currency: cel`has(req.new_currency) && req.new_currency != null ? req.new_currency : 'cny'`, + status: 'active', + verified_by: cel$(current('req', 'finance_approver')), + is_default: cel`has(old.is_default) && old.is_default == true`, + }, + outputVariable: 'created', + }, + }, + { + id: 'done_account', + type: 'update_record', + label: '变更单已生效', + config: { + objectName: 'srm_supplier_change_request', + filter: { id: cel`req.id` }, + fields: { + status: 'applied', + applied_at: cel`isoDatetime(now())`, + new_bank_account: cel`created.id`, + after_value: snapshotOf('created', ['id', 'bank_name', 'branch_name', 'account_name', 'swift_code', 'currency', 'status', 'is_default']), + }, + }, + }, + + // 联系人 + { + id: 'load_contact', + type: 'get_record', + label: '读取联系人', + config: { objectName: 'srm_supplier_contact', filter: { id: cel`req.contact` }, outputVariable: 'contact' }, + }, + { + id: 'write_contact', + type: 'update_record', + label: '写回联系人', + config: { + objectName: 'srm_supplier_contact', + filter: { id: cel`req.contact` }, + fields: { + name: text('new_contact_name', 'contact', 'name'), + mobile: text('new_mobile', 'contact', 'mobile'), + email: text('new_email', 'contact', 'email'), + }, + }, + }, + { + id: 'reload_contact', + type: 'get_record', + label: '读取写回后的联系人', + config: { objectName: 'srm_supplier_contact', filter: { id: cel`req.contact` }, outputVariable: 'after' }, + }, + { + id: 'done_contact', + type: 'update_record', + label: '变更单已生效', + config: { + objectName: 'srm_supplier_change_request', + filter: { id: cel`req.id` }, + fields: { status: 'applied', applied_at: cel`isoDatetime(now())`, after_value: snapshotOf('after', ['id', 'name', 'mobile', 'email']) }, + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e0', source: 'start', target: 'load', type: 'default' }, + { id: 'e1', source: 'load', target: 'by_type', type: 'default' }, + { + id: 'e_supplier', + source: 'by_type', + target: 'load_supplier', + type: 'conditional', + condition: P`req.change_type == 'basic' || req.change_type == 'address'`, + label: '供应商档案', + }, + { + id: 'e_account', + source: 'by_type', + target: 'load_account', + type: 'conditional', + condition: P`req.change_type == 'bank_account'`, + label: '银行账户', + }, + { + id: 'e_contact', + source: 'by_type', + target: 'load_contact', + type: 'conditional', + condition: P`req.change_type == 'contact'`, + label: '联系人', + }, + { id: 'e_basic', source: 'load_supplier', target: 'write_basic', type: 'conditional', condition: P`req.change_type == 'basic'`, label: '基本信息' }, + { id: 'e_address', source: 'load_supplier', target: 'write_address', type: 'conditional', condition: P`req.change_type == 'address'`, label: '地址' }, + { id: 'e_basic_done', source: 'write_basic', target: 'reload_supplier', type: 'default' }, + { id: 'e_address_done', source: 'write_address', target: 'reload_supplier', type: 'default' }, + { id: 'e_supplier_done', source: 'reload_supplier', target: 'done_supplier', type: 'default' }, + { id: 'e_supplier_end', source: 'done_supplier', target: 'end', type: 'default' }, + { id: 'e_retire', source: 'load_account', target: 'retire_account', type: 'default' }, + { id: 'e_create', source: 'retire_account', target: 'create_account', type: 'default' }, + { id: 'e_account_done', source: 'create_account', target: 'done_account', type: 'default' }, + { id: 'e_account_end', source: 'done_account', target: 'end', type: 'default' }, + { id: 'e_write_contact', source: 'load_contact', target: 'write_contact', type: 'default' }, + { id: 'e_reload_contact', source: 'write_contact', target: 'reload_contact', type: 'default' }, + { id: 'e_contact_done', source: 'reload_contact', target: 'done_contact', type: 'default' }, + { id: 'e_contact_end', source: 'done_contact', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/change-request/change-approval.flow.ts b/src/core/flows/change-request/change-approval.flow.ts new file mode 100644 index 0000000..0222db0 --- /dev/null +++ b/src/core/flows/change-request/change-approval.flow.ts @@ -0,0 +1,335 @@ +import { P, cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// 信息变更审批(03 §5,FR-207,BR-11):变更单新建即快照当前档案到 before_value,然后审批: +// 第 1 级采购主管(srm_buyer_lead);银行账户变更再加第 2 级应付会计(srm_ap_accountant)。 +// 第 1 级可以通过、驳回或退回修改,退回后发起人改完重新提交、重新审批;第 2 级只能通过或驳回(见下)。 +// 通过后经 srm_change_apply 写回档案;驳回不改主记录,把驳回意见写进 reject_reason 并通知发起人。 +// +// 职责分离:发起人不能审批本人的变更单,两级审批人不能是同一人。平台审批服务还没有「提交人不得 +// 审批本人单据」的开关(objectstack#25446),岗位又可能兼任,所以每级通过后由 flow 读审批记录比对: +// 撞人时这次通过不算数,通知审批人后重新开一轮同级审批(临时做法,平台支持后改为节点配置)。 +const snapshotOf = (v: string, keys: string[]) => ({ + dialect: 'cel' as const, + source: `{${keys.map((k) => `'${k}': has(${v}.${k}) ? ${v}.${k} : null`).join(', ')}}`, +}); + +const approval = (label: string, position: string, maxRevisions: number) => ({ + type: 'approval' as const, + label, + config: { + approvers: [{ type: 'position' as const, value: position }], + // 岗位没人时交给组织管理员,避免变更单卡在锁定状态 + onEmptyApprovers: 'admin_rescue' as const, + behavior: 'first_response' as const, + lockRecord: true, + approvalStatusField: 'approval_status', + maxRevisions, + }, +}); + +// 读某次审批的通过 / 驳回记录:actor_id 是审批人,comment 是审批意见 +const actionOf = (id: string, label: string, node: string, action: string, outputVariable: string) => ({ + id, + type: 'get_record' as const, + label, + config: { + objectName: 'sys_approval_action', + filter: { request_id: { dialect: 'cel' as const, source: `${node}.requestId` }, action }, + outputVariable, + }, +}); + +export const ChangeApprovalFlow: Automation.Flow = { + name: 'srm_change_approval', + label: '信息变更审批', + type: 'record_change', + status: 'active', + runAs: 'system', + + nodes: [ + { + id: 'start', + type: 'start', + label: '新建变更单', + config: { + objectName: 'srm_supplier_change_request', + triggerType: 'record-after-create', + condition: P`record.status == 'in_review'`, + }, + }, + + // 1. 快照变更前的档案(银行账号不进快照,BR-20) + { id: 'snapshot_by_type', type: 'decision', label: '按变更类型快照' }, + { + id: 'load_supplier', + type: 'get_record', + label: '读取供应商', + config: { objectName: 'srm_supplier', filter: { id: cel`record.supplier` }, outputVariable: 'supplier' }, + }, + { + id: 'snapshot_supplier', + type: 'update_record', + label: '快照供应商档案', + config: { + objectName: 'srm_supplier_change_request', + filter: { id: cel`record.id` }, + fields: { + before_value: snapshotOf('supplier', ['name', 'credit_code', 'registration_no', 'legal_representative', 'registered_capital', 'address']), + }, + }, + }, + { + id: 'load_account', + type: 'get_record', + label: '读取原银行账户', + config: { objectName: 'srm_supplier_bank_account', filter: { id: cel`record.bank_account` }, outputVariable: 'account' }, + }, + { + id: 'snapshot_account', + type: 'update_record', + label: '快照原银行账户', + config: { + objectName: 'srm_supplier_change_request', + filter: { id: cel`record.id` }, + fields: { + before_value: snapshotOf('account', ['id', 'bank_name', 'branch_name', 'account_name', 'swift_code', 'currency', 'status', 'is_default']), + }, + }, + }, + { + id: 'load_contact', + type: 'get_record', + label: '读取联系人', + config: { objectName: 'srm_supplier_contact', filter: { id: cel`record.contact` }, outputVariable: 'contact' }, + }, + { + id: 'snapshot_contact', + type: 'update_record', + label: '快照联系人', + config: { + objectName: 'srm_supplier_change_request', + filter: { id: cel`record.id` }, + fields: { before_value: snapshotOf('contact', ['id', 'name', 'mobile', 'email']) }, + }, + }, + + // 2. 第 1 级:采购主管 + { id: 'purchase_review', ...approval('采购主管审批', 'srm_buyer_lead', 3) }, + actionOf('purchase_action', '读取采购审批记录', 'purchase_review', 'approve', 'purchaseAction'), + { id: 'purchase_sod', type: 'decision', label: '采购审批人是发起人?' }, + { + id: 'purchase_self', + type: 'notify', + label: '发起人不能审批本人的变更单', + config: { + recipients: cel`purchaseAction.actor_id`, + title: '变更单 {{ record.change_no }} 需由另一位采购主管审批', + message: '你是这张变更单的发起人,你的通过不计入审批,已重新提交给采购主管。', + severity: 'warning', + sourceObject: 'srm_supplier_change_request', + sourceId: cel`record.id`, + }, + }, + { + id: 'record_purchase', + type: 'update_record', + label: '记录采购审批人', + config: { + objectName: 'srm_supplier_change_request', + filter: { id: cel`record.id` }, + fields: { purchase_approver: cel`purchaseAction.actor_id` }, + }, + }, + { id: 'needs_finance', type: 'decision', label: '银行账户变更?' }, + + // 3. 第 2 级:应付会计(只有银行账户变更)。临时做法:这一级只能通过或驳回、不能退回修改——平台把 + // 第 2 个审批节点的提交人记成恢复流程的人(第 1 级审批人),退回后只有他能重新提交,发起人反而不能 + // (objectstack#24817 已在主干修复、18.0.0 未带上)。平台修复后加回 revise 边,退回到发起人 + { id: 'finance_review', ...approval('应付会计复核', 'srm_ap_accountant', 0) }, + actionOf('finance_action', '读取财务审批记录', 'finance_review', 'approve', 'financeAction'), + { id: 'finance_sod', type: 'decision', label: '财务审批人与发起人、采购审批人不同?' }, + { + id: 'finance_self', + type: 'notify', + label: '两级审批人不能是同一人', + config: { + recipients: cel`financeAction.actor_id`, + title: '变更单 {{ record.change_no }} 需由另一位应付会计复核', + message: '银行账户变更的发起人、采购审批人和财务复核人不能是同一人(BR-11),你的通过不计入审批,已重新提交给应付会计。', + severity: 'warning', + sourceObject: 'srm_supplier_change_request', + sourceId: cel`record.id`, + }, + }, + { + id: 'record_finance', + type: 'update_record', + label: '记录财务审批人', + config: { + objectName: 'srm_supplier_change_request', + filter: { id: cel`record.id` }, + fields: { finance_approver: cel`financeAction.actor_id` }, + }, + }, + + // 4. 通过:写回档案 + { + id: 'apply', + type: 'subflow', + label: '写回档案', + config: { flowName: 'srm_change_apply', input: { recordId: cel`record.id` } }, + }, + { + id: 'notify_applied', + type: 'notify', + label: '通知发起人:已生效', + config: { + recipients: cel`record.created_by`, + title: '变更单 {{ record.change_no }} 已审批通过并生效', + message: '变更内容已写回供应商档案。', + sourceObject: 'srm_supplier_change_request', + sourceId: cel`record.id`, + }, + }, + + // 5. 驳回:不改主记录,记下驳回意见 + actionOf('purchase_reject_action', '读取采购驳回意见', 'purchase_review', 'reject', 'rejectAction'), + actionOf('finance_reject_action', '读取财务驳回意见', 'finance_review', 'reject', 'rejectAction'), + { + id: 'mark_rejected', + type: 'update_record', + label: '变更单已驳回', + config: { + objectName: 'srm_supplier_change_request', + filter: { id: cel`record.id` }, + fields: { + status: 'rejected', + reject_reason: cel`rejectAction != null && has(rejectAction.comment) ? rejectAction.comment : null`, + }, + }, + }, + { + id: 'notify_rejected', + type: 'notify', + label: '通知发起人:已驳回', + config: { + recipients: cel`record.created_by`, + title: '变更单 {{ record.change_no }} 已驳回', + message: '供应商档案未作修改,驳回原因见变更单。', + severity: 'warning', + sourceObject: 'srm_supplier_change_request', + sourceId: cel`record.id`, + }, + }, + + // 6. 退回修改:记录解锁,发起人改完后重新提交,从第 1 级重新审批 + { id: 'revise_window', type: 'approval_revise', label: '退回修改' }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e_start', source: 'start', target: 'snapshot_by_type', type: 'default' }, + { + id: 'e_snap_supplier', + source: 'snapshot_by_type', + target: 'load_supplier', + type: 'conditional', + condition: P`record.change_type == 'basic' || record.change_type == 'address'`, + label: '供应商档案', + }, + { + id: 'e_snap_account', + source: 'snapshot_by_type', + target: 'load_account', + type: 'conditional', + condition: P`record.change_type == 'bank_account'`, + label: '银行账户', + }, + { + id: 'e_snap_contact', + source: 'snapshot_by_type', + target: 'load_contact', + type: 'conditional', + condition: P`record.change_type == 'contact'`, + label: '联系人', + }, + { id: 'e_supplier', source: 'load_supplier', target: 'snapshot_supplier', type: 'default' }, + { id: 'e_account', source: 'load_account', target: 'snapshot_account', type: 'default' }, + { id: 'e_contact', source: 'load_contact', target: 'snapshot_contact', type: 'default' }, + { id: 'e_review_1', source: 'snapshot_supplier', target: 'purchase_review', type: 'default' }, + { id: 'e_review_2', source: 'snapshot_account', target: 'purchase_review', type: 'default' }, + { id: 'e_review_3', source: 'snapshot_contact', target: 'purchase_review', type: 'default' }, + + { id: 'e_p_approve', source: 'purchase_review', target: 'purchase_action', type: 'default', label: 'approve' }, + { id: 'e_p_reject', source: 'purchase_review', target: 'purchase_reject_action', type: 'default', label: 'reject' }, + { id: 'e_p_revise', source: 'purchase_review', target: 'revise_window', type: 'default', label: 'revise' }, + { id: 'e_p_sod', source: 'purchase_action', target: 'purchase_sod', type: 'default' }, + { + id: 'e_p_self', + source: 'purchase_sod', + target: 'purchase_self', + type: 'conditional', + condition: P`purchaseAction.actor_id == record.created_by`, + label: '是发起人', + }, + { + id: 'e_p_ok', + source: 'purchase_sod', + target: 'record_purchase', + type: 'conditional', + condition: P`purchaseAction.actor_id != record.created_by`, + label: '不是发起人', + }, + { id: 'e_p_again', source: 'purchase_self', target: 'purchase_review', type: 'back', label: '重新审批' }, + { id: 'e_p_next', source: 'record_purchase', target: 'needs_finance', type: 'default' }, + { + id: 'e_finance', + source: 'needs_finance', + target: 'finance_review', + type: 'conditional', + condition: P`record.change_type == 'bank_account'`, + label: '银行账户变更', + }, + { + id: 'e_no_finance', + source: 'needs_finance', + target: 'apply', + type: 'conditional', + condition: P`record.change_type != 'bank_account'`, + label: '其他变更', + }, + + { id: 'e_f_approve', source: 'finance_review', target: 'finance_action', type: 'default', label: 'approve' }, + { id: 'e_f_reject', source: 'finance_review', target: 'finance_reject_action', type: 'default', label: 'reject' }, + { id: 'e_f_sod', source: 'finance_action', target: 'finance_sod', type: 'default' }, + { + id: 'e_f_self', + source: 'finance_sod', + target: 'finance_self', + type: 'conditional', + condition: P`financeAction.actor_id == record.created_by || financeAction.actor_id == purchaseAction.actor_id`, + label: '撞人', + }, + { + id: 'e_f_ok', + source: 'finance_sod', + target: 'record_finance', + type: 'conditional', + condition: P`financeAction.actor_id != record.created_by && financeAction.actor_id != purchaseAction.actor_id`, + label: '不同的人', + }, + { id: 'e_f_again', source: 'finance_self', target: 'finance_review', type: 'back', label: '重新复核' }, + { id: 'e_f_next', source: 'record_finance', target: 'apply', type: 'default' }, + + { id: 'e_applied', source: 'apply', target: 'notify_applied', type: 'default' }, + { id: 'e_applied_end', source: 'notify_applied', target: 'end', type: 'default' }, + + { id: 'e_p_rejected', source: 'purchase_reject_action', target: 'mark_rejected', type: 'default' }, + { id: 'e_f_rejected', source: 'finance_reject_action', target: 'mark_rejected', type: 'default' }, + { id: 'e_rejected', source: 'mark_rejected', target: 'notify_rejected', type: 'default' }, + { id: 'e_rejected_end', source: 'notify_rejected', target: 'end', type: 'default' }, + + { id: 'e_resubmit', source: 'revise_window', target: 'purchase_review', type: 'back', label: 'resubmit' }, + ], +}; diff --git a/src/core/flows/index.ts b/src/core/flows/index.ts index 59ab72f..9b48e79 100644 --- a/src/core/flows/index.ts +++ b/src/core/flows/index.ts @@ -4,3 +4,5 @@ export { AdmissionRejectionFlow } from './admission/rejection.flow.js'; export { CertificateSweepFlow } from './qualification/certificate-sweep.flow.js'; export { QualificationRecalcFlow } from './qualification/qualification-recalc.flow.js'; export { BankVerifyFlow } from './bank-account/bank-verify.flow.js'; +export { ChangeApprovalFlow } from './change-request/change-approval.flow.js'; +export { ChangeApplyFlow } from './change-request/change-apply.flow.js'; diff --git a/src/core/objects/index.ts b/src/core/objects/index.ts index 1c44619..6c4caad 100644 --- a/src/core/objects/index.ts +++ b/src/core/objects/index.ts @@ -6,4 +6,5 @@ export { SupplierCertificate } from './supplier-certificate.object.js'; export { SupplierCategory } from './supplier-category.object.js'; export { SupplierApplication } from './supplier-application.object.js'; export { SupplierBankAccount } from './supplier-bank-account.object.js'; +export { SupplierChangeRequest } from './supplier-change-request.object.js'; export { Material } from './material.object.js'; diff --git a/src/core/objects/supplier-bank-account.object.ts b/src/core/objects/supplier-bank-account.object.ts index dc4be3b..94362c4 100644 --- a/src/core/objects/supplier-bank-account.object.ts +++ b/src/core/objects/supplier-bank-account.object.ts @@ -68,7 +68,6 @@ export const SupplierBankAccount = ObjectSchema.create({ label: '默认账户', group: 'account', defaultValue: false, - readonlyWhen: P`record.status != 'active'`, }), erp_bank_id: Field.text({ label: 'ERP 银行账户 ID', group: 'review', maxLength: 50, readonly: true }), }, @@ -97,7 +96,8 @@ export const SupplierBankAccount = ObjectSchema.create({ condition: P`has(record.supplier.country) && record.supplier.country == 'cn' && has(record.supplier.name) && record.account_name != record.supplier.name`, }, { - // 只有核验通过的账户能设为默认(readonlyWhen 不管新建,新建时由这条拦住) + // 只有核验通过的账户能设为默认(新建和修改都由这条拦住)。不用 readonlyWhen:信息变更单 flow 停用原账户时 + // 要同时取消默认,readonlyWhen 对系统身份的写入同样生效 name: 'default_account_active', type: 'script', severity: 'error', diff --git a/src/core/objects/supplier-change-request.object.ts b/src/core/objects/supplier-change-request.object.ts new file mode 100644 index 0000000..ce46091 --- /dev/null +++ b/src/core/objects/supplier-change-request.object.ts @@ -0,0 +1,241 @@ +import { P } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +// 信息变更单(02 §4.2,03 §5,FR-207,BR-11)。门户上线前由采购员代供应商发起:在供应商档案下新建, +// 填写变更后的值;新建即进入审批(srm_change_approval),审批通过后由 flow 写回供应商、银行账户或联系人。 +// 变更后的值放在本单的分类型字段里(表单可校验、退回后可修改),before_value / after_value 是 flow 写入的 +// json 快照,供审计对照。银行账号不进快照(BR-20:本单对采购员可读,快照没有脱敏规则)。 +// 供应商、类型和要改的对象只在新建时填写(readonlyWhen 不管新建;三个状态覆盖了所有已建记录) +const created = P`record.status == 'in_review' || record.status == 'applied' || record.status == 'rejected'`; +// 变更内容只在新建时和被退回修改期间可改;审批中平台锁定记录,审结后只读 +const proposal = P`record.approval_status != 'returned'`; + +export const SupplierChangeRequest = ObjectSchema.create({ + name: 'srm_supplier_change_request', + label: '信息变更单', + pluralLabel: '信息变更单', + icon: 'file-pen', + sharingModel: 'private', + enable: { trackHistory: true }, + stageField: 'status', + nameField: 'change_no', + + fieldGroups: [ + { key: 'request', label: '变更申请', icon: 'file-pen' }, + { key: 'basic', label: '基本信息变更', icon: 'building' }, + { key: 'address', label: '地址变更', icon: 'map-pin' }, + { key: 'bank', label: '银行账户变更', icon: 'landmark' }, + { key: 'contact', label: '联系人变更', icon: 'contact' }, + { key: 'review', label: '审批与写回', icon: 'shield-check' }, + ], + + fields: { + change_no: Field.autonumber({ label: '变更单号', group: 'request', autonumberFormat: 'CHG{YYYY}{00000}' }), + supplier: Field.lookup('srm_supplier', { label: '供应商', group: 'request', required: true, readonlyWhen: created }), + change_type: Field.select({ + label: '变更类型', + group: 'request', + required: true, + readonlyWhen: created, + options: [ + { label: '基本信息', value: 'basic' }, + { label: '银行账户', value: 'bank_account' }, + { label: '地址', value: 'address' }, + { label: '联系人', value: 'contact' }, + ], + }), + reason: Field.textarea({ label: '变更原因', group: 'request', required: true, readonlyWhen: proposal }), + + // 基本信息:只填要改的项,留空的保持原值 + new_name: Field.text({ label: '新公司全称', group: 'basic', maxLength: 200, readonlyWhen: proposal, visibleWhen: P`record.change_type == 'basic'` }), + new_credit_code: Field.text({ label: '新统一社会信用代码', group: 'basic', maxLength: 18, readonlyWhen: proposal, visibleWhen: P`record.change_type == 'basic'` }), + new_registration_no: Field.text({ label: '新注册号 / 税号', group: 'basic', maxLength: 50, readonlyWhen: proposal, visibleWhen: P`record.change_type == 'basic'` }), + new_legal_representative: Field.text({ label: '新法定代表人', group: 'basic', maxLength: 50, readonlyWhen: proposal, visibleWhen: P`record.change_type == 'basic'` }), + new_registered_capital: Field.currency({ label: '新注册资本', group: 'basic', readonlyWhen: proposal, visibleWhen: P`record.change_type == 'basic'` }), + + new_address: Field.address({ + label: '新注册地址', + group: 'address', + readonlyWhen: proposal, + requiredWhen: P`record.change_type == 'address'`, + visibleWhen: P`record.change_type == 'address'`, + }), + + // 银行账户变更 = 用一个新账户替换一个有效账户:通过后新账户直接为「有效」,原账户停用 + bank_account: Field.lookup('srm_supplier_bank_account', { + label: '原银行账户', + group: 'bank', + readonlyWhen: created, + requiredWhen: P`record.change_type == 'bank_account'`, + visibleWhen: P`record.change_type == 'bank_account'`, + }), + new_bank_name: Field.text({ + label: '新开户行', + group: 'bank', + maxLength: 100, + readonlyWhen: proposal, + requiredWhen: P`record.change_type == 'bank_account'`, + visibleWhen: P`record.change_type == 'bank_account'`, + }), + new_branch_name: Field.text({ label: '新支行', group: 'bank', maxLength: 100, readonlyWhen: proposal, visibleWhen: P`record.change_type == 'bank_account'` }), + new_account_name: Field.text({ + label: '新账户名称', + group: 'bank', + maxLength: 200, + readonlyWhen: proposal, + requiredWhen: P`record.change_type == 'bank_account'`, + visibleWhen: P`record.change_type == 'bank_account'`, + }), + // 与银行账户上的账号同样脱敏(BR-20,04 §4.2) + new_account_no: Field.text({ + label: '新银行账号', + group: 'bank', + maxLength: 50, + readonlyWhen: proposal, + requiredWhen: P`record.change_type == 'bank_account'`, + visibleWhen: P`record.change_type == 'bank_account'`, + maskingRule: 'bank_account', + requiredPermissions: ['srm_view_bank_account_no'], + }), + new_swift_code: Field.text({ label: '新 SWIFT 代码', group: 'bank', maxLength: 11, readonlyWhen: proposal, visibleWhen: P`record.change_type == 'bank_account'` }), + new_currency: Field.select({ + label: '新账户币种', + group: 'bank', + defaultValue: 'cny', + readonlyWhen: proposal, + visibleWhen: P`record.change_type == 'bank_account'`, + options: [ + { label: '人民币', value: 'cny' }, + { label: '美元', value: 'usd' }, + { label: '欧元', value: 'eur' }, + { label: '日元', value: 'jpy' }, + { label: '港币', value: 'hkd' }, + ], + }), + + contact: Field.lookup('srm_supplier_contact', { + label: '联系人', + group: 'contact', + readonlyWhen: created, + requiredWhen: P`record.change_type == 'contact'`, + visibleWhen: P`record.change_type == 'contact'`, + }), + new_contact_name: Field.text({ label: '新姓名', group: 'contact', maxLength: 50, readonlyWhen: proposal, visibleWhen: P`record.change_type == 'contact'` }), + new_mobile: Field.phone({ label: '新手机', group: 'contact', readonlyWhen: proposal, visibleWhen: P`record.change_type == 'contact'` }), + new_email: Field.email({ label: '新邮箱', group: 'contact', readonlyWhen: proposal, visibleWhen: P`record.change_type == 'contact'` }), + + status: Field.select({ + label: '状态', + group: 'review', + defaultValue: 'in_review', + readonly: true, + options: [ + { label: '审批中', value: 'in_review', kind: 'open' }, + { label: '已生效', value: 'applied', kind: 'won' }, + { label: '已驳回', value: 'rejected', kind: 'lost' }, + ], + }), + // 审批节点写入的审批结论;returned = 退回修改 + approval_status: Field.select({ + label: '审批结论', + group: 'review', + readonly: true, + options: [ + { label: '审批中', value: 'pending' }, + { label: '已批准', value: 'approved' }, + { label: '已驳回', value: 'rejected' }, + { label: '退回修改', value: 'returned' }, + { label: '已撤回', value: 'recalled' }, + ], + }), + purchase_approver: Field.user({ label: '采购审批人', group: 'review', readonly: true }), + finance_approver: Field.user({ label: '财务审批人', group: 'review', readonly: true }), + reject_reason: Field.textarea({ label: '驳回原因', group: 'review', readonly: true }), + applied_at: Field.datetime({ label: '写回时间', group: 'review', readonly: true }), + new_bank_account: Field.lookup('srm_supplier_bank_account', { label: '新银行账户', group: 'review', readonly: true }), + before_value: Field.json({ label: '变更前', group: 'review', readonly: true }), + after_value: Field.json({ label: '变更后', group: 'review', readonly: true }), + }, + + validations: [ + { + name: 'change_request_status_flow', + type: 'state_machine', + field: 'status', + message: '信息变更单状态不允许这样变更', + initialStates: ['in_review'], + transitions: { + in_review: ['applied', 'rejected'], + applied: [], + rejected: [], + }, + }, + { + name: 'basic_change_not_empty', + type: 'script', + severity: 'error', + message: '基本信息变更至少填写一项新值', + condition: P`record.change_type == 'basic' && (!has(record.new_name) || record.new_name == null || isBlank(record.new_name)) && (!has(record.new_credit_code) || record.new_credit_code == null || isBlank(record.new_credit_code)) && (!has(record.new_registration_no) || record.new_registration_no == null || isBlank(record.new_registration_no)) && (!has(record.new_legal_representative) || record.new_legal_representative == null || isBlank(record.new_legal_representative)) && (!has(record.new_registered_capital) || record.new_registered_capital == null)`, + }, + { + name: 'new_credit_code_format', + type: 'script', + severity: 'error', + message: '统一社会信用代码应为 18 位数字或大写字母', + condition: P`has(record.new_credit_code) && record.new_credit_code != null && !isBlank(record.new_credit_code) && !record.new_credit_code.matches('^[0-9A-Z]{18}$')`, + }, + { + // 引用字段为空时不读关联记录(规则读不到会拒绝写入) + name: 'bank_account_of_supplier_guard', + type: 'conditional', + message: '原银行账户必须属于这家供应商', + when: P`has(record.bank_account) && record.bank_account != null`, + then: { + name: 'bank_account_of_supplier', + type: 'script', + severity: 'error', + message: '原银行账户必须属于这家供应商', + condition: P`record.change_type == 'bank_account' && has(record.bank_account.supplier) && record.bank_account.supplier != record.supplier`, + }, + }, + { + // 引用字段为空时不读关联记录(规则读不到会拒绝写入) + name: 'bank_account_active_guard', + type: 'conditional', + events: ['insert'], + message: '只能变更「有效」的银行账户', + when: P`has(record.bank_account) && record.bank_account != null`, + then: { + // 只替换有效账户:待核验的账户还没生效,停用的账户不再收款 + name: 'bank_account_active', + type: 'script', + severity: 'error', + events: ['insert'], + message: '只能变更「有效」的银行账户', + condition: P`record.change_type == 'bank_account' && has(record.bank_account.status) && record.bank_account.status != 'active'`, + }, + }, + { + // 与银行账户自身的规则一致:中国供应商的户名必须是供应商全称;提前在提交时拦住,免得审批通过后写不进去 + name: 'new_account_name_matches_supplier', + type: 'script', + severity: 'error', + message: '新账户名称必须与供应商全称一致', + condition: P`record.change_type == 'bank_account' && has(record.supplier.country) && record.supplier.country == 'cn' && has(record.supplier.name) && has(record.new_account_name) && record.new_account_name != record.supplier.name`, + }, + { + // 引用字段为空时不读关联记录(规则读不到会拒绝写入) + name: 'contact_of_supplier_guard', + type: 'conditional', + message: '联系人必须属于这家供应商', + when: P`has(record.contact) && record.contact != null`, + then: { + name: 'contact_of_supplier', + type: 'script', + severity: 'error', + message: '联系人必须属于这家供应商', + condition: P`record.change_type == 'contact' && has(record.contact.supplier) && record.contact.supplier != record.supplier`, + }, + }, + ], +}); diff --git a/src/core/objects/supplier.object.ts b/src/core/objects/supplier.object.ts index 00584e0..6ecbd2d 100644 --- a/src/core/objects/supplier.object.ts +++ b/src/core/objects/supplier.object.ts @@ -2,6 +2,12 @@ import { P, cel } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; // 供应商(02 §4.1、§5.1)。门户岗位、绩效等级等字段随对应版本加入。 +// 关键信息在准入期间可以直接改;准入通过后只能经信息变更单修改(03 §5,FR-207): +// srm_change_apply 写回时同时写只读字段 last_change_request,校验规则 key_info_via_change_request +// 只放行带着它的修改。不用 readonlyWhen:它连系统身份的写入也挡(flow 写不回去)。 +const keyInfoFields = ['name', 'country', 'credit_code', 'registration_no', 'legal_representative', 'registered_capital', 'address']; +const changed = (k: string) => `(has(record.${k}) ? record.${k} : null) != (has(previous.${k}) ? previous.${k} : null)`; + export const Supplier = ObjectSchema.create({ name: 'srm_supplier', label: '供应商', @@ -97,6 +103,8 @@ export const Supplier = ObjectSchema.create({ readonlyWhen: P`previous.legacy_imported != true`, }), legacy_reviewed: Field.boolean({ label: '存量已复核', group: 'qualification', defaultValue: false }), + // 最近一次写回本档案的信息变更单,由 srm_change_apply 写入(v0.2) + last_change_request: Field.lookup('srm_supplier_change_request', { label: '最近变更单', group: 'registration', readonly: true }), erp_vendor_code: Field.text({ label: 'ERP 供应商编码', group: 'registration', unique: true, maxLength: 40 }), contact_count: Field.summary({ label: '联系人数', @@ -165,6 +173,19 @@ export const Supplier = ObjectSchema.create({ message: '新建供应商须从「潜在」开始准入;只有存量导入的供应商建档即为「合格」', condition: P`(record.legacy_imported == true) != (record.status == 'qualified')`, }, + { + name: 'key_info_via_change_request', + type: 'script', + severity: 'error', + events: ['update'], + message: '合格供应商的全称、国家 / 地区、信用代码、注册号、法定代表人、注册资本、注册地址请经信息变更单修改', + condition: { + dialect: 'cel', + source: + `previous.status != 'potential' && previous.status != 'qualifying' && ` + + `${changed('last_change_request')} == false && (${keyInfoFields.map(changed).join(' || ')})`, + }, + }, { name: 'credit_code_format', type: 'script', diff --git a/src/core/security/permission-sets.ts b/src/core/security/permission-sets.ts index de710e8..135a2dc 100644 --- a/src/core/security/permission-sets.ts +++ b/src/core/security/permission-sets.ts @@ -35,6 +35,9 @@ export const BuyerPermissionSet = { label: 'SRM 采购', objects: { srm_material: { ...read, allowEdit: true }, + // 代供应商发起信息变更(v0.6 前);改只限自己发起、被退回修改的变更单(对象上的 readonlyWhen)。 + // 采购员没有完整账号的读写能力,银行账户变更由品类经理或采购主管发起 + srm_supplier_change_request: { ...write }, }, fields: { 'srm_material.erp_material_no': readOnly, @@ -56,6 +59,8 @@ export const SupplierAdminPermissionSet = { srm_supplier_certificate: write, srm_supplier_category: write, srm_supplier_application: write, + // 发起信息变更;银行账户变更要填完整账号,只有持 srm_view_bank_account_no 的岗位能发起(BR-20) + srm_supplier_change_request: write, // 新建账户后为「待核验」并锁定;核验只经 srm_bank_verify,已有账户的修改走信息变更单(v0.2 M4) srm_supplier_bank_account: write, }, @@ -102,6 +107,7 @@ export const ExecutivePermissionSet = { srm_supplier_certificate: read, srm_supplier_category: read, srm_supplier_application: read, + srm_supplier_change_request: read, }, }; @@ -115,6 +121,8 @@ export const FinancePermissionSet = { srm_supplier_contact: read, srm_supplier_certificate: read, srm_supplier_bank_account: read, + // 银行账户变更的第 2 级审批(BR-11);审批权由 flow 授予 + srm_supplier_change_request: read, }, systemPermissions: ['srm_view_bank_account_no'], }; diff --git a/src/core/translations/en.ts b/src/core/translations/en.ts index 618f6b9..15f1f1e 100644 --- a/src/core/translations/en.ts +++ b/src/core/translations/en.ts @@ -64,6 +64,10 @@ export const en: TranslationData = { _validations: { supplier_status_flow: { message: 'This supplier status change is not allowed' }, credit_code_format: { message: 'The Unified Social Credit Code must be 18 digits or uppercase letters' }, + key_info_via_change_request: { + message: + 'Change the legal name, country or region, credit code, registration number, legal representative, registered capital or address of a qualified supplier through a change request', + }, initial_status_by_source: { message: 'A new supplier must start admission as Potential; only a legacy-imported supplier is created as Qualified', }, @@ -104,6 +108,7 @@ export const en: TranslationData = { is_related_party: { label: 'Related Party' }, legacy_imported: { label: 'Legacy Import' }, legacy_reviewed: { label: 'Legacy Import Reviewed' }, + last_change_request: { label: 'Last Change Request' }, erp_vendor_code: { label: 'ERP Vendor Code' }, contact_count: { label: 'Contacts' }, primary_contact_count: { label: 'Primary Contacts' }, @@ -204,6 +209,73 @@ export const en: TranslationData = { pending_verify: { label: 'Pending Verification' }, }, }, + srm_supplier_change_request: { + label: 'Change Request', + pluralLabel: 'Change Requests', + _sections: { + request: { label: 'Request' }, + basic: { label: 'Basic Information Change' }, + address: { label: 'Address Change' }, + bank: { label: 'Bank Account Change' }, + contact: { label: 'Contact Change' }, + review: { label: 'Approval and Write-back' }, + }, + _validations: { + change_request_status_flow: { message: 'This change request status change is not allowed' }, + basic_change_not_empty: { message: 'Enter at least one new value for a basic information change' }, + new_credit_code_format: { message: 'The Unified Social Credit Code must be 18 digits or uppercase letters' }, + bank_account_of_supplier: { message: 'The current bank account must belong to this supplier' }, + bank_account_active: { message: 'Only an active bank account can be changed' }, + new_account_name_matches_supplier: { message: 'The new account name must match the supplier legal name' }, + contact_of_supplier: { message: 'The contact must belong to this supplier' }, + }, + fields: { + change_no: { label: 'Change Request No.' }, + supplier: { label: 'Supplier' }, + change_type: { + label: 'Change Type', + options: { basic: 'Basic Information', bank_account: 'Bank Account', address: 'Address', contact: 'Contact' }, + }, + reason: { label: 'Reason' }, + 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' }, + new_address: { label: 'New Registered Address' }, + bank_account: { label: 'Current Bank Account' }, + 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' }, + status: { label: 'Status', options: { in_review: 'In Review', applied: 'Applied', rejected: 'Rejected' } }, + approval_status: { + label: 'Approval Result', + options: { pending: 'Pending', approved: 'Approved', rejected: 'Rejected', returned: 'Returned for Revision', recalled: 'Recalled' }, + }, + purchase_approver: { label: 'Purchasing Approver' }, + finance_approver: { label: 'Finance Approver' }, + reject_reason: { label: 'Rejection Reason' }, + applied_at: { label: 'Applied At' }, + new_bank_account: { label: 'New Bank Account' }, + before_value: { label: 'Before' }, + after_value: { label: 'After' }, + }, + _views: { + default: { label: 'All Change Requests' }, + in_review: { label: 'In Review' }, + returned: { label: 'Returned for Revision' }, + }, + }, srm_supplier_category: { label: 'Category Qualification', pluralLabel: 'Category Qualifications', @@ -310,6 +382,7 @@ export const en: TranslationData = { nav_application: { label: 'Admission Applications' }, nav_certificate: { label: 'Certificates' }, nav_bank_account: { label: 'Bank Accounts' }, + nav_change_request: { label: 'Change Requests' }, group_master_data: { label: 'Master Data' }, nav_category: { label: 'Purchasing Categories' }, nav_material: { label: 'Materials' }, diff --git a/src/core/views/index.ts b/src/core/views/index.ts index a0c97ad..df65658 100644 --- a/src/core/views/index.ts +++ b/src/core/views/index.ts @@ -4,4 +4,5 @@ export { SupplierViews } from './supplier.view.js'; export { SupplierCertificateViews } from './supplier-certificate.view.js'; export { SupplierApplicationViews } from './supplier-application.view.js'; export { SupplierBankAccountViews } from './supplier-bank-account.view.js'; +export { SupplierChangeRequestViews } from './supplier-change-request.view.js'; export { MaterialViews } from './material.view.js'; diff --git a/src/core/views/supplier-change-request.view.ts b/src/core/views/supplier-change-request.view.ts new file mode 100644 index 0000000..dd713e3 --- /dev/null +++ b/src/core/views/supplier-change-request.view.ts @@ -0,0 +1,43 @@ +import { defineView } from '@objectstack/spec'; + +const data = { provider: 'object' as const, object: 'srm_supplier_change_request' }; +const columns = [ + { field: 'change_no' }, + { field: 'supplier' }, + { field: 'change_type' }, + { field: 'status' }, + { field: 'approval_status' }, + { field: 'created_by' }, + { field: 'applied_at' }, +]; + +export const SupplierChangeRequestViews = defineView({ + list: { + label: '全部变更单', + type: 'grid', + data, + columns, + sort: [{ field: 'change_no', order: 'desc' }], + }, + + listViews: { + in_review: { + label: '审批中', + type: 'grid', + data, + columns, + filter: [{ field: 'status', operator: 'equals', value: 'in_review' }], + }, + // 被退回、等发起人修改后重新提交的变更单 + returned: { + label: '退回修改', + type: 'grid', + data, + columns, + filter: [ + { field: 'status', operator: 'equals', value: 'in_review' }, + { field: 'approval_status', operator: 'equals', value: 'returned' }, + ], + }, + }, +}); diff --git a/test/change-request.test.ts b/test/change-request.test.ts new file mode 100644 index 0000000..095512d --- /dev/null +++ b/test/change-request.test.ts @@ -0,0 +1,314 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import config from '../objectstack.config'; +import { Supplier, SupplierBankAccount, SupplierChangeRequest, SupplierContact } from '../src/core/objects'; + +// v0.2 M4 信息变更单(02 §4.2、03 §5,FR-207,BR-11):采购员代供应商发起 → 采购主管审批 → +// 银行账户变更再经应付会计复核 → 写回档案;驳回不改主记录;退回后修改、重新提交。 +// 测试数据全部在这里生成(示例公司名、示例银行),不依赖外部样本。 +describe('supplier change request', () => { + let stack: VerifyStack; + let seq = 0; + + const person = async (...positions: string[]) => { + const token = await stack.signUp(`chg-${positions.join('-')}-${++seq}@example.com`); + const context = await stack.contextFor(token); + const org = context.tenantId ? { organization_id: String(context.tenantId) } : {}; + await stack.seed('sys_user_position', positions.map((position) => ({ user_id: String(context.userId), position, ...org }))); + return { token, id: String(context.userId) }; + }; + const row = async (object: string, id: string) => (await stack.rows(object, { id }))[0]!; + const request = (id: string) => row(SupplierChangeRequest.name, id); + + // 已合格的供应商(存量导入建档即为合格),带一个有效的默认账户和一位联系人 + const qualifiedSupplier = async () => { + const n = ++seq; + const supplier = (await stack.hooks.run( + Supplier.name, + 'insert', + { + name: `示例变更测试有限公司 ${n}`, + credit_code: `91000000MA0CHG${String(n).padStart(4, '0')}`, + legal_representative: '张示例', + address: { street: '示例路 1 号', city: '苏州', country: 'CN' }, + legacy_imported: true, + }, + { system: true }, + )) as { id: string; name: string; credit_code: string }; + const account = (await stack.hooks.run( + SupplierBankAccount.name, + 'insert', + { + supplier: supplier.id, + bank_name: '示例银行', + branch_name: '示例支行', + account_name: supplier.name, + account_no: `62220000${String(n).padStart(8, '0')}`, + status: 'active', + is_default: true, + }, + { system: true }, + )) as { id: string }; + const contact = (await stack.hooks.run( + SupplierContact.name, + 'insert', + { supplier: supplier.id, name: '李示例', email: `li${n}@example.com`, mobile: '13800000000' }, + { system: true }, + )) as { id: string }; + return { ...supplier, account: account.id, contact: contact.id }; + }; + + const submit = async (as: string, doc: Record) => + (await stack.hooks.run(SupplierChangeRequest.name, 'insert', { reason: '供应商来函(示例)', ...doc }, { as })) as { id: string }; + + const pending = 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 pending(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!; + }; + + let buyer: { token: string; id: string }; + let manager: { token: string; id: string }; + let lead: { token: string; id: string }; + let accountant: { token: string; id: string }; + let otherLead: { token: string; id: string }; + + beforeAll(async () => { + stack = await bootStack(config); + buyer = await person('srm_buyer'); + manager = await person('srm_category_manager'); + lead = await person('srm_buyer_lead'); + accountant = await person('srm_ap_accountant'); + // 审批节点开启时按岗位解析审批人,第二位采购主管要提前任命 + otherLead = await person('srm_buyer_lead'); + }); + + afterAll(async () => { + await stack?.stop(); + }); + + it('snapshots the current record and applies a basic change after one approval', async () => { + const supplier = await qualifiedSupplier(); + const change = await submit(buyer.token, { + supplier: supplier.id, + change_type: 'basic', + new_name: `${supplier.name}(更名)`, + new_legal_representative: '王示例', + }); + const before = await request(change.id); + expect(before.status).toBe('in_review'); + expect(before.approval_status).toBe('pending'); + expect(before.before_value).toMatchObject({ name: supplier.name, legal_representative: '张示例' }); + + await decide(lead.token, change.id, 'approve'); + + const after = await request(change.id); + expect(after).toMatchObject({ status: 'applied', purchase_approver: lead.id }); + expect(after.applied_at).toBeTruthy(); + expect(after.after_value).toMatchObject({ name: `${supplier.name}(更名)`, legal_representative: '王示例' }); + const updated = await row(Supplier.name, supplier.id); + expect(updated).toMatchObject({ name: `${supplier.name}(更名)`, legal_representative: '王示例', last_change_request: change.id }); + // 留空的项保持原值 + expect(updated.credit_code).toBe(supplier.credit_code); + }); + + it('leaves the supplier untouched and records the reason when the change is rejected', async () => { + const supplier = await qualifiedSupplier(); + const change = await submit(buyer.token, { supplier: supplier.id, change_type: 'basic', new_name: '示例不该生效有限公司' }); + await decide(lead.token, change.id, 'reject', '未见工商变更通知书'); + expect(await request(change.id)).toMatchObject({ status: 'rejected', reject_reason: '未见工商变更通知书' }); + expect((await row(Supplier.name, supplier.id)).name).toBe(supplier.name); + }); + + it('lets the submitter revise a returned change and resubmit it', async () => { + const supplier = await qualifiedSupplier(); + const change = await submit(buyer.token, { supplier: supplier.id, change_type: 'basic', new_legal_representative: '赵示例' }); + const first = await decide(lead.token, change.id, 'revise', '法定代表人姓名有误'); + expect((await request(change.id)).approval_status).toBe('returned'); + + await stack.hooks.run(SupplierChangeRequest.name, 'update', { id: change.id, new_legal_representative: '钱示例' }, { as: buyer.token }); + const res = await stack.apiAs(buyer.token, 'POST', `/approvals/requests/${first.id}/resubmit`, {}); + expect(res.status, await res.clone().text()).toBe(200); + expect((await request(change.id)).approval_status).toBe('pending'); + + await decide(lead.token, change.id, 'approve'); + expect((await row(Supplier.name, supplier.id)).legal_representative).toBe('钱示例'); + expect((await request(change.id)).status).toBe('applied'); + }); + + it('does not let the submitter edit the change while it is under review', async () => { + const supplier = await qualifiedSupplier(); + const change = await submit(buyer.token, { supplier: supplier.id, change_type: 'basic', new_name: '示例审批中有限公司' }); + await stack.hooks + .run(SupplierChangeRequest.name, 'update', { id: change.id, new_name: '示例偷改有限公司' }, { as: buyer.token }) + .catch(() => undefined); + expect((await request(change.id)).new_name).toBe('示例审批中有限公司'); + }); + + it('applies an address change', async () => { + const supplier = await qualifiedSupplier(); + const address = { street: '示例大道 88 号', city: '成都', country: 'CN' }; + const change = await submit(buyer.token, { supplier: supplier.id, change_type: 'address', new_address: address }); + await decide(lead.token, change.id, 'approve'); + expect((await row(Supplier.name, supplier.id)).address).toMatchObject(address); + expect((await request(change.id)).after_value).toMatchObject({ address }); + }); + + it('applies a contact change', async () => { + const supplier = await qualifiedSupplier(); + const change = await submit(buyer.token, { + supplier: supplier.id, + change_type: 'contact', + contact: supplier.contact, + new_email: 'new-contact@example.com', + }); + await decide(lead.token, change.id, 'approve'); + expect(await row(SupplierContact.name, supplier.contact)).toMatchObject({ name: '李示例', email: 'new-contact@example.com' }); + }); + + it('replaces a bank account only after purchasing and finance both approve', async () => { + const supplier = await qualifiedSupplier(); + const change = await submit(manager.token, { + supplier: supplier.id, + change_type: 'bank_account', + bank_account: supplier.account, + new_bank_name: '示例新银行', + new_branch_name: '示例新支行', + new_account_name: supplier.name, + new_account_no: '6228888800001234', + }); + // 银行账号不进快照 + expect(JSON.stringify((await request(change.id)).before_value)).not.toContain('6222'); + + await decide(lead.token, change.id, 'approve'); + expect(await request(change.id)).toMatchObject({ status: 'in_review', purchase_approver: lead.id }); + expect((await row(SupplierBankAccount.name, supplier.account)).status).toBe('active'); + + await decide(accountant.token, change.id, 'approve'); + const done = await request(change.id); + expect(done).toMatchObject({ status: 'applied', purchase_approver: lead.id, finance_approver: accountant.id }); + expect(await row(SupplierBankAccount.name, supplier.account)).toMatchObject({ status: 'inactive', is_default: false }); + const fresh = await row(SupplierBankAccount.name, String(done.new_bank_account)); + expect(fresh).toMatchObject({ + supplier: supplier.id, + bank_name: '示例新银行', + account_no: '6228888800001234', + status: 'active', + verified_by: accountant.id, + is_default: true, + }); + expect(JSON.stringify(done.after_value)).not.toContain('6228888800001234'); + }); + + it('keeps the old bank account when finance rejects', async () => { + const supplier = await qualifiedSupplier(); + const change = await submit(manager.token, { + supplier: supplier.id, + change_type: 'bank_account', + bank_account: supplier.account, + new_bank_name: '示例可疑银行', + new_account_name: supplier.name, + new_account_no: '6228888800009999', + }); + await decide(lead.token, change.id, 'approve'); + await decide(accountant.token, change.id, 'reject', '电话核实供应商未变更账户'); + expect(await request(change.id)).toMatchObject({ status: 'rejected', reject_reason: '电话核实供应商未变更账户' }); + expect(await row(SupplierBankAccount.name, supplier.account)).toMatchObject({ status: 'active', is_default: true }); + expect(await stack.rows(SupplierBankAccount.name, { supplier: supplier.id })).toHaveLength(1); + }); + + // 临时:第 2 级不能退回修改(objectstack#24817 已在主干修复、18.0.0 未带上),只能通过或驳回 + it('does not let finance send a bank change back', async () => { + const supplier = await qualifiedSupplier(); + const change = await submit(manager.token, { + supplier: supplier.id, + change_type: 'bank_account', + bank_account: supplier.account, + new_bank_name: '示例新银行', + new_account_name: supplier.name, + new_account_no: '6228888800005555', + }); + await decide(lead.token, change.id, 'approve'); + const req = await pending(change.id); + const res = await stack.apiAs(accountant.token, 'POST', `/approvals/requests/${req!.id}/revise`, { comment: '请补支行' }); + expect(res.status).not.toBe(200); + expect(await request(change.id)).toMatchObject({ status: 'in_review', approval_status: 'pending' }); + }); + + it('does not count an approval by the same person on both levels (BR-11)', async () => { + const both = await person('srm_buyer_lead', 'srm_ap_accountant'); + const supplier = await qualifiedSupplier(); + const change = await submit(manager.token, { + supplier: supplier.id, + change_type: 'bank_account', + bank_account: supplier.account, + new_bank_name: '示例新银行', + new_account_name: supplier.name, + new_account_no: '6228888800007777', + }); + await decide(both.token, change.id, 'approve'); + await decide(both.token, change.id, 'approve'); + // 第二次通过不算数,重新开一轮财务复核 + expect(await request(change.id)).toMatchObject({ status: 'in_review', approval_status: 'pending', purchase_approver: both.id }); + expect((await row(SupplierBankAccount.name, supplier.account)).status).toBe('active'); + await decide(accountant.token, change.id, 'approve'); + expect(await request(change.id)).toMatchObject({ status: 'applied', finance_approver: accountant.id }); + }); + + it('does not count an approval by the submitter', async () => { + const supplier = await qualifiedSupplier(); + const change = await submit(lead.token, { supplier: supplier.id, change_type: 'basic', new_legal_representative: '孙示例' }); + await decide(lead.token, change.id, 'approve'); + expect(await request(change.id)).toMatchObject({ status: 'in_review', approval_status: 'pending' }); + expect((await row(Supplier.name, supplier.id)).legal_representative).toBe('张示例'); + await decide(otherLead.token, change.id, 'approve'); + expect(await request(change.id)).toMatchObject({ status: 'applied', purchase_approver: otherLead.id }); + }); + + it('refuses a bank change to another supplier\'s or a non-active account', async () => { + const a = await qualifiedSupplier(); + const b = await qualifiedSupplier(); + const bank = { change_type: 'bank_account', new_bank_name: '示例新银行', new_account_no: '6228888800003333' }; + await expect(submit(manager.token, { supplier: a.id, bank_account: b.account, new_account_name: a.name, ...bank })).rejects.toBeTruthy(); + const pendingAccount = (await stack.hooks.run( + SupplierBankAccount.name, + 'insert', + { supplier: a.id, bank_name: '示例银行', account_name: a.name, account_no: '6222000011112222' }, + { system: true }, + )) as { id: string }; + await expect(submit(manager.token, { supplier: a.id, bank_account: pendingAccount.id, new_account_name: a.name, ...bank })).rejects.toBeTruthy(); + await expect(submit(manager.token, { supplier: a.id, bank_account: a.account, new_account_name: '别的示例公司', ...bank })).rejects.toBeTruthy(); + // 采购员读写不了完整账号,不能发起银行账户变更(BR-20) + await expect(submit(buyer.token, { supplier: a.id, bank_account: a.account, new_account_name: a.name, ...bank })).rejects.toBeTruthy(); + }); + + it('refuses an empty basic change', async () => { + const supplier = await qualifiedSupplier(); + await expect(submit(buyer.token, { supplier: supplier.id, change_type: 'basic' })).rejects.toBeTruthy(); + }); + + it('does not let anyone edit the key information of a qualified supplier directly', async () => { + const supplier = await qualifiedSupplier(); + await expect( + stack.hooks.run(Supplier.name, 'update', { id: supplier.id, name: '示例直接改名有限公司' }, { as: manager.token }), + ).rejects.toMatchObject({ code: 'VALIDATION_FAILED' }); + // 资料字段照常可改 + await stack.hooks.run(Supplier.name, 'update', { id: supplier.id, short_name: '示例简称' }, { as: manager.token }); + expect(await row(Supplier.name, supplier.id)).toMatchObject({ name: supplier.name, short_name: '示例简称' }); + }); + + it('still lets a supplier under admission be corrected directly', async () => { + const draft = (await stack.hooks.run( + Supplier.name, + 'insert', + { name: '示例准入中有限公司', credit_code: '91000000MA0CHGPOT1' }, + { as: manager.token }, + )) as { id: string }; + await stack.hooks.run(Supplier.name, 'update', { id: draft.id, name: '示例准入中(更正)有限公司' }, { as: manager.token }); + expect((await row(Supplier.name, draft.id)).name).toBe('示例准入中(更正)有限公司'); + }); +}); diff --git a/test/permissions.test.ts b/test/permissions.test.ts index 8497b8c..7a684b8 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -69,6 +69,7 @@ describe('internal permissions', () => { "srm_supplier_bank_account": "R", "srm_supplier_category": "R", "srm_supplier_certificate": "R", + "srm_supplier_change_request": "R", "srm_supplier_contact": "R", } `); @@ -85,6 +86,7 @@ describe('internal permissions', () => { "srm_supplier_bank_account": "RCE", "srm_supplier_category": "RCE", "srm_supplier_certificate": "RCE", + "srm_supplier_change_request": "RCE", "srm_supplier_contact": "RCED", } `);