Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/design/02-domain-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ erDiagram

### 4.1 供应商主数据

**`srm_supplier` 供应商**(OWD `public_read`;开启字段历史;表单分组:基本信息、工商与税务〔`credit_code`、`registration_no`、`taxpayer_type`、`registered_capital`、`legal_representative`〕、外部系统〔`erp_vendor_code`〕)
**`srm_supplier` 供应商**(OWD `public_read`,暂用 `public_read_write`:品类经理要能改别人建的供应商,`modifyAllRecords` 会连删除一起放开,[objectstack#25470](https://github.com/objectstack-ai/objectstack/issues/25470);能改哪些字段由 `allowEdit` 与 FLS 决定,删除仍受 `allowDelete` 约束;开启字段历史;表单分组:基本信息、工商与税务〔`credit_code`、`registration_no`、`taxpayer_type`、`registered_capital`、`legal_representative`〕、外部系统〔`erp_vendor_code`〕)

| 字段 | 类型 | 说明 / 约束 |
|---|---|---|
Expand Down
4 changes: 3 additions & 1 deletion docs/design/04-security-and-portal.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ SRM 同时服务内部员工和互相竞争的供应商,权限设计的首要

R 读 · C 建 · E 改 · D 删 · F 仅经 flow / screen flow 写入 · — 无权限。删除一律只给
`srm_admin`,业务上用状态(取消、作废)代替删除。供应商主数据在集团内共享(§5),
`supplier_admin` 和 `srm_admin` 对供应商的编辑不限于自己建的记录(`modifyAllRecords`)。
`supplier_admin` 和 `srm_admin` 对供应商的编辑不限于自己建的记录:目标是 OWD `public_read` + 只放开编辑的「改所有记录」,
平台的 `modifyAllRecords` 会连删除一起放开(objectstack#25470),所以暂用 OWD `public_read_write`,
`srm_quality` 为写证书保留对供应商的编辑权、供应商字段经 FLS 全部只读。

| 对象 | base | sourcing | supplier_admin | buyer | quality | finance | portal |
|---|---|---|---|---|---|---|---|
Expand Down
4 changes: 3 additions & 1 deletion src/core/objects/supplier.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ export const Supplier = ObjectSchema.create({
label: '供应商',
pluralLabel: '供应商',
icon: 'factory',
sharingModel: 'public_read',
// 临时:目标是 public_read。品类经理要能改别人建的供应商(含存量导入的),平台的 modifyAllRecords
// 会连删除一起放开,只能先放宽 OWD;能改哪些字段由权限集的 allowEdit 与 FLS 决定(objectstack#25470,02 §4.1)。
sharingModel: 'public_read_write',
enable: { trackHistory: true },
stageField: 'status',

Expand Down
18 changes: 10 additions & 8 deletions src/core/security/permission-sets.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Supplier } from '../objects/supplier.object.js';

// 内部权限集(04 §2.2)的 v0.1 子集。一律不设 isDefault(AGENTS.md 红线 2)。
// 状态类字段(供应商状态、资质异常、证书状态)只由 flow 写入,权限集里一律只读。

const read = { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false, viewAllRecords: true, modifyAllRecords: false };
const write = { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false, viewAllRecords: true, modifyAllRecords: false };
// 供应商主数据在集团内共享(04 §5):维护者能改所有供应商,不只是自己建的
const writeAll = { ...write, modifyAllRecords: true };
const flowOwned = { readable: true, editable: false };
// 存量导入标记只由系统管理员在导入时写入:品类经理和 SQE 写不了,避免绕过准入直接建「合格」供应商
const importOwned = {
Expand Down Expand Up @@ -54,7 +54,7 @@ export const SupplierAdminPermissionSet = {
name: 'srm_supplier_admin',
label: 'SRM 供应商管理',
objects: {
srm_supplier: writeAll,
srm_supplier: write,
srm_supplier_contact: { ...write, allowDelete: true },
srm_supplier_certificate: write,
srm_supplier_category: write,
Expand All @@ -76,6 +76,10 @@ export const SupplierAdminPermissionSet = {
},
};

// 供应商 OWD 暂为 public_read_write(objectstack#25470),有 allowEdit 就能改所有供应商:
// SQE 的编辑权只为写证书(controlled_by_parent 要求父记录可编辑,04 §2.3),供应商字段全部只读
const supplierFieldsReadOnly = Object.fromEntries(Object.keys(Supplier.fields).map((field) => [`srm_supplier.${field}`, readOnly]));

// SQE:v0.1 由 SQE 维护证书(门户版本改为供应商上传、SQE 经 screen flow 核验)
export const QualityPermissionSet = {
name: 'srm_quality',
Expand All @@ -86,11 +90,9 @@ export const QualityPermissionSet = {
srm_supplier_application: read,
},
fields: {
'srm_supplier.status': flowOwned,
'srm_supplier.qualification_alert': flowOwned,
...supplierFieldsReadOnly,
'srm_supplier_certificate.status': flowOwned,
'srm_supplier.legacy_reviewed': flowOwned,
...importOwned,
'srm_supplier_certificate.legacy_imported': flowOwned,
},
};

Expand Down Expand Up @@ -135,7 +137,7 @@ export const AdminPermissionSet = {
srm_category: { ...write, allowDelete: true },
srm_plant: { ...write, allowDelete: true },
srm_material: { ...write, allowDelete: true },
srm_supplier: writeAll,
srm_supplier: write,
srm_supplier_contact: write,
srm_supplier_certificate: write,
},
Expand Down
26 changes: 26 additions & 0 deletions test/permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,32 @@ describe('internal permissions', () => {
expect(row!.status).toBe('potential');
});

// 供应商 OWD 暂为 public_read_write(objectstack#25470):维护者能改别人建的供应商,但删不了
it('lets supplier maintainers edit a supplier they do not own but not delete it', async () => {
const supplier = await stack.hooks.run(Supplier.name, 'insert', supplierDoc(), { system: true });
for (const position of ['srm_category_manager', 'srm_buyer_lead']) {
const maintainer = await person(position);
await stack.hooks.run(Supplier.name, 'update', { id: supplier.id, short_name: `改名 ${position}` }, { as: maintainer });
await expect(stack.hooks.run(Supplier.name, 'delete', { id: supplier.id }, { as: maintainer })).rejects.toBeTruthy();
}
expect(await stack.rows(Supplier.name, { id: supplier.id })).toHaveLength(1);
});

it('keeps supplier fields read-only for an SQE', async () => {
const supplier = await stack.hooks.run(Supplier.name, 'insert', supplierDoc(), { system: true });
const sqe = await person('srm_sqe');
await expect(stack.hooks.run(Supplier.name, 'update', { id: supplier.id, short_name: 'SQE 改名' }, { as: sqe })).rejects.toBeTruthy();
await expect(stack.hooks.run(Supplier.name, 'delete', { id: supplier.id }, { as: sqe })).rejects.toBeTruthy();
});

it('does not let a buyer or an AP accountant edit a supplier', async () => {
const supplier = await stack.hooks.run(Supplier.name, 'insert', supplierDoc(), { system: true });
for (const position of ['srm_buyer', 'srm_ap_accountant']) {
const reader = await person(position);
await expect(stack.hooks.run(Supplier.name, 'update', { id: supplier.id, short_name: '越权改名' }, { as: reader })).rejects.toBeTruthy();
}
});

it('keeps admission applications out of a buyer\'s reach', async () => {
const buyer = await person('srm_buyer');
await expect(stack.rows(SupplierApplication.name, {}, { as: buyer })).rejects.toBeTruthy();
Expand Down
Loading