Die Berichtsseite loest die Position ueber die Besetzung auf -- die Taetigkeit der Planstelle, auf der die Person am Stichtag sitzt, und nur ersatzweise employees.job_title. Die Exporte nahmen nur die Spalte. Beides sind unterschiedliche Angaben, also konnte in der Akte etwas anderes stehen als in der Datei daneben, ohne dass eines von beidem falsch aussah. Betrifft die Spalte "Position" im vollstaendigen Datenexport und im LOLYO-Export.
135 lines
5.9 KiB
TypeScript
135 lines
5.9 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>;
|
|
/** Cornerstone-ID je Kennung — für Exporte, die über die Kennung des Zielsystems verweisen. */
|
|
managerKennung: Map<string, string | null>;
|
|
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),
|
|
// Absichtlich schmal: die Liste umfasst **alle** Personen, nicht nur die
|
|
// gefilterten, und dient nur dazu, einen Verweis aufzulösen.
|
|
tx.selectFrom("employees").select(["id", "first_name", "last_name", "cornerstone_id"]).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,
|
|
// Die Tätigkeit der Planstelle, auf der die Person am Stichtag sitzt —
|
|
// und nur ersatzweise die Spalte an der Person. Genau so löst die
|
|
// Berichtsseite es auf (loadSnapshotEmployees); vorher stand hier die
|
|
// Spalte allein, und damit konnte in der Akte eine andere Position
|
|
// stehen als in der Datei daneben, ohne dass eines von beidem falsch
|
|
// aussah.
|
|
job_title: placement?.current ? placement.jobTitle : e.job_title,
|
|
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)])),
|
|
managerKennung: new Map(allEmployees.map((e) => [e.id, e.cornerstone_id])),
|
|
dependentsCounts,
|
|
};
|
|
}
|