Befoerderung wahlweise auf eine neue Planstelle -- und der Nachtlauf, der sie ausfuehrt
Bei "Befoerdern" steht jetzt die Wahl: selbe Planstelle oder eine neue.
Bei "neu" wird sie aus den freien gewaehlt, dieselbe Auswahl wie die
Zielplanstelle bei der Versetzung, und die Stelle schlaegt ihre
Taetigkeit als neue Bezeichnung vor -- nur als Vorschlag, denn
job_title darf von der Planstelle abweichen.
Die Zielstelle muss frei sein, mit derselben Meldung wie bei der
Versetzung. Ausgenommen ist die eigene: darauf sitzt die Person schon,
und "bereits besetzt" waere eine Falschaussage ueber sie selbst.
Dabei ist ein aelterer Fehler aufgefallen. transfer_employee legt fuer
ein kuenftiges Datum einen Eintrag in pending_org_changes mit
target_position_id an. Der Nachtlauf hat die Besetzung dort zur
Umstellung auf Planstellen (20260727120200) umgehaengt; zuletzt stand in
diesem Zweig nur noch
update employees set job_title = coalesce(payload->>'new_title', ...)
also die Fassung von *vor* jener Umstellung -- und new_title kommt in
diesem payload gar nicht vor. Eine auf spaeter datierte Versetzung wurde
am Stichtag auf "applied" gesetzt und bewegte niemanden: die Person blieb
auf ihrer alten Planstelle, im Organigramm unveraendert, ohne Meldung.
Eine der Migrationen dazwischen hat die Funktion aus einer alten Vorlage
neu geschrieben -- dieselbe Falle wie bei der Gruppe `role` und bei der
Anmerkung zum Austritt.
Beide Zweige haengen die Besetzung jetzt um, und die Selbstpruefung
zaehlt nach, dass es zwei sind. Nebenbei bekommt promote_employee den
festen search_path und app_current_user_id() statt auth.uid().
This commit is contained in:
@@ -215,6 +215,13 @@ export async function promoteEmployee(payload: {
|
|||||||
effective_date: string;
|
effective_date: string;
|
||||||
new_title: string;
|
new_title: string;
|
||||||
new_paygrade?: "A" | "B" | "C" | "D" | "E" | "F";
|
new_paygrade?: "A" | "B" | "C" | "D" | "E" | "F";
|
||||||
|
/**
|
||||||
|
* Nur, wenn die Beförderung zugleich auf eine andere Planstelle führt.
|
||||||
|
*
|
||||||
|
* Fehlt sie, bleibt die Person auf ihrer bisherigen — das ist der andere
|
||||||
|
* der beiden Fälle, nicht ein vergessenes Feld.
|
||||||
|
*/
|
||||||
|
target_position_id?: string;
|
||||||
}): Promise<ActionResult> {
|
}): Promise<ActionResult> {
|
||||||
return callRpc("promote_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
|
return callRpc("promote_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,7 +223,12 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
|||||||
employee={employee}
|
employee={employee}
|
||||||
openPositions={openPositions}
|
openPositions={openPositions}
|
||||||
/>
|
/>
|
||||||
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
|
<PromotePanel
|
||||||
|
open={panel === "promote"}
|
||||||
|
onClose={() => setPanel(null)}
|
||||||
|
employee={employee}
|
||||||
|
openPositions={openPositions}
|
||||||
|
/>
|
||||||
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} status={status} />
|
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} status={status} />
|
||||||
<DatenAendernPanel
|
<DatenAendernPanel
|
||||||
open={panel === "daten"}
|
open={panel === "daten"}
|
||||||
|
|||||||
@@ -387,11 +387,13 @@ export function DatenAendernPanel({
|
|||||||
<AngehoerigeSection employeeId={employee.id} dependents={dependents} effectiveDate={effectiveDate} />
|
<AngehoerigeSection employeeId={employee.id} dependents={dependents} effectiveDate={effectiveDate} />
|
||||||
|
|
||||||
{/* Unterhalb der Angehörigen und ganz am Schluss — dieselbe Reihenfolge
|
{/* Unterhalb der Angehörigen und ganz am Schluss — dieselbe Reihenfolge
|
||||||
wie im Reiter „Stammdaten". Vorher stand der Block mitten im
|
wie im Reiter „Stammdaten". Aufgebaut wie „Person", „Vertrag" und
|
||||||
Abschnitt „Person", zwischen den Adressfeldern; beides sind
|
„Rolle & Anstellung" darüber: eine Überschrift, darunter die
|
||||||
Personen im Umfeld, und die beiden gehören nebeneinander. */}
|
Felder. Hier stand ein umrahmtes fieldset mit eigener
|
||||||
<fieldset className="rounded-md border border-border-subtle p-3">
|
Beschriftung; das hob den Abschnitt gegenüber allen anderen
|
||||||
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Notfallkontakt</legend>
|
heraus, ohne dass er wichtiger wäre. */}
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-sm font-bold text-ink">Notfallkontakt</h3>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<TextField label="Name" dense value={notfallName} onChange={setNotfallName} />
|
<TextField label="Name" dense value={notfallName} onChange={setNotfallName} />
|
||||||
<TextField label="Telefon" dense type="tel" value={notfallTelefon} onChange={setNotfallTelefon} />
|
<TextField label="Telefon" dense type="tel" value={notfallTelefon} onChange={setNotfallTelefon} />
|
||||||
@@ -404,7 +406,7 @@ export function DatenAendernPanel({
|
|||||||
options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))}
|
options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SlideOver>
|
</SlideOver>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { promoteEmployee } from "@/actions/employees";
|
import { promoteEmployee } from "@/actions/employees";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { SelectField, TextField } from "@/components/ui/Field";
|
import { SelectField, TextField } from "@/components/ui/Field";
|
||||||
import { SlideOver } from "@/components/ui/SlideOver";
|
import { SlideOver } from "@/components/ui/SlideOver";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
|
import type { OpenPositionResolved } from "@/lib/positions";
|
||||||
import type { Database, PaygradeType } from "@/lib/types";
|
import type { Database, PaygradeType } from "@/lib/types";
|
||||||
import { fmtName } from "@/lib/format";
|
import { fmtName } from "@/lib/format";
|
||||||
|
|
||||||
@@ -21,25 +22,73 @@ const PAYGRADES: { value: PaygradeType; label: string }[] = [
|
|||||||
{ value: "F", label: "F – Bereichsleitung / GF" },
|
{ value: "F", label: "F – Bereichsleitung / GF" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function PromotePanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
|
// 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
|
||||||
|
// bei der Versetzung.
|
||||||
|
type Stellenwahl = "selbe" | "neue";
|
||||||
|
|
||||||
|
export function PromotePanel({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
employee,
|
||||||
|
openPositions,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
employee: EmployeeRow;
|
||||||
|
openPositions: OpenPositionResolved[];
|
||||||
|
}) {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [effectiveDate, setEffectiveDate] = useState("");
|
const [effectiveDate, setEffectiveDate] = useState("");
|
||||||
const [newTitle, setNewTitle] = useState(employee.job_title);
|
const [newTitle, setNewTitle] = useState(employee.job_title);
|
||||||
const [paygrade, setPaygrade] = useState<PaygradeType>(employee.paygrade);
|
const [paygrade, setPaygrade] = useState<PaygradeType>(employee.paygrade);
|
||||||
|
const [stellenwahl, setStellenwahl] = useState<Stellenwahl>("selbe");
|
||||||
|
const [positionId, setPositionId] = useState("");
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
const options = useMemo(
|
||||||
|
() =>
|
||||||
|
openPositions
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.orgLabel.localeCompare(b.orgLabel, "de") || a.title.localeCompare(b.title, "de"))
|
||||||
|
.map((p) => ({ value: p.id, label: `${p.orgLabel} · ${p.title} (${p.position_number})` })),
|
||||||
|
[openPositions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selected = openPositions.find((p) => p.id === positionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Stelle wählen schlägt ihre Tätigkeit als neue Bezeichnung vor.
|
||||||
|
*
|
||||||
|
* Nur ein Vorschlag: `employees.job_title` darf von der Planstelle
|
||||||
|
* abweichen, und wer eine eigene Bezeichnung tippt, soll sie behalten.
|
||||||
|
* Deshalb wird nur überschrieben, solange das Feld unverändert auf dem
|
||||||
|
* alten Titel steht.
|
||||||
|
*/
|
||||||
|
function stelleWaehlen(id: string) {
|
||||||
|
setPositionId(id);
|
||||||
|
const stelle = openPositions.find((p) => p.id === id);
|
||||||
|
if (stelle && newTitle === employee.job_title) setNewTitle(stelle.title);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!effectiveDate || !newTitle) {
|
if (!effectiveDate || !newTitle) {
|
||||||
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
|
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (stellenwahl === "neue" && !positionId) {
|
||||||
|
showToast("Bitte die neue Planstelle auswählen.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setPending(true);
|
setPending(true);
|
||||||
const result = await promoteEmployee({
|
const result = await promoteEmployee({
|
||||||
employee_id: employee.id,
|
employee_id: employee.id,
|
||||||
effective_date: effectiveDate,
|
effective_date: effectiveDate,
|
||||||
new_title: newTitle,
|
new_title: newTitle,
|
||||||
new_paygrade: paygrade,
|
new_paygrade: paygrade,
|
||||||
|
target_position_id: stellenwahl === "neue" ? positionId : undefined,
|
||||||
});
|
});
|
||||||
setPending(false);
|
setPending(false);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -72,9 +121,59 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend className="mb-1.5 text-sm font-semibold text-ink-body">Planstelle</legend>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-ink-body">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="stellenwahl"
|
||||||
|
checked={stellenwahl === "selbe"}
|
||||||
|
onChange={() => setStellenwahl("selbe")}
|
||||||
|
/>
|
||||||
|
Selbe Planstelle
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-ink-body">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="stellenwahl"
|
||||||
|
checked={stellenwahl === "neue"}
|
||||||
|
onChange={() => setStellenwahl("neue")}
|
||||||
|
disabled={options.length === 0}
|
||||||
|
/>
|
||||||
|
Neue Planstelle
|
||||||
|
{options.length === 0 && <span className="text-xs text-ink-muted">(derzeit keine freie)</span>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{stellenwahl === "neue" && (
|
||||||
|
<>
|
||||||
|
<SelectField
|
||||||
|
label="Neue Planstelle"
|
||||||
|
required
|
||||||
|
value={positionId}
|
||||||
|
onChange={stelleWaehlen}
|
||||||
|
placeholder="Bitte wählen…"
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
{selected && (
|
||||||
|
<div className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
|
||||||
|
<div className="font-semibold text-ink">{selected.title}</div>
|
||||||
|
<div className="text-xs text-ink-muted">{selected.orgLabel}</div>
|
||||||
|
<div className="mt-1 text-xs text-ink-muted">
|
||||||
|
{selected.is_chief ? "Leitungsplanstelle" : "Mitarbeiterplanstelle"}
|
||||||
|
{selected.managerName ? ` · berichtet an ${selected.managerName}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<TextField label="Neue Position" required value={newTitle} onChange={setNewTitle} />
|
<TextField label="Neue Position" required value={newTitle} onChange={setNewTitle} />
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Paygrade"
|
label="Verwendungsgruppe"
|
||||||
value={paygrade}
|
value={paygrade}
|
||||||
onChange={(v) => setPaygrade(v as PaygradeType)}
|
onChange={(v) => setPaygrade(v as PaygradeType)}
|
||||||
options={PAYGRADES}
|
options={PAYGRADES}
|
||||||
|
|||||||
328
db/migrations/20260924100000_befoerderung_mit_planstelle.sql
Normal file
328
db/migrations/20260924100000_befoerderung_mit_planstelle.sql
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
-- Die Beförderung kann die Planstelle wechseln — und der Nachtlauf führt
|
||||||
|
-- beides aus.
|
||||||
|
--
|
||||||
|
-- ═══ 1. Beförderung: dieselbe Stelle oder eine neue ════════════════
|
||||||
|
--
|
||||||
|
-- Bisher änderte eine Beförderung nur Bezeichnung und Verwendungsgruppe; die
|
||||||
|
-- Planstelle blieb, wo sie war. Gewünscht ist die Wahl: dieselbe Planstelle
|
||||||
|
-- oder eine neue, und dann aus den freien — wie die Zielplanstelle bei der
|
||||||
|
-- Versetzung.
|
||||||
|
--
|
||||||
|
-- Die neue Stelle muss frei sein, mit derselben Meldung wie dort. Ein
|
||||||
|
-- Sonderfall ist erlaubt: die Person sitzt bereits darauf. Dann ist es keine
|
||||||
|
-- Bewegung, sondern eine Beförderung auf der eigenen Stelle, und ein „bereits
|
||||||
|
-- besetzt" wäre eine Falschaussage über sie selbst.
|
||||||
|
--
|
||||||
|
-- `job_title` kommt aus dem Formular und nicht aus dem Job der Zielstelle:
|
||||||
|
-- die Spalte darf abweichen (so steht es auch in der Importvorlage), und die
|
||||||
|
-- Oberfläche schlägt den Titel der gewählten Stelle ohnehin vor.
|
||||||
|
--
|
||||||
|
-- Nebenbei zwei Altlasten dieser Funktion, die beim Neuschreiben ohnehin
|
||||||
|
-- anfielen: `auth.uid()` weicht `app_current_user_id()` — die Anwendungsrolle
|
||||||
|
-- darf das Schema `auth` nicht —, und der `search_path` steht jetzt fest.
|
||||||
|
--
|
||||||
|
-- ═══ 2. Der Nachtlauf bewegte niemanden ════════════════════════════
|
||||||
|
--
|
||||||
|
-- Beim Aufräumen dieser Stelle ist ein zweiter, älterer Fehler aufgefallen.
|
||||||
|
--
|
||||||
|
-- `transfer_employee` schreibt für ein künftiges Datum einen Eintrag in
|
||||||
|
-- pending_org_changes mit `target_position_id`. Der Nachtlauf hat dort zur
|
||||||
|
-- Umstellung auf Planstellen (20260727120200) die Besetzung umgehängt. In der
|
||||||
|
-- zuletzt gültigen Fassung stand nur noch:
|
||||||
|
--
|
||||||
|
-- update employees set job_title = coalesce(payload->>'new_title', ...)
|
||||||
|
--
|
||||||
|
-- also die Fassung von **vor** jener Umstellung — und `new_title` kommt in
|
||||||
|
-- diesem payload gar nicht vor. Eine auf später datierte Versetzung wurde am
|
||||||
|
-- Stichtag auf `applied` gesetzt und bewegte niemanden: die Person blieb auf
|
||||||
|
-- ihrer alten Planstelle, im Organigramm unverändert, ohne dass irgendwo eine
|
||||||
|
-- Meldung entstand.
|
||||||
|
--
|
||||||
|
-- Eine der Migrationen dazwischen hat die Funktion aus einer alten Vorlage
|
||||||
|
-- neu geschrieben. Dieselbe Falle wie bei der Gruppe `role`
|
||||||
|
-- (20260915120000) und bei der Anmerkung zum Austritt (20260923180000). Die
|
||||||
|
-- Selbstprüfung unten prüft deshalb nicht mehr nur das neue Verhalten,
|
||||||
|
-- sondern beide Zweige auf das, was sie anfassen müssen.
|
||||||
|
|
||||||
|
create or replace function promote_employee(payload jsonb)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
set search_path to 'public', 'pg_temp'
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||||
|
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
|
||||||
|
v_position uuid := nullif(payload->>'target_position_id', '')::uuid;
|
||||||
|
v_old_paygrade paygrade_type;
|
||||||
|
v_name text;
|
||||||
|
v_details text;
|
||||||
|
v_besetzt uuid;
|
||||||
|
v_nummer text;
|
||||||
|
begin
|
||||||
|
perform require_hr_admin();
|
||||||
|
select paygrade, first_name || ' ' || last_name into v_old_paygrade, v_name
|
||||||
|
from employees where id = v_employee_id;
|
||||||
|
|
||||||
|
if v_position is not null then
|
||||||
|
select position_number into v_nummer from om_positions where id = v_position;
|
||||||
|
if v_nummer is null then
|
||||||
|
raise exception 'Die gewählte Planstelle existiert nicht.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Wie bei der Versetzung. Die eigene Stelle ist ausgenommen: darauf sitzt
|
||||||
|
-- die Person schon, und das ist kein Hindernis für ihre Beförderung.
|
||||||
|
select pa.employee_id into v_besetzt
|
||||||
|
from position_assignments pa
|
||||||
|
where pa.position_id = v_position and pa.valid_to is null;
|
||||||
|
if v_besetzt is not null and v_besetzt <> v_employee_id then
|
||||||
|
raise exception 'Die Zielplanstelle ist bereits besetzt.';
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_details := 'Neue Position: ' || (payload->>'new_title');
|
||||||
|
if payload->>'new_paygrade' is not null and (payload->>'new_paygrade')::paygrade_type <> v_old_paygrade then
|
||||||
|
v_details := v_details || ', neue Verwendungsgruppe: ' || (payload->>'new_paygrade');
|
||||||
|
end if;
|
||||||
|
if v_nummer is not null then
|
||||||
|
v_details := v_details || ', neue Planstelle ' || v_nummer;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_effective_date <= current_date then
|
||||||
|
update employees set
|
||||||
|
job_title = payload->>'new_title',
|
||||||
|
paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade)
|
||||||
|
where id = v_employee_id;
|
||||||
|
|
||||||
|
-- Nur, wenn sich die Stelle wirklich ändert: sonst entstünde eine
|
||||||
|
-- Besetzung, die am selben Tag endet und beginnt, und die Historie der
|
||||||
|
-- Planstelle hätte eine Naht ohne Anlass.
|
||||||
|
if v_position is not null and v_besetzt is distinct from v_employee_id then
|
||||||
|
update position_assignments set valid_to = v_effective_date
|
||||||
|
where employee_id = v_employee_id and valid_to is null;
|
||||||
|
insert into position_assignments (position_id, employee_id, valid_from)
|
||||||
|
values (v_position, v_employee_id, v_effective_date);
|
||||||
|
end if;
|
||||||
|
else
|
||||||
|
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||||
|
values (v_employee_id, 'promotion', v_effective_date,
|
||||||
|
jsonb_build_object(
|
||||||
|
'new_title', payload->>'new_title',
|
||||||
|
'new_paygrade', payload->>'new_paygrade',
|
||||||
|
'target_position_id', v_position));
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into employee_history (employee_id, event_date, event_type, description)
|
||||||
|
values (v_employee_id, v_effective_date, 'Beförderung', v_details);
|
||||||
|
|
||||||
|
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||||
|
values (app_current_user_id(), current_actor_name(), 'Beförderung', v_name, v_employee_id, v_details);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
grant execute on function public.promote_employee(payload jsonb) to alpenwerk_app;
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
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$;
|
||||||
|
|
||||||
|
grant execute on function public.apply_due_pending_changes() to alpenwerk_app;
|
||||||
|
|
||||||
|
|
||||||
|
-- Selbstprüfung.
|
||||||
|
do $$
|
||||||
|
declare
|
||||||
|
v_pro text := pg_get_functiondef('public.promote_employee(jsonb)'::regprocedure);
|
||||||
|
v_lauf text := pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure);
|
||||||
|
begin
|
||||||
|
if v_pro not like '%target_position_id%' then
|
||||||
|
raise exception 'promote_employee kennt die Zielplanstelle nicht.';
|
||||||
|
end if;
|
||||||
|
if v_pro like '%auth.uid()%' then
|
||||||
|
raise exception 'promote_employee ruft wieder auth.uid() — die Anwendungsrolle darf das Schema auth nicht.';
|
||||||
|
end if;
|
||||||
|
if position('SET search_path' in v_pro) = 0 then
|
||||||
|
raise exception 'promote_employee hat keinen festen search_path.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Beide Zweige müssen die Besetzung anfassen. Genau das ist hier schon
|
||||||
|
-- einmal still verlorengegangen; eine Prüfung auf „kommt vor" reicht
|
||||||
|
-- nicht, weil die Funktion an anderer Stelle ohnehin damit arbeitet.
|
||||||
|
if v_lauf not like '%target_position_id%' then
|
||||||
|
raise exception 'Der Nachtlauf kennt die Zielplanstelle nicht — geplante Versetzungen bewegten niemanden.';
|
||||||
|
end if;
|
||||||
|
if (length(v_lauf) - length(replace(v_lauf, 'insert into position_assignments', ''))) / length('insert into position_assignments') < 2 then
|
||||||
|
raise exception 'Der Nachtlauf haengt die Besetzung nicht in beiden Zweigen um (Versetzung und Befoerderung).';
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
Reference in New Issue
Block a user