From 29e2e12824fc78ccacdc8a554b1fdb68bde2e97b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 15:46:30 +0000 Subject: [PATCH] feat(core): category manager level for dispositions raised by a non-CM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit srm_disposition_approval treated initiation as the category manager level, so a disposition raised by a buyer lead (who also holds srm_supplier_admin) went straight to the procurement director and skipped the CM level of the 03 §12 matrix. Like srm_contract_approval, the flow now looks up whether the initiator holds srm_category_manager; if not, a category manager approves first (recorded in the new read-only category_approver field), and the later levels' BR-11 checks also compare against that approver. The CM level is the first approval node on that path, so it can send the disposition back; the director then sits on a second node that can only approve or reject until objectstack#24817 ships. CM-initiated dispositions are unchanged. Docs: 03 §5 / §12, 02 disposition fields, GL-08 6.2.1 / 6.3.1 / 6.3.2 / ch. 7 / appendix A, ZC-02 and QP-01 appendix C notes. Closes #47 Claude-Session: https://claude.ai/code/session_01Ut39cZ4zdvuJmqenJzvnKh Co-authored-by: Claude --- docs/design/02-domain-model.md | 3 +- docs/design/03-processes-and-automation.md | 18 +- governance/templates/core/GL-08.md | 10 +- governance/templates/core/QP-01.md | 2 +- governance/templates/core/ZC-02.md | 2 + .../disposition/disposition-approval.flow.ts | 236 +++++++++++------- .../objects/supplier-disposition.object.ts | 4 +- src/core/translations/en.ts | 1 + test/disposition.test.ts | 65 ++++- 9 files changed, 239 insertions(+), 102 deletions(-) diff --git a/docs/design/02-domain-model.md b/docs/design/02-domain-model.md index c9af898..e32230c 100644 --- a/docs/design/02-domain-model.md +++ b/docs/design/02-domain-model.md @@ -365,7 +365,8 @@ upsert 写入,结构不变。 (`freeze` / `unfreeze` / `eliminate` / `blacklist` / `remove_blacklist`)、 `scope`(`all` 全部品类 / `category` 指定品类,只有冻结和解冻可以指定品类)、`categories`(多值 lookup 品类, 指定品类时必填)、`reason`(必填)、`effective_date`(默认当天)、`status`(`in_review` 审批中 / `applied` 已执行 / -`rejected` 已驳回)、`approval_status`(审批节点写入)、只读的 `director_approver` / `gm_approver` / +`rejected` 已驳回)、`approval_status`(审批节点写入)、只读的 `category_approver`(发起人不是品类经理时才有 +这一级,[03 §5](./03-processes-and-automation.md#5-p2-信息变更与供应商处置))/ `director_approver` / `gm_approver` / `compliance_approver`、`reject_reason`、`applied_at`。 新建即进入审批,供应商、动作、范围和品类只在新建时填写,原因和生效日期在被退回修改期间可改。审批通过即执行, diff --git a/docs/design/03-processes-and-automation.md b/docs/design/03-processes-and-automation.md index 4beab6a..adcb0e0 100644 --- a/docs/design/03-processes-and-automation.md +++ b/docs/design/03-processes-and-automation.md @@ -224,13 +224,15 @@ ERP 侧的银行账户由财务按 SRM 的审批记录维护(ERP 集成为预 实现(flow `srm_disposition_approval` / `srm_disposition_apply`): -- 品类经理一级就是发起:持 `srm_supplier_admin` 的岗位(品类经理、采购主管)新建处置单即提交审批, - 审批节点依次为采购总监、总经理(淘汰 / 黑名单 / 解除黑名单)、内控 `srm_procurement_compliance` - (仅拉入黑名单,串行会签)。 -- 采购总监可以通过、驳回或退回修改;总经理和内控只能通过或驳回——第 2 个审批节点退回后发起人无法重新提交 +- 持 `srm_supplier_admin` 的岗位(品类经理、采购主管)新建处置单即提交审批。第 1 级品类经理:发起人持 + `srm_category_manager` 岗位时发起即是这一级;不持有(如采购主管发起)时先由 `position = srm_category_manager` + 审批,审批人记在 `category_approver`。之后依次为采购总监、总经理(淘汰 / 黑名单 / 解除黑名单)、内控 + `srm_procurement_compliance`(仅拉入黑名单,串行会签)。 +- 第一个审批节点(品类经理发起时的采购总监,采购主管发起时的品类经理)可以通过、驳回或退回修改,发起人改完 + 重新提交、从这一级重新审批;后面几级只能通过或驳回——第 2 个审批节点退回后发起人无法重新提交 (objectstack#24817 已在主干修复、未发版),发版后加回退回。 -- 职责分离同变更单(BR-11):发起人不能审批本人的处置单,各级审批人不能是同一人;撞人时这次通过不算数, - 通知审批人并重新开一轮同级审批。 +- 职责分离同变更单(BR-11):发起人不能审批本人的处置单,各级审批人(含品类经理一级)不能是同一人;撞人时 + 这次通过不算数,通知审批人并重新开一轮同级审批。 - 执行以系统身份改供应商状态和品类资格(结果见 [02 §4.2](./02-domain-model.md#42-生命周期) 处置单), 处置单置为「已执行」,通知发起人并提醒在 ERP 同步冻结标志;驳回不改供应商,记下驳回意见。 - 通知供应商与门户账号回收随门户(v0.6);冻结后不能新建合同由合同的校验规则负责(v0.3 C1)。 @@ -402,8 +404,8 @@ GL-02 附录 A 按本表编写,制度里的岗位称谓经 `governance/org.yam | 合同 | 全部 | 法务(`field: legal_reviewer`) | 按定标同档金额审批(框架协议按年度预计金额);起草人或提交人是品类经理时,品类经理一级由采购总监审批(§6) | | 串行 | | 银行账户新增 / 变更 | 全部 | 采购主管 | 应付会计 | — | 串行,两级不同岗位 | | 其他信息变更 | 全部 | 采购主管 | — | — | — | -| 冻结 / 解冻 | 全部 | 品类经理 | 采购总监 | — | 串行 | -| 淘汰 / 黑名单 / 解除黑名单 | 全部 | 品类经理 | 采购总监 | 总经理 | 串行;黑名单由内控会签 | +| 冻结 / 解冻 | 全部 | 品类经理(品类经理发起即是这一级) | 采购总监 | — | 串行 | +| 淘汰 / 黑名单 / 解除黑名单 | 全部 | 品类经理(同上) | 采购总监 | 总经理 | 串行;黑名单由内控会签 | | 绩效发布 | 全部 | 采购总监 | — | — | — | | SCAR 措施审核 | 全部 | SQE(`field: sqe`) | — | — | 可退回修改 | diff --git a/governance/templates/core/GL-08.md b/governance/templates/core/GL-08.md index 4049896..094af44 100644 --- a/governance/templates/core/GL-08.md +++ b/governance/templates/core/GL-08.md @@ -105,7 +105,7 @@ f) 冒用身份、伪造材料变更收款账户或以其他方式骗取付款 ## 6.2 处置的发起 -6.2.1 处置一律通过 SRM 系统的处置单办理。{{org.position.category_manager}}根据第 6.1 条的条件,或相关部门的书面建议,在 SRM 系统中新建处置单,写明处置动作、范围、原因和生效日期,并上传证据材料;处置单新建即提交审批。 +6.2.1 处置一律通过 SRM 系统的处置单办理。{{org.position.category_manager}}根据第 6.1 条的条件,或相关部门的书面建议,在 SRM 系统中新建处置单({{org.position.buyer_lead}}也可以新建,按第 6.3.1 条先经{{org.position.category_manager}}审批),写明处置动作、范围、原因和生效日期,并上传证据材料;处置单新建即提交审批。 6.2.2 处置动作应与供应商的当前状态相符: @@ -127,9 +127,9 @@ e) 解除黑名单,适用于黑名单内的供应商。 ## 6.3 审批 -6.3.1 处置按附录 A 的权限审批:冻结、解冻由{{org.position.category_manager}}发起,{{org.position.procurement_director}}审批;淘汰、解除黑名单再经{{org.position.general_manager}}审批;列入黑名单经{{org.position.general_manager}}审批后,由{{org.position.procurement_compliance}}会签。 +6.3.1 处置按附录 A 的权限审批:冻结、解冻由{{org.position.category_manager}}发起,{{org.position.procurement_director}}审批;处置单由{{org.position.buyer_lead}}发起的,先经{{org.position.category_manager}}审批,再按本款审批;淘汰、解除黑名单再经{{org.position.general_manager}}审批;列入黑名单经{{org.position.general_manager}}审批后,由{{org.position.procurement_compliance}}会签。 -6.3.2 {{org.position.procurement_director}}可以通过、驳回或退回修改;其后各级只能通过或驳回。审批意见应写明理由,驳回意见记入处置单。 +6.3.2 第一级审批人({{org.position.category_manager}}发起时为{{org.position.procurement_director}},{{org.position.buyer_lead}}发起时为{{org.position.category_manager}})可以通过、驳回或退回修改;其后各级只能通过或驳回。审批意见应写明理由,驳回意见记入处置单。 6.3.3 处置单的发起人不得审批本人发起的处置单,各级审批人不得为同一人。审批人与发起人或前级审批人为同一人的,该次审批不予认可,由同级的其他人员重新审批;同级没有其他人员的,应先另行任命。 @@ -198,7 +198,7 @@ f) 解除黑名单:供应商状态转为「淘汰」,不恢复任何供货 | 处置经处置单办理 | 6.2.1 | 供应商状态和供货资格只能由处置单的执行流程修改;新建处置单即提交审批 | | 处置动作与供应商状态相符 | 6.2.2、6.2.3 | 新建处置单时校验动作与供应商当前状态;只有冻结、解冻可以指定物资类别,指定时必须选择物资类别 | | 同一供应商只有一张审批中的处置单 | 6.2.4 | 供应商已有审批中的处置单时,不能再新建 | -| 按处置动作分级审批、黑名单会签 | 6.3.1 | 处置审批流程:{{org.position.procurement_director}}审批;淘汰、列入和解除黑名单加{{org.position.general_manager}};列入黑名单再经{{org.position.procurement_compliance}}会签 | +| 按处置动作分级审批、黑名单会签 | 6.3.1 | 处置审批流程:发起人不是{{org.position.category_manager}}的,先由{{org.position.category_manager}}审批;{{org.position.procurement_director}}审批;淘汰、列入和解除黑名单加{{org.position.general_manager}};列入黑名单再经{{org.position.procurement_compliance}}会签 | | 发起人不审批本人的单据、各级审批人不同 | 6.3.3 | 每级通过后比对审批人与发起人、前级审批人,相同的该次通过不算数,通知审批人并重新审批 | | 审批通过后自动执行 | 6.4.1 | 审批通过后由系统更新供应商状态和供货资格,记录冻结前的状态和冻结范围,处置单置为「已执行」 | | 解冻只恢复同一范围冻结的资格 | 6.4.1 c) | 按冻结时记录的范围和冻结前状态恢复;淘汰、列入黑名单时清除冻结记录 | @@ -226,7 +226,7 @@ SRM 系统上线后,上述记录以系统电子记录为主。 | 列入黑名单 | {{org.position.category_manager}} | {{org.position.procurement_director}} | {{org.position.general_manager}} | {{org.position.procurement_compliance}} | | 解除黑名单 | {{org.position.category_manager}} | {{org.position.procurement_director}} | {{org.position.general_manager}} | — | -审批依次进行;发起人不得审批本人发起的处置单,各级审批人不得为同一人。审批权限以{{doc.ZC-02.title}}和公司现行授权文件为准。 +处置单由{{org.position.buyer_lead}}发起的,在第 1 级之前先经{{org.position.category_manager}}审批。审批依次进行;发起人不得审批本人发起的处置单,各级审批人不得为同一人。审批权限以{{doc.ZC-02.title}}和公司现行授权文件为准。 # 附录 B 处置动作与执行结果 diff --git a/governance/templates/core/QP-01.md b/governance/templates/core/QP-01.md index 2bacfda..7afc2ec 100644 --- a/governance/templates/core/QP-01.md +++ b/governance/templates/core/QP-01.md @@ -272,7 +272,7 @@ SRM 系统上线后,上述记录以系统电子记录为主,保存期限相 | 收款账户新增 / 变更 | 全部 | {{org.position.buyer_lead}} → {{org.position.ap_accountant}}(两级不得为同一人) | | 绩效结果发布 | 每期 | {{org.position.procurement_director}} | -审批权限以{{doc.ZC-02.title}}和公司现行授权文件为准;同一事项的发起人不得作为其审批人。物资类别只决定是否会签{{org.position.quality_manager}}、现场审核频次和绩效评价周期(附录 A),不再区分审批层级。 +冻结、解冻、淘汰、列入和解除黑名单由{{org.position.buyer_lead}}发起的,先经{{org.position.category_manager}}审批,再按上表审批。审批权限以{{doc.ZC-02.title}}和公司现行授权文件为准;同一事项的发起人不得作为其审批人。物资类别只决定是否会签{{org.position.quality_manager}}、现场审核频次和绩效评价周期(附录 A),不再区分审批层级。 # 附录 D 绩效评价指标(示例,A 类) diff --git a/governance/templates/core/ZC-02.md b/governance/templates/core/ZC-02.md index 60140c1..19df73a 100644 --- a/governance/templates/core/ZC-02.md +++ b/governance/templates/core/ZC-02.md @@ -46,6 +46,8 @@ f) 各级审批均在 SRM 系统中进行,系统审批记录为正式记录。 | 绩效结果发布 | 每期 | {{org.position.procurement_director}} | {{doc.QP-01.number}} 6.7 | | 整改措施审核 | 全部 | {{org.position.sqe}}(整改单未指定 SQE 时为{{org.position.quality_manager}}) | {{doc.QP-04.number}} 6.4;{{doc.GL-07.number}} 6.4 | +冻结、解冻、淘汰、列入和解除黑名单由{{org.position.buyer_lead}}发起的,先经{{org.position.category_manager}}审批,再按上表审批({{doc.GL-08.number}} 6.3.1)。 + # 3 采购与寻源 | 事项 | 条件 | 审批 | 依据 | diff --git a/src/core/flows/disposition/disposition-approval.flow.ts b/src/core/flows/disposition/disposition-approval.flow.ts index 253dfc0..7308859 100644 --- a/src/core/flows/disposition/disposition-approval.flow.ts +++ b/src/core/flows/disposition/disposition-approval.flow.ts @@ -1,9 +1,10 @@ import { P, cel } from '@objectstack/spec'; import type * as Automation from '@objectstack/spec/automation'; -// 供应商处置审批(03 §5、§12,FR-208,BR-11):品类经理新建处置单即发起审批,发起即是品类经理一级。 -// 冻结 / 解冻:采购总监;淘汰 / 黑名单 / 解除黑名单:再加总经理;拉入黑名单最后由内控会签。 -// 采购总监可以通过、驳回或退回修改;后面几级只能通过或驳回(见下)。全部通过后经 srm_disposition_apply +// 供应商处置审批(03 §5、§12,FR-208,BR-11):持 srm_supplier_admin 的岗位新建处置单即发起审批。 +// 第 1 级品类经理:发起人是品类经理时发起即是这一级;不是(如采购主管)时先由品类经理审批。 +// 冻结 / 解冻:再经采购总监;淘汰 / 黑名单 / 解除黑名单:再加总经理;拉入黑名单最后由内控会签。 +// 第一个审批节点可以通过、驳回或退回修改;后面几级只能通过或驳回(见下)。全部通过后经 srm_disposition_apply // 执行;驳回不改供应商,把驳回意见写进 reject_reason 并通知发起人。 // // 职责分离:发起人不能审批本人的处置单,各级审批人不能是同一人。平台审批服务还没有「提交人不得 @@ -32,7 +33,7 @@ const actionOf = (id: string, label: string, node: string, action: string, outpu label, config: { objectName: 'sys_approval_action', - filter: { request_id: { dialect: 'cel' as const, source: `${node}.requestId` }, action }, + filter: { request_id: cel$(`${node}.requestId`), action }, outputVariable, }, }); @@ -51,16 +52,29 @@ const self = (id: string, label: string, actor: string, title: string, message: }, }); -const recordApprover = (id: string, label: string, field: string, actor: string) => ({ - id, - type: 'update_record' as const, - label, - config: { - objectName: 'srm_supplier_disposition', - filter: { id: cel`record.id` }, - fields: { [field]: cel$(`${actor}.actor_id`) }, +// 记下这一级的审批人:写到处置单上,同时留在变量里给后面几级比对 +const recordApprover = (id: string, label: string, field: string, actor: string, variable: string) => [ + { + id, + type: 'update_record' as const, + label, + config: { + objectName: 'srm_supplier_disposition', + filter: { id: cel`record.id` }, + fields: { [field]: cel$(`${actor}.actor_id`) }, + }, }, -}); + { id: `${id}_var`, type: 'assignment' as const, label: `${label}(变量)`, config: { assignments: { [variable]: cel$(`${actor}.actor_id`) } } }, +]; + +// 审批人撞人:发起人或前面各级的审批人(没经过的级别变量是空串,不会撞上) +const collides = (actor: string, earlier: string[]) => + [`${actor}.actor_id == record.created_by`, ...earlier.map((v) => `${actor}.actor_id == ${v}`)].join(' || '); +const sodEdges = (prefix: string, decision: string, actor: string, earlier: string[], selfNode: string, okNode: string, review: string) => [ + { id: `e_${prefix}_self`, source: decision, target: selfNode, type: 'conditional' as const, condition: cel$(collides(actor, earlier)), label: '撞人' }, + { id: `e_${prefix}_ok`, source: decision, target: okNode, type: 'conditional' as const, condition: cel$(`!(${collides(actor, earlier)})`), label: '不同的人' }, + { id: `e_${prefix}_again`, source: selfNode, target: review, type: 'back' as const, label: '重新审批' }, +]; // 需要总经理审批的动作 const needsGm = `record.action == 'eliminate' || record.action == 'blacklist' || record.action == 'remove_blacklist'`; @@ -83,8 +97,37 @@ export const DispositionApprovalFlow: Automation.Flow = { condition: P`record.status == 'in_review'`, }, }, + { id: 'init', type: 'assignment', label: '初始化审批人变量', config: { assignments: { categoryActor: '', directorActor: '', gmActor: '' } } }, + { + id: 'load_initiator_cm', + type: 'get_record', + label: '发起人是品类经理?', + config: { + objectName: 'sys_user_position', + filter: { user_id: cel`record.created_by`, position: 'srm_category_manager' }, + outputVariable: 'initiatorCm', + }, + }, + { id: 'route_category', type: 'decision', label: '品类经理一级是否已由发起完成?' }, + + // 1. 品类经理(发起人不是品类经理时)。可以通过、驳回或退回修改,退回后发起人改完重新提交、从品类经理重新审批 + { id: 'category_review', ...approval('品类经理审批', 'srm_category_manager', 3) }, + actionOf('category_action', '读取品类经理审批记录', 'category_review', 'approve', 'categoryAction'), + { id: 'category_sod', type: 'decision', label: '品类经理是发起人?' }, + self( + 'category_self', + '发起人不能审批本人的处置单', + 'categoryAction', + '处置单 {{ record.disposition_no }} 需由另一位品类经理审批', + '你是这张处置单的发起人,你的通过不计入审批,已重新提交给品类经理。', + ), + ...recordApprover('record_category', '记录品类经理审批人', 'category_approver', 'categoryAction', 'categoryActor'), - // 1. 采购总监 + // 2. 采购总监。发起人是品类经理时这是第一个审批节点,可以退回修改(director_review);先经品类经理审批时 + // 它是第 2 个审批节点,只能通过或驳回(director_review_after_category)。 + // 临时做法:第 2 个起的审批节点只能通过或驳回、不能退回修改——平台把第 2 个审批节点的提交人记成恢复流程的人 + // (上一级审批人),退回后发起人无法重新提交(objectstack#24817 已在主干修复、18.0.0 未带上)。 + // 平台修复后合并两个采购总监节点,并给后面几级加回 revise 边,退回到发起人 { id: 'director_review', ...approval('采购总监审批', 'srm_procurement_director', 3) }, actionOf('director_action', '读取采购总监审批记录', 'director_review', 'approve', 'directorAction'), { id: 'director_sod', type: 'decision', label: '采购总监是发起人?' }, @@ -95,15 +138,23 @@ export const DispositionApprovalFlow: Automation.Flow = { '处置单 {{ record.disposition_no }} 需由另一位采购总监审批', '你是这张处置单的发起人,你的通过不计入审批,已重新提交给采购总监。', ), - recordApprover('record_director', '记录采购总监审批人', 'director_approver', 'directorAction'), + { id: 'director_review_after_category', ...approval('采购总监审批', 'srm_procurement_director', 0) }, + actionOf('director_action_after_category', '读取采购总监审批记录', 'director_review_after_category', 'approve', 'directorAction'), + { id: 'director_sod_after_category', type: 'decision', label: '采购总监与发起人、品类经理不同?' }, + self( + 'director_self_after_category', + '各级审批人不能是同一人', + 'directorAction', + '处置单 {{ record.disposition_no }} 需由另一位采购总监审批', + '处置单的发起人和各级审批人不能是同一人(BR-11),你的通过不计入审批,已重新提交给采购总监。', + ), + ...recordApprover('record_director', '记录采购总监审批人', 'director_approver', 'directorAction', 'directorActor'), { id: 'needs_gm', type: 'decision', label: '淘汰、黑名单或解除黑名单?' }, - // 2. 总经理(淘汰 / 黑名单 / 解除黑名单)。临时做法:这一级和内控会签只能通过或驳回、不能退回修改—— - // 平台把第 2 个审批节点的提交人记成恢复流程的人(上一级审批人),退回后发起人无法重新提交 - // (objectstack#24817 已在主干修复、18.0.0 未带上)。平台修复后加回 revise 边,退回到发起人 + // 3. 总经理(淘汰 / 黑名单 / 解除黑名单) { id: 'gm_review', ...approval('总经理审批', 'srm_general_manager', 0) }, actionOf('gm_action', '读取总经理审批记录', 'gm_review', 'approve', 'gmAction'), - { id: 'gm_sod', type: 'decision', label: '总经理与发起人、采购总监不同?' }, + { id: 'gm_sod', type: 'decision', label: '总经理与发起人、前几级不同?' }, self( 'gm_self', '各级审批人不能是同一人', @@ -111,13 +162,13 @@ export const DispositionApprovalFlow: Automation.Flow = { '处置单 {{ record.disposition_no }} 需由另一位总经理审批', '处置单的发起人和各级审批人不能是同一人(BR-11),你的通过不计入审批,已重新提交给总经理。', ), - recordApprover('record_gm', '记录总经理审批人', 'gm_approver', 'gmAction'), + ...recordApprover('record_gm', '记录总经理审批人', 'gm_approver', 'gmAction', 'gmActor'), { id: 'needs_compliance', type: 'decision', label: '拉入黑名单?' }, - // 3. 内控会签(拉入黑名单) + // 4. 内控会签(拉入黑名单) { id: 'compliance_review', ...approval('内控会签', 'srm_procurement_compliance', 0) }, actionOf('compliance_action', '读取内控会签记录', 'compliance_review', 'approve', 'complianceAction'), - { id: 'compliance_sod', type: 'decision', label: '会签人与发起人、前两级审批人不同?' }, + { id: 'compliance_sod', type: 'decision', label: '会签人与发起人、前几级审批人不同?' }, self( 'compliance_self', '各级审批人不能是同一人', @@ -125,9 +176,18 @@ export const DispositionApprovalFlow: Automation.Flow = { '处置单 {{ record.disposition_no }} 需由另一位内控人员会签', '处置单的发起人和各级审批人不能是同一人(BR-11),你的会签不计入审批,已重新提交给内控。', ), - recordApprover('record_compliance', '记录内控会签人', 'compliance_approver', 'complianceAction'), + { + id: 'record_compliance', + type: 'update_record', + label: '记录内控会签人', + config: { + objectName: 'srm_supplier_disposition', + filter: { id: cel`record.id` }, + fields: { compliance_approver: cel`complianceAction.actor_id` }, + }, + }, - // 4. 通过:执行处置 + // 5. 通过:执行处置 { id: 'apply', type: 'subflow', @@ -147,8 +207,10 @@ export const DispositionApprovalFlow: Automation.Flow = { }, }, - // 5. 驳回:不改供应商,记下驳回意见 + // 6. 驳回:不改供应商,记下驳回意见 + actionOf('category_reject_action', '读取品类经理驳回意见', 'category_review', 'reject', 'rejectAction'), actionOf('director_reject_action', '读取采购总监驳回意见', 'director_review', 'reject', 'rejectAction'), + actionOf('director_reject_action_after_category', '读取采购总监驳回意见', 'director_review_after_category', 'reject', 'rejectAction'), actionOf('gm_reject_action', '读取总经理驳回意见', 'gm_review', 'reject', 'rejectAction'), actionOf('compliance_reject_action', '读取内控驳回意见', 'compliance_review', 'reject', 'rejectAction'), { @@ -178,60 +240,71 @@ export const DispositionApprovalFlow: Automation.Flow = { }, }, - // 6. 退回修改:记录解锁,发起人改完后重新提交,从采购总监重新审批 + // 7. 退回修改:记录解锁,发起人改完后重新提交,从退回的那一级重新审批 + { id: 'category_revise_window', type: 'approval_revise', label: '退回修改' }, { id: 'revise_window', type: 'approval_revise', label: '退回修改' }, { id: 'end', type: 'end', label: '结束' }, ], edges: [ - { id: 'e_start', source: 'start', target: 'director_review', type: 'default' }, - - { id: 'e_d_approve', source: 'director_review', target: 'director_action', type: 'default', label: 'approve' }, - { id: 'e_d_reject', source: 'director_review', target: 'director_reject_action', type: 'default', label: 'reject' }, - { id: 'e_d_revise', source: 'director_review', target: 'revise_window', type: 'default', label: 'revise' }, - { id: 'e_d_sod', source: 'director_action', target: 'director_sod', type: 'default' }, + { id: 'e_start', source: 'start', target: 'init', type: 'default' }, + { id: 'e_init', source: 'init', target: 'load_initiator_cm', type: 'default' }, + { id: 'e_route', source: 'load_initiator_cm', target: 'route_category', type: 'default' }, { - id: 'e_d_self', - source: 'director_sod', - target: 'director_self', + id: 'e_route_cm', + source: 'route_category', + target: 'category_review', type: 'conditional', - condition: P`directorAction.actor_id == record.created_by`, - label: '是发起人', + condition: P`initiatorCm == null`, + label: '发起人不是品类经理:先经品类经理', }, { - id: 'e_d_ok', - source: 'director_sod', - target: 'record_director', + id: 'e_route_director', + source: 'route_category', + target: 'director_review', type: 'conditional', - condition: P`directorAction.actor_id != record.created_by`, - label: '不是发起人', + condition: P`initiatorCm != null`, + label: '品类经理发起:直接到采购总监', }, - { id: 'e_d_again', source: 'director_self', target: 'director_review', type: 'back', label: '重新审批' }, - { id: 'e_d_next', source: 'record_director', target: 'needs_gm', type: 'default' }, + + { id: 'e_c_approve', source: 'category_review', target: 'category_action', type: 'default', label: 'approve' }, + { id: 'e_c_reject', source: 'category_review', target: 'category_reject_action', type: 'default', label: 'reject' }, + { id: 'e_c_revise', source: 'category_review', target: 'category_revise_window', type: 'default', label: 'revise' }, + { id: 'e_c_sod', source: 'category_action', target: 'category_sod', type: 'default' }, + ...sodEdges('c', 'category_sod', 'categoryAction', [], 'category_self', 'record_category', 'category_review'), + { id: 'e_c_var', source: 'record_category', target: 'record_category_var', type: 'default' }, + { id: 'e_c_next', source: 'record_category_var', target: 'director_review_after_category', type: 'default' }, + + { id: 'e_d_approve', source: 'director_review', target: 'director_action', type: 'default', label: 'approve' }, + { id: 'e_d_reject', source: 'director_review', target: 'director_reject_action', type: 'default', label: 'reject' }, + { id: 'e_d_revise', source: 'director_review', target: 'revise_window', type: 'default', label: 'revise' }, + { id: 'e_d_sod', source: 'director_action', target: 'director_sod', type: 'default' }, + ...sodEdges('d', 'director_sod', 'directorAction', [], 'director_self', 'record_director', 'director_review'), + + { id: 'e_dc_approve', source: 'director_review_after_category', target: 'director_action_after_category', type: 'default', label: 'approve' }, + { id: 'e_dc_reject', source: 'director_review_after_category', target: 'director_reject_action_after_category', type: 'default', label: 'reject' }, + { id: 'e_dc_sod', source: 'director_action_after_category', target: 'director_sod_after_category', type: 'default' }, + ...sodEdges( + 'dc', + 'director_sod_after_category', + 'directorAction', + ['categoryActor'], + 'director_self_after_category', + 'record_director', + 'director_review_after_category', + ), + + { id: 'e_d_var', source: 'record_director', target: 'record_director_var', type: 'default' }, + { id: 'e_d_next', source: 'record_director_var', target: 'needs_gm', type: 'default' }, { id: 'e_gm', source: 'needs_gm', target: 'gm_review', type: 'conditional', condition: cel$(needsGm), label: '需总经理审批' }, { id: 'e_no_gm', source: 'needs_gm', target: 'apply', type: 'conditional', condition: cel$(`!(${needsGm})`), label: '冻结 / 解冻' }, { id: 'e_g_approve', source: 'gm_review', target: 'gm_action', type: 'default', label: 'approve' }, { id: 'e_g_reject', source: 'gm_review', target: 'gm_reject_action', type: 'default', label: 'reject' }, { id: 'e_g_sod', source: 'gm_action', target: 'gm_sod', type: 'default' }, - { - id: 'e_g_self', - source: 'gm_sod', - target: 'gm_self', - type: 'conditional', - condition: P`gmAction.actor_id == record.created_by || gmAction.actor_id == directorAction.actor_id`, - label: '撞人', - }, - { - id: 'e_g_ok', - source: 'gm_sod', - target: 'record_gm', - type: 'conditional', - condition: P`gmAction.actor_id != record.created_by && gmAction.actor_id != directorAction.actor_id`, - label: '不同的人', - }, - { id: 'e_g_again', source: 'gm_self', target: 'gm_review', type: 'back', label: '重新审批' }, - { id: 'e_g_next', source: 'record_gm', target: 'needs_compliance', type: 'default' }, + ...sodEdges('g', 'gm_sod', 'gmAction', ['categoryActor', 'directorActor'], 'gm_self', 'record_gm', 'gm_review'), + { id: 'e_g_var', source: 'record_gm', target: 'record_gm_var', type: 'default' }, + { id: 'e_g_next', source: 'record_gm_var', target: 'needs_compliance', type: 'default' }, { id: 'e_compliance', source: 'needs_compliance', @@ -249,37 +322,32 @@ export const DispositionApprovalFlow: Automation.Flow = { label: '淘汰 / 解除黑名单', }, - { id: 'e_c_approve', source: 'compliance_review', target: 'compliance_action', type: 'default', label: 'approve' }, - { id: 'e_c_reject', source: 'compliance_review', target: 'compliance_reject_action', type: 'default', label: 'reject' }, - { id: 'e_c_sod', source: 'compliance_action', target: 'compliance_sod', type: 'default' }, - { - id: 'e_c_self', - source: 'compliance_sod', - target: 'compliance_self', - type: 'conditional', - condition: P`complianceAction.actor_id == record.created_by || complianceAction.actor_id == directorAction.actor_id || complianceAction.actor_id == gmAction.actor_id`, - label: '撞人', - }, - { - id: 'e_c_ok', - source: 'compliance_sod', - target: 'record_compliance', - type: 'conditional', - condition: P`complianceAction.actor_id != record.created_by && complianceAction.actor_id != directorAction.actor_id && complianceAction.actor_id != gmAction.actor_id`, - label: '不同的人', - }, - { id: 'e_c_again', source: 'compliance_self', target: 'compliance_review', type: 'back', label: '重新会签' }, - { id: 'e_c_next', source: 'record_compliance', target: 'apply', type: 'default' }, + { id: 'e_k_approve', source: 'compliance_review', target: 'compliance_action', type: 'default', label: 'approve' }, + { id: 'e_k_reject', source: 'compliance_review', target: 'compliance_reject_action', type: 'default', label: 'reject' }, + { id: 'e_k_sod', source: 'compliance_action', target: 'compliance_sod', type: 'default' }, + ...sodEdges( + 'k', + 'compliance_sod', + 'complianceAction', + ['categoryActor', 'directorActor', 'gmActor'], + 'compliance_self', + 'record_compliance', + 'compliance_review', + ), + { id: 'e_k_next', source: 'record_compliance', 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_c_rejected', source: 'category_reject_action', target: 'mark_rejected', type: 'default' }, { id: 'e_d_rejected', source: 'director_reject_action', target: 'mark_rejected', type: 'default' }, + { id: 'e_dc_rejected', source: 'director_reject_action_after_category', target: 'mark_rejected', type: 'default' }, { id: 'e_g_rejected', source: 'gm_reject_action', target: 'mark_rejected', type: 'default' }, - { id: 'e_c_rejected', source: 'compliance_reject_action', target: 'mark_rejected', type: 'default' }, + { id: 'e_k_rejected', source: 'compliance_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_c_resubmit', source: 'category_revise_window', target: 'category_review', type: 'back', label: 'resubmit' }, { id: 'e_resubmit', source: 'revise_window', target: 'director_review', type: 'back', label: 'resubmit' }, ], }; diff --git a/src/core/objects/supplier-disposition.object.ts b/src/core/objects/supplier-disposition.object.ts index 832d498..545a832 100644 --- a/src/core/objects/supplier-disposition.object.ts +++ b/src/core/objects/supplier-disposition.object.ts @@ -1,7 +1,7 @@ import { P, cel } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; -// 供应商处置单(02 §4.2、§5.1,03 §5,FR-208):品类经理在供应商档案下新建,新建即进入审批 +// 供应商处置单(02 §4.2、§5.1,03 §5,FR-208):品类经理(或采购主管)在供应商档案下新建,新建即进入审批 // (srm_disposition_approval),通过后由 srm_disposition_apply 以系统身份改供应商状态和品类资格。 // 动作与供应商当前状态的合法组合在新建时校验,与供应商状态机(02 §5.1)一致;同一家供应商同时 // 只能有一张审批中的处置单,免得两张单子审完后互相覆盖。 @@ -93,6 +93,8 @@ export const SupplierDisposition = ObjectSchema.create({ { label: '已撤回', value: 'recalled' }, ], }), + // 发起人不是品类经理时才有品类经理一级;品类经理发起时为空,发起人即是这一级 + category_approver: Field.user({ label: '品类经理审批人', group: 'review', readonly: true }), director_approver: Field.user({ label: '采购总监审批人', group: 'review', readonly: true }), gm_approver: Field.user({ label: '总经理审批人', group: 'review', readonly: true }), compliance_approver: Field.user({ label: '内控会签人', group: 'review', readonly: true }), diff --git a/src/core/translations/en.ts b/src/core/translations/en.ts index ccd2d8b..5fb0cfc 100644 --- a/src/core/translations/en.ts +++ b/src/core/translations/en.ts @@ -329,6 +329,7 @@ export const en: TranslationData = { label: 'Approval Result', options: { pending: 'Pending', approved: 'Approved', rejected: 'Rejected', returned: 'Returned for Revision', recalled: 'Recalled' }, }, + category_approver: { label: 'Category Manager Approver' }, director_approver: { label: 'Procurement Director Approver' }, gm_approver: { label: 'General Manager Approver' }, compliance_approver: { label: 'Compliance Countersigner' }, diff --git a/test/disposition.test.ts b/test/disposition.test.ts index f0a4e78..5ecf0b7 100644 --- a/test/disposition.test.ts +++ b/test/disposition.test.ts @@ -3,8 +3,8 @@ import { bootStack, type VerifyStack } from '@objectstack/verify'; import config from '../objectstack.config'; import { Category, Supplier, SupplierCategory, SupplierDisposition } from '../src/core/objects'; -// v0.3 C3 供应商处置单(02 §4.2、§5.1,03 §5、§12,FR-208,BR-11):品类经理发起 → 采购总监 → -// 淘汰 / 黑名单 / 解除黑名单再经总经理 → 拉入黑名单最后由内控会签 → 执行。 +// v0.3 C3 供应商处置单(02 §4.2、§5.1,03 §5、§12,FR-208,BR-11):品类经理发起(发起即品类经理一级; +// 采购主管发起时先经品类经理)→ 采购总监 → 淘汰 / 黑名单 / 解除黑名单再经总经理 → 拉入黑名单最后由内控会签 → 执行。 // 测试数据全部在这里生成(示例公司名、示例品类),不依赖外部样本。 describe('supplier disposition', () => { let stack: VerifyStack; @@ -60,6 +60,8 @@ describe('supplier disposition', () => { const pending = async (recordId: string) => (await stack.rows('sys_approval_request', { record_id: recordId })).find((r) => r.status === 'pending'); + // 当前这一级的待审批人(按岗位解析出的人) + const pendingApprovers = async (recordId: string) => String((await pending(recordId))?.pending_approvers ?? ''); 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(); @@ -73,6 +75,7 @@ describe('supplier disposition', () => { let gm: { token: string; id: string }; let compliance: { token: string; id: string }; let buyer: { token: string; id: string }; + let buyerLead: { token: string; id: string }; let otherDirector: { token: string; id: string }; beforeAll(async () => { @@ -82,6 +85,7 @@ describe('supplier disposition', () => { gm = await person('srm_general_manager'); compliance = await person('srm_procurement_compliance'); buyer = await person('srm_buyer'); + buyerLead = await person('srm_buyer_lead'); // 审批节点开启时按岗位解析审批人,第二位采购总监要提前任命 otherDirector = await person('srm_procurement_director'); }); @@ -95,11 +99,15 @@ describe('supplier disposition', () => { const freeze = await submit(manager.token, { supplier: supplier.id, action: 'freeze' }); expect(await disposition(freeze.id)).toMatchObject({ status: 'in_review', approval_status: 'pending', scope: 'all' }); expect(await supplierStatus(supplier.id)).toBe('qualified'); + // 品类经理发起即是品类经理一级,直接到采购总监 + expect(await pendingApprovers(freeze.id)).toContain(director.id); + expect(await pendingApprovers(freeze.id)).not.toContain(manager.id); await decide(director.token, freeze.id, 'approve'); const frozen = await disposition(freeze.id); expect(frozen).toMatchObject({ status: 'applied', director_approver: director.id }); expect(frozen.applied_at).toBeTruthy(); + expect(frozen.category_approver ?? null).toBeNull(); expect(await supplierStatus(supplier.id)).toBe('frozen'); expect(await qualificationStatus(supplier)).toEqual({ qualified: 'suspended', trial: 'suspended', suspended: 'suspended' }); expect(await row(SupplierCategory.name, supplier.qualifications.trial)).toMatchObject({ @@ -231,6 +239,59 @@ describe('supplier disposition', () => { expect(await disposition(eliminate.id)).toMatchObject({ status: 'applied', gm_approver: gm.id }); }); + it('adds the category manager level when a buyer lead raises the disposition (03 §12)', async () => { + const supplier = await qualifiedSupplier(); + const freeze = await submit(buyerLead.token, { supplier: supplier.id, action: 'freeze' }); + expect(await pendingApprovers(freeze.id)).toContain(manager.id); + expect(await pendingApprovers(freeze.id)).not.toContain(director.id); + + await decide(manager.token, freeze.id, 'approve'); + expect(await disposition(freeze.id)).toMatchObject({ status: 'in_review', approval_status: 'pending', category_approver: manager.id }); + expect(await supplierStatus(supplier.id)).toBe('qualified'); + expect(await pendingApprovers(freeze.id)).toContain(director.id); + + await decide(director.token, freeze.id, 'approve'); + expect(await disposition(freeze.id)).toMatchObject({ status: 'applied', category_approver: manager.id, director_approver: director.id }); + expect(await supplierStatus(supplier.id)).toBe('frozen'); + }); + + it('lets the category manager send a buyer lead disposition back and the buyer lead resubmit it', async () => { + const supplier = await qualifiedSupplier(); + const freeze = await submit(buyerLead.token, { supplier: supplier.id, action: 'freeze', reason: '质量问题' }); + const first = await decide(manager.token, freeze.id, 'revise', '请写明不合格批次'); + expect((await disposition(freeze.id)).approval_status).toBe('returned'); + + await stack.hooks.run(SupplierDisposition.name, 'update', { id: freeze.id, reason: '示例批次连续两批不合格' }, { as: buyerLead.token }); + const res = await stack.apiAs(buyerLead.token, 'POST', `/approvals/requests/${first.id}/resubmit`, {}); + expect(res.status, await res.clone().text()).toBe(200); + expect(await pendingApprovers(freeze.id)).toContain(manager.id); + await decide(manager.token, freeze.id, 'approve'); + await decide(director.token, freeze.id, 'reject', '先发整改单'); + expect(await disposition(freeze.id)).toMatchObject({ status: 'rejected', reject_reason: '先发整改单', reason: '示例批次连续两批不合格' }); + expect(await supplierStatus(supplier.id)).toBe('qualified'); + }); + + it('does not count the category manager approving again on a later level (BR-11)', async () => { + const cmGm = await person('srm_category_manager', 'srm_general_manager'); + const supplier = await qualifiedSupplier(); + const blacklist = await submit(buyerLead.token, { supplier: supplier.id, action: 'blacklist' }); + await decide(cmGm.token, blacklist.id, 'approve'); + await decide(director.token, blacklist.id, 'approve'); + await decide(cmGm.token, blacklist.id, 'approve'); + expect(await disposition(blacklist.id)).toMatchObject({ status: 'in_review', approval_status: 'pending', category_approver: cmGm.id }); + expect(await disposition(blacklist.id)).not.toHaveProperty('gm_approver', cmGm.id); + await decide(gm.token, blacklist.id, 'approve'); + await decide(compliance.token, blacklist.id, 'approve'); + expect(await disposition(blacklist.id)).toMatchObject({ + status: 'applied', + category_approver: cmGm.id, + director_approver: director.id, + gm_approver: gm.id, + compliance_approver: compliance.id, + }); + expect(await supplierStatus(supplier.id)).toBe('blacklisted'); + }); + it('refuses actions the supplier status does not allow', async () => { const supplier = await qualifiedSupplier(); const potential = await system(Supplier.name, { name: '示例潜在有限公司', credit_code: '91000000MA0DSPPOT1' });