Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f77dae587 | ||
|
|
8d936ffdaa | ||
|
|
990db61ba7 | ||
|
|
5402bc179c | ||
|
|
2b60f8e51f | ||
|
|
07139324a3 | ||
|
|
0d1e2825e5 | ||
|
|
cf1c77489d | ||
|
|
404454d270 | ||
|
|
771450d46d | ||
|
|
c21becd5b9 | ||
|
|
7ceb22d46d | ||
|
|
d943b54ce8 | ||
|
|
00c5077fe4 | ||
|
|
5180253826 | ||
|
|
9bd71f1e78 | ||
|
|
3f04cb856f | ||
|
|
9421aa007a | ||
|
|
c536c965de | ||
|
|
5add817c10 | ||
|
|
11ef26d8cc | ||
|
|
f39ec2afa3 | ||
|
|
67a60e7f63 | ||
|
|
ccae27b3da | ||
|
|
7bcbc726ce | ||
|
|
8a537edd6c | ||
|
|
e7bed37cda | ||
|
|
94699f3603 | ||
|
|
951bdd8364 |
+12
-2
@@ -199,9 +199,19 @@ _Split from the original S-09 — scoped to the portal only; the approval flow i
|
|||||||
|
|
||||||
### S-10 · Document upload + boundary timer for document timeout (Flow 2)
|
### S-10 · Document upload + boundary timer for document timeout (Flow 2)
|
||||||
|
|
||||||
**Outcome:** BPMN extended with a "wacht op documenten" user task with a 30-day boundary timer. Self-service portal supports diploma upload. On timeout the case is cancelled.
|
Split (issue #11 closed) into two independently-demoable slices per §13 — the original spanned six net-new surfaces including a new ZGW boundary:
|
||||||
|
|
||||||
**Acceptance:** BDD scenarios for both branches; integration tests for the timer firing.
|
#### S-10a · Document-wait task + 30-day timeout cancellation + provision trigger — #102
|
||||||
|
|
||||||
|
**Outcome:** BPMN gains a `WachtOpDocumenten` user task with a 30-day (P30D) interrupting boundary timer. On timeout the case is cancelled — the timer runs to a dedicated cancel end-event and the domain aggregate moves to a new terminal status `Verlopen` via an external-worker (mirrors S-14 escalation / S-11 withdrawal). "Documents received" is wired end-to-end (domain endpoint + BFF + a "Documenten aanleveren" button on the self-service page) so the walking-skeleton e2e stays green — but the document is **not yet stored** in ZGW; that is S-10b.
|
||||||
|
|
||||||
|
**Acceptance:** BDD both branches (documents-in-time vs timeout-cancel); live timer-fire via the management-API "move" idiom; the registration e2e provides documents before the behandelaar step.
|
||||||
|
|
||||||
|
#### S-10b · Real diploma upload stored via the ACL Documenten API — #103
|
||||||
|
|
||||||
|
**Outcome:** the self-service "Documenten aanleveren" action becomes a real file upload; the document is stored in the ZGW Documenten (DRC) API and related to the zaak, with all document calls routed through the ACL (§8.1), and the zaak is set to a cancellation status on timeout expiry. Builds on the S-10a trigger/wait. Depends on #102.
|
||||||
|
|
||||||
|
**Acceptance:** ACL Documenten gateway integration test; Playwright e2e uploads a real document; the openbaar/zaak reflects the stored document.
|
||||||
|
|
||||||
### S-11 · Withdrawal (Flow 3)
|
### S-11 · Withdrawal (Flow 3)
|
||||||
|
|
||||||
|
|||||||
@@ -19,5 +19,9 @@ COPY --from=build /src/dist/apps/behandel/browser /usr/share/nginx/html
|
|||||||
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
|
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
|
||||||
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
|
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
|
||||||
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
|
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
|
||||||
|
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||||
|
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||||
|
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -17,5 +17,9 @@ FROM nginx:1.27-alpine AS runtime
|
|||||||
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/nginx/html
|
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/nginx/html
|
||||||
# No runtime config: the openbaar register is anonymous (no OIDC authority to inject).
|
# No runtime config: the openbaar register is anonymous (no OIDC authority to inject).
|
||||||
|
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||||
|
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||||
|
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Point nginx's reverse-proxy `resolver` at THIS container's real DNS server.
|
||||||
|
#
|
||||||
|
# The portal nginx configs use a variable proxy_pass, which needs a `resolver` so the BFF hostname is
|
||||||
|
# resolved at request time (nginx can start before the BFF is up). The config hardcodes Docker's
|
||||||
|
# embedded DNS (127.0.0.11) — correct on Docker/Docker Desktop, but rootless podman uses a
|
||||||
|
# network-specific address (aardvark, e.g. 10.89.0.1), so proxied calls 502 there. Read the actual
|
||||||
|
# nameserver from /etc/resolv.conf and substitute it, so the reverse proxy works on any engine.
|
||||||
|
#
|
||||||
|
# Runs from the nginx image's /docker-entrypoint.d/ before nginx starts. On Docker the nameserver IS
|
||||||
|
# 127.0.0.11, so the substitution is a no-op. Guarded (no `set -e`) so it's safe whether the nginx
|
||||||
|
# entrypoint executes or sources it.
|
||||||
|
ns="$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf 2>/dev/null)"
|
||||||
|
if [ -n "$ns" ] && [ "$ns" != "127.0.0.11" ]; then
|
||||||
|
sed -i "s/resolver 127\.0\.0\.11/resolver $ns/" /etc/nginx/conf.d/default.conf 2>/dev/null || true
|
||||||
|
echo "portal-nginx-resolver: set resolver to $ns"
|
||||||
|
fi
|
||||||
@@ -19,5 +19,9 @@ COPY --from=build /src/dist/apps/self-service/browser /usr/share/nginx/html
|
|||||||
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
|
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
|
||||||
# service name, so the token issuer matches the BFF's authority (host-consistent, ADR-0010).
|
# service name, so the token issuer matches the BFF's authority (host-consistent, ADR-0010).
|
||||||
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/nginx/html/config.json
|
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/nginx/html/config.json
|
||||||
|
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
|
||||||
|
# the nginx image's /docker-entrypoint.d before nginx starts.
|
||||||
|
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -3,9 +3,47 @@
|
|||||||
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
|
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
|
||||||
|
|
||||||
@if (submitted()) {
|
@if (submitted()) {
|
||||||
<p utrecht-paragraph role="status">
|
@if (withdrawn()) {
|
||||||
Uw registratie is ontvangen. Referentie: {{ reference() }}.
|
<p utrecht-paragraph role="status">
|
||||||
</p>
|
Uw registratie met referentie {{ reference() }} is ingetrokken.
|
||||||
|
</p>
|
||||||
|
} @else {
|
||||||
|
<p utrecht-paragraph role="status">
|
||||||
|
Uw registratie is ontvangen. Referentie: {{ reference() }}.
|
||||||
|
</p>
|
||||||
|
@if (documentsProvided()) {
|
||||||
|
<p utrecht-paragraph role="status">Uw documenten zijn aangeleverd.</p>
|
||||||
|
} @else {
|
||||||
|
@if (provideDocumentsFailed()) {
|
||||||
|
<p utrecht-paragraph role="alert">
|
||||||
|
Het aanleveren van uw documenten is niet gelukt. Probeer het opnieuw.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
utrecht-button
|
||||||
|
appearance="primary-action-button"
|
||||||
|
type="button"
|
||||||
|
[disabled]="providingDocuments()"
|
||||||
|
(click)="provideDocuments()"
|
||||||
|
>
|
||||||
|
Documenten aanleveren
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (withdrawFailed()) {
|
||||||
|
<p utrecht-paragraph role="alert">
|
||||||
|
Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
utrecht-button
|
||||||
|
appearance="secondary-action-button"
|
||||||
|
type="button"
|
||||||
|
[disabled]="withdrawing()"
|
||||||
|
(click)="withdraw()"
|
||||||
|
>
|
||||||
|
Trek aanvraag in
|
||||||
|
</button>
|
||||||
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
|
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
|
||||||
@if (failed()) {
|
@if (failed()) {
|
||||||
|
|||||||
@@ -17,12 +17,25 @@ class FakeAuth extends AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function providers(post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' }))) {
|
function providers(
|
||||||
|
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||||
|
withdraw = vi.fn().mockReturnValue(of(undefined)),
|
||||||
|
provideDocuments = vi.fn().mockReturnValue(of(undefined)),
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
post,
|
post,
|
||||||
|
withdraw,
|
||||||
|
provideDocuments,
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: AuthService, useClass: FakeAuth },
|
{ provide: AuthService, useClass: FakeAuth },
|
||||||
{ provide: BffApiV1Service, useValue: { postSelfServiceRegistrations: post } },
|
{
|
||||||
|
provide: BffApiV1Service,
|
||||||
|
useValue: {
|
||||||
|
postSelfServiceRegistrations: post,
|
||||||
|
postSelfServiceRegistrationsIdWithdraw: withdraw,
|
||||||
|
postSelfServiceRegistrationsIdDocuments: provideDocuments,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -56,6 +69,67 @@ describe('RegistrationPage', () => {
|
|||||||
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
|
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers to withdraw after submitting, and withdrawing confirms', async () => {
|
||||||
|
const { withdraw, providers: p } = providers();
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
await screen.findByText(/ontvangen/i);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
|
||||||
|
|
||||||
|
// The withdrawal is keyed by the reference the submit returned, and the page confirms it.
|
||||||
|
expect(withdraw).toHaveBeenCalledWith('reg-9');
|
||||||
|
expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers to provide documents after submitting, and doing so confirms', async () => {
|
||||||
|
const { provideDocuments, providers: p } = providers();
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
await screen.findByText(/ontvangen/i);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
|
||||||
|
|
||||||
|
// The provide-documents call is keyed by the reference the submit returned, and the page confirms.
|
||||||
|
expect(provideDocuments).toHaveBeenCalledWith('reg-9');
|
||||||
|
expect(await screen.findByText(/documenten.*aangeleverd/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a provide-documents failure and keeps the action available', async () => {
|
||||||
|
const { providers: p } = providers(
|
||||||
|
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||||
|
vi.fn().mockReturnValue(of(undefined)),
|
||||||
|
vi.fn().mockReturnValue(throwError(() => new Error('documents rejected'))),
|
||||||
|
);
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
await screen.findByText(/ontvangen/i);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/aangeleverd/i)).toBeNull();
|
||||||
|
expect(screen.getByRole('button', { name: /documenten aanleveren/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a withdraw failure and keeps the action available', async () => {
|
||||||
|
const { providers: p } = providers(
|
||||||
|
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||||
|
vi.fn().mockReturnValue(throwError(() => new Error('withdraw rejected'))),
|
||||||
|
);
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
await screen.findByText(/ontvangen/i);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/is ingetrokken/i)).toBeNull();
|
||||||
|
expect(screen.getByRole('button', { name: /trek aanvraag in/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('has no WCAG 2.1 AA violations on the submit page', async () => {
|
it('has no WCAG 2.1 AA violations on the submit page', async () => {
|
||||||
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
|
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
|
||||||
// html-has-lang rule reflects the app, not the bare jsdom document.
|
// html-has-lang rule reflects the app, not the bare jsdom document.
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { UtrechtComponentsModule } from 'ui';
|
|||||||
/**
|
/**
|
||||||
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
|
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
|
||||||
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
|
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
|
||||||
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c).
|
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After
|
||||||
|
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-registration-page',
|
selector: 'app-registration-page',
|
||||||
@@ -22,6 +23,12 @@ export class RegistrationPage {
|
|||||||
protected readonly reference = signal<string | undefined>(undefined);
|
protected readonly reference = signal<string | undefined>(undefined);
|
||||||
protected readonly submitted = signal(false);
|
protected readonly submitted = signal(false);
|
||||||
protected readonly failed = signal(false);
|
protected readonly failed = signal(false);
|
||||||
|
protected readonly withdrawing = signal(false);
|
||||||
|
protected readonly withdrawn = signal(false);
|
||||||
|
protected readonly withdrawFailed = signal(false);
|
||||||
|
protected readonly providingDocuments = signal(false);
|
||||||
|
protected readonly documentsProvided = signal(false);
|
||||||
|
protected readonly provideDocumentsFailed = signal(false);
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
this.submitting.set(true);
|
this.submitting.set(true);
|
||||||
@@ -39,4 +46,44 @@ export class RegistrationPage {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
provideDocuments(): void {
|
||||||
|
const reference = this.reference();
|
||||||
|
if (!reference) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.providingDocuments.set(true);
|
||||||
|
this.provideDocumentsFailed.set(false);
|
||||||
|
this.bff.postSelfServiceRegistrationsIdDocuments(reference).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.documentsProvided.set(true);
|
||||||
|
this.providingDocuments.set(false);
|
||||||
|
},
|
||||||
|
// Surface the failure instead of swallowing it: keep the action so the user can retry.
|
||||||
|
error: () => {
|
||||||
|
this.provideDocumentsFailed.set(true);
|
||||||
|
this.providingDocuments.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
withdraw(): void {
|
||||||
|
const reference = this.reference();
|
||||||
|
if (!reference) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.withdrawing.set(true);
|
||||||
|
this.withdrawFailed.set(false);
|
||||||
|
this.bff.postSelfServiceRegistrationsIdWithdraw(reference).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.withdrawn.set(true);
|
||||||
|
this.withdrawing.set(false);
|
||||||
|
},
|
||||||
|
// Surface the failure instead of swallowing it: keep the action so the user can retry.
|
||||||
|
error: () => {
|
||||||
|
this.withdrawFailed.set(true);
|
||||||
|
this.withdrawing.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# ADR-0015: Beoordeling escalation reassigns via an external-worker task
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-17
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-14 (#15); proposal #98. Builds on ADR-0009 (external-task worker / Workflow
|
||||||
|
Client), ADR-0013 (behandel-portal wiring, the `Beoordelen` user task), ADR-0014 (the boundary-event
|
||||||
|
pattern on `Beoordelen`).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
S-14 escalates a beoordeling that a behandelaar does not pick up in time: after 14 days the case must
|
||||||
|
move to the `teamlead` role (PRD §5, flow 5). The `Beoordelen` user task already exists, claimable by
|
||||||
|
the `behandelaar` candidate group; the teamlead role is seeded in the medewerker realm.
|
||||||
|
|
||||||
|
Two forces shape this.
|
||||||
|
|
||||||
|
1. **The task must stay open.** Escalation changes *who may claim* an unclaimed beoordeling, not the
|
||||||
|
work itself — so the timer must be **non-interrupting**: the `Beoordelen` task keeps running while
|
||||||
|
escalation happens alongside it.
|
||||||
|
2. **Reassigning an open task's candidate group needs code.** Flowable cannot rewrite the candidate
|
||||||
|
groups of an already-open user task from BPMN XML alone — that requires either a Java delegate/listener
|
||||||
|
embedded in the engine, or an out-of-process actor driving the REST API. The repository has held a
|
||||||
|
"stock Flowable image, no custom jars; the Workflow Client is the only code that talks to Flowable
|
||||||
|
(§8.2)" posture since ADR-0009.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**A non-interrupting `P14D` boundary timer on `Beoordelen` fires an external-worker task
|
||||||
|
(`BeoordelingEscaleren`); the Workflow Client reassigns the still-open `Beoordelen` task from the
|
||||||
|
behandelaar group to teamlead.**
|
||||||
|
|
||||||
|
- **Modelled in BPMN, driven by an external worker.** The timer routes a parallel token to an
|
||||||
|
`external-worker` service task on the `BeoordelingEscaleren` topic, ending at a dedicated "Beoordeling
|
||||||
|
geëscaleerd" end event. The model owns *when* escalation happens; the Workflow Client — the only code
|
||||||
|
that talks to Flowable (§8.2) — owns *how* the reassignment is applied, exactly as `OpenZaakAanmaken`
|
||||||
|
delegates the ZGW call (ADR-0009). No custom code runs inside Flowable.
|
||||||
|
- **Reassignment is a candidate-group swap.** The escalation worker finds the still-open `Beoordelen`
|
||||||
|
task in the escalating instance (task query by `processInstanceId` + `taskDefinitionKey`), adds
|
||||||
|
`teamlead` as a candidate group via the task identity links, then removes `behandelaar`. The task now
|
||||||
|
belongs to the teamlead; its history and variables are untouched.
|
||||||
|
- **Best-effort, mirroring beoordeling and withdrawal.** If the task is no longer open — the behandelaar
|
||||||
|
completed it in the window before the timer fired — the reassignment is a no-op. A failed reassignment
|
||||||
|
leaves the escalation job un-completed so Flowable redelivers it (§8.6), consistent with the
|
||||||
|
`OpenZaakAanmaken` worker.
|
||||||
|
- **Segregated interface.** The escalation methods live on `IBeoordelingEscalatieClient`, separate from
|
||||||
|
the `OpenZaakAanmaken` worker's `IExternalWorkerClient`, so the OpenZaak worker never sees escalation
|
||||||
|
(interface segregation). Both are implemented by the one `FlowableWorkflowClient`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- The escalation trigger is visible in `registratie.bpmn`; Flowable stays a stock image, and the
|
||||||
|
Workflow Client remains the sole Flowable client (§8.2 upheld, not bent).
|
||||||
|
- Reuses the external-worker mechanics (topic acquire/complete, hosted pump, per-tick scope,
|
||||||
|
redelivery-on-failure) wholesale — the new code is one client capability, one processor, one pump.
|
||||||
|
- Escalation latency is bounded by the worker's poll interval (seconds) — negligible against a 14-day
|
||||||
|
timer.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- Escalation is two REST hops (add teamlead, remove behandelaar) rather than one atomic update; between
|
||||||
|
them the task is briefly claimable by both groups. Harmless at these volumes, and the pair is idempotent
|
||||||
|
on redelivery.
|
||||||
|
- The Flowable identity-link and management-job REST shapes are validated live (verify-domain fires the
|
||||||
|
timer early via the management API), not in the Workflow Client's unit tests, which stub the HTTP
|
||||||
|
exchange and assert only the request shape — consistent with ADR-0009 and ADR-0014.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Flowable timer/task listener (Java delegate).** Reassign in-engine when the timer fires. Rejected:
|
||||||
|
it needs a custom jar in Flowable, breaking the stock-image, REST-only posture and adding a build/deploy
|
||||||
|
surface to the engine for no capability the external-worker route lacks.
|
||||||
|
- **Interrupting timer that re-creates the task for teamlead.** Cancel `Beoordelen` and start a fresh
|
||||||
|
teamlead task. Rejected: it loses the task's identity/history and complicates correlation, where a
|
||||||
|
candidate-group swap on the same task expresses "the same work, now the teamlead's" directly.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# ADR-0016: Diploma eligibility is a DMN evaluated inline as a BPMN DMN service task
|
||||||
|
|
||||||
|
- **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 DMN service task (`flowable:type="dmn"`); 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 DMN service task
|
||||||
|
(`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. (A `businessRuleTask` would
|
||||||
|
bind Flowable's legacy Drools/KIE implementation, which `flowable-rest` does not bundle — its parse
|
||||||
|
handler throws `NoClassDefFoundError` at deploy time; the DMN service task is the supported route.)
|
||||||
|
- **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 as its own DMN-engine deployment, separate from the BPMN.** The DMN is version-controlled
|
||||||
|
in `workflows/` and `flowable-init` deploys it to the DMN engine via the `dmn-api`
|
||||||
|
(`/dmn-api/dmn-repository/deployments`), while `registratie.bpmn` goes to the process engine via
|
||||||
|
`/service/repository/deployments`. Two things were learned the hard way here (both cost a CI cycle):
|
||||||
|
(1) `flowable-rest` does **not** cascade a `.dmn` bundled inside a process `.bar` into the DMN engine
|
||||||
|
— the resource is stored but no decision is created, so the service task fails at runtime with
|
||||||
|
`FlowableObjectNotFoundException: No decision found for key`; the DMN must go through `dmn-api`.
|
||||||
|
(2) Flowable's DMN XML converter rejects an XML comment placed between the `<?xml?>` declaration and
|
||||||
|
the root `<definitions>` element (`XMLStreamReader not in START_DOCUMENT or START_ELEMENT state`),
|
||||||
|
unlike its BPMN converter — so the DMN's documentation comment lives *inside* `<definitions>`.
|
||||||
|
With the decision present in the DMN repository, the process's DMN service task resolves it across
|
||||||
|
deployments by key (verified live), so no shared parent deployment id is needed.
|
||||||
|
|
||||||
|
## 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 service-task 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.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# ADR-0017: A document-wait task with a 30-day interrupting timer cancels the registration
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-20
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-10a (#102); proposal #104; split from S-10 (#11). Builds on ADR-0009 (external-task
|
||||||
|
worker / Workflow Client), ADR-0014 (withdrawal cancels the process), ADR-0015 (beoordeling
|
||||||
|
escalation — the boundary-timer + external-worker pattern), ADR-0016 (diploma-eligibility DMN).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Flow 2 (PRD §5) requires the citizen to supply documents (their diploma) after submitting. The
|
||||||
|
registratie process must park waiting for those documents and, if they do not arrive within 30 days,
|
||||||
|
cancel the case. S-10 was split (§13): **S-10a** is this workflow/timeout spine (backend only);
|
||||||
|
**S-10b** wires the actual upload (portal → BFF → domain → ACL → Documenten API) that completes the
|
||||||
|
wait. This ADR records the spine: where the wait sits, how the timeout cancels, and how the domain
|
||||||
|
aggregate stays in sync.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**A `WachtOpDocumenten` user task is inserted immediately after `OpenZaakAanmaken`, carrying an
|
||||||
|
`cancelActivity="true"` (interrupting) `P30D` boundary timer. "Documents received" completes the task
|
||||||
|
and the process continues into the diploma-eligibility routing; on timeout the timer cancels the task,
|
||||||
|
runs a `RegistratieVerlopen` external-worker task, and ends the process at `endVerlopen`. A domain
|
||||||
|
worker expires the correlated aggregate to a new terminal status `Verlopen`.**
|
||||||
|
|
||||||
|
- **Where the wait sits.** Right after the zaak is opened, before the diploma-eligibility DMN: the zaak
|
||||||
|
exists, then the process waits for documents; on receipt it continues to the DMN routing → Beoordelen
|
||||||
|
(ADR-0016). The wait gates the whole assessment, so it precedes the routing rather than sitting
|
||||||
|
between the gateway and Beoordelen.
|
||||||
|
- **Interrupting timer, mirroring the existing constructs.** Unlike the S-14 escalation timer
|
||||||
|
(non-interrupting — the Beoordelen task stays open), this timer is interrupting: when it fires the
|
||||||
|
wait token is consumed and the case is cancelled, like the S-11 withdrawal boundary (ADR-0014). The
|
||||||
|
timeout branch runs a `RegistratieVerlopen` external-worker task (topic mirrors
|
||||||
|
`OpenZaakAanmaken`/`BeoordelingEscaleren`) → `endVerlopen`.
|
||||||
|
- **The domain stays authoritative.** The `RegistratieVerlopen` job carries the `registrationId`; the
|
||||||
|
`RegistratieVerlopenProcessor` drains it and the `ExpireRegistrationWorker` loads the aggregate and
|
||||||
|
calls `Registration.Expire()`, moving it to the new terminal status `Verlopen`. This keeps the
|
||||||
|
aggregate — which the projection/openbaar view reads — the source of truth, exactly as escalation and
|
||||||
|
withdrawal do. Idempotent per §8.6: a redelivered job whose aggregate is already `Verlopen` completes
|
||||||
|
without persisting again; an unknown registration throws so the job is redelivered.
|
||||||
|
- **Documents-in-time transition.** `IWorkflowClient.CompleteDocumentWaitAsync(processInstanceId)`
|
||||||
|
completes the `WachtOpDocumenten` task (the Workflow Client remains the only code that talks to
|
||||||
|
Flowable, §8.2). It is best-effort — a no-op if the instance already left the wait (continued, or
|
||||||
|
timed out). The trigger is wired end-to-end in S-10a: a `ProvideDocuments` application use case behind
|
||||||
|
an owner-scoped domain endpoint `POST /registrations/{id}/documents`, a BFF passthrough
|
||||||
|
`POST /self-service/registrations/{id}/documents` (bsn from the DigiD token), and a "Documenten
|
||||||
|
aanleveren" action on the self-service page — so the walking-skeleton e2e stays green (a registration
|
||||||
|
can still reach the behandelaar). **S-10b replaces the stub trigger with a real file upload stored in
|
||||||
|
the ZGW Documenten (DRC) API via the ACL**; the completion of the wait is unchanged.
|
||||||
|
- *Why the trigger lives here, not in S-10b:* inserting the `WachtOpDocumenten` gate without any way
|
||||||
|
to pass it breaks the submit→beoordeling e2e (a merge gate). Splitting "gate" from "means to pass
|
||||||
|
the gate" across slices would leave `main` red, so S-10a owns both; S-10b is purely the ZGW storage
|
||||||
|
behind the same action.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- The wait/timeout is a first-class workflow construct that reuses the boundary-timer + external-worker
|
||||||
|
pattern already proven by S-14, so the domain change is small and additive: one terminal status, one
|
||||||
|
worker trio (worker + processor + pump), one Workflow Client method.
|
||||||
|
- §8 stays clean: the Workflow Client is still the only Flowable caller, and no new ZGW boundary is
|
||||||
|
introduced in S-10a.
|
||||||
|
- The timeout is verified live (verify-domain fires the P30D timer via the management-API "move" idiom
|
||||||
|
and asserts the domain reaches `Verlopen`), consistent with ADR-0009/0014/0015.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- Every registration now parks at `WachtOpDocumenten` before Beoordelen, so the other flows must supply
|
||||||
|
documents first: the live-check blocks (S-11/S-12b/S-13/S-14) complete the task via Flowable, and the
|
||||||
|
registration e2e clicks "Documenten aanleveren". A small, explicit step, but it touches every path
|
||||||
|
through the process.
|
||||||
|
- On expiry S-10a cancels the *process* and marks the aggregate `Verlopen` but does **not** set the ZGW
|
||||||
|
*zaak* to a cancellation status — that needs a new ACL method + statustype seeding, which overlaps
|
||||||
|
S-10b's ACL/infra work. Deferred to S-10b (or a follow-up); noted here as the S-10a/S-10b boundary.
|
||||||
|
- Withdrawing while parked at `WachtOpDocumenten` marks the aggregate `Ingetrokken` but does not cancel
|
||||||
|
the process (the withdrawal message boundary is on `Beoordelen`); the timeout worker tolerates this
|
||||||
|
by no-op'ing on an already-resolved aggregate. Extending withdrawal to the wait state is a follow-up.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Pure-BPMN cancellation (timer → end event, no worker).** Rejected: the domain aggregate would then
|
||||||
|
be out of sync with the cancelled process, and the openbaar/projection view reads the aggregate's
|
||||||
|
status — the case would still look open.
|
||||||
|
- **Wait task between the gateway and Beoordelen.** Rejected: documents gate the whole assessment
|
||||||
|
(including the CBGV-advies routing), so the wait belongs before the DMN, not after it.
|
||||||
|
- **A dedicated timeout status per branch vs. reusing an open-state guard.** `Expire()` reuses the same
|
||||||
|
`RequireOpenForDecision` guard as withdrawal/decision, so only an `INGEDIEND`/`IN_BEHANDELING`
|
||||||
|
registration can lapse and the terminal states stay mutually exclusive — no new guard logic.
|
||||||
@@ -275,3 +275,162 @@ ACL → NRC → event-subscriber → projection → openbaar register shows INGE
|
|||||||
> The full round-trip — DigiD submit → public INGEDIEND → behandelaar goedkeurt in the werkbak →
|
> The full round-trip — DigiD submit → public INGEDIEND → behandelaar goedkeurt in the werkbak →
|
||||||
> public INGESCHREVEN — is the Playwright happy path (`tests/e2e/registration.spec.ts`), which now
|
> public INGESCHREVEN — is the Playwright happy path (`tests/e2e/registration.spec.ts`), which now
|
||||||
> drives the behandel portal in place of the old admin endpoint.
|
> drives the behandel portal in place of the old admin endpoint.
|
||||||
|
|
||||||
|
## S-11 — Withdrawal: "trek aanvraag in" (#12, ADR-0014)
|
||||||
|
|
||||||
|
A zorgprofessional can withdraw their own still-open registration from the self-service portal. The
|
||||||
|
withdrawal is owner-scoped (the BFF forwards the DigiD token's bsn; the domain only lets the owner
|
||||||
|
withdraw) and cancels the running workflow via a BPMN message event, so the case leaves the
|
||||||
|
behandelaar's werkbak.
|
||||||
|
|
||||||
|
```text
|
||||||
|
# 1. Log in and submit at the self-service portal (http://localhost:8140/, jan-burger / test123),
|
||||||
|
# note the "Referentie" on the confirmation.
|
||||||
|
# 2. Click "Trek aanvraag in" → the page confirms the registration is ingetrokken.
|
||||||
|
# 3. In the behandel werkbak (http://localhost:8142/, merel-behandelaar) the registration no longer
|
||||||
|
# appears — its Beoordelen task was cancelled.
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** self-service → BFF `POST /self-service/registrations/{id}/withdraw` (DigiD, owner-scoped)
|
||||||
|
→ domain sets INGETROKKEN + correlates the `RegistratieIngetrokken` message to the process → the
|
||||||
|
interrupting boundary event ends it → the werkbak drops the case.
|
||||||
|
|
||||||
|
> DigiD submit → trek aanvraag in → ingetrokken is the Playwright happy path
|
||||||
|
> (`tests/e2e/withdrawal.spec.ts`); the owner-scoping + workflow cancellation are covered by the
|
||||||
|
> `Een registratie intrekken` acceptance scenarios and the domain live check.
|
||||||
|
|
||||||
|
## S-14 — Beoordeling escalation: 14 days unclaimed → teamlead (#15, ADR-0015)
|
||||||
|
|
||||||
|
A beoordeling a behandelaar does not pick up within 14 days escalates to the teamlead. A
|
||||||
|
non-interrupting boundary timer on the `Beoordelen` task fires a `BeoordelingEscaleren` external task;
|
||||||
|
the domain's escalation worker reassigns the still-open task's candidate group from `behandelaar` to
|
||||||
|
`teamlead`, so it moves from the behandelaar werkbak into the teamlead's. The `Beoordelen` task keeps
|
||||||
|
its identity throughout — only who may claim it changes.
|
||||||
|
|
||||||
|
The timer is 14 days, so the demo fires it early through Flowable's management API (exactly what the
|
||||||
|
verify-domain check automates):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Submit at the self-service portal (http://localhost:8140/, jan-burger / test123). The case
|
||||||
|
# parks at Beoordelen, visible in the behandelaar werkbak (http://localhost:8142/, merel-behandelaar)
|
||||||
|
# but NOT claimed.
|
||||||
|
#
|
||||||
|
# 2. Find the parked instance and its Beoordelen task, then fire the boundary timer early:
|
||||||
|
FL=http://localhost:8090/flowable-rest/service
|
||||||
|
PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])')
|
||||||
|
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen"}' \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||||
|
TJ=$(curl -s -u rest-admin:test "$FL/management/timer-jobs?processInstanceId=$PID" \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||||
|
curl -s -u rest-admin:test -X POST "$FL/management/timer-jobs/$TJ" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"action":"move"}'
|
||||||
|
AJ=$(curl -s -u rest-admin:test "$FL/management/jobs?processInstanceId=$PID" \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||||
|
curl -s -u rest-admin:test -X POST "$FL/management/jobs/$AJ" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"action":"execute"}'
|
||||||
|
#
|
||||||
|
# 3. Within a couple of poll cycles the task's candidate group flips to teamlead:
|
||||||
|
curl -s -u rest-admin:test "$FL/runtime/tasks/$TID/identitylinks" # → [{"group":"teamlead","type":"candidate"}]
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** BPMN non-interrupting `P14D` boundary timer on `Beoordelen` → `BeoordelingEscaleren`
|
||||||
|
external task → domain escalation worker (`BeoordelingEscalatiePump`) → Workflow Client swaps the task's
|
||||||
|
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 service task 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 first parks at WachtOpDocumenten (S-10a); complete that task
|
||||||
|
# (documents received) — then it 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
|
||||||
|
DMN service task 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.
|
||||||
|
|
||||||
|
## S-10a — Document wait + 30-day timeout cancels the registration (#102, ADR-0017)
|
||||||
|
|
||||||
|
After the zaak is opened the registratie process parks at a **WachtOpDocumenten** user task, waiting
|
||||||
|
for the citizen's documents (their diploma). Two things can happen:
|
||||||
|
|
||||||
|
- **Documents arrive in time** → the task completes and the process continues to the diploma-eligibility
|
||||||
|
routing (S-13) → beoordeling.
|
||||||
|
- **30 days pass with no documents** → an interrupting `P30D` boundary timer cancels the wait, runs the
|
||||||
|
`RegistratieVerlopen` external task, and the domain expires the registration to the terminal status
|
||||||
|
**VERLOPEN** (the case is cancelled).
|
||||||
|
|
||||||
|
The "documents received" trigger is wired end-to-end in S-10a: the self-service page shows a
|
||||||
|
**"Documenten aanleveren"** button after submit (portal → BFF → domain → completes the wait). S-10b
|
||||||
|
turns that into a real file upload stored in the ZGW Documenten API via the ACL. The timeout branch is
|
||||||
|
demonstrated by firing the 30-day timer early via the management API.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DOM=http://localhost:8080 # domain service
|
||||||
|
FL=http://localhost:8090/flowable-rest/service # flowable-rest
|
||||||
|
|
||||||
|
# 1. Submit a registration; once the zaak is opened it parks at WachtOpDocumenten:
|
||||||
|
curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"bsn":"123456782"}' | grep -i '^location:' # note the /registrations/<id> reference
|
||||||
|
WQ='{"processDefinitionKey":"registratie","taskDefinitionKey":"WachtOpDocumenten"}'
|
||||||
|
|
||||||
|
# 2a. Documents-in-time: complete the WachtOpDocumenten task → the process advances to beoordeling.
|
||||||
|
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||||
|
-d "$WQ" | 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"}'
|
||||||
|
|
||||||
|
# 2b. Timeout: instead of completing it, fire the 30-day timer early via the management API. Find the
|
||||||
|
# instance's timer job, "move" it to executable; the async executor fires the interrupting event.
|
||||||
|
PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||||
|
-d "$WQ" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])')
|
||||||
|
JID=$(curl -s -u rest-admin:test "$FL/management/timer-jobs?processInstanceId=$PID" \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||||
|
curl -s -u rest-admin:test -X POST "$FL/management/timer-jobs/$JID" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"action":"move"}'
|
||||||
|
# The RegistratieVerlopen worker then expires the aggregate — read it back as VERLOPEN:
|
||||||
|
curl -s "$DOM/registrations/<id>" # → {"status":"Verlopen", ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** registratie process parks at `WachtOpDocumenten` → documents received completes it (→
|
||||||
|
routing → `Beoordelen`), OR the `P30D` interrupting timer fires → `RegistratieVerlopen` external task
|
||||||
|
→ domain worker expires the aggregate to `Verlopen` → `endVerlopen` (§8.2, ADR-0017).
|
||||||
|
|
||||||
|
> Both branches are covered by the `Een documenttermijn laten verlopen` acceptance scenarios (worker +
|
||||||
|
> aggregate) and unit tests; the wait completion and the 30-day timer firing are asserted live by the
|
||||||
|
> verify-domain check.
|
||||||
|
|||||||
@@ -151,3 +151,19 @@ frontend work is the medewerker realm auth and the werkbak/decide page. Wiring r
|
|||||||
to assert the medewerker token attaches to `/behandel/*` (and not to the anonymous openbaar call).
|
to assert the medewerker token attaches to `/behandel/*` (and not to the anonymous openbaar call).
|
||||||
The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy
|
The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy
|
||||||
path.
|
path.
|
||||||
|
|
||||||
|
## Self-service withdrawal: "trek aanvraag in" (S-11c, #12)
|
||||||
|
|
||||||
|
The submit confirmation grows a **"Trek aanvraag in"** action so a zorgprofessional can withdraw the
|
||||||
|
registration they just submitted (`apps/self-service`, on the existing `RegistrationPage`).
|
||||||
|
|
||||||
|
- **Keyed by the reference, owner-scoped at the BFF.** The button calls the generated
|
||||||
|
`postSelfServiceRegistrationsIdWithdraw(reference)` with the reference the submit returned. The
|
||||||
|
DigiD token (attached by the interceptor) carries the bsn the BFF forwards; the domain only lets
|
||||||
|
the owner withdraw (a mismatch is 404). No extra identity is entered in the UI.
|
||||||
|
- **Same confirm-and-surface pattern as submit.** A secondary-action button; on success the page
|
||||||
|
switches to an ingetrokken confirmation; a failure is surfaced (`role="alert"`) and the action
|
||||||
|
stays available to retry — mirroring how submit handles its failure rather than swallowing it.
|
||||||
|
- **Testing.** Component tests (`@testing-library/angular`, mocked BFF) cover the button appearing
|
||||||
|
after submit, the reference being passed, the ingetrokken confirmation, and the failure path; the
|
||||||
|
browser round-trip is `tests/e2e/withdrawal.spec.ts`.
|
||||||
|
|||||||
@@ -380,6 +380,10 @@ services:
|
|||||||
image: register-referentie/event-subscriber:dev
|
image: register-referentie/event-subscriber:dev
|
||||||
environment:
|
environment:
|
||||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
||||||
|
# The subscriber enriches the projection with each zaak's reference by asking the ACL — the only
|
||||||
|
# code allowed to read ZGW (§8.1, #78). Required: startup throws without it (parity with the
|
||||||
|
# canonical compose).
|
||||||
|
Acl__BaseUrl: http://acl:8080/
|
||||||
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
||||||
ports:
|
ports:
|
||||||
- "8110:8080"
|
- "8110:8080"
|
||||||
@@ -392,6 +396,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
projection-db:
|
projection-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
acl:
|
||||||
|
condition: service_healthy
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
projection-api:
|
projection-api:
|
||||||
|
|||||||
@@ -259,19 +259,30 @@ services:
|
|||||||
flowable-init:
|
flowable-init:
|
||||||
image: docker.io/curlimages/curl:latest
|
image: docker.io/curlimages/curl:latest
|
||||||
restart: "no"
|
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:
|
volumes:
|
||||||
- fl-bpmn:/work:ro
|
- fl-bpmn:/work:ro
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
||||||
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments
|
||||||
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
|
until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
||||||
echo "registratie already deployed; skip"
|
# Deploy the DMN to the DMN engine and the BPMN to the process engine as SEPARATE deployments:
|
||||||
|
# flowable-rest does NOT cascade a .dmn bundled in a process .bar into the DMN engine, so the DMN
|
||||||
|
# must go via dmn-api. The process's DMN service task then resolves the decision across deployments
|
||||||
|
# by key (S-13, ADR-0016). Both steps are idempotent (skip if already deployed).
|
||||||
|
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
|
||||||
|
echo "diploma-eligibility DMN already deployed; skip"
|
||||||
else
|
else
|
||||||
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
|
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmn" >/dev/null && echo "deployed diploma-eligibility DMN"
|
||||||
|
fi
|
||||||
|
if curl -s -u rest-admin:test "$$svc?name=registratie" | grep -q '"name":"registratie"'; then
|
||||||
|
echo "registratie BPMN already deployed; skip"
|
||||||
|
else
|
||||||
|
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$svc" >/dev/null && echo "deployed registratie BPMN"
|
||||||
fi
|
fi
|
||||||
depends_on:
|
depends_on:
|
||||||
flowable-rest:
|
flowable-rest:
|
||||||
|
|||||||
@@ -35,24 +35,35 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
# Deploys workflows/registratie.bpmn via the REST API once flowable-rest is up.
|
# Deploys registratie.bpmn (process engine) and diploma-eligibility.dmn (DMN engine) via the REST
|
||||||
# Idempotent: skips if a deployment named "registratie" already exists.
|
# API once flowable-rest is up. Idempotent: skips each if already deployed.
|
||||||
flowable-init:
|
flowable-init:
|
||||||
image: docker.io/curlimages/curl:latest
|
image: docker.io/curlimages/curl:latest
|
||||||
restart: "no"
|
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:
|
volumes:
|
||||||
- fl-bpmn:/work:ro
|
- fl-bpmn:/work:ro
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
||||||
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments
|
||||||
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
|
until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
||||||
echo "registratie already deployed; skip"
|
# Deploy the DMN to the DMN engine and the BPMN to the process engine as SEPARATE deployments:
|
||||||
|
# flowable-rest does NOT cascade a .dmn bundled in a process .bar into the DMN engine, so the DMN
|
||||||
|
# must go via dmn-api. The process's DMN service task then resolves the decision across deployments
|
||||||
|
# by key (S-13, ADR-0016). Both steps are idempotent (skip if already deployed).
|
||||||
|
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
|
||||||
|
echo "diploma-eligibility DMN already deployed; skip"
|
||||||
else
|
else
|
||||||
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
|
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmn" >/dev/null && echo "deployed diploma-eligibility DMN"
|
||||||
|
fi
|
||||||
|
if curl -s -u rest-admin:test "$$svc?name=registratie" | grep -q '"name":"registratie"'; then
|
||||||
|
echo "registratie BPMN already deployed; skip"
|
||||||
|
else
|
||||||
|
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$svc" >/dev/null && echo "deployed registratie BPMN"
|
||||||
fi
|
fi
|
||||||
depends_on:
|
depends_on:
|
||||||
flowable-rest:
|
flowable-rest:
|
||||||
|
|||||||
@@ -93,6 +93,27 @@ print(next((t['id'] for t in (d.get('data') or [])
|
|||||||
|
|
||||||
flcurl() { docker run --rm --network "$net" curlimages/curl:latest -fsS -u rest-admin:test "$@"; }
|
flcurl() { docker run --rm --network "$net" curlimages/curl:latest -fsS -u rest-admin:test "$@"; }
|
||||||
query='{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen","includeProcessVariables":true}'
|
query='{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen","includeProcessVariables":true}'
|
||||||
|
wacht_query='{"processDefinitionKey":"registratie","taskDefinitionKey":"WachtOpDocumenten","includeProcessVariables":true}'
|
||||||
|
|
||||||
|
# S-10a: every registration now parks at WachtOpDocumenten first (interrupting P30D timer). Completing
|
||||||
|
# that task stands in for the citizen's document upload (wired for real in S-10b), letting the process
|
||||||
|
# advance to the diploma routing / Beoordelen so the checks below still hold. The 30-day timeout branch
|
||||||
|
# is exercised separately at the end.
|
||||||
|
complete_wacht() { # reg_id
|
||||||
|
local rid="$1" wid="" r
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
r="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$wacht_query" 2>/dev/null || true)"
|
||||||
|
wid="$(printf '%s' "$r" | task_for_reg "$rid")"
|
||||||
|
[ -n "$wid" ] && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$wid" ] || { echo "FAIL — no WachtOpDocumenten task appeared for $rid" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
|
flcurl -X POST "$fl_base/runtime/tasks/$wid" -H 'Content-Type: application/json' -d '{"action":"complete"}' >/dev/null
|
||||||
|
echo ">> completed WachtOpDocumenten for $rid (documents received)"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ">> completing WachtOpDocumenten so the process advances (documents received)"
|
||||||
|
complete_wacht "$reg_id"
|
||||||
|
|
||||||
echo ">> polling Flowable for the Beoordelen user task (werkbak)"
|
echo ">> polling Flowable for the Beoordelen user task (werkbak)"
|
||||||
task_id=""
|
task_id=""
|
||||||
@@ -130,6 +151,7 @@ loc2="$(docker run --rm --network "$net" curlimages/curl:latest \
|
|||||||
[ -n "$loc2" ] || { echo "FAIL — second POST /registrations returned no Location" >&2; exit 1; }
|
[ -n "$loc2" ] || { echo "FAIL — second POST /registrations returned no Location" >&2; exit 1; }
|
||||||
reg_id2="${loc2##*/}"
|
reg_id2="${loc2##*/}"
|
||||||
echo ">> second registration $reg_id2"
|
echo ">> second registration $reg_id2"
|
||||||
|
complete_wacht "$reg_id2"
|
||||||
|
|
||||||
echo ">> polling Flowable for its Beoordelen task"
|
echo ">> polling Flowable for its Beoordelen task"
|
||||||
task_id2=""
|
task_id2=""
|
||||||
@@ -157,4 +179,174 @@ for _ in $(seq 1 15); do
|
|||||||
done
|
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; }
|
[ -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)"
|
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 service task 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"
|
||||||
|
complete_wacht "$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
|
||||||
|
# it and reassigns the still-open Beoordelen task from the behandelaar group to teamlead (ADR-0015). ─
|
||||||
|
echo ">> submitting a third registration to escalate"
|
||||||
|
loc3="$(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"}' \
|
||||||
|
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
|
||||||
|
[ -n "$loc3" ] || { echo "FAIL — third POST /registrations returned no Location" >&2; exit 1; }
|
||||||
|
reg_id3="${loc3##*/}"
|
||||||
|
echo ">> third registration $reg_id3"
|
||||||
|
|
||||||
|
# Extracts "<taskId> <processInstanceId>" for a registration from a task-query response on stdin.
|
||||||
|
task_and_pid_for_reg() { REG_ID="$1" python3 -c "import os,sys,json
|
||||||
|
try:
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
d={}
|
||||||
|
rid=os.environ['REG_ID']
|
||||||
|
t=next((t for t in (d.get('data') or [])
|
||||||
|
if any(v.get('name')=='registrationId' and v.get('value')==rid for v in (t.get('variables') or []))), None)
|
||||||
|
print(f\"{t['id']} {t['processInstanceId']}\" if t else '')"; }
|
||||||
|
|
||||||
|
# The candidate groups on a task (space-separated, sorted) from a runtime identitylinks response.
|
||||||
|
candidate_groups() { python3 -c "import sys,json
|
||||||
|
try:
|
||||||
|
links=json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
links=[]
|
||||||
|
print(' '.join(sorted(l.get('group') or '' for l in links if l.get('type')=='candidate' and l.get('group'))))"; }
|
||||||
|
|
||||||
|
# The first job id in a management jobs/timer-jobs response on stdin.
|
||||||
|
first_job_id() { python3 -c "import sys,json
|
||||||
|
try:
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
d={}
|
||||||
|
print(((d.get('data') or [{}])[0]).get('id',''))"; }
|
||||||
|
|
||||||
|
complete_wacht "$reg_id3"
|
||||||
|
|
||||||
|
echo ">> polling Flowable for its Beoordelen task"
|
||||||
|
task_id3=""; pid3=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
|
||||||
|
read -r task_id3 pid3 <<<"$(printf '%s' "$resp" | task_and_pid_for_reg "$reg_id3")"
|
||||||
|
[ -n "$task_id3" ] && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$task_id3" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id3" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
|
echo ">> Beoordelen task $task_id3 (instance $pid3) is waiting for the behandelaar"
|
||||||
|
|
||||||
|
echo ">> asserting the task starts out claimable by the behandelaar group"
|
||||||
|
before="$(flcurl "$fl_base/runtime/tasks/$task_id3/identitylinks" | candidate_groups)"
|
||||||
|
[ "$before" = "behandelaar" ] || { echo "FAIL — expected candidate group 'behandelaar', got '$before'" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo ">> firing the 14-day boundary timer early via the management API"
|
||||||
|
timer_id="$(flcurl "$fl_base/management/timer-jobs?processInstanceId=$pid3" | first_job_id)"
|
||||||
|
[ -n "$timer_id" ] || { echo "FAIL — no timer job found for instance $pid3" >&2; exit 1; }
|
||||||
|
# Move the timer job to an executable async job. Flowable's async executor (running in flowable-rest)
|
||||||
|
# then picks it up and fires the non-interrupting boundary event. It may run the job before we can
|
||||||
|
# look, so executing it explicitly is a best-effort nudge — tolerate the job already being gone.
|
||||||
|
flcurl -X POST "$fl_base/management/timer-jobs/$timer_id" -H 'Content-Type: application/json' -d '{"action":"move"}' >/dev/null
|
||||||
|
async_id="$(flcurl "$fl_base/management/jobs?processInstanceId=$pid3" 2>/dev/null | first_job_id || true)"
|
||||||
|
if [ -n "$async_id" ]; then
|
||||||
|
flcurl -X POST "$fl_base/management/jobs/$async_id" -H 'Content-Type: application/json' -d '{"action":"execute"}' >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
echo ">> timer fired; the BeoordelingEscaleren token is parked for the domain worker"
|
||||||
|
|
||||||
|
echo ">> polling until the escalation worker reassigns the beoordeling to the teamlead"
|
||||||
|
escalated=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
groups="$(flcurl "$fl_base/runtime/tasks/$task_id3/identitylinks" 2>/dev/null | candidate_groups || true)"
|
||||||
|
[ "$groups" = "teamlead" ] && { escalated=1; break; }
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$escalated" ] || { echo "FAIL — Beoordelen task not reassigned to teamlead (candidate groups: '$groups')" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
|
echo "OK — the 14-day timer escalated the still-open Beoordelen task to the teamlead"
|
||||||
|
|
||||||
|
# ── S-10a: document timeout. A registration parks at WachtOpDocumenten and — unlike every block above —
|
||||||
|
# its documents never arrive. We fire its 30-day boundary timer early via the management API; the
|
||||||
|
# INTERRUPTING timer cancels the wait and routes a token to the RegistratieVerlopen external task. The
|
||||||
|
# domain's timeout worker acquires it and expires the registration to VERLOPEN (ADR-0017). ────────────
|
||||||
|
echo ">> submitting a registration to let its document term lapse"
|
||||||
|
locv="$(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"}' \
|
||||||
|
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
|
||||||
|
[ -n "$locv" ] || { echo "FAIL — timeout POST /registrations returned no Location" >&2; exit 1; }
|
||||||
|
reg_idv="${locv##*/}"
|
||||||
|
echo ">> timeout registration $reg_idv"
|
||||||
|
|
||||||
|
echo ">> polling Flowable for its WachtOpDocumenten task"
|
||||||
|
wacht_id=""; pidv=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$wacht_query" 2>/dev/null || true)"
|
||||||
|
read -r wacht_id pidv <<<"$(printf '%s' "$resp" | task_and_pid_for_reg "$reg_idv")"
|
||||||
|
[ -n "$wacht_id" ] && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$wacht_id" ] || { echo "FAIL — no WachtOpDocumenten task appeared for $reg_idv" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
|
echo ">> WachtOpDocumenten task $wacht_id (instance $pidv) is waiting for documents"
|
||||||
|
|
||||||
|
echo ">> firing the 30-day document timer early via the management API"
|
||||||
|
timer_idv="$(flcurl "$fl_base/management/timer-jobs?processInstanceId=$pidv" | first_job_id)"
|
||||||
|
[ -n "$timer_idv" ] || { echo "FAIL — no timer job found for instance $pidv" >&2; exit 1; }
|
||||||
|
# Move the timer job to an executable async job; the async executor fires the interrupting boundary
|
||||||
|
# event. It may run before we look, so executing it explicitly is a best-effort nudge (as for S-14).
|
||||||
|
flcurl -X POST "$fl_base/management/timer-jobs/$timer_idv" -H 'Content-Type: application/json' -d '{"action":"move"}' >/dev/null
|
||||||
|
async_idv="$(flcurl "$fl_base/management/jobs?processInstanceId=$pidv" 2>/dev/null | first_job_id || true)"
|
||||||
|
if [ -n "$async_idv" ]; then
|
||||||
|
flcurl -X POST "$fl_base/management/jobs/$async_idv" -H 'Content-Type: application/json' -d '{"action":"execute"}' >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
echo ">> timer fired; the RegistratieVerlopen token is parked for the domain worker"
|
||||||
|
|
||||||
|
echo ">> polling the domain until the timeout worker expires the registration to VERLOPEN"
|
||||||
|
verlopen=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
body="$(docker run --rm --network "$net" curlimages/curl:latest -fsS "http://$dom_ip:8080$locv" 2>/dev/null || true)"
|
||||||
|
printf '%s' "$body" | grep -qi 'verlopen' && { verlopen=1; break; }
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$verlopen" ] || { echo "FAIL — registration $reg_idv not VERLOPEN after the document timer fired (body: $body)" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
|
echo "OK — the 30-day document timer expired the registration to VERLOPEN"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
+10
-1
@@ -35,12 +35,21 @@ populate() { # volume source(file or dir/.)
|
|||||||
|
|
||||||
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl> ..." >&2; exit 2; }
|
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl> ..." >&2; exit 2; }
|
||||||
|
|
||||||
|
# The registratie process (BPMN) and its diploma-eligibility DMN are deployed as SEPARATE Flowable
|
||||||
|
# deployments — the process engine and the DMN engine each own theirs (S-13, ADR-0016). flowable-rest
|
||||||
|
# does not cascade a .dmn bundled in a process .bar into the DMN engine, so we seed both raw files and
|
||||||
|
# let flowable-init deploy each via its own REST app. We stage them in a temp dir and copy its contents.
|
||||||
|
stage_flowable_workflows() {
|
||||||
|
local dir="$1"
|
||||||
|
cp "$here/../workflows/registratie.bpmn" "$here/../workflows/diploma-eligibility.dmn" "$dir/"
|
||||||
|
}
|
||||||
|
|
||||||
for key in "$@"; do
|
for key in "$@"; do
|
||||||
case "$key" in
|
case "$key" in
|
||||||
oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;;
|
oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;;
|
||||||
nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;;
|
nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;;
|
||||||
kc) populate rr-kc-realms "$here/keycloak/realms/." ;;
|
kc) populate rr-kc-realms "$here/keycloak/realms/." ;;
|
||||||
fl) populate rr-fl-bpmn "$here/../workflows/registratie.bpmn" ;;
|
fl) d="$(mktemp -d)"; stage_flowable_workflows "$d"; populate rr-fl-bpmn "$d/." ;;
|
||||||
*) echo "unknown seed key: $key" >&2; exit 2 ;;
|
*) echo "unknown seed key: $key" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -226,6 +226,40 @@ export class BffApiV1Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
|
||||||
|
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||||
|
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||||
|
postSelfServiceRegistrationsIdDocuments<TData = void>(
|
||||||
|
id: string, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||||
|
if (options?.observe === 'events') {
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/self-service/registrations/${id}/documents`,
|
||||||
|
undefined,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'events',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.observe === 'response') {
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/self-service/registrations/${id}/documents`,
|
||||||
|
undefined,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'response',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/self-service/registrations/${id}/documents`,
|
||||||
|
undefined,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'body',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable<TData>;
|
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable<TData>;
|
||||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ public interface IDomainClient
|
|||||||
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary>
|
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary>
|
||||||
Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default);
|
Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Provide the documents the caller's own registration is waiting for ("documenten
|
||||||
|
/// aanleveren"). Owner-scoped by <paramref name="bsn"/>. Returns <c>false</c> when the domain
|
||||||
|
/// reports the registration is unknown or not the caller's (404), so the BFF can relay a 404.</summary>
|
||||||
|
Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
|
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
|
||||||
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
@@ -63,6 +68,17 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var response = await http.PostAsJsonAsync(
|
||||||
|
$"registrations/{registrationId}/documents", new { bsn }, ct);
|
||||||
|
// The domain 404s an unknown or not-owned registration; relay that rather than fail hard.
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
return false;
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||||
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
|
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,26 @@ app.MapPost("/self-service/registrations/{id}/withdraw", async (string id, Claim
|
|||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
// Self-service provide-documents (S-10a): the signed-in zorgprofessional supplies the documents their
|
||||||
|
// registration is waiting for ("documenten aanleveren"). The bsn comes from the DigiD token and is
|
||||||
|
// forwarded to the domain, which owner-scopes the action and completes the WachtOpDocumenten task; a
|
||||||
|
// registration that is unknown or not the caller's comes back 404. The real file upload + ZGW storage
|
||||||
|
// is S-10b — this is the trigger that unblocks the process.
|
||||||
|
app.MapPost("/self-service/registrations/{id}/documents", async (string id, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var bsn = user.FindFirstValue("bsn");
|
||||||
|
if (string.IsNullOrWhiteSpace(bsn))
|
||||||
|
return Results.BadRequest("The token carries no bsn claim.");
|
||||||
|
|
||||||
|
var provided = await domain.ProvideDocumentsAsync(id, bsn, ct);
|
||||||
|
return provided ? Results.NoContent() : Results.NotFound();
|
||||||
|
})
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
|
.Produces(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
|
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
|
||||||
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
|
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -94,6 +94,18 @@ internal sealed class FakeDomainClient : IDomainClient
|
|||||||
return Task.FromResult(WithdrawSucceeds);
|
return Task.FromResult(WithdrawSucceeds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public (string RegistrationId, string Bsn)? DocumentsProvidedFor { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Whether the fake domain reports the provide-documents as done (true → 204) or
|
||||||
|
/// not-found/not-owned (false → 404). Tests set this to exercise the relay.</summary>
|
||||||
|
public bool ProvideDocumentsSucceeds { get; set; } = true;
|
||||||
|
|
||||||
|
public Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
DocumentsProvidedFor = (registrationId, bsn);
|
||||||
|
return Task.FromResult(ProvideDocumentsSucceeds);
|
||||||
|
}
|
||||||
|
|
||||||
public (string RegistrationId, string Besluit)? Decided { get; private set; }
|
public (string RegistrationId, string Besluit)? Decided { get; private set; }
|
||||||
|
|
||||||
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||||
|
|||||||
@@ -112,5 +112,46 @@ public class SelfServiceEndpointTests
|
|||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static HttpRequestMessage ProvideDocuments(string? bearer, string id = "reg-123")
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, $"/self-service/registrations/{id}/documents");
|
||||||
|
if (bearer is not null)
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rejects_providing_documents_without_a_token()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
|
||||||
|
var response = await factory.CreateClient().SendAsync(ProvideDocuments(bearer: null));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
Assert.Null(factory.Domain.DocumentsProvidedFor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Provides_documents_for_the_callers_registration_forwarding_the_id_and_bsn()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
|
||||||
|
var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782"), "reg-9"));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||||
|
Assert.Equal(("reg-9", "123456782"), factory.Domain.DocumentsProvidedFor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Relays_not_found_providing_documents_for_an_unknown_or_not_owned_registration()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
factory.Domain.ProvideDocumentsSucceeds = false;
|
||||||
|
|
||||||
|
var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782")));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
|
private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/self-service/registrations/{id}/documents": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Bff.Api"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No Content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/openbaar/register": {
|
"/openbaar/register": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -21,18 +21,30 @@ builder.Services.AddHttpClient<FlowableWorkflowClient>();
|
|||||||
builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||||
builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||||
builder.Services.AddTransient<IUserTaskClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
builder.Services.AddTransient<IUserTaskClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||||
|
builder.Services.AddTransient<IBeoordelingEscalatieClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||||
|
builder.Services.AddTransient<IRegistratieVerlopenClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||||
builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
|
builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
|
||||||
|
|
||||||
builder.Services.AddScoped<SubmitRegistration>();
|
builder.Services.AddScoped<SubmitRegistration>();
|
||||||
builder.Services.AddScoped<ApproveRegistration>();
|
builder.Services.AddScoped<ApproveRegistration>();
|
||||||
builder.Services.AddScoped<BeoordeelRegistratie>();
|
builder.Services.AddScoped<BeoordeelRegistratie>();
|
||||||
builder.Services.AddScoped<WithdrawRegistration>();
|
builder.Services.AddScoped<WithdrawRegistration>();
|
||||||
|
builder.Services.AddScoped<ProvideDocuments>();
|
||||||
builder.Services.AddScoped<Werkbak>();
|
builder.Services.AddScoped<Werkbak>();
|
||||||
builder.Services.AddScoped<OpenZaakWorker>();
|
builder.Services.AddScoped<OpenZaakWorker>();
|
||||||
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
||||||
|
builder.Services.AddScoped<BeoordelingEscalatieProcessor>();
|
||||||
|
builder.Services.AddScoped<ExpireRegistrationWorker>();
|
||||||
|
builder.Services.AddScoped<RegistratieVerlopenProcessor>();
|
||||||
|
|
||||||
// The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion.
|
// The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion.
|
||||||
builder.Services.AddHostedService<OpenZaakJobPump>();
|
builder.Services.AddHostedService<OpenZaakJobPump>();
|
||||||
|
// The escalation worker polls the BeoordelingEscaleren jobs the 14-day timer parks and reassigns
|
||||||
|
// each overdue beoordeling to the teamlead (S-14).
|
||||||
|
builder.Services.AddHostedService<BeoordelingEscalatiePump>();
|
||||||
|
// The document-timeout worker polls the RegistratieVerlopen jobs the 30-day timer on WachtOpDocumenten
|
||||||
|
// parks and expires each lapsed registration to VERLOPEN (S-10a, ADR-0017).
|
||||||
|
builder.Services.AddHostedService<RegistratieVerlopenPump>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -43,7 +55,12 @@ app.MapGet("/health", () => "Healthy");
|
|||||||
// a location to read the registration's progress (ADR-0009, eventual consistency).
|
// a location to read the registration's progress (ADR-0009, eventual consistency).
|
||||||
app.MapPost("/registrations", async (SubmitRegistrationRequest body, SubmitRegistration submit, CancellationToken ct) =>
|
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<DiplomaOrigin>(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));
|
return Results.Accepted($"/registrations/{id}", new RegistrationResponse(id.ToString(), RegistrationStatus.Ingediend.ToString(), null));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,6 +108,23 @@ app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRequest bo
|
|||||||
return outcome == WithdrawOutcome.Withdrawn ? Results.NoContent() : Results.NotFound();
|
return outcome == WithdrawOutcome.Withdrawn ? Results.NoContent() : Results.NotFound();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Provide documents (S-10a): the zorgprofessional supplies the documents their registration is parked
|
||||||
|
// waiting for, completing the WachtOpDocumenten task so the process advances to beoordeling (ADR-0017).
|
||||||
|
// Owner-scoped by the caller's bsn (the BFF forwards it from the DigiD token); unknown or not-the-
|
||||||
|
// caller's is 404 (indistinguishable). Idempotent — completing an already-left wait is a no-op. The
|
||||||
|
// real file upload + ZGW storage is S-10b; this endpoint is the trigger that unblocks the process.
|
||||||
|
app.MapPost("/registrations/{id}/documents", async (string id, ProvideDocumentsRequest body, ProvideDocuments provide, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (!Guid.TryParse(id, out var guid))
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(body?.Bsn))
|
||||||
|
return Results.BadRequest(new { error = "A bsn is required to provide documents." });
|
||||||
|
|
||||||
|
var outcome = await provide.HandleAsync(new ProvideDocumentsCommand(new RegistrationId(guid), body.Bsn), ct);
|
||||||
|
return outcome == ProvideDocumentsOutcome.Accepted ? Results.NoContent() : Results.NotFound();
|
||||||
|
});
|
||||||
|
|
||||||
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
|
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
|
||||||
// Beoordelen user tasks (§8.2) and enriched with bsn + status. The BFF proxies this behind
|
// Beoordelen user tasks (§8.2) and enriched with bsn + status. The BFF proxies this behind
|
||||||
// medewerker-realm + behandelaar-role authorization; the domain trusts its callers (§8.3).
|
// medewerker-realm + behandelaar-role authorization; the domain trusts its callers (§8.3).
|
||||||
@@ -112,12 +146,14 @@ app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, Ca
|
|||||||
|
|
||||||
await app.RunAsync();
|
await app.RunAsync();
|
||||||
|
|
||||||
public sealed record SubmitRegistrationRequest(string Bsn);
|
public sealed record SubmitRegistrationRequest(string Bsn, string? DiplomaOrigin = null);
|
||||||
|
|
||||||
public sealed record DecideRequest(string Besluit);
|
public sealed record DecideRequest(string Besluit);
|
||||||
|
|
||||||
public sealed record WithdrawRequest(string Bsn);
|
public sealed record WithdrawRequest(string Bsn);
|
||||||
|
|
||||||
|
public sealed record ProvideDocumentsRequest(string Bsn);
|
||||||
|
|
||||||
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
|
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Big.Domain;
|
||||||
|
|
||||||
|
namespace Big.Application;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles one acquired <c>RegistratieVerlopen</c> external-worker job (S-10a, ADR-0017): load the
|
||||||
|
/// registration the job correlates to and expire it to VERLOPEN — the 30-day document-wait timer fired
|
||||||
|
/// before the documents arrived, so the case is cancelled. Pure application logic over ports; it knows
|
||||||
|
/// nothing of Flowable. The polling loop that feeds it jobs lives in Infrastructure. Mirrors
|
||||||
|
/// <see cref="OpenZaakWorker"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExpireRegistrationWorker(IRegistrationStore store)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Process the job. Idempotent and tolerant of races (§8.6, at-least-once delivery): a job whose
|
||||||
|
/// registration is already resolved — a redelivered expiry (VERLOPEN), or one withdrawn/decided
|
||||||
|
/// while it waited (INGETROKKEN/INGESCHREVEN/AFGEWEZEN) — is a no-op, so the job still completes
|
||||||
|
/// rather than throwing into a redelivery loop. Only a still-open registration is expired. An
|
||||||
|
/// unknown registration is an error: it throws, leaving the job un-completed for Flowable to redeliver.
|
||||||
|
/// </summary>
|
||||||
|
public async Task HandleAsync(RegistratieVerlopenJob job, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(job);
|
||||||
|
|
||||||
|
var registration = await store.GetAsync(job.RegistrationId, ct)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"No registration {job.RegistrationId} for RegistratieVerlopen job {job.JobId}.");
|
||||||
|
|
||||||
|
// Only a still-open registration lapses; an already-resolved one (expired, or withdrawn/decided
|
||||||
|
// while it waited) is left untouched so the job can complete without violating the aggregate.
|
||||||
|
if (registration.Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
|
||||||
|
return;
|
||||||
|
|
||||||
|
registration.Expire();
|
||||||
|
await store.SaveAsync(registration, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,10 +11,12 @@ public interface IWorkflowClient
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Start one <c>registratie</c> process instance for the given registration, carrying the
|
/// Start one <c>registratie</c> process instance for the given registration, carrying the
|
||||||
/// registration id so the <c>OpenZaakAanmaken</c> external task can be correlated back to its
|
/// registration id (so the <c>OpenZaakAanmaken</c> external task can be correlated back to its
|
||||||
/// aggregate. Returns the process instance id.
|
/// aggregate) and the diploma origin (so the workflow's DMN can route foreign diplomas through
|
||||||
|
/// CBGV-advies, S-13). Returns the process instance id.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default);
|
Task<string> StartRegistrationProcessAsync(
|
||||||
|
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cancel a running <c>registratie</c> process on withdrawal (S-11): correlate the
|
/// Cancel a running <c>registratie</c> process on withdrawal (S-11): correlate the
|
||||||
@@ -23,6 +25,14 @@ public interface IWorkflowClient
|
|||||||
/// ended, or not yet parked) it is a no-op; the aggregate is INGETROKKEN regardless.
|
/// ended, or not yet parked) it is a no-op; the aggregate is INGETROKKEN regardless.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default);
|
Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signal that the required documents have arrived (S-10a): complete the <c>WachtOpDocumenten</c>
|
||||||
|
/// user task in the instance so the process leaves the 30-day wait state and continues to
|
||||||
|
/// beoordeling (ADR-0017). Best-effort — if the instance is not parked at that task (already
|
||||||
|
/// continued, or timed out) it is a no-op. The upload trigger that calls this is wired in S-10b.
|
||||||
|
/// </summary>
|
||||||
|
Task CompleteDocumentWaitAsync(string processInstanceId, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -86,3 +96,18 @@ public interface IRegistrationStore
|
|||||||
/// it) and the registration id it carries as a process variable.
|
/// it) and the registration id it carries as a process variable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId);
|
public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An acquired <c>BeoordelingEscaleren</c> escalation job (S-14): the Flowable job id and the process
|
||||||
|
/// instance whose still-open <c>Beoordelen</c> task must be reassigned from behandelaar to teamlead
|
||||||
|
/// once the 14-day boundary timer fires (ADR-0015).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record EscalatieJob(string JobId, string ProcessInstanceId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An acquired <c>RegistratieVerlopen</c> job (S-10a): the Flowable job id and the registration id it
|
||||||
|
/// carries as a process variable. The 30-day boundary timer on <c>WachtOpDocumenten</c> spawns it when
|
||||||
|
/// the required documents were not supplied in time; expiring the correlated registration to VERLOPEN
|
||||||
|
/// cancels the case (ADR-0017).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RegistratieVerlopenJob(string JobId, RegistrationId RegistrationId);
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using Big.Domain;
|
||||||
|
|
||||||
|
namespace Big.Application;
|
||||||
|
|
||||||
|
/// <summary>A zorgprofessional's signal that they have supplied the documents their registration is
|
||||||
|
/// waiting for ("documenten aanleveren"). <paramref name="Bsn"/> is the authenticated caller (from the
|
||||||
|
/// DigiD token, forwarded by the BFF): only the registration's own bsn may provide its documents.</summary>
|
||||||
|
public sealed record ProvideDocumentsCommand(RegistrationId RegistrationId, string Bsn);
|
||||||
|
|
||||||
|
/// <summary>The outcome of a provide-documents request.</summary>
|
||||||
|
public enum ProvideDocumentsOutcome
|
||||||
|
{
|
||||||
|
/// <summary>The documents were accepted; the process's document wait was completed (if any).</summary>
|
||||||
|
Accepted,
|
||||||
|
|
||||||
|
/// <summary>No registration with that id belongs to the caller — unknown, or owned by someone else
|
||||||
|
/// (the two are deliberately indistinguishable, so the endpoint reveals neither).</summary>
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The provide-documents use case (S-10a): a zorgprofessional supplies the documents their registration
|
||||||
|
/// is parked waiting for, completing the WachtOpDocumenten task so the registratie process leaves the
|
||||||
|
/// 30-day wait and continues to beoordeling (ADR-0017). Owner-scoped by bsn. Completing the wait is
|
||||||
|
/// best-effort: if the registration never started a process (or already left the wait), the request
|
||||||
|
/// still stands, mirroring how <see cref="WithdrawRegistration"/> cancels best-effort. The actual file
|
||||||
|
/// upload and its ZGW storage via the ACL is S-10b; this is the trigger that unblocks the process.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProvideDocuments(IRegistrationStore store, IWorkflowClient workflow)
|
||||||
|
{
|
||||||
|
public async Task<ProvideDocumentsOutcome> HandleAsync(ProvideDocumentsCommand command, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
|
|
||||||
|
var registration = await store.GetAsync(command.RegistrationId, ct);
|
||||||
|
|
||||||
|
// Unknown, or not the caller's registration: report NotFound either way (don't reveal which).
|
||||||
|
if (registration is null || registration.Bsn != command.Bsn)
|
||||||
|
return ProvideDocumentsOutcome.NotFound;
|
||||||
|
|
||||||
|
// Complete the document wait (if a process is running) so beoordeling can proceed.
|
||||||
|
if (registration.ProcessInstanceId is not null)
|
||||||
|
await workflow.CompleteDocumentWaitAsync(registration.ProcessInstanceId, ct);
|
||||||
|
|
||||||
|
return ProvideDocumentsOutcome.Accepted;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ using Big.Domain;
|
|||||||
|
|
||||||
namespace Big.Application;
|
namespace Big.Application;
|
||||||
|
|
||||||
/// <summary>A zorgprofessional's request to register, in domain language. No ZGW concepts.</summary>
|
/// <summary>A zorgprofessional's request to register, in domain language. No ZGW concepts. The
|
||||||
public sealed record SubmitRegistrationCommand(string Bsn);
|
/// diploma origin defaults to domestic (the DigiD path); a foreign (eIDAS) submission sets it to
|
||||||
|
/// <see cref="DiplomaOrigin.Buitenlands"/> so the workflow's DMN routes it through CBGV-advies (S-13).</summary>
|
||||||
|
public sealed record SubmitRegistrationCommand(string Bsn, DiplomaOrigin DiplomaOrigin = DiplomaOrigin.Binnenlands);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The submit use case: create the <see cref="Registration"/> aggregate (INGEDIEND), persist it,
|
/// The submit use case: create the <see cref="Registration"/> aggregate (INGEDIEND), persist it,
|
||||||
@@ -18,13 +20,14 @@ public sealed class SubmitRegistration(IRegistrationStore store, IWorkflowClient
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(command);
|
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
|
// Persist before starting the process so the worker can correlate the OpenZaakAanmaken
|
||||||
// job back to an aggregate that already exists (ADR-0009).
|
// job back to an aggregate that already exists (ADR-0009).
|
||||||
await store.SaveAsync(registration, ct);
|
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);
|
registration.RecordProcessStarted(processInstanceId);
|
||||||
await store.SaveAsync(registration, ct);
|
await store.SaveAsync(registration, ct);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace Big.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where a zorgprofessional's diploma was issued. It is the input to the diploma-eligibility decision
|
||||||
|
/// (S-13): a <see cref="Buitenlands"/> (foreign) diploma routes the registratie through an extra
|
||||||
|
/// CBGV-advies assessment step, a <see cref="Binnenlands"/> (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.
|
||||||
|
/// </summary>
|
||||||
|
public enum DiplomaOrigin
|
||||||
|
{
|
||||||
|
/// <summary>A Dutch (domestic) diploma. Default for a registration submitted via DigiD.</summary>
|
||||||
|
Binnenlands,
|
||||||
|
|
||||||
|
/// <summary>A foreign diploma (e.g. an eIDAS submission). Triggers the CBGV-advies step.</summary>
|
||||||
|
Buitenlands,
|
||||||
|
}
|
||||||
@@ -7,10 +7,11 @@ namespace Big.Domain;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class Registration
|
public sealed class Registration
|
||||||
{
|
{
|
||||||
private Registration(RegistrationId id, string bsn)
|
private Registration(RegistrationId id, string bsn, DiplomaOrigin diplomaOrigin)
|
||||||
{
|
{
|
||||||
Id = id;
|
Id = id;
|
||||||
Bsn = bsn;
|
Bsn = bsn;
|
||||||
|
DiplomaOrigin = diplomaOrigin;
|
||||||
Status = RegistrationStatus.Ingediend;
|
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).</summary>
|
/// as the domain payload; the domain never constructs ZGW concepts from it (§8.1).</summary>
|
||||||
public string Bsn { get; }
|
public string Bsn { get; }
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
public DiplomaOrigin DiplomaOrigin { get; }
|
||||||
|
|
||||||
public RegistrationStatus Status { get; private set; }
|
public RegistrationStatus Status { get; private set; }
|
||||||
|
|
||||||
/// <summary>The Flowable process instance driving this registration, once started.</summary>
|
/// <summary>The Flowable process instance driving this registration, once started.</summary>
|
||||||
@@ -28,11 +33,13 @@ public sealed class Registration
|
|||||||
/// <summary>The zaak the ACL opened for this registration, once the external task has run.</summary>
|
/// <summary>The zaak the ACL opened for this registration, once the external task has run.</summary>
|
||||||
public Uri? ZaakUrl { get; private set; }
|
public Uri? ZaakUrl { get; private set; }
|
||||||
|
|
||||||
/// <summary>Submit a new registration. It begins in <see cref="RegistrationStatus.Ingediend"/>.</summary>
|
/// <summary>Submit a new registration. It begins in <see cref="RegistrationStatus.Ingediend"/>.
|
||||||
public static Registration Submit(string bsn)
|
/// The diploma origin defaults to <see cref="DiplomaOrigin.Binnenlands"/> — the common DigiD path;
|
||||||
|
/// a foreign (eIDAS) submission passes <see cref="DiplomaOrigin.Buitenlands"/>.</summary>
|
||||||
|
public static Registration Submit(string bsn, DiplomaOrigin diplomaOrigin = DiplomaOrigin.Binnenlands)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(bsn);
|
ArgumentException.ThrowIfNullOrWhiteSpace(bsn);
|
||||||
return new Registration(RegistrationId.New(), bsn);
|
return new Registration(RegistrationId.New(), bsn, diplomaOrigin);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Record that the registratie workflow process has been started for this registration.</summary>
|
/// <summary>Record that the registratie workflow process has been started for this registration.</summary>
|
||||||
@@ -126,8 +133,24 @@ public sealed class Registration
|
|||||||
Status = RegistrationStatus.Ingetrokken;
|
Status = RegistrationStatus.Ingetrokken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A decision (or withdrawal) is only valid while the registration is still open (INGEDIEND or
|
/// <summary>
|
||||||
// IN_BEHANDELING).
|
/// Expire the registration — the 30-day document-wait timer fired before the required documents
|
||||||
|
/// were supplied, so the registratie process cancels the case (S-10a). Allowed while it is still
|
||||||
|
/// open (INGEDIEND or IN_BEHANDELING) and needs no zaak; a decided (INGESCHREVEN/AFGEWEZEN) or
|
||||||
|
/// withdrawn (INGETROKKEN) registration can no longer expire. Re-expiring one already
|
||||||
|
/// <see cref="RegistrationStatus.Verlopen"/> is a no-op — the worker job may be redelivered (§8.6).
|
||||||
|
/// </summary>
|
||||||
|
public void Expire()
|
||||||
|
{
|
||||||
|
if (Status == RegistrationStatus.Verlopen)
|
||||||
|
return;
|
||||||
|
|
||||||
|
RequireOpenForDecision(nameof(Expire));
|
||||||
|
Status = RegistrationStatus.Verlopen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A decision (or withdrawal, or expiry) is only valid while the registration is still open
|
||||||
|
// (INGEDIEND or IN_BEHANDELING).
|
||||||
private void RequireOpenForDecision(string decision)
|
private void RequireOpenForDecision(string decision)
|
||||||
{
|
{
|
||||||
if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
|
if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
|
||||||
|
|||||||
@@ -21,4 +21,8 @@ public enum RegistrationStatus
|
|||||||
|
|
||||||
/// <summary>Withdrawn by the zorgprofessional before a decision (S-11). Terminal.</summary>
|
/// <summary>Withdrawn by the zorgprofessional before a decision (S-11). Terminal.</summary>
|
||||||
Ingetrokken,
|
Ingetrokken,
|
||||||
|
|
||||||
|
/// <summary>Lapsed: the required documents were not supplied within the 30-day window, so the
|
||||||
|
/// registratie process cancelled the case (S-10a). Terminal.</summary>
|
||||||
|
Verlopen,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Big.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One poll tick of the beoordeling-escalation worker (S-14, ADR-0015): acquire the parked
|
||||||
|
/// <c>BeoordelingEscaleren</c> jobs — the tokens the 14-day boundary timer on <c>Beoordelen</c> spawns
|
||||||
|
/// — reassign each instance's still-open <c>Beoordelen</c> task to the teamlead, and complete the job.
|
||||||
|
/// A job that fails is logged and left un-completed so Flowable redelivers it (§8.6). Split out from
|
||||||
|
/// the hosted pump so the acquire→reassign→complete logic is unit-testable without a running host.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BeoordelingEscalatieProcessor(
|
||||||
|
IBeoordelingEscalatieClient client,
|
||||||
|
ILogger<BeoordelingEscalatieProcessor> logger)
|
||||||
|
{
|
||||||
|
/// <summary>Acquire and process up to <paramref name="maxJobs"/> escalations. Returns the number acquired.</summary>
|
||||||
|
public async Task<int> PumpOnceAsync(int maxJobs, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var jobs = await client.AcquireBeoordelingEscalatieJobsAsync(maxJobs, ct);
|
||||||
|
|
||||||
|
foreach (var job in jobs)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.ReassignBeoordelingToTeamleadAsync(job.ProcessInstanceId, ct);
|
||||||
|
await client.CompleteBeoordelingEscalatieJobAsync(job.JobId, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Leave the job un-completed: its lock expires and Flowable redelivers it (§8.6).
|
||||||
|
logger.LogError(ex, "BeoordelingEscaleren job {JobId} failed; leaving it for redelivery.", job.JobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobs.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Big.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The hosted polling loop of the beoordeling-escalation worker (S-14, ADR-0015): on an interval it
|
||||||
|
/// resolves a scoped <see cref="BeoordelingEscalatieProcessor"/> and asks it to drain the parked
|
||||||
|
/// <c>BeoordelingEscaleren</c> jobs. A deliberately thin shell — all acquire/reassign/complete logic
|
||||||
|
/// lives in the processor, which is unit-tested; this class only owns the timer, the per-tick scope,
|
||||||
|
/// and loop resilience. Structurally identical to <see cref="OpenZaakJobPump"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BeoordelingEscalatiePump(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
FlowableOptions options,
|
||||||
|
ILogger<BeoordelingEscalatiePump> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = scopeFactory.CreateScope();
|
||||||
|
var processor = scope.ServiceProvider.GetRequiredService<BeoordelingEscalatieProcessor>();
|
||||||
|
await processor.PumpOnceAsync(options.MaxJobsPerPoll, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A transient fault (e.g. Flowable briefly unreachable) must not kill the loop.
|
||||||
|
logger.LogError(ex, "BeoordelingEscaleren job poll failed; retrying after the poll interval.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(options.PollInterval, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,21 +15,31 @@ namespace Big.Infrastructure;
|
|||||||
/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009).
|
/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options)
|
public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options)
|
||||||
: IWorkflowClient, IExternalWorkerClient, IUserTaskClient
|
: IWorkflowClient, IExternalWorkerClient, IUserTaskClient, IBeoordelingEscalatieClient, IRegistratieVerlopenClient
|
||||||
{
|
{
|
||||||
private const string Topic = "OpenZaakAanmaken";
|
private const string Topic = "OpenZaakAanmaken";
|
||||||
|
private const string EscalatieTopic = "BeoordelingEscaleren";
|
||||||
|
private const string VerlopenTopic = "RegistratieVerlopen";
|
||||||
private const string ProcessDefinitionKey = "registratie";
|
private const string ProcessDefinitionKey = "registratie";
|
||||||
private const string BeoordelenTaskKey = "Beoordelen";
|
private const string BeoordelenTaskKey = "Beoordelen";
|
||||||
|
private const string WachtOpDocumentenTaskKey = "WachtOpDocumenten";
|
||||||
|
private const string BehandelaarGroup = "behandelaar";
|
||||||
|
private const string TeamleadGroup = "teamlead";
|
||||||
private const string RegistrationIdVariable = "registrationId";
|
private const string RegistrationIdVariable = "registrationId";
|
||||||
|
private const string DiplomaOriginVariable = "diplomaOrigin";
|
||||||
private const string ZaakUrlVariable = "zaakUrl";
|
private const string ZaakUrlVariable = "zaakUrl";
|
||||||
private const string BesluitVariable = "besluit";
|
private const string BesluitVariable = "besluit";
|
||||||
private const string IngetrokkenMessage = "RegistratieIngetrokken";
|
private const string IngetrokkenMessage = "RegistratieIngetrokken";
|
||||||
|
|
||||||
public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
|
public async Task<string> StartRegistrationProcessAsync(
|
||||||
|
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var request = new StartProcessRequest(
|
var request = new StartProcessRequest(
|
||||||
ProcessDefinitionKey,
|
ProcessDefinitionKey,
|
||||||
[new Variable(RegistrationIdVariable, "string", registrationId.ToString())]);
|
[
|
||||||
|
new Variable(RegistrationIdVariable, "string", registrationId.ToString()),
|
||||||
|
new Variable(DiplomaOriginVariable, "string", diplomaOrigin.ToString()),
|
||||||
|
]);
|
||||||
|
|
||||||
var created = await PostAsync<StartProcessRequest, ProcessInstance>(
|
var created = await PostAsync<StartProcessRequest, ProcessInstance>(
|
||||||
"service/runtime/process-instances", request, ct)
|
"service/runtime/process-instances", request, ct)
|
||||||
@@ -106,6 +116,83 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
|||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task CompleteDocumentWaitAsync(string processInstanceId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Find the still-open WachtOpDocumenten task in this instance and complete it, so the process
|
||||||
|
// leaves the 30-day wait and continues to beoordeling (S-10a, ADR-0017). If the instance is no
|
||||||
|
// longer parked there (already continued, or the timer already cancelled it) this is a
|
||||||
|
// best-effort no-op — mirroring the withdrawal/escalation correlation (§8.6).
|
||||||
|
var query = new TaskByInstanceQueryRequest(processInstanceId, WachtOpDocumentenTaskKey);
|
||||||
|
var page = await PostAsync<TaskByInstanceQueryRequest, TaskQueryResult>(
|
||||||
|
"service/query/tasks", query, ct);
|
||||||
|
|
||||||
|
var task = page?.Data?.FirstOrDefault();
|
||||||
|
if (task is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var response = await SendAsync(
|
||||||
|
$"service/runtime/tasks/{task.Id}", new CompleteTaskRequest("complete", []), ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var request = new AcquireJobsRequest(EscalatieTopic, options.LockDuration, maxJobs, options.WorkerId);
|
||||||
|
|
||||||
|
var jobs = await PostAsync<AcquireJobsRequest, List<AcquiredEscalatieJob>>(
|
||||||
|
"external-job-api/acquire/jobs", request, ct) ?? [];
|
||||||
|
|
||||||
|
return [.. jobs.Select(job => new EscalatieJob(job.Id, job.ProcessInstanceId))];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// The escalation token runs in parallel to the still-open Beoordelen task (non-interrupting
|
||||||
|
// boundary timer); find that task in this instance so we can move it to the teamlead. If the
|
||||||
|
// behandelaar completed it just before the timer fired there is nothing to reassign — a
|
||||||
|
// best-effort no-op (the timer/completion race, cf. §8.6).
|
||||||
|
var query = new TaskByInstanceQueryRequest(processInstanceId, BeoordelenTaskKey);
|
||||||
|
var page = await PostAsync<TaskByInstanceQueryRequest, TaskQueryResult>(
|
||||||
|
"service/query/tasks", query, ct);
|
||||||
|
|
||||||
|
var task = page?.Data?.FirstOrDefault();
|
||||||
|
if (task is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Add teamlead, then drop behandelaar: the task now belongs to the teamlead group.
|
||||||
|
using (var added = await SendAsync(
|
||||||
|
$"service/runtime/tasks/{task.Id}/identitylinks",
|
||||||
|
new IdentityLinkRequest(TeamleadGroup, "candidate"), ct))
|
||||||
|
added.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
await DeleteAsync(
|
||||||
|
$"service/runtime/tasks/{task.Id}/identitylinks/groups/{BehandelaarGroup}/candidate", ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var response = await SendAsync(
|
||||||
|
$"external-job-api/acquire/jobs/{jobId}/complete", new CompleteJobRequest(options.WorkerId, []), ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<RegistratieVerlopenJob>> AcquireRegistratieVerlopenJobsAsync(int maxJobs, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var request = new AcquireJobsRequest(VerlopenTopic, options.LockDuration, maxJobs, options.WorkerId);
|
||||||
|
|
||||||
|
var jobs = await PostAsync<AcquireJobsRequest, List<AcquiredJob>>(
|
||||||
|
"external-job-api/acquire/jobs", request, ct) ?? [];
|
||||||
|
|
||||||
|
return [.. jobs.Select(job => new RegistratieVerlopenJob(job.Id, RegistrationId.Parse(job.RegistrationId())))];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CompleteRegistratieVerlopenJobAsync(string jobId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var response = await SendAsync(
|
||||||
|
$"external-job-api/acquire/jobs/{jobId}/complete", new CompleteJobRequest(options.WorkerId, []), ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<TResponse?> GetAsync<TResponse>(string path, CancellationToken ct)
|
private async Task<TResponse?> GetAsync<TResponse>(string path, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, path));
|
var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, path));
|
||||||
@@ -115,6 +202,14 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
|||||||
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
|
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync(string path, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var message = new HttpRequestMessage(HttpMethod.Delete, new Uri(options.BaseUrl, path));
|
||||||
|
message.Headers.Authorization = new AuthenticationHeaderValue("Basic", BasicCredentials());
|
||||||
|
using var response = await http.SendAsync(message, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
|
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var response = await SendAsync(path, body, ct);
|
using var response = await SendAsync(path, body, ct);
|
||||||
@@ -154,6 +249,14 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
|||||||
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey,
|
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey,
|
||||||
[property: JsonPropertyName("includeProcessVariables")] bool IncludeProcessVariables);
|
[property: JsonPropertyName("includeProcessVariables")] bool IncludeProcessVariables);
|
||||||
|
|
||||||
|
private sealed record TaskByInstanceQueryRequest(
|
||||||
|
[property: JsonPropertyName("processInstanceId")] string ProcessInstanceId,
|
||||||
|
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey);
|
||||||
|
|
||||||
|
private sealed record IdentityLinkRequest(
|
||||||
|
[property: JsonPropertyName("group")] string Group,
|
||||||
|
[property: JsonPropertyName("type")] string Type);
|
||||||
|
|
||||||
private sealed record ClaimTaskRequest(
|
private sealed record ClaimTaskRequest(
|
||||||
[property: JsonPropertyName("action")] string Action,
|
[property: JsonPropertyName("action")] string Action,
|
||||||
[property: JsonPropertyName("assignee")] string Assignee);
|
[property: JsonPropertyName("assignee")] string Assignee);
|
||||||
@@ -193,6 +296,10 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
|||||||
|
|
||||||
private sealed record ExecutionDto([property: JsonPropertyName("id")] string Id);
|
private sealed record ExecutionDto([property: JsonPropertyName("id")] string Id);
|
||||||
|
|
||||||
|
private sealed record AcquiredEscalatieJob(
|
||||||
|
[property: JsonPropertyName("id")] string Id,
|
||||||
|
[property: JsonPropertyName("processInstanceId")] string ProcessInstanceId);
|
||||||
|
|
||||||
private sealed record AcquiredJob(
|
private sealed record AcquiredJob(
|
||||||
[property: JsonPropertyName("id")] string Id,
|
[property: JsonPropertyName("id")] string Id,
|
||||||
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables)
|
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables)
|
||||||
|
|||||||
@@ -16,3 +16,39 @@ public interface IExternalWorkerClient
|
|||||||
/// <summary>Complete an acquired job, passing the opened zaak URL back into the process.</summary>
|
/// <summary>Complete an acquired job, passing the opened zaak URL back into the process.</summary>
|
||||||
Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default);
|
Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The escalation side of the Workflow Client (S-14): the <c>BeoordelingEscaleren</c> external-worker
|
||||||
|
/// jobs parked by the 14-day boundary timer on <c>Beoordelen</c>, and the reassignment they drive.
|
||||||
|
/// Kept separate from <see cref="IExternalWorkerClient"/> (interface segregation) so the OpenZaak
|
||||||
|
/// worker never sees escalation. Implemented by <see cref="FlowableWorkflowClient"/> — the only code
|
||||||
|
/// that talks to Flowable (§8.2, ADR-0015).
|
||||||
|
/// </summary>
|
||||||
|
public interface IBeoordelingEscalatieClient
|
||||||
|
{
|
||||||
|
/// <summary>Acquire and lock up to <paramref name="maxJobs"/> <c>BeoordelingEscaleren</c> jobs.</summary>
|
||||||
|
Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Reassign the still-open <c>Beoordelen</c> task in the given process instance from the
|
||||||
|
/// behandelaar group to teamlead. Best-effort no-op if the task is no longer open.</summary>
|
||||||
|
Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Complete an acquired escalation job so its token reaches the escalation end event.</summary>
|
||||||
|
Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The document-timeout side of the Workflow Client (S-10a): the <c>RegistratieVerlopen</c>
|
||||||
|
/// external-worker jobs parked by the 30-day boundary timer on <c>WachtOpDocumenten</c>. Kept separate
|
||||||
|
/// from the other worker ports (interface segregation) so neither the OpenZaak nor escalation worker
|
||||||
|
/// sees expiry. Implemented by <see cref="FlowableWorkflowClient"/> — the only code that talks to
|
||||||
|
/// Flowable (§8.2, ADR-0017).
|
||||||
|
/// </summary>
|
||||||
|
public interface IRegistratieVerlopenClient
|
||||||
|
{
|
||||||
|
/// <summary>Acquire and lock up to <paramref name="maxJobs"/> <c>RegistratieVerlopen</c> jobs.</summary>
|
||||||
|
Task<IReadOnlyList<RegistratieVerlopenJob>> AcquireRegistratieVerlopenJobsAsync(int maxJobs, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Complete an acquired expiry job so its token reaches the <c>endVerlopen</c> end event.</summary>
|
||||||
|
Task CompleteRegistratieVerlopenJobAsync(string jobId, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Big.Application;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Big.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One poll tick of the document-timeout worker (S-10a, ADR-0017): acquire the parked
|
||||||
|
/// <c>RegistratieVerlopen</c> jobs — the tokens the 30-day boundary timer on <c>WachtOpDocumenten</c>
|
||||||
|
/// spawns — expire each correlated registration via the <see cref="ExpireRegistrationWorker"/>, and
|
||||||
|
/// complete the job so its token reaches <c>endVerlopen</c>. A job that fails is logged and left
|
||||||
|
/// un-completed so Flowable redelivers it (§8.6). Split out from the hosted pump so the
|
||||||
|
/// acquire→expire→complete logic is unit-testable without a running host. Mirrors
|
||||||
|
/// <see cref="OpenZaakJobProcessor"/> and <see cref="BeoordelingEscalatieProcessor"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RegistratieVerlopenProcessor(
|
||||||
|
IRegistratieVerlopenClient client,
|
||||||
|
ExpireRegistrationWorker worker,
|
||||||
|
ILogger<RegistratieVerlopenProcessor> logger)
|
||||||
|
{
|
||||||
|
/// <summary>Acquire and process up to <paramref name="maxJobs"/> jobs. Returns the number acquired.</summary>
|
||||||
|
public async Task<int> PumpOnceAsync(int maxJobs, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var jobs = await client.AcquireRegistratieVerlopenJobsAsync(maxJobs, ct);
|
||||||
|
|
||||||
|
foreach (var job in jobs)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await worker.HandleAsync(job, ct);
|
||||||
|
await client.CompleteRegistratieVerlopenJobAsync(job.JobId, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Leave the job un-completed: its lock expires and Flowable redelivers it (§8.6).
|
||||||
|
logger.LogError(ex, "RegistratieVerlopen job {JobId} failed; leaving it for redelivery.", job.JobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobs.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Big.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The hosted polling loop of the document-timeout worker (S-10a, ADR-0017): on an interval it
|
||||||
|
/// resolves a scoped <see cref="RegistratieVerlopenProcessor"/> and asks it to drain the parked
|
||||||
|
/// <c>RegistratieVerlopen</c> jobs. A deliberately thin shell — all acquire/expire/complete logic
|
||||||
|
/// lives in the processor, which is unit-tested; this class only owns the timer, the per-tick scope,
|
||||||
|
/// and loop resilience. Structurally identical to <see cref="BeoordelingEscalatiePump"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RegistratieVerlopenPump(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
FlowableOptions options,
|
||||||
|
ILogger<RegistratieVerlopenPump> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = scopeFactory.CreateScope();
|
||||||
|
var processor = scope.ServiceProvider.GetRequiredService<RegistratieVerlopenProcessor>();
|
||||||
|
await processor.PumpOnceAsync(options.MaxJobsPerPoll, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A transient fault (e.g. Flowable briefly unreachable) must not kill the loop.
|
||||||
|
logger.LogError(ex, "RegistratieVerlopen job poll failed; retrying after the poll interval.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(options.PollInterval, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using Big.Application;
|
||||||
|
using Big.Infrastructure;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace Big.Tests;
|
||||||
|
|
||||||
|
// S-14 (#15): the escalation drain loop. Mirrors OpenZaakJobProcessor — acquire the parked
|
||||||
|
// BeoordelingEscaleren jobs, reassign each instance's Beoordelen task to teamlead, then complete the
|
||||||
|
// job. A job whose reassignment fails is logged and left un-completed for Flowable to redeliver (§8.6).
|
||||||
|
public class BeoordelingEscalatieProcessorTests
|
||||||
|
{
|
||||||
|
/// <summary>A fake escalation client: scripts the jobs to acquire, records reassignments and
|
||||||
|
/// completions, and can be told to throw on reassigning a given instance.</summary>
|
||||||
|
private sealed class FakeEscalatieClient(params EscalatieJob[] jobs) : IBeoordelingEscalatieClient
|
||||||
|
{
|
||||||
|
public int AcquireCount { get; private set; }
|
||||||
|
public List<string> Reassigned { get; } = [];
|
||||||
|
public List<string> Completed { get; } = [];
|
||||||
|
public string? ThrowOnInstance { get; set; }
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
AcquireCount++;
|
||||||
|
return Task.FromResult<IReadOnlyList<EscalatieJob>>(jobs.Take(maxJobs).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (processInstanceId == ThrowOnInstance)
|
||||||
|
throw new InvalidOperationException("reassign failed");
|
||||||
|
Reassigned.Add(processInstanceId);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Completed.Add(jobId);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acquires_an_escalation_reassigns_to_teamlead_and_completes_the_job()
|
||||||
|
{
|
||||||
|
var client = new FakeEscalatieClient(new EscalatieJob("job-9", "pi-1"));
|
||||||
|
|
||||||
|
var acquired = await new BeoordelingEscalatieProcessor(
|
||||||
|
client, NullLogger<BeoordelingEscalatieProcessor>.Instance).PumpOnceAsync(5);
|
||||||
|
|
||||||
|
Assert.Equal(1, acquired);
|
||||||
|
Assert.Equal("pi-1", Assert.Single(client.Reassigned));
|
||||||
|
Assert.Equal("job-9", Assert.Single(client.Completed));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_failing_reassign_is_left_uncompleted_for_flowable_to_redeliver()
|
||||||
|
{
|
||||||
|
var client = new FakeEscalatieClient(new EscalatieJob("job-9", "pi-1")) { ThrowOnInstance = "pi-1" };
|
||||||
|
var logger = new CapturingLogger<BeoordelingEscalatieProcessor>();
|
||||||
|
|
||||||
|
var acquired = await new BeoordelingEscalatieProcessor(client, logger).PumpOnceAsync(5);
|
||||||
|
|
||||||
|
Assert.Equal(1, acquired);
|
||||||
|
Assert.Empty(client.Completed);
|
||||||
|
var error = Assert.Single(logger.Entries, e => e.Level == LogLevel.Error);
|
||||||
|
Assert.Contains("job-9", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Does_nothing_but_poll_when_there_are_no_escalations()
|
||||||
|
{
|
||||||
|
var client = new FakeEscalatieClient();
|
||||||
|
|
||||||
|
var acquired = await new BeoordelingEscalatieProcessor(
|
||||||
|
client, NullLogger<BeoordelingEscalatieProcessor>.Instance).PumpOnceAsync(5);
|
||||||
|
|
||||||
|
Assert.Equal(0, acquired);
|
||||||
|
Assert.Equal(1, client.AcquireCount);
|
||||||
|
Assert.Empty(client.Completed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using Big.Application;
|
||||||
|
using Big.Domain;
|
||||||
|
|
||||||
|
namespace Big.Tests;
|
||||||
|
|
||||||
|
// S-10a (#102): the application handler behind the RegistratieVerlopen external-worker job. The 30-day
|
||||||
|
// document-wait timer fired, so the correlated registration is expired to VERLOPEN. Mirrors
|
||||||
|
// OpenZaakWorker — pure application logic over ports, idempotent under at-least-once delivery (§8.6).
|
||||||
|
public class ExpireRegistrationWorkerTests
|
||||||
|
{
|
||||||
|
private const string Bsn = "123456782";
|
||||||
|
|
||||||
|
private static Registration Submitted(string processInstanceId = "proc-1")
|
||||||
|
{
|
||||||
|
var registration = Registration.Submit(Bsn);
|
||||||
|
registration.RecordProcessStarted(processInstanceId);
|
||||||
|
return registration;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Expires_the_registration_the_job_correlates_to()
|
||||||
|
{
|
||||||
|
var store = new FakeRegistrationStore();
|
||||||
|
var registration = Submitted();
|
||||||
|
store.Seed(registration);
|
||||||
|
|
||||||
|
await new ExpireRegistrationWorker(store).HandleAsync(
|
||||||
|
new RegistratieVerlopenJob("job-7", registration.Id));
|
||||||
|
|
||||||
|
var saved = await store.GetAsync(registration.Id);
|
||||||
|
Assert.Equal(RegistrationStatus.Verlopen, saved!.Status);
|
||||||
|
Assert.Equal(1, store.SaveCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_already_verlopen_registration_is_not_persisted_again()
|
||||||
|
{
|
||||||
|
// A redelivered job (§8.6) finds the aggregate already VERLOPEN: a no-op, not saved again.
|
||||||
|
var store = new FakeRegistrationStore();
|
||||||
|
var registration = Submitted();
|
||||||
|
registration.Expire();
|
||||||
|
store.Seed(registration);
|
||||||
|
|
||||||
|
await new ExpireRegistrationWorker(store).HandleAsync(
|
||||||
|
new RegistratieVerlopenJob("job-7", registration.Id));
|
||||||
|
|
||||||
|
Assert.Equal(0, store.SaveCount);
|
||||||
|
Assert.Equal(RegistrationStatus.Verlopen, (await store.GetAsync(registration.Id))!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_already_resolved_registration_is_left_alone_and_the_job_completes()
|
||||||
|
{
|
||||||
|
// Race with S-11: the citizen withdrew while parked at WachtOpDocumenten, so the aggregate is
|
||||||
|
// already terminal (INGETROKKEN) when the timer's job arrives. Expiring it would violate the
|
||||||
|
// aggregate's invariant; the worker must instead no-op (and let the job complete), not throw
|
||||||
|
// into a redelivery loop.
|
||||||
|
var store = new FakeRegistrationStore();
|
||||||
|
var registration = Submitted();
|
||||||
|
registration.Withdraw();
|
||||||
|
store.Seed(registration);
|
||||||
|
|
||||||
|
await new ExpireRegistrationWorker(store).HandleAsync(
|
||||||
|
new RegistratieVerlopenJob("job-7", registration.Id));
|
||||||
|
|
||||||
|
Assert.Equal(0, store.SaveCount);
|
||||||
|
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_unknown_registration_throws_so_the_job_is_redelivered()
|
||||||
|
{
|
||||||
|
var store = new FakeRegistrationStore();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
new ExpireRegistrationWorker(store).HandleAsync(
|
||||||
|
new RegistratieVerlopenJob("job-7", RegistrationId.New())));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rejects_a_null_job()
|
||||||
|
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||||
|
new ExpireRegistrationWorker(new FakeRegistrationStore()).HandleAsync(null!));
|
||||||
|
}
|
||||||
@@ -32,12 +32,16 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
|
|||||||
: IWorkflowClient
|
: IWorkflowClient
|
||||||
{
|
{
|
||||||
public RegistrationId? StartedFor { get; private set; }
|
public RegistrationId? StartedFor { get; private set; }
|
||||||
|
public DiplomaOrigin? StartedWithOrigin { get; private set; }
|
||||||
public string? WithdrawnProcessInstanceId { get; private set; }
|
public string? WithdrawnProcessInstanceId { get; private set; }
|
||||||
|
public string? CompletedDocumentWaitFor { get; private set; }
|
||||||
|
|
||||||
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
|
public Task<string> StartRegistrationProcessAsync(
|
||||||
|
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
onStart?.Invoke(registrationId);
|
onStart?.Invoke(registrationId);
|
||||||
StartedFor = registrationId;
|
StartedFor = registrationId;
|
||||||
|
StartedWithOrigin = diplomaOrigin;
|
||||||
return Task.FromResult(processInstanceId);
|
return Task.FromResult(processInstanceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +50,12 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
|
|||||||
WithdrawnProcessInstanceId = processInstanceId;
|
WithdrawnProcessInstanceId = processInstanceId;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task CompleteDocumentWaitAsync(string processInstanceId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
CompletedDocumentWaitFor = processInstanceId;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A fake user-task client for the werkbak/decision use cases: returns a scripted set of
|
/// <summary>A fake user-task client for the werkbak/decision use cases: returns a scripted set of
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public class FlowableWorkflowClientTests
|
|||||||
var client = Client(capture.Responds(HttpStatusCode.Created, """{"id":"pi-1"}"""));
|
var client = Client(capture.Responds(HttpStatusCode.Created, """{"id":"pi-1"}"""));
|
||||||
var rid = RegistrationId.New();
|
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("pi-1", pid);
|
||||||
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
||||||
@@ -38,6 +38,22 @@ public class FlowableWorkflowClientTests
|
|||||||
Assert.Contains($"\"value\":\"{rid}\"", capture.Body);
|
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]
|
[Fact]
|
||||||
public async Task Start_uses_the_configured_worker_credentials_and_defaults()
|
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));
|
var client = Client(capture.Responds(HttpStatusCode.InternalServerError));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<HttpRequestException>(
|
await Assert.ThrowsAsync<HttpRequestException>(
|
||||||
() => client.StartRegistrationProcessAsync(RegistrationId.New()));
|
() => client.StartRegistrationProcessAsync(RegistrationId.New(), DiplomaOrigin.Binnenlands));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -135,7 +151,7 @@ public class FlowableWorkflowClientTests
|
|||||||
var client = Client(capture.Responds(HttpStatusCode.Created, "null"));
|
var client = Client(capture.Responds(HttpStatusCode.Created, "null"));
|
||||||
|
|
||||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
() => client.StartRegistrationProcessAsync(RegistrationId.New()));
|
() => client.StartRegistrationProcessAsync(RegistrationId.New(), DiplomaOrigin.Binnenlands));
|
||||||
Assert.Contains("empty process-instance", ex.Message);
|
Assert.Contains("empty process-instance", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,4 +322,210 @@ public class FlowableWorkflowClientTests
|
|||||||
await Assert.ThrowsAsync<HttpRequestException>(
|
await Assert.ThrowsAsync<HttpRequestException>(
|
||||||
() => client.CompleteBeoordelingAsync("task-1", BeoordelingsBesluit.Goedkeuren));
|
() => client.CompleteBeoordelingAsync("task-1", BeoordelingsBesluit.Goedkeuren));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── S-14 (#15): 14-day beoordeling escalation → reassign to teamlead (ADR-0015) ───────────────
|
||||||
|
// The BPMN parks a parallel escalation token on a non-interrupting P14D boundary timer, surfaced as
|
||||||
|
// an external-worker job on the BeoordelingEscaleren topic. The worker reassigns the still-open
|
||||||
|
// Beoordelen task from the behandelaar group to teamlead, then completes the escalation job.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acquire_escalation_jobs_posts_the_escalation_topic_and_parses_the_process_instance()
|
||||||
|
{
|
||||||
|
var capture = new RequestCapture();
|
||||||
|
var client = Client(capture.Responds(HttpStatusCode.OK,
|
||||||
|
"""[{"id":"job-9","processInstanceId":"pi-1"}]"""));
|
||||||
|
|
||||||
|
var jobs = await client.AcquireBeoordelingEscalatieJobsAsync(3);
|
||||||
|
|
||||||
|
var job = Assert.Single(jobs);
|
||||||
|
Assert.Equal("job-9", job.JobId);
|
||||||
|
Assert.Equal("pi-1", job.ProcessInstanceId);
|
||||||
|
Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs",
|
||||||
|
capture.Seen!.RequestUri!.ToString());
|
||||||
|
Assert.Contains("\"topic\":\"BeoordelingEscaleren\"", capture.Body);
|
||||||
|
Assert.Contains("\"numberOfTasks\":3", capture.Body);
|
||||||
|
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("[]")]
|
||||||
|
[InlineData("null")]
|
||||||
|
public async Task Acquire_escalation_jobs_returns_empty_when_none_are_parked(string body)
|
||||||
|
{
|
||||||
|
var capture = new RequestCapture();
|
||||||
|
var client = Client(capture.Responds(HttpStatusCode.OK, body));
|
||||||
|
|
||||||
|
Assert.Empty(await client.AcquireBeoordelingEscalatieJobsAsync(1));
|
||||||
|
Assert.NotNull(capture.Seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reassign_moves_the_open_beoordelen_task_from_behandelaar_to_teamlead()
|
||||||
|
{
|
||||||
|
var requests = new List<(HttpMethod Method, string Url, string? Body)>();
|
||||||
|
var client = Client(new StubHandler(async req =>
|
||||||
|
{
|
||||||
|
requests.Add((req.Method, req.RequestUri!.ToString(),
|
||||||
|
req.Content is null ? null : await req.Content.ReadAsStringAsync()));
|
||||||
|
// The task query returns the still-open Beoordelen task parked in this instance.
|
||||||
|
return req.RequestUri!.AbsoluteUri.EndsWith("service/query/tasks")
|
||||||
|
? new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("""{"data":[{"id":"task-7"}],"total":1}""",
|
||||||
|
Encoding.UTF8, "application/json"),
|
||||||
|
}
|
||||||
|
: new HttpResponseMessage(HttpStatusCode.OK);
|
||||||
|
}));
|
||||||
|
|
||||||
|
await client.ReassignBeoordelingToTeamleadAsync("pi-1");
|
||||||
|
|
||||||
|
// 1. Find the still-open Beoordelen task in this process instance.
|
||||||
|
var query = requests.Single(r => r.Url.EndsWith("service/query/tasks"));
|
||||||
|
Assert.Equal(HttpMethod.Post, query.Method);
|
||||||
|
Assert.Contains("\"processInstanceId\":\"pi-1\"", query.Body);
|
||||||
|
Assert.Contains("\"taskDefinitionKey\":\"Beoordelen\"", query.Body);
|
||||||
|
// 2. Add teamlead as a candidate group on that task.
|
||||||
|
var add = requests.Single(r => r.Method == HttpMethod.Post
|
||||||
|
&& r.Url.EndsWith("service/runtime/tasks/task-7/identitylinks"));
|
||||||
|
Assert.Contains("\"group\":\"teamlead\"", add.Body);
|
||||||
|
Assert.Contains("\"type\":\"candidate\"", add.Body);
|
||||||
|
// 3. Remove behandelaar as a candidate group — the task now belongs to teamlead.
|
||||||
|
Assert.Contains(requests, r => r.Method == HttpMethod.Delete
|
||||||
|
&& r.Url.EndsWith("service/runtime/tasks/task-7/identitylinks/groups/behandelaar/candidate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reassign_is_a_no_op_when_the_beoordelen_task_is_no_longer_open()
|
||||||
|
{
|
||||||
|
// The behandelaar completed it just before the timer fired: nothing to reassign, no throw.
|
||||||
|
var methods = new List<HttpMethod>();
|
||||||
|
var client = Client(new StubHandler(req =>
|
||||||
|
{
|
||||||
|
methods.Add(req.Method);
|
||||||
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("""{"data":[],"total":0}""", Encoding.UTF8, "application/json"),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
await client.ReassignBeoordelingToTeamleadAsync("pi-1");
|
||||||
|
|
||||||
|
Assert.DoesNotContain(HttpMethod.Delete, methods);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Complete_escalation_job_posts_to_the_job_complete_endpoint()
|
||||||
|
{
|
||||||
|
var capture = new RequestCapture();
|
||||||
|
var client = Client(capture.Responds(HttpStatusCode.NoContent));
|
||||||
|
|
||||||
|
await client.CompleteBeoordelingEscalatieJobAsync("job-9");
|
||||||
|
|
||||||
|
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
||||||
|
Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs/job-9/complete",
|
||||||
|
capture.Seen.RequestUri!.ToString());
|
||||||
|
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── S-10a (#102): document-wait timeout → RegistratieVerlopen (ADR-0017) ──────────────────────
|
||||||
|
// A 30-day interrupting boundary timer on WachtOpDocumenten spawns a RegistratieVerlopen
|
||||||
|
// external-worker job carrying the registration id; the worker expires the registration and
|
||||||
|
// completes the job. Separately, "documents received" completes the WachtOpDocumenten user task.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acquire_verlopen_jobs_posts_the_topic_and_parses_jobs_with_their_registration_id()
|
||||||
|
{
|
||||||
|
var rid = RegistrationId.New();
|
||||||
|
var capture = new RequestCapture();
|
||||||
|
var client = Client(capture.Responds(HttpStatusCode.OK,
|
||||||
|
$$"""[{"id":"job-9","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}]"""));
|
||||||
|
|
||||||
|
var jobs = await client.AcquireRegistratieVerlopenJobsAsync(3);
|
||||||
|
|
||||||
|
var job = Assert.Single(jobs);
|
||||||
|
Assert.Equal("job-9", job.JobId);
|
||||||
|
Assert.Equal(rid, job.RegistrationId);
|
||||||
|
Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs",
|
||||||
|
capture.Seen!.RequestUri!.ToString());
|
||||||
|
Assert.Contains("\"topic\":\"RegistratieVerlopen\"", capture.Body);
|
||||||
|
Assert.Contains("\"numberOfTasks\":3", capture.Body);
|
||||||
|
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("[]")]
|
||||||
|
[InlineData("null")]
|
||||||
|
public async Task Acquire_verlopen_jobs_returns_empty_when_none_are_parked(string body)
|
||||||
|
{
|
||||||
|
var capture = new RequestCapture();
|
||||||
|
var client = Client(capture.Responds(HttpStatusCode.OK, body));
|
||||||
|
|
||||||
|
Assert.Empty(await client.AcquireRegistratieVerlopenJobsAsync(1));
|
||||||
|
Assert.NotNull(capture.Seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Complete_verlopen_job_posts_to_the_job_complete_endpoint()
|
||||||
|
{
|
||||||
|
var capture = new RequestCapture();
|
||||||
|
var client = Client(capture.Responds(HttpStatusCode.NoContent));
|
||||||
|
|
||||||
|
await client.CompleteRegistratieVerlopenJobAsync("job-9");
|
||||||
|
|
||||||
|
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
||||||
|
Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs/job-9/complete",
|
||||||
|
capture.Seen.RequestUri!.ToString());
|
||||||
|
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Provide_documents_completes_the_wacht_op_documenten_task_in_the_instance()
|
||||||
|
{
|
||||||
|
var requests = new List<(HttpMethod Method, string Url, string? Body)>();
|
||||||
|
var client = Client(new StubHandler(async req =>
|
||||||
|
{
|
||||||
|
requests.Add((req.Method, req.RequestUri!.ToString(),
|
||||||
|
req.Content is null ? null : await req.Content.ReadAsStringAsync()));
|
||||||
|
return req.RequestUri!.AbsoluteUri.EndsWith("service/query/tasks")
|
||||||
|
? new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("""{"data":[{"id":"task-3"}],"total":1}""",
|
||||||
|
Encoding.UTF8, "application/json"),
|
||||||
|
}
|
||||||
|
: new HttpResponseMessage(HttpStatusCode.OK);
|
||||||
|
}));
|
||||||
|
|
||||||
|
await client.CompleteDocumentWaitAsync("pi-1");
|
||||||
|
|
||||||
|
// 1. Find the still-open WachtOpDocumenten task in this process instance.
|
||||||
|
var query = requests.Single(r => r.Url.EndsWith("service/query/tasks"));
|
||||||
|
Assert.Equal(HttpMethod.Post, query.Method);
|
||||||
|
Assert.Contains("\"processInstanceId\":\"pi-1\"", query.Body);
|
||||||
|
Assert.Contains("\"taskDefinitionKey\":\"WachtOpDocumenten\"", query.Body);
|
||||||
|
// 2. Complete that task so the process leaves the wait state.
|
||||||
|
var complete = requests.Single(r => r.Url.EndsWith("service/runtime/tasks/task-3"));
|
||||||
|
Assert.Equal(HttpMethod.Post, complete.Method);
|
||||||
|
Assert.Contains("\"action\":\"complete\"", complete.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Provide_documents_is_a_no_op_when_the_wait_task_is_no_longer_open()
|
||||||
|
{
|
||||||
|
// The process already left WachtOpDocumenten (e.g. timed out): nothing to complete, no throw.
|
||||||
|
var methods = new List<HttpMethod>();
|
||||||
|
var client = Client(new StubHandler(req =>
|
||||||
|
{
|
||||||
|
methods.Add(req.Method);
|
||||||
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("""{"data":[],"total":0}""", Encoding.UTF8, "application/json"),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
await client.CompleteDocumentWaitAsync("pi-1");
|
||||||
|
|
||||||
|
// Only the query ran; no task-completion POST followed.
|
||||||
|
Assert.DoesNotContain(methods, m => m == HttpMethod.Put || m == HttpMethod.Delete);
|
||||||
|
Assert.Single(methods);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using Big.Application;
|
||||||
|
using Big.Domain;
|
||||||
|
|
||||||
|
namespace Big.Tests;
|
||||||
|
|
||||||
|
// S-10a (#102): the "documents received" use case. A zorgprofessional supplies the documents their
|
||||||
|
// registration is waiting for; the handler completes the WachtOpDocumenten task via the Workflow Client
|
||||||
|
// so the process leaves the 30-day wait and continues to beoordeling. Owner-scoped by the caller's bsn,
|
||||||
|
// like WithdrawRegistration. (The real file upload + ZGW storage is S-10b; this is the trigger path.)
|
||||||
|
public class ProvideDocumentsTests
|
||||||
|
{
|
||||||
|
private const string Bsn = "123456782";
|
||||||
|
|
||||||
|
private static Registration Submitted(string processInstanceId = "proc-1")
|
||||||
|
{
|
||||||
|
var registration = Registration.Submit(Bsn);
|
||||||
|
registration.RecordProcessStarted(processInstanceId);
|
||||||
|
return registration;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProvideDocumentsCommand Command(RegistrationId id, string bsn = Bsn) => new(id, bsn);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Providing_documents_completes_the_document_wait()
|
||||||
|
{
|
||||||
|
var store = new FakeRegistrationStore();
|
||||||
|
var registration = Submitted("proc-42");
|
||||||
|
store.Seed(registration);
|
||||||
|
var workflow = new FakeWorkflowClient();
|
||||||
|
var handler = new ProvideDocuments(store, workflow);
|
||||||
|
|
||||||
|
var outcome = await handler.HandleAsync(Command(registration.Id));
|
||||||
|
|
||||||
|
Assert.Equal(ProvideDocumentsOutcome.Accepted, outcome);
|
||||||
|
Assert.Equal("proc-42", workflow.CompletedDocumentWaitFor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_different_bsn_cannot_provide_documents()
|
||||||
|
{
|
||||||
|
// Owner-scoping: only the registration's own bsn may supply its documents. Another bsn is told
|
||||||
|
// NotFound (existence not revealed) and the wait is not completed.
|
||||||
|
var store = new FakeRegistrationStore();
|
||||||
|
var registration = Submitted();
|
||||||
|
store.Seed(registration);
|
||||||
|
var workflow = new FakeWorkflowClient();
|
||||||
|
var handler = new ProvideDocuments(store, workflow);
|
||||||
|
|
||||||
|
var outcome = await handler.HandleAsync(Command(registration.Id, bsn: "999999990"));
|
||||||
|
|
||||||
|
Assert.Equal(ProvideDocumentsOutcome.NotFound, outcome);
|
||||||
|
Assert.Null(workflow.CompletedDocumentWaitFor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Providing_for_an_unknown_registration_is_not_found()
|
||||||
|
{
|
||||||
|
var store = new FakeRegistrationStore();
|
||||||
|
var handler = new ProvideDocuments(store, new FakeWorkflowClient());
|
||||||
|
|
||||||
|
Assert.Equal(ProvideDocumentsOutcome.NotFound, await handler.HandleAsync(Command(RegistrationId.New())));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Providing_before_a_process_started_is_accepted_without_calling_the_workflow()
|
||||||
|
{
|
||||||
|
// No process yet → no wait task to complete; the request still stands (best-effort, mirroring
|
||||||
|
// WithdrawRegistration) and the Workflow Client is not called.
|
||||||
|
var store = new FakeRegistrationStore();
|
||||||
|
var registration = Registration.Submit(Bsn); // no RecordProcessStarted
|
||||||
|
store.Seed(registration);
|
||||||
|
var workflow = new FakeWorkflowClient();
|
||||||
|
var handler = new ProvideDocuments(store, workflow);
|
||||||
|
|
||||||
|
var outcome = await handler.HandleAsync(Command(registration.Id));
|
||||||
|
|
||||||
|
Assert.Equal(ProvideDocumentsOutcome.Accepted, outcome);
|
||||||
|
Assert.Null(workflow.CompletedDocumentWaitFor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rejects_a_null_command()
|
||||||
|
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||||
|
new ProvideDocuments(new FakeRegistrationStore(), new FakeWorkflowClient()).HandleAsync(null!));
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using Big.Application;
|
||||||
|
using Big.Infrastructure;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace Big.Tests;
|
||||||
|
|
||||||
|
// S-10a (#102): the document-timeout drain loop. Mirrors BeoordelingEscalatieProcessor — acquire the
|
||||||
|
// parked RegistratieVerlopen jobs (the tokens the 30-day boundary timer on WachtOpDocumenten spawns),
|
||||||
|
// expire each correlated registration via the ExpireRegistrationWorker, then complete the job. A job
|
||||||
|
// whose expiry fails is logged and left un-completed for Flowable to redeliver (§8.6).
|
||||||
|
public class RegistratieVerlopenProcessorTests
|
||||||
|
{
|
||||||
|
/// <summary>A fake client scripting the jobs to acquire and recording completions.</summary>
|
||||||
|
private sealed class FakeVerlopenClient(params RegistratieVerlopenJob[] jobs) : IRegistratieVerlopenClient
|
||||||
|
{
|
||||||
|
public int AcquireCount { get; private set; }
|
||||||
|
public List<string> Completed { get; } = [];
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<RegistratieVerlopenJob>> AcquireRegistratieVerlopenJobsAsync(int maxJobs, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
AcquireCount++;
|
||||||
|
return Task.FromResult<IReadOnlyList<RegistratieVerlopenJob>>(jobs.Take(maxJobs).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task CompleteRegistratieVerlopenJobAsync(string jobId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Completed.Add(jobId);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExpireRegistrationWorker Worker(FakeRegistrationStore store) => new(store);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acquires_a_job_expires_the_registration_and_completes_the_job()
|
||||||
|
{
|
||||||
|
var store = new FakeRegistrationStore();
|
||||||
|
var registration = Domain.Registration.Submit("123456782");
|
||||||
|
store.Seed(registration);
|
||||||
|
var client = new FakeVerlopenClient(new RegistratieVerlopenJob("job-9", registration.Id));
|
||||||
|
|
||||||
|
var acquired = await new RegistratieVerlopenProcessor(
|
||||||
|
client, Worker(store), NullLogger<RegistratieVerlopenProcessor>.Instance).PumpOnceAsync(5);
|
||||||
|
|
||||||
|
Assert.Equal(1, acquired);
|
||||||
|
Assert.Equal(Domain.RegistrationStatus.Verlopen, (await store.GetAsync(registration.Id))!.Status);
|
||||||
|
Assert.Equal("job-9", Assert.Single(client.Completed));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_failing_expiry_is_left_uncompleted_for_flowable_to_redeliver()
|
||||||
|
{
|
||||||
|
// Unknown registration → the worker throws → the job is left for redelivery, error logged.
|
||||||
|
var store = new FakeRegistrationStore();
|
||||||
|
var client = new FakeVerlopenClient(new RegistratieVerlopenJob("job-9", Domain.RegistrationId.New()));
|
||||||
|
var logger = new CapturingLogger<RegistratieVerlopenProcessor>();
|
||||||
|
|
||||||
|
var acquired = await new RegistratieVerlopenProcessor(client, Worker(store), logger).PumpOnceAsync(5);
|
||||||
|
|
||||||
|
Assert.Equal(1, acquired);
|
||||||
|
Assert.Empty(client.Completed);
|
||||||
|
var error = Assert.Single(logger.Entries, e => e.Level == LogLevel.Error);
|
||||||
|
Assert.Contains("job-9", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Does_nothing_but_poll_when_there_are_no_jobs()
|
||||||
|
{
|
||||||
|
var client = new FakeVerlopenClient();
|
||||||
|
|
||||||
|
var acquired = await new RegistratieVerlopenProcessor(
|
||||||
|
client, Worker(new FakeRegistrationStore()), NullLogger<RegistratieVerlopenProcessor>.Instance).PumpOnceAsync(5);
|
||||||
|
|
||||||
|
Assert.Equal(0, acquired);
|
||||||
|
Assert.Equal(1, client.AcquireCount);
|
||||||
|
Assert.Empty(client.Completed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,15 @@ public class RegistrationTests
|
|||||||
Assert.Null(registration.ProcessInstanceId);
|
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]
|
[Theory]
|
||||||
[InlineData("")]
|
[InlineData("")]
|
||||||
[InlineData(" ")]
|
[InlineData(" ")]
|
||||||
@@ -285,4 +294,63 @@ public class RegistrationTests
|
|||||||
Assert.Contains("only an INGEDIEND", ex.Message);
|
Assert.Contains("only an INGEDIEND", ex.Message);
|
||||||
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
|
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Expiring_an_ingediend_registration_sets_it_verlopen()
|
||||||
|
{
|
||||||
|
// The 30-day document-wait timer fired before the documents arrived (S-10a): the case is
|
||||||
|
// cancelled and the aggregate becomes terminal VERLOPEN.
|
||||||
|
var registration = Registration.Submit("123456782");
|
||||||
|
|
||||||
|
registration.Expire();
|
||||||
|
|
||||||
|
Assert.Equal(RegistrationStatus.Verlopen, registration.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Expiring_needs_no_zaak()
|
||||||
|
{
|
||||||
|
// The timer fires on a purely time-based boundary; expiry does not depend on the zaak.
|
||||||
|
var registration = Registration.Submit("123456782");
|
||||||
|
|
||||||
|
registration.Expire();
|
||||||
|
|
||||||
|
Assert.Equal(RegistrationStatus.Verlopen, registration.Status);
|
||||||
|
Assert.Null(registration.ZaakUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Re_expiring_an_already_verlopen_registration_is_idempotent()
|
||||||
|
{
|
||||||
|
// The RegistratieVerlopen worker job may be redelivered (§8.6); re-expiring is a no-op.
|
||||||
|
var registration = Registration.Submit("123456782");
|
||||||
|
registration.Expire();
|
||||||
|
|
||||||
|
registration.Expire();
|
||||||
|
|
||||||
|
Assert.Equal(RegistrationStatus.Verlopen, registration.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Expiring_an_approved_registration_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.Expire());
|
||||||
|
Assert.Contains("only an INGEDIEND", ex.Message);
|
||||||
|
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Expiring_a_withdrawn_registration_is_rejected()
|
||||||
|
{
|
||||||
|
var registration = Registration.Submit("123456782");
|
||||||
|
registration.Withdraw();
|
||||||
|
|
||||||
|
var ex = Assert.Throws<InvalidOperationException>(() => registration.Expire());
|
||||||
|
Assert.Contains("only an INGEDIEND", ex.Message);
|
||||||
|
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ public class SubmitRegistrationTests
|
|||||||
Assert.Equal(2, store.SaveCount);
|
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]
|
[Fact]
|
||||||
public async Task Rejects_a_null_command_without_touching_the_store_or_workflow()
|
public async Task Rejects_a_null_command_without_touching_the_store_or_workflow()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
"test-projects": ["Big.Tests/Big.Tests.csproj"],
|
"test-projects": ["Big.Tests/Big.Tests.csproj"],
|
||||||
"reporters": ["progress", "html"],
|
"reporters": ["progress", "html"],
|
||||||
"mutate": [
|
"mutate": [
|
||||||
"!**/OpenZaakJobPump.cs"
|
"!**/OpenZaakJobPump.cs",
|
||||||
|
"!**/BeoordelingEscalatiePump.cs",
|
||||||
|
"!**/RegistratieVerlopenPump.cs"
|
||||||
],
|
],
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
"high": 95,
|
"high": 95,
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
<ProjectReference Include="..\..\services\acl\Acl.Infrastructure\Acl.Infrastructure.csproj" />
|
<ProjectReference Include="..\..\services\acl\Acl.Infrastructure\Acl.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\..\services\event-subscriber\EventSubscriber.Application\EventSubscriber.Application.csproj" />
|
<ProjectReference Include="..\..\services\event-subscriber\EventSubscriber.Application\EventSubscriber.Application.csproj" />
|
||||||
<ProjectReference Include="..\..\services\domain\Big.Application\Big.Application.csproj" />
|
<ProjectReference Include="..\..\services\domain\Big.Application\Big.Application.csproj" />
|
||||||
|
<!-- The beoordeling-escalation scenario drives the escalation worker (Infrastructure), as the
|
||||||
|
ACL scenario drives Acl.Infrastructure — escalation has no domain-aggregate surface (S-14). -->
|
||||||
|
<ProjectReference Include="..\..\services\domain\Big.Infrastructure\Big.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\..\services\bff\Bff.Api\Bff.Api.csproj" />
|
<ProjectReference Include="..\..\services\bff\Bff.Api\Bff.Api.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# language: en
|
||||||
|
# Drives S-14 (#15). A beoordeling a behandelaar does not pick up within 14 days escalates to the
|
||||||
|
# teamlead: a non-interrupting boundary timer parks a BeoordelingEscaleren job (ADR-0015) which the
|
||||||
|
# escalation worker drains, reassigning the still-open Beoordelen task's candidate group to teamlead.
|
||||||
|
# A beoordeling completed before the timer fires does not escalate. This scenario exercises the
|
||||||
|
# escalation worker against an in-memory Flowable stand-in; the timer firing live is verify-domain.
|
||||||
|
Feature: Een beoordeling escaleren
|
||||||
|
Als teamleider wil ik dat een beoordeling die na 14 dagen niet is opgepakt naar mij escaleert
|
||||||
|
zodat aanvragen niet blijven liggen.
|
||||||
|
|
||||||
|
Scenario: Na 14 dagen zonder oppakken escaleert de beoordeling naar de teamlead
|
||||||
|
Given a registration parked at the Beoordelen task for the behandelaar
|
||||||
|
When the 14-day escalation timer fires
|
||||||
|
And the escalation worker runs
|
||||||
|
Then the beoordeling is reassigned to the teamlead
|
||||||
|
|
||||||
|
Scenario: Een tijdig afgeronde beoordeling escaleert niet
|
||||||
|
Given a registration parked at the Beoordelen task for the behandelaar
|
||||||
|
When the behandelaar completes the beoordeling before the timer fires
|
||||||
|
And the 14-day escalation timer fires
|
||||||
|
And the escalation worker runs
|
||||||
|
Then the beoordeling stays with the behandelaar
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# language: en
|
||||||
|
# Drives S-10a (#102). After the zaak is opened the process parks at WachtOpDocumenten with an
|
||||||
|
# INTERRUPTING 30-day boundary timer. If the documents do not arrive in time the timer cancels the
|
||||||
|
# task and parks a RegistratieVerlopen job (ADR-0017) which the timeout worker drains, expiring the
|
||||||
|
# registration to VERLOPEN. Documents received before the timer fires close the wait, so no expiry
|
||||||
|
# happens. This scenario exercises the timeout worker against an in-memory Flowable stand-in; the timer
|
||||||
|
# firing live is verify-domain.
|
||||||
|
Feature: Een documenttermijn laten verlopen
|
||||||
|
Als registerbeheerder wil ik dat een aanvraag waarvoor de documenten niet binnen 30 dagen binnen zijn
|
||||||
|
automatisch vervalt zodat onvolledige aanvragen niet blijven liggen.
|
||||||
|
|
||||||
|
Scenario: Zonder documenten binnen 30 dagen vervalt de registratie
|
||||||
|
Given a registration parked at the WachtOpDocumenten task
|
||||||
|
When the 30-day document timer fires
|
||||||
|
And the document-timeout worker runs
|
||||||
|
Then the registration is verlopen
|
||||||
|
|
||||||
|
Scenario: Tijdig aangeleverde documenten laten de registratie niet vervallen
|
||||||
|
Given a registration parked at the WachtOpDocumenten task
|
||||||
|
When the documents arrive before the timer fires
|
||||||
|
And the 30-day document timer fires
|
||||||
|
And the document-timeout worker runs
|
||||||
|
Then the registration is not verlopen
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# language: en
|
||||||
|
# Drives S-11 (#12). A zorgprofessional withdraws their own submitted registration ("trek aanvraag
|
||||||
|
# in"): it advances to INGETROKKEN and its running workflow is cancelled (ADR-0014). Only the owner
|
||||||
|
# may withdraw — another bsn is told not-found. Exercised against in-memory stand-ins for the store
|
||||||
|
# and the Workflow Client; the live Flowable message correlation is verified by the domain check.
|
||||||
|
Feature: Een registratie intrekken
|
||||||
|
Als zorgprofessional wil ik mijn ingediende registratie kunnen intrekken
|
||||||
|
zodat een aanvraag die ik niet meer wil niet in behandeling blijft.
|
||||||
|
|
||||||
|
Scenario: De zorgprofessional trekt zijn eigen registratie in
|
||||||
|
Given a submitted registration with a running process
|
||||||
|
When the zorgprofessional withdraws it
|
||||||
|
Then the withdrawal succeeds
|
||||||
|
And the registration has status "INGETROKKEN"
|
||||||
|
And the running process is cancelled
|
||||||
|
|
||||||
|
Scenario: Een andere zorgprofessional kan de registratie niet intrekken
|
||||||
|
Given a submitted registration with a running process
|
||||||
|
When a different zorgprofessional tries to withdraw it
|
||||||
|
Then the withdrawal is reported not found
|
||||||
|
And the registration has status "INGEDIEND"
|
||||||
|
And the running process is not cancelled
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Acceptance.Support;
|
||||||
|
using Big.Infrastructure;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Reqnroll;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Acceptance.Steps;
|
||||||
|
|
||||||
|
/// <summary>Bindings for <c>EenBeoordelingEscaleren.feature</c> (S-14). Drives the escalation worker
|
||||||
|
/// (<see cref="BeoordelingEscalatieProcessor"/>) against an in-memory Flowable stand-in; one instance
|
||||||
|
/// per scenario. Escalation has no domain-aggregate surface — it only reassigns who may claim the
|
||||||
|
/// still-open Beoordelen task — so the scenario asserts on the task's candidate group.</summary>
|
||||||
|
[Binding]
|
||||||
|
[Scope(Feature = "Een beoordeling escaleren")]
|
||||||
|
public sealed class EenBeoordelingEscalerenSteps
|
||||||
|
{
|
||||||
|
private readonly InMemoryEscalatieClient _flowable = new();
|
||||||
|
private string _processInstanceId = "";
|
||||||
|
|
||||||
|
[Given("a registration parked at the Beoordelen task for the behandelaar")]
|
||||||
|
public void GivenARegistrationParkedForTheBehandelaar()
|
||||||
|
=> _processInstanceId = _flowable.ParkBeoordeling();
|
||||||
|
|
||||||
|
[When("the 14-day escalation timer fires")]
|
||||||
|
public void WhenTheEscalationTimerFires()
|
||||||
|
=> _flowable.FireEscalationTimer(_processInstanceId);
|
||||||
|
|
||||||
|
[When("the behandelaar completes the beoordeling before the timer fires")]
|
||||||
|
public void WhenTheBehandelaarCompletesBeforeTheTimer()
|
||||||
|
=> _flowable.CompleteBeoordeling(_processInstanceId);
|
||||||
|
|
||||||
|
[When("the escalation worker runs")]
|
||||||
|
public async Task WhenTheEscalationWorkerRuns()
|
||||||
|
=> await new BeoordelingEscalatieProcessor(
|
||||||
|
_flowable, NullLogger<BeoordelingEscalatieProcessor>.Instance).PumpOnceAsync(5);
|
||||||
|
|
||||||
|
[Then("the beoordeling is reassigned to the teamlead")]
|
||||||
|
public void ThenTheBeoordelingIsReassignedToTheTeamlead()
|
||||||
|
=> Assert.Equal("teamlead", _flowable.CandidateGroupFor(_processInstanceId));
|
||||||
|
|
||||||
|
[Then("the beoordeling stays with the behandelaar")]
|
||||||
|
public void ThenTheBeoordelingStaysWithTheBehandelaar()
|
||||||
|
=> Assert.Equal("behandelaar", _flowable.CandidateGroupFor(_processInstanceId));
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Acceptance.Support;
|
||||||
|
using Big.Application;
|
||||||
|
using Big.Domain;
|
||||||
|
using Reqnroll;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Acceptance.Steps;
|
||||||
|
|
||||||
|
/// <summary>Bindings for <c>EenDiplomaRouteren.feature</c> (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.</summary>
|
||||||
|
[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<DiplomaOrigin>(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<DiplomaOrigin>(expected, ignoreCase: true), _workflow.StartedWithOrigin);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using Acceptance.Support;
|
||||||
|
using Big.Application;
|
||||||
|
using Big.Domain;
|
||||||
|
using Big.Infrastructure;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Reqnroll;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Acceptance.Steps;
|
||||||
|
|
||||||
|
/// <summary>Bindings for <c>EenDocumentTermijnVerlopen.feature</c> (S-10a). Drives the timeout worker
|
||||||
|
/// (<see cref="RegistratieVerlopenProcessor"/> over the <see cref="ExpireRegistrationWorker"/>) against
|
||||||
|
/// an in-memory Flowable stand-in and a shared registration store; one instance per scenario. The
|
||||||
|
/// interrupting 30-day timer either cancels the wait and expires the registration, or — if the
|
||||||
|
/// documents arrived first — never fires; the scenario asserts on the aggregate's status.</summary>
|
||||||
|
[Binding]
|
||||||
|
[Scope(Feature = "Een documenttermijn laten verlopen")]
|
||||||
|
public sealed class EenDocumentTermijnVerlopenSteps
|
||||||
|
{
|
||||||
|
private readonly InMemoryDocumentTimeoutClient _flowable = new();
|
||||||
|
private readonly Support.InMemoryRegistrationStore _store = new();
|
||||||
|
private Registration _registration = null!;
|
||||||
|
private string _processInstanceId = "";
|
||||||
|
|
||||||
|
[Given("a registration parked at the WachtOpDocumenten task")]
|
||||||
|
public async Task GivenARegistrationParkedAtWachtOpDocumenten()
|
||||||
|
{
|
||||||
|
_registration = Registration.Submit("123456782");
|
||||||
|
await _store.SaveAsync(_registration);
|
||||||
|
_processInstanceId = _flowable.ParkWaitingForDocuments(_registration.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("the 30-day document timer fires")]
|
||||||
|
public void WhenTheDocumentTimerFires() => _flowable.FireDocumentTimer(_processInstanceId);
|
||||||
|
|
||||||
|
[When("the documents arrive before the timer fires")]
|
||||||
|
public void WhenTheDocumentsArriveBeforeTheTimer() => _flowable.ReceiveDocuments(_processInstanceId);
|
||||||
|
|
||||||
|
[When("the document-timeout worker runs")]
|
||||||
|
public async Task WhenTheTimeoutWorkerRuns()
|
||||||
|
=> await new RegistratieVerlopenProcessor(
|
||||||
|
_flowable, new ExpireRegistrationWorker(_store),
|
||||||
|
NullLogger<RegistratieVerlopenProcessor>.Instance).PumpOnceAsync(5);
|
||||||
|
|
||||||
|
[Then("the registration is verlopen")]
|
||||||
|
public async Task ThenTheRegistrationIsVerlopen()
|
||||||
|
=> Assert.Equal(RegistrationStatus.Verlopen, (await _store.GetAsync(_registration.Id))!.Status);
|
||||||
|
|
||||||
|
[Then("the registration is not verlopen")]
|
||||||
|
public async Task ThenTheRegistrationIsNotVerlopen()
|
||||||
|
=> Assert.Equal(RegistrationStatus.Ingediend, (await _store.GetAsync(_registration.Id))!.Status);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using Acceptance.Support;
|
||||||
|
using Big.Application;
|
||||||
|
using Big.Domain;
|
||||||
|
using Reqnroll;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Acceptance.Steps;
|
||||||
|
|
||||||
|
/// <summary>Bindings for <c>EenRegistratieIntrekken.feature</c> (S-11). Submits a registration (which
|
||||||
|
/// records its process) and then applies the WithdrawRegistration 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 steps in the other features.</summary>
|
||||||
|
[Binding]
|
||||||
|
[Scope(Feature = "Een registratie intrekken")]
|
||||||
|
public sealed class EenRegistratieIntrekkenSteps
|
||||||
|
{
|
||||||
|
private const string OwnerBsn = "123456782";
|
||||||
|
|
||||||
|
private readonly InMemoryRegistrationStore _store = new();
|
||||||
|
private readonly InMemoryWorkflowClient _workflow = new();
|
||||||
|
private RegistrationId _id;
|
||||||
|
private WithdrawOutcome _outcome;
|
||||||
|
|
||||||
|
[Given("a submitted registration with a running process")]
|
||||||
|
public async Task GivenASubmittedRegistrationWithARunningProcess()
|
||||||
|
=> _id = await new SubmitRegistration(_store, _workflow).HandleAsync(new SubmitRegistrationCommand(OwnerBsn));
|
||||||
|
|
||||||
|
[When("the zorgprofessional withdraws it")]
|
||||||
|
public async Task WhenTheZorgprofessionalWithdrawsIt()
|
||||||
|
=> _outcome = await new WithdrawRegistration(_store, _workflow).HandleAsync(
|
||||||
|
new WithdrawRegistrationCommand(_id, OwnerBsn));
|
||||||
|
|
||||||
|
[When("a different zorgprofessional tries to withdraw it")]
|
||||||
|
public async Task WhenADifferentZorgprofessionalTriesToWithdrawIt()
|
||||||
|
=> _outcome = await new WithdrawRegistration(_store, _workflow).HandleAsync(
|
||||||
|
new WithdrawRegistrationCommand(_id, "999999990"));
|
||||||
|
|
||||||
|
[Then("the withdrawal succeeds")]
|
||||||
|
public void ThenTheWithdrawalSucceeds() => Assert.Equal(WithdrawOutcome.Withdrawn, _outcome);
|
||||||
|
|
||||||
|
[Then("the withdrawal is reported not found")]
|
||||||
|
public void ThenTheWithdrawalIsReportedNotFound() => Assert.Equal(WithdrawOutcome.NotFound, _outcome);
|
||||||
|
|
||||||
|
[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 running process is cancelled")]
|
||||||
|
public void ThenTheRunningProcessIsCancelled()
|
||||||
|
=> Assert.Equal(InMemoryWorkflowClient.StartedProcessInstanceId, _workflow.WithdrawnProcessInstanceId);
|
||||||
|
|
||||||
|
[Then("the running process is not cancelled")]
|
||||||
|
public void ThenTheRunningProcessIsNotCancelled()
|
||||||
|
=> Assert.Null(_workflow.WithdrawnProcessInstanceId);
|
||||||
|
}
|
||||||
@@ -72,6 +72,9 @@ public sealed class CapturingDomainClient : IDomainClient
|
|||||||
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||||
=> Task.FromResult(true);
|
=> Task.FromResult(true);
|
||||||
|
|
||||||
|
public Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(true);
|
||||||
|
|
||||||
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||||
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
|
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Big.Application;
|
using Big.Application;
|
||||||
using Big.Domain;
|
using Big.Domain;
|
||||||
|
using Big.Infrastructure;
|
||||||
|
|
||||||
namespace Acceptance.Support;
|
namespace Acceptance.Support;
|
||||||
|
|
||||||
@@ -11,11 +12,15 @@ public sealed class InMemoryWorkflowClient : IWorkflowClient
|
|||||||
public const string StartedProcessInstanceId = "proc-acc-1";
|
public const string StartedProcessInstanceId = "proc-acc-1";
|
||||||
|
|
||||||
public RegistrationId? StartedFor { get; private set; }
|
public RegistrationId? StartedFor { get; private set; }
|
||||||
|
public DiplomaOrigin? StartedWithOrigin { get; private set; }
|
||||||
public string? WithdrawnProcessInstanceId { get; private set; }
|
public string? WithdrawnProcessInstanceId { get; private set; }
|
||||||
|
public string? CompletedDocumentWaitFor { get; private set; }
|
||||||
|
|
||||||
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
|
public Task<string> StartRegistrationProcessAsync(
|
||||||
|
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
StartedFor = registrationId;
|
StartedFor = registrationId;
|
||||||
|
StartedWithOrigin = diplomaOrigin;
|
||||||
return Task.FromResult(StartedProcessInstanceId);
|
return Task.FromResult(StartedProcessInstanceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +29,12 @@ public sealed class InMemoryWorkflowClient : IWorkflowClient
|
|||||||
WithdrawnProcessInstanceId = processInstanceId;
|
WithdrawnProcessInstanceId = processInstanceId;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task CompleteDocumentWaitAsync(string processInstanceId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
CompletedDocumentWaitFor = processInstanceId;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>An in-memory ACL stand-in: records the bsn it opened a zaak for and returns a fixed URL,
|
/// <summary>An in-memory ACL stand-in: records the bsn it opened a zaak for and returns a fixed URL,
|
||||||
@@ -73,6 +84,110 @@ public sealed class InMemoryUserTaskClient : IUserTaskClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>An in-memory Flowable stand-in for the beoordeling-escalation scenario (S-14): it models
|
||||||
|
/// one Beoordelen task per process instance — its candidate group and whether it is still open — and
|
||||||
|
/// the escalation jobs the non-interrupting 14-day boundary timer parks. It drives the escalation
|
||||||
|
/// worker's behaviour without a running Flowable; the timer firing live is the verify-domain check.</summary>
|
||||||
|
public sealed class InMemoryEscalatieClient : IBeoordelingEscalatieClient
|
||||||
|
{
|
||||||
|
private sealed class ParkedTask
|
||||||
|
{
|
||||||
|
public string CandidateGroup { get; set; } = "behandelaar";
|
||||||
|
public bool IsOpen { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Dictionary<string, ParkedTask> _tasks = [];
|
||||||
|
private readonly List<EscalatieJob> _parked = [];
|
||||||
|
private int _seq;
|
||||||
|
|
||||||
|
/// <summary>A registration parks at Beoordelen, claimable by the behandelaar group.</summary>
|
||||||
|
public string ParkBeoordeling()
|
||||||
|
{
|
||||||
|
var pid = $"pi-{++_seq}";
|
||||||
|
_tasks[pid] = new ParkedTask();
|
||||||
|
return pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The behandelaar completes the beoordeling before the timer fires: the task closes.</summary>
|
||||||
|
public void CompleteBeoordeling(string processInstanceId) => _tasks[processInstanceId].IsOpen = false;
|
||||||
|
|
||||||
|
/// <summary>The 14-day boundary timer fires: a non-interrupting token parks an escalation job.</summary>
|
||||||
|
public void FireEscalationTimer(string processInstanceId)
|
||||||
|
=> _parked.Add(new EscalatieJob($"job-{++_seq}", processInstanceId));
|
||||||
|
|
||||||
|
/// <summary>The candidate group that may currently pick the task up.</summary>
|
||||||
|
public string CandidateGroupFor(string processInstanceId) => _tasks[processInstanceId].CandidateGroup;
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<IReadOnlyList<EscalatieJob>>(_parked.Take(maxJobs).ToList());
|
||||||
|
|
||||||
|
public Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// No-op if the behandelaar already completed it — the timer/completion race (§8.6).
|
||||||
|
var task = _tasks[processInstanceId];
|
||||||
|
if (task.IsOpen)
|
||||||
|
task.CandidateGroup = "teamlead";
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_parked.RemoveAll(j => j.JobId == jobId);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An in-memory Flowable stand-in for the document-timeout scenario (S-10a): it models one
|
||||||
|
/// WachtOpDocumenten wait per process instance — whether it is still open and the registration it
|
||||||
|
/// correlates to — and the RegistratieVerlopen jobs the interrupting 30-day boundary timer parks. It
|
||||||
|
/// drives the timeout worker's behaviour without a running Flowable; the timer firing live is the
|
||||||
|
/// verify-domain check.</summary>
|
||||||
|
public sealed class InMemoryDocumentTimeoutClient : IRegistratieVerlopenClient
|
||||||
|
{
|
||||||
|
private sealed class Wait
|
||||||
|
{
|
||||||
|
public required RegistrationId RegistrationId { get; init; }
|
||||||
|
public bool IsWaiting { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Dictionary<string, Wait> _waits = [];
|
||||||
|
private readonly List<RegistratieVerlopenJob> _parked = [];
|
||||||
|
private int _seq;
|
||||||
|
|
||||||
|
/// <summary>A registration parks at WachtOpDocumenten, waiting for the citizen's documents.</summary>
|
||||||
|
public string ParkWaitingForDocuments(RegistrationId registrationId)
|
||||||
|
{
|
||||||
|
var pid = $"pi-{++_seq}";
|
||||||
|
_waits[pid] = new Wait { RegistrationId = registrationId };
|
||||||
|
return pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The documents arrive before the timer fires: the wait task closes, so the interrupting
|
||||||
|
/// timer no longer fires (mirrors the Workflow Client completing WachtOpDocumenten).</summary>
|
||||||
|
public void ReceiveDocuments(string processInstanceId) => _waits[processInstanceId].IsWaiting = false;
|
||||||
|
|
||||||
|
/// <summary>The 30-day interrupting boundary timer fires: if still waiting, it cancels the wait and
|
||||||
|
/// parks a RegistratieVerlopen job carrying the correlated registration id. A no-op if the documents
|
||||||
|
/// already arrived (the wait/timer race, §8.6).</summary>
|
||||||
|
public void FireDocumentTimer(string processInstanceId)
|
||||||
|
{
|
||||||
|
var wait = _waits[processInstanceId];
|
||||||
|
if (!wait.IsWaiting)
|
||||||
|
return;
|
||||||
|
wait.IsWaiting = false;
|
||||||
|
_parked.Add(new RegistratieVerlopenJob($"job-{++_seq}", wait.RegistrationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<RegistratieVerlopenJob>> AcquireRegistratieVerlopenJobsAsync(int maxJobs, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<IReadOnlyList<RegistratieVerlopenJob>>(_parked.Take(maxJobs).ToList());
|
||||||
|
|
||||||
|
public Task CompleteRegistratieVerlopenJobAsync(string jobId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_parked.RemoveAll(j => j.JobId == jobId);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
|
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
|
||||||
public sealed class InMemoryRegistrationStore : IRegistrationStore
|
public sealed class InMemoryRegistrationStore : IRegistrationStore
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12): a zorgprofessional logs in via mock
|
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12 + S-10a): a zorgprofessional logs in via
|
||||||
// DigiD and submits through the self-service portal → BFF → domain; the entry appears in the openbaar
|
// mock DigiD and submits through the self-service portal → BFF → domain; the entry appears in the
|
||||||
// register as INGEDIEND; a behandelaar then logs in to the behandel portal, finds the registration in
|
// openbaar register as INGEDIEND; the citizen supplies the documents the process is waiting for
|
||||||
// the werkbak, and approves it (goedkeuren); the decision completes the Flowable Beoordelen task and
|
// (S-10a); a behandelaar then logs in to the behandel portal, finds the registration in the werkbak,
|
||||||
// flows via the ACL → NRC → event-subscriber → projection, and the openbaar register shows INGESCHREVEN.
|
// and approves it (goedkeuren); the decision completes the Flowable Beoordelen task and flows via the
|
||||||
test('DigiD submit → public INGEDIEND → behandelaar goedkeurt → public INGESCHREVEN', async ({ page }) => {
|
// ACL → NRC → event-subscriber → projection, and the openbaar register shows INGESCHREVEN.
|
||||||
|
test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt → public INGESCHREVEN', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
@@ -26,42 +30,53 @@ test('DigiD submit → public INGEDIEND → behandelaar goedkeurt → public ING
|
|||||||
expect(reference, 'the confirmation shows a registration reference').toBeTruthy();
|
expect(reference, 'the confirmation shows a registration reference').toBeTruthy();
|
||||||
|
|
||||||
// The openbaar register (anonymous, its own origin) shows the submitted entry once the projection
|
// The openbaar register (anonymous, its own origin) shows the submitted entry once the projection
|
||||||
// catches up. The projection updates asynchronously (NRC → event-subscriber), and the register loads
|
// catches up. We check it on a SEPARATE page so the self-service tab keeps its (in-memory) submitted
|
||||||
// on open, so reload until *this* submission's row appears. We poll on the reference cell (not a
|
// state — the "Documenten aanleveren" action below acts on that same session. The projection updates
|
||||||
// generic INGEDIEND cell): the shared verify stack already holds INGEDIEND rows from earlier checks,
|
// asynchronously (NRC → event-subscriber), so reload until *this* submission's row appears. We poll
|
||||||
// so a status-only poll would short-circuit on a stale row before our row is projected.
|
// on the reference cell (not a generic INGEDIEND cell): the shared verify stack already holds
|
||||||
await page.goto('http://openbaar/');
|
// INGEDIEND rows from earlier checks, so a status-only poll would short-circuit on a stale row.
|
||||||
await expect(page.getByRole('heading', { name: /Openbaar BIG-register/i })).toBeVisible();
|
const staff = await context.newPage();
|
||||||
|
await staff.goto('http://openbaar/');
|
||||||
|
await expect(staff.getByRole('heading', { name: /Openbaar BIG-register/i })).toBeVisible();
|
||||||
|
|
||||||
// #78: the reference shown in the public register must be the exact one the citizen saw on the
|
// #78: the reference shown in the public register must be the exact one the citizen saw on the
|
||||||
// submit confirmation — no mismatch between the two portals.
|
// submit confirmation — no mismatch between the two portals.
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
await page.reload();
|
await staff.reload();
|
||||||
return page.getByRole('cell', { name: reference }).count();
|
return staff.getByRole('cell', { name: reference }).count();
|
||||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||||
.toBeGreaterThan(0);
|
.toBeGreaterThan(0);
|
||||||
await expect(page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
|
await expect(staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
|
||||||
.toBeVisible();
|
.toBeVisible();
|
||||||
|
|
||||||
// A behandelaar picks the registration up in the behandel-portal werkbak and approves it
|
// Provide the documents the registration is waiting for (S-10a), on the still-open self-service tab.
|
||||||
// (goedkeuren) — the S-12 flow that replaces the temporary admin endpoint. Navigating here switches
|
// The process parks at WachtOpDocumenten only after the zaak is opened; the INGEDIEND row above proves
|
||||||
// to the medewerker realm (a different Keycloak realm than the citizen's digid session).
|
// the zaak exists — so the OpenZaak worker has completed and the process is now at the wait — which is
|
||||||
await page.goto('http://behandel/');
|
// why we supply the documents here rather than right after submit, when the trigger would race the
|
||||||
await page.locator('#username').fill('merel-behandelaar');
|
// wait and no-op. (S-10b turns this into a real file upload; here it is the trigger that unblocks
|
||||||
await page.locator('#password').fill('test123');
|
// beoordeling.)
|
||||||
await page.locator('#kc-login').click();
|
await page.getByRole('button', { name: /documenten aanleveren/i }).click();
|
||||||
|
await expect(page.getByText(/documenten zijn aangeleverd/i)).toBeVisible();
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: /Werkbak/i })).toBeVisible();
|
// A behandelaar picks the registration up in the behandel-portal werkbak and approves it (goedkeuren)
|
||||||
|
// — the S-12 flow that replaces the temporary admin endpoint. The staff tab switches to the
|
||||||
|
// medewerker realm (a different Keycloak realm than the citizen's digid session).
|
||||||
|
await staff.goto('http://behandel/');
|
||||||
|
await staff.locator('#username').fill('merel-behandelaar');
|
||||||
|
await staff.locator('#password').fill('test123');
|
||||||
|
await staff.locator('#kc-login').click();
|
||||||
|
|
||||||
// The registration parks at the Beoordelen user task only after the worker has opened its zaak, so
|
await expect(staff.getByRole('heading', { name: /Werkbak/i })).toBeVisible();
|
||||||
|
|
||||||
|
// The registration reaches the Beoordelen user task only after its documents are provided (above), so
|
||||||
// it appears in the werkbak asynchronously — reload until this reference's row shows up. Target the
|
// it appears in the werkbak asynchronously — reload until this reference's row shows up. Target the
|
||||||
// decide button by reference (not a generic "Goedkeuren"): the shared verify stack holds other open
|
// decide button by reference (not a generic "Goedkeuren"): the shared verify stack holds other open
|
||||||
// tasks, so a positional match could act on someone else's registration.
|
// tasks, so a positional match could act on someone else's registration.
|
||||||
const goedkeuren = page.getByRole('button', { name: `Goedkeuren ${reference}` });
|
const goedkeuren = staff.getByRole('button', { name: `Goedkeuren ${reference}` });
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
await page.reload();
|
await staff.reload();
|
||||||
return goedkeuren.count();
|
return goedkeuren.count();
|
||||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||||
.toBeGreaterThan(0);
|
.toBeGreaterThan(0);
|
||||||
@@ -69,7 +84,7 @@ test('DigiD submit → public INGEDIEND → behandelaar goedkeurt → public ING
|
|||||||
// Click and wait for the decide POST to finish (204) BEFORE leaving the page. `click()` only
|
// Click and wait for the decide POST to finish (204) BEFORE leaving the page. `click()` only
|
||||||
// dispatches the request; navigating away immediately cancels it in flight (nginx logs a 499) and
|
// dispatches the request; navigating away immediately cancels it in flight (nginx logs a 499) and
|
||||||
// the decision never reaches the domain — so the registration would stay INGEDIEND.
|
// the decision never reaches the domain — so the registration would stay INGEDIEND.
|
||||||
const decided = page.waitForResponse(
|
const decided = staff.waitForResponse(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.url().includes(`/behandel/registrations/${reference}/decide`) &&
|
r.url().includes(`/behandel/registrations/${reference}/decide`) &&
|
||||||
r.request().method() === 'POST',
|
r.request().method() === 'POST',
|
||||||
@@ -79,11 +94,11 @@ test('DigiD submit → public INGEDIEND → behandelaar goedkeurt → public ING
|
|||||||
|
|
||||||
// The approval flows back to the projection; back on the openbaar register *our* row (matched by
|
// The approval flows back to the projection; back on the openbaar register *our* row (matched by
|
||||||
// its reference) now shows INGESCHREVEN.
|
// its reference) now shows INGESCHREVEN.
|
||||||
await page.goto('http://openbaar/');
|
await staff.goto('http://openbaar/');
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
await page.reload();
|
await staff.reload();
|
||||||
return page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count();
|
return staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count();
|
||||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||||
.toBeGreaterThan(0);
|
.toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
// S-11 (Flow 3): a zorgprofessional logs in via mock DigiD, submits a registration, then withdraws
|
||||||
|
// it ("trek aanvraag in") from the self-service portal. The withdrawal goes portal → BFF (owner-
|
||||||
|
// scoped by the DigiD token's bsn) → domain, which cancels the running workflow (ADR-0014); the page
|
||||||
|
// then confirms the registration is ingetrokken.
|
||||||
|
test('DigiD submit → trek aanvraag in → self-service confirms ingetrokken', async ({ page }) => {
|
||||||
|
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await page.locator('#username').fill('jan-burger');
|
||||||
|
await page.locator('#password').fill('test123');
|
||||||
|
await page.locator('#kc-login').click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /indienen/i }).click();
|
||||||
|
|
||||||
|
// The BFF accepted it and the page shows the confirmation with the reference.
|
||||||
|
await expect(page.getByText(/ontvangen/i)).toBeVisible();
|
||||||
|
|
||||||
|
// Withdraw it. The confirmation of withdrawal appears only after the decide POST completes (204),
|
||||||
|
// so awaiting the "ingetrokken" text also proves the request landed — no premature navigation.
|
||||||
|
await page.getByRole('button', { name: /trek aanvraag in/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText(/is ingetrokken/i)).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<definitions xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/"
|
||||||
|
xmlns:flowable="http://flowable.org/dmn"
|
||||||
|
id="diplomaEligibilityDefinitions"
|
||||||
|
name="Diploma eligibility"
|
||||||
|
namespace="http://respellion.nl/big/dmn">
|
||||||
|
<!-- Diploma-eligibility decision (S-13, ADR-0016). Evaluated inline by the registratie process as a
|
||||||
|
BPMN DMN service task: given the diploma's origin, it sets the `route` the process should take.
|
||||||
|
A foreign (Buitenlands) diploma routes through the extra CBGV-advies assessment step; a domestic
|
||||||
|
one (or anything else) goes DIRECT to beoordeling. FIRST hit policy: the foreign rule wins, and
|
||||||
|
the empty-input catch-all is the default.
|
||||||
|
NB: the comment lives INSIDE <definitions> on purpose — Flowable's DMN XML converter chokes on a
|
||||||
|
comment between the XML declaration and the root element ("XMLStreamReader not in START_DOCUMENT
|
||||||
|
or START_ELEMENT state"), unlike its BPMN converter. -->
|
||||||
|
<decision id="diploma-eligibility" name="Diploma eligibility">
|
||||||
|
<decisionTable id="dt-diploma-eligibility" hitPolicy="FIRST">
|
||||||
|
<input id="in-origin" label="Diploma origin">
|
||||||
|
<inputExpression id="ie-origin" typeRef="string">
|
||||||
|
<text>diplomaOrigin</text>
|
||||||
|
</inputExpression>
|
||||||
|
</input>
|
||||||
|
<output id="out-route" label="Route" name="route" typeRef="string"/>
|
||||||
|
<rule id="rule-foreign">
|
||||||
|
<inputEntry id="rule-foreign-in">
|
||||||
|
<text>"Buitenlands"</text>
|
||||||
|
</inputEntry>
|
||||||
|
<outputEntry id="rule-foreign-out">
|
||||||
|
<text>"CBGV_ADVIES"</text>
|
||||||
|
</outputEntry>
|
||||||
|
</rule>
|
||||||
|
<rule id="rule-default">
|
||||||
|
<inputEntry id="rule-default-in">
|
||||||
|
<text></text>
|
||||||
|
</inputEntry>
|
||||||
|
<outputEntry id="rule-default-out">
|
||||||
|
<text>"DIRECT"</text>
|
||||||
|
</outputEntry>
|
||||||
|
</rule>
|
||||||
|
</decisionTable>
|
||||||
|
</decision>
|
||||||
|
</definitions>
|
||||||
+176
-18
@@ -1,6 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||||
xmlns:flowable="http://flowable.org/bpmn"
|
xmlns:flowable="http://flowable.org/bpmn"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
|
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
|
||||||
xmlns:omgdc="http://www.omg.org/spec/DD/20100524/DC"
|
xmlns:omgdc="http://www.omg.org/spec/DD/20100524/DC"
|
||||||
xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI"
|
xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI"
|
||||||
@@ -15,7 +16,22 @@
|
|||||||
S-11 adds withdrawal: while parked at Beoordelen the citizen can trek de aanvraag in — an
|
S-11 adds withdrawal: while parked at Beoordelen the citizen can trek de aanvraag in — an
|
||||||
interrupting message boundary event (RegistratieIngetrokken) cancels the task and ends the
|
interrupting message boundary event (RegistratieIngetrokken) cancels the task and ends the
|
||||||
process via a dedicated "ingetrokken" end (ADR-0014). The Workflow Client delivers the message
|
process via a dedicated "ingetrokken" end (ADR-0014). The Workflow Client delivers the message
|
||||||
to the task's execution; the BPMN owns the cancellation path. -->
|
to the task's execution; the BPMN owns the cancellation path.
|
||||||
|
S-14 adds escalation: a NON-interrupting boundary timer (P14D) on Beoordelen. If a behandelaar
|
||||||
|
has not picked the task up within 14 days it fires a parallel token to an external-worker task
|
||||||
|
(BeoordelingEscaleren); the Workflow Client reassigns the still-open Beoordelen task from the
|
||||||
|
behandelaar group to teamlead (ADR-0015). The Beoordelen task stays open throughout — the timer
|
||||||
|
only changes who may claim it.
|
||||||
|
S-13 adds diploma-eligibility routing: between the document wait and Beoordelen a DMN service
|
||||||
|
task (flowable:type="dmn") 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).
|
||||||
|
S-10a adds the document wait: right after the zaak is opened the process parks at a
|
||||||
|
WachtOpDocumenten user task with an INTERRUPTING P30D boundary timer. "Documents received"
|
||||||
|
(the S-10b upload, via the Workflow Client) completes the task and the process continues to the
|
||||||
|
diploma routing; if the 30 days lapse first the timer cancels the task and runs the
|
||||||
|
RegistratieVerlopen external-worker task, whose worker expires the registration to VERLOPEN,
|
||||||
|
ending the process as "verlopen" (ADR-0017). -->
|
||||||
<message id="Message_Ingetrokken" name="RegistratieIngetrokken"/>
|
<message id="Message_Ingetrokken" name="RegistratieIngetrokken"/>
|
||||||
|
|
||||||
<process id="registratie" name="Registratie ontvangen" isExecutable="true">
|
<process id="registratie" name="Registratie ontvangen" isExecutable="true">
|
||||||
@@ -28,7 +44,62 @@
|
|||||||
flowable:type="external-worker"
|
flowable:type="external-worker"
|
||||||
flowable:topic="OpenZaakAanmaken"/>
|
flowable:topic="OpenZaakAanmaken"/>
|
||||||
|
|
||||||
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="Beoordelen"/>
|
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="WachtOpDocumenten"/>
|
||||||
|
|
||||||
|
<!-- S-10a: wait for the citizen's documents (diploma). The process parks here; "documents received"
|
||||||
|
(the S-10b upload path) completes the task via the Workflow Client. An INTERRUPTING P30D
|
||||||
|
boundary timer cancels the case if the documents never arrive (ADR-0017). -->
|
||||||
|
<userTask id="WachtOpDocumenten" name="Wacht op documenten"/>
|
||||||
|
|
||||||
|
<sequenceFlow id="flow2doc" sourceRef="WachtOpDocumenten" targetRef="DiplomaEligibiliteit"/>
|
||||||
|
|
||||||
|
<boundaryEvent id="DocumentenTimer" attachedToRef="WachtOpDocumenten" cancelActivity="true">
|
||||||
|
<timerEventDefinition>
|
||||||
|
<timeDuration>P30D</timeDuration>
|
||||||
|
</timerEventDefinition>
|
||||||
|
</boundaryEvent>
|
||||||
|
|
||||||
|
<sequenceFlow id="flow7" sourceRef="DocumentenTimer" targetRef="RegistratieVerlopen"/>
|
||||||
|
|
||||||
|
<!-- On timeout: an external-worker task the Workflow Client picks up to expire the registration to
|
||||||
|
VERLOPEN (S-10a). Its topic mirrors OpenZaakAanmaken/BeoordelingEscaleren. -->
|
||||||
|
<serviceTask id="RegistratieVerlopen" name="Registratie laten verlopen"
|
||||||
|
flowable:type="external-worker"
|
||||||
|
flowable:topic="RegistratieVerlopen"/>
|
||||||
|
|
||||||
|
<sequenceFlow id="flow8" sourceRef="RegistratieVerlopen" targetRef="endVerlopen"/>
|
||||||
|
|
||||||
|
<endEvent id="endVerlopen" name="Registratie verlopen"/>
|
||||||
|
|
||||||
|
<!-- S-13: evaluate the diploma-eligibility DMN inline (ADR-0016). A Flowable DMN service task
|
||||||
|
(flowable:type="dmn" — NOT a businessRuleTask, whose default implementation is the legacy
|
||||||
|
Drools/KIE one that flowable-rest does not bundle) runs the deployed `diploma-eligibility`
|
||||||
|
decision against the diplomaOrigin start variable and sets the `route` output as a process
|
||||||
|
variable. The gateway then routes a foreign diploma through CBGV-advies, a domestic one
|
||||||
|
straight to Beoordelen. -->
|
||||||
|
<serviceTask id="DiplomaEligibiliteit" name="Diploma-eligibiliteit bepalen" flowable:type="dmn">
|
||||||
|
<extensionElements>
|
||||||
|
<flowable:field name="decisionTableReferenceKey">
|
||||||
|
<flowable:string><![CDATA[diploma-eligibility]]></flowable:string>
|
||||||
|
</flowable:field>
|
||||||
|
</extensionElements>
|
||||||
|
</serviceTask>
|
||||||
|
|
||||||
|
<sequenceFlow id="flow2a" sourceRef="DiplomaEligibiliteit" targetRef="RouteOpDiploma"/>
|
||||||
|
|
||||||
|
<exclusiveGateway id="RouteOpDiploma" name="Buitenlands diploma?" default="flowDirect"/>
|
||||||
|
|
||||||
|
<sequenceFlow id="flowCbgv" sourceRef="RouteOpDiploma" targetRef="CBGVAdvies">
|
||||||
|
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${route == 'CBGV_ADVIES'}]]></conditionExpression>
|
||||||
|
</sequenceFlow>
|
||||||
|
|
||||||
|
<!-- The extra CBGV-style assessment for foreign diplomas (PRD flow 4). A CBGV medewerker
|
||||||
|
completes it, after which the case continues to the regular beoordeling. -->
|
||||||
|
<userTask id="CBGVAdvies" name="CBGV-advies" flowable:candidateGroups="cbgv"/>
|
||||||
|
|
||||||
|
<sequenceFlow id="flowCbgvNaarBeoordelen" sourceRef="CBGVAdvies" targetRef="Beoordelen"/>
|
||||||
|
|
||||||
|
<sequenceFlow id="flowDirect" sourceRef="RouteOpDiploma" targetRef="Beoordelen"/>
|
||||||
|
|
||||||
<userTask id="Beoordelen" name="Beoordelen" flowable:candidateGroups="behandelaar"/>
|
<userTask id="Beoordelen" name="Beoordelen" flowable:candidateGroups="behandelaar"/>
|
||||||
|
|
||||||
@@ -45,6 +116,25 @@
|
|||||||
<sequenceFlow id="flow4" sourceRef="Ingetrokken" targetRef="endIngetrokken"/>
|
<sequenceFlow id="flow4" sourceRef="Ingetrokken" targetRef="endIngetrokken"/>
|
||||||
|
|
||||||
<endEvent id="endIngetrokken" name="Registratie ingetrokken"/>
|
<endEvent id="endIngetrokken" name="Registratie ingetrokken"/>
|
||||||
|
|
||||||
|
<!-- Escalation (S-14): non-interrupting P14D boundary timer on Beoordelen. On timeout a parallel
|
||||||
|
token runs EscaleerBeoordeling, an external-worker task the Workflow Client picks up to
|
||||||
|
reassign the still-open Beoordelen task from behandelaar to teamlead (ADR-0015). -->
|
||||||
|
<boundaryEvent id="EscaleerTimer" attachedToRef="Beoordelen" cancelActivity="false">
|
||||||
|
<timerEventDefinition>
|
||||||
|
<timeDuration>P14D</timeDuration>
|
||||||
|
</timerEventDefinition>
|
||||||
|
</boundaryEvent>
|
||||||
|
|
||||||
|
<sequenceFlow id="flow5" sourceRef="EscaleerTimer" targetRef="EscaleerBeoordeling"/>
|
||||||
|
|
||||||
|
<serviceTask id="EscaleerBeoordeling" name="Beoordeling escaleren"
|
||||||
|
flowable:type="external-worker"
|
||||||
|
flowable:topic="BeoordelingEscaleren"/>
|
||||||
|
|
||||||
|
<sequenceFlow id="flow6" sourceRef="EscaleerBeoordeling" targetRef="endEscaleren"/>
|
||||||
|
|
||||||
|
<endEvent id="endEscaleren" name="Beoordeling geëscaleerd"/>
|
||||||
</process>
|
</process>
|
||||||
|
|
||||||
<bpmndi:BPMNDiagram id="diagram">
|
<bpmndi:BPMNDiagram id="diagram">
|
||||||
@@ -53,35 +143,103 @@
|
|||||||
<omgdc:Bounds x="100" y="100" width="30" height="30"/>
|
<omgdc:Bounds x="100" y="100" width="30" height="30"/>
|
||||||
</bpmndi:BPMNShape>
|
</bpmndi:BPMNShape>
|
||||||
<bpmndi:BPMNShape id="s_task" bpmnElement="OpenZaakAanmaken">
|
<bpmndi:BPMNShape id="s_task" bpmnElement="OpenZaakAanmaken">
|
||||||
<omgdc:Bounds x="200" y="85" width="120" height="60"/>
|
<omgdc:Bounds x="180" y="85" width="120" height="60"/>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="s_wacht" bpmnElement="WachtOpDocumenten">
|
||||||
|
<omgdc:Bounds x="340" y="85" width="120" height="60"/>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="s_documentenTimer" bpmnElement="DocumentenTimer">
|
||||||
|
<omgdc:Bounds x="385" y="130" width="30" height="30"/>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="s_verlopen" bpmnElement="RegistratieVerlopen">
|
||||||
|
<omgdc:Bounds x="340" y="220" width="120" height="60"/>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="s_endVerlopen" bpmnElement="endVerlopen">
|
||||||
|
<omgdc:Bounds x="510" y="235" width="30" height="30"/>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="s_dmn" bpmnElement="DiplomaEligibiliteit">
|
||||||
|
<omgdc:Bounds x="510" y="85" width="120" height="60"/>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="s_route" bpmnElement="RouteOpDiploma" isMarkerVisible="true">
|
||||||
|
<omgdc:Bounds x="680" y="90" width="40" height="40"/>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="s_cbgv" bpmnElement="CBGVAdvies">
|
||||||
|
<omgdc:Bounds x="660" y="200" width="120" height="60"/>
|
||||||
</bpmndi:BPMNShape>
|
</bpmndi:BPMNShape>
|
||||||
<bpmndi:BPMNShape id="s_beoordelen" bpmnElement="Beoordelen">
|
<bpmndi:BPMNShape id="s_beoordelen" bpmnElement="Beoordelen">
|
||||||
<omgdc:Bounds x="390" y="85" width="120" height="60"/>
|
<omgdc:Bounds x="790" y="85" width="120" height="60"/>
|
||||||
</bpmndi:BPMNShape>
|
</bpmndi:BPMNShape>
|
||||||
<bpmndi:BPMNShape id="s_end" bpmnElement="end">
|
<bpmndi:BPMNShape id="s_end" bpmnElement="end">
|
||||||
<omgdc:Bounds x="580" y="100" width="30" height="30"/>
|
<omgdc:Bounds x="970" y="100" width="30" height="30"/>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="s_ingetrokken" bpmnElement="Ingetrokken">
|
||||||
|
<omgdc:Bounds x="850" y="135" width="30" height="30"/>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="s_endIngetrokken" bpmnElement="endIngetrokken">
|
||||||
|
<omgdc:Bounds x="850" y="250" width="30" height="30"/>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="s_escaleerTimer" bpmnElement="EscaleerTimer">
|
||||||
|
<omgdc:Bounds x="860" y="70" width="30" height="30"/>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="s_escaleerBeoordeling" bpmnElement="EscaleerBeoordeling">
|
||||||
|
<omgdc:Bounds x="960" y="20" width="120" height="60"/>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="s_endEscaleren" bpmnElement="endEscaleren">
|
||||||
|
<omgdc:Bounds x="1130" y="35" width="30" height="30"/>
|
||||||
</bpmndi:BPMNShape>
|
</bpmndi:BPMNShape>
|
||||||
<bpmndi:BPMNEdge id="e_flow1" bpmnElement="flow1">
|
<bpmndi:BPMNEdge id="e_flow1" bpmnElement="flow1">
|
||||||
<omgdi:waypoint x="130" y="115"/>
|
<omgdi:waypoint x="130" y="115"/>
|
||||||
<omgdi:waypoint x="200" y="115"/>
|
<omgdi:waypoint x="180" y="115"/>
|
||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
<bpmndi:BPMNEdge id="e_flow2" bpmnElement="flow2">
|
<bpmndi:BPMNEdge id="e_flow2" bpmnElement="flow2">
|
||||||
<omgdi:waypoint x="320" y="115"/>
|
<omgdi:waypoint x="300" y="115"/>
|
||||||
<omgdi:waypoint x="390" y="115"/>
|
<omgdi:waypoint x="340" y="115"/>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="e_flow2doc" bpmnElement="flow2doc">
|
||||||
|
<omgdi:waypoint x="460" y="115"/>
|
||||||
|
<omgdi:waypoint x="510" y="115"/>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="e_flow7" bpmnElement="flow7">
|
||||||
|
<omgdi:waypoint x="400" y="160"/>
|
||||||
|
<omgdi:waypoint x="400" y="220"/>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="e_flow8" bpmnElement="flow8">
|
||||||
|
<omgdi:waypoint x="460" y="250"/>
|
||||||
|
<omgdi:waypoint x="510" y="250"/>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="e_flow2a" bpmnElement="flow2a">
|
||||||
|
<omgdi:waypoint x="630" y="115"/>
|
||||||
|
<omgdi:waypoint x="680" y="110"/>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="e_flowDirect" bpmnElement="flowDirect">
|
||||||
|
<omgdi:waypoint x="720" y="110"/>
|
||||||
|
<omgdi:waypoint x="790" y="115"/>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="e_flowCbgv" bpmnElement="flowCbgv">
|
||||||
|
<omgdi:waypoint x="700" y="130"/>
|
||||||
|
<omgdi:waypoint x="700" y="200"/>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="e_flowCbgvNaarBeoordelen" bpmnElement="flowCbgvNaarBeoordelen">
|
||||||
|
<omgdi:waypoint x="780" y="230"/>
|
||||||
|
<omgdi:waypoint x="820" y="230"/>
|
||||||
|
<omgdi:waypoint x="820" y="145"/>
|
||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
<bpmndi:BPMNEdge id="e_flow3" bpmnElement="flow3">
|
<bpmndi:BPMNEdge id="e_flow3" bpmnElement="flow3">
|
||||||
<omgdi:waypoint x="510" y="115"/>
|
<omgdi:waypoint x="910" y="115"/>
|
||||||
<omgdi:waypoint x="580" y="115"/>
|
<omgdi:waypoint x="970" y="115"/>
|
||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
<bpmndi:BPMNShape id="s_ingetrokken" bpmnElement="Ingetrokken">
|
|
||||||
<omgdc:Bounds x="435" y="135" width="30" height="30"/>
|
|
||||||
</bpmndi:BPMNShape>
|
|
||||||
<bpmndi:BPMNShape id="s_endIngetrokken" bpmnElement="endIngetrokken">
|
|
||||||
<omgdc:Bounds x="435" y="220" width="30" height="30"/>
|
|
||||||
</bpmndi:BPMNShape>
|
|
||||||
<bpmndi:BPMNEdge id="e_flow4" bpmnElement="flow4">
|
<bpmndi:BPMNEdge id="e_flow4" bpmnElement="flow4">
|
||||||
<omgdi:waypoint x="450" y="165"/>
|
<omgdi:waypoint x="865" y="165"/>
|
||||||
<omgdi:waypoint x="450" y="220"/>
|
<omgdi:waypoint x="865" y="250"/>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="e_flow5" bpmnElement="flow5">
|
||||||
|
<omgdi:waypoint x="875" y="70"/>
|
||||||
|
<omgdi:waypoint x="875" y="50"/>
|
||||||
|
<omgdi:waypoint x="960" y="50"/>
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="e_flow6" bpmnElement="flow6">
|
||||||
|
<omgdi:waypoint x="1080" y="50"/>
|
||||||
|
<omgdi:waypoint x="1130" y="50"/>
|
||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
</bpmndi:BPMNPlane>
|
</bpmndi:BPMNPlane>
|
||||||
</bpmndi:BPMNDiagram>
|
</bpmndi:BPMNDiagram>
|
||||||
|
|||||||
Reference in New Issue
Block a user