@rytass/bpm-core-nestjs-module 0.14.0 → 0.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +100 -0
- package/README.md +19 -13
- package/package.json +5 -5
- package/src/lib/attachment/attachment-options.d.ts +23 -10
- package/src/lib/attachment/attachment-options.js +17 -31
- package/src/lib/attachment/attachment-options.js.map +1 -1
- package/src/lib/attachment/attachment-request-origin.d.ts +19 -0
- package/src/lib/attachment/attachment-request-origin.js +51 -0
- package/src/lib/attachment/attachment-request-origin.js.map +1 -0
- package/src/lib/attachment/attachment.module.d.ts +1 -2
- package/src/lib/attachment/attachment.module.js.map +1 -1
- package/src/lib/attachment/attachment.queries.d.ts +2 -2
- package/src/lib/attachment/attachment.queries.js +9 -4
- package/src/lib/attachment/attachment.queries.js.map +1 -1
- package/src/lib/attachment/attachment.service.d.ts +6 -1
- package/src/lib/attachment/attachment.service.js +12 -5
- package/src/lib/attachment/attachment.service.js.map +1 -1
- package/src/lib/bpm/bpm-root-options.d.ts +3 -3
- package/src/lib/bpm/bpm-root-options.js +7 -1
- package/src/lib/bpm/bpm-root-options.js.map +1 -1
- package/src/lib/bpm/bpm-root.module.d.ts +1 -2
- package/src/lib/bpm/bpm-root.module.js.map +1 -1
- package/src/lib/bpm-auth/bpm-auth.options.d.ts +1 -2
- package/src/lib/calendar/calendar.module.d.ts +1 -2
- package/src/lib/calendar/calendar.module.js.map +1 -1
- package/src/lib/form/form-member-value.validator.d.ts +16 -0
- package/src/lib/form/form-member-value.validator.js +37 -0
- package/src/lib/form/form-member-value.validator.js.map +1 -0
- package/src/lib/form/form-schema.validator.js +90 -1
- package/src/lib/form/form-schema.validator.js.map +1 -1
- package/src/lib/form-data-source/form-data-source.module.d.ts +1 -2
- package/src/lib/form-data-source/form-data-source.module.js.map +1 -1
- package/src/lib/identity/identity.module.d.ts +1 -2
- package/src/lib/identity/identity.module.js.map +1 -1
- package/src/lib/migrations/0000000026000-approval-instance-business-key.d.ts +20 -0
- package/src/lib/migrations/0000000026000-approval-instance-business-key.js +44 -0
- package/src/lib/migrations/0000000026000-approval-instance-business-key.js.map +1 -0
- package/src/lib/migrations/0000000027000-approval-instance-submitted-by.d.ts +19 -0
- package/src/lib/migrations/0000000027000-approval-instance-submitted-by.js +42 -0
- package/src/lib/migrations/0000000027000-approval-instance-submitted-by.js.map +1 -0
- package/src/lib/migrations/index.d.ts +2 -0
- package/src/lib/migrations/index.js +6 -0
- package/src/lib/migrations/index.js.map +1 -1
- package/src/lib/notification/notification-options.module.d.ts +1 -2
- package/src/lib/notification/notification-options.module.js.map +1 -1
- package/src/lib/notification/notification-template.js +1 -1
- package/src/lib/notification/notification-template.js.map +1 -1
- package/src/lib/notification/notification.service.d.ts +16 -0
- package/src/lib/notification/notification.service.js +54 -0
- package/src/lib/notification/notification.service.js.map +1 -1
- package/src/lib/organization/index.d.ts +1 -0
- package/src/lib/organization/index.js +1 -0
- package/src/lib/organization/index.js.map +1 -1
- package/src/lib/organization/organization-time-zone.d.ts +46 -0
- package/src/lib/organization/organization-time-zone.js +47 -0
- package/src/lib/organization/organization-time-zone.js.map +1 -0
- package/src/lib/organization/organization.service.d.ts +3 -1
- package/src/lib/organization/organization.service.js +8 -11
- package/src/lib/organization/organization.service.js.map +1 -1
- package/src/lib/signature/signature.module.d.ts +1 -2
- package/src/lib/signature/signature.module.js.map +1 -1
- package/src/lib/template/template.service.js +64 -0
- package/src/lib/template/template.service.js.map +1 -1
- package/src/lib/workflow-engine/approval-instance.entity.d.ts +13 -0
- package/src/lib/workflow-engine/approval-instance.entity.js +20 -0
- package/src/lib/workflow-engine/approval-instance.entity.js.map +1 -1
- package/src/lib/workflow-engine/instance-submitter.d.ts +2 -0
- package/src/lib/workflow-engine/instance-submitter.js +14 -0
- package/src/lib/workflow-engine/instance-submitter.js.map +1 -0
- package/src/lib/workflow-engine/task.entity.d.ts +9 -0
- package/src/lib/workflow-engine/task.entity.js +13 -0
- package/src/lib/workflow-engine/task.entity.js.map +1 -1
- package/src/lib/workflow-engine/workflow-dashboard-summary.object.js +18 -6
- package/src/lib/workflow-engine/workflow-dashboard-summary.object.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.module.d.ts +1 -2
- package/src/lib/workflow-engine/workflow-engine.module.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.mutations.js +5 -0
- package/src/lib/workflow-engine/workflow-engine.mutations.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.service.d.ts +83 -6
- package/src/lib/workflow-engine/workflow-engine.service.js +731 -230
- package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
- package/src/lib/workflow-webhook/workflow-webhook-options.module.d.ts +1 -2
- package/src/lib/workflow-webhook/workflow-webhook-options.module.js.map +1 -1
- package/src/lib/workflow-webhook/workflow-webhook.module.d.ts +1 -2
- package/src/lib/workflow-webhook/workflow-webhook.module.js.map +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.WorkflowEngineService = exports.MANUAL_TRANSFER_DELEGATION_REASON = void 0;
|
|
3
|
+
exports.WorkflowEngineService = exports.MANUAL_TRANSFER_DELEGATION_REASON = exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
|
+
const class_validator_1 = require("class-validator");
|
|
5
6
|
const form_1 = require("@rytass/bpm-core-shared/form");
|
|
6
7
|
const workflow_graph_1 = require("@rytass/bpm-core-shared/workflow-graph");
|
|
7
8
|
const workflow_webhook_delivery_service_1 = require("../workflow-webhook/workflow-webhook-delivery.service");
|
|
@@ -13,6 +14,7 @@ const delegation_service_1 = require("../delegation/delegation.service");
|
|
|
13
14
|
const form_definition_version_entity_1 = require("../form/form-definition-version.entity");
|
|
14
15
|
const form_enums_1 = require("../form/form.enums");
|
|
15
16
|
const form_data_source_errors_1 = require("../form-data-source/form-data-source.errors");
|
|
17
|
+
const form_member_value_validator_1 = require("../form/form-member-value.validator");
|
|
16
18
|
const form_data_source_types_1 = require("../form-data-source/form-data-source.types");
|
|
17
19
|
const sla_schedule_service_1 = require("../calendar/sla-schedule.service");
|
|
18
20
|
const notification_service_1 = require("../notification/notification.service");
|
|
@@ -25,6 +27,7 @@ const membership_entity_1 = require("../organization/membership.entity");
|
|
|
25
27
|
const org_unit_entity_1 = require("../organization/org-unit.entity");
|
|
26
28
|
const position_entity_1 = require("../organization/position.entity");
|
|
27
29
|
const organization_enums_1 = require("../organization/organization.enums");
|
|
30
|
+
const organization_time_zone_1 = require("../organization/organization-time-zone");
|
|
28
31
|
const approval_template_version_entity_1 = require("../template/approval-template-version.entity");
|
|
29
32
|
const approval_template_entity_1 = require("../template/approval-template.entity");
|
|
30
33
|
const template_enums_1 = require("../template/template.enums");
|
|
@@ -32,6 +35,7 @@ const activity_log_entity_1 = require("./activity-log.entity");
|
|
|
32
35
|
const adhoc_directive_entity_1 = require("./adhoc-directive.entity");
|
|
33
36
|
const adhoc_enums_1 = require("./adhoc.enums");
|
|
34
37
|
const approval_instance_entity_1 = require("./approval-instance.entity");
|
|
38
|
+
const instance_submitter_1 = require("./instance-submitter");
|
|
35
39
|
const task_decision_entity_1 = require("./task-decision.entity");
|
|
36
40
|
const task_candidate_entity_1 = require("./task-candidate.entity");
|
|
37
41
|
const task_entity_1 = require("./task.entity");
|
|
@@ -42,6 +46,8 @@ const workflow_service_task_dispatcher_token_1 = require("./workflow-service-tas
|
|
|
42
46
|
const workflow_token_entity_1 = require("./workflow-token.entity");
|
|
43
47
|
const bpm_root_options_1 = require("../bpm/bpm-root-options");
|
|
44
48
|
const MAX_PROCESSING_STEPS = 500;
|
|
49
|
+
exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH = 200;
|
|
50
|
+
const APPROVAL_INSTANCE_BUSINESS_KEY_INDEX = 'approval_instances_business_key_key';
|
|
45
51
|
// Guards ORG_MANAGER_CHAIN against misconfigured manager rules that never end.
|
|
46
52
|
const ORG_MANAGER_CHAIN_MAX_HOPS = 20;
|
|
47
53
|
const DEFAULT_APPROVAL_HISTORY_TASK_LIMIT = 50;
|
|
@@ -92,9 +98,27 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
92
98
|
injectedServiceTaskDispatcher ??
|
|
93
99
|
rootOptions?.workflowServiceTaskDispatcher ??
|
|
94
100
|
new workflow_service_task_dispatcher_token_1.DefaultWorkflowServiceTaskDispatcher();
|
|
101
|
+
this.organizationTimeZone = (0, organization_time_zone_1.resolveBPMOrganizationTimeZone)(rootOptions?.organizationTimeZone);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Today's date as the organization sees it (`organizationTimeZone`), for
|
|
105
|
+
* comparing against the date-only `effectiveFrom` / `effectiveTo` of
|
|
106
|
+
* memberships and manager resolutions. Not for timestamp columns, which are
|
|
107
|
+
* compared as instants.
|
|
108
|
+
*/
|
|
109
|
+
readOrganizationToday() {
|
|
110
|
+
return (0, organization_time_zone_1.formatDateOnlyInTimeZone)(new Date(), this.organizationTimeZone);
|
|
95
111
|
}
|
|
96
112
|
async submitApprovalInstance(input, authContext) {
|
|
113
|
+
if (input.submittedByMemberId !== undefined &&
|
|
114
|
+
input.submittedByMemberId !== null &&
|
|
115
|
+
(typeof input.submittedByMemberId !== 'string' ||
|
|
116
|
+
input.submittedByMemberId.trim() === '')) {
|
|
117
|
+
throw new common_1.BadRequestException('submittedByMemberId must be a non-empty string');
|
|
118
|
+
}
|
|
97
119
|
const formData = parseJsonObject(input.formDataJson, 'formDataJson');
|
|
120
|
+
const businessKey = normalizeBusinessKey(input.businessKey);
|
|
121
|
+
const submittedByMemberId = input.submittedByMemberId ?? input.initiatorMemberId;
|
|
98
122
|
const template = await this.getTemplateOrThrow(input.templateId);
|
|
99
123
|
if (!template.isActive) {
|
|
100
124
|
throw new common_1.ConflictException('Approval template is deactivated');
|
|
@@ -117,6 +141,12 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
117
141
|
if (!this.conditionService.evaluateBoolean(templateVersion.initiatorPolicyCel, buildInitiatorPolicyContext(initiatorMetadataSnapshot), 'initiatorPolicyCel')) {
|
|
118
142
|
throw new common_1.ConflictException('Current member is not allowed to start this approval template');
|
|
119
143
|
}
|
|
144
|
+
// Named here, before the transaction, so the completion notice that later
|
|
145
|
+
// says "由 X 代為送出" reads it from the started activity instead of
|
|
146
|
+
// asking the identity source while an approval holds the instance's locks.
|
|
147
|
+
const submittedByName = submittedByMemberId === input.initiatorMemberId
|
|
148
|
+
? null
|
|
149
|
+
: await this.notificationService.readMemberDisplayName(submittedByMemberId);
|
|
120
150
|
const startedAt = new Date();
|
|
121
151
|
const startNode = templateVersion.workflowDefinition.nodes.find((node) => node.type === 'startEvent');
|
|
122
152
|
if (!startNode) {
|
|
@@ -136,10 +166,12 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
136
166
|
uiSchema: formDefinitionVersion.uiSchema,
|
|
137
167
|
version: formDefinitionVersion.version,
|
|
138
168
|
},
|
|
169
|
+
businessKey,
|
|
139
170
|
initiatorMemberId: input.initiatorMemberId,
|
|
140
171
|
initiatorMetadataSnapshot,
|
|
141
172
|
startedAt,
|
|
142
173
|
state: workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING,
|
|
174
|
+
submittedByMemberId,
|
|
143
175
|
templateId: template.id,
|
|
144
176
|
templateVersionId: templateVersion.id,
|
|
145
177
|
title: input.title?.trim() || template.name,
|
|
@@ -163,6 +195,8 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
163
195
|
instanceId: instance.id,
|
|
164
196
|
nodeId: startNode.id,
|
|
165
197
|
payload: {
|
|
198
|
+
submittedByMemberId,
|
|
199
|
+
...(submittedByName === null ? {} : { submittedByName }),
|
|
166
200
|
templateId: template.id,
|
|
167
201
|
templateVersionId: templateVersion.id,
|
|
168
202
|
},
|
|
@@ -181,6 +215,28 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
181
215
|
]);
|
|
182
216
|
await this.processRunningInstance(manager, instance);
|
|
183
217
|
return instance;
|
|
218
|
+
}).catch((error) =>
|
|
219
|
+
// The insert decides, not a pre-check: two concurrent submits would both
|
|
220
|
+
// pass a check, and only the unique index sees them as one.
|
|
221
|
+
throwBusinessKeyConflictOrRethrow(error, businessKey));
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* The instance a host submitted for `businessKey` on `templateId`, whatever
|
|
225
|
+
* its state (a cancelled or rejected instance still holds its key), or
|
|
226
|
+
* `null`. The key is trimmed the way submit trims it, so a lookup finds what
|
|
227
|
+
* a submit stored.
|
|
228
|
+
*/
|
|
229
|
+
async findApprovalInstanceByBusinessKey(templateId, businessKey) {
|
|
230
|
+
const normalizedKey = businessKey.trim();
|
|
231
|
+
// A template id that is not a uuid cannot match a row, and handing it to
|
|
232
|
+
// Postgres would raise 22P02 (a 500) instead of "not found".
|
|
233
|
+
if (!(0, class_validator_1.isUUID)(templateId) ||
|
|
234
|
+
normalizedKey === '' ||
|
|
235
|
+
countCodePoints(normalizedKey) > exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
return this.approvalInstanceRepository.findOne({
|
|
239
|
+
where: { businessKey: normalizedKey, templateId },
|
|
184
240
|
});
|
|
185
241
|
}
|
|
186
242
|
async resolveFormDataOptionSnapshots({ authContext, formData, previousFormData, previousSnapshots, revalidateAll, schema, }) {
|
|
@@ -324,6 +380,27 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
324
380
|
transferToMemberId === instance.initiatorMemberId) {
|
|
325
381
|
throw new common_1.ConflictException(`簽核節點「${taskNode.data.label}」不允許申請人自簽,不能將任務轉交給申請人本人`);
|
|
326
382
|
}
|
|
383
|
+
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED &&
|
|
384
|
+
options.transferReason === undefined &&
|
|
385
|
+
isInitiatorExcluded(taskNode) &&
|
|
386
|
+
transferToMemberId !== null &&
|
|
387
|
+
transferToMemberId === (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance)) {
|
|
388
|
+
throw new common_1.ConflictException(`簽核節點「${taskNode.data.label}」不允許代送人簽核,不能將任務轉交給代為送出此單的人`);
|
|
389
|
+
}
|
|
390
|
+
// Same carve-out: a human picking the target on a manager-chain node
|
|
391
|
+
// must pick someone at least as senior as the member being replaced.
|
|
392
|
+
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED &&
|
|
393
|
+
options.transferReason === undefined &&
|
|
394
|
+
transferToMemberId !== null &&
|
|
395
|
+
!task.isAdhoc &&
|
|
396
|
+
isOrgManagerChainNode(taskNode)) {
|
|
397
|
+
await this.assertTransferTargetGrade(manager, {
|
|
398
|
+
actorMemberId: input.decidedByMemberId,
|
|
399
|
+
delegationChain: actorCandidate?.delegationChain ?? task.delegationChain,
|
|
400
|
+
label: taskNode.data.label,
|
|
401
|
+
targetMemberId: transferToMemberId,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
327
404
|
const returnToNodeId = input.action === workflow_engine_enums_1.TaskDecisionActionEnum.RETURNED
|
|
328
405
|
? readReturnTargetNodeId(instance.workflowSnapshot, task.nodeId, input.returnToNodeId)
|
|
329
406
|
: null;
|
|
@@ -850,6 +927,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
850
927
|
})));
|
|
851
928
|
task.candidateMemberIds = savedCandidates.map((candidate) => candidate.memberId);
|
|
852
929
|
task.openCandidateMemberIds = task.candidateMemberIds;
|
|
930
|
+
task.nodeLabel = readWorkflowNodeLabel(instance.workflowSnapshot, node.id);
|
|
853
931
|
await activityRepository.save(activityRepository.create({
|
|
854
932
|
actorMemberId: directive.createdByMemberId,
|
|
855
933
|
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_CREATED,
|
|
@@ -882,14 +960,18 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
882
960
|
return task;
|
|
883
961
|
}
|
|
884
962
|
async resolveAdhocTaskCandidates(manager, instance, node, label, targetValue) {
|
|
885
|
-
const resolvedCandidates = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), `${label}「${node.data.label}
|
|
886
|
-
|
|
887
|
-
|
|
963
|
+
const resolvedCandidates = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), `${label}「${node.data.label}」`, '簽核人', isInitiatorExcluded(node) ? readInitiatorAndSubmitter(instance) : []);
|
|
964
|
+
// Ad-hoc approvers are picked by a person, not walked up a manager chain,
|
|
965
|
+
// so the chain's grade rule does not apply to them.
|
|
966
|
+
const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, false);
|
|
967
|
+
const candidates = selectNodeCandidates(delegatedCandidates, isInitiatorExcluded(node), readInitiatorAndSubmitter(instance));
|
|
888
968
|
if (candidates.length > 0) {
|
|
889
969
|
return candidates;
|
|
890
970
|
}
|
|
891
971
|
throw new common_1.ConflictException(delegatedCandidates.length > 0
|
|
892
|
-
?
|
|
972
|
+
? (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) === null
|
|
973
|
+
? `${label}「${node.data.label}」 無法建立任務:指定對象只有申請人本人,此節點不允許申請人簽核。`
|
|
974
|
+
: `${label}「${node.data.label}」 無法建立任務:指定對象只有申請人或代為送出此單的人,此節點不允許申請人或代送人簽核。`
|
|
893
975
|
: `${label}「${node.data.label}」 did not resolve to any member id`);
|
|
894
976
|
}
|
|
895
977
|
async spawnCountersignTasksForNode(manager, instance, token, node, now) {
|
|
@@ -1277,7 +1359,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1277
1359
|
return;
|
|
1278
1360
|
}
|
|
1279
1361
|
try {
|
|
1280
|
-
const recipients = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), '臨時通知對象');
|
|
1362
|
+
const recipients = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), '臨時通知對象', '通知對象');
|
|
1281
1363
|
await this.notificationService.createAdhocWorkflowNotifications({
|
|
1282
1364
|
channels: directive.channels,
|
|
1283
1365
|
instance,
|
|
@@ -1395,6 +1477,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1395
1477
|
: null;
|
|
1396
1478
|
const assigneeMemberId = assignment?.assigneeMemberId ?? null;
|
|
1397
1479
|
const skippedByExclusion = assignment?.outcome === 'SKIPPED';
|
|
1480
|
+
const stoppedByAssignment = assignment?.outcome === 'STOPPED';
|
|
1398
1481
|
const nextLastDecision = entryConditionMet && node.type === 'userTask' && !skippedByExclusion
|
|
1399
1482
|
? {
|
|
1400
1483
|
action: workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED,
|
|
@@ -1402,6 +1485,15 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1402
1485
|
nodeId: node.id,
|
|
1403
1486
|
}
|
|
1404
1487
|
: input.lastDecision;
|
|
1488
|
+
// A node that stops on its assignment, an asynchronous NOTIFY node (at
|
|
1489
|
+
// runtime it consumes its token and never advances) and an end event do
|
|
1490
|
+
// not route any further.
|
|
1491
|
+
const routes = !stoppedByAssignment &&
|
|
1492
|
+
!(0, workflow_graph_1.isAsyncNotifyServiceTask)(node) &&
|
|
1493
|
+
node.type !== 'endEvent';
|
|
1494
|
+
const outgoing = routes
|
|
1495
|
+
? this.readDryRunOutgoing(input, node, nextLastDecision, entryConditionMet && !skippedByExclusion)
|
|
1496
|
+
: { edges: [], stopMessage: null };
|
|
1405
1497
|
const nextSteps = [
|
|
1406
1498
|
...input.steps,
|
|
1407
1499
|
createDryRunStep({
|
|
@@ -1411,39 +1503,16 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1411
1503
|
edgeReason: readDryRunEdgeReason(input.incomingEdge),
|
|
1412
1504
|
entryCondition,
|
|
1413
1505
|
entryConditionMet,
|
|
1414
|
-
message: assignment?.message ?? readDryRunStepMessage(node, entryConditionMet),
|
|
1506
|
+
message: readDryRunStepResultMessage(assignment?.message ?? null, outgoing.stopMessage, readDryRunStepMessage(node, entryConditionMet), !entryConditionMet || skippedByExclusion),
|
|
1415
1507
|
node,
|
|
1416
|
-
status:
|
|
1508
|
+
status: outgoing.stopMessage === null
|
|
1509
|
+
? (assignment?.outcome ??
|
|
1510
|
+
readDryRunStepStatus(node, entryConditionMet))
|
|
1511
|
+
: 'STOPPED',
|
|
1417
1512
|
stepIndex: input.steps.length,
|
|
1418
1513
|
}),
|
|
1419
1514
|
];
|
|
1420
|
-
|
|
1421
|
-
// branch ends here, as it would fail at runtime.
|
|
1422
|
-
if (assignment?.outcome === 'STOPPED') {
|
|
1423
|
-
return nextSteps;
|
|
1424
|
-
}
|
|
1425
|
-
// A NOTIFY node is an asynchronous branch: at runtime it consumes its
|
|
1426
|
-
// token and never advances, so its branch ends here rather than failing
|
|
1427
|
-
// for want of an outgoing edge it is not allowed to have.
|
|
1428
|
-
if ((0, workflow_graph_1.isAsyncNotifyServiceTask)(node)) {
|
|
1429
|
-
return nextSteps;
|
|
1430
|
-
}
|
|
1431
|
-
if (!entryConditionMet || skippedByExclusion || node.type === 'endEvent') {
|
|
1432
|
-
if (node.type === 'endEvent') {
|
|
1433
|
-
return nextSteps;
|
|
1434
|
-
}
|
|
1435
|
-
return readOutgoingEdgesOrThrow(input.workflowDefinition, node.id).reduce((steps, edge) => this.simulateWorkflowPath({
|
|
1436
|
-
...input,
|
|
1437
|
-
depth: input.depth + 1,
|
|
1438
|
-
incomingEdge: edge,
|
|
1439
|
-
lastDecision: nextLastDecision,
|
|
1440
|
-
nodeId: edge.target,
|
|
1441
|
-
steps,
|
|
1442
|
-
visitedNodeIds: nextVisitedNodeIds,
|
|
1443
|
-
}), nextSteps);
|
|
1444
|
-
}
|
|
1445
|
-
const outgoingEdges = this.readDryRunAvailableOutgoingEdges(input, node, nextLastDecision);
|
|
1446
|
-
return outgoingEdges.reduce((steps, edge) => this.simulateWorkflowPath({
|
|
1515
|
+
return outgoing.edges.reduce((steps, edge) => this.simulateWorkflowPath({
|
|
1447
1516
|
...input,
|
|
1448
1517
|
depth: input.depth + 1,
|
|
1449
1518
|
incomingEdge: edge,
|
|
@@ -1453,28 +1522,45 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1453
1522
|
visitedNodeIds: nextVisitedNodeIds,
|
|
1454
1523
|
}), nextSteps);
|
|
1455
1524
|
}
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1525
|
+
/**
|
|
1526
|
+
* The edges a dry-run step continues along. A node a designer has left
|
|
1527
|
+
* unfinished (nothing drawn out of it, or no branch of a gateway taken) ends
|
|
1528
|
+
* its branch with a readable message instead of failing the whole dry run.
|
|
1529
|
+
*/
|
|
1530
|
+
readDryRunOutgoing(input, node, lastDecision, evaluatesConditions) {
|
|
1531
|
+
const nodeName = readDryRunNodeName(node);
|
|
1532
|
+
const outgoingEdges = input.workflowDefinition.edges.filter((edge) => edge.source === node.id);
|
|
1533
|
+
if (outgoingEdges.length === 0) {
|
|
1534
|
+
return {
|
|
1535
|
+
edges: [],
|
|
1536
|
+
stopMessage: `節點「${nodeName}」沒有連到下一個節點,請在流程設計器為此節點接上連線`,
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
if (!evaluatesConditions) {
|
|
1540
|
+
return { edges: outgoingEdges, stopMessage: null };
|
|
1459
1541
|
}
|
|
1460
|
-
const outgoingEdges = readOutgoingEdgesOrThrow(input.workflowDefinition, node.id);
|
|
1461
1542
|
const conditionalEdges = outgoingEdges.filter((edge) => !edge.data.isDefault && edgeHasCondition(edge));
|
|
1462
1543
|
if (conditionalEdges.length === 0) {
|
|
1463
|
-
return outgoingEdges;
|
|
1544
|
+
return { edges: outgoingEdges, stopMessage: null };
|
|
1464
1545
|
}
|
|
1465
1546
|
const matchingEdges = conditionalEdges.filter((edge) => this.evaluateDryRunEdge(input, edge, lastDecision));
|
|
1466
1547
|
if (matchingEdges.length > 0) {
|
|
1467
|
-
return
|
|
1468
|
-
? [matchingEdges[0]]
|
|
1469
|
-
:
|
|
1548
|
+
return {
|
|
1549
|
+
edges: node.type === 'exclusiveGateway' ? [matchingEdges[0]] : matchingEdges,
|
|
1550
|
+
stopMessage: null,
|
|
1551
|
+
};
|
|
1470
1552
|
}
|
|
1471
1553
|
const defaultEdges = outgoingEdges.filter((edge) => edge.data.isDefault);
|
|
1472
1554
|
if (defaultEdges.length > 0) {
|
|
1473
|
-
return
|
|
1474
|
-
? [defaultEdges[0]]
|
|
1475
|
-
:
|
|
1555
|
+
return {
|
|
1556
|
+
edges: node.type === 'exclusiveGateway' ? [defaultEdges[0]] : defaultEdges,
|
|
1557
|
+
stopMessage: null,
|
|
1558
|
+
};
|
|
1476
1559
|
}
|
|
1477
|
-
|
|
1560
|
+
return {
|
|
1561
|
+
edges: [],
|
|
1562
|
+
stopMessage: `節點「${nodeName}」的連出線條件都不符合,且沒有設定預設路徑,請檢查連出線的條件或補上預設路徑`,
|
|
1563
|
+
};
|
|
1478
1564
|
}
|
|
1479
1565
|
evaluateDryRunEdge(input, edge, lastDecision) {
|
|
1480
1566
|
const expression = edge.data.condition?.trim();
|
|
@@ -1491,14 +1577,28 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1491
1577
|
*/
|
|
1492
1578
|
resolveDryRunAssignment(input, node) {
|
|
1493
1579
|
const resolver = node.data.approverResolver;
|
|
1494
|
-
const label = `workflow.nodes.${node.id}.data.approverResolver`;
|
|
1495
1580
|
const excludeInitiator = node.data.excludeInitiator;
|
|
1496
|
-
const memberIds =
|
|
1497
|
-
|
|
1498
|
-
: null;
|
|
1499
|
-
if (!excludeInitiator || !memberIds?.includes(input.initiatorMemberId)) {
|
|
1581
|
+
const memberIds = this.readDryRunMemberIds(input, resolver, `workflow.nodes.${node.id}.data.approverResolver`);
|
|
1582
|
+
if (memberIds === null) {
|
|
1500
1583
|
return {
|
|
1501
|
-
assigneeMemberId:
|
|
1584
|
+
assigneeMemberId: readDryRunPlaceholderAssignee(resolver),
|
|
1585
|
+
message: null,
|
|
1586
|
+
outcome: null,
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
// Nobody to ask yet — typically a node whose approver has not been chosen.
|
|
1590
|
+
// The branch ends here and says which node needs attention, rather than
|
|
1591
|
+
// failing the whole dry run.
|
|
1592
|
+
if (memberIds.length === 0) {
|
|
1593
|
+
return {
|
|
1594
|
+
assigneeMemberId: null,
|
|
1595
|
+
message: readDryRunUnresolvedApproverMessage(resolver, readDryRunNodeName(node), input.initiatorMemberId),
|
|
1596
|
+
outcome: 'STOPPED',
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
if (!excludeInitiator || !memberIds.includes(input.initiatorMemberId)) {
|
|
1600
|
+
return {
|
|
1601
|
+
assigneeMemberId: memberIds[0],
|
|
1502
1602
|
message: null,
|
|
1503
1603
|
outcome: null,
|
|
1504
1604
|
};
|
|
@@ -1542,48 +1642,29 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1542
1642
|
outcome: 'STOPPED',
|
|
1543
1643
|
};
|
|
1544
1644
|
}
|
|
1645
|
+
/**
|
|
1646
|
+
* The member ids a dry run resolves for a user task: an empty list when the
|
|
1647
|
+
* resolver names nobody, `null` for resolvers that only resolve to a
|
|
1648
|
+
* placeholder in a dry run.
|
|
1649
|
+
*/
|
|
1545
1650
|
readDryRunMemberIds(input, resolver, label) {
|
|
1546
1651
|
if (resolver.type === 'DIRECT') {
|
|
1547
|
-
return
|
|
1652
|
+
return findMemberIdsInValue(resolver.memberIds);
|
|
1548
1653
|
}
|
|
1549
1654
|
if (resolver.type === 'DYNAMIC_FORM') {
|
|
1550
|
-
return
|
|
1655
|
+
return findMemberIdsInValue(readValueAtPath(input.formData, resolver.formPath));
|
|
1551
1656
|
}
|
|
1552
1657
|
if (resolver.type === 'EXPRESSION') {
|
|
1553
|
-
return
|
|
1658
|
+
return findMemberIdsInValue(this.conditionService.evaluateValue(resolver.expression, buildDryRunExpressionContext(input, null), `${label}.expression`));
|
|
1554
1659
|
}
|
|
1555
1660
|
if (resolver.type === 'ORG_MANAGER') {
|
|
1556
|
-
|
|
1661
|
+
const managerMemberIds = findMemberIdsInValue(readManagerMemberIdFromInitiatorSnapshot(input.initiatorMetadataSnapshot, resolver.levelsUp));
|
|
1662
|
+
return managerMemberIds.length > 0
|
|
1663
|
+
? managerMemberIds
|
|
1664
|
+
: readDryRunFallbackMemberIds(input, resolver.fallback);
|
|
1557
1665
|
}
|
|
1558
1666
|
return null;
|
|
1559
1667
|
}
|
|
1560
|
-
resolveDryRunAssigneeMemberId(input, resolver, label) {
|
|
1561
|
-
if (resolver.type === 'DIRECT') {
|
|
1562
|
-
return readMemberIdFromValue(resolver.memberIds, label);
|
|
1563
|
-
}
|
|
1564
|
-
if (resolver.type === 'DYNAMIC_FORM') {
|
|
1565
|
-
return readMemberIdFromValue(readValueAtPath(input.formData, resolver.formPath), `${label}.formPath`);
|
|
1566
|
-
}
|
|
1567
|
-
if (resolver.type === 'EXPRESSION') {
|
|
1568
|
-
return readMemberIdFromValue(this.conditionService.evaluateValue(resolver.expression, buildDryRunExpressionContext(input, null), `${label}.expression`), `${label}.expression`);
|
|
1569
|
-
}
|
|
1570
|
-
if (resolver.type === 'POSITION') {
|
|
1571
|
-
return `position:${resolver.positionId}`;
|
|
1572
|
-
}
|
|
1573
|
-
if (resolver.type === 'ORG_UNIT_MEMBER') {
|
|
1574
|
-
return `orgUnitMember:${resolver.orgUnitId}`;
|
|
1575
|
-
}
|
|
1576
|
-
if (resolver.type === 'ORG_UNIT_POSITION') {
|
|
1577
|
-
return `orgUnitPosition:${resolver.orgUnitId}:${resolver.positionId}`;
|
|
1578
|
-
}
|
|
1579
|
-
if (resolver.type === 'ORG_UNIT_MANAGER') {
|
|
1580
|
-
return `orgUnitManager:${resolver.orgUnitId}`;
|
|
1581
|
-
}
|
|
1582
|
-
if (resolver.type === 'ORG_MANAGER_CHAIN') {
|
|
1583
|
-
return `orgManagerChain:${resolver.minPositionLevel}`;
|
|
1584
|
-
}
|
|
1585
|
-
return readMemberIdFromValue(readManagerMemberIdFromInitiatorSnapshot(input.initiatorMetadataSnapshot, resolver.levelsUp), label);
|
|
1586
|
-
}
|
|
1587
1668
|
async getApprovalInstance(id, scope) {
|
|
1588
1669
|
const instance = await this.approvalInstanceRepository.findOne({
|
|
1589
1670
|
where: { id },
|
|
@@ -1655,7 +1736,11 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1655
1736
|
}
|
|
1656
1737
|
const view = options.view ?? workflow_engine_enums_1.ApprovalInstanceListViewEnum.ALL;
|
|
1657
1738
|
if (view === workflow_engine_enums_1.ApprovalInstanceListViewEnum.SENT) {
|
|
1658
|
-
|
|
1739
|
+
// The assistant who filed on someone's behalf finds it here too.
|
|
1740
|
+
queryBuilder.andWhere(`(
|
|
1741
|
+
"approvalInstance"."initiator_member_id" = :readableMemberId
|
|
1742
|
+
OR "approvalInstance"."submitted_by_member_id" = :readableMemberId
|
|
1743
|
+
)`, {
|
|
1659
1744
|
readableMemberId: scope.memberId,
|
|
1660
1745
|
});
|
|
1661
1746
|
return true;
|
|
@@ -1679,6 +1764,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1679
1764
|
queryBuilder.andWhere(`
|
|
1680
1765
|
(
|
|
1681
1766
|
"approvalInstance"."initiator_member_id" = :readableMemberId
|
|
1767
|
+
OR "approvalInstance"."submitted_by_member_id" = :readableMemberId
|
|
1682
1768
|
OR EXISTS (
|
|
1683
1769
|
SELECT 1
|
|
1684
1770
|
FROM "tasks" "readableTask"
|
|
@@ -1718,14 +1804,13 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1718
1804
|
view: workflow_engine_enums_1.ApprovalInstanceListViewEnum.ALL,
|
|
1719
1805
|
})).filter((instance) => isInstanceWithinRange(instance, options));
|
|
1720
1806
|
const instanceIds = instances.map((instance) => instance.id);
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
: [];
|
|
1807
|
+
// The task counters answer "what is waiting on me", not "what is open on
|
|
1808
|
+
// the instances I can see": a member who can read an instance as initiator,
|
|
1809
|
+
// reader or administrator is not thereby the one who has to act on it. So
|
|
1810
|
+
// they count the member's own inbox, narrowed to the instances in range.
|
|
1811
|
+
// Without a scope there is no member to speak for, and the counters keep
|
|
1812
|
+
// describing every open task.
|
|
1813
|
+
const openTasks = await this.readDashboardOpenTasks(scope, instanceIds);
|
|
1729
1814
|
const now = Date.now();
|
|
1730
1815
|
return {
|
|
1731
1816
|
activeInstanceCount: instances.filter((instance) => instance.state === workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING).length,
|
|
@@ -1736,6 +1821,21 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1736
1821
|
totalInstanceCount: instances.length,
|
|
1737
1822
|
};
|
|
1738
1823
|
}
|
|
1824
|
+
async readDashboardOpenTasks(scope, instanceIds) {
|
|
1825
|
+
if (!instanceIds.length) {
|
|
1826
|
+
return [];
|
|
1827
|
+
}
|
|
1828
|
+
if (scope) {
|
|
1829
|
+
const instanceIdSet = new Set(instanceIds);
|
|
1830
|
+
return (await this.readOpenInboxTasks(scope.memberId)).filter((task) => instanceIdSet.has(task.instanceId));
|
|
1831
|
+
}
|
|
1832
|
+
return this.taskRepository.find({
|
|
1833
|
+
where: {
|
|
1834
|
+
instanceId: (0, typeorm_2.In)(instanceIds),
|
|
1835
|
+
status: (0, typeorm_2.In)([workflow_engine_enums_1.TaskStatusEnum.PENDING, workflow_engine_enums_1.TaskStatusEnum.IN_PROGRESS]),
|
|
1836
|
+
},
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1739
1839
|
async listLaunchableApprovalTemplates(memberId) {
|
|
1740
1840
|
const templates = await this.approvalTemplateRepository.find({
|
|
1741
1841
|
order: { updatedAt: 'DESC' },
|
|
@@ -1783,6 +1883,17 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1783
1883
|
}));
|
|
1784
1884
|
}
|
|
1785
1885
|
async listInboxTasks(assigneeMemberId) {
|
|
1886
|
+
return this.attachTaskCandidateSummaries(await this.readOpenInboxTasks(assigneeMemberId));
|
|
1887
|
+
}
|
|
1888
|
+
/**
|
|
1889
|
+
* The open tasks that are this member's own to act on — the single
|
|
1890
|
+
* definition behind both the inbox list and the dashboard counters, so the
|
|
1891
|
+
* two can never disagree. A task is theirs when it is assigned to them, or
|
|
1892
|
+
* when they hold a PENDING or CLAIMED candidate row on it. A sequential
|
|
1893
|
+
* chain's later candidates sit in WAITING until it is their turn, so a task
|
|
1894
|
+
* that has not reached them is not theirs yet.
|
|
1895
|
+
*/
|
|
1896
|
+
async readOpenInboxTasks(assigneeMemberId) {
|
|
1786
1897
|
const directTasks = await this.taskRepository.find({
|
|
1787
1898
|
order: { createdAt: 'DESC' },
|
|
1788
1899
|
where: [
|
|
@@ -1832,7 +1943,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1832
1943
|
})
|
|
1833
1944
|
: [];
|
|
1834
1945
|
const settledTaskIds = new Set(settledCandidateRows.map((candidate) => candidate.taskId));
|
|
1835
|
-
return
|
|
1946
|
+
return openTasks.filter((task) => !settledTaskIds.has(task.id));
|
|
1836
1947
|
}
|
|
1837
1948
|
async listApprovalHistoryTasks(assigneeMemberId) {
|
|
1838
1949
|
const directTasks = await this.taskRepository.find({
|
|
@@ -1909,13 +2020,54 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1909
2020
|
]);
|
|
1910
2021
|
return nextMap;
|
|
1911
2022
|
}, new Map());
|
|
2023
|
+
const nodeLabels = await this.readTaskNodeLabels(tasks);
|
|
1912
2024
|
return tasks.map((task) => Object.assign(new task_entity_1.TaskEntity(), task, {
|
|
1913
2025
|
candidateMemberIds: candidatesByTaskId.get(task.id) ?? [],
|
|
2026
|
+
nodeLabel: nodeLabels.get(readNodeLabelKey(task.instanceId, task.nodeId)) ??
|
|
2027
|
+
null,
|
|
1914
2028
|
openCandidateMemberIds: openCandidatesByTaskId.get(task.id) ?? [],
|
|
1915
2029
|
}));
|
|
1916
2030
|
}
|
|
2031
|
+
/**
|
|
2032
|
+
* The display names of the nodes a page of tasks sits on, keyed by
|
|
2033
|
+
* {@link readNodeLabelKey}. One query for the whole page, and Postgres picks
|
|
2034
|
+
* the labels out of the snapshots: a workflow snapshot is a whole workflow
|
|
2035
|
+
* definition, and every inbox, history and instance task list runs this, so
|
|
2036
|
+
* shipping the snapshots to the app just to read one string each would cost
|
|
2037
|
+
* page size × snapshot size on every refresh. A missing instance or node, a
|
|
2038
|
+
* snapshot without a `nodes` array, and a blank or non-string label all
|
|
2039
|
+
* leave the task without a label, as `readWorkflowNodeLabel` does.
|
|
2040
|
+
*/
|
|
2041
|
+
async readTaskNodeLabels(tasks) {
|
|
2042
|
+
// The table as the entity reads it, so a configured `schema` is honoured
|
|
2043
|
+
// here exactly as it is by every repository read of the instance.
|
|
2044
|
+
const { metadata } = this.approvalInstanceRepository;
|
|
2045
|
+
const { driver } = this.approvalInstanceRepository.manager.connection;
|
|
2046
|
+
const instanceTable = [metadata.schema, metadata.tableName]
|
|
2047
|
+
.filter((part) => Boolean(part))
|
|
2048
|
+
.map((part) => driver.escape(part))
|
|
2049
|
+
.join('.');
|
|
2050
|
+
const rows = await this.approvalInstanceRepository.query(`SELECT instance.id AS "instanceId",
|
|
2051
|
+
node->>'id' AS "nodeId",
|
|
2052
|
+
CASE WHEN jsonb_typeof(node->'data'->'label') = 'string'
|
|
2053
|
+
THEN node->'data'->>'label' END AS "label"
|
|
2054
|
+
FROM ${instanceTable} AS instance
|
|
2055
|
+
CROSS JOIN LATERAL jsonb_array_elements(
|
|
2056
|
+
CASE WHEN jsonb_typeof(instance.workflow_snapshot->'nodes') = 'array'
|
|
2057
|
+
THEN instance.workflow_snapshot->'nodes'
|
|
2058
|
+
ELSE '[]'::jsonb END
|
|
2059
|
+
) AS node
|
|
2060
|
+
WHERE instance.id = ANY($1::uuid[])
|
|
2061
|
+
AND node->>'id' = ANY($2::text[])`, [
|
|
2062
|
+
[...new Set(tasks.map((task) => task.instanceId))],
|
|
2063
|
+
[...new Set(tasks.map((task) => task.nodeId))],
|
|
2064
|
+
]);
|
|
2065
|
+
return new Map(rows.flatMap((row) => typeof row.label === 'string' && row.label.trim()
|
|
2066
|
+
? [[readNodeLabelKey(row.instanceId, row.nodeId), row.label]]
|
|
2067
|
+
: []));
|
|
2068
|
+
}
|
|
1917
2069
|
async readDefaultInitiatorMetadataSnapshot(memberId) {
|
|
1918
|
-
const today =
|
|
2070
|
+
const today = this.readOrganizationToday();
|
|
1919
2071
|
const memberships = (await this.approvalInstanceRepository.manager
|
|
1920
2072
|
.getRepository(membership_entity_1.MembershipEntity)
|
|
1921
2073
|
.find({ where: { memberId } }))
|
|
@@ -1950,7 +2102,10 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1950
2102
|
return task;
|
|
1951
2103
|
}
|
|
1952
2104
|
async isApprovalInstanceReadableByMember(instance, memberId) {
|
|
1953
|
-
|
|
2105
|
+
// The member who filed the document on the applicant's behalf can read it
|
|
2106
|
+
// back; resubmit and cancel stay with the initiator.
|
|
2107
|
+
if (instance.initiatorMemberId === memberId ||
|
|
2108
|
+
(0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) === memberId) {
|
|
1954
2109
|
return true;
|
|
1955
2110
|
}
|
|
1956
2111
|
const tasks = await this.taskRepository.find({
|
|
@@ -2157,6 +2312,12 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2157
2312
|
});
|
|
2158
2313
|
}
|
|
2159
2314
|
async skipNode(manager, instance, token, node, reason) {
|
|
2315
|
+
// A NOTIFY node is an asynchronous branch that may not have an outgoing
|
|
2316
|
+
// edge, so a skipped one ends its branch exactly as an executed one does:
|
|
2317
|
+
// the token is consumed instead of advanced (advancing would throw "has no
|
|
2318
|
+
// outgoing edge" and fail the whole submission). Instance completion is
|
|
2319
|
+
// evaluated by `processRunningInstance` once no ACTIVE token remains.
|
|
2320
|
+
const consumesToken = (0, workflow_graph_1.isAsyncNotifyServiceTask)(node);
|
|
2160
2321
|
await manager.getRepository(activity_log_entity_1.ActivityLogEntity).save(manager.getRepository(activity_log_entity_1.ActivityLogEntity).create({
|
|
2161
2322
|
actorMemberId: null,
|
|
2162
2323
|
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TOKEN_ADVANCED,
|
|
@@ -2164,12 +2325,24 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2164
2325
|
nodeId: node.id,
|
|
2165
2326
|
payload: {
|
|
2166
2327
|
...reason,
|
|
2328
|
+
...(consumesToken ? { consumed: true } : {}),
|
|
2167
2329
|
tokenId: token.id,
|
|
2168
2330
|
},
|
|
2169
2331
|
taskId: null,
|
|
2170
2332
|
}));
|
|
2333
|
+
if (consumesToken) {
|
|
2334
|
+
await this.consumeToken(manager, token);
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2171
2337
|
await this.advanceTokenToOutgoingNodes(manager, instance, token, node);
|
|
2172
2338
|
}
|
|
2339
|
+
async consumeToken(manager, token) {
|
|
2340
|
+
await manager.getRepository(workflow_token_entity_1.WorkflowTokenEntity).save({
|
|
2341
|
+
...token,
|
|
2342
|
+
consumedAt: new Date(),
|
|
2343
|
+
status: workflow_engine_enums_1.WorkflowTokenStatusEnum.CONSUMED,
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
2173
2346
|
async readAvailableOutgoingEdges(manager, instance, node) {
|
|
2174
2347
|
const outgoingEdges = readOutgoingEdgesOrThrow(instance.workflowSnapshot, node.id);
|
|
2175
2348
|
const conditionalEdges = outgoingEdges.filter((edge) => !edge.data.isDefault && edgeHasCondition(edge));
|
|
@@ -2361,14 +2534,15 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2361
2534
|
});
|
|
2362
2535
|
return;
|
|
2363
2536
|
}
|
|
2364
|
-
const
|
|
2537
|
+
const resolvedCandidates = await this.resolveRuntimeTaskCandidates(manager, instance, node);
|
|
2365
2538
|
// The initiator was the only approver and the node asks to be skipped in
|
|
2366
2539
|
// that case: no task, the token moves on. Pending countersign directives
|
|
2367
2540
|
// stay pending for the next user task that actually gets created.
|
|
2368
|
-
if (
|
|
2541
|
+
if (resolvedCandidates === null) {
|
|
2369
2542
|
await this.skipNodeByInitiatorExclusion(manager, instance, token, node);
|
|
2370
2543
|
return;
|
|
2371
2544
|
}
|
|
2545
|
+
const { candidates, skippedDelegations } = resolvedCandidates;
|
|
2372
2546
|
const primaryCandidate = candidates[0];
|
|
2373
2547
|
// Legacy snapshots may carry no policy at all; they behave as before.
|
|
2374
2548
|
const decisionPolicy = node.data.decisionPolicy;
|
|
@@ -2441,6 +2615,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2441
2615
|
sequentialCandidateMemberIds: savedCandidates.map((candidate) => candidate.memberId),
|
|
2442
2616
|
}
|
|
2443
2617
|
: {}),
|
|
2618
|
+
...(skippedDelegations.length > 0 ? { skippedDelegations } : {}),
|
|
2444
2619
|
tokenId: token.id,
|
|
2445
2620
|
},
|
|
2446
2621
|
taskId: task.id,
|
|
@@ -2481,11 +2656,13 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2481
2656
|
const now = new Date();
|
|
2482
2657
|
// Delegation was resolved when the task was created; the candidate may have
|
|
2483
2658
|
// gained a delegate while waiting, so resolve it again for the new turn.
|
|
2484
|
-
const delegation = await this.resolveActivationDelegation({
|
|
2659
|
+
const { skippedDelegation, ...delegation } = await this.resolveActivationDelegation({
|
|
2485
2660
|
candidate: nextCandidate,
|
|
2486
2661
|
candidates,
|
|
2487
2662
|
excludesInitiator: isInitiatorExcluded(node),
|
|
2663
|
+
gradeGated: isGradeGatedCandidate(node, nextCandidate.sourceType),
|
|
2488
2664
|
instance,
|
|
2665
|
+
manager,
|
|
2489
2666
|
nodeId: task.nodeId,
|
|
2490
2667
|
});
|
|
2491
2668
|
const activatedCandidate = await manager
|
|
@@ -2525,6 +2702,9 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2525
2702
|
originalMemberId: activatedCandidate.originalMemberId,
|
|
2526
2703
|
previousMemberId,
|
|
2527
2704
|
sequence: activatedCandidate.sequence,
|
|
2705
|
+
...(skippedDelegation
|
|
2706
|
+
? { skippedDelegations: [skippedDelegation] }
|
|
2707
|
+
: {}),
|
|
2528
2708
|
tokenId: task.tokenId,
|
|
2529
2709
|
},
|
|
2530
2710
|
taskId: task.id,
|
|
@@ -2542,29 +2722,31 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2542
2722
|
* are kept: (task_id, member_id) is unique, and that member signs from the
|
|
2543
2723
|
* row they already have. On a node that excludes the initiator the stored
|
|
2544
2724
|
* values are kept as well when the new delegation path would run through the
|
|
2545
|
-
* initiator: creation already filtered such paths out, and a
|
|
2546
|
-
* while the candidate waited must not bring them back.
|
|
2725
|
+
* initiator or the submitter: creation already filtered such paths out, and a
|
|
2726
|
+
* delegation added while the candidate waited must not bring them back.
|
|
2547
2727
|
*/
|
|
2548
|
-
async resolveActivationDelegation({ candidate, candidates, excludesInitiator, instance, nodeId, }) {
|
|
2728
|
+
async resolveActivationDelegation({ candidate, candidates, excludesInitiator, gradeGated, instance, manager, nodeId, }) {
|
|
2549
2729
|
const stored = {
|
|
2550
2730
|
delegationChain: candidate.delegationChain,
|
|
2551
2731
|
memberId: candidate.memberId,
|
|
2552
2732
|
};
|
|
2553
|
-
const resolution = await this.
|
|
2733
|
+
const { skippedDelegation, ...resolution } = await this.resolveDelegation(manager, candidate.originalMemberId, this.buildDelegationContext(instance, nodeId), gradeGated);
|
|
2554
2734
|
const collides = candidates.some((other) => other.id !== candidate.id &&
|
|
2555
2735
|
other.memberId === resolution.finalAssigneeMemberId);
|
|
2556
2736
|
if (collides ||
|
|
2557
2737
|
(excludesInitiator &&
|
|
2558
|
-
|
|
2738
|
+
isExcludedMemberOnCandidatePath({
|
|
2559
2739
|
delegationChain: resolution.delegationChain,
|
|
2560
2740
|
memberId: resolution.finalAssigneeMemberId,
|
|
2561
2741
|
originalMemberId: candidate.originalMemberId,
|
|
2562
|
-
}, instance
|
|
2563
|
-
|
|
2742
|
+
}, readInitiatorAndSubmitter(instance)))) {
|
|
2743
|
+
// The refusal happened even though the stored values are kept.
|
|
2744
|
+
return { ...stored, ...(skippedDelegation ? { skippedDelegation } : {}) };
|
|
2564
2745
|
}
|
|
2565
2746
|
return {
|
|
2566
2747
|
delegationChain: resolution.delegationChain,
|
|
2567
2748
|
memberId: resolution.finalAssigneeMemberId,
|
|
2749
|
+
...(skippedDelegation ? { skippedDelegation } : {}),
|
|
2568
2750
|
};
|
|
2569
2751
|
}
|
|
2570
2752
|
async markTaskCandidateDecision({ action, candidate, candidates, decidedAt, manager, task, }) {
|
|
@@ -2802,12 +2984,16 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2802
2984
|
*/
|
|
2803
2985
|
async resolveRuntimeTaskCandidates(manager, instance, node) {
|
|
2804
2986
|
const label = `簽核節點「${node.data.label}」`;
|
|
2805
|
-
const resolvedCandidates = await this.resolveApproverResolver(manager, instance, node.data.approverResolver, label);
|
|
2806
|
-
const delegatedCandidates = await this.delegateResolvedCandidates(instance, node.id, resolvedCandidates);
|
|
2987
|
+
const resolvedCandidates = await this.resolveApproverResolver(manager, instance, node.data.approverResolver, label, '簽核人', isInitiatorExcluded(node) ? readInitiatorAndSubmitter(instance) : []);
|
|
2988
|
+
const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, isOrgManagerChainNode(node));
|
|
2807
2989
|
const excludeInitiator = node.data.excludeInitiator;
|
|
2808
|
-
const
|
|
2990
|
+
const hasSubmitter = (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) !== null;
|
|
2991
|
+
const uniqueCandidates = selectNodeCandidates(delegatedCandidates, isInitiatorExcluded(node), readInitiatorAndSubmitter(instance));
|
|
2809
2992
|
if (uniqueCandidates.length > 0) {
|
|
2810
|
-
return
|
|
2993
|
+
return {
|
|
2994
|
+
candidates: uniqueCandidates,
|
|
2995
|
+
skippedDelegations: collectSkippedDelegations(delegatedCandidates, isInitiatorExcluded(node), readInitiatorAndSubmitter(instance)),
|
|
2996
|
+
};
|
|
2811
2997
|
}
|
|
2812
2998
|
// Only the initiator was left — what the node does about it is configured.
|
|
2813
2999
|
// Nothing resolved to begin with is the ordinary failure below.
|
|
@@ -2819,23 +3005,31 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2819
3005
|
}
|
|
2820
3006
|
if (excludeInitiator.whenEmpty === 'INITIATOR_MANAGER') {
|
|
2821
3007
|
const managerCandidates = await this.resolveInitiatorManagerReplacements(manager, instance, node);
|
|
2822
|
-
if (managerCandidates.length > 0) {
|
|
3008
|
+
if (managerCandidates.candidates.length > 0) {
|
|
2823
3009
|
return managerCandidates;
|
|
2824
3010
|
}
|
|
2825
|
-
throw new common_1.ConflictException(
|
|
3011
|
+
throw new common_1.ConflictException(hasSubmitter
|
|
3012
|
+
? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人,此節點不允許申請人或代送人簽核,且找不到申請人的主管可改派。`
|
|
3013
|
+
: `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人,此節點不允許申請人自簽,且找不到申請人的主管可改派。`);
|
|
2826
3014
|
}
|
|
2827
|
-
throw new common_1.ConflictException(
|
|
3015
|
+
throw new common_1.ConflictException(hasSubmitter
|
|
3016
|
+
? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人(含委派後的代理人),且此節點不允許申請人或代送人簽核。`
|
|
3017
|
+
: `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人(含委派後的代理人),且此節點不允許申請人自簽。`);
|
|
2828
3018
|
}
|
|
2829
3019
|
async resolveInitiatorManagerReplacements(manager, instance, node) {
|
|
2830
|
-
// The initiator's own direct manager: the closest org unit only
|
|
2831
|
-
// initiator himself (a unit head)
|
|
2832
|
-
//
|
|
2833
|
-
|
|
2834
|
-
const
|
|
3020
|
+
// The initiator's own direct manager: the closest org unit only. The
|
|
3021
|
+
// initiator himself (a unit head) and the member who submitted for him are
|
|
3022
|
+
// skipped before the tier is chosen, so resolution falls through to the
|
|
3023
|
+
// next tier instead of ending on either of them.
|
|
3024
|
+
const managerMemberIds = await this.resolveDirectManagerMemberIds(manager, instance.initiatorMemberId, this.readOrganizationToday(), true, readInitiatorAndSubmitter(instance));
|
|
3025
|
+
const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, managerMemberIds.map((memberId) => ({
|
|
2835
3026
|
memberId,
|
|
2836
3027
|
sourceType: 'EXCLUDE_INITIATOR_MANAGER',
|
|
2837
|
-
})));
|
|
2838
|
-
return
|
|
3028
|
+
})), isOrgManagerChainNode(node));
|
|
3029
|
+
return {
|
|
3030
|
+
candidates: selectNodeCandidates(delegatedCandidates, true, readInitiatorAndSubmitter(instance)),
|
|
3031
|
+
skippedDelegations: collectSkippedDelegations(delegatedCandidates, true, readInitiatorAndSubmitter(instance)),
|
|
3032
|
+
};
|
|
2839
3033
|
}
|
|
2840
3034
|
buildDelegationContext(instance, nodeId) {
|
|
2841
3035
|
return {
|
|
@@ -2852,48 +3046,86 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2852
3046
|
}
|
|
2853
3047
|
// Not deduplicated: a node that excludes the initiator filters first, so a
|
|
2854
3048
|
// first-seen entry that must be dropped cannot shadow a legitimate one.
|
|
2855
|
-
async delegateResolvedCandidates(instance, nodeId, resolvedCandidates) {
|
|
3049
|
+
async delegateResolvedCandidates(manager, instance, nodeId, resolvedCandidates, gradeGated) {
|
|
2856
3050
|
const context = this.buildDelegationContext(instance, nodeId);
|
|
2857
|
-
const
|
|
2858
|
-
|
|
3051
|
+
const resolved = await Promise.all(resolvedCandidates.map(async (candidate) => ({
|
|
3052
|
+
candidate,
|
|
3053
|
+
resolution: await this.delegationService.resolveAssignee(candidate.memberId, context),
|
|
3054
|
+
})));
|
|
3055
|
+
// One batch of grade lookups for the whole node, not two per delegate.
|
|
3056
|
+
const levels = gradeGated
|
|
3057
|
+
? await this.readMemberPositionLevels(manager, resolved.flatMap(({ candidate, resolution }) => readGradeGateMemberIds(candidate.memberId, resolution)), this.readOrganizationToday())
|
|
3058
|
+
: new Map();
|
|
3059
|
+
return resolved.map(({ candidate, resolution }) => {
|
|
3060
|
+
const { skippedDelegation, ...delegationResolution } = applyGradeGate(candidate.memberId, resolution, gradeGated, levels);
|
|
2859
3061
|
return {
|
|
2860
3062
|
delegationChain: delegationResolution.delegationChain,
|
|
2861
3063
|
memberId: delegationResolution.finalAssigneeMemberId,
|
|
2862
3064
|
originalMemberId: candidate.memberId,
|
|
3065
|
+
...(skippedDelegation ? { skippedDelegation } : {}),
|
|
2863
3066
|
sourceType: candidate.sourceType,
|
|
2864
3067
|
};
|
|
2865
|
-
})
|
|
2866
|
-
|
|
3068
|
+
});
|
|
3069
|
+
}
|
|
3070
|
+
/** One member's delegation, grade-gated as {@link applyGradeGate} says. */
|
|
3071
|
+
async resolveDelegation(manager, principalMemberId, context, gradeGated) {
|
|
3072
|
+
const resolution = await this.delegationService.resolveAssignee(principalMemberId, context);
|
|
3073
|
+
const memberIds = gradeGated
|
|
3074
|
+
? readGradeGateMemberIds(principalMemberId, resolution)
|
|
3075
|
+
: [];
|
|
3076
|
+
const levels = memberIds.length > 0
|
|
3077
|
+
? await this.readMemberPositionLevels(manager, memberIds, this.readOrganizationToday())
|
|
3078
|
+
: new Map();
|
|
3079
|
+
return applyGradeGate(principalMemberId, resolution, gradeGated, levels);
|
|
3080
|
+
}
|
|
3081
|
+
/**
|
|
3082
|
+
* Manual transfer on a manager-chain node: the target must hold at least the
|
|
3083
|
+
* grade of the member being replaced, i.e. the original principal of the
|
|
3084
|
+
* deciding candidate (the first hop of its delegation chain when it was
|
|
3085
|
+
* delegated, otherwise the actor). No grade on the principal means no
|
|
3086
|
+
* constraint.
|
|
3087
|
+
*/
|
|
3088
|
+
async assertTransferTargetGrade(manager, { actorMemberId, delegationChain, label, targetMemberId, }) {
|
|
3089
|
+
const principalMemberId = readDelegationSteps(delegationChain)[0]?.from ?? actorMemberId;
|
|
3090
|
+
const date = this.readOrganizationToday();
|
|
3091
|
+
const requiredLevel = await this.readMemberPositionLevel(manager, principalMemberId, date);
|
|
3092
|
+
if (requiredLevel === null) {
|
|
3093
|
+
return;
|
|
3094
|
+
}
|
|
3095
|
+
const targetLevel = await this.readMemberPositionLevel(manager, targetMemberId, date);
|
|
3096
|
+
if (targetLevel === null || targetLevel < requiredLevel) {
|
|
3097
|
+
throw new common_1.ForbiddenException(`簽核節點「${label}」需由職等 ${requiredLevel} 以上的成員簽核,不能轉簽給職等不足的成員`);
|
|
3098
|
+
}
|
|
2867
3099
|
}
|
|
2868
|
-
async resolveApproverResolver(manager, instance, resolver, label) {
|
|
3100
|
+
async resolveApproverResolver(manager, instance, resolver, label, subject = '簽核人', skippedChainMemberIds = []) {
|
|
2869
3101
|
if (resolver.type === 'DIRECT') {
|
|
2870
|
-
return readMemberIdsFromValue(resolver.memberIds, label).map((memberId) => ({ memberId, sourceType: resolver.type }));
|
|
3102
|
+
return readMemberIdsFromValue(resolver.memberIds, label, subject).map((memberId) => ({ memberId, sourceType: resolver.type }));
|
|
2871
3103
|
}
|
|
2872
3104
|
if (resolver.type === 'DYNAMIC_FORM') {
|
|
2873
|
-
return readMemberIdsFromValue(readValueAtPath(instance.formData, resolver.formPath),
|
|
3105
|
+
return readMemberIdsFromValue(readValueAtPath(instance.formData, resolver.formPath), label, subject, `依表單欄位「${resolver.formPath}」`).map((memberId) => ({ memberId, sourceType: resolver.type }));
|
|
2874
3106
|
}
|
|
2875
3107
|
if (resolver.type === 'EXPRESSION') {
|
|
2876
|
-
return readMemberIdsFromValue(this.conditionService.evaluateValue(resolver.expression, buildWorkflowExpressionContext(instance), `${label}.expression`),
|
|
3108
|
+
return readMemberIdsFromValue(this.conditionService.evaluateValue(resolver.expression, buildWorkflowExpressionContext(instance), `${label}.expression`), label, subject, '依運算式').map((memberId) => ({ memberId, sourceType: resolver.type }));
|
|
2877
3109
|
}
|
|
2878
3110
|
if (resolver.type === 'POSITION') {
|
|
2879
|
-
return this.resolvePositionCandidates(manager, resolver.positionId, label).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
3111
|
+
return this.resolvePositionCandidates(manager, resolver.positionId, label, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
2880
3112
|
}
|
|
2881
3113
|
if (resolver.type === 'ORG_UNIT_MEMBER') {
|
|
2882
|
-
return this.resolveOrgUnitMemberCandidates(manager, resolver.orgUnitId, Boolean(resolver.includeDescendants), label).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
3114
|
+
return this.resolveOrgUnitMemberCandidates(manager, resolver.orgUnitId, Boolean(resolver.includeDescendants), label, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
2883
3115
|
}
|
|
2884
3116
|
if (resolver.type === 'ORG_UNIT_POSITION') {
|
|
2885
|
-
return this.resolveOrgUnitPositionCandidates(manager, resolver.orgUnitId, resolver.positionId, Boolean(resolver.includeDescendants), label).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
3117
|
+
return this.resolveOrgUnitPositionCandidates(manager, resolver.orgUnitId, resolver.positionId, Boolean(resolver.includeDescendants), label, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
2886
3118
|
}
|
|
2887
3119
|
if (resolver.type === 'ORG_UNIT_MANAGER') {
|
|
2888
|
-
return this.resolveOrgUnitManagerCandidates(manager, instance, resolver.orgUnitId, resolver.fallback, label, resolver.preferClosestOrgUnit === true).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
3120
|
+
return this.resolveOrgUnitManagerCandidates(manager, instance, resolver.orgUnitId, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
2889
3121
|
}
|
|
2890
3122
|
if (resolver.type === 'ORG_MANAGER_CHAIN') {
|
|
2891
|
-
return this.resolveOrgManagerChainCandidates(manager, instance, resolver.minPositionLevel, resolver.fallback, label).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
3123
|
+
return this.resolveOrgManagerChainCandidates(manager, instance, resolver.minPositionLevel, resolver.fallback, label, subject, skippedChainMemberIds).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
2892
3124
|
}
|
|
2893
|
-
return this.resolveInitiatorManagerCandidates(manager, instance, resolver.levelsUp, resolver.fallback, label, resolver.preferClosestOrgUnit === true).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
3125
|
+
return this.resolveInitiatorManagerCandidates(manager, instance, resolver.levelsUp, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
2894
3126
|
}
|
|
2895
|
-
async resolvePositionCandidates(manager, positionId, label) {
|
|
2896
|
-
const today =
|
|
3127
|
+
async resolvePositionCandidates(manager, positionId, label, subject) {
|
|
3128
|
+
const today = this.readOrganizationToday();
|
|
2897
3129
|
const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
|
|
2898
3130
|
where: { positionId },
|
|
2899
3131
|
});
|
|
@@ -2907,27 +3139,28 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2907
3139
|
return second.effectiveFrom.localeCompare(first.effectiveFrom);
|
|
2908
3140
|
});
|
|
2909
3141
|
if (!activeMemberships.length) {
|
|
2910
|
-
throw new common_1.ConflictException(`${label}
|
|
3142
|
+
throw new common_1.ConflictException(`${label} 找不到${subject}:職位「${await this.readPositionName(manager, positionId)}」目前沒有任何有效成員`);
|
|
2911
3143
|
}
|
|
2912
3144
|
return uniqueTexts(activeMemberships.map((membership) => membership.memberId));
|
|
2913
3145
|
}
|
|
2914
|
-
async resolveInitiatorManagerCandidates(manager, instance, levelsUp, fallback, label, preferClosestOrgUnit) {
|
|
3146
|
+
async resolveInitiatorManagerCandidates(manager, instance, levelsUp, fallback, label, preferClosestOrgUnit, subject) {
|
|
2915
3147
|
const normalizedLevelsUp = Math.max(Math.trunc(levelsUp), 1);
|
|
2916
3148
|
const resolvedManagerMemberIds = await this.resolveManagerMemberIdsFromOrganization(manager, instance.initiatorMemberId, normalizedLevelsUp, preferClosestOrgUnit);
|
|
2917
3149
|
if (resolvedManagerMemberIds.length) {
|
|
2918
3150
|
return resolvedManagerMemberIds;
|
|
2919
3151
|
}
|
|
2920
3152
|
return [
|
|
2921
|
-
this.resolveFallbackAssignee(instance, fallback, label, `找不到發起人的第 ${normalizedLevelsUp} 層主管`),
|
|
3153
|
+
this.resolveFallbackAssignee(instance, fallback, label, subject, `找不到發起人的第 ${normalizedLevelsUp} 層主管`),
|
|
2922
3154
|
];
|
|
2923
3155
|
}
|
|
2924
3156
|
/**
|
|
2925
3157
|
* Walks up the initiator's manager chain, nearest manager first, until a
|
|
2926
3158
|
* manager whose position level reaches `minPositionLevel` is added. The
|
|
2927
|
-
* list is returned nearest manager first.
|
|
3159
|
+
* list is returned nearest manager first. Members in `skippedMemberIds` are
|
|
3160
|
+
* walked past without joining the chain or counting as the stopping point.
|
|
2928
3161
|
*/
|
|
2929
|
-
async resolveOrgManagerChainCandidates(manager, instance, minPositionLevel, fallback, label) {
|
|
2930
|
-
const date =
|
|
3162
|
+
async resolveOrgManagerChainCandidates(manager, instance, minPositionLevel, fallback, label, subject, skippedMemberIds) {
|
|
3163
|
+
const date = this.readOrganizationToday();
|
|
2931
3164
|
const visitedMemberIds = new Set([instance.initiatorMemberId]);
|
|
2932
3165
|
const chain = [];
|
|
2933
3166
|
let currentMemberId = instance.initiatorMemberId;
|
|
@@ -2938,7 +3171,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2938
3171
|
// excluded from his own candidates: a unit head belongs to the unit he
|
|
2939
3172
|
// heads, so without this his unit rule would name himself and the walk
|
|
2940
3173
|
// could never climb past him.
|
|
2941
|
-
const directManagerMemberIds = await this.resolveDirectManagerMemberIds(manager, currentMemberId, date, true, currentMemberId);
|
|
3174
|
+
const directManagerMemberIds = await this.resolveDirectManagerMemberIds(manager, currentMemberId, date, true, [currentMemberId]);
|
|
2942
3175
|
if (directManagerMemberIds.length === 0) {
|
|
2943
3176
|
break;
|
|
2944
3177
|
}
|
|
@@ -2952,6 +3185,12 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2952
3185
|
}
|
|
2953
3186
|
visitedMemberIds.add(managerMemberId);
|
|
2954
3187
|
currentMemberId = managerMemberId;
|
|
3188
|
+
// A member the node keeps out (the person who filed the document) is
|
|
3189
|
+
// climbed through, not stopped at: filtering him after the walk would
|
|
3190
|
+
// lose the chain when he is the manager that reaches the level.
|
|
3191
|
+
if (skippedMemberIds.includes(managerMemberId)) {
|
|
3192
|
+
continue;
|
|
3193
|
+
}
|
|
2955
3194
|
const level = await this.readMemberPositionLevel(manager, managerMemberId, date);
|
|
2956
3195
|
if (level === null ||
|
|
2957
3196
|
(lastAddedLevel !== null && level <= lastAddedLevel)) {
|
|
@@ -2963,45 +3202,60 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2963
3202
|
return chain;
|
|
2964
3203
|
}
|
|
2965
3204
|
}
|
|
2966
|
-
const fallbackMemberId = this.resolveFallbackAssignee(instance, fallback, label, `沿主管鏈找不到職等達 ${minPositionLevel} 的主管`);
|
|
3205
|
+
const fallbackMemberId = this.resolveFallbackAssignee(instance, fallback, label, subject, `沿主管鏈找不到職等達 ${minPositionLevel} 的主管`);
|
|
2967
3206
|
return chain.includes(fallbackMemberId)
|
|
2968
3207
|
? chain
|
|
2969
3208
|
: [...chain, fallbackMemberId];
|
|
2970
3209
|
}
|
|
2971
3210
|
/** Highest position level over the member's active memberships, or `null`. */
|
|
2972
3211
|
async readMemberPositionLevel(manager, memberId, date) {
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
3212
|
+
return ((await this.readMemberPositionLevels(manager, [memberId], date)).get(memberId) ?? null);
|
|
3213
|
+
}
|
|
3214
|
+
/**
|
|
3215
|
+
* {@link readMemberPositionLevel} for several members in two queries: every
|
|
3216
|
+
* requested member is in the map, `null` when they hold no graded position.
|
|
3217
|
+
*/
|
|
3218
|
+
async readMemberPositionLevels(manager, memberIds, date) {
|
|
3219
|
+
const uniqueMemberIds = uniqueTexts(memberIds);
|
|
3220
|
+
if (uniqueMemberIds.length === 0) {
|
|
3221
|
+
return new Map();
|
|
3222
|
+
}
|
|
3223
|
+
const memberships = (await manager.getRepository(membership_entity_1.MembershipEntity).find({
|
|
3224
|
+
where: { memberId: (0, typeorm_2.In)([...uniqueMemberIds]) },
|
|
3225
|
+
})).filter((membership) => Boolean(membership.positionId) && isDateRangeActive(membership, date));
|
|
3226
|
+
const positionIds = uniqueTexts(memberships.map((membership) => membership.positionId ?? ''));
|
|
3227
|
+
const positions = positionIds.length === 0
|
|
3228
|
+
? []
|
|
3229
|
+
: await manager.getRepository(position_entity_1.PositionEntity).find({
|
|
3230
|
+
where: { id: (0, typeorm_2.In)([...positionIds]) },
|
|
3231
|
+
});
|
|
3232
|
+
const levelByPositionId = new Map(positions.map((position) => [position.id, position.level]));
|
|
3233
|
+
return new Map(uniqueMemberIds.map((memberId) => {
|
|
3234
|
+
const levels = memberships
|
|
3235
|
+
.filter((membership) => membership.memberId === memberId)
|
|
3236
|
+
.flatMap((membership) => {
|
|
3237
|
+
const level = levelByPositionId.get(membership.positionId ?? '');
|
|
3238
|
+
return level === undefined ? [] : [level];
|
|
3239
|
+
});
|
|
3240
|
+
return [memberId, levels.length === 0 ? null : Math.max(...levels)];
|
|
3241
|
+
}));
|
|
2989
3242
|
}
|
|
2990
|
-
resolveFallbackAssignee(instance, fallback, label, failureReason) {
|
|
3243
|
+
resolveFallbackAssignee(instance, fallback, label, subject, failureReason) {
|
|
3244
|
+
const failure = subject === '通知對象' ? '無法建立通知' : '無法建立待簽任務';
|
|
2991
3245
|
if (!fallback || fallback.type === 'NONE') {
|
|
2992
|
-
throw new common_1.ConflictException(`${label}
|
|
3246
|
+
throw new common_1.ConflictException(`${label} ${failure}:${failureReason},且未設定改派固定人。請聯絡流程管理員調整主管規則或模板 fallback。`);
|
|
2993
3247
|
}
|
|
2994
3248
|
if (!fallback.memberId.trim()) {
|
|
2995
|
-
throw new common_1.ConflictException(`${label}
|
|
3249
|
+
throw new common_1.ConflictException(`${label} ${failure}:改派固定人未設定。`);
|
|
2996
3250
|
}
|
|
2997
3251
|
if (fallback.memberId === instance.initiatorMemberId &&
|
|
2998
3252
|
!fallback.allowInitiatorSelfApproval) {
|
|
2999
|
-
throw new common_1.ConflictException(`${label}
|
|
3253
|
+
throw new common_1.ConflictException(`${label} ${failure}:改派固定人是申請人本人,且此節點未允許申請人自簽。`);
|
|
3000
3254
|
}
|
|
3001
3255
|
return fallback.memberId;
|
|
3002
3256
|
}
|
|
3003
3257
|
async resolveManagerMemberIdsFromOrganization(manager, memberId, levelsUp, preferClosestOrgUnit) {
|
|
3004
|
-
return this.resolveManagerMemberIdsAtLevel(manager, memberId,
|
|
3258
|
+
return this.resolveManagerMemberIdsAtLevel(manager, memberId, this.readOrganizationToday(), levelsUp, preferClosestOrgUnit);
|
|
3005
3259
|
}
|
|
3006
3260
|
async resolveManagerMemberIdsAtLevel(manager, memberId, date, remainingLevels, preferClosestOrgUnit) {
|
|
3007
3261
|
const directManagerMemberIds = await this.resolveDirectManagerMemberIds(manager, memberId, date, preferClosestOrgUnit);
|
|
@@ -3011,7 +3265,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3011
3265
|
const nextLevelGroups = await Promise.all(directManagerMemberIds.map((managerMemberId) => this.resolveManagerMemberIdsAtLevel(manager, managerMemberId, date, remainingLevels - 1, preferClosestOrgUnit)));
|
|
3012
3266
|
return uniqueTexts(nextLevelGroups.flat());
|
|
3013
3267
|
}
|
|
3014
|
-
async resolveDirectManagerMemberIds(manager, memberId, date, preferClosestOrgUnit,
|
|
3268
|
+
async resolveDirectManagerMemberIds(manager, memberId, date, preferClosestOrgUnit, excludedManagerMemberIds = []) {
|
|
3015
3269
|
const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
|
|
3016
3270
|
where: { memberId },
|
|
3017
3271
|
});
|
|
@@ -3032,10 +3286,10 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3032
3286
|
scopeType: organization_enums_1.ManagerResolutionScopeTypeEnum.POSITION,
|
|
3033
3287
|
})),
|
|
3034
3288
|
];
|
|
3035
|
-
return this.resolveManagerResolutionCandidates(manager, candidatePairs, date, readOrgUnitDepthMap(orgUnits), preferClosestOrgUnit,
|
|
3289
|
+
return this.resolveManagerResolutionCandidates(manager, candidatePairs, date, readOrgUnitDepthMap(orgUnits), preferClosestOrgUnit, excludedManagerMemberIds);
|
|
3036
3290
|
}
|
|
3037
|
-
async resolveOrgUnitManagerCandidates(manager, instance, orgUnitId, fallback, label, preferClosestOrgUnit) {
|
|
3038
|
-
const date =
|
|
3291
|
+
async resolveOrgUnitManagerCandidates(manager, instance, orgUnitId, fallback, label, preferClosestOrgUnit, subject) {
|
|
3292
|
+
const date = this.readOrganizationToday();
|
|
3039
3293
|
const orgUnits = await this.readOrgUnitAndAncestors(manager, [orgUnitId]);
|
|
3040
3294
|
const managerMemberIds = await this.resolveManagerResolutionCandidates(manager, orgUnits.map((orgUnit) => ({
|
|
3041
3295
|
scopeId: orgUnit.id,
|
|
@@ -3043,7 +3297,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3043
3297
|
})), date, readOrgUnitDepthMap(orgUnits), preferClosestOrgUnit);
|
|
3044
3298
|
if (!managerMemberIds.length) {
|
|
3045
3299
|
return [
|
|
3046
|
-
this.resolveFallbackAssignee(instance, fallback, label, `找不到指定組織的主管規則`),
|
|
3300
|
+
this.resolveFallbackAssignee(instance, fallback, label, subject, `找不到指定組織的主管規則`),
|
|
3047
3301
|
];
|
|
3048
3302
|
}
|
|
3049
3303
|
return managerMemberIds;
|
|
@@ -3064,7 +3318,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3064
3318
|
const byId = new Map([...directOrgUnits, ...ancestorGroups.flat()].map((orgUnit) => [orgUnit.id, orgUnit]));
|
|
3065
3319
|
return [...byId.values()];
|
|
3066
3320
|
}
|
|
3067
|
-
async resolveManagerResolutionCandidates(manager, candidatePairs, date, orgUnitDepths, preferClosestOrgUnit,
|
|
3321
|
+
async resolveManagerResolutionCandidates(manager, candidatePairs, date, orgUnitDepths, preferClosestOrgUnit, excludedManagerMemberIds = []) {
|
|
3068
3322
|
const scopeIds = candidatePairs.map((pair) => pair.scopeId);
|
|
3069
3323
|
if (!scopeIds.length) {
|
|
3070
3324
|
return [];
|
|
@@ -3088,58 +3342,78 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3088
3342
|
.filter((resolution) => isDateRangeActive(resolution, date))
|
|
3089
3343
|
// Dropped before the top-priority tier is chosen, so resolution falls
|
|
3090
3344
|
// through to the next tier instead of ending on the excluded member.
|
|
3091
|
-
.filter((resolution) =>
|
|
3092
|
-
resolution.managerMemberId !== excludedManagerMemberId)
|
|
3345
|
+
.filter((resolution) => !excludedManagerMemberIds.includes(resolution.managerMemberId))
|
|
3093
3346
|
.sort((left, right) => compareManagerResolution(left, right, depthLookup));
|
|
3094
3347
|
return uniqueTexts(readTopPriorityResolutions(active, depthLookup).map((resolution) => resolution.managerMemberId));
|
|
3095
3348
|
}
|
|
3096
|
-
async resolveOrgUnitMemberCandidates(manager, orgUnitId, includeDescendants, label) {
|
|
3097
|
-
const
|
|
3098
|
-
|
|
3349
|
+
async resolveOrgUnitMemberCandidates(manager, orgUnitId, includeDescendants, label, subject) {
|
|
3350
|
+
const scope = await this.readOrgUnitScope(manager, orgUnitId, includeDescendants);
|
|
3351
|
+
if (!scope) {
|
|
3352
|
+
throw new common_1.ConflictException(readMissingOrgUnitMessage(label, subject, orgUnitId));
|
|
3353
|
+
}
|
|
3354
|
+
const today = this.readOrganizationToday();
|
|
3099
3355
|
const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
|
|
3100
|
-
where: { orgUnitId: (0, typeorm_2.In)([...orgUnitIds]) },
|
|
3356
|
+
where: { orgUnitId: (0, typeorm_2.In)([...scope.orgUnitIds]) },
|
|
3101
3357
|
});
|
|
3102
3358
|
const memberIds = memberships
|
|
3103
3359
|
.filter((membership) => isDateRangeActive(membership, today))
|
|
3104
3360
|
.sort(compareMembership)
|
|
3105
3361
|
.map((membership) => membership.memberId);
|
|
3106
3362
|
if (memberIds.length === 0) {
|
|
3107
|
-
throw new common_1.ConflictException(`${label}
|
|
3363
|
+
throw new common_1.ConflictException(`${label} 找不到${subject}:組織「${scope.name}」目前沒有任何有效成員,請先將人員加入該組織`);
|
|
3108
3364
|
}
|
|
3109
3365
|
return uniqueTexts(memberIds);
|
|
3110
3366
|
}
|
|
3111
|
-
async resolveOrgUnitPositionCandidates(manager, orgUnitId, positionId, includeDescendants, label) {
|
|
3112
|
-
const
|
|
3113
|
-
|
|
3367
|
+
async resolveOrgUnitPositionCandidates(manager, orgUnitId, positionId, includeDescendants, label, subject) {
|
|
3368
|
+
const scope = await this.readOrgUnitScope(manager, orgUnitId, includeDescendants);
|
|
3369
|
+
if (!scope) {
|
|
3370
|
+
throw new common_1.ConflictException(readMissingOrgUnitMessage(label, subject, orgUnitId));
|
|
3371
|
+
}
|
|
3372
|
+
const today = this.readOrganizationToday();
|
|
3114
3373
|
const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
|
|
3115
|
-
where: { orgUnitId: (0, typeorm_2.In)([...orgUnitIds]), positionId },
|
|
3374
|
+
where: { orgUnitId: (0, typeorm_2.In)([...scope.orgUnitIds]), positionId },
|
|
3116
3375
|
});
|
|
3117
3376
|
const memberIds = memberships
|
|
3118
3377
|
.filter((membership) => isDateRangeActive(membership, today))
|
|
3119
3378
|
.sort(compareMembership)
|
|
3120
3379
|
.map((membership) => membership.memberId);
|
|
3121
3380
|
if (memberIds.length === 0) {
|
|
3122
|
-
throw new common_1.ConflictException(`${label}
|
|
3381
|
+
throw new common_1.ConflictException(`${label} 找不到${subject}:組織「${scope.name}」的職位「${await this.readPositionName(manager, positionId)}」目前沒有任何有效成員`);
|
|
3123
3382
|
}
|
|
3124
3383
|
return uniqueTexts(memberIds);
|
|
3125
3384
|
}
|
|
3126
|
-
|
|
3385
|
+
// Names only make an error readable, so a row that has gone missing falls
|
|
3386
|
+
// back to its id instead of hiding the real problem.
|
|
3387
|
+
async readPositionName(manager, positionId) {
|
|
3388
|
+
const position = await manager
|
|
3389
|
+
.getRepository(position_entity_1.PositionEntity)
|
|
3390
|
+
.findOne({ where: { id: positionId } });
|
|
3391
|
+
return position?.name ?? positionId;
|
|
3392
|
+
}
|
|
3393
|
+
/** The org unit's name and the ids it covers, or `null` once it is gone. */
|
|
3394
|
+
async readOrgUnitScope(manager, orgUnitId, includeDescendants) {
|
|
3127
3395
|
const orgUnitRepository = manager.getRepository(org_unit_entity_1.OrgUnitEntity);
|
|
3128
3396
|
const orgUnit = await orgUnitRepository.findOne({
|
|
3129
3397
|
where: { deletedAt: (0, typeorm_2.IsNull)(), id: orgUnitId },
|
|
3130
3398
|
});
|
|
3131
3399
|
if (!orgUnit) {
|
|
3132
|
-
|
|
3400
|
+
return null;
|
|
3133
3401
|
}
|
|
3134
3402
|
if (!includeDescendants) {
|
|
3135
|
-
return [orgUnit.id];
|
|
3403
|
+
return { name: orgUnit.name, orgUnitIds: [orgUnit.id] };
|
|
3136
3404
|
}
|
|
3137
3405
|
const descendants = await orgUnitRepository
|
|
3138
3406
|
.createQueryBuilder('orgUnit')
|
|
3139
3407
|
.where('orgUnit.deleted_at IS NULL')
|
|
3140
3408
|
.andWhere('orgUnit.path <@ :path', { path: orgUnit.path })
|
|
3141
3409
|
.getMany();
|
|
3142
|
-
return
|
|
3410
|
+
return {
|
|
3411
|
+
name: orgUnit.name,
|
|
3412
|
+
orgUnitIds: uniqueTexts([
|
|
3413
|
+
orgUnit.id,
|
|
3414
|
+
...descendants.map((unit) => unit.id),
|
|
3415
|
+
]),
|
|
3416
|
+
};
|
|
3143
3417
|
}
|
|
3144
3418
|
async processExclusiveGateway(manager, instance, token, node) {
|
|
3145
3419
|
if (node.data.direction !== 'split') {
|
|
@@ -3221,7 +3495,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3221
3495
|
const recipients = (0, workflow_graph_1.isNotifyRecipientsEmpty)(action.recipients) &&
|
|
3222
3496
|
(0, workflow_graph_1.readNotifyWebhookTargets)(action).length > 0
|
|
3223
3497
|
? []
|
|
3224
|
-
: await this.resolveApproverResolver(manager, instance, action.recipients, `知會節點「${node.data.label}
|
|
3498
|
+
: await this.resolveApproverResolver(manager, instance, action.recipients, `知會節點「${node.data.label}」`, '通知對象');
|
|
3225
3499
|
const recipientMemberIds = uniqueTexts(recipients.map((recipient) => recipient.memberId));
|
|
3226
3500
|
await this.notificationService.createServiceTaskNotifications({
|
|
3227
3501
|
instance,
|
|
@@ -3249,11 +3523,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3249
3523
|
tokenId: token.id,
|
|
3250
3524
|
}, action.webhooks)
|
|
3251
3525
|
: [];
|
|
3252
|
-
await
|
|
3253
|
-
...token,
|
|
3254
|
-
consumedAt: new Date(),
|
|
3255
|
-
status: workflow_engine_enums_1.WorkflowTokenStatusEnum.CONSUMED,
|
|
3256
|
-
});
|
|
3526
|
+
await this.consumeToken(manager, token);
|
|
3257
3527
|
await activityRepository.save(activityRepository.create({
|
|
3258
3528
|
actorMemberId: null,
|
|
3259
3529
|
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TOKEN_ADVANCED,
|
|
@@ -3780,6 +4050,58 @@ function buildDryRunExpressionContext(input, lastDecision) {
|
|
|
3780
4050
|
function isRecord(value) {
|
|
3781
4051
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3782
4052
|
}
|
|
4053
|
+
// The members whose grades decide a grade-gated delegation: none when the
|
|
4054
|
+
// delegation leaves the seat with its principal.
|
|
4055
|
+
function readGradeGateMemberIds(principalMemberId, resolution) {
|
|
4056
|
+
return resolution.finalAssigneeMemberId === principalMemberId
|
|
4057
|
+
? []
|
|
4058
|
+
: [principalMemberId, resolution.finalAssigneeMemberId];
|
|
4059
|
+
}
|
|
4060
|
+
/**
|
|
4061
|
+
* On a manager-chain node the delegate must hold at least the grade of the
|
|
4062
|
+
* member they replace: anything lower is not applied, the member stays and
|
|
4063
|
+
* the refused delegation is returned so it can be recorded. Resolution does
|
|
4064
|
+
* not walk further down the delegation chain once it has been refused. A
|
|
4065
|
+
* member without a grade (or a delegate that stays the member) is never
|
|
4066
|
+
* constrained. `levels` holds the grades of `readGradeGateMemberIds`.
|
|
4067
|
+
*/
|
|
4068
|
+
function applyGradeGate(principalMemberId, resolution, gradeGated, levels) {
|
|
4069
|
+
if (!gradeGated || resolution.finalAssigneeMemberId === principalMemberId) {
|
|
4070
|
+
return resolution;
|
|
4071
|
+
}
|
|
4072
|
+
const principalLevel = levels.get(principalMemberId) ?? null;
|
|
4073
|
+
if (principalLevel === null) {
|
|
4074
|
+
return resolution;
|
|
4075
|
+
}
|
|
4076
|
+
const agentLevel = levels.get(resolution.finalAssigneeMemberId) ?? null;
|
|
4077
|
+
if (agentLevel !== null && agentLevel >= principalLevel) {
|
|
4078
|
+
return resolution;
|
|
4079
|
+
}
|
|
4080
|
+
return {
|
|
4081
|
+
delegationChain: [],
|
|
4082
|
+
finalAssigneeMemberId: principalMemberId,
|
|
4083
|
+
skippedDelegation: {
|
|
4084
|
+
agentLevel,
|
|
4085
|
+
agentMemberId: resolution.finalAssigneeMemberId,
|
|
4086
|
+
principalLevel,
|
|
4087
|
+
principalMemberId,
|
|
4088
|
+
reason: 'AGENT_POSITION_LEVEL_TOO_LOW',
|
|
4089
|
+
},
|
|
4090
|
+
};
|
|
4091
|
+
}
|
|
4092
|
+
function readNodeLabelKey(instanceId, nodeId) {
|
|
4093
|
+
return `${instanceId}\u0000${nodeId}`;
|
|
4094
|
+
}
|
|
4095
|
+
/**
|
|
4096
|
+
* The node's display name from an instance's workflow snapshot, or `null`
|
|
4097
|
+
* when the instance or node is missing, or the node carries no usable label
|
|
4098
|
+
* (older snapshots).
|
|
4099
|
+
*/
|
|
4100
|
+
function readWorkflowNodeLabel(workflow, nodeId) {
|
|
4101
|
+
const label = workflow?.nodes.find((candidate) => candidate.id === nodeId)
|
|
4102
|
+
?.data.label;
|
|
4103
|
+
return typeof label === 'string' && label.trim() ? label : null;
|
|
4104
|
+
}
|
|
3783
4105
|
function readWorkflowNodeOrThrow(workflow, nodeId) {
|
|
3784
4106
|
const node = workflow.nodes.find((candidate) => candidate.id === nodeId);
|
|
3785
4107
|
if (!node) {
|
|
@@ -3880,22 +4202,26 @@ function edgeHasCondition(edge) {
|
|
|
3880
4202
|
return Boolean(edge.data.condition?.trim() ||
|
|
3881
4203
|
(edge.data.conditionFieldKey && edge.data.conditionOperator));
|
|
3882
4204
|
}
|
|
3883
|
-
function
|
|
3884
|
-
return
|
|
4205
|
+
function readMissingOrgUnitMessage(label, subject, orgUnitId) {
|
|
4206
|
+
return `${label} 找不到${subject}:組織(${orgUnitId})已不存在,請在流程設計器重新選擇組織`;
|
|
3885
4207
|
}
|
|
3886
|
-
function
|
|
4208
|
+
function findMemberIdsInValue(value) {
|
|
3887
4209
|
if (typeof value === 'string' && value.trim()) {
|
|
3888
4210
|
return [value.trim()];
|
|
3889
4211
|
}
|
|
3890
4212
|
if (Array.isArray(value)) {
|
|
3891
|
-
|
|
4213
|
+
return uniqueTexts(value
|
|
3892
4214
|
.filter((candidate) => typeof candidate === 'string' && Boolean(candidate.trim()))
|
|
3893
|
-
.map((candidate) => candidate.trim());
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
4215
|
+
.map((candidate) => candidate.trim()));
|
|
4216
|
+
}
|
|
4217
|
+
return [];
|
|
4218
|
+
}
|
|
4219
|
+
function readMemberIdsFromValue(value, label, subject = '簽核人', source = '') {
|
|
4220
|
+
const memberIds = findMemberIdsInValue(value);
|
|
4221
|
+
if (memberIds.length === 0) {
|
|
4222
|
+
throw new common_1.ConflictException(`${label} ${source}找不到${subject}`);
|
|
3897
4223
|
}
|
|
3898
|
-
|
|
4224
|
+
return memberIds;
|
|
3899
4225
|
}
|
|
3900
4226
|
function uniqueTexts(values) {
|
|
3901
4227
|
return [...new Set(values.filter((value) => value.trim()))];
|
|
@@ -3921,20 +4247,62 @@ function isOrderedSequentialTask(task, candidates) {
|
|
|
3921
4247
|
function isInitiatorExcluded(node) {
|
|
3922
4248
|
return Boolean(node.data.excludeInitiator);
|
|
3923
4249
|
}
|
|
4250
|
+
// Everyone a node that excludes the initiator keeps out of its approvers: the
|
|
4251
|
+
// applicant and, when someone filed the document for them, that person too
|
|
4252
|
+
// (authority rule: the creator must not approve what they created).
|
|
4253
|
+
function readInitiatorAndSubmitter(instance) {
|
|
4254
|
+
const submitterMemberId = (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance);
|
|
4255
|
+
return submitterMemberId === null
|
|
4256
|
+
? [instance.initiatorMemberId]
|
|
4257
|
+
: [instance.initiatorMemberId, submitterMemberId];
|
|
4258
|
+
}
|
|
4259
|
+
function isOrgManagerChainNode(node) {
|
|
4260
|
+
return node.data.approverResolver.type === 'ORG_MANAGER_CHAIN';
|
|
4261
|
+
}
|
|
4262
|
+
// Candidates of a manager-chain node that came from the chain (or replaced the
|
|
4263
|
+
// excluded initiator's seat); ad-hoc approvers are chosen by a person instead.
|
|
4264
|
+
function isGradeGatedCandidate(node, sourceType) {
|
|
4265
|
+
return (isOrgManagerChainNode(node) &&
|
|
4266
|
+
(sourceType === 'ORG_MANAGER_CHAIN' ||
|
|
4267
|
+
sourceType === 'EXCLUDE_INITIATOR_MANAGER'));
|
|
4268
|
+
}
|
|
4269
|
+
// Source of the additive `skippedDelegations` payload field (absent when
|
|
4270
|
+
// nothing was refused, so existing payloads stay byte-identical). One record
|
|
4271
|
+
// per principal and delegate pair.
|
|
4272
|
+
function collectSkippedDelegations(delegatedCandidates, excludeInitiator, excludedMemberIds) {
|
|
4273
|
+
const seen = new Set();
|
|
4274
|
+
return delegatedCandidates.flatMap((candidate) => {
|
|
4275
|
+
const skipped = candidate.skippedDelegation;
|
|
4276
|
+
// A seat the initiator filter removes is not on the task, so a refusal on
|
|
4277
|
+
// it has nothing to report. Rows only the dedupe drops are still on it.
|
|
4278
|
+
if (!skipped ||
|
|
4279
|
+
(excludeInitiator &&
|
|
4280
|
+
isExcludedMemberOnCandidatePath(candidate, excludedMemberIds))) {
|
|
4281
|
+
return [];
|
|
4282
|
+
}
|
|
4283
|
+
const key = `${skipped.principalMemberId}\u0000${skipped.agentMemberId}`;
|
|
4284
|
+
if (seen.has(key)) {
|
|
4285
|
+
return [];
|
|
4286
|
+
}
|
|
4287
|
+
seen.add(key);
|
|
4288
|
+
return [skipped];
|
|
4289
|
+
});
|
|
4290
|
+
}
|
|
3924
4291
|
// A delegate acting for the initiator is still the initiator approving their
|
|
3925
4292
|
// own document, so the initiator anywhere on the delegation path (the
|
|
3926
4293
|
// pre-delegation member, any hop, or the final assignee) drops the candidate.
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
candidate.
|
|
4294
|
+
// The submitter is held to exactly the same rule.
|
|
4295
|
+
function isExcludedMemberOnCandidatePath(candidate, excludedMemberIds) {
|
|
4296
|
+
return excludedMemberIds.some((excludedMemberId) => candidate.memberId === excludedMemberId ||
|
|
4297
|
+
candidate.originalMemberId === excludedMemberId ||
|
|
4298
|
+
candidate.delegationChain.some((step) => step.from === excludedMemberId || step.to === excludedMemberId));
|
|
3931
4299
|
}
|
|
3932
4300
|
// The initiator filter runs on the delegated list before the dedupe, so a
|
|
3933
4301
|
// legitimate approver is never shadowed by an excluded entry for the same
|
|
3934
4302
|
// final assignee. Without the exclusion this is the plain dedupe.
|
|
3935
|
-
function selectNodeCandidates(delegatedCandidates, excludeInitiator,
|
|
4303
|
+
function selectNodeCandidates(delegatedCandidates, excludeInitiator, excludedMemberIds) {
|
|
3936
4304
|
return uniqueRuntimeCandidates(excludeInitiator
|
|
3937
|
-
? delegatedCandidates.filter((candidate) => !
|
|
4305
|
+
? delegatedCandidates.filter((candidate) => !isExcludedMemberOnCandidatePath(candidate, excludedMemberIds))
|
|
3938
4306
|
: delegatedCandidates);
|
|
3939
4307
|
}
|
|
3940
4308
|
// A COUNT threshold above the people left would never be reached once the
|
|
@@ -4363,9 +4731,6 @@ function compareMembership(left, right) {
|
|
|
4363
4731
|
}
|
|
4364
4732
|
return right.effectiveFrom.localeCompare(left.effectiveFrom);
|
|
4365
4733
|
}
|
|
4366
|
-
function toDateOnlyString(value) {
|
|
4367
|
-
return value.toISOString().slice(0, 10);
|
|
4368
|
-
}
|
|
4369
4734
|
function canReadAllWorkflows(scope) {
|
|
4370
4735
|
return (scope.roles.includes('BPM_ADMIN') ||
|
|
4371
4736
|
scope.permissions.some((permission) => WORKFLOW_READ_ALL_PERMISSIONS.has(permission)));
|
|
@@ -4396,9 +4761,26 @@ function validateSubmittedFormData(schema, formData) {
|
|
|
4396
4761
|
.map((field) => field.label || field.fieldKey)
|
|
4397
4762
|
.join(', ')}`);
|
|
4398
4763
|
}
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4764
|
+
// A `DYNAMIC_FORM` resolver reads a member value whether or not its field is
|
|
4765
|
+
// visible, so a hidden field still holding a value must be well-formed too.
|
|
4766
|
+
// Whether a hidden field is *required* stays a visibility question above.
|
|
4767
|
+
const memberErrors = (0, form_member_value_validator_1.readSubmittedMemberValueErrors)(schema.fields.filter((field) => visibleFields.includes(field) ||
|
|
4768
|
+
isSubmittedFieldValuePresent(formData[field.fieldKey])), formData);
|
|
4769
|
+
if (memberErrors.length > 0) {
|
|
4770
|
+
throw new common_1.BadRequestException(memberErrors.join('; '));
|
|
4771
|
+
}
|
|
4772
|
+
const tableErrors = [
|
|
4773
|
+
...visibleFields
|
|
4774
|
+
.filter(form_1.isTableFieldDefinition)
|
|
4775
|
+
.flatMap((field) => validateSubmittedTableValue(field, formData[field.fieldKey], isSubmittedFormFieldRequired(field, schema.fields, formData))),
|
|
4776
|
+
// A hidden table is not "visible", but its stored value still has to be a
|
|
4777
|
+
// well-formed table under the row cap: only the required / minimum checks
|
|
4778
|
+
// are skipped, since nobody can fill the table in.
|
|
4779
|
+
...schema.fields
|
|
4780
|
+
.filter(form_1.isTableFieldDefinition)
|
|
4781
|
+
.filter((field) => field.hidden === true)
|
|
4782
|
+
.flatMap((field) => validateSubmittedTableValue(field, formData[field.fieldKey], false, true)),
|
|
4783
|
+
];
|
|
4402
4784
|
if (tableErrors.length > 0) {
|
|
4403
4785
|
throw new common_1.BadRequestException(tableErrors.join('; '));
|
|
4404
4786
|
}
|
|
@@ -4409,28 +4791,32 @@ function validateSubmittedFormData(schema, formData) {
|
|
|
4409
4791
|
* required / shape / row count are enforced server side; `minimum`, `maxLength`
|
|
4410
4792
|
* and friends stay a frontend hint.
|
|
4411
4793
|
*/
|
|
4412
|
-
function validateSubmittedTableValue(field, value, required) {
|
|
4794
|
+
function validateSubmittedTableValue(field, value, required, shapeOnly = false) {
|
|
4413
4795
|
const path = `formData.${field.fieldKey}`;
|
|
4414
4796
|
if (typeof value === 'undefined' || value === null) {
|
|
4415
|
-
return
|
|
4416
|
-
? [
|
|
4417
|
-
:
|
|
4797
|
+
return shapeOnly
|
|
4798
|
+
? []
|
|
4799
|
+
: required || readTableMinRows(field) > 0
|
|
4800
|
+
? [`${path} must include at least ${readTableRequiredRowCount(field)} row(s)`]
|
|
4801
|
+
: [];
|
|
4418
4802
|
}
|
|
4419
4803
|
if (!Array.isArray(value)) {
|
|
4420
4804
|
return [`${path} must be an array of rows`];
|
|
4421
4805
|
}
|
|
4422
|
-
const rowErrors = value.flatMap((row, index) => validateSubmittedTableRow(field, row, `${path}[${index}]
|
|
4806
|
+
const rowErrors = value.flatMap((row, index) => validateSubmittedTableRow(field, row, `${path}[${index}]`, shapeOnly));
|
|
4423
4807
|
// A malformed row cannot be counted against min/max meaningfully; report the
|
|
4424
4808
|
// shape problem first so the submitter fixes the real cause.
|
|
4425
4809
|
if (rowErrors.length > 0) {
|
|
4426
4810
|
return rowErrors;
|
|
4427
4811
|
}
|
|
4428
|
-
return validateSubmittedTableRowCount(field, value.length, required, path);
|
|
4812
|
+
return validateSubmittedTableRowCount(field, value.length, required, path, shapeOnly);
|
|
4429
4813
|
}
|
|
4430
|
-
function validateSubmittedTableRowCount(field, rowCount, required, path) {
|
|
4431
|
-
const minRows =
|
|
4432
|
-
?
|
|
4433
|
-
:
|
|
4814
|
+
function validateSubmittedTableRowCount(field, rowCount, required, path, shapeOnly = false) {
|
|
4815
|
+
const minRows = shapeOnly
|
|
4816
|
+
? 0
|
|
4817
|
+
: required
|
|
4818
|
+
? Math.max(readTableMinRows(field), 1)
|
|
4819
|
+
: readTableMinRows(field);
|
|
4434
4820
|
const maxRows = readTableMaxRows(field);
|
|
4435
4821
|
return [
|
|
4436
4822
|
...(rowCount < minRows
|
|
@@ -4441,7 +4827,7 @@ function validateSubmittedTableRowCount(field, rowCount, required, path) {
|
|
|
4441
4827
|
: []),
|
|
4442
4828
|
];
|
|
4443
4829
|
}
|
|
4444
|
-
function validateSubmittedTableRow(field, row, path) {
|
|
4830
|
+
function validateSubmittedTableRow(field, row, path, shapeOnly = false) {
|
|
4445
4831
|
if (!isRecord(row)) {
|
|
4446
4832
|
return [`${path} must be an object`];
|
|
4447
4833
|
}
|
|
@@ -4452,10 +4838,12 @@ function validateSubmittedTableRow(field, row, path) {
|
|
|
4452
4838
|
const cellErrors = Object.entries(row)
|
|
4453
4839
|
.filter(([columnKey, cellValue]) => columnsByKey.has(columnKey) && !(0, form_1.isFormTableCellValue)(cellValue))
|
|
4454
4840
|
.map(([columnKey]) => `${path}.${columnKey} is not a valid cell value`);
|
|
4455
|
-
const requiredErrors =
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4841
|
+
const requiredErrors = shapeOnly
|
|
4842
|
+
? []
|
|
4843
|
+
: field.columns
|
|
4844
|
+
.filter((column) => isSubmittedTableColumnRequired(column) &&
|
|
4845
|
+
!isSubmittedFieldValuePresent(readRowCellValue(row, column.fieldKey)))
|
|
4846
|
+
.map((column) => `${path}.${column.fieldKey} is required`);
|
|
4459
4847
|
return [...unknownKeyErrors, ...cellErrors, ...requiredErrors];
|
|
4460
4848
|
}
|
|
4461
4849
|
/**
|
|
@@ -4500,6 +4888,11 @@ function isFormDefinitionSchema(value) {
|
|
|
4500
4888
|
value.fields.every((field) => isRecord(field)));
|
|
4501
4889
|
}
|
|
4502
4890
|
function isSubmittedFormFieldVisible(field, fields, values) {
|
|
4891
|
+
// A hidden (store-only) field is never shown, so nobody can fill it in:
|
|
4892
|
+
// treat it like a false `visibleWhen`. Its submitted value is still kept.
|
|
4893
|
+
if (field.hidden) {
|
|
4894
|
+
return false;
|
|
4895
|
+
}
|
|
4503
4896
|
return field.visibleWhen
|
|
4504
4897
|
? evaluateFormConditionExpression(field.visibleWhen, fields, values, true)
|
|
4505
4898
|
: true;
|
|
@@ -4627,6 +5020,84 @@ function isSubmittedFieldValuePresent(value) {
|
|
|
4627
5020
|
function isNumberFormField(field) {
|
|
4628
5021
|
return field.type === 'number' || field.type === 'money';
|
|
4629
5022
|
}
|
|
5023
|
+
// A node that is skipped and then has nowhere to go keeps saying why it was
|
|
5024
|
+
// skipped, then why the branch ends; any other node just says why it ends.
|
|
5025
|
+
function readDryRunStepResultMessage(assignmentMessage, stopMessage, defaultMessage, skipped) {
|
|
5026
|
+
const message = assignmentMessage ?? defaultMessage;
|
|
5027
|
+
if (stopMessage === null) {
|
|
5028
|
+
return message;
|
|
5029
|
+
}
|
|
5030
|
+
return skipped ? `${endSentence(message)}${stopMessage}` : stopMessage;
|
|
5031
|
+
}
|
|
5032
|
+
// The skip reason and the stop reason are two sentences; a reason that does not
|
|
5033
|
+
// already end in full-stop punctuation gets one so they do not run together.
|
|
5034
|
+
function endSentence(message) {
|
|
5035
|
+
return /[。!?.!?]$/.test(message) ? message : `${message}。`;
|
|
5036
|
+
}
|
|
5037
|
+
// Why a manager resolver's fixed fallback cannot take over when the initiator
|
|
5038
|
+
// has no manager, or `null` when it can: like the runtime, it must exist, name
|
|
5039
|
+
// a member, and not be the initiator unless the node allows self-approval.
|
|
5040
|
+
function readDryRunFallbackProblem(initiatorMemberId, fallback) {
|
|
5041
|
+
if (!fallback || fallback.type !== 'DIRECT') {
|
|
5042
|
+
return 'NOT_SET';
|
|
5043
|
+
}
|
|
5044
|
+
const memberId = fallback.memberId.trim();
|
|
5045
|
+
if (memberId === '') {
|
|
5046
|
+
return 'BLANK_MEMBER';
|
|
5047
|
+
}
|
|
5048
|
+
return memberId === initiatorMemberId && !fallback.allowInitiatorSelfApproval
|
|
5049
|
+
? 'IS_INITIATOR'
|
|
5050
|
+
: null;
|
|
5051
|
+
}
|
|
5052
|
+
function readDryRunFallbackMemberIds(input, fallback) {
|
|
5053
|
+
return readDryRunFallbackProblem(input.initiatorMemberId, fallback) ===
|
|
5054
|
+
null && fallback?.type === 'DIRECT'
|
|
5055
|
+
? [fallback.memberId.trim()]
|
|
5056
|
+
: [];
|
|
5057
|
+
}
|
|
5058
|
+
function readDryRunNodeName(node) {
|
|
5059
|
+
return node.data.label?.trim() || node.id;
|
|
5060
|
+
}
|
|
5061
|
+
// The dry run cannot look the organisation up, so these resolvers stand in
|
|
5062
|
+
// with a placeholder that names what the runtime would resolve.
|
|
5063
|
+
function readDryRunPlaceholderAssignee(resolver) {
|
|
5064
|
+
if (resolver.type === 'POSITION') {
|
|
5065
|
+
return `position:${resolver.positionId}`;
|
|
5066
|
+
}
|
|
5067
|
+
if (resolver.type === 'ORG_UNIT_MEMBER') {
|
|
5068
|
+
return `orgUnitMember:${resolver.orgUnitId}`;
|
|
5069
|
+
}
|
|
5070
|
+
if (resolver.type === 'ORG_UNIT_POSITION') {
|
|
5071
|
+
return `orgUnitPosition:${resolver.orgUnitId}:${resolver.positionId}`;
|
|
5072
|
+
}
|
|
5073
|
+
if (resolver.type === 'ORG_UNIT_MANAGER') {
|
|
5074
|
+
return `orgUnitManager:${resolver.orgUnitId}`;
|
|
5075
|
+
}
|
|
5076
|
+
return resolver.type === 'ORG_MANAGER_CHAIN'
|
|
5077
|
+
? `orgManagerChain:${resolver.minPositionLevel}`
|
|
5078
|
+
: resolver.type;
|
|
5079
|
+
}
|
|
5080
|
+
function readDryRunUnresolvedApproverMessage(resolver, nodeName, initiatorMemberId) {
|
|
5081
|
+
if (resolver.type === 'DIRECT') {
|
|
5082
|
+
return `簽核節點「${nodeName}」尚未選擇簽核人,請在流程設計器點選此節點設定簽核人`;
|
|
5083
|
+
}
|
|
5084
|
+
if (resolver.type === 'DYNAMIC_FORM') {
|
|
5085
|
+
return `簽核節點「${nodeName}」依表單欄位找不到簽核人,請確認試跑資料中該欄位有填人員`;
|
|
5086
|
+
}
|
|
5087
|
+
if (resolver.type === 'EXPRESSION') {
|
|
5088
|
+
return `簽核節點「${nodeName}」依運算式找不到簽核人,請確認運算式的結果`;
|
|
5089
|
+
}
|
|
5090
|
+
const fallbackProblem = resolver.type === 'ORG_MANAGER'
|
|
5091
|
+
? readDryRunFallbackProblem(initiatorMemberId, resolver.fallback)
|
|
5092
|
+
: null;
|
|
5093
|
+
if (fallbackProblem === 'BLANK_MEMBER') {
|
|
5094
|
+
return `簽核節點「${nodeName}」找不到主管,且改派固定人未設定`;
|
|
5095
|
+
}
|
|
5096
|
+
if (fallbackProblem === 'IS_INITIATOR') {
|
|
5097
|
+
return `簽核節點「${nodeName}」找不到主管,且改派固定人是申請人本人,此節點未允許申請人自簽`;
|
|
5098
|
+
}
|
|
5099
|
+
return `簽核節點「${nodeName}」依申請人的組織找不到主管,請確認試跑時的申請人資料`;
|
|
5100
|
+
}
|
|
4630
5101
|
function createDryRunStep(input) {
|
|
4631
5102
|
return Object.assign(new workflow_dry_run_object_1.WorkflowDryRunStepObject(), {
|
|
4632
5103
|
assigneeMemberId: input.assigneeMemberId,
|
|
@@ -4703,4 +5174,34 @@ function mapDecisionToResolution(action) {
|
|
|
4703
5174
|
return notification_enums_1.NotificationResolutionEnum.TRANSFERRED;
|
|
4704
5175
|
}
|
|
4705
5176
|
}
|
|
5177
|
+
// The limit is stated in characters, so a character outside the BMP counts
|
|
5178
|
+
// once rather than as its two UTF-16 code units.
|
|
5179
|
+
function countCodePoints(value) {
|
|
5180
|
+
return Array.from(value).length;
|
|
5181
|
+
}
|
|
5182
|
+
function normalizeBusinessKey(businessKey) {
|
|
5183
|
+
const trimmed = businessKey?.trim() ?? '';
|
|
5184
|
+
if (trimmed === '') {
|
|
5185
|
+
return null;
|
|
5186
|
+
}
|
|
5187
|
+
if (countCodePoints(trimmed) > exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH) {
|
|
5188
|
+
throw new common_1.BadRequestException(`業務鍵長度不可超過 ${exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH} 個字元`);
|
|
5189
|
+
}
|
|
5190
|
+
return trimmed;
|
|
5191
|
+
}
|
|
5192
|
+
/**
|
|
5193
|
+
* Turns Postgres rejecting a duplicate (template, business key) into a
|
|
5194
|
+
* Conflict. Any other failure, including other unique violations inside the
|
|
5195
|
+
* same transaction, surfaces as it is.
|
|
5196
|
+
*/
|
|
5197
|
+
function throwBusinessKeyConflictOrRethrow(error, businessKey) {
|
|
5198
|
+
const { code, constraint, driverError } = (error ?? {});
|
|
5199
|
+
if (businessKey !== null &&
|
|
5200
|
+
(code ?? driverError?.code) === '23505' &&
|
|
5201
|
+
(constraint ?? driverError?.constraint) ===
|
|
5202
|
+
APPROVAL_INSTANCE_BUSINESS_KEY_INDEX) {
|
|
5203
|
+
throw new common_1.ConflictException(`此範本已有相同業務鍵(${businessKey})的簽核單`);
|
|
5204
|
+
}
|
|
5205
|
+
throw error;
|
|
5206
|
+
}
|
|
4706
5207
|
//# sourceMappingURL=workflow-engine.service.js.map
|