feat(acl): gateway cancels a zaak via Geannuleerd status + Vervallen resultaat (refs #106)

SetZaakToCancellationStatusAsync resolves the cancellation statustype and
resultaat by omschrijving and posts resultaat-then-status, mirroring the
approval path. Approval now resolves its Geregistreerd resultaat by name too,
since a second resultaattype now exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
not
2026-07-21 14:29:47 +02:00
co-authored by Claude Opus 4.8
parent a985284b71
commit 64b924fe3a
2 changed files with 49 additions and 11 deletions
@@ -8,6 +8,12 @@ namespace Acl.Infrastructure;
/// <summary>The only code that talks to OpenZaak's Zaken API (ADR-0001).</summary> /// <summary>The only code that talks to OpenZaak's Zaken API (ADR-0001).</summary>
public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : IZaakGateway public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : IZaakGateway
{ {
// The ACL owns which ZGW statustype/resultaat carries each domain outcome (§8.1). These
// omschrijvingen match the seeded BIG catalogus (infra/openzaak/seed_catalogus.py).
private const string GeregistreerdResultaat = "Geregistreerd"; // approval outcome
private const string GeannuleerdStatus = "Geannuleerd"; // document-timeout cancellation status (S-10c)
private const string VervallenResultaat = "Vervallen"; // document-timeout cancellation outcome (S-10c)
public async Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) public async Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
@@ -48,7 +54,9 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
ArgumentNullException.ThrowIfNull(zaaktypeUrl); ArgumentNullException.ThrowIfNull(zaaktypeUrl);
var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct); var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct);
var resultaattype = await ResolveResultaattypeAsync(zaaktypeUrl, ct); // Resolve the approval resultaat by name: once S-10c adds the Vervallen resultaattype, taking
// the first would be ambiguous (the Zaken API does not guarantee order).
var resultaattype = await ResolveResultaattypeByOmschrijvingAsync(zaaktypeUrl, GeregistreerdResultaat, ct);
// OpenZaak refuses to set a zaak's eindstatus unless the zaak has a resultaat // OpenZaak refuses to set a zaak's eindstatus unless the zaak has a resultaat
// ("resultaat-does-not-exist"), so record the resultaat first, then the status. // ("resultaat-does-not-exist"), so record the resultaat first, then the status.
@@ -62,8 +70,26 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
"Setting the zaak status", ct); "Setting the zaak status", ct);
} }
public Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default) public async Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
=> throw new NotImplementedException(); {
ArgumentNullException.ThrowIfNull(zaakUrl);
ArgumentNullException.ThrowIfNull(zaaktypeUrl);
// Distinct from approval: resolve the cancellation statustype + resultaat by name (Geannuleerd
// is a non-terminal statustype, so it is never the eindstatus the approval path resolves).
var cancellationStatus = await ResolveStatustypeByOmschrijvingAsync(zaaktypeUrl, GeannuleerdStatus, ct);
var cancellationResultaat = await ResolveResultaattypeByOmschrijvingAsync(zaaktypeUrl, VervallenResultaat, ct);
// As with approval, OpenZaak wants the resultaat recorded before the status.
await PostAsync("/zaken/api/v1/resultaten",
new ResultaatDto(zaakUrl.ToString(), cancellationResultaat.ToString()),
"Setting the zaak cancellation resultaat", ct);
await PostAsync("/zaken/api/v1/statussen",
new StatusDto(zaakUrl.ToString(), cancellationStatus.ToString(),
datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ")),
"Setting the zaak cancellation status", ct);
}
public async Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default) public async Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
{ {
@@ -177,13 +203,23 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
return new Uri(eindstatus.Url); return new Uri(eindstatus.Url);
} }
/// <summary>Resolve the zaaktype's resultaattype from the catalogus (the seed defines one).</summary> /// <summary>Resolve a specific statustype from the catalogus by its omschrijving (e.g. "Geannuleerd").</summary>
private async Task<Uri> ResolveResultaattypeAsync(Uri zaaktypeUrl, CancellationToken ct) private async Task<Uri> ResolveStatustypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, CancellationToken ct)
{
var page = await GetCatalogusAsync<StatustypePage>("statustypen", zaaktypeUrl, "statustypen", ct);
var match = (page.Results ?? []).FirstOrDefault(s => s.Omschrijving == omschrijving)
?? throw new InvalidOperationException($"No '{omschrijving}' statustype found for zaaktype {zaaktypeUrl}");
return new Uri(match.Url);
}
/// <summary>Resolve a specific resultaattype from the catalogus by its omschrijving (the seed defines
/// "Geregistreerd" for approval and "Vervallen" for a document-timeout cancellation).</summary>
private async Task<Uri> ResolveResultaattypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, CancellationToken ct)
{ {
var page = await GetCatalogusAsync<ResultaattypePage>("resultaattypen", zaaktypeUrl, "resultaattypen", ct); var page = await GetCatalogusAsync<ResultaattypePage>("resultaattypen", zaaktypeUrl, "resultaattypen", ct);
var resultaattype = (page.Results ?? []).FirstOrDefault() var match = (page.Results ?? []).FirstOrDefault(r => r.Omschrijving == omschrijving)
?? throw new InvalidOperationException($"No resultaattypen found for zaaktype {zaaktypeUrl}"); ?? throw new InvalidOperationException($"No '{omschrijving}' resultaattype found for zaaktype {zaaktypeUrl}");
return new Uri(resultaattype.Url); return new Uri(match.Url);
} }
// GETs a catalogus collection filtered by zaaktype (status=alles includes concept + published). // GETs a catalogus collection filtered by zaaktype (status=alles includes concept + published).
@@ -227,7 +263,8 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
private sealed record StatustypeDto( private sealed record StatustypeDto(
[property: JsonPropertyName("url")] string Url, [property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("volgnummer")] int Volgnummer, [property: JsonPropertyName("volgnummer")] int Volgnummer,
[property: JsonPropertyName("isEindstatus")] bool IsEindstatus); [property: JsonPropertyName("isEindstatus")] bool IsEindstatus,
[property: JsonPropertyName("omschrijving")] string? Omschrijving);
private sealed record ResultaatDto( private sealed record ResultaatDto(
[property: JsonPropertyName("zaak")] string Zaak, [property: JsonPropertyName("zaak")] string Zaak,
@@ -237,7 +274,8 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
[property: JsonPropertyName("results")] IReadOnlyList<ResultaattypeDto>? Results); [property: JsonPropertyName("results")] IReadOnlyList<ResultaattypeDto>? Results);
private sealed record ResultaattypeDto( private sealed record ResultaattypeDto(
[property: JsonPropertyName("url")] string Url); [property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("omschrijving")] string? Omschrijving);
private sealed record CreatedDto( private sealed record CreatedDto(
[property: JsonPropertyName("url")] string Url); [property: JsonPropertyName("url")] string Url);
@@ -410,7 +410,7 @@ public class OpenZaakGatewayTests
Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = "{}" })) Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = "{}" }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("No resultaattypen found", ex.Message); Assert.Contains("'Geregistreerd' resultaattype", ex.Message);
// Resolved the eindstatus + queried resultaattypen, but posted nothing. // Resolved the eindstatus + queried resultaattypen, but posted nothing.
Assert.Equal(-1, rec.IndexOf("/resultaten")); Assert.Equal(-1, rec.IndexOf("/resultaten"));
Assert.Equal(-1, rec.IndexOf("/statussen")); Assert.Equal(-1, rec.IndexOf("/statussen"));