@rytass/bpm-core-nestjs-module 0.13.4 → 0.14.0

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