Files
dejanandClaude Opus 5 e6e5a105a8 Namen der Demo-Mitarbeiter als feste Paare statt ueber Kreuz
"Sebastian Yilmaz" entstand nicht durch eine Namensauswahl, sondern durch die
Bauart: Vor- und Nachnamen lagen in zwei Listen und wurden ueber Index
kombiniert. Bei 24 x 24 Moeglichkeiten entstehen dabei zwangslaeufig Paare,
die nicht zusammengehoeren.

Jetzt eine Liste ganzer Namen. Aus "Sebastian Yilmaz" wird "Mehmet Yılmaz",
dazu "Emre Demir" als zweiter tuerkischer Name. Feste Paare kosten nichts und
koennen den Fehler nicht wiederholen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
2026-08-31 21:12:47 +02:00

640 lines
22 KiB
TypeScript
Raw Permalink 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.
/**
* 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 */
/**
* Ganze Namen statt getrennter Listen.
*
* Vorher wurden Vor- und Nachnamen ueber Kreuz kombiniert. Bei 24 x 24
* Moeglichkeiten entstehen dabei zwangslaeufig Paare, die nicht zusammengehoeren
* ("Sebastian Yilmaz"). Feste Paare kosten nichts und koennen das nicht.
*/
const NAMEN = [
"Thomas Krause", "Michael Weber", "Andreas Klein", "Stefan Richter",
"Christian Fischer", "Markus Hoffmann", "Peter Lange", "Martin Schäfer",
"Daniel Becker", "Kevin Wolf", "Mehmet Yılmaz", "Tobias Neumann",
"Jens Zimmermann", "Sandra Braun", "Maike Schmidt", "Nicole Hartmann",
"Julia Werner", "Patrick Peters", "Dennis Schulz", "Marco Wagner",
"Sven Meyer", "Lukas Müller", "Fabian Schneider", "Emre Demir",
];
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 = NAMEN[i % NAMEN.length];
while (benutzt.has(name)) name = aus(NAMEN);
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),
};