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
+55
View File
@@ -21,6 +21,7 @@
"mailparser": "^3.9.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "^7.0.13",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"undici": "^6.23.0"
},
@@ -473,6 +474,36 @@
"node": ">=12"
}
},
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/standard-fonts/node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@pdf-lib/upng/node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -2478,6 +2509,30 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
},
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/pdfkit": {
"version": "0.17.2",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
+1
View File
@@ -31,6 +31,7 @@
"mailparser": "^3.9.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "^7.0.13",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"undici": "^6.23.0"
},
+31
View File
@@ -7,6 +7,26 @@ datasource db {
url = env("DATABASE_URL")
}
// ==================== PDF TEMPLATES (Auftragsvorlagen) ====================
model PdfTemplate {
id Int @id @default(autoincrement())
name String @unique // z.B. "EWE Auftragsformular"
description String? // Beschreibung
providerName String? // Zugehöriger Anbieter (z.B. "EWE")
templatePath String // Pfad zur PDF-Vorlage
originalName String // Originaler Dateiname
// Feld-Mapping: JSON-Objekt { pdfFieldName: crmFieldPath }
// z.B. { "Vorname": "customer.firstName", "PLZ": "customer.addresses[0].postalCode" }
fieldMapping String @db.LongText // JSON
// Rufnummern-Konfiguration
phoneFieldPrefix String? // Prefix für Rufnummern-Felder (z.B. "Rufnummer")
maxPhoneFields Int? @default(8) // Max. Rufnummern-Felder im PDF
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ==================== EMAIL LOG ====================
model EmailLog {
@@ -224,6 +244,17 @@ model Address {
city String
country String @default("Deutschland")
isDefault Boolean @default(false)
// Eigentümer (leer = Kunde ist selbst Eigentümer)
ownerCompany String?
ownerFirstName String?
ownerLastName String?
ownerStreet String?
ownerHouseNumber String?
ownerPostalCode String?
ownerCity String?
ownerPhone String?
ownerMobile String?
ownerEmail String?
contractsAsDelivery Contract[] @relation("DeliveryAddress")
contractsAsBilling Contract[] @relation("BillingAddress")
createdAt DateTime @default(now())
@@ -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());
}
+39 -11
View File
@@ -2,7 +2,7 @@ Vertragliste bei Energie mit Anschlussadresse/Lieferadresse noch in der Liste
Bei Mobilfunk die Mobilfunknummer und wenn vorhanden Karteninhaber
Bei Festnetz, die Anschlussadresse/Lieferadresse
Bei KFZ das Kennzeichen
#
# ende
#erledigt
Datenschutzerklärung wenn PDF hinterlegt wurde, alle Haken auf Grün setzten.
@@ -12,41 +12,42 @@ Aktuell zählt das PDF als Alternative zu den Online-Haken. Du willst es so:
PDF hochgeladen → alle 4 Online-Consents automatisch auf GRANTED setzen
Kunde entfernt einen Haken im Portal → PDF löschen + Tabs sperren
Entsperrung nur durch: alle Haken wieder setzen ODER neues PDF hochladen
#
# ende
#erledigt
Zweitarif (Gibt es auch 3 Tarifuzähler?) Zähler HT/NT bei Strom Zähler hinzufügen.
Auch in die Berechnung die Verbäuche dann darstellen
# ende
#erledigt
Alle Datumsfelder mit 0 davor wenn es ne einstellige Zahl ist
Jetzt : 1.1.2026
Und gewollt 01.01.2026
#
# ende
#erledigt
Die Auditmeldungen aussagekräftig
#
# ende
Email Log und system testen
Sprich senden und Empfnagen
#
# ende
Security System testen
#
# ende
#erledigt
Datenschutzerklärung Website unserer Seite und ein impressum im Kundenportal.
Auch wieder über das Einstellungsmenü editirerbar.
Bitte mach mir da auch einen Vorschagstext rein
#
# ende
Geburtstagskalender, und Geburtgsgruß als Modal beim ersten Login an dem Tag,
Sollte der Login bis n7 btage nach Geburtsag sein dann Glückwunsch nachträglich
#
# ende
#erledigt
Bei der Email datenschutzerklärung erst wenn alle hebel drin sind, auf einen bestätigungsbutton klicken, um sicherzustellen, das alle heben drin sind.
@@ -54,18 +55,45 @@ Danch bestätigen, nochmals eine Bestätigiguns emails enden.
Denn jetzt kann der Kudne auch nur einen Haken auslassen, das würd uns aber nichts bringen.
#
# ende
#erledigt
Haben wir bei den Vertragen (also alle) ein Dokumentfeld zum Upload von, Auftragsformular, Lieferbestätigung, Vertragsunterlagen?
hier sind wieder png,pdf erlaubt
#
# ende
EWE Auftragsformular generieren aus Kundendaten, nur wie bei Neuvertrag. Hinter folgevertrag vielleicht ein Pfeil als Drop down und dann Kann man da Neuer Auftrag EWE, später die Liste erweitern mit Moon usw. was man halt hat aber am anfang EWE. Das wäre dann eine PDF die von der EWE kommt die dann ausgefüllt werden soll.
Und wenn es der Erste Vertrag wäre , dann beim Kunden im TAB Verträge, vielleich dann ein auch ein Pfeil nach unten hinter dem Button mit dem Namen Vertrag hinzufügen.
Ist die Frage wo legen wir die PDF Vorlagen hin.
Vielleicht sogar ein Editor, für Vorlagen wo man dann rein zieht an welcher stelle, welches feld stehen soll aus den kunden daten.
Dann kann man das für weiter Formulare machen die PDF sind.
Moon fachhandle als Beispiel, hat ne API, deshalb kommt das später ;-)
Bei Rufnummern wäre das interessant wie man das dann realisiert, weil jede rufnumemr ja ein einzelenen feld ist, aber wir ja vorher nicht wissen wie viele rufnumemrn der kunde hat. allerdings muss man auch das maximum angeben können, denn wenn nur 8 felder sa sind kann man nur 8 rufnummern portieren. Oder es wird eine Extra seite hinten angehangen also erstellt weitere Rufnummern und bei dem Origionaldokument ein Hinweis, weitere Rufnummern siehe letzte Seite, wenn das da erschöpft sein sollte
#
# ende
Aus der EMail wenn Vertrag zugeordnet ist, Anhang speichern auch in Vertragsdokumente
Und Rechnungen wie bei den Kündigungsdokumenten
# ende
Da steht noch Eigentümer,
Wo können wir das am besten in unser System verpacken?
Denn bei Festnetz und Energieprodukten ist das relevant.
Denn wenn der Kunde zu Miete wohnt ist er nicht Eigentümer.
Ein Eigentümertümer kann auch eine Firma sein, bei ner Wohnungsbaugesselschft zum beispiel,
DA müssten wir auch wieder name Firma etc.
Eigentlich müssten wir das unter adressen packen.
Vielleicht mit ner Möglichkeit wenn eigentümer nicht ausgefüllt ist, ist der Kudne immer selbst Eigentümer
ABer es könnte ja auch mehrere Objekte mit verschiedenen Eigentümern geben.
Scheiße wie am sinnvollsten lösen
# ende
Bug auswhl stressfrei email geht nich im Auftragsgenerator
# ende
Es soll auch zwischen Lieferadresse und Rechungsadresse ausgewählt werden können. als Gruppe. Beudetet wenn eine Feldgruppe aus einer gruppe entweder liefer / Rechnung / oder eigentümer. Dann soll man das auswählen können
# ende