Files
opencrm/backend/src/services/contractCockpit.service.ts
T
duffyduckandClaude Opus 4.8 fc3131059b Neuer Vertragsstatus "Gekuendigt / bestaetigt" + Kuendigungs-Workflow
Bisheriges "Gekuendigt" (CANCELLED) umbenannt in "Gekuendigt / Bestaetigung
abwarten" und wird jetzt automatisch gesetzt, sobald ein Kuendigungsschreiben
hochgeladen wird. Neuer Status CANCELLED_CONFIRMED ("Gekuendigt / bestaetigt")
wird automatisch gesetzt, sobald ein Kuendigungsbestaetigungsdatum vorliegt
(Dokument fuellt das Datum oder manuell) + Vertragsende = Kuendigungsdatum.

Schema: Enum-Wert CANCELLED_CONFIRMED + Migration (idempotentes MODIFY COLUMN);
Daten-Migration hebt bestehende CANCELLED (alte Logik: nur bei Bestaetigung
gesetzt) auf CANCELLED_CONFIRMED.

Backend: neue Trigger maybeMarkAwaitingConfirmationOnLetter (Schreiben->CANCELLED)
im Upload-Handler; maybeCancelOnCancellationConfirmation setzt jetzt
CANCELLED_CONFIRMED (auch aus CANCELLED). Cockpit-Semantik mitgewandert
(Fristen-Skip/"beendet" fuer CANCELLED_CONFIRMED; Ladeliste + Kuendigungs-
bestaetigungs-Filter erweitert).

Frontend: Labels/Farben/Status-Erklaerungen + Status-Dropdown in ContractList,
ContractDetail, ContractForm, ContractDetailModal, CustomerDetail
(CANCELLED orange "abwarten", CANCELLED_CONFIRMED rot).

Verifiziert: tsc+build gruen; Schreiben->CANCELLED, Bestaetigung->
CANCELLED_CONFIRMED+Enddatum; Daten-Migration idempotent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 18:14:34 +02:00

966 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ContractStatus } from '@prisma/client';
import prisma from '../lib/prisma.js';
import * as appSettingService from './appSetting.service.js';
// Typen für das Cockpit
export type UrgencyLevel = 'critical' | 'warning' | 'ok' | 'none';
export interface CockpitIssue {
type: string;
label: string;
urgency: UrgencyLevel;
daysRemaining?: number;
details?: string;
}
export interface CockpitContract {
id: number;
contractNumber: string;
type: string;
status: ContractStatus;
customer: {
id: number;
customerNumber: string;
name: string;
};
provider?: {
id: number;
name: string;
};
tariff?: {
id: number;
name: string;
};
providerName?: string;
tariffName?: string;
issues: CockpitIssue[];
highestUrgency: UrgencyLevel;
}
export interface CockpitSummary {
totalContracts: number;
criticalCount: number;
warningCount: number;
okCount: number;
byCategory: {
cancellationDeadlines: number;
contractEnding: number;
missingCredentials: number;
missingData: number;
missingInvoices: number;
openTasks: number;
pendingContracts: number;
reviewDue: number; // Erneute Prüfung fällig (Snooze abgelaufen)
missingConsents: number; // Fehlende oder widerrufene Einwilligungen
};
}
export interface DocumentAlert {
id: number;
type: string; // ID_CARD, PASSPORT, DRIVERS_LICENSE, OTHER
documentNumber: string;
expiryDate: string;
daysUntilExpiry: number;
urgency: UrgencyLevel;
customer: {
id: number;
customerNumber: string;
name: string;
};
}
export interface ReportedMeterReading {
id: number;
readingDate: string;
value: number;
unit: string;
notes?: string;
reportedBy?: string;
createdAt: string;
meter: {
id: number;
meterNumber: string;
type: string;
};
customer: {
id: number;
customerNumber: string;
name: string;
};
// Zugehöriger Vertrag
contract?: {
id: number;
contractNumber: string;
};
// Anbieter-Info für Quick-Login
providerPortal?: {
providerName: string;
portalUrl: string;
portalUsername?: string;
};
}
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;
thresholds: {
criticalDays: number;
warningDays: number;
okDays: number;
};
}
// Hilfsfunktion: Tage bis zu einem Datum berechnen
function daysUntil(date: Date | null | undefined): number | null {
if (!date) return null;
const now = new Date();
now.setHours(0, 0, 0, 0);
const target = new Date(date);
target.setHours(0, 0, 0, 0);
const diff = target.getTime() - now.getTime();
return Math.ceil(diff / (1000 * 60 * 60 * 24));
}
// Hilfsfunktion: Urgency basierend auf Tagen bestimmen
function getUrgencyByDays(
daysRemaining: number | null,
criticalDays: number,
warningDays: number,
okDays: number
): UrgencyLevel {
if (daysRemaining === null) return 'none';
if (daysRemaining < 0) return 'critical'; // Bereits überfällig
if (daysRemaining <= criticalDays) return 'critical';
if (daysRemaining <= warningDays) return 'warning';
if (daysRemaining <= okDays) return 'ok';
return 'none';
}
// Hilfsfunktion: Höchste Dringlichkeit ermitteln
function getHighestUrgency(issues: CockpitIssue[]): UrgencyLevel {
const levels: UrgencyLevel[] = ['critical', 'warning', 'ok', 'none'];
for (const level of levels) {
if (issues.some(i => i.urgency === level)) {
return level;
}
}
return 'none';
}
// Kündigungsfrist berechnen
function calculateCancellationDeadline(
endDate: Date | null | undefined,
cancellationPeriodCode: string | null | undefined
): Date | null {
if (!endDate || !cancellationPeriodCode) return null;
const end = new Date(endDate);
// Parse Kündigungsperiode (z.B. "1M" = 1 Monat, "6W" = 6 Wochen, "14D" = 14 Tage)
const match = cancellationPeriodCode.match(/^(\d+)([DMWY])$/i);
if (!match) return null;
const amount = parseInt(match[1]);
const unit = match[2].toUpperCase();
switch (unit) {
case 'D':
end.setDate(end.getDate() - amount);
break;
case 'W':
end.setDate(end.getDate() - (amount * 7));
break;
case 'M':
end.setMonth(end.getMonth() - amount);
break;
case 'Y':
end.setFullYear(end.getFullYear() - amount);
break;
}
return end;
}
export async function getCockpitData(opts?: { customerIds?: number[] }): Promise<CockpitResult> {
// Lade Einstellungen
const settings = await appSettingService.getAllSettings();
const criticalDays = parseInt(settings.deadlineCriticalDays) || 14;
const warningDays = parseInt(settings.deadlineWarningDays) || 42;
const okDays = parseInt(settings.deadlineOkDays) || 90;
const docExpiryCriticalDays = parseInt(settings.documentExpiryCriticalDays) || 30;
const docExpiryWarningDays = parseInt(settings.documentExpiryWarningDays) || 90;
// Portal-Filter: Wenn customerIds gesetzt sind (Kundenportal-User), beschränken
// wir ALLE Cockpit-Queries auf diese Customer-IDs. Leeres Array → keine Treffer.
const customerScopeFilter = opts?.customerIds
? { customerId: { in: opts.customerIds } }
: {};
// Lade alle relevanten Verträge (inkl. CANCELLED/DEACTIVATED für
// Schlussrechnung-Check, inkl. ONGOING damit DSGVO-/Consent-Warnungen
// weiter greifen Fristen-Blöcke werden pro Vertrag geskippt, s.u.).
const contracts = await prisma.contract.findMany({
where: {
status: {
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'CANCELLED_CONFIRMED', 'DEACTIVATED', 'EXPIRED', 'ONGOING'],
},
...customerScopeFilter,
},
include: {
customer: {
select: {
id: true,
customerNumber: true,
firstName: true,
lastName: true,
companyName: true,
},
},
provider: {
select: {
id: true,
name: true,
},
},
tariff: {
select: {
id: true,
name: true,
},
},
cancellationPeriod: {
select: {
code: true,
},
},
address: true,
bankCard: true,
identityDocument: true,
energyDetails: {
include: {
meter: true,
invoices: true,
},
},
internetDetails: {
include: {
phoneNumbers: true,
},
},
mobileDetails: {
include: {
simCards: true,
},
},
tasks: {
where: {
status: 'OPEN',
},
},
// Folgevertrag laden um zu prüfen ob dieser aktiv ist
followUpContract: {
select: {
id: true,
status: true,
},
},
},
orderBy: [
{ endDate: 'asc' },
{ createdAt: 'desc' },
],
});
const cockpitContracts: CockpitContract[] = [];
const summary: CockpitSummary = {
totalContracts: 0,
criticalCount: 0,
warningCount: 0,
okCount: 0,
byCategory: {
cancellationDeadlines: 0,
contractEnding: 0,
missingCredentials: 0,
missingData: 0,
missingInvoices: 0,
openTasks: 0,
pendingContracts: 0,
reviewDue: 0,
missingConsents: 0,
},
};
// Consent-Daten batch-laden für alle (erlaubten) Kunden
const allConsents = await prisma.customerConsent.findMany({
where: { status: 'GRANTED', ...customerScopeFilter },
select: { customerId: true, consentType: true },
});
// Map: customerId → Set<consentType>
const grantedConsentsMap = new Map<number, Set<string>>();
for (const c of allConsents) {
if (!grantedConsentsMap.has(c.customerId)) {
grantedConsentsMap.set(c.customerId, new Set());
}
grantedConsentsMap.get(c.customerId)!.add(c.consentType);
}
// Widerrufene Consents laden
const withdrawnConsents = await prisma.customerConsent.findMany({
where: { status: 'WITHDRAWN', ...customerScopeFilter },
select: { customerId: true, consentType: true },
});
const withdrawnConsentsMap = new Map<number, Set<string>>();
for (const c of withdrawnConsents) {
if (!withdrawnConsentsMap.has(c.customerId)) {
withdrawnConsentsMap.set(c.customerId, new Set());
}
withdrawnConsentsMap.get(c.customerId)!.add(c.consentType);
}
// Track welche Kunden bereits eine Consent-Warnung bekommen haben (nur einmal pro Kunde)
const customerConsentWarned = new Set<number>();
for (const contract of contracts) {
const issues: CockpitIssue[] = [];
// SNOOZE-LOGIK: Prüfen ob Snooze aktiv ist (für Fristen-Unterdrückung)
let snoozeActive = false;
if (contract.nextReviewDate) {
const reviewDate = new Date(contract.nextReviewDate);
const now = new Date();
now.setHours(0, 0, 0, 0);
reviewDate.setHours(0, 0, 0, 0);
if (reviewDate > now) {
// Snooze aktiv → NUR Fristen-Warnungen unterdrücken, andere Prüfungen laufen weiter
snoozeActive = true;
} else {
// Snooze abgelaufen → "Erneute Prüfung fällig" Warnung
const daysSince = Math.floor((now.getTime() - reviewDate.getTime()) / (1000 * 60 * 60 * 24));
issues.push({
type: 'review_due',
label: 'Erneute Prüfung fällig',
urgency: daysSince > 30 ? 'critical' : 'warning',
daysRemaining: -daysSince,
details: daysSince === 0
? 'Heute zur Prüfung fällig'
: `Zur Prüfung seit ${daysSince} Tagen fällig`,
});
summary.byCategory.reviewDue++;
}
}
// Prüfen ob aktiver Folgevertrag existiert - dann keine Kündigungswarnungen nötig
const hasActiveFollowUp = contract.followUpContract?.status === 'ACTIVE';
// Fristen-Blöcke pro Vertrag skippen für Status, bei denen laufende
// Fristen semantisch keinen Sinn ergeben:
// - ONGOING : "Läuft weiter auf unbestimmte Zeit" → kein Ende
// - DEACTIVATED : manuell beendet/archiviert → nichts mehr zu erinnern
// - CANCELLED_CONFIRMED : Kündigung vom Anbieter bestätigt →
// Vertragsende steht fest, keine Frist mehr.
// (CANCELLED = nur "Bestätigung abwarten" skippt NICHT,
// damit die "Kündigungsbestätigung fehlt"-Warnung greift.)
// Die Schlussrechnung-Fehlt-Warnung (Block 13a) bleibt aktiv, weil
// sie WEGEN dieser Status feuert (kein separater Guard). DSGVO-/
// Consent-Checks (Block 8) sind pro Kunde, nicht pro Vertrag, und
// laufen ebenfalls weiter.
// User-Wunsch 2026-07-13: DEACTIVATED-Vertrag zeigte im Cockpit
// "Vertrag seit 79 Tagen abgelaufen!" sinnlos für einen Vertrag,
// der vom Mitarbeiter aktiv archiviert wurde.
const skipDeadlines =
contract.status === 'ONGOING'
|| contract.status === 'DEACTIVATED'
|| contract.status === 'CANCELLED_CONFIRMED';
// 1. KÜNDIGUNGSFRIST (nur wenn kein aktiver Folgevertrag UND Snooze nicht aktiv)
// Snooze unterdrückt NUR Fristen-bezogene Warnungen!
if (!hasActiveFollowUp && !snoozeActive && !skipDeadlines) {
const cancellationDeadline = calculateCancellationDeadline(
contract.endDate,
contract.cancellationPeriod?.code
);
const daysToCancellation = daysUntil(cancellationDeadline);
if (daysToCancellation !== null && daysToCancellation <= okDays) {
const urgency = getUrgencyByDays(daysToCancellation, criticalDays, warningDays, okDays);
if (urgency !== 'none') {
issues.push({
type: 'cancellation_deadline',
label: 'Kündigungsfrist',
urgency,
daysRemaining: daysToCancellation,
details: daysToCancellation < 0
? `Frist seit ${Math.abs(daysToCancellation)} Tagen überschritten!`
: `Noch ${daysToCancellation} Tage bis zur Kündigungsfrist`,
});
summary.byCategory.cancellationDeadlines++;
}
// 1a. KÜNDIGUNG NICHT GESENDET (wenn Frist naht)
if (!contract.cancellationLetterPath) {
issues.push({
type: 'missing_cancellation_letter',
label: 'Kündigung nicht gesendet',
urgency,
details: 'Kündigungsschreiben wurde noch nicht hochgeladen',
});
summary.byCategory.missingData++;
}
// 1b. KÜNDIGUNGSBESTÄTIGUNG FEHLT (wenn Kündigung gesendet aber keine Bestätigung)
if (contract.cancellationLetterPath && !contract.cancellationConfirmationPath && !contract.cancellationConfirmationDate) {
issues.push({
type: 'missing_cancellation_confirmation',
label: 'Kündigungsbestätigung fehlt',
urgency: urgency === 'critical' ? 'critical' : 'warning',
details: 'Kündigungsbestätigung vom Anbieter wurde noch nicht erhalten',
});
summary.byCategory.missingData++;
}
}
}
// 2. VERTRAGSENDE (nur wenn Snooze nicht aktiv, und nicht ONGOING)
// Snooze unterdrückt NUR Fristen-bezogene Warnungen!
if (!snoozeActive && !skipDeadlines) {
const daysToEnd = daysUntil(contract.endDate);
if (daysToEnd !== null && daysToEnd <= okDays) {
const urgency = getUrgencyByDays(daysToEnd, criticalDays, warningDays, okDays);
if (urgency !== 'none') {
issues.push({
type: 'contract_ending',
label: 'Vertragsende',
urgency,
daysRemaining: daysToEnd,
details: daysToEnd < 0
? `Vertrag seit ${Math.abs(daysToEnd)} Tagen abgelaufen!`
: `Noch ${daysToEnd} Tage bis Vertragsende`,
});
summary.byCategory.contractEnding++;
}
}
}
// 3. FEHLENDE PORTAL-ZUGANGSDATEN
// Benutzername kann entweder manuell (portalUsername) oder via Stressfrei-Wechseln E-Mail (stressfreiEmailId) gesetzt sein.
// Vertragsseitiges Opt-out: wenn der Anbieter kein Portal hat oder der
// Kunde bewusst keine Zugangsdaten pflegt, unterdrückt
// `portalCredentialsNotRequired` die Warnung. So verstopft die Zeile
// nicht dauerhaft das Cockpit.
if (!contract.portalCredentialsNotRequired) {
const hasUsername = contract.portalUsername || contract.stressfreiEmailId;
const hasPassword = contract.portalPasswordEncrypted;
if (!hasUsername || !hasPassword) {
issues.push({
type: 'missing_portal_credentials',
label: 'Portal-Zugangsdaten fehlen',
urgency: 'warning',
details: 'Benutzername oder Passwort für das Anbieter-Portal fehlt',
});
summary.byCategory.missingCredentials++;
}
}
// 4. KEINE KUNDENNUMMER BEIM ANBIETER
if (!contract.customerNumberAtProvider) {
issues.push({
type: 'missing_customer_number',
label: 'Kundennummer fehlt',
urgency: 'warning',
details: 'Kundennummer beim Anbieter fehlt',
});
summary.byCategory.missingData++;
}
// 4b. KEINE VERTRAGSNUMMER BEIM ANBIETER
if (!contract.contractNumberAtProvider) {
issues.push({
type: 'missing_contract_number',
label: 'Vertragsnummer fehlt',
urgency: 'warning',
details: 'Vertragsnummer beim Anbieter fehlt',
});
summary.byCategory.missingData++;
}
// 5. KEIN ANBIETER/TARIF
if (!contract.providerId && !contract.providerName) {
issues.push({
type: 'missing_provider',
label: 'Anbieter fehlt',
urgency: 'warning',
details: 'Kein Anbieter ausgewählt',
});
summary.byCategory.missingData++;
}
// 6. KEINE ADRESSE
if (!contract.addressId) {
issues.push({
type: 'missing_address',
label: 'Adresse fehlt',
urgency: 'warning',
details: 'Keine Lieferadresse verknüpft',
});
summary.byCategory.missingData++;
}
// 7. KEINE BANKVERBINDUNG
// Für DSL, FIBER, CABLE, MOBILE ist dies ein kritisches Problem
const requiresBankAndId = ['DSL', 'FIBER', 'CABLE', 'MOBILE'].includes(contract.type);
if (!contract.bankCardId) {
issues.push({
type: 'missing_bank',
label: 'Bankverbindung fehlt',
urgency: requiresBankAndId ? 'critical' : 'warning',
details: 'Keine Bankverbindung verknüpft',
});
summary.byCategory.missingData++;
}
// 7b. KEIN AUSWEIS nur Mobilfunk. Bei Festnetz/Internet (DSL, FIBER,
// CABLE) verlangt der Anbieter beim Auftrag keinen Ausweis, die
// Warnung ist da nur Rauschen. Mobile bleibt drin, weil dort echte
// Identitätsfeststellung Pflicht ist.
const requiresIdentityDocument = contract.type === 'MOBILE';
if (requiresIdentityDocument && !contract.identityDocumentId) {
issues.push({
type: 'missing_identity_document',
label: 'Ausweis fehlt',
urgency: 'critical',
details: 'Kein Ausweisdokument verknüpft',
});
summary.byCategory.missingData++;
}
// 7c. AUSWEIS LÄUFT AB (nur aktive Ausweise prüfen)
if (contract.identityDocument && contract.identityDocument.isActive && contract.identityDocument.expiryDate) {
const expiryDate = new Date(contract.identityDocument.expiryDate);
const today = new Date();
const daysUntilExpiry = Math.ceil((expiryDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (daysUntilExpiry < 0) {
issues.push({
type: 'identity_document_expired',
label: 'Ausweis abgelaufen',
urgency: 'critical',
details: `Ausweis seit ${Math.abs(daysUntilExpiry)} Tagen abgelaufen (${expiryDate.toLocaleDateString('de-DE')})`,
});
summary.byCategory.missingData++;
} else if (daysUntilExpiry <= docExpiryWarningDays) {
issues.push({
type: 'identity_document_expiring',
label: 'Ausweis läuft ab',
urgency: daysUntilExpiry <= docExpiryCriticalDays ? 'critical' : 'warning',
details: `Ausweis läuft in ${daysUntilExpiry} Tagen ab (${expiryDate.toLocaleDateString('de-DE')})`,
});
summary.byCategory.cancellationDeadlines++;
}
}
// 8. ENERGIE-SPEZIFISCH: KEIN ZÄHLER
if (['ELECTRICITY', 'GAS'].includes(contract.type) && contract.energyDetails) {
if (!contract.energyDetails.meterId) {
issues.push({
type: 'missing_meter',
label: 'Zähler fehlt',
urgency: 'warning',
details: 'Kein Zähler verknüpft',
});
summary.byCategory.missingData++;
}
}
// 9. MOBIL-SPEZIFISCH: SIM-KARTEN
if (contract.type === 'MOBILE' && contract.mobileDetails) {
if (!contract.mobileDetails.simCards || contract.mobileDetails.simCards.length === 0) {
issues.push({
type: 'missing_sim',
label: 'SIM-Karte fehlt',
urgency: 'warning',
details: 'Keine SIM-Karte eingetragen',
});
summary.byCategory.missingData++;
}
}
// 10. OFFENE AUFGABEN
if (contract.tasks && contract.tasks.length > 0) {
issues.push({
type: 'open_tasks',
label: 'Offene Aufgaben',
urgency: 'ok',
details: `${contract.tasks.length} offene Aufgabe(n)`,
});
summary.byCategory.openTasks++;
}
// 11. PENDING STATUS
if (contract.status === 'PENDING') {
issues.push({
type: 'pending_status',
label: 'Warte auf Aktivierung',
urgency: 'ok',
details: 'Vertrag noch nicht aktiv',
});
summary.byCategory.pendingContracts++;
}
// 12. DRAFT STATUS
if (contract.status === 'DRAFT') {
issues.push({
type: 'draft_status',
label: 'Entwurf',
urgency: 'warning',
details: 'Vertrag ist noch ein Entwurf',
});
summary.byCategory.pendingContracts++;
}
// 13. ENERGIE-RECHNUNGEN (nur für ELECTRICITY und GAS)
if (['ELECTRICITY', 'GAS'].includes(contract.type) && contract.energyDetails) {
const invoices = contract.energyDetails.invoices || [];
const now = new Date();
now.setHours(0, 0, 0, 0);
// 13a. SCHLUSSRECHNUNG FEHLT (nur wenn Vertrag bestätigt gekündigt/deaktiviert)
// "Beendet" = CANCELLED_CONFIRMED (Kündigung bestätigt) oder DEACTIVATED
// (nicht nur Laufzeit abgelaufen; NICHT "Bestätigung abwarten"/CANCELLED,
// da dort das Vertragsende noch nicht feststeht).
const isContractTerminated = contract.status === 'CANCELLED_CONFIRMED' || contract.status === 'DEACTIVATED';
if (isContractTerminated) {
const hasFinalInvoice = invoices.some(inv => inv.invoiceType === 'FINAL');
const hasNotAvailable = invoices.some(inv => inv.invoiceType === 'NOT_AVAILABLE');
if (!hasFinalInvoice && !hasNotAvailable) {
issues.push({
type: 'missing_final_invoice',
label: 'Schlussrechnung fehlt',
urgency: 'warning',
details: 'Vertrag gekündigt/deaktiviert, aber keine Schlussrechnung vorhanden',
});
summary.byCategory.missingInvoices++;
}
}
// 13b. ZWISCHENRECHNUNG FEHLT/ÜBERFÄLLIG (wenn Vertrag > 12 Monate läuft)
// Für alle Status außer DRAFT und nicht gekündigt/deaktiviert
// Auch EXPIRED zählt hier, da der Vertrag ohne Kündigung weiterläuft!
// ONGOING wird ebenfalls ausgeschlossen ist ein Fristen-Check.
if (contract.startDate && contract.status !== 'DRAFT' && !isContractTerminated && !skipDeadlines) {
const startDate = new Date(contract.startDate);
startDate.setHours(0, 0, 0, 0);
const daysSinceStart = Math.floor((now.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysSinceStart > 365) {
// Vertrag läuft > 12 Monate
if (invoices.length === 0) {
// Keine Rechnungen vorhanden
issues.push({
type: 'missing_interim_invoice',
label: 'Zwischenrechnung fehlt',
urgency: 'warning',
details: 'Vertrag läuft über 12 Monate ohne Rechnung',
});
summary.byCategory.missingInvoices++;
} else {
// Prüfen ob letzte Rechnung > 12 Monate alt
const latestInvoice = invoices
.filter(inv => inv.invoiceType !== 'NOT_AVAILABLE')
.sort((a, b) => new Date(b.invoiceDate).getTime() - new Date(a.invoiceDate).getTime())[0];
if (latestInvoice) {
const invoiceDate = new Date(latestInvoice.invoiceDate);
const daysSinceInvoice = Math.floor((now.getTime() - invoiceDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysSinceInvoice > 365) {
issues.push({
type: 'overdue_interim_invoice',
label: 'Zwischenrechnung überfällig',
urgency: 'warning',
details: `Letzte Rechnung vor ${Math.floor(daysSinceInvoice / 30)} Monaten`,
});
summary.byCategory.missingInvoices++;
}
}
}
}
}
}
// #14 - Consent-Prüfung (nur für aktive Verträge, einmal pro Kunde)
if (['ACTIVE', 'PENDING', 'DRAFT'].includes(contract.status) && !customerConsentWarned.has(contract.customer.id)) {
const granted = grantedConsentsMap.get(contract.customer.id);
const withdrawn = withdrawnConsentsMap.get(contract.customer.id);
const requiredTypes = ['DATA_PROCESSING', 'MARKETING_EMAIL', 'MARKETING_PHONE', 'DATA_SHARING_PARTNER'];
if (withdrawn && withdrawn.size > 0) {
// Mindestens eine Einwilligung widerrufen
issues.push({
type: 'consent_withdrawn',
label: 'Einwilligung widerrufen',
urgency: 'critical',
details: `${withdrawn.size} Einwilligung(en) widerrufen`,
});
summary.byCategory.missingConsents++;
customerConsentWarned.add(contract.customer.id);
} else if (!granted || granted.size < requiredTypes.length) {
// Nicht alle 4 Einwilligungen erteilt
const missing = requiredTypes.length - (granted?.size || 0);
issues.push({
type: 'missing_consents',
label: 'Fehlende Einwilligungen',
urgency: 'critical',
details: `${missing} von ${requiredTypes.length} Einwilligungen fehlen`,
});
summary.byCategory.missingConsents++;
customerConsentWarned.add(contract.customer.id);
}
}
// Nur Verträge mit Issues hinzufügen
if (issues.length > 0) {
const highestUrgency = getHighestUrgency(issues);
const customerName = contract.customer.companyName ||
`${contract.customer.firstName} ${contract.customer.lastName}`;
cockpitContracts.push({
id: contract.id,
contractNumber: contract.contractNumber,
type: contract.type,
status: contract.status,
customer: {
id: contract.customer.id,
customerNumber: contract.customer.customerNumber,
name: customerName,
},
provider: contract.provider ? {
id: contract.provider.id,
name: contract.provider.name,
} : undefined,
tariff: contract.tariff ? {
id: contract.tariff.id,
name: contract.tariff.name,
} : undefined,
providerName: contract.providerName || undefined,
tariffName: contract.tariffName || undefined,
issues,
highestUrgency,
});
// Summary zählen
summary.totalContracts++;
if (highestUrgency === 'critical') summary.criticalCount++;
else if (highestUrgency === 'warning') summary.warningCount++;
else if (highestUrgency === 'ok') summary.okCount++;
}
}
// Sortiere nach Dringlichkeit
cockpitContracts.sort((a, b) => {
const urgencyOrder: Record<UrgencyLevel, number> = {
critical: 0,
warning: 1,
ok: 2,
none: 3,
};
return urgencyOrder[a.highestUrgency] - urgencyOrder[b.highestUrgency];
});
// Vertragsunabhängige Ausweis-Warnungen
const documentAlerts = await getDocumentExpiryAlerts(docExpiryCriticalDays, docExpiryWarningDays, opts?.customerIds);
// 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/EXPIRED. Eigene Liste, weil solche Verträge (v.a. bereits
// CANCELLED/EXPIRED) sonst mangels „Issue" nicht in der Cockpit-Liste
// auftauchen.
const CANCEL_CONF_STATUSES: ContractStatus[] = ['ACTIVE', 'DRAFT', 'CANCELLED', 'CANCELLED_CONFIRMED', 'EXPIRED'];
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,
thresholds: {
criticalDays,
warningDays,
okDays,
},
};
}
/**
* Alle aktiven Ausweise die ablaufen oder abgelaufen sind (vertragsunabhängig)
*/
async function getDocumentExpiryAlerts(
criticalDays: number,
warningDays: number,
customerIds?: number[],
): Promise<DocumentAlert[]> {
const now = new Date();
const inWarningDays = new Date(now.getTime() + warningDays * 24 * 60 * 60 * 1000);
const documents = await prisma.identityDocument.findMany({
where: {
isActive: true,
expiryDate: { lte: inWarningDays },
...(customerIds ? { customerId: { in: customerIds } } : {}),
},
include: {
customer: {
select: { id: true, customerNumber: true, firstName: true, lastName: true },
},
},
orderBy: { expiryDate: 'asc' },
});
return documents.map((doc) => {
const expiryDate = new Date(doc.expiryDate!);
const daysUntilExpiry = Math.ceil((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
let urgency: UrgencyLevel = 'warning';
if (daysUntilExpiry < 0) urgency = 'critical';
else if (daysUntilExpiry <= criticalDays) urgency = 'critical';
return {
id: doc.id,
type: doc.type,
documentNumber: doc.documentNumber,
expiryDate: expiryDate.toISOString(),
daysUntilExpiry,
urgency,
customer: {
id: doc.customer.id,
customerNumber: doc.customer.customerNumber,
name: `${doc.customer.firstName} ${doc.customer.lastName}`,
},
};
});
}
/**
* Vom Kunden gemeldete Zählerstände die noch nicht übertragen wurden
*/
async function getReportedMeterReadings(customerIds?: number[]): Promise<ReportedMeterReading[]> {
const readings = await prisma.meterReading.findMany({
where: {
status: 'REPORTED',
...(customerIds ? { meter: { customerId: { in: customerIds } } } : {}),
},
include: {
meter: {
include: {
customer: {
select: { id: true, customerNumber: true, firstName: true, lastName: true },
},
// Energie-Verträge für diesen Zähler (um Provider-Portal-Daten zu bekommen)
energyDetails: {
include: {
contract: {
select: {
id: true,
contractNumber: true,
portalUsername: true,
provider: {
select: { id: true, name: true, portalUrl: true },
},
},
},
},
take: 1,
},
},
},
},
orderBy: { createdAt: 'asc' },
});
return readings.map((r) => {
const contract = r.meter.energyDetails?.[0]?.contract;
const provider = contract?.provider;
return {
id: r.id,
readingDate: r.readingDate.toISOString(),
value: r.value,
unit: r.unit,
notes: r.notes ?? undefined,
reportedBy: r.reportedBy ?? undefined,
createdAt: r.createdAt.toISOString(),
meter: {
id: r.meter.id,
meterNumber: r.meter.meterNumber,
type: r.meter.type,
},
customer: {
id: r.meter.customer.id,
customerNumber: r.meter.customer.customerNumber,
name: `${r.meter.customer.firstName} ${r.meter.customer.lastName}`,
},
contract: contract ? {
id: contract.id,
contractNumber: contract.contractNumber,
} : undefined,
providerPortal: provider?.portalUrl ? {
providerName: provider.name,
portalUrl: provider.portalUrl,
portalUsername: contract?.portalUsername ?? undefined,
} : undefined,
};
});
}