467 Stellen. Nicht durch einen anderen Strich ersetzt, sondern durch das
Satzzeichen, das an der jeweiligen Stelle richtig ist: Doppelpunkt, wo eine
Erlaeuterung folgt; Komma, wo der Satz weiterlaeuft; Punkt, wo zwei
Hauptsaetze aneinanderstiessen. Ein Halbgeviertstrich waere nur derselbe
Griff mit kuerzerem Strich gewesen.
Regelbasiert ersetzt und danach nachgeprueft, weil eine Regel allein nicht
reicht. Gefunden und einzeln korrigiert:
- Eingeschobene Saetze mit Strichpaar. Nur der oeffnende Strich passte ins
Muster, der schliessende blieb als "—," stehen. Drei Stellen, jetzt mit
Klammern oder als eigener Satz.
- Doppelte Doppelpunkte, wo beide Striche eines Paars zu Doppelpunkten wurden
("Globale Regeln: Schrift, Groessen, Abstaende: kommen aus HiveGrid").
Sieben Stellen in Doku und Oberflaeche, jetzt in Klammern.
- Pfade, denen ein Komma statt eines Doppelpunkts folgte.
- Ausgerichtete Kommentartabellen, in denen der Strich eine Spalte war.
Nicht angefasst: drei Geviertstriche, die keine Prosa sind, sondern
Tabellenplatzhalter fuer "kein Wert" (Trichter, Auftraege, Kunden). Dort ist
der Strich die Angabe selbst; ihn zu entfernen liesse leere Zellen zurueck.
tsc, lint, build, verify:carbon und verify:layout laufen sauber.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
569 lines
20 KiB
TypeScript
569 lines
20 KiB
TypeScript
/**
|
||
* 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<string, number>();
|
||
const vorjahr = new Map<string, number>();
|
||
|
||
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<string, { betrag: number; anzahl: number }> = {
|
||
"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<string, number>();
|
||
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<string, number>();
|
||
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<AuftragStatus, number>();
|
||
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<string, number>();
|
||
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<string, number>();
|
||
|
||
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<string, number>();
|
||
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<string, number>();
|
||
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<string, number>();
|
||
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 },
|
||
],
|
||
};
|
||
}
|