feat(behandelportal): WP-66 wire the decision into OpenZaak

Extends IZaakSource with RecordBesluit, mirroring WP-50's CreateZaak write
pattern: OpenZaakZaakSource POSTs a new Statussen entry (highest-volgnummer
statustype, since the harness catalogus has no per-outcome besluittype),
carrying the besluit + toelichting in statustoelichting; LocalZaakSource
no-ops. The beoordeling endpoint calls it after the local decision commits,
flagging a failure via RecordZgwDivergence the same way submit's
create-zaak/document writes do — closing WP-60's "second write pair" gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-04 09:41:28 +02:00
co-authored by Claude Sonnet 5
parent 39409bdf76
commit d996ca2463
8 changed files with 203 additions and 22 deletions
@@ -43,4 +43,15 @@ public interface IZaakSource
/// citizen — the ZGW JWT's audit claims reflect them, not a static config identity.
/// </summary>
(string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller);
/// <summary>
/// Extend a behandelaar's already-locally-recorded decision (WP-65b's
/// <c>ApplicationStore.RecordBesluit</c> already ran) with a ZGW-side status transition
/// (WP-66) — the write counterpart to <see cref="CreateZaak"/>'s initial status. The local
/// source is a no-op (the decision IS the record of truth there, unchanged from before this
/// seam existed); the OpenZaak source POSTs a new Statussen entry to
/// <paramref name="aanvraag"/>'s zaak. <paramref name="caller"/> (WP-53/62) is the acting
/// medewerker.
/// </summary>
void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller);
}
@@ -25,4 +25,8 @@ public sealed class LocalZaakSource : IZaakSource
/// of truth, exactly as before this seam existed (WP-50). Zero behaviour change.</summary>
public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) =>
(aanvraag.Referentie!, aanvraag.ToStatusDto(now), null);
/// <summary>No external zaak to update — the recorded decision already IS the record of
/// truth locally (WP-66). Zero behaviour change.</summary>
public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) { }
}
+20 -6
View File
@@ -442,13 +442,14 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
.ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
// --- Besluit (WP-65b): record a behandelaar's decision, advancing the WP-63 status
// lifecycle. Runs against ApplicationStore directly (not the IZaakSource seam) — same
// reasoning as the GET above: a new seam method would force an OpenZaakZaakSource
// write now, which is WP-66's surface, not this one's. The transition-legality check
// --- Besluit (WP-65b/66): record a behandelaar's decision, advancing the WP-63 status
// lifecycle. The local write runs against ApplicationStore directly (not the IZaakSource
// seam) — same reasoning as the GET above. The transition-legality check
// (BeoordelingRules.CanDecide) is the SAME function the GET's canBesluiten flag uses,
// so the two can never drift.
api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, HttpContext ctx) =>
// so the two can never drift. WP-66: once the local decision has committed, IZaakSource
// also gets a chance to advance the ZGW-side zaak status — LocalZaakSource no-ops,
// OpenZaakZaakSource POSTs a new Statussen entry (see its RecordBesluit).
api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, HttpContext ctx, IZaakSource zaken) =>
Beoordelen(ctx, $"aanvraag/{id}/besluit", () =>
{
if (!Enum.TryParse<Besluit>(req.Besluit, out var besluit))
@@ -468,6 +469,19 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
var updated = ApplicationStore.RecordBesluit(id, besluit, req.Toelichting)!;
app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", id, besluit);
// WP-60: the local decision above already committed — a ZGW failure here is caught and
// flagged rather than allowed to diverge silently, same handling as submit's create-zaak
// and document-link writes.
try
{
zaken.RecordBesluit(updated, besluit, req.Toelichting, now, ctx.Caller());
}
catch (Exception ex)
{
RecordZgwDivergence(ctx, id, updated.Referentie ?? id, ex);
}
return Results.Ok(new RecordBesluitResponse(updated.ToStatusDto(now)));
}))
.Produces<RecordBesluitResponse>()
@@ -137,6 +137,49 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
return first.Url;
}
// --- Write path (WP-66): record a behandelaar's decision as a new zaak status -----------
/// <summary>POST a new Statussen entry to <paramref name="aanvraag"/>'s zaak, carrying the
/// besluit (+ toelichting) in <c>statustoelichting</c> — the harness's catalogus (WP-56)
/// provisions only a begin/eind statustype pair per zaaktype, not one per decision outcome
/// (a real deployment's Besluiten API is future work, see openzaak-integration.md), so this
/// reuses the SAME "last statustype" resolution WP-50's create uses for "first", rather than
/// adding a besluittype abstraction this catalogus doesn't have. No-op if this aanvraag never
/// got a zaak (Zgw was off at submit time, or the create diverged) — same "nothing to do"
/// skip <see cref="OpenZaakDocumentSource.LinkToZaak"/> uses for a null zaakUrl. Sync-over-async
/// for the same reason as <see cref="CreateZaak"/>.
///
/// WP-60: no compensating transaction here either — the local decision already committed
/// (<c>ApplicationStore.RecordBesluit</c>, called by the endpoint before this). A failure here
/// is caught by the endpoint and recorded as a flagged divergence (<c>Aanvraag.ZgwError</c>),
/// the same way the submit endpoint's create-zaak/document writes are.</summary>
public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) =>
RecordBesluitAsync(aanvraag, besluit, toelichting, now, caller).GetAwaiter().GetResult();
private async Task RecordBesluitAsync(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller)
{
if (aanvraag.ZaakUrl is null) return;
if (!options.ZaaktypeUrls.TryGetValue(aanvraag.Type, out var zaaktypeUrl))
throw new InvalidOperationException(
$"Zgw:ZaaktypeUrls has no entry for aanvraag type '{aanvraag.Type}'.");
var statustypeUrl = await LastStatustypeUrlAsync(zaaktypeUrl);
var toelichtingText = string.IsNullOrWhiteSpace(toelichting) ? $"{besluit}" : $"{besluit}: {toelichting}";
await zgw.PostAsync<JsonElement>($"{options.ZrcBaseUrl}/statussen", new CreateStatusRequest(
aanvraag.ZaakUrl, statustypeUrl, now, toelichtingText), caller);
}
/// <summary>The counterpart to <see cref="FirstStatustypeUrlAsync"/> — highest volgnummer
/// (the eind status) rather than lowest.</summary>
private async Task<string> LastStatustypeUrlAsync(string zaaktypeUrl)
{
var page = await zgw.GetAsync<ZgwPage<Statustype>>(
$"{options.ZtcBaseUrl}/statustypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}");
var last = page.Results.OrderByDescending(s => s.Volgnummer).FirstOrDefault()
?? throw new InvalidOperationException($"No statustype found for zaaktype {zaaktypeUrl}.");
return last.Url;
}
private async Task<string> FirstInitiatorRoltypeUrlAsync(string zaaktypeUrl)
{
var page = await zgw.GetAsync<ZgwPage<Roltype>>(
@@ -164,7 +207,8 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
private sealed record CreateStatusRequest(
[property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("statustype")] string Statustype,
[property: JsonPropertyName("datumStatusGezet")] DateTimeOffset DatumStatusGezet);
[property: JsonPropertyName("datumStatusGezet")] DateTimeOffset DatumStatusGezet,
[property: JsonPropertyName("statustoelichting")] string Statustoelichting = "");
private sealed record CreateRolRequest(
[property: JsonPropertyName("zaak")] string Zaak,