Compare commits
28 Commits
e11544b8da
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 96c94642ba | |||
| d9966c9623 | |||
| 0039ce5069 | |||
| 38f8c99968 | |||
| fe858c6c24 | |||
| cf5ed5c6c6 | |||
| a0b9f6dc80 | |||
| 194b6d8916 | |||
| 89ba34c901 | |||
| 990ebdbc84 | |||
| be9ca4758f | |||
| 3664b9a435 | |||
| 2eda038c2e | |||
| ab50c21b8a | |||
| a3dde61ec1 | |||
| a6b6a7d67c | |||
| 8b7e32f14a | |||
| 771020af72 | |||
| 8ac4df17c1 | |||
| 38e35e2ff9 | |||
| 676cecbf15 | |||
| af2ad53ef9 | |||
| 677ca35df2 | |||
| 779beb4478 | |||
| c3e19606e2 | |||
| 6717412c9f | |||
| 410acfe01f | |||
| 478364e2d0 |
@@ -6,7 +6,7 @@ import { withUser } from "@/lib/db";
|
||||
import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc";
|
||||
import { OFFBOARDING_PUNKTE } from "@/lib/offboarding";
|
||||
import { ONBOARDING_PUNKTE } from "@/lib/onboarding";
|
||||
import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/types";
|
||||
import type { CollectiveAgreement, DienstwagenArt, NoteCategory, PaygradeType, RelationshipType, Weekday, WorkerType } from "@/lib/types";
|
||||
|
||||
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
|
||||
const result = await runMutation(await currentUserId(), fn, payload);
|
||||
@@ -85,6 +85,8 @@ export async function hireEmployee(payload: {
|
||||
* der in unserem Namen Einladungen verschickt.
|
||||
*/
|
||||
company_email?: string;
|
||||
/** Kennung im Lernsystem Cornerstone. Freiwillig. */
|
||||
cornerstone_id?: string;
|
||||
phone?: string;
|
||||
position_id?: string;
|
||||
team_id?: string;
|
||||
@@ -95,7 +97,7 @@ export async function hireEmployee(payload: {
|
||||
contract_end_date?: string;
|
||||
employment_type?: "Vollzeit" | "Teilzeit";
|
||||
weekly_hours?: number;
|
||||
paygrade?: "A" | "B" | "C" | "D" | "E" | "F";
|
||||
paygrade?: PaygradeType;
|
||||
source: "Intern" | "Extern";
|
||||
worker_type?: WorkerType;
|
||||
collective_agreement?: CollectiveAgreement;
|
||||
@@ -214,7 +216,7 @@ export async function promoteEmployee(payload: {
|
||||
employee_id: string;
|
||||
effective_date: string;
|
||||
new_title: string;
|
||||
new_paygrade?: "A" | "B" | "C" | "D" | "E" | "F";
|
||||
new_paygrade?: PaygradeType;
|
||||
/**
|
||||
* Nur, wenn die Beförderung zugleich auf eine andere Planstelle führt.
|
||||
*
|
||||
@@ -281,6 +283,7 @@ export async function changeEmployeeData(payload: {
|
||||
export async function rehireEmployee(payload: {
|
||||
employee_id: string;
|
||||
company_email?: string;
|
||||
cornerstone_id?: string;
|
||||
rehire_date: string;
|
||||
position_id: string;
|
||||
/**
|
||||
|
||||
47
actions/org.ts
Normal file
47
actions/org.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { runMutation, type ActionResult } from "@/lib/db/rpc";
|
||||
|
||||
/**
|
||||
* Eine Organisationseinheit unter einer bestehenden anlegen.
|
||||
*
|
||||
* Bewusst die einzige Änderung an der Struktur, die es aus der Anwendung
|
||||
* heraus gibt. Umbenennen, verschieben und schliessen fehlen nicht aus
|
||||
* Zeitmangel: org_units.parent_id trägt kein Datum, ein Verschieben änderte
|
||||
* damit auch jede Auswertung auf einen vergangenen Stichtag. Siehe Migration
|
||||
* 20260929120000.
|
||||
*/
|
||||
const ORG_PFADE = ["/orgchart", "/positions", "/"];
|
||||
|
||||
export async function createOrgUnit(payload: {
|
||||
parent_id: string;
|
||||
org_number: string;
|
||||
name: string;
|
||||
unit_type: "Bereich" | "Abteilung" | "Team";
|
||||
valid_from: string;
|
||||
/** Leer lassen heisst: die Einheit entsteht ohne Leitung. */
|
||||
leitung_taetigkeit?: string;
|
||||
}): Promise<ActionResult> {
|
||||
const result = await runMutation(await currentUserId(), "create_org_unit", payload);
|
||||
if (!result.success) return result;
|
||||
// Die Einheit taucht in drei Ansichten auf, und in allen dreien wäre sie
|
||||
// sonst erst nach dem nächsten harten Neuladen zu sehen.
|
||||
for (const path of ORG_PFADE) revalidatePath(path);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Organisationseinheit entfernen.
|
||||
*
|
||||
* Geht nur, solange nichts darunter hängt und nie etwas hing. Alles andere
|
||||
* weist die Datenbankfunktion mit dem Grund ab — ein Schliessen zum Stichtag
|
||||
* gibt es nicht, weil org_units nirgends gegen den Stichtag gelesen wird.
|
||||
*/
|
||||
export async function deleteOrgUnit(orgUnitId: string): Promise<ActionResult> {
|
||||
const result = await runMutation(await currentUserId(), "delete_org_unit", { org_unit_id: orgUnitId });
|
||||
if (!result.success) return result;
|
||||
for (const path of ORG_PFADE) revalidatePath(path);
|
||||
return result;
|
||||
}
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { kontierungZum, kostenstellenAbfrage, type KontierungsZeile } from "@/lib/cost-centers";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { jsonArrayFrom } from "@/lib/db/json";
|
||||
import { runMutation, type ActionResult } from "@/lib/db/rpc";
|
||||
import { fmtName, todayIso } from "@/lib/format";
|
||||
import type { PlanstelleZumAendern } from "@/lib/positions";
|
||||
|
||||
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
|
||||
|
||||
@@ -82,3 +87,88 @@ export async function setPositionCostCenter(payload: {
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("set_position_cost_center", payload, POSITION_PATHS);
|
||||
}
|
||||
|
||||
export type PlanstelleDerEinheit = PlanstelleZumAendern & {
|
||||
/** Wer am Stichtag darauf sitzt; null heisst unbesetzt. */
|
||||
besetztVon: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Planstellen einer Organisationseinheit — **auch die besetzten**.
|
||||
*
|
||||
* Gelesen, nicht geschrieben, und erst beim Öffnen der Einheit: der Bestand
|
||||
* zählt 788 Planstellen, und sie alle in jede Zeichnung des Organigramms zu
|
||||
* legen hiesse, für achtzig Einheiten zu laden, was man für eine braucht.
|
||||
*
|
||||
* Der Grund, warum es diese Abfrage überhaupt gibt: die Seite Positionen zeigt
|
||||
* nur unbesetzte Stellen, und der Änderungsdialog war allein von dort aus
|
||||
* erreichbar. Die Kontierung einer besetzten Planstelle liess sich damit
|
||||
* nirgends ändern.
|
||||
*
|
||||
* Kein Schutzgatter im Anwendungscode: die Zeilenschutz-Regeln geben einer
|
||||
* Person ohne HR-Recht nichts zurück, und das ist die Grenze, die zählt.
|
||||
*/
|
||||
export async function ladePlanstellenDerEinheit(orgUnitId: string): Promise<{
|
||||
planstellen: PlanstelleDerEinheit[];
|
||||
kostenstellen: { id: string; code: string; name: string }[];
|
||||
/** Vom Server: der Dialog rechnet sonst mit der Zone des Browsers. */
|
||||
heute: string;
|
||||
}> {
|
||||
const heute = todayIso();
|
||||
|
||||
const g = await withUser(await currentUserId(), (tx) =>
|
||||
tx
|
||||
.selectNoFrom((eb) => [
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("om_positions as p")
|
||||
.innerJoin("jobs as j", "j.id", "p.job_id")
|
||||
.select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "p.valid_from", "p.valid_to", "j.title"])
|
||||
.where("p.org_unit_id", "=", orgUnitId)
|
||||
.orderBy("p.position_number")
|
||||
).as("stellen"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("position_assignments as a")
|
||||
.innerJoin("om_positions as p", "p.id", "a.position_id")
|
||||
.innerJoin("employees as e", "e.id", "a.employee_id")
|
||||
.select(["a.position_id", "e.first_name", "e.last_name"])
|
||||
.where("p.org_unit_id", "=", orgUnitId)
|
||||
.where((x) => x.or([x("a.valid_to", "is", null), x("a.valid_to", ">", heute)]))
|
||||
).as("besetzungen"),
|
||||
// Nicht kontierungenAbfrage(): die filtert über eine Liste von
|
||||
// Planstellen, die hier erst aus der ersten Teilabfrage käme — und das
|
||||
// wäre eine zweite Rundreise für dieselbe Auskunft.
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("position_cost_centers as z")
|
||||
.innerJoin("cost_centers as k", "k.id", "z.cost_center_id")
|
||||
.innerJoin("om_positions as p", "p.id", "z.position_id")
|
||||
.select(["z.position_id", "z.cost_center_id", "k.code", "k.name", "z.valid_from", "z.valid_to"])
|
||||
.where("p.org_unit_id", "=", orgUnitId)
|
||||
.orderBy("z.valid_from")
|
||||
).as("kontierungen"),
|
||||
jsonArrayFrom(kostenstellenAbfrage(eb, heute)).as("kostenstellen"),
|
||||
])
|
||||
.executeTakeFirstOrThrow()
|
||||
);
|
||||
|
||||
const kontierung = kontierungZum(g.kontierungen as KontierungsZeile[], heute);
|
||||
const inhaber = new Map(g.besetzungen.map((b) => [b.position_id, fmtName(b.first_name, b.last_name)]));
|
||||
|
||||
return {
|
||||
planstellen: g.stellen.map((p) => ({
|
||||
id: p.id,
|
||||
position_number: p.position_number,
|
||||
title: p.title,
|
||||
org_unit_id: p.org_unit_id,
|
||||
is_chief: p.is_chief,
|
||||
valid_from: p.valid_from,
|
||||
valid_to: p.valid_to,
|
||||
kostenstelle: kontierung.get(p.id) ?? null,
|
||||
besetztVon: inhaber.get(p.id) ?? null,
|
||||
})),
|
||||
kostenstellen: g.kostenstellen,
|
||||
heute,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { StatusChip } from "@/components/ui/StatusChip";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { sql, withUser } from "@/lib/db";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "@/lib/db/json";
|
||||
import { istPersonalnummer, suchMuster } from "@/lib/employee-search";
|
||||
import { istPersonalnummer, suchMuster, TRENNZEICHEN, TRENNZEICHEN_ERSATZ } from "@/lib/employee-search";
|
||||
import {
|
||||
SORTIERFELDER,
|
||||
naechsteRichtung,
|
||||
@@ -150,7 +150,15 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
.innerJoin("om_positions as p", "p.id", "a.position_id")
|
||||
.select("a.id")
|
||||
.whereRef("a.employee_id", "=", "employees.id")
|
||||
.where("a.valid_to", "is", null)
|
||||
// Laufend **oder noch bevorstehend**: `valid_to is null` allein
|
||||
// liess jede Person mit vorgemerktem Austritt aus der Einheit
|
||||
// verschwinden — terminate_employee setzt das Ende schon beim
|
||||
// Erfassen, Monate vor dem Tag. Gemeldet im Test vom 29.09.
|
||||
// (C.09). Das Ende ist ausschliessend, deshalb `>` und nicht
|
||||
// `>=`. Kein `valid_from <= heute`: ein geplanter Eintritt
|
||||
// gehört in die Liste, sonst fiele er aus dem Einheitenfilter,
|
||||
// obwohl der Status „Geplant" ihn ausdrücklich führt.
|
||||
.where((e) => e.or([e("a.valid_to", "is", null), e("a.valid_to", ">", today)]))
|
||||
.where("p.org_unit_id", "in", units)
|
||||
)
|
||||
);
|
||||
@@ -190,9 +198,12 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
// diesem Ausdruck nicht mehr. Bei knapp neunhundert Zeilen liest
|
||||
// Postgres die Tabelle in wenigen Millisekunden; ein Index auf
|
||||
// demselben Ausdruck holt das zurück, sobald das nicht mehr stimmt.
|
||||
// Die Trennzeichen stehen in lib/employee-search.ts, weil die
|
||||
// Eingabe an denselben zerlegt werden muss — standen sie nur hier,
|
||||
// fand „Müller-Weiß" nichts (C.08).
|
||||
const heuhaufen = nurNamen
|
||||
? sql<string>`translate(lower(first_name || ' ' || last_name), '-/:.,', ' ')`
|
||||
: sql<string>`translate(lower(first_name || ' ' || last_name || ' ' || job_title), '-/:.,', ' ')`;
|
||||
? sql<string>`translate(lower(first_name || ' ' || last_name), ${TRENNZEICHEN}, ${TRENNZEICHEN_ERSATZ})`
|
||||
: sql<string>`translate(lower(first_name || ' ' || last_name || ' ' || job_title), ${TRENNZEICHEN}, ${TRENNZEICHEN_ERSATZ})`;
|
||||
q = q.where((eb) =>
|
||||
eb.and(
|
||||
// Als Parameter gebunden, nicht in die Abfrage geschrieben.
|
||||
|
||||
@@ -22,7 +22,17 @@ export default async function OrgChartPage({ searchParams }: { searchParams: Pro
|
||||
const { org, units } = await withUser(await currentUserId(), async (tx) => {
|
||||
const [org, units] = await Promise.all([
|
||||
loadOrgAsOf(tx, asOf),
|
||||
tx.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number").execute(),
|
||||
tx
|
||||
.selectFrom("org_units")
|
||||
// valid_from/valid_to nur hier: die Angaben zur Einheit zeigen sie,
|
||||
// der Druck braucht sie nicht.
|
||||
.select(["id", "org_number", "name", "parent_id", "unit_type", "valid_from", "valid_to"])
|
||||
// Zum Stichtag — sonst zeigt die Struktursicht zum 01.01.2020
|
||||
// Einheiten, die es damals nicht gab (D.06).
|
||||
.where("valid_from", "<=", asOf)
|
||||
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)]))
|
||||
.orderBy("org_number")
|
||||
.execute(),
|
||||
]);
|
||||
return { org, units };
|
||||
});
|
||||
|
||||
@@ -70,7 +70,10 @@ export default async function DashboardPage({
|
||||
const today = todayIso();
|
||||
const year = today.slice(0, 4);
|
||||
const yearStart = `${year}-01-01`;
|
||||
const yearEnd = `${year}-12-31`;
|
||||
// Das obere Ende des YTD-Fensters ist **heute**. Hier stand der 31.12., also
|
||||
// das ganze Kalenderjahr — womit die Kacheln auch zählten, was erst
|
||||
// bevorsteht (B.02).
|
||||
const ytdBis = today;
|
||||
|
||||
// Der Vorschauzeitraum ist einstellbar, und mit ihm, was überhaupt geladen
|
||||
// wird. Deshalb steht die Auswahl in der Adresse und nicht im Browser: 90
|
||||
@@ -103,7 +106,7 @@ export default async function DashboardPage({
|
||||
upcomingReturns,
|
||||
upcomingNotes,
|
||||
history,
|
||||
} = await withUser(userId, (tx) => loadDashboardData(tx, { userId, today, yearStart, yearEnd, bisIso, arten }));
|
||||
} = await withUser(userId, (tx) => loadDashboardData(tx, { userId, today, yearStart, ytdBis, bisIso, arten }));
|
||||
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not
|
||||
// active, and is counted by its own tile instead. FTE follows the same
|
||||
// set: Karenz contributes no capacity, so including it would overstate
|
||||
@@ -195,8 +198,13 @@ export default async function DashboardPage({
|
||||
// wer heute arbeitet, hier, mit wem ein Vertrag läuft —
|
||||
// Langzeitabwesende eingeschlossen. Sichtbar waren 805 und 10,
|
||||
// addieren musste man selbst.
|
||||
// Die Namen der beiden stehen so fest: Max am 30.09. — „Es gibt zwei
|
||||
// Headcounts: Headcount Aktiv (nur Aktive) und Headcount Aktives
|
||||
// Dienstverhältnis (Aktiv + Langzeitabwesenheit)". Vorher hiess nur die
|
||||
// rechte Kachel „(HC)", und im Bericht stand daneben „Headcount" für
|
||||
// eine dritte Zahl — dasselbe Wort für Verschiedenes (N.02).
|
||||
{
|
||||
label: "Aktives Dienstverhältnis",
|
||||
label: "Headcount Aktives Dienstverhältnis",
|
||||
value: activeCount + karenzCount,
|
||||
tone: "default",
|
||||
// Mit Komma, nicht zweimal `status=`: die Liste liest den Parameter
|
||||
@@ -204,9 +212,7 @@ export default async function DashboardPage({
|
||||
// Next daraus ein Array, und die Seite scheiterte an `.split(",")`.
|
||||
href: "/employees?status=Aktiv,Karenz",
|
||||
},
|
||||
// „(HC)" für Headcount — der Unterschied zur Kachel links ist genau
|
||||
// der, den die Abkürzung benennt: Köpfe, die heute arbeiten.
|
||||
{ label: "Aktive Mitarbeiter:innen (HC)", value: activeCount, tone: "default", href: "/employees?status=Aktiv" },
|
||||
{ label: "Headcount Aktiv", value: activeCount, tone: "default", href: "/employees?status=Aktiv" },
|
||||
{ label: "Langzeitabwesende", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
|
||||
// Ohne Zeitgrenze — anders als die Karte „Anstehend" darunter, die nur
|
||||
// den eingestellten Vorschauzeitraum zeigt. Ein Eintritt in vier
|
||||
@@ -225,16 +231,18 @@ export default async function DashboardPage({
|
||||
kacheln: [
|
||||
{
|
||||
// YTD: seit Jahresbeginn bis heute, nicht das ganze Kalenderjahr.
|
||||
// Der Verweis trägt denselben Zeitraum — sonst zeigte der Bericht
|
||||
// eine andere Zahl als die Kachel, über die man ihn geöffnet hat.
|
||||
label: "Eintritte (YTD)",
|
||||
value: hiresYtd,
|
||||
tone: "success",
|
||||
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
|
||||
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${ytdBis}`,
|
||||
},
|
||||
{
|
||||
label: "Austritte (YTD)",
|
||||
value: exitsYtd,
|
||||
tone: "danger",
|
||||
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
||||
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${ytdBis}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -157,7 +157,7 @@ function BrandPanel() {
|
||||
|
||||
<div className="relative max-w-md">
|
||||
<p className="text-3xl font-extrabold leading-tight tracking-tight text-brand-700">
|
||||
Die Organisation, so wie sie heute wirklich aussieht.
|
||||
Alles im Blick. Alles Manner.
|
||||
</p>
|
||||
<p className="mt-4 text-sm leading-relaxed text-brand-700">
|
||||
Stammdaten, Planstellen und Berichtslinien der Alpenwerk Industrie GmbH — jederzeit auch zu einem beliebigen
|
||||
|
||||
46
app/api/export/cornerstone/route.ts
Normal file
46
app/api/export/cornerstone/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { baueCornerstoneZeile, CORNERSTONE_CSV, cornerstoneSpalten, type CornerstoneKontext } from "@/lib/cornerstone";
|
||||
import { loadKontierungen } from "@/lib/cost-centers";
|
||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx } from "@/lib/export";
|
||||
import { exportParameter, ladeExportMitarbeiter } from "@/lib/export-auswahl";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
// Der Export für Cornerstone — Spalten und Zuordnung stehen in
|
||||
// lib/cornerstone.ts.
|
||||
//
|
||||
// Dieselben Personen wie im vollständigen Export daneben, mit denselben
|
||||
// Filtern (lib/export-auswahl.ts). Wer nur die Aktiven einspielen will,
|
||||
// stellt das auf der Berichtsseite ein, bevor er exportiert.
|
||||
export async function GET(request: NextRequest) {
|
||||
const gate = await requireHrUser();
|
||||
if ("denied" in gate) return gate.denied;
|
||||
|
||||
const { format, asOf, filters } = exportParameter(request.nextUrl.searchParams);
|
||||
const stichtag = asOf ?? todayIso();
|
||||
|
||||
const { zeilen } = await withUser(gate.userId, async (tx) => {
|
||||
const { rows, managerKennung } = await ladeExportMitarbeiter(tx, { asOf, filters });
|
||||
|
||||
// Die eine Angabe, die die gemeinsame Auswahl nicht mitbringt: die
|
||||
// Kostenstelle hängt an der Planstelle, nicht an der Person. Erst jetzt,
|
||||
// weil erst jetzt feststeht, um welche Planstellen es geht.
|
||||
const positionIds = [...new Set(rows.map((r) => r.position_id).filter((id): id is string => Boolean(id)))];
|
||||
const kontext: CornerstoneKontext = {
|
||||
managerKennung,
|
||||
kostenstelle: await loadKontierungen(tx, { asOf: stichtag, positionIds }),
|
||||
};
|
||||
|
||||
return { zeilen: rows.map((r) => baueCornerstoneZeile(r, kontext)) };
|
||||
});
|
||||
|
||||
const filename = exportFilename("cornerstone-export", format);
|
||||
// Form und Spalten kommen beide aus lib/cornerstone.ts — siehe dort.
|
||||
const body =
|
||||
format === "xlsx"
|
||||
? await toXlsx(zeilen, cornerstoneSpalten(), "Worksheet")
|
||||
: toCsv(zeilen, cornerstoneSpalten(), CORNERSTONE_CSV);
|
||||
// Derselbe Umweg über Blob wie in den anderen Exporten (TS#59417).
|
||||
return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) });
|
||||
}
|
||||
@@ -53,6 +53,7 @@ function employeeExportColumns(
|
||||
{ header: "Wohnsitzland", get: (e) => e.address_country },
|
||||
{ header: "Private E-Mail", get: (e) => e.email },
|
||||
{ header: "Firmen-E-Mail", get: (e) => e.company_email },
|
||||
{ header: "Cornerstone-ID", get: (e) => e.cornerstone_id },
|
||||
{ header: "Private Telefonnummer", get: (e) => e.phone },
|
||||
{ header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") },
|
||||
{ header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") },
|
||||
@@ -96,7 +97,7 @@ function employeeExportColumns(
|
||||
{ header: "Notfallkontakt Verhältnis", get: (e) => e.emergency_contact_relation ?? "" },
|
||||
{ header: "Laterale Führung", get: (e) => e.is_laterale_fuehrung },
|
||||
{ header: "C-Level", get: (e) => e.is_c_level },
|
||||
{ header: "Paygrade", get: (e) => e.paygrade },
|
||||
{ header: "Hay-Grade", get: (e) => e.paygrade },
|
||||
{ header: "Herkunft", get: (e) => e.source },
|
||||
// The export shows the display name, not the raw enum value — a payroll
|
||||
// hand-off saying "Karenz" for what the app calls Langzeitabwesenheit
|
||||
|
||||
45
app/api/export/lolyo/route.ts
Normal file
45
app/api/export/lolyo/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx } from "@/lib/export";
|
||||
import { exportParameter, ladeExportMitarbeiter } from "@/lib/export-auswahl";
|
||||
import { baueLolyoZeile, LOLYO_CSV, lolyoSpalten } from "@/lib/lolyo";
|
||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
// Der Export für die Mitarbeiter-App LOLYO — Spalten und feste Werte stehen in
|
||||
// lib/lolyo.ts.
|
||||
//
|
||||
// Dieselben Personen wie im vollständigen Export daneben, mit denselben Filtern
|
||||
// (lib/export-auswahl.ts). Wer nur einen Bereich oder nur die Aktiven anlegen
|
||||
// will, stellt das auf der Berichtsseite ein, bevor er exportiert.
|
||||
|
||||
/**
|
||||
* Das gemeinsame Startpasswort, mit dem die Konten in LOLYO angelegt werden.
|
||||
*
|
||||
* Steht bewusst **hier** und nicht in lib/lolyo.ts: eine Route ist serverseitig
|
||||
* und gelangt nie in ein Bündel, das der Browser lädt. Eine Konstante in einer
|
||||
* lib-Datei wäre nur so lange sicher, wie niemand sie aus einem Client-Bauteil
|
||||
* heraus einbindet — und das sähe man dem Bündel nicht an. Dieselbe Überlegung
|
||||
* steht in lib/passwort.ts.
|
||||
*
|
||||
* Es ist dasselbe Passwort wie in Alpenwerk, so bestellt. Damit gilt hier auch
|
||||
* dasselbe: es ist allen gemeinsam und damit bekannt, und es trägt so lange,
|
||||
* bis die Person es wechselt. Die erzeugte Datei ist deshalb kein Anhang, den
|
||||
* man weiterleitet.
|
||||
*/
|
||||
const STARTPASSWORT = "Initialpasswort2026!";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const gate = await requireHrUser();
|
||||
if ("denied" in gate) return gate.denied;
|
||||
|
||||
const { format, asOf, filters } = exportParameter(request.nextUrl.searchParams);
|
||||
const { rows } = await withUser(gate.userId, (tx) => ladeExportMitarbeiter(tx, { asOf, filters }));
|
||||
|
||||
const zeilen = rows.map((r) => baueLolyoZeile(r, STARTPASSWORT));
|
||||
const spalten = lolyoSpalten();
|
||||
const filename = exportFilename("lolyo-export", format);
|
||||
|
||||
const body = format === "xlsx" ? await toXlsx(zeilen, spalten, "LOLYO") : toCsv(zeilen, spalten, LOLYO_CSV);
|
||||
// Derselbe Umweg über Blob wie im vollständigen Export (TS#59417).
|
||||
return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) });
|
||||
}
|
||||
@@ -217,20 +217,44 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
|
||||
</div>
|
||||
|
||||
{/* Der `key` an jedem Panel ist kein Feinschliff, sondern der Unterschied
|
||||
zwischen „zeigt Altes" und „schreibt Altes zurück".
|
||||
|
||||
Die Panels bleiben eingebunden, damit ihr Ein- und Ausfahren laufen
|
||||
kann. Sie belegen ihre Felder aber mit useState(employee.…) vor, und
|
||||
das läuft nur beim ersten Aufbau. Nach einer Beförderung bringt
|
||||
router.refresh() zwar die frische Akte herein — der Zustand im Panel
|
||||
bleibt der von vorhin. Beim nächsten Speichern geht er als Ganzes an
|
||||
change_employee_data, und der eben gesetzte Hay-Grade steht wieder
|
||||
auf dem alten Wert. Gemeldet im Test vom 29.09. (H.05).
|
||||
|
||||
employees.updated_at wechselt bei jeder Änderung an der Zeile
|
||||
(Trigger trg_employees_touch_updated_at), also genau dann, wenn die
|
||||
Vorbelegung neu zu lesen ist — und sonst nie. Der Wiedereintritts-
|
||||
Assistent darunter macht es seit jeher so. */}
|
||||
<TransferPanel
|
||||
key={`transfer-${employee.updated_at}`}
|
||||
open={panel === "transfer"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
openPositions={openPositions}
|
||||
/>
|
||||
<PromotePanel
|
||||
key={`promote-${employee.updated_at}`}
|
||||
open={panel === "promote"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
openPositions={openPositions}
|
||||
/>
|
||||
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} status={status} />
|
||||
<KarenzPanel
|
||||
key={`karenz-${employee.updated_at}`}
|
||||
open={panel === "karenz"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
status={status}
|
||||
/>
|
||||
<DatenAendernPanel
|
||||
key={`daten-${employee.updated_at}`}
|
||||
open={panel === "daten"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
@@ -238,6 +262,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
locationCountry={location?.country}
|
||||
/>
|
||||
<TerminatePanel
|
||||
key={`terminate-${employee.updated_at}`}
|
||||
open={panel === "terminate"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
|
||||
@@ -13,13 +13,14 @@ import { Field, SelectField, TextField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { brauchtAufenthaltstitel, UN_COUNTRIES } from "@/lib/countries";
|
||||
import { HAY_GRADES } from "@/lib/hay-grade";
|
||||
import { STUNDEN_GRUENDE } from "@/lib/absence";
|
||||
import { GRUND_BEGUENSTIGT_BEHINDERT } from "@/lib/kuendigungsschutz";
|
||||
import { MITARBEITERART_STANDARD } from "@/lib/mitarbeiterart";
|
||||
import { fmtFullName, todayIso } from "@/lib/format";
|
||||
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
|
||||
import { NotfallkontaktSection } from "@/components/employees/NotfallkontaktSection";
|
||||
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/types";
|
||||
import type { ContractType, Database, EmploymentType, GenderType, PaygradeType } from "@/lib/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
||||
@@ -66,6 +67,7 @@ export function DatenAendernPanel({
|
||||
const [addressCountry, setAddressCountry] = useState(employee.address_country ?? "Österreich");
|
||||
const [email, setEmail] = useState(employee.email ?? "");
|
||||
const [companyEmail, setCompanyEmail] = useState(employee.company_email ?? "");
|
||||
const [cornerstoneId, setCornerstoneId] = useState(employee.cornerstone_id ?? "");
|
||||
const [phone, setPhone] = useState(employee.phone ?? "");
|
||||
|
||||
const [employmentType, setEmploymentType] = useState<EmploymentType>(employee.employment_type);
|
||||
@@ -78,6 +80,7 @@ export function DatenAendernPanel({
|
||||
const [teilzeitBis, setTeilzeitBis] = useState(employee.teilzeit_bis ?? "");
|
||||
const [contractType, setContractType] = useState<ContractType>(employee.contract_type);
|
||||
const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? "");
|
||||
const [paygrade, setPaygrade] = useState<PaygradeType>(employee.paygrade);
|
||||
|
||||
const [role, setRole] = useState<RoleEmploymentValue>({
|
||||
workerType: employee.worker_type ?? "Angestellte:r",
|
||||
@@ -163,6 +166,7 @@ export function DatenAendernPanel({
|
||||
// Leerzeichen stehen geblieben ist, als Inhalt in der Spalte an.
|
||||
email: email.trim(),
|
||||
company_email: companyEmail.trim(),
|
||||
cornerstone_id: cornerstoneId.trim(),
|
||||
phone: phone.trim(),
|
||||
// Der Notfallkontakt läuft über seinen eigenen Abschnitt weiter unten
|
||||
// — wie die Angehörigen. Stünde er zusätzlich hier, schrieben zwei
|
||||
@@ -174,6 +178,7 @@ export function DatenAendernPanel({
|
||||
weekly_hours: Number(weeklyHours),
|
||||
contract_type: contractType,
|
||||
contract_end_date: contractType === "befristet" ? contractEndDate : "",
|
||||
paygrade,
|
||||
},
|
||||
role: {
|
||||
worker_type: role.workerType,
|
||||
@@ -309,6 +314,7 @@ export function DatenAendernPanel({
|
||||
</Field>
|
||||
<TextField label="E-Mail (privat)" dense type="email" value={email} onChange={setEmail} />
|
||||
<TextField label="Firmen-E-Mail" dense type="email" value={companyEmail} onChange={setCompanyEmail} />
|
||||
<TextField label="Cornerstone-ID" dense value={cornerstoneId} onChange={setCornerstoneId} />
|
||||
<TextField label="Telefon" dense type="tel" value={phone} onChange={setPhone} />
|
||||
</div>
|
||||
|
||||
@@ -369,6 +375,18 @@ export function DatenAendernPanel({
|
||||
{contractType === "befristet" && (
|
||||
<TextField label="Befristet bis" required dense type="date" value={contractEndDate} onChange={setContractEndDate} />
|
||||
)}
|
||||
{/* Auch über die Beförderung zu setzen — und trotzdem hier. Eine
|
||||
Umstufung ist nicht immer eine Beförderung: „da stand von
|
||||
Anfang an die falsche Stufe" ist eine Richtigstellung, und die
|
||||
soll keine Planstelle wechseln und kein Ereignis „Beförderung"
|
||||
in der Historie hinterlassen. */}
|
||||
<SelectField
|
||||
label="Hay-Grade"
|
||||
dense
|
||||
value={paygrade}
|
||||
onChange={(v) => setPaygrade(v as PaygradeType)}
|
||||
options={HAY_GRADES}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,21 +7,13 @@ import { Button } from "@/components/ui/Button";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { HAY_GRADES } from "@/lib/hay-grade";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { Database, PaygradeType } from "@/lib/types";
|
||||
import { fmtName } from "@/lib/format";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
const PAYGRADES: { value: PaygradeType; label: string }[] = [
|
||||
{ value: "A", label: "A – Einstieg" },
|
||||
{ value: "B", label: "B – Qualifiziert" },
|
||||
{ value: "C", label: "C – Erfahren" },
|
||||
{ value: "D", label: "D – Spezialist:in" },
|
||||
{ value: "E", label: "E – Teamleitung" },
|
||||
{ value: "F", label: "F – Bereichsleitung / GF" },
|
||||
];
|
||||
|
||||
// Eine Beförderung kann auf derselben Planstelle stattfinden oder auf eine
|
||||
// andere führen. Beides kommt vor: jemand wächst auf seiner Stelle, oder er
|
||||
// rückt auf eine höhere — und dann ist die Zielstelle dieselbe Auswahl wie
|
||||
@@ -173,10 +165,10 @@ export function PromotePanel({
|
||||
|
||||
<TextField label="Neue Position" required value={newTitle} onChange={setNewTitle} />
|
||||
<SelectField
|
||||
label="Verwendungsgruppe"
|
||||
label="Hay-Grade"
|
||||
value={paygrade}
|
||||
onChange={(v) => setPaygrade(v as PaygradeType)}
|
||||
options={PAYGRADES}
|
||||
options={HAY_GRADES}
|
||||
/>
|
||||
</div>
|
||||
</SlideOver>
|
||||
|
||||
@@ -54,6 +54,7 @@ export function StammdatenTab({ employee, location, dependents }: { employee: Em
|
||||
["Land", employee.address_country ?? "–"],
|
||||
["Private E-Mail", employee.email ?? "–"],
|
||||
["Firmen-E-Mail", employee.company_email ?? "–"],
|
||||
["Cornerstone-ID", employee.cornerstone_id ?? "–"],
|
||||
["Private Telefonnummer", employee.phone ?? "–"],
|
||||
// Der Standort ist keine Angabe zur Person, sondern die Betriebsstätte —
|
||||
// er steht deshalb am Ende und nicht zwischen Adresse und Land, wo man
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
import { dienstwagenLabel } from "@/lib/dienstwagen";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import { hayGradeLabel } from "@/lib/hay-grade";
|
||||
import type { Database } from "@/lib/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
const PAYGRADE_LABELS: Record<string, string> = {
|
||||
A: "A – Einstieg",
|
||||
B: "B – Qualifiziert",
|
||||
C: "C – Erfahren",
|
||||
D: "D – Spezialist:in",
|
||||
E: "E – Teamleitung",
|
||||
F: "F – Bereichsleitung / GF",
|
||||
};
|
||||
/**
|
||||
* Der Kündigungsschutz mit Grund und Zeitraum.
|
||||
*
|
||||
* Hier stand nur „bis TT.MM.JJJJ". Erfasst werden aber Personenkreis, Beginn
|
||||
* und Ende — und der Personenkreis ist die eigentliche Auskunft: „bis 2030"
|
||||
* sagt nicht, warum. Gemeldet am 29.09. von Lara und im Testprotokoll (H.09).
|
||||
*
|
||||
* Die Wortwahl für den einfachsten Fall bleibt: steht nur das Kennzeichen da,
|
||||
* heisst es weiter „Ja (Ende offen)".
|
||||
*/
|
||||
function kuendigungsschutzText(e: EmployeeRow): string {
|
||||
if (!e.has_kuendigungsschutz) return "Nein";
|
||||
const teile = [
|
||||
e.kuendigungsschutz_grund,
|
||||
e.kuendigungsschutz_ab ? `ab ${fmtDate(e.kuendigungsschutz_ab)}` : null,
|
||||
e.kuendigungsschutz_bis ? `bis ${fmtDate(e.kuendigungsschutz_bis)}` : null,
|
||||
].filter((t): t is string => Boolean(t));
|
||||
|
||||
if (teile.length === 0) return "Ja (Ende offen)";
|
||||
return e.kuendigungsschutz_bis ? teile.join(" · ") : `${teile.join(" · ")} · Ende offen`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Begünstigung als eigene Zeile, nicht an den Kündigungsschutz gehängt.
|
||||
*
|
||||
* Sie führt zwar oft zu besonderem Kündigungsschutz, ist aber ein eigener
|
||||
* Bescheid — und der Grad gehört zur Behinderung, nicht zum Schutz.
|
||||
*/
|
||||
function behinderungText(e: EmployeeRow): string {
|
||||
if (!e.ist_beguenstigt_behindert) return "Nein";
|
||||
const teile = [
|
||||
e.behinderung_grad !== null ? `${e.behinderung_grad} %` : null,
|
||||
e.behinderung_ab ? `Bescheid ab ${fmtDate(e.behinderung_ab)}` : null,
|
||||
e.behinderung_bis ? `bis ${fmtDate(e.behinderung_bis)}` : null,
|
||||
].filter((t): t is string => Boolean(t));
|
||||
|
||||
return teile.length > 0 ? teile.join(" · ") : "Ja";
|
||||
}
|
||||
|
||||
export function VertragTab({ employee }: { employee: EmployeeRow }) {
|
||||
// Früher stand hier eine einzige Zeile „Merkmale" mit allem, was zutraf,
|
||||
@@ -26,7 +57,7 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) {
|
||||
["Beschäftigungsausmaß", employee.employment_type],
|
||||
["Wochenstunden", `${employee.weekly_hours} h`],
|
||||
["Urlaubsanspruch", "25 Tage"],
|
||||
["Paygrade", PAYGRADE_LABELS[employee.paygrade] ?? employee.paygrade],
|
||||
["Hay-Grade", hayGradeLabel(employee.paygrade)],
|
||||
["Beschäftigtengruppe", employee.worker_type ?? "–"],
|
||||
["Arbeitstage", employee.work_days?.join(", ") || "–"],
|
||||
// Beim Dienstwagen steht die Antriebsart statt eines blossen „Ja" — das
|
||||
@@ -47,14 +78,17 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) {
|
||||
: `${employee.teilzeit_art} (Ende offen)`
|
||||
: "–",
|
||||
],
|
||||
[
|
||||
"Besonderer Kündigungsschutz",
|
||||
employee.has_kuendigungsschutz
|
||||
? employee.kuendigungsschutz_bis
|
||||
? `bis ${fmtDate(employee.kuendigungsschutz_bis)}`
|
||||
: "Ja (Ende offen)"
|
||||
: "Nein",
|
||||
],
|
||||
// Grund und Zeitraum stehen dabei, nicht nur das Enddatum. Gemeldet am
|
||||
// 29.09.: erfasst werden sieben Felder — Personenkreis, Beginn, Ende,
|
||||
// Behinderung mit Grad und Bescheidzeitraum —, sichtbar war in der Akte
|
||||
// nur „bis TT.MM.JJJJ". Wer den Grund brauchte, fand ihn nur in der
|
||||
// Historie oder über einen Bericht.
|
||||
["Besonderer Kündigungsschutz", kuendigungsschutzText(employee)],
|
||||
// Eigene Zeile und nicht an den Kündigungsschutz gehängt: die Begünstigung
|
||||
// ist ein Bescheid für sich. Sie führt zwar oft zu besonderem
|
||||
// Kündigungsschutz, ist aber nicht dasselbe, und der Grad gehört zur
|
||||
// Behinderung, nicht zum Schutz.
|
||||
["Begünstigt behindert", behinderungText(employee)],
|
||||
];
|
||||
if (employee.exit_date) rows.push(["Austrittsdatum", fmtDate(employee.exit_date)]);
|
||||
|
||||
|
||||
@@ -149,6 +149,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
sv_nummer: draft.svNummer || undefined,
|
||||
email: draft.email.trim() || undefined,
|
||||
company_email: draft.companyEmail.trim() || undefined,
|
||||
cornerstone_id: draft.cornerstoneId.trim() || undefined,
|
||||
phone: draft.phone || undefined,
|
||||
position_id: draft.positionId,
|
||||
location_id: draft.locationId,
|
||||
|
||||
@@ -125,6 +125,7 @@ export function RehireWizard({ open, onClose, employee, openPositions, locations
|
||||
sv_nummer: draft.svNummer.trim(),
|
||||
email: draft.email.trim(),
|
||||
company_email: draft.companyEmail.trim(),
|
||||
cornerstone_id: draft.cornerstoneId.trim(),
|
||||
phone: draft.phone.trim(),
|
||||
location_id: draft.locationId,
|
||||
// Anschrift, Staatsbürgerschaft und Aufenthaltstitel stehen bewusst
|
||||
|
||||
@@ -74,6 +74,12 @@ export function StepPerson({ draft, update, locations, onNummerBefund, nummerGes
|
||||
value={draft.companyEmail}
|
||||
onChange={(companyEmail) => update({ companyEmail })}
|
||||
/>
|
||||
<TextField
|
||||
label="Cornerstone-ID"
|
||||
value={draft.cornerstoneId}
|
||||
onChange={(cornerstoneId) => update({ cornerstoneId })}
|
||||
hint="Freiwillig. Füllt im Cornerstone-Export „User ID“ und „Username“."
|
||||
/>
|
||||
<TextField label="Private Telefonnummer" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
|
||||
</div>
|
||||
<SelectField
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { dienstwagenLabel } from "@/lib/dienstwagen";
|
||||
import { fmtDate, fmtFullName } from "@/lib/format";
|
||||
import { hayGradeLabel } from "@/lib/hay-grade";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { HireDraftData } from "./types";
|
||||
|
||||
const PAYGRADE_LABELS: Record<string, string> = {
|
||||
A: "A – Einstieg",
|
||||
B: "B – Qualifiziert",
|
||||
C: "C – Erfahren",
|
||||
D: "D – Spezialist:in",
|
||||
E: "E – Teamleitung",
|
||||
F: "F – Bereichsleitung / GF",
|
||||
};
|
||||
|
||||
type StepSummaryProps = {
|
||||
draft: HireDraftData;
|
||||
selectedPosition: OpenPositionResolved | null;
|
||||
@@ -49,7 +41,7 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
|
||||
["Eintrittsdatum", fmtDate(draft.entryDate)],
|
||||
["Vertragsart", draft.contractType === "befristet" ? `befristet bis ${fmtDate(draft.contractEndDate)}` : "unbefristet"],
|
||||
["Beschäftigungsausmaß", `${draft.employmentType} (${draft.weeklyHours} h)`],
|
||||
["Paygrade", PAYGRADE_LABELS[draft.paygrade]],
|
||||
["Hay-Grade", hayGradeLabel(draft.paygrade)],
|
||||
["Beschäftigtengruppe", draft.workerType],
|
||||
["Kollektivvertrag", draft.collectiveAgreement],
|
||||
["Arbeitstage", draft.workDays.join(", ") || "–"],
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { RoleEmploymentFields } from "@/components/employees/RoleEmploymentFields";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { HAY_GRADES } from "@/lib/hay-grade";
|
||||
import type { PaygradeType } from "@/lib/types";
|
||||
import type { HireDraftData } from "./types";
|
||||
|
||||
const PAYGRADES: { value: PaygradeType; label: string; description: string }[] = [
|
||||
{ value: "A", label: "A – Einstieg", description: "Berufseinsteiger:innen ohne einschlägige Erfahrung" },
|
||||
{ value: "B", label: "B – Qualifiziert", description: "Fachkräfte mit abgeschlossener Ausbildung" },
|
||||
{ value: "C", label: "C – Erfahren", description: "Mehrjährige einschlägige Berufserfahrung" },
|
||||
{ value: "D", label: "D – Spezialist:in", description: "Vertiefte Fachexpertise" },
|
||||
{ value: "E", label: "E – Teamleitung", description: "Fachliche und disziplinäre Führung eines Teams" },
|
||||
{ value: "F", label: "F – Bereichsleitung / GF", description: "Führung eines Bereichs bzw. Geschäftsführung" },
|
||||
];
|
||||
|
||||
export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (patch: Partial<HireDraftData>) => void }) {
|
||||
function handleEmploymentTypeChange(value: HireDraftData["employmentType"]) {
|
||||
update({
|
||||
@@ -63,12 +55,11 @@ export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (
|
||||
/>
|
||||
</div>
|
||||
<SelectField
|
||||
label="Paygrade"
|
||||
label="Hay-Grade"
|
||||
required
|
||||
value={draft.paygrade}
|
||||
onChange={(v) => update({ paygrade: v as PaygradeType })}
|
||||
options={PAYGRADES}
|
||||
hint={PAYGRADES.find((p) => p.value === draft.paygrade)?.description}
|
||||
options={HAY_GRADES}
|
||||
/>
|
||||
<p className="text-xs text-ink-muted">Es gilt eine Probezeit von 1 Monat gemäß Kollektivvertrag.</p>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { HAY_GRADE_STANDARD } from "@/lib/hay-grade";
|
||||
import { MITARBEITERART_STANDARD } from "@/lib/mitarbeiterart";
|
||||
import type { CollectiveAgreement, ContractType, DienstwagenArt, EmploymentType, GenderType, Mitarbeiterart, PaygradeType, RelationshipType, Weekday, WorkerType } from "@/lib/types";
|
||||
|
||||
@@ -35,6 +36,8 @@ export type HireDraftData = {
|
||||
svNummer: string;
|
||||
email: string;
|
||||
companyEmail: string;
|
||||
/** Kennung im Lernsystem Cornerstone. Freiwillig. */
|
||||
cornerstoneId: string;
|
||||
phone: string;
|
||||
locationId: string;
|
||||
positionId: string;
|
||||
@@ -84,6 +87,7 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
||||
svNummer: "",
|
||||
email: "",
|
||||
companyEmail: "",
|
||||
cornerstoneId: "",
|
||||
phone: "",
|
||||
locationId: "",
|
||||
positionId: "",
|
||||
@@ -93,7 +97,7 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
||||
contractEndDate: "",
|
||||
employmentType: "Vollzeit",
|
||||
weeklyHours: "38.5",
|
||||
paygrade: "B",
|
||||
paygrade: HAY_GRADE_STANDARD,
|
||||
workerType: "Angestellte:r",
|
||||
collectiveAgreement: "Handel",
|
||||
workDays: ["Mo", "Di", "Mi", "Do", "Fr"],
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { FILTER_SELECT_CLASS } from "@/components/ui/Field";
|
||||
import { FILTER_SELECT_CLASS, istMeldbaresDatum } from "@/components/ui/Field";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
|
||||
type AsOfPickerProps = {
|
||||
@@ -27,6 +28,14 @@ export function AsOfPicker({ asOf, today, projectedCount, historyStartsAt }: AsO
|
||||
router.push(sp.size > 0 ? `${pathname}?${sp}` : pathname, { scroll: false });
|
||||
}
|
||||
|
||||
// Angeglichen während des Renderns, nicht in einem Effekt — siehe DateField.
|
||||
const [entwurf, setEntwurf] = useState(asOf);
|
||||
const [zuletzt, setZuletzt] = useState(asOf);
|
||||
if (asOf !== zuletzt) {
|
||||
setZuletzt(asOf);
|
||||
setEntwurf(asOf);
|
||||
}
|
||||
|
||||
const isToday = asOf === today;
|
||||
const isFuture = asOf > today;
|
||||
// Assignments only started being recorded when the history table was
|
||||
@@ -41,11 +50,23 @@ export function AsOfPicker({ asOf, today, projectedCount, historyStartsAt }: AsO
|
||||
<CalendarClock className="h-4 w-4 text-ink-muted" />
|
||||
Stichtag
|
||||
</label>
|
||||
{/* Der Tippstand bleibt hier, gemeldet wird nur ein vollständiges
|
||||
Datum: jede Änderung ist eine Navigation, und beim Tippen der
|
||||
Jahreszahl entstehen unterwegs 0002, 0020 und 0202 — jede davon
|
||||
lud die Seite neu und setzte das Feld mitten im Tippen zurück.
|
||||
Dieselbe Regel wie in DateField, dort steht auch der lange Grund. */}
|
||||
<input
|
||||
id="orgchart-asof"
|
||||
type="date"
|
||||
value={asOf}
|
||||
onChange={(e) => setAsOf(e.target.value || undefined)}
|
||||
value={entwurf}
|
||||
onChange={(e) => {
|
||||
const wert = e.target.value;
|
||||
setEntwurf(wert);
|
||||
if (istMeldbaresDatum(wert) && wert !== asOf) setAsOf(wert || undefined);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (entwurf !== asOf && istMeldbaresDatum(entwurf)) setAsOf(entwurf || undefined);
|
||||
}}
|
||||
className={FILTER_SELECT_CLASS}
|
||||
/>
|
||||
{!isToday && (
|
||||
|
||||
126
components/orgchart/CreateOrgUnitModal.tsx
Normal file
126
components/orgchart/CreateOrgUnitModal.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { createOrgUnit } from "@/actions/org";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { naechsteOrgnummer } from "@/lib/org-nummer";
|
||||
import type { OrgUnitNode } from "./types";
|
||||
|
||||
type Art = "Bereich" | "Abteilung" | "Team";
|
||||
|
||||
// „Gesellschaft“ fehlt in der Auswahl mit Absicht: es gibt genau eine, sie ist
|
||||
// die Wurzel, und unter einer anderen Einheit wäre sie ein Etikett, das seiner
|
||||
// Stelle im Baum widerspricht. Die Datenbankfunktion weist es ohnehin ab — die
|
||||
// fehlende Zeile erspart den Weg dorthin.
|
||||
const ARTEN: { value: Art; label: string }[] = [
|
||||
{ value: "Bereich", label: "Bereich" },
|
||||
{ value: "Abteilung", label: "Abteilung" },
|
||||
{ value: "Team", label: "Team" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Die Art, die unter einer Einheit am wahrscheinlichsten ist: eine Stufe
|
||||
* feiner als die darüber. Nur eine Vorbelegung — die Tiefe des Baums ist frei,
|
||||
* und eine Abteilung unter einer Abteilung ist erlaubt.
|
||||
*/
|
||||
function vorschlagArt(eltern: OrgUnitNode["unit_type"]): Art {
|
||||
if (eltern === "Gesellschaft") return "Bereich";
|
||||
if (eltern === "Bereich") return "Abteilung";
|
||||
return "Team";
|
||||
}
|
||||
|
||||
export function CreateOrgUnitModal({
|
||||
parent,
|
||||
units,
|
||||
onClose,
|
||||
}: {
|
||||
parent: OrgUnitNode;
|
||||
units: OrgUnitNode[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [orgNumber, setOrgNumber] = useState(() => naechsteOrgnummer(units.map((u) => u.org_number)) ?? "");
|
||||
const [name, setName] = useState("");
|
||||
const [art, setArt] = useState<Art>(() => vorschlagArt(parent.unit_type));
|
||||
// **Nicht** der Stichtag der Ansicht. Wer sich das Organigramm zum letzten
|
||||
// Jahresende ansieht und hier auf Plus drückt, will in aller Regel eine
|
||||
// Einheit von heute anlegen und keine rückdatierte. Ein stillschweigend
|
||||
// übernommenes Datum aus der Ansicht wäre die Art von Vorbelegung, die
|
||||
// niemand liest und die hinterher niemand erklären kann.
|
||||
const [validFrom, setValidFrom] = useState(todayIso);
|
||||
const [leitung, setLeitung] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!orgNumber.trim() || !name.trim() || !validFrom) {
|
||||
showToast("Orgnummer, Bezeichnung und Gültigkeitsbeginn sind Pflicht.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await createOrgUnit({
|
||||
parent_id: parent.id,
|
||||
org_number: orgNumber.trim(),
|
||||
name: name.trim(),
|
||||
unit_type: art,
|
||||
valid_from: validFrom,
|
||||
leitung_taetigkeit: leitung.trim() || undefined,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
showToast(leitung.trim() ? "Einheit samt Leitungsplanstelle angelegt." : "Einheit angelegt.");
|
||||
router.refresh();
|
||||
onClose();
|
||||
} else {
|
||||
showToast(result.error ?? "Fehler beim Anlegen.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title="Einheit anlegen"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} pending={pending}>
|
||||
Anlegen
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="rounded-md bg-surface px-3 py-2 text-xs leading-relaxed text-ink-muted">
|
||||
Unter <span className="font-semibold text-ink-body">{parent.org_number} · {parent.name}</span>.
|
||||
Bestehende Einheiten lassen sich nicht hierher verschieben — die neue entsteht leer.
|
||||
</p>
|
||||
|
||||
<TextField
|
||||
label="Orgnummer"
|
||||
required
|
||||
value={orgNumber}
|
||||
onChange={setOrgNumber}
|
||||
hint="Vorgeschlagen wird die nächste freie Nummer der bestehenden Reihe. Sie muss zum führenden System passen."
|
||||
/>
|
||||
<TextField label="Bezeichnung" required value={name} onChange={setName} />
|
||||
<SelectField label="Art" required value={art} onChange={(v) => setArt(v as Art)} options={ARTEN} />
|
||||
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
|
||||
|
||||
<TextField
|
||||
label="Leitungsplanstelle (optional)"
|
||||
value={leitung}
|
||||
onChange={setLeitung}
|
||||
hint="Tätigkeit der Leitung, z. B. „Abteilungsleitung Einkauf“. Leer lassen, dann entsteht die Einheit ohne Leitung und ist im Organigramm als vakant zu sehen."
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,10 @@ export type GraphOrgChartProps = {
|
||||
isExpanded: (id: string) => boolean;
|
||||
onToggle: (id: string) => void;
|
||||
matchedIds?: Set<string> | null;
|
||||
/** Nur die Struktursicht reicht sie herein; in der Mitarbeitersicht gibt es keine Einheiten. */
|
||||
onAddUnit?: (unitId: string) => void;
|
||||
onDeleteUnit?: (unitId: string) => void;
|
||||
onSelectUnit?: (unitId: string) => void;
|
||||
};
|
||||
|
||||
export function GraphOrgChart(props: GraphOrgChartProps) {
|
||||
@@ -49,7 +53,15 @@ export function GraphOrgChart(props: GraphOrgChartProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrgChartProps) {
|
||||
function GraphOrgChartInner({
|
||||
tree,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
matchedIds,
|
||||
onAddUnit,
|
||||
onDeleteUnit,
|
||||
onSelectUnit,
|
||||
}: GraphOrgChartProps) {
|
||||
const { visibleNodes, visibleEdges } = useMemo(() => collectVisible(tree, isExpanded), [tree, isExpanded]);
|
||||
|
||||
const { rfNodes, rfEdges } = useMemo(() => {
|
||||
@@ -65,6 +77,9 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
|
||||
hasChildren: n.children.length > 0,
|
||||
childCount: n.children.length,
|
||||
onToggle,
|
||||
onAddUnit,
|
||||
onDeleteUnit,
|
||||
onSelectUnit,
|
||||
},
|
||||
}));
|
||||
const rfEdges: Edge[] = visibleEdges.map((e) => ({
|
||||
@@ -76,7 +91,7 @@ function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrg
|
||||
style: { stroke: "#e3cddb", strokeWidth: 1.5 },
|
||||
}));
|
||||
return { rfNodes, rfEdges };
|
||||
}, [visibleNodes, visibleEdges, isExpanded, onToggle]);
|
||||
}, [visibleNodes, visibleEdges, isExpanded, onToggle, onAddUnit, onDeleteUnit, onSelectUnit]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(rfNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(rfEdges);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
|
||||
import { Building2, ChevronDown, Plus, UserRound } from "lucide-react";
|
||||
import { Building2, ChevronDown, Plus, Trash2, UserRound } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { memo } from "react";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
@@ -14,6 +14,11 @@ export type OrgChartNodeData = {
|
||||
hasChildren: boolean;
|
||||
childCount: number;
|
||||
onToggle: (id: string) => void;
|
||||
/** Nur in der Struktursicht gesetzt — ohne sie bleibt die Karte unverändert. */
|
||||
onAddUnit?: (unitId: string) => void;
|
||||
onDeleteUnit?: (unitId: string) => void;
|
||||
/** Klick auf die Karte einer Einheit — öffnet ihre Angaben. */
|
||||
onSelectUnit?: (unitId: string) => void;
|
||||
};
|
||||
|
||||
export type OrgChartRFNode = Node<OrgChartNodeData, "orgNode">;
|
||||
@@ -42,8 +47,8 @@ const KIND_SHELL: Record<ChartNodeKind, string> = {
|
||||
// React Flow re-renders node components on every pan/zoom frame — memo is
|
||||
// required, not just tidy, to keep that smooth at a few hundred nodes.
|
||||
export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<OrgChartRFNode>) {
|
||||
const { chartNode, expanded, hasChildren, childCount, onToggle } = data;
|
||||
const { kind, label, sublabel, avatar, href, vacant, totalReports, absent, coveredBy, coveringFor } = chartNode;
|
||||
const { chartNode, expanded, hasChildren, childCount, onToggle, onAddUnit, onDeleteUnit, onSelectUnit } = data;
|
||||
const { kind, label, sublabel, avatar, href, vacant, totalReports, absent, coveredBy, coveringFor, unitId } = chartNode;
|
||||
const isMatch = chartNode.matched ?? false;
|
||||
const { width, height } = NODE_DIMENSIONS[kind];
|
||||
|
||||
@@ -107,6 +112,50 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<O
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} className="!invisible" />
|
||||
|
||||
{/* Erscheint erst beim Überfahren der Karte: auf achtzig Einheiten wären
|
||||
achtzig ständig sichtbare Pluszeichen ein Muster und kein Angebot.
|
||||
Auf Geräten ohne Zeigegerät bleibt er stehen — dort gibt es kein
|
||||
Überfahren, und unsichtbar hiesse dann unerreichbar. */}
|
||||
{unitId && (onAddUnit || onDeleteUnit) && (
|
||||
<span
|
||||
className="nodrag nopan absolute -right-2.5 -top-2.5 z-10 flex items-center gap-1
|
||||
opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100
|
||||
[@media(hover:none)]:opacity-100"
|
||||
>
|
||||
{/* Der Papierkorb nur am Blatt. Eine Einheit mit Kindern lässt sich
|
||||
ohnehin nicht entfernen — der Knopf wäre dort ein Angebot, das
|
||||
beim Klick eine Absage erteilt, und das ist schlechter als kein
|
||||
Knopf. Die Datenbank prüft es trotzdem: sie sieht auch
|
||||
geschlossene Planstellen, die hier gar nicht gezeichnet sind. */}
|
||||
{onDeleteUnit && !hasChildren && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteUnit(unitId)}
|
||||
aria-label={`Einheit ${label} entfernen`}
|
||||
title="Einheit entfernen"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-border bg-white text-ink-muted shadow-sm
|
||||
transition-colors hover:border-danger-solid hover:bg-danger-solid hover:text-white
|
||||
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{onAddUnit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddUnit(unitId)}
|
||||
aria-label={`Einheit unter ${label} anlegen`}
|
||||
title="Untergeordnete Einheit anlegen"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-border bg-white text-ink-muted shadow-sm
|
||||
transition-colors hover:border-brand-500 hover:bg-brand-500 hover:text-white
|
||||
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Accent stripe, inset so it follows the card's rounded corner. */}
|
||||
<span className={`absolute inset-y-1.5 left-0 w-1 rounded-r ${KIND_ACCENT[kind]}`} />
|
||||
|
||||
@@ -114,6 +163,18 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<O
|
||||
<Link href={href} className="nodrag nopan flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3">
|
||||
{content}
|
||||
</Link>
|
||||
) : unitId && onSelectUnit ? (
|
||||
// Die ganze Karte, nicht ein Symbol darin: bei achtzig Einheiten ist
|
||||
// die Fläche das Ziel, das man trifft, ohne hinzusehen.
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectUnit(unitId)}
|
||||
aria-label={`Angaben zu ${label}`}
|
||||
className="nodrag nopan flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3 text-left
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3">{content}</div>
|
||||
)}
|
||||
|
||||
241
components/orgchart/OrgUnitPanel.tsx
Normal file
241
components/orgchart/OrgUnitPanel.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ladePlanstellenDerEinheit, type PlanstelleDerEinheit } from "@/actions/positions";
|
||||
import { EditPositionModal } from "@/components/positions/EditPositionModal";
|
||||
import type { UnitOption } from "@/components/positions/CreatePositionModal";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { fmtDate, fmtName } from "@/lib/format";
|
||||
import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
|
||||
|
||||
// Was an einer Organisationseinheit steht — beim Klick auf ihre Karte.
|
||||
//
|
||||
// Alles hier kommt aus den Daten, die die Struktursicht ohnehin geladen hat:
|
||||
// Einheiten, Personen und Vakanzen zum eingestellten Stichtag. Kein zusätzlicher
|
||||
// Gang zur Datenbank, und damit auch keine zweite Wahrheit — was die Karte
|
||||
// zeigt, ist dasselbe, woraus der Baum daneben gezeichnet ist.
|
||||
|
||||
function Zeile({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-4 border-b border-border-subtle py-1.5 last:border-0">
|
||||
<span className="shrink-0 text-xs text-ink-muted">{label}</span>
|
||||
<span className="text-right text-sm text-ink-body">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrgUnitPanel({
|
||||
unit,
|
||||
units,
|
||||
unitOptions,
|
||||
employees,
|
||||
vacancies,
|
||||
onAddUnit,
|
||||
onDeleteUnit,
|
||||
onClose,
|
||||
}: {
|
||||
unit: OrgUnitNode;
|
||||
units: OrgUnitNode[];
|
||||
/** Für den Änderungsdialog: derselbe Baum, nur in seiner Form. */
|
||||
unitOptions: UnitOption[];
|
||||
employees: OrgEmployee[];
|
||||
vacancies: OrgVacancy[];
|
||||
onAddUnit: (unitId: string) => void;
|
||||
onDeleteUnit: (unitId: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// Erst beim Öffnen geladen, und nur für diese eine Einheit — siehe den Grund
|
||||
// bei ladePlanstellenDerEinheit.
|
||||
const [stellen, setStellen] = useState<PlanstelleDerEinheit[] | null>(null);
|
||||
const [kostenstellen, setKostenstellen] = useState<{ id: string; code: string; name: string }[]>([]);
|
||||
const [heute, setHeute] = useState("");
|
||||
const [bearbeitet, setBearbeitet] = useState<PlanstelleDerEinheit | null>(null);
|
||||
|
||||
const laden = useCallback(async () => {
|
||||
const daten = await ladePlanstellenDerEinheit(unit.id);
|
||||
setStellen(daten.planstellen);
|
||||
setKostenstellen(daten.kostenstellen);
|
||||
setHeute(daten.heute);
|
||||
}, [unit.id]);
|
||||
|
||||
useEffect(() => {
|
||||
let verworfen = false;
|
||||
// Der Nachtrag einer Antwort, die zu einer anderen Einheit gehört, wäre
|
||||
// hier besonders tückisch: die Leiste sähe richtig aus und zeigte fremde
|
||||
// Planstellen.
|
||||
void ladePlanstellenDerEinheit(unit.id).then((daten) => {
|
||||
if (verworfen) return;
|
||||
setStellen(daten.planstellen);
|
||||
setKostenstellen(daten.kostenstellen);
|
||||
setHeute(daten.heute);
|
||||
});
|
||||
return () => {
|
||||
verworfen = true;
|
||||
};
|
||||
}, [unit.id]);
|
||||
|
||||
const kinder = units.filter((u) => u.parent_id === unit.id);
|
||||
|
||||
// Die Kette nach oben, mit Schutz gegen einen Kreis in parent_id. Der ist
|
||||
// heute nicht herstellbar, aber diese Schleife wäre die erste Stelle, die
|
||||
// ihn ausbaden müsste — und ein hängender Browser sagt niemandem, woran es
|
||||
// liegt.
|
||||
const kette: OrgUnitNode[] = [];
|
||||
const gesehen = new Set<string>();
|
||||
let lauf = unit.parent_id ? units.find((u) => u.id === unit.parent_id) : undefined;
|
||||
while (lauf && !gesehen.has(lauf.id)) {
|
||||
gesehen.add(lauf.id);
|
||||
kette.unshift(lauf);
|
||||
lauf = lauf.parent_id ? units.find((u) => u.id === lauf!.parent_id) : undefined;
|
||||
}
|
||||
|
||||
const eigene = employees.filter((e) => e.org_unit_id === unit.id);
|
||||
const leitung = eigene.find((e) => e.is_chief);
|
||||
const belegschaft = eigene.filter((e) => !e.is_chief);
|
||||
const offene = vacancies.filter((v) => v.org_unit_id === unit.id);
|
||||
|
||||
// Alles darunter, nicht nur die direkten Kinder — die Frage hinter „wie
|
||||
// gross ist dieser Bereich" meint immer den ganzen Teilbaum.
|
||||
const teilbaum = new Set<string>([unit.id]);
|
||||
for (let gewachsen = true; gewachsen; ) {
|
||||
gewachsen = false;
|
||||
for (const u of units) {
|
||||
if (u.parent_id && teilbaum.has(u.parent_id) && !teilbaum.has(u.id)) {
|
||||
teilbaum.add(u.id);
|
||||
gewachsen = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const imTeilbaum = employees.filter((e) => teilbaum.has(e.org_unit_id)).length;
|
||||
|
||||
// Dieselbe Bedingung wie am Papierkorb auf der Karte: nur ein Blatt ohne
|
||||
// Planstellen lässt sich entfernen. Die Datenbank prüft strenger — sie sieht
|
||||
// auch geschlossene Planstellen, die hier nicht gezeichnet sind.
|
||||
const entfernbar = kinder.length === 0 && eigene.length === 0 && offene.length === 0 && unit.parent_id !== null;
|
||||
|
||||
return (
|
||||
<SlideOver open onClose={onClose} title={unit.name} subtitle={`${unit.org_number} · ${unit.unit_type}`}>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<Zeile label="Orgnummer">{unit.org_number}</Zeile>
|
||||
<Zeile label="Art">{unit.unit_type}</Zeile>
|
||||
<Zeile label="Übergeordnet">
|
||||
{kette.length > 0 ? kette.map((u) => u.name).join(" › ") : "—"}
|
||||
</Zeile>
|
||||
<Zeile label="Gültig ab">{unit.valid_from ? fmtDate(unit.valid_from) : "—"}</Zeile>
|
||||
<Zeile label="Gültig bis">{unit.valid_to ? fmtDate(unit.valid_to) : "unbefristet"}</Zeile>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Besetzung</h3>
|
||||
<Zeile label="Leitung">
|
||||
{leitung ? (
|
||||
<Link href={`/employees/${leitung.id}`} className="text-brand-700 hover:underline">
|
||||
{fmtName(leitung.first_name, leitung.last_name)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-semibold text-warning-text">vakant</span>
|
||||
)}
|
||||
</Zeile>
|
||||
<Zeile label="Mitarbeitende">{belegschaft.length}</Zeile>
|
||||
<Zeile label="Unbesetzte Planstellen">{offene.length}</Zeile>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Struktur</h3>
|
||||
<Zeile label="Untergeordnete Einheiten">{kinder.length}</Zeile>
|
||||
<Zeile label="Personen im gesamten Teilbaum">{imTeilbaum}</Zeile>
|
||||
</div>
|
||||
|
||||
{kinder.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-ink-muted">Direkt darunter</h3>
|
||||
<ul className="flex flex-col gap-1 text-sm text-ink-body">
|
||||
{kinder.map((k) => (
|
||||
<li key={k.id}>
|
||||
<span className="tabular-nums text-ink-muted">{k.org_number}</span> · {k.name}{" "}
|
||||
<span className="text-xs text-ink-muted">({k.unit_type})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
||||
Planstellen{stellen ? ` (${stellen.length})` : ""}
|
||||
</h3>
|
||||
{stellen === null ? (
|
||||
<p className="text-sm text-ink-muted">Lädt…</p>
|
||||
) : stellen.length === 0 ? (
|
||||
<p className="text-sm text-ink-muted">In dieser Einheit besteht keine Planstelle.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border-subtle">
|
||||
{stellen.map((s) => (
|
||||
<li key={s.id}>
|
||||
{/* Auch die besetzten sind anklickbar. Was sich daran ändern
|
||||
lässt, entscheidet die Datenbankfunktion: Einheit und
|
||||
Gültigkeitsende bleiben bei einer besetzten Stelle
|
||||
gesperrt, Tätigkeit und Kontierung nicht. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBearbeitet(s)}
|
||||
className="flex w-full flex-col items-start rounded py-1.5 text-left hover:bg-surface
|
||||
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||
>
|
||||
<span className="text-sm text-ink-body">
|
||||
<span className="tabular-nums text-ink-muted">{s.position_number}</span> · {s.title}
|
||||
{s.is_chief && <span className="text-xs text-ink-muted"> (Leitung)</span>}
|
||||
</span>
|
||||
<span className="text-xs text-ink-muted">
|
||||
{s.besetztVon ?? <span className="font-semibold text-warning-text">unbesetzt</span>}
|
||||
{s.kostenstelle ? ` · Kostenstelle ${s.kostenstelle.code}` : " · ohne Kostenstelle"}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Link href="/positions" className="mt-1.5 inline-block text-xs font-semibold text-brand-700 hover:underline">
|
||||
Alle unbesetzten Planstellen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 border-t border-border pt-4">
|
||||
<Button variant="secondary" size="sm" onClick={() => onAddUnit(unit.id)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Einheit darunter anlegen
|
||||
</Button>
|
||||
{entfernbar && (
|
||||
<Button variant="secondary" size="sm" onClick={() => onDeleteUnit(unit.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Einheit entfernen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Derselbe Dialog wie auf der Seite Positionen. Ein zweiter, der
|
||||
dasselbe tut, liefe beim ersten Zusatz auseinander. */}
|
||||
{bearbeitet && (
|
||||
<EditPositionModal
|
||||
key={bearbeitet.id}
|
||||
position={bearbeitet}
|
||||
units={unitOptions}
|
||||
kostenstellen={kostenstellen}
|
||||
heute={heute}
|
||||
onClose={() => {
|
||||
setBearbeitet(null);
|
||||
// Die Liste stammt aus dem Zustand dieser Leiste; ohne erneutes
|
||||
// Laden stünde nach dem Umkontieren weiter die alte Kostenstelle
|
||||
// da, und der Dialog daneben zeigte die neue.
|
||||
void laden();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SlideOver>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Plus, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { deleteOrgUnit } from "@/actions/org";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { CreateOrgUnitModal } from "./CreateOrgUnitModal";
|
||||
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
|
||||
import { OrgUnitPanel } from "./OrgUnitPanel";
|
||||
import type { ChartNode, OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
|
||||
import { fmtName } from "@/lib/format";
|
||||
|
||||
@@ -27,6 +32,8 @@ type PositionTreeProps = {
|
||||
export function PositionTree({ employees, units, vacancies }: PositionTreeProps) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(units.filter((u) => u.parent_id === null).map((u) => `unit-${u.id}`)));
|
||||
const [mode, setMode] = useState<ViewMode>("list");
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
// useCallback-stabil: das Layout-Memo von GraphOrgChart hängt an diesen
|
||||
// Referenzen, instabile Funktionen erzwängen sonst bei jedem Re-Render ein
|
||||
@@ -41,8 +48,70 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps)
|
||||
}, []);
|
||||
const isExpanded = useCallback((id: string) => expanded.has(id), [expanded]);
|
||||
|
||||
// Die Einheit, unter der gerade eine neue entstehen soll. Als Kennung und
|
||||
// nicht als Objekt: nach dem Anlegen lädt die Seite neu, und ein
|
||||
// festgehaltenes Objekt wäre dann der Stand von vorhin.
|
||||
const [anlegenUnter, setAnlegenUnter] = useState<string | null>(null);
|
||||
const onAddUnit = useCallback((unitId: string) => setAnlegenUnter(unitId), []);
|
||||
const eltern = anlegenUnter ? (units.find((u) => u.id === anlegenUnter) ?? null) : null;
|
||||
|
||||
// Die Einheit, deren Angaben offen sind. Der Klick auf die Karte war bisher
|
||||
// ohne Wirkung — bei achtzig Einheiten die naheliegendste Geste ohne Antwort.
|
||||
const [gewaehlt, setGewaehlt] = useState<string | null>(null);
|
||||
const onSelectUnit = useCallback((unitId: string) => setGewaehlt(unitId), []);
|
||||
const einheit = gewaehlt ? (units.find((u) => u.id === gewaehlt) ?? null) : null;
|
||||
|
||||
// Ohne Rückfrage, wie beim Entfernen einer Planstelle: entfernbar ist nur,
|
||||
// was nie eine Planstelle getragen hat und nichts unter sich hat. Alles
|
||||
// andere weist die Datenbank mit dem Grund ab, und der steht dann im Toast.
|
||||
const onDeleteUnit = useCallback(
|
||||
async (unitId: string) => {
|
||||
const betroffen = units.find((u) => u.id === unitId);
|
||||
const result = await deleteOrgUnit(unitId);
|
||||
if (result.success) {
|
||||
showToast(`${betroffen ? `${betroffen.org_number} · ${betroffen.name}` : "Einheit"} entfernt.`);
|
||||
// Die Angaben zu einer Einheit, die es nicht mehr gibt, stünden sonst
|
||||
// offen da.
|
||||
setGewaehlt(null);
|
||||
router.refresh();
|
||||
} else {
|
||||
showToast(result.error ?? "Fehler beim Entfernen.", "error");
|
||||
}
|
||||
},
|
||||
[units, showToast, router]
|
||||
);
|
||||
|
||||
const tree = useMemo(() => buildUnitTree(units, employees, vacancies), [units, employees, vacancies]);
|
||||
|
||||
// Der Baum in der Form, die der Änderungsdialog einer Planstelle erwartet.
|
||||
// Aus denselben Daten gerechnet statt zusätzlich geladen: `hasChief` heisst
|
||||
// dort „diese Einheit hat schon eine Leitungsplanstelle", und das ist genau
|
||||
// die Leitung im Baum — besetzt oder als Vakanz.
|
||||
const unitOptions = useMemo(() => {
|
||||
const tiefe = new Map<string, number>();
|
||||
const berechne = (u: OrgUnitNode, gesehen: Set<string>): number => {
|
||||
const bekannt = tiefe.get(u.id);
|
||||
if (bekannt !== undefined) return bekannt;
|
||||
if (!u.parent_id || gesehen.has(u.id)) return 0;
|
||||
gesehen.add(u.id);
|
||||
const eltern = units.find((e) => e.id === u.parent_id);
|
||||
const d = eltern ? berechne(eltern, gesehen) + 1 : 0;
|
||||
tiefe.set(u.id, d);
|
||||
return d;
|
||||
};
|
||||
const mitLeitung = new Set([
|
||||
...employees.filter((e) => e.is_chief).map((e) => e.org_unit_id),
|
||||
...vacancies.filter((v) => v.is_chief).map((v) => v.org_unit_id),
|
||||
]);
|
||||
return units.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
unit_type: u.unit_type,
|
||||
depth: berechne(u, new Set()),
|
||||
hasChief: mitLeitung.has(u.id),
|
||||
}));
|
||||
}, [units, employees, vacancies]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
@@ -56,14 +125,45 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps)
|
||||
/>
|
||||
</div>
|
||||
{mode === "graph" ? (
|
||||
<LazyGraphOrgChart tree={tree} isExpanded={isExpanded} onToggle={toggle} />
|
||||
<LazyGraphOrgChart
|
||||
tree={tree}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={toggle}
|
||||
onAddUnit={onAddUnit}
|
||||
onDeleteUnit={onDeleteUnit}
|
||||
onSelectUnit={onSelectUnit}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
{tree.map((node) => (
|
||||
<ListNode key={node.id} node={node} depth={0} expanded={expanded} onToggle={toggle} />
|
||||
<ListNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
expanded={expanded}
|
||||
onToggle={toggle}
|
||||
onAddUnit={onAddUnit}
|
||||
onDeleteUnit={onDeleteUnit}
|
||||
onSelectUnit={onSelectUnit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eltern && <CreateOrgUnitModal parent={eltern} units={units} onClose={() => setAnlegenUnter(null)} />}
|
||||
|
||||
{einheit && (
|
||||
<OrgUnitPanel
|
||||
unit={einheit}
|
||||
units={units}
|
||||
unitOptions={unitOptions}
|
||||
employees={employees}
|
||||
vacancies={vacancies}
|
||||
onAddUnit={onAddUnit}
|
||||
onDeleteUnit={onDeleteUnit}
|
||||
onClose={() => setGewaehlt(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,11 +173,17 @@ function ListNode({
|
||||
depth,
|
||||
expanded,
|
||||
onToggle,
|
||||
onAddUnit,
|
||||
onDeleteUnit,
|
||||
onSelectUnit,
|
||||
}: {
|
||||
node: ChartNode;
|
||||
depth: number;
|
||||
expanded: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
onAddUnit: (unitId: string) => void;
|
||||
onDeleteUnit: (unitId: string) => void;
|
||||
onSelectUnit: (unitId: string) => void;
|
||||
}) {
|
||||
const expandable = node.children.length > 0;
|
||||
const open = expanded.has(node.id);
|
||||
@@ -85,7 +191,7 @@ function ListNode({
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface"
|
||||
className="group/zeile flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface"
|
||||
style={{ paddingLeft: depth * 24 + 8 }}
|
||||
>
|
||||
{expandable ? (
|
||||
@@ -108,14 +214,65 @@ function ListNode({
|
||||
>
|
||||
{node.label}
|
||||
</Link>
|
||||
) : node.unitId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectUnit(node.unitId!)}
|
||||
aria-label={`Angaben zu ${node.label}`}
|
||||
className="rounded text-left text-sm font-semibold text-ink hover:text-brand-700 hover:underline
|
||||
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||
>
|
||||
{node.label}
|
||||
</button>
|
||||
) : (
|
||||
<span className={node.kind === "group" ? "text-sm text-ink" : "text-sm font-semibold text-ink"}>{node.label}</span>
|
||||
)}
|
||||
{node.sublabel && (
|
||||
<span className={node.vacant ? "text-xs font-semibold text-warning-text" : "text-xs text-ink-muted"}>{node.sublabel}</span>
|
||||
)}
|
||||
{/* Derselbe Knopf wie auf der Karte in der Grafik — beide Ansichten
|
||||
kommen aus einem Baum, und eine Handlung, die es nur in einer von
|
||||
beiden gibt, findet man in der anderen nie. */}
|
||||
{node.unitId && !expandable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteUnit(node.unitId!)}
|
||||
aria-label={`Einheit ${node.label} entfernen`}
|
||||
title="Einheit entfernen"
|
||||
className="ml-1 shrink-0 rounded p-0.5 text-ink-muted opacity-0 transition-opacity hover:text-danger-solid
|
||||
focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500
|
||||
group-hover/zeile:opacity-100 [@media(hover:none)]:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{node.unitId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddUnit(node.unitId!)}
|
||||
aria-label={`Einheit unter ${node.label} anlegen`}
|
||||
title="Untergeordnete Einheit anlegen"
|
||||
className="ml-1 shrink-0 rounded p-0.5 text-ink-muted opacity-0 transition-opacity hover:text-brand-700
|
||||
focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500
|
||||
group-hover/zeile:opacity-100 [@media(hover:none)]:opacity-100"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{open && node.children.map((child) => <ListNode key={child.id} node={child} depth={depth + 1} expanded={expanded} onToggle={onToggle} />)}
|
||||
{open &&
|
||||
node.children.map((child) => (
|
||||
<ListNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
onAddUnit={onAddUnit}
|
||||
onDeleteUnit={onDeleteUnit}
|
||||
onSelectUnit={onSelectUnit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -203,6 +360,7 @@ export function buildUnitTree(units: OrgUnitNode[], employees: OrgEmployee[], va
|
||||
return {
|
||||
id: key,
|
||||
kind: "role",
|
||||
unitId: unit.id,
|
||||
label: `${unit.org_number} · ${unit.name}`,
|
||||
// Die Gesellschaft bleibt ohne Vermerk: sie wird nicht geführt, sondern
|
||||
// ist das Ganze. „Leitung vakant" las sich dort wie eine offene Stelle,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { ChevronRight, Printer } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { PrintModel, PrintPerson, PrintUnit } from "@/lib/orgchart-print";
|
||||
@@ -40,6 +40,16 @@ const TIEFEN: { wert: Tiefe; label: string; hinweis: string }[] = [
|
||||
{ wert: "personen", label: "mit Personen", hinweis: "je Abteilung ein Blatt, mit allen Namen" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Ob die Personalnummer mitgedruckt wird.
|
||||
*
|
||||
* Als Kontext und nicht als Eigenschaft durch den Baum: die Angabe betrifft
|
||||
* eine einzige Zeile ganz unten (PersonZeile), müsste aber sonst durch jede
|
||||
* Ebene der Seitenerzeugung durchgereicht werden — vier Signaturen für ein
|
||||
* Häkchen. Gewünscht von Max am 30.09., ausdrücklich mit Wahlmöglichkeit.
|
||||
*/
|
||||
const NummerImDruck = createContext(false);
|
||||
|
||||
type Format = "a4" | "a3";
|
||||
// Die Satzfläche ist die Blattgrösse abzüglich der Ränder aus @page, ein
|
||||
// wenig knapper gerechnet. Die Vorschau bekommt genau diese Masse fest
|
||||
@@ -160,6 +170,7 @@ function seitenBauen(model: PrintModel, bereiche: PrintUnit[], tiefe: Tiefe, asO
|
||||
export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: string; today: string }) {
|
||||
const [format, setFormat] = useState<Format>("a4");
|
||||
const [tiefe, setTiefe] = useState<Tiefe>("team");
|
||||
const [mitNummer, setMitNummer] = useState(false);
|
||||
const [gewaehlt, setGewaehlt] = useState<Set<string>>(() => new Set(model.divisions.map((d) => d.id)));
|
||||
const [schritt, setSchritt] = useState<"auswahl" | "vorschau">("auswahl");
|
||||
|
||||
@@ -203,6 +214,7 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st
|
||||
}
|
||||
|
||||
return (
|
||||
<NummerImDruck.Provider value={mitNummer}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<style>{`
|
||||
@page { size: ${FORMATE[format].css}; margin: 12mm 14mm 14mm; }
|
||||
@@ -290,6 +302,15 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Auf jeder Tiefe angeboten, nicht nur bei „mit Personen": die
|
||||
Leitung einer Einheit steht auch auf den Blättern, die sonst
|
||||
keine Namen führen. Ein Häkchen, das dort fehlte, sähe aus, als
|
||||
gäbe es dort keine Namen. */}
|
||||
<label className="mt-3 flex items-center gap-2 text-sm text-ink-body">
|
||||
<input type="checkbox" checked={mitNummer} onChange={(e) => setMitNummer(e.target.checked)} />
|
||||
Personalnummer hinter den Namen drucken
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
@@ -412,7 +433,14 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st
|
||||
unterzeile={seite.unterzeile}
|
||||
seite={i === 0 ? "Übersicht" : `Seite ${i + 1} von ${seiten.length}`}
|
||||
/>
|
||||
<Skaliert kennung={`${seite.key}|${format}|${tiefe}`} onFaktor={(f) => meldeMassstab(seite.key, f)}>
|
||||
{/* Die Kennung trägt `mitNummer` mit: die Zeilen werden
|
||||
breiter, also muss der Massstab neu gemessen werden. Ohne
|
||||
das bliebe der Wert des vorigen Laufs stehen, und das Blatt
|
||||
liefe über den Rand. */}
|
||||
<Skaliert
|
||||
kennung={`${seite.key}|${format}|${tiefe}|${mitNummer}`}
|
||||
onFaktor={(f) => meldeMassstab(seite.key, f)}
|
||||
>
|
||||
<Chart seite={seite} />
|
||||
</Skaliert>
|
||||
</Blatt>
|
||||
@@ -421,6 +449,7 @@ export function PrintChart({ model, asOf, today }: { model: PrintModel; asOf: st
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</NummerImDruck.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -635,9 +664,15 @@ function Kasten({ unit, mitPersonen }: { unit: PrintUnit; mitPersonen: boolean }
|
||||
}
|
||||
|
||||
function PersonZeile({ p, leitung }: { p: PrintPerson; leitung?: boolean }) {
|
||||
const mitNummer = useContext(NummerImDruck);
|
||||
return (
|
||||
<p className="text-[10px] leading-tight">
|
||||
<span className={leitung ? "font-bold text-ink" : "text-ink"}>{p.name}</span>
|
||||
{/* Die Personalnummer hinter den Namen und nicht in eine eigene Zeile:
|
||||
auf einem Blatt mit zweihundert Kästen kostet jede zusätzliche Zeile
|
||||
Massstab, und der entscheidet darüber, ob das Blatt noch lesbar ist.
|
||||
Tabellenziffern, damit die Nummern untereinander stehen. */}
|
||||
{mitNummer && <span className="text-ink-muted tabular-nums"> · {p.personnelNumber}</span>}
|
||||
{p.absent && <span className="text-ink-muted"> · abwesend</span>}
|
||||
<br />
|
||||
<span className="text-ink-muted">{p.jobTitle}</span>
|
||||
|
||||
@@ -38,6 +38,13 @@ export type OrgUnitNode = {
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
unit_type: "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
|
||||
/**
|
||||
* Nur die Struktursicht liest sie, für die Angaben zur Einheit. Der Druck
|
||||
* kommt ohne sie aus und lädt sie deshalb auch nicht — optional statt
|
||||
* überall mitgeschleppt.
|
||||
*/
|
||||
valid_from?: string;
|
||||
valid_to?: string | null;
|
||||
};
|
||||
|
||||
// Generic tree shape both EmployeeTree and PositionTree map their own data
|
||||
@@ -66,5 +73,13 @@ export type ChartNode = {
|
||||
coveringFor?: string;
|
||||
/** Reports below this node in total, not just direct ones. */
|
||||
totalReports?: number;
|
||||
/**
|
||||
* Die Organisationseinheit hinter dem Knoten — gesetzt nur in der
|
||||
* Struktursicht und nur an Einheiten, nicht an Personen, Tätigkeitsgruppen
|
||||
* oder Vakanzen. Daran hängt der Knopf „Einheit darunter anlegen“: aus der
|
||||
* Knoten-ID (`unit-<uuid>`) liesse sich dasselbe herauslösen, aber dann
|
||||
* wäre die ID plötzlich ein Datenformat und nicht mehr nur ein Schlüssel.
|
||||
*/
|
||||
unitId?: string;
|
||||
children: ChartNode[];
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/Button";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { PlanstelleZumAendern } from "@/lib/positions";
|
||||
import type { UnitOption } from "./CreatePositionModal";
|
||||
|
||||
// Ändern statt löschen und neu anlegen.
|
||||
@@ -33,7 +33,7 @@ export function EditPositionModal({
|
||||
heute,
|
||||
onClose,
|
||||
}: {
|
||||
position: OpenPositionResolved;
|
||||
position: PlanstelleZumAendern;
|
||||
units: UnitOption[];
|
||||
kostenstellen: { id: string; code: string; name: string }[];
|
||||
/** Vom Server, nicht aus new Date(): sonst rechnet der Browser mit seiner Zone. */
|
||||
@@ -63,20 +63,42 @@ export function EditPositionModal({
|
||||
// Die eigene Leitung zählt nicht als Hindernis für sich selbst.
|
||||
const chiefTaken = Boolean(unit?.hasChief) && !(position.is_chief && orgUnitId === position.org_unit_id);
|
||||
|
||||
// Ob sich an den Stammangaben überhaupt etwas geändert hat.
|
||||
//
|
||||
// update_position weist einen Aufruf ohne Änderung mit „Es wurde nichts
|
||||
// geändert." ab — und weil die Umkontierung erst bei dessen Erfolg lief,
|
||||
// liess sich die Kostenstelle **allein** nicht setzen: die Meldung kam, und
|
||||
// die neue Kostenstelle blieb liegen. Wer es trotzdem wollte, musste
|
||||
// nebenbei ein anderes Feld verstellen. Gemeldet im Test vom 29.09. (E.07).
|
||||
const stammGeaendert =
|
||||
jobTitle.trim() !== position.title ||
|
||||
orgUnitId !== position.org_unit_id ||
|
||||
(isChief && !chiefTaken) !== position.is_chief ||
|
||||
validFrom !== position.valid_from ||
|
||||
(validTo || null) !== (position.valid_to ?? null);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!jobTitle.trim() || !orgUnitId || !validFrom) {
|
||||
showToast("Tätigkeit, Einheit und Gültigkeitsbeginn sind Pflicht.", "error");
|
||||
return;
|
||||
}
|
||||
if (!stammGeaendert && !kostenstelleGeaendert) {
|
||||
showToast("Es wurde nichts geändert.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await updatePosition({
|
||||
position_id: position.id,
|
||||
org_unit_id: orgUnitId,
|
||||
job_title: jobTitle.trim(),
|
||||
is_chief: isChief && !chiefTaken,
|
||||
valid_from: validFrom,
|
||||
valid_to: validTo || null,
|
||||
});
|
||||
// Nur aufrufen, wenn es etwas zu ändern gibt. Sonst ist der Fehlschlag
|
||||
// dieses Schritts die einzige Antwort auf eine reine Umkontierung.
|
||||
const result = stammGeaendert
|
||||
? await updatePosition({
|
||||
position_id: position.id,
|
||||
org_unit_id: orgUnitId,
|
||||
job_title: jobTitle.trim(),
|
||||
is_chief: isChief && !chiefTaken,
|
||||
valid_from: validFrom,
|
||||
valid_to: validTo || null,
|
||||
})
|
||||
: { success: true as const };
|
||||
if (result.success && kostenstelleGeaendert) {
|
||||
// Nacheinander, nicht gemeinsam: die Umkontierung ist ein eigener
|
||||
// Vorgang mit eigenem Stichtag und eigenem Protokolleintrag. Schlägt sie
|
||||
@@ -96,7 +118,13 @@ export function EditPositionModal({
|
||||
}
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
showToast(kostenstelleGeaendert ? "Planstelle geändert und umkontiert." : "Planstelle geändert.");
|
||||
showToast(
|
||||
stammGeaendert && kostenstelleGeaendert
|
||||
? "Planstelle geändert und umkontiert."
|
||||
: kostenstelleGeaendert
|
||||
? "Planstelle umkontiert."
|
||||
: "Planstelle geändert."
|
||||
);
|
||||
router.refresh();
|
||||
onClose();
|
||||
} else {
|
||||
|
||||
@@ -6,13 +6,14 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { deleteReport, saveReport } from "@/actions/reports";
|
||||
import { Button, LINK_BUTTON_CLASS } from "@/components/ui/Button";
|
||||
import { CONTROL_CLASS, SelectField, TextField } from "@/components/ui/Field";
|
||||
import { CONTROL_CLASS, DateField, SelectField, TextField } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import {
|
||||
anzahlKriterien,
|
||||
AUSWAHL_KRITERIEN,
|
||||
beschreibeKriterien,
|
||||
JANEIN_KRITERIEN,
|
||||
kriterienParameter,
|
||||
ZEITRAUM_KRITERIEN,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
EVENT_TYPE_LABELS,
|
||||
GROUP_LABELS,
|
||||
MEASURE_LABELS,
|
||||
messgroesseLabel,
|
||||
parseStatuses,
|
||||
REPORT_PRESETS,
|
||||
sortKeysForDimension,
|
||||
@@ -48,6 +50,21 @@ const EVENT_TYPES = EREIGNIS_AUSWAHL;
|
||||
/** Wie viele Namen eine aufgeklappte Zeile zeigt, bevor der Rest auf Klick kommt. */
|
||||
const VORSCHAU_NAMEN = 12;
|
||||
|
||||
/**
|
||||
* Die Exporte, die in ein anderes System geladen werden.
|
||||
*
|
||||
* Sie stehen zusammen und getrennt vom Datenexport darüber, weil sie etwas
|
||||
* anderes sind: der Datenexport gibt Zahlen heraus, die jemand ansieht, diese
|
||||
* hier legen im Zielsystem Datensätze an. Nebeneinander in einer Reihe
|
||||
* gleichartiger Karten war das nicht zu sehen — und eine versehentlich
|
||||
* eingeschränkte Auswahl wirkt sich hier ganz anders aus.
|
||||
*/
|
||||
const ZIELSYSTEME: readonly { name: string; pfad: string }[] = [
|
||||
{ name: "Honestly — Mitarbeiterbefragung", pfad: "/api/export/honestly" },
|
||||
{ name: "Cornerstone — Lernsystem", pfad: "/api/export/cornerstone" },
|
||||
{ name: "LOLYO — Mitarbeiter-App", pfad: "/api/export/lolyo" },
|
||||
];
|
||||
|
||||
type SavedReport = { id: string; name: string; config: Record<string, unknown> };
|
||||
type OrgOption = { id: string; name: string };
|
||||
|
||||
@@ -171,17 +188,15 @@ function KriterienBlock({
|
||||
<fieldset key={k.key} className="rounded border border-border px-3 py-2">
|
||||
<legend className="mb-1 text-xs font-semibold text-ink-muted">{k.label}</legend>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<TextField
|
||||
<DateField
|
||||
label="Von"
|
||||
dense
|
||||
type="date"
|
||||
value={criteria.zeitraum[k.key]?.von ?? ""}
|
||||
onChange={(v) => onChange({ [`${k.key}From`]: v || undefined })}
|
||||
/>
|
||||
<TextField
|
||||
<DateField
|
||||
label="Bis"
|
||||
dense
|
||||
type="date"
|
||||
value={criteria.zeitraum[k.key]?.bis ?? ""}
|
||||
onChange={(v) => onChange({ [`${k.key}To`]: v || undefined })}
|
||||
/>
|
||||
@@ -212,7 +227,8 @@ function KriterienBlock({
|
||||
</fieldset>
|
||||
|
||||
<p className="text-xs text-ink-muted">
|
||||
Alle Kriterien gelten für den Bericht <em>und</em> für beide Exporte.
|
||||
Alle Kriterien gelten für den Bericht <em>und</em> für jeden Export dieser Seite — auch für den
|
||||
Honestly- und den Cornerstone-Report. Was gerade eingestellt ist, steht auf der jeweiligen Karte.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -220,6 +236,27 @@ function KriterienBlock({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Was gerade in die Datei geht — Stichtag, Status, Einheit, Standort und jedes
|
||||
* gesetzte Kriterium, dazu die Zeilenzahl.
|
||||
*
|
||||
* Auf den Karten stand nur der Status. Alles andere schränkt den Export
|
||||
* genauso ein, steht aber eingeklappt weiter oben: eine Datei mit 40 statt 785
|
||||
* Personen sah genauso aus wie eine vollständige. Für einen Bericht ist das
|
||||
* ärgerlich, für ein Load-File ins Zielsystem ist es ein Datenstand, den dort
|
||||
* niemand mehr hinterfragt.
|
||||
*/
|
||||
function AuswahlHinweis({ texte, anzahl }: { texte: string[]; anzahl: number }) {
|
||||
return (
|
||||
<div className="mb-2 rounded bg-surface px-2 py-1.5 text-xs text-ink-body">
|
||||
<span className="font-semibold">Auswahl:</span> {texte.join(" · ")}
|
||||
<span className="mt-0.5 block font-semibold">
|
||||
{anzahl} {anzahl === 1 ? "Person" : "Personen"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatValue(measure: Measure, value: number): string {
|
||||
if (measure === "headcount") return String(Math.round(value));
|
||||
if (measure === "fte") return value.toFixed(1);
|
||||
@@ -355,13 +392,27 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
const showWeekdayMultiCountNote = mode === "snapshot" && (props.group === "weekday" || props.split === "weekday");
|
||||
const selectedStatuses = mode === "snapshot" ? parseStatuses(props.filters.status) : [];
|
||||
const statusExportLabel = selectedStatuses.length === STATUS_OPTIONS.length ? "Alle" : selectedStatuses.join(", ");
|
||||
// Einheit und Standort stehen in der Adresszeile als Kennung; auf der Karte
|
||||
// hilft nur der Name.
|
||||
const einheitName = mode === "snapshot" ? units.find((u) => u.id === props.filters.division)?.name : undefined;
|
||||
const standortName = mode === "snapshot" ? locations.find((l) => l.id === props.filters.location)?.name : undefined;
|
||||
const auswahlTexte =
|
||||
mode === "snapshot"
|
||||
? [
|
||||
`Stichtag ${fmtDate(props.asOf || todayIso())}`,
|
||||
`Status ${statusExportLabel}`,
|
||||
...(einheitName ? [`Einheit ${einheitName}`] : []),
|
||||
...(standortName ? [`Standort ${standortName}`] : []),
|
||||
...beschreibeKriterien(props.criteria),
|
||||
]
|
||||
: [];
|
||||
const currentYear = new Date().getFullYear();
|
||||
const defaultEventFrom = `${currentYear}-01-01`;
|
||||
const defaultEventTo = `${currentYear}-12-31`;
|
||||
|
||||
const heading =
|
||||
mode === "snapshot"
|
||||
? `${MEASURE_LABELS[props.measure]} nach ${GROUP_LABELS[props.group]}`
|
||||
? `${messgroesseLabel(props.measure, selectedStatuses)} nach ${GROUP_LABELS[props.group]}`
|
||||
: `${props.eventType ? EVENT_TYPE_LABELS[props.eventType as HistoryEventType] : "Ereignisse"} nach ${EVENT_GROUP_LABELS[props.eventGroup]}`;
|
||||
const totalDisplay = mode === "snapshot" ? formatValue(props.measure, total) : String(Math.round(total));
|
||||
|
||||
@@ -418,11 +469,10 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-end gap-2">
|
||||
<TextField
|
||||
<DateField
|
||||
label="Stichtag"
|
||||
dense
|
||||
className="flex-1"
|
||||
type="date"
|
||||
value={props.asOf || todayIso()}
|
||||
onChange={(v) => updateParams({ asOf: v })}
|
||||
/>
|
||||
@@ -503,10 +553,9 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
<legend className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Zeitraum</legend>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<TextField
|
||||
<DateField
|
||||
label="Von"
|
||||
dense
|
||||
type="date"
|
||||
value={props.eventFilters.from === EVENT_DATE_OPEN ? "" : props.eventFilters.from || defaultEventFrom}
|
||||
disabled={props.eventFilters.from === EVENT_DATE_OPEN}
|
||||
onChange={(v) => updateParams({ from: v })}
|
||||
@@ -521,10 +570,9 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<TextField
|
||||
<DateField
|
||||
label="Bis"
|
||||
dense
|
||||
type="date"
|
||||
value={props.eventFilters.to === EVENT_DATE_OPEN ? "" : props.eventFilters.to || defaultEventTo}
|
||||
disabled={props.eventFilters.to === EVENT_DATE_OPEN}
|
||||
onChange={(v) => updateParams({ to: v })}
|
||||
@@ -619,8 +667,8 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Vollständiger Datenexport</h3>
|
||||
{mode === "snapshot" ? (
|
||||
<>
|
||||
<p className="mb-1 text-xs text-ink-muted">Alle Mitarbeiterdaten (nicht nur die Kennzahl){props.asOf ? ` zum Stichtag ${fmtDate(props.asOf)}` : ""}.</p>
|
||||
<p className="mb-2 rounded bg-surface px-2 py-1.5 text-xs font-semibold text-ink-body">Status im Export: {statusExportLabel}</p>
|
||||
<p className="mb-1 text-xs text-ink-muted">Alle Mitarbeiterdaten, nicht nur die Kennzahl.</p>
|
||||
<AuswahlHinweis texte={auswahlTexte} anzahl={recordCount} />
|
||||
</>
|
||||
) : (
|
||||
<p className="mb-2 text-xs text-ink-muted">Alle Ereignisse im gewählten Zeitraum als Rohdaten (eine Zeile pro Ereignis).</p>
|
||||
@@ -637,22 +685,31 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nur im Bestand: eine Befragung richtet sich an Personen, nicht an
|
||||
Ereignisse. Dieselben Filter wie der vollständige Export darüber
|
||||
— was dort steht, steht auch hier. */}
|
||||
{/* Nur im Bestand: ein Zielsystem führt Personen, keine Ereignisse.
|
||||
Dieselben Filter wie der vollständige Export darüber. */}
|
||||
{mode === "snapshot" && (
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Honestly-Report</h3>
|
||||
<p className="mb-2 rounded bg-surface px-2 py-1.5 text-xs font-semibold text-ink-body">Status im Export: {statusExportLabel}</p>
|
||||
<div className="flex gap-2">
|
||||
<a href={exportHref("/api/export/honestly", "csv")} className={LINK_BUTTON_CLASS}>
|
||||
<FileText className="h-4 w-4" />
|
||||
CSV
|
||||
</a>
|
||||
<a href={exportHref("/api/export/honestly", "xlsx")} className={LINK_BUTTON_CLASS}>
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
Excel
|
||||
</a>
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Exporte für Zielsysteme</h3>
|
||||
<p className="mb-1 text-xs text-ink-muted">
|
||||
Load-Dateien für Fremdsysteme, jede in deren eigenem Format. Für alle gilt dieselbe Auswahl.
|
||||
</p>
|
||||
<AuswahlHinweis texte={auswahlTexte} anzahl={recordCount} />
|
||||
<div className="flex flex-col divide-y divide-border-subtle">
|
||||
{ZIELSYSTEME.map((z) => (
|
||||
<div key={z.pfad} className="py-2 first:pt-0 last:pb-0">
|
||||
<p className="mb-1.5 text-sm font-semibold text-ink">{z.name}</p>
|
||||
<div className="flex gap-2">
|
||||
<a href={exportHref(z.pfad, "csv")} className={LINK_BUTTON_CLASS}>
|
||||
<FileText className="h-4 w-4" />
|
||||
CSV
|
||||
</a>
|
||||
<a href={exportHref(z.pfad, "xlsx")} className={LINK_BUTTON_CLASS}>
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
Excel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useId, type ReactNode, type SelectHTMLAttributes, type InputHTMLAttributes, type TextareaHTMLAttributes } from "react";
|
||||
import {
|
||||
useId,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type SelectHTMLAttributes,
|
||||
type InputHTMLAttributes,
|
||||
type TextareaHTMLAttributes,
|
||||
} from "react";
|
||||
|
||||
// Form primitives.
|
||||
//
|
||||
@@ -112,6 +119,74 @@ export function TextField({ label, required, hint, error, dense, className, valu
|
||||
);
|
||||
}
|
||||
|
||||
/** Ein vollständiges Datum mit vierstelliger Jahreszahl. */
|
||||
const VOLLSTAENDIGES_DATUM = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* Ob ein Datumswert eine Aussage ist — oder ein Zwischenstand beim Tippen.
|
||||
*
|
||||
* Exportiert, weil der Stichtag im Organigramm dieselbe Frage stellt und dort
|
||||
* ein blankes `<input>` steht. Zwei Fassungen derselben Regel laufen in diesem
|
||||
* Projekt erfahrungsgemäss auseinander.
|
||||
*/
|
||||
export function istMeldbaresDatum(wert: string): boolean {
|
||||
// Leer ist eine Aussage — „kein Datum" —, und die soll durchkommen.
|
||||
if (wert === "") return true;
|
||||
if (!VOLLSTAENDIGES_DATUM.test(wert)) return false;
|
||||
// Ein Jahr unter 1000 entsteht beim Tippen: wer „2026" eingibt, erzeugt
|
||||
// unterwegs 0002, 0020 und 0202. Das sind gültige Daten und damit nicht von
|
||||
// einer echten Eingabe zu unterscheiden — ausser an ihrer Grösse.
|
||||
return Number(wert.slice(0, 4)) >= 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Datumsfeld, das Zwischenstände für sich behält.
|
||||
*
|
||||
* Der Anlass: in den Berichten hängt jedes Feld an der Adresszeile — eine
|
||||
* Änderung heisst `router.push`, also neu laden und neu rendern. Ein
|
||||
* `<input type="date">` meldet beim Tippen der Jahreszahl aber viermal:
|
||||
* 0002, 0020, 0202, 2026. Die ersten drei lösten je eine Navigation aus, und
|
||||
* jede setzte das Feld auf den Stand aus der Adresse zurück — mitten im
|
||||
* Tippen. Mit der Maus über den Kalender ging es, weil der in einem Zug ein
|
||||
* fertiges Datum setzt.
|
||||
*
|
||||
* Deshalb: der Tippstand bleibt hier, gemeldet wird nur, was eine Aussage ist
|
||||
* — ein vollständiges Datum mit plausibler Jahreszahl, oder das Leeren des
|
||||
* Feldes. Beim Verlassen wird nachgereicht, was bis dahin liegengeblieben ist.
|
||||
*/
|
||||
export function DateField({ value, onChange, onBlur, ...rest }: Omit<TextFieldProps, "type">) {
|
||||
const [entwurf, setEntwurf] = useState(value);
|
||||
const [zuletzt, setZuletzt] = useState(value);
|
||||
|
||||
// Von aussen gewinnt: „Zurücksetzen" in den Kriterien leert das Feld, und
|
||||
// nach dem Melden steht hier ohnehin schon dasselbe.
|
||||
//
|
||||
// Während des Renderns angeglichen und nicht in einem Effekt: ein Effekt
|
||||
// liefe erst nach dem Zeichnen, das Feld zeigte also für einen Bildaufbau
|
||||
// den alten Stand — und die Regel „kein setState im Effekt" verbietet ihn
|
||||
// aus genau diesem Grund.
|
||||
if (value !== zuletzt) {
|
||||
setZuletzt(value);
|
||||
setEntwurf(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
{...rest}
|
||||
type="date"
|
||||
value={entwurf}
|
||||
onChange={(v) => {
|
||||
setEntwurf(v);
|
||||
if (istMeldbaresDatum(v) && v !== value) onChange(v);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (entwurf !== value && istMeldbaresDatum(entwurf)) onChange(entwurf);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type Option = { value: string; label: string; disabled?: boolean };
|
||||
|
||||
type SelectFieldProps = FieldShellProps &
|
||||
|
||||
866
db/migrations/20260928100000_cornerstone_id.sql
Normal file
866
db/migrations/20260928100000_cornerstone_id.sql
Normal file
@@ -0,0 +1,866 @@
|
||||
-- Die Cornerstone-ID als eigenes, freiwilliges Feld.
|
||||
--
|
||||
-- Cornerstone (CSOD) führt jede Person unter einer eigenen Kennung. Der
|
||||
-- Export dorthin (lib/cornerstone.ts) trug in „User ID" und „Username"
|
||||
-- bisher die Alpenwerk-UUID — richtig, solange es nichts Besseres gab, aber
|
||||
-- eben nicht die Kennung, unter der Cornerstone die Person kennt. Ab jetzt
|
||||
-- steht dort diese Spalte.
|
||||
--
|
||||
-- Freiwillig: für 434 der 784 Personen liefert die Zuordnungstabelle des
|
||||
-- Kunden kein „#N/A", also keine Kennung. Ein Pflichtfeld hätte jede
|
||||
-- Änderung an diesen Zeilen blockiert.
|
||||
--
|
||||
-- Eindeutig: eine Kennung gehört genau einer Person. Mehrere ohne Kennung
|
||||
-- stören den Index nicht, weil null nie gleich null ist.
|
||||
--
|
||||
-- Text und keine Zahl: die Kennungen sind achtstellig, zwei sind
|
||||
-- siebenstellig, und führende Nullen wären in einer Zahlenspalte verloren.
|
||||
-- Gerechnet wird damit ohnehin nicht.
|
||||
--
|
||||
-- Fünf Funktionen müssen mit, weil `create or replace` die ganze Fassung
|
||||
-- ersetzt und ein ausgelassenes Feld dort still verschwindet — dieselbe
|
||||
-- Liste und derselbe Grund wie bei der Firmen-E-Mail (20260923120000):
|
||||
-- hire_employee und rehire_employee teilen sich den Schritt „Person",
|
||||
-- change_employee_data macht das Feld änderbar, apply_due_pending_changes
|
||||
-- sorgt dafür, dass eine auf später datierte Änderung nicht verfällt, und
|
||||
-- app_feld_karte, damit der Eintrag in der Historie richtigstellbar bleibt.
|
||||
|
||||
alter table employees add column if not exists cornerstone_id text;
|
||||
|
||||
comment on column employees.cornerstone_id is
|
||||
'Kennung der Person im Lernsystem Cornerstone (CSOD). Freiwillig, eindeutig. Füllt User ID und Username im Cornerstone-Export.';
|
||||
|
||||
create unique index if not exists employees_cornerstone_id_key on employees (cornerstone_id);
|
||||
|
||||
|
||||
-- ── hire_employee (Vorlage: 20260923120000_firmenemail.sql) ──────────
|
||||
CREATE OR REPLACE FUNCTION public.hire_employee(payload jsonb)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_id uuid;
|
||||
v_position_id uuid := (payload->>'position_id')::uuid;
|
||||
v_entry date := (payload->>'entry_date')::date;
|
||||
v_besetzt uuid;
|
||||
v_schutz boolean := coalesce((payload->>'has_kuendigungsschutz')::boolean, false);
|
||||
v_behindert boolean := coalesce((payload->>'ist_beguenstigt_behindert')::boolean, false);
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
if v_position_id is null then
|
||||
raise exception 'Es muss eine Planstelle angegeben werden.';
|
||||
end if;
|
||||
|
||||
if payload->>'personnel_number' is null or btrim(payload->>'personnel_number') = '' then
|
||||
raise exception 'Es muss eine Personalnummer angegeben werden.';
|
||||
end if;
|
||||
if exists (select 1 from employees where personnel_number = (payload->>'personnel_number')::int) then
|
||||
raise exception 'Die Personalnummer % ist bereits vergeben.', payload->>'personnel_number';
|
||||
end if;
|
||||
|
||||
declare
|
||||
v_ab date;
|
||||
v_bis date;
|
||||
begin
|
||||
select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_position_id;
|
||||
if v_ab is null then
|
||||
raise exception 'Die Planstelle existiert nicht.';
|
||||
end if;
|
||||
if v_entry < v_ab then
|
||||
raise exception 'Die Planstelle gilt erst ab %. Ein Eintritt am % ist darauf nicht möglich.', v_ab, v_entry;
|
||||
end if;
|
||||
if v_bis is not null and v_entry >= v_bis then
|
||||
raise exception 'Die Planstelle gilt nur bis %. Ein Eintritt am % ist darauf nicht möglich.', v_bis, v_entry;
|
||||
end if;
|
||||
end;
|
||||
|
||||
select pa.employee_id into v_besetzt
|
||||
from position_assignments pa
|
||||
where pa.position_id = v_position_id
|
||||
and (pa.valid_to is null or pa.valid_to > v_entry);
|
||||
if v_besetzt is not null then
|
||||
raise exception 'Diese Planstelle ist bereits besetzt.';
|
||||
end if;
|
||||
|
||||
insert into employees (
|
||||
personnel_number, first_name, last_name, gender, birth_date, sv_nummer, nationality, email, company_email, cornerstone_id, phone,
|
||||
address, postal_code, city, address_country, location_id, job_title,
|
||||
employment_type, weekly_hours, contract_type, contract_end_date, paygrade,
|
||||
source, status, entry_date, title_prefix, title_suffix,
|
||||
worker_type, mitarbeiterart, collective_agreement, work_days,
|
||||
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level,
|
||||
has_kuendigungsschutz, kuendigungsschutz_grund, kuendigungsschutz_ab, kuendigungsschutz_bis,
|
||||
ist_beguenstigt_behindert, behinderung_grad, behinderung_ab, behinderung_bis,
|
||||
hat_aufenthaltstitel, aufenthaltstitel_bis,
|
||||
dienstwagen_art, emergency_contact_name, emergency_contact_phone, emergency_contact_relation
|
||||
)
|
||||
values (
|
||||
(payload->>'personnel_number')::int, payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
|
||||
(payload->>'birth_date')::date, payload->>'sv_nummer',
|
||||
coalesce(payload->>'nationality', 'Österreich'), payload->>'email', payload->>'company_email', payload->>'cornerstone_id', payload->>'phone',
|
||||
payload->>'address', payload->>'postal_code', payload->>'city',
|
||||
coalesce(payload->>'address_country', 'Österreich'),
|
||||
(payload->>'location_id')::uuid,
|
||||
(select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id),
|
||||
coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'),
|
||||
coalesce((payload->>'weekly_hours')::numeric, 38.5),
|
||||
coalesce((payload->>'contract_type')::contract_type, 'unbefristet'),
|
||||
nullif(payload->>'contract_end_date', '')::date,
|
||||
coalesce((payload->>'paygrade')::paygrade_type, 'B'),
|
||||
coalesce((payload->>'source')::source_type, 'Extern'),
|
||||
case when v_entry > current_date then 'Geplant' else 'Aktiv' end::employment_status,
|
||||
v_entry,
|
||||
coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}'),
|
||||
coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}'),
|
||||
coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'),
|
||||
coalesce(nullif(payload->>'mitarbeiterart', ''), 'Standard'),
|
||||
coalesce((payload->>'collective_agreement')::collective_agreement, 'Süßwaren'),
|
||||
coalesce(nullif(array(select jsonb_array_elements_text(payload->'work_days'))::text[], '{}'), '{Mo,Di,Mi,Do,Fr}'),
|
||||
coalesce((payload->>'is_betriebsrat')::boolean, false),
|
||||
coalesce((payload->>'has_dienstwagen')::boolean, false),
|
||||
coalesce((payload->>'is_laterale_fuehrung')::boolean, false),
|
||||
coalesce((payload->>'is_c_level')::boolean, false),
|
||||
v_schutz,
|
||||
-- Grund, Beginn und Ende nur, wenn der Schutz überhaupt gesetzt ist:
|
||||
-- sonst bliebe ein Rest ohne Bezug stehen, und die Bedingungen wiesen
|
||||
-- den ganzen Vorgang zurück.
|
||||
case when v_schutz then nullif(payload->>'kuendigungsschutz_grund', '') else null end,
|
||||
case when v_schutz then nullif(payload->>'kuendigungsschutz_ab', '')::date else null end,
|
||||
case when v_schutz then nullif(payload->>'kuendigungsschutz_bis', '')::date else null end,
|
||||
v_behindert,
|
||||
case when v_behindert then nullif(payload->>'behinderung_grad', '')::integer else null end,
|
||||
case when v_behindert then nullif(payload->>'behinderung_ab', '')::date else null end,
|
||||
case when v_behindert then nullif(payload->>'behinderung_bis', '')::date else null end,
|
||||
coalesce((payload->>'hat_aufenthaltstitel')::boolean, false),
|
||||
case when coalesce((payload->>'hat_aufenthaltstitel')::boolean, false)
|
||||
then nullif(payload->>'aufenthaltstitel_bis', '')::date else null end,
|
||||
case when coalesce((payload->>'has_dienstwagen')::boolean, false) then coalesce(nullif(payload->>'dienstwagen_art', ''), 'Verbrenner') else null end,
|
||||
nullif(payload->>'emergency_contact_name', ''),
|
||||
nullif(payload->>'emergency_contact_phone', ''),
|
||||
nullif(payload->>'emergency_contact_relation', '')
|
||||
)
|
||||
returning id into v_id;
|
||||
|
||||
insert into position_assignments (position_id, employee_id, valid_from)
|
||||
values (v_position_id, v_id, v_entry);
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_id, v_entry, 'Eintritt', 'Eintritt auf Planstelle ' ||
|
||||
(select position_number from om_positions where id = v_position_id));
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (app_current_user_id(), current_actor_name(), 'Neueinstellung',
|
||||
(payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || v_entry);
|
||||
|
||||
return v_id;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
-- ── rehire_employee (Vorlage: 20260923120000_firmenemail.sql) ──────────
|
||||
CREATE OR REPLACE FUNCTION public.rehire_employee(payload jsonb)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_date date := (payload->>'rehire_date')::date;
|
||||
v_position_id uuid := (payload->>'position_id')::uuid;
|
||||
v_name text;
|
||||
v_alt employees%rowtype;
|
||||
v_besetzt uuid;
|
||||
v_schutz boolean;
|
||||
v_behindert boolean;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
select * into v_alt from employees where id = v_employee_id;
|
||||
if v_alt.id is null then
|
||||
raise exception 'Die Person existiert nicht.';
|
||||
end if;
|
||||
v_name := v_alt.first_name || ' ' || v_alt.last_name;
|
||||
|
||||
if v_position_id is null then
|
||||
raise exception 'Für die Wiedereinstellung muss eine Planstelle angegeben werden.';
|
||||
end if;
|
||||
|
||||
-- Dieselben Prüfungen wie bei einer Neueinstellung. Sie fehlten hier, und
|
||||
-- das hiess: eine Wiedereinstellung auf eine besetzte oder noch nicht
|
||||
-- gültige Planstelle scheiterte erst an einem Index oder gar nicht.
|
||||
declare
|
||||
v_ab date;
|
||||
v_bis date;
|
||||
begin
|
||||
select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_position_id;
|
||||
if v_ab is null then
|
||||
raise exception 'Die Planstelle existiert nicht.';
|
||||
end if;
|
||||
if v_date < v_ab then
|
||||
raise exception 'Die Planstelle gilt erst ab %. Ein Eintritt am % ist darauf nicht möglich.', v_ab, v_date;
|
||||
end if;
|
||||
if v_bis is not null and v_date >= v_bis then
|
||||
raise exception 'Die Planstelle gilt nur bis %. Ein Eintritt am % ist darauf nicht möglich.', v_bis, v_date;
|
||||
end if;
|
||||
end;
|
||||
|
||||
select pa.employee_id into v_besetzt
|
||||
from position_assignments pa
|
||||
where pa.position_id = v_position_id
|
||||
and (pa.valid_to is null or pa.valid_to > v_date);
|
||||
if v_besetzt is not null then
|
||||
raise exception 'Diese Planstelle ist bereits besetzt.';
|
||||
end if;
|
||||
|
||||
-- Die beiden Kennzeichen zuerst: an ihnen hängen Felder, die ohne sie nicht
|
||||
-- dastehen dürfen (chk_kuendigungsschutz_grund, chk_behinderung).
|
||||
v_schutz := coalesce((payload->>'has_kuendigungsschutz')::boolean, v_alt.has_kuendigungsschutz);
|
||||
v_behindert := coalesce((payload->>'ist_beguenstigt_behindert')::boolean, v_alt.ist_beguenstigt_behindert);
|
||||
|
||||
update employees set
|
||||
status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end,
|
||||
entry_date = v_date,
|
||||
exit_date = null,
|
||||
exit_reason = null,
|
||||
austrittsart = null,
|
||||
|
||||
first_name = coalesce(payload->>'first_name', first_name),
|
||||
last_name = coalesce(payload->>'last_name', last_name),
|
||||
gender = coalesce((payload->>'gender')::gender_type, gender),
|
||||
birth_date = coalesce((payload->>'birth_date')::date, birth_date),
|
||||
sv_nummer = case when payload ? 'sv_nummer' then nullif(payload->>'sv_nummer', '') else sv_nummer end,
|
||||
nationality = coalesce(payload->>'nationality', nationality),
|
||||
email = case when payload ? 'email' then nullif(payload->>'email', '') else email end,
|
||||
company_email = case when payload ? 'company_email' then nullif(payload->>'company_email', '') else company_email end,
|
||||
cornerstone_id = case when payload ? 'cornerstone_id' then nullif(payload->>'cornerstone_id', '') else cornerstone_id end,
|
||||
phone = case when payload ? 'phone' then nullif(payload->>'phone', '') else phone end,
|
||||
address = coalesce(payload->>'address', address),
|
||||
postal_code = coalesce(payload->>'postal_code', postal_code),
|
||||
city = coalesce(payload->>'city', city),
|
||||
address_country = coalesce(payload->>'address_country', address_country),
|
||||
location_id = coalesce((payload->>'location_id')::uuid, location_id),
|
||||
title_prefix = case when payload ? 'title_prefix'
|
||||
then coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}')
|
||||
else title_prefix end,
|
||||
title_suffix = case when payload ? 'title_suffix'
|
||||
then coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}')
|
||||
else title_suffix end,
|
||||
emergency_contact_name = case when payload ? 'emergency_contact_name' then nullif(payload->>'emergency_contact_name', '') else emergency_contact_name end,
|
||||
emergency_contact_phone = case when payload ? 'emergency_contact_phone' then nullif(payload->>'emergency_contact_phone', '') else emergency_contact_phone end,
|
||||
emergency_contact_relation = case when payload ? 'emergency_contact_relation' then nullif(payload->>'emergency_contact_relation', '') else emergency_contact_relation end,
|
||||
hat_aufenthaltstitel = coalesce((payload->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel),
|
||||
aufenthaltstitel_bis = case
|
||||
when coalesce((payload->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel)
|
||||
then case when payload ? 'aufenthaltstitel_bis' then nullif(payload->>'aufenthaltstitel_bis', '')::date else aufenthaltstitel_bis end
|
||||
else null
|
||||
end,
|
||||
|
||||
-- Die Tätigkeit kommt von der Planstelle, nicht aus dem payload: sie
|
||||
-- steht am Platz, den die Person einnimmt, und nicht an ihr.
|
||||
job_title = (select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id),
|
||||
employment_type = coalesce((payload->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((payload->>'weekly_hours')::numeric, weekly_hours),
|
||||
contract_type = coalesce((payload->>'contract_type')::contract_type, contract_type),
|
||||
contract_end_date = case when payload ? 'contract_end_date' then nullif(payload->>'contract_end_date', '')::date else contract_end_date end,
|
||||
paygrade = coalesce((payload->>'paygrade')::paygrade_type, paygrade),
|
||||
source = coalesce((payload->>'source')::source_type, source),
|
||||
|
||||
worker_type = coalesce((payload->>'worker_type')::worker_type, worker_type),
|
||||
mitarbeiterart = coalesce(nullif(payload->>'mitarbeiterart', ''), mitarbeiterart),
|
||||
collective_agreement = coalesce((payload->>'collective_agreement')::collective_agreement, collective_agreement),
|
||||
work_days = case when payload ? 'work_days'
|
||||
then coalesce(nullif(array(select jsonb_array_elements_text(payload->'work_days'))::text[], '{}'), work_days)
|
||||
else work_days end,
|
||||
is_betriebsrat = coalesce((payload->>'is_betriebsrat')::boolean, is_betriebsrat),
|
||||
has_dienstwagen = coalesce((payload->>'has_dienstwagen')::boolean, has_dienstwagen),
|
||||
dienstwagen_art = case
|
||||
when coalesce((payload->>'has_dienstwagen')::boolean, has_dienstwagen)
|
||||
then coalesce(nullif(payload->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
|
||||
else null
|
||||
end,
|
||||
is_laterale_fuehrung = coalesce((payload->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
|
||||
is_c_level = coalesce((payload->>'is_c_level')::boolean, is_c_level),
|
||||
|
||||
has_kuendigungsschutz = v_schutz,
|
||||
kuendigungsschutz_grund = case when v_schutz then
|
||||
case when payload ? 'kuendigungsschutz_grund' then nullif(payload->>'kuendigungsschutz_grund', '') else kuendigungsschutz_grund end
|
||||
else null end,
|
||||
kuendigungsschutz_ab = case when v_schutz then
|
||||
case when payload ? 'kuendigungsschutz_ab' then nullif(payload->>'kuendigungsschutz_ab', '')::date else kuendigungsschutz_ab end
|
||||
else null end,
|
||||
kuendigungsschutz_bis = case when v_schutz then
|
||||
case when payload ? 'kuendigungsschutz_bis' then nullif(payload->>'kuendigungsschutz_bis', '')::date else kuendigungsschutz_bis end
|
||||
else null end,
|
||||
|
||||
ist_beguenstigt_behindert = v_behindert,
|
||||
behinderung_grad = case when v_behindert then
|
||||
case when payload ? 'behinderung_grad' then nullif(payload->>'behinderung_grad', '')::integer else behinderung_grad end
|
||||
else null end,
|
||||
behinderung_ab = case when v_behindert then
|
||||
case when payload ? 'behinderung_ab' then nullif(payload->>'behinderung_ab', '')::date else behinderung_ab end
|
||||
else null end,
|
||||
behinderung_bis = case when v_behindert then
|
||||
case when payload ? 'behinderung_bis' then nullif(payload->>'behinderung_bis', '')::date else behinderung_bis end
|
||||
else null end
|
||||
where id = v_employee_id;
|
||||
|
||||
insert into position_assignments (position_id, employee_id, valid_from)
|
||||
values (v_position_id, v_employee_id, v_date);
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_date, 'Wiedereintritt',
|
||||
'Wiedereinstellung zum ' || v_date || ' auf Planstelle ' ||
|
||||
(select position_number from om_positions where id = v_position_id));
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (app_current_user_id(), current_actor_name(), 'Wiedereinstellung', v_name, v_employee_id,
|
||||
'Wiedereintritt am ' || v_date);
|
||||
end;
|
||||
$function$;
|
||||
|
||||
-- ── app_feld_karte (Vorlage: 20260923120000_firmenemail.sql) ──────────
|
||||
CREATE OR REPLACE FUNCTION public.app_feld_karte()
|
||||
RETURNS jsonb
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
select jsonb_build_object(
|
||||
'Vorname', jsonb_build_array('first_name', 'text', 'person'),
|
||||
'Nachname', jsonb_build_array('last_name', 'text', 'person'),
|
||||
'Geschlecht', jsonb_build_array('gender', 'gender_type', 'person'),
|
||||
'Geburtsdatum', jsonb_build_array('birth_date', 'date', 'person'),
|
||||
'SV-Nummer', jsonb_build_array('sv_nummer', 'text', 'person'),
|
||||
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text', 'person'),
|
||||
'Adresse', jsonb_build_array('address', 'text', 'person'),
|
||||
'Postleitzahl', jsonb_build_array('postal_code', 'text', 'person'),
|
||||
'Ort', jsonb_build_array('city', 'text', 'person'),
|
||||
'Land', jsonb_build_array('address_country', 'text', 'person'),
|
||||
'E-Mail', jsonb_build_array('email', 'text', 'person'),
|
||||
'Firmen-E-Mail', jsonb_build_array('company_email', 'text', 'person'),
|
||||
'Cornerstone-ID', jsonb_build_array('cornerstone_id', 'text', 'person'),
|
||||
'Telefon', jsonb_build_array('phone', 'text', 'person'),
|
||||
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text', 'person'),
|
||||
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text', 'person'),
|
||||
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text', 'person'),
|
||||
'Aufenthaltstitel', jsonb_build_array('hat_aufenthaltstitel', 'boolean', 'person'),
|
||||
'Aufenthaltstitel bis', jsonb_build_array('aufenthaltstitel_bis', 'date', 'person'),
|
||||
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste', 'person'),
|
||||
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste', 'person'),
|
||||
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type', 'contract'),
|
||||
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric', 'contract'),
|
||||
'Vertragsart', jsonb_build_array('contract_type', 'contract_type', 'contract'),
|
||||
'Befristet bis', jsonb_build_array('contract_end_date', 'date', 'contract'),
|
||||
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type', 'role'),
|
||||
'Mitarbeiterart', jsonb_build_array('mitarbeiterart', 'text', 'role'),
|
||||
'Besetzungsart', jsonb_build_array('source', 'source_type', 'role'),
|
||||
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement', 'role'),
|
||||
'Arbeitstage', jsonb_build_array('work_days', 'liste', 'role'),
|
||||
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean', 'role'),
|
||||
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean', 'role'),
|
||||
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean', 'role'),
|
||||
'C-Level', jsonb_build_array('is_c_level', 'boolean', 'role'),
|
||||
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text', 'role'),
|
||||
'Besonderer Kündigungsschutz', jsonb_build_array('has_kuendigungsschutz', 'boolean', 'role'),
|
||||
'Kündigungsschutz Personenkreis', jsonb_build_array('kuendigungsschutz_grund', 'text', 'role'),
|
||||
'Kündigungsschutz ab', jsonb_build_array('kuendigungsschutz_ab', 'date', 'role'),
|
||||
'Kündigungsschutz bis', jsonb_build_array('kuendigungsschutz_bis', 'date', 'role'),
|
||||
'Begünstigt behindert', jsonb_build_array('ist_beguenstigt_behindert', 'boolean', 'role'),
|
||||
-- 'integer' und nicht 'numeric': der Typ geht in update_history_entry
|
||||
-- unmittelbar als Umwandlung ins SQL (`%L::%s`). 'numeric' liefe über
|
||||
-- eine zweite, stillschweigende Umwandlung auf die Spalte und rundete
|
||||
-- dabei — der Grad ist eine ganze Zahl aus einem Bescheid.
|
||||
'Grad der Behinderung', jsonb_build_array('behinderung_grad', 'integer', 'role'),
|
||||
'Behinderung ab', jsonb_build_array('behinderung_ab', 'date', 'role'),
|
||||
'Behinderung bis', jsonb_build_array('behinderung_bis', 'date', 'role'),
|
||||
'Teilzeitvariante', jsonb_build_array('teilzeit_art', 'text', 'role'),
|
||||
'Teilzeit bis', jsonb_build_array('teilzeit_bis', 'date', 'role'),
|
||||
-- Die Felder der Abwesenheit. Dritter Eintrag null: sie gehören zu
|
||||
-- keiner Gruppe im payload einer geplanten Änderung, weil Abwesenheit
|
||||
-- und Rückkehr ihre eigenen Vorgänge haben.
|
||||
'Status', jsonb_build_array('status', 'employment_status', null),
|
||||
'Art der Abwesenheit', jsonb_build_array('absence_type', 'text', null),
|
||||
'Abwesend ab', jsonb_build_array('karenz_start_date', 'date', null),
|
||||
'Geplante Rückkehr', jsonb_build_array('karenz_return_date', 'date', null)
|
||||
);
|
||||
$function$;
|
||||
|
||||
-- ── change_employee_data (Vorlage: 20260923160000_leere_angaben_sind_null.sql) ──────────
|
||||
CREATE OR REPLACE FUNCTION public.change_employee_data(payload jsonb)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
|
||||
v_old employees%rowtype;
|
||||
v_name text;
|
||||
v_person_changes jsonb := '[]'::jsonb;
|
||||
v_contract_changes jsonb := '[]'::jsonb;
|
||||
v_person jsonb := payload->'person';
|
||||
v_contract jsonb := payload->'contract';
|
||||
v_role jsonb := payload->'role';
|
||||
v_immediate boolean;
|
||||
v_new_work_days text[];
|
||||
v_new_title_prefix text[];
|
||||
v_new_title_suffix text[];
|
||||
v_pending_id uuid;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select * into v_old from employees where id = v_employee_id;
|
||||
v_name := v_old.first_name || ' ' || v_old.last_name;
|
||||
v_immediate := v_effective_date <= current_date;
|
||||
|
||||
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
|
||||
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
|
||||
-- auf null gemeldet.
|
||||
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
|
||||
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
|
||||
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
|
||||
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
|
||||
-- „2026-08-03" nicht als Änderung gelten.
|
||||
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
|
||||
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
|
||||
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
|
||||
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
|
||||
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
|
||||
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
|
||||
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
|
||||
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
|
||||
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
|
||||
if v_person ? 'company_email' then v_person_changes := app_aenderung(v_person_changes, 'Firmen-E-Mail', v_old.company_email, v_person->>'company_email'); end if;
|
||||
if v_person ? 'cornerstone_id' then v_person_changes := app_aenderung(v_person_changes, 'Cornerstone-ID', v_old.cornerstone_id, v_person->>'cornerstone_id'); end if;
|
||||
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
|
||||
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
|
||||
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;
|
||||
if v_person ? 'hat_aufenthaltstitel' then v_person_changes := app_aenderung(v_person_changes, 'Aufenthaltstitel', v_old.hat_aufenthaltstitel::text, v_person->>'hat_aufenthaltstitel'); end if;
|
||||
if v_person ? 'aufenthaltstitel_bis' then v_person_changes := app_aenderung(v_person_changes, 'Aufenthaltstitel bis', v_old.aufenthaltstitel_bis::text, (nullif(v_person->>'aufenthaltstitel_bis','')::date)::text); end if;
|
||||
|
||||
if v_person ? 'title_prefix' then
|
||||
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
|
||||
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
|
||||
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
|
||||
end if;
|
||||
if v_person ? 'title_suffix' then
|
||||
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
|
||||
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
|
||||
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
|
||||
end if;
|
||||
|
||||
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
|
||||
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
|
||||
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
|
||||
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
|
||||
if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
|
||||
|
||||
if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
|
||||
if v_role ? 'mitarbeiterart' then v_contract_changes := app_aenderung(v_contract_changes, 'Mitarbeiterart', v_old.mitarbeiterart, v_role->>'mitarbeiterart'); end if;
|
||||
if v_role ? 'source' then v_contract_changes := app_aenderung(v_contract_changes, 'Besetzungsart', v_old.source::text, v_role->>'source'); end if;
|
||||
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
|
||||
if v_role ? 'work_days' then
|
||||
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
|
||||
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
|
||||
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
|
||||
end if;
|
||||
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
|
||||
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
|
||||
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
|
||||
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
|
||||
if v_role ? 'has_kuendigungsschutz' then v_contract_changes := app_aenderung(v_contract_changes, 'Besonderer Kündigungsschutz', v_old.has_kuendigungsschutz::text, v_role->>'has_kuendigungsschutz'); end if;
|
||||
if v_role ? 'kuendigungsschutz_grund' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz Personenkreis', v_old.kuendigungsschutz_grund, nullif(v_role->>'kuendigungsschutz_grund', '')); end if;
|
||||
if v_role ? 'kuendigungsschutz_ab' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz ab', v_old.kuendigungsschutz_ab::text, (nullif(v_role->>'kuendigungsschutz_ab','')::date)::text); end if;
|
||||
if v_role ? 'kuendigungsschutz_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz bis', v_old.kuendigungsschutz_bis::text, (nullif(v_role->>'kuendigungsschutz_bis','')::date)::text); end if;
|
||||
if v_role ? 'ist_beguenstigt_behindert' then v_contract_changes := app_aenderung(v_contract_changes, 'Begünstigt behindert', v_old.ist_beguenstigt_behindert::text, v_role->>'ist_beguenstigt_behindert'); end if;
|
||||
if v_role ? 'behinderung_grad' then v_contract_changes := app_aenderung(v_contract_changes, 'Grad der Behinderung', v_old.behinderung_grad::text, (nullif(v_role->>'behinderung_grad','')::integer)::text); end if;
|
||||
if v_role ? 'behinderung_ab' then v_contract_changes := app_aenderung(v_contract_changes, 'Behinderung ab', v_old.behinderung_ab::text, (nullif(v_role->>'behinderung_ab','')::date)::text); end if;
|
||||
if v_role ? 'behinderung_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Behinderung bis', v_old.behinderung_bis::text, (nullif(v_role->>'behinderung_bis','')::date)::text); end if;
|
||||
if v_role ? 'teilzeit_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeitvariante', v_old.teilzeit_art, nullif(v_role->>'teilzeit_art', '')); end if;
|
||||
if v_role ? 'teilzeit_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Teilzeit bis', v_old.teilzeit_bis::text, (nullif(v_role->>'teilzeit_bis','')::date)::text); end if;
|
||||
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;
|
||||
|
||||
if v_immediate then
|
||||
update employees set
|
||||
first_name = coalesce(v_person->>'first_name', first_name),
|
||||
last_name = coalesce(v_person->>'last_name', last_name),
|
||||
gender = coalesce((v_person->>'gender')::gender_type, gender),
|
||||
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
|
||||
sv_nummer = case when v_person ? 'sv_nummer' then nullif(v_person->>'sv_nummer', '') else sv_nummer end,
|
||||
nationality = coalesce(v_person->>'nationality', nationality),
|
||||
address = case when v_person ? 'address' then nullif(v_person->>'address', '') else address end,
|
||||
postal_code = case when v_person ? 'postal_code' then nullif(v_person->>'postal_code', '') else postal_code end,
|
||||
city = case when v_person ? 'city' then nullif(v_person->>'city', '') else city end,
|
||||
address_country = case when v_person ? 'address_country' then nullif(v_person->>'address_country', '') else address_country end,
|
||||
email = case when v_person ? 'email' then nullif(v_person->>'email', '') else email end,
|
||||
phone = case when v_person ? 'phone' then nullif(v_person->>'phone', '') else phone end,
|
||||
company_email = case when v_person ? 'company_email' then nullif(v_person->>'company_email', '') else company_email end,
|
||||
cornerstone_id = case when v_person ? 'cornerstone_id' then nullif(v_person->>'cornerstone_id', '') else cornerstone_id end,
|
||||
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
|
||||
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
|
||||
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,
|
||||
hat_aufenthaltstitel = coalesce((v_person->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel),
|
||||
-- Fällt der Titel weg, fällt das Datum mit. Sonst bliebe ein
|
||||
-- Enddatum ohne Titel stehen, und chk_aufenthaltstitel_bis liesse
|
||||
-- die ganze Änderung scheitern statt das Offensichtliche zu tun.
|
||||
aufenthaltstitel_bis = case
|
||||
when coalesce((v_person->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) then
|
||||
case when v_person ? 'aufenthaltstitel_bis'
|
||||
then nullif(v_person->>'aufenthaltstitel_bis','')::date
|
||||
else aufenthaltstitel_bis end
|
||||
else null
|
||||
end,
|
||||
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
|
||||
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
|
||||
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
|
||||
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
|
||||
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
|
||||
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
|
||||
-- Ohne nullif: die Spalte ist NOT NULL, ein leerer Text waere kein
|
||||
-- geloeschter Wert, sondern ein Wert, den chk_mitarbeiterart abweist.
|
||||
mitarbeiterart = coalesce(nullif(v_role->>'mitarbeiterart', ''), mitarbeiterart),
|
||||
source = coalesce((nullif(v_role->>'source', ''))::source_type, source),
|
||||
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
|
||||
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
|
||||
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
|
||||
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
|
||||
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
|
||||
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
|
||||
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
|
||||
teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end,
|
||||
-- Ohne Variante kein Enddatum: chk_teilzeit_bis verlangt es so, und
|
||||
-- ein Datum ohne Sache wäre ein Rest, den niemand mehr deutet.
|
||||
teilzeit_bis = case
|
||||
when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null
|
||||
when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date
|
||||
else teilzeit_bis
|
||||
end,
|
||||
-- Fällt der Schutz weg, fallen Grund und beide Daten mit. Andernfalls
|
||||
-- bliebe ein Rest ohne Bezug stehen — die Bedingungen verbieten das,
|
||||
-- und der Vorgang schlüge fehl, statt das Offensichtliche zu tun.
|
||||
kuendigungsschutz_grund = case
|
||||
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
|
||||
case when v_role ? 'kuendigungsschutz_grund'
|
||||
then nullif(v_role->>'kuendigungsschutz_grund','')
|
||||
else kuendigungsschutz_grund end
|
||||
else null
|
||||
end,
|
||||
kuendigungsschutz_ab = case
|
||||
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
|
||||
case when v_role ? 'kuendigungsschutz_ab'
|
||||
then nullif(v_role->>'kuendigungsschutz_ab','')::date
|
||||
else kuendigungsschutz_ab end
|
||||
else null
|
||||
end,
|
||||
kuendigungsschutz_bis = case
|
||||
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
|
||||
case when v_role ? 'kuendigungsschutz_bis'
|
||||
then nullif(v_role->>'kuendigungsschutz_bis','')::date
|
||||
else kuendigungsschutz_bis end
|
||||
else null
|
||||
end,
|
||||
ist_beguenstigt_behindert = coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert),
|
||||
behinderung_grad = case
|
||||
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
|
||||
case when v_role ? 'behinderung_grad' then nullif(v_role->>'behinderung_grad','')::integer else behinderung_grad end
|
||||
else null
|
||||
end,
|
||||
behinderung_ab = case
|
||||
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
|
||||
case when v_role ? 'behinderung_ab' then nullif(v_role->>'behinderung_ab','')::date else behinderung_ab end
|
||||
else null
|
||||
end,
|
||||
behinderung_bis = case
|
||||
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
|
||||
case when v_role ? 'behinderung_bis' then nullif(v_role->>'behinderung_bis','')::date else behinderung_bis end
|
||||
else null
|
||||
end,
|
||||
dienstwagen_art = case
|
||||
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
|
||||
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
|
||||
else null
|
||||
end
|
||||
where id = v_employee_id;
|
||||
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||
values (v_employee_id, 'contract_change', v_effective_date, payload)
|
||||
returning id into v_pending_id;
|
||||
end if;
|
||||
|
||||
if jsonb_array_length(v_person_changes) > 0 then
|
||||
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
|
||||
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
|
||||
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes, v_pending_id);
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
|
||||
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
|
||||
end if;
|
||||
|
||||
if jsonb_array_length(v_contract_changes) > 0 then
|
||||
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
|
||||
values (v_employee_id, v_effective_date, 'Vertragsänderung',
|
||||
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes, v_pending_id);
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
|
||||
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
|
||||
end if;
|
||||
|
||||
-- ── Die Übernahme ────────────────────────────────────────────────
|
||||
--
|
||||
-- Nur in dieser einen Richtung. Wer von extern nach intern wechselt, wird
|
||||
-- übernommen; der umgekehrte Weg ist kein Vorgang, den es fachlich gibt.
|
||||
--
|
||||
-- Das Ereignis entsteht **zusätzlich** zur Vertragsänderung darüber und
|
||||
-- nicht an ihrer Stelle: die eine hält fest, dass ein Feld sich geändert
|
||||
-- hat und worauf (und lässt sich darüber zurücknehmen), das andere, dass
|
||||
-- dieser Wechsel eine Übernahme war — und nur das lässt sich zählen.
|
||||
--
|
||||
-- `v_effective_date`, nicht `now()`: eine auf später datierte Übernahme
|
||||
-- gehört in die Akte auf den Tag, an dem sie gilt. Bei einer sofortigen
|
||||
-- ist das heute.
|
||||
if v_role ? 'source' and v_old.source::text = 'Extern' and v_role->>'source' = 'Intern' then
|
||||
insert into employee_history (employee_id, event_date, event_type, description, pending_id)
|
||||
values (v_employee_id, v_effective_date, 'Übernahme',
|
||||
'Übernahme von extern auf intern, wirksam ab ' || v_effective_date, v_pending_id);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (app_current_user_id(), current_actor_name(), 'Übernahme', v_name, v_employee_id,
|
||||
'Von extern auf intern, wirksam ab ' || v_effective_date);
|
||||
end if;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
-- ── apply_due_pending_changes (Vorlage: 20260924180000_datierte_aenderung_vollstaendig.sql) ──────────
|
||||
CREATE OR REPLACE FUNCTION public.apply_due_pending_changes()
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $function$
|
||||
declare
|
||||
v_rec record;
|
||||
v_count integer := 0;
|
||||
v_role jsonb;
|
||||
begin
|
||||
for v_rec in
|
||||
select * from pending_org_changes
|
||||
where status = 'pending' and effective_date <= current_date
|
||||
order by effective_date, created_at
|
||||
loop
|
||||
if v_rec.change_type = 'transfer' then
|
||||
-- Die Besetzung wandert mit. Hier stand zuletzt nur noch das Setzen
|
||||
-- des Titels aus einem Feld, das in diesem payload gar nicht vorkommt
|
||||
-- — die Fassung von vor der Umstellung auf Planstellen. Eine auf
|
||||
-- später datierte Versetzung galt damit am Stichtag als angewendet
|
||||
-- und bewegte niemanden.
|
||||
update position_assignments set valid_to = v_rec.effective_date
|
||||
where employee_id = v_rec.employee_id and valid_to is null;
|
||||
insert into position_assignments (position_id, employee_id, valid_from)
|
||||
values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date);
|
||||
update employees set job_title = (
|
||||
select j.title from om_positions p join jobs j on j.id = p.job_id
|
||||
where p.id = (v_rec.payload->>'target_position_id')::uuid
|
||||
) where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'promotion' then
|
||||
update employees set
|
||||
job_title = coalesce(v_rec.payload->>'new_title', job_title),
|
||||
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
|
||||
where id = v_rec.employee_id;
|
||||
-- Eine Beförderung kann die Planstelle wechseln. Ohne diesen Zweig
|
||||
-- stünde am Stichtag der neue Titel an der Person und sie säsze
|
||||
-- weiterhin auf der alten Stelle — im Organigramm unverändert.
|
||||
if nullif(v_rec.payload->>'target_position_id', '') is not null then
|
||||
update position_assignments set valid_to = v_rec.effective_date
|
||||
where employee_id = v_rec.employee_id and valid_to is null;
|
||||
insert into position_assignments (position_id, employee_id, valid_from)
|
||||
values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date);
|
||||
end if;
|
||||
|
||||
elsif v_rec.change_type = 'karenz_start' then
|
||||
update employees set
|
||||
status = 'Karenz',
|
||||
karenz_return_date = (v_rec.payload->>'planned_return_date')::date,
|
||||
absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type)
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'karenz_return' then
|
||||
update employees set
|
||||
status = 'Aktiv',
|
||||
karenz_return_date = null,
|
||||
karenz_start_date = null,
|
||||
absence_type = null,
|
||||
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours),
|
||||
-- Auch bei einer *geplanten* Rückkehr: sonst käme jemand am
|
||||
-- Stichtag mit reduzierten Stunden zurück, und der Grund dafür
|
||||
-- wäre verschwunden.
|
||||
teilzeit_art = case when v_rec.payload ? 'teilzeit_art'
|
||||
then nullif(v_rec.payload->>'teilzeit_art', '') else teilzeit_art end,
|
||||
teilzeit_bis = case when v_rec.payload ? 'teilzeit_art'
|
||||
then nullif(v_rec.payload->>'teilzeit_bis', '')::date else teilzeit_bis end
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'contract_change' then
|
||||
v_role := v_rec.payload->'role';
|
||||
update employees set
|
||||
first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name),
|
||||
last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name),
|
||||
gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender),
|
||||
birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date),
|
||||
sv_nummer = case when v_rec.payload->'person' ? 'sv_nummer' then nullif(v_rec.payload->'person'->>'sv_nummer', '') else sv_nummer end,
|
||||
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
|
||||
address = case when v_rec.payload->'person' ? 'address' then nullif(v_rec.payload->'person'->>'address', '') else address end,
|
||||
postal_code = case when v_rec.payload->'person' ? 'postal_code' then nullif(v_rec.payload->'person'->>'postal_code', '') else postal_code end,
|
||||
city = case when v_rec.payload->'person' ? 'city' then nullif(v_rec.payload->'person'->>'city', '') else city end,
|
||||
address_country = case when v_rec.payload->'person' ? 'address_country' then nullif(v_rec.payload->'person'->>'address_country', '') else address_country end,
|
||||
email = case when v_rec.payload->'person' ? 'email' then nullif(v_rec.payload->'person'->>'email', '') else email end,
|
||||
phone = case when v_rec.payload->'person' ? 'phone' then nullif(v_rec.payload->'person'->>'phone', '') else phone end,
|
||||
company_email = case when v_rec.payload->'person' ? 'company_email' then nullif(v_rec.payload->'person'->>'company_email', '') else company_email end,
|
||||
cornerstone_id = case when v_rec.payload->'person' ? 'cornerstone_id' then nullif(v_rec.payload->'person'->>'cornerstone_id', '') else cornerstone_id end,
|
||||
-- Der Notfallkontakt und die Titel fehlten hier. Eine auf später
|
||||
-- datierte Änderung stand damit in der Akte, kam aber nie an der
|
||||
-- Person an: change_employee_data schreibt sie sofort, dieser Zweig
|
||||
-- kannte sie nicht. Dieselbe Lücke wie einst bei der Gruppe `role`
|
||||
-- (20260915120000).
|
||||
emergency_contact_name = case when v_rec.payload->'person' ? 'emergency_contact_name'
|
||||
then nullif(v_rec.payload->'person'->>'emergency_contact_name', '') else emergency_contact_name end,
|
||||
emergency_contact_phone = case when v_rec.payload->'person' ? 'emergency_contact_phone'
|
||||
then nullif(v_rec.payload->'person'->>'emergency_contact_phone', '') else emergency_contact_phone end,
|
||||
emergency_contact_relation = case when v_rec.payload->'person' ? 'emergency_contact_relation'
|
||||
then nullif(v_rec.payload->'person'->>'emergency_contact_relation', '') else emergency_contact_relation end,
|
||||
-- Wie work_days weiter unten: eine leere Liste ist eine Angabe, kein
|
||||
-- fehlender Wert, deshalb coalesce auf '{}' statt auf den alten Stand.
|
||||
title_prefix = case when v_rec.payload->'person' ? 'title_prefix'
|
||||
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'person'->'title_prefix') elem), '{}')
|
||||
else title_prefix end,
|
||||
title_suffix = case when v_rec.payload->'person' ? 'title_suffix'
|
||||
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'person'->'title_suffix') elem), '{}')
|
||||
else title_suffix end,
|
||||
employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours),
|
||||
contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type),
|
||||
contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date'
|
||||
then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date
|
||||
else contract_end_date end,
|
||||
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
|
||||
mitarbeiterart = coalesce(nullif(v_role->>'mitarbeiterart', ''), mitarbeiterart),
|
||||
source = coalesce((nullif(v_role->>'source', ''))::source_type, source),
|
||||
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
|
||||
work_days = case when v_role ? 'work_days'
|
||||
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}')
|
||||
else work_days end,
|
||||
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
|
||||
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
|
||||
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
|
||||
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
|
||||
dienstwagen_art = case
|
||||
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
|
||||
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
|
||||
else null
|
||||
end,
|
||||
teilzeit_art = case when v_role ? 'teilzeit_art' then nullif(v_role->>'teilzeit_art', '') else teilzeit_art end,
|
||||
teilzeit_bis = case
|
||||
when coalesce(nullif(v_role->>'teilzeit_art', ''), case when v_role ? 'teilzeit_art' then null else teilzeit_art end) is null then null
|
||||
when v_role ? 'teilzeit_bis' then nullif(v_role->>'teilzeit_bis','')::date
|
||||
else teilzeit_bis
|
||||
end,
|
||||
hat_aufenthaltstitel = coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel),
|
||||
aufenthaltstitel_bis = case
|
||||
when coalesce((v_rec.payload->'person'->>'hat_aufenthaltstitel')::boolean, hat_aufenthaltstitel) then
|
||||
case when v_rec.payload->'person' ? 'aufenthaltstitel_bis'
|
||||
then nullif(v_rec.payload->'person'->>'aufenthaltstitel_bis','')::date
|
||||
else aufenthaltstitel_bis end
|
||||
else null
|
||||
end,
|
||||
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
|
||||
kuendigungsschutz_grund = case
|
||||
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
|
||||
case when v_role ? 'kuendigungsschutz_grund' then nullif(v_role->>'kuendigungsschutz_grund','') else kuendigungsschutz_grund end
|
||||
else null
|
||||
end,
|
||||
kuendigungsschutz_ab = case
|
||||
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
|
||||
case when v_role ? 'kuendigungsschutz_ab' then nullif(v_role->>'kuendigungsschutz_ab','')::date else kuendigungsschutz_ab end
|
||||
else null
|
||||
end,
|
||||
kuendigungsschutz_bis = case
|
||||
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
|
||||
case when v_role ? 'kuendigungsschutz_bis' then nullif(v_role->>'kuendigungsschutz_bis','')::date else kuendigungsschutz_bis end
|
||||
else null
|
||||
end,
|
||||
ist_beguenstigt_behindert = coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert),
|
||||
behinderung_grad = case
|
||||
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
|
||||
case when v_role ? 'behinderung_grad' then nullif(v_role->>'behinderung_grad','')::integer else behinderung_grad end
|
||||
else null
|
||||
end,
|
||||
behinderung_ab = case
|
||||
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
|
||||
case when v_role ? 'behinderung_ab' then nullif(v_role->>'behinderung_ab','')::date else behinderung_ab end
|
||||
else null
|
||||
end,
|
||||
behinderung_bis = case
|
||||
when coalesce((v_role->>'ist_beguenstigt_behindert')::boolean, ist_beguenstigt_behindert) then
|
||||
case when v_role ? 'behinderung_bis' then nullif(v_role->>'behinderung_bis','')::date else behinderung_bis end
|
||||
else null
|
||||
end
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'dependent_add' then
|
||||
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
|
||||
values (v_rec.employee_id, v_rec.payload->>'first_name', v_rec.payload->>'last_name',
|
||||
(v_rec.payload->>'relationship')::text,
|
||||
nullif(v_rec.payload->>'sv_nummer', ''), (v_rec.payload->>'birth_date')::date);
|
||||
|
||||
elsif v_rec.change_type = 'dependent_remove' then
|
||||
delete from employee_dependents where id = (v_rec.payload->>'dependent_id')::uuid;
|
||||
end if;
|
||||
|
||||
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
|
||||
v_count := v_count + 1;
|
||||
end loop;
|
||||
|
||||
return v_count;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
-- Selbstprüfung.
|
||||
do $$
|
||||
declare
|
||||
v_fn text;
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'employees' and column_name = 'cornerstone_id'
|
||||
) then
|
||||
raise exception 'Die Spalte cornerstone_id fehlt.';
|
||||
end if;
|
||||
|
||||
-- Jede der fünf einzeln: fehlt sie in einer, verschwindet das Feld genau
|
||||
-- in deren Weg, ohne dass es auffällt.
|
||||
foreach v_fn in array array[
|
||||
'public.hire_employee(jsonb)',
|
||||
'public.rehire_employee(jsonb)',
|
||||
'public.change_employee_data(jsonb)',
|
||||
'public.apply_due_pending_changes()'
|
||||
] loop
|
||||
if pg_get_functiondef(v_fn::regprocedure) not like '%cornerstone_id%' then
|
||||
raise exception '% kennt die Cornerstone-ID nicht.', v_fn;
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
if app_feld_karte() -> 'Cornerstone-ID' is null then
|
||||
raise exception 'Die Feldkarte kennt die Cornerstone-ID nicht — der Eintrag in der Historie waere nicht korrigierbar.';
|
||||
end if;
|
||||
|
||||
-- Und was zuletzt hier verlorenging, bleibt drin.
|
||||
if pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure) not like '%emergency_contact_name%' then
|
||||
raise exception 'Der Nachtlauf hat den Notfallkontakt wieder verloren.';
|
||||
end if;
|
||||
if pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure) not like '%target_position_id%' then
|
||||
raise exception 'Der Nachtlauf hat die Zielplanstelle wieder verloren.';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
247
db/migrations/20260928140000_hay_grade.sql
Normal file
247
db/migrations/20260928140000_hay_grade.sql
Normal file
@@ -0,0 +1,247 @@
|
||||
-- Hay-Grade statt Verwendungsgruppe A–F
|
||||
--
|
||||
-- ═══ 1. Warum die Liste wechselt ═══════════════════════════════════
|
||||
--
|
||||
-- A–F stammte aus der Spezifikation, nicht vom Kunden: sechs erfundene
|
||||
-- Stufen mit erfundenen Beschreibungen („B – Qualifiziert"). Der Kunde
|
||||
-- bewertet nach Hay und hat die Liste geschickt — 13 Stufen plus einen
|
||||
-- „Generic Grade" für alles, was keine trägt.
|
||||
--
|
||||
-- Die Spalte füllt im Cornerstone-Extrakt die „Grade ID". Solange dort A–F
|
||||
-- steht, ist die Datei in jeder Zeile falsch, ohne dass es beim Erzeugen
|
||||
-- auffällt: das Zielsystem kennt diese Kennungen nicht.
|
||||
--
|
||||
-- ═══ 2. Warum eine Domain und kein Aufzählungstyp ══════════════════
|
||||
--
|
||||
-- `paygrade_type` war ein Enum. Ein Enum lässt sich nicht umschreiben:
|
||||
-- Werte entfernen geht gar nicht, und `alter type … add value` darf im
|
||||
-- selben Vorgang, der den neuen Wert schreibt, nicht benutzt werden — die
|
||||
-- Umstellung bräuchte also zwei Migrationen und liesse dazwischen eine
|
||||
-- Datenbank mit beiden Listen zurück.
|
||||
--
|
||||
-- Der Typ **heisst** deshalb weiter `paygrade_type`, ist aber jetzt eine
|
||||
-- Domain über text mit CHECK. Das ist die Form, die dieses Projekt für
|
||||
-- kundenseitige Listen ohnehin schon führt (Abwesenheitsart, Personenkreis,
|
||||
-- Mitarbeiterart) — und, wichtiger: die rund zehn SQL-Funktionen, die
|
||||
-- `(payload->>'paygrade')::paygrade_type` schreiben, bleiben unverändert
|
||||
-- gültig. Jede von ihnen neu zu erzeugen hiesse, zehnmal die Gelegenheit zu
|
||||
-- haben, aus einer veralteten Vorlage zu kopieren; genau daran hat dieses
|
||||
-- Projekt schon dreimal Verhalten verloren.
|
||||
--
|
||||
-- Eine einzige Stelle muss doch angefasst werden: `hire_employee` setzt als
|
||||
-- Vorgabe das Literal 'B', und das besteht den CHECK nicht mehr. Sie wird
|
||||
-- unten **nicht** aus einer Datei kopiert, sondern aus der laufenden
|
||||
-- Definition gelesen und an genau dieser Stelle geändert.
|
||||
|
||||
-- ═══ 3. Hängt der Typ noch woanders? ══════════════════════════════
|
||||
--
|
||||
-- Die Dateien sagen: nur employees.paygrade. Die Datenbank ist die Quelle,
|
||||
-- nicht die Dateien — also gefragt statt angenommen. Hinge eine zweite
|
||||
-- Spalte daran, scheiterte der DROP weiter unten ohnehin, aber mit einer
|
||||
-- Meldung, die nicht sagt, was los ist.
|
||||
do $$
|
||||
declare
|
||||
v_spalten text;
|
||||
begin
|
||||
select string_agg(c.relname || '.' || a.attname, ', ')
|
||||
into v_spalten
|
||||
from pg_attribute a
|
||||
join pg_class c on c.oid = a.attrelid
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where a.atttypid = 'paygrade_type'::regtype
|
||||
and a.attnum > 0
|
||||
and not a.attisdropped
|
||||
and n.nspname = 'public'
|
||||
and not (c.relname = 'employees' and a.attname = 'paygrade');
|
||||
|
||||
if v_spalten is not null then
|
||||
raise exception 'paygrade_type wird noch benutzt von: %. Umstellung abgebrochen.', v_spalten;
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
|
||||
-- ═══ 4. Offene datierte Beförderungen ═════════════════════════════
|
||||
--
|
||||
-- Eine Beförderung mit Datum in der Zukunft liegt als pending-Zeile mit
|
||||
-- `new_paygrade` im Nutzdatensatz. Steht dort A–F, scheiterte der Nachtlauf
|
||||
-- Wochen später an der Umwandlung — um drei Uhr früh und ohne jemanden, dem
|
||||
-- die Meldung zugestellt würde. Lieber jetzt und laut.
|
||||
do $$
|
||||
declare
|
||||
v_anzahl int;
|
||||
begin
|
||||
select count(*) into v_anzahl
|
||||
from pending_org_changes
|
||||
where status = 'pending'
|
||||
and payload ? 'new_paygrade'
|
||||
and payload->>'new_paygrade' not in
|
||||
('-','HG09','HG10','HG11','HG12','HG13','HG14','HG15','HG16','HG17','HG18','HG19','HG19P','HG20');
|
||||
|
||||
if v_anzahl > 0 then
|
||||
raise exception
|
||||
'Es warten % geplante Beförderungen auf eine Verwendungsgruppe, die es nicht mehr gibt. Erst entscheiden, dann umstellen.',
|
||||
v_anzahl;
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
|
||||
-- ═══ 5. Der Typwechsel ════════════════════════════════════════════
|
||||
alter table employees alter column paygrade drop default;
|
||||
alter table employees alter column paygrade type text using paygrade::text;
|
||||
|
||||
drop type paygrade_type;
|
||||
|
||||
create domain paygrade_type as text
|
||||
constraint chk_hay_grade check (
|
||||
value in ('-','HG09','HG10','HG11','HG12','HG13','HG14','HG15','HG16','HG17','HG18','HG19','HG19P','HG20')
|
||||
);
|
||||
|
||||
-- Der Bestand bekommt „Generic Grade". Aus A–F liesse sich kein Hay-Grade
|
||||
-- ableiten: andere Einteilung, andere Anzahl. Geraten sähe im Extrakt
|
||||
-- genauso aus wie erhoben — und wäre nicht mehr davon zu unterscheiden.
|
||||
update employees
|
||||
set paygrade = '-'
|
||||
where paygrade not in ('-','HG09','HG10','HG11','HG12','HG13','HG14','HG15','HG16','HG17','HG18','HG19','HG19P','HG20');
|
||||
|
||||
alter table employees alter column paygrade type paygrade_type using paygrade::paygrade_type;
|
||||
alter table employees alter column paygrade set default '-';
|
||||
|
||||
-- ═══ 6. hire_employee: Vorgabewert 'B' → '-' ══════════════════════
|
||||
--
|
||||
-- Aus der laufenden Definition gelesen, nicht aus einer Migrationsdatei
|
||||
-- kopiert: ein Teil der Funktionen dieses Projekts ist nachträglich
|
||||
-- dynamisch gepatcht worden, die Summe der Dateien ist also nicht das, was
|
||||
-- in der Datenbank steht. Der Anker muss genau einmal vorkommen — käme er
|
||||
-- keinmal vor, hätte jemand die Vorgabe schon geändert und wir überschrieben
|
||||
-- etwas Unbekanntes; käme er zweimal vor, träfe die Ersetzung eine Stelle,
|
||||
-- die hier niemand angeschaut hat.
|
||||
do $migration$
|
||||
declare
|
||||
v_alt constant text := $anker$coalesce((payload->>'paygrade')::paygrade_type, 'B')$anker$;
|
||||
v_neu constant text := $anker$coalesce((payload->>'paygrade')::paygrade_type, '-')$anker$;
|
||||
v_def text;
|
||||
v_anzahl int;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid)
|
||||
into v_def
|
||||
from pg_proc p
|
||||
join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public'
|
||||
and p.proname = 'hire_employee'
|
||||
and p.prokind = 'f';
|
||||
|
||||
if v_def is null then
|
||||
raise exception 'hire_employee ist nicht vorhanden.';
|
||||
end if;
|
||||
|
||||
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
|
||||
if v_anzahl <> 1 then
|
||||
raise exception 'Der Vorgabewert kommt % mal vor, erwartet genau einmal.', v_anzahl;
|
||||
end if;
|
||||
|
||||
execute replace(v_def, v_alt, v_neu);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
-- ═══ 6b. promote_employee: die Beschriftung im Protokoll ══════════
|
||||
--
|
||||
-- Die Funktion schreibt den Grund einer Beförderung als Klartext nach
|
||||
-- audit_log und employee_history: „…, neue Verwendungsgruppe: C". Diese
|
||||
-- Zeilen liest der Kunde. Bliebe das Wort stehen, hiesse ein Feld in der
|
||||
-- Maske anders als im Protokoll darüber — dieselbe Änderung unter zwei
|
||||
-- Namen. Bestehende Einträge bleiben, wie sie sind: Historie wird
|
||||
-- fortgeschrieben, nicht umgeschrieben.
|
||||
do $migration$
|
||||
declare
|
||||
v_alt constant text := $anker$, neue Verwendungsgruppe: $anker$;
|
||||
v_neu constant text := $anker$, neuer Hay-Grade: $anker$;
|
||||
v_def text;
|
||||
v_anzahl int;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid)
|
||||
into v_def
|
||||
from pg_proc p
|
||||
join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public'
|
||||
and p.proname = 'promote_employee'
|
||||
and p.prokind = 'f';
|
||||
|
||||
if v_def is null then
|
||||
raise exception 'promote_employee ist nicht vorhanden.';
|
||||
end if;
|
||||
|
||||
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
|
||||
if v_anzahl <> 1 then
|
||||
raise exception 'Die Beschriftung kommt % mal vor, erwartet genau einmal.', v_anzahl;
|
||||
end if;
|
||||
|
||||
execute replace(v_def, v_alt, v_neu);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
-- ═══ 7. Selbstprüfung ═════════════════════════════════════════════
|
||||
--
|
||||
-- Geprüft wird nicht nur das Neue, sondern auch das, was bei früheren
|
||||
-- Neuerzeugungen schon einmal still verschwunden ist: Rechteprüfung,
|
||||
-- search_path und die zuletzt hinzugekommenen Felder. `hire_employee` ist
|
||||
-- gerade neu erzeugt worden — wenn dabei etwas abhanden gekommen wäre, wäre
|
||||
-- es hier zu sehen und nirgends sonst.
|
||||
do $$
|
||||
declare
|
||||
v_def text;
|
||||
v_werte int;
|
||||
begin
|
||||
select count(*) into v_werte
|
||||
from pg_constraint
|
||||
where conname = 'chk_hay_grade'
|
||||
and contypid = 'paygrade_type'::regtype;
|
||||
if v_werte <> 1 then
|
||||
raise exception 'Die Bedingung chk_hay_grade fehlt an paygrade_type.';
|
||||
end if;
|
||||
|
||||
if exists (select 1 from employees where paygrade::text = 'B') then
|
||||
raise exception 'Es stehen noch Verwendungsgruppen in employees.paygrade.';
|
||||
end if;
|
||||
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'hire_employee' and p.prokind = 'f';
|
||||
|
||||
if v_def not like '%paygrade_type, ''-''%' then
|
||||
raise exception 'hire_employee traegt den neuen Vorgabewert nicht.';
|
||||
end if;
|
||||
if v_def like '%paygrade_type, ''B''%' then
|
||||
raise exception 'hire_employee traegt noch den alten Vorgabewert.';
|
||||
end if;
|
||||
if v_def not like '%require_hr_admin()%' then
|
||||
raise exception 'hire_employee hat die Rechtepruefung verloren.';
|
||||
end if;
|
||||
if v_def not like '%search_path%' then
|
||||
raise exception 'hire_employee hat den search_path verloren.';
|
||||
end if;
|
||||
if v_def not like '%cornerstone_id%' then
|
||||
raise exception 'hire_employee hat die Cornerstone-ID verloren.';
|
||||
end if;
|
||||
if v_def not like '%company_email%' then
|
||||
raise exception 'hire_employee hat die Firmen-E-Mail verloren.';
|
||||
end if;
|
||||
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'promote_employee' and p.prokind = 'f';
|
||||
|
||||
if v_def not like '%neuer Hay-Grade%' then
|
||||
raise exception 'promote_employee traegt die neue Beschriftung nicht.';
|
||||
end if;
|
||||
if v_def not like '%require_hr_admin()%' then
|
||||
raise exception 'promote_employee hat die Rechtepruefung verloren.';
|
||||
end if;
|
||||
-- Die Zielplanstelle kam erst mit 20260924100000 dazu und ist genau die Art
|
||||
-- Verhalten, die bei einer Neuerzeugung schon einmal still verschwunden ist.
|
||||
if v_def not like '%target_position_id%' then
|
||||
raise exception 'promote_employee hat die Zielplanstelle verloren.';
|
||||
end if;
|
||||
|
||||
raise notice 'Hay-Grade steht, hire_employee und promote_employee vollstaendig.';
|
||||
end
|
||||
$$;
|
||||
199
db/migrations/20260929100000_hay_grade_in_daten_aendern.sql
Normal file
199
db/migrations/20260929100000_hay_grade_in_daten_aendern.sql
Normal file
@@ -0,0 +1,199 @@
|
||||
-- Der Hay-Grade in „Daten ändern"
|
||||
--
|
||||
-- Die Beförderung konnte ihn schon setzen, „Daten ändern" nicht: weder das
|
||||
-- Formular noch change_employee_data kannten das Feld. Damit war eine
|
||||
-- Einstufung nur über den Weg „Beförderung" zu ändern — und eine Korrektur
|
||||
-- („da stand von Anfang an die falsche Stufe") ist keine Beförderung, sondern
|
||||
-- eine Richtigstellung, die auch keine Planstelle wechseln soll.
|
||||
--
|
||||
-- Das Feld gehört zur Gruppe `contract` und ist damit **datiert**: eine
|
||||
-- Umstufung gilt ab einem Tag, nicht rückwirkend seit immer. Das heisst, sie
|
||||
-- kann in pending_org_changes landen — und deshalb steht sie unten auch im
|
||||
-- Nachtlauf. Genau diese zweite Stelle ist in diesem Projekt schon einmal
|
||||
-- vergessen worden: die Gruppe `role` fehlte dort monatelang, und eine
|
||||
-- datierte Änderung an Betriebsrat oder Kollektivvertrag wurde als `applied`
|
||||
-- vermerkt, ohne etwas zu tun.
|
||||
--
|
||||
-- Drittens app_feld_karte: ohne Eintrag dort liesse sich der Eintrag in der
|
||||
-- Historie hinterher nicht richtigstellen.
|
||||
--
|
||||
-- Alle drei Funktionen werden **nicht** aus einer Datei kopiert, sondern aus
|
||||
-- der laufenden Definition gelesen und an genau einem Anker ergänzt. Jeder
|
||||
-- Anker muss genau einmal vorkommen; sonst bricht die Migration ab, statt an
|
||||
-- einer Stelle zu schreiben, die hier niemand angesehen hat.
|
||||
|
||||
-- ═══ 1. change_employee_data ══════════════════════════════════════
|
||||
do $migration$
|
||||
declare
|
||||
-- Der Eintrag im Änderungsprotokoll …
|
||||
v_alt_log constant text := $anker$if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;$anker$;
|
||||
v_neu_log constant text := $anker$if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
|
||||
if v_contract ? 'paygrade' then v_contract_changes := app_aenderung(v_contract_changes, 'Hay-Grade', v_old.paygrade::text, v_contract->>'paygrade'); end if;$anker$;
|
||||
|
||||
-- … und die Spalte selbst. coalesce und nicht nullif: die Stufe ist NOT NULL,
|
||||
-- sie lässt sich nicht leeren, nur wechseln.
|
||||
v_alt_upd constant text := $anker$contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),$anker$;
|
||||
v_neu_upd constant text := $anker$contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
|
||||
paygrade = coalesce((v_contract->>'paygrade')::paygrade_type, paygrade),$anker$;
|
||||
|
||||
v_def text;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'change_employee_data' and p.prokind = 'f';
|
||||
|
||||
if v_def is null then
|
||||
raise exception 'change_employee_data ist nicht vorhanden.';
|
||||
end if;
|
||||
|
||||
if (length(v_def) - length(replace(v_def, v_alt_log, ''))) / length(v_alt_log) <> 1 then
|
||||
raise exception 'Der Anker für das Protokoll kommt nicht genau einmal vor.';
|
||||
end if;
|
||||
if (length(v_def) - length(replace(v_def, v_alt_upd, ''))) / length(v_alt_upd) <> 1 then
|
||||
raise exception 'Der Anker für die Spalte kommt nicht genau einmal vor.';
|
||||
end if;
|
||||
if v_def like '%paygrade%' then
|
||||
raise exception 'change_employee_data kennt den Hay-Grade bereits.';
|
||||
end if;
|
||||
|
||||
execute replace(replace(v_def, v_alt_log, v_neu_log), v_alt_upd, v_neu_upd);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
-- ═══ 2. apply_due_pending_changes ═════════════════════════════════
|
||||
do $migration$
|
||||
declare
|
||||
v_alt constant text := $anker$contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type),$anker$;
|
||||
v_neu constant text := $anker$contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type),
|
||||
paygrade = coalesce((v_rec.payload->'contract'->>'paygrade')::paygrade_type, paygrade),$anker$;
|
||||
v_def text;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prokind = 'f';
|
||||
|
||||
if v_def is null then
|
||||
raise exception 'apply_due_pending_changes ist nicht vorhanden.';
|
||||
end if;
|
||||
|
||||
if (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt) <> 1 then
|
||||
raise exception 'Der Anker im Nachtlauf kommt nicht genau einmal vor.';
|
||||
end if;
|
||||
|
||||
execute replace(v_def, v_alt, v_neu);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
-- ═══ 3. app_feld_karte ════════════════════════════════════════════
|
||||
do $migration$
|
||||
declare
|
||||
-- Der zweite Eintrag ist der Typ, den update_history_entry unmittelbar als
|
||||
-- Umwandlung ins SQL setzt. paygrade_type ist seit 20260928140000 eine
|
||||
-- Domain über text; die Prüfung der Stufe passiert damit dort, wo sie
|
||||
-- hingehört, und nicht an einer zweiten Liste in dieser Funktion.
|
||||
v_alt constant text := $anker$jsonb_build_array('contract_end_date', 'date', 'contract'),$anker$;
|
||||
v_neu constant text := $anker$jsonb_build_array('contract_end_date', 'date', 'contract'),
|
||||
'Hay-Grade', jsonb_build_array('paygrade', 'paygrade_type', 'contract'),$anker$;
|
||||
v_def text;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'app_feld_karte' and p.prokind = 'f';
|
||||
|
||||
if v_def is null then
|
||||
raise exception 'app_feld_karte ist nicht vorhanden.';
|
||||
end if;
|
||||
|
||||
if (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt) <> 1 then
|
||||
raise exception 'Der Anker in der Feldkarte kommt nicht genau einmal vor.';
|
||||
end if;
|
||||
|
||||
execute replace(v_def, v_alt, v_neu);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
-- ═══ 4. Selbstprüfung ═════════════════════════════════════════════
|
||||
--
|
||||
-- Geprüft wird nicht nur das Neue, sondern auch das, was bei früheren
|
||||
-- Neuerzeugungen in diesem Projekt schon einmal still verschwunden ist.
|
||||
do $$
|
||||
declare
|
||||
v_def text;
|
||||
v_anz int;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'change_employee_data' and p.prokind = 'f';
|
||||
|
||||
if v_def not like '%''Hay-Grade''%' then
|
||||
raise exception 'change_employee_data protokolliert den Hay-Grade nicht.';
|
||||
end if;
|
||||
if v_def not like '%v_contract->>''paygrade''%' then
|
||||
raise exception 'change_employee_data schreibt den Hay-Grade nicht.';
|
||||
end if;
|
||||
if v_def not like '%require_hr_admin()%' then
|
||||
raise exception 'change_employee_data hat die Rechtepruefung verloren.';
|
||||
end if;
|
||||
if v_def not like '%search_path%' then
|
||||
raise exception 'change_employee_data hat den search_path verloren.';
|
||||
end if;
|
||||
if v_def not like '%cornerstone_id%' then
|
||||
raise exception 'change_employee_data hat die Cornerstone-ID verloren.';
|
||||
end if;
|
||||
-- Die Form, die eine leere Angabe als null schreibt statt als '' (siehe
|
||||
-- 20260923160000). Ginge sie verloren, scheiterte die zweite Person ohne
|
||||
-- E-Mail am eindeutigen Index.
|
||||
if v_def not like '%nullif(v_person->>''email'', '''')%' then
|
||||
raise exception 'change_employee_data hat die Leerstring-Behandlung verloren.';
|
||||
end if;
|
||||
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prokind = 'f';
|
||||
|
||||
if v_def not like '%''contract''->>''paygrade''%' then
|
||||
raise exception 'Der Nachtlauf wendet den Hay-Grade nicht an.';
|
||||
end if;
|
||||
if v_def not like '%v_rec.payload->''role''%' then
|
||||
raise exception 'Der Nachtlauf hat die Gruppe role verloren.';
|
||||
end if;
|
||||
-- Zwei Zweige hängen eine Besetzung um: die Versetzung und die Beförderung
|
||||
-- auf eine andere Planstelle. Gezählt und nicht bloss auf Vorhandensein
|
||||
-- geprüft — einer der beiden ist hier schon einmal verschwunden.
|
||||
v_anz := (length(v_def) - length(replace(v_def, 'insert into position_assignments', '')))
|
||||
/ length('insert into position_assignments');
|
||||
if v_anz <> 2 then
|
||||
raise exception 'Der Nachtlauf haengt die Besetzung % mal um, erwartet zweimal.', v_anz;
|
||||
end if;
|
||||
-- Und was zuletzt hier verlorenging, bleibt drin.
|
||||
if v_def not like '%emergency_contact_name%' then
|
||||
raise exception 'Der Nachtlauf hat den Notfallkontakt wieder verloren.';
|
||||
end if;
|
||||
if v_def not like '%target_position_id%' then
|
||||
raise exception 'Der Nachtlauf hat die Zielplanstelle wieder verloren.';
|
||||
end if;
|
||||
-- Der Lauf hat keine angemeldete Person: er arbeitet als SECURITY DEFINER
|
||||
-- und ruft deshalb **nicht** require_hr_admin(). Ginge das Kennzeichen bei
|
||||
-- einer Neuerzeugung verloren, liefe er nachts in die Zeilenschutz-Regeln
|
||||
-- und täte nichts mehr.
|
||||
if not exists (
|
||||
select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prosecdef
|
||||
) then
|
||||
raise exception 'Der Nachtlauf ist nicht mehr SECURITY DEFINER.';
|
||||
end if;
|
||||
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'app_feld_karte' and p.prokind = 'f';
|
||||
|
||||
if v_def not like '%''Hay-Grade''%' then
|
||||
raise exception 'Die Feldkarte kennt den Hay-Grade nicht.';
|
||||
end if;
|
||||
if v_def not like '%cornerstone_id%' then
|
||||
raise exception 'Die Feldkarte hat die Cornerstone-ID verloren.';
|
||||
end if;
|
||||
|
||||
raise notice 'Hay-Grade steht in Daten aendern, im Nachtlauf und in der Feldkarte.';
|
||||
end
|
||||
$$;
|
||||
156
db/migrations/20260929120000_organisationseinheit_anlegen.sql
Normal file
156
db/migrations/20260929120000_organisationseinheit_anlegen.sql
Normal file
@@ -0,0 +1,156 @@
|
||||
-- Eine Organisationseinheit aus der Anwendung heraus anlegen
|
||||
--
|
||||
-- Bisher gab es dafür nur den Import — und für alles andere an einer Einheit
|
||||
-- (umbenennen, verschieben, schliessen) gibt es weiterhin nur die Datenbank.
|
||||
-- Das hier ist bewusst nur der eine Fall: **eine neue Einheit unter einer
|
||||
-- bestehenden**.
|
||||
--
|
||||
-- Warum gerade der: er ist der einzige, der nichts umschreibt. Die neue
|
||||
-- Einheit ist immer ein Blatt, also
|
||||
--
|
||||
-- * kann kein Kreis entstehen — und das ist keine Kleinigkeit: die
|
||||
-- rekursive Abfrage `ancestry` in om_reporting_lines hat weder
|
||||
-- Tiefenbegrenzung noch Kreiserkennung, ein Kreis in parent_id würde dort
|
||||
-- nicht zu einem falschen Ergebnis führen, sondern zu keinem;
|
||||
-- * wird keine Vergangenheit verändert. org_units.parent_id trägt kein
|
||||
-- Datum: wer eine bestehende Einheit umhängt, ändert damit auch jede
|
||||
-- Auswertung auf einen vergangenen Stichtag, und der frühere Stand ist
|
||||
-- danach nirgends mehr ablesbar. Beim Anlegen stellt sich die Frage
|
||||
-- nicht, weil vorher nichts da war.
|
||||
--
|
||||
-- Das Verschieben bestehender Einheiten braucht deshalb eine Entscheidung
|
||||
-- (Vergangenheit überschreiben oder die Aufhängung datieren) und steht
|
||||
-- absichtlich noch nicht hier.
|
||||
|
||||
create or replace function create_org_unit(payload jsonb)
|
||||
returns uuid language plpgsql
|
||||
set search_path = public, pg_temp
|
||||
as $$
|
||||
declare
|
||||
v_parent_id uuid := (payload->>'parent_id')::uuid;
|
||||
v_org_number text := nullif(trim(payload->>'org_number'), '');
|
||||
v_name text := nullif(trim(payload->>'name'), '');
|
||||
v_unit_type text := nullif(trim(payload->>'unit_type'), '');
|
||||
v_valid_from date := coalesce(nullif(payload->>'valid_from','')::date, current_date);
|
||||
v_leitung text := nullif(trim(payload->>'leitung_taetigkeit'), '');
|
||||
v_parent record;
|
||||
v_id uuid;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
if v_parent_id is null then
|
||||
-- Eine Einheit ohne Übergeordnete wäre eine zweite Wurzel. Es gibt genau
|
||||
-- eine, und sie entsteht nicht hier.
|
||||
raise exception 'Es muss eine übergeordnete Einheit angegeben werden.';
|
||||
end if;
|
||||
|
||||
select id, org_number, name, valid_from, valid_to
|
||||
into v_parent
|
||||
from org_units
|
||||
where id = v_parent_id;
|
||||
if v_parent.id is null then
|
||||
raise exception 'Die übergeordnete Einheit existiert nicht.';
|
||||
end if;
|
||||
|
||||
if v_org_number is null then
|
||||
raise exception 'Es muss eine Orgnummer angegeben werden.';
|
||||
end if;
|
||||
if v_name is null then
|
||||
raise exception 'Es muss eine Bezeichnung angegeben werden.';
|
||||
end if;
|
||||
if v_unit_type is null then
|
||||
raise exception 'Es muss eine Art angegeben werden.';
|
||||
end if;
|
||||
|
||||
-- Die Gesellschaft ist das Ganze und steht über allem. Als Kind einer
|
||||
-- anderen Einheit wäre sie ein Etikett, das seiner Stelle im Baum
|
||||
-- widerspricht — und genau daran ist in diesem Projekt schon dreimal Code
|
||||
-- zerbrochen, der aus dem Etikett auf die Ebene geschlossen hat.
|
||||
if v_unit_type = 'Gesellschaft' then
|
||||
raise exception 'Die Gesellschaft ist die Wurzel und kann nicht unter einer anderen Einheit stehen.';
|
||||
end if;
|
||||
|
||||
-- Der eindeutige Index würde das ebenfalls abfangen, aber mit einer
|
||||
-- Meldung, die in der Oberfläche nichts erklärt.
|
||||
if exists (select 1 from org_units where org_number = v_org_number) then
|
||||
raise exception 'Die Orgnummer % ist bereits vergeben.', v_org_number;
|
||||
end if;
|
||||
|
||||
-- Eine Einheit, die früher beginnt als die über ihr, hängt an einem Tag im
|
||||
-- Baum, an dem es diesen Baum noch nicht gab. Auf einen solchen Stichtag
|
||||
-- ausgewertet stünde sie ohne Vorfahren da.
|
||||
if v_valid_from < v_parent.valid_from then
|
||||
raise exception 'Die übergeordnete Einheit gilt erst ab %. Ein früherer Beginn ist nicht möglich.', v_parent.valid_from;
|
||||
end if;
|
||||
if v_parent.valid_to is not null and v_valid_from >= v_parent.valid_to then
|
||||
raise exception 'Die übergeordnete Einheit gilt nur bis %.', v_parent.valid_to;
|
||||
end if;
|
||||
|
||||
insert into org_units (org_number, name, parent_id, unit_type, valid_from)
|
||||
values (v_org_number, v_name, v_parent_id, v_unit_type::org_unit_type, v_valid_from)
|
||||
returning id into v_id;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
|
||||
values (app_current_user_id(), current_actor_name(), 'Organisationseinheit angelegt',
|
||||
v_org_number || ' · ' || v_name,
|
||||
'Unter ' || v_parent.org_number || ' · ' || v_parent.name ||
|
||||
', Art ' || v_unit_type || ', gültig ab ' || v_valid_from ||
|
||||
case when v_leitung is not null then ', mit Leitungsplanstelle „' || v_leitung || '“' else '' end);
|
||||
|
||||
-- Die Leitungsplanstelle entsteht über create_position und nicht durch eine
|
||||
-- zweite Fassung derselben Logik. Dort hängen der Jobkatalog (gleiche
|
||||
-- Tätigkeit = ein Eintrag), die Nummernvergabe und die Prüfung „je Einheit
|
||||
-- genau eine Leitung“ — nachgebaut liefe das hier beim ersten Zusatz
|
||||
-- auseinander. Sie schreibt ihren eigenen Protokolleintrag.
|
||||
if v_leitung is not null then
|
||||
perform create_position(jsonb_build_object(
|
||||
'org_unit_id', v_id,
|
||||
'job_title', v_leitung,
|
||||
'is_chief', true,
|
||||
'valid_from', v_valid_from
|
||||
));
|
||||
end if;
|
||||
|
||||
return v_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
comment on function create_org_unit(jsonb) is
|
||||
'Legt eine Organisationseinheit unter einer bestehenden an, wahlweise gleich mit Leitungsplanstelle. Nur anlegen: umbenennen, verschieben und schliessen gibt es bewusst nicht, weil parent_id kein Datum trägt.';
|
||||
|
||||
grant execute on function create_org_unit(jsonb) to alpenwerk_app;
|
||||
|
||||
|
||||
-- Selbstprüfung.
|
||||
do $$
|
||||
declare
|
||||
v_def text;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'create_org_unit' and p.prokind = 'f';
|
||||
|
||||
if v_def is null then
|
||||
raise exception 'create_org_unit wurde nicht angelegt';
|
||||
end if;
|
||||
if v_def not like '%require_hr_admin()%' then
|
||||
raise exception 'create_org_unit prüft die Rechte nicht';
|
||||
end if;
|
||||
-- Ohne search_path liesse sich der Funktion über einen eigenen Schemapfad
|
||||
-- eine andere Tabelle unterschieben. Hauskonvention für jede Funktion.
|
||||
if v_def not like '%search_path%' then
|
||||
raise exception 'create_org_unit hat keinen festen search_path';
|
||||
end if;
|
||||
if v_def not like '%audit_log%' then
|
||||
raise exception 'create_org_unit schreibt nichts ins Protokoll';
|
||||
end if;
|
||||
-- Nicht nachgebaut, sondern aufgerufen — siehe den Kommentar dort.
|
||||
if v_def not like '%create_position(%' then
|
||||
raise exception 'create_org_unit legt die Leitungsplanstelle nicht über create_position an';
|
||||
end if;
|
||||
-- Ohne Übergeordnete entstünde eine zweite Wurzel.
|
||||
if v_def not like '%parent_id%' then
|
||||
raise exception 'create_org_unit kennt die übergeordnete Einheit nicht';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
127
db/migrations/20260929140000_organisationseinheit_entfernen.sql
Normal file
127
db/migrations/20260929140000_organisationseinheit_entfernen.sql
Normal file
@@ -0,0 +1,127 @@
|
||||
-- Eine Organisationseinheit wieder entfernen
|
||||
--
|
||||
-- Das Gegenstück zu create_org_unit und mit derselben Einschränkung: es geht
|
||||
-- nur dort, wo nichts umgeschrieben wird.
|
||||
--
|
||||
-- ═══ Warum gelöscht und nicht geschlossen ═════════════════════════
|
||||
--
|
||||
-- delete_position macht es seit dem OM-Umbau vor: besetzt → Abweisung, einmal
|
||||
-- besetzt gewesen → `valid_to` statt Löschen (sonst verschwände mit der Stelle
|
||||
-- die Besetzungshistorie), nie besetzt gewesen → weg. Der Unterschied ist
|
||||
-- keine Vorsicht, sondern eine Aussage: was nie getragen hat, hat es auch nie
|
||||
-- gegeben.
|
||||
--
|
||||
-- Für Einheiten lässt sich davon heute nur die letzte Stufe umsetzen. Ein
|
||||
-- `valid_to` an einer Einheit wäre zwar eingetragen, aber **nirgends
|
||||
-- gelesen**: weder das Organigramm noch orgMapsAbfragen, die Berichte, der
|
||||
-- Druck oder die Auswahl beim Anlegen einer Planstelle schränken org_units auf
|
||||
-- den Stichtag ein. Die geschlossene Einheit stünde überall weiter da, nur mit
|
||||
-- einem Datum, das niemand sieht — das wäre schlechter als die klare
|
||||
-- Abweisung unten, weil es wie Erledigt aussieht.
|
||||
--
|
||||
-- Deshalb hier nur: eine Einheit, unter der nie etwas hing, verschwindet
|
||||
-- ganz. Alles andere wird abgewiesen, mit dem Grund. Das Schliessen zum
|
||||
-- Stichtag kommt, wenn org_units überall gegen den Stichtag gelesen wird —
|
||||
-- dieselbe Arbeit, die auch das Verschieben braucht.
|
||||
|
||||
create or replace function delete_org_unit(payload jsonb)
|
||||
returns void language plpgsql
|
||||
set search_path = public, pg_temp
|
||||
as $$
|
||||
declare
|
||||
v_id uuid := (payload->>'org_unit_id')::uuid;
|
||||
v_unit record;
|
||||
v_einheiten int;
|
||||
v_stellen int;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
select id, org_number, name, parent_id into v_unit from org_units where id = v_id;
|
||||
if v_unit.id is null then
|
||||
raise exception 'Die Organisationseinheit existiert nicht.';
|
||||
end if;
|
||||
|
||||
if v_unit.parent_id is null then
|
||||
raise exception 'Die Gesellschaft ist die Wurzel und kann nicht entfernt werden.';
|
||||
end if;
|
||||
|
||||
select count(*) into v_einheiten from org_units where parent_id = v_id;
|
||||
if v_einheiten > 0 then
|
||||
raise exception 'Unter dieser Einheit hängen noch % Einheiten. Sie müssen zuerst woanders hin.', v_einheiten;
|
||||
end if;
|
||||
|
||||
-- Geschlossene Planstellen zählen mit. Sie tragen weiterhin diese Einheit,
|
||||
-- und mit ihr verschwände die Angabe, wo jemand einmal gesessen hat.
|
||||
select count(*) into v_stellen from om_positions where org_unit_id = v_id;
|
||||
if v_stellen > 0 then
|
||||
raise exception
|
||||
'In dieser Einheit bestehen noch % Planstellen (geschlossene zählen mit). Sie müssen zuerst entfernt oder verschoben werden.',
|
||||
v_stellen;
|
||||
end if;
|
||||
|
||||
-- Die Kostenstellen tragen die Einheit nur als Hinweis (siehe Migration
|
||||
-- 20260816100000), nicht als Zuordnung. Sie stünden nach dem Löschen auf
|
||||
-- einer Einheit, die es nicht mehr gibt — der Fremdschlüssel würde das
|
||||
-- abweisen, also vorher und mit Grund.
|
||||
if exists (select 1 from cost_centers where org_unit_id = v_id) then
|
||||
raise exception 'Auf diese Einheit verweisen noch Kostenstellen.';
|
||||
end if;
|
||||
|
||||
delete from org_units where id = v_id;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
|
||||
values (app_current_user_id(), current_actor_name(), 'Organisationseinheit gelöscht',
|
||||
v_unit.org_number || ' · ' || v_unit.name,
|
||||
'Ohne untergeordnete Einheiten und ohne Planstellen');
|
||||
end;
|
||||
$$;
|
||||
|
||||
comment on function delete_org_unit(jsonb) is
|
||||
'Entfernt eine Organisationseinheit, unter der nichts hängt und nie etwas hing. Einheiten mit Planstellen — auch geschlossenen — oder mit untergeordneten Einheiten werden abgewiesen; ein Schliessen zum Stichtag gibt es noch nicht, weil org_units nirgends gegen den Stichtag gelesen wird.';
|
||||
|
||||
grant execute on function delete_org_unit(jsonb) to alpenwerk_app;
|
||||
|
||||
|
||||
-- Selbstprüfung.
|
||||
do $$
|
||||
declare
|
||||
v_def text;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'delete_org_unit' and p.prokind = 'f';
|
||||
|
||||
if v_def is null then
|
||||
raise exception 'delete_org_unit wurde nicht angelegt';
|
||||
end if;
|
||||
if v_def not like '%require_hr_admin()%' then
|
||||
raise exception 'delete_org_unit prüft die Rechte nicht';
|
||||
end if;
|
||||
if v_def not like '%search_path%' then
|
||||
raise exception 'delete_org_unit hat keinen festen search_path';
|
||||
end if;
|
||||
if v_def not like '%audit_log%' then
|
||||
raise exception 'delete_org_unit schreibt nichts ins Protokoll';
|
||||
end if;
|
||||
-- Die drei Abweisungen. Fällt eine bei einer Neuerzeugung weg, löscht die
|
||||
-- Funktion weiter — nur mehr, als sie darf.
|
||||
if v_def not like '%om_positions%' then
|
||||
raise exception 'delete_org_unit prüft die Planstellen nicht';
|
||||
end if;
|
||||
if v_def not like '%parent_id = v_id%' then
|
||||
raise exception 'delete_org_unit prüft die untergeordneten Einheiten nicht';
|
||||
end if;
|
||||
if v_def not like '%v_unit.parent_id is null%' then
|
||||
raise exception 'delete_org_unit schützt die Wurzel nicht';
|
||||
end if;
|
||||
|
||||
-- Das Gegenstück muss weiterhin dastehen: ein Löschen ohne Anlegen wäre
|
||||
-- eine Einbahnstrasse.
|
||||
if not exists (
|
||||
select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'create_org_unit'
|
||||
) then
|
||||
raise exception 'create_org_unit fehlt';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
103
db/migrations/20260929160000_wiedereintritt_status_cast.sql
Normal file
103
db/migrations/20260929160000_wiedereintritt_status_cast.sql
Normal file
@@ -0,0 +1,103 @@
|
||||
-- Wiedereintritt: der Umwandlungsschritt beim Status, zum zweiten Mal
|
||||
--
|
||||
-- rehire_employee scheitert bei jedem Aufruf mit
|
||||
--
|
||||
-- column "status" is of type employment_status but expression is of type text
|
||||
--
|
||||
-- Der case-Ausdruck liefert `text` (beide Zweige sind Literale ohne Typ), die
|
||||
-- Spalte ist ein Aufzählungstyp, und Postgres wandelt beim Zuweisen nicht von
|
||||
-- selbst um. Genau dieser Fehler wurde am 10.08. mit
|
||||
-- 20260810130000_fix_rehire_status_cast.sql behoben.
|
||||
--
|
||||
-- ═══ Warum er wieder da ist ═══════════════════════════════════════
|
||||
--
|
||||
-- Vier spätere Migrationen haben rehire_employee neu erzeugt und dabei den
|
||||
-- Zusatz nicht mitgenommen: 20260917100000 (Austrittsart), 20260917130000
|
||||
-- (Wiedereintritt mit Stammdaten), 20260923120000 (Firmen-E-Mail) und
|
||||
-- 20260928100000 (Cornerstone-ID).
|
||||
--
|
||||
-- Die ersten beiden haben den Rumpf aus einer älteren Datei kopiert — der
|
||||
-- Fall, vor dem der Abschnitt „create or replace aus veraltetem Muster" warnt.
|
||||
-- Die letzten beiden sind aus der **laufenden** Definition erzeugt worden und
|
||||
-- haben damit getreu weitergetragen, was seit dem 17.09. dort stand. Daraus
|
||||
-- die Lehre, die vorher nicht dastand: das Erzeugen aus dem laufenden Stand
|
||||
-- schützt davor, Verhalten zu **verlieren** — nicht davor, einen bereits
|
||||
-- vorhandenen Fehler **mitzunehmen**. Dagegen hilft nur eine Selbstprüfung,
|
||||
-- die das Erwartete benennt, statt nur das Neue zu prüfen.
|
||||
--
|
||||
-- Deshalb steht unten nicht bloss „der Zusatz ist da", sondern eine Prüfung,
|
||||
-- die jede künftige Neuerzeugung dieser Funktion mitnehmen kann.
|
||||
|
||||
do $migration$
|
||||
declare
|
||||
v_alt constant text := $anker$status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end,$anker$;
|
||||
v_neu constant text := $anker$status = (case when v_date <= current_date then 'Aktiv' else 'Geplant' end)::employment_status,$anker$;
|
||||
v_def text;
|
||||
v_anzahl int;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'rehire_employee' and p.prokind = 'f';
|
||||
|
||||
if v_def is null then
|
||||
raise exception 'rehire_employee ist nicht vorhanden.';
|
||||
end if;
|
||||
|
||||
-- Schon in Ordnung? Dann nichts tun. Die Migration soll auch auf einer
|
||||
-- Datenbank durchlaufen, auf der die Funktion aus einer künftigen Datei mit
|
||||
-- Umwandlung entstanden ist.
|
||||
if v_def like '%end)::employment_status%' then
|
||||
raise notice 'rehire_employee wandelt den Status bereits um.';
|
||||
return;
|
||||
end if;
|
||||
|
||||
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
|
||||
if v_anzahl <> 1 then
|
||||
raise exception 'Die Statuszuweisung kommt % mal vor, erwartet genau einmal.', v_anzahl;
|
||||
end if;
|
||||
|
||||
execute replace(v_def, v_alt, v_neu);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
|
||||
-- Selbstprüfung.
|
||||
--
|
||||
-- Geprüft wird die Umwandlung **und** alles, was an dieser Funktion schon
|
||||
-- einmal verlorengegangen ist. Wer sie das nächste Mal neu erzeugt, kopiert
|
||||
-- diesen Block am besten mit.
|
||||
do $$
|
||||
declare
|
||||
v_def text;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'rehire_employee' and p.prokind = 'f';
|
||||
|
||||
if v_def not like '%end)::employment_status%' then
|
||||
raise exception 'rehire_employee wandelt den Status nicht um — der Wiedereintritt scheitert bei jedem Aufruf.';
|
||||
end if;
|
||||
if v_def not like '%require_hr_admin()%' then
|
||||
raise exception 'rehire_employee prueft die Rechte nicht.';
|
||||
end if;
|
||||
if v_def not like '%search_path%' then
|
||||
raise exception 'rehire_employee hat keinen festen search_path.';
|
||||
end if;
|
||||
-- Die Stammdaten, die der Wiedereintritt seit 20260917130000 mitschreibt.
|
||||
-- Ohne sie zeigt der Assistent Felder an und wirft ihre Werte weg.
|
||||
if v_def not like '%cornerstone_id%' then
|
||||
raise exception 'rehire_employee hat die Cornerstone-ID verloren.';
|
||||
end if;
|
||||
if v_def not like '%company_email%' then
|
||||
raise exception 'rehire_employee hat die Firmen-E-Mail verloren.';
|
||||
end if;
|
||||
if v_def not like '%title_prefix%' then
|
||||
raise exception 'rehire_employee hat die Titel verloren.';
|
||||
end if;
|
||||
if v_def not like '%paygrade%' then
|
||||
raise exception 'rehire_employee hat den Hay-Grade verloren.';
|
||||
end if;
|
||||
|
||||
raise notice 'rehire_employee: Statusumwandlung und Stammdaten vollstaendig.';
|
||||
end
|
||||
$$;
|
||||
177
db/migrations/20260929180000_besetzung_am_selben_tag.sql
Normal file
177
db/migrations/20260929180000_besetzung_am_selben_tag.sql
Normal file
@@ -0,0 +1,177 @@
|
||||
-- Versetzung und Beförderung am selben Tag, an dem die Besetzung begann
|
||||
--
|
||||
-- Gemeldet beim Nachtest am 29.09.: eine Beförderung auf eine andere
|
||||
-- Planstelle scheiterte mit
|
||||
--
|
||||
-- new row for relation "position_assignments" violates check constraint
|
||||
-- "chk_assignment_range"
|
||||
--
|
||||
-- Die Person war am selben Tag schon einmal versetzt worden. Ihre laufende
|
||||
-- Besetzung begann also heute, und der Code schliesst sie auf denselben Tag:
|
||||
--
|
||||
-- update position_assignments set valid_to = <wirksam ab> where valid_to is null;
|
||||
-- insert into position_assignments (…) values (…, <wirksam ab>);
|
||||
--
|
||||
-- `chk_assignment_range` verlangt `valid_to > valid_from`; ein Intervall von
|
||||
-- null Tagen ist verboten. Das ist richtig so — und der Fall ist im Haus auch
|
||||
-- schon einmal gelöst worden: terminate_employee **löscht** die Besetzung beim
|
||||
-- Grund „No Show", statt sie auf [Eintritt, Eintritt) zu schliessen, mit der
|
||||
-- Begründung, dass niemand sie je innehatte. Dieselbe Begründung gilt hier:
|
||||
-- eine Besetzung, die am Tag ihres Beginns wieder endet, hat nicht
|
||||
-- stattgefunden.
|
||||
--
|
||||
-- Die Alternative — die zweite Versetzung am selben Tag abzuweisen — wäre
|
||||
-- genau der häufigste Fall: jemand hat die falsche Planstelle erwischt und
|
||||
-- will es sofort richtigstellen. Die Ereignisse in employee_history bleiben
|
||||
-- beide stehen; sichtbar ist also weiterhin, dass zweimal verschoben wurde.
|
||||
--
|
||||
-- Betroffen sind vier Stellen mit demselben Paar aus Schliessen und Einfügen:
|
||||
-- transfer_employee, promote_employee und im Nachtlauf die Zweige `transfer`
|
||||
-- und `promotion`. Alle vier werden aus der laufenden Definition gelesen und
|
||||
-- an einem Anker ergänzt.
|
||||
|
||||
-- ═══ 1. transfer_employee ═════════════════════════════════════════
|
||||
do $migration$
|
||||
declare
|
||||
v_alt constant text := $anker$update position_assignments set valid_to = v_effective$anker$;
|
||||
v_neu constant text := $anker$delete from position_assignments
|
||||
where employee_id = v_employee_id and valid_to is null and valid_from >= v_effective;
|
||||
update position_assignments set valid_to = v_effective$anker$;
|
||||
v_def text;
|
||||
v_anzahl int;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'transfer_employee' and p.prokind = 'f';
|
||||
|
||||
if v_def is null then
|
||||
raise exception 'transfer_employee ist nicht vorhanden.';
|
||||
end if;
|
||||
|
||||
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
|
||||
if v_anzahl <> 1 then
|
||||
raise exception 'Der Anker in transfer_employee kommt % mal vor, erwartet einmal.', v_anzahl;
|
||||
end if;
|
||||
|
||||
execute replace(v_def, v_alt, v_neu);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
-- ═══ 2. promote_employee ══════════════════════════════════════════
|
||||
do $migration$
|
||||
declare
|
||||
v_alt constant text := $anker$update position_assignments set valid_to = v_effective_date$anker$;
|
||||
v_neu constant text := $anker$delete from position_assignments
|
||||
where employee_id = v_employee_id and valid_to is null and valid_from >= v_effective_date;
|
||||
update position_assignments set valid_to = v_effective_date$anker$;
|
||||
v_def text;
|
||||
v_anzahl int;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'promote_employee' and p.prokind = 'f';
|
||||
|
||||
if v_def is null then
|
||||
raise exception 'promote_employee ist nicht vorhanden.';
|
||||
end if;
|
||||
|
||||
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
|
||||
if v_anzahl <> 1 then
|
||||
raise exception 'Der Anker in promote_employee kommt % mal vor, erwartet einmal.', v_anzahl;
|
||||
end if;
|
||||
|
||||
execute replace(v_def, v_alt, v_neu);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
-- ═══ 3. Der Nachtlauf, beide Zweige ═══════════════════════════════
|
||||
--
|
||||
-- Hier steht dasselbe zweimal — bei `transfer` und bei `promotion` mit
|
||||
-- Zielplanstelle. Beide werden ersetzt, und die Zahl wird geprüft: der Zweig
|
||||
-- `transfer` ist in diesem Projekt schon einmal spurlos verschwunden.
|
||||
do $migration$
|
||||
declare
|
||||
v_alt constant text := $anker$update position_assignments set valid_to = v_rec.effective_date$anker$;
|
||||
v_neu constant text := $anker$delete from position_assignments
|
||||
where employee_id = v_rec.employee_id and valid_to is null and valid_from >= v_rec.effective_date;
|
||||
update position_assignments set valid_to = v_rec.effective_date$anker$;
|
||||
v_def text;
|
||||
v_anzahl int;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prokind = 'f';
|
||||
|
||||
if v_def is null then
|
||||
raise exception 'apply_due_pending_changes ist nicht vorhanden.';
|
||||
end if;
|
||||
|
||||
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
|
||||
if v_anzahl <> 2 then
|
||||
raise exception 'Der Anker im Nachtlauf kommt % mal vor, erwartet zweimal.', v_anzahl;
|
||||
end if;
|
||||
|
||||
execute replace(v_def, v_alt, v_neu);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
|
||||
-- ═══ 4. Selbstprüfung ═════════════════════════════════════════════
|
||||
do $$
|
||||
declare
|
||||
v_def text;
|
||||
v_anzahl int;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'transfer_employee' and p.prokind = 'f';
|
||||
if v_def not like '%valid_from >= v_effective%' then
|
||||
raise exception 'transfer_employee raeumt die Besetzung vom selben Tag nicht ab.';
|
||||
end if;
|
||||
if v_def not like '%require_hr_admin()%' then
|
||||
raise exception 'transfer_employee prueft die Rechte nicht.';
|
||||
end if;
|
||||
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'promote_employee' and p.prokind = 'f';
|
||||
if v_def not like '%valid_from >= v_effective_date%' then
|
||||
raise exception 'promote_employee raeumt die Besetzung vom selben Tag nicht ab.';
|
||||
end if;
|
||||
if v_def not like '%require_hr_admin()%' then
|
||||
raise exception 'promote_employee prueft die Rechte nicht.';
|
||||
end if;
|
||||
-- Was an dieser Funktion schon einmal verlorenging.
|
||||
if v_def not like '%target_position_id%' then
|
||||
raise exception 'promote_employee hat die Zielplanstelle verloren.';
|
||||
end if;
|
||||
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prokind = 'f';
|
||||
|
||||
v_anzahl := (length(v_def) - length(replace(v_def, 'valid_from >= v_rec.effective_date', '')))
|
||||
/ length('valid_from >= v_rec.effective_date');
|
||||
if v_anzahl <> 2 then
|
||||
raise exception 'Der Nachtlauf raeumt an % von 2 Stellen ab.', v_anzahl;
|
||||
end if;
|
||||
|
||||
v_anzahl := (length(v_def) - length(replace(v_def, 'insert into position_assignments', '')))
|
||||
/ length('insert into position_assignments');
|
||||
if v_anzahl <> 2 then
|
||||
raise exception 'Der Nachtlauf haengt die Besetzung % mal um, erwartet zweimal.', v_anzahl;
|
||||
end if;
|
||||
|
||||
if v_def not like '%v_rec.payload->''role''%' then
|
||||
raise exception 'Der Nachtlauf hat die Gruppe role verloren.';
|
||||
end if;
|
||||
if not exists (
|
||||
select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prosecdef
|
||||
) then
|
||||
raise exception 'Der Nachtlauf ist nicht mehr SECURITY DEFINER.';
|
||||
end if;
|
||||
|
||||
raise notice 'Besetzung am selben Tag: alle vier Stellen abgeraeumt.';
|
||||
end
|
||||
$$;
|
||||
250
db/migrations/20260929200000_vorgemerkte_planstelle.sql
Normal file
250
db/migrations/20260929200000_vorgemerkte_planstelle.sql
Normal file
@@ -0,0 +1,250 @@
|
||||
-- Eine Planstelle, auf die jemand vorgemerkt ist, ist nicht frei
|
||||
--
|
||||
-- Gemeldet im Test vom 29.09. (F.24): eine Person wurde auf eine Planstelle
|
||||
-- eingestellt, auf die bereits eine Versetzung zum 20.10. vorgemerkt war. Die
|
||||
-- Planstellenliste führte die Stelle als unbesetzt, der Assistent bot sie an,
|
||||
-- und das Organigramm zum 20.10. zeigte sie zweifach besetzt.
|
||||
--
|
||||
-- ═══ Warum das schlimmer ist, als es aussieht ═════════════════════
|
||||
--
|
||||
-- Eine vorgemerkte Versetzung steht in pending_org_changes und noch nicht in
|
||||
-- position_assignments. „Besetzt?" wird aber allein an position_assignments
|
||||
-- gefragt — die Stelle ist zum Zeitpunkt der Buchung tatsächlich frei.
|
||||
--
|
||||
-- Auffallen würde es erst in der Nacht zum 20.10.: dann legt der Nachtlauf die
|
||||
-- Besetzung der vorgemerkten Person an und läuft in
|
||||
-- `position_assignments_one_holder`, den Teilindex, der genau eine laufende
|
||||
-- Besetzung je Planstelle zulässt. Der Nachtlauf arbeitet in **einer**
|
||||
-- Transaktion über alle fälligen Vorgänge; eine einzige solche Buchung bringt
|
||||
-- ihn also vollständig zum Stehen — auch für alle anderen. Der Fehler entsteht
|
||||
-- heute und schlägt Wochen später bei jemand anderem zu.
|
||||
--
|
||||
-- ═══ Was hier steht ═══════════════════════════════════════════════
|
||||
--
|
||||
-- Eine Auskunft, die pending_org_changes mitliest, und ihr Aufruf in den vier
|
||||
-- Funktionen, die jemanden auf eine Planstelle setzen: hire_employee,
|
||||
-- rehire_employee, transfer_employee, promote_employee.
|
||||
--
|
||||
-- Abgewiesen wird mit Datum — „ab dem 20.10.2026 vorgemerkt" sagt der Person
|
||||
-- vor dem Bildschirm, was zu tun ist. Die eigene Vormerkung zählt nicht als
|
||||
-- Hindernis: wer dieselbe Versetzung noch einmal erfasst, korrigiert sie.
|
||||
|
||||
create or replace function planstelle_vorgemerkt(p_position_id uuid, p_ab date, p_ausser uuid default null)
|
||||
returns date language sql stable
|
||||
set search_path = public, pg_temp
|
||||
as $$
|
||||
select min(o.effective_date)
|
||||
from pending_org_changes o
|
||||
where o.status = 'pending'
|
||||
-- Nur die beiden Vorgänge, die eine Planstelle belegen. Eine
|
||||
-- Vertragsänderung oder eine Karenz trägt keine Zielplanstelle.
|
||||
and o.change_type in ('transfer', 'promotion')
|
||||
and o.effective_date >= p_ab
|
||||
and (p_ausser is null or o.employee_id <> p_ausser)
|
||||
and nullif(o.payload->>'target_position_id', '')::uuid = p_position_id;
|
||||
$$;
|
||||
|
||||
comment on function planstelle_vorgemerkt(uuid, date, uuid) is
|
||||
'Ab wann eine Planstelle durch eine vorgemerkte Versetzung oder Beförderung belegt ist, oder null. Die Vormerkung der übergebenen Person zählt nicht mit.';
|
||||
|
||||
grant execute on function planstelle_vorgemerkt(uuid, date, uuid) to alpenwerk_app;
|
||||
|
||||
|
||||
-- ═══ hire_employee ════════════════════════════════════════════════
|
||||
do $migration$
|
||||
declare
|
||||
v_alt constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_entry);$anker$;
|
||||
v_neu constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_entry);
|
||||
|
||||
declare
|
||||
v_vorgemerkt date := planstelle_vorgemerkt(v_position_id, v_entry, null);
|
||||
begin
|
||||
if v_vorgemerkt is not null then
|
||||
raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.',
|
||||
to_char(v_vorgemerkt, 'DD.MM.YYYY');
|
||||
end if;
|
||||
end;
|
||||
$anker$;
|
||||
v_def text;
|
||||
v_anzahl int;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'hire_employee' and p.prokind = 'f';
|
||||
if v_def is null then raise exception 'hire_employee ist nicht vorhanden.'; end if;
|
||||
|
||||
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
|
||||
if v_anzahl <> 1 then
|
||||
raise exception 'Der Anker in hire_employee kommt % mal vor, erwartet einmal.', v_anzahl;
|
||||
end if;
|
||||
|
||||
execute replace(v_def, v_alt, v_neu);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
-- ═══ rehire_employee ══════════════════════════════════════════════
|
||||
do $migration$
|
||||
declare
|
||||
v_alt constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_date);$anker$;
|
||||
v_neu constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_date);
|
||||
|
||||
declare
|
||||
v_vorgemerkt date := planstelle_vorgemerkt(v_position_id, v_date, v_employee_id);
|
||||
begin
|
||||
if v_vorgemerkt is not null then
|
||||
raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.',
|
||||
to_char(v_vorgemerkt, 'DD.MM.YYYY');
|
||||
end if;
|
||||
end;
|
||||
$anker$;
|
||||
v_def text;
|
||||
v_anzahl int;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'rehire_employee' and p.prokind = 'f';
|
||||
if v_def is null then raise exception 'rehire_employee ist nicht vorhanden.'; end if;
|
||||
|
||||
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
|
||||
if v_anzahl <> 1 then
|
||||
raise exception 'Der Anker in rehire_employee kommt % mal vor, erwartet einmal.', v_anzahl;
|
||||
end if;
|
||||
|
||||
execute replace(v_def, v_alt, v_neu);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
-- ═══ transfer_employee ════════════════════════════════════════════
|
||||
--
|
||||
-- Der Anker steht auf der zweiten Zeile der Belegungsabfrage. Die erste
|
||||
-- Fassung dieser Migration zielte auf
|
||||
--
|
||||
-- where pa.position_id = v_target_position and pa.valid_to is null;
|
||||
--
|
||||
-- also auf den Wortlaut aus 20260727120200 — und fand nichts. Die Funktion ist
|
||||
-- seither dynamisch gepatcht worden: die Abfrage ist zweizeilig und
|
||||
-- berücksichtigt den Stichtag. Dieselbe Falle wie immer, nur diesmal nicht im
|
||||
-- Rumpf einer Funktion, sondern im Anker auf sie.
|
||||
do $migration$
|
||||
declare
|
||||
v_alt constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_effective);$anker$;
|
||||
v_neu constant text := $anker$and (pa.valid_to is null or pa.valid_to > v_effective);
|
||||
|
||||
declare
|
||||
v_vorgemerkt date := planstelle_vorgemerkt(v_target_position, v_effective, v_employee_id);
|
||||
begin
|
||||
if v_vorgemerkt is not null then
|
||||
raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.',
|
||||
to_char(v_vorgemerkt, 'DD.MM.YYYY');
|
||||
end if;
|
||||
end;
|
||||
$anker$;
|
||||
v_def text;
|
||||
v_anzahl int;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'transfer_employee' and p.prokind = 'f';
|
||||
if v_def is null then raise exception 'transfer_employee ist nicht vorhanden.'; end if;
|
||||
|
||||
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
|
||||
if v_anzahl <> 1 then
|
||||
raise exception 'Der Anker in transfer_employee kommt % mal vor, erwartet einmal.', v_anzahl;
|
||||
end if;
|
||||
|
||||
execute replace(v_def, v_alt, v_neu);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
-- ═══ promote_employee ═════════════════════════════════════════════
|
||||
--
|
||||
-- Ohne Zielplanstelle ist v_position null; die Auskunft liefert dann nichts,
|
||||
-- und der Zweig läuft durch. Eine Beförderung auf derselben Stelle braucht
|
||||
-- deshalb keine Sonderbehandlung.
|
||||
do $migration$
|
||||
declare
|
||||
v_alt constant text := $anker$where pa.position_id = v_position and pa.valid_to is null;$anker$;
|
||||
v_neu constant text := $anker$where pa.position_id = v_position and pa.valid_to is null;
|
||||
|
||||
declare
|
||||
v_vorgemerkt date := planstelle_vorgemerkt(v_position, v_effective_date, v_employee_id);
|
||||
begin
|
||||
if v_vorgemerkt is not null then
|
||||
raise exception 'Für diese Planstelle ist ab dem % bereits eine Versetzung vorgemerkt.',
|
||||
to_char(v_vorgemerkt, 'DD.MM.YYYY');
|
||||
end if;
|
||||
end;
|
||||
$anker$;
|
||||
v_def text;
|
||||
v_anzahl int;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'promote_employee' and p.prokind = 'f';
|
||||
if v_def is null then raise exception 'promote_employee ist nicht vorhanden.'; end if;
|
||||
|
||||
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
|
||||
if v_anzahl <> 1 then
|
||||
raise exception 'Der Anker in promote_employee kommt % mal vor, erwartet einmal.', v_anzahl;
|
||||
end if;
|
||||
|
||||
execute replace(v_def, v_alt, v_neu);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
|
||||
-- ═══ Selbstprüfung ════════════════════════════════════════════════
|
||||
do $$
|
||||
declare
|
||||
v_name text;
|
||||
v_def text;
|
||||
begin
|
||||
foreach v_name in array array['hire_employee', 'rehire_employee', 'transfer_employee', 'promote_employee'] loop
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = v_name and p.prokind = 'f';
|
||||
|
||||
if v_def not like '%planstelle_vorgemerkt(%' then
|
||||
raise exception '% fragt die vorgemerkten Planstellen nicht ab.', v_name;
|
||||
end if;
|
||||
if v_def not like '%require_hr_admin()%' then
|
||||
raise exception '% prueft die Rechte nicht.', v_name;
|
||||
end if;
|
||||
if v_def not like '%search_path%' then
|
||||
raise exception '% hat keinen festen search_path.', v_name;
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
-- Was an diesen Funktionen schon einmal verlorenging. Die Statusumwandlung
|
||||
-- im Wiedereintritt ist zweimal verschwunden; sie wird hier mitgeprüft,
|
||||
-- weil diese Migration die Funktion erneut erzeugt.
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'rehire_employee' and p.prokind = 'f';
|
||||
if v_def not like '%end)::employment_status%' then
|
||||
raise exception 'rehire_employee wandelt den Status nicht um.';
|
||||
end if;
|
||||
if v_def not like '%cornerstone_id%' then
|
||||
raise exception 'rehire_employee hat die Cornerstone-ID verloren.';
|
||||
end if;
|
||||
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'promote_employee' and p.prokind = 'f';
|
||||
if v_def not like '%valid_from >= v_effective_date%' then
|
||||
raise exception 'promote_employee raeumt die Besetzung vom selben Tag nicht mehr ab.';
|
||||
end if;
|
||||
if v_def not like '%target_position_id%' then
|
||||
raise exception 'promote_employee hat die Zielplanstelle verloren.';
|
||||
end if;
|
||||
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'transfer_employee' and p.prokind = 'f';
|
||||
if v_def not like '%valid_from >= v_effective%' then
|
||||
raise exception 'transfer_employee raeumt die Besetzung vom selben Tag nicht mehr ab.';
|
||||
end if;
|
||||
|
||||
raise notice 'Vorgemerkte Planstellen: alle vier Buchungswege pruefen sie.';
|
||||
end
|
||||
$$;
|
||||
76
db/migrations/20260929220000_austritt_am_selben_tag.sql
Normal file
76
db/migrations/20260929220000_austritt_am_selben_tag.sql
Normal file
@@ -0,0 +1,76 @@
|
||||
-- Austritt am Tag, an dem die Besetzung begann
|
||||
--
|
||||
-- Die fünfte Stelle mit demselben Paar aus Schliessen und Einfügen, gefunden
|
||||
-- beim Schreiben des Rauchtests: terminate_employee schliesst die Besetzung auf
|
||||
-- das Austrittsdatum. Wer heute eingestellt und heute wieder ausgetragen wird —
|
||||
-- ohne den Grund „No Show" —, läuft damit in chk_assignment_range.
|
||||
--
|
||||
-- Die No-Show-Hälfte derselben Funktion macht es längst richtig und erklärt
|
||||
-- auch, warum: eine Besetzung von null Tagen hat nicht stattgefunden, also wird
|
||||
-- sie entfernt statt geschlossen. Nur galt das bisher für genau einen
|
||||
-- Austrittsgrund. Ein Eintritt am selben Tag zurückgenommen, aber mit
|
||||
-- „Einvernehmliche Auflösung" statt „No Show", scheiterte an der rohen
|
||||
-- Datenbankmeldung.
|
||||
--
|
||||
-- Damit sind es fünf: hire/rehire brauchen es nicht (sie legen an, sie
|
||||
-- schliessen nicht), transfer_employee, promote_employee und die beiden Zweige
|
||||
-- des Nachtlaufs stehen seit 20260929180000, terminate_employee kommt hier
|
||||
-- dazu.
|
||||
|
||||
do $migration$
|
||||
declare
|
||||
v_alt constant text := $anker$update position_assignments set valid_to = v_exit$anker$;
|
||||
v_neu constant text := $anker$delete from position_assignments
|
||||
where employee_id = v_employee_id and valid_to is null and valid_from >= v_exit;
|
||||
update position_assignments set valid_to = v_exit$anker$;
|
||||
v_def text;
|
||||
v_anzahl int;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'terminate_employee' and p.prokind = 'f';
|
||||
if v_def is null then raise exception 'terminate_employee ist nicht vorhanden.'; end if;
|
||||
|
||||
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
|
||||
if v_anzahl <> 1 then
|
||||
raise exception 'Der Anker in terminate_employee kommt % mal vor, erwartet einmal.', v_anzahl;
|
||||
end if;
|
||||
|
||||
execute replace(v_def, v_alt, v_neu);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
|
||||
-- Selbstprüfung: das Neue und alles, was an dieser Funktion schon einmal
|
||||
-- verlorengegangen ist. Die Anmerkung ist beim Umbau auf No-Show einmal
|
||||
-- stillschweigend verschwunden und fiel erst durch eine Frage des Kunden auf.
|
||||
do $$
|
||||
declare
|
||||
v_def text;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'terminate_employee' and p.prokind = 'f';
|
||||
|
||||
if v_def not like '%valid_from >= v_exit%' then
|
||||
raise exception 'terminate_employee raeumt die Besetzung vom selben Tag nicht ab.';
|
||||
end if;
|
||||
if v_def not like '%No Show%' then
|
||||
raise exception 'terminate_employee kennt den Nichtantritt nicht mehr.';
|
||||
end if;
|
||||
if v_def not like '%v_note%' then
|
||||
raise exception 'terminate_employee schreibt die Anmerkung nicht.';
|
||||
end if;
|
||||
if v_def not like '%austrittsart%' then
|
||||
raise exception 'terminate_employee schreibt die Austrittsart nicht.';
|
||||
end if;
|
||||
if v_def not like '%require_hr_admin()%' then
|
||||
raise exception 'terminate_employee prueft die Rechte nicht.';
|
||||
end if;
|
||||
if v_def not like '%search_path%' then
|
||||
raise exception 'terminate_employee hat keinen festen search_path.';
|
||||
end if;
|
||||
|
||||
raise notice 'terminate_employee: Austritt am selben Tag abgeraeumt.';
|
||||
end
|
||||
$$;
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Keine Vertragsänderung über nichts
|
||||
--
|
||||
-- Gemeldet im Test vom 29.09. (H.06): jedes Speichern in „Daten ändern"
|
||||
-- erzeugte zusätzlich eine Vertragsänderung „Wochenstunden 30.0 → 30", auch
|
||||
-- wenn niemand die Stunden angefasst hatte.
|
||||
--
|
||||
-- Der Vergleich läuft über Text. Die Spalte ist numeric(4,1), also liefert
|
||||
-- `v_old.weekly_hours::text` den Wert „30.0". Das Formular schickt die Zahl
|
||||
-- 30, und `('30')::numeric::text` ergibt „30" — numeric merkt sich die
|
||||
-- Nachkommastellen der Eingabe. Zwei verschiedene Zeichenketten für dieselbe
|
||||
-- Zahl.
|
||||
--
|
||||
-- Der Kommentar an dieser Zeile sagt „Über ::numeric::text, damit 38.50 und
|
||||
-- 38.5 gleich zählen" — die Absicht stimmte, nur reicht `::numeric` dafür
|
||||
-- nicht. Die Umwandlung muss auf **die Genauigkeit der Spalte** gehen, dann
|
||||
-- steht auf beiden Seiten dieselbe Schreibweise.
|
||||
--
|
||||
-- Folgen bisher: Historie und der Ereignisbericht „Vertragsänderung" füllten
|
||||
-- sich mit Einträgen über nichts, und im Nachtlauf hing an jeder solchen
|
||||
-- Änderung ein geplanter Vorgang, der nichts tat.
|
||||
|
||||
do $migration$
|
||||
declare
|
||||
v_alt constant text := $anker$(nullif(v_contract->>'weekly_hours','')::numeric)::text$anker$;
|
||||
v_neu constant text := $anker$(nullif(v_contract->>'weekly_hours','')::numeric(4,1))::text$anker$;
|
||||
v_def text;
|
||||
v_anzahl int;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'change_employee_data' and p.prokind = 'f';
|
||||
if v_def is null then raise exception 'change_employee_data ist nicht vorhanden.'; end if;
|
||||
|
||||
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
|
||||
if v_anzahl <> 1 then
|
||||
raise exception 'Der Vergleich der Wochenstunden kommt % mal vor, erwartet einmal.', v_anzahl;
|
||||
end if;
|
||||
|
||||
execute replace(v_def, v_alt, v_neu);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
|
||||
-- Selbstprüfung: das Neue und das, was an dieser Funktion schon einmal
|
||||
-- verlorengegangen ist.
|
||||
do $$
|
||||
declare
|
||||
v_def text;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'change_employee_data' and p.prokind = 'f';
|
||||
|
||||
if v_def not like '%::numeric(4,1))::text%' then
|
||||
raise exception 'change_employee_data vergleicht die Wochenstunden weiter ohne Genauigkeit.';
|
||||
end if;
|
||||
if v_def not like '%''Hay-Grade''%' then
|
||||
raise exception 'change_employee_data protokolliert den Hay-Grade nicht mehr.';
|
||||
end if;
|
||||
if v_def not like '%nullif(v_person->>''email'', '''')%' then
|
||||
raise exception 'change_employee_data hat die Leerstring-Behandlung verloren.';
|
||||
end if;
|
||||
if v_def not like '%cornerstone_id%' then
|
||||
raise exception 'change_employee_data hat die Cornerstone-ID verloren.';
|
||||
end if;
|
||||
if v_def not like '%require_hr_admin()%' then
|
||||
raise exception 'change_employee_data prueft die Rechte nicht.';
|
||||
end if;
|
||||
if v_def not like '%search_path%' then
|
||||
raise exception 'change_employee_data hat keinen festen search_path.';
|
||||
end if;
|
||||
|
||||
raise notice 'Wochenstunden werden mit der Genauigkeit der Spalte verglichen.';
|
||||
end
|
||||
$$;
|
||||
@@ -0,0 +1,252 @@
|
||||
-- Eine Beförderung benennt die Planstelle um
|
||||
--
|
||||
-- Bisher schrieb promote_employee den neuen Titel nach employees.job_title —
|
||||
-- und **nur** dorthin. Gesehen hat ihn niemand: Akte, Organigramm und die
|
||||
-- Exportdateien zeigen alle die Tätigkeit der Planstelle. Schlimmer noch, der
|
||||
-- Wert war nicht einmal haltbar: hire_employee, transfer_employee und
|
||||
-- change_employee_data setzen dieselbe Spalte aus der Planstelle, also
|
||||
-- überschrieb die nächste Adressänderung den Titel wieder.
|
||||
--
|
||||
-- Gemeldet von Lara („in der zusammenfassung der person ist jedoch noch
|
||||
-- Marketingtätigkeit angeführt") und im Testprotokoll als H.10. Max hat am
|
||||
-- 30.09. entschieden: die Beförderung benennt die Planstelle um.
|
||||
--
|
||||
-- ═══ Welche Planstelle ════════════════════════════════════════════
|
||||
--
|
||||
-- Die, auf der die Person **danach** sitzt: die Zielplanstelle, wenn eine
|
||||
-- gewählt wurde, sonst die bisherige. Ein Titel, der zu keiner Stelle gehört,
|
||||
-- war ja gerade das Problem.
|
||||
--
|
||||
-- Im Formular ist das ohne Überraschung, weil es beim Wählen einer
|
||||
-- Zielplanstelle deren Tätigkeit als Vorschlag einträgt: wer nichts ändert,
|
||||
-- benennt auch nichts um.
|
||||
--
|
||||
-- ═══ Der Katalog ═════════════════════════════════════════════════
|
||||
--
|
||||
-- Umbenannt wird nicht der Katalogeintrag, sondern die Planstelle zeigt auf
|
||||
-- einen anderen. Den Eintrag selbst umzubenennen träfe **jede** Planstelle mit
|
||||
-- derselben Tätigkeit — bei Manner hängen an einem Eintrag bis zu fünf.
|
||||
--
|
||||
-- Die Regel „gleiche Tätigkeit, ein Katalogeintrag" stand bisher nur in
|
||||
-- create_position. Sie wandert in job_fuer_titel() und wird von dort aus
|
||||
-- benutzt; zwei Fassungen derselben Regel laufen in diesem Projekt
|
||||
-- erfahrungsgemäss auseinander.
|
||||
|
||||
create or replace function job_fuer_titel(p_titel text)
|
||||
returns uuid language plpgsql
|
||||
set search_path = public, pg_temp
|
||||
as $$
|
||||
declare
|
||||
v_titel text := nullif(btrim(p_titel), '');
|
||||
v_id uuid;
|
||||
begin
|
||||
if v_titel is null then
|
||||
raise exception 'Es muss eine Tätigkeit angegeben werden.';
|
||||
end if;
|
||||
|
||||
-- Ohne Rücksicht auf Gross- und Kleinschreibung: sonst stehen
|
||||
-- „Schlosser:in" und „schlosser:in" nebeneinander und jede Auswertung nach
|
||||
-- Tätigkeit ist wertlos.
|
||||
select id into v_id from jobs where lower(title) = lower(v_titel);
|
||||
if v_id is not null then
|
||||
return v_id;
|
||||
end if;
|
||||
|
||||
-- Die erste freie Nummer statt count(*) + 1. Gezählt wurde bisher, und das
|
||||
-- vergibt eine bereits belegte Nummer, sobald ein Eintrag gelöscht wurde
|
||||
-- oder — wie bei der Übernahme der Manner-Daten — Codes aus einer fremden
|
||||
-- Quelle danebenstehen.
|
||||
insert into jobs (code, title)
|
||||
select 'J' || lpad(n::text, 4, '0'), v_titel
|
||||
from generate_series(1, 100000) n
|
||||
where not exists (select 1 from jobs j where j.code = 'J' || lpad(n::text, 4, '0'))
|
||||
order by n
|
||||
limit 1
|
||||
returning id into v_id;
|
||||
|
||||
if v_id is null then
|
||||
raise exception 'Es ist keine freie Jobnummer mehr frei.';
|
||||
end if;
|
||||
return v_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
comment on function job_fuer_titel(text) is
|
||||
'Der Katalogeintrag zu einer Tätigkeit — vorhandener oder neuer. Eine Tätigkeit, ein Eintrag, ohne Rücksicht auf Gross- und Kleinschreibung.';
|
||||
|
||||
grant execute on function job_fuer_titel(text) to alpenwerk_app;
|
||||
|
||||
|
||||
-- ═══ 1. create_position benutzt die Auskunft statt einer zweiten Fassung ══
|
||||
do $migration$
|
||||
declare
|
||||
v_alt constant text := $anker$ select id into v_job_id from jobs where lower(title) = lower(v_job_title);
|
||||
if v_job_id is null then
|
||||
insert into jobs (code, title)
|
||||
values ('J' || lpad((select count(*) + 1 from jobs)::text, 4, '0'), v_job_title)
|
||||
returning id into v_job_id;
|
||||
end if;$anker$;
|
||||
v_neu constant text := $anker$ v_job_id := job_fuer_titel(v_job_title);$anker$;
|
||||
v_def text;
|
||||
v_anzahl int;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'create_position' and p.prokind = 'f';
|
||||
if v_def is null then raise exception 'create_position ist nicht vorhanden.'; end if;
|
||||
|
||||
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
|
||||
if v_anzahl <> 1 then
|
||||
raise exception 'Der Anker in create_position kommt % mal vor, erwartet einmal.', v_anzahl;
|
||||
end if;
|
||||
|
||||
execute replace(v_def, v_alt, v_neu);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
-- ═══ 2. promote_employee benennt die Planstelle um ════════════════
|
||||
do $migration$
|
||||
declare
|
||||
v_alt constant text := $anker$ update employees set
|
||||
job_title = payload->>'new_title',
|
||||
paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade)
|
||||
where id = v_employee_id;$anker$;
|
||||
v_neu constant text := $anker$ update employees set
|
||||
job_title = payload->>'new_title',
|
||||
paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade)
|
||||
where id = v_employee_id;
|
||||
|
||||
-- Die Bezeichnung gehört zur Planstelle, nicht zur Person: dort lesen sie
|
||||
-- Akte, Organigramm und die Exporte. Betroffen ist die Stelle, auf der die
|
||||
-- Person danach sitzt — die Zielstelle, sonst die bisherige.
|
||||
declare
|
||||
v_umbenennen uuid := coalesce(v_position, (
|
||||
select pa.position_id from position_assignments pa
|
||||
where pa.employee_id = v_employee_id and pa.valid_to is null));
|
||||
begin
|
||||
if v_umbenennen is not null then
|
||||
update om_positions set job_id = job_fuer_titel(payload->>'new_title')
|
||||
where id = v_umbenennen;
|
||||
end if;
|
||||
end;$anker$;
|
||||
v_def text;
|
||||
v_anzahl int;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'promote_employee' and p.prokind = 'f';
|
||||
if v_def is null then raise exception 'promote_employee ist nicht vorhanden.'; end if;
|
||||
|
||||
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
|
||||
if v_anzahl <> 1 then
|
||||
raise exception 'Der Anker in promote_employee kommt % mal vor, erwartet einmal.', v_anzahl;
|
||||
end if;
|
||||
|
||||
execute replace(v_def, v_alt, v_neu);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
-- ═══ 3. Der Nachtlauf, Zweig `promotion` ══════════════════════════
|
||||
--
|
||||
-- Eine auf später datierte Beförderung muss dasselbe tun, sonst benennt sie
|
||||
-- am Stichtag nichts um und der Titel wäre wieder nur an der Person — genau
|
||||
-- der Zustand, den diese Migration beendet.
|
||||
do $migration$
|
||||
declare
|
||||
v_alt constant text := $anker$ update employees set
|
||||
job_title = coalesce(v_rec.payload->>'new_title', job_title),
|
||||
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
|
||||
where id = v_rec.employee_id;$anker$;
|
||||
v_neu constant text := $anker$ update employees set
|
||||
job_title = coalesce(v_rec.payload->>'new_title', job_title),
|
||||
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
if nullif(v_rec.payload->>'new_title', '') is not null then
|
||||
declare
|
||||
v_umbenennen uuid := coalesce(
|
||||
nullif(v_rec.payload->>'target_position_id', '')::uuid, (
|
||||
select pa.position_id from position_assignments pa
|
||||
where pa.employee_id = v_rec.employee_id and pa.valid_to is null));
|
||||
begin
|
||||
if v_umbenennen is not null then
|
||||
update om_positions set job_id = job_fuer_titel(v_rec.payload->>'new_title')
|
||||
where id = v_umbenennen;
|
||||
end if;
|
||||
end;
|
||||
end if;$anker$;
|
||||
v_def text;
|
||||
v_anzahl int;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prokind = 'f';
|
||||
if v_def is null then raise exception 'apply_due_pending_changes ist nicht vorhanden.'; end if;
|
||||
|
||||
v_anzahl := (length(v_def) - length(replace(v_def, v_alt, ''))) / length(v_alt);
|
||||
if v_anzahl <> 1 then
|
||||
raise exception 'Der Anker im Nachtlauf kommt % mal vor, erwartet einmal.', v_anzahl;
|
||||
end if;
|
||||
|
||||
execute replace(v_def, v_alt, v_neu);
|
||||
end
|
||||
$migration$;
|
||||
|
||||
|
||||
-- ═══ Selbstprüfung ════════════════════════════════════════════════
|
||||
do $$
|
||||
declare
|
||||
v_def text;
|
||||
v_anzahl int;
|
||||
begin
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'create_position' and p.prokind = 'f';
|
||||
if v_def not like '%job_fuer_titel(%' then
|
||||
raise exception 'create_position benutzt die gemeinsame Auskunft nicht.';
|
||||
end if;
|
||||
if v_def like '%count(*) + 1 from jobs%' then
|
||||
raise exception 'create_position traegt noch die zweite Fassung der Katalogregel.';
|
||||
end if;
|
||||
if v_def not like '%require_hr_admin()%' then
|
||||
raise exception 'create_position prueft die Rechte nicht.';
|
||||
end if;
|
||||
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'promote_employee' and p.prokind = 'f';
|
||||
if v_def not like '%update om_positions set job_id = job_fuer_titel%' then
|
||||
raise exception 'promote_employee benennt die Planstelle nicht um.';
|
||||
end if;
|
||||
-- Was an dieser Funktion schon einmal verlorenging.
|
||||
if v_def not like '%target_position_id%' then
|
||||
raise exception 'promote_employee hat die Zielplanstelle verloren.';
|
||||
end if;
|
||||
if v_def not like '%valid_from >= v_effective_date%' then
|
||||
raise exception 'promote_employee raeumt die Besetzung vom selben Tag nicht mehr ab.';
|
||||
end if;
|
||||
if v_def not like '%planstelle_vorgemerkt(%' then
|
||||
raise exception 'promote_employee prueft die vorgemerkten Planstellen nicht mehr.';
|
||||
end if;
|
||||
if v_def not like '%require_hr_admin()%' then
|
||||
raise exception 'promote_employee prueft die Rechte nicht.';
|
||||
end if;
|
||||
|
||||
select pg_get_functiondef(p.oid) into v_def
|
||||
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'apply_due_pending_changes' and p.prokind = 'f';
|
||||
if v_def not like '%job_fuer_titel(v_rec.payload%' then
|
||||
raise exception 'Der Nachtlauf benennt die Planstelle nicht um.';
|
||||
end if;
|
||||
v_anzahl := (length(v_def) - length(replace(v_def, 'insert into position_assignments', '')))
|
||||
/ length('insert into position_assignments');
|
||||
if v_anzahl <> 2 then
|
||||
raise exception 'Der Nachtlauf haengt die Besetzung % mal um, erwartet zweimal.', v_anzahl;
|
||||
end if;
|
||||
if v_def not like '%v_rec.payload->''role''%' then
|
||||
raise exception 'Der Nachtlauf hat die Gruppe role verloren.';
|
||||
end if;
|
||||
|
||||
raise notice 'Befoerderung benennt die Planstelle um — sofort und im Nachtlauf.';
|
||||
end
|
||||
$$;
|
||||
223
db/tests/rauchtest.sql
Normal file
223
db/tests/rauchtest.sql
Normal file
@@ -0,0 +1,223 @@
|
||||
-- Rauchtest: der Lebenszyklus, einmal wirklich ausgeführt
|
||||
--
|
||||
-- docker compose run --rm psql -f /repo/db/tests/rauchtest.sql
|
||||
--
|
||||
-- ═══ Warum es das gibt ════════════════════════════════════════════
|
||||
--
|
||||
-- rehire_employee war seit dem 17.09. bei jedem Aufruf kaputt: ein fehlender
|
||||
-- Umwandlungsschritt beim Status. Aufgefallen ist es erst beim Handtest am
|
||||
-- 29.09. Keine Prüfung der Anwendung hat es bemerkt, und keine konnte es:
|
||||
-- die Selbstprüfungen der Migrationen lesen den **Text** der Funktionen, die
|
||||
-- Unit-Tests laufen ohne Datenbank, und die Integrationstests haben keinen
|
||||
-- Bestand, gegen den sie liefen.
|
||||
--
|
||||
-- Diese Datei schliesst genau diese Lücke: sie **ruft** die Funktionen auf,
|
||||
-- gegen die echte Datenbank, in der Reihenfolge des Lebenszyklus.
|
||||
--
|
||||
-- ═══ Zwei Eigenschaften, ohne die es gefährlich wäre ══════════════
|
||||
--
|
||||
-- 1. **Es wird zurückgerollt.** Alles läuft in einer Transaktion, die am Ende
|
||||
-- verworfen wird. Nach dem Lauf steht kein Testmensch, keine Testplanstelle
|
||||
-- und keine Testeinheit in der Datenbank. Der Anlass steht daneben: beim
|
||||
-- Test vom 29.09. sind reale Personen auf Testplanstellen umgezogen und
|
||||
-- dort geblieben.
|
||||
--
|
||||
-- 2. **Es läuft als Anwendungsrolle.** psql verbindet sich als Superuser, und
|
||||
-- der geht an jeder Zeilenschutz-Regel und an require_hr_admin() vorbei.
|
||||
-- Ein Lauf als postgres würde beweisen, dass die Funktionen für niemanden
|
||||
-- funktionieren, der sie benutzt.
|
||||
--
|
||||
-- Fehlschläge brechen mit einer Meldung ab, die den Schritt nennt. Kommt der
|
||||
-- Lauf bis „Rauchtest bestanden" durch, ist der Lebenszyklus gegangen worden.
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
begin;
|
||||
|
||||
select set_config(
|
||||
'app.user_id',
|
||||
(select p.id::text from profiles p where p.role = 'hr' and p.is_active limit 1),
|
||||
true
|
||||
) as angemeldet_als;
|
||||
|
||||
set local role alpenwerk_app;
|
||||
|
||||
do $rauchtest$
|
||||
declare
|
||||
v_wurzel uuid;
|
||||
v_einheit uuid;
|
||||
v_p1 uuid; v_p2 uuid; v_p3 uuid; v_p4 uuid;
|
||||
v_a uuid; v_b uuid;
|
||||
v_ort uuid;
|
||||
v_nr int;
|
||||
v_status text;
|
||||
v_stelle uuid;
|
||||
v_offen int;
|
||||
v_meldung text;
|
||||
v_ging boolean;
|
||||
v_titel text;
|
||||
begin
|
||||
select id into v_wurzel from org_units where parent_id is null;
|
||||
select id into v_ort from locations order by name limit 1;
|
||||
select coalesce(max(personnel_number), 0) + 1 into v_nr from employees;
|
||||
if v_wurzel is null or v_ort is null then
|
||||
raise exception 'Kein Bestand: es fehlt die oberste Einheit oder ein Standort.';
|
||||
end if;
|
||||
|
||||
-- ── Spielwiese ──────────────────────────────────────────────────
|
||||
v_einheit := create_org_unit(jsonb_build_object(
|
||||
'parent_id', v_wurzel, 'org_number', 'RAUCHTEST', 'name', 'Rauchtest',
|
||||
'unit_type', 'Team', 'valid_from', (current_date - 30)::text));
|
||||
|
||||
v_p1 := create_position(jsonb_build_object('org_unit_id', v_einheit,
|
||||
'job_title', 'Rauchtest Eins', 'is_chief', false, 'valid_from', (current_date - 30)::text));
|
||||
v_p2 := create_position(jsonb_build_object('org_unit_id', v_einheit,
|
||||
'job_title', 'Rauchtest Zwei', 'is_chief', false, 'valid_from', (current_date - 30)::text));
|
||||
v_p3 := create_position(jsonb_build_object('org_unit_id', v_einheit,
|
||||
'job_title', 'Rauchtest Drei', 'is_chief', false, 'valid_from', (current_date - 30)::text));
|
||||
v_p4 := create_position(jsonb_build_object('org_unit_id', v_einheit,
|
||||
'job_title', 'Rauchtest Vier', 'is_chief', false, 'valid_from', (current_date - 30)::text));
|
||||
raise notice '1/8 Einheit und vier Planstellen angelegt';
|
||||
|
||||
-- ── Einstellung ─────────────────────────────────────────────────
|
||||
v_a := hire_employee(jsonb_build_object(
|
||||
'personnel_number', v_nr, 'first_name', 'Rauch', 'last_name', 'Test-A',
|
||||
'gender', 'w', 'birth_date', '1990-01-01', 'location_id', v_ort,
|
||||
'position_id', v_p1, 'entry_date', current_date::text));
|
||||
raise notice '2/8 Einstellung auf Planstelle 1';
|
||||
|
||||
-- ── Versetzung am selben Tag (20260929180000) ───────────────────
|
||||
-- Die Besetzung begann heute; sie auf heute zu schliessen wäre ein
|
||||
-- Intervall von null Tagen. Vorher: chk_assignment_range.
|
||||
perform transfer_employee(jsonb_build_object(
|
||||
'employee_id', v_a, 'target_position_id', v_p2, 'effective_date', current_date::text));
|
||||
|
||||
select position_id into v_stelle from position_assignments
|
||||
where employee_id = v_a and valid_to is null;
|
||||
if v_stelle is distinct from v_p2 then
|
||||
raise exception 'Nach der Versetzung sitzt die Person nicht auf Planstelle 2.';
|
||||
end if;
|
||||
raise notice '3/8 Versetzung am selben Tag';
|
||||
|
||||
-- ── Beförderung mit Planstellenwechsel, ebenfalls heute ─────────
|
||||
perform promote_employee(jsonb_build_object(
|
||||
'employee_id', v_a, 'effective_date', current_date::text,
|
||||
'new_title', 'Rauchtest Leitung', 'new_paygrade', 'HG15',
|
||||
'target_position_id', v_p3));
|
||||
|
||||
select position_id into v_stelle from position_assignments
|
||||
where employee_id = v_a and valid_to is null;
|
||||
if v_stelle is distinct from v_p3 then
|
||||
raise exception 'Nach der Beförderung sitzt die Person nicht auf Planstelle 3.';
|
||||
end if;
|
||||
if (select paygrade::text from employees where id = v_a) <> 'HG15' then
|
||||
raise exception 'Die Beförderung hat den Hay-Grade nicht gesetzt.';
|
||||
end if;
|
||||
|
||||
-- Die Bezeichnung gehört zur Planstelle: dort lesen sie Akte, Organigramm
|
||||
-- und die Exporte. Vorher stand der neue Titel allein an der Person und war
|
||||
-- nirgends zu sehen.
|
||||
select j.title into v_titel
|
||||
from om_positions p join jobs j on j.id = p.job_id where p.id = v_p3;
|
||||
if v_titel <> 'Rauchtest Leitung' then
|
||||
raise exception 'Die Zielplanstelle heisst nach der Beförderung „%", erwartet „Rauchtest Leitung".', v_titel;
|
||||
end if;
|
||||
raise notice '4/8 Beförderung am selben Tag: Hay-Grade und Planstellenbezeichnung';
|
||||
|
||||
-- Dieselbe Planstelle, nur ein neuer Name — der Fall aus dem Testtag.
|
||||
perform promote_employee(jsonb_build_object(
|
||||
'employee_id', v_a, 'effective_date', current_date::text,
|
||||
'new_title', 'Rauchtest Leitung Senior', 'new_paygrade', 'HG16'));
|
||||
|
||||
select j.title into v_titel
|
||||
from om_positions p join jobs j on j.id = p.job_id where p.id = v_p3;
|
||||
if v_titel <> 'Rauchtest Leitung Senior' then
|
||||
raise exception 'Ohne Stellenwechsel heisst die Planstelle „%", erwartet „Rauchtest Leitung Senior".', v_titel;
|
||||
end if;
|
||||
|
||||
-- Der Katalogeintrag der alten Bezeichnung bleibt stehen: an ihm können
|
||||
-- andere Planstellen hängen. Umbenannt wird die Stelle, nicht der Katalog.
|
||||
if not exists (select 1 from jobs where title = 'Rauchtest Leitung') then
|
||||
raise exception 'Der alte Katalogeintrag wurde umbenannt statt die Planstelle umgehängt.';
|
||||
end if;
|
||||
raise notice '4b/8 Beförderung ohne Stellenwechsel benennt dieselbe Planstelle um';
|
||||
|
||||
-- ── Vorgemerkte Planstelle (20260929200000) ─────────────────────
|
||||
-- Eine Versetzung in die Zukunft belegt die Zielstelle, obwohl dort noch
|
||||
-- keine Besetzung steht. Wer sie vorher besetzt, bringt den Nachtlauf zum
|
||||
-- Stehen — vorher ging genau das durch.
|
||||
perform transfer_employee(jsonb_build_object(
|
||||
'employee_id', v_a, 'target_position_id', v_p4, 'effective_date', (current_date + 10)::text));
|
||||
|
||||
-- Der Fehlschlag wird **ausserhalb** des Blocks ausgewertet. Ein `raise`
|
||||
-- innerhalb landet im eigenen exception-Zweig — und weil seine Meldung das
|
||||
-- Wort „vorgemerkte" enthielt, bestand der Schritt beim ersten Lauf, obwohl
|
||||
-- die Migration gar nicht angewendet war. Ein Test, der sich selbst
|
||||
-- durchwinkt, ist schlimmer als keiner.
|
||||
v_ging := false;
|
||||
begin
|
||||
perform hire_employee(jsonb_build_object(
|
||||
'personnel_number', v_nr + 1, 'first_name', 'Rauch', 'last_name', 'Test-B',
|
||||
'gender', 'm', 'birth_date', '1990-01-01', 'location_id', v_ort,
|
||||
'position_id', v_p4, 'entry_date', current_date::text));
|
||||
v_ging := true;
|
||||
exception
|
||||
when others then v_meldung := SQLERRM;
|
||||
end;
|
||||
|
||||
if v_ging then
|
||||
raise exception 'Die vorgemerkte Planstelle liess sich besetzen — die Pruefung greift nicht.';
|
||||
end if;
|
||||
if v_meldung not like '%bereits eine Versetzung vorgemerkt%' then
|
||||
raise exception 'Erwartet wurde die Meldung zur Vormerkung, kam aber: %', v_meldung;
|
||||
end if;
|
||||
raise notice '5/8 Vorgemerkte Planstelle wird abgewiesen';
|
||||
|
||||
-- Die eigene Vormerkung darf nicht im Weg stehen: dieselbe Versetzung noch
|
||||
-- einmal zu erfassen ist eine Berichtigung.
|
||||
perform transfer_employee(jsonb_build_object(
|
||||
'employee_id', v_a, 'target_position_id', v_p4, 'effective_date', (current_date + 20)::text));
|
||||
raise notice '6/8 Die eigene Vormerkung steht nicht im Weg';
|
||||
|
||||
-- ── Austritt am selben Tag (20260929220000) ─────────────────────
|
||||
perform terminate_employee(jsonb_build_object(
|
||||
'employee_id', v_a, 'exit_date', current_date::text,
|
||||
'exit_reason', 'Einvernehmliche Auflösung', 'austrittsart', 'freiwillig'));
|
||||
|
||||
select count(*) into v_offen from position_assignments
|
||||
where employee_id = v_a and valid_to is null;
|
||||
if v_offen <> 0 then
|
||||
raise exception 'Nach dem Austritt ist die Planstelle nicht frei.';
|
||||
end if;
|
||||
raise notice '7/8 Austritt am selben Tag, Planstelle frei';
|
||||
|
||||
-- ── Wiedereintritt (20260929160000, K.01) ───────────────────────
|
||||
perform rehire_employee(jsonb_build_object(
|
||||
'employee_id', v_a, 'rehire_date', current_date::text, 'position_id', v_p1,
|
||||
'first_name', 'Rauch', 'last_name', 'Test-A', 'gender', 'w',
|
||||
'birth_date', '1990-01-01', 'nationality', 'Österreich', 'location_id', v_ort));
|
||||
|
||||
select status::text into v_status from employees where id = v_a;
|
||||
if v_status <> 'Aktiv' then
|
||||
raise exception 'Nach dem Wiedereintritt steht der Status auf %, erwartet Aktiv.', v_status;
|
||||
end if;
|
||||
select position_id into v_stelle from position_assignments
|
||||
where employee_id = v_a and valid_to is null;
|
||||
if v_stelle is distinct from v_p1 then
|
||||
raise exception 'Nach dem Wiedereintritt sitzt die Person nicht auf Planstelle 1.';
|
||||
end if;
|
||||
raise notice '8/8 Wiedereintritt';
|
||||
|
||||
raise notice '';
|
||||
raise notice 'Rauchtest bestanden. Alles Angelegte wird gleich verworfen.';
|
||||
end
|
||||
$rauchtest$;
|
||||
|
||||
rollback;
|
||||
|
||||
-- Beweis, dass nichts geblieben ist. Läuft ausserhalb der verworfenen
|
||||
-- Transaktion und muss überall 0 liefern.
|
||||
select
|
||||
(select count(*) from org_units where org_number = 'RAUCHTEST') as einheiten,
|
||||
(select count(*) from employees where last_name like 'Test-%' and first_name = 'Rauch') as personen,
|
||||
(select count(*) from jobs where title like 'Rauchtest %') as taetigkeiten;
|
||||
@@ -108,7 +108,7 @@ die kommt über die Planstelle.
|
||||
| `contract_end_date` | date | ja | – | Pflicht bei `befristet` |
|
||||
| `worker_type` | `worker_type` | – | `Angestellte:r` | |
|
||||
| `collective_agreement` | `collective_agreement` | – | `Handel` | |
|
||||
| `paygrade` | `paygrade_type` | – | `B` | A–F |
|
||||
| `paygrade` | `paygrade_type` | – | `-` | Hay-Grade; der Spaltenname ist geblieben |
|
||||
| `source` | `source_type` | – | `Extern` | Intern besetzt oder extern geholt |
|
||||
| `work_days` | text[] | – | `{Mo,Di,Mi,Do,Fr}` | In Klickreihenfolge gespeichert, nicht sortiert |
|
||||
| `status` | `employment_status` | – | `Aktiv` | Gilt für **heute**; für einen Stichtag wird er zurückgerechnet |
|
||||
@@ -304,7 +304,7 @@ eigenen Zeilen sind ohnehin immer dabei. Die Tabelle hiess bis September 2026
|
||||
| `contract_type` | unbefristet, befristet |
|
||||
| `worker_type` | Angestellte:r, Arbeiter:in, Lehrling |
|
||||
| `collective_agreement` | Handel, Süßwaren |
|
||||
| `paygrade_type` | A, B, C, D, E, F |
|
||||
| `paygrade_type` | -, HG09…HG19, HG19P, HG20 (Domain über text, kein Aufzählungstyp) |
|
||||
| `source_type` | Intern, Extern |
|
||||
| `gender_type` | m, w |
|
||||
| `org_unit_type` | Gesellschaft, Bereich, Abteilung, Team |
|
||||
|
||||
@@ -50,7 +50,20 @@ export const RUECKKEHR_GRUENDE = ["Wiedereingliederungsteilzeit", "Elternteilzei
|
||||
* geregelte Teilzeiten, die man später wiederfinden können muss — im
|
||||
* Zweifel Jahre danach, wenn jemand fragt, warum die Stunden damals sanken.
|
||||
*/
|
||||
export const STUNDEN_GRUENDE = ["Vertragliche Stundenänderung", "Bildungsteilzeit", "Pflegeteilzeit"] as const;
|
||||
export const STUNDEN_GRUENDE = [
|
||||
"Vertragliche Stundenänderung",
|
||||
"Bildungsteilzeit",
|
||||
"Pflegeteilzeit",
|
||||
// Die beiden standen zuerst nur in RUECKKEHR_GRUENDE, weil sie typischerweise
|
||||
// dann beginnen, wenn eine Abwesenheit endet. Typischerweise ist aber nicht
|
||||
// immer: eine Elternteilzeit kann auch vereinbart werden, ohne dass in
|
||||
// diesem System je eine Karenz stand — etwa beim zweiten Elternteil oder
|
||||
// wenn die Karenz vor der Einführung lag. Gemeldet von Lara am 29.09.:
|
||||
// „Teilzeitvariante Elternteilzeit fehlt". Der Bericht konnte schon immer
|
||||
// nach allen vier filtern; erfassen liessen sich nur zwei.
|
||||
"Elternteilzeit",
|
||||
"Wiedereingliederungsteilzeit",
|
||||
] as const;
|
||||
|
||||
export type StundenGrund = (typeof STUNDEN_GRUENDE)[number];
|
||||
|
||||
|
||||
371
lib/cornerstone.ts
Normal file
371
lib/cornerstone.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import type { Kostenstelle } from "./cost-centers";
|
||||
import type { CsvForm, ExportColumn } from "./export";
|
||||
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_WAEHRUNG = "EUR";
|
||||
|
||||
/**
|
||||
* Der Benutzertyp als **Systemwert**, nicht als Anzeigename.
|
||||
*
|
||||
* Cornerstone zeigt „Mitarbeiter" und erwartet beim Einlesen „Employee".
|
||||
* Der Anzeigename führt zu „User Type ungültiger Wert" und danach zu
|
||||
* „Alle abhängigen Felder müssen gültig sein" — einem Folgefehler, der
|
||||
* verschwindet, sobald der erste behoben ist.
|
||||
*/
|
||||
export const CORNERSTONE_USER_TYPE = "Employee";
|
||||
|
||||
/**
|
||||
* Die Zeitzone bleibt leer.
|
||||
*
|
||||
* Gültig ist allein eine Zeitzonen-ID aus dem Portal; ein Kürzel wie „CET"
|
||||
* quittiert der Load mit „Zeitzonencode nicht eindeutig". Leer heisst:
|
||||
* Vererbung vom Portal beziehungsweise von der Organisationseinheit.
|
||||
*/
|
||||
export const CORNERSTONE_ZEITZONE = "";
|
||||
|
||||
/**
|
||||
* Die Division als GUID, übernommen aus dem Testload vom 27.09.2026.
|
||||
*
|
||||
* Cornerstone verlangt hier die GUID, nicht den Namen. Sie steht in keiner
|
||||
* Alpenwerk-Tabelle — sie gehört dem Zielsystem. Vor einem Echtlauf gegen
|
||||
* das Portal prüfen.
|
||||
*/
|
||||
export const DIVISION_GUID = "1f4c00b3-f0e7-4b53-b84e-e1f8d6088262";
|
||||
|
||||
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}` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Kontoname im Verzeichnisdienst: vorname.nachname, klein, ohne Umlaute.
|
||||
*
|
||||
* Nur für das Customfield AD. Der **Username** in Cornerstone ist etwas
|
||||
* anderes: er ist die UUID aus Alpenwerk, dieselbe wie die User ID.
|
||||
*
|
||||
* Abgeleitet und nicht aus der dienstlichen Adresse geschnitten: die Adresse
|
||||
* ist freiwillig, der Name ist es nicht.
|
||||
*/
|
||||
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" ? "Inactive" : "Active";
|
||||
}
|
||||
|
||||
/**
|
||||
* Working, On Leave oder Terminated — die Werte des Zielsystems.
|
||||
*
|
||||
* „Geplant" kennt Cornerstone nicht. Eine Person, deren Eintritt noch
|
||||
* bevorsteht, wird als Working geladen: sie bekommt ein Konto, und das
|
||||
* Eintrittsdatum steht ohnehin in der Zeile. Alles andere hiesse, sie
|
||||
* entweder wegzulassen oder einen Wert zu erfinden.
|
||||
*/
|
||||
export function beschaeftigungsStatus(status: EmploymentStatus): string {
|
||||
if (status === "Karenz") return "On Leave";
|
||||
if (status === "Ausgetreten") return "Terminated";
|
||||
return "Working";
|
||||
}
|
||||
|
||||
export type CornerstoneZeile = Record<string, string | number>;
|
||||
|
||||
/** 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;
|
||||
cornerstone_id: 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;
|
||||
position_id: string | null;
|
||||
manager_id: string | null;
|
||||
};
|
||||
|
||||
export type CornerstoneKontext = {
|
||||
/** Cornerstone-ID je Mitarbeiterkennung — der Verweis auf die vorgesetzte Person. */
|
||||
managerKennung: Map<string, string | null>;
|
||||
/** Kostenstelle je Planstelle zum Stichtag. */
|
||||
kostenstelle: Map<string, Kostenstelle>;
|
||||
};
|
||||
|
||||
/** Eine Zeile — die Zuordnung selbst, ohne Datenbank und ohne Dateiformat. */
|
||||
export function baueCornerstoneZeile(p: CornerstoneQuelle, k: CornerstoneKontext): CornerstoneZeile {
|
||||
// Zwei Kennungen, zwei Herkünfte:
|
||||
//
|
||||
// User ID + Username die Cornerstone-ID (employees.cornerstone_id)
|
||||
// Local System ID die Personalnummer, wie LOGA sie vergibt
|
||||
//
|
||||
// Benutzername und Benutzer-ID sind stets derselbe Wert — liefen sie
|
||||
// auseinander, zeigte Cornerstone zwei Kennungen für eine Person, und
|
||||
// jeder Verweis darauf träfe womöglich die falsche.
|
||||
//
|
||||
// Hier stand die Alpenwerk-UUID, solange es nichts Besseres gab. Seit
|
||||
// 20260928100000 führt Alpenwerk die Kennung mit, unter der Cornerstone
|
||||
// die Person selbst kennt; die UUID sagte dort niemandem etwas.
|
||||
//
|
||||
// **Leer, wenn keine hinterlegt ist.** Ein Rückfall auf die UUID brächte
|
||||
// zwei Kennungsarten in eine Datei, ohne dass es jemandem auffiele — und
|
||||
// legte in Cornerstone eine zweite Person neben der bestehenden an. Eine
|
||||
// fehlende Kennung ist eine fehlende Angabe und soll als solche auffallen.
|
||||
//
|
||||
// Der Anmeldename des Verzeichnisdienstes steht nur im Customfield AD.
|
||||
const kennung = p.cornerstone_id ?? "";
|
||||
const nummer = String(p.personnel_number);
|
||||
const adName = benutzername(p.first_name, p.last_name);
|
||||
const kst = p.position_id ? k.kostenstelle.get(p.position_id) : undefined;
|
||||
// Der Verweis auf die vorgesetzte Person geht über **deren** User ID, also
|
||||
// über ihre Cornerstone-ID. Hier stand die Personalnummer: die ist in dieser
|
||||
// Datei die Local System ID, und Cornerstone hätte den Verweis entweder ins
|
||||
// Leere laufen lassen oder — schlimmer — auf jemand anderen gelegt, dessen
|
||||
// User ID zufällig so aussieht wie eine Personalnummer.
|
||||
//
|
||||
// Leer, wenn die vorgesetzte Person selbst keine Cornerstone-ID trägt. Aus
|
||||
// demselben Grund wie oben: eine Kennung der falschen Art ist schlimmer als
|
||||
// keine, weil niemand ihr ansieht, dass sie falsch ist.
|
||||
const chef = p.manager_id ? k.managerKennung.get(p.manager_id) : undefined;
|
||||
const ausgetreten = p.status === "Ausgetreten";
|
||||
|
||||
return {
|
||||
"User ID": kennung,
|
||||
"Local System ID": nummer,
|
||||
Prefix: p.title_prefix.join(" "),
|
||||
"First Name": p.first_name,
|
||||
"Middle Name": "",
|
||||
"Last Name": p.last_name,
|
||||
Suffix: p.title_suffix.join(" "),
|
||||
Username: kennung,
|
||||
Approver: "",
|
||||
Manager: chef ?? "",
|
||||
Absent: "",
|
||||
"Allow Reconciliation": "",
|
||||
Email: p.company_email ?? "",
|
||||
"Required Training Approvals": "",
|
||||
Gender: geschlecht(p.gender),
|
||||
Exempt: "",
|
||||
Ethnicity: "",
|
||||
// Leer: Cornerstone rechnet die Dienstzeit aus den Eintrittsdaten selbst.
|
||||
// Ein mitgeladener Wert wäre ab dem Tag nach dem Einlesen veraltet.
|
||||
"Months of Service": "",
|
||||
"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),
|
||||
// Die vier Austrittsfelder und der Abwesenheitsgrund nur, wenn der
|
||||
// Beschäftigungsstatus sie trägt. Ein Grund ohne Austritt ist für den
|
||||
// Load ein ungültiger Zustand, nicht eine Zusatzangabe.
|
||||
"Leave Reason": ausgetreten ? "" : p.status === "Karenz" ? (p.absence_type ?? "") : "",
|
||||
"Termination Type": ausgetreten ? (p.austrittsart ?? "") : "",
|
||||
"Termination Reason": ausgetreten ? (p.exit_reason ?? "") : "",
|
||||
"Termination Date": ausgetreten ? csDatum(p.exit_date) : "",
|
||||
// Wiedereinstellbarkeit wird in Alpenwerk nicht erhoben.
|
||||
"Eligible For Rehire": "",
|
||||
// „Nein" gehört laut Vorgabe nicht in die Datei, und der Wert für „Ja"
|
||||
// ist nicht bestätigt. Ein nicht bestätigter Wert liesse den Load die
|
||||
// **ganze** Zeile abweisen, nicht nur das Feld — deshalb bleibt sie leer.
|
||||
"Rehired Employee": "",
|
||||
"Division ID": DIVISION_GUID,
|
||||
// Leer: die Planstellennummer aus Alpenwerk (S-0001) ist keine
|
||||
// Cornerstone-Position-ID. Sie hier einzutragen hiesse, eine Kennung des
|
||||
// Zielsystems zu erfinden.
|
||||
"Position ID": "",
|
||||
// Der Hay-Grade, so wie er gespeichert ist. Wer keinen trägt, bekommt den
|
||||
// „Generic Grade" — und der ist laut der Tabelle des Kunden der
|
||||
// Bindestrich, nicht die leere Zelle. Hier wird deshalb nichts übersetzt;
|
||||
// siehe lib/hay-grade.ts.
|
||||
"Grade ID": p.paygrade,
|
||||
"Cost Center ID": kst?.code ?? "",
|
||||
// Leer: Cornerstone erwartet seine eigene Standortkennung („01"), und
|
||||
// die Tabelle locations führt nur id, name und country. Der Name wäre
|
||||
// hier keine Kennung, sondern eine, die es im Zielsystem nicht gibt.
|
||||
// Solange dafür keine Spalte existiert, bleibt das Feld leer.
|
||||
"Location ID": "",
|
||||
// Beide Spalten wertet der Load derzeit nicht aus. Sie stehen in der
|
||||
// Kopfzeile, weil die Vorlage sie führt.
|
||||
"Retention Rules": "",
|
||||
Organisationsstufe: "",
|
||||
"Customfield ID AD": adName,
|
||||
"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<CornerstoneZeile>[] {
|
||||
return CORNERSTONE_SPALTEN.map((header) => ({ header, get: (z) => z[header] ?? "" }));
|
||||
}
|
||||
@@ -22,13 +22,22 @@ export type DashboardParams = {
|
||||
userId: string | null;
|
||||
today: string;
|
||||
yearStart: string;
|
||||
yearEnd: string;
|
||||
/**
|
||||
* Das obere Ende des YTD-Fensters — **heute**, nicht der 31.12.
|
||||
*
|
||||
* Hier stand das Jahresende, womit die Kacheln „Eintritte/Austritte (YTD)"
|
||||
* auch zählten, was erst bevorsteht: ein für den 01.11. erfasster Austritt
|
||||
* stand schon im September als geschehen da. „Year to date" heisst bis
|
||||
* heute, und die Kachel soll sagen, was war — was kommt, steht in
|
||||
* „Anstehend" daneben. Gemeldet im Test vom 29.09. (B.02).
|
||||
*/
|
||||
ytdBis: string;
|
||||
bisIso: string;
|
||||
arten: AnstehendArt[];
|
||||
};
|
||||
|
||||
export async function loadDashboardData(tx: Tx, p: DashboardParams) {
|
||||
const { userId, today, yearStart, yearEnd, bisIso, arten } = p;
|
||||
const { userId, today, yearStart, ytdBis, bisIso, arten } = p;
|
||||
const zeigt = (art: AnstehendArt) => arten.includes(art);
|
||||
// Elf Abfragen standen hier in einem Promise.all, das keine
|
||||
// Gleichzeitigkeit war: eine Transaktion hängt an einer Verbindung, und
|
||||
@@ -43,7 +52,7 @@ export async function loadDashboardData(tx: Tx, p: DashboardParams) {
|
||||
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||||
.where("event_type", "in", [...types])
|
||||
.where("event_date", ">=", yearStart)
|
||||
.where("event_date", "<=", yearEnd);
|
||||
.where("event_date", "<=", ytdBis);
|
||||
|
||||
const g = await tx
|
||||
.selectNoFrom((eb) => [
|
||||
|
||||
@@ -8,6 +8,24 @@
|
||||
// verglichen wird gegen Vorname, Nachname und Position zusammengesetzt, mit
|
||||
// Trennzeichen als Wortgrenze.
|
||||
|
||||
/**
|
||||
* Die Zeichen, die im Namen als Wortgrenze zählen.
|
||||
*
|
||||
* Sie stehen hier und nicht in der Seite, weil **beide Seiten des Vergleichs**
|
||||
* sie brauchen: das Feld wird per `translate` an diesen Zeichen in Leerzeichen
|
||||
* zerlegt, und die Eingabe muss genauso zerlegt werden. Vorher tat es nur das
|
||||
* Feld — wer „Müller-Weiß" mit Bindestrich suchte, erzeugte das Muster
|
||||
* `müller-weiß%`, während im Heuhaufen „müller weiß" stand. Null Treffer,
|
||||
* obwohl die Person da ist; ohne Bindestrich fand man sie. Gemeldet im Test
|
||||
* vom 29.09. (C.08).
|
||||
*/
|
||||
export const TRENNZEICHEN = "-/:.,";
|
||||
|
||||
/** Für `translate(…, TRENNZEICHEN, TRENNZEICHEN_ERSATZ)`: gleich lang, nur Leerzeichen. */
|
||||
export const TRENNZEICHEN_ERSATZ = " ".repeat(TRENNZEICHEN.length);
|
||||
|
||||
const WORTGRENZE = new RegExp(`[\\s${TRENNZEICHEN.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}]+`);
|
||||
|
||||
/** Nur Ziffern? Dann ist es eine Personalnummer und kein Name. */
|
||||
export function istPersonalnummer(term: string): boolean {
|
||||
return /^\d+$/.test(term.trim());
|
||||
@@ -25,7 +43,10 @@ export function istPersonalnummer(term: string): boolean {
|
||||
export function suchMuster(term: string): string[][] {
|
||||
return term
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
// An denselben Zeichen zerlegt wie das Feld, gegen das verglichen wird:
|
||||
// „Müller-Weiß" wird zu zwei Wörtern, die beide am Wortanfang treffen
|
||||
// müssen — genau wie „Müller Weiß".
|
||||
.split(WORTGRENZE)
|
||||
.filter(Boolean)
|
||||
.map((wort) => {
|
||||
// Die Platzhalter von LIKE entschärfen: wer „50 %" tippt, sucht Text.
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { Database } from "./types";
|
||||
* ü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;
|
||||
@@ -57,6 +58,8 @@ export async function ladeExportMitarbeiter(
|
||||
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);
|
||||
@@ -83,7 +86,9 @@ export async function ladeExportMitarbeiter(
|
||||
const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
|
||||
employeeQuery().execute(),
|
||||
loadOrgLookups(tx),
|
||||
tx.selectFrom("employees").select(["id", "first_name", "last_name"]).orderBy("id").execute(),
|
||||
// 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),
|
||||
@@ -102,7 +107,15 @@ export async function ladeExportMitarbeiter(
|
||||
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,
|
||||
@@ -115,6 +128,7 @@ export async function ladeExportMitarbeiter(
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,20 +23,41 @@ export function sanitizeForSpreadsheetCell(text: string): string {
|
||||
return /^[=+\-@]/.test(text) ? `'${text}` : text;
|
||||
}
|
||||
|
||||
function csvCell(value: string | number | boolean | null): string {
|
||||
function csvCell(value: string | number | boolean | null, trenner: string, formelschutz: boolean): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
const text = typeof value === "boolean" ? (value ? "Ja" : "Nein") : sanitizeForSpreadsheetCell(String(value));
|
||||
return /[";\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
const roh = typeof value === "boolean" ? (value ? "Ja" : "Nein") : String(value);
|
||||
const text = formelschutz ? sanitizeForSpreadsheetCell(roh) : roh;
|
||||
// Das Trennzeichen gehört in die Prüfung: mit Komma als Trenner muss ein
|
||||
// Komma im Wert maskiert werden, sonst zerfällt die Zeile in zwei Spalten.
|
||||
return text.includes(trenner) || /["\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
}
|
||||
|
||||
// Leading BOM + semicolon delimiter: Excel's German locale default, and what
|
||||
// makes umlauts render correctly instead of mojibake on open.
|
||||
export function toCsv<T>(rows: T[], columns: ExportColumn<T>[]): string {
|
||||
const lines = [columns.map((c) => csvCell(c.header)).join(";")];
|
||||
/**
|
||||
* Wie die Datei aussehen soll.
|
||||
*
|
||||
* Die Vorgaben sind für Excel in deutscher Einstellung gemacht: Semikolon als
|
||||
* Trenner und ein BOM voran, damit Umlaute nicht als Buchstabensalat
|
||||
* ankommen. Ein fremdes System will oft das Gegenteil — Cornerstone etwa
|
||||
* erwartet Kommas, und ein BOM machte dort aus der ersten Spalte eine, die in
|
||||
* keiner Zuordnung vorkommt, weil das unsichtbare Zeichen im Namen steckt.
|
||||
*
|
||||
* `formelschutz: false` schaltet das vorangestellte Hochkomma ab (siehe
|
||||
* sanitizeForSpreadsheetCell). Es gehört in eine Datei, die ein Mensch in Excel
|
||||
* öffnet, und es verdirbt eine, die eine Maschine einliest: eine österreichische
|
||||
* Telefonnummer beginnt mit „+", und im Zielsystem stünde danach `'+43…` als
|
||||
* Nummer. Nur für Dateien setzen, die ausschliesslich ein Fremdsystem liest.
|
||||
*/
|
||||
export type CsvForm = { trenner?: "," | ";"; bom?: boolean; formelschutz?: boolean };
|
||||
|
||||
export function toCsv<T>(rows: T[], columns: ExportColumn<T>[], form: CsvForm = {}): string {
|
||||
const trenner = form.trenner ?? ";";
|
||||
const bom = form.bom ?? true;
|
||||
const schutz = form.formelschutz ?? true;
|
||||
const lines = [columns.map((c) => csvCell(c.header, trenner, schutz)).join(trenner)];
|
||||
for (const row of rows) {
|
||||
lines.push(columns.map((c) => csvCell(c.get(row))).join(";"));
|
||||
lines.push(columns.map((c) => csvCell(c.get(row), trenner, schutz)).join(trenner));
|
||||
}
|
||||
return "" + lines.join("\r\n");
|
||||
return (bom ? "" : "") + lines.join("\r\n");
|
||||
}
|
||||
|
||||
// Anchored at UTC midnight, not local: ExcelJS converts a JS Date to an Excel
|
||||
|
||||
55
lib/hay-grade.ts
Normal file
55
lib/hay-grade.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { PaygradeType } from "./types";
|
||||
|
||||
// Die Hay-Grades — die Bewertungsstufen, die der Kunde tatsächlich führt.
|
||||
//
|
||||
// Bis September 2026 stand an dieser Stelle eine Verwendungsgruppe A–F samt
|
||||
// Beschreibungen („B – Qualifiziert"). Die stammte aus der Spezifikation, nicht
|
||||
// vom Kunden: sechs erfundene Stufen. Dieselbe Spalte füllt im
|
||||
// Cornerstone-Extrakt die „Grade ID", und dort ist A–F keine Kennung, die das
|
||||
// Zielsystem kennt — die Datei wäre in jeder Zeile falsch gewesen, ohne dass
|
||||
// es beim Erzeugen aufgefallen wäre.
|
||||
//
|
||||
// Gespeichert wird die ID, angezeigt der Titel. Bei den HG-Stufen ist beides
|
||||
// dasselbe; nur „Generic Grade" trägt als ID einen Bindestrich. Der steht so
|
||||
// in der Tabelle des Kunden und wird hier nicht in eine leere Angabe
|
||||
// übersetzt: ein Bindestrich ist dort ein Wert, kein fehlender.
|
||||
//
|
||||
// Dieselbe Liste steht in Migration 20260928140000 als CHECK der Domain
|
||||
// paygrade_type; tests/unit/hay-grade.test.ts hält beide gegeneinander.
|
||||
export const HAY_GRADES: readonly { value: PaygradeType; label: string }[] = [
|
||||
{ value: "-", label: "Generic Grade" },
|
||||
{ value: "HG09", label: "HG09" },
|
||||
{ value: "HG10", label: "HG10" },
|
||||
{ value: "HG11", label: "HG11" },
|
||||
{ value: "HG12", label: "HG12" },
|
||||
{ value: "HG13", label: "HG13" },
|
||||
{ value: "HG14", label: "HG14" },
|
||||
{ value: "HG15", label: "HG15" },
|
||||
{ value: "HG16", label: "HG16" },
|
||||
{ value: "HG17", label: "HG17" },
|
||||
{ value: "HG18", label: "HG18" },
|
||||
{ value: "HG19", label: "HG19" },
|
||||
{ value: "HG19P", label: "HG19P" },
|
||||
{ value: "HG20", label: "HG20" },
|
||||
] as const;
|
||||
|
||||
export const HAY_GRADE_WERTE: readonly PaygradeType[] = HAY_GRADES.map((g) => g.value);
|
||||
|
||||
/**
|
||||
* Die Vorgabe — und zugleich der Wert, den der Bestand bei der Umstellung
|
||||
* bekommen hat.
|
||||
*
|
||||
* Aus A–F liesse sich kein Hay-Grade ableiten: die alten Stufen waren eine
|
||||
* andere Einteilung mit einer anderen Anzahl. Jede Zuordnung wäre geraten,
|
||||
* und geraten sähe im Extrakt genauso aus wie erhoben.
|
||||
*/
|
||||
export const HAY_GRADE_STANDARD: PaygradeType = "-";
|
||||
|
||||
/** Der anzuzeigende Titel; unbekannte Werte bleiben, wie sie in der Zeile stehen. */
|
||||
export function hayGradeLabel(wert: string | null | undefined): string {
|
||||
return HAY_GRADES.find((g) => g.value === wert)?.label ?? wert ?? "–";
|
||||
}
|
||||
|
||||
export function istHayGrade(wert: string | null | undefined): wert is PaygradeType {
|
||||
return HAY_GRADE_WERTE.includes(wert as PaygradeType);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ABSENCE_TYPES } from "./absence";
|
||||
import { HAY_GRADE_WERTE } from "./hay-grade";
|
||||
import { KUENDIGUNGSSCHUTZ_GRUENDE } from "./kuendigungsschutz";
|
||||
import { MITARBEITERARTEN } from "./mitarbeiterart";
|
||||
import { EMERGENCY_RELATIONS } from "./types";
|
||||
@@ -27,6 +28,12 @@ import { EMERGENCY_RELATIONS } from "./types";
|
||||
// Fehlwert. Das gehört gemeinsam mit der Anzeigeschicht gelöst, nicht
|
||||
// nebenbei hier.
|
||||
export const FELD_AUSWAHLEN: Record<string, readonly string[]> = {
|
||||
// Kein Aufzählungstyp mehr, sondern eine Domain über text — die Liste darf
|
||||
// hier also stehen. Angezeigt wird der **gespeicherte** Wert und nicht der
|
||||
// Titel: „-" statt „Generic Grade". In einem Dialog, der eine Zeile der
|
||||
// Historie richtigstellt, ist genau das die richtige Anzeige, denn dort
|
||||
// steht hinterher derselbe Wert.
|
||||
"Hay-Grade": HAY_GRADE_WERTE,
|
||||
"Notfallkontakt Verhältnis": EMERGENCY_RELATIONS,
|
||||
"Kündigungsschutz Personenkreis": KUENDIGUNGSSCHUTZ_GRUENDE,
|
||||
Mitarbeiterart: MITARBEITERARTEN,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
import { sql, type Tx } from "@/lib/db";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { HAY_GRADE_STANDARD } from "@/lib/hay-grade";
|
||||
import { deriveStatusAsOf } from "@/lib/reports";
|
||||
import { normalizeSvnr } from "@/lib/svnr";
|
||||
import { sortiereWochentage } from "@/lib/wochentage";
|
||||
@@ -58,7 +59,7 @@ export async function bestandLaden(tx: Tx): Promise<Bestand> {
|
||||
.select(["position_id"])
|
||||
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">=", heute)]))
|
||||
.execute(),
|
||||
tx.selectFrom("employees").select(["id", "personnel_number", "email", "company_email", "sv_nummer"]).execute(),
|
||||
tx.selectFrom("employees").select(["id", "personnel_number", "email", "company_email", "cornerstone_id", "sv_nummer"]).execute(),
|
||||
]);
|
||||
|
||||
const besetzt = new Set(besetzungen.map((b) => b.position_id));
|
||||
@@ -73,6 +74,7 @@ export async function bestandLaden(tx: Tx): Promise<Bestand> {
|
||||
// kann sie fehlen.
|
||||
emails: new Set(personen.filter((e) => e.email).map((e) => e.email!.toLowerCase())),
|
||||
firmenEmails: new Set(personen.filter((e) => e.company_email).map((e) => e.company_email!.toLowerCase())),
|
||||
cornerstoneIds: new Set(personen.filter((e) => e.cornerstone_id).map((e) => e.cornerstone_id!)),
|
||||
svNummern: new Set(personen.filter((e) => e.sv_nummer).map((e) => normalizeSvnr(e.sv_nummer!))),
|
||||
};
|
||||
}
|
||||
@@ -211,6 +213,7 @@ export async function laden(
|
||||
address_country: txt(w.address_country),
|
||||
email: txt(w.email)!,
|
||||
company_email: txt(w.company_email),
|
||||
cornerstone_id: txt(w.cornerstone_id),
|
||||
phone: txt(w.phone),
|
||||
title_prefix: liste(w.title_prefix) ?? [],
|
||||
title_suffix: liste(w.title_suffix) ?? [],
|
||||
@@ -227,7 +230,7 @@ export async function laden(
|
||||
work_days: sortiereWochentage(liste(w.work_days) ?? ["Mo", "Di", "Mi", "Do", "Fr"]) as never,
|
||||
contract_type: (txt(w.contract_type) ?? "unbefristet") as never,
|
||||
contract_end_date: txt(w.contract_end_date),
|
||||
paygrade: (txt(w.paygrade) ?? "B") as never,
|
||||
paygrade: (txt(w.paygrade) ?? HAY_GRADE_STANDARD) as never,
|
||||
collective_agreement: (txt(w.collective_agreement) ?? "Handel") as never,
|
||||
worker_type: (txt(w.worker_type) ?? "Angestellte:r") as never,
|
||||
monthly_salary_gross: zahl(w.monthly_salary_gross),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ABSENCE_TYPES } from "@/lib/absence";
|
||||
import { HAY_GRADE_WERTE } from "@/lib/hay-grade";
|
||||
import { TITLE_PREFIXES, TITLE_SUFFIXES } from "@/lib/titles";
|
||||
|
||||
// Was in einer Importdatei stehen darf.
|
||||
@@ -240,6 +241,14 @@ export const BLATT_PERSONEN: BlattSchema = {
|
||||
hinweis: "Dienstliche Adresse. Freiwillig, eindeutig. Geht in den Export für die Mitarbeiterbefragung.",
|
||||
beispiel: "s.aigner@manner.com",
|
||||
},
|
||||
{
|
||||
name: "Cornerstone-ID",
|
||||
ziel: "cornerstone_id",
|
||||
pflicht: false,
|
||||
typ: { art: "text" },
|
||||
hinweis: "Kennung im Lernsystem Cornerstone. Freiwillig, eindeutig. Füllt dort „User ID“ und „Username“.",
|
||||
beispiel: "10000382",
|
||||
},
|
||||
{ name: "Private Telefonnummer", ziel: "phone", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "+43 660 1234567" },
|
||||
{ name: "Adresse", ziel: "address", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Hauptstraße 1" },
|
||||
{ name: "PLZ", ziel: "postal_code", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "1010" },
|
||||
@@ -331,12 +340,12 @@ export const BLATT_PERSONEN: BlattSchema = {
|
||||
beispiel: "",
|
||||
},
|
||||
{
|
||||
name: "Verwendungsgruppe",
|
||||
name: "Hay-Grade",
|
||||
ziel: "paygrade",
|
||||
pflicht: false,
|
||||
typ: { art: "auswahl", werte: ["A", "B", "C", "D", "E", "F"] },
|
||||
hinweis: "Leer = B.",
|
||||
beispiel: "C",
|
||||
typ: { art: "auswahl", werte: [...HAY_GRADE_WERTE] },
|
||||
hinweis: "Leer = Generic Grade, also ein Bindestrich.",
|
||||
beispiel: "HG13",
|
||||
},
|
||||
{
|
||||
name: "Kollektivvertrag",
|
||||
|
||||
@@ -35,6 +35,7 @@ export type Bestand = {
|
||||
personalnummern: Map<number, string>;
|
||||
emails: Set<string>;
|
||||
firmenEmails: Set<string>;
|
||||
cornerstoneIds: Set<string>;
|
||||
svNummern: Set<string>;
|
||||
};
|
||||
|
||||
@@ -46,6 +47,7 @@ export const LEERER_BESTAND: Bestand = {
|
||||
personalnummern: new Map(),
|
||||
emails: new Set(),
|
||||
firmenEmails: new Set(),
|
||||
cornerstoneIds: new Set(),
|
||||
svNummern: new Set(),
|
||||
};
|
||||
|
||||
@@ -265,6 +267,7 @@ export function pruefe(blaetter: ImportSheet[], bestand: Bestand = LEERER_BESTAN
|
||||
const personalnummern = new Set([...bestand.personalnummern.keys()]);
|
||||
const emails = new Set([...bestand.emails]);
|
||||
const firmenEmails = new Set([...bestand.firmenEmails]);
|
||||
const cornerstoneIds = new Set([...bestand.cornerstoneIds]);
|
||||
const svNummern = new Set([...bestand.svNummern]);
|
||||
const belegtePlanstellen = new Map<string, number>();
|
||||
const eintritte = new Map<number, string>();
|
||||
@@ -297,6 +300,17 @@ export function pruefe(blaetter: ImportSheet[], bestand: Bestand = LEERER_BESTAN
|
||||
}
|
||||
}
|
||||
|
||||
// Die Kennung gehört genau einer Person; die Spalte trägt einen
|
||||
// eindeutigen Index. Ohne Prüfung hier bräche erst das Schreiben ab —
|
||||
// mitten im Import und ohne Zeilennummer.
|
||||
const cornerstoneId = s(w.cornerstone_id);
|
||||
if (cornerstoneId) {
|
||||
if (cornerstoneIds.has(cornerstoneId)) {
|
||||
melde("Personen", z.zeile, "Cornerstone-ID", "Kommt bereits vor.", cornerstoneId);
|
||||
}
|
||||
cornerstoneIds.add(cornerstoneId);
|
||||
}
|
||||
|
||||
const geburt = s(w.birth_date);
|
||||
const svRoh = s(w.sv_nummer);
|
||||
if (svRoh) {
|
||||
|
||||
158
lib/lolyo.ts
Normal file
158
lib/lolyo.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { CsvForm, ExportColumn } from "./export";
|
||||
|
||||
// Der Export für LOLYO, die Mitarbeiter-App: eine Zeile je Person, die dort
|
||||
// einen Zugang bekommen soll.
|
||||
//
|
||||
// Diese Datei tut etwas anderes als die beiden Exporte daneben. Honestly lädt
|
||||
// zu einer Befragung ein, Cornerstone führt Stammdaten nach — hier entstehen
|
||||
// **Konten**, samt Passwort in der Zeile. Wer die Datei hat, hat damit einen
|
||||
// Zugang zu jedem Konto darin, solange niemand sein Passwort gewechselt hat.
|
||||
// Deshalb steht das Passwort nicht in dieser Datei: die Route reicht es herein
|
||||
// (app/api/export/lolyo/route.ts), und es gelangt damit nie in ein Bündel, das
|
||||
// der Browser lädt. Dieselbe Überlegung steht in lib/passwort.ts.
|
||||
//
|
||||
// Kopfzeile und Trennzeichen kommen aus der Vorlage des Anbieters, Zeichen für
|
||||
// Zeichen. Die gemischte Schreibweise — „Titel prefix" deutsch, „Title Suffix"
|
||||
// englisch — steht so darin; sie hier stillschweigend zu vereinheitlichen
|
||||
// hiesse, eine Spalte zu liefern, die LOLYO nicht zuordnet.
|
||||
|
||||
/**
|
||||
* Semikolon, kein BOM, kein Formelschutz — so liest LOLYO die Datei.
|
||||
*
|
||||
* Die Vorgabe von toCsv ist für Excel gemacht. Das BOM hinge unsichtbar am
|
||||
* Namen der ersten Spalte, und „Benutzer/Code" käme beim Einlesen in keiner
|
||||
* Zuordnung mehr vor.
|
||||
*
|
||||
* Der Formelschutz stellt jedem Wert, der mit `=`, `+`, `-` oder `@` beginnt,
|
||||
* ein Hochkomma voran, damit Excel ihn nicht als Formel rechnet. Hier wäre das
|
||||
* falsch: **jede** österreichische Telefonnummer beginnt mit „+", und in LOLYO
|
||||
* stünde danach `'+4366…` als Nummer — in jedem Konto, und niemandem fiele es
|
||||
* beim Erzeugen auf. Die Datei liest eine Maschine, kein Mensch in Excel.
|
||||
*/
|
||||
export const LOLYO_CSV: CsvForm = { trenner: ";", bom: false, formelschutz: false };
|
||||
|
||||
/** Für alle dieselbe. Wer eine andere braucht, stellt sie in LOLYO um, nicht hier. */
|
||||
export const LOLYO_SPRACHE = "DE";
|
||||
|
||||
/**
|
||||
* Die Gruppe, in die alle kommen.
|
||||
*
|
||||
* Fest vorgegeben, nicht aus der Organisationseinheit abgeleitet: LOLYO-Gruppen
|
||||
* sind Verteiler in der App und nicht die Aufbauorganisation. Beides
|
||||
* gleichzusetzen erzeugte 78 Gruppen, die dort niemand bestellt hat.
|
||||
*/
|
||||
export const LOLYO_GRUPPE = "Demo-Gruppe";
|
||||
|
||||
/** LOLYO holt die Bestätigung selbst ein; aus Alpenwerk kommt sie nie bestätigt. */
|
||||
export const LOLYO_EMAIL_BESTAETIGT = "0";
|
||||
|
||||
/**
|
||||
* Die Anrede aus dem Geschlecht.
|
||||
*
|
||||
* Alpenwerk kennt genau zwei Werte (gender_type), und die Vorgabe nennt genau
|
||||
* zwei Anreden. Ein dritter Wert bliebe hier leer statt geraten — eine falsche
|
||||
* Anrede steht in jeder Nachricht, die die App verschickt.
|
||||
*/
|
||||
export function anrede(gender: string): string {
|
||||
if (gender === "m") return "Herr";
|
||||
if (gender === "w") return "Frau";
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Typografische Zeichen auf ihre ASCII-Entsprechung.
|
||||
*
|
||||
* Im Zielsystem kam der Gedankenstrich „–" als `–` an — das sind seine
|
||||
* UTF-8-Bytes, gelesen als Windows-1252. Gemessen hat die Vorlage des Anbieters
|
||||
* dieselbe Kodierung wie unsere Datei (UTF-8 ohne BOM, nachgeprüft am
|
||||
* „bestätigt" in ihrer Kopfzeile), die Ursache liegt also nicht in dem, was wir
|
||||
* schreiben. Solange sie nicht geklärt ist, geht der Weg über das Zeichen
|
||||
* selbst: ein Bindestrich sagt dasselbe und überlebt jede Fehlinterpretation,
|
||||
* weil er in beiden Kodierungen dasselbe Byte ist.
|
||||
*
|
||||
* Umlaute bleiben. „Bäckerei" zu „Baeckerei" zu machen hiesse, ein Problem zu
|
||||
* verstecken, das es entweder gar nicht gibt — oder das dann auch Namen
|
||||
* betrifft, und dort wäre es keine Kosmetik mehr.
|
||||
*/
|
||||
export function ohneTypografie(text: string): string {
|
||||
return text
|
||||
.replace(/[‐-―−]/g, "-")
|
||||
.replace(/[‘’‚‛′]/g, "'")
|
||||
.replace(/[“”„‟″]/g, '"')
|
||||
.replace(/…/g, "...")
|
||||
.replace(/[ ]/g, " ");
|
||||
}
|
||||
|
||||
/** Die Person, so wie der Export sie braucht. */
|
||||
export type LolyoQuelle = {
|
||||
id: string;
|
||||
company_email: string | null;
|
||||
title_prefix: string[];
|
||||
title_suffix: string[];
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
job_title: string;
|
||||
phone: string | null;
|
||||
gender: string;
|
||||
};
|
||||
|
||||
export type LolyoZeile = Record<string, string>;
|
||||
|
||||
/**
|
||||
* Eine Zeile.
|
||||
*
|
||||
* Der Benutzercode ist die Alpenwerk-UUID. Anders als bei Cornerstone ist das
|
||||
* hier richtig: LOLYO kennt die Person noch nicht und vergibt keine eigene
|
||||
* Kennung, die wir treffen müssten — die UUID ist der Schlüssel, über den sich
|
||||
* ein späterer Abgleich wieder auf dieselbe Person bezieht.
|
||||
*
|
||||
* Die E-Mail-Spalte trägt die **Firmenadresse** und niemals die private aus
|
||||
* employees.email: die Datei legt Konten bei einem fremden Anbieter an.
|
||||
*/
|
||||
export function baueLolyoZeile(p: LolyoQuelle, passwort: string): LolyoZeile {
|
||||
// ohneTypografie steht an jeder Zelle, die aus den Daten kommt, und nicht nur
|
||||
// an der Position: aufgefallen ist es dort, weil die Tätigkeiten einen
|
||||
// Gedankenstrich führen — dieselben Zeichen stehen aber auch in Namen und
|
||||
// Titeln, und die fielen erst bei der Person auf, die einen trägt.
|
||||
const aus = (wert: string) => ohneTypografie(wert);
|
||||
|
||||
return {
|
||||
"Benutzer/Code": p.id,
|
||||
Email: aus(p.company_email ?? ""),
|
||||
// **Ohne** Umschrift: das Passwort muss Zeichen für Zeichen das sein, was
|
||||
// die Person eingibt. Ein ersetztes Zeichen fiele niemandem auf — es gäbe
|
||||
// nur ein Konto, in das niemand hineinkommt.
|
||||
Passwort: passwort,
|
||||
"Titel prefix": aus(p.title_prefix.join(" ")),
|
||||
Vorname: aus(p.first_name),
|
||||
Nachname: aus(p.last_name),
|
||||
"Title Suffix": aus(p.title_suffix.join(" ")),
|
||||
Position: aus(p.job_title),
|
||||
Gruppen: LOLYO_GRUPPE,
|
||||
Telefon: aus(p.phone ?? ""),
|
||||
Anrede: anrede(p.gender),
|
||||
Sprache: LOLYO_SPRACHE,
|
||||
"E-Mail bestätigt": LOLYO_EMAIL_BESTAETIGT,
|
||||
};
|
||||
}
|
||||
|
||||
/** Die Spalten in der Reihenfolge der Vorlage. */
|
||||
export const LOLYO_SPALTEN: readonly string[] = [
|
||||
"Benutzer/Code",
|
||||
"Email",
|
||||
"Passwort",
|
||||
"Titel prefix",
|
||||
"Vorname",
|
||||
"Nachname",
|
||||
"Title Suffix",
|
||||
"Position",
|
||||
"Gruppen",
|
||||
"Telefon",
|
||||
"Anrede",
|
||||
"Sprache",
|
||||
"E-Mail bestätigt",
|
||||
];
|
||||
|
||||
export function lolyoSpalten(): ExportColumn<LolyoZeile>[] {
|
||||
return LOLYO_SPALTEN.map((header) => ({ header, get: (z) => z[header] ?? "" }));
|
||||
}
|
||||
46
lib/org-nummer.ts
Normal file
46
lib/org-nummer.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// Ein Vorschlag für die nächste Orgnummer.
|
||||
//
|
||||
// Die Nummer wird eingetragen, nicht erzeugt — wie die Personalnummer, und aus
|
||||
// demselben Grund: sie stammt aus dem führenden System des Kunden, und eine
|
||||
// selbst vergebene Nummer wäre eine, die dort niemand kennt. Ein leeres Feld
|
||||
// in einem Dialog, der sonst zwei Klicks braucht, ist aber die Stelle, an der
|
||||
// jemand ins Stocken gerät.
|
||||
//
|
||||
// Deshalb ein **Vorschlag** und keine Vergabe: erkennbar ist nur, was sich aus
|
||||
// dem Bestand ablesen lässt, und wenn sich nichts ablesen lässt, steht hier
|
||||
// nichts. Geraten wird nicht — eine plausibel aussehende, aber erfundene
|
||||
// Systematik wäre schlimmer als ein leeres Feld, weil sie niemand nachprüft.
|
||||
//
|
||||
// Erkannt wird genau ein Muster: gleichbleibender Vorspann, dann Ziffern
|
||||
// („OE-0013“, „10000001“). Führende Nullen bleiben in der Breite erhalten, die
|
||||
// der Bestand führt.
|
||||
|
||||
const MUSTER = /^(.*?)(\d+)$/;
|
||||
|
||||
export function naechsteOrgnummer(vorhandene: readonly string[]): string | undefined {
|
||||
if (vorhandene.length === 0) return undefined;
|
||||
|
||||
let vorspann: string | null = null;
|
||||
let breite = 0;
|
||||
let hoechste = 0;
|
||||
|
||||
for (const nummer of vorhandene) {
|
||||
const treffer = MUSTER.exec(nummer.trim());
|
||||
if (!treffer) return undefined;
|
||||
|
||||
const [, kopf, ziffern] = treffer;
|
||||
// Zwei Systematiken nebeneinander („OE-0013“ und „B7“) — dann gibt es
|
||||
// keine nächste Nummer, sondern zwei Reihen, und welche gemeint ist,
|
||||
// weiss nur der Mensch vor dem Dialog.
|
||||
if (vorspann === null) vorspann = kopf;
|
||||
else if (vorspann !== kopf) return undefined;
|
||||
|
||||
breite = Math.max(breite, ziffern.length);
|
||||
hoechste = Math.max(hoechste, Number(ziffern));
|
||||
}
|
||||
|
||||
const naechste = hoechste + 1;
|
||||
// Wächst die Zahl über die bisherige Breite hinaus, wird sie eben länger —
|
||||
// abzuschneiden hiesse, eine bereits vergebene Nummer vorzuschlagen.
|
||||
return `${vorspann}${String(naechste).padStart(breite, "0")}`;
|
||||
}
|
||||
@@ -60,7 +60,16 @@ export async function loadOrgAsOf(tx: Tx, asOf: string): Promise<OrgAsOfResult>
|
||||
// Ohne die 1000-Zeilen-Grenze der API-Schicht fällt das seitenweise
|
||||
// Nachladen weg: sechs Abfragen, jede vollständig.
|
||||
const [units, positions, assignments, employees, pending, earliest] = await Promise.all([
|
||||
tx.selectFrom("org_units").select(["id", "parent_id"]).orderBy("id").execute(),
|
||||
// Zum Stichtag, wie die Planstellen und die Besetzungen darunter. Hier
|
||||
// stand die Tabelle ungefiltert: eine Einheit, die es 2020 noch nicht gab,
|
||||
// erschien in der Organisation von 2020. Gemeldet am 29.09. (D.06).
|
||||
tx
|
||||
.selectFrom("org_units")
|
||||
.select(["id", "parent_id"])
|
||||
.where("valid_from", "<=", asOf)
|
||||
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)]))
|
||||
.orderBy("id")
|
||||
.execute(),
|
||||
|
||||
tx
|
||||
.selectFrom("om_positions as p")
|
||||
|
||||
@@ -42,6 +42,20 @@ export type OpenPositionResolved = {
|
||||
kostenstelle: { code: string; name: string } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Was der Änderungsdialog von einer Planstelle braucht — und nicht mehr.
|
||||
*
|
||||
* Er verlangte bisher eine `OpenPositionResolved`, also die *unbesetzte*
|
||||
* Planstelle samt „seit wann offen" und „wer würde sie führen". Damit liess er
|
||||
* sich nur dort öffnen, wo diese Angaben entstehen: auf der Seite Positionen,
|
||||
* die ausschliesslich unbesetzte Stellen zeigt. Die Kontierung einer besetzten
|
||||
* Planstelle war deshalb nirgends änderbar — bei Manner betraf das 784 von 788.
|
||||
*/
|
||||
export type PlanstelleZumAendern = Pick<
|
||||
OpenPositionResolved,
|
||||
"id" | "position_number" | "title" | "org_unit_id" | "is_chief" | "valid_from" | "valid_to" | "kostenstelle"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen
|
||||
* Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ABSENCE_TYPES } from "./absence";
|
||||
import { AUSTRITTSART_LABELS, AUSTRITTSARTEN, BEENDIGUNGSART_WERTE } from "./beendigung";
|
||||
import { fmtDate } from "./format";
|
||||
import { HAY_GRADE_WERTE } from "./hay-grade";
|
||||
import { KUENDIGUNGSSCHUTZ_GRUENDE } from "./kuendigungsschutz";
|
||||
import { MITARBEITERARTEN } from "./mitarbeiterart";
|
||||
import { parseIsoDateParam } from "./reports";
|
||||
@@ -49,7 +51,7 @@ export const AUSWAHL_KRITERIEN: readonly AuswahlKriterium[] = [
|
||||
{ key: "worker", label: "Beschäftigtengruppe", alle: "Alle", optionen: werte("Angestellte:r", "Arbeiter:in", "Lehrling") },
|
||||
{ key: "mitarbeiterart", label: "Mitarbeiterart", alle: "Alle Mitarbeiterarten", optionen: werte(...MITARBEITERARTEN) },
|
||||
{ key: "kv", label: "Kollektivvertrag", alle: "Alle Kollektivverträge", optionen: werte("Handel", "Süßwaren") },
|
||||
{ key: "paygrade", label: "Paygrade", alle: "Alle Paygrades", optionen: werte("A", "B", "C", "D", "E", "F") },
|
||||
{ key: "paygrade", label: "Hay-Grade", alle: "Alle Hay-Grades", optionen: werte(...HAY_GRADE_WERTE) },
|
||||
{ key: "source", label: "Intern/Extern", alle: "Alle", optionen: werte("Intern", "Extern") },
|
||||
{
|
||||
key: "gender",
|
||||
@@ -178,6 +180,49 @@ export function anzahlKriterien(c: Criteria): number {
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die gesetzten Kriterien in Worten.
|
||||
*
|
||||
* Die Exportkarten nannten nur den Status. Die übrigen Kriterien stehen
|
||||
* eingeklappt weiter oben und wirken auf jeden Export dieser Seite genauso —
|
||||
* wer eines gesetzt und vergessen hatte, lud eine Datei herunter, die nach dem
|
||||
* ganzen Haus aussah und einen Ausschnitt enthielt. Bei einer Datei für ein
|
||||
* Zielsystem merkt das niemand mehr, sobald sie dort eingespielt ist: dort
|
||||
* fehlen dann Personen, und es sieht aus wie ein Datenstand, nicht wie ein
|
||||
* Filter.
|
||||
*
|
||||
* `anzahlKriterien` daneben zählt dasselbe; hier steht, *was* es ist.
|
||||
*/
|
||||
export function beschreibeKriterien(c: Criteria): string[] {
|
||||
const teile: string[] = [];
|
||||
|
||||
for (const k of AUSWAHL_KRITERIEN) {
|
||||
const wert = c.auswahl[k.key];
|
||||
if (!wert) continue;
|
||||
teile.push(`${k.label}: ${k.optionen.find((o) => o.wert === wert)?.label ?? wert}`);
|
||||
}
|
||||
|
||||
for (const k of JANEIN_KRITERIEN) {
|
||||
const wert = c.jaNein[k.key];
|
||||
if (wert) teile.push(`${k.label}: ${wert}`);
|
||||
}
|
||||
|
||||
for (const k of ZEITRAUM_KRITERIEN) {
|
||||
const z = c.zeitraum[k.key];
|
||||
if (!z) continue;
|
||||
if (z.von && z.bis) teile.push(`${k.label} ${fmtDate(z.von)} bis ${fmtDate(z.bis)}`);
|
||||
else if (z.von) teile.push(`${k.label} ab ${fmtDate(z.von)}`);
|
||||
else if (z.bis) teile.push(`${k.label} bis ${fmtDate(z.bis)}`);
|
||||
}
|
||||
|
||||
const { stundenVon: von, stundenBis: bis } = c;
|
||||
if (von !== undefined && bis !== undefined) teile.push(`Wochenstunden ${von} bis ${bis}`);
|
||||
else if (von !== undefined) teile.push(`Wochenstunden ab ${von}`);
|
||||
else if (bis !== undefined) teile.push(`Wochenstunden bis ${bis}`);
|
||||
|
||||
return teile;
|
||||
}
|
||||
|
||||
/** Alle Parameternamen, die zu den Kriterien gehören — zum Zurücksetzen. */
|
||||
export function kriterienParameter(): string[] {
|
||||
return [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ABSENCE_TYPES } from "./absence";
|
||||
import { ABSENCE_TYPES, statusLabel } from "./absence";
|
||||
import { BEENDIGUNGSART_WERTE } from "./beendigung";
|
||||
import { fmtName, todayIso, yearsBetweenIso } from "./format";
|
||||
import { HAY_GRADE_WERTE } from "./hay-grade";
|
||||
import { WOCHENTAGE } from "./wochentage";
|
||||
import type { EmploymentStatus, HistoryEventType, Weekday } from "./types";
|
||||
|
||||
@@ -31,6 +32,33 @@ export type GroupDimension =
|
||||
| "teilzeit_art"
|
||||
| "weekday";
|
||||
|
||||
/**
|
||||
* Die Kennzahl mit dem, was sie tatsächlich zählt.
|
||||
*
|
||||
* „Headcount" stand auf der Übersicht für die Aktiven und im Bericht für eine
|
||||
* andere Zahl — dasselbe Wort für Verschiedenes, gemeldet als N.02. Der Kunde
|
||||
* führt zwei Grössen mit festen Namen (Max, 30.09.): **Headcount Aktiv** für
|
||||
* die Aktiven und **Headcount Aktives Dienstverhältnis** für Aktive plus
|
||||
* Langzeitabwesende.
|
||||
*
|
||||
* Die Kennzahl im Bericht zählt aber, was gerade ausgewählt ist — auch
|
||||
* Geplante oder Ausgetretene. Ein fester Name wäre dort also schlicht falsch.
|
||||
* Deshalb: trifft die Auswahl eine der beiden Grössen, steht ihr Name da;
|
||||
* sonst steht dabei, welche Status gezählt wurden.
|
||||
*/
|
||||
export function messgroesseLabel(measure: Measure, statuses: readonly string[]): string {
|
||||
const basis = MEASURE_LABELS[measure];
|
||||
if (measure !== "headcount") return basis;
|
||||
|
||||
const gewaehlt = new Set(statuses);
|
||||
if (gewaehlt.size === 1 && gewaehlt.has("Aktiv")) return "Headcount Aktiv";
|
||||
if (gewaehlt.size === 2 && gewaehlt.has("Aktiv") && gewaehlt.has("Karenz")) {
|
||||
return "Headcount Aktives Dienstverhältnis";
|
||||
}
|
||||
if (gewaehlt.size === 0) return basis;
|
||||
return `${basis} (${statuses.map((s) => statusLabel(s as EmploymentStatus)).join(", ")})`;
|
||||
}
|
||||
|
||||
export const MEASURE_LABELS: Record<Measure, string> = {
|
||||
headcount: "Headcount",
|
||||
fte: "FTE",
|
||||
@@ -51,7 +79,7 @@ export const GROUP_LABELS: Record<GroupDimension, string> = {
|
||||
contract_type: "Vertragsart",
|
||||
entry_year: "Eintrittsjahr",
|
||||
source: "Intern/Extern",
|
||||
paygrade: "Paygrade",
|
||||
paygrade: "Hay-Grade",
|
||||
worker_type: "Beschäftigtengruppe",
|
||||
collective_agreement: "Kollektivvertrag",
|
||||
betriebsrat: "Betriebsrat",
|
||||
@@ -235,20 +263,43 @@ export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: Org
|
||||
}
|
||||
}
|
||||
|
||||
function weekdayRank(key: string): number {
|
||||
const i = WOCHENTAGE.indexOf(key as Weekday);
|
||||
return i === -1 ? WOCHENTAGE.length : i;
|
||||
/**
|
||||
* Dimensionen, deren Werte eine eigene Reihenfolge haben.
|
||||
*
|
||||
* Alles andere wird nach der Kennzahl sortiert, gross zuerst — bei „Bereich"
|
||||
* oder „Standort" ist das die Antwort auf die Frage, die der Bericht stellt.
|
||||
* Bei einer Leiter ist es keine: Montag vor Dienstag und HG09 vor HG10 sind
|
||||
* die Reihenfolge, in der die Werte *sind*, und eine nach Häufigkeit
|
||||
* umgestellte Leiter liest sich als Zufall.
|
||||
*
|
||||
* HG09 ist die unterste Stufe, HG20 die oberste. Der „Generic Grade" (`-`)
|
||||
* steht vor allen: er ist keine Stufe, sondern ihr Fehlen, und vor der
|
||||
* niedrigsten ist der Platz, an dem das am wenigsten nach einer Aussage
|
||||
* aussieht.
|
||||
*/
|
||||
const EIGENE_REIHENFOLGE: Partial<Record<GroupDimension, readonly string[]>> = {
|
||||
weekday: WOCHENTAGE,
|
||||
paygrade: HAY_GRADE_WERTE,
|
||||
};
|
||||
|
||||
function rang(dim: GroupDimension, key: string): number {
|
||||
const liste = EIGENE_REIHENFOLGE[dim];
|
||||
if (!liste) return 0;
|
||||
const i = liste.indexOf(key);
|
||||
// Unbekanntes hinten, nicht vorn: ein Wert, den die Liste nicht kennt, soll
|
||||
// auffallen und nicht die Leiter anführen.
|
||||
return i === -1 ? liste.length : i;
|
||||
}
|
||||
|
||||
function sortByWeekday<T extends { key: string }>(items: T[]): T[] {
|
||||
return [...items].sort((a, b) => weekdayRank(a.key) - weekdayRank(b.key));
|
||||
function sortiereNachReihenfolge<T extends { key: string }>(items: T[], dim: GroupDimension): T[] {
|
||||
return [...items].sort((a, b) => rang(dim, a.key) - rang(dim, b.key));
|
||||
}
|
||||
|
||||
// Reused by ReportsPageClient (split legend) and the report export route
|
||||
// (split columns) to render a `weekday` split chronologically rather than
|
||||
// (split columns) to render a split in the order its values have rather than
|
||||
// in first-encountered order; a no-op for every other dimension.
|
||||
export function sortKeysForDimension(keys: string[], dim: GroupDimension): string[] {
|
||||
return dim === "weekday" ? [...keys].sort((a, b) => weekdayRank(a) - weekdayRank(b)) : keys;
|
||||
return EIGENE_REIHENFOLGE[dim] ? [...keys].sort((a, b) => rang(dim, a) - rang(dim, b)) : keys;
|
||||
}
|
||||
|
||||
// Every dimension other than `weekday` is a strict single-key partition
|
||||
@@ -326,11 +377,11 @@ export function aggregateReport(
|
||||
value: measureValue(sRows, measure, asOf),
|
||||
count: sRows.length,
|
||||
}));
|
||||
row.split = split === "weekday" ? sortByWeekday(splitRows) : splitRows;
|
||||
row.split = EIGENE_REIHENFOLGE[split] ? sortiereNachReihenfolge(splitRows, split) : splitRows;
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
return group === "weekday" ? sortByWeekday(rows) : rows.sort((a, b) => b.value - a.value);
|
||||
return EIGENE_REIHENFOLGE[group] ? sortiereNachReihenfolge(rows, group) : rows.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
export function sumValues(rows: { value: number }[]): number {
|
||||
@@ -349,7 +400,7 @@ export function totalForRows(rows: { value: number; count: number }[], measure:
|
||||
export const REPORT_PRESETS: { name: string; measure: Measure; group: GroupDimension; split?: GroupDimension }[] = [
|
||||
{ name: "Headcount nach Bereich", measure: "headcount", group: "division" },
|
||||
{ name: "Frauenanteil nach Bereich", measure: "female_share", group: "division" },
|
||||
{ name: "Headcount nach Paygrade", measure: "headcount", group: "paygrade" },
|
||||
{ name: "Headcount nach Hay-Grade", measure: "headcount", group: "paygrade" },
|
||||
{ name: "Teilzeitquote nach Standort", measure: "parttime_rate", group: "location" },
|
||||
{ name: "Headcount nach Wochentag", measure: "headcount", group: "weekday" },
|
||||
{ name: "Headcount nach C-Level", measure: "headcount", group: "c_level" },
|
||||
|
||||
36
lib/types.ts
36
lib/types.ts
@@ -6,7 +6,26 @@
|
||||
export type EmploymentStatus = "Aktiv" | "Karenz" | "Geplant" | "Ausgetreten";
|
||||
export type EmploymentType = "Vollzeit" | "Teilzeit";
|
||||
export type ContractType = "unbefristet" | "befristet";
|
||||
export type PaygradeType = "A" | "B" | "C" | "D" | "E" | "F";
|
||||
/**
|
||||
* Der Hay-Grade. Der Name der Spalte ist `paygrade` geblieben — sie steht in
|
||||
* rund zehn SQL-Funktionen, und eine Umbenennung hätte jede davon angefasst,
|
||||
* ohne dass sich am Inhalt etwas ändert. Die Liste führt lib/hay-grade.ts.
|
||||
*/
|
||||
export type PaygradeType =
|
||||
| "-"
|
||||
| "HG09"
|
||||
| "HG10"
|
||||
| "HG11"
|
||||
| "HG12"
|
||||
| "HG13"
|
||||
| "HG14"
|
||||
| "HG15"
|
||||
| "HG16"
|
||||
| "HG17"
|
||||
| "HG18"
|
||||
| "HG19"
|
||||
| "HG19P"
|
||||
| "HG20";
|
||||
export type SourceType = "Intern" | "Extern";
|
||||
export type GenderType = "m" | "w";
|
||||
/**
|
||||
@@ -214,6 +233,14 @@ export type Database = {
|
||||
* das Haus verlassen. Der Honestly-Export nimmt diese hier.
|
||||
*/
|
||||
company_email: string | null;
|
||||
/**
|
||||
* Kennung der Person im Lernsystem Cornerstone (CSOD).
|
||||
*
|
||||
* Freiwillig und eindeutig. Als Text geführt — führende Nullen
|
||||
* gingen in einer numerischen Spalte verloren, und gerechnet wird
|
||||
* mit der Kennung ohnehin nicht.
|
||||
*/
|
||||
cornerstone_id: string | null;
|
||||
phone: string | null;
|
||||
job_title: string;
|
||||
location_id: string;
|
||||
@@ -294,6 +321,7 @@ export type Database = {
|
||||
address_country?: string | null;
|
||||
email?: string | null;
|
||||
company_email?: string | null;
|
||||
cornerstone_id?: string | null;
|
||||
phone?: string | null;
|
||||
job_title: string;
|
||||
location_id: string;
|
||||
@@ -733,6 +761,12 @@ export type Database = {
|
||||
// Kopie einer bestehenden Planstelle — gleiche Einheit, Tätigkeit und
|
||||
// Kontierung, neue Nummer, unbesetzt. Leitungsplanstellen ausgenommen.
|
||||
clone_position: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
// Eine Organisationseinheit unter einer bestehenden. Nur anlegen:
|
||||
// parent_id trägt kein Datum, ein Verschieben änderte deshalb auch die
|
||||
// Vergangenheit — siehe Migration 20260929120000.
|
||||
create_org_unit: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
// Nur, wenn nichts darunter hängt und nie etwas hing — siehe 20260929140000.
|
||||
delete_org_unit: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
update_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
set_position_cost_center: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
set_onboarding_task: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
|
||||
@@ -55,6 +55,10 @@ export function vorbelegungAus(employee: EmployeeRow): Partial<HireDraftData> {
|
||||
// stillschweigend. Sichtbar vorbelegt lässt sie sich bewusst ersetzen
|
||||
// oder leeren.
|
||||
companyEmail: employee.company_email ?? "",
|
||||
// Aus demselben Grund vorbelegt wie die Firmenadresse: der Assistent
|
||||
// schickt das Feld in jedem Fall mit, und leer geliefert löschte es die
|
||||
// gespeicherte Kennung stillschweigend.
|
||||
cornerstoneId: employee.cornerstone_id ?? "",
|
||||
phone: employee.phone ?? "",
|
||||
locationId: employee.location_id,
|
||||
besetzung: employee.source,
|
||||
|
||||
86
tests/components/DateField.test.tsx
Normal file
86
tests/components/DateField.test.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DateField, istMeldbaresDatum } from "@/components/ui/Field";
|
||||
|
||||
// Der Anlass: in den Berichten hängt jedes Datumsfeld an der Adresszeile, eine
|
||||
// Änderung heisst also neu laden. Ein `<input type="date">` meldet beim Tippen
|
||||
// der Jahreszahl aber viermal — 0002, 0020, 0202, 2026 —, und die ersten drei
|
||||
// setzten das Feld mitten im Tippen zurück. Mit dem Kalender ging es, weil der
|
||||
// in einem Zug ein fertiges Datum setzt.
|
||||
|
||||
describe("istMeldbaresDatum", () => {
|
||||
it("nimmt ein vollständiges Datum an", () => {
|
||||
expect(istMeldbaresDatum("2026-09-30")).toBe(true);
|
||||
expect(istMeldbaresDatum("1980-01-01")).toBe(true);
|
||||
});
|
||||
|
||||
it("nimmt das leere Feld an, denn kein Datum ist auch eine Aussage", () => {
|
||||
expect(istMeldbaresDatum("")).toBe(true);
|
||||
});
|
||||
|
||||
it("weist die Zwischenstände beim Tippen ab", () => {
|
||||
for (const zwischen of ["0002-09-30", "0020-09-30", "0202-09-30"]) {
|
||||
expect(istMeldbaresDatum(zwischen), zwischen).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("weist Bruchstücke ab", () => {
|
||||
expect(istMeldbaresDatum("2026-09")).toBe(false);
|
||||
expect(istMeldbaresDatum("2026")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DateField", () => {
|
||||
function feld(onChange: (v: string) => void, value = "") {
|
||||
render(<DateField label="Von" value={value} onChange={onChange} />);
|
||||
return screen.getByLabelText("Von") as HTMLInputElement;
|
||||
}
|
||||
|
||||
it("meldet den Zwischenstand nicht, zeigt ihn aber an", () => {
|
||||
const gemeldet = vi.fn();
|
||||
const input = feld(gemeldet);
|
||||
|
||||
fireEvent.change(input, { target: { value: "0002-09-30" } });
|
||||
expect(gemeldet).not.toHaveBeenCalled();
|
||||
// Der Tippstand bleibt stehen: würde das Feld ihn verwerfen, kämpfte es
|
||||
// gegen die Eingabe.
|
||||
expect(input.value).toBe("0002-09-30");
|
||||
});
|
||||
|
||||
it("meldet, sobald die Jahreszahl vollständig ist", () => {
|
||||
const gemeldet = vi.fn();
|
||||
const input = feld(gemeldet);
|
||||
|
||||
fireEvent.change(input, { target: { value: "0002-09-30" } });
|
||||
fireEvent.change(input, { target: { value: "2026-09-30" } });
|
||||
expect(gemeldet).toHaveBeenCalledExactlyOnceWith("2026-09-30");
|
||||
});
|
||||
|
||||
it("meldet das Leeren", () => {
|
||||
const gemeldet = vi.fn();
|
||||
const input = feld(gemeldet, "2026-09-30");
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
expect(gemeldet).toHaveBeenCalledExactlyOnceWith("");
|
||||
});
|
||||
|
||||
it("reicht beim Verlassen nach, was liegengeblieben ist", () => {
|
||||
// Kommt in der Praxis kaum vor, ist aber der Rückfall: was nicht gemeldet
|
||||
// wurde, geht spätestens hier hinaus — sofern es eine Aussage ist.
|
||||
const gemeldet = vi.fn();
|
||||
const input = feld(gemeldet);
|
||||
fireEvent.change(input, { target: { value: "0002-09-30" } });
|
||||
fireEvent.blur(input);
|
||||
expect(gemeldet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("übernimmt einen Wert, der von aussen kommt", () => {
|
||||
// „Zurücksetzen" in den Kriterien leert das Feld — der Tippstand darf
|
||||
// dann nicht stehenbleiben.
|
||||
const { rerender } = render(<DateField label="Von" value="2026-09-30" onChange={vi.fn()} />);
|
||||
const input = screen.getByLabelText("Von") as HTMLInputElement;
|
||||
expect(input.value).toBe("2026-09-30");
|
||||
|
||||
rerender(<DateField label="Von" value="" onChange={vi.fn()} />);
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
});
|
||||
116
tests/components/PositionTree.test.tsx
Normal file
116
tests/components/PositionTree.test.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { render as rtlRender, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildUnitTree, PositionTree } from "@/components/orgchart/PositionTree";
|
||||
import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "@/components/orgchart/types";
|
||||
import { ToastProvider } from "@/components/ui/Toast";
|
||||
|
||||
vi.mock("@/actions/org", () => ({
|
||||
createOrgUnit: vi.fn(async () => ({ success: true })),
|
||||
deleteOrgUnit: vi.fn(async () => ({ success: true })),
|
||||
}));
|
||||
// Die Angaben zu einer Einheit laden ihre Planstellen nach; das Aktionsmodul
|
||||
// zieht über lib/db das Paket `server-only` nach und ist im Test ein
|
||||
// Importfehler. Geprüft wird hier, was die Ansicht anbietet, nicht was die
|
||||
// Datenbank liefert.
|
||||
vi.mock("@/actions/positions", () => ({
|
||||
ladePlanstellenDerEinheit: vi.fn(async () => ({ planstellen: [], kostenstellen: [], heute: "2026-09-29" })),
|
||||
updatePosition: vi.fn(async () => ({ success: true })),
|
||||
setPositionCostCenter: vi.fn(async () => ({ success: true })),
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }));
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
return rtlRender(<ToastProvider>{ui}</ToastProvider>);
|
||||
}
|
||||
|
||||
// Der Knopf „Einheit darunter anlegen" hängt allein daran, dass ein Knoten
|
||||
// eine `unitId` trägt. Verschwindet die still — etwa weil jemand den Baum
|
||||
// umbaut —, bleibt die Oberfläche fehlerfrei und die Handlung ist weg.
|
||||
|
||||
const UNITS: OrgUnitNode[] = [
|
||||
{ id: "u1", org_number: "OE-0001", name: "Gesellschaft", parent_id: null, unit_type: "Gesellschaft" },
|
||||
{ id: "u2", org_number: "OE-0010", name: "Produktion", parent_id: "u1", unit_type: "Bereich" },
|
||||
];
|
||||
|
||||
const EMPLOYEES: OrgEmployee[] = [
|
||||
{
|
||||
id: "p1",
|
||||
personnel_number: 1,
|
||||
first_name: "Anna",
|
||||
last_name: "Huber",
|
||||
job_title: "Maschinenbedienung",
|
||||
manager_id: null,
|
||||
formal_manager_id: null,
|
||||
absent: false,
|
||||
absence_type: null,
|
||||
org_unit_id: "u2",
|
||||
is_chief: false,
|
||||
position_id: "s1",
|
||||
position_number: "S-0001",
|
||||
},
|
||||
];
|
||||
|
||||
const VACANCIES: OrgVacancy[] = [
|
||||
{ position_id: "s2", position_number: "S-0002", job_title: "Schlosser:in", org_unit_id: "u2", is_chief: false },
|
||||
];
|
||||
|
||||
describe("buildUnitTree", () => {
|
||||
const baum = buildUnitTree(UNITS, EMPLOYEES, VACANCIES);
|
||||
|
||||
it("trägt die Einheit an jedem Einheitenknoten", () => {
|
||||
expect(baum[0].unitId).toBe("u1");
|
||||
expect(baum[0].children[0].unitId).toBe("u2");
|
||||
});
|
||||
|
||||
it("trägt sie an nichts anderem", () => {
|
||||
// Eine Person, eine Tätigkeitsgruppe oder eine Vakanz ist keine Einheit —
|
||||
// ein Plus daran würde etwas anlegen, das woanders hinge.
|
||||
const bereich = baum[0].children[0];
|
||||
for (const kind of bereich.children.filter((k) => k.kind !== "role")) {
|
||||
expect(kind.unitId, `„${kind.label}" darf keine Einheit tragen`).toBeUndefined();
|
||||
for (const enkel of kind.children) expect(enkel.unitId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PositionTree", () => {
|
||||
it("bietet je Einheit einen Knopf zum Anlegen an", async () => {
|
||||
render(<PositionTree employees={EMPLOYEES} units={UNITS} vacancies={VACANCIES} />);
|
||||
// Die Wurzel ist aufgeklappt; ihr Kind ist damit sichtbar.
|
||||
expect(await screen.findByRole("button", { name: /Einheit unter .*Gesellschaft anlegen/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Einheit unter .*Produktion anlegen/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("bietet ihn nicht an einer Person an", () => {
|
||||
render(<PositionTree employees={EMPLOYEES} units={UNITS} vacancies={VACANCIES} />);
|
||||
expect(screen.queryByRole("button", { name: /Einheit unter Huber/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("bietet den Papierkorb nur an, wo nichts darunter hängt", () => {
|
||||
// „Produktion" trägt eine Person und eine Vakanz, die Gesellschaft trägt
|
||||
// „Produktion" — entfernbar ist keine von beiden. Ein Knopf, der beim
|
||||
// Klick eine Absage erteilt, ist schlechter als kein Knopf.
|
||||
render(<PositionTree employees={EMPLOYEES} units={UNITS} vacancies={VACANCIES} />);
|
||||
expect(screen.queryByRole("button", { name: /Einheit .* entfernen/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("bietet ihn an einer leeren Einheit an", () => {
|
||||
const leer: OrgUnitNode = { id: "u3", org_number: "OE-0411", name: "Neu", parent_id: "u1", unit_type: "Bereich" };
|
||||
render(<PositionTree employees={EMPLOYEES} units={[...UNITS, leer]} vacancies={VACANCIES} />);
|
||||
expect(screen.getByRole("button", { name: /Einheit .*OE-0411.* entfernen/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("öffnet beim Klick auf eine Einheit deren Angaben", async () => {
|
||||
// Der Klick auf die Karte war lange ohne Wirkung — die naheliegendste
|
||||
// Geste in dieser Ansicht, und sie antwortete nicht.
|
||||
render(<PositionTree employees={EMPLOYEES} units={UNITS} vacancies={VACANCIES} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /Angaben zu .*Produktion/ }));
|
||||
|
||||
const panel = await screen.findByRole("dialog");
|
||||
expect(within(panel).getByText("OE-0010 · Bereich")).toBeInTheDocument();
|
||||
// Eine Person, keine Leitung, eine offene Planstelle — genau der Bestand
|
||||
// aus den Fixtures.
|
||||
expect(within(panel).getByText("vakant")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -103,6 +103,34 @@ describe("PrintChart — Messung", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Von Max gewünscht (30.09.), ausdrücklich zum An- und Abwählen. Die Nummer
|
||||
// steht hinter dem Namen und nicht in einer eigenen Zeile: auf einem Blatt mit
|
||||
// zweihundert Kästen kostet jede Zeile Massstab.
|
||||
describe("PrintChart — Personalnummer", () => {
|
||||
/** Die Zeile, in der eine Person steht — nicht die Kopfzeile ihrer Einheit. */
|
||||
function personZeile(nachname: string): string {
|
||||
return screen.getAllByText(new RegExp(nachname))[0].closest("p")!.textContent ?? "";
|
||||
}
|
||||
|
||||
it("druckt sie erst, wenn sie angehakt ist", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderChart();
|
||||
await zurVorschau(user);
|
||||
// Wiesinger führt die Gesellschaft und steht damit auf jedem Blatt.
|
||||
expect(personZeile("Wiesinger")).not.toContain("· 1");
|
||||
});
|
||||
|
||||
it("stellt sie hinter den Namen, sobald sie angehakt ist", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderChart();
|
||||
await user.click(screen.getByLabelText(/Personalnummer/));
|
||||
await zurVorschau(user);
|
||||
// Die Leitung steht auch auf Blättern ohne Namensliste — deshalb ist das
|
||||
// Häkchen auf jeder Tiefe zu haben.
|
||||
expect(personZeile("Wiesinger")).toContain("· 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PrintChart", () => {
|
||||
it("fragt zuerst, was gedruckt wird, und zeigt vorher kein Blatt", () => {
|
||||
renderChart();
|
||||
|
||||
@@ -43,7 +43,7 @@ function mitarbeiter(teil: Partial<EmployeeRow> = {}): EmployeeRow {
|
||||
monthly_salary_gross: null,
|
||||
contract_type: "unbefristet",
|
||||
contract_end_date: null,
|
||||
paygrade: "C",
|
||||
paygrade: "HG13",
|
||||
source: "Extern",
|
||||
status: "Geplant",
|
||||
entry_date: EINTRITT,
|
||||
|
||||
@@ -36,7 +36,7 @@ function mitarbeiter(teil: Partial<EmployeeRow> = {}): EmployeeRow {
|
||||
monthly_salary_gross: null,
|
||||
contract_type: "unbefristet",
|
||||
contract_end_date: null,
|
||||
paygrade: "C",
|
||||
paygrade: "HG13",
|
||||
source: "Extern",
|
||||
status: "Aktiv",
|
||||
entry_date: "2017-07-15",
|
||||
|
||||
@@ -92,11 +92,30 @@ describe("Gründe für weniger Stunden", () => {
|
||||
expect([...STUNDEN_GRUENDE]).toContain("Pflegeteilzeit");
|
||||
});
|
||||
|
||||
it("verteilt die vier Teilzeiten vollständig und ohne Überschneidung", () => {
|
||||
// Keine darf verlorengehen, und keine an beiden Stellen auftauchen —
|
||||
// sonst hinge dieselbe Sache an zwei Vorgängen.
|
||||
const verteilt = [...RUECKKEHR_GRUENDE, ...STUNDEN_GRUENDE].filter((g) => TEILZEITEN.includes(g));
|
||||
expect(new Set(verteilt)).toEqual(new Set(TEILZEITEN));
|
||||
expect(verteilt.length).toBe(TEILZEITEN.length);
|
||||
it("bietet alle vier Teilzeiten bei der Stundenänderung an", () => {
|
||||
// Bis zum 29.09. standen Eltern- und Wiedereingliederungsteilzeit nur bei
|
||||
// der Rückkehr — mit der Begründung, dass sie typischerweise dann
|
||||
// beginnen. Typischerweise ist nicht immer, und wer sie ohne
|
||||
// vorangegangene Abwesenheit vereinbart, fand sie nirgends. Gemeldet von
|
||||
// Lara: „Teilzeitvariante Elternteilzeit fehlt".
|
||||
for (const teilzeit of TEILZEITEN) {
|
||||
expect([...STUNDEN_GRUENDE], `„${teilzeit}" fehlt bei der Stundenänderung`).toContain(teilzeit);
|
||||
}
|
||||
});
|
||||
|
||||
it("überschneidet sich dabei bewusst mit der Rückkehr", () => {
|
||||
// Die Überschneidung ist kein Versehen: es ist derselbe Wert in derselben
|
||||
// Spalte, nur aus zwei Anlässen erfasst. Was es nicht geben darf, ist eine
|
||||
// Teilzeit, die an *keinem* der beiden Wege hängt.
|
||||
const beide = TEILZEITEN.filter(
|
||||
(t) => ([...RUECKKEHR_GRUENDE] as string[]).includes(t) && ([...STUNDEN_GRUENDE] as string[]).includes(t)
|
||||
);
|
||||
expect(new Set(beide)).toEqual(new Set(RUECKKEHR_GRUENDE));
|
||||
});
|
||||
|
||||
it("führt den Normalfall nicht als Teilzeit", () => {
|
||||
// „Vertragliche Stundenänderung" ist keine Variante, sondern ihr Fehlen:
|
||||
// sie landet als null in teilzeit_art.
|
||||
expect([...TEILZEITEN]).not.toContain("Vertragliche Stundenänderung");
|
||||
});
|
||||
});
|
||||
|
||||
341
tests/unit/cornerstone.test.ts
Normal file
341
tests/unit/cornerstone.test.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
baueCornerstoneZeile,
|
||||
benutzername,
|
||||
beschaeftigungsStatus,
|
||||
CORNERSTONE_CSV,
|
||||
CORNERSTONE_SPALTEN,
|
||||
cornerstoneSpalten,
|
||||
csDatum,
|
||||
DIVISION_GUID,
|
||||
geschlecht,
|
||||
kontoStatus,
|
||||
type CornerstoneKontext,
|
||||
type CornerstoneQuelle,
|
||||
} from "@/lib/cornerstone";
|
||||
import { toCsv } from "@/lib/export";
|
||||
|
||||
// Die Datei geht in ein fremdes System, das sie über eine im Portal
|
||||
// eingerichtete Zuordnung einliest. Zwei Sorten Fehler sind hier teuer:
|
||||
//
|
||||
// * Ein deutscher Anzeigename statt des Systemwerts („Aktiv" statt
|
||||
// „Active") weist Cornerstone mit „ungültiger Wert" ab — und zwar die
|
||||
// ganze Zeile, gefolgt von „Alle abhängigen Felder müssen gültig sein".
|
||||
// * Ein Datum in der falschen Reihenfolge fällt gar nicht auf: 09.03. und
|
||||
// 03.09. sind beide gültig.
|
||||
//
|
||||
// Deshalb steht hier beides fest, Wert für Wert.
|
||||
|
||||
// Die Kopfzeile aus der Vorlage, Zeichen für Zeichen.
|
||||
const VORGABE =
|
||||
"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";
|
||||
|
||||
const UUID = "8f2b1c40-9d3e-4a77-b2c1-5e6f70a81234";
|
||||
// Die Kennung, unter der Cornerstone die Person kennt — seit 20260928100000
|
||||
// fuehrt Alpenwerk sie mit, und sie steht in User ID und Username.
|
||||
const CS_ID = "10000382";
|
||||
|
||||
const kontext: CornerstoneKontext = {
|
||||
managerKennung: new Map([
|
||||
["chef", "10000117"],
|
||||
// Eine vorgesetzte Person, die selbst keine Cornerstone-ID trägt — bei 435
|
||||
// von 785 Personen der Normalfall, nicht der Sonderfall.
|
||||
["chef-ohne-kennung", null],
|
||||
]),
|
||||
kostenstelle: new Map([["pos-1", { id: "k", code: "41130", name: "Waffelproduktion" }]]),
|
||||
};
|
||||
|
||||
function person(teil: Partial<CornerstoneQuelle> = {}): CornerstoneQuelle {
|
||||
return {
|
||||
id: UUID,
|
||||
personnel_number: 9002,
|
||||
first_name: "Max",
|
||||
last_name: "Alpentest",
|
||||
title_prefix: [],
|
||||
title_suffix: [],
|
||||
gender: "m",
|
||||
company_email: "m.test@alpenwerk.test.com",
|
||||
cornerstone_id: CS_ID,
|
||||
address: "Teststrasse 2",
|
||||
postal_code: "1010",
|
||||
city: "Wien",
|
||||
address_country: "AT",
|
||||
entry_date: "2026-10-01",
|
||||
exit_date: null,
|
||||
exit_reason: null,
|
||||
austrittsart: null,
|
||||
absence_type: null,
|
||||
status: "Aktiv",
|
||||
paygrade: "HG15",
|
||||
location_id: "wien",
|
||||
position_id: "pos-1",
|
||||
manager_id: null,
|
||||
...teil,
|
||||
};
|
||||
}
|
||||
|
||||
const zeile = (teil: Partial<CornerstoneQuelle> = {}) => baueCornerstoneZeile(person(teil), kontext);
|
||||
|
||||
describe("Spalten", () => {
|
||||
it("stehen genau so und in genau der Reihenfolge wie in der Vorlage", () => {
|
||||
expect(CORNERSTONE_SPALTEN.join(",")).toBe(VORGABE);
|
||||
});
|
||||
|
||||
it("sind 57", () => {
|
||||
expect(CORNERSTONE_SPALTEN).toHaveLength(57);
|
||||
});
|
||||
|
||||
it("decken sich mit den Feldern, die eine Zeile liefert", () => {
|
||||
// Ein Feld, das die Zeile setzt, das aber in keiner Spalte steht, ginge
|
||||
// stillschweigend verloren.
|
||||
expect(Object.keys(zeile()).sort()).toEqual([...CORNERSTONE_SPALTEN].sort());
|
||||
});
|
||||
|
||||
it("schreibt die Datei mit Komma und ohne BOM", () => {
|
||||
// Stünden die Vorgaben von toCsv drin (Semikolon fürs deutsche Excel,
|
||||
// BOM gegen Buchstabensalat), käme in Cornerstone eine einspaltige Datei
|
||||
// an, deren Spalte auch noch ein unsichtbares Zeichen im Namen trägt.
|
||||
expect(CORNERSTONE_CSV).toEqual({ trenner: ",", bom: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Systemwerte statt Anzeigenamen", () => {
|
||||
it("setzt Status auf Active beziehungsweise Inactive", () => {
|
||||
expect(kontoStatus("Aktiv")).toBe("Active");
|
||||
expect(kontoStatus("Karenz")).toBe("Active");
|
||||
expect(kontoStatus("Ausgetreten")).toBe("Inactive");
|
||||
});
|
||||
|
||||
it("setzt Employment Status auf Working, On Leave oder Terminated", () => {
|
||||
expect(beschaeftigungsStatus("Aktiv")).toBe("Working");
|
||||
expect(beschaeftigungsStatus("Karenz")).toBe("On Leave");
|
||||
expect(beschaeftigungsStatus("Ausgetreten")).toBe("Terminated");
|
||||
// „Geplant" kennt Cornerstone nicht — die Person bekommt ein Konto.
|
||||
expect(beschaeftigungsStatus("Geplant")).toBe("Working");
|
||||
});
|
||||
|
||||
it("schreibt nirgends einen deutschen Anzeigenamen in die Statusfelder", () => {
|
||||
// Der häufigste Fehler aus früheren Loads.
|
||||
const z = zeile();
|
||||
expect([z.Status, z["Employment Status"], z["User Type"]]).toEqual(["Active", "Working", "Employee"]);
|
||||
});
|
||||
|
||||
it("lässt die Zeitzone leer, statt ein Kürzel zu schreiben", () => {
|
||||
// Gültig ist allein eine Zeitzonen-ID aus dem Portal; „CET" quittiert
|
||||
// der Load mit „Zeitzonencode nicht eindeutig".
|
||||
expect(zeile()["Time Zone"]).toBe("");
|
||||
});
|
||||
|
||||
it("übersetzt das Geschlecht in Cornerstones Werte", () => {
|
||||
expect(geschlecht("w")).toBe("female");
|
||||
expect(geschlecht("m")).toBe("male");
|
||||
// Alles andere ist nicht „leer", sondern ein gültiger Wert — sonst weist
|
||||
// Cornerstone die ganze Zeile ab.
|
||||
expect(geschlecht("x")).toBe("not specified");
|
||||
});
|
||||
|
||||
it("schreibt das Datum mit dem Tag zuerst", () => {
|
||||
expect(csDatum("2026-10-01")).toBe("01.10.2026");
|
||||
expect(csDatum(null)).toBe("");
|
||||
});
|
||||
|
||||
it("baut den Anmeldenamen ohne Umlaute und Sonderzeichen", () => {
|
||||
expect(benutzername("Jörg", "Müller-Groß")).toBe("joerg.muellergross");
|
||||
expect(benutzername("Anna Maria", "Berger")).toBe("annamaria.berger");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Eine Zeile gegen das Muster vom 27.09.2026", () => {
|
||||
it("trägt dieselben Werte wie der Testload", () => {
|
||||
expect(zeile()).toMatchObject({
|
||||
"Local System ID": "9002",
|
||||
"First Name": "Max",
|
||||
"Last Name": "Alpentest",
|
||||
Username: CS_ID,
|
||||
Email: "m.test@alpenwerk.test.com",
|
||||
Gender: "male",
|
||||
"Compensation Currency": "EUR",
|
||||
"Address Line 1": "Teststrasse 2",
|
||||
City: "Wien",
|
||||
Zip: "1010",
|
||||
Country: "AT",
|
||||
"Original Hire Date": "01.10.2026",
|
||||
"Last Hire Date": "01.10.2026",
|
||||
Status: "Active",
|
||||
Language: "de-DE",
|
||||
"Time Zone": "",
|
||||
"User Type": "Employee",
|
||||
"Employment Status": "Working",
|
||||
"Division ID": DIVISION_GUID,
|
||||
"Grade ID": "HG15",
|
||||
"Cost Center ID": "41130",
|
||||
"Customfield ID AD": "max.alpentest",
|
||||
"Customfield ID Doxis": "DOX-9002",
|
||||
"Customfield ID Interflex": "9002",
|
||||
"Customfield ID LGVplus": "LGV-9002",
|
||||
});
|
||||
});
|
||||
|
||||
it("lässt die Felder leer, die das Muster leer lässt", () => {
|
||||
const z = zeile();
|
||||
for (const feld of [
|
||||
"Prefix",
|
||||
"Middle Name",
|
||||
"Suffix",
|
||||
"Approver",
|
||||
"Manager",
|
||||
"Absent",
|
||||
"Allow Reconciliation",
|
||||
"Required Training Approvals",
|
||||
"Exempt",
|
||||
"Ethnicity",
|
||||
"Months of Service",
|
||||
"Address Line 2",
|
||||
"State",
|
||||
"Mailstop",
|
||||
"Phone",
|
||||
"Mobile",
|
||||
"Home Phone",
|
||||
"Fax",
|
||||
"User activation date",
|
||||
"User de-activation date",
|
||||
"Personal Email",
|
||||
"Leave Reason",
|
||||
"Termination Type",
|
||||
"Termination Reason",
|
||||
"Termination Date",
|
||||
"Eligible For Rehire",
|
||||
"Rehired Employee",
|
||||
"Position ID",
|
||||
"Location ID",
|
||||
"Retention Rules",
|
||||
"Organisationsstufe",
|
||||
]) {
|
||||
expect(z[feld], feld).toBe("");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Abhängige Felder", () => {
|
||||
it("füllt die Austrittsfelder nur bei Terminated", () => {
|
||||
// Ein Austrittsgrund ohne Austritt ist für den Load ein ungültiger
|
||||
// Zustand, nicht eine Zusatzangabe.
|
||||
const offen = zeile({ exit_date: "2026-12-31", exit_reason: "Kündigung", austrittsart: "freiwillig" });
|
||||
expect(offen["Termination Date"]).toBe("");
|
||||
expect(offen["Termination Type"]).toBe("");
|
||||
expect(offen["Termination Reason"]).toBe("");
|
||||
|
||||
const weg = zeile({ status: "Ausgetreten", exit_date: "2026-12-31", exit_reason: "Kündigung", austrittsart: "freiwillig" });
|
||||
expect(weg["Termination Date"]).toBe("31.12.2026");
|
||||
expect(weg["Termination Type"]).toBe("freiwillig");
|
||||
expect(weg["Termination Reason"]).toBe("Kündigung");
|
||||
expect(weg.Status).toBe("Inactive");
|
||||
expect(weg["Employment Status"]).toBe("Terminated");
|
||||
});
|
||||
|
||||
it("nennt den Abwesenheitsgrund nur bei On Leave", () => {
|
||||
expect(zeile({ absence_type: "Elternkarenz" })["Leave Reason"]).toBe("");
|
||||
const karenz = zeile({ status: "Karenz", absence_type: "Elternkarenz" });
|
||||
expect(karenz["Leave Reason"]).toBe("Elternkarenz");
|
||||
expect(karenz["Employment Status"]).toBe("On Leave");
|
||||
});
|
||||
|
||||
it("nennt keinen Abwesenheitsgrund an einer ausgetretenen Person", () => {
|
||||
const weg = zeile({ status: "Ausgetreten", absence_type: "Elternkarenz", exit_date: "2026-12-31" });
|
||||
expect(weg["Leave Reason"]).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Benutzername und Benutzer-ID", () => {
|
||||
it("tragen denselben Wert", () => {
|
||||
// In Alpenwerk sind die beiden stets im Gleichklang. Liefen sie
|
||||
// auseinander, zeigte Cornerstone zwei Kennungen für eine Person, und
|
||||
// jeder Verweis darauf (Manager, Berichte) träfe womöglich die falsche.
|
||||
const z = zeile();
|
||||
expect(z.Username).toBe(z["User ID"]);
|
||||
});
|
||||
|
||||
it("sind die Cornerstone-ID, nicht die UUID und nicht die Personalnummer", () => {
|
||||
// Hier stand die Alpenwerk-UUID, solange Alpenwerk die Kennung des
|
||||
// Lernsystems nicht kannte. Sie sagte dort niemandem etwas.
|
||||
const z = zeile();
|
||||
expect(z["User ID"]).toBe(CS_ID);
|
||||
expect(z.Username).toBe(CS_ID);
|
||||
expect(z["User ID"]).not.toBe(UUID);
|
||||
expect(z["User ID"]).not.toBe("9002");
|
||||
});
|
||||
|
||||
it("bleiben leer, wenn keine Cornerstone-ID hinterlegt ist", () => {
|
||||
// Kein Rueckfall auf die UUID: das braechte zwei Kennungsarten in eine
|
||||
// Datei, ohne dass es auffiele, und legte in Cornerstone eine zweite
|
||||
// Person neben der bestehenden an. Eine fehlende Angabe soll fehlen.
|
||||
const z = baueCornerstoneZeile(person({ cornerstone_id: null }), kontext);
|
||||
expect(z["User ID"]).toBe("");
|
||||
expect(z.Username).toBe("");
|
||||
expect(JSON.stringify(z)).not.toContain(UUID);
|
||||
});
|
||||
|
||||
it("fuehren die LOGA-Personalnummer in Local System ID", () => {
|
||||
expect(zeile()["Local System ID"]).toBe("9002");
|
||||
});
|
||||
|
||||
it("lassen den Anmeldenamen im Customfield AD stehen", () => {
|
||||
// Der Verzeichnisdienst kennt die Person unter vorname.nachname; das ist
|
||||
// nicht der Username in Cornerstone, sondern eine eigene Angabe.
|
||||
const z = zeile();
|
||||
expect(z["Customfield ID AD"]).toBe("max.alpentest");
|
||||
expect(z["Customfield ID AD"]).not.toBe(z.Username);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kennungen statt Namen", () => {
|
||||
it("verweist auf die vorgesetzte Person über deren Cornerstone-ID", () => {
|
||||
// Cornerstone verknüpft über die User ID, nicht über den Namen und nicht
|
||||
// über die Personalnummer: die ist in dieser Datei die Local System ID.
|
||||
expect(zeile({ manager_id: "chef" }).Manager).toBe("10000117");
|
||||
expect(zeile({ manager_id: null }).Manager).toBe("");
|
||||
});
|
||||
|
||||
it("lässt den Verweis leer, wenn die vorgesetzte Person keine Kennung trägt", () => {
|
||||
// Lieber keine Angabe als eine Kennung der falschen Art: eine
|
||||
// Personalnummer im Feld Manager sähe aus wie eine User ID und träfe im
|
||||
// Zielsystem womöglich eine andere Person.
|
||||
expect(zeile({ manager_id: "chef-ohne-kennung" }).Manager).toBe("");
|
||||
});
|
||||
|
||||
it("nimmt als Kostenstelle den Code der Planstelle", () => {
|
||||
expect(zeile()["Cost Center ID"]).toBe("41130");
|
||||
expect(zeile({ position_id: null })["Cost Center ID"]).toBe("");
|
||||
});
|
||||
|
||||
it("schreibt die Division als GUID", () => {
|
||||
expect(zeile()["Division ID"]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("als CSV", () => {
|
||||
it("schreibt die Kopfzeile Zeichen für Zeichen wie die Vorlage", () => {
|
||||
const csv = toCsv([zeile()], cornerstoneSpalten(), CORNERSTONE_CSV);
|
||||
expect(csv.startsWith("User ID,")).toBe(true);
|
||||
expect(csv.split(/\r?\n/)[0]).toBe(VORGABE);
|
||||
});
|
||||
|
||||
it("trennt die Zeilen mit CRLF", () => {
|
||||
expect(toCsv([zeile()], cornerstoneSpalten(), CORNERSTONE_CSV)).toContain("\r\n");
|
||||
});
|
||||
|
||||
it("maskiert ein Komma im Wert, statt die Zeile zu zerreissen", () => {
|
||||
const csv = toCsv([zeile({ address: "Teststrasse 2, Stiege 3" })], cornerstoneSpalten(), CORNERSTONE_CSV);
|
||||
expect(csv).toContain('"Teststrasse 2, Stiege 3"');
|
||||
// Felder zählen, nicht Kommas: das maskierte Komma steht ja in der Zeile.
|
||||
const felder = (t: string) => t.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/).length;
|
||||
const [kopf, daten] = csv.split(/\r?\n/);
|
||||
expect(felder(daten)).toBe(felder(kopf));
|
||||
expect(felder(kopf)).toBe(57);
|
||||
});
|
||||
|
||||
it("lässt die private Adresse nirgends durch", () => {
|
||||
const csv = toCsv([zeile({ company_email: null })], cornerstoneSpalten(), CORNERSTONE_CSV);
|
||||
expect(csv).not.toContain("@");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { istPersonalnummer, suchMuster } from "@/lib/employee-search";
|
||||
import { istPersonalnummer, suchMuster, TRENNZEICHEN, TRENNZEICHEN_ERSATZ } from "@/lib/employee-search";
|
||||
|
||||
// Der Anlass: „Winkler H" gab alle sieben Winkler zurück statt der einen
|
||||
// Hannah. Das „H" wurde als Teilzeichenkette gesucht und traf damit T-h-omas,
|
||||
@@ -35,6 +35,38 @@ describe("suchMuster", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("zerlegt an denselben Zeichen wie das Feld, gegen das verglichen wird", () => {
|
||||
// Das Feld wird per translate an TRENNZEICHEN in Leerzeichen zerlegt. Tat
|
||||
// es nur das Feld, erzeugte „Müller-Weiß" das Muster „müller-weiß%",
|
||||
// während im Heuhaufen „müller weiß" stand: null Treffer, obwohl die
|
||||
// Person da ist. Ohne Bindestrich fand man sie. (C.08, 29.09.)
|
||||
expect(suchMuster("müller-weiß")).toEqual([
|
||||
["müller%", "% müller%"],
|
||||
["weiß%", "% weiß%"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("zerlegt an jedem der Trennzeichen", () => {
|
||||
for (const zeichen of [...TRENNZEICHEN]) {
|
||||
expect(suchMuster(`abc${zeichen}def`), `„${zeichen}" trennt nicht`).toEqual([
|
||||
["abc%", "% abc%"],
|
||||
["def%", "% def%"],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("findet dieselbe Person mit und ohne Bindestrich", () => {
|
||||
expect(suchMuster("müller-weiß")).toEqual(suchMuster("müller weiß"));
|
||||
});
|
||||
|
||||
it("hält Trennzeichen und Ersatz gleich lang", () => {
|
||||
// translate() ordnet Zeichen für Zeichen zu. Wäre der Ersatz kürzer,
|
||||
// fielen die überzähligen Trennzeichen ersatzlos weg und „Müller-Weiß"
|
||||
// würde im Feld zu „müllerweiß" — die Suche fände es dann wieder nicht.
|
||||
expect(TRENNZEICHEN_ERSATZ).toHaveLength(TRENNZEICHEN.length);
|
||||
expect(TRENNZEICHEN_ERSATZ.trim()).toBe("");
|
||||
});
|
||||
|
||||
it("kennt keinen Platzhalter mitten im Wort", () => {
|
||||
// Das ist der Kern: „h%" trifft Hannah, „%h%" träfe auch Thomas.
|
||||
const [, [amAnfang]] = suchMuster("winkler h");
|
||||
|
||||
56
tests/unit/hay-grade.test.ts
Normal file
56
tests/unit/hay-grade.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HAY_GRADES, HAY_GRADE_STANDARD, HAY_GRADE_WERTE, hayGradeLabel, istHayGrade } from "@/lib/hay-grade";
|
||||
|
||||
// Dieselbe Vorsichtsmassnahme wie bei Personenkreis und Mitarbeiterart: die
|
||||
// Liste steht in der Migration als CHECK und hier für die Oberfläche. Laufen
|
||||
// die beiden auseinander, bietet das Formular einen Wert an, den die Datenbank
|
||||
// abweist — und die Benutzerin sieht einen Fehler aus dem Maschinenraum.
|
||||
|
||||
const MIGRATION = join(process.cwd(), "db/migrations/20260928140000_hay_grade.sql");
|
||||
|
||||
function werteAusDerBedingung(): string[] {
|
||||
const sql = readFileSync(MIGRATION, "utf8");
|
||||
const block = sql.match(/constraint chk_hay_grade check \([\s\S]*?\);/)?.[0];
|
||||
expect(block, "Die Bedingung steht nicht mehr in der Migration").toBeDefined();
|
||||
return [...block!.matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
describe("Die Hay-Grades", () => {
|
||||
it("stimmen mit der Bedingung in der Datenbank überein", () => {
|
||||
expect(new Set(werteAusDerBedingung())).toEqual(new Set(HAY_GRADE_WERTE));
|
||||
});
|
||||
|
||||
it("führen den Generic Grade als Vorgabe", () => {
|
||||
// Die Spalte ist NOT NULL mit genau diesem Vorgabewert, und der Bestand
|
||||
// hat ihn bei der Umstellung bekommen. Weicht die Oberfläche davon ab,
|
||||
// bekämen neue Zeilen etwas anderes als die vorhandenen.
|
||||
expect(HAY_GRADE_WERTE).toContain(HAY_GRADE_STANDARD);
|
||||
expect(readFileSync(MIGRATION, "utf8")).toContain("set default '-'");
|
||||
});
|
||||
|
||||
it("zeigen den Titel und speichern die Kennung", () => {
|
||||
// Nur beim Generic Grade gehen beide auseinander — genau deshalb darf die
|
||||
// Anzeige nicht einfach der gespeicherte Wert sein.
|
||||
expect(hayGradeLabel("-")).toBe("Generic Grade");
|
||||
expect(hayGradeLabel("HG15")).toBe("HG15");
|
||||
for (const g of HAY_GRADES) {
|
||||
if (g.value !== "-") expect(g.label).toBe(g.value);
|
||||
}
|
||||
});
|
||||
|
||||
it("lassen einen unbekannten Wert stehen, statt ihn zu verschlucken", () => {
|
||||
// Eine Zeile aus der Zeit vor der Umstellung soll sichtbar falsch sein,
|
||||
// nicht unsichtbar leer.
|
||||
expect(hayGradeLabel("B")).toBe("B");
|
||||
expect(hayGradeLabel(null)).toBe("–");
|
||||
});
|
||||
|
||||
it("weisen zurück, was nicht in der Liste steht", () => {
|
||||
expect(istHayGrade("HG19P")).toBe(true);
|
||||
expect(istHayGrade("HG21")).toBe(false);
|
||||
expect(istHayGrade("B")).toBe(false);
|
||||
expect(istHayGrade(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FELD_AUSWAHLEN, auswahlFuer } from "@/lib/historie-felder";
|
||||
@@ -9,15 +9,24 @@ import { FELD_AUSWAHLEN, auswahlFuer } from "@/lib/historie-felder";
|
||||
// landeten wieder in der Spalte. Ein stiller Rückfall auf den Zustand, den
|
||||
// diese Datei beheben soll.
|
||||
//
|
||||
// Geprüft wird gegen die zuletzt gültige Fassung der Feldkarte.
|
||||
const MIGRATION = join(process.cwd(), "db/migrations/20260923120000_firmenemail.sql");
|
||||
// Gelesen werden **alle** Migrationen, nicht eine bestimmte. Hier stand
|
||||
// einmal ein fester Dateiname mit dem Vermerk „die zuletzt gültige Fassung";
|
||||
// der war schon zwei Migrationen später falsch, und ein Teil der Feldkarte
|
||||
// kommt inzwischen ohnehin aus einer Punktänderung an der laufenden
|
||||
// Definition statt aus einer vollständigen Fassung. Eine Bezeichnung, die
|
||||
// irgendwann einmal eingetragen wurde, zählt damit als bekannt — das ist
|
||||
// schwächer als der Stand von heute, aber es ist die Frage, die hier zählt:
|
||||
// ob jemand einen Schlüssel erfunden hat, den es nie gab.
|
||||
const MIGRATIONEN = join(process.cwd(), "db/migrations");
|
||||
|
||||
function bezeichnungenDerFeldkarte(): string[] {
|
||||
const sql = readFileSync(MIGRATION, "utf8");
|
||||
const block = sql.match(/FUNCTION public\.app_feld_karte\(\)[\s\S]*?\$function\$;/)?.[0];
|
||||
expect(block, "app_feld_karte steht nicht mehr in dieser Migration").toBeDefined();
|
||||
// Je Zeile: 'Bezeichnung', jsonb_build_array('spalte', 'typ', 'gruppe'),
|
||||
return [...block!.matchAll(/^\s*'([^']+)',\s+jsonb_build_array\(/gm)].map((m) => m[1]);
|
||||
const muster = /'([^']+)',\s+jsonb_build_array\('[a-z_]+', '[a-z_]+'/g;
|
||||
const gefunden = readdirSync(MIGRATIONEN)
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.flatMap((f) => [...readFileSync(join(MIGRATIONEN, f), "utf8").matchAll(muster)].map((m) => m[1]));
|
||||
expect(gefunden.length, "Die Feldkarte steht in keiner Migration mehr").toBeGreaterThan(0);
|
||||
return gefunden;
|
||||
}
|
||||
|
||||
describe("Die Auswahllisten im Dialog „Eintrag berichtigen“", () => {
|
||||
|
||||
170
tests/unit/lolyo.test.ts
Normal file
170
tests/unit/lolyo.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toCsv } from "@/lib/export";
|
||||
import {
|
||||
anrede,
|
||||
baueLolyoZeile,
|
||||
LOLYO_CSV,
|
||||
LOLYO_EMAIL_BESTAETIGT,
|
||||
LOLYO_GRUPPE,
|
||||
LOLYO_SPALTEN,
|
||||
LOLYO_SPRACHE,
|
||||
lolyoSpalten,
|
||||
ohneTypografie,
|
||||
type LolyoQuelle,
|
||||
} from "@/lib/lolyo";
|
||||
|
||||
// Die Datei legt in LOLYO Konten an. Eine falsche Spalte fällt hier nicht auf,
|
||||
// sondern dort — als Konto, das niemandem gehört, oder als Zeile, die der
|
||||
// Import abweist.
|
||||
|
||||
// Die Kopfzeile aus der Vorlage des Anbieters, Zeichen für Zeichen.
|
||||
const VORGABE = "Benutzer/Code;Email;Passwort;Titel prefix;Vorname;Nachname;Title Suffix;Position;Gruppen;Telefon;Anrede;Sprache;E-Mail bestätigt";
|
||||
|
||||
const UUID = "8f2b1c40-9d3e-4a77-b2c1-5e6f70a81234";
|
||||
const PASSWORT = "Startpasswort1!";
|
||||
|
||||
function person(teil: Partial<LolyoQuelle> = {}): LolyoQuelle {
|
||||
return {
|
||||
id: UUID,
|
||||
company_email: "max.alpentest@alpenwerk-firmentest.com",
|
||||
title_prefix: ["Mag."],
|
||||
title_suffix: ["MBA"],
|
||||
first_name: "Max",
|
||||
last_name: "Alpentest",
|
||||
job_title: "HR-Manager",
|
||||
phone: "+4366412345678",
|
||||
gender: "m",
|
||||
...teil,
|
||||
};
|
||||
}
|
||||
|
||||
const zeile = (teil: Partial<LolyoQuelle> = {}) => baueLolyoZeile(person(teil), PASSWORT);
|
||||
|
||||
describe("Spalten", () => {
|
||||
it("stehen genau so und in genau der Reihenfolge wie in der Vorlage", () => {
|
||||
expect(LOLYO_SPALTEN.join(";")).toBe(VORGABE);
|
||||
});
|
||||
|
||||
it("sind 13", () => {
|
||||
expect(LOLYO_SPALTEN).toHaveLength(13);
|
||||
});
|
||||
|
||||
it("werden alle gefüllt — keine Spalte ohne Zuordnung", () => {
|
||||
const z = zeile();
|
||||
for (const spalte of LOLYO_SPALTEN) {
|
||||
expect(z, `Spalte "${spalte}" fehlt in der Zeile`).toHaveProperty(spalte);
|
||||
}
|
||||
expect(Object.keys(z).sort()).toEqual([...LOLYO_SPALTEN].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Die Zuordnung", () => {
|
||||
it("nimmt als Benutzercode die Alpenwerk-UUID", () => {
|
||||
expect(zeile()["Benutzer/Code"]).toBe(UUID);
|
||||
});
|
||||
|
||||
it("nimmt die Firmenadresse und nie die private", () => {
|
||||
// Die Datei legt Konten bei einem fremden Anbieter an. Eine Privatadresse
|
||||
// hat dort nichts verloren; ohne Firmenadresse bleibt die Zelle leer.
|
||||
expect(zeile().Email).toBe("max.alpentest@alpenwerk-firmentest.com");
|
||||
expect(zeile({ company_email: null }).Email).toBe("");
|
||||
});
|
||||
|
||||
it("trägt das Passwort ein, das die Route hereinreicht", () => {
|
||||
expect(zeile().Passwort).toBe(PASSWORT);
|
||||
});
|
||||
|
||||
it("setzt Titel, Name und Position aus Alpenwerk", () => {
|
||||
const z = zeile();
|
||||
expect(z["Titel prefix"]).toBe("Mag.");
|
||||
expect(z.Vorname).toBe("Max");
|
||||
expect(z.Nachname).toBe("Alpentest");
|
||||
expect(z["Title Suffix"]).toBe("MBA");
|
||||
expect(z.Position).toBe("HR-Manager");
|
||||
});
|
||||
|
||||
it("fügt mehrere Titel zusammen und lässt keine zu einer leeren Zelle werden", () => {
|
||||
expect(zeile({ title_prefix: ["Dr.", "Mag."] })["Titel prefix"]).toBe("Dr. Mag.");
|
||||
expect(zeile({ title_prefix: [], title_suffix: [] })["Titel prefix"]).toBe("");
|
||||
});
|
||||
|
||||
it("setzt Gruppe, Sprache und Bestätigung fest", () => {
|
||||
const z = zeile();
|
||||
expect(z.Gruppen).toBe(LOLYO_GRUPPE);
|
||||
expect(z.Sprache).toBe(LOLYO_SPRACHE);
|
||||
expect(z["E-Mail bestätigt"]).toBe(LOLYO_EMAIL_BESTAETIGT);
|
||||
});
|
||||
|
||||
it("leitet die Anrede aus dem Geschlecht ab", () => {
|
||||
expect(anrede("m")).toBe("Herr");
|
||||
expect(anrede("w")).toBe("Frau");
|
||||
// Lieber leer als geraten: eine falsche Anrede steht in jeder Nachricht,
|
||||
// die die App verschickt.
|
||||
expect(anrede("")).toBe("");
|
||||
});
|
||||
|
||||
it("lässt eine fehlende Telefonnummer leer", () => {
|
||||
expect(zeile({ phone: null }).Telefon).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Typografische Zeichen", () => {
|
||||
it("weichen dem Bindestrich", () => {
|
||||
// Der Gedankenstrich kam im Zielsystem als „–" an. Ein Bindestrich sagt
|
||||
// dasselbe und ist in beiden Kodierungen dasselbe Byte.
|
||||
expect(ohneTypografie("Angestellte/r – F&E")).toBe("Angestellte/r - F&E");
|
||||
expect(ohneTypografie("Lehrling — Wolkersdorf")).toBe("Lehrling - Wolkersdorf");
|
||||
});
|
||||
|
||||
it("gelten für Anführungszeichen, Auslassungspunkte und geschützte Leerzeichen", () => {
|
||||
expect(ohneTypografie("„Schnitte“")).toBe('"Schnitte"');
|
||||
expect(ohneTypografie("O’Brien")).toBe("O'Brien");
|
||||
expect(ohneTypografie("und so weiter …")).toBe("und so weiter ...");
|
||||
expect(ohneTypografie("W 17")).toBe("W 17");
|
||||
});
|
||||
|
||||
it("lassen Umlaute in Ruhe", () => {
|
||||
// „Bäckerei" zu „Baeckerei" zu machen hiesse, ein Problem zu verstecken,
|
||||
// das dann auch Namen beträfe — und dort wäre es keine Kosmetik mehr.
|
||||
expect(ohneTypografie("Arbeiter/in – Bäckerei")).toBe("Arbeiter/in - Bäckerei");
|
||||
expect(ohneTypografie("Müller-Weiß")).toBe("Müller-Weiß");
|
||||
});
|
||||
|
||||
it("gelten für jede Zelle aus den Daten, nicht nur für die Position", () => {
|
||||
const z = zeile({ job_title: "Angestellte/r – F&E", last_name: "O’Brien", title_suffix: ["M–A"] });
|
||||
expect(z.Position).toBe("Angestellte/r - F&E");
|
||||
expect(z.Nachname).toBe("O'Brien");
|
||||
expect(z["Title Suffix"]).toBe("M-A");
|
||||
});
|
||||
|
||||
it("gelten nicht für das Passwort", () => {
|
||||
// Ein ersetztes Zeichen fiele niemandem auf — es gäbe nur ein Konto, in
|
||||
// das niemand hineinkommt.
|
||||
expect(baueLolyoZeile(person(), "Pass–wort–2026").Passwort).toBe("Pass–wort–2026");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Die Datei", () => {
|
||||
it("ist semikolongetrennt und ohne BOM", () => {
|
||||
// Das BOM hinge unsichtbar am Namen der ersten Spalte, und
|
||||
// „Benutzer/Code" käme beim Einlesen in keiner Zuordnung mehr vor.
|
||||
const csv = toCsv([zeile()], lolyoSpalten(), LOLYO_CSV);
|
||||
expect(csv.startsWith("Benutzer/Code;")).toBe(true);
|
||||
expect(csv.charCodeAt(0)).not.toBe(0xfeff);
|
||||
});
|
||||
|
||||
it("beginnt mit der Kopfzeile der Vorlage", () => {
|
||||
const csv = toCsv([], lolyoSpalten(), LOLYO_CSV);
|
||||
expect(csv.split("\r\n")[0]).toBe(VORGABE);
|
||||
});
|
||||
|
||||
it("lässt die Telefonnummer unangetastet", () => {
|
||||
// Der Formelschutz von toCsv stellt jedem Wert mit führendem „+" ein
|
||||
// Hochkomma voran, damit Excel ihn nicht rechnet. Diese Datei liest eine
|
||||
// Maschine: dort stünde danach in **jedem** Konto `'+4366…` als Nummer,
|
||||
// weil jede österreichische Nummer mit „+" beginnt.
|
||||
const csv = toCsv([zeile()], lolyoSpalten(), LOLYO_CSV);
|
||||
expect(csv).toContain(";+4366412345678;");
|
||||
expect(csv).not.toContain("'+43");
|
||||
});
|
||||
});
|
||||
38
tests/unit/org-nummer.test.ts
Normal file
38
tests/unit/org-nummer.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { naechsteOrgnummer } from "@/lib/org-nummer";
|
||||
|
||||
// Der Vorschlag darf lieber schweigen als raten: eine erfundene, aber
|
||||
// plausibel aussehende Nummer prüft niemand nach, und sie landet im führenden
|
||||
// System des Kunden als Kennung, die es dort nicht gibt.
|
||||
|
||||
describe("naechsteOrgnummer", () => {
|
||||
it("zählt die höchste Nummer hoch und behält die Breite", () => {
|
||||
expect(naechsteOrgnummer(["OE-0001", "OE-0013", "OE-0080"])).toBe("OE-0081");
|
||||
});
|
||||
|
||||
it("richtet sich nach der höchsten, nicht nach der letzten in der Liste", () => {
|
||||
expect(naechsteOrgnummer(["OE-0080", "OE-0001", "OE-0013"])).toBe("OE-0081");
|
||||
});
|
||||
|
||||
it("kommt auch ohne Vorspann aus", () => {
|
||||
expect(naechsteOrgnummer(["10000001", "10000002"])).toBe("10000003");
|
||||
});
|
||||
|
||||
it("verlängert, statt eine vergebene Nummer vorzuschlagen", () => {
|
||||
// 99 + 1 passt nicht mehr in zwei Stellen. Abgeschnitten käme „00“
|
||||
// heraus, und das gibt es bereits.
|
||||
expect(naechsteOrgnummer(["OE-98", "OE-99"])).toBe("OE-100");
|
||||
});
|
||||
|
||||
it("schweigt bei zwei Systematiken nebeneinander", () => {
|
||||
expect(naechsteOrgnummer(["OE-0001", "B7"])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("schweigt, wenn eine Nummer gar nicht auf Ziffern endet", () => {
|
||||
expect(naechsteOrgnummer(["OE-0001", "Vertrieb"])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("schweigt im leeren Bestand", () => {
|
||||
expect(naechsteOrgnummer([])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
anzahlKriterien,
|
||||
AUSWAHL_KRITERIEN,
|
||||
beschreibeKriterien,
|
||||
JANEIN_KRITERIEN,
|
||||
kriterienParameter,
|
||||
LEERE_CRITERIA,
|
||||
@@ -16,15 +17,15 @@ function aus(params: Record<string, string>) {
|
||||
|
||||
describe("parseCriteria", () => {
|
||||
it("nimmt bekannte Werte an", () => {
|
||||
const c = aus({ employment: "Teilzeit", paygrade: "C", gender: "w" });
|
||||
expect(c.auswahl).toEqual({ employment: "Teilzeit", paygrade: "C", gender: "w" });
|
||||
const c = aus({ employment: "Teilzeit", paygrade: "HG13", gender: "w" });
|
||||
expect(c.auswahl).toEqual({ employment: "Teilzeit", paygrade: "HG13", gender: "w" });
|
||||
});
|
||||
|
||||
it("verwirft Werte, die nicht in der Liste stehen", () => {
|
||||
// Die Werte landen in SQL-Vergleichen und im Dateinamen des Downloads,
|
||||
// also in einem Content-Disposition-Header — was nicht in der Liste
|
||||
// steht, darf nicht durchkommen.
|
||||
const c = aus({ employment: "Vollzeit'; drop table employees; --", paygrade: "Z", gender: "x" });
|
||||
const c = aus({ employment: "Vollzeit'; drop table employees; --", paygrade: "HG21", gender: "x" });
|
||||
expect(c.auswahl).toEqual({});
|
||||
});
|
||||
|
||||
@@ -58,6 +59,52 @@ describe("parseCriteria", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("beschreibeKriterien", () => {
|
||||
it("sagt nichts, wenn nichts eingeschränkt ist", () => {
|
||||
expect(beschreibeKriterien(LEERE_CRITERIA)).toEqual([]);
|
||||
});
|
||||
|
||||
it("nennt jedes gesetzte Kriterium beim Namen", () => {
|
||||
const c = aus({ employment: "Teilzeit", dienstwagen: "ja", entryFrom: "2024-01-01", hoursTo: "30" });
|
||||
expect(beschreibeKriterien(c)).toEqual([
|
||||
"Beschäftigungsart: Teilzeit",
|
||||
"Dienstwagen: ja",
|
||||
"Eintritt ab 01.01.2024",
|
||||
"Wochenstunden bis 30",
|
||||
]);
|
||||
});
|
||||
|
||||
it("unterscheidet ab, bis und den geschlossenen Zeitraum", () => {
|
||||
expect(beschreibeKriterien(aus({ entryFrom: "2020-06-01" }))).toEqual(["Eintritt ab 01.06.2020"]);
|
||||
expect(beschreibeKriterien(aus({ entryTo: "2020-06-01" }))).toEqual(["Eintritt bis 01.06.2020"]);
|
||||
expect(beschreibeKriterien(aus({ entryFrom: "2020-01-01", entryTo: "2020-12-31" }))).toEqual([
|
||||
"Eintritt 01.01.2020 bis 31.12.2020",
|
||||
]);
|
||||
});
|
||||
|
||||
it("zeigt die Beschriftung, nicht den gespeicherten Wert", () => {
|
||||
// Bei der Austrittsart gehen die beiden auseinander; auf der Karte nützt
|
||||
// nur das, was auch im Auswahlfeld steht.
|
||||
const kriterium = AUSWAHL_KRITERIEN.find((k) => k.key === "austrittsart")!;
|
||||
const option = kriterium.optionen[0];
|
||||
expect(beschreibeKriterien(aus({ austrittsart: option.wert }))).toEqual([`${kriterium.label}: ${option.label}`]);
|
||||
});
|
||||
|
||||
it("beschreibt so viele Kriterien, wie anzahlKriterien zählt", () => {
|
||||
// Die Zahl am Aufklapper und der Text auf der Exportkarte dürfen nicht
|
||||
// auseinanderlaufen: sonst meldet die eine Stelle eine Einschränkung, die
|
||||
// die andere nicht benennt.
|
||||
const alle = {
|
||||
...Object.fromEntries(AUSWAHL_KRITERIEN.map((k) => [k.key, k.optionen[0].wert])),
|
||||
...Object.fromEntries(JANEIN_KRITERIEN.map((k) => [k.key, "ja"])),
|
||||
...Object.fromEntries(ZEITRAUM_KRITERIEN.map((k) => [`${k.key}From`, "2024-01-01"])),
|
||||
hoursFrom: "10",
|
||||
};
|
||||
const c = aus(alle);
|
||||
expect(beschreibeKriterien(c)).toHaveLength(anzahlKriterien(c));
|
||||
});
|
||||
});
|
||||
|
||||
describe("kriterienParameter", () => {
|
||||
it("nennt jeden Parameter, den parseCriteria liest", () => {
|
||||
// „Zurücksetzen" räumt anhand dieser Liste auf. Fehlt darin ein Name,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
groupKeysFor,
|
||||
measureValue,
|
||||
MEASURE_LABELS,
|
||||
messgroesseLabel,
|
||||
type OrgLookups,
|
||||
type ReportEmployee,
|
||||
type ReportEvent,
|
||||
@@ -27,7 +28,7 @@ function emp(overrides: Partial<ReportEmployee> = {}): ReportEmployee {
|
||||
exit_date: null,
|
||||
weekly_hours: 38.5,
|
||||
source: "Extern",
|
||||
paygrade: "B",
|
||||
paygrade: "HG13",
|
||||
birth_date: "1990-01-01",
|
||||
gender: "w",
|
||||
worker_type: "Angestellte:r",
|
||||
@@ -179,6 +180,33 @@ describe("measureValue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("messgroesseLabel", () => {
|
||||
// „Headcount" stand auf der Übersicht für die Aktiven und im Bericht für
|
||||
// eine andere Zahl (N.02). Der Kunde führt zwei Grössen mit festen Namen;
|
||||
// die Kennzahl im Bericht zählt aber, was ausgewählt ist.
|
||||
it("nennt die beiden Grössen des Kunden beim Namen", () => {
|
||||
expect(messgroesseLabel("headcount", ["Aktiv"])).toBe("Headcount Aktiv");
|
||||
expect(messgroesseLabel("headcount", ["Aktiv", "Karenz"])).toBe("Headcount Aktives Dienstverhältnis");
|
||||
expect(messgroesseLabel("headcount", ["Karenz", "Aktiv"])).toBe("Headcount Aktives Dienstverhältnis");
|
||||
});
|
||||
|
||||
it("schreibt sonst dazu, was gezählt wurde", () => {
|
||||
// Mit Anzeigenamen: „Karenz" heisst in der Oberfläche überall
|
||||
// „Langzeitabwesenheit".
|
||||
expect(messgroesseLabel("headcount", ["Aktiv", "Karenz", "Geplant"])).toBe(
|
||||
"Headcount (Aktiv, Langzeitabwesenheit, Geplant)"
|
||||
);
|
||||
expect(messgroesseLabel("headcount", ["Ausgetreten"])).toBe("Headcount (Ausgetreten)");
|
||||
});
|
||||
|
||||
it("lässt die übrigen Kennzahlen unberührt", () => {
|
||||
// Nur beim Headcount hing an der Auswahl ein zweiter Begriff. „Ø Alter"
|
||||
// der Aktiven heisst weiter „Ø Alter".
|
||||
expect(messgroesseLabel("fte", ["Aktiv"])).toBe("FTE");
|
||||
expect(messgroesseLabel("avg_age", ["Aktiv", "Karenz"])).toBe("Ø Alter");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateReport", () => {
|
||||
it("groups rows, sorts groups descending by value, and never includes a salary field", () => {
|
||||
const employees = [
|
||||
@@ -251,6 +279,30 @@ describe("aggregateReport", () => {
|
||||
const rows = aggregateReport(employees, "headcount", "division", "weekday", lookups);
|
||||
expect(rows[0].split?.map((s) => s.key)).toEqual(["Mo", "Fr"]);
|
||||
});
|
||||
|
||||
it("sorts Hay-Grades aufsteigend statt nach Häufigkeit", () => {
|
||||
// Die Leiter ist die Aussage: HG09 unten, HG20 oben. Nach Kennzahl
|
||||
// sortiert stünde hier HG13 vorn, weil dort zufällig zwei Personen
|
||||
// sitzen — und die Reihenfolge läse sich als Zufall.
|
||||
const employees = [
|
||||
emp({ id: "1", paygrade: "HG20" }),
|
||||
emp({ id: "2", paygrade: "HG09" }),
|
||||
emp({ id: "3", paygrade: "HG13" }),
|
||||
emp({ id: "4", paygrade: "HG13" }),
|
||||
emp({ id: "5", paygrade: "HG19P" }),
|
||||
emp({ id: "6", paygrade: "HG19" }),
|
||||
];
|
||||
const rows = aggregateReport(employees, "headcount", "paygrade", null, lookups);
|
||||
expect(rows.map((r) => r.key)).toEqual(["HG09", "HG13", "HG19", "HG19P", "HG20"]);
|
||||
});
|
||||
|
||||
it("stellt den Generic Grade vor die unterste Stufe", () => {
|
||||
// Er ist keine Stufe, sondern ihr Fehlen. Zwischen HG13 und HG14 stünde
|
||||
// er wie eine.
|
||||
const employees = [emp({ id: "1", paygrade: "HG13" }), emp({ id: "2", paygrade: "-" })];
|
||||
const rows = aggregateReport(employees, "headcount", "paygrade", null, lookups);
|
||||
expect(rows.map((r) => r.key)).toEqual(["-", "HG13"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveStatusAsOf", () => {
|
||||
|
||||
Reference in New Issue
Block a user