feat: cancel running instances as CANCELLED without initiator checks
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Ordo
|
# Ordo
|
||||||
|
|
||||||
轻量审批流程引擎。宿主通过 `OrdoEngine` 注册流程定义、发起实例、审批/驳回/转派/撤回,并查询任务、实例与审计历史。引擎不绑定业务表单、用户体系或设计器 UI,当前也不自带 REST;业务数据放在 `ProcessContext` 里。
|
轻量审批流程引擎。宿主通过 `OrdoEngine` 注册流程定义、发起实例、审批/驳回/转派/撤回/取消,并查询任务、实例与审计历史。引擎不绑定业务表单、用户体系或设计器 UI,当前也不自带 REST;业务数据放在 `ProcessContext` 里。
|
||||||
|
|
||||||
要求 **Java 17+**。当前版本 `0.0.1-SNAPSHOT`。
|
要求 **Java 17+**。当前版本 `0.0.1-SNAPSHOT`。
|
||||||
|
|
||||||
@@ -22,13 +22,14 @@
|
|||||||
- 会签/或签:`ApprovalPolicy.ALL` / `ANY`(多候选人)
|
- 会签/或签:`ApprovalPolicy.ALL` / `ANY`(多候选人)
|
||||||
- ACTION 步骤:事务提交后调用宿主 `ActionHandler`
|
- ACTION 步骤:事务提交后调用宿主 `ActionHandler`
|
||||||
- 发起人撤回:`WITHDRAWN`,待办任务 `SKIPPED`
|
- 发起人撤回:`WITHDRAWN`,待办任务 `SKIPPED`
|
||||||
|
- 管理员/系统取消:`cancel` → `CANCELLED`,待办任务 `SKIPPED`(引擎不鉴权角色)
|
||||||
- 任务转派:当前办理人 `reassign`,审计 `TASK_REASSIGNED`
|
- 任务转派:当前办理人 `reassign`,审计 `TASK_REASSIGNED`
|
||||||
- 到期升级:步骤 `due` + `processDue`,审计 `TASK_ESCALATED`
|
- 到期升级:步骤 `due` + `processDue`,审计 `TASK_ESCALATED`
|
||||||
- 分页查询:任务 / 实例 / 流程定义
|
- 分页查询:任务 / 实例 / 流程定义
|
||||||
- 审计时间线:`ProcessEvent` + `queryHistory`
|
- 审计时间线:`ProcessEvent` + `queryHistory`
|
||||||
- 扩展点:`AssigneeResolver`、`RoutingCondition`、`ActionHandler`、`OrdoEventListener`
|
- 扩展点:`AssigneeResolver`、`RoutingCondition`、`ActionHandler`、`OrdoEventListener`
|
||||||
|
|
||||||
开发计划:`CANCELLED`、定义不可变多版本、MySQL 方言、可选 REST + 目录 SPI。设计器为独立产品(不进本仓库),待 REST、目录与多版本定义之后。多租户 **暂不在计划中**。见 [docs/roadmap.md](docs/roadmap.md)。
|
开发计划:定义不可变多版本、MySQL 方言、可选 REST + 目录 SPI。设计器为独立产品(不进本仓库),待 REST、目录与多版本定义之后。多租户 **暂不在计划中**。见 [docs/roadmap.md](docs/roadmap.md)。
|
||||||
|
|
||||||
详细用法(定义 JSON、扩展点、异常、查询、ACTION/审计语义)见 **[docs/usage.md](docs/usage.md)**。对外行为变更时同步更新该文档。
|
详细用法(定义 JSON、扩展点、异常、查询、ACTION/审计语义)见 **[docs/usage.md](docs/usage.md)**。对外行为变更时同步更新该文档。
|
||||||
|
|
||||||
@@ -117,8 +118,9 @@ ordo:
|
|||||||
**办理人** 必须等于任务 `assignee`,否则 `UnauthorizedTaskOperationException`(`approve` / `reject` / `reassign`)。
|
**办理人** 必须等于任务 `assignee`,否则 `UnauthorizedTaskOperationException`(`approve` / `reject` / `reassign`)。
|
||||||
**转派** 只改 PENDING 任务的 `assignee`,不推进步骤。
|
**转派** 只改 PENDING 任务的 `assignee`,不推进步骤。
|
||||||
**撤回** 仅发起人可操作,且实例须为 `RUNNING`。
|
**撤回** 仅发起人可操作,且实例须为 `RUNNING`。
|
||||||
|
**取消** `cancel` 任意非空 `actor`,实例须为 `RUNNING`;谁能调用由宿主决定。
|
||||||
|
|
||||||
实例状态:`RUNNING` / `APPROVED` / `REJECTED` / `WITHDRAWN`。
|
实例状态:`RUNNING` / `APPROVED` / `REJECTED` / `WITHDRAWN` / `CANCELLED`。
|
||||||
任务状态:`PENDING` / `APPROVED` / `REJECTED` / `SKIPPED`。
|
任务状态:`PENDING` / `APPROVED` / `REJECTED` / `SKIPPED`。
|
||||||
|
|
||||||
**ACTION** 在审批事务提交之后执行。失败只记 `FAILED`、打日志、发 `ACTION_FAILED`,**不回滚已生效审批、不阻塞后续步骤**。可重试策略留给宿主(listener 或 `queryActionExecutions`)。
|
**ACTION** 在审批事务提交之后执行。失败只记 `FAILED`、打日志、发 `ACTION_FAILED`,**不回滚已生效审批、不阻塞后续步骤**。可重试策略留给宿主(listener 或 `queryActionExecutions`)。
|
||||||
|
|||||||
+1
-4
@@ -15,15 +15,12 @@
|
|||||||
- 审计 `ProcessEvent` / `queryHistory`;`OrdoEventListener`
|
- 审计 `ProcessEvent` / `queryHistory`;`OrdoEventListener`
|
||||||
- 任务转派 `reassign` / `TASK_REASSIGNED`
|
- 任务转派 `reassign` / `TASK_REASSIGNED`
|
||||||
- 到期升级 `processDue` / `TASK_ESCALATED`
|
- 到期升级 `processDue` / `TASK_ESCALATED`
|
||||||
|
- 管理员/系统取消 `cancel` / `CANCELLED`
|
||||||
|
|
||||||
## 开发计划(确定要做)
|
## 开发计划(确定要做)
|
||||||
|
|
||||||
下列能力已纳入计划,尚未实现。实现顺序可按依赖调整,但范围本身不从计划中拿掉。
|
下列能力已纳入计划,尚未实现。实现顺序可按依赖调整,但范围本身不从计划中拿掉。
|
||||||
|
|
||||||
### CANCELLED
|
|
||||||
|
|
||||||
目前终态只有 `APPROVED` / `REJECTED` / `WITHDRAWN`(撤回仅发起人)。计划增加管理员/系统取消语义 `CANCELLED`,与撤回区分。
|
|
||||||
|
|
||||||
### 定义不可变多版本
|
### 定义不可变多版本
|
||||||
|
|
||||||
当前同 `id` 用 `replace` 整体替换;存在 `RUNNING` 实例时拒绝。计划改为定义不可变多版本:新版本不改写旧版本;运行中实例锁定发起时所用版本。
|
当前同 `id` 用 `replace` 整体替换;存在 `RUNNING` 实例时拒绝。计划改为定义不可变多版本:新版本不改写旧版本;运行中实例锁定发起时所用版本。
|
||||||
|
|||||||
+12
-8
@@ -12,7 +12,7 @@ Ordo 是嵌入宿主进程的审批引擎,入口是 `OrdoEngine`。
|
|||||||
|
|
||||||
产品边界:不做业务表单、用户体系、多租户;不内置设计器 UI。业务字段放在 `ProcessContext`(不可变 `Map<String, Object>`)。当前也**没有** REST;HTTP 仍由宿主自建。计划中的可选 REST 与独立设计器见 [roadmap.md](roadmap.md)。
|
产品边界:不做业务表单、用户体系、多租户;不内置设计器 UI。业务字段放在 `ProcessContext`(不可变 `Map<String, Object>`)。当前也**没有** REST;HTTP 仍由宿主自建。计划中的可选 REST 与独立设计器见 [roadmap.md](roadmap.md)。
|
||||||
|
|
||||||
开发计划(尚未提供,见 [roadmap.md](roadmap.md)):`CANCELLED`、定义不可变多版本、MySQL 方言、可选 REST + 目录 SPI。
|
开发计划(尚未提供,见 [roadmap.md](roadmap.md)):定义不可变多版本、MySQL 方言、可选 REST + 目录 SPI。
|
||||||
|
|
||||||
## 2. 模块与接入
|
## 2. 模块与接入
|
||||||
|
|
||||||
@@ -185,6 +185,7 @@ start → RUNNING
|
|||||||
approve / 路由结束 → APPROVED
|
approve / 路由结束 → APPROVED
|
||||||
reject(步被否决)→ REJECTED
|
reject(步被否决)→ REJECTED
|
||||||
withdraw(仅发起人)→ WITHDRAWN,剩余 PENDING 任务 → SKIPPED
|
withdraw(仅发起人)→ WITHDRAWN,剩余 PENDING 任务 → SKIPPED
|
||||||
|
cancel(任意非空 actor)→ CANCELLED,剩余 PENDING 任务 → SKIPPED
|
||||||
```
|
```
|
||||||
|
|
||||||
```java
|
```java
|
||||||
@@ -197,6 +198,7 @@ ordo.approve(pending.get(0).id(), "diana", "ok");
|
|||||||
|
|
||||||
ordo.reject(taskId, "maria", "额度不足");
|
ordo.reject(taskId, "maria", "额度不足");
|
||||||
ordo.withdraw(instance.id(), "alice", "计划有变");
|
ordo.withdraw(instance.id(), "alice", "计划有变");
|
||||||
|
ordo.cancel(instance.id(), "admin", "政策变更");
|
||||||
```
|
```
|
||||||
|
|
||||||
`start` 的 `initiator`、办理人 `actor` 均不可空白。
|
`start` 的 `initiator`、办理人 `actor` 均不可空白。
|
||||||
@@ -208,14 +210,15 @@ ordo.withdraw(instance.id(), "alice", "计划有变");
|
|||||||
- `processDue(limit)`:认领 `dueAt <= now` 的 PENDING 任务(`limit > 0`),按步上 `due.then` 执行:`reassign` 换办理人(`to` 走 `AssigneeResolver`)、`notify` 可选 `ActionHandler`、`goto` 跳过当前步 PENDING 并进入 `to` 步骤(`to` 必须是步骤 id)。每种策略对一张任务最多成功一次(清空 `dueAt`)。引擎无后台线程;Spring 下 `ordo.due.poll-ms > 0` 才轮询。
|
- `processDue(limit)`:认领 `dueAt <= now` 的 PENDING 任务(`limit > 0`),按步上 `due.then` 执行:`reassign` 换办理人(`to` 走 `AssigneeResolver`)、`notify` 可选 `ActionHandler`、`goto` 跳过当前步 PENDING 并进入 `to` 步骤(`to` 必须是步骤 id)。每种策略对一张任务最多成功一次(清空 `dueAt`)。引擎无后台线程;Spring 下 `ordo.due.poll-ms > 0` 才轮询。
|
||||||
- 任务非 `PENDING`:`TaskAlreadyCompletedException`。
|
- 任务非 `PENDING`:`TaskAlreadyCompletedException`。
|
||||||
- `withdraw`:仅 `initiator`,否则 `UnauthorizedInstanceOperationException`;实例非 `RUNNING`:`InstanceAlreadyCompletedException`。
|
- `withdraw`:仅 `initiator`,否则 `UnauthorizedInstanceOperationException`;实例非 `RUNNING`:`InstanceAlreadyCompletedException`。
|
||||||
|
- `cancel`:`actor` 非空即可,**不校验**是否发起人;实例须为 `RUNNING`,否则 `InstanceAlreadyCompletedException`。谁能调用由宿主决定。
|
||||||
|
|
||||||
没有管理员代批、没有系统取消。
|
没有管理员代批。
|
||||||
|
|
||||||
### 4.3 状态
|
### 4.3 状态
|
||||||
|
|
||||||
实例 `ProcessStatus`:`RUNNING`、`APPROVED`、`REJECTED`、`WITHDRAWN`。
|
实例 `ProcessStatus`:`RUNNING`、`APPROVED`、`REJECTED`、`WITHDRAWN`、`CANCELLED`。
|
||||||
|
|
||||||
任务 `TaskStatus`:`PENDING`、`APPROVED`、`REJECTED`、`SKIPPED`(同一步其它候选人已决定结果,或实例被撤回)。
|
任务 `TaskStatus`:`PENDING`、`APPROVED`、`REJECTED`、`SKIPPED`(同一步其它候选人已决定结果,或实例被撤回/取消)。
|
||||||
|
|
||||||
`ApprovalTask.action`(`TaskAction`)仍表示**该任务**上的办理人/意见/时间;实例级时间线用 `ProcessEvent`,不要靠拼任务 action 还原流程史。
|
`ApprovalTask.action`(`TaskAction`)仍表示**该任务**上的办理人/意见/时间;实例级时间线用 `ProcessEvent`,不要靠拼任务 action 还原流程史。
|
||||||
|
|
||||||
@@ -278,7 +281,7 @@ public class MailActions implements ActionHandler {
|
|||||||
|
|
||||||
`ProcessEventType`:
|
`ProcessEventType`:
|
||||||
|
|
||||||
- `INSTANCE_STARTED` / `INSTANCE_APPROVED` / `INSTANCE_REJECTED` / `INSTANCE_WITHDRAWN`
|
- `INSTANCE_STARTED` / `INSTANCE_APPROVED` / `INSTANCE_REJECTED` / `INSTANCE_WITHDRAWN` / `INSTANCE_CANCELLED`
|
||||||
- `TASK_CREATED` / `TASK_APPROVED` / `TASK_REJECTED` / `TASK_SKIPPED` / `TASK_REASSIGNED` / `TASK_ESCALATED`
|
- `TASK_CREATED` / `TASK_APPROVED` / `TASK_REJECTED` / `TASK_SKIPPED` / `TASK_REASSIGNED` / `TASK_ESCALATED`
|
||||||
- `ACTION_SUCCEEDED` / `ACTION_FAILED`
|
- `ACTION_SUCCEEDED` / `ACTION_FAILED`
|
||||||
|
|
||||||
@@ -318,6 +321,7 @@ ACTION 成功事件发生在提交之后,因此排在同轮事务内写入的
|
|||||||
| `reassign` | 当前办理人把 PENDING 任务转给他人 |
|
| `reassign` | 当前办理人把 PENDING 任务转给他人 |
|
||||||
| `processDue` | 认领并处理已到期 PENDING 任务 |
|
| `processDue` | 认领并处理已到期 PENDING 任务 |
|
||||||
| `withdraw` | 发起人撤回 |
|
| `withdraw` | 发起人撤回 |
|
||||||
|
| `cancel` | 管理员/系统取消(引擎不鉴权角色) |
|
||||||
| `find*` | 按 id / 待办索引读取 |
|
| `find*` | 按 id / 待办索引读取 |
|
||||||
| `queryTasks` / `queryInstances` / `queryDefinitions` | 分页列表 |
|
| `queryTasks` / `queryInstances` / `queryDefinitions` | 分页列表 |
|
||||||
| `queryHistory` / `queryActionExecutions` | 实例审计与 ACTION 记录 |
|
| `queryHistory` / `queryActionExecutions` | 实例审计与 ACTION 记录 |
|
||||||
@@ -331,8 +335,8 @@ ACTION 成功事件发生在提交之后,因此排在同轮事务内写入的
|
|||||||
| `DefinitionAlreadyExistsException` | `register` 撞 id |
|
| `DefinitionAlreadyExistsException` | `register` 撞 id |
|
||||||
| `DefinitionNotFoundException` | `start` 等找不到定义 |
|
| `DefinitionNotFoundException` | `start` 等找不到定义 |
|
||||||
| `DefinitionInUseException` | `replace` 时仍有 RUNNING 实例 |
|
| `DefinitionInUseException` | `replace` 时仍有 RUNNING 实例 |
|
||||||
| `InstanceNotFoundException` | 撤回等找不到实例 |
|
| `InstanceNotFoundException` | 撤回/取消等找不到实例 |
|
||||||
| `InstanceAlreadyCompletedException` | 对非 RUNNING 实例完成/撤回 |
|
| `InstanceAlreadyCompletedException` | 对非 RUNNING 实例完成/撤回/取消 |
|
||||||
| `TaskNotFoundException` | 任务 id 不存在 |
|
| `TaskNotFoundException` | 任务 id 不存在 |
|
||||||
| `TaskAlreadyCompletedException` | 重复办理或已被 SKIPPED |
|
| `TaskAlreadyCompletedException` | 重复办理或已被 SKIPPED |
|
||||||
| `UnauthorizedTaskOperationException` | actor ≠ 当前 assignee(approve / reject / reassign) |
|
| `UnauthorizedTaskOperationException` | actor ≠ 当前 assignee(approve / reject / reassign) |
|
||||||
@@ -349,6 +353,6 @@ Flyway 脚本在 `ordo-storage-jdbc` 的 `db/migration`(V1–V6)。表包括
|
|||||||
|
|
||||||
## 13. 未提供能力
|
## 13. 未提供能力
|
||||||
|
|
||||||
开发计划中(见 [roadmap.md](roadmap.md)):`CANCELLED`、定义不可变多版本、MySQL 方言、可选 REST + 目录 SPI。独立设计器不进本仓库,等 REST、目录与多版本定义之后再做。
|
开发计划中(见 [roadmap.md](roadmap.md)):定义不可变多版本、MySQL 方言、可选 REST + 目录 SPI。独立设计器不进本仓库,等 REST、目录与多版本定义之后再做。
|
||||||
|
|
||||||
暂不在计划中:多租户、设计器 UI。当前 REST 由宿主自建。
|
暂不在计划中:多租户、设计器 UI。当前 REST 由宿主自建。
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ public interface OrdoEngine {
|
|||||||
return withdraw(instanceId, actor, null);
|
return withdraw(instanceId, actor, null);
|
||||||
}
|
}
|
||||||
ProcessInstance withdraw(String instanceId, String actor, String comment);
|
ProcessInstance withdraw(String instanceId, String actor, String comment);
|
||||||
|
|
||||||
|
default ProcessInstance cancel(String instanceId, String actor) {
|
||||||
|
return cancel(instanceId, actor, null);
|
||||||
|
}
|
||||||
|
ProcessInstance cancel(String instanceId, String actor, String comment);
|
||||||
Optional<ProcessInstance> findInstance(String instanceId);
|
Optional<ProcessInstance> findInstance(String instanceId);
|
||||||
Optional<ApprovalTask> findTask(String taskId);
|
Optional<ApprovalTask> findTask(String taskId);
|
||||||
List<ApprovalTask> findTasks(String instanceId);
|
List<ApprovalTask> findTasks(String instanceId);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public enum ProcessEventType {
|
|||||||
INSTANCE_APPROVED,
|
INSTANCE_APPROVED,
|
||||||
INSTANCE_REJECTED,
|
INSTANCE_REJECTED,
|
||||||
INSTANCE_WITHDRAWN,
|
INSTANCE_WITHDRAWN,
|
||||||
|
INSTANCE_CANCELLED,
|
||||||
ACTION_SUCCEEDED,
|
ACTION_SUCCEEDED,
|
||||||
ACTION_FAILED
|
ACTION_FAILED
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
package com.jetlumen.ordo.api;
|
package com.jetlumen.ordo.api;
|
||||||
|
|
||||||
public enum ProcessStatus {
|
public enum ProcessStatus {
|
||||||
RUNNING, APPROVED, REJECTED, WITHDRAWN
|
RUNNING, APPROVED, REJECTED, WITHDRAWN, CANCELLED
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,25 +207,37 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public synchronized ProcessInstance withdraw(String instanceId, String actor, String comment) {
|
public synchronized ProcessInstance withdraw(String instanceId, String actor, String comment) {
|
||||||
|
return terminateRunning(instanceId, actor, comment, true, ProcessStatus.WITHDRAWN,
|
||||||
|
ProcessEventType.INSTANCE_WITHDRAWN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized ProcessInstance cancel(String instanceId, String actor, String comment) {
|
||||||
|
return terminateRunning(instanceId, actor, comment, false, ProcessStatus.CANCELLED,
|
||||||
|
ProcessEventType.INSTANCE_CANCELLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProcessInstance terminateRunning(String instanceId, String actor, String comment, boolean initiatorOnly,
|
||||||
|
ProcessStatus status, ProcessEventType eventType) {
|
||||||
requireText(instanceId, "instance id");
|
requireText(instanceId, "instance id");
|
||||||
requireText(actor, "actor");
|
requireText(actor, "actor");
|
||||||
List<ProcessEvent> events = new ArrayList<>();
|
List<ProcessEvent> events = new ArrayList<>();
|
||||||
ProcessInstance withdrawn = transactionExecutor.execute(() -> {
|
ProcessInstance completed = transactionExecutor.execute(() -> {
|
||||||
ProcessInstance instance = instanceRepository.findById(instanceId)
|
ProcessInstance instance = instanceRepository.findById(instanceId)
|
||||||
.orElseThrow(() -> new InstanceNotFoundException(instanceId));
|
.orElseThrow(() -> new InstanceNotFoundException(instanceId));
|
||||||
if (!instance.initiator().equals(actor)) {
|
if (initiatorOnly && !instance.initiator().equals(actor)) {
|
||||||
throw new UnauthorizedInstanceOperationException(instanceId, actor);
|
throw new UnauthorizedInstanceOperationException(instanceId, actor);
|
||||||
}
|
}
|
||||||
if (instance.status() != ProcessStatus.RUNNING) {
|
if (instance.status() != ProcessStatus.RUNNING) {
|
||||||
throw new InstanceAlreadyCompletedException(instanceId);
|
throw new InstanceAlreadyCompletedException(instanceId);
|
||||||
}
|
}
|
||||||
Instant now = clock.instant();
|
Instant now = clock.instant();
|
||||||
ProcessInstance completed = new ProcessInstance(instance.id(), instance.definitionId(), instance.initiator(),
|
ProcessInstance finished = new ProcessInstance(instance.id(), instance.definitionId(), instance.initiator(),
|
||||||
ProcessStatus.WITHDRAWN, instance.startedAt(), now, instance.context());
|
status, instance.startedAt(), now, instance.context());
|
||||||
if (!instanceRepository.completeIfRunning(completed)) {
|
if (!instanceRepository.completeIfRunning(finished)) {
|
||||||
throw new InstanceAlreadyCompletedException(instanceId);
|
throw new InstanceAlreadyCompletedException(instanceId);
|
||||||
}
|
}
|
||||||
record(events, instanceId, null, null, ProcessEventType.INSTANCE_WITHDRAWN, actor, comment, now);
|
record(events, instanceId, null, null, eventType, actor, comment, now);
|
||||||
TaskAction action = new TaskAction(actor, comment, now);
|
TaskAction action = new TaskAction(actor, comment, now);
|
||||||
for (ApprovalTask pending : taskRepository.findPendingByInstanceId(instanceId)) {
|
for (ApprovalTask pending : taskRepository.findPendingByInstanceId(instanceId)) {
|
||||||
ApprovalTask skipped = new ApprovalTask(pending.id(), pending.instanceId(), pending.stepId(),
|
ApprovalTask skipped = new ApprovalTask(pending.id(), pending.instanceId(), pending.stepId(),
|
||||||
@@ -236,10 +248,10 @@ public final class DefaultOrdoEngine implements OrdoEngine {
|
|||||||
comment, now);
|
comment, now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return completed;
|
return finished;
|
||||||
});
|
});
|
||||||
dispatch(events);
|
dispatch(events);
|
||||||
return withdrawn;
|
return completed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -110,6 +110,11 @@ public final class InMemoryOrdoEngine implements OrdoEngine {
|
|||||||
return delegate.withdraw(instanceId, actor, comment);
|
return delegate.withdraw(instanceId, actor, comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ProcessInstance cancel(String instanceId, String actor, String comment) {
|
||||||
|
return delegate.cancel(instanceId, actor, comment);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<ProcessInstance> findInstance(String instanceId) {
|
public Optional<ProcessInstance> findInstance(String instanceId) {
|
||||||
return delegate.findInstance(instanceId);
|
return delegate.findInstance(instanceId);
|
||||||
|
|||||||
@@ -239,6 +239,40 @@ class InMemoryOrdoEngineTest {
|
|||||||
assertThrows(InstanceAlreadyCompletedException.class, () -> engine.withdraw(instance.id(), "alice"));
|
assertThrows(InstanceAlreadyCompletedException.class, () -> engine.withdraw(instance.id(), "alice"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nonInitiatorCanCancelWhileALaterStepIsPending() {
|
||||||
|
var instance = engine.start("leave", "alice");
|
||||||
|
engine.approve(engine.findTasks(instance.id()).get(0).id(), "maria");
|
||||||
|
|
||||||
|
ProcessInstance cancelled = engine.cancel(instance.id(), "admin", "policy");
|
||||||
|
|
||||||
|
assertEquals(ProcessStatus.CANCELLED, cancelled.status());
|
||||||
|
assertEquals(ProcessStatus.CANCELLED, engine.findInstance(instance.id()).orElseThrow().status());
|
||||||
|
assertTrue(engine.findPendingTasksByInstanceId(instance.id()).isEmpty());
|
||||||
|
ApprovalTask hrTask = engine.findTasks(instance.id()).stream()
|
||||||
|
.filter(task -> task.stepId().equals("hr"))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
|
assertEquals(TaskStatus.SKIPPED, hrTask.status());
|
||||||
|
assertEquals("admin", hrTask.action().actor());
|
||||||
|
assertEquals("policy", hrTask.action().comment());
|
||||||
|
assertEquals(TaskStatus.APPROVED, engine.findTasks(instance.id()).stream()
|
||||||
|
.filter(task -> task.stepId().equals("manager"))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow()
|
||||||
|
.status());
|
||||||
|
assertThrows(InstanceAlreadyCompletedException.class, () -> engine.withdraw(instance.id(), "alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cancelIsRejectedForMissingAndCompletedInstances() {
|
||||||
|
var instance = engine.start("leave", "alice");
|
||||||
|
assertThrows(InstanceNotFoundException.class, () -> engine.cancel("missing", "admin"));
|
||||||
|
|
||||||
|
engine.reject(engine.findTasks(instance.id()).get(0).id(), "maria");
|
||||||
|
assertThrows(InstanceAlreadyCompletedException.class, () -> engine.cancel(instance.id(), "admin"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsBlankRuntimeArguments() {
|
void rejectsBlankRuntimeArguments() {
|
||||||
assertThrows(IllegalArgumentException.class, () -> engine.start("leave", " "));
|
assertThrows(IllegalArgumentException.class, () -> engine.start("leave", " "));
|
||||||
@@ -249,6 +283,7 @@ class InMemoryOrdoEngineTest {
|
|||||||
assertThrows(IllegalArgumentException.class, () -> engine.approve(task.id(), " "));
|
assertThrows(IllegalArgumentException.class, () -> engine.approve(task.id(), " "));
|
||||||
assertThrows(IllegalArgumentException.class, () -> engine.reject(task.id(), " "));
|
assertThrows(IllegalArgumentException.class, () -> engine.reject(task.id(), " "));
|
||||||
assertThrows(IllegalArgumentException.class, () -> engine.reassign(task.id(), " ", "diana"));
|
assertThrows(IllegalArgumentException.class, () -> engine.reassign(task.id(), " ", "diana"));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> engine.cancel(instance.id(), " "));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -565,6 +600,12 @@ class InMemoryOrdoEngineTest {
|
|||||||
assertEquals(1, runningOnly.totalElements());
|
assertEquals(1, runningOnly.totalElements());
|
||||||
assertEquals(running.id(), runningOnly.content().get(0).id());
|
assertEquals(running.id(), runningOnly.content().get(0).id());
|
||||||
|
|
||||||
|
var toCancel = engine.start("leave", "carol");
|
||||||
|
engine.cancel(toCancel.id(), "admin");
|
||||||
|
Page<ProcessInstance> cancelledOnly = engine.queryInstances(
|
||||||
|
InstanceQuery.any().withStatus(ProcessStatus.CANCELLED), new PageRequest(0, 10));
|
||||||
|
assertEquals(List.of(toCancel.id()), cancelledOnly.content().stream().map(ProcessInstance::id).toList());
|
||||||
|
|
||||||
Page<ProcessInstance> byInitiator = engine.queryInstances(InstanceQuery.any().withInitiator("bob"),
|
Page<ProcessInstance> byInitiator = engine.queryInstances(InstanceQuery.any().withInitiator("bob"),
|
||||||
new PageRequest(0, 10));
|
new PageRequest(0, 10));
|
||||||
assertEquals(1, byInitiator.totalElements());
|
assertEquals(1, byInitiator.totalElements());
|
||||||
@@ -623,6 +664,21 @@ class InMemoryOrdoEngineTest {
|
|||||||
assertEquals("alice", engine.queryHistory(instance.id(), new PageRequest(0, 20)).content().get(2).actor());
|
assertEquals("alice", engine.queryHistory(instance.id(), new PageRequest(0, 20)).content().get(2).actor());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordsHistoryForCancel() {
|
||||||
|
var instance = engine.start("leave", "alice");
|
||||||
|
engine.cancel(instance.id(), "admin", "policy");
|
||||||
|
|
||||||
|
List<ProcessEvent> events = engine.queryHistory(instance.id(), new PageRequest(0, 20)).content();
|
||||||
|
assertEquals(List.of(
|
||||||
|
ProcessEventType.INSTANCE_STARTED,
|
||||||
|
ProcessEventType.TASK_CREATED,
|
||||||
|
ProcessEventType.INSTANCE_CANCELLED,
|
||||||
|
ProcessEventType.TASK_SKIPPED), events.stream().map(ProcessEvent::type).toList());
|
||||||
|
assertEquals("admin", events.get(2).actor());
|
||||||
|
assertEquals("policy", events.get(2).detail());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void notifiesListenersAfterCommitAndIsolatesListenerFailures() {
|
void notifiesListenersAfterCommitAndIsolatesListenerFailures() {
|
||||||
List<ProcessEventType> received = new java.util.ArrayList<>();
|
List<ProcessEventType> received = new java.util.ArrayList<>();
|
||||||
|
|||||||
+9
-3
@@ -24,9 +24,11 @@ class InMemoryProcessInstanceRepositoryTest {
|
|||||||
ProcessInstance i1 = instance("inst-1", "leave", "alice", ProcessStatus.RUNNING, STARTED_AT);
|
ProcessInstance i1 = instance("inst-1", "leave", "alice", ProcessStatus.RUNNING, STARTED_AT);
|
||||||
ProcessInstance i2 = instance("inst-2", "leave", "bob", ProcessStatus.RUNNING, STARTED_AT.plusSeconds(5));
|
ProcessInstance i2 = instance("inst-2", "leave", "bob", ProcessStatus.RUNNING, STARTED_AT.plusSeconds(5));
|
||||||
ProcessInstance i3 = instance("inst-3", "expense", "alice", ProcessStatus.WITHDRAWN, STARTED_AT.plusSeconds(10));
|
ProcessInstance i3 = instance("inst-3", "expense", "alice", ProcessStatus.WITHDRAWN, STARTED_AT.plusSeconds(10));
|
||||||
|
ProcessInstance i4 = instance("inst-4", "expense", "carol", ProcessStatus.CANCELLED, STARTED_AT.plusSeconds(15));
|
||||||
repository.insert(i1);
|
repository.insert(i1);
|
||||||
repository.insert(i2);
|
repository.insert(i2);
|
||||||
repository.insert(i3);
|
repository.insert(i3);
|
||||||
|
repository.insert(i4);
|
||||||
|
|
||||||
Page<ProcessInstance> byDefinition = repository.query(InstanceQuery.any().withDefinitionId("leave"),
|
Page<ProcessInstance> byDefinition = repository.query(InstanceQuery.any().withDefinitionId("leave"),
|
||||||
new PageRequest(0, 10));
|
new PageRequest(0, 10));
|
||||||
@@ -37,14 +39,18 @@ class InMemoryProcessInstanceRepositoryTest {
|
|||||||
new PageRequest(0, 10));
|
new PageRequest(0, 10));
|
||||||
assertEquals(List.of(i3), byStatus.content());
|
assertEquals(List.of(i3), byStatus.content());
|
||||||
|
|
||||||
|
Page<ProcessInstance> cancelled = repository.query(InstanceQuery.any().withStatus(ProcessStatus.CANCELLED),
|
||||||
|
new PageRequest(0, 10));
|
||||||
|
assertEquals(List.of(i4), cancelled.content());
|
||||||
|
|
||||||
Page<ProcessInstance> pageOne = repository.query(InstanceQuery.any(), new PageRequest(0, 2));
|
Page<ProcessInstance> pageOne = repository.query(InstanceQuery.any(), new PageRequest(0, 2));
|
||||||
assertEquals(3, pageOne.totalElements());
|
assertEquals(4, pageOne.totalElements());
|
||||||
assertEquals(2, pageOne.totalPages());
|
assertEquals(2, pageOne.totalPages());
|
||||||
assertTrue(pageOne.hasNext());
|
assertTrue(pageOne.hasNext());
|
||||||
assertEquals(List.of(i3, i2), pageOne.content());
|
assertEquals(List.of(i4, i3), pageOne.content());
|
||||||
|
|
||||||
Page<ProcessInstance> pageTwo = repository.query(InstanceQuery.any(), new PageRequest(1, 2));
|
Page<ProcessInstance> pageTwo = repository.query(InstanceQuery.any(), new PageRequest(1, 2));
|
||||||
assertEquals(List.of(i1), pageTwo.content());
|
assertEquals(List.of(i2, i1), pageTwo.content());
|
||||||
assertFalse(pageTwo.hasNext());
|
assertFalse(pageTwo.hasNext());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
@@ -266,6 +266,28 @@ class JdbcOrdoEngineIntegrationTest {
|
|||||||
assertThrows(InstanceAlreadyCompletedException.class, () -> engine.withdraw(instance.id(), "alice"));
|
assertThrows(InstanceAlreadyCompletedException.class, () -> engine.withdraw(instance.id(), "alice"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminCanCancelARunningInstance() {
|
||||||
|
ProcessInstance instance = engine.start("leave", "alice");
|
||||||
|
engine.approve(engine.findPendingTasksByInstanceId(instance.id()).get(0).id(), "maria");
|
||||||
|
|
||||||
|
ProcessInstance cancelled = engine.cancel(instance.id(), "admin", "policy");
|
||||||
|
|
||||||
|
assertEquals(ProcessStatus.CANCELLED, cancelled.status());
|
||||||
|
assertEquals(NOW, cancelled.finishedAt());
|
||||||
|
assertTrue(engine.findPendingTasksByInstanceId(instance.id()).isEmpty());
|
||||||
|
ApprovalTask hrTask = engine.findTasks(instance.id()).stream()
|
||||||
|
.filter(task -> task.stepId().equals("hr"))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
|
assertEquals(TaskStatus.SKIPPED, hrTask.status());
|
||||||
|
assertEquals("admin", hrTask.action().actor());
|
||||||
|
assertEquals("policy", hrTask.action().comment());
|
||||||
|
assertTrue(engine.queryHistory(instance.id(), new PageRequest(0, 20)).content().stream()
|
||||||
|
.anyMatch(event -> event.type() == ProcessEventType.INSTANCE_CANCELLED));
|
||||||
|
assertThrows(InstanceAlreadyCompletedException.class, () -> engine.withdraw(instance.id(), "alice"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void persistHistoryAndActionExecutions() {
|
void persistHistoryAndActionExecutions() {
|
||||||
OrdoEngine actionEngine = new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), AssigneeResolver.direct(),
|
OrdoEngine actionEngine = new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), AssigneeResolver.direct(),
|
||||||
|
|||||||
Reference in New Issue
Block a user