pruefeInvarianten() checked that no history event falls after an exit, but not that none falls before an entry — and the database enforces exactly that, in trg_history_not_before_entry. That gap explains why the live database holds 852 people and no history at all. employee_history is the last table the seed writes and insertInChunks throws on the first rejected chunk, so everything before it was already committed while every one of the ~950 events was lost. The result did not look like an aborted run. It looked like an application that shows little history. The cause was the timezone bug in isoDate() that this seed already documents: dates built from local parts but formatted through UTC land a day early in Austria, which put every "Eintritt" one day before the entry date it was derived from. That is fixed; the database was simply never rebuilt. A dry run now reports 951 events and no violation, so the current code is sound. The check stays because it turns this class of failure into a refusal before the wipe instead of an abort halfway through it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
951 lines
40 KiB
TypeScript
951 lines
40 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`);
|
||
// Die Gegenrichtung — und die hat gefehlt.
|
||
//
|
||
// trg_history_not_before_entry weist jede Zeile ab, deren event_date vor
|
||
// dem Eintritt liegt. insertInChunks bricht beim ersten Fehler ab, und
|
||
// employee_history ist die *letzte* Tabelle im Seed: alles davor war
|
||
// bereits geschrieben. Ergebnis war eine Datenbank mit 852 Personen und
|
||
// null Historie — und das sah nicht nach einem Abbruch aus, sondern nach
|
||
// einer Anwendung, die eben wenig Historie zeigt.
|
||
if (h.event_date < e.entry_date) {
|
||
throw new Error(`${e.email}: Ereignis ${h.event_type} am ${h.event_date} liegt vor dem Eintritt am ${e.entry_date}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
});
|