feat: support multi-approver steps with ANY/ALL policy
- ApprovalStep now has candidates + ApprovalPolicy (ANY/ALL) instead of a single assignee; ApprovalStep.single(...) kept as a convenience factory for the single-approver case - AssigneeResolver resolves per-candidate; TaskStatus gains SKIPPED for auto-skipped sibling tasks - DefaultOrdoEngine creates one task per candidate and advances per-step according to policy (ANY: first approval wins and skips the rest, only rejects once everyone rejects; ALL: fail-fast on first rejection, only advances once everyone approves) - Added ApprovalTaskRepository.findByInstanceIdAndStepId and JDBC/in-memory implementations - Added V2 migration: ordo_approval_step.policy column + ordo_step_candidate table - Updated ApprovalStepMapper/ApprovalTaskMapper/JdbcProcessDefinitionRepository for the new schema, handling null action for SKIPPED tasks - Migrated all call sites (tests, ordo-example, rhizome demo) to ApprovalStep.single(...) and the 3-arg AssigneeResolver - Added ANY/ALL policy test coverage; fixed JdbcTestSupport to apply the V2 migration and strip SQL comments before splitting on ';'
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
package com.jetlumen.ordo.api;
|
||||
|
||||
/**
|
||||
* Decision rule applied when an approval step has more than one candidate.
|
||||
* <ul>
|
||||
* <li>{@link #ANY} - the first candidate to approve advances the process; the remaining
|
||||
* pending candidate tasks for the step are marked {@link TaskStatus#SKIPPED}. The step
|
||||
* (and the whole process instance) is only rejected once every candidate has rejected.</li>
|
||||
* <li>{@link #ALL} - every candidate must approve before the process advances. A single
|
||||
* rejection immediately rejects the step (and the process instance), skipping any
|
||||
* remaining pending candidate tasks.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum ApprovalPolicy {
|
||||
ANY, ALL
|
||||
}
|
||||
@@ -1,11 +1,38 @@
|
||||
package com.jetlumen.ordo.api;
|
||||
|
||||
/** A single, named approval step in a linear process definition. */
|
||||
public record ApprovalStep(String id, String name, String assignee) {
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* A single, named approval step in a process definition. A step has one or more candidate
|
||||
* assignees; when there is more than one candidate, {@link #policy()} decides whether any
|
||||
* single approval is enough ({@link ApprovalPolicy#ANY}) or every candidate must approve
|
||||
* ({@link ApprovalPolicy#ALL}).
|
||||
*/
|
||||
public record ApprovalStep(String id, String name, List<String> candidates, ApprovalPolicy policy) {
|
||||
public ApprovalStep {
|
||||
requireText(id, "step id");
|
||||
requireText(name, "step name");
|
||||
requireText(assignee, "step assignee");
|
||||
Objects.requireNonNull(candidates, "candidates must not be null");
|
||||
candidates = List.copyOf(candidates);
|
||||
if (candidates.isEmpty()) {
|
||||
throw new IllegalArgumentException("a step must have at least one candidate");
|
||||
}
|
||||
Set<String> distinct = new HashSet<>();
|
||||
for (String candidate : candidates) {
|
||||
requireText(candidate, "candidate");
|
||||
if (!distinct.add(candidate)) {
|
||||
throw new IllegalArgumentException("duplicate candidate in step " + id + ": " + candidate);
|
||||
}
|
||||
}
|
||||
Objects.requireNonNull(policy, "policy must not be null");
|
||||
}
|
||||
|
||||
/** Convenience factory for the common case of a single, fixed approver. */
|
||||
public static ApprovalStep single(String id, String name, String assignee) {
|
||||
return new ApprovalStep(id, name, List.of(assignee), ApprovalPolicy.ANY);
|
||||
}
|
||||
|
||||
static void requireText(String value, String field) {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package com.jetlumen.ordo.api;
|
||||
|
||||
/** Resolves the current assignee for an approval step when a task is created. */
|
||||
/**
|
||||
* Resolves the actual assignee for a single candidate of an approval step when its task is
|
||||
* created. The default implementation treats each candidate as the literal assignee; hosts can
|
||||
* supply their own resolver to translate a candidate placeholder (e.g. a role name) into a
|
||||
* concrete user.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface AssigneeResolver {
|
||||
String resolve(ApprovalStep step, ProcessContext context);
|
||||
String resolve(String candidate, ApprovalStep step, ProcessContext context);
|
||||
|
||||
static AssigneeResolver direct() {
|
||||
return (step, context) -> step.assignee();
|
||||
return (candidate, step, context) -> candidate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.jetlumen.ordo.api;
|
||||
|
||||
public enum TaskStatus {
|
||||
PENDING, APPROVED, REJECTED
|
||||
PENDING, APPROVED, REJECTED,
|
||||
/** Auto-completed because a sibling candidate task already settled the step's outcome. */
|
||||
SKIPPED
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ public interface ApprovalTaskRepository {
|
||||
|
||||
List<ApprovalTask> findByInstanceId(String instanceId);
|
||||
|
||||
/** Returns every candidate task created for a single step instance, in creation order. */
|
||||
List<ApprovalTask> findByInstanceIdAndStepId(String instanceId, String stepId);
|
||||
|
||||
List<ApprovalTask> findPendingByAssignee(String assignee);
|
||||
|
||||
List<ApprovalTask> findPendingByInstanceId(String instanceId);
|
||||
|
||||
@@ -12,9 +12,17 @@ class ProcessDefinitionTest {
|
||||
|
||||
@Test
|
||||
void rejectsBlankStepFields() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new ApprovalStep(" ", "Manager", "maria"));
|
||||
assertThrows(IllegalArgumentException.class, () -> new ApprovalStep("manager", " ", "maria"));
|
||||
assertThrows(IllegalArgumentException.class, () -> new ApprovalStep("manager", "Manager", " "));
|
||||
assertThrows(IllegalArgumentException.class, () -> ApprovalStep.single(" ", "Manager", "maria"));
|
||||
assertThrows(IllegalArgumentException.class, () -> ApprovalStep.single("manager", " ", "maria"));
|
||||
assertThrows(IllegalArgumentException.class, () -> ApprovalStep.single("manager", "Manager", " "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsStepsWithNoOrDuplicateCandidates() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ApprovalStep("manager", "Manager", List.of(), ApprovalPolicy.ANY));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ApprovalStep("manager", "Manager", List.of("maria", "maria"), ApprovalPolicy.ALL));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -22,14 +30,14 @@ class ProcessDefinitionTest {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ProcessDefinition("leave", "Leave request", List.of()));
|
||||
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", "Leave request", List.of(
|
||||
new ApprovalStep("manager", "Manager approval", "maria"),
|
||||
new ApprovalStep("manager", "HR approval", "henry")
|
||||
ApprovalStep.single("manager", "Manager approval", "maria"),
|
||||
ApprovalStep.single("manager", "HR approval", "henry")
|
||||
)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBlankDefinitionFields() {
|
||||
List<ApprovalStep> steps = List.of(new ApprovalStep("manager", "Manager approval", "maria"));
|
||||
List<ApprovalStep> steps = List.of(ApprovalStep.single("manager", "Manager approval", "maria"));
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition(" ", "Leave request", steps));
|
||||
assertThrows(IllegalArgumentException.class, () -> new ProcessDefinition("leave", " ", steps));
|
||||
@@ -38,13 +46,13 @@ class ProcessDefinitionTest {
|
||||
@Test
|
||||
void copiesTheSuppliedStepList() {
|
||||
List<ApprovalStep> suppliedSteps = new ArrayList<>();
|
||||
suppliedSteps.add(new ApprovalStep("manager", "Manager approval", "maria"));
|
||||
suppliedSteps.add(ApprovalStep.single("manager", "Manager approval", "maria"));
|
||||
ProcessDefinition definition = new ProcessDefinition("leave", "Leave request", suppliedSteps);
|
||||
|
||||
suppliedSteps.add(new ApprovalStep("hr", "HR approval", "henry"));
|
||||
suppliedSteps.add(ApprovalStep.single("hr", "HR approval", "henry"));
|
||||
|
||||
assertEquals(1, definition.steps().size());
|
||||
assertThrows(UnsupportedOperationException.class,
|
||||
() -> definition.steps().add(new ApprovalStep("lead", "Lead approval", "lee")));
|
||||
() -> definition.steps().add(ApprovalStep.single("lead", "Lead approval", "lee")));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user