@rytass/bpm-core-nestjs-module 0.20.1 → 0.21.1
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 +81 -0
- package/README.md +109 -5
- 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/form-data-issue.d.ts +34 -0
- package/src/lib/form/form-data-issue.js +59 -0
- package/src/lib/form/form-data-issue.js.map +1 -0
- package/src/lib/form/form-member-value.validator.d.ts +2 -2
- package/src/lib/form/form-member-value.validator.js +2 -3
- package/src/lib/form/form-member-value.validator.js.map +1 -1
- package/src/lib/form/form-org-unit-value.validator.d.ts +2 -2
- package/src/lib/form/form-org-unit-value.validator.js +4 -1
- package/src/lib/form/form-org-unit-value.validator.js.map +1 -1
- package/src/lib/form/form-schema.validator.js +40 -0
- package/src/lib/form/form-schema.validator.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/migrations/0000000031000-approval-instance-form-data-gin.d.ts +29 -0
- package/src/lib/migrations/0000000031000-approval-instance-form-data-gin.js +44 -0
- package/src/lib/migrations/0000000031000-approval-instance-form-data-gin.js.map +1 -0
- package/src/lib/migrations/index.d.ts +1 -0
- package/src/lib/migrations/index.js +3 -0
- package/src/lib/migrations/index.js.map +1 -1
- package/src/lib/notification/notification.service.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/filterable-field-guard.d.ts +49 -0
- package/src/lib/template/filterable-field-guard.js +141 -0
- package/src/lib/template/filterable-field-guard.js.map +1 -0
- package/src/lib/template/filterable-field-issue.object.d.ts +21 -0
- package/src/lib/template/filterable-field-issue.object.js +65 -0
- package/src/lib/template/filterable-field-issue.object.js.map +1 -0
- package/src/lib/template/filterable-field.queries.d.ts +20 -0
- package/src/lib/template/filterable-field.queries.js +80 -0
- package/src/lib/template/filterable-field.queries.js.map +1 -0
- package/src/lib/template/index.d.ts +1 -0
- package/src/lib/template/index.js +1 -0
- package/src/lib/template/index.js.map +1 -1
- package/src/lib/template/template.module.js +2 -0
- package/src/lib/template/template.module.js.map +1 -1
- package/src/lib/template/template.service.d.ts +39 -0
- package/src/lib/template/template.service.js +218 -49
- 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/approval-instance-field-filter.compiler.d.ts +49 -0
- package/src/lib/workflow-engine/approval-instance-field-filter.compiler.js +501 -0
- package/src/lib/workflow-engine/approval-instance-field-filter.compiler.js.map +1 -0
- package/src/lib/workflow-engine/approval-instance-filterable-field.policy.d.ts +15 -0
- package/src/lib/workflow-engine/approval-instance-filterable-field.policy.js +20 -0
- package/src/lib/workflow-engine/approval-instance-filterable-field.policy.js.map +1 -0
- package/src/lib/workflow-engine/dto/approval-instance-field-filter.input.d.ts +15 -0
- package/src/lib/workflow-engine/dto/approval-instance-field-filter.input.js +55 -0
- package/src/lib/workflow-engine/dto/approval-instance-field-filter.input.js.map +1 -0
- package/src/lib/workflow-engine/dto/approval-instance-list-args.d.ts +9 -0
- package/src/lib/workflow-engine/dto/approval-instance-list-args.js +24 -0
- package/src/lib/workflow-engine/dto/approval-instance-list-args.js.map +1 -0
- package/src/lib/workflow-engine/dto/approval-instance-sort.input.d.ts +10 -0
- package/src/lib/workflow-engine/dto/approval-instance-sort.input.js +32 -0
- package/src/lib/workflow-engine/dto/approval-instance-sort.input.js.map +1 -0
- 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 +13 -0
- package/src/lib/workflow-engine/workflow-engine.enums.js +22 -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 +9 -3
- package/src/lib/workflow-engine/workflow-engine.module.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.queries.d.ts +6 -4
- package/src/lib/workflow-engine/workflow-engine.queries.js +35 -12
- package/src/lib/workflow-engine/workflow-engine.queries.js.map +1 -1
- package/src/lib/workflow-engine/workflow-engine.service.d.ts +166 -18
- package/src/lib/workflow-engine/workflow-engine.service.js +918 -139
- package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
|
@@ -13,11 +13,13 @@ 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");
|
|
19
20
|
const form_data_source_errors_1 = require("../form-data-source/form-data-source.errors");
|
|
20
21
|
const form_member_value_validator_1 = require("../form/form-member-value.validator");
|
|
22
|
+
const form_data_issue_1 = require("../form/form-data-issue");
|
|
21
23
|
const form_org_unit_value_validator_1 = require("../form/form-org-unit-value.validator");
|
|
22
24
|
const form_data_source_types_1 = require("../form-data-source/form-data-source.types");
|
|
23
25
|
const sla_schedule_service_1 = require("../calendar/sla-schedule.service");
|
|
@@ -39,6 +41,8 @@ const activity_log_entity_1 = require("./activity-log.entity");
|
|
|
39
41
|
const adhoc_directive_entity_1 = require("./adhoc-directive.entity");
|
|
40
42
|
const adhoc_enums_1 = require("./adhoc.enums");
|
|
41
43
|
const approval_instance_entity_1 = require("./approval-instance.entity");
|
|
44
|
+
const approval_instance_field_filter_compiler_1 = require("./approval-instance-field-filter.compiler");
|
|
45
|
+
const approval_instance_filterable_field_policy_1 = require("./approval-instance-filterable-field.policy");
|
|
42
46
|
const text_field_pattern_test_1 = require("./text-field-pattern-test");
|
|
43
47
|
const form_data_fingerprint_1 = require("./form-data-fingerprint");
|
|
44
48
|
const instance_submitter_1 = require("./instance-submitter");
|
|
@@ -53,6 +57,7 @@ const time_budget_1 = require("./time-budget");
|
|
|
53
57
|
const expression_budget_1 = require("../condition/expression-budget");
|
|
54
58
|
const expression_errors_1 = require("../condition/expression-errors");
|
|
55
59
|
const expression_dialect_1 = require("../condition/expression-dialect");
|
|
60
|
+
const initiator_custom_fields_resolver_token_1 = require("./initiator-custom-fields-resolver.token");
|
|
56
61
|
const workflow_service_task_dispatcher_token_1 = require("./workflow-service-task-dispatcher.token");
|
|
57
62
|
const workflow_token_entity_1 = require("./workflow-token.entity");
|
|
58
63
|
const bpm_root_options_1 = require("../bpm/bpm-root-options");
|
|
@@ -67,6 +72,15 @@ const APPROVAL_INSTANCE_BUSINESS_KEY_INDEX = 'approval_instances_business_key_ke
|
|
|
67
72
|
const ORG_MANAGER_CHAIN_MAX_HOPS = 20;
|
|
68
73
|
const DEFAULT_APPROVAL_HISTORY_TASK_LIMIT = 50;
|
|
69
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 = '無法取得發起人的自訂欄位(回傳格式不正確),這次申請沒有送出;請聯絡系統管理者';
|
|
70
84
|
// The dry run checks each expression against the roots its use point may read,
|
|
71
85
|
// as publish does. Keep these in step with `WORKFLOW_EXPRESSION_ROOT_IDENTIFIERS`
|
|
72
86
|
// in template.service.ts; P2 moves both to one place.
|
|
@@ -119,6 +133,7 @@ const TASK_REASSIGNMENT_MESSAGES = {
|
|
|
119
133
|
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_ALREADY_DECIDED]: '接手人已對此任務簽過核',
|
|
120
134
|
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_ALREADY_ON_TASK]: '接手人已在此任務的待簽名單上',
|
|
121
135
|
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_GRADE_TOO_LOW]: '接手人職等不足,不能取代此簽核位置',
|
|
136
|
+
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_EXCLUDED_NODE_ACTOR]: '此節點不允許與前面關卡的簽核者為同一人,接手人不能是前面關卡的簽核者',
|
|
122
137
|
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_INITIATOR]: '此節點不允許申請人自簽,接手人不能是申請人',
|
|
123
138
|
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_SUBMITTER]: '此節點不允許代送人簽核,接手人不能是代為送出此單的人',
|
|
124
139
|
[workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_SAME_AS_SOURCE]: '接手人與原簽核人相同',
|
|
@@ -174,7 +189,7 @@ const WORKFLOW_READ_ALL_PERMISSIONS = new Set([
|
|
|
174
189
|
'workflow.read_all',
|
|
175
190
|
]);
|
|
176
191
|
let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineService {
|
|
177
|
-
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) {
|
|
178
193
|
this.approvalInstanceRepository = approvalInstanceRepository;
|
|
179
194
|
this.workflowTokenRepository = workflowTokenRepository;
|
|
180
195
|
this.taskRepository = taskRepository;
|
|
@@ -195,6 +210,8 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
195
210
|
this.formDataSourceValueResolver = formDataSourceValueResolver;
|
|
196
211
|
this.webhookDeliveryService = webhookDeliveryService;
|
|
197
212
|
this.logger = new common_1.Logger(WorkflowEngineService_1.name);
|
|
213
|
+
this.approvalInstanceFieldFilterCompiler = new approval_instance_field_filter_compiler_1.ApprovalInstanceFieldFilterCompiler();
|
|
214
|
+
this.approvalInstanceFilterableFieldPolicy = new approval_instance_filterable_field_policy_1.ApprovalInstanceFilterableFieldPolicy();
|
|
198
215
|
/** Webhooks each open engine transaction will send once it has committed. */
|
|
199
216
|
this.deferredWebhookDispatches = new WeakMap();
|
|
200
217
|
// A binding under the token wins: that is either the host's own provider
|
|
@@ -205,12 +222,18 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
205
222
|
// keeps a host that binds the token from its own global module working —
|
|
206
223
|
// a module-local default would shadow that binding, silently sending
|
|
207
224
|
// WEBHOOK service tasks through the built-in `fetch` dispatcher instead of
|
|
208
|
-
// the host's signing or queueing one.
|
|
209
|
-
// service, which a provider registered on one module does not.
|
|
225
|
+
// the host's signing or queueing one.
|
|
210
226
|
this.serviceTaskDispatcher =
|
|
211
227
|
injectedServiceTaskDispatcher ??
|
|
212
228
|
rootOptions?.workflowServiceTaskDispatcher ??
|
|
213
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;
|
|
214
237
|
this.organizationTimeZone = (0, organization_time_zone_1.resolveBPMOrganizationTimeZone)(rootOptions?.organizationTimeZone);
|
|
215
238
|
}
|
|
216
239
|
/**
|
|
@@ -276,6 +299,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
276
299
|
}
|
|
277
300
|
}
|
|
278
301
|
async submitApprovalInstance(input, authContext) {
|
|
302
|
+
const submitAuthContext = authContext ?? createWorkflowAuthContext(input.initiatorMemberId);
|
|
279
303
|
if (input.submittedByMemberId !== undefined &&
|
|
280
304
|
input.submittedByMemberId !== null &&
|
|
281
305
|
(typeof input.submittedByMemberId !== 'string' ||
|
|
@@ -308,15 +332,28 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
308
332
|
validateSubmittedFormData(formDefinitionVersion.schema, formData, {
|
|
309
333
|
deferredFieldKeys,
|
|
310
334
|
});
|
|
335
|
+
// Checked against the bound version's schema before any data source is
|
|
336
|
+
// called, so a bad value is refused cheaply. The transaction below
|
|
337
|
+
// confirms under the template lock that this version is still current
|
|
338
|
+
// (ADR 25 §15), which is what makes this schema the one in force.
|
|
339
|
+
assertFilterableFormDataValues(formDefinitionVersion.schema, formData);
|
|
311
340
|
const formDataOptionSnapshot = await this.resolveFormDataOptionSnapshots({
|
|
312
|
-
authContext:
|
|
341
|
+
authContext: submitAuthContext,
|
|
313
342
|
formData,
|
|
314
343
|
revalidateAll: true,
|
|
315
344
|
schema: formDefinitionVersion.schema,
|
|
316
345
|
});
|
|
317
346
|
const initiatorMetadataSnapshot = input.initiatorMetadataSnapshotJson
|
|
318
347
|
? parseJsonObject(input.initiatorMetadataSnapshotJson, 'initiatorMetadataSnapshotJson')
|
|
319
|
-
: 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
|
+
});
|
|
320
357
|
if (!this.conditionService.evaluateBoolean(templateVersion.initiatorPolicyCel, buildInitiatorPolicyContext(initiatorMetadataSnapshot), 'initiatorPolicyCel',
|
|
321
358
|
// The policy is written in its version's dialect (ADR 22 §3.3).
|
|
322
359
|
readExpressionDialectOptions(templateVersion.initiatorPolicyCel, templateVersion.workflowDefinition))) {
|
|
@@ -334,6 +371,26 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
334
371
|
throw new common_1.BadRequestException('Published workflow does not include a startEvent');
|
|
335
372
|
}
|
|
336
373
|
return this.runEngineTransaction(async (manager) => {
|
|
374
|
+
// ADR 25 §6 and §15: take FOR SHARE on the template row, so a publish,
|
|
375
|
+
// deactivation or edit cannot commit between this point and the
|
|
376
|
+
// insert, then re-check under the lock what was read before it. The
|
|
377
|
+
// filterable check above used the version read then; it holds only
|
|
378
|
+
// while that version is still the current one.
|
|
379
|
+
const lockedTemplate = await manager
|
|
380
|
+
.getRepository(approval_template_entity_1.ApprovalTemplateEntity)
|
|
381
|
+
.findOne({
|
|
382
|
+
lock: { mode: 'pessimistic_read' },
|
|
383
|
+
where: { deletedAt: (0, typeorm_2.IsNull)(), id: template.id },
|
|
384
|
+
});
|
|
385
|
+
if (!lockedTemplate) {
|
|
386
|
+
throw new common_1.NotFoundException(`Approval template ${template.id} was not found`);
|
|
387
|
+
}
|
|
388
|
+
if (!lockedTemplate.isActive) {
|
|
389
|
+
throw new common_1.ConflictException('Approval template is deactivated');
|
|
390
|
+
}
|
|
391
|
+
if (lockedTemplate.currentVersionId !== templateVersion.id) {
|
|
392
|
+
throw new common_1.ConflictException('範本在送出期間發布了新版本,這次申請沒有送出;請重新整理頁面後再送出');
|
|
393
|
+
}
|
|
337
394
|
const instanceRepository = manager.getRepository(approval_instance_entity_1.ApprovalInstanceEntity);
|
|
338
395
|
const tokenRepository = manager.getRepository(workflow_token_entity_1.WorkflowTokenEntity);
|
|
339
396
|
const activityRepository = manager.getRepository(activity_log_entity_1.ActivityLogEntity);
|
|
@@ -460,6 +517,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
460
517
|
? (0, bpm_core_shared_1.overlayStoredFormDataKeys)(submittedFormData, instance.formData, deferredFieldKeys)
|
|
461
518
|
: submittedFormData;
|
|
462
519
|
validateSubmittedFormData(schema, formData, { deferredFieldKeys });
|
|
520
|
+
await this.assertCurrentFilterableFormDataValues(instance.templateId, formData);
|
|
463
521
|
const formDataOptionSnapshot = await this.resolveFormDataOptionSnapshots({
|
|
464
522
|
authContext,
|
|
465
523
|
formData,
|
|
@@ -611,6 +669,15 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
611
669
|
transferToMemberId === (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance)) {
|
|
612
670
|
throw new common_1.ConflictException(`簽核節點「${taskNode.data.label}」不允許代送人簽核,不能將任務轉交給代為送出此單的人`);
|
|
613
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
|
+
}
|
|
614
681
|
// Same carve-out: a human picking the target on a manager-chain node
|
|
615
682
|
// must pick someone at least as senior as the member being replaced.
|
|
616
683
|
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED &&
|
|
@@ -634,7 +701,18 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
634
701
|
if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.RETURNED &&
|
|
635
702
|
!decisionComment &&
|
|
636
703
|
isReturnCommentRequired(instance.workflowSnapshot, task.nodeId)) {
|
|
637
|
-
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('此節點要求同意時填寫意見,請輸入意見後再同意。');
|
|
638
716
|
}
|
|
639
717
|
if (filledFormData !== null && task.isAdhoc) {
|
|
640
718
|
throw new common_1.ForbiddenException(`Ad-hoc task ${task.id} cannot fill in form fields`);
|
|
@@ -673,6 +751,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
673
751
|
}
|
|
674
752
|
}
|
|
675
753
|
if (filledFormData !== null && changedFieldKeys.length > 0) {
|
|
754
|
+
await this.assertCurrentFilterableFormDataValues(instance.templateId, mergedFormData, manager);
|
|
676
755
|
// Merged into the shared `instance` before it is signed and before
|
|
677
756
|
// the token moves on: the signature must cover the filled values, a
|
|
678
757
|
// gateway after this node must route on them, and an end node saves
|
|
@@ -994,6 +1073,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
994
1073
|
validateSubmittedFormData(resubmitSchema, formData, {
|
|
995
1074
|
deferredFieldKeys,
|
|
996
1075
|
});
|
|
1076
|
+
await this.assertCurrentFilterableFormDataValues(instance.templateId, formData, manager);
|
|
997
1077
|
if (!preparation) {
|
|
998
1078
|
assertNoUnresolvableDynamicValues(resubmitSchema, formData);
|
|
999
1079
|
}
|
|
@@ -1056,7 +1136,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
1056
1136
|
// reached, against that node's own setting. This checks the target
|
|
1057
1137
|
// against the node being decided now, as an early rejection for the
|
|
1058
1138
|
// common case; it is not a guarantee about the node it attaches to.
|
|
1059
|
-
if (
|
|
1139
|
+
if (hasSeatExclusion(node)) {
|
|
1060
1140
|
await this.resolveAdhocTaskCandidates(manager, instance, node, '臨時會簽', targetValue);
|
|
1061
1141
|
}
|
|
1062
1142
|
const directiveRepository = manager.getRepository(adhoc_directive_entity_1.AdhocDirectiveEntity);
|
|
@@ -1321,19 +1401,25 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
1321
1401
|
return task;
|
|
1322
1402
|
}
|
|
1323
1403
|
async resolveAdhocTaskCandidates(manager, instance, node, label, targetValue) {
|
|
1324
|
-
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);
|
|
1325
1406
|
// Ad-hoc approvers are picked by a person, not walked up a manager chain,
|
|
1326
1407
|
// so the chain's grade rule does not apply to them.
|
|
1327
1408
|
const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, false);
|
|
1328
|
-
const candidates = selectNodeCandidates(delegatedCandidates,
|
|
1409
|
+
const candidates = selectNodeCandidates(delegatedCandidates, hasSeatExclusion(node), exclusions.memberIds);
|
|
1329
1410
|
if (candidates.length > 0) {
|
|
1330
1411
|
return candidates;
|
|
1331
1412
|
}
|
|
1332
|
-
|
|
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)
|
|
1333
1419
|
? (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) === null
|
|
1334
1420
|
? `${label}「${node.data.label}」 無法建立任務:指定對象只有申請人本人,此節點不允許申請人簽核。`
|
|
1335
1421
|
: `${label}「${node.data.label}」 無法建立任務:指定對象只有申請人或代為送出此單的人,此節點不允許申請人或代送人簽核。`
|
|
1336
|
-
: `${label}「${node.data.label}」
|
|
1422
|
+
: `${label}「${node.data.label}」 無法建立任務:指定對象只有前面${readNodeActorNodeNames(instance, readExcludedNodeActorNodeIds(node))}的簽核者,此節點不允許與前面關卡的簽核者為同一人。`);
|
|
1337
1423
|
}
|
|
1338
1424
|
async spawnCountersignTasksForNode(manager, instance, token, node, now) {
|
|
1339
1425
|
const directiveRepository = manager.getRepository(adhoc_directive_entity_1.AdhocDirectiveEntity);
|
|
@@ -1781,6 +1867,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
1781
1867
|
}
|
|
1782
1868
|
const workflowDefinition = parseWorkflowDefinition(input.workflowDefinitionJson);
|
|
1783
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.
|
|
1784
1874
|
const initiatorMetadataSnapshot = input.initiatorMetadataSnapshotJson
|
|
1785
1875
|
? parseJsonObject(input.initiatorMetadataSnapshotJson, 'initiatorMetadataSnapshotJson')
|
|
1786
1876
|
: readDefaultInitiatorMetadataSnapshot(input.initiatorMemberId);
|
|
@@ -1979,6 +2069,18 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
1979
2069
|
outcome: 'STOPPED',
|
|
1980
2070
|
};
|
|
1981
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
|
+
}
|
|
1982
2084
|
const memberIds = this.readDryRunMemberIds(input, resolver, `workflow.nodes.${node.id}.data.approverResolver`);
|
|
1983
2085
|
if (memberIds === null) {
|
|
1984
2086
|
return {
|
|
@@ -2085,22 +2187,76 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2085
2187
|
}
|
|
2086
2188
|
return instance;
|
|
2087
2189
|
}
|
|
2190
|
+
/**
|
|
2191
|
+
* ADR 25 §6: a value written to a filterable field must fit its type, on
|
|
2192
|
+
* every path that writes form data. The fields checked are the ones the
|
|
2193
|
+
* template's *current* version marks filterable, not the instance's own
|
|
2194
|
+
* snapshot: an older instance can be re-filled, resubmitted or written by a
|
|
2195
|
+
* service task after a newer version enabled filtering on a field, and the
|
|
2196
|
+
* enable check (ADR 25 §5.4) guarantees the type is the same in every version
|
|
2197
|
+
* that holds instances, so the current definition judges the old value too.
|
|
2198
|
+
*/
|
|
2199
|
+
async assertCurrentFilterableFormDataValues(templateId, formData, manager, serviceNodeLabel) {
|
|
2200
|
+
const schema = await this.readCurrentFormSchema(templateId, manager);
|
|
2201
|
+
assertFilterableFormDataValues(schema, formData, serviceNodeLabel);
|
|
2202
|
+
}
|
|
2203
|
+
/** The form schema of the template's current version, or `null` when there is none. */
|
|
2204
|
+
async readCurrentFormSchema(templateId, manager) {
|
|
2205
|
+
// Inside a write transaction the template row is taken FOR SHARE (ADR 25
|
|
2206
|
+
// §15): a publish, compose or rollback needs FOR UPDATE on the same row, so
|
|
2207
|
+
// the current version read here cannot change before the write commits.
|
|
2208
|
+
const template = manager
|
|
2209
|
+
? await manager.getRepository(approval_template_entity_1.ApprovalTemplateEntity).findOne({
|
|
2210
|
+
lock: { mode: 'pessimistic_read' },
|
|
2211
|
+
where: { id: templateId },
|
|
2212
|
+
})
|
|
2213
|
+
: await this.approvalTemplateRepository.findOne({
|
|
2214
|
+
where: { id: templateId },
|
|
2215
|
+
});
|
|
2216
|
+
if (!template?.currentVersionId) {
|
|
2217
|
+
return null;
|
|
2218
|
+
}
|
|
2219
|
+
const versions = manager
|
|
2220
|
+
? manager.getRepository(approval_template_version_entity_1.ApprovalTemplateVersionEntity)
|
|
2221
|
+
: this.approvalTemplateVersionRepository;
|
|
2222
|
+
const version = await versions.findOne({
|
|
2223
|
+
where: { id: template.currentVersionId },
|
|
2224
|
+
});
|
|
2225
|
+
if (!version?.formDefinitionVersionId) {
|
|
2226
|
+
return null;
|
|
2227
|
+
}
|
|
2228
|
+
const formVersions = manager
|
|
2229
|
+
? manager.getRepository(form_definition_version_entity_1.FormDefinitionVersionEntity)
|
|
2230
|
+
: this.formDefinitionVersionRepository;
|
|
2231
|
+
const formVersion = await formVersions.findOne({
|
|
2232
|
+
where: { id: version.formDefinitionVersionId },
|
|
2233
|
+
});
|
|
2234
|
+
return formVersion?.schema ?? null;
|
|
2235
|
+
}
|
|
2088
2236
|
async listApprovalInstances(scope, options = {}) {
|
|
2089
|
-
const
|
|
2090
|
-
if (!
|
|
2237
|
+
const query = await this.createFilteredApprovalInstanceQuery(scope, options);
|
|
2238
|
+
if (!query) {
|
|
2091
2239
|
return [];
|
|
2092
2240
|
}
|
|
2093
|
-
|
|
2241
|
+
const { orderBy, queryBuilder } = query;
|
|
2242
|
+
orderBy.forEach((order, index) => {
|
|
2243
|
+
if (index === 0) {
|
|
2244
|
+
queryBuilder.orderBy(order.sql, order.direction);
|
|
2245
|
+
}
|
|
2246
|
+
else {
|
|
2247
|
+
queryBuilder.addOrderBy(order.sql, order.direction);
|
|
2248
|
+
}
|
|
2249
|
+
});
|
|
2094
2250
|
if (shouldPaginateList(options)) {
|
|
2095
2251
|
queryBuilder
|
|
2096
|
-
.
|
|
2097
|
-
.
|
|
2252
|
+
.offset(readPageOffset(options.page, options.pageSize))
|
|
2253
|
+
.limit(normalizePageSize(options.pageSize));
|
|
2098
2254
|
}
|
|
2099
2255
|
return queryBuilder.getMany();
|
|
2100
2256
|
}
|
|
2101
2257
|
async countApprovalInstances(scope, options = {}) {
|
|
2102
|
-
const
|
|
2103
|
-
return
|
|
2258
|
+
const query = await this.createFilteredApprovalInstanceQuery(scope, options);
|
|
2259
|
+
return query ? query.queryBuilder.getCount() : 0;
|
|
2104
2260
|
}
|
|
2105
2261
|
async readApprovalInstancePageInfo(scope, options = {}) {
|
|
2106
2262
|
const totalCount = await this.countApprovalInstances(scope, options);
|
|
@@ -2116,7 +2272,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2116
2272
|
totalPages,
|
|
2117
2273
|
};
|
|
2118
2274
|
}
|
|
2119
|
-
async
|
|
2275
|
+
async createFilteredApprovalInstanceQuery(scope, options = {}) {
|
|
2120
2276
|
const queryBuilder = this.approvalInstanceRepository.createQueryBuilder('approvalInstance');
|
|
2121
2277
|
const hasReadableInstances = this.applyReadableInstanceFilter(queryBuilder, scope, options);
|
|
2122
2278
|
if (!hasReadableInstances) {
|
|
@@ -2134,7 +2290,72 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2134
2290
|
});
|
|
2135
2291
|
}
|
|
2136
2292
|
applyApprovalInstanceSearchFilter(queryBuilder, options.searchText);
|
|
2137
|
-
|
|
2293
|
+
const compiled = await this.compileApprovalInstanceFieldQuery(scope, templateId, options);
|
|
2294
|
+
compiled.conditions.forEach((condition) => {
|
|
2295
|
+
queryBuilder.andWhere(condition.sql, condition.parameters);
|
|
2296
|
+
});
|
|
2297
|
+
queryBuilder.setParameters(compiled.orderBy.reduce((all, order) => ({ ...all, ...order.parameters }), {}));
|
|
2298
|
+
return { orderBy: compiled.orderBy, queryBuilder };
|
|
2299
|
+
}
|
|
2300
|
+
/**
|
|
2301
|
+
* The WHERE conditions and sort keys of the field filters and sort on the
|
|
2302
|
+
* approval-instance list (ADR 25 §7). The template's current schema is read,
|
|
2303
|
+
* and the static option lists of every version holding instances, only when
|
|
2304
|
+
* a condition or a non-time sort key needs them.
|
|
2305
|
+
*/
|
|
2306
|
+
async compileApprovalInstanceFieldQuery(scope, templateId, options) {
|
|
2307
|
+
const fieldFilters = options.fieldFilters ?? [];
|
|
2308
|
+
const sort = options.sort ?? [];
|
|
2309
|
+
const needsSchema = fieldFilters.length > 0 ||
|
|
2310
|
+
sort.some((item) => !(0, bpm_core_shared_1.isApprovalInstanceSortTimeField)(item.field));
|
|
2311
|
+
if (needsSchema && !templateId) {
|
|
2312
|
+
throw new common_1.BadRequestException('篩選或排序表單欄位時需要指定簽核範本');
|
|
2313
|
+
}
|
|
2314
|
+
const schema = needsSchema && templateId
|
|
2315
|
+
? await this.readCurrentFormSchema(templateId)
|
|
2316
|
+
: null;
|
|
2317
|
+
// Only a name search (CONTAINS) reads option names; other conditions and
|
|
2318
|
+
// sort keys need the current schema alone.
|
|
2319
|
+
const needsOptionNames = fieldFilters.some((filter) => filter.operator === 'CONTAINS');
|
|
2320
|
+
const staticOptionsByPath = needsOptionNames && templateId
|
|
2321
|
+
? await this.readStaticOptionsByPathForTemplate(templateId, schema)
|
|
2322
|
+
: (0, approval_instance_field_filter_compiler_1.readStaticOptionsByPath)([]);
|
|
2323
|
+
return this.approvalInstanceFieldFilterCompiler.compile({
|
|
2324
|
+
alias: 'approvalInstance',
|
|
2325
|
+
fieldFilters,
|
|
2326
|
+
paths: this.approvalInstanceFilterableFieldPolicy.readFilterableFieldPaths(scope, schema),
|
|
2327
|
+
sort,
|
|
2328
|
+
staticOptionsByPath,
|
|
2329
|
+
});
|
|
2330
|
+
}
|
|
2331
|
+
/**
|
|
2332
|
+
* Static option lists by field path, across the current schema and the form
|
|
2333
|
+
* version of every template version that holds instances (ADR 25 §7.3): a
|
|
2334
|
+
* CONTAINS on a retired option still matches its name.
|
|
2335
|
+
*/
|
|
2336
|
+
async readStaticOptionsByPathForTemplate(templateId, currentSchema) {
|
|
2337
|
+
const held = await this.approvalInstanceRepository
|
|
2338
|
+
.createQueryBuilder('approvalInstance')
|
|
2339
|
+
.select('DISTINCT "approvalInstance"."template_version_id"', 'id')
|
|
2340
|
+
.where('"approvalInstance"."template_id" = :templateId', { templateId })
|
|
2341
|
+
.getRawMany();
|
|
2342
|
+
const templateVersions = held.length === 0
|
|
2343
|
+
? []
|
|
2344
|
+
: await this.approvalTemplateVersionRepository.find({
|
|
2345
|
+
where: { id: (0, typeorm_2.In)(held.map((row) => row.id)) },
|
|
2346
|
+
});
|
|
2347
|
+
const formVersionIds = templateVersions
|
|
2348
|
+
.map((version) => version.formDefinitionVersionId)
|
|
2349
|
+
.filter((id) => id !== null);
|
|
2350
|
+
const formVersions = formVersionIds.length === 0
|
|
2351
|
+
? []
|
|
2352
|
+
: await this.formDefinitionVersionRepository.find({
|
|
2353
|
+
where: { id: (0, typeorm_2.In)(formVersionIds) },
|
|
2354
|
+
});
|
|
2355
|
+
return (0, approval_instance_field_filter_compiler_1.readStaticOptionsByPath)([
|
|
2356
|
+
currentSchema,
|
|
2357
|
+
...formVersions.map((version) => version.schema),
|
|
2358
|
+
]);
|
|
2138
2359
|
}
|
|
2139
2360
|
applyReadableInstanceFilter(queryBuilder, scope, options) {
|
|
2140
2361
|
if (!scope) {
|
|
@@ -2242,7 +2463,12 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2242
2463
|
},
|
|
2243
2464
|
});
|
|
2244
2465
|
}
|
|
2245
|
-
|
|
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) {
|
|
2246
2472
|
const templates = await this.approvalTemplateRepository.find({
|
|
2247
2473
|
order: { updatedAt: 'DESC' },
|
|
2248
2474
|
where: { isActive: true },
|
|
@@ -2263,7 +2489,17 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2263
2489
|
version.id,
|
|
2264
2490
|
version,
|
|
2265
2491
|
]));
|
|
2266
|
-
|
|
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
|
+
});
|
|
2267
2503
|
return templates.filter((template) => {
|
|
2268
2504
|
const currentVersion = template.currentVersionId
|
|
2269
2505
|
? (versionById.get(template.currentVersionId) ?? null)
|
|
@@ -2281,8 +2517,19 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2281
2517
|
return false;
|
|
2282
2518
|
}
|
|
2283
2519
|
// Each template is its own operation, so one slow template cannot use
|
|
2284
|
-
// up the allowance of the rest of the list.
|
|
2285
|
-
|
|
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
|
+
}
|
|
2286
2533
|
});
|
|
2287
2534
|
}
|
|
2288
2535
|
async listWorkflowTokens(instanceId, scope) {
|
|
@@ -2523,6 +2770,112 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2523
2770
|
primaryOrgUnitId: primaryMembership?.orgUnitId ?? null,
|
|
2524
2771
|
};
|
|
2525
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
|
+
}
|
|
2526
2879
|
async listActivityLogs(instanceId, scope) {
|
|
2527
2880
|
await this.getApprovalInstance(instanceId, scope);
|
|
2528
2881
|
return this.activityLogRepository.find({
|
|
@@ -2743,10 +3096,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2743
3096
|
skippedByEntryCondition: true,
|
|
2744
3097
|
});
|
|
2745
3098
|
}
|
|
2746
|
-
async skipNodeByInitiatorExclusion(manager, instance, token, node) {
|
|
2747
|
-
await this.skipNode(manager, instance, token, node,
|
|
2748
|
-
|
|
2749
|
-
|
|
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 });
|
|
2750
3103
|
}
|
|
2751
3104
|
async skipNode(manager, instance, token, node, reason) {
|
|
2752
3105
|
// A NOTIFY node is an asynchronous branch that may not have an outgoing
|
|
@@ -2975,11 +3328,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
2975
3328
|
return;
|
|
2976
3329
|
}
|
|
2977
3330
|
const resolvedCandidates = await this.resolveRuntimeTaskCandidates(manager, instance, node);
|
|
2978
|
-
//
|
|
2979
|
-
//
|
|
2980
|
-
//
|
|
2981
|
-
if (
|
|
2982
|
-
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);
|
|
2983
3336
|
return;
|
|
2984
3337
|
}
|
|
2985
3338
|
const { candidates, skippedDelegations } = resolvedCandidates;
|
|
@@ -3002,7 +3355,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3002
3355
|
: workflow_engine_enums_1.TaskAssignmentTypeEnum.CANDIDATE_GROUP,
|
|
3003
3356
|
completedAt: null,
|
|
3004
3357
|
createdAt: now,
|
|
3005
|
-
decisionPolicySnapshot:
|
|
3358
|
+
decisionPolicySnapshot: hasSeatExclusion(node)
|
|
3006
3359
|
? capDecisionPolicyToCandidateCount(decisionPolicy, candidates.length)
|
|
3007
3360
|
: decisionPolicy,
|
|
3008
3361
|
delegationChain: assignedCandidate?.delegationChain ?? [],
|
|
@@ -3100,7 +3453,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3100
3453
|
const { skippedDelegation, ...delegation } = await this.resolveActivationDelegation({
|
|
3101
3454
|
candidate: nextCandidate,
|
|
3102
3455
|
candidates,
|
|
3103
|
-
|
|
3456
|
+
excludedMemberIds: (await this.readNodeExclusions(manager, instance, node)).memberIds,
|
|
3104
3457
|
gradeGated: isGradeGatedCandidate(node, nextCandidate.sourceType),
|
|
3105
3458
|
instance,
|
|
3106
3459
|
manager,
|
|
@@ -3161,12 +3514,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3161
3514
|
* Re-resolves the delegation of a candidate whose turn has come. When the
|
|
3162
3515
|
* delegate already holds another candidate row on the task, the stored values
|
|
3163
3516
|
* are kept: (task_id, member_id) is unique, and that member signs from the
|
|
3164
|
-
* row they already have. On a node that
|
|
3165
|
-
*
|
|
3166
|
-
*
|
|
3167
|
-
*
|
|
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.
|
|
3168
3522
|
*/
|
|
3169
|
-
async resolveActivationDelegation({ candidate, candidates,
|
|
3523
|
+
async resolveActivationDelegation({ candidate, candidates, excludedMemberIds, gradeGated, instance, manager, nodeId, }) {
|
|
3170
3524
|
const stored = {
|
|
3171
3525
|
delegationChain: candidate.delegationChain,
|
|
3172
3526
|
memberId: candidate.memberId,
|
|
@@ -3175,12 +3529,11 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3175
3529
|
const collides = candidates.some((other) => other.id !== candidate.id &&
|
|
3176
3530
|
other.memberId === resolution.finalAssigneeMemberId);
|
|
3177
3531
|
if (collides ||
|
|
3178
|
-
(
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
}, readInitiatorAndSubmitter(instance)))) {
|
|
3532
|
+
isExcludedMemberOnCandidatePath({
|
|
3533
|
+
delegationChain: resolution.delegationChain,
|
|
3534
|
+
memberId: resolution.finalAssigneeMemberId,
|
|
3535
|
+
originalMemberId: candidate.originalMemberId,
|
|
3536
|
+
}, excludedMemberIds)) {
|
|
3184
3537
|
// The refusal happened even though the stored values are kept.
|
|
3185
3538
|
return { ...stored, ...(skippedDelegation ? { skippedDelegation } : {}) };
|
|
3186
3539
|
}
|
|
@@ -3730,6 +4083,10 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3730
4083
|
throw new TaskReassignmentRejectedError(workflow_engine_enums_1.TaskReassignmentErrorCodeEnum.TARGET_IS_SUBMITTER);
|
|
3731
4084
|
}
|
|
3732
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
|
+
}
|
|
3733
4090
|
if (!task.isAdhoc && isOrgManagerChainNode(node)) {
|
|
3734
4091
|
const requiredGrade = await this.readTransferTargetRequiredGrade(manager, {
|
|
3735
4092
|
actorMemberId: fromMemberId,
|
|
@@ -3942,58 +4299,178 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
3942
4299
|
});
|
|
3943
4300
|
}
|
|
3944
4301
|
/**
|
|
3945
|
-
* Resolves who gets the node's task.
|
|
3946
|
-
* skipped (`excludeInitiator.whenEmpty
|
|
3947
|
-
*
|
|
3948
|
-
* 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.
|
|
3949
4311
|
*/
|
|
3950
4312
|
async resolveRuntimeTaskCandidates(manager, instance, node) {
|
|
3951
4313
|
const label = `簽核節點「${node.data.label}」`;
|
|
3952
|
-
const
|
|
4314
|
+
const exclusions = await this.readNodeExclusions(manager, instance, node);
|
|
4315
|
+
const resolvedCandidates = await this.resolveApproverResolver(manager, instance, node.data.approverResolver, label, '簽核人', exclusions.memberIds);
|
|
3953
4316
|
const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, isOrgManagerChainNode(node));
|
|
3954
|
-
const
|
|
4317
|
+
const hasExclusion = hasSeatExclusion(node);
|
|
3955
4318
|
const hasSubmitter = (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) !== null;
|
|
3956
|
-
const uniqueCandidates = selectNodeCandidates(delegatedCandidates,
|
|
4319
|
+
const uniqueCandidates = selectNodeCandidates(delegatedCandidates, hasExclusion, exclusions.memberIds);
|
|
3957
4320
|
if (uniqueCandidates.length > 0) {
|
|
3958
4321
|
return {
|
|
3959
4322
|
candidates: uniqueCandidates,
|
|
3960
|
-
skippedDelegations: collectSkippedDelegations(delegatedCandidates,
|
|
4323
|
+
skippedDelegations: collectSkippedDelegations(delegatedCandidates, hasExclusion, exclusions.memberIds),
|
|
3961
4324
|
};
|
|
3962
4325
|
}
|
|
3963
|
-
// Only
|
|
3964
|
-
// Nothing resolved to begin with is the ordinary failure below.
|
|
3965
|
-
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) {
|
|
3966
4329
|
throw new common_1.ConflictException(`${label} did not resolve to any member id`);
|
|
3967
4330
|
}
|
|
3968
|
-
|
|
3969
|
-
|
|
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' };
|
|
3970
4337
|
}
|
|
3971
|
-
if (
|
|
3972
|
-
const managerCandidates = await this.resolveInitiatorManagerReplacements(manager, instance, node);
|
|
4338
|
+
if (whenEmpty === 'INITIATOR_MANAGER') {
|
|
4339
|
+
const managerCandidates = await this.resolveInitiatorManagerReplacements(manager, instance, node, exclusions.memberIds);
|
|
3973
4340
|
if (managerCandidates.candidates.length > 0) {
|
|
3974
4341
|
return managerCandidates;
|
|
3975
4342
|
}
|
|
3976
|
-
throw new common_1.ConflictException(
|
|
3977
|
-
? `${label}
|
|
3978
|
-
:
|
|
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;
|
|
3979
4411
|
}
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
4412
|
+
if (excludedMemberIds.length === 0) {
|
|
4413
|
+
return false;
|
|
4414
|
+
}
|
|
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);
|
|
3983
4434
|
}
|
|
3984
|
-
|
|
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
|
+
]);
|
|
3985
4462
|
// The initiator's own direct manager: the closest org unit only. The
|
|
3986
4463
|
// initiator himself (a unit head) and the member who submitted for him are
|
|
3987
4464
|
// skipped before the tier is chosen, so resolution falls through to the
|
|
3988
4465
|
// next tier instead of ending on either of them.
|
|
3989
|
-
const managerMemberIds = await this.resolveDirectManagerMemberIds(manager, instance.initiatorMemberId, this.readOrganizationToday(), true,
|
|
4466
|
+
const managerMemberIds = await this.resolveDirectManagerMemberIds(manager, instance.initiatorMemberId, this.readOrganizationToday(), true, replacementExcludedMemberIds);
|
|
3990
4467
|
const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, managerMemberIds.map((memberId) => ({
|
|
3991
4468
|
memberId,
|
|
3992
4469
|
sourceType: 'EXCLUDE_INITIATOR_MANAGER',
|
|
3993
4470
|
})), isOrgManagerChainNode(node));
|
|
3994
4471
|
return {
|
|
3995
|
-
candidates: selectNodeCandidates(delegatedCandidates, true,
|
|
3996
|
-
skippedDelegations: collectSkippedDelegations(delegatedCandidates, true,
|
|
4472
|
+
candidates: selectNodeCandidates(delegatedCandidates, true, replacementExcludedMemberIds),
|
|
4473
|
+
skippedDelegations: collectSkippedDelegations(delegatedCandidates, true, replacementExcludedMemberIds),
|
|
3997
4474
|
};
|
|
3998
4475
|
}
|
|
3999
4476
|
buildDelegationContext(instance, nodeId) {
|
|
@@ -4107,7 +4584,7 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4107
4584
|
return this.resolveOrgUnitManagerCandidates(manager, instance, resolver.orgUnitId, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject);
|
|
4108
4585
|
}
|
|
4109
4586
|
if (resolver.type === 'ORG_MANAGER_CHAIN') {
|
|
4110
|
-
return this.resolveOrgManagerChainCandidates(manager, instance, resolver
|
|
4587
|
+
return this.resolveOrgManagerChainCandidates(manager, instance, resolver, label, subject, skippedChainMemberIds);
|
|
4111
4588
|
}
|
|
4112
4589
|
if (resolver.type === 'ORG_MANAGER') {
|
|
4113
4590
|
return this.resolveInitiatorManagerCandidates(manager, instance, resolver.levelsUp, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject);
|
|
@@ -4212,6 +4689,15 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4212
4689
|
* tasks and approvals the SLA timeout made do not count.
|
|
4213
4690
|
*/
|
|
4214
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) {
|
|
4215
4701
|
const tasks = await manager.getRepository(task_entity_1.TaskEntity).find({
|
|
4216
4702
|
where: { instanceId: instance.id, isAdhoc: false, nodeId: (0, typeorm_2.In)([...nodeIds]) },
|
|
4217
4703
|
});
|
|
@@ -4242,10 +4728,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4242
4728
|
const automaticDecisionIds = new Set(decidedLogs
|
|
4243
4729
|
.filter((log) => log.payload['automatic'] === SLA_AUTO_APPROVE_MARK)
|
|
4244
4730
|
.map((log) => log.payload['decisionId']));
|
|
4245
|
-
return
|
|
4731
|
+
return [...decisions]
|
|
4246
4732
|
.filter((decision) => !automaticDecisionIds.has(decision.id))
|
|
4247
|
-
.sort((first, second) => first.decidedAt.getTime() - second.decidedAt.getTime())
|
|
4248
|
-
.map((decision) => decision.decidedByMemberId));
|
|
4733
|
+
.sort((first, second) => first.decidedAt.getTime() - second.decidedAt.getTime());
|
|
4249
4734
|
}
|
|
4250
4735
|
async resolvePositionCandidates(manager, positionId, label, subject) {
|
|
4251
4736
|
const memberIds = await this.readActivePositionMemberIds(manager, positionId);
|
|
@@ -4284,16 +4769,28 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4284
4769
|
return this.resolveFallbackAssignees(manager, instance, fallback, label, subject, `找不到發起人的第 ${normalizedLevelsUp} 層主管`);
|
|
4285
4770
|
}
|
|
4286
4771
|
/**
|
|
4287
|
-
* Walks up
|
|
4288
|
-
*
|
|
4289
|
-
*
|
|
4290
|
-
*
|
|
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.
|
|
4291
4780
|
*/
|
|
4292
|
-
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
|
+
}
|
|
4293
4790
|
const date = this.readOrganizationToday();
|
|
4294
|
-
const visitedMemberIds = new Set([
|
|
4791
|
+
const visitedMemberIds = new Set([start.memberId]);
|
|
4295
4792
|
const chain = [];
|
|
4296
|
-
let currentMemberId =
|
|
4793
|
+
let currentMemberId = start.memberId;
|
|
4297
4794
|
let lastAddedLevel = null;
|
|
4298
4795
|
for (let hop = 0; hop < ORG_MANAGER_CHAIN_MAX_HOPS; hop += 1) {
|
|
4299
4796
|
// A chain is a single path, so it always uses closest-org-unit semantics
|
|
@@ -4326,6 +4823,13 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4326
4823
|
(lastAddedLevel !== null && level <= lastAddedLevel)) {
|
|
4327
4824
|
continue;
|
|
4328
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
|
+
}
|
|
4329
4833
|
chain.push(managerMemberId);
|
|
4330
4834
|
lastAddedLevel = level;
|
|
4331
4835
|
if (level >= minPositionLevel) {
|
|
@@ -4334,9 +4838,68 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4334
4838
|
}
|
|
4335
4839
|
// A position fallback can name several holders. They all join the last
|
|
4336
4840
|
// tier, so they all have to sign, the same as every other chain member.
|
|
4337
|
-
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} 的主管`);
|
|
4338
4844
|
return uniqueTexts([...chain, ...fallbackMemberIds]);
|
|
4339
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
|
+
}
|
|
4340
4903
|
/** Highest position level over the member's active memberships, or `null`. */
|
|
4341
4904
|
async readMemberPositionLevel(manager, memberId, date) {
|
|
4342
4905
|
return ((await this.readMemberPositionLevels(manager, [memberId], date)).get(memberId) ?? null);
|
|
@@ -4647,8 +5210,9 @@ let WorkflowEngineService = WorkflowEngineService_1 = class WorkflowEngineServic
|
|
|
4647
5210
|
}
|
|
4648
5211
|
if (action.type === 'SET_FORM_FIELD') {
|
|
4649
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) });
|
|
4650
|
-
|
|
5213
|
+
assertServiceTaskFieldValue(instance.formDefinitionSnapshot, action.fieldPath, value, node.data.label);
|
|
4651
5214
|
const updatedFormData = writeValueAtPath(instance.formData, action.fieldPath, value);
|
|
5215
|
+
await this.assertCurrentFilterableFormDataValues(instance.templateId, updatedFormData, manager, node.data.label);
|
|
4652
5216
|
// A service task runs inside this transaction, so it cannot call a host
|
|
4653
5217
|
// provider to re-resolve the label. Rather than leave the previous
|
|
4654
5218
|
// option snapshot describing a value that no longer exists, drop it: the
|
|
@@ -5027,6 +5591,8 @@ exports.WorkflowEngineService = WorkflowEngineService = WorkflowEngineService_1
|
|
|
5027
5591
|
tslib_1.__param(19, (0, common_1.Optional)()),
|
|
5028
5592
|
tslib_1.__param(19, (0, common_1.Inject)(bpm_root_options_1.BPM_ROOT_OPTIONS)),
|
|
5029
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)),
|
|
5030
5596
|
tslib_1.__metadata("design:paramtypes", [typeorm_2.Repository,
|
|
5031
5597
|
typeorm_2.Repository,
|
|
5032
5598
|
typeorm_2.Repository,
|
|
@@ -5043,7 +5609,7 @@ exports.WorkflowEngineService = WorkflowEngineService = WorkflowEngineService_1
|
|
|
5043
5609
|
delegation_service_1.DelegationService,
|
|
5044
5610
|
notification_service_1.NotificationService,
|
|
5045
5611
|
signature_service_1.SignatureService,
|
|
5046
|
-
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])
|
|
5047
5613
|
], WorkflowEngineService);
|
|
5048
5614
|
function parseJsonObject(value, label) {
|
|
5049
5615
|
try {
|
|
@@ -5066,6 +5632,74 @@ function readDefaultInitiatorMetadataSnapshot(memberId) {
|
|
|
5066
5632
|
memberId,
|
|
5067
5633
|
};
|
|
5068
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
|
+
}
|
|
5069
5703
|
function createWorkflowAuthContext(memberId) {
|
|
5070
5704
|
return {
|
|
5071
5705
|
memberId,
|
|
@@ -5108,32 +5742,87 @@ function readDataSourceFieldKeyAtPath(snapshot, fieldPath) {
|
|
|
5108
5742
|
return field && (0, form_1.isFormDataSourceFieldDefinition)(field) ? field.fieldKey : null;
|
|
5109
5743
|
}
|
|
5110
5744
|
/**
|
|
5111
|
-
*
|
|
5112
|
-
*
|
|
5113
|
-
*
|
|
5745
|
+
* Refuses form data whose filterable fields hold a value of the wrong type
|
|
5746
|
+
* (ADR 25 §6). The list query reads these values with type guards, so a wrong
|
|
5747
|
+
* value would silently fall out of every filter; refusing the write is what
|
|
5748
|
+
* keeps filter results trustworthy.
|
|
5749
|
+
*/
|
|
5750
|
+
function assertFilterableFormDataValues(schema, formData, serviceNodeLabel) {
|
|
5751
|
+
const problems = (0, bpm_core_shared_1.readFilterableValueProblems)((0, bpm_core_shared_1.readFilterableFieldPaths)(schema), formData);
|
|
5752
|
+
if (problems.length === 0) {
|
|
5753
|
+
return;
|
|
5754
|
+
}
|
|
5755
|
+
const issues = problems.map((problem) => (0, form_data_issue_1.createFormDataIssue)(readFilterableProblemLocation(schema, problem), 'TYPE', `必須是${problem.expected}`));
|
|
5756
|
+
const details = (0, form_data_issue_1.readFormDataIssueMessage)(issues);
|
|
5757
|
+
throw (0, form_data_issue_1.createFormDataBadRequest)(serviceNodeLabel
|
|
5758
|
+
? `系統節點「${serviceNodeLabel}」要寫入的表單值不符合可篩選欄位的型別(${details}),這次操作沒有生效;請聯絡流程管理者修正此節點的寫入值`
|
|
5759
|
+
: `可篩選欄位的值型別不正確:${details}`, issues);
|
|
5760
|
+
}
|
|
5761
|
+
/** Where a filterable problem sits: a field, a table row, or one cell of a row. */
|
|
5762
|
+
function readFilterableProblemLocation(schema, problem) {
|
|
5763
|
+
const [tableKey, columnKey] = problem.path.split('.');
|
|
5764
|
+
const table = schema?.fields.find((field) => field.fieldKey === tableKey && (0, form_1.isTableFieldDefinition)(field));
|
|
5765
|
+
const column = table && (0, form_1.isTableFieldDefinition)(table) && columnKey !== undefined
|
|
5766
|
+
? table.columns.find((candidate) => candidate.fieldKey === columnKey)
|
|
5767
|
+
: undefined;
|
|
5768
|
+
if (table && (0, form_1.isTableFieldDefinition)(table)) {
|
|
5769
|
+
if (problem.rowIndex === null) {
|
|
5770
|
+
return (0, form_data_issue_1.readFieldIssueLocation)(table);
|
|
5771
|
+
}
|
|
5772
|
+
const rowLocation = (0, form_data_issue_1.readRowIssueLocation)(table, problem.rowIndex);
|
|
5773
|
+
return column ? (0, form_data_issue_1.readCellIssueLocation)(rowLocation, column) : rowLocation;
|
|
5774
|
+
}
|
|
5775
|
+
const field = schema?.fields.find((candidate) => candidate.fieldKey === problem.path);
|
|
5776
|
+
if (field) {
|
|
5777
|
+
return (0, form_data_issue_1.readFieldIssueLocation)(field);
|
|
5778
|
+
}
|
|
5779
|
+
// Not in the schema (the check and this lookup read the same schema, so
|
|
5780
|
+
// this is defensive): still address it the way FormDataIssue.path promises,
|
|
5781
|
+
// `table[i].col` for a row value rather than the dotted filter path.
|
|
5782
|
+
const row = `${tableKey}[${problem.rowIndex}]`;
|
|
5783
|
+
const path = problem.rowIndex === null
|
|
5784
|
+
? problem.path
|
|
5785
|
+
: columnKey === undefined
|
|
5786
|
+
? row
|
|
5787
|
+
: `${row}.${columnKey}`;
|
|
5788
|
+
return { path, ref: `欄位「${path}」` };
|
|
5789
|
+
}
|
|
5790
|
+
/**
|
|
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
|
|
5114
5798
|
* that reached the node: the value is never half-written, and the node's value
|
|
5115
5799
|
* has to be corrected. Only a top-level field can carry those rules — the
|
|
5116
5800
|
* writer ignores an indexed path, and a nested record path names no field —
|
|
5117
5801
|
* and an unreadable snapshot is left alone, as in
|
|
5118
5802
|
* `readDataSourceFieldKeyAtPath`.
|
|
5119
5803
|
*/
|
|
5120
|
-
function
|
|
5804
|
+
function assertServiceTaskFieldValue(snapshot, fieldPath, value, nodeLabel) {
|
|
5121
5805
|
const schema = snapshot?.schema;
|
|
5122
5806
|
const segments = normalizeFormFieldPath(fieldPath);
|
|
5123
5807
|
if (!isFormDefinitionSchema(schema) || segments.length !== 1) {
|
|
5124
5808
|
return;
|
|
5125
5809
|
}
|
|
5126
5810
|
const field = schema.fields.find((candidate) => candidate.fieldKey === segments[0]);
|
|
5811
|
+
const written = { [segments[0]]: value };
|
|
5127
5812
|
const errors = field
|
|
5128
|
-
?
|
|
5129
|
-
|
|
5130
|
-
|
|
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
|
+
]
|
|
5131
5820
|
: [];
|
|
5132
5821
|
if (!field || errors.length === 0) {
|
|
5133
5822
|
return;
|
|
5134
5823
|
}
|
|
5135
|
-
throw
|
|
5136
|
-
|
|
5824
|
+
throw (0, form_data_issue_1.createFormDataBadRequest)(`系統節點「${nodeLabel}」要寫入的值不符合欄位規則(${(0, form_data_issue_1.readFormDataIssueMessage)(errors)}),` +
|
|
5825
|
+
`這次操作沒有生效;請聯絡流程管理者修正此節點的寫入值`, errors);
|
|
5137
5826
|
}
|
|
5138
5827
|
function readSnapshotWithoutField(snapshots, fieldKey) {
|
|
5139
5828
|
return Object.fromEntries(Object.entries(snapshots ?? {}).filter(([key]) => key !== fieldKey));
|
|
@@ -5559,6 +6248,24 @@ function isOrderedSequentialTask(task, candidates) {
|
|
|
5559
6248
|
function isInitiatorExcluded(node) {
|
|
5560
6249
|
return Boolean(node.data.excludeInitiator);
|
|
5561
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
|
+
}
|
|
5562
6269
|
// Everyone a node that excludes the initiator keeps out of its approvers: the
|
|
5563
6270
|
// applicant and, when someone filed the document for them, that person too
|
|
5564
6271
|
// (authority rule: the creator must not approve what they created).
|
|
@@ -5600,6 +6307,27 @@ function collectSkippedDelegations(delegatedCandidates, excludeInitiator, exclud
|
|
|
5600
6307
|
return [skipped];
|
|
5601
6308
|
});
|
|
5602
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
|
+
}
|
|
5603
6331
|
// A delegate acting for the initiator is still the initiator approving their
|
|
5604
6332
|
// own document, so the initiator anywhere on the delegation path (the
|
|
5605
6333
|
// pre-delegation member, any hop, or the final assignee) drops the candidate.
|
|
@@ -6175,8 +6903,43 @@ function validateSubmittedFormData(schema, formData, options = {}) {
|
|
|
6175
6903
|
.filter((field) => field.hidden === true)
|
|
6176
6904
|
.flatMap((field) => validateSubmittedTableValue(field, formData[field.fieldKey], false, testPattern, true)),
|
|
6177
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),
|
|
6178
6910
|
});
|
|
6179
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
|
+
}
|
|
6180
6943
|
/**
|
|
6181
6944
|
* What an approval at a node has to leave behind: every field the node lets the
|
|
6182
6945
|
* approver fill in is well-formed (a table is an array of well-formed rows with
|
|
@@ -6207,30 +6970,31 @@ function validateFilledFormFields(schema, formData, fieldKeys, requiredFieldKeys
|
|
|
6207
6970
|
* The shared tail of submit and approver-fill validation: a missing required
|
|
6208
6971
|
* field wins over a text value breaking its length or format rule, which wins
|
|
6209
6972
|
* over a malformed member value, which wins over a malformed org unit value,
|
|
6210
|
-
* which wins over a table problem,
|
|
6211
|
-
*
|
|
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
|
|
6975
|
+
* when the earlier ones pass. The refusal carries every issue of the winning
|
|
6976
|
+
* check. Its message is those issues' sentences joined, except for missing
|
|
6977
|
+
* required fields, which keep the one-line summary `缺少必填欄位:A, B` that
|
|
6978
|
+
* submitters read; each issue there still says `欄位「A」為必填`. A client
|
|
6979
|
+
* that places errors on fields reads the issues, not the message.
|
|
6212
6980
|
*/
|
|
6213
|
-
function throwFirstFormDataFieldError({ missingFields, readMemberErrors, readOrgUnitErrors, readTableErrors, readTextErrors, }) {
|
|
6981
|
+
function throwFirstFormDataFieldError({ missingFields, readFileUploadErrors = () => [], readMemberErrors, readOrgUnitErrors, readTableErrors, readTextErrors, }) {
|
|
6214
6982
|
if (missingFields.length > 0) {
|
|
6215
|
-
throw
|
|
6983
|
+
throw (0, form_data_issue_1.createFormDataBadRequest)(`缺少必填欄位:${missingFields
|
|
6216
6984
|
.map((field) => field.label || field.fieldKey)
|
|
6217
|
-
.join(', ')}
|
|
6218
|
-
}
|
|
6219
|
-
const textErrors = readTextErrors();
|
|
6220
|
-
if (textErrors.length > 0) {
|
|
6221
|
-
throw new common_1.BadRequestException(textErrors.join('; '));
|
|
6222
|
-
}
|
|
6223
|
-
const memberErrors = readMemberErrors();
|
|
6224
|
-
if (memberErrors.length > 0) {
|
|
6225
|
-
throw new common_1.BadRequestException(memberErrors.join('; '));
|
|
6985
|
+
.join(', ')}`, missingFields.map((field) => (0, form_data_issue_1.createFormDataIssue)((0, form_data_issue_1.readFieldIssueLocation)(field), 'REQUIRED', '為必填')));
|
|
6226
6986
|
}
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
|
|
6233
|
-
|
|
6987
|
+
// Each check runs only when every earlier one passed, so a later check's
|
|
6988
|
+
// work is never done for a request that is already refused.
|
|
6989
|
+
const winning = [
|
|
6990
|
+
readTextErrors,
|
|
6991
|
+
readMemberErrors,
|
|
6992
|
+
readOrgUnitErrors,
|
|
6993
|
+
readTableErrors,
|
|
6994
|
+
readFileUploadErrors,
|
|
6995
|
+
].reduce((found, check) => (found.length > 0 ? found : check()), []);
|
|
6996
|
+
if (winning.length > 0) {
|
|
6997
|
+
throw (0, form_data_issue_1.createFormDataBadRequest)((0, form_data_issue_1.readFormDataIssueMessage)(winning), winning);
|
|
6234
6998
|
}
|
|
6235
6999
|
}
|
|
6236
7000
|
/**
|
|
@@ -6239,64 +7003,73 @@ function throwFirstFormDataFieldError({ missingFields, readMemberErrors, readOrg
|
|
|
6239
7003
|
* the flat text fields share. `minimum` / `maximum` stay a frontend hint.
|
|
6240
7004
|
*/
|
|
6241
7005
|
function validateSubmittedTableValue(field, value, required, testPattern, shapeOnly = false) {
|
|
6242
|
-
const
|
|
7006
|
+
const location = (0, form_data_issue_1.readFieldIssueLocation)(field);
|
|
6243
7007
|
if (typeof value === 'undefined' || value === null) {
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
6247
|
-
|
|
6248
|
-
|
|
7008
|
+
if (shapeOnly) {
|
|
7009
|
+
return [];
|
|
7010
|
+
}
|
|
7011
|
+
if (required || readTableMinRows(field) > 0) {
|
|
7012
|
+
const rows = readTableRequiredRowCount(field);
|
|
7013
|
+
return [
|
|
7014
|
+
(0, form_data_issue_1.createFormDataIssue)(location, 'MIN_ROWS', `至少需要 ${rows} 列`, rows),
|
|
7015
|
+
];
|
|
7016
|
+
}
|
|
7017
|
+
return [];
|
|
6249
7018
|
}
|
|
6250
7019
|
if (!Array.isArray(value)) {
|
|
6251
|
-
return [
|
|
7020
|
+
return [(0, form_data_issue_1.createFormDataIssue)(location, 'TYPE', '必須是表格列的清單')];
|
|
6252
7021
|
}
|
|
6253
7022
|
// Too many rows is refused before any row is checked: the rows a submitter
|
|
6254
7023
|
// sends would otherwise decide how many cells the text patterns run on.
|
|
6255
7024
|
const maxRows = readTableMaxRows(field);
|
|
6256
7025
|
if (value.length > maxRows) {
|
|
6257
|
-
return [
|
|
7026
|
+
return [
|
|
7027
|
+
(0, form_data_issue_1.createFormDataIssue)(location, 'MAX_ROWS', `最多 ${maxRows} 列`, maxRows),
|
|
7028
|
+
];
|
|
6258
7029
|
}
|
|
6259
|
-
const rowErrors = value.flatMap((row, index) => validateSubmittedTableRow(field, row,
|
|
7030
|
+
const rowErrors = value.flatMap((row, index) => validateSubmittedTableRow(field, row, (0, form_data_issue_1.readRowIssueLocation)(field, index), testPattern, shapeOnly));
|
|
6260
7031
|
// A malformed row cannot be counted against the minimum meaningfully; report
|
|
6261
7032
|
// the shape problem first so the submitter fixes the real cause.
|
|
6262
7033
|
if (rowErrors.length > 0) {
|
|
6263
7034
|
return rowErrors;
|
|
6264
7035
|
}
|
|
6265
|
-
return validateSubmittedTableRowCount(field, value.length, required,
|
|
7036
|
+
return validateSubmittedTableRowCount(field, value.length, required, location, shapeOnly);
|
|
6266
7037
|
}
|
|
6267
7038
|
/** The minimum only: the maximum is refused before any row is checked. */
|
|
6268
|
-
function validateSubmittedTableRowCount(field, rowCount, required,
|
|
7039
|
+
function validateSubmittedTableRowCount(field, rowCount, required, location, shapeOnly = false) {
|
|
6269
7040
|
const minRows = shapeOnly
|
|
6270
7041
|
? 0
|
|
6271
7042
|
: required
|
|
6272
7043
|
? Math.max(readTableMinRows(field), 1)
|
|
6273
7044
|
: readTableMinRows(field);
|
|
6274
7045
|
return rowCount < minRows
|
|
6275
|
-
? [
|
|
7046
|
+
? [
|
|
7047
|
+
(0, form_data_issue_1.createFormDataIssue)(location, 'MIN_ROWS', `至少需要 ${minRows} 列`, minRows),
|
|
7048
|
+
]
|
|
6276
7049
|
: [];
|
|
6277
7050
|
}
|
|
6278
|
-
function validateSubmittedTableRow(field, row,
|
|
7051
|
+
function validateSubmittedTableRow(field, row, location, testPattern, shapeOnly = false) {
|
|
6279
7052
|
if (!isRecord(row)) {
|
|
6280
|
-
return [
|
|
7053
|
+
return [(0, form_data_issue_1.createFormDataIssue)(location, 'TYPE', '必須是物件')];
|
|
6281
7054
|
}
|
|
6282
7055
|
const columnsByKey = new Map(field.columns.map((column) => [column.fieldKey, column]));
|
|
6283
7056
|
const unknownKeyErrors = Object.keys(row)
|
|
6284
7057
|
.filter((columnKey) => !columnsByKey.has(columnKey))
|
|
6285
|
-
.map((columnKey) =>
|
|
7058
|
+
.map((columnKey) => (0, form_data_issue_1.createFormDataIssue)(location, 'UNKNOWN_COLUMN', `含有不屬於此表格的欄位「${columnKey}」`));
|
|
6286
7059
|
const cellErrors = Object.entries(row)
|
|
6287
7060
|
.filter(([columnKey, cellValue]) => columnsByKey.has(columnKey) && !(0, form_1.isFormTableCellValue)(cellValue))
|
|
6288
|
-
.map(([columnKey]) =>
|
|
7061
|
+
.map(([columnKey]) => (0, form_data_issue_1.createFormDataIssue)((0, form_data_issue_1.readCellIssueLocation)(location, columnsByKey.get(columnKey) ?? { fieldKey: columnKey }), 'TYPE', '不是有效的值'));
|
|
6289
7062
|
const requiredErrors = shapeOnly
|
|
6290
7063
|
? []
|
|
6291
7064
|
: field.columns
|
|
6292
7065
|
.filter((column) => isSubmittedTableColumnRequired(column) &&
|
|
6293
7066
|
!isSubmittedFieldValuePresent(readRowCellValue(row, column.fieldKey)))
|
|
6294
|
-
.map((column) =>
|
|
7067
|
+
.map((column) => (0, form_data_issue_1.createFormDataIssue)((0, form_data_issue_1.readCellIssueLocation)(location, column), 'REQUIRED', '為必填'));
|
|
6295
7068
|
// A shape-only pass covers a table nobody fills in, so the filler-facing
|
|
6296
7069
|
// rules do not apply to it, the same as the required check above; only a
|
|
6297
7070
|
// cell of the wrong type is still refused.
|
|
6298
7071
|
const textErrors = field.columns.flatMap((column) => readSubmittedTextValueErrors(column, readRowCellValue(row, column.fieldKey), {
|
|
6299
|
-
|
|
7072
|
+
location: (0, form_data_issue_1.readCellIssueLocation)(location, column),
|
|
6300
7073
|
shapeOnly,
|
|
6301
7074
|
testPattern,
|
|
6302
7075
|
}));
|
|
@@ -6313,7 +7086,7 @@ function validateSubmittedTableRow(field, row, path, testPattern, shapeOnly = fa
|
|
|
6313
7086
|
* budget is spent, a value is reported as not checked rather than as a
|
|
6314
7087
|
* format error.
|
|
6315
7088
|
*/
|
|
6316
|
-
function readSubmittedTextValueErrors(field, value, {
|
|
7089
|
+
function readSubmittedTextValueErrors(field, value, { location = (0, form_data_issue_1.readFieldIssueLocation)(field), shapeOnly = false, testPattern, }) {
|
|
6317
7090
|
if (field.type !== 'text' && field.type !== 'textarea') {
|
|
6318
7091
|
return [];
|
|
6319
7092
|
}
|
|
@@ -6332,21 +7105,27 @@ function readSubmittedTextValueErrors(field, value, { path = `formData.${field.f
|
|
|
6332
7105
|
};
|
|
6333
7106
|
const issue = readIssue();
|
|
6334
7107
|
if (issue === 'timeout') {
|
|
6335
|
-
return [
|
|
7108
|
+
return [
|
|
7109
|
+
(0, form_data_issue_1.createFormDataIssue)(location, 'PATTERN_TIMEOUT', '無法在時限內完成格式檢查'),
|
|
7110
|
+
];
|
|
6336
7111
|
}
|
|
6337
7112
|
if (!issue || (shapeOnly && issue.kind !== 'type')) {
|
|
6338
7113
|
return [];
|
|
6339
7114
|
}
|
|
6340
7115
|
if (issue.kind === 'type') {
|
|
6341
|
-
return [
|
|
7116
|
+
return [(0, form_data_issue_1.createFormDataIssue)(location, 'TYPE', '必須是文字')];
|
|
6342
7117
|
}
|
|
6343
7118
|
if (issue.kind === 'minLength') {
|
|
6344
|
-
return [
|
|
7119
|
+
return [
|
|
7120
|
+
(0, form_data_issue_1.createFormDataIssue)(location, 'MIN_LENGTH', `至少需要 ${issue.limit} 個字`, issue.limit),
|
|
7121
|
+
];
|
|
6345
7122
|
}
|
|
6346
7123
|
if (issue.kind === 'maxLength') {
|
|
6347
|
-
return [
|
|
7124
|
+
return [
|
|
7125
|
+
(0, form_data_issue_1.createFormDataIssue)(location, 'MAX_LENGTH', `最多 ${issue.limit} 個字`, issue.limit),
|
|
7126
|
+
];
|
|
6348
7127
|
}
|
|
6349
|
-
return [
|
|
7128
|
+
return [(0, form_data_issue_1.createFormDataIssue)(location, 'PATTERN', '格式不正確')];
|
|
6350
7129
|
}
|
|
6351
7130
|
/**
|
|
6352
7131
|
* Own-property read. `constructor`, `toString` and friends all satisfy the
|
|
@@ -6593,7 +7372,7 @@ function readDryRunPlaceholderAssignee(resolver) {
|
|
|
6593
7372
|
return `nodeActorManager:${resolver.nodeIds.join(',')}:${resolver.levelsUp}`;
|
|
6594
7373
|
}
|
|
6595
7374
|
return resolver.type === 'ORG_MANAGER_CHAIN'
|
|
6596
|
-
?
|
|
7375
|
+
? (0, workflow_1.formatOrgManagerChainPlaceholder)(resolver)
|
|
6597
7376
|
: resolver.type;
|
|
6598
7377
|
}
|
|
6599
7378
|
function readDryRunUnresolvedApproverMessage(resolver, nodeName, initiatorMemberId) {
|