first commit
This commit is contained in:
@@ -0,0 +1,475 @@
|
||||
import { PrismaClient, ContractStatus
|
||||
} from '@prisma/client';
|
||||
import * as appSettingService from './appSetting.service.js';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// 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;
|
||||
openTasks: number;
|
||||
pendingContracts: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CockpitResult {
|
||||
contracts: CockpitContract[];
|
||||
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(): 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;
|
||||
|
||||
// Lade alle aktiven/pending Verträge mit allen relevanten Daten
|
||||
const contracts = await prisma.contract.findMany({
|
||||
where: {
|
||||
status: {
|
||||
in: ['ACTIVE', 'PENDING', 'DRAFT'],
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
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,
|
||||
openTasks: 0,
|
||||
pendingContracts: 0,
|
||||
},
|
||||
};
|
||||
|
||||
for (const contract of contracts) {
|
||||
const issues: CockpitIssue[] = [];
|
||||
|
||||
// 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)
|
||||
if (!hasActiveFollowUp) {
|
||||
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
|
||||
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
|
||||
if (!contract.portalUsername || !contract.portalPasswordEncrypted) {
|
||||
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++;
|
||||
}
|
||||
|
||||
// 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
|
||||
if (!contract.bankCardId) {
|
||||
issues.push({
|
||||
type: 'missing_bank',
|
||||
label: 'Bankverbindung fehlt',
|
||||
urgency: 'warning',
|
||||
details: 'Keine Bankverbindung verknüpft',
|
||||
});
|
||||
summary.byCategory.missingData++;
|
||||
}
|
||||
|
||||
// 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++;
|
||||
}
|
||||
|
||||
// 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];
|
||||
});
|
||||
|
||||
return {
|
||||
contracts: cockpitContracts,
|
||||
summary,
|
||||
thresholds: {
|
||||
criticalDays,
|
||||
warningDays,
|
||||
okDays,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user