feat(workflow): Workflow Client reassigns beoordeling to teamlead (S-14, refs #15)
Implement the BeoordelingEscaleren escalation capability on the Flowable Workflow Client: acquire escalation jobs, find the still-open Beoordelen task in the instance and swap its candidate group behandelaar -> teamlead via task identity links, and complete the job. Segregated onto IBeoordelingEscalatieClient so the OpenZaak worker is unaffected (ADR-0015). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,11 +15,14 @@ namespace Big.Infrastructure;
|
|||||||
/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009).
|
/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options)
|
public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options)
|
||||||
: IWorkflowClient, IExternalWorkerClient, IUserTaskClient
|
: IWorkflowClient, IExternalWorkerClient, IUserTaskClient, IBeoordelingEscalatieClient
|
||||||
{
|
{
|
||||||
private const string Topic = "OpenZaakAanmaken";
|
private const string Topic = "OpenZaakAanmaken";
|
||||||
|
private const string EscalatieTopic = "BeoordelingEscaleren";
|
||||||
private const string ProcessDefinitionKey = "registratie";
|
private const string ProcessDefinitionKey = "registratie";
|
||||||
private const string BeoordelenTaskKey = "Beoordelen";
|
private const string BeoordelenTaskKey = "Beoordelen";
|
||||||
|
private const string BehandelaarGroup = "behandelaar";
|
||||||
|
private const string TeamleadGroup = "teamlead";
|
||||||
private const string RegistrationIdVariable = "registrationId";
|
private const string RegistrationIdVariable = "registrationId";
|
||||||
private const string ZaakUrlVariable = "zaakUrl";
|
private const string ZaakUrlVariable = "zaakUrl";
|
||||||
private const string BesluitVariable = "besluit";
|
private const string BesluitVariable = "besluit";
|
||||||
@@ -106,14 +109,46 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
|||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
|
public async Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
|
||||||
=> throw new NotImplementedException();
|
{
|
||||||
|
var request = new AcquireJobsRequest(EscalatieTopic, options.LockDuration, maxJobs, options.WorkerId);
|
||||||
|
|
||||||
public Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
|
var jobs = await PostAsync<AcquireJobsRequest, List<AcquiredEscalatieJob>>(
|
||||||
=> throw new NotImplementedException();
|
"external-job-api/acquire/jobs", request, ct) ?? [];
|
||||||
|
|
||||||
public Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
|
return [.. jobs.Select(job => new EscalatieJob(job.Id, job.ProcessInstanceId))];
|
||||||
=> throw new NotImplementedException();
|
}
|
||||||
|
|
||||||
|
public async Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// The escalation token runs in parallel to the still-open Beoordelen task (non-interrupting
|
||||||
|
// boundary timer); find that task in this instance so we can move it to the teamlead. If the
|
||||||
|
// behandelaar completed it just before the timer fired there is nothing to reassign — a
|
||||||
|
// best-effort no-op (the timer/completion race, cf. §8.6).
|
||||||
|
var query = new TaskByInstanceQueryRequest(processInstanceId, BeoordelenTaskKey);
|
||||||
|
var page = await PostAsync<TaskByInstanceQueryRequest, TaskQueryResult>(
|
||||||
|
"service/query/tasks", query, ct);
|
||||||
|
|
||||||
|
var task = page?.Data?.FirstOrDefault();
|
||||||
|
if (task is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Add teamlead, then drop behandelaar: the task now belongs to the teamlead group.
|
||||||
|
using (var added = await SendAsync(
|
||||||
|
$"service/runtime/tasks/{task.Id}/identitylinks",
|
||||||
|
new IdentityLinkRequest(TeamleadGroup, "candidate"), ct))
|
||||||
|
added.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
await DeleteAsync(
|
||||||
|
$"service/runtime/tasks/{task.Id}/identitylinks/groups/{BehandelaarGroup}/candidate", ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var response = await SendAsync(
|
||||||
|
$"external-job-api/acquire/jobs/{jobId}/complete", new CompleteJobRequest(options.WorkerId, []), ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<TResponse?> GetAsync<TResponse>(string path, CancellationToken ct)
|
private async Task<TResponse?> GetAsync<TResponse>(string path, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -124,6 +159,14 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
|||||||
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
|
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync(string path, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var message = new HttpRequestMessage(HttpMethod.Delete, new Uri(options.BaseUrl, path));
|
||||||
|
message.Headers.Authorization = new AuthenticationHeaderValue("Basic", BasicCredentials());
|
||||||
|
using var response = await http.SendAsync(message, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
|
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var response = await SendAsync(path, body, ct);
|
using var response = await SendAsync(path, body, ct);
|
||||||
@@ -163,6 +206,14 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
|||||||
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey,
|
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey,
|
||||||
[property: JsonPropertyName("includeProcessVariables")] bool IncludeProcessVariables);
|
[property: JsonPropertyName("includeProcessVariables")] bool IncludeProcessVariables);
|
||||||
|
|
||||||
|
private sealed record TaskByInstanceQueryRequest(
|
||||||
|
[property: JsonPropertyName("processInstanceId")] string ProcessInstanceId,
|
||||||
|
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey);
|
||||||
|
|
||||||
|
private sealed record IdentityLinkRequest(
|
||||||
|
[property: JsonPropertyName("group")] string Group,
|
||||||
|
[property: JsonPropertyName("type")] string Type);
|
||||||
|
|
||||||
private sealed record ClaimTaskRequest(
|
private sealed record ClaimTaskRequest(
|
||||||
[property: JsonPropertyName("action")] string Action,
|
[property: JsonPropertyName("action")] string Action,
|
||||||
[property: JsonPropertyName("assignee")] string Assignee);
|
[property: JsonPropertyName("assignee")] string Assignee);
|
||||||
@@ -202,6 +253,10 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
|||||||
|
|
||||||
private sealed record ExecutionDto([property: JsonPropertyName("id")] string Id);
|
private sealed record ExecutionDto([property: JsonPropertyName("id")] string Id);
|
||||||
|
|
||||||
|
private sealed record AcquiredEscalatieJob(
|
||||||
|
[property: JsonPropertyName("id")] string Id,
|
||||||
|
[property: JsonPropertyName("processInstanceId")] string ProcessInstanceId);
|
||||||
|
|
||||||
private sealed record AcquiredJob(
|
private sealed record AcquiredJob(
|
||||||
[property: JsonPropertyName("id")] string Id,
|
[property: JsonPropertyName("id")] string Id,
|
||||||
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables)
|
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables)
|
||||||
|
|||||||
@@ -16,3 +16,23 @@ public interface IExternalWorkerClient
|
|||||||
/// <summary>Complete an acquired job, passing the opened zaak URL back into the process.</summary>
|
/// <summary>Complete an acquired job, passing the opened zaak URL back into the process.</summary>
|
||||||
Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default);
|
Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The escalation side of the Workflow Client (S-14): the <c>BeoordelingEscaleren</c> external-worker
|
||||||
|
/// jobs parked by the 14-day boundary timer on <c>Beoordelen</c>, and the reassignment they drive.
|
||||||
|
/// Kept separate from <see cref="IExternalWorkerClient"/> (interface segregation) so the OpenZaak
|
||||||
|
/// worker never sees escalation. Implemented by <see cref="FlowableWorkflowClient"/> — the only code
|
||||||
|
/// that talks to Flowable (§8.2, ADR-0015).
|
||||||
|
/// </summary>
|
||||||
|
public interface IBeoordelingEscalatieClient
|
||||||
|
{
|
||||||
|
/// <summary>Acquire and lock up to <paramref name="maxJobs"/> <c>BeoordelingEscaleren</c> jobs.</summary>
|
||||||
|
Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Reassign the still-open <c>Beoordelen</c> task in the given process instance from the
|
||||||
|
/// behandelaar group to teamlead. Best-effort no-op if the task is no longer open.</summary>
|
||||||
|
Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Complete an acquired escalation job so its token reaches the escalation end event.</summary>
|
||||||
|
Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user