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, type Absender } 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 } & Absender }): 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, }) // 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() }) }