Compare commits

..

7 Commits

Author SHA1 Message Date
7693a4a85a feat(domain): expose POST /registrations/{id}/decide for the beoordeling (refs #13)
All checks were successful
CI / lint (pull_request) Successful in 1m20s
CI / build (pull_request) Successful in 1m12s
CI / unit (pull_request) Successful in 1m32s
CI / frontend (pull_request) Successful in 2m47s
CI / mutation (pull_request) Successful in 6m6s
CI / verify-stack (pull_request) Successful in 7m45s
The behandel-portal's decision reaches the domain here (via the BFF, later slices):
goedkeuren/afwijzen, idempotent, superseding the temporary /approve endpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 17:24:46 +02:00
ddebfd371a test(acceptance): beoordeling scenario — goedkeuren via ACL, afwijzen (refs #13)
BDD scenarios (Gherkin/Reqnroll) driving S-12's decide flow: a behandelaar takes an
INGEDIEND registration into behandeling and decides it goedkeuren (→ INGESCHREVEN,
zaak status via the ACL) or afwijzen (→ AFGEWEZEN, no ACL). Feature-scoped bindings
avoid clashing with the identically-phrased status step in RegistratieIndienen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 17:24:46 +02:00
d700659957 feat(domain): implement the beoordeling use-case (goedkeuren/afwijzen) (refs #13)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 17:20:51 +02:00
63699c3f55 test(domain): beoordeling use-case — goedkeuren via ACL, afwijzen, idempotency (refs #13)
Red — the BeoordeelRegistratie use-case and BeoordelingsBesluit do not exist yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 17:20:22 +02:00
5c05879cde test(domain): lock beoordeling transitions — reject, idempotent re-take, guards (refs #13)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 17:19:02 +02:00
09000d8e64 feat(domain): take a registration into behandeling; generalise decisions (refs #13)
Adds InBehandeling + Afgewezen statuses and TakeIntoBehandeling(). Generalises the
behandelaar decision: Approve() (requires a zaak) and new Reject() both act on an
INGEDIEND or IN_BEHANDELING registration, moving it to Ingeschreven/Afgewezen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 17:18:18 +02:00
23125ef492 test(domain): a behandelaar can take a registration into behandeling (refs #13)
First step of S-12a (beoordeling decision model): an INGEDIEND registration moves
to IN_BEHANDELING when a behandelaar picks it up. Red — TakeIntoBehandeling() and
the InBehandeling status do not exist yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 17:15:41 +02:00
8 changed files with 450 additions and 12 deletions

View File

@@ -24,6 +24,7 @@ builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
builder.Services.AddScoped<SubmitRegistration>();
builder.Services.AddScoped<ApproveRegistration>();
builder.Services.AddScoped<BeoordeelRegistratie>();
builder.Services.AddScoped<OpenZaakWorker>();
builder.Services.AddScoped<OpenZaakJobProcessor>();
@@ -55,6 +56,22 @@ app.MapPost("/registrations/{id}/approve", async (string id, ApproveRegistration
return Results.NoContent();
});
// The behandelaar's beoordeling (S-12): decide a registration goedkeuren (→ INGESCHREVEN, sets the
// zaak's final status via the ACL) or afwijzen (→ AFGEWEZEN). Idempotent. This is the domain contract
// the behandel-portal's decision reaches through the BFF; it supersedes the temporary /approve above,
// which is retired once the portal lands.
app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body, BeoordeelRegistratie beoordeel, CancellationToken ct) =>
{
if (!Guid.TryParse(id, out var guid))
return Results.NotFound();
if (!Enum.TryParse<BeoordelingsBesluit>(body.Besluit, ignoreCase: true, out var besluit))
return Results.BadRequest(new { error = $"Unknown besluit '{body.Besluit}'. Expected 'goedkeuren' or 'afwijzen'." });
await beoordeel.HandleAsync(new BeoordeelRegistratieCommand(new RegistrationId(guid), besluit), ct);
return Results.NoContent();
});
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
{
@@ -72,6 +89,8 @@ await app.RunAsync();
public sealed record SubmitRegistrationRequest(string Bsn);
public sealed record DecideRequest(string Besluit);
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
public partial class Program;

View File

@@ -0,0 +1,57 @@
using Big.Domain;
namespace Big.Application;
/// <summary>A behandelaar's beoordeling outcome, in domain language.</summary>
public enum BeoordelingsBesluit
{
/// <summary>Approve — enter the registration in the register.</summary>
Goedkeuren,
/// <summary>Reject — turn the registration down.</summary>
Afwijzen,
}
/// <summary>A behandelaar's decision on a registration.</summary>
public sealed record BeoordeelRegistratieCommand(RegistrationId RegistrationId, BeoordelingsBesluit Besluit);
/// <summary>
/// The beoordeling use case (S-12): apply a behandelaar's decision to a registration.
/// <see cref="BeoordelingsBesluit.Goedkeuren"/> sets the zaak's final status via the ACL (§8.1) and
/// advances the aggregate to INGESCHREVEN; <see cref="BeoordelingsBesluit.Afwijzen"/> advances it to
/// AFGEWEZEN in the domain (propagating a rejection to the zaak, so the openbaar projection reflects
/// it, is a later sub-slice of S-12). Both decisions are idempotent — a repeated or redelivered
/// decision that matches the current terminal state is a no-op, so the ACL is not called twice.
/// </summary>
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl)
{
public async Task HandleAsync(BeoordeelRegistratieCommand command, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(command);
var registration = await store.GetAsync(command.RegistrationId, ct)
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to decide.");
switch (command.Besluit)
{
case BeoordelingsBesluit.Goedkeuren:
// A repeated approval is a no-op: don't set the zaak status a second time.
if (registration.Status == RegistrationStatus.Ingeschreven)
return;
if (registration.ZaakUrl is null)
throw new InvalidOperationException(
$"Registration {command.RegistrationId} has no zaak yet; it cannot be approved.");
await acl.ApproveZaakAsync(registration.ZaakUrl, ct);
registration.Approve();
break;
case BeoordelingsBesluit.Afwijzen:
if (registration.Status == RegistrationStatus.Afgewezen)
return;
registration.Reject();
break;
}
await store.SaveAsync(registration, ct);
}
}

View File

@@ -66,10 +66,28 @@ public sealed class Registration
}
/// <summary>
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances
/// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.Ingeschreven"/>.
/// Requires an opened zaak (the approval sets that zaak's status via the ACL), and only a
/// submitted registration can be approved: re-approving is rejected as an invalid transition.
/// A behandelaar picks the registration up for beoordeling. Advances
/// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.InBehandeling"/>.
/// Re-taking one already <see cref="RegistrationStatus.InBehandeling"/> is a no-op (the same or
/// another behandelaar re-opens it); a decided registration can no longer be taken into behandeling.
/// </summary>
public void TakeIntoBehandeling()
{
if (Status == RegistrationStatus.InBehandeling)
return;
if (Status != RegistrationStatus.Ingediend)
throw new InvalidOperationException(
$"Registration {Id} is {Status}; only an INGEDIEND registration can be taken into behandeling.");
Status = RegistrationStatus.InBehandeling;
}
/// <summary>
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances a
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Ingeschreven"/>.
/// Requires an opened zaak (the approval sets that zaak's status via the ACL); a registration that
/// has already been decided cannot be approved again.
/// </summary>
public void Approve()
{
@@ -77,10 +95,27 @@ public sealed class Registration
throw new InvalidOperationException(
$"Registration {Id} has no zaak yet; it cannot be approved before its zaak is opened.");
if (Status != RegistrationStatus.Ingediend)
throw new InvalidOperationException(
$"Registration {Id} is {Status}; only an INGEDIEND registration can be approved.");
RequireOpenForDecision(nameof(Approve));
Status = RegistrationStatus.Ingeschreven;
}
/// <summary>
/// Reject the registration — the behandelaar's decision not to enter it in the register. Advances a
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Afgewezen"/>. Unlike
/// approval this needs no zaak: a registration can be rejected before or after its zaak is opened.
/// A registration that has already been decided cannot be rejected again.
/// </summary>
public void Reject()
{
RequireOpenForDecision(nameof(Reject));
Status = RegistrationStatus.Afgewezen;
}
// A decision is only valid while the registration is still open (INGEDIEND or IN_BEHANDELING).
private void RequireOpenForDecision(string decision)
{
if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
throw new InvalidOperationException(
$"Registration {Id} is {Status}; only an INGEDIEND or IN_BEHANDELING registration can be decided ({decision}).");
}
}

View File

@@ -1,13 +1,20 @@
namespace Big.Domain;
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. The walking
/// skeleton knows <see cref="Ingediend"/> and the terminal <see cref="Ingeschreven"/>; withdrawal,
/// beoordeling and herregistratie states arrive in their own slices (Iteration 2+).</summary>
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. Submission starts in
/// <see cref="Ingediend"/>; a behandelaar takes it <see cref="InBehandeling"/> and decides it into one
/// of the terminal states <see cref="Ingeschreven"/> (approved) or <see cref="Afgewezen"/> (rejected).
/// Withdrawal and herregistratie states arrive in their own slices (S-11+).</summary>
public enum RegistrationStatus
{
/// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary>
Ingediend,
/// <summary>Approved: entered in the register. Terminal in the walking skeleton (S-09b).</summary>
/// <summary>Picked up by a behandelaar for beoordeling (S-12).</summary>
InBehandeling,
/// <summary>Approved: entered in the register. Terminal.</summary>
Ingeschreven,
/// <summary>Rejected by the behandelaar. Terminal.</summary>
Afgewezen,
}

View File

@@ -0,0 +1,142 @@
using Big.Application;
using Big.Domain;
namespace Big.Tests;
/// <summary>
/// The beoordeling use case (S-12): a behandelaar's decision on a registration. Goedkeuren sets the
/// zaak's final status via the ACL (§8.1) and marks the aggregate INGESCHREVEN; Afwijzen marks it
/// AFGEWEZEN in the domain (propagating a rejection to the zaak is a later sub-slice). Both are
/// idempotent so a repeated or redelivered decision is a no-op.
/// </summary>
public class BeoordeelRegistratieTests
{
private static Registration WithZaak(string bsn = "123456782")
{
var registration = Registration.Submit(bsn);
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
return registration;
}
[Fact]
public async Task Goedkeuren_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
var saved = await store.GetAsync(registration.Id);
Assert.Equal(RegistrationStatus.Ingeschreven, saved!.Status);
Assert.Equal(FakeAclClient.DefaultZaakUrl, acl.ApprovedZaakUrl);
Assert.Equal(1, acl.ApproveCallCount);
Assert.Equal(1, store.SaveCount);
}
[Fact]
public async Task Afwijzen_marks_the_registration_afgewezen_without_calling_the_acl()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
var saved = await store.GetAsync(registration.Id);
Assert.Equal(RegistrationStatus.Afgewezen, saved!.Status);
Assert.Equal(0, acl.ApproveCallCount);
Assert.Equal(1, store.SaveCount);
}
[Fact]
public async Task Goedkeuren_an_in_behandeling_registration_marks_it_ingeschreven()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var registration = WithZaak();
registration.TakeIntoBehandeling();
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status);
}
[Fact]
public async Task Rejects_a_null_command_without_touching_the_store_or_acl()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var handler = new BeoordeelRegistratie(store, acl);
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, acl.ApproveCallCount);
Assert.Equal(0, store.SaveCount);
}
[Fact]
public async Task Deciding_an_unknown_registration_throws_and_does_not_call_the_acl()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var handler = new BeoordeelRegistratie(store, acl);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
handler.HandleAsync(new BeoordeelRegistratieCommand(RegistrationId.New(), BeoordelingsBesluit.Goedkeuren)));
Assert.Contains("No registration", ex.Message);
Assert.Equal(0, acl.ApproveCallCount);
}
[Fact]
public async Task Goedkeuren_before_the_zaak_is_opened_throws_without_calling_the_acl()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var registration = Registration.Submit("123456782"); // no zaak yet
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)));
Assert.Contains("no zaak", ex.Message);
Assert.Equal(0, acl.ApproveCallCount);
}
[Fact]
public async Task Re_goedkeuren_an_already_ingeschreven_registration_is_idempotent()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
Assert.Equal(1, acl.ApproveCallCount);
Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status);
}
[Fact]
public async Task Re_afwijzen_an_already_afgewezen_registration_is_idempotent()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
Assert.Equal(1, store.SaveCount);
Assert.Equal(RegistrationStatus.Afgewezen, (await store.GetAsync(registration.Id))!.Status);
}
}

View File

@@ -121,4 +121,100 @@ public class RegistrationTests
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
[Fact]
public void Taking_a_registration_into_behandeling_moves_it_to_in_behandeling()
{
var registration = Registration.Submit("123456782");
registration.TakeIntoBehandeling();
Assert.Equal(RegistrationStatus.InBehandeling, registration.Status);
}
[Fact]
public void Re_taking_an_in_behandeling_registration_is_idempotent()
{
var registration = Registration.Submit("123456782");
registration.TakeIntoBehandeling();
registration.TakeIntoBehandeling();
Assert.Equal(RegistrationStatus.InBehandeling, registration.Status);
}
[Fact]
public void Taking_a_decided_registration_into_behandeling_is_rejected()
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve();
var ex = Assert.Throws<InvalidOperationException>(() => registration.TakeIntoBehandeling());
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
[Fact]
public void Approving_an_in_behandeling_registration_with_a_zaak_sets_it_ingeschreven()
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.TakeIntoBehandeling();
registration.Approve();
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
[Fact]
public void Rejecting_a_registration_sets_it_afgewezen()
{
var registration = Registration.Submit("123456782");
registration.Reject();
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
}
[Fact]
public void Rejecting_needs_no_zaak()
{
// A registration can be turned down before its zaak is ever opened, so Reject must not require one.
var registration = Registration.Submit("123456782");
registration.Reject();
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
Assert.Null(registration.ZaakUrl);
}
[Fact]
public void Rejecting_an_in_behandeling_registration_sets_it_afgewezen()
{
var registration = Registration.Submit("123456782");
registration.TakeIntoBehandeling();
registration.Reject();
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
}
[Fact]
public void Deciding_an_already_decided_registration_is_rejected()
{
var registration = Registration.Submit("123456782");
registration.Reject();
var approveEx = Assert.Throws<InvalidOperationException>(() =>
{
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve();
});
Assert.Contains("IN_BEHANDELING", approveEx.Message);
var rejectEx = Assert.Throws<InvalidOperationException>(() => registration.Reject());
Assert.Contains("Afgewezen", rejectEx.Message);
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
}
}

View File

@@ -0,0 +1,24 @@
# language: en
# Drives S-12 (#13). A behandelaar picks up a submitted registration for beoordeling and decides it:
# goedkeuren enters it in the register (INGESCHREVEN, the zaak's final status set via the ACL, §8.1),
# afwijzen turns it down (AFGEWEZEN). This scenario exercises the use cases against in-memory
# stand-ins for the ACL and the store; real Flowable user-task claim/complete arrives in a later
# sub-slice and is verified live.
Feature: Een registratie beoordelen
Als behandelaar wil ik een ingediende registratie beoordelen
zodat deze wordt ingeschreven of afgewezen.
Scenario: Goedkeuren schrijft de registratie in via de ACL
Given a submitted registration with an opened zaak
When the behandelaar takes it into behandeling
Then the registration has status "INBEHANDELING"
When the behandelaar decides "goedkeuren"
Then the registration has status "INGESCHREVEN"
And the zaak's final status is set via the ACL
Scenario: Afwijzen wijst de registratie af zonder de ACL
Given a submitted registration with an opened zaak
When the behandelaar takes it into behandeling
And the behandelaar decides "afwijzen"
Then the registration has status "AFGEWEZEN"
And the ACL is not asked to set the zaak status

View File

@@ -0,0 +1,58 @@
using Acceptance.Support;
using Big.Application;
using Big.Domain;
using Reqnroll;
using Xunit;
namespace Acceptance.Steps;
/// <summary>Bindings for <c>EenRegistratieBeoordelen.feature</c> (S-12). Drives the TakeIntoBehandeling
/// transition and the BeoordeelRegistratie use case against in-memory ports; one instance per scenario.
/// Scoped to this feature so its "the registration has status" step does not clash with the identically
/// phrased (but differently-asserting) step in <see cref="RegistratieIndienenSteps"/>.</summary>
[Binding]
[Scope(Feature = "Een registratie beoordelen")]
public sealed class EenRegistratieBeoordelenSteps
{
private readonly InMemoryAclClient _acl = new();
private readonly InMemoryRegistrationStore _store = new();
private RegistrationId _id;
[Given("a submitted registration with an opened zaak")]
public async Task GivenASubmittedRegistrationWithAnOpenedZaak()
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(InMemoryAclClient.OpenedZaakUrl);
await _store.SaveAsync(registration);
_id = registration.Id;
}
[When("the behandelaar takes it into behandeling")]
public async Task WhenTheBehandelaarTakesItIntoBehandeling()
{
var registration = await _store.GetAsync(_id);
registration!.TakeIntoBehandeling();
await _store.SaveAsync(registration);
}
[When("the behandelaar decides \"(.*)\"")]
public async Task WhenTheBehandelaarDecides(string besluit)
=> await new BeoordeelRegistratie(_store, _acl).HandleAsync(
new BeoordeelRegistratieCommand(_id, Enum.Parse<BeoordelingsBesluit>(besluit, ignoreCase: true)));
[Then("the registration has status \"(.*)\"")]
public async Task ThenTheRegistrationHasStatus(string expected)
{
var registration = await _store.GetAsync(_id);
Assert.NotNull(registration);
Assert.Equal(expected, registration.Status.ToString().ToUpperInvariant());
}
[Then("the zaak's final status is set via the ACL")]
public void ThenTheZaakFinalStatusIsSetViaTheAcl()
=> Assert.Equal(InMemoryAclClient.OpenedZaakUrl, _acl.ApprovedZaakUrl);
[Then("the ACL is not asked to set the zaak status")]
public void ThenTheAclIsNotAskedToSetTheZaakStatus()
=> Assert.Null(_acl.ApprovedZaakUrl);
}