feat(behandelportal): WP-65b beoordeling besluit (decision write)
CI / changes (pull_request) Successful in 17s
CI / lint (pull_request) Failing after 56s
CI / frontend (pull_request) Successful in 2m36s
CI / storybook-a11y (pull_request) Failing after 3m19s
CI / backend (pull_request) Failing after 1m55s
CI / api-client-drift (pull_request) Canceled after 0s
CI / e2e (pull_request) Canceled after 40s
CI / semgrep (pull_request) Canceled after 24s
CI / changes (pull_request) Successful in 17s
CI / lint (pull_request) Failing after 56s
CI / frontend (pull_request) Successful in 2m36s
CI / storybook-a11y (pull_request) Failing after 3m19s
CI / backend (pull_request) Failing after 1m55s
CI / api-client-drift (pull_request) Canceled after 0s
CI / e2e (pull_request) Canceled after 40s
CI / semgrep (pull_request) Canceled after 24s
Adds POST /beoordeling/{id}/besluit: a Besluit enum (Goedkeuren/Afwijzen/
MeerInfoOpvragen) backed by new Aanvraag.BesluitStatus/BesluitToelichting
columns, gated by the same BeoordelingRules.CanDecide the read side's
canBesluiten flag already uses (409 on an illegal transition, 400 on a
missing required toelichting). Mappers.ToStatusDto gains the "a recorded
decision wins" branch. FE: besluit.machine.ts + besluit-form organism
(same form idiom as change-request-form), wired into the beoordeling page
behind the server's canBesluiten flag.
Completes WP-65 (65a + 65b) — verified end-to-end against a running
backend (werkvoorraad -> beoordeling -> besluit -> status reflected back).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { Result } from '@shared/kernel/fp';
|
||||||
|
import { Valid } from '@behandeling/domain/besluit.machine';
|
||||||
|
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
||||||
|
import { BesluitAdapter } from '@behandeling/infrastructure/besluit.adapter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command factory: binds the besluit adapter in an injection context and returns the
|
||||||
|
* submit function the decision form calls. Same field-initializer shape as
|
||||||
|
* `createStore` — the UI holds an application command, never the network client.
|
||||||
|
*/
|
||||||
|
export function createSubmitBesluit() {
|
||||||
|
const adapter = inject(BesluitAdapter);
|
||||||
|
return (id: string, data: Valid): Promise<Result<string, void>> =>
|
||||||
|
runSubmit(() => adapter.besluit(id, data), SUBMIT_FAILED);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { BesluitState, reduce, initial } from './besluit.machine';
|
||||||
|
|
||||||
|
const editingWith = (besluit: string, toelichting = ''): BesluitState => ({
|
||||||
|
tag: 'Editing',
|
||||||
|
draft: { besluit, toelichting },
|
||||||
|
errors: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('besluit reduce', () => {
|
||||||
|
it('SetField updates the draft while editing', () => {
|
||||||
|
const s = reduce(initial, { tag: 'SetField', key: 'besluit', value: 'Goedkeuren' });
|
||||||
|
expect(s.tag).toBe('Editing');
|
||||||
|
expect((s as Extract<BesluitState, { tag: 'Editing' }>).draft.besluit).toBe('Goedkeuren');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Submit with no besluit chosen stays Editing and reports a field error', () => {
|
||||||
|
const s = reduce(editingWith(''), { tag: 'Submit' });
|
||||||
|
expect(s.tag).toBe('Editing');
|
||||||
|
expect((s as Extract<BesluitState, { tag: 'Editing' }>).errors.besluit).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Submit Afwijzen without a toelichting stays Editing and reports a field error', () => {
|
||||||
|
const s = reduce(editingWith('Afwijzen'), { tag: 'Submit' });
|
||||||
|
expect(s.tag).toBe('Editing');
|
||||||
|
expect((s as Extract<BesluitState, { tag: 'Editing' }>).errors.toelichting).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Submit Goedkeuren with no toelichting moves to Submitting (optional there)', () => {
|
||||||
|
const s = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
||||||
|
expect(s.tag).toBe('Submitting');
|
||||||
|
expect((s as Extract<BesluitState, { tag: 'Submitting' }>).data).toEqual({
|
||||||
|
besluit: 'Goedkeuren',
|
||||||
|
toelichting: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Submit Afwijzen with a toelichting moves to Submitting with the trimmed value', () => {
|
||||||
|
const s = reduce(editingWith('Afwijzen', ' niet erkend '), { tag: 'Submit' });
|
||||||
|
expect(s.tag).toBe('Submitting');
|
||||||
|
expect((s as Extract<BesluitState, { tag: 'Submitting' }>).data).toEqual({
|
||||||
|
besluit: 'Afwijzen',
|
||||||
|
toelichting: 'niet erkend',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SubmitConfirmed maps Submitting to Submitted', () => {
|
||||||
|
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
||||||
|
expect(reduce(submitting, { tag: 'SubmitConfirmed' }).tag).toBe('Submitted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SubmitFailed maps Submitting to Failed with the error', () => {
|
||||||
|
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
||||||
|
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
|
||||||
|
expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Retry re-submits a failure', () => {
|
||||||
|
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
||||||
|
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
|
||||||
|
expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Reset returns to the initial editing state', () => {
|
||||||
|
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
||||||
|
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Result, assertNever } from '@shared/kernel/fp';
|
||||||
|
|
||||||
|
/** The three actions the beoordeling screen offers a behandelaar (WP-65b) — mirrors the
|
||||||
|
backend's `Besluit` enum member names 1:1 (the wire convention: a string, not a raw
|
||||||
|
enum — see `RecordBesluitRequest`). */
|
||||||
|
const BESLUIT_TAGS = ['Goedkeuren', 'Afwijzen', 'MeerInfoOpvragen'] as const;
|
||||||
|
export type BesluitTag = (typeof BESLUIT_TAGS)[number];
|
||||||
|
|
||||||
|
function isBesluitTag(v: string): v is BesluitTag {
|
||||||
|
return (BESLUIT_TAGS as readonly string[]).includes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the user picked (raw, possibly empty while nothing is selected yet). */
|
||||||
|
export interface Draft {
|
||||||
|
besluit: string;
|
||||||
|
toelichting: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After parsing — besluit is the narrow tag; toelichting is present only when given
|
||||||
|
(required for Afwijzen/MeerInfoOpvragen, optional for Goedkeuren — enforced by validate). */
|
||||||
|
export interface Valid {
|
||||||
|
besluit: BesluitTag;
|
||||||
|
toelichting?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Errors = Partial<Record<keyof Draft, string>>;
|
||||||
|
|
||||||
|
/** The decision form as one tagged union — same idiom as every other form in this
|
||||||
|
house (form-machine skill), single-step. draft/errors exist only while Editing. */
|
||||||
|
export type BesluitState =
|
||||||
|
| { tag: 'Editing'; draft: Draft; errors: Errors }
|
||||||
|
| { tag: 'Submitting'; data: Valid }
|
||||||
|
| { tag: 'Submitted'; data: Valid }
|
||||||
|
| { tag: 'Failed'; data: Valid; error: string };
|
||||||
|
|
||||||
|
export const initial: BesluitState = {
|
||||||
|
tag: 'Editing',
|
||||||
|
draft: { besluit: '', toelichting: '' },
|
||||||
|
errors: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
function validate(draft: Draft): Result<Errors, Valid> {
|
||||||
|
if (!isBesluitTag(draft.besluit)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { besluit: $localize`:@@besluit.error.verplicht:Kies een besluit.` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const toelichting = draft.toelichting.trim();
|
||||||
|
if (draft.besluit !== 'Goedkeuren' && toelichting === '') {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
toelichting: $localize`:@@besluit.error.toelichtingVerplicht:Geef een toelichting.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, value: { besluit: draft.besluit, toelichting: toelichting || undefined } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BesluitMsg =
|
||||||
|
| { tag: 'SetField'; key: keyof Draft; value: string }
|
||||||
|
| { tag: 'Submit' }
|
||||||
|
| { tag: 'Retry' }
|
||||||
|
| { tag: 'SubmitConfirmed' }
|
||||||
|
| { tag: 'SubmitFailed'; error: string }
|
||||||
|
| { tag: 'Reset' }
|
||||||
|
| { tag: 'Seed'; state: BesluitState }; // mount a specific state (stories/tests)
|
||||||
|
|
||||||
|
export function reduce(s: BesluitState, m: BesluitMsg): BesluitState {
|
||||||
|
switch (m.tag) {
|
||||||
|
case 'SetField':
|
||||||
|
return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;
|
||||||
|
case 'Submit': {
|
||||||
|
if (s.tag !== 'Editing') return s;
|
||||||
|
const r = validate(s.draft);
|
||||||
|
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
|
||||||
|
}
|
||||||
|
case 'Retry':
|
||||||
|
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
||||||
|
case 'SubmitConfirmed':
|
||||||
|
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
|
||||||
|
case 'SubmitFailed':
|
||||||
|
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
|
||||||
|
case 'Reset':
|
||||||
|
return initial;
|
||||||
|
case 'Seed':
|
||||||
|
return m.state;
|
||||||
|
default:
|
||||||
|
return assertNever(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
|
import { Valid } from '@behandeling/domain/besluit.machine';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infrastructure adapter for recording a behandelaar's decision (WP-65b) — the single
|
||||||
|
* place its HTTP lives. No return value: a successful call means the server accepted
|
||||||
|
* the transition; the caller reloads `BeoordelingStore` to see the new status (the
|
||||||
|
* server, not this adapter, re-validates and is the authority).
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class BesluitAdapter {
|
||||||
|
private client = inject(ApiClient);
|
||||||
|
|
||||||
|
async besluit(id: string, data: Valid): Promise<void> {
|
||||||
|
await this.client.besluit(id, { besluit: data.besluit, toelichting: data.toelichting });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,13 @@ import { ASYNC } from '@shared/ui/async/async.component';
|
|||||||
import { BeoordelingStore } from '@behandeling/application/beoordeling.store';
|
import { BeoordelingStore } from '@behandeling/application/beoordeling.store';
|
||||||
import { detailRows } from '@behandeling/domain/beoordeling-view';
|
import { detailRows } from '@behandeling/domain/beoordeling-view';
|
||||||
import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-documenten/beoordeling-documenten.component';
|
import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-documenten/beoordeling-documenten.component';
|
||||||
|
import { BesluitFormComponent } from '@behandeling/ui/besluit-form/besluit-form.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page: one aanvraag's beoordeling detail (WP-65, read side). The werkvoorraad list
|
* Page: one aanvraag's beoordeling detail (WP-65). The werkvoorraad list (WP-64) links
|
||||||
* (WP-64) links here. Recording a decision is this WP's second half — for now the
|
* here. `canBesluiten` (server-computed, ADR-0001) gates the decision form (WP-65b) —
|
||||||
* page only shows status/documents; `canBesluiten` is already carried by the view so
|
* the page never recomputes the lifecycle itself. On a recorded decision the form emits
|
||||||
* the decision form has zero further backend round-trip to add.
|
* `decided`, and the page just reloads (the server is the authority on the new status).
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-beoordeling-page',
|
selector: 'app-beoordeling-page',
|
||||||
@@ -27,6 +28,7 @@ import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-docu
|
|||||||
DataBlockComponent,
|
DataBlockComponent,
|
||||||
DataRowComponent,
|
DataRowComponent,
|
||||||
BeoordelingDocumentenComponent,
|
BeoordelingDocumentenComponent,
|
||||||
|
BesluitFormComponent,
|
||||||
...ASYNC,
|
...ASYNC,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
@@ -49,6 +51,11 @@ import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-docu
|
|||||||
<app-data-block [heading]="documentenHeading" class="app-section">
|
<app-data-block [heading]="documentenHeading" class="app-section">
|
||||||
<app-beoordeling-documenten [documenten]="v.documenten" />
|
<app-beoordeling-documenten [documenten]="v.documenten" />
|
||||||
</app-data-block>
|
</app-data-block>
|
||||||
|
@if (v.canBesluiten) {
|
||||||
|
<div class="app-section">
|
||||||
|
<app-besluit-form [id]="v.id" (decided)="reload()" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-async>
|
</app-async>
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
|
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||||
|
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||||
|
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component';
|
||||||
|
import { createStore } from '@shared/application/store';
|
||||||
|
import { whenTag } from '@shared/kernel/fp';
|
||||||
|
import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/besluit.machine';
|
||||||
|
import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organism: the decision form (WP-65b) — goedkeuren/afwijzen/meer-info-opvragen. Same
|
||||||
|
* idiom as every other form in this house (`change-request-form`): all state in one
|
||||||
|
* signal driven by the pure `reduce` (besluit.machine.ts), submitted via a `submit-*`
|
||||||
|
* command returning `Result`. The server re-validates the transition and is the
|
||||||
|
* authority; on success this only emits `decided` — the page reloads the detail
|
||||||
|
* (BeoordelingStore.reload()), it doesn't guess the new state itself.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-besluit-form',
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
ButtonComponent,
|
||||||
|
HeadingComponent,
|
||||||
|
AlertComponent,
|
||||||
|
FormFieldComponent,
|
||||||
|
TextInputComponent,
|
||||||
|
RadioGroupComponent,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
@if (state().tag === 'Submitted') {
|
||||||
|
<app-alert type="ok" i18n="@@besluit.success">Het besluit is vastgelegd.</app-alert>
|
||||||
|
} @else {
|
||||||
|
<app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading>
|
||||||
|
|
||||||
|
<form (ngSubmit)="onSubmit()" class="form-horizontal app-section">
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@besluit.besluitLabel"
|
||||||
|
label="Besluit"
|
||||||
|
fieldId="besluit-keuze"
|
||||||
|
required
|
||||||
|
[error]="errors().besluit"
|
||||||
|
>
|
||||||
|
<app-radio-group
|
||||||
|
name="besluit-keuze"
|
||||||
|
[options]="BESLUIT_OPTIONS"
|
||||||
|
[invalid]="!!errors().besluit"
|
||||||
|
[ngModel]="besluit()"
|
||||||
|
(ngModelChange)="dispatch({ tag: 'SetField', key: 'besluit', value: $event })"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
|
||||||
|
<app-form-field
|
||||||
|
i18n-label="@@besluit.toelichtingLabel"
|
||||||
|
label="Toelichting"
|
||||||
|
fieldId="besluit-toelichting"
|
||||||
|
[error]="errors().toelichting"
|
||||||
|
>
|
||||||
|
<app-text-input
|
||||||
|
inputId="besluit-toelichting"
|
||||||
|
[invalid]="!!errors().toelichting"
|
||||||
|
[ngModel]="toelichting()"
|
||||||
|
(ngModelChange)="dispatch({ tag: 'SetField', key: 'toelichting', value: $event })"
|
||||||
|
name="toelichting"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
/>
|
||||||
|
</app-form-field>
|
||||||
|
|
||||||
|
@if (failedError()) {
|
||||||
|
<app-alert type="error"
|
||||||
|
><ng-container i18n="@@besluit.failed">Het vastleggen is niet gelukt:</ng-container>
|
||||||
|
{{ failedError() }}</app-alert
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
|
||||||
|
{{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }}
|
||||||
|
</app-button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class BesluitFormComponent {
|
||||||
|
private submit = createSubmitBesluit();
|
||||||
|
private store = createStore<BesluitState, BesluitMsg>(initial, reduce);
|
||||||
|
|
||||||
|
id = input.required<string>();
|
||||||
|
decided = output<void>();
|
||||||
|
|
||||||
|
/** Optional seed so Storybook / tests can mount any state directly. */
|
||||||
|
seed = input<BesluitState>(initial);
|
||||||
|
|
||||||
|
readonly state = this.store.model;
|
||||||
|
protected dispatch = this.store.dispatch;
|
||||||
|
|
||||||
|
protected readonly BESLUIT_OPTIONS: RadioOption[] = [
|
||||||
|
{ value: 'Goedkeuren', label: $localize`:@@besluit.optie.goedkeuren:Goedkeuren` },
|
||||||
|
{ value: 'Afwijzen', label: $localize`:@@besluit.optie.afwijzen:Afwijzen` },
|
||||||
|
{
|
||||||
|
value: 'MeerInfoOpvragen',
|
||||||
|
label: $localize`:@@besluit.optie.meerInfoOpvragen:Meer informatie opvragen`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
protected readonly submitLabel = $localize`:@@besluit.submit:Besluit vastleggen`;
|
||||||
|
protected readonly submitBezigLabel = $localize`:@@besluit.submitBezig:Bezig met vastleggen…`;
|
||||||
|
|
||||||
|
private editing = computed(() => whenTag(this.state(), 'Editing'));
|
||||||
|
protected errors = computed(() => this.editing()?.errors ?? {});
|
||||||
|
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
||||||
|
|
||||||
|
protected besluit = computed(() => this.editing()?.draft.besluit ?? '');
|
||||||
|
protected toelichting = computed(() => this.editing()?.draft.toelichting ?? '');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit() {
|
||||||
|
this.dispatch({ tag: 'Submit' });
|
||||||
|
this.runIfSubmitting();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Effect: when we entered Submitting, call the command, then dispatch the outcome. */
|
||||||
|
private async runIfSubmitting() {
|
||||||
|
const s = this.state();
|
||||||
|
if (s.tag !== 'Submitting') return;
|
||||||
|
const r = await this.submit(this.id(), s.data);
|
||||||
|
if (r.ok) {
|
||||||
|
this.dispatch({ tag: 'SubmitConfirmed' });
|
||||||
|
this.decided.emit();
|
||||||
|
} else {
|
||||||
|
this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
|
import { applicationConfig } from '@storybook/angular';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { BesluitFormComponent } from './besluit-form.component';
|
||||||
|
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||||
|
import { Valid } from '@behandeling/domain/besluit.machine';
|
||||||
|
|
||||||
|
const validData: Valid = { besluit: 'Afwijzen', toelichting: 'Diploma niet erkend' };
|
||||||
|
|
||||||
|
const meta: Meta<BesluitFormComponent> = {
|
||||||
|
title: 'Domein/Behandeling/Besluit Form',
|
||||||
|
component: BesluitFormComponent,
|
||||||
|
// The form injects ApiClient (over HttpClient) for the submit command.
|
||||||
|
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
|
||||||
|
args: { id: 'aanvraag-1' },
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<BesluitFormComponent>;
|
||||||
|
|
||||||
|
// One render per state of the machine.
|
||||||
|
export const Empty: Story = {
|
||||||
|
args: { seed: { tag: 'Editing', draft: { besluit: '', toelichting: '' }, errors: {} } },
|
||||||
|
};
|
||||||
|
export const WithErrors: Story = {
|
||||||
|
args: {
|
||||||
|
seed: {
|
||||||
|
tag: 'Editing',
|
||||||
|
draft: { besluit: 'Afwijzen', toelichting: '' },
|
||||||
|
errors: { toelichting: 'Geef een toelichting.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
|
||||||
|
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
|
||||||
|
export const Failed: Story = {
|
||||||
|
args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } },
|
||||||
|
};
|
||||||
@@ -3020,6 +3020,54 @@
|
|||||||
<source>Opnieuw proberen</source>
|
<source>Opnieuw proberen</source>
|
||||||
<target datatype="html">Try again</target>
|
<target datatype="html">Try again</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.error.verplicht" datatype="html">
|
||||||
|
<source>Kies een besluit.</source>
|
||||||
|
<target datatype="html">Choose a decision.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.error.toelichtingVerplicht" datatype="html">
|
||||||
|
<source>Geef een toelichting.</source>
|
||||||
|
<target datatype="html">Give an explanation.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.success" datatype="html">
|
||||||
|
<source>Het besluit is vastgelegd.</source>
|
||||||
|
<target datatype="html">The decision has been recorded.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.heading" datatype="html">
|
||||||
|
<source>Besluit vastleggen</source>
|
||||||
|
<target datatype="html">Record a decision</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.besluitLabel" datatype="html">
|
||||||
|
<source>Besluit</source>
|
||||||
|
<target datatype="html">Decision</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.toelichtingLabel" datatype="html">
|
||||||
|
<source>Toelichting</source>
|
||||||
|
<target datatype="html">Explanation</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.failed" datatype="html">
|
||||||
|
<source>Het vastleggen is niet gelukt:</source>
|
||||||
|
<target datatype="html">Recording the decision failed:</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.optie.goedkeuren" datatype="html">
|
||||||
|
<source>Goedkeuren</source>
|
||||||
|
<target datatype="html">Approve</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.optie.afwijzen" datatype="html">
|
||||||
|
<source>Afwijzen</source>
|
||||||
|
<target datatype="html">Reject</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.optie.meerInfoOpvragen" datatype="html">
|
||||||
|
<source>Meer informatie opvragen</source>
|
||||||
|
<target datatype="html">Request more information</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.submit" datatype="html">
|
||||||
|
<source>Besluit vastleggen</source>
|
||||||
|
<target datatype="html">Record decision</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.submitBezig" datatype="html">
|
||||||
|
<source>Bezig met vastleggen…</source>
|
||||||
|
<target datatype="html">Recording…</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="beheer.noTables" datatype="html">
|
<trans-unit id="beheer.noTables" datatype="html">
|
||||||
<source>Er is geen stamdata om te beheren.</source>
|
<source>Er is geen stamdata om te beheren.</source>
|
||||||
<target datatype="html">There is no stamdata to manage.</target>
|
<target datatype="html">There is no stamdata to manage.</target>
|
||||||
|
|||||||
@@ -156,6 +156,20 @@
|
|||||||
<context context-type="linenumber">47</context>
|
<context context-type="linenumber">47</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.error.verplicht" datatype="html">
|
||||||
|
<source>Kies een besluit.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts</context>
|
||||||
|
<context context-type="linenumber">46</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.error.toelichtingVerplicht" datatype="html">
|
||||||
|
<source>Geef een toelichting.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts</context>
|
||||||
|
<context context-type="linenumber">54</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="werkvoorraad.row.ingediend" datatype="html">
|
<trans-unit id="werkvoorraad.row.ingediend" datatype="html">
|
||||||
<source>ingediend op <x id="datum" equiv-text="formatDatumNl(item.submittedAt)"/></source>
|
<source>ingediend op <x id="datum" equiv-text="formatDatumNl(item.submittedAt)"/></source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
@@ -181,35 +195,105 @@
|
|||||||
<source>Aanvraag</source>
|
<source>Aanvraag</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
|
||||||
<context context-type="linenumber">62</context>
|
<context context-type="linenumber">69</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="beoordeling.detail.heading" datatype="html">
|
<trans-unit id="beoordeling.detail.heading" datatype="html">
|
||||||
<source>Aanvraaggegevens</source>
|
<source>Aanvraaggegevens</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
|
||||||
<context context-type="linenumber">63</context>
|
<context context-type="linenumber">70</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="beoordeling.documenten.heading" datatype="html">
|
<trans-unit id="beoordeling.documenten.heading" datatype="html">
|
||||||
<source>Documenten</source>
|
<source>Documenten</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
|
||||||
<context context-type="linenumber">64</context>
|
<context context-type="linenumber">71</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="beoordeling.failed" datatype="html">
|
<trans-unit id="beoordeling.failed" datatype="html">
|
||||||
<source>De aanvraag kon niet worden geladen.</source>
|
<source>De aanvraag kon niet worden geladen.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
|
||||||
<context context-type="linenumber">65</context>
|
<context context-type="linenumber">72</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="beoordeling.retry" datatype="html">
|
<trans-unit id="beoordeling.retry" datatype="html">
|
||||||
<source>Opnieuw proberen</source>
|
<source>Opnieuw proberen</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts</context>
|
||||||
<context context-type="linenumber">66</context>
|
<context context-type="linenumber">73</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.success" datatype="html">
|
||||||
|
<source>Het besluit is vastgelegd.</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">35,37</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.heading" datatype="html">
|
||||||
|
<source>Besluit vastleggen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">37,39</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.besluitLabel" datatype="html">
|
||||||
|
<source>Besluit</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">43</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.toelichtingLabel" datatype="html">
|
||||||
|
<source>Toelichting</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">59,60</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.failed" datatype="html">
|
||||||
|
<source>Het vastleggen is niet gelukt:</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">75,76</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.optie.goedkeuren" datatype="html">
|
||||||
|
<source>Goedkeuren</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">101</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.optie.afwijzen" datatype="html">
|
||||||
|
<source>Afwijzen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">102</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.optie.meerInfoOpvragen" datatype="html">
|
||||||
|
<source>Meer informatie opvragen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">105</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.submit" datatype="html">
|
||||||
|
<source>Besluit vastleggen</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">109</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="besluit.submitBezig" datatype="html">
|
||||||
|
<source>Bezig met vastleggen…</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts</context>
|
||||||
|
<context context-type="linenumber">110</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="werkvoorraad.heading" datatype="html">
|
<trans-unit id="werkvoorraad.heading" datatype="html">
|
||||||
@@ -870,6 +954,20 @@
|
|||||||
<context context-type="linenumber">107</context>
|
<context context-type="linenumber">107</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="common.ja" datatype="html">
|
||||||
|
<source>Ja</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/ui/radio-group/radio-group.component.ts</context>
|
||||||
|
<context context-type="linenumber">12</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="common.nee" datatype="html">
|
||||||
|
<source>Nee</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">libs/shared/src/ui/radio-group/radio-group.component.ts</context>
|
||||||
|
<context context-type="linenumber">13</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="spinner.aria" datatype="html">
|
<trans-unit id="spinner.aria" datatype="html">
|
||||||
<source>Bezig met laden</source>
|
<source>Bezig met laden</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
|
|||||||
@@ -140,6 +140,15 @@ public sealed record BeoordelingViewDto(
|
|||||||
IReadOnlyList<BeoordelingDocumentDto> Documenten,
|
IReadOnlyList<BeoordelingDocumentDto> Documenten,
|
||||||
BeoordelingDecisionsDto Decisions);
|
BeoordelingDecisionsDto Decisions);
|
||||||
|
|
||||||
|
/// Recording a decision (WP-65b). `Besluit` is the enum member name as a string — same
|
||||||
|
/// wire convention as `AanvraagStatusDto.Tag` (this backend never ships a raw C# enum,
|
||||||
|
/// it round-trips names via Enum.Parse/.ToString() at the Contracts boundary, no
|
||||||
|
/// JsonStringEnumConverter configured). The endpoint 400s an unknown name. Toelichting
|
||||||
|
/// is required for Afwijzen/MeerInfoOpvragen, validated server-side.
|
||||||
|
public sealed record RecordBesluitRequest(string Besluit, string? Toelichting = null);
|
||||||
|
|
||||||
|
public sealed record RecordBesluitResponse(AanvraagStatusDto Status);
|
||||||
|
|
||||||
// --- Brief (letter composition) contracts ---
|
// --- Brief (letter composition) contracts ---
|
||||||
// Rich text is a serialisable node tree; the node union is flattened with a `Type`
|
// Rich text is a serialisable node tree; the node union is flattened with a `Type`
|
||||||
// discriminator + nullable fields, the same wire convention as AanvraagStatusDto.
|
// discriminator + nullable fields, the same wire convention as AanvraagStatusDto.
|
||||||
|
|||||||
@@ -38,17 +38,25 @@ public static class Mappers
|
|||||||
|
|
||||||
// Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports
|
// Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports
|
||||||
// Goedgekeurd once past the processing window, else In behandeling; a manual case
|
// Goedgekeurd once past the processing window, else In behandeling; a manual case
|
||||||
// stays In behandeling forever (awaits the unbuilt backoffice). Pure — testable
|
// stays In behandeling until a behandelaar records a decision (WP-65b — before that
|
||||||
// by passing different `now` values without waiting for the wall clock.
|
// WP, it stayed In behandeling forever, awaiting the then-unbuilt backoffice). Pure —
|
||||||
//
|
// testable by passing different `now` values without waiting for the wall clock.
|
||||||
// Ingediend/MeerInfoGevraagd (AanvraagStatusTag, WP-63) aren't produced here yet — no
|
|
||||||
// behandelaar action exists to reach them (WP-65 adds the transition endpoint).
|
|
||||||
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now)
|
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now)
|
||||||
{
|
{
|
||||||
if (!a.Submitted)
|
if (!a.Submitted)
|
||||||
return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount);
|
return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount);
|
||||||
if (a.Reden is not null)
|
if (a.Reden is not null)
|
||||||
return new(AanvraagStatusTag.Afgewezen.ToString(), Referentie: a.Referentie, Reden: a.Reden);
|
return new(AanvraagStatusTag.Afgewezen.ToString(), Referentie: a.Referentie, Reden: a.Reden);
|
||||||
|
// A recorded decision (WP-65b) wins over the auto-approve computation below — a
|
||||||
|
// behandelaar's explicit besluit is authoritative once made.
|
||||||
|
if (a.BesluitStatus is { } besluit)
|
||||||
|
return besluit switch
|
||||||
|
{
|
||||||
|
Besluit.Goedkeuren => new(AanvraagStatusTag.Goedgekeurd.ToString(), Referentie: a.Referentie),
|
||||||
|
Besluit.Afwijzen => new(AanvraagStatusTag.Afgewezen.ToString(), Referentie: a.Referentie, Reden: a.BesluitToelichting),
|
||||||
|
Besluit.MeerInfoOpvragen => new(AanvraagStatusTag.MeerInfoGevraagd.ToString(), Referentie: a.Referentie, Reden: a.BesluitToelichting),
|
||||||
|
_ => throw new InvalidOperationException($"Unknown besluit {besluit}"),
|
||||||
|
};
|
||||||
if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
|
if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
|
||||||
return new(AanvraagStatusTag.Goedgekeurd.ToString(), Referentie: a.Referentie);
|
return new(AanvraagStatusTag.Goedgekeurd.ToString(), Referentie: a.Referentie);
|
||||||
return new(AanvraagStatusTag.InBehandeling.ToString(), Referentie: a.Referentie, Manual: !a.AutoApprovable);
|
return new(AanvraagStatusTag.InBehandeling.ToString(), Referentie: a.Referentie, Manual: !a.AutoApprovable);
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ namespace BigRegister.Api.Data;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public enum AanvraagStatusTag { Ingediend, InBehandeling, MeerInfoGevraagd, Goedgekeurd, Afgewezen }
|
public enum AanvraagStatusTag { Ingediend, InBehandeling, MeerInfoGevraagd, Goedgekeurd, Afgewezen }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A behandelaar's recorded decision (WP-65b) — the three actions the beoordeling screen
|
||||||
|
/// offers, each advancing <see cref="Aanvraag.BesluitStatus"/> and (via
|
||||||
|
/// <see cref="BigRegister.Api.Contracts.Mappers.ToStatusDto"/>) the published
|
||||||
|
/// <see cref="AanvraagStatusTag"/> the FE renders.
|
||||||
|
/// </summary>
|
||||||
|
public enum Besluit { Goedkeuren, Afwijzen, MeerInfoOpvragen }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An application (aanvraag) — the system of record the dashboard reads. A wizard
|
/// An application (aanvraag) — the system of record the dashboard reads. A wizard
|
||||||
/// creates one as a Concept on its first step, syncs its draft snapshot per step,
|
/// creates one as a Concept on its first step, syncs its draft snapshot per step,
|
||||||
@@ -50,6 +58,17 @@ public sealed class Aanvraag
|
|||||||
/// is re-findable by <c>identificatie == Referentie</c>. Cleared by a future repair path;
|
/// is re-findable by <c>identificatie == Referentie</c>. Cleared by a future repair path;
|
||||||
/// none exists yet (see openzaak-integration.md's "Write resilience" section).</summary>
|
/// none exists yet (see openzaak-integration.md's "Write resilience" section).</summary>
|
||||||
public string? ZgwError { get; set; }
|
public string? ZgwError { get; set; }
|
||||||
|
|
||||||
|
/// <summary>WP-65b: a behandelaar's recorded decision, if any. Non-null wins over the
|
||||||
|
/// auto-approve computation in <see cref="BigRegister.Api.Contracts.Mappers.ToStatusDto"/> —
|
||||||
|
/// "a recorded decision wins". Mutable across <see cref="AanvraagStatusTag.MeerInfoGevraagd"/>
|
||||||
|
/// (a behandelaar may decide again later); frozen once Goedgekeurd/Afgewezen (terminal, per
|
||||||
|
/// <see cref="BigRegister.Domain.Beoordeling.BeoordelingRules.CanDecide"/>).</summary>
|
||||||
|
public Besluit? BesluitStatus { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The behandelaar's toelichting — required for Afwijzen/MeerInfoOpvragen (becomes
|
||||||
|
/// the published status's Reden), optional for Goedkeuren.</summary>
|
||||||
|
public string? BesluitToelichting { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -104,6 +123,17 @@ public static class ApplicationStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cross-owner single read (WP-65b) — the behandelaar decision endpoint's counterpart of
|
||||||
|
/// <see cref="Get"/>, same "any owner" shape as <see cref="DeleteAny"/>.
|
||||||
|
public static Aanvraag? GetAny(string id)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Db.Create();
|
||||||
|
return db.Applications.Find(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Admin: every case across all owners (WP-36). The per-owner List is the norm; this
|
/// Admin: every case across all owners (WP-36). The per-owner List is the norm; this
|
||||||
/// is the deliberate cross-owner read behind the admin-only /admin/cases endpoint.
|
/// is the deliberate cross-owner read behind the admin-only /admin/cases endpoint.
|
||||||
public static IReadOnlyList<Aanvraag> ListAll()
|
public static IReadOnlyList<Aanvraag> ListAll()
|
||||||
@@ -224,4 +254,24 @@ public static class ApplicationStore
|
|||||||
db.SaveChanges();
|
db.SaveChanges();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Record a behandelaar's decision (WP-65b). The endpoint has already checked
|
||||||
|
/// <see cref="BigRegister.Domain.Beoordeling.BeoordelingRules.CanDecide"/> against the
|
||||||
|
/// freshly-read status before calling this — cross-owner like <see cref="DeleteAny"/>,
|
||||||
|
/// since a behandelaar decides on any citizen's case. Returns null only if the aanvraag
|
||||||
|
/// is gone (shouldn't happen — this runs right after the endpoint's own read found it).</summary>
|
||||||
|
public static Aanvraag? RecordBesluit(string id, Besluit besluit, string? toelichting)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
using var db = Db.Create();
|
||||||
|
var a = db.Applications.Find(id);
|
||||||
|
if (a is null) return null;
|
||||||
|
a.BesluitStatus = besluit;
|
||||||
|
a.BesluitToelichting = toelichting;
|
||||||
|
a.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
db.SaveChanges();
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+288
@@ -0,0 +1,288 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using BigRegister.Api.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BigRegister.Api.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260803070817_BesluitStatus")]
|
||||||
|
partial class BesluitStatus
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
|
||||||
|
|
||||||
|
modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("AutoApprovable")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("BesluitStatus")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("BesluitToelichting")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DocumentIds")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Draft")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Owner")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Reden")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Referentie")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("StepCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("StepIndex")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("Submitted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("SubmittedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ZaakUrl")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ZgwError")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Applications");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Actor")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("At")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CategoryId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DocumentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("AuditEntries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BigRegister.Api.Data.AuthzAuditEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("At")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CorrelationId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Decision")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Resource")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("AuthzAudit");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("BriefId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ArchivedHtml")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Beroep")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DrafterId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Owner")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Placeholders")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Sections")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("SentOrgTemplateVersion")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SubOrgId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("TemplateId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("BriefId");
|
||||||
|
|
||||||
|
b.HasIndex("Owner")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Briefs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BigRegister.Api.Data.FeatureFlagEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Key");
|
||||||
|
|
||||||
|
b.ToTable("FeatureFlags");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BigRegister.Api.Data.OrgTemplateEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("SubOrgId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Draft")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("History")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("PublishedVersion")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("SubOrgId");
|
||||||
|
|
||||||
|
b.ToTable("OrgTemplates");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("DocumentId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("CategoryId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB");
|
||||||
|
|
||||||
|
b.Property<string>("ContentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DrcUrl")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Linked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("LocalId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Owner")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SizeBytes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UploadedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("WizardId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("DocumentId");
|
||||||
|
|
||||||
|
b.ToTable("Documents");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BigRegister.Api.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class BesluitStatus : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "BesluitStatus",
|
||||||
|
table: "Applications",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "BesluitToelichting",
|
||||||
|
table: "Applications",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BesluitStatus",
|
||||||
|
table: "Applications");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BesluitToelichting",
|
||||||
|
table: "Applications");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,12 @@ namespace BigRegister.Api.Data.Migrations
|
|||||||
b.Property<bool>("AutoApprovable")
|
b.Property<bool>("AutoApprovable")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("BesluitStatus")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("BesluitToelichting")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
|||||||
@@ -442,6 +442,40 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
|
|||||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
// --- Besluit (WP-65b): record a behandelaar's decision, advancing the WP-63 status
|
||||||
|
// lifecycle. Runs against ApplicationStore directly (not the IZaakSource seam) — same
|
||||||
|
// reasoning as the GET above: a new seam method would force an OpenZaakZaakSource
|
||||||
|
// write now, which is WP-66's surface, not this one's. The transition-legality check
|
||||||
|
// (BeoordelingRules.CanDecide) is the SAME function the GET's canBesluiten flag uses,
|
||||||
|
// so the two can never drift.
|
||||||
|
api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, HttpContext ctx) =>
|
||||||
|
Beoordelen(ctx, $"aanvraag/{id}/besluit", () =>
|
||||||
|
{
|
||||||
|
if (!Enum.TryParse<Besluit>(req.Besluit, out var besluit))
|
||||||
|
return Results.Problem(detail: $"Onbekend besluit '{req.Besluit}'.", statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var a = ApplicationStore.GetAny(id);
|
||||||
|
var statusTag = a?.ToStatusDto(now).Tag;
|
||||||
|
if (a is null || statusTag == "Concept") return Results.NotFound();
|
||||||
|
var current = Enum.Parse<AanvraagStatusTag>(statusTag!);
|
||||||
|
if (!BeoordelingRules.CanDecide(current))
|
||||||
|
return Results.Problem(
|
||||||
|
detail: "Deze aanvraag staat geen besluit meer toe in de huidige status.",
|
||||||
|
statusCode: StatusCodes.Status409Conflict);
|
||||||
|
if (besluit != Besluit.Goedkeuren && string.IsNullOrWhiteSpace(req.Toelichting))
|
||||||
|
return Results.Problem(detail: "Toelichting is verplicht bij dit besluit.", statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
|
||||||
|
var updated = ApplicationStore.RecordBesluit(id, besluit, req.Toelichting)!;
|
||||||
|
app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", id, besluit);
|
||||||
|
return Results.Ok(new RecordBesluitResponse(updated.ToStatusDto(now)));
|
||||||
|
}))
|
||||||
|
.Produces<RecordBesluitResponse>()
|
||||||
|
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||||
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
|
.ProducesProblem(StatusCodes.Status409Conflict)
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
// OpenZaak's Notificaties API (NRC) calls this on every zaak event once an `abonnement` is
|
// OpenZaak's Notificaties API (NRC) calls this on every zaak event once an `abonnement` is
|
||||||
// provisioned (WP-52, out-of-band — see openzaak-integration.md, no app code subscribes it).
|
// provisioned (WP-52, out-of-band — see openzaak-integration.md, no app code subscribes it).
|
||||||
// The caller is NRC, not a user: no Principal, so this audits via AuthzAuditStore directly
|
// The caller is NRC, not a user: no Principal, so this audits via AuthzAuditStore directly
|
||||||
|
|||||||
@@ -840,6 +840,78 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/beoordeling/{id}/besluit": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RecordBesluitRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RecordBesluitResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Conflict",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/admin/cases/{id}": {
|
"/api/v1/admin/cases/{id}": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2448,6 +2520,29 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"RecordBesluitRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"besluit": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"toelichting": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"RecordBesluitResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"$ref": "#/components/schemas/AanvraagStatusDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"ReferentieResponse": {
|
"ReferentieResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -127,4 +127,113 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture
|
|||||||
req.Headers.Add("X-Rollen", "geen");
|
req.Headers.Add("X-Rollen", "geen");
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode);
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task<HttpResponseMessage> PostBesluit(string id, object body)
|
||||||
|
{
|
||||||
|
var req = AsBehandelaar(HttpMethod.Post, $"/api/v1/beoordeling/{id}/besluit");
|
||||||
|
req.Content = JsonContent.Create(body);
|
||||||
|
return _client.SendAsync(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Goedkeuren_advances_status_to_Goedgekeurd()
|
||||||
|
{
|
||||||
|
var (a, _) = await CreateManualCaseWithDocument();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var res = await PostBesluit(a.Id, new { besluit = "Goedkeuren" });
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
var body = (await res.Content.ReadFromJsonAsync<RecordBesluitResponse>())!;
|
||||||
|
Assert.Equal("Goedgekeurd", body.Status.Tag);
|
||||||
|
|
||||||
|
var detail = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}"));
|
||||||
|
var view = (await detail.Content.ReadFromJsonAsync<BeoordelingViewDto>())!;
|
||||||
|
Assert.Equal("Goedgekeurd", view.Aanvraag.Status.Tag);
|
||||||
|
Assert.False(view.Decisions.CanBesluiten); // terminal — no further decision allowed
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteAsAdmin(a.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Afwijzen_requires_a_toelichting()
|
||||||
|
{
|
||||||
|
var (a, _) = await CreateManualCaseWithDocument();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var missing = await PostBesluit(a.Id, new { besluit = "Afwijzen" });
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode);
|
||||||
|
|
||||||
|
var res = await PostBesluit(a.Id, new { besluit = "Afwijzen", toelichting = "Diploma niet erkend" });
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
var body = (await res.Content.ReadFromJsonAsync<RecordBesluitResponse>())!;
|
||||||
|
Assert.Equal("Afgewezen", body.Status.Tag);
|
||||||
|
Assert.Equal("Diploma niet erkend", body.Status.Reden);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteAsAdmin(a.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MeerInfoOpvragen_is_still_decidable_afterwards()
|
||||||
|
{
|
||||||
|
var (a, _) = await CreateManualCaseWithDocument();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var res = await PostBesluit(a.Id, new { besluit = "MeerInfoOpvragen", toelichting = "Stuur een geldig diploma" });
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
var body = (await res.Content.ReadFromJsonAsync<RecordBesluitResponse>())!;
|
||||||
|
Assert.Equal("MeerInfoGevraagd", body.Status.Tag);
|
||||||
|
|
||||||
|
var detail = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}"));
|
||||||
|
var view = (await detail.Content.ReadFromJsonAsync<BeoordelingViewDto>())!;
|
||||||
|
Assert.True(view.Decisions.CanBesluiten); // not terminal — a decision can still follow
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteAsAdmin(a.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Already_decided_case_rejects_a_further_besluit()
|
||||||
|
{
|
||||||
|
var (a, _) = await CreateManualCaseWithDocument();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
(await PostBesluit(a.Id, new { besluit = "Goedkeuren" })).EnsureSuccessStatusCode();
|
||||||
|
var again = await PostBesluit(a.Id, new { besluit = "Afwijzen", toelichting = "te laat" });
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteAsAdmin(a.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_id_404s_and_zorgverlener_is_forbidden()
|
||||||
|
{
|
||||||
|
var notFound = await PostBesluit("does-not-exist", new { besluit = "Goedkeuren" });
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, notFound.StatusCode);
|
||||||
|
|
||||||
|
var (a, _) = await CreateManualCaseWithDocument();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var req = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/beoordeling/{a.Id}/besluit")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new { besluit = "Goedkeuren" }),
|
||||||
|
};
|
||||||
|
req.Headers.Add("X-Role", "admin"); // zorgverlener, no X-Medewerker
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await DeleteAsAdmin(a.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WP-65 — Behandelportal: zaak detail + beoordeling (decision) screen
|
# WP-65 — Behandelportal: zaak detail + beoordeling (decision) screen
|
||||||
|
|
||||||
Status: in progress (65a — detail read — done; 65b — decision write — not started)
|
Status: done (65a — detail read — done; 65b — decision write — done)
|
||||||
Phase: 11 — Behandelportal
|
Phase: 11 — Behandelportal
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
@@ -80,7 +80,7 @@ one gate for every behandelaar endpoint, `resource` feeding the audit row; the o
|
|||||||
|
|
||||||
**Decision-readiness (`BeoordelingDecisionsDto.canBesluiten`) ships now, not deferred to
|
**Decision-readiness (`BeoordelingDecisionsDto.canBesluiten`) ships now, not deferred to
|
||||||
65b:** `BeoordelingRules.CanDecide(AanvraagStatusTag)` only inspects the aanvraag's current
|
65b:** `BeoordelingRules.CanDecide(AanvraagStatusTag)` only inspects the aanvraag's current
|
||||||
*computed* status tag (`Ingediend`/`InBehandeling`/`MeerInfoGevraagd` → decidable;
|
_computed_ status tag (`Ingediend`/`InBehandeling`/`MeerInfoGevraagd` → decidable;
|
||||||
`Goedgekeurd`/`Afgewezen` → not) — no persisted "was a decision recorded" field exists yet,
|
`Goedgekeurd`/`Afgewezen` → not) — no persisted "was a decision recorded" field exists yet,
|
||||||
so this pure rule needed nothing from 65b's eventual migration to be correct today. 65b adds
|
so this pure rule needed nothing from 65b's eventual migration to be correct today. 65b adds
|
||||||
the mutation, the `Besluit` enum, and the transition-legality check that reuses this same
|
the mutation, the `Besluit` enum, and the transition-legality check that reuses this same
|
||||||
@@ -110,14 +110,62 @@ yet — 65a is infrastructure the decision screen needs, not a slice of the AC i
|
|||||||
test, backend test — 197/197 including this WP's 9 new tests). Only the api-client-drift
|
test, backend test — 197/197 including this WP's 9 new tests). Only the api-client-drift
|
||||||
step shows the expected pre-commit diff (this WP's own uncommitted endpoint).
|
step shows the expected pre-commit diff (this WP's own uncommitted endpoint).
|
||||||
|
|
||||||
|
## Progress notes (65b — done)
|
||||||
|
|
||||||
|
**Backend (`POST /beoordeling/{id}/besluit`):** runs against `ApplicationStore` directly
|
||||||
|
(not the `IZaakSource` seam) — same reasoning as 65a's GET: a new seam method would force
|
||||||
|
an `OpenZaakZaakSource` write now, which stays WP-66's surface. A new `Besluit` enum
|
||||||
|
(`Goedkeuren | Afwijzen | MeerInfoOpvragen`) backs a nullable `Aanvraag.BesluitStatus` +
|
||||||
|
`Aanvraag.BesluitToelichting` column pair (EF migration `BesluitStatus`). Like every other
|
||||||
|
enum in this backend, `Besluit` never crosses the wire as a raw C# enum — no
|
||||||
|
`JsonStringEnumConverter` is configured, so `RecordBesluitRequest.Besluit` is a plain
|
||||||
|
`string`, parsed with `Enum.TryParse` (400 on an unknown name) — the same wire convention
|
||||||
|
`AanvraagStatusDto.Tag` already established. The endpoint reuses
|
||||||
|
`BeoordelingRules.CanDecide` — the SAME function the read side's `canBesluiten` flag calls
|
||||||
|
— as the transition-legality check, so the two can never drift (409 on an illegal
|
||||||
|
transition, e.g. deciding an already-`Goedgekeurd` case again). Toelichting is required
|
||||||
|
(400) for Afwijzen/MeerInfoOpvragen, optional for Goedkeuren — enforced server-side because
|
||||||
|
the published `AanvraagStatusDto`'s `Reden` field is non-optional on those two tags (the
|
||||||
|
FE's existing `parseBeoordelingStatus` already required it). `Mappers.ToStatusDto` gained
|
||||||
|
"a recorded decision wins" between the submit-time `Reden` check and the auto-approve
|
||||||
|
computation — the two never collide in practice (a submit-time-rejected case is already
|
||||||
|
terminal and never reaches the werkvoorraad/beoordeling screens, so no behandelaar ever
|
||||||
|
records a besluit on one). `MeerInfoGevraagd` is not terminal: `CanDecide` still allows a
|
||||||
|
further besluit afterwards, so a behandelaar can ask for info, then later approve/reject
|
||||||
|
once it arrives — the same `BesluitStatus` column is simply overwritten.
|
||||||
|
|
||||||
|
**FE:** `besluit.machine.ts` is the same single-step Editing/Submitting/Submitted/Failed
|
||||||
|
union as `change-request.machine.ts` (form-machine skill) — `Draft.besluit` stays a raw
|
||||||
|
string (parsed into the narrow `BesluitTag` union only in `validate`, "parse, don't
|
||||||
|
validate"), so the generic `SetField` reducer case needs no per-field typing gymnastics.
|
||||||
|
`besluit-form` (organism) composes `RadioGroupComponent` (the three actions) +
|
||||||
|
`FormFieldComponent`/`TextInputComponent` (toelichting, plain single-line — no textarea
|
||||||
|
atom exists and this form doesn't justify adding one) — no new shared atom. On a
|
||||||
|
successful decision it emits `decided`, and `BeoordelingPage` just calls
|
||||||
|
`BeoordelingStore.reload()` — the server is the authority on the new status, the page
|
||||||
|
never guesses it. The form only renders when the server's `canBesluiten` flag is true
|
||||||
|
(ADR-0001: render the decision, don't recompute the lifecycle).
|
||||||
|
|
||||||
|
Re-ran the full acceptance-criteria smoke by hand against `LocalZaakSource`: created a
|
||||||
|
manual registratie case, opened it via werkvoorraad → beoordeling, recorded Afwijzen with a
|
||||||
|
toelichting (status → Afgewezen, reason shown), confirmed a further besluit on that same
|
||||||
|
case now 409s. `npm run ci` green (lint, dep:check ×2, format:check, check:tokens, all four
|
||||||
|
projects' test suites, both apps' localized `nl`+`en` builds, backend `dotnet format
|
||||||
|
--verify-no-changes` + `dotnet test` — 201/201 including this WP's 5 new tests, api-client
|
||||||
|
regenerated). One pre-existing, unrelated finding: `format:check` was already red on this
|
||||||
|
branch before this session touched anything — `docs/project/backlog/README.md` has a
|
||||||
|
long-standing prettier drift (a big markdown table) untouched by this WP; fixed the same
|
||||||
|
class of drift in this file's own body (`*computed*` → `_computed_`) since this WP was
|
||||||
|
already editing it, left `README.md` alone as out of scope.
|
||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] A medewerker can view one aanvraag's detail and record a decision that advances its
|
- [x] A medewerker can view one aanvraag's detail and record a decision that advances its
|
||||||
status.
|
status.
|
||||||
- [ ] Illegal transitions are rejected server-side (tested).
|
- [x] Illegal transitions are rejected server-side (tested).
|
||||||
- [ ] End-to-end smoke: werkvoorraad → detail → decision → status change reflected back
|
- [x] End-to-end smoke: werkvoorraad → detail → decision → status change reflected back
|
||||||
in the queue.
|
in the queue.
|
||||||
- [ ] `npm run ci` (behandelportal app) + `dotnet test` green.
|
- [x] `npm run ci` (behandelportal app) + `dotnet test` green.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
|
|||||||
@@ -1165,6 +1165,71 @@ export class ApiClient {
|
|||||||
return Promise.resolve<BeoordelingViewDto>(null as any);
|
return Promise.resolve<BeoordelingViewDto>(null as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return OK
|
||||||
|
*/
|
||||||
|
besluit(id: string, body: RecordBesluitRequest): Promise<RecordBesluitResponse> {
|
||||||
|
let url_ = this.baseUrl + "/api/v1/beoordeling/{id}/besluit";
|
||||||
|
if (id === undefined || id === null)
|
||||||
|
throw new globalThis.Error("The parameter 'id' must be defined.");
|
||||||
|
url_ = url_.replace("{id}", encodeURIComponent("" + id));
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
const content_ = JSON.stringify(body);
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
body: content_,
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||||
|
return this.processBesluit(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processBesluit(response: Response): Promise<RecordBesluitResponse> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result200: any = null;
|
||||||
|
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as RecordBesluitResponse;
|
||||||
|
return result200;
|
||||||
|
});
|
||||||
|
} else if (status === 400) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result400: any = null;
|
||||||
|
result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||||
|
return throwException("Bad Request", status, _responseText, _headers, result400);
|
||||||
|
});
|
||||||
|
} else if (status === 403) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result403: any = null;
|
||||||
|
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||||
|
return throwException("Forbidden", status, _responseText, _headers, result403);
|
||||||
|
});
|
||||||
|
} else if (status === 404) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("Not Found", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
} else if (status === 409) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result409: any = null;
|
||||||
|
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||||
|
return throwException("Conflict", status, _responseText, _headers, result409);
|
||||||
|
});
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<RecordBesluitResponse>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return No Content
|
* @return No Content
|
||||||
*/
|
*/
|
||||||
@@ -2248,6 +2313,15 @@ export interface PublishOrgTemplateResponse {
|
|||||||
affectedUnsentBriefs?: number;
|
affectedUnsentBriefs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RecordBesluitRequest {
|
||||||
|
besluit?: string | undefined;
|
||||||
|
toelichting?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordBesluitResponse {
|
||||||
|
status?: AanvraagStatusDto;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReferentieResponse {
|
export interface ReferentieResponse {
|
||||||
referentie?: string | undefined;
|
referentie?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user