@rytass/bpm-core-nestjs-module 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +10 -0
  3. package/package.json +2 -2
  4. package/src/lib/attachment/attachment.service.js +6 -1
  5. package/src/lib/attachment/attachment.service.js.map +1 -1
  6. package/src/lib/bpm/bpm-root-options.d.ts +2 -1
  7. package/src/lib/bpm/bpm-root-options.js +7 -1
  8. package/src/lib/bpm/bpm-root-options.js.map +1 -1
  9. package/src/lib/form/form-member-value.validator.d.ts +16 -0
  10. package/src/lib/form/form-member-value.validator.js +37 -0
  11. package/src/lib/form/form-member-value.validator.js.map +1 -0
  12. package/src/lib/form/form-schema.validator.js +90 -1
  13. package/src/lib/form/form-schema.validator.js.map +1 -1
  14. package/src/lib/migrations/0000000026000-approval-instance-business-key.d.ts +20 -0
  15. package/src/lib/migrations/0000000026000-approval-instance-business-key.js +44 -0
  16. package/src/lib/migrations/0000000026000-approval-instance-business-key.js.map +1 -0
  17. package/src/lib/migrations/0000000027000-approval-instance-submitted-by.d.ts +19 -0
  18. package/src/lib/migrations/0000000027000-approval-instance-submitted-by.js +42 -0
  19. package/src/lib/migrations/0000000027000-approval-instance-submitted-by.js.map +1 -0
  20. package/src/lib/migrations/index.d.ts +2 -0
  21. package/src/lib/migrations/index.js +6 -0
  22. package/src/lib/migrations/index.js.map +1 -1
  23. package/src/lib/notification/notification-template.js +1 -1
  24. package/src/lib/notification/notification-template.js.map +1 -1
  25. package/src/lib/notification/notification.service.d.ts +16 -0
  26. package/src/lib/notification/notification.service.js +54 -0
  27. package/src/lib/notification/notification.service.js.map +1 -1
  28. package/src/lib/organization/index.d.ts +1 -0
  29. package/src/lib/organization/index.js +1 -0
  30. package/src/lib/organization/index.js.map +1 -1
  31. package/src/lib/organization/organization-time-zone.d.ts +46 -0
  32. package/src/lib/organization/organization-time-zone.js +47 -0
  33. package/src/lib/organization/organization-time-zone.js.map +1 -0
  34. package/src/lib/organization/organization.service.d.ts +3 -1
  35. package/src/lib/organization/organization.service.js +8 -11
  36. package/src/lib/organization/organization.service.js.map +1 -1
  37. package/src/lib/template/template.service.js +64 -0
  38. package/src/lib/template/template.service.js.map +1 -1
  39. package/src/lib/workflow-engine/approval-instance.entity.d.ts +13 -0
  40. package/src/lib/workflow-engine/approval-instance.entity.js +20 -0
  41. package/src/lib/workflow-engine/approval-instance.entity.js.map +1 -1
  42. package/src/lib/workflow-engine/instance-submitter.d.ts +2 -0
  43. package/src/lib/workflow-engine/instance-submitter.js +14 -0
  44. package/src/lib/workflow-engine/instance-submitter.js.map +1 -0
  45. package/src/lib/workflow-engine/task.entity.d.ts +9 -0
  46. package/src/lib/workflow-engine/task.entity.js +13 -0
  47. package/src/lib/workflow-engine/task.entity.js.map +1 -1
  48. package/src/lib/workflow-engine/workflow-engine.mutations.js +5 -0
  49. package/src/lib/workflow-engine/workflow-engine.mutations.js.map +1 -1
  50. package/src/lib/workflow-engine/workflow-engine.service.d.ts +73 -6
  51. package/src/lib/workflow-engine/workflow-engine.service.js +697 -221
  52. package/src/lib/workflow-engine/workflow-engine.service.js.map +1 -1
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.WorkflowEngineService = exports.MANUAL_TRANSFER_DELEGATION_REASON = void 0;
3
+ exports.WorkflowEngineService = exports.MANUAL_TRANSFER_DELEGATION_REASON = exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH = void 0;
4
4
  const tslib_1 = require("tslib");
5
+ const class_validator_1 = require("class-validator");
5
6
  const form_1 = require("@rytass/bpm-core-shared/form");
6
7
  const workflow_graph_1 = require("@rytass/bpm-core-shared/workflow-graph");
7
8
  const workflow_webhook_delivery_service_1 = require("../workflow-webhook/workflow-webhook-delivery.service");
@@ -13,6 +14,7 @@ const delegation_service_1 = require("../delegation/delegation.service");
13
14
  const form_definition_version_entity_1 = require("../form/form-definition-version.entity");
14
15
  const form_enums_1 = require("../form/form.enums");
15
16
  const form_data_source_errors_1 = require("../form-data-source/form-data-source.errors");
17
+ const form_member_value_validator_1 = require("../form/form-member-value.validator");
16
18
  const form_data_source_types_1 = require("../form-data-source/form-data-source.types");
17
19
  const sla_schedule_service_1 = require("../calendar/sla-schedule.service");
18
20
  const notification_service_1 = require("../notification/notification.service");
@@ -25,6 +27,7 @@ const membership_entity_1 = require("../organization/membership.entity");
25
27
  const org_unit_entity_1 = require("../organization/org-unit.entity");
26
28
  const position_entity_1 = require("../organization/position.entity");
27
29
  const organization_enums_1 = require("../organization/organization.enums");
30
+ const organization_time_zone_1 = require("../organization/organization-time-zone");
28
31
  const approval_template_version_entity_1 = require("../template/approval-template-version.entity");
29
32
  const approval_template_entity_1 = require("../template/approval-template.entity");
30
33
  const template_enums_1 = require("../template/template.enums");
@@ -32,6 +35,7 @@ const activity_log_entity_1 = require("./activity-log.entity");
32
35
  const adhoc_directive_entity_1 = require("./adhoc-directive.entity");
33
36
  const adhoc_enums_1 = require("./adhoc.enums");
34
37
  const approval_instance_entity_1 = require("./approval-instance.entity");
38
+ const instance_submitter_1 = require("./instance-submitter");
35
39
  const task_decision_entity_1 = require("./task-decision.entity");
36
40
  const task_candidate_entity_1 = require("./task-candidate.entity");
37
41
  const task_entity_1 = require("./task.entity");
@@ -42,6 +46,8 @@ const workflow_service_task_dispatcher_token_1 = require("./workflow-service-tas
42
46
  const workflow_token_entity_1 = require("./workflow-token.entity");
43
47
  const bpm_root_options_1 = require("../bpm/bpm-root-options");
44
48
  const MAX_PROCESSING_STEPS = 500;
49
+ exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH = 200;
50
+ const APPROVAL_INSTANCE_BUSINESS_KEY_INDEX = 'approval_instances_business_key_key';
45
51
  // Guards ORG_MANAGER_CHAIN against misconfigured manager rules that never end.
46
52
  const ORG_MANAGER_CHAIN_MAX_HOPS = 20;
47
53
  const DEFAULT_APPROVAL_HISTORY_TASK_LIMIT = 50;
@@ -92,9 +98,27 @@ let WorkflowEngineService = class WorkflowEngineService {
92
98
  injectedServiceTaskDispatcher ??
93
99
  rootOptions?.workflowServiceTaskDispatcher ??
94
100
  new workflow_service_task_dispatcher_token_1.DefaultWorkflowServiceTaskDispatcher();
101
+ this.organizationTimeZone = (0, organization_time_zone_1.resolveBPMOrganizationTimeZone)(rootOptions?.organizationTimeZone);
102
+ }
103
+ /**
104
+ * Today's date as the organization sees it (`organizationTimeZone`), for
105
+ * comparing against the date-only `effectiveFrom` / `effectiveTo` of
106
+ * memberships and manager resolutions. Not for timestamp columns, which are
107
+ * compared as instants.
108
+ */
109
+ readOrganizationToday() {
110
+ return (0, organization_time_zone_1.formatDateOnlyInTimeZone)(new Date(), this.organizationTimeZone);
95
111
  }
96
112
  async submitApprovalInstance(input, authContext) {
113
+ if (input.submittedByMemberId !== undefined &&
114
+ input.submittedByMemberId !== null &&
115
+ (typeof input.submittedByMemberId !== 'string' ||
116
+ input.submittedByMemberId.trim() === '')) {
117
+ throw new common_1.BadRequestException('submittedByMemberId must be a non-empty string');
118
+ }
97
119
  const formData = parseJsonObject(input.formDataJson, 'formDataJson');
120
+ const businessKey = normalizeBusinessKey(input.businessKey);
121
+ const submittedByMemberId = input.submittedByMemberId ?? input.initiatorMemberId;
98
122
  const template = await this.getTemplateOrThrow(input.templateId);
99
123
  if (!template.isActive) {
100
124
  throw new common_1.ConflictException('Approval template is deactivated');
@@ -117,6 +141,12 @@ let WorkflowEngineService = class WorkflowEngineService {
117
141
  if (!this.conditionService.evaluateBoolean(templateVersion.initiatorPolicyCel, buildInitiatorPolicyContext(initiatorMetadataSnapshot), 'initiatorPolicyCel')) {
118
142
  throw new common_1.ConflictException('Current member is not allowed to start this approval template');
119
143
  }
144
+ // Named here, before the transaction, so the completion notice that later
145
+ // says "由 X 代為送出" reads it from the started activity instead of
146
+ // asking the identity source while an approval holds the instance's locks.
147
+ const submittedByName = submittedByMemberId === input.initiatorMemberId
148
+ ? null
149
+ : await this.notificationService.readMemberDisplayName(submittedByMemberId);
120
150
  const startedAt = new Date();
121
151
  const startNode = templateVersion.workflowDefinition.nodes.find((node) => node.type === 'startEvent');
122
152
  if (!startNode) {
@@ -136,10 +166,12 @@ let WorkflowEngineService = class WorkflowEngineService {
136
166
  uiSchema: formDefinitionVersion.uiSchema,
137
167
  version: formDefinitionVersion.version,
138
168
  },
169
+ businessKey,
139
170
  initiatorMemberId: input.initiatorMemberId,
140
171
  initiatorMetadataSnapshot,
141
172
  startedAt,
142
173
  state: workflow_engine_enums_1.ApprovalInstanceStateEnum.RUNNING,
174
+ submittedByMemberId,
143
175
  templateId: template.id,
144
176
  templateVersionId: templateVersion.id,
145
177
  title: input.title?.trim() || template.name,
@@ -163,6 +195,8 @@ let WorkflowEngineService = class WorkflowEngineService {
163
195
  instanceId: instance.id,
164
196
  nodeId: startNode.id,
165
197
  payload: {
198
+ submittedByMemberId,
199
+ ...(submittedByName === null ? {} : { submittedByName }),
166
200
  templateId: template.id,
167
201
  templateVersionId: templateVersion.id,
168
202
  },
@@ -181,6 +215,28 @@ let WorkflowEngineService = class WorkflowEngineService {
181
215
  ]);
182
216
  await this.processRunningInstance(manager, instance);
183
217
  return instance;
218
+ }).catch((error) =>
219
+ // The insert decides, not a pre-check: two concurrent submits would both
220
+ // pass a check, and only the unique index sees them as one.
221
+ throwBusinessKeyConflictOrRethrow(error, businessKey));
222
+ }
223
+ /**
224
+ * The instance a host submitted for `businessKey` on `templateId`, whatever
225
+ * its state (a cancelled or rejected instance still holds its key), or
226
+ * `null`. The key is trimmed the way submit trims it, so a lookup finds what
227
+ * a submit stored.
228
+ */
229
+ async findApprovalInstanceByBusinessKey(templateId, businessKey) {
230
+ const normalizedKey = businessKey.trim();
231
+ // A template id that is not a uuid cannot match a row, and handing it to
232
+ // Postgres would raise 22P02 (a 500) instead of "not found".
233
+ if (!(0, class_validator_1.isUUID)(templateId) ||
234
+ normalizedKey === '' ||
235
+ countCodePoints(normalizedKey) > exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH) {
236
+ return null;
237
+ }
238
+ return this.approvalInstanceRepository.findOne({
239
+ where: { businessKey: normalizedKey, templateId },
184
240
  });
185
241
  }
186
242
  async resolveFormDataOptionSnapshots({ authContext, formData, previousFormData, previousSnapshots, revalidateAll, schema, }) {
@@ -324,6 +380,27 @@ let WorkflowEngineService = class WorkflowEngineService {
324
380
  transferToMemberId === instance.initiatorMemberId) {
325
381
  throw new common_1.ConflictException(`簽核節點「${taskNode.data.label}」不允許申請人自簽,不能將任務轉交給申請人本人`);
326
382
  }
383
+ if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED &&
384
+ options.transferReason === undefined &&
385
+ isInitiatorExcluded(taskNode) &&
386
+ transferToMemberId !== null &&
387
+ transferToMemberId === (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance)) {
388
+ throw new common_1.ConflictException(`簽核節點「${taskNode.data.label}」不允許代送人簽核,不能將任務轉交給代為送出此單的人`);
389
+ }
390
+ // Same carve-out: a human picking the target on a manager-chain node
391
+ // must pick someone at least as senior as the member being replaced.
392
+ if (input.action === workflow_engine_enums_1.TaskDecisionActionEnum.TRANSFERRED &&
393
+ options.transferReason === undefined &&
394
+ transferToMemberId !== null &&
395
+ !task.isAdhoc &&
396
+ isOrgManagerChainNode(taskNode)) {
397
+ await this.assertTransferTargetGrade(manager, {
398
+ actorMemberId: input.decidedByMemberId,
399
+ delegationChain: actorCandidate?.delegationChain ?? task.delegationChain,
400
+ label: taskNode.data.label,
401
+ targetMemberId: transferToMemberId,
402
+ });
403
+ }
327
404
  const returnToNodeId = input.action === workflow_engine_enums_1.TaskDecisionActionEnum.RETURNED
328
405
  ? readReturnTargetNodeId(instance.workflowSnapshot, task.nodeId, input.returnToNodeId)
329
406
  : null;
@@ -850,6 +927,7 @@ let WorkflowEngineService = class WorkflowEngineService {
850
927
  })));
851
928
  task.candidateMemberIds = savedCandidates.map((candidate) => candidate.memberId);
852
929
  task.openCandidateMemberIds = task.candidateMemberIds;
930
+ task.nodeLabel = readWorkflowNodeLabel(instance.workflowSnapshot, node.id);
853
931
  await activityRepository.save(activityRepository.create({
854
932
  actorMemberId: directive.createdByMemberId,
855
933
  eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TASK_CREATED,
@@ -882,14 +960,18 @@ let WorkflowEngineService = class WorkflowEngineService {
882
960
  return task;
883
961
  }
884
962
  async resolveAdhocTaskCandidates(manager, instance, node, label, targetValue) {
885
- const resolvedCandidates = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), `${label}「${node.data.label}」`);
886
- const delegatedCandidates = await this.delegateResolvedCandidates(instance, node.id, resolvedCandidates);
887
- const candidates = selectNodeCandidates(delegatedCandidates, isInitiatorExcluded(node), instance.initiatorMemberId);
963
+ const resolvedCandidates = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), `${label}「${node.data.label}」`, '簽核人', isInitiatorExcluded(node) ? readInitiatorAndSubmitter(instance) : []);
964
+ // Ad-hoc approvers are picked by a person, not walked up a manager chain,
965
+ // so the chain's grade rule does not apply to them.
966
+ const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, false);
967
+ const candidates = selectNodeCandidates(delegatedCandidates, isInitiatorExcluded(node), readInitiatorAndSubmitter(instance));
888
968
  if (candidates.length > 0) {
889
969
  return candidates;
890
970
  }
891
971
  throw new common_1.ConflictException(delegatedCandidates.length > 0
892
- ? `${label}「${node.data.label}」 無法建立任務:指定對象只有申請人本人,此節點不允許申請人簽核。`
972
+ ? (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) === null
973
+ ? `${label}「${node.data.label}」 無法建立任務:指定對象只有申請人本人,此節點不允許申請人簽核。`
974
+ : `${label}「${node.data.label}」 無法建立任務:指定對象只有申請人或代為送出此單的人,此節點不允許申請人或代送人簽核。`
893
975
  : `${label}「${node.data.label}」 did not resolve to any member id`);
894
976
  }
895
977
  async spawnCountersignTasksForNode(manager, instance, token, node, now) {
@@ -1277,7 +1359,7 @@ let WorkflowEngineService = class WorkflowEngineService {
1277
1359
  return;
1278
1360
  }
1279
1361
  try {
1280
- const recipients = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), '臨時通知對象');
1362
+ const recipients = await this.resolveApproverResolver(manager, instance, buildAdhocApproverResolver(targetValue), '臨時通知對象', '通知對象');
1281
1363
  await this.notificationService.createAdhocWorkflowNotifications({
1282
1364
  channels: directive.channels,
1283
1365
  instance,
@@ -1395,6 +1477,7 @@ let WorkflowEngineService = class WorkflowEngineService {
1395
1477
  : null;
1396
1478
  const assigneeMemberId = assignment?.assigneeMemberId ?? null;
1397
1479
  const skippedByExclusion = assignment?.outcome === 'SKIPPED';
1480
+ const stoppedByAssignment = assignment?.outcome === 'STOPPED';
1398
1481
  const nextLastDecision = entryConditionMet && node.type === 'userTask' && !skippedByExclusion
1399
1482
  ? {
1400
1483
  action: workflow_engine_enums_1.TaskDecisionActionEnum.APPROVED,
@@ -1402,6 +1485,15 @@ let WorkflowEngineService = class WorkflowEngineService {
1402
1485
  nodeId: node.id,
1403
1486
  }
1404
1487
  : input.lastDecision;
1488
+ // A node that stops on its assignment, an asynchronous NOTIFY node (at
1489
+ // runtime it consumes its token and never advances) and an end event do
1490
+ // not route any further.
1491
+ const routes = !stoppedByAssignment &&
1492
+ !(0, workflow_graph_1.isAsyncNotifyServiceTask)(node) &&
1493
+ node.type !== 'endEvent';
1494
+ const outgoing = routes
1495
+ ? this.readDryRunOutgoing(input, node, nextLastDecision, entryConditionMet && !skippedByExclusion)
1496
+ : { edges: [], stopMessage: null };
1405
1497
  const nextSteps = [
1406
1498
  ...input.steps,
1407
1499
  createDryRunStep({
@@ -1411,39 +1503,16 @@ let WorkflowEngineService = class WorkflowEngineService {
1411
1503
  edgeReason: readDryRunEdgeReason(input.incomingEdge),
1412
1504
  entryCondition,
1413
1505
  entryConditionMet,
1414
- message: assignment?.message ?? readDryRunStepMessage(node, entryConditionMet),
1506
+ message: readDryRunStepResultMessage(assignment?.message ?? null, outgoing.stopMessage, readDryRunStepMessage(node, entryConditionMet), !entryConditionMet || skippedByExclusion),
1415
1507
  node,
1416
- status: assignment?.outcome ?? readDryRunStepStatus(node, entryConditionMet),
1508
+ status: outgoing.stopMessage === null
1509
+ ? (assignment?.outcome ??
1510
+ readDryRunStepStatus(node, entryConditionMet))
1511
+ : 'STOPPED',
1417
1512
  stepIndex: input.steps.length,
1418
1513
  }),
1419
1514
  ];
1420
- // The initiator is the only approver and the node cannot proceed: this
1421
- // branch ends here, as it would fail at runtime.
1422
- if (assignment?.outcome === 'STOPPED') {
1423
- return nextSteps;
1424
- }
1425
- // A NOTIFY node is an asynchronous branch: at runtime it consumes its
1426
- // token and never advances, so its branch ends here rather than failing
1427
- // for want of an outgoing edge it is not allowed to have.
1428
- if ((0, workflow_graph_1.isAsyncNotifyServiceTask)(node)) {
1429
- return nextSteps;
1430
- }
1431
- if (!entryConditionMet || skippedByExclusion || node.type === 'endEvent') {
1432
- if (node.type === 'endEvent') {
1433
- return nextSteps;
1434
- }
1435
- return readOutgoingEdgesOrThrow(input.workflowDefinition, node.id).reduce((steps, edge) => this.simulateWorkflowPath({
1436
- ...input,
1437
- depth: input.depth + 1,
1438
- incomingEdge: edge,
1439
- lastDecision: nextLastDecision,
1440
- nodeId: edge.target,
1441
- steps,
1442
- visitedNodeIds: nextVisitedNodeIds,
1443
- }), nextSteps);
1444
- }
1445
- const outgoingEdges = this.readDryRunAvailableOutgoingEdges(input, node, nextLastDecision);
1446
- return outgoingEdges.reduce((steps, edge) => this.simulateWorkflowPath({
1515
+ return outgoing.edges.reduce((steps, edge) => this.simulateWorkflowPath({
1447
1516
  ...input,
1448
1517
  depth: input.depth + 1,
1449
1518
  incomingEdge: edge,
@@ -1453,28 +1522,45 @@ let WorkflowEngineService = class WorkflowEngineService {
1453
1522
  visitedNodeIds: nextVisitedNodeIds,
1454
1523
  }), nextSteps);
1455
1524
  }
1456
- readDryRunAvailableOutgoingEdges(input, node, lastDecision) {
1457
- if (node.type === 'endEvent') {
1458
- return [];
1525
+ /**
1526
+ * The edges a dry-run step continues along. A node a designer has left
1527
+ * unfinished (nothing drawn out of it, or no branch of a gateway taken) ends
1528
+ * its branch with a readable message instead of failing the whole dry run.
1529
+ */
1530
+ readDryRunOutgoing(input, node, lastDecision, evaluatesConditions) {
1531
+ const nodeName = readDryRunNodeName(node);
1532
+ const outgoingEdges = input.workflowDefinition.edges.filter((edge) => edge.source === node.id);
1533
+ if (outgoingEdges.length === 0) {
1534
+ return {
1535
+ edges: [],
1536
+ stopMessage: `節點「${nodeName}」沒有連到下一個節點,請在流程設計器為此節點接上連線`,
1537
+ };
1538
+ }
1539
+ if (!evaluatesConditions) {
1540
+ return { edges: outgoingEdges, stopMessage: null };
1459
1541
  }
1460
- const outgoingEdges = readOutgoingEdgesOrThrow(input.workflowDefinition, node.id);
1461
1542
  const conditionalEdges = outgoingEdges.filter((edge) => !edge.data.isDefault && edgeHasCondition(edge));
1462
1543
  if (conditionalEdges.length === 0) {
1463
- return outgoingEdges;
1544
+ return { edges: outgoingEdges, stopMessage: null };
1464
1545
  }
1465
1546
  const matchingEdges = conditionalEdges.filter((edge) => this.evaluateDryRunEdge(input, edge, lastDecision));
1466
1547
  if (matchingEdges.length > 0) {
1467
- return node.type === 'exclusiveGateway'
1468
- ? [matchingEdges[0]]
1469
- : matchingEdges;
1548
+ return {
1549
+ edges: node.type === 'exclusiveGateway' ? [matchingEdges[0]] : matchingEdges,
1550
+ stopMessage: null,
1551
+ };
1470
1552
  }
1471
1553
  const defaultEdges = outgoingEdges.filter((edge) => edge.data.isDefault);
1472
1554
  if (defaultEdges.length > 0) {
1473
- return node.type === 'exclusiveGateway'
1474
- ? [defaultEdges[0]]
1475
- : defaultEdges;
1555
+ return {
1556
+ edges: node.type === 'exclusiveGateway' ? [defaultEdges[0]] : defaultEdges,
1557
+ stopMessage: null,
1558
+ };
1476
1559
  }
1477
- throw new common_1.ConflictException(`Workflow node ${node.id} has no matching outgoing edge`);
1560
+ return {
1561
+ edges: [],
1562
+ stopMessage: `節點「${nodeName}」的連出線條件都不符合,且沒有設定預設路徑,請檢查連出線的條件或補上預設路徑`,
1563
+ };
1478
1564
  }
1479
1565
  evaluateDryRunEdge(input, edge, lastDecision) {
1480
1566
  const expression = edge.data.condition?.trim();
@@ -1491,14 +1577,28 @@ let WorkflowEngineService = class WorkflowEngineService {
1491
1577
  */
1492
1578
  resolveDryRunAssignment(input, node) {
1493
1579
  const resolver = node.data.approverResolver;
1494
- const label = `workflow.nodes.${node.id}.data.approverResolver`;
1495
1580
  const excludeInitiator = node.data.excludeInitiator;
1496
- const memberIds = excludeInitiator
1497
- ? this.readDryRunMemberIds(input, resolver, label)
1498
- : null;
1499
- if (!excludeInitiator || !memberIds?.includes(input.initiatorMemberId)) {
1581
+ const memberIds = this.readDryRunMemberIds(input, resolver, `workflow.nodes.${node.id}.data.approverResolver`);
1582
+ if (memberIds === null) {
1583
+ return {
1584
+ assigneeMemberId: readDryRunPlaceholderAssignee(resolver),
1585
+ message: null,
1586
+ outcome: null,
1587
+ };
1588
+ }
1589
+ // Nobody to ask yet — typically a node whose approver has not been chosen.
1590
+ // The branch ends here and says which node needs attention, rather than
1591
+ // failing the whole dry run.
1592
+ if (memberIds.length === 0) {
1593
+ return {
1594
+ assigneeMemberId: null,
1595
+ message: readDryRunUnresolvedApproverMessage(resolver, readDryRunNodeName(node), input.initiatorMemberId),
1596
+ outcome: 'STOPPED',
1597
+ };
1598
+ }
1599
+ if (!excludeInitiator || !memberIds.includes(input.initiatorMemberId)) {
1500
1600
  return {
1501
- assigneeMemberId: this.resolveDryRunAssigneeMemberId(input, resolver, label),
1601
+ assigneeMemberId: memberIds[0],
1502
1602
  message: null,
1503
1603
  outcome: null,
1504
1604
  };
@@ -1542,48 +1642,29 @@ let WorkflowEngineService = class WorkflowEngineService {
1542
1642
  outcome: 'STOPPED',
1543
1643
  };
1544
1644
  }
1645
+ /**
1646
+ * The member ids a dry run resolves for a user task: an empty list when the
1647
+ * resolver names nobody, `null` for resolvers that only resolve to a
1648
+ * placeholder in a dry run.
1649
+ */
1545
1650
  readDryRunMemberIds(input, resolver, label) {
1546
1651
  if (resolver.type === 'DIRECT') {
1547
- return readMemberIdsFromValue(resolver.memberIds, label);
1652
+ return findMemberIdsInValue(resolver.memberIds);
1548
1653
  }
1549
1654
  if (resolver.type === 'DYNAMIC_FORM') {
1550
- return readMemberIdsFromValue(readValueAtPath(input.formData, resolver.formPath), `${label}.formPath`);
1655
+ return findMemberIdsInValue(readValueAtPath(input.formData, resolver.formPath));
1551
1656
  }
1552
1657
  if (resolver.type === 'EXPRESSION') {
1553
- return readMemberIdsFromValue(this.conditionService.evaluateValue(resolver.expression, buildDryRunExpressionContext(input, null), `${label}.expression`), `${label}.expression`);
1658
+ return findMemberIdsInValue(this.conditionService.evaluateValue(resolver.expression, buildDryRunExpressionContext(input, null), `${label}.expression`));
1554
1659
  }
1555
1660
  if (resolver.type === 'ORG_MANAGER') {
1556
- return readMemberIdsFromValue(readManagerMemberIdFromInitiatorSnapshot(input.initiatorMetadataSnapshot, resolver.levelsUp), label);
1661
+ const managerMemberIds = findMemberIdsInValue(readManagerMemberIdFromInitiatorSnapshot(input.initiatorMetadataSnapshot, resolver.levelsUp));
1662
+ return managerMemberIds.length > 0
1663
+ ? managerMemberIds
1664
+ : readDryRunFallbackMemberIds(input, resolver.fallback);
1557
1665
  }
1558
1666
  return null;
1559
1667
  }
1560
- resolveDryRunAssigneeMemberId(input, resolver, label) {
1561
- if (resolver.type === 'DIRECT') {
1562
- return readMemberIdFromValue(resolver.memberIds, label);
1563
- }
1564
- if (resolver.type === 'DYNAMIC_FORM') {
1565
- return readMemberIdFromValue(readValueAtPath(input.formData, resolver.formPath), `${label}.formPath`);
1566
- }
1567
- if (resolver.type === 'EXPRESSION') {
1568
- return readMemberIdFromValue(this.conditionService.evaluateValue(resolver.expression, buildDryRunExpressionContext(input, null), `${label}.expression`), `${label}.expression`);
1569
- }
1570
- if (resolver.type === 'POSITION') {
1571
- return `position:${resolver.positionId}`;
1572
- }
1573
- if (resolver.type === 'ORG_UNIT_MEMBER') {
1574
- return `orgUnitMember:${resolver.orgUnitId}`;
1575
- }
1576
- if (resolver.type === 'ORG_UNIT_POSITION') {
1577
- return `orgUnitPosition:${resolver.orgUnitId}:${resolver.positionId}`;
1578
- }
1579
- if (resolver.type === 'ORG_UNIT_MANAGER') {
1580
- return `orgUnitManager:${resolver.orgUnitId}`;
1581
- }
1582
- if (resolver.type === 'ORG_MANAGER_CHAIN') {
1583
- return `orgManagerChain:${resolver.minPositionLevel}`;
1584
- }
1585
- return readMemberIdFromValue(readManagerMemberIdFromInitiatorSnapshot(input.initiatorMetadataSnapshot, resolver.levelsUp), label);
1586
- }
1587
1668
  async getApprovalInstance(id, scope) {
1588
1669
  const instance = await this.approvalInstanceRepository.findOne({
1589
1670
  where: { id },
@@ -1655,7 +1736,11 @@ let WorkflowEngineService = class WorkflowEngineService {
1655
1736
  }
1656
1737
  const view = options.view ?? workflow_engine_enums_1.ApprovalInstanceListViewEnum.ALL;
1657
1738
  if (view === workflow_engine_enums_1.ApprovalInstanceListViewEnum.SENT) {
1658
- queryBuilder.andWhere('"approvalInstance"."initiator_member_id" = :readableMemberId', {
1739
+ // The assistant who filed on someone's behalf finds it here too.
1740
+ queryBuilder.andWhere(`(
1741
+ "approvalInstance"."initiator_member_id" = :readableMemberId
1742
+ OR "approvalInstance"."submitted_by_member_id" = :readableMemberId
1743
+ )`, {
1659
1744
  readableMemberId: scope.memberId,
1660
1745
  });
1661
1746
  return true;
@@ -1679,6 +1764,7 @@ let WorkflowEngineService = class WorkflowEngineService {
1679
1764
  queryBuilder.andWhere(`
1680
1765
  (
1681
1766
  "approvalInstance"."initiator_member_id" = :readableMemberId
1767
+ OR "approvalInstance"."submitted_by_member_id" = :readableMemberId
1682
1768
  OR EXISTS (
1683
1769
  SELECT 1
1684
1770
  FROM "tasks" "readableTask"
@@ -1909,13 +1995,54 @@ let WorkflowEngineService = class WorkflowEngineService {
1909
1995
  ]);
1910
1996
  return nextMap;
1911
1997
  }, new Map());
1998
+ const nodeLabels = await this.readTaskNodeLabels(tasks);
1912
1999
  return tasks.map((task) => Object.assign(new task_entity_1.TaskEntity(), task, {
1913
2000
  candidateMemberIds: candidatesByTaskId.get(task.id) ?? [],
2001
+ nodeLabel: nodeLabels.get(readNodeLabelKey(task.instanceId, task.nodeId)) ??
2002
+ null,
1914
2003
  openCandidateMemberIds: openCandidatesByTaskId.get(task.id) ?? [],
1915
2004
  }));
1916
2005
  }
2006
+ /**
2007
+ * The display names of the nodes a page of tasks sits on, keyed by
2008
+ * {@link readNodeLabelKey}. One query for the whole page, and Postgres picks
2009
+ * the labels out of the snapshots: a workflow snapshot is a whole workflow
2010
+ * definition, and every inbox, history and instance task list runs this, so
2011
+ * shipping the snapshots to the app just to read one string each would cost
2012
+ * page size × snapshot size on every refresh. A missing instance or node, a
2013
+ * snapshot without a `nodes` array, and a blank or non-string label all
2014
+ * leave the task without a label, as `readWorkflowNodeLabel` does.
2015
+ */
2016
+ async readTaskNodeLabels(tasks) {
2017
+ // The table as the entity reads it, so a configured `schema` is honoured
2018
+ // here exactly as it is by every repository read of the instance.
2019
+ const { metadata } = this.approvalInstanceRepository;
2020
+ const { driver } = this.approvalInstanceRepository.manager.connection;
2021
+ const instanceTable = [metadata.schema, metadata.tableName]
2022
+ .filter((part) => Boolean(part))
2023
+ .map((part) => driver.escape(part))
2024
+ .join('.');
2025
+ const rows = await this.approvalInstanceRepository.query(`SELECT instance.id AS "instanceId",
2026
+ node->>'id' AS "nodeId",
2027
+ CASE WHEN jsonb_typeof(node->'data'->'label') = 'string'
2028
+ THEN node->'data'->>'label' END AS "label"
2029
+ FROM ${instanceTable} AS instance
2030
+ CROSS JOIN LATERAL jsonb_array_elements(
2031
+ CASE WHEN jsonb_typeof(instance.workflow_snapshot->'nodes') = 'array'
2032
+ THEN instance.workflow_snapshot->'nodes'
2033
+ ELSE '[]'::jsonb END
2034
+ ) AS node
2035
+ WHERE instance.id = ANY($1::uuid[])
2036
+ AND node->>'id' = ANY($2::text[])`, [
2037
+ [...new Set(tasks.map((task) => task.instanceId))],
2038
+ [...new Set(tasks.map((task) => task.nodeId))],
2039
+ ]);
2040
+ return new Map(rows.flatMap((row) => typeof row.label === 'string' && row.label.trim()
2041
+ ? [[readNodeLabelKey(row.instanceId, row.nodeId), row.label]]
2042
+ : []));
2043
+ }
1917
2044
  async readDefaultInitiatorMetadataSnapshot(memberId) {
1918
- const today = toDateOnlyString(new Date());
2045
+ const today = this.readOrganizationToday();
1919
2046
  const memberships = (await this.approvalInstanceRepository.manager
1920
2047
  .getRepository(membership_entity_1.MembershipEntity)
1921
2048
  .find({ where: { memberId } }))
@@ -1950,7 +2077,10 @@ let WorkflowEngineService = class WorkflowEngineService {
1950
2077
  return task;
1951
2078
  }
1952
2079
  async isApprovalInstanceReadableByMember(instance, memberId) {
1953
- if (instance.initiatorMemberId === memberId) {
2080
+ // The member who filed the document on the applicant's behalf can read it
2081
+ // back; resubmit and cancel stay with the initiator.
2082
+ if (instance.initiatorMemberId === memberId ||
2083
+ (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) === memberId) {
1954
2084
  return true;
1955
2085
  }
1956
2086
  const tasks = await this.taskRepository.find({
@@ -2157,6 +2287,12 @@ let WorkflowEngineService = class WorkflowEngineService {
2157
2287
  });
2158
2288
  }
2159
2289
  async skipNode(manager, instance, token, node, reason) {
2290
+ // A NOTIFY node is an asynchronous branch that may not have an outgoing
2291
+ // edge, so a skipped one ends its branch exactly as an executed one does:
2292
+ // the token is consumed instead of advanced (advancing would throw "has no
2293
+ // outgoing edge" and fail the whole submission). Instance completion is
2294
+ // evaluated by `processRunningInstance` once no ACTIVE token remains.
2295
+ const consumesToken = (0, workflow_graph_1.isAsyncNotifyServiceTask)(node);
2160
2296
  await manager.getRepository(activity_log_entity_1.ActivityLogEntity).save(manager.getRepository(activity_log_entity_1.ActivityLogEntity).create({
2161
2297
  actorMemberId: null,
2162
2298
  eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TOKEN_ADVANCED,
@@ -2164,12 +2300,24 @@ let WorkflowEngineService = class WorkflowEngineService {
2164
2300
  nodeId: node.id,
2165
2301
  payload: {
2166
2302
  ...reason,
2303
+ ...(consumesToken ? { consumed: true } : {}),
2167
2304
  tokenId: token.id,
2168
2305
  },
2169
2306
  taskId: null,
2170
2307
  }));
2308
+ if (consumesToken) {
2309
+ await this.consumeToken(manager, token);
2310
+ return;
2311
+ }
2171
2312
  await this.advanceTokenToOutgoingNodes(manager, instance, token, node);
2172
2313
  }
2314
+ async consumeToken(manager, token) {
2315
+ await manager.getRepository(workflow_token_entity_1.WorkflowTokenEntity).save({
2316
+ ...token,
2317
+ consumedAt: new Date(),
2318
+ status: workflow_engine_enums_1.WorkflowTokenStatusEnum.CONSUMED,
2319
+ });
2320
+ }
2173
2321
  async readAvailableOutgoingEdges(manager, instance, node) {
2174
2322
  const outgoingEdges = readOutgoingEdgesOrThrow(instance.workflowSnapshot, node.id);
2175
2323
  const conditionalEdges = outgoingEdges.filter((edge) => !edge.data.isDefault && edgeHasCondition(edge));
@@ -2361,14 +2509,15 @@ let WorkflowEngineService = class WorkflowEngineService {
2361
2509
  });
2362
2510
  return;
2363
2511
  }
2364
- const candidates = await this.resolveRuntimeTaskCandidates(manager, instance, node);
2512
+ const resolvedCandidates = await this.resolveRuntimeTaskCandidates(manager, instance, node);
2365
2513
  // The initiator was the only approver and the node asks to be skipped in
2366
2514
  // that case: no task, the token moves on. Pending countersign directives
2367
2515
  // stay pending for the next user task that actually gets created.
2368
- if (candidates === null) {
2516
+ if (resolvedCandidates === null) {
2369
2517
  await this.skipNodeByInitiatorExclusion(manager, instance, token, node);
2370
2518
  return;
2371
2519
  }
2520
+ const { candidates, skippedDelegations } = resolvedCandidates;
2372
2521
  const primaryCandidate = candidates[0];
2373
2522
  // Legacy snapshots may carry no policy at all; they behave as before.
2374
2523
  const decisionPolicy = node.data.decisionPolicy;
@@ -2441,6 +2590,7 @@ let WorkflowEngineService = class WorkflowEngineService {
2441
2590
  sequentialCandidateMemberIds: savedCandidates.map((candidate) => candidate.memberId),
2442
2591
  }
2443
2592
  : {}),
2593
+ ...(skippedDelegations.length > 0 ? { skippedDelegations } : {}),
2444
2594
  tokenId: token.id,
2445
2595
  },
2446
2596
  taskId: task.id,
@@ -2481,11 +2631,13 @@ let WorkflowEngineService = class WorkflowEngineService {
2481
2631
  const now = new Date();
2482
2632
  // Delegation was resolved when the task was created; the candidate may have
2483
2633
  // gained a delegate while waiting, so resolve it again for the new turn.
2484
- const delegation = await this.resolveActivationDelegation({
2634
+ const { skippedDelegation, ...delegation } = await this.resolveActivationDelegation({
2485
2635
  candidate: nextCandidate,
2486
2636
  candidates,
2487
2637
  excludesInitiator: isInitiatorExcluded(node),
2638
+ gradeGated: isGradeGatedCandidate(node, nextCandidate.sourceType),
2488
2639
  instance,
2640
+ manager,
2489
2641
  nodeId: task.nodeId,
2490
2642
  });
2491
2643
  const activatedCandidate = await manager
@@ -2525,6 +2677,9 @@ let WorkflowEngineService = class WorkflowEngineService {
2525
2677
  originalMemberId: activatedCandidate.originalMemberId,
2526
2678
  previousMemberId,
2527
2679
  sequence: activatedCandidate.sequence,
2680
+ ...(skippedDelegation
2681
+ ? { skippedDelegations: [skippedDelegation] }
2682
+ : {}),
2528
2683
  tokenId: task.tokenId,
2529
2684
  },
2530
2685
  taskId: task.id,
@@ -2542,29 +2697,31 @@ let WorkflowEngineService = class WorkflowEngineService {
2542
2697
  * are kept: (task_id, member_id) is unique, and that member signs from the
2543
2698
  * row they already have. On a node that excludes the initiator the stored
2544
2699
  * values are kept as well when the new delegation path would run through the
2545
- * initiator: creation already filtered such paths out, and a delegation added
2546
- * while the candidate waited must not bring them back.
2700
+ * initiator or the submitter: creation already filtered such paths out, and a
2701
+ * delegation added while the candidate waited must not bring them back.
2547
2702
  */
2548
- async resolveActivationDelegation({ candidate, candidates, excludesInitiator, instance, nodeId, }) {
2703
+ async resolveActivationDelegation({ candidate, candidates, excludesInitiator, gradeGated, instance, manager, nodeId, }) {
2549
2704
  const stored = {
2550
2705
  delegationChain: candidate.delegationChain,
2551
2706
  memberId: candidate.memberId,
2552
2707
  };
2553
- const resolution = await this.delegationService.resolveAssignee(candidate.originalMemberId, this.buildDelegationContext(instance, nodeId));
2708
+ const { skippedDelegation, ...resolution } = await this.resolveDelegation(manager, candidate.originalMemberId, this.buildDelegationContext(instance, nodeId), gradeGated);
2554
2709
  const collides = candidates.some((other) => other.id !== candidate.id &&
2555
2710
  other.memberId === resolution.finalAssigneeMemberId);
2556
2711
  if (collides ||
2557
2712
  (excludesInitiator &&
2558
- isInitiatorOnCandidatePath({
2713
+ isExcludedMemberOnCandidatePath({
2559
2714
  delegationChain: resolution.delegationChain,
2560
2715
  memberId: resolution.finalAssigneeMemberId,
2561
2716
  originalMemberId: candidate.originalMemberId,
2562
- }, instance.initiatorMemberId))) {
2563
- return stored;
2717
+ }, readInitiatorAndSubmitter(instance)))) {
2718
+ // The refusal happened even though the stored values are kept.
2719
+ return { ...stored, ...(skippedDelegation ? { skippedDelegation } : {}) };
2564
2720
  }
2565
2721
  return {
2566
2722
  delegationChain: resolution.delegationChain,
2567
2723
  memberId: resolution.finalAssigneeMemberId,
2724
+ ...(skippedDelegation ? { skippedDelegation } : {}),
2568
2725
  };
2569
2726
  }
2570
2727
  async markTaskCandidateDecision({ action, candidate, candidates, decidedAt, manager, task, }) {
@@ -2802,12 +2959,16 @@ let WorkflowEngineService = class WorkflowEngineService {
2802
2959
  */
2803
2960
  async resolveRuntimeTaskCandidates(manager, instance, node) {
2804
2961
  const label = `簽核節點「${node.data.label}」`;
2805
- const resolvedCandidates = await this.resolveApproverResolver(manager, instance, node.data.approverResolver, label);
2806
- const delegatedCandidates = await this.delegateResolvedCandidates(instance, node.id, resolvedCandidates);
2962
+ const resolvedCandidates = await this.resolveApproverResolver(manager, instance, node.data.approverResolver, label, '簽核人', isInitiatorExcluded(node) ? readInitiatorAndSubmitter(instance) : []);
2963
+ const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, resolvedCandidates, isOrgManagerChainNode(node));
2807
2964
  const excludeInitiator = node.data.excludeInitiator;
2808
- const uniqueCandidates = selectNodeCandidates(delegatedCandidates, isInitiatorExcluded(node), instance.initiatorMemberId);
2965
+ const hasSubmitter = (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance) !== null;
2966
+ const uniqueCandidates = selectNodeCandidates(delegatedCandidates, isInitiatorExcluded(node), readInitiatorAndSubmitter(instance));
2809
2967
  if (uniqueCandidates.length > 0) {
2810
- return uniqueCandidates;
2968
+ return {
2969
+ candidates: uniqueCandidates,
2970
+ skippedDelegations: collectSkippedDelegations(delegatedCandidates, isInitiatorExcluded(node), readInitiatorAndSubmitter(instance)),
2971
+ };
2811
2972
  }
2812
2973
  // Only the initiator was left — what the node does about it is configured.
2813
2974
  // Nothing resolved to begin with is the ordinary failure below.
@@ -2819,23 +2980,31 @@ let WorkflowEngineService = class WorkflowEngineService {
2819
2980
  }
2820
2981
  if (excludeInitiator.whenEmpty === 'INITIATOR_MANAGER') {
2821
2982
  const managerCandidates = await this.resolveInitiatorManagerReplacements(manager, instance, node);
2822
- if (managerCandidates.length > 0) {
2983
+ if (managerCandidates.candidates.length > 0) {
2823
2984
  return managerCandidates;
2824
2985
  }
2825
- throw new common_1.ConflictException(`${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人,此節點不允許申請人自簽,且找不到申請人的主管可改派。`);
2986
+ throw new common_1.ConflictException(hasSubmitter
2987
+ ? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人,此節點不允許申請人或代送人簽核,且找不到申請人的主管可改派。`
2988
+ : `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人,此節點不允許申請人自簽,且找不到申請人的主管可改派。`);
2826
2989
  }
2827
- throw new common_1.ConflictException(`${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人(含委派後的代理人),且此節點不允許申請人自簽。`);
2990
+ throw new common_1.ConflictException(hasSubmitter
2991
+ ? `${label} 無法建立待簽任務:解析出的簽核人只剩申請人或代為送出此單的人(含委派後的代理人),且此節點不允許申請人或代送人簽核。`
2992
+ : `${label} 無法建立待簽任務:解析出的簽核人只剩申請人本人(含委派後的代理人),且此節點不允許申請人自簽。`);
2828
2993
  }
2829
2994
  async resolveInitiatorManagerReplacements(manager, instance, node) {
2830
- // The initiator's own direct manager: the closest org unit only, and the
2831
- // initiator himself (a unit head) is skipped so resolution falls through
2832
- // to the next tier instead of ending on him.
2833
- const managerMemberIds = await this.resolveDirectManagerMemberIds(manager, instance.initiatorMemberId, toDateOnlyString(new Date()), true, instance.initiatorMemberId);
2834
- const delegatedCandidates = await this.delegateResolvedCandidates(instance, node.id, managerMemberIds.map((memberId) => ({
2995
+ // The initiator's own direct manager: the closest org unit only. The
2996
+ // initiator himself (a unit head) and the member who submitted for him are
2997
+ // skipped before the tier is chosen, so resolution falls through to the
2998
+ // next tier instead of ending on either of them.
2999
+ const managerMemberIds = await this.resolveDirectManagerMemberIds(manager, instance.initiatorMemberId, this.readOrganizationToday(), true, readInitiatorAndSubmitter(instance));
3000
+ const delegatedCandidates = await this.delegateResolvedCandidates(manager, instance, node.id, managerMemberIds.map((memberId) => ({
2835
3001
  memberId,
2836
3002
  sourceType: 'EXCLUDE_INITIATOR_MANAGER',
2837
- })));
2838
- return selectNodeCandidates(delegatedCandidates, true, instance.initiatorMemberId);
3003
+ })), isOrgManagerChainNode(node));
3004
+ return {
3005
+ candidates: selectNodeCandidates(delegatedCandidates, true, readInitiatorAndSubmitter(instance)),
3006
+ skippedDelegations: collectSkippedDelegations(delegatedCandidates, true, readInitiatorAndSubmitter(instance)),
3007
+ };
2839
3008
  }
2840
3009
  buildDelegationContext(instance, nodeId) {
2841
3010
  return {
@@ -2852,48 +3021,86 @@ let WorkflowEngineService = class WorkflowEngineService {
2852
3021
  }
2853
3022
  // Not deduplicated: a node that excludes the initiator filters first, so a
2854
3023
  // first-seen entry that must be dropped cannot shadow a legitimate one.
2855
- async delegateResolvedCandidates(instance, nodeId, resolvedCandidates) {
3024
+ async delegateResolvedCandidates(manager, instance, nodeId, resolvedCandidates, gradeGated) {
2856
3025
  const context = this.buildDelegationContext(instance, nodeId);
2857
- const delegatedCandidates = await Promise.all(resolvedCandidates.map(async (candidate) => {
2858
- const delegationResolution = await this.delegationService.resolveAssignee(candidate.memberId, context);
3026
+ const resolved = await Promise.all(resolvedCandidates.map(async (candidate) => ({
3027
+ candidate,
3028
+ resolution: await this.delegationService.resolveAssignee(candidate.memberId, context),
3029
+ })));
3030
+ // One batch of grade lookups for the whole node, not two per delegate.
3031
+ const levels = gradeGated
3032
+ ? await this.readMemberPositionLevels(manager, resolved.flatMap(({ candidate, resolution }) => readGradeGateMemberIds(candidate.memberId, resolution)), this.readOrganizationToday())
3033
+ : new Map();
3034
+ return resolved.map(({ candidate, resolution }) => {
3035
+ const { skippedDelegation, ...delegationResolution } = applyGradeGate(candidate.memberId, resolution, gradeGated, levels);
2859
3036
  return {
2860
3037
  delegationChain: delegationResolution.delegationChain,
2861
3038
  memberId: delegationResolution.finalAssigneeMemberId,
2862
3039
  originalMemberId: candidate.memberId,
3040
+ ...(skippedDelegation ? { skippedDelegation } : {}),
2863
3041
  sourceType: candidate.sourceType,
2864
3042
  };
2865
- }));
2866
- return delegatedCandidates;
3043
+ });
2867
3044
  }
2868
- async resolveApproverResolver(manager, instance, resolver, label) {
3045
+ /** One member's delegation, grade-gated as {@link applyGradeGate} says. */
3046
+ async resolveDelegation(manager, principalMemberId, context, gradeGated) {
3047
+ const resolution = await this.delegationService.resolveAssignee(principalMemberId, context);
3048
+ const memberIds = gradeGated
3049
+ ? readGradeGateMemberIds(principalMemberId, resolution)
3050
+ : [];
3051
+ const levels = memberIds.length > 0
3052
+ ? await this.readMemberPositionLevels(manager, memberIds, this.readOrganizationToday())
3053
+ : new Map();
3054
+ return applyGradeGate(principalMemberId, resolution, gradeGated, levels);
3055
+ }
3056
+ /**
3057
+ * Manual transfer on a manager-chain node: the target must hold at least the
3058
+ * grade of the member being replaced, i.e. the original principal of the
3059
+ * deciding candidate (the first hop of its delegation chain when it was
3060
+ * delegated, otherwise the actor). No grade on the principal means no
3061
+ * constraint.
3062
+ */
3063
+ async assertTransferTargetGrade(manager, { actorMemberId, delegationChain, label, targetMemberId, }) {
3064
+ const principalMemberId = readDelegationSteps(delegationChain)[0]?.from ?? actorMemberId;
3065
+ const date = this.readOrganizationToday();
3066
+ const requiredLevel = await this.readMemberPositionLevel(manager, principalMemberId, date);
3067
+ if (requiredLevel === null) {
3068
+ return;
3069
+ }
3070
+ const targetLevel = await this.readMemberPositionLevel(manager, targetMemberId, date);
3071
+ if (targetLevel === null || targetLevel < requiredLevel) {
3072
+ throw new common_1.ForbiddenException(`簽核節點「${label}」需由職等 ${requiredLevel} 以上的成員簽核,不能轉簽給職等不足的成員`);
3073
+ }
3074
+ }
3075
+ async resolveApproverResolver(manager, instance, resolver, label, subject = '簽核人', skippedChainMemberIds = []) {
2869
3076
  if (resolver.type === 'DIRECT') {
2870
- return readMemberIdsFromValue(resolver.memberIds, label).map((memberId) => ({ memberId, sourceType: resolver.type }));
3077
+ return readMemberIdsFromValue(resolver.memberIds, label, subject).map((memberId) => ({ memberId, sourceType: resolver.type }));
2871
3078
  }
2872
3079
  if (resolver.type === 'DYNAMIC_FORM') {
2873
- return readMemberIdsFromValue(readValueAtPath(instance.formData, resolver.formPath), `${label}.formPath`).map((memberId) => ({ memberId, sourceType: resolver.type }));
3080
+ return readMemberIdsFromValue(readValueAtPath(instance.formData, resolver.formPath), label, subject, `依表單欄位「${resolver.formPath}」`).map((memberId) => ({ memberId, sourceType: resolver.type }));
2874
3081
  }
2875
3082
  if (resolver.type === 'EXPRESSION') {
2876
- return readMemberIdsFromValue(this.conditionService.evaluateValue(resolver.expression, buildWorkflowExpressionContext(instance), `${label}.expression`), `${label}.expression`).map((memberId) => ({ memberId, sourceType: resolver.type }));
3083
+ return readMemberIdsFromValue(this.conditionService.evaluateValue(resolver.expression, buildWorkflowExpressionContext(instance), `${label}.expression`), label, subject, '依運算式').map((memberId) => ({ memberId, sourceType: resolver.type }));
2877
3084
  }
2878
3085
  if (resolver.type === 'POSITION') {
2879
- return this.resolvePositionCandidates(manager, resolver.positionId, label).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
3086
+ return this.resolvePositionCandidates(manager, resolver.positionId, label, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
2880
3087
  }
2881
3088
  if (resolver.type === 'ORG_UNIT_MEMBER') {
2882
- return this.resolveOrgUnitMemberCandidates(manager, resolver.orgUnitId, Boolean(resolver.includeDescendants), label).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
3089
+ return this.resolveOrgUnitMemberCandidates(manager, resolver.orgUnitId, Boolean(resolver.includeDescendants), label, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
2883
3090
  }
2884
3091
  if (resolver.type === 'ORG_UNIT_POSITION') {
2885
- return this.resolveOrgUnitPositionCandidates(manager, resolver.orgUnitId, resolver.positionId, Boolean(resolver.includeDescendants), label).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
3092
+ return this.resolveOrgUnitPositionCandidates(manager, resolver.orgUnitId, resolver.positionId, Boolean(resolver.includeDescendants), label, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
2886
3093
  }
2887
3094
  if (resolver.type === 'ORG_UNIT_MANAGER') {
2888
- return this.resolveOrgUnitManagerCandidates(manager, instance, resolver.orgUnitId, resolver.fallback, label, resolver.preferClosestOrgUnit === true).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
3095
+ return this.resolveOrgUnitManagerCandidates(manager, instance, resolver.orgUnitId, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
2889
3096
  }
2890
3097
  if (resolver.type === 'ORG_MANAGER_CHAIN') {
2891
- return this.resolveOrgManagerChainCandidates(manager, instance, resolver.minPositionLevel, resolver.fallback, label).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
3098
+ return this.resolveOrgManagerChainCandidates(manager, instance, resolver.minPositionLevel, resolver.fallback, label, subject, skippedChainMemberIds).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
2892
3099
  }
2893
- return this.resolveInitiatorManagerCandidates(manager, instance, resolver.levelsUp, resolver.fallback, label, resolver.preferClosestOrgUnit === true).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
3100
+ return this.resolveInitiatorManagerCandidates(manager, instance, resolver.levelsUp, resolver.fallback, label, resolver.preferClosestOrgUnit === true, subject).then((memberIds) => memberIds.map((memberId) => ({ memberId, sourceType: resolver.type })));
2894
3101
  }
2895
- async resolvePositionCandidates(manager, positionId, label) {
2896
- const today = toDateOnlyString(new Date());
3102
+ async resolvePositionCandidates(manager, positionId, label, subject) {
3103
+ const today = this.readOrganizationToday();
2897
3104
  const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
2898
3105
  where: { positionId },
2899
3106
  });
@@ -2907,27 +3114,28 @@ let WorkflowEngineService = class WorkflowEngineService {
2907
3114
  return second.effectiveFrom.localeCompare(first.effectiveFrom);
2908
3115
  });
2909
3116
  if (!activeMemberships.length) {
2910
- throw new common_1.ConflictException(`${label} position ${positionId} has no active membership`);
3117
+ throw new common_1.ConflictException(`${label} 找不到${subject}:職位「${await this.readPositionName(manager, positionId)}」目前沒有任何有效成員`);
2911
3118
  }
2912
3119
  return uniqueTexts(activeMemberships.map((membership) => membership.memberId));
2913
3120
  }
2914
- async resolveInitiatorManagerCandidates(manager, instance, levelsUp, fallback, label, preferClosestOrgUnit) {
3121
+ async resolveInitiatorManagerCandidates(manager, instance, levelsUp, fallback, label, preferClosestOrgUnit, subject) {
2915
3122
  const normalizedLevelsUp = Math.max(Math.trunc(levelsUp), 1);
2916
3123
  const resolvedManagerMemberIds = await this.resolveManagerMemberIdsFromOrganization(manager, instance.initiatorMemberId, normalizedLevelsUp, preferClosestOrgUnit);
2917
3124
  if (resolvedManagerMemberIds.length) {
2918
3125
  return resolvedManagerMemberIds;
2919
3126
  }
2920
3127
  return [
2921
- this.resolveFallbackAssignee(instance, fallback, label, `找不到發起人的第 ${normalizedLevelsUp} 層主管`),
3128
+ this.resolveFallbackAssignee(instance, fallback, label, subject, `找不到發起人的第 ${normalizedLevelsUp} 層主管`),
2922
3129
  ];
2923
3130
  }
2924
3131
  /**
2925
3132
  * Walks up the initiator's manager chain, nearest manager first, until a
2926
3133
  * manager whose position level reaches `minPositionLevel` is added. The
2927
- * list is returned nearest manager first.
3134
+ * list is returned nearest manager first. Members in `skippedMemberIds` are
3135
+ * walked past without joining the chain or counting as the stopping point.
2928
3136
  */
2929
- async resolveOrgManagerChainCandidates(manager, instance, minPositionLevel, fallback, label) {
2930
- const date = toDateOnlyString(new Date());
3137
+ async resolveOrgManagerChainCandidates(manager, instance, minPositionLevel, fallback, label, subject, skippedMemberIds) {
3138
+ const date = this.readOrganizationToday();
2931
3139
  const visitedMemberIds = new Set([instance.initiatorMemberId]);
2932
3140
  const chain = [];
2933
3141
  let currentMemberId = instance.initiatorMemberId;
@@ -2938,7 +3146,7 @@ let WorkflowEngineService = class WorkflowEngineService {
2938
3146
  // excluded from his own candidates: a unit head belongs to the unit he
2939
3147
  // heads, so without this his unit rule would name himself and the walk
2940
3148
  // could never climb past him.
2941
- const directManagerMemberIds = await this.resolveDirectManagerMemberIds(manager, currentMemberId, date, true, currentMemberId);
3149
+ const directManagerMemberIds = await this.resolveDirectManagerMemberIds(manager, currentMemberId, date, true, [currentMemberId]);
2942
3150
  if (directManagerMemberIds.length === 0) {
2943
3151
  break;
2944
3152
  }
@@ -2952,6 +3160,12 @@ let WorkflowEngineService = class WorkflowEngineService {
2952
3160
  }
2953
3161
  visitedMemberIds.add(managerMemberId);
2954
3162
  currentMemberId = managerMemberId;
3163
+ // A member the node keeps out (the person who filed the document) is
3164
+ // climbed through, not stopped at: filtering him after the walk would
3165
+ // lose the chain when he is the manager that reaches the level.
3166
+ if (skippedMemberIds.includes(managerMemberId)) {
3167
+ continue;
3168
+ }
2955
3169
  const level = await this.readMemberPositionLevel(manager, managerMemberId, date);
2956
3170
  if (level === null ||
2957
3171
  (lastAddedLevel !== null && level <= lastAddedLevel)) {
@@ -2963,45 +3177,60 @@ let WorkflowEngineService = class WorkflowEngineService {
2963
3177
  return chain;
2964
3178
  }
2965
3179
  }
2966
- const fallbackMemberId = this.resolveFallbackAssignee(instance, fallback, label, `沿主管鏈找不到職等達 ${minPositionLevel} 的主管`);
3180
+ const fallbackMemberId = this.resolveFallbackAssignee(instance, fallback, label, subject, `沿主管鏈找不到職等達 ${minPositionLevel} 的主管`);
2967
3181
  return chain.includes(fallbackMemberId)
2968
3182
  ? chain
2969
3183
  : [...chain, fallbackMemberId];
2970
3184
  }
2971
3185
  /** Highest position level over the member's active memberships, or `null`. */
2972
3186
  async readMemberPositionLevel(manager, memberId, date) {
2973
- const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
2974
- where: { memberId },
2975
- });
2976
- const positionIds = uniqueTexts(memberships
2977
- .filter((membership) => isDateRangeActive(membership, date))
2978
- .map((membership) => membership.positionId)
2979
- .filter((positionId) => Boolean(positionId)));
2980
- if (positionIds.length === 0) {
2981
- return null;
2982
- }
2983
- const positions = await manager.getRepository(position_entity_1.PositionEntity).find({
2984
- where: { id: (0, typeorm_2.In)([...positionIds]) },
2985
- });
2986
- return positions.length === 0
2987
- ? null
2988
- : Math.max(...positions.map((position) => position.level));
3187
+ return ((await this.readMemberPositionLevels(manager, [memberId], date)).get(memberId) ?? null);
3188
+ }
3189
+ /**
3190
+ * {@link readMemberPositionLevel} for several members in two queries: every
3191
+ * requested member is in the map, `null` when they hold no graded position.
3192
+ */
3193
+ async readMemberPositionLevels(manager, memberIds, date) {
3194
+ const uniqueMemberIds = uniqueTexts(memberIds);
3195
+ if (uniqueMemberIds.length === 0) {
3196
+ return new Map();
3197
+ }
3198
+ const memberships = (await manager.getRepository(membership_entity_1.MembershipEntity).find({
3199
+ where: { memberId: (0, typeorm_2.In)([...uniqueMemberIds]) },
3200
+ })).filter((membership) => Boolean(membership.positionId) && isDateRangeActive(membership, date));
3201
+ const positionIds = uniqueTexts(memberships.map((membership) => membership.positionId ?? ''));
3202
+ const positions = positionIds.length === 0
3203
+ ? []
3204
+ : await manager.getRepository(position_entity_1.PositionEntity).find({
3205
+ where: { id: (0, typeorm_2.In)([...positionIds]) },
3206
+ });
3207
+ const levelByPositionId = new Map(positions.map((position) => [position.id, position.level]));
3208
+ return new Map(uniqueMemberIds.map((memberId) => {
3209
+ const levels = memberships
3210
+ .filter((membership) => membership.memberId === memberId)
3211
+ .flatMap((membership) => {
3212
+ const level = levelByPositionId.get(membership.positionId ?? '');
3213
+ return level === undefined ? [] : [level];
3214
+ });
3215
+ return [memberId, levels.length === 0 ? null : Math.max(...levels)];
3216
+ }));
2989
3217
  }
2990
- resolveFallbackAssignee(instance, fallback, label, failureReason) {
3218
+ resolveFallbackAssignee(instance, fallback, label, subject, failureReason) {
3219
+ const failure = subject === '通知對象' ? '無法建立通知' : '無法建立待簽任務';
2991
3220
  if (!fallback || fallback.type === 'NONE') {
2992
- throw new common_1.ConflictException(`${label} 無法建立待簽任務:${failureReason},且未設定改派固定人。請聯絡流程管理員調整主管規則或模板 fallback。`);
3221
+ throw new common_1.ConflictException(`${label} ${failure}:${failureReason},且未設定改派固定人。請聯絡流程管理員調整主管規則或模板 fallback。`);
2993
3222
  }
2994
3223
  if (!fallback.memberId.trim()) {
2995
- throw new common_1.ConflictException(`${label} 無法建立待簽任務:改派固定人未設定。`);
3224
+ throw new common_1.ConflictException(`${label} ${failure}:改派固定人未設定。`);
2996
3225
  }
2997
3226
  if (fallback.memberId === instance.initiatorMemberId &&
2998
3227
  !fallback.allowInitiatorSelfApproval) {
2999
- throw new common_1.ConflictException(`${label} 無法建立待簽任務:改派固定人是申請人本人,且此節點未允許申請人自簽。`);
3228
+ throw new common_1.ConflictException(`${label} ${failure}:改派固定人是申請人本人,且此節點未允許申請人自簽。`);
3000
3229
  }
3001
3230
  return fallback.memberId;
3002
3231
  }
3003
3232
  async resolveManagerMemberIdsFromOrganization(manager, memberId, levelsUp, preferClosestOrgUnit) {
3004
- return this.resolveManagerMemberIdsAtLevel(manager, memberId, toDateOnlyString(new Date()), levelsUp, preferClosestOrgUnit);
3233
+ return this.resolveManagerMemberIdsAtLevel(manager, memberId, this.readOrganizationToday(), levelsUp, preferClosestOrgUnit);
3005
3234
  }
3006
3235
  async resolveManagerMemberIdsAtLevel(manager, memberId, date, remainingLevels, preferClosestOrgUnit) {
3007
3236
  const directManagerMemberIds = await this.resolveDirectManagerMemberIds(manager, memberId, date, preferClosestOrgUnit);
@@ -3011,7 +3240,7 @@ let WorkflowEngineService = class WorkflowEngineService {
3011
3240
  const nextLevelGroups = await Promise.all(directManagerMemberIds.map((managerMemberId) => this.resolveManagerMemberIdsAtLevel(manager, managerMemberId, date, remainingLevels - 1, preferClosestOrgUnit)));
3012
3241
  return uniqueTexts(nextLevelGroups.flat());
3013
3242
  }
3014
- async resolveDirectManagerMemberIds(manager, memberId, date, preferClosestOrgUnit, excludedManagerMemberId) {
3243
+ async resolveDirectManagerMemberIds(manager, memberId, date, preferClosestOrgUnit, excludedManagerMemberIds = []) {
3015
3244
  const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
3016
3245
  where: { memberId },
3017
3246
  });
@@ -3032,10 +3261,10 @@ let WorkflowEngineService = class WorkflowEngineService {
3032
3261
  scopeType: organization_enums_1.ManagerResolutionScopeTypeEnum.POSITION,
3033
3262
  })),
3034
3263
  ];
3035
- return this.resolveManagerResolutionCandidates(manager, candidatePairs, date, readOrgUnitDepthMap(orgUnits), preferClosestOrgUnit, excludedManagerMemberId);
3264
+ return this.resolveManagerResolutionCandidates(manager, candidatePairs, date, readOrgUnitDepthMap(orgUnits), preferClosestOrgUnit, excludedManagerMemberIds);
3036
3265
  }
3037
- async resolveOrgUnitManagerCandidates(manager, instance, orgUnitId, fallback, label, preferClosestOrgUnit) {
3038
- const date = toDateOnlyString(new Date());
3266
+ async resolveOrgUnitManagerCandidates(manager, instance, orgUnitId, fallback, label, preferClosestOrgUnit, subject) {
3267
+ const date = this.readOrganizationToday();
3039
3268
  const orgUnits = await this.readOrgUnitAndAncestors(manager, [orgUnitId]);
3040
3269
  const managerMemberIds = await this.resolveManagerResolutionCandidates(manager, orgUnits.map((orgUnit) => ({
3041
3270
  scopeId: orgUnit.id,
@@ -3043,7 +3272,7 @@ let WorkflowEngineService = class WorkflowEngineService {
3043
3272
  })), date, readOrgUnitDepthMap(orgUnits), preferClosestOrgUnit);
3044
3273
  if (!managerMemberIds.length) {
3045
3274
  return [
3046
- this.resolveFallbackAssignee(instance, fallback, label, `找不到指定組織的主管規則`),
3275
+ this.resolveFallbackAssignee(instance, fallback, label, subject, `找不到指定組織的主管規則`),
3047
3276
  ];
3048
3277
  }
3049
3278
  return managerMemberIds;
@@ -3064,7 +3293,7 @@ let WorkflowEngineService = class WorkflowEngineService {
3064
3293
  const byId = new Map([...directOrgUnits, ...ancestorGroups.flat()].map((orgUnit) => [orgUnit.id, orgUnit]));
3065
3294
  return [...byId.values()];
3066
3295
  }
3067
- async resolveManagerResolutionCandidates(manager, candidatePairs, date, orgUnitDepths, preferClosestOrgUnit, excludedManagerMemberId) {
3296
+ async resolveManagerResolutionCandidates(manager, candidatePairs, date, orgUnitDepths, preferClosestOrgUnit, excludedManagerMemberIds = []) {
3068
3297
  const scopeIds = candidatePairs.map((pair) => pair.scopeId);
3069
3298
  if (!scopeIds.length) {
3070
3299
  return [];
@@ -3088,58 +3317,78 @@ let WorkflowEngineService = class WorkflowEngineService {
3088
3317
  .filter((resolution) => isDateRangeActive(resolution, date))
3089
3318
  // Dropped before the top-priority tier is chosen, so resolution falls
3090
3319
  // through to the next tier instead of ending on the excluded member.
3091
- .filter((resolution) => excludedManagerMemberId === undefined ||
3092
- resolution.managerMemberId !== excludedManagerMemberId)
3320
+ .filter((resolution) => !excludedManagerMemberIds.includes(resolution.managerMemberId))
3093
3321
  .sort((left, right) => compareManagerResolution(left, right, depthLookup));
3094
3322
  return uniqueTexts(readTopPriorityResolutions(active, depthLookup).map((resolution) => resolution.managerMemberId));
3095
3323
  }
3096
- async resolveOrgUnitMemberCandidates(manager, orgUnitId, includeDescendants, label) {
3097
- const orgUnitIds = await this.readOrgUnitScopeIds(manager, orgUnitId, includeDescendants);
3098
- const today = toDateOnlyString(new Date());
3324
+ async resolveOrgUnitMemberCandidates(manager, orgUnitId, includeDescendants, label, subject) {
3325
+ const scope = await this.readOrgUnitScope(manager, orgUnitId, includeDescendants);
3326
+ if (!scope) {
3327
+ throw new common_1.ConflictException(readMissingOrgUnitMessage(label, subject, orgUnitId));
3328
+ }
3329
+ const today = this.readOrganizationToday();
3099
3330
  const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
3100
- where: { orgUnitId: (0, typeorm_2.In)([...orgUnitIds]) },
3331
+ where: { orgUnitId: (0, typeorm_2.In)([...scope.orgUnitIds]) },
3101
3332
  });
3102
3333
  const memberIds = memberships
3103
3334
  .filter((membership) => isDateRangeActive(membership, today))
3104
3335
  .sort(compareMembership)
3105
3336
  .map((membership) => membership.memberId);
3106
3337
  if (memberIds.length === 0) {
3107
- throw new common_1.ConflictException(`${label} orgUnit ${orgUnitId} has no active member`);
3338
+ throw new common_1.ConflictException(`${label} 找不到${subject}:組織「${scope.name}」目前沒有任何有效成員,請先將人員加入該組織`);
3108
3339
  }
3109
3340
  return uniqueTexts(memberIds);
3110
3341
  }
3111
- async resolveOrgUnitPositionCandidates(manager, orgUnitId, positionId, includeDescendants, label) {
3112
- const orgUnitIds = await this.readOrgUnitScopeIds(manager, orgUnitId, includeDescendants);
3113
- const today = toDateOnlyString(new Date());
3342
+ async resolveOrgUnitPositionCandidates(manager, orgUnitId, positionId, includeDescendants, label, subject) {
3343
+ const scope = await this.readOrgUnitScope(manager, orgUnitId, includeDescendants);
3344
+ if (!scope) {
3345
+ throw new common_1.ConflictException(readMissingOrgUnitMessage(label, subject, orgUnitId));
3346
+ }
3347
+ const today = this.readOrganizationToday();
3114
3348
  const memberships = await manager.getRepository(membership_entity_1.MembershipEntity).find({
3115
- where: { orgUnitId: (0, typeorm_2.In)([...orgUnitIds]), positionId },
3349
+ where: { orgUnitId: (0, typeorm_2.In)([...scope.orgUnitIds]), positionId },
3116
3350
  });
3117
3351
  const memberIds = memberships
3118
3352
  .filter((membership) => isDateRangeActive(membership, today))
3119
3353
  .sort(compareMembership)
3120
3354
  .map((membership) => membership.memberId);
3121
3355
  if (memberIds.length === 0) {
3122
- throw new common_1.ConflictException(`${label} orgUnit ${orgUnitId} and position ${positionId} has no active membership`);
3356
+ throw new common_1.ConflictException(`${label} 找不到${subject}:組織「${scope.name}」的職位「${await this.readPositionName(manager, positionId)}」目前沒有任何有效成員`);
3123
3357
  }
3124
3358
  return uniqueTexts(memberIds);
3125
3359
  }
3126
- async readOrgUnitScopeIds(manager, orgUnitId, includeDescendants) {
3360
+ // Names only make an error readable, so a row that has gone missing falls
3361
+ // back to its id instead of hiding the real problem.
3362
+ async readPositionName(manager, positionId) {
3363
+ const position = await manager
3364
+ .getRepository(position_entity_1.PositionEntity)
3365
+ .findOne({ where: { id: positionId } });
3366
+ return position?.name ?? positionId;
3367
+ }
3368
+ /** The org unit's name and the ids it covers, or `null` once it is gone. */
3369
+ async readOrgUnitScope(manager, orgUnitId, includeDescendants) {
3127
3370
  const orgUnitRepository = manager.getRepository(org_unit_entity_1.OrgUnitEntity);
3128
3371
  const orgUnit = await orgUnitRepository.findOne({
3129
3372
  where: { deletedAt: (0, typeorm_2.IsNull)(), id: orgUnitId },
3130
3373
  });
3131
3374
  if (!orgUnit) {
3132
- throw new common_1.ConflictException(`Org unit ${orgUnitId} was not found`);
3375
+ return null;
3133
3376
  }
3134
3377
  if (!includeDescendants) {
3135
- return [orgUnit.id];
3378
+ return { name: orgUnit.name, orgUnitIds: [orgUnit.id] };
3136
3379
  }
3137
3380
  const descendants = await orgUnitRepository
3138
3381
  .createQueryBuilder('orgUnit')
3139
3382
  .where('orgUnit.deleted_at IS NULL')
3140
3383
  .andWhere('orgUnit.path <@ :path', { path: orgUnit.path })
3141
3384
  .getMany();
3142
- return uniqueTexts([orgUnit.id, ...descendants.map((unit) => unit.id)]);
3385
+ return {
3386
+ name: orgUnit.name,
3387
+ orgUnitIds: uniqueTexts([
3388
+ orgUnit.id,
3389
+ ...descendants.map((unit) => unit.id),
3390
+ ]),
3391
+ };
3143
3392
  }
3144
3393
  async processExclusiveGateway(manager, instance, token, node) {
3145
3394
  if (node.data.direction !== 'split') {
@@ -3221,7 +3470,7 @@ let WorkflowEngineService = class WorkflowEngineService {
3221
3470
  const recipients = (0, workflow_graph_1.isNotifyRecipientsEmpty)(action.recipients) &&
3222
3471
  (0, workflow_graph_1.readNotifyWebhookTargets)(action).length > 0
3223
3472
  ? []
3224
- : await this.resolveApproverResolver(manager, instance, action.recipients, `知會節點「${node.data.label}」`);
3473
+ : await this.resolveApproverResolver(manager, instance, action.recipients, `知會節點「${node.data.label}」`, '通知對象');
3225
3474
  const recipientMemberIds = uniqueTexts(recipients.map((recipient) => recipient.memberId));
3226
3475
  await this.notificationService.createServiceTaskNotifications({
3227
3476
  instance,
@@ -3249,11 +3498,7 @@ let WorkflowEngineService = class WorkflowEngineService {
3249
3498
  tokenId: token.id,
3250
3499
  }, action.webhooks)
3251
3500
  : [];
3252
- await manager.getRepository(workflow_token_entity_1.WorkflowTokenEntity).save({
3253
- ...token,
3254
- consumedAt: new Date(),
3255
- status: workflow_engine_enums_1.WorkflowTokenStatusEnum.CONSUMED,
3256
- });
3501
+ await this.consumeToken(manager, token);
3257
3502
  await activityRepository.save(activityRepository.create({
3258
3503
  actorMemberId: null,
3259
3504
  eventType: workflow_engine_enums_1.ActivityLogEventTypeEnum.TOKEN_ADVANCED,
@@ -3780,6 +4025,58 @@ function buildDryRunExpressionContext(input, lastDecision) {
3780
4025
  function isRecord(value) {
3781
4026
  return typeof value === 'object' && value !== null && !Array.isArray(value);
3782
4027
  }
4028
+ // The members whose grades decide a grade-gated delegation: none when the
4029
+ // delegation leaves the seat with its principal.
4030
+ function readGradeGateMemberIds(principalMemberId, resolution) {
4031
+ return resolution.finalAssigneeMemberId === principalMemberId
4032
+ ? []
4033
+ : [principalMemberId, resolution.finalAssigneeMemberId];
4034
+ }
4035
+ /**
4036
+ * On a manager-chain node the delegate must hold at least the grade of the
4037
+ * member they replace: anything lower is not applied, the member stays and
4038
+ * the refused delegation is returned so it can be recorded. Resolution does
4039
+ * not walk further down the delegation chain once it has been refused. A
4040
+ * member without a grade (or a delegate that stays the member) is never
4041
+ * constrained. `levels` holds the grades of `readGradeGateMemberIds`.
4042
+ */
4043
+ function applyGradeGate(principalMemberId, resolution, gradeGated, levels) {
4044
+ if (!gradeGated || resolution.finalAssigneeMemberId === principalMemberId) {
4045
+ return resolution;
4046
+ }
4047
+ const principalLevel = levels.get(principalMemberId) ?? null;
4048
+ if (principalLevel === null) {
4049
+ return resolution;
4050
+ }
4051
+ const agentLevel = levels.get(resolution.finalAssigneeMemberId) ?? null;
4052
+ if (agentLevel !== null && agentLevel >= principalLevel) {
4053
+ return resolution;
4054
+ }
4055
+ return {
4056
+ delegationChain: [],
4057
+ finalAssigneeMemberId: principalMemberId,
4058
+ skippedDelegation: {
4059
+ agentLevel,
4060
+ agentMemberId: resolution.finalAssigneeMemberId,
4061
+ principalLevel,
4062
+ principalMemberId,
4063
+ reason: 'AGENT_POSITION_LEVEL_TOO_LOW',
4064
+ },
4065
+ };
4066
+ }
4067
+ function readNodeLabelKey(instanceId, nodeId) {
4068
+ return `${instanceId}\u0000${nodeId}`;
4069
+ }
4070
+ /**
4071
+ * The node's display name from an instance's workflow snapshot, or `null`
4072
+ * when the instance or node is missing, or the node carries no usable label
4073
+ * (older snapshots).
4074
+ */
4075
+ function readWorkflowNodeLabel(workflow, nodeId) {
4076
+ const label = workflow?.nodes.find((candidate) => candidate.id === nodeId)
4077
+ ?.data.label;
4078
+ return typeof label === 'string' && label.trim() ? label : null;
4079
+ }
3783
4080
  function readWorkflowNodeOrThrow(workflow, nodeId) {
3784
4081
  const node = workflow.nodes.find((candidate) => candidate.id === nodeId);
3785
4082
  if (!node) {
@@ -3880,22 +4177,26 @@ function edgeHasCondition(edge) {
3880
4177
  return Boolean(edge.data.condition?.trim() ||
3881
4178
  (edge.data.conditionFieldKey && edge.data.conditionOperator));
3882
4179
  }
3883
- function readMemberIdFromValue(value, label) {
3884
- return readMemberIdsFromValue(value, label)[0] ?? '';
4180
+ function readMissingOrgUnitMessage(label, subject, orgUnitId) {
4181
+ return `${label} 找不到${subject}:組織(${orgUnitId})已不存在,請在流程設計器重新選擇組織`;
3885
4182
  }
3886
- function readMemberIdsFromValue(value, label) {
4183
+ function findMemberIdsInValue(value) {
3887
4184
  if (typeof value === 'string' && value.trim()) {
3888
4185
  return [value.trim()];
3889
4186
  }
3890
4187
  if (Array.isArray(value)) {
3891
- const memberIds = value
4188
+ return uniqueTexts(value
3892
4189
  .filter((candidate) => typeof candidate === 'string' && Boolean(candidate.trim()))
3893
- .map((candidate) => candidate.trim());
3894
- if (memberIds.length > 0) {
3895
- return uniqueTexts(memberIds);
3896
- }
4190
+ .map((candidate) => candidate.trim()));
4191
+ }
4192
+ return [];
4193
+ }
4194
+ function readMemberIdsFromValue(value, label, subject = '簽核人', source = '') {
4195
+ const memberIds = findMemberIdsInValue(value);
4196
+ if (memberIds.length === 0) {
4197
+ throw new common_1.ConflictException(`${label} ${source}找不到${subject}`);
3897
4198
  }
3898
- throw new common_1.ConflictException(`${label} did not resolve to a member id`);
4199
+ return memberIds;
3899
4200
  }
3900
4201
  function uniqueTexts(values) {
3901
4202
  return [...new Set(values.filter((value) => value.trim()))];
@@ -3921,20 +4222,62 @@ function isOrderedSequentialTask(task, candidates) {
3921
4222
  function isInitiatorExcluded(node) {
3922
4223
  return Boolean(node.data.excludeInitiator);
3923
4224
  }
4225
+ // Everyone a node that excludes the initiator keeps out of its approvers: the
4226
+ // applicant and, when someone filed the document for them, that person too
4227
+ // (authority rule: the creator must not approve what they created).
4228
+ function readInitiatorAndSubmitter(instance) {
4229
+ const submitterMemberId = (0, instance_submitter_1.readDistinctSubmitterMemberId)(instance);
4230
+ return submitterMemberId === null
4231
+ ? [instance.initiatorMemberId]
4232
+ : [instance.initiatorMemberId, submitterMemberId];
4233
+ }
4234
+ function isOrgManagerChainNode(node) {
4235
+ return node.data.approverResolver.type === 'ORG_MANAGER_CHAIN';
4236
+ }
4237
+ // Candidates of a manager-chain node that came from the chain (or replaced the
4238
+ // excluded initiator's seat); ad-hoc approvers are chosen by a person instead.
4239
+ function isGradeGatedCandidate(node, sourceType) {
4240
+ return (isOrgManagerChainNode(node) &&
4241
+ (sourceType === 'ORG_MANAGER_CHAIN' ||
4242
+ sourceType === 'EXCLUDE_INITIATOR_MANAGER'));
4243
+ }
4244
+ // Source of the additive `skippedDelegations` payload field (absent when
4245
+ // nothing was refused, so existing payloads stay byte-identical). One record
4246
+ // per principal and delegate pair.
4247
+ function collectSkippedDelegations(delegatedCandidates, excludeInitiator, excludedMemberIds) {
4248
+ const seen = new Set();
4249
+ return delegatedCandidates.flatMap((candidate) => {
4250
+ const skipped = candidate.skippedDelegation;
4251
+ // A seat the initiator filter removes is not on the task, so a refusal on
4252
+ // it has nothing to report. Rows only the dedupe drops are still on it.
4253
+ if (!skipped ||
4254
+ (excludeInitiator &&
4255
+ isExcludedMemberOnCandidatePath(candidate, excludedMemberIds))) {
4256
+ return [];
4257
+ }
4258
+ const key = `${skipped.principalMemberId}\u0000${skipped.agentMemberId}`;
4259
+ if (seen.has(key)) {
4260
+ return [];
4261
+ }
4262
+ seen.add(key);
4263
+ return [skipped];
4264
+ });
4265
+ }
3924
4266
  // A delegate acting for the initiator is still the initiator approving their
3925
4267
  // own document, so the initiator anywhere on the delegation path (the
3926
4268
  // pre-delegation member, any hop, or the final assignee) drops the candidate.
3927
- function isInitiatorOnCandidatePath(candidate, initiatorMemberId) {
3928
- return (candidate.memberId === initiatorMemberId ||
3929
- candidate.originalMemberId === initiatorMemberId ||
3930
- candidate.delegationChain.some((step) => step.from === initiatorMemberId || step.to === initiatorMemberId));
4269
+ // The submitter is held to exactly the same rule.
4270
+ function isExcludedMemberOnCandidatePath(candidate, excludedMemberIds) {
4271
+ return excludedMemberIds.some((excludedMemberId) => candidate.memberId === excludedMemberId ||
4272
+ candidate.originalMemberId === excludedMemberId ||
4273
+ candidate.delegationChain.some((step) => step.from === excludedMemberId || step.to === excludedMemberId));
3931
4274
  }
3932
4275
  // The initiator filter runs on the delegated list before the dedupe, so a
3933
4276
  // legitimate approver is never shadowed by an excluded entry for the same
3934
4277
  // final assignee. Without the exclusion this is the plain dedupe.
3935
- function selectNodeCandidates(delegatedCandidates, excludeInitiator, initiatorMemberId) {
4278
+ function selectNodeCandidates(delegatedCandidates, excludeInitiator, excludedMemberIds) {
3936
4279
  return uniqueRuntimeCandidates(excludeInitiator
3937
- ? delegatedCandidates.filter((candidate) => !isInitiatorOnCandidatePath(candidate, initiatorMemberId))
4280
+ ? delegatedCandidates.filter((candidate) => !isExcludedMemberOnCandidatePath(candidate, excludedMemberIds))
3938
4281
  : delegatedCandidates);
3939
4282
  }
3940
4283
  // A COUNT threshold above the people left would never be reached once the
@@ -4363,9 +4706,6 @@ function compareMembership(left, right) {
4363
4706
  }
4364
4707
  return right.effectiveFrom.localeCompare(left.effectiveFrom);
4365
4708
  }
4366
- function toDateOnlyString(value) {
4367
- return value.toISOString().slice(0, 10);
4368
- }
4369
4709
  function canReadAllWorkflows(scope) {
4370
4710
  return (scope.roles.includes('BPM_ADMIN') ||
4371
4711
  scope.permissions.some((permission) => WORKFLOW_READ_ALL_PERMISSIONS.has(permission)));
@@ -4396,9 +4736,26 @@ function validateSubmittedFormData(schema, formData) {
4396
4736
  .map((field) => field.label || field.fieldKey)
4397
4737
  .join(', ')}`);
4398
4738
  }
4399
- const tableErrors = visibleFields
4400
- .filter(form_1.isTableFieldDefinition)
4401
- .flatMap((field) => validateSubmittedTableValue(field, formData[field.fieldKey], isSubmittedFormFieldRequired(field, schema.fields, formData)));
4739
+ // A `DYNAMIC_FORM` resolver reads a member value whether or not its field is
4740
+ // visible, so a hidden field still holding a value must be well-formed too.
4741
+ // Whether a hidden field is *required* stays a visibility question above.
4742
+ const memberErrors = (0, form_member_value_validator_1.readSubmittedMemberValueErrors)(schema.fields.filter((field) => visibleFields.includes(field) ||
4743
+ isSubmittedFieldValuePresent(formData[field.fieldKey])), formData);
4744
+ if (memberErrors.length > 0) {
4745
+ throw new common_1.BadRequestException(memberErrors.join('; '));
4746
+ }
4747
+ const tableErrors = [
4748
+ ...visibleFields
4749
+ .filter(form_1.isTableFieldDefinition)
4750
+ .flatMap((field) => validateSubmittedTableValue(field, formData[field.fieldKey], isSubmittedFormFieldRequired(field, schema.fields, formData))),
4751
+ // A hidden table is not "visible", but its stored value still has to be a
4752
+ // well-formed table under the row cap: only the required / minimum checks
4753
+ // are skipped, since nobody can fill the table in.
4754
+ ...schema.fields
4755
+ .filter(form_1.isTableFieldDefinition)
4756
+ .filter((field) => field.hidden === true)
4757
+ .flatMap((field) => validateSubmittedTableValue(field, formData[field.fieldKey], false, true)),
4758
+ ];
4402
4759
  if (tableErrors.length > 0) {
4403
4760
  throw new common_1.BadRequestException(tableErrors.join('; '));
4404
4761
  }
@@ -4409,28 +4766,32 @@ function validateSubmittedFormData(schema, formData) {
4409
4766
  * required / shape / row count are enforced server side; `minimum`, `maxLength`
4410
4767
  * and friends stay a frontend hint.
4411
4768
  */
4412
- function validateSubmittedTableValue(field, value, required) {
4769
+ function validateSubmittedTableValue(field, value, required, shapeOnly = false) {
4413
4770
  const path = `formData.${field.fieldKey}`;
4414
4771
  if (typeof value === 'undefined' || value === null) {
4415
- return required || readTableMinRows(field) > 0
4416
- ? [`${path} must include at least ${readTableRequiredRowCount(field)} row(s)`]
4417
- : [];
4772
+ return shapeOnly
4773
+ ? []
4774
+ : required || readTableMinRows(field) > 0
4775
+ ? [`${path} must include at least ${readTableRequiredRowCount(field)} row(s)`]
4776
+ : [];
4418
4777
  }
4419
4778
  if (!Array.isArray(value)) {
4420
4779
  return [`${path} must be an array of rows`];
4421
4780
  }
4422
- const rowErrors = value.flatMap((row, index) => validateSubmittedTableRow(field, row, `${path}[${index}]`));
4781
+ const rowErrors = value.flatMap((row, index) => validateSubmittedTableRow(field, row, `${path}[${index}]`, shapeOnly));
4423
4782
  // A malformed row cannot be counted against min/max meaningfully; report the
4424
4783
  // shape problem first so the submitter fixes the real cause.
4425
4784
  if (rowErrors.length > 0) {
4426
4785
  return rowErrors;
4427
4786
  }
4428
- return validateSubmittedTableRowCount(field, value.length, required, path);
4787
+ return validateSubmittedTableRowCount(field, value.length, required, path, shapeOnly);
4429
4788
  }
4430
- function validateSubmittedTableRowCount(field, rowCount, required, path) {
4431
- const minRows = required
4432
- ? Math.max(readTableMinRows(field), 1)
4433
- : readTableMinRows(field);
4789
+ function validateSubmittedTableRowCount(field, rowCount, required, path, shapeOnly = false) {
4790
+ const minRows = shapeOnly
4791
+ ? 0
4792
+ : required
4793
+ ? Math.max(readTableMinRows(field), 1)
4794
+ : readTableMinRows(field);
4434
4795
  const maxRows = readTableMaxRows(field);
4435
4796
  return [
4436
4797
  ...(rowCount < minRows
@@ -4441,7 +4802,7 @@ function validateSubmittedTableRowCount(field, rowCount, required, path) {
4441
4802
  : []),
4442
4803
  ];
4443
4804
  }
4444
- function validateSubmittedTableRow(field, row, path) {
4805
+ function validateSubmittedTableRow(field, row, path, shapeOnly = false) {
4445
4806
  if (!isRecord(row)) {
4446
4807
  return [`${path} must be an object`];
4447
4808
  }
@@ -4452,10 +4813,12 @@ function validateSubmittedTableRow(field, row, path) {
4452
4813
  const cellErrors = Object.entries(row)
4453
4814
  .filter(([columnKey, cellValue]) => columnsByKey.has(columnKey) && !(0, form_1.isFormTableCellValue)(cellValue))
4454
4815
  .map(([columnKey]) => `${path}.${columnKey} is not a valid cell value`);
4455
- const requiredErrors = field.columns
4456
- .filter((column) => isSubmittedTableColumnRequired(column) &&
4457
- !isSubmittedFieldValuePresent(readRowCellValue(row, column.fieldKey)))
4458
- .map((column) => `${path}.${column.fieldKey} is required`);
4816
+ const requiredErrors = shapeOnly
4817
+ ? []
4818
+ : field.columns
4819
+ .filter((column) => isSubmittedTableColumnRequired(column) &&
4820
+ !isSubmittedFieldValuePresent(readRowCellValue(row, column.fieldKey)))
4821
+ .map((column) => `${path}.${column.fieldKey} is required`);
4459
4822
  return [...unknownKeyErrors, ...cellErrors, ...requiredErrors];
4460
4823
  }
4461
4824
  /**
@@ -4500,6 +4863,11 @@ function isFormDefinitionSchema(value) {
4500
4863
  value.fields.every((field) => isRecord(field)));
4501
4864
  }
4502
4865
  function isSubmittedFormFieldVisible(field, fields, values) {
4866
+ // A hidden (store-only) field is never shown, so nobody can fill it in:
4867
+ // treat it like a false `visibleWhen`. Its submitted value is still kept.
4868
+ if (field.hidden) {
4869
+ return false;
4870
+ }
4503
4871
  return field.visibleWhen
4504
4872
  ? evaluateFormConditionExpression(field.visibleWhen, fields, values, true)
4505
4873
  : true;
@@ -4627,6 +4995,84 @@ function isSubmittedFieldValuePresent(value) {
4627
4995
  function isNumberFormField(field) {
4628
4996
  return field.type === 'number' || field.type === 'money';
4629
4997
  }
4998
+ // A node that is skipped and then has nowhere to go keeps saying why it was
4999
+ // skipped, then why the branch ends; any other node just says why it ends.
5000
+ function readDryRunStepResultMessage(assignmentMessage, stopMessage, defaultMessage, skipped) {
5001
+ const message = assignmentMessage ?? defaultMessage;
5002
+ if (stopMessage === null) {
5003
+ return message;
5004
+ }
5005
+ return skipped ? `${endSentence(message)}${stopMessage}` : stopMessage;
5006
+ }
5007
+ // The skip reason and the stop reason are two sentences; a reason that does not
5008
+ // already end in full-stop punctuation gets one so they do not run together.
5009
+ function endSentence(message) {
5010
+ return /[。!?.!?]$/.test(message) ? message : `${message}。`;
5011
+ }
5012
+ // Why a manager resolver's fixed fallback cannot take over when the initiator
5013
+ // has no manager, or `null` when it can: like the runtime, it must exist, name
5014
+ // a member, and not be the initiator unless the node allows self-approval.
5015
+ function readDryRunFallbackProblem(initiatorMemberId, fallback) {
5016
+ if (!fallback || fallback.type !== 'DIRECT') {
5017
+ return 'NOT_SET';
5018
+ }
5019
+ const memberId = fallback.memberId.trim();
5020
+ if (memberId === '') {
5021
+ return 'BLANK_MEMBER';
5022
+ }
5023
+ return memberId === initiatorMemberId && !fallback.allowInitiatorSelfApproval
5024
+ ? 'IS_INITIATOR'
5025
+ : null;
5026
+ }
5027
+ function readDryRunFallbackMemberIds(input, fallback) {
5028
+ return readDryRunFallbackProblem(input.initiatorMemberId, fallback) ===
5029
+ null && fallback?.type === 'DIRECT'
5030
+ ? [fallback.memberId.trim()]
5031
+ : [];
5032
+ }
5033
+ function readDryRunNodeName(node) {
5034
+ return node.data.label?.trim() || node.id;
5035
+ }
5036
+ // The dry run cannot look the organisation up, so these resolvers stand in
5037
+ // with a placeholder that names what the runtime would resolve.
5038
+ function readDryRunPlaceholderAssignee(resolver) {
5039
+ if (resolver.type === 'POSITION') {
5040
+ return `position:${resolver.positionId}`;
5041
+ }
5042
+ if (resolver.type === 'ORG_UNIT_MEMBER') {
5043
+ return `orgUnitMember:${resolver.orgUnitId}`;
5044
+ }
5045
+ if (resolver.type === 'ORG_UNIT_POSITION') {
5046
+ return `orgUnitPosition:${resolver.orgUnitId}:${resolver.positionId}`;
5047
+ }
5048
+ if (resolver.type === 'ORG_UNIT_MANAGER') {
5049
+ return `orgUnitManager:${resolver.orgUnitId}`;
5050
+ }
5051
+ return resolver.type === 'ORG_MANAGER_CHAIN'
5052
+ ? `orgManagerChain:${resolver.minPositionLevel}`
5053
+ : resolver.type;
5054
+ }
5055
+ function readDryRunUnresolvedApproverMessage(resolver, nodeName, initiatorMemberId) {
5056
+ if (resolver.type === 'DIRECT') {
5057
+ return `簽核節點「${nodeName}」尚未選擇簽核人,請在流程設計器點選此節點設定簽核人`;
5058
+ }
5059
+ if (resolver.type === 'DYNAMIC_FORM') {
5060
+ return `簽核節點「${nodeName}」依表單欄位找不到簽核人,請確認試跑資料中該欄位有填人員`;
5061
+ }
5062
+ if (resolver.type === 'EXPRESSION') {
5063
+ return `簽核節點「${nodeName}」依運算式找不到簽核人,請確認運算式的結果`;
5064
+ }
5065
+ const fallbackProblem = resolver.type === 'ORG_MANAGER'
5066
+ ? readDryRunFallbackProblem(initiatorMemberId, resolver.fallback)
5067
+ : null;
5068
+ if (fallbackProblem === 'BLANK_MEMBER') {
5069
+ return `簽核節點「${nodeName}」找不到主管,且改派固定人未設定`;
5070
+ }
5071
+ if (fallbackProblem === 'IS_INITIATOR') {
5072
+ return `簽核節點「${nodeName}」找不到主管,且改派固定人是申請人本人,此節點未允許申請人自簽`;
5073
+ }
5074
+ return `簽核節點「${nodeName}」依申請人的組織找不到主管,請確認試跑時的申請人資料`;
5075
+ }
4630
5076
  function createDryRunStep(input) {
4631
5077
  return Object.assign(new workflow_dry_run_object_1.WorkflowDryRunStepObject(), {
4632
5078
  assigneeMemberId: input.assigneeMemberId,
@@ -4703,4 +5149,34 @@ function mapDecisionToResolution(action) {
4703
5149
  return notification_enums_1.NotificationResolutionEnum.TRANSFERRED;
4704
5150
  }
4705
5151
  }
5152
+ // The limit is stated in characters, so a character outside the BMP counts
5153
+ // once rather than as its two UTF-16 code units.
5154
+ function countCodePoints(value) {
5155
+ return Array.from(value).length;
5156
+ }
5157
+ function normalizeBusinessKey(businessKey) {
5158
+ const trimmed = businessKey?.trim() ?? '';
5159
+ if (trimmed === '') {
5160
+ return null;
5161
+ }
5162
+ if (countCodePoints(trimmed) > exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH) {
5163
+ throw new common_1.BadRequestException(`業務鍵長度不可超過 ${exports.MAX_APPROVAL_INSTANCE_BUSINESS_KEY_LENGTH} 個字元`);
5164
+ }
5165
+ return trimmed;
5166
+ }
5167
+ /**
5168
+ * Turns Postgres rejecting a duplicate (template, business key) into a
5169
+ * Conflict. Any other failure, including other unique violations inside the
5170
+ * same transaction, surfaces as it is.
5171
+ */
5172
+ function throwBusinessKeyConflictOrRethrow(error, businessKey) {
5173
+ const { code, constraint, driverError } = (error ?? {});
5174
+ if (businessKey !== null &&
5175
+ (code ?? driverError?.code) === '23505' &&
5176
+ (constraint ?? driverError?.constraint) ===
5177
+ APPROVAL_INSTANCE_BUSINESS_KEY_INDEX) {
5178
+ throw new common_1.ConflictException(`此範本已有相同業務鍵(${businessKey})的簽核單`);
5179
+ }
5180
+ throw error;
5181
+ }
4706
5182
  //# sourceMappingURL=workflow-engine.service.js.map