Befoerderung wahlweise auf eine neue Planstelle -- und der Nachtlauf, der sie ausfuehrt
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m9s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled

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:
2026-09-23 18:41:41 +02:00
parent 93cb1c1699
commit 37650ee9a4
5 changed files with 451 additions and 10 deletions

View File

@@ -215,6 +215,13 @@ export async function promoteEmployee(payload: {
effective_date: string;
new_title: string;
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> {
return callRpc("promote_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
}

View File

@@ -223,7 +223,12 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
employee={employee}
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} />
<DatenAendernPanel
open={panel === "daten"}

View File

@@ -387,11 +387,13 @@ export function DatenAendernPanel({
<AngehoerigeSection employeeId={employee.id} dependents={dependents} effectiveDate={effectiveDate} />
{/* Unterhalb der Angehörigen und ganz am Schluss — dieselbe Reihenfolge
wie im Reiter „Stammdaten". Vorher stand der Block mitten im
Abschnitt „Person", zwischen den Adressfeldern; beides sind
Personen im Umfeld, und die beiden gehören nebeneinander. */}
<fieldset className="rounded-md border border-border-subtle p-3">
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Notfallkontakt</legend>
wie im Reiter „Stammdaten". Aufgebaut wie „Person", „Vertrag" und
„Rolle & Anstellung" darüber: eine Überschrift, darunter die
Felder. Hier stand ein umrahmtes fieldset mit eigener
Beschriftung; das hob den Abschnitt gegenüber allen anderen
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">
<TextField label="Name" dense value={notfallName} onChange={setNotfallName} />
<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 }))}
/>
</div>
</fieldset>
</div>
</div>
</SlideOver>
);

View File

@@ -1,12 +1,13 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useMemo, useState } from "react";
import { promoteEmployee } from "@/actions/employees";
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 type { OpenPositionResolved } from "@/lib/positions";
import type { Database, PaygradeType } from "@/lib/types";
import { fmtName } from "@/lib/format";
@@ -21,25 +22,73 @@ const PAYGRADES: { value: PaygradeType; label: string }[] = [
{ 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 router = useRouter();
const [effectiveDate, setEffectiveDate] = useState("");
const [newTitle, setNewTitle] = useState(employee.job_title);
const [paygrade, setPaygrade] = useState<PaygradeType>(employee.paygrade);
const [stellenwahl, setStellenwahl] = useState<Stellenwahl>("selbe");
const [positionId, setPositionId] = useState("");
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() {
if (!effectiveDate || !newTitle) {
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
return;
}
if (stellenwahl === "neue" && !positionId) {
showToast("Bitte die neue Planstelle auswählen.", "error");
return;
}
setPending(true);
const result = await promoteEmployee({
employee_id: employee.id,
effective_date: effectiveDate,
new_title: newTitle,
new_paygrade: paygrade,
target_position_id: stellenwahl === "neue" ? positionId : undefined,
});
setPending(false);
if (result.success) {
@@ -72,9 +121,59 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
>
<div className="flex flex-col gap-4">
<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} />
<SelectField
label="Paygrade"
label="Verwendungsgruppe"
value={paygrade}
onChange={(v) => setPaygrade(v as PaygradeType)}
options={PAYGRADES}

View 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
$$;