Alle Daten gelöscht und neu aufgebaut: 60 Organisationseinheiten, 133 Jobs,
823 Planstellen, 852 Personen, 852 Besetzungen. Die Anmeldekonten bleiben
stehen — ein Seed, der sich selbst aus der Anwendung aussperrt, ist keiner.
Der Baum kommt aus buildOrg(); der Seed entscheidet nur noch, wer welche
Planstelle besetzt. Damit fällt die halbe Datei weg: keine division_id,
team_id, manager_id, org_level, is_lead mehr auf der Person.
Zwei Dinge, die das Altmodell nicht abbilden konnte, stehen jetzt bewusst in
den Daten:
- Vakanz ist eine Planstelle ohne laufende Besetzung, keine eigene Tabelle.
14 Planstellen sind heute unbesetzt, drei davon mit einem Eintritt in der
Zukunft — die Besetzung beginnt später, die Planstelle existiert schon.
- Ausgetretene sind Vorgänger:innen auf heute besetzten Planstellen, nicht
Karteileichen an einem Team. Vorher liessen sie deren Planstellen als
vakant erscheinen.
Drei Teamleitungen sind unbesetzt und zwei langzeitabwesend, damit die
Hochroll-Regel überhaupt Daten hat: 76 der 809 Berichtslinien weichen von der
formalen ab. Genau eine Person hat keine Vorgesetzte, die Geschäftsführung.
Beim ersten scharfen Lauf hat der SVNR-Trigger mitten im Einfügen abgebrochen,
mit bereits geleerter Datenbank. Ursache war nicht die Prüfziffer, sondern
isoDate(): es ging über toISOString(), während makeSvNummer die lokalen
Datumsteile liest. In Österreich verschiebt das jedes Datum um einen Tag — das
gespeicherte Geburtsdatum passte nicht mehr zu dem in der SV-Nummer codierten.
isoDate rechnet jetzt lokal, wie der Rest des Seeds auch.
Damit so etwas nicht wieder erst die Datenbank leerräumt: pruefeInvarianten()
läuft *vor* dem Löschen und prüft, was sonst erst die Unique-Indizes und
Trigger abfangen — doppelte Besetzungen, überlappende Historie, Ereignisse
nach dem Austritt, und jede SV-Nummer gegen ihr Geburtsdatum. Mit --dry-run
schreibt der Seed gar nichts und meldet nur, was entstehen würde.
943 lines
39 KiB
TypeScript
943 lines
39 KiB
TypeScript
// 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<T>(arr: readonly T[]): T {
|
||
return arr[randInt(0, arr.length - 1)];
|
||
}
|
||
function chance(probability: number): boolean {
|
||
return Math.random() < probability;
|
||
}
|
||
function weightedPick<T>(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, { city: string; postal: () => 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<string>();
|
||
|
||
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<EmployeeRow["paygrade"]>([
|
||
["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<typeof newHireBase>,
|
||
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<T>(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<string | null, BuiltUnit[]>();
|
||
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<string, unknown>[], 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<string, number>();
|
||
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<string, number>();
|
||
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<string, AssignmentRow[]>();
|
||
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);
|
||
});
|