@rytass/bpm-core-nestjs-module 0.22.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 +95 -0
- package/README.md +68 -1
- package/package.json +2 -2
- package/src/index.d.ts +1 -0
- package/src/index.js +1 -0
- package/src/index.js.map +1 -1
- package/src/lib/bpm/bpm-root-options.d.ts +57 -0
- package/src/lib/bpm/bpm-root-options.js.map +1 -1
- package/src/lib/bpm/bpm-root.module.d.ts +21 -0
- package/src/lib/bpm/bpm-root.module.js +6 -0
- package/src/lib/bpm/bpm-root.module.js.map +1 -1
- package/src/lib/form/dto/form-definition.input.d.ts +5 -0
- package/src/lib/form/dto/form-definition.input.js.map +1 -1
- package/src/lib/form/form-definition.entity.d.ts +5 -0
- package/src/lib/form/form-definition.entity.js +7 -0
- package/src/lib/form/form-definition.entity.js.map +1 -1
- package/src/lib/form/form-schema.validator.js +17 -5
- package/src/lib/form/form-schema.validator.js.map +1 -1
- package/src/lib/form/form.queries.d.ts +2 -2
- package/src/lib/form/form.queries.js +17 -5
- package/src/lib/form/form.queries.js.map +1 -1
- package/src/lib/form/form.service.d.ts +2 -1
- package/src/lib/form/form.service.js +10 -5
- package/src/lib/form/form.service.js.map +1 -1
- package/src/lib/form-data-source/form-data-source-value-resolver.service.js +22 -2
- package/src/lib/form-data-source/form-data-source-value-resolver.service.js.map +1 -1
- package/src/lib/form-data-source/form-data-source.errors.d.ts +11 -0
- package/src/lib/form-data-source/form-data-source.errors.js +14 -1
- package/src/lib/form-data-source/form-data-source.errors.js.map +1 -1
- package/src/lib/form-data-source/form-data-source.service.d.ts +6 -0
- package/src/lib/form-data-source/form-data-source.service.js +37 -3
- package/src/lib/form-data-source/form-data-source.service.js.map +1 -1
- package/src/lib/instance-event/approval-instance-event.entity.d.ts +27 -0
- package/src/lib/instance-event/approval-instance-event.entity.js +93 -0
- package/src/lib/instance-event/approval-instance-event.entity.js.map +1 -0
- package/src/lib/instance-event/index.d.ts +13 -0
- package/src/lib/instance-event/index.js +17 -0
- package/src/lib/instance-event/index.js.map +1 -0
- package/src/lib/instance-event/instance-event.delivery-scheduler.service.d.ts +21 -0
- package/src/lib/instance-event/instance-event.delivery-scheduler.service.js +81 -0
- package/src/lib/instance-event/instance-event.delivery-scheduler.service.js.map +1 -0
- package/src/lib/instance-event/instance-event.delivery.service.d.ts +69 -0
- package/src/lib/instance-event/instance-event.delivery.service.js +259 -0
- package/src/lib/instance-event/instance-event.delivery.service.js.map +1 -0
- package/src/lib/instance-event/instance-event.delivery.subscriber.d.ts +24 -0
- package/src/lib/instance-event/instance-event.delivery.subscriber.js +85 -0
- package/src/lib/instance-event/instance-event.delivery.subscriber.js.map +1 -0
- package/src/lib/instance-event/instance-event.enums.d.ts +12 -0
- package/src/lib/instance-event/instance-event.enums.js +28 -0
- package/src/lib/instance-event/instance-event.enums.js.map +1 -0
- package/src/lib/instance-event/instance-event.module.d.ts +13 -0
- package/src/lib/instance-event/instance-event.module.js +52 -0
- package/src/lib/instance-event/instance-event.module.js.map +1 -0
- package/src/lib/instance-event/instance-event.object.d.ts +24 -0
- package/src/lib/instance-event/instance-event.object.js +73 -0
- package/src/lib/instance-event/instance-event.object.js.map +1 -0
- package/src/lib/instance-event/instance-event.options.d.ts +26 -0
- package/src/lib/instance-event/instance-event.options.js +35 -0
- package/src/lib/instance-event/instance-event.options.js.map +1 -0
- package/src/lib/instance-event/instance-event.resolver.d.ts +10 -0
- package/src/lib/instance-event/instance-event.resolver.js +75 -0
- package/src/lib/instance-event/instance-event.resolver.js.map +1 -0
- package/src/lib/instance-event/instance-event.service.d.ts +18 -0
- package/src/lib/instance-event/instance-event.service.js +37 -0
- package/src/lib/instance-event/instance-event.service.js.map +1 -0
- package/src/lib/instance-event/instance-event.token.d.ts +19 -0
- package/src/lib/instance-event/instance-event.token.js +9 -0
- package/src/lib/instance-event/instance-event.token.js.map +1 -0
- package/src/lib/instance-event/instance-event.types.d.ts +67 -0
- package/src/lib/instance-event/instance-event.types.js +9 -0
- package/src/lib/instance-event/instance-event.types.js.map +1 -0
- package/src/lib/instance-event/instance-event.writer.d.ts +21 -0
- package/src/lib/instance-event/instance-event.writer.js +45 -0
- package/src/lib/instance-event/instance-event.writer.js.map +1 -0
- package/src/lib/migrations/0000000032000-host-integration-round.d.ts +25 -0
- package/src/lib/migrations/0000000032000-host-integration-round.js +104 -0
- package/src/lib/migrations/0000000032000-host-integration-round.js.map +1 -0
- 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 +2 -0
- package/src/lib/migrations/index.js +6 -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 +52 -10
- package/src/lib/notification/notification.service.js.map +1 -1
- package/src/lib/template/approval-template-version.entity.d.ts +9 -1
- package/src/lib/template/approval-template-version.entity.js +11 -1
- package/src/lib/template/approval-template-version.entity.js.map +1 -1
- package/src/lib/template/approval-template.entity.d.ts +18 -0
- package/src/lib/template/approval-template.entity.js +23 -0
- package/src/lib/template/approval-template.entity.js.map +1 -1
- package/src/lib/template/dto/approval-template.input.d.ts +21 -0
- package/src/lib/template/dto/approval-template.input.js +38 -0
- package/src/lib/template/dto/approval-template.input.js.map +1 -1
- package/src/lib/template/dto/compose-approval-template.input.d.ts +23 -0
- package/src/lib/template/dto/compose-approval-template.input.js +37 -0
- package/src/lib/template/dto/compose-approval-template.input.js.map +1 -1
- package/src/lib/template/template-key.d.ts +26 -0
- package/src/lib/template/template-key.js +67 -0
- package/src/lib/template/template-key.js.map +1 -0
- package/src/lib/template/template.enums.d.ts +11 -0
- package/src/lib/template/template.enums.js +17 -1
- package/src/lib/template/template.enums.js.map +1 -1
- package/src/lib/template/template.queries.d.ts +3 -2
- package/src/lib/template/template.queries.js +29 -4
- package/src/lib/template/template.queries.js.map +1 -1
- package/src/lib/template/template.service.d.ts +8 -1
- package/src/lib/template/template.service.js +111 -17
- 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/approver-filled-table-columns.d.ts +91 -0
- package/src/lib/workflow-engine/approver-filled-table-columns.js +301 -0
- package/src/lib/workflow-engine/approver-filled-table-columns.js.map +1 -0
- 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/dry-run-approval-workflow.input.d.ts +7 -0
- package/src/lib/workflow-engine/dto/dry-run-approval-workflow.input.js +9 -0
- package/src/lib/workflow-engine/dto/dry-run-approval-workflow.input.js.map +1 -1
- 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 +3 -0
- package/src/lib/workflow-engine/index.js +3 -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/instance-guard.runner.d.ts +19 -0
- package/src/lib/workflow-engine/instance-guard.runner.js +102 -0
- package/src/lib/workflow-engine/instance-guard.runner.js.map +1 -0
- package/src/lib/workflow-engine/instance-guard.token.d.ts +101 -0
- package/src/lib/workflow-engine/instance-guard.token.js +9 -0
- package/src/lib/workflow-engine/instance-guard.token.js.map +1 -0
- package/src/lib/workflow-engine/missing-form-fields.d.ts +12 -0
- package/src/lib/workflow-engine/missing-form-fields.js +27 -0
- package/src/lib/workflow-engine/missing-form-fields.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.module.d.ts +2 -0
- package/src/lib/workflow-engine/workflow-engine.module.js +3 -0
- package/src/lib/workflow-engine/workflow-engine.module.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.mutations.d.ts +3 -1
- package/src/lib/workflow-engine/workflow-engine.mutations.js +39 -6
- package/src/lib/workflow-engine/workflow-engine.mutations.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.queries.d.ts +10 -8
- package/src/lib/workflow-engine/workflow-engine.queries.js +131 -28
- package/src/lib/workflow-engine/workflow-engine.queries.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.service.d.ts +348 -10
- package/src/lib/workflow-engine/workflow-engine.service.js +1396 -118
- package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.tokens.d.ts +5 -0
|
@@ -36,7 +36,9 @@ const organization_enums_1 = require("../organization/organization.enums");
|
|
|
36
36
|
const organization_time_zone_1 = require("../organization/organization-time-zone");
|
|
37
37
|
const approval_template_version_entity_1 = require("../template/approval-template-version.entity");
|
|
38
38
|
const approval_template_entity_1 = require("../template/approval-template.entity");
|
|
39
|
+
const approver_filled_table_columns_1 = require("./approver-filled-table-columns");
|
|
39
40
|
const template_enums_1 = require("../template/template.enums");
|
|
41
|
+
const missing_form_fields_1 = require("./missing-form-fields");
|
|
40
42
|
const activity_log_entity_1 = require("./activity-log.entity");
|
|
41
43
|
const adhoc_directive_entity_1 = require("./adhoc-directive.entity");
|
|
42
44
|
const adhoc_enums_1 = require("./adhoc.enums");
|
|
@@ -45,10 +47,12 @@ const approval_instance_field_filter_compiler_1 = require("./approval-instance-f
|
|
|
45
47
|
const approval_instance_filterable_field_policy_1 = require("./approval-instance-filterable-field.policy");
|
|
46
48
|
const text_field_pattern_test_1 = require("./text-field-pattern-test");
|
|
47
49
|
const form_data_fingerprint_1 = require("./form-data-fingerprint");
|
|
50
|
+
const instance_advance_failure_1 = require("./instance-advance-failure");
|
|
48
51
|
const instance_submitter_1 = require("./instance-submitter");
|
|
49
52
|
const task_decision_entity_1 = require("./task-decision.entity");
|
|
50
53
|
const task_candidate_entity_1 = require("./task-candidate.entity");
|
|
51
54
|
const task_entity_1 = require("./task.entity");
|
|
55
|
+
const stuck_approval_instance_query_1 = require("./stuck-approval-instance.query");
|
|
52
56
|
const workflow_engine_enums_1 = require("./workflow-engine.enums");
|
|
53
57
|
const workflow_condition_evaluator_1 = require("./workflow-condition-evaluator");
|
|
54
58
|
const workflow_form_value_1 = require("./workflow-form-value");
|
|
@@ -58,6 +62,9 @@ const expression_budget_1 = require("../condition/expression-budget");
|
|
|
58
62
|
const expression_errors_1 = require("../condition/expression-errors");
|
|
59
63
|
const expression_dialect_1 = require("../condition/expression-dialect");
|
|
60
64
|
const initiator_custom_fields_resolver_token_1 = require("./initiator-custom-fields-resolver.token");
|
|
65
|
+
const instance_event_writer_1 = require("../instance-event/instance-event.writer");
|
|
66
|
+
const instance_guard_token_1 = require("./instance-guard.token");
|
|
67
|
+
const instance_guard_runner_1 = require("./instance-guard.runner");
|
|
61
68
|
const workflow_service_task_dispatcher_token_1 = require("./workflow-service-task-dispatcher.token");
|
|
62
69
|
const workflow_token_entity_1 = require("./workflow-token.entity");
|
|
63
70
|
const bpm_root_options_1 = require("../bpm/bpm-root-options");
|
|
@@ -182,6 +189,44 @@ const INACTIVE_MEMBER_NAME_LOCALE = 'zh-Hant-TW';
|
|
|
182
189
|
* every load of the member screen.
|
|
183
190
|
*/
|
|
184
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 = '接續前一段主管鏈、或「起點本人已達簽到職等時略過」,只能在建立簽核任務時使用,此處不支援。';
|
|
208
|
+
/**
|
|
209
|
+
* ADR 27 §4.2: an approver-filled value the provider no longer offers answers
|
|
210
|
+
* 400 with `formDataErrors` naming the field or table cell, `code` kept. Other
|
|
211
|
+
* failures pass through unchanged.
|
|
212
|
+
*/
|
|
213
|
+
async function answerValueNotResolved(run) {
|
|
214
|
+
try {
|
|
215
|
+
return await run();
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (error instanceof form_data_source_errors_1.BPMFormDataSourceValueNotResolvedException) {
|
|
219
|
+
const issue = {
|
|
220
|
+
...(0, form_data_issue_1.createFormDataIssue)(error.location, 'VALUE_NOT_RESOLVED', '的選項已不在可選範圍內,請重新選擇'),
|
|
221
|
+
code: form_data_source_errors_1.BPM_FORM_DATA_SOURCE_ERROR_CODES.VALUE_NOT_RESOLVED,
|
|
222
|
+
};
|
|
223
|
+
throw (0, form_data_issue_1.createFormDataBadRequest)((0, form_data_issue_1.readFormDataIssueMessage)([issue]), [
|
|
224
|
+
issue,
|
|
225
|
+
]);
|
|
226
|
+
}
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
185
230
|
const WORKFLOW_READ_ALL_PERMISSIONS = new Set([
|
|
186
231
|
'bpm:*',
|
|
187
232
|
'bpm.workflow.read_all',
|
|
@@ -189,7 +234,7 @@ const WORKFLOW_READ_ALL_PERMISSIONS = new Set([
|
|
|
189
234
|
'workflow.read_all',
|
|
190
235
|
]);
|
|
191
236
|
let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineService {
|
|
192
|
-
constructor(approvalInstanceRepository, workflowTokenRepository, taskRepository, taskCandidateRepository, taskDecisionRepository, notificationRepository, activityLogRepository, adhocDirectiveRepository, approvalTemplateRepository, approvalTemplateVersionRepository, formDefinitionVersionRepository, attachmentService, conditionService, delegationService, notificationService, signatureService, slaScheduleService, injectedServiceTaskDispatcher, formDataSourceValueResolver, rootOptions, webhookDeliveryService, injectedInitiatorCustomFieldsResolver) {
|
|
237
|
+
constructor(approvalInstanceRepository, workflowTokenRepository, taskRepository, taskCandidateRepository, taskDecisionRepository, notificationRepository, activityLogRepository, adhocDirectiveRepository, approvalTemplateRepository, approvalTemplateVersionRepository, formDefinitionVersionRepository, attachmentService, conditionService, delegationService, notificationService, signatureService, slaScheduleService, injectedServiceTaskDispatcher, formDataSourceValueResolver, rootOptions, webhookDeliveryService, injectedInitiatorCustomFieldsResolver, injectedInstanceGuard, instanceEventWriter) {
|
|
193
238
|
this.approvalInstanceRepository = approvalInstanceRepository;
|
|
194
239
|
this.workflowTokenRepository = workflowTokenRepository;
|
|
195
240
|
this.taskRepository = taskRepository;
|
|
@@ -209,6 +254,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
209
254
|
this.slaScheduleService = slaScheduleService;
|
|
210
255
|
this.formDataSourceValueResolver = formDataSourceValueResolver;
|
|
211
256
|
this.webhookDeliveryService = webhookDeliveryService;
|
|
257
|
+
this.instanceEventWriter = instanceEventWriter;
|
|
212
258
|
this.logger = new common_1.Logger(WorkflowEngineService_1.name);
|
|
213
259
|
/** In-flight inbox reads by member; see `readSharedOpenInboxTasks`. */
|
|
214
260
|
this.openInboxReadsInFlight = new Map();
|
|
@@ -236,7 +282,68 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
236
282
|
injectedInitiatorCustomFieldsResolver ??
|
|
237
283
|
rootOptions?.initiatorCustomFieldsResolver ??
|
|
238
284
|
null;
|
|
285
|
+
// Same three-way order as the initiator resolver: a host binding under the
|
|
286
|
+
// token, then the runtime instance, then no guard at all.
|
|
287
|
+
this.instanceGuard =
|
|
288
|
+
injectedInstanceGuard ?? rootOptions?.instanceGuard ?? null;
|
|
289
|
+
this.instanceGuardTimeoutMs =
|
|
290
|
+
rootOptions?.instanceGuardTimeoutMs ??
|
|
291
|
+
instance_guard_runner_1.BPM_INSTANCE_GUARD_DEFAULT_TIMEOUT_MS;
|
|
239
292
|
this.organizationTimeZone = (0, organization_time_zone_1.resolveBPMOrganizationTimeZone)(rootOptions?.organizationTimeZone);
|
|
293
|
+
this.expressionMissingFormFieldsAsNull =
|
|
294
|
+
rootOptions?.expressionMissingFormFieldsAsNull === true;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Runs the bound guard for one engine path inside its transaction. A no-op
|
|
298
|
+
* when no guard is bound, so a host without one sees no behavior change.
|
|
299
|
+
*/
|
|
300
|
+
async guardInstanceChange(input) {
|
|
301
|
+
if (this.instanceGuard === null) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
await (0, instance_guard_runner_1.runInstanceGuard)(this.instanceGuard, input, this.instanceGuardTimeoutMs);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Writes one case-lifecycle event (ADR 27 §1) in the caller's transaction.
|
|
308
|
+
* Returns before any lookup when no observer is bound, so a host without
|
|
309
|
+
* one pays nothing. `instanceState` is the state the change commits, not the
|
|
310
|
+
* state the instance held before it.
|
|
311
|
+
*/
|
|
312
|
+
async recordInstanceEvent(manager, instance, instanceState, type, fields) {
|
|
313
|
+
if (this.instanceEventWriter?.enabled !== true) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const template = await manager
|
|
317
|
+
.getRepository(approval_template_entity_1.ApprovalTemplateEntity)
|
|
318
|
+
.findOne({
|
|
319
|
+
select: { id: true, key: true },
|
|
320
|
+
where: { id: instance.templateId },
|
|
321
|
+
withDeleted: true,
|
|
322
|
+
});
|
|
323
|
+
const snapshot = {
|
|
324
|
+
instanceState,
|
|
325
|
+
templateId: instance.templateId,
|
|
326
|
+
templateKey: template?.key ?? null,
|
|
327
|
+
templateVersionId: instance.templateVersionId,
|
|
328
|
+
};
|
|
329
|
+
await this.instanceEventWriter.record(manager, instance.id, snapshot, type, fields);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Reads the template version a case runs on, inside the engine transaction,
|
|
333
|
+
* so the guard sees the same snapshot as the decision. A missing row fails
|
|
334
|
+
* the change rather than handing the guard a partial input.
|
|
335
|
+
*/
|
|
336
|
+
async readBoundTemplateVersionOrThrow(manager, instance) {
|
|
337
|
+
const version = await manager
|
|
338
|
+
.getRepository(approval_template_version_entity_1.ApprovalTemplateVersionEntity)
|
|
339
|
+
.findOne({ where: { id: instance.templateVersionId } });
|
|
340
|
+
if (!version) {
|
|
341
|
+
throw new common_1.ConflictException({
|
|
342
|
+
code: 'TEMPLATE_VERSION_NOT_FOUND',
|
|
343
|
+
message: 'The template version of this instance no longer exists.',
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return version;
|
|
240
347
|
}
|
|
241
348
|
/**
|
|
242
349
|
* Today's date as the organization sees it (`organizationTimeZone`), for
|
|
@@ -272,6 +379,151 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
272
379
|
}, Promise.resolve());
|
|
273
380
|
return result;
|
|
274
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
|
+
}
|
|
275
527
|
/**
|
|
276
528
|
* Queues a webhook for after the commit. A manager this service did not open
|
|
277
529
|
* has no queue and no commit it can see, so that webhook goes out on the spot
|
|
@@ -308,6 +560,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
308
560
|
input.submittedByMemberId.trim() === '')) {
|
|
309
561
|
throw new common_1.BadRequestException('submittedByMemberId must be a non-empty string');
|
|
310
562
|
}
|
|
563
|
+
const hostCustomFields = readHostInitiatorCustomFields(input);
|
|
311
564
|
const submittedFormData = parseJsonObject(input.formDataJson, 'formDataJson');
|
|
312
565
|
const businessKey = normalizeBusinessKey(input.businessKey);
|
|
313
566
|
const submittedByMemberId = input.submittedByMemberId ?? input.initiatorMemberId;
|
|
@@ -319,6 +572,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
319
572
|
throw new common_1.ConflictException('Approval template does not have a published version');
|
|
320
573
|
}
|
|
321
574
|
const templateVersion = await this.getPublishedTemplateVersionOrThrow(template.currentVersionId);
|
|
575
|
+
if (input.endUserSubmission === true) {
|
|
576
|
+
assertEndUserMayLaunch(templateVersion);
|
|
577
|
+
}
|
|
322
578
|
const formDefinitionVersion = await this.getPublishedFormVersionOrThrow(templateVersion.formDefinitionVersionId);
|
|
323
579
|
// The fields an approver fills in belong to the approval, not the
|
|
324
580
|
// application: an end user's value for one is dropped (it would otherwise
|
|
@@ -327,13 +583,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
327
583
|
// checked here; required and row minimums wait for the node that fills
|
|
328
584
|
// them. Read off the version the instance is bound to, not the template's
|
|
329
585
|
// latest, so a publish in between cannot change what is stripped.
|
|
330
|
-
const
|
|
586
|
+
const deferral = (0, approver_filled_table_columns_1.readApproverFilledDeferral)(templateVersion.workflowDefinition);
|
|
331
587
|
const formData = input.endUserSubmission === true
|
|
332
|
-
? (0, bpm_core_shared_1.omitFormDataKeys)(submittedFormData,
|
|
588
|
+
? (0, approver_filled_table_columns_1.applyOpenColumnCells)((0, bpm_core_shared_1.omitFormDataKeys)(submittedFormData, deferral.wholeKeys), null, deferral)
|
|
333
589
|
: submittedFormData;
|
|
334
|
-
validateSubmittedFormData(formDefinitionVersion.schema, formData, {
|
|
335
|
-
deferredFieldKeys,
|
|
336
|
-
});
|
|
590
|
+
validateSubmittedFormData((0, approver_filled_table_columns_1.projectOpenColumnsOptional)(formDefinitionVersion.schema, deferral), formData, { deferredFieldKeys: deferral.wholeKeys });
|
|
337
591
|
// Checked against the bound version's schema before any data source is
|
|
338
592
|
// called, so a bad value is refused cheaply. The transaction below
|
|
339
593
|
// confirms under the template lock that this version is still current
|
|
@@ -347,7 +601,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
347
601
|
});
|
|
348
602
|
const initiatorMetadataSnapshot = input.initiatorMetadataSnapshotJson
|
|
349
603
|
? parseJsonObject(input.initiatorMetadataSnapshotJson, 'initiatorMetadataSnapshotJson')
|
|
350
|
-
: await this.buildInitiatorMetadataSnapshot({
|
|
604
|
+
: mergeHostInitiatorCustomFields(await this.buildInitiatorMetadataSnapshot({
|
|
351
605
|
authContext: submitAuthContext,
|
|
352
606
|
memberId: input.initiatorMemberId,
|
|
353
607
|
submission: {
|
|
@@ -355,7 +609,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
355
609
|
templateId: template.id,
|
|
356
610
|
templateVersionId: templateVersion.id,
|
|
357
611
|
},
|
|
358
|
-
});
|
|
612
|
+
}), hostCustomFields);
|
|
359
613
|
if (!this.conditionService.evaluateBoolean(templateVersion.initiatorPolicyCel, buildInitiatorPolicyContext(initiatorMetadataSnapshot), 'initiatorPolicyCel',
|
|
360
614
|
// The policy is written in its version's dialect (ADR 22 §3.3).
|
|
361
615
|
readExpressionDialectOptions(templateVersion.initiatorPolicyCel, templateVersion.workflowDefinition))) {
|
|
@@ -396,7 +650,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
396
650
|
const instanceRepository = manager.getRepository(approval_instance_entity_1.ApprovalInstanceEntity);
|
|
397
651
|
const tokenRepository = manager.getRepository(workflow_token_entity_1.WorkflowTokenEntity);
|
|
398
652
|
const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
|
|
399
|
-
const
|
|
653
|
+
const instanceDraft = instanceRepository.create({
|
|
400
654
|
completedAt: null,
|
|
401
655
|
formData,
|
|
402
656
|
formDataOptionSnapshot,
|
|
@@ -416,7 +670,24 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
416
670
|
templateVersionId: templateVersion.id,
|
|
417
671
|
title: input.title?.trim() || template.name,
|
|
418
672
|
workflowSnapshot: templateVersion.workflowDefinition,
|
|
419
|
-
})
|
|
673
|
+
});
|
|
674
|
+
// ADR 27 §2: the host guard sees the case as it is about to be saved,
|
|
675
|
+
// under the template lock. A refusal throws here, before any insert.
|
|
676
|
+
await this.guardInstanceChange({
|
|
677
|
+
action: null,
|
|
678
|
+
actorMemberId: submittedByMemberId,
|
|
679
|
+
authContext: authContext ?? null,
|
|
680
|
+
automatic: null,
|
|
681
|
+
changedFieldKeys: Object.keys(formData),
|
|
682
|
+
formData,
|
|
683
|
+
instance: instanceDraft,
|
|
684
|
+
kind: 'SUBMIT',
|
|
685
|
+
manager,
|
|
686
|
+
node: null,
|
|
687
|
+
task: null,
|
|
688
|
+
templateVersion,
|
|
689
|
+
});
|
|
690
|
+
const instance = await instanceRepository.save(instanceDraft);
|
|
420
691
|
await this.attachmentService.bindFormDataAttachmentsToInstance(manager, {
|
|
421
692
|
formData,
|
|
422
693
|
instanceId: instance.id,
|
|
@@ -454,6 +725,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
454
725
|
taskId: null,
|
|
455
726
|
}),
|
|
456
727
|
]);
|
|
728
|
+
await this.recordInstanceEvent(manager, instance, instance.state, 'INSTANCE_SUBMITTED', { actorMemberId: submittedByMemberId });
|
|
457
729
|
await this.processRunningInstance(manager, instance);
|
|
458
730
|
return instance;
|
|
459
731
|
}).catch((error) =>
|
|
@@ -494,6 +766,66 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
494
766
|
schema,
|
|
495
767
|
});
|
|
496
768
|
}
|
|
769
|
+
/**
|
|
770
|
+
* ADR 27 §4(b): asks the data-source providers again, under the decision
|
|
771
|
+
* lock and as the approver, for the options of the approver-fillable fields.
|
|
772
|
+
* A value that is no longer among the options is refused by the resolver
|
|
773
|
+
* (`VALUE_NOT_RESOLVED`). Returns snapshots for the scoped fields only; the
|
|
774
|
+
* caller swaps them in for the stored ones.
|
|
775
|
+
*/
|
|
776
|
+
async refreshApproverFilledOptionSnapshots({ approverAuthContext, fieldKeys, formData, instance, }) {
|
|
777
|
+
const schema = readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot);
|
|
778
|
+
return this.resolveFormDataOptionSnapshots({
|
|
779
|
+
authContext: approverAuthContext,
|
|
780
|
+
formData,
|
|
781
|
+
previousFormData: instance.formData,
|
|
782
|
+
previousSnapshots: readSnapshotWithoutFields(instance.formDataOptionSnapshot, fieldKeys),
|
|
783
|
+
revalidateAll: true,
|
|
784
|
+
schema: {
|
|
785
|
+
...schema,
|
|
786
|
+
fields: schema.fields.filter((field) => fieldKeys.has(field.fieldKey)),
|
|
787
|
+
},
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* ADR 27 §4(b): asks the data-source providers again, as the approver, for
|
|
792
|
+
* the open data-source cells of column-mode tables. `revalidateAll` false lets
|
|
793
|
+
* the resolver reuse the stored options of cells whose value and bindings did
|
|
794
|
+
* not move. Returns snapshots for the cells of `cellSchema` only.
|
|
795
|
+
*/
|
|
796
|
+
async refreshApproverFilledOpenCellSnapshots({ approverAuthContext, cellSchema, formData, instance, revalidateAll, }) {
|
|
797
|
+
return this.resolveFormDataOptionSnapshots({
|
|
798
|
+
authContext: approverAuthContext,
|
|
799
|
+
formData,
|
|
800
|
+
previousFormData: instance.formData,
|
|
801
|
+
previousSnapshots: instance.formDataOptionSnapshot ?? {},
|
|
802
|
+
revalidateAll,
|
|
803
|
+
schema: cellSchema,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Refuses an end user's resubmission of an instance bound to a `HOST_ONLY`
|
|
808
|
+
* version (ADR 27 §5). Checked before any data source is called and outside
|
|
809
|
+
* the lock: an instance never changes version and a published version never
|
|
810
|
+
* changes `launchMode`, so the answer cannot go stale. An instance that does
|
|
811
|
+
* not exist is left to the locked read, which reports it.
|
|
812
|
+
*/
|
|
813
|
+
async assertEndUserMayResubmit(instanceId) {
|
|
814
|
+
const instance = await this.approvalInstanceRepository.findOne({
|
|
815
|
+
select: { id: true, templateVersionId: true },
|
|
816
|
+
where: { id: instanceId },
|
|
817
|
+
});
|
|
818
|
+
if (!instance) {
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const version = await this.approvalTemplateVersionRepository.findOne({
|
|
822
|
+
select: { id: true, launchMode: true },
|
|
823
|
+
where: { id: instance.templateVersionId },
|
|
824
|
+
});
|
|
825
|
+
if (version) {
|
|
826
|
+
assertEndUserMayLaunch(version);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
497
829
|
async prepareResubmitFormDataOptions(input, submittedFormData, authContext) {
|
|
498
830
|
const instance = await this.approvalInstanceRepository.findOne({
|
|
499
831
|
where: { id: input.instanceId },
|
|
@@ -512,13 +844,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
512
844
|
throw new common_1.ConflictException('Approval template is deactivated');
|
|
513
845
|
}
|
|
514
846
|
const schema = readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot);
|
|
515
|
-
const
|
|
847
|
+
const deferral = (0, approver_filled_table_columns_1.readApproverFilledDeferral)(instance.workflowSnapshot);
|
|
516
848
|
// Same overlay as inside the lock, so the options resolved here are for
|
|
517
849
|
// the form data that is actually saved.
|
|
518
850
|
const formData = input.endUserSubmission === true
|
|
519
|
-
? (0, bpm_core_shared_1.overlayStoredFormDataKeys)(submittedFormData, instance.formData,
|
|
851
|
+
? (0, approver_filled_table_columns_1.applyOpenColumnCells)((0, bpm_core_shared_1.overlayStoredFormDataKeys)(submittedFormData, instance.formData, deferral.wholeKeys), instance.formData, deferral)
|
|
520
852
|
: submittedFormData;
|
|
521
|
-
validateSubmittedFormData(schema, formData, { deferredFieldKeys });
|
|
853
|
+
validateSubmittedFormData((0, approver_filled_table_columns_1.projectOpenColumnsOptional)(schema, deferral), formData, { deferredFieldKeys: deferral.wholeKeys });
|
|
522
854
|
await this.assertCurrentFilterableFormDataValues(instance.templateId, formData);
|
|
523
855
|
const formDataOptionSnapshot = await this.resolveFormDataOptionSnapshots({
|
|
524
856
|
authContext,
|
|
@@ -556,7 +888,30 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
556
888
|
await this.processRunningInstance(manager, instance);
|
|
557
889
|
});
|
|
558
890
|
}
|
|
891
|
+
/**
|
|
892
|
+
* Records `TASK_AUTO_DECISION_FAILED` (ADR 27 §3) in its own transaction.
|
|
893
|
+
* Used after an automatic decision was refused by the instance guard: the
|
|
894
|
+
* decision's transaction has already rolled back, so the event cannot ride
|
|
895
|
+
* on it. Returns before any query when no observer is bound.
|
|
896
|
+
*/
|
|
897
|
+
async recordTaskAutoDecisionFailed(input) {
|
|
898
|
+
if (this.instanceEventWriter?.enabled !== true) {
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
await this.runEngineTransaction(async (manager) => {
|
|
902
|
+
const instance = await manager
|
|
903
|
+
.getRepository(approval_instance_entity_1.ApprovalInstanceEntity)
|
|
904
|
+
.findOne({ where: { id: input.instanceId } });
|
|
905
|
+
if (!instance) {
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
await this.recordInstanceEvent(manager, instance, instance.state, 'TASK_AUTO_DECISION_FAILED', { reason: input.reason, taskId: input.taskId });
|
|
909
|
+
});
|
|
910
|
+
}
|
|
559
911
|
async decideTask(input, options = {}) {
|
|
912
|
+
return this.withAdvanceFailureRecorded(() => this.decideTaskInTransaction(input, options));
|
|
913
|
+
}
|
|
914
|
+
async decideTaskInTransaction(input, options) {
|
|
560
915
|
return this.runEngineTransaction(async (manager) => {
|
|
561
916
|
const taskRepository = manager.getRepository(task_entity_1.TaskEntity);
|
|
562
917
|
const taskCandidateRepository = manager.getRepository(task_candidate_entity_1.TaskCandidateEntity);
|
|
@@ -719,9 +1074,14 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
719
1074
|
if (filledFormData !== null && task.isAdhoc) {
|
|
720
1075
|
throw new common_1.ForbiddenException(`Ad-hoc task ${task.id} cannot fill in form fields`);
|
|
721
1076
|
}
|
|
1077
|
+
// ADR 27 §4(a): column-mode tables are accepted here and settled
|
|
1078
|
+
// cell by cell below. Their open data-source columns are re-resolved per
|
|
1079
|
+
// row as the approver (ADR 27 §4(b)).
|
|
1080
|
+
const nodeColumnFillMap = (0, approver_filled_table_columns_1.readNodeColumnFillMap)(taskNode);
|
|
722
1081
|
if (filledFormData !== null) {
|
|
723
1082
|
const fillableFieldKeys = (0, bpm_core_shared_1.readNodeFillableFieldKeys)(taskNode);
|
|
724
|
-
const refusedFieldKeys = Object.keys(filledFormData).filter((fieldKey) => !fillableFieldKeys.has(fieldKey)
|
|
1083
|
+
const refusedFieldKeys = Object.keys(filledFormData).filter((fieldKey) => !fillableFieldKeys.has(fieldKey) &&
|
|
1084
|
+
!nodeColumnFillMap.has(fieldKey));
|
|
725
1085
|
if (refusedFieldKeys.length > 0) {
|
|
726
1086
|
throw new common_1.ForbiddenException(`Workflow node ${task.nodeId} does not let approvers fill in: ${refusedFieldKeys.join(', ')}`);
|
|
727
1087
|
}
|
|
@@ -732,35 +1092,134 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
732
1092
|
(0, form_data_fingerprint_1.computeFormDataFingerprint)(instance.formData)) {
|
|
733
1093
|
throw new common_1.ConflictException(`Approval instance ${instance.id} form data changed; refresh and retry`);
|
|
734
1094
|
}
|
|
1095
|
+
// ADR 27 §4(a): each column-mode table is merged per cell against the
|
|
1096
|
+
// stored rows. A changed row count, row order or applicant cell is
|
|
1097
|
+
// refused before anything below sees the payload.
|
|
1098
|
+
const settledFormData = filledFormData === null
|
|
1099
|
+
? null
|
|
1100
|
+
: (0, approver_filled_table_columns_1.settleColumnFillPayload)(filledFormData, instance.formData, nodeColumnFillMap);
|
|
735
1101
|
// Only keys whose value really changes are written and reported. A key
|
|
736
1102
|
// the form data does not have yet counts as changed even when the new
|
|
737
1103
|
// value is null.
|
|
738
|
-
const changedFieldKeys =
|
|
1104
|
+
const changedFieldKeys = settledFormData === null
|
|
739
1105
|
? []
|
|
740
|
-
: Object.keys(
|
|
1106
|
+
: Object.keys(settledFormData)
|
|
741
1107
|
.filter((fieldKey) => !Object.prototype.hasOwnProperty.call(instance.formData, fieldKey) ||
|
|
742
|
-
(0, form_data_fingerprint_1.computeStableValueFingerprint)(
|
|
1108
|
+
(0, form_data_fingerprint_1.computeStableValueFingerprint)(settledFormData[fieldKey]) !==
|
|
743
1109
|
(0, form_data_fingerprint_1.computeStableValueFingerprint)(instance.formData[fieldKey]))
|
|
744
1110
|
.sort();
|
|
745
|
-
const mergedFormData = { ...instance.formData, ...
|
|
1111
|
+
const mergedFormData = { ...instance.formData, ...settledFormData };
|
|
1112
|
+
// ADR 27 §4.3: an approver may change a column only if no column this
|
|
1113
|
+
// approver may not fill reads it. Such a dependent keeps its stored value
|
|
1114
|
+
// here, so a changed source would leave it stale. Fail closed.
|
|
1115
|
+
if (nodeColumnFillMap.size > 0) {
|
|
1116
|
+
(0, approver_filled_table_columns_1.assertDependentColumnsFrozen)(readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot), taskNode, instance.formData, mergedFormData);
|
|
1117
|
+
}
|
|
746
1118
|
// Every approval at a node that lets its approver fill in fields is
|
|
747
1119
|
// held to those fields, whether or not this request sent any: the host
|
|
748
|
-
// may have pre-filled a table the approver then leaves as is.
|
|
1120
|
+
// may have pre-filled a table the approver then leaves as is. A column
|
|
1121
|
+
// this node grants REQUIRED is required in the check, not only in the
|
|
1122
|
+
// template.
|
|
749
1123
|
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED && !task.isAdhoc) {
|
|
750
|
-
const nodeFillableFieldKeys =
|
|
1124
|
+
const nodeFillableFieldKeys = new Set([
|
|
1125
|
+
...(0, bpm_core_shared_1.readNodeFillableFieldKeys)(taskNode),
|
|
1126
|
+
...nodeColumnFillMap.keys(),
|
|
1127
|
+
]);
|
|
751
1128
|
if (nodeFillableFieldKeys.size > 0) {
|
|
752
|
-
validateFilledFormFields(readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot), mergedFormData, nodeFillableFieldKeys, (0, bpm_core_shared_1.readNodeRequiredFieldKeys)(taskNode));
|
|
1129
|
+
validateFilledFormFields((0, approver_filled_table_columns_1.projectNodeOpenColumnsRequired)(readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot), nodeColumnFillMap), mergedFormData, nodeFillableFieldKeys, (0, bpm_core_shared_1.readNodeRequiredFieldKeys)(taskNode));
|
|
753
1130
|
}
|
|
754
1131
|
}
|
|
755
|
-
|
|
1132
|
+
// ADR 27 §4(b): an approver-filled data-source field is judged as the
|
|
1133
|
+
// approver's own pick. The provider is asked again under the lock with
|
|
1134
|
+
// the approver's identity, so a value outside the current options is
|
|
1135
|
+
// refused and the recorded label is refreshed before anything is
|
|
1136
|
+
// written. Table data-source columns resolve per row (ROW_FIELD).
|
|
1137
|
+
const fillableFieldKeys = (0, bpm_core_shared_1.readNodeFillableFieldKeys)(taskNode);
|
|
1138
|
+
const dataSourceRefreshKeys = input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED && !task.isAdhoc
|
|
1139
|
+
? fillableFieldKeys
|
|
1140
|
+
: new Set(changedFieldKeys.filter((fieldKey) => fillableFieldKeys.has(fieldKey)));
|
|
1141
|
+
const approverAuthContext = options.approverAuthContext ??
|
|
1142
|
+
createWorkflowAuthContext(input.decidedByMemberId);
|
|
1143
|
+
const refreshedFormDataOptionSnapshots = dataSourceRefreshKeys.size === 0
|
|
1144
|
+
? null
|
|
1145
|
+
: await answerValueNotResolved(() => this.refreshApproverFilledOptionSnapshots({
|
|
1146
|
+
approverAuthContext,
|
|
1147
|
+
fieldKeys: dataSourceRefreshKeys,
|
|
1148
|
+
formData: mergedFormData,
|
|
1149
|
+
instance,
|
|
1150
|
+
}));
|
|
1151
|
+
const wholeFieldOptionSnapshot = refreshedFormDataOptionSnapshots === null
|
|
1152
|
+
? (instance.formDataOptionSnapshot ?? {})
|
|
1153
|
+
: {
|
|
1154
|
+
...readSnapshotWithoutFields(instance.formDataOptionSnapshot, dataSourceRefreshKeys),
|
|
1155
|
+
...refreshedFormDataOptionSnapshots,
|
|
1156
|
+
};
|
|
1157
|
+
// ADR 27 §4(b): the open data-source cells of column-mode tables. An
|
|
1158
|
+
// APPROVED decision re-resolves all of them; any other decision only
|
|
1159
|
+
// when a column-mode table's cells changed. The form schema is read
|
|
1160
|
+
// only for a node that has column-mode fills, so other nodes never
|
|
1161
|
+
// touch the snapshot's schema.
|
|
1162
|
+
const openCellSchema = nodeColumnFillMap.size === 0
|
|
1163
|
+
? null
|
|
1164
|
+
: (0, approver_filled_table_columns_1.readOpenDataSourceCellSchema)(readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot), nodeColumnFillMap);
|
|
1165
|
+
const openCellTableKeys = new Set((openCellSchema?.fields ?? []).map((field) => field.fieldKey));
|
|
1166
|
+
const revalidateAllOpenCells = input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED && !task.isAdhoc;
|
|
1167
|
+
const hasChangedOpenCellTable = changedFieldKeys.some((fieldKey) => openCellTableKeys.has(fieldKey));
|
|
1168
|
+
const refreshedOpenCellSnapshots = openCellSchema === null ||
|
|
1169
|
+
openCellSchema.fields.length === 0 ||
|
|
1170
|
+
(!revalidateAllOpenCells && !hasChangedOpenCellTable)
|
|
1171
|
+
? null
|
|
1172
|
+
: await answerValueNotResolved(() => this.refreshApproverFilledOpenCellSnapshots({
|
|
1173
|
+
approverAuthContext,
|
|
1174
|
+
cellSchema: openCellSchema,
|
|
1175
|
+
formData: mergedFormData,
|
|
1176
|
+
instance,
|
|
1177
|
+
revalidateAll: revalidateAllOpenCells,
|
|
1178
|
+
}));
|
|
1179
|
+
const nextFormDataOptionSnapshot = refreshedOpenCellSnapshots === null || openCellSchema === null
|
|
1180
|
+
? wholeFieldOptionSnapshot
|
|
1181
|
+
: {
|
|
1182
|
+
...(0, approver_filled_table_columns_1.readSnapshotWithoutOpenCells)(wholeFieldOptionSnapshot, openCellSchema),
|
|
1183
|
+
...(0, approver_filled_table_columns_1.readOpenCellSnapshots)(refreshedOpenCellSnapshots, openCellSchema),
|
|
1184
|
+
};
|
|
1185
|
+
// ADR 27 §2: the host guard judges the decision as it will be
|
|
1186
|
+
// committed, after the form checks and before anything is written.
|
|
1187
|
+
// The version is read only when a guard is bound, so an engine
|
|
1188
|
+
// without one issues exactly the queries it did before.
|
|
1189
|
+
if (this.instanceGuard !== null) {
|
|
1190
|
+
await this.guardInstanceChange({
|
|
1191
|
+
action: input.action,
|
|
1192
|
+
actorMemberId: input.decidedByMemberId,
|
|
1193
|
+
// The approver's own context when the GraphQL mutation passed one;
|
|
1194
|
+
// never the member-id stub the data-source path falls back to.
|
|
1195
|
+
authContext: options.approverAuthContext ?? null,
|
|
1196
|
+
automatic: options.automatic ?? null,
|
|
1197
|
+
changedFieldKeys,
|
|
1198
|
+
formData: mergedFormData,
|
|
1199
|
+
instance,
|
|
1200
|
+
kind: 'DECIDE',
|
|
1201
|
+
manager,
|
|
1202
|
+
node: taskNode,
|
|
1203
|
+
task,
|
|
1204
|
+
templateVersion: await this.readBoundTemplateVersionOrThrow(manager, instance),
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
const formDataChanged = settledFormData !== null && changedFieldKeys.length > 0;
|
|
1208
|
+
const formDataOptionSnapshotChanged = (0, form_data_fingerprint_1.computeStableValueFingerprint)(nextFormDataOptionSnapshot) !==
|
|
1209
|
+
(0, form_data_fingerprint_1.computeStableValueFingerprint)(instance.formDataOptionSnapshot ?? {});
|
|
1210
|
+
if (formDataChanged) {
|
|
756
1211
|
await this.assertCurrentFilterableFormDataValues(instance.templateId, mergedFormData, manager);
|
|
1212
|
+
}
|
|
1213
|
+
if (formDataChanged || formDataOptionSnapshotChanged) {
|
|
757
1214
|
// Merged into the shared `instance` before it is signed and before
|
|
758
1215
|
// the token moves on: the signature must cover the filled values, a
|
|
759
1216
|
// gateway after this node must route on them, and an end node saves
|
|
760
|
-
// this same object, which would write the old values back.
|
|
1217
|
+
// this same object, which would write the old values back. The
|
|
1218
|
+
// refreshed option labels go in the same write.
|
|
761
1219
|
const savedInstance = await instanceRepository.save({
|
|
762
1220
|
...instance,
|
|
763
1221
|
formData: mergedFormData,
|
|
1222
|
+
formDataOptionSnapshot: nextFormDataOptionSnapshot,
|
|
764
1223
|
});
|
|
765
1224
|
Object.assign(instance, savedInstance);
|
|
766
1225
|
}
|
|
@@ -874,12 +1333,25 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
874
1333
|
},
|
|
875
1334
|
taskId: completedTask.id,
|
|
876
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
|
+
});
|
|
877
1348
|
// Resolve the recipient's "待簽" notification so its inline 同意/拒絕
|
|
878
1349
|
// actions disappear once the task is decided. When the task has ended
|
|
879
1350
|
// (rejected / returned, or an approval that completed it), every other
|
|
880
1351
|
// candidate's notification is superseded too; a non-completing approval
|
|
881
1352
|
// on a multi-approver task leaves the remaining approvers' open.
|
|
882
|
-
const taskEnded = input.action !== workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED ||
|
|
1353
|
+
const taskEnded = input.action !== workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED ||
|
|
1354
|
+
shouldCompleteTask;
|
|
883
1355
|
await this.notificationService.resolveTaskNotifications({
|
|
884
1356
|
actingMemberId: input.decidedByMemberId,
|
|
885
1357
|
manager,
|
|
@@ -907,69 +1379,151 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
907
1379
|
if (await this.hasOpenTasksForTokenNode(manager, claimedTask.tokenId, claimedTask.nodeId)) {
|
|
908
1380
|
return decision;
|
|
909
1381
|
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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);
|
|
923
1402
|
});
|
|
924
|
-
await this.dispatchAdhocStageNotifications(manager, instance, claimedTask.nodeId, 'APPROVED');
|
|
925
|
-
await this.advanceTokenToOutgoingNodes(manager, instance, activeToken, readWorkflowNodeOrThrow(instance.workflowSnapshot, claimedTask.nodeId));
|
|
926
|
-
await this.processRunningInstance(manager, instance);
|
|
927
1403
|
return decision;
|
|
928
1404
|
}
|
|
929
1405
|
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED) {
|
|
930
1406
|
return decision;
|
|
931
1407
|
}
|
|
932
1408
|
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.RETURNED) {
|
|
933
|
-
await this.returnInstanceToNode(manager, instance, completedTask, returnToNodeId, decidedAt);
|
|
1409
|
+
await runInstanceAdvanceStep(advanceContext('RETURNED'), () => this.returnInstanceToNode(manager, instance, completedTask, returnToNodeId, decidedAt, input.decidedByMemberId));
|
|
934
1410
|
return decision;
|
|
935
1411
|
}
|
|
936
1412
|
if (await this.handleAdhocPreApprovalRejection(manager, instance, completedTask, decidedAt, decisionComment)) {
|
|
937
1413
|
return decision;
|
|
938
1414
|
}
|
|
939
|
-
await this.rejectInstance(manager, instance, completedTask, decidedAt);
|
|
1415
|
+
await this.rejectInstance(manager, instance, completedTask, decidedAt, input.decidedByMemberId);
|
|
940
1416
|
return decision;
|
|
941
1417
|
});
|
|
942
1418
|
}
|
|
943
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) {
|
|
944
1501
|
return this.runEngineTransaction(async (manager) => {
|
|
945
1502
|
await manager.query('SELECT pg_advisory_xact_lock(hashtext($1))', [
|
|
946
|
-
|
|
1503
|
+
cancellation.instanceId,
|
|
947
1504
|
]);
|
|
948
1505
|
const instanceRepository = manager.getRepository(approval_instance_entity_1.ApprovalInstanceEntity);
|
|
949
1506
|
const instance = await instanceRepository.findOne({
|
|
950
|
-
where: { id:
|
|
1507
|
+
where: { id: cancellation.instanceId },
|
|
951
1508
|
});
|
|
952
1509
|
if (!instance) {
|
|
953
|
-
throw new common_1.NotFoundException(`Approval instance ${
|
|
954
|
-
}
|
|
955
|
-
if (instance.initiatorMemberId !== input.cancelledByMemberId) {
|
|
956
|
-
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`);
|
|
957
1511
|
}
|
|
1512
|
+
cancellation.authorize(instance);
|
|
958
1513
|
if (instance.state !== workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING &&
|
|
959
1514
|
instance.state !== workflow_engine_enums_1.ApprovalInstanceStateEnum.RETURNED) {
|
|
960
1515
|
throw new common_1.ConflictException(`Approval instance ${instance.id} is not cancellable`);
|
|
961
1516
|
}
|
|
962
1517
|
const cancelledInstance = await this.closeInstance(manager, instance, workflow_engine_enums_1.ApprovalInstanceStateEnum.CANCELLED);
|
|
963
1518
|
await manager.getRepository(activity_log_entity_1.ActivityLogEntity).save(manager.getRepository(activity_log_entity_1.ActivityLogEntity).create({
|
|
964
|
-
actorMemberId:
|
|
1519
|
+
actorMemberId: cancellation.actorMemberId,
|
|
965
1520
|
eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.INSTANCE_CANCELLED,
|
|
966
1521
|
instanceId: instance.id,
|
|
967
1522
|
nodeId: null,
|
|
968
|
-
payload:
|
|
969
|
-
comment: input.comment?.trim() || null,
|
|
970
|
-
},
|
|
1523
|
+
payload: cancellation.payload,
|
|
971
1524
|
taskId: null,
|
|
972
1525
|
}));
|
|
1526
|
+
await this.recordInstanceEvent(manager, cancelledInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.CANCELLED, 'INSTANCE_CANCELLED', { actorMemberId: cancellation.actorMemberId });
|
|
973
1527
|
return cancelledInstance;
|
|
974
1528
|
});
|
|
975
1529
|
}
|
|
@@ -1007,6 +1561,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
1007
1561
|
},
|
|
1008
1562
|
taskId: input.taskId ?? null,
|
|
1009
1563
|
}));
|
|
1564
|
+
await this.recordInstanceEvent(manager, expiredInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.EXPIRED, 'INSTANCE_EXPIRED', {});
|
|
1010
1565
|
return expiredInstance;
|
|
1011
1566
|
});
|
|
1012
1567
|
}
|
|
@@ -1035,7 +1590,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
1035
1590
|
return closedInstance;
|
|
1036
1591
|
}
|
|
1037
1592
|
async resubmitApprovalInstance(input, authContext) {
|
|
1593
|
+
return this.withAdvanceFailureRecorded(() => this.resubmitApprovalInstanceInTransaction(input, authContext));
|
|
1594
|
+
}
|
|
1595
|
+
async resubmitApprovalInstanceInTransaction(input, authContext) {
|
|
1038
1596
|
const submittedFormData = parseJsonObject(input.formDataJson, 'formDataJson');
|
|
1597
|
+
if (input.endUserSubmission === true) {
|
|
1598
|
+
await this.assertEndUserMayResubmit(input.instanceId);
|
|
1599
|
+
}
|
|
1039
1600
|
const preparation = this.formDataSourceValueResolver
|
|
1040
1601
|
? await this.prepareResubmitFormDataOptions(input, submittedFormData, authContext ?? createWorkflowAuthContext(input.initiatorMemberId))
|
|
1041
1602
|
: null;
|
|
@@ -1065,20 +1626,37 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
1065
1626
|
throw new common_1.ConflictException('Approval instance changed while options were resolving; refresh and retry');
|
|
1066
1627
|
}
|
|
1067
1628
|
const resubmitSchema = readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot);
|
|
1068
|
-
const
|
|
1629
|
+
const deferral = (0, approver_filled_table_columns_1.readApproverFilledDeferral)(instance.workflowSnapshot);
|
|
1069
1630
|
// An end user gets back the value an approver already filled in, and
|
|
1070
1631
|
// loses a key the stored data never had: the renderer seeds `false`,
|
|
1071
1632
|
// `[]` and empty rows for every field, none of which an approver wrote.
|
|
1633
|
+
// An open cell of a table is settled per row the same way.
|
|
1072
1634
|
const formData = input.endUserSubmission === true
|
|
1073
|
-
? (0, bpm_core_shared_1.overlayStoredFormDataKeys)(submittedFormData, instance.formData,
|
|
1635
|
+
? (0, approver_filled_table_columns_1.applyOpenColumnCells)((0, bpm_core_shared_1.overlayStoredFormDataKeys)(submittedFormData, instance.formData, deferral.wholeKeys), instance.formData, deferral)
|
|
1074
1636
|
: submittedFormData;
|
|
1075
|
-
validateSubmittedFormData(resubmitSchema, formData, {
|
|
1076
|
-
deferredFieldKeys,
|
|
1077
|
-
});
|
|
1637
|
+
validateSubmittedFormData((0, approver_filled_table_columns_1.projectOpenColumnsOptional)(resubmitSchema, deferral), formData, { deferredFieldKeys: deferral.wholeKeys });
|
|
1078
1638
|
await this.assertCurrentFilterableFormDataValues(instance.templateId, formData, manager);
|
|
1079
1639
|
if (!preparation) {
|
|
1080
1640
|
assertNoUnresolvableDynamicValues(resubmitSchema, formData);
|
|
1081
1641
|
}
|
|
1642
|
+
// ADR 27 §2: the host guard judges the resubmission as it will be
|
|
1643
|
+
// committed, after every form check and before the write.
|
|
1644
|
+
if (this.instanceGuard !== null) {
|
|
1645
|
+
await this.guardInstanceChange({
|
|
1646
|
+
action: null,
|
|
1647
|
+
actorMemberId: input.initiatorMemberId,
|
|
1648
|
+
authContext: authContext ?? null,
|
|
1649
|
+
automatic: null,
|
|
1650
|
+
changedFieldKeys: listChangedFormDataKeys(instance.formData, formData),
|
|
1651
|
+
formData,
|
|
1652
|
+
instance,
|
|
1653
|
+
kind: 'RESUBMIT',
|
|
1654
|
+
manager,
|
|
1655
|
+
node: null,
|
|
1656
|
+
task: null,
|
|
1657
|
+
templateVersion: await this.readBoundTemplateVersionOrThrow(manager, instance),
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1082
1660
|
const resubmittedInstance = await instanceRepository.save({
|
|
1083
1661
|
...instance,
|
|
1084
1662
|
completedAt: null,
|
|
@@ -1091,6 +1669,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
1091
1669
|
state: workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING,
|
|
1092
1670
|
title: input.title?.trim() || instance.title,
|
|
1093
1671
|
});
|
|
1672
|
+
// Written before the resume below, so RESUBMITTED precedes any
|
|
1673
|
+
// NODE_ACTIVATED that the resume produces in the same transaction.
|
|
1674
|
+
await this.recordInstanceEvent(manager, resubmittedInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING, 'INSTANCE_RESUBMITTED', { actorMemberId: input.initiatorMemberId });
|
|
1094
1675
|
await this.attachmentService.bindFormDataAttachmentsToInstance(manager, {
|
|
1095
1676
|
formData,
|
|
1096
1677
|
instanceId: instance.id,
|
|
@@ -1118,7 +1699,15 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
1118
1699
|
},
|
|
1119
1700
|
taskId: null,
|
|
1120
1701
|
}));
|
|
1121
|
-
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));
|
|
1122
1711
|
const refreshedInstance = await instanceRepository.findOne({
|
|
1123
1712
|
where: { id: instance.id },
|
|
1124
1713
|
});
|
|
@@ -1404,7 +1993,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
1404
1993
|
}
|
|
1405
1994
|
async resolveAdhocTaskCandidates(manager, instance, node, label, targetValue) {
|
|
1406
1995
|
const exclusions = await this.readNodeExclusions(manager, instance, node);
|
|
1407
|
-
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);
|
|
1408
1997
|
// Ad-hoc approvers are picked by a person, not walked up a manager chain,
|
|
1409
1998
|
// so the chain's grade rule does not apply to them.
|
|
1410
1999
|
const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, false);
|
|
@@ -1812,7 +2401,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
1812
2401
|
return;
|
|
1813
2402
|
}
|
|
1814
2403
|
try {
|
|
1815
|
-
const recipients = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), '臨時通知對象', '通知對象');
|
|
2404
|
+
const recipients = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), '臨時通知對象', '通知對象', [], null);
|
|
1816
2405
|
await this.notificationService.createAdhocWorkflowNotifications({
|
|
1817
2406
|
channels: directive.channels,
|
|
1818
2407
|
instance,
|
|
@@ -1864,11 +2453,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
1864
2453
|
input.workflowDefinitionJson,
|
|
1865
2454
|
input.formDataJson,
|
|
1866
2455
|
input.initiatorMetadataSnapshotJson ?? '',
|
|
2456
|
+
input.formSchemaJson ?? '',
|
|
1867
2457
|
].some((json) => json.length > MAX_DRY_RUN_INPUT_LENGTH)) {
|
|
1868
2458
|
return createFailedDryRunResult('試跑資料過大,請縮小流程或測試資料後再試');
|
|
1869
2459
|
}
|
|
1870
2460
|
const workflowDefinition = parseWorkflowDefinition(input.workflowDefinitionJson);
|
|
1871
2461
|
const formData = parseJsonObject(input.formDataJson, 'formDataJson');
|
|
2462
|
+
const formSchema = readDryRunFormSchema(input.formSchemaJson);
|
|
1872
2463
|
// The snapshot here is the caller's own sample (the designer always sends
|
|
1873
2464
|
// one) or a bare `{ customFields: {}, memberId }`. It is not the one a real
|
|
1874
2465
|
// submission builds, and a dry run has no template version to hand the
|
|
@@ -1885,6 +2476,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
1885
2476
|
return Object.assign(new workflow_dry_run_object_1.WorkflowDryRunResultObject(), {
|
|
1886
2477
|
errors: [],
|
|
1887
2478
|
steps: (0, expression_budget_1.runExpressionOperation)(() => (0, time_budget_1.runWithinTimeBudget)(() => this.simulateWorkflow({
|
|
2479
|
+
expressionFormData: this.expressionMissingFormFieldsAsNull && formSchema
|
|
2480
|
+
? (0, missing_form_fields_1.fillMissingFormFields)(formData, formSchema)
|
|
2481
|
+
: formData,
|
|
1888
2482
|
formData,
|
|
1889
2483
|
initiatorMemberId: input.initiatorMemberId,
|
|
1890
2484
|
initiatorMetadataSnapshot,
|
|
@@ -2189,6 +2783,51 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2189
2783
|
}
|
|
2190
2784
|
return instance;
|
|
2191
2785
|
}
|
|
2786
|
+
/**
|
|
2787
|
+
* ADR 27 §4.2 (gap 1): the fill units an approver may use to resolve runtime
|
|
2788
|
+
* data-source options on a RUNNING instance. Only tasks the member can act on
|
|
2789
|
+
* count: the member is the assignee or a PENDING/CLAIMED candidate of a
|
|
2790
|
+
* PENDING or IN_PROGRESS, non-ad-hoc task. A task whose candidate row is
|
|
2791
|
+
* WAITING or already decided grants nothing, even though its status is open.
|
|
2792
|
+
* A field counts only when its node lists it as SHOW; a HIDE entry never
|
|
2793
|
+
* grants, whatever its fill is. Returns `[]` when the instance is not RUNNING.
|
|
2794
|
+
*/
|
|
2795
|
+
async listApproverFillUnits(instanceId, memberId) {
|
|
2796
|
+
const instance = await this.approvalInstanceRepository.findOne({
|
|
2797
|
+
where: { id: instanceId },
|
|
2798
|
+
});
|
|
2799
|
+
if (!instance ||
|
|
2800
|
+
instance.state !== workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING) {
|
|
2801
|
+
return [];
|
|
2802
|
+
}
|
|
2803
|
+
const tasks = await this.taskRepository.find({
|
|
2804
|
+
where: {
|
|
2805
|
+
instanceId,
|
|
2806
|
+
isAdhoc: false,
|
|
2807
|
+
status: (0, typeorm_2.In)([workflow_engine_enums_1.TaskStatusEnum.PENDING, workflow_engine_enums_1.TaskStatusEnum.IN_PROGRESS]),
|
|
2808
|
+
},
|
|
2809
|
+
});
|
|
2810
|
+
const unitsPerTask = await Promise.all(tasks.map(async (task) => {
|
|
2811
|
+
const node = instance.workflowSnapshot.nodes.find((candidate) => candidate.type === 'userTask' && candidate.id === task.nodeId);
|
|
2812
|
+
if (!node) {
|
|
2813
|
+
return [];
|
|
2814
|
+
}
|
|
2815
|
+
try {
|
|
2816
|
+
await this.readTaskActorContext(this.taskRepository.manager, task, memberId);
|
|
2817
|
+
}
|
|
2818
|
+
catch (error) {
|
|
2819
|
+
if (error instanceof common_1.ConflictException) {
|
|
2820
|
+
return [];
|
|
2821
|
+
}
|
|
2822
|
+
throw error;
|
|
2823
|
+
}
|
|
2824
|
+
const shown = new Set((0, bpm_core_shared_1.readNodeFieldAccess)(node)
|
|
2825
|
+
.filter((entry) => entry.display === 'SHOW')
|
|
2826
|
+
.map((entry) => entry.fieldKey));
|
|
2827
|
+
return (0, bpm_core_shared_1.readNodeFillUnits)(node).filter((unit) => shown.has(unit.fieldKey));
|
|
2828
|
+
}));
|
|
2829
|
+
return unitsPerTask.flat();
|
|
2830
|
+
}
|
|
2192
2831
|
/**
|
|
2193
2832
|
* ADR 25 §6: a value written to a filterable field must fit its type, on
|
|
2194
2833
|
* every path that writes form data. The fields checked are the ones the
|
|
@@ -2274,7 +2913,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2274
2913
|
totalPages,
|
|
2275
2914
|
};
|
|
2276
2915
|
}
|
|
2277
|
-
async createFilteredApprovalInstanceQuery(scope,
|
|
2916
|
+
async createFilteredApprovalInstanceQuery(scope, requested = {}) {
|
|
2917
|
+
const options = await this.resolveApprovalInstanceTemplateKey(requested);
|
|
2918
|
+
if (!options) {
|
|
2919
|
+
return null;
|
|
2920
|
+
}
|
|
2278
2921
|
const queryBuilder = this.approvalInstanceRepository.createQueryBuilder('approvalInstance');
|
|
2279
2922
|
const hasReadableInstances = this.applyReadableInstanceFilter(queryBuilder, scope, options);
|
|
2280
2923
|
if (!hasReadableInstances) {
|
|
@@ -2502,9 +3145,15 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2502
3145
|
memberId,
|
|
2503
3146
|
submission: null,
|
|
2504
3147
|
});
|
|
2505
|
-
return templates.filter((template) =>
|
|
2506
|
-
|
|
2507
|
-
|
|
3148
|
+
return templates.filter((template) => {
|
|
3149
|
+
const version = template.currentVersionId
|
|
3150
|
+
? (versionById.get(template.currentVersionId) ?? null)
|
|
3151
|
+
: null;
|
|
3152
|
+
// A HOST_ONLY template is started by the host's backend, never from the
|
|
3153
|
+
// launch page (ADR 27 §5), so it is not offered there at all.
|
|
3154
|
+
return (version?.launchMode !== template_enums_1.ApprovalTemplateLaunchModeEnum.HOST_ONLY &&
|
|
3155
|
+
this.isInitiatorPolicyMet(template, version, initiatorMetadataSnapshot, 'launchable templates'));
|
|
3156
|
+
});
|
|
2508
3157
|
}
|
|
2509
3158
|
/**
|
|
2510
3159
|
* The published version whose form `memberId` may read data-source options
|
|
@@ -2605,7 +3254,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2605
3254
|
* given its candidate summaries.
|
|
2606
3255
|
*/
|
|
2607
3256
|
async listInboxTasks(assigneeMemberId, options = {}) {
|
|
2608
|
-
const openTasks = await this.readSharedOpenInboxTasks(assigneeMemberId);
|
|
3257
|
+
const openTasks = await this.filterTasksByTemplate(await this.readSharedOpenInboxTasks(assigneeMemberId), options);
|
|
2609
3258
|
if (!isTaskListPaged(options)) {
|
|
2610
3259
|
return this.attachTaskCandidateSummaries(openTasks);
|
|
2611
3260
|
}
|
|
@@ -2613,8 +3262,77 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2613
3262
|
return this.attachTaskCandidateSummaries(openTasks.slice(offset, offset + normalizePageSize(options.pageSize)));
|
|
2614
3263
|
}
|
|
2615
3264
|
/** How many tasks {@link listInboxTasks} pages through. */
|
|
2616
|
-
async countInboxTasks(assigneeMemberId) {
|
|
2617
|
-
return (await this.readSharedOpenInboxTasks(assigneeMemberId)).length;
|
|
3265
|
+
async countInboxTasks(assigneeMemberId, filter = {}) {
|
|
3266
|
+
return (await this.filterTasksByTemplate(await this.readSharedOpenInboxTasks(assigneeMemberId), filter)).length;
|
|
3267
|
+
}
|
|
3268
|
+
/**
|
|
3269
|
+
* The template ids a {@link TaskTemplateFilter} allows, or `null` when it
|
|
3270
|
+
* does not filter. A `templateKey` that names no template allows nothing.
|
|
3271
|
+
*/
|
|
3272
|
+
async readTaskTemplateFilterIds(filter) {
|
|
3273
|
+
// Trimmed, and a blank key does not filter, as on the instance list.
|
|
3274
|
+
const key = normalizeText(filter.templateKey);
|
|
3275
|
+
const byKey = key ? await this.readTemplateIdsByKey(key) : null;
|
|
3276
|
+
if (filter.templateIds === undefined) {
|
|
3277
|
+
return byKey;
|
|
3278
|
+
}
|
|
3279
|
+
if (byKey === null) {
|
|
3280
|
+
return filter.templateIds;
|
|
3281
|
+
}
|
|
3282
|
+
return filter.templateIds.filter((id) => byKey.includes(id));
|
|
3283
|
+
}
|
|
3284
|
+
/**
|
|
3285
|
+
* Turns `templateKey` into `templateId` (ADR 27 §6), or `null` when the
|
|
3286
|
+
* options can match nothing: an unknown key, or a key naming another
|
|
3287
|
+
* template than `templateId` does.
|
|
3288
|
+
*/
|
|
3289
|
+
async resolveApprovalInstanceTemplateKey(options) {
|
|
3290
|
+
const key = normalizeText(options.templateKey);
|
|
3291
|
+
if (!key) {
|
|
3292
|
+
return options;
|
|
3293
|
+
}
|
|
3294
|
+
const [templateId] = await this.readTemplateIdsByKey(key);
|
|
3295
|
+
const requestedId = normalizeText(options.templateId);
|
|
3296
|
+
if (!templateId || (requestedId && requestedId !== templateId)) {
|
|
3297
|
+
return null;
|
|
3298
|
+
}
|
|
3299
|
+
return { ...options, templateId };
|
|
3300
|
+
}
|
|
3301
|
+
/**
|
|
3302
|
+
* The id of the live template carrying `key`, as a list so a key that names
|
|
3303
|
+
* nothing filters everything out. At most one live template holds a key.
|
|
3304
|
+
*/
|
|
3305
|
+
async readTemplateIdsByKey(key) {
|
|
3306
|
+
const template = await this.approvalTemplateRepository.findOne({
|
|
3307
|
+
select: { id: true },
|
|
3308
|
+
where: { key },
|
|
3309
|
+
});
|
|
3310
|
+
return template ? [template.id] : [];
|
|
3311
|
+
}
|
|
3312
|
+
/**
|
|
3313
|
+
* Keeps the tasks whose instance belongs to the filter's templates. The open
|
|
3314
|
+
* task read is shared with the dashboard counters, so the filter is applied
|
|
3315
|
+
* to its result rather than pushed into it.
|
|
3316
|
+
*/
|
|
3317
|
+
async filterTasksByTemplate(tasks, filter) {
|
|
3318
|
+
const templateIds = await this.readTaskTemplateFilterIds(filter);
|
|
3319
|
+
if (templateIds === null) {
|
|
3320
|
+
return tasks;
|
|
3321
|
+
}
|
|
3322
|
+
if (!templateIds.length || !tasks.length) {
|
|
3323
|
+
return [];
|
|
3324
|
+
}
|
|
3325
|
+
const instances = await this.approvalInstanceRepository.find({
|
|
3326
|
+
select: { id: true },
|
|
3327
|
+
where: {
|
|
3328
|
+
id: (0, typeorm_2.In)([
|
|
3329
|
+
...new Set(tasks.map((task) => task.instanceId)),
|
|
3330
|
+
]),
|
|
3331
|
+
templateId: (0, typeorm_2.In)([...templateIds]),
|
|
3332
|
+
},
|
|
3333
|
+
});
|
|
3334
|
+
const instanceIds = new Set(instances.map((instance) => instance.id));
|
|
3335
|
+
return tasks.filter((task) => instanceIds.has(task.instanceId));
|
|
2618
3336
|
}
|
|
2619
3337
|
/**
|
|
2620
3338
|
* {@link readOpenInboxTasks} for the inbox list and its count, joining a
|
|
@@ -2711,7 +3429,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2711
3429
|
*/
|
|
2712
3430
|
async listApprovalHistoryTasks(assigneeMemberId, options = {}) {
|
|
2713
3431
|
const paged = isTaskListPaged(options);
|
|
2714
|
-
const
|
|
3432
|
+
const templateIds = await this.readTaskTemplateFilterIds(options);
|
|
3433
|
+
if (templateIds?.length === 0) {
|
|
3434
|
+
return [];
|
|
3435
|
+
}
|
|
3436
|
+
const tasks = await this.createApprovalHistoryTaskQuery(assigneeMemberId, options.instanceState, templateIds)
|
|
2715
3437
|
.orderBy(this.readApprovalHistoryActedAtExpression(), 'DESC')
|
|
2716
3438
|
.addOrderBy('"task"."id"', 'DESC')
|
|
2717
3439
|
.offset(paged ? readPageOffset(options.page, options.pageSize) : 0)
|
|
@@ -2723,10 +3445,14 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2723
3445
|
}
|
|
2724
3446
|
/** How many tasks {@link listApprovalHistoryTasks} pages through. */
|
|
2725
3447
|
async countApprovalHistoryTasks(assigneeMemberId, options = {}) {
|
|
2726
|
-
|
|
3448
|
+
const templateIds = await this.readTaskTemplateFilterIds(options);
|
|
3449
|
+
if (templateIds?.length === 0) {
|
|
3450
|
+
return 0;
|
|
3451
|
+
}
|
|
3452
|
+
return this.createApprovalHistoryTaskQuery(assigneeMemberId, options.instanceState, templateIds).getCount();
|
|
2727
3453
|
}
|
|
2728
3454
|
/** The rows of {@link listApprovalHistoryTasks}, unordered and unpaged. */
|
|
2729
|
-
createApprovalHistoryTaskQuery(assigneeMemberId, instanceState) {
|
|
3455
|
+
createApprovalHistoryTaskQuery(assigneeMemberId, instanceState, templateIds = null) {
|
|
2730
3456
|
const candidateTable = this.readQualifiedTableName(this.taskCandidateRepository);
|
|
2731
3457
|
const queryBuilder = this.taskRepository
|
|
2732
3458
|
.createQueryBuilder('task')
|
|
@@ -2759,6 +3485,16 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2759
3485
|
)
|
|
2760
3486
|
`, { historyInstanceState: instanceState });
|
|
2761
3487
|
}
|
|
3488
|
+
if (templateIds) {
|
|
3489
|
+
queryBuilder.andWhere(`
|
|
3490
|
+
EXISTS (
|
|
3491
|
+
SELECT 1
|
|
3492
|
+
FROM ${this.readQualifiedTableName(this.approvalInstanceRepository)} "historyTemplateInstance"
|
|
3493
|
+
WHERE "historyTemplateInstance"."id" = "task"."instance_id"
|
|
3494
|
+
AND "historyTemplateInstance"."template_id" IN (:...historyTemplateIds)
|
|
3495
|
+
)
|
|
3496
|
+
`, { historyTemplateIds: [...templateIds] });
|
|
3497
|
+
}
|
|
2762
3498
|
return queryBuilder;
|
|
2763
3499
|
}
|
|
2764
3500
|
/** When the member acted on a history task; see {@link listApprovalHistoryTasks}. */
|
|
@@ -2845,7 +3581,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2845
3581
|
* snapshot without a `nodes` array, and a blank or non-string label all
|
|
2846
3582
|
* leave the task without a label, as `readWorkflowNodeLabel` does. Only the
|
|
2847
3583
|
* node's `fieldAccess` leaves the database for the fill flag, which
|
|
2848
|
-
* `
|
|
3584
|
+
* `readNodeFillGrantFieldKeys` then reads as tolerantly as the engine does.
|
|
2849
3585
|
*/
|
|
2850
3586
|
async readTaskNodeSummaries(tasks) {
|
|
2851
3587
|
// The table as the entity reads it, so a configured `schema` is honoured
|
|
@@ -2880,7 +3616,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2880
3616
|
readNodeLabelKey(row.instanceId, row.nodeId),
|
|
2881
3617
|
{
|
|
2882
3618
|
hasFillableFields: row.nodeType === 'userTask' &&
|
|
2883
|
-
(0, bpm_core_shared_1.
|
|
3619
|
+
(0, bpm_core_shared_1.readNodeFillGrantFieldKeys)({
|
|
2884
3620
|
data: { fieldAccess: row.fieldAccess },
|
|
2885
3621
|
}).size > 0,
|
|
2886
3622
|
label: typeof row.label === 'string' && row.label.trim()
|
|
@@ -3169,6 +3905,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3169
3905
|
});
|
|
3170
3906
|
await this.dispatchAdhocCompletionNotifications(manager, completedInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.APPROVED);
|
|
3171
3907
|
await this.cancelPendingAdhocDirectives(manager, instance.id, null);
|
|
3908
|
+
await this.recordInstanceEvent(manager, completedInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.APPROVED, 'INSTANCE_APPROVED', {});
|
|
3172
3909
|
}
|
|
3173
3910
|
async isNodeEntryReady(manager, instance, token, node) {
|
|
3174
3911
|
if (node.type === 'startEvent') {
|
|
@@ -3234,7 +3971,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3234
3971
|
}
|
|
3235
3972
|
isNodeEntryConditionMet(instance, node) {
|
|
3236
3973
|
const entryCondition = readNodeEntryCondition(node);
|
|
3237
|
-
return this.conditionService.evaluateBoolean(entryCondition,
|
|
3974
|
+
return this.conditionService.evaluateBoolean(entryCondition, this.buildExpressionContext(instance), `workflow.nodes.${node.id}.data.entryCondition`, readExpressionDialectOptions(entryCondition, instance.workflowSnapshot));
|
|
3238
3975
|
}
|
|
3239
3976
|
async skipNodeByEntryCondition(manager, instance, token, node) {
|
|
3240
3977
|
await this.skipNode(manager, instance, token, node, {
|
|
@@ -3242,9 +3979,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3242
3979
|
});
|
|
3243
3980
|
}
|
|
3244
3981
|
async skipNodeByInitiatorExclusion(manager, instance, token, node, skippedBy = 'INITIATOR') {
|
|
3245
|
-
await this.skipNode(manager, instance, token, node, skippedBy
|
|
3246
|
-
? { skippedByNodeActorExclusion: true }
|
|
3247
|
-
: { skippedByInitiatorExclusion: true });
|
|
3982
|
+
await this.skipNode(manager, instance, token, node, SKIPPED_RUNTIME_TASK_PAYLOADS[skippedBy]);
|
|
3248
3983
|
}
|
|
3249
3984
|
async skipNode(manager, instance, token, node, reason) {
|
|
3250
3985
|
// A NOTIFY node is an asynchronous branch that may not have an outgoing
|
|
@@ -3308,7 +4043,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3308
4043
|
});
|
|
3309
4044
|
}
|
|
3310
4045
|
const context = {
|
|
3311
|
-
...
|
|
4046
|
+
...this.buildExpressionContext(instance),
|
|
3312
4047
|
lastDecision: await this.readLastDecisionContext(manager, instance.id, node.id),
|
|
3313
4048
|
};
|
|
3314
4049
|
return evaluateWithStructuredFallback(edge, instance.formData, (message) => this.logger.warn(`instance=${instance.id} ${message}`), () => this.conditionService.evaluateBoolean(expression, context, `workflow.edges.${edge.id}.data.condition`, { dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot) }));
|
|
@@ -3431,6 +4166,12 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3431
4166
|
// worse — an `endState: 'REJECTED'` row overwritten with `APPROVED`
|
|
3432
4167
|
// immediately after it had been committed.
|
|
3433
4168
|
const completedInstance = await instanceRepository.save(Object.assign(instance, { completedAt, state: instanceState }));
|
|
4169
|
+
if (instanceState === workflow_engine_enums_1.ApprovalInstanceStateEnum.REJECTED) {
|
|
4170
|
+
await this.recordInstanceEvent(manager, completedInstance, instanceState, 'INSTANCE_REJECTED', { actorMemberId: null });
|
|
4171
|
+
}
|
|
4172
|
+
else {
|
|
4173
|
+
await this.recordInstanceEvent(manager, completedInstance, instanceState, 'INSTANCE_APPROVED', {});
|
|
4174
|
+
}
|
|
3434
4175
|
if (instanceState === workflow_engine_enums_1.ApprovalInstanceStateEnum.APPROVED) {
|
|
3435
4176
|
await this.notificationService.createInstanceCompletedNotification({
|
|
3436
4177
|
instance: completedInstance,
|
|
@@ -3532,7 +4273,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3532
4273
|
const singleCandidate = savedCandidates.length === 1 ? savedCandidates[0] : null;
|
|
3533
4274
|
const openCandidates = savedCandidates.filter((candidate) => candidate.status === workflow_engine_enums_1.TaskCandidateStatusEnum.PENDING);
|
|
3534
4275
|
task.openCandidateMemberIds = openCandidates.map((candidate) => candidate.memberId);
|
|
3535
|
-
task.hasFillableFields = (0, bpm_core_shared_1.
|
|
4276
|
+
task.hasFillableFields = (0, bpm_core_shared_1.readNodeFillGrantFieldKeys)(node).size > 0;
|
|
3536
4277
|
await tokenRepository.save({
|
|
3537
4278
|
...token,
|
|
3538
4279
|
status: workflow_engine_enums_1.WorkflowTokenStatusEnum.WAITING,
|
|
@@ -3559,6 +4300,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3559
4300
|
},
|
|
3560
4301
|
taskId: task.id,
|
|
3561
4302
|
}));
|
|
4303
|
+
await this.recordInstanceEvent(manager, instance, instance.state, 'NODE_ACTIVATED', { nodeId: node.id, taskIds: [task.id] });
|
|
3562
4304
|
await openCandidates.reduce(async (previous, candidate) => {
|
|
3563
4305
|
await previous;
|
|
3564
4306
|
await this.notificationService.createTaskAssignedNotification({
|
|
@@ -3949,9 +4691,23 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3949
4691
|
* its own transaction, and reports one result per task. A failing task does
|
|
3950
4692
|
* not stop the others: a refusal is a REJECTED result, and an unexpected
|
|
3951
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.
|
|
3952
4700
|
*/
|
|
3953
4701
|
async reassignMemberTasks(options) {
|
|
3954
|
-
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;
|
|
3955
4711
|
return held.reduce(async (previous, { task }) => {
|
|
3956
4712
|
const results = await previous;
|
|
3957
4713
|
const result = await this.executeTaskReassignment(task, options).catch((error) => {
|
|
@@ -4020,6 +4776,103 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4020
4776
|
};
|
|
4021
4777
|
});
|
|
4022
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
|
+
}
|
|
4023
4876
|
/**
|
|
4024
4877
|
* Deactivated members who still hold open tasks, so an administrator can find
|
|
4025
4878
|
* them: a deactivated member no longer shows up in the member search.
|
|
@@ -4447,7 +5300,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4447
5300
|
* Resolves who gets the node's task. A {@link SkippedRuntimeTask} means the
|
|
4448
5301
|
* node asked to be skipped (`excludeInitiator.whenEmpty` or
|
|
4449
5302
|
* `excludeNodeActors.whenEmpty` = SKIP_NODE) because everyone left was kept
|
|
4450
|
-
* 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.
|
|
4451
5306
|
*
|
|
4452
5307
|
* With both exclusions set the initiator rule runs first. When it alone
|
|
4453
5308
|
* leaves nobody, `excludeInitiator.whenEmpty` decides; when it leaves
|
|
@@ -4456,8 +5311,14 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4456
5311
|
*/
|
|
4457
5312
|
async resolveRuntimeTaskCandidates(manager, instance, node) {
|
|
4458
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
|
+
}
|
|
4459
5320
|
const exclusions = await this.readNodeExclusions(manager, instance, node);
|
|
4460
|
-
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);
|
|
4461
5322
|
const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, isOrgManagerChainNode(node));
|
|
4462
5323
|
const hasExclusion = hasSeatExclusion(node);
|
|
4463
5324
|
const hasSubmitter = (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) !== null;
|
|
@@ -4497,6 +5358,136 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4497
5358
|
? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人(含委派後的代理人),且此節點不允許申請人或代送人簽核。`
|
|
4498
5359
|
: `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人(含委派後的代理人),且此節點不允許申請人自簽。`);
|
|
4499
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
|
+
}
|
|
4500
5491
|
/**
|
|
4501
5492
|
* Everyone `node` keeps out of its approvers, per rule: the initiator and
|
|
4502
5493
|
* the submitter for `excludeInitiator`, and everyone who actually approved
|
|
@@ -4618,9 +5609,22 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4618
5609
|
skippedDelegations: collectSkippedDelegations(delegatedCandidates, true, replacementExcludedMemberIds),
|
|
4619
5610
|
};
|
|
4620
5611
|
}
|
|
5612
|
+
/**
|
|
5613
|
+
* The context workflow expressions on `instance` read. Its `form` is the
|
|
5614
|
+
* stored data, or with `expressionMissingFormFieldsAsNull` on, a copy with
|
|
5615
|
+
* every unfilled top-level field present (ADR 27 §3).
|
|
5616
|
+
*/
|
|
5617
|
+
buildExpressionContext(instance) {
|
|
5618
|
+
return buildWorkflowExpressionContext(instance, this.readExpressionFormData(instance));
|
|
5619
|
+
}
|
|
5620
|
+
readExpressionFormData(instance) {
|
|
5621
|
+
return this.expressionMissingFormFieldsAsNull
|
|
5622
|
+
? (0, missing_form_fields_1.fillMissingFormFields)(instance.formData, readFormDefinitionSnapshotSchema(instance.formDefinitionSnapshot))
|
|
5623
|
+
: instance.formData;
|
|
5624
|
+
}
|
|
4621
5625
|
buildDelegationContext(instance, nodeId) {
|
|
4622
5626
|
return {
|
|
4623
|
-
formData: instance
|
|
5627
|
+
formData: this.readExpressionFormData(instance),
|
|
4624
5628
|
initiatorMemberId: instance.initiatorMemberId,
|
|
4625
5629
|
initiatorMetadataSnapshot: instance.initiatorMetadataSnapshot,
|
|
4626
5630
|
instanceId: instance.id,
|
|
@@ -4698,15 +5702,18 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4698
5702
|
? requiredLevel
|
|
4699
5703
|
: null;
|
|
4700
5704
|
}
|
|
4701
|
-
async resolveApproverResolver(manager, instance, resolver, label, subject
|
|
4702
|
-
|
|
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);
|
|
4703
5710
|
return memberIds.map((memberId) => ({
|
|
4704
5711
|
memberId,
|
|
4705
5712
|
sourceType: resolver.type,
|
|
4706
5713
|
}));
|
|
4707
5714
|
}
|
|
4708
5715
|
/** The member ids `resolver` routes to; every source type, one branch each. */
|
|
4709
|
-
async resolveApproverMemberIds(manager, instance, resolver, label, subject, skippedChainMemberIds) {
|
|
5716
|
+
async resolveApproverMemberIds(manager, instance, resolver, label, subject, skippedChainMemberIds, chainContext) {
|
|
4710
5717
|
if (resolver.type === 'DIRECT') {
|
|
4711
5718
|
return readMemberIdsFromValue(resolver.memberIds, label, subject);
|
|
4712
5719
|
}
|
|
@@ -4714,7 +5721,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4714
5721
|
return readMemberIdsFromValue(readValueAtPath(instance.formData, resolver.formPath), label, subject, `依表單欄位「${resolver.formPath}」`);
|
|
4715
5722
|
}
|
|
4716
5723
|
if (resolver.type === 'EXPRESSION') {
|
|
4717
|
-
return readMemberIdsFromValue(this.conditionService.evaluateValue(resolver.expression,
|
|
5724
|
+
return readMemberIdsFromValue(this.conditionService.evaluateValue(resolver.expression, this.buildExpressionContext(instance), `${label}.expression`, { dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot) }), label, subject, '依運算式');
|
|
4718
5725
|
}
|
|
4719
5726
|
if (resolver.type === 'POSITION') {
|
|
4720
5727
|
return this.resolvePositionCandidates(manager, resolver.positionId, label, subject);
|
|
@@ -4729,7 +5736,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4729
5736
|
return this.resolveOrgUnitManagerCandidates(manager, instance, resolver.orgUnitId, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject);
|
|
4730
5737
|
}
|
|
4731
5738
|
if (resolver.type === 'ORG_MANAGER_CHAIN') {
|
|
4732
|
-
return this.resolveOrgManagerChainCandidates(manager, instance, resolver, label, subject, skippedChainMemberIds);
|
|
5739
|
+
return this.resolveOrgManagerChainCandidates(manager, instance, resolver, label, subject, skippedChainMemberIds, chainContext);
|
|
4733
5740
|
}
|
|
4734
5741
|
if (resolver.type === 'ORG_MANAGER') {
|
|
4735
5742
|
return this.resolveInitiatorManagerCandidates(manager, instance, resolver.levelsUp, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject);
|
|
@@ -4844,7 +5851,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4844
5851
|
*/
|
|
4845
5852
|
async readNodeActorApprovals(manager, instance, nodeIds) {
|
|
4846
5853
|
const tasks = await manager.getRepository(task_entity_1.TaskEntity).find({
|
|
4847
|
-
where: {
|
|
5854
|
+
where: {
|
|
5855
|
+
instanceId: instance.id,
|
|
5856
|
+
isAdhoc: false,
|
|
5857
|
+
nodeId: (0, typeorm_2.In)([...nodeIds]),
|
|
5858
|
+
},
|
|
4848
5859
|
});
|
|
4849
5860
|
if (tasks.length === 0) {
|
|
4850
5861
|
return [];
|
|
@@ -4860,7 +5871,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4860
5871
|
}
|
|
4861
5872
|
const [decisions, decidedLogs] = await Promise.all([
|
|
4862
5873
|
manager.getRepository(task_decision_entity_1.TaskDecisionEntity).find({
|
|
4863
|
-
where: {
|
|
5874
|
+
where: {
|
|
5875
|
+
action: workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED,
|
|
5876
|
+
taskId: (0, typeorm_2.In)(batchTaskIds),
|
|
5877
|
+
},
|
|
4864
5878
|
}),
|
|
4865
5879
|
manager.getRepository(activity_log_entity_1.ActivityLogEntity).find({
|
|
4866
5880
|
where: {
|
|
@@ -4922,11 +5936,26 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4922
5936
|
* past without joining the chain or counting as the stopping point, and so
|
|
4923
5937
|
* are managers below `startPositionLevel`, until the first one at or above
|
|
4924
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.
|
|
4925
5947
|
*/
|
|
4926
|
-
async resolveOrgManagerChainCandidates(manager, instance, resolver, label, subject, skippedMemberIds) {
|
|
5948
|
+
async resolveOrgManagerChainCandidates(manager, instance, resolver, label, subject, skippedMemberIds, chainContext) {
|
|
4927
5949
|
const { fallback, minPositionLevel } = resolver;
|
|
4928
5950
|
const startPositionLevel = (0, workflow_1.readOrgManagerChainStartPositionLevel)(resolver);
|
|
4929
|
-
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));
|
|
4930
5959
|
// Nobody to climb from: the same "no manager" path as a chain whose walk
|
|
4931
5960
|
// finds nobody, so the fallback decides.
|
|
4932
5961
|
if (start.memberId === null) {
|
|
@@ -4936,7 +5965,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4936
5965
|
const visitedMemberIds = new Set([start.memberId]);
|
|
4937
5966
|
const chain = [];
|
|
4938
5967
|
let currentMemberId = start.memberId;
|
|
4939
|
-
|
|
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;
|
|
4940
5972
|
for (let hop = 0; hop < ORG_MANAGER_CHAIN_MAX_HOPS; hop += 1) {
|
|
4941
5973
|
// A chain is a single path, so it always uses closest-org-unit semantics
|
|
4942
5974
|
// (merging ancestor-unit rules could only produce ties). The member is
|
|
@@ -5021,6 +6053,12 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
5021
6053
|
if (subject === '通知對象') {
|
|
5022
6054
|
throw new common_1.ConflictException(`${label} ${failure}:知會節點的主管鏈只能從發起人開始。`);
|
|
5023
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
|
+
}
|
|
5024
6062
|
if (startFrom.type === 'FORM_MEMBER') {
|
|
5025
6063
|
const source = `依表單欄位「${startFrom.formPath}」`;
|
|
5026
6064
|
const pickedMemberIds = findMemberIdsInValue(readValueAtPath(instance.formData, startFrom.formPath));
|
|
@@ -5322,11 +6360,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
5322
6360
|
const action = node.data.action;
|
|
5323
6361
|
if (action.type === 'WEBHOOK') {
|
|
5324
6362
|
const payload = typeof action.payload === 'string' && action.payload.trim()
|
|
5325
|
-
? this.conditionService.evaluateValue(action.payload,
|
|
6363
|
+
? this.conditionService.evaluateValue(action.payload, this.buildExpressionContext(instance), `workflow.nodes.${node.id}.data.action.payload`, {
|
|
5326
6364
|
dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot),
|
|
5327
6365
|
})
|
|
5328
6366
|
: {
|
|
5329
|
-
instance:
|
|
6367
|
+
instance: this.buildExpressionContext(instance).instance,
|
|
5330
6368
|
triggeredAt: new Date().toISOString(),
|
|
5331
6369
|
};
|
|
5332
6370
|
await this.dispatchWebhookAfterCommit(manager, {
|
|
@@ -5354,7 +6392,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
5354
6392
|
return;
|
|
5355
6393
|
}
|
|
5356
6394
|
if (action.type === 'SET_FORM_FIELD') {
|
|
5357
|
-
const value = this.conditionService.evaluateValue(action.value,
|
|
6395
|
+
const value = this.conditionService.evaluateValue(action.value, this.buildExpressionContext(instance), `workflow.nodes.${node.id}.data.action.value`, { dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot) });
|
|
5358
6396
|
assertServiceTaskFieldValue(instance.formDefinitionSnapshot, action.fieldPath, value, node.data.label);
|
|
5359
6397
|
const updatedFormData = writeValueAtPath(instance.formData, action.fieldPath, value);
|
|
5360
6398
|
await this.assertCurrentFilterableFormDataValues(instance.templateId, updatedFormData, manager, node.data.label);
|
|
@@ -5404,7 +6442,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
5404
6442
|
const recipients = (0, workflow_graph_1.isNotifyRecipientsEmpty)(action.recipients) &&
|
|
5405
6443
|
(0, workflow_graph_1.readNotifyWebhookTargets)(action).length > 0
|
|
5406
6444
|
? []
|
|
5407
|
-
: 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);
|
|
5408
6449
|
const recipientMemberIds = uniqueTexts(recipients.map((recipient) => recipient.memberId));
|
|
5409
6450
|
await this.notificationService.createServiceTaskNotifications({
|
|
5410
6451
|
instance,
|
|
@@ -5416,8 +6457,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
5416
6457
|
// §3.5). Without the webhook module there is nothing to queue into; the
|
|
5417
6458
|
// publish lint already refuses a template that references an endpoint
|
|
5418
6459
|
// in that case.
|
|
5419
|
-
const webhookDeliveryIds = this.webhookDeliveryService &&
|
|
5420
|
-
(0, workflow_graph_1.readNotifyWebhookTargets)(action).length > 0
|
|
6460
|
+
const webhookDeliveryIds = this.webhookDeliveryService && (0, workflow_graph_1.readNotifyWebhookTargets)(action).length > 0
|
|
5421
6461
|
? await this.webhookDeliveryService.enqueueNotifyWebhooks(manager, {
|
|
5422
6462
|
instance: {
|
|
5423
6463
|
formData: instance.formData,
|
|
@@ -5447,7 +6487,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
5447
6487
|
taskId: null,
|
|
5448
6488
|
}));
|
|
5449
6489
|
}
|
|
5450
|
-
async rejectInstance(manager, instance, task, rejectedAt) {
|
|
6490
|
+
async rejectInstance(manager, instance, task, rejectedAt, decidedByMemberId) {
|
|
5451
6491
|
const instanceRepository = manager.getRepository(approval_instance_entity_1.ApprovalInstanceEntity);
|
|
5452
6492
|
// Dispatch pending ad-hoc notifications for the rejecting node before the
|
|
5453
6493
|
// remaining directives are cancelled by the terminal cleanup below.
|
|
@@ -5478,8 +6518,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
5478
6518
|
},
|
|
5479
6519
|
taskId: task.id,
|
|
5480
6520
|
}));
|
|
6521
|
+
await this.recordInstanceEvent(manager, rejectedInstance, workflow_engine_enums_1.ApprovalInstanceStateEnum.REJECTED, 'INSTANCE_REJECTED', { actorMemberId: decidedByMemberId });
|
|
5481
6522
|
}
|
|
5482
|
-
async returnInstanceToNode(manager, instance, task, returnToNodeId, returnedAt) {
|
|
6523
|
+
async returnInstanceToNode(manager, instance, task, returnToNodeId, returnedAt, decidedByMemberId) {
|
|
5483
6524
|
if (!returnToNodeId) {
|
|
5484
6525
|
throw new common_1.ConflictException('Return target node is required');
|
|
5485
6526
|
}
|
|
@@ -5537,6 +6578,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
5537
6578
|
taskId: null,
|
|
5538
6579
|
}),
|
|
5539
6580
|
]);
|
|
6581
|
+
await this.recordInstanceEvent(manager, returnedInstance, returnedInstance.state, 'INSTANCE_RETURNED', { actorMemberId: decidedByMemberId, nodeId: task.nodeId });
|
|
5540
6582
|
if (returnedInstance.state === workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING) {
|
|
5541
6583
|
await this.processRunningInstance(manager, returnedInstance);
|
|
5542
6584
|
}
|
|
@@ -5738,6 +6780,9 @@ exports.WorkflowEngineService = WorkflowEngineService = WorkflowEngineService_1
|
|
|
5738
6780
|
tslib_1.__param(20, (0, common_1.Optional)()),
|
|
5739
6781
|
tslib_1.__param(21, (0, common_1.Optional)()),
|
|
5740
6782
|
tslib_1.__param(21, (0, common_1.Inject)(initiator_custom_fields_resolver_token_1.BPM_INITIATOR_CUSTOM_FIELDS_RESOLVER)),
|
|
6783
|
+
tslib_1.__param(22, (0, common_1.Optional)()),
|
|
6784
|
+
tslib_1.__param(22, (0, common_1.Inject)(instance_guard_token_1.BPM_INSTANCE_GUARD)),
|
|
6785
|
+
tslib_1.__param(23, (0, common_1.Optional)()),
|
|
5741
6786
|
tslib_1.__metadata("design:paramtypes", [typeorm_2.Repository,
|
|
5742
6787
|
typeorm_2.Repository,
|
|
5743
6788
|
typeorm_2.Repository,
|
|
@@ -5754,7 +6799,7 @@ exports.WorkflowEngineService = WorkflowEngineService = WorkflowEngineService_1
|
|
|
5754
6799
|
delegation_service_1.DelegationService,
|
|
5755
6800
|
notification_service_1.NotificationService,
|
|
5756
6801
|
signature_service_1.SignatureService,
|
|
5757
|
-
sla_schedule_service_1.BPMSlaScheduleService, Object, Object, Object, workflow_webhook_delivery_service_1.WorkflowWebhookDeliveryService, Object])
|
|
6802
|
+
sla_schedule_service_1.BPMSlaScheduleService, Object, Object, Object, workflow_webhook_delivery_service_1.WorkflowWebhookDeliveryService, Object, Object, instance_event_writer_1.InstanceEventWriter])
|
|
5758
6803
|
], WorkflowEngineService);
|
|
5759
6804
|
function parseJsonObject(value, label) {
|
|
5760
6805
|
try {
|
|
@@ -5777,6 +6822,67 @@ function readDefaultInitiatorMetadataSnapshot(memberId) {
|
|
|
5777
6822
|
memberId,
|
|
5778
6823
|
};
|
|
5779
6824
|
}
|
|
6825
|
+
/**
|
|
6826
|
+
* The form schema a dry run was given to fill unfilled fields with (ADR 27
|
|
6827
|
+
* §3), or `null` when it was given none. A schema that does not parse is a
|
|
6828
|
+
* 400, like the other inputs.
|
|
6829
|
+
*/
|
|
6830
|
+
function readDryRunFormSchema(json) {
|
|
6831
|
+
if (!json) {
|
|
6832
|
+
return null;
|
|
6833
|
+
}
|
|
6834
|
+
const schema = parseJsonObject(json, 'formSchemaJson');
|
|
6835
|
+
if (!isFormDefinitionSchema(schema)) {
|
|
6836
|
+
throw new common_1.BadRequestException('formSchemaJson is not a form schema');
|
|
6837
|
+
}
|
|
6838
|
+
return schema;
|
|
6839
|
+
}
|
|
6840
|
+
/**
|
|
6841
|
+
* Refuses a submission or resubmission through GraphQL on a `HOST_ONLY`
|
|
6842
|
+
* version (ADR 27 §5). Only the host's backend, calling the service, may start
|
|
6843
|
+
* or resubmit such a case.
|
|
6844
|
+
*/
|
|
6845
|
+
function assertEndUserMayLaunch(version) {
|
|
6846
|
+
if (version.launchMode === template_enums_1.ApprovalTemplateLaunchModeEnum.HOST_ONLY) {
|
|
6847
|
+
throw new common_1.ForbiddenException('This approval template can only be submitted by the host system');
|
|
6848
|
+
}
|
|
6849
|
+
}
|
|
6850
|
+
/**
|
|
6851
|
+
* Reads the service-only `initiatorCustomFields` of a submission (ADR 27 §5):
|
|
6852
|
+
* `null` when absent, otherwise a JSON copy. Refused with 400 when it is not a
|
|
6853
|
+
* plain object or comes together with a whole `initiatorMetadataSnapshotJson`,
|
|
6854
|
+
* which would leave it unclear which one decides.
|
|
6855
|
+
*/
|
|
6856
|
+
function readHostInitiatorCustomFields(input) {
|
|
6857
|
+
const fields = input.initiatorCustomFields;
|
|
6858
|
+
if (fields === undefined || fields === null) {
|
|
6859
|
+
return null;
|
|
6860
|
+
}
|
|
6861
|
+
if (input.initiatorMetadataSnapshotJson) {
|
|
6862
|
+
throw new common_1.BadRequestException('initiatorCustomFields cannot be combined with initiatorMetadataSnapshotJson');
|
|
6863
|
+
}
|
|
6864
|
+
if (typeof fields !== 'object' || Array.isArray(fields)) {
|
|
6865
|
+
throw new common_1.BadRequestException('initiatorCustomFields must be an object');
|
|
6866
|
+
}
|
|
6867
|
+
return cloneJsonObject(fields);
|
|
6868
|
+
}
|
|
6869
|
+
/**
|
|
6870
|
+
* Lays the host's `initiatorCustomFields` over the `customFields` of a built
|
|
6871
|
+
* snapshot; a key the host gave wins over the resolver's.
|
|
6872
|
+
*/
|
|
6873
|
+
function mergeHostInitiatorCustomFields(snapshot, hostCustomFields) {
|
|
6874
|
+
if (hostCustomFields === null) {
|
|
6875
|
+
return snapshot;
|
|
6876
|
+
}
|
|
6877
|
+
const current = snapshot['customFields'];
|
|
6878
|
+
return {
|
|
6879
|
+
...snapshot,
|
|
6880
|
+
customFields: {
|
|
6881
|
+
...(isRecord(current) ? current : {}),
|
|
6882
|
+
...hostCustomFields,
|
|
6883
|
+
},
|
|
6884
|
+
};
|
|
6885
|
+
}
|
|
5780
6886
|
function cloneJsonObject(value) {
|
|
5781
6887
|
return JSON.parse(JSON.stringify(value));
|
|
5782
6888
|
}
|
|
@@ -5861,7 +6967,8 @@ function createWorkflowAuthContext(memberId) {
|
|
|
5861
6967
|
};
|
|
5862
6968
|
}
|
|
5863
6969
|
function isSameApprovalInstanceRevision(left, right) {
|
|
5864
|
-
return (readApprovalInstanceRevision(left) ===
|
|
6970
|
+
return (readApprovalInstanceRevision(left) ===
|
|
6971
|
+
readApprovalInstanceRevision(right) &&
|
|
5865
6972
|
JSON.stringify(left.formData) === JSON.stringify(right.formData));
|
|
5866
6973
|
}
|
|
5867
6974
|
/**
|
|
@@ -5891,7 +6998,9 @@ function readDataSourceFieldKeyAtPath(snapshot, fieldPath) {
|
|
|
5891
6998
|
// A corrupt snapshot can hold non-object entries: read them defensively
|
|
5892
6999
|
// rather than letting a service task throw.
|
|
5893
7000
|
const field = schema.fields.find((candidate) => isRecord(candidate) && candidate.fieldKey === fieldKey);
|
|
5894
|
-
return field && (0, form_1.isFormDataSourceFieldDefinition)(field)
|
|
7001
|
+
return field && (0, form_1.isFormDataSourceFieldDefinition)(field)
|
|
7002
|
+
? field.fieldKey
|
|
7003
|
+
: null;
|
|
5895
7004
|
}
|
|
5896
7005
|
/**
|
|
5897
7006
|
* Refuses form data whose filterable fields hold a value of the wrong type
|
|
@@ -5979,6 +7088,13 @@ function assertServiceTaskFieldValue(snapshot, fieldPath, value, nodeLabel) {
|
|
|
5979
7088
|
function readSnapshotWithoutField(snapshots, fieldKey) {
|
|
5980
7089
|
return Object.fromEntries(Object.entries(snapshots ?? {}).filter(([key]) => key !== fieldKey));
|
|
5981
7090
|
}
|
|
7091
|
+
/**
|
|
7092
|
+
* Drops every snapshot that belongs to one of the fields, table cells included
|
|
7093
|
+
* (`items[0].col` belongs to `items`).
|
|
7094
|
+
*/
|
|
7095
|
+
function readSnapshotWithoutFields(snapshots, fieldKeys) {
|
|
7096
|
+
return Object.fromEntries(Object.entries(snapshots ?? {}).filter(([key]) => !fieldKeys.has((0, bpm_core_shared_1.readTopLevelFieldKey)(key))));
|
|
7097
|
+
}
|
|
5982
7098
|
/**
|
|
5983
7099
|
* Keeps only the snapshots whose field still carries a value, so clearing a
|
|
5984
7100
|
* dynamic option also removes the label recorded for it.
|
|
@@ -6056,13 +7172,13 @@ function buildInitiatorPolicyContext(initiatorMetadataSnapshot) {
|
|
|
6056
7172
|
subject: initiatorMetadataSnapshot,
|
|
6057
7173
|
};
|
|
6058
7174
|
}
|
|
6059
|
-
function buildWorkflowExpressionContext(instance) {
|
|
7175
|
+
function buildWorkflowExpressionContext(instance, formData) {
|
|
6060
7176
|
return {
|
|
6061
7177
|
env: {
|
|
6062
7178
|
now: new Date().toISOString(),
|
|
6063
7179
|
},
|
|
6064
|
-
form:
|
|
6065
|
-
formData
|
|
7180
|
+
form: formData,
|
|
7181
|
+
formData,
|
|
6066
7182
|
initiator: {
|
|
6067
7183
|
...instance.initiatorMetadataSnapshot,
|
|
6068
7184
|
memberId: instance.initiatorMemberId,
|
|
@@ -6106,8 +7222,8 @@ function buildDryRunExpressionContext(input, lastDecision) {
|
|
|
6106
7222
|
env: {
|
|
6107
7223
|
now: new Date().toISOString(),
|
|
6108
7224
|
},
|
|
6109
|
-
form: input.
|
|
6110
|
-
formData: input.
|
|
7225
|
+
form: input.expressionFormData,
|
|
7226
|
+
formData: input.expressionFormData,
|
|
6111
7227
|
initiator: {
|
|
6112
7228
|
...input.initiatorMetadataSnapshot,
|
|
6113
7229
|
memberId: input.initiatorMemberId,
|
|
@@ -6263,8 +7379,7 @@ function readDefaultReturnTargetNodeId(workflow, currentNode) {
|
|
|
6263
7379
|
}
|
|
6264
7380
|
function isReturnCommentRequired(workflow, nodeId) {
|
|
6265
7381
|
const node = readWorkflowNodeOrThrow(workflow, nodeId);
|
|
6266
|
-
return (node.type === 'userTask' &&
|
|
6267
|
-
node.data.returnBehavior.requireComment === true);
|
|
7382
|
+
return (node.type === 'userTask' && node.data.returnBehavior.requireComment === true);
|
|
6268
7383
|
}
|
|
6269
7384
|
function readReturnResubmitStrategy(task, instance) {
|
|
6270
7385
|
const node = readWorkflowNodeOrThrow(instance.workflowSnapshot, task.nodeId);
|
|
@@ -6278,6 +7393,92 @@ function readReturnResubmitStrategyFromPayload(payload) {
|
|
|
6278
7393
|
? 'FROM_RETURN_POINT'
|
|
6279
7394
|
: 'RESTART';
|
|
6280
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
|
+
}
|
|
6281
7482
|
function readStringPayloadValue(payload, key) {
|
|
6282
7483
|
const value = payload[key];
|
|
6283
7484
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
@@ -6430,6 +7631,73 @@ function readInitiatorAndSubmitter(instance) {
|
|
|
6430
7631
|
function isOrgManagerChainNode(node) {
|
|
6431
7632
|
return node.data.approverResolver.type === 'ORG_MANAGER_CHAIN';
|
|
6432
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
|
+
}
|
|
6433
7701
|
// Candidates of a manager-chain node that came from the chain (or replaced the
|
|
6434
7702
|
// excluded initiator's seat); ad-hoc approvers are chosen by a person instead.
|
|
6435
7703
|
function isGradeGatedCandidate(node, sourceType) {
|
|
@@ -6843,9 +8111,7 @@ function readAdhocTargetValue(directive) {
|
|
|
6843
8111
|
webhookHeaders: isRecord(value.webhookHeaders)
|
|
6844
8112
|
? Object.entries(value.webhookHeaders).reduce((accumulator, [key, headerValue]) => ({
|
|
6845
8113
|
...accumulator,
|
|
6846
|
-
...(typeof headerValue === 'string'
|
|
6847
|
-
? { [key]: headerValue }
|
|
6848
|
-
: {}),
|
|
8114
|
+
...(typeof headerValue === 'string' ? { [key]: headerValue } : {}),
|
|
6849
8115
|
}), {})
|
|
6850
8116
|
: undefined,
|
|
6851
8117
|
webhookUrl: typeof value.webhookUrl === 'string' ? value.webhookUrl : '',
|
|
@@ -6961,10 +8227,7 @@ function compareManagerResolution(left, right, orgUnitDepths) {
|
|
|
6961
8227
|
return right.effectiveFrom.localeCompare(left.effectiveFrom);
|
|
6962
8228
|
}
|
|
6963
8229
|
function readOrgUnitDepthMap(orgUnits) {
|
|
6964
|
-
return new Map(orgUnits.map((orgUnit) => [
|
|
6965
|
-
orgUnit.id,
|
|
6966
|
-
orgUnit.path.split('.').length,
|
|
6967
|
-
]));
|
|
8230
|
+
return new Map(orgUnits.map((orgUnit) => [orgUnit.id, orgUnit.path.split('.').length]));
|
|
6968
8231
|
}
|
|
6969
8232
|
function readManagerResolutionScopeRank(scopeType) {
|
|
6970
8233
|
const ranks = {
|
|
@@ -7303,7 +8566,9 @@ function readTableMinRows(field) {
|
|
|
7303
8566
|
return typeof field.minRows === 'number' ? field.minRows : 0;
|
|
7304
8567
|
}
|
|
7305
8568
|
function readTableMaxRows(field) {
|
|
7306
|
-
return typeof field.maxRows === 'number'
|
|
8569
|
+
return typeof field.maxRows === 'number'
|
|
8570
|
+
? field.maxRows
|
|
8571
|
+
: form_1.FORM_TABLE_MAX_ROWS;
|
|
7307
8572
|
}
|
|
7308
8573
|
function readTableRequiredRowCount(field) {
|
|
7309
8574
|
return Math.max(readTableMinRows(field), 1);
|
|
@@ -7663,4 +8928,17 @@ function throwBusinessKeyConflictOrRethrow(error, businessKey) {
|
|
|
7663
8928
|
}
|
|
7664
8929
|
throw error;
|
|
7665
8930
|
}
|
|
8931
|
+
/**
|
|
8932
|
+
* The keys whose value differs between the stored form data and the data a
|
|
8933
|
+
* resubmission replaces it with, sorted. A key present on only one side counts.
|
|
8934
|
+
*/
|
|
8935
|
+
function listChangedFormDataKeys(previous, next) {
|
|
8936
|
+
const keys = new Set([...Object.keys(previous), ...Object.keys(next)]);
|
|
8937
|
+
return [...keys]
|
|
8938
|
+
.filter((fieldKey) => Object.prototype.hasOwnProperty.call(previous, fieldKey) !==
|
|
8939
|
+
Object.prototype.hasOwnProperty.call(next, fieldKey) ||
|
|
8940
|
+
(0, form_data_fingerprint_1.computeStableValueFingerprint)(previous[fieldKey]) !==
|
|
8941
|
+
(0, form_data_fingerprint_1.computeStableValueFingerprint)(next[fieldKey]))
|
|
8942
|
+
.sort();
|
|
8943
|
+
}
|
|
7666
8944
|
//# sourceMappingURL=workflow-engine.service.js.map
|