/** * Kennzahlen-Berechnungen. * * Alle Funktionen sind pure functions über dem gefilterten Datensatz * (Anforderung INFRA-06): die Entwicklung kann sie unverändert übernehmen * und lediglich die Datenquelle austauschen. * * Die Definition jeder Kennzahl steht in docs/KPI-KATALOG.md. */ import { AUFTRAG_STATUS_LABEL, FUNNEL_STUFEN, RECHNUNG_STATUS_LABEL, type Auftrag, type AuftragStatus, type ChartPunkt, type Datensatz, type Kunde, type Rechnung, FOERDERFAEHIGE_KATEGORIEN, type ZeitraumPreset, type ZeitreihePunkt, type Zeiteintrag, type Mitarbeiter, } from "./types"; import { HEUTE, bucketKey, bucketsFuer, formatWoche, imFenster, vorperiode, zeitfenster, addMonate, } from "../utils/dateUtils"; import { formatDatumKurz, formatMonatKurz, tageSeit } from "../utils/formatters"; /* ------------------------------------------------------------- Umsatz */ /** Umsatz = Summe bezahlter Ausgangsrechnungen im Zeitraum (Zahlungseingang). */ export function umsatz(rechnungen: Rechnung[], zeitraum: ZeitraumPreset): number { const fenster = zeitfenster(zeitraum); return rechnungen .filter((r) => r.status === "bezahlt" && imFenster(r.bezahltAm, fenster)) .reduce((summe, r) => summe + r.betrag, 0); } /** Gleiche Berechnung für die Vorperiode: Basis der Trend-Badges. */ export function umsatzVorperiode(rechnungen: Rechnung[], zeitraum: ZeitraumPreset): number { const fenster = vorperiode(zeitraum); return rechnungen .filter((r) => r.status === "bezahlt" && imFenster(r.bezahltAm, fenster)) .reduce((summe, r) => summe + r.betrag, 0); } /** Beschriftung eines Buckets, eine Stelle fuer alle drei Zeitreihen. */ function bucketLabel(key: string, art: "tag" | "woche" | "monat"): string { if (art === "monat") return formatMonatKurz(key); if (art === "woche") return formatWoche(key); return formatDatumKurz(key); } /** * Umsatzverlauf mit Vorjahresvergleich. * Buckets richten sich nach dem Zeitraum (Tage bei ≤ 30 Tagen, sonst Monate). */ export function umsatzVerlauf( rechnungen: Rechnung[], zeitraum: ZeitraumPreset ): ZeitreihePunkt[] { const { art, schluessel } = bucketsFuer(zeitraum); const aktuell = new Map(); const vorjahr = new Map(); for (const r of rechnungen) { if (r.status !== "bezahlt" || !r.bezahltAm) continue; aktuell.set( bucketKey(r.bezahltAm, art), (aktuell.get(bucketKey(r.bezahltAm, art)) ?? 0) + r.betrag ); // Vorjahreswert: dasselbe Datum plus 12 Monate landet im aktuellen Bucket. const verschoben = addMonate(new Date(r.bezahltAm), 12); const key = bucketKey(verschoben.toISOString().slice(0, 10), art); vorjahr.set(key, (vorjahr.get(key) ?? 0) + r.betrag); } return schluessel.map((key) => ({ datum: key, label: bucketLabel(key, art), value: aktuell.get(key) ?? 0, vergleich: vorjahr.get(key) ?? 0, })); } /** Deckungsbeitrag: gewichtete Marge über abgeschlossene Aufträge im Zeitraum. */ export function marge(auftraege: Auftrag[], zeitraum: ZeitraumPreset): number { const fenster = zeitfenster(zeitraum); const relevant = auftraege.filter( (a) => a.status === "abgeschlossen" && imFenster(a.abgeschlossenAm, fenster) ); const gesamtwert = relevant.reduce((s, a) => s + a.wert, 0); if (gesamtwert === 0) return 0; const db = relevant.reduce((s, a) => s + (a.wert * a.margeProzent) / 100, 0); return (db / gesamtwert) * 100; } /* --------------------------------------------------------- Rechnungen */ export interface OffenePosten { gesamt: number; anzahl: number; ueberfaelligBetrag: number; ueberfaelligAnzahl: number; nachAlter: ChartPunkt[]; liste: Rechnung[]; } /** * Offene Posten sind stichtagsbezogen (nicht zeitraumgefiltert): * Was ist heute noch nicht bezahlt? */ export function offenePosten(rechnungen: Rechnung[]): OffenePosten { const offen = rechnungen.filter((r) => r.status === "gestellt" || r.status === "ueberfaellig"); const buckets: Record = { "0–30 Tage": { betrag: 0, anzahl: 0 }, "31–60 Tage": { betrag: 0, anzahl: 0 }, "61–90 Tage": { betrag: 0, anzahl: 0 }, "> 90 Tage": { betrag: 0, anzahl: 0 }, }; for (const r of offen) { const tage = tageSeit(r.faelligAm, HEUTE); const bucket = tage <= 30 ? "0–30 Tage" : tage <= 60 ? "31–60 Tage" : tage <= 90 ? "61–90 Tage" : "> 90 Tage"; buckets[bucket].betrag += r.betrag; buckets[bucket].anzahl += 1; } const ueberfaellig = offen.filter((r) => r.status === "ueberfaellig"); return { gesamt: offen.reduce((s, r) => s + r.betrag, 0), anzahl: offen.length, ueberfaelligBetrag: ueberfaellig.reduce((s, r) => s + r.betrag, 0), ueberfaelligAnzahl: ueberfaellig.length, nachAlter: Object.entries(buckets).map(([name, v]) => ({ name, value: v.betrag })), liste: offen.sort((a, b) => (a.faelligAm < b.faelligAm ? -1 : 1)), }; } /** Durchschnittliche Zahlungsdauer in Tagen (gestellt → bezahlt). */ export function zahlungsdauer(rechnungen: Rechnung[], zeitraum: ZeitraumPreset): number { const fenster = zeitfenster(zeitraum); const bezahlt = rechnungen.filter( (r) => r.status === "bezahlt" && r.bezahltAm && imFenster(r.bezahltAm, fenster) ); if (!bezahlt.length) return 0; const summe = bezahlt.reduce( (s, r) => s + (new Date(r.bezahltAm!).getTime() - new Date(r.gestelltAm).getTime()) / 86_400_000, 0 ); return summe / bezahlt.length; } /** * Zusammensetzung des OFFENEN Bestands. * * Nicht zu verwechseln mit rechnungenNachStatus(): dort steht der kumulierte * Umsatz aus zwei Jahren gegen den heutigen Forderungsbestand, dabei entfallen * über 90 % auf "Bezahlt", und der Ring zeigt nichts mehr. Der offene Bestand * ist ein echtes Ganzes: alles, was heute noch aussteht. */ export function offeneNachStatus(rechnungen: Rechnung[]): ChartPunkt[] { // Entwuerfe sind noch nicht gestellt und damit keine Forderung. Wuerden sie // mitzaehlen, waeche die Ringsumme von der KPI "Offene Forderungen" ab. const offen = rechnungen.filter( (r) => r.richtung === "ausgang" && (r.status === "gestellt" || r.status === "ueberfaellig") ); const summen = new Map(); for (const r of offen) { const label = RECHNUNG_STATUS_LABEL[r.status]; summen.set(label, (summen.get(label) ?? 0) + r.betrag); } return [...summen.entries()].map(([name, value]) => ({ name, value })); } export function rechnungenNachStatus(rechnungen: Rechnung[]): ChartPunkt[] { const zaehler = new Map(); for (const r of rechnungen) { const label = RECHNUNG_STATUS_LABEL[r.status]; zaehler.set(label, (zaehler.get(label) ?? 0) + r.betrag); } return [...zaehler.entries()].map(([name, value]) => ({ name, value })); } /* ------------------------------------------------------------ Aufträge */ export function auftraegeImZeitraum(auftraege: Auftrag[], zeitraum: ZeitraumPreset): Auftrag[] { const fenster = zeitfenster(zeitraum); return auftraege.filter((a) => imFenster(a.angelegtAm, fenster)); } /** "Aktiv" = beauftragt oder in Arbeit: das, was das Team gerade bindet. */ export function aktiveAuftraege(auftraege: Auftrag[]): Auftrag[] { return auftraege.filter((a) => a.status === "beauftragt" || a.status === "in_arbeit"); } export function ueberfaelligeAuftraege(auftraege: Auftrag[]): Auftrag[] { // Einen Termin gibt es erst ab der Beauftragung, eine offene Anfrage kann // nicht "über Termin" sein. return auftraege .filter( (a) => (a.status === "beauftragt" || a.status === "in_arbeit") && new Date(a.faelligAm) < HEUTE ) .sort((a, b) => (a.faelligAm < b.faelligAm ? -1 : 1)); } export function auftraegeNachStatus(auftraege: Auftrag[]): ChartPunkt[] { const zaehler = new Map(); for (const a of auftraege) zaehler.set(a.status, (zaehler.get(a.status) ?? 0) + 1); return (Object.keys(AUFTRAG_STATUS_LABEL) as AuftragStatus[]) .map((status) => ({ name: AUFTRAG_STATUS_LABEL[status], value: zaehler.get(status) ?? 0 })) .filter((p) => p.value > 0); } export function auftraegeNachGewerk(auftraege: Auftrag[]): ChartPunkt[] { const zaehler = new Map(); for (const a of auftraege) zaehler.set(a.gewerk, (zaehler.get(a.gewerk) ?? 0) + 1); return [...zaehler.entries()] .map(([name, value]) => ({ name, value })) .sort((a, b) => b.value - a.value); } export function umsatzNachGewerk( auftraege: Auftrag[], rechnungen: Rechnung[], zeitraum: ZeitraumPreset ): ChartPunkt[] { const fenster = zeitfenster(zeitraum); const gewerkProAuftrag = new Map(auftraege.map((a) => [a.id, a.gewerk])); const summen = new Map(); for (const r of rechnungen) { if (r.status !== "bezahlt" || !imFenster(r.bezahltAm, fenster)) continue; const gewerk = gewerkProAuftrag.get(r.auftragId); if (!gewerk) continue; summen.set(gewerk, (summen.get(gewerk) ?? 0) + r.betrag); } return [...summen.entries()] .map(([name, value]) => ({ name, value })) .sort((a, b) => b.value - a.value); } /** * Vertriebstrichter: Wie viele Vorgänge haben eine Stufe mindestens erreicht? * Verlorene Aufträge zählen nur bis zu ihrer letzten erreichten Stufe. */ /** * Vertriebstrichter: bewusst nur über das Angebotsgeschäft. * * Wartung, Notdienst und E-Check laufen ohne Angebot direkt in die Ausführung. * Zählte man sie mit, würde der Trichter unten breiter als oben und die * Übergangsquoten wären wertlos. */ export function vertriebsTrichter(auftraege: Auftrag[]): ChartPunkt[] { const rang = (status: AuftragStatus) => FUNNEL_STUFEN.indexOf(status); const angebotsgeschaeft = auftraege.filter((a) => a.ueberAngebot); return FUNNEL_STUFEN.map((stufe, index) => { const anzahl = angebotsgeschaeft.filter((a) => { // Verlorene Vorgänge zählen bis zu der Stufe, auf der sie abgesprungen sind. if (a.status === "verloren") { return a.verlorenAb ? rang(a.verlorenAb) >= index : false; } return rang(a.status) >= index; }).length; return { name: AUFTRAG_STATUS_LABEL[stufe], value: anzahl }; }); } export function abschlussquote(auftraege: Auftrag[]): number { // Bezugsgröße ist ausschließlich das Angebotsgeschäft. Wartungs- und // Notdienstaufträge laufen ohne Angebot und würden die Quote schönrechnen. const mitAngebot = auftraege.filter( (a) => a.ueberAngebot && (["angebot", "beauftragt", "in_arbeit", "abgeschlossen"].includes(a.status) || (a.status === "verloren" && a.verlorenAb === "angebot")) ); if (!mitAngebot.length) return 0; const gewonnen = mitAngebot.filter((a) => ["beauftragt", "in_arbeit", "abgeschlossen"].includes(a.status) ).length; return (gewonnen / mitAngebot.length) * 100; } /** Ø Durchlaufzeit abgeschlossener Aufträge in Tagen. */ export function durchlaufzeit(auftraege: Auftrag[]): number { const fertig = auftraege.filter((a) => a.status === "abgeschlossen" && a.abgeschlossenAm); if (!fertig.length) return 0; const summe = fertig.reduce( (s, a) => s + (new Date(a.abgeschlossenAm!).getTime() - new Date(a.angelegtAm).getTime()) / 86_400_000, 0 ); return summe / fertig.length; } export function auftragseingang(auftraege: Auftrag[], zeitraum: ZeitraumPreset): ZeitreihePunkt[] { const { art, schluessel } = bucketsFuer(zeitraum); const zaehler = new Map(); for (const a of auftraege) { const key = bucketKey(a.angelegtAm, art); zaehler.set(key, (zaehler.get(key) ?? 0) + 1); } return schluessel.map((key) => ({ datum: key, label: bucketLabel(key, art), value: zaehler.get(key) ?? 0, })); } /* -------------------------------------------------------------- Kunden */ export function neueKunden(kunden: Kunde[], zeitraum: ZeitraumPreset): number { const fenster = zeitfenster(zeitraum); return kunden.filter((k) => imFenster(k.angelegtAm, fenster)).length; } /** Gleiches Fenster davor: Basis fuer den Trend an der Neukunden-Karte. */ export function neueKundenVorperiode(kunden: Kunde[], zeitraum: ZeitraumPreset): number { const fenster = vorperiode(zeitraum); return kunden.filter((k) => imFenster(k.angelegtAm, fenster)).length; } export function kundenEntwicklung(kunden: Kunde[], zeitraum: ZeitraumPreset): ZeitreihePunkt[] { const { art, schluessel } = bucketsFuer(zeitraum); const zaehler = new Map(); for (const k of kunden) { const key = bucketKey(k.angelegtAm, art); zaehler.set(key, (zaehler.get(key) ?? 0) + 1); } return schluessel.map((key) => ({ datum: key, label: bucketLabel(key, art), value: zaehler.get(key) ?? 0, })); } export function kundenNach(kunden: Kunde[], feld: "quelle" | "typ" | "region"): ChartPunkt[] { const zaehler = new Map(); for (const k of kunden) zaehler.set(k[feld], (zaehler.get(k[feld]) ?? 0) + 1); return [...zaehler.entries()] .map(([name, value]) => ({ name, value })) .sort((a, b) => b.value - a.value); } export function topKunden(kunden: Kunde[], anzahl = 5): ChartPunkt[] { return [...kunden] .filter((k) => k.umsatzGesamt > 0) .sort((a, b) => b.umsatzGesamt - a.umsatzGesamt) .slice(0, anzahl) .map((k) => ({ name: k.name, value: k.umsatzGesamt })); } /** Anteil Kunden mit mehr als einem Auftrag. */ export function wiederkehrquote(kunden: Kunde[]): number { const mitAuftrag = kunden.filter((k) => k.auftraegeGesamt > 0); if (!mitAuftrag.length) return 0; return (mitAuftrag.filter((k) => k.auftraegeGesamt > 1).length / mitAuftrag.length) * 100; } /** Kunden, die seit über 12 Monaten keinen Auftrag hatten. */ export function inaktiveKunden(kunden: Kunde[]): Kunde[] { const grenze = addMonate(HEUTE, -12); return kunden .filter((k) => k.auftraegeGesamt > 0 && k.letzterAuftragAm && new Date(k.letzterAuftragAm) < grenze) .sort((a, b) => b.umsatzGesamt - a.umsatzGesamt); } /** Kunden mit auffälliger Zahlungsmoral (> 30 Tage im Schnitt). */ export function zahlungsauffaellig(kunden: Kunde[]): Kunde[] { return kunden .filter((k) => k.zahlungsmoralTage > 30 && k.auftraegeGesamt > 0) .sort((a, b) => b.zahlungsmoralTage - a.zahlungsmoralTage); } /* ---------------------------------------------------------------- Team */ export interface MitarbeiterKennzahl { mitarbeiter: Mitarbeiter; stunden: number; produktivStunden: number; auslastungProzent: number; abgeschlossen: number; umsatz: number; } /** * Auslastung = erfasste Arbeitsstunden / verfügbare Stunden im Zeitraum. * Verfügbare Stunden werden aus den Wochenstunden hochgerechnet. */ export function teamKennzahlen( mitarbeiter: Mitarbeiter[], zeiten: Zeiteintrag[], auftraege: Auftrag[], zeitraum: ZeitraumPreset ): MitarbeiterKennzahl[] { const fenster = zeitfenster(zeitraum); const tageImFenster = Math.max( 1, Math.round((fenster.bis.getTime() - fenster.von.getTime()) / 86_400_000) ); const wochen = Math.max(1, tageImFenster / 7); return mitarbeiter .filter((m) => m.rolle !== "Büro") .map((m) => { const eigene = zeiten.filter((z) => z.mitarbeiterId === m.id && imFenster(z.datum, fenster)); const stunden = eigene.reduce((s, z) => s + z.stunden, 0); const produktiv = eigene .filter((z) => z.art === "arbeit" && z.auftragId) .reduce((s, z) => s + z.stunden, 0); const verfuegbar = m.wochenstunden * wochen; const eigeneAuftraege = auftraege.filter((a) => a.mitarbeiterId === m.id); const abgeschlossen = eigeneAuftraege.filter( (a) => a.status === "abgeschlossen" && imFenster(a.abgeschlossenAm, fenster) ); return { mitarbeiter: m, stunden, produktivStunden: produktiv, auslastungProzent: verfuegbar > 0 ? Math.min(135, (produktiv / verfuegbar) * 100) : 0, abgeschlossen: abgeschlossen.length, umsatz: abgeschlossen.reduce((s, a) => s + a.wert, 0), }; }) .sort((a, b) => b.auslastungProzent - a.auslastungProzent); } /* -------------------------------------------------------------- Alerts */ export interface AlertEintrag { id: string; text: string; detail: string; art: "kritisch" | "warnung" | "hinweis"; /** Betrag, um den es geht: macht die Zeilen untereinander vergleichbar. */ betrag?: number; /** * Die Handlung, nicht die Beobachtung. Eine Liste ohne Verb ist ein Bericht; * "Was ist zu tun" braucht ein Verb je Zeile. */ aktion: string; } /** Handlungsbedarf für die Übersichtsseite. */ export function handlungsbedarf(daten: Datensatz): AlertEintrag[] { const eintraege: AlertEintrag[] = []; const posten = offenePosten(daten.rechnungen); const ueberfaellig = ueberfaelligeAuftraege(daten.auftraege); const kritischAlt = posten.nachAlter.find((b) => b.name === "> 90 Tage"); if (posten.ueberfaelligAnzahl > 0) { eintraege.push({ id: "rechnungen-ueberfaellig", text: `${posten.ueberfaelligAnzahl} überfällige Rechnungen`, detail: "Zahlungsziel überschritten", betrag: posten.ueberfaelligBetrag, aktion: "Mahnlauf starten", art: "kritisch", }); } if (kritischAlt && kritischAlt.value > 0) { eintraege.push({ id: "rechnungen-90", text: "Forderungen älter als 90 Tage", detail: "Einbringungsquote sinkt deutlich", betrag: kritischAlt.value, aktion: "Inkasso prüfen", art: "kritisch", }); } if (ueberfaellig.length > 0) { eintraege.push({ id: "auftraege-ueberfaellig", text: `${ueberfaellig.length} Aufträge über Termin`, detail: `Ältester seit ${tageSeit(ueberfaellig[0].faelligAm, HEUTE)} Tagen fällig`, betrag: ueberfaellig.reduce((s, a) => s + a.wert, 0), aktion: "Termine neu planen", art: "warnung", }); } const offeneAngebote = daten.auftraege.filter( (a) => a.status === "angebot" && tageSeit(a.angelegtAm, HEUTE) > 21 ); if (offeneAngebote.length > 0) { eintraege.push({ id: "angebote-alt", text: `${offeneAngebote.length} Angebote ohne Rückmeldung`, detail: "Seit über 21 Tagen offen", betrag: offeneAngebote.reduce((s, a) => s + a.wert, 0), aktion: "Nachfassen", art: "warnung", }); } const foerderOffen = daten.auftraege.filter((a) => a.foerderung === "beantragt"); if (foerderOffen.length > 0) { eintraege.push({ id: "foerderung", text: `${foerderOffen.length} Förderanträge in Bearbeitung`, detail: "Bescheid ausstehend", betrag: foerderOffen.reduce((s, a) => s + a.foerderbetrag, 0), aktion: "Status prüfen", art: "hinweis", }); } return eintraege; } /* --------------------------------------------------------- Förderung */ /** * Förderquote BAFA/KfW. * * Bezugsgröße sind ausschließlich förderfähige Leistungen (Wärmepumpe, PV, * Speicher, Lüftung). Über alle Aufträge gerechnet läge die Quote im * einstelligen Bereich und wäre wertlos, eine Badwartung war nie förderfähig. */ export function foerderQuote(auftraege: Auftrag[]): { quote: number; foerderfaehig: number; bewilligt: number; beantragt: number; summe: number; verteilung: ChartPunkt[]; } { const foerderfaehig = auftraege.filter((a) => FOERDERFAEHIGE_KATEGORIEN.has(a.kategorie)); const bewilligt = foerderfaehig.filter((a) => a.foerderung === "bewilligt"); const beantragt = foerderfaehig.filter((a) => a.foerderung === "beantragt"); const mitFoerderung = bewilligt.length + beantragt.length; return { quote: foerderfaehig.length ? (mitFoerderung / foerderfaehig.length) * 100 : 0, foerderfaehig: foerderfaehig.length, bewilligt: bewilligt.length, beantragt: beantragt.length, summe: bewilligt.reduce((s, a) => s + a.foerderbetrag, 0), verteilung: [ { name: "Bewilligt", value: bewilligt.length }, { name: "Beantragt", value: beantragt.length }, { name: "Nicht beantragt", value: foerderfaehig.length - mitFoerderung }, ], }; }