F.24 -- eine Planstelle, auf die eine Versetzung vorgemerkt ist, galt als frei. Die Vormerkung steht in pending_org_changes, "besetzt?" wurde allein an position_assignments gefragt. Auffallen wuerde es erst in der Nacht des Wirksamkeitstages: dort legt der Nachtlauf die zweite Besetzung an und laeuft in den Teilindex, der genau eine laufende Besetzung je Planstelle zulaesst. Er arbeitet in einer Transaktion ueber alle faelligen Vorgaenge -- eine einzige solche Buchung haette ihn vollstaendig zum Stehen gebracht, auch fuer alle anderen. Neue Auskunft planstelle_vorgemerkt, abgefragt in hire_employee, rehire_employee, transfer_employee und promote_employee, mit Datum in der Meldung. Die eigene Vormerkung zaehlt nicht als Hindernis. D.06 -- die Struktursicht zeigte Einheiten, die es am gewaehlten Stichtag noch nicht gab: org_units wurde ungefiltert gelesen, waehrend Planstellen und Besetzungen daneben auf den Stichtag eingeschraenkt waren. Die Druckansicht filterte schon immer richtig; jetzt tun es alle drei Stellen. H.09 -- der Kuendigungsschutz stand in der Akte nur als "bis TT.MM.JJJJ", obwohl Personenkreis, Beginn, Ende und die ganze Begunstigung erfasst werden. Der Personenkreis ist die eigentliche Auskunft. Die Behinderung bekommt eine eigene Zeile: sie fuehrt oft zu Kuendigungsschutz, ist aber ein eigener Bescheid. Elternteilzeit und Wiedereingliederungsteilzeit sind jetzt auch bei der Stundenaenderung waehlbar. Sie standen nur bei der Rueckkehr aus einer Abwesenheit, mit der Begruendung, dass sie typischerweise dann beginnen -- typischerweise ist aber nicht immer. Die Ueberschneidung der beiden Listen ist damit gewollt; der Test prueft nicht mehr auf Partition, sondern darauf, dass keine Teilzeit an keinem der beiden Wege haengt.
239 lines
8.2 KiB
TypeScript
239 lines
8.2 KiB
TypeScript
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<string, unknown> };
|
|
|
|
export async function loadOrgAsOf(tx: Tx, asOf: string): Promise<OrgAsOfResult> {
|
|
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([
|
|
// Zum Stichtag, wie die Planstellen und die Besetzungen darunter. Hier
|
|
// stand die Tabelle ungefiltert: eine Einheit, die es 2020 noch nicht gab,
|
|
// erschien in der Organisation von 2020. Gemeldet am 29.09. (D.06).
|
|
tx
|
|
.selectFrom("org_units")
|
|
.select(["id", "parent_id"])
|
|
.where("valid_from", "<=", asOf)
|
|
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)]))
|
|
.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<string, string>();
|
|
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<string>();
|
|
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 };
|
|
}
|