feat(workflow): 14-day beoordeling escalation timer + hosted worker (S-14, refs #15)

Add a non-interrupting P14D boundary timer on the Beoordelen user task that fires
an external-worker task (BeoordelingEscaleren) to the escalation end; wire the
hosted BeoordelingEscalatiePump and register the escalation client/processor in DI.
On timeout the still-open beoordeling is reassigned to teamlead (ADR-0015).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 09:06:27 +02:00
parent 33160fa7d0
commit aa6132a6a9
3 changed files with 97 additions and 1 deletions

View File

@@ -21,6 +21,7 @@ builder.Services.AddHttpClient<FlowableWorkflowClient>();
builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IUserTaskClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IBeoordelingEscalatieClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
builder.Services.AddScoped<SubmitRegistration>();
@@ -30,9 +31,13 @@ builder.Services.AddScoped<WithdrawRegistration>();
builder.Services.AddScoped<Werkbak>();
builder.Services.AddScoped<OpenZaakWorker>();
builder.Services.AddScoped<OpenZaakJobProcessor>();
builder.Services.AddScoped<BeoordelingEscalatieProcessor>();
// The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion.
builder.Services.AddHostedService<OpenZaakJobPump>();
// The escalation worker polls the BeoordelingEscaleren jobs the 14-day timer parks and reassigns
// each overdue beoordeling to the teamlead (S-14).
builder.Services.AddHostedService<BeoordelingEscalatiePump>();
var app = builder.Build();

View File

@@ -0,0 +1,49 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Big.Infrastructure;
/// <summary>
/// The hosted polling loop of the beoordeling-escalation worker (S-14, ADR-0015): on an interval it
/// resolves a scoped <see cref="BeoordelingEscalatieProcessor"/> and asks it to drain the parked
/// <c>BeoordelingEscaleren</c> jobs. A deliberately thin shell — all acquire/reassign/complete logic
/// lives in the processor, which is unit-tested; this class only owns the timer, the per-tick scope,
/// and loop resilience. Structurally identical to <see cref="OpenZaakJobPump"/>.
/// </summary>
public sealed class BeoordelingEscalatiePump(
IServiceScopeFactory scopeFactory,
FlowableOptions options,
ILogger<BeoordelingEscalatiePump> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = scopeFactory.CreateScope();
var processor = scope.ServiceProvider.GetRequiredService<BeoordelingEscalatieProcessor>();
await processor.PumpOnceAsync(options.MaxJobsPerPoll, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
// A transient fault (e.g. Flowable briefly unreachable) must not kill the loop.
logger.LogError(ex, "BeoordelingEscaleren job poll failed; retrying after the poll interval.");
}
try
{
await Task.Delay(options.PollInterval, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
}
}