Auf dem Dashboard standen dauerhaft drei rote Kaesten mit Warndreieck und Knopf "Frist jetzt bearbeiten": Aufwandspauschalen mit Verfall 2027, 2028 und 2029. Alle anderen Bloecke des Dashboards filtern nach Dringlichkeit. Dieser fragte nur, OB etwas eine Ausschlussfrist ist, und nie, WANN sie faellig wird - eine Frist im Mai 2029 landete damit in derselben Aufmachung wie eine, die morgen ablaeuft. Die Daten sind richtig: Pflichtfristen werden bewusst drei Jahre im Voraus angelegt. Sie gehoeren nur nicht in die Warnung, sondern in die Liste. Neu ist ein Horizont von 90 Tagen. Er ist grosszuegig gewaehlt, weil die kuerzeste hier vorkommende Ausschlussfrist sechs Monate laesst. Abgelaufene warnen weiterhin immer - eine versaeumte Aufwandspauschale bleibt eine schlechte Nachricht, auch wenn sie lange zurueckliegt. Der Grund ist nicht Bequemlichkeit: Eine Warnung, die monatelang dasteht und nichts von einem will, stumpft ab. Man uebersieht die vierte, die etwas will - und bei § 1878 BGB ist das die Jahrespauschale, die ersatzlos erlischt. Ein bestehender Test behauptete "unabhaengig vom Faelligkeitstopf" und belegte das mit einer Frist 134 Tage in der Zukunft. Die Aussage stimmt weiterhin, das Beispiel nicht mehr - es liegt jetzt innerhalb des Horizonts, aber ausserhalb aller Faelligkeitstoepfe. Gegen drei Mutationen geprueft: Filter entfernt, Abgelaufenes ausgeschlossen, Horizont um einen Tag verschoben - jede wird rot. Refs #40
435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
fristLage,
|
|
fristStufe,
|
|
fristenEinordnen,
|
|
heimkostenlageBerechnen,
|
|
nachforderungenAuswerten,
|
|
tagAusDatenbank,
|
|
vermoegenBerechnen,
|
|
verfallwarnung,
|
|
wartendeEinordnen,
|
|
WARTEZEIT_DRINGEND_TAGE,
|
|
type FristRoh,
|
|
type HeimkostenmonatRoh,
|
|
type NachforderungRoh,
|
|
type PostenRoh,
|
|
type WartendRoh,
|
|
} from '@/lib/dashboard/bewertung'
|
|
|
|
/**
|
|
* Das Dashboard entscheidet, was heute dringend ist. Diese Tests
|
|
* beschreiben, was fachlich gelten soll - nicht, welche Funktion gerufen
|
|
* wird.
|
|
*/
|
|
|
|
/** Mittwoch, 19.08.2026. Die Woche endet am Sonntag, dem 23.08. */
|
|
const HEUTE = new Date(2026, 7, 19)
|
|
|
|
/** Ein @db.Date kommt als UTC-Mitternacht aus der Datenbank. */
|
|
function dbTag(text: string): Date {
|
|
return new Date(`${text}T00:00:00.000Z`)
|
|
}
|
|
|
|
function frist(teil: Partial<FristRoh> & { datum: Date }): FristRoh {
|
|
return {
|
|
id: 'f1',
|
|
titel: 'Irgendeine Frist',
|
|
typ: 'GESETZLICH',
|
|
rechtsgrundlage: null,
|
|
ausschlussfrist: false,
|
|
vorgangId: null,
|
|
...teil,
|
|
}
|
|
}
|
|
|
|
describe('Fristen-Ampel', () => {
|
|
it('meldet ein Datum in der Vergangenheit als überfällig', () => {
|
|
expect(fristStufe(dbTag('2026-08-10'), HEUTE)).toBe('rot')
|
|
})
|
|
|
|
it('färbt orange, was bis zum Sonntag dieser Woche fällig wird', () => {
|
|
expect(fristStufe(dbTag('2026-08-19'), HEUTE)).toBe('orange')
|
|
expect(fristStufe(dbTag('2026-08-23'), HEUTE)).toBe('orange')
|
|
})
|
|
|
|
it('färbt gelb, was noch in diesem Monat fällig wird', () => {
|
|
expect(fristStufe(dbTag('2026-08-24'), HEUTE)).toBe('gelb')
|
|
expect(fristStufe(dbTag('2026-08-31'), HEUTE)).toBe('gelb')
|
|
})
|
|
|
|
it('lässt alles Spätere grün', () => {
|
|
expect(fristStufe(dbTag('2026-09-01'), HEUTE)).toBe('gruen')
|
|
})
|
|
|
|
it('stellt überfällige Fristen nach oben und nennt die Tage', () => {
|
|
const daten = fristenEinordnen(
|
|
[
|
|
frist({ id: 'kommend', datum: dbTag('2026-08-28') }),
|
|
frist({ id: 'alt', datum: dbTag('2026-07-30') }),
|
|
frist({ id: 'aeltest', datum: dbTag('2026-06-01') }),
|
|
],
|
|
HEUTE,
|
|
)
|
|
|
|
expect(daten.ueberfaellig.map((e) => e.id)).toEqual(['aeltest', 'alt'])
|
|
expect(daten.ueberfaellig[0]?.tage).toBe(-79)
|
|
expect(daten.diesenMonat.map((e) => e.id)).toEqual(['kommend'])
|
|
})
|
|
|
|
it('zählt Fristen jenseits des Monats gesondert, statt sie zu verschweigen', () => {
|
|
const daten = fristenEinordnen(
|
|
[frist({ id: 'spaet', datum: dbTag('2027-03-01') })],
|
|
HEUTE,
|
|
)
|
|
expect(daten.weitereOffen).toBe(1)
|
|
expect(daten.gesamtOffen).toBe(1)
|
|
expect(daten.ueberfaellig).toHaveLength(0)
|
|
})
|
|
|
|
it('hebt Ausschlussfristen unabhängig vom Fälligkeitstopf hervor', () => {
|
|
const daten = fristenEinordnen(
|
|
[
|
|
frist({ id: 'normal', datum: dbTag('2026-08-20') }),
|
|
frist({
|
|
// Weit genug weg fuer "gruen", nah genug fuer die Warnung.
|
|
id: 'pauschale',
|
|
titel: 'Aufwandspauschale 2026',
|
|
datum: dbTag('2026-10-15'),
|
|
ausschlussfrist: true,
|
|
rechtsgrundlage: '§ 1878 BGB',
|
|
}),
|
|
],
|
|
HEUTE,
|
|
)
|
|
|
|
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['pauschale'])
|
|
// Nicht in einem der Faelligkeitstoepfe - der Warnblock steht daneben.
|
|
expect(daten.weitereOffen).toBe(1)
|
|
})
|
|
|
|
describe('Ausschlussfristen: nur warnen, wenn es etwas zu tun gibt', () => {
|
|
/**
|
|
* Der Anlass: Auf dem Dashboard standen dauerhaft drei rote Kaesten mit
|
|
* Warndreieck - Aufwandspauschalen mit Verfall 2027, 2028 und 2029. Die
|
|
* Pflichtfristen werden bewusst drei Jahre im Voraus angelegt, und der
|
|
* Warnblock fragte nur, OB etwas eine Ausschlussfrist ist, nie WANN sie
|
|
* faellig wird.
|
|
*
|
|
* Eine Warnung, die monatelang dasteht und nichts von einem will,
|
|
* stumpft ab. Genau davor schuetzen diese Tests.
|
|
*/
|
|
function pauschale(id: string, datum: string) {
|
|
return frist({
|
|
id,
|
|
titel: `Aufwandspauschale ${id}`,
|
|
datum: dbTag(datum),
|
|
ausschlussfrist: true,
|
|
rechtsgrundlage: '§ 1878 BGB',
|
|
})
|
|
}
|
|
|
|
it('lässt Fristen weit in der Zukunft aus der Warnung heraus', () => {
|
|
const daten = fristenEinordnen(
|
|
[
|
|
pauschale('2027', '2027-05-06'),
|
|
pauschale('2028', '2028-05-06'),
|
|
pauschale('2029', '2029-05-06'),
|
|
],
|
|
HEUTE,
|
|
)
|
|
|
|
expect(daten.ausschlussfristen).toEqual([])
|
|
// Verschwunden sind sie deshalb nicht - sie stehen in der Liste.
|
|
expect(daten.weitereOffen).toBe(3)
|
|
expect(daten.gesamtOffen).toBe(3)
|
|
})
|
|
|
|
it('warnt, sobald eine in den Horizont rückt', () => {
|
|
// 90 Tage nach dem 19.08.2026 ist der 17.11.2026.
|
|
const daten = fristenEinordnen([pauschale('bald', '2026-11-17')], HEUTE)
|
|
|
|
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['bald'])
|
|
})
|
|
|
|
it('warnt einen Tag davor noch nicht', () => {
|
|
const daten = fristenEinordnen([pauschale('knapp-drueber', '2026-11-18')], HEUTE)
|
|
|
|
expect(daten.ausschlussfristen).toEqual([])
|
|
})
|
|
|
|
it('warnt bei einer versäumten Frist immer, egal wie lange sie zurückliegt', () => {
|
|
// Eine verfallene Aufwandspauschale bleibt eine schlechte Nachricht.
|
|
// Sie stillschweigend aus der Warnung zu nehmen, waere das Gegenteil
|
|
// von dem, was hier gewollt ist.
|
|
const daten = fristenEinordnen([pauschale('verpasst', '2024-05-06')], HEUTE)
|
|
|
|
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['verpasst'])
|
|
})
|
|
|
|
it('mischt Fälliges und Fernes richtig', () => {
|
|
const daten = fristenEinordnen(
|
|
[
|
|
pauschale('verpasst', '2025-05-06'),
|
|
pauschale('bald', '2026-10-01'),
|
|
pauschale('fern', '2029-05-06'),
|
|
],
|
|
HEUTE,
|
|
)
|
|
|
|
expect(daten.ausschlussfristen.map((e) => e.id)).toEqual(['verpasst', 'bald'])
|
|
})
|
|
})
|
|
|
|
it('warnt bei einer Ausschlussfrist ausdrücklich vor dem ersatzlosen Verfall', () => {
|
|
const text = verfallwarnung({ tage: 12, rechtsgrundlage: '§ 1878 BGB' })
|
|
expect(text).toContain('§ 1878 BGB')
|
|
expect(text).toContain('ersatzlos weg')
|
|
})
|
|
|
|
it('sagt bei einer abgelaufenen Ausschlussfrist, wie lange sie schon abgelaufen ist', () => {
|
|
const text = verfallwarnung({ tage: -3, rechtsgrundlage: null })
|
|
expect(text).toContain('seit 3 Tagen abgelaufen')
|
|
expect(text).toContain('ersatzlos weg')
|
|
})
|
|
|
|
it('schreibt die Lage in Worten, damit Farbe nie die einzige Information ist', () => {
|
|
expect(fristLage(-5)).toBe('seit 5 Tagen überfällig')
|
|
expect(fristLage(-1)).toBe('seit gestern überfällig')
|
|
expect(fristLage(0)).toBe('heute fällig')
|
|
expect(fristLage(1)).toBe('morgen fällig')
|
|
expect(fristLage(9)).toBe('in 9 Tagen fällig')
|
|
})
|
|
|
|
it('verschiebt ein Datum ohne Uhrzeit nicht über die Zeitzone hinweg', () => {
|
|
const tag = tagAusDatenbank(dbTag('2026-08-31'))
|
|
expect(tag.getDate()).toBe(31)
|
|
expect(tag.getMonth()).toBe(7)
|
|
})
|
|
})
|
|
|
|
describe('Wartet auf andere', () => {
|
|
function vorgang(teil: Partial<WartendRoh>): WartendRoh {
|
|
return {
|
|
id: 'v1',
|
|
titel: 'Antrag Hilfe zur Pflege',
|
|
kategorie: 'SOZIALHILFE',
|
|
gegenstelle: 'Bezirk Oberfranken',
|
|
aktenzeichen: null,
|
|
naechsterSchritt: null,
|
|
letzterKontaktAm: null,
|
|
erstelltAm: new Date(2026, 7, 1),
|
|
...teil,
|
|
}
|
|
}
|
|
|
|
it('sortiert die längste Wartezeit nach oben', () => {
|
|
const daten = wartendeEinordnen(
|
|
[
|
|
vorgang({ id: 'kurz', letzterKontaktAm: dbTag('2026-08-15') }),
|
|
vorgang({ id: 'lang', letzterKontaktAm: dbTag('2026-06-01') }),
|
|
],
|
|
HEUTE,
|
|
)
|
|
expect(daten.eintraege.map((e) => e.id)).toEqual(['lang', 'kurz'])
|
|
expect(daten.eintraege[0]?.tageOhneRueckmeldung).toBe(79)
|
|
})
|
|
|
|
it(`hebt ab ${WARTEZEIT_DRINGEND_TAGE} Tagen ohne Rückmeldung hervor`, () => {
|
|
const grenze = wartendeEinordnen(
|
|
[vorgang({ letzterKontaktAm: dbTag('2026-07-29') })], // genau 21 Tage
|
|
HEUTE,
|
|
)
|
|
expect(grenze.eintraege[0]?.tageOhneRueckmeldung).toBe(WARTEZEIT_DRINGEND_TAGE)
|
|
expect(grenze.eintraege[0]?.dringend).toBe(true)
|
|
expect(grenze.dringende).toBe(1)
|
|
|
|
const knapp = wartendeEinordnen([vorgang({ letzterKontaktAm: dbTag('2026-07-30') })], HEUTE)
|
|
expect(knapp.eintraege[0]?.dringend).toBe(false)
|
|
})
|
|
|
|
it('rechnet ab dem Anlegen, wenn nie ein Kontakt vermerkt wurde', () => {
|
|
const daten = wartendeEinordnen([vorgang({ letzterKontaktAm: null })], HEUTE)
|
|
expect(daten.eintraege[0]?.tageOhneRueckmeldung).toBe(18)
|
|
expect(daten.eintraege[0]?.letzterKontaktAm).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('Nachforderungen des Trägers', () => {
|
|
function zeile(teil: Partial<NachforderungRoh>): NachforderungRoh {
|
|
return {
|
|
id: 'n1',
|
|
bezeichnung: 'Kontoauszüge der letzten 12 Monate',
|
|
status: 'OFFEN',
|
|
fristBis: null,
|
|
reihenfolge: 0,
|
|
antragId: 'a1',
|
|
antragTraeger: 'Bezirk Oberfranken',
|
|
antragAktenzeichen: 'HzP 12345',
|
|
...teil,
|
|
}
|
|
}
|
|
|
|
it('zählt übersandte, anerkannte und begründet fehlende Unterlagen als erledigt', () => {
|
|
const daten = nachforderungenAuswerten(
|
|
[
|
|
zeile({ id: '1', status: 'UEBERSANDT' }),
|
|
zeile({ id: '2', status: 'ANERKANNT' }),
|
|
zeile({ id: '3', status: 'NICHT_VORHANDEN' }),
|
|
zeile({ id: '4', status: 'OFFEN' }),
|
|
zeile({ id: '5', status: 'IN_BESCHAFFUNG' }),
|
|
],
|
|
HEUTE,
|
|
)
|
|
|
|
expect(daten.gesamt).toBe(5)
|
|
expect(daten.erledigt).toBe(3)
|
|
expect(daten.offen.map((o) => o.id)).toEqual(['4', '5'])
|
|
})
|
|
|
|
it('reiht die dringendste Frist zuoberst und meldet abgelaufene gesondert', () => {
|
|
const daten = nachforderungenAuswerten(
|
|
[
|
|
zeile({ id: 'ohne', fristBis: null }),
|
|
zeile({ id: 'spaet', fristBis: dbTag('2026-09-30') }),
|
|
zeile({ id: 'abgelaufen', fristBis: dbTag('2026-08-10') }),
|
|
],
|
|
HEUTE,
|
|
)
|
|
|
|
expect(daten.offen.map((o) => o.id)).toEqual(['abgelaufen', 'spaet', 'ohne'])
|
|
expect(daten.offen[0]?.tageBisFrist).toBe(-9)
|
|
expect(daten.ueberfaellig).toBe(1)
|
|
})
|
|
|
|
it('nimmt Träger und Aktenzeichen aus dem Antrag mit', () => {
|
|
const daten = nachforderungenAuswerten([zeile({})], HEUTE)
|
|
expect(daten.traeger).toBe('Bezirk Oberfranken')
|
|
expect(daten.aktenzeichen).toBe('HzP 12345')
|
|
expect(daten.antragId).toBe('a1')
|
|
})
|
|
|
|
it('kommt mit einer leeren Liste zurecht', () => {
|
|
const daten = nachforderungenAuswerten([], HEUTE)
|
|
expect(daten.gesamt).toBe(0)
|
|
expect(daten.traeger).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('Vermögen zum letzten Stichtag', () => {
|
|
function posten(teil: Partial<PostenRoh>): PostenRoh {
|
|
return { id: 'p1', art: 'KONTO', wertstaende: [], ...teil }
|
|
}
|
|
|
|
it('nimmt je Posten den jüngsten Wertstand und zieht Verbindlichkeiten ab', () => {
|
|
const lage = vermoegenBerechnen([
|
|
posten({
|
|
id: 'giro',
|
|
art: 'KONTO',
|
|
wertstaende: [
|
|
{ stichtag: dbTag('2026-01-01'), wertCent: 100_000n },
|
|
{ stichtag: dbTag('2026-07-01'), wertCent: 250_000n },
|
|
],
|
|
}),
|
|
posten({
|
|
id: 'lebensversicherung',
|
|
art: 'VERSICHERUNG',
|
|
wertstaende: [{ stichtag: dbTag('2026-06-01'), wertCent: 1_248_000n }],
|
|
}),
|
|
posten({
|
|
id: 'heimrechnung',
|
|
art: 'VERBINDLICHKEIT',
|
|
wertstaende: [{ stichtag: dbTag('2026-07-01'), wertCent: 340_000n }],
|
|
}),
|
|
])
|
|
|
|
expect(lage.aktivaCent).toBe(1_498_000n)
|
|
expect(lage.verbindlichkeitenCent).toBe(340_000n)
|
|
expect(lage.nettoCent).toBe(1_158_000n)
|
|
})
|
|
|
|
it('weist den jüngsten tatsächlich verwendeten Stichtag aus', () => {
|
|
const lage = vermoegenBerechnen([
|
|
posten({
|
|
wertstaende: [
|
|
{ stichtag: dbTag('2026-03-15'), wertCent: 1n },
|
|
{ stichtag: dbTag('2026-05-20'), wertCent: 2n },
|
|
],
|
|
}),
|
|
])
|
|
expect(lage.stichtag?.getDate()).toBe(20)
|
|
expect(lage.stichtag?.getMonth()).toBe(4)
|
|
})
|
|
|
|
it('zählt Posten ohne Wert gesondert, statt sie stillschweigend als 0 zu führen', () => {
|
|
const lage = vermoegenBerechnen([
|
|
posten({ id: 'a', wertstaende: [{ stichtag: dbTag('2026-05-01'), wertCent: 500n }] }),
|
|
posten({ id: 'b', wertstaende: [] }),
|
|
posten({ id: 'c', wertstaende: [] }),
|
|
])
|
|
|
|
expect(lage.postenGesamt).toBe(3)
|
|
expect(lage.postenOhneWert).toBe(2)
|
|
expect(lage.nettoCent).toBe(500n)
|
|
})
|
|
|
|
it('liefert bei einer frischen Installation keinen Stichtag', () => {
|
|
const lage = vermoegenBerechnen([])
|
|
expect(lage.stichtag).toBeNull()
|
|
expect(lage.nettoCent).toBe(0n)
|
|
})
|
|
})
|
|
|
|
describe('Heimkosten und Deckungslücke', () => {
|
|
function monat(teil: Partial<HeimkostenmonatRoh>): HeimkostenmonatRoh {
|
|
return {
|
|
jahr: 2026,
|
|
monat: 7,
|
|
pflegeEigenanteilCent: 0n,
|
|
unterkunftVerpflegungCent: 0n,
|
|
investitionskostenCent: 0n,
|
|
ausbildungsumlageCent: 0n,
|
|
zusatzleistungenCent: 0n,
|
|
pflegekasseCent: 0n,
|
|
leistungszuschlagCent: 0n,
|
|
renteCent: 0n,
|
|
sonstigeEinkuenfteCent: 0n,
|
|
sozialhilfeCent: 0n,
|
|
eigenmittelCent: 0n,
|
|
...teil,
|
|
}
|
|
}
|
|
|
|
it('stellt die Kostenseite der Deckungsseite gegenüber', () => {
|
|
const lage = heimkostenlageBerechnen(
|
|
monat({
|
|
pflegeEigenanteilCent: 130_000n,
|
|
unterkunftVerpflegungCent: 95_000n,
|
|
investitionskostenCent: 45_000n,
|
|
ausbildungsumlageCent: 5_000n,
|
|
renteCent: 120_000n,
|
|
sozialhilfeCent: 100_000n,
|
|
}),
|
|
)
|
|
|
|
expect(lage.gesamtkostenCent).toBe(275_000n)
|
|
expect(lage.deckungCent).toBe(220_000n)
|
|
expect(lage.deckungsluecke).toBe(55_000n)
|
|
})
|
|
|
|
it('weist eine Überdeckung als negative Lücke aus', () => {
|
|
const lage = heimkostenlageBerechnen(
|
|
monat({ unterkunftVerpflegungCent: 100_000n, renteCent: 150_000n }),
|
|
)
|
|
expect(lage.deckungsluecke).toBe(-50_000n)
|
|
})
|
|
|
|
it('rechnet in Cent und nicht in Fließkomma', () => {
|
|
const lage = heimkostenlageBerechnen(
|
|
monat({ pflegeEigenanteilCent: 1n, renteCent: 0n }),
|
|
)
|
|
expect(typeof lage.deckungsluecke).toBe('bigint')
|
|
expect(lage.deckungsluecke).toBe(1n)
|
|
})
|
|
})
|