Rueckkaufswerte aller Versicherungen in einem Zug anfragen

Vor Vermoegensverzeichnis und Jahresbericht braucht es von jeder Lebens-
und Rentenversicherung den Rueckkaufswert zum Stichtag - jedes Mal
dieselben fuenf bis zehn Briefe. Neu unter Vertraege: Stichtag waehlen,
Liste pruefen, ein Klick. Heraus kommt ein ZIP mit einer Word-Datei je
Gesellschaft plus Begleitblatt; je Vertrag entstehen nebenbei die
PDF-Fassung in der Akte, die Wiedervorlage nach 28 Tagen und der
Anfragevermerk.

Aufbau:
- Auswahllogik als reine Funktionen in vertraege/serienabfrage.ts:
  Vorauswahl, Hindernisse mit Begruendung statt stummem Ausblenden,
  28-Tage-Sperre im Gleichschritt mit der Wiedervorlage.
- Der Lauf in vertraege/serienlauf.ts: je Vertrag das volle Paket aus
  Issue #4, aber in einem Durchgang. Kein Alles-oder-nichts - ein
  gescheiterter Vertrag steht mit Grund im Begleitblatt, die uebrigen
  laufen durch. Eine Transaktion ueber alle waere die falsche Zusage.
- Dublettenschutz tagesgenau: Der Doppelklick erzeugt nichts Doppeltes,
  die bewusste zweite Anfrage nach einer Woche bleibt moeglich.
- Die Anzahl steht vor dem Klick auf dem Knopf.

Gekuendigte Vertraege bleiben anfragbar: Bis zum Wirksamwerden besteht
der Vertrag, und genau dann will der Sozialhilfetraeger den Wert sehen.

Geprueft: zwoelf Einheitstests, drei End-to-End-Tests inklusive der
Nebenwirkungen in der Datenbank und des Dublettenschutzes. 602
Unit-Tests, 87 e2e, null Typfehler.

Refs #6
This commit is contained in:
Florian Wilde
2026-08-23 12:10:32 +02:00
parent 96a9bc4d63
commit 9fe6f10f25
14 changed files with 1345 additions and 250 deletions
+139
View File
@@ -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
}