import { PDFDocument, PDFTextField, PDFCheckBox, PDFDropdown, PDFName } from 'pdf-lib'; import fs from 'fs'; import path from 'path'; import prisma from '../lib/prisma.js'; // ==================== VERFÜGBARE CRM-FELDER ==================== export const CRM_FIELDS = [ // Kunde { path: 'customer.salutation', label: 'Anrede', group: 'Kunde' }, { path: 'customer.firstName', label: 'Vorname', group: 'Kunde' }, { path: 'customer.lastName', label: 'Nachname', group: 'Kunde' }, { path: 'customer.fullName', label: 'Voller Name (Vor- + Nachname)', group: 'Kunde' }, { path: 'customer.customerNumber', label: 'Kundennummer', group: 'Kunde' }, { path: 'customer.email', label: 'E-Mail', group: 'Kunde' }, { path: 'customer.phone', label: 'Telefon', group: 'Kunde' }, { path: 'customer.mobile', label: 'Mobil', group: 'Kunde' }, { path: 'customer.birthDate', label: 'Geburtsdatum', group: 'Kunde' }, { path: 'customer.birthPlace', label: 'Geburtsort', group: 'Kunde' }, { path: 'customer.companyName', label: 'Firma', group: 'Kunde' }, { path: 'customer.type', label: 'Kundentyp (Privat/Firma)', group: 'Kunde' }, { path: 'customer.taxNumber', label: 'Steuernummer', group: 'Kunde' }, // Adresse (Lieferadresse) { path: 'address.street', label: 'Straße', group: 'Adresse' }, { path: 'address.houseNumber', label: 'Hausnummer', group: 'Adresse' }, { path: 'address.streetFull', label: 'Straße + Hausnummer', group: 'Adresse' }, { path: 'address.postalCode', label: 'PLZ', group: 'Adresse' }, { path: 'address.city', label: 'Stadt', group: 'Adresse' }, { path: 'address.postalCodeCity', label: 'PLZ + Stadt', group: 'Adresse' }, { path: 'address.country', label: 'Land', group: 'Adresse' }, { path: 'address.full', label: 'Vollständige Adresse', group: 'Adresse' }, // Eigentümer (der Lieferadresse) { path: 'owner.company', label: 'Firma', group: 'Eigentümer' }, { path: 'owner.firstName', label: 'Vorname', group: 'Eigentümer' }, { path: 'owner.lastName', label: 'Nachname', group: 'Eigentümer' }, { path: 'owner.fullName', label: 'Vorname Nachname', group: 'Eigentümer' }, { path: 'owner.companyFirstNameLastName', label: 'Firma + Vorname + Nachname', group: 'Eigentümer' }, { path: 'owner.companyLastNameFirstName', label: 'Firma + Nachname + Vorname', group: 'Eigentümer' }, { path: 'owner.companyFirstName', label: 'Firma + Vorname', group: 'Eigentümer' }, { path: 'owner.companyLastName', label: 'Firma + Nachname', group: 'Eigentümer' }, { path: 'owner.street', label: 'Straße', group: 'Eigentümer' }, { path: 'owner.houseNumber', label: 'Hausnummer', group: 'Eigentümer' }, { path: 'owner.postalCode', label: 'PLZ', group: 'Eigentümer' }, { path: 'owner.city', label: 'Ort', group: 'Eigentümer' }, { path: 'owner.phone', label: 'Telefon', group: 'Eigentümer' }, { path: 'owner.mobile', label: 'Mobil', group: 'Eigentümer' }, { path: 'owner.email', label: 'E-Mail', group: 'Eigentümer' }, // Rechnungsadresse { path: 'billingAddress.street', label: 'Straße (Rechnung)', group: 'Rechnungsadresse' }, { path: 'billingAddress.houseNumber', label: 'Hausnummer (Rechnung)', group: 'Rechnungsadresse' }, { path: 'billingAddress.streetFull', label: 'Straße + Nr. (Rechnung)', group: 'Rechnungsadresse' }, { path: 'billingAddress.postalCode', label: 'PLZ (Rechnung)', group: 'Rechnungsadresse' }, { path: 'billingAddress.city', label: 'Stadt (Rechnung)', group: 'Rechnungsadresse' }, { path: 'billingAddress.postalCodeCity', label: 'PLZ + Stadt (Rechnung)', group: 'Rechnungsadresse' }, // Bankverbindung { path: 'bankCard.iban', label: 'IBAN', group: 'Bank' }, { path: 'bankCard.bic', label: 'BIC', group: 'Bank' }, { path: 'bankCard.bankName', label: 'Bank', group: 'Bank' }, { path: 'bankCard.accountHolder', label: 'Kontoinhaber', group: 'Bank' }, // Vertrag { path: 'contract.contractNumber', label: 'Vertragsnummer', group: 'Vertrag' }, { path: 'contract.type', label: 'Vertragstyp', group: 'Vertrag' }, { path: 'contract.status', label: 'Status', group: 'Vertrag' }, { path: 'contract.startDate', label: 'Vertragsbeginn', group: 'Vertrag' }, { path: 'contract.endDate', label: 'Vertragsende', group: 'Vertrag' }, { path: 'contract.providerName', label: 'Anbieter', group: 'Vertrag' }, { path: 'contract.tariffName', label: 'Tarif', group: 'Vertrag' }, { path: 'contract.customerNumberAtProvider', label: 'Kundennr. beim Anbieter', group: 'Vertrag' }, { path: 'contract.cancellationDate', label: 'Kündigungsdatum', group: 'Vertrag' }, { path: 'contract.commission', label: 'Provision', group: 'Vertrag' }, { path: 'contract.platformName', label: 'Vertriebsplattform', group: 'Vertrag' }, { path: 'contract.notes', label: 'Notizen', group: 'Vertrag' }, // Altanbieter { path: 'contract.previousProviderName', label: 'Altanbieter', group: 'Altanbieter' }, { path: 'contract.previousCustomerNumber', label: 'Kundennr. Altanbieter', group: 'Altanbieter' }, // Ausweis { path: 'identityDocument.type', label: 'Dokumenttyp', group: 'Ausweis' }, { path: 'identityDocument.documentNumber', label: 'Ausweisnummer', group: 'Ausweis' }, { path: 'identityDocument.issuingAuthority', label: 'Ausstellungsbehörde', group: 'Ausweis' }, { path: 'identityDocument.issueDate', label: 'Ausstellungsdatum', group: 'Ausweis' }, { path: 'identityDocument.expiryDate', label: 'Gültig bis', group: 'Ausweis' }, // Zähler (Energie) { path: 'meter.meterNumber', label: 'Zählernummer', group: 'Energie' }, { path: 'meter.type', label: 'Zählertyp (Strom/Gas)', group: 'Energie' }, { path: 'energyDetails.maloId', label: 'MaLo-ID', group: 'Energie' }, { path: 'energyDetails.annualConsumption', label: 'Jahresverbrauch', group: 'Energie' }, { path: 'energyDetails.basePrice', label: 'Grundpreis (€/Monat)', group: 'Energie' }, { path: 'energyDetails.unitPrice', label: 'Arbeitspreis (€/kWh)', group: 'Energie' }, { path: 'energyDetails.unitPriceNt', label: 'NT-Arbeitspreis (€/kWh)', group: 'Energie' }, { path: 'energyDetails.instantBonus', label: 'Sofort-Bonus (€)', group: 'Energie' }, { path: 'energyDetails.newCustomerBonus', label: 'Neukunden-Bonus (€)', group: 'Energie' }, { path: 'energyDetails.totalBonus', label: 'Gesamtbonus (€)', group: 'Energie' }, // Internet/DSL/Glasfaser/Kabel { path: 'internetDetails.downloadSpeed', label: 'Download-Speed (Mbit/s)', group: 'Internet' }, { path: 'internetDetails.uploadSpeed', label: 'Upload-Speed (Mbit/s)', group: 'Internet' }, { path: 'internetDetails.routerModel', label: 'Router-Modell', group: 'Internet' }, { path: 'internetDetails.routerSerialNumber', label: 'Router-Seriennummer', group: 'Internet' }, { path: 'internetDetails.installationDate', label: 'Installationsdatum', group: 'Internet' }, { path: 'internetDetails.internetUsername', label: 'Internet-Benutzername', group: 'Internet' }, { path: 'internetDetails.homeId', label: 'Home-ID', group: 'Internet' }, { path: 'internetDetails.activationCode', label: 'Aktivierungscode', group: 'Internet' }, { path: 'internetDetails.propertyType', label: 'Objekttyp', group: 'Internet' }, { path: 'internetDetails.propertyLocation', label: 'Lage', group: 'Internet' }, { path: 'internetDetails.connectionLocation', label: 'Anschluss-Lage', group: 'Internet' }, // Mobilfunk { path: 'mobileDetails.phoneNumber', label: 'Mobilfunknummer', group: 'Mobilfunk' }, { path: 'mobileDetails.simCardNumber', label: 'SIM-Kartennummer', group: 'Mobilfunk' }, { path: 'mobileDetails.dataVolume', label: 'Datenvolumen', group: 'Mobilfunk' }, { path: 'mobileDetails.deviceName', label: 'Gerätename', group: 'Mobilfunk' }, { path: 'mobileDetails.deviceImei', label: 'IMEI', group: 'Mobilfunk' }, // Rufnummern werden dynamisch generiert über getCrmFieldsForTemplate() // Stressfrei-Wechseln E-Mail { path: 'stressfreiEmail', label: 'Stressfrei-E-Mail (Auswahl beim Generieren)', group: 'Stressfrei-Wechseln' }, { path: 'stressfreiEmail.password', label: 'Stressfrei-E-Mail Passwort', group: 'Stressfrei-Wechseln' }, // Sonstiges (individuelle Werte) { path: 'today', label: 'Heutiges Datum', group: 'Sonstiges' }, { path: 'static:true', label: 'Checkbox: Angehakt', group: 'Sonstiges' }, { path: 'static:false', label: 'Checkbox: Nicht angehakt', group: 'Sonstiges' }, { path: 'static:X', label: 'Text: X (Kreuz)', group: 'Sonstiges' }, { path: 'static:Ja', label: 'Text: Ja', group: 'Sonstiges' }, { path: 'static:Nein', label: 'Text: Nein', group: 'Sonstiges' }, { path: 'static:SEPA', label: 'Text: SEPA-Lastschrift', group: 'Sonstiges' }, { path: 'static:Privatkunde', label: 'Text: Privatkunde', group: 'Sonstiges' }, { path: 'static:Geschäftskunde', label: 'Text: Geschäftskunde', group: 'Sonstiges' }, { path: 'static:Herr', label: 'Text: Herr', group: 'Sonstiges' }, { path: 'static:Frau', label: 'Text: Frau', group: 'Sonstiges' }, { path: 'static:Anbieterwechsel', label: 'Text: Anbieterwechsel', group: 'Sonstiges' }, { path: 'static:Neuanschluss', label: 'Text: Neuanschluss', group: 'Sonstiges' }, { path: 'static:Umzug', label: 'Text: Umzug', group: 'Sonstiges' }, // Freitextfelder (leer lassen zum manuellen Ausfüllen) { path: 'manual:1', label: 'Freitext 1 (manuell beim Generieren)', group: 'Freitext' }, { path: 'manual:2', label: 'Freitext 2 (manuell beim Generieren)', group: 'Freitext' }, { path: 'manual:3', label: 'Freitext 3 (manuell beim Generieren)', group: 'Freitext' }, { path: 'manual:4', label: 'Freitext 4 (manuell beim Generieren)', group: 'Freitext' }, { path: 'manual:5', label: 'Freitext 5 (manuell beim Generieren)', group: 'Freitext' }, ]; /** * Generiert die vollständige CRM-Feldliste inkl. dynamischer Rufnummern */ export function getCrmFieldsForTemplate(maxPhoneFields: number = 8) { const phoneFields = []; for (let i = 0; i < maxPhoneFields; i++) { const n = i + 1; phoneFields.push( { path: `phoneNumbers[${i}]`, label: `Vorwahl+Rufnummer ${n}`, group: 'Rufnummern' }, { path: `phoneAreaCode[${i}]`, label: `Vorwahl ${n}`, group: 'Rufnummern' }, { path: `phoneLocal[${i}]`, label: `Rufnummer ${n} (ohne Vorwahl)`, group: 'Rufnummern' }, ); } return [...CRM_FIELDS, ...phoneFields]; } // ==================== PDF-FELDER AUSLESEN ==================== export async function extractPdfFields(pdfPath: string): Promise<{ fields: { name: string; type: string; page: number; y: number }[]; totalPages: number }> { const pdfBytes = fs.readFileSync(path.join(process.cwd(), pdfPath)); const pdfDoc = await PDFDocument.load(pdfBytes); const form = pdfDoc.getForm(); const fields = form.getFields(); const pages = pdfDoc.getPages(); const totalPages = pages.length; // Page-Refs sammeln: Jede Seite hat eine interne Referenz const pageRefs: string[] = pages.map(p => String((p as any).ref)); const result: { name: string; type: string; page: number; y: number }[] = []; for (const field of fields) { const widgets = field.acroField.getWidgets(); let pageIndex = 0; let yPos = 0; if (widgets.length > 0) { const widget = widgets[0]; const rect = widget.getRectangle(); // Seitenzuordnung über /P Eintrag im Widget try { const pRef = widget.dict.get(PDFName.of('P')); if (pRef) { const pRefStr = String(pRef); const idx = pageRefs.indexOf(pRefStr); if (idx >= 0) pageIndex = idx; } } catch { /* ignore */ } const pageHeight = pages[pageIndex]?.getHeight() || 842; yPos = Math.round(pageHeight - rect.y); } result.push({ name: field.getName(), type: field.constructor.name.replace('PDF', '').replace('Field', ''), page: pageIndex, y: yPos, }); } // Sortieren: erst nach Seite, dann nach Y-Position (oben nach unten) result.sort((a, b) => a.page !== b.page ? a.page - b.page : a.y - b.y); return { fields: result, totalPages }; } /** * Generiert eine annotierte PDF-Vorschau: Alle Formularfelder werden mit ihrem * Feldnamen als Text befüllt, damit man sieht wo welches Feld ist. */ export async function generateAnnotatedPreview(pdfPath: string): Promise { const pdfBytes = fs.readFileSync(path.join(process.cwd(), pdfPath)); const pdfDoc = await PDFDocument.load(pdfBytes); const form = pdfDoc.getForm(); const fields = form.getFields(); for (const field of fields) { const name = field.getName(); try { if (field instanceof PDFTextField) { field.setText(`[${name}]`); } else if (field instanceof PDFCheckBox) { // Checkboxen angehakt lassen damit man sie sieht field.check(); } else if (field instanceof PDFDropdown) { // Dropdown-Name als Text nicht möglich, ignorieren } } catch { /* ignore */ } } // Alle Felder flatten damit sie als Text sichtbar sind form.flatten(); return Buffer.from(await pdfDoc.save()); } // ==================== TEMPLATE CRUD ==================== export async function getAllTemplates() { return prisma.pdfTemplate.findMany({ orderBy: { name: 'asc' }, }); } export async function getTemplateById(id: number) { return prisma.pdfTemplate.findUnique({ where: { id } }); } export async function createTemplate(data: { name: string; description?: string; providerName?: string; templatePath: string; originalName: string; fieldMapping?: string; phoneFieldPrefix?: string; maxPhoneFields?: number; }) { return prisma.pdfTemplate.create({ data: { name: data.name, description: data.description, providerName: data.providerName, templatePath: data.templatePath, originalName: data.originalName, fieldMapping: data.fieldMapping || '{}', phoneFieldPrefix: data.phoneFieldPrefix, maxPhoneFields: data.maxPhoneFields ?? 8, }, }); } export async function updateTemplate(id: number, data: { name?: string; description?: string; providerName?: string; fieldMapping?: string; phoneFieldPrefix?: string; maxPhoneFields?: number; isActive?: boolean; }) { return prisma.pdfTemplate.update({ where: { id }, data, }); } export async function deleteTemplate(id: number) { const template = await prisma.pdfTemplate.findUnique({ where: { id } }); if (template?.templatePath) { const filePath = path.join(process.cwd(), template.templatePath); if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } return prisma.pdfTemplate.delete({ where: { id } }); } // ==================== PDF GENERIEREN ==================== /** * Generiert ein ausgefülltes PDF aus einem Template + Kundendaten */ /** * Ermittelt welche Felder beim Generieren manuell eingegeben werden müssen */ export async function getRequiredInputs(templateId: number, contractId: number): Promise<{ needsStressfreiEmail: boolean; stressfreiEmails: { id: number; email: string }[]; manualFields: { key: string; pdfFieldName: string }[]; }> { const template = await prisma.pdfTemplate.findUnique({ where: { id: templateId } }); if (!template) throw new Error('Vorlage nicht gefunden'); const mapping: Record = JSON.parse(template.fieldMapping || '{}'); const needsStressfreiEmail = Object.values(mapping).some(v => v.startsWith('stressfreiEmail')); const manualFields = Object.entries(mapping) .filter(([_, v]) => v.startsWith('manual:')) .map(([pdfFieldName, v]) => ({ key: v, pdfFieldName })); let stressfreiEmails: { id: number; email: string }[] = []; if (needsStressfreiEmail) { const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { customerId: true }, }); if (contract) { const emails = await prisma.stressfreiEmail.findMany({ where: { customerId: contract.customerId, isActive: true }, select: { id: true, email: true }, }); stressfreiEmails = emails; } } return { needsStressfreiEmail, stressfreiEmails, manualFields }; } export async function generateFilledPdf( templateId: number, contractId: number, extras?: { stressfreiEmailId?: number; manualValues?: Record; } ): Promise { const template = await prisma.pdfTemplate.findUnique({ where: { id: templateId } }); if (!template) throw new Error('Vorlage nicht gefunden'); // Vertrag mit allen Relationen laden const contract = await prisma.contract.findUnique({ where: { id: contractId }, include: { customer: true, address: true, billingAddress: true, bankCard: true, identityDocument: true, provider: true, tariff: true, salesPlatform: true, energyDetails: { include: { meter: true } }, internetDetails: { include: { phoneNumbers: true } }, mobileDetails: { include: { simCards: true } }, stressfreiEmail: true, }, }); if (!contract) throw new Error('Vertrag nicht gefunden'); // Fallback: Wenn keine Bankverbindung am Vertrag, erste aktive des Kunden nehmen let bankCard = contract.bankCard; if (!bankCard && contract.customer) { bankCard = await prisma.bankCard.findFirst({ where: { customerId: contract.customer.id, isActive: true }, orderBy: { createdAt: 'desc' }, }); } // Daten-Kontext aufbauen const formatDate = (d: Date | null | undefined) => { if (!d) return ''; return new Date(d).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); }; const typeLabels: Record = { ELECTRICITY: 'Strom', GAS: 'Gas', DSL: 'DSL', CABLE: 'Kabelinternet', FIBER: 'Glasfaser', MOBILE: 'Mobilfunk', TV: 'TV', CAR_INSURANCE: 'KFZ-Versicherung' }; const docTypeLabels: Record = { ID_CARD: 'Personalausweis', PASSPORT: 'Reisepass', DRIVERS_LICENSE: 'Führerschein', OTHER: 'Sonstiges' }; const addr = contract.address; // Wenn keine separate Rechnungsadresse hinterlegt ist, fällt der Wert auf // die Lieferadresse zurück – konsistent mit der Kundenakte-Logik // (Contract.billingAddressId NULL = "Wie Lieferadresse"). Damit füllen // Auftragsformulare die Rechnungs-Felder nicht mehr leer aus, wenn der // Anbieter eine identische Adresse erwartet. const bAddr = contract.billingAddress ?? contract.address; const dataContext: Record = { // Kunde 'customer.salutation': contract.customer?.salutation || '', 'customer.firstName': contract.customer?.firstName || '', 'customer.lastName': contract.customer?.lastName || '', 'customer.fullName': `${contract.customer?.firstName || ''} ${contract.customer?.lastName || ''}`.trim(), 'customer.customerNumber': contract.customer?.customerNumber || '', 'customer.email': contract.customer?.email || '', 'customer.phone': contract.customer?.phone || '', 'customer.mobile': contract.customer?.mobile || '', 'customer.birthDate': formatDate(contract.customer?.birthDate), 'customer.birthPlace': contract.customer?.birthPlace || '', 'customer.companyName': contract.customer?.companyName || '', 'customer.type': contract.customer?.type === 'BUSINESS' ? 'Geschäftskunde' : 'Privatkunde', 'customer.taxNumber': contract.customer?.taxNumber || '', // Adresse 'address.street': addr?.street || '', 'address.houseNumber': addr?.houseNumber || '', 'address.streetFull': addr ? `${addr.street} ${addr.houseNumber}` : '', 'address.postalCode': addr?.postalCode || '', 'address.city': addr?.city || '', 'address.postalCodeCity': addr ? `${addr.postalCode} ${addr.city}` : '', 'address.country': addr?.country || '', 'address.full': addr ? `${addr.street} ${addr.houseNumber}, ${addr.postalCode} ${addr.city}` : '', // Eigentümer (aus Lieferadresse, Fallback: Kundendaten) 'owner.company': addr?.ownerCompany || contract.customer?.companyName || '', 'owner.firstName': addr?.ownerFirstName || contract.customer?.firstName || '', 'owner.lastName': addr?.ownerLastName || contract.customer?.lastName || '', 'owner.fullName': addr?.ownerFirstName ? `${addr.ownerFirstName} ${addr.ownerLastName || ''}`.trim() : `${contract.customer?.firstName || ''} ${contract.customer?.lastName || ''}`.trim(), 'owner.companyFirstNameLastName': [addr?.ownerCompany || contract.customer?.companyName, addr?.ownerFirstName || contract.customer?.firstName, addr?.ownerLastName || contract.customer?.lastName].filter(Boolean).join(' '), 'owner.companyLastNameFirstName': [addr?.ownerCompany || contract.customer?.companyName, addr?.ownerLastName || contract.customer?.lastName, addr?.ownerFirstName || contract.customer?.firstName].filter(Boolean).join(' '), 'owner.companyFirstName': [addr?.ownerCompany || contract.customer?.companyName, addr?.ownerFirstName || contract.customer?.firstName].filter(Boolean).join(' '), 'owner.companyLastName': [addr?.ownerCompany || contract.customer?.companyName, addr?.ownerLastName || contract.customer?.lastName].filter(Boolean).join(' '), 'owner.street': addr?.ownerStreet || addr?.street || '', 'owner.houseNumber': addr?.ownerHouseNumber || addr?.houseNumber || '', 'owner.postalCode': addr?.ownerPostalCode || addr?.postalCode || '', 'owner.city': addr?.ownerCity || addr?.city || '', 'owner.phone': addr?.ownerPhone || contract.customer?.phone || '', 'owner.mobile': addr?.ownerMobile || contract.customer?.mobile || '', 'owner.email': addr?.ownerEmail || contract.customer?.email || '', // Rechnungsadresse 'billingAddress.street': bAddr?.street || '', 'billingAddress.houseNumber': bAddr?.houseNumber || '', 'billingAddress.streetFull': bAddr ? `${bAddr.street} ${bAddr.houseNumber}` : '', 'billingAddress.postalCode': bAddr?.postalCode || '', 'billingAddress.city': bAddr?.city || '', 'billingAddress.postalCodeCity': bAddr ? `${bAddr.postalCode} ${bAddr.city}` : '', // Bank 'bankCard.iban': bankCard?.iban || '', 'bankCard.bic': bankCard?.bic || '', 'bankCard.bankName': bankCard?.bankName || '', 'bankCard.accountHolder': bankCard?.accountHolder || '', // Vertrag 'contract.contractNumber': contract.contractNumber || '', 'contract.type': typeLabels[contract.type] || contract.type, 'contract.status': contract.status || '', 'contract.startDate': formatDate(contract.startDate), 'contract.endDate': formatDate(contract.endDate), 'contract.providerName': contract.providerName || contract.provider?.name || '', 'contract.tariffName': contract.tariffName || contract.tariff?.name || '', 'contract.customerNumberAtProvider': contract.customerNumberAtProvider || '', 'contract.cancellationDate': formatDate((contract as any).cancellationDate), 'contract.commission': contract.commission?.toString() || '', 'contract.platformName': contract.salesPlatform?.name || '', 'contract.notes': contract.notes || '', 'contract.previousProviderName': contract.energyDetails?.previousProviderName || '', 'contract.previousCustomerNumber': contract.energyDetails?.previousCustomerNumber || '', // Ausweis 'identityDocument.type': docTypeLabels[contract.identityDocument?.type || ''] || '', 'identityDocument.documentNumber': contract.identityDocument?.documentNumber || '', 'identityDocument.issuingAuthority': contract.identityDocument?.issuingAuthority || '', 'identityDocument.issueDate': formatDate(contract.identityDocument?.issueDate), 'identityDocument.expiryDate': formatDate(contract.identityDocument?.expiryDate), // Energie 'meter.meterNumber': contract.energyDetails?.meter?.meterNumber || '', 'meter.type': contract.energyDetails?.meter?.type === 'ELECTRICITY' ? 'Strom' : contract.energyDetails?.meter?.type === 'GAS' ? 'Gas' : '', 'energyDetails.maloId': contract.energyDetails?.maloId || '', 'energyDetails.annualConsumption': contract.energyDetails?.annualConsumption?.toString() || '', 'energyDetails.basePrice': contract.energyDetails?.basePrice?.toString() || '', 'energyDetails.unitPrice': contract.energyDetails?.unitPrice?.toString() || '', 'energyDetails.unitPriceNt': contract.energyDetails?.unitPriceNt?.toString() || '', 'energyDetails.instantBonus': contract.energyDetails?.instantBonus?.toString() || '', 'energyDetails.newCustomerBonus': contract.energyDetails?.newCustomerBonus?.toString() || '', 'energyDetails.totalBonus': ( ((contract.energyDetails?.instantBonus ?? 0) + (contract.energyDetails?.newCustomerBonus ?? 0)) || '' ).toString(), // Internet 'internetDetails.downloadSpeed': contract.internetDetails?.downloadSpeed?.toString() || '', 'internetDetails.uploadSpeed': contract.internetDetails?.uploadSpeed?.toString() || '', 'internetDetails.routerModel': contract.internetDetails?.routerModel || '', 'internetDetails.routerSerialNumber': contract.internetDetails?.routerSerialNumber || '', 'internetDetails.installationDate': formatDate(contract.internetDetails?.installationDate), 'internetDetails.internetUsername': contract.internetDetails?.internetUsername || '', 'internetDetails.homeId': contract.internetDetails?.homeId || '', 'internetDetails.activationCode': contract.internetDetails?.activationCode || '', 'internetDetails.propertyType': contract.internetDetails?.propertyType || '', 'internetDetails.propertyLocation': contract.internetDetails?.propertyLocation || '', 'internetDetails.connectionLocation': contract.internetDetails?.connectionLocation || '', // Mobilfunk 'mobileDetails.phoneNumber': contract.mobileDetails?.phoneNumber || '', 'mobileDetails.simCardNumber': contract.mobileDetails?.simCardNumber || '', 'mobileDetails.dataVolume': contract.mobileDetails?.dataVolume?.toString() || '', 'mobileDetails.deviceName': contract.mobileDetails?.deviceImei || '', 'mobileDetails.deviceImei': contract.mobileDetails?.deviceImei || '', // Sonstiges 'today': new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }), // Statische Werte (für Checkboxen/Radio/individuelle Felder) 'static:true': 'true', 'static:false': 'false', 'static:X': 'X', 'static:Ja': 'Ja', 'static:Nein': 'Nein', 'static:SEPA': 'SEPA-Lastschrift', 'static:Privatkunde': 'Privatkunde', 'static:Geschäftskunde': 'Geschäftskunde', 'static:Herr': 'Herr', 'static:Frau': 'Frau', 'static:Anbieterwechsel': 'Anbieterwechsel', 'static:Neuanschluss': 'Neuanschluss', 'static:Umzug': 'Umzug', }; // Stressfrei-E-Mail if (extras?.stressfreiEmailId) { const sfEmail = await prisma.stressfreiEmail.findUnique({ where: { id: extras.stressfreiEmailId }, select: { email: true, emailPasswordEncrypted: true }, }); if (sfEmail) { dataContext['stressfreiEmail'] = sfEmail.email; if (sfEmail.emailPasswordEncrypted) { try { const { decrypt } = await import('../utils/encryption.js'); dataContext['stressfreiEmail.password'] = decrypt(sfEmail.emailPasswordEncrypted); } catch { /* ignore */ } } } } else if (contract.stressfreiEmail) { dataContext['stressfreiEmail'] = contract.stressfreiEmail.email; } // Freitext-Felder (manuell beim Generieren eingegeben) if (extras?.manualValues) { for (const [key, value] of Object.entries(extras.manualValues)) { dataContext[key] = value; } } // Rufnummern + Vorwahl-Extraktion const phoneNumbers = contract.internetDetails?.phoneNumbers || []; /** * Extrahiert Vorwahl und Rufnummer aus einer deutschen Festnetznummer. * z.B. "04941 123456" → { areaCode: "04941", local: "123456" } * z.B. "04941/123456" → { areaCode: "04941", local: "123456" } * z.B. "04941-123456" → { areaCode: "04941", local: "123456" } */ const splitPhoneNumber = (phone: string): { areaCode: string; local: string } => { if (!phone) return { areaCode: '', local: '' }; const cleaned = phone.replace(/[()]/g, '').trim(); // Trennzeichen: Leerzeichen, /, - const match = cleaned.match(/^(\d{2,5})[\/\s\-](.+)$/); if (match) return { areaCode: match[1], local: match[2].replace(/[\s\-\/]/g, '') }; // Kein Trennzeichen: Versuche deutsche Vorwahl-Muster (2-5 Stellen nach 0) const numMatch = cleaned.match(/^(0\d{1,4})(\d{3,})$/); if (numMatch) return { areaCode: numMatch[1], local: numMatch[2] }; return { areaCode: '', local: cleaned }; }; const maxFields = template.maxPhoneFields || 8; for (let i = 0; i < Math.max(maxFields, phoneNumbers.length); i++) { const entry = phoneNumbers[i]; const fullNumber = entry?.phoneNumber || ''; // Bevorzugt den explizit gepflegten areaCode aus der DB (verlässlich), // fällt sonst auf die Heuristik zurück (Altbestand ohne separates // Vorwahl-Feld). `phoneLocal` analog: aus phoneNumber abgeleitet, // wenn areaCode da → den Vorwahl-Prefix abschneiden, sonst Heuristik. let areaCode = ''; let local = ''; if (entry?.areaCode) { areaCode = entry.areaCode; const split = splitPhoneNumber(fullNumber); // Wenn der heuristische areaCode mit dem DB-Wert übereinstimmt, // ist der heuristische local-Anteil korrekt – sonst pragmatisch: // alles nach dem areaCode-Prefix bis zum Ende if (split.areaCode === entry.areaCode) { local = split.local; } else { const stripped = fullNumber.replace(new RegExp('^' + entry.areaCode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\s\\-/]*'), '').trim(); local = stripped || split.local; } } else { const split = splitPhoneNumber(fullNumber); areaCode = split.areaCode; local = split.local; } dataContext[`phoneNumbers[${i}]`] = fullNumber; dataContext[`phoneAreaCode[${i}]`] = areaCode; dataContext[`phoneLocal[${i}]`] = local; } // PDF laden und befüllen const pdfBytes = fs.readFileSync(path.join(process.cwd(), template.templatePath)); const pdfDoc = await PDFDocument.load(pdfBytes); const form = pdfDoc.getForm(); // Feld-Mapping anwenden const mapping: Record = JSON.parse(template.fieldMapping || '{}'); for (const [pdfFieldName, crmFieldPath] of Object.entries(mapping)) { const value = dataContext[crmFieldPath] || ''; try { const field = form.getField(pdfFieldName); if (field instanceof PDFTextField) { field.setText(value); } else if (field instanceof PDFCheckBox) { if (value === 'true' || value === 'Ja' || value === '1') { field.check(); } } else if (field instanceof PDFDropdown) { if (value) field.select(value); } } catch { // Feld nicht gefunden - überspringen } } // Rufnummern-Overflow: Extra-Seite wenn nötig const maxPhoneFields = template.maxPhoneFields || 8; const overflowNumbers = phoneNumbers.slice(maxPhoneFields); if (overflowNumbers.length > 0) { // Neue Seite für zusätzliche Rufnummern const page = pdfDoc.addPage(); const { width, height } = page.getSize(); const font = await pdfDoc.embedFont('Helvetica' as any); const boldFont = await pdfDoc.embedFont('Helvetica-Bold' as any); let y = height - 50; page.drawText('Weitere Rufnummern zur Portierung', { x: 50, y, size: 14, font: boldFont }); y -= 25; page.drawText(`Kunde: ${contract.customer?.firstName} ${contract.customer?.lastName} (${contract.customer?.customerNumber})`, { x: 50, y, size: 10, font }); y -= 15; page.drawText(`Vertrag: ${contract.contractNumber}`, { x: 50, y, size: 10, font }); y -= 30; for (let i = 0; i < overflowNumbers.length; i++) { page.drawText(`${maxPhoneFields + i + 1}. ${overflowNumbers[i].phoneNumber}`, { x: 50, y, size: 11, font }); y -= 20; if (y < 50) { // Nächste Seite const newPage = pdfDoc.addPage(); y = newPage.getSize().height - 50; } } } // Nur zugeordnete Felder flatten (nicht editierbar machen) // Nicht zugeordnete Felder bleiben editierbar zum manuellen Ausfüllen for (const pdfFieldName of Object.keys(mapping)) { try { const field = form.getField(pdfFieldName); if (field instanceof PDFTextField) { field.enableReadOnly(); } } catch { /* Feld nicht gefunden */ } } return Buffer.from(await pdfDoc.save()); }