feat(workflow): beoordeling escalation to teamlead after 14 days (S-14, closes #15) #99

Merged
not merged 10 commits from feat/15-beoordeling-escalation into main 2026-07-17 09:45:37 +00:00
4 changed files with 123 additions and 0 deletions
Showing only changes of commit f02d2ef568 - Show all commits

View File

@@ -22,6 +22,9 @@
<ProjectReference Include="..\..\services\acl\Acl.Infrastructure\Acl.Infrastructure.csproj" /> <ProjectReference Include="..\..\services\acl\Acl.Infrastructure\Acl.Infrastructure.csproj" />
<ProjectReference Include="..\..\services\event-subscriber\EventSubscriber.Application\EventSubscriber.Application.csproj" /> <ProjectReference Include="..\..\services\event-subscriber\EventSubscriber.Application\EventSubscriber.Application.csproj" />
<ProjectReference Include="..\..\services\domain\Big.Application\Big.Application.csproj" /> <ProjectReference Include="..\..\services\domain\Big.Application\Big.Application.csproj" />
<!-- The beoordeling-escalation scenario drives the escalation worker (Infrastructure), as the
ACL scenario drives Acl.Infrastructure — escalation has no domain-aggregate surface (S-14). -->
<ProjectReference Include="..\..\services\domain\Big.Infrastructure\Big.Infrastructure.csproj" />
<ProjectReference Include="..\..\services\bff\Bff.Api\Bff.Api.csproj" /> <ProjectReference Include="..\..\services\bff\Bff.Api\Bff.Api.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -0,0 +1,22 @@
# language: en
# Drives S-14 (#15). A beoordeling a behandelaar does not pick up within 14 days escalates to the
# teamlead: a non-interrupting boundary timer parks a BeoordelingEscaleren job (ADR-0015) which the
# escalation worker drains, reassigning the still-open Beoordelen task's candidate group to teamlead.
# A beoordeling completed before the timer fires does not escalate. This scenario exercises the
# escalation worker against an in-memory Flowable stand-in; the timer firing live is verify-domain.
Feature: Een beoordeling escaleren
Als teamleider wil ik dat een beoordeling die na 14 dagen niet is opgepakt naar mij escaleert
zodat aanvragen niet blijven liggen.
Scenario: Na 14 dagen zonder oppakken escaleert de beoordeling naar de teamlead
Given a registration parked at the Beoordelen task for the behandelaar
When the 14-day escalation timer fires
And the escalation worker runs
Then the beoordeling is reassigned to the teamlead
Scenario: Een tijdig afgeronde beoordeling escaleert niet
Given a registration parked at the Beoordelen task for the behandelaar
When the behandelaar completes the beoordeling before the timer fires
And the 14-day escalation timer fires
And the escalation worker runs
Then the beoordeling stays with the behandelaar

View File

@@ -0,0 +1,44 @@
using Acceptance.Support;
using Big.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Reqnroll;
using Xunit;
namespace Acceptance.Steps;
/// <summary>Bindings for <c>EenBeoordelingEscaleren.feature</c> (S-14). Drives the escalation worker
/// (<see cref="BeoordelingEscalatieProcessor"/>) against an in-memory Flowable stand-in; one instance
/// per scenario. Escalation has no domain-aggregate surface — it only reassigns who may claim the
/// still-open Beoordelen task — so the scenario asserts on the task's candidate group.</summary>
[Binding]
[Scope(Feature = "Een beoordeling escaleren")]
public sealed class EenBeoordelingEscalerenSteps
{
private readonly InMemoryEscalatieClient _flowable = new();
private string _processInstanceId = "";
[Given("a registration parked at the Beoordelen task for the behandelaar")]
public void GivenARegistrationParkedForTheBehandelaar()
=> _processInstanceId = _flowable.ParkBeoordeling();
[When("the 14-day escalation timer fires")]
public void WhenTheEscalationTimerFires()
=> _flowable.FireEscalationTimer(_processInstanceId);
[When("the behandelaar completes the beoordeling before the timer fires")]
public void WhenTheBehandelaarCompletesBeforeTheTimer()
=> _flowable.CompleteBeoordeling(_processInstanceId);
[When("the escalation worker runs")]
public async Task WhenTheEscalationWorkerRuns()
=> await new BeoordelingEscalatieProcessor(
_flowable, NullLogger<BeoordelingEscalatieProcessor>.Instance).PumpOnceAsync(5);
[Then("the beoordeling is reassigned to the teamlead")]
public void ThenTheBeoordelingIsReassignedToTheTeamlead()
=> Assert.Equal("teamlead", _flowable.CandidateGroupFor(_processInstanceId));
[Then("the beoordeling stays with the behandelaar")]
public void ThenTheBeoordelingStaysWithTheBehandelaar()
=> Assert.Equal("behandelaar", _flowable.CandidateGroupFor(_processInstanceId));
}

View File

@@ -1,5 +1,6 @@
using Big.Application; using Big.Application;
using Big.Domain; using Big.Domain;
using Big.Infrastructure;
namespace Acceptance.Support; 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> /// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
public sealed class InMemoryRegistrationStore : IRegistrationStore public sealed class InMemoryRegistrationStore : IRegistrationStore
{ {