@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.
- package/CHANGELOG.md +31 -0
- package/README.md +68 -1
- package/package.json +2 -2
- package/src/lib/migrations/0000000033000-activity-log-advance-failed-index.d.ts +35 -0
- package/src/lib/migrations/0000000033000-activity-log-advance-failed-index.js +51 -0
- package/src/lib/migrations/0000000033000-activity-log-advance-failed-index.js.map +1 -0
- package/src/lib/migrations/index.d.ts +1 -0
- package/src/lib/migrations/index.js +3 -0
- package/src/lib/migrations/index.js.map +1 -1
- package/src/lib/notification/notification.service.d.ts +3 -2
- package/src/lib/notification/notification.service.js +2 -1
- package/src/lib/notification/notification.service.js.map +1 -1
- package/src/lib/template/template.service.js +17 -0
- package/src/lib/template/template.service.js.map +1 -1
- package/src/lib/template/workflow-definition.validator.js +12 -6
- package/src/lib/template/workflow-definition.validator.js.map +1 -1
- package/src/lib/workflow-engine/dto/admin-cancel-approval-instance.input.d.ts +9 -0
- package/src/lib/workflow-engine/dto/admin-cancel-approval-instance.input.js +24 -0
- package/src/lib/workflow-engine/dto/admin-cancel-approval-instance.input.js.map +1 -0
- package/src/lib/workflow-engine/dto/reassign-member-tasks.input.d.ts +2 -0
- package/src/lib/workflow-engine/dto/reassign-member-tasks.input.js +8 -0
- package/src/lib/workflow-engine/dto/reassign-member-tasks.input.js.map +1 -1
- package/src/lib/workflow-engine/index.d.ts +2 -0
- package/src/lib/workflow-engine/index.js +2 -0
- package/src/lib/workflow-engine/index.js.map +1 -1
- package/src/lib/workflow-engine/instance-advance-failure.d.ts +28 -0
- package/src/lib/workflow-engine/instance-advance-failure.js +20 -0
- package/src/lib/workflow-engine/instance-advance-failure.js.map +1 -0
- package/src/lib/workflow-engine/stuck-approval-instance.object.d.ts +22 -0
- package/src/lib/workflow-engine/stuck-approval-instance.object.js +107 -0
- package/src/lib/workflow-engine/stuck-approval-instance.object.js.map +1 -0
- package/src/lib/workflow-engine/stuck-approval-instance.query.d.ts +22 -0
- package/src/lib/workflow-engine/stuck-approval-instance.query.js +139 -0
- package/src/lib/workflow-engine/stuck-approval-instance.query.js.map +1 -0
- package/src/lib/workflow-engine/workflow-engine.enums.d.ts +11 -0
- package/src/lib/workflow-engine/workflow-engine.enums.js +16 -1
- package/src/lib/workflow-engine/workflow-engine.enums.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.mutations.d.ts +2 -0
- package/src/lib/workflow-engine/workflow-engine.mutations.js +31 -3
- package/src/lib/workflow-engine/workflow-engine.mutations.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.queries.d.ts +3 -1
- package/src/lib/workflow-engine/workflow-engine.queries.js +34 -0
- package/src/lib/workflow-engine/workflow-engine.queries.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.service.d.ts +207 -3
- package/src/lib/workflow-engine/workflow-engine.service.js +745 -45
- 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
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
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
|
-
|
|
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:
|
|
1507
|
+
where: { id: cancellation.instanceId },
|
|
1245
1508
|
});
|
|
1246
1509
|
if (!instance) {
|
|
1247
|
-
throw new common_1.NotFoundException(`Approval instance ${
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
5189
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|