Files
alpenwerk-hr/lib/placement.ts
Maximilian Stubhan 27e0e8a8ce Ask for someone's organisation on a day they actually have one
An employee starting 01.09. showed "Keine Führungskraft (Geschäftsführung)"
although their team has one — Josef Bauer, on chief position 60000752. The
reporting line was requested as of today, and today that person holds no
assignment, so om_reporting_lines() returned no row at all.

The database function was right; the caller asked the wrong question.

What made it look like a data problem rather than a date problem: the header
did show the unit and the position, because pickPlacements() falls back to
the next best assignment when none is current. Two notions of where someone
sits — one forgiving, one strict — sitting next to each other on the same
page.

orgAsOf() pulls the date into the employment: the first day for someone not
yet started, the last for someone who has left, today otherwise. Exit dates
are exclusive throughout the model, so the last working day is the day
before.

Anyone already gone had the same defect for the same reason, which is why
the rule covers both ends rather than special-casing the case that was
reported.

Verified against the live database: as of today no row, as of 2026-09-01 the
manager is Josef Bauer. Six unit tests over the boundaries, checked by
mutation — remove the future-entry branch and one fails.

Open positions still resolve as of today: they belong to the organisation,
not to the person whose file is open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:23:21 +02:00

173 lines
6.1 KiB
TypeScript

import { sql, type Tx } from "./db";
import { addDaysIso } from "./format";
/**
* Der Stichtag, zu dem die Organisation *dieser* Person betrachtet wird.
*
* „Heute" ist für zwei Gruppen die falsche Frage. Wer am 01.09. anfängt, hat
* heute keine laufende Besetzung — om_reporting_lines() liefert dann gar
* keine Zeile, und die Akte behauptet „Keine Führungskraft", obwohl das Team
* eine hat. Wer ausgetreten ist, ebenso.
*
* Die Stammdaten zeigten die Planstelle trotzdem an, weil pickPlacements()
* ersatzweise auf die nächstbeste Zuordnung zurückfällt. Zwei Vorstellungen
* davon, wo jemand sitzt — eine nachsichtige und eine strenge — und in der
* Akte standen sie nebeneinander.
*
* Deshalb: der Stichtag wird in das Beschäftigungsverhältnis hineingezogen.
* Für künftige Eintritte auf den ersten Tag, für Ausgetretene auf den
* letzten. Sonst bleibt es heute.
*/
export function orgAsOf(
employee: { entry_date: string; exit_date: string | null },
today: string
): string {
if (employee.entry_date > today) return employee.entry_date;
// exit_date ist ausschliessend wie überall im Modell: der letzte Arbeitstag
// ist der Tag davor.
if (employee.exit_date && employee.exit_date <= today) return addDaysIso(employee.exit_date, -1);
return today;
}
// Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es
// ergibt sich aus der Planstelle, die sie zum Stichtag innehat:
//
// employees ──A008──> position_assignments ──> om_positions ──> org_units
// └────────> jobs
//
// Das ist der Grund, warum es diese Datei gibt: die Verkettung braucht es an
// einem Dutzend Stellen, und sie zeitrichtig aufzulösen ist die Arbeit.
export type Placement = {
employeeId: string;
positionId: string;
positionNumber: string;
orgUnitId: string;
isChief: boolean;
jobTitle: string;
validFrom: string;
validTo: string | null;
/** Die Besetzung läuft am Stichtag; sonst ist es die zuletzt beendete. */
current: boolean;
};
type Row = {
employee_id: string;
valid_from: string;
valid_to: string | null;
position_id: string;
position_number: string;
org_unit_id: string;
is_chief: boolean;
job_title: string;
};
function toPlacement(row: Row, asOf: string): Placement {
return {
employeeId: row.employee_id,
positionId: row.position_id,
positionNumber: row.position_number,
orgUnitId: row.org_unit_id,
isChief: row.is_chief,
jobTitle: row.job_title,
validFrom: row.valid_from,
validTo: row.valid_to,
current: row.valid_from <= asOf && (row.valid_to === null || row.valid_to > asOf),
};
}
/**
* Die am Stichtag laufende Besetzung je Person — und für alle, die zu dem
* Zeitpunkt keine hatten, die zuletzt beendete. Ohne diesen Rückfall stünde
* bei jeder ausgetretenen Person „–" statt der Stelle, die sie innehatte.
*/
export function pickPlacements(rows: Row[], asOf: string): Map<string, Placement> {
const byEmployee = new Map<string, Placement>();
for (const row of rows) {
const p = toPlacement(row, asOf);
const best = byEmployee.get(p.employeeId);
if (!best) {
byEmployee.set(p.employeeId, p);
continue;
}
// Laufend schlägt beendet; unter beendeten gewinnt die jüngste.
if (p.current && !best.current) byEmployee.set(p.employeeId, p);
else if (p.current === best.current && p.validFrom > best.validFrom) byEmployee.set(p.employeeId, p);
}
return byEmployee;
}
export async function loadPlacements(
tx: Tx,
{ asOf, employeeIds }: { asOf: string; employeeIds?: string[] }
): Promise<Map<string, Placement>> {
if (employeeIds?.length === 0) return new Map();
// Ein Join statt einer eingebetteten Ressource. Und ohne die
// 1000-Zeilen-Grenze von PostgREST fällt das seitenweise Nachladen weg,
// das es dafür brauchte.
let q = tx
.selectFrom("position_assignments as pa")
.innerJoin("om_positions as p", "p.id", "pa.position_id")
.innerJoin("jobs as j", "j.id", "p.job_id")
.select([
"pa.employee_id",
"pa.valid_from",
"pa.valid_to",
"p.id as position_id",
"p.position_number",
"p.org_unit_id",
"p.is_chief",
"j.title as job_title",
])
.orderBy("pa.employee_id");
if (employeeIds) q = q.where("pa.employee_id", "in", employeeIds);
return pickPlacements((await q.execute()) as Row[], asOf);
}
// ── Abgeleitete Berichtslinie ──────────────────────────────────────
// Sie steht nirgends als Spalte; om_reporting_lines() rechnet sie aus dem
// Baum aus. formal_manager_id ist die zuständige Leitung, acting_manager_id
// die nächste besetzte und anwesende darüber — beides, damit sich in der
// Oberfläche zeigen lässt, dass eine Vertretung im Spiel ist, statt sie
// stillschweigend als die echte Führungskraft auszugeben.
export type ReportingLine = {
employee_id: string;
position_id: string;
org_unit_id: string;
is_chief: boolean;
formal_manager_id: string | null;
acting_manager_id: string | null;
};
/**
* `filter` schränkt die Funktion selbst ein, nicht das Ergebnis im Speicher —
* bei der Detailseite wandern damit neun Zeilen über die Leitung statt
* achthundert.
*/
export async function loadReportingLines(
tx: Tx,
asOf: string,
filter?: { employeeId?: string; actingManagerId?: string }
): Promise<ReportingLine[]> {
const conditions = [sql`true`];
if (filter?.employeeId) conditions.push(sql`employee_id = ${filter.employeeId}::uuid`);
if (filter?.actingManagerId) conditions.push(sql`acting_manager_id = ${filter.actingManagerId}::uuid`);
const result = await sql<ReportingLine>`
select * from om_reporting_lines(${asOf}::date)
where ${sql.join(conditions, sql` and `)}
`.execute(tx);
return result.rows;
}
/** Wie loadReportingLines, aber als Karte über die Personen-Kennung. */
export async function loadReportingLineMap(tx: Tx, asOf: string): Promise<Map<string, ReportingLine>> {
const lines = await loadReportingLines(tx, asOf);
return new Map(lines.map((l) => [l.employee_id, l]));
}