diff --git a/docs/schema-wuensche/vorgaenge.md b/docs/schema-wuensche/vorgaenge.md new file mode 100644 index 0000000..a8bd3e8 --- /dev/null +++ b/docs/schema-wuensche/vorgaenge.md @@ -0,0 +1,136 @@ +# Schema-Wünsche: Vorgänge, Aufgaben, Genehmigungen + +Beim Bau von Etappe 2 („Vorgänge (Fallakte)“) sind vier Stellen aufgefallen, +an denen das Datenmodell die Fachlichkeit nur mit Umwegen abbildet. Nichts +davon blockiert die Umsetzung — alles ist im Code sauber umschifft. Die +Umwege stehen unten mit dabei, damit man beim Ändern weiß, was wegfällt. + +`prisma/schema.prisma` wurde **nicht** angefasst. + +--- + +## 1. `Genehmigung`: Die Versagung hat kein eigenes Datum + +**Ist**: `mitteilungAm`, `antragAm`, `anhoerungAm`, `beschlussAm`, +`rechtskraftAm`, `vollzugAm` — für jede Stufe der Kette ein Feld. Nur der +Seitenausgang `VERSAGT` hat keins. + +**Warum das stört**: Der Zeitstrahl der Fallakte baut die Chronik des +Genehmigungsverfahrens aus genau diesen Datumsfeldern. Eine Versagung ist +aber der Ausgang, der später am meisten Erklärung braucht — gegenüber dem +Gericht, gegenüber Miterben, und für die Frage, ob eine Beschwerde noch +läuft. + +**Wunsch**: + +```prisma +versagungAm DateTime? @db.Date +versagungsgrund String? +``` + +**Umweg bis dahin**: `src/lib/vorgaenge/zeitstrahl.ts` zeigt eine Versagung +am `beschlussAm` an — das Gericht versagt ja mit einem Beschluss. Der Grund +landet in `notizenVerschluesselt`. + +--- + +## 2. `Aufgabe`: kein Papierkorb + +**Ist**: `Aufgabe` hat kein `geloeschtAm`. Sie hängt per `onDelete: Cascade` +am Vorgang. + +**Warum das stört**: Vorlagen bringen bis zu zehn Aufgaben mit. Manche davon +passen im Einzelfall nicht („Rundfunkbeitrag“, wenn nie einer gezahlt +wurde). Sie einfach abzuhaken ist unehrlich — sie wurden nicht erledigt, +sie waren gegenstandslos. Löschen wiederum ist im Rest der Anwendung immer +ein Papierkorb, hier wäre es endgültig. + +**Wunsch**: + +```prisma +geloeschtAm DateTime? +entfaellt Boolean @default(false) +entfallGrund String? +``` + +`entfaellt` wäre das Ehrlichere: Die Aufgabe bleibt in der Akte sichtbar, +zählt aber nicht gegen den Fortschritt. + +**Umweg bis dahin**: Es gibt in dieser Etappe keine Möglichkeit, eine +Aufgabe zu entfernen. Nicht passende Aufgaben bleiben offen stehen. + +--- + +## 3. Statuswechsel des Vorgangs stehen nur im Audit-Log + +**Ist**: Der Zeitstrahl liest die Statuschronik eines Vorgangs aus +`AuditLog`, entschlüsselt `aenderungVerschluesselt` und sucht darin das +Feld `status`. + +**Warum das stört**: Drei Dinge auf einmal — + +1. Es werden bis zu 200 Log-Einträge je Aufruf entschlüsselt, nur um ein + Feld zu lesen. Das skaliert schlecht und lässt sich nicht filtern, + weil der Umschlag nicht durchsuchbar ist. +2. Das Audit-Log ist ein Nachweis, keine Fachtabelle. Es fachlich + auszuwerten vermischt zwei Zwecke. +3. Ein Log-Eintrag, der sich nicht entschlüsseln lässt (Schlüsselrotation), + reißt still ein Loch in die Chronik. + +**Wunsch**: eine schmale Tabelle + +```prisma +model VorgangStatuswechsel { + id String @id @default(cuid()) + vorgangId String + vorgang Vorgang @relation(fields: [vorgangId], references: [id], onDelete: Cascade) + von VorgangStatus? + nach VorgangStatus + zeitpunkt DateTime @default(now()) + userId String? + userName String? + @@index([vorgangId, zeitpunkt]) +} +``` + +**Umweg bis dahin**: `src/app/(app)/vorgaenge/[id]/page.tsx` liest das +Audit-Log und fängt unlesbare Einträge einzeln ab. Die Schnittstelle von +`zeitstrahlBauen()` nimmt die Wechsel bereits als eigene Quelle entgegen — +ein Umstieg auf die Tabelle wäre eine Änderung in einer Datei. + +--- + +## 4. `Vorgang`: kein Feld für den Ansprechpartner bei der Gegenstelle + +**Ist**: `gegenstelle` (Text) und `aktenzeichen` (Text), dazu die n:m- +Beziehung `kontakte`. Wer bei der Behörde konkret zuständig ist, steht +nirgends direkt am Vorgang. + +**Warum das stört**: Die Sachstandsanfrage +(`src/lib/vorgaenge/sachstandsanfrage.ts`) kann eine namentliche Anrede +erzeugen — genau die, die erfahrungsgemäß schneller beantwortet wird. Sie +bekommt den Namen aber nicht, ohne über `kontakte` zu raten, welcher der +verknüpften Kontakte gemeint ist. + +**Wunsch**: + +```prisma +/// Der eine Kontakt, der diesen Vorgang bei der Gegenstelle führt. +ansprechpartnerId String? +ansprechpartner Kontakt? @relation("VorgangAnsprechpartner", fields: [ansprechpartnerId], references: [id], onDelete: SetNull) +``` + +**Umweg bis dahin**: `sachstandsanfrageErzeugen()` nimmt den +Ansprechpartner als optionalen Parameter entgegen und fällt sonst auf +„Sehr geehrte Damen und Herren“ zurück. Die Seite übergibt ihn derzeit +nicht. + +--- + +## Nicht gewünscht + +Ausdrücklich **kein** Wunsch ist ein Enum für +`Genehmigung.rechtsgrundlage`. Der Freitext ist richtig: Die Auswahlliste +in `src/lib/vorgaenge/genehmigungskette.ts` deckt den Alltag ab, aber +seltene Tatbestände muss man eintragen können, ohne eine Migration zu +schreiben. diff --git a/src/app/(app)/vorgaenge/[id]/page.tsx b/src/app/(app)/vorgaenge/[id]/page.tsx new file mode 100644 index 0000000..915eb55 --- /dev/null +++ b/src/app/(app)/vorgaenge/[id]/page.tsx @@ -0,0 +1,379 @@ +import { notFound } from 'next/navigation' +import Link from 'next/link' +import { ArrowLeft, Clock } from 'lucide-react' +import type { VorgangStatus } from '@prisma/client' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { SeitenKopf } from '@/components/seiten-kopf' +import { AufgabenListe, type AufgabenZeile } from '@/components/vorgaenge/aufgaben-liste' +import { Faelligkeit } from '@/components/vorgaenge/faelligkeit' +import { GenehmigungAnlegen } from '@/components/vorgaenge/genehmigung-anlegen' +import { + GenehmigungsKette, + type GenehmigungsAnzeige, +} from '@/components/vorgaenge/genehmigungs-kette' +import { SachstandsanfrageKnopf } from '@/components/vorgaenge/sachstandsanfrage-knopf' +import { StatusWechsler } from '@/components/vorgaenge/status-wechsler' +import { VorgangLoeschen } from '@/components/vorgaenge/vorgang-loeschen' +import { VorgangsFormular } from '@/components/vorgaenge/vorgangs-formular' +import { ZeitstrahlAnzeige } from '@/components/vorgaenge/zeitstrahl-anzeige' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { centZuZahl, db, nichtGeloescht } from '@/lib/db' +import { entschluesselnWennMoeglich, oeffnen } from '@/lib/crypto/feld' +import { KATEGORIE_BESCHRIFTUNG, PRIORITAET_BESCHRIFTUNG } from '@/lib/validierung/vorgaenge' +import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage' +import { + VORGANG_STATUS_BESCHRIFTUNG, + zeitstrahlBauen, + type ZeitstrahlStatuswechsel, +} from '@/lib/vorgaenge/zeitstrahl' +import { formatDatum } from '@/lib/utils' + +/** + * Die Fallakte. + * + * Kopf mit Status, Gegenstelle, Aktenzeichen und dem nächsten Handgriff; + * darunter die Aufgabenliste, das Genehmigungsverfahren und der + * Zeitstrahl, der alles zusammenführt, was in dieser Sache passiert ist. + */ +export default async function VorgangSeite({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const benutzer = await erfordereBenutzer(`/vorgaenge/${id}`) + const aktive = await aktiveBetreute(benutzer) + if (!aktive) notFound() + + const vorgang = await db.vorgang.findFirst({ + where: { id, betreuteId: aktive.id, ...nichtGeloescht }, + include: { + aufgaben: { orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }] }, + genehmigungen: { + where: nichtGeloescht, + include: { beschlussDokument: { select: { id: true, titel: true } } }, + orderBy: { erstelltAm: 'asc' }, + }, + fristen: { where: nichtGeloescht, orderBy: { datum: 'asc' } }, + buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, + kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, + verknuepfungen: { include: { dokument: true } }, + }, + }) + if (!vorgang) notFound() + + const { darfSchreiben } = await erfordereZugang(aktive.id, 'VORGAENGE', 'lesen') + const schreiben = darfSchreiben('VORGAENGE') + + const offen = oeffnen('Vorgang', vorgang) + + // --- Statuswechsel aus dem Audit-Log -------------------------------------- + // Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die + // Statuschronik zu lesen, spart eine zusaetzliche Tabelle. + const protokolle = await db.auditLog.findMany({ + where: { entitaet: 'Vorgang', entitaetId: vorgang.id, aktion: 'GEAENDERT' }, + orderBy: { zeitpunkt: 'asc' }, + take: 200, + }) + + const statuswechsel: ZeitstrahlStatuswechsel[] = [] + for (const eintrag of protokolle) { + const roh = entschluesselnWennMoeglich( + eintrag.aenderungVerschluesselt, + 'aenderungVerschluesselt', + ) + if (!roh) continue + try { + const gelesen = JSON.parse(roh) as Record + const status = gelesen.status + if (!status || typeof status.neu !== 'string') continue + statuswechsel.push({ + id: eintrag.id, + zeitpunkt: eintrag.zeitpunkt, + von: typeof status.alt === 'string' ? (status.alt as VorgangStatus) : null, + nach: status.neu as VorgangStatus, + wer: eintrag.userName, + }) + } catch { + // Ein unlesbarer Protokolleintrag darf die Akte nicht blockieren. + continue + } + } + + // --- Zeitstrahl ----------------------------------------------------------- + const zeitstrahl = zeitstrahlBauen({ + vorgang: { + titel: vorgang.titel, + erstelltAm: vorgang.erstelltAm, + erledigtAm: vorgang.erledigtAm, + }, + dokumente: vorgang.verknuepfungen + .map((v) => v.dokument) + .filter((d) => d.geloeschtAm === null) + .map((d) => ({ + id: d.id, + titel: d.titel, + eingangAm: d.eingangAm, + versandAm: d.versandAm, + erstelltAm: d.erstelltAm, + absender: d.absender, + empfaenger: d.empfaenger, + })), + kontaktprotokolle: vorgang.kontaktprotokolle.map((p) => { + const klar = oeffnen('Kontaktprotokoll', p) + return { + id: p.id, + datum: p.datum, + art: p.art, + gespraechspartner: p.gespraechspartner, + mitBetreuter: p.mitBetreuter, + inhalt: klar.inhalt ?? null, + } + }), + fristen: vorgang.fristen.map((f) => ({ + id: f.id, + titel: f.titel, + datum: f.datum, + erledigtAm: f.erledigtAm, + ausschlussfrist: f.ausschlussfrist, + rechtsgrundlage: f.rechtsgrundlage, + })), + buchungen: vorgang.buchungen.map((b) => { + const klar = oeffnen('Buchung', b) + return { + id: b.id, + datum: b.datum, + betragCent: centZuZahl(b.betragCent) ?? 0, + richtung: b.richtung, + gegenstelle: b.gegenstelle, + verwendungszweck: klar.verwendungszweck ?? null, + } + }), + genehmigungen: vorgang.genehmigungen.map((g) => ({ + id: g.id, + titel: g.titel, + rechtsgrundlage: g.rechtsgrundlage, + status: g.status, + mitteilungAm: g.mitteilungAm, + antragAm: g.antragAm, + anhoerungAm: g.anhoerungAm, + beschlussAm: g.beschlussAm, + rechtskraftAm: g.rechtskraftAm, + vollzugAm: g.vollzugAm, + })), + statuswechsel, + }) + + // --- Sachstandsanfrage ---------------------------------------------------- + // Nur, wenn tatsaechlich auf jemanden gewartet wird - sonst waere der + // Knopf nur Zierde. + const wartetAufDritte = vorgang.status === 'WARTET_AUF_DRITTE' + const anfrage = wartetAufDritte + ? sachstandsanfrageErzeugen({ + vorgangTitel: vorgang.titel, + gegenstelle: vorgang.gegenstelle, + aktenzeichen: vorgang.aktenzeichen, + letzterKontaktAm: vorgang.letzterKontaktAm, + betreute: { + vorname: aktive.vorname, + nachname: aktive.nachname, + geburtsdatum: aktive.geburtsdatum, + }, + betreuer: { + name: benutzer.name, + aktenzeichenGericht: aktive.aktenzeichenGericht, + betreuungsgericht: aktive.betreuungsgericht, + }, + }) + : null + + // Dokumente der betreuten Person - Auswahl fuer das Beschlussdokument. + const dokumente = vorgang.genehmigungen.length + ? await db.dokument.findMany({ + where: { betreuteId: aktive.id, ...nichtGeloescht }, + select: { id: true, titel: true }, + orderBy: { erstelltAm: 'desc' }, + take: 100, + }) + : [] + + const aufgaben: AufgabenZeile[] = vorgang.aufgaben.map((a) => ({ + id: a.id, + titel: a.titel, + beschreibung: a.beschreibung, + erledigt: a.erledigt, + faelligAm: a.faelligAm, + })) + + const genehmigungen: GenehmigungsAnzeige[] = vorgang.genehmigungen.map((g) => ({ + id: g.id, + titel: g.titel, + rechtsgrundlage: g.rechtsgrundlage, + status: g.status, + mitteilungAm: g.mitteilungAm, + antragAm: g.antragAm, + anhoerungAm: g.anhoerungAm, + beschlussAm: g.beschlussAm, + rechtskraftAm: g.rechtskraftAm, + vollzugAm: g.vollzugAm, + beschlussDokumentId: g.beschlussDokumentId, + beschlussDokumentTitel: g.beschlussDokument?.titel ?? null, + dokumente, + })) + + return ( +
+ + + {anfrage && ( + + )} + {schreiben && } + + } + /> + + {/* --- Kopf der Akte ---------------------------------------------- */} + + +
+

Status

+

{VORGANG_STATUS_BESCHRIFTUNG[vorgang.status]}

+ {schreiben && ( + + )} +
+ +
+

Gegenstelle

+

{vorgang.gegenstelle ?? '–'}

+ {vorgang.letzterKontaktAm && ( +

+

+ )} +
+ +
+

Aktenzeichen

+

{vorgang.aktenzeichen ?? '–'}

+
+ +
+

Nächster Schritt

+

+ {vorgang.naechsterSchritt ?? 'Kein offener Handgriff notiert.'} +

+
+ +
+

Einordnung

+
+ {KATEGORIE_BESCHRIFTUNG[vorgang.kategorie]} + + {PRIORITAET_BESCHRIFTUNG[vorgang.prioritaet] ?? 'Normal'} + + +
+
+
+
+ + {/* --- Aufgaben ----------------------------------------------------- */} +
+

+ Aufgaben +

+ +
+ + {/* --- Genehmigungen ------------------------------------------------ */} + {(vorgang.genehmigungspflichtig || genehmigungen.length > 0) && ( +
+

+ Genehmigung des Betreuungsgerichts +

+ + {genehmigungen.map((genehmigung) => ( + + ))} + + {schreiben && genehmigungen.length === 0 && ( + + )} +
+ )} + + {/* --- Zeitstrahl --------------------------------------------------- */} +
+

+ Zeitstrahl +

+

+ Dokumente, Gespräche, Fristen, Zahlungen und Statuswechsel in einer Spalte - neueste + zuerst. +

+ +
+ + {/* --- Bearbeiten --------------------------------------------------- */} + {schreiben && ( +
+ + + Angaben bearbeiten + + + + + +
+ )} +
+ ) +} diff --git a/src/components/vorgaenge/status-wechsler.tsx b/src/components/vorgaenge/status-wechsler.tsx index 8f4bb75..9613733 100644 --- a/src/components/vorgaenge/status-wechsler.tsx +++ b/src/components/vorgaenge/status-wechsler.tsx @@ -6,9 +6,8 @@ import type { VorgangStatus } from '@prisma/client' import { Button } from '@/components/ui/button' import { Select } from '@/components/ui/select' import { vorgangStatusWechseln } from '@/server/actions/vorgaenge' -import { VORGANG_STATUS } from '@/lib/validierung/vorgaenge' +import { VORGANG_STATUS, type Formularzustand } from '@/lib/validierung/vorgaenge' import { VORGANG_STATUS_BESCHRIFTUNG } from '@/lib/vorgaenge/zeitstrahl' -import type { Formularzustand } from '@/lib/validierung/vorgaenge' import { cn } from '@/lib/utils' /** diff --git a/src/lib/vorgaenge/vorlagen.ts b/src/lib/vorgaenge/vorlagen.ts index 574f4af..9cc568d 100644 --- a/src/lib/vorgaenge/vorlagen.ts +++ b/src/lib/vorgaenge/vorlagen.ts @@ -746,15 +746,16 @@ export type VorlagenErgebnis = { } /** - * Der Tagesanteil eines Datums, auf Mitternacht UTC normiert. + * Bildet den örtlichen Kalendertag auf Mitternacht UTC ab. * - * Fristen und Fälligkeiten sind in der Datenbank @db.Date - ohne diese - * Normierung verschiebt die Zeitzone eine Frist um einen Tag. + * Fristen und Fälligkeiten sind in der Datenbank @db.Date. Wer hier den + * Zeitstempel einfach abschneidet, verschiebt in Deutschland jede Frist um + * einen Tag nach hinten - der 22.08. um 01:00 Uhr Ortszeit ist in UTC noch + * der 21.08. Deshalb wird der Tag gelesen, wie ihn der Kalender an der Wand + * zeigt, und dann als Mitternacht UTC abgelegt. */ export function alsTag(datum: Date): Date { - return new Date( - Date.UTC(datum.getUTCFullYear(), datum.getUTCMonth(), datum.getUTCDate(), 0, 0, 0, 0), - ) + return new Date(Date.UTC(datum.getFullYear(), datum.getMonth(), datum.getDate(), 0, 0, 0, 0)) } export function vorlageAnwenden(schluessel: string, start: Date = new Date()): VorlagenErgebnis { @@ -763,22 +764,22 @@ export function vorlageAnwenden(schluessel: string, start: Date = new Date()): V throw new Error(`Unbekannte Vorgangs-Vorlage: ${schluessel}`) } - const basis = alsTag(start) - + // Gerechnet wird auf dem oertlichen Datum (date-fns ist dabei + // sommerzeitfest), erst das Ergebnis wird auf Mitternacht UTC normiert. return { vorlage, aufgaben: vorlage.aufgaben.map((aufgabe, index) => ({ titel: aufgabe.titel, beschreibung: aufgabe.beschreibung ?? null, faelligAm: - aufgabe.tageBisFaellig === undefined ? null : alsTag(addDays(basis, aufgabe.tageBisFaellig)), + aufgabe.tageBisFaellig === undefined ? null : alsTag(addDays(start, aufgabe.tageBisFaellig)), // Die Reihenfolge ist der fachliche Kern der Vorlage - sie wird // festgeschrieben, damit spaeteres Sortieren sie nicht zerstoert. reihenfolge: index, })), fristen: (vorlage.fristen ?? []).map((frist) => ({ titel: frist.titel, - datum: alsTag(addDays(basis, frist.tageBisFaellig)), + datum: alsTag(addDays(start, frist.tageBisFaellig)), rechtsgrundlage: frist.rechtsgrundlage ?? null, })), } diff --git a/tests/vorgaenge.test.ts b/tests/vorgaenge.test.ts new file mode 100644 index 0000000..2a64f5e --- /dev/null +++ b/tests/vorgaenge.test.ts @@ -0,0 +1,494 @@ +import { describe, expect, it } from 'vitest' +import { + darfStatusWechseln, + GENEHMIGUNGSKETTE, + moeglicheStatusWechsel, + naechsterStatus, + RECHTSGRUNDLAGEN, + stufeVon, +} from '@/lib/vorgaenge/genehmigungskette' +import { VORLAGEN, vorlageAnwenden, vorlageFinden } from '@/lib/vorgaenge/vorlagen' +import { zeitstrahlBauen } from '@/lib/vorgaenge/zeitstrahl' +import { + nachfassenFaellig, + sachstandsanfrageErzeugen, +} from '@/lib/vorgaenge/sachstandsanfrage' + +/** + * Diese Tests beschreiben, was fachlich gelten soll - nicht, welche + * Funktion aufgerufen wird. Keine Datenbank: alles hier ist reine Logik. + */ + +// ============================================================================= +describe('Genehmigungskette', () => { + it('läuft in der vorgeschriebenen Reihenfolge vom Plan bis zum Vollzug', () => { + expect(GENEHMIGUNGSKETTE).toEqual([ + 'GEPLANT', + 'MITGETEILT', + 'BEANTRAGT', + 'ANGEHOERT', + 'ERTEILT', + 'RECHTSKRAEFTIG', + 'VOLLZOGEN', + ]) + }) + + it('lässt jeweils genau eine Stufe vorwärts zu', () => { + for (let i = 0; i < GENEHMIGUNGSKETTE.length - 1; i++) { + const von = GENEHMIGUNGSKETTE[i]! + const nach = GENEHMIGUNGSKETTE[i + 1]! + // Der letzte Schritt braucht den Beschluss - der wird unten geprüft. + const beschluss = nach === 'VOLLZOGEN' ? 'dok-1' : null + expect(darfStatusWechseln({ status: von, beschlussDokumentId: beschluss }, nach).erlaubt).toBe( + true, + ) + expect(naechsterStatus(von)).toBe(nach) + } + }) + + it('verweigert das Überspringen von Stufen und nennt die übersprungenen', () => { + const pruefung = darfStatusWechseln( + { status: 'BEANTRAGT', beschlussDokumentId: 'dok-1' }, + 'VOLLZOGEN', + ) + expect(pruefung.erlaubt).toBe(false) + expect(pruefung.grund).toContain('Anhörung erfolgt') + expect(pruefung.grund).toContain('§ 1858 BGB') + }) + + it('erlaubt eine Stufe zurück als Korrektur, aber nicht zwei', () => { + expect(darfStatusWechseln({ status: 'ERTEILT' }, 'ANGEHOERT').erlaubt).toBe(true) + expect(darfStatusWechseln({ status: 'ERTEILT' }, 'BEANTRAGT').erlaubt).toBe(false) + }) + + // --- die abnahmerelevante Regel ------------------------------------------- + it('sperrt den Vollzug, solange kein Beschlussdokument hinterlegt ist', () => { + const ohneBeschluss = darfStatusWechseln( + { status: 'RECHTSKRAEFTIG', beschlussDokumentId: null }, + 'VOLLZOGEN', + ) + expect(ohneBeschluss.erlaubt).toBe(false) + expect(ohneBeschluss.grund).toContain('Beschluss') + + const mitBeschluss = darfStatusWechseln( + { status: 'RECHTSKRAEFTIG', beschlussDokumentId: 'dok-42' }, + 'VOLLZOGEN', + ) + expect(mitBeschluss.erlaubt).toBe(true) + }) + + it('bietet den Vollzug ohne Beschluss gar nicht erst zur Auswahl an', () => { + expect(moeglicheStatusWechsel({ status: 'RECHTSKRAEFTIG', beschlussDokumentId: null })).not.toContain( + 'VOLLZOGEN', + ) + expect(moeglicheStatusWechsel({ status: 'RECHTSKRAEFTIG', beschlussDokumentId: 'dok-42' })).toContain( + 'VOLLZOGEN', + ) + }) + + it('behandelt den Vollzug als endgültig', () => { + for (const status of [...GENEHMIGUNGSKETTE, 'VERSAGT'] as const) { + if (status === 'VOLLZOGEN') continue + expect( + darfStatusWechseln({ status: 'VOLLZOGEN', beschlussDokumentId: 'dok-1' }, status).erlaubt, + ).toBe(false) + } + }) + + it('lässt die Versagung nur zu, solange etwas anhängig und nichts rechtskräftig ist', () => { + for (const status of ['MITGETEILT', 'BEANTRAGT', 'ANGEHOERT', 'ERTEILT'] as const) { + expect(darfStatusWechseln({ status }, 'VERSAGT').erlaubt).toBe(true) + } + for (const status of ['GEPLANT', 'RECHTSKRAEFTIG'] as const) { + expect(darfStatusWechseln({ status }, 'VERSAGT').erlaubt).toBe(false) + } + }) + + it('führt nach einer Versagung zurück auf den Anfang - und nur dorthin', () => { + expect(darfStatusWechseln({ status: 'VERSAGT' }, 'GEPLANT').erlaubt).toBe(true) + expect(darfStatusWechseln({ status: 'VERSAGT' }, 'BEANTRAGT').erlaubt).toBe(false) + expect(stufeVon('VERSAGT')).toBe(-1) + }) + + it('meldet einen Wechsel auf den bereits gesetzten Status als unnötig', () => { + const pruefung = darfStatusWechseln({ status: 'BEANTRAGT' }, 'BEANTRAGT') + expect(pruefung.erlaubt).toBe(false) + expect(pruefung.grund).toContain('bereits') + }) + + it('erklärt jede wählbare Rechtsgrundlage im Klartext', () => { + const werte = RECHTSGRUNDLAGEN.map((r) => r.wert) + for (const paragraph of [ + '§ 1850 BGB', + '§ 1833 BGB', + '§ 1854 BGB', + '§ 1851 BGB', + '§ 1849 BGB', + '§ 1859 BGB', + ]) { + expect(werte).toContain(paragraph) + } + for (const grundlage of RECHTSGRUNDLAGEN) { + expect(grundlage.erklaerung.length).toBeGreaterThan(40) + expect(grundlage.beispiele.length).toBeGreaterThan(0) + } + }) +}) + +// ============================================================================= +describe('Vorgangs-Vorlagen', () => { + it('bringt die neun Vorlagen mit, auf die sich die anderen Module verlassen', () => { + const schluessel = VORLAGEN.map((v) => v.schluessel) + expect(schluessel).toEqual( + expect.arrayContaining([ + 'heimeinzug', + 'antrag-hilfe-zur-pflege', + 'wohnungsaufloesung', + 'hausverkauf', + 'darlehen-91-sgb-xii', + 'versicherungsverwertung', + 'pflegegrad-hoeherstufung', + 'steuererklaerung', + 'widerspruch-bescheid', + ]), + ) + expect(new Set(schluessel).size).toBe(schluessel.length) + }) + + it('gibt jeder Vorlage eine vorbefüllte Aufgabenliste', () => { + for (const vorlage of VORLAGEN) { + expect(vorlage.aufgaben.length).toBeGreaterThanOrEqual(4) + expect(vorlage.titel.length).toBeGreaterThan(3) + expect(vorlage.beschreibung.length).toBeGreaterThan(20) + } + }) + + it('nennt bei jeder genehmigungspflichtigen Vorlage die Vorschrift', () => { + for (const vorlage of VORLAGEN.filter((v) => v.genehmigungspflichtig)) { + expect(vorlage.rechtsgrundlage).toBeTruthy() + } + expect(vorlageFinden('wohnungsaufloesung')?.rechtsgrundlage).toBe('§ 1833 BGB') + expect(vorlageFinden('hausverkauf')?.rechtsgrundlage).toBe('§ 1850 Nr. 1 BGB') + expect(vorlageFinden('darlehen-91-sgb-xii')?.genehmigungspflichtig).toBe(true) + }) + + it('stellt bei der Wohnungsauflösung die Mitteilung ans Gericht vor die Kündigung', () => { + const aufgaben = vorlageFinden('wohnungsaufloesung')!.aufgaben.map((a) => a.titel) + const mitteilung = aufgaben.findIndex((t) => t.includes('Mitteilung an das Betreuungsgericht')) + const genehmigung = aufgaben.findIndex((t) => t.includes('Genehmigung des Gerichts abwarten')) + const kuendigung = aufgaben.findIndex((t) => t.includes('Mietverhältnis kündigen')) + + expect(mitteilung).toBeGreaterThanOrEqual(0) + expect(mitteilung).toBeLessThan(genehmigung) + expect(genehmigung).toBeLessThan(kuendigung) + }) + + it('holt beim Hausverkauf die Genehmigung vor die Beurkundung', () => { + const aufgaben = vorlageFinden('hausverkauf')!.aufgaben.map((a) => a.titel) + const einreichen = aufgaben.findIndex((t) => t.includes('Kaufvertragsentwurf beim')) + const rechtskraft = aufgaben.findIndex((t) => t.includes('Rechtskraft prüfen')) + const beurkundung = aufgaben.findIndex((t) => t.includes('Beurkundung')) + + expect(einreichen).toBeLessThan(rechtskraft) + expect(rechtskraft).toBeLessThan(beurkundung) + }) + + it('setzt den Widerspruch fristwahrend an den Anfang und legt die Monatsfrist an', () => { + const vorlage = vorlageFinden('widerspruch-bescheid')! + expect(vorlage.aufgaben[0]?.titel).toBe( + 'Widerspruch fristwahrend einlegen, Begründung nachreichen', + ) + const frist = vorlage.fristen?.find((f) => f.rechtsgrundlage === '§ 84 SGG') + expect(frist?.tageBisFaellig).toBe(30) + }) + + it('rechnet die Fälligkeiten vom Anlegedatum aus, ohne Uhrzeit', () => { + const ergebnis = vorlageAnwenden('heimeinzug', new Date(2026, 0, 15, 17, 42)) + + expect(ergebnis.aufgaben).toHaveLength(8) + expect(ergebnis.aufgaben[0]?.titel).toBe('Heimvertrag prüfen und gegenzeichnen') + // 15.01.2026 + 7 Tage = 22.01.2026, Mitternacht UTC (@db.Date). + expect(ergebnis.aufgaben[0]?.faelligAm?.toISOString()).toBe('2026-01-22T00:00:00.000Z') + expect(ergebnis.aufgaben[1]?.faelligAm?.toISOString()).toBe('2026-01-29T00:00:00.000Z') + + // Die Reihenfolge der Vorlage wird festgeschrieben. + expect(ergebnis.aufgaben.map((a) => a.reihenfolge)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + + expect(ergebnis.fristen).toHaveLength(1) + expect(ergebnis.fristen[0]?.datum.toISOString()).toBe('2026-03-16T00:00:00.000Z') + }) + + it('rechnet über einen Monatswechsel hinweg richtig', () => { + const ergebnis = vorlageAnwenden('widerspruch-bescheid', new Date(2026, 1, 20)) + // 20.02.2026 + 30 Tage = 22.03.2026 + expect(ergebnis.fristen[0]?.datum.toISOString()).toBe('2026-03-22T00:00:00.000Z') + }) + + it('weist einen erfundenen Vorlagenschlüssel zurück', () => { + expect(() => vorlageAnwenden('gibt-es-nicht')).toThrowError(/Vorlage/) + }) +}) + +// ============================================================================= +describe('Zeitstrahl der Fallakte', () => { + const grundlage = { + vorgang: { + titel: 'Hausverkauf', + erstelltAm: new Date('2026-01-10T09:00:00.000Z'), + erledigtAm: null, + }, + } + + it('sortiert alle Quellen zusammen, neueste zuerst', () => { + const eintraege = zeitstrahlBauen({ + ...grundlage, + dokumente: [ + { + id: 'd1', + titel: 'Kaufvertragsentwurf', + eingangAm: new Date('2026-03-01T00:00:00.000Z'), + versandAm: null, + erstelltAm: new Date('2026-03-05T00:00:00.000Z'), + absender: 'Notariat Bayreuth', + empfaenger: null, + }, + ], + kontaktprotokolle: [ + { + id: 'k1', + datum: new Date('2026-02-01T10:00:00.000Z'), + art: 'TELEFONAT', + gespraechspartner: 'Rechtspfleger Meier', + mitBetreuter: false, + inhalt: 'Ablauf der Genehmigung besprochen.', + }, + ], + fristen: [ + { + id: 'f1', + titel: 'Wertgutachten anfordern', + datum: new Date('2026-04-01T00:00:00.000Z'), + erledigtAm: null, + ausschlussfrist: false, + rechtsgrundlage: null, + }, + ], + buchungen: [ + { + id: 'b1', + datum: new Date('2026-05-01T00:00:00.000Z'), + betragCent: 150_000, + richtung: 'AUSGABE', + gegenstelle: 'Gutachterbüro', + verwendungszweck: 'Verkehrswertgutachten', + }, + ], + }) + + expect(eintraege.map((e) => e.id)).toEqual([ + 'BUCHUNG:b1', + 'FRIST:f1', + 'DOKUMENT:d1', + 'KONTAKT:k1', + 'ANGELEGT:vorgang', + ]) + }) + + it('kann auch von hinten gelesen werden', () => { + const eintraege = zeitstrahlBauen( + { + ...grundlage, + fristen: [ + { + id: 'f1', + titel: 'Frist A', + datum: new Date('2026-04-01T00:00:00.000Z'), + erledigtAm: null, + ausschlussfrist: true, + rechtsgrundlage: '§ 1878 BGB', + }, + ], + }, + { reihenfolge: 'aelteste-zuerst' }, + ) + + expect(eintraege[0]?.id).toBe('ANGELEGT:vorgang') + expect(eintraege[1]?.titel).toBe('Ausschlussfrist: Frist A') + }) + + it('nimmt beim Dokument den Eingang, sonst den Versand', () => { + const eintraege = zeitstrahlBauen({ + ...grundlage, + dokumente: [ + { + id: 'd1', + titel: 'Antrag', + eingangAm: null, + versandAm: new Date('2026-02-10T00:00:00.000Z'), + erstelltAm: new Date('2026-02-20T00:00:00.000Z'), + absender: null, + empfaenger: 'Bezirk Oberfranken', + }, + ], + }) + + const dokument = eintraege.find((e) => e.id === 'DOKUMENT:d1') + expect(dokument?.datum.toISOString()).toBe('2026-02-10T00:00:00.000Z') + expect(dokument?.detail).toBe('Versand an Bezirk Oberfranken') + }) + + it('macht aus den Datumsfeldern einer Genehmigung die Chronik des Verfahrens', () => { + const eintraege = zeitstrahlBauen({ + ...grundlage, + genehmigungen: [ + { + id: 'g1', + titel: 'Verkauf Hausgrundstück', + rechtsgrundlage: '§ 1850 BGB', + status: 'RECHTSKRAEFTIG', + mitteilungAm: new Date('2026-02-01T00:00:00.000Z'), + antragAm: new Date('2026-02-15T00:00:00.000Z'), + anhoerungAm: null, + beschlussAm: new Date('2026-03-20T00:00:00.000Z'), + rechtskraftAm: new Date('2026-04-05T00:00:00.000Z'), + vollzugAm: null, + }, + ], + }) + + const genehmigung = eintraege.filter((e) => e.art === 'GENEHMIGUNG') + expect(genehmigung).toHaveLength(4) + expect(genehmigung[0]?.titel).toBe('Genehmigung: Rechtskräftig') + expect(genehmigung.at(-1)?.titel).toBe('Genehmigung: Dem Gericht mitgeteilt') + }) + + it('zeigt Ausgaben mit negativem Vorzeichen', () => { + const eintraege = zeitstrahlBauen({ + ...grundlage, + buchungen: [ + { + id: 'b1', + datum: new Date('2026-05-01T00:00:00.000Z'), + betragCent: 4200, + richtung: 'AUSGABE', + gegenstelle: null, + verwendungszweck: null, + }, + { + id: 'b2', + datum: new Date('2026-05-02T00:00:00.000Z'), + betragCent: 9900, + richtung: 'EINNAHME', + gegenstelle: null, + verwendungszweck: null, + }, + ], + }) + + expect(eintraege.find((e) => e.id === 'BUCHUNG:b1')?.betragCent).toBe(-4200) + expect(eintraege.find((e) => e.id === 'BUCHUNG:b2')?.betragCent).toBe(9900) + }) + + it('hält die Reihenfolge bei gleichem Datum stabil', () => { + const gleich = new Date('2026-06-01T00:00:00.000Z') + const einmal = zeitstrahlBauen({ + ...grundlage, + fristen: [ + { + id: 'f1', + titel: 'A', + datum: gleich, + erledigtAm: null, + ausschlussfrist: false, + rechtsgrundlage: null, + }, + ], + dokumente: [ + { + id: 'd1', + titel: 'B', + eingangAm: gleich, + versandAm: null, + erstelltAm: gleich, + absender: null, + empfaenger: null, + }, + ], + }) + + // Dokument vor Frist: was passiert ist, steht über dem, was ansteht. + expect(einmal.map((e) => e.id).slice(0, 2)).toEqual(['DOKUMENT:d1', 'FRIST:f1']) + }) +}) + +// ============================================================================= +describe('Sachstandsanfrage', () => { + const eingabe = { + vorgangTitel: 'Antrag auf Hilfe zur Pflege', + gegenstelle: 'Bezirk Oberfranken', + aktenzeichen: 'HzP 2026/4711', + letzterKontaktAm: new Date(2026, 4, 12), + betreute: { + vorname: 'Erika', + nachname: 'Mustermann', + geburtsdatum: new Date(1948, 2, 3), + }, + betreuer: { + name: 'Florian Wilde', + aktenzeichenGericht: 'XVII 123/25', + betreuungsgericht: 'Amtsgericht Bayreuth', + }, + heute: new Date(2026, 5, 30), + } + + it('nennt Aktenzeichen, Gegenstelle und das Datum des letzten Kontakts', () => { + const anfrage = sachstandsanfrageErzeugen(eingabe) + + expect(anfrage.text).toContain('Bezirk Oberfranken') + expect(anfrage.text).toContain('HzP 2026/4711') + expect(anfrage.text).toContain('12.05.2026') + expect(anfrage.text).toContain('30.06.2026') + expect(anfrage.text).toContain('Erika Mustermann') + expect(anfrage.text).toContain('XVII 123/25') + }) + + it('bittet ausdrücklich um den Sachstand und schließt mit dem Namen des Betreuers', () => { + const anfrage = sachstandsanfrageErzeugen(eingabe) + + expect(anfrage.text).toContain('Sachstand') + expect(anfrage.text).toContain('Mit freundlichen Grüßen') + expect(anfrage.text.trimEnd().endsWith('Rechtlicher Betreuer')).toBe(true) + }) + + it('rechnet die Tage seit dem letzten Kontakt aus', () => { + // 12.05.2026 bis 30.06.2026 sind 49 Tage. + const anfrage = sachstandsanfrageErzeugen(eingabe) + expect(anfrage.tageSeitKontakt).toBe(49) + expect(anfrage.text).toContain('49 Tage') + }) + + it('kommt ohne erfassten letzten Kontakt aus', () => { + const anfrage = sachstandsanfrageErzeugen({ ...eingabe, letzterKontaktAm: null }) + expect(anfrage.tageSeitKontakt).toBeNull() + expect(anfrage.text).toContain('bisher nicht eingegangen') + }) + + it('baut einen Betreff, der die Sache eindeutig macht', () => { + const anfrage = sachstandsanfrageErzeugen(eingabe) + expect(anfrage.betreff).toBe( + 'Sachstandsanfrage: Antrag auf Hilfe zur Pflege · Erika Mustermann, geb. 03.03.1948 · Az. HzP 2026/4711', + ) + }) + + it('spricht einen bekannten Ansprechpartner direkt an', () => { + const anfrage = sachstandsanfrageErzeugen({ ...eingabe, ansprechpartner: 'Frau Schmidt' }) + expect(anfrage.text).toContain('Sehr geehrte(r) Frau Schmidt,') + }) + + it('hält Nachfassen erst nach sechs Wochen für angebracht', () => { + const heute = new Date(2026, 5, 30) + expect(nachfassenFaellig(new Date(2026, 5, 20), heute)).toBe(false) + expect(nachfassenFaellig(new Date(2026, 4, 12), heute)).toBe(true) + expect(nachfassenFaellig(null, heute)).toBe(true) + }) +})