Die Datenbank stand seit dem Cut-over auf org_units/om_positions/
position_assignments, die Anwendung fragte weiter nach employees.division_id,
team_id und manager_id — Spalten, die es nicht mehr gab. Die Oberfläche war
deshalb leer, obwohl die Daten vollständig da waren. Das ist jetzt behoben,
und zwar nicht durch Nachbau der alten Begriffe, sondern indem sie verschwinden.
Neu ist eine dünne Schicht, die die Verkettung Person → Besetzung →
Planstelle → Einheit einmal auflöst (lib/placement.ts) und der Baum als reine
Funktionen darauf (lib/org.ts): Vorfahrenkette, Teilbaum, Brotkrume. Alles
Weitere hängt daran.
Was sich dadurch von selbst erledigt hat:
- Das Organigramm musste drei Quellen versöhnen, weil keine den ganzen
Zeitstrahl abdeckte. position_assignments ist zeitabhängig, also
beantwortet eine Abfrage "wer besetzte am Stichtag welche Planstelle" —
für Vergangenheit und Zukunft gleichermassen. Wer keine Planstelle hatte,
war nicht da; eine zweite Zugehörigkeitsregel braucht es nicht mehr.
- Die Struktursicht war auf genau vier Ebenen verdrahtet und rendert jetzt
rekursiv über parent_id. Liste und Grafik entstehen aus *einem* Baum;
vorher lag dieselbe Hierarchie zweimal vor und konnte auseinanderlaufen.
- Eine offene Stelle ist keine eigene Tabelle mehr, sondern eine Planstelle
ohne laufende Besetzung — das Komplement kann nicht aus dem Tritt geraten.
- Eine Versetzung ist der Wechsel auf eine Zielplanstelle statt Zielteam
plus frei getipptem Titel. Sie kann damit nicht mehr dort landen, wo es
keine Stelle gibt, und die Tätigkeit kommt aus dem Job-Katalog.
- Beim Anlegen einer Planstelle entfällt die Suche nach der vorgesetzten
Person: sie ergibt sich aus der Einheit, die Frage kann nicht mehr falsch
beantwortet werden.
Zwei Auswertungen werden dabei richtiger, nicht nur anders. Ein
Stichtagsbericht gruppierte bisher nach der *heutigen* Zuordnung, weil es
keine Historie gab; er löst sie jetzt zum Stichtag auf. Und ein Ereignis
trägt die Einheit, in der die Person am Tag des Ereignisses sass — vorher
stand ein Austritt von vor zwei Jahren unter einem Team, in das sie nie
versetzt worden war. Der Bereichsfilter greift überall auf den ganzen
Teilbaum; auf den Bereich allein angewandt lieferte er nur die
Bereichsleitung.
Gelöscht: die Reorganisations-Werkbank samt Szenarien und Zügen (sie
verschob Teams und Abteilungen zwischen Bereichen — Objekte, die es nicht
mehr gibt; im OM-Modell ist das ein Umhängen von parent_id), die
Mitarbeiter- und Vorgesetztensuche, die nur sie und die Ausschreibung
brauchten, und aus lib/supabase/types.ts die Tabellen divisions,
departments, teams, positions und employee_assignments.
Die beiliegende Migration räumt die Datenbank entsprechend auf. Sie entfernt
auch Funktionen, die der Cut-over verfehlt hat: create_position,
delete_position und undo_reorg existierten zusätzlich in einer
jsonb-Variante und tauchen deshalb weiter in der PostgREST-Schnittstelle auf,
obwohl ihre Tabellen weg sind — ein Aufruf wäre erst zur Laufzeit
gescheitert. An ihre Stelle treten create_position und delete_position im
OM-Sinn; letzteres schliesst eine früher besetzte Planstelle, statt sie zu
löschen, sonst verschwände mit ihr die Besetzungshistorie.
Typecheck, Lint, Build und 182 Tests sind grün. Die Integrationstests sind
mitgezogen, aber weiterhin ungelaufen — dafür braucht es eine laufende
lokale Datenbank.
940 lines
39 KiB
TypeScript
940 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[] = [];
|
||
|
||
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 = [
|
||
"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);
|
||
});
|