diff --git a/README.md b/README.md index 7011e90..cbeaada 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Ordo -轻量审批流程引擎。宿主通过 `OrdoEngine` 注册流程定义、发起实例、审批/驳回/转派/撤回,并查询任务、实例与审计历史。引擎不绑定业务表单、用户体系或设计器 UI,当前也不自带 REST;业务数据放在 `ProcessContext` 里。 +轻量审批流程引擎。宿主通过 `OrdoEngine` 注册流程定义、发起实例、审批/驳回/转派/撤回/取消,并查询任务、实例与审计历史。引擎不绑定业务表单、用户体系或设计器 UI,当前也不自带 REST;业务数据放在 `ProcessContext` 里。 要求 **Java 17+**。当前版本 `0.0.1-SNAPSHOT`。 @@ -22,13 +22,14 @@ - 会签/或签:`ApprovalPolicy.ALL` / `ANY`(多候选人) - ACTION 步骤:事务提交后调用宿主 `ActionHandler` - 发起人撤回:`WITHDRAWN`,待办任务 `SKIPPED` +- 管理员/系统取消:`cancel` → `CANCELLED`,待办任务 `SKIPPED`(引擎不鉴权角色) - 任务转派:当前办理人 `reassign`,审计 `TASK_REASSIGNED` - 到期升级:步骤 `due` + `processDue`,审计 `TASK_ESCALATED` - 分页查询:任务 / 实例 / 流程定义 - 审计时间线:`ProcessEvent` + `queryHistory` - 扩展点:`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)**。对外行为变更时同步更新该文档。 @@ -116,9 +117,10 @@ ordo: **办理人** 必须等于任务 `assignee`,否则 `UnauthorizedTaskOperationException`(`approve` / `reject` / `reassign`)。 **转派** 只改 PENDING 任务的 `assignee`,不推进步骤。 -**撤回** 仅发起人可操作,且实例须为 `RUNNING`。 +**撤回** 仅发起人可操作,且实例须为 `RUNNING`。 +**取消** `cancel` 任意非空 `actor`,实例须为 `RUNNING`;谁能调用由宿主决定。 -实例状态:`RUNNING` / `APPROVED` / `REJECTED` / `WITHDRAWN`。 +实例状态:`RUNNING` / `APPROVED` / `REJECTED` / `WITHDRAWN` / `CANCELLED`。 任务状态:`PENDING` / `APPROVED` / `REJECTED` / `SKIPPED`。 **ACTION** 在审批事务提交之后执行。失败只记 `FAILED`、打日志、发 `ACTION_FAILED`,**不回滚已生效审批、不阻塞后续步骤**。可重试策略留给宿主(listener 或 `queryActionExecutions`)。 diff --git a/docs/roadmap.md b/docs/roadmap.md index ef87d15..5f043f0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -15,15 +15,12 @@ - 审计 `ProcessEvent` / `queryHistory`;`OrdoEventListener` - 任务转派 `reassign` / `TASK_REASSIGNED` - 到期升级 `processDue` / `TASK_ESCALATED` +- 管理员/系统取消 `cancel` / `CANCELLED` ## 开发计划(确定要做) 下列能力已纳入计划,尚未实现。实现顺序可按依赖调整,但范围本身不从计划中拿掉。 -### CANCELLED - -目前终态只有 `APPROVED` / `REJECTED` / `WITHDRAWN`(撤回仅发起人)。计划增加管理员/系统取消语义 `CANCELLED`,与撤回区分。 - ### 定义不可变多版本 当前同 `id` 用 `replace` 整体替换;存在 `RUNNING` 实例时拒绝。计划改为定义不可变多版本:新版本不改写旧版本;运行中实例锁定发起时所用版本。 diff --git a/docs/usage.md b/docs/usage.md index b6a4dc0..bcee61b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -12,7 +12,7 @@ Ordo 是嵌入宿主进程的审批引擎,入口是 `OrdoEngine`。 产品边界:不做业务表单、用户体系、多租户;不内置设计器 UI。业务字段放在 `ProcessContext`(不可变 `Map`)。当前也**没有** REST;HTTP 仍由宿主自建。计划中的可选 REST 与独立设计器见 [roadmap.md](roadmap.md)。 -开发计划(尚未提供,见 [roadmap.md](roadmap.md)):`CANCELLED`、定义不可变多版本、MySQL 方言、可选 REST + 目录 SPI。 +开发计划(尚未提供,见 [roadmap.md](roadmap.md)):定义不可变多版本、MySQL 方言、可选 REST + 目录 SPI。 ## 2. 模块与接入 @@ -185,6 +185,7 @@ start → RUNNING approve / 路由结束 → APPROVED reject(步被否决)→ REJECTED withdraw(仅发起人)→ WITHDRAWN,剩余 PENDING 任务 → SKIPPED +cancel(任意非空 actor)→ CANCELLED,剩余 PENDING 任务 → SKIPPED ``` ```java @@ -197,6 +198,7 @@ ordo.approve(pending.get(0).id(), "diana", "ok"); ordo.reject(taskId, "maria", "额度不足"); ordo.withdraw(instance.id(), "alice", "计划有变"); +ordo.cancel(instance.id(), "admin", "政策变更"); ``` `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` 才轮询。 - 任务非 `PENDING`:`TaskAlreadyCompletedException`。 - `withdraw`:仅 `initiator`,否则 `UnauthorizedInstanceOperationException`;实例非 `RUNNING`:`InstanceAlreadyCompletedException`。 +- `cancel`:`actor` 非空即可,**不校验**是否发起人;实例须为 `RUNNING`,否则 `InstanceAlreadyCompletedException`。谁能调用由宿主决定。 -没有管理员代批、没有系统取消。 +没有管理员代批。 ### 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 还原流程史。 @@ -278,7 +281,7 @@ public class MailActions implements ActionHandler { `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` - `ACTION_SUCCEEDED` / `ACTION_FAILED` @@ -318,6 +321,7 @@ ACTION 成功事件发生在提交之后,因此排在同轮事务内写入的 | `reassign` | 当前办理人把 PENDING 任务转给他人 | | `processDue` | 认领并处理已到期 PENDING 任务 | | `withdraw` | 发起人撤回 | +| `cancel` | 管理员/系统取消(引擎不鉴权角色) | | `find*` | 按 id / 待办索引读取 | | `queryTasks` / `queryInstances` / `queryDefinitions` | 分页列表 | | `queryHistory` / `queryActionExecutions` | 实例审计与 ACTION 记录 | @@ -331,8 +335,8 @@ ACTION 成功事件发生在提交之后,因此排在同轮事务内写入的 | `DefinitionAlreadyExistsException` | `register` 撞 id | | `DefinitionNotFoundException` | `start` 等找不到定义 | | `DefinitionInUseException` | `replace` 时仍有 RUNNING 实例 | -| `InstanceNotFoundException` | 撤回等找不到实例 | -| `InstanceAlreadyCompletedException` | 对非 RUNNING 实例完成/撤回 | +| `InstanceNotFoundException` | 撤回/取消等找不到实例 | +| `InstanceAlreadyCompletedException` | 对非 RUNNING 实例完成/撤回/取消 | | `TaskNotFoundException` | 任务 id 不存在 | | `TaskAlreadyCompletedException` | 重复办理或已被 SKIPPED | | `UnauthorizedTaskOperationException` | actor ≠ 当前 assignee(approve / reject / reassign) | @@ -349,6 +353,6 @@ Flyway 脚本在 `ordo-storage-jdbc` 的 `db/migration`(V1–V6)。表包括 ## 13. 未提供能力 -开发计划中(见 [roadmap.md](roadmap.md)):`CANCELLED`、定义不可变多版本、MySQL 方言、可选 REST + 目录 SPI。独立设计器不进本仓库,等 REST、目录与多版本定义之后再做。 +开发计划中(见 [roadmap.md](roadmap.md)):定义不可变多版本、MySQL 方言、可选 REST + 目录 SPI。独立设计器不进本仓库,等 REST、目录与多版本定义之后再做。 暂不在计划中:多租户、设计器 UI。当前 REST 由宿主自建。 diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoEngine.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoEngine.java index cda5ad8..74074d4 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoEngine.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/OrdoEngine.java @@ -38,6 +38,11 @@ public interface OrdoEngine { return withdraw(instanceId, actor, null); } 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 findInstance(String instanceId); Optional findTask(String taskId); List findTasks(String instanceId); diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessEventType.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessEventType.java index 9ff1e07..5f75d5a 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessEventType.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessEventType.java @@ -12,6 +12,7 @@ public enum ProcessEventType { INSTANCE_APPROVED, INSTANCE_REJECTED, INSTANCE_WITHDRAWN, + INSTANCE_CANCELLED, ACTION_SUCCEEDED, ACTION_FAILED } diff --git a/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessStatus.java b/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessStatus.java index 30c39cb..66e212a 100644 --- a/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessStatus.java +++ b/ordo-api/src/main/java/com/jetlumen/ordo/api/ProcessStatus.java @@ -1,5 +1,5 @@ package com.jetlumen.ordo.api; public enum ProcessStatus { - RUNNING, APPROVED, REJECTED, WITHDRAWN + RUNNING, APPROVED, REJECTED, WITHDRAWN, CANCELLED } diff --git a/ordo-core/src/main/java/com/jetlumen/ordo/core/DefaultOrdoEngine.java b/ordo-core/src/main/java/com/jetlumen/ordo/core/DefaultOrdoEngine.java index 3d4decf..7f785ea 100644 --- a/ordo-core/src/main/java/com/jetlumen/ordo/core/DefaultOrdoEngine.java +++ b/ordo-core/src/main/java/com/jetlumen/ordo/core/DefaultOrdoEngine.java @@ -207,25 +207,37 @@ public final class DefaultOrdoEngine implements OrdoEngine { @Override 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(actor, "actor"); List events = new ArrayList<>(); - ProcessInstance withdrawn = transactionExecutor.execute(() -> { + ProcessInstance completed = transactionExecutor.execute(() -> { ProcessInstance instance = instanceRepository.findById(instanceId) .orElseThrow(() -> new InstanceNotFoundException(instanceId)); - if (!instance.initiator().equals(actor)) { + if (initiatorOnly && !instance.initiator().equals(actor)) { throw new UnauthorizedInstanceOperationException(instanceId, actor); } if (instance.status() != ProcessStatus.RUNNING) { throw new InstanceAlreadyCompletedException(instanceId); } Instant now = clock.instant(); - ProcessInstance completed = new ProcessInstance(instance.id(), instance.definitionId(), instance.initiator(), - ProcessStatus.WITHDRAWN, instance.startedAt(), now, instance.context()); - if (!instanceRepository.completeIfRunning(completed)) { + ProcessInstance finished = new ProcessInstance(instance.id(), instance.definitionId(), instance.initiator(), + status, instance.startedAt(), now, instance.context()); + if (!instanceRepository.completeIfRunning(finished)) { 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); for (ApprovalTask pending : taskRepository.findPendingByInstanceId(instanceId)) { ApprovalTask skipped = new ApprovalTask(pending.id(), pending.instanceId(), pending.stepId(), @@ -236,10 +248,10 @@ public final class DefaultOrdoEngine implements OrdoEngine { comment, now); } } - return completed; + return finished; }); dispatch(events); - return withdrawn; + return completed; } @Override diff --git a/ordo-core/src/main/java/com/jetlumen/ordo/core/InMemoryOrdoEngine.java b/ordo-core/src/main/java/com/jetlumen/ordo/core/InMemoryOrdoEngine.java index 844ac34..55a4642 100644 --- a/ordo-core/src/main/java/com/jetlumen/ordo/core/InMemoryOrdoEngine.java +++ b/ordo-core/src/main/java/com/jetlumen/ordo/core/InMemoryOrdoEngine.java @@ -110,6 +110,11 @@ public final class InMemoryOrdoEngine implements OrdoEngine { return delegate.withdraw(instanceId, actor, comment); } + @Override + public ProcessInstance cancel(String instanceId, String actor, String comment) { + return delegate.cancel(instanceId, actor, comment); + } + @Override public Optional findInstance(String instanceId) { return delegate.findInstance(instanceId); diff --git a/ordo-core/src/test/java/com/jetlumen/ordo/core/InMemoryOrdoEngineTest.java b/ordo-core/src/test/java/com/jetlumen/ordo/core/InMemoryOrdoEngineTest.java index 11901d2..243e9eb 100644 --- a/ordo-core/src/test/java/com/jetlumen/ordo/core/InMemoryOrdoEngineTest.java +++ b/ordo-core/src/test/java/com/jetlumen/ordo/core/InMemoryOrdoEngineTest.java @@ -239,6 +239,40 @@ class InMemoryOrdoEngineTest { 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 void rejectsBlankRuntimeArguments() { assertThrows(IllegalArgumentException.class, () -> engine.start("leave", " ")); @@ -249,6 +283,7 @@ class InMemoryOrdoEngineTest { assertThrows(IllegalArgumentException.class, () -> engine.approve(task.id(), " ")); assertThrows(IllegalArgumentException.class, () -> engine.reject(task.id(), " ")); assertThrows(IllegalArgumentException.class, () -> engine.reassign(task.id(), " ", "diana")); + assertThrows(IllegalArgumentException.class, () -> engine.cancel(instance.id(), " ")); } @Test @@ -565,6 +600,12 @@ class InMemoryOrdoEngineTest { assertEquals(1, runningOnly.totalElements()); assertEquals(running.id(), runningOnly.content().get(0).id()); + var toCancel = engine.start("leave", "carol"); + engine.cancel(toCancel.id(), "admin"); + Page 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 byInitiator = engine.queryInstances(InstanceQuery.any().withInitiator("bob"), new PageRequest(0, 10)); assertEquals(1, byInitiator.totalElements()); @@ -623,6 +664,21 @@ class InMemoryOrdoEngineTest { 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 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 void notifiesListenersAfterCommitAndIsolatesListenerFailures() { List received = new java.util.ArrayList<>(); diff --git a/ordo-core/src/test/java/com/jetlumen/ordo/core/repository/InMemoryProcessInstanceRepositoryTest.java b/ordo-core/src/test/java/com/jetlumen/ordo/core/repository/InMemoryProcessInstanceRepositoryTest.java index 03fde1a..787966d 100644 --- a/ordo-core/src/test/java/com/jetlumen/ordo/core/repository/InMemoryProcessInstanceRepositoryTest.java +++ b/ordo-core/src/test/java/com/jetlumen/ordo/core/repository/InMemoryProcessInstanceRepositoryTest.java @@ -24,9 +24,11 @@ class InMemoryProcessInstanceRepositoryTest { 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 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(i2); repository.insert(i3); + repository.insert(i4); Page byDefinition = repository.query(InstanceQuery.any().withDefinitionId("leave"), new PageRequest(0, 10)); @@ -37,14 +39,18 @@ class InMemoryProcessInstanceRepositoryTest { new PageRequest(0, 10)); assertEquals(List.of(i3), byStatus.content()); + Page cancelled = repository.query(InstanceQuery.any().withStatus(ProcessStatus.CANCELLED), + new PageRequest(0, 10)); + assertEquals(List.of(i4), cancelled.content()); + Page pageOne = repository.query(InstanceQuery.any(), new PageRequest(0, 2)); - assertEquals(3, pageOne.totalElements()); + assertEquals(4, pageOne.totalElements()); assertEquals(2, pageOne.totalPages()); assertTrue(pageOne.hasNext()); - assertEquals(List.of(i3, i2), pageOne.content()); + assertEquals(List.of(i4, i3), pageOne.content()); Page 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()); } diff --git a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcOrdoEngineIntegrationTest.java b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcOrdoEngineIntegrationTest.java index 8750069..e73f6a8 100644 --- a/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcOrdoEngineIntegrationTest.java +++ b/ordo-storage-jdbc/src/test/java/com/jetlumen/ordo/storage/jdbc/JdbcOrdoEngineIntegrationTest.java @@ -266,6 +266,28 @@ class JdbcOrdoEngineIntegrationTest { 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 void persistHistoryAndActionExecutions() { OrdoEngine actionEngine = new DefaultOrdoEngine(Clock.fixed(NOW, ZoneOffset.UTC), AssigneeResolver.direct(),