import { Request, Response } from 'express'; import prisma from '../lib/prisma.js'; import * as contractService from '../services/contract.service.js'; import * as contractCockpitService from '../services/contractCockpit.service.js'; import * as contractHistoryService from '../services/contractHistory.service.js'; import * as authorizationService from '../services/authorization.service.js'; import { ApiResponse, AuthRequest } from '../types/index.js'; import { logChange } from '../services/audit.service.js'; import { canAccessContract } from '../utils/accessControl.js'; import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js'; export async function getContracts(req: AuthRequest, res: Response): Promise { try { const { customerId, type, status, search, page, limit, tree } = req.query; // Baumstruktur für Kundenansicht if (tree === 'true' && customerId) { const treeData = await contractService.getContractTreeForCustomer( parseInt(customerId as string) ); res.json({ success: true, data: treeData } as ApiResponse); return; } // Für Kundenportal-Benutzer: nur eigene + vertretene Kunden MIT Vollmacht let customerIds: number[] | undefined; if (req.user?.isCustomerPortal && req.user.customerId) { // Eigene Customer-ID immer customerIds = [req.user.customerId]; // Vertretene Kunden nur wenn Vollmacht erteilt const representedIds: number[] = req.user.representedCustomerIds || []; for (const repCustId of representedIds) { const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId); if (hasAuth) { customerIds.push(repCustId); } } } const result = await contractService.getAllContracts({ customerId: customerId ? parseInt(customerId as string) : undefined, customerIds, // Wird nur für Kundenportal-Benutzer gesetzt type: type as any, status: status as any, search: search as string, page: page ? parseInt(page as string) : undefined, limit: limit ? parseInt(limit as string) : undefined, }); res.json({ success: true, data: result.contracts, pagination: result.pagination, } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden der Verträge', } as ApiResponse); } } export async function getContract(req: AuthRequest, res: Response): Promise { try { const contract = await contractService.getContractById(parseInt(req.params.id)); if (!contract) { res.status(404).json({ success: false, error: 'Vertrag nicht gefunden', } as ApiResponse); return; } // Für Kundenportal-Benutzer: Zugriff nur auf eigene + vertretene Kunden MIT Vollmacht if (req.user?.isCustomerPortal && req.user.customerId) { const allowedCustomerIds = [req.user.customerId]; const representedIds: number[] = req.user.representedCustomerIds || []; for (const repCustId of representedIds) { const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId); if (hasAuth) { allowedCustomerIds.push(repCustId); } } if (!allowedCustomerIds.includes(contract.customerId)) { res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Vertrag', } as ApiResponse); return; } } res.json({ success: true, data: contract } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden des Vertrags', } as ApiResponse); } } export async function createContract(req: Request, res: Response): Promise { try { const contract = await contractService.createContract(req.body); await logChange({ req, action: 'CREATE', resourceType: 'Contract', resourceId: contract.id.toString(), label: `Vertrag ${contract.contractNumber} angelegt`, customerId: contract.customerId, }); res.status(201).json({ success: true, data: contract } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Vertrags', } as ApiResponse); } } export async function updateContract(req: AuthRequest, res: Response): Promise { try { const contractId = parseInt(req.params.id); // Vorherigen Stand laden für Audit-Vergleich const before = await prisma.contract.findUnique({ where: { id: contractId }, include: { energyDetails: true, internetDetails: true, mobileDetails: true, tvDetails: true, carInsuranceDetails: true }, }); const contract = await contractService.updateContract(contractId, req.body); // Geänderte Felder ermitteln const changes: Record = {}; const fieldLabels: Record = { status: 'Status', startDate: 'Vertragsbeginn', endDate: 'Vertragsende', portalUsername: 'Portal-Benutzername', customerNumberAtProvider: 'Kundennummer beim Anbieter', providerId: 'Anbieter', tariffId: 'Tarif', cancellationPeriodId: 'Kündigungsfrist', contractDurationId: 'Vertragslaufzeit', platformId: 'Vertriebsplattform', cancellationDate: 'Kündigungsdatum', cancellationSentDate: 'Kündigung gesendet am', identityDocumentId: 'Ausweis', bankCardId: 'Bankverbindung', addressId: 'Adresse', commission: 'Provision', notes: 'Notizen', }; const energyLabels: Record = { meterId: 'Zähler', maloId: 'MaLo-ID', annualConsumption: 'Jahresverbrauch', basePrice: 'Grundpreis', unitPrice: 'Arbeitspreis', unitPriceNt: 'NT-Arbeitspreis', bonus: 'Bonus', }; // Hauptfelder vergleichen const body = req.body; if (before) { for (const [key, newVal] of Object.entries(body)) { if (['energyDetails', 'internetDetails', 'mobileDetails', 'tvDetails', 'carInsuranceDetails', 'password'].includes(key)) continue; const oldVal = (before as any)[key]; const norm = (v: unknown) => (v === null || v === undefined || v === '' ? null : v); if (JSON.stringify(norm(oldVal)) !== JSON.stringify(norm(newVal))) { const label = fieldLabels[key] || key; changes[label] = { von: oldVal ?? '-', nach: newVal ?? '-' }; } } // Energie-Details vergleichen if (body.energyDetails && before.energyDetails) { for (const [key, newVal] of Object.entries(body.energyDetails)) { const oldVal = (before.energyDetails as any)[key]; const norm = (v: unknown) => (v === null || v === undefined || v === '' ? null : v); if (JSON.stringify(norm(oldVal)) !== JSON.stringify(norm(newVal))) { const label = energyLabels[key] || key; changes[label] = { von: oldVal ?? '-', nach: newVal ?? '-' }; } } } } const changeList = Object.entries(changes).map(([f, c]) => `${f}: ${c.von} → ${c.nach}`).join(', '); await logChange({ req, action: 'UPDATE', resourceType: 'Contract', resourceId: contractId.toString(), label: changeList ? `Vertrag ${before?.contractNumber || contractId} aktualisiert: ${changeList}` : `Vertrag ${before?.contractNumber || contractId} aktualisiert`, details: Object.keys(changes).length > 0 ? changes : undefined, customerId: before?.customerId, }); res.json({ success: true, data: contract } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Vertrags', } as ApiResponse); } } export async function deleteContract(req: Request, res: Response): Promise { try { const contractId = parseInt(req.params.id); const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } }); await contractService.deleteContract(contractId); await logChange({ req, action: 'DELETE', resourceType: 'Contract', resourceId: contractId.toString(), label: `Vertrag ${contract?.contractNumber} gelöscht`, customerId: contract?.customerId, }); res.json({ success: true, message: 'Vertrag gelöscht' } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Löschen des Vertrags', } as ApiResponse); } } export async function createFollowUp(req: AuthRequest, res: Response): Promise { try { const previousContractId = parseInt(req.params.id); // Vorgängervertrag laden für Vertragsnummer const previousContract = await prisma.contract.findUnique({ where: { id: previousContractId }, select: { contractNumber: true }, }); if (!previousContract) { res.status(404).json({ success: false, error: 'Vorgängervertrag nicht gefunden' } as ApiResponse); return; } const contract = await contractService.createFollowUpContract(previousContractId); const createdBy = req.user?.email || 'unbekannt'; // Historie-Eintrag für den Vorgängervertrag erstellen await contractHistoryService.createFollowUpHistoryEntry( previousContractId, contract.contractNumber, createdBy ); // Historie-Eintrag für den neuen Folgevertrag erstellen await contractHistoryService.createNewContractFromPredecessorEntry( contract.id, previousContract.contractNumber, createdBy ); await logChange({ req, action: 'CREATE', resourceType: 'Contract', resourceId: contract.id.toString(), label: `Folgevertrag erstellt für ${previousContract.contractNumber}`, customerId: contract.customerId, }); res.status(201).json({ success: true, data: contract } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Folgevertrags', } as ApiResponse); } } /** * VVL = Vertragsverlängerung beim selben Anbieter. * Erstellt einen neuen Vertrag mit allen Daten des Vorgängers (außer * Auftragsdokument), Startdatum = altes Start + Vertragslaufzeit. */ export async function createRenewal(req: AuthRequest, res: Response): Promise { try { const previousContractId = parseInt(req.params.id); const previousContract = await prisma.contract.findUnique({ where: { id: previousContractId }, select: { contractNumber: true }, }); if (!previousContract) { res.status(404).json({ success: false, error: 'Vorgängervertrag nicht gefunden' } as ApiResponse); return; } const contract = await contractService.createRenewalContract(previousContractId); if (!contract) { res.status(500).json({ success: false, error: 'VVL konnte nicht erstellt werden' } as ApiResponse); return; } const createdBy = req.user?.email || 'unbekannt'; await contractHistoryService.createRenewalHistoryEntry( previousContractId, contract.contractNumber, createdBy, ); await contractHistoryService.createNewRenewalFromPredecessorEntry( contract.id, previousContract.contractNumber, createdBy, ); await logChange({ req, action: 'CREATE', resourceType: 'Contract', resourceId: contract.id.toString(), label: `VVL erstellt für ${previousContract.contractNumber}`, customerId: contract.customerId, }); res.status(201).json({ success: true, data: contract } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Erstellen der VVL', } as ApiResponse); } } export async function getContractPassword(req: AuthRequest, res: Response): Promise { try { const contractId = parseInt(req.params.id); if (!(await canAccessContract(req, res, contractId))) return; const password = await contractService.getContractPassword(contractId); if (password === null) { res.status(404).json({ success: false, error: 'Kein Passwort hinterlegt', } as ApiResponse); return; } res.json({ success: true, data: { password } } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Entschlüsseln des Passworts', } as ApiResponse); } } export async function getSimCardCredentials(req: AuthRequest, res: Response): Promise { try { const simCardId = parseInt(req.params.simCardId); // SimCard → MobileDetails → Contract const sim = await prisma.simCard.findUnique({ where: { id: simCardId }, select: { mobileDetails: { select: { contractId: true } } }, }); if (!sim?.mobileDetails) { res.status(404).json({ success: false, error: 'SIM-Karte nicht gefunden' } as ApiResponse); return; } if (!(await canAccessContract(req, res, sim.mobileDetails.contractId))) return; const credentials = await contractService.getSimCardCredentials(simCardId); res.json({ success: true, data: credentials } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Entschlüsseln der SIM-Karten-Daten', } as ApiResponse); } } export async function getInternetCredentials(req: AuthRequest, res: Response): Promise { try { const contractId = parseInt(req.params.id); if (!(await canAccessContract(req, res, contractId))) return; const credentials = await contractService.getInternetCredentials(contractId); res.json({ success: true, data: credentials } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Entschlüsseln des Internet-Passworts', } as ApiResponse); } } export async function getSipCredentials(req: AuthRequest, res: Response): Promise { try { const phoneNumberId = parseInt(req.params.phoneNumberId); // PhoneNumber → InternetDetails → Contract const phone = await prisma.phoneNumber.findUnique({ where: { id: phoneNumberId }, select: { internetDetails: { select: { contractId: true } } }, }); if (!phone?.internetDetails) { res.status(404).json({ success: false, error: 'Rufnummer nicht gefunden' } as ApiResponse); return; } if (!(await canAccessContract(req, res, phone.internetDetails.contractId))) return; const credentials = await contractService.getSipCredentials(phoneNumberId); res.json({ success: true, data: credentials } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Entschlüsseln des SIP-Passworts', } as ApiResponse); } } // ==================== VERTRAGS-COCKPIT ==================== export async function getCockpit(req: AuthRequest, res: Response): Promise { try { const cockpitData = await contractCockpitService.getCockpitData(); res.json({ success: true, data: cockpitData } as ApiResponse); } catch (error) { console.error('Cockpit error:', error); res.status(500).json({ success: false, error: 'Fehler beim Laden des Vertrags-Cockpits', } as ApiResponse); } } // ==================== FOLGEZÄHLER ==================== export async function addSuccessorMeter(req: AuthRequest, res: Response): Promise { try { const contractId = parseInt(req.params.id); const { meterId, installedAt, finalReadingPrevious } = req.body; const contract = await prisma.contract.findUnique({ where: { id: contractId }, include: { energyDetails: { include: { contractMeters: { orderBy: { position: 'asc' } } } } }, }); if (!contract?.energyDetails) { res.status(404).json({ success: false, error: 'Energievertrag nicht gefunden' } as ApiResponse); return; } const ecdId = contract.energyDetails.id; const existingMeters = contract.energyDetails.contractMeters; const nextPosition = existingMeters.length > 0 ? Math.max(...existingMeters.map(m => m.position)) + 1 : 0; // Vorherigen Zähler als gewechselt markieren if (existingMeters.length > 0 && finalReadingPrevious !== undefined) { const prevMeter = existingMeters[existingMeters.length - 1]; await prisma.contractMeter.update({ where: { id: prevMeter.id }, data: { removedAt: installedAt ? new Date(installedAt) : new Date(), finalReading: parseFloat(finalReadingPrevious), }, }); } const contractMeter = await prisma.contractMeter.create({ data: { energyContractDetailsId: ecdId, meterId: parseInt(meterId), position: nextPosition, installedAt: installedAt ? new Date(installedAt) : new Date(), }, include: { meter: { include: { readings: true } } }, }); // Aktuellen Zähler am Vertrag aktualisieren await prisma.energyContractDetails.update({ where: { id: ecdId }, data: { meterId: parseInt(meterId) }, }); await logChange({ req, action: 'CREATE', resourceType: 'ContractMeter', resourceId: contractMeter.id.toString(), label: `Folgezähler hinzugefügt zu Vertrag #${contractId}`, customerId: contract.customerId, }); res.json({ success: true, data: contractMeter } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen des Folgezählers', } as ApiResponse); } } export async function removeContractMeter(req: AuthRequest, res: Response): Promise { try { const contractMeterId = parseInt(req.params.contractMeterId); const contractId = parseInt(req.params.id); await prisma.contractMeter.delete({ where: { id: contractMeterId } }); await logChange({ req, action: 'DELETE', resourceType: 'ContractMeter', resourceId: contractMeterId.toString(), label: `Folgezähler entfernt von Vertrag #${contractId}`, }); res.json({ success: true, data: null } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Entfernen', } as ApiResponse); } } // ==================== VERTRAGSDOKUMENTE ==================== export async function getContractDocuments(req: AuthRequest, res: Response): Promise { try { const contractId = parseInt(req.params.id); if (!(await canAccessContract(req, res, contractId))) return; const documents = await prisma.contractDocument.findMany({ where: { contractId }, orderBy: { createdAt: 'desc' }, }); res.json({ success: true, data: documents } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden der Dokumente' } as ApiResponse); } } export async function uploadContractDocument(req: AuthRequest, res: Response): Promise { try { const contractId = parseInt(req.params.id); if (!(await canAccessContract(req, res, contractId))) return; const { documentType, notes, deliveryDate } = req.body; if (!req.file) { res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse); return; } if (!documentType) { res.status(400).json({ success: false, error: 'Dokumenttyp erforderlich' } as ApiResponse); return; } const documentPath = `/uploads/contract-documents/${req.file.filename}`; const doc = await prisma.contractDocument.create({ data: { contractId, documentType, documentPath, originalName: req.file.originalname, notes: notes || null, uploadedBy: req.user?.email, }, }); const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } }); await logChange({ req, action: 'CREATE', resourceType: 'ContractDocument', resourceId: doc.id.toString(), label: `Dokument "${documentType}" hochgeladen für Vertrag ${contract?.contractNumber}`, details: { typ: documentType, datei: req.file.originalname }, customerId: contract?.customerId, }); // Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer await maybeActivateOnDeliveryConfirmation(contractId, documentType, req, deliveryDate); res.status(201).json({ success: true, data: doc } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Hochladen', } as ApiResponse); } } export async function deleteContractDocument(req: AuthRequest, res: Response): Promise { try { const documentId = parseInt(req.params.documentId); const contractId = parseInt(req.params.id); if (!(await canAccessContract(req, res, contractId))) return; const doc = await prisma.contractDocument.findUnique({ where: { id: documentId } }); if (!doc || doc.contractId !== contractId) { res.status(404).json({ success: false, error: 'Dokument nicht gefunden' } as ApiResponse); return; } // Datei löschen const fs = await import('fs'); const path = await import('path'); const filePath = path.join(process.cwd(), doc.documentPath); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } await prisma.contractDocument.delete({ where: { id: documentId } }); const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } }); await logChange({ req, action: 'DELETE', resourceType: 'ContractDocument', resourceId: documentId.toString(), label: `Dokument "${doc.documentType}" gelöscht von Vertrag ${contract?.contractNumber}`, details: { typ: doc.documentType, datei: doc.originalName }, customerId: contract?.customerId, }); res.json({ success: true, message: 'Dokument gelöscht' } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Löschen', } as ApiResponse); } } // ==================== SNOOZE (VERTRAG ZURÜCKSTELLEN) ==================== export async function snoozeContract(req: Request, res: Response): Promise { try { const id = parseInt(req.params.id); const { nextReviewDate, months } = req.body; let reviewDate: Date | null = null; if (nextReviewDate) { // Explizites Datum angegeben reviewDate = new Date(nextReviewDate); } else if (months) { // Monate angegeben → berechne Datum reviewDate = new Date(); reviewDate.setMonth(reviewDate.getMonth() + months); } // Wenn beides leer → nextReviewDate wird auf null gesetzt (Snooze aufheben) const updated = await prisma.contract.update({ where: { id }, data: { nextReviewDate: reviewDate }, select: { id: true, contractNumber: true, nextReviewDate: true, }, }); await logChange({ req, action: 'UPDATE', resourceType: 'Contract', resourceId: id.toString(), label: `Vertrag ${updated.contractNumber} zurückgestellt`, }); res.json({ success: true, data: updated, message: reviewDate ? `Vertrag zurückgestellt bis ${reviewDate.toLocaleDateString('de-DE')}` : 'Zurückstellung aufgehoben', } as ApiResponse); } catch (error) { console.error('Snooze error:', error); res.status(500).json({ success: false, error: 'Fehler beim Zurückstellen des Vertrags', } as ApiResponse); } }