@rytass/bpm-core-nestjs-module 0.15.0 → 0.17.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.
Files changed (97) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +421 -271
  3. package/package.json +5 -5
  4. package/src/lib/attachment/attachment-options.d.ts +23 -10
  5. package/src/lib/attachment/attachment-options.js +17 -31
  6. package/src/lib/attachment/attachment-options.js.map +1 -1
  7. package/src/lib/attachment/attachment-request-origin.d.ts +19 -0
  8. package/src/lib/attachment/attachment-request-origin.js +51 -0
  9. package/src/lib/attachment/attachment-request-origin.js.map +1 -0
  10. package/src/lib/attachment/attachment.module.d.ts +1 -2
  11. package/src/lib/attachment/attachment.module.js.map +1 -1
  12. package/src/lib/attachment/attachment.queries.d.ts +2 -2
  13. package/src/lib/attachment/attachment.queries.js +9 -4
  14. package/src/lib/attachment/attachment.queries.js.map +1 -1
  15. package/src/lib/attachment/attachment.service.d.ts +6 -1
  16. package/src/lib/attachment/attachment.service.js +6 -4
  17. package/src/lib/attachment/attachment.service.js.map +1 -1
  18. package/src/lib/bpm/bpm-root-options.d.ts +1 -2
  19. package/src/lib/bpm/bpm-root-options.js.map +1 -1
  20. package/src/lib/bpm/bpm-root.module.d.ts +1 -2
  21. package/src/lib/bpm/bpm-root.module.js.map +1 -1
  22. package/src/lib/bpm-auth/bpm-auth.options.d.ts +1 -2
  23. package/src/lib/calendar/calendar.module.d.ts +1 -2
  24. package/src/lib/calendar/calendar.module.js.map +1 -1
  25. package/src/lib/delegation/delegation-rule.entity.js +3 -1
  26. package/src/lib/delegation/delegation-rule.entity.js.map +1 -1
  27. package/src/lib/delegation/delegation.service.d.ts +4 -0
  28. package/src/lib/delegation/delegation.service.js +19 -3
  29. package/src/lib/delegation/delegation.service.js.map +1 -1
  30. package/src/lib/delegation/dto/delegation-rule.input.js +6 -2
  31. package/src/lib/delegation/dto/delegation-rule.input.js.map +1 -1
  32. package/src/lib/form-data-source/form-data-source.module.d.ts +1 -2
  33. package/src/lib/form-data-source/form-data-source.module.js.map +1 -1
  34. package/src/lib/identity/identity.module.d.ts +1 -2
  35. package/src/lib/identity/identity.module.js.map +1 -1
  36. package/src/lib/migrations/0000000000001-enable-postgres-extensions.js +6 -2
  37. package/src/lib/migrations/0000000000001-enable-postgres-extensions.js.map +1 -1
  38. package/src/lib/notification/notification-options.d.ts +5 -5
  39. package/src/lib/notification/notification-options.module.d.ts +1 -2
  40. package/src/lib/notification/notification-options.module.js.map +1 -1
  41. package/src/lib/notification/notification.service.js +5 -2
  42. package/src/lib/notification/notification.service.js.map +1 -1
  43. package/src/lib/organization/index.d.ts +1 -0
  44. package/src/lib/organization/index.js +1 -0
  45. package/src/lib/organization/index.js.map +1 -1
  46. package/src/lib/organization/org-unit-retirement-impact.object.d.ts +18 -0
  47. package/src/lib/organization/org-unit-retirement-impact.object.js +34 -0
  48. package/src/lib/organization/org-unit-retirement-impact.object.js.map +1 -0
  49. package/src/lib/organization/organization.mutations.d.ts +1 -1
  50. package/src/lib/organization/organization.mutations.js +7 -4
  51. package/src/lib/organization/organization.mutations.js.map +1 -1
  52. package/src/lib/organization/organization.queries.d.ts +2 -0
  53. package/src/lib/organization/organization.queries.js +13 -0
  54. package/src/lib/organization/organization.queries.js.map +1 -1
  55. package/src/lib/organization/organization.service.d.ts +32 -1
  56. package/src/lib/organization/organization.service.js +157 -16
  57. package/src/lib/organization/organization.service.js.map +1 -1
  58. package/src/lib/signature/signature.module.d.ts +1 -2
  59. package/src/lib/signature/signature.module.js.map +1 -1
  60. package/src/lib/template/template.service.js +91 -57
  61. package/src/lib/template/template.service.js.map +1 -1
  62. package/src/lib/template/workflow-definition.validator.js +11 -0
  63. package/src/lib/template/workflow-definition.validator.js.map +1 -1
  64. package/src/lib/workflow-engine/dto/reassign-member-tasks.input.d.ts +7 -0
  65. package/src/lib/workflow-engine/dto/reassign-member-tasks.input.js +34 -0
  66. package/src/lib/workflow-engine/dto/reassign-member-tasks.input.js.map +1 -0
  67. package/src/lib/workflow-engine/dto/reassign-task.input.d.ts +8 -0
  68. package/src/lib/workflow-engine/dto/reassign-task.input.js +39 -0
  69. package/src/lib/workflow-engine/dto/reassign-task.input.js.map +1 -0
  70. package/src/lib/workflow-engine/index.d.ts +3 -0
  71. package/src/lib/workflow-engine/index.js +3 -0
  72. package/src/lib/workflow-engine/index.js.map +1 -1
  73. package/src/lib/workflow-engine/task-reassignment.object.d.ts +26 -0
  74. package/src/lib/workflow-engine/task-reassignment.object.js +97 -0
  75. package/src/lib/workflow-engine/task-reassignment.object.js.map +1 -0
  76. package/src/lib/workflow-engine/workflow-dashboard-summary.object.js +18 -6
  77. package/src/lib/workflow-engine/workflow-dashboard-summary.object.js.map +1 -1
  78. package/src/lib/workflow-engine/workflow-engine.enums.d.ts +29 -0
  79. package/src/lib/workflow-engine/workflow-engine.enums.js +42 -1
  80. package/src/lib/workflow-engine/workflow-engine.enums.js.map +1 -1
  81. package/src/lib/workflow-engine/workflow-engine.module.d.ts +1 -2
  82. package/src/lib/workflow-engine/workflow-engine.module.js.map +1 -1
  83. package/src/lib/workflow-engine/workflow-engine.mutations.d.ts +5 -0
  84. package/src/lib/workflow-engine/workflow-engine.mutations.js +48 -0
  85. package/src/lib/workflow-engine/workflow-engine.mutations.js.map +1 -1
  86. package/src/lib/workflow-engine/workflow-engine.queries.d.ts +2 -0
  87. package/src/lib/workflow-engine/workflow-engine.queries.js +15 -0
  88. package/src/lib/workflow-engine/workflow-engine.queries.js.map +1 -1
  89. package/src/lib/workflow-engine/workflow-engine.service.d.ts +160 -1
  90. package/src/lib/workflow-engine/workflow-engine.service.js +749 -53
  91. package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
  92. package/src/lib/workflow-engine/workflow-engine.tokens.d.ts +2 -1
  93. package/src/lib/workflow-engine/workflow-engine.tokens.js.map +1 -1
  94. package/src/lib/workflow-webhook/workflow-webhook-options.module.d.ts +1 -2
  95. package/src/lib/workflow-webhook/workflow-webhook-options.module.js.map +1 -1
  96. package/src/lib/workflow-webhook/workflow-webhook.module.d.ts +1 -2
  97. package/src/lib/workflow-webhook/workflow-webhook.module.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 cancelledAt = new Date();
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
@@ -1804,14 +1943,13 @@ let WorkflowEngineService = class WorkflowEngineService {
1804
1943
  view: workflow_engine_enums_1.ApprovalInstanceListViewEnum.ALL,
1805
1944
  })).filter((instance) => isInstanceWithinRange(instance, options));
1806
1945
  const instanceIds = instances.map((instance) => instance.id);
1807
- const openTasks = instanceIds.length
1808
- ? await this.taskRepository.find({
1809
- where: {
1810
- instanceId: (0, typeorm_2.In)(instanceIds),
1811
- status: (0, typeorm_2.In)([workflow_engine_enums_1.TaskStatusEnum.PENDING, workflow_engine_enums_1.TaskStatusEnum.IN_PROGRESS]),
1812
- },
1813
- })
1814
- : [];
1946
+ // The task counters answer "what is waiting on me", not "what is open on
1947
+ // the instances I can see": a member who can read an instance as initiator,
1948
+ // reader or administrator is not thereby the one who has to act on it. So
1949
+ // they count the member's own inbox, narrowed to the instances in range.
1950
+ // Without a scope there is no member to speak for, and the counters keep
1951
+ // describing every open task.
1952
+ const openTasks = await this.readDashboardOpenTasks(scope, instanceIds);
1815
1953
  const now = Date.now();
1816
1954
  return {
1817
1955
  activeInstanceCount: instances.filter((instance) => instance.state === workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING).length,
@@ -1822,6 +1960,21 @@ let WorkflowEngineService = class WorkflowEngineService {
1822
1960
  totalInstanceCount: instances.length,
1823
1961
  };
1824
1962
  }
1963
+ async readDashboardOpenTasks(scope, instanceIds) {
1964
+ if (!instanceIds.length) {
1965
+ return [];
1966
+ }
1967
+ if (scope) {
1968
+ const instanceIdSet = new Set(instanceIds);
1969
+ return (await this.readOpenInboxTasks(scope.memberId)).filter((task) => instanceIdSet.has(task.instanceId));
1970
+ }
1971
+ return this.taskRepository.find({
1972
+ where: {
1973
+ instanceId: (0, typeorm_2.In)(instanceIds),
1974
+ status: (0, typeorm_2.In)([workflow_engine_enums_1.TaskStatusEnum.PENDING, workflow_engine_enums_1.TaskStatusEnum.IN_PROGRESS]),
1975
+ },
1976
+ });
1977
+ }
1825
1978
  async listLaunchableApprovalTemplates(memberId) {
1826
1979
  const templates = await this.approvalTemplateRepository.find({
1827
1980
  order: { updatedAt: 'DESC' },
@@ -1869,6 +2022,20 @@ let WorkflowEngineService = class WorkflowEngineService {
1869
2022
  }));
1870
2023
  }
1871
2024
  async listInboxTasks(assigneeMemberId) {
2025
+ return this.attachTaskCandidateSummaries(await this.readOpenInboxTasks(assigneeMemberId));
2026
+ }
2027
+ /**
2028
+ * The open tasks that are this member's own to act on — the single
2029
+ * definition behind both the inbox list and the dashboard counters, so the
2030
+ * two can never disagree. A task is theirs when it is assigned to them, or
2031
+ * when they hold a PENDING or CLAIMED candidate row on it. A sequential
2032
+ * chain's later candidates sit in WAITING until it is their turn, so a task
2033
+ * that has not reached them is not theirs yet.
2034
+ */
2035
+ async readOpenInboxTasks(assigneeMemberId, candidateStatuses = [
2036
+ workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING,
2037
+ workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED,
2038
+ ]) {
1872
2039
  const directTasks = await this.taskRepository.find({
1873
2040
  order: { createdAt: 'DESC' },
1874
2041
  where: [
@@ -1881,10 +2048,7 @@ let WorkflowEngineService = class WorkflowEngineService {
1881
2048
  const candidateRows = await this.taskCandidateRepository.find({
1882
2049
  where: {
1883
2050
  memberId: assigneeMemberId,
1884
- status: (0, typeorm_2.In)([
1885
- workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING,
1886
- workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED,
1887
- ]),
2051
+ status: (0, typeorm_2.In)(candidateStatuses),
1888
2052
  },
1889
2053
  });
1890
2054
  const candidateTasks = candidateRows.length
@@ -1918,7 +2082,7 @@ let WorkflowEngineService = class WorkflowEngineService {
1918
2082
  })
1919
2083
  : [];
1920
2084
  const settledTaskIds = new Set(settledCandidateRows.map((candidate) => candidate.taskId));
1921
- return this.attachTaskCandidateSummaries(openTasks.filter((task) => !settledTaskIds.has(task.id)));
2085
+ return openTasks.filter((task) => !settledTaskIds.has(task.id));
1922
2086
  }
1923
2087
  async listApprovalHistoryTasks(assigneeMemberId) {
1924
2088
  const directTasks = await this.taskRepository.find({
@@ -2951,6 +3115,439 @@ let WorkflowEngineService = class WorkflowEngineService {
2951
3115
  });
2952
3116
  }
2953
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
+ }
2954
3551
  /**
2955
3552
  * Resolves who gets the node's task. `null` means the node asked to be
2956
3553
  * skipped (`excludeInitiator.whenEmpty = SKIP_NODE`) because the initiator
@@ -3060,17 +3657,29 @@ let WorkflowEngineService = class WorkflowEngineService {
3060
3657
  * delegated, otherwise the actor). No grade on the principal means no
3061
3658
  * constraint.
3062
3659
  */
3063
- async assertTransferTargetGrade(manager, { actorMemberId, delegationChain, label, targetMemberId, }) {
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, }) {
3064
3673
  const principalMemberId = readDelegationSteps(delegationChain)[0]?.from ?? actorMemberId;
3065
3674
  const date = this.readOrganizationToday();
3066
3675
  const requiredLevel = await this.readMemberPositionLevel(manager, principalMemberId, date);
3067
3676
  if (requiredLevel === null) {
3068
- return;
3677
+ return null;
3069
3678
  }
3070
3679
  const targetLevel = await this.readMemberPositionLevel(manager, targetMemberId, date);
3071
- if (targetLevel === null || targetLevel < requiredLevel) {
3072
- throw new common_1.ForbiddenException(`簽核節點「${label}」需由職等 ${requiredLevel} 以上的成員簽核,不能轉簽給職等不足的成員`);
3073
- }
3680
+ return targetLevel === null || targetLevel < requiredLevel
3681
+ ? requiredLevel
3682
+ : null;
3074
3683
  }
3075
3684
  async resolveApproverResolver(manager, instance, resolver, label, subject = '簽核人', skippedChainMemberIds = []) {
3076
3685
  if (resolver.type === 'DIRECT') {
@@ -3100,6 +3709,18 @@ let WorkflowEngineService = class WorkflowEngineService {
3100
3709
  return this.resolveInitiatorManagerCandidates(manager, instance, resolver.levelsUp, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
3101
3710
  }
3102
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) {
3103
3724
  const today = this.readOrganizationToday();
3104
3725
  const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
3105
3726
  where: { positionId },
@@ -3113,9 +3734,6 @@ let WorkflowEngineService = class WorkflowEngineService {
3113
3734
  }
3114
3735
  return second.effectiveFrom.localeCompare(first.effectiveFrom);
3115
3736
  });
3116
- if (!activeMemberships.length) {
3117
- throw new common_1.ConflictException(`${label} 找不到${subject}:職位「${await this.readPositionName(manager, positionId)}」目前沒有任何有效成員`);
3118
- }
3119
3737
  return uniqueTexts(activeMemberships.map((membership) => membership.memberId));
3120
3738
  }
3121
3739
  async resolveInitiatorManagerCandidates(manager, instance, levelsUp, fallback, label, preferClosestOrgUnit, subject) {
@@ -3124,9 +3742,7 @@ let WorkflowEngineService = class WorkflowEngineService {
3124
3742
  if (resolvedManagerMemberIds.length) {
3125
3743
  return resolvedManagerMemberIds;
3126
3744
  }
3127
- return [
3128
- this.resolveFallbackAssignee(instance, fallback, label, subject, `找不到發起人的第 ${normalizedLevelsUp} 層主管`),
3129
- ];
3745
+ return this.resolveFallbackAssignees(manager, instance, fallback, label, subject, `找不到發起人的第 ${normalizedLevelsUp} 層主管`);
3130
3746
  }
3131
3747
  /**
3132
3748
  * Walks up the initiator's manager chain, nearest manager first, until a
@@ -3177,10 +3793,10 @@ let WorkflowEngineService = class WorkflowEngineService {
3177
3793
  return chain;
3178
3794
  }
3179
3795
  }
3180
- const fallbackMemberId = this.resolveFallbackAssignee(instance, fallback, label, subject, `沿主管鏈找不到職等達 ${minPositionLevel} 的主管`);
3181
- return chain.includes(fallbackMemberId)
3182
- ? chain
3183
- : [...chain, fallbackMemberId];
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]);
3184
3800
  }
3185
3801
  /** Highest position level over the member's active memberships, or `null`. */
3186
3802
  async readMemberPositionLevel(manager, memberId, date) {
@@ -3215,11 +3831,46 @@ let WorkflowEngineService = class WorkflowEngineService {
3215
3831
  return [memberId, levels.length === 0 ? null : Math.max(...levels)];
3216
3832
  }));
3217
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
+ }
3218
3862
  resolveFallbackAssignee(instance, fallback, label, subject, failureReason) {
3219
3863
  const failure = subject === '通知對象' ? '無法建立通知' : '無法建立待簽任務';
3220
3864
  if (!fallback || fallback.type === 'NONE') {
3221
3865
  throw new common_1.ConflictException(`${label} ${failure}:${failureReason},且未設定改派固定人。請聯絡流程管理員調整主管規則或模板 fallback。`);
3222
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
+ }
3223
3874
  if (!fallback.memberId.trim()) {
3224
3875
  throw new common_1.ConflictException(`${label} ${failure}:改派固定人未設定。`);
3225
3876
  }
@@ -3271,9 +3922,7 @@ let WorkflowEngineService = class WorkflowEngineService {
3271
3922
  scopeType: organization_enums_1.ManagerResolutionScopeTypeEnum.ORG_UNIT,
3272
3923
  })), date, readOrgUnitDepthMap(orgUnits), preferClosestOrgUnit);
3273
3924
  if (!managerMemberIds.length) {
3274
- return [
3275
- this.resolveFallbackAssignee(instance, fallback, label, subject, `找不到指定組織的主管規則`),
3276
- ];
3925
+ return this.resolveFallbackAssignees(manager, instance, fallback, label, subject, `找不到指定組織的主管規則`);
3277
3926
  }
3278
3927
  return managerMemberIds;
3279
3928
  }
@@ -3782,7 +4431,7 @@ let WorkflowEngineService = class WorkflowEngineService {
3782
4431
  }
3783
4432
  };
3784
4433
  exports.WorkflowEngineService = WorkflowEngineService;
3785
- exports.WorkflowEngineService = WorkflowEngineService = tslib_1.__decorate([
4434
+ exports.WorkflowEngineService = WorkflowEngineService = WorkflowEngineService_1 = tslib_1.__decorate([
3786
4435
  (0, common_1.Injectable)(),
3787
4436
  tslib_1.__param(0, (0, typeorm_1.InjectRepository)(approval_instance_entity_1.ApprovalInstanceEntity)),
3788
4437
  tslib_1.__param(1, (0, typeorm_1.InjectRepository)(workflow_token_entity_1.WorkflowTokenEntity)),
@@ -3991,8 +4640,12 @@ function readDelegationSteps(value) {
3991
4640
  if (!from || !to || !reason) {
3992
4641
  return null;
3993
4642
  }
4643
+ const actorMemberId = readStringValue(step.actorMemberId);
4644
+ const comment = readStringValue(step.comment);
3994
4645
  return {
3995
4646
  from,
4647
+ ...(actorMemberId ? { actorMemberId } : {}),
4648
+ ...(comment ? { comment } : {}),
3996
4649
  reason,
3997
4650
  ruleId: readStringValue(step.ruleId),
3998
4651
  to,
@@ -4372,6 +5025,22 @@ function readDecisionPolicySnapshot(task) {
4372
5025
  }
4373
5026
  return { type: 'SINGLE' };
4374
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
+ ];
4375
5044
  function shouldCompleteTaskAfterDecision({ action, candidates, decisionPolicy, }) {
4376
5045
  if (action !== workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED) {
4377
5046
  return true;
@@ -4381,7 +5050,11 @@ function shouldCompleteTaskAfterDecision({ action, candidates, decisionPolicy, }
4381
5050
  return true;
4382
5051
  }
4383
5052
  const completedCount = candidates.filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.COMPLETED).length;
4384
- const totalCount = Math.max(candidates.length, 1);
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);
4385
5058
  if (decisionPolicy.type === 'PARALLEL_ALL' ||
4386
5059
  decisionPolicy.type === 'SEQUENTIAL') {
4387
5060
  return completedCount >= totalCount;
@@ -4612,6 +5285,9 @@ function readInstanceFinalStateLabel(finalState) {
4612
5285
  if (finalState === workflow_engine_enums_1.ApprovalInstanceStateEnum.REJECTED) {
4613
5286
  return '拒絕';
4614
5287
  }
5288
+ if (finalState === workflow_engine_enums_1.ApprovalInstanceStateEnum.EXPIRED) {
5289
+ return '逾期終止';
5290
+ }
4615
5291
  return '取消';
4616
5292
  }
4617
5293
  function readManagerMemberIdFromInitiatorSnapshot(initiatorMetadataSnapshot, levelsUp) {
@@ -5013,6 +5689,14 @@ function endSentence(message) {
5013
5689
  // has no manager, or `null` when it can: like the runtime, it must exist, name
5014
5690
  // a member, and not be the initiator unless the node allows self-approval.
5015
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
+ }
5016
5700
  if (!fallback || fallback.type !== 'DIRECT') {
5017
5701
  return 'NOT_SET';
5018
5702
  }
@@ -5025,9 +5709,15 @@ function readDryRunFallbackProblem(initiatorMemberId, fallback) {
5025
5709
  : null;
5026
5710
  }
5027
5711
  function readDryRunFallbackMemberIds(input, fallback) {
5028
- return readDryRunFallbackProblem(input.initiatorMemberId, fallback) ===
5029
- null && fallback?.type === 'DIRECT'
5030
- ? [fallback.memberId.trim()]
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()}`]
5031
5721
  : [];
5032
5722
  }
5033
5723
  function readDryRunNodeName(node) {
@@ -5071,6 +5761,12 @@ function readDryRunUnresolvedApproverMessage(resolver, nodeName, initiatorMember
5071
5761
  if (fallbackProblem === 'IS_INITIATOR') {
5072
5762
  return `簽核節點「${nodeName}」找不到主管,且改派固定人是申請人本人,此節點未允許申請人自簽`;
5073
5763
  }
5764
+ if (fallbackProblem === 'BLANK_POSITION') {
5765
+ return `簽核節點「${nodeName}」找不到主管,且改派職位未設定`;
5766
+ }
5767
+ if (fallbackProblem === 'UNSUPPORTED') {
5768
+ return `簽核節點「${nodeName}」找不到主管,且此引擎版本不支援該改派設定,請升級 BPMCore 後再試`;
5769
+ }
5074
5770
  return `簽核節點「${nodeName}」依申請人的組織找不到主管,請確認試跑時的申請人資料`;
5075
5771
  }
5076
5772
  function createDryRunStep(input) {