Files
Dashboard/features/dashboard/data/seed.ts
T
dejanandClaude Opus 5 6a0b36ddcc Geviertstriche aus Text, Kommentaren und Doku entfernen
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
2026-08-31 20:51:19 +02:00

630 lines
22 KiB
TypeScript

/**
* Deterministischer Dummy-Datensatz für einen SHK-/Handwerksbetrieb.
*
* Bewusst deterministisch (fester Seed): Die Zahlen bleiben über Reloads,
* Screenshots und Abstimmungsrunden identisch. Für die echte Software wird
* diese Datei durch Entity-Builder-Queries ersetzt, das Interface `Datensatz`
* bleibt gleich.
*
* Betriebsprofil (fiktiv):
* Meister Krause GmbH · Heizung · Sanitär · Klima · PV · Elektro
* 24 Beschäftigte, ca. 3,4 Mio. € Jahresumsatz, Raum Rhein-Main
*/
import {
AUFTRAG_STATUS_LABEL,
GEWERKE,
FOERDERFAEHIGE_KATEGORIEN,
KATEGORIEN,
type Auftrag,
type AuftragStatus,
type Datensatz,
type Gewerk,
type Kunde,
type KundenQuelle,
type KundenTyp,
type Mitarbeiter,
type MitarbeiterRolle,
type Rechnung,
type Zeiteintrag,
} from "./types";
import { HEUTE, addTage, toISO } from "../utils/dateUtils";
/* ------------------------------------------------------ Zufall mit Seed */
/** mulberry32: kleiner, schneller PRNG mit reproduzierbarer Sequenz. */
function prng(seed: number) {
let a = seed;
return function random(): number {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rnd = prng(20260730);
const zahl = (min: number, max: number) => Math.floor(rnd() * (max - min + 1)) + min;
const kommazahl = (min: number, max: number) => rnd() * (max - min) + min;
const aus = <T,>(liste: readonly T[]): T => liste[Math.floor(rnd() * liste.length)];
function gewichtet<T>(optionen: { wert: T; gewicht: number }[]): T {
const gesamt = optionen.reduce((s, o) => s + o.gewicht, 0);
let r = rnd() * gesamt;
for (const o of optionen) {
r -= o.gewicht;
if (r <= 0) return o.wert;
}
return optionen[optionen.length - 1].wert;
}
/** Zufälliges Datum zwischen zwei Zeitpunkten. */
function datumZwischen(von: Date, bis: Date): Date {
return new Date(von.getTime() + rnd() * (bis.getTime() - von.getTime()));
}
/* ---------------------------------------------------------- Mitarbeiter */
const VORNAMEN = [
"Thomas", "Michael", "Andreas", "Stefan", "Christian", "Markus", "Peter", "Martin",
"Daniel", "Kevin", "Sebastian", "Tobias", "Jens", "Sandra", "Maike", "Nicole",
"Julia", "Patrick", "Dennis", "Marco", "Sven", "Lukas", "Fabian", "Mehmet",
];
const NACHNAMEN = [
"Krause", "Müller", "Schmidt", "Schneider", "Fischer", "Weber", "Meyer", "Wagner",
"Becker", "Schulz", "Hoffmann", "Schäfer", "Koch", "Richter", "Klein", "Wolf",
"Neumann", "Zimmermann", "Braun", "Hartmann", "Lange", "Werner", "Yilmaz", "Peters",
];
function kuerzelAus(name: string): string {
const [v, n] = name.split(" ");
return `${v[0]}${n[0]}`.toUpperCase();
}
function baueMitarbeiter(): Mitarbeiter[] {
const rollen: MitarbeiterRolle[] = [
"Meister", "Meister", "Meister",
"Monteur", "Monteur", "Monteur", "Monteur", "Monteur", "Monteur", "Monteur",
"Monteur", "Monteur", "Monteur", "Monteur", "Monteur", "Monteur", "Monteur",
"Auszubildender", "Auszubildender", "Auszubildender",
"Büro", "Büro", "Büro", "Büro",
];
const benutzt = new Set<string>();
return rollen.map((rolle, i) => {
let name = `${VORNAMEN[i % VORNAMEN.length]} ${NACHNAMEN[(i * 7) % NACHNAMEN.length]}`;
while (benutzt.has(name)) name = `${aus(VORNAMEN)} ${aus(NACHNAMEN)}`;
benutzt.add(name);
return {
id: `ma-${i + 1}`,
name,
kuerzel: kuerzelAus(name),
rolle,
gewerk: GEWERKE[i % GEWERKE.length],
// Zwei Langzeitabwesende, damit "aktiv" vs. "gesamt" sichtbar wird.
status: i === 12 || i === 19 ? "inaktiv" : "aktiv",
wochenstunden: rolle === "Auszubildender" ? 35 : rolle === "Büro" ? 38 : 40,
eintrittsdatum: toISO(addTage(HEUTE, -zahl(90, 4200))),
} satisfies Mitarbeiter;
});
}
/* ---------------------------------------------------------------- Kunden */
const ORTE: { ort: string; plz: string; region: string }[] = [
{ ort: "Wiesbaden", plz: "65189", region: "Hessen" },
{ ort: "Mainz", plz: "55116", region: "Rheinland-Pfalz" },
{ ort: "Frankfurt am Main", plz: "60313", region: "Hessen" },
{ ort: "Rüsselsheim", plz: "65428", region: "Hessen" },
{ ort: "Hofheim am Taunus", plz: "65719", region: "Hessen" },
{ ort: "Bad Homburg", plz: "61348", region: "Hessen" },
{ ort: "Darmstadt", plz: "64283", region: "Hessen" },
{ ort: "Ingelheim", plz: "55218", region: "Rheinland-Pfalz" },
{ ort: "Bingen", plz: "55411", region: "Rheinland-Pfalz" },
{ ort: "Aschaffenburg", plz: "63739", region: "Bayern" },
];
const GEWERBE_NAMEN = [
"Autohaus Reinhardt", "Bäckerei Lindner", "Hotel Rheinblick", "Praxis Dr. Alber",
"Logistik Weber KG", "Metallbau Süd GmbH", "Kanzlei Hoffmann & Partner",
"Sporthalle Taunusstein", "Kita Sonnenschein", "Getränke Hauser",
"Elektro Nord GmbH", "Weingut Steinberg", "Fahrschule Brandt", "Pflegeheim St. Anna",
];
const VERWALTUNG_NAMEN = [
"Rhein-Main Immobilien GmbH", "Hausverwaltung Kessler", "WEG Parkstraße 12",
"Wohnbau Wiesbaden eG", "Immobilien Service Taunus",
];
const QUELLEN: { wert: KundenQuelle; gewicht: number }[] = [
{ wert: "Empfehlung", gewicht: 30 },
{ wert: "Bestandskunde", gewicht: 24 },
{ wert: "Google", gewicht: 18 },
{ wert: "Website", gewicht: 14 },
{ wert: "Partnerbetrieb", gewicht: 8 },
{ wert: "Werbung", gewicht: 6 },
];
function baueKunden(anzahl: number): Kunde[] {
const kunden: Kunde[] = [];
for (let i = 0; i < anzahl; i++) {
const typ = gewichtet<KundenTyp>([
{ wert: "Privat", gewicht: 68 },
{ wert: "Gewerbe", gewicht: 22 },
{ wert: "Hausverwaltung", gewicht: 10 },
]);
const ort = aus(ORTE);
const name =
typ === "Privat"
? `Familie ${NACHNAMEN[(i * 5) % NACHNAMEN.length]}`
: typ === "Gewerbe"
? GEWERBE_NAMEN[i % GEWERBE_NAMEN.length]
: VERWALTUNG_NAMEN[i % VERWALTUNG_NAMEN.length];
kunden.push({
id: `kd-${i + 1}`,
name: typ === "Privat" ? `${name} · ${ort.ort}` : name,
typ,
quelle: gewichtet(QUELLEN),
region: ort.region,
plz: ort.plz,
ort: ort.ort,
angelegtAm: toISO(datumZwischen(addTage(HEUTE, -900), addTage(HEUTE, -2))),
letzterAuftragAm: null,
umsatzGesamt: 0,
auftraegeGesamt: 0,
// Die meisten zahlen nahe am Zahlungsziel; ein kleiner Teil schleppt.
// Gleichverteilt wäre jeder zweite Kunde auffällig, das wäre kein Betrieb,
// sondern ein Inkassofall.
zahlungsmoralTage: gewichtet([
{ wert: zahl(5, 14), gewicht: 42 },
{ wert: zahl(15, 24), gewicht: 33 },
{ wert: zahl(25, 34), gewicht: 15 },
{ wert: zahl(35, 52), gewicht: 7 },
{ wert: zahl(53, 78), gewicht: 3 },
]),
});
}
return kunden;
}
/* -------------------------------------------------------------- Aufträge */
/** Realistische Auftragswerte je Leistungsart (netto, in €). */
const WERT_SPANNEN: Record<string, [number, number]> = {
"Wärmepumpe": [17_500, 34_000],
"Gastherme": [5_800, 12_400],
"Heizkörper": [1_200, 6_500],
"Wartung Heizung": [150, 420],
"Bad-Sanierung": [9_500, 27_000],
"Rohrleitungen": [900, 7_800],
"Armaturen": [250, 2_400],
"Notdienst": [180, 950],
"Splitgerät": [2_400, 8_900],
"Lüftungsanlage": [6_500, 21_000],
"Kanalreinigung": [420, 1_800],
"Wartung Klima": [180, 480],
"PV-Anlage": [11_500, 29_500],
"Batteriespeicher": [6_800, 14_500],
"Wallbox": [1_150, 3_400],
"Wartung PV": [190, 460],
"Elektroinstallation": [1_800, 12_000],
"Zählerschrank": [1_400, 4_200],
"Smart Home": [2_200, 9_800],
"E-Check": [140, 390],
};
/** Bezugsgröße der Förderquote: zentral in types.ts definiert. */
const FOERDERFAEHIG = FOERDERFAEHIGE_KATEGORIEN;
/**
* Ergebnis eines Vorgangs, abhängig von seinem Alter.
*
* Die Verteilung bildet einen realistischen Trichter ab:
* von 100 Anfragen werden rund 55 zum Angebot und rund 24 zum Auftrag.
* Verlorene Vorgänge merken sich, auf welcher Stufe sie abgesprungen sind,
* sonst zeigt der Trichter oben zu wenig und unten zu viel.
*/
function ergebnisFuerAlter(
tageAlt: number,
/** Große Vorhaben werden angeboten und oft verglichen: kleine kaum. */
grossauftrag: boolean
): {
status: AuftragStatus;
verlorenAb: AuftragStatus | null;
} {
type Ergebnis = { status: AuftragStatus; verlorenAb: AuftragStatus | null };
const verloren = (stufe: AuftragStatus): Ergebnis => ({ status: "verloren", verlorenAb: stufe });
const offen = (stufe: AuftragStatus): Ergebnis => ({ status: stufe, verlorenAb: null });
/**
* Verlustneigung: Wartungs- und Notdienstaufträge werden praktisch immer
* ausgeführt, große Anlagen dagegen ausgeschrieben und verglichen.
* Ein pauschaler Wert über alle Auftragsarten würde die Angebotsquote
* unrealistisch schönrechnen.
*/
const verlustFaktor = grossauftrag ? 2.1 : 0.28;
const v = (gewicht: number) => gewicht * verlustFaktor;
// Frisch: steckt noch vorne im Trichter.
if (tageAlt < 12) {
return gewichtet([
{ wert: offen("anfrage"), gewicht: 40 },
{ wert: offen("aufmass"), gewicht: 24 },
{ wert: offen("angebot"), gewicht: 20 },
{ wert: offen("beauftragt"), gewicht: 9 },
{ wert: verloren("anfrage"), gewicht: v(7) },
]);
}
// In Bearbeitung: Entscheidungen fallen.
if (tageAlt < 45) {
return gewichtet([
{ wert: offen("aufmass"), gewicht: 7 },
{ wert: offen("angebot"), gewicht: 20 },
{ wert: offen("beauftragt"), gewicht: 13 },
{ wert: offen("in_arbeit"), gewicht: 17 },
{ wert: offen("abgeschlossen"), gewicht: 8 },
{ wert: verloren("anfrage"), gewicht: v(9) },
{ wert: verloren("aufmass"), gewicht: v(7) },
{ wert: verloren("angebot"), gewicht: v(34) },
]);
}
// Älter als ein Quartal: fast alles ist entschieden.
if (tageAlt < 120) {
return gewichtet([
{ wert: offen("in_arbeit"), gewicht: 9 },
{ wert: offen("abgeschlossen"), gewicht: 50 },
{ wert: verloren("anfrage"), gewicht: v(8) },
{ wert: verloren("aufmass"), gewicht: v(7) },
{ wert: verloren("angebot"), gewicht: v(46) },
]);
}
// Über vier Monate alt: entweder fertig oder verloren. Ein Vorgang, der
// seit zwei Jahren "in Arbeit" ist, wäre ein Datenfehler, kein Auftrag.
return gewichtet([
{ wert: offen("abgeschlossen"), gewicht: 60 },
{ wert: verloren("anfrage"), gewicht: v(9) },
{ wert: verloren("aufmass"), gewicht: v(8) },
{ wert: verloren("angebot"), gewicht: v(54) },
]);
}
/**
* Leistungsart innerhalb eines Gewerks.
* Wartung, Notdienst und E-Check sind im Alltag die häufigsten Aufträge,
* gleichverteilt zu ziehen würde den Durchschnittswert massiv verzerren.
*/
function kategorieFuer(gewerk: Gewerk): string {
const liste = KATEGORIEN[gewerk];
const kleinteilig = liste.filter((k) => /Wartung|Notdienst|E-Check|Armaturen/.test(k));
const gross = liste.filter((k) => !kleinteilig.includes(k));
if (!kleinteilig.length) return aus(liste);
return rnd() < 0.42 ? aus(kleinteilig) : aus(gross);
}
/**
* Verteilt Auftragsdaten über die letzten 24 Monate.
*
* Zwei Effekte sind absichtlich eingebaut, weil ein gleichverteilter
* Datensatz in jedem Zeitreihen-Chart unnatürlich flach aussieht:
* - moderates Wachstum von rund 12 % gegenüber dem Vorjahr
* - Saisonalität: Heizung, Wärmepumpe und Lüftung ziehen von September
* bis Februar an, der Hochsommer ist schwächer
* Umgesetzt über Verwerfungsmethode gegen eine Dichtefunktion.
*/
const SAISON_INDEX = [
// Jan Feb Mär Apr Mai Jun Jul Aug Sep Okt Nov Dez
1.12, 1.06, 1.0, 0.94, 0.88, 0.84, 0.82, 0.9, 1.1, 1.22, 1.18, 0.98,
];
function ziehAuftragsalter(): number {
const maxDichte = 1.12 * 1.22;
for (let versuch = 0; versuch < 40; versuch++) {
const tage = zahl(0, 719);
const datum = addTage(HEUTE, -tage);
const wachstum = 1 + 0.12 * (1 - tage / 720);
const dichte = wachstum * SAISON_INDEX[datum.getMonth()];
if (rnd() <= dichte / maxDichte) return tage;
}
return zahl(0, 719);
}
function baueAuftraege(kunden: Kunde[], mitarbeiter: Mitarbeiter[], anzahl: number): Auftrag[] {
const ausfuehrende = mitarbeiter.filter((m) => m.rolle === "Monteur" || m.rolle === "Meister");
const auftraege: Auftrag[] = [];
for (let i = 0; i < anzahl; i++) {
const tageAlt = ziehAuftragsalter();
const angelegt = addTage(HEUTE, -tageAlt);
const gewerk = gewichtet<Gewerk>([
{ wert: "Heizung", gewicht: 34 },
{ wert: "Sanitär", gewicht: 24 },
{ wert: "Photovoltaik", gewicht: 20 },
{ wert: "Klima & Lüftung", gewicht: 12 },
{ wert: "Elektro", gewicht: 10 },
]);
const kategorie = kategorieFuer(gewerk);
const [minWert, maxWert] = WERT_SPANNEN[kategorie] ?? [500, 5_000];
const wert = Math.round(kommazahl(minWert, maxWert) / 10) * 10;
// Ab etwa 6.000 € wird im Handwerk regelmäßig schriftlich angeboten und
// verglichen; darunter läuft der Auftrag meist direkt.
const ueberAngebot = wert >= 6_000;
const { status, verlorenAb } = ergebnisFuerAlter(tageAlt, ueberAngebot);
const kunde = aus(kunden);
const mitarbeiterId = aus(ausfuehrende).id;
const laufzeitTage = wert > 12_000 ? zahl(21, 75) : zahl(3, 28);
const startAm =
status === "in_arbeit" || status === "abgeschlossen"
? toISO(addTage(angelegt, zahl(5, 30)))
: null;
const abgeschlossenAm =
status === "abgeschlossen" ? toISO(addTage(angelegt, laufzeitTage + zahl(0, 20))) : null;
const foerderfaehig = FOERDERFAEHIG.has(kategorie);
// Ein Förderbescheid dauert Wochen, nicht Jahre: Nur bei jungen Vorgängen
// ist "beantragt" plausibel, ältere sind längst entschieden.
const foerderung: Auftrag["foerderung"] = !foerderfaehig
? "keine"
: tageAlt <= 100
? gewichtet([
{ wert: "beantragt" as const, gewicht: 46 },
{ wert: "bewilligt" as const, gewicht: 39 },
{ wert: "keine" as const, gewicht: 15 },
])
: gewichtet([
{ wert: "bewilligt" as const, gewicht: 84 },
{ wert: "keine" as const, gewicht: 16 },
]);
auftraege.push({
id: `au-${i + 1}`,
nummer: `A-2026-${String(1000 + i).slice(-4)}`,
kundeId: kunde.id,
mitarbeiterId,
gewerk,
kategorie,
titel: `${kategorie} · ${kunde.ort}`,
status,
prioritaet: gewichtet([
{ wert: "normal" as const, gewicht: 62 },
{ wert: "hoch" as const, gewicht: 22 },
{ wert: "niedrig" as const, gewicht: 16 },
]),
wert,
margeProzent: Math.round(kommazahl(16, 38) * 10) / 10,
angelegtAm: toISO(angelegt),
startAm,
abgeschlossenAm,
faelligAm: toISO(addTage(angelegt, laufzeitTage)),
foerderung,
foerderbetrag:
foerderung === "keine" ? 0 : Math.round((wert * kommazahl(0.2, 0.4)) / 50) * 50,
verlorenAb,
ueberAngebot,
});
}
return auftraege.sort((a, b) => (a.angelegtAm < b.angelegtAm ? 1 : -1));
}
/* ------------------------------------------------------------ Rechnungen */
function baueRechnungen(auftraege: Auftrag[]): Rechnung[] {
const rechnungen: Rechnung[] = [];
let nr = 0;
for (const auftrag of auftraege) {
// Rechnung entsteht erst ab Beauftragung (Anzahlung) bzw. Abschluss.
if (!["beauftragt", "in_arbeit", "abgeschlossen"].includes(auftrag.status)) continue;
const positionen: { betrag: number; basis: string }[] = [];
if (auftrag.wert > 8_000 && auftrag.startAm) {
// Große Vorhaben: 30 % Anzahlung bei Auftragserteilung.
positionen.push({ betrag: Math.round(auftrag.wert * 0.3), basis: auftrag.startAm });
}
if (auftrag.status === "abgeschlossen" && auftrag.abgeschlossenAm) {
const bereits = positionen.reduce((s, p) => s + p.betrag, 0);
positionen.push({ betrag: auftrag.wert - bereits, basis: auftrag.abgeschlossenAm });
}
if (positionen.length === 0 && auftrag.status !== "beauftragt" && auftrag.startAm) {
positionen.push({ betrag: Math.round(auftrag.wert * 0.5), basis: auftrag.startAm });
}
for (const position of positionen) {
nr += 1;
const gestellt = new Date(position.basis);
const zahlungsziel = 21;
const faellig = addTage(gestellt, zahlungsziel);
const ueberfaelligTage = Math.floor((HEUTE.getTime() - faellig.getTime()) / 86_400_000);
let status: Rechnung["status"];
let bezahltAm: string | null = null;
if (gestellt > HEUTE) {
// Rechnung existiert noch nicht: z. B. Schlussrechnung eines laufenden Auftrags.
status = "entwurf";
} else if (ueberfaelligTage < 0) {
// Noch innerhalb des Zahlungsziels; ein Teil zahlt sofort.
status = rnd() < 0.08 ? "bezahlt" : "gestellt";
if (status === "bezahlt") bezahltAm = toISO(addTage(gestellt, zahl(2, zahlungsziel)));
} else if (ueberfaelligTage <= 18) {
// Kurz über dem Ziel: das ist Alltag, noch kein Mahnfall.
if (rnd() < 0.52) {
status = "bezahlt";
bezahltAm = toISO(addTage(faellig, zahl(-6, ueberfaelligTage)));
} else {
status = "gestellt";
}
} else if (ueberfaelligTage <= 60) {
if (rnd() < 0.84) {
status = "bezahlt";
bezahltAm = toISO(addTage(faellig, zahl(-8, Math.min(ueberfaelligTage, 34))));
} else {
status = "ueberfaellig";
}
} else if (ueberfaelligTage <= 120) {
// Mahnstufe erreicht, der Großteil zahlt spätestens jetzt.
if (rnd() < 0.94) {
status = "bezahlt";
bezahltAm = toISO(addTage(faellig, zahl(-8, 70)));
} else {
status = "ueberfaellig";
}
} else if (ueberfaelligTage <= 210) {
// Letzte Mahnstufe: hier entscheidet sich Zahlung oder Ausfall.
if (rnd() < 0.992) {
status = "bezahlt";
bezahltAm = toISO(addTage(faellig, zahl(-8, 120)));
} else {
status = "ueberfaellig";
}
} else {
// Älter als sieben Monate: eingebracht oder ausgebucht, in beiden
// Fällen steht die Forderung nicht mehr in der offenen Liste.
status = "bezahlt";
bezahltAm = toISO(addTage(faellig, zahl(-8, 150)));
}
rechnungen.push({
id: `re-${nr}`,
nummer: `R-2026-${String(2000 + nr).slice(-4)}`,
auftragId: auftrag.id,
kundeId: auftrag.kundeId,
richtung: "ausgang",
betrag: position.betrag,
status,
gestelltAm: toISO(gestellt),
faelligAm: toISO(faellig),
bezahltAm,
});
}
}
return rechnungen;
}
/* ---------------------------------------------------------- Zeiterfassung */
function baueZeiten(mitarbeiter: Mitarbeiter[], auftraege: Auftrag[]): Zeiteintrag[] {
const zeiten: Zeiteintrag[] = [];
const laufend = auftraege.filter((a) => a.status === "in_arbeit" || a.status === "abgeschlossen");
const gewerblich = mitarbeiter.filter(
(m) => m.status === "aktiv" && m.rolle !== "Büro"
);
let nr = 0;
// Letzte 12 Wochen, Montag bis Freitag.
for (let tagOffset = 84; tagOffset >= 0; tagOffset--) {
const tag = addTage(HEUTE, -tagOffset);
const wochentag = tag.getDay();
if (wochentag === 0 || wochentag === 6) continue;
for (const person of gewerblich) {
const abwesend = rnd() < 0.09;
if (abwesend) {
nr += 1;
zeiten.push({
id: `ze-${nr}`,
mitarbeiterId: person.id,
auftragId: null,
datum: toISO(tag),
stunden: 8,
art: "abwesenheit",
abwesenheitsgrund: gewichtet([
{ wert: "urlaub" as const, gewicht: 55 },
{ wert: "krank" as const, gewicht: 33 },
{ wert: "fortbildung" as const, gewicht: 12 },
]),
});
continue;
}
nr += 1;
const auftrag = laufend.length ? aus(laufend) : null;
zeiten.push({
id: `ze-${nr}`,
mitarbeiterId: person.id,
auftragId: auftrag?.id ?? null,
datum: toISO(tag),
// Produktive Stunden schwanken; nicht jede Stunde ist verrechenbar.
stunden: Math.round(kommazahl(4.5, 9.5) * 2) / 2,
art: "arbeit",
});
}
}
return zeiten;
}
/* -------------------------------------------------------- Aggregation */
function ergaenzeKundenkennzahlen(
kunden: Kunde[],
auftraege: Auftrag[],
rechnungen: Rechnung[]
): Kunde[] {
const umsatzProKunde = new Map<string, number>();
for (const r of rechnungen) {
if (r.status !== "bezahlt") continue;
umsatzProKunde.set(r.kundeId, (umsatzProKunde.get(r.kundeId) ?? 0) + r.betrag);
}
const auftraegeProKunde = new Map<string, Auftrag[]>();
for (const a of auftraege) {
const liste = auftraegeProKunde.get(a.kundeId) ?? [];
liste.push(a);
auftraegeProKunde.set(a.kundeId, liste);
}
return kunden.map((kunde) => {
const eigene = auftraegeProKunde.get(kunde.id) ?? [];
const letzter = eigene
.map((a) => a.angelegtAm)
.sort()
.at(-1);
return {
...kunde,
umsatzGesamt: umsatzProKunde.get(kunde.id) ?? 0,
auftraegeGesamt: eigene.filter((a) => a.status !== "verloren").length,
letzterAuftragAm: letzter ?? null,
};
});
}
/* --------------------------------------------------------------- Export */
function baueDatensatz(): Datensatz {
const mitarbeiter = baueMitarbeiter();
const rohkunden = baueKunden(420);
const auftraege = baueAuftraege(rohkunden, mitarbeiter, 1900);
const rechnungen = baueRechnungen(auftraege);
const kunden = ergaenzeKundenkennzahlen(rohkunden, auftraege, rechnungen);
const zeiten = baueZeiten(mitarbeiter, auftraege);
return { mitarbeiter, kunden, auftraege, rechnungen, zeiten };
}
/** Einmal erzeugter, unveränderlicher Datensatz für die gesamte App. */
export const DATENSATZ: Datensatz = baueDatensatz();
/** Nur für Debug-Ausgaben / die Komponenten-Galerie. */
export const DATENSATZ_INFO = {
mitarbeiter: DATENSATZ.mitarbeiter.length,
kunden: DATENSATZ.kunden.length,
auftraege: DATENSATZ.auftraege.length,
rechnungen: DATENSATZ.rechnungen.length,
zeiteintraege: DATENSATZ.zeiten.length,
statusLabels: Object.values(AUFTRAG_STATUS_LABEL),
};