Compare commits
11
Commits
main
...
d943b54ce8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d943b54ce8 | ||
|
|
00c5077fe4 | ||
|
|
5180253826 | ||
|
|
9bd71f1e78 | ||
|
|
3f04cb856f | ||
|
|
9421aa007a | ||
|
|
c536c965de | ||
|
|
5add817c10 | ||
|
|
11ef26d8cc | ||
|
|
f39ec2afa3 | ||
|
|
67a60e7f63 |
+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 (workflow spine) — #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 via an external-worker (mirrors S-14 escalation / S-11 withdrawal). Backend only, no frontend.
|
||||||
|
|
||||||
|
**Acceptance:** BDD both branches (documents-in-time vs timeout-cancel); live timer-fire via the management-API "move" idiom.
|
||||||
|
|
||||||
|
#### S-10b · Diploma upload via ACL Documenten API + self-service portal — #103
|
||||||
|
|
||||||
|
**Outcome:** the self-service portal supports diploma 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). A successful upload completes the `WachtOpDocumenten` task from S-10a. Depends on #102.
|
||||||
|
|
||||||
|
**Acceptance:** BDD upload-completes-wait-task; Playwright e2e upload journey.
|
||||||
|
|
||||||
### S-11 · Withdrawal (Flow 3)
|
### S-11 · Withdrawal (Flow 3)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# 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* that calls it (the portal upload) is wired in S-10b; S-10a builds and tests
|
||||||
|
the completion path with the trigger stubbed (the live check completes the task directly to prove the
|
||||||
|
in-time branch, and the domain acceptance drives the worker against an in-memory stand-in).
|
||||||
|
|
||||||
|
## 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 live-check blocks
|
||||||
|
(S-11/S-12b/S-13/S-14) must complete that task first — a small, explicit step standing in for the
|
||||||
|
S-10b upload until it lands.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
+52
-1
@@ -361,7 +361,8 @@ DOM=http://localhost:8080 # domain service
|
|||||||
curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \
|
curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \
|
||||||
-d '{"bsn":"123456782","diplomaOrigin":"Buitenlands"}' | grep -i '^location:'
|
-d '{"bsn":"123456782","diplomaOrigin":"Buitenlands"}' | grep -i '^location:'
|
||||||
#
|
#
|
||||||
# 2. Once the zaak is opened, the process parks at the CBGV-advies task (NOT Beoordelen). In Flowable:
|
# 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
|
FL=http://localhost:8090/flowable-rest/service
|
||||||
curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
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
|
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' | python3 -m json.tool
|
||||||
@@ -382,3 +383,53 @@ domestic: `Beoordelen` directly (§8.2, ADR-0016).
|
|||||||
> The domestic/foreign paths are covered by the `Een diploma op herkomst routeren` acceptance
|
> 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
|
> 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.
|
> 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 real upload trigger (portal → BFF → domain → ACL → Documenten API) is S-10b; until then the
|
||||||
|
"documents received" step is completing the task in Flowable, and the timeout is demonstrated by
|
||||||
|
firing the 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.
|
||||||
|
|||||||
@@ -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=""
|
||||||
@@ -171,6 +193,7 @@ locf="$(docker run --rm --network "$net" curlimages/curl:latest \
|
|||||||
[ -n "$locf" ] || { echo "FAIL — foreign POST /registrations returned no Location" >&2; exit 1; }
|
[ -n "$locf" ] || { echo "FAIL — foreign POST /registrations returned no Location" >&2; exit 1; }
|
||||||
reg_idf="${locf##*/}"
|
reg_idf="${locf##*/}"
|
||||||
echo ">> foreign registration $reg_idf"
|
echo ">> foreign registration $reg_idf"
|
||||||
|
complete_wacht "$reg_idf"
|
||||||
|
|
||||||
echo ">> polling Flowable for its CBGV-advies task (foreign diplomas route here first)"
|
echo ">> polling Flowable for its CBGV-advies task (foreign diplomas route here first)"
|
||||||
cbgv_task=""
|
cbgv_task=""
|
||||||
@@ -241,6 +264,8 @@ except Exception:
|
|||||||
d={}
|
d={}
|
||||||
print(((d.get('data') or [{}])[0]).get('id',''))"; }
|
print(((d.get('data') or [{}])[0]).get('id',''))"; }
|
||||||
|
|
||||||
|
complete_wacht "$reg_id3"
|
||||||
|
|
||||||
echo ">> polling Flowable for its Beoordelen task"
|
echo ">> polling Flowable for its Beoordelen task"
|
||||||
task_id3=""; pid3=""
|
task_id3=""; pid3=""
|
||||||
for _ in $(seq 1 30); do
|
for _ in $(seq 1 30); do
|
||||||
@@ -278,4 +303,50 @@ for _ in $(seq 1 30); do
|
|||||||
done
|
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; }
|
[ -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"
|
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
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<Flowa
|
|||||||
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<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>();
|
||||||
@@ -32,12 +33,17 @@ 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<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
|
// The escalation worker polls the BeoordelingEscaleren jobs the 14-day timer parks and reassigns
|
||||||
// each overdue beoordeling to the teamlead (S-14).
|
// each overdue beoordeling to the teamlead (S-14).
|
||||||
builder.Services.AddHostedService<BeoordelingEscalatiePump>();
|
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();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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: a redelivered job whose registration is already VERLOPEN is a
|
||||||
|
/// no-op — not persisted again (§8.6, at-least-once delivery). 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}.");
|
||||||
|
|
||||||
|
// A redelivered job whose registration is already VERLOPEN completes without persisting again.
|
||||||
|
if (registration.Status == RegistrationStatus.Verlopen)
|
||||||
|
return;
|
||||||
|
|
||||||
|
registration.Expire();
|
||||||
|
await store.SaveAsync(registration, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,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>
|
||||||
@@ -95,3 +103,11 @@ public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId);
|
|||||||
/// once the 14-day boundary timer fires (ADR-0015).
|
/// once the 14-day boundary timer fires (ADR-0015).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record EscalatieJob(string JobId, string ProcessInstanceId);
|
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);
|
||||||
|
|||||||
@@ -133,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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ 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, IBeoordelingEscalatieClient
|
: IWorkflowClient, IExternalWorkerClient, IUserTaskClient, IBeoordelingEscalatieClient, IRegistratieVerlopenClient
|
||||||
{
|
{
|
||||||
private const string Topic = "OpenZaakAanmaken";
|
private const string Topic = "OpenZaakAanmaken";
|
||||||
private const string EscalatieTopic = "BeoordelingEscaleren";
|
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 BehandelaarGroup = "behandelaar";
|
||||||
private const string TeamleadGroup = "teamlead";
|
private const string TeamleadGroup = "teamlead";
|
||||||
private const string RegistrationIdVariable = "registrationId";
|
private const string RegistrationIdVariable = "registrationId";
|
||||||
@@ -114,6 +116,25 @@ 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)
|
public async Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var request = new AcquireJobsRequest(EscalatieTopic, options.LockDuration, maxJobs, options.WorkerId);
|
var request = new AcquireJobsRequest(EscalatieTopic, options.LockDuration, maxJobs, options.WorkerId);
|
||||||
@@ -155,6 +176,23 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
|||||||
response.EnsureSuccessStatusCode();
|
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));
|
||||||
|
|||||||
@@ -36,3 +36,19 @@ public interface IBeoordelingEscalatieClient
|
|||||||
/// <summary>Complete an acquired escalation job so its token reaches the escalation end event.</summary>
|
/// <summary>Complete an acquired escalation job so its token reaches the escalation end event.</summary>
|
||||||
Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default);
|
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,65 @@
|
|||||||
|
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_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!));
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
|
|||||||
public RegistrationId? StartedFor { get; private set; }
|
public RegistrationId? StartedFor { get; private set; }
|
||||||
public DiplomaOrigin? StartedWithOrigin { 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(
|
public Task<string> StartRegistrationProcessAsync(
|
||||||
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default)
|
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default)
|
||||||
@@ -49,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
|
||||||
|
|||||||
@@ -426,4 +426,106 @@ public class FlowableWorkflowClientTests
|
|||||||
capture.Seen.RequestUri!.ToString());
|
capture.Seen.RequestUri!.ToString());
|
||||||
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
|
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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -294,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"reporters": ["progress", "html"],
|
"reporters": ["progress", "html"],
|
||||||
"mutate": [
|
"mutate": [
|
||||||
"!**/OpenZaakJobPump.cs",
|
"!**/OpenZaakJobPump.cs",
|
||||||
"!**/BeoordelingEscalatiePump.cs"
|
"!**/BeoordelingEscalatiePump.cs",
|
||||||
|
"!**/RegistratieVerlopenPump.cs"
|
||||||
],
|
],
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
"high": 95,
|
"high": 95,
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ public sealed class InMemoryWorkflowClient : IWorkflowClient
|
|||||||
public RegistrationId? StartedFor { get; private set; }
|
public RegistrationId? StartedFor { get; private set; }
|
||||||
public DiplomaOrigin? StartedWithOrigin { 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(
|
public Task<string> StartRegistrationProcessAsync(
|
||||||
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default)
|
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default)
|
||||||
@@ -28,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,
|
||||||
@@ -130,6 +137,57 @@ public sealed class InMemoryEscalatieClient : IBeoordelingEscalatieClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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
|
||||||
{
|
{
|
||||||
|
|||||||
+87
-32
@@ -22,10 +22,16 @@
|
|||||||
(BeoordelingEscaleren); the Workflow Client reassigns the still-open Beoordelen task from the
|
(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
|
behandelaar group to teamlead (ADR-0015). The Beoordelen task stays open throughout — the timer
|
||||||
only changes who may claim it.
|
only changes who may claim it.
|
||||||
S-13 adds diploma-eligibility routing: between OpenZaakAanmaken and Beoordelen a DMN service
|
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
|
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
|
start variable; an exclusive gateway routes a foreign diploma through the CBGV-advies user task
|
||||||
before Beoordelen, a domestic one straight there (ADR-0016). -->
|
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">
|
||||||
@@ -38,7 +44,32 @@
|
|||||||
flowable:type="external-worker"
|
flowable:type="external-worker"
|
||||||
flowable:topic="OpenZaakAanmaken"/>
|
flowable:topic="OpenZaakAanmaken"/>
|
||||||
|
|
||||||
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="DiplomaEligibiliteit"/>
|
<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
|
<!-- 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
|
(flowable:type="dmn" — NOT a businessRuleTask, whose default implementation is the legacy
|
||||||
@@ -114,35 +145,47 @@
|
|||||||
<bpmndi:BPMNShape id="s_task" bpmnElement="OpenZaakAanmaken">
|
<bpmndi:BPMNShape id="s_task" bpmnElement="OpenZaakAanmaken">
|
||||||
<omgdc:Bounds x="180" y="85" width="120" height="60"/>
|
<omgdc:Bounds x="180" y="85" width="120" height="60"/>
|
||||||
</bpmndi:BPMNShape>
|
</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">
|
<bpmndi:BPMNShape id="s_dmn" bpmnElement="DiplomaEligibiliteit">
|
||||||
<omgdc:Bounds x="350" y="85" width="120" height="60"/>
|
<omgdc:Bounds x="510" y="85" width="120" height="60"/>
|
||||||
</bpmndi:BPMNShape>
|
</bpmndi:BPMNShape>
|
||||||
<bpmndi:BPMNShape id="s_route" bpmnElement="RouteOpDiploma" isMarkerVisible="true">
|
<bpmndi:BPMNShape id="s_route" bpmnElement="RouteOpDiploma" isMarkerVisible="true">
|
||||||
<omgdc:Bounds x="520" y="90" width="40" height="40"/>
|
<omgdc:Bounds x="680" y="90" width="40" height="40"/>
|
||||||
</bpmndi:BPMNShape>
|
</bpmndi:BPMNShape>
|
||||||
<bpmndi:BPMNShape id="s_cbgv" bpmnElement="CBGVAdvies">
|
<bpmndi:BPMNShape id="s_cbgv" bpmnElement="CBGVAdvies">
|
||||||
<omgdc:Bounds x="500" y="200" width="120" height="60"/>
|
<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="630" 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="810" y="100" width="30" height="30"/>
|
<omgdc:Bounds x="970" y="100" width="30" height="30"/>
|
||||||
</bpmndi:BPMNShape>
|
</bpmndi:BPMNShape>
|
||||||
<bpmndi:BPMNShape id="s_ingetrokken" bpmnElement="Ingetrokken">
|
<bpmndi:BPMNShape id="s_ingetrokken" bpmnElement="Ingetrokken">
|
||||||
<omgdc:Bounds x="690" y="135" width="30" height="30"/>
|
<omgdc:Bounds x="850" y="135" width="30" height="30"/>
|
||||||
</bpmndi:BPMNShape>
|
</bpmndi:BPMNShape>
|
||||||
<bpmndi:BPMNShape id="s_endIngetrokken" bpmnElement="endIngetrokken">
|
<bpmndi:BPMNShape id="s_endIngetrokken" bpmnElement="endIngetrokken">
|
||||||
<omgdc:Bounds x="690" y="250" width="30" height="30"/>
|
<omgdc:Bounds x="850" y="250" width="30" height="30"/>
|
||||||
</bpmndi:BPMNShape>
|
</bpmndi:BPMNShape>
|
||||||
<bpmndi:BPMNShape id="s_escaleerTimer" bpmnElement="EscaleerTimer">
|
<bpmndi:BPMNShape id="s_escaleerTimer" bpmnElement="EscaleerTimer">
|
||||||
<omgdc:Bounds x="700" y="70" width="30" height="30"/>
|
<omgdc:Bounds x="860" y="70" width="30" height="30"/>
|
||||||
</bpmndi:BPMNShape>
|
</bpmndi:BPMNShape>
|
||||||
<bpmndi:BPMNShape id="s_escaleerBeoordeling" bpmnElement="EscaleerBeoordeling">
|
<bpmndi:BPMNShape id="s_escaleerBeoordeling" bpmnElement="EscaleerBeoordeling">
|
||||||
<omgdc:Bounds x="800" y="20" width="120" height="60"/>
|
<omgdc:Bounds x="960" y="20" width="120" height="60"/>
|
||||||
</bpmndi:BPMNShape>
|
</bpmndi:BPMNShape>
|
||||||
<bpmndi:BPMNShape id="s_endEscaleren" bpmnElement="endEscaleren">
|
<bpmndi:BPMNShape id="s_endEscaleren" bpmnElement="endEscaleren">
|
||||||
<omgdc:Bounds x="970" y="35" width="30" height="30"/>
|
<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"/>
|
||||||
@@ -150,41 +193,53 @@
|
|||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
<bpmndi:BPMNEdge id="e_flow2" bpmnElement="flow2">
|
<bpmndi:BPMNEdge id="e_flow2" bpmnElement="flow2">
|
||||||
<omgdi:waypoint x="300" y="115"/>
|
<omgdi:waypoint x="300" y="115"/>
|
||||||
<omgdi:waypoint x="350" 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>
|
||||||
<bpmndi:BPMNEdge id="e_flow2a" bpmnElement="flow2a">
|
<bpmndi:BPMNEdge id="e_flow2a" bpmnElement="flow2a">
|
||||||
<omgdi:waypoint x="470" y="115"/>
|
<omgdi:waypoint x="630" y="115"/>
|
||||||
<omgdi:waypoint x="520" y="110"/>
|
<omgdi:waypoint x="680" y="110"/>
|
||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
<bpmndi:BPMNEdge id="e_flowDirect" bpmnElement="flowDirect">
|
<bpmndi:BPMNEdge id="e_flowDirect" bpmnElement="flowDirect">
|
||||||
<omgdi:waypoint x="560" y="110"/>
|
<omgdi:waypoint x="720" y="110"/>
|
||||||
<omgdi:waypoint x="630" y="115"/>
|
<omgdi:waypoint x="790" y="115"/>
|
||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
<bpmndi:BPMNEdge id="e_flowCbgv" bpmnElement="flowCbgv">
|
<bpmndi:BPMNEdge id="e_flowCbgv" bpmnElement="flowCbgv">
|
||||||
<omgdi:waypoint x="540" y="130"/>
|
<omgdi:waypoint x="700" y="130"/>
|
||||||
<omgdi:waypoint x="540" y="200"/>
|
<omgdi:waypoint x="700" y="200"/>
|
||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
<bpmndi:BPMNEdge id="e_flowCbgvNaarBeoordelen" bpmnElement="flowCbgvNaarBeoordelen">
|
<bpmndi:BPMNEdge id="e_flowCbgvNaarBeoordelen" bpmnElement="flowCbgvNaarBeoordelen">
|
||||||
<omgdi:waypoint x="620" y="230"/>
|
<omgdi:waypoint x="780" y="230"/>
|
||||||
<omgdi:waypoint x="660" y="230"/>
|
<omgdi:waypoint x="820" y="230"/>
|
||||||
<omgdi:waypoint x="660" y="145"/>
|
<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="750" y="115"/>
|
<omgdi:waypoint x="910" y="115"/>
|
||||||
<omgdi:waypoint x="810" y="115"/>
|
<omgdi:waypoint x="970" y="115"/>
|
||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
<bpmndi:BPMNEdge id="e_flow4" bpmnElement="flow4">
|
<bpmndi:BPMNEdge id="e_flow4" bpmnElement="flow4">
|
||||||
<omgdi:waypoint x="705" y="165"/>
|
<omgdi:waypoint x="865" y="165"/>
|
||||||
<omgdi:waypoint x="705" y="250"/>
|
<omgdi:waypoint x="865" y="250"/>
|
||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
<bpmndi:BPMNEdge id="e_flow5" bpmnElement="flow5">
|
<bpmndi:BPMNEdge id="e_flow5" bpmnElement="flow5">
|
||||||
<omgdi:waypoint x="715" y="70"/>
|
<omgdi:waypoint x="875" y="70"/>
|
||||||
<omgdi:waypoint x="715" y="50"/>
|
<omgdi:waypoint x="875" y="50"/>
|
||||||
<omgdi:waypoint x="800" y="50"/>
|
<omgdi:waypoint x="960" y="50"/>
|
||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
<bpmndi:BPMNEdge id="e_flow6" bpmnElement="flow6">
|
<bpmndi:BPMNEdge id="e_flow6" bpmnElement="flow6">
|
||||||
<omgdi:waypoint x="920" y="50"/>
|
<omgdi:waypoint x="1080" y="50"/>
|
||||||
<omgdi:waypoint x="970" 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