diff --git a/docs/architecture/adr-0016-diploma-eligibility-dmn.md b/docs/architecture/adr-0016-diploma-eligibility-dmn.md new file mode 100644 index 0000000..af5e19c --- /dev/null +++ b/docs/architecture/adr-0016-diploma-eligibility-dmn.md @@ -0,0 +1,66 @@ +# ADR-0016: Diploma eligibility is a DMN evaluated inline as a BPMN businessRuleTask + +- **Status:** Accepted +- **Date:** 2026-07-17 +- **Deciders:** Respellion engineering +- **Relates to:** S-13 (#14); proposal #100. Builds on ADR-0009 (external-task worker / Workflow + Client), ADR-0014/0015 (the boundary-event and routing constructs on the registratie process). + +## Context + +S-13 adds flow 4: a foreign diploma must get an extra CBGV-advies assessment before beoordeling +(PRD §5). The eligibility decision — domestic goes straight to beoordeling, foreign routes through +CBGV-advies — needs a home. The Flowable REST app bundles a DMN engine, and the same +`repository/deployments` machinery that deploys `registratie.bpmn` can deploy a `.dmn`. §8.2 makes +the Workflow Client the only code that talks to Flowable; the PRD frames the workflow as "BPMN + DMN +governing the registration workflow" (Flowable as a peer orchestration module). + +The issue's wording ("a DMN decision table evaluated by the Domain Service via Workflow Client") +suggests the domain reaches into Flowable's DMN API to evaluate the decision and feeds the result +back. That is one option; it is not the only one, and it is not the cleanest. + +## Decision + +**The diploma-eligibility DMN is deployed to Flowable and evaluated inline by the registratie process +as a `businessRuleTask`; an exclusive gateway routes on its output. The domain's only new job is to +carry the diploma origin and pass it into the process as a start variable.** + +- **The decision lives in the workflow.** `workflows/diploma-eligibility.dmn` maps `diplomaOrigin` + → `route` (`Buitenlands` ⇒ `CBGV_ADVIES`, otherwise `DIRECT`). A `businessRuleTask` + (`flowable:type="dmn"`, `decisionTableReferenceKey=diploma-eligibility`) runs it between + `OpenZaakAanmaken` and `Beoordelen`, and an exclusive gateway sends `CBGV_ADVIES` through a new + `CBGVAdvies` user task before `Beoordelen`, `DIRECT` straight there. +- **The domain carries the input, not the decision.** The `Registration` aggregate gains a + `DiplomaOrigin` (Binnenlands/Buitenlands); `SubmitRegistration` passes it to + `StartRegistrationProcessAsync`, which sets it as the `diplomaOrigin` start variable. The domain + never evaluates the DMN and never learns the route — that is the process's concern. +- **Deployed like the BPMN.** The DMN is version-controlled in `workflows/` and deployed to the DMN + engine by the same `flowable-init` step (via `dmn-api/dmn-repository/deployments`), staged into the + `fl-bpmn` volume alongside the BPMN. + +## Consequences + +**Positive** + +- The eligibility rule is a first-class, inspectable workflow artefact (matching the PRD's BPMN+DMN + framing); business users can read/adjust the decision table without touching domain code. +- §8.2 stays clean: the Workflow Client remains the only code talking to Flowable, and the decision + runs inside the process the client already started — no domain→Flowable round-trip for a decision. +- The domain change is minimal and additive: one value on the aggregate, one start variable. + +**Negative / costs** + +- Deviates from #14's literal "evaluated by the Domain Service via Workflow Client" wording (noted on + the issue). The outcome — DMN decides eligibility, foreign diplomas get the CBGV step — is unchanged. +- The DMN and its `businessRuleTask` wiring are validated live (verify-domain drives a foreign + registration through CBGV-advies and a domestic one straight to beoordeling, exercising both + branches), not in unit tests — consistent with ADR-0009/0014/0015. The domain unit/acceptance tests + cover only that the origin is carried into the process. + +## Alternatives considered + +- **Domain evaluates the DMN via the Workflow Client** (the issue's wording). Rejected: it couples + the domain to Flowable for a decision and splits the routing across two places (domain computes, + BPMN branches), for no benefit over letting the engine that owns the process own the decision. +- **Eligibility rules in domain C#.** Rejected: it moves a governable business decision out of the + DMN the PRD calls for, and hard-codes what the reference app is meant to demonstrate as data. diff --git a/docs/demo-script.md b/docs/demo-script.md index 5c8b44c..d2fcacb 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -343,3 +343,42 @@ candidate group behandelaar → teamlead (§8.2). > Both branches (escalate after 14 days; no-op when completed in time) are covered by the > `Een beoordeling escaleren` acceptance scenarios and the Workflow Client unit tests; the timer firing > and reassignment are asserted live by the verify-domain check. + +## S-13 — Diploma-eligibility: foreign diplomas route through CBGV-advies (#14, ADR-0016) + +A registration's diploma origin decides its route. A DMN `businessRuleTask` in the registratie +process evaluates the `diploma-eligibility` decision on the `diplomaOrigin` start variable: a +**foreign** (Buitenlands) diploma is routed through an extra **CBGV-advies** user task before +beoordeling; a **domestic** (Binnenlands) one goes straight to beoordeling. The decision lives in the +DMN, not in code — a beheerder can read and adjust the decision table directly. + +The self-service portal's eIDAS→foreign wiring is a later slice; for now the origin is submitted to +the domain directly, so the demo drives it through the domain endpoint: + +```bash +# 1. Submit a foreign-diploma registration to the domain (note the returned Location/reference): +DOM=http://localhost:8080 # domain service +curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \ + -d '{"bsn":"123456782","diplomaOrigin":"Buitenlands"}' | grep -i '^location:' +# +# 2. Once the zaak is opened, the process parks at the CBGV-advies task (NOT Beoordelen). In Flowable: +FL=http://localhost:8090/flowable-rest/service +curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \ + -d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' | python3 -m json.tool +# +# 3. Complete the CBGV-advies task; the case then advances to the regular Beoordelen task: +TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \ + -d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])') +curl -s -u rest-admin:test -X POST "$FL/runtime/tasks/$TID" \ + -H 'Content-Type: application/json' -d '{"action":"complete"}' +# A domestic submission (default, or "Binnenlands") skips CBGV-advies and parks straight at Beoordelen. +``` + +**The path:** domain sets the `diplomaOrigin` start variable → registratie process DMN +`businessRuleTask` sets `route` → exclusive gateway → foreign: `CBGVAdvies` user task → `Beoordelen`; +domestic: `Beoordelen` directly (§8.2, ADR-0016). + +> The domestic/foreign paths are covered by the `Een diploma op herkomst routeren` acceptance +> scenarios and unit tests (the origin is carried into the process); the DMN decision and the +> foreign→CBGV routing are asserted live by the verify-domain check. diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 1142dd5..e141d56 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -259,7 +259,7 @@ services: flowable-init: image: docker.io/curlimages/curl:latest restart: "no" - # registratie.bpmn is streamed into this external volume by infra/seed-config.sh. + # registratie.bpmn + diploma-eligibility.dmn are streamed into this external volume by infra/seed-config.sh. volumes: - fl-bpmn:/work:ro command: @@ -267,7 +267,15 @@ services: - -c - | base=http://flowable-rest:8080/flowable-rest/service/repository/deployments + dmnbase=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done + # Deploy the diploma-eligibility DMN to the DMN engine so the registratie process's + # businessRuleTask can resolve it by key (S-13, ADR-0016). + if curl -s -u rest-admin:test "$$dmnbase?name=diploma-eligibility" | grep -q '"name":"diploma-eligibility"'; then + echo "diploma-eligibility already deployed; skip" + else + curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmnbase" >/dev/null && echo "deployed diploma-eligibility" + fi if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then echo "registratie already deployed; skip" else diff --git a/infra/flowable/docker-compose.yml b/infra/flowable/docker-compose.yml index f1921b6..084fe1f 100644 --- a/infra/flowable/docker-compose.yml +++ b/infra/flowable/docker-compose.yml @@ -35,12 +35,12 @@ services: condition: service_healthy networks: [cg] - # Deploys workflows/registratie.bpmn via the REST API once flowable-rest is up. - # Idempotent: skips if a deployment named "registratie" already exists. + # Deploys workflows/registratie.bpmn + diploma-eligibility.dmn via the REST API once flowable-rest is up. + # Idempotent: skips each if a deployment of that name already exists. flowable-init: image: docker.io/curlimages/curl:latest restart: "no" - # registratie.bpmn is streamed into this external volume by infra/seed-config.sh. + # registratie.bpmn + diploma-eligibility.dmn are streamed into this external volume by infra/seed-config.sh. volumes: - fl-bpmn:/work:ro command: @@ -48,7 +48,15 @@ services: - -c - | base=http://flowable-rest:8080/flowable-rest/service/repository/deployments + dmnbase=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done + # Deploy the diploma-eligibility DMN to the DMN engine so the registratie process's + # businessRuleTask can resolve it by key (S-13, ADR-0016). + if curl -s -u rest-admin:test "$$dmnbase?name=diploma-eligibility" | grep -q '"name":"diploma-eligibility"'; then + echo "diploma-eligibility already deployed; skip" + else + curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmnbase" >/dev/null && echo "deployed diploma-eligibility" + fi if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then echo "registratie already deployed; skip" else diff --git a/infra/run-domain-check.sh b/infra/run-domain-check.sh index ef9f95d..409ea76 100755 --- a/infra/run-domain-check.sh +++ b/infra/run-domain-check.sh @@ -158,6 +158,49 @@ done [ -n "$gone" ] || { echo "FAIL — Beoordelen task for $reg_id2 still active after withdrawal" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; } echo "OK — withdrawal cancelled the Beoordelen task; the registratie process ended (ingetrokken)" +# ── S-13: diploma-eligibility routing. A registration with a FOREIGN diploma must route through the +# extra CBGVAdvies user task before Beoordelen (the DMN businessRuleTask sets route=CBGV_ADVIES and the +# gateway branches, ADR-0016). The domestic DIRECT path is already proven by the first registration +# above, which parked straight at Beoordelen. ────────────────────────────────────────────────────── +cbgv_query='{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies","includeProcessVariables":true}' +echo ">> submitting a registration with a foreign diploma" +locf="$(docker run --rm --network "$net" curlimages/curl:latest \ + -fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \ + -H 'Content-Type: application/json' -d '{"bsn":"123456782","diplomaOrigin":"Buitenlands"}' \ + | sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)" +[ -n "$locf" ] || { echo "FAIL — foreign POST /registrations returned no Location" >&2; exit 1; } +reg_idf="${locf##*/}" +echo ">> foreign registration $reg_idf" + +echo ">> polling Flowable for its CBGV-advies task (foreign diplomas route here first)" +cbgv_task="" +for _ in $(seq 1 30); do + resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$cbgv_query" 2>/dev/null || true)" + cbgv_task="$(printf '%s' "$resp" | task_for_reg "$reg_idf")" + [ -n "$cbgv_task" ] && break + sleep 2 +done +[ -n "$cbgv_task" ] || { echo "FAIL — no CBGVAdvies task appeared for the foreign registration $reg_idf" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; } +echo ">> CBGVAdvies task $cbgv_task is waiting" + +echo ">> asserting it has NOT reached Beoordelen yet (still awaiting CBGV-advies)" +resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")" +early="$(printf '%s' "$resp" | task_for_reg "$reg_idf")" +[ -z "$early" ] || { echo "FAIL — foreign registration reached Beoordelen ($early) before CBGV-advies" >&2; exit 1; } + +echo ">> completing the CBGV-advies task" +flcurl -X POST "$fl_base/runtime/tasks/$cbgv_task" -H 'Content-Type: application/json' -d '{"action":"complete"}' >/dev/null + +echo ">> asserting it now advances to Beoordelen" +onward="" +for _ in $(seq 1 15); do + resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)" + [ -n "$(printf '%s' "$resp" | task_for_reg "$reg_idf")" ] && { onward=1; break; } + sleep 2 +done +[ -n "$onward" ] || { echo "FAIL — foreign registration did not reach Beoordelen after CBGV-advies" >&2; exit 1; } +echo "OK — foreign diploma routed through CBGV-advies, then on to Beoordelen (DMN + gateway)" + # ── S-14: escalation. A third registration parks at Beoordelen. We fire its 14-day boundary timer # early via Flowable's management API (the timer job is moved to executable and run), which routes a # parallel token to the BeoordelingEscaleren external task. The domain's escalation worker acquires diff --git a/infra/seed-config.sh b/infra/seed-config.sh index 4a2ae2d..570d65c 100755 --- a/infra/seed-config.sh +++ b/infra/seed-config.sh @@ -40,7 +40,7 @@ for key in "$@"; do oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;; nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;; kc) populate rr-kc-realms "$here/keycloak/realms/." ;; - fl) populate rr-fl-bpmn "$here/../workflows/registratie.bpmn" ;; + fl) populate rr-fl-bpmn "$here/../workflows/." ;; # registratie.bpmn + diploma-eligibility.dmn *) echo "unknown seed key: $key" >&2; exit 2 ;; esac done diff --git a/services/domain/Big.Api/Program.cs b/services/domain/Big.Api/Program.cs index b6ee497..ac83c22 100644 --- a/services/domain/Big.Api/Program.cs +++ b/services/domain/Big.Api/Program.cs @@ -48,7 +48,12 @@ app.MapGet("/health", () => "Healthy"); // a location to read the registration's progress (ADR-0009, eventual consistency). app.MapPost("/registrations", async (SubmitRegistrationRequest body, SubmitRegistration submit, CancellationToken ct) => { - var id = await submit.HandleAsync(new SubmitRegistrationCommand(body.Bsn), ct); + // Diploma origin defaults to domestic; a foreign (eIDAS) submission passes "Buitenlands" so the + // workflow's DMN routes it through CBGV-advies (S-13). An unknown value is a bad request. + if (!Enum.TryParse(body.DiplomaOrigin, ignoreCase: true, out var origin) && body.DiplomaOrigin is not null) + return Results.BadRequest(new { error = $"Unknown diplomaOrigin '{body.DiplomaOrigin}'. Expected 'Binnenlands' or 'Buitenlands'." }); + + var id = await submit.HandleAsync(new SubmitRegistrationCommand(body.Bsn, origin), ct); return Results.Accepted($"/registrations/{id}", new RegistrationResponse(id.ToString(), RegistrationStatus.Ingediend.ToString(), null)); }); @@ -117,7 +122,7 @@ app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, Ca await app.RunAsync(); -public sealed record SubmitRegistrationRequest(string Bsn); +public sealed record SubmitRegistrationRequest(string Bsn, string? DiplomaOrigin = null); public sealed record DecideRequest(string Besluit); diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index 8e74b72..b89ac82 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -11,10 +11,12 @@ public interface IWorkflowClient { /// /// Start one registratie process instance for the given registration, carrying the - /// registration id so the OpenZaakAanmaken external task can be correlated back to its - /// aggregate. Returns the process instance id. + /// registration id (so the OpenZaakAanmaken external task can be correlated back to its + /// aggregate) and the diploma origin (so the workflow's DMN can route foreign diplomas through + /// CBGV-advies, S-13). Returns the process instance id. /// - Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default); + Task StartRegistrationProcessAsync( + RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default); /// /// Cancel a running registratie process on withdrawal (S-11): correlate the diff --git a/services/domain/Big.Application/SubmitRegistration.cs b/services/domain/Big.Application/SubmitRegistration.cs index 3e2f55f..1f70e9e 100644 --- a/services/domain/Big.Application/SubmitRegistration.cs +++ b/services/domain/Big.Application/SubmitRegistration.cs @@ -2,8 +2,10 @@ using Big.Domain; namespace Big.Application; -/// A zorgprofessional's request to register, in domain language. No ZGW concepts. -public sealed record SubmitRegistrationCommand(string Bsn); +/// A zorgprofessional's request to register, in domain language. No ZGW concepts. The +/// diploma origin defaults to domestic (the DigiD path); a foreign (eIDAS) submission sets it to +/// so the workflow's DMN routes it through CBGV-advies (S-13). +public sealed record SubmitRegistrationCommand(string Bsn, DiplomaOrigin DiplomaOrigin = DiplomaOrigin.Binnenlands); /// /// The submit use case: create the aggregate (INGEDIEND), persist it, @@ -18,13 +20,14 @@ public sealed class SubmitRegistration(IRegistrationStore store, IWorkflowClient { ArgumentNullException.ThrowIfNull(command); - var registration = Registration.Submit(command.Bsn); + var registration = Registration.Submit(command.Bsn, command.DiplomaOrigin); // Persist before starting the process so the worker can correlate the OpenZaakAanmaken // job back to an aggregate that already exists (ADR-0009). await store.SaveAsync(registration, ct); - var processInstanceId = await workflow.StartRegistrationProcessAsync(registration.Id, ct); + var processInstanceId = await workflow.StartRegistrationProcessAsync( + registration.Id, registration.DiplomaOrigin, ct); registration.RecordProcessStarted(processInstanceId); await store.SaveAsync(registration, ct); diff --git a/services/domain/Big.Domain/DiplomaOrigin.cs b/services/domain/Big.Domain/DiplomaOrigin.cs new file mode 100644 index 0000000..74812dc --- /dev/null +++ b/services/domain/Big.Domain/DiplomaOrigin.cs @@ -0,0 +1,17 @@ +namespace Big.Domain; + +/// +/// Where a zorgprofessional's diploma was issued. It is the input to the diploma-eligibility decision +/// (S-13): a (foreign) diploma routes the registratie through an extra +/// CBGV-advies assessment step, a (domestic) one goes straight to beoordeling. +/// The decision itself lives in the workflow's DMN, not here (ADR-0016); the domain only carries the +/// origin and hands it to the process as a start variable. +/// +public enum DiplomaOrigin +{ + /// A Dutch (domestic) diploma. Default for a registration submitted via DigiD. + Binnenlands, + + /// A foreign diploma (e.g. an eIDAS submission). Triggers the CBGV-advies step. + Buitenlands, +} diff --git a/services/domain/Big.Domain/Registration.cs b/services/domain/Big.Domain/Registration.cs index 4b29eab..51f20cf 100644 --- a/services/domain/Big.Domain/Registration.cs +++ b/services/domain/Big.Domain/Registration.cs @@ -7,10 +7,11 @@ namespace Big.Domain; /// public sealed class Registration { - private Registration(RegistrationId id, string bsn) + private Registration(RegistrationId id, string bsn, DiplomaOrigin diplomaOrigin) { Id = id; Bsn = bsn; + DiplomaOrigin = diplomaOrigin; Status = RegistrationStatus.Ingediend; } @@ -20,6 +21,10 @@ public sealed class Registration /// as the domain payload; the domain never constructs ZGW concepts from it (§8.1). public string Bsn { get; } + /// Where the diploma was issued. Rides along to the process as a start variable and + /// drives the diploma-eligibility DMN's foreign→CBGV-advies routing (S-13, ADR-0016). + public DiplomaOrigin DiplomaOrigin { get; } + public RegistrationStatus Status { get; private set; } /// The Flowable process instance driving this registration, once started. @@ -28,11 +33,13 @@ public sealed class Registration /// The zaak the ACL opened for this registration, once the external task has run. public Uri? ZaakUrl { get; private set; } - /// Submit a new registration. It begins in . - public static Registration Submit(string bsn) + /// Submit a new registration. It begins in . + /// The diploma origin defaults to — the common DigiD path; + /// a foreign (eIDAS) submission passes . + public static Registration Submit(string bsn, DiplomaOrigin diplomaOrigin = DiplomaOrigin.Binnenlands) { ArgumentException.ThrowIfNullOrWhiteSpace(bsn); - return new Registration(RegistrationId.New(), bsn); + return new Registration(RegistrationId.New(), bsn, diplomaOrigin); } /// Record that the registratie workflow process has been started for this registration. diff --git a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs index d8933e2..edf379c 100644 --- a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs +++ b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs @@ -24,15 +24,20 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti private const string BehandelaarGroup = "behandelaar"; private const string TeamleadGroup = "teamlead"; private const string RegistrationIdVariable = "registrationId"; + private const string DiplomaOriginVariable = "diplomaOrigin"; private const string ZaakUrlVariable = "zaakUrl"; private const string BesluitVariable = "besluit"; private const string IngetrokkenMessage = "RegistratieIngetrokken"; - public async Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) + public async Task StartRegistrationProcessAsync( + RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default) { var request = new StartProcessRequest( ProcessDefinitionKey, - [new Variable(RegistrationIdVariable, "string", registrationId.ToString())]); + [ + new Variable(RegistrationIdVariable, "string", registrationId.ToString()), + new Variable(DiplomaOriginVariable, "string", diplomaOrigin.ToString()), + ]); var created = await PostAsync( "service/runtime/process-instances", request, ct) diff --git a/services/domain/Big.Tests/Fakes.cs b/services/domain/Big.Tests/Fakes.cs index 539f554..e9c6953 100644 --- a/services/domain/Big.Tests/Fakes.cs +++ b/services/domain/Big.Tests/Fakes.cs @@ -32,12 +32,15 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac : IWorkflowClient { public RegistrationId? StartedFor { get; private set; } + public DiplomaOrigin? StartedWithOrigin { get; private set; } public string? WithdrawnProcessInstanceId { get; private set; } - public Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) + public Task StartRegistrationProcessAsync( + RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default) { onStart?.Invoke(registrationId); StartedFor = registrationId; + StartedWithOrigin = diplomaOrigin; return Task.FromResult(processInstanceId); } diff --git a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs index d852e11..b67d9d7 100644 --- a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs +++ b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs @@ -24,7 +24,7 @@ public class FlowableWorkflowClientTests var client = Client(capture.Responds(HttpStatusCode.Created, """{"id":"pi-1"}""")); var rid = RegistrationId.New(); - var pid = await client.StartRegistrationProcessAsync(rid); + var pid = await client.StartRegistrationProcessAsync(rid, DiplomaOrigin.Binnenlands); Assert.Equal("pi-1", pid); Assert.Equal(HttpMethod.Post, capture.Seen!.Method); @@ -38,6 +38,22 @@ public class FlowableWorkflowClientTests Assert.Contains($"\"value\":\"{rid}\"", capture.Body); } + [Theory] + [InlineData(DiplomaOrigin.Binnenlands, "Binnenlands")] + [InlineData(DiplomaOrigin.Buitenlands, "Buitenlands")] + public async Task Start_posts_the_diploma_origin_as_a_process_variable(DiplomaOrigin origin, string expected) + { + // The diploma origin rides along as a start variable so the workflow's DMN can route foreign + // diplomas through CBGV-advies (S-13, ADR-0016). + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.Created, """{"id":"pi-1"}""")); + + await client.StartRegistrationProcessAsync(RegistrationId.New(), origin); + + Assert.Contains("\"name\":\"diplomaOrigin\"", capture.Body); + Assert.Contains($"\"value\":\"{expected}\"", capture.Body); + } + [Fact] public async Task Start_uses_the_configured_worker_credentials_and_defaults() { @@ -125,7 +141,7 @@ public class FlowableWorkflowClientTests var client = Client(capture.Responds(HttpStatusCode.InternalServerError)); await Assert.ThrowsAsync( - () => client.StartRegistrationProcessAsync(RegistrationId.New())); + () => client.StartRegistrationProcessAsync(RegistrationId.New(), DiplomaOrigin.Binnenlands)); } [Fact] @@ -135,7 +151,7 @@ public class FlowableWorkflowClientTests var client = Client(capture.Responds(HttpStatusCode.Created, "null")); var ex = await Assert.ThrowsAsync( - () => client.StartRegistrationProcessAsync(RegistrationId.New())); + () => client.StartRegistrationProcessAsync(RegistrationId.New(), DiplomaOrigin.Binnenlands)); Assert.Contains("empty process-instance", ex.Message); } diff --git a/services/domain/Big.Tests/RegistrationTests.cs b/services/domain/Big.Tests/RegistrationTests.cs index 48ef524..b166831 100644 --- a/services/domain/Big.Tests/RegistrationTests.cs +++ b/services/domain/Big.Tests/RegistrationTests.cs @@ -16,6 +16,15 @@ public class RegistrationTests Assert.Null(registration.ProcessInstanceId); } + [Fact] + public void A_registration_defaults_to_a_domestic_diploma() + => Assert.Equal(DiplomaOrigin.Binnenlands, Registration.Submit("123456782").DiplomaOrigin); + + [Fact] + public void A_foreign_diploma_submission_records_its_origin() + => Assert.Equal(DiplomaOrigin.Buitenlands, + Registration.Submit("123456782", DiplomaOrigin.Buitenlands).DiplomaOrigin); + [Theory] [InlineData("")] [InlineData(" ")] diff --git a/services/domain/Big.Tests/SubmitRegistrationTests.cs b/services/domain/Big.Tests/SubmitRegistrationTests.cs index 29974bf..bdcc2df 100644 --- a/services/domain/Big.Tests/SubmitRegistrationTests.cs +++ b/services/domain/Big.Tests/SubmitRegistrationTests.cs @@ -25,6 +25,19 @@ public class SubmitRegistrationTests Assert.Equal(2, store.SaveCount); } + [Fact] + public async Task Submitting_a_foreign_diploma_carries_its_origin_to_the_process() + { + var store = new FakeRegistrationStore(); + var workflow = new FakeWorkflowClient(); + var handler = new SubmitRegistration(store, workflow); + + var id = await handler.HandleAsync(new SubmitRegistrationCommand("123456782", DiplomaOrigin.Buitenlands)); + + Assert.Equal(DiplomaOrigin.Buitenlands, (await store.GetAsync(id))!.DiplomaOrigin); + Assert.Equal(DiplomaOrigin.Buitenlands, workflow.StartedWithOrigin); + } + [Fact] public async Task Rejects_a_null_command_without_touching_the_store_or_workflow() { diff --git a/tests/acceptance/Features/EenDiplomaRouteren.feature b/tests/acceptance/Features/EenDiplomaRouteren.feature new file mode 100644 index 0000000..f404ab5 --- /dev/null +++ b/tests/acceptance/Features/EenDiplomaRouteren.feature @@ -0,0 +1,19 @@ +# language: en +# Drives S-13 (#14). A registration's diploma origin decides its route: a domestic (Binnenlands) +# diploma goes straight to beoordeling, a foreign (Buitenlands) one is routed through an extra +# CBGV-advies step (PRD flow 4). The decision itself is a DMN evaluated inside the workflow +# (ADR-0016); the domain's part — verified here — is carrying the origin into the process so the DMN +# can route on it. The DMN evaluation and the CBGV routing are verified live (verify-domain). +Feature: Een diploma op herkomst routeren + Als register wil ik een aanvraag met een buitenlands diploma extra laten toetsen + zodat een CBGV-advies wordt ingewonnen voordat een behandelaar beoordeelt. + + Scenario: Een binnenlands diploma start de registratie als binnenlands + Given a zorgprofessional with a "Binnenlands" diploma + When they submit their registration + Then the registratie process is started carrying a "Binnenlands" diploma + + Scenario: Een buitenlands diploma start de registratie als buitenlands + Given a zorgprofessional with a "Buitenlands" diploma + When they submit their registration + Then the registratie process is started carrying a "Buitenlands" diploma diff --git a/tests/acceptance/Steps/EenDiplomaRouterenSteps.cs b/tests/acceptance/Steps/EenDiplomaRouterenSteps.cs new file mode 100644 index 0000000..187f3ca --- /dev/null +++ b/tests/acceptance/Steps/EenDiplomaRouterenSteps.cs @@ -0,0 +1,36 @@ +using Acceptance.Support; +using Big.Application; +using Big.Domain; +using Reqnroll; +using Xunit; + +namespace Acceptance.Steps; + +/// Bindings for EenDiplomaRouteren.feature (S-13). Drives the submit use case against +/// in-memory ports and asserts the registratie process is started carrying the diploma origin — the +/// domain's contribution to flow 4. The DMN evaluation and the foreign→CBGV-advies routing it drives +/// are verified live (verify-domain); one instance per scenario. +[Binding] +[Scope(Feature = "Een diploma op herkomst routeren")] +public sealed class EenDiplomaRouterenSteps +{ + private readonly InMemoryRegistrationStore _store = new(); + private readonly InMemoryWorkflowClient _workflow = new(); + private DiplomaOrigin _origin; + + [Given("a zorgprofessional with a \"(.*)\" diploma")] + public void GivenAZorgprofessionalWithADiploma(string origin) + => _origin = Enum.Parse(origin, ignoreCase: true); + + [When("they submit their registration")] + public async Task WhenTheySubmitTheirRegistration() + => await new SubmitRegistration(_store, _workflow).HandleAsync( + new SubmitRegistrationCommand("123456782", _origin)); + + [Then("the registratie process is started carrying a \"(.*)\" diploma")] + public void ThenTheProcessIsStartedCarryingTheDiploma(string expected) + { + Assert.NotNull(_workflow.StartedFor); + Assert.Equal(Enum.Parse(expected, ignoreCase: true), _workflow.StartedWithOrigin); + } +} diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs index 4926b21..af07de0 100644 --- a/tests/acceptance/Support/InMemoryDomainPorts.cs +++ b/tests/acceptance/Support/InMemoryDomainPorts.cs @@ -12,11 +12,14 @@ public sealed class InMemoryWorkflowClient : IWorkflowClient public const string StartedProcessInstanceId = "proc-acc-1"; public RegistrationId? StartedFor { get; private set; } + public DiplomaOrigin? StartedWithOrigin { get; private set; } public string? WithdrawnProcessInstanceId { get; private set; } - public Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) + public Task StartRegistrationProcessAsync( + RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default) { StartedFor = registrationId; + StartedWithOrigin = diplomaOrigin; return Task.FromResult(StartedProcessInstanceId); } diff --git a/workflows/diploma-eligibility.dmn b/workflows/diploma-eligibility.dmn new file mode 100644 index 0000000..4dde9c2 --- /dev/null +++ b/workflows/diploma-eligibility.dmn @@ -0,0 +1,38 @@ + + + + + + + + diplomaOrigin + + + + + + "Buitenlands" + + + "CBGV_ADVIES" + + + + + + + + "DIRECT" + + + + + diff --git a/workflows/registratie.bpmn b/workflows/registratie.bpmn index 4817d99..8180567 100644 --- a/workflows/registratie.bpmn +++ b/workflows/registratie.bpmn @@ -1,6 +1,7 @@ + only changes who may claim it. + S-13 adds diploma-eligibility routing: between OpenZaakAanmaken and Beoordelen a DMN + businessRuleTask evaluates the `diploma-eligibility` decision on the diplomaOrigin start + variable; an exclusive gateway routes a foreign diploma through the CBGV-advies user task + before Beoordelen, a domestic one straight there (ADR-0016). --> @@ -33,7 +38,35 @@ flowable:type="external-worker" flowable:topic="OpenZaakAanmaken"/> - + + + + + + + + + + + + + + + + + + + + + + + + + @@ -77,53 +110,79 @@ - + + + + + + + + + + - + - + + + + + + + + + + + + + + + + - + - - + + + + + + + + + + + + + + + + + + + - - + + - - - - - - - - + + - - - - - - - - - - - - + + + - - + +