Files
opencrm/backend/src/services/pdfTemplate.service.ts
T
duffyduck ffb0d81b6a PDF-Templates: billingAddress fällt auf Lieferadresse zurück
Wie in der Kundenakte: wenn Contract.billingAddressId NULL ist
(= "Wie Lieferadresse"), liefern die billingAddress.*-Felder im
Auftragsformular jetzt die Werte der Lieferadresse statt leer
zu bleiben.

Konkret betrifft das die 6 Template-Variablen:
- billingAddress.street, houseNumber, streetFull
- billingAddress.postalCode, city, postalCodeCity

Anbieter, die ein vollständig befülltes "Rechnungsadresse"-Block
im PDF erwarten, bekommen es jetzt automatisch – kein manueller
Doppel-Eintrag der Adresse beim Kunden mehr nötig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 15:06:57 +02:00

668 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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;
// 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<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.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<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());
}