diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a80368..4616d6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,59 @@ Wiederherstellung*. _Noch nichts._ +## [1.6.0] – 2026-08-23 + +### Hinzugefügt + +- **Rückkaufswerte aller Versicherungen in einem Zug anfragen** + ([#6](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/6)). + Vor dem Vermögensverzeichnis und vor jedem Jahresbericht braucht es von + jeder Lebens- und Rentenversicherung den Rückkaufswert zum Stichtag — + jedes Mal dieselben fünf bis zehn Briefe. Das war der Handgriff, der im + Jahr am meisten Zeit gekostet hat. + + Neu unter **Verträge → Rückkaufswerte anfragen**: Stichtag wählen, Liste + prüfen, ein Klick. Heraus kommt ein **ZIP mit einer Word-Datei je + Gesellschaft** plus Begleitblatt — und je Vertrag entsteht nebenbei die + PDF-Fassung in der Akte, die Wiedervorlage nach 28 Tagen und der + Vermerk, dass angefragt wurde. Auf „Heute" steht danach unter *Wartet + auf andere*, worauf gewartet wird und seit wann. + +- **Die Auswahl begründet sich selbst.** Vorausgewählt ist nur, was in + Frage kommt. Was nicht in Frage kommt, wird nicht stumm ausgeblendet, + sondern steht mit Grund in der Liste: „Ohne Gesellschaft fehlt der + Empfänger", „bereits vor 10 Tagen angefragt". Nur Vertragsarten, die + nie einen Rückkaufswert bilden (Strom, Haftpflicht), bleiben draußen. + Gekündigte Verträge bleiben anfragbar — bis zum Wirksamwerden besteht + der Vertrag, und genau dann will der Sozialhilfeträger den Wert sehen. + +- **Die Zahl steht vor dem Klick auf dem Knopf** („4 Schreiben erzeugen"). + Einen Fehlgriff soll man bemerken, bevor zehn Briefe entstehen — nicht + danach beim Aufräumen. + +### Zwei Zusagen + +**Kein Alles-oder-nichts.** Jeder Vertrag läuft für sich. Scheitert einer +(etwa am Objektspeicher), steht er mit Grund im Begleitblatt des ZIP — +die übrigen laufen durch. Eine Transaktion über alle wäre die falsche +Zusage: Wenn der neunte Brief scheitert, sollen die acht fertigen nicht +verworfen werden. + +**Der Doppelklick kostet nichts.** Ein zweiter Lauf am selben Tag +überspringt bereits angefragte Verträge und sagt es im Begleitblatt — +keine doppelten Dokumente, keine doppelten Wiedervorlagen. Eine bewusste +zweite Anfrage nach einer Woche (Brief verloren) bleibt möglich: von Hand +anwählen genügt. + +### Geprüft + +- Zwölf Einheitstests für die Auswahllogik, darunter der Gleichschritt + von Sperrfrist und Wiedervorlage (beide 28 Tage — liefen sie + auseinander, würde gemahnt und zugleich neu angefragt). +- Drei End-to-End-Tests: Vorauswahl samt Begründungen, der volle Lauf mit + allen Nebenwirkungen in der Datenbank, und der Dublettenschutz beim + zweiten Lauf. + ## [1.5.0] – 2026-08-23 ### Hinzugefügt @@ -815,7 +868,8 @@ Was davon wann angegangen wird, steht in [`docs/ROADMAP.md`](docs/ROADMAP.md). --- -[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.5.0...HEAD +[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.6.0...HEAD +[1.6.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.5.0...v1.6.0 [1.5.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.4.0...v1.5.0 [1.4.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.3.0...v1.4.0 [1.3.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.2.0...v1.3.0 diff --git a/README.md b/README.md index 8e30974..d01c018 100644 --- a/README.md +++ b/README.md @@ -580,11 +580,11 @@ Fehler, die während des Baus aufgefallen sind. | | | nachzuzählen mit | |---|---:|---| -| Zeilen TypeScript/TSX | 78.486 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` | +| Zeilen TypeScript/TSX | 79.102 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` | | Prisma-Modelle | 34 | `grep -c '^model ' prisma/schema.prisma` | -| Seiten | 81 | `find src/app -name page.tsx \| wc -l` | -| Unit-Tests (Vitest) | 590 | `npm test` | -| End-to-End-Tests (Playwright) | 81 | `npm run test:e2e` — 44 Tests über zwei Geräteprofile, geräteabhängige übersprungen | +| Seiten | 82 | `find src/app -name page.tsx \| wc -l` | +| Unit-Tests (Vitest) | 602 | `npm test` | +| End-to-End-Tests (Playwright) | 87 | `npm run test:e2e` — 47 Tests über zwei Geräteprofile, geräteabhängige übersprungen | | TypeScript-Fehler | 0 | `npm run typecheck` | Die Befehle stehen dabei, weil die Zahlen sonst altern, ohne dass es diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index adaddd5..0dda444 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -131,14 +131,14 @@ cd /opt/betreuungs-cockpit # Auf die letzte veröffentlichte Fassung wechseln, # nicht auf den laufenden Entwicklungsstand -git checkout v1.5.0 +git checkout v1.6.0 ``` ### Kontrolle ```bash ls docker-compose.yml Dockerfile .env.example # alle drei da -git describe --tags # v1.5.0 +git describe --tags # v1.6.0 ``` --- @@ -299,7 +299,7 @@ betreuungs-cockpit-sicherung-1 Up ```bash # Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast) curl -s http://127.0.0.1:3000/api/gesundheit -# {"status":"bereit","version":"1.5.0","commit":"..."} +# {"status":"bereit","version":"1.6.0","commit":"..."} ``` Kommt hier nichts, sieh in die Logs: @@ -649,7 +649,7 @@ Erst **vom Proxy-Server aus** — kommt er überhaupt an? ```bash curl -s http://10.0.0.5:3000/api/gesundheit -# {"status":"bereit","version":"1.5.0",...} +# {"status":"bereit","version":"1.6.0",...} ``` Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der @@ -700,7 +700,7 @@ Zum Schluss: **Fristen → Pflichtfristen ableiten**. - Du kommst mit E-Mail, Passwort und dem Code aus der App herein. - Unter **Fristen** stehen Einträge mit Paragraphenbezug. - Unter **Protokoll** siehst du deine eigene Anmeldung. -- Unten links in der Seitenleiste steht `Version 1.5.0 · `. +- Unten links in der Seitenleiste steht `Version 1.6.0 · `. --- @@ -875,7 +875,7 @@ alles so vor wie zuvor. | Aufruf | Wirkung | |---|---| | `./scripts/update.sh` | auf die neueste veröffentlichte Fassung | -| `./scripts/update.sh v1.5.0` | auf eine bestimmte Fassung | +| `./scripts/update.sh v1.6.0` | auf eine bestimmte Fassung | | `./scripts/update.sh --pruefen` | nur nachsehen, nichts ändern | | `./scripts/update.sh --ja` | ohne Rückfrage (für cron) | | `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen | @@ -913,7 +913,7 @@ git fetch --tags git log --oneline HEAD..origin/main # 3. Umschalten -git checkout v1.5.0 +git checkout v1.6.0 # 4. Neu bauen und starten GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5ae3df0..1754126 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -9,7 +9,7 @@ Zeit spart. Er steht weit unten, wenn er nur schön wäre. Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb als wichtiger herausstellt, rutscht nach oben. -Stand: 23.08.2026 · Aktuelle Fassung: **1.5.0** ([CHANGELOG](../CHANGELOG.md)) +Stand: 23.08.2026 · Aktuelle Fassung: **1.6.0** ([CHANGELOG](../CHANGELOG.md)) --- diff --git a/e2e/serienabfrage.spec.ts b/e2e/serienabfrage.spec.ts new file mode 100644 index 0000000..82efd8c --- /dev/null +++ b/e2e/serienabfrage.spec.ts @@ -0,0 +1,185 @@ +import { expect, test } from '@playwright/test' +import { PrismaClient } from '@prisma/client' +import { anmelden } from './hilfen' + +/** + * Serienabfrage der Rückkaufswerte — gegen die echte Anwendung. + * + * Geprüft wird der ganze Lauf: Vorauswahl, ZIP-Download, die + * Nebenwirkungen (Anfragedatum, Wiedervorlage, Ausgangsdokumente) und der + * Dublettenschutz beim zweiten Lauf am selben Tag. + */ + +const KENNUNG = 'Prüfhausen Serie' + +async function mitDb(arbeit: (db: PrismaClient) => Promise): Promise { + const db = new PrismaClient() + try { + return await arbeit(db) + } finally { + await db.$disconnect() + } +} + +async function aufraeumen(): Promise { + await mitDb(async (db) => { + await db.dokument.deleteMany({ where: { empfaenger: { contains: KENNUNG } } }) + await db.frist.deleteMany({ where: { titel: { contains: KENNUNG } } }) + await db.vertrag.deleteMany({ where: { bezeichnung: { contains: KENNUNG } } }) + }) +} + +test.describe('Serienabfrage', () => { + test.beforeAll(async () => { + await aufraeumen() + await mitDb(async (db) => { + const betreute = await db.betreute.findMany({ + where: { geloeschtAm: null }, + select: { id: true }, + }) + for (const person of betreute) { + // Zwei anfragbare, eine ohne Rueckkaufswert, eine ohne Gesellschaft. + await db.vertrag.createMany({ + data: [ + { + betreuteId: person.id, + bezeichnung: `${KENNUNG} Leben`, + gesellschaft: `${KENNUNG} Lebensversicherung`, + art: 'LEBENSVERSICHERUNG', + status: 'AKTIV', + }, + { + betreuteId: person.id, + bezeichnung: `${KENNUNG} Sterbegeld`, + gesellschaft: `${KENNUNG} Sterbekasse`, + art: 'STERBEGELDVERSICHERUNG', + status: 'AKTIV', + }, + { + betreuteId: person.id, + bezeichnung: `${KENNUNG} Haftpflicht`, + gesellschaft: `${KENNUNG} Allgemeine`, + art: 'HAFTPFLICHT', + status: 'AKTIV', + }, + { + betreuteId: person.id, + bezeichnung: `${KENNUNG} Ohne Gesellschaft`, + gesellschaft: null, + art: 'LEBENSVERSICHERUNG', + status: 'AKTIV', + }, + ], + }) + } + }) + }) + + test.afterAll(async () => { + await aufraeumen() + }) + + test('wählt richtig vor und benennt die Hindernisse', async ({ page }) => { + await anmelden(page) + await page.goto('/vertraege/serienabfrage/seite') + + // Die beiden anfragbaren sind vorausgewählt … + await expect( + page.locator('li', { hasText: `${KENNUNG} Leben` }).locator('input[type="checkbox"]'), + ).toBeChecked() + await expect( + page.locator('li', { hasText: `${KENNUNG} Sterbegeld` }).locator('input[type="checkbox"]'), + ).toBeChecked() + + // … die ohne Gesellschaft steht mit Begründung da, ohne Haken … + const ohne = page.locator('li', { hasText: `${KENNUNG} Ohne Gesellschaft` }) + await expect(ohne).toContainText('nachtragen') + await expect(ohne.locator('input[type="checkbox"]')).toHaveCount(0) + + // … und die Haftpflicht taucht gar nicht erst auf. + await expect(page.locator('li', { hasText: `${KENNUNG} Haftpflicht` })).toHaveCount(0) + + // Die Zusammenfassung steht auf dem Knopf, BEVOR etwas entsteht - + // und sie stimmt mit der Zahl der gesetzten Haken ueberein. Auf die + // absolute Zahl laesst sich nicht bauen: Der Demofall bringt eigene + // Vertraege mit. + const haken = await page.locator('input[type="checkbox"]:checked').count() + expect(haken).toBeGreaterThanOrEqual(2) + await expect( + page.getByRole('button', { name: new RegExp(`${haken} Schreiben erzeugen`) }), + ).toBeVisible() + }) + + test('führt den Lauf durch: ZIP, Akte, Wiedervorlagen, Anfragedatum', async ({ page }) => { + await anmelden(page) + await page.goto('/vertraege/serienabfrage/seite') + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: /Schreiben erzeugen und herunterladen/ }).click(), + ]) + expect(download.suggestedFilename()).toMatch(/^\d{4}-\d{2}-\d{2}_Rueckkaufswert-Anfragen\.zip$/) + + // Nebenwirkungen in der Datenbank: Anfragedatum, Dokumente, Fristen. + const stand = await mitDb(async (db) => { + const angefragt = await db.vertrag.count({ + where: { + bezeichnung: { in: [`${KENNUNG} Leben`, `${KENNUNG} Sterbegeld`] }, + rueckkaufswertAngefragtAm: { not: null }, + }, + }) + const dokumente = await db.dokument.count({ + where: { empfaenger: { contains: KENNUNG }, richtung: 'AUSGANG' }, + }) + const fristen = await db.frist.count({ + where: { titel: { contains: KENNUNG }, typ: 'WIEDERVORLAGE' }, + }) + return { angefragt, dokumente, fristen } + }) + + // Beide betreute Personen haben die Verträge - angefragt wird aber nur + // bei der aktiven. Deshalb: genau 2 von 4. + expect(stand.angefragt).toBe(2) + expect(stand.dokumente).toBe(2) + expect(stand.fristen).toBe(2) + + // Auf der Seite stehen die beiden jetzt als frisch angefragt. + await page.goto('/vertraege/serienabfrage/seite') + await expect(page.getByText(/bereits angefragt/).first()).toBeVisible() + }) + + test('erzeugt beim zweiten Lauf am selben Tag keine Dubletten', async ({ page }) => { + await anmelden(page) + await page.goto('/vertraege/serienabfrage/seite') + + // Alles abwaehlen, dann NUR den frisch angefragten Vertrag anwaehlen. + // So prueft der Lauf genau den Dublettenschutz und fasst die uebrigen + // Vertraege nicht an. + const angehakte = page.locator('input[type="checkbox"]:checked') + while ((await angehakte.count()) > 0) { + await angehakte.first().uncheck() + } + const leben = page.locator('li', { hasText: `${KENNUNG} Leben` }) + await leben.locator('input[type="checkbox"]').check() + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: /Schreiben erzeugen und herunterladen/ }).click(), + ]) + expect(download.suggestedFilename()).toContain('.zip') + + // Der Dublettenschutz greift: weiterhin genau ein Dokument und eine + // Frist für diesen Vertrag. + const stand = await mitDb(async (db) => { + const dokumente = await db.dokument.count({ + where: { empfaenger: `${KENNUNG} Lebensversicherung` }, + }) + const fristen = await db.frist.count({ + where: { titel: { contains: `${KENNUNG} Leben` }, typ: 'WIEDERVORLAGE' }, + }) + return { dokumente, fristen } + }) + expect(stand.dokumente).toBe(1) + expect(stand.fristen).toBe(1) + }) +}) diff --git a/package.json b/package.json index 0ce2b1f..24cef03 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betreuungs-cockpit", - "version": "1.5.0", + "version": "1.6.0", "private": true, "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "engines": { diff --git a/src/app/(app)/vertraege/page.tsx b/src/app/(app)/vertraege/page.tsx index eae6751..be589af 100644 --- a/src/app/(app)/vertraege/page.tsx +++ b/src/app/(app)/vertraege/page.tsx @@ -1,235 +1,243 @@ -import Link from 'next/link' -import { Plus, Shield } from 'lucide-react' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' -import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf' -import { Rechtshinweis } from '@/components/rechtshinweis' -import { - KuendigungsUebersicht, - type Kuendigungswarnung, -} from '@/components/vertraege/kuendigungs-uebersicht' -import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' -import { centZuZahl, db, nichtGeloescht } from '@/lib/db' -import { oeffnen } from '@/lib/crypto/feld' -import { heuteAlsTag } from '@/lib/fristen/datum' -import { - kuendigungsampel, - kuendigungsplan, - laufzeitAusVertrag, -} from '@/lib/vertraege/kuendigung' -import { - BEWERTUNG_BESCHRIFTUNG, - jahresbeitragCent, - RHYTHMUS_BESCHRIFTUNG, - VERTRAG_ART_BESCHRIFTUNG, - VERTRAG_STATUS_BESCHRIFTUNG, -} from '@/lib/vertraege/konstanten' -import { formatCent, formatDatum } from '@/lib/utils' - -export const dynamic = 'force-dynamic' - -const BEWERTUNG_VARIANTE = { - UNGEPRUEFT: 'outline', - VERWERTBAR: 'orange', - GESCHUETZT: 'gruen', - STRITTIG: 'gelb', -} as const - -export default async function VertraegeUebersicht() { - const benutzer = await erfordereBenutzer('/vertraege') - const aktive = await aktiveBetreute(benutzer) - if (!aktive) return null - - const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') - const schreiben = darfSchreiben('VERTRAEGE') - - const vertraege = await db.vertrag.findMany({ - where: { betreuteId: aktive.id, ...nichtGeloescht }, - include: { - vermoegensposten: { - where: { art: 'VERSICHERUNG', ...nichtGeloescht }, - include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } }, - }, - }, - orderBy: [{ status: 'asc' }, { bezeichnung: 'asc' }], - }) - - const heute = heuteAlsTag() - const warnungen: Kuendigungswarnung[] = [] - let jahresbeitragSumme = 0n - - const zeilen = vertraege.map((vertrag) => { - const offen = oeffnen('Vertrag', vertrag) - const plan = kuendigungsplan({ - beginnAm: vertrag.beginnAm, - endeAm: vertrag.endeAm, - laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm), - kuendigungsfristMonate: vertrag.kuendigungsfristMonate, - heute, - }) - const ampel = kuendigungsampel(plan) - - const jahr = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus) - if (jahr && vertrag.status === 'AKTIV') jahresbeitragSumme += jahr - - const laufend = vertrag.status === 'AKTIV' || vertrag.status === 'BEITRAGSFREI' - if (laufend && ampel && (ampel.variante === 'rot' || ampel.variante === 'orange')) { - warnungen.push({ - vertragId: vertrag.id, - bezeichnung: vertrag.bezeichnung, - absendetag: plan.spaetesterAbsendetag?.toISOString() ?? null, - tageBisAbsendetag: plan.tageBisAbsendetag, - verpasst: plan.verpasst, - variante: ampel.variante, - text: ampel.text, - }) - } - - return { - id: vertrag.id, - bezeichnung: vertrag.bezeichnung, - gesellschaft: vertrag.gesellschaft, - art: vertrag.art, - status: vertrag.status, - vertragsnummer: offen.vertragsnummer, - beitragCent: centZuZahl(vertrag.beitragCent), - rhythmus: vertrag.rhythmus, - bewertung: vertrag.bewertungSozialhilfe, - rueckkaufswertCent: centZuZahl( - vertrag.vermoegensposten[0]?.wertstaende[0]?.wertCent ?? null, - ), - rueckkaufswertStichtag: - vertrag.vermoegensposten[0]?.wertstaende[0]?.stichtag.toISOString() ?? null, - ampel, - } - }) - - return ( -
- - -
- ) -} +import Link from 'next/link' +import { FileArchive, Plus, Shield } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf' +import { Rechtshinweis } from '@/components/rechtshinweis' +import { + KuendigungsUebersicht, + type Kuendigungswarnung, +} from '@/components/vertraege/kuendigungs-uebersicht' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { centZuZahl, db, nichtGeloescht } from '@/lib/db' +import { oeffnen } from '@/lib/crypto/feld' +import { heuteAlsTag } from '@/lib/fristen/datum' +import { + kuendigungsampel, + kuendigungsplan, + laufzeitAusVertrag, +} from '@/lib/vertraege/kuendigung' +import { + BEWERTUNG_BESCHRIFTUNG, + jahresbeitragCent, + RHYTHMUS_BESCHRIFTUNG, + VERTRAG_ART_BESCHRIFTUNG, + VERTRAG_STATUS_BESCHRIFTUNG, +} from '@/lib/vertraege/konstanten' +import { formatCent, formatDatum } from '@/lib/utils' + +export const dynamic = 'force-dynamic' + +const BEWERTUNG_VARIANTE = { + UNGEPRUEFT: 'outline', + VERWERTBAR: 'orange', + GESCHUETZT: 'gruen', + STRITTIG: 'gelb', +} as const + +export default async function VertraegeUebersicht() { + const benutzer = await erfordereBenutzer('/vertraege') + const aktive = await aktiveBetreute(benutzer) + if (!aktive) return null + + const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') + const schreiben = darfSchreiben('VERTRAEGE') + + const vertraege = await db.vertrag.findMany({ + where: { betreuteId: aktive.id, ...nichtGeloescht }, + include: { + vermoegensposten: { + where: { art: 'VERSICHERUNG', ...nichtGeloescht }, + include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } }, + }, + }, + orderBy: [{ status: 'asc' }, { bezeichnung: 'asc' }], + }) + + const heute = heuteAlsTag() + const warnungen: Kuendigungswarnung[] = [] + let jahresbeitragSumme = 0n + + const zeilen = vertraege.map((vertrag) => { + const offen = oeffnen('Vertrag', vertrag) + const plan = kuendigungsplan({ + beginnAm: vertrag.beginnAm, + endeAm: vertrag.endeAm, + laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm), + kuendigungsfristMonate: vertrag.kuendigungsfristMonate, + heute, + }) + const ampel = kuendigungsampel(plan) + + const jahr = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus) + if (jahr && vertrag.status === 'AKTIV') jahresbeitragSumme += jahr + + const laufend = vertrag.status === 'AKTIV' || vertrag.status === 'BEITRAGSFREI' + if (laufend && ampel && (ampel.variante === 'rot' || ampel.variante === 'orange')) { + warnungen.push({ + vertragId: vertrag.id, + bezeichnung: vertrag.bezeichnung, + absendetag: plan.spaetesterAbsendetag?.toISOString() ?? null, + tageBisAbsendetag: plan.tageBisAbsendetag, + verpasst: plan.verpasst, + variante: ampel.variante, + text: ampel.text, + }) + } + + return { + id: vertrag.id, + bezeichnung: vertrag.bezeichnung, + gesellschaft: vertrag.gesellschaft, + art: vertrag.art, + status: vertrag.status, + vertragsnummer: offen.vertragsnummer, + beitragCent: centZuZahl(vertrag.beitragCent), + rhythmus: vertrag.rhythmus, + bewertung: vertrag.bewertungSozialhilfe, + rueckkaufswertCent: centZuZahl( + vertrag.vermoegensposten[0]?.wertstaende[0]?.wertCent ?? null, + ), + rueckkaufswertStichtag: + vertrag.vermoegensposten[0]?.wertstaende[0]?.stichtag.toISOString() ?? null, + ampel, + } + }) + + return ( +
+ + + + + ) + } + /> + + + + {vertraege.length === 0 ? ( + + +
+ ) +} diff --git a/src/app/(app)/vertraege/serienabfrage/route.ts b/src/app/(app)/vertraege/serienabfrage/route.ts new file mode 100644 index 0000000..979246a --- /dev/null +++ b/src/app/(app)/vertraege/serienabfrage/route.ts @@ -0,0 +1,74 @@ +import { NextResponse } from 'next/server' +import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung' +import { serienlaufDurchfuehren } from '@/lib/vertraege/serienlauf' +import { tagAusText } from '@/lib/finanzen/datum' + +/** + * Fuehrt die Serienabfrage durch und liefert das ZIP. + * + * POST wie bei den anderen Downloads: Die Auswahl kommt mit, und der Lauf + * hat Nebenwirkungen (Dokumente, Fristen, Anfragedatum) - ein GET, das + * schreibt, waere eine Falle fuer jeden Vorabruf durch den Browser. + * + * Die Nebenwirkungen passieren VOR der Auslieferung. Faellt danach noch + * etwas beim Herunterladen aus, sind Akte und Fristen trotzdem korrekt - + * und die Schreiben liegen als PDF ohnehin in der Akte. + */ + +export const dynamic = 'force-dynamic' + +export async function POST(anfrage: Request): Promise { + let formular: FormData + try { + formular = await anfrage.formData() + } catch { + return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 }) + } + + const betreuteId = String(formular.get('betreuteId') ?? '') + const stichtagRoh = String(formular.get('stichtag') ?? '').trim() + const vertragIds = formular + .getAll('vertragId') + .map((wert) => String(wert).trim()) + .filter(Boolean) + + if (!betreuteId || vertragIds.length === 0) { + return NextResponse.json({ fehler: 'Es ist kein Vertrag ausgewählt.' }, { status: 400 }) + } + // Obergrenze als Schutz vor Unsinn - real sind es fuenf bis zehn. + if (vertragIds.length > 100) { + return NextResponse.json({ fehler: 'Zu viele Verträge auf einmal.' }, { status: 400 }) + } + + const stichtag = tagAusText(stichtagRoh) + if (!stichtag) { + return NextResponse.json({ fehler: 'Bitte einen gültigen Stichtag angeben.' }, { status: 400 }) + } + + let benutzer + try { + const zugang = await erfordereZugang(betreuteId, 'VERTRAEGE', 'schreiben') + benutzer = zugang.benutzer + } catch (ausnahme) { + if (ausnahme instanceof ZugriffVerweigert) { + return NextResponse.json({ fehler: ausnahme.message }, { status: 403 }) + } + throw ausnahme + } + + const ergebnis = await serienlaufDurchfuehren({ + betreuteId, + vertragIds, + stichtag, + benutzer: { id: benutzer.id, name: benutzer.name, email: benutzer.email }, + }) + + return new NextResponse(new Uint8Array(ergebnis.zip), { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${ergebnis.dateiname}"`, + 'Content-Length': String(ergebnis.zip.length), + 'Cache-Control': 'no-store, must-revalidate', + }, + }) +} diff --git a/src/app/(app)/vertraege/serienabfrage/seite/page.tsx b/src/app/(app)/vertraege/serienabfrage/seite/page.tsx new file mode 100644 index 0000000..4d15f97 --- /dev/null +++ b/src/app/(app)/vertraege/serienabfrage/seite/page.tsx @@ -0,0 +1,92 @@ +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { Scale } from 'lucide-react' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { SeitenKopf } from '@/components/seiten-kopf' +import { + SerienabfrageFormular, + type SerienZeile, +} from '@/components/vertraege/serienabfrage-formular' +import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' +import { db, nichtGeloescht } from '@/lib/db' +import { kandidatenBewerten } from '@/lib/vertraege/serienabfrage' +import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten' +import { heuteAlsTag } from '@/lib/fristen/datum' + +export const metadata: Metadata = { title: 'Rückkaufswerte anfragen' } +export const dynamic = 'force-dynamic' + +/** + * Die Serienabfrage: alle Rückkaufswerte in einem Zug. + * + * Liegt unter /vertraege/serienabfrage/seite, weil /vertraege/serienabfrage + * die POST-Route ist, die das ZIP liefert. Die Trennung ist Absicht: Die + * Route hat Nebenwirkungen und gehört nicht hinter einen GET. + */ +export default async function SerienabfrageSeite() { + const benutzer = await erfordereBenutzer('/vertraege/serienabfrage/seite') + const aktive = await aktiveBetreute(benutzer) + if (!aktive) redirect('/ersteinrichtung') + + const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') + if (!darfSchreiben('VERTRAEGE')) redirect('/vertraege') + + const vertraege = await db.vertrag.findMany({ + where: { betreuteId: aktive.id, ...nichtGeloescht }, + select: { + id: true, + bezeichnung: true, + gesellschaft: true, + art: true, + status: true, + rueckkaufswertAngefragtAm: true, + }, + }) + + const bewertet = kandidatenBewerten(vertraege) + + // Vertraege ohne jede Aussicht auf einen Rueckkaufswert (Strom, Telefon, + // Rundfunk) wuerden die Liste nur verlaengern - sie bleiben draussen. + // Alles andere steht drin, notfalls mit Hindernis. + const zeilen: SerienZeile[] = bewertet + .filter( + (k) => k.moeglich || k.hindernis !== 'Diese Vertragsart bildet keinen Rückkaufswert.', + ) + .map((k) => ({ + vertragId: k.vertrag.id, + bezeichnung: k.vertrag.bezeichnung, + gesellschaft: k.vertrag.gesellschaft, + artBeschriftung: VERTRAG_ART_BESCHRIFTUNG[k.vertrag.art], + moeglich: k.moeglich, + hindernis: k.hindernis, + vorausgewaehlt: k.vorausgewaehlt, + })) + + return ( +
+ + + + + + +
+ ) +} diff --git a/src/components/vertraege/serienabfrage-formular.tsx b/src/components/vertraege/serienabfrage-formular.tsx new file mode 100644 index 0000000..51f4670 --- /dev/null +++ b/src/components/vertraege/serienabfrage-formular.tsx @@ -0,0 +1,165 @@ +'use client' + +import * as React from 'react' +import { useRouter } from 'next/navigation' +import { AlertCircle, FileArchive, Info } from 'lucide-react' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { Feld } from '@/components/feld' + +export type SerienZeile = { + vertragId: string + bezeichnung: string + gesellschaft: string | null + artBeschriftung: string + moeglich: boolean + hindernis: string | null + vorausgewaehlt: boolean +} + +/** + * Auswahl und Start der Serienabfrage. + * + * Die Zusammenfassung ("Es entstehen N Schreiben") steht direkt auf dem + * Knopf. Man soll den Fehlgriff bemerken, BEVOR zehn Briefe entstehen — + * nicht danach beim Aufräumen. + * + * Der Start läuft als gewöhnliches Formular-POST gegen die Route: Der + * Browser bekommt das ZIP als Download, und die Nebenwirkungen (Akte, + * Fristen, Anfragedatum) sind serverseitig schon passiert. Nach dem + * Klick wird die Seite neu geladen, damit die Sperrvermerke stimmen. + */ +export function SerienabfrageFormular({ + betreuteId, + zeilen, + stichtagVorschlag, +}: { + betreuteId: string + zeilen: SerienZeile[] + stichtagVorschlag: string +}) { + const router = useRouter() + const [gewaehlt, setGewaehlt] = React.useState>( + () => new Set(zeilen.filter((z) => z.vorausgewaehlt).map((z) => z.vertragId)), + ) + const [stichtag, setStichtag] = React.useState(stichtagVorschlag) + const [gestartet, setGestartet] = React.useState(false) + + function umschalten(id: string) { + setGewaehlt((bisher) => { + const neu = new Set(bisher) + if (neu.has(id)) neu.delete(id) + else neu.add(id) + return neu + }) + } + + const anzahl = gewaehlt.size + + function beimAbsenden() { + // Das Formular lädt als Download - die Seite bleibt stehen. Nach + // kurzer Zeit neu laden, damit Sperrvermerke und "Wartet auf andere" + // den neuen Stand zeigen. + setGestartet(true) + window.setTimeout(() => { + router.refresh() + setGestartet(false) + }, 4000) + } + + return ( +
+ + + + setStichtag(ereignis.target.value)} + required + /> + + +
    + {zeilen.map((zeile) => ( +
  • + {zeile.moeglich ? ( + umschalten(zeile.vertragId)} + className="mt-0.5" + /> + ) : ( +
  • + ))} +
+ + {zeilen.length === 0 && ( + + + )} + + {anzahl === 0 && zeilen.length > 0 && ( + + + )} + +
+ +

+ Je Vertrag entsteht eine Word-Datei im ZIP, eine PDF-Fassung in der Akte, eine + Wiedervorlage nach 28 Tagen — und der Vertrag gilt als angefragt. +

+
+
+ ) +} diff --git a/src/lib/version.ts b/src/lib/version.ts index 3849ef6..af8b3f0 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -17,7 +17,7 @@ */ /** Muss zur "version" in der package.json passen. */ -export const VERSION = '1.5.0' +export const VERSION = '1.6.0' /** * Kurzer Git-Stand, beim Bauen hineingereicht: diff --git a/src/lib/vertraege/serienabfrage.ts b/src/lib/vertraege/serienabfrage.ts new file mode 100644 index 0000000..00441ef --- /dev/null +++ b/src/lib/vertraege/serienabfrage.ts @@ -0,0 +1,139 @@ +import type { VertragArt, VertragStatus } from '@prisma/client' +import { kannRueckkaufswertHaben } from '@/lib/vertraege/konstanten' +import { RUECKKAUFSWERT_WIEDERVORLAGE_TAGE } from '@/lib/vertraege/rueckkaufswert' +import { heuteAlsTag, tageZwischen } from '@/lib/fristen/datum' + +/** + * Die Serienabfrage der Rueckkaufswerte - Auswahllogik. + * + * Rein und ohne Datenbank: Welche Vertraege kommen in Frage, welche sind + * vorausgewaehlt, welche gesperrt? Das sind die Entscheidungen, die + * hinterher niemand mehr nachvollziehen kann, wenn sie in einer + * Datenbankabfrage stecken. Hier stehen sie einzeln und mit Begruendung. + * + * Der Anlass: Vor dem Vermoegensverzeichnis und vor jedem Jahresbericht + * braucht es von jeder Lebens- und Rentenversicherung den Rueckkaufswert + * zum Stichtag. Das sind jedes Mal dieselben fuenf bis zehn Briefe. + */ + +export type SerienKandidat = { + id: string + bezeichnung: string + gesellschaft: string | null + art: VertragArt + status: VertragStatus + rueckkaufswertAngefragtAm: Date | null +} + +export type KandidatBewertung = { + vertrag: SerienKandidat + /** Kommt der Vertrag ueberhaupt in Frage? */ + moeglich: boolean + /** Warum nicht? Steht in der Liste, statt den Vertrag stumm auszublenden. */ + hindernis: string | null + /** Vorausgewaehlt? Nur moegliche Vertraege ohne frische Anfrage. */ + vorausgewaehlt: boolean + /** Tage seit der letzten Anfrage, falls eine lief. */ + tageSeitAnfrage: number | null +} + +/** + * Sperrfrist: Wer in den letzten 28 Tagen schon gefragt hat, fragt nicht + * gleich noch einmal - die Antwort ist schlicht noch unterwegs. Dieselbe + * Zahl wie die Wiedervorlage; ein Test haelt beide zusammen. + */ +export const SPERRFRIST_TAGE = RUECKKAUFSWERT_WIEDERVORLAGE_TAGE + +/** + * Bewertet einen Vertrag fuer die Serienabfrage. + * + * Grundsatz: Nicht stumm ausblenden, sondern begruenden. Ein Vertrag, der + * fehlt, wirft die Frage auf "und wo ist der?"; ein Vertrag mit dem + * Vermerk "gekuendigt" beantwortet sie gleich mit. + */ +export function bewerten(vertrag: SerienKandidat, heute: Date = heuteAlsTag()): KandidatBewertung { + const tageSeitAnfrage = vertrag.rueckkaufswertAngefragtAm + ? tageZwischen(vertrag.rueckkaufswertAngefragtAm, heute) + : null + + if (!kannRueckkaufswertHaben(vertrag.art)) { + return { + vertrag, + moeglich: false, + hindernis: 'Diese Vertragsart bildet keinen Rückkaufswert.', + vorausgewaehlt: false, + tageSeitAnfrage, + } + } + + // Verwertet oder abgelaufen: Da gibt es nichts mehr anzufragen. GEKUENDIGT + // bleibt dagegen moeglich - bis zum Wirksamwerden der Kuendigung besteht + // der Vertrag, und genau dann will der Sozialhilfetraeger den Wert sehen. + if (vertrag.status === 'VERWERTET' || vertrag.status === 'ABGELAUFEN') { + return { + vertrag, + moeglich: false, + hindernis: + vertrag.status === 'VERWERTET' + ? 'Der Vertrag ist bereits verwertet.' + : 'Der Vertrag ist abgelaufen.', + vorausgewaehlt: false, + tageSeitAnfrage, + } + } + + if (!vertrag.gesellschaft?.trim()) { + return { + vertrag, + moeglich: false, + hindernis: 'Ohne Gesellschaft fehlt der Empfänger — bitte am Vertrag nachtragen.', + vorausgewaehlt: false, + tageSeitAnfrage, + } + } + + // Frisch angefragt: moeglich, aber nicht vorausgewaehlt. Wer trotzdem + // will (etwa weil der Brief verloren ging), waehlt von Hand an. + if (tageSeitAnfrage !== null && tageSeitAnfrage < SPERRFRIST_TAGE) { + return { + vertrag, + moeglich: true, + hindernis: `Vor ${tageSeitAnfrage} Tag${tageSeitAnfrage === 1 ? '' : 'en'} bereits angefragt — die Antwort ist vermutlich noch unterwegs.`, + vorausgewaehlt: false, + tageSeitAnfrage, + } + } + + return { vertrag, moeglich: true, hindernis: null, vorausgewaehlt: true, tageSeitAnfrage } +} + +export function kandidatenBewerten( + vertraege: SerienKandidat[], + heute: Date = heuteAlsTag(), +): KandidatBewertung[] { + return vertraege + .map((vertrag) => bewerten(vertrag, heute)) + .sort((a, b) => { + // Vorausgewaehlte zuerst, dann die moeglichen, dann der Rest - + // jeweils alphabetisch. Was zu tun ist, steht oben. + const rang = (k: KandidatBewertung) => (k.vorausgewaehlt ? 0 : k.moeglich ? 1 : 2) + if (rang(a) !== rang(b)) return rang(a) - rang(b) + return a.vertrag.bezeichnung.localeCompare(b.vertrag.bezeichnung, 'de') + }) +} + +/** + * Wurde heute schon angefragt? Der Dublettenschutz fuer den zweiten Lauf. + * + * Bewusst tagesgenau und nicht "innerhalb der Sperrfrist": Wer denselben + * Lauf versehentlich zweimal startet, soll keine doppelten Dokumente + * bekommen. Wer nach einer Woche bewusst erneut anfragt (Brief verloren), + * soll es duerfen. + */ +export function heuteSchonAngefragt( + angefragtAm: Date | null, + heute: Date = heuteAlsTag(), +): boolean { + if (!angefragtAm) return false + return tageZwischen(angefragtAm, heute) === 0 +} diff --git a/src/lib/vertraege/serienlauf.ts b/src/lib/vertraege/serienlauf.ts new file mode 100644 index 0000000..3d2aa67 --- /dev/null +++ b/src/lib/vertraege/serienlauf.ts @@ -0,0 +1,254 @@ +import 'server-only' +import archiver from 'archiver' +import { db, nichtGeloescht } from '@/lib/db' +import { oeffnen } from '@/lib/crypto/feld' +import { protokollieren } from '@/lib/audit' +import { ablegenMitTyp } from '@/lib/speicher' +import { briefkopfBauen } from '@/lib/vertraege/briefkopf' +import { + RUECKKAUFSWERT_WIEDERVORLAGE_TAGE, + rueckkaufswertAnschreiben, + wiedervorlageAm, +} from '@/lib/vertraege/rueckkaufswert' +import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten' +import { heuteSchonAngefragt } from '@/lib/vertraege/serienabfrage' +import { briefDocxErzeugen } from '@/lib/schreiben/docx' +import { briefPdfErzeugen } from '@/lib/schreiben/pdf' +import { dateinameFuerSchreiben } from '@/lib/schreiben/arten' +import { betreffAusText } from '@/lib/schreiben/entwurf' +import { heuteAlsTag } from '@/lib/fristen/datum' + +/** + * Der Serienlauf: Rueckkaufswerte aller gewaehlten Vertraege anfragen. + * + * Je Vertrag entsteht das volle Paket aus Issue #4 - Word-Datei fuers + * ZIP, PDF in die Akte, rueckkaufswertAngefragtAm, Wiedervorlage, + * Protokolleintrag. Der Unterschied: alles in einem Lauf, und ein + * gescheiterter Vertrag reisst die uebrigen nicht mit. + * + * Kein Alles-oder-nichts heisst konkret: Jeder Vertrag laeuft fuer sich. + * Was gelingt, gilt; was scheitert, steht mit Grund im Ergebnis und im + * Begleitblatt des ZIP. Eine Transaktion ueber alle Vertraege waere die + * falsche Zusage - wenn der neunte Brief am Objektspeicher scheitert, + * sollen die acht fertigen nicht verworfen werden. + */ + +export type SerienErgebnisZeile = { + vertragId: string + bezeichnung: string + gesellschaft: string | null + /** 'erzeugt' | 'uebersprungen' | 'fehlgeschlagen' */ + ausgang: 'erzeugt' | 'uebersprungen' | 'fehlgeschlagen' + grund: string | null +} + +export type SerienErgebnis = { + zip: Buffer + dateiname: string + zeilen: SerienErgebnisZeile[] + erzeugt: number +} + +export async function serienlaufDurchfuehren(eingabe: { + betreuteId: string + vertragIds: string[] + stichtag: Date + benutzer: { id: string; name: string; email: string } +}): Promise { + const heute = heuteAlsTag() + const { betreuteId, benutzer } = eingabe + + const betreute = await db.betreute.findFirst({ + where: { id: betreuteId, ...nichtGeloescht }, + }) + if (!betreute) throw new Error('Die betreute Person gibt es nicht (mehr).') + + const kopf = briefkopfBauen({ + betreute: oeffnen('Betreute', betreute), + benutzer: { name: benutzer.name }, + }) + + // Nur Vertraege dieser betreuten Person - fremde IDs laufen ins Leere. + const vertraege = await db.vertrag.findMany({ + where: { id: { in: eingabe.vertragIds }, betreuteId, ...nichtGeloescht }, + }) + + const zeilen: SerienErgebnisZeile[] = [] + const dateien: { name: string; inhalt: Buffer }[] = [] + + for (const vertrag of vertraege) { + const basis = { + vertragId: vertrag.id, + bezeichnung: vertrag.bezeichnung, + gesellschaft: vertrag.gesellschaft, + } + + try { + // Dublettenschutz: Der zweite Lauf am selben Tag erzeugt nichts + // Neues, sondern sagt, dass es heute schon lief. + if (heuteSchonAngefragt(vertrag.rueckkaufswertAngefragtAm, heute)) { + zeilen.push({ + ...basis, + ausgang: 'uebersprungen', + grund: 'Heute bereits angefragt — kein zweites Schreiben erzeugt.', + }) + continue + } + + const offen = oeffnen('Vertrag', vertrag) + const text = rueckkaufswertAnschreiben({ + kopf, + empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null }, + vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`, + vertragsnummer: offen.vertragsnummer, + stichtag: eingabe.stichtag, + heute, + }) + const betreff = betreffAusText(text) || `Rückkaufswert ${vertrag.bezeichnung}` + + // --- Word-Datei fuers ZIP ------------------------------------------ + const docx = await briefDocxErzeugen(text) + dateien.push({ + name: dateinameFuerSchreiben('RUECKKAUFSWERT', vertrag.gesellschaft, heute, 'docx'), + inhalt: docx, + }) + + // --- PDF in die Akte ----------------------------------------------- + const pdf = await briefPdfErzeugen(text, heute) + const ablage = await ablegenMitTyp( + betreuteId, + 'dokumente', + dateinameFuerSchreiben('RUECKKAUFSWERT', vertrag.gesellschaft, heute, 'pdf'), + pdf, + 'application/pdf', + ) + + const dokument = await db.dokument.create({ + data: { + betreuteId, + titel: betreff.slice(0, 200), + art: 'SCHREIBEN', + richtung: 'AUSGANG', + schreibenVom: heute, + versandAm: heute, + empfaenger: vertrag.gesellschaft, + objektSchluessel: ablage.schluessel, + dateiname: ablage.dateiname, + mimeTyp: ablage.typ.mimeTyp, + groesseBytes: ablage.groesseBytes, + pruefsumme: ablage.pruefsumme, + ocrStatus: 'nicht_verfuegbar', + posteingangStatus: 'ZUGEORDNET', + verknuepfungen: { create: [{ betreuteId, vertragId: vertrag.id }] }, + }, + select: { id: true }, + }) + + // --- Wiedervorlage und Anfragedatum -------------------------------- + await db.frist.create({ + data: { + betreuteId, + titel: `Antwort abwarten: Rückkaufswert ${vertrag.bezeichnung}`.slice(0, 200), + beschreibung: + `Am ${heute.toLocaleDateString('de-DE')} an ${vertrag.gesellschaft ?? 'die Gesellschaft'} ` + + `geschickt (Stichtag ${eingabe.stichtag.toLocaleDateString('de-DE')}). ` + + 'Kommt bis zu diesem Tag nichts, nachfassen.', + datum: wiedervorlageAm(heute), + typ: 'WIEDERVORLAGE', + verknuepfungen: { create: [{ betreuteId, dokumentId: dokument.id }] }, + }, + }) + + await db.vertrag.update({ + where: { id: vertrag.id }, + data: { rueckkaufswertAngefragtAm: heute }, + }) + + await protokollieren({ + aktion: 'ERSTELLT', + entitaet: 'Dokument', + entitaetId: dokument.id, + bezeichnung: `Serienabfrage Rückkaufswert: ${vertrag.bezeichnung}`, + betreuteId, + userId: benutzer.id, + userEmail: benutzer.email, + userName: benutzer.name, + }) + + zeilen.push({ ...basis, ausgang: 'erzeugt', grund: null }) + } catch (ausnahme) { + // Ein Vertrag scheitert, die uebrigen laufen weiter. Der Grund + // steht im Ergebnis - nichts verschwindet stumm. + console.error(`[serienlauf] Vertrag ${vertrag.id} fehlgeschlagen:`, ausnahme) + zeilen.push({ + ...basis, + ausgang: 'fehlgeschlagen', + grund: ausnahme instanceof Error ? ausnahme.message : 'Unbekannter Fehler.', + }) + } + } + + // IDs, die zu keinem Vertrag der Person gehoeren - auch das wird + // benannt statt verschluckt. + const gefunden = new Set(vertraege.map((v) => v.id)) + for (const id of eingabe.vertragIds) { + if (!gefunden.has(id)) { + zeilen.push({ + vertragId: id, + bezeichnung: '(unbekannt)', + gesellschaft: null, + ausgang: 'fehlgeschlagen', + grund: 'Diesen Vertrag gibt es nicht (mehr).', + }) + } + } + + const erzeugt = zeilen.filter((z) => z.ausgang === 'erzeugt').length + + // --- Begleitblatt und ZIP ---------------------------------------------- + const begleitblatt = [ + `Serienabfrage Rückkaufswerte vom ${heute.toLocaleDateString('de-DE')}`, + `Stichtag der Anfragen: ${eingabe.stichtag.toLocaleDateString('de-DE')}`, + `Wiedervorlage: ${wiedervorlageAm(heute).toLocaleDateString('de-DE')} (${RUECKKAUFSWERT_WIEDERVORLAGE_TAGE} Tage)`, + '', + ...zeilen.map((zeile) => { + const stand = + zeile.ausgang === 'erzeugt' + ? 'erzeugt' + : zeile.ausgang === 'uebersprungen' + ? `übersprungen: ${zeile.grund}` + : `FEHLGESCHLAGEN: ${zeile.grund}` + return `- ${zeile.bezeichnung}${zeile.gesellschaft ? ` (${zeile.gesellschaft})` : ''} — ${stand}` + }), + '', + 'Jedes erzeugte Schreiben liegt zusätzlich als PDF in der Akte (Dokumente, Richtung Ausgang).', + ].join('\n') + + const zip = await zipBauen([ + { name: 'UEBERSICHT.txt', inhalt: Buffer.from(begleitblatt, 'utf8') }, + ...dateien, + ]) + + return { + zip, + dateiname: `${heute.toISOString().slice(0, 10)}_Rueckkaufswert-Anfragen.zip`, + zeilen, + erzeugt, + } +} + +/** Packt die Dateien in ein ZIP im Speicher. Bei einer Handvoll Word-Dateien + * ist Streamen unnoetig - Einfachheit schlaegt hier Durchsatz. */ +function zipBauen(dateien: { name: string; inhalt: Buffer }[]): Promise { + return new Promise((erfuellen, verwerfen) => { + const archiv = archiver('zip', { zlib: { level: 9 } }) + const teile: Buffer[] = [] + archiv.on('data', (teil: Buffer) => teile.push(teil)) + archiv.on('error', verwerfen) + archiv.on('end', () => erfuellen(Buffer.concat(teile))) + for (const datei of dateien) { + archiv.append(datei.inhalt, { name: datei.name }) + } + void archiv.finalize() + }) +} diff --git a/tests/serienabfrage.test.ts b/tests/serienabfrage.test.ts new file mode 100644 index 0000000..47a9cb1 --- /dev/null +++ b/tests/serienabfrage.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { + SPERRFRIST_TAGE, + bewerten, + heuteSchonAngefragt, + kandidatenBewerten, + type SerienKandidat, +} from '@/lib/vertraege/serienabfrage' +import { RUECKKAUFSWERT_WIEDERVORLAGE_TAGE } from '@/lib/vertraege/rueckkaufswert' + +/** + * Serienabfrage der Rückkaufswerte — die Auswahllogik. + * + * Der teuerste Fehler wäre hier ein Brief zu viel an der falschen Stelle + * (die Gesellschaft antwortet auf die zweite Anfrage mit Verwirrung) oder + * ein stumm fehlender Vertrag (der Wert fehlt dann im Vermögensverzeichnis). + * Beides wird geprüft. + */ + +const HEUTE = new Date('2026-08-23T00:00:00.000Z') + +function vertrag(teile: Partial = {}): SerienKandidat { + return { + id: 'v1', + bezeichnung: 'Lebensversicherung', + gesellschaft: 'Allianz', + art: 'LEBENSVERSICHERUNG', + status: 'AKTIV', + rueckkaufswertAngefragtAm: null, + ...teile, + } +} + +function vorTagen(tage: number): Date { + const d = new Date(HEUTE) + d.setUTCDate(d.getUTCDate() - tage) + return d +} + +describe('Bewertung eines Vertrags', () => { + it('wählt eine unbefragte Lebensversicherung vor', () => { + const b = bewerten(vertrag(), HEUTE) + expect(b.moeglich).toBe(true) + expect(b.vorausgewaehlt).toBe(true) + expect(b.hindernis).toBeNull() + }) + + it('schließt Vertragsarten ohne Rückkaufswert aus — mit Begründung', () => { + const b = bewerten(vertrag({ art: 'HAFTPFLICHT' }), HEUTE) + expect(b.moeglich).toBe(false) + expect(b.hindernis).toContain('keinen Rückkaufswert') + }) + + it('schließt verwertete und abgelaufene Verträge aus', () => { + expect(bewerten(vertrag({ status: 'VERWERTET' }), HEUTE).moeglich).toBe(false) + expect(bewerten(vertrag({ status: 'ABGELAUFEN' }), HEUTE).moeglich).toBe(false) + }) + + it('lässt einen gekündigten Vertrag zu', () => { + // Bis zum Wirksamwerden besteht der Vertrag - und genau dann will der + // Sozialhilfeträger den Wert sehen. + const b = bewerten(vertrag({ status: 'GEKUENDIGT' }), HEUTE) + expect(b.moeglich).toBe(true) + }) + + it('verlangt eine Gesellschaft und sagt, was zu tun ist', () => { + const b = bewerten(vertrag({ gesellschaft: null }), HEUTE) + expect(b.moeglich).toBe(false) + expect(b.hindernis).toContain('nachtragen') + }) + + it('wählt einen frisch angefragten Vertrag NICHT vor, lässt ihn aber wählbar', () => { + // Der Brief könnte verloren gegangen sein - dann wählt man von Hand an. + const b = bewerten(vertrag({ rueckkaufswertAngefragtAm: vorTagen(10) }), HEUTE) + expect(b.moeglich).toBe(true) + expect(b.vorausgewaehlt).toBe(false) + expect(b.hindernis).toContain('bereits angefragt') + expect(b.tageSeitAnfrage).toBe(10) + }) + + it('wählt nach Ablauf der Sperrfrist wieder vor', () => { + const b = bewerten(vertrag({ rueckkaufswertAngefragtAm: vorTagen(SPERRFRIST_TAGE) }), HEUTE) + expect(b.vorausgewaehlt).toBe(true) + }) + + it('hält die Sperrfrist mit der Wiedervorlage im Gleichschritt', () => { + // Beide 28 Tage: Solange die Wiedervorlage läuft, ist die Antwort + // unterwegs - eine zweite Anfrage davor wäre nur Verwirrung. + expect(SPERRFRIST_TAGE).toBe(RUECKKAUFSWERT_WIEDERVORLAGE_TAGE) + }) +}) + +describe('Sortierung der Kandidaten', () => { + it('stellt Vorausgewählte nach oben, Unmögliche ans Ende', () => { + const bewertet = kandidatenBewerten( + [ + vertrag({ id: 'a', bezeichnung: 'Zulassung', art: 'HAFTPFLICHT' }), + vertrag({ id: 'b', bezeichnung: 'Beitragsfrei' }), + vertrag({ id: 'c', bezeichnung: 'Angefragt', rueckkaufswertAngefragtAm: vorTagen(5) }), + ], + HEUTE, + ) + + expect(bewertet[0]!.vertrag.id).toBe('b') + expect(bewertet[1]!.vertrag.id).toBe('c') + expect(bewertet[2]!.vertrag.id).toBe('a') + }) +}) + +describe('Dublettenschutz', () => { + it('erkennt die Anfrage vom selben Tag', () => { + expect(heuteSchonAngefragt(HEUTE, HEUTE)).toBe(true) + }) + + it('lässt eine bewusste zweite Anfrage nach einer Woche zu', () => { + // Tagesgenau, nicht sperrfristgenau: Der Doppelklick soll abgefangen + // werden, die bewusste Wiederholung (Brief verloren) nicht. + expect(heuteSchonAngefragt(vorTagen(7), HEUTE)).toBe(false) + }) + + it('meldet ohne Anfrage nichts', () => { + expect(heuteSchonAngefragt(null, HEUTE)).toBe(false) + }) +})