import type { OrgEmployee, OrgVacancy } from "@/components/orgchart/types"; import type { Tx } from "./db"; import { todayIso } from "./format"; import { resolveReportingLines, type OmHolder, type OmUnit } from "./om-reporting"; // Das Organigramm, wie es an einem Stichtag stand oder stehen wird. // // Im Altmodell mussten dafür drei Quellen versöhnt werden, weil keine den // ganzen Zeitstrahl abdeckte: eine mitgeschriebene Zuordnungshistorie für die // Vergangenheit, vorgemerkte Änderungen für die Zukunft und die // Ein-/Austrittsdaten für die Frage, wer überhaupt dazuzählte. // // Im OM-Modell fällt das zusammen. position_assignments ist zeitabhängig, also // beantwortet eine einzige Abfrage „wer besetzte am Stichtag welche // Planstelle" — für Vergangenheit und Zukunft gleichermassen. Wer zu dem // Zeitpunkt keine Planstelle innehatte, war nicht da; eine zweite // Zugehörigkeitsregel braucht es nicht mehr. // // Übrig bleibt die Projektion vorgemerkter Versetzungen: die stehen noch nicht // in position_assignments, weil sie erst am Stichtag geschrieben werden. /** Änderungsarten, die jemanden in der Organisation verschieben. */ const PLACEMENT_CHANGES = ["transfer"] as const; export type OrgAsOfResult = { employees: OrgEmployee[]; vacancies: OrgVacancy[]; /** Wie viele Platzierungen aus noch nicht angewandten Änderungen stammen. */ projectedCount: number; /** Frühester Tag, den die Besetzungshistorie tatsächlich abdeckt. */ historyStartsAt: string | null; }; type EmployeeRow = { id: string; personnel_number: number; first_name: string; last_name: string; job_title: string; karenz_start_date: string | null; karenz_return_date: string | null; absence_type: string | null; }; type PositionRow = { id: string; position_number: string; org_unit_id: string; is_chief: boolean; jobs: { title: string }; }; type AssignmentRow = { employee_id: string; position_id: string }; type PendingRow = { employee_id: string; effective_date: string; payload: Record }; export async function loadOrgAsOf(tx: Tx, asOf: string): Promise { const today = todayIso(); // Ohne die 1000-Zeilen-Grenze der API-Schicht fällt das seitenweise // Nachladen weg: sechs Abfragen, jede vollständig. const [units, positions, assignments, employees, pending, earliest] = await Promise.all([ tx.selectFrom("org_units").select(["id", "parent_id"]).orderBy("id").execute(), tx .selectFrom("om_positions as p") .innerJoin("jobs as j", "j.id", "p.job_id") .select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "j.title"]) .where("p.valid_from", "<=", asOf) .where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)])) .orderBy("p.id") .execute(), tx .selectFrom("position_assignments") .select(["employee_id", "position_id"]) .where("valid_from", "<=", asOf) .where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)])) .orderBy("employee_id") .execute(), tx .selectFrom("employees") .select([ "id", "personnel_number", "first_name", "last_name", "job_title", "karenz_start_date", "karenz_return_date", "absence_type", ]) .orderBy("id") .execute(), asOf > today ? tx .selectFrom("pending_org_changes") .select(["employee_id", "effective_date", "payload"]) .where("status", "=", "pending") .where("effective_date", "<=", asOf) .where("change_type", "in", [...PLACEMENT_CHANGES]) .orderBy("effective_date") .execute() : Promise.resolve([]), tx.selectFrom("position_assignments").select("valid_from").orderBy("valid_from").limit(1).executeTakeFirst(), ]); return resolveOrgSnapshot({ asOf, units: units.map((u) => ({ id: u.id, parentId: u.parent_id })), // Der Join liefert den Jobtitel flach; die reine Funktion erwartet ihn // verschachtelt, weil sie so auch aus einem Testbestand gefüttert wird. positions: positions.map((p) => ({ id: p.id, position_number: p.position_number, org_unit_id: p.org_unit_id, is_chief: p.is_chief, jobs: { title: p.title }, })), assignments: assignments as AssignmentRow[], employees: employees as EmployeeRow[], pending: pending as PendingRow[], historyStartsAt: earliest?.valid_from ?? null, }); } /** * Der reine Teil: aus den Zeilen den Stand machen, ohne Datenbank, damit die * Regeln direkt prüfbar sind. */ export function resolveOrgSnapshot({ asOf, units, positions, assignments, employees: allEmployees, pending, historyStartsAt, }: { asOf: string; units: OmUnit[]; positions: PositionRow[]; assignments: AssignmentRow[]; employees: EmployeeRow[]; pending: PendingRow[]; historyStartsAt: string | null; }): OrgAsOfResult { const positionById = new Map(positions.map((p) => [p.id, p])); const employeeById = new Map(allEmployees.map((e) => [e.id, e])); // Dieselbe Ableitung wie in deriveStatusAsOf() und in om_reporting_lines(), // damit die drei nie auseinanderlaufen können. const isAbsent = (e: EmployeeRow) => e.karenz_start_date !== null && e.karenz_start_date <= asOf && (e.karenz_return_date === null || asOf < e.karenz_return_date); const positionOf = new Map(); for (const a of assignments) { if (positionById.has(a.position_id) && employeeById.has(a.employee_id)) positionOf.set(a.employee_id, a.position_id); } // Die Zukunft projizieren: nach effective_date sortiert, eine spätere // Versetzung gewinnt. const moved = new Set(); for (const change of pending) { if (!positionOf.has(change.employee_id)) continue; const targetId = (change.payload as { target_position_id?: string }).target_position_id; if (!targetId || !positionById.has(targetId)) continue; positionOf.set(change.employee_id, targetId); moved.add(change.employee_id); } const holders: OmHolder[] = []; for (const [employeeId, positionId] of positionOf) { const position = positionById.get(positionId)!; holders.push({ employeeId, positionId, orgUnitId: position.org_unit_id, isChief: position.is_chief, absent: isAbsent(employeeById.get(employeeId)!), }); } const lines = resolveReportingLines(units, holders); const employees: OrgEmployee[] = lines.map((l) => { const e = employeeById.get(l.employeeId)!; const position = positionById.get(l.positionId)!; return { id: e.id, personnel_number: e.personnel_number, first_name: e.first_name, last_name: e.last_name, // Die Tätigkeit der Planstelle, nicht das Freitextfeld auf der Person: // bei einer projizierten Versetzung ist nur die erste schon richtig. job_title: position.jobs.title, manager_id: l.actingManagerId, // Nur setzen, wenn eine Vertretung im Spiel ist — sonst zeigt die // Oberfläche zweimal dieselbe Person an. formal_manager_id: l.formalManagerId === l.actingManagerId ? null : l.formalManagerId, absent: isAbsent(e), absence_type: e.absence_type, org_unit_id: l.orgUnitId, is_chief: l.isChief, position_id: l.positionId, position_number: position.position_number, }; }); // Unbesetzte Planstellen. Im Altmodell waren offene Stellen eine eigene // Tabelle neben der Organisation; hier sind sie schlicht das Komplement. const besetzt = new Set(positionOf.values()); const vacancies: OrgVacancy[] = positions .filter((p) => !besetzt.has(p.id)) .map((p) => ({ position_id: p.id, position_number: p.position_number, job_title: p.jobs.title, org_unit_id: p.org_unit_id, is_chief: p.is_chief, })); return { employees, vacancies, projectedCount: moved.size, historyStartsAt }; }