diff --git a/backend/src/controllers/contract.controller.ts b/backend/src/controllers/contract.controller.ts index ac0bd339..9ced51d6 100644 --- a/backend/src/controllers/contract.controller.ts +++ b/backend/src/controllers/contract.controller.ts @@ -256,6 +256,58 @@ export async function createFollowUp(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); diff --git a/backend/src/routes/contract.routes.ts b/backend/src/routes/contract.routes.ts index 2bab4ecc..020f6570 100644 --- a/backend/src/routes/contract.routes.ts +++ b/backend/src/routes/contract.routes.ts @@ -42,6 +42,9 @@ router.delete('/:id', authenticate, requirePermission('contracts:delete'), contr // Follow-up contract router.post('/:id/follow-up', authenticate, requirePermission('contracts:create'), contractController.createFollowUp); +// VVL (Vertragsverlängerung beim selben Anbieter, vollständige Kopie + Datums-Berechnung) +router.post('/:id/renewal', authenticate, requirePermission('contracts:create'), contractController.createRenewal); + // Snooze (Vertrag zurückstellen) router.patch('/:id/snooze', authenticate, requirePermission('contracts:update'), contractController.snoozeContract); diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index fb144efe..3d266337 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -765,6 +765,251 @@ export async function createFollowUpContract(previousContractId: number) { return createContract(newContractData); } +/** + * Hilfsfunktion: extrahiert die Anzahl Monate aus einer ContractDuration. + * Code-Beispiele: "12M", "24M", "1J", "2J". Falls nichts erkannt wird, fällt + * sie auf 12 Monate als sicheren Default zurück. + */ +function durationToMonths(code: string | null | undefined, description: string | null | undefined): number { + const c = (code || '').trim(); + const d = (description || '').trim(); + let m = c.match(/^(\d+)\s*M$/i); + if (m) return parseInt(m[1], 10); + m = c.match(/^(\d+)\s*J$/i); + if (m) return parseInt(m[1], 10) * 12; + m = d.match(/(\d+)\s*Monat/i); + if (m) return parseInt(m[1], 10); + m = d.match(/(\d+)\s*Jahr/i); + if (m) return parseInt(m[1], 10) * 12; + return 12; +} + +/** + * VVL = Vertragsverlängerung beim selben Anbieter. + * + * Im Gegensatz zu createFollowUpContract werden ALLE Daten 1:1 kopiert: + * Provider, Tarif, Portal-Credentials, Preise, Notes, ContractDocuments. + * + * Berechnet wird das neue Startdatum: altes startDate + Vertragslaufzeit. + * Stimmt das gefundene Datum nicht mit dem späteren Auftrag überein, kann + * der User es im Vertrag manuell anpassen. + * + * NICHT mitkopiert wird: + * - das Auftragsdokument (documentType "Auftragsformular") – das ist + * schließlich die NEU zu unterschreibende VVL. + * - Kündigungsschreiben/-bestätigung (das war der ALTE Cancel-Flow, + * bei einer VVL nicht relevant) + */ +export async function createRenewalContract(previousContractId: number) { + const previousContract = await getContractById(previousContractId, true); + if (!previousContract) { + throw new Error('Vorgängervertrag nicht gefunden'); + } + + // Bereits ein Folge-/VVL-Vertrag vorhanden? + const existing = await prisma.contract.findFirst({ + where: { previousContractId }, + select: { id: true, contractNumber: true }, + }); + if (existing) { + throw new Error(`Es existiert bereits ein Folgevertrag: ${existing.contractNumber}`); + } + + // Neues Startdatum = altes Start + Laufzeit + let newStartDate: Date | null = null; + let newEndDate: Date | null = null; + if (previousContract.startDate && previousContract.contractDuration) { + const months = durationToMonths( + previousContract.contractDuration.code, + previousContract.contractDuration.description, + ); + newStartDate = new Date(previousContract.startDate); + newStartDate.setMonth(newStartDate.getMonth() + months); + newEndDate = new Date(newStartDate); + newEndDate.setMonth(newEndDate.getMonth() + months); + } + + // Vertrags-Daten 1:1 kopieren (außer id/contractNumber/Datums-/Cancellation-Felder) + const contractNumber = generateContractNumber(previousContract.type); + + const newContract = await prisma.contract.create({ + data: { + contractNumber, + customerId: previousContract.customerId, + type: previousContract.type, + status: 'DRAFT', + contractCategoryId: previousContract.contractCategoryId, + addressId: previousContract.addressId, + billingAddressId: previousContract.billingAddressId, + bankCardId: previousContract.bankCardId, + identityDocumentId: previousContract.identityDocumentId, + salesPlatformId: previousContract.salesPlatformId, + cancellationPeriodId: previousContract.cancellationPeriodId, + contractDurationId: previousContract.contractDurationId, + previousContractId: previousContract.id, + previousProviderId: previousContract.previousProviderId, + providerId: previousContract.providerId, + tariffId: previousContract.tariffId, + providerName: previousContract.providerName, + tariffName: previousContract.tariffName, + customerNumberAtProvider: previousContract.customerNumberAtProvider, + portalUsername: previousContract.portalUsername, + portalPasswordEncrypted: previousContract.portalPasswordEncrypted, + commission: previousContract.commission, + notes: previousContract.notes, + startDate: newStartDate, + endDate: newEndDate, + // Cancellation-Felder bewusst leer lassen – die VVL hat den alten + // Cancel-Flow nicht geerbt. + }, + }); + + // Detail-Tabellen 1:1 kopieren (id rausnehmen, contractId neu) + if (previousContract.energyDetails) { + const ed = previousContract.energyDetails; + const newEnergy = await prisma.energyContractDetails.create({ + data: { + contractId: newContract.id, + meterId: ed.meterId, + maloId: ed.maloId, + annualConsumption: ed.annualConsumption, + annualConsumptionKwh: ed.annualConsumptionKwh, + basePrice: ed.basePrice, + unitPrice: ed.unitPrice, + unitPriceNt: ed.unitPriceNt, + bonus: ed.bonus, + previousProviderName: ed.previousProviderName, + previousCustomerNumber: ed.previousCustomerNumber, + }, + }); + // ContractMeter-Verknüpfungen mitkopieren + for (const cm of ed.contractMeters || []) { + await prisma.contractMeter.create({ + data: { + energyContractDetailsId: newEnergy.id, + meterId: cm.meterId, + position: cm.position, + installedAt: cm.installedAt, + removedAt: cm.removedAt, + finalReading: cm.finalReading, + }, + }); + } + } + if (previousContract.internetDetails) { + const id = previousContract.internetDetails; + const newInet = await prisma.internetContractDetails.create({ + data: { + contractId: newContract.id, + downloadSpeed: id.downloadSpeed, + uploadSpeed: id.uploadSpeed, + routerModel: id.routerModel, + routerSerialNumber: id.routerSerialNumber, + installationDate: id.installationDate, + internetUsername: id.internetUsername, + internetPasswordEncrypted: id.internetPasswordEncrypted, + propertyType: id.propertyType, + propertyLocation: id.propertyLocation, + connectionLocation: id.connectionLocation, + homeId: id.homeId, + activationCode: id.activationCode, + }, + }); + for (const pn of id.phoneNumbers || []) { + await prisma.phoneNumber.create({ + data: { + internetContractDetailsId: newInet.id, + phoneNumber: pn.phoneNumber, + isMain: pn.isMain, + sipUsername: pn.sipUsername, + sipPasswordEncrypted: pn.sipPasswordEncrypted, + sipServer: pn.sipServer, + }, + }); + } + } + if (previousContract.mobileDetails) { + const md = previousContract.mobileDetails; + const newMob = await prisma.mobileContractDetails.create({ + data: { + contractId: newContract.id, + requiresMultisim: md.requiresMultisim, + dataVolume: md.dataVolume, + includedMinutes: md.includedMinutes, + includedSMS: md.includedSMS, + deviceModel: md.deviceModel, + deviceImei: md.deviceImei, + phoneNumber: md.phoneNumber, + simCardNumber: md.simCardNumber, + }, + }); + for (const sc of md.simCards || []) { + await prisma.simCard.create({ + data: { + mobileDetailsId: newMob.id, + phoneNumber: sc.phoneNumber, + simCardNumber: sc.simCardNumber, + isMultisim: sc.isMultisim, + isMain: sc.isMain, + pin: sc.pin, + puk: sc.puk, + }, + }); + } + } + if (previousContract.tvDetails) { + await prisma.tvContractDetails.create({ + data: { + contractId: newContract.id, + receiverModel: previousContract.tvDetails.receiverModel, + smartcardNumber: previousContract.tvDetails.smartcardNumber, + package: previousContract.tvDetails.package, + }, + }); + } + if (previousContract.carInsuranceDetails) { + const ci = previousContract.carInsuranceDetails; + await prisma.carInsuranceDetails.create({ + data: { + contractId: newContract.id, + licensePlate: ci.licensePlate, + hsn: ci.hsn, + tsn: ci.tsn, + vin: ci.vin, + vehicleType: ci.vehicleType, + firstRegistration: ci.firstRegistration, + noClaimsClass: ci.noClaimsClass, + insuranceType: ci.insuranceType, + deductiblePartial: ci.deductiblePartial, + deductibleFull: ci.deductibleFull, + previousInsurer: ci.previousInsurer, + }, + }); + } + + // ContractDocuments mitkopieren – AUSSER "Auftragsformular" (das ist die + // neue Unterschrift, die der User selbst hochlädt). Files werden NICHT + // physisch dupliziert; beide Verträge zeigen auf dieselbe Datei. + const docs = await prisma.contractDocument.findMany({ + where: { contractId: previousContract.id }, + }); + for (const d of docs) { + if (d.documentType.toLowerCase().includes('auftragsformular')) continue; + await prisma.contractDocument.create({ + data: { + contractId: newContract.id, + documentType: d.documentType, + documentPath: d.documentPath, + originalName: d.originalName, + notes: d.notes, + uploadedBy: d.uploadedBy, + }, + }); + } + + return prisma.contract.findUnique({ where: { id: newContract.id } }); +} + // Decrypt password for viewing export async function getContractPassword(id: number): Promise { const contract = await prisma.contract.findUnique({ diff --git a/backend/src/services/contractHistory.service.ts b/backend/src/services/contractHistory.service.ts index 5563f415..a4fe794f 100644 --- a/backend/src/services/contractHistory.service.ts +++ b/backend/src/services/contractHistory.service.ts @@ -129,3 +129,35 @@ export async function createNewContractFromPredecessorEntry( createdBy, }); } + +/** + * Automatischen Historie-Eintrag für VVL (Vertragsverlängerung) im Vorgängervertrag. + */ +export async function createRenewalHistoryEntry( + previousContractId: number, + newContractNumber: string, + createdBy: string +) { + return createHistoryEntry(previousContractId, { + title: `Vertragsverlängerung erstellt: ${newContractNumber}`, + description: `Eine Vertragsverlängerung (VVL) als ${newContractNumber} wurde aus diesem Vertrag erstellt – alle Daten wurden 1:1 übernommen, das Auftragsdokument muss neu hochgeladen werden.`, + isAutomatic: true, + createdBy, + }); +} + +/** + * Automatischen Historie-Eintrag im neuen VVL-Vertrag. + */ +export async function createNewRenewalFromPredecessorEntry( + newContractId: number, + previousContractNumber: string, + createdBy: string +) { + return createHistoryEntry(newContractId, { + title: `VVL zu ${previousContractNumber}`, + description: `Dieser Vertrag wurde als Vertragsverlängerung (VVL) zu ${previousContractNumber} erstellt.`, + isAutomatic: true, + createdBy, + }); +} diff --git a/frontend/src/pages/contracts/ContractDetail.tsx b/frontend/src/pages/contracts/ContractDetail.tsx index 53b2d7ab..0a086187 100644 --- a/frontend/src/pages/contracts/ContractDetail.tsx +++ b/frontend/src/pages/contracts/ContractDetail.tsx @@ -1514,6 +1514,26 @@ export default function ContractDetail() { }, }); + // VVL = Vertragsverlängerung beim selben Anbieter (alle Daten 1:1 + Datum berechnet) + const renewalMutation = useMutation({ + mutationFn: () => contractApi.createRenewal(contractId), + onSuccess: (data) => { + if (data.data) { + navigate(`/contracts/${data.data.id}/edit`); + } else { + alert('VVL wurde erstellt, aber keine ID zurückgegeben'); + } + }, + onError: (error) => { + console.error('VVL Fehler:', error); + alert(`Fehler beim Erstellen der VVL: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`); + }, + }); + + // Dropdown-Toggle für VVL + const [showFollowUpMenu, setShowFollowUpMenu] = useState(false); + const [showVvlConfirm, setShowVvlConfirm] = useState(false); + // Un-Snooze Mutation const unsnoozeMutation = useMutation({ mutationFn: () => contractApi.snooze(contractId, {}), @@ -1756,14 +1776,50 @@ export default function ContractDetail() { )} {hasPermission('contracts:create') && !c.followUpContract && ( - +
+ {/* Hauptaktion: Folgevertrag anlegen */} + + {/* Dropdown-Pfeil für VVL */} + + {showFollowUpMenu && ( + <> + {/* Click-outside-Overlay */} +
setShowFollowUpMenu(false)} + /> +
+ +
+ + )} +
)} {c.followUpContract && ( @@ -3077,6 +3133,53 @@ export default function ContractDetail() {
+ {/* VVL Bestätigung */} + setShowVvlConfirm(false)} + title="Vertragsverlängerung (VVL) anlegen" + size="sm" + > +
+

+ Möchten Sie eine Vertragsverlängerung für diesen Vertrag anlegen? +

+

+ Alle Daten werden 1:1 übernommen (auch Provider, Tarif, Portal- + Zugang, Preise und Vertragsdokumente). Das Startdatum wird auf + den nächsten Laufzeit-Beginn berechnet (altes Startdatum + + Vertragslaufzeit). Das Auftragsdokument wird + nicht mitkopiert – das ist die neue, + unterschriebene VVL, die Sie selbst hochladen. +

+ {c.startDate && c.contractDuration?.description && ( +
+ Vorhersage: alter Beginn{' '} + {new Date(c.startDate).toLocaleDateString('de-DE')} +{' '} + {c.contractDuration.description} + {' = '}neuer VVL-Beginn (siehe danach im Vertrag) +
+ )} +
+ + +
+
+
+ {/* Status-Info Modal */} setShowStatusInfo(false)} /> diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 8312774c..eeaa33d8 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -657,6 +657,10 @@ export const contractApi = { const res = await api.post>(`/contracts/${id}/follow-up`); return res.data; }, + createRenewal: async (id: number) => { + const res = await api.post>(`/contracts/${id}/renewal`); + return res.data; + }, getPassword: async (id: number) => { const res = await api.get>(`/contracts/${id}/password`); return res.data;