feat: 初步支持审批流程

Reviewed-on: daoyoucloud/tachycode#664
This commit is contained in:
sealday 2024-04-10 16:00:42 +08:00
parent 8a2548f480
commit 438169cd71
29 changed files with 4029 additions and 3 deletions

View File

@ -0,0 +1,2 @@
/node_modules
/src

View File

@ -0,0 +1 @@
# @hera/plugin-approval

View File

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

View File

@ -0,0 +1,27 @@
{
"name": "@hera/plugin-approval",
"version": "0.0.1",
"main": "dist/server/index.js",
"devDependencies": {
"@ant-design/icons": "5.x",
"@formily/antd-v5": "1.1.9",
"@nocobase/actions": "workspace:*",
"@nocobase/data-source-manager": "workspace:*",
"@nocobase/database": "workspace:*",
"@nocobase/plugin-ui-schema-storage": "workspace:*",
"@nocobase/schema": "workspace:*",
"@nocobase/utils": "workspace:*",
"@types/lodash": "4.17.0",
"antd": "5.x",
"lodash": "^4.17.21",
"react": "18.x",
"react-i18next": "^11.15.1",
"sequelize": "^6.26.0"
},
"peerDependencies": {
"@nocobase/client": "workspace:*",
"@nocobase/plugin-workflow": "workspace:*",
"@nocobase/server": "workspace:*",
"@nocobase/test": "workspace:*"
}
}

View File

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

View File

@ -0,0 +1,26 @@
import { SchemaInitializer } from '@nocobase/client';
import { NAMESPACE } from '../locale';
import { ke, X } from './refined';
export const ApprovalApplyAddActionButton = new SchemaInitializer({
name: 'ApprovalApplyAddActionButton',
title: '{{t("Configure actions")}}',
items: [
{
name: 'submit',
type: 'item',
title: '{{t("Submit")}}',
Component: ke,
action: X.SUBMITTED,
actionProps: { type: 'primary' },
disabled: !0,
},
{
name: 'save',
type: 'item',
title: `{{t("Save draft", { ns: "${NAMESPACE}" })}}`,
Component: ke,
action: X.DRAFT,
},
],
});

View File

@ -0,0 +1,23 @@
import { SchemaInitializer, gridRowColWrap } from '@nocobase/client';
import { NAMESPACE } from '../locale';
import { Po } from './refined';
export const ApprovalApplyAddBlockButton = new SchemaInitializer({
name: 'ApprovalApplyAddBlockButton',
wrap: gridRowColWrap,
title: '{{t("Add block")}}',
items: [
{
name: 'forms',
type: 'itemGroup',
title: '{{t("Form")}}',
children: [{ name: 'form', type: 'item', title: `{{t("Apply form", { ns: "${NAMESPACE}" })}}`, Component: Po }],
},
{
name: 'others',
type: 'itemGroup',
title: '{{t("Other blocks")}}',
children: [{ name: 'markdown', type: 'item', title: '{{t("Markdown")}}', Component: 'MarkdownBlockInitializer' }],
},
],
});

View File

@ -0,0 +1,163 @@
import { ArrayItems } from '@formily/antd-v5';
import { css } from '@nocobase/client';
import { Instruction, RadioWithTooltip } from '@nocobase/plugin-workflow/client';
import { uid } from '@nocobase/utils/client';
import { NAMESPACE } from '../locale';
import { T, rt, ot, et, Ro, jo, it, st, at, b } from './refined';
export class ApprovalInstruction extends Instruction {
constructor() {
super(...arguments);
T(this, 'title', `{{t("Approval", { ns: "${NAMESPACE}" })}}`);
T(this, 'type', 'approval');
T(
this,
'description',
`{{t("Manual approval operations within the approval process, the approver can approve in the global approval block or in the approval block of a single record.", { ns: "${NAMESPACE}" })}}`
);
T(this, 'group', 'manual');
T(this, 'fieldset', {
branchMode: {
type: 'boolean',
title: `{{t("Pass mode", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
'x-component-props': { disabled: !0 },
enum: rt([
{
value: !1,
label: `{{t('Passthrough mode', { ns: "${NAMESPACE}" })}}`,
tooltip: `{{t('When rejected or returned, the workflow will be terminated immediately.', { ns: "${NAMESPACE}" })}}`,
},
{
value: !0,
label: `{{t('Branch mode', { ns: "${NAMESPACE}" })}}`,
tooltip: `{{t('Could run different branch based on result.', { ns: "${NAMESPACE}" })}}`,
},
]),
default: !1,
},
assignees: {
type: 'array',
title: `{{t("Assignees", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'ArrayItems',
'x-component-props': {
className: css`
&[disabled] {
> .ant-formily-array-base-addition {
display: none;
}
> .ant-formily-array-items-item .ant-space-item:not(:nth-child(2)) {
display: none;
}
}
`,
},
items: {
type: 'void',
'x-component': 'Space',
'x-component-props': {
className: css`
width: 100%;
&.ant-space.ant-space-horizontal {
flex-wrap: nowrap;
}
> .ant-space-item:nth-child(2) {
flex-grow: 1;
}
`,
},
properties: {
sort: { type: 'void', 'x-decorator': 'FormItem', 'x-component': 'ArrayItems.SortHandle' },
input: { type: 'string', 'x-decorator': 'FormItem', 'x-component': 'AssigneesSelect' },
remove: { type: 'void', 'x-decorator': 'FormItem', 'x-component': 'ArrayItems.Remove' },
},
},
required: !0,
properties: {
add: {
type: 'void',
title: `{{t("Add assignee", { ns: "${NAMESPACE}" })}}`,
'x-component': 'AssigneesAddition',
},
},
},
negotiation: {
type: 'number',
title: `{{t("Negotiation mode", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'NegotiationConfig',
default: 0,
},
order: {
type: 'boolean',
title: `{{t("Order", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'RadioWithTooltip',
'x-component-props': {
options: [
{
label: `{{t("Parallelly", { ns: "${NAMESPACE}" })}}`,
value: !1,
tooltip: `{{t("Multiple approvers can approve in any order.", { ns: "${NAMESPACE}" })}}`,
},
{
label: `{{t("Sequentially", { ns: "${NAMESPACE}" })}}`,
value: !0,
tooltip: `{{t("Multiple approvers in sequential order.", { ns: "${NAMESPACE}" })}}`,
},
],
},
default: !1,
},
endOnReject: {
type: 'boolean',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
'x-content': `{{t("End the workflow after rejection branch", { ns: "${NAMESPACE}" })}}`,
description: `{{t("When checked, the workflow will terminate when the rejection branch ends.", { ns: "${NAMESPACE}" })}}`,
'x-reactions': [{ dependencies: ['.branchMode'], fulfill: { state: { visible: '{{$deps[0]}}' } } }],
},
applyDetail: {
type: 'void',
title: `{{t("Approver's interface", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'SchemaConfigButton',
properties: { applyDetail: { type: 'void', 'x-component': 'SchemaConfig' } },
required: !0,
},
});
T(this, 'options', [
{
label: `{{t('Passthrough mode', { ns: "${NAMESPACE}" })}}`,
key: 'false',
value() {
return { branchMode: !1, applyDetail: uid() };
},
},
{
label: `{{t('Branch mode', { ns: "${NAMESPACE}" })}}`,
key: 'true',
value() {
return { branchMode: !0, applyDetail: uid() };
},
},
]);
T(this, 'Component', ot);
T(this, 'components', {
ArrayItems: ArrayItems,
SchemaConfigButton: et,
SchemaConfig: Ro,
AssigneesSelect: jo,
NegotiationConfig: it,
RadioWithTooltip: RadioWithTooltip,
AssigneesAddition: st,
});
}
isAvailable({ workflow: n, upstream: a, branchIndex: s }) {
return !(
n.type !== 'approval' || at(a, s, (i, v) => (i == null ? void 0 : i.type) === 'approval' && v === b.RETURNED)
);
}
}

View File

@ -0,0 +1,33 @@
import { SchemaInitializer } from '@nocobase/client';
import { NAMESPACE } from '../locale';
import { b, ie } from './refined';
export const ApprovalProcessAddActionButton = new SchemaInitializer({
name: 'ApprovalProcessAddActionButton',
title: '{{t("Configure actions")}}',
items: [
{
name: `action-${b.APPROVED}`,
type: 'item',
title: `{{t("Approve", { ns: "${NAMESPACE}" })}}`,
Component: ie,
action: b.APPROVED,
actionProps: { type: 'primary' },
},
{
name: `action-${b.REJECTED}`,
type: 'item',
title: `{{t("Reject", { ns: "${NAMESPACE}" })}}`,
Component: ie,
action: b.REJECTED,
actionProps: { danger: !0 },
},
{
name: `action-${b.RETURNED}`,
type: 'item',
title: `{{t("Return", { ns: "${NAMESPACE}" })}}`,
Component: ie,
action: b.RETURNED,
},
],
});

View File

@ -0,0 +1,53 @@
import { SchemaInitializer, gridRowColWrap, usePlugin } from '@nocobase/client';
import PluginWorkflow, {
useAvailableUpstreams,
useFlowContext,
useNodeContext, useTrigger
} from '@nocobase/plugin-workflow/client';
import { NAMESPACE } from '../locale';
import { Lo, Jo } from './refined';
export const ApprovalProcessAddBlockButton = new SchemaInitializer({
name: 'ApprovalProcessAddBlockButton',
wrap: gridRowColWrap,
title: "{{t('Add block')}}",
items: [
{
name: 'approval',
type: 'itemGroup',
title: `{{t("Approval blocks", { ns: "${NAMESPACE}" })}}`,
children: [
{ name: 'detail', type: 'item', title: '{{t("Details")}}', Component: Lo },
{ name: 'actions', type: 'item', title: '{{t("Actions")}}', Component: Jo },
],
},
{
type: 'itemGroup',
name: 'dataBlocks',
title: '{{t("Data blocks")}}',
checkChildrenLength: !0,
useChildren() {
var l;
const e = usePlugin(PluginWorkflow), { workflow: t } = useFlowContext(), n = useTrigger(), a = useNodeContext(), s = useAvailableUpstreams(a), i = [(l = n.useInitializers) == null ? void 0 : l.call(n, t.config)].filter(Boolean), v = s
.map((d) => {
var h;
const m = e.instructions.get(d.type);
return (h = m == null ? void 0 : m.useInitializers) == null ? void 0 : h.call(m, d);
})
.filter(Boolean);
return [
...i,
...(v.length
? [{ name: 'nodes', type: 'subMenu', title: '{{t("Node result", { ns: "workflow" })}}', children: v }]
: []),
].filter(Boolean);
},
},
{
name: 'others',
type: 'itemGroup',
title: '{{t("Other blocks")}}',
children: [{ name: 'markdown', type: 'item', title: '{{t("Markdown")}}', component: 'MarkdownBlockInitializer' }],
},
],
});

View File

@ -0,0 +1,110 @@
import { useCollectionManager_deprecated, useCompile } from '@nocobase/client';
import {
RadioWithTooltip,
Trigger,
getCollectionFieldOptions,
useWorkflowAnyExecuted,
} from '@nocobase/plugin-workflow/client';
import { useForm } from '@nocobase/schema';
import { NAMESPACE } from '../locale';
import { T, Vo, zo, useTranslation, w, x } from './refined';
import { usePluginTranslation } from '../locale';
export class ApprovalTrigger extends Trigger {
sync = false;
title = `{{t('Approval event', { ns: "${NAMESPACE}" })}}`;
description = `{{t("Triggered when an approval request is initiated through an action button or API. Dedicated to the approval process, with exclusive approval node and block for managing documents and tracking processing processes.", { ns: "${NAMESPACE}" })}}`;
fieldset = {
collection: {
type: 'string',
title: '{{t("Collection")}}',
required: !0,
'x-decorator': 'FormItem',
'x-component': 'DataSourceCollectionCascader',
'x-disabled': '{{ useWorkflowAnyExecuted() }}',
},
centralized: {
type: 'boolean',
title: `{{t("Where to initiate and approve", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'RadioWithTooltip',
'x-component-props': {
direction: 'vertical',
options: [
{
label: `{{t("Initiate and approve in data blocks only", { ns: "${NAMESPACE}" })}}`,
value: !1,
tooltip: `{{t("Actions from any form block can be bound to this workflow for initiating approvals, and the approval process can be handled and tracked in the approval block of a single record which is typically applicable to business data.", { ns: "${NAMESPACE}" })}}`,
},
{
label: `{{t("Initiate and approve in both data blocks and global approval blocks", { ns: "${NAMESPACE}" })}}`,
value: !0,
tooltip: `{{t("In addition to data blocks, a global approval block can also be used to initiates and processes approvals, which typically applies to administrative data.", { ns: "${NAMESPACE}" })}}`,
},
],
},
default: !1,
},
withdrawable: {
type: 'boolean',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
'x-content': `{{t("Allowed to be withdrawn", { ns: "${NAMESPACE}" })}}`,
description: `{{t("Allow the initiator to withdraw the approval before the approval starts.", { ns: "${NAMESPACE}" })}}`,
},
applyForm: {
type: 'void',
title: `{{t("Initiator's interface", { ns: "${NAMESPACE}" })}}`,
description: `{{t("For initiating approvals, or viewing and manipulating initiated approvals.", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'SchemaConfigButton',
'x-reactions': [{ dependencies: ['collection'], fulfill: { state: { visible: '{{!!$deps[0]}}' } } }],
properties: { applyForm: { type: 'void', 'x-component': 'SchemaConfig', default: null } },
},
appends: {
type: 'array',
title: '{{t("Preload associations", { ns: "workflow" })}}',
description:
'{{t("Please select the associated fields that need to be accessed in subsequent nodes. With more than two levels of to-many associations may cause performance issue, please use with caution.", { ns: "workflow" })}}',
'x-decorator': 'FormItem',
'x-component': 'AppendsTreeSelect',
'x-component-props': {
title: 'Preload associations',
multiple: !0,
useCollection() {
const { values: n } = useForm();
return n == null ? void 0 : n.collection;
},
},
'x-reactions': [{ dependencies: ['collection'], fulfill: { state: { visible: '{{!!$deps[0]}}' } } }],
},
};
scope = { useWorkflowAnyExecuted: useWorkflowAnyExecuted };
components = { SchemaConfigButton: Vo, SchemaConfig: zo, RadioWithTooltip: RadioWithTooltip };
isActionTriggerable = (n, a) => ['create', 'update'].includes(a.action) && !a.direct;
useVariables(n, a) {
var m;
const s = useCompile(),
{ getCollectionFields: i } = useCollectionManager_deprecated(),
{ t: v } = usePluginTranslation(),
l = [
{
collectionName: n.collection,
name: 'data',
type: 'hasOne',
target: n.collection,
uiSchema: { title: v('Trigger data', { ns: 'workflow' }) },
},
];
return getCollectionFieldOptions(
w(x({ appends: ['data', ...(((m = n.appends) == null ? void 0 : m.map((h) => `data.${h}`)) || [])] }, a), {
fields: l,
compile: s,
getCollectionFields: i,
}),
);
}
}

View File

@ -0,0 +1,40 @@
import { Plugin } from '@nocobase/client';
import { ApprovalInstruction } from './ApprovalInstruction';
import { ApprovalTrigger } from './ApprovalTrigger';
import { ApprovalProcessAddBlockButton } from './ApprovalProcessAddBlockButton';
import { ApprovalProcessAddActionButton } from './ApprovalProcessAddActionButton';
import { ApprovalApplyAddBlockButton } from './ApprovalApplyAddBlockButton';
import { ApprovalApplyAddActionButton } from './ApprovalApplyAddActionButton';
import PluginWorkflow from '@nocobase/plugin-workflow/client';
import { NAMESPACE } from '../locale';
import { ApprovalBlock } from './refined';
export default class ApprovalPlugin extends Plugin {
async afterAdd() {}
async beforeLoad() {}
async load() {
const plugin = this.app.pm.get(PluginWorkflow);
plugin.registerTrigger('approval', ApprovalTrigger);
plugin.registerInstruction('approval', ApprovalInstruction);
this.app.addComponents({ ApprovalBlock });
this.app.schemaInitializerManager.add(ApprovalApplyAddActionButton);
this.app.schemaInitializerManager.add(ApprovalApplyAddBlockButton);
this.app.schemaInitializerManager.add(ApprovalProcessAddActionButton);
this.app.schemaInitializerManager.add(ApprovalProcessAddBlockButton);
this.app.schemaInitializerManager.get('BlockInitializers').add('otherBlocks.approval', {
key: 'approvalBlock',
name: 'approvalBlock',
type: 'item',
title: `{{t("Approval", { ns: "${NAMESPACE}" })}}`,
Component: 'ApprovalBlock.BlockInitializer',
icon: 'AuditOutlined',
});
this.app.schemaInitializerManager.get('RecordBlockInitializers').add('otherBlocks.approvalsBlock', {
type: 'item',
name: 'approvalsBlock',
title: `{{t("Related approvals", { ns: "${NAMESPACE}" })}}`,
Component: 'ApprovalBlock.RecordApprovals.BlockInitializer',
});
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
export * from './server';
export { default } from './server';

View File

@ -0,0 +1,64 @@
{
"Approval": "Approval",
"Approvals": "Approvals",
"Approval blocks": "Approval blocks",
"Initiations": "Initiations",
"Todos": "Todos",
"Approval applications": "Approval applications",
"Approval todos": "Approval todos",
"Related approvals": "Related approvals",
"Apply new": "Apply new",
"Apply": "Apply",
"Initiator": "Initiator",
"Application content": "Application content",
"Approval process": "Approval process",
"History": "History",
"Approval ID": "Approval ID",
"Node": "Node",
"Assignee": "Assignees",
"Assignees": "Assignees",
"Add assignee": "Add assignee",
"Negotiation mode": "Negotiation mode",
"Or": "Or",
"And": "And",
"Voting": "Voting",
"Anyone approve or reject as result.": "Anyone approve or reject as result.",
"Everyone approve as approved, or any one reject as rejected.": "Everyone approve as approved, or any one reject as rejected.",
"Approve when approvals rate greater than the set percentage, reject when rejections rate greater than or equal to (1 - percentage).": "Approve when approvals rate greater than the set percentage, reject when rejections rate greater than or equal to (1 - percentage).",
"Order": "Order",
"Sequentially": "Sequentially",
"Parallelly": "Parallelly",
"Draft": "Draft",
"Withdraw": "Withdraw",
"Are you sure you want to withdraw it?": "Are you sure you want to withdraw it?",
"Returned": "Returned",
"Submitted": "Submitted",
"Processing": "Processing",
"Approved": "Approved",
"Rejected": "Rejected",
"Approve": "Approve",
"Reject": "Reject",
"Return": "Return",
"Canceled": "Canceled",
"Assigned": "Assigned",
"Pending": "Pending",
"Withdrawn": "Withdrawn",
"Unprocessed": "Unprocessed",
"User interface": "User interface",
"View user interface": "View user interface",
"Configure user interface": "Configure user interface",
"Apply form": "Apply form",
"Withdrawable": "Withdrawable",
"Returnable": "Returnable",
"Comment": "Comment",
"Event will be triggered when submitted current workflow bound form action, or new application from approval block.": "Event will be triggered when submitted current workflow bound form action, or new application from approval block.",
"Used to perform manual approval operations within approval workflow, approvers can perform approval action through the to-do list in the approval block, such as approval, rejection or return.": "Used to perform manual approval operations within approval workflow, approvers can perform approval action through the to-do list in the approval block, such as approval, rejection or return.",
"Pass mode": "Pass mode",
"Passthrough mode": "Passthrough mode",
"When rejected or returned, the workflow will be terminated immediately.": "When rejected or returned, the workflow will be terminated immediately.",
"Branch mode": "Branch mode",
"Could run different branch based on result.": "Could run different branch based on result.",
"End on reject": "End on reject",
"If checked, the workflow will be terminated after rejection branch processed.": "If checked, the workflow will be terminated after rejection branch processed.",
"Disabled": "Disabled"
}

View File

@ -0,0 +1,10 @@
import { i18n} from '@nocobase/client';
import { useTranslation } from 'react-i18next';
export const NAMESPACE = "@hera/plugin-approval";
export function lang(key: string, options = {}) {
return i18n.t(key, { ...options, ns: NAMESPACE });
}
export function usePluginTranslation() {
return useTranslation(NAMESPACE);
}

View File

@ -0,0 +1,79 @@
{
"Approval event": "审批事件",
"Approval": "审批",
"Approvals": "审批",
"Approval blocks": "审批区块",
"Initiations": "发起",
"Todos": "待办",
"Approval applications": "审批申请",
"Approval todos": "审批待办",
"Related approvals": "相关审批",
"Apply new": "发起新申请",
"Apply": "发起",
"Initiator": "发起人",
"Application content": "申请内容",
"Approval process": "审批处理",
"No data yet": "暂无数据",
"Approval ID": "单据编号",
"Current status": "当前状态",
"Task node": "任务节点",
"Assignee": "审批人",
"Assignees": "审批人",
"Add assignee": "添加审批人",
"Select assignees": "选择审批人",
"Query assignees": "查询审批人",
"Negotiation mode": "协商模式",
"Or": "或签",
"And": "会签",
"Voting": "投票",
"The approval or rejection by anyone of them is the result.": "任意一人通过或否决即为结果。",
"If it's approved by all, it's approved. If it's rejected by anyone, it's rejected.": "所有人通过才通过,任意一人否决则否决。",
"Approved if the approval rate is greater than the set percentage, otherwise rejected.": "通过率大于设置的百分比时通过,否则否决。",
"Order": "多人处理顺序",
"Parallelly": "并行",
"Multiple approvers can approve in any order.": "多个审批人可以任意顺序审批。",
"Sequentially": "顺序",
"Multiple approvers in sequential order.": "多个审批人按照排序审批。",
"Save draft": "保存草稿",
"Draft": "草稿",
"Withdraw": "撤回",
"Are you sure you want to withdraw it?": "确定要撤回吗?",
"Returned": "退回",
"Submitted": "提交",
"Processing": "处理中",
"Approved": "通过",
"Rejected": "否决",
"Approve": "通过",
"Reject": "否决",
"Return": "退回",
"Canceled": "取消",
"Assigned": "已分配",
"Pending": "待处理",
"Withdrawn": "撤回",
"Unprocessed": "未处理",
"Where to initiate and approve": "发起和审批的位置",
"Initiate and approve in data blocks only": "仅在数据区块中发起和审批",
"Actions from any form block can be bound to this workflow for initiating approvals, and the approval process can be handled and tracked in the approval block of a single record which is typically applicable to business data.": "可以将任意表单区块的操作绑定到该工作流,用于发起审批,并在单条数据的审批区块里处理和跟踪审批过程,通常适用于业务数据。",
"Initiate and approve in both data blocks and global approval blocks": "在数据区块和审批中心都可以发起和审批",
"In addition to data blocks, a global approval block can also be used to initiates and processes approvals, which typically applies to administrative data.": "除了数据区块,还可以在全局的审批中心发起和处理审批,这通常适用于行政数据。",
"Initiator's interface": "发起人的操作界面",
"Approver's interface": "审批人的操作界面",
"Go to configure": "进入配置",
"For initiating approvals, or viewing and manipulating initiated approvals.": "用于发起审批,或者查看和操作已发起的审批。",
"Apply form": "申请表单",
"Allowed to be withdrawn": "允许撤回",
"Allow the initiator to withdraw the approval before the approval starts.": "在审批开始之前,允许发起人撤回审批。",
"Returnable": "可退回",
"Comment": "意见",
"Triggered when an approval request is initiated through an action button or API. Dedicated to the approval process, with exclusive approval node and block for managing documents and tracking processing processes.": "通过操作按钮或 API 发起审批申请时触发。专用于审批流程,有专属的审批节点和区块用于管理单据和追踪处理过程。",
"Manual approval operations within the approval process, the approver can approve in the global approval block or in the approval block of a single record.": "在审批流程内进行人工审批操作,审批人可以在全局的审批区块里进行审批,也可以在单条数据的审批区块里审批。",
"Pass mode": "通过模式",
"Passthrough mode": "直通模式",
"When rejected or returned, the workflow will be terminated immediately.": "当否决或退回时,工作流将立即终止。",
"Branch mode": "分支模式",
"Could run different branch based on result.": "产生结果后可按结果继续不同的分支。",
"End the workflow after rejection branch": "否决后终止流程",
"When checked, the workflow will terminate when the rejection branch ends.": "勾选后,否决分支结束后工作流将终止。",
"Disabled": "已失效",
"Submission may be withdrawn, please try refresh the list.": "提交可能已被撤回,请尝试刷新列表。"
}

View File

@ -0,0 +1,246 @@
import { uid } from '@nocobase/utils';
import { Instruction, JOB_STATUS } from '@nocobase/plugin-workflow';
import { APPROVAL_STATUS, APPROVAL_ACTION_STATUS } from './constants';
const NEGOTIATION_MODE = {
SINGLE: Symbol('single'),
ALL: Symbol('all'),
ANY: Symbol('any'),
PERCENTAGE: Symbol('percentage'),
};
const JobStatusMap = {
[APPROVAL_ACTION_STATUS.PENDING]: JOB_STATUS.PENDING,
[APPROVAL_ACTION_STATUS.APPROVED]: JOB_STATUS.RESOLVED,
[APPROVAL_ACTION_STATUS.REJECTED]: JOB_STATUS.REJECTED,
[APPROVAL_ACTION_STATUS.RETURNED]: JOB_STATUS.RETRY_NEEDED,
};
const ApprovalJobStatusMap = {
[JOB_STATUS.PENDING]: APPROVAL_ACTION_STATUS.PENDING,
[JOB_STATUS.RESOLVED]: APPROVAL_ACTION_STATUS.APPROVED,
[JOB_STATUS.REJECTED]: APPROVAL_ACTION_STATUS.REJECTED,
[JOB_STATUS.RETRY_NEEDED]: APPROVAL_ACTION_STATUS.RETURNED,
[JOB_STATUS.CANCELED]: APPROVAL_ACTION_STATUS.CANCELED,
};
const Modes = {
[NEGOTIATION_MODE.SINGLE]: {
getStatus(distribution, assignees, mode) {
const done = distribution.find((item) => item.status !== APPROVAL_ACTION_STATUS.PENDING && item.count > 0);
return done ? JobStatusMap[done.status] : null;
},
},
[NEGOTIATION_MODE.ALL]: {
getStatus(distribution, assignees, mode) {
const rejected = distribution.find((item) =>
[APPROVAL_ACTION_STATUS.REJECTED, APPROVAL_ACTION_STATUS.RETURNED].includes(item.status),
);
if (rejected && rejected.count) {
return JobStatusMap[rejected.status];
}
const approved = distribution.find((item) => item.status === APPROVAL_ACTION_STATUS.APPROVED);
if (approved && approved.count === assignees.length) {
return JOB_STATUS.RESOLVED;
}
return null;
},
},
[NEGOTIATION_MODE.ANY]: {
getStatus(distribution, assignees, mode) {
const returned = distribution.find((item) => item.status === APPROVAL_ACTION_STATUS.RETURNED);
if (returned && returned.count) {
return JOB_STATUS.RETRY_NEEDED;
}
const approved = distribution.find((item) => item.status === APPROVAL_ACTION_STATUS.APPROVED);
if (approved && approved.count) {
return APPROVAL_ACTION_STATUS.APPROVED;
}
const rejectedCount = distribution.reduce(
(count, item) => (item.status === APPROVAL_ACTION_STATUS.REJECTED ? count + item.count : count),
0,
);
if (rejectedCount === assignees.length) {
return JOB_STATUS.REJECTED;
}
return null;
},
},
[NEGOTIATION_MODE.PERCENTAGE]: {
getStatus(distribution, assignees, mode) {
const returned = distribution.find((item) => item.status === APPROVAL_ACTION_STATUS.RETURNED);
if (returned && returned.count) {
return JOB_STATUS.RETRY_NEEDED;
}
const approved = distribution.find((item) => item.status === APPROVAL_ACTION_STATUS.APPROVED);
if (approved && approved.count / assignees.length > mode) {
return JOB_STATUS.RESOLVED;
}
const rejected = distribution.find((item) => item.status === APPROVAL_ACTION_STATUS.REJECTED);
if (rejected && rejected.count / assignees.length >= 1 - mode) {
return JOB_STATUS.REJECTED;
}
return null;
},
},
};
function getNegotiationMode(mode) {
switch (true) {
case mode === 1:
return Modes[NEGOTIATION_MODE.ALL];
case 0 < mode && mode < 1:
return Modes[NEGOTIATION_MODE.PERCENTAGE];
default:
return Modes[NEGOTIATION_MODE.SINGLE];
}
}
async function parseAssignees(node, processor) {
const configAssignees = processor
.getParsedValue(node.config.assignees ?? [], node.id)
.flat()
.filter(Boolean);
const assignees = /* @__PURE__ */ new Set();
const UserRepo = processor.options.plugin.app.db.getRepository('users');
for (const item of configAssignees) {
if (typeof item === 'object') {
const result = await UserRepo.find({
...item,
fields: ['id'],
transaction: processor.transaction,
});
result.forEach((item2) => assignees.add(item2.id));
} else {
assignees.add(item);
}
}
return [...assignees];
}
export default class ApprovalInstruction extends Instruction {
async run(node, prevJob, processor) {
const job = await processor.saveJob({
status: JOB_STATUS.PENDING,
nodeId: node.id,
nodeKey: node.key,
upstreamId: (prevJob == null ? void 0 : prevJob.id) ?? null,
});
const assignees = await parseAssignees(node, processor);
const { db } = processor.options.plugin;
const ApprovalRepo = db.getRepository('approvals');
const approval = await ApprovalRepo.findOne({
filter: {
'executions.id': processor.execution.id,
},
fields: ['id', 'status', 'data'],
appends: ['approvalExecutions'],
except: ['data'],
});
const approvalExecution = approval.approvalExecutions.find((item) => item.executionId === processor.execution.id);
const RecordModel = db.getModel('approvalRecords');
await RecordModel.bulkCreate(
assignees.map((userId, index) => ({
approvalId: approval.id,
approvalExecutionId: approvalExecution.id,
userId,
jobId: job.id,
nodeId: node.id,
executionId: job.executionId,
workflowId: node.workflowId,
index,
status: node.config.order && index ? APPROVAL_ACTION_STATUS.ASSIGNED : APPROVAL_ACTION_STATUS.PENDING,
snapshot: approvalExecution.snapshot,
})),
{
transaction: processor.transaction,
},
);
return job;
}
async resume(node, job, processor) {
if (job.nodeId !== node.id) {
const nodeJob = processor.findBranchParentJob(job, node);
if (job.status === JOB_STATUS.RESOLVED) {
const jobNode = processor.nodesMap.get(job.nodeId);
const branchStart = processor.findBranchStartNode(jobNode);
if (branchStart.branchIndex === APPROVAL_ACTION_STATUS.RETURNED) {
nodeJob.set('status', JOB_STATUS.RETRY_NEEDED);
} else if (branchStart.branchIndex === APPROVAL_ACTION_STATUS.REJECTED && node.config.endOnReject) {
nodeJob.set('status', JOB_STATUS.REJECTED);
}
return nodeJob;
}
return processor.exit(job.status);
}
const { branchMode, negotiation, order } = node.config;
const assignees = await parseAssignees(node, processor);
const RecordRepo = this.workflow.app.db.getRepository('approvalRecords');
const records = await RecordRepo.find({
filter: {
jobId: job.id,
},
except: ['snapshot'],
sort: ['index'],
transaction: processor.transaction,
});
const distribution = records.reduce((prev, record) => {
const item = prev.find((item2) => item2.status === record.status);
if (item) {
item.count += 1;
} else {
prev.push({
status: record.status,
count: 1,
});
}
return prev;
}, []);
const processing = Boolean(distribution.find((item) => item.status !== APPROVAL_ACTION_STATUS.PENDING));
const status =
job.status ||
(getNegotiationMode(negotiation).getStatus(distribution, assignees, negotiation) ?? JOB_STATUS.PENDING);
const result = ApprovalJobStatusMap[status];
processor.logger.debug(`approval resume job and next status: ${status}`);
job.set({
status: status && status !== JOB_STATUS.CANCELED ? (branchMode ? JOB_STATUS.RESOLVED : status) : status,
result,
});
if ((status && status !== JOB_STATUS.CANCELED) || (negotiation && processing)) {
await job.latestUserJob.approval.update(
{
status: APPROVAL_STATUS.PROCESSING,
},
{ transaction: processor.transaction },
);
}
const nextAssignee = assignees[assignees.indexOf(job.latestUserJob.userId) + 1];
if (!status && negotiation && order && nextAssignee) {
await RecordRepo.update({
values: {
status: APPROVAL_ACTION_STATUS.PENDING,
},
filter: {
jobId: job.id,
userId: nextAssignee,
},
transaction: processor.transaction,
});
}
if (branchMode) {
const branchNode = processor.nodes.find((item) => item.upstream === node && item.branchIndex === result);
if (branchNode) {
await processor.saveJob(job);
await processor.run(branchNode, job);
return null;
}
}
return job;
}
async duplicateConfig(node, { transaction }) {
const uiSchemaRepo = this.workflow.app.db.getRepository('uiSchemas');
if (!node.config.applyDetail) {
return node.config;
}
const result = await uiSchemaRepo.duplicate(node.config.applyDetail, {
transaction,
});
return {
...node.config,
applyDetail: (result == null ? void 0 : result['x-uid']) ?? uid(),
};
}
}

View File

@ -0,0 +1,271 @@
import { get } from 'lodash';
import { Op, BelongsTo, HasOne } from 'sequelize';
import { modelAssociationByKey } from '@nocobase/database';
import { parseCollectionName } from '@nocobase/data-source-manager';
import { EXECUTION_STATUS, Trigger, toJSON, JOB_STATUS } from '@nocobase/plugin-workflow';
import { APPROVAL_ACTION_STATUS, APPROVAL_STATUS } from './constants';
import { UiSchemaRepository } from '@nocobase/plugin-ui-schema-storage';
const ExecutionStatusMap = {
[EXECUTION_STATUS.RESOLVED]: APPROVAL_STATUS.APPROVED,
[EXECUTION_STATUS.REJECTED]: APPROVAL_STATUS.REJECTED,
[EXECUTION_STATUS.CANCELED]: APPROVAL_STATUS.DRAFT,
[EXECUTION_STATUS.RETRY_NEEDED]: APPROVAL_STATUS.RETURNED,
// FAILED: -1,
// ERROR: -2,
// ABORTED: -3,
};
const ApprovalJobStatusMap = {
[APPROVAL_ACTION_STATUS.APPROVED]: APPROVAL_STATUS.APPROVED,
[APPROVAL_ACTION_STATUS.REJECTED]: APPROVAL_STATUS.REJECTED,
[APPROVAL_ACTION_STATUS.RETURNED]: APPROVAL_STATUS.RETURNED,
};
export default class ApprovalTrigger extends Trigger {
static TYPE = 'approval';
sync = false;
triggerHandler = async (approval, { transaction }) => {
const workflow = await approval.getWorkflow({
where: {
id: approval.get('workflowId'),
type: ApprovalTrigger.TYPE,
enabled: true,
'config.collection': approval.collectionName,
},
transaction,
});
if (!workflow || !approval.changed('status') || approval.status !== APPROVAL_STATUS.SUBMITTED) {
return;
}
const [dataSourceName, collectionName] = parseCollectionName(approval.collectionName);
const { repository } = this.workflow.app.dataSourceManager.dataSources
.get(dataSourceName)
.collectionManager.getCollection(collectionName);
const data = await repository.findOne({
filterByTk: approval.get('dataKey'),
appends: workflow.config.appends,
transaction: this.workflow.useDataSourceTransaction(dataSourceName, transaction),
});
this.workflow.trigger(
workflow,
{
data: toJSON(data),
approvalId: approval.id,
applicantRoleName: approval.applicantRoleName,
},
{ transaction },
);
};
onExecutionCreate = async (execution, { transaction }) => {
const workflow = await execution.getWorkflow({ transaction });
if (workflow.type !== ApprovalTrigger.TYPE) {
return;
}
const { approvalId, data } = execution.context;
const approvalExecution = await this.workflow.db.getRepository('approvalExecutions').create({
values: {
approvalId,
executionId: execution.id,
status: execution.status,
snapshot: data,
},
transaction,
});
await this.workflow.db.getRepository('approvals').update({
filterByTk: approvalId,
values: { latestExecutionId: approvalExecution.id },
transaction,
});
};
onExecutionUpdate = async (execution, { transaction }) => {
if (!execution.workflow) {
execution.workflow = await execution.getWorkflow({ transaction });
}
if (!execution.status || execution.workflow.type !== ApprovalTrigger.TYPE) {
return;
}
const approvalExecution = await this.workflow.db.getRepository('approvalExecutions').findOne({
filter: {
executionId: execution.id,
approvalId: execution.context.approvalId,
},
appends: ['approval'],
transaction,
});
const { approval } = approvalExecution;
if (![APPROVAL_STATUS.SUBMITTED, APPROVAL_STATUS.PROCESSING].includes(approval.status)) {
return;
}
let status = APPROVAL_STATUS.APPROVED;
if (execution.status === EXECUTION_STATUS.RESOLVED) {
const [approvalNodeJob] = await execution.getJobs({
where: {
status: {
[Op.ne]: JOB_STATUS.PENDING,
},
},
include: [
{
association: 'node',
where: {
type: 'approval',
},
required: true,
},
],
order: [['updatedAt', 'DESC']],
limit: 1,
transaction,
});
if (approvalNodeJob && ApprovalJobStatusMap[approvalNodeJob.result] != null) {
status = ApprovalJobStatusMap[approvalNodeJob.result];
}
} else {
if (ExecutionStatusMap[execution.status] != null) {
status = ExecutionStatusMap[execution.status];
}
}
await approval.update({ status }, { transaction });
await approvalExecution.update({ status: execution.status }, { transaction });
};
middleware = async (context, next) => {
const {
resourceName,
actionName,
params: { triggerWorkflows },
} = context.action;
if (resourceName === 'workflows' && actionName === 'trigger') {
return this.workflowTriggerAction(context, next);
}
await next();
if (!triggerWorkflows || !['create', 'update'].includes(actionName)) {
return;
}
this.collectionTriggerAction(context);
};
constructor(workflow) {
super(workflow);
const { db } = workflow.app;
db.on('approvals.afterSave', this.triggerHandler);
db.on('executions.afterCreate', this.onExecutionCreate);
db.on('executions.afterUpdate', this.onExecutionUpdate);
workflow.app.use(this.middleware, { after: 'dataSource' });
}
async workflowTriggerAction(context, next) {
const { triggerWorkflows, values } = context.action.params;
if (!triggerWorkflows) {
return context.throw(400);
}
const triggers = triggerWorkflows.split(',').map((trigger) => trigger.split('!'));
const workflowRepo = this.workflow.db.getRepository('workflows');
const workflows = await workflowRepo.find({
filter: {
type: ApprovalTrigger.TYPE,
key: triggers.map((trigger) => trigger[0]),
current: true,
enabled: true,
},
});
context.status = 202;
await next();
workflows.forEach(async (workflow) => {
const trigger = triggers.find((trigger2) => trigger2[0] === workflow.key);
const [dataSourceName, collectionName] = parseCollectionName(workflow.config.collection);
const collecton = this.workflow.app.dataSourceManager.dataSources
.get(dataSourceName)
.collectionManager.getCollection(collectionName);
const data = await collecton.repository.create({
values: {
...(trigger[1] ? get(values, trigger[1]) : values),
// createdBy: currentUser.id,
// updatedById: currentUser.id,
},
context,
});
const approvalRepo = this.workflow.db.getRepository('approvals');
await approvalRepo.create({
values: {
collectionName: workflow.config.collection,
data: toJSON(data),
dataKey: data.get(collecton.filterTargetKey),
status: APPROVAL_STATUS.SUBMITTED,
// createdBy: currentUser.id,
// updatedById: currentUser.id,
workflowId: workflow.id,
workflowKey: workflow.key,
},
context,
});
});
}
async collectionTriggerAction(context) {
const { triggerWorkflows = '' } = context.action.params;
const dataSourceHeader = context.get('x-data-source') || 'main';
const approvalRepo = this.workflow.db.getRepository('approvals');
const triggers = triggerWorkflows.split(',').map((trigger) => trigger.split('!'));
const triggersKeysMap = new Map(triggers);
const workflows = Array.from(this.workflow.enabledCache.values()).filter(
(item) => item.type === ApprovalTrigger.TYPE && triggersKeysMap.has(item.key),
);
for (const workflow of workflows) {
const [dataSourceName, collectionName] = parseCollectionName(workflow.config.collection);
const trigger = triggers.find((trigger2) => trigger2[0] === workflow.key);
const { body: data } = context;
if (!data) {
return;
}
if (dataSourceName !== dataSourceHeader) {
continue;
}
(Array.isArray(data) ? data : [data]).forEach(async (row) => {
let payload = row;
if (trigger[1]) {
const paths = trigger[1].split('.');
for await (const field of paths) {
if (payload.get(field)) {
payload = payload.get(field);
} else {
const association = <HasOne | BelongsTo>modelAssociationByKey(payload, field);
payload = await payload[association.accessors.get]();
}
}
}
const collection = context.app.dataSourceManager.dataSources
.get(dataSourceName)
.collectionManager.getCollection(collectionName);
if (!collection || collection.model !== payload.constructor) {
return;
}
await approvalRepo.create({
values: {
collectionName: workflow.config.collection,
data: toJSON(payload),
dataKey: payload.get(collection.filterTargetKey),
status: APPROVAL_STATUS.SUBMITTED,
// createdBy: currentUser.id,
// updatedBy: currentUser.id,
workflowId: workflow.id,
workflowKey: workflow.key,
applicantRoleName: context.state.currentRole,
},
context,
});
});
}
}
on(workflow) {}
off(workflow) {}
async duplicateConfig(workflow, { transaction }) {
const { db } = this.workflow;
const uiSchemaRepo = db.getRepository<UiSchemaRepository>('uiSchemas');
if (!workflow.config.applyForm) {
return workflow.config;
}
const result = await uiSchemaRepo.duplicate(workflow.config.applyForm, {
transaction,
});
return {
...workflow.config,
applyForm: result['x-uid'],
};
}
}

View File

@ -0,0 +1,271 @@
import actions, { utils } from '@nocobase/actions';
import WorkflowPlugin, { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow';
import { APPROVAL_STATUS, APPROVAL_ACTION_STATUS } from './constants';
import { parseCollectionName } from '@nocobase/data-source-manager';
const workflows = {
async listApprovalFlows(context, next) {
context.action.mergeParams({
filter: {
type: 'approval',
enabled: true,
// TODO: 仅显示当前用户有权限的流程
},
});
return actions.list(context, next);
},
};
const approvals = {
async create(context, next) {
const { status, collectionName, data, workflowId } = context.action.params.values ?? {};
const [dataSourceName, cName] = parseCollectionName(collectionName);
const dataSource = context.app.dataSourceManager.dataSources.get(dataSourceName);
if (!dataSource) {
return context.throw(400, `Data source "${dataSourceName}" not found`);
}
const collection = dataSource.collectionManager.getCollection(cName);
if (!collection) {
return context.throw(400, `Collection "${cName}" not found`);
}
const workflow = await context.db.getRepository('workflows').findOne({
filterByTk: workflowId,
});
if (!(workflow == null ? void 0 : workflow.enabled)) {
return context.throw(400, 'Current workflow not found or disabled, please refresh and try again');
}
if (status !== APPROVAL_STATUS.DRAFT) {
context.action.mergeParams({
values: {
status: APPROVAL_STATUS.SUBMITTED,
},
});
}
const { repository, model } = collection;
const values = await repository.create({
values: {
...data,
createdBy: context.state.currentUser.id,
updatedBy: context.state.currentUser.id,
},
context,
});
const instance = values.get();
Object.keys(model.associations).forEach((key) => {
delete instance[key];
});
context.action.mergeParams({
values: {
collectionName,
data: instance,
dataKey: values[collection.filterTargetKey],
workflowKey: workflow.key,
applicantRoleName: context.state.currentRole,
},
});
return actions.create(context, next);
},
async update(context, next) {
const { collectionName, data, status } = context.action.params.values ?? {};
const [dataSourceName, cName] = parseCollectionName(collectionName);
const dataSource = context.app.dataSourceManager.dataSources.get(dataSourceName);
const collection = dataSource.collectionManager.getCollection(cName);
const [target] = await collection.repository.update({
filterByTk: data[collection.filterTargetKey],
values: data,
});
context.action.mergeParams({
values: {
status: status ?? APPROVAL_STATUS.SUBMITTED,
data: target.toJSON(),
applicantRoleName: context.state.currentRole,
},
});
return actions.update(context, next);
},
async destroy(context, next) {
const {
filterByTk,
values: { status },
} = context.action.params ?? {};
if (status !== APPROVAL_STATUS.DRAFT) {
return context.throw(400);
}
const repository = utils.getRepositoryFromParams(context);
const approval = await repository.findOne({
filterByTk,
filter: {
createdById: context.state.currentUser.id,
},
});
if (!approval) {
return context.throw(404);
}
return actions.destroy(context, next);
},
async withdraw(context, next) {
let _a;
const { filterByTk } = context.action.params;
const repository = utils.getRepositoryFromParams(context);
const approval = await repository.findOne({
filterByTk,
appends: ['workflow'],
except: ['workflow.options'],
});
if (!approval) {
return context.throw(404);
}
if (approval.createdById !== ((_a = context.state.currentUser) == null ? void 0 : _a.id)) {
return context.throw(403);
}
if (approval.status !== APPROVAL_STATUS.SUBMITTED || !approval.workflow.config.withdrawable) {
return context.throw(400);
}
const [execution] = await approval.getExecutions({
where: {
status: EXECUTION_STATUS.STARTED,
},
limit: 1,
});
execution.workflow = approval.workflow;
const jobs = await context.db.sequelize.transaction(async (transaction) => {
const records = await approval.getRecords({
where: {
executionId: execution.id,
},
include: [
{
association: 'job',
where: {
status: JOB_STATUS.PENDING,
},
required: true,
},
],
transaction,
});
await context.db.getRepository('approvalRecords').destroy({
filter: {
id: records.map((record) => record.id),
},
transaction,
});
const jobsMap = records.reduce((map, record) => {
if (!map.has(record.job.id)) {
record.job.execution = execution;
record.job.latestUserJob = record.get();
record.job.latestUserJob.approval = approval;
map.set(record.job.id, record.job);
}
return map;
}, /* @__PURE__ */ new Map());
return Array.from(jobsMap.values());
});
context.body = approval;
context.status = 202;
await next();
const workflowPlugin = context.app.getPlugin(WorkflowPlugin);
if (jobs.length) {
jobs.forEach((job) => {
job.set('status', JOB_STATUS.CANCELED);
workflowPlugin.resume(job);
});
} else {
await execution.update({
status: EXECUTION_STATUS.CANCELED,
});
}
},
async listCentralized(context, next) {
const centralizedApprovalFlow = await context.db.getRepository('workflows').find({
filter: {
type: 'approval',
'config.centralized': true,
},
fields: ['id'],
});
context.action.mergeParams({
filter: {
workflowId: centralizedApprovalFlow.map((item) => item.id),
},
});
return actions.list(context, next);
},
};
const approvalRecords = {
async listCentralized(context, next) {
const centralizedApprovalFlow = await context.db.getRepository('workflows').find({
filter: {
type: 'approval',
'config.centralized': true,
},
fields: ['id'],
});
context.action.mergeParams({
filter: {
workflowId: centralizedApprovalFlow.map((item) => item.id),
},
});
return actions.list(context, next);
},
async submit(context, next) {
const repository = utils.getRepositoryFromParams(context);
const { filterByTk, values } = context.action.params;
const { currentUser } = context.state;
if (!currentUser) {
return context.throw(401);
}
const approvalRecord = await repository.findOne({
filterByTk,
filter: {
userId: currentUser == null ? void 0 : currentUser.id,
},
appends: ['job', 'node', 'execution', 'workflow', 'approval'],
context,
});
if (!approvalRecord) {
return context.throw(404);
}
if (
!approvalRecord.workflow.enabled ||
approvalRecord.execution.status ||
approvalRecord.job.status ||
approvalRecord.status !== APPROVAL_ACTION_STATUS.PENDING ||
!(approvalRecord.node.config.actions ?? []).includes(values.status)
) {
return context.throw(400);
}
await approvalRecord.update({
status: values.status,
comment: values.comment,
snapshot: approvalRecord.approval.data,
});
context.body = approvalRecord.get();
context.status = 202;
await next();
approvalRecord.execution.workflow = approvalRecord.workflow;
approvalRecord.job.execution = approvalRecord.execution;
approvalRecord.job.latestUserJob = approvalRecord.get();
const workflow = context.app.getPlugin(WorkflowPlugin);
const processor = workflow.createProcessor(approvalRecord.execution);
processor.logger.info(
`approval node (${approvalRecord.nodeId}) action trigger execution (${approvalRecord.execution.id}) to resume`,
);
workflow.resume(approvalRecord.job);
},
};
function make(name, mod) {
return Object.keys(mod).reduce(
(result, key) => ({
...result,
[`${name}:${key}`]: mod[key],
}),
{},
);
}
export function init({ app }) {
app.actions({
...make('workflows', workflows),
...make('approvals', approvals),
...make('approvalRecords', approvalRecords),
});
}

View File

@ -0,0 +1,40 @@
import { defineCollection } from '@nocobase/database';
export default defineCollection({
namespace: 'workflow.approvalExecutions',
duplicator: 'required',
name: 'approvalExecutions',
fields: [
{
type: 'bigInt',
name: 'id',
primaryKey: true,
autoIncrement: true,
},
{
type: 'belongsTo',
name: 'approval',
foreignKey: 'approvalId',
primaryKey: false,
},
{
type: 'belongsTo',
name: 'execution',
foreignKey: 'executionId',
primaryKey: false,
},
{
type: 'integer',
name: 'status',
},
{
type: 'jsonb',
name: 'snapshot',
},
{
type: 'hasMany',
name: 'records',
target: 'approvalRecords',
},
],
});

View File

@ -0,0 +1,56 @@
import { defineCollection } from '@nocobase/database';
export default defineCollection({
namespace: 'workflow.approvalRecords',
duplicator: 'required',
name: 'approvalRecords',
fields: [
{
type: 'belongsTo',
name: 'approval',
},
{
type: 'belongsTo',
name: 'approvalExecution',
},
{
type: 'belongsTo',
name: 'user',
target: 'users',
},
{
type: 'belongsTo',
name: 'job',
target: 'jobs',
},
{
type: 'belongsTo',
name: 'execution',
},
{
type: 'belongsTo',
name: 'node',
target: 'flow_nodes',
},
{
type: 'belongsTo',
name: 'workflow',
},
{
type: 'string',
name: 'index',
},
{
type: 'integer',
name: 'status',
},
{
type: 'jsonb',
name: 'snapshot',
defaultValue: {},
},
{
type: 'text',
name: 'comment',
},
],
});

View File

@ -0,0 +1,69 @@
import { defineCollection } from '@nocobase/database';
export default defineCollection({
namespace: 'workflow.approvals',
duplicator: 'required',
name: 'approvals',
createdBy: true,
updatedBy: true,
fields: [
{
type: 'string',
name: 'collectionName',
},
{
type: 'string',
name: 'dataKey',
},
{
type: 'belongsTo',
name: 'workflow',
onDelete: 'CASCADE',
},
{
type: 'string',
name: 'workflowKey',
},
{
type: 'belongsToMany',
name: 'executions',
through: 'approvalExecutions',
targetKey: 'id',
sourceKey: 'id',
foreignKey: 'approvalId',
otherKey: 'executionId',
},
{
type: 'hasMany',
name: 'approvalExecutions',
target: 'approvalExecutions',
onDelete: 'CASCADE',
},
{
type: 'belongsTo',
name: 'latestApprovalExecution',
target: 'approvalExecutions',
foreignKey: 'latestExecutionId',
},
{
type: 'hasMany',
name: 'records',
target: 'approvalRecords',
onDelete: 'CASCADE',
},
{
type: 'integer',
name: 'status',
},
{
type: 'json',
name: 'data',
defaultValue: {},
},
{
type: 'belongsTo',
name: 'applicantRole',
foreignKey: 'applicantRoleName',
target: 'roles',
},
],
});

View File

@ -0,0 +1,16 @@
export const APPROVAL_STATUS = {
DRAFT: 0,
RETURNED: 1,
SUBMITTED: 2,
PROCESSING: 3,
APPROVED: 4,
REJECTED: -1,
};
export const APPROVAL_ACTION_STATUS = {
ASSIGNED: null,
PENDING: 0,
RETURNED: 1,
APPROVED: 2,
REJECTED: -1,
CANCELED: -2,
};

View File

@ -0,0 +1 @@
export { default } from './plugin';

View File

@ -0,0 +1,44 @@
import path from 'path';
import { Plugin } from '@nocobase/server';
import WorkflowPlugin from '@nocobase/plugin-workflow';
import ApprovalTrigger from './ApprovalTrigger';
import ApprovalInstruction from './ApprovalInstruction';
import { init } from './actions';
export class PluginWorkflowApproval extends Plugin {
workflow;
afterAdd() {}
beforeLoad() {
this.app.on('afterLoadPlugin', (plugin) => {
if (!(plugin instanceof WorkflowPlugin)) {
return;
}
this.workflow = plugin;
plugin.triggers.register('approval', new ApprovalTrigger(plugin));
plugin.instructions.register('approval', new ApprovalInstruction(plugin));
});
}
async load() {
const { db } = this;
db.addMigrations({
namespace: 'approval',
directory: path.resolve(__dirname, 'migrations'),
context: {
plugin: this,
},
});
await db.import({
directory: path.resolve(__dirname, 'collections'),
});
init(this);
this.app.acl.allow('workflows', ['listApprovalFlows'], 'loggedIn');
this.app.acl.allow('approvals', '*', 'loggedIn');
this.app.acl.allow('approvalExecutions', ['get'], 'loggedIn');
this.app.acl.allow('approvalRecords', ['get', 'list', 'listCentralized', 'submit'], 'loggedIn');
}
async install(options) {}
async afterEnable() {}
async afterDisable() {}
async remove() {}
}
export default PluginWorkflowApproval;

View File

@ -1255,6 +1255,64 @@ importers:
specifier: ^20.12.2
version: 20.12.2
packages/plugins/@hera/plugin-approval:
dependencies:
'@nocobase/client':
specifier: workspace:*
version: link:../../../core/client
'@nocobase/plugin-workflow':
specifier: workspace:*
version: link:../../@nocobase/plugin-workflow
'@nocobase/server':
specifier: workspace:*
version: link:../../../core/server
'@nocobase/test':
specifier: workspace:*
version: link:../../../core/test
devDependencies:
'@ant-design/icons':
specifier: 5.x
version: 5.3.6(react-dom@18.2.0)(react@18.2.0)
'@formily/antd-v5':
specifier: 1.1.9
version: 1.1.9(@types/react-dom@18.2.23)(@types/react@18.2.73)(antd@5.16.0)(react-dom@18.2.0)(react-is@18.2.0)(react@18.2.0)(typescript@5.4.3)
'@nocobase/actions':
specifier: workspace:*
version: link:../../../core/actions
'@nocobase/data-source-manager':
specifier: workspace:*
version: link:../../../core/data-source-manager
'@nocobase/database':
specifier: workspace:*
version: link:../../../core/database
'@nocobase/plugin-ui-schema-storage':
specifier: workspace:*
version: link:../../@nocobase/plugin-ui-schema-storage
'@nocobase/schema':
specifier: workspace:*
version: link:../../../core/schema
'@nocobase/utils':
specifier: workspace:*
version: link:../../../core/utils
'@types/lodash':
specifier: 4.17.0
version: 4.17.0
antd:
specifier: 5.16.0
version: 5.16.0(react-dom@18.2.0)(react@18.2.0)
lodash:
specifier: ^4.17.21
version: 4.17.21
react:
specifier: 18.2.0
version: 18.2.0
react-i18next:
specifier: ^11.15.1
version: 11.18.6(i18next@22.5.1)(react-dom@18.2.0)(react@18.2.0)
sequelize:
specifier: ^6.26.0
version: 6.35.2
packages/plugins/@hera/plugin-audit-logs:
dependencies:
'@nocobase/client':
@ -4784,7 +4842,7 @@ packages:
react: 18.2.0
react-dom: 18.2.0
dependencies:
'@ant-design/cssinjs': 1.18.2(react-dom@18.2.0)(react@18.2.0)
'@ant-design/cssinjs': 1.19.0(react-dom@18.2.0)(react@18.2.0)
'@babel/runtime': 7.24.1
'@ctrl/tinycolor': 3.6.1
antd: 5.16.0(react-dom@18.2.0)(react@18.2.0)
@ -8873,7 +8931,7 @@ packages:
react-dom: 18.2.0
react-is: '>=16.8.0 || >=17.0.0'
dependencies:
'@ant-design/cssinjs': 1.18.2(react-dom@18.2.0)(react@18.2.0)
'@ant-design/cssinjs': 1.19.0(react-dom@18.2.0)(react@18.2.0)
'@ant-design/icons': 5.3.6(react-dom@18.2.0)(react@18.2.0)
'@dnd-kit/core': 6.1.0(react-dom@18.2.0)(react@18.2.0)
'@dnd-kit/sortable': 7.0.2(@dnd-kit/core@6.1.0)(react@18.2.0)
@ -12518,7 +12576,7 @@ packages:
'@typescript-eslint/typescript-estree': 5.62.0(typescript@5.4.3)
eslint: 8.55.0
eslint-scope: 5.1.1
semver: 7.5.4
semver: 7.6.0
transitivePeerDependencies:
- supports-color
- typescript