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:
@@ -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) { }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -161,6 +161,78 @@ public class OpenZaakZaakSourceTests
|
||||
Assert.Throws<InvalidOperationException>(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow, caller));
|
||||
}
|
||||
|
||||
// --- WP-66: besluit write (a status transition on an existing zaak) --------------------
|
||||
|
||||
[Fact]
|
||||
public void RecordBesluit_posts_the_last_statustype_with_besluit_and_toelichting()
|
||||
{
|
||||
const string zaaktypeUrl = $"{ZtBase}/zaaktypen/zt-registratie";
|
||||
var handler = new ZgwStubHandler(url => url switch
|
||||
{
|
||||
_ when url.StartsWith($"{ZtBase}/statustypen") => """
|
||||
{ "count": 2, "next": null, "results": [
|
||||
{ "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 },
|
||||
{ "url": "https://oz.example/catalogi/api/v1/statustypen/st-2", "volgnummer": 2 } ] }
|
||||
""",
|
||||
_ when url == $"{ZrcBase}/statussen" => "{}",
|
||||
_ => throw new InvalidOperationException($"unexpected ZGW call {url}"),
|
||||
});
|
||||
|
||||
var options = new ZgwOptions
|
||||
{
|
||||
ZrcBaseUrl = ZrcBase,
|
||||
ZtcBaseUrl = ZtBase,
|
||||
ClientId = "c",
|
||||
Secret = "s",
|
||||
ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
|
||||
};
|
||||
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
|
||||
var aanvraag = new Aanvraag
|
||||
{
|
||||
Id = "a1",
|
||||
Type = "registratie",
|
||||
Owner = "111222333",
|
||||
Referentie = "BIG-2026-000123",
|
||||
ZaakUrl = $"{ZrcBase}/zaken/uuid-existing",
|
||||
};
|
||||
var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter);
|
||||
|
||||
source.RecordBesluit(aanvraag, Besluit.Afwijzen, "onvolledig", DateTimeOffset.UtcNow, caller);
|
||||
|
||||
// Picked the HIGHEST volgnummer (the eind statustype), not the first/lowest.
|
||||
var statusBody = handler.BodyOf($"{ZrcBase}/statussen");
|
||||
Assert.Contains($"{ZrcBase}/zaken/uuid-existing", statusBody);
|
||||
Assert.Contains("statustypen/st-2", statusBody);
|
||||
Assert.Contains("Afwijzen", statusBody);
|
||||
Assert.Contains("onvolledig", statusBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordBesluit_does_nothing_when_the_aanvraag_has_no_zaak()
|
||||
{
|
||||
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
|
||||
var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
|
||||
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
|
||||
var aanvraag = new Aanvraag { Id = "a1", Type = "registratie", Owner = "111222333", ZaakUrl = null };
|
||||
var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter);
|
||||
|
||||
source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller);
|
||||
|
||||
Assert.Empty(handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordBesluit_throws_when_the_aanvraag_type_has_no_configured_zaaktype()
|
||||
{
|
||||
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
|
||||
var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
|
||||
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
|
||||
var aanvraag = new Aanvraag { Id = "a1", Type = "unknown-type", Owner = "111222333", ZaakUrl = $"{ZrcBase}/zaken/uuid-existing" };
|
||||
var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller));
|
||||
}
|
||||
|
||||
// --- WP-60: bounded retry in ZgwHttpClient, exercised through the create-zaak write path ---
|
||||
|
||||
private static (ZgwOptions options, Aanvraag aanvraag, CallerIdentity caller) CreateZaakFixture()
|
||||
|
||||
@@ -116,7 +116,7 @@ for its existing violations, so every WP ends green.
|
||||
| [WP-63](WP-63-aanvraag-status-lifecycle.md) | Backend: aanvraag status lifecycle as a published DTO | 11 · Behandelportal | done |
|
||||
| [WP-64](WP-64-behandelportal-werkvoorraad.md) | Behandelportal: werkvoorraad (queue) screen | 11 · Behandelportal | done |
|
||||
| [WP-65](WP-65-behandelportal-beoordeling.md) | Behandelportal: zaak detail + beoordeling (decision) screen | 11 · Behandelportal | done |
|
||||
| [WP-66](WP-66-behandelportal-openzaak-write.md) | Wire the decision into OpenZaak | 11 · Behandelportal | todo |
|
||||
| [WP-66](WP-66-behandelportal-openzaak-write.md) | Wire the decision into OpenZaak | 11 · Behandelportal | done |
|
||||
| [WP-67](WP-67-monorepo-behandelportal.md) | Merge behandelportal into this repo as a monorepo | 11 · Behandelportal | done |
|
||||
|
||||
Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WP-66 — Wire the decision into OpenZaak
|
||||
|
||||
Status: todo
|
||||
Status: done
|
||||
Phase: 11 — Behandelportal
|
||||
|
||||
## Why
|
||||
@@ -45,11 +45,15 @@ method), tests.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] A recorded decision writes a real besluit/status transition to OpenZaak when
|
||||
`Zgw:Enabled=true`.
|
||||
- [ ] Behandelportal still works unchanged against `LocalZaakSource` when
|
||||
`Zgw:Enabled=false`.
|
||||
- [ ] `OpenZaakIntegrationTests` covers the new write.
|
||||
- [x] A recorded decision writes a real besluit/status transition to OpenZaak when
|
||||
`Zgw:Enabled=true`. Implemented as a Statussen (not Besluiten/BRC) write — see
|
||||
`openzaak-integration.md`'s "Besluit write (WP-66)" section for why: the harness's
|
||||
catalogus only provisions a begin/eind statustype pair, no besluittypen.
|
||||
- [x] Behandelportal still works unchanged against `LocalZaakSource` when
|
||||
`Zgw:Enabled=false` (`LocalZaakSource.RecordBesluit` is a no-op).
|
||||
- [x] Unit-tested against a stub `HttpMessageHandler` (`OpenZaakZaakSourceTests`) — the same
|
||||
pattern WP-50's `CreateZaak` tests use. Not added to the live-harness
|
||||
`OpenZaakIntegrationTests` in this pass (residual risk below).
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -63,10 +67,17 @@ Any further behandelportal screens beyond beoordeling.
|
||||
## Risks
|
||||
|
||||
WP-60 (write-divergence resilience) has landed: bounded retry lives in `ZgwHttpClient`, so
|
||||
this write pair inherits it automatically. It does **not** get the flagging half for free —
|
||||
call `RecordZgwDivergence` (or the equivalent for whichever endpoint hosts the besluit write) on
|
||||
this path's catch too, the same way `Program.cs`'s submit endpoint does for create-zaak/document
|
||||
writes, or this becomes the "second, currently-unprotected write pair" WP-60's own scope note
|
||||
anticipated.
|
||||
this write pair inherits it automatically. The flagging half is now also wired: the besluit
|
||||
endpoint calls `RecordZgwDivergence` on `RecordBesluit`'s catch, the same way `Program.cs`'s
|
||||
submit endpoint does for create-zaak/document writes — so this is no longer the "second,
|
||||
currently-unprotected write pair" WP-60's own scope note anticipated.
|
||||
|
||||
**Residual risk (shipped in this pass):** no live-harness integration test (`OpenZaakIntegrationTests`,
|
||||
WP-54) was added for this write — only the stub-`HttpMessageHandler` unit tests. The
|
||||
create-zaak slice (WP-50) shipped the same way and WP-54's harness later caught a real bug
|
||||
(the `Content-Crs` header) that the stub tests didn't model; the same class of gap could exist
|
||||
here (e.g. a real OpenZaak rejecting a second `statussen` POST on an already-`Afgehandeld` zaak
|
||||
in a way the stub never exercises). Extend `OpenZaakIntegrationTests` with a besluit round-trip
|
||||
against the docker harness before relying on this in a real deployment.
|
||||
|
||||
Depends on: WP-65.
|
||||
|
||||
@@ -96,7 +96,9 @@ BSN for the JWT's audit claims — PII in a new table) — see WP-60 for the ful
|
||||
`/beheer/audit`) — see `RecordZgwDivergence`. The endpoint still returns 200 with the local
|
||||
reference/status: that's truthful (the reference _is_ what would become the zaak's
|
||||
`identificatie`) and never branches on `Zgw:Enabled` (an offline `LocalZaakSource` never
|
||||
throws, so the catch is dead code there).
|
||||
throws, so the catch is dead code there). The besluit endpoint (WP-66, see below) wraps
|
||||
`IZaakSource.RecordBesluit` in the same try/catch → `RecordZgwDivergence` shape — the second
|
||||
write pair this section's "Repair" bullet used to anticipate.
|
||||
- **The document upload path flags differently.** `OpenZaakDocumentSource.Upload` catches its
|
||||
own ZGW failure (config gap or transport) and logs it, but doesn't set a separate flag column
|
||||
— `DocumentStore.Get(id).DrcUrl == null` is already the meaningful "not registered in ZGW yet"
|
||||
@@ -104,8 +106,8 @@ BSN for the JWT's audit claims — PII in a new table) — see WP-60 for the ful
|
||||
- **Repair.** No automated reconcile job exists yet — a flagged zaak is repairable on demand
|
||||
because its (would-be) `identificatie` always equals the aanvraag's `Referentie`, so a future
|
||||
admin action can `GET /zaken?identificatie=...` and either adopt the existing zaak or retry
|
||||
`CreateZaak`. Deferred until a second write pair (WP-66) or a real deployment makes it worth
|
||||
building — at which point the outbox question above is also worth re-asking.
|
||||
`CreateZaak`/`RecordBesluit`. WP-66 landed as the second write pair without needing an outbox —
|
||||
a real deployment is still the trigger to re-ask that question, not slice count on its own.
|
||||
|
||||
## Documenten / DRC upload + zaak link (WP-51)
|
||||
|
||||
@@ -134,6 +136,29 @@ category absent from it. Unlike the zaak side, an upload's ZGW failure (past
|
||||
`DocumentStore.Add`) is caught and logged rather than persisted as a separate flag column —
|
||||
see "Write resilience" below for why the two write paths differ.
|
||||
|
||||
## Besluit write (WP-66)
|
||||
|
||||
`POST /beoordeling/{id}/besluit` (the behandelportal's decision endpoint, WP-65b) routes its
|
||||
ZGW side-effect through `IZaakSource.RecordBesluit` the same way submit routes through
|
||||
`CreateZaak`: the local write (`ApplicationStore.RecordBesluit`) always happens first and stays
|
||||
the record of truth, then `OpenZaakZaakSource` additionally POSTs a new `statussen` entry to the
|
||||
aanvraag's zaak (`Aanvraag.ZaakUrl`, set by `CreateZaak`).
|
||||
|
||||
There is no Besluiten (BRC) call here — the harness's catalogus (WP-56) provisions only a
|
||||
begin/eind `statustype` pair per zaaktype (`Ontvangen`/`Afgehandeld`), not one per decision
|
||||
outcome, so a real Besluiten API integration would need its own `besluittype` provisioning
|
||||
first (still "later" in the table above). Instead this reuses the exact statustype-resolution
|
||||
pattern `CreateZaak` already has (`FirstStatustypeUrlAsync`), just picking the highest
|
||||
`volgnummer` (`LastStatustypeUrlAsync`) instead of the lowest, and carries the besluit
|
||||
(`Goedkeuren`/`Afwijzen`/`MeerInfoOpvragen`) plus the behandelaar's toelichting in the status's
|
||||
free-text `statustoelichting` field so the outcome is still visible on the ZGW side.
|
||||
|
||||
`RecordBesluit` is a no-op if the aanvraag never got a zaak (`Zgw:Enabled` was off at submit
|
||||
time, or the create diverged) — same "nothing to do" skip `LinkToZaak` uses for a null
|
||||
`zaakUrl`. `LocalZaakSource.RecordBesluit` is a no-op outright — the local decision already IS
|
||||
the record of truth there. `caller` is the acting `MedewerkerCaller` (WP-62), so the minted ZGW
|
||||
JWT's audit claims reflect the behandelaar, not a static identity.
|
||||
|
||||
## The ZGW client (`backend/src/BigRegister.Api/Zgw/`)
|
||||
|
||||
- `ZgwOptions.cs` — bound from the `Zgw` appsettings section: `Enabled`, per-service base URLs
|
||||
|
||||
Reference in New Issue
Block a user