Am Anfang einer Betreuung weiss niemand, welche Vertraege es gibt. Die Antwort steht aber im Kontoauszug: Wer regelmaessig abbucht, hat einen Vertrag. Neu unter Vermoegen & Kasse wird aus hunderten Buchungszeilen eine Arbeitsliste. Aufbau: - Die Erkennung liegt als reine Funktionen in src/lib/finanzen/wiederkehrend.ts, ohne Datenbank. Nur so laesst sich jede Einordnung einzeln nachrechnen. - Der Datenzugriff steht getrennt in wiederkehrend-laden.ts. - Ein kleines Modell haelt fest, was bewusst KEIN Vertrag ist. Es haengt am normalisierten Empfaengernamen, nicht an Buchungen - nur so ueberlebt die Entscheidung den naechsten Import. Zwei Haltungen liegen der Erkennung zugrunde: Im Zweifel getrennt lassen. Zusammengefasst wird nur das Unstrittige: Gross- und Kleinschreibung, Satzzeichen, Umlaute, Rechtsform am Ende. Keine Aehnlichkeitssuche - "Stadtwerke Bamberg" und "Stadtwerke Bayreuth" trennen drei Zeichen und zwei Rechnungssteller. Zwei Zeilen zu viel sind harmlos; eine falsche Gruppe verbirgt einen Vertrag. Nichts behaupten, was nicht belegt ist. Statt "monatlich" steht dort "6 Zahlungen, etwa monatlich". Schwankt der Betrag, wird die Spanne genannt. Ohne erkennbaren Abstand wird nicht hochgerechnet, sondern gezeigt, was geflossen ist. Dazu eine zweite Liste fuer einzelne groessere Zahlungen: Die Gruppierung braucht zwei Zahlungen, ein Jahresbeitrag erscheint bei einem importierten Jahr aber nur einmal - und ausgerechnet Versicherungen zahlt man oft jaehrlich. Geprueft: 37 Einheitstests, die Haelfte davon in der Gegenrichtung, plus vier End-to-End-Tests auf beiden Geraeteprofilen. Der Browsertest hat zwei Fehler gefunden, die Typpruefung und Einheitstests durchgelassen haben - darunter ein verlorener Backslash, der die Vorbelegung des Beitrags stillschweigend verschluckte. Nebenbei: .claude/ ins .gitignore. Dort legen parallel arbeitende Agenten ihre Worktrees ab; ein "git add -A" haette einen kompletten zweiten Checkout eingecheckt. Refs #3
344 lines
10 KiB
TypeScript
344 lines
10 KiB
TypeScript
import 'server-only'
|
|
import { startOfDay } from 'date-fns'
|
|
import type { Modul } from '@prisma/client'
|
|
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
|
|
import {
|
|
fristenEinordnen,
|
|
heimkostenlageBerechnen,
|
|
nachforderungenAuswerten,
|
|
tagAusDatenbank,
|
|
tageBisTag,
|
|
vermoegenBerechnen,
|
|
wartendeEinordnen,
|
|
type NachforderungRoh,
|
|
} from '@/lib/dashboard/bewertung'
|
|
import type {
|
|
BelegeDaten,
|
|
DashboardDaten,
|
|
FristenAmpelDaten,
|
|
KennzahlenDaten,
|
|
NachforderungenDaten,
|
|
PosteingangDaten,
|
|
WartetAufAndereDaten,
|
|
} from '@/lib/dashboard/typen'
|
|
import { offeneZahlungsgruppen } from '@/lib/finanzen/wiederkehrend-laden'
|
|
|
|
/**
|
|
* Alles, was das Dashboard "Heute" braucht - in einem Rutsch.
|
|
*
|
|
* Die Abfragen laufen nebeneinander in einem Promise.all. Keine Schleife
|
|
* fragt je Datensatz nach; wo eine Beziehung gebraucht wird (juengster
|
|
* Wertstand je Vermoegensposten), holt Prisma sie in derselben Runde mit.
|
|
*
|
|
* Wer ein Modul nicht lesen darf, bekommt dafuer null zurueck statt
|
|
* leerer Listen - die Kachel entfaellt dann ganz, statt faelschlich "0"
|
|
* zu behaupten.
|
|
*/
|
|
|
|
/** Wie viele Zeilen zeigt eine Kachel hoechstens? Der Rest steht im Modul. */
|
|
const KACHEL_ZEILEN = 5
|
|
/** Sicherung gegen ausufernde Abfragen bei sehr langen Akten. */
|
|
const FRISTEN_MAX = 200
|
|
|
|
export type Lesepruefung = (modul: Modul) => boolean
|
|
|
|
export async function dashboardLaden(
|
|
betreute: { id: string; vorname: string; nachname: string },
|
|
darfLesen: Lesepruefung,
|
|
jetzt: Date = new Date(),
|
|
): Promise<DashboardDaten> {
|
|
const heute = startOfDay(jetzt)
|
|
const betreuteId = betreute.id
|
|
|
|
const darfFristen = darfLesen('FRISTEN')
|
|
const darfVorgaenge = darfLesen('VORGAENGE')
|
|
const darfSozialhilfe = darfLesen('SOZIALHILFE')
|
|
const darfDokumente = darfLesen('DOKUMENTE')
|
|
const darfFinanzen = darfLesen('FINANZEN')
|
|
const darfGesundheit = darfLesen('GESUNDHEIT')
|
|
|
|
const [
|
|
fristenRoh,
|
|
wartendRoh,
|
|
nachforderungenRoh,
|
|
posteingang,
|
|
posteingangAnzahl,
|
|
belegeOhneBuchung,
|
|
belegeOhneBuchungGesamt,
|
|
buchungenOhneBeleg,
|
|
buchungenOhneBelegGesamt,
|
|
postenRoh,
|
|
heimkostenRoh,
|
|
jahresberichtRoh,
|
|
] = await Promise.all([
|
|
// 1. Fristen - alle offenen, die Einordnung passiert danach im Speicher.
|
|
darfFristen
|
|
? db.frist.findMany({
|
|
where: { betreuteId, status: 'OFFEN', ...nichtGeloescht },
|
|
orderBy: { datum: 'asc' },
|
|
take: FRISTEN_MAX,
|
|
select: {
|
|
id: true,
|
|
titel: true,
|
|
datum: true,
|
|
typ: true,
|
|
rechtsgrundlage: true,
|
|
ausschlussfrist: true,
|
|
vorgangId: true,
|
|
},
|
|
})
|
|
: null,
|
|
|
|
// 2. Vorgaenge, bei denen jemand anderes am Zug ist.
|
|
darfVorgaenge
|
|
? db.vorgang.findMany({
|
|
where: { betreuteId, status: 'WARTET_AUF_DRITTE', ...nichtGeloescht },
|
|
orderBy: { letzterKontaktAm: 'asc' },
|
|
select: {
|
|
id: true,
|
|
titel: true,
|
|
kategorie: true,
|
|
gegenstelle: true,
|
|
aktenzeichen: true,
|
|
naechsterSchritt: true,
|
|
letzterKontaktAm: true,
|
|
erstelltAm: true,
|
|
},
|
|
})
|
|
: null,
|
|
|
|
// 3. Nachforderungen: alle Zeilen, damit "x von y" stimmt.
|
|
darfSozialhilfe
|
|
? db.nachforderung.findMany({
|
|
where: { antrag: { betreuteId, ...nichtGeloescht }, ...nichtGeloescht },
|
|
orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }],
|
|
select: {
|
|
id: true,
|
|
bezeichnung: true,
|
|
status: true,
|
|
fristBis: true,
|
|
reihenfolge: true,
|
|
antragId: true,
|
|
antrag: { select: { traeger: true, aktenzeichen: true } },
|
|
},
|
|
})
|
|
: null,
|
|
|
|
// 4. Ungeprüfter Posteingang.
|
|
darfDokumente
|
|
? db.dokument.findMany({
|
|
where: { betreuteId, posteingangStatus: 'NEU', ...nichtGeloescht },
|
|
orderBy: [{ eingangAm: 'desc' }, { erstelltAm: 'desc' }],
|
|
take: KACHEL_ZEILEN,
|
|
select: { id: true, titel: true, art: true, absender: true, eingangAm: true },
|
|
})
|
|
: null,
|
|
darfDokumente
|
|
? db.dokument.count({
|
|
where: { betreuteId, posteingangStatus: 'NEU', ...nichtGeloescht },
|
|
})
|
|
: null,
|
|
|
|
// 5a. Rechnungen und Quittungen, an denen keine Buchung haengt.
|
|
darfDokumente && darfFinanzen
|
|
? db.dokument.findMany({
|
|
where: {
|
|
betreuteId,
|
|
art: { in: ['RECHNUNG', 'QUITTUNG'] },
|
|
belegFuer: { none: { geloeschtAm: null } },
|
|
...nichtGeloescht,
|
|
},
|
|
orderBy: [{ eingangAm: 'desc' }, { erstelltAm: 'desc' }],
|
|
take: KACHEL_ZEILEN,
|
|
select: { id: true, titel: true, art: true, eingangAm: true },
|
|
})
|
|
: null,
|
|
darfDokumente && darfFinanzen
|
|
? db.dokument.count({
|
|
where: {
|
|
betreuteId,
|
|
art: { in: ['RECHNUNG', 'QUITTUNG'] },
|
|
belegFuer: { none: { geloeschtAm: null } },
|
|
...nichtGeloescht,
|
|
},
|
|
})
|
|
: null,
|
|
|
|
// 5b. Buchungen ohne Beleg und ohne Begruendung - Belegzwang.
|
|
darfFinanzen
|
|
? db.buchung.findMany({
|
|
where: { betreuteId, belegId: null, belegFehltBegruendung: null, ...nichtGeloescht },
|
|
orderBy: { datum: 'desc' },
|
|
take: KACHEL_ZEILEN,
|
|
select: {
|
|
id: true,
|
|
datum: true,
|
|
betragCent: true,
|
|
richtung: true,
|
|
kategorie: true,
|
|
gegenstelle: true,
|
|
},
|
|
})
|
|
: null,
|
|
darfFinanzen
|
|
? db.buchung.count({
|
|
where: { betreuteId, belegId: null, belegFehltBegruendung: null, ...nichtGeloescht },
|
|
})
|
|
: null,
|
|
|
|
// 6a. Vermoegen: je Posten der juengste Wertstand.
|
|
darfFinanzen
|
|
? db.vermoegensposten.findMany({
|
|
where: { betreuteId, ...nichtGeloescht },
|
|
select: {
|
|
id: true,
|
|
art: true,
|
|
wertstaende: {
|
|
orderBy: { stichtag: 'desc' },
|
|
take: 1,
|
|
select: { stichtag: true, wertCent: true },
|
|
},
|
|
},
|
|
})
|
|
: null,
|
|
|
|
// 6b. Der zuletzt erfasste Heimkostenmonat.
|
|
darfGesundheit
|
|
? db.heimkostenmonat.findFirst({
|
|
where: { betreuteId },
|
|
orderBy: [{ jahr: 'desc' }, { monat: 'desc' }],
|
|
})
|
|
: null,
|
|
|
|
// 6c. Der naechste Jahresbericht.
|
|
darfFristen
|
|
? db.frist.findFirst({
|
|
where: {
|
|
betreuteId,
|
|
status: 'OFFEN',
|
|
systemSchluessel: { startsWith: 'jahresbericht-' },
|
|
...nichtGeloescht,
|
|
},
|
|
orderBy: { datum: 'asc' },
|
|
select: { id: true, titel: true, datum: true },
|
|
})
|
|
: null,
|
|
])
|
|
|
|
// --- Einordnen -----------------------------------------------------------
|
|
|
|
const fristen: FristenAmpelDaten | null = fristenRoh
|
|
? fristenEinordnen(fristenRoh, heute)
|
|
: null
|
|
|
|
const wartet: WartetAufAndereDaten | null = wartendRoh
|
|
? (() => {
|
|
const alle = wartendeEinordnen(wartendRoh, heute)
|
|
return { ...alle, eintraege: alle.eintraege.slice(0, KACHEL_ZEILEN) }
|
|
})()
|
|
: null
|
|
|
|
const nachforderungen: NachforderungenDaten | null = nachforderungenRoh
|
|
? nachforderungenAuswerten(
|
|
nachforderungenRoh.map<NachforderungRoh>((zeile) => ({
|
|
id: zeile.id,
|
|
bezeichnung: zeile.bezeichnung,
|
|
status: zeile.status,
|
|
fristBis: zeile.fristBis,
|
|
reihenfolge: zeile.reihenfolge,
|
|
antragId: zeile.antragId,
|
|
antragTraeger: zeile.antrag.traeger,
|
|
antragAktenzeichen: zeile.antrag.aktenzeichen,
|
|
})),
|
|
heute,
|
|
)
|
|
: null
|
|
|
|
const posteingangDaten: PosteingangDaten | null =
|
|
posteingang && posteingangAnzahl !== null
|
|
? {
|
|
anzahl: posteingangAnzahl,
|
|
neueste: posteingang.map((d) => ({
|
|
id: d.id,
|
|
titel: d.titel,
|
|
art: d.art,
|
|
absender: d.absender,
|
|
eingangAm: d.eingangAm ? tagAusDatenbank(d.eingangAm) : null,
|
|
})),
|
|
}
|
|
: null
|
|
|
|
// Regelmaessige Abbuchungen ohne Vertrag. Eigene Abfrage, weil sie andere
|
|
// Spalten braucht als der Belegzwang - und weil sie ganz entfaellt, wenn
|
|
// das Finanzmodul nicht lesbar ist.
|
|
const wiederkehrendOhneVertrag = darfFinanzen ? await offeneZahlungsgruppen(betreuteId) : 0
|
|
|
|
const belege: BelegeDaten | null =
|
|
buchungenOhneBeleg && buchungenOhneBelegGesamt !== null
|
|
? {
|
|
belegeOhneBuchung: (belegeOhneBuchung ?? []).map((d) => ({
|
|
id: d.id,
|
|
titel: d.titel,
|
|
art: d.art,
|
|
eingangAm: d.eingangAm ? tagAusDatenbank(d.eingangAm) : null,
|
|
})),
|
|
belegeOhneBuchungGesamt: belegeOhneBuchungGesamt ?? 0,
|
|
buchungenOhneBeleg: buchungenOhneBeleg.map((b) => ({
|
|
id: b.id,
|
|
datum: tagAusDatenbank(b.datum),
|
|
betragCent: centZuZahl(b.betragCent) ?? 0,
|
|
richtung: b.richtung,
|
|
kategorie: b.kategorie,
|
|
gegenstelle: b.gegenstelle,
|
|
})),
|
|
buchungenOhneBelegGesamt,
|
|
wiederkehrendOhneVertrag,
|
|
}
|
|
: null
|
|
|
|
const vermoegenRoh = postenRoh ? vermoegenBerechnen(postenRoh) : null
|
|
const heimkosten = heimkostenRoh ? heimkostenlageBerechnen(heimkostenRoh) : null
|
|
|
|
const kennzahlen: KennzahlenDaten = {
|
|
vermoegen: vermoegenRoh
|
|
? {
|
|
stichtag: vermoegenRoh.stichtag,
|
|
aktivaCent: centZuZahl(vermoegenRoh.aktivaCent) ?? 0,
|
|
verbindlichkeitenCent: centZuZahl(vermoegenRoh.verbindlichkeitenCent) ?? 0,
|
|
nettoCent: centZuZahl(vermoegenRoh.nettoCent) ?? 0,
|
|
postenGesamt: vermoegenRoh.postenGesamt,
|
|
postenOhneWert: vermoegenRoh.postenOhneWert,
|
|
}
|
|
: null,
|
|
heimkosten: heimkosten
|
|
? {
|
|
jahr: heimkosten.jahr,
|
|
monat: heimkosten.monat,
|
|
gesamtkostenCent: centZuZahl(heimkosten.gesamtkostenCent) ?? 0,
|
|
deckungCent: centZuZahl(heimkosten.deckungCent) ?? 0,
|
|
deckungsluecke: centZuZahl(heimkosten.deckungsluecke) ?? 0,
|
|
}
|
|
: null,
|
|
jahresbericht: jahresberichtRoh
|
|
? {
|
|
id: jahresberichtRoh.id,
|
|
titel: jahresberichtRoh.titel,
|
|
datum: tagAusDatenbank(jahresberichtRoh.datum),
|
|
tage: tageBisTag(tagAusDatenbank(jahresberichtRoh.datum), heute),
|
|
}
|
|
: null,
|
|
}
|
|
|
|
return {
|
|
betreuteId,
|
|
name: `${betreute.vorname} ${betreute.nachname}`.trim(),
|
|
heute,
|
|
fristen,
|
|
wartet,
|
|
nachforderungen,
|
|
posteingang: posteingangDaten,
|
|
belege,
|
|
kennzahlen,
|
|
}
|
|
}
|