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
24 changes: 15 additions & 9 deletions docs/design/02-domain-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand All @@ -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 → 供应商)

Expand Down
6 changes: 6 additions & 0 deletions docs/design/03-processes-and-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 级
Expand Down
2 changes: 1 addition & 1 deletion docs/design/04-security-and-portal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 同上 | 寻源 | 同上 |
Expand Down
15 changes: 15 additions & 0 deletions src/core/actions/bank-account.action.ts
Original file line number Diff line number Diff line change
@@ -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,
});
1 change: 1 addition & 0 deletions src/core/actions/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { BankVerifyAction } from './bank-account.action.js';
export { ConfirmLegacyReviewAction } from './supplier.actions.js';
1 change: 1 addition & 0 deletions src/core/apps/workbench.app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
{
Expand Down
110 changes: 110 additions & 0 deletions src/core/flows/bank-account/bank-verify.flow.ts
Original file line number Diff line number Diff line change
@@ -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' },
],
};
1 change: 1 addition & 0 deletions src/core/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
4 changes: 3 additions & 1 deletion src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,

Expand Down
1 change: 1 addition & 0 deletions src/core/objects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
116 changes: 116 additions & 0 deletions src/core/objects/supplier-bank-account.object.ts
Original file line number Diff line number Diff line change
@@ -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`,
},
],
});
11 changes: 11 additions & 0 deletions src/core/objects/supplier.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
11 changes: 11 additions & 0 deletions src/core/security/capabilities.ts
Original file line number Diff line number Diff line change
@@ -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];
Loading
Loading