@rytass/bpm-core-nestjs-module 0.13.5 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +10 -0
  3. package/package.json +2 -2
  4. package/src/lib/attachment/attachment.service.js +6 -1
  5. package/src/lib/attachment/attachment.service.js.map +1 -1
  6. package/src/lib/bpm/bpm-root-options.d.ts +2 -1
  7. package/src/lib/bpm/bpm-root-options.js +7 -1
  8. package/src/lib/bpm/bpm-root-options.js.map +1 -1
  9. package/src/lib/form/form-member-value.validator.d.ts +16 -0
  10. package/src/lib/form/form-member-value.validator.js +37 -0
  11. package/src/lib/form/form-member-value.validator.js.map +1 -0
  12. package/src/lib/form/form-schema.validator.js +90 -1
  13. package/src/lib/form/form-schema.validator.js.map +1 -1
  14. package/src/lib/migrations/0000000025000-task-candidate-sequence.d.ts +21 -0
  15. package/src/lib/migrations/0000000025000-task-candidate-sequence.js +50 -0
  16. package/src/lib/migrations/0000000025000-task-candidate-sequence.js.map +1 -0
  17. package/src/lib/migrations/0000000026000-approval-instance-business-key.d.ts +20 -0
  18. package/src/lib/migrations/0000000026000-approval-instance-business-key.js +44 -0
  19. package/src/lib/migrations/0000000026000-approval-instance-business-key.js.map +1 -0
  20. package/src/lib/migrations/0000000027000-approval-instance-submitted-by.d.ts +19 -0
  21. package/src/lib/migrations/0000000027000-approval-instance-submitted-by.js +42 -0
  22. package/src/lib/migrations/0000000027000-approval-instance-submitted-by.js.map +1 -0
  23. package/src/lib/migrations/index.d.ts +3 -0
  24. package/src/lib/migrations/index.js +9 -0
  25. package/src/lib/migrations/index.js.map +1 -1
  26. package/src/lib/notification/notification-template.js +1 -1
  27. package/src/lib/notification/notification-template.js.map +1 -1
  28. package/src/lib/notification/notification.service.d.ts +16 -0
  29. package/src/lib/notification/notification.service.js +56 -2
  30. package/src/lib/notification/notification.service.js.map +1 -1
  31. package/src/lib/organization/index.d.ts +1 -0
  32. package/src/lib/organization/index.js +1 -0
  33. package/src/lib/organization/index.js.map +1 -1
  34. package/src/lib/organization/organization-time-zone.d.ts +46 -0
  35. package/src/lib/organization/organization-time-zone.js +47 -0
  36. package/src/lib/organization/organization-time-zone.js.map +1 -0
  37. package/src/lib/organization/organization.service.d.ts +3 -1
  38. package/src/lib/organization/organization.service.js +8 -11
  39. package/src/lib/organization/organization.service.js.map +1 -1
  40. package/src/lib/template/template.service.js +64 -0
  41. package/src/lib/template/template.service.js.map +1 -1
  42. package/src/lib/template/workflow-definition.validator.js +26 -1
  43. package/src/lib/template/workflow-definition.validator.js.map +1 -1
  44. package/src/lib/workflow-engine/approval-instance.entity.d.ts +13 -0
  45. package/src/lib/workflow-engine/approval-instance.entity.js +20 -0
  46. package/src/lib/workflow-engine/approval-instance.entity.js.map +1 -1
  47. package/src/lib/workflow-engine/instance-submitter.d.ts +2 -0
  48. package/src/lib/workflow-engine/instance-submitter.js +14 -0
  49. package/src/lib/workflow-engine/instance-submitter.js.map +1 -0
  50. package/src/lib/workflow-engine/task-candidate.entity.d.ts +2 -0
  51. package/src/lib/workflow-engine/task-candidate.entity.js +5 -0
  52. package/src/lib/workflow-engine/task-candidate.entity.js.map +1 -1
  53. package/src/lib/workflow-engine/task.entity.d.ts +20 -0
  54. package/src/lib/workflow-engine/task.entity.js +28 -0
  55. package/src/lib/workflow-engine/task.entity.js.map +1 -1
  56. package/src/lib/workflow-engine/workflow-engine.enums.d.ts +5 -1
  57. package/src/lib/workflow-engine/workflow-engine.enums.js +4 -0
  58. package/src/lib/workflow-engine/workflow-engine.enums.js.map +1 -1
  59. package/src/lib/workflow-engine/workflow-engine.mutations.js +5 -0
  60. package/src/lib/workflow-engine/workflow-engine.mutations.js.map +1 -1
  61. package/src/lib/workflow-engine/workflow-engine.service.d.ts +130 -4
  62. package/src/lib/workflow-engine/workflow-engine.service.js +1284 -179
  63. package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.WorkflowEngineService = exports.MANUAL_TRANSFER_DELEGATION_REASON = void 0;
3
+ exports.WorkflowEngineService = exports.MANUAL_TRANSFER_DELEGATION_REASON = exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH = void 0;
4
4
  const tslib_1 = require("tslib");
5
+ const class_validator_1 = require("class-validator");
5
6
  const form_1 = require("@rytass/bpm-core-shared/form");
6
7
  const workflow_graph_1 = require("@rytass/bpm-core-shared/workflow-graph");
7
8
  const workflow_webhook_delivery_service_1 = require("../workflow-webhook/workflow-webhook-delivery.service");
@@ -13,6 +14,7 @@ const delegation_service_1 = require("../delegation/delegation.service");
13
14
  const form_definition_version_entity_1 = require("../form/form-definition-version.entity");
14
15
  const form_enums_1 = require("../form/form.enums");
15
16
  const form_data_source_errors_1 = require("../form-data-source/form-data-source.errors");
17
+ const form_member_value_validator_1 = require("../form/form-member-value.validator");
16
18
  const form_data_source_types_1 = require("../form-data-source/form-data-source.types");
17
19
  const sla_schedule_service_1 = require("../calendar/sla-schedule.service");
18
20
  const notification_service_1 = require("../notification/notification.service");
@@ -23,7 +25,9 @@ const condition_service_1 = require("../condition/condition.service");
23
25
  const manager_resolution_entity_1 = require("../organization/manager-resolution.entity");
24
26
  const membership_entity_1 = require("../organization/membership.entity");
25
27
  const org_unit_entity_1 = require("../organization/org-unit.entity");
28
+ const position_entity_1 = require("../organization/position.entity");
26
29
  const organization_enums_1 = require("../organization/organization.enums");
30
+ const organization_time_zone_1 = require("../organization/organization-time-zone");
27
31
  const approval_template_version_entity_1 = require("../template/approval-template-version.entity");
28
32
  const approval_template_entity_1 = require("../template/approval-template.entity");
29
33
  const template_enums_1 = require("../template/template.enums");
@@ -31,6 +35,7 @@ const activity_log_entity_1 = require("./activity-log.entity");
31
35
  const adhoc_directive_entity_1 = require("./adhoc-directive.entity");
32
36
  const adhoc_enums_1 = require("./adhoc.enums");
33
37
  const approval_instance_entity_1 = require("./approval-instance.entity");
38
+ const instance_submitter_1 = require("./instance-submitter");
34
39
  const task_decision_entity_1 = require("./task-decision.entity");
35
40
  const task_candidate_entity_1 = require("./task-candidate.entity");
36
41
  const task_entity_1 = require("./task.entity");
@@ -41,6 +46,10 @@ const workflow_service_task_dispatcher_token_1 = require("./workflow-service-tas
41
46
  const workflow_token_entity_1 = require("./workflow-token.entity");
42
47
  const bpm_root_options_1 = require("../bpm/bpm-root-options");
43
48
  const MAX_PROCESSING_STEPS = 500;
49
+ exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH = 200;
50
+ const APPROVAL_INSTANCE_BUSINESS_KEY_INDEX = 'approval_instances_business_key_key';
51
+ // Guards ORG_MANAGER_CHAIN against misconfigured manager rules that never end.
52
+ const ORG_MANAGER_CHAIN_MAX_HOPS = 20;
44
53
  const DEFAULT_APPROVAL_HISTORY_TASK_LIMIT = 50;
45
54
  const SYSTEM_DRY_RUN_INSTANCE_ID = 'dry-run-instance';
46
55
  /**
@@ -89,9 +98,27 @@ let WorkflowEngineService = class WorkflowEngineService {
89
98
  injectedServiceTaskDispatcher ??
90
99
  rootOptions?.workflowServiceTaskDispatcher ??
91
100
  new workflow_service_task_dispatcher_token_1.DefaultWorkflowServiceTaskDispatcher();
101
+ this.organizationTimeZone = (0, organization_time_zone_1.resolveBPMOrganizationTimeZone)(rootOptions?.organizationTimeZone);
102
+ }
103
+ /**
104
+ * Today's date as the organization sees it (`organizationTimeZone`), for
105
+ * comparing against the date-only `effectiveFrom` / `effectiveTo` of
106
+ * memberships and manager resolutions. Not for timestamp columns, which are
107
+ * compared as instants.
108
+ */
109
+ readOrganizationToday() {
110
+ return (0, organization_time_zone_1.formatDateOnlyInTimeZone)(new Date(), this.organizationTimeZone);
92
111
  }
93
112
  async submitApprovalInstance(input, authContext) {
113
+ if (input.submittedByMemberId !== undefined &&
114
+ input.submittedByMemberId !== null &&
115
+ (typeof input.submittedByMemberId !== 'string' ||
116
+ input.submittedByMemberId.trim() === '')) {
117
+ throw new common_1.BadRequestException('submittedByMemberId must be a non-empty string');
118
+ }
94
119
  const formData = parseJsonObject(input.formDataJson, 'formDataJson');
120
+ const businessKey = normalizeBusinessKey(input.businessKey);
121
+ const submittedByMemberId = input.submittedByMemberId ?? input.initiatorMemberId;
95
122
  const template = await this.getTemplateOrThrow(input.templateId);
96
123
  if (!template.isActive) {
97
124
  throw new common_1.ConflictException('Approval template is deactivated');
@@ -114,6 +141,12 @@ let WorkflowEngineService = class WorkflowEngineService {
114
141
  if (!this.conditionService.evaluateBoolean(templateVersion.initiatorPolicyCel, buildInitiatorPolicyContext(initiatorMetadataSnapshot), 'initiatorPolicyCel')) {
115
142
  throw new common_1.ConflictException('Current member is not allowed to start this approval template');
116
143
  }
144
+ // Named here, before the transaction, so the completion notice that later
145
+ // says "由 X 代為送出" reads it from the started activity instead of
146
+ // asking the identity source while an approval holds the instance's locks.
147
+ const submittedByName = submittedByMemberId === input.initiatorMemberId
148
+ ? null
149
+ : await this.notificationService.readMemberDisplayName(submittedByMemberId);
117
150
  const startedAt = new Date();
118
151
  const startNode = templateVersion.workflowDefinition.nodes.find((node) => node.type === 'startEvent');
119
152
  if (!startNode) {
@@ -133,10 +166,12 @@ let WorkflowEngineService = class WorkflowEngineService {
133
166
  uiSchema: formDefinitionVersion.uiSchema,
134
167
  version: formDefinitionVersion.version,
135
168
  },
169
+ businessKey,
136
170
  initiatorMemberId: input.initiatorMemberId,
137
171
  initiatorMetadataSnapshot,
138
172
  startedAt,
139
173
  state: workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING,
174
+ submittedByMemberId,
140
175
  templateId: template.id,
141
176
  templateVersionId: templateVersion.id,
142
177
  title: input.title?.trim() || template.name,
@@ -160,6 +195,8 @@ let WorkflowEngineService = class WorkflowEngineService {
160
195
  instanceId: instance.id,
161
196
  nodeId: startNode.id,
162
197
  payload: {
198
+ submittedByMemberId,
199
+ ...(submittedByName === null ? {} : { submittedByName }),
163
200
  templateId: template.id,
164
201
  templateVersionId: templateVersion.id,
165
202
  },
@@ -178,6 +215,28 @@ let WorkflowEngineService = class WorkflowEngineService {
178
215
  ]);
179
216
  await this.processRunningInstance(manager, instance);
180
217
  return instance;
218
+ }).catch((error) =>
219
+ // The insert decides, not a pre-check: two concurrent submits would both
220
+ // pass a check, and only the unique index sees them as one.
221
+ throwBusinessKeyConflictOrRethrow(error, businessKey));
222
+ }
223
+ /**
224
+ * The instance a host submitted for `businessKey` on `templateId`, whatever
225
+ * its state (a cancelled or rejected instance still holds its key), or
226
+ * `null`. The key is trimmed the way submit trims it, so a lookup finds what
227
+ * a submit stored.
228
+ */
229
+ async findApprovalInstanceByBusinessKey(templateId, businessKey) {
230
+ const normalizedKey = businessKey.trim();
231
+ // A template id that is not a uuid cannot match a row, and handing it to
232
+ // Postgres would raise 22P02 (a 500) instead of "not found".
233
+ if (!(0, class_validator_1.isUUID)(templateId) ||
234
+ normalizedKey === '' ||
235
+ countCodePoints(normalizedKey) > exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH) {
236
+ return null;
237
+ }
238
+ return this.approvalInstanceRepository.findOne({
239
+ where: { businessKey: normalizedKey, templateId },
181
240
  });
182
241
  }
183
242
  async resolveFormDataOptionSnapshots({ authContext, formData, previousFormData, previousSnapshots, revalidateAll, schema, }) {
@@ -313,6 +372,35 @@ let WorkflowEngineService = class WorkflowEngineService {
313
372
  taskNode.data.allowTransfer === false) {
314
373
  throw new common_1.ForbiddenException(`Workflow node ${task.nodeId} does not allow transfer`);
315
374
  }
375
+ // Only guards a human picking the target; SLA escalation passes a
376
+ // `transferReason` and is deliberately left alone.
377
+ if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED &&
378
+ options.transferReason === undefined &&
379
+ isInitiatorExcluded(taskNode) &&
380
+ transferToMemberId === instance.initiatorMemberId) {
381
+ throw new common_1.ConflictException(`簽核節點「${taskNode.data.label}」不允許申請人自簽,不能將任務轉交給申請人本人`);
382
+ }
383
+ if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED &&
384
+ options.transferReason === undefined &&
385
+ isInitiatorExcluded(taskNode) &&
386
+ transferToMemberId !== null &&
387
+ transferToMemberId === (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance)) {
388
+ throw new common_1.ConflictException(`簽核節點「${taskNode.data.label}」不允許代送人簽核,不能將任務轉交給代為送出此單的人`);
389
+ }
390
+ // Same carve-out: a human picking the target on a manager-chain node
391
+ // must pick someone at least as senior as the member being replaced.
392
+ if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED &&
393
+ options.transferReason === undefined &&
394
+ transferToMemberId !== null &&
395
+ !task.isAdhoc &&
396
+ isOrgManagerChainNode(taskNode)) {
397
+ await this.assertTransferTargetGrade(manager, {
398
+ actorMemberId: input.decidedByMemberId,
399
+ delegationChain: actorCandidate?.delegationChain ?? task.delegationChain,
400
+ label: taskNode.data.label,
401
+ targetMemberId: transferToMemberId,
402
+ });
403
+ }
316
404
  const returnToNodeId = input.action === workflow_engine_enums_1.TaskDecisionActionEnum.RETURNED
317
405
  ? readReturnTargetNodeId(instance.workflowSnapshot, task.nodeId, input.returnToNodeId)
318
406
  : null;
@@ -422,6 +510,17 @@ let WorkflowEngineService = class WorkflowEngineService {
422
510
  supersedeOthers: taskEnded,
423
511
  taskId: completedTask.id,
424
512
  });
513
+ if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED &&
514
+ !shouldCompleteTask) {
515
+ await this.activateNextSequentialCandidate({
516
+ candidates: nextCandidateRows,
517
+ instance,
518
+ manager,
519
+ node: taskNode,
520
+ previousMemberId: input.decidedByMemberId,
521
+ task: completedTask,
522
+ });
523
+ }
425
524
  if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED &&
426
525
  shouldCompleteTask) {
427
526
  // Ad-hoc gate: the token may only advance once every task bound to
@@ -592,6 +691,13 @@ let WorkflowEngineService = class WorkflowEngineService {
592
691
  const targetValue = buildAdhocTargetValue(target);
593
692
  // Rejects WEBHOOK targets — countersigners must be members.
594
693
  buildAdhocApproverResolver(targetValue);
694
+ // The countersign task is only created when a later user task is
695
+ // reached, against that node's own setting. This checks the target
696
+ // against the node being decided now, as an early rejection for the
697
+ // common case; it is not a guarantee about the node it attaches to.
698
+ if (isInitiatorExcluded(node)) {
699
+ await this.resolveAdhocTaskCandidates(manager, instance, node, '臨時會簽', targetValue);
700
+ }
595
701
  const directiveRepository = manager.getRepository(adhoc_directive_entity_1.AdhocDirectiveEntity);
596
702
  const directive = await directiveRepository.save(directiveRepository.create({
597
703
  channels: null,
@@ -707,6 +813,9 @@ let WorkflowEngineService = class WorkflowEngineService {
707
813
  if (!isDirectAssignee && !actorCandidate) {
708
814
  throw new common_1.ConflictException(`Task ${task.id} is assigned to another member`);
709
815
  }
816
+ if (actorCandidate?.status === workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING) {
817
+ throw new common_1.ConflictException(`Task ${task.id} is not this member's turn yet`);
818
+ }
710
819
  if (actorCandidate &&
711
820
  actorCandidate.status !== workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING &&
712
821
  actorCandidate.status !== workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED) {
@@ -771,11 +880,7 @@ let WorkflowEngineService = class WorkflowEngineService {
771
880
  const label = directive.type === adhoc_enums_1.AdhocDirectiveTypeEnum.COUNTERSIGN
772
881
  ? '臨時會簽'
773
882
  : '臨時加簽';
774
- const resolvedCandidates = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(readAdhocTargetValue(directive)), `${label}「${node.data.label}」`);
775
- const candidates = await this.applyDelegationToResolvedCandidates(instance, node.id, resolvedCandidates);
776
- if (candidates.length === 0) {
777
- throw new common_1.ConflictException(`${label}「${node.data.label}」 did not resolve to any member id`);
778
- }
883
+ const candidates = await this.resolveAdhocTaskCandidates(manager, instance, node, label, readAdhocTargetValue(directive));
779
884
  const primaryCandidate = candidates[0];
780
885
  const task = await taskRepository.save(taskRepository.create({
781
886
  adhocDirectiveId: directive.id,
@@ -821,6 +926,8 @@ let WorkflowEngineService = class WorkflowEngineService {
821
926
  taskId: task.id,
822
927
  })));
823
928
  task.candidateMemberIds = savedCandidates.map((candidate) => candidate.memberId);
929
+ task.openCandidateMemberIds = task.candidateMemberIds;
930
+ task.nodeLabel = readWorkflowNodeLabel(instance.workflowSnapshot, node.id);
824
931
  await activityRepository.save(activityRepository.create({
825
932
  actorMemberId: directive.createdByMemberId,
826
933
  eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_CREATED,
@@ -852,6 +959,21 @@ let WorkflowEngineService = class WorkflowEngineService {
852
959
  }, Promise.resolve());
853
960
  return task;
854
961
  }
962
+ async resolveAdhocTaskCandidates(manager, instance, node, label, targetValue) {
963
+ const resolvedCandidates = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), `${label}「${node.data.label}」`, '簽核人', isInitiatorExcluded(node) ? readInitiatorAndSubmitter(instance) : []);
964
+ // Ad-hoc approvers are picked by a person, not walked up a manager chain,
965
+ // so the chain's grade rule does not apply to them.
966
+ const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, false);
967
+ const candidates = selectNodeCandidates(delegatedCandidates, isInitiatorExcluded(node), readInitiatorAndSubmitter(instance));
968
+ if (candidates.length > 0) {
969
+ return candidates;
970
+ }
971
+ throw new common_1.ConflictException(delegatedCandidates.length > 0
972
+ ? (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) === null
973
+ ? `${label}「${node.data.label}」 無法建立任務:指定對象只有申請人本人,此節點不允許申請人簽核。`
974
+ : `${label}「${node.data.label}」 無法建立任務:指定對象只有申請人或代為送出此單的人,此節點不允許申請人或代送人簽核。`
975
+ : `${label}「${node.data.label}」 did not resolve to any member id`);
976
+ }
855
977
  async spawnCountersignTasksForNode(manager, instance, token, node, now) {
856
978
  const directiveRepository = manager.getRepository(adhoc_directive_entity_1.AdhocDirectiveEntity);
857
979
  const directives = await directiveRepository.find({
@@ -915,9 +1037,18 @@ let WorkflowEngineService = class WorkflowEngineService {
915
1037
  const taskRepository = manager.getRepository(task_entity_1.TaskEntity);
916
1038
  const taskCandidateRepository = manager.getRepository(task_candidate_entity_1.TaskCandidateEntity);
917
1039
  const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
918
- const originTask = task.adhocOriginTaskId
1040
+ const adhocOriginTask = task.adhocOriginTaskId
919
1041
  ? await taskRepository.findOne({ where: { id: task.adhocOriginTaskId } })
920
1042
  : null;
1043
+ // transferTask re-points an open pre-approval when its origin is
1044
+ // transferred, but a pre-approval whose origin was transferred by a release
1045
+ // that did not do that yet still points at the TRANSFERRED row, and so does
1046
+ // the pointer on `task` when a transfer commits after decideTask read it
1047
+ // and before it took the instance lock. Following the transfer here hands
1048
+ // the decision to whoever holds the stage now in both cases.
1049
+ const originTask = adhocOriginTask
1050
+ ? await this.readTransferSuccessorTask(manager, adhocOriginTask)
1051
+ : null;
921
1052
  const originTaskIsOpen = originTask?.status === workflow_engine_enums_1.TaskStatusEnum.PENDING ||
922
1053
  originTask?.status === workflow_engine_enums_1.TaskStatusEnum.IN_PROGRESS;
923
1054
  // Safety: when there is nobody to hand the decision back to (origin task
@@ -927,21 +1058,43 @@ let WorkflowEngineService = class WorkflowEngineService {
927
1058
  if (!originTask || (!originTaskIsOpen && !originTask.assigneeMemberId)) {
928
1059
  return false;
929
1060
  }
1061
+ const originCandidates = readDecisionPolicySnapshot(originTask).type === 'SEQUENTIAL'
1062
+ ? await taskCandidateRepository.find({
1063
+ where: { taskId: originTask.id },
1064
+ })
1065
+ : [];
1066
+ const isSequentialOrigin = isOrderedSequentialTask(originTask, originCandidates);
930
1067
  const node = readWorkflowNodeOrThrow(instance.workflowSnapshot, task.nodeId);
931
1068
  if (!originTaskIsOpen && originTask.assigneeMemberId) {
932
1069
  // The origin approver already decided — reopen a fresh decision task so
933
- // the stage can be re-decided after the rejected pre-approval.
1070
+ // the stage can be re-decided after the rejected pre-approval. A
1071
+ // SEQUENTIAL task's assignee rotates and ends on the last signer, so it
1072
+ // goes back to whoever asked for the pre-approval instead.
1073
+ const requesterCandidate = isSequentialOrigin
1074
+ ? await this.readSequentialRequesterCandidate(manager, originTask, originCandidates, directive.createdByMemberId)
1075
+ : undefined;
1076
+ // A sequential origin always reopens for the requester, even when the
1077
+ // successor of a transferred task has no row for them.
1078
+ const reopenedAssigneeMemberId = isSequentialOrigin
1079
+ ? directive.createdByMemberId
1080
+ : originTask.assigneeMemberId;
1081
+ const reopenedOriginalMemberId = isSequentialOrigin
1082
+ ? (requesterCandidate?.originalMemberId ?? directive.createdByMemberId)
1083
+ : originTask.originalAssigneeMemberId;
1084
+ const reopenedDelegationChain = isSequentialOrigin
1085
+ ? (requesterCandidate?.delegationChain ?? [])
1086
+ : originTask.delegationChain;
934
1087
  const reopenedTask = await taskRepository.save(taskRepository.create({
935
- assigneeMemberId: originTask.assigneeMemberId,
1088
+ assigneeMemberId: reopenedAssigneeMemberId,
936
1089
  assignmentType: workflow_engine_enums_1.TaskAssignmentTypeEnum.DIRECT_MEMBER,
937
1090
  completedAt: null,
938
1091
  createdAt: decidedAt,
939
1092
  decisionPolicySnapshot: { type: 'SINGLE' },
940
- delegationChain: originTask.delegationChain,
1093
+ delegationChain: reopenedDelegationChain,
941
1094
  instanceId: instance.id,
942
1095
  nodeId: originTask.nodeId,
943
1096
  openedAt: null,
944
- originalAssigneeMemberId: originTask.originalAssigneeMemberId,
1097
+ originalAssigneeMemberId: reopenedOriginalMemberId,
945
1098
  slaDueAt: null,
946
1099
  status: workflow_engine_enums_1.TaskStatusEnum.PENDING,
947
1100
  tokenId: originTask.tokenId,
@@ -950,9 +1103,9 @@ let WorkflowEngineService = class WorkflowEngineService {
950
1103
  claimedAt: null,
951
1104
  createdAt: decidedAt,
952
1105
  decidedAt: null,
953
- delegationChain: originTask.delegationChain,
954
- memberId: originTask.assigneeMemberId,
955
- originalMemberId: originTask.originalAssigneeMemberId ?? originTask.assigneeMemberId,
1106
+ delegationChain: reopenedDelegationChain,
1107
+ memberId: reopenedAssigneeMemberId,
1108
+ originalMemberId: reopenedOriginalMemberId ?? reopenedAssigneeMemberId,
956
1109
  sourceType: 'DIRECT',
957
1110
  status: workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING,
958
1111
  taskId: reopenedTask.id,
@@ -964,7 +1117,7 @@ let WorkflowEngineService = class WorkflowEngineService {
964
1117
  nodeId: originTask.nodeId,
965
1118
  payload: {
966
1119
  adhocDirectiveId: directive.id,
967
- assigneeMemberId: originTask.assigneeMemberId,
1120
+ assigneeMemberId: reopenedAssigneeMemberId,
968
1121
  reopenedFromTaskId: originTask.id,
969
1122
  rejectedAdhocTaskId: task.id,
970
1123
  tokenId: originTask.tokenId,
@@ -980,7 +1133,14 @@ let WorkflowEngineService = class WorkflowEngineService {
980
1133
  });
981
1134
  }
982
1135
  }
983
- const recipientMemberIds = await this.readOpenTaskRecipientMemberIds(manager, originTask);
1136
+ // On a SEQUENTIAL task the requester has already signed and the task has
1137
+ // moved on to the next signer, so they are told as well. On any other task
1138
+ // the requester is the origin holder (or handed the stage away by a
1139
+ // transfer) and only whoever holds the task now is told.
1140
+ const recipientMemberIds = uniqueTexts([
1141
+ ...(isSequentialOrigin ? [directive.createdByMemberId] : []),
1142
+ ...(await this.readOpenTaskRecipientMemberIds(manager, originTask)),
1143
+ ]);
984
1144
  if (recipientMemberIds.length > 0) {
985
1145
  await this.notificationService.createAdhocWorkflowNotifications({
986
1146
  channels: null,
@@ -1012,6 +1172,89 @@ let WorkflowEngineService = class WorkflowEngineService {
1012
1172
  }));
1013
1173
  return true;
1014
1174
  }
1175
+ /**
1176
+ * Follows a TRANSFERRED task to the task that replaced it, repeatedly, and
1177
+ * returns the last one reached. A transfer leaves no pointer on either task
1178
+ * row; the link is the `transferredFromTaskId` in the payload of the
1179
+ * TASK_CREATED activity written for the replacement. When the trail stops
1180
+ * (no such activity, or its task is gone) the last task reached is returned,
1181
+ * so a caller sees the transferred row exactly as it did before.
1182
+ */
1183
+ async readTransferSuccessorTask(manager, task) {
1184
+ if (task.status !== workflow_engine_enums_1.TaskStatusEnum.TRANSFERRED) {
1185
+ return task;
1186
+ }
1187
+ const taskRepository = manager.getRepository(task_entity_1.TaskEntity);
1188
+ const taskCreatedActivities = await manager
1189
+ .getRepository(activity_log_entity_1.ActivityLogEntity)
1190
+ .find({
1191
+ where: {
1192
+ eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_CREATED,
1193
+ instanceId: task.instanceId,
1194
+ nodeId: task.nodeId,
1195
+ },
1196
+ });
1197
+ const successorTaskIdByTransferredTaskId = new Map(taskCreatedActivities.flatMap((activity) => {
1198
+ const transferredFromTaskId = activity.payload['transferredFromTaskId'];
1199
+ return typeof transferredFromTaskId === 'string' && activity.taskId
1200
+ ? [[transferredFromTaskId, activity.taskId]]
1201
+ : [];
1202
+ }));
1203
+ const followTransfer = async (currentTask, visitedTaskIds) => {
1204
+ const successorTaskId = currentTask.status === workflow_engine_enums_1.TaskStatusEnum.TRANSFERRED
1205
+ ? successorTaskIdByTransferredTaskId.get(currentTask.id)
1206
+ : undefined;
1207
+ if (!successorTaskId || visitedTaskIds.has(successorTaskId)) {
1208
+ return currentTask;
1209
+ }
1210
+ const successorTask = await taskRepository.findOne({
1211
+ where: { id: successorTaskId },
1212
+ });
1213
+ return successorTask
1214
+ ? followTransfer(successorTask, new Set([...visitedTaskIds, successorTaskId]))
1215
+ : currentTask;
1216
+ };
1217
+ return followTransfer(task, new Set([task.id]));
1218
+ }
1219
+ /**
1220
+ * The requester's candidate row on a SEQUENTIAL origin task. A task that was
1221
+ * transferred mid-order continues as a replacement that carries only the
1222
+ * candidates still to sign, so a requester who already signed is found on an
1223
+ * earlier task of the transfer chain instead.
1224
+ */
1225
+ async readSequentialRequesterCandidate(manager, originTask, originCandidates, requesterMemberId) {
1226
+ const ownCandidate = originCandidates.find((candidate) => candidate.memberId === requesterMemberId);
1227
+ if (ownCandidate) {
1228
+ return ownCandidate;
1229
+ }
1230
+ const taskCreatedActivities = await manager
1231
+ .getRepository(activity_log_entity_1.ActivityLogEntity)
1232
+ .find({
1233
+ where: {
1234
+ eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_CREATED,
1235
+ instanceId: originTask.instanceId,
1236
+ nodeId: originTask.nodeId,
1237
+ },
1238
+ });
1239
+ const transferredFromTaskIdByTaskId = new Map(taskCreatedActivities.flatMap((activity) => {
1240
+ const transferredFromTaskId = activity.payload['transferredFromTaskId'];
1241
+ return typeof transferredFromTaskId === 'string' && activity.taskId
1242
+ ? [[activity.taskId, transferredFromTaskId]]
1243
+ : [];
1244
+ }));
1245
+ const searchEarlierTasks = async (currentTaskId, visitedTaskIds) => {
1246
+ const earlierTaskId = transferredFromTaskIdByTaskId.get(currentTaskId);
1247
+ if (!earlierTaskId || visitedTaskIds.has(earlierTaskId)) {
1248
+ return undefined;
1249
+ }
1250
+ const earlierCandidate = (await manager
1251
+ .getRepository(task_candidate_entity_1.TaskCandidateEntity)
1252
+ .find({ where: { taskId: earlierTaskId } })).find((candidate) => candidate.memberId === requesterMemberId);
1253
+ return (earlierCandidate ??
1254
+ searchEarlierTasks(earlierTaskId, new Set([...visitedTaskIds, earlierTaskId])));
1255
+ };
1256
+ return searchEarlierTasks(originTask.id, new Set([originTask.id]));
1257
+ }
1015
1258
  async readOpenTaskRecipientMemberIds(manager, task) {
1016
1259
  if (task.assigneeMemberId) {
1017
1260
  return [task.assigneeMemberId];
@@ -1116,7 +1359,7 @@ let WorkflowEngineService = class WorkflowEngineService {
1116
1359
  return;
1117
1360
  }
1118
1361
  try {
1119
- const recipients = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), '臨時通知對象');
1362
+ const recipients = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), '臨時通知對象', '通知對象');
1120
1363
  await this.notificationService.createAdhocWorkflowNotifications({
1121
1364
  channels: directive.channels,
1122
1365
  instance,
@@ -1229,16 +1472,28 @@ let WorkflowEngineService = class WorkflowEngineService {
1229
1472
  }
1230
1473
  const nextVisitedNodeIds = new Set([...input.visitedNodeIds, visitKey]);
1231
1474
  const entryConditionMet = this.conditionService.evaluateBoolean(entryCondition, buildDryRunExpressionContext(input, input.lastDecision), `workflow.nodes.${node.id}.data.entryCondition`);
1232
- const assigneeMemberId = entryConditionMet && node.type === 'userTask'
1233
- ? this.resolveDryRunAssigneeMemberId(input, node.data.approverResolver, `workflow.nodes.${node.id}.data.approverResolver`)
1475
+ const assignment = entryConditionMet && node.type === 'userTask'
1476
+ ? this.resolveDryRunAssignment(input, node)
1234
1477
  : null;
1235
- const nextLastDecision = entryConditionMet && node.type === 'userTask'
1478
+ const assigneeMemberId = assignment?.assigneeMemberId ?? null;
1479
+ const skippedByExclusion = assignment?.outcome === 'SKIPPED';
1480
+ const stoppedByAssignment = assignment?.outcome === 'STOPPED';
1481
+ const nextLastDecision = entryConditionMet && node.type === 'userTask' && !skippedByExclusion
1236
1482
  ? {
1237
1483
  action: workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED,
1238
1484
  assigneeMemberId,
1239
1485
  nodeId: node.id,
1240
1486
  }
1241
1487
  : input.lastDecision;
1488
+ // A node that stops on its assignment, an asynchronous NOTIFY node (at
1489
+ // runtime it consumes its token and never advances) and an end event do
1490
+ // not route any further.
1491
+ const routes = !stoppedByAssignment &&
1492
+ !(0, workflow_graph_1.isAsyncNotifyServiceTask)(node) &&
1493
+ node.type !== 'endEvent';
1494
+ const outgoing = routes
1495
+ ? this.readDryRunOutgoing(input, node, nextLastDecision, entryConditionMet && !skippedByExclusion)
1496
+ : { edges: [], stopMessage: null };
1242
1497
  const nextSteps = [
1243
1498
  ...input.steps,
1244
1499
  createDryRunStep({
@@ -1248,34 +1503,16 @@ let WorkflowEngineService = class WorkflowEngineService {
1248
1503
  edgeReason: readDryRunEdgeReason(input.incomingEdge),
1249
1504
  entryCondition,
1250
1505
  entryConditionMet,
1251
- message: readDryRunStepMessage(node, entryConditionMet),
1506
+ message: readDryRunStepResultMessage(assignment?.message ?? null, outgoing.stopMessage, readDryRunStepMessage(node, entryConditionMet), !entryConditionMet || skippedByExclusion),
1252
1507
  node,
1253
- status: readDryRunStepStatus(node, entryConditionMet),
1508
+ status: outgoing.stopMessage === null
1509
+ ? (assignment?.outcome ??
1510
+ readDryRunStepStatus(node, entryConditionMet))
1511
+ : 'STOPPED',
1254
1512
  stepIndex: input.steps.length,
1255
1513
  }),
1256
1514
  ];
1257
- // A NOTIFY node is an asynchronous branch: at runtime it consumes its
1258
- // token and never advances, so its branch ends here rather than failing
1259
- // for want of an outgoing edge it is not allowed to have.
1260
- if ((0, workflow_graph_1.isAsyncNotifyServiceTask)(node)) {
1261
- return nextSteps;
1262
- }
1263
- if (!entryConditionMet || node.type === 'endEvent') {
1264
- if (node.type === 'endEvent') {
1265
- return nextSteps;
1266
- }
1267
- return readOutgoingEdgesOrThrow(input.workflowDefinition, node.id).reduce((steps, edge) => this.simulateWorkflowPath({
1268
- ...input,
1269
- depth: input.depth + 1,
1270
- incomingEdge: edge,
1271
- lastDecision: nextLastDecision,
1272
- nodeId: edge.target,
1273
- steps,
1274
- visitedNodeIds: nextVisitedNodeIds,
1275
- }), nextSteps);
1276
- }
1277
- const outgoingEdges = this.readDryRunAvailableOutgoingEdges(input, node, nextLastDecision);
1278
- return outgoingEdges.reduce((steps, edge) => this.simulateWorkflowPath({
1515
+ return outgoing.edges.reduce((steps, edge) => this.simulateWorkflowPath({
1279
1516
  ...input,
1280
1517
  depth: input.depth + 1,
1281
1518
  incomingEdge: edge,
@@ -1285,28 +1522,45 @@ let WorkflowEngineService = class WorkflowEngineService {
1285
1522
  visitedNodeIds: nextVisitedNodeIds,
1286
1523
  }), nextSteps);
1287
1524
  }
1288
- readDryRunAvailableOutgoingEdges(input, node, lastDecision) {
1289
- if (node.type === 'endEvent') {
1290
- return [];
1525
+ /**
1526
+ * The edges a dry-run step continues along. A node a designer has left
1527
+ * unfinished (nothing drawn out of it, or no branch of a gateway taken) ends
1528
+ * its branch with a readable message instead of failing the whole dry run.
1529
+ */
1530
+ readDryRunOutgoing(input, node, lastDecision, evaluatesConditions) {
1531
+ const nodeName = readDryRunNodeName(node);
1532
+ const outgoingEdges = input.workflowDefinition.edges.filter((edge) => edge.source === node.id);
1533
+ if (outgoingEdges.length === 0) {
1534
+ return {
1535
+ edges: [],
1536
+ stopMessage: `節點「${nodeName}」沒有連到下一個節點,請在流程設計器為此節點接上連線`,
1537
+ };
1538
+ }
1539
+ if (!evaluatesConditions) {
1540
+ return { edges: outgoingEdges, stopMessage: null };
1291
1541
  }
1292
- const outgoingEdges = readOutgoingEdgesOrThrow(input.workflowDefinition, node.id);
1293
1542
  const conditionalEdges = outgoingEdges.filter((edge) => !edge.data.isDefault && edgeHasCondition(edge));
1294
1543
  if (conditionalEdges.length === 0) {
1295
- return outgoingEdges;
1544
+ return { edges: outgoingEdges, stopMessage: null };
1296
1545
  }
1297
1546
  const matchingEdges = conditionalEdges.filter((edge) => this.evaluateDryRunEdge(input, edge, lastDecision));
1298
1547
  if (matchingEdges.length > 0) {
1299
- return node.type === 'exclusiveGateway'
1300
- ? [matchingEdges[0]]
1301
- : matchingEdges;
1548
+ return {
1549
+ edges: node.type === 'exclusiveGateway' ? [matchingEdges[0]] : matchingEdges,
1550
+ stopMessage: null,
1551
+ };
1302
1552
  }
1303
1553
  const defaultEdges = outgoingEdges.filter((edge) => edge.data.isDefault);
1304
1554
  if (defaultEdges.length > 0) {
1305
- return node.type === 'exclusiveGateway'
1306
- ? [defaultEdges[0]]
1307
- : defaultEdges;
1555
+ return {
1556
+ edges: node.type === 'exclusiveGateway' ? [defaultEdges[0]] : defaultEdges,
1557
+ stopMessage: null,
1558
+ };
1308
1559
  }
1309
- throw new common_1.ConflictException(`Workflow node ${node.id} has no matching outgoing edge`);
1560
+ return {
1561
+ edges: [],
1562
+ stopMessage: `節點「${nodeName}」的連出線條件都不符合,且沒有設定預設路徑,請檢查連出線的條件或補上預設路徑`,
1563
+ };
1310
1564
  }
1311
1565
  evaluateDryRunEdge(input, edge, lastDecision) {
1312
1566
  const expression = edge.data.condition?.trim();
@@ -1315,29 +1569,101 @@ let WorkflowEngineService = class WorkflowEngineService {
1315
1569
  }
1316
1570
  return (0, workflow_condition_evaluator_1.evaluateWorkflowEdgeCondition)(edge, { formData: input.formData });
1317
1571
  }
1318
- resolveDryRunAssigneeMemberId(input, resolver, label) {
1319
- if (resolver.type === 'DIRECT') {
1320
- return readMemberIdFromValue(resolver.memberIds, label);
1572
+ /**
1573
+ * The dry-run assignee of a user task. Only resolvers whose dry run yields
1574
+ * real member ids (DIRECT, DYNAMIC_FORM, EXPRESSION and ORG_MANAGER, which
1575
+ * reads the initiator snapshot) can be checked against the initiator; the
1576
+ * others resolve to placeholders and are left as they are.
1577
+ */
1578
+ resolveDryRunAssignment(input, node) {
1579
+ const resolver = node.data.approverResolver;
1580
+ const excludeInitiator = node.data.excludeInitiator;
1581
+ const memberIds = this.readDryRunMemberIds(input, resolver, `workflow.nodes.${node.id}.data.approverResolver`);
1582
+ if (memberIds === null) {
1583
+ return {
1584
+ assigneeMemberId: readDryRunPlaceholderAssignee(resolver),
1585
+ message: null,
1586
+ outcome: null,
1587
+ };
1321
1588
  }
1322
- if (resolver.type === 'DYNAMIC_FORM') {
1323
- return readMemberIdFromValue(readValueAtPath(input.formData, resolver.formPath), `${label}.formPath`);
1589
+ // Nobody to ask yet — typically a node whose approver has not been chosen.
1590
+ // The branch ends here and says which node needs attention, rather than
1591
+ // failing the whole dry run.
1592
+ if (memberIds.length === 0) {
1593
+ return {
1594
+ assigneeMemberId: null,
1595
+ message: readDryRunUnresolvedApproverMessage(resolver, readDryRunNodeName(node), input.initiatorMemberId),
1596
+ outcome: 'STOPPED',
1597
+ };
1324
1598
  }
1325
- if (resolver.type === 'EXPRESSION') {
1326
- return readMemberIdFromValue(this.conditionService.evaluateValue(resolver.expression, buildDryRunExpressionContext(input, null), `${label}.expression`), `${label}.expression`);
1599
+ if (!excludeInitiator || !memberIds.includes(input.initiatorMemberId)) {
1600
+ return {
1601
+ assigneeMemberId: memberIds[0],
1602
+ message: null,
1603
+ outcome: null,
1604
+ };
1327
1605
  }
1328
- if (resolver.type === 'POSITION') {
1329
- return `position:${resolver.positionId}`;
1606
+ const remainingMemberId = memberIds.find((memberId) => memberId !== input.initiatorMemberId);
1607
+ if (remainingMemberId !== undefined) {
1608
+ return {
1609
+ assigneeMemberId: remainingMemberId,
1610
+ message: null,
1611
+ outcome: null,
1612
+ };
1330
1613
  }
1331
- if (resolver.type === 'ORG_UNIT_MEMBER') {
1332
- return `orgUnitMember:${resolver.orgUnitId}`;
1614
+ if (excludeInitiator.whenEmpty === 'SKIP_NODE') {
1615
+ return {
1616
+ assigneeMemberId: null,
1617
+ message: '簽核人只有申請人本人,依設定略過節點。',
1618
+ outcome: 'SKIPPED',
1619
+ };
1333
1620
  }
1334
- if (resolver.type === 'ORG_UNIT_POSITION') {
1335
- return `orgUnitPosition:${resolver.orgUnitId}:${resolver.positionId}`;
1621
+ // Level 1 is the initiator when they head their unit; like the runtime,
1622
+ // fall through to the next level of the snapshot's manager chain.
1623
+ const managerMemberId = excludeInitiator.whenEmpty === 'INITIATOR_MANAGER'
1624
+ ? [1, 2]
1625
+ .map((levelsUp) => readManagerMemberIdFromInitiatorSnapshot(input.initiatorMetadataSnapshot, levelsUp))
1626
+ .find((memberId) => typeof memberId === 'string' &&
1627
+ memberId.trim() !== '' &&
1628
+ memberId.trim() !== input.initiatorMemberId)
1629
+ : undefined;
1630
+ if (managerMemberId !== undefined) {
1631
+ return {
1632
+ assigneeMemberId: managerMemberId.trim(),
1633
+ message: null,
1634
+ outcome: null,
1635
+ };
1336
1636
  }
1337
- if (resolver.type === 'ORG_UNIT_MANAGER') {
1338
- return `orgUnitManager:${resolver.orgUnitId}`;
1637
+ return {
1638
+ assigneeMemberId: null,
1639
+ message: excludeInitiator.whenEmpty === 'INITIATOR_MANAGER'
1640
+ ? '簽核人只有申請人本人,此節點不允許申請人自簽,且找不到申請人的主管可改派。'
1641
+ : '簽核人只有申請人本人,此節點不允許申請人自簽。',
1642
+ outcome: 'STOPPED',
1643
+ };
1644
+ }
1645
+ /**
1646
+ * The member ids a dry run resolves for a user task: an empty list when the
1647
+ * resolver names nobody, `null` for resolvers that only resolve to a
1648
+ * placeholder in a dry run.
1649
+ */
1650
+ readDryRunMemberIds(input, resolver, label) {
1651
+ if (resolver.type === 'DIRECT') {
1652
+ return findMemberIdsInValue(resolver.memberIds);
1339
1653
  }
1340
- return readMemberIdFromValue(readManagerMemberIdFromInitiatorSnapshot(input.initiatorMetadataSnapshot, resolver.levelsUp), label);
1654
+ if (resolver.type === 'DYNAMIC_FORM') {
1655
+ return findMemberIdsInValue(readValueAtPath(input.formData, resolver.formPath));
1656
+ }
1657
+ if (resolver.type === 'EXPRESSION') {
1658
+ return findMemberIdsInValue(this.conditionService.evaluateValue(resolver.expression, buildDryRunExpressionContext(input, null), `${label}.expression`));
1659
+ }
1660
+ if (resolver.type === 'ORG_MANAGER') {
1661
+ const managerMemberIds = findMemberIdsInValue(readManagerMemberIdFromInitiatorSnapshot(input.initiatorMetadataSnapshot, resolver.levelsUp));
1662
+ return managerMemberIds.length > 0
1663
+ ? managerMemberIds
1664
+ : readDryRunFallbackMemberIds(input, resolver.fallback);
1665
+ }
1666
+ return null;
1341
1667
  }
1342
1668
  async getApprovalInstance(id, scope) {
1343
1669
  const instance = await this.approvalInstanceRepository.findOne({
@@ -1410,7 +1736,11 @@ let WorkflowEngineService = class WorkflowEngineService {
1410
1736
  }
1411
1737
  const view = options.view ?? workflow_engine_enums_1.ApprovalInstanceListViewEnum.ALL;
1412
1738
  if (view === workflow_engine_enums_1.ApprovalInstanceListViewEnum.SENT) {
1413
- queryBuilder.andWhere('"approvalInstance"."initiator_member_id" = :readableMemberId', {
1739
+ // The assistant who filed on someone's behalf finds it here too.
1740
+ queryBuilder.andWhere(`(
1741
+ "approvalInstance"."initiator_member_id" = :readableMemberId
1742
+ OR "approvalInstance"."submitted_by_member_id" = :readableMemberId
1743
+ )`, {
1414
1744
  readableMemberId: scope.memberId,
1415
1745
  });
1416
1746
  return true;
@@ -1434,6 +1764,7 @@ let WorkflowEngineService = class WorkflowEngineService {
1434
1764
  queryBuilder.andWhere(`
1435
1765
  (
1436
1766
  "approvalInstance"."initiator_member_id" = :readableMemberId
1767
+ OR "approvalInstance"."submitted_by_member_id" = :readableMemberId
1437
1768
  OR EXISTS (
1438
1769
  SELECT 1
1439
1770
  FROM "tasks" "readableTask"
@@ -1627,7 +1958,11 @@ let WorkflowEngineService = class WorkflowEngineService {
1627
1958
  async listTaskCandidates(taskId, scope) {
1628
1959
  await this.assertTaskReadable(taskId, scope);
1629
1960
  return this.taskCandidateRepository.find({
1630
- order: { createdAt: 'ASC', id: 'ASC' },
1961
+ order: {
1962
+ sequence: { direction: 'ASC', nulls: 'LAST' },
1963
+ createdAt: 'ASC',
1964
+ id: 'ASC',
1965
+ },
1631
1966
  where: { taskId },
1632
1967
  });
1633
1968
  }
@@ -1636,7 +1971,11 @@ let WorkflowEngineService = class WorkflowEngineService {
1636
1971
  return tasks;
1637
1972
  }
1638
1973
  const candidates = await this.taskCandidateRepository.find({
1639
- order: { createdAt: 'ASC', id: 'ASC' },
1974
+ order: {
1975
+ sequence: { direction: 'ASC', nulls: 'LAST' },
1976
+ createdAt: 'ASC',
1977
+ id: 'ASC',
1978
+ },
1640
1979
  where: { taskId: (0, typeorm_2.In)(tasks.map((task) => task.id)) },
1641
1980
  });
1642
1981
  const candidatesByTaskId = candidates.reduce((currentMap, candidate) => {
@@ -1645,12 +1984,65 @@ let WorkflowEngineService = class WorkflowEngineService {
1645
1984
  nextMap.set(candidate.taskId, [...memberIds, candidate.memberId]);
1646
1985
  return nextMap;
1647
1986
  }, new Map());
1987
+ const openCandidatesByTaskId = candidates
1988
+ .filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING ||
1989
+ candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED)
1990
+ .reduce((currentMap, candidate) => {
1991
+ const nextMap = new Map(currentMap);
1992
+ nextMap.set(candidate.taskId, [
1993
+ ...(nextMap.get(candidate.taskId) ?? []),
1994
+ candidate.memberId,
1995
+ ]);
1996
+ return nextMap;
1997
+ }, new Map());
1998
+ const nodeLabels = await this.readTaskNodeLabels(tasks);
1648
1999
  return tasks.map((task) => Object.assign(new task_entity_1.TaskEntity(), task, {
1649
2000
  candidateMemberIds: candidatesByTaskId.get(task.id) ?? [],
2001
+ nodeLabel: nodeLabels.get(readNodeLabelKey(task.instanceId, task.nodeId)) ??
2002
+ null,
2003
+ openCandidateMemberIds: openCandidatesByTaskId.get(task.id) ?? [],
1650
2004
  }));
1651
2005
  }
2006
+ /**
2007
+ * The display names of the nodes a page of tasks sits on, keyed by
2008
+ * {@link readNodeLabelKey}. One query for the whole page, and Postgres picks
2009
+ * the labels out of the snapshots: a workflow snapshot is a whole workflow
2010
+ * definition, and every inbox, history and instance task list runs this, so
2011
+ * shipping the snapshots to the app just to read one string each would cost
2012
+ * page size × snapshot size on every refresh. A missing instance or node, a
2013
+ * snapshot without a `nodes` array, and a blank or non-string label all
2014
+ * leave the task without a label, as `readWorkflowNodeLabel` does.
2015
+ */
2016
+ async readTaskNodeLabels(tasks) {
2017
+ // The table as the entity reads it, so a configured `schema` is honoured
2018
+ // here exactly as it is by every repository read of the instance.
2019
+ const { metadata } = this.approvalInstanceRepository;
2020
+ const { driver } = this.approvalInstanceRepository.manager.connection;
2021
+ const instanceTable = [metadata.schema, metadata.tableName]
2022
+ .filter((part) => Boolean(part))
2023
+ .map((part) => driver.escape(part))
2024
+ .join('.');
2025
+ const rows = await this.approvalInstanceRepository.query(`SELECT instance.id AS "instanceId",
2026
+ node->>'id' AS "nodeId",
2027
+ CASE WHEN jsonb_typeof(node->'data'->'label') = 'string'
2028
+ THEN node->'data'->>'label' END AS "label"
2029
+ FROM ${instanceTable} AS instance
2030
+ CROSS JOIN LATERAL jsonb_array_elements(
2031
+ CASE WHEN jsonb_typeof(instance.workflow_snapshot->'nodes') = 'array'
2032
+ THEN instance.workflow_snapshot->'nodes'
2033
+ ELSE '[]'::jsonb END
2034
+ ) AS node
2035
+ WHERE instance.id = ANY($1::uuid[])
2036
+ AND node->>'id' = ANY($2::text[])`, [
2037
+ [...new Set(tasks.map((task) => task.instanceId))],
2038
+ [...new Set(tasks.map((task) => task.nodeId))],
2039
+ ]);
2040
+ return new Map(rows.flatMap((row) => typeof row.label === 'string' && row.label.trim()
2041
+ ? [[readNodeLabelKey(row.instanceId, row.nodeId), row.label]]
2042
+ : []));
2043
+ }
1652
2044
  async readDefaultInitiatorMetadataSnapshot(memberId) {
1653
- const today = toDateOnlyString(new Date());
2045
+ const today = this.readOrganizationToday();
1654
2046
  const memberships = (await this.approvalInstanceRepository.manager
1655
2047
  .getRepository(membership_entity_1.MembershipEntity)
1656
2048
  .find({ where: { memberId } }))
@@ -1685,7 +2077,10 @@ let WorkflowEngineService = class WorkflowEngineService {
1685
2077
  return task;
1686
2078
  }
1687
2079
  async isApprovalInstanceReadableByMember(instance, memberId) {
1688
- if (instance.initiatorMemberId === memberId) {
2080
+ // The member who filed the document on the applicant's behalf can read it
2081
+ // back; resubmit and cancel stay with the initiator.
2082
+ if (instance.initiatorMemberId === memberId ||
2083
+ (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) === memberId) {
1689
2084
  return true;
1690
2085
  }
1691
2086
  const tasks = await this.taskRepository.find({
@@ -1882,19 +2277,47 @@ let WorkflowEngineService = class WorkflowEngineService {
1882
2277
  return this.conditionService.evaluateBoolean(entryCondition, buildWorkflowExpressionContext(instance), `workflow.nodes.${node.id}.data.entryCondition`);
1883
2278
  }
1884
2279
  async skipNodeByEntryCondition(manager, instance, token, node) {
2280
+ await this.skipNode(manager, instance, token, node, {
2281
+ skippedByEntryCondition: true,
2282
+ });
2283
+ }
2284
+ async skipNodeByInitiatorExclusion(manager, instance, token, node) {
2285
+ await this.skipNode(manager, instance, token, node, {
2286
+ skippedByInitiatorExclusion: true,
2287
+ });
2288
+ }
2289
+ async skipNode(manager, instance, token, node, reason) {
2290
+ // A NOTIFY node is an asynchronous branch that may not have an outgoing
2291
+ // edge, so a skipped one ends its branch exactly as an executed one does:
2292
+ // the token is consumed instead of advanced (advancing would throw "has no
2293
+ // outgoing edge" and fail the whole submission). Instance completion is
2294
+ // evaluated by `processRunningInstance` once no ACTIVE token remains.
2295
+ const consumesToken = (0, workflow_graph_1.isAsyncNotifyServiceTask)(node);
1885
2296
  await manager.getRepository(activity_log_entity_1.ActivityLogEntity).save(manager.getRepository(activity_log_entity_1.ActivityLogEntity).create({
1886
2297
  actorMemberId: null,
1887
2298
  eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TOKEN_ADVANCED,
1888
2299
  instanceId: instance.id,
1889
2300
  nodeId: node.id,
1890
2301
  payload: {
1891
- skippedByEntryCondition: true,
2302
+ ...reason,
2303
+ ...(consumesToken ? { consumed: true } : {}),
1892
2304
  tokenId: token.id,
1893
2305
  },
1894
2306
  taskId: null,
1895
2307
  }));
2308
+ if (consumesToken) {
2309
+ await this.consumeToken(manager, token);
2310
+ return;
2311
+ }
1896
2312
  await this.advanceTokenToOutgoingNodes(manager, instance, token, node);
1897
2313
  }
2314
+ async consumeToken(manager, token) {
2315
+ await manager.getRepository(workflow_token_entity_1.WorkflowTokenEntity).save({
2316
+ ...token,
2317
+ consumedAt: new Date(),
2318
+ status: workflow_engine_enums_1.WorkflowTokenStatusEnum.CONSUMED,
2319
+ });
2320
+ }
1898
2321
  async readAvailableOutgoingEdges(manager, instance, node) {
1899
2322
  const outgoingEdges = readOutgoingEdgesOrThrow(instance.workflowSnapshot, node.id);
1900
2323
  const conditionalEdges = outgoingEdges.filter((edge) => !edge.data.isDefault && edgeHasCondition(edge));
@@ -2086,43 +2509,66 @@ let WorkflowEngineService = class WorkflowEngineService {
2086
2509
  });
2087
2510
  return;
2088
2511
  }
2089
- const candidates = await this.resolveRuntimeTaskCandidates(manager, instance, node);
2512
+ const resolvedCandidates = await this.resolveRuntimeTaskCandidates(manager, instance, node);
2513
+ // The initiator was the only approver and the node asks to be skipped in
2514
+ // that case: no task, the token moves on. Pending countersign directives
2515
+ // stay pending for the next user task that actually gets created.
2516
+ if (resolvedCandidates === null) {
2517
+ await this.skipNodeByInitiatorExclusion(manager, instance, token, node);
2518
+ return;
2519
+ }
2520
+ const { candidates, skippedDelegations } = resolvedCandidates;
2090
2521
  const primaryCandidate = candidates[0];
2522
+ // Legacy snapshots may carry no policy at all; they behave as before.
2523
+ const decisionPolicy = node.data.decisionPolicy;
2524
+ const isSequential = decisionPolicy?.type === 'SEQUENTIAL' && candidates.length > 1;
2525
+ // A sequential task is held by exactly one candidate at a time, so it
2526
+ // carries that candidate as its assignee from the start.
2527
+ const assignedCandidate = candidates.length === 1 || isSequential ? primaryCandidate : null;
2091
2528
  const now = new Date();
2092
2529
  const slaDueAt = await this.slaScheduleService.resolveTaskSlaDueAt({
2093
2530
  node,
2094
2531
  now,
2095
2532
  });
2096
2533
  const task = await taskRepository.save(taskRepository.create({
2097
- assigneeMemberId: candidates.length === 1 ? primaryCandidate.memberId : null,
2534
+ assigneeMemberId: assignedCandidate?.memberId ?? null,
2098
2535
  assignmentType: candidates.length === 1
2099
2536
  ? workflow_engine_enums_1.TaskAssignmentTypeEnum.DIRECT_MEMBER
2100
2537
  : workflow_engine_enums_1.TaskAssignmentTypeEnum.CANDIDATE_GROUP,
2101
2538
  completedAt: null,
2102
2539
  createdAt: now,
2103
- decisionPolicySnapshot: node.data.decisionPolicy,
2104
- delegationChain: candidates.length === 1 ? primaryCandidate.delegationChain : [],
2540
+ decisionPolicySnapshot: isInitiatorExcluded(node)
2541
+ ? capDecisionPolicyToCandidateCount(decisionPolicy, candidates.length)
2542
+ : decisionPolicy,
2543
+ delegationChain: assignedCandidate?.delegationChain ?? [],
2105
2544
  instanceId: instance.id,
2106
2545
  nodeId: node.id,
2107
2546
  openedAt: null,
2108
- originalAssigneeMemberId: candidates.length === 1 ? primaryCandidate.originalMemberId : null,
2547
+ originalAssigneeMemberId: assignedCandidate?.originalMemberId ?? null,
2109
2548
  slaDueAt,
2110
2549
  status: workflow_engine_enums_1.TaskStatusEnum.PENDING,
2111
2550
  tokenId: token.id,
2112
2551
  }));
2113
- const savedCandidates = await taskCandidateRepository.save(candidates.map((candidate) => taskCandidateRepository.create({
2552
+ const savedCandidates = await taskCandidateRepository.save(candidates.map((candidate, index) => taskCandidateRepository.create({
2114
2553
  claimedAt: null,
2115
2554
  createdAt: now,
2116
2555
  decidedAt: null,
2117
2556
  delegationChain: candidate.delegationChain,
2118
2557
  memberId: candidate.memberId,
2119
2558
  originalMemberId: candidate.originalMemberId,
2559
+ sequence: isSequential ? index : null,
2120
2560
  sourceType: candidate.sourceType,
2121
- status: workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING,
2561
+ status: isSequential && index > 0
2562
+ ? workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING
2563
+ : workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING,
2122
2564
  taskId: task.id,
2123
2565
  })));
2566
+ // Display summary of everyone on the task, including the ones whose turn
2567
+ // has not come; who may act now is `status`, not membership of this list.
2124
2568
  task.candidateMemberIds = savedCandidates.map((candidate) => candidate.memberId);
2125
2569
  const singleCandidate = savedCandidates.length === 1 ? savedCandidates[0] : null;
2570
+ const openCandidates = savedCandidates.filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING);
2571
+ task.openCandidateMemberIds = openCandidates.map((candidate) => candidate.memberId);
2126
2572
  await tokenRepository.save({
2127
2573
  ...token,
2128
2574
  status: workflow_engine_enums_1.WorkflowTokenStatusEnum.WAITING,
@@ -2134,14 +2580,22 @@ let WorkflowEngineService = class WorkflowEngineService {
2134
2580
  nodeId: node.id,
2135
2581
  payload: {
2136
2582
  assignmentType: task.assignmentType,
2137
- assigneeMemberId: singleCandidate?.memberId ?? null,
2138
- candidateMemberIds: savedCandidates.map((candidate) => candidate.memberId),
2139
- originalAssigneeMemberId: singleCandidate?.originalMemberId ?? null,
2583
+ assigneeMemberId: (isSequential ? openCandidates[0] : singleCandidate)?.memberId ??
2584
+ null,
2585
+ candidateMemberIds: openCandidates.map((candidate) => candidate.memberId),
2586
+ originalAssigneeMemberId: (isSequential ? openCandidates[0] : singleCandidate)
2587
+ ?.originalMemberId ?? null,
2588
+ ...(isSequential
2589
+ ? {
2590
+ sequentialCandidateMemberIds: savedCandidates.map((candidate) => candidate.memberId),
2591
+ }
2592
+ : {}),
2593
+ ...(skippedDelegations.length > 0 ? { skippedDelegations } : {}),
2140
2594
  tokenId: token.id,
2141
2595
  },
2142
2596
  taskId: task.id,
2143
2597
  }));
2144
- await savedCandidates.reduce(async (previous, candidate) => {
2598
+ await openCandidates.reduce(async (previous, candidate) => {
2145
2599
  await previous;
2146
2600
  await this.notificationService.createTaskAssignedNotification({
2147
2601
  instance,
@@ -2158,6 +2612,118 @@ let WorkflowEngineService = class WorkflowEngineService {
2158
2612
  // created after they were requested — spawn their parallel tasks now.
2159
2613
  await this.spawnCountersignTasksForNode(manager, instance, token, node, now);
2160
2614
  }
2615
+ /**
2616
+ * Hands a SEQUENTIAL task to the lowest-`sequence` candidate still WAITING.
2617
+ * A task without a WAITING candidate (every other policy, and SEQUENTIAL
2618
+ * tasks created before candidates were ordered) is left alone.
2619
+ */
2620
+ async activateNextSequentialCandidate({ candidates, instance, manager, node, previousMemberId, task, }) {
2621
+ if (readDecisionPolicySnapshot(task).type !== 'SEQUENTIAL') {
2622
+ return;
2623
+ }
2624
+ const nextCandidate = [...candidates]
2625
+ .filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING &&
2626
+ candidate.sequence !== null)
2627
+ .sort((left, right) => (left.sequence ?? 0) - (right.sequence ?? 0))[0];
2628
+ if (!nextCandidate) {
2629
+ return;
2630
+ }
2631
+ const now = new Date();
2632
+ // Delegation was resolved when the task was created; the candidate may have
2633
+ // gained a delegate while waiting, so resolve it again for the new turn.
2634
+ const { skippedDelegation, ...delegation } = await this.resolveActivationDelegation({
2635
+ candidate: nextCandidate,
2636
+ candidates,
2637
+ excludesInitiator: isInitiatorExcluded(node),
2638
+ gradeGated: isGradeGatedCandidate(node, nextCandidate.sourceType),
2639
+ instance,
2640
+ manager,
2641
+ nodeId: task.nodeId,
2642
+ });
2643
+ const activatedCandidate = await manager
2644
+ .getRepository(task_candidate_entity_1.TaskCandidateEntity)
2645
+ .save(Object.assign(new task_candidate_entity_1.TaskCandidateEntity(), nextCandidate, delegation, {
2646
+ status: workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING,
2647
+ }));
2648
+ const activatedTask = await manager.getRepository(task_entity_1.TaskEntity).save({
2649
+ ...task,
2650
+ assigneeMemberId: activatedCandidate.memberId,
2651
+ delegationChain: activatedCandidate.delegationChain,
2652
+ openedAt: null,
2653
+ originalAssigneeMemberId: activatedCandidate.originalMemberId,
2654
+ // The new holder gets the node's full SLA window, not what is left of
2655
+ // the previous holder's; the warning is measured from this moment.
2656
+ slaDueAt: await this.slaScheduleService.resolveTaskSlaDueAt({
2657
+ node,
2658
+ now,
2659
+ }),
2660
+ slaStartedAt: now,
2661
+ status: workflow_engine_enums_1.TaskStatusEnum.PENDING,
2662
+ });
2663
+ const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
2664
+ await activityRepository.save(activityRepository.create({
2665
+ actorMemberId: null,
2666
+ eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_CANDIDATE_ACTIVATED,
2667
+ instanceId: instance.id,
2668
+ nodeId: task.nodeId,
2669
+ // Carries the same recipient fields as TASK_CREATED so a consumer can
2670
+ // route this event exactly like a task assignment.
2671
+ payload: {
2672
+ assigneeMemberId: activatedCandidate.memberId,
2673
+ assignmentType: activatedTask.assignmentType,
2674
+ candidateMemberId: activatedCandidate.memberId,
2675
+ candidateMemberIds: [activatedCandidate.memberId],
2676
+ originalAssigneeMemberId: activatedCandidate.originalMemberId,
2677
+ originalMemberId: activatedCandidate.originalMemberId,
2678
+ previousMemberId,
2679
+ sequence: activatedCandidate.sequence,
2680
+ ...(skippedDelegation
2681
+ ? { skippedDelegations: [skippedDelegation] }
2682
+ : {}),
2683
+ tokenId: task.tokenId,
2684
+ },
2685
+ taskId: task.id,
2686
+ }));
2687
+ await this.notificationService.createTaskAssignedNotification({
2688
+ instance,
2689
+ manager,
2690
+ node,
2691
+ task: activatedTask,
2692
+ });
2693
+ }
2694
+ /**
2695
+ * Re-resolves the delegation of a candidate whose turn has come. When the
2696
+ * delegate already holds another candidate row on the task, the stored values
2697
+ * are kept: (task_id, member_id) is unique, and that member signs from the
2698
+ * row they already have. On a node that excludes the initiator the stored
2699
+ * values are kept as well when the new delegation path would run through the
2700
+ * initiator or the submitter: creation already filtered such paths out, and a
2701
+ * delegation added while the candidate waited must not bring them back.
2702
+ */
2703
+ async resolveActivationDelegation({ candidate, candidates, excludesInitiator, gradeGated, instance, manager, nodeId, }) {
2704
+ const stored = {
2705
+ delegationChain: candidate.delegationChain,
2706
+ memberId: candidate.memberId,
2707
+ };
2708
+ const { skippedDelegation, ...resolution } = await this.resolveDelegation(manager, candidate.originalMemberId, this.buildDelegationContext(instance, nodeId), gradeGated);
2709
+ const collides = candidates.some((other) => other.id !== candidate.id &&
2710
+ other.memberId === resolution.finalAssigneeMemberId);
2711
+ if (collides ||
2712
+ (excludesInitiator &&
2713
+ isExcludedMemberOnCandidatePath({
2714
+ delegationChain: resolution.delegationChain,
2715
+ memberId: resolution.finalAssigneeMemberId,
2716
+ originalMemberId: candidate.originalMemberId,
2717
+ }, readInitiatorAndSubmitter(instance)))) {
2718
+ // The refusal happened even though the stored values are kept.
2719
+ return { ...stored, ...(skippedDelegation ? { skippedDelegation } : {}) };
2720
+ }
2721
+ return {
2722
+ delegationChain: resolution.delegationChain,
2723
+ memberId: resolution.finalAssigneeMemberId,
2724
+ ...(skippedDelegation ? { skippedDelegation } : {}),
2725
+ };
2726
+ }
2161
2727
  async markTaskCandidateDecision({ action, candidate, candidates, decidedAt, manager, task, }) {
2162
2728
  const taskCandidateRepository = manager.getRepository(task_candidate_entity_1.TaskCandidateEntity);
2163
2729
  if (!candidate && candidates.length === 0 && task.assigneeMemberId) {
@@ -2191,7 +2757,8 @@ let WorkflowEngineService = class WorkflowEngineService {
2191
2757
  }
2192
2758
  if (shouldCancelOpenCandidates &&
2193
2759
  (currentCandidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING ||
2194
- currentCandidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED)) {
2760
+ currentCandidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED ||
2761
+ currentCandidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING)) {
2195
2762
  return Object.assign(new task_candidate_entity_1.TaskCandidateEntity(), currentCandidate, {
2196
2763
  decidedAt,
2197
2764
  status: workflow_engine_enums_1.TaskCandidateStatusEnum.CANCELLED,
@@ -2230,6 +2797,18 @@ let WorkflowEngineService = class WorkflowEngineService {
2230
2797
  delegationChain: nextDelegationChain,
2231
2798
  status: workflow_engine_enums_1.TaskStatusEnum.TRANSFERRED,
2232
2799
  });
2800
+ // Candidates still waiting for their turn move with the task; without this
2801
+ // the replacement SINGLE task would silently drop them from the chain.
2802
+ // A waiting candidate who is also the transfer target takes the active slot
2803
+ // and is not asked a second time later in the order.
2804
+ const waitingCandidates = readDecisionPolicySnapshot(task).type === 'SEQUENTIAL' &&
2805
+ actorCandidate?.sequence != null
2806
+ ? (await taskCandidateRepository.find({ where: { taskId: task.id } }))
2807
+ .filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING)
2808
+ .sort((left, right) => (left.sequence ?? 0) - (right.sequence ?? 0))
2809
+ : [];
2810
+ const isSequentialTransfer = waitingCandidates.length > 0;
2811
+ const carriedCandidates = waitingCandidates.filter((candidate) => candidate.memberId !== transferToMemberId);
2233
2812
  const nextTask = await taskRepository.save(taskRepository.create({
2234
2813
  // A transferred ad-hoc task stays the same ad-hoc task for its new
2235
2814
  // holder: the pre-approval rejection path (RETURN_TO_ORIGIN) reads these
@@ -2239,9 +2818,13 @@ let WorkflowEngineService = class WorkflowEngineService {
2239
2818
  adhocOriginTaskId: task.adhocOriginTaskId,
2240
2819
  adhocType: task.adhocType,
2241
2820
  assigneeMemberId: transferToMemberId,
2242
- assignmentType: workflow_engine_enums_1.TaskAssignmentTypeEnum.DIRECT_MEMBER,
2821
+ assignmentType: isSequentialTransfer
2822
+ ? task.assignmentType
2823
+ : workflow_engine_enums_1.TaskAssignmentTypeEnum.DIRECT_MEMBER,
2243
2824
  completedAt: null,
2244
- decisionPolicySnapshot: { type: 'SINGLE' },
2825
+ decisionPolicySnapshot: isSequentialTransfer
2826
+ ? task.decisionPolicySnapshot
2827
+ : { type: 'SINGLE' },
2245
2828
  delegationChain: nextDelegationChain,
2246
2829
  instanceId: task.instanceId,
2247
2830
  isAdhoc: task.isAdhoc,
@@ -2252,6 +2835,29 @@ let WorkflowEngineService = class WorkflowEngineService {
2252
2835
  status: workflow_engine_enums_1.TaskStatusEnum.PENDING,
2253
2836
  tokenId: task.tokenId,
2254
2837
  }));
2838
+ // A pre-approval the previous holder requested still points at the task
2839
+ // being transferred. Its RETURN_TO_ORIGIN rejection hands the decision back
2840
+ // through adhocOriginTaskId, so leaving it on the TRANSFERRED row reopened
2841
+ // a task for the member who had just transferred the stage away. Only open
2842
+ // pre-approvals move; closed ones keep pointing at the task they ran
2843
+ // against, and countersign tasks keep agreeing with directive.originTaskId
2844
+ // because nothing reads their pointer. A pre-approval is always created on
2845
+ // the origin task's instance and node, so those narrow the lookup to the
2846
+ // idx_tasks_instance_node index (adhoc_origin_task_id has none).
2847
+ const openPreApprovalsOnTransferredTask = (await taskRepository.find({
2848
+ where: {
2849
+ adhocOriginTaskId: task.id,
2850
+ adhocType: adhoc_enums_1.AdhocDirectiveTypeEnum.PRE_APPROVAL,
2851
+ instanceId: task.instanceId,
2852
+ nodeId: task.nodeId,
2853
+ },
2854
+ })).filter((adhocTask) => adhocTask.status === workflow_engine_enums_1.TaskStatusEnum.PENDING ||
2855
+ adhocTask.status === workflow_engine_enums_1.TaskStatusEnum.IN_PROGRESS);
2856
+ if (openPreApprovalsOnTransferredTask.length > 0) {
2857
+ await taskRepository.save(openPreApprovalsOnTransferredTask.map((adhocTask) => Object.assign(new task_entity_1.TaskEntity(), adhocTask, {
2858
+ adhocOriginTaskId: nextTask.id,
2859
+ })));
2860
+ }
2255
2861
  await taskCandidateRepository.save([
2256
2862
  ...(actorCandidate
2257
2863
  ? [
@@ -2269,10 +2875,28 @@ let WorkflowEngineService = class WorkflowEngineService {
2269
2875
  delegationChain: nextDelegationChain,
2270
2876
  memberId: transferToMemberId,
2271
2877
  originalMemberId: task.originalAssigneeMemberId ?? transferToMemberId,
2878
+ sequence: isSequentialTransfer
2879
+ ? (actorCandidate?.sequence ?? null)
2880
+ : null,
2272
2881
  sourceType: 'DIRECT',
2273
2882
  status: workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING,
2274
2883
  taskId: nextTask.id,
2275
2884
  }),
2885
+ ...carriedCandidates.map((candidate) => taskCandidateRepository.create({
2886
+ claimedAt: null,
2887
+ createdAt: candidate.createdAt,
2888
+ decidedAt: null,
2889
+ delegationChain: candidate.delegationChain,
2890
+ memberId: candidate.memberId,
2891
+ originalMemberId: candidate.originalMemberId,
2892
+ sequence: candidate.sequence,
2893
+ sourceType: candidate.sourceType,
2894
+ status: workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING,
2895
+ taskId: nextTask.id,
2896
+ })),
2897
+ ...waitingCandidates.map((candidate) => Object.assign(new task_candidate_entity_1.TaskCandidateEntity(), candidate, {
2898
+ status: workflow_engine_enums_1.TaskCandidateStatusEnum.SUPERSEDED,
2899
+ })),
2276
2900
  ]);
2277
2901
  await activityRepository.save([
2278
2902
  activityRepository.create({
@@ -2297,8 +2921,19 @@ let WorkflowEngineService = class WorkflowEngineService {
2297
2921
  nodeId: task.nodeId,
2298
2922
  payload: {
2299
2923
  assigneeMemberId: transferToMemberId,
2924
+ ...(isSequentialTransfer
2925
+ ? { candidateMemberIds: [transferToMemberId] }
2926
+ : {}),
2300
2927
  delegationChain: nextDelegationChain,
2301
2928
  originalAssigneeMemberId: task.originalAssigneeMemberId,
2929
+ ...(isSequentialTransfer
2930
+ ? {
2931
+ sequentialCandidateMemberIds: [
2932
+ transferToMemberId,
2933
+ ...carriedCandidates.map((candidate) => candidate.memberId),
2934
+ ],
2935
+ }
2936
+ : {}),
2302
2937
  tokenId: task.tokenId,
2303
2938
  transferredFromTaskId: task.id,
2304
2939
  },
@@ -2316,16 +2951,63 @@ let WorkflowEngineService = class WorkflowEngineService {
2316
2951
  });
2317
2952
  }
2318
2953
  }
2954
+ /**
2955
+ * Resolves who gets the node's task. `null` means the node asked to be
2956
+ * skipped (`excludeInitiator.whenEmpty = SKIP_NODE`) because the initiator
2957
+ * was the only approver — the sole caller creates no task and advances the
2958
+ * token instead.
2959
+ */
2319
2960
  async resolveRuntimeTaskCandidates(manager, instance, node) {
2320
- const resolvedCandidates = await this.resolveApproverResolver(manager, instance, node.data.approverResolver, `簽核節點「${node.data.label}」`);
2321
- const uniqueCandidates = await this.applyDelegationToResolvedCandidates(instance, node.id, resolvedCandidates);
2322
- if (uniqueCandidates.length === 0) {
2323
- throw new common_1.ConflictException(`簽核節點「${node.data.label}」 did not resolve to any member id`);
2961
+ const label = `簽核節點「${node.data.label}」`;
2962
+ const resolvedCandidates = await this.resolveApproverResolver(manager, instance, node.data.approverResolver, label, '簽核人', isInitiatorExcluded(node) ? readInitiatorAndSubmitter(instance) : []);
2963
+ const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, isOrgManagerChainNode(node));
2964
+ const excludeInitiator = node.data.excludeInitiator;
2965
+ const hasSubmitter = (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) !== null;
2966
+ const uniqueCandidates = selectNodeCandidates(delegatedCandidates, isInitiatorExcluded(node), readInitiatorAndSubmitter(instance));
2967
+ if (uniqueCandidates.length > 0) {
2968
+ return {
2969
+ candidates: uniqueCandidates,
2970
+ skippedDelegations: collectSkippedDelegations(delegatedCandidates, isInitiatorExcluded(node), readInitiatorAndSubmitter(instance)),
2971
+ };
2972
+ }
2973
+ // Only the initiator was left — what the node does about it is configured.
2974
+ // Nothing resolved to begin with is the ordinary failure below.
2975
+ if (!excludeInitiator || delegatedCandidates.length === 0) {
2976
+ throw new common_1.ConflictException(`${label} did not resolve to any member id`);
2324
2977
  }
2325
- return uniqueCandidates;
2978
+ if (excludeInitiator.whenEmpty === 'SKIP_NODE') {
2979
+ return null;
2980
+ }
2981
+ if (excludeInitiator.whenEmpty === 'INITIATOR_MANAGER') {
2982
+ const managerCandidates = await this.resolveInitiatorManagerReplacements(manager, instance, node);
2983
+ if (managerCandidates.candidates.length > 0) {
2984
+ return managerCandidates;
2985
+ }
2986
+ throw new common_1.ConflictException(hasSubmitter
2987
+ ? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人,此節點不允許申請人或代送人簽核,且找不到申請人的主管可改派。`
2988
+ : `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人,此節點不允許申請人自簽,且找不到申請人的主管可改派。`);
2989
+ }
2990
+ throw new common_1.ConflictException(hasSubmitter
2991
+ ? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人(含委派後的代理人),且此節點不允許申請人或代送人簽核。`
2992
+ : `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人(含委派後的代理人),且此節點不允許申請人自簽。`);
2993
+ }
2994
+ async resolveInitiatorManagerReplacements(manager, instance, node) {
2995
+ // The initiator's own direct manager: the closest org unit only. The
2996
+ // initiator himself (a unit head) and the member who submitted for him are
2997
+ // skipped before the tier is chosen, so resolution falls through to the
2998
+ // next tier instead of ending on either of them.
2999
+ const managerMemberIds = await this.resolveDirectManagerMemberIds(manager, instance.initiatorMemberId, this.readOrganizationToday(), true, readInitiatorAndSubmitter(instance));
3000
+ const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, managerMemberIds.map((memberId) => ({
3001
+ memberId,
3002
+ sourceType: 'EXCLUDE_INITIATOR_MANAGER',
3003
+ })), isOrgManagerChainNode(node));
3004
+ return {
3005
+ candidates: selectNodeCandidates(delegatedCandidates, true, readInitiatorAndSubmitter(instance)),
3006
+ skippedDelegations: collectSkippedDelegations(delegatedCandidates, true, readInitiatorAndSubmitter(instance)),
3007
+ };
2326
3008
  }
2327
- async applyDelegationToResolvedCandidates(instance, nodeId, resolvedCandidates) {
2328
- const context = {
3009
+ buildDelegationContext(instance, nodeId) {
3010
+ return {
2329
3011
  formData: instance.formData,
2330
3012
  initiatorMemberId: instance.initiatorMemberId,
2331
3013
  initiatorMetadataSnapshot: instance.initiatorMetadataSnapshot,
@@ -2336,43 +3018,89 @@ let WorkflowEngineService = class WorkflowEngineService {
2336
3018
  templateVersionId: instance.templateVersionId,
2337
3019
  title: instance.title,
2338
3020
  };
2339
- const delegatedCandidates = await Promise.all(resolvedCandidates.map(async (candidate) => {
2340
- const delegationResolution = await this.delegationService.resolveAssignee(candidate.memberId, context);
3021
+ }
3022
+ // Not deduplicated: a node that excludes the initiator filters first, so a
3023
+ // first-seen entry that must be dropped cannot shadow a legitimate one.
3024
+ async delegateResolvedCandidates(manager, instance, nodeId, resolvedCandidates, gradeGated) {
3025
+ const context = this.buildDelegationContext(instance, nodeId);
3026
+ const resolved = await Promise.all(resolvedCandidates.map(async (candidate) => ({
3027
+ candidate,
3028
+ resolution: await this.delegationService.resolveAssignee(candidate.memberId, context),
3029
+ })));
3030
+ // One batch of grade lookups for the whole node, not two per delegate.
3031
+ const levels = gradeGated
3032
+ ? await this.readMemberPositionLevels(manager, resolved.flatMap(({ candidate, resolution }) => readGradeGateMemberIds(candidate.memberId, resolution)), this.readOrganizationToday())
3033
+ : new Map();
3034
+ return resolved.map(({ candidate, resolution }) => {
3035
+ const { skippedDelegation, ...delegationResolution } = applyGradeGate(candidate.memberId, resolution, gradeGated, levels);
2341
3036
  return {
2342
3037
  delegationChain: delegationResolution.delegationChain,
2343
3038
  memberId: delegationResolution.finalAssigneeMemberId,
2344
3039
  originalMemberId: candidate.memberId,
3040
+ ...(skippedDelegation ? { skippedDelegation } : {}),
2345
3041
  sourceType: candidate.sourceType,
2346
3042
  };
2347
- }));
2348
- return uniqueRuntimeCandidates(delegatedCandidates);
3043
+ });
3044
+ }
3045
+ /** One member's delegation, grade-gated as {@link applyGradeGate} says. */
3046
+ async resolveDelegation(manager, principalMemberId, context, gradeGated) {
3047
+ const resolution = await this.delegationService.resolveAssignee(principalMemberId, context);
3048
+ const memberIds = gradeGated
3049
+ ? readGradeGateMemberIds(principalMemberId, resolution)
3050
+ : [];
3051
+ const levels = memberIds.length > 0
3052
+ ? await this.readMemberPositionLevels(manager, memberIds, this.readOrganizationToday())
3053
+ : new Map();
3054
+ return applyGradeGate(principalMemberId, resolution, gradeGated, levels);
3055
+ }
3056
+ /**
3057
+ * Manual transfer on a manager-chain node: the target must hold at least the
3058
+ * grade of the member being replaced, i.e. the original principal of the
3059
+ * deciding candidate (the first hop of its delegation chain when it was
3060
+ * delegated, otherwise the actor). No grade on the principal means no
3061
+ * constraint.
3062
+ */
3063
+ async assertTransferTargetGrade(manager, { actorMemberId, delegationChain, label, targetMemberId, }) {
3064
+ const principalMemberId = readDelegationSteps(delegationChain)[0]?.from ?? actorMemberId;
3065
+ const date = this.readOrganizationToday();
3066
+ const requiredLevel = await this.readMemberPositionLevel(manager, principalMemberId, date);
3067
+ if (requiredLevel === null) {
3068
+ return;
3069
+ }
3070
+ const targetLevel = await this.readMemberPositionLevel(manager, targetMemberId, date);
3071
+ if (targetLevel === null || targetLevel < requiredLevel) {
3072
+ throw new common_1.ForbiddenException(`簽核節點「${label}」需由職等 ${requiredLevel} 以上的成員簽核,不能轉簽給職等不足的成員`);
3073
+ }
2349
3074
  }
2350
- async resolveApproverResolver(manager, instance, resolver, label) {
3075
+ async resolveApproverResolver(manager, instance, resolver, label, subject = '簽核人', skippedChainMemberIds = []) {
2351
3076
  if (resolver.type === 'DIRECT') {
2352
- return readMemberIdsFromValue(resolver.memberIds, label).map((memberId) => ({ memberId, sourceType: resolver.type }));
3077
+ return readMemberIdsFromValue(resolver.memberIds, label, subject).map((memberId) => ({ memberId, sourceType: resolver.type }));
2353
3078
  }
2354
3079
  if (resolver.type === 'DYNAMIC_FORM') {
2355
- return readMemberIdsFromValue(readValueAtPath(instance.formData, resolver.formPath), `${label}.formPath`).map((memberId) => ({ memberId, sourceType: resolver.type }));
3080
+ return readMemberIdsFromValue(readValueAtPath(instance.formData, resolver.formPath), label, subject, `依表單欄位「${resolver.formPath}」`).map((memberId) => ({ memberId, sourceType: resolver.type }));
2356
3081
  }
2357
3082
  if (resolver.type === 'EXPRESSION') {
2358
- return readMemberIdsFromValue(this.conditionService.evaluateValue(resolver.expression, buildWorkflowExpressionContext(instance), `${label}.expression`), `${label}.expression`).map((memberId) => ({ memberId, sourceType: resolver.type }));
3083
+ return readMemberIdsFromValue(this.conditionService.evaluateValue(resolver.expression, buildWorkflowExpressionContext(instance), `${label}.expression`), label, subject, '依運算式').map((memberId) => ({ memberId, sourceType: resolver.type }));
2359
3084
  }
2360
3085
  if (resolver.type === 'POSITION') {
2361
- return this.resolvePositionCandidates(manager, resolver.positionId, label).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
3086
+ return this.resolvePositionCandidates(manager, resolver.positionId, label, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
2362
3087
  }
2363
3088
  if (resolver.type === 'ORG_UNIT_MEMBER') {
2364
- return this.resolveOrgUnitMemberCandidates(manager, resolver.orgUnitId, Boolean(resolver.includeDescendants), label).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
3089
+ return this.resolveOrgUnitMemberCandidates(manager, resolver.orgUnitId, Boolean(resolver.includeDescendants), label, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
2365
3090
  }
2366
3091
  if (resolver.type === 'ORG_UNIT_POSITION') {
2367
- return this.resolveOrgUnitPositionCandidates(manager, resolver.orgUnitId, resolver.positionId, Boolean(resolver.includeDescendants), label).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
3092
+ return this.resolveOrgUnitPositionCandidates(manager, resolver.orgUnitId, resolver.positionId, Boolean(resolver.includeDescendants), label, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
2368
3093
  }
2369
3094
  if (resolver.type === 'ORG_UNIT_MANAGER') {
2370
- return this.resolveOrgUnitManagerCandidates(manager, instance, resolver.orgUnitId, resolver.fallback, label, resolver.preferClosestOrgUnit === true).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
3095
+ return this.resolveOrgUnitManagerCandidates(manager, instance, resolver.orgUnitId, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
2371
3096
  }
2372
- return this.resolveInitiatorManagerCandidates(manager, instance, resolver.levelsUp, resolver.fallback, label, resolver.preferClosestOrgUnit === true).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
3097
+ if (resolver.type === 'ORG_MANAGER_CHAIN') {
3098
+ return this.resolveOrgManagerChainCandidates(manager, instance, resolver.minPositionLevel, resolver.fallback, label, subject, skippedChainMemberIds).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
3099
+ }
3100
+ return this.resolveInitiatorManagerCandidates(manager, instance, resolver.levelsUp, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
2373
3101
  }
2374
- async resolvePositionCandidates(manager, positionId, label) {
2375
- const today = toDateOnlyString(new Date());
3102
+ async resolvePositionCandidates(manager, positionId, label, subject) {
3103
+ const today = this.readOrganizationToday();
2376
3104
  const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
2377
3105
  where: { positionId },
2378
3106
  });
@@ -2386,35 +3114,123 @@ let WorkflowEngineService = class WorkflowEngineService {
2386
3114
  return second.effectiveFrom.localeCompare(first.effectiveFrom);
2387
3115
  });
2388
3116
  if (!activeMemberships.length) {
2389
- throw new common_1.ConflictException(`${label} position ${positionId} has no active membership`);
3117
+ throw new common_1.ConflictException(`${label} 找不到${subject}:職位「${await this.readPositionName(manager, positionId)}」目前沒有任何有效成員`);
2390
3118
  }
2391
3119
  return uniqueTexts(activeMemberships.map((membership) => membership.memberId));
2392
3120
  }
2393
- async resolveInitiatorManagerCandidates(manager, instance, levelsUp, fallback, label, preferClosestOrgUnit) {
3121
+ async resolveInitiatorManagerCandidates(manager, instance, levelsUp, fallback, label, preferClosestOrgUnit, subject) {
2394
3122
  const normalizedLevelsUp = Math.max(Math.trunc(levelsUp), 1);
2395
3123
  const resolvedManagerMemberIds = await this.resolveManagerMemberIdsFromOrganization(manager, instance.initiatorMemberId, normalizedLevelsUp, preferClosestOrgUnit);
2396
3124
  if (resolvedManagerMemberIds.length) {
2397
3125
  return resolvedManagerMemberIds;
2398
3126
  }
2399
3127
  return [
2400
- this.resolveFallbackAssignee(instance, fallback, label, `找不到發起人的第 ${normalizedLevelsUp} 層主管`),
3128
+ this.resolveFallbackAssignee(instance, fallback, label, subject, `找不到發起人的第 ${normalizedLevelsUp} 層主管`),
2401
3129
  ];
2402
3130
  }
2403
- resolveFallbackAssignee(instance, fallback, label, failureReason) {
3131
+ /**
3132
+ * Walks up the initiator's manager chain, nearest manager first, until a
3133
+ * manager whose position level reaches `minPositionLevel` is added. The
3134
+ * list is returned nearest manager first. Members in `skippedMemberIds` are
3135
+ * walked past without joining the chain or counting as the stopping point.
3136
+ */
3137
+ async resolveOrgManagerChainCandidates(manager, instance, minPositionLevel, fallback, label, subject, skippedMemberIds) {
3138
+ const date = this.readOrganizationToday();
3139
+ const visitedMemberIds = new Set([instance.initiatorMemberId]);
3140
+ const chain = [];
3141
+ let currentMemberId = instance.initiatorMemberId;
3142
+ let lastAddedLevel = null;
3143
+ for (let hop = 0; hop < ORG_MANAGER_CHAIN_MAX_HOPS; hop += 1) {
3144
+ // A chain is a single path, so it always uses closest-org-unit semantics
3145
+ // (merging ancestor-unit rules could only produce ties). The member is
3146
+ // excluded from his own candidates: a unit head belongs to the unit he
3147
+ // heads, so without this his unit rule would name himself and the walk
3148
+ // could never climb past him.
3149
+ const directManagerMemberIds = await this.resolveDirectManagerMemberIds(manager, currentMemberId, date, true, [currentMemberId]);
3150
+ if (directManagerMemberIds.length === 0) {
3151
+ break;
3152
+ }
3153
+ if (directManagerMemberIds.length > 1) {
3154
+ // Picking one silently would route the document by accident.
3155
+ throw new common_1.ConflictException(`${label} 無法沿主管鏈簽核:成員 ${currentMemberId} 的主管規則在同一優先順序出現多位主管(${directManagerMemberIds.join('、')}),無法決定簽核路徑。請聯絡流程管理員調整主管規則。`);
3156
+ }
3157
+ const [managerMemberId] = directManagerMemberIds;
3158
+ if (visitedMemberIds.has(managerMemberId)) {
3159
+ break;
3160
+ }
3161
+ visitedMemberIds.add(managerMemberId);
3162
+ currentMemberId = managerMemberId;
3163
+ // A member the node keeps out (the person who filed the document) is
3164
+ // climbed through, not stopped at: filtering him after the walk would
3165
+ // lose the chain when he is the manager that reaches the level.
3166
+ if (skippedMemberIds.includes(managerMemberId)) {
3167
+ continue;
3168
+ }
3169
+ const level = await this.readMemberPositionLevel(manager, managerMemberId, date);
3170
+ if (level === null ||
3171
+ (lastAddedLevel !== null && level <= lastAddedLevel)) {
3172
+ continue;
3173
+ }
3174
+ chain.push(managerMemberId);
3175
+ lastAddedLevel = level;
3176
+ if (level >= minPositionLevel) {
3177
+ return chain;
3178
+ }
3179
+ }
3180
+ const fallbackMemberId = this.resolveFallbackAssignee(instance, fallback, label, subject, `沿主管鏈找不到職等達 ${minPositionLevel} 的主管`);
3181
+ return chain.includes(fallbackMemberId)
3182
+ ? chain
3183
+ : [...chain, fallbackMemberId];
3184
+ }
3185
+ /** Highest position level over the member's active memberships, or `null`. */
3186
+ async readMemberPositionLevel(manager, memberId, date) {
3187
+ return ((await this.readMemberPositionLevels(manager, [memberId], date)).get(memberId) ?? null);
3188
+ }
3189
+ /**
3190
+ * {@link readMemberPositionLevel} for several members in two queries: every
3191
+ * requested member is in the map, `null` when they hold no graded position.
3192
+ */
3193
+ async readMemberPositionLevels(manager, memberIds, date) {
3194
+ const uniqueMemberIds = uniqueTexts(memberIds);
3195
+ if (uniqueMemberIds.length === 0) {
3196
+ return new Map();
3197
+ }
3198
+ const memberships = (await manager.getRepository(membership_entity_1.MembershipEntity).find({
3199
+ where: { memberId: (0, typeorm_2.In)([...uniqueMemberIds]) },
3200
+ })).filter((membership) => Boolean(membership.positionId) && isDateRangeActive(membership, date));
3201
+ const positionIds = uniqueTexts(memberships.map((membership) => membership.positionId ?? ''));
3202
+ const positions = positionIds.length === 0
3203
+ ? []
3204
+ : await manager.getRepository(position_entity_1.PositionEntity).find({
3205
+ where: { id: (0, typeorm_2.In)([...positionIds]) },
3206
+ });
3207
+ const levelByPositionId = new Map(positions.map((position) => [position.id, position.level]));
3208
+ return new Map(uniqueMemberIds.map((memberId) => {
3209
+ const levels = memberships
3210
+ .filter((membership) => membership.memberId === memberId)
3211
+ .flatMap((membership) => {
3212
+ const level = levelByPositionId.get(membership.positionId ?? '');
3213
+ return level === undefined ? [] : [level];
3214
+ });
3215
+ return [memberId, levels.length === 0 ? null : Math.max(...levels)];
3216
+ }));
3217
+ }
3218
+ resolveFallbackAssignee(instance, fallback, label, subject, failureReason) {
3219
+ const failure = subject === '通知對象' ? '無法建立通知' : '無法建立待簽任務';
2404
3220
  if (!fallback || fallback.type === 'NONE') {
2405
- throw new common_1.ConflictException(`${label} 無法建立待簽任務:${failureReason},且未設定改派固定人。請聯絡流程管理員調整主管規則或模板 fallback。`);
3221
+ throw new common_1.ConflictException(`${label} ${failure}:${failureReason},且未設定改派固定人。請聯絡流程管理員調整主管規則或模板 fallback。`);
2406
3222
  }
2407
3223
  if (!fallback.memberId.trim()) {
2408
- throw new common_1.ConflictException(`${label} 無法建立待簽任務:改派固定人未設定。`);
3224
+ throw new common_1.ConflictException(`${label} ${failure}:改派固定人未設定。`);
2409
3225
  }
2410
3226
  if (fallback.memberId === instance.initiatorMemberId &&
2411
3227
  !fallback.allowInitiatorSelfApproval) {
2412
- throw new common_1.ConflictException(`${label} 無法建立待簽任務:改派固定人是申請人本人,且此節點未允許申請人自簽。`);
3228
+ throw new common_1.ConflictException(`${label} ${failure}:改派固定人是申請人本人,且此節點未允許申請人自簽。`);
2413
3229
  }
2414
3230
  return fallback.memberId;
2415
3231
  }
2416
3232
  async resolveManagerMemberIdsFromOrganization(manager, memberId, levelsUp, preferClosestOrgUnit) {
2417
- return this.resolveManagerMemberIdsAtLevel(manager, memberId, toDateOnlyString(new Date()), levelsUp, preferClosestOrgUnit);
3233
+ return this.resolveManagerMemberIdsAtLevel(manager, memberId, this.readOrganizationToday(), levelsUp, preferClosestOrgUnit);
2418
3234
  }
2419
3235
  async resolveManagerMemberIdsAtLevel(manager, memberId, date, remainingLevels, preferClosestOrgUnit) {
2420
3236
  const directManagerMemberIds = await this.resolveDirectManagerMemberIds(manager, memberId, date, preferClosestOrgUnit);
@@ -2424,7 +3240,7 @@ let WorkflowEngineService = class WorkflowEngineService {
2424
3240
  const nextLevelGroups = await Promise.all(directManagerMemberIds.map((managerMemberId) => this.resolveManagerMemberIdsAtLevel(manager, managerMemberId, date, remainingLevels - 1, preferClosestOrgUnit)));
2425
3241
  return uniqueTexts(nextLevelGroups.flat());
2426
3242
  }
2427
- async resolveDirectManagerMemberIds(manager, memberId, date, preferClosestOrgUnit) {
3243
+ async resolveDirectManagerMemberIds(manager, memberId, date, preferClosestOrgUnit, excludedManagerMemberIds = []) {
2428
3244
  const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
2429
3245
  where: { memberId },
2430
3246
  });
@@ -2445,10 +3261,10 @@ let WorkflowEngineService = class WorkflowEngineService {
2445
3261
  scopeType: organization_enums_1.ManagerResolutionScopeTypeEnum.POSITION,
2446
3262
  })),
2447
3263
  ];
2448
- return this.resolveManagerResolutionCandidates(manager, candidatePairs, date, readOrgUnitDepthMap(orgUnits), preferClosestOrgUnit);
3264
+ return this.resolveManagerResolutionCandidates(manager, candidatePairs, date, readOrgUnitDepthMap(orgUnits), preferClosestOrgUnit, excludedManagerMemberIds);
2449
3265
  }
2450
- async resolveOrgUnitManagerCandidates(manager, instance, orgUnitId, fallback, label, preferClosestOrgUnit) {
2451
- const date = toDateOnlyString(new Date());
3266
+ async resolveOrgUnitManagerCandidates(manager, instance, orgUnitId, fallback, label, preferClosestOrgUnit, subject) {
3267
+ const date = this.readOrganizationToday();
2452
3268
  const orgUnits = await this.readOrgUnitAndAncestors(manager, [orgUnitId]);
2453
3269
  const managerMemberIds = await this.resolveManagerResolutionCandidates(manager, orgUnits.map((orgUnit) => ({
2454
3270
  scopeId: orgUnit.id,
@@ -2456,7 +3272,7 @@ let WorkflowEngineService = class WorkflowEngineService {
2456
3272
  })), date, readOrgUnitDepthMap(orgUnits), preferClosestOrgUnit);
2457
3273
  if (!managerMemberIds.length) {
2458
3274
  return [
2459
- this.resolveFallbackAssignee(instance, fallback, label, `找不到指定組織的主管規則`),
3275
+ this.resolveFallbackAssignee(instance, fallback, label, subject, `找不到指定組織的主管規則`),
2460
3276
  ];
2461
3277
  }
2462
3278
  return managerMemberIds;
@@ -2477,7 +3293,7 @@ let WorkflowEngineService = class WorkflowEngineService {
2477
3293
  const byId = new Map([...directOrgUnits, ...ancestorGroups.flat()].map((orgUnit) => [orgUnit.id, orgUnit]));
2478
3294
  return [...byId.values()];
2479
3295
  }
2480
- async resolveManagerResolutionCandidates(manager, candidatePairs, date, orgUnitDepths, preferClosestOrgUnit) {
3296
+ async resolveManagerResolutionCandidates(manager, candidatePairs, date, orgUnitDepths, preferClosestOrgUnit, excludedManagerMemberIds = []) {
2481
3297
  const scopeIds = candidatePairs.map((pair) => pair.scopeId);
2482
3298
  if (!scopeIds.length) {
2483
3299
  return [];
@@ -2499,56 +3315,80 @@ let WorkflowEngineService = class WorkflowEngineService {
2499
3315
  .filter((resolution) => candidatePairs.some((pair) => pair.scopeId === resolution.scopeId &&
2500
3316
  pair.scopeType === resolution.scopeType))
2501
3317
  .filter((resolution) => isDateRangeActive(resolution, date))
3318
+ // Dropped before the top-priority tier is chosen, so resolution falls
3319
+ // through to the next tier instead of ending on the excluded member.
3320
+ .filter((resolution) => !excludedManagerMemberIds.includes(resolution.managerMemberId))
2502
3321
  .sort((left, right) => compareManagerResolution(left, right, depthLookup));
2503
3322
  return uniqueTexts(readTopPriorityResolutions(active, depthLookup).map((resolution) => resolution.managerMemberId));
2504
3323
  }
2505
- async resolveOrgUnitMemberCandidates(manager, orgUnitId, includeDescendants, label) {
2506
- const orgUnitIds = await this.readOrgUnitScopeIds(manager, orgUnitId, includeDescendants);
2507
- const today = toDateOnlyString(new Date());
3324
+ async resolveOrgUnitMemberCandidates(manager, orgUnitId, includeDescendants, label, subject) {
3325
+ const scope = await this.readOrgUnitScope(manager, orgUnitId, includeDescendants);
3326
+ if (!scope) {
3327
+ throw new common_1.ConflictException(readMissingOrgUnitMessage(label, subject, orgUnitId));
3328
+ }
3329
+ const today = this.readOrganizationToday();
2508
3330
  const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
2509
- where: { orgUnitId: (0, typeorm_2.In)([...orgUnitIds]) },
3331
+ where: { orgUnitId: (0, typeorm_2.In)([...scope.orgUnitIds]) },
2510
3332
  });
2511
3333
  const memberIds = memberships
2512
3334
  .filter((membership) => isDateRangeActive(membership, today))
2513
3335
  .sort(compareMembership)
2514
3336
  .map((membership) => membership.memberId);
2515
3337
  if (memberIds.length === 0) {
2516
- throw new common_1.ConflictException(`${label} orgUnit ${orgUnitId} has no active member`);
3338
+ throw new common_1.ConflictException(`${label} 找不到${subject}:組織「${scope.name}」目前沒有任何有效成員,請先將人員加入該組織`);
2517
3339
  }
2518
3340
  return uniqueTexts(memberIds);
2519
3341
  }
2520
- async resolveOrgUnitPositionCandidates(manager, orgUnitId, positionId, includeDescendants, label) {
2521
- const orgUnitIds = await this.readOrgUnitScopeIds(manager, orgUnitId, includeDescendants);
2522
- const today = toDateOnlyString(new Date());
3342
+ async resolveOrgUnitPositionCandidates(manager, orgUnitId, positionId, includeDescendants, label, subject) {
3343
+ const scope = await this.readOrgUnitScope(manager, orgUnitId, includeDescendants);
3344
+ if (!scope) {
3345
+ throw new common_1.ConflictException(readMissingOrgUnitMessage(label, subject, orgUnitId));
3346
+ }
3347
+ const today = this.readOrganizationToday();
2523
3348
  const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
2524
- where: { orgUnitId: (0, typeorm_2.In)([...orgUnitIds]), positionId },
3349
+ where: { orgUnitId: (0, typeorm_2.In)([...scope.orgUnitIds]), positionId },
2525
3350
  });
2526
3351
  const memberIds = memberships
2527
3352
  .filter((membership) => isDateRangeActive(membership, today))
2528
3353
  .sort(compareMembership)
2529
3354
  .map((membership) => membership.memberId);
2530
3355
  if (memberIds.length === 0) {
2531
- throw new common_1.ConflictException(`${label} orgUnit ${orgUnitId} and position ${positionId} has no active membership`);
3356
+ throw new common_1.ConflictException(`${label} 找不到${subject}:組織「${scope.name}」的職位「${await this.readPositionName(manager, positionId)}」目前沒有任何有效成員`);
2532
3357
  }
2533
3358
  return uniqueTexts(memberIds);
2534
3359
  }
2535
- async readOrgUnitScopeIds(manager, orgUnitId, includeDescendants) {
3360
+ // Names only make an error readable, so a row that has gone missing falls
3361
+ // back to its id instead of hiding the real problem.
3362
+ async readPositionName(manager, positionId) {
3363
+ const position = await manager
3364
+ .getRepository(position_entity_1.PositionEntity)
3365
+ .findOne({ where: { id: positionId } });
3366
+ return position?.name ?? positionId;
3367
+ }
3368
+ /** The org unit's name and the ids it covers, or `null` once it is gone. */
3369
+ async readOrgUnitScope(manager, orgUnitId, includeDescendants) {
2536
3370
  const orgUnitRepository = manager.getRepository(org_unit_entity_1.OrgUnitEntity);
2537
3371
  const orgUnit = await orgUnitRepository.findOne({
2538
3372
  where: { deletedAt: (0, typeorm_2.IsNull)(), id: orgUnitId },
2539
3373
  });
2540
3374
  if (!orgUnit) {
2541
- throw new common_1.ConflictException(`Org unit ${orgUnitId} was not found`);
3375
+ return null;
2542
3376
  }
2543
3377
  if (!includeDescendants) {
2544
- return [orgUnit.id];
3378
+ return { name: orgUnit.name, orgUnitIds: [orgUnit.id] };
2545
3379
  }
2546
3380
  const descendants = await orgUnitRepository
2547
3381
  .createQueryBuilder('orgUnit')
2548
3382
  .where('orgUnit.deleted_at IS NULL')
2549
3383
  .andWhere('orgUnit.path <@ :path', { path: orgUnit.path })
2550
3384
  .getMany();
2551
- return uniqueTexts([orgUnit.id, ...descendants.map((unit) => unit.id)]);
3385
+ return {
3386
+ name: orgUnit.name,
3387
+ orgUnitIds: uniqueTexts([
3388
+ orgUnit.id,
3389
+ ...descendants.map((unit) => unit.id),
3390
+ ]),
3391
+ };
2552
3392
  }
2553
3393
  async processExclusiveGateway(manager, instance, token, node) {
2554
3394
  if (node.data.direction !== 'split') {
@@ -2630,7 +3470,7 @@ let WorkflowEngineService = class WorkflowEngineService {
2630
3470
  const recipients = (0, workflow_graph_1.isNotifyRecipientsEmpty)(action.recipients) &&
2631
3471
  (0, workflow_graph_1.readNotifyWebhookTargets)(action).length > 0
2632
3472
  ? []
2633
- : await this.resolveApproverResolver(manager, instance, action.recipients, `知會節點「${node.data.label}」`);
3473
+ : await this.resolveApproverResolver(manager, instance, action.recipients, `知會節點「${node.data.label}」`, '通知對象');
2634
3474
  const recipientMemberIds = uniqueTexts(recipients.map((recipient) => recipient.memberId));
2635
3475
  await this.notificationService.createServiceTaskNotifications({
2636
3476
  instance,
@@ -2658,11 +3498,7 @@ let WorkflowEngineService = class WorkflowEngineService {
2658
3498
  tokenId: token.id,
2659
3499
  }, action.webhooks)
2660
3500
  : [];
2661
- await manager.getRepository(workflow_token_entity_1.WorkflowTokenEntity).save({
2662
- ...token,
2663
- consumedAt: new Date(),
2664
- status: workflow_engine_enums_1.WorkflowTokenStatusEnum.CONSUMED,
2665
- });
3501
+ await this.consumeToken(manager, token);
2666
3502
  await activityRepository.save(activityRepository.create({
2667
3503
  actorMemberId: null,
2668
3504
  eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TOKEN_ADVANCED,
@@ -2901,7 +3737,8 @@ let WorkflowEngineService = class WorkflowEngineService {
2901
3737
  }
2902
3738
  const candidateRepository = manager.getRepository(task_candidate_entity_1.TaskCandidateEntity);
2903
3739
  const candidates = (await candidateRepository.find({ where: { taskId: (0, typeorm_2.In)([...taskIds]) } })).filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING ||
2904
- candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED);
3740
+ candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED ||
3741
+ candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.WAITING);
2905
3742
  if (candidates.length === 0) {
2906
3743
  return;
2907
3744
  }
@@ -3188,6 +4025,58 @@ function buildDryRunExpressionContext(input, lastDecision) {
3188
4025
  function isRecord(value) {
3189
4026
  return typeof value === 'object' && value !== null && !Array.isArray(value);
3190
4027
  }
4028
+ // The members whose grades decide a grade-gated delegation: none when the
4029
+ // delegation leaves the seat with its principal.
4030
+ function readGradeGateMemberIds(principalMemberId, resolution) {
4031
+ return resolution.finalAssigneeMemberId === principalMemberId
4032
+ ? []
4033
+ : [principalMemberId, resolution.finalAssigneeMemberId];
4034
+ }
4035
+ /**
4036
+ * On a manager-chain node the delegate must hold at least the grade of the
4037
+ * member they replace: anything lower is not applied, the member stays and
4038
+ * the refused delegation is returned so it can be recorded. Resolution does
4039
+ * not walk further down the delegation chain once it has been refused. A
4040
+ * member without a grade (or a delegate that stays the member) is never
4041
+ * constrained. `levels` holds the grades of `readGradeGateMemberIds`.
4042
+ */
4043
+ function applyGradeGate(principalMemberId, resolution, gradeGated, levels) {
4044
+ if (!gradeGated || resolution.finalAssigneeMemberId === principalMemberId) {
4045
+ return resolution;
4046
+ }
4047
+ const principalLevel = levels.get(principalMemberId) ?? null;
4048
+ if (principalLevel === null) {
4049
+ return resolution;
4050
+ }
4051
+ const agentLevel = levels.get(resolution.finalAssigneeMemberId) ?? null;
4052
+ if (agentLevel !== null && agentLevel >= principalLevel) {
4053
+ return resolution;
4054
+ }
4055
+ return {
4056
+ delegationChain: [],
4057
+ finalAssigneeMemberId: principalMemberId,
4058
+ skippedDelegation: {
4059
+ agentLevel,
4060
+ agentMemberId: resolution.finalAssigneeMemberId,
4061
+ principalLevel,
4062
+ principalMemberId,
4063
+ reason: 'AGENT_POSITION_LEVEL_TOO_LOW',
4064
+ },
4065
+ };
4066
+ }
4067
+ function readNodeLabelKey(instanceId, nodeId) {
4068
+ return `${instanceId}\u0000${nodeId}`;
4069
+ }
4070
+ /**
4071
+ * The node's display name from an instance's workflow snapshot, or `null`
4072
+ * when the instance or node is missing, or the node carries no usable label
4073
+ * (older snapshots).
4074
+ */
4075
+ function readWorkflowNodeLabel(workflow, nodeId) {
4076
+ const label = workflow?.nodes.find((candidate) => candidate.id === nodeId)
4077
+ ?.data.label;
4078
+ return typeof label === 'string' && label.trim() ? label : null;
4079
+ }
3191
4080
  function readWorkflowNodeOrThrow(workflow, nodeId) {
3192
4081
  const node = workflow.nodes.find((candidate) => candidate.id === nodeId);
3193
4082
  if (!node) {
@@ -3288,22 +4177,26 @@ function edgeHasCondition(edge) {
3288
4177
  return Boolean(edge.data.condition?.trim() ||
3289
4178
  (edge.data.conditionFieldKey && edge.data.conditionOperator));
3290
4179
  }
3291
- function readMemberIdFromValue(value, label) {
3292
- return readMemberIdsFromValue(value, label)[0] ?? '';
4180
+ function readMissingOrgUnitMessage(label, subject, orgUnitId) {
4181
+ return `${label} 找不到${subject}:組織(${orgUnitId})已不存在,請在流程設計器重新選擇組織`;
3293
4182
  }
3294
- function readMemberIdsFromValue(value, label) {
4183
+ function findMemberIdsInValue(value) {
3295
4184
  if (typeof value === 'string' && value.trim()) {
3296
4185
  return [value.trim()];
3297
4186
  }
3298
4187
  if (Array.isArray(value)) {
3299
- const memberIds = value
4188
+ return uniqueTexts(value
3300
4189
  .filter((candidate) => typeof candidate === 'string' && Boolean(candidate.trim()))
3301
- .map((candidate) => candidate.trim());
3302
- if (memberIds.length > 0) {
3303
- return uniqueTexts(memberIds);
3304
- }
4190
+ .map((candidate) => candidate.trim()));
4191
+ }
4192
+ return [];
4193
+ }
4194
+ function readMemberIdsFromValue(value, label, subject = '簽核人', source = '') {
4195
+ const memberIds = findMemberIdsInValue(value);
4196
+ if (memberIds.length === 0) {
4197
+ throw new common_1.ConflictException(`${label} ${source}找不到${subject}`);
3305
4198
  }
3306
- throw new common_1.ConflictException(`${label} did not resolve to a member id`);
4199
+ return memberIds;
3307
4200
  }
3308
4201
  function uniqueTexts(values) {
3309
4202
  return [...new Set(values.filter((value) => value.trim()))];
@@ -3318,6 +4211,85 @@ function uniqueRuntimeCandidates(candidates) {
3318
4211
  return true;
3319
4212
  });
3320
4213
  }
4214
+ // A task signs in order only when its snapshot says so and its candidates carry
4215
+ // a sequence; a SEQUENTIAL task from before ordering existed behaves as before.
4216
+ function isOrderedSequentialTask(task, candidates) {
4217
+ return (readDecisionPolicySnapshot(task).type === 'SEQUENTIAL' &&
4218
+ candidates.some((candidate) => candidate.sequence !== null));
4219
+ }
4220
+ // Every read of the setting goes through here so an explicit `null` behaves
4221
+ // exactly like an absent setting.
4222
+ function isInitiatorExcluded(node) {
4223
+ return Boolean(node.data.excludeInitiator);
4224
+ }
4225
+ // Everyone a node that excludes the initiator keeps out of its approvers: the
4226
+ // applicant and, when someone filed the document for them, that person too
4227
+ // (authority rule: the creator must not approve what they created).
4228
+ function readInitiatorAndSubmitter(instance) {
4229
+ const submitterMemberId = (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance);
4230
+ return submitterMemberId === null
4231
+ ? [instance.initiatorMemberId]
4232
+ : [instance.initiatorMemberId, submitterMemberId];
4233
+ }
4234
+ function isOrgManagerChainNode(node) {
4235
+ return node.data.approverResolver.type === 'ORG_MANAGER_CHAIN';
4236
+ }
4237
+ // Candidates of a manager-chain node that came from the chain (or replaced the
4238
+ // excluded initiator's seat); ad-hoc approvers are chosen by a person instead.
4239
+ function isGradeGatedCandidate(node, sourceType) {
4240
+ return (isOrgManagerChainNode(node) &&
4241
+ (sourceType === 'ORG_MANAGER_CHAIN' ||
4242
+ sourceType === 'EXCLUDE_INITIATOR_MANAGER'));
4243
+ }
4244
+ // Source of the additive `skippedDelegations` payload field (absent when
4245
+ // nothing was refused, so existing payloads stay byte-identical). One record
4246
+ // per principal and delegate pair.
4247
+ function collectSkippedDelegations(delegatedCandidates, excludeInitiator, excludedMemberIds) {
4248
+ const seen = new Set();
4249
+ return delegatedCandidates.flatMap((candidate) => {
4250
+ const skipped = candidate.skippedDelegation;
4251
+ // A seat the initiator filter removes is not on the task, so a refusal on
4252
+ // it has nothing to report. Rows only the dedupe drops are still on it.
4253
+ if (!skipped ||
4254
+ (excludeInitiator &&
4255
+ isExcludedMemberOnCandidatePath(candidate, excludedMemberIds))) {
4256
+ return [];
4257
+ }
4258
+ const key = `${skipped.principalMemberId}\u0000${skipped.agentMemberId}`;
4259
+ if (seen.has(key)) {
4260
+ return [];
4261
+ }
4262
+ seen.add(key);
4263
+ return [skipped];
4264
+ });
4265
+ }
4266
+ // A delegate acting for the initiator is still the initiator approving their
4267
+ // own document, so the initiator anywhere on the delegation path (the
4268
+ // pre-delegation member, any hop, or the final assignee) drops the candidate.
4269
+ // The submitter is held to exactly the same rule.
4270
+ function isExcludedMemberOnCandidatePath(candidate, excludedMemberIds) {
4271
+ return excludedMemberIds.some((excludedMemberId) => candidate.memberId === excludedMemberId ||
4272
+ candidate.originalMemberId === excludedMemberId ||
4273
+ candidate.delegationChain.some((step) => step.from === excludedMemberId || step.to === excludedMemberId));
4274
+ }
4275
+ // The initiator filter runs on the delegated list before the dedupe, so a
4276
+ // legitimate approver is never shadowed by an excluded entry for the same
4277
+ // final assignee. Without the exclusion this is the plain dedupe.
4278
+ function selectNodeCandidates(delegatedCandidates, excludeInitiator, excludedMemberIds) {
4279
+ return uniqueRuntimeCandidates(excludeInitiator
4280
+ ? delegatedCandidates.filter((candidate) => !isExcludedMemberOnCandidatePath(candidate, excludedMemberIds))
4281
+ : delegatedCandidates);
4282
+ }
4283
+ // A COUNT threshold above the people left would never be reached once the
4284
+ // initiator is gone, so it is capped at who remains ("everyone else").
4285
+ // PERCENTAGE is computed from the actual candidate count and needs nothing.
4286
+ function capDecisionPolicyToCandidateCount(policy, candidateCount) {
4287
+ return policy?.type === 'QUORUM' &&
4288
+ policy.thresholdType === 'COUNT' &&
4289
+ policy.threshold > candidateCount
4290
+ ? { ...policy, threshold: Math.max(candidateCount, 1) }
4291
+ : policy;
4292
+ }
3321
4293
  function uniqueTasksById(tasks) {
3322
4294
  const seen = new Set();
3323
4295
  return tasks.filter((task) => {
@@ -3734,9 +4706,6 @@ function compareMembership(left, right) {
3734
4706
  }
3735
4707
  return right.effectiveFrom.localeCompare(left.effectiveFrom);
3736
4708
  }
3737
- function toDateOnlyString(value) {
3738
- return value.toISOString().slice(0, 10);
3739
- }
3740
4709
  function canReadAllWorkflows(scope) {
3741
4710
  return (scope.roles.includes('BPM_ADMIN') ||
3742
4711
  scope.permissions.some((permission) => WORKFLOW_READ_ALL_PERMISSIONS.has(permission)));
@@ -3767,9 +4736,26 @@ function validateSubmittedFormData(schema, formData) {
3767
4736
  .map((field) => field.label || field.fieldKey)
3768
4737
  .join(', ')}`);
3769
4738
  }
3770
- const tableErrors = visibleFields
3771
- .filter(form_1.isTableFieldDefinition)
3772
- .flatMap((field) => validateSubmittedTableValue(field, formData[field.fieldKey], isSubmittedFormFieldRequired(field, schema.fields, formData)));
4739
+ // A `DYNAMIC_FORM` resolver reads a member value whether or not its field is
4740
+ // visible, so a hidden field still holding a value must be well-formed too.
4741
+ // Whether a hidden field is *required* stays a visibility question above.
4742
+ const memberErrors = (0, form_member_value_validator_1.readSubmittedMemberValueErrors)(schema.fields.filter((field) => visibleFields.includes(field) ||
4743
+ isSubmittedFieldValuePresent(formData[field.fieldKey])), formData);
4744
+ if (memberErrors.length > 0) {
4745
+ throw new common_1.BadRequestException(memberErrors.join('; '));
4746
+ }
4747
+ const tableErrors = [
4748
+ ...visibleFields
4749
+ .filter(form_1.isTableFieldDefinition)
4750
+ .flatMap((field) => validateSubmittedTableValue(field, formData[field.fieldKey], isSubmittedFormFieldRequired(field, schema.fields, formData))),
4751
+ // A hidden table is not "visible", but its stored value still has to be a
4752
+ // well-formed table under the row cap: only the required / minimum checks
4753
+ // are skipped, since nobody can fill the table in.
4754
+ ...schema.fields
4755
+ .filter(form_1.isTableFieldDefinition)
4756
+ .filter((field) => field.hidden === true)
4757
+ .flatMap((field) => validateSubmittedTableValue(field, formData[field.fieldKey], false, true)),
4758
+ ];
3773
4759
  if (tableErrors.length > 0) {
3774
4760
  throw new common_1.BadRequestException(tableErrors.join('; '));
3775
4761
  }
@@ -3780,28 +4766,32 @@ function validateSubmittedFormData(schema, formData) {
3780
4766
  * required / shape / row count are enforced server side; `minimum`, `maxLength`
3781
4767
  * and friends stay a frontend hint.
3782
4768
  */
3783
- function validateSubmittedTableValue(field, value, required) {
4769
+ function validateSubmittedTableValue(field, value, required, shapeOnly = false) {
3784
4770
  const path = `formData.${field.fieldKey}`;
3785
4771
  if (typeof value === 'undefined' || value === null) {
3786
- return required || readTableMinRows(field) > 0
3787
- ? [`${path} must include at least ${readTableRequiredRowCount(field)} row(s)`]
3788
- : [];
4772
+ return shapeOnly
4773
+ ? []
4774
+ : required || readTableMinRows(field) > 0
4775
+ ? [`${path} must include at least ${readTableRequiredRowCount(field)} row(s)`]
4776
+ : [];
3789
4777
  }
3790
4778
  if (!Array.isArray(value)) {
3791
4779
  return [`${path} must be an array of rows`];
3792
4780
  }
3793
- const rowErrors = value.flatMap((row, index) => validateSubmittedTableRow(field, row, `${path}[${index}]`));
4781
+ const rowErrors = value.flatMap((row, index) => validateSubmittedTableRow(field, row, `${path}[${index}]`, shapeOnly));
3794
4782
  // A malformed row cannot be counted against min/max meaningfully; report the
3795
4783
  // shape problem first so the submitter fixes the real cause.
3796
4784
  if (rowErrors.length > 0) {
3797
4785
  return rowErrors;
3798
4786
  }
3799
- return validateSubmittedTableRowCount(field, value.length, required, path);
4787
+ return validateSubmittedTableRowCount(field, value.length, required, path, shapeOnly);
3800
4788
  }
3801
- function validateSubmittedTableRowCount(field, rowCount, required, path) {
3802
- const minRows = required
3803
- ? Math.max(readTableMinRows(field), 1)
3804
- : readTableMinRows(field);
4789
+ function validateSubmittedTableRowCount(field, rowCount, required, path, shapeOnly = false) {
4790
+ const minRows = shapeOnly
4791
+ ? 0
4792
+ : required
4793
+ ? Math.max(readTableMinRows(field), 1)
4794
+ : readTableMinRows(field);
3805
4795
  const maxRows = readTableMaxRows(field);
3806
4796
  return [
3807
4797
  ...(rowCount < minRows
@@ -3812,7 +4802,7 @@ function validateSubmittedTableRowCount(field, rowCount, required, path) {
3812
4802
  : []),
3813
4803
  ];
3814
4804
  }
3815
- function validateSubmittedTableRow(field, row, path) {
4805
+ function validateSubmittedTableRow(field, row, path, shapeOnly = false) {
3816
4806
  if (!isRecord(row)) {
3817
4807
  return [`${path} must be an object`];
3818
4808
  }
@@ -3823,10 +4813,12 @@ function validateSubmittedTableRow(field, row, path) {
3823
4813
  const cellErrors = Object.entries(row)
3824
4814
  .filter(([columnKey, cellValue]) => columnsByKey.has(columnKey) && !(0, form_1.isFormTableCellValue)(cellValue))
3825
4815
  .map(([columnKey]) => `${path}.${columnKey} is not a valid cell value`);
3826
- const requiredErrors = field.columns
3827
- .filter((column) => isSubmittedTableColumnRequired(column) &&
3828
- !isSubmittedFieldValuePresent(readRowCellValue(row, column.fieldKey)))
3829
- .map((column) => `${path}.${column.fieldKey} is required`);
4816
+ const requiredErrors = shapeOnly
4817
+ ? []
4818
+ : field.columns
4819
+ .filter((column) => isSubmittedTableColumnRequired(column) &&
4820
+ !isSubmittedFieldValuePresent(readRowCellValue(row, column.fieldKey)))
4821
+ .map((column) => `${path}.${column.fieldKey} is required`);
3830
4822
  return [...unknownKeyErrors, ...cellErrors, ...requiredErrors];
3831
4823
  }
3832
4824
  /**
@@ -3871,6 +4863,11 @@ function isFormDefinitionSchema(value) {
3871
4863
  value.fields.every((field) => isRecord(field)));
3872
4864
  }
3873
4865
  function isSubmittedFormFieldVisible(field, fields, values) {
4866
+ // A hidden (store-only) field is never shown, so nobody can fill it in:
4867
+ // treat it like a false `visibleWhen`. Its submitted value is still kept.
4868
+ if (field.hidden) {
4869
+ return false;
4870
+ }
3874
4871
  return field.visibleWhen
3875
4872
  ? evaluateFormConditionExpression(field.visibleWhen, fields, values, true)
3876
4873
  : true;
@@ -3998,6 +4995,84 @@ function isSubmittedFieldValuePresent(value) {
3998
4995
  function isNumberFormField(field) {
3999
4996
  return field.type === 'number' || field.type === 'money';
4000
4997
  }
4998
+ // A node that is skipped and then has nowhere to go keeps saying why it was
4999
+ // skipped, then why the branch ends; any other node just says why it ends.
5000
+ function readDryRunStepResultMessage(assignmentMessage, stopMessage, defaultMessage, skipped) {
5001
+ const message = assignmentMessage ?? defaultMessage;
5002
+ if (stopMessage === null) {
5003
+ return message;
5004
+ }
5005
+ return skipped ? `${endSentence(message)}${stopMessage}` : stopMessage;
5006
+ }
5007
+ // The skip reason and the stop reason are two sentences; a reason that does not
5008
+ // already end in full-stop punctuation gets one so they do not run together.
5009
+ function endSentence(message) {
5010
+ return /[。!?.!?]$/.test(message) ? message : `${message}。`;
5011
+ }
5012
+ // Why a manager resolver's fixed fallback cannot take over when the initiator
5013
+ // has no manager, or `null` when it can: like the runtime, it must exist, name
5014
+ // a member, and not be the initiator unless the node allows self-approval.
5015
+ function readDryRunFallbackProblem(initiatorMemberId, fallback) {
5016
+ if (!fallback || fallback.type !== 'DIRECT') {
5017
+ return 'NOT_SET';
5018
+ }
5019
+ const memberId = fallback.memberId.trim();
5020
+ if (memberId === '') {
5021
+ return 'BLANK_MEMBER';
5022
+ }
5023
+ return memberId === initiatorMemberId && !fallback.allowInitiatorSelfApproval
5024
+ ? 'IS_INITIATOR'
5025
+ : null;
5026
+ }
5027
+ function readDryRunFallbackMemberIds(input, fallback) {
5028
+ return readDryRunFallbackProblem(input.initiatorMemberId, fallback) ===
5029
+ null && fallback?.type === 'DIRECT'
5030
+ ? [fallback.memberId.trim()]
5031
+ : [];
5032
+ }
5033
+ function readDryRunNodeName(node) {
5034
+ return node.data.label?.trim() || node.id;
5035
+ }
5036
+ // The dry run cannot look the organisation up, so these resolvers stand in
5037
+ // with a placeholder that names what the runtime would resolve.
5038
+ function readDryRunPlaceholderAssignee(resolver) {
5039
+ if (resolver.type === 'POSITION') {
5040
+ return `position:${resolver.positionId}`;
5041
+ }
5042
+ if (resolver.type === 'ORG_UNIT_MEMBER') {
5043
+ return `orgUnitMember:${resolver.orgUnitId}`;
5044
+ }
5045
+ if (resolver.type === 'ORG_UNIT_POSITION') {
5046
+ return `orgUnitPosition:${resolver.orgUnitId}:${resolver.positionId}`;
5047
+ }
5048
+ if (resolver.type === 'ORG_UNIT_MANAGER') {
5049
+ return `orgUnitManager:${resolver.orgUnitId}`;
5050
+ }
5051
+ return resolver.type === 'ORG_MANAGER_CHAIN'
5052
+ ? `orgManagerChain:${resolver.minPositionLevel}`
5053
+ : resolver.type;
5054
+ }
5055
+ function readDryRunUnresolvedApproverMessage(resolver, nodeName, initiatorMemberId) {
5056
+ if (resolver.type === 'DIRECT') {
5057
+ return `簽核節點「${nodeName}」尚未選擇簽核人,請在流程設計器點選此節點設定簽核人`;
5058
+ }
5059
+ if (resolver.type === 'DYNAMIC_FORM') {
5060
+ return `簽核節點「${nodeName}」依表單欄位找不到簽核人,請確認試跑資料中該欄位有填人員`;
5061
+ }
5062
+ if (resolver.type === 'EXPRESSION') {
5063
+ return `簽核節點「${nodeName}」依運算式找不到簽核人,請確認運算式的結果`;
5064
+ }
5065
+ const fallbackProblem = resolver.type === 'ORG_MANAGER'
5066
+ ? readDryRunFallbackProblem(initiatorMemberId, resolver.fallback)
5067
+ : null;
5068
+ if (fallbackProblem === 'BLANK_MEMBER') {
5069
+ return `簽核節點「${nodeName}」找不到主管,且改派固定人未設定`;
5070
+ }
5071
+ if (fallbackProblem === 'IS_INITIATOR') {
5072
+ return `簽核節點「${nodeName}」找不到主管,且改派固定人是申請人本人,此節點未允許申請人自簽`;
5073
+ }
5074
+ return `簽核節點「${nodeName}」依申請人的組織找不到主管,請確認試跑時的申請人資料`;
5075
+ }
4001
5076
  function createDryRunStep(input) {
4002
5077
  return Object.assign(new workflow_dry_run_object_1.WorkflowDryRunStepObject(), {
4003
5078
  assigneeMemberId: input.assigneeMemberId,
@@ -4074,4 +5149,34 @@ function mapDecisionToResolution(action) {
4074
5149
  return notification_enums_1.NotificationResolutionEnum.TRANSFERRED;
4075
5150
  }
4076
5151
  }
5152
+ // The limit is stated in characters, so a character outside the BMP counts
5153
+ // once rather than as its two UTF-16 code units.
5154
+ function countCodePoints(value) {
5155
+ return Array.from(value).length;
5156
+ }
5157
+ function normalizeBusinessKey(businessKey) {
5158
+ const trimmed = businessKey?.trim() ?? '';
5159
+ if (trimmed === '') {
5160
+ return null;
5161
+ }
5162
+ if (countCodePoints(trimmed) > exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH) {
5163
+ throw new common_1.BadRequestException(`業務鍵長度不可超過 ${exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH} 個字元`);
5164
+ }
5165
+ return trimmed;
5166
+ }
5167
+ /**
5168
+ * Turns Postgres rejecting a duplicate (template, business key) into a
5169
+ * Conflict. Any other failure, including other unique violations inside the
5170
+ * same transaction, surfaces as it is.
5171
+ */
5172
+ function throwBusinessKeyConflictOrRethrow(error, businessKey) {
5173
+ const { code, constraint, driverError } = (error ?? {});
5174
+ if (businessKey !== null &&
5175
+ (code ?? driverError?.code) === '23505' &&
5176
+ (constraint ?? driverError?.constraint) ===
5177
+ APPROVAL_INSTANCE_BUSINESS_KEY_INDEX) {
5178
+ throw new common_1.ConflictException(`此範本已有相同業務鍵(${businessKey})的簽核單`);
5179
+ }
5180
+ throw error;
5181
+ }
4077
5182
  //# sourceMappingURL=workflow-engine.service.js.map