Files
alpenwerk-hr/lib/honestly.ts
Andrei Laas 57acbfae9d
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m29s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m27s
Die Firmen-E-Mail als eigenes, freiwilliges Feld
employees.email ist die private Adresse (20260811140000). Sie taugt nicht
als Dienstadresse und darf auch nicht als solche benutzt werden: der
Honestly-Export geht an einen fremden Anbieter, der damit im Namen des
Arbeitgebers einlaedt. Die Spalte "Email" stand dort deshalb seit jeher
leer, mit einem Vermerk in lib/honestly.ts, dass die Firmenadresse im
Datenmodell fehlt. Jetzt gibt es sie, und die Spalte fuellt sich.

Eindeutig, aber freiwillig -- mehrere Personen ohne Adresse stoeren den
Index nicht, weil null nie gleich null ist. Geschrieben wird ueber
`case when ? then nullif` statt `coalesce`: eine Dienstadresse muss sich
auch wieder entfernen lassen.

Vier SQL-Funktionen mussten mit, weil `create or replace` die ganze
Fassung ersetzt und ein ausgelassenes Feld dort still verschwindet:
hire_employee und rehire_employee (beide teilen sich den Schritt
"Person" -- das Formular haette das Feld gezeigt und den Wert
weggeworfen), change_employee_data (sonst nicht aenderbar),
apply_due_pending_changes (sonst verfiele eine auf spaeter datierte
Aenderung) und die Feldkarte (sonst waere der Eintrag in der Historie
nicht korrigierbar). Die Selbstpruefung am Ende prueft jede einzeln.
2026-09-23 10:52:26 +02:00

105 lines
4.0 KiB
TypeScript

import type { ExportColumn } from "./export";
import { ancestorsOf, type OrgMaps } from "./org";
// Der Export für Honestly (https://www.honestly.de/), die Plattform für
// Mitarbeiterbefragungen: eine Zeile je Person, die befragt werden soll.
//
// Zwei Spalten stehen fest, statt aus den Daten zu kommen — so ist es
// vorgegeben: die Sprache ist immer Deutsch, die Rolle immer „Respondee".
// Wer anders befragt werden soll, wird in Honestly umgestellt, nicht hier.
//
// Der Organisationsbaum kommt von unten nach oben: „OU" ist die Einheit der
// Person, „OU+1" die darüber, und so weiter bis zur obersten. Die Zahl dieser
// Spalten richtet sich nach der tiefsten Kette im Export. Wer weiter oben
// hängt, hat hinten leere Zellen — und die oberste Einheit steht deshalb
// nicht bei allen in derselben Spalte. So ist es bestellt.
// Die E-Mail-Spalte trägt die **Firmenadresse** (employees.company_email,
// Migration 20260923120000) und niemals die private aus employees.email: die
// Datei geht an einen fremden Anbieter, der damit im Namen des Arbeitgebers
// einlädt. Eine Privatadresse hat dort nichts verloren.
//
// Bis zur Migration war die Spalte ein leerer Platzhalter — sie stand
// trotzdem in der Datei, damit die Zuordnung in Honestly nicht neu
// eingerichtet werden muss. Wer keine Firmenadresse hinterlegt hat, hat sie
// weiterhin leer; das ist eine fehlende Angabe und keine Ausrede, die private
// zu nehmen.
export const HONESTLY_SPRACHE = "de";
export const HONESTLY_ROLLE = "Respondee";
export type HonestlyZeile = {
personalnummer: number | string | null;
/** Die dienstliche Adresse. Leer, wenn keine hinterlegt ist. */
email: string;
vorname: string;
nachname: string;
standort: string;
/** Die Einheit der Person zuerst, die oberste zuletzt. */
kette: string[];
};
/**
* Die Namen der Einheiten von der eigenen bis zur obersten.
*
* ancestorsOf liefert die Wurzel zuerst; hier wird es umgedreht, weil die
* Spalten bei der eigenen Einheit anfangen.
*/
export function orgKette(maps: OrgMaps, unitId: string | null | undefined): string[] {
return ancestorsOf(maps, unitId)
.map((u) => u.name)
.reverse();
}
/** Überschrift der n-ten Organisationsspalte: OU, OU+1, OU+2, … */
export function ouUeberschrift(stufe: number): string {
return stufe === 0 ? "OU" : `OU+${stufe}`;
}
/**
* Die Spalten in der Reihenfolge der Vorgabe.
*
* Mindestens eine OU-Spalte, auch wenn niemand im Export einer Einheit
* zugeordnet ist: eine Datei, deren Spalten je nach Inhalt verschwinden,
* bricht jede Zuordnung, die auf der anderen Seite eingerichtet wurde.
*/
export function honestlySpalten(tiefe: number): ExportColumn<HonestlyZeile>[] {
const ou: ExportColumn<HonestlyZeile>[] = Array.from({ length: Math.max(1, tiefe) }, (_, stufe) => ({
header: ouUeberschrift(stufe),
get: (z) => z.kette[stufe] ?? "",
}));
return [
{ header: "Personalnummer", get: (z) => z.personalnummer },
{ header: "Email", get: (z) => z.email },
{ header: "Firstname", get: (z) => z.vorname },
{ header: "Last Name", get: (z) => z.nachname },
{ header: "Language", get: () => HONESTLY_SPRACHE },
{ header: "Location", get: (z) => z.standort },
...ou,
{ header: "Role", get: () => HONESTLY_ROLLE },
];
}
/** Aus den Exportzeilen die Honestly-Zeilen und die Zahl der OU-Spalten. */
export function baueHonestlyZeilen(
personen: {
personnel_number: number | string | null;
company_email: string | null;
first_name: string;
last_name: string;
location_id: string;
org_unit_id: string | null;
}[],
maps: OrgMaps,
standortName: Map<string, string>
): { zeilen: HonestlyZeile[]; tiefe: number } {
const zeilen = personen.map((p) => ({
personalnummer: p.personnel_number,
email: p.company_email ?? "",
vorname: p.first_name,
nachname: p.last_name,
standort: standortName.get(p.location_id) ?? "",
kette: orgKette(maps, p.org_unit_id),
}));
return { zeilen, tiefe: Math.max(1, ...zeilen.map((z) => z.kette.length)) };
}