Files
opencrm/backend/src/services/contractCockpit.service.ts
T
duffyduck da1934aa2d Cockpit: "Ausweis fehlt" nur noch bei Mobilfunk
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>
2026-06-01 19:19:54 +02:00

884 lines
28 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[];
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,
};
});
}