diff --git a/supabase/migrations/20260727120200_om_cutover.sql b/supabase/migrations/20260727120200_om_cutover.sql index 22db557..7639df8 100644 --- a/supabase/migrations/20260727120200_om_cutover.sql +++ b/supabase/migrations/20260727120200_om_cutover.sql @@ -169,6 +169,12 @@ drop trigger if exists trg_track_employee_assignment on employees; drop function if exists fn_track_employee_assignment(); drop table if exists employee_assignments; +-- Leitet division_id aus team_id ab und haengt damit an einer Spalte, die +-- gleich faellt. Im neuen Modell uebernimmt die Einheit der Planstelle +-- diese Rolle, der Trigger wird ersatzlos entfernt. +drop trigger if exists trg_employees_set_org_unit on employees; +drop function if exists fn_set_employee_org_unit(); + -- Die View selektiert team_id/division_id/manager_id/org_level/is_lead und -- blockiert damit das Entfernen dieser Spalten. Sie wird von der Anwendung -- nirgends benutzt und ersatzlos entfernt; die Ableitung über @@ -203,6 +209,12 @@ drop table if exists teams; drop table if exists departments; drop table if exists divisions; +-- Mit der positions-Tabelle verschwindet ihr Trigger, nicht aber dessen +-- Funktion und die Nummernvergabe, die nur als Spaltenvorgabe dort benutzt +-- wurde. om_positions vergibt seine Nummern selbst. +drop function if exists fn_set_position_org_unit(); +drop function if exists generate_position_number(); + -- ═══ 5. Mutationen im neuen Modell ═══════════════════════════════ -- Die Berichtslinie wird nicht mehr mitgeschrieben, sondern abgeleitet. -- Das entfernt aus jeder dieser Funktionen die Manager-Nachführung — beim diff --git a/supabase/seed.ts b/supabase/seed.ts index c22ce30..80f0ee7 100644 --- a/supabase/seed.ts +++ b/supabase/seed.ts @@ -10,8 +10,9 @@ 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 } from "../lib/svnr.ts"; +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; @@ -49,8 +50,15 @@ function addDays(d: Date, days: number): Date { 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 { - return d.toISOString().slice(0, 10); + 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()); @@ -141,9 +149,8 @@ const LOCATION_WEIGHTS: readonly (readonly [(typeof LOCATIONS)[number], number]) ]; // ── Org structure ──────────────────────────────────────────── -type TeamDef = { name: string; leadTitle: string; icTitles: string[]; baseSize: number }; -type DeptDef = { name: string; teams: TeamDef[] }; -type DivisionDef = { name: string; headTitle: string; departments: DeptDef[] }; +// 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 @@ -154,6 +161,7 @@ const DIVISIONS: DivisionDef[] = [ 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 }, @@ -162,6 +170,7 @@ const DIVISIONS: DivisionDef[] = [ }, { 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 }, @@ -175,6 +184,7 @@ const DIVISIONS: DivisionDef[] = [ 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 }, @@ -183,6 +193,7 @@ const DIVISIONS: DivisionDef[] = [ }, { 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 }, @@ -196,6 +207,7 @@ const DIVISIONS: DivisionDef[] = [ 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 }, @@ -204,6 +216,7 @@ const DIVISIONS: DivisionDef[] = [ }, { 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 }, @@ -217,6 +230,7 @@ const DIVISIONS: DivisionDef[] = [ 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 }, @@ -224,6 +238,7 @@ const DIVISIONS: DivisionDef[] = [ }, { 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 }, @@ -237,6 +252,7 @@ const DIVISIONS: DivisionDef[] = [ 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 }, @@ -244,6 +260,7 @@ const DIVISIONS: DivisionDef[] = [ }, { 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 }, @@ -257,6 +274,7 @@ const DIVISIONS: DivisionDef[] = [ 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 }, @@ -264,6 +282,7 @@ const DIVISIONS: DivisionDef[] = [ }, { 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 }, @@ -277,6 +296,7 @@ const DIVISIONS: DivisionDef[] = [ 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 }, @@ -284,6 +304,7 @@ const DIVISIONS: DivisionDef[] = [ }, { 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 }, @@ -297,6 +318,7 @@ const DIVISIONS: DivisionDef[] = [ 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 }, @@ -304,6 +326,7 @@ const DIVISIONS: DivisionDef[] = [ }, { 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 }, @@ -328,13 +351,11 @@ type EmployeeRow = { address_country: string; email: string; phone: string; - team_id: string | null; - division_id: 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; - manager_id: string | null; - org_level: number; - is_lead: boolean; employment_type: "Vollzeit" | "Teilzeit"; weekly_hours: number; contract_type: "unbefristet" | "befristet"; @@ -403,7 +424,7 @@ function paygradeForIc(): EmployeeRow["paygrade"] { ]); } -function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId: string | null, divisionId: string, managerId: string | null) { +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); @@ -423,13 +444,8 @@ function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId address_country: addressCountryFor(nationality), email: makeEmail(firstName, lastName), phone: `+43 664 ${randInt(1000000, 9999999)}`, - team_id: teamId, - division_id: divisionId, job_title: jobTitle, location_id: location.id, - manager_id: managerId, - org_level: orgLevel, - is_lead: isLead, }; } @@ -437,11 +453,27 @@ const employees: EmployeeRow[] = []; const history: HistoryRow[] = []; const icPoolForStatusAssignment: EmployeeRow[] = []; -function finalizeEmployee(base: ReturnType, opts: { paygrade: EmployeeRow["paygrade"] }): EmployeeRow { - const age = randInt(22, 60); +function finalizeEmployee( + base: ReturnType, + opts: { paygrade: EmployeeRow["paygrade"]; entryDate?: Date } +): EmployeeRow { + // Alter und Eintritt hängen zusammen: sonst entstehen Beschäftigte, die mit + // sechs Jahren angefangen haben. Ist der Eintritt vorgegeben (ausgetretene + // Vorgänger:innen, geplante Eintritte), richtet sich das Alter danach — + // sonst umgekehrt. + let age: number; + let entryDate: Date; + if (opts.entryDate) { + entryDate = opts.entryDate; + const tenureYears = Math.max(0, Math.floor((TODAY.getTime() - entryDate.getTime()) / (365.25 * 864e5))); + const minAge = Math.min(Math.max(22, 20 + tenureYears), 55); + age = randInt(minAge, 62); + } else { + age = randInt(22, 60); + const maxTenureYears = Math.min(15, age - 20); + entryDate = randomDateBetween(addDays(TODAY, -maxTenureYears * 365), addDays(TODAY, -30)); + } const birthDate = birthDateForAge(age); - const maxTenureYears = Math.min(15, age - 20); - const entryDate = randomDateBetween(addDays(TODAY, -maxTenureYears * 365), addDays(TODAY, -30)); const employmentType: "Vollzeit" | "Teilzeit" = chance(0.8) ? "Vollzeit" : "Teilzeit"; const weeklyHours = employmentType === "Vollzeit" ? 38.5 : pick([15, 18, 20, 25, 28, 30, 32, 35]); @@ -487,80 +519,45 @@ function finalizeEmployee(base: ReturnType, opts: { paygrade return row; } -type TeamRef = { id: string; org_number: string; name: string; department_id: string }; -type DeptRef = { id: string; org_number: string; name: string; division_id: string }; -type DivisionRef = { id: string; org_number: string; name: string }; +// ── 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 divisionRows: DivisionRef[] = []; -const departmentRows: DeptRef[] = []; -const teamRows: TeamRef[] = []; +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) })), + })), +})); -// Geschäftsführung: small division, no departments/teams — CEO and their -// assistant sit directly under it (§5). -const gfDivisionId = randomUUID(); -divisionRows.push({ id: gfDivisionId, org_number: "20900000", name: "Geschäftsführung" }); +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])); -const ceo = finalizeEmployee( - newHireBase("Geschäftsführer:in", 0, true, null, gfDivisionId, null), - { paygrade: "F" } -); -const gfAssistant = finalizeEmployee( - newHireBase("Assistenz der Geschäftsführung", 3, false, null, gfDivisionId, ceo.id), - { paygrade: "C" } -); -employees.push(ceo, gfAssistant); +type AssignmentRow = { + position_id: string; + employee_id: string; + valid_from: string; + valid_to: string | null; +}; +const assignments: AssignmentRow[] = []; -let divisionCounter = 0; -let deptCounter = 0; -let teamCounter = 0; +// 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; -for (const div of DIVISIONS) { - divisionCounter += 1; - const divisionId = randomUUID(); - const divisionOrgNumber = `20${String(divisionCounter * 100000).padStart(6, "0")}`; - divisionRows.push({ id: divisionId, org_number: divisionOrgNumber, name: div.name }); - - const divisionHead = finalizeEmployee( - newHireBase(div.headTitle, 1, true, null, divisionId, ceo.id), - { paygrade: "F" } - ); - employees.push(divisionHead); - - for (const dept of div.departments) { - deptCounter += 1; - const departmentId = randomUUID(); - const deptOrgNumber = `21${String(deptCounter * 10000).padStart(6, "0")}`; - departmentRows.push({ id: departmentId, org_number: deptOrgNumber, name: dept.name, division_id: divisionId }); - - for (const team of dept.teams) { - teamCounter += 1; - const teamId = randomUUID(); - const teamOrgNumber = `22${String(teamCounter * 1000).padStart(6, "0")}`; - teamRows.push({ id: teamId, org_number: teamOrgNumber, name: team.name, department_id: departmentId }); - - const size = Math.max(2, Math.round(team.baseSize * SCALE)); - - const teamLead = finalizeEmployee( - newHireBase(team.leadTitle, 2, true, teamId, divisionId, divisionHead.id), - { paygrade: "E" } - ); - employees.push(teamLead); - - for (let i = 0; i < size - 1; i++) { - const jobTitle = pick(team.icTitles); - const paygrade = paygradeForIc(); - const ic = finalizeEmployee( - newHireBase(jobTitle, 3, false, teamId, divisionId, teamLead.id), - { paygrade } - ); - employees.push(ic); - icPoolForStatusAssignment.push(ic); - } - } - } -} - -// ── Apply the target status distribution (§5) across the IC pool ──────── function shuffle(arr: T[]): T[] { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { @@ -569,24 +566,74 @@ function shuffle(arr: T[]): T[] { } return a; } -const shuffledIcs = shuffle(icPoolForStatusAssignment); -let cursor = 0; -// ~40 Ausgetreten -for (let i = 0; i < 40 && cursor < shuffledIcs.length; i++, cursor++) { - const e = shuffledIcs[cursor]; - const entryDate = new Date(e.entry_date); - const exitDate = randomDateBetween(addDays(entryDate, 90), TODAY); - e.status = "Ausgetreten"; - e.exit_date = isoDate(exitDate); - e.exit_reason = pick(EXIT_REASONS); - history.push({ employee_id: e.id, event_date: e.exit_date, event_type: "Austritt", description: `Austritt (${e.exit_reason})` }); +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"; } -// ~12 Langzeitabwesenheiten, über die Arten gestreut statt alle als Karenz — -// die Auswertung nach Art ist sonst nicht zu sehen. -for (let i = 0; i < 12 && cursor < shuffledIcs.length; i++, cursor++) { - const e = shuffledIcs[cursor]; +/** 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)); @@ -603,65 +650,89 @@ for (let i = 0; i < 12 && cursor < shuffledIcs.length; i++, cursor++) { }); } -// ~3 Geplant (future entry). A person who hasn't started yet can't already -// have a Beförderung or other history predating that future entry date — -// found during the consolidation review that reassigning an already- -// finalized IC to Geplant only patched their Eintritt row's date, leaving -// any earlier-generated history (e.g. a Beförderung) still on the record -// with a date before the (now future) entry_date. Fixed by dropping every -// history row for that employee except Eintritt, then moving Eintritt to -// the new future date. -for (let i = 0; i < 3 && cursor < shuffledIcs.length; i++, cursor++) { - const e = shuffledIcs[cursor]; - const futureEntry = addDays(TODAY, randInt(10, 90)); - e.status = "Geplant"; - e.entry_date = isoDate(futureEntry); - for (let hi = history.length - 1; hi >= 0; hi--) { - if (history[hi].employee_id === e.id && history[hi].event_type !== "Eintritt") history.splice(hi, 1); - } - const historyEntry = history.find((h) => h.employee_id === e.id && h.event_type === "Eintritt"); - if (historyEntry) historyEntry.event_date = e.entry_date; -} - -// ~3 planned future exits (still Aktiv until the exit date arrives) -for (let i = 0; i < 3 && cursor < shuffledIcs.length; i++, cursor++) { - const e = shuffledIcs[cursor]; +// 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; } -// ── Open positions (§4.6 / §5) ─────────────────────────────── -type PositionRow = { - title: string; - team_id: string; - is_lead: boolean; - reports_to_employee_id: string | null; - status: "open"; - created_at: string; -}; -const positions: PositionRow[] = []; +// ── 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 pool = shuffle([...teamRows]); - for (let i = 0; i < 8; i++) { - const team = pool[i % pool.length]; - const leadOfTeam = employees.find((e) => e.team_id === team.id && e.is_lead); - const isLeadPosition = i < 2; // first two are leadership requisitions - const reportsTo = isLeadPosition - ? (employees.find((e) => e.division_id === leadOfTeam?.division_id && e.org_level === 1)?.id ?? null) - : (leadOfTeam?.id ?? null); - positions.push({ - title: isLeadPosition ? `Teamleitung ${team.name}` : "Neue Position", - team_id: team.id, - is_lead: isLeadPosition, - reports_to_employee_id: reportsTo, - status: "open", - created_at: new Date(addDays(TODAY, -randInt(1, 45))).toISOString(), + const holderOf = new Map(icEmployees.map((e) => [e.id, e])); + const uebernehmbar = shuffle( + assignments.filter((a) => { + const holder = holderOf.get(a.employee_id); + // Genug Vorlauf, damit vor der heutigen Besetzung noch eine ganze + // Beschäftigung Platz hat. + return holder && new Date(holder.entry_date) > addDays(TODAY, -8 * 365) && holder.status === "Aktiv"; + }) + ).slice(0, AUSGETRETEN); + + for (const a of uebernehmbar) { + const nachfolgerEintritt = new Date(a.valid_from); + const exitDate = addDays(nachfolgerEintritt, -randInt(1, 60)); + const entryDate = addDays(exitDate, -randInt(400, 3000)); + const p = org.positions.find((x) => x.id === a.position_id)!; + const title = jobTitleById.get(p.job_id)!; + + const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForIc(), entryDate }); + e.status = "Ausgetreten"; + e.exit_date = isoDate(exitDate); + e.exit_reason = pick(EXIT_REASONS); + // Ein befristeter Vertrag, der nach dem Austritt endet, wäre Unsinn; und + // finalizeEmployee kann eine Beförderung bis heute gestreut haben, die + // hier nach dem Austritt läge. + e.contract_type = "unbefristet"; + e.contract_end_date = null; + for (let i = history.length - 1; i >= 0; i--) { + if (history[i].employee_id === e.id && history[i].event_date > e.exit_date) history.splice(i, 1); + } + employees.push(e); + history.push({ + employee_id: e.id, + event_date: e.exit_date, + event_type: "Austritt", + description: `Austritt (${e.exit_reason})`, + }); + assignments.push({ + position_id: p.id, + employee_id: e.id, + valid_from: e.entry_date, + valid_to: e.exit_date, }); } } // ── Insert helpers ─────────────────────────────────────────── +/** Eltern vor Kindern, damit parent_id beim Einfügen schon existiert. */ +function sortParentsFirst(units: BuiltUnit[]): BuiltUnit[] { + const byParent = new Map(); + for (const u of units) { + const list = byParent.get(u.parent_id) ?? []; + list.push(u); + byParent.set(u.parent_id, list); + } + const out: BuiltUnit[] = []; + const queue = [...(byParent.get(null) ?? [])]; + while (queue.length > 0) { + const u = queue.shift()!; + out.push(u); + queue.push(...(byParent.get(u.id) ?? [])); + } + if (out.length !== units.length) throw new Error("Org-Baum hat abgehängte Einheiten"); + return out; +} + async function insertInChunks(table: string, rows: Record[], chunkSize = 200) { for (let i = 0; i < rows.length; i += chunkSize) { const chunk = rows.slice(i, i + chunkSize); @@ -671,52 +742,198 @@ async function insertInChunks(table: string, rows: Record[], ch console.log(` inserted ${rows.length} row(s) into ${table}`); } +// Alles ausser den Anmeldekonten. profiles und auth.users bleiben stehen — +// sonst sperrt sich der Seed selbst aus der Anwendung aus. +// +// Reihenfolge: Kinder vor Eltern. org_units verweist auf sich selbst; ein +// einzelnes DELETE über alle Zeilen geht trotzdem durch, weil Postgres die +// Fremdschlüsselprüfung erst nach dem Statement auswertet. +const WIPE_ORDER = [ + "reorg_moves", + "reorg_scenarios", + "pending_org_changes", + "hire_drafts", + "employee_notes", + "employee_dependents", + "employee_history", + "position_assignments", + "audit_log", + "saved_reports", + "employees", + "om_positions", + "jobs", + "org_units", + "locations", +]; + +async function wipe() { + for (const table of WIPE_ORDER) { + // PostgREST verlangt einen Filter; "id ist nicht null" trifft alles. + const { error } = await supabase.from(table).delete().not("id", "is", null); + if (error) throw new Error(`Delete from ${table} failed: ${error.message}`); + const { count } = await supabase.from(table).select("*", { count: "exact", head: true }); + if (count) throw new Error(`${table} ist nach dem Löschen nicht leer (${count} Zeilen)`); + console.log(` geleert: ${table}`); + } +} + +/** + * Prüft die Zusagen, die der Seed der Datenbank gegenüber macht, bevor er sie + * löscht. Die Unique-Indizes fangen das Meiste ab — aber erst nach dem + * Löschen, und dann steht die Datenbank leer da. + */ +function pruefeInvarianten() { + const laufend = assignments.filter((a) => a.valid_to === null); + + const jeStelle = new Map(); + for (const a of laufend) jeStelle.set(a.position_id, (jeStelle.get(a.position_id) ?? 0) + 1); + for (const [id, n] of jeStelle) if (n > 1) throw new Error(`Planstelle ${id} ist ${n}-fach laufend besetzt`); + + const jePerson = new Map(); + for (const a of laufend) jePerson.set(a.employee_id, (jePerson.get(a.employee_id) ?? 0) + 1); + for (const [id, n] of jePerson) if (n > 1) throw new Error(`Person ${id} hat ${n} laufende Planstellen`); + + // Überlappende Besetzungen derselben Planstelle: der Unique-Index deckt nur + // die laufende ab, die Historie könnte sich also unbemerkt überschneiden. + const nachStelle = new Map(); + for (const a of assignments) { + const list = nachStelle.get(a.position_id) ?? []; + list.push(a); + nachStelle.set(a.position_id, list); + } + for (const [id, list] of nachStelle) { + const sortiert = [...list].sort((x, y) => x.valid_from.localeCompare(y.valid_from)); + for (let i = 1; i < sortiert.length; i++) { + const vorher = sortiert[i - 1]; + if (vorher.valid_to === null || vorher.valid_to > sortiert[i].valid_from) { + throw new Error(`Planstelle ${id}: Besetzungen überschneiden sich (${vorher.valid_from}–${vorher.valid_to})`); + } + } + } + + for (const a of assignments) { + if (a.valid_to !== null && a.valid_to <= a.valid_from) throw new Error(`Besetzung ${a.position_id}: valid_to <= valid_from`); + } + + const personen = new Set(employees.map((e) => e.id)); + for (const a of assignments) if (!personen.has(a.employee_id)) throw new Error("Besetzung ohne Person"); + for (const h of history) if (!personen.has(h.employee_id)) throw new Error("Historie ohne Person"); + + // Jede Person genau eine Planstelle — auch die ausgetretenen, sonst hinge + // sie ausserhalb der Organisation. + const mitStelle = new Set(assignments.map((a) => a.employee_id)); + for (const e of employees) if (!mitStelle.has(e.id)) throw new Error(`${e.first_name} ${e.last_name} hat keine Planstelle`); + + // Die SV-Nummer trägt das Geburtsdatum in sich; weichen die beiden + // voneinander ab, weist der Trigger die Zeile zurück — mitten im Einfügen, + // wenn die Datenbank bereits leergeräumt ist. + for (const e of employees) { + const fehler = validateSvnr(e.sv_nummer, e.birth_date); + if (fehler) throw new Error(`${e.email}: SV-Nummer ${e.sv_nummer} zu Geburtsdatum ${e.birth_date} — ${svnrErrorMessage(fehler)}`); + } + + for (const e of employees) { + if (e.exit_date && e.exit_date <= e.entry_date) throw new Error(`${e.email}: Austritt vor Eintritt`); + } + for (const h of history) { + const e = employees.find((x) => x.id === h.employee_id)!; + if (e.exit_date && h.event_date > e.exit_date) throw new Error(`${e.email}: Ereignis ${h.event_type} nach dem Austritt`); + } +} + async function main() { - console.log("Seeding locations..."); + 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 divisions..."); - await insertInChunks("divisions", divisionRows); + 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 departments..."); - await insertInChunks("departments", departmentRows); + console.log(`Seeding ${org.jobs.length} jobs...`); + await insertInChunks("jobs", org.jobs); - console.log("Seeding teams..."); - await insertInChunks("teams", teamRows); + 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); - console.log(`Seeding ${positions.length} open positions...`); - await insertInChunks("positions", positions); + // 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.)"); + } - // The app is HR-only now (see docs/decisions/0001-hr-only-access.md) — no - // second "manager" role exists to seed a test account for. This is the - // one deliberate, explicit bootstrap grant of HR access (not an automatic - // one): every other new profile row defaults to is_active = false and - // must be activated by an existing HR user (§2.3). - console.log("Creating initial HR account..."); - 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, - }); + // 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("\nDone."); - console.log(`HR login: ${ADMIN_EMAIL} / ${hrPassword}`); - console.log("(Password is shown once here only — store it somewhere safe.)"); + 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) => {