The 57 columns of the Cornerstone user import, in the given order and spelling, offered as CSV and Excel under the Honestly report. Same people and same filters as the full export. Two things about the file itself, both of which would have failed quietly. Cornerstone reads comma-separated, while toCsv writes the semicolons and the BOM that German Excel wants -- the BOM would have hidden inside the name of the first column, so "User ID" would have matched nothing in the mapping. toCsv now takes the form as an argument and keeps its old defaults; the Cornerstone form lives next to the columns so a test can hold both. Quoting follows the delimiter now, otherwise a comma in an address would split the row. Dates go out day-first, matching the import setting. An ISO date is read as a different, equally valid date and nobody notices. Gender maps to Cornerstone's own values; anything unexpected becomes "not specified" rather than empty, because an invalid value makes Cornerstone reject the whole row, not just the field. Status and Employment Status come from separate rules: somebody on Karenz has a working account and is not working, and filling both from one value gets one of the two wrong. Four fields carry visible placeholders because the leading systems do not supply their identifiers yet: Division ID, Doxis, Interflex, LGVplus. Home Phone and Personal Email stay empty on purpose -- what leaves the house is the business data. Not verified against a running Cornerstone import, and not seen in a browser; there is no database reachable here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
126 lines
5.3 KiB
TypeScript
126 lines
5.3 KiB
TypeScript
import type { Tx } from "./db";
|
|
import { derivedStatusFilter } from "./employee-status-filter";
|
|
import { fmtName, todayIso } from "./format";
|
|
import { subtreeOf, type OrgMaps } from "./org";
|
|
import { loadPlacements, loadReportingLineMap } from "./placement";
|
|
import { LEERE_CRITERIA, parseCriteria, passtImSpeicher } from "./report-criteria";
|
|
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "./reports";
|
|
import { applyCriteria, loadDependentsCounts, loadOrgLookups, type ReportFilters } from "./reports-data";
|
|
import type { Database } from "./types";
|
|
|
|
// Wer in einem Personenexport steht — für alle Exporte dieselbe Antwort.
|
|
//
|
|
// Stand bis September 2026 in der Route des vollständigen Exports. Seit es
|
|
// den Honestly-Export daneben gibt, gäbe es sonst zwei Fassungen derselben
|
|
// Auswahl, und die beiden Dateien enthielten bei gleichen Filtern
|
|
// verschiedene Leute, ohne dass es jemand merkt.
|
|
|
|
/** Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt
|
|
* über die Planstelle und die abgeleitete Berichtslinie. */
|
|
export type ExportMitarbeiter = Database["public"]["Tables"]["employees"]["Row"] & {
|
|
org_unit_id: string | null;
|
|
position_id: string | null;
|
|
position_number: string | null;
|
|
is_chief: boolean;
|
|
manager_id: string | null;
|
|
};
|
|
|
|
export type ExportParameter = {
|
|
format: "csv" | "xlsx";
|
|
asOf: string | undefined;
|
|
filters: ReportFilters;
|
|
};
|
|
|
|
export function exportParameter(params: URLSearchParams): ExportParameter {
|
|
return {
|
|
format: params.get("format") === "xlsx" ? "xlsx" : "csv",
|
|
asOf: parseIsoDateParam(params.get("asOf")),
|
|
filters: {
|
|
division: params.get("division") ?? undefined,
|
|
location: params.get("location") ?? undefined,
|
|
status: params.get("status") ?? undefined,
|
|
criteria: parseCriteria((k) => params.get(k)),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Die Personen, die ein Export mit diesen Filtern enthält, samt Einordnung.
|
|
*
|
|
* Mit Stichtag wird der Status gegen den *abgeleiteten* Stand an diesem Tag
|
|
* geprüft, nicht gegen die Spalte `status` — siehe deriveStatusAsOf.
|
|
*/
|
|
export async function ladeExportMitarbeiter(
|
|
tx: Tx,
|
|
{ asOf, filters }: Pick<ExportParameter, "asOf" | "filters">
|
|
): Promise<{
|
|
rows: ExportMitarbeiter[];
|
|
lookups: OrgLookups;
|
|
orgMaps: OrgMaps;
|
|
managerName: Map<string, string>;
|
|
/** Personalnummer je Kennung — für Exporte, die über Nummern verweisen. */
|
|
managerNummer: Map<string, number>;
|
|
dependentsCounts: Map<string, number>;
|
|
}> {
|
|
const statuses = parseStatuses(filters.status);
|
|
const stichtag = asOf ?? todayIso();
|
|
const criteria = filters.criteria ?? LEERE_CRITERIA;
|
|
|
|
function employeeQuery() {
|
|
let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id");
|
|
if (filters.location) q = q.where("location_id", "=", filters.location);
|
|
// Ohne Stichtag wurde hier bisher über die Spalte `status` gefiltert,
|
|
// mit Stichtag weiter unten über die Ableitung. Das sind zwei
|
|
// verschiedene Antworten auf dieselbe Frage: die Spalte hängt nach,
|
|
// sobald ein Austritt mit einem damals künftigen Datum erfasst wurde
|
|
// (Migration 20260814100000 zieht sie nicht nach). Der Export nach
|
|
// „Ausgetreten" liess damit genau die Leute aus, die gerade
|
|
// ausgetreten sind. Jetzt beidemale dieselbe Regel, nur einmal in SQL
|
|
// und einmal im Speicher.
|
|
if (!asOf) q = q.where((eb) => derivedStatusFilter(eb, statuses, stichtag) ?? eb.val(true));
|
|
// Dieselben Bedingungen wie im Bericht daneben — sonst stimmt die
|
|
// Zahl auf dem Bildschirm nicht mit der Zeilenzahl im Export überein.
|
|
return applyCriteria(q, criteria);
|
|
}
|
|
|
|
const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
|
|
employeeQuery().execute(),
|
|
loadOrgLookups(tx),
|
|
tx.selectFrom("employees").select(["id", "first_name", "last_name", "personnel_number"]).orderBy("id").execute(),
|
|
loadDependentsCounts(tx),
|
|
loadPlacements(tx, { asOf: stichtag }),
|
|
loadReportingLineMap(tx, stichtag),
|
|
]);
|
|
|
|
const orgMaps = lookupResult.orgMaps;
|
|
// Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein
|
|
// Export für "Produktion" nur die Bereichsleitung.
|
|
const allowedUnits = filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
|
|
|
|
const enriched: ExportMitarbeiter[] = employees.flatMap((e) => {
|
|
const placement = placements.get(e.id);
|
|
const orgUnitId = placement?.current ? placement.orgUnitId : null;
|
|
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) return [];
|
|
if (!passtImSpeicher({ work_days: e.work_days, dependentsCount: dependentsCounts.get(e.id) ?? 0 }, criteria)) return [];
|
|
return [
|
|
{
|
|
...e,
|
|
org_unit_id: orgUnitId,
|
|
position_id: placement?.current ? placement.positionId : null,
|
|
position_number: placement?.positionNumber ?? null,
|
|
is_chief: placement?.isChief ?? false,
|
|
manager_id: lines.get(e.id)?.acting_manager_id ?? null,
|
|
},
|
|
];
|
|
});
|
|
|
|
return {
|
|
rows: asOf ? enriched.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : enriched,
|
|
lookups: lookupResult.lookups,
|
|
orgMaps,
|
|
managerName: new Map(allEmployees.map((e) => [e.id, fmtName(e.first_name, e.last_name)])),
|
|
managerNummer: new Map(allEmployees.map((e) => [e.id, e.personnel_number])),
|
|
dependentsCounts,
|
|
};
|
|
}
|