From ca5e6ae9a4d3d0715fe2e3913c77b25e44351ca3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:30:06 +0000 Subject: [PATCH] feat(core): v0.2 M5 finance and buyer lead positions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add srm_ap_accountant (srm_internal_base + srm_finance) and srm_buyer_lead (srm_internal_base + srm_supplier_admin) per design 04 §2.1, and the srm_finance permission set with read-only supplier profile access. Bank account access is added when M2 lands. Tests snapshot what each new position can see and check the accountant cannot edit suppliers. Closes #8 Claude-Session: https://claude.ai/code/session_0197hVjZ87aVrju7md9EL1Gf Co-authored-by: Claude --- src/core/security/permission-sets.ts | 12 +++++ src/core/security/positions.ts | 5 +- test/permissions.test.ts | 80 ++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/src/core/security/permission-sets.ts b/src/core/security/permission-sets.ts index c92d8a5..c180712 100644 --- a/src/core/security/permission-sets.ts +++ b/src/core/security/permission-sets.ts @@ -70,6 +70,17 @@ export const ExecutivePermissionSet = { }, }; +// 财务(应付会计):供应商档案只读。银行账户(v0.2 M2)合入后在此加账号可读,核验只经 screen flow +export const FinancePermissionSet = { + name: 'srm_finance', + label: 'SRM 财务', + objects: { + srm_supplier: read, + srm_supplier_contact: read, + srm_supplier_certificate: read, + }, +}; + // 系统管理员:维护品类与工厂等配置 export const AdminPermissionSet = { name: 'srm_admin', @@ -85,5 +96,6 @@ export const SrmPermissionSets = [ SupplierAdminPermissionSet, QualityPermissionSet, ExecutivePermissionSet, + FinancePermissionSet, AdminPermissionSet, ]; diff --git a/src/core/security/positions.ts b/src/core/security/positions.ts index 72e2b99..c37c1f0 100644 --- a/src/core/security/positions.ts +++ b/src/core/security/positions.ts @@ -1,8 +1,11 @@ -// 内部岗位(04 §2.1)的 v0.1 子集:只列 v0.1 流程用到的岗位,其余随对应版本加入。 +// 内部岗位(04 §2.1)的子集:只列已上线流程用到的岗位,其余随对应版本加入。 +// 挂载的权限集同样是子集:`srm_buyer`、`srm_sourcing` 等权限集随其对象上线后补挂。 export const SrmPositions = [ { name: 'srm_procurement_director', label: '采购总监', permissionSets: ['srm_internal_base', 'srm_executive'] }, { name: 'srm_category_manager', label: '品类经理', permissionSets: ['srm_internal_base', 'srm_supplier_admin'] }, { name: 'srm_buyer', label: '采购员', permissionSets: ['srm_internal_base'] }, + { name: 'srm_buyer_lead', label: '采购主管', permissionSets: ['srm_internal_base', 'srm_supplier_admin'] }, { name: 'srm_sqe', label: 'SQE', permissionSets: ['srm_internal_base', 'srm_quality'] }, + { name: 'srm_ap_accountant', label: '应付会计', permissionSets: ['srm_internal_base', 'srm_finance'] }, { name: 'srm_admin', label: 'SRM 系统管理员', permissionSets: ['srm_internal_base', 'srm_admin'] }, ]; diff --git a/test/permissions.test.ts b/test/permissions.test.ts index a788af9..9567749 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -3,6 +3,28 @@ import { bootStack, type VerifyStack } from '@objectstack/verify'; import config from '../objectstack.config'; import { Supplier, SupplierApplication } from '../src/core/objects'; import { SrmPermissionSets } from '../src/core/security/permission-sets'; +import { SrmPositions } from '../src/core/security/positions'; + +type Access = { allowCreate: boolean; allowRead: boolean; allowEdit: boolean; allowDelete: boolean }; + +// 岗位「能看到什么」:把岗位挂载的权限集按对象合并成 R/C/E/D 字符串,权限变更时对比快照(工程规范 §4) +const visibility = (position: string) => { + const sets = SrmPositions.find((p) => p.name === position)!.permissionSets.map( + (name) => SrmPermissionSets.find((set) => set.name === name)!, + ); + const merged: Record = {}; + for (const set of sets) { + for (const [object, access] of Object.entries(set.objects as Record)) { + const flags = (merged[object] ?? '').split(''); + if (access.allowRead) flags.push('R'); + if (access.allowCreate) flags.push('C'); + if (access.allowEdit) flags.push('E'); + if (access.allowDelete) flags.push('D'); + merged[object] = 'RCED'.split('').filter((f) => flags.includes(f)).join(''); + } + } + return Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b))); +}; // 内部岗位与权限集(04 §2):采购员只读,品类经理维护档案但不能直接改状态。 describe('internal permissions', () => { @@ -30,6 +52,64 @@ describe('internal permissions', () => { expect(SrmPermissionSets.filter((set) => 'isDefault' in set)).toEqual([]); }); + it('mounts only permission sets that exist', () => { + const names = new Set(SrmPermissionSets.map((set) => set.name)); + for (const position of SrmPositions) { + expect(position.permissionSets.filter((name) => !names.has(name))).toEqual([]); + } + }); + + it('keeps what an AP accountant can see', () => { + expect(visibility('srm_ap_accountant')).toMatchInlineSnapshot(` + { + "srm_category": "R", + "srm_plant": "R", + "srm_supplier": "R", + "srm_supplier_category": "R", + "srm_supplier_certificate": "R", + "srm_supplier_contact": "R", + } + `); + }); + + it('keeps what a buyer lead can see', () => { + expect(visibility('srm_buyer_lead')).toMatchInlineSnapshot(` + { + "srm_category": "R", + "srm_plant": "R", + "srm_supplier": "RCE", + "srm_supplier_application": "RCE", + "srm_supplier_category": "RCE", + "srm_supplier_certificate": "RCE", + "srm_supplier_contact": "RCED", + } + `); + }); + + it('lets an AP accountant read suppliers but not edit or create one', async () => { + const manager = await person('srm_category_manager'); + const supplier = await stack.hooks.run(Supplier.name, 'insert', supplierDoc(), { as: manager }); + const accountant = await person('srm_ap_accountant'); + const [row] = await stack.rows(Supplier.name, { id: supplier.id }, { as: accountant }); + expect(row!.name).toBe(supplier.name); + await expect( + stack.hooks.run(Supplier.name, 'update', { id: supplier.id, short_name: '改名' }, { as: accountant }), + ).rejects.toBeTruthy(); + await expect(stack.hooks.run(Supplier.name, 'insert', supplierDoc(), { as: accountant })).rejects.toBeTruthy(); + await expect(stack.rows(SupplierApplication.name, {}, { as: accountant })).rejects.toBeTruthy(); + }); + + it('lets a buyer lead maintain suppliers but not set the status directly', async () => { + const lead = await person('srm_buyer_lead'); + const supplier = await stack.hooks.run(Supplier.name, 'insert', supplierDoc(), { as: lead }); + await stack.hooks.run(Supplier.name, 'update', { id: supplier.id, short_name: '简称' }, { as: lead }); + await expect( + stack.hooks.run(Supplier.name, 'update', { id: supplier.id, status: 'eliminated' }, { as: lead }), + ).rejects.toBeTruthy(); + const [row] = await stack.rows(Supplier.name, { id: supplier.id }, { as: lead }); + expect(row).toMatchObject({ short_name: '简称', status: 'potential' }); + }); + it('lets a buyer read suppliers but not create one', async () => { const buyer = await person('srm_buyer'); expect((await stack.rows(Supplier.name, {}, { as: buyer })).length).toBeGreaterThan(0);