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
630 lines
22 KiB
TypeScript
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),
|
|
};
|