@rytass/bpm-core-nestjs-module 0.16.2 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/README.md +415 -261
- package/package.json +2 -2
- package/src/lib/delegation/delegation-rule.entity.js +3 -1
- package/src/lib/delegation/delegation-rule.entity.js.map +1 -1
- package/src/lib/delegation/delegation.service.d.ts +4 -0
- package/src/lib/delegation/delegation.service.js +19 -3
- package/src/lib/delegation/delegation.service.js.map +1 -1
- package/src/lib/delegation/dto/delegation-rule.input.js +6 -2
- package/src/lib/delegation/dto/delegation-rule.input.js.map +1 -1
- package/src/lib/migrations/0000000000001-enable-postgres-extensions.js +6 -2
- package/src/lib/migrations/0000000000001-enable-postgres-extensions.js.map +1 -1
- package/src/lib/notification/notification-options.d.ts +5 -5
- package/src/lib/notification/notification.service.js +5 -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/org-unit-retirement-impact.object.d.ts +18 -0
- package/src/lib/organization/org-unit-retirement-impact.object.js +34 -0
- package/src/lib/organization/org-unit-retirement-impact.object.js.map +1 -0
- package/src/lib/organization/organization.mutations.d.ts +1 -1
- package/src/lib/organization/organization.mutations.js +7 -4
- package/src/lib/organization/organization.mutations.js.map +1 -1
- package/src/lib/organization/organization.queries.d.ts +2 -0
- package/src/lib/organization/organization.queries.js +13 -0
- package/src/lib/organization/organization.queries.js.map +1 -1
- package/src/lib/organization/organization.service.d.ts +32 -1
- package/src/lib/organization/organization.service.js +157 -16
- package/src/lib/organization/organization.service.js.map +1 -1
- package/src/lib/template/template.service.js +95 -58
- package/src/lib/template/template.service.js.map +1 -1
- package/src/lib/template/workflow-definition.validator.js +11 -0
- package/src/lib/template/workflow-definition.validator.js.map +1 -1
- package/src/lib/workflow-engine/dto/reassign-member-tasks.input.d.ts +7 -0
- package/src/lib/workflow-engine/dto/reassign-member-tasks.input.js +34 -0
- package/src/lib/workflow-engine/dto/reassign-member-tasks.input.js.map +1 -0
- package/src/lib/workflow-engine/dto/reassign-task.input.d.ts +8 -0
- package/src/lib/workflow-engine/dto/reassign-task.input.js +39 -0
- package/src/lib/workflow-engine/dto/reassign-task.input.js.map +1 -0
- package/src/lib/workflow-engine/index.d.ts +3 -0
- package/src/lib/workflow-engine/index.js +3 -0
- package/src/lib/workflow-engine/index.js.map +1 -1
- package/src/lib/workflow-engine/task-reassignment.object.d.ts +26 -0
- package/src/lib/workflow-engine/task-reassignment.object.js +97 -0
- package/src/lib/workflow-engine/task-reassignment.object.js.map +1 -0
- package/src/lib/workflow-engine/workflow-engine.enums.d.ts +29 -0
- package/src/lib/workflow-engine/workflow-engine.enums.js +42 -1
- package/src/lib/workflow-engine/workflow-engine.enums.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.mutations.d.ts +5 -0
- package/src/lib/workflow-engine/workflow-engine.mutations.js +48 -0
- package/src/lib/workflow-engine/workflow-engine.mutations.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.queries.d.ts +2 -0
- package/src/lib/workflow-engine/workflow-engine.queries.js +15 -0
- package/src/lib/workflow-engine/workflow-engine.queries.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.service.d.ts +150 -1
- package/src/lib/workflow-engine/workflow-engine.service.js +716 -45
- package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.tokens.d.ts +2 -1
- package/src/lib/workflow-engine/workflow-engine.tokens.js.map +1 -1
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var WorkflowEngineService_1;
|
|
2
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.WorkflowEngineService = exports.MANUAL_TRANSFER_DELEGATION_REASON = exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH = void 0;
|
|
4
|
+
exports.WorkflowEngineService = exports.ADMIN_REASSIGN_DELEGATION_REASON = exports.MANUAL_TRANSFER_DELEGATION_REASON = exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH = void 0;
|
|
4
5
|
const tslib_1 = require("tslib");
|
|
5
6
|
const class_validator_1 = require("class-validator");
|
|
6
7
|
const form_1 = require("@rytass/bpm-core-shared/form");
|
|
8
|
+
const workflow_1 = require("@rytass/bpm-core-shared/workflow");
|
|
7
9
|
const workflow_graph_1 = require("@rytass/bpm-core-shared/workflow-graph");
|
|
8
10
|
const workflow_webhook_delivery_service_1 = require("../workflow-webhook/workflow-webhook-delivery.service");
|
|
9
11
|
const common_1 = require("@nestjs/common");
|
|
@@ -57,13 +59,65 @@ const SYSTEM_DRY_RUN_INSTANCE_ID = 'dry-run-instance';
|
|
|
57
59
|
* opposed to {@link SLA_ESCALATION_DELEGATION_REASON}.
|
|
58
60
|
*/
|
|
59
61
|
exports.MANUAL_TRANSFER_DELEGATION_REASON = 'MANUAL_TRANSFER';
|
|
62
|
+
/**
|
|
63
|
+
* Delegation-chain reason for a seat an administrator moved to another member
|
|
64
|
+
* (see {@link WorkflowEngineService.reassignTask}). The step's `actorMemberId`
|
|
65
|
+
* names the administrator and `comment` carries the reason they typed.
|
|
66
|
+
*/
|
|
67
|
+
exports.ADMIN_REASSIGN_DELEGATION_REASON = 'ADMIN_REASSIGN';
|
|
68
|
+
const TASK_REASSIGNMENT_MESSAGES = {
|
|
69
|
+
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.INSTANCE_NOT_RUNNING]: '案件已不在進行中',
|
|
70
|
+
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.REASON_REQUIRED]: '必須填寫改派原因',
|
|
71
|
+
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.SOURCE_NOT_ON_TASK]: '來源成員已不在此任務的待簽名單上',
|
|
72
|
+
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_ALREADY_DECIDED]: '接手人已對此任務簽過核',
|
|
73
|
+
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_ALREADY_ON_TASK]: '接手人已在此任務的待簽名單上',
|
|
74
|
+
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_GRADE_TOO_LOW]: '接手人職等不足,不能取代此簽核位置',
|
|
75
|
+
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_INITIATOR]: '此節點不允許申請人自簽,接手人不能是申請人',
|
|
76
|
+
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_SUBMITTER]: '此節點不允許代送人簽核,接手人不能是代為送出此單的人',
|
|
77
|
+
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_SAME_AS_SOURCE]: '接手人與原簽核人相同',
|
|
78
|
+
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TASK_NOT_OPEN]: '任務已不在待簽狀態',
|
|
79
|
+
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.UNEXPECTED_ERROR]: '改派時發生非預期錯誤',
|
|
80
|
+
};
|
|
81
|
+
/** Statuses in which a candidate row still holds a seat on the task. */
|
|
82
|
+
const OPEN_SEAT_STATUSES = [
|
|
83
|
+
workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING,
|
|
84
|
+
workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED,
|
|
85
|
+
workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING,
|
|
86
|
+
];
|
|
87
|
+
/** Closed row states that a member can be moved back into (one row per member per task). */
|
|
88
|
+
const REUSABLE_SEAT_STATUSES = [
|
|
89
|
+
workflow_engine_enums_1.TaskCandidateStatusEnum.CANCELLED,
|
|
90
|
+
workflow_engine_enums_1.TaskCandidateStatusEnum.SUPERSEDED,
|
|
91
|
+
workflow_engine_enums_1.TaskCandidateStatusEnum.TRANSFERRED,
|
|
92
|
+
];
|
|
93
|
+
const OPEN_TASK_STATUSES = [
|
|
94
|
+
workflow_engine_enums_1.TaskStatusEnum.PENDING,
|
|
95
|
+
workflow_engine_enums_1.TaskStatusEnum.IN_PROGRESS,
|
|
96
|
+
];
|
|
97
|
+
function buildTaskReassignmentResult(task, outcome, errorCode) {
|
|
98
|
+
return {
|
|
99
|
+
errorCode,
|
|
100
|
+
instanceId: task.instanceId,
|
|
101
|
+
message: errorCode ? TASK_REASSIGNMENT_MESSAGES[errorCode] : null,
|
|
102
|
+
nodeId: task.nodeId,
|
|
103
|
+
outcome,
|
|
104
|
+
taskId: task.id,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/** A refused reassignment: raised while planning, before anything is written. */
|
|
108
|
+
class TaskReassignmentRejectedError extends Error {
|
|
109
|
+
constructor(errorCode) {
|
|
110
|
+
super(TASK_REASSIGNMENT_MESSAGES[errorCode]);
|
|
111
|
+
this.errorCode = errorCode;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
60
114
|
const WORKFLOW_READ_ALL_PERMISSIONS = new Set([
|
|
61
115
|
'bpm:*',
|
|
62
116
|
'bpm.workflow.read_all',
|
|
63
117
|
'bpm:workflow:read_all',
|
|
64
118
|
'workflow.read_all',
|
|
65
119
|
]);
|
|
66
|
-
let WorkflowEngineService = class WorkflowEngineService {
|
|
120
|
+
let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineService {
|
|
67
121
|
constructor(approvalInstanceRepository, workflowTokenRepository, taskRepository, taskCandidateRepository, taskDecisionRepository, notificationRepository, activityLogRepository, adhocDirectiveRepository, approvalTemplateRepository, approvalTemplateVersionRepository, formDefinitionVersionRepository, attachmentService, conditionService, delegationService, notificationService, signatureService, slaScheduleService, injectedServiceTaskDispatcher, formDataSourceValueResolver, rootOptions, webhookDeliveryService) {
|
|
68
122
|
this.approvalInstanceRepository = approvalInstanceRepository;
|
|
69
123
|
this.workflowTokenRepository = workflowTokenRepository;
|
|
@@ -84,6 +138,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
84
138
|
this.slaScheduleService = slaScheduleService;
|
|
85
139
|
this.formDataSourceValueResolver = formDataSourceValueResolver;
|
|
86
140
|
this.webhookDeliveryService = webhookDeliveryService;
|
|
141
|
+
this.logger = new common_1.Logger(WorkflowEngineService_1.name);
|
|
87
142
|
// A binding under the token wins: that is either the host's own provider
|
|
88
143
|
// or `workflowServiceTaskDispatcherProvider`, both of them deliberate. The
|
|
89
144
|
// runtime instance from `BPMRootModule`'s async factory comes next.
|
|
@@ -356,6 +411,24 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
356
411
|
if (!instance) {
|
|
357
412
|
throw new common_1.NotFoundException(`Approval instance ${task.instanceId} was not found`);
|
|
358
413
|
}
|
|
414
|
+
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED &&
|
|
415
|
+
transferToMemberId !== null &&
|
|
416
|
+
isGroupDecisionPolicy(readDecisionPolicySnapshot(task)) &&
|
|
417
|
+
taskCandidates.some((candidate) => candidate.memberId === transferToMemberId &&
|
|
418
|
+
OPEN_SEAT_STATUSES.includes(candidate.status))) {
|
|
419
|
+
throw new common_1.ConflictException('Transfer target already holds an open seat on this task');
|
|
420
|
+
}
|
|
421
|
+
// A member who has decided keeps a COMPLETED row, and the database
|
|
422
|
+
// allows one row per (task, member): moving a seat to them would hit
|
|
423
|
+
// that index instead of answering why. Mirrors TARGET_ALREADY_DECIDED
|
|
424
|
+
// on the administrator reassignment.
|
|
425
|
+
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED &&
|
|
426
|
+
transferToMemberId !== null &&
|
|
427
|
+
isGroupDecisionPolicy(readDecisionPolicySnapshot(task)) &&
|
|
428
|
+
taskCandidates.some((candidate) => candidate.memberId === transferToMemberId &&
|
|
429
|
+
candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.COMPLETED)) {
|
|
430
|
+
throw new common_1.ConflictException('Transfer target has already decided this task');
|
|
431
|
+
}
|
|
359
432
|
if (instance.state !== workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING) {
|
|
360
433
|
throw new common_1.ConflictException(`Approval instance ${instance.id} is not running`);
|
|
361
434
|
}
|
|
@@ -449,6 +522,25 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
449
522
|
taskId: task.id,
|
|
450
523
|
transferToMemberId,
|
|
451
524
|
}));
|
|
525
|
+
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED &&
|
|
526
|
+
transferToMemberId !== null &&
|
|
527
|
+
isGroupDecisionPolicy(readDecisionPolicySnapshot(claimedTask))) {
|
|
528
|
+
await this.transferGroupSeat(manager, {
|
|
529
|
+
actorCandidate: claimedCandidate,
|
|
530
|
+
decidedAt,
|
|
531
|
+
decision,
|
|
532
|
+
instance,
|
|
533
|
+
node: taskNode,
|
|
534
|
+
reusableRow: taskCandidates.find((candidate) => candidate.memberId === transferToMemberId &&
|
|
535
|
+
REUSABLE_SEAT_STATUSES.includes(candidate.status)) ?? null,
|
|
536
|
+
signature,
|
|
537
|
+
task: claimedTask,
|
|
538
|
+
taskBeforeClaim: task,
|
|
539
|
+
toMemberId: transferToMemberId,
|
|
540
|
+
transferReason: options.transferReason ?? exports.MANUAL_TRANSFER_DELEGATION_REASON,
|
|
541
|
+
});
|
|
542
|
+
return decision;
|
|
543
|
+
}
|
|
452
544
|
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED) {
|
|
453
545
|
await this.transferTask(manager, instance, claimedTask, decision, signature, transferToMemberId, decisionComment, decidedAt, claimedCandidate, options.transferReason ?? exports.MANUAL_TRANSFER_DELEGATION_REASON);
|
|
454
546
|
await this.notificationService.resolveTaskNotifications({
|
|
@@ -582,21 +674,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
582
674
|
instance.state !== workflow_engine_enums_1.ApprovalInstanceStateEnum.RETURNED) {
|
|
583
675
|
throw new common_1.ConflictException(`Approval instance ${instance.id} is not cancellable`);
|
|
584
676
|
}
|
|
585
|
-
const
|
|
586
|
-
await this.consumeOpenRuntimeState(manager, instance, cancelledAt, workflow_engine_enums_1.TaskStatusEnum.CANCELLED);
|
|
587
|
-
await this.notificationService.supersedeInstanceTaskNotifications({
|
|
588
|
-
instanceId: instance.id,
|
|
589
|
-
manager,
|
|
590
|
-
});
|
|
591
|
-
// Same prototype-preserving reason as `cancelAdhocDirective`: this
|
|
592
|
-
// entity is returned to the resolver and `ApprovalInstance` exposes
|
|
593
|
-
// five getter-backed JSON fields.
|
|
594
|
-
const cancelledInstance = await instanceRepository.save(Object.assign(new approval_instance_entity_1.ApprovalInstanceEntity(), instance, {
|
|
595
|
-
completedAt: cancelledAt,
|
|
596
|
-
state: workflow_engine_enums_1.ApprovalInstanceStateEnum.CANCELLED,
|
|
597
|
-
}));
|
|
598
|
-
await this.dispatchAdhocCompletionNotifications(manager, cancelledInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.CANCELLED);
|
|
599
|
-
await this.cancelPendingAdhocDirectives(manager, instance.id, null);
|
|
677
|
+
const cancelledInstance = await this.closeInstance(manager, instance, workflow_engine_enums_1.ApprovalInstanceStateEnum.CANCELLED);
|
|
600
678
|
await manager.getRepository(activity_log_entity_1.ActivityLogEntity).save(manager.getRepository(activity_log_entity_1.ActivityLogEntity).create({
|
|
601
679
|
actorMemberId: input.cancelledByMemberId,
|
|
602
680
|
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.INSTANCE_CANCELLED,
|
|
@@ -610,6 +688,67 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
610
688
|
return cancelledInstance;
|
|
611
689
|
});
|
|
612
690
|
}
|
|
691
|
+
/**
|
|
692
|
+
* Ends a running or returned instance because a task on it ran past its SLA
|
|
693
|
+
* with `onTimeout: 'TERMINATE_INSTANCE'`. It closes the instance exactly as
|
|
694
|
+
* a cancellation does, but lands in `EXPIRED` and is recorded with no actor:
|
|
695
|
+
* nobody chose to end it. An instance that is already closed — another
|
|
696
|
+
* overdue task of it got here first, or someone decided it meanwhile — is
|
|
697
|
+
* returned unchanged.
|
|
698
|
+
*/
|
|
699
|
+
async expireApprovalInstance(input) {
|
|
700
|
+
return this.approvalInstanceRepository.manager.transaction(async (manager) => {
|
|
701
|
+
await manager.query('SELECT pg_advisory_xact_lock(hashtext($1))', [
|
|
702
|
+
input.instanceId,
|
|
703
|
+
]);
|
|
704
|
+
const instance = await manager
|
|
705
|
+
.getRepository(approval_instance_entity_1.ApprovalInstanceEntity)
|
|
706
|
+
.findOne({ where: { id: input.instanceId } });
|
|
707
|
+
if (!instance) {
|
|
708
|
+
throw new common_1.NotFoundException(`Approval instance ${input.instanceId} was not found`);
|
|
709
|
+
}
|
|
710
|
+
if (instance.state !== workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING &&
|
|
711
|
+
instance.state !== workflow_engine_enums_1.ApprovalInstanceStateEnum.RETURNED) {
|
|
712
|
+
return instance;
|
|
713
|
+
}
|
|
714
|
+
const expiredInstance = await this.closeInstance(manager, instance, workflow_engine_enums_1.ApprovalInstanceStateEnum.EXPIRED);
|
|
715
|
+
await manager.getRepository(activity_log_entity_1.ActivityLogEntity).save(manager.getRepository(activity_log_entity_1.ActivityLogEntity).create({
|
|
716
|
+
actorMemberId: null,
|
|
717
|
+
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.INSTANCE_EXPIRED,
|
|
718
|
+
instanceId: instance.id,
|
|
719
|
+
nodeId: input.nodeId ?? null,
|
|
720
|
+
payload: {
|
|
721
|
+
comment: input.comment?.trim() || null,
|
|
722
|
+
},
|
|
723
|
+
taskId: input.taskId ?? null,
|
|
724
|
+
}));
|
|
725
|
+
return expiredInstance;
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
// Shared ending of a cancellation and an SLA expiry: everything open on the
|
|
729
|
+
// instance is closed, its task notifications are superseded, ad-hoc
|
|
730
|
+
// completion notices go out with the final state, and what is still pending
|
|
731
|
+
// is dropped. The caller records who (if anyone) ended it.
|
|
732
|
+
async closeInstance(manager, instance, finalState) {
|
|
733
|
+
const closedAt = new Date();
|
|
734
|
+
await this.consumeOpenRuntimeState(manager, instance, closedAt, workflow_engine_enums_1.TaskStatusEnum.CANCELLED);
|
|
735
|
+
await this.notificationService.supersedeInstanceTaskNotifications({
|
|
736
|
+
instanceId: instance.id,
|
|
737
|
+
manager,
|
|
738
|
+
});
|
|
739
|
+
// Same prototype-preserving reason as `cancelAdhocDirective`: this
|
|
740
|
+
// entity is returned to the resolver and `ApprovalInstance` exposes
|
|
741
|
+
// five getter-backed JSON fields.
|
|
742
|
+
const closedInstance = await manager
|
|
743
|
+
.getRepository(approval_instance_entity_1.ApprovalInstanceEntity)
|
|
744
|
+
.save(Object.assign(new approval_instance_entity_1.ApprovalInstanceEntity(), instance, {
|
|
745
|
+
completedAt: closedAt,
|
|
746
|
+
state: finalState,
|
|
747
|
+
}));
|
|
748
|
+
await this.dispatchAdhocCompletionNotifications(manager, closedInstance, finalState);
|
|
749
|
+
await this.cancelPendingAdhocDirectives(manager, instance.id, null);
|
|
750
|
+
return closedInstance;
|
|
751
|
+
}
|
|
613
752
|
async resubmitApprovalInstance(input, authContext) {
|
|
614
753
|
const formData = parseJsonObject(input.formDataJson, 'formDataJson');
|
|
615
754
|
const preparation = this.formDataSourceValueResolver
|
|
@@ -1893,7 +2032,10 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1893
2032
|
* chain's later candidates sit in WAITING until it is their turn, so a task
|
|
1894
2033
|
* that has not reached them is not theirs yet.
|
|
1895
2034
|
*/
|
|
1896
|
-
async readOpenInboxTasks(assigneeMemberId
|
|
2035
|
+
async readOpenInboxTasks(assigneeMemberId, candidateStatuses = [
|
|
2036
|
+
workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING,
|
|
2037
|
+
workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED,
|
|
2038
|
+
]) {
|
|
1897
2039
|
const directTasks = await this.taskRepository.find({
|
|
1898
2040
|
order: { createdAt: 'DESC' },
|
|
1899
2041
|
where: [
|
|
@@ -1906,10 +2048,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
1906
2048
|
const candidateRows = await this.taskCandidateRepository.find({
|
|
1907
2049
|
where: {
|
|
1908
2050
|
memberId: assigneeMemberId,
|
|
1909
|
-
status: (0, typeorm_2.In)(
|
|
1910
|
-
workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING,
|
|
1911
|
-
workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED,
|
|
1912
|
-
]),
|
|
2051
|
+
status: (0, typeorm_2.In)(candidateStatuses),
|
|
1913
2052
|
},
|
|
1914
2053
|
});
|
|
1915
2054
|
const candidateTasks = candidateRows.length
|
|
@@ -2976,6 +3115,439 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
2976
3115
|
});
|
|
2977
3116
|
}
|
|
2978
3117
|
}
|
|
3118
|
+
/**
|
|
3119
|
+
* Administrator reassignment of one open task, moved in place: the task keeps
|
|
3120
|
+
* its id, decision policy, SLA and every other seat. Only `fromMemberId`'s
|
|
3121
|
+
* seat (and the assignee field, when it is theirs) moves to `toMemberId`.
|
|
3122
|
+
*
|
|
3123
|
+
* This is not a decision by the holder: it writes no TaskDecision and no
|
|
3124
|
+
* signature. It is recorded as a TASK_REASSIGNED activity and an
|
|
3125
|
+
* {@link ADMIN_REASSIGN_DELEGATION_REASON} delegation step.
|
|
3126
|
+
*
|
|
3127
|
+
* Unlike the holder's transfer, the target is NOT run through the delegation
|
|
3128
|
+
* rules (a rule for `toMemberId` does not apply). The initiator and submitter
|
|
3129
|
+
* exclusions and the grade check of ORG_MANAGER_CHAIN nodes still do.
|
|
3130
|
+
*
|
|
3131
|
+
* A refused reassignment is reported as `REJECTED` with an error code. When
|
|
3132
|
+
* `dryRun` is off, a refusal is thrown as a ConflictException whose message
|
|
3133
|
+
* carries that code.
|
|
3134
|
+
*/
|
|
3135
|
+
async reassignTask(taskId, options) {
|
|
3136
|
+
const task = await this.taskRepository.findOne({ where: { id: taskId } });
|
|
3137
|
+
if (!task) {
|
|
3138
|
+
throw new common_1.NotFoundException(`Task ${taskId} was not found`);
|
|
3139
|
+
}
|
|
3140
|
+
const result = await this.executeTaskReassignment(task, options);
|
|
3141
|
+
if (result.outcome === workflow_engine_enums_1.TaskReassignmentOutcomeEnum.REJECTED &&
|
|
3142
|
+
!options.dryRun) {
|
|
3143
|
+
throw new common_1.ConflictException(`[${result.errorCode}] ${result.message ?? ''}`.trim());
|
|
3144
|
+
}
|
|
3145
|
+
return result;
|
|
3146
|
+
}
|
|
3147
|
+
/**
|
|
3148
|
+
* Runs {@link reassignTask} for every open task `fromMemberId` holds, each in
|
|
3149
|
+
* its own transaction, and reports one result per task. A failing task does
|
|
3150
|
+
* not stop the others: a refusal is a REJECTED result, and an unexpected
|
|
3151
|
+
* error is reported as REJECTED with UNEXPECTED_ERROR for that task alone.
|
|
3152
|
+
*/
|
|
3153
|
+
async reassignMemberTasks(options) {
|
|
3154
|
+
const held = await this.readRunningMemberTasks(options.fromMemberId);
|
|
3155
|
+
return held.reduce(async (previous, { task }) => {
|
|
3156
|
+
const results = await previous;
|
|
3157
|
+
const result = await this.executeTaskReassignment(task, options).catch((error) => {
|
|
3158
|
+
this.logger.warn(`Reassigning task ${task.id} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3159
|
+
return buildTaskReassignmentResult(task, workflow_engine_enums_1.TaskReassignmentOutcomeEnum.REJECTED, workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.UNEXPECTED_ERROR);
|
|
3160
|
+
});
|
|
3161
|
+
return [...results, result];
|
|
3162
|
+
}, Promise.resolve([]));
|
|
3163
|
+
}
|
|
3164
|
+
/**
|
|
3165
|
+
* The open tasks a member holds, for the administrator's reassignment screen.
|
|
3166
|
+
* Same definition as the inbox, plus WAITING sequential seats, restricted to
|
|
3167
|
+
* RUNNING instances.
|
|
3168
|
+
*/
|
|
3169
|
+
async listMemberOpenTasks(memberId) {
|
|
3170
|
+
const held = await this.readRunningMemberTasks(memberId);
|
|
3171
|
+
if (!held.length) {
|
|
3172
|
+
return [];
|
|
3173
|
+
}
|
|
3174
|
+
const tasks = await this.attachTaskCandidateSummaries(held.map(({ task }) => task));
|
|
3175
|
+
const openRows = await this.taskCandidateRepository.find({
|
|
3176
|
+
where: {
|
|
3177
|
+
memberId,
|
|
3178
|
+
status: (0, typeorm_2.In)(OPEN_SEAT_STATUSES),
|
|
3179
|
+
taskId: (0, typeorm_2.In)(tasks.map((task) => task.id)),
|
|
3180
|
+
},
|
|
3181
|
+
});
|
|
3182
|
+
// One query for every template, not one per template: the listing opens
|
|
3183
|
+
// each time an administrator opens the reassignment screen.
|
|
3184
|
+
const templates = await this.approvalTemplateRepository.find({
|
|
3185
|
+
where: {
|
|
3186
|
+
id: (0, typeorm_2.In)([...new Set(held.map(({ instance }) => instance.templateId))]),
|
|
3187
|
+
},
|
|
3188
|
+
});
|
|
3189
|
+
const templateNameById = new Map(templates.map((template) => [
|
|
3190
|
+
template.id,
|
|
3191
|
+
template.name,
|
|
3192
|
+
]));
|
|
3193
|
+
const openRowByTaskId = new Map(openRows.map((row) => [
|
|
3194
|
+
row.taskId,
|
|
3195
|
+
row,
|
|
3196
|
+
]));
|
|
3197
|
+
const summaryByTaskId = new Map(tasks.map((summary) => [
|
|
3198
|
+
summary.id,
|
|
3199
|
+
summary,
|
|
3200
|
+
]));
|
|
3201
|
+
return held.map(({ instance, task }) => {
|
|
3202
|
+
const row = openRowByTaskId.get(task.id);
|
|
3203
|
+
const summary = summaryByTaskId.get(task.id);
|
|
3204
|
+
return {
|
|
3205
|
+
candidateStatus: row?.status ?? null,
|
|
3206
|
+
createdAt: task.createdAt,
|
|
3207
|
+
decisionPolicyType: readDecisionPolicySnapshot(task).type,
|
|
3208
|
+
businessKey: instance.businessKey,
|
|
3209
|
+
instanceId: instance.id,
|
|
3210
|
+
instanceTitle: instance.title,
|
|
3211
|
+
memberRole: row
|
|
3212
|
+
? workflow_engine_enums_1.MemberTaskRoleEnum.CANDIDATE
|
|
3213
|
+
: workflow_engine_enums_1.MemberTaskRoleEnum.ASSIGNEE,
|
|
3214
|
+
nodeId: task.nodeId,
|
|
3215
|
+
nodeLabel: summary?.nodeLabel ?? null,
|
|
3216
|
+
slaDueAt: task.slaDueAt,
|
|
3217
|
+
taskId: task.id,
|
|
3218
|
+
templateId: instance.templateId,
|
|
3219
|
+
templateName: templateNameById.get(instance.templateId) ?? null,
|
|
3220
|
+
};
|
|
3221
|
+
});
|
|
3222
|
+
}
|
|
3223
|
+
/**
|
|
3224
|
+
* The open tasks `memberId` holds on RUNNING instances, with the instance
|
|
3225
|
+
* each belongs to. The instance filter is applied here, not only in the query.
|
|
3226
|
+
*/
|
|
3227
|
+
async readRunningMemberTasks(memberId) {
|
|
3228
|
+
const tasks = await this.readOpenInboxTasks(memberId, OPEN_SEAT_STATUSES);
|
|
3229
|
+
if (!tasks.length) {
|
|
3230
|
+
return [];
|
|
3231
|
+
}
|
|
3232
|
+
const instances = await this.approvalInstanceRepository.find({
|
|
3233
|
+
where: { id: (0, typeorm_2.In)([...new Set(tasks.map((task) => task.instanceId))]) },
|
|
3234
|
+
});
|
|
3235
|
+
const runningById = new Map(instances
|
|
3236
|
+
.filter((instance) => instance.state === workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING)
|
|
3237
|
+
.map((instance) => [
|
|
3238
|
+
instance.id,
|
|
3239
|
+
instance,
|
|
3240
|
+
]));
|
|
3241
|
+
return tasks.flatMap((task) => {
|
|
3242
|
+
const instance = runningById.get(task.instanceId);
|
|
3243
|
+
return instance ? [{ instance, task }] : [];
|
|
3244
|
+
});
|
|
3245
|
+
}
|
|
3246
|
+
async executeTaskReassignment(preRead, options) {
|
|
3247
|
+
return this.approvalInstanceRepository.manager.transaction(async (manager) => {
|
|
3248
|
+
try {
|
|
3249
|
+
const plan = await this.planTaskReassignment(manager, preRead.id, options);
|
|
3250
|
+
if (options.dryRun) {
|
|
3251
|
+
return buildTaskReassignmentResult(preRead, workflow_engine_enums_1.TaskReassignmentOutcomeEnum.WOULD_REASSIGN, null);
|
|
3252
|
+
}
|
|
3253
|
+
await this.applyTaskReassignment(manager, plan, options);
|
|
3254
|
+
return buildTaskReassignmentResult(preRead, workflow_engine_enums_1.TaskReassignmentOutcomeEnum.REASSIGNED, null);
|
|
3255
|
+
}
|
|
3256
|
+
catch (error) {
|
|
3257
|
+
// Planning writes nothing, so a refusal commits no change.
|
|
3258
|
+
if (error instanceof TaskReassignmentRejectedError) {
|
|
3259
|
+
return buildTaskReassignmentResult(preRead, workflow_engine_enums_1.TaskReassignmentOutcomeEnum.REJECTED, error.errorCode);
|
|
3260
|
+
}
|
|
3261
|
+
throw error;
|
|
3262
|
+
}
|
|
3263
|
+
});
|
|
3264
|
+
}
|
|
3265
|
+
/**
|
|
3266
|
+
* Checks every rule of an administrator reassignment under the instance lock,
|
|
3267
|
+
* against the rows as they are now. Writes nothing; a refusal throws
|
|
3268
|
+
* {@link TaskReassignmentRejectedError}.
|
|
3269
|
+
*/
|
|
3270
|
+
async planTaskReassignment(manager, taskId, options) {
|
|
3271
|
+
const fromMemberId = options.fromMemberId;
|
|
3272
|
+
const toMemberId = options.toMemberId;
|
|
3273
|
+
if (!options.reason.trim()) {
|
|
3274
|
+
throw new TaskReassignmentRejectedError(workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.REASON_REQUIRED);
|
|
3275
|
+
}
|
|
3276
|
+
if (fromMemberId === toMemberId) {
|
|
3277
|
+
throw new TaskReassignmentRejectedError(workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_SAME_AS_SOURCE);
|
|
3278
|
+
}
|
|
3279
|
+
const taskRepository = manager.getRepository(task_entity_1.TaskEntity);
|
|
3280
|
+
const unlockedTask = await taskRepository.findOne({
|
|
3281
|
+
where: { id: taskId },
|
|
3282
|
+
});
|
|
3283
|
+
if (!unlockedTask) {
|
|
3284
|
+
throw new TaskReassignmentRejectedError(workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TASK_NOT_OPEN);
|
|
3285
|
+
}
|
|
3286
|
+
await manager.query('SELECT pg_advisory_xact_lock(hashtext($1))', [
|
|
3287
|
+
unlockedTask.instanceId,
|
|
3288
|
+
]);
|
|
3289
|
+
// Read again under the lock: the task may have been decided or moved while
|
|
3290
|
+
// this request waited for it.
|
|
3291
|
+
const task = await taskRepository.findOne({ where: { id: taskId } });
|
|
3292
|
+
if (!task || !OPEN_TASK_STATUSES.includes(task.status)) {
|
|
3293
|
+
throw new TaskReassignmentRejectedError(workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TASK_NOT_OPEN);
|
|
3294
|
+
}
|
|
3295
|
+
const instance = await manager
|
|
3296
|
+
.getRepository(approval_instance_entity_1.ApprovalInstanceEntity)
|
|
3297
|
+
.findOne({ where: { id: task.instanceId } });
|
|
3298
|
+
if (!instance || instance.state !== workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING) {
|
|
3299
|
+
throw new TaskReassignmentRejectedError(workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.INSTANCE_NOT_RUNNING);
|
|
3300
|
+
}
|
|
3301
|
+
const node = readWorkflowNodeOrThrow(instance.workflowSnapshot, task.nodeId);
|
|
3302
|
+
if (node.type !== 'userTask') {
|
|
3303
|
+
throw new common_1.ConflictException(`Task ${task.id} is not bound to a user task node`);
|
|
3304
|
+
}
|
|
3305
|
+
const candidates = await manager
|
|
3306
|
+
.getRepository(task_candidate_entity_1.TaskCandidateEntity)
|
|
3307
|
+
.find({ where: { taskId: task.id } });
|
|
3308
|
+
const sourceRow = candidates.find((candidate) => candidate.memberId === fromMemberId &&
|
|
3309
|
+
OPEN_SEAT_STATUSES.includes(candidate.status)) ?? null;
|
|
3310
|
+
// A holder with no candidate row at all is the assignee of a direct task.
|
|
3311
|
+
// Anyone with a row that is not open (COMPLETED, TRANSFERRED, ...) no
|
|
3312
|
+
// longer has a seat, so they are refused even when they are the assignee.
|
|
3313
|
+
const sourceHasAnyRow = candidates.some((candidate) => candidate.memberId === fromMemberId);
|
|
3314
|
+
if (!sourceRow &&
|
|
3315
|
+
!(task.assigneeMemberId === fromMemberId && !sourceHasAnyRow)) {
|
|
3316
|
+
throw new TaskReassignmentRejectedError(workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.SOURCE_NOT_ON_TASK);
|
|
3317
|
+
}
|
|
3318
|
+
const targetRows = candidates.filter((candidate) => candidate.memberId === toMemberId);
|
|
3319
|
+
const targetDecisions = await manager
|
|
3320
|
+
.getRepository(task_decision_entity_1.TaskDecisionEntity)
|
|
3321
|
+
.find({ where: { decidedByMemberId: toMemberId, taskId: task.id } });
|
|
3322
|
+
// Checked before "already on task": a member who has decided here may
|
|
3323
|
+
// still be the assignee field, and the decided reason is the useful one.
|
|
3324
|
+
if (targetDecisions.length > 0 ||
|
|
3325
|
+
targetRows.some((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.COMPLETED)) {
|
|
3326
|
+
throw new TaskReassignmentRejectedError(workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_ALREADY_DECIDED);
|
|
3327
|
+
}
|
|
3328
|
+
if (task.assigneeMemberId === toMemberId ||
|
|
3329
|
+
targetRows.some((candidate) => OPEN_SEAT_STATUSES.includes(candidate.status))) {
|
|
3330
|
+
throw new TaskReassignmentRejectedError(workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_ALREADY_ON_TASK);
|
|
3331
|
+
}
|
|
3332
|
+
if (isInitiatorExcluded(node)) {
|
|
3333
|
+
if (toMemberId === instance.initiatorMemberId) {
|
|
3334
|
+
throw new TaskReassignmentRejectedError(workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_INITIATOR);
|
|
3335
|
+
}
|
|
3336
|
+
if (toMemberId === (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance)) {
|
|
3337
|
+
throw new TaskReassignmentRejectedError(workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_SUBMITTER);
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
if (!task.isAdhoc && isOrgManagerChainNode(node)) {
|
|
3341
|
+
const requiredGrade = await this.readTransferTargetRequiredGrade(manager, {
|
|
3342
|
+
actorMemberId: fromMemberId,
|
|
3343
|
+
delegationChain: sourceRow?.delegationChain ?? task.delegationChain,
|
|
3344
|
+
targetMemberId: toMemberId,
|
|
3345
|
+
});
|
|
3346
|
+
if (requiredGrade !== null) {
|
|
3347
|
+
throw new TaskReassignmentRejectedError(workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_GRADE_TOO_LOW);
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
return {
|
|
3351
|
+
instance,
|
|
3352
|
+
node,
|
|
3353
|
+
reusableRow: targetRows.find((candidate) => REUSABLE_SEAT_STATUSES.includes(candidate.status)) ?? null,
|
|
3354
|
+
sourceRow,
|
|
3355
|
+
task,
|
|
3356
|
+
};
|
|
3357
|
+
}
|
|
3358
|
+
async applyTaskReassignment(manager, plan, options) {
|
|
3359
|
+
const decidedAt = new Date();
|
|
3360
|
+
const reason = options.reason.trim();
|
|
3361
|
+
const step = {
|
|
3362
|
+
actorMemberId: options.actorMemberId,
|
|
3363
|
+
comment: reason,
|
|
3364
|
+
from: options.fromMemberId,
|
|
3365
|
+
reason: exports.ADMIN_REASSIGN_DELEGATION_REASON,
|
|
3366
|
+
ruleId: null,
|
|
3367
|
+
to: options.toMemberId,
|
|
3368
|
+
};
|
|
3369
|
+
const seat = await this.replaceOpenSeat(manager, {
|
|
3370
|
+
decidedAt,
|
|
3371
|
+
fromMemberId: options.fromMemberId,
|
|
3372
|
+
reusableRow: plan.reusableRow,
|
|
3373
|
+
sourceRow: plan.sourceRow,
|
|
3374
|
+
step,
|
|
3375
|
+
task: plan.task,
|
|
3376
|
+
toMemberId: options.toMemberId,
|
|
3377
|
+
});
|
|
3378
|
+
const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
|
|
3379
|
+
await activityRepository.save(activityRepository.create({
|
|
3380
|
+
actorMemberId: options.actorMemberId,
|
|
3381
|
+
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_REASSIGNED,
|
|
3382
|
+
instanceId: plan.instance.id,
|
|
3383
|
+
nodeId: plan.task.nodeId,
|
|
3384
|
+
payload: {
|
|
3385
|
+
actorMemberId: options.actorMemberId,
|
|
3386
|
+
assigneeChanged: seat.assigneeMoved,
|
|
3387
|
+
candidateStatus: plan.sourceRow?.status ?? null,
|
|
3388
|
+
fromMemberId: options.fromMemberId,
|
|
3389
|
+
reason,
|
|
3390
|
+
sequence: plan.sourceRow?.sequence ?? null,
|
|
3391
|
+
toMemberId: options.toMemberId,
|
|
3392
|
+
},
|
|
3393
|
+
taskId: plan.task.id,
|
|
3394
|
+
}));
|
|
3395
|
+
await this.notifySeatReplaced(manager, {
|
|
3396
|
+
fromMemberId: options.fromMemberId,
|
|
3397
|
+
instance: plan.instance,
|
|
3398
|
+
node: plan.node,
|
|
3399
|
+
seat,
|
|
3400
|
+
toMemberId: options.toMemberId,
|
|
3401
|
+
});
|
|
3402
|
+
}
|
|
3403
|
+
/**
|
|
3404
|
+
* A holder's transfer of their seat on a group stage (PARALLEL_ALL,
|
|
3405
|
+
* PARALLEL_ANY, QUORUM). The stage stays open: the transferring seat moves
|
|
3406
|
+
* to the target in place, the other seats keep their state, and the task
|
|
3407
|
+
* keeps its id. Unlike transferTask, no replacement SINGLE task is created
|
|
3408
|
+
* and the other voters are not superseded. The transferring member's
|
|
3409
|
+
* decision and signature are kept on the task.
|
|
3410
|
+
*/
|
|
3411
|
+
async transferGroupSeat(manager, { actorCandidate, decidedAt, decision, instance, node, reusableRow, signature, task, taskBeforeClaim, toMemberId, transferReason, }) {
|
|
3412
|
+
const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
|
|
3413
|
+
const fromMemberId = decision.decidedByMemberId;
|
|
3414
|
+
// decideTask claims the task for whoever decides, writing them into the
|
|
3415
|
+
// task-level assignee. A seat transfer is not a claim of the whole stage:
|
|
3416
|
+
// put the task-level holder back first, so the seat replacement only moves
|
|
3417
|
+
// the assignee, the delegation chain and openedAt when the transferring
|
|
3418
|
+
// member really held the task.
|
|
3419
|
+
const unclaimedTask = await manager.getRepository(task_entity_1.TaskEntity).save(Object.assign(new task_entity_1.TaskEntity(), task, {
|
|
3420
|
+
assigneeMemberId: taskBeforeClaim.assigneeMemberId,
|
|
3421
|
+
openedAt: taskBeforeClaim.openedAt,
|
|
3422
|
+
originalAssigneeMemberId: taskBeforeClaim.originalAssigneeMemberId,
|
|
3423
|
+
}));
|
|
3424
|
+
const seat = await this.replaceOpenSeat(manager, {
|
|
3425
|
+
decidedAt,
|
|
3426
|
+
fromMemberId,
|
|
3427
|
+
reusableRow,
|
|
3428
|
+
sourceRow: actorCandidate,
|
|
3429
|
+
step: {
|
|
3430
|
+
from: fromMemberId,
|
|
3431
|
+
reason: transferReason,
|
|
3432
|
+
ruleId: null,
|
|
3433
|
+
to: toMemberId,
|
|
3434
|
+
},
|
|
3435
|
+
task: unclaimedTask,
|
|
3436
|
+
toMemberId,
|
|
3437
|
+
});
|
|
3438
|
+
await activityRepository.save(activityRepository.create({
|
|
3439
|
+
actorMemberId: fromMemberId,
|
|
3440
|
+
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_DECIDED,
|
|
3441
|
+
instanceId: instance.id,
|
|
3442
|
+
nodeId: task.nodeId,
|
|
3443
|
+
payload: {
|
|
3444
|
+
action: workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED,
|
|
3445
|
+
comment: decision.comment,
|
|
3446
|
+
decisionId: decision.id,
|
|
3447
|
+
signatureId: signature.id,
|
|
3448
|
+
signedPayloadHash: signature.signedPayloadHash,
|
|
3449
|
+
transferToMemberId: toMemberId,
|
|
3450
|
+
},
|
|
3451
|
+
taskId: task.id,
|
|
3452
|
+
}));
|
|
3453
|
+
await this.notifySeatReplaced(manager, {
|
|
3454
|
+
fromMemberId,
|
|
3455
|
+
instance,
|
|
3456
|
+
node,
|
|
3457
|
+
seat,
|
|
3458
|
+
toMemberId,
|
|
3459
|
+
});
|
|
3460
|
+
}
|
|
3461
|
+
/**
|
|
3462
|
+
* Moves one open seat from `fromMemberId` to `toMemberId` on the same task.
|
|
3463
|
+
*
|
|
3464
|
+
* With a source candidate row: that row is marked TRANSFERRED, and the target
|
|
3465
|
+
* takes a row with the same sequence, source type and original member. A
|
|
3466
|
+
* WAITING seat stays WAITING; anything else becomes PENDING. A closed row the
|
|
3467
|
+
* target already has (CANCELLED, SUPERSEDED, TRANSFERRED) is reused, because
|
|
3468
|
+
* the database allows one row per (task, member).
|
|
3469
|
+
*
|
|
3470
|
+
* Without one (a direct assignee with no candidate row) only the assignee
|
|
3471
|
+
* field moves. When the source is the assignee, the assignee moves too and
|
|
3472
|
+
* the step is appended to the task's chain.
|
|
3473
|
+
*/
|
|
3474
|
+
async replaceOpenSeat(manager, { decidedAt, fromMemberId, reusableRow, sourceRow, step, task, toMemberId, }) {
|
|
3475
|
+
const taskRepository = manager.getRepository(task_entity_1.TaskEntity);
|
|
3476
|
+
const candidateRepository = manager.getRepository(task_candidate_entity_1.TaskCandidateEntity);
|
|
3477
|
+
const assigneeMoved = task.assigneeMemberId === fromMemberId;
|
|
3478
|
+
if (sourceRow) {
|
|
3479
|
+
await candidateRepository.save(Object.assign(new task_candidate_entity_1.TaskCandidateEntity(), sourceRow, {
|
|
3480
|
+
decidedAt,
|
|
3481
|
+
status: workflow_engine_enums_1.TaskCandidateStatusEnum.TRANSFERRED,
|
|
3482
|
+
}));
|
|
3483
|
+
}
|
|
3484
|
+
const seatStatus = sourceRow
|
|
3485
|
+
? sourceRow.status === workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING
|
|
3486
|
+
? workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING
|
|
3487
|
+
: workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING
|
|
3488
|
+
: null;
|
|
3489
|
+
if (sourceRow) {
|
|
3490
|
+
const seat = {
|
|
3491
|
+
claimedAt: null,
|
|
3492
|
+
decidedAt: null,
|
|
3493
|
+
delegationChain: [
|
|
3494
|
+
...readDelegationSteps(sourceRow.delegationChain),
|
|
3495
|
+
step,
|
|
3496
|
+
],
|
|
3497
|
+
memberId: toMemberId,
|
|
3498
|
+
originalMemberId: sourceRow.originalMemberId,
|
|
3499
|
+
sequence: sourceRow.sequence,
|
|
3500
|
+
sourceType: sourceRow.sourceType,
|
|
3501
|
+
status: seatStatus ?? workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING,
|
|
3502
|
+
};
|
|
3503
|
+
if (reusableRow) {
|
|
3504
|
+
await candidateRepository.save(Object.assign(new task_candidate_entity_1.TaskCandidateEntity(), reusableRow, seat));
|
|
3505
|
+
}
|
|
3506
|
+
else {
|
|
3507
|
+
await candidateRepository.save(candidateRepository.create({ ...seat, taskId: task.id }));
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
const nextTask = assigneeMoved
|
|
3511
|
+
? await taskRepository.save(Object.assign(new task_entity_1.TaskEntity(), task, {
|
|
3512
|
+
assigneeMemberId: toMemberId,
|
|
3513
|
+
delegationChain: [
|
|
3514
|
+
...readDelegationSteps(task.delegationChain),
|
|
3515
|
+
step,
|
|
3516
|
+
],
|
|
3517
|
+
openedAt: null,
|
|
3518
|
+
}))
|
|
3519
|
+
: task;
|
|
3520
|
+
return { assigneeMoved, seatStatus, task: nextTask };
|
|
3521
|
+
}
|
|
3522
|
+
/**
|
|
3523
|
+
* Notifications for a replaced seat: the member who gave it up is resolved as
|
|
3524
|
+
* TRANSFERRED (their notifications on this task only), and the member who
|
|
3525
|
+
* takes it is notified now if the seat is active. A WAITING seat notifies
|
|
3526
|
+
* when its turn comes, through the normal sequential activation.
|
|
3527
|
+
*/
|
|
3528
|
+
async notifySeatReplaced(manager, { fromMemberId, instance, node, seat, toMemberId, }) {
|
|
3529
|
+
await this.notificationService.resolveTaskNotifications({
|
|
3530
|
+
actingMemberId: fromMemberId,
|
|
3531
|
+
manager,
|
|
3532
|
+
resolution: notification_enums_1.NotificationResolutionEnum.TRANSFERRED,
|
|
3533
|
+
supersedeOthers: false,
|
|
3534
|
+
taskId: seat.task.id,
|
|
3535
|
+
});
|
|
3536
|
+
const isActive = seat.seatStatus === null ||
|
|
3537
|
+
seat.seatStatus === workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING;
|
|
3538
|
+
if (!isActive) {
|
|
3539
|
+
return;
|
|
3540
|
+
}
|
|
3541
|
+
await this.notificationService.createTaskAssignedNotification({
|
|
3542
|
+
instance,
|
|
3543
|
+
manager,
|
|
3544
|
+
node,
|
|
3545
|
+
task: Object.assign(new task_entity_1.TaskEntity(), seat.task, {
|
|
3546
|
+
assigneeMemberId: toMemberId,
|
|
3547
|
+
}),
|
|
3548
|
+
transferred: true,
|
|
3549
|
+
});
|
|
3550
|
+
}
|
|
2979
3551
|
/**
|
|
2980
3552
|
* Resolves who gets the node's task. `null` means the node asked to be
|
|
2981
3553
|
* skipped (`excludeInitiator.whenEmpty = SKIP_NODE`) because the initiator
|
|
@@ -3085,17 +3657,29 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3085
3657
|
* delegated, otherwise the actor). No grade on the principal means no
|
|
3086
3658
|
* constraint.
|
|
3087
3659
|
*/
|
|
3088
|
-
async assertTransferTargetGrade(manager,
|
|
3660
|
+
async assertTransferTargetGrade(manager, args) {
|
|
3661
|
+
const requiredLevel = await this.readTransferTargetRequiredGrade(manager, args);
|
|
3662
|
+
if (requiredLevel !== null) {
|
|
3663
|
+
throw new common_1.ForbiddenException(`簽核節點「${args.label}」需由職等 ${requiredLevel} 以上的成員簽核,不能轉簽給職等不足的成員`);
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3666
|
+
/**
|
|
3667
|
+
* The grade a transfer target must reach, or `null` when the target passes.
|
|
3668
|
+
* Seniority is measured from the member being replaced: the first delegator
|
|
3669
|
+
* on their chain, or that member themselves. Shared by the user transfer
|
|
3670
|
+
* (which throws) and the administrator reassignment (which reports).
|
|
3671
|
+
*/
|
|
3672
|
+
async readTransferTargetRequiredGrade(manager, { actorMemberId, delegationChain, targetMemberId, }) {
|
|
3089
3673
|
const principalMemberId = readDelegationSteps(delegationChain)[0]?.from ?? actorMemberId;
|
|
3090
3674
|
const date = this.readOrganizationToday();
|
|
3091
3675
|
const requiredLevel = await this.readMemberPositionLevel(manager, principalMemberId, date);
|
|
3092
3676
|
if (requiredLevel === null) {
|
|
3093
|
-
return;
|
|
3677
|
+
return null;
|
|
3094
3678
|
}
|
|
3095
3679
|
const targetLevel = await this.readMemberPositionLevel(manager, targetMemberId, date);
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3680
|
+
return targetLevel === null || targetLevel < requiredLevel
|
|
3681
|
+
? requiredLevel
|
|
3682
|
+
: null;
|
|
3099
3683
|
}
|
|
3100
3684
|
async resolveApproverResolver(manager, instance, resolver, label, subject = '簽核人', skippedChainMemberIds = []) {
|
|
3101
3685
|
if (resolver.type === 'DIRECT') {
|
|
@@ -3125,6 +3709,18 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3125
3709
|
return this.resolveInitiatorManagerCandidates(manager, instance, resolver.levelsUp, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
|
|
3126
3710
|
}
|
|
3127
3711
|
async resolvePositionCandidates(manager, positionId, label, subject) {
|
|
3712
|
+
const memberIds = await this.readActivePositionMemberIds(manager, positionId);
|
|
3713
|
+
if (!memberIds.length) {
|
|
3714
|
+
throw new common_1.ConflictException(`${label} 找不到${subject}:職位「${await this.readPositionName(manager, positionId)}」目前沒有任何有效成員`);
|
|
3715
|
+
}
|
|
3716
|
+
return memberIds;
|
|
3717
|
+
}
|
|
3718
|
+
/**
|
|
3719
|
+
* The members who hold the position today (organisation time zone) within
|
|
3720
|
+
* their effective period. Primary memberships come first, then the most
|
|
3721
|
+
* recently started. Empty when nobody holds it; never throws.
|
|
3722
|
+
*/
|
|
3723
|
+
async readActivePositionMemberIds(manager, positionId) {
|
|
3128
3724
|
const today = this.readOrganizationToday();
|
|
3129
3725
|
const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
|
|
3130
3726
|
where: { positionId },
|
|
@@ -3138,9 +3734,6 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3138
3734
|
}
|
|
3139
3735
|
return second.effectiveFrom.localeCompare(first.effectiveFrom);
|
|
3140
3736
|
});
|
|
3141
|
-
if (!activeMemberships.length) {
|
|
3142
|
-
throw new common_1.ConflictException(`${label} 找不到${subject}:職位「${await this.readPositionName(manager, positionId)}」目前沒有任何有效成員`);
|
|
3143
|
-
}
|
|
3144
3737
|
return uniqueTexts(activeMemberships.map((membership) => membership.memberId));
|
|
3145
3738
|
}
|
|
3146
3739
|
async resolveInitiatorManagerCandidates(manager, instance, levelsUp, fallback, label, preferClosestOrgUnit, subject) {
|
|
@@ -3149,9 +3742,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3149
3742
|
if (resolvedManagerMemberIds.length) {
|
|
3150
3743
|
return resolvedManagerMemberIds;
|
|
3151
3744
|
}
|
|
3152
|
-
return
|
|
3153
|
-
this.resolveFallbackAssignee(instance, fallback, label, subject, `找不到發起人的第 ${normalizedLevelsUp} 層主管`),
|
|
3154
|
-
];
|
|
3745
|
+
return this.resolveFallbackAssignees(manager, instance, fallback, label, subject, `找不到發起人的第 ${normalizedLevelsUp} 層主管`);
|
|
3155
3746
|
}
|
|
3156
3747
|
/**
|
|
3157
3748
|
* Walks up the initiator's manager chain, nearest manager first, until a
|
|
@@ -3202,10 +3793,10 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3202
3793
|
return chain;
|
|
3203
3794
|
}
|
|
3204
3795
|
}
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3796
|
+
// A position fallback can name several holders. They all join the last
|
|
3797
|
+
// tier, so they all have to sign, the same as every other chain member.
|
|
3798
|
+
const fallbackMemberIds = await this.resolveFallbackAssignees(manager, instance, fallback, label, subject, `沿主管鏈找不到職等達 ${minPositionLevel} 的主管`);
|
|
3799
|
+
return uniqueTexts([...chain, ...fallbackMemberIds]);
|
|
3209
3800
|
}
|
|
3210
3801
|
/** Highest position level over the member's active memberships, or `null`. */
|
|
3211
3802
|
async readMemberPositionLevel(manager, memberId, date) {
|
|
@@ -3240,11 +3831,46 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3240
3831
|
return [memberId, levels.length === 0 ? null : Math.max(...levels)];
|
|
3241
3832
|
}));
|
|
3242
3833
|
}
|
|
3834
|
+
/**
|
|
3835
|
+
* Every member a manager resolver's fallback yields. A `DIRECT` or `NONE`
|
|
3836
|
+
* fallback yields at most one; a `POSITION` fallback yields the position's
|
|
3837
|
+
* current holders, minus the initiator unless the fallback allows self
|
|
3838
|
+
* approval. Failure throws, as the single-member path does.
|
|
3839
|
+
*/
|
|
3840
|
+
async resolveFallbackAssignees(manager, instance, fallback, label, subject, failureReason) {
|
|
3841
|
+
if (fallback?.type !== 'POSITION') {
|
|
3842
|
+
return [
|
|
3843
|
+
this.resolveFallbackAssignee(instance, fallback, label, subject, failureReason),
|
|
3844
|
+
];
|
|
3845
|
+
}
|
|
3846
|
+
const failure = subject === '通知對象' ? '無法建立通知' : '無法建立待簽任務';
|
|
3847
|
+
if (!fallback.positionId.trim()) {
|
|
3848
|
+
throw new common_1.ConflictException(`${label} ${failure}:改派職位未設定。`);
|
|
3849
|
+
}
|
|
3850
|
+
const holderMemberIds = await this.readActivePositionMemberIds(manager, fallback.positionId);
|
|
3851
|
+
if (holderMemberIds.length === 0) {
|
|
3852
|
+
throw new common_1.ConflictException(`${label} ${failure}:${failureReason},且改派職位「${await this.readPositionName(manager, fallback.positionId)}」目前沒有任何有效成員。請聯絡流程管理員調整主管規則或模板 fallback。`);
|
|
3853
|
+
}
|
|
3854
|
+
const assignees = fallback.allowInitiatorSelfApproval
|
|
3855
|
+
? holderMemberIds
|
|
3856
|
+
: holderMemberIds.filter((memberId) => memberId !== instance.initiatorMemberId);
|
|
3857
|
+
if (assignees.length === 0) {
|
|
3858
|
+
throw new common_1.ConflictException(`${label} ${failure}:${failureReason},且改派職位的有效成員只有申請人本人,此節點未允許申請人自簽。`);
|
|
3859
|
+
}
|
|
3860
|
+
return assignees;
|
|
3861
|
+
}
|
|
3243
3862
|
resolveFallbackAssignee(instance, fallback, label, subject, failureReason) {
|
|
3244
3863
|
const failure = subject === '通知對象' ? '無法建立通知' : '無法建立待簽任務';
|
|
3245
3864
|
if (!fallback || fallback.type === 'NONE') {
|
|
3246
3865
|
throw new common_1.ConflictException(`${label} ${failure}:${failureReason},且未設定改派固定人。請聯絡流程管理員調整主管規則或模板 fallback。`);
|
|
3247
3866
|
}
|
|
3867
|
+
// A template saved by a newer engine can carry a fallback type this
|
|
3868
|
+
// engine does not know. Refuse it by name instead of reading `memberId`
|
|
3869
|
+
// off a shape it does not have, which surfaced as an opaque TypeError.
|
|
3870
|
+
const unsupportedType = (0, workflow_1.readUnsupportedApproverFallbackType)(fallback);
|
|
3871
|
+
if (unsupportedType !== null) {
|
|
3872
|
+
throw new common_1.ConflictException(`${label} ${failure}:${failureReason},且此引擎版本不支援改派類型「${unsupportedType}」。請升級 BPMCore 後再發佈或執行此流程。`);
|
|
3873
|
+
}
|
|
3248
3874
|
if (!fallback.memberId.trim()) {
|
|
3249
3875
|
throw new common_1.ConflictException(`${label} ${failure}:改派固定人未設定。`);
|
|
3250
3876
|
}
|
|
@@ -3296,9 +3922,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3296
3922
|
scopeType: organization_enums_1.ManagerResolutionScopeTypeEnum.ORG_UNIT,
|
|
3297
3923
|
})), date, readOrgUnitDepthMap(orgUnits), preferClosestOrgUnit);
|
|
3298
3924
|
if (!managerMemberIds.length) {
|
|
3299
|
-
return
|
|
3300
|
-
this.resolveFallbackAssignee(instance, fallback, label, subject, `找不到指定組織的主管規則`),
|
|
3301
|
-
];
|
|
3925
|
+
return this.resolveFallbackAssignees(manager, instance, fallback, label, subject, `找不到指定組織的主管規則`);
|
|
3302
3926
|
}
|
|
3303
3927
|
return managerMemberIds;
|
|
3304
3928
|
}
|
|
@@ -3807,7 +4431,7 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
3807
4431
|
}
|
|
3808
4432
|
};
|
|
3809
4433
|
exports.WorkflowEngineService = WorkflowEngineService;
|
|
3810
|
-
exports.WorkflowEngineService = WorkflowEngineService = tslib_1.__decorate([
|
|
4434
|
+
exports.WorkflowEngineService = WorkflowEngineService = WorkflowEngineService_1 = tslib_1.__decorate([
|
|
3811
4435
|
(0, common_1.Injectable)(),
|
|
3812
4436
|
tslib_1.__param(0, (0, typeorm_1.InjectRepository)(approval_instance_entity_1.ApprovalInstanceEntity)),
|
|
3813
4437
|
tslib_1.__param(1, (0, typeorm_1.InjectRepository)(workflow_token_entity_1.WorkflowTokenEntity)),
|
|
@@ -4016,8 +4640,12 @@ function readDelegationSteps(value) {
|
|
|
4016
4640
|
if (!from || !to || !reason) {
|
|
4017
4641
|
return null;
|
|
4018
4642
|
}
|
|
4643
|
+
const actorMemberId = readStringValue(step.actorMemberId);
|
|
4644
|
+
const comment = readStringValue(step.comment);
|
|
4019
4645
|
return {
|
|
4020
4646
|
from,
|
|
4647
|
+
...(actorMemberId ? { actorMemberId } : {}),
|
|
4648
|
+
...(comment ? { comment } : {}),
|
|
4021
4649
|
reason,
|
|
4022
4650
|
ruleId: readStringValue(step.ruleId),
|
|
4023
4651
|
to,
|
|
@@ -4397,6 +5025,22 @@ function readDecisionPolicySnapshot(task) {
|
|
|
4397
5025
|
}
|
|
4398
5026
|
return { type: 'SINGLE' };
|
|
4399
5027
|
}
|
|
5028
|
+
/**
|
|
5029
|
+
* Stages where several seats decide together. A holder's transfer on one of
|
|
5030
|
+
* them moves that seat in place; SINGLE and SEQUENTIAL keep the replacement
|
|
5031
|
+
* task path.
|
|
5032
|
+
*/
|
|
5033
|
+
function isGroupDecisionPolicy(policy) {
|
|
5034
|
+
return (policy.type === 'PARALLEL_ALL' ||
|
|
5035
|
+
policy.type === 'PARALLEL_ANY' ||
|
|
5036
|
+
policy.type === 'QUORUM');
|
|
5037
|
+
}
|
|
5038
|
+
/** Candidate states in which the seat is no longer part of the task. */
|
|
5039
|
+
const SEAT_LEFT_STATUSES = [
|
|
5040
|
+
workflow_engine_enums_1.TaskCandidateStatusEnum.CANCELLED,
|
|
5041
|
+
workflow_engine_enums_1.TaskCandidateStatusEnum.SUPERSEDED,
|
|
5042
|
+
workflow_engine_enums_1.TaskCandidateStatusEnum.TRANSFERRED,
|
|
5043
|
+
];
|
|
4400
5044
|
function shouldCompleteTaskAfterDecision({ action, candidates, decisionPolicy, }) {
|
|
4401
5045
|
if (action !== workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED) {
|
|
4402
5046
|
return true;
|
|
@@ -4406,7 +5050,11 @@ function shouldCompleteTaskAfterDecision({ action, candidates, decisionPolicy, }
|
|
|
4406
5050
|
return true;
|
|
4407
5051
|
}
|
|
4408
5052
|
const completedCount = candidates.filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.COMPLETED).length;
|
|
4409
|
-
|
|
5053
|
+
// A seat that has left the task (transferred away, cancelled, superseded)
|
|
5054
|
+
// no longer belongs to the stage. Counting it kept a PARALLEL_ALL or
|
|
5055
|
+
// SEQUENTIAL stage from ever reaching "every seat decided" once a seat had
|
|
5056
|
+
// been reassigned.
|
|
5057
|
+
const totalCount = Math.max(candidates.filter((candidate) => !SEAT_LEFT_STATUSES.includes(candidate.status)).length, 1);
|
|
4410
5058
|
if (decisionPolicy.type === 'PARALLEL_ALL' ||
|
|
4411
5059
|
decisionPolicy.type === 'SEQUENTIAL') {
|
|
4412
5060
|
return completedCount >= totalCount;
|
|
@@ -4637,6 +5285,9 @@ function readInstanceFinalStateLabel(finalState) {
|
|
|
4637
5285
|
if (finalState === workflow_engine_enums_1.ApprovalInstanceStateEnum.REJECTED) {
|
|
4638
5286
|
return '拒絕';
|
|
4639
5287
|
}
|
|
5288
|
+
if (finalState === workflow_engine_enums_1.ApprovalInstanceStateEnum.EXPIRED) {
|
|
5289
|
+
return '逾期終止';
|
|
5290
|
+
}
|
|
4640
5291
|
return '取消';
|
|
4641
5292
|
}
|
|
4642
5293
|
function readManagerMemberIdFromInitiatorSnapshot(initiatorMetadataSnapshot, levelsUp) {
|
|
@@ -5038,6 +5689,14 @@ function endSentence(message) {
|
|
|
5038
5689
|
// has no manager, or `null` when it can: like the runtime, it must exist, name
|
|
5039
5690
|
// a member, and not be the initiator unless the node allows self-approval.
|
|
5040
5691
|
function readDryRunFallbackProblem(initiatorMemberId, fallback) {
|
|
5692
|
+
if (fallback && (0, workflow_1.readUnsupportedApproverFallbackType)(fallback) !== null) {
|
|
5693
|
+
return 'UNSUPPORTED';
|
|
5694
|
+
}
|
|
5695
|
+
if (fallback?.type === 'POSITION') {
|
|
5696
|
+
// The holders are only known at run time, so the dry run cannot tell
|
|
5697
|
+
// whether the position is empty; it only rules out a blank position.
|
|
5698
|
+
return fallback.positionId.trim() === '' ? 'BLANK_POSITION' : null;
|
|
5699
|
+
}
|
|
5041
5700
|
if (!fallback || fallback.type !== 'DIRECT') {
|
|
5042
5701
|
return 'NOT_SET';
|
|
5043
5702
|
}
|
|
@@ -5050,9 +5709,15 @@ function readDryRunFallbackProblem(initiatorMemberId, fallback) {
|
|
|
5050
5709
|
: null;
|
|
5051
5710
|
}
|
|
5052
5711
|
function readDryRunFallbackMemberIds(input, fallback) {
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5712
|
+
if (readDryRunFallbackProblem(input.initiatorMemberId, fallback) !== null) {
|
|
5713
|
+
return [];
|
|
5714
|
+
}
|
|
5715
|
+
if (fallback?.type === 'DIRECT') {
|
|
5716
|
+
return [fallback.memberId.trim()];
|
|
5717
|
+
}
|
|
5718
|
+
// Same placeholder a POSITION node gets in a dry run; the label names it.
|
|
5719
|
+
return fallback?.type === 'POSITION'
|
|
5720
|
+
? [`position:${fallback.positionId.trim()}`]
|
|
5056
5721
|
: [];
|
|
5057
5722
|
}
|
|
5058
5723
|
function readDryRunNodeName(node) {
|
|
@@ -5096,6 +5761,12 @@ function readDryRunUnresolvedApproverMessage(resolver, nodeName, initiatorMember
|
|
|
5096
5761
|
if (fallbackProblem === 'IS_INITIATOR') {
|
|
5097
5762
|
return `簽核節點「${nodeName}」找不到主管,且改派固定人是申請人本人,此節點未允許申請人自簽`;
|
|
5098
5763
|
}
|
|
5764
|
+
if (fallbackProblem === 'BLANK_POSITION') {
|
|
5765
|
+
return `簽核節點「${nodeName}」找不到主管,且改派職位未設定`;
|
|
5766
|
+
}
|
|
5767
|
+
if (fallbackProblem === 'UNSUPPORTED') {
|
|
5768
|
+
return `簽核節點「${nodeName}」找不到主管,且此引擎版本不支援該改派設定,請升級 BPMCore 後再試`;
|
|
5769
|
+
}
|
|
5099
5770
|
return `簽核節點「${nodeName}」依申請人的組織找不到主管,請確認試跑時的申請人資料`;
|
|
5100
5771
|
}
|
|
5101
5772
|
function createDryRunStep(input) {
|