Files
betreuungs-cockpit/tests/vorgaenge.test.ts
T
Florian Wilde 1217725076 feat: Vorgaenge als Fallakte mit Genehmigungskette und Vorlagen
Jeder Vorgang ist eine kleine Akte: Kopfdaten, Aufgabenliste zum
Abhaken, Genehmigungsstufen und ein Zeitstrahl, der Dokumente,
Kontaktprotokolle, Fristen, Buchungen und Statuswechsel zusammenfuehrt.

Die abnahmerelevante Regel: Ein genehmigungspflichtiger Vorgang laesst
sich nicht auf VOLLZOGEN setzen, solange kein Beschlussdokument
hinterlegt ist. Geprueft wird in darfStatusWechseln() UND in der Server
Action - nicht nur im UI, wo das Auswahlfeld die Stufe ohnehin gar nicht
erst anbietet. Hintergrund ist § 1858 BGB: die Genehmigung muss vor dem
Rechtsgeschaeft vorliegen, nicht danach.

Neun Vorlagen mit durchdachter Aufgabenreihenfolge. Bei Wohnungs-
aufloesung und Hausverkauf erzwingen sie die Kette Mitteilung ->
Genehmigung -> Rechtsgeschaeft; ein Test prueft das.

Kanban ohne Drag-and-Drop: Statuswechsel laufen ueber Auswahlfeld und
Knopf. Am Handy ist das verlaesslicher, und am Schreibtisch kostet es
einen Klick mehr statt einer verlorenen Karte.

Zeitzonen: alsTag() bildet den oertlichen Kalendertag auf Mitternacht
UTC ab. Ohne das rutscht in Deutschland jede Faelligkeit einen Tag
zurueck.

33 Tests.

Etappe: 2
Phase: Alltag
2026-08-22 02:24:16 +02:00

495 lines
17 KiB
TypeScript

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)
})
})