|
|
|
@@ -0,0 +1,633 @@
|
|
|
|
|
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.bonus', label: 'Bonus (€)', 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<Buffer> {
|
|
|
|
|
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<string, string> = 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<string, string>;
|
|
|
|
|
}
|
|
|
|
|
): Promise<Buffer> {
|
|
|
|
|
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<string, string> = { ELECTRICITY: 'Strom', GAS: 'Gas', DSL: 'DSL', CABLE: 'Kabelinternet', FIBER: 'Glasfaser', MOBILE: 'Mobilfunk', TV: 'TV', CAR_INSURANCE: 'KFZ-Versicherung' };
|
|
|
|
|
const docTypeLabels: Record<string, string> = { ID_CARD: 'Personalausweis', PASSPORT: 'Reisepass', DRIVERS_LICENSE: 'Führerschein', OTHER: 'Sonstiges' };
|
|
|
|
|
|
|
|
|
|
const addr = contract.address;
|
|
|
|
|
const bAddr = contract.billingAddress;
|
|
|
|
|
|
|
|
|
|
const dataContext: Record<string, string> = {
|
|
|
|
|
// 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.bonus': contract.energyDetails?.bonus?.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 fullNumber = phoneNumbers[i]?.phoneNumber || '';
|
|
|
|
|
const { areaCode, local } = splitPhoneNumber(fullNumber);
|
|
|
|
|
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<string, string> = 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());
|
|
|
|
|
}
|