From 8169c74d8efdb43f662c1c5caf9db9cc24239503 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 8 Aug 2026 15:13:05 +0200 Subject: [PATCH] Vertrag: Auto-CANCELLED bei Kuendigungsbestaetigung + Cockpit-Filter 1) Auto-Status: Wird eine Kuendigungsbestaetigung hinzugefuegt (Datum ueber Formular und/oder Dokument-Upload), wird der Vertrag von ACTIVE auf CANCELLED gesetzt und das Vertragsende = Kuendigungsdatum. Zentrale Funktion maybeCancelOnCancellationConfirmation (idempotent, nur aus ACTIVE). Upload-Route ersetzt die alte Inline-Logik (setzt jetzt auch endDate); Update-Controller triggert nur bei neu/geaendertem Bestaetigungsdatum (manuelle Status-Korrekturen bleiben unangetastet). 2) Cockpit-Filter 'Kuendigungsbestaetigung': neue Liste cancellationConfirmations (Vertraege mit Bestaetigung in Status ACTIVE/DRAFT/CANCELLED) + Filter-Option im Cockpit-Dropdown. Eigene Liste, weil bereits CANCELLED-Vertraege mangels Issue sonst nicht in der Cockpit-Liste auftauchen. Beides lokal verifiziert (Helper: ACTIVE->CANCELLED + endDate; Cockpit: Vertrag erscheint in der Liste mit korrektem Status). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/controllers/contract.controller.ts | 16 ++++- backend/src/routes/upload.routes.ts | 21 ++---- .../src/services/contractCockpit.service.ts | 50 +++++++++++++++ .../contractStatusScheduler.service.ts | 64 +++++++++++++++++++ docs/todo.md | 16 +++++ .../src/pages/contracts/ContractCockpit.tsx | 11 +++- frontend/src/types/index.ts | 1 + 7 files changed, 159 insertions(+), 20 deletions(-) diff --git a/backend/src/controllers/contract.controller.ts b/backend/src/controllers/contract.controller.ts index 94c0dd5f..e6ec14e5 100644 --- a/backend/src/controllers/contract.controller.ts +++ b/backend/src/controllers/contract.controller.ts @@ -11,7 +11,7 @@ import { logChange } from '../services/audit.service.js'; import { sanitizeContract, sanitizeContractStrict, sanitizeContracts, sanitizeContractsStrict, stripHtml, sanitizeNotes, validateContractDocumentType, validateOptionalIsoDate, isContractIdentifierField, validateContractIdentifier, validatePortalUsername } from '../utils/sanitize.js'; import { ApiError } from '../utils/apiError.js'; import { canAccessContract, canAccessCustomer } from '../utils/accessControl.js'; -import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js'; +import { maybeActivateOnDeliveryConfirmation, maybeCancelOnCancellationConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js'; /** * Walk-and-clean: strippt HTML/Script-/URI-Schemata in allen String-Werten @@ -232,6 +232,20 @@ export async function updateContract(req: AuthRequest, res: Response): Promise + d ? new Date(d as string).toISOString().split('T')[0] : null; + const beforeConf = asDay((before as any)?.cancellationConfirmationDate); + const afterConf = asDay((sanitizedBody as any)?.cancellationConfirmationDate); + if (afterConf && afterConf !== beforeConf) { + await maybeCancelOnCancellationConfirmation(contractId, req); + } + } + // Geänderte Felder ermitteln const changes: Record = {}; const fieldLabels: Record = { diff --git a/backend/src/routes/upload.routes.ts b/backend/src/routes/upload.routes.ts index ca71661b..3262b7f4 100644 --- a/backend/src/routes/upload.routes.ts +++ b/backend/src/routes/upload.routes.ts @@ -14,6 +14,7 @@ import { } from '../utils/accessControl.js'; import { validateOptionalIsoDate } from '../utils/sanitize.js'; import { validateUploadedFile } from '../middleware/uploadFileTypeValidator.js'; +import { maybeCancelOnCancellationConfirmation } from '../services/contractStatusScheduler.service.js'; // Pentest 56.1 (HIGH, 2026-06-01): Upload-Endpoints prüften nur die // Permission, nicht ob die Ziel-Resource zum Caller passt. Helper-Funktion @@ -694,23 +695,11 @@ async function handleContractDocumentUpload( data: updateData, }); - // Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde und - // der Vertrag noch ACTIVE ist → auf CANCELLED umstellen + Audit-Log. + // Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde: + // Vertrag ACTIVE → CANCELLED und Vertragsende = Kündigungsdatum. // "Optionen" ist für Vertrags-Änderungen gedacht, nicht für echte Kündigungen. - if (fieldName === 'cancellationConfirmationPath' && contract.status === 'ACTIVE') { - await prisma.contract.update({ - where: { id: contractId }, - data: { status: 'CANCELLED' }, - }); - await logChange({ - req, - action: 'UPDATE', - resourceType: 'Contract', - resourceId: contractId.toString(), - label: `Vertrag ${contract.contractNumber} automatisch auf CANCELLED gesetzt (Kündigungsbestätigung hochgeladen)`, - details: { vorher: 'ACTIVE', nachher: 'CANCELLED', trigger: 'cancellationConfirmation-Upload' }, - customerId: contract.customerId, - }); + if (fieldName === 'cancellationConfirmationPath') { + await maybeCancelOnCancellationConfirmation(contractId, req); } res.json({ diff --git a/backend/src/services/contractCockpit.service.ts b/backend/src/services/contractCockpit.service.ts index 88e793ad..8792564f 100644 --- a/backend/src/services/contractCockpit.service.ts +++ b/backend/src/services/contractCockpit.service.ts @@ -102,6 +102,9 @@ export interface ReportedMeterReading { export interface CockpitResult { contracts: CockpitContract[]; + // Verträge mit hinterlegter Kündigungsbestätigung (Dokument und/oder Datum) + // in Status ACTIVE/DRAFT/CANCELLED – als eigener Filter im Cockpit. + cancellationConfirmations: CockpitContract[]; documentAlerts: DocumentAlert[]; reportedReadings: ReportedMeterReading[]; summary: CockpitSummary; @@ -775,8 +778,55 @@ export async function getCockpitData(opts?: { customerIds?: number[] }): Promise // Gemeldete Zählerstände (REPORTED Status) const reportedReadings = await getReportedMeterReadings(opts?.customerIds); + // Filter „Kündigungsbestätigung vorhanden": alle Verträge mit + // cancellationConfirmationPath und/oder -Date in Status ACTIVE/DRAFT/ + // CANCELLED. Eigene Liste, weil solche Verträge (v.a. bereits CANCELLED) + // sonst mangels „Issue" nicht in der Cockpit-Liste auftauchen. + const CANCEL_CONF_STATUSES: ContractStatus[] = ['ACTIVE', 'DRAFT', 'CANCELLED']; + const cancellationConfirmations: CockpitContract[] = contracts + .filter( + (c) => + (c.cancellationConfirmationPath || c.cancellationConfirmationDate) && + CANCEL_CONF_STATUSES.includes(c.status), + ) + .map((c) => { + const customerName = + c.customer.companyName || `${c.customer.firstName} ${c.customer.lastName}`; + const dateStr = c.cancellationConfirmationDate + ? new Date(c.cancellationConfirmationDate).toLocaleDateString('de-DE') + : null; + const parts: string[] = []; + if (c.cancellationConfirmationPath) parts.push('Dokument'); + if (dateStr) parts.push(`Datum ${dateStr}`); + return { + id: c.id, + contractNumber: c.contractNumber, + type: c.type, + status: c.status, + customer: { + id: c.customer.id, + customerNumber: c.customer.customerNumber, + name: customerName, + }, + provider: c.provider ? { id: c.provider.id, name: c.provider.name } : undefined, + tariff: c.tariff ? { id: c.tariff.id, name: c.tariff.name } : undefined, + providerName: c.providerName || undefined, + tariffName: c.tariffName || undefined, + issues: [ + { + type: 'has_cancellation_confirmation', + label: 'Kündigungsbestätigung vorhanden', + urgency: 'none' as UrgencyLevel, + details: parts.join(' · ') || 'Kündigungsbestätigung hinterlegt', + }, + ], + highestUrgency: 'none' as UrgencyLevel, + }; + }); + return { contracts: cockpitContracts, + cancellationConfirmations, documentAlerts, reportedReadings, summary, diff --git a/backend/src/services/contractStatusScheduler.service.ts b/backend/src/services/contractStatusScheduler.service.ts index 145ab625..e2f9ae49 100644 --- a/backend/src/services/contractStatusScheduler.service.ts +++ b/backend/src/services/contractStatusScheduler.service.ts @@ -157,6 +157,70 @@ export async function withContractDocumentLock( * (SaveAttachmentModal / ContractDetail). Vergleich case-insensitive + * getrimmt zur Robustheit. */ +/** + * Wird aufgerufen, wenn zu einem Vertrag eine Kündigungsbestätigung + * hinzugefügt wird – entweder als Datum (`cancellationConfirmationDate`, + * z.B. über das Vertragsformular) und/oder als Dokument + * (`cancellationConfirmationPath`, Upload). Effekt: + * - Vertrag ACTIVE → CANCELLED (nur aus ACTIVE; andere Status werden + * bewusst nicht angetastet). + * - Berechnetes Vertragsende (`endDate`) = Kündigungs(bestätigungs)datum, + * sofern eines vorliegt. + * Idempotent: läuft nur, wenn tatsächlich eine Bestätigung vorhanden ist, + * und schreibt nur bei echten Änderungen. + */ +export async function maybeCancelOnCancellationConfirmation( + contractId: number, + req: unknown, +): Promise { + const c = await prisma.contract.findUnique({ + where: { id: contractId }, + select: { + status: true, + endDate: true, + contractNumber: true, + customerId: true, + cancellationConfirmationPath: true, + cancellationConfirmationDate: true, + }, + }); + if (!c) return; + + const hasConfirmation = !!c.cancellationConfirmationPath || !!c.cancellationConfirmationDate; + if (!hasConfirmation) return; + + const asDay = (d: Date | null | undefined) => + d ? new Date(d).toISOString().split('T')[0] : null; + + const updateData: Record = {}; + const changes: Record = {}; + + if (c.status === 'ACTIVE') { + updateData.status = 'CANCELLED'; + changes.status = { vorher: 'ACTIVE', nachher: 'CANCELLED' }; + } + + // Vertragsende = Kündigungsdatum (Bestätigungsdatum), falls vorhanden. + if (c.cancellationConfirmationDate && asDay(c.endDate) !== asDay(c.cancellationConfirmationDate)) { + updateData.endDate = c.cancellationConfirmationDate; + changes.endDate = { vorher: asDay(c.endDate), nachher: asDay(c.cancellationConfirmationDate) }; + } + + if (Object.keys(updateData).length === 0) return; + + await prisma.contract.update({ where: { id: contractId }, data: updateData }); + + await logChange({ + req, + action: 'UPDATE', + resourceType: 'Contract', + resourceId: contractId.toString(), + label: `Vertrag ${c.contractNumber} automatisch aktualisiert (Kündigungsbestätigung)`, + details: { ...changes, trigger: 'Kündigungsbestätigung' }, + customerId: c.customerId, + }); +} + export async function maybeActivateOnDeliveryConfirmation( contractId: number, documentType: string, diff --git a/docs/todo.md b/docs/todo.md index 6233ae38..bfbc100c 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,22 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🚫 Auto-Kündigung + Cockpit-Filter Kündigungsbestätigung** (2026-08-07) + - **1) Auto-Status bei Kündigungsbestätigung:** Wird zu einem Vertrag eine + Kündigungsbestätigung hinzugefügt – als **Datum** (`cancellationConfirmationDate` + übers Formular) und/oder als **Dokument** (Upload) – wird der Vertrag von + ACTIVE → **CANCELLED** gesetzt und das **Vertragsende = Kündigungsdatum**. + Zentrale Funktion `maybeCancelOnCancellationConfirmation` (idempotent, nur + aus ACTIVE, nur echte Änderungen); genutzt vom Upload (ersetzt die alte + Inline-Logik, jetzt auch endDate) und vom Update-Controller (nur bei + neu/geändertem Bestätigungsdatum, damit manuelle Status-Korrekturen nicht + zurückgesetzt werden). Verifiziert. + - **2) Cockpit-Filter „Kündigungsbestätigung":** listet alle Verträge mit + hinterlegter Kündigungsbestätigung (Dokument und/oder Datum) in Status + ACTIVE/DRAFT/CANCELLED. Eigene Liste `cancellationConfirmations` im + Cockpit-Result (weil bereits CANCELLED-Verträge mangels „Issue" sonst nicht + auftauchen) + neue Filter-Option im Cockpit-Dropdown. Verifiziert. + - [~] **🧾 Gutschriftsverwaltung (Subventionen am Vertrag) – Phase 1: Backend** (2026-08-06) - Use-Case: zu einem Vertrag kann eine Subvention gewährt werden – **Geld** (Überweisung, EUR) oder **Sachwert** (Smartphone/Elektro …, Textfeld). diff --git a/frontend/src/pages/contracts/ContractCockpit.tsx b/frontend/src/pages/contracts/ContractCockpit.tsx index a23d0f4e..fb88f1dc 100644 --- a/frontend/src/pages/contracts/ContractCockpit.tsx +++ b/frontend/src/pages/contracts/ContractCockpit.tsx @@ -112,7 +112,7 @@ const categoryLabels: Record = { missingConsents: 'Fehlende Einwilligungen', }; -type FilterType = 'all' | 'critical' | 'warning' | 'ok' | 'deadlines' | 'credentials' | 'data' | 'tasks' | 'review' | 'invoices' | 'consents'; +type FilterType = 'all' | 'critical' | 'warning' | 'ok' | 'deadlines' | 'credentials' | 'data' | 'tasks' | 'review' | 'invoices' | 'consents' | 'cancellation_confirmations'; export default function ContractCockpit() { const [searchParams, setSearchParams] = useSearchParams(); @@ -238,10 +238,14 @@ export default function ContractCockpit() { return contracts.filter(c => c.issues.some(i => ['missing_consents', 'consent_withdrawn'].includes(i.type)) ); + case 'cancellation_confirmations': + // Eigene Liste vom Backend (Verträge mit Kündigungsbestätigung in + // Status ACTIVE/DRAFT/CANCELLED), nicht die issue-basierte Liste. + return cockpitData?.data?.cancellationConfirmations ?? []; default: return contracts; } - }, [cockpitData?.data?.contracts, filter]); + }, [cockpitData?.data?.contracts, cockpitData?.data?.cancellationConfirmations, filter]); if (isLoading) { return ( @@ -742,8 +746,9 @@ export default function ContractCockpit() { { value: 'review', label: `Erneute Prüfung (${summary.byCategory.reviewDue || 0})` }, { value: 'invoices', label: `Fehlende Rechnungen (${summary.byCategory.missingInvoices || 0})` }, { value: 'consents', label: `Einwilligungen (${summary.byCategory.missingConsents || 0})` }, + { value: 'cancellation_confirmations', label: `Kündigungsbestätigung (${cockpitData.data.cancellationConfirmations?.length || 0})` }, ]} - className="w-64" + className="w-72" /> {filteredContracts.length} Verträge angezeigt diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 98eb4743..0f2f8f9d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -842,6 +842,7 @@ export interface ReportedMeterReading { export interface CockpitResult { contracts: CockpitContract[]; + cancellationConfirmations: CockpitContract[]; documentAlerts: DocumentAlert[]; reportedReadings: ReportedMeterReading[]; summary: CockpitSummary;