import type { Kostenstelle } from "./cost-centers"; import type { CsvForm, ExportColumn } from "./export"; import { unitOf, type OrgMaps } from "./org"; import type { EmploymentStatus } from "./types"; // Der Export für Cornerstone (CSOD) — 57 Spalten in fester Reihenfolge. // // Die Reihenfolge und die Schreibweise der Überschriften sind vorgegeben: // Cornerstone ordnet die Spalten über eine Konfiguration zu, die im Portal // eingerichtet wird. Eine umbenannte oder verschobene Spalte fällt hier nicht // auf, sondern dort — als Feld, das leer ankommt. // // ═══ Was aus Alpenwerk kommt und was nicht ══════════════════════════ // // Ein Teil der Spalten hat in Alpenwerk keine Entsprechung. Sie bleiben leer, // so wie in der Beispielzeile, statt mit erfundenen Werten gefüllt zu werden: // Approver, Absent, Allow Reconciliation, Required Training Approvals, Exempt, // Ethnicity, State, Mailstop, Phone, Mobile, Fax, Retention Rules, // User activation/de-activation date, Eligible For Rehire. // // Vier Spalten tragen ausdrücklich **Platzhalter**, weil die führenden // Systeme ihre Kennungen noch nicht liefern: Division ID sowie die drei // Customfields für Doxis, Interflex und LGVplus. Sie sind als solche erkennbar // (siehe die Konstanten unten) und müssen vor einem echten Lauf ersetzt werden. // // ═══ Was bewusst NICHT mitgeht ══════════════════════════════════════ // // Private Telefonnummer und private E-Mail-Adresse. Cornerstone hat dafür // Spalten (Home Phone, Personal Email), und die Beispielzeile lässt beide // leer. Dabei bleibt es: Daten, die das Haus verlassen, sind die dienstlichen. /** * Die Form der CSV-Datei, wie Cornerstone sie einliest. * * Steht hier und nicht in der Route, damit sie zusammen mit den Spalten * geprueft werden kann: die Vorgaben von toCsv sind fuer Excel gemacht * (Semikolon, BOM) und waeren hier beide falsch. Das BOM haenge unsichtbar am * Namen der ersten Spalte, und die kaeme in keiner Zuordnung vor. */ export const CORNERSTONE_CSV: CsvForm = { trenner: ",", bom: false }; export const CORNERSTONE_SPRACHE = "de-DE"; export const CORNERSTONE_ZEITZONE = "CET"; export const CORNERSTONE_WAEHRUNG = "EUR"; export const CORNERSTONE_USER_TYPE = "Mitarbeiter"; /** Platzhalter, solange das führende System die Kennung nicht liefert. */ export const DIVISION_PLATZHALTER = "DIVISION-ID-HIER"; export const DOXIS_PRAEFIX = "DOX-"; export const LGV_PRAEFIX = "LGV-"; /** * Cornerstone kennt zwei Geschlechter nicht als „m"/„w". * * Die erlaubten Werte sind female, male, not known und not specified. Ein * Wert ausserhalb davon lässt Cornerstone die Zeile abweisen — nicht das * Feld, die ganze Zeile. */ export function geschlecht(g: string): string { if (g === "m") return "male"; if (g === "w") return "female"; return "not specified"; } /** * Tag zuerst: 15.09.2026. * * Die Einleseeinstellung in Cornerstone ist auf „Tag zuerst" gestellt. Käme * ein ISO-Datum an, läse Cornerstone den 09. März statt des 3. September — * lautlos, weil beides gültige Daten sind. */ export function csDatum(iso: string | null | undefined): string { if (!iso) return ""; const [j, m, t] = iso.slice(0, 10).split("-"); return j && m && t ? `${t}.${m}.${j}` : ""; } /** Volle Monate zwischen Eintritt und Stichtag. */ export function dienstmonate(eintritt: string | null | undefined, stichtag: string): number | "" { if (!eintritt) return ""; const [ej, em, et] = eintritt.slice(0, 10).split("-").map(Number); const [sj, sm, st] = stichtag.slice(0, 10).split("-").map(Number); if (!ej || !sj) return ""; const monate = (sj - ej) * 12 + (sm - em) - (st < et ? 1 : 0); return Math.max(0, monate); } /** * Der Anmeldename: vorname.nachname, klein, ohne Umlaute und Leerzeichen. * * Abgeleitet und nicht aus der dienstlichen Adresse geschnitten: die Adresse * ist freiwillig, der Name ist es nicht. Cornerstone braucht das Feld. */ export function benutzername(vorname: string, nachname: string): string { const sauber = (s: string) => s .toLowerCase() .replace(/ä/g, "ae") .replace(/ö/g, "oe") .replace(/ü/g, "ue") .replace(/ß/g, "ss") .normalize("NFD") .replace(/[̀-ͯ]/g, "") .replace(/[^a-z0-9]+/g, ""); return [sauber(vorname), sauber(nachname)].filter(Boolean).join("."); } /** * Zwei Statusspalten, zwei verschiedene Fragen. * * `Status` ist das Konto in Cornerstone: wer ausgetreten ist, soll sich nicht * mehr anmelden. `Employment Status` ist das Beschäftigungsverhältnis. Eine * Person in Karenz hat ein aktives Konto und arbeitet trotzdem nicht — würde * man beide Spalten aus demselben Wert füllen, verschwände sie entweder aus * dem System oder sie gälte als arbeitend. */ export function kontoStatus(status: EmploymentStatus): string { return status === "Ausgetreten" ? "Inaktiv" : "Aktiv"; } export function beschaeftigungsStatus(status: EmploymentStatus): string { if (status === "Karenz") return "Abwesend"; if (status === "Ausgetreten") return "Ausgetreten"; if (status === "Geplant") return "Eintritt geplant"; return "Arbeitend"; } export type CornerstoneZeile = Record; /** Die Person, so wie der Export sie braucht. */ export type CornerstoneQuelle = { id: string; personnel_number: number; first_name: string; last_name: string; title_prefix: string[]; title_suffix: string[]; gender: string; company_email: string | null; address: string | null; postal_code: string | null; city: string | null; address_country: string | null; entry_date: string; exit_date: string | null; exit_reason: string | null; austrittsart: string | null; absence_type: string | null; status: EmploymentStatus; paygrade: string; location_id: string; org_unit_id: string | null; position_id: string | null; position_number: string | null; manager_id: string | null; }; export type CornerstoneKontext = { orgMaps: OrgMaps; standortName: Map; /** Personalnummer je Mitarbeiterkennung — Cornerstone verweist über die User ID. */ managerNummer: Map; /** Kostenstelle je Planstelle zum Stichtag. */ kostenstelle: Map; /** Wer schon einmal wiedereingetreten ist. */ wiedereingestellt: Set; stichtag: string; }; /** Eine Zeile — die Zuordnung selbst, ohne Datenbank und ohne Dateiformat. */ export function baueCornerstoneZeile(p: CornerstoneQuelle, k: CornerstoneKontext): CornerstoneZeile { const nummer = p.personnel_number; const name = benutzername(p.first_name, p.last_name); const einheit = unitOf(k.orgMaps, p.org_unit_id); const kst = p.position_id ? k.kostenstelle.get(p.position_id) : undefined; const chef = p.manager_id ? k.managerNummer.get(p.manager_id) : undefined; return { "User ID": String(nummer), "Local System ID": String(nummer), Prefix: p.title_prefix.join(" "), "First Name": p.first_name, "Middle Name": "", "Last Name": p.last_name, Suffix: p.title_suffix.join(" "), Username: name, Approver: "", Manager: chef ? String(chef) : "", Absent: "", "Allow Reconciliation": "", Email: p.company_email ?? "", "Required Training Approvals": "", Gender: geschlecht(p.gender), Exempt: "", Ethnicity: "", "Months of Service": dienstmonate(p.entry_date, k.stichtag), "Compensation Currency": CORNERSTONE_WAEHRUNG, "Address Line 1": p.address ?? "", "Address Line 2": "", City: p.city ?? "", State: "", Zip: p.postal_code ?? "", Mailstop: "", Country: p.address_country ?? "", "Original Hire Date": csDatum(p.entry_date), "Last Hire Date": csDatum(p.entry_date), Phone: "", Mobile: "", // Private Nummer und private Adresse bleiben im Haus — siehe oben. "Home Phone": "", Fax: "", Status: kontoStatus(p.status), "User activation date": "", "User de-activation date": "", "Personal Email": "", Language: CORNERSTONE_SPRACHE, "Time Zone": CORNERSTONE_ZEITZONE, "User Type": CORNERSTONE_USER_TYPE, "Employment Status": beschaeftigungsStatus(p.status), "Leave Reason": p.status === "Karenz" ? (p.absence_type ?? "") : "", "Termination Type": p.austrittsart ?? "", "Termination Reason": p.exit_reason ?? "", "Termination Date": csDatum(p.exit_date), "Eligible For Rehire": "", // „Ja" nur, wenn die Historie einen Wiedereintritt trägt. Leer heisst // „nie ausgetreten und zurückgekommen", nicht „unbekannt". "Rehired Employee": k.wiedereingestellt.has(p.id) ? "Ja" : "", "Division ID": DIVISION_PLATZHALTER, "Position ID": p.position_number ?? "", "Grade ID": p.paygrade, "Cost Center ID": kst?.code ?? "", "Location ID": k.standortName.get(p.location_id) ?? "", "Retention Rules": "", // Die Ebene, auf der die Person hängt — Gesellschaft, Bereich, Abteilung // oder Team. Nicht der Name der Einheit: der steht im Organigramm, und // Cornerstone gliedert nach der Stufe. Organisationsstufe: einheit?.unit_type ?? "", "Customfield ID AD": name, "Customfield ID Doxis": `${DOXIS_PRAEFIX}${nummer}`, "Customfield ID Interflex": String(nummer), "Customfield ID LGVplus": `${LGV_PRAEFIX}${nummer}`, }; } /** Die Spalten in der Reihenfolge, die Cornerstone erwartet. */ export const CORNERSTONE_SPALTEN: readonly string[] = [ "User ID", "Local System ID", "Prefix", "First Name", "Middle Name", "Last Name", "Suffix", "Username", "Approver", "Manager", "Absent", "Allow Reconciliation", "Email", "Required Training Approvals", "Gender", "Exempt", "Ethnicity", "Months of Service", "Compensation Currency", "Address Line 1", "Address Line 2", "City", "State", "Zip", "Mailstop", "Country", "Original Hire Date", "Last Hire Date", "Phone", "Mobile", "Home Phone", "Fax", "Status", "User activation date", "User de-activation date", "Personal Email", "Language", "Time Zone", "User Type", "Employment Status", "Leave Reason", "Termination Type", "Termination Reason", "Termination Date", "Eligible For Rehire", "Rehired Employee", "Division ID", "Position ID", "Grade ID", "Cost Center ID", "Location ID", "Retention Rules", "Organisationsstufe", "Customfield ID AD", "Customfield ID Doxis", "Customfield ID Interflex", "Customfield ID LGVplus", ]; export function cornerstoneSpalten(): ExportColumn[] { return CORNERSTONE_SPALTEN.map((header) => ({ header, get: (z) => z[header] ?? "" })); }