// Seeds ~800 realistic Austrian/DACH employees + org structure + a handful of // open positions, per spec §5. // // Run with: node --env-file=.env.local supabase/seed.ts // Uses the service-role key over the Supabase REST API (bypasses RLS) — no // direct Postgres connection needed. All row ids are generated client-side // so parent/child references never require a round-trip. import { createClient } from "@supabase/supabase-js"; import { randomUUID } from "node:crypto"; // Explicit .ts extension: this file is run directly by Node (type-stripping, // ESM), where an extensionless relative import does not resolve. import { svnrCheckDigit, svnrErrorMessage, validateSvnr } from "../lib/svnr.ts"; import { ABSENCE_TYPES } from "../lib/absence.ts"; import { buildOrg, type BuiltUnit, type DivisionDef } from "./build-org.ts"; const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL; const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in the environment"); } const ADMIN_EMAIL = "m.stubhan@loudspring.at"; const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { autoRefreshToken: false, persistSession: false }, }); // ── RNG helpers ────────────────────────────────────────────── function randInt(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1)) + min; } function pick(arr: readonly T[]): T { return arr[randInt(0, arr.length - 1)]; } function chance(probability: number): boolean { return Math.random() < probability; } function weightedPick(entries: readonly (readonly [T, number])[]): T { const total = entries.reduce((sum, [, w]) => sum + w, 0); let r = Math.random() * total; for (const [value, w] of entries) { r -= w; if (r <= 0) return value; } return entries[entries.length - 1][0]; } function addDays(d: Date, days: number): Date { const r = new Date(d); r.setDate(r.getDate() + days); return r; } // Bewusst *nicht* über toISOString(): alle Daten hier entstehen aus lokalen // Bestandteilen (new Date(jahr, monat, tag), addDays), und toISOString rechnet // nach UTC um. In Österreich verschiebt das jedes Datum um einen Tag nach // hinten — womit das gespeicherte Geburtsdatum nicht mehr zu dem passt, das // makeSvNummer aus denselben lokalen Bestandteilen in die SV-Nummer schreibt. function isoDate(d: Date): string { const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${d.getFullYear()}-${m}-${day}`; } function randomDateBetween(start: Date, end: Date): Date { const t = start.getTime() + Math.random() * (end.getTime() - start.getTime()); return new Date(t); } function slugify(s: string): string { return s .toLowerCase() .replace(/ä/g, "ae") .replace(/ö/g, "oe") .replace(/ü/g, "ue") .replace(/ß/g, "ss") .replace(/[^a-z0-9]+/g, ""); } const TODAY = new Date(); // ── Name pools ─────────────────────────────────────────────── const MALE_FIRST_NAMES = [ "Michael", "Andreas", "Thomas", "Stefan", "Christian", "Martin", "Markus", "Daniel", "Christoph", "Alexander", "Wolfgang", "Peter", "Josef", "Franz", "Johann", "Georg", "Bernhard", "Florian", "Manuel", "Philipp", "Sebastian", "Patrick", "Dominik", "Simon", "Lukas", "David", "Matthias", "Robert", "Gerhard", "Helmut", "Karl", "Anton", "Rudolf", "Herbert", "Kurt", "Werner", "Erwin", "Hannes", "Fabian", "Julian", ]; const FEMALE_FIRST_NAMES = [ "Maria", "Anna", "Sabine", "Andrea", "Claudia", "Petra", "Julia", "Sarah", "Lisa", "Nicole", "Christine", "Elisabeth", "Monika", "Barbara", "Karin", "Silvia", "Martina", "Susanne", "Katharina", "Eva", "Michaela", "Stephanie", "Verena", "Melanie", "Sandra", "Birgit", "Ingrid", "Renate", "Gabriele", "Brigitte", "Theresa", "Laura", "Hannah", "Johanna", "Magdalena", "Carina", "Vanessa", "Nadine", "Bettina", "Ursula", ]; const LAST_NAMES = [ "Gruber", "Huber", "Bauer", "Wagner", "Müller", "Pichler", "Steiner", "Moser", "Mayer", "Hofer", "Leitner", "Berger", "Fuchs", "Eder", "Fischer", "Schmid", "Winkler", "Weber", "Schwarz", "Maier", "Schneider", "Reiter", "Mayr", "Wolf", "Aigner", "Lang", "Baumgartner", "Auer", "Brunner", "Wallner", "Wimmer", "Egger", "Binder", "Wieser", "Höller", "Schmidt", "Riegler", "Kaiser", "Lechner", "Kogler", "Peer", "Lehner", "Zimmermann", "Pöll", "Haas", "Novak", "Horvat", "Vukovic", "Yilmaz", "Demir", "Kovac", "Nemec", "Simic", "Kralj", "Toth", "Szabo", ]; const NATIONALITIES: readonly (readonly [string, number])[] = [ ["Österreich", 75], ["Deutschland", 5], ["Tschechien", 3], ["Slowenien", 2], ["Türkei", 5], ["Serbien", 3], ["Kroatien", 3], ["Bosnien", 2], ["Ungarn", 2], ]; function addressCountryFor(nationality: string): string { return ["Österreich", "Deutschland", "Tschechien", "Slowenien"].includes(nationality) ? nationality : "Andere"; } const STREETS = ["Hauptstraße", "Bahnhofstraße", "Schulgasse", "Kirchenplatz", "Gartenweg", "Industriestraße", "Ringstraße", "Feldweg"]; // Home address should be plausible for the employee's actual work location, // not a one-size-fits-all Vienna postal code regardless of where they're // based (found during the consolidation review). const HOME_LOCALE_BY_LOCATION: Record string }> = { "Wien-Hernals": { city: "Wien", postal: () => String(randInt(1100, 1230)) }, Wolkersdorf: { city: "Wolkersdorf", postal: () => "2120" }, Köln: { city: "Köln", postal: () => String(randInt(50667, 51149)) }, Brünn: { city: "Brno", postal: () => `${randInt(600, 664)} ${randInt(10, 99)}` }, Ljubljana: { city: "Ljubljana", postal: () => "1000" }, }; const EXIT_REASONS = [ "Einvernehmliche Auflösung", "Kündigung AN", "Kündigung AG", "Befristungsablauf", "Pensionierung", "Entlassung", ]; // ── Locations ──────────────────────────────────────────────── const LOCATIONS = [ { id: randomUUID(), name: "Wien-Hernals", country: "Österreich" }, { id: randomUUID(), name: "Wolkersdorf", country: "Österreich" }, { id: randomUUID(), name: "Köln", country: "Deutschland" }, { id: randomUUID(), name: "Brünn", country: "Tschechien" }, { id: randomUUID(), name: "Ljubljana", country: "Slowenien" }, ] as const; const LOCATION_WEIGHTS: readonly (readonly [(typeof LOCATIONS)[number], number])[] = [ [LOCATIONS[0], 55], [LOCATIONS[1], 20], [LOCATIONS[2], 10], [LOCATIONS[3], 10], [LOCATIONS[4], 5], ]; // ── Org structure ──────────────────────────────────────────── // TeamDef/DeptDef/DivisionDef kommen aus build-org.ts — dort steht auch, was // daraus gebaut wird. const SCALE = 1.44; // brings the ~556-person base roster up to ~800 const DIVISIONS: DivisionDef[] = [ { name: "Produktion", headTitle: "Bereichsleitung Produktion", departments: [ { name: "Fertigung", leadTitle: "Abteilungsleitung Fertigung", teams: [ { name: "Montage", leadTitle: "Teamleitung Montage", icTitles: ["Maschinenbediener:in", "Montagemitarbeiter:in", "Anlagenführer:in"], baseSize: 45 }, { name: "CNC-Fertigung", leadTitle: "Teamleitung CNC-Fertigung", icTitles: ["CNC-Fräser:in", "CNC-Dreher:in", "Zerspanungstechniker:in"], baseSize: 35 }, { name: "Qualitätssicherung Fertigung", leadTitle: "Teamleitung Qualitätssicherung Fertigung", icTitles: ["Qualitätsprüfer:in", "Messtechniker:in"], baseSize: 22 }, ], }, { name: "Instandhaltung", leadTitle: "Abteilungsleitung Instandhaltung", teams: [ { name: "Elektrotechnik", leadTitle: "Teamleitung Elektrotechnik", icTitles: ["Elektrotechniker:in", "Automatisierungstechniker:in"], baseSize: 24 }, { name: "Mechanik", leadTitle: "Teamleitung Mechanik", icTitles: ["Industriemechaniker:in", "Schlosser:in"], baseSize: 22 }, ], }, ], }, { name: "Logistik & Einkauf", headTitle: "Bereichsleitung Logistik & Einkauf", departments: [ { name: "Logistik", leadTitle: "Abteilungsleitung Logistik", teams: [ { name: "Lager", leadTitle: "Teamleitung Lager", icTitles: ["Lagerlogistiker:in", "Staplerfahrer:in", "Kommissionierer:in"], baseSize: 30 }, { name: "Versand", leadTitle: "Teamleitung Versand", icTitles: ["Versandmitarbeiter:in", "Speditionskaufmann/-frau"], baseSize: 20 }, { name: "Fuhrpark", leadTitle: "Teamleitung Fuhrpark", icTitles: ["Berufskraftfahrer:in", "Fuhrparkdisponent:in"], baseSize: 16 }, ], }, { name: "Einkauf", leadTitle: "Abteilungsleitung Einkauf", teams: [ { name: "Strategischer Einkauf", leadTitle: "Teamleitung Strategischer Einkauf", icTitles: ["Einkäufer:in", "Category Manager:in"], baseSize: 14 }, { name: "Operativer Einkauf", leadTitle: "Teamleitung Operativer Einkauf", icTitles: ["Operative:r Einkäufer:in", "Bestelldisponent:in"], baseSize: 14 }, ], }, ], }, { name: "Vertrieb & Marketing", headTitle: "Bereichsleitung Vertrieb & Marketing", departments: [ { name: "Vertrieb", leadTitle: "Abteilungsleitung Vertrieb", teams: [ { name: "Key Account Management", leadTitle: "Teamleitung Key Account Management", icTitles: ["Key Account Manager:in", "Sales Manager:in"], baseSize: 16 }, { name: "Außendienst", leadTitle: "Teamleitung Außendienst", icTitles: ["Außendienstmitarbeiter:in", "Gebietsverkaufsleiter:in"], baseSize: 22 }, { name: "Vertriebsinnendienst", leadTitle: "Teamleitung Vertriebsinnendienst", icTitles: ["Vertriebsinnendienstmitarbeiter:in", "Auftragssachbearbeiter:in"], baseSize: 18 }, ], }, { name: "Marketing", leadTitle: "Abteilungsleitung Marketing", teams: [ { name: "Brand Marketing", leadTitle: "Teamleitung Brand Marketing", icTitles: ["Brand Manager:in", "Produktmanager:in"], baseSize: 12 }, { name: "Digital Marketing", leadTitle: "Teamleitung Digital Marketing", icTitles: ["Digital Marketing Manager:in", "Social-Media-Manager:in"], baseSize: 12 }, ], }, ], }, { name: "Forschung & Entwicklung", headTitle: "Bereichsleitung Forschung & Entwicklung", departments: [ { name: "Produktentwicklung", leadTitle: "Abteilungsleitung Produktentwicklung", teams: [ { name: "Rezeptur & Sensorik", leadTitle: "Teamleitung Rezeptur & Sensorik", icTitles: ["Lebensmitteltechniker:in", "Sensoriker:in"], baseSize: 16 }, { name: "Verpackungsentwicklung", leadTitle: "Teamleitung Verpackungsentwicklung", icTitles: ["Verpackungstechniker:in", "Packmittelentwickler:in"], baseSize: 12 }, ], }, { name: "Verfahrenstechnik", leadTitle: "Abteilungsleitung Verfahrenstechnik", teams: [ { name: "Prozessoptimierung", leadTitle: "Teamleitung Prozessoptimierung", icTitles: ["Verfahrenstechniker:in", "Prozessingenieur:in"], baseSize: 14 }, { name: "Anlagentechnik", leadTitle: "Teamleitung Anlagentechnik", icTitles: ["Anlagentechniker:in", "Projektingenieur:in"], baseSize: 12 }, ], }, ], }, { name: "Qualitätsmanagement", headTitle: "Bereichsleitung Qualitätsmanagement", departments: [ { name: "Qualitätssicherung", leadTitle: "Abteilungsleitung Qualitätssicherung", teams: [ { name: "Wareneingangsprüfung", leadTitle: "Teamleitung Wareneingangsprüfung", icTitles: ["Qualitätsprüfer:in", "Wareneingangskontrolleur:in"], baseSize: 14 }, { name: "Prozessaudit", leadTitle: "Teamleitung Prozessaudit", icTitles: ["Qualitätsauditor:in", "QM-Beauftragte:r"], baseSize: 10 }, ], }, { name: "Lebensmittelsicherheit", leadTitle: "Abteilungsleitung Lebensmittelsicherheit", teams: [ { name: "Hygienemanagement", leadTitle: "Teamleitung Hygienemanagement", icTitles: ["Hygienebeauftragte:r", "Lebensmittelsicherheitsbeauftragte:r"], baseSize: 12 }, { name: "Zertifizierung", leadTitle: "Teamleitung Zertifizierung", icTitles: ["Zertifizierungsmanager:in", "QM-Sachbearbeiter:in"], baseSize: 10 }, ], }, ], }, { name: "IT", headTitle: "Bereichsleitung IT", departments: [ { name: "Business Applications", leadTitle: "Abteilungsleitung Business Applications", teams: [ { name: "SAP-Team", leadTitle: "Teamleitung SAP-Team", icTitles: ["SAP-Consultant", "SAP-Entwickler:in"], baseSize: 12 }, { name: "Power Platform & Automatisierung", leadTitle: "Teamleitung Power Platform & Automatisierung", icTitles: ["Power Platform Developer:in", "Prozessautomatisierer:in"], baseSize: 10 }, ], }, { name: "Infrastruktur", leadTitle: "Abteilungsleitung Infrastruktur", teams: [ { name: "Netzwerk & Security", leadTitle: "Teamleitung Netzwerk & Security", icTitles: ["Netzwerktechniker:in", "IT-Security-Spezialist:in"], baseSize: 12 }, { name: "IT-Support", leadTitle: "Teamleitung IT-Support", icTitles: ["IT-Support-Mitarbeiter:in", "Systemadministrator:in"], baseSize: 14 }, ], }, ], }, { name: "Finanzen & Controlling", headTitle: "Bereichsleitung Finanzen & Controlling", departments: [ { name: "Finanzen", leadTitle: "Abteilungsleitung Finanzen", teams: [ { name: "Buchhaltung", leadTitle: "Teamleitung Buchhaltung", icTitles: ["Buchhalter:in", "Bilanzbuchhalter:in"], baseSize: 16 }, { name: "Treasury", leadTitle: "Teamleitung Treasury", icTitles: ["Treasury-Manager:in", "Finanzanalyst:in"], baseSize: 10 }, ], }, { name: "Controlling", leadTitle: "Abteilungsleitung Controlling", teams: [ { name: "Konzerncontrolling", leadTitle: "Teamleitung Konzerncontrolling", icTitles: ["Controller:in", "Financial Analyst:in"], baseSize: 12 }, { name: "Werkscontrolling", leadTitle: "Teamleitung Werkscontrolling", icTitles: ["Werkscontroller:in", "Kostenrechner:in"], baseSize: 12 }, ], }, ], }, { name: "Human Resources", headTitle: "Bereichsleitung Human Resources", departments: [ { name: "HR Business Partner", leadTitle: "Abteilungsleitung HR Business Partner", teams: [ { name: "Recruiting", leadTitle: "Teamleitung Recruiting", icTitles: ["Recruiter:in", "Talent Acquisition Manager:in"], baseSize: 10 }, { name: "Personalentwicklung", leadTitle: "Teamleitung Personalentwicklung", icTitles: ["Personalentwickler:in", "Trainer:in"], baseSize: 8 }, ], }, { name: "Personaladministration", leadTitle: "Abteilungsleitung Personaladministration", teams: [ { name: "Gehaltsabrechnung", leadTitle: "Teamleitung Gehaltsabrechnung", icTitles: ["Payroll-Spezialist:in", "Personalverrechner:in"], baseSize: 10 }, { name: "HR-Systeme", leadTitle: "Teamleitung HR-Systeme", icTitles: ["HR-IT-Spezialist:in", "HRIS Manager:in"], baseSize: 8 }, ], }, ], }, ]; // ── Employee generation ────────────────────────────────────── type EmployeeRow = { id: string; first_name: string; last_name: string; gender: "m" | "w"; birth_date: string; sv_nummer: string; nationality: string; address: string; postal_code: string; city: string; address_country: string; email: string; phone: string; // Die Einordnung in die Organisation steckt jetzt ausschliesslich in der // Planstelle (position_assignments -> om_positions -> org_units). Keine // division_id/team_id/manager_id mehr auf der Person. job_title: string; location_id: string; employment_type: "Vollzeit" | "Teilzeit"; weekly_hours: number; contract_type: "unbefristet" | "befristet"; contract_end_date: string | null; paygrade: "A" | "B" | "C" | "D" | "E" | "F"; source: "Intern" | "Extern"; status: "Aktiv" | "Karenz" | "Geplant" | "Ausgetreten"; entry_date: string; exit_date: string | null; exit_reason: string | null; karenz_start_date: string | null; karenz_return_date: string | null; absence_type: string | null; }; type HistoryRow = { employee_id: string; event_date: string; event_type: string; description: string; }; const usedEmails = new Set(); function makeEmail(firstName: string, lastName: string): string { const base = `${slugify(firstName)}.${slugify(lastName)}`; let email = `${base}@test.manner.at`; let n = 2; while (usedEmails.has(email)) { email = `${base}${n}@test.manner.at`; n += 1; } usedEmails.add(email); return email; } // Used a random four-digit prefix before, so the check digit was right only // by chance — which the SVNR validation trigger now rejects outright. The // serial is still random; only the check digit is computed for it. function makeSvNummer(birthDate: Date): string { const dd = String(birthDate.getDate()).padStart(2, "0"); const mm = String(birthDate.getMonth() + 1).padStart(2, "0"); const yy = String(birthDate.getFullYear()).slice(-2); const tail = `${dd}${mm}${yy}`; // Not every serial yields a usable check digit (a weighted sum of 11 is // skipped rather than wrapped), so draw until one does. for (;;) { const serial = String(randInt(1, 999)).padStart(3, "0"); const check = svnrCheckDigit(`${serial}0${tail}`); if (check !== null) return `${serial}${check} ${tail}`; } } function birthDateForAge(age: number): Date { const year = TODAY.getFullYear() - age; return new Date(year, randInt(0, 11), randInt(1, 28)); } function paygradeForIc(): EmployeeRow["paygrade"] { return weightedPick([ ["A", 15], ["B", 35], ["C", 30], ["D", 20], ]); } function newHireBase(jobTitle: string) { const gender: "m" | "w" = chance(0.48) ? "m" : "w"; const firstName = pick(gender === "m" ? MALE_FIRST_NAMES : FEMALE_FIRST_NAMES); const lastName = pick(LAST_NAMES); const nationality = weightedPick(NATIONALITIES); const location = weightedPick(LOCATION_WEIGHTS); const homeLocale = HOME_LOCALE_BY_LOCATION[location.name]; return { id: randomUUID(), first_name: firstName, last_name: lastName, gender, nationality, address: `${pick(STREETS)} ${randInt(1, 90)}`, postal_code: homeLocale.postal(), city: homeLocale.city, address_country: addressCountryFor(nationality), email: makeEmail(firstName, lastName), phone: `+43 664 ${randInt(1000000, 9999999)}`, job_title: jobTitle, location_id: location.id, }; } const employees: EmployeeRow[] = []; const history: HistoryRow[] = []; const icPoolForStatusAssignment: EmployeeRow[] = []; function finalizeEmployee( base: ReturnType, opts: { paygrade: EmployeeRow["paygrade"]; entryDate?: Date } ): EmployeeRow { // Alter und Eintritt hängen zusammen: sonst entstehen Beschäftigte, die mit // sechs Jahren angefangen haben. Ist der Eintritt vorgegeben (ausgetretene // Vorgänger:innen, geplante Eintritte), richtet sich das Alter danach — // sonst umgekehrt. let age: number; let entryDate: Date; if (opts.entryDate) { entryDate = opts.entryDate; const tenureYears = Math.max(0, Math.floor((TODAY.getTime() - entryDate.getTime()) / (365.25 * 864e5))); const minAge = Math.min(Math.max(22, 20 + tenureYears), 55); age = randInt(minAge, 62); } else { age = randInt(22, 60); const maxTenureYears = Math.min(15, age - 20); entryDate = randomDateBetween(addDays(TODAY, -maxTenureYears * 365), addDays(TODAY, -30)); } const birthDate = birthDateForAge(age); const employmentType: "Vollzeit" | "Teilzeit" = chance(0.8) ? "Vollzeit" : "Teilzeit"; const weeklyHours = employmentType === "Vollzeit" ? 38.5 : pick([15, 18, 20, 25, 28, 30, 32, 35]); const isBefristet = chance(0.1) && entryDate > addDays(TODAY, -540); const contractEndDate = isBefristet ? addDays(TODAY, randInt(90, 540)) : null; const row: EmployeeRow = { ...base, birth_date: isoDate(birthDate), sv_nummer: makeSvNummer(birthDate), employment_type: employmentType, weekly_hours: weeklyHours, contract_type: isBefristet ? "befristet" : "unbefristet", contract_end_date: contractEndDate ? isoDate(contractEndDate) : null, paygrade: opts.paygrade, source: chance(0.15) ? "Intern" : "Extern", status: "Aktiv", entry_date: isoDate(entryDate), exit_date: null, exit_reason: null, karenz_start_date: null, karenz_return_date: null, absence_type: null, }; history.push({ employee_id: row.id, event_date: row.entry_date, event_type: "Eintritt", description: `Eintritt als ${row.job_title}`, }); if (row.status === "Aktiv" && entryDate < addDays(TODAY, -2 * 365) && chance(0.06)) { const promoDate = randomDateBetween(addDays(entryDate, 365), addDays(TODAY, -30)); history.push({ employee_id: row.id, event_date: isoDate(promoDate), event_type: "Beförderung", description: `Beförderung im Rahmen der Laufbahnentwicklung, neue Position: ${row.job_title}`, }); } return row; } // ── Organisation im OM-Modell ──────────────────────────────── // Der Baum kommt aus buildOrg(): reine Funktion, eigene Tests // (tests/unit/build-org.test.ts). Der Seed entscheidet hier nur noch, *wer* // welche Planstelle besetzt — die Struktur selbst ist nicht mehr seine Sache. const COMPANY_NAME = "Alpenwerk Industrie GmbH"; const scaledDivisions: DivisionDef[] = DIVISIONS.map((div) => ({ ...div, departments: div.departments.map((dept) => ({ ...dept, // -1, weil die Teamleitung im Altmodell Teil der Teamgrösse war und // buildOrg sie zusätzlich zu icTitles anlegt. teams: dept.teams.map((t) => ({ ...t, baseSize: Math.max(1, Math.round(t.baseSize * SCALE) - 1) })), })), })); const org = buildOrg(COMPANY_NAME, scaledDivisions, randomUUID); const unitById = new Map(org.units.map((u) => [u.id, u])); const jobTitleById = new Map(org.jobs.map((j) => [j.id, j.title])); type AssignmentRow = { position_id: string; employee_id: string; valid_from: string; valid_to: string | null; }; const assignments: AssignmentRow[] = []; // Wer welche Planstelle besetzt, wird bewusst nicht überall besetzt: Vakanz // ist im OM-Modell keine eigene Tabelle mehr, sondern eine Planstelle ohne // laufende Besetzung. Ein paar davon braucht es, damit "offene Stellen" und // die Vertretungsregel bei fehlender Leitung überhaupt Daten haben. const VAKANT_IC = 8; // offene Stellen ohne Nachfolge const VAKANT_GEPLANT = 3; // offene Stellen mit Eintritt in der Zukunft const VAKANT_LEITUNG = 3; // unbesetzte Leitungen -> Berichtslinie rollt hoch const AUSGETRETEN = 40; // Vorgänger:innen auf heute besetzten Planstellen const LANGZEITABWESEND = 12; const GEPLANTER_AUSTRITT = 3; function shuffle(arr: T[]): T[] { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = randInt(0, i); [a[i], a[j]] = [a[j], a[i]]; } return a; } function paygradeForPosition(p: (typeof org.positions)[number], title: string): EmployeeRow["paygrade"] { if (!p.is_chief) return title.startsWith("Assistenz") ? "C" : paygradeForIc(); const type = unitById.get(p.org_unit_id)!.unit_type; return type === "Gesellschaft" || type === "Bereich" ? "F" : "E"; } /** Besetzt eine Planstelle laufend und legt die Person an. */ function occupy(p: (typeof org.positions)[number]): EmployeeRow { const title = jobTitleById.get(p.job_id)!; const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForPosition(p, title) }); employees.push(e); assignments.push({ position_id: p.id, employee_id: e.id, valid_from: e.entry_date, valid_to: null }); return e; } const chiefPositions = org.positions.filter((p) => p.is_chief); const icPositions = org.positions.filter((p) => !p.is_chief); // Leitungen: alle besetzen bis auf ein paar Teamleitungen, damit die // Hochrollen-Regel im Organigramm sichtbar wird. const vakanteLeitungen = new Set( shuffle(chiefPositions.filter((p) => unitById.get(p.org_unit_id)!.unit_type === "Team")) .slice(0, VAKANT_LEITUNG) .map((p) => p.id) ); const leadEmployees: EmployeeRow[] = []; for (const p of chiefPositions) { if (vakanteLeitungen.has(p.id)) continue; leadEmployees.push(occupy(p)); } // Mitarbeiter-Planstellen: der Rest wird besetzt, ein Teil bleibt offen. const shuffledIc = shuffle(icPositions); const offeneStellen = shuffledIc.slice(0, VAKANT_IC); const geplanteStellen = shuffledIc.slice(VAKANT_IC, VAKANT_IC + VAKANT_GEPLANT); const besetzteIc = shuffledIc.slice(VAKANT_IC + VAKANT_GEPLANT); const icEmployees = besetzteIc.map((p) => occupy(p)); void offeneStellen; // bleiben unbesetzt — genau das macht sie zu offenen Stellen // ── Statusverteilung (§5) ──────────────────────────────────── const statusPool = shuffle(icEmployees); let cursor = 0; // Eintritt in der Zukunft: die Person ist angelegt, die Planstelle heute noch // vakant, die Besetzung beginnt erst. Genau der Fall, für den die Planstellen // zeitabhängig sind. for (const p of geplanteStellen) { const futureEntry = addDays(TODAY, randInt(10, 90)); const title = jobTitleById.get(p.job_id)!; const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForIc(), entryDate: futureEntry }); e.status = "Geplant"; employees.push(e); assignments.push({ position_id: p.id, employee_id: e.id, valid_from: e.entry_date, valid_to: null }); } // Langzeitabwesenheit, über die Arten gestreut statt alle als Karenz — sonst // ist die Auswertung nach Art nicht zu sehen. Zwei davon treffen bewusst eine // Teamleitung, damit die Vertretungsregel auch mit *abwesender* (nicht nur // unbesetzter) Leitung Daten hat. const abwesende: EmployeeRow[] = [ ...shuffle(leadEmployees.filter((e) => e.job_title.startsWith("Teamleitung"))).slice(0, 2), ]; while (abwesende.length < LANGZEITABWESEND && cursor < statusPool.length) { abwesende.push(statusPool[cursor++]); } for (const e of abwesende) { const entryDate = new Date(e.entry_date); const karenzStart = randomDateBetween(addDays(entryDate, 180), addDays(TODAY, -10)); const returnDate = addDays(TODAY, randInt(10, 300)); const absenceType = pick(ABSENCE_TYPES); e.status = "Karenz"; e.karenz_start_date = isoDate(karenzStart); e.karenz_return_date = isoDate(returnDate); e.absence_type = absenceType; history.push({ employee_id: e.id, event_date: isoDate(karenzStart), event_type: "Karenz", description: `${absenceType}, geplante Rückkehr am ${isoDate(returnDate)}`, }); } // Geplante Austritte: noch aktiv, die Besetzung endet an einem Datum in der // Zukunft. for (let i = 0; i < GEPLANTER_AUSTRITT && cursor < statusPool.length; i++, cursor++) { const e = statusPool[cursor]; const futureExit = addDays(TODAY, randInt(10, 90)); e.exit_date = isoDate(futureExit); e.exit_reason = pick(EXIT_REASONS); const a = assignments.find((x) => x.employee_id === e.id)!; a.valid_to = e.exit_date; } // ── Ausgetretene als Vorgänger:innen auf besetzten Planstellen ─────── // Im Altmodell hingen Ausgetretene weiter an einem Team und liessen dessen // Planstellen als vakant erscheinen. Im OM-Modell hat eine Planstelle eine // Besetzungshistorie: die vorherige Besetzung ist beendet, die heutige läuft. // Voraussetzung ist, dass der Austritt vor dem Eintritt der heutigen // Besetzung liegt — sonst wäre die Planstelle zweimal gleichzeitig besetzt. { const holderOf = new Map(icEmployees.map((e) => [e.id, e])); const uebernehmbar = shuffle( assignments.filter((a) => { const holder = holderOf.get(a.employee_id); // Genug Vorlauf, damit vor der heutigen Besetzung noch eine ganze // Beschäftigung Platz hat. return holder && new Date(holder.entry_date) > addDays(TODAY, -8 * 365) && holder.status === "Aktiv"; }) ).slice(0, AUSGETRETEN); for (const a of uebernehmbar) { const nachfolgerEintritt = new Date(a.valid_from); const exitDate = addDays(nachfolgerEintritt, -randInt(1, 60)); const entryDate = addDays(exitDate, -randInt(400, 3000)); const p = org.positions.find((x) => x.id === a.position_id)!; const title = jobTitleById.get(p.job_id)!; const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForIc(), entryDate }); e.status = "Ausgetreten"; e.exit_date = isoDate(exitDate); e.exit_reason = pick(EXIT_REASONS); // Ein befristeter Vertrag, der nach dem Austritt endet, wäre Unsinn; und // finalizeEmployee kann eine Beförderung bis heute gestreut haben, die // hier nach dem Austritt läge. e.contract_type = "unbefristet"; e.contract_end_date = null; for (let i = history.length - 1; i >= 0; i--) { if (history[i].employee_id === e.id && history[i].event_date > e.exit_date) history.splice(i, 1); } employees.push(e); history.push({ employee_id: e.id, event_date: e.exit_date, event_type: "Austritt", description: `Austritt (${e.exit_reason})`, }); assignments.push({ position_id: p.id, employee_id: e.id, valid_from: e.entry_date, valid_to: e.exit_date, }); } } // ── Insert helpers ─────────────────────────────────────────── /** Eltern vor Kindern, damit parent_id beim Einfügen schon existiert. */ function sortParentsFirst(units: BuiltUnit[]): BuiltUnit[] { const byParent = new Map(); for (const u of units) { const list = byParent.get(u.parent_id) ?? []; list.push(u); byParent.set(u.parent_id, list); } const out: BuiltUnit[] = []; const queue = [...(byParent.get(null) ?? [])]; while (queue.length > 0) { const u = queue.shift()!; out.push(u); queue.push(...(byParent.get(u.id) ?? [])); } if (out.length !== units.length) throw new Error("Org-Baum hat abgehängte Einheiten"); return out; } async function insertInChunks(table: string, rows: Record[], chunkSize = 200) { for (let i = 0; i < rows.length; i += chunkSize) { const chunk = rows.slice(i, i + chunkSize); const { error } = await supabase.from(table).insert(chunk); if (error) throw new Error(`Insert into ${table} failed: ${error.message}`); } console.log(` inserted ${rows.length} row(s) into ${table}`); } // Alles ausser den Anmeldekonten. profiles und auth.users bleiben stehen — // sonst sperrt sich der Seed selbst aus der Anwendung aus. // // Reihenfolge: Kinder vor Eltern. org_units verweist auf sich selbst; ein // einzelnes DELETE über alle Zeilen geht trotzdem durch, weil Postgres die // Fremdschlüsselprüfung erst nach dem Statement auswertet. const WIPE_ORDER = [ "reorg_moves", "reorg_scenarios", "pending_org_changes", "hire_drafts", "employee_notes", "employee_dependents", "employee_history", "position_assignments", "audit_log", "saved_reports", "employees", "om_positions", "jobs", "org_units", "locations", ]; async function wipe() { for (const table of WIPE_ORDER) { // PostgREST verlangt einen Filter; "id ist nicht null" trifft alles. const { error } = await supabase.from(table).delete().not("id", "is", null); if (error) throw new Error(`Delete from ${table} failed: ${error.message}`); const { count } = await supabase.from(table).select("*", { count: "exact", head: true }); if (count) throw new Error(`${table} ist nach dem Löschen nicht leer (${count} Zeilen)`); console.log(` geleert: ${table}`); } } /** * Prüft die Zusagen, die der Seed der Datenbank gegenüber macht, bevor er sie * löscht. Die Unique-Indizes fangen das Meiste ab — aber erst nach dem * Löschen, und dann steht die Datenbank leer da. */ function pruefeInvarianten() { const laufend = assignments.filter((a) => a.valid_to === null); const jeStelle = new Map(); for (const a of laufend) jeStelle.set(a.position_id, (jeStelle.get(a.position_id) ?? 0) + 1); for (const [id, n] of jeStelle) if (n > 1) throw new Error(`Planstelle ${id} ist ${n}-fach laufend besetzt`); const jePerson = new Map(); for (const a of laufend) jePerson.set(a.employee_id, (jePerson.get(a.employee_id) ?? 0) + 1); for (const [id, n] of jePerson) if (n > 1) throw new Error(`Person ${id} hat ${n} laufende Planstellen`); // Überlappende Besetzungen derselben Planstelle: der Unique-Index deckt nur // die laufende ab, die Historie könnte sich also unbemerkt überschneiden. const nachStelle = new Map(); for (const a of assignments) { const list = nachStelle.get(a.position_id) ?? []; list.push(a); nachStelle.set(a.position_id, list); } for (const [id, list] of nachStelle) { const sortiert = [...list].sort((x, y) => x.valid_from.localeCompare(y.valid_from)); for (let i = 1; i < sortiert.length; i++) { const vorher = sortiert[i - 1]; if (vorher.valid_to === null || vorher.valid_to > sortiert[i].valid_from) { throw new Error(`Planstelle ${id}: Besetzungen überschneiden sich (${vorher.valid_from}–${vorher.valid_to})`); } } } for (const a of assignments) { if (a.valid_to !== null && a.valid_to <= a.valid_from) throw new Error(`Besetzung ${a.position_id}: valid_to <= valid_from`); } const personen = new Set(employees.map((e) => e.id)); for (const a of assignments) if (!personen.has(a.employee_id)) throw new Error("Besetzung ohne Person"); for (const h of history) if (!personen.has(h.employee_id)) throw new Error("Historie ohne Person"); // Jede Person genau eine Planstelle — auch die ausgetretenen, sonst hinge // sie ausserhalb der Organisation. const mitStelle = new Set(assignments.map((a) => a.employee_id)); for (const e of employees) if (!mitStelle.has(e.id)) throw new Error(`${e.first_name} ${e.last_name} hat keine Planstelle`); // Die SV-Nummer trägt das Geburtsdatum in sich; weichen die beiden // voneinander ab, weist der Trigger die Zeile zurück — mitten im Einfügen, // wenn die Datenbank bereits leergeräumt ist. for (const e of employees) { const fehler = validateSvnr(e.sv_nummer, e.birth_date); if (fehler) throw new Error(`${e.email}: SV-Nummer ${e.sv_nummer} zu Geburtsdatum ${e.birth_date} — ${svnrErrorMessage(fehler)}`); } for (const e of employees) { if (e.exit_date && e.exit_date <= e.entry_date) throw new Error(`${e.email}: Austritt vor Eintritt`); } for (const h of history) { const e = employees.find((x) => x.id === h.employee_id)!; if (e.exit_date && h.event_date > e.exit_date) throw new Error(`${e.email}: Ereignis ${h.event_type} nach dem Austritt`); } } async function main() { pruefeInvarianten(); if (process.argv.includes("--dry-run")) { console.log("Trockenlauf — es wird nichts geschrieben."); berichte(); return; } console.log("Lösche alle Daten (Anmeldekonten bleiben)..."); await wipe(); console.log("\nSeeding locations..."); await insertInChunks("locations", LOCATIONS.map((l) => ({ ...l }))); console.log(`Seeding ${org.units.length} org_units...`); // Eltern vor Kindern: der Fremdschlüssel auf parent_id wird pro Zeile // geprüft, und insertInChunks zerlegt in mehrere Statements. buildOrg // liefert die Einheiten bereits in dieser Reihenfolge, aber darauf soll // sich der Seed nicht verlassen. await insertInChunks("org_units", sortParentsFirst(org.units)); console.log(`Seeding ${org.jobs.length} jobs...`); await insertInChunks("jobs", org.jobs); console.log(`Seeding ${org.positions.length} Planstellen...`); await insertInChunks("om_positions", org.positions); console.log(`Seeding ${employees.length} employees...`); await insertInChunks("employees", employees); console.log(`Seeding ${assignments.length} Besetzungen...`); await insertInChunks("position_assignments", assignments); console.log(`Seeding ${history.length} employee_history rows...`); await insertInChunks("employee_history", history); // Das HR-Konto wird nicht neu angelegt: die Auth-Konten überstehen den // Seed, und ein zweites Konto auf dieselbe Adresse liesse sich gar nicht // anlegen. Fehlt es, wird es einmalig erzeugt — das ist die eine bewusste // Freischaltung, jede weitere profiles-Zeile startet mit is_active = false // und muss von HR freigeschaltet werden (§2.3). const { data: existing } = await supabase.from("profiles").select("id, email").eq("email", ADMIN_EMAIL).maybeSingle(); if (existing) { console.log(`\nHR-Konto ${ADMIN_EMAIL} besteht weiter — Passwort unverändert.`); } else { console.log("\nLege HR-Konto an..."); const hrPassword = randomUUID().slice(0, 12) + "!Aa1"; const { data: hrUser, error: hrErr } = await supabase.auth.admin.createUser({ email: ADMIN_EMAIL, password: hrPassword, email_confirm: true, }); if (hrErr) throw new Error(`Creating HR user failed: ${hrErr.message}`); await supabase.from("profiles").insert({ id: hrUser.user.id, email: ADMIN_EMAIL, full_name: "Maximilian Stubhan", role: "hr", is_active: true, }); console.log(`HR login: ${ADMIN_EMAIL} / ${hrPassword}`); console.log("(Password is shown once here only — store it somewhere safe.)"); } // Gegenprobe an der Datenbank selbst: die Berichtslinie wird nicht mehr // gepflegt, sondern abgeleitet. Wenn der Seed den Baum falsch verdrahtet // hat, fällt das hier auf und nicht erst im Organigramm. const { data: linien, error: linienErr } = await supabase.rpc("om_reporting_lines", { p_as_of: isoDate(TODAY) }); if (linienErr) throw new Error(`om_reporting_lines failed: ${linienErr.message}`); const ohneVorgesetzte = (linien ?? []).filter( (l: { acting_manager_id: string | null }) => l.acting_manager_id === null ); console.log(`\nBerichtslinie: ${linien?.length} Zeilen, ${ohneVorgesetzte.length} ohne Vorgesetzte (erwartet: 1, die Geschäftsführung)`); console.log("\nFertig."); berichte(); } function berichte() { const heute = isoDate(TODAY); const laufendHeute = assignments.filter((a) => a.valid_from <= heute && (a.valid_to === null || a.valid_to > heute)); const zahl = (t: string) => org.units.filter((u) => u.unit_type === t).length; console.log(` Organisation: ${zahl("Gesellschaft")} Gesellschaft, ${zahl("Bereich")} Bereiche, ${zahl("Abteilung")} Abteilungen, ${zahl("Team")} Teams`); console.log(` Jobkatalog: ${org.jobs.length} Tätigkeiten`); console.log(` Planstellen: ${org.positions.length}, davon ${org.positions.length - laufendHeute.length} heute unbesetzt`); console.log(` Personen: ${employees.length}`); for (const s of ["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const) { console.log(` ${s.padEnd(12)} ${employees.filter((e) => e.status === s).length}`); } console.log(` Besetzungen: ${assignments.length} (${assignments.filter((a) => a.valid_to !== null).length} beendet)`); console.log(` Historie: ${history.length} Ereignisse`); } main().catch((err) => { console.error(err); process.exit(1); });