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
+1 -1
View File
@@ -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 1015 (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.
+28 -3
View File
@@ -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