@rytass/bpm-core-nestjs-module 0.14.0 → 0.15.0
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 +63 -0
- package/README.md +10 -0
- package/package.json +2 -2
- package/src/lib/attachment/attachment.service.js +6 -1
- package/src/lib/attachment/attachment.service.js.map +1 -1
- package/src/lib/bpm/bpm-root-options.d.ts +2 -1
- package/src/lib/bpm/bpm-root-options.js +7 -1
- package/src/lib/bpm/bpm-root-options.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/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-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/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-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 +73 -6
- package/src/lib/workflow-engine/workflow-engine.service.js +697 -221
- package/src/lib/workflow-engine/workflow-engine.service.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
|
-
|
|
1499
|
-
|
|
1581
|
+
const memberIds = this.readDryRunMemberIds(input, resolver, `workflow.nodes.${node.id}.data.approverResolver`);
|
|
1582
|
+
if (memberIds === null) {
|
|
1583
|
+
return {
|
|
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)) {
|
|
1500
1600
|
return {
|
|
1501
|
-
assigneeMemberId:
|
|
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"
|
|
@@ -1909,13 +1995,54 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1909
1995
|
]);
|
|
1910
1996
|
return nextMap;
|
|
1911
1997
|
}, new Map());
|
|
1998
|
+
const nodeLabels = await this.readTaskNodeLabels(tasks);
|
|
1912
1999
|
return tasks.map((task) => Object.assign(new task_entity_1.TaskEntity(), task, {
|
|
1913
2000
|
candidateMemberIds: candidatesByTaskId.get(task.id) ?? [],
|
|
2001
|
+
nodeLabel: nodeLabels.get(readNodeLabelKey(task.instanceId, task.nodeId)) ??
|
|
2002
|
+
null,
|
|
1914
2003
|
openCandidateMemberIds: openCandidatesByTaskId.get(task.id) ?? [],
|
|
1915
2004
|
}));
|
|
1916
2005
|
}
|
|
2006
|
+
/**
|
|
2007
|
+
* The display names of the nodes a page of tasks sits on, keyed by
|
|
2008
|
+
* {@link readNodeLabelKey}. One query for the whole page, and Postgres picks
|
|
2009
|
+
* the labels out of the snapshots: a workflow snapshot is a whole workflow
|
|
2010
|
+
* definition, and every inbox, history and instance task list runs this, so
|
|
2011
|
+
* shipping the snapshots to the app just to read one string each would cost
|
|
2012
|
+
* page size × snapshot size on every refresh. A missing instance or node, a
|
|
2013
|
+
* snapshot without a `nodes` array, and a blank or non-string label all
|
|
2014
|
+
* leave the task without a label, as `readWorkflowNodeLabel` does.
|
|
2015
|
+
*/
|
|
2016
|
+
async readTaskNodeLabels(tasks) {
|
|
2017
|
+
// The table as the entity reads it, so a configured `schema` is honoured
|
|
2018
|
+
// here exactly as it is by every repository read of the instance.
|
|
2019
|
+
const { metadata } = this.approvalInstanceRepository;
|
|
2020
|
+
const { driver } = this.approvalInstanceRepository.manager.connection;
|
|
2021
|
+
const instanceTable = [metadata.schema, metadata.tableName]
|
|
2022
|
+
.filter((part) => Boolean(part))
|
|
2023
|
+
.map((part) => driver.escape(part))
|
|
2024
|
+
.join('.');
|
|
2025
|
+
const rows = await this.approvalInstanceRepository.query(`SELECT instance.id AS "instanceId",
|
|
2026
|
+
node->>'id' AS "nodeId",
|
|
2027
|
+
CASE WHEN jsonb_typeof(node->'data'->'label') = 'string'
|
|
2028
|
+
THEN node->'data'->>'label' END AS "label"
|
|
2029
|
+
FROM ${instanceTable} AS instance
|
|
2030
|
+
CROSS JOIN LATERAL jsonb_array_elements(
|
|
2031
|
+
CASE WHEN jsonb_typeof(instance.workflow_snapshot->'nodes') = 'array'
|
|
2032
|
+
THEN instance.workflow_snapshot->'nodes'
|
|
2033
|
+
ELSE '[]'::jsonb END
|
|
2034
|
+
) AS node
|
|
2035
|
+
WHERE instance.id = ANY($1::uuid[])
|
|
2036
|
+
AND node->>'id' = ANY($2::text[])`, [
|
|
2037
|
+
[...new Set(tasks.map((task) => task.instanceId))],
|
|
2038
|
+
[...new Set(tasks.map((task) => task.nodeId))],
|
|
2039
|
+
]);
|
|
2040
|
+
return new Map(rows.flatMap((row) => typeof row.label === 'string' && row.label.trim()
|
|
2041
|
+
? [[readNodeLabelKey(row.instanceId, row.nodeId), row.label]]
|
|
2042
|
+
: []));
|
|
2043
|
+
}
|
|
1917
2044
|
async readDefaultInitiatorMetadataSnapshot(memberId) {
|
|
1918
|
-
const today =
|
|
2045
|
+
const today = this.readOrganizationToday();
|
|
1919
2046
|
const memberships = (await this.approvalInstanceRepository.manager
|
|
1920
2047
|
.getRepository(membership_entity_1.MembershipEntity)
|
|
1921
2048
|
.find({ where: { memberId } }))
|
|
@@ -1950,7 +2077,10 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1950
2077
|
return task;
|
|
1951
2078
|
}
|
|
1952
2079
|
async isApprovalInstanceReadableByMember(instance, memberId) {
|
|
1953
|
-
|
|
2080
|
+
// The member who filed the document on the applicant's behalf can read it
|
|
2081
|
+
// back; resubmit and cancel stay with the initiator.
|
|
2082
|
+
if (instance.initiatorMemberId === memberId ||
|
|
2083
|
+
(0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) === memberId) {
|
|
1954
2084
|
return true;
|
|
1955
2085
|
}
|
|
1956
2086
|
const tasks = await this.taskRepository.find({
|
|
@@ -2157,6 +2287,12 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2157
2287
|
});
|
|
2158
2288
|
}
|
|
2159
2289
|
async skipNode(manager, instance, token, node, reason) {
|
|
2290
|
+
// A NOTIFY node is an asynchronous branch that may not have an outgoing
|
|
2291
|
+
// edge, so a skipped one ends its branch exactly as an executed one does:
|
|
2292
|
+
// the token is consumed instead of advanced (advancing would throw "has no
|
|
2293
|
+
// outgoing edge" and fail the whole submission). Instance completion is
|
|
2294
|
+
// evaluated by `processRunningInstance` once no ACTIVE token remains.
|
|
2295
|
+
const consumesToken = (0, workflow_graph_1.isAsyncNotifyServiceTask)(node);
|
|
2160
2296
|
await manager.getRepository(activity_log_entity_1.ActivityLogEntity).save(manager.getRepository(activity_log_entity_1.ActivityLogEntity).create({
|
|
2161
2297
|
actorMemberId: null,
|
|
2162
2298
|
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TOKEN_ADVANCED,
|
|
@@ -2164,12 +2300,24 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2164
2300
|
nodeId: node.id,
|
|
2165
2301
|
payload: {
|
|
2166
2302
|
...reason,
|
|
2303
|
+
...(consumesToken ? { consumed: true } : {}),
|
|
2167
2304
|
tokenId: token.id,
|
|
2168
2305
|
},
|
|
2169
2306
|
taskId: null,
|
|
2170
2307
|
}));
|
|
2308
|
+
if (consumesToken) {
|
|
2309
|
+
await this.consumeToken(manager, token);
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2171
2312
|
await this.advanceTokenToOutgoingNodes(manager, instance, token, node);
|
|
2172
2313
|
}
|
|
2314
|
+
async consumeToken(manager, token) {
|
|
2315
|
+
await manager.getRepository(workflow_token_entity_1.WorkflowTokenEntity).save({
|
|
2316
|
+
...token,
|
|
2317
|
+
consumedAt: new Date(),
|
|
2318
|
+
status: workflow_engine_enums_1.WorkflowTokenStatusEnum.CONSUMED,
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2173
2321
|
async readAvailableOutgoingEdges(manager, instance, node) {
|
|
2174
2322
|
const outgoingEdges = readOutgoingEdgesOrThrow(instance.workflowSnapshot, node.id);
|
|
2175
2323
|
const conditionalEdges = outgoingEdges.filter((edge) => !edge.data.isDefault && edgeHasCondition(edge));
|
|
@@ -2361,14 +2509,15 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2361
2509
|
});
|
|
2362
2510
|
return;
|
|
2363
2511
|
}
|
|
2364
|
-
const
|
|
2512
|
+
const resolvedCandidates = await this.resolveRuntimeTaskCandidates(manager, instance, node);
|
|
2365
2513
|
// The initiator was the only approver and the node asks to be skipped in
|
|
2366
2514
|
// that case: no task, the token moves on. Pending countersign directives
|
|
2367
2515
|
// stay pending for the next user task that actually gets created.
|
|
2368
|
-
if (
|
|
2516
|
+
if (resolvedCandidates === null) {
|
|
2369
2517
|
await this.skipNodeByInitiatorExclusion(manager, instance, token, node);
|
|
2370
2518
|
return;
|
|
2371
2519
|
}
|
|
2520
|
+
const { candidates, skippedDelegations } = resolvedCandidates;
|
|
2372
2521
|
const primaryCandidate = candidates[0];
|
|
2373
2522
|
// Legacy snapshots may carry no policy at all; they behave as before.
|
|
2374
2523
|
const decisionPolicy = node.data.decisionPolicy;
|
|
@@ -2441,6 +2590,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2441
2590
|
sequentialCandidateMemberIds: savedCandidates.map((candidate) => candidate.memberId),
|
|
2442
2591
|
}
|
|
2443
2592
|
: {}),
|
|
2593
|
+
...(skippedDelegations.length > 0 ? { skippedDelegations } : {}),
|
|
2444
2594
|
tokenId: token.id,
|
|
2445
2595
|
},
|
|
2446
2596
|
taskId: task.id,
|
|
@@ -2481,11 +2631,13 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2481
2631
|
const now = new Date();
|
|
2482
2632
|
// Delegation was resolved when the task was created; the candidate may have
|
|
2483
2633
|
// gained a delegate while waiting, so resolve it again for the new turn.
|
|
2484
|
-
const delegation = await this.resolveActivationDelegation({
|
|
2634
|
+
const { skippedDelegation, ...delegation } = await this.resolveActivationDelegation({
|
|
2485
2635
|
candidate: nextCandidate,
|
|
2486
2636
|
candidates,
|
|
2487
2637
|
excludesInitiator: isInitiatorExcluded(node),
|
|
2638
|
+
gradeGated: isGradeGatedCandidate(node, nextCandidate.sourceType),
|
|
2488
2639
|
instance,
|
|
2640
|
+
manager,
|
|
2489
2641
|
nodeId: task.nodeId,
|
|
2490
2642
|
});
|
|
2491
2643
|
const activatedCandidate = await manager
|
|
@@ -2525,6 +2677,9 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2525
2677
|
originalMemberId: activatedCandidate.originalMemberId,
|
|
2526
2678
|
previousMemberId,
|
|
2527
2679
|
sequence: activatedCandidate.sequence,
|
|
2680
|
+
...(skippedDelegation
|
|
2681
|
+
? { skippedDelegations: [skippedDelegation] }
|
|
2682
|
+
: {}),
|
|
2528
2683
|
tokenId: task.tokenId,
|
|
2529
2684
|
},
|
|
2530
2685
|
taskId: task.id,
|
|
@@ -2542,29 +2697,31 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2542
2697
|
* are kept: (task_id, member_id) is unique, and that member signs from the
|
|
2543
2698
|
* row they already have. On a node that excludes the initiator the stored
|
|
2544
2699
|
* 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.
|
|
2700
|
+
* initiator or the submitter: creation already filtered such paths out, and a
|
|
2701
|
+
* delegation added while the candidate waited must not bring them back.
|
|
2547
2702
|
*/
|
|
2548
|
-
async resolveActivationDelegation({ candidate, candidates, excludesInitiator, instance, nodeId, }) {
|
|
2703
|
+
async resolveActivationDelegation({ candidate, candidates, excludesInitiator, gradeGated, instance, manager, nodeId, }) {
|
|
2549
2704
|
const stored = {
|
|
2550
2705
|
delegationChain: candidate.delegationChain,
|
|
2551
2706
|
memberId: candidate.memberId,
|
|
2552
2707
|
};
|
|
2553
|
-
const resolution = await this.
|
|
2708
|
+
const { skippedDelegation, ...resolution } = await this.resolveDelegation(manager, candidate.originalMemberId, this.buildDelegationContext(instance, nodeId), gradeGated);
|
|
2554
2709
|
const collides = candidates.some((other) => other.id !== candidate.id &&
|
|
2555
2710
|
other.memberId === resolution.finalAssigneeMemberId);
|
|
2556
2711
|
if (collides ||
|
|
2557
2712
|
(excludesInitiator &&
|
|
2558
|
-
|
|
2713
|
+
isExcludedMemberOnCandidatePath({
|
|
2559
2714
|
delegationChain: resolution.delegationChain,
|
|
2560
2715
|
memberId: resolution.finalAssigneeMemberId,
|
|
2561
2716
|
originalMemberId: candidate.originalMemberId,
|
|
2562
|
-
}, instance
|
|
2563
|
-
|
|
2717
|
+
}, readInitiatorAndSubmitter(instance)))) {
|
|
2718
|
+
// The refusal happened even though the stored values are kept.
|
|
2719
|
+
return { ...stored, ...(skippedDelegation ? { skippedDelegation } : {}) };
|
|
2564
2720
|
}
|
|
2565
2721
|
return {
|
|
2566
2722
|
delegationChain: resolution.delegationChain,
|
|
2567
2723
|
memberId: resolution.finalAssigneeMemberId,
|
|
2724
|
+
...(skippedDelegation ? { skippedDelegation } : {}),
|
|
2568
2725
|
};
|
|
2569
2726
|
}
|
|
2570
2727
|
async markTaskCandidateDecision({ action, candidate, candidates, decidedAt, manager, task, }) {
|
|
@@ -2802,12 +2959,16 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2802
2959
|
*/
|
|
2803
2960
|
async resolveRuntimeTaskCandidates(manager, instance, node) {
|
|
2804
2961
|
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);
|
|
2962
|
+
const resolvedCandidates = await this.resolveApproverResolver(manager, instance, node.data.approverResolver, label, '簽核人', isInitiatorExcluded(node) ? readInitiatorAndSubmitter(instance) : []);
|
|
2963
|
+
const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, isOrgManagerChainNode(node));
|
|
2807
2964
|
const excludeInitiator = node.data.excludeInitiator;
|
|
2808
|
-
const
|
|
2965
|
+
const hasSubmitter = (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) !== null;
|
|
2966
|
+
const uniqueCandidates = selectNodeCandidates(delegatedCandidates, isInitiatorExcluded(node), readInitiatorAndSubmitter(instance));
|
|
2809
2967
|
if (uniqueCandidates.length > 0) {
|
|
2810
|
-
return
|
|
2968
|
+
return {
|
|
2969
|
+
candidates: uniqueCandidates,
|
|
2970
|
+
skippedDelegations: collectSkippedDelegations(delegatedCandidates, isInitiatorExcluded(node), readInitiatorAndSubmitter(instance)),
|
|
2971
|
+
};
|
|
2811
2972
|
}
|
|
2812
2973
|
// Only the initiator was left — what the node does about it is configured.
|
|
2813
2974
|
// Nothing resolved to begin with is the ordinary failure below.
|
|
@@ -2819,23 +2980,31 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2819
2980
|
}
|
|
2820
2981
|
if (excludeInitiator.whenEmpty === 'INITIATOR_MANAGER') {
|
|
2821
2982
|
const managerCandidates = await this.resolveInitiatorManagerReplacements(manager, instance, node);
|
|
2822
|
-
if (managerCandidates.length > 0) {
|
|
2983
|
+
if (managerCandidates.candidates.length > 0) {
|
|
2823
2984
|
return managerCandidates;
|
|
2824
2985
|
}
|
|
2825
|
-
throw new common_1.ConflictException(
|
|
2986
|
+
throw new common_1.ConflictException(hasSubmitter
|
|
2987
|
+
? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人,此節點不允許申請人或代送人簽核,且找不到申請人的主管可改派。`
|
|
2988
|
+
: `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人,此節點不允許申請人自簽,且找不到申請人的主管可改派。`);
|
|
2826
2989
|
}
|
|
2827
|
-
throw new common_1.ConflictException(
|
|
2990
|
+
throw new common_1.ConflictException(hasSubmitter
|
|
2991
|
+
? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人(含委派後的代理人),且此節點不允許申請人或代送人簽核。`
|
|
2992
|
+
: `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人(含委派後的代理人),且此節點不允許申請人自簽。`);
|
|
2828
2993
|
}
|
|
2829
2994
|
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
|
|
2995
|
+
// The initiator's own direct manager: the closest org unit only. The
|
|
2996
|
+
// initiator himself (a unit head) and the member who submitted for him are
|
|
2997
|
+
// skipped before the tier is chosen, so resolution falls through to the
|
|
2998
|
+
// next tier instead of ending on either of them.
|
|
2999
|
+
const managerMemberIds = await this.resolveDirectManagerMemberIds(manager, instance.initiatorMemberId, this.readOrganizationToday(), true, readInitiatorAndSubmitter(instance));
|
|
3000
|
+
const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, managerMemberIds.map((memberId) => ({
|
|
2835
3001
|
memberId,
|
|
2836
3002
|
sourceType: 'EXCLUDE_INITIATOR_MANAGER',
|
|
2837
|
-
})));
|
|
2838
|
-
return
|
|
3003
|
+
})), isOrgManagerChainNode(node));
|
|
3004
|
+
return {
|
|
3005
|
+
candidates: selectNodeCandidates(delegatedCandidates, true, readInitiatorAndSubmitter(instance)),
|
|
3006
|
+
skippedDelegations: collectSkippedDelegations(delegatedCandidates, true, readInitiatorAndSubmitter(instance)),
|
|
3007
|
+
};
|
|
2839
3008
|
}
|
|
2840
3009
|
buildDelegationContext(instance, nodeId) {
|
|
2841
3010
|
return {
|
|
@@ -2852,48 +3021,86 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2852
3021
|
}
|
|
2853
3022
|
// Not deduplicated: a node that excludes the initiator filters first, so a
|
|
2854
3023
|
// first-seen entry that must be dropped cannot shadow a legitimate one.
|
|
2855
|
-
async delegateResolvedCandidates(instance, nodeId, resolvedCandidates) {
|
|
3024
|
+
async delegateResolvedCandidates(manager, instance, nodeId, resolvedCandidates, gradeGated) {
|
|
2856
3025
|
const context = this.buildDelegationContext(instance, nodeId);
|
|
2857
|
-
const
|
|
2858
|
-
|
|
3026
|
+
const resolved = await Promise.all(resolvedCandidates.map(async (candidate) => ({
|
|
3027
|
+
candidate,
|
|
3028
|
+
resolution: await this.delegationService.resolveAssignee(candidate.memberId, context),
|
|
3029
|
+
})));
|
|
3030
|
+
// One batch of grade lookups for the whole node, not two per delegate.
|
|
3031
|
+
const levels = gradeGated
|
|
3032
|
+
? await this.readMemberPositionLevels(manager, resolved.flatMap(({ candidate, resolution }) => readGradeGateMemberIds(candidate.memberId, resolution)), this.readOrganizationToday())
|
|
3033
|
+
: new Map();
|
|
3034
|
+
return resolved.map(({ candidate, resolution }) => {
|
|
3035
|
+
const { skippedDelegation, ...delegationResolution } = applyGradeGate(candidate.memberId, resolution, gradeGated, levels);
|
|
2859
3036
|
return {
|
|
2860
3037
|
delegationChain: delegationResolution.delegationChain,
|
|
2861
3038
|
memberId: delegationResolution.finalAssigneeMemberId,
|
|
2862
3039
|
originalMemberId: candidate.memberId,
|
|
3040
|
+
...(skippedDelegation ? { skippedDelegation } : {}),
|
|
2863
3041
|
sourceType: candidate.sourceType,
|
|
2864
3042
|
};
|
|
2865
|
-
})
|
|
2866
|
-
return delegatedCandidates;
|
|
3043
|
+
});
|
|
2867
3044
|
}
|
|
2868
|
-
|
|
3045
|
+
/** One member's delegation, grade-gated as {@link applyGradeGate} says. */
|
|
3046
|
+
async resolveDelegation(manager, principalMemberId, context, gradeGated) {
|
|
3047
|
+
const resolution = await this.delegationService.resolveAssignee(principalMemberId, context);
|
|
3048
|
+
const memberIds = gradeGated
|
|
3049
|
+
? readGradeGateMemberIds(principalMemberId, resolution)
|
|
3050
|
+
: [];
|
|
3051
|
+
const levels = memberIds.length > 0
|
|
3052
|
+
? await this.readMemberPositionLevels(manager, memberIds, this.readOrganizationToday())
|
|
3053
|
+
: new Map();
|
|
3054
|
+
return applyGradeGate(principalMemberId, resolution, gradeGated, levels);
|
|
3055
|
+
}
|
|
3056
|
+
/**
|
|
3057
|
+
* Manual transfer on a manager-chain node: the target must hold at least the
|
|
3058
|
+
* grade of the member being replaced, i.e. the original principal of the
|
|
3059
|
+
* deciding candidate (the first hop of its delegation chain when it was
|
|
3060
|
+
* delegated, otherwise the actor). No grade on the principal means no
|
|
3061
|
+
* constraint.
|
|
3062
|
+
*/
|
|
3063
|
+
async assertTransferTargetGrade(manager, { actorMemberId, delegationChain, label, targetMemberId, }) {
|
|
3064
|
+
const principalMemberId = readDelegationSteps(delegationChain)[0]?.from ?? actorMemberId;
|
|
3065
|
+
const date = this.readOrganizationToday();
|
|
3066
|
+
const requiredLevel = await this.readMemberPositionLevel(manager, principalMemberId, date);
|
|
3067
|
+
if (requiredLevel === null) {
|
|
3068
|
+
return;
|
|
3069
|
+
}
|
|
3070
|
+
const targetLevel = await this.readMemberPositionLevel(manager, targetMemberId, date);
|
|
3071
|
+
if (targetLevel === null || targetLevel < requiredLevel) {
|
|
3072
|
+
throw new common_1.ForbiddenException(`簽核節點「${label}」需由職等 ${requiredLevel} 以上的成員簽核,不能轉簽給職等不足的成員`);
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
async resolveApproverResolver(manager, instance, resolver, label, subject = '簽核人', skippedChainMemberIds = []) {
|
|
2869
3076
|
if (resolver.type === 'DIRECT') {
|
|
2870
|
-
return readMemberIdsFromValue(resolver.memberIds, label).map((memberId) => ({ memberId, sourceType: resolver.type }));
|
|
3077
|
+
return readMemberIdsFromValue(resolver.memberIds, label, subject).map((memberId) => ({ memberId, sourceType: resolver.type }));
|
|
2871
3078
|
}
|
|
2872
3079
|
if (resolver.type === 'DYNAMIC_FORM') {
|
|
2873
|
-
return readMemberIdsFromValue(readValueAtPath(instance.formData, resolver.formPath),
|
|
3080
|
+
return readMemberIdsFromValue(readValueAtPath(instance.formData, resolver.formPath), label, subject, `依表單欄位「${resolver.formPath}」`).map((memberId) => ({ memberId, sourceType: resolver.type }));
|
|
2874
3081
|
}
|
|
2875
3082
|
if (resolver.type === 'EXPRESSION') {
|
|
2876
|
-
return readMemberIdsFromValue(this.conditionService.evaluateValue(resolver.expression, buildWorkflowExpressionContext(instance), `${label}.expression`),
|
|
3083
|
+
return readMemberIdsFromValue(this.conditionService.evaluateValue(resolver.expression, buildWorkflowExpressionContext(instance), `${label}.expression`), label, subject, '依運算式').map((memberId) => ({ memberId, sourceType: resolver.type }));
|
|
2877
3084
|
}
|
|
2878
3085
|
if (resolver.type === 'POSITION') {
|
|
2879
|
-
return this.resolvePositionCandidates(manager, resolver.positionId, label).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
3086
|
+
return this.resolvePositionCandidates(manager, resolver.positionId, label, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
2880
3087
|
}
|
|
2881
3088
|
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 })));
|
|
3089
|
+
return this.resolveOrgUnitMemberCandidates(manager, resolver.orgUnitId, Boolean(resolver.includeDescendants), label, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
2883
3090
|
}
|
|
2884
3091
|
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 })));
|
|
3092
|
+
return this.resolveOrgUnitPositionCandidates(manager, resolver.orgUnitId, resolver.positionId, Boolean(resolver.includeDescendants), label, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
2886
3093
|
}
|
|
2887
3094
|
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 })));
|
|
3095
|
+
return this.resolveOrgUnitManagerCandidates(manager, instance, resolver.orgUnitId, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
2889
3096
|
}
|
|
2890
3097
|
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 })));
|
|
3098
|
+
return this.resolveOrgManagerChainCandidates(manager, instance, resolver.minPositionLevel, resolver.fallback, label, subject, skippedChainMemberIds).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
2892
3099
|
}
|
|
2893
|
-
return this.resolveInitiatorManagerCandidates(manager, instance, resolver.levelsUp, resolver.fallback, label, resolver.preferClosestOrgUnit === true).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
3100
|
+
return this.resolveInitiatorManagerCandidates(manager, instance, resolver.levelsUp, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
2894
3101
|
}
|
|
2895
|
-
async resolvePositionCandidates(manager, positionId, label) {
|
|
2896
|
-
const today =
|
|
3102
|
+
async resolvePositionCandidates(manager, positionId, label, subject) {
|
|
3103
|
+
const today = this.readOrganizationToday();
|
|
2897
3104
|
const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
|
|
2898
3105
|
where: { positionId },
|
|
2899
3106
|
});
|
|
@@ -2907,27 +3114,28 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2907
3114
|
return second.effectiveFrom.localeCompare(first.effectiveFrom);
|
|
2908
3115
|
});
|
|
2909
3116
|
if (!activeMemberships.length) {
|
|
2910
|
-
throw new common_1.ConflictException(`${label}
|
|
3117
|
+
throw new common_1.ConflictException(`${label} 找不到${subject}:職位「${await this.readPositionName(manager, positionId)}」目前沒有任何有效成員`);
|
|
2911
3118
|
}
|
|
2912
3119
|
return uniqueTexts(activeMemberships.map((membership) => membership.memberId));
|
|
2913
3120
|
}
|
|
2914
|
-
async resolveInitiatorManagerCandidates(manager, instance, levelsUp, fallback, label, preferClosestOrgUnit) {
|
|
3121
|
+
async resolveInitiatorManagerCandidates(manager, instance, levelsUp, fallback, label, preferClosestOrgUnit, subject) {
|
|
2915
3122
|
const normalizedLevelsUp = Math.max(Math.trunc(levelsUp), 1);
|
|
2916
3123
|
const resolvedManagerMemberIds = await this.resolveManagerMemberIdsFromOrganization(manager, instance.initiatorMemberId, normalizedLevelsUp, preferClosestOrgUnit);
|
|
2917
3124
|
if (resolvedManagerMemberIds.length) {
|
|
2918
3125
|
return resolvedManagerMemberIds;
|
|
2919
3126
|
}
|
|
2920
3127
|
return [
|
|
2921
|
-
this.resolveFallbackAssignee(instance, fallback, label, `找不到發起人的第 ${normalizedLevelsUp} 層主管`),
|
|
3128
|
+
this.resolveFallbackAssignee(instance, fallback, label, subject, `找不到發起人的第 ${normalizedLevelsUp} 層主管`),
|
|
2922
3129
|
];
|
|
2923
3130
|
}
|
|
2924
3131
|
/**
|
|
2925
3132
|
* Walks up the initiator's manager chain, nearest manager first, until a
|
|
2926
3133
|
* manager whose position level reaches `minPositionLevel` is added. The
|
|
2927
|
-
* list is returned nearest manager first.
|
|
3134
|
+
* list is returned nearest manager first. Members in `skippedMemberIds` are
|
|
3135
|
+
* walked past without joining the chain or counting as the stopping point.
|
|
2928
3136
|
*/
|
|
2929
|
-
async resolveOrgManagerChainCandidates(manager, instance, minPositionLevel, fallback, label) {
|
|
2930
|
-
const date =
|
|
3137
|
+
async resolveOrgManagerChainCandidates(manager, instance, minPositionLevel, fallback, label, subject, skippedMemberIds) {
|
|
3138
|
+
const date = this.readOrganizationToday();
|
|
2931
3139
|
const visitedMemberIds = new Set([instance.initiatorMemberId]);
|
|
2932
3140
|
const chain = [];
|
|
2933
3141
|
let currentMemberId = instance.initiatorMemberId;
|
|
@@ -2938,7 +3146,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2938
3146
|
// excluded from his own candidates: a unit head belongs to the unit he
|
|
2939
3147
|
// heads, so without this his unit rule would name himself and the walk
|
|
2940
3148
|
// could never climb past him.
|
|
2941
|
-
const directManagerMemberIds = await this.resolveDirectManagerMemberIds(manager, currentMemberId, date, true, currentMemberId);
|
|
3149
|
+
const directManagerMemberIds = await this.resolveDirectManagerMemberIds(manager, currentMemberId, date, true, [currentMemberId]);
|
|
2942
3150
|
if (directManagerMemberIds.length === 0) {
|
|
2943
3151
|
break;
|
|
2944
3152
|
}
|
|
@@ -2952,6 +3160,12 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2952
3160
|
}
|
|
2953
3161
|
visitedMemberIds.add(managerMemberId);
|
|
2954
3162
|
currentMemberId = managerMemberId;
|
|
3163
|
+
// A member the node keeps out (the person who filed the document) is
|
|
3164
|
+
// climbed through, not stopped at: filtering him after the walk would
|
|
3165
|
+
// lose the chain when he is the manager that reaches the level.
|
|
3166
|
+
if (skippedMemberIds.includes(managerMemberId)) {
|
|
3167
|
+
continue;
|
|
3168
|
+
}
|
|
2955
3169
|
const level = await this.readMemberPositionLevel(manager, managerMemberId, date);
|
|
2956
3170
|
if (level === null ||
|
|
2957
3171
|
(lastAddedLevel !== null && level <= lastAddedLevel)) {
|
|
@@ -2963,45 +3177,60 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2963
3177
|
return chain;
|
|
2964
3178
|
}
|
|
2965
3179
|
}
|
|
2966
|
-
const fallbackMemberId = this.resolveFallbackAssignee(instance, fallback, label, `沿主管鏈找不到職等達 ${minPositionLevel} 的主管`);
|
|
3180
|
+
const fallbackMemberId = this.resolveFallbackAssignee(instance, fallback, label, subject, `沿主管鏈找不到職等達 ${minPositionLevel} 的主管`);
|
|
2967
3181
|
return chain.includes(fallbackMemberId)
|
|
2968
3182
|
? chain
|
|
2969
3183
|
: [...chain, fallbackMemberId];
|
|
2970
3184
|
}
|
|
2971
3185
|
/** Highest position level over the member's active memberships, or `null`. */
|
|
2972
3186
|
async readMemberPositionLevel(manager, memberId, date) {
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
3187
|
+
return ((await this.readMemberPositionLevels(manager, [memberId], date)).get(memberId) ?? null);
|
|
3188
|
+
}
|
|
3189
|
+
/**
|
|
3190
|
+
* {@link readMemberPositionLevel} for several members in two queries: every
|
|
3191
|
+
* requested member is in the map, `null` when they hold no graded position.
|
|
3192
|
+
*/
|
|
3193
|
+
async readMemberPositionLevels(manager, memberIds, date) {
|
|
3194
|
+
const uniqueMemberIds = uniqueTexts(memberIds);
|
|
3195
|
+
if (uniqueMemberIds.length === 0) {
|
|
3196
|
+
return new Map();
|
|
3197
|
+
}
|
|
3198
|
+
const memberships = (await manager.getRepository(membership_entity_1.MembershipEntity).find({
|
|
3199
|
+
where: { memberId: (0, typeorm_2.In)([...uniqueMemberIds]) },
|
|
3200
|
+
})).filter((membership) => Boolean(membership.positionId) && isDateRangeActive(membership, date));
|
|
3201
|
+
const positionIds = uniqueTexts(memberships.map((membership) => membership.positionId ?? ''));
|
|
3202
|
+
const positions = positionIds.length === 0
|
|
3203
|
+
? []
|
|
3204
|
+
: await manager.getRepository(position_entity_1.PositionEntity).find({
|
|
3205
|
+
where: { id: (0, typeorm_2.In)([...positionIds]) },
|
|
3206
|
+
});
|
|
3207
|
+
const levelByPositionId = new Map(positions.map((position) => [position.id, position.level]));
|
|
3208
|
+
return new Map(uniqueMemberIds.map((memberId) => {
|
|
3209
|
+
const levels = memberships
|
|
3210
|
+
.filter((membership) => membership.memberId === memberId)
|
|
3211
|
+
.flatMap((membership) => {
|
|
3212
|
+
const level = levelByPositionId.get(membership.positionId ?? '');
|
|
3213
|
+
return level === undefined ? [] : [level];
|
|
3214
|
+
});
|
|
3215
|
+
return [memberId, levels.length === 0 ? null : Math.max(...levels)];
|
|
3216
|
+
}));
|
|
2989
3217
|
}
|
|
2990
|
-
resolveFallbackAssignee(instance, fallback, label, failureReason) {
|
|
3218
|
+
resolveFallbackAssignee(instance, fallback, label, subject, failureReason) {
|
|
3219
|
+
const failure = subject === '通知對象' ? '無法建立通知' : '無法建立待簽任務';
|
|
2991
3220
|
if (!fallback || fallback.type === 'NONE') {
|
|
2992
|
-
throw new common_1.ConflictException(`${label}
|
|
3221
|
+
throw new common_1.ConflictException(`${label} ${failure}:${failureReason},且未設定改派固定人。請聯絡流程管理員調整主管規則或模板 fallback。`);
|
|
2993
3222
|
}
|
|
2994
3223
|
if (!fallback.memberId.trim()) {
|
|
2995
|
-
throw new common_1.ConflictException(`${label}
|
|
3224
|
+
throw new common_1.ConflictException(`${label} ${failure}:改派固定人未設定。`);
|
|
2996
3225
|
}
|
|
2997
3226
|
if (fallback.memberId === instance.initiatorMemberId &&
|
|
2998
3227
|
!fallback.allowInitiatorSelfApproval) {
|
|
2999
|
-
throw new common_1.ConflictException(`${label}
|
|
3228
|
+
throw new common_1.ConflictException(`${label} ${failure}:改派固定人是申請人本人,且此節點未允許申請人自簽。`);
|
|
3000
3229
|
}
|
|
3001
3230
|
return fallback.memberId;
|
|
3002
3231
|
}
|
|
3003
3232
|
async resolveManagerMemberIdsFromOrganization(manager, memberId, levelsUp, preferClosestOrgUnit) {
|
|
3004
|
-
return this.resolveManagerMemberIdsAtLevel(manager, memberId,
|
|
3233
|
+
return this.resolveManagerMemberIdsAtLevel(manager, memberId, this.readOrganizationToday(), levelsUp, preferClosestOrgUnit);
|
|
3005
3234
|
}
|
|
3006
3235
|
async resolveManagerMemberIdsAtLevel(manager, memberId, date, remainingLevels, preferClosestOrgUnit) {
|
|
3007
3236
|
const directManagerMemberIds = await this.resolveDirectManagerMemberIds(manager, memberId, date, preferClosestOrgUnit);
|
|
@@ -3011,7 +3240,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3011
3240
|
const nextLevelGroups = await Promise.all(directManagerMemberIds.map((managerMemberId) => this.resolveManagerMemberIdsAtLevel(manager, managerMemberId, date, remainingLevels - 1, preferClosestOrgUnit)));
|
|
3012
3241
|
return uniqueTexts(nextLevelGroups.flat());
|
|
3013
3242
|
}
|
|
3014
|
-
async resolveDirectManagerMemberIds(manager, memberId, date, preferClosestOrgUnit,
|
|
3243
|
+
async resolveDirectManagerMemberIds(manager, memberId, date, preferClosestOrgUnit, excludedManagerMemberIds = []) {
|
|
3015
3244
|
const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
|
|
3016
3245
|
where: { memberId },
|
|
3017
3246
|
});
|
|
@@ -3032,10 +3261,10 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3032
3261
|
scopeType: organization_enums_1.ManagerResolutionScopeTypeEnum.POSITION,
|
|
3033
3262
|
})),
|
|
3034
3263
|
];
|
|
3035
|
-
return this.resolveManagerResolutionCandidates(manager, candidatePairs, date, readOrgUnitDepthMap(orgUnits), preferClosestOrgUnit,
|
|
3264
|
+
return this.resolveManagerResolutionCandidates(manager, candidatePairs, date, readOrgUnitDepthMap(orgUnits), preferClosestOrgUnit, excludedManagerMemberIds);
|
|
3036
3265
|
}
|
|
3037
|
-
async resolveOrgUnitManagerCandidates(manager, instance, orgUnitId, fallback, label, preferClosestOrgUnit) {
|
|
3038
|
-
const date =
|
|
3266
|
+
async resolveOrgUnitManagerCandidates(manager, instance, orgUnitId, fallback, label, preferClosestOrgUnit, subject) {
|
|
3267
|
+
const date = this.readOrganizationToday();
|
|
3039
3268
|
const orgUnits = await this.readOrgUnitAndAncestors(manager, [orgUnitId]);
|
|
3040
3269
|
const managerMemberIds = await this.resolveManagerResolutionCandidates(manager, orgUnits.map((orgUnit) => ({
|
|
3041
3270
|
scopeId: orgUnit.id,
|
|
@@ -3043,7 +3272,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3043
3272
|
})), date, readOrgUnitDepthMap(orgUnits), preferClosestOrgUnit);
|
|
3044
3273
|
if (!managerMemberIds.length) {
|
|
3045
3274
|
return [
|
|
3046
|
-
this.resolveFallbackAssignee(instance, fallback, label, `找不到指定組織的主管規則`),
|
|
3275
|
+
this.resolveFallbackAssignee(instance, fallback, label, subject, `找不到指定組織的主管規則`),
|
|
3047
3276
|
];
|
|
3048
3277
|
}
|
|
3049
3278
|
return managerMemberIds;
|
|
@@ -3064,7 +3293,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3064
3293
|
const byId = new Map([...directOrgUnits, ...ancestorGroups.flat()].map((orgUnit) => [orgUnit.id, orgUnit]));
|
|
3065
3294
|
return [...byId.values()];
|
|
3066
3295
|
}
|
|
3067
|
-
async resolveManagerResolutionCandidates(manager, candidatePairs, date, orgUnitDepths, preferClosestOrgUnit,
|
|
3296
|
+
async resolveManagerResolutionCandidates(manager, candidatePairs, date, orgUnitDepths, preferClosestOrgUnit, excludedManagerMemberIds = []) {
|
|
3068
3297
|
const scopeIds = candidatePairs.map((pair) => pair.scopeId);
|
|
3069
3298
|
if (!scopeIds.length) {
|
|
3070
3299
|
return [];
|
|
@@ -3088,58 +3317,78 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3088
3317
|
.filter((resolution) => isDateRangeActive(resolution, date))
|
|
3089
3318
|
// Dropped before the top-priority tier is chosen, so resolution falls
|
|
3090
3319
|
// through to the next tier instead of ending on the excluded member.
|
|
3091
|
-
.filter((resolution) =>
|
|
3092
|
-
resolution.managerMemberId !== excludedManagerMemberId)
|
|
3320
|
+
.filter((resolution) => !excludedManagerMemberIds.includes(resolution.managerMemberId))
|
|
3093
3321
|
.sort((left, right) => compareManagerResolution(left, right, depthLookup));
|
|
3094
3322
|
return uniqueTexts(readTopPriorityResolutions(active, depthLookup).map((resolution) => resolution.managerMemberId));
|
|
3095
3323
|
}
|
|
3096
|
-
async resolveOrgUnitMemberCandidates(manager, orgUnitId, includeDescendants, label) {
|
|
3097
|
-
const
|
|
3098
|
-
|
|
3324
|
+
async resolveOrgUnitMemberCandidates(manager, orgUnitId, includeDescendants, label, subject) {
|
|
3325
|
+
const scope = await this.readOrgUnitScope(manager, orgUnitId, includeDescendants);
|
|
3326
|
+
if (!scope) {
|
|
3327
|
+
throw new common_1.ConflictException(readMissingOrgUnitMessage(label, subject, orgUnitId));
|
|
3328
|
+
}
|
|
3329
|
+
const today = this.readOrganizationToday();
|
|
3099
3330
|
const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
|
|
3100
|
-
where: { orgUnitId: (0, typeorm_2.In)([...orgUnitIds]) },
|
|
3331
|
+
where: { orgUnitId: (0, typeorm_2.In)([...scope.orgUnitIds]) },
|
|
3101
3332
|
});
|
|
3102
3333
|
const memberIds = memberships
|
|
3103
3334
|
.filter((membership) => isDateRangeActive(membership, today))
|
|
3104
3335
|
.sort(compareMembership)
|
|
3105
3336
|
.map((membership) => membership.memberId);
|
|
3106
3337
|
if (memberIds.length === 0) {
|
|
3107
|
-
throw new common_1.ConflictException(`${label}
|
|
3338
|
+
throw new common_1.ConflictException(`${label} 找不到${subject}:組織「${scope.name}」目前沒有任何有效成員,請先將人員加入該組織`);
|
|
3108
3339
|
}
|
|
3109
3340
|
return uniqueTexts(memberIds);
|
|
3110
3341
|
}
|
|
3111
|
-
async resolveOrgUnitPositionCandidates(manager, orgUnitId, positionId, includeDescendants, label) {
|
|
3112
|
-
const
|
|
3113
|
-
|
|
3342
|
+
async resolveOrgUnitPositionCandidates(manager, orgUnitId, positionId, includeDescendants, label, subject) {
|
|
3343
|
+
const scope = await this.readOrgUnitScope(manager, orgUnitId, includeDescendants);
|
|
3344
|
+
if (!scope) {
|
|
3345
|
+
throw new common_1.ConflictException(readMissingOrgUnitMessage(label, subject, orgUnitId));
|
|
3346
|
+
}
|
|
3347
|
+
const today = this.readOrganizationToday();
|
|
3114
3348
|
const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
|
|
3115
|
-
where: { orgUnitId: (0, typeorm_2.In)([...orgUnitIds]), positionId },
|
|
3349
|
+
where: { orgUnitId: (0, typeorm_2.In)([...scope.orgUnitIds]), positionId },
|
|
3116
3350
|
});
|
|
3117
3351
|
const memberIds = memberships
|
|
3118
3352
|
.filter((membership) => isDateRangeActive(membership, today))
|
|
3119
3353
|
.sort(compareMembership)
|
|
3120
3354
|
.map((membership) => membership.memberId);
|
|
3121
3355
|
if (memberIds.length === 0) {
|
|
3122
|
-
throw new common_1.ConflictException(`${label}
|
|
3356
|
+
throw new common_1.ConflictException(`${label} 找不到${subject}:組織「${scope.name}」的職位「${await this.readPositionName(manager, positionId)}」目前沒有任何有效成員`);
|
|
3123
3357
|
}
|
|
3124
3358
|
return uniqueTexts(memberIds);
|
|
3125
3359
|
}
|
|
3126
|
-
|
|
3360
|
+
// Names only make an error readable, so a row that has gone missing falls
|
|
3361
|
+
// back to its id instead of hiding the real problem.
|
|
3362
|
+
async readPositionName(manager, positionId) {
|
|
3363
|
+
const position = await manager
|
|
3364
|
+
.getRepository(position_entity_1.PositionEntity)
|
|
3365
|
+
.findOne({ where: { id: positionId } });
|
|
3366
|
+
return position?.name ?? positionId;
|
|
3367
|
+
}
|
|
3368
|
+
/** The org unit's name and the ids it covers, or `null` once it is gone. */
|
|
3369
|
+
async readOrgUnitScope(manager, orgUnitId, includeDescendants) {
|
|
3127
3370
|
const orgUnitRepository = manager.getRepository(org_unit_entity_1.OrgUnitEntity);
|
|
3128
3371
|
const orgUnit = await orgUnitRepository.findOne({
|
|
3129
3372
|
where: { deletedAt: (0, typeorm_2.IsNull)(), id: orgUnitId },
|
|
3130
3373
|
});
|
|
3131
3374
|
if (!orgUnit) {
|
|
3132
|
-
|
|
3375
|
+
return null;
|
|
3133
3376
|
}
|
|
3134
3377
|
if (!includeDescendants) {
|
|
3135
|
-
return [orgUnit.id];
|
|
3378
|
+
return { name: orgUnit.name, orgUnitIds: [orgUnit.id] };
|
|
3136
3379
|
}
|
|
3137
3380
|
const descendants = await orgUnitRepository
|
|
3138
3381
|
.createQueryBuilder('orgUnit')
|
|
3139
3382
|
.where('orgUnit.deleted_at IS NULL')
|
|
3140
3383
|
.andWhere('orgUnit.path <@ :path', { path: orgUnit.path })
|
|
3141
3384
|
.getMany();
|
|
3142
|
-
return
|
|
3385
|
+
return {
|
|
3386
|
+
name: orgUnit.name,
|
|
3387
|
+
orgUnitIds: uniqueTexts([
|
|
3388
|
+
orgUnit.id,
|
|
3389
|
+
...descendants.map((unit) => unit.id),
|
|
3390
|
+
]),
|
|
3391
|
+
};
|
|
3143
3392
|
}
|
|
3144
3393
|
async processExclusiveGateway(manager, instance, token, node) {
|
|
3145
3394
|
if (node.data.direction !== 'split') {
|
|
@@ -3221,7 +3470,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3221
3470
|
const recipients = (0, workflow_graph_1.isNotifyRecipientsEmpty)(action.recipients) &&
|
|
3222
3471
|
(0, workflow_graph_1.readNotifyWebhookTargets)(action).length > 0
|
|
3223
3472
|
? []
|
|
3224
|
-
: await this.resolveApproverResolver(manager, instance, action.recipients, `知會節點「${node.data.label}
|
|
3473
|
+
: await this.resolveApproverResolver(manager, instance, action.recipients, `知會節點「${node.data.label}」`, '通知對象');
|
|
3225
3474
|
const recipientMemberIds = uniqueTexts(recipients.map((recipient) => recipient.memberId));
|
|
3226
3475
|
await this.notificationService.createServiceTaskNotifications({
|
|
3227
3476
|
instance,
|
|
@@ -3249,11 +3498,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3249
3498
|
tokenId: token.id,
|
|
3250
3499
|
}, action.webhooks)
|
|
3251
3500
|
: [];
|
|
3252
|
-
await
|
|
3253
|
-
...token,
|
|
3254
|
-
consumedAt: new Date(),
|
|
3255
|
-
status: workflow_engine_enums_1.WorkflowTokenStatusEnum.CONSUMED,
|
|
3256
|
-
});
|
|
3501
|
+
await this.consumeToken(manager, token);
|
|
3257
3502
|
await activityRepository.save(activityRepository.create({
|
|
3258
3503
|
actorMemberId: null,
|
|
3259
3504
|
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TOKEN_ADVANCED,
|
|
@@ -3780,6 +4025,58 @@ function buildDryRunExpressionContext(input, lastDecision) {
|
|
|
3780
4025
|
function isRecord(value) {
|
|
3781
4026
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3782
4027
|
}
|
|
4028
|
+
// The members whose grades decide a grade-gated delegation: none when the
|
|
4029
|
+
// delegation leaves the seat with its principal.
|
|
4030
|
+
function readGradeGateMemberIds(principalMemberId, resolution) {
|
|
4031
|
+
return resolution.finalAssigneeMemberId === principalMemberId
|
|
4032
|
+
? []
|
|
4033
|
+
: [principalMemberId, resolution.finalAssigneeMemberId];
|
|
4034
|
+
}
|
|
4035
|
+
/**
|
|
4036
|
+
* On a manager-chain node the delegate must hold at least the grade of the
|
|
4037
|
+
* member they replace: anything lower is not applied, the member stays and
|
|
4038
|
+
* the refused delegation is returned so it can be recorded. Resolution does
|
|
4039
|
+
* not walk further down the delegation chain once it has been refused. A
|
|
4040
|
+
* member without a grade (or a delegate that stays the member) is never
|
|
4041
|
+
* constrained. `levels` holds the grades of `readGradeGateMemberIds`.
|
|
4042
|
+
*/
|
|
4043
|
+
function applyGradeGate(principalMemberId, resolution, gradeGated, levels) {
|
|
4044
|
+
if (!gradeGated || resolution.finalAssigneeMemberId === principalMemberId) {
|
|
4045
|
+
return resolution;
|
|
4046
|
+
}
|
|
4047
|
+
const principalLevel = levels.get(principalMemberId) ?? null;
|
|
4048
|
+
if (principalLevel === null) {
|
|
4049
|
+
return resolution;
|
|
4050
|
+
}
|
|
4051
|
+
const agentLevel = levels.get(resolution.finalAssigneeMemberId) ?? null;
|
|
4052
|
+
if (agentLevel !== null && agentLevel >= principalLevel) {
|
|
4053
|
+
return resolution;
|
|
4054
|
+
}
|
|
4055
|
+
return {
|
|
4056
|
+
delegationChain: [],
|
|
4057
|
+
finalAssigneeMemberId: principalMemberId,
|
|
4058
|
+
skippedDelegation: {
|
|
4059
|
+
agentLevel,
|
|
4060
|
+
agentMemberId: resolution.finalAssigneeMemberId,
|
|
4061
|
+
principalLevel,
|
|
4062
|
+
principalMemberId,
|
|
4063
|
+
reason: 'AGENT_POSITION_LEVEL_TOO_LOW',
|
|
4064
|
+
},
|
|
4065
|
+
};
|
|
4066
|
+
}
|
|
4067
|
+
function readNodeLabelKey(instanceId, nodeId) {
|
|
4068
|
+
return `${instanceId}\u0000${nodeId}`;
|
|
4069
|
+
}
|
|
4070
|
+
/**
|
|
4071
|
+
* The node's display name from an instance's workflow snapshot, or `null`
|
|
4072
|
+
* when the instance or node is missing, or the node carries no usable label
|
|
4073
|
+
* (older snapshots).
|
|
4074
|
+
*/
|
|
4075
|
+
function readWorkflowNodeLabel(workflow, nodeId) {
|
|
4076
|
+
const label = workflow?.nodes.find((candidate) => candidate.id === nodeId)
|
|
4077
|
+
?.data.label;
|
|
4078
|
+
return typeof label === 'string' && label.trim() ? label : null;
|
|
4079
|
+
}
|
|
3783
4080
|
function readWorkflowNodeOrThrow(workflow, nodeId) {
|
|
3784
4081
|
const node = workflow.nodes.find((candidate) => candidate.id === nodeId);
|
|
3785
4082
|
if (!node) {
|
|
@@ -3880,22 +4177,26 @@ function edgeHasCondition(edge) {
|
|
|
3880
4177
|
return Boolean(edge.data.condition?.trim() ||
|
|
3881
4178
|
(edge.data.conditionFieldKey && edge.data.conditionOperator));
|
|
3882
4179
|
}
|
|
3883
|
-
function
|
|
3884
|
-
return
|
|
4180
|
+
function readMissingOrgUnitMessage(label, subject, orgUnitId) {
|
|
4181
|
+
return `${label} 找不到${subject}:組織(${orgUnitId})已不存在,請在流程設計器重新選擇組織`;
|
|
3885
4182
|
}
|
|
3886
|
-
function
|
|
4183
|
+
function findMemberIdsInValue(value) {
|
|
3887
4184
|
if (typeof value === 'string' && value.trim()) {
|
|
3888
4185
|
return [value.trim()];
|
|
3889
4186
|
}
|
|
3890
4187
|
if (Array.isArray(value)) {
|
|
3891
|
-
|
|
4188
|
+
return uniqueTexts(value
|
|
3892
4189
|
.filter((candidate) => typeof candidate === 'string' && Boolean(candidate.trim()))
|
|
3893
|
-
.map((candidate) => candidate.trim());
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
4190
|
+
.map((candidate) => candidate.trim()));
|
|
4191
|
+
}
|
|
4192
|
+
return [];
|
|
4193
|
+
}
|
|
4194
|
+
function readMemberIdsFromValue(value, label, subject = '簽核人', source = '') {
|
|
4195
|
+
const memberIds = findMemberIdsInValue(value);
|
|
4196
|
+
if (memberIds.length === 0) {
|
|
4197
|
+
throw new common_1.ConflictException(`${label} ${source}找不到${subject}`);
|
|
3897
4198
|
}
|
|
3898
|
-
|
|
4199
|
+
return memberIds;
|
|
3899
4200
|
}
|
|
3900
4201
|
function uniqueTexts(values) {
|
|
3901
4202
|
return [...new Set(values.filter((value) => value.trim()))];
|
|
@@ -3921,20 +4222,62 @@ function isOrderedSequentialTask(task, candidates) {
|
|
|
3921
4222
|
function isInitiatorExcluded(node) {
|
|
3922
4223
|
return Boolean(node.data.excludeInitiator);
|
|
3923
4224
|
}
|
|
4225
|
+
// Everyone a node that excludes the initiator keeps out of its approvers: the
|
|
4226
|
+
// applicant and, when someone filed the document for them, that person too
|
|
4227
|
+
// (authority rule: the creator must not approve what they created).
|
|
4228
|
+
function readInitiatorAndSubmitter(instance) {
|
|
4229
|
+
const submitterMemberId = (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance);
|
|
4230
|
+
return submitterMemberId === null
|
|
4231
|
+
? [instance.initiatorMemberId]
|
|
4232
|
+
: [instance.initiatorMemberId, submitterMemberId];
|
|
4233
|
+
}
|
|
4234
|
+
function isOrgManagerChainNode(node) {
|
|
4235
|
+
return node.data.approverResolver.type === 'ORG_MANAGER_CHAIN';
|
|
4236
|
+
}
|
|
4237
|
+
// Candidates of a manager-chain node that came from the chain (or replaced the
|
|
4238
|
+
// excluded initiator's seat); ad-hoc approvers are chosen by a person instead.
|
|
4239
|
+
function isGradeGatedCandidate(node, sourceType) {
|
|
4240
|
+
return (isOrgManagerChainNode(node) &&
|
|
4241
|
+
(sourceType === 'ORG_MANAGER_CHAIN' ||
|
|
4242
|
+
sourceType === 'EXCLUDE_INITIATOR_MANAGER'));
|
|
4243
|
+
}
|
|
4244
|
+
// Source of the additive `skippedDelegations` payload field (absent when
|
|
4245
|
+
// nothing was refused, so existing payloads stay byte-identical). One record
|
|
4246
|
+
// per principal and delegate pair.
|
|
4247
|
+
function collectSkippedDelegations(delegatedCandidates, excludeInitiator, excludedMemberIds) {
|
|
4248
|
+
const seen = new Set();
|
|
4249
|
+
return delegatedCandidates.flatMap((candidate) => {
|
|
4250
|
+
const skipped = candidate.skippedDelegation;
|
|
4251
|
+
// A seat the initiator filter removes is not on the task, so a refusal on
|
|
4252
|
+
// it has nothing to report. Rows only the dedupe drops are still on it.
|
|
4253
|
+
if (!skipped ||
|
|
4254
|
+
(excludeInitiator &&
|
|
4255
|
+
isExcludedMemberOnCandidatePath(candidate, excludedMemberIds))) {
|
|
4256
|
+
return [];
|
|
4257
|
+
}
|
|
4258
|
+
const key = `${skipped.principalMemberId}\u0000${skipped.agentMemberId}`;
|
|
4259
|
+
if (seen.has(key)) {
|
|
4260
|
+
return [];
|
|
4261
|
+
}
|
|
4262
|
+
seen.add(key);
|
|
4263
|
+
return [skipped];
|
|
4264
|
+
});
|
|
4265
|
+
}
|
|
3924
4266
|
// A delegate acting for the initiator is still the initiator approving their
|
|
3925
4267
|
// own document, so the initiator anywhere on the delegation path (the
|
|
3926
4268
|
// pre-delegation member, any hop, or the final assignee) drops the candidate.
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
candidate.
|
|
4269
|
+
// The submitter is held to exactly the same rule.
|
|
4270
|
+
function isExcludedMemberOnCandidatePath(candidate, excludedMemberIds) {
|
|
4271
|
+
return excludedMemberIds.some((excludedMemberId) => candidate.memberId === excludedMemberId ||
|
|
4272
|
+
candidate.originalMemberId === excludedMemberId ||
|
|
4273
|
+
candidate.delegationChain.some((step) => step.from === excludedMemberId || step.to === excludedMemberId));
|
|
3931
4274
|
}
|
|
3932
4275
|
// The initiator filter runs on the delegated list before the dedupe, so a
|
|
3933
4276
|
// legitimate approver is never shadowed by an excluded entry for the same
|
|
3934
4277
|
// final assignee. Without the exclusion this is the plain dedupe.
|
|
3935
|
-
function selectNodeCandidates(delegatedCandidates, excludeInitiator,
|
|
4278
|
+
function selectNodeCandidates(delegatedCandidates, excludeInitiator, excludedMemberIds) {
|
|
3936
4279
|
return uniqueRuntimeCandidates(excludeInitiator
|
|
3937
|
-
? delegatedCandidates.filter((candidate) => !
|
|
4280
|
+
? delegatedCandidates.filter((candidate) => !isExcludedMemberOnCandidatePath(candidate, excludedMemberIds))
|
|
3938
4281
|
: delegatedCandidates);
|
|
3939
4282
|
}
|
|
3940
4283
|
// A COUNT threshold above the people left would never be reached once the
|
|
@@ -4363,9 +4706,6 @@ function compareMembership(left, right) {
|
|
|
4363
4706
|
}
|
|
4364
4707
|
return right.effectiveFrom.localeCompare(left.effectiveFrom);
|
|
4365
4708
|
}
|
|
4366
|
-
function toDateOnlyString(value) {
|
|
4367
|
-
return value.toISOString().slice(0, 10);
|
|
4368
|
-
}
|
|
4369
4709
|
function canReadAllWorkflows(scope) {
|
|
4370
4710
|
return (scope.roles.includes('BPM_ADMIN') ||
|
|
4371
4711
|
scope.permissions.some((permission) => WORKFLOW_READ_ALL_PERMISSIONS.has(permission)));
|
|
@@ -4396,9 +4736,26 @@ function validateSubmittedFormData(schema, formData) {
|
|
|
4396
4736
|
.map((field) => field.label || field.fieldKey)
|
|
4397
4737
|
.join(', ')}`);
|
|
4398
4738
|
}
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4739
|
+
// A `DYNAMIC_FORM` resolver reads a member value whether or not its field is
|
|
4740
|
+
// visible, so a hidden field still holding a value must be well-formed too.
|
|
4741
|
+
// Whether a hidden field is *required* stays a visibility question above.
|
|
4742
|
+
const memberErrors = (0, form_member_value_validator_1.readSubmittedMemberValueErrors)(schema.fields.filter((field) => visibleFields.includes(field) ||
|
|
4743
|
+
isSubmittedFieldValuePresent(formData[field.fieldKey])), formData);
|
|
4744
|
+
if (memberErrors.length > 0) {
|
|
4745
|
+
throw new common_1.BadRequestException(memberErrors.join('; '));
|
|
4746
|
+
}
|
|
4747
|
+
const tableErrors = [
|
|
4748
|
+
...visibleFields
|
|
4749
|
+
.filter(form_1.isTableFieldDefinition)
|
|
4750
|
+
.flatMap((field) => validateSubmittedTableValue(field, formData[field.fieldKey], isSubmittedFormFieldRequired(field, schema.fields, formData))),
|
|
4751
|
+
// A hidden table is not "visible", but its stored value still has to be a
|
|
4752
|
+
// well-formed table under the row cap: only the required / minimum checks
|
|
4753
|
+
// are skipped, since nobody can fill the table in.
|
|
4754
|
+
...schema.fields
|
|
4755
|
+
.filter(form_1.isTableFieldDefinition)
|
|
4756
|
+
.filter((field) => field.hidden === true)
|
|
4757
|
+
.flatMap((field) => validateSubmittedTableValue(field, formData[field.fieldKey], false, true)),
|
|
4758
|
+
];
|
|
4402
4759
|
if (tableErrors.length > 0) {
|
|
4403
4760
|
throw new common_1.BadRequestException(tableErrors.join('; '));
|
|
4404
4761
|
}
|
|
@@ -4409,28 +4766,32 @@ function validateSubmittedFormData(schema, formData) {
|
|
|
4409
4766
|
* required / shape / row count are enforced server side; `minimum`, `maxLength`
|
|
4410
4767
|
* and friends stay a frontend hint.
|
|
4411
4768
|
*/
|
|
4412
|
-
function validateSubmittedTableValue(field, value, required) {
|
|
4769
|
+
function validateSubmittedTableValue(field, value, required, shapeOnly = false) {
|
|
4413
4770
|
const path = `formData.${field.fieldKey}`;
|
|
4414
4771
|
if (typeof value === 'undefined' || value === null) {
|
|
4415
|
-
return
|
|
4416
|
-
? [
|
|
4417
|
-
:
|
|
4772
|
+
return shapeOnly
|
|
4773
|
+
? []
|
|
4774
|
+
: required || readTableMinRows(field) > 0
|
|
4775
|
+
? [`${path} must include at least ${readTableRequiredRowCount(field)} row(s)`]
|
|
4776
|
+
: [];
|
|
4418
4777
|
}
|
|
4419
4778
|
if (!Array.isArray(value)) {
|
|
4420
4779
|
return [`${path} must be an array of rows`];
|
|
4421
4780
|
}
|
|
4422
|
-
const rowErrors = value.flatMap((row, index) => validateSubmittedTableRow(field, row, `${path}[${index}]
|
|
4781
|
+
const rowErrors = value.flatMap((row, index) => validateSubmittedTableRow(field, row, `${path}[${index}]`, shapeOnly));
|
|
4423
4782
|
// A malformed row cannot be counted against min/max meaningfully; report the
|
|
4424
4783
|
// shape problem first so the submitter fixes the real cause.
|
|
4425
4784
|
if (rowErrors.length > 0) {
|
|
4426
4785
|
return rowErrors;
|
|
4427
4786
|
}
|
|
4428
|
-
return validateSubmittedTableRowCount(field, value.length, required, path);
|
|
4787
|
+
return validateSubmittedTableRowCount(field, value.length, required, path, shapeOnly);
|
|
4429
4788
|
}
|
|
4430
|
-
function validateSubmittedTableRowCount(field, rowCount, required, path) {
|
|
4431
|
-
const minRows =
|
|
4432
|
-
?
|
|
4433
|
-
:
|
|
4789
|
+
function validateSubmittedTableRowCount(field, rowCount, required, path, shapeOnly = false) {
|
|
4790
|
+
const minRows = shapeOnly
|
|
4791
|
+
? 0
|
|
4792
|
+
: required
|
|
4793
|
+
? Math.max(readTableMinRows(field), 1)
|
|
4794
|
+
: readTableMinRows(field);
|
|
4434
4795
|
const maxRows = readTableMaxRows(field);
|
|
4435
4796
|
return [
|
|
4436
4797
|
...(rowCount < minRows
|
|
@@ -4441,7 +4802,7 @@ function validateSubmittedTableRowCount(field, rowCount, required, path) {
|
|
|
4441
4802
|
: []),
|
|
4442
4803
|
];
|
|
4443
4804
|
}
|
|
4444
|
-
function validateSubmittedTableRow(field, row, path) {
|
|
4805
|
+
function validateSubmittedTableRow(field, row, path, shapeOnly = false) {
|
|
4445
4806
|
if (!isRecord(row)) {
|
|
4446
4807
|
return [`${path} must be an object`];
|
|
4447
4808
|
}
|
|
@@ -4452,10 +4813,12 @@ function validateSubmittedTableRow(field, row, path) {
|
|
|
4452
4813
|
const cellErrors = Object.entries(row)
|
|
4453
4814
|
.filter(([columnKey, cellValue]) => columnsByKey.has(columnKey) && !(0, form_1.isFormTableCellValue)(cellValue))
|
|
4454
4815
|
.map(([columnKey]) => `${path}.${columnKey} is not a valid cell value`);
|
|
4455
|
-
const requiredErrors =
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4816
|
+
const requiredErrors = shapeOnly
|
|
4817
|
+
? []
|
|
4818
|
+
: field.columns
|
|
4819
|
+
.filter((column) => isSubmittedTableColumnRequired(column) &&
|
|
4820
|
+
!isSubmittedFieldValuePresent(readRowCellValue(row, column.fieldKey)))
|
|
4821
|
+
.map((column) => `${path}.${column.fieldKey} is required`);
|
|
4459
4822
|
return [...unknownKeyErrors, ...cellErrors, ...requiredErrors];
|
|
4460
4823
|
}
|
|
4461
4824
|
/**
|
|
@@ -4500,6 +4863,11 @@ function isFormDefinitionSchema(value) {
|
|
|
4500
4863
|
value.fields.every((field) => isRecord(field)));
|
|
4501
4864
|
}
|
|
4502
4865
|
function isSubmittedFormFieldVisible(field, fields, values) {
|
|
4866
|
+
// A hidden (store-only) field is never shown, so nobody can fill it in:
|
|
4867
|
+
// treat it like a false `visibleWhen`. Its submitted value is still kept.
|
|
4868
|
+
if (field.hidden) {
|
|
4869
|
+
return false;
|
|
4870
|
+
}
|
|
4503
4871
|
return field.visibleWhen
|
|
4504
4872
|
? evaluateFormConditionExpression(field.visibleWhen, fields, values, true)
|
|
4505
4873
|
: true;
|
|
@@ -4627,6 +4995,84 @@ function isSubmittedFieldValuePresent(value) {
|
|
|
4627
4995
|
function isNumberFormField(field) {
|
|
4628
4996
|
return field.type === 'number' || field.type === 'money';
|
|
4629
4997
|
}
|
|
4998
|
+
// A node that is skipped and then has nowhere to go keeps saying why it was
|
|
4999
|
+
// skipped, then why the branch ends; any other node just says why it ends.
|
|
5000
|
+
function readDryRunStepResultMessage(assignmentMessage, stopMessage, defaultMessage, skipped) {
|
|
5001
|
+
const message = assignmentMessage ?? defaultMessage;
|
|
5002
|
+
if (stopMessage === null) {
|
|
5003
|
+
return message;
|
|
5004
|
+
}
|
|
5005
|
+
return skipped ? `${endSentence(message)}${stopMessage}` : stopMessage;
|
|
5006
|
+
}
|
|
5007
|
+
// The skip reason and the stop reason are two sentences; a reason that does not
|
|
5008
|
+
// already end in full-stop punctuation gets one so they do not run together.
|
|
5009
|
+
function endSentence(message) {
|
|
5010
|
+
return /[。!?.!?]$/.test(message) ? message : `${message}。`;
|
|
5011
|
+
}
|
|
5012
|
+
// Why a manager resolver's fixed fallback cannot take over when the initiator
|
|
5013
|
+
// has no manager, or `null` when it can: like the runtime, it must exist, name
|
|
5014
|
+
// a member, and not be the initiator unless the node allows self-approval.
|
|
5015
|
+
function readDryRunFallbackProblem(initiatorMemberId, fallback) {
|
|
5016
|
+
if (!fallback || fallback.type !== 'DIRECT') {
|
|
5017
|
+
return 'NOT_SET';
|
|
5018
|
+
}
|
|
5019
|
+
const memberId = fallback.memberId.trim();
|
|
5020
|
+
if (memberId === '') {
|
|
5021
|
+
return 'BLANK_MEMBER';
|
|
5022
|
+
}
|
|
5023
|
+
return memberId === initiatorMemberId && !fallback.allowInitiatorSelfApproval
|
|
5024
|
+
? 'IS_INITIATOR'
|
|
5025
|
+
: null;
|
|
5026
|
+
}
|
|
5027
|
+
function readDryRunFallbackMemberIds(input, fallback) {
|
|
5028
|
+
return readDryRunFallbackProblem(input.initiatorMemberId, fallback) ===
|
|
5029
|
+
null && fallback?.type === 'DIRECT'
|
|
5030
|
+
? [fallback.memberId.trim()]
|
|
5031
|
+
: [];
|
|
5032
|
+
}
|
|
5033
|
+
function readDryRunNodeName(node) {
|
|
5034
|
+
return node.data.label?.trim() || node.id;
|
|
5035
|
+
}
|
|
5036
|
+
// The dry run cannot look the organisation up, so these resolvers stand in
|
|
5037
|
+
// with a placeholder that names what the runtime would resolve.
|
|
5038
|
+
function readDryRunPlaceholderAssignee(resolver) {
|
|
5039
|
+
if (resolver.type === 'POSITION') {
|
|
5040
|
+
return `position:${resolver.positionId}`;
|
|
5041
|
+
}
|
|
5042
|
+
if (resolver.type === 'ORG_UNIT_MEMBER') {
|
|
5043
|
+
return `orgUnitMember:${resolver.orgUnitId}`;
|
|
5044
|
+
}
|
|
5045
|
+
if (resolver.type === 'ORG_UNIT_POSITION') {
|
|
5046
|
+
return `orgUnitPosition:${resolver.orgUnitId}:${resolver.positionId}`;
|
|
5047
|
+
}
|
|
5048
|
+
if (resolver.type === 'ORG_UNIT_MANAGER') {
|
|
5049
|
+
return `orgUnitManager:${resolver.orgUnitId}`;
|
|
5050
|
+
}
|
|
5051
|
+
return resolver.type === 'ORG_MANAGER_CHAIN'
|
|
5052
|
+
? `orgManagerChain:${resolver.minPositionLevel}`
|
|
5053
|
+
: resolver.type;
|
|
5054
|
+
}
|
|
5055
|
+
function readDryRunUnresolvedApproverMessage(resolver, nodeName, initiatorMemberId) {
|
|
5056
|
+
if (resolver.type === 'DIRECT') {
|
|
5057
|
+
return `簽核節點「${nodeName}」尚未選擇簽核人,請在流程設計器點選此節點設定簽核人`;
|
|
5058
|
+
}
|
|
5059
|
+
if (resolver.type === 'DYNAMIC_FORM') {
|
|
5060
|
+
return `簽核節點「${nodeName}」依表單欄位找不到簽核人,請確認試跑資料中該欄位有填人員`;
|
|
5061
|
+
}
|
|
5062
|
+
if (resolver.type === 'EXPRESSION') {
|
|
5063
|
+
return `簽核節點「${nodeName}」依運算式找不到簽核人,請確認運算式的結果`;
|
|
5064
|
+
}
|
|
5065
|
+
const fallbackProblem = resolver.type === 'ORG_MANAGER'
|
|
5066
|
+
? readDryRunFallbackProblem(initiatorMemberId, resolver.fallback)
|
|
5067
|
+
: null;
|
|
5068
|
+
if (fallbackProblem === 'BLANK_MEMBER') {
|
|
5069
|
+
return `簽核節點「${nodeName}」找不到主管,且改派固定人未設定`;
|
|
5070
|
+
}
|
|
5071
|
+
if (fallbackProblem === 'IS_INITIATOR') {
|
|
5072
|
+
return `簽核節點「${nodeName}」找不到主管,且改派固定人是申請人本人,此節點未允許申請人自簽`;
|
|
5073
|
+
}
|
|
5074
|
+
return `簽核節點「${nodeName}」依申請人的組織找不到主管,請確認試跑時的申請人資料`;
|
|
5075
|
+
}
|
|
4630
5076
|
function createDryRunStep(input) {
|
|
4631
5077
|
return Object.assign(new workflow_dry_run_object_1.WorkflowDryRunStepObject(), {
|
|
4632
5078
|
assigneeMemberId: input.assigneeMemberId,
|
|
@@ -4703,4 +5149,34 @@ function mapDecisionToResolution(action) {
|
|
|
4703
5149
|
return notification_enums_1.NotificationResolutionEnum.TRANSFERRED;
|
|
4704
5150
|
}
|
|
4705
5151
|
}
|
|
5152
|
+
// The limit is stated in characters, so a character outside the BMP counts
|
|
5153
|
+
// once rather than as its two UTF-16 code units.
|
|
5154
|
+
function countCodePoints(value) {
|
|
5155
|
+
return Array.from(value).length;
|
|
5156
|
+
}
|
|
5157
|
+
function normalizeBusinessKey(businessKey) {
|
|
5158
|
+
const trimmed = businessKey?.trim() ?? '';
|
|
5159
|
+
if (trimmed === '') {
|
|
5160
|
+
return null;
|
|
5161
|
+
}
|
|
5162
|
+
if (countCodePoints(trimmed) > exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH) {
|
|
5163
|
+
throw new common_1.BadRequestException(`業務鍵長度不可超過 ${exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH} 個字元`);
|
|
5164
|
+
}
|
|
5165
|
+
return trimmed;
|
|
5166
|
+
}
|
|
5167
|
+
/**
|
|
5168
|
+
* Turns Postgres rejecting a duplicate (template, business key) into a
|
|
5169
|
+
* Conflict. Any other failure, including other unique violations inside the
|
|
5170
|
+
* same transaction, surfaces as it is.
|
|
5171
|
+
*/
|
|
5172
|
+
function throwBusinessKeyConflictOrRethrow(error, businessKey) {
|
|
5173
|
+
const { code, constraint, driverError } = (error ?? {});
|
|
5174
|
+
if (businessKey !== null &&
|
|
5175
|
+
(code ?? driverError?.code) === '23505' &&
|
|
5176
|
+
(constraint ?? driverError?.constraint) ===
|
|
5177
|
+
APPROVAL_INSTANCE_BUSINESS_KEY_INDEX) {
|
|
5178
|
+
throw new common_1.ConflictException(`此範本已有相同業務鍵(${businessKey})的簽核單`);
|
|
5179
|
+
}
|
|
5180
|
+
throw error;
|
|
5181
|
+
}
|
|
4706
5182
|
//# sourceMappingURL=workflow-engine.service.js.map
|