feat(workflow): beoordeling escalation to teamlead after 14 days (S-14, closes #15) (#99)
All checks were successful
CI / lint (push) Successful in 1m14s
CI / build (push) Successful in 56s
CI / unit (push) Successful in 1m9s
CI / frontend (push) Successful in 2m27s
CI / mutation (push) Successful in 5m11s
CI / verify-stack (push) Successful in 7m30s

## What & why

S-14: a beoordeling a behandelaar does not pick up within **14 days** escalates to the **teamlead**.

A non-interrupting `P14D` boundary timer on the `Beoordelen` user task fires an external-worker task
(`BeoordelingEscaleren`); the domain's escalation worker reassigns the still-open task's candidate group
from `behandelaar` to `teamlead`. The task keeps its identity — only who may claim it changes. The
escalation-via-external-worker decision is recorded in **ADR-0015** (proposal #98); it upholds §8.2
(the Workflow Client stays the only code that talks to Flowable) and keeps Flowable a stock image.

Closes #15

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation.
- [x] Implementation makes the test pass; refactor commit if structure improved.
- [x] Conventional Commits referencing the issue (`refs #NN`).
- [x] CI green — all Gitea Actions jobs.
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes (no new services; escalation is additive to the domain worker).
- [x] Docs updated (ADR-0015, demo note).
- [x] ADR added (`docs/architecture/adr-0015-beoordeling-escalation.md`).
- [x] Demo note in `docs/demo-script.md`.

## How it was built (TDD)

- **Workflow Client** (`IBeoordelingEscalatieClient`): acquire `BeoordelingEscaleren` jobs → find the open `Beoordelen` task in the instance → add `teamlead`/remove `behandelaar` candidate group → complete the job. Red → green.
- **Escalation drain loop** (`BeoordelingEscalatieProcessor`) + hosted `BeoordelingEscalatiePump`, mirroring the OpenZaak worker. Red → green.
- **BPMN**: non-interrupting `P14D` boundary timer on `Beoordelen` → external task → escalation end.
- **Both branches** (escalate after timeout; no-op when completed in time) covered by the `Een beoordeling escaleren` acceptance scenarios + Workflow Client unit tests.
- **Live integration**: `verify-domain` fires the timer early via Flowable's management API and asserts the reassignment to teamlead.

## Notes for reviewers

- Interface segregation: escalation is on `IBeoordelingEscalatieClient`, separate from the OpenZaak worker's `IExternalWorkerClient`.
- Reassignment is two REST hops (add teamlead, remove behandelaar); idempotent on redelivery — see ADR-0015 consequences.
- Local checks green: domain unit tests (104), acceptance (13), `dotnet format --verify-no-changes`, Release build (0 errors), **domain mutation 96.69%** (break 90). The `run-domain-check.sh` escalation path is CI-verified on verify-stack (local full-stack run is constrained here).
- `BeoordelingEscalatiePump` excluded from mutation, mirroring the existing `OpenZaakJobPump` exclusion.

Reviewed-on: #99
This commit was merged in pull request #99.
This commit is contained in:
2026-07-17 09:45:36 +00:00
parent 8a537edd6c
commit 7bcbc726ce
17 changed files with 737 additions and 3 deletions

View File

@@ -1,5 +1,6 @@
using Big.Application;
using Big.Domain;
using Big.Infrastructure;
namespace Acceptance.Support;
@@ -73,6 +74,59 @@ public sealed class InMemoryUserTaskClient : IUserTaskClient
}
}
/// <summary>An in-memory Flowable stand-in for the beoordeling-escalation scenario (S-14): it models
/// one Beoordelen task per process instance — its candidate group and whether it is still open — and
/// the escalation jobs the non-interrupting 14-day boundary timer parks. It drives the escalation
/// worker's behaviour without a running Flowable; the timer firing live is the verify-domain check.</summary>
public sealed class InMemoryEscalatieClient : IBeoordelingEscalatieClient
{
private sealed class ParkedTask
{
public string CandidateGroup { get; set; } = "behandelaar";
public bool IsOpen { get; set; } = true;
}
private readonly Dictionary<string, ParkedTask> _tasks = [];
private readonly List<EscalatieJob> _parked = [];
private int _seq;
/// <summary>A registration parks at Beoordelen, claimable by the behandelaar group.</summary>
public string ParkBeoordeling()
{
var pid = $"pi-{++_seq}";
_tasks[pid] = new ParkedTask();
return pid;
}
/// <summary>The behandelaar completes the beoordeling before the timer fires: the task closes.</summary>
public void CompleteBeoordeling(string processInstanceId) => _tasks[processInstanceId].IsOpen = false;
/// <summary>The 14-day boundary timer fires: a non-interrupting token parks an escalation job.</summary>
public void FireEscalationTimer(string processInstanceId)
=> _parked.Add(new EscalatieJob($"job-{++_seq}", processInstanceId));
/// <summary>The candidate group that may currently pick the task up.</summary>
public string CandidateGroupFor(string processInstanceId) => _tasks[processInstanceId].CandidateGroup;
public Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<EscalatieJob>>(_parked.Take(maxJobs).ToList());
public Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
{
// No-op if the behandelaar already completed it — the timer/completion race (§8.6).
var task = _tasks[processInstanceId];
if (task.IsOpen)
task.CandidateGroup = "teamlead";
return Task.CompletedTask;
}
public Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
{
_parked.RemoveAll(j => j.JobId == jobId);
return Task.CompletedTask;
}
}
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
public sealed class InMemoryRegistrationStore : IRegistrationStore
{