PDF-Auftragsvorlagen-System, Objekttyp/Lage-Felder, Eigentümer-Fallback bei Bankverbindung

- PDF-Template-Editor in Einstellungen: Vorlagen hochladen, Formularfelder automatisch auslesen, CRM-Felder zuordnen
- PDF-Vorschau mit annotierten Feldnamen, seitenweise Sortierung der Felder
- Auftrag generieren aus Vertragsdaten (Button im Vertrags-Detail)
- Dynamische Rufnummern-Felder mit Vorwahl-Extraktion und konfigurierbarer Maximalanzahl
- Nicht zugeordnete Felder bleiben editierbar im generierten PDF
- Eigentümer-Felder mit Namens-Kombinationen (Firma+Name etc.) und Fallback auf Kundendaten
- Stressfrei-E-Mail als Feld-Option im Template-Editor
- Objekttyp, Lage und Lage des Anschlusses als neue Felder bei Festnetz-Verträgen (DSL, Glasfaser, Kabel)
- Bankverbindung-Fallback: wenn keine am Vertrag verknüpft, wird automatisch die neueste aktive des Kunden genommen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 19:16:47 +02:00
parent 5e9e553882
commit 29eceef26b
16 changed files with 1881 additions and 21 deletions
@@ -202,6 +202,11 @@ export async function updateAddress(req: Request, res: Response): Promise<void>
const fieldLabels: Record<string, string> = {
street: 'Straße', houseNumber: 'Hausnummer', postalCode: 'PLZ',
city: 'Stadt', country: 'Land', type: 'Typ', isDefault: 'Standard',
ownerCompany: 'Eigentümer Firma', ownerFirstName: 'Eigentümer Vorname',
ownerLastName: 'Eigentümer Nachname', ownerStreet: 'Eigentümer Straße',
ownerHouseNumber: 'Eigentümer Hausnr.', ownerPostalCode: 'Eigentümer PLZ',
ownerCity: 'Eigentümer Ort', ownerPhone: 'Eigentümer Telefon',
ownerMobile: 'Eigentümer Mobil', ownerEmail: 'Eigentümer E-Mail',
};
for (const [key, newVal] of Object.entries(data)) {
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue;
@@ -0,0 +1,200 @@
import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import * as pdfTemplateService from '../services/pdfTemplate.service.js';
import { logChange } from '../services/audit.service.js';
export async function getTemplates(req: AuthRequest, res: Response) {
try {
const templates = await pdfTemplateService.getAllTemplates();
res.json({ success: true, data: templates });
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}
export async function getTemplate(req: AuthRequest, res: Response) {
try {
const template = await pdfTemplateService.getTemplateById(parseInt(req.params.id));
if (!template) return res.status(404).json({ success: false, error: 'Vorlage nicht gefunden' });
res.json({ success: true, data: template });
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden' });
}
}
export async function createTemplate(req: AuthRequest, res: Response) {
try {
if (!req.file) return res.status(400).json({ success: false, error: 'PDF-Datei erforderlich' });
const { name, description, providerName, phoneFieldPrefix, maxPhoneFields } = req.body;
const templatePath = `/uploads/pdf-templates/${req.file.filename}`;
// PDF-Felder auslesen
let pdfFields: { name: string; type: string; page: number; y: number }[] = [];
try {
const extracted = await pdfTemplateService.extractPdfFields(templatePath);
pdfFields = extracted.fields;
} catch {
// PDF hat keine Formularfelder - OK, kann trotzdem gespeichert werden
}
const template = await pdfTemplateService.createTemplate({
name,
description,
providerName,
templatePath,
originalName: req.file.originalname,
phoneFieldPrefix,
maxPhoneFields: maxPhoneFields ? parseInt(maxPhoneFields) : undefined,
});
await logChange({
req, action: 'CREATE', resourceType: 'PdfTemplate',
resourceId: template.id.toString(),
label: `Auftragsvorlage "${name}" angelegt`,
});
res.status(201).json({ success: true, data: { ...template, pdfFields } });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Erstellen',
});
}
}
export async function updateTemplate(req: AuthRequest, res: Response) {
try {
const id = parseInt(req.params.id);
const { name, description, providerName, fieldMapping, phoneFieldPrefix, maxPhoneFields, isActive } = req.body;
const template = await pdfTemplateService.updateTemplate(id, {
name,
description,
providerName,
fieldMapping: fieldMapping ? JSON.stringify(fieldMapping) : undefined,
phoneFieldPrefix,
maxPhoneFields: maxPhoneFields !== undefined ? parseInt(maxPhoneFields) : undefined,
isActive,
});
await logChange({
req, action: 'UPDATE', resourceType: 'PdfTemplate',
resourceId: id.toString(),
label: `Auftragsvorlage "${template.name}" aktualisiert`,
});
res.json({ success: true, data: template });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren',
});
}
}
export async function deleteTemplate(req: AuthRequest, res: Response) {
try {
const id = parseInt(req.params.id);
const template = await pdfTemplateService.getTemplateById(id);
await pdfTemplateService.deleteTemplate(id);
await logChange({
req, action: 'DELETE', resourceType: 'PdfTemplate',
resourceId: id.toString(),
label: `Auftragsvorlage "${template?.name}" gelöscht`,
});
res.json({ success: true, message: 'Vorlage gelöscht' });
} catch (error) {
res.status(400).json({ success: false, error: 'Fehler beim Löschen' });
}
}
export async function extractFields(req: AuthRequest, res: Response) {
try {
const id = parseInt(req.params.id);
const template = await pdfTemplateService.getTemplateById(id);
if (!template) return res.status(404).json({ success: false, error: 'Vorlage nicht gefunden' });
const result = await pdfTemplateService.extractPdfFields(template.templatePath);
res.json({ success: true, data: result.fields, totalPages: result.totalPages });
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Auslesen der PDF-Felder',
});
}
}
export async function getAnnotatedPreview(req: AuthRequest, res: Response) {
try {
const id = parseInt(req.params.id);
const template = await pdfTemplateService.getTemplateById(id);
if (!template) return res.status(404).json({ success: false, error: 'Vorlage nicht gefunden' });
const pdfBuffer = await pdfTemplateService.generateAnnotatedPreview(template.templatePath);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'inline; filename="preview.pdf"');
res.send(pdfBuffer);
} catch (error) {
res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler' });
}
}
export async function getCrmFields(req: AuthRequest, res: Response) {
const maxPhoneFields = req.query.maxPhoneFields ? parseInt(req.query.maxPhoneFields as string) : 8;
res.json({ success: true, data: pdfTemplateService.getCrmFieldsForTemplate(maxPhoneFields) });
}
export async function getRequiredInputs(req: AuthRequest, res: Response) {
try {
const templateId = parseInt(req.params.id);
const contractId = parseInt(req.params.contractId);
const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId);
res.json({ success: true, data: inputs });
} catch (error) {
res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler' });
}
}
export async function generatePdf(req: AuthRequest, res: Response) {
try {
const templateId = parseInt(req.params.id);
const contractId = parseInt(req.params.contractId);
// Extras aus Body (POST) oder Query-Parametern (GET)
const stressfreiEmailId = req.body?.stressfreiEmailId || req.query.stressfreiEmailId;
const manualValues: Record<string, string> = req.body?.manualValues || {};
// Manual-Werte aus Query-Parametern extrahieren (manual_manual:1=Wert)
if (req.query) {
for (const [key, value] of Object.entries(req.query)) {
if (key.startsWith('manual_') && typeof value === 'string') {
manualValues[key.replace('manual_', '')] = value;
}
}
}
const pdfBuffer = await pdfTemplateService.generateFilledPdf(templateId, contractId, {
stressfreiEmailId: stressfreiEmailId ? parseInt(stressfreiEmailId as string) : undefined,
manualValues: Object.keys(manualValues).length > 0 ? manualValues : undefined,
});
const template = await pdfTemplateService.getTemplateById(templateId);
const filename = `${template?.name || 'Auftrag'}_${new Date().toISOString().split('T')[0]}.pdf`;
await logChange({
req, action: 'CREATE', resourceType: 'GeneratedPdf',
label: `PDF "${template?.name}" generiert für Vertrag #${contractId}`,
});
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(filename)}"`);
res.send(pdfBuffer);
} catch (error) {
console.error('PDF generate error:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Generieren',
});
}
}
+2
View File
@@ -30,6 +30,7 @@ import auditLogRoutes from './routes/auditLog.routes.js';
import gdprRoutes from './routes/gdpr.routes.js';
import consentPublicRoutes from './routes/consent-public.routes.js';
import emailLogRoutes from './routes/emailLog.routes.js';
import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
import { auditContextMiddleware } from './middleware/auditContext.js';
import { auditMiddleware } from './middleware/audit.js';
@@ -79,6 +80,7 @@ app.use('/api', contractHistoryRoutes);
app.use('/api/audit-logs', auditLogRoutes);
app.use('/api/gdpr', gdprRoutes);
app.use('/api/email-logs', emailLogRoutes);
app.use('/api/pdf-templates', pdfTemplateRoutes);
// Health check
app.get('/api/health', (req, res) => {
+53
View File
@@ -0,0 +1,53 @@
import { Router } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { authenticate, requirePermission } from '../middleware/auth.js';
import * as pdfTemplateController from '../controllers/pdfTemplate.controller.js';
const router = Router();
// Upload-Verzeichnis
const templatesDir = path.join(process.cwd(), 'uploads', 'pdf-templates');
if (!fs.existsSync(templatesDir)) {
fs.mkdirSync(templatesDir, { recursive: true });
}
const upload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, templatesDir),
filename: (_req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, `template-${uniqueSuffix}${path.extname(file.originalname)}`);
},
}),
fileFilter: (_req, file, cb) => {
if (file.mimetype === 'application/pdf') cb(null, true);
else cb(new Error('Nur PDF-Dateien sind erlaubt'));
},
limits: { fileSize: 20 * 1024 * 1024 },
});
router.use(authenticate);
// CRUD
router.get('/', requirePermission('settings:read'), pdfTemplateController.getTemplates);
router.get('/crm-fields', requirePermission('settings:read'), pdfTemplateController.getCrmFields);
router.get('/:id', requirePermission('settings:read'), pdfTemplateController.getTemplate);
router.post('/', requirePermission('settings:update'), upload.single('template'), pdfTemplateController.createTemplate);
router.put('/:id', requirePermission('settings:update'), pdfTemplateController.updateTemplate);
router.delete('/:id', requirePermission('settings:update'), pdfTemplateController.deleteTemplate);
// PDF-Felder auslesen
router.get('/:id/fields', requirePermission('settings:read'), pdfTemplateController.extractFields);
// Annotierte Vorschau (Feldnamen in der PDF sichtbar)
router.get('/:id/preview', requirePermission('settings:read'), pdfTemplateController.getAnnotatedPreview);
// PDF generieren
router.get('/:id/generate/:contractId/inputs', requirePermission('contracts:read'), pdfTemplateController.getRequiredInputs);
router.post('/:id/generate/:contractId', requirePermission('contracts:read'), pdfTemplateController.generatePdf);
// Auch GET für direkte Links (ohne Extras)
router.get('/:id/generate/:contractId', requirePermission('contracts:read'), pdfTemplateController.generatePdf);
export default router;
+633
View File
@@ -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());
}