Files
Florian Wilde 9e68d10787 Ansprechpartner am Vorgang hinterlegen
Ein namentlich adressiertes Schreiben landet bei der Person, die den
Vorgang kennt, statt in der Poststelle. Am Vorgang laesst sich jetzt ein
Kontakt aus dem Verzeichnis als Ansprechpartner benennen; die
Sachstandsanfrage redet ihn an, und sein Name steht ueber der Behoerde.

Die Sachstandsanfrage konnte das schon - sie bekam den Namen nur nie
uebergeben. entwurf.ts lud einen der verknuepften Kontakte und verwarf
ihn wieder. Wer angeredet wird, ist jetzt eine Entscheidung und kein
Zufall.

Aufbau:
- Migration: ansprechpartnerId am Vorgang (SetNull - ein Vorgang darf
  nicht daran haengen, dass es den Kontakt noch gibt) und anrede am
  Kontakt.
- Die Anrede als reine Funktion in kontakte/anrede.ts. Geraten wird nie:
  ohne hinterlegte Anrede wird mit vollem Namen angeredet. Ein leerer
  Name mit gesetzter Anrede faellt auf die allgemeine Anrede zurueck -
  "Sehr geehrte Frau ," darf nie herausgehen.
- Der Wechsel des Ansprechpartners steht mit NAMEN im Protokoll. Zwei
  cuids nebeneinander waeren protokolliert, aber nicht nachvollziehbar,
  und nachvollziehbar war die Anforderung.
- Der Ansprechpartner wird gegen die Akte geprueft, bevor er gespeichert
  wird - sein Name steht anschliessend im Briefkopf.

Nebenbei behoben: Der Schreiben-Editor verwarf die empfaengerAnschrift
aus dem Entwurf, sein State startete immer leer. Jetzt kommt die
Anschrift des Ansprechpartners im Formular an.

Geprueft: neun Einheitstests fuer die Anrede, fuenf End-to-End-Tests von
der Auswahl bis zum Protokolleintrag.

Refs #9
2026-08-23 16:31:10 +02:00

507 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: { name: 'Petra Schmidt', anrede: 'FRAU' },
})
expect(anfrage.text).toContain('Sehr geehrte Frau Schmidt,')
// Der Name steht ueber der Stelle - so landet der Brief bei ihr.
const zeilen = anfrage.text.split('\n')
expect(zeilen[0]).toBe('Petra Schmidt')
expect(zeilen[1]).toBe('Bezirk Oberfranken')
})
it('bleibt ohne Ansprechpartner bei der allgemeinen Anrede', () => {
const anfrage = sachstandsanfrageErzeugen(eingabe)
expect(anfrage.text).toContain('Sehr geehrte Damen und Herren,')
})
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)
})
})