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:
0264408
2026-09-10 18:20:33 +08:00
parent c524c74289
commit 581823290a
22 changed files with 440 additions and 90 deletions
@@ -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")));
}
}