da1934aa2d
Bei Festnetz/Internet-Verträgen (DSL, FIBER, CABLE) verlangt der Anbieter beim Auftrag keinen Ausweis – die Cockpit-Warnung "Ausweis fehlt" war dort nur Rauschen. Mobile bleibt drin, weil für SIM-Kartenausgabe echte Identitätsfeststellung Pflicht ist. Die "Ausweis läuft ab"-Warnung bleibt unverändert: sie greift nur, wenn ein Ausweis verknüpft ist, und ist damit für alle Vertragstypen sinnvoll (wenn schon ein Ausweis dranhängt, will der User auch über den Ablauf informiert werden). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
884 lines
28 KiB
TypeScript
884 lines
28 KiB
TypeScript
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[];
|
||
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)
|
||
const contracts = await prisma.contract.findMany({
|
||
where: {
|
||
status: {
|
||
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'DEACTIVATED', 'EXPIRED'],
|
||
},
|
||
...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';
|
||
|
||
// 1. KÜNDIGUNGSFRIST (nur wenn kein aktiver Folgevertrag UND Snooze nicht aktiv)
|
||
// Snooze unterdrückt NUR Fristen-bezogene Warnungen!
|
||
if (!hasActiveFollowUp && !snoozeActive) {
|
||
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)
|
||
// Snooze unterdrückt NUR Fristen-bezogene Warnungen!
|
||
if (!snoozeActive) {
|
||
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
|
||
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 gekündigt/deaktiviert ist)
|
||
// "Beendet" = CANCELLED oder DEACTIVATED (nicht nur Laufzeit abgelaufen!)
|
||
const isContractTerminated = contract.status === 'CANCELLED' || 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!
|
||
if (contract.startDate && contract.status !== 'DRAFT' && !isContractTerminated) {
|
||
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);
|
||
|
||
return {
|
||
contracts: cockpitContracts,
|
||
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,
|
||
};
|
||
});
|
||
}
|