From b9d6166cf55978f55ae1f4357140912717d26d8c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:32:34 +0000 Subject: [PATCH 1/2] feat(core): v0.2 M1 material read-only copy (srm_material) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New object srm_material: ERP material master copy keyed by erp_material_no (unique per organization), with list views, a "without category" view and a workbench nav entry. - New permission set srm_buyer (on buyer and procurement director): edit only the category link; other fields read-only via FLS; no create/delete. srm_admin imports and maintains materials. - OWD is public_read_write: under public_read a buyer needs modifyAllRecords to edit an imported row, which also grants delete. - Sample sheet data/samples/srm_material.csv with an import guide; test/material.test.ts imports it twice through the platform import API and checks counts and values are unchanged. - Design 02 §4.1 and 04 §2.2 updated to match; en translations added. Closes #9 Claude-Session: https://claude.ai/code/session_01EGdBSEBg7Sy4QhBKoLqR1H Co-authored-by: Claude --- data/samples/README.md | 39 ++++++++++ data/samples/srm_material.csv | 9 +++ docs/design/02-domain-model.md | 13 +++- docs/design/04-security-and-portal.md | 4 +- src/core/apps/workbench.app.ts | 1 + src/core/objects/index.ts | 1 + src/core/objects/material.object.ts | 26 +++++++ src/core/security/permission-sets.ts | 24 +++++- src/core/security/positions.ts | 4 +- src/core/translations/en.ts | 19 +++++ src/core/views/index.ts | 1 + src/core/views/material.view.ts | 30 ++++++++ test/material.test.ts | 102 ++++++++++++++++++++++++++ 13 files changed, 265 insertions(+), 8 deletions(-) create mode 100644 data/samples/README.md create mode 100644 data/samples/srm_material.csv create mode 100644 src/core/objects/material.object.ts create mode 100644 src/core/views/material.view.ts create mode 100644 test/material.test.ts diff --git a/data/samples/README.md b/data/samples/README.md new file mode 100644 index 0000000..8ffa889 --- /dev/null +++ b/data/samples/README.md @@ -0,0 +1,39 @@ +# 样例导入表格 + +这里的表格演示从 ERP 导出数据、用平台导入写入 SRM 的格式。全部是标明「示例」的虚构数据, +不对应任何真实企业或物料清单(AGENTS.md 红线 5)。CI 用它们验证导入可重复执行(`test/material.test.ts`)。 + +## 物料 `srm_material.csv` + +物料是 ERP 物料主数据的只读副本([02 §4.1](../../docs/design/02-domain-model.md#41-供应商主数据), +[ADR-0008](../../docs/adr/0008-order-to-pay-stays-in-erp.md))。ERP 是源,SRM 不新建物料。 + +| 列 | 字段 | 说明 | +|---|---|---| +| `erp_material_no` | ERP 物料号 | 必填,组织内唯一;导入按它匹配,已有则更新、没有则新建 | +| `material_code` | 物料编码 | 必填 | +| `name` | 物料名称 | 必填 | +| `specification` | 规格型号 | | +| `uom` | 计量单位 | 文本,如「个」「卷」 | +| `active` | 启用 | `true` / `false`;ERP 中停用的物料导入为 `false`,不删除 | + +### 导入步骤 + +1. 从 ERP 导出物料主数据,把列名改成上表的字段名(或在导入界面逐列映射)。 +2. 以 `srm_admin` 岗位登录,打开「基础数据 › 物料」,选择导入,写入方式选 **upsert**, + 匹配字段选 **ERP 物料号**(`erp_material_no`)。 +3. 先试运行(接口参数 `dryRun: true`)看预览,没有错误再正式导入。 + +接口方式等价于: + +```http +POST /api/v1/data/srm_material/import +{ "format": "csv", "csv": "<表格内容>", "writeMode": "upsert", "matchFields": ["erp_material_no"] } +``` + +### 注意 + +- **日常重复导入不要带 `category` 列**。品类关联由采购员在 SRM 里维护,表格里带这一列会把它覆盖。 + 首次初始化时如果 ERP 的物料组能对上 SRM 品类,可以带上,值填品类名称。 +- 同一张表格导入两遍,第二遍只更新、不新增,记录数与字段值不变。 +- 未关联品类的物料在「物料 › 未关联品类」视图里,供采购员补齐。 diff --git a/data/samples/srm_material.csv b/data/samples/srm_material.csv new file mode 100644 index 0000000..c180e25 --- /dev/null +++ b/data/samples/srm_material.csv @@ -0,0 +1,9 @@ +erp_material_no,material_code,name,specification,uom,active +EX-100001,RES-0603-10K,示例贴片电阻,0603 10kΩ ±1%,个,true +EX-100002,CAP-0805-100N,示例贴片电容,0805 100nF 50V X7R,个,true +EX-100003,IC-MCU-32F1,示例微控制器,"32 位, LQFP-48",个,true +EX-100004,CON-USB-C16,示例 USB-C 连接器,16 针 贴片,个,true +EX-200001,BOX-300-200,示例瓦楞纸箱,300×200×150 mm 五层,只,true +EX-200002,FILM-PE-50,示例 PE 缠绕膜,宽 500 mm 厚 50 μm,卷,true +EX-300001,BRG-6204,示例深沟球轴承,6204-2RS,套,true +EX-300002,GLV-NIT-L,示例丁腈手套,L 号 100 只/盒,盒,false diff --git a/docs/design/02-domain-model.md b/docs/design/02-domain-model.md index c53ad05..15b3710 100644 --- a/docs/design/02-domain-model.md +++ b/docs/design/02-domain-model.md @@ -255,10 +255,17 @@ erDiagram (lookup)、`status`(`trial` 试供 / `qualified` 合格 / `suspended` 暂停)、 `approved_date`、`source_application`(lookup 准入申请)。同一供应商同一品类唯一。 -**`srm_material` 物料**(只读副本,OWD `public_read`;v0.2):`erp_material_no` -(唯一,导入匹配键)、`material_code`、`name`、`specification`、`uom`、`category`(lookup)、 +**`srm_material` 物料**(只读副本,OWD `public_read_write`;v0.2):`erp_material_no` +(组织内唯一,导入匹配键)、`material_code`、`name`、`specification`、`uom`、`category`(lookup)、 `active`。用平台导入按 `erp_material_no` 从 ERP 导出表格导入,可重复执行;采购员只 -维护 `category` 关联,其他字段对内部只读(FLS)。ERP 集成后改为 upsert 写入,结构不变。 +维护 `category` 关联,其他字段对采购员只读(FLS),新建、删除只有 `srm_admin`。ERP 集成后改为 +upsert 写入,结构不变。 + +> OWD 取 `public_read_write` 而不是 `public_read`:物料归执行导入的管理员所有,`public_read` +> 下采购员要改别人的记录只能授予 `modifyAllRecords`,而平台的 `modifyAllRecords` 同时放开删除。 +> 改为 `public_read_write` 后,能写哪些字段由权限集的 `allowEdit` 与 FLS 决定,删除仍受 `allowDelete` +> 约束。日常重复导入的表格**不带** `category` 列,以免覆盖采购员维护的关联;导入说明见 +> [`data/samples/README.md`](../../data/samples/README.md)。 **`srm_plant` 工厂 / 收货地点**(手工维护或导入):`erp_plant_id`(预留)、`code`、`name`、 `legal_entity`(法人名称或组织节点)、`address`。 diff --git a/docs/design/04-security-and-portal.md b/docs/design/04-security-and-portal.md index 0a48efb..d37b427 100644 --- a/docs/design/04-security-and-portal.md +++ b/docs/design/04-security-and-portal.md @@ -54,14 +54,14 @@ SRM 同时服务内部员工和互相竞争的供应商,权限设计的首要 | `srm_internal_base` | 内部员工的基础:读品类、物料、工厂、供应商主数据(不含银行账号)、价格库;**不是** `isDefault` | | `srm_sourcing` | 寻源单、寻源明细的增改(状态只经 screen flow 推进);邀请、技术评分、定标只经 screen flow;读报价(受封标 RLS 约束);新建合同 | | `srm_supplier_admin` | 供应商档案、联系人、证书、准入申请、审核、供货品类资格、处置单、合同的增改;开通门户 | -| `srm_buyer` | 供应商档案、合同、交付记录读;代供应商发起变更单(v0.6 前);ERP 集成后加订单、送货单的内部字段 | +| `srm_buyer` | 供应商档案、合同、交付记录读;物料只改 `category` 关联(其余字段 FLS 只读,不新建、不删除);代供应商发起变更单(v0.6 前);ERP 集成后加订单、送货单的内部字段 | | `srm_quality` | 审核、交付记录(录入)、整改单的增改;证书只读,核验经 screen flow | | `srm_iqc` | 交付记录的录入(没有质量系统的客户) | | `srm_finance` | 银行账号可读,核验经 screen flow;银行账户变更的第 2 级审批;整改单索赔结论标记 | | `srm_legal` | 合同读改(审批由 flow 授予) | | `srm_executive` | 全部 SRM 对象只读 + 仪表盘;审批由 flow 授予,不需要额外写权限 | | `srm_auditor` | 全部 SRM 对象只读(报价同样受封标约束),可读审批历史与字段历史,不可导出银行账号 | -| `srm_admin` | 品类、指标库、编码、通知模板的配置;不授予报价读权限 | +| `srm_admin` | 品类、指标库、编码、通知模板的配置;物料导入与维护;不授予报价读权限 | | `srm_portal_supplier` | 供应商门户用户:见 §3.2 | | `srm_portal_supplier_admin` | 在 `srm_portal_supplier` 之上:维护本公司联系人、银行账户,提交变更申请与绩效申诉;直接授予具体用户 | | `srm_integration` | 预留(ERP 集成后):集成服务账号,物料、交付记录、订单镜像的写入;供应商仅可写 `erp_vendor_code`,价格记录只读;**不**授予报价单与银行账号 | diff --git a/src/core/apps/workbench.app.ts b/src/core/apps/workbench.app.ts index 318ed5e..159fe9a 100644 --- a/src/core/apps/workbench.app.ts +++ b/src/core/apps/workbench.app.ts @@ -25,6 +25,7 @@ export const Workbench = App.create({ icon: 'database', children: [ { id: 'nav_category', type: 'object', objectName: 'srm_category', label: '采购品类', icon: 'folder-tree' }, + { id: 'nav_material', type: 'object', objectName: 'srm_material', label: '物料', icon: 'package' }, { id: 'nav_plant', type: 'object', objectName: 'srm_plant', label: '工厂', icon: 'warehouse' }, ], }, diff --git a/src/core/objects/index.ts b/src/core/objects/index.ts index 9eee422..c5f911f 100644 --- a/src/core/objects/index.ts +++ b/src/core/objects/index.ts @@ -5,3 +5,4 @@ export { SupplierContact } from './supplier-contact.object.js'; export { SupplierCertificate } from './supplier-certificate.object.js'; export { SupplierCategory } from './supplier-category.object.js'; export { SupplierApplication } from './supplier-application.object.js'; +export { Material } from './material.object.js'; diff --git a/src/core/objects/material.object.ts b/src/core/objects/material.object.ts new file mode 100644 index 0000000..be4bbcf --- /dev/null +++ b/src/core/objects/material.object.ts @@ -0,0 +1,26 @@ +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +// 物料(02 §4.1):ERP 物料主数据的只读副本,用平台导入按 erp_material_no 幂等写入(ADR-0008)。 +// SRM 内只维护与品类的关联;其余字段对采购员只读,见权限集 srm_buyer。 +export const Material = ObjectSchema.create({ + name: 'srm_material', + label: '物料', + pluralLabel: '物料', + icon: 'package', + sharingModel: 'public_read_write', + + fieldGroups: [ + { key: 'basic', label: '基本信息', icon: 'package' }, + { key: 'external', label: '外部系统', icon: 'link' }, + ], + + fields: { + material_code: Field.text({ label: '物料编码', group: 'basic', required: true, searchable: true, maxLength: 60 }), + name: Field.text({ label: '物料名称', group: 'basic', required: true, searchable: true, maxLength: 200 }), + specification: Field.text({ label: '规格型号', group: 'basic', searchable: true, maxLength: 500 }), + uom: Field.text({ label: '计量单位', group: 'basic', maxLength: 20 }), + category: Field.lookup('srm_category', { label: '采购品类', group: 'basic' }), + active: Field.boolean({ label: '启用', group: 'basic', defaultValue: true }), + erp_material_no: Field.text({ label: 'ERP 物料号', group: 'external', required: true, unique: true, maxLength: 60 }), + }, +}); diff --git a/src/core/security/permission-sets.ts b/src/core/security/permission-sets.ts index c92d8a5..271759f 100644 --- a/src/core/security/permission-sets.ts +++ b/src/core/security/permission-sets.ts @@ -4,6 +4,7 @@ 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 }; const flowOwned = { readable: true, editable: false }; +const readOnly = { readable: true, editable: false }; // 内部员工的基础:读基础数据与供应商主数据 export const InternalBasePermissionSet = { @@ -12,6 +13,7 @@ export const InternalBasePermissionSet = { objects: { srm_category: read, srm_plant: read, + srm_material: read, srm_supplier: read, srm_supplier_contact: read, srm_supplier_certificate: read, @@ -19,6 +21,23 @@ export const InternalBasePermissionSet = { }, }; +// 采购员:物料是 ERP 的只读副本(ADR-0008),采购员只维护物料与品类的关联,不新建、不删除 +export const BuyerPermissionSet = { + name: 'srm_buyer', + label: 'SRM 采购', + objects: { + srm_material: { ...read, allowEdit: true }, + }, + fields: { + 'srm_material.erp_material_no': readOnly, + 'srm_material.material_code': readOnly, + 'srm_material.name': readOnly, + 'srm_material.specification': readOnly, + 'srm_material.uom': readOnly, + 'srm_material.active': readOnly, + }, +}; + // 品类经理等:维护供应商档案与子信息,发起和推进准入申请 export const SupplierAdminPermissionSet = { name: 'srm_supplier_admin', @@ -62,6 +81,7 @@ export const ExecutivePermissionSet = { objects: { srm_category: read, srm_plant: read, + srm_material: read, srm_supplier: read, srm_supplier_contact: read, srm_supplier_certificate: read, @@ -70,18 +90,20 @@ export const ExecutivePermissionSet = { }, }; -// 系统管理员:维护品类与工厂等配置 +// 系统管理员:维护品类与工厂等配置,执行物料导入 export const AdminPermissionSet = { name: 'srm_admin', label: 'SRM 系统管理员', objects: { srm_category: { ...write, allowDelete: true }, srm_plant: { ...write, allowDelete: true }, + srm_material: { ...write, allowDelete: true }, }, }; export const SrmPermissionSets = [ InternalBasePermissionSet, + BuyerPermissionSet, SupplierAdminPermissionSet, QualityPermissionSet, ExecutivePermissionSet, diff --git a/src/core/security/positions.ts b/src/core/security/positions.ts index 72e2b99..e3c8147 100644 --- a/src/core/security/positions.ts +++ b/src/core/security/positions.ts @@ -1,8 +1,8 @@ // 内部岗位(04 §2.1)的 v0.1 子集:只列 v0.1 流程用到的岗位,其余随对应版本加入。 export const SrmPositions = [ - { name: 'srm_procurement_director', label: '采购总监', permissionSets: ['srm_internal_base', 'srm_executive'] }, + { name: 'srm_procurement_director', label: '采购总监', permissionSets: ['srm_internal_base', 'srm_buyer', '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', label: '采购员', permissionSets: ['srm_internal_base', 'srm_buyer'] }, { name: 'srm_sqe', label: 'SQE', permissionSets: ['srm_internal_base', 'srm_quality'] }, { name: 'srm_admin', label: 'SRM 系统管理员', permissionSets: ['srm_internal_base', 'srm_admin'] }, ]; diff --git a/src/core/translations/en.ts b/src/core/translations/en.ts index fae5d5e..a4fce90 100644 --- a/src/core/translations/en.ts +++ b/src/core/translations/en.ts @@ -35,6 +35,24 @@ export const en: TranslationData = { }, _views: { default: { label: 'All Plants' } }, }, + srm_material: { + label: 'Material', + pluralLabel: 'Materials', + _sections: { basic: { label: 'Basic Information' }, external: { label: 'External Systems' } }, + fields: { + material_code: { label: 'Material Code' }, + name: { label: 'Material Name' }, + specification: { label: 'Specification' }, + uom: { label: 'Unit of Measure' }, + category: { label: 'Purchasing Category' }, + active: { label: 'Active' }, + erp_material_no: { label: 'ERP Material No.' }, + }, + _views: { + default: { label: 'All Materials' }, + uncategorized: { label: 'Without Category' }, + }, + }, srm_supplier: { label: 'Supplier', pluralLabel: 'Suppliers', @@ -217,6 +235,7 @@ export const en: TranslationData = { nav_certificate: { label: 'Certificates' }, group_master_data: { label: 'Master Data' }, nav_category: { label: 'Purchasing Categories' }, + nav_material: { label: 'Materials' }, nav_plant: { label: 'Plants' }, }, }, diff --git a/src/core/views/index.ts b/src/core/views/index.ts index 0f81929..506f937 100644 --- a/src/core/views/index.ts +++ b/src/core/views/index.ts @@ -3,3 +3,4 @@ export { PlantViews } from './plant.view.js'; export { SupplierViews } from './supplier.view.js'; export { SupplierCertificateViews } from './supplier-certificate.view.js'; export { SupplierApplicationViews } from './supplier-application.view.js'; +export { MaterialViews } from './material.view.js'; diff --git a/src/core/views/material.view.ts b/src/core/views/material.view.ts new file mode 100644 index 0000000..f81a044 --- /dev/null +++ b/src/core/views/material.view.ts @@ -0,0 +1,30 @@ +import { defineView } from '@objectstack/spec'; + +const data = { provider: 'object' as const, object: 'srm_material' }; + +export const MaterialViews = defineView({ + list: { + label: '全部物料', + type: 'grid', + data, + columns: [ + { field: 'material_code' }, + { field: 'name' }, + { field: 'specification' }, + { field: 'uom' }, + { field: 'category' }, + { field: 'active' }, + ], + sort: [{ field: 'material_code', order: 'asc' }], + }, + + listViews: { + uncategorized: { + label: '未关联品类', + type: 'grid', + data, + columns: [{ field: 'material_code' }, { field: 'name' }, { field: 'specification' }, { field: 'uom' }], + filter: [{ field: 'category', operator: 'is_null' }], + }, + }, +}); diff --git a/test/material.test.ts b/test/material.test.ts new file mode 100644 index 0000000..8499367 --- /dev/null +++ b/test/material.test.ts @@ -0,0 +1,102 @@ +import { readFileSync } from 'node:fs'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import config from '../objectstack.config'; +import { Material } from '../src/core/objects'; + +// 物料只读副本(02 §4.1,ADR-0008):样例表格按 erp_material_no 重复导入结果不变; +// 采购员只能维护品类关联。 +const sampleCsv = readFileSync(new URL('../data/samples/srm_material.csv', import.meta.url), 'utf8'); +const importBody = { format: 'csv', csv: sampleCsv, writeMode: 'upsert', matchFields: ['erp_material_no'] }; +const fields = ['erp_material_no', 'material_code', 'name', 'specification', 'uom', 'active'] as const; + +describe('material import and maintenance', () => { + let stack: VerifyStack; + let seq = 0; + + const person = async (position: string) => { + const token = await stack.signUp(`material-${position}-${++seq}@example.com`); + const context = await stack.contextFor(token); + const org = context.tenantId ? { organization_id: String(context.tenantId) } : {}; + await stack.seed('sys_user_position', [{ user_id: String(context.userId), position, ...org }]); + return token; + }; + const snapshot = (rows: Array>) => + rows + .map((row) => Object.fromEntries(fields.map((f) => [f, row[f]]))) + .sort((a, b) => String(a.erp_material_no).localeCompare(String(b.erp_material_no))); + const runImport = async (token: string) => { + const res = await stack.apiAs(token, 'POST', `/data/${Material.name}/import`, importBody); + expect(res.status).toBe(200); + return res.json() as Promise<{ total: number; ok: number; errors: number; created: number; updated: number }>; + }; + + beforeAll(async () => { + stack = await bootStack(config); + }); + + afterAll(async () => { + await stack?.stop(); + }); + + it('imports the sample sheet twice with the same result', async () => { + const admin = await person('srm_admin'); + const lines = sampleCsv.trim().split('\n').length - 1; + + const first = await runImport(admin); + expect(first).toMatchObject({ total: lines, errors: 0, created: lines, updated: 0 }); + const afterFirst = snapshot(await stack.rows(Material.name, {}, { as: admin })); + + const second = await runImport(admin); + expect(second).toMatchObject({ total: lines, errors: 0, created: 0 }); + const afterSecond = snapshot(await stack.rows(Material.name, {}, { as: admin })); + + expect(afterSecond).toHaveLength(lines); + expect(afterSecond).toEqual(afterFirst); + expect(afterSecond.find((m) => m.erp_material_no === 'EX-300002')).toMatchObject({ active: false, uom: '盒' }); + }); + + it('keeps a buyer-maintained category when the sheet is imported again', async () => { + const admin = await person('srm_admin'); + const buyer = await person('srm_buyer'); + const [category] = await stack.rows('srm_category', { code: 'ELEC' }, { as: buyer }); + const [material] = await stack.rows(Material.name, { erp_material_no: 'EX-100001' }, { as: buyer }); + await stack.hooks.run(Material.name, 'update', { id: material!.id, category: category!.id }, { as: buyer }); + + await runImport(admin); + const [row] = await stack.rows(Material.name, { id: material!.id }, { as: admin }); + expect(row!.category).toBe(category!.id); + }); + + it('lets a buyer set the category but not change ERP-owned fields', async () => { + const buyer = await person('srm_buyer'); + const [category] = await stack.rows('srm_category', { code: 'PACK' }, { as: buyer }); + const [material] = await stack.rows(Material.name, { erp_material_no: 'EX-200001' }, { as: buyer }); + + await stack.hooks.run(Material.name, 'update', { id: material!.id, category: category!.id }, { as: buyer }); + for (const change of [{ name: '改名' }, { specification: '改规格' }, { active: false }]) { + await expect( + stack.hooks.run(Material.name, 'update', { id: material!.id, ...change }, { as: buyer }), + ).rejects.toBeTruthy(); + } + + const [row] = await stack.rows(Material.name, { id: material!.id }, { as: buyer }); + expect(row).toMatchObject({ category: category!.id, name: material!.name, specification: material!.specification, active: true }); + }); + + it('does not let a buyer create or delete materials', async () => { + const buyer = await person('srm_buyer'); + await expect( + stack.hooks.run(Material.name, 'insert', { erp_material_no: 'EX-999999', material_code: 'X', name: '采购员新建' }, { as: buyer }), + ).rejects.toBeTruthy(); + const [material] = await stack.rows(Material.name, { erp_material_no: 'EX-300001' }, { as: buyer }); + await expect(stack.hooks.run(Material.name, 'delete', { id: material!.id }, { as: buyer })).rejects.toBeTruthy(); + }); + + it('refuses a second material with the same ERP number', async () => { + const admin = await person('srm_admin'); + await expect( + stack.hooks.run(Material.name, 'insert', { erp_material_no: 'EX-100001', material_code: 'DUP', name: '重复' }, { as: admin }), + ).rejects.toBeTruthy(); + }); +}); From 707a2d571e7f80da644ba9fac9cc490b33bca963 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:35:49 +0000 Subject: [PATCH 2/2] docs(core): mark srm_material OWD as a temporary workaround pending objectstack#25470 Claude-Session: https://claude.ai/code/session_01EGdBSEBg7Sy4QhBKoLqR1H Co-authored-by: Claude --- docs/design/02-domain-model.md | 12 +++++++----- src/core/objects/material.object.ts | 2 ++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/design/02-domain-model.md b/docs/design/02-domain-model.md index 15b3710..0025f65 100644 --- a/docs/design/02-domain-model.md +++ b/docs/design/02-domain-model.md @@ -255,16 +255,18 @@ erDiagram (lookup)、`status`(`trial` 试供 / `qualified` 合格 / `suspended` 暂停)、 `approved_date`、`source_application`(lookup 准入申请)。同一供应商同一品类唯一。 -**`srm_material` 物料**(只读副本,OWD `public_read_write`;v0.2):`erp_material_no` +**`srm_material` 物料**(只读副本,OWD `public_read`,暂用 `public_read_write`,见下方说明;v0.2):`erp_material_no` (组织内唯一,导入匹配键)、`material_code`、`name`、`specification`、`uom`、`category`(lookup)、 `active`。用平台导入按 `erp_material_no` 从 ERP 导出表格导入,可重复执行;采购员只 维护 `category` 关联,其他字段对采购员只读(FLS),新建、删除只有 `srm_admin`。ERP 集成后改为 upsert 写入,结构不变。 -> OWD 取 `public_read_write` 而不是 `public_read`:物料归执行导入的管理员所有,`public_read` -> 下采购员要改别人的记录只能授予 `modifyAllRecords`,而平台的 `modifyAllRecords` 同时放开删除。 -> 改为 `public_read_write` 后,能写哪些字段由权限集的 `allowEdit` 与 FLS 决定,删除仍受 `allowDelete` -> 约束。日常重复导入的表格**不带** `category` 列,以免覆盖采购员维护的关联;导入说明见 +> **临时做法**:目标 OWD 是 `public_read`,现阶段先用 `public_read_write`。物料归执行导入的管理员 +> 所有,`public_read` 下采购员要改别人的记录只能授予 `modifyAllRecords`,而平台的 +> `modifyAllRecords` 同时放开删除(平台需求 [objectstack#25470](https://github.com/objectstack-ai/objectstack/issues/25470))。 +> 用 `public_read_write` 时,能写哪些字段由权限集的 `allowEdit` 与 FLS 决定,删除仍受 `allowDelete` +> 约束;代价是今后任何授予 `allowEdit` 的权限集都能改全部物料。平台支持「可改不可删」后改回 +> `public_read`。日常重复导入的表格**不带** `category` 列,以免覆盖采购员维护的关联;导入说明见 > [`data/samples/README.md`](../../data/samples/README.md)。 **`srm_plant` 工厂 / 收货地点**(手工维护或导入):`erp_plant_id`(预留)、`code`、`name`、 diff --git a/src/core/objects/material.object.ts b/src/core/objects/material.object.ts index be4bbcf..f40e5e9 100644 --- a/src/core/objects/material.object.ts +++ b/src/core/objects/material.object.ts @@ -7,6 +7,8 @@ export const Material = ObjectSchema.create({ label: '物料', pluralLabel: '物料', icon: 'package', + // 临时:目标是 public_read。平台的 modifyAllRecords 会连删除一起放开,采购员「可改不可删」 + // 只能先放宽 OWD(objectstack#25470,02 §4.1)。 sharingModel: 'public_read_write', fieldGroups: [