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:
@@ -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<SerienErgebnis> {
|
||||
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<Buffer> {
|
||||
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()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user