@rytass/bpm-core-nestjs-module 0.21.0 → 0.21.2
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 +51 -0
- package/README.md +105 -1
- package/package.json +2 -2
- package/src/lib/attachment/attachment.service.js +2 -10
- package/src/lib/attachment/attachment.service.js.map +1 -1
- package/src/lib/attachment/file-upload-value-ids.d.ts +8 -0
- package/src/lib/attachment/file-upload-value-ids.js +15 -0
- package/src/lib/attachment/file-upload-value-ids.js.map +1 -0
- package/src/lib/bpm/bpm-root-options.d.ts +18 -0
- package/src/lib/bpm/bpm-root-options.js.map +1 -1
- package/src/lib/bpm/bpm-root.module.d.ts +11 -0
- package/src/lib/bpm/bpm-root.module.js +9 -4
- package/src/lib/bpm/bpm-root.module.js.map +1 -1
- package/src/lib/form-data-source/form-data-source.module.d.ts +8 -0
- package/src/lib/form-data-source/form-data-source.module.js +1 -1
- package/src/lib/form-data-source/form-data-source.module.js.map +1 -1
- package/src/lib/form-data-source/form-data-source.service.js +1 -1
- package/src/lib/form-data-source/form-data-source.service.js.map +1 -1
- package/src/lib/notification/notification.service.js +6 -0
- package/src/lib/notification/notification.service.js.map +1 -1
- package/src/lib/organization/organization-time-zone.d.ts +4 -2
- package/src/lib/organization/organization-time-zone.js +3 -6
- package/src/lib/organization/organization-time-zone.js.map +1 -1
- package/src/lib/template/template.service.js +51 -5
- package/src/lib/template/template.service.js.map +1 -1
- package/src/lib/template/workflow-definition.validator.js +84 -2
- package/src/lib/template/workflow-definition.validator.js.map +1 -1
- package/src/lib/workflow-engine/index.d.ts +1 -0
- package/src/lib/workflow-engine/index.js +1 -0
- package/src/lib/workflow-engine/index.js.map +1 -1
- package/src/lib/workflow-engine/initiator-custom-fields-resolver.token.d.ts +108 -0
- package/src/lib/workflow-engine/initiator-custom-fields-resolver.token.js +9 -0
- package/src/lib/workflow-engine/initiator-custom-fields-resolver.token.js.map +1 -0
- package/src/lib/workflow-engine/workflow-engine.enums.d.ts +1 -0
- package/src/lib/workflow-engine/workflow-engine.enums.js +1 -0
- 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 +9 -3
- package/src/lib/workflow-engine/workflow-engine.module.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.queries.d.ts +1 -1
- package/src/lib/workflow-engine/workflow-engine.queries.js +4 -3
- package/src/lib/workflow-engine/workflow-engine.queries.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.service.d.ts +131 -14
- package/src/lib/workflow-engine/workflow-engine.service.js +654 -88
- package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
|
@@ -13,6 +13,7 @@ const common_1 = require("@nestjs/common");
|
|
|
13
13
|
const typeorm_1 = require("@nestjs/typeorm");
|
|
14
14
|
const typeorm_2 = require("typeorm");
|
|
15
15
|
const attachment_service_1 = require("../attachment/attachment.service");
|
|
16
|
+
const file_upload_value_ids_1 = require("../attachment/file-upload-value-ids");
|
|
16
17
|
const delegation_service_1 = require("../delegation/delegation.service");
|
|
17
18
|
const form_definition_version_entity_1 = require("../form/form-definition-version.entity");
|
|
18
19
|
const form_enums_1 = require("../form/form.enums");
|
|
@@ -56,6 +57,7 @@ const time_budget_1 = require("./time-budget");
|
|
|
56
57
|
const expression_budget_1 = require("../condition/expression-budget");
|
|
57
58
|
const expression_errors_1 = require("../condition/expression-errors");
|
|
58
59
|
const expression_dialect_1 = require("../condition/expression-dialect");
|
|
60
|
+
const initiator_custom_fields_resolver_token_1 = require("./initiator-custom-fields-resolver.token");
|
|
59
61
|
const workflow_service_task_dispatcher_token_1 = require("./workflow-service-task-dispatcher.token");
|
|
60
62
|
const workflow_token_entity_1 = require("./workflow-token.entity");
|
|
61
63
|
const bpm_root_options_1 = require("../bpm/bpm-root-options");
|
|
@@ -70,6 +72,15 @@ const APPROVAL_INSTANCE_BUSINESS_KEY_INDEX = 'approval_instances_business_key_ke
|
|
|
70
72
|
const ORG_MANAGER_CHAIN_MAX_HOPS = 20;
|
|
71
73
|
const DEFAULT_APPROVAL_HISTORY_TASK_LIMIT = 50;
|
|
72
74
|
const SYSTEM_DRY_RUN_INSTANCE_ID = 'dry-run-instance';
|
|
75
|
+
// How long a submission waits for the host's initiator `customFields`
|
|
76
|
+
// resolver. The case is not created when it runs out (the snapshot never falls
|
|
77
|
+
// back to `{}`), so this bounds how long a member stares at a spinner when the
|
|
78
|
+
// host's lookup hangs; it is shorter than the 30 s webhook budget because a
|
|
79
|
+
// person is waiting on this one.
|
|
80
|
+
const INITIATOR_CUSTOM_FIELDS_RESOLVER_TIMEOUT_MS = 10_000;
|
|
81
|
+
const INITIATOR_CUSTOM_FIELDS_TIMEOUT_MESSAGE = '無法取得發起人的自訂欄位(逾時),這次申請沒有送出;請稍後再試或聯絡系統管理者';
|
|
82
|
+
const INITIATOR_CUSTOM_FIELDS_FAILED_MESSAGE = '無法取得發起人的自訂欄位,這次申請沒有送出;請稍後再試或聯絡系統管理者';
|
|
83
|
+
const INITIATOR_CUSTOM_FIELDS_INVALID_MESSAGE = '無法取得發起人的自訂欄位(回傳格式不正確),這次申請沒有送出;請聯絡系統管理者';
|
|
73
84
|
// The dry run checks each expression against the roots its use point may read,
|
|
74
85
|
// as publish does. Keep these in step with `WORKFLOW_EXPRESSION_ROOT_IDENTIFIERS`
|
|
75
86
|
// in template.service.ts; P2 moves both to one place.
|
|
@@ -122,6 +133,7 @@ const TASK_REASSIGNMENT_MESSAGES = {
|
|
|
122
133
|
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_ALREADY_DECIDED]: '接手人已對此任務簽過核',
|
|
123
134
|
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_ALREADY_ON_TASK]: '接手人已在此任務的待簽名單上',
|
|
124
135
|
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_GRADE_TOO_LOW]: '接手人職等不足,不能取代此簽核位置',
|
|
136
|
+
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_EXCLUDED_NODE_ACTOR]: '此節點不允許與前面關卡的簽核者為同一人,接手人不能是前面關卡的簽核者',
|
|
125
137
|
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_INITIATOR]: '此節點不允許申請人自簽,接手人不能是申請人',
|
|
126
138
|
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_SUBMITTER]: '此節點不允許代送人簽核,接手人不能是代為送出此單的人',
|
|
127
139
|
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_SAME_AS_SOURCE]: '接手人與原簽核人相同',
|
|
@@ -177,7 +189,7 @@ const WORKFLOW_READ_ALL_PERMISSIONS = new Set([
|
|
|
177
189
|
'workflow.read_all',
|
|
178
190
|
]);
|
|
179
191
|
let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineService {
|
|
180
|
-
constructor(approvalInstanceRepository, workflowTokenRepository, taskRepository, taskCandidateRepository, taskDecisionRepository, notificationRepository, activityLogRepository, adhocDirectiveRepository, approvalTemplateRepository, approvalTemplateVersionRepository, formDefinitionVersionRepository, attachmentService, conditionService, delegationService, notificationService, signatureService, slaScheduleService, injectedServiceTaskDispatcher, formDataSourceValueResolver, rootOptions, webhookDeliveryService) {
|
|
192
|
+
constructor(approvalInstanceRepository, workflowTokenRepository, taskRepository, taskCandidateRepository, taskDecisionRepository, notificationRepository, activityLogRepository, adhocDirectiveRepository, approvalTemplateRepository, approvalTemplateVersionRepository, formDefinitionVersionRepository, attachmentService, conditionService, delegationService, notificationService, signatureService, slaScheduleService, injectedServiceTaskDispatcher, formDataSourceValueResolver, rootOptions, webhookDeliveryService, injectedInitiatorCustomFieldsResolver) {
|
|
181
193
|
this.approvalInstanceRepository = approvalInstanceRepository;
|
|
182
194
|
this.workflowTokenRepository = workflowTokenRepository;
|
|
183
195
|
this.taskRepository = taskRepository;
|
|
@@ -210,12 +222,18 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
210
222
|
// keeps a host that binds the token from its own global module working —
|
|
211
223
|
// a module-local default would shadow that binding, silently sending
|
|
212
224
|
// WEBHOOK service tasks through the built-in `fetch` dispatcher instead of
|
|
213
|
-
// the host's signing or queueing one.
|
|
214
|
-
// service, which a provider registered on one module does not.
|
|
225
|
+
// the host's signing or queueing one.
|
|
215
226
|
this.serviceTaskDispatcher =
|
|
216
227
|
injectedServiceTaskDispatcher ??
|
|
217
228
|
rootOptions?.workflowServiceTaskDispatcher ??
|
|
218
229
|
new workflow_service_task_dispatcher_token_1.DefaultWorkflowServiceTaskDispatcher();
|
|
230
|
+
// Resolved like the dispatcher above, for the same reason: a binding the
|
|
231
|
+
// host made under the token wins, then the runtime instance, and with
|
|
232
|
+
// neither the snapshot's `customFields` stays `{}`.
|
|
233
|
+
this.initiatorCustomFieldsResolver =
|
|
234
|
+
injectedInitiatorCustomFieldsResolver ??
|
|
235
|
+
rootOptions?.initiatorCustomFieldsResolver ??
|
|
236
|
+
null;
|
|
219
237
|
this.organizationTimeZone = (0, organization_time_zone_1.resolveBPMOrganizationTimeZone)(rootOptions?.organizationTimeZone);
|
|
220
238
|
}
|
|
221
239
|
/**
|
|
@@ -281,6 +299,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
281
299
|
}
|
|
282
300
|
}
|
|
283
301
|
async submitApprovalInstance(input, authContext) {
|
|
302
|
+
const submitAuthContext = authContext ?? createWorkflowAuthContext(input.initiatorMemberId);
|
|
284
303
|
if (input.submittedByMemberId !== undefined &&
|
|
285
304
|
input.submittedByMemberId !== null &&
|
|
286
305
|
(typeof input.submittedByMemberId !== 'string' ||
|
|
@@ -319,14 +338,22 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
319
338
|
// (ADR 25 §15), which is what makes this schema the one in force.
|
|
320
339
|
assertFilterableFormDataValues(formDefinitionVersion.schema, formData);
|
|
321
340
|
const formDataOptionSnapshot = await this.resolveFormDataOptionSnapshots({
|
|
322
|
-
authContext:
|
|
341
|
+
authContext: submitAuthContext,
|
|
323
342
|
formData,
|
|
324
343
|
revalidateAll: true,
|
|
325
344
|
schema: formDefinitionVersion.schema,
|
|
326
345
|
});
|
|
327
346
|
const initiatorMetadataSnapshot = input.initiatorMetadataSnapshotJson
|
|
328
347
|
? parseJsonObject(input.initiatorMetadataSnapshotJson, 'initiatorMetadataSnapshotJson')
|
|
329
|
-
: await this.
|
|
348
|
+
: await this.buildInitiatorMetadataSnapshot({
|
|
349
|
+
authContext: submitAuthContext,
|
|
350
|
+
memberId: input.initiatorMemberId,
|
|
351
|
+
submission: {
|
|
352
|
+
formData,
|
|
353
|
+
templateId: template.id,
|
|
354
|
+
templateVersionId: templateVersion.id,
|
|
355
|
+
},
|
|
356
|
+
});
|
|
330
357
|
if (!this.conditionService.evaluateBoolean(templateVersion.initiatorPolicyCel, buildInitiatorPolicyContext(initiatorMetadataSnapshot), 'initiatorPolicyCel',
|
|
331
358
|
// The policy is written in its version's dialect (ADR 22 §3.3).
|
|
332
359
|
readExpressionDialectOptions(templateVersion.initiatorPolicyCel, templateVersion.workflowDefinition))) {
|
|
@@ -642,6 +669,15 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
642
669
|
transferToMemberId === (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance)) {
|
|
643
670
|
throw new common_1.ConflictException(`簽核節點「${taskNode.data.label}」不允許代送人簽核,不能將任務轉交給代為送出此單的人`);
|
|
644
671
|
}
|
|
672
|
+
// `excludeNodeActors`: the same carve-out, the same refusal. A human
|
|
673
|
+
// may not hand the seat to someone who signed one of the listed stages.
|
|
674
|
+
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED &&
|
|
675
|
+
options.transferReason === undefined &&
|
|
676
|
+
isNodeActorsExcluded(taskNode) &&
|
|
677
|
+
transferToMemberId !== null &&
|
|
678
|
+
(await this.isExcludedNodeActorTarget(manager, instance, taskNode, transferToMemberId))) {
|
|
679
|
+
throw new common_1.ConflictException(`簽核節點「${taskNode.data.label}」不允許與前面關卡的簽核者為同一人,不能將任務轉交給前面${readNodeActorNodeNames(instance, readExcludedNodeActorNodeIds(taskNode))}的簽核者`);
|
|
680
|
+
}
|
|
645
681
|
// Same carve-out: a human picking the target on a manager-chain node
|
|
646
682
|
// must pick someone at least as senior as the member being replaced.
|
|
647
683
|
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED &&
|
|
@@ -665,7 +701,18 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
665
701
|
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.RETURNED &&
|
|
666
702
|
!decisionComment &&
|
|
667
703
|
isReturnCommentRequired(instance.workflowSnapshot, task.nodeId)) {
|
|
668
|
-
throw new common_1.BadRequestException(
|
|
704
|
+
throw new common_1.BadRequestException('此節點要求退回時填寫意見,請輸入意見後再退回。');
|
|
705
|
+
}
|
|
706
|
+
// Same snapshot rule as the return check above. Only a person's
|
|
707
|
+
// approval is held to it: an engine-made approval (the SLA
|
|
708
|
+
// `AUTO_APPROVE` timeout) carries `options.automatic` and must never
|
|
709
|
+
// stall waiting for a comment nobody is there to write. A person's
|
|
710
|
+
// approval of an ad-hoc task counts too: it runs on the main node.
|
|
711
|
+
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED &&
|
|
712
|
+
options.automatic === undefined &&
|
|
713
|
+
!decisionComment &&
|
|
714
|
+
taskNode.data.requireApprovalComment === true) {
|
|
715
|
+
throw new common_1.BadRequestException('此節點要求同意時填寫意見,請輸入意見後再同意。');
|
|
669
716
|
}
|
|
670
717
|
if (filledFormData !== null && task.isAdhoc) {
|
|
671
718
|
throw new common_1.ForbiddenException(`Ad-hoc task ${task.id} cannot fill in form fields`);
|
|
@@ -1089,7 +1136,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
1089
1136
|
// reached, against that node's own setting. This checks the target
|
|
1090
1137
|
// against the node being decided now, as an early rejection for the
|
|
1091
1138
|
// common case; it is not a guarantee about the node it attaches to.
|
|
1092
|
-
if (
|
|
1139
|
+
if (hasSeatExclusion(node)) {
|
|
1093
1140
|
await this.resolveAdhocTaskCandidates(manager, instance, node, '臨時會簽', targetValue);
|
|
1094
1141
|
}
|
|
1095
1142
|
const directiveRepository = manager.getRepository(adhoc_directive_entity_1.AdhocDirectiveEntity);
|
|
@@ -1354,19 +1401,25 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
1354
1401
|
return task;
|
|
1355
1402
|
}
|
|
1356
1403
|
async resolveAdhocTaskCandidates(manager, instance, node, label, targetValue) {
|
|
1357
|
-
const
|
|
1404
|
+
const exclusions = await this.readNodeExclusions(manager, instance, node);
|
|
1405
|
+
const resolvedCandidates = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), `${label}「${node.data.label}」`, '簽核人', exclusions.memberIds);
|
|
1358
1406
|
// Ad-hoc approvers are picked by a person, not walked up a manager chain,
|
|
1359
1407
|
// so the chain's grade rule does not apply to them.
|
|
1360
1408
|
const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, false);
|
|
1361
|
-
const candidates = selectNodeCandidates(delegatedCandidates,
|
|
1409
|
+
const candidates = selectNodeCandidates(delegatedCandidates, hasSeatExclusion(node), exclusions.memberIds);
|
|
1362
1410
|
if (candidates.length > 0) {
|
|
1363
1411
|
return candidates;
|
|
1364
1412
|
}
|
|
1365
|
-
|
|
1413
|
+
if (delegatedCandidates.length === 0) {
|
|
1414
|
+
throw new common_1.ConflictException(`${label}「${node.data.label}」 did not resolve to any member id`);
|
|
1415
|
+
}
|
|
1416
|
+
// The initiator rule runs first: only when it alone left somebody is the
|
|
1417
|
+
// earlier-stage rule the one that emptied the list.
|
|
1418
|
+
throw new common_1.ConflictException(isEmptiedByInitiatorRule(node, delegatedCandidates, exclusions)
|
|
1366
1419
|
? (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) === null
|
|
1367
1420
|
? `${label}「${node.data.label}」 無法建立任務:指定對象只有申請人本人,此節點不允許申請人簽核。`
|
|
1368
1421
|
: `${label}「${node.data.label}」 無法建立任務:指定對象只有申請人或代為送出此單的人,此節點不允許申請人或代送人簽核。`
|
|
1369
|
-
: `${label}「${node.data.label}」
|
|
1422
|
+
: `${label}「${node.data.label}」 無法建立任務:指定對象只有前面${readNodeActorNodeNames(instance, readExcludedNodeActorNodeIds(node))}的簽核者,此節點不允許與前面關卡的簽核者為同一人。`);
|
|
1370
1423
|
}
|
|
1371
1424
|
async spawnCountersignTasksForNode(manager, instance, token, node, now) {
|
|
1372
1425
|
const directiveRepository = manager.getRepository(adhoc_directive_entity_1.AdhocDirectiveEntity);
|
|
@@ -1814,6 +1867,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
1814
1867
|
}
|
|
1815
1868
|
const workflowDefinition = parseWorkflowDefinition(input.workflowDefinitionJson);
|
|
1816
1869
|
const formData = parseJsonObject(input.formDataJson, 'formDataJson');
|
|
1870
|
+
// The snapshot here is the caller's own sample (the designer always sends
|
|
1871
|
+
// one) or a bare `{ customFields: {}, memberId }`. It is not the one a real
|
|
1872
|
+
// submission builds, and a dry run has no template version to hand the
|
|
1873
|
+
// host's `customFields` resolver, so that resolver is not called.
|
|
1817
1874
|
const initiatorMetadataSnapshot = input.initiatorMetadataSnapshotJson
|
|
1818
1875
|
? parseJsonObject(input.initiatorMetadataSnapshotJson, 'initiatorMetadataSnapshotJson')
|
|
1819
1876
|
: readDefaultInitiatorMetadataSnapshot(input.initiatorMemberId);
|
|
@@ -2012,6 +2069,18 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2012
2069
|
outcome: 'STOPPED',
|
|
2013
2070
|
};
|
|
2014
2071
|
}
|
|
2072
|
+
// Same for a manager chain whose start a newer engine wrote.
|
|
2073
|
+
const unsupportedStartType = resolver.type === 'ORG_MANAGER_CHAIN' &&
|
|
2074
|
+
!(0, workflow_1.isOrgManagerChainStartedFromInitiator)(resolver)
|
|
2075
|
+
? (0, workflow_1.readUnsupportedOrgManagerChainStartType)(resolver.startFrom)
|
|
2076
|
+
: null;
|
|
2077
|
+
if (unsupportedStartType !== null) {
|
|
2078
|
+
return {
|
|
2079
|
+
assigneeMemberId: null,
|
|
2080
|
+
message: `簽核節點「${readDryRunNodeName(node)}」此引擎版本不支援主管鏈起點「${unsupportedStartType}」,請升級 BPMCore 後再試`,
|
|
2081
|
+
outcome: 'STOPPED',
|
|
2082
|
+
};
|
|
2083
|
+
}
|
|
2015
2084
|
const memberIds = this.readDryRunMemberIds(input, resolver, `workflow.nodes.${node.id}.data.approverResolver`);
|
|
2016
2085
|
if (memberIds === null) {
|
|
2017
2086
|
return {
|
|
@@ -2394,7 +2463,12 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2394
2463
|
},
|
|
2395
2464
|
});
|
|
2396
2465
|
}
|
|
2397
|
-
|
|
2466
|
+
/**
|
|
2467
|
+
* The active templates `memberId` may start now. `authContext` is handed to
|
|
2468
|
+
* the host's initiator `customFields` resolver; a host calling this without
|
|
2469
|
+
* one gets a bare context for the member, as a submission does.
|
|
2470
|
+
*/
|
|
2471
|
+
async listLaunchableApprovalTemplates(memberId, authContext) {
|
|
2398
2472
|
const templates = await this.approvalTemplateRepository.find({
|
|
2399
2473
|
order: { updatedAt: 'DESC' },
|
|
2400
2474
|
where: { isActive: true },
|
|
@@ -2415,7 +2489,17 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2415
2489
|
version.id,
|
|
2416
2490
|
version,
|
|
2417
2491
|
]));
|
|
2418
|
-
|
|
2492
|
+
// Built like a submission's, so a policy that reads
|
|
2493
|
+
// `initiator.customFields` is judged here against what the host's resolver
|
|
2494
|
+
// returns for this member. The resolver depends on the initiator only, so
|
|
2495
|
+
// it is called once for the whole list, not once per template. When it
|
|
2496
|
+
// fails, the list is judged against `customFields: {}` instead of failing
|
|
2497
|
+
// (see `buildInitiatorMetadataSnapshot`).
|
|
2498
|
+
const initiatorMetadataSnapshot = await this.buildInitiatorMetadataSnapshot({
|
|
2499
|
+
authContext: authContext ?? createWorkflowAuthContext(memberId),
|
|
2500
|
+
memberId,
|
|
2501
|
+
submission: null,
|
|
2502
|
+
});
|
|
2419
2503
|
return templates.filter((template) => {
|
|
2420
2504
|
const currentVersion = template.currentVersionId
|
|
2421
2505
|
? (versionById.get(template.currentVersionId) ?? null)
|
|
@@ -2433,8 +2517,19 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2433
2517
|
return false;
|
|
2434
2518
|
}
|
|
2435
2519
|
// Each template is its own operation, so one slow template cannot use
|
|
2436
|
-
// up the allowance of the rest of the list.
|
|
2437
|
-
|
|
2520
|
+
// up the allowance of the rest of the list. A policy that throws (for
|
|
2521
|
+
// example a missing `initiator.customFields.*` key, because the resolver
|
|
2522
|
+
// did not return it, or failed and the list fell back to `{}`) is also
|
|
2523
|
+
// only that template's problem: it is left out, and the error code is
|
|
2524
|
+
// logged, never the values.
|
|
2525
|
+
try {
|
|
2526
|
+
return (0, expression_budget_1.runExpressionOperation)(() => this.conditionService.evaluateBoolean(currentVersion.initiatorPolicyCel, buildInitiatorPolicyContext(initiatorMetadataSnapshot), 'initiatorPolicyCel', readExpressionDialectOptions(currentVersion.initiatorPolicyCel, currentVersion.workflowDefinition)));
|
|
2527
|
+
}
|
|
2528
|
+
catch (error) {
|
|
2529
|
+
const reason = error instanceof expression_errors_1.ExpressionRuntimeError ? error.code : 'UNEXPECTED';
|
|
2530
|
+
this.logger.warn(`template=${template.id} version=${currentVersion.id} left out of launchable templates: initiatorPolicyCel failed (${reason})`);
|
|
2531
|
+
return false;
|
|
2532
|
+
}
|
|
2438
2533
|
});
|
|
2439
2534
|
}
|
|
2440
2535
|
async listWorkflowTokens(instanceId, scope) {
|
|
@@ -2675,6 +2770,112 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2675
2770
|
primaryOrgUnitId: primaryMembership?.orgUnitId ?? null,
|
|
2676
2771
|
};
|
|
2677
2772
|
}
|
|
2773
|
+
/**
|
|
2774
|
+
* The initiator snapshot the engine builds itself: what it knows of the
|
|
2775
|
+
* initiator, with `customFields` filled by the host's resolver when there is
|
|
2776
|
+
* one. Without a resolver this is exactly the default snapshot
|
|
2777
|
+
* (`customFields` is `{}`). A submission (`submission` set) stores it and
|
|
2778
|
+
* judges the initiator policy with it; the launchable template list
|
|
2779
|
+
* (`submission: null`) only judges the policies with it, so both see the same
|
|
2780
|
+
* `customFields` for the same member.
|
|
2781
|
+
*
|
|
2782
|
+
* Only `customFields` comes from the host. The other keys are the engine's
|
|
2783
|
+
* and are written back after the resolver returns, so nothing it does can
|
|
2784
|
+
* change who the initiator is. The resolver gets copies of the snapshot and
|
|
2785
|
+
* of the form data for the same reason; `authContext` is the caller's own
|
|
2786
|
+
* object, as every other host hook receives it.
|
|
2787
|
+
*
|
|
2788
|
+
* The two callers treat a resolver failure (an error, a bad value, the
|
|
2789
|
+
* timeout) differently, on purpose:
|
|
2790
|
+
*
|
|
2791
|
+
* - A submission fails, and no case is created. Its snapshot is stored and
|
|
2792
|
+
* routes the case from then on, so carrying on with `{}` would start a
|
|
2793
|
+
* case on data the host never gave. It runs before the engine
|
|
2794
|
+
* transaction, so a failure leaves nothing behind and a slow lookup holds
|
|
2795
|
+
* no lock.
|
|
2796
|
+
* - The list carries on with `customFields: {}` and logs a warning: one
|
|
2797
|
+
* failing lookup must not take the whole list away. That fails closed: a
|
|
2798
|
+
* policy that reads `initiator.customFields` then throws on the missing key
|
|
2799
|
+
* and the list's per-template catch leaves that template out, while a
|
|
2800
|
+
* template whose policy does not read it is listed as usual. Submitting is
|
|
2801
|
+
* still judged against the resolver's real answer.
|
|
2802
|
+
*/
|
|
2803
|
+
async buildInitiatorMetadataSnapshot({ authContext, memberId, submission, }) {
|
|
2804
|
+
const defaultSnapshot = await this.readDefaultInitiatorMetadataSnapshot(memberId);
|
|
2805
|
+
if (!this.initiatorCustomFieldsResolver) {
|
|
2806
|
+
return defaultSnapshot;
|
|
2807
|
+
}
|
|
2808
|
+
const input = {
|
|
2809
|
+
authContext,
|
|
2810
|
+
defaultSnapshot: cloneJsonObject(defaultSnapshot),
|
|
2811
|
+
formData: submission ? cloneJsonObject(submission.formData) : null,
|
|
2812
|
+
memberId,
|
|
2813
|
+
purpose: submission ? 'SUBMISSION' : 'LAUNCHABLE_TEMPLATES',
|
|
2814
|
+
templateId: submission?.templateId ?? null,
|
|
2815
|
+
templateVersionId: submission?.templateVersionId ?? null,
|
|
2816
|
+
};
|
|
2817
|
+
if (submission) {
|
|
2818
|
+
return {
|
|
2819
|
+
...defaultSnapshot,
|
|
2820
|
+
customFields: await this.resolveInitiatorCustomFields(this.initiatorCustomFieldsResolver, input),
|
|
2821
|
+
};
|
|
2822
|
+
}
|
|
2823
|
+
try {
|
|
2824
|
+
return {
|
|
2825
|
+
...defaultSnapshot,
|
|
2826
|
+
customFields: await this.resolveInitiatorCustomFields(this.initiatorCustomFieldsResolver, input),
|
|
2827
|
+
};
|
|
2828
|
+
}
|
|
2829
|
+
catch (error) {
|
|
2830
|
+
this.logger.warn(`Launchable templates judged with customFields {}: the initiator customFields resolver failed (${error instanceof common_1.HttpException ? error.getStatus() : 'UNEXPECTED'})`);
|
|
2831
|
+
return defaultSnapshot;
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
/**
|
|
2835
|
+
* Calls the host's initiator `customFields` resolver within
|
|
2836
|
+
* {@link INITIATOR_CUSTOM_FIELDS_RESOLVER_TIMEOUT_MS} and returns what it
|
|
2837
|
+
* gave as the object the snapshot keeps.
|
|
2838
|
+
*
|
|
2839
|
+
* Every failure becomes an `InternalServerErrorException` with a message
|
|
2840
|
+
* meant for the member, and the cause goes to the log only, so nothing of
|
|
2841
|
+
* the host's lookup reaches the client: running out of time, an error that
|
|
2842
|
+
* is not an `HttpException`, and a value that is not a plain JSON object. An
|
|
2843
|
+
* `HttpException` the resolver throws is passed on as it is, because the
|
|
2844
|
+
* host chose that answer for the member.
|
|
2845
|
+
*
|
|
2846
|
+
* A resolver that settles after the time has run out is not waited for; what
|
|
2847
|
+
* it settles with is logged and dropped, so a late rejection is never left
|
|
2848
|
+
* unhandled.
|
|
2849
|
+
*/
|
|
2850
|
+
async resolveInitiatorCustomFields(resolver, input) {
|
|
2851
|
+
const subject = input.purpose === 'SUBMISSION'
|
|
2852
|
+
? `template=${input.templateId} version=${input.templateVersionId}`
|
|
2853
|
+
: 'launchable templates';
|
|
2854
|
+
const outcome = await settleWithin(new Promise((resolve) => {
|
|
2855
|
+
resolve(resolver.resolveCustomFields(input));
|
|
2856
|
+
}), INITIATOR_CUSTOM_FIELDS_RESOLVER_TIMEOUT_MS, (late) => {
|
|
2857
|
+
this.logger.warn(`Initiator customFields resolver settled after the ${INITIATOR_CUSTOM_FIELDS_RESOLVER_TIMEOUT_MS} ms timeout (${subject}); ${late.status === 'fulfilled'
|
|
2858
|
+
? 'its value was dropped'
|
|
2859
|
+
: `its error was dropped: ${describeRejection(late.reason)}`}`);
|
|
2860
|
+
});
|
|
2861
|
+
if (outcome.status === 'timeout') {
|
|
2862
|
+
this.logger.error(`Initiator customFields resolver timed out after ${INITIATOR_CUSTOM_FIELDS_RESOLVER_TIMEOUT_MS} ms (${subject})`);
|
|
2863
|
+
throw new common_1.InternalServerErrorException(INITIATOR_CUSTOM_FIELDS_TIMEOUT_MESSAGE);
|
|
2864
|
+
}
|
|
2865
|
+
if (outcome.status === 'rejected') {
|
|
2866
|
+
if (outcome.reason instanceof common_1.HttpException) {
|
|
2867
|
+
throw outcome.reason;
|
|
2868
|
+
}
|
|
2869
|
+
this.logger.error(`Initiator customFields resolver failed (${subject}): ${describeRejection(outcome.reason)}`, outcome.reason instanceof Error ? outcome.reason.stack : undefined);
|
|
2870
|
+
throw new common_1.InternalServerErrorException(INITIATOR_CUSTOM_FIELDS_FAILED_MESSAGE);
|
|
2871
|
+
}
|
|
2872
|
+
const customFields = readResolvedInitiatorCustomFields(outcome.value);
|
|
2873
|
+
if (!customFields.ok) {
|
|
2874
|
+
this.logger.error(`Initiator customFields resolver returned an unusable value (${subject}): ${customFields.problem}`);
|
|
2875
|
+
throw new common_1.InternalServerErrorException(INITIATOR_CUSTOM_FIELDS_INVALID_MESSAGE);
|
|
2876
|
+
}
|
|
2877
|
+
return customFields.value;
|
|
2878
|
+
}
|
|
2678
2879
|
async listActivityLogs(instanceId, scope) {
|
|
2679
2880
|
await this.getApprovalInstance(instanceId, scope);
|
|
2680
2881
|
return this.activityLogRepository.find({
|
|
@@ -2895,10 +3096,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2895
3096
|
skippedByEntryCondition: true,
|
|
2896
3097
|
});
|
|
2897
3098
|
}
|
|
2898
|
-
async skipNodeByInitiatorExclusion(manager, instance, token, node) {
|
|
2899
|
-
await this.skipNode(manager, instance, token, node,
|
|
2900
|
-
|
|
2901
|
-
|
|
3099
|
+
async skipNodeByInitiatorExclusion(manager, instance, token, node, skippedBy = 'INITIATOR') {
|
|
3100
|
+
await this.skipNode(manager, instance, token, node, skippedBy === 'NODE_ACTORS'
|
|
3101
|
+
? { skippedByNodeActorExclusion: true }
|
|
3102
|
+
: { skippedByInitiatorExclusion: true });
|
|
2902
3103
|
}
|
|
2903
3104
|
async skipNode(manager, instance, token, node, reason) {
|
|
2904
3105
|
// A NOTIFY node is an asynchronous branch that may not have an outgoing
|
|
@@ -3127,11 +3328,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3127
3328
|
return;
|
|
3128
3329
|
}
|
|
3129
3330
|
const resolvedCandidates = await this.resolveRuntimeTaskCandidates(manager, instance, node);
|
|
3130
|
-
//
|
|
3131
|
-
//
|
|
3132
|
-
//
|
|
3133
|
-
if (
|
|
3134
|
-
await this.skipNodeByInitiatorExclusion(manager, instance, token, node);
|
|
3331
|
+
// Everyone left was kept out and the node asks to be skipped in that case:
|
|
3332
|
+
// no task, the token moves on. Pending countersign directives stay
|
|
3333
|
+
// pending for the next user task that actually gets created.
|
|
3334
|
+
if ('skippedBy' in resolvedCandidates) {
|
|
3335
|
+
await this.skipNodeByInitiatorExclusion(manager, instance, token, node, resolvedCandidates.skippedBy);
|
|
3135
3336
|
return;
|
|
3136
3337
|
}
|
|
3137
3338
|
const { candidates, skippedDelegations } = resolvedCandidates;
|
|
@@ -3154,7 +3355,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3154
3355
|
: workflow_engine_enums_1.TaskAssignmentTypeEnum.CANDIDATE_GROUP,
|
|
3155
3356
|
completedAt: null,
|
|
3156
3357
|
createdAt: now,
|
|
3157
|
-
decisionPolicySnapshot:
|
|
3358
|
+
decisionPolicySnapshot: hasSeatExclusion(node)
|
|
3158
3359
|
? capDecisionPolicyToCandidateCount(decisionPolicy, candidates.length)
|
|
3159
3360
|
: decisionPolicy,
|
|
3160
3361
|
delegationChain: assignedCandidate?.delegationChain ?? [],
|
|
@@ -3252,7 +3453,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3252
3453
|
const { skippedDelegation, ...delegation } = await this.resolveActivationDelegation({
|
|
3253
3454
|
candidate: nextCandidate,
|
|
3254
3455
|
candidates,
|
|
3255
|
-
|
|
3456
|
+
excludedMemberIds: (await this.readNodeExclusions(manager, instance, node)).memberIds,
|
|
3256
3457
|
gradeGated: isGradeGatedCandidate(node, nextCandidate.sourceType),
|
|
3257
3458
|
instance,
|
|
3258
3459
|
manager,
|
|
@@ -3313,12 +3514,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3313
3514
|
* Re-resolves the delegation of a candidate whose turn has come. When the
|
|
3314
3515
|
* delegate already holds another candidate row on the task, the stored values
|
|
3315
3516
|
* are kept: (task_id, member_id) is unique, and that member signs from the
|
|
3316
|
-
* row they already have. On a node that
|
|
3317
|
-
*
|
|
3318
|
-
*
|
|
3319
|
-
*
|
|
3517
|
+
* row they already have. On a node that keeps members out (the initiator and
|
|
3518
|
+
* submitter, or the approvers of earlier stages) the stored values are kept
|
|
3519
|
+
* as well when the new delegation path would run through one of them:
|
|
3520
|
+
* creation already filtered such paths out, and a delegation added while the
|
|
3521
|
+
* candidate waited must not bring them back.
|
|
3320
3522
|
*/
|
|
3321
|
-
async resolveActivationDelegation({ candidate, candidates,
|
|
3523
|
+
async resolveActivationDelegation({ candidate, candidates, excludedMemberIds, gradeGated, instance, manager, nodeId, }) {
|
|
3322
3524
|
const stored = {
|
|
3323
3525
|
delegationChain: candidate.delegationChain,
|
|
3324
3526
|
memberId: candidate.memberId,
|
|
@@ -3327,12 +3529,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3327
3529
|
const collides = candidates.some((other) => other.id !== candidate.id &&
|
|
3328
3530
|
other.memberId === resolution.finalAssigneeMemberId);
|
|
3329
3531
|
if (collides ||
|
|
3330
|
-
(
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
}, readInitiatorAndSubmitter(instance)))) {
|
|
3532
|
+
isExcludedMemberOnCandidatePath({
|
|
3533
|
+
delegationChain: resolution.delegationChain,
|
|
3534
|
+
memberId: resolution.finalAssigneeMemberId,
|
|
3535
|
+
originalMemberId: candidate.originalMemberId,
|
|
3536
|
+
}, excludedMemberIds)) {
|
|
3336
3537
|
// The refusal happened even though the stored values are kept.
|
|
3337
3538
|
return { ...stored, ...(skippedDelegation ? { skippedDelegation } : {}) };
|
|
3338
3539
|
}
|
|
@@ -3882,6 +4083,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3882
4083
|
throw new TaskReassignmentRejectedError(workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_SUBMITTER);
|
|
3883
4084
|
}
|
|
3884
4085
|
}
|
|
4086
|
+
if (isNodeActorsExcluded(node) &&
|
|
4087
|
+
(await this.isExcludedNodeActorTarget(manager, instance, node, toMemberId))) {
|
|
4088
|
+
throw new TaskReassignmentRejectedError(workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_EXCLUDED_NODE_ACTOR);
|
|
4089
|
+
}
|
|
3885
4090
|
if (!task.isAdhoc && isOrgManagerChainNode(node)) {
|
|
3886
4091
|
const requiredGrade = await this.readTransferTargetRequiredGrade(manager, {
|
|
3887
4092
|
actorMemberId: fromMemberId,
|
|
@@ -4094,58 +4299,178 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4094
4299
|
});
|
|
4095
4300
|
}
|
|
4096
4301
|
/**
|
|
4097
|
-
* Resolves who gets the node's task.
|
|
4098
|
-
* skipped (`excludeInitiator.whenEmpty
|
|
4099
|
-
*
|
|
4100
|
-
* token instead.
|
|
4302
|
+
* Resolves who gets the node's task. A {@link SkippedRuntimeTask} means the
|
|
4303
|
+
* node asked to be skipped (`excludeInitiator.whenEmpty` or
|
|
4304
|
+
* `excludeNodeActors.whenEmpty` = SKIP_NODE) because everyone left was kept
|
|
4305
|
+
* out: the sole caller creates no task and advances the token instead.
|
|
4306
|
+
*
|
|
4307
|
+
* With both exclusions set the initiator rule runs first. When it alone
|
|
4308
|
+
* leaves nobody, `excludeInitiator.whenEmpty` decides; when it leaves
|
|
4309
|
+
* somebody and the earlier-stage rule then removes them all,
|
|
4310
|
+
* `excludeNodeActors.whenEmpty` decides.
|
|
4101
4311
|
*/
|
|
4102
4312
|
async resolveRuntimeTaskCandidates(manager, instance, node) {
|
|
4103
4313
|
const label = `簽核節點「${node.data.label}」`;
|
|
4104
|
-
const
|
|
4314
|
+
const exclusions = await this.readNodeExclusions(manager, instance, node);
|
|
4315
|
+
const resolvedCandidates = await this.resolveApproverResolver(manager, instance, node.data.approverResolver, label, '簽核人', exclusions.memberIds);
|
|
4105
4316
|
const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, isOrgManagerChainNode(node));
|
|
4106
|
-
const
|
|
4317
|
+
const hasExclusion = hasSeatExclusion(node);
|
|
4107
4318
|
const hasSubmitter = (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) !== null;
|
|
4108
|
-
const uniqueCandidates = selectNodeCandidates(delegatedCandidates,
|
|
4319
|
+
const uniqueCandidates = selectNodeCandidates(delegatedCandidates, hasExclusion, exclusions.memberIds);
|
|
4109
4320
|
if (uniqueCandidates.length > 0) {
|
|
4110
4321
|
return {
|
|
4111
4322
|
candidates: uniqueCandidates,
|
|
4112
|
-
skippedDelegations: collectSkippedDelegations(delegatedCandidates,
|
|
4323
|
+
skippedDelegations: collectSkippedDelegations(delegatedCandidates, hasExclusion, exclusions.memberIds),
|
|
4113
4324
|
};
|
|
4114
4325
|
}
|
|
4115
|
-
// Only
|
|
4116
|
-
// Nothing resolved to begin with is the ordinary failure below.
|
|
4117
|
-
if (!
|
|
4326
|
+
// Only excluded people were left — what the node does about it is
|
|
4327
|
+
// configured. Nothing resolved to begin with is the ordinary failure below.
|
|
4328
|
+
if (!hasExclusion || delegatedCandidates.length === 0) {
|
|
4118
4329
|
throw new common_1.ConflictException(`${label} did not resolve to any member id`);
|
|
4119
4330
|
}
|
|
4120
|
-
|
|
4121
|
-
|
|
4331
|
+
const emptiedByInitiator = isEmptiedByInitiatorRule(node, delegatedCandidates, exclusions);
|
|
4332
|
+
const whenEmpty = emptiedByInitiator
|
|
4333
|
+
? node.data.excludeInitiator?.whenEmpty
|
|
4334
|
+
: node.data.excludeNodeActors?.whenEmpty;
|
|
4335
|
+
if (whenEmpty === 'SKIP_NODE') {
|
|
4336
|
+
return { skippedBy: emptiedByInitiator ? 'INITIATOR' : 'NODE_ACTORS' };
|
|
4122
4337
|
}
|
|
4123
|
-
if (
|
|
4124
|
-
const managerCandidates = await this.resolveInitiatorManagerReplacements(manager, instance, node);
|
|
4338
|
+
if (whenEmpty === 'INITIATOR_MANAGER') {
|
|
4339
|
+
const managerCandidates = await this.resolveInitiatorManagerReplacements(manager, instance, node, exclusions.memberIds);
|
|
4125
4340
|
if (managerCandidates.candidates.length > 0) {
|
|
4126
4341
|
return managerCandidates;
|
|
4127
4342
|
}
|
|
4128
|
-
throw new common_1.ConflictException(
|
|
4129
|
-
? `${label}
|
|
4130
|
-
:
|
|
4343
|
+
throw new common_1.ConflictException(!emptiedByInitiator
|
|
4344
|
+
? `${label} 無法建立待簽任務:解析出的簽核人只剩前面${readNodeActorNodeNames(instance, readExcludedNodeActorNodeIds(node))}的簽核者,此節點不允許與前面關卡的簽核者為同一人,且找不到申請人的主管可改派。`
|
|
4345
|
+
: hasSubmitter
|
|
4346
|
+
? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人,此節點不允許申請人或代送人簽核,且找不到申請人的主管可改派。`
|
|
4347
|
+
: `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人,此節點不允許申請人自簽,且找不到申請人的主管可改派。`);
|
|
4348
|
+
}
|
|
4349
|
+
throw new common_1.ConflictException(!emptiedByInitiator
|
|
4350
|
+
? `${label} 無法建立待簽任務:解析出的簽核人只剩前面${readNodeActorNodeNames(instance, readExcludedNodeActorNodeIds(node))}的簽核者(含委派後的代理人),此節點不允許與前面關卡的簽核者為同一人。`
|
|
4351
|
+
: hasSubmitter
|
|
4352
|
+
? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人(含委派後的代理人),且此節點不允許申請人或代送人簽核。`
|
|
4353
|
+
: `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人(含委派後的代理人),且此節點不允許申請人自簽。`);
|
|
4354
|
+
}
|
|
4355
|
+
/**
|
|
4356
|
+
* Everyone `node` keeps out of its approvers, per rule: the initiator and
|
|
4357
|
+
* the submitter for `excludeInitiator`, and everyone who actually approved
|
|
4358
|
+
* one of the listed earlier stages for `excludeNodeActors`. Read through
|
|
4359
|
+
* `manager` so an approval written earlier in this transaction counts.
|
|
4360
|
+
*/
|
|
4361
|
+
async readNodeExclusions(manager, instance, node) {
|
|
4362
|
+
const initiatorMemberIds = isInitiatorExcluded(node)
|
|
4363
|
+
? readInitiatorAndSubmitter(instance)
|
|
4364
|
+
: [];
|
|
4365
|
+
const nodeActorMemberIds = isNodeActorsExcluded(node)
|
|
4366
|
+
? await this.readExcludedNodeActorMemberIds(manager, instance, readExcludedNodeActorNodeIds(node))
|
|
4367
|
+
: [];
|
|
4368
|
+
return {
|
|
4369
|
+
initiatorMemberIds,
|
|
4370
|
+
memberIds: uniqueTexts([...initiatorMemberIds, ...nodeActorMemberIds]),
|
|
4371
|
+
nodeActorMemberIds,
|
|
4372
|
+
};
|
|
4373
|
+
}
|
|
4374
|
+
/**
|
|
4375
|
+
* The people who approved any of `nodeIds`, each listed node read on its own
|
|
4376
|
+
* with the `NODE_ACTOR` rules ({@link readNodeActorMemberIds}) and the
|
|
4377
|
+
* results combined. A source reads only the most recent of its listed nodes;
|
|
4378
|
+
* an exclusion must cover every listed node, so it asks one node at a time.
|
|
4379
|
+
*
|
|
4380
|
+
* There is no round boundary here: a node's newest batch is what counts. The
|
|
4381
|
+
* publish lint makes that the right batch, since every listed node runs and
|
|
4382
|
+
* is signed by a person on every path to this node (a return to the start
|
|
4383
|
+
* runs it again; a return to a stage below it leaves its earlier approval
|
|
4384
|
+
* standing, and the exclusion with it).
|
|
4385
|
+
*/
|
|
4386
|
+
async readExcludedNodeActorMemberIds(manager, instance, nodeIds) {
|
|
4387
|
+
const groups = await Promise.all(nodeIds.map(async (nodeId) => {
|
|
4388
|
+
const approvals = await this.readNodeActorApprovals(manager, instance, [
|
|
4389
|
+
nodeId,
|
|
4390
|
+
]);
|
|
4391
|
+
const principalMemberIds = await this.readApprovalPrincipalMemberIds(manager, approvals);
|
|
4392
|
+
return [
|
|
4393
|
+
...approvals.map((decision) => decision.decidedByMemberId),
|
|
4394
|
+
...principalMemberIds,
|
|
4395
|
+
];
|
|
4396
|
+
}));
|
|
4397
|
+
return uniqueTexts(groups.flat());
|
|
4398
|
+
}
|
|
4399
|
+
/**
|
|
4400
|
+
* Whether `targetMemberId` may not take a seat on `node` under
|
|
4401
|
+
* `excludeNodeActors`, checked the way task creation checks a candidate: the
|
|
4402
|
+
* target is one of the excluded members, or one end of a delegation between
|
|
4403
|
+
* the target and an excluded member is on the path. A delegation is read in
|
|
4404
|
+
* both directions, since the target may be the delegate of an excluded
|
|
4405
|
+
* member, or delegate an excluded member's seat onward.
|
|
4406
|
+
*/
|
|
4407
|
+
async isExcludedNodeActorTarget(manager, instance, node, targetMemberId) {
|
|
4408
|
+
const excludedMemberIds = await this.readExcludedNodeActorMemberIds(manager, instance, readExcludedNodeActorNodeIds(node));
|
|
4409
|
+
if (excludedMemberIds.includes(targetMemberId)) {
|
|
4410
|
+
return true;
|
|
4411
|
+
}
|
|
4412
|
+
if (excludedMemberIds.length === 0) {
|
|
4413
|
+
return false;
|
|
4131
4414
|
}
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4415
|
+
const context = this.buildDelegationContext(instance, node.id);
|
|
4416
|
+
const gradeGated = isOrgManagerChainNode(node);
|
|
4417
|
+
const ownDelegation = await this.resolveDelegation(manager, targetMemberId, context, gradeGated);
|
|
4418
|
+
if (isExcludedMemberOnCandidatePath({
|
|
4419
|
+
delegationChain: ownDelegation.delegationChain,
|
|
4420
|
+
memberId: ownDelegation.finalAssigneeMemberId,
|
|
4421
|
+
originalMemberId: targetMemberId,
|
|
4422
|
+
}, excludedMemberIds)) {
|
|
4423
|
+
return true;
|
|
4424
|
+
}
|
|
4425
|
+
const excludedSeats = await Promise.all(excludedMemberIds.map(async (memberId) => {
|
|
4426
|
+
const delegation = await this.resolveDelegation(manager, memberId, context, gradeGated);
|
|
4427
|
+
return isExcludedMemberOnCandidatePath({
|
|
4428
|
+
delegationChain: delegation.delegationChain,
|
|
4429
|
+
memberId: delegation.finalAssigneeMemberId,
|
|
4430
|
+
originalMemberId: memberId,
|
|
4431
|
+
}, [targetMemberId]);
|
|
4432
|
+
}));
|
|
4433
|
+
return excludedSeats.some((routesToTarget) => routesToTarget);
|
|
4135
4434
|
}
|
|
4136
|
-
|
|
4435
|
+
/**
|
|
4436
|
+
* The principals the approvals stand for: a delegate who approved counts as
|
|
4437
|
+
* the member who delegated to them, read from the candidate row each approval
|
|
4438
|
+
* was signed on (see {@link readRuleDelegatorMemberIds}).
|
|
4439
|
+
*/
|
|
4440
|
+
async readApprovalPrincipalMemberIds(manager, approvals) {
|
|
4441
|
+
if (approvals.length === 0) {
|
|
4442
|
+
return [];
|
|
4443
|
+
}
|
|
4444
|
+
const candidates = await manager.getRepository(task_candidate_entity_1.TaskCandidateEntity).find({
|
|
4445
|
+
where: {
|
|
4446
|
+
taskId: (0, typeorm_2.In)(uniqueTexts(approvals.map((decision) => decision.taskId))),
|
|
4447
|
+
},
|
|
4448
|
+
});
|
|
4449
|
+
return uniqueTexts(approvals.flatMap((decision) => candidates
|
|
4450
|
+
.filter((candidate) => candidate.taskId === decision.taskId &&
|
|
4451
|
+
candidate.memberId === decision.decidedByMemberId)
|
|
4452
|
+
.flatMap(readRuleDelegatorMemberIds)));
|
|
4453
|
+
}
|
|
4454
|
+
async resolveInitiatorManagerReplacements(manager, instance, node, excludedMemberIds) {
|
|
4455
|
+
// Whoever the node keeps out is also kept out of the replacements: the
|
|
4456
|
+
// initiator and the submitter always (even when only `excludeNodeActors`
|
|
4457
|
+
// asked for this replacement), plus the earlier-stage approvers.
|
|
4458
|
+
const replacementExcludedMemberIds = uniqueTexts([
|
|
4459
|
+
...readInitiatorAndSubmitter(instance),
|
|
4460
|
+
...excludedMemberIds,
|
|
4461
|
+
]);
|
|
4137
4462
|
// The initiator's own direct manager: the closest org unit only. The
|
|
4138
4463
|
// initiator himself (a unit head) and the member who submitted for him are
|
|
4139
4464
|
// skipped before the tier is chosen, so resolution falls through to the
|
|
4140
4465
|
// next tier instead of ending on either of them.
|
|
4141
|
-
const managerMemberIds = await this.resolveDirectManagerMemberIds(manager, instance.initiatorMemberId, this.readOrganizationToday(), true,
|
|
4466
|
+
const managerMemberIds = await this.resolveDirectManagerMemberIds(manager, instance.initiatorMemberId, this.readOrganizationToday(), true, replacementExcludedMemberIds);
|
|
4142
4467
|
const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, managerMemberIds.map((memberId) => ({
|
|
4143
4468
|
memberId,
|
|
4144
4469
|
sourceType: 'EXCLUDE_INITIATOR_MANAGER',
|
|
4145
4470
|
})), isOrgManagerChainNode(node));
|
|
4146
4471
|
return {
|
|
4147
|
-
candidates: selectNodeCandidates(delegatedCandidates, true,
|
|
4148
|
-
skippedDelegations: collectSkippedDelegations(delegatedCandidates, true,
|
|
4472
|
+
candidates: selectNodeCandidates(delegatedCandidates, true, replacementExcludedMemberIds),
|
|
4473
|
+
skippedDelegations: collectSkippedDelegations(delegatedCandidates, true, replacementExcludedMemberIds),
|
|
4149
4474
|
};
|
|
4150
4475
|
}
|
|
4151
4476
|
buildDelegationContext(instance, nodeId) {
|
|
@@ -4259,7 +4584,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4259
4584
|
return this.resolveOrgUnitManagerCandidates(manager, instance, resolver.orgUnitId, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject);
|
|
4260
4585
|
}
|
|
4261
4586
|
if (resolver.type === 'ORG_MANAGER_CHAIN') {
|
|
4262
|
-
return this.resolveOrgManagerChainCandidates(manager, instance, resolver
|
|
4587
|
+
return this.resolveOrgManagerChainCandidates(manager, instance, resolver, label, subject, skippedChainMemberIds);
|
|
4263
4588
|
}
|
|
4264
4589
|
if (resolver.type === 'ORG_MANAGER') {
|
|
4265
4590
|
return this.resolveInitiatorManagerCandidates(manager, instance, resolver.levelsUp, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject);
|
|
@@ -4364,6 +4689,15 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4364
4689
|
* tasks and approvals the SLA timeout made do not count.
|
|
4365
4690
|
*/
|
|
4366
4691
|
async readNodeActorMemberIds(manager, instance, nodeIds) {
|
|
4692
|
+
const approvals = await this.readNodeActorApprovals(manager, instance, nodeIds);
|
|
4693
|
+
return uniqueTexts(approvals.map((decision) => decision.decidedByMemberId));
|
|
4694
|
+
}
|
|
4695
|
+
/**
|
|
4696
|
+
* The real approvals (earliest first) of the most recent batch among
|
|
4697
|
+
* `nodeIds`: the rows {@link readNodeActorMemberIds} reads the approvers from.
|
|
4698
|
+
* Kept as decisions so the exclusion can also reach each decision's task.
|
|
4699
|
+
*/
|
|
4700
|
+
async readNodeActorApprovals(manager, instance, nodeIds) {
|
|
4367
4701
|
const tasks = await manager.getRepository(task_entity_1.TaskEntity).find({
|
|
4368
4702
|
where: { instanceId: instance.id, isAdhoc: false, nodeId: (0, typeorm_2.In)([...nodeIds]) },
|
|
4369
4703
|
});
|
|
@@ -4394,10 +4728,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4394
4728
|
const automaticDecisionIds = new Set(decidedLogs
|
|
4395
4729
|
.filter((log) => log.payload['automatic'] === SLA_AUTO_APPROVE_MARK)
|
|
4396
4730
|
.map((log) => log.payload['decisionId']));
|
|
4397
|
-
return
|
|
4731
|
+
return [...decisions]
|
|
4398
4732
|
.filter((decision) => !automaticDecisionIds.has(decision.id))
|
|
4399
|
-
.sort((first, second) => first.decidedAt.getTime() - second.decidedAt.getTime())
|
|
4400
|
-
.map((decision) => decision.decidedByMemberId));
|
|
4733
|
+
.sort((first, second) => first.decidedAt.getTime() - second.decidedAt.getTime());
|
|
4401
4734
|
}
|
|
4402
4735
|
async resolvePositionCandidates(manager, positionId, label, subject) {
|
|
4403
4736
|
const memberIds = await this.readActivePositionMemberIds(manager, positionId);
|
|
@@ -4436,16 +4769,28 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4436
4769
|
return this.resolveFallbackAssignees(manager, instance, fallback, label, subject, `找不到發起人的第 ${normalizedLevelsUp} 層主管`);
|
|
4437
4770
|
}
|
|
4438
4771
|
/**
|
|
4439
|
-
* Walks up
|
|
4440
|
-
*
|
|
4441
|
-
*
|
|
4442
|
-
*
|
|
4772
|
+
* Walks up a manager chain, nearest manager first, until a manager whose
|
|
4773
|
+
* position level reaches `minPositionLevel` is added. The chain starts at
|
|
4774
|
+
* the initiator unless `startFrom` names someone else, and the start person
|
|
4775
|
+
* is never on it: the walk begins at their direct manager. The list is
|
|
4776
|
+
* returned nearest manager first. Members in `skippedMemberIds` are walked
|
|
4777
|
+
* past without joining the chain or counting as the stopping point, and so
|
|
4778
|
+
* are managers below `startPositionLevel`, until the first one at or above
|
|
4779
|
+
* it starts the chain.
|
|
4443
4780
|
*/
|
|
4444
|
-
async resolveOrgManagerChainCandidates(manager, instance,
|
|
4781
|
+
async resolveOrgManagerChainCandidates(manager, instance, resolver, label, subject, skippedMemberIds) {
|
|
4782
|
+
const { fallback, minPositionLevel } = resolver;
|
|
4783
|
+
const startPositionLevel = (0, workflow_1.readOrgManagerChainStartPositionLevel)(resolver);
|
|
4784
|
+
const start = await this.resolveOrgManagerChainStart(manager, instance, resolver, label, subject);
|
|
4785
|
+
// Nobody to climb from: the same "no manager" path as a chain whose walk
|
|
4786
|
+
// finds nobody, so the fallback decides.
|
|
4787
|
+
if (start.memberId === null) {
|
|
4788
|
+
return this.resolveFallbackAssignees(manager, instance, fallback, label, subject, start.missingReason);
|
|
4789
|
+
}
|
|
4445
4790
|
const date = this.readOrganizationToday();
|
|
4446
|
-
const visitedMemberIds = new Set([
|
|
4791
|
+
const visitedMemberIds = new Set([start.memberId]);
|
|
4447
4792
|
const chain = [];
|
|
4448
|
-
let currentMemberId =
|
|
4793
|
+
let currentMemberId = start.memberId;
|
|
4449
4794
|
let lastAddedLevel = null;
|
|
4450
4795
|
for (let hop = 0; hop < ORG_MANAGER_CHAIN_MAX_HOPS; hop += 1) {
|
|
4451
4796
|
// A chain is a single path, so it always uses closest-org-unit semantics
|
|
@@ -4478,6 +4823,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4478
4823
|
(lastAddedLevel !== null && level <= lastAddedLevel)) {
|
|
4479
4824
|
continue;
|
|
4480
4825
|
}
|
|
4826
|
+
// Below the start level a manager is walked past like one without a
|
|
4827
|
+
// position: he neither joins the chain nor raises the bar the next
|
|
4828
|
+
// manager has to clear. Every manager added after the first is above
|
|
4829
|
+
// the last one added, so this only ever holds before the chain starts.
|
|
4830
|
+
if (startPositionLevel !== null && level < startPositionLevel) {
|
|
4831
|
+
continue;
|
|
4832
|
+
}
|
|
4481
4833
|
chain.push(managerMemberId);
|
|
4482
4834
|
lastAddedLevel = level;
|
|
4483
4835
|
if (level >= minPositionLevel) {
|
|
@@ -4486,9 +4838,68 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4486
4838
|
}
|
|
4487
4839
|
// A position fallback can name several holders. They all join the last
|
|
4488
4840
|
// tier, so they all have to sign, the same as every other chain member.
|
|
4489
|
-
const fallbackMemberIds = await this.resolveFallbackAssignees(manager, instance, fallback, label, subject,
|
|
4841
|
+
const fallbackMemberIds = await this.resolveFallbackAssignees(manager, instance, fallback, label, subject, chain.length === 0 && startPositionLevel !== null
|
|
4842
|
+
? `${start.description}沿主管鏈找不到職等達 ${startPositionLevel} 的主管(起始職等 ${startPositionLevel}、簽到職等 ${minPositionLevel})`
|
|
4843
|
+
: `${start.description}沿主管鏈找不到職等達 ${minPositionLevel} 的主管`);
|
|
4490
4844
|
return uniqueTexts([...chain, ...fallbackMemberIds]);
|
|
4491
4845
|
}
|
|
4846
|
+
/**
|
|
4847
|
+
* Whose manager an `ORG_MANAGER_CHAIN` starts from. `memberId` is `null`
|
|
4848
|
+
* when the start names nobody (an empty form field, an earlier stage nobody
|
|
4849
|
+
* approved), with `missingReason` saying why; `description` is the lead-in
|
|
4850
|
+
* of the "no manager reaches the level" reason, empty for the initiator so
|
|
4851
|
+
* a chain without a start keeps today's wording.
|
|
4852
|
+
*
|
|
4853
|
+
* A start that picks more than one person is refused rather than guessed,
|
|
4854
|
+
* the same as a manager rule that names several managers at one priority.
|
|
4855
|
+
*/
|
|
4856
|
+
async resolveOrgManagerChainStart(manager, instance, resolver, label, subject) {
|
|
4857
|
+
if ((0, workflow_1.isOrgManagerChainStartedFromInitiator)(resolver)) {
|
|
4858
|
+
return {
|
|
4859
|
+
description: '',
|
|
4860
|
+
memberId: instance.initiatorMemberId,
|
|
4861
|
+
missingReason: '',
|
|
4862
|
+
};
|
|
4863
|
+
}
|
|
4864
|
+
// `INITIATOR` (and an absent start) was answered above.
|
|
4865
|
+
const startFrom = resolver.startFrom;
|
|
4866
|
+
const unsupportedType = (0, workflow_1.readUnsupportedOrgManagerChainStartType)(startFrom);
|
|
4867
|
+
const failure = subject === '通知對象' ? '無法建立通知' : '無法建立待簽任務';
|
|
4868
|
+
// A template saved by a newer engine can carry a start this engine does
|
|
4869
|
+
// not know. Refuse it by name instead of climbing from the initiator,
|
|
4870
|
+
// which would route the task to the wrong people.
|
|
4871
|
+
if (unsupportedType !== null) {
|
|
4872
|
+
throw new common_1.ConflictException(`${label} ${failure}:此引擎版本不支援主管鏈起點「${unsupportedType}」。請升級 BPMCore 後再發佈或執行此流程。`);
|
|
4873
|
+
}
|
|
4874
|
+
// A notify node has no earlier stage and no form pick the publish lint
|
|
4875
|
+
// would have checked; one that reaches here anyway is refused.
|
|
4876
|
+
if (subject === '通知對象') {
|
|
4877
|
+
throw new common_1.ConflictException(`${label} ${failure}:知會節點的主管鏈只能從發起人開始。`);
|
|
4878
|
+
}
|
|
4879
|
+
if (startFrom.type === 'FORM_MEMBER') {
|
|
4880
|
+
const source = `依表單欄位「${startFrom.formPath}」`;
|
|
4881
|
+
const pickedMemberIds = findMemberIdsInValue(readValueAtPath(instance.formData, startFrom.formPath));
|
|
4882
|
+
// Picking one silently would route the document by accident.
|
|
4883
|
+
if (pickedMemberIds.length > 1) {
|
|
4884
|
+
throw new common_1.ConflictException(`${label} ${source}只能選一位人員`);
|
|
4885
|
+
}
|
|
4886
|
+
return {
|
|
4887
|
+
description: `從表單欄位「${startFrom.formPath}」所選人員起算,`,
|
|
4888
|
+
memberId: pickedMemberIds[0] ?? null,
|
|
4889
|
+
missingReason: `表單欄位「${startFrom.formPath}」沒有選人員`,
|
|
4890
|
+
};
|
|
4891
|
+
}
|
|
4892
|
+
const nodeNames = readNodeActorNodeNames(instance, startFrom.nodeIds);
|
|
4893
|
+
const actorMemberIds = await this.readNodeActorMemberIds(manager, instance, startFrom.nodeIds);
|
|
4894
|
+
if (actorMemberIds.length > 1) {
|
|
4895
|
+
throw new common_1.ConflictException(`${label} 無法沿主管鏈簽核:${nodeNames}有多位實際簽核人(${actorMemberIds.join('、')}),無法決定從誰的主管開始。請改選只有一位簽核人的關卡。`);
|
|
4896
|
+
}
|
|
4897
|
+
return {
|
|
4898
|
+
description: `從${nodeNames}的簽核者起算,`,
|
|
4899
|
+
memberId: actorMemberIds[0] ?? null,
|
|
4900
|
+
missingReason: `${nodeNames}沒有實際同意的簽核紀錄`,
|
|
4901
|
+
};
|
|
4902
|
+
}
|
|
4492
4903
|
/** Highest position level over the member's active memberships, or `null`. */
|
|
4493
4904
|
async readMemberPositionLevel(manager, memberId, date) {
|
|
4494
4905
|
return ((await this.readMemberPositionLevels(manager, [memberId], date)).get(memberId) ?? null);
|
|
@@ -4799,7 +5210,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4799
5210
|
}
|
|
4800
5211
|
if (action.type === 'SET_FORM_FIELD') {
|
|
4801
5212
|
const value = this.conditionService.evaluateValue(action.value, buildWorkflowExpressionContext(instance), `workflow.nodes.${node.id}.data.action.value`, { dialect: (0, expression_dialect_1.readWorkflowExpressionDialect)(instance.workflowSnapshot) });
|
|
4802
|
-
|
|
5213
|
+
assertServiceTaskFieldValue(instance.formDefinitionSnapshot, action.fieldPath, value, node.data.label);
|
|
4803
5214
|
const updatedFormData = writeValueAtPath(instance.formData, action.fieldPath, value);
|
|
4804
5215
|
await this.assertCurrentFilterableFormDataValues(instance.templateId, updatedFormData, manager, node.data.label);
|
|
4805
5216
|
// A service task runs inside this transaction, so it cannot call a host
|
|
@@ -5180,6 +5591,8 @@ exports.WorkflowEngineService = WorkflowEngineService = WorkflowEngineService_1
|
|
|
5180
5591
|
tslib_1.__param(19, (0, common_1.Optional)()),
|
|
5181
5592
|
tslib_1.__param(19, (0, common_1.Inject)(bpm_root_options_1.BPM_ROOT_OPTIONS)),
|
|
5182
5593
|
tslib_1.__param(20, (0, common_1.Optional)()),
|
|
5594
|
+
tslib_1.__param(21, (0, common_1.Optional)()),
|
|
5595
|
+
tslib_1.__param(21, (0, common_1.Inject)(initiator_custom_fields_resolver_token_1.BPM_INITIATOR_CUSTOM_FIELDS_RESOLVER)),
|
|
5183
5596
|
tslib_1.__metadata("design:paramtypes", [typeorm_2.Repository,
|
|
5184
5597
|
typeorm_2.Repository,
|
|
5185
5598
|
typeorm_2.Repository,
|
|
@@ -5196,7 +5609,7 @@ exports.WorkflowEngineService = WorkflowEngineService = WorkflowEngineService_1
|
|
|
5196
5609
|
delegation_service_1.DelegationService,
|
|
5197
5610
|
notification_service_1.NotificationService,
|
|
5198
5611
|
signature_service_1.SignatureService,
|
|
5199
|
-
sla_schedule_service_1.BPMSlaScheduleService, Object, Object, Object, workflow_webhook_delivery_service_1.WorkflowWebhookDeliveryService])
|
|
5612
|
+
sla_schedule_service_1.BPMSlaScheduleService, Object, Object, Object, workflow_webhook_delivery_service_1.WorkflowWebhookDeliveryService, Object])
|
|
5200
5613
|
], WorkflowEngineService);
|
|
5201
5614
|
function parseJsonObject(value, label) {
|
|
5202
5615
|
try {
|
|
@@ -5219,6 +5632,74 @@ function readDefaultInitiatorMetadataSnapshot(memberId) {
|
|
|
5219
5632
|
memberId,
|
|
5220
5633
|
};
|
|
5221
5634
|
}
|
|
5635
|
+
function cloneJsonObject(value) {
|
|
5636
|
+
return JSON.parse(JSON.stringify(value));
|
|
5637
|
+
}
|
|
5638
|
+
/**
|
|
5639
|
+
* What a host's initiator `customFields` resolver returned, as the object the
|
|
5640
|
+
* snapshot keeps: a plain object that survives JSON, which is also how it is
|
|
5641
|
+
* stored, so the policy evaluated at submit and the conditions evaluated later
|
|
5642
|
+
* read the same values. Anything else is a bug in the host's resolver; the
|
|
5643
|
+
* caller fails the submission with it rather than replacing it by `{}`, and
|
|
5644
|
+
* `problem` is for the log only.
|
|
5645
|
+
*
|
|
5646
|
+
* The check runs on the round-tripped value too, because an object's `toJSON`
|
|
5647
|
+
* can turn it into something that is not an object. A key whose value is
|
|
5648
|
+
* `undefined` does not survive the round trip either, so it is not in the
|
|
5649
|
+
* stored snapshot.
|
|
5650
|
+
*/
|
|
5651
|
+
function readResolvedInitiatorCustomFields(value) {
|
|
5652
|
+
if (!isPlainObjectValue(value)) {
|
|
5653
|
+
return { ok: false, problem: 'not a plain object' };
|
|
5654
|
+
}
|
|
5655
|
+
const roundTripped = (() => {
|
|
5656
|
+
try {
|
|
5657
|
+
return JSON.parse(JSON.stringify(value));
|
|
5658
|
+
}
|
|
5659
|
+
catch {
|
|
5660
|
+
return undefined;
|
|
5661
|
+
}
|
|
5662
|
+
})();
|
|
5663
|
+
if (roundTripped === undefined) {
|
|
5664
|
+
return { ok: false, problem: 'not JSON-serializable' };
|
|
5665
|
+
}
|
|
5666
|
+
if (!isPlainObjectValue(roundTripped)) {
|
|
5667
|
+
return { ok: false, problem: 'not a plain object once serialized' };
|
|
5668
|
+
}
|
|
5669
|
+
return { ok: true, value: roundTripped };
|
|
5670
|
+
}
|
|
5671
|
+
/**
|
|
5672
|
+
* Waits for `promise` at most `timeoutMs`. The timer is cleared as soon as
|
|
5673
|
+
* either side wins. Once the time has run out, whatever `promise` settles with
|
|
5674
|
+
* later goes to `onLateSettle` and nowhere else, so a late rejection is
|
|
5675
|
+
* handled rather than left to surface as an unhandled rejection.
|
|
5676
|
+
*/
|
|
5677
|
+
function settleWithin(promise, timeoutMs, onLateSettle) {
|
|
5678
|
+
return new Promise((resolve) => {
|
|
5679
|
+
const state = { timedOut: false };
|
|
5680
|
+
const timer = setTimeout(() => {
|
|
5681
|
+
state.timedOut = true;
|
|
5682
|
+
resolve({ status: 'timeout' });
|
|
5683
|
+
}, timeoutMs);
|
|
5684
|
+
const settle = (outcome) => {
|
|
5685
|
+
if (state.timedOut) {
|
|
5686
|
+
onLateSettle(outcome);
|
|
5687
|
+
return;
|
|
5688
|
+
}
|
|
5689
|
+
clearTimeout(timer);
|
|
5690
|
+
resolve(outcome);
|
|
5691
|
+
};
|
|
5692
|
+
promise.then((value) => settle({ status: 'fulfilled', value }), (reason) => settle({ status: 'rejected', reason }));
|
|
5693
|
+
});
|
|
5694
|
+
}
|
|
5695
|
+
function describeRejection(reason) {
|
|
5696
|
+
return reason instanceof Error
|
|
5697
|
+
? `${reason.name}: ${reason.message}`
|
|
5698
|
+
: String(reason);
|
|
5699
|
+
}
|
|
5700
|
+
function isPlainObjectValue(value) {
|
|
5701
|
+
return Object.prototype.toString.call(value) === '[object Object]';
|
|
5702
|
+
}
|
|
5222
5703
|
function createWorkflowAuthContext(memberId) {
|
|
5223
5704
|
return {
|
|
5224
5705
|
memberId,
|
|
@@ -5307,32 +5788,41 @@ function readFilterableProblemLocation(schema, problem) {
|
|
|
5307
5788
|
return { path, ref: `欄位「${path}」` };
|
|
5308
5789
|
}
|
|
5309
5790
|
/**
|
|
5310
|
-
* Holds a `SET_FORM_FIELD` write to the
|
|
5311
|
-
*
|
|
5312
|
-
*
|
|
5791
|
+
* Holds a `SET_FORM_FIELD` write to the rules the form applies to a filler —
|
|
5792
|
+
* text length and format, and the shape of a member or organization-unit value
|
|
5793
|
+
* — so a service task cannot store what the field would have refused. A member
|
|
5794
|
+
* value matters past the write: a `DYNAMIC_FORM` resolver or a manager chain
|
|
5795
|
+
* starting from the field reads it later, and an array on a single-member
|
|
5796
|
+
* field would make that node refuse inside a later decision, which then
|
|
5797
|
+
* cannot commit. Breaking them throws, which rolls back the submit or decision
|
|
5313
5798
|
* that reached the node: the value is never half-written, and the node's value
|
|
5314
5799
|
* has to be corrected. Only a top-level field can carry those rules — the
|
|
5315
5800
|
* writer ignores an indexed path, and a nested record path names no field —
|
|
5316
5801
|
* and an unreadable snapshot is left alone, as in
|
|
5317
5802
|
* `readDataSourceFieldKeyAtPath`.
|
|
5318
5803
|
*/
|
|
5319
|
-
function
|
|
5804
|
+
function assertServiceTaskFieldValue(snapshot, fieldPath, value, nodeLabel) {
|
|
5320
5805
|
const schema = snapshot?.schema;
|
|
5321
5806
|
const segments = normalizeFormFieldPath(fieldPath);
|
|
5322
5807
|
if (!isFormDefinitionSchema(schema) || segments.length !== 1) {
|
|
5323
5808
|
return;
|
|
5324
5809
|
}
|
|
5325
5810
|
const field = schema.fields.find((candidate) => candidate.fieldKey === segments[0]);
|
|
5811
|
+
const written = { [segments[0]]: value };
|
|
5326
5812
|
const errors = field
|
|
5327
|
-
?
|
|
5328
|
-
|
|
5329
|
-
|
|
5813
|
+
? [
|
|
5814
|
+
...readSubmittedTextValueErrors(field, value, {
|
|
5815
|
+
testPattern: (0, text_field_pattern_test_1.createTimedTextFieldPatternTest)(),
|
|
5816
|
+
}),
|
|
5817
|
+
...(0, form_member_value_validator_1.readSubmittedMemberValueErrors)([field], written),
|
|
5818
|
+
...(0, form_org_unit_value_validator_1.readSubmittedOrgUnitValueErrors)([field], written),
|
|
5819
|
+
]
|
|
5330
5820
|
: [];
|
|
5331
5821
|
if (!field || errors.length === 0) {
|
|
5332
5822
|
return;
|
|
5333
5823
|
}
|
|
5334
|
-
throw
|
|
5335
|
-
|
|
5824
|
+
throw (0, form_data_issue_1.createFormDataBadRequest)(`系統節點「${nodeLabel}」要寫入的值不符合欄位規則(${(0, form_data_issue_1.readFormDataIssueMessage)(errors)}),` +
|
|
5825
|
+
`這次操作沒有生效;請聯絡流程管理者修正此節點的寫入值`, errors);
|
|
5336
5826
|
}
|
|
5337
5827
|
function readSnapshotWithoutField(snapshots, fieldKey) {
|
|
5338
5828
|
return Object.fromEntries(Object.entries(snapshots ?? {}).filter(([key]) => key !== fieldKey));
|
|
@@ -5758,6 +6248,24 @@ function isOrderedSequentialTask(task, candidates) {
|
|
|
5758
6248
|
function isInitiatorExcluded(node) {
|
|
5759
6249
|
return Boolean(node.data.excludeInitiator);
|
|
5760
6250
|
}
|
|
6251
|
+
// Same rule for the earlier-stage exclusion: an explicit `null` is absent.
|
|
6252
|
+
function isNodeActorsExcluded(node) {
|
|
6253
|
+
return Boolean(node.data.excludeNodeActors);
|
|
6254
|
+
}
|
|
6255
|
+
// Whether the node keeps anyone out of its approvers at all.
|
|
6256
|
+
function hasSeatExclusion(node) {
|
|
6257
|
+
return isInitiatorExcluded(node) || isNodeActorsExcluded(node);
|
|
6258
|
+
}
|
|
6259
|
+
function readExcludedNodeActorNodeIds(node) {
|
|
6260
|
+
return node.data.excludeNodeActors?.nodeIds ?? [];
|
|
6261
|
+
}
|
|
6262
|
+
// With both rules set the initiator rule runs first, so the earlier-stage rule
|
|
6263
|
+
// is the one that emptied the list only when the initiator rule alone left
|
|
6264
|
+
// somebody. Without `excludeInitiator` the earlier-stage rule is always it.
|
|
6265
|
+
function isEmptiedByInitiatorRule(node, delegatedCandidates, exclusions) {
|
|
6266
|
+
return (isInitiatorExcluded(node) &&
|
|
6267
|
+
selectNodeCandidates(delegatedCandidates, true, exclusions.initiatorMemberIds).length === 0);
|
|
6268
|
+
}
|
|
5761
6269
|
// Everyone a node that excludes the initiator keeps out of its approvers: the
|
|
5762
6270
|
// applicant and, when someone filed the document for them, that person too
|
|
5763
6271
|
// (authority rule: the creator must not approve what they created).
|
|
@@ -5799,6 +6307,27 @@ function collectSkippedDelegations(delegatedCandidates, excludeInitiator, exclud
|
|
|
5799
6307
|
return [skipped];
|
|
5800
6308
|
});
|
|
5801
6309
|
}
|
|
6310
|
+
// The members a rule delegation stands for on a candidate row a person
|
|
6311
|
+
// approved: the delegators of the trailing run of rule steps (`ruleId` set).
|
|
6312
|
+
// A transfer step carries no rule and is not a delegation, so when one sits on
|
|
6313
|
+
// the chain the original assignee is not a delegator and is left out. A chain
|
|
6314
|
+
// of rule steps only also names the original assignee.
|
|
6315
|
+
function readRuleDelegatorMemberIds(candidate) {
|
|
6316
|
+
const steps = readDelegationSteps(candidate.delegationChain);
|
|
6317
|
+
const trailingRuleCount = [...steps]
|
|
6318
|
+
.reverse()
|
|
6319
|
+
.findIndex((step) => step.ruleId === null);
|
|
6320
|
+
const ruleSteps = trailingRuleCount === -1
|
|
6321
|
+
? steps
|
|
6322
|
+
: steps.slice(steps.length - trailingRuleCount);
|
|
6323
|
+
if (ruleSteps.length === 0) {
|
|
6324
|
+
return [];
|
|
6325
|
+
}
|
|
6326
|
+
return [
|
|
6327
|
+
...(trailingRuleCount === -1 ? [candidate.originalMemberId] : []),
|
|
6328
|
+
...ruleSteps.map((step) => step.from),
|
|
6329
|
+
];
|
|
6330
|
+
}
|
|
5802
6331
|
// A delegate acting for the initiator is still the initiator approving their
|
|
5803
6332
|
// own document, so the initiator anywhere on the delegation path (the
|
|
5804
6333
|
// pre-delegation member, any hop, or the final assignee) drops the candidate.
|
|
@@ -6374,8 +6903,43 @@ function validateSubmittedFormData(schema, formData, options = {}) {
|
|
|
6374
6903
|
.filter((field) => field.hidden === true)
|
|
6375
6904
|
.flatMap((field) => validateSubmittedTableValue(field, formData[field.fieldKey], false, testPattern, true)),
|
|
6376
6905
|
],
|
|
6906
|
+
// Every `file_upload` field, shown or not: the submit binds the files of
|
|
6907
|
+
// each one to the case, so a cap only the visible ones obey could be
|
|
6908
|
+
// stepped around by sending files in a hidden field.
|
|
6909
|
+
readFileUploadErrors: () => readSubmittedFileUploadErrors(schema.fields, formData),
|
|
6377
6910
|
});
|
|
6378
6911
|
}
|
|
6912
|
+
/**
|
|
6913
|
+
* A `file_upload` field holding more files than its `maxFiles`. The renderer
|
|
6914
|
+
* stops at the cap, but nothing kept a caller that writes the form data itself
|
|
6915
|
+
* from going past it. On a resubmit the value lists the files kept from the
|
|
6916
|
+
* returned case and the ones uploaded since, so both count, as they do in the
|
|
6917
|
+
* renderer. Each distinct non-empty id in the value is one file: the binding
|
|
6918
|
+
* attaches an id once however often it repeats, so a repeat is not a second
|
|
6919
|
+
* file. A field without `maxFiles` takes any number, as it does in the
|
|
6920
|
+
* renderer.
|
|
6921
|
+
*/
|
|
6922
|
+
function readSubmittedFileUploadErrors(fields, formData) {
|
|
6923
|
+
return fields.flatMap((field) => {
|
|
6924
|
+
if (field.type !== 'file_upload' || typeof field.maxFiles !== 'number') {
|
|
6925
|
+
return [];
|
|
6926
|
+
}
|
|
6927
|
+
const fileCount = countFileUploadValueIds(readRowCellValue(formData, field.fieldKey));
|
|
6928
|
+
return fileCount > field.maxFiles
|
|
6929
|
+
? [
|
|
6930
|
+
(0, form_data_issue_1.createFormDataIssue)((0, form_data_issue_1.readFieldIssueLocation)(field), 'MAX_FILES', `最多 ${field.maxFiles} 個檔案`, field.maxFiles),
|
|
6931
|
+
]
|
|
6932
|
+
: [];
|
|
6933
|
+
});
|
|
6934
|
+
}
|
|
6935
|
+
/**
|
|
6936
|
+
* The distinct files a `file_upload` value names. The ids are read by the same
|
|
6937
|
+
* helper the attachment binding uses, and collapsed the way the binding
|
|
6938
|
+
* collapses them (one attachment per id).
|
|
6939
|
+
*/
|
|
6940
|
+
function countFileUploadValueIds(value) {
|
|
6941
|
+
return new Set((0, file_upload_value_ids_1.readFileUploadValueIds)(value)).size;
|
|
6942
|
+
}
|
|
6379
6943
|
/**
|
|
6380
6944
|
* What an approval at a node has to leave behind: every field the node lets the
|
|
6381
6945
|
* approver fill in is well-formed (a table is an array of well-formed rows with
|
|
@@ -6406,14 +6970,15 @@ function validateFilledFormFields(schema, formData, fieldKeys, requiredFieldKeys
|
|
|
6406
6970
|
* The shared tail of submit and approver-fill validation: a missing required
|
|
6407
6971
|
* field wins over a text value breaking its length or format rule, which wins
|
|
6408
6972
|
* over a malformed member value, which wins over a malformed org unit value,
|
|
6409
|
-
* which wins over a table problem,
|
|
6973
|
+
* which wins over a table problem, which wins over a `file_upload` field
|
|
6974
|
+
* holding more files than it allows, and the later checks are only evaluated
|
|
6410
6975
|
* when the earlier ones pass. The refusal carries every issue of the winning
|
|
6411
6976
|
* check. Its message is those issues' sentences joined, except for missing
|
|
6412
6977
|
* required fields, which keep the one-line summary `缺少必填欄位:A, B` that
|
|
6413
6978
|
* submitters read; each issue there still says `欄位「A」為必填`. A client
|
|
6414
6979
|
* that places errors on fields reads the issues, not the message.
|
|
6415
6980
|
*/
|
|
6416
|
-
function throwFirstFormDataFieldError({ missingFields, readMemberErrors, readOrgUnitErrors, readTableErrors, readTextErrors, }) {
|
|
6981
|
+
function throwFirstFormDataFieldError({ missingFields, readFileUploadErrors = () => [], readMemberErrors, readOrgUnitErrors, readTableErrors, readTextErrors, }) {
|
|
6417
6982
|
if (missingFields.length > 0) {
|
|
6418
6983
|
throw (0, form_data_issue_1.createFormDataBadRequest)(`缺少必填欄位:${missingFields
|
|
6419
6984
|
.map((field) => field.label || field.fieldKey)
|
|
@@ -6426,6 +6991,7 @@ function throwFirstFormDataFieldError({ missingFields, readMemberErrors, readOrg
|
|
|
6426
6991
|
readMemberErrors,
|
|
6427
6992
|
readOrgUnitErrors,
|
|
6428
6993
|
readTableErrors,
|
|
6994
|
+
readFileUploadErrors,
|
|
6429
6995
|
].reduce((found, check) => (found.length > 0 ? found : check()), []);
|
|
6430
6996
|
if (winning.length > 0) {
|
|
6431
6997
|
throw (0, form_data_issue_1.createFormDataBadRequest)((0, form_data_issue_1.readFormDataIssueMessage)(winning), winning);
|
|
@@ -6806,7 +7372,7 @@ function readDryRunPlaceholderAssignee(resolver) {
|
|
|
6806
7372
|
return `nodeActorManager:${resolver.nodeIds.join(',')}:${resolver.levelsUp}`;
|
|
6807
7373
|
}
|
|
6808
7374
|
return resolver.type === 'ORG_MANAGER_CHAIN'
|
|
6809
|
-
?
|
|
7375
|
+
? (0, workflow_1.formatOrgManagerChainPlaceholder)(resolver)
|
|
6810
7376
|
: resolver.type;
|
|
6811
7377
|
}
|
|
6812
7378
|
function readDryRunUnresolvedApproverMessage(resolver, nodeName, initiatorMemberId) {
|