@rytass/bpm-core-nestjs-module 0.23.0 → 0.24.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 (46) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +68 -1
  3. package/package.json +2 -2
  4. package/src/lib/migrations/0000000033000-activity-log-advance-failed-index.d.ts +35 -0
  5. package/src/lib/migrations/0000000033000-activity-log-advance-failed-index.js +51 -0
  6. package/src/lib/migrations/0000000033000-activity-log-advance-failed-index.js.map +1 -0
  7. package/src/lib/migrations/index.d.ts +1 -0
  8. package/src/lib/migrations/index.js +3 -0
  9. package/src/lib/migrations/index.js.map +1 -1
  10. package/src/lib/notification/notification.service.d.ts +3 -2
  11. package/src/lib/notification/notification.service.js +2 -1
  12. package/src/lib/notification/notification.service.js.map +1 -1
  13. package/src/lib/template/template.service.js +17 -0
  14. package/src/lib/template/template.service.js.map +1 -1
  15. package/src/lib/template/workflow-definition.validator.js +12 -6
  16. package/src/lib/template/workflow-definition.validator.js.map +1 -1
  17. package/src/lib/workflow-engine/dto/admin-cancel-approval-instance.input.d.ts +9 -0
  18. package/src/lib/workflow-engine/dto/admin-cancel-approval-instance.input.js +24 -0
  19. package/src/lib/workflow-engine/dto/admin-cancel-approval-instance.input.js.map +1 -0
  20. package/src/lib/workflow-engine/dto/reassign-member-tasks.input.d.ts +2 -0
  21. package/src/lib/workflow-engine/dto/reassign-member-tasks.input.js +8 -0
  22. package/src/lib/workflow-engine/dto/reassign-member-tasks.input.js.map +1 -1
  23. package/src/lib/workflow-engine/index.d.ts +2 -0
  24. package/src/lib/workflow-engine/index.js +2 -0
  25. package/src/lib/workflow-engine/index.js.map +1 -1
  26. package/src/lib/workflow-engine/instance-advance-failure.d.ts +28 -0
  27. package/src/lib/workflow-engine/instance-advance-failure.js +20 -0
  28. package/src/lib/workflow-engine/instance-advance-failure.js.map +1 -0
  29. package/src/lib/workflow-engine/stuck-approval-instance.object.d.ts +22 -0
  30. package/src/lib/workflow-engine/stuck-approval-instance.object.js +107 -0
  31. package/src/lib/workflow-engine/stuck-approval-instance.object.js.map +1 -0
  32. package/src/lib/workflow-engine/stuck-approval-instance.query.d.ts +22 -0
  33. package/src/lib/workflow-engine/stuck-approval-instance.query.js +139 -0
  34. package/src/lib/workflow-engine/stuck-approval-instance.query.js.map +1 -0
  35. package/src/lib/workflow-engine/workflow-engine.enums.d.ts +11 -0
  36. package/src/lib/workflow-engine/workflow-engine.enums.js +16 -1
  37. package/src/lib/workflow-engine/workflow-engine.enums.js.map +1 -1
  38. package/src/lib/workflow-engine/workflow-engine.mutations.d.ts +2 -0
  39. package/src/lib/workflow-engine/workflow-engine.mutations.js +31 -3
  40. package/src/lib/workflow-engine/workflow-engine.mutations.js.map +1 -1
  41. package/src/lib/workflow-engine/workflow-engine.queries.d.ts +3 -1
  42. package/src/lib/workflow-engine/workflow-engine.queries.js +34 -0
  43. package/src/lib/workflow-engine/workflow-engine.queries.js.map +1 -1
  44. package/src/lib/workflow-engine/workflow-engine.service.d.ts +207 -3
  45. package/src/lib/workflow-engine/workflow-engine.service.js +745 -45
  46. package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
@@ -47,10 +47,12 @@ const approval_instance_field_filter_compiler_1 = require("./approval-instance-f
47
47
  const approval_instance_filterable_field_policy_1 = require("./approval-instance-filterable-field.policy");
48
48
  const text_field_pattern_test_1 = require("./text-field-pattern-test");
49
49
  const form_data_fingerprint_1 = require("./form-data-fingerprint");
50
+ const instance_advance_failure_1 = require("./instance-advance-failure");
50
51
  const instance_submitter_1 = require("./instance-submitter");
51
52
  const task_decision_entity_1 = require("./task-decision.entity");
52
53
  const task_candidate_entity_1 = require("./task-candidate.entity");
53
54
  const task_entity_1 = require("./task.entity");
55
+ const stuck_approval_instance_query_1 = require("./stuck-approval-instance.query");
54
56
  const workflow_engine_enums_1 = require("./workflow-engine.enums");
55
57
  const workflow_condition_evaluator_1 = require("./workflow-condition-evaluator");
56
58
  const workflow_form_value_1 = require("./workflow-form-value");
@@ -187,6 +189,22 @@ const INACTIVE_MEMBER_NAME_LOCALE = 'zh-Hant-TW';
187
189
  * every load of the member screen.
188
190
  */
189
191
  const INACTIVE_MEMBER_COUNT_CONCURRENCY = 5;
192
+ /**
193
+ * How many times an `ADVANCE_FAILED` row is tried before giving up. A missed
194
+ * row hides the instance from the stuck list, and a second try is safe: the
195
+ * duplicate check runs under the instance lock.
196
+ */
197
+ const ADVANCE_FAILURE_RECORD_ATTEMPTS = 2;
198
+ // What each way of being skipped writes into the `TOKEN_ADVANCED` payload. The
199
+ // two chain settings that can skip a node share one key.
200
+ const SKIPPED_RUNTIME_TASK_PAYLOADS = {
201
+ INITIATOR: { skippedByInitiatorExclusion: true },
202
+ MANAGER_CHAIN_REACHED: { skippedByManagerChainReached: true },
203
+ NODE_ACTORS: { skippedByNodeActorExclusion: true },
204
+ };
205
+ // Why a chain that continues an earlier one, or skips when its start already
206
+ // qualifies, is refused anywhere task creation did not work its context out.
207
+ const ORG_MANAGER_CHAIN_CONTEXT_REFUSAL = '接續前一段主管鏈、或「起點本人已達簽到職等時略過」,只能在建立簽核任務時使用,此處不支援。';
190
208
  /**
191
209
  * ADR 27 §4.2: an approver-filled value the provider no longer offers answers
192
210
  * 400 with `formDataErrors` naming the field or table cell, `code` kept. Other
@@ -361,6 +379,151 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
361
379
  }, Promise.resolve());
362
380
  return result;
363
381
  }
382
+ /**
383
+ * Runs a public operation whose engine transaction may fail with an
384
+ * {@link InstanceAdvanceFailure}. The transaction has rolled back by the time
385
+ * it lands here, so the record is written in a transaction of its own, and
386
+ * the caller then gets the original error: same class, same message, as if
387
+ * none of this existed. The wrapper type never leaves this method.
388
+ */
389
+ async withAdvanceFailureRecorded(run) {
390
+ try {
391
+ return await run();
392
+ }
393
+ catch (error) {
394
+ if (error instanceof instance_advance_failure_1.InstanceAdvanceFailure) {
395
+ await this.recordAdvanceFailure(error);
396
+ throw error.originalError;
397
+ }
398
+ throw error;
399
+ }
400
+ }
401
+ /**
402
+ * Writes the `ADVANCE_FAILED` row for a rolled-back attempt, trying again
403
+ * once if the write fails. If every try fails the failure is logged as an
404
+ * error with what the row would have held, and swallowed: the caller must
405
+ * see the error that stopped the case, not the one that stopped its record.
406
+ * Such an instance is missing from the stuck list, so the log is the only
407
+ * trace of it.
408
+ */
409
+ async recordAdvanceFailure(failure, attempt = 1) {
410
+ const { context, message } = failure;
411
+ try {
412
+ await this.writeAdvanceFailure(failure);
413
+ }
414
+ catch (recordError) {
415
+ const reason = recordError instanceof Error ? recordError.message : 'unknown error';
416
+ if (attempt < ADVANCE_FAILURE_RECORD_ATTEMPTS) {
417
+ this.logger.warn(`Could not record ADVANCE_FAILED for instance ${context.instanceId} (attempt ${attempt}), trying again: ${reason}`);
418
+ return this.recordAdvanceFailure(failure, attempt + 1);
419
+ }
420
+ this.logger.error(`Could not record ADVANCE_FAILED for instance ${context.instanceId}; it will not appear in the stuck list: ${reason}`, JSON.stringify({
421
+ action: context.action,
422
+ actorMemberId: context.actorMemberId,
423
+ automatic: context.automatic,
424
+ kind: context.kind,
425
+ message,
426
+ nodeId: context.nodeId,
427
+ taskId: context.taskId,
428
+ }));
429
+ }
430
+ }
431
+ /** One try of {@link recordAdvanceFailure}, in a transaction of its own. */
432
+ async writeAdvanceFailure(failure) {
433
+ const { context, message } = failure;
434
+ await this.approvalInstanceRepository.manager.transaction(async (manager) => {
435
+ // The same per-instance lock every decision takes, so two attempts
436
+ // failing together cannot both pass the check below and write twice.
437
+ await manager.query('SELECT pg_advisory_xact_lock(hashtext($1))', [
438
+ context.instanceId,
439
+ ]);
440
+ const unresolved = await this.readUnresolvedAdvanceFailures(manager, context.instanceId);
441
+ if (unresolved.some((logged) => isSameAdvanceFailure(logged, context, message))) {
442
+ return;
443
+ }
444
+ const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
445
+ await activityRepository.save(activityRepository.create({
446
+ actorMemberId: context.actorMemberId,
447
+ eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.ADVANCE_FAILED,
448
+ instanceId: context.instanceId,
449
+ nodeId: context.nodeId,
450
+ payload: {
451
+ action: context.action,
452
+ automatic: context.automatic,
453
+ kind: context.kind,
454
+ message,
455
+ nodeId: context.nodeId,
456
+ taskId: context.taskId,
457
+ },
458
+ taskId: context.taskId,
459
+ }));
460
+ });
461
+ }
462
+ /**
463
+ * The `ADVANCE_FAILED` rows of one instance that still describe how it is
464
+ * stuck, oldest first. Whether a decision went through is not read from
465
+ * `TASK_DECIDED`: a countersign leaves one behind from an earlier voter while
466
+ * the last vote is the one that failed. What counts is whether the situation
467
+ * the row describes still holds.
468
+ *
469
+ * - `DECISION`: the task is still open and the instance is still RUNNING.
470
+ * Once the task is done, cancelled, transferred or the instance has ended,
471
+ * the case has moved on or been taken over.
472
+ * - `RESUBMIT`: the instance is still RETURNED and no later
473
+ * `INSTANCE_RESUBMITTED` exists.
474
+ *
475
+ * The rule itself is `readUnresolvedInstanceFailures` in
476
+ * `stuck-approval-instance.query.ts`, which also holds the SQL the stuck list
477
+ * pages with; the two must agree. This is the duplicate check's side.
478
+ */
479
+ async readUnresolvedAdvanceFailures(manager, instanceId) {
480
+ const instance = await manager
481
+ .getRepository(approval_instance_entity_1.ApprovalInstanceEntity)
482
+ .findOne({ where: { id: instanceId } });
483
+ if (!instance) {
484
+ return [];
485
+ }
486
+ const byInstance = await this.readUnresolvedAdvanceFailuresByInstance(manager, [instance]);
487
+ return byInstance.get(instance.id) ?? [];
488
+ }
489
+ /**
490
+ * {@link readUnresolvedAdvanceFailures} for many instances at once, in two
491
+ * queries whatever their number: one for their activity rows, one for the
492
+ * tasks those rows name. Instances with nothing unresolved are absent.
493
+ */
494
+ async readUnresolvedAdvanceFailuresByInstance(manager, instances) {
495
+ if (instances.length === 0) {
496
+ return new Map();
497
+ }
498
+ const logs = await manager.getRepository(activity_log_entity_1.ActivityLogEntity).find({
499
+ order: { seq: 'ASC' },
500
+ where: {
501
+ eventType: (0, typeorm_2.In)([
502
+ workflow_engine_enums_1.ActivityLogEventTypeEnum.ADVANCE_FAILED,
503
+ workflow_engine_enums_1.ActivityLogEventTypeEnum.INSTANCE_RESUBMITTED,
504
+ ]),
505
+ instanceId: (0, typeorm_2.In)(instances.map((instance) => instance.id)),
506
+ },
507
+ });
508
+ const decidedTaskIds = [
509
+ ...new Set(logs.flatMap((log) => log.eventType === workflow_engine_enums_1.ActivityLogEventTypeEnum.ADVANCE_FAILED &&
510
+ log.taskId
511
+ ? [log.taskId]
512
+ : [])),
513
+ ];
514
+ const openTaskIds = new Set(decidedTaskIds.length === 0
515
+ ? []
516
+ : (await manager.getRepository(task_entity_1.TaskEntity).find({
517
+ where: {
518
+ id: (0, typeorm_2.In)(decidedTaskIds),
519
+ status: (0, typeorm_2.In)([...OPEN_TASK_STATUSES]),
520
+ },
521
+ })).map((task) => task.id));
522
+ return new Map(instances.flatMap((instance) => {
523
+ const unresolved = (0, stuck_approval_instance_query_1.readUnresolvedInstanceFailures)(instance, logs.filter((log) => log.instanceId === instance.id), openTaskIds);
524
+ return unresolved.length === 0 ? [] : [[instance.id, unresolved]];
525
+ }));
526
+ }
364
527
  /**
365
528
  * Queues a webhook for after the commit. A manager this service did not open
366
529
  * has no queue and no commit it can see, so that webhook goes out on the spot
@@ -746,6 +909,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
746
909
  });
747
910
  }
748
911
  async decideTask(input, options = {}) {
912
+ return this.withAdvanceFailureRecorded(() => this.decideTaskInTransaction(input, options));
913
+ }
914
+ async decideTaskInTransaction(input, options) {
749
915
  return this.runEngineTransaction(async (manager) => {
750
916
  const taskRepository = manager.getRepository(task_entity_1.TaskEntity);
751
917
  const taskCandidateRepository = manager.getRepository(task_candidate_entity_1.TaskCandidateEntity);
@@ -1167,6 +1333,18 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1167
1333
  },
1168
1334
  taskId: completedTask.id,
1169
1335
  }));
1336
+ // What an ADVANCE_FAILED row says if the move to the next stage fails.
1337
+ // An SLA timeout acts as the member `resolveTaskActorMemberId` picked,
1338
+ // who never pressed anything, so `automatic` is what tells them apart.
1339
+ const advanceContext = (action) => ({
1340
+ action,
1341
+ actorMemberId: input.decidedByMemberId,
1342
+ automatic: options.automatic ?? null,
1343
+ instanceId: instance.id,
1344
+ kind: 'DECISION',
1345
+ nodeId: claimedTask.nodeId,
1346
+ taskId: claimedTask.id,
1347
+ });
1170
1348
  // Resolve the recipient's "待簽" notification so its inline 同意/拒絕
1171
1349
  // actions disappear once the task is decided. When the task has ended
1172
1350
  // (rejected / returned, or an approval that completed it), every other
@@ -1201,30 +1379,34 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1201
1379
  if (await this.hasOpenTasksForTokenNode(manager, claimedTask.tokenId, claimedTask.nodeId)) {
1202
1380
  return decision;
1203
1381
  }
1204
- const token = await tokenRepository.findOne({
1205
- where: { id: claimedTask.tokenId },
1206
- });
1207
- if (!token) {
1208
- throw new common_1.NotFoundException(`Workflow token ${claimedTask.tokenId} was not found`);
1209
- }
1210
- if (token.status !== workflow_engine_enums_1.WorkflowTokenStatusEnum.WAITING) {
1211
- throw new common_1.ConflictException(`Workflow token ${token.id} is not waiting for a task decision`);
1212
- }
1213
- const activeToken = await tokenRepository.save({
1214
- ...token,
1215
- consumedAt: null,
1216
- status: workflow_engine_enums_1.WorkflowTokenStatusEnum.ACTIVE,
1382
+ // Everything from here to the next stage is the part that leaves the
1383
+ // case stuck when it fails: the decision rolls back with it.
1384
+ await runInstanceAdvanceStep(advanceContext('APPROVED'), async () => {
1385
+ const token = await tokenRepository.findOne({
1386
+ where: { id: claimedTask.tokenId },
1387
+ });
1388
+ if (!token) {
1389
+ throw new common_1.NotFoundException(`Workflow token ${claimedTask.tokenId} was not found`);
1390
+ }
1391
+ if (token.status !== workflow_engine_enums_1.WorkflowTokenStatusEnum.WAITING) {
1392
+ throw new common_1.ConflictException(`Workflow token ${token.id} is not waiting for a task decision`);
1393
+ }
1394
+ const activeToken = await tokenRepository.save({
1395
+ ...token,
1396
+ consumedAt: null,
1397
+ status: workflow_engine_enums_1.WorkflowTokenStatusEnum.ACTIVE,
1398
+ });
1399
+ await this.dispatchAdhocStageNotifications(manager, instance, claimedTask.nodeId, 'APPROVED');
1400
+ await this.advanceTokenToOutgoingNodes(manager, instance, activeToken, readWorkflowNodeOrThrow(instance.workflowSnapshot, claimedTask.nodeId));
1401
+ await this.processRunningInstance(manager, instance);
1217
1402
  });
1218
- await this.dispatchAdhocStageNotifications(manager, instance, claimedTask.nodeId, 'APPROVED');
1219
- await this.advanceTokenToOutgoingNodes(manager, instance, activeToken, readWorkflowNodeOrThrow(instance.workflowSnapshot, claimedTask.nodeId));
1220
- await this.processRunningInstance(manager, instance);
1221
1403
  return decision;
1222
1404
  }
1223
1405
  if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED) {
1224
1406
  return decision;
1225
1407
  }
1226
1408
  if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.RETURNED) {
1227
- await this.returnInstanceToNode(manager, instance, completedTask, returnToNodeId, decidedAt, input.decidedByMemberId);
1409
+ await runInstanceAdvanceStep(advanceContext('RETURNED'), () => this.returnInstanceToNode(manager, instance, completedTask, returnToNodeId, decidedAt, input.decidedByMemberId));
1228
1410
  return decision;
1229
1411
  }
1230
1412
  if (await this.handleAdhocPreApprovalRejection(manager, instance, completedTask, decidedAt, decisionComment)) {
@@ -1235,36 +1417,113 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1235
1417
  });
1236
1418
  }
1237
1419
  async cancelApprovalInstance(input) {
1420
+ return this.cancelApprovalInstanceInTransaction({
1421
+ actorMemberId: input.cancelledByMemberId,
1422
+ authorize: (instance) => {
1423
+ if (instance.initiatorMemberId !== input.cancelledByMemberId) {
1424
+ throw new common_1.ConflictException(`Approval instance ${instance.id} can only be cancelled by its initiator`);
1425
+ }
1426
+ },
1427
+ instanceId: input.instanceId,
1428
+ payload: {
1429
+ comment: input.comment?.trim() || null,
1430
+ },
1431
+ });
1432
+ }
1433
+ /**
1434
+ * Administrator: ends a running or returned instance on the initiator's
1435
+ * behalf, whoever started it. The reason is required; it is recorded as the
1436
+ * comment of the INSTANCE_CANCELLED activity (flagged
1437
+ * `cancelledByAdministrator`, so a host can tell this from the initiator
1438
+ * withdrawing) and sent to the initiator as an in-app notification. An
1439
+ * administrator ending their own instance is not told.
1440
+ *
1441
+ * The notification is written only after the cancellation has committed,
1442
+ * so nobody is told of a cancellation that then rolled back. A failure to
1443
+ * write it is logged and does not undo the cancellation.
1444
+ *
1445
+ * Like the other administrator operations of this service it does not check
1446
+ * who `cancelledByMemberId` is: the GraphQL mutation is guarded by
1447
+ * `@BPMAdminOnly()`, and a host calling this directly must check first. That
1448
+ * member is recorded as the actor of the INSTANCE_CANCELLED activity.
1449
+ */
1450
+ async adminCancelApprovalInstance(input) {
1451
+ const reason = input.reason.trim();
1452
+ // Refused before anything is read, so an empty reason is never mistaken
1453
+ // for a missing instance.
1454
+ if (!reason) {
1455
+ throw new common_1.BadRequestException('A reason is required to cancel an approval instance as an administrator');
1456
+ }
1457
+ const cancelled = await this.cancelApprovalInstanceInTransaction({
1458
+ actorMemberId: input.cancelledByMemberId,
1459
+ authorize: () => undefined,
1460
+ instanceId: input.instanceId,
1461
+ payload: {
1462
+ cancelledByAdministrator: true,
1463
+ comment: reason,
1464
+ },
1465
+ });
1466
+ if (cancelled.initiatorMemberId !== input.cancelledByMemberId) {
1467
+ await this.notifyInitiatorOfAdminCancel(cancelled, reason);
1468
+ }
1469
+ return cancelled;
1470
+ }
1471
+ /**
1472
+ * Tells the initiator, after the commit, that an administrator ended their
1473
+ * instance. Written outside any transaction, so external channels go out
1474
+ * straight away and an observer is told the rows are committed.
1475
+ */
1476
+ async notifyInitiatorOfAdminCancel(instance, reason) {
1477
+ try {
1478
+ await this.notificationService.createAdhocWorkflowNotifications({
1479
+ channels: null,
1480
+ instance,
1481
+ message: `案件「${instance.title}」已由管理員撤銷:${reason}`,
1482
+ payload: {
1483
+ nodeLabel: '管理員撤銷',
1484
+ reason,
1485
+ type: 'INSTANCE_CANCELLED_BY_ADMINISTRATOR',
1486
+ },
1487
+ recipientMemberIds: [instance.initiatorMemberId],
1488
+ });
1489
+ }
1490
+ catch (error) {
1491
+ this.logger.error(`Instance ${instance.id} was cancelled by an administrator, but its initiator ${instance.initiatorMemberId} could not be notified: ${error instanceof Error ? error.message : 'unknown error'}`);
1492
+ }
1493
+ }
1494
+ /**
1495
+ * The cancel transaction both paths share. The order is part of the
1496
+ * contract: read, authorize, check the state, close, record. A non-initiator who tries to withdraw an already
1497
+ * approved instance must keep getting the "initiator" refusal, not
1498
+ * "not cancellable".
1499
+ */
1500
+ async cancelApprovalInstanceInTransaction(cancellation) {
1238
1501
  return this.runEngineTransaction(async (manager) => {
1239
1502
  await manager.query('SELECT pg_advisory_xact_lock(hashtext($1))', [
1240
- input.instanceId,
1503
+ cancellation.instanceId,
1241
1504
  ]);
1242
1505
  const instanceRepository = manager.getRepository(approval_instance_entity_1.ApprovalInstanceEntity);
1243
1506
  const instance = await instanceRepository.findOne({
1244
- where: { id: input.instanceId },
1507
+ where: { id: cancellation.instanceId },
1245
1508
  });
1246
1509
  if (!instance) {
1247
- throw new common_1.NotFoundException(`Approval instance ${input.instanceId} was not found`);
1248
- }
1249
- if (instance.initiatorMemberId !== input.cancelledByMemberId) {
1250
- throw new common_1.ConflictException(`Approval instance ${instance.id} can only be cancelled by its initiator`);
1510
+ throw new common_1.NotFoundException(`Approval instance ${cancellation.instanceId} was not found`);
1251
1511
  }
1512
+ cancellation.authorize(instance);
1252
1513
  if (instance.state !== workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING &&
1253
1514
  instance.state !== workflow_engine_enums_1.ApprovalInstanceStateEnum.RETURNED) {
1254
1515
  throw new common_1.ConflictException(`Approval instance ${instance.id} is not cancellable`);
1255
1516
  }
1256
1517
  const cancelledInstance = await this.closeInstance(manager, instance, workflow_engine_enums_1.ApprovalInstanceStateEnum.CANCELLED);
1257
1518
  await manager.getRepository(activity_log_entity_1.ActivityLogEntity).save(manager.getRepository(activity_log_entity_1.ActivityLogEntity).create({
1258
- actorMemberId: input.cancelledByMemberId,
1519
+ actorMemberId: cancellation.actorMemberId,
1259
1520
  eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.INSTANCE_CANCELLED,
1260
1521
  instanceId: instance.id,
1261
1522
  nodeId: null,
1262
- payload: {
1263
- comment: input.comment?.trim() || null,
1264
- },
1523
+ payload: cancellation.payload,
1265
1524
  taskId: null,
1266
1525
  }));
1267
- await this.recordInstanceEvent(manager, cancelledInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.CANCELLED, 'INSTANCE_CANCELLED', { actorMemberId: input.cancelledByMemberId });
1526
+ await this.recordInstanceEvent(manager, cancelledInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.CANCELLED, 'INSTANCE_CANCELLED', { actorMemberId: cancellation.actorMemberId });
1268
1527
  return cancelledInstance;
1269
1528
  });
1270
1529
  }
@@ -1331,6 +1590,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1331
1590
  return closedInstance;
1332
1591
  }
1333
1592
  async resubmitApprovalInstance(input, authContext) {
1593
+ return this.withAdvanceFailureRecorded(() => this.resubmitApprovalInstanceInTransaction(input, authContext));
1594
+ }
1595
+ async resubmitApprovalInstanceInTransaction(input, authContext) {
1334
1596
  const submittedFormData = parseJsonObject(input.formDataJson, 'formDataJson');
1335
1597
  if (input.endUserSubmission === true) {
1336
1598
  await this.assertEndUserMayResubmit(input.instanceId);
@@ -1437,7 +1699,15 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1437
1699
  },
1438
1700
  taskId: null,
1439
1701
  }));
1440
- await this.processRunningInstance(manager, runtimeInstance);
1702
+ await runInstanceAdvanceStep({
1703
+ action: null,
1704
+ actorMemberId: input.initiatorMemberId,
1705
+ automatic: null,
1706
+ instanceId: instance.id,
1707
+ kind: 'RESUBMIT',
1708
+ nodeId: null,
1709
+ taskId: null,
1710
+ }, () => this.processRunningInstance(manager, runtimeInstance));
1441
1711
  const refreshedInstance = await instanceRepository.findOne({
1442
1712
  where: { id: instance.id },
1443
1713
  });
@@ -1723,7 +1993,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
1723
1993
  }
1724
1994
  async resolveAdhocTaskCandidates(manager, instance, node, label, targetValue) {
1725
1995
  const exclusions = await this.readNodeExclusions(manager, instance, node);
1726
- const resolvedCandidates = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), `${label}「${node.data.label}」`, '簽核人', exclusions.memberIds);
1996
+ const resolvedCandidates = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), `${label}「${node.data.label}」`, '簽核人', exclusions.memberIds, null);
1727
1997
  // Ad-hoc approvers are picked by a person, not walked up a manager chain,
1728
1998
  // so the chain's grade rule does not apply to them.
1729
1999
  const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, false);
@@ -2131,7 +2401,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
2131
2401
  return;
2132
2402
  }
2133
2403
  try {
2134
- const recipients = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), '臨時通知對象', '通知對象');
2404
+ const recipients = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), '臨時通知對象', '通知對象', [], null);
2135
2405
  await this.notificationService.createAdhocWorkflowNotifications({
2136
2406
  channels: directive.channels,
2137
2407
  instance,
@@ -3709,9 +3979,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
3709
3979
  });
3710
3980
  }
3711
3981
  async skipNodeByInitiatorExclusion(manager, instance, token, node, skippedBy = 'INITIATOR') {
3712
- await this.skipNode(manager, instance, token, node, skippedBy === 'NODE_ACTORS'
3713
- ? { skippedByNodeActorExclusion: true }
3714
- : { skippedByInitiatorExclusion: true });
3982
+ await this.skipNode(manager, instance, token, node, SKIPPED_RUNTIME_TASK_PAYLOADS[skippedBy]);
3715
3983
  }
3716
3984
  async skipNode(manager, instance, token, node, reason) {
3717
3985
  // A NOTIFY node is an asynchronous branch that may not have an outgoing
@@ -4423,9 +4691,23 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4423
4691
  * its own transaction, and reports one result per task. A failing task does
4424
4692
  * not stop the others: a refusal is a REJECTED result, and an unexpected
4425
4693
  * error is reported as REJECTED with UNEXPECTED_ERROR for that task alone.
4694
+ *
4695
+ * `taskIds` narrows the batch to those tasks. Omitted or null keeps every
4696
+ * open task. An empty list throws a BadRequestException before anything is
4697
+ * read: it must never be taken for "all", the write is not reversible. An id
4698
+ * the member does not hold (already moved, instance finished, unknown) yields
4699
+ * no result row and no error; the others still run.
4426
4700
  */
4427
4701
  async reassignMemberTasks(options) {
4428
- const held = await this.readRunningMemberTasks(options.fromMemberId);
4702
+ const { taskIds } = options;
4703
+ if (taskIds && taskIds.length === 0) {
4704
+ throw new common_1.BadRequestException('taskIds must not be empty: omit it to reassign every open task');
4705
+ }
4706
+ const wanted = taskIds ? new Set(taskIds) : null;
4707
+ const allHeld = await this.readRunningMemberTasks(options.fromMemberId);
4708
+ const held = wanted
4709
+ ? allHeld.filter(({ task }) => wanted.has(task.id))
4710
+ : allHeld;
4429
4711
  return held.reduce(async (previous, { task }) => {
4430
4712
  const results = await previous;
4431
4713
  const result = await this.executeTaskReassignment(task, options).catch((error) => {
@@ -4494,6 +4776,103 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4494
4776
  };
4495
4777
  });
4496
4778
  }
4779
+ /**
4780
+ * The instances that could not move on and still cannot, newest failure
4781
+ * first, one row each, described by their latest unresolved `ADVANCE_FAILED`.
4782
+ *
4783
+ * Which failures are unresolved, the order and the page are decided by the
4784
+ * database (`readStuckFailurePage`, the SQL form of the rule the duplicate
4785
+ * check applies in {@link readUnresolvedAdvanceFailures}); the stuck set is
4786
+ * still evaluated whole before LIMIT applies, but only the page's rows leave
4787
+ * the database. This decorates that page.
4788
+ *
4789
+ * Who has to press again is read now, not copied from the failure: a task
4790
+ * the administrator has reassigned since shows its new holder.
4791
+ * - `DECISION`: the candidates whose seat is PENDING or CLAIMED. A WAITING
4792
+ * seat of a sequential task is not that member's turn yet. A task without
4793
+ * a single candidate row is a direct task, held by its assignee.
4794
+ * - `RESUBMIT`: the initiator.
4795
+ */
4796
+ async listStuckApprovalInstances(options = {}) {
4797
+ return this.approvalInstanceRepository.manager.transaction(async (manager) => {
4798
+ const failureIds = await (0, stuck_approval_instance_query_1.readStuckFailurePage)(manager, OPEN_TASK_STATUSES, {
4799
+ limit: normalizePageSize(options.pageSize),
4800
+ offset: readPageOffset(options.page, options.pageSize),
4801
+ });
4802
+ const failures = failureIds.length
4803
+ ? await manager
4804
+ .getRepository(activity_log_entity_1.ActivityLogEntity)
4805
+ .find({ where: { id: (0, typeorm_2.In)([...failureIds]) } })
4806
+ : [];
4807
+ const instances = failures.length
4808
+ ? await manager.getRepository(approval_instance_entity_1.ApprovalInstanceEntity).find({
4809
+ where: {
4810
+ id: (0, typeorm_2.In)([
4811
+ ...new Set(failures.map((failure) => failure.instanceId)),
4812
+ ]),
4813
+ },
4814
+ })
4815
+ : [];
4816
+ const failureById = new Map(failures.map((failure) => [
4817
+ failure.id,
4818
+ failure,
4819
+ ]));
4820
+ const instanceById = new Map(instances.map((instance) => [
4821
+ instance.id,
4822
+ instance,
4823
+ ]));
4824
+ // `find` does not keep the order of the ids; the page's order is the
4825
+ // database's.
4826
+ const stuck = failureIds.flatMap((failureId) => {
4827
+ const failure = failureById.get(failureId);
4828
+ const instance = failure && instanceById.get(failure.instanceId);
4829
+ return failure && instance ? [{ failure, instance }] : [];
4830
+ });
4831
+ if (stuck.length === 0) {
4832
+ return [];
4833
+ }
4834
+ const taskIds = [
4835
+ ...new Set(stuck.flatMap(({ failure }) => failure.taskId ? [failure.taskId] : [])),
4836
+ ];
4837
+ const tasks = taskIds.length
4838
+ ? await manager
4839
+ .getRepository(task_entity_1.TaskEntity)
4840
+ .find({ where: { id: (0, typeorm_2.In)(taskIds) } })
4841
+ : [];
4842
+ const candidates = taskIds.length
4843
+ ? await manager.getRepository(task_candidate_entity_1.TaskCandidateEntity).find({
4844
+ order: {
4845
+ sequence: { direction: 'ASC', nulls: 'LAST' },
4846
+ createdAt: 'ASC',
4847
+ id: 'ASC',
4848
+ },
4849
+ where: { taskId: (0, typeorm_2.In)(taskIds) },
4850
+ })
4851
+ : [];
4852
+ const templates = await manager
4853
+ .getRepository(approval_template_entity_1.ApprovalTemplateEntity)
4854
+ .find({
4855
+ where: {
4856
+ id: (0, typeorm_2.In)([
4857
+ ...new Set(stuck.map(({ instance }) => instance.templateId)),
4858
+ ]),
4859
+ },
4860
+ });
4861
+ const templateNameById = new Map(templates.map((template) => [
4862
+ template.id,
4863
+ template.name,
4864
+ ]));
4865
+ return stuck.map((candidate) => toStuckApprovalInstanceRecord(candidate, {
4866
+ candidates,
4867
+ tasks,
4868
+ templateNameById,
4869
+ }));
4870
+ });
4871
+ }
4872
+ /** How many instances {@link listStuckApprovalInstances} pages through. */
4873
+ async countStuckApprovalInstances() {
4874
+ return this.approvalInstanceRepository.manager.transaction(async (manager) => (0, stuck_approval_instance_query_1.countStuckInstances)(manager, OPEN_TASK_STATUSES));
4875
+ }
4497
4876
  /**
4498
4877
  * Deactivated members who still hold open tasks, so an administrator can find
4499
4878
  * them: a deactivated member no longer shows up in the member search.
@@ -4921,7 +5300,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4921
5300
  * Resolves who gets the node's task. A {@link SkippedRuntimeTask} means the
4922
5301
  * node asked to be skipped (`excludeInitiator.whenEmpty` or
4923
5302
  * `excludeNodeActors.whenEmpty` = SKIP_NODE) because everyone left was kept
4924
- * out: the sole caller creates no task and advances the token instead.
5303
+ * out, or its manager chain found the level it asks for already reached
5304
+ * (`skipWhenStartReached`, or an earlier stage it continues): the sole
5305
+ * caller creates no task and advances the token instead.
4925
5306
  *
4926
5307
  * With both exclusions set the initiator rule runs first. When it alone
4927
5308
  * leaves nobody, `excludeInitiator.whenEmpty` decides; when it leaves
@@ -4930,8 +5311,14 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4930
5311
  */
4931
5312
  async resolveRuntimeTaskCandidates(manager, instance, node) {
4932
5313
  const label = `簽核節點「${node.data.label}」`;
5314
+ // Worked out before anything else because it can end the node: a chain
5315
+ // that needs nobody more is skipped without resolving or excluding anyone.
5316
+ const chainContext = await this.resolveOrgManagerChainContext(manager, instance, node, label);
5317
+ if (chainContext?.skipped) {
5318
+ return { skippedBy: 'MANAGER_CHAIN_REACHED' };
5319
+ }
4933
5320
  const exclusions = await this.readNodeExclusions(manager, instance, node);
4934
- const resolvedCandidates = await this.resolveApproverResolver(manager, instance, node.data.approverResolver, label, '簽核人', exclusions.memberIds);
5321
+ const resolvedCandidates = await this.resolveApproverResolver(manager, instance, node.data.approverResolver, label, '簽核人', exclusions.memberIds, chainContext);
4935
5322
  const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, isOrgManagerChainNode(node));
4936
5323
  const hasExclusion = hasSeatExclusion(node);
4937
5324
  const hasSubmitter = (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) !== null;
@@ -4971,6 +5358,136 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
4971
5358
  ? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人(含委派後的代理人),且此節點不允許申請人或代送人簽核。`
4972
5359
  : `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人(含委派後的代理人),且此節點不允許申請人自簽。`);
4973
5360
  }
5361
+ /**
5362
+ * What the walk of `node`'s manager chain has to know first, or `null`
5363
+ * for a node that is not a manager chain using one of the two settings that
5364
+ * need it (continuing an earlier chain, or skipping when the start already
5365
+ * qualifies); such a node is resolved exactly as before.
5366
+ *
5367
+ * The settings are checked again here, since the instance runs on its own
5368
+ * snapshot of the definition and a definition from another build can carry
5369
+ * a shape this one never published. A setting that does not hold throws a
5370
+ * {@link ConflictException} naming the node.
5371
+ *
5372
+ * `skipped` is set when the level the chain asks for is already there: the
5373
+ * start person's own level for `skipWhenStartReached`, the level the earlier
5374
+ * stage got a signature from for a continuation.
5375
+ */
5376
+ async resolveOrgManagerChainContext(manager, instance, node, label) {
5377
+ const resolver = node.data.approverResolver;
5378
+ if (resolver.type !== 'ORG_MANAGER_CHAIN' ||
5379
+ !usesOrgManagerChainSettings(resolver)) {
5380
+ return null;
5381
+ }
5382
+ const shapeIssue = (0, workflow_graph_1.readOrgManagerChainIssue)(resolver, '簽核節點');
5383
+ if (shapeIssue !== null) {
5384
+ throw new common_1.ConflictException(`${label} 無法建立待簽任務:${shapeIssue}`);
5385
+ }
5386
+ if (!(0, workflow_1.isOrgManagerChainContinuation)(resolver)) {
5387
+ return {
5388
+ continuation: null,
5389
+ skipped: await this.isOrgManagerChainStartLevelReached(manager, instance, resolver, label),
5390
+ };
5391
+ }
5392
+ const found = readChainContinuationSource(instance.workflowSnapshot, node.id, resolver);
5393
+ if ('issue' in found) {
5394
+ throw new common_1.ConflictException(`${label} 無法建立待簽任務:${found.issue}`);
5395
+ }
5396
+ // The earlier stage's own start, so both stages climb the same line.
5397
+ const start = await this.resolveOrgManagerChainStart(manager, instance, found.source, label, '簽核人');
5398
+ const reachedLevel = await this.readChainStageReachedLevel(manager, instance, found.sourceId);
5399
+ return {
5400
+ continuation: { reachedLevel, start },
5401
+ skipped: reachedLevel !== null && reachedLevel >= resolver.minPositionLevel,
5402
+ };
5403
+ }
5404
+ /**
5405
+ * `skipWhenStartReached`: whether the person the chain starts from already
5406
+ * holds a position level of `minPositionLevel` or more today. No start
5407
+ * person, or one without a graded position, reads as "not reached" so the
5408
+ * chain is walked as usual.
5409
+ */
5410
+ async isOrgManagerChainStartLevelReached(manager, instance, resolver, label) {
5411
+ const start = await this.resolveOrgManagerChainStart(manager, instance, resolver, label, '簽核人');
5412
+ if (start.memberId === null) {
5413
+ return false;
5414
+ }
5415
+ const level = await this.readMemberPositionLevel(manager, start.memberId, this.readOrganizationToday());
5416
+ return level !== null && level >= resolver.minPositionLevel;
5417
+ }
5418
+ /**
5419
+ * The position level the manager-chain stage `stageNodeId` actually got a
5420
+ * signature from in its latest round (`T`), or `null` when none counts.
5421
+ *
5422
+ * - A stage whose latest round was a skip for `skippedByManagerChainReached`
5423
+ * got nobody to sign, so its older approvals do not count: `null`.
5424
+ * - Otherwise the people who really approved its latest batch
5425
+ * ({@link readNodeActorApprovals}: no ad-hoc countersign, no SLA
5426
+ * auto-approval), each at the level they held on the day they approved.
5427
+ * `Position.level` has no history, but memberships are dated, so a
5428
+ * promotion that took effect after the approval does not raise `T`. The day
5429
+ * is the organisation's, the same one "today" is.
5430
+ *
5431
+ * The people counted are those who pressed approve, delegates and transfer
5432
+ * receivers included, not the seat they sat in.
5433
+ */
5434
+ async readChainStageReachedLevel(manager, instance, stageNodeId) {
5435
+ if (await this.wasChainStageSkippedLastRound(manager, instance, stageNodeId)) {
5436
+ return null;
5437
+ }
5438
+ const approvals = (await this.readNodeActorApprovals(manager, instance, [stageNodeId])).map((decision) => ({
5439
+ date: (0, organization_time_zone_1.formatDateOnlyInTimeZone)(decision.decidedAt, this.organizationTimeZone),
5440
+ memberId: decision.decidedByMemberId,
5441
+ }));
5442
+ const levelGroups = await Promise.all(uniqueTexts(approvals.map((approval) => approval.date)).map(async (date) => [
5443
+ ...(await this.readMemberPositionLevels(manager, approvals
5444
+ .filter((approval) => approval.date === date)
5445
+ .map((approval) => approval.memberId), date)).values(),
5446
+ ]));
5447
+ const levels = levelGroups
5448
+ .flat()
5449
+ .filter((level) => level !== null);
5450
+ return levels.length === 0 ? null : Math.max(...levels);
5451
+ }
5452
+ /**
5453
+ * Whether the latest thing that happened to the stage `stageNodeId` was a
5454
+ * skip for `skippedByManagerChainReached`, rather than a task being made for
5455
+ * it: the newest such `TOKEN_ADVANCED` is newer than the newest
5456
+ * `TASK_CREATED` of one of the stage's own (non-ad-hoc) tasks. Newer is
5457
+ * decided by the log's `seq` (the order the database wrote the rows in;
5458
+ * `created_at` is the transaction start and ties within one transaction).
5459
+ *
5460
+ * The `TASK_CREATED` payload does not say whether its task is ad-hoc, so the
5461
+ * task is looked up by the id the log carries.
5462
+ */
5463
+ async wasChainStageSkippedLastRound(manager, instance, stageNodeId) {
5464
+ const [logs, stageTasks] = await Promise.all([
5465
+ manager.getRepository(activity_log_entity_1.ActivityLogEntity).find({
5466
+ where: {
5467
+ eventType: (0, typeorm_2.In)([
5468
+ workflow_engine_enums_1.ActivityLogEventTypeEnum.TOKEN_ADVANCED,
5469
+ workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_CREATED,
5470
+ ]),
5471
+ instanceId: instance.id,
5472
+ nodeId: stageNodeId,
5473
+ },
5474
+ }),
5475
+ manager.getRepository(task_entity_1.TaskEntity).find({
5476
+ where: { instanceId: instance.id, isAdhoc: false, nodeId: stageNodeId },
5477
+ }),
5478
+ ]);
5479
+ const stageTaskIds = new Set(stageTasks.map((task) => task.id));
5480
+ const newestSeq = (matching) => matching.reduce((newest, log) => {
5481
+ const seq = BigInt(log.seq);
5482
+ return newest === null || seq > newest ? seq : newest;
5483
+ }, null);
5484
+ const skippedSeq = newestSeq(logs.filter((log) => log.eventType === workflow_engine_enums_1.ActivityLogEventTypeEnum.TOKEN_ADVANCED &&
5485
+ log.payload['skippedByManagerChainReached'] === true));
5486
+ const createdSeq = newestSeq(logs.filter((log) => log.eventType === workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_CREATED &&
5487
+ log.taskId !== null &&
5488
+ stageTaskIds.has(log.taskId)));
5489
+ return (skippedSeq !== null && (createdSeq === null || skippedSeq > createdSeq));
5490
+ }
4974
5491
  /**
4975
5492
  * Everyone `node` keeps out of its approvers, per rule: the initiator and
4976
5493
  * the submitter for `excludeInitiator`, and everyone who actually approved
@@ -5185,15 +5702,18 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5185
5702
  ? requiredLevel
5186
5703
  : null;
5187
5704
  }
5188
- async resolveApproverResolver(manager, instance, resolver, label, subject = '簽核人', skippedChainMemberIds = []) {
5189
- const memberIds = await this.resolveApproverMemberIds(manager, instance, resolver, label, subject, skippedChainMemberIds);
5705
+ async resolveApproverResolver(manager, instance, resolver, label, subject, skippedChainMemberIds,
5706
+ // Required, so a new entry point has to say it has none: only task
5707
+ // creation works one out, and a chain that needs it refuses without it.
5708
+ chainContext) {
5709
+ const memberIds = await this.resolveApproverMemberIds(manager, instance, resolver, label, subject, skippedChainMemberIds, chainContext);
5190
5710
  return memberIds.map((memberId) => ({
5191
5711
  memberId,
5192
5712
  sourceType: resolver.type,
5193
5713
  }));
5194
5714
  }
5195
5715
  /** The member ids `resolver` routes to; every source type, one branch each. */
5196
- async resolveApproverMemberIds(manager, instance, resolver, label, subject, skippedChainMemberIds) {
5716
+ async resolveApproverMemberIds(manager, instance, resolver, label, subject, skippedChainMemberIds, chainContext) {
5197
5717
  if (resolver.type === 'DIRECT') {
5198
5718
  return readMemberIdsFromValue(resolver.memberIds, label, subject);
5199
5719
  }
@@ -5216,7 +5736,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5216
5736
  return this.resolveOrgUnitManagerCandidates(manager, instance, resolver.orgUnitId, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject);
5217
5737
  }
5218
5738
  if (resolver.type === 'ORG_MANAGER_CHAIN') {
5219
- return this.resolveOrgManagerChainCandidates(manager, instance, resolver, label, subject, skippedChainMemberIds);
5739
+ return this.resolveOrgManagerChainCandidates(manager, instance, resolver, label, subject, skippedChainMemberIds, chainContext);
5220
5740
  }
5221
5741
  if (resolver.type === 'ORG_MANAGER') {
5222
5742
  return this.resolveInitiatorManagerCandidates(manager, instance, resolver.levelsUp, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject);
@@ -5416,11 +5936,26 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5416
5936
  * past without joining the chain or counting as the stopping point, and so
5417
5937
  * are managers below `startPositionLevel`, until the first one at or above
5418
5938
  * it starts the chain.
5939
+ *
5940
+ * A chain that continues an earlier stage climbs from that stage's start and
5941
+ * walks past every manager whose level is not above the one the stage got a
5942
+ * signature from, as if that level were the last one already added. Both it
5943
+ * and a chain that skips when its start already qualifies are only walked
5944
+ * with the `chainContext` task creation worked out; without one they are
5945
+ * refused, which keeps every other entry point (a notify node's recipients,
5946
+ * for one) from reading them as an ordinary chain.
5419
5947
  */
5420
- async resolveOrgManagerChainCandidates(manager, instance, resolver, label, subject, skippedMemberIds) {
5948
+ async resolveOrgManagerChainCandidates(manager, instance, resolver, label, subject, skippedMemberIds, chainContext) {
5421
5949
  const { fallback, minPositionLevel } = resolver;
5422
5950
  const startPositionLevel = (0, workflow_1.readOrgManagerChainStartPositionLevel)(resolver);
5423
- const start = await this.resolveOrgManagerChainStart(manager, instance, resolver, label, subject);
5951
+ const continuation = chainContext?.continuation ?? null;
5952
+ if (usesOrgManagerChainSettings(resolver) &&
5953
+ (!chainContext ||
5954
+ ((0, workflow_1.isOrgManagerChainContinuation)(resolver) && continuation === null))) {
5955
+ throw new common_1.ConflictException(`${label} ${subject === '通知對象' ? '無法建立通知' : '無法建立待簽任務'}:${ORG_MANAGER_CHAIN_CONTEXT_REFUSAL}`);
5956
+ }
5957
+ const start = continuation?.start ??
5958
+ (await this.resolveOrgManagerChainStart(manager, instance, resolver, label, subject));
5424
5959
  // Nobody to climb from: the same "no manager" path as a chain whose walk
5425
5960
  // finds nobody, so the fallback decides.
5426
5961
  if (start.memberId === null) {
@@ -5430,7 +5965,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5430
5965
  const visitedMemberIds = new Set([start.memberId]);
5431
5966
  const chain = [];
5432
5967
  let currentMemberId = start.memberId;
5433
- let lastAddedLevel = null;
5968
+ // A continuation starts out as if the level its earlier stage got a
5969
+ // signature from were the last one added: managers at or below it are
5970
+ // walked past, so only those above it are asked.
5971
+ let lastAddedLevel = continuation?.reachedLevel ?? null;
5434
5972
  for (let hop = 0; hop < ORG_MANAGER_CHAIN_MAX_HOPS; hop += 1) {
5435
5973
  // A chain is a single path, so it always uses closest-org-unit semantics
5436
5974
  // (merging ancestor-unit rules could only produce ties). The member is
@@ -5515,6 +6053,12 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5515
6053
  if (subject === '通知對象') {
5516
6054
  throw new common_1.ConflictException(`${label} ${failure}:知會節點的主管鏈只能從發起人開始。`);
5517
6055
  }
6056
+ // The start of a continuation is its earlier stage's, which task creation
6057
+ // hands over already resolved; asking for it here is a mistake of the
6058
+ // caller, not a start to climb from.
6059
+ if (startFrom.type === 'CHAIN_CONTINUATION') {
6060
+ throw new common_1.ConflictException(`${label} ${failure}:${ORG_MANAGER_CHAIN_CONTEXT_REFUSAL}`);
6061
+ }
5518
6062
  if (startFrom.type === 'FORM_MEMBER') {
5519
6063
  const source = `依表單欄位「${startFrom.formPath}」`;
5520
6064
  const pickedMemberIds = findMemberIdsInValue(readValueAtPath(instance.formData, startFrom.formPath));
@@ -5898,7 +6442,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
5898
6442
  const recipients = (0, workflow_graph_1.isNotifyRecipientsEmpty)(action.recipients) &&
5899
6443
  (0, workflow_graph_1.readNotifyWebhookTargets)(action).length > 0
5900
6444
  ? []
5901
- : await this.resolveApproverResolver(manager, instance, action.recipients, `知會節點「${node.data.label}」`, '通知對象');
6445
+ : await this.resolveApproverResolver(manager, instance, action.recipients, `知會節點「${node.data.label}」`, '通知對象', [],
6446
+ // A notify node has no task to build the context: a chain that
6447
+ // needs one is refused (publish lint forbids it here already).
6448
+ null);
5902
6449
  const recipientMemberIds = uniqueTexts(recipients.map((recipient) => recipient.memberId));
5903
6450
  await this.notificationService.createServiceTaskNotifications({
5904
6451
  instance,
@@ -6846,6 +7393,92 @@ function readReturnResubmitStrategyFromPayload(payload) {
6846
7393
  ? 'FROM_RETURN_POINT'
6847
7394
  : 'RESTART';
6848
7395
  }
7396
+ /**
7397
+ * Whether a recorded failure is the one a new attempt would write again: same
7398
+ * kind and key, same reason. A decision is keyed by task, actor and action, so
7399
+ * the votes of a countersign each keep their own row; a resubmission has one
7400
+ * key per instance. The same actor failing for a different reason is news, so
7401
+ * the message is part of the match.
7402
+ */
7403
+ function isSameAdvanceFailure(logged, context, message) {
7404
+ if (readStringPayloadValue(logged.payload, 'kind') !== context.kind ||
7405
+ logged.payload['message'] !== message) {
7406
+ return false;
7407
+ }
7408
+ return (context.kind === 'RESUBMIT' ||
7409
+ (logged.taskId === context.taskId &&
7410
+ logged.actorMemberId === context.actorMemberId &&
7411
+ readStringPayloadValue(logged.payload, 'action') === context.action));
7412
+ }
7413
+ /**
7414
+ * Runs the step that moves an instance on and, when it fails with an error an
7415
+ * operator is meant to read (an `HttpException` below 500), hands it on as an
7416
+ * {@link InstanceAdvanceFailure} so the public method can record it once the
7417
+ * transaction has rolled back. Everything else passes through untouched: a 5xx
7418
+ * is usually a hiccup that retrying clears, and a plain `Error` may carry SQL.
7419
+ *
7420
+ * Used at the three call sites only. `processRunningInstance` and
7421
+ * `returnInstanceToNode` stay unwrapped because submit and the administrator's
7422
+ * re-run call them too, and they must keep throwing the original class.
7423
+ */
7424
+ async function runInstanceAdvanceStep(context, step) {
7425
+ try {
7426
+ return await step();
7427
+ }
7428
+ catch (error) {
7429
+ if (error instanceof common_1.HttpException && error.getStatus() < 500) {
7430
+ throw new instance_advance_failure_1.InstanceAdvanceFailure(error, context);
7431
+ }
7432
+ throw error;
7433
+ }
7434
+ }
7435
+ function toStuckApprovalInstanceRecord({ failure, instance }, lookup) {
7436
+ const isResubmit = readStringPayloadValue(failure.payload, 'kind') === 'RESUBMIT';
7437
+ const taskId = isResubmit ? null : failure.taskId;
7438
+ const nodeId = isResubmit ? null : failure.nodeId;
7439
+ const message = failure.payload['message'];
7440
+ return {
7441
+ automatic: failure.payload['automatic'] === SLA_AUTO_APPROVE_MARK
7442
+ ? SLA_AUTO_APPROVE_MARK
7443
+ : null,
7444
+ failedActorMemberId: failure.actorMemberId,
7445
+ failedAt: failure.createdAt,
7446
+ initiatorMemberId: instance.initiatorMemberId,
7447
+ instanceId: instance.id,
7448
+ instanceTitle: instance.title,
7449
+ kind: isResubmit
7450
+ ? workflow_engine_enums_1.StuckApprovalInstanceKindEnum.RESUBMIT
7451
+ : workflow_engine_enums_1.StuckApprovalInstanceKindEnum.DECISION,
7452
+ message: typeof message === 'string' ? message : '',
7453
+ nodeId,
7454
+ nodeLabel: nodeId
7455
+ ? readWorkflowNodeLabel(instance.workflowSnapshot, nodeId)
7456
+ : null,
7457
+ pendingActorMemberIds: taskId === null
7458
+ ? [instance.initiatorMemberId]
7459
+ : readTaskHolderMemberIds(lookup.tasks.find((task) => task.id === taskId) ?? null, lookup.candidates.filter((candidate) => candidate.taskId === taskId)),
7460
+ taskId,
7461
+ templateId: instance.templateId,
7462
+ templateName: lookup.templateNameById.get(instance.templateId) ?? null,
7463
+ };
7464
+ }
7465
+ /**
7466
+ * The members who can press a task's button now: a PENDING or CLAIMED seat,
7467
+ * each member once. A WAITING seat of a sequential task is not that member's
7468
+ * turn yet. A task with no candidate row at all is a direct task, held by its
7469
+ * assignee, as the reassignment check reads it.
7470
+ */
7471
+ function readTaskHolderMemberIds(task, candidates) {
7472
+ if (candidates.length === 0) {
7473
+ return task?.assigneeMemberId ? [task.assigneeMemberId] : [];
7474
+ }
7475
+ return [
7476
+ ...new Set(candidates
7477
+ .filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING ||
7478
+ candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.CLAIMED)
7479
+ .map((candidate) => candidate.memberId)),
7480
+ ];
7481
+ }
6849
7482
  function readStringPayloadValue(payload, key) {
6850
7483
  const value = payload[key];
6851
7484
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -6998,6 +7631,73 @@ function readInitiatorAndSubmitter(instance) {
6998
7631
  function isOrgManagerChainNode(node) {
6999
7632
  return node.data.approverResolver.type === 'ORG_MANAGER_CHAIN';
7000
7633
  }
7634
+ /**
7635
+ * Whether a manager chain uses one of the two settings that need the context
7636
+ * task creation works out first: it continues an earlier chain, or
7637
+ * `skipWhenStartReached` is anything but absent, `null` or `false` (a value
7638
+ * that is not a boolean is "used" too, so the shape check can refuse it
7639
+ * instead of the chain being walked as if it were absent).
7640
+ */
7641
+ function usesOrgManagerChainSettings(resolver) {
7642
+ const skipWhenStartReached = resolver.skipWhenStartReached;
7643
+ return ((0, workflow_1.isOrgManagerChainContinuation)(resolver) ||
7644
+ (skipWhenStartReached !== undefined &&
7645
+ skipWhenStartReached !== null &&
7646
+ skipWhenStartReached !== false));
7647
+ }
7648
+ /**
7649
+ * The manager-chain stage a continuing chain (`nodeId`, whose resolver is
7650
+ * `resolver`) continues, or the first reason it cannot: it is missing, not a
7651
+ * user task, not a manager chain, itself a continuation (stages are not
7652
+ * chained more than once), started from something that can change while the
7653
+ * stage's approvals stay (a form field, or a start this build does not know),
7654
+ * or asks for a level at or below this chain's own, which would skip this
7655
+ * chain every time. The same rules the publish lint applies to the stage;
7656
+ * where the stage sits in the graph is only checked there.
7657
+ */
7658
+ function readChainContinuationSource(workflow, nodeId, resolver) {
7659
+ const sourceId = resolver.startFrom.nodeId;
7660
+ const source = workflow.nodes.find((candidate) => candidate.id === sourceId);
7661
+ const nameOf = (id) => `「${readWorkflowNodeLabel(workflow, id) ?? id}」`;
7662
+ if (source === undefined) {
7663
+ return { issue: `接續的前一段主管鏈「${sourceId}」不存在。` };
7664
+ }
7665
+ if (source.type !== 'userTask') {
7666
+ return { issue: `接續的前一段主管鏈 ${nameOf(sourceId)} 不是簽核關卡。` };
7667
+ }
7668
+ const sourceResolver = source.data.approverResolver;
7669
+ if (sourceResolver?.type !== 'ORG_MANAGER_CHAIN') {
7670
+ return {
7671
+ issue: `接續的前一段 ${nameOf(sourceId)} 的簽核來源不是「沿主管鏈簽核」。`,
7672
+ };
7673
+ }
7674
+ if ((0, workflow_1.isOrgManagerChainContinuation)(sourceResolver)) {
7675
+ return {
7676
+ issue: `接續的前一段主管鏈 ${nameOf(sourceId)} 本身也是接續型主管鏈,不支援多段串接。`,
7677
+ };
7678
+ }
7679
+ const sourceStart = sourceResolver.startFrom;
7680
+ const sourceStartType = typeof sourceStart === 'object' && sourceStart !== null
7681
+ ? sourceStart.type
7682
+ : undefined;
7683
+ if (sourceStart !== undefined &&
7684
+ sourceStart !== null &&
7685
+ sourceStartType !== 'INITIATOR' &&
7686
+ sourceStartType !== 'NODE_ACTOR') {
7687
+ return {
7688
+ issue: `接續的前一段主管鏈 ${nameOf(sourceId)} 的起點只能是申請人或前面關卡的簽核者,不能是表單欄位或其他起點。`,
7689
+ };
7690
+ }
7691
+ const sourceLevel = sourceResolver.minPositionLevel;
7692
+ if (typeof sourceLevel === 'number' &&
7693
+ Number.isInteger(sourceLevel) &&
7694
+ resolver.minPositionLevel <= sourceLevel) {
7695
+ return {
7696
+ issue: `接續關卡 ${nameOf(nodeId)} 的職等門檻(${resolver.minPositionLevel})必須高於前一段主管鏈 ${nameOf(sourceId)} 的職等門檻(${sourceLevel});否則前一段只要簽到自己的職等,這一關就一定被略過。`,
7697
+ };
7698
+ }
7699
+ return { source: sourceResolver, sourceId };
7700
+ }
7001
7701
  // Candidates of a manager-chain node that came from the chain (or replaced the
7002
7702
  // excluded initiator's seat); ad-hoc approvers are chosen by a person instead.
7003
7703
  function isGradeGatedCandidate(node, sourceType) {