@rytass/bpm-core-nestjs-module 0.13.5 → 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 +118 -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/0000000025000-task-candidate-sequence.d.ts +21 -0
- package/src/lib/migrations/0000000025000-task-candidate-sequence.js +50 -0
- package/src/lib/migrations/0000000025000-task-candidate-sequence.js.map +1 -0
- 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 +3 -0
- package/src/lib/migrations/index.js +9 -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 +56 -2
- 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/template/workflow-definition.validator.js +26 -1
- package/src/lib/template/workflow-definition.validator.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-candidate.entity.d.ts +2 -0
- package/src/lib/workflow-engine/task-candidate.entity.js +5 -0
- package/src/lib/workflow-engine/task-candidate.entity.js.map +1 -1
- package/src/lib/workflow-engine/task.entity.d.ts +20 -0
- package/src/lib/workflow-engine/task.entity.js +28 -0
- package/src/lib/workflow-engine/task.entity.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.enums.d.ts +5 -1
- package/src/lib/workflow-engine/workflow-engine.enums.js +4 -0
- package/src/lib/workflow-engine/workflow-engine.enums.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 +130 -4
- package/src/lib/workflow-engine/workflow-engine.service.js +1284 -179
- 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");
|
|
@@ -23,7 +25,9 @@ const condition_service_1 = require("../condition/condition.service");
|
|
|
23
25
|
const manager_resolution_entity_1 = require("../organization/manager-resolution.entity");
|
|
24
26
|
const membership_entity_1 = require("../organization/membership.entity");
|
|
25
27
|
const org_unit_entity_1 = require("../organization/org-unit.entity");
|
|
28
|
+
const position_entity_1 = require("../organization/position.entity");
|
|
26
29
|
const organization_enums_1 = require("../organization/organization.enums");
|
|
30
|
+
const organization_time_zone_1 = require("../organization/organization-time-zone");
|
|
27
31
|
const approval_template_version_entity_1 = require("../template/approval-template-version.entity");
|
|
28
32
|
const approval_template_entity_1 = require("../template/approval-template.entity");
|
|
29
33
|
const template_enums_1 = require("../template/template.enums");
|
|
@@ -31,6 +35,7 @@ const activity_log_entity_1 = require("./activity-log.entity");
|
|
|
31
35
|
const adhoc_directive_entity_1 = require("./adhoc-directive.entity");
|
|
32
36
|
const adhoc_enums_1 = require("./adhoc.enums");
|
|
33
37
|
const approval_instance_entity_1 = require("./approval-instance.entity");
|
|
38
|
+
const instance_submitter_1 = require("./instance-submitter");
|
|
34
39
|
const task_decision_entity_1 = require("./task-decision.entity");
|
|
35
40
|
const task_candidate_entity_1 = require("./task-candidate.entity");
|
|
36
41
|
const task_entity_1 = require("./task.entity");
|
|
@@ -41,6 +46,10 @@ const workflow_service_task_dispatcher_token_1 = require("./workflow-service-tas
|
|
|
41
46
|
const workflow_token_entity_1 = require("./workflow-token.entity");
|
|
42
47
|
const bpm_root_options_1 = require("../bpm/bpm-root-options");
|
|
43
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';
|
|
51
|
+
// Guards ORG_MANAGER_CHAIN against misconfigured manager rules that never end.
|
|
52
|
+
const ORG_MANAGER_CHAIN_MAX_HOPS = 20;
|
|
44
53
|
const DEFAULT_APPROVAL_HISTORY_TASK_LIMIT = 50;
|
|
45
54
|
const SYSTEM_DRY_RUN_INSTANCE_ID = 'dry-run-instance';
|
|
46
55
|
/**
|
|
@@ -89,9 +98,27 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
89
98
|
injectedServiceTaskDispatcher ??
|
|
90
99
|
rootOptions?.workflowServiceTaskDispatcher ??
|
|
91
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);
|
|
92
111
|
}
|
|
93
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
|
+
}
|
|
94
119
|
const formData = parseJsonObject(input.formDataJson, 'formDataJson');
|
|
120
|
+
const businessKey = normalizeBusinessKey(input.businessKey);
|
|
121
|
+
const submittedByMemberId = input.submittedByMemberId ?? input.initiatorMemberId;
|
|
95
122
|
const template = await this.getTemplateOrThrow(input.templateId);
|
|
96
123
|
if (!template.isActive) {
|
|
97
124
|
throw new common_1.ConflictException('Approval template is deactivated');
|
|
@@ -114,6 +141,12 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
114
141
|
if (!this.conditionService.evaluateBoolean(templateVersion.initiatorPolicyCel, buildInitiatorPolicyContext(initiatorMetadataSnapshot), 'initiatorPolicyCel')) {
|
|
115
142
|
throw new common_1.ConflictException('Current member is not allowed to start this approval template');
|
|
116
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);
|
|
117
150
|
const startedAt = new Date();
|
|
118
151
|
const startNode = templateVersion.workflowDefinition.nodes.find((node) => node.type === 'startEvent');
|
|
119
152
|
if (!startNode) {
|
|
@@ -133,10 +166,12 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
133
166
|
uiSchema: formDefinitionVersion.uiSchema,
|
|
134
167
|
version: formDefinitionVersion.version,
|
|
135
168
|
},
|
|
169
|
+
businessKey,
|
|
136
170
|
initiatorMemberId: input.initiatorMemberId,
|
|
137
171
|
initiatorMetadataSnapshot,
|
|
138
172
|
startedAt,
|
|
139
173
|
state: workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING,
|
|
174
|
+
submittedByMemberId,
|
|
140
175
|
templateId: template.id,
|
|
141
176
|
templateVersionId: templateVersion.id,
|
|
142
177
|
title: input.title?.trim() || template.name,
|
|
@@ -160,6 +195,8 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
160
195
|
instanceId: instance.id,
|
|
161
196
|
nodeId: startNode.id,
|
|
162
197
|
payload: {
|
|
198
|
+
submittedByMemberId,
|
|
199
|
+
...(submittedByName === null ? {} : { submittedByName }),
|
|
163
200
|
templateId: template.id,
|
|
164
201
|
templateVersionId: templateVersion.id,
|
|
165
202
|
},
|
|
@@ -178,6 +215,28 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
178
215
|
]);
|
|
179
216
|
await this.processRunningInstance(manager, instance);
|
|
180
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 },
|
|
181
240
|
});
|
|
182
241
|
}
|
|
183
242
|
async resolveFormDataOptionSnapshots({ authContext, formData, previousFormData, previousSnapshots, revalidateAll, schema, }) {
|
|
@@ -313,6 +372,35 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
313
372
|
taskNode.data.allowTransfer === false) {
|
|
314
373
|
throw new common_1.ForbiddenException(`Workflow node ${task.nodeId} does not allow transfer`);
|
|
315
374
|
}
|
|
375
|
+
// Only guards a human picking the target; SLA escalation passes a
|
|
376
|
+
// `transferReason` and is deliberately left alone.
|
|
377
|
+
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED &&
|
|
378
|
+
options.transferReason === undefined &&
|
|
379
|
+
isInitiatorExcluded(taskNode) &&
|
|
380
|
+
transferToMemberId === instance.initiatorMemberId) {
|
|
381
|
+
throw new common_1.ConflictException(`簽核節點「${taskNode.data.label}」不允許申請人自簽,不能將任務轉交給申請人本人`);
|
|
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
|
+
}
|
|
316
404
|
const returnToNodeId = input.action === workflow_engine_enums_1.TaskDecisionActionEnum.RETURNED
|
|
317
405
|
? readReturnTargetNodeId(instance.workflowSnapshot, task.nodeId, input.returnToNodeId)
|
|
318
406
|
: null;
|
|
@@ -422,6 +510,17 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
422
510
|
supersedeOthers: taskEnded,
|
|
423
511
|
taskId: completedTask.id,
|
|
424
512
|
});
|
|
513
|
+
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED &&
|
|
514
|
+
!shouldCompleteTask) {
|
|
515
|
+
await this.activateNextSequentialCandidate({
|
|
516
|
+
candidates: nextCandidateRows,
|
|
517
|
+
instance,
|
|
518
|
+
manager,
|
|
519
|
+
node: taskNode,
|
|
520
|
+
previousMemberId: input.decidedByMemberId,
|
|
521
|
+
task: completedTask,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
425
524
|
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED &&
|
|
426
525
|
shouldCompleteTask) {
|
|
427
526
|
// Ad-hoc gate: the token may only advance once every task bound to
|
|
@@ -592,6 +691,13 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
592
691
|
const targetValue = buildAdhocTargetValue(target);
|
|
593
692
|
// Rejects WEBHOOK targets — countersigners must be members.
|
|
594
693
|
buildAdhocApproverResolver(targetValue);
|
|
694
|
+
// The countersign task is only created when a later user task is
|
|
695
|
+
// reached, against that node's own setting. This checks the target
|
|
696
|
+
// against the node being decided now, as an early rejection for the
|
|
697
|
+
// common case; it is not a guarantee about the node it attaches to.
|
|
698
|
+
if (isInitiatorExcluded(node)) {
|
|
699
|
+
await this.resolveAdhocTaskCandidates(manager, instance, node, '臨時會簽', targetValue);
|
|
700
|
+
}
|
|
595
701
|
const directiveRepository = manager.getRepository(adhoc_directive_entity_1.AdhocDirectiveEntity);
|
|
596
702
|
const directive = await directiveRepository.save(directiveRepository.create({
|
|
597
703
|
channels: null,
|
|
@@ -707,6 +813,9 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
707
813
|
if (!isDirectAssignee && !actorCandidate) {
|
|
708
814
|
throw new common_1.ConflictException(`Task ${task.id} is assigned to another member`);
|
|
709
815
|
}
|
|
816
|
+
if (actorCandidate?.status === workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING) {
|
|
817
|
+
throw new common_1.ConflictException(`Task ${task.id} is not this member's turn yet`);
|
|
818
|
+
}
|
|
710
819
|
if (actorCandidate &&
|
|
711
820
|
actorCandidate.status !== workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING &&
|
|
712
821
|
actorCandidate.status !== workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED) {
|
|
@@ -771,11 +880,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
771
880
|
const label = directive.type === adhoc_enums_1.AdhocDirectiveTypeEnum.COUNTERSIGN
|
|
772
881
|
? '臨時會簽'
|
|
773
882
|
: '臨時加簽';
|
|
774
|
-
const
|
|
775
|
-
const candidates = await this.applyDelegationToResolvedCandidates(instance, node.id, resolvedCandidates);
|
|
776
|
-
if (candidates.length === 0) {
|
|
777
|
-
throw new common_1.ConflictException(`${label}「${node.data.label}」 did not resolve to any member id`);
|
|
778
|
-
}
|
|
883
|
+
const candidates = await this.resolveAdhocTaskCandidates(manager, instance, node, label, readAdhocTargetValue(directive));
|
|
779
884
|
const primaryCandidate = candidates[0];
|
|
780
885
|
const task = await taskRepository.save(taskRepository.create({
|
|
781
886
|
adhocDirectiveId: directive.id,
|
|
@@ -821,6 +926,8 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
821
926
|
taskId: task.id,
|
|
822
927
|
})));
|
|
823
928
|
task.candidateMemberIds = savedCandidates.map((candidate) => candidate.memberId);
|
|
929
|
+
task.openCandidateMemberIds = task.candidateMemberIds;
|
|
930
|
+
task.nodeLabel = readWorkflowNodeLabel(instance.workflowSnapshot, node.id);
|
|
824
931
|
await activityRepository.save(activityRepository.create({
|
|
825
932
|
actorMemberId: directive.createdByMemberId,
|
|
826
933
|
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_CREATED,
|
|
@@ -852,6 +959,21 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
852
959
|
}, Promise.resolve());
|
|
853
960
|
return task;
|
|
854
961
|
}
|
|
962
|
+
async resolveAdhocTaskCandidates(manager, instance, node, label, targetValue) {
|
|
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));
|
|
968
|
+
if (candidates.length > 0) {
|
|
969
|
+
return candidates;
|
|
970
|
+
}
|
|
971
|
+
throw new common_1.ConflictException(delegatedCandidates.length > 0
|
|
972
|
+
? (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) === null
|
|
973
|
+
? `${label}「${node.data.label}」 無法建立任務:指定對象只有申請人本人,此節點不允許申請人簽核。`
|
|
974
|
+
: `${label}「${node.data.label}」 無法建立任務:指定對象只有申請人或代為送出此單的人,此節點不允許申請人或代送人簽核。`
|
|
975
|
+
: `${label}「${node.data.label}」 did not resolve to any member id`);
|
|
976
|
+
}
|
|
855
977
|
async spawnCountersignTasksForNode(manager, instance, token, node, now) {
|
|
856
978
|
const directiveRepository = manager.getRepository(adhoc_directive_entity_1.AdhocDirectiveEntity);
|
|
857
979
|
const directives = await directiveRepository.find({
|
|
@@ -915,9 +1037,18 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
915
1037
|
const taskRepository = manager.getRepository(task_entity_1.TaskEntity);
|
|
916
1038
|
const taskCandidateRepository = manager.getRepository(task_candidate_entity_1.TaskCandidateEntity);
|
|
917
1039
|
const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
|
|
918
|
-
const
|
|
1040
|
+
const adhocOriginTask = task.adhocOriginTaskId
|
|
919
1041
|
? await taskRepository.findOne({ where: { id: task.adhocOriginTaskId } })
|
|
920
1042
|
: null;
|
|
1043
|
+
// transferTask re-points an open pre-approval when its origin is
|
|
1044
|
+
// transferred, but a pre-approval whose origin was transferred by a release
|
|
1045
|
+
// that did not do that yet still points at the TRANSFERRED row, and so does
|
|
1046
|
+
// the pointer on `task` when a transfer commits after decideTask read it
|
|
1047
|
+
// and before it took the instance lock. Following the transfer here hands
|
|
1048
|
+
// the decision to whoever holds the stage now in both cases.
|
|
1049
|
+
const originTask = adhocOriginTask
|
|
1050
|
+
? await this.readTransferSuccessorTask(manager, adhocOriginTask)
|
|
1051
|
+
: null;
|
|
921
1052
|
const originTaskIsOpen = originTask?.status === workflow_engine_enums_1.TaskStatusEnum.PENDING ||
|
|
922
1053
|
originTask?.status === workflow_engine_enums_1.TaskStatusEnum.IN_PROGRESS;
|
|
923
1054
|
// Safety: when there is nobody to hand the decision back to (origin task
|
|
@@ -927,21 +1058,43 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
927
1058
|
if (!originTask || (!originTaskIsOpen && !originTask.assigneeMemberId)) {
|
|
928
1059
|
return false;
|
|
929
1060
|
}
|
|
1061
|
+
const originCandidates = readDecisionPolicySnapshot(originTask).type === 'SEQUENTIAL'
|
|
1062
|
+
? await taskCandidateRepository.find({
|
|
1063
|
+
where: { taskId: originTask.id },
|
|
1064
|
+
})
|
|
1065
|
+
: [];
|
|
1066
|
+
const isSequentialOrigin = isOrderedSequentialTask(originTask, originCandidates);
|
|
930
1067
|
const node = readWorkflowNodeOrThrow(instance.workflowSnapshot, task.nodeId);
|
|
931
1068
|
if (!originTaskIsOpen && originTask.assigneeMemberId) {
|
|
932
1069
|
// The origin approver already decided — reopen a fresh decision task so
|
|
933
|
-
// the stage can be re-decided after the rejected pre-approval.
|
|
1070
|
+
// the stage can be re-decided after the rejected pre-approval. A
|
|
1071
|
+
// SEQUENTIAL task's assignee rotates and ends on the last signer, so it
|
|
1072
|
+
// goes back to whoever asked for the pre-approval instead.
|
|
1073
|
+
const requesterCandidate = isSequentialOrigin
|
|
1074
|
+
? await this.readSequentialRequesterCandidate(manager, originTask, originCandidates, directive.createdByMemberId)
|
|
1075
|
+
: undefined;
|
|
1076
|
+
// A sequential origin always reopens for the requester, even when the
|
|
1077
|
+
// successor of a transferred task has no row for them.
|
|
1078
|
+
const reopenedAssigneeMemberId = isSequentialOrigin
|
|
1079
|
+
? directive.createdByMemberId
|
|
1080
|
+
: originTask.assigneeMemberId;
|
|
1081
|
+
const reopenedOriginalMemberId = isSequentialOrigin
|
|
1082
|
+
? (requesterCandidate?.originalMemberId ?? directive.createdByMemberId)
|
|
1083
|
+
: originTask.originalAssigneeMemberId;
|
|
1084
|
+
const reopenedDelegationChain = isSequentialOrigin
|
|
1085
|
+
? (requesterCandidate?.delegationChain ?? [])
|
|
1086
|
+
: originTask.delegationChain;
|
|
934
1087
|
const reopenedTask = await taskRepository.save(taskRepository.create({
|
|
935
|
-
assigneeMemberId:
|
|
1088
|
+
assigneeMemberId: reopenedAssigneeMemberId,
|
|
936
1089
|
assignmentType: workflow_engine_enums_1.TaskAssignmentTypeEnum.DIRECT_MEMBER,
|
|
937
1090
|
completedAt: null,
|
|
938
1091
|
createdAt: decidedAt,
|
|
939
1092
|
decisionPolicySnapshot: { type: 'SINGLE' },
|
|
940
|
-
delegationChain:
|
|
1093
|
+
delegationChain: reopenedDelegationChain,
|
|
941
1094
|
instanceId: instance.id,
|
|
942
1095
|
nodeId: originTask.nodeId,
|
|
943
1096
|
openedAt: null,
|
|
944
|
-
originalAssigneeMemberId:
|
|
1097
|
+
originalAssigneeMemberId: reopenedOriginalMemberId,
|
|
945
1098
|
slaDueAt: null,
|
|
946
1099
|
status: workflow_engine_enums_1.TaskStatusEnum.PENDING,
|
|
947
1100
|
tokenId: originTask.tokenId,
|
|
@@ -950,9 +1103,9 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
950
1103
|
claimedAt: null,
|
|
951
1104
|
createdAt: decidedAt,
|
|
952
1105
|
decidedAt: null,
|
|
953
|
-
delegationChain:
|
|
954
|
-
memberId:
|
|
955
|
-
originalMemberId:
|
|
1106
|
+
delegationChain: reopenedDelegationChain,
|
|
1107
|
+
memberId: reopenedAssigneeMemberId,
|
|
1108
|
+
originalMemberId: reopenedOriginalMemberId ?? reopenedAssigneeMemberId,
|
|
956
1109
|
sourceType: 'DIRECT',
|
|
957
1110
|
status: workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING,
|
|
958
1111
|
taskId: reopenedTask.id,
|
|
@@ -964,7 +1117,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
964
1117
|
nodeId: originTask.nodeId,
|
|
965
1118
|
payload: {
|
|
966
1119
|
adhocDirectiveId: directive.id,
|
|
967
|
-
assigneeMemberId:
|
|
1120
|
+
assigneeMemberId: reopenedAssigneeMemberId,
|
|
968
1121
|
reopenedFromTaskId: originTask.id,
|
|
969
1122
|
rejectedAdhocTaskId: task.id,
|
|
970
1123
|
tokenId: originTask.tokenId,
|
|
@@ -980,7 +1133,14 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
980
1133
|
});
|
|
981
1134
|
}
|
|
982
1135
|
}
|
|
983
|
-
|
|
1136
|
+
// On a SEQUENTIAL task the requester has already signed and the task has
|
|
1137
|
+
// moved on to the next signer, so they are told as well. On any other task
|
|
1138
|
+
// the requester is the origin holder (or handed the stage away by a
|
|
1139
|
+
// transfer) and only whoever holds the task now is told.
|
|
1140
|
+
const recipientMemberIds = uniqueTexts([
|
|
1141
|
+
...(isSequentialOrigin ? [directive.createdByMemberId] : []),
|
|
1142
|
+
...(await this.readOpenTaskRecipientMemberIds(manager, originTask)),
|
|
1143
|
+
]);
|
|
984
1144
|
if (recipientMemberIds.length > 0) {
|
|
985
1145
|
await this.notificationService.createAdhocWorkflowNotifications({
|
|
986
1146
|
channels: null,
|
|
@@ -1012,6 +1172,89 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1012
1172
|
}));
|
|
1013
1173
|
return true;
|
|
1014
1174
|
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Follows a TRANSFERRED task to the task that replaced it, repeatedly, and
|
|
1177
|
+
* returns the last one reached. A transfer leaves no pointer on either task
|
|
1178
|
+
* row; the link is the `transferredFromTaskId` in the payload of the
|
|
1179
|
+
* TASK_CREATED activity written for the replacement. When the trail stops
|
|
1180
|
+
* (no such activity, or its task is gone) the last task reached is returned,
|
|
1181
|
+
* so a caller sees the transferred row exactly as it did before.
|
|
1182
|
+
*/
|
|
1183
|
+
async readTransferSuccessorTask(manager, task) {
|
|
1184
|
+
if (task.status !== workflow_engine_enums_1.TaskStatusEnum.TRANSFERRED) {
|
|
1185
|
+
return task;
|
|
1186
|
+
}
|
|
1187
|
+
const taskRepository = manager.getRepository(task_entity_1.TaskEntity);
|
|
1188
|
+
const taskCreatedActivities = await manager
|
|
1189
|
+
.getRepository(activity_log_entity_1.ActivityLogEntity)
|
|
1190
|
+
.find({
|
|
1191
|
+
where: {
|
|
1192
|
+
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_CREATED,
|
|
1193
|
+
instanceId: task.instanceId,
|
|
1194
|
+
nodeId: task.nodeId,
|
|
1195
|
+
},
|
|
1196
|
+
});
|
|
1197
|
+
const successorTaskIdByTransferredTaskId = new Map(taskCreatedActivities.flatMap((activity) => {
|
|
1198
|
+
const transferredFromTaskId = activity.payload['transferredFromTaskId'];
|
|
1199
|
+
return typeof transferredFromTaskId === 'string' && activity.taskId
|
|
1200
|
+
? [[transferredFromTaskId, activity.taskId]]
|
|
1201
|
+
: [];
|
|
1202
|
+
}));
|
|
1203
|
+
const followTransfer = async (currentTask, visitedTaskIds) => {
|
|
1204
|
+
const successorTaskId = currentTask.status === workflow_engine_enums_1.TaskStatusEnum.TRANSFERRED
|
|
1205
|
+
? successorTaskIdByTransferredTaskId.get(currentTask.id)
|
|
1206
|
+
: undefined;
|
|
1207
|
+
if (!successorTaskId || visitedTaskIds.has(successorTaskId)) {
|
|
1208
|
+
return currentTask;
|
|
1209
|
+
}
|
|
1210
|
+
const successorTask = await taskRepository.findOne({
|
|
1211
|
+
where: { id: successorTaskId },
|
|
1212
|
+
});
|
|
1213
|
+
return successorTask
|
|
1214
|
+
? followTransfer(successorTask, new Set([...visitedTaskIds, successorTaskId]))
|
|
1215
|
+
: currentTask;
|
|
1216
|
+
};
|
|
1217
|
+
return followTransfer(task, new Set([task.id]));
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* The requester's candidate row on a SEQUENTIAL origin task. A task that was
|
|
1221
|
+
* transferred mid-order continues as a replacement that carries only the
|
|
1222
|
+
* candidates still to sign, so a requester who already signed is found on an
|
|
1223
|
+
* earlier task of the transfer chain instead.
|
|
1224
|
+
*/
|
|
1225
|
+
async readSequentialRequesterCandidate(manager, originTask, originCandidates, requesterMemberId) {
|
|
1226
|
+
const ownCandidate = originCandidates.find((candidate) => candidate.memberId === requesterMemberId);
|
|
1227
|
+
if (ownCandidate) {
|
|
1228
|
+
return ownCandidate;
|
|
1229
|
+
}
|
|
1230
|
+
const taskCreatedActivities = await manager
|
|
1231
|
+
.getRepository(activity_log_entity_1.ActivityLogEntity)
|
|
1232
|
+
.find({
|
|
1233
|
+
where: {
|
|
1234
|
+
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_CREATED,
|
|
1235
|
+
instanceId: originTask.instanceId,
|
|
1236
|
+
nodeId: originTask.nodeId,
|
|
1237
|
+
},
|
|
1238
|
+
});
|
|
1239
|
+
const transferredFromTaskIdByTaskId = new Map(taskCreatedActivities.flatMap((activity) => {
|
|
1240
|
+
const transferredFromTaskId = activity.payload['transferredFromTaskId'];
|
|
1241
|
+
return typeof transferredFromTaskId === 'string' && activity.taskId
|
|
1242
|
+
? [[activity.taskId, transferredFromTaskId]]
|
|
1243
|
+
: [];
|
|
1244
|
+
}));
|
|
1245
|
+
const searchEarlierTasks = async (currentTaskId, visitedTaskIds) => {
|
|
1246
|
+
const earlierTaskId = transferredFromTaskIdByTaskId.get(currentTaskId);
|
|
1247
|
+
if (!earlierTaskId || visitedTaskIds.has(earlierTaskId)) {
|
|
1248
|
+
return undefined;
|
|
1249
|
+
}
|
|
1250
|
+
const earlierCandidate = (await manager
|
|
1251
|
+
.getRepository(task_candidate_entity_1.TaskCandidateEntity)
|
|
1252
|
+
.find({ where: { taskId: earlierTaskId } })).find((candidate) => candidate.memberId === requesterMemberId);
|
|
1253
|
+
return (earlierCandidate ??
|
|
1254
|
+
searchEarlierTasks(earlierTaskId, new Set([...visitedTaskIds, earlierTaskId])));
|
|
1255
|
+
};
|
|
1256
|
+
return searchEarlierTasks(originTask.id, new Set([originTask.id]));
|
|
1257
|
+
}
|
|
1015
1258
|
async readOpenTaskRecipientMemberIds(manager, task) {
|
|
1016
1259
|
if (task.assigneeMemberId) {
|
|
1017
1260
|
return [task.assigneeMemberId];
|
|
@@ -1116,7 +1359,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1116
1359
|
return;
|
|
1117
1360
|
}
|
|
1118
1361
|
try {
|
|
1119
|
-
const recipients = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), '臨時通知對象');
|
|
1362
|
+
const recipients = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), '臨時通知對象', '通知對象');
|
|
1120
1363
|
await this.notificationService.createAdhocWorkflowNotifications({
|
|
1121
1364
|
channels: directive.channels,
|
|
1122
1365
|
instance,
|
|
@@ -1229,16 +1472,28 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1229
1472
|
}
|
|
1230
1473
|
const nextVisitedNodeIds = new Set([...input.visitedNodeIds, visitKey]);
|
|
1231
1474
|
const entryConditionMet = this.conditionService.evaluateBoolean(entryCondition, buildDryRunExpressionContext(input, input.lastDecision), `workflow.nodes.${node.id}.data.entryCondition`);
|
|
1232
|
-
const
|
|
1233
|
-
? this.
|
|
1475
|
+
const assignment = entryConditionMet && node.type === 'userTask'
|
|
1476
|
+
? this.resolveDryRunAssignment(input, node)
|
|
1234
1477
|
: null;
|
|
1235
|
-
const
|
|
1478
|
+
const assigneeMemberId = assignment?.assigneeMemberId ?? null;
|
|
1479
|
+
const skippedByExclusion = assignment?.outcome === 'SKIPPED';
|
|
1480
|
+
const stoppedByAssignment = assignment?.outcome === 'STOPPED';
|
|
1481
|
+
const nextLastDecision = entryConditionMet && node.type === 'userTask' && !skippedByExclusion
|
|
1236
1482
|
? {
|
|
1237
1483
|
action: workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED,
|
|
1238
1484
|
assigneeMemberId,
|
|
1239
1485
|
nodeId: node.id,
|
|
1240
1486
|
}
|
|
1241
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 };
|
|
1242
1497
|
const nextSteps = [
|
|
1243
1498
|
...input.steps,
|
|
1244
1499
|
createDryRunStep({
|
|
@@ -1248,34 +1503,16 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1248
1503
|
edgeReason: readDryRunEdgeReason(input.incomingEdge),
|
|
1249
1504
|
entryCondition,
|
|
1250
1505
|
entryConditionMet,
|
|
1251
|
-
message: readDryRunStepMessage(node, entryConditionMet),
|
|
1506
|
+
message: readDryRunStepResultMessage(assignment?.message ?? null, outgoing.stopMessage, readDryRunStepMessage(node, entryConditionMet), !entryConditionMet || skippedByExclusion),
|
|
1252
1507
|
node,
|
|
1253
|
-
status:
|
|
1508
|
+
status: outgoing.stopMessage === null
|
|
1509
|
+
? (assignment?.outcome ??
|
|
1510
|
+
readDryRunStepStatus(node, entryConditionMet))
|
|
1511
|
+
: 'STOPPED',
|
|
1254
1512
|
stepIndex: input.steps.length,
|
|
1255
1513
|
}),
|
|
1256
1514
|
];
|
|
1257
|
-
|
|
1258
|
-
// token and never advances, so its branch ends here rather than failing
|
|
1259
|
-
// for want of an outgoing edge it is not allowed to have.
|
|
1260
|
-
if ((0, workflow_graph_1.isAsyncNotifyServiceTask)(node)) {
|
|
1261
|
-
return nextSteps;
|
|
1262
|
-
}
|
|
1263
|
-
if (!entryConditionMet || node.type === 'endEvent') {
|
|
1264
|
-
if (node.type === 'endEvent') {
|
|
1265
|
-
return nextSteps;
|
|
1266
|
-
}
|
|
1267
|
-
return readOutgoingEdgesOrThrow(input.workflowDefinition, node.id).reduce((steps, edge) => this.simulateWorkflowPath({
|
|
1268
|
-
...input,
|
|
1269
|
-
depth: input.depth + 1,
|
|
1270
|
-
incomingEdge: edge,
|
|
1271
|
-
lastDecision: nextLastDecision,
|
|
1272
|
-
nodeId: edge.target,
|
|
1273
|
-
steps,
|
|
1274
|
-
visitedNodeIds: nextVisitedNodeIds,
|
|
1275
|
-
}), nextSteps);
|
|
1276
|
-
}
|
|
1277
|
-
const outgoingEdges = this.readDryRunAvailableOutgoingEdges(input, node, nextLastDecision);
|
|
1278
|
-
return outgoingEdges.reduce((steps, edge) => this.simulateWorkflowPath({
|
|
1515
|
+
return outgoing.edges.reduce((steps, edge) => this.simulateWorkflowPath({
|
|
1279
1516
|
...input,
|
|
1280
1517
|
depth: input.depth + 1,
|
|
1281
1518
|
incomingEdge: edge,
|
|
@@ -1285,28 +1522,45 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1285
1522
|
visitedNodeIds: nextVisitedNodeIds,
|
|
1286
1523
|
}), nextSteps);
|
|
1287
1524
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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 };
|
|
1291
1541
|
}
|
|
1292
|
-
const outgoingEdges = readOutgoingEdgesOrThrow(input.workflowDefinition, node.id);
|
|
1293
1542
|
const conditionalEdges = outgoingEdges.filter((edge) => !edge.data.isDefault && edgeHasCondition(edge));
|
|
1294
1543
|
if (conditionalEdges.length === 0) {
|
|
1295
|
-
return outgoingEdges;
|
|
1544
|
+
return { edges: outgoingEdges, stopMessage: null };
|
|
1296
1545
|
}
|
|
1297
1546
|
const matchingEdges = conditionalEdges.filter((edge) => this.evaluateDryRunEdge(input, edge, lastDecision));
|
|
1298
1547
|
if (matchingEdges.length > 0) {
|
|
1299
|
-
return
|
|
1300
|
-
? [matchingEdges[0]]
|
|
1301
|
-
:
|
|
1548
|
+
return {
|
|
1549
|
+
edges: node.type === 'exclusiveGateway' ? [matchingEdges[0]] : matchingEdges,
|
|
1550
|
+
stopMessage: null,
|
|
1551
|
+
};
|
|
1302
1552
|
}
|
|
1303
1553
|
const defaultEdges = outgoingEdges.filter((edge) => edge.data.isDefault);
|
|
1304
1554
|
if (defaultEdges.length > 0) {
|
|
1305
|
-
return
|
|
1306
|
-
? [defaultEdges[0]]
|
|
1307
|
-
:
|
|
1555
|
+
return {
|
|
1556
|
+
edges: node.type === 'exclusiveGateway' ? [defaultEdges[0]] : defaultEdges,
|
|
1557
|
+
stopMessage: null,
|
|
1558
|
+
};
|
|
1308
1559
|
}
|
|
1309
|
-
|
|
1560
|
+
return {
|
|
1561
|
+
edges: [],
|
|
1562
|
+
stopMessage: `節點「${nodeName}」的連出線條件都不符合,且沒有設定預設路徑,請檢查連出線的條件或補上預設路徑`,
|
|
1563
|
+
};
|
|
1310
1564
|
}
|
|
1311
1565
|
evaluateDryRunEdge(input, edge, lastDecision) {
|
|
1312
1566
|
const expression = edge.data.condition?.trim();
|
|
@@ -1315,29 +1569,101 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1315
1569
|
}
|
|
1316
1570
|
return (0, workflow_condition_evaluator_1.evaluateWorkflowEdgeCondition)(edge, { formData: input.formData });
|
|
1317
1571
|
}
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1572
|
+
/**
|
|
1573
|
+
* The dry-run assignee of a user task. Only resolvers whose dry run yields
|
|
1574
|
+
* real member ids (DIRECT, DYNAMIC_FORM, EXPRESSION and ORG_MANAGER, which
|
|
1575
|
+
* reads the initiator snapshot) can be checked against the initiator; the
|
|
1576
|
+
* others resolve to placeholders and are left as they are.
|
|
1577
|
+
*/
|
|
1578
|
+
resolveDryRunAssignment(input, node) {
|
|
1579
|
+
const resolver = node.data.approverResolver;
|
|
1580
|
+
const excludeInitiator = node.data.excludeInitiator;
|
|
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
|
+
};
|
|
1321
1588
|
}
|
|
1322
|
-
|
|
1323
|
-
|
|
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
|
+
};
|
|
1324
1598
|
}
|
|
1325
|
-
if (
|
|
1326
|
-
return
|
|
1599
|
+
if (!excludeInitiator || !memberIds.includes(input.initiatorMemberId)) {
|
|
1600
|
+
return {
|
|
1601
|
+
assigneeMemberId: memberIds[0],
|
|
1602
|
+
message: null,
|
|
1603
|
+
outcome: null,
|
|
1604
|
+
};
|
|
1327
1605
|
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1606
|
+
const remainingMemberId = memberIds.find((memberId) => memberId !== input.initiatorMemberId);
|
|
1607
|
+
if (remainingMemberId !== undefined) {
|
|
1608
|
+
return {
|
|
1609
|
+
assigneeMemberId: remainingMemberId,
|
|
1610
|
+
message: null,
|
|
1611
|
+
outcome: null,
|
|
1612
|
+
};
|
|
1330
1613
|
}
|
|
1331
|
-
if (
|
|
1332
|
-
return
|
|
1614
|
+
if (excludeInitiator.whenEmpty === 'SKIP_NODE') {
|
|
1615
|
+
return {
|
|
1616
|
+
assigneeMemberId: null,
|
|
1617
|
+
message: '簽核人只有申請人本人,依設定略過節點。',
|
|
1618
|
+
outcome: 'SKIPPED',
|
|
1619
|
+
};
|
|
1333
1620
|
}
|
|
1334
|
-
|
|
1335
|
-
|
|
1621
|
+
// Level 1 is the initiator when they head their unit; like the runtime,
|
|
1622
|
+
// fall through to the next level of the snapshot's manager chain.
|
|
1623
|
+
const managerMemberId = excludeInitiator.whenEmpty === 'INITIATOR_MANAGER'
|
|
1624
|
+
? [1, 2]
|
|
1625
|
+
.map((levelsUp) => readManagerMemberIdFromInitiatorSnapshot(input.initiatorMetadataSnapshot, levelsUp))
|
|
1626
|
+
.find((memberId) => typeof memberId === 'string' &&
|
|
1627
|
+
memberId.trim() !== '' &&
|
|
1628
|
+
memberId.trim() !== input.initiatorMemberId)
|
|
1629
|
+
: undefined;
|
|
1630
|
+
if (managerMemberId !== undefined) {
|
|
1631
|
+
return {
|
|
1632
|
+
assigneeMemberId: managerMemberId.trim(),
|
|
1633
|
+
message: null,
|
|
1634
|
+
outcome: null,
|
|
1635
|
+
};
|
|
1336
1636
|
}
|
|
1337
|
-
|
|
1338
|
-
|
|
1637
|
+
return {
|
|
1638
|
+
assigneeMemberId: null,
|
|
1639
|
+
message: excludeInitiator.whenEmpty === 'INITIATOR_MANAGER'
|
|
1640
|
+
? '簽核人只有申請人本人,此節點不允許申請人自簽,且找不到申請人的主管可改派。'
|
|
1641
|
+
: '簽核人只有申請人本人,此節點不允許申請人自簽。',
|
|
1642
|
+
outcome: 'STOPPED',
|
|
1643
|
+
};
|
|
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
|
+
*/
|
|
1650
|
+
readDryRunMemberIds(input, resolver, label) {
|
|
1651
|
+
if (resolver.type === 'DIRECT') {
|
|
1652
|
+
return findMemberIdsInValue(resolver.memberIds);
|
|
1339
1653
|
}
|
|
1340
|
-
|
|
1654
|
+
if (resolver.type === 'DYNAMIC_FORM') {
|
|
1655
|
+
return findMemberIdsInValue(readValueAtPath(input.formData, resolver.formPath));
|
|
1656
|
+
}
|
|
1657
|
+
if (resolver.type === 'EXPRESSION') {
|
|
1658
|
+
return findMemberIdsInValue(this.conditionService.evaluateValue(resolver.expression, buildDryRunExpressionContext(input, null), `${label}.expression`));
|
|
1659
|
+
}
|
|
1660
|
+
if (resolver.type === 'ORG_MANAGER') {
|
|
1661
|
+
const managerMemberIds = findMemberIdsInValue(readManagerMemberIdFromInitiatorSnapshot(input.initiatorMetadataSnapshot, resolver.levelsUp));
|
|
1662
|
+
return managerMemberIds.length > 0
|
|
1663
|
+
? managerMemberIds
|
|
1664
|
+
: readDryRunFallbackMemberIds(input, resolver.fallback);
|
|
1665
|
+
}
|
|
1666
|
+
return null;
|
|
1341
1667
|
}
|
|
1342
1668
|
async getApprovalInstance(id, scope) {
|
|
1343
1669
|
const instance = await this.approvalInstanceRepository.findOne({
|
|
@@ -1410,7 +1736,11 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1410
1736
|
}
|
|
1411
1737
|
const view = options.view ?? workflow_engine_enums_1.ApprovalInstanceListViewEnum.ALL;
|
|
1412
1738
|
if (view === workflow_engine_enums_1.ApprovalInstanceListViewEnum.SENT) {
|
|
1413
|
-
|
|
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
|
+
)`, {
|
|
1414
1744
|
readableMemberId: scope.memberId,
|
|
1415
1745
|
});
|
|
1416
1746
|
return true;
|
|
@@ -1434,6 +1764,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1434
1764
|
queryBuilder.andWhere(`
|
|
1435
1765
|
(
|
|
1436
1766
|
"approvalInstance"."initiator_member_id" = :readableMemberId
|
|
1767
|
+
OR "approvalInstance"."submitted_by_member_id" = :readableMemberId
|
|
1437
1768
|
OR EXISTS (
|
|
1438
1769
|
SELECT 1
|
|
1439
1770
|
FROM "tasks" "readableTask"
|
|
@@ -1627,7 +1958,11 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1627
1958
|
async listTaskCandidates(taskId, scope) {
|
|
1628
1959
|
await this.assertTaskReadable(taskId, scope);
|
|
1629
1960
|
return this.taskCandidateRepository.find({
|
|
1630
|
-
order: {
|
|
1961
|
+
order: {
|
|
1962
|
+
sequence: { direction: 'ASC', nulls: 'LAST' },
|
|
1963
|
+
createdAt: 'ASC',
|
|
1964
|
+
id: 'ASC',
|
|
1965
|
+
},
|
|
1631
1966
|
where: { taskId },
|
|
1632
1967
|
});
|
|
1633
1968
|
}
|
|
@@ -1636,7 +1971,11 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1636
1971
|
return tasks;
|
|
1637
1972
|
}
|
|
1638
1973
|
const candidates = await this.taskCandidateRepository.find({
|
|
1639
|
-
order: {
|
|
1974
|
+
order: {
|
|
1975
|
+
sequence: { direction: 'ASC', nulls: 'LAST' },
|
|
1976
|
+
createdAt: 'ASC',
|
|
1977
|
+
id: 'ASC',
|
|
1978
|
+
},
|
|
1640
1979
|
where: { taskId: (0, typeorm_2.In)(tasks.map((task) => task.id)) },
|
|
1641
1980
|
});
|
|
1642
1981
|
const candidatesByTaskId = candidates.reduce((currentMap, candidate) => {
|
|
@@ -1645,12 +1984,65 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1645
1984
|
nextMap.set(candidate.taskId, [...memberIds, candidate.memberId]);
|
|
1646
1985
|
return nextMap;
|
|
1647
1986
|
}, new Map());
|
|
1987
|
+
const openCandidatesByTaskId = candidates
|
|
1988
|
+
.filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING ||
|
|
1989
|
+
candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED)
|
|
1990
|
+
.reduce((currentMap, candidate) => {
|
|
1991
|
+
const nextMap = new Map(currentMap);
|
|
1992
|
+
nextMap.set(candidate.taskId, [
|
|
1993
|
+
...(nextMap.get(candidate.taskId) ?? []),
|
|
1994
|
+
candidate.memberId,
|
|
1995
|
+
]);
|
|
1996
|
+
return nextMap;
|
|
1997
|
+
}, new Map());
|
|
1998
|
+
const nodeLabels = await this.readTaskNodeLabels(tasks);
|
|
1648
1999
|
return tasks.map((task) => Object.assign(new task_entity_1.TaskEntity(), task, {
|
|
1649
2000
|
candidateMemberIds: candidatesByTaskId.get(task.id) ?? [],
|
|
2001
|
+
nodeLabel: nodeLabels.get(readNodeLabelKey(task.instanceId, task.nodeId)) ??
|
|
2002
|
+
null,
|
|
2003
|
+
openCandidateMemberIds: openCandidatesByTaskId.get(task.id) ?? [],
|
|
1650
2004
|
}));
|
|
1651
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
|
+
}
|
|
1652
2044
|
async readDefaultInitiatorMetadataSnapshot(memberId) {
|
|
1653
|
-
const today =
|
|
2045
|
+
const today = this.readOrganizationToday();
|
|
1654
2046
|
const memberships = (await this.approvalInstanceRepository.manager
|
|
1655
2047
|
.getRepository(membership_entity_1.MembershipEntity)
|
|
1656
2048
|
.find({ where: { memberId } }))
|
|
@@ -1685,7 +2077,10 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1685
2077
|
return task;
|
|
1686
2078
|
}
|
|
1687
2079
|
async isApprovalInstanceReadableByMember(instance, memberId) {
|
|
1688
|
-
|
|
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) {
|
|
1689
2084
|
return true;
|
|
1690
2085
|
}
|
|
1691
2086
|
const tasks = await this.taskRepository.find({
|
|
@@ -1882,19 +2277,47 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1882
2277
|
return this.conditionService.evaluateBoolean(entryCondition, buildWorkflowExpressionContext(instance), `workflow.nodes.${node.id}.data.entryCondition`);
|
|
1883
2278
|
}
|
|
1884
2279
|
async skipNodeByEntryCondition(manager, instance, token, node) {
|
|
2280
|
+
await this.skipNode(manager, instance, token, node, {
|
|
2281
|
+
skippedByEntryCondition: true,
|
|
2282
|
+
});
|
|
2283
|
+
}
|
|
2284
|
+
async skipNodeByInitiatorExclusion(manager, instance, token, node) {
|
|
2285
|
+
await this.skipNode(manager, instance, token, node, {
|
|
2286
|
+
skippedByInitiatorExclusion: true,
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
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);
|
|
1885
2296
|
await manager.getRepository(activity_log_entity_1.ActivityLogEntity).save(manager.getRepository(activity_log_entity_1.ActivityLogEntity).create({
|
|
1886
2297
|
actorMemberId: null,
|
|
1887
2298
|
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TOKEN_ADVANCED,
|
|
1888
2299
|
instanceId: instance.id,
|
|
1889
2300
|
nodeId: node.id,
|
|
1890
2301
|
payload: {
|
|
1891
|
-
|
|
2302
|
+
...reason,
|
|
2303
|
+
...(consumesToken ? { consumed: true } : {}),
|
|
1892
2304
|
tokenId: token.id,
|
|
1893
2305
|
},
|
|
1894
2306
|
taskId: null,
|
|
1895
2307
|
}));
|
|
2308
|
+
if (consumesToken) {
|
|
2309
|
+
await this.consumeToken(manager, token);
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
1896
2312
|
await this.advanceTokenToOutgoingNodes(manager, instance, token, node);
|
|
1897
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
|
+
}
|
|
1898
2321
|
async readAvailableOutgoingEdges(manager, instance, node) {
|
|
1899
2322
|
const outgoingEdges = readOutgoingEdgesOrThrow(instance.workflowSnapshot, node.id);
|
|
1900
2323
|
const conditionalEdges = outgoingEdges.filter((edge) => !edge.data.isDefault && edgeHasCondition(edge));
|
|
@@ -2086,43 +2509,66 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2086
2509
|
});
|
|
2087
2510
|
return;
|
|
2088
2511
|
}
|
|
2089
|
-
const
|
|
2512
|
+
const resolvedCandidates = await this.resolveRuntimeTaskCandidates(manager, instance, node);
|
|
2513
|
+
// The initiator was the only approver and the node asks to be skipped in
|
|
2514
|
+
// that case: no task, the token moves on. Pending countersign directives
|
|
2515
|
+
// stay pending for the next user task that actually gets created.
|
|
2516
|
+
if (resolvedCandidates === null) {
|
|
2517
|
+
await this.skipNodeByInitiatorExclusion(manager, instance, token, node);
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2520
|
+
const { candidates, skippedDelegations } = resolvedCandidates;
|
|
2090
2521
|
const primaryCandidate = candidates[0];
|
|
2522
|
+
// Legacy snapshots may carry no policy at all; they behave as before.
|
|
2523
|
+
const decisionPolicy = node.data.decisionPolicy;
|
|
2524
|
+
const isSequential = decisionPolicy?.type === 'SEQUENTIAL' && candidates.length > 1;
|
|
2525
|
+
// A sequential task is held by exactly one candidate at a time, so it
|
|
2526
|
+
// carries that candidate as its assignee from the start.
|
|
2527
|
+
const assignedCandidate = candidates.length === 1 || isSequential ? primaryCandidate : null;
|
|
2091
2528
|
const now = new Date();
|
|
2092
2529
|
const slaDueAt = await this.slaScheduleService.resolveTaskSlaDueAt({
|
|
2093
2530
|
node,
|
|
2094
2531
|
now,
|
|
2095
2532
|
});
|
|
2096
2533
|
const task = await taskRepository.save(taskRepository.create({
|
|
2097
|
-
assigneeMemberId:
|
|
2534
|
+
assigneeMemberId: assignedCandidate?.memberId ?? null,
|
|
2098
2535
|
assignmentType: candidates.length === 1
|
|
2099
2536
|
? workflow_engine_enums_1.TaskAssignmentTypeEnum.DIRECT_MEMBER
|
|
2100
2537
|
: workflow_engine_enums_1.TaskAssignmentTypeEnum.CANDIDATE_GROUP,
|
|
2101
2538
|
completedAt: null,
|
|
2102
2539
|
createdAt: now,
|
|
2103
|
-
decisionPolicySnapshot: node
|
|
2104
|
-
|
|
2540
|
+
decisionPolicySnapshot: isInitiatorExcluded(node)
|
|
2541
|
+
? capDecisionPolicyToCandidateCount(decisionPolicy, candidates.length)
|
|
2542
|
+
: decisionPolicy,
|
|
2543
|
+
delegationChain: assignedCandidate?.delegationChain ?? [],
|
|
2105
2544
|
instanceId: instance.id,
|
|
2106
2545
|
nodeId: node.id,
|
|
2107
2546
|
openedAt: null,
|
|
2108
|
-
originalAssigneeMemberId:
|
|
2547
|
+
originalAssigneeMemberId: assignedCandidate?.originalMemberId ?? null,
|
|
2109
2548
|
slaDueAt,
|
|
2110
2549
|
status: workflow_engine_enums_1.TaskStatusEnum.PENDING,
|
|
2111
2550
|
tokenId: token.id,
|
|
2112
2551
|
}));
|
|
2113
|
-
const savedCandidates = await taskCandidateRepository.save(candidates.map((candidate) => taskCandidateRepository.create({
|
|
2552
|
+
const savedCandidates = await taskCandidateRepository.save(candidates.map((candidate, index) => taskCandidateRepository.create({
|
|
2114
2553
|
claimedAt: null,
|
|
2115
2554
|
createdAt: now,
|
|
2116
2555
|
decidedAt: null,
|
|
2117
2556
|
delegationChain: candidate.delegationChain,
|
|
2118
2557
|
memberId: candidate.memberId,
|
|
2119
2558
|
originalMemberId: candidate.originalMemberId,
|
|
2559
|
+
sequence: isSequential ? index : null,
|
|
2120
2560
|
sourceType: candidate.sourceType,
|
|
2121
|
-
status:
|
|
2561
|
+
status: isSequential && index > 0
|
|
2562
|
+
? workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING
|
|
2563
|
+
: workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING,
|
|
2122
2564
|
taskId: task.id,
|
|
2123
2565
|
})));
|
|
2566
|
+
// Display summary of everyone on the task, including the ones whose turn
|
|
2567
|
+
// has not come; who may act now is `status`, not membership of this list.
|
|
2124
2568
|
task.candidateMemberIds = savedCandidates.map((candidate) => candidate.memberId);
|
|
2125
2569
|
const singleCandidate = savedCandidates.length === 1 ? savedCandidates[0] : null;
|
|
2570
|
+
const openCandidates = savedCandidates.filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING);
|
|
2571
|
+
task.openCandidateMemberIds = openCandidates.map((candidate) => candidate.memberId);
|
|
2126
2572
|
await tokenRepository.save({
|
|
2127
2573
|
...token,
|
|
2128
2574
|
status: workflow_engine_enums_1.WorkflowTokenStatusEnum.WAITING,
|
|
@@ -2134,14 +2580,22 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2134
2580
|
nodeId: node.id,
|
|
2135
2581
|
payload: {
|
|
2136
2582
|
assignmentType: task.assignmentType,
|
|
2137
|
-
assigneeMemberId: singleCandidate?.memberId ??
|
|
2138
|
-
|
|
2139
|
-
|
|
2583
|
+
assigneeMemberId: (isSequential ? openCandidates[0] : singleCandidate)?.memberId ??
|
|
2584
|
+
null,
|
|
2585
|
+
candidateMemberIds: openCandidates.map((candidate) => candidate.memberId),
|
|
2586
|
+
originalAssigneeMemberId: (isSequential ? openCandidates[0] : singleCandidate)
|
|
2587
|
+
?.originalMemberId ?? null,
|
|
2588
|
+
...(isSequential
|
|
2589
|
+
? {
|
|
2590
|
+
sequentialCandidateMemberIds: savedCandidates.map((candidate) => candidate.memberId),
|
|
2591
|
+
}
|
|
2592
|
+
: {}),
|
|
2593
|
+
...(skippedDelegations.length > 0 ? { skippedDelegations } : {}),
|
|
2140
2594
|
tokenId: token.id,
|
|
2141
2595
|
},
|
|
2142
2596
|
taskId: task.id,
|
|
2143
2597
|
}));
|
|
2144
|
-
await
|
|
2598
|
+
await openCandidates.reduce(async (previous, candidate) => {
|
|
2145
2599
|
await previous;
|
|
2146
2600
|
await this.notificationService.createTaskAssignedNotification({
|
|
2147
2601
|
instance,
|
|
@@ -2158,6 +2612,118 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2158
2612
|
// created after they were requested — spawn their parallel tasks now.
|
|
2159
2613
|
await this.spawnCountersignTasksForNode(manager, instance, token, node, now);
|
|
2160
2614
|
}
|
|
2615
|
+
/**
|
|
2616
|
+
* Hands a SEQUENTIAL task to the lowest-`sequence` candidate still WAITING.
|
|
2617
|
+
* A task without a WAITING candidate (every other policy, and SEQUENTIAL
|
|
2618
|
+
* tasks created before candidates were ordered) is left alone.
|
|
2619
|
+
*/
|
|
2620
|
+
async activateNextSequentialCandidate({ candidates, instance, manager, node, previousMemberId, task, }) {
|
|
2621
|
+
if (readDecisionPolicySnapshot(task).type !== 'SEQUENTIAL') {
|
|
2622
|
+
return;
|
|
2623
|
+
}
|
|
2624
|
+
const nextCandidate = [...candidates]
|
|
2625
|
+
.filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING &&
|
|
2626
|
+
candidate.sequence !== null)
|
|
2627
|
+
.sort((left, right) => (left.sequence ?? 0) - (right.sequence ?? 0))[0];
|
|
2628
|
+
if (!nextCandidate) {
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
const now = new Date();
|
|
2632
|
+
// Delegation was resolved when the task was created; the candidate may have
|
|
2633
|
+
// gained a delegate while waiting, so resolve it again for the new turn.
|
|
2634
|
+
const { skippedDelegation, ...delegation } = await this.resolveActivationDelegation({
|
|
2635
|
+
candidate: nextCandidate,
|
|
2636
|
+
candidates,
|
|
2637
|
+
excludesInitiator: isInitiatorExcluded(node),
|
|
2638
|
+
gradeGated: isGradeGatedCandidate(node, nextCandidate.sourceType),
|
|
2639
|
+
instance,
|
|
2640
|
+
manager,
|
|
2641
|
+
nodeId: task.nodeId,
|
|
2642
|
+
});
|
|
2643
|
+
const activatedCandidate = await manager
|
|
2644
|
+
.getRepository(task_candidate_entity_1.TaskCandidateEntity)
|
|
2645
|
+
.save(Object.assign(new task_candidate_entity_1.TaskCandidateEntity(), nextCandidate, delegation, {
|
|
2646
|
+
status: workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING,
|
|
2647
|
+
}));
|
|
2648
|
+
const activatedTask = await manager.getRepository(task_entity_1.TaskEntity).save({
|
|
2649
|
+
...task,
|
|
2650
|
+
assigneeMemberId: activatedCandidate.memberId,
|
|
2651
|
+
delegationChain: activatedCandidate.delegationChain,
|
|
2652
|
+
openedAt: null,
|
|
2653
|
+
originalAssigneeMemberId: activatedCandidate.originalMemberId,
|
|
2654
|
+
// The new holder gets the node's full SLA window, not what is left of
|
|
2655
|
+
// the previous holder's; the warning is measured from this moment.
|
|
2656
|
+
slaDueAt: await this.slaScheduleService.resolveTaskSlaDueAt({
|
|
2657
|
+
node,
|
|
2658
|
+
now,
|
|
2659
|
+
}),
|
|
2660
|
+
slaStartedAt: now,
|
|
2661
|
+
status: workflow_engine_enums_1.TaskStatusEnum.PENDING,
|
|
2662
|
+
});
|
|
2663
|
+
const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
|
|
2664
|
+
await activityRepository.save(activityRepository.create({
|
|
2665
|
+
actorMemberId: null,
|
|
2666
|
+
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_CANDIDATE_ACTIVATED,
|
|
2667
|
+
instanceId: instance.id,
|
|
2668
|
+
nodeId: task.nodeId,
|
|
2669
|
+
// Carries the same recipient fields as TASK_CREATED so a consumer can
|
|
2670
|
+
// route this event exactly like a task assignment.
|
|
2671
|
+
payload: {
|
|
2672
|
+
assigneeMemberId: activatedCandidate.memberId,
|
|
2673
|
+
assignmentType: activatedTask.assignmentType,
|
|
2674
|
+
candidateMemberId: activatedCandidate.memberId,
|
|
2675
|
+
candidateMemberIds: [activatedCandidate.memberId],
|
|
2676
|
+
originalAssigneeMemberId: activatedCandidate.originalMemberId,
|
|
2677
|
+
originalMemberId: activatedCandidate.originalMemberId,
|
|
2678
|
+
previousMemberId,
|
|
2679
|
+
sequence: activatedCandidate.sequence,
|
|
2680
|
+
...(skippedDelegation
|
|
2681
|
+
? { skippedDelegations: [skippedDelegation] }
|
|
2682
|
+
: {}),
|
|
2683
|
+
tokenId: task.tokenId,
|
|
2684
|
+
},
|
|
2685
|
+
taskId: task.id,
|
|
2686
|
+
}));
|
|
2687
|
+
await this.notificationService.createTaskAssignedNotification({
|
|
2688
|
+
instance,
|
|
2689
|
+
manager,
|
|
2690
|
+
node,
|
|
2691
|
+
task: activatedTask,
|
|
2692
|
+
});
|
|
2693
|
+
}
|
|
2694
|
+
/**
|
|
2695
|
+
* Re-resolves the delegation of a candidate whose turn has come. When the
|
|
2696
|
+
* delegate already holds another candidate row on the task, the stored values
|
|
2697
|
+
* are kept: (task_id, member_id) is unique, and that member signs from the
|
|
2698
|
+
* row they already have. On a node that excludes the initiator the stored
|
|
2699
|
+
* values are kept as well when the new delegation path would run through the
|
|
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.
|
|
2702
|
+
*/
|
|
2703
|
+
async resolveActivationDelegation({ candidate, candidates, excludesInitiator, gradeGated, instance, manager, nodeId, }) {
|
|
2704
|
+
const stored = {
|
|
2705
|
+
delegationChain: candidate.delegationChain,
|
|
2706
|
+
memberId: candidate.memberId,
|
|
2707
|
+
};
|
|
2708
|
+
const { skippedDelegation, ...resolution } = await this.resolveDelegation(manager, candidate.originalMemberId, this.buildDelegationContext(instance, nodeId), gradeGated);
|
|
2709
|
+
const collides = candidates.some((other) => other.id !== candidate.id &&
|
|
2710
|
+
other.memberId === resolution.finalAssigneeMemberId);
|
|
2711
|
+
if (collides ||
|
|
2712
|
+
(excludesInitiator &&
|
|
2713
|
+
isExcludedMemberOnCandidatePath({
|
|
2714
|
+
delegationChain: resolution.delegationChain,
|
|
2715
|
+
memberId: resolution.finalAssigneeMemberId,
|
|
2716
|
+
originalMemberId: candidate.originalMemberId,
|
|
2717
|
+
}, readInitiatorAndSubmitter(instance)))) {
|
|
2718
|
+
// The refusal happened even though the stored values are kept.
|
|
2719
|
+
return { ...stored, ...(skippedDelegation ? { skippedDelegation } : {}) };
|
|
2720
|
+
}
|
|
2721
|
+
return {
|
|
2722
|
+
delegationChain: resolution.delegationChain,
|
|
2723
|
+
memberId: resolution.finalAssigneeMemberId,
|
|
2724
|
+
...(skippedDelegation ? { skippedDelegation } : {}),
|
|
2725
|
+
};
|
|
2726
|
+
}
|
|
2161
2727
|
async markTaskCandidateDecision({ action, candidate, candidates, decidedAt, manager, task, }) {
|
|
2162
2728
|
const taskCandidateRepository = manager.getRepository(task_candidate_entity_1.TaskCandidateEntity);
|
|
2163
2729
|
if (!candidate && candidates.length === 0 && task.assigneeMemberId) {
|
|
@@ -2191,7 +2757,8 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2191
2757
|
}
|
|
2192
2758
|
if (shouldCancelOpenCandidates &&
|
|
2193
2759
|
(currentCandidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING ||
|
|
2194
|
-
currentCandidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED
|
|
2760
|
+
currentCandidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED ||
|
|
2761
|
+
currentCandidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING)) {
|
|
2195
2762
|
return Object.assign(new task_candidate_entity_1.TaskCandidateEntity(), currentCandidate, {
|
|
2196
2763
|
decidedAt,
|
|
2197
2764
|
status: workflow_engine_enums_1.TaskCandidateStatusEnum.CANCELLED,
|
|
@@ -2230,6 +2797,18 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2230
2797
|
delegationChain: nextDelegationChain,
|
|
2231
2798
|
status: workflow_engine_enums_1.TaskStatusEnum.TRANSFERRED,
|
|
2232
2799
|
});
|
|
2800
|
+
// Candidates still waiting for their turn move with the task; without this
|
|
2801
|
+
// the replacement SINGLE task would silently drop them from the chain.
|
|
2802
|
+
// A waiting candidate who is also the transfer target takes the active slot
|
|
2803
|
+
// and is not asked a second time later in the order.
|
|
2804
|
+
const waitingCandidates = readDecisionPolicySnapshot(task).type === 'SEQUENTIAL' &&
|
|
2805
|
+
actorCandidate?.sequence != null
|
|
2806
|
+
? (await taskCandidateRepository.find({ where: { taskId: task.id } }))
|
|
2807
|
+
.filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING)
|
|
2808
|
+
.sort((left, right) => (left.sequence ?? 0) - (right.sequence ?? 0))
|
|
2809
|
+
: [];
|
|
2810
|
+
const isSequentialTransfer = waitingCandidates.length > 0;
|
|
2811
|
+
const carriedCandidates = waitingCandidates.filter((candidate) => candidate.memberId !== transferToMemberId);
|
|
2233
2812
|
const nextTask = await taskRepository.save(taskRepository.create({
|
|
2234
2813
|
// A transferred ad-hoc task stays the same ad-hoc task for its new
|
|
2235
2814
|
// holder: the pre-approval rejection path (RETURN_TO_ORIGIN) reads these
|
|
@@ -2239,9 +2818,13 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2239
2818
|
adhocOriginTaskId: task.adhocOriginTaskId,
|
|
2240
2819
|
adhocType: task.adhocType,
|
|
2241
2820
|
assigneeMemberId: transferToMemberId,
|
|
2242
|
-
assignmentType:
|
|
2821
|
+
assignmentType: isSequentialTransfer
|
|
2822
|
+
? task.assignmentType
|
|
2823
|
+
: workflow_engine_enums_1.TaskAssignmentTypeEnum.DIRECT_MEMBER,
|
|
2243
2824
|
completedAt: null,
|
|
2244
|
-
decisionPolicySnapshot:
|
|
2825
|
+
decisionPolicySnapshot: isSequentialTransfer
|
|
2826
|
+
? task.decisionPolicySnapshot
|
|
2827
|
+
: { type: 'SINGLE' },
|
|
2245
2828
|
delegationChain: nextDelegationChain,
|
|
2246
2829
|
instanceId: task.instanceId,
|
|
2247
2830
|
isAdhoc: task.isAdhoc,
|
|
@@ -2252,6 +2835,29 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2252
2835
|
status: workflow_engine_enums_1.TaskStatusEnum.PENDING,
|
|
2253
2836
|
tokenId: task.tokenId,
|
|
2254
2837
|
}));
|
|
2838
|
+
// A pre-approval the previous holder requested still points at the task
|
|
2839
|
+
// being transferred. Its RETURN_TO_ORIGIN rejection hands the decision back
|
|
2840
|
+
// through adhocOriginTaskId, so leaving it on the TRANSFERRED row reopened
|
|
2841
|
+
// a task for the member who had just transferred the stage away. Only open
|
|
2842
|
+
// pre-approvals move; closed ones keep pointing at the task they ran
|
|
2843
|
+
// against, and countersign tasks keep agreeing with directive.originTaskId
|
|
2844
|
+
// because nothing reads their pointer. A pre-approval is always created on
|
|
2845
|
+
// the origin task's instance and node, so those narrow the lookup to the
|
|
2846
|
+
// idx_tasks_instance_node index (adhoc_origin_task_id has none).
|
|
2847
|
+
const openPreApprovalsOnTransferredTask = (await taskRepository.find({
|
|
2848
|
+
where: {
|
|
2849
|
+
adhocOriginTaskId: task.id,
|
|
2850
|
+
adhocType: adhoc_enums_1.AdhocDirectiveTypeEnum.PRE_APPROVAL,
|
|
2851
|
+
instanceId: task.instanceId,
|
|
2852
|
+
nodeId: task.nodeId,
|
|
2853
|
+
},
|
|
2854
|
+
})).filter((adhocTask) => adhocTask.status === workflow_engine_enums_1.TaskStatusEnum.PENDING ||
|
|
2855
|
+
adhocTask.status === workflow_engine_enums_1.TaskStatusEnum.IN_PROGRESS);
|
|
2856
|
+
if (openPreApprovalsOnTransferredTask.length > 0) {
|
|
2857
|
+
await taskRepository.save(openPreApprovalsOnTransferredTask.map((adhocTask) => Object.assign(new task_entity_1.TaskEntity(), adhocTask, {
|
|
2858
|
+
adhocOriginTaskId: nextTask.id,
|
|
2859
|
+
})));
|
|
2860
|
+
}
|
|
2255
2861
|
await taskCandidateRepository.save([
|
|
2256
2862
|
...(actorCandidate
|
|
2257
2863
|
? [
|
|
@@ -2269,10 +2875,28 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2269
2875
|
delegationChain: nextDelegationChain,
|
|
2270
2876
|
memberId: transferToMemberId,
|
|
2271
2877
|
originalMemberId: task.originalAssigneeMemberId ?? transferToMemberId,
|
|
2878
|
+
sequence: isSequentialTransfer
|
|
2879
|
+
? (actorCandidate?.sequence ?? null)
|
|
2880
|
+
: null,
|
|
2272
2881
|
sourceType: 'DIRECT',
|
|
2273
2882
|
status: workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING,
|
|
2274
2883
|
taskId: nextTask.id,
|
|
2275
2884
|
}),
|
|
2885
|
+
...carriedCandidates.map((candidate) => taskCandidateRepository.create({
|
|
2886
|
+
claimedAt: null,
|
|
2887
|
+
createdAt: candidate.createdAt,
|
|
2888
|
+
decidedAt: null,
|
|
2889
|
+
delegationChain: candidate.delegationChain,
|
|
2890
|
+
memberId: candidate.memberId,
|
|
2891
|
+
originalMemberId: candidate.originalMemberId,
|
|
2892
|
+
sequence: candidate.sequence,
|
|
2893
|
+
sourceType: candidate.sourceType,
|
|
2894
|
+
status: workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING,
|
|
2895
|
+
taskId: nextTask.id,
|
|
2896
|
+
})),
|
|
2897
|
+
...waitingCandidates.map((candidate) => Object.assign(new task_candidate_entity_1.TaskCandidateEntity(), candidate, {
|
|
2898
|
+
status: workflow_engine_enums_1.TaskCandidateStatusEnum.SUPERSEDED,
|
|
2899
|
+
})),
|
|
2276
2900
|
]);
|
|
2277
2901
|
await activityRepository.save([
|
|
2278
2902
|
activityRepository.create({
|
|
@@ -2297,8 +2921,19 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2297
2921
|
nodeId: task.nodeId,
|
|
2298
2922
|
payload: {
|
|
2299
2923
|
assigneeMemberId: transferToMemberId,
|
|
2924
|
+
...(isSequentialTransfer
|
|
2925
|
+
? { candidateMemberIds: [transferToMemberId] }
|
|
2926
|
+
: {}),
|
|
2300
2927
|
delegationChain: nextDelegationChain,
|
|
2301
2928
|
originalAssigneeMemberId: task.originalAssigneeMemberId,
|
|
2929
|
+
...(isSequentialTransfer
|
|
2930
|
+
? {
|
|
2931
|
+
sequentialCandidateMemberIds: [
|
|
2932
|
+
transferToMemberId,
|
|
2933
|
+
...carriedCandidates.map((candidate) => candidate.memberId),
|
|
2934
|
+
],
|
|
2935
|
+
}
|
|
2936
|
+
: {}),
|
|
2302
2937
|
tokenId: task.tokenId,
|
|
2303
2938
|
transferredFromTaskId: task.id,
|
|
2304
2939
|
},
|
|
@@ -2316,16 +2951,63 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2316
2951
|
});
|
|
2317
2952
|
}
|
|
2318
2953
|
}
|
|
2954
|
+
/**
|
|
2955
|
+
* Resolves who gets the node's task. `null` means the node asked to be
|
|
2956
|
+
* skipped (`excludeInitiator.whenEmpty = SKIP_NODE`) because the initiator
|
|
2957
|
+
* was the only approver — the sole caller creates no task and advances the
|
|
2958
|
+
* token instead.
|
|
2959
|
+
*/
|
|
2319
2960
|
async resolveRuntimeTaskCandidates(manager, instance, node) {
|
|
2320
|
-
const
|
|
2321
|
-
const
|
|
2322
|
-
|
|
2323
|
-
|
|
2961
|
+
const label = `簽核節點「${node.data.label}」`;
|
|
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));
|
|
2964
|
+
const excludeInitiator = node.data.excludeInitiator;
|
|
2965
|
+
const hasSubmitter = (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) !== null;
|
|
2966
|
+
const uniqueCandidates = selectNodeCandidates(delegatedCandidates, isInitiatorExcluded(node), readInitiatorAndSubmitter(instance));
|
|
2967
|
+
if (uniqueCandidates.length > 0) {
|
|
2968
|
+
return {
|
|
2969
|
+
candidates: uniqueCandidates,
|
|
2970
|
+
skippedDelegations: collectSkippedDelegations(delegatedCandidates, isInitiatorExcluded(node), readInitiatorAndSubmitter(instance)),
|
|
2971
|
+
};
|
|
2972
|
+
}
|
|
2973
|
+
// Only the initiator was left — what the node does about it is configured.
|
|
2974
|
+
// Nothing resolved to begin with is the ordinary failure below.
|
|
2975
|
+
if (!excludeInitiator || delegatedCandidates.length === 0) {
|
|
2976
|
+
throw new common_1.ConflictException(`${label} did not resolve to any member id`);
|
|
2324
2977
|
}
|
|
2325
|
-
|
|
2978
|
+
if (excludeInitiator.whenEmpty === 'SKIP_NODE') {
|
|
2979
|
+
return null;
|
|
2980
|
+
}
|
|
2981
|
+
if (excludeInitiator.whenEmpty === 'INITIATOR_MANAGER') {
|
|
2982
|
+
const managerCandidates = await this.resolveInitiatorManagerReplacements(manager, instance, node);
|
|
2983
|
+
if (managerCandidates.candidates.length > 0) {
|
|
2984
|
+
return managerCandidates;
|
|
2985
|
+
}
|
|
2986
|
+
throw new common_1.ConflictException(hasSubmitter
|
|
2987
|
+
? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人,此節點不允許申請人或代送人簽核,且找不到申請人的主管可改派。`
|
|
2988
|
+
: `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人,此節點不允許申請人自簽,且找不到申請人的主管可改派。`);
|
|
2989
|
+
}
|
|
2990
|
+
throw new common_1.ConflictException(hasSubmitter
|
|
2991
|
+
? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人(含委派後的代理人),且此節點不允許申請人或代送人簽核。`
|
|
2992
|
+
: `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人(含委派後的代理人),且此節點不允許申請人自簽。`);
|
|
2993
|
+
}
|
|
2994
|
+
async resolveInitiatorManagerReplacements(manager, instance, node) {
|
|
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) => ({
|
|
3001
|
+
memberId,
|
|
3002
|
+
sourceType: 'EXCLUDE_INITIATOR_MANAGER',
|
|
3003
|
+
})), isOrgManagerChainNode(node));
|
|
3004
|
+
return {
|
|
3005
|
+
candidates: selectNodeCandidates(delegatedCandidates, true, readInitiatorAndSubmitter(instance)),
|
|
3006
|
+
skippedDelegations: collectSkippedDelegations(delegatedCandidates, true, readInitiatorAndSubmitter(instance)),
|
|
3007
|
+
};
|
|
2326
3008
|
}
|
|
2327
|
-
|
|
2328
|
-
|
|
3009
|
+
buildDelegationContext(instance, nodeId) {
|
|
3010
|
+
return {
|
|
2329
3011
|
formData: instance.formData,
|
|
2330
3012
|
initiatorMemberId: instance.initiatorMemberId,
|
|
2331
3013
|
initiatorMetadataSnapshot: instance.initiatorMetadataSnapshot,
|
|
@@ -2336,43 +3018,89 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2336
3018
|
templateVersionId: instance.templateVersionId,
|
|
2337
3019
|
title: instance.title,
|
|
2338
3020
|
};
|
|
2339
|
-
|
|
2340
|
-
|
|
3021
|
+
}
|
|
3022
|
+
// Not deduplicated: a node that excludes the initiator filters first, so a
|
|
3023
|
+
// first-seen entry that must be dropped cannot shadow a legitimate one.
|
|
3024
|
+
async delegateResolvedCandidates(manager, instance, nodeId, resolvedCandidates, gradeGated) {
|
|
3025
|
+
const context = this.buildDelegationContext(instance, nodeId);
|
|
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);
|
|
2341
3036
|
return {
|
|
2342
3037
|
delegationChain: delegationResolution.delegationChain,
|
|
2343
3038
|
memberId: delegationResolution.finalAssigneeMemberId,
|
|
2344
3039
|
originalMemberId: candidate.memberId,
|
|
3040
|
+
...(skippedDelegation ? { skippedDelegation } : {}),
|
|
2345
3041
|
sourceType: candidate.sourceType,
|
|
2346
3042
|
};
|
|
2347
|
-
})
|
|
2348
|
-
|
|
3043
|
+
});
|
|
3044
|
+
}
|
|
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
|
+
}
|
|
2349
3074
|
}
|
|
2350
|
-
async resolveApproverResolver(manager, instance, resolver, label) {
|
|
3075
|
+
async resolveApproverResolver(manager, instance, resolver, label, subject = '簽核人', skippedChainMemberIds = []) {
|
|
2351
3076
|
if (resolver.type === 'DIRECT') {
|
|
2352
|
-
return readMemberIdsFromValue(resolver.memberIds, label).map((memberId) => ({ memberId, sourceType: resolver.type }));
|
|
3077
|
+
return readMemberIdsFromValue(resolver.memberIds, label, subject).map((memberId) => ({ memberId, sourceType: resolver.type }));
|
|
2353
3078
|
}
|
|
2354
3079
|
if (resolver.type === 'DYNAMIC_FORM') {
|
|
2355
|
-
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 }));
|
|
2356
3081
|
}
|
|
2357
3082
|
if (resolver.type === 'EXPRESSION') {
|
|
2358
|
-
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 }));
|
|
2359
3084
|
}
|
|
2360
3085
|
if (resolver.type === 'POSITION') {
|
|
2361
|
-
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 })));
|
|
2362
3087
|
}
|
|
2363
3088
|
if (resolver.type === 'ORG_UNIT_MEMBER') {
|
|
2364
|
-
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 })));
|
|
2365
3090
|
}
|
|
2366
3091
|
if (resolver.type === 'ORG_UNIT_POSITION') {
|
|
2367
|
-
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 })));
|
|
2368
3093
|
}
|
|
2369
3094
|
if (resolver.type === 'ORG_UNIT_MANAGER') {
|
|
2370
|
-
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 })));
|
|
2371
3096
|
}
|
|
2372
|
-
|
|
3097
|
+
if (resolver.type === 'ORG_MANAGER_CHAIN') {
|
|
3098
|
+
return this.resolveOrgManagerChainCandidates(manager, instance, resolver.minPositionLevel, resolver.fallback, label, subject, skippedChainMemberIds).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
3099
|
+
}
|
|
3100
|
+
return this.resolveInitiatorManagerCandidates(manager, instance, resolver.levelsUp, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
2373
3101
|
}
|
|
2374
|
-
async resolvePositionCandidates(manager, positionId, label) {
|
|
2375
|
-
const today =
|
|
3102
|
+
async resolvePositionCandidates(manager, positionId, label, subject) {
|
|
3103
|
+
const today = this.readOrganizationToday();
|
|
2376
3104
|
const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
|
|
2377
3105
|
where: { positionId },
|
|
2378
3106
|
});
|
|
@@ -2386,35 +3114,123 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2386
3114
|
return second.effectiveFrom.localeCompare(first.effectiveFrom);
|
|
2387
3115
|
});
|
|
2388
3116
|
if (!activeMemberships.length) {
|
|
2389
|
-
throw new common_1.ConflictException(`${label}
|
|
3117
|
+
throw new common_1.ConflictException(`${label} 找不到${subject}:職位「${await this.readPositionName(manager, positionId)}」目前沒有任何有效成員`);
|
|
2390
3118
|
}
|
|
2391
3119
|
return uniqueTexts(activeMemberships.map((membership) => membership.memberId));
|
|
2392
3120
|
}
|
|
2393
|
-
async resolveInitiatorManagerCandidates(manager, instance, levelsUp, fallback, label, preferClosestOrgUnit) {
|
|
3121
|
+
async resolveInitiatorManagerCandidates(manager, instance, levelsUp, fallback, label, preferClosestOrgUnit, subject) {
|
|
2394
3122
|
const normalizedLevelsUp = Math.max(Math.trunc(levelsUp), 1);
|
|
2395
3123
|
const resolvedManagerMemberIds = await this.resolveManagerMemberIdsFromOrganization(manager, instance.initiatorMemberId, normalizedLevelsUp, preferClosestOrgUnit);
|
|
2396
3124
|
if (resolvedManagerMemberIds.length) {
|
|
2397
3125
|
return resolvedManagerMemberIds;
|
|
2398
3126
|
}
|
|
2399
3127
|
return [
|
|
2400
|
-
this.resolveFallbackAssignee(instance, fallback, label, `找不到發起人的第 ${normalizedLevelsUp} 層主管`),
|
|
3128
|
+
this.resolveFallbackAssignee(instance, fallback, label, subject, `找不到發起人的第 ${normalizedLevelsUp} 層主管`),
|
|
2401
3129
|
];
|
|
2402
3130
|
}
|
|
2403
|
-
|
|
3131
|
+
/**
|
|
3132
|
+
* Walks up the initiator's manager chain, nearest manager first, until a
|
|
3133
|
+
* manager whose position level reaches `minPositionLevel` is added. The
|
|
3134
|
+
* list is returned nearest manager first. Members in `skippedMemberIds` are
|
|
3135
|
+
* walked past without joining the chain or counting as the stopping point.
|
|
3136
|
+
*/
|
|
3137
|
+
async resolveOrgManagerChainCandidates(manager, instance, minPositionLevel, fallback, label, subject, skippedMemberIds) {
|
|
3138
|
+
const date = this.readOrganizationToday();
|
|
3139
|
+
const visitedMemberIds = new Set([instance.initiatorMemberId]);
|
|
3140
|
+
const chain = [];
|
|
3141
|
+
let currentMemberId = instance.initiatorMemberId;
|
|
3142
|
+
let lastAddedLevel = null;
|
|
3143
|
+
for (let hop = 0; hop < ORG_MANAGER_CHAIN_MAX_HOPS; hop += 1) {
|
|
3144
|
+
// A chain is a single path, so it always uses closest-org-unit semantics
|
|
3145
|
+
// (merging ancestor-unit rules could only produce ties). The member is
|
|
3146
|
+
// excluded from his own candidates: a unit head belongs to the unit he
|
|
3147
|
+
// heads, so without this his unit rule would name himself and the walk
|
|
3148
|
+
// could never climb past him.
|
|
3149
|
+
const directManagerMemberIds = await this.resolveDirectManagerMemberIds(manager, currentMemberId, date, true, [currentMemberId]);
|
|
3150
|
+
if (directManagerMemberIds.length === 0) {
|
|
3151
|
+
break;
|
|
3152
|
+
}
|
|
3153
|
+
if (directManagerMemberIds.length > 1) {
|
|
3154
|
+
// Picking one silently would route the document by accident.
|
|
3155
|
+
throw new common_1.ConflictException(`${label} 無法沿主管鏈簽核:成員 ${currentMemberId} 的主管規則在同一優先順序出現多位主管(${directManagerMemberIds.join('、')}),無法決定簽核路徑。請聯絡流程管理員調整主管規則。`);
|
|
3156
|
+
}
|
|
3157
|
+
const [managerMemberId] = directManagerMemberIds;
|
|
3158
|
+
if (visitedMemberIds.has(managerMemberId)) {
|
|
3159
|
+
break;
|
|
3160
|
+
}
|
|
3161
|
+
visitedMemberIds.add(managerMemberId);
|
|
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
|
+
}
|
|
3169
|
+
const level = await this.readMemberPositionLevel(manager, managerMemberId, date);
|
|
3170
|
+
if (level === null ||
|
|
3171
|
+
(lastAddedLevel !== null && level <= lastAddedLevel)) {
|
|
3172
|
+
continue;
|
|
3173
|
+
}
|
|
3174
|
+
chain.push(managerMemberId);
|
|
3175
|
+
lastAddedLevel = level;
|
|
3176
|
+
if (level >= minPositionLevel) {
|
|
3177
|
+
return chain;
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
const fallbackMemberId = this.resolveFallbackAssignee(instance, fallback, label, subject, `沿主管鏈找不到職等達 ${minPositionLevel} 的主管`);
|
|
3181
|
+
return chain.includes(fallbackMemberId)
|
|
3182
|
+
? chain
|
|
3183
|
+
: [...chain, fallbackMemberId];
|
|
3184
|
+
}
|
|
3185
|
+
/** Highest position level over the member's active memberships, or `null`. */
|
|
3186
|
+
async readMemberPositionLevel(manager, memberId, date) {
|
|
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
|
+
}));
|
|
3217
|
+
}
|
|
3218
|
+
resolveFallbackAssignee(instance, fallback, label, subject, failureReason) {
|
|
3219
|
+
const failure = subject === '通知對象' ? '無法建立通知' : '無法建立待簽任務';
|
|
2404
3220
|
if (!fallback || fallback.type === 'NONE') {
|
|
2405
|
-
throw new common_1.ConflictException(`${label}
|
|
3221
|
+
throw new common_1.ConflictException(`${label} ${failure}:${failureReason},且未設定改派固定人。請聯絡流程管理員調整主管規則或模板 fallback。`);
|
|
2406
3222
|
}
|
|
2407
3223
|
if (!fallback.memberId.trim()) {
|
|
2408
|
-
throw new common_1.ConflictException(`${label}
|
|
3224
|
+
throw new common_1.ConflictException(`${label} ${failure}:改派固定人未設定。`);
|
|
2409
3225
|
}
|
|
2410
3226
|
if (fallback.memberId === instance.initiatorMemberId &&
|
|
2411
3227
|
!fallback.allowInitiatorSelfApproval) {
|
|
2412
|
-
throw new common_1.ConflictException(`${label}
|
|
3228
|
+
throw new common_1.ConflictException(`${label} ${failure}:改派固定人是申請人本人,且此節點未允許申請人自簽。`);
|
|
2413
3229
|
}
|
|
2414
3230
|
return fallback.memberId;
|
|
2415
3231
|
}
|
|
2416
3232
|
async resolveManagerMemberIdsFromOrganization(manager, memberId, levelsUp, preferClosestOrgUnit) {
|
|
2417
|
-
return this.resolveManagerMemberIdsAtLevel(manager, memberId,
|
|
3233
|
+
return this.resolveManagerMemberIdsAtLevel(manager, memberId, this.readOrganizationToday(), levelsUp, preferClosestOrgUnit);
|
|
2418
3234
|
}
|
|
2419
3235
|
async resolveManagerMemberIdsAtLevel(manager, memberId, date, remainingLevels, preferClosestOrgUnit) {
|
|
2420
3236
|
const directManagerMemberIds = await this.resolveDirectManagerMemberIds(manager, memberId, date, preferClosestOrgUnit);
|
|
@@ -2424,7 +3240,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2424
3240
|
const nextLevelGroups = await Promise.all(directManagerMemberIds.map((managerMemberId) => this.resolveManagerMemberIdsAtLevel(manager, managerMemberId, date, remainingLevels - 1, preferClosestOrgUnit)));
|
|
2425
3241
|
return uniqueTexts(nextLevelGroups.flat());
|
|
2426
3242
|
}
|
|
2427
|
-
async resolveDirectManagerMemberIds(manager, memberId, date, preferClosestOrgUnit) {
|
|
3243
|
+
async resolveDirectManagerMemberIds(manager, memberId, date, preferClosestOrgUnit, excludedManagerMemberIds = []) {
|
|
2428
3244
|
const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
|
|
2429
3245
|
where: { memberId },
|
|
2430
3246
|
});
|
|
@@ -2445,10 +3261,10 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2445
3261
|
scopeType: organization_enums_1.ManagerResolutionScopeTypeEnum.POSITION,
|
|
2446
3262
|
})),
|
|
2447
3263
|
];
|
|
2448
|
-
return this.resolveManagerResolutionCandidates(manager, candidatePairs, date, readOrgUnitDepthMap(orgUnits), preferClosestOrgUnit);
|
|
3264
|
+
return this.resolveManagerResolutionCandidates(manager, candidatePairs, date, readOrgUnitDepthMap(orgUnits), preferClosestOrgUnit, excludedManagerMemberIds);
|
|
2449
3265
|
}
|
|
2450
|
-
async resolveOrgUnitManagerCandidates(manager, instance, orgUnitId, fallback, label, preferClosestOrgUnit) {
|
|
2451
|
-
const date =
|
|
3266
|
+
async resolveOrgUnitManagerCandidates(manager, instance, orgUnitId, fallback, label, preferClosestOrgUnit, subject) {
|
|
3267
|
+
const date = this.readOrganizationToday();
|
|
2452
3268
|
const orgUnits = await this.readOrgUnitAndAncestors(manager, [orgUnitId]);
|
|
2453
3269
|
const managerMemberIds = await this.resolveManagerResolutionCandidates(manager, orgUnits.map((orgUnit) => ({
|
|
2454
3270
|
scopeId: orgUnit.id,
|
|
@@ -2456,7 +3272,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2456
3272
|
})), date, readOrgUnitDepthMap(orgUnits), preferClosestOrgUnit);
|
|
2457
3273
|
if (!managerMemberIds.length) {
|
|
2458
3274
|
return [
|
|
2459
|
-
this.resolveFallbackAssignee(instance, fallback, label, `找不到指定組織的主管規則`),
|
|
3275
|
+
this.resolveFallbackAssignee(instance, fallback, label, subject, `找不到指定組織的主管規則`),
|
|
2460
3276
|
];
|
|
2461
3277
|
}
|
|
2462
3278
|
return managerMemberIds;
|
|
@@ -2477,7 +3293,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2477
3293
|
const byId = new Map([...directOrgUnits, ...ancestorGroups.flat()].map((orgUnit) => [orgUnit.id, orgUnit]));
|
|
2478
3294
|
return [...byId.values()];
|
|
2479
3295
|
}
|
|
2480
|
-
async resolveManagerResolutionCandidates(manager, candidatePairs, date, orgUnitDepths, preferClosestOrgUnit) {
|
|
3296
|
+
async resolveManagerResolutionCandidates(manager, candidatePairs, date, orgUnitDepths, preferClosestOrgUnit, excludedManagerMemberIds = []) {
|
|
2481
3297
|
const scopeIds = candidatePairs.map((pair) => pair.scopeId);
|
|
2482
3298
|
if (!scopeIds.length) {
|
|
2483
3299
|
return [];
|
|
@@ -2499,56 +3315,80 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2499
3315
|
.filter((resolution) => candidatePairs.some((pair) => pair.scopeId === resolution.scopeId &&
|
|
2500
3316
|
pair.scopeType === resolution.scopeType))
|
|
2501
3317
|
.filter((resolution) => isDateRangeActive(resolution, date))
|
|
3318
|
+
// Dropped before the top-priority tier is chosen, so resolution falls
|
|
3319
|
+
// through to the next tier instead of ending on the excluded member.
|
|
3320
|
+
.filter((resolution) => !excludedManagerMemberIds.includes(resolution.managerMemberId))
|
|
2502
3321
|
.sort((left, right) => compareManagerResolution(left, right, depthLookup));
|
|
2503
3322
|
return uniqueTexts(readTopPriorityResolutions(active, depthLookup).map((resolution) => resolution.managerMemberId));
|
|
2504
3323
|
}
|
|
2505
|
-
async resolveOrgUnitMemberCandidates(manager, orgUnitId, includeDescendants, label) {
|
|
2506
|
-
const
|
|
2507
|
-
|
|
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();
|
|
2508
3330
|
const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
|
|
2509
|
-
where: { orgUnitId: (0, typeorm_2.In)([...orgUnitIds]) },
|
|
3331
|
+
where: { orgUnitId: (0, typeorm_2.In)([...scope.orgUnitIds]) },
|
|
2510
3332
|
});
|
|
2511
3333
|
const memberIds = memberships
|
|
2512
3334
|
.filter((membership) => isDateRangeActive(membership, today))
|
|
2513
3335
|
.sort(compareMembership)
|
|
2514
3336
|
.map((membership) => membership.memberId);
|
|
2515
3337
|
if (memberIds.length === 0) {
|
|
2516
|
-
throw new common_1.ConflictException(`${label}
|
|
3338
|
+
throw new common_1.ConflictException(`${label} 找不到${subject}:組織「${scope.name}」目前沒有任何有效成員,請先將人員加入該組織`);
|
|
2517
3339
|
}
|
|
2518
3340
|
return uniqueTexts(memberIds);
|
|
2519
3341
|
}
|
|
2520
|
-
async resolveOrgUnitPositionCandidates(manager, orgUnitId, positionId, includeDescendants, label) {
|
|
2521
|
-
const
|
|
2522
|
-
|
|
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();
|
|
2523
3348
|
const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
|
|
2524
|
-
where: { orgUnitId: (0, typeorm_2.In)([...orgUnitIds]), positionId },
|
|
3349
|
+
where: { orgUnitId: (0, typeorm_2.In)([...scope.orgUnitIds]), positionId },
|
|
2525
3350
|
});
|
|
2526
3351
|
const memberIds = memberships
|
|
2527
3352
|
.filter((membership) => isDateRangeActive(membership, today))
|
|
2528
3353
|
.sort(compareMembership)
|
|
2529
3354
|
.map((membership) => membership.memberId);
|
|
2530
3355
|
if (memberIds.length === 0) {
|
|
2531
|
-
throw new common_1.ConflictException(`${label}
|
|
3356
|
+
throw new common_1.ConflictException(`${label} 找不到${subject}:組織「${scope.name}」的職位「${await this.readPositionName(manager, positionId)}」目前沒有任何有效成員`);
|
|
2532
3357
|
}
|
|
2533
3358
|
return uniqueTexts(memberIds);
|
|
2534
3359
|
}
|
|
2535
|
-
|
|
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) {
|
|
2536
3370
|
const orgUnitRepository = manager.getRepository(org_unit_entity_1.OrgUnitEntity);
|
|
2537
3371
|
const orgUnit = await orgUnitRepository.findOne({
|
|
2538
3372
|
where: { deletedAt: (0, typeorm_2.IsNull)(), id: orgUnitId },
|
|
2539
3373
|
});
|
|
2540
3374
|
if (!orgUnit) {
|
|
2541
|
-
|
|
3375
|
+
return null;
|
|
2542
3376
|
}
|
|
2543
3377
|
if (!includeDescendants) {
|
|
2544
|
-
return [orgUnit.id];
|
|
3378
|
+
return { name: orgUnit.name, orgUnitIds: [orgUnit.id] };
|
|
2545
3379
|
}
|
|
2546
3380
|
const descendants = await orgUnitRepository
|
|
2547
3381
|
.createQueryBuilder('orgUnit')
|
|
2548
3382
|
.where('orgUnit.deleted_at IS NULL')
|
|
2549
3383
|
.andWhere('orgUnit.path <@ :path', { path: orgUnit.path })
|
|
2550
3384
|
.getMany();
|
|
2551
|
-
return
|
|
3385
|
+
return {
|
|
3386
|
+
name: orgUnit.name,
|
|
3387
|
+
orgUnitIds: uniqueTexts([
|
|
3388
|
+
orgUnit.id,
|
|
3389
|
+
...descendants.map((unit) => unit.id),
|
|
3390
|
+
]),
|
|
3391
|
+
};
|
|
2552
3392
|
}
|
|
2553
3393
|
async processExclusiveGateway(manager, instance, token, node) {
|
|
2554
3394
|
if (node.data.direction !== 'split') {
|
|
@@ -2630,7 +3470,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2630
3470
|
const recipients = (0, workflow_graph_1.isNotifyRecipientsEmpty)(action.recipients) &&
|
|
2631
3471
|
(0, workflow_graph_1.readNotifyWebhookTargets)(action).length > 0
|
|
2632
3472
|
? []
|
|
2633
|
-
: await this.resolveApproverResolver(manager, instance, action.recipients, `知會節點「${node.data.label}
|
|
3473
|
+
: await this.resolveApproverResolver(manager, instance, action.recipients, `知會節點「${node.data.label}」`, '通知對象');
|
|
2634
3474
|
const recipientMemberIds = uniqueTexts(recipients.map((recipient) => recipient.memberId));
|
|
2635
3475
|
await this.notificationService.createServiceTaskNotifications({
|
|
2636
3476
|
instance,
|
|
@@ -2658,11 +3498,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2658
3498
|
tokenId: token.id,
|
|
2659
3499
|
}, action.webhooks)
|
|
2660
3500
|
: [];
|
|
2661
|
-
await
|
|
2662
|
-
...token,
|
|
2663
|
-
consumedAt: new Date(),
|
|
2664
|
-
status: workflow_engine_enums_1.WorkflowTokenStatusEnum.CONSUMED,
|
|
2665
|
-
});
|
|
3501
|
+
await this.consumeToken(manager, token);
|
|
2666
3502
|
await activityRepository.save(activityRepository.create({
|
|
2667
3503
|
actorMemberId: null,
|
|
2668
3504
|
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TOKEN_ADVANCED,
|
|
@@ -2901,7 +3737,8 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2901
3737
|
}
|
|
2902
3738
|
const candidateRepository = manager.getRepository(task_candidate_entity_1.TaskCandidateEntity);
|
|
2903
3739
|
const candidates = (await candidateRepository.find({ where: { taskId: (0, typeorm_2.In)([...taskIds]) } })).filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING ||
|
|
2904
|
-
candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED
|
|
3740
|
+
candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED ||
|
|
3741
|
+
candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING);
|
|
2905
3742
|
if (candidates.length === 0) {
|
|
2906
3743
|
return;
|
|
2907
3744
|
}
|
|
@@ -3188,6 +4025,58 @@ function buildDryRunExpressionContext(input, lastDecision) {
|
|
|
3188
4025
|
function isRecord(value) {
|
|
3189
4026
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3190
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
|
+
}
|
|
3191
4080
|
function readWorkflowNodeOrThrow(workflow, nodeId) {
|
|
3192
4081
|
const node = workflow.nodes.find((candidate) => candidate.id === nodeId);
|
|
3193
4082
|
if (!node) {
|
|
@@ -3288,22 +4177,26 @@ function edgeHasCondition(edge) {
|
|
|
3288
4177
|
return Boolean(edge.data.condition?.trim() ||
|
|
3289
4178
|
(edge.data.conditionFieldKey && edge.data.conditionOperator));
|
|
3290
4179
|
}
|
|
3291
|
-
function
|
|
3292
|
-
return
|
|
4180
|
+
function readMissingOrgUnitMessage(label, subject, orgUnitId) {
|
|
4181
|
+
return `${label} 找不到${subject}:組織(${orgUnitId})已不存在,請在流程設計器重新選擇組織`;
|
|
3293
4182
|
}
|
|
3294
|
-
function
|
|
4183
|
+
function findMemberIdsInValue(value) {
|
|
3295
4184
|
if (typeof value === 'string' && value.trim()) {
|
|
3296
4185
|
return [value.trim()];
|
|
3297
4186
|
}
|
|
3298
4187
|
if (Array.isArray(value)) {
|
|
3299
|
-
|
|
4188
|
+
return uniqueTexts(value
|
|
3300
4189
|
.filter((candidate) => typeof candidate === 'string' && Boolean(candidate.trim()))
|
|
3301
|
-
.map((candidate) => candidate.trim());
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
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}`);
|
|
3305
4198
|
}
|
|
3306
|
-
|
|
4199
|
+
return memberIds;
|
|
3307
4200
|
}
|
|
3308
4201
|
function uniqueTexts(values) {
|
|
3309
4202
|
return [...new Set(values.filter((value) => value.trim()))];
|
|
@@ -3318,6 +4211,85 @@ function uniqueRuntimeCandidates(candidates) {
|
|
|
3318
4211
|
return true;
|
|
3319
4212
|
});
|
|
3320
4213
|
}
|
|
4214
|
+
// A task signs in order only when its snapshot says so and its candidates carry
|
|
4215
|
+
// a sequence; a SEQUENTIAL task from before ordering existed behaves as before.
|
|
4216
|
+
function isOrderedSequentialTask(task, candidates) {
|
|
4217
|
+
return (readDecisionPolicySnapshot(task).type === 'SEQUENTIAL' &&
|
|
4218
|
+
candidates.some((candidate) => candidate.sequence !== null));
|
|
4219
|
+
}
|
|
4220
|
+
// Every read of the setting goes through here so an explicit `null` behaves
|
|
4221
|
+
// exactly like an absent setting.
|
|
4222
|
+
function isInitiatorExcluded(node) {
|
|
4223
|
+
return Boolean(node.data.excludeInitiator);
|
|
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
|
+
}
|
|
4266
|
+
// A delegate acting for the initiator is still the initiator approving their
|
|
4267
|
+
// own document, so the initiator anywhere on the delegation path (the
|
|
4268
|
+
// pre-delegation member, any hop, or the final assignee) drops the 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));
|
|
4274
|
+
}
|
|
4275
|
+
// The initiator filter runs on the delegated list before the dedupe, so a
|
|
4276
|
+
// legitimate approver is never shadowed by an excluded entry for the same
|
|
4277
|
+
// final assignee. Without the exclusion this is the plain dedupe.
|
|
4278
|
+
function selectNodeCandidates(delegatedCandidates, excludeInitiator, excludedMemberIds) {
|
|
4279
|
+
return uniqueRuntimeCandidates(excludeInitiator
|
|
4280
|
+
? delegatedCandidates.filter((candidate) => !isExcludedMemberOnCandidatePath(candidate, excludedMemberIds))
|
|
4281
|
+
: delegatedCandidates);
|
|
4282
|
+
}
|
|
4283
|
+
// A COUNT threshold above the people left would never be reached once the
|
|
4284
|
+
// initiator is gone, so it is capped at who remains ("everyone else").
|
|
4285
|
+
// PERCENTAGE is computed from the actual candidate count and needs nothing.
|
|
4286
|
+
function capDecisionPolicyToCandidateCount(policy, candidateCount) {
|
|
4287
|
+
return policy?.type === 'QUORUM' &&
|
|
4288
|
+
policy.thresholdType === 'COUNT' &&
|
|
4289
|
+
policy.threshold > candidateCount
|
|
4290
|
+
? { ...policy, threshold: Math.max(candidateCount, 1) }
|
|
4291
|
+
: policy;
|
|
4292
|
+
}
|
|
3321
4293
|
function uniqueTasksById(tasks) {
|
|
3322
4294
|
const seen = new Set();
|
|
3323
4295
|
return tasks.filter((task) => {
|
|
@@ -3734,9 +4706,6 @@ function compareMembership(left, right) {
|
|
|
3734
4706
|
}
|
|
3735
4707
|
return right.effectiveFrom.localeCompare(left.effectiveFrom);
|
|
3736
4708
|
}
|
|
3737
|
-
function toDateOnlyString(value) {
|
|
3738
|
-
return value.toISOString().slice(0, 10);
|
|
3739
|
-
}
|
|
3740
4709
|
function canReadAllWorkflows(scope) {
|
|
3741
4710
|
return (scope.roles.includes('BPM_ADMIN') ||
|
|
3742
4711
|
scope.permissions.some((permission) => WORKFLOW_READ_ALL_PERMISSIONS.has(permission)));
|
|
@@ -3767,9 +4736,26 @@ function validateSubmittedFormData(schema, formData) {
|
|
|
3767
4736
|
.map((field) => field.label || field.fieldKey)
|
|
3768
4737
|
.join(', ')}`);
|
|
3769
4738
|
}
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
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
|
+
];
|
|
3773
4759
|
if (tableErrors.length > 0) {
|
|
3774
4760
|
throw new common_1.BadRequestException(tableErrors.join('; '));
|
|
3775
4761
|
}
|
|
@@ -3780,28 +4766,32 @@ function validateSubmittedFormData(schema, formData) {
|
|
|
3780
4766
|
* required / shape / row count are enforced server side; `minimum`, `maxLength`
|
|
3781
4767
|
* and friends stay a frontend hint.
|
|
3782
4768
|
*/
|
|
3783
|
-
function validateSubmittedTableValue(field, value, required) {
|
|
4769
|
+
function validateSubmittedTableValue(field, value, required, shapeOnly = false) {
|
|
3784
4770
|
const path = `formData.${field.fieldKey}`;
|
|
3785
4771
|
if (typeof value === 'undefined' || value === null) {
|
|
3786
|
-
return
|
|
3787
|
-
? [
|
|
3788
|
-
:
|
|
4772
|
+
return shapeOnly
|
|
4773
|
+
? []
|
|
4774
|
+
: required || readTableMinRows(field) > 0
|
|
4775
|
+
? [`${path} must include at least ${readTableRequiredRowCount(field)} row(s)`]
|
|
4776
|
+
: [];
|
|
3789
4777
|
}
|
|
3790
4778
|
if (!Array.isArray(value)) {
|
|
3791
4779
|
return [`${path} must be an array of rows`];
|
|
3792
4780
|
}
|
|
3793
|
-
const rowErrors = value.flatMap((row, index) => validateSubmittedTableRow(field, row, `${path}[${index}]
|
|
4781
|
+
const rowErrors = value.flatMap((row, index) => validateSubmittedTableRow(field, row, `${path}[${index}]`, shapeOnly));
|
|
3794
4782
|
// A malformed row cannot be counted against min/max meaningfully; report the
|
|
3795
4783
|
// shape problem first so the submitter fixes the real cause.
|
|
3796
4784
|
if (rowErrors.length > 0) {
|
|
3797
4785
|
return rowErrors;
|
|
3798
4786
|
}
|
|
3799
|
-
return validateSubmittedTableRowCount(field, value.length, required, path);
|
|
4787
|
+
return validateSubmittedTableRowCount(field, value.length, required, path, shapeOnly);
|
|
3800
4788
|
}
|
|
3801
|
-
function validateSubmittedTableRowCount(field, rowCount, required, path) {
|
|
3802
|
-
const minRows =
|
|
3803
|
-
?
|
|
3804
|
-
:
|
|
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);
|
|
3805
4795
|
const maxRows = readTableMaxRows(field);
|
|
3806
4796
|
return [
|
|
3807
4797
|
...(rowCount < minRows
|
|
@@ -3812,7 +4802,7 @@ function validateSubmittedTableRowCount(field, rowCount, required, path) {
|
|
|
3812
4802
|
: []),
|
|
3813
4803
|
];
|
|
3814
4804
|
}
|
|
3815
|
-
function validateSubmittedTableRow(field, row, path) {
|
|
4805
|
+
function validateSubmittedTableRow(field, row, path, shapeOnly = false) {
|
|
3816
4806
|
if (!isRecord(row)) {
|
|
3817
4807
|
return [`${path} must be an object`];
|
|
3818
4808
|
}
|
|
@@ -3823,10 +4813,12 @@ function validateSubmittedTableRow(field, row, path) {
|
|
|
3823
4813
|
const cellErrors = Object.entries(row)
|
|
3824
4814
|
.filter(([columnKey, cellValue]) => columnsByKey.has(columnKey) && !(0, form_1.isFormTableCellValue)(cellValue))
|
|
3825
4815
|
.map(([columnKey]) => `${path}.${columnKey} is not a valid cell value`);
|
|
3826
|
-
const requiredErrors =
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
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`);
|
|
3830
4822
|
return [...unknownKeyErrors, ...cellErrors, ...requiredErrors];
|
|
3831
4823
|
}
|
|
3832
4824
|
/**
|
|
@@ -3871,6 +4863,11 @@ function isFormDefinitionSchema(value) {
|
|
|
3871
4863
|
value.fields.every((field) => isRecord(field)));
|
|
3872
4864
|
}
|
|
3873
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
|
+
}
|
|
3874
4871
|
return field.visibleWhen
|
|
3875
4872
|
? evaluateFormConditionExpression(field.visibleWhen, fields, values, true)
|
|
3876
4873
|
: true;
|
|
@@ -3998,6 +4995,84 @@ function isSubmittedFieldValuePresent(value) {
|
|
|
3998
4995
|
function isNumberFormField(field) {
|
|
3999
4996
|
return field.type === 'number' || field.type === 'money';
|
|
4000
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
|
+
}
|
|
4001
5076
|
function createDryRunStep(input) {
|
|
4002
5077
|
return Object.assign(new workflow_dry_run_object_1.WorkflowDryRunStepObject(), {
|
|
4003
5078
|
assigneeMemberId: input.assigneeMemberId,
|
|
@@ -4074,4 +5149,34 @@ function mapDecisionToResolution(action) {
|
|
|
4074
5149
|
return notification_enums_1.NotificationResolutionEnum.TRANSFERRED;
|
|
4075
5150
|
}
|
|
4076
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
|
+
}
|
|
4077
5182
|
//# sourceMappingURL=workflow-engine.service.js.map
|