@rytass/bpm-core-nestjs-module 0.13.5 → 0.14.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 +55 -0
- package/package.json +2 -2
- 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/index.d.ts +1 -0
- package/src/lib/migrations/index.js +3 -0
- package/src/lib/migrations/index.js.map +1 -1
- package/src/lib/notification/notification.service.js +2 -2
- package/src/lib/notification/notification.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/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 +11 -0
- package/src/lib/workflow-engine/task.entity.js +15 -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.service.d.ts +60 -1
- package/src/lib/workflow-engine/workflow-engine.service.js +678 -49
- package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
|
@@ -23,6 +23,7 @@ const condition_service_1 = require("../condition/condition.service");
|
|
|
23
23
|
const manager_resolution_entity_1 = require("../organization/manager-resolution.entity");
|
|
24
24
|
const membership_entity_1 = require("../organization/membership.entity");
|
|
25
25
|
const org_unit_entity_1 = require("../organization/org-unit.entity");
|
|
26
|
+
const position_entity_1 = require("../organization/position.entity");
|
|
26
27
|
const organization_enums_1 = require("../organization/organization.enums");
|
|
27
28
|
const approval_template_version_entity_1 = require("../template/approval-template-version.entity");
|
|
28
29
|
const approval_template_entity_1 = require("../template/approval-template.entity");
|
|
@@ -41,6 +42,8 @@ const workflow_service_task_dispatcher_token_1 = require("./workflow-service-tas
|
|
|
41
42
|
const workflow_token_entity_1 = require("./workflow-token.entity");
|
|
42
43
|
const bpm_root_options_1 = require("../bpm/bpm-root-options");
|
|
43
44
|
const MAX_PROCESSING_STEPS = 500;
|
|
45
|
+
// Guards ORG_MANAGER_CHAIN against misconfigured manager rules that never end.
|
|
46
|
+
const ORG_MANAGER_CHAIN_MAX_HOPS = 20;
|
|
44
47
|
const DEFAULT_APPROVAL_HISTORY_TASK_LIMIT = 50;
|
|
45
48
|
const SYSTEM_DRY_RUN_INSTANCE_ID = 'dry-run-instance';
|
|
46
49
|
/**
|
|
@@ -313,6 +316,14 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
313
316
|
taskNode.data.allowTransfer === false) {
|
|
314
317
|
throw new common_1.ForbiddenException(`Workflow node ${task.nodeId} does not allow transfer`);
|
|
315
318
|
}
|
|
319
|
+
// Only guards a human picking the target; SLA escalation passes a
|
|
320
|
+
// `transferReason` and is deliberately left alone.
|
|
321
|
+
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED &&
|
|
322
|
+
options.transferReason === undefined &&
|
|
323
|
+
isInitiatorExcluded(taskNode) &&
|
|
324
|
+
transferToMemberId === instance.initiatorMemberId) {
|
|
325
|
+
throw new common_1.ConflictException(`簽核節點「${taskNode.data.label}」不允許申請人自簽,不能將任務轉交給申請人本人`);
|
|
326
|
+
}
|
|
316
327
|
const returnToNodeId = input.action === workflow_engine_enums_1.TaskDecisionActionEnum.RETURNED
|
|
317
328
|
? readReturnTargetNodeId(instance.workflowSnapshot, task.nodeId, input.returnToNodeId)
|
|
318
329
|
: null;
|
|
@@ -422,6 +433,17 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
422
433
|
supersedeOthers: taskEnded,
|
|
423
434
|
taskId: completedTask.id,
|
|
424
435
|
});
|
|
436
|
+
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED &&
|
|
437
|
+
!shouldCompleteTask) {
|
|
438
|
+
await this.activateNextSequentialCandidate({
|
|
439
|
+
candidates: nextCandidateRows,
|
|
440
|
+
instance,
|
|
441
|
+
manager,
|
|
442
|
+
node: taskNode,
|
|
443
|
+
previousMemberId: input.decidedByMemberId,
|
|
444
|
+
task: completedTask,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
425
447
|
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED &&
|
|
426
448
|
shouldCompleteTask) {
|
|
427
449
|
// Ad-hoc gate: the token may only advance once every task bound to
|
|
@@ -592,6 +614,13 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
592
614
|
const targetValue = buildAdhocTargetValue(target);
|
|
593
615
|
// Rejects WEBHOOK targets — countersigners must be members.
|
|
594
616
|
buildAdhocApproverResolver(targetValue);
|
|
617
|
+
// The countersign task is only created when a later user task is
|
|
618
|
+
// reached, against that node's own setting. This checks the target
|
|
619
|
+
// against the node being decided now, as an early rejection for the
|
|
620
|
+
// common case; it is not a guarantee about the node it attaches to.
|
|
621
|
+
if (isInitiatorExcluded(node)) {
|
|
622
|
+
await this.resolveAdhocTaskCandidates(manager, instance, node, '臨時會簽', targetValue);
|
|
623
|
+
}
|
|
595
624
|
const directiveRepository = manager.getRepository(adhoc_directive_entity_1.AdhocDirectiveEntity);
|
|
596
625
|
const directive = await directiveRepository.save(directiveRepository.create({
|
|
597
626
|
channels: null,
|
|
@@ -707,6 +736,9 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
707
736
|
if (!isDirectAssignee && !actorCandidate) {
|
|
708
737
|
throw new common_1.ConflictException(`Task ${task.id} is assigned to another member`);
|
|
709
738
|
}
|
|
739
|
+
if (actorCandidate?.status === workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING) {
|
|
740
|
+
throw new common_1.ConflictException(`Task ${task.id} is not this member's turn yet`);
|
|
741
|
+
}
|
|
710
742
|
if (actorCandidate &&
|
|
711
743
|
actorCandidate.status !== workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING &&
|
|
712
744
|
actorCandidate.status !== workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED) {
|
|
@@ -771,11 +803,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
771
803
|
const label = directive.type === adhoc_enums_1.AdhocDirectiveTypeEnum.COUNTERSIGN
|
|
772
804
|
? '臨時會簽'
|
|
773
805
|
: '臨時加簽';
|
|
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
|
-
}
|
|
806
|
+
const candidates = await this.resolveAdhocTaskCandidates(manager, instance, node, label, readAdhocTargetValue(directive));
|
|
779
807
|
const primaryCandidate = candidates[0];
|
|
780
808
|
const task = await taskRepository.save(taskRepository.create({
|
|
781
809
|
adhocDirectiveId: directive.id,
|
|
@@ -821,6 +849,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
821
849
|
taskId: task.id,
|
|
822
850
|
})));
|
|
823
851
|
task.candidateMemberIds = savedCandidates.map((candidate) => candidate.memberId);
|
|
852
|
+
task.openCandidateMemberIds = task.candidateMemberIds;
|
|
824
853
|
await activityRepository.save(activityRepository.create({
|
|
825
854
|
actorMemberId: directive.createdByMemberId,
|
|
826
855
|
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_CREATED,
|
|
@@ -852,6 +881,17 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
852
881
|
}, Promise.resolve());
|
|
853
882
|
return task;
|
|
854
883
|
}
|
|
884
|
+
async resolveAdhocTaskCandidates(manager, instance, node, label, targetValue) {
|
|
885
|
+
const resolvedCandidates = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), `${label}「${node.data.label}」`);
|
|
886
|
+
const delegatedCandidates = await this.delegateResolvedCandidates(instance, node.id, resolvedCandidates);
|
|
887
|
+
const candidates = selectNodeCandidates(delegatedCandidates, isInitiatorExcluded(node), instance.initiatorMemberId);
|
|
888
|
+
if (candidates.length > 0) {
|
|
889
|
+
return candidates;
|
|
890
|
+
}
|
|
891
|
+
throw new common_1.ConflictException(delegatedCandidates.length > 0
|
|
892
|
+
? `${label}「${node.data.label}」 無法建立任務:指定對象只有申請人本人,此節點不允許申請人簽核。`
|
|
893
|
+
: `${label}「${node.data.label}」 did not resolve to any member id`);
|
|
894
|
+
}
|
|
855
895
|
async spawnCountersignTasksForNode(manager, instance, token, node, now) {
|
|
856
896
|
const directiveRepository = manager.getRepository(adhoc_directive_entity_1.AdhocDirectiveEntity);
|
|
857
897
|
const directives = await directiveRepository.find({
|
|
@@ -915,9 +955,18 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
915
955
|
const taskRepository = manager.getRepository(task_entity_1.TaskEntity);
|
|
916
956
|
const taskCandidateRepository = manager.getRepository(task_candidate_entity_1.TaskCandidateEntity);
|
|
917
957
|
const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
|
|
918
|
-
const
|
|
958
|
+
const adhocOriginTask = task.adhocOriginTaskId
|
|
919
959
|
? await taskRepository.findOne({ where: { id: task.adhocOriginTaskId } })
|
|
920
960
|
: null;
|
|
961
|
+
// transferTask re-points an open pre-approval when its origin is
|
|
962
|
+
// transferred, but a pre-approval whose origin was transferred by a release
|
|
963
|
+
// that did not do that yet still points at the TRANSFERRED row, and so does
|
|
964
|
+
// the pointer on `task` when a transfer commits after decideTask read it
|
|
965
|
+
// and before it took the instance lock. Following the transfer here hands
|
|
966
|
+
// the decision to whoever holds the stage now in both cases.
|
|
967
|
+
const originTask = adhocOriginTask
|
|
968
|
+
? await this.readTransferSuccessorTask(manager, adhocOriginTask)
|
|
969
|
+
: null;
|
|
921
970
|
const originTaskIsOpen = originTask?.status === workflow_engine_enums_1.TaskStatusEnum.PENDING ||
|
|
922
971
|
originTask?.status === workflow_engine_enums_1.TaskStatusEnum.IN_PROGRESS;
|
|
923
972
|
// Safety: when there is nobody to hand the decision back to (origin task
|
|
@@ -927,21 +976,43 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
927
976
|
if (!originTask || (!originTaskIsOpen && !originTask.assigneeMemberId)) {
|
|
928
977
|
return false;
|
|
929
978
|
}
|
|
979
|
+
const originCandidates = readDecisionPolicySnapshot(originTask).type === 'SEQUENTIAL'
|
|
980
|
+
? await taskCandidateRepository.find({
|
|
981
|
+
where: { taskId: originTask.id },
|
|
982
|
+
})
|
|
983
|
+
: [];
|
|
984
|
+
const isSequentialOrigin = isOrderedSequentialTask(originTask, originCandidates);
|
|
930
985
|
const node = readWorkflowNodeOrThrow(instance.workflowSnapshot, task.nodeId);
|
|
931
986
|
if (!originTaskIsOpen && originTask.assigneeMemberId) {
|
|
932
987
|
// The origin approver already decided — reopen a fresh decision task so
|
|
933
|
-
// the stage can be re-decided after the rejected pre-approval.
|
|
988
|
+
// the stage can be re-decided after the rejected pre-approval. A
|
|
989
|
+
// SEQUENTIAL task's assignee rotates and ends on the last signer, so it
|
|
990
|
+
// goes back to whoever asked for the pre-approval instead.
|
|
991
|
+
const requesterCandidate = isSequentialOrigin
|
|
992
|
+
? await this.readSequentialRequesterCandidate(manager, originTask, originCandidates, directive.createdByMemberId)
|
|
993
|
+
: undefined;
|
|
994
|
+
// A sequential origin always reopens for the requester, even when the
|
|
995
|
+
// successor of a transferred task has no row for them.
|
|
996
|
+
const reopenedAssigneeMemberId = isSequentialOrigin
|
|
997
|
+
? directive.createdByMemberId
|
|
998
|
+
: originTask.assigneeMemberId;
|
|
999
|
+
const reopenedOriginalMemberId = isSequentialOrigin
|
|
1000
|
+
? (requesterCandidate?.originalMemberId ?? directive.createdByMemberId)
|
|
1001
|
+
: originTask.originalAssigneeMemberId;
|
|
1002
|
+
const reopenedDelegationChain = isSequentialOrigin
|
|
1003
|
+
? (requesterCandidate?.delegationChain ?? [])
|
|
1004
|
+
: originTask.delegationChain;
|
|
934
1005
|
const reopenedTask = await taskRepository.save(taskRepository.create({
|
|
935
|
-
assigneeMemberId:
|
|
1006
|
+
assigneeMemberId: reopenedAssigneeMemberId,
|
|
936
1007
|
assignmentType: workflow_engine_enums_1.TaskAssignmentTypeEnum.DIRECT_MEMBER,
|
|
937
1008
|
completedAt: null,
|
|
938
1009
|
createdAt: decidedAt,
|
|
939
1010
|
decisionPolicySnapshot: { type: 'SINGLE' },
|
|
940
|
-
delegationChain:
|
|
1011
|
+
delegationChain: reopenedDelegationChain,
|
|
941
1012
|
instanceId: instance.id,
|
|
942
1013
|
nodeId: originTask.nodeId,
|
|
943
1014
|
openedAt: null,
|
|
944
|
-
originalAssigneeMemberId:
|
|
1015
|
+
originalAssigneeMemberId: reopenedOriginalMemberId,
|
|
945
1016
|
slaDueAt: null,
|
|
946
1017
|
status: workflow_engine_enums_1.TaskStatusEnum.PENDING,
|
|
947
1018
|
tokenId: originTask.tokenId,
|
|
@@ -950,9 +1021,9 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
950
1021
|
claimedAt: null,
|
|
951
1022
|
createdAt: decidedAt,
|
|
952
1023
|
decidedAt: null,
|
|
953
|
-
delegationChain:
|
|
954
|
-
memberId:
|
|
955
|
-
originalMemberId:
|
|
1024
|
+
delegationChain: reopenedDelegationChain,
|
|
1025
|
+
memberId: reopenedAssigneeMemberId,
|
|
1026
|
+
originalMemberId: reopenedOriginalMemberId ?? reopenedAssigneeMemberId,
|
|
956
1027
|
sourceType: 'DIRECT',
|
|
957
1028
|
status: workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING,
|
|
958
1029
|
taskId: reopenedTask.id,
|
|
@@ -964,7 +1035,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
964
1035
|
nodeId: originTask.nodeId,
|
|
965
1036
|
payload: {
|
|
966
1037
|
adhocDirectiveId: directive.id,
|
|
967
|
-
assigneeMemberId:
|
|
1038
|
+
assigneeMemberId: reopenedAssigneeMemberId,
|
|
968
1039
|
reopenedFromTaskId: originTask.id,
|
|
969
1040
|
rejectedAdhocTaskId: task.id,
|
|
970
1041
|
tokenId: originTask.tokenId,
|
|
@@ -980,7 +1051,14 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
980
1051
|
});
|
|
981
1052
|
}
|
|
982
1053
|
}
|
|
983
|
-
|
|
1054
|
+
// On a SEQUENTIAL task the requester has already signed and the task has
|
|
1055
|
+
// moved on to the next signer, so they are told as well. On any other task
|
|
1056
|
+
// the requester is the origin holder (or handed the stage away by a
|
|
1057
|
+
// transfer) and only whoever holds the task now is told.
|
|
1058
|
+
const recipientMemberIds = uniqueTexts([
|
|
1059
|
+
...(isSequentialOrigin ? [directive.createdByMemberId] : []),
|
|
1060
|
+
...(await this.readOpenTaskRecipientMemberIds(manager, originTask)),
|
|
1061
|
+
]);
|
|
984
1062
|
if (recipientMemberIds.length > 0) {
|
|
985
1063
|
await this.notificationService.createAdhocWorkflowNotifications({
|
|
986
1064
|
channels: null,
|
|
@@ -1012,6 +1090,89 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1012
1090
|
}));
|
|
1013
1091
|
return true;
|
|
1014
1092
|
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Follows a TRANSFERRED task to the task that replaced it, repeatedly, and
|
|
1095
|
+
* returns the last one reached. A transfer leaves no pointer on either task
|
|
1096
|
+
* row; the link is the `transferredFromTaskId` in the payload of the
|
|
1097
|
+
* TASK_CREATED activity written for the replacement. When the trail stops
|
|
1098
|
+
* (no such activity, or its task is gone) the last task reached is returned,
|
|
1099
|
+
* so a caller sees the transferred row exactly as it did before.
|
|
1100
|
+
*/
|
|
1101
|
+
async readTransferSuccessorTask(manager, task) {
|
|
1102
|
+
if (task.status !== workflow_engine_enums_1.TaskStatusEnum.TRANSFERRED) {
|
|
1103
|
+
return task;
|
|
1104
|
+
}
|
|
1105
|
+
const taskRepository = manager.getRepository(task_entity_1.TaskEntity);
|
|
1106
|
+
const taskCreatedActivities = await manager
|
|
1107
|
+
.getRepository(activity_log_entity_1.ActivityLogEntity)
|
|
1108
|
+
.find({
|
|
1109
|
+
where: {
|
|
1110
|
+
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_CREATED,
|
|
1111
|
+
instanceId: task.instanceId,
|
|
1112
|
+
nodeId: task.nodeId,
|
|
1113
|
+
},
|
|
1114
|
+
});
|
|
1115
|
+
const successorTaskIdByTransferredTaskId = new Map(taskCreatedActivities.flatMap((activity) => {
|
|
1116
|
+
const transferredFromTaskId = activity.payload['transferredFromTaskId'];
|
|
1117
|
+
return typeof transferredFromTaskId === 'string' && activity.taskId
|
|
1118
|
+
? [[transferredFromTaskId, activity.taskId]]
|
|
1119
|
+
: [];
|
|
1120
|
+
}));
|
|
1121
|
+
const followTransfer = async (currentTask, visitedTaskIds) => {
|
|
1122
|
+
const successorTaskId = currentTask.status === workflow_engine_enums_1.TaskStatusEnum.TRANSFERRED
|
|
1123
|
+
? successorTaskIdByTransferredTaskId.get(currentTask.id)
|
|
1124
|
+
: undefined;
|
|
1125
|
+
if (!successorTaskId || visitedTaskIds.has(successorTaskId)) {
|
|
1126
|
+
return currentTask;
|
|
1127
|
+
}
|
|
1128
|
+
const successorTask = await taskRepository.findOne({
|
|
1129
|
+
where: { id: successorTaskId },
|
|
1130
|
+
});
|
|
1131
|
+
return successorTask
|
|
1132
|
+
? followTransfer(successorTask, new Set([...visitedTaskIds, successorTaskId]))
|
|
1133
|
+
: currentTask;
|
|
1134
|
+
};
|
|
1135
|
+
return followTransfer(task, new Set([task.id]));
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* The requester's candidate row on a SEQUENTIAL origin task. A task that was
|
|
1139
|
+
* transferred mid-order continues as a replacement that carries only the
|
|
1140
|
+
* candidates still to sign, so a requester who already signed is found on an
|
|
1141
|
+
* earlier task of the transfer chain instead.
|
|
1142
|
+
*/
|
|
1143
|
+
async readSequentialRequesterCandidate(manager, originTask, originCandidates, requesterMemberId) {
|
|
1144
|
+
const ownCandidate = originCandidates.find((candidate) => candidate.memberId === requesterMemberId);
|
|
1145
|
+
if (ownCandidate) {
|
|
1146
|
+
return ownCandidate;
|
|
1147
|
+
}
|
|
1148
|
+
const taskCreatedActivities = await manager
|
|
1149
|
+
.getRepository(activity_log_entity_1.ActivityLogEntity)
|
|
1150
|
+
.find({
|
|
1151
|
+
where: {
|
|
1152
|
+
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_CREATED,
|
|
1153
|
+
instanceId: originTask.instanceId,
|
|
1154
|
+
nodeId: originTask.nodeId,
|
|
1155
|
+
},
|
|
1156
|
+
});
|
|
1157
|
+
const transferredFromTaskIdByTaskId = new Map(taskCreatedActivities.flatMap((activity) => {
|
|
1158
|
+
const transferredFromTaskId = activity.payload['transferredFromTaskId'];
|
|
1159
|
+
return typeof transferredFromTaskId === 'string' && activity.taskId
|
|
1160
|
+
? [[activity.taskId, transferredFromTaskId]]
|
|
1161
|
+
: [];
|
|
1162
|
+
}));
|
|
1163
|
+
const searchEarlierTasks = async (currentTaskId, visitedTaskIds) => {
|
|
1164
|
+
const earlierTaskId = transferredFromTaskIdByTaskId.get(currentTaskId);
|
|
1165
|
+
if (!earlierTaskId || visitedTaskIds.has(earlierTaskId)) {
|
|
1166
|
+
return undefined;
|
|
1167
|
+
}
|
|
1168
|
+
const earlierCandidate = (await manager
|
|
1169
|
+
.getRepository(task_candidate_entity_1.TaskCandidateEntity)
|
|
1170
|
+
.find({ where: { taskId: earlierTaskId } })).find((candidate) => candidate.memberId === requesterMemberId);
|
|
1171
|
+
return (earlierCandidate ??
|
|
1172
|
+
searchEarlierTasks(earlierTaskId, new Set([...visitedTaskIds, earlierTaskId])));
|
|
1173
|
+
};
|
|
1174
|
+
return searchEarlierTasks(originTask.id, new Set([originTask.id]));
|
|
1175
|
+
}
|
|
1015
1176
|
async readOpenTaskRecipientMemberIds(manager, task) {
|
|
1016
1177
|
if (task.assigneeMemberId) {
|
|
1017
1178
|
return [task.assigneeMemberId];
|
|
@@ -1229,10 +1390,12 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1229
1390
|
}
|
|
1230
1391
|
const nextVisitedNodeIds = new Set([...input.visitedNodeIds, visitKey]);
|
|
1231
1392
|
const entryConditionMet = this.conditionService.evaluateBoolean(entryCondition, buildDryRunExpressionContext(input, input.lastDecision), `workflow.nodes.${node.id}.data.entryCondition`);
|
|
1232
|
-
const
|
|
1233
|
-
? this.
|
|
1393
|
+
const assignment = entryConditionMet && node.type === 'userTask'
|
|
1394
|
+
? this.resolveDryRunAssignment(input, node)
|
|
1234
1395
|
: null;
|
|
1235
|
-
const
|
|
1396
|
+
const assigneeMemberId = assignment?.assigneeMemberId ?? null;
|
|
1397
|
+
const skippedByExclusion = assignment?.outcome === 'SKIPPED';
|
|
1398
|
+
const nextLastDecision = entryConditionMet && node.type === 'userTask' && !skippedByExclusion
|
|
1236
1399
|
? {
|
|
1237
1400
|
action: workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED,
|
|
1238
1401
|
assigneeMemberId,
|
|
@@ -1248,19 +1411,24 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1248
1411
|
edgeReason: readDryRunEdgeReason(input.incomingEdge),
|
|
1249
1412
|
entryCondition,
|
|
1250
1413
|
entryConditionMet,
|
|
1251
|
-
message: readDryRunStepMessage(node, entryConditionMet),
|
|
1414
|
+
message: assignment?.message ?? readDryRunStepMessage(node, entryConditionMet),
|
|
1252
1415
|
node,
|
|
1253
|
-
status: readDryRunStepStatus(node, entryConditionMet),
|
|
1416
|
+
status: assignment?.outcome ?? readDryRunStepStatus(node, entryConditionMet),
|
|
1254
1417
|
stepIndex: input.steps.length,
|
|
1255
1418
|
}),
|
|
1256
1419
|
];
|
|
1420
|
+
// The initiator is the only approver and the node cannot proceed: this
|
|
1421
|
+
// branch ends here, as it would fail at runtime.
|
|
1422
|
+
if (assignment?.outcome === 'STOPPED') {
|
|
1423
|
+
return nextSteps;
|
|
1424
|
+
}
|
|
1257
1425
|
// A NOTIFY node is an asynchronous branch: at runtime it consumes its
|
|
1258
1426
|
// token and never advances, so its branch ends here rather than failing
|
|
1259
1427
|
// for want of an outgoing edge it is not allowed to have.
|
|
1260
1428
|
if ((0, workflow_graph_1.isAsyncNotifyServiceTask)(node)) {
|
|
1261
1429
|
return nextSteps;
|
|
1262
1430
|
}
|
|
1263
|
-
if (!entryConditionMet || node.type === 'endEvent') {
|
|
1431
|
+
if (!entryConditionMet || skippedByExclusion || node.type === 'endEvent') {
|
|
1264
1432
|
if (node.type === 'endEvent') {
|
|
1265
1433
|
return nextSteps;
|
|
1266
1434
|
}
|
|
@@ -1315,6 +1483,80 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1315
1483
|
}
|
|
1316
1484
|
return (0, workflow_condition_evaluator_1.evaluateWorkflowEdgeCondition)(edge, { formData: input.formData });
|
|
1317
1485
|
}
|
|
1486
|
+
/**
|
|
1487
|
+
* The dry-run assignee of a user task. Only resolvers whose dry run yields
|
|
1488
|
+
* real member ids (DIRECT, DYNAMIC_FORM, EXPRESSION and ORG_MANAGER, which
|
|
1489
|
+
* reads the initiator snapshot) can be checked against the initiator; the
|
|
1490
|
+
* others resolve to placeholders and are left as they are.
|
|
1491
|
+
*/
|
|
1492
|
+
resolveDryRunAssignment(input, node) {
|
|
1493
|
+
const resolver = node.data.approverResolver;
|
|
1494
|
+
const label = `workflow.nodes.${node.id}.data.approverResolver`;
|
|
1495
|
+
const excludeInitiator = node.data.excludeInitiator;
|
|
1496
|
+
const memberIds = excludeInitiator
|
|
1497
|
+
? this.readDryRunMemberIds(input, resolver, label)
|
|
1498
|
+
: null;
|
|
1499
|
+
if (!excludeInitiator || !memberIds?.includes(input.initiatorMemberId)) {
|
|
1500
|
+
return {
|
|
1501
|
+
assigneeMemberId: this.resolveDryRunAssigneeMemberId(input, resolver, label),
|
|
1502
|
+
message: null,
|
|
1503
|
+
outcome: null,
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
const remainingMemberId = memberIds.find((memberId) => memberId !== input.initiatorMemberId);
|
|
1507
|
+
if (remainingMemberId !== undefined) {
|
|
1508
|
+
return {
|
|
1509
|
+
assigneeMemberId: remainingMemberId,
|
|
1510
|
+
message: null,
|
|
1511
|
+
outcome: null,
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
if (excludeInitiator.whenEmpty === 'SKIP_NODE') {
|
|
1515
|
+
return {
|
|
1516
|
+
assigneeMemberId: null,
|
|
1517
|
+
message: '簽核人只有申請人本人,依設定略過節點。',
|
|
1518
|
+
outcome: 'SKIPPED',
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
// Level 1 is the initiator when they head their unit; like the runtime,
|
|
1522
|
+
// fall through to the next level of the snapshot's manager chain.
|
|
1523
|
+
const managerMemberId = excludeInitiator.whenEmpty === 'INITIATOR_MANAGER'
|
|
1524
|
+
? [1, 2]
|
|
1525
|
+
.map((levelsUp) => readManagerMemberIdFromInitiatorSnapshot(input.initiatorMetadataSnapshot, levelsUp))
|
|
1526
|
+
.find((memberId) => typeof memberId === 'string' &&
|
|
1527
|
+
memberId.trim() !== '' &&
|
|
1528
|
+
memberId.trim() !== input.initiatorMemberId)
|
|
1529
|
+
: undefined;
|
|
1530
|
+
if (managerMemberId !== undefined) {
|
|
1531
|
+
return {
|
|
1532
|
+
assigneeMemberId: managerMemberId.trim(),
|
|
1533
|
+
message: null,
|
|
1534
|
+
outcome: null,
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
return {
|
|
1538
|
+
assigneeMemberId: null,
|
|
1539
|
+
message: excludeInitiator.whenEmpty === 'INITIATOR_MANAGER'
|
|
1540
|
+
? '簽核人只有申請人本人,此節點不允許申請人自簽,且找不到申請人的主管可改派。'
|
|
1541
|
+
: '簽核人只有申請人本人,此節點不允許申請人自簽。',
|
|
1542
|
+
outcome: 'STOPPED',
|
|
1543
|
+
};
|
|
1544
|
+
}
|
|
1545
|
+
readDryRunMemberIds(input, resolver, label) {
|
|
1546
|
+
if (resolver.type === 'DIRECT') {
|
|
1547
|
+
return readMemberIdsFromValue(resolver.memberIds, label);
|
|
1548
|
+
}
|
|
1549
|
+
if (resolver.type === 'DYNAMIC_FORM') {
|
|
1550
|
+
return readMemberIdsFromValue(readValueAtPath(input.formData, resolver.formPath), `${label}.formPath`);
|
|
1551
|
+
}
|
|
1552
|
+
if (resolver.type === 'EXPRESSION') {
|
|
1553
|
+
return readMemberIdsFromValue(this.conditionService.evaluateValue(resolver.expression, buildDryRunExpressionContext(input, null), `${label}.expression`), `${label}.expression`);
|
|
1554
|
+
}
|
|
1555
|
+
if (resolver.type === 'ORG_MANAGER') {
|
|
1556
|
+
return readMemberIdsFromValue(readManagerMemberIdFromInitiatorSnapshot(input.initiatorMetadataSnapshot, resolver.levelsUp), label);
|
|
1557
|
+
}
|
|
1558
|
+
return null;
|
|
1559
|
+
}
|
|
1318
1560
|
resolveDryRunAssigneeMemberId(input, resolver, label) {
|
|
1319
1561
|
if (resolver.type === 'DIRECT') {
|
|
1320
1562
|
return readMemberIdFromValue(resolver.memberIds, label);
|
|
@@ -1337,6 +1579,9 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1337
1579
|
if (resolver.type === 'ORG_UNIT_MANAGER') {
|
|
1338
1580
|
return `orgUnitManager:${resolver.orgUnitId}`;
|
|
1339
1581
|
}
|
|
1582
|
+
if (resolver.type === 'ORG_MANAGER_CHAIN') {
|
|
1583
|
+
return `orgManagerChain:${resolver.minPositionLevel}`;
|
|
1584
|
+
}
|
|
1340
1585
|
return readMemberIdFromValue(readManagerMemberIdFromInitiatorSnapshot(input.initiatorMetadataSnapshot, resolver.levelsUp), label);
|
|
1341
1586
|
}
|
|
1342
1587
|
async getApprovalInstance(id, scope) {
|
|
@@ -1627,7 +1872,11 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1627
1872
|
async listTaskCandidates(taskId, scope) {
|
|
1628
1873
|
await this.assertTaskReadable(taskId, scope);
|
|
1629
1874
|
return this.taskCandidateRepository.find({
|
|
1630
|
-
order: {
|
|
1875
|
+
order: {
|
|
1876
|
+
sequence: { direction: 'ASC', nulls: 'LAST' },
|
|
1877
|
+
createdAt: 'ASC',
|
|
1878
|
+
id: 'ASC',
|
|
1879
|
+
},
|
|
1631
1880
|
where: { taskId },
|
|
1632
1881
|
});
|
|
1633
1882
|
}
|
|
@@ -1636,7 +1885,11 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1636
1885
|
return tasks;
|
|
1637
1886
|
}
|
|
1638
1887
|
const candidates = await this.taskCandidateRepository.find({
|
|
1639
|
-
order: {
|
|
1888
|
+
order: {
|
|
1889
|
+
sequence: { direction: 'ASC', nulls: 'LAST' },
|
|
1890
|
+
createdAt: 'ASC',
|
|
1891
|
+
id: 'ASC',
|
|
1892
|
+
},
|
|
1640
1893
|
where: { taskId: (0, typeorm_2.In)(tasks.map((task) => task.id)) },
|
|
1641
1894
|
});
|
|
1642
1895
|
const candidatesByTaskId = candidates.reduce((currentMap, candidate) => {
|
|
@@ -1645,8 +1898,20 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1645
1898
|
nextMap.set(candidate.taskId, [...memberIds, candidate.memberId]);
|
|
1646
1899
|
return nextMap;
|
|
1647
1900
|
}, new Map());
|
|
1901
|
+
const openCandidatesByTaskId = candidates
|
|
1902
|
+
.filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING ||
|
|
1903
|
+
candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED)
|
|
1904
|
+
.reduce((currentMap, candidate) => {
|
|
1905
|
+
const nextMap = new Map(currentMap);
|
|
1906
|
+
nextMap.set(candidate.taskId, [
|
|
1907
|
+
...(nextMap.get(candidate.taskId) ?? []),
|
|
1908
|
+
candidate.memberId,
|
|
1909
|
+
]);
|
|
1910
|
+
return nextMap;
|
|
1911
|
+
}, new Map());
|
|
1648
1912
|
return tasks.map((task) => Object.assign(new task_entity_1.TaskEntity(), task, {
|
|
1649
1913
|
candidateMemberIds: candidatesByTaskId.get(task.id) ?? [],
|
|
1914
|
+
openCandidateMemberIds: openCandidatesByTaskId.get(task.id) ?? [],
|
|
1650
1915
|
}));
|
|
1651
1916
|
}
|
|
1652
1917
|
async readDefaultInitiatorMetadataSnapshot(memberId) {
|
|
@@ -1882,13 +2147,23 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1882
2147
|
return this.conditionService.evaluateBoolean(entryCondition, buildWorkflowExpressionContext(instance), `workflow.nodes.${node.id}.data.entryCondition`);
|
|
1883
2148
|
}
|
|
1884
2149
|
async skipNodeByEntryCondition(manager, instance, token, node) {
|
|
2150
|
+
await this.skipNode(manager, instance, token, node, {
|
|
2151
|
+
skippedByEntryCondition: true,
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
2154
|
+
async skipNodeByInitiatorExclusion(manager, instance, token, node) {
|
|
2155
|
+
await this.skipNode(manager, instance, token, node, {
|
|
2156
|
+
skippedByInitiatorExclusion: true,
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
async skipNode(manager, instance, token, node, reason) {
|
|
1885
2160
|
await manager.getRepository(activity_log_entity_1.ActivityLogEntity).save(manager.getRepository(activity_log_entity_1.ActivityLogEntity).create({
|
|
1886
2161
|
actorMemberId: null,
|
|
1887
2162
|
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TOKEN_ADVANCED,
|
|
1888
2163
|
instanceId: instance.id,
|
|
1889
2164
|
nodeId: node.id,
|
|
1890
2165
|
payload: {
|
|
1891
|
-
|
|
2166
|
+
...reason,
|
|
1892
2167
|
tokenId: token.id,
|
|
1893
2168
|
},
|
|
1894
2169
|
taskId: null,
|
|
@@ -2087,42 +2362,64 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2087
2362
|
return;
|
|
2088
2363
|
}
|
|
2089
2364
|
const candidates = await this.resolveRuntimeTaskCandidates(manager, instance, node);
|
|
2365
|
+
// The initiator was the only approver and the node asks to be skipped in
|
|
2366
|
+
// that case: no task, the token moves on. Pending countersign directives
|
|
2367
|
+
// stay pending for the next user task that actually gets created.
|
|
2368
|
+
if (candidates === null) {
|
|
2369
|
+
await this.skipNodeByInitiatorExclusion(manager, instance, token, node);
|
|
2370
|
+
return;
|
|
2371
|
+
}
|
|
2090
2372
|
const primaryCandidate = candidates[0];
|
|
2373
|
+
// Legacy snapshots may carry no policy at all; they behave as before.
|
|
2374
|
+
const decisionPolicy = node.data.decisionPolicy;
|
|
2375
|
+
const isSequential = decisionPolicy?.type === 'SEQUENTIAL' && candidates.length > 1;
|
|
2376
|
+
// A sequential task is held by exactly one candidate at a time, so it
|
|
2377
|
+
// carries that candidate as its assignee from the start.
|
|
2378
|
+
const assignedCandidate = candidates.length === 1 || isSequential ? primaryCandidate : null;
|
|
2091
2379
|
const now = new Date();
|
|
2092
2380
|
const slaDueAt = await this.slaScheduleService.resolveTaskSlaDueAt({
|
|
2093
2381
|
node,
|
|
2094
2382
|
now,
|
|
2095
2383
|
});
|
|
2096
2384
|
const task = await taskRepository.save(taskRepository.create({
|
|
2097
|
-
assigneeMemberId:
|
|
2385
|
+
assigneeMemberId: assignedCandidate?.memberId ?? null,
|
|
2098
2386
|
assignmentType: candidates.length === 1
|
|
2099
2387
|
? workflow_engine_enums_1.TaskAssignmentTypeEnum.DIRECT_MEMBER
|
|
2100
2388
|
: workflow_engine_enums_1.TaskAssignmentTypeEnum.CANDIDATE_GROUP,
|
|
2101
2389
|
completedAt: null,
|
|
2102
2390
|
createdAt: now,
|
|
2103
|
-
decisionPolicySnapshot: node
|
|
2104
|
-
|
|
2391
|
+
decisionPolicySnapshot: isInitiatorExcluded(node)
|
|
2392
|
+
? capDecisionPolicyToCandidateCount(decisionPolicy, candidates.length)
|
|
2393
|
+
: decisionPolicy,
|
|
2394
|
+
delegationChain: assignedCandidate?.delegationChain ?? [],
|
|
2105
2395
|
instanceId: instance.id,
|
|
2106
2396
|
nodeId: node.id,
|
|
2107
2397
|
openedAt: null,
|
|
2108
|
-
originalAssigneeMemberId:
|
|
2398
|
+
originalAssigneeMemberId: assignedCandidate?.originalMemberId ?? null,
|
|
2109
2399
|
slaDueAt,
|
|
2110
2400
|
status: workflow_engine_enums_1.TaskStatusEnum.PENDING,
|
|
2111
2401
|
tokenId: token.id,
|
|
2112
2402
|
}));
|
|
2113
|
-
const savedCandidates = await taskCandidateRepository.save(candidates.map((candidate) => taskCandidateRepository.create({
|
|
2403
|
+
const savedCandidates = await taskCandidateRepository.save(candidates.map((candidate, index) => taskCandidateRepository.create({
|
|
2114
2404
|
claimedAt: null,
|
|
2115
2405
|
createdAt: now,
|
|
2116
2406
|
decidedAt: null,
|
|
2117
2407
|
delegationChain: candidate.delegationChain,
|
|
2118
2408
|
memberId: candidate.memberId,
|
|
2119
2409
|
originalMemberId: candidate.originalMemberId,
|
|
2410
|
+
sequence: isSequential ? index : null,
|
|
2120
2411
|
sourceType: candidate.sourceType,
|
|
2121
|
-
status:
|
|
2412
|
+
status: isSequential && index > 0
|
|
2413
|
+
? workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING
|
|
2414
|
+
: workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING,
|
|
2122
2415
|
taskId: task.id,
|
|
2123
2416
|
})));
|
|
2417
|
+
// Display summary of everyone on the task, including the ones whose turn
|
|
2418
|
+
// has not come; who may act now is `status`, not membership of this list.
|
|
2124
2419
|
task.candidateMemberIds = savedCandidates.map((candidate) => candidate.memberId);
|
|
2125
2420
|
const singleCandidate = savedCandidates.length === 1 ? savedCandidates[0] : null;
|
|
2421
|
+
const openCandidates = savedCandidates.filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING);
|
|
2422
|
+
task.openCandidateMemberIds = openCandidates.map((candidate) => candidate.memberId);
|
|
2126
2423
|
await tokenRepository.save({
|
|
2127
2424
|
...token,
|
|
2128
2425
|
status: workflow_engine_enums_1.WorkflowTokenStatusEnum.WAITING,
|
|
@@ -2134,14 +2431,21 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2134
2431
|
nodeId: node.id,
|
|
2135
2432
|
payload: {
|
|
2136
2433
|
assignmentType: task.assignmentType,
|
|
2137
|
-
assigneeMemberId: singleCandidate?.memberId ??
|
|
2138
|
-
|
|
2139
|
-
|
|
2434
|
+
assigneeMemberId: (isSequential ? openCandidates[0] : singleCandidate)?.memberId ??
|
|
2435
|
+
null,
|
|
2436
|
+
candidateMemberIds: openCandidates.map((candidate) => candidate.memberId),
|
|
2437
|
+
originalAssigneeMemberId: (isSequential ? openCandidates[0] : singleCandidate)
|
|
2438
|
+
?.originalMemberId ?? null,
|
|
2439
|
+
...(isSequential
|
|
2440
|
+
? {
|
|
2441
|
+
sequentialCandidateMemberIds: savedCandidates.map((candidate) => candidate.memberId),
|
|
2442
|
+
}
|
|
2443
|
+
: {}),
|
|
2140
2444
|
tokenId: token.id,
|
|
2141
2445
|
},
|
|
2142
2446
|
taskId: task.id,
|
|
2143
2447
|
}));
|
|
2144
|
-
await
|
|
2448
|
+
await openCandidates.reduce(async (previous, candidate) => {
|
|
2145
2449
|
await previous;
|
|
2146
2450
|
await this.notificationService.createTaskAssignedNotification({
|
|
2147
2451
|
instance,
|
|
@@ -2158,6 +2462,111 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2158
2462
|
// created after they were requested — spawn their parallel tasks now.
|
|
2159
2463
|
await this.spawnCountersignTasksForNode(manager, instance, token, node, now);
|
|
2160
2464
|
}
|
|
2465
|
+
/**
|
|
2466
|
+
* Hands a SEQUENTIAL task to the lowest-`sequence` candidate still WAITING.
|
|
2467
|
+
* A task without a WAITING candidate (every other policy, and SEQUENTIAL
|
|
2468
|
+
* tasks created before candidates were ordered) is left alone.
|
|
2469
|
+
*/
|
|
2470
|
+
async activateNextSequentialCandidate({ candidates, instance, manager, node, previousMemberId, task, }) {
|
|
2471
|
+
if (readDecisionPolicySnapshot(task).type !== 'SEQUENTIAL') {
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
2474
|
+
const nextCandidate = [...candidates]
|
|
2475
|
+
.filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING &&
|
|
2476
|
+
candidate.sequence !== null)
|
|
2477
|
+
.sort((left, right) => (left.sequence ?? 0) - (right.sequence ?? 0))[0];
|
|
2478
|
+
if (!nextCandidate) {
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
const now = new Date();
|
|
2482
|
+
// Delegation was resolved when the task was created; the candidate may have
|
|
2483
|
+
// gained a delegate while waiting, so resolve it again for the new turn.
|
|
2484
|
+
const delegation = await this.resolveActivationDelegation({
|
|
2485
|
+
candidate: nextCandidate,
|
|
2486
|
+
candidates,
|
|
2487
|
+
excludesInitiator: isInitiatorExcluded(node),
|
|
2488
|
+
instance,
|
|
2489
|
+
nodeId: task.nodeId,
|
|
2490
|
+
});
|
|
2491
|
+
const activatedCandidate = await manager
|
|
2492
|
+
.getRepository(task_candidate_entity_1.TaskCandidateEntity)
|
|
2493
|
+
.save(Object.assign(new task_candidate_entity_1.TaskCandidateEntity(), nextCandidate, delegation, {
|
|
2494
|
+
status: workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING,
|
|
2495
|
+
}));
|
|
2496
|
+
const activatedTask = await manager.getRepository(task_entity_1.TaskEntity).save({
|
|
2497
|
+
...task,
|
|
2498
|
+
assigneeMemberId: activatedCandidate.memberId,
|
|
2499
|
+
delegationChain: activatedCandidate.delegationChain,
|
|
2500
|
+
openedAt: null,
|
|
2501
|
+
originalAssigneeMemberId: activatedCandidate.originalMemberId,
|
|
2502
|
+
// The new holder gets the node's full SLA window, not what is left of
|
|
2503
|
+
// the previous holder's; the warning is measured from this moment.
|
|
2504
|
+
slaDueAt: await this.slaScheduleService.resolveTaskSlaDueAt({
|
|
2505
|
+
node,
|
|
2506
|
+
now,
|
|
2507
|
+
}),
|
|
2508
|
+
slaStartedAt: now,
|
|
2509
|
+
status: workflow_engine_enums_1.TaskStatusEnum.PENDING,
|
|
2510
|
+
});
|
|
2511
|
+
const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
|
|
2512
|
+
await activityRepository.save(activityRepository.create({
|
|
2513
|
+
actorMemberId: null,
|
|
2514
|
+
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_CANDIDATE_ACTIVATED,
|
|
2515
|
+
instanceId: instance.id,
|
|
2516
|
+
nodeId: task.nodeId,
|
|
2517
|
+
// Carries the same recipient fields as TASK_CREATED so a consumer can
|
|
2518
|
+
// route this event exactly like a task assignment.
|
|
2519
|
+
payload: {
|
|
2520
|
+
assigneeMemberId: activatedCandidate.memberId,
|
|
2521
|
+
assignmentType: activatedTask.assignmentType,
|
|
2522
|
+
candidateMemberId: activatedCandidate.memberId,
|
|
2523
|
+
candidateMemberIds: [activatedCandidate.memberId],
|
|
2524
|
+
originalAssigneeMemberId: activatedCandidate.originalMemberId,
|
|
2525
|
+
originalMemberId: activatedCandidate.originalMemberId,
|
|
2526
|
+
previousMemberId,
|
|
2527
|
+
sequence: activatedCandidate.sequence,
|
|
2528
|
+
tokenId: task.tokenId,
|
|
2529
|
+
},
|
|
2530
|
+
taskId: task.id,
|
|
2531
|
+
}));
|
|
2532
|
+
await this.notificationService.createTaskAssignedNotification({
|
|
2533
|
+
instance,
|
|
2534
|
+
manager,
|
|
2535
|
+
node,
|
|
2536
|
+
task: activatedTask,
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
/**
|
|
2540
|
+
* Re-resolves the delegation of a candidate whose turn has come. When the
|
|
2541
|
+
* delegate already holds another candidate row on the task, the stored values
|
|
2542
|
+
* are kept: (task_id, member_id) is unique, and that member signs from the
|
|
2543
|
+
* row they already have. On a node that excludes the initiator the stored
|
|
2544
|
+
* values are kept as well when the new delegation path would run through the
|
|
2545
|
+
* initiator: creation already filtered such paths out, and a delegation added
|
|
2546
|
+
* while the candidate waited must not bring them back.
|
|
2547
|
+
*/
|
|
2548
|
+
async resolveActivationDelegation({ candidate, candidates, excludesInitiator, instance, nodeId, }) {
|
|
2549
|
+
const stored = {
|
|
2550
|
+
delegationChain: candidate.delegationChain,
|
|
2551
|
+
memberId: candidate.memberId,
|
|
2552
|
+
};
|
|
2553
|
+
const resolution = await this.delegationService.resolveAssignee(candidate.originalMemberId, this.buildDelegationContext(instance, nodeId));
|
|
2554
|
+
const collides = candidates.some((other) => other.id !== candidate.id &&
|
|
2555
|
+
other.memberId === resolution.finalAssigneeMemberId);
|
|
2556
|
+
if (collides ||
|
|
2557
|
+
(excludesInitiator &&
|
|
2558
|
+
isInitiatorOnCandidatePath({
|
|
2559
|
+
delegationChain: resolution.delegationChain,
|
|
2560
|
+
memberId: resolution.finalAssigneeMemberId,
|
|
2561
|
+
originalMemberId: candidate.originalMemberId,
|
|
2562
|
+
}, instance.initiatorMemberId))) {
|
|
2563
|
+
return stored;
|
|
2564
|
+
}
|
|
2565
|
+
return {
|
|
2566
|
+
delegationChain: resolution.delegationChain,
|
|
2567
|
+
memberId: resolution.finalAssigneeMemberId,
|
|
2568
|
+
};
|
|
2569
|
+
}
|
|
2161
2570
|
async markTaskCandidateDecision({ action, candidate, candidates, decidedAt, manager, task, }) {
|
|
2162
2571
|
const taskCandidateRepository = manager.getRepository(task_candidate_entity_1.TaskCandidateEntity);
|
|
2163
2572
|
if (!candidate && candidates.length === 0 && task.assigneeMemberId) {
|
|
@@ -2191,7 +2600,8 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2191
2600
|
}
|
|
2192
2601
|
if (shouldCancelOpenCandidates &&
|
|
2193
2602
|
(currentCandidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING ||
|
|
2194
|
-
currentCandidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED
|
|
2603
|
+
currentCandidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED ||
|
|
2604
|
+
currentCandidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING)) {
|
|
2195
2605
|
return Object.assign(new task_candidate_entity_1.TaskCandidateEntity(), currentCandidate, {
|
|
2196
2606
|
decidedAt,
|
|
2197
2607
|
status: workflow_engine_enums_1.TaskCandidateStatusEnum.CANCELLED,
|
|
@@ -2230,6 +2640,18 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2230
2640
|
delegationChain: nextDelegationChain,
|
|
2231
2641
|
status: workflow_engine_enums_1.TaskStatusEnum.TRANSFERRED,
|
|
2232
2642
|
});
|
|
2643
|
+
// Candidates still waiting for their turn move with the task; without this
|
|
2644
|
+
// the replacement SINGLE task would silently drop them from the chain.
|
|
2645
|
+
// A waiting candidate who is also the transfer target takes the active slot
|
|
2646
|
+
// and is not asked a second time later in the order.
|
|
2647
|
+
const waitingCandidates = readDecisionPolicySnapshot(task).type === 'SEQUENTIAL' &&
|
|
2648
|
+
actorCandidate?.sequence != null
|
|
2649
|
+
? (await taskCandidateRepository.find({ where: { taskId: task.id } }))
|
|
2650
|
+
.filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING)
|
|
2651
|
+
.sort((left, right) => (left.sequence ?? 0) - (right.sequence ?? 0))
|
|
2652
|
+
: [];
|
|
2653
|
+
const isSequentialTransfer = waitingCandidates.length > 0;
|
|
2654
|
+
const carriedCandidates = waitingCandidates.filter((candidate) => candidate.memberId !== transferToMemberId);
|
|
2233
2655
|
const nextTask = await taskRepository.save(taskRepository.create({
|
|
2234
2656
|
// A transferred ad-hoc task stays the same ad-hoc task for its new
|
|
2235
2657
|
// holder: the pre-approval rejection path (RETURN_TO_ORIGIN) reads these
|
|
@@ -2239,9 +2661,13 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2239
2661
|
adhocOriginTaskId: task.adhocOriginTaskId,
|
|
2240
2662
|
adhocType: task.adhocType,
|
|
2241
2663
|
assigneeMemberId: transferToMemberId,
|
|
2242
|
-
assignmentType:
|
|
2664
|
+
assignmentType: isSequentialTransfer
|
|
2665
|
+
? task.assignmentType
|
|
2666
|
+
: workflow_engine_enums_1.TaskAssignmentTypeEnum.DIRECT_MEMBER,
|
|
2243
2667
|
completedAt: null,
|
|
2244
|
-
decisionPolicySnapshot:
|
|
2668
|
+
decisionPolicySnapshot: isSequentialTransfer
|
|
2669
|
+
? task.decisionPolicySnapshot
|
|
2670
|
+
: { type: 'SINGLE' },
|
|
2245
2671
|
delegationChain: nextDelegationChain,
|
|
2246
2672
|
instanceId: task.instanceId,
|
|
2247
2673
|
isAdhoc: task.isAdhoc,
|
|
@@ -2252,6 +2678,29 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2252
2678
|
status: workflow_engine_enums_1.TaskStatusEnum.PENDING,
|
|
2253
2679
|
tokenId: task.tokenId,
|
|
2254
2680
|
}));
|
|
2681
|
+
// A pre-approval the previous holder requested still points at the task
|
|
2682
|
+
// being transferred. Its RETURN_TO_ORIGIN rejection hands the decision back
|
|
2683
|
+
// through adhocOriginTaskId, so leaving it on the TRANSFERRED row reopened
|
|
2684
|
+
// a task for the member who had just transferred the stage away. Only open
|
|
2685
|
+
// pre-approvals move; closed ones keep pointing at the task they ran
|
|
2686
|
+
// against, and countersign tasks keep agreeing with directive.originTaskId
|
|
2687
|
+
// because nothing reads their pointer. A pre-approval is always created on
|
|
2688
|
+
// the origin task's instance and node, so those narrow the lookup to the
|
|
2689
|
+
// idx_tasks_instance_node index (adhoc_origin_task_id has none).
|
|
2690
|
+
const openPreApprovalsOnTransferredTask = (await taskRepository.find({
|
|
2691
|
+
where: {
|
|
2692
|
+
adhocOriginTaskId: task.id,
|
|
2693
|
+
adhocType: adhoc_enums_1.AdhocDirectiveTypeEnum.PRE_APPROVAL,
|
|
2694
|
+
instanceId: task.instanceId,
|
|
2695
|
+
nodeId: task.nodeId,
|
|
2696
|
+
},
|
|
2697
|
+
})).filter((adhocTask) => adhocTask.status === workflow_engine_enums_1.TaskStatusEnum.PENDING ||
|
|
2698
|
+
adhocTask.status === workflow_engine_enums_1.TaskStatusEnum.IN_PROGRESS);
|
|
2699
|
+
if (openPreApprovalsOnTransferredTask.length > 0) {
|
|
2700
|
+
await taskRepository.save(openPreApprovalsOnTransferredTask.map((adhocTask) => Object.assign(new task_entity_1.TaskEntity(), adhocTask, {
|
|
2701
|
+
adhocOriginTaskId: nextTask.id,
|
|
2702
|
+
})));
|
|
2703
|
+
}
|
|
2255
2704
|
await taskCandidateRepository.save([
|
|
2256
2705
|
...(actorCandidate
|
|
2257
2706
|
? [
|
|
@@ -2269,10 +2718,28 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2269
2718
|
delegationChain: nextDelegationChain,
|
|
2270
2719
|
memberId: transferToMemberId,
|
|
2271
2720
|
originalMemberId: task.originalAssigneeMemberId ?? transferToMemberId,
|
|
2721
|
+
sequence: isSequentialTransfer
|
|
2722
|
+
? (actorCandidate?.sequence ?? null)
|
|
2723
|
+
: null,
|
|
2272
2724
|
sourceType: 'DIRECT',
|
|
2273
2725
|
status: workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING,
|
|
2274
2726
|
taskId: nextTask.id,
|
|
2275
2727
|
}),
|
|
2728
|
+
...carriedCandidates.map((candidate) => taskCandidateRepository.create({
|
|
2729
|
+
claimedAt: null,
|
|
2730
|
+
createdAt: candidate.createdAt,
|
|
2731
|
+
decidedAt: null,
|
|
2732
|
+
delegationChain: candidate.delegationChain,
|
|
2733
|
+
memberId: candidate.memberId,
|
|
2734
|
+
originalMemberId: candidate.originalMemberId,
|
|
2735
|
+
sequence: candidate.sequence,
|
|
2736
|
+
sourceType: candidate.sourceType,
|
|
2737
|
+
status: workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING,
|
|
2738
|
+
taskId: nextTask.id,
|
|
2739
|
+
})),
|
|
2740
|
+
...waitingCandidates.map((candidate) => Object.assign(new task_candidate_entity_1.TaskCandidateEntity(), candidate, {
|
|
2741
|
+
status: workflow_engine_enums_1.TaskCandidateStatusEnum.SUPERSEDED,
|
|
2742
|
+
})),
|
|
2276
2743
|
]);
|
|
2277
2744
|
await activityRepository.save([
|
|
2278
2745
|
activityRepository.create({
|
|
@@ -2297,8 +2764,19 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2297
2764
|
nodeId: task.nodeId,
|
|
2298
2765
|
payload: {
|
|
2299
2766
|
assigneeMemberId: transferToMemberId,
|
|
2767
|
+
...(isSequentialTransfer
|
|
2768
|
+
? { candidateMemberIds: [transferToMemberId] }
|
|
2769
|
+
: {}),
|
|
2300
2770
|
delegationChain: nextDelegationChain,
|
|
2301
2771
|
originalAssigneeMemberId: task.originalAssigneeMemberId,
|
|
2772
|
+
...(isSequentialTransfer
|
|
2773
|
+
? {
|
|
2774
|
+
sequentialCandidateMemberIds: [
|
|
2775
|
+
transferToMemberId,
|
|
2776
|
+
...carriedCandidates.map((candidate) => candidate.memberId),
|
|
2777
|
+
],
|
|
2778
|
+
}
|
|
2779
|
+
: {}),
|
|
2302
2780
|
tokenId: task.tokenId,
|
|
2303
2781
|
transferredFromTaskId: task.id,
|
|
2304
2782
|
},
|
|
@@ -2316,16 +2794,51 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2316
2794
|
});
|
|
2317
2795
|
}
|
|
2318
2796
|
}
|
|
2797
|
+
/**
|
|
2798
|
+
* Resolves who gets the node's task. `null` means the node asked to be
|
|
2799
|
+
* skipped (`excludeInitiator.whenEmpty = SKIP_NODE`) because the initiator
|
|
2800
|
+
* was the only approver — the sole caller creates no task and advances the
|
|
2801
|
+
* token instead.
|
|
2802
|
+
*/
|
|
2319
2803
|
async resolveRuntimeTaskCandidates(manager, instance, node) {
|
|
2320
|
-
const
|
|
2321
|
-
const
|
|
2322
|
-
|
|
2323
|
-
|
|
2804
|
+
const label = `簽核節點「${node.data.label}」`;
|
|
2805
|
+
const resolvedCandidates = await this.resolveApproverResolver(manager, instance, node.data.approverResolver, label);
|
|
2806
|
+
const delegatedCandidates = await this.delegateResolvedCandidates(instance, node.id, resolvedCandidates);
|
|
2807
|
+
const excludeInitiator = node.data.excludeInitiator;
|
|
2808
|
+
const uniqueCandidates = selectNodeCandidates(delegatedCandidates, isInitiatorExcluded(node), instance.initiatorMemberId);
|
|
2809
|
+
if (uniqueCandidates.length > 0) {
|
|
2810
|
+
return uniqueCandidates;
|
|
2811
|
+
}
|
|
2812
|
+
// Only the initiator was left — what the node does about it is configured.
|
|
2813
|
+
// Nothing resolved to begin with is the ordinary failure below.
|
|
2814
|
+
if (!excludeInitiator || delegatedCandidates.length === 0) {
|
|
2815
|
+
throw new common_1.ConflictException(`${label} did not resolve to any member id`);
|
|
2816
|
+
}
|
|
2817
|
+
if (excludeInitiator.whenEmpty === 'SKIP_NODE') {
|
|
2818
|
+
return null;
|
|
2819
|
+
}
|
|
2820
|
+
if (excludeInitiator.whenEmpty === 'INITIATOR_MANAGER') {
|
|
2821
|
+
const managerCandidates = await this.resolveInitiatorManagerReplacements(manager, instance, node);
|
|
2822
|
+
if (managerCandidates.length > 0) {
|
|
2823
|
+
return managerCandidates;
|
|
2824
|
+
}
|
|
2825
|
+
throw new common_1.ConflictException(`${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人,此節點不允許申請人自簽,且找不到申請人的主管可改派。`);
|
|
2324
2826
|
}
|
|
2325
|
-
|
|
2827
|
+
throw new common_1.ConflictException(`${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人(含委派後的代理人),且此節點不允許申請人自簽。`);
|
|
2828
|
+
}
|
|
2829
|
+
async resolveInitiatorManagerReplacements(manager, instance, node) {
|
|
2830
|
+
// The initiator's own direct manager: the closest org unit only, and the
|
|
2831
|
+
// initiator himself (a unit head) is skipped so resolution falls through
|
|
2832
|
+
// to the next tier instead of ending on him.
|
|
2833
|
+
const managerMemberIds = await this.resolveDirectManagerMemberIds(manager, instance.initiatorMemberId, toDateOnlyString(new Date()), true, instance.initiatorMemberId);
|
|
2834
|
+
const delegatedCandidates = await this.delegateResolvedCandidates(instance, node.id, managerMemberIds.map((memberId) => ({
|
|
2835
|
+
memberId,
|
|
2836
|
+
sourceType: 'EXCLUDE_INITIATOR_MANAGER',
|
|
2837
|
+
})));
|
|
2838
|
+
return selectNodeCandidates(delegatedCandidates, true, instance.initiatorMemberId);
|
|
2326
2839
|
}
|
|
2327
|
-
|
|
2328
|
-
|
|
2840
|
+
buildDelegationContext(instance, nodeId) {
|
|
2841
|
+
return {
|
|
2329
2842
|
formData: instance.formData,
|
|
2330
2843
|
initiatorMemberId: instance.initiatorMemberId,
|
|
2331
2844
|
initiatorMetadataSnapshot: instance.initiatorMetadataSnapshot,
|
|
@@ -2336,6 +2849,11 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2336
2849
|
templateVersionId: instance.templateVersionId,
|
|
2337
2850
|
title: instance.title,
|
|
2338
2851
|
};
|
|
2852
|
+
}
|
|
2853
|
+
// Not deduplicated: a node that excludes the initiator filters first, so a
|
|
2854
|
+
// first-seen entry that must be dropped cannot shadow a legitimate one.
|
|
2855
|
+
async delegateResolvedCandidates(instance, nodeId, resolvedCandidates) {
|
|
2856
|
+
const context = this.buildDelegationContext(instance, nodeId);
|
|
2339
2857
|
const delegatedCandidates = await Promise.all(resolvedCandidates.map(async (candidate) => {
|
|
2340
2858
|
const delegationResolution = await this.delegationService.resolveAssignee(candidate.memberId, context);
|
|
2341
2859
|
return {
|
|
@@ -2345,7 +2863,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2345
2863
|
sourceType: candidate.sourceType,
|
|
2346
2864
|
};
|
|
2347
2865
|
}));
|
|
2348
|
-
return
|
|
2866
|
+
return delegatedCandidates;
|
|
2349
2867
|
}
|
|
2350
2868
|
async resolveApproverResolver(manager, instance, resolver, label) {
|
|
2351
2869
|
if (resolver.type === 'DIRECT') {
|
|
@@ -2369,6 +2887,9 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2369
2887
|
if (resolver.type === 'ORG_UNIT_MANAGER') {
|
|
2370
2888
|
return this.resolveOrgUnitManagerCandidates(manager, instance, resolver.orgUnitId, resolver.fallback, label, resolver.preferClosestOrgUnit === true).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
2371
2889
|
}
|
|
2890
|
+
if (resolver.type === 'ORG_MANAGER_CHAIN') {
|
|
2891
|
+
return this.resolveOrgManagerChainCandidates(manager, instance, resolver.minPositionLevel, resolver.fallback, label).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
2892
|
+
}
|
|
2372
2893
|
return this.resolveInitiatorManagerCandidates(manager, instance, resolver.levelsUp, resolver.fallback, label, resolver.preferClosestOrgUnit === true).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
2373
2894
|
}
|
|
2374
2895
|
async resolvePositionCandidates(manager, positionId, label) {
|
|
@@ -2400,6 +2921,72 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2400
2921
|
this.resolveFallbackAssignee(instance, fallback, label, `找不到發起人的第 ${normalizedLevelsUp} 層主管`),
|
|
2401
2922
|
];
|
|
2402
2923
|
}
|
|
2924
|
+
/**
|
|
2925
|
+
* Walks up the initiator's manager chain, nearest manager first, until a
|
|
2926
|
+
* manager whose position level reaches `minPositionLevel` is added. The
|
|
2927
|
+
* list is returned nearest manager first.
|
|
2928
|
+
*/
|
|
2929
|
+
async resolveOrgManagerChainCandidates(manager, instance, minPositionLevel, fallback, label) {
|
|
2930
|
+
const date = toDateOnlyString(new Date());
|
|
2931
|
+
const visitedMemberIds = new Set([instance.initiatorMemberId]);
|
|
2932
|
+
const chain = [];
|
|
2933
|
+
let currentMemberId = instance.initiatorMemberId;
|
|
2934
|
+
let lastAddedLevel = null;
|
|
2935
|
+
for (let hop = 0; hop < ORG_MANAGER_CHAIN_MAX_HOPS; hop += 1) {
|
|
2936
|
+
// A chain is a single path, so it always uses closest-org-unit semantics
|
|
2937
|
+
// (merging ancestor-unit rules could only produce ties). The member is
|
|
2938
|
+
// excluded from his own candidates: a unit head belongs to the unit he
|
|
2939
|
+
// heads, so without this his unit rule would name himself and the walk
|
|
2940
|
+
// could never climb past him.
|
|
2941
|
+
const directManagerMemberIds = await this.resolveDirectManagerMemberIds(manager, currentMemberId, date, true, currentMemberId);
|
|
2942
|
+
if (directManagerMemberIds.length === 0) {
|
|
2943
|
+
break;
|
|
2944
|
+
}
|
|
2945
|
+
if (directManagerMemberIds.length > 1) {
|
|
2946
|
+
// Picking one silently would route the document by accident.
|
|
2947
|
+
throw new common_1.ConflictException(`${label} 無法沿主管鏈簽核:成員 ${currentMemberId} 的主管規則在同一優先順序出現多位主管(${directManagerMemberIds.join('、')}),無法決定簽核路徑。請聯絡流程管理員調整主管規則。`);
|
|
2948
|
+
}
|
|
2949
|
+
const [managerMemberId] = directManagerMemberIds;
|
|
2950
|
+
if (visitedMemberIds.has(managerMemberId)) {
|
|
2951
|
+
break;
|
|
2952
|
+
}
|
|
2953
|
+
visitedMemberIds.add(managerMemberId);
|
|
2954
|
+
currentMemberId = managerMemberId;
|
|
2955
|
+
const level = await this.readMemberPositionLevel(manager, managerMemberId, date);
|
|
2956
|
+
if (level === null ||
|
|
2957
|
+
(lastAddedLevel !== null && level <= lastAddedLevel)) {
|
|
2958
|
+
continue;
|
|
2959
|
+
}
|
|
2960
|
+
chain.push(managerMemberId);
|
|
2961
|
+
lastAddedLevel = level;
|
|
2962
|
+
if (level >= minPositionLevel) {
|
|
2963
|
+
return chain;
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
const fallbackMemberId = this.resolveFallbackAssignee(instance, fallback, label, `沿主管鏈找不到職等達 ${minPositionLevel} 的主管`);
|
|
2967
|
+
return chain.includes(fallbackMemberId)
|
|
2968
|
+
? chain
|
|
2969
|
+
: [...chain, fallbackMemberId];
|
|
2970
|
+
}
|
|
2971
|
+
/** Highest position level over the member's active memberships, or `null`. */
|
|
2972
|
+
async readMemberPositionLevel(manager, memberId, date) {
|
|
2973
|
+
const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
|
|
2974
|
+
where: { memberId },
|
|
2975
|
+
});
|
|
2976
|
+
const positionIds = uniqueTexts(memberships
|
|
2977
|
+
.filter((membership) => isDateRangeActive(membership, date))
|
|
2978
|
+
.map((membership) => membership.positionId)
|
|
2979
|
+
.filter((positionId) => Boolean(positionId)));
|
|
2980
|
+
if (positionIds.length === 0) {
|
|
2981
|
+
return null;
|
|
2982
|
+
}
|
|
2983
|
+
const positions = await manager.getRepository(position_entity_1.PositionEntity).find({
|
|
2984
|
+
where: { id: (0, typeorm_2.In)([...positionIds]) },
|
|
2985
|
+
});
|
|
2986
|
+
return positions.length === 0
|
|
2987
|
+
? null
|
|
2988
|
+
: Math.max(...positions.map((position) => position.level));
|
|
2989
|
+
}
|
|
2403
2990
|
resolveFallbackAssignee(instance, fallback, label, failureReason) {
|
|
2404
2991
|
if (!fallback || fallback.type === 'NONE') {
|
|
2405
2992
|
throw new common_1.ConflictException(`${label} 無法建立待簽任務:${failureReason},且未設定改派固定人。請聯絡流程管理員調整主管規則或模板 fallback。`);
|
|
@@ -2424,7 +3011,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2424
3011
|
const nextLevelGroups = await Promise.all(directManagerMemberIds.map((managerMemberId) => this.resolveManagerMemberIdsAtLevel(manager, managerMemberId, date, remainingLevels - 1, preferClosestOrgUnit)));
|
|
2425
3012
|
return uniqueTexts(nextLevelGroups.flat());
|
|
2426
3013
|
}
|
|
2427
|
-
async resolveDirectManagerMemberIds(manager, memberId, date, preferClosestOrgUnit) {
|
|
3014
|
+
async resolveDirectManagerMemberIds(manager, memberId, date, preferClosestOrgUnit, excludedManagerMemberId) {
|
|
2428
3015
|
const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
|
|
2429
3016
|
where: { memberId },
|
|
2430
3017
|
});
|
|
@@ -2445,7 +3032,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2445
3032
|
scopeType: organization_enums_1.ManagerResolutionScopeTypeEnum.POSITION,
|
|
2446
3033
|
})),
|
|
2447
3034
|
];
|
|
2448
|
-
return this.resolveManagerResolutionCandidates(manager, candidatePairs, date, readOrgUnitDepthMap(orgUnits), preferClosestOrgUnit);
|
|
3035
|
+
return this.resolveManagerResolutionCandidates(manager, candidatePairs, date, readOrgUnitDepthMap(orgUnits), preferClosestOrgUnit, excludedManagerMemberId);
|
|
2449
3036
|
}
|
|
2450
3037
|
async resolveOrgUnitManagerCandidates(manager, instance, orgUnitId, fallback, label, preferClosestOrgUnit) {
|
|
2451
3038
|
const date = toDateOnlyString(new Date());
|
|
@@ -2477,7 +3064,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2477
3064
|
const byId = new Map([...directOrgUnits, ...ancestorGroups.flat()].map((orgUnit) => [orgUnit.id, orgUnit]));
|
|
2478
3065
|
return [...byId.values()];
|
|
2479
3066
|
}
|
|
2480
|
-
async resolveManagerResolutionCandidates(manager, candidatePairs, date, orgUnitDepths, preferClosestOrgUnit) {
|
|
3067
|
+
async resolveManagerResolutionCandidates(manager, candidatePairs, date, orgUnitDepths, preferClosestOrgUnit, excludedManagerMemberId) {
|
|
2481
3068
|
const scopeIds = candidatePairs.map((pair) => pair.scopeId);
|
|
2482
3069
|
if (!scopeIds.length) {
|
|
2483
3070
|
return [];
|
|
@@ -2499,6 +3086,10 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2499
3086
|
.filter((resolution) => candidatePairs.some((pair) => pair.scopeId === resolution.scopeId &&
|
|
2500
3087
|
pair.scopeType === resolution.scopeType))
|
|
2501
3088
|
.filter((resolution) => isDateRangeActive(resolution, date))
|
|
3089
|
+
// Dropped before the top-priority tier is chosen, so resolution falls
|
|
3090
|
+
// through to the next tier instead of ending on the excluded member.
|
|
3091
|
+
.filter((resolution) => excludedManagerMemberId === undefined ||
|
|
3092
|
+
resolution.managerMemberId !== excludedManagerMemberId)
|
|
2502
3093
|
.sort((left, right) => compareManagerResolution(left, right, depthLookup));
|
|
2503
3094
|
return uniqueTexts(readTopPriorityResolutions(active, depthLookup).map((resolution) => resolution.managerMemberId));
|
|
2504
3095
|
}
|
|
@@ -2901,7 +3492,8 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2901
3492
|
}
|
|
2902
3493
|
const candidateRepository = manager.getRepository(task_candidate_entity_1.TaskCandidateEntity);
|
|
2903
3494
|
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
|
|
3495
|
+
candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED ||
|
|
3496
|
+
candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING);
|
|
2905
3497
|
if (candidates.length === 0) {
|
|
2906
3498
|
return;
|
|
2907
3499
|
}
|
|
@@ -3318,6 +3910,43 @@ function uniqueRuntimeCandidates(candidates) {
|
|
|
3318
3910
|
return true;
|
|
3319
3911
|
});
|
|
3320
3912
|
}
|
|
3913
|
+
// A task signs in order only when its snapshot says so and its candidates carry
|
|
3914
|
+
// a sequence; a SEQUENTIAL task from before ordering existed behaves as before.
|
|
3915
|
+
function isOrderedSequentialTask(task, candidates) {
|
|
3916
|
+
return (readDecisionPolicySnapshot(task).type === 'SEQUENTIAL' &&
|
|
3917
|
+
candidates.some((candidate) => candidate.sequence !== null));
|
|
3918
|
+
}
|
|
3919
|
+
// Every read of the setting goes through here so an explicit `null` behaves
|
|
3920
|
+
// exactly like an absent setting.
|
|
3921
|
+
function isInitiatorExcluded(node) {
|
|
3922
|
+
return Boolean(node.data.excludeInitiator);
|
|
3923
|
+
}
|
|
3924
|
+
// A delegate acting for the initiator is still the initiator approving their
|
|
3925
|
+
// own document, so the initiator anywhere on the delegation path (the
|
|
3926
|
+
// pre-delegation member, any hop, or the final assignee) drops the candidate.
|
|
3927
|
+
function isInitiatorOnCandidatePath(candidate, initiatorMemberId) {
|
|
3928
|
+
return (candidate.memberId === initiatorMemberId ||
|
|
3929
|
+
candidate.originalMemberId === initiatorMemberId ||
|
|
3930
|
+
candidate.delegationChain.some((step) => step.from === initiatorMemberId || step.to === initiatorMemberId));
|
|
3931
|
+
}
|
|
3932
|
+
// The initiator filter runs on the delegated list before the dedupe, so a
|
|
3933
|
+
// legitimate approver is never shadowed by an excluded entry for the same
|
|
3934
|
+
// final assignee. Without the exclusion this is the plain dedupe.
|
|
3935
|
+
function selectNodeCandidates(delegatedCandidates, excludeInitiator, initiatorMemberId) {
|
|
3936
|
+
return uniqueRuntimeCandidates(excludeInitiator
|
|
3937
|
+
? delegatedCandidates.filter((candidate) => !isInitiatorOnCandidatePath(candidate, initiatorMemberId))
|
|
3938
|
+
: delegatedCandidates);
|
|
3939
|
+
}
|
|
3940
|
+
// A COUNT threshold above the people left would never be reached once the
|
|
3941
|
+
// initiator is gone, so it is capped at who remains ("everyone else").
|
|
3942
|
+
// PERCENTAGE is computed from the actual candidate count and needs nothing.
|
|
3943
|
+
function capDecisionPolicyToCandidateCount(policy, candidateCount) {
|
|
3944
|
+
return policy?.type === 'QUORUM' &&
|
|
3945
|
+
policy.thresholdType === 'COUNT' &&
|
|
3946
|
+
policy.threshold > candidateCount
|
|
3947
|
+
? { ...policy, threshold: Math.max(candidateCount, 1) }
|
|
3948
|
+
: policy;
|
|
3949
|
+
}
|
|
3321
3950
|
function uniqueTasksById(tasks) {
|
|
3322
3951
|
const seen = new Set();
|
|
3323
3952
|
return tasks.filter((task) => {
|