diff --git a/docs/design/02-domain-model.md b/docs/design/02-domain-model.md index 40befa5..1b215b4 100644 --- a/docs/design/02-domain-model.md +++ b/docs/design/02-domain-model.md @@ -207,6 +207,7 @@ erDiagram | `contact_count` | summary | `count` 联系人 | | `primary_contact_count` | summary | `count` 联系人,条件 `is_primary == true`;联系人的主联系人守卫规则读它(v0.2) | | `expired_certificate_count` | summary | `count` 资质证书,条件 `status == 'expired'` | +| `default_bank_account_count` | summary | `count` 银行账户,条件 `is_default == true`;「每家至多一个默认账户」的校验读它(v0.2) | **`srm_supplier_contact` 供应商联系人**(master_detail → 供应商;OWD `controlled_by_parent`) @@ -219,19 +220,24 @@ erDiagram | `is_primary` | boolean | 默认 false;每家供应商至多一位主联系人。守卫:校验规则在供应商 `primary_contact_count > 0` 时拒绝再设一位(插入,或更新时由否改是),导入同样适用;同一批写入里的两位拦不住(objectstack#25477),由「主联系人重复」视图兜底。更换主联系人由「设为主联系人」screen flow 以系统身份先清掉旧的再设新的 | | `active` | boolean | 默认 true | -**`srm_supplier_bank_account` 银行账户**(master_detail → 供应商;v0.2) +**`srm_supplier_bank_account` 银行账户**(master_detail → 供应商;OWD `controlled_by_parent`;v0.2) | 字段 | 类型 | 说明 | |---|---|---| -| `bank_name` / `branch_name` | text | 开户行 / 支行 | -| `account_name` | text | 必须与供应商名称一致(校验规则,境外供应商除外) | -| `account_no` | text | 字段级权限:仅应付会计、采购管理员可读(BR-20) | +| `bank_name` / `branch_name` | text | 开户行(必填)/ 支行 | +| `account_name` | text | 必填;中国供应商(`country == 'cn'`)必须与供应商全称一致(校验规则),境外供应商不校验 | +| `account_no` | text | 必填;脱敏规则 `bank_account`(只留后 4 位)+ `requiredPermissions: ['srm_view_bank_account_no']`:只有持该能力的权限集(`srm_finance`、`srm_supplier_admin`)读到完整值,其他人读到脱敏值且不能按它筛选排序(BR-20,04 §4.2) | | `swift_code` | text | 境外账户 | -| `currency` | select | `cny` / `usd` / `eur` / … | -| `status` | select | `pending_verify` 待核验 / `active` 有效 / `inactive` 停用 | -| `verified_by` | user | 核验人(应付会计) | -| `is_default` | boolean | 默认 false | -| `erp_bank_id` | text | 预留,ERP 集成前空着 | +| `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`) | +| `erp_bank_id` | text(只读) | 预留,ERP 集成前空着 | + +锁定:开户行、支行、户名、账号、SWIFT、币种在任何状态下都 `readonlyWhen` 只读(新建时可填)—— +`pending_verify` 期间整条记录不可改,`active` / `inactive` 账户的修改走信息变更单(v0.2 M4)。 +新建与核验是两个人:采购侧(`srm_supplier_admin`)新建,应付会计经 `srm_bank_verify` 核验, +flow 拒绝新建人核验自己建的账户(03 §13)。 **`srm_supplier_certificate` 资质证书**(master_detail → 供应商) diff --git a/docs/design/03-processes-and-automation.md b/docs/design/03-processes-and-automation.md index 7ed2bab..fe55607 100644 --- a/docs/design/03-processes-and-automation.md +++ b/docs/design/03-processes-and-automation.md @@ -184,6 +184,12 @@ flowchart TD ## 5. P2 信息变更与供应商处置 +**新增银行账户**(v0.2,BR-20):采购侧(`srm_supplier_admin`)在供应商档案下新建银行账户, +记录为 `pending_verify` 并锁定 → 应付会计在账户上点「核验」,经 screen flow `srm_bank_verify` +按 `params/core.yaml` 的 `bank_change_verification` 核实后确认 → 账户置为 `active`,写入 +`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 级 diff --git a/docs/design/04-security-and-portal.md b/docs/design/04-security-and-portal.md index e8345fe..c5180e8 100644 --- a/docs/design/04-security-and-portal.md +++ b/docs/design/04-security-and-portal.md @@ -333,7 +333,7 @@ export const PortalSupplier = definePermissionSet({ | 字段 | 谁可读 | 谁可改 | 原因 | |---|---|---|---| -| `srm_supplier_bank_account.account_no` | 应付会计、`srm_supplier_admin`、本公司门户管理员 | 门户管理员(新建时)、经变更单 flow | 资金安全(BR-20) | +| `srm_supplier_bank_account.account_no` | 完整值:应付会计(`srm_finance`)、`srm_supplier_admin`、本公司门户管理员(v0.6);其他内部岗位读到只留后 4 位的脱敏值。实现为字段 `maskingRule: 'bank_account'` + `requiredPermissions: ['srm_view_bank_account_no']`,能力由权限集 `systemPermissions` 授予;没有任何权限集给银行账户 `allowExport` | 新建时(`srm_supplier_admin`;v0.6 起门户管理员)、经变更单 flow;`pending_verify` 期间与有效后都只读 | 资金安全(BR-20) | | `srm_supplier_contact.mobile` | 内部员工、本公司门户用户 | 本公司门户管理员 | 个人信息最小化 | | `srm_sourcing_event.budget_amount` | 寻源、采购总监、内控 | 寻源 | 门户不可见(门户本就不读寻源单) | | `srm_sourcing_item.target_price` / `last_price` | 同上 | 寻源 | 同上 | diff --git a/src/core/actions/bank-account.action.ts b/src/core/actions/bank-account.action.ts new file mode 100644 index 0000000..577e545 --- /dev/null +++ b/src/core/actions/bank-account.action.ts @@ -0,0 +1,15 @@ +import { P } from '@objectstack/spec'; +import { defineAction } from '@objectstack/spec/ui'; + +// 银行账户详情页的「核验」按钮,启动 srm_bank_verify(03 §13)。谁能核验由 flow 自己检查。 +export const BankVerifyAction = defineAction({ + name: 'srm_bank_verify', + label: '核验', + objectName: 'srm_supplier_bank_account', + icon: 'shield-check', + type: 'flow', + target: 'srm_bank_verify', + locations: ['record_header', 'list_item'], + visible: P`record.status == 'pending_verify'`, + refreshAfter: true, +}); diff --git a/src/core/actions/index.ts b/src/core/actions/index.ts index 9e94300..a0952bc 100644 --- a/src/core/actions/index.ts +++ b/src/core/actions/index.ts @@ -1 +1,2 @@ +export { BankVerifyAction } from './bank-account.action.js'; export { ConfirmLegacyReviewAction } from './supplier.actions.js'; diff --git a/src/core/apps/workbench.app.ts b/src/core/apps/workbench.app.ts index 159fe9a..b7cced5 100644 --- a/src/core/apps/workbench.app.ts +++ b/src/core/apps/workbench.app.ts @@ -16,6 +16,7 @@ export const Workbench = App.create({ { id: 'nav_supplier', type: 'object', objectName: 'srm_supplier', label: '供应商', icon: 'factory' }, { 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' }, ], }, { diff --git a/src/core/flows/bank-account/bank-verify.flow.ts b/src/core/flows/bank-account/bank-verify.flow.ts new file mode 100644 index 0000000..f30d037 --- /dev/null +++ b/src/core/flows/bank-account/bank-verify.flow.ts @@ -0,0 +1,110 @@ +import { P, cel } from '@objectstack/spec'; +import type * as Automation from '@objectstack/spec/automation'; + +// 核验银行账户(03 §13 screen flow,04 §4.2,BR-20):采购侧新建的账户为「待核验」并锁定, +// 应付会计按 params/core.yaml 的 bank_change_verification 核实后在这里确认,账户置为「有效」。 +// 双人审批:新建人不能核验自己建的账户。以系统身份运行,因为状态和核验人对所有人只读; +// 进门的检查都在 flow 里做(只有应付会计、只核验待核验的账户、不能是新建人)。 +// 临时做法:平台还不能在 flow 上声明谁可以启动(objectstack#25481),任何成员都能经 trigger 入口 +// 启动系统身份的 screen flow,所以岗位检查只能写在 flow 里读 current_user.positions;平台支持后改为声明。 +export const BankVerifyFlow: Automation.Flow = { + name: 'srm_bank_verify', + label: '核验银行账户', + type: 'screen', + status: 'active', + runAs: 'system', + successMessage: '银行账户已核验,状态为有效', + + 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_bank_account', filter: { id: cel`recordId` }, outputVariable: 'account' }, + }, + { id: 'gate', type: 'decision', label: '可以核验?' }, + { + id: 'refuse_position', + type: 'end', + label: '不是应付会计', + config: { outcome: 'refused', message: '只有应付会计可以核验银行账户' }, + }, + { + id: 'refuse_status', + type: 'end', + label: '不是待核验', + config: { outcome: 'refused', message: '只有「待核验」的银行账户需要核验' }, + }, + { + id: 'refuse_self', + type: 'end', + label: '新建人不能核验', + config: { outcome: 'refused', message: '新建人不能核验自己建的银行账户,请由另一位应付会计核验' }, + }, + { + id: 'confirm', + type: 'screen', + label: '核对账户', + config: { + title: '核验银行账户:{{ account.account_name }}', + description: + '开户行:{{ account.bank_name }} {{ account.branch_name }}\n银行账号:{{ account.account_no }}\n' + + '请先通过对公账户验证或向供应商法定代表人电话核实等方式确认账户属实,再确认核验。', + waitForInput: true, + }, + }, + { + id: 'activate', + type: 'update_record', + label: '置为有效', + config: { + objectName: 'srm_supplier_bank_account', + filter: { id: cel`account.id` }, + fields: { status: 'active', verified_by: cel`current_user.id` }, + }, + }, + { id: 'end', type: 'end', label: '结束' }, + ], + + edges: [ + { id: 'e0', source: 'start', target: 'load', type: 'default' }, + { id: 'e1', source: 'load', target: 'gate', type: 'default' }, + { + id: 'e2', + source: 'gate', + target: 'refuse_position', + type: 'conditional', + condition: P`current_user == null || !('srm_ap_accountant' in current_user.positions)`, + label: '不是应付会计', + }, + { + id: 'e3', + source: 'gate', + target: 'refuse_status', + type: 'conditional', + condition: P`current_user != null && 'srm_ap_accountant' in current_user.positions && (account == null || account.status != 'pending_verify')`, + label: '不是待核验', + }, + { + id: 'e4', + source: 'gate', + target: 'refuse_self', + type: 'conditional', + condition: P`current_user != null && 'srm_ap_accountant' in current_user.positions && account != null && account.status == 'pending_verify' && account.created_by == current_user.id`, + label: '是新建人', + }, + { + id: 'e5', + source: 'gate', + target: 'confirm', + type: 'conditional', + condition: P`current_user != null && 'srm_ap_accountant' in current_user.positions && account != null && account.status == 'pending_verify' && account.created_by != current_user.id`, + label: '可以', + }, + { id: 'e6', source: 'confirm', target: 'activate', type: 'default' }, + { id: 'e7', source: 'activate', target: 'end', type: 'default' }, + ], +}; diff --git a/src/core/flows/index.ts b/src/core/flows/index.ts index d95d6ee..59ab72f 100644 --- a/src/core/flows/index.ts +++ b/src/core/flows/index.ts @@ -3,3 +3,4 @@ export { AdmissionApprovalFlow } from './admission/approval.flow.js'; 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'; diff --git a/src/core/index.ts b/src/core/index.ts index 899f8bb..3313dea 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -9,6 +9,7 @@ import * as translations from './translations/index.js'; import { SrmSeedData } from './data/index.js'; import { SrmPermissionSets } from './security/permission-sets.js'; import { SrmPositions } from './security/positions.js'; +import { SrmCapabilities } from './security/capabilities.js'; // 通用核心(type: app,命名空间 srm)。扩展包在 objectstack.config.ts 里与它合成一个应用。 export default defineStack({ @@ -32,8 +33,9 @@ export default defineStack({ picklists: Object.values(picklists), views: Object.values(views), flows: Object.values(flows), - apps: Object.values(apps), actions: Object.values(actions), + apps: Object.values(apps), + capabilities: SrmCapabilities, permissions: SrmPermissionSets, positions: SrmPositions, diff --git a/src/core/objects/index.ts b/src/core/objects/index.ts index c5f911f..1c44619 100644 --- a/src/core/objects/index.ts +++ b/src/core/objects/index.ts @@ -5,4 +5,5 @@ 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 { SupplierBankAccount } from './supplier-bank-account.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 new file mode 100644 index 0000000..dc4be3b --- /dev/null +++ b/src/core/objects/supplier-bank-account.object.ts @@ -0,0 +1,116 @@ +import { P } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +// 银行账户(02 §4.1,v0.2 M2)。采购侧新建后为「待核验」并锁定,应付会计经「核验银行账户」 +// screen flow 置为有效(双人审批,04 §4.2);有效账户的关键字段只读,修改走信息变更单(v0.2 M4)。 +// 收款与付款留在 ERP(ADR-0008),这里只保存账户主数据。 +const locked = P`record.status == 'pending_verify' || record.status == 'active' || record.status == 'inactive'`; + +export const SupplierBankAccount = ObjectSchema.create({ + name: 'srm_supplier_bank_account', + label: '银行账户', + pluralLabel: '银行账户', + icon: 'landmark', + nameField: 'account_name', + sharingModel: 'controlled_by_parent', + enable: { trackHistory: true }, + stageField: 'status', + + fieldGroups: [ + { key: 'account', label: '账户信息', icon: 'landmark' }, + { key: 'review', label: '状态与核验', icon: 'shield-check' }, + ], + + fields: { + supplier: Field.masterDetail('srm_supplier', { label: '供应商', required: true, deleteBehavior: 'cascade' }), + bank_name: Field.text({ label: '开户行', group: 'account', required: true, maxLength: 100, readonlyWhen: locked }), + branch_name: Field.text({ label: '支行', group: 'account', maxLength: 100, readonlyWhen: locked }), + account_name: Field.text({ label: '账户名称', group: 'account', required: true, searchable: true, maxLength: 200, readonlyWhen: locked }), + // BR-20:只有持「查看完整银行账号」能力的岗位(应付会计、供应商管理)能读到完整账号, + // 其他人读到的是只留后 4 位的脱敏值,也不能按账号筛选或排序 + account_no: Field.text({ + label: '银行账号', + group: 'account', + required: true, + maxLength: 50, + readonlyWhen: locked, + maskingRule: 'bank_account', + requiredPermissions: ['srm_view_bank_account_no'], + }), + swift_code: Field.text({ label: 'SWIFT 代码', group: 'account', maxLength: 11, readonlyWhen: locked }), + currency: Field.select({ + label: '币种', + group: 'account', + required: true, + defaultValue: 'cny', + readonlyWhen: locked, + options: [ + { label: '人民币', value: 'cny' }, + { label: '美元', value: 'usd' }, + { label: '欧元', value: 'eur' }, + { label: '日元', value: 'jpy' }, + { label: '港币', value: 'hkd' }, + ], + }), + status: Field.select({ + label: '状态', + group: 'review', + defaultValue: 'pending_verify', + readonly: true, + options: [ + { label: '待核验', value: 'pending_verify', kind: 'open' }, + { label: '有效', value: 'active', kind: 'won' }, + { label: '停用', value: 'inactive', kind: 'lost' }, + ], + }), + verified_by: Field.user({ label: '核验人', group: 'review', readonly: true }), + is_default: Field.boolean({ + label: '默认账户', + group: 'account', + defaultValue: false, + readonlyWhen: P`record.status != 'active'`, + }), + erp_bank_id: Field.text({ label: 'ERP 银行账户 ID', group: 'review', maxLength: 50, readonly: true }), + }, + + validations: [ + { + name: 'bank_account_status_flow', + type: 'state_machine', + field: 'status', + message: '银行账户状态不允许这样变更', + // 用户新建一律是 pending_verify(status 只读,写入的值被丢弃);active 留给信息变更单 flow + // 以系统身份直接建出已审批的新账户(03 §5,v0.2 M4) + initialStates: ['pending_verify', 'active'], + transitions: { + pending_verify: ['active'], + active: ['inactive'], + inactive: ['active'], + }, + }, + { + // 户名与供应商全称一致,防止付款给第三方;境外账户的户名常用英文名,不校验 + name: 'account_name_matches_supplier', + type: 'script', + severity: 'error', + message: '账户名称必须与供应商全称一致', + condition: P`has(record.supplier.country) && record.supplier.country == 'cn' && has(record.supplier.name) && record.account_name != record.supplier.name`, + }, + { + // 只有核验通过的账户能设为默认(readonlyWhen 不管新建,新建时由这条拦住) + name: 'default_account_active', + type: 'script', + severity: 'error', + message: '只有「有效」的银行账户可以设为默认账户', + condition: P`record.is_default == true && record.status != 'active'`, + }, + { + // 每家供应商至多一个默认账户:供应商上的 default_bank_account_count 是本条写入前的计数 + name: 'single_default_account', + type: 'script', + severity: 'error', + message: '这家供应商已有默认账户,请先取消原默认账户', + condition: P`record.is_default == true && (!has(previous.is_default) || previous.is_default != true) && has(record.supplier.default_bank_account_count) && record.supplier.default_bank_account_count > 0`, + }, + ], +}); diff --git a/src/core/objects/supplier.object.ts b/src/core/objects/supplier.object.ts index 73a95d3..00584e0 100644 --- a/src/core/objects/supplier.object.ts +++ b/src/core/objects/supplier.object.ts @@ -124,6 +124,17 @@ export const Supplier = ObjectSchema.create({ filter: { status: 'expired' }, }, }), + // 银行账户「每家至多一个默认账户」的校验读这个计数(v0.2) + default_bank_account_count: Field.summary({ + label: '默认银行账户数', + group: 'qualification', + summaryOperations: { + object: 'srm_supplier_bank_account', + field: 'id', + function: 'count', + filter: { is_default: true }, + }, + }), }, indexes: [ diff --git a/src/core/security/capabilities.ts b/src/core/security/capabilities.ts new file mode 100644 index 0000000..b5b9a05 --- /dev/null +++ b/src/core/security/capabilities.ts @@ -0,0 +1,11 @@ +import { defineCapability } from '@objectstack/spec'; + +// 应用自己的授权能力(平台 ADR-0066 D1)。字段用 requiredPermissions 引用,权限集用 systemPermissions 授予。 +export const ViewBankAccountNo = defineCapability({ + name: 'srm_view_bank_account_no', + label: '查看完整银行账号', + description: '读取供应商银行账号的完整值;没有这项能力的人只看到后 4 位(BR-20)', + scope: 'org', +}); + +export const SrmCapabilities = [ViewBankAccountNo]; diff --git a/src/core/security/permission-sets.ts b/src/core/security/permission-sets.ts index 5d25a4e..de710e8 100644 --- a/src/core/security/permission-sets.ts +++ b/src/core/security/permission-sets.ts @@ -25,6 +25,7 @@ export const InternalBasePermissionSet = { srm_supplier_contact: read, srm_supplier_certificate: read, srm_supplier_category: read, + srm_supplier_bank_account: read, }, }; @@ -55,7 +56,11 @@ export const SupplierAdminPermissionSet = { srm_supplier_certificate: write, srm_supplier_category: write, srm_supplier_application: write, + // 新建账户后为「待核验」并锁定;核验只经 srm_bank_verify,已有账户的修改走信息变更单(v0.2 M4) + srm_supplier_bank_account: write, }, + // 银行账号完整可读(BR-20,04 §4.2) + systemPermissions: ['srm_view_bank_account_no'], fields: { 'srm_supplier.status': flowOwned, 'srm_supplier.qualification_alert': flowOwned, @@ -100,7 +105,8 @@ export const ExecutivePermissionSet = { }, }; -// 财务(应付会计):供应商档案只读。银行账户(v0.2 M2)合入后在此加账号可读,核验只经 screen flow +// 财务(应付会计):供应商档案与银行账户只读,银行账号完整可读(BR-20); +// 核验只经「核验银行账户」screen flow(srm_bank_verify),不直接改记录 export const FinancePermissionSet = { name: 'srm_finance', label: 'SRM 财务', @@ -108,7 +114,9 @@ export const FinancePermissionSet = { srm_supplier: read, srm_supplier_contact: read, srm_supplier_certificate: read, + srm_supplier_bank_account: read, }, + systemPermissions: ['srm_view_bank_account_no'], }; // 系统管理员:维护品类与工厂等配置,执行物料导入和存量供应商、联系人、证书导入(04 §2.2) diff --git a/src/core/translations/en.ts b/src/core/translations/en.ts index 3f5c099..618f6b9 100644 --- a/src/core/translations/en.ts +++ b/src/core/translations/en.ts @@ -108,6 +108,7 @@ export const en: TranslationData = { contact_count: { label: 'Contacts' }, primary_contact_count: { label: 'Primary Contacts' }, expired_certificate_count: { label: 'Expired Certificates' }, + default_bank_account_count: { label: 'Default Bank Accounts' }, }, _views: { default: { label: 'All Suppliers' }, @@ -168,6 +169,41 @@ export const en: TranslationData = { legacy_missing_attachment: { label: 'Legacy Imports Missing a Scan' }, }, }, + srm_supplier_bank_account: { + label: 'Bank Account', + pluralLabel: 'Bank Accounts', + _sections: { account: { label: 'Account' }, review: { label: 'Status and Verification' } }, + _validations: { + bank_account_status_flow: { message: 'This bank account status change is not allowed' }, + account_name_matches_supplier: { message: 'The account name must match the supplier legal name' }, + default_account_active: { message: 'Only an active bank account can be the default account' }, + single_default_account: { message: 'This supplier already has a default account; clear it first' }, + }, + _actions: { srm_bank_verify: { label: 'Verify' } }, + fields: { + supplier: { label: 'Supplier' }, + bank_name: { label: 'Bank' }, + branch_name: { label: 'Branch' }, + account_name: { label: 'Account Name' }, + account_no: { label: 'Account No.' }, + swift_code: { label: 'SWIFT Code' }, + currency: { + label: 'Currency', + options: { cny: 'CNY', usd: 'USD', eur: 'EUR', jpy: 'JPY', hkd: 'HKD' }, + }, + status: { + label: 'Status', + options: { pending_verify: 'Pending Verification', active: 'Active', inactive: 'Inactive' }, + }, + verified_by: { label: 'Verified By' }, + is_default: { label: 'Default Account' }, + erp_bank_id: { label: 'ERP Bank Account ID' }, + }, + _views: { + default: { label: 'All Bank Accounts' }, + pending_verify: { label: 'Pending Verification' }, + }, + }, srm_supplier_category: { label: 'Category Qualification', pluralLabel: 'Category Qualifications', @@ -246,6 +282,25 @@ export const en: TranslationData = { }, }, }, + flows: { + srm_bank_verify: { + label: 'Verify Bank Account', + successMessage: 'Bank account verified and now active', + screens: { + confirm: { + title: 'Verify bank account: {{ account.account_name }}', + description: + 'Bank: {{ account.bank_name }} {{ account.branch_name }}\nAccount No.: {{ account.account_no }}\n' + + 'Confirm the account first, for example by a corporate-account check or a call to the supplier\'s legal representative, then verify.', + }, + }, + refusals: { + refuse_position: { message: 'Only AP accountants can verify bank accounts' }, + refuse_status: { message: 'Only bank accounts pending verification need verifying' }, + refuse_self: { message: 'You cannot verify a bank account you created; ask another AP accountant' }, + }, + }, + }, apps: { srm_workbench: { label: 'SRM Workbench', @@ -254,6 +309,7 @@ export const en: TranslationData = { nav_supplier: { label: 'Suppliers' }, nav_application: { label: 'Admission Applications' }, nav_certificate: { label: 'Certificates' }, + nav_bank_account: { label: 'Bank Accounts' }, 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 506f937..a0c97ad 100644 --- a/src/core/views/index.ts +++ b/src/core/views/index.ts @@ -3,4 +3,5 @@ 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 { SupplierBankAccountViews } from './supplier-bank-account.view.js'; export { MaterialViews } from './material.view.js'; diff --git a/src/core/views/supplier-bank-account.view.ts b/src/core/views/supplier-bank-account.view.ts new file mode 100644 index 0000000..8fb0bba --- /dev/null +++ b/src/core/views/supplier-bank-account.view.ts @@ -0,0 +1,31 @@ +import { defineView } from '@objectstack/spec'; + +const data = { provider: 'object' as const, object: 'srm_supplier_bank_account' }; +const columns = [ + { field: 'supplier' }, + { field: 'account_name' }, + { field: 'bank_name' }, + { field: 'account_no' }, + { field: 'currency' }, + { field: 'status' }, + { field: 'is_default' }, +]; + +export const SupplierBankAccountViews = defineView({ + list: { + label: '全部银行账户', + type: 'grid', + data, + columns, + }, + + listViews: { + pending_verify: { + label: '待核验', + type: 'grid', + data, + columns, + filter: [{ field: 'status', operator: 'equals', value: 'pending_verify' }], + }, + }, +}); diff --git a/test/bank-account.test.ts b/test/bank-account.test.ts new file mode 100644 index 0000000..3dca797 --- /dev/null +++ b/test/bank-account.test.ts @@ -0,0 +1,180 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import config from '../objectstack.config'; +import { Supplier, SupplierBankAccount } from '../src/core/objects'; +import { SrmPermissionSets } from '../src/core/security/permission-sets'; + +// v0.2 M2 银行账户(02 §4.1、03 §13、04 §4.2,BR-20):采购侧新建为「待核验」并锁定, +// 应付会计经 srm_bank_verify 核验后有效;新建人不能核验;完整账号只有应付会计与供应商管理可读。 +describe('supplier bank account', () => { + let stack: VerifyStack; + let seq = 0; + + const person = async (...positions: string[]) => { + const token = await stack.signUp(`bank-${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; + }; + const read = async (id: string) => (await stack.rows(SupplierBankAccount.name, { id }))[0]!; + + const newSupplier = async (as: string, country = 'cn') => { + const n = ++seq; + const doc = + country === 'cn' + ? { name: `银行测试供应商 ${n}`, credit_code: `91000000MA0BNK${String(n).padStart(4, '0')}` } + : { name: `Bank Test Supplier ${n} GmbH`, country, registration_no: `HRB${n}` }; + return (await stack.hooks.run(Supplier.name, 'insert', doc, { as })) as { id: string; name: string }; + }; + const newAccount = async (as: string, supplier: { id: string; name: string }, extra: Record = {}) => + (await stack.hooks.run( + SupplierBankAccount.name, + 'insert', + { + supplier: supplier.id, + bank_name: '示例银行', + branch_name: '示例支行', + account_name: supplier.name, + account_no: `62220000${String(++seq).padStart(8, '0')}`, + ...extra, + }, + { as }, + )) as { id: string; account_no: string }; + + // 从详情页按钮启动核验,停在确认屏后提交 + const verify = async (as: string, recordId: string) => { + const started = (await stack.actions.run(SupplierBankAccount.name, 'srm_bank_verify', { as, recordId })) as { + status?: string; + runId?: string; + }; + if (started.status !== 'paused') return started; + return stack.flows.resume({ flowName: 'srm_bank_verify', runId: started.runId }, {}, { as }); + }; + + let manager: string; + let accountant: string; + + beforeAll(async () => { + stack = await bootStack(config); + manager = await person('srm_category_manager'); + accountant = await person('srm_ap_accountant'); + }); + + afterAll(async () => { + await stack?.stop(); + }); + + it('creates an account pending verification even if the creator asks for active', async () => { + const supplier = await newSupplier(manager); + const account = await newAccount(manager, supplier, { status: 'active', verified_by: null }); + expect(await read(account.id)).toMatchObject({ status: 'pending_verify', bank_name: '示例银行', account_no: account.account_no }); + }); + + it('does not let a new account be the default before it is verified', async () => { + const supplier = await newSupplier(manager); + await expect(newAccount(manager, supplier, { is_default: true })).rejects.toBeTruthy(); + }); + + it('runs create → verify → active and records the verifier', async () => { + const supplier = await newSupplier(manager); + const account = await newAccount(manager, supplier); + const run = await verify(accountant, account.id); + expect(run, JSON.stringify(run)).toMatchObject({ success: true }); + const row = await read(account.id); + expect(row.status).toBe('active'); + expect(row.verified_by).toBe(String((await stack.contextFor(accountant)).userId)); + }); + + it('refuses verification by the person who created the account', async () => { + const both = await person('srm_category_manager', 'srm_ap_accountant'); + const supplier = await newSupplier(both); + const account = await newAccount(both, supplier); + const run = (await verify(both, account.id)) as { status?: string }; + expect(run, JSON.stringify(run)).toMatchObject({ status: 'refused' }); + expect(JSON.stringify(run)).toContain('a bank account you created'); + expect((await read(account.id)).status).toBe('pending_verify'); + }); + + it('refuses verification by someone who is not an AP accountant', async () => { + const supplier = await newSupplier(manager); + const account = await newAccount(manager, supplier); + const other = await person('srm_category_manager'); + const run = (await verify(other, account.id)) as { status?: string }; + expect(run, JSON.stringify(run)).toMatchObject({ status: 'refused' }); + expect(JSON.stringify(run)).toContain('Only AP accountants'); + expect((await read(account.id)).status).toBe('pending_verify'); + }); + + it('keeps a pending account locked', async () => { + const supplier = await newSupplier(manager); + const account = await newAccount(manager, supplier); + await stack.hooks + .run(SupplierBankAccount.name, 'update', { id: account.id, bank_name: '改过的银行', account_no: '6222999999999999' }, { as: manager }) + .catch(() => undefined); + expect(await read(account.id)).toMatchObject({ bank_name: '示例银行', account_no: account.account_no }); + }); + + it('keeps the key fields of an active account read-only but lets it become the default', async () => { + const supplier = await newSupplier(manager); + const account = await newAccount(manager, supplier); + await verify(accountant, account.id); + await stack.hooks + .run(SupplierBankAccount.name, 'update', { id: account.id, account_no: '6222999999999999' }, { as: manager }) + .catch(() => undefined); + await stack.hooks.run(SupplierBankAccount.name, 'update', { id: account.id, is_default: true }, { as: manager }); + expect(await read(account.id)).toMatchObject({ account_no: account.account_no, is_default: true }); + }); + + it('allows at most one default account per supplier', async () => { + const supplier = await newSupplier(manager); + const first = await newAccount(manager, supplier); + const second = await newAccount(manager, supplier); + await verify(accountant, first.id); + await verify(accountant, second.id); + await stack.hooks.run(SupplierBankAccount.name, 'update', { id: first.id, is_default: true }, { as: manager }); + await expect( + stack.hooks.run(SupplierBankAccount.name, 'update', { id: second.id, is_default: true }, { as: manager }), + ).rejects.toBeTruthy(); + expect((await read(second.id)).is_default).toBe(false); + }); + + it('requires a domestic account name to match the supplier name', async () => { + const supplier = await newSupplier(manager); + await expect(newAccount(manager, supplier, { account_name: '别的公司' })).rejects.toBeTruthy(); + }); + + it('does not check the account name of an overseas supplier', async () => { + const supplier = await newSupplier(manager, 'de'); + const account = await newAccount(manager, supplier, { account_name: 'Bank Test Supplier', currency: 'eur', swift_code: 'DEUTDEFF' }); + expect((await read(account.id)).status).toBe('pending_verify'); + }); + + it('shows the full account number to AP accountants and supplier admins only', async () => { + const supplier = await newSupplier(manager); + const account = await newAccount(manager, supplier); + const buyer = await person('srm_buyer'); + const [asAccountant] = await stack.rows(SupplierBankAccount.name, { id: account.id }, { as: accountant }); + const [asManager] = await stack.rows(SupplierBankAccount.name, { id: account.id }, { as: manager }); + const [asBuyer] = await stack.rows(SupplierBankAccount.name, { id: account.id }, { as: buyer }); + expect(asAccountant!.account_no).toBe(account.account_no); + expect(asManager!.account_no).toBe(account.account_no); + expect(asBuyer!.account_no).not.toBe(account.account_no); + expect(String(asBuyer!.account_no).endsWith(account.account_no.slice(-4))).toBe(true); + }); + + it('grants no permission set export on bank accounts', () => { + const exporting = SrmPermissionSets.filter( + (set) => (set.objects as Record).srm_supplier_bank_account?.allowExport, + ); + expect(exporting).toEqual([]); + }); + + it('does not let an AP accountant edit an account directly', async () => { + const supplier = await newSupplier(manager); + const account = await newAccount(manager, supplier); + await expect( + stack.hooks.run(SupplierBankAccount.name, 'update', { id: account.id, branch_name: '改' }, { as: accountant }), + ).rejects.toBeTruthy(); + }); +}); diff --git a/test/permissions.test.ts b/test/permissions.test.ts index 457254a..8497b8c 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -66,6 +66,7 @@ describe('internal permissions', () => { "srm_material": "R", "srm_plant": "R", "srm_supplier": "R", + "srm_supplier_bank_account": "R", "srm_supplier_category": "R", "srm_supplier_certificate": "R", "srm_supplier_contact": "R", @@ -81,6 +82,7 @@ describe('internal permissions', () => { "srm_plant": "R", "srm_supplier": "RCE", "srm_supplier_application": "RCE", + "srm_supplier_bank_account": "RCE", "srm_supplier_category": "RCE", "srm_supplier_certificate": "RCE", "srm_supplier_contact": "RCED",